这回的bug有点幼稚,但是算是一个常见的。
bug算是引子,我们主要是学习v-model的源码
突然发现知乎支持了markdown实时转换了?!
这个bug有点幼稚,不过归根到底其实是一种误区 :如果这里return了,那么selectedTabId就不会变。
这里的handleClick是回调!回调的意思是 完成之后才触发!
就这么简单。
这个难度看具体业务,简单的就是调整下,不通过v-model的方式去处理,但如果业务错综复杂,selectedTabId不能轻易改动,那么这个时候可以考虑使用computed代理:
那么这个bug到此为止,我们进入主题,来学习下v-model的源码
Component v-model | Vue.js (vuejs.org)
突然发现vue3.4
之后支持宏(macro
)的方式了,来学习下
嘿,方便了很多。
来看下相关的描述:通过defineModel()
返回的是一个ref
对象。既然是ref
,那么它自然可以被访问(accessed
)和修改(mutated
),唯一不同的是,它是在父组件的值和子组件的值之间双向绑定(two-way binding
)的:
.value
是和父组件v-model
绑定的值同步的(synced
)这意味着我们可以顺便把它绑定到一个input
上,这样就不需要额外的代码,很是舒服
既然它是作为一个宏存在的,那么它在编译阶段就应该展开。
展开的自然就是我们熟悉的写法:
modelValue
的 prop
,接收父组件的值,是同步的emit
事件update:modelValue
,抛出新的值给父组件另外既然是prop
,那它自然可以设置默认值和是否必要:
不过默认值这个我们需要特别注意,来看个例子:
父组件没有提供任何的值,子组件设置了默认值1
。这种情况下父组件和子组件就去同步了(de-synchronization
)
当然,如果我们存在多个v-model
呢?这种时候自然需要区分才行:
相当于:
整体简洁了很多。
**注意:**如果没有指定参数即v-model:args
的args
,那就是使用默认的,需要在defineProps
里声明modelValue
,因为没命名。
然后我们还可以设置修饰符(modifier
),比如.trim
、.number
、.lazy
等,然后我们甚至可以自定义修饰符!
比如:
capitalize
是我们自定义的修饰符,在子组件中我们可以获取到:
有了这修饰符信息,我们可以做一些自定义的行为:
另外这是原来的写法:
方便了不是一点,当然,代价就是编译那边要塞一堆代码来处理这种场景。
ok,用法就了解到这里。接下来我们来探讨下源码。
宏的展开我们就不看了,我们还是聚焦于v-model
自身。
如果看过往期代码分析的vue3源码学习--编译阶段汇总 - 知乎 (zhihu.com),那你应该知道这块编译阶段是在哪里处理以及如何处理的。
实际上分成两部分,核心部分是在compiler-core
处理的,然后compiler-dom
做一些额外处理。
**补充一下compiler-dom
和compiler-core
的关系:**实际上compiler-sfc
调用的是compiler-dom
,而compiler-dom
是封装的compiler-core
(调用compiler-core
之前封装数据,补充一些方法再传给compiler-core
),执行顺序不是compiler-dom
先于compiler-core
,而是compiler-core
在parse
之后transform
的过程中调用compiler-dom
封装的一些方法做额外处理比如处理v-model
使用在input
的场景等。
貌似有点乱,不过看一下compiler-dom
的入口就知道了,篇幅问题,就不贴代码了,位置:packages\compiler-dom\src\index.ts
回到我们的分析中:
第一部分位置:packages\compiler-core\src\transforms\vModel.ts
代码就不贴了,简单地说。。算了,复习一下:
代码100来行,也挺好理解。
不过还是结合测试用例会舒服很多。
我们的测试用例:
然后我们跑一下测试用例,不知道咋整的可以去看往期文章,这里就不多说了(至于为什么不直接使用自带的v-model
测试用例,因为太麻烦。。要模拟那个场景需要写一堆代码,所以稍微改动了源码的部分import
使得可以直接通过compiler-dom
得到预期场景)。
dir
:这个就是v-model:xxx="xxx"
,但是这时已经是parse
之后的了,处于transform
阶段,所以它是一个对象exp.loc.source
是原始的代码,就是我们写的v-model="checkbox"
的checkbox
。bindingMetadata
是来自compiler-sfc
处理完毕的script
里的数据,是的,编译阶段template
的处理需要 script
里的数据配合处理。(因为我们这里是直接从compiler-dom
进入的,所以并没有script
相关的内容,这部分在compiler-sfc
里处理完毕)arg
就是v-model:xx="xxx"
里的xx
,作为子组件需要的字段名,比如:<Add v-model:xx="xxx">
里的xx
,需要注意的是只能在组件中使用。createSimpleExpression('modelValue', true)
里的modelValue
自然就是默认给子组件要用的字段名。另外这货也是可以动态设置的,比如v-model:[xx]="xxx"
里的[xx]
就是一个动态的。eventName
:自然就是emit
的事件名。如果arg
是动态的,那么就不能编译阶段生成了,得到runtime
去创建组件的时候生成。注意这里的createCompoundExpression
是标记这个arg
是动态的,到了在generate
阶段包裹一层,注入上下文用来在创建组件实例的时候生成对应字段。const eventArg = context.isTS ? '($event: any)' : '$event'
:这段代码单独拉出来说一下,为什么转换成runtime
的代码还得带有类型?这样不会报错么?因为vue-loader
以及一系列vue
的编译只是webpack
(这里以webpack
为基础)编译阶段的一部分,vue
相关的编译结束后还会走babel-loader
之类的,所以保持类型检查是可以的。IS_REF
:这个也简单说一下,我们平时在template
中使用ref
方法创建的数据是不需要加上.value
的(部分情况),那是因为编译阶段会帮忙补充,这也算是一个语法糖
吧。modifiers
:修饰符,仅作用于组件使用v-model
的情况下。ConstantTypes.CAN_HOIST
:这个也单独拉出来说一下,这是vue
的一种优化,将节点、属性等mark
上NOT_CONSTANT
、CAN_SKIP_PATCH
、CAN_HOIST
、CAN_STRINGIFY
这四种标记,在patch
(指的是更新而非创建)判断是否需要更新。其它没什么好说的了,compiler-core
部分结束。
在开始compiler-core
之前,我们看下几种写法对应的编译产物(中间阶段,仅指vue编译阶段)。
组件中使用(动态propsName + 带有修饰符):
忘了说了,这里type: 16
是NodeTypes.JS_PROPERTY
,注意这里的Node
不是我们传统的dom
的Node
,而是vue
自己的定义的,一个指令
也可以是一个Node
。
然后是原生的input
绑定v-model
(静态):
没啥好说的
然后回到compiler-dom
,同样是省略掉处理错误的代码:
const baseResult = baseTransform(dir, node, context)
:第一行便是调用compiler-core
的baseTransform
也就是上面说的那个方法。这里拿的是没有处理修饰符的。tag
:额,这个本来不应该多说,但是因为编译阶段涉及到很多的type
,很容易搞混,所以这里强调一下,type
有的时候和标签tag
有关,但不是所有时候。tag
指的是元素的标签。isCustomElement
:是否是自定义元素
,vue
是支持自定义元素的:Global API: General | Vue.js (vuejs.org),注意是自定义元素
而不是组件
,这两者是有区别的,它和组件类似,但是返回的是原生的元素Using custom elements - Web APIs | MDN (mozilla.org)。(比如webComponent
)checkDuplicatedValue
:这个我把代码去掉了,逻辑就是判断这个input
是否在用了v-model
的情况下还绑定了value
属性,这就是多余的行为了,因为v-model
针对input
会自动绑定上value
属性。V_MODEL_RADIO
:这些是runtime helper
,也就是runtime
执行时的辅助函数,用来确定如何渲染。__DEV__
:你应该注意到了后面几个用来确定runtime
要怎么处理input + v-model
的定义,但是它们前面还加上了__DEV__
,如果是生产环境就不需要定义,这是为什么呢?这个问题我们先记下,等会看编译产物会说原因。所以简单的说 compiler-dom
里关于v-model
相关的逻辑 是对原生元素
以及自定义元素
做的额外处理。
ok
,那么编译阶段就结束了,接下来我们来看下编译阶段的产物(包含webpack
整个流程最终输出产物)。
因为涉及webpack
,所以我们需要搞一个项目,这里简单的贴一下配置:
以及webpack
的简单配置:
还有main.rs
:
以及我们vModelTest.vue
的代码:
Ok
,让我们来看下产物
这里贴出生产环境和开发环境的两种:
开发环境:
看着应该有点乱,其实很好理解
render(_ctx, _cache, $props, $setup, $data, $options)
:首先是render function
的参数,这些不必多说,你要runtime
渲染,那自然需要runtime
的数据支撑。也从这里,script
编译产物和template
编译产物终于完全
联系上了(之所以用了完全,是因为前面我们编译template
时也用到了script编译产物
的部分:bindMetaData
)$setup["Add"]
:自然就是我们的组件,这里补充一下compiler-sfc
相关的知识,我们的setup
语法糖之所以被叫做语法糖
,那就是因为编译阶段会帮我们转换回defineComponent({ props: {}, emit: {}, setup: () {} })
这种,所有我们的import
都会被放到setup
里面,这也就是为什么Add
组件是从setup
里暴露出来的。_cache[0]
:这货和我们要分析的没关系,不过还是提一嘴,这是一种runtime cache
,是一种优化,因为函数结构是固定的。其它也没啥好说的了。
然后我们来看下生产环境的编译产物,调整webpack.config.js
中mode
为production
:
你应该发现了render function
不见了,只剩一个setup
。其实render function
是被整合进setup
里了。
为啥要这么做呢(前面mark
下的问题):
因为要实现热更新(HMR
)!
如果只是改动template
,那么script
部分完全没必要重新编译。当然,之所以把render function
单独拿出来应该还有其它考量,这里我就不多说了。这里只需要记住和热更新有关即可。
然后我们顺便看下vue
编译的最终产物(不包含webpack
其它流程):
然后运行这个测试用例,拿到vue
编译的最终产物
那些以_
开头的就是runtime helper
。
ok,编译阶段到此就结束了,接下来我们来看下runtime
部分的源码。
这块调试会比较麻烦一些,毕竟我们编译阶段是通过测试用例来调试的,而编译的产物并不能直接用于runtime
的测试用例调试。
不过在开始写测试用例之前,我们需要思考下我们要看的runtime
代码是哪些,这会直接影响到我们分析流程:
render function
,这一步非常重要,意味着组件实例的创建,也就是(局部)渲染的开头。v-model
是怎么把属性传递给子组件即数据更新时如何从props
传递数据给子组件,又是怎么做到数据变化时子组件通知父组件的即如何emit
给父组件。这里有两个地方需要关注,prop
和emit
。(实际上就是分析组件的props
注入和emit
提交)。input
或者自定义元素使用v-model
是如何实现数据变化监听的(这个其实不是很必要,相信大家都知道是通过绑定原生事件来实现,不过这里还是看一下,因为要配合下面一点)setup
数据变化时更新视图,视图中交互时更新数据的。(这一点是包含在上面第二第三部分里的,不单独分析)目前就分为上面几点,然后我们来定义调试方案。
虽然包里自带的jest
环境是支持client
的,但是写一大串内容也是很累的。。
要么我们调整链路,把包的引用改成本地路径(就像我们前面compiler-sfc
引用compiler-dom
那么做),但是runtime
涉及的包引用是很多的,和编译阶段不同(形象一点的说:编译阶段是一条链路层层套下去的,runtime
则是扁平的,虽然渲染这块流程也是固定的,但是辅助函数等其它不是。。),改起来也挺累,并且可能遗漏某个流程就麻烦了。
貌似也可以直接通过h函数
来实现,不过写起来也有些麻烦,另外就看不到转换的过程了。
当然,也可以直接采用暴力的方案:直接去之前看编译产物的项目中看!
简单地说就是跑到浏览器上去debugger
,属于是回归传统,毕竟是runtime
环境。
low
,但是省事!
不过我们还是选择写测试用例,因为代码和调试都在同一个屏里,这样会直观一些。
那么我们就需要之前拿到的编译产物,然后将它稍微调整下(去除webpack
相关的处理):
把webpack
的module
名去掉即可,然后引入vue
相关的api
。
这样我们就可以调试了。
如果你有好的方案和简洁的配置,请务必评论区里说下,不胜感激!
从入口createApp
这一块到patch
中间一大段这里不分析,如果对这块感兴趣可以去看往期分析:vue runtime源码分析学习——patch汇总 - 知乎 (zhihu.com)。
ok,我们来开始旅途。
先抛开我们以前的知识,我们根据常理来看,render function
执行的时机应该得是在函数创建/更新的时候!
那么又根据我们以前分析得到的知识:
我们知道组件创建的地方是在patch
阶段,那么具体在patch
阶段的哪里呢?直接从patch
函数往里走就能看到了:processComponent
。
然后我们进入到processComponent
中,看到:
因为我们是创建,所以走的是mount
,如果是更新,则执行updateComponent
函数。
这里的createComponentInstance
就是我们组件实例创建的入口。
代码不是很长,所以全放出来了。
appContext
:组件创建时需要的上下文数据,如果此时存在父组件,自然是用父组件的上下文。uid
:组件的id
,每个组件都有自己的id
,作为scoped
的id
,也是作为HMR
需要的id
。provides
:你可能会想,这货是不是provide/inject
?是的:EMPTY_OBJ
:后面一大串的这玩意儿都是初始化的数据,现在还不能填充。EffectScope
:ReactiveEffect
是它的一个字段,切勿搞混。是父组件收集
子组件的,父组件更新也会触发子组件更新。normalizePropsOptions
:和normalizeEmitOptions
这俩就不说了,就是整理下props
和emit
而已。createDevRenderContext
:这货是用来跟踪渲染上下文的,这里可以简单理解为和sourcemap
相同效果的吧,用__DEV__
区分开来的原因前面说过了,开发阶段会单独把render
的拿出来。instance.emit = emit.bind(null, instance)
:绑定emit
的上下文,另外这个emit
我们等会也要说一下,也是我们要分析的一部分。ce
:这玩意儿是自定义元素
的,这里就不多说了。然后我们回到mountComponent
那里,我们还需要初始化setup
函数:
initProps
这里先不说,后面分析第二部分的时候我们再分析,比较复杂isStateful
:表示组件的状态,其它比如还有SUSPENSE
、KEPT_ALIVE
等。然后我们来看下setupStatefulComponent
:
markRaw
:这个是标记这个对象不需要被监听,不参与响应式流程。PublicInstanceProxyHandlers
:这个简单说就是代理组件实例的一些属性,比如$attrs
等的访问。代码量较大所以不贴了,感兴趣自行去看。 exposePropsOnRenderContext
:这个还是单独拎出来说下,也是前面说到的dev
下会单独拿出render function
,那么就需要有个数据作为script
和template
之间的桥梁,即instace.ctx
。这个函数是将ctx[key]
代理到instance.props[key]
。createSetupContext
:创建setup
上下文,代理attrs
(只读)、expose
、emit
以及slot
。setCurrentInstance
:全局唯一,指向当前正在创建的组件自身,类似的还有activeEffect
等,都是用来指向当前正在实例化或者创建的组件,毕竟是单线程,这一点可以放心。然后这里面还打开了领域
,也就是前面提过一嘴的EffectScope.on
,毕竟可能过一会全局唯一就不是指向这个组件,而是它的子组件,然后等所有子组件实例化完之后才会回到它这里,类似栈的特性。callWithErrorHandling
:就是执行setup
,顺便记录下错误信息。setupResult
:这个就是setup
函数执行的返回值,也就是我们在setup中return
出去的东西。unsetCurrentInstance
:释放当前的ActiveEffectScope
重新指向组件的parent
。isPromise(setupResult)
:这个要提一嘴,因为组件可以是异步的:Async Components | Vue.js (vuejs.org),既然是异步的,那自然要等到then
的时候才能重设activeAffectScope
上下文。handleSetupResult
:代码就不看了,在这里面prod
把setup
的结果赋值给render
,dev
则是把setupResult
代理到instance.setupState
,然后再用ctx
代理一次,前面是代理prop
,这次是代理data
(按vue2.x
的说法,实际上就是setup
返回出去的数据)。finishComponentSetup
:结束setup
初始化的最后一步,也不看代码,简单说下里面做了什么,如果是on-the-fly
(也就是runtime-compoiled
),那么template
的解析将在这里处理。然后最终的render
赋值到instance.render
。那么至此,setup
就初始化完毕了。
开始有些乱了,这里简单的总结下setupComponent
里面做了什么,方面后续分析:
initProps
和initSlots
初始化组件实例的的prop
和slot
,initProps
我们之后再分析,至于initSlot
就不说了,不是我们此次的范围。dev
,则需要在执行setup
之前代理prop
到ctx
(dev
中沟通template
和script
的桥梁)上。setup
上下文,代理一些属性比如$attrs
等setup函数
,得到返回值setupResult
,prod
此时拿到的setupResult
就是render
。如果是异步的,需要在then
中才能释放currentInstance
prod
将setupResult
赋值给instace.render
。dev
则是代理setupResult
并将值代理给ctx
(此时是data
,上次是props
)。如果是runtime-compiled
,则需要调用compiler-core
的代码处理template
。最终的render function
再赋值给instance.render
。那么我们回到mountComponent
中,现在我们setup
执行完了,组件实例也初始化完毕了,接下来就是准备渲染了。
代码稍微有些多,在省略了vue2.x
的部分生命周期 + 信息栈跟踪 + devTools等以及更新渲染部分之后,还剩下100多行。
我们先不看componentUpdateFn
里面做了什么
ReactiveEffect
:这个是依赖收集
和组件能响应式更新
的核心,这里不再说了,主要是篇幅问题,感兴趣可以去看往期文章。queueJob
:这个是任务调度队列
,简单地说组件更新会触发它,然后组件把自己放进任务调度队列中排队等待重新渲染
从而达成更新
。也是以前说过的。然后返回来看componentUpdateFn
:
几个生命周期我就不都说了,自行记住即可(是有可能作为面试题的,他们就喜欢这么玩)。
onVnodeBeforeMount
以及onVnodeMounted
:这个提一嘴,它们是一种特殊的用法,是通过父组件注册函数的方式来监听子组件的生命周期,vue3
和vue2
有差异,具体看文章:VNode Lifecycle Events | Vue 3 Migration Guide (vuejs.org)。initialVNode
:呃,这个也提一下吧,这个得在patch
递归之前就创建完毕,调用createVNode
创建vnode
,这货代表newVNode
。既然有new
,自然就有old
,old
是之前就存在的,具体是patch diff
的一部分,这里就不多说了。核心部分就是:
而我们第一部分render function
只需要renderComponentRoot
这一部分。
patch
这里先不说,因为涉及到我们的第三部分也就是原生元素和自定义元素的事件绑定这些,我们在第三部分分析。
那么我们来看下renderComponentRoot
:
省略了一大堆内容,我们这里只需要了解上面这四步:
const prev = setCurrentRenderingInstance(instance)
:前面说过,整个渲染流程都需要将组件相关的对象抛到全局做唯一,所以这里自然也得对齐。prev
是父组件渲染上下文,和前面也是对齐的setCurrentRenderingInstance(prev)
:有拿自然有还,将渲染上下文指回父组件。normalizedVNode
:这个就不多说了,因为在这个场景中并不影响。render!.call
:这就是我们的目标,render function
就在这里执行!我们来看下产物:
本来打算省略掉无关字段的,但是想了下觉得全部给出来让大家看下整个组件在执行完normalizeVNode(render.call())
之后是什么样的。
这里拿到的result
也就是render function
执行的结果,将作为new VNode
传递给patch
,然后diff
再然后创建真实dom
,最终渲染到浏览器上面。
ok,那么第一部分我们就分析到这里。
简单的总结下:
在总结之前,简单的说下patch
流程,我们的整个app
入口是createApp
,它创建一个appContext
,这个上下文初始化完毕后,我们调用的(createApp(AppCmp).
)mount(dom)
会执行app.mount
,app.mount
实际上是包裹一层的render
(注意这个render
不是组件自带的render
,而是外部定义的一个普通函数名叫render
而已,其内部实际上是调用patch
)那么就进入了patch
不断递归自己的流程,在patch
过程中判断不同的type
,然后分类去patch
且渲染。组件的创建和渲染都是在patch
阶段中实现。
patch
阶段里的 processComponent
,在里面判断是否是更新,如果是更新,则调用updateComponent
,否则调用mountComponent
。mountComponent
里我们调用createComponentInstance
创建组件的实例setupComponent
函数执行setup
函数,初始数据。setupComponent
函数中我们调用了initProps
初始化props
,然后调用setupStatefulComponent
函数,在里面执行setup
函数并把结果抛给instance.render
(prod
),使用ctx
代理props
以及setupState
,作为template
和script
编译之后的数据桥梁(dev
)。到这setup
函数执行完毕,只差执行render function
。setupRenderEffect
函数,创建组件的响应式effect
(ReactiveEffect
),注册更新组件(渲染组件)的函数。然后执行第一次渲染,调用渲染函数componentUpdateFn
,在里面调用renderComponentRoot
函数。renderComponentRoot
函数执行render function
,创建vnode
。new VNode
之后可以递归进入下一次patch
了,然后一边diff
,一边渲染,一边递归patch
直到结束。实际上双向绑定涉及到两个点:props
和emit
,所以我们来分析这俩块。
到这里已经5w
字了。。嘴太碎没办法。。所以可能得大幅减少流程。。
在看initProps
之前,我们还需要了解下normalizePropsOptions
做了什么,前面我们只是说它里面整理了下props
,但是没说清楚。
normalizedPropsOptions
整理的是组件自身的props
字段,比如:
会被整理成:[normalized, needCastKeys]
然后initProps
里的props
并不是这个,而是来自于父组件
的,回看下我们前面拿到的new VNode
里dynamicChildren
第一个里的props
,我们在initProps
里拿到的vnode.props
就是这个:
而这个数据的前身是:
它是在render function
时执行的,这个normalizeProps
是一个runtime helper
。
另外这里还要提一嘴:
当前的instance
是子组件
,但是我们前面又说过父组件走到一半可能会遇到子组件,然后把xxx上下文交给子组件。
那这不就冲突了吗?
实际上我没说清楚,创建vnode
是顺序
的,即父组件创建完了再创建子组件的,可以回看下前面renderComponentRoot
的产物即new VNode
,可以发现子组件还没创建组件实例。
但是渲染不是,先渲染父组件再渲染子组件这句话本身就是有悖的,即你渲染完了父组件,那么子组件必定也渲染完了,是栈
的特性,后进先出,子组件必定先于父组件渲染完毕。
那么响应式更新也是如此,如果一个子组件进入任务调度队列
先于父组件(在同一批次队列中排队等待更新),那么这个子组件很有可能会二次渲染。
ok,扯远了。
我们来看下initProps
的代码:
propsOptions
:这个就是前面提到的normalizedPropsOptions
的产物rawProps
:传递给initProps
的props
def
:Object.defineProperty
设置__vInternal
为1
。我们来看下setFullProps
里做了什么:
isReservedProp
:一些内置的不能作为props
传给子组件。这里面的几个监听子组件生命周期的用法前面又说到过,不多说。camelize
:驼峰化,dddd。isEmitListener
:判断是否是父组件的update:xx=() => {}
。其它的就不说了,我们只是简单的了解下props
的流程,这个函数就是在配对
,把子组件的prop
和父组件的prop
配对,配上了就放到props
里,来看下结果:
我们的自定义修饰符被放到了attrs
里,实际上一些没用到的props
也会被放到这里面。
貌似有东西不见了?先别急
回到initProps
里
shallowReactive
:响应式props
,不过是浅
的,只响应式最外层,内部的对象不做响应式处理。最后赋值给instance.props
,那么子组件的props
就搞定了。然后把其它放到attrs
里。
那么initProps
简单地说就是在配对
,把子组件定义的prop
和父组件给的prop
进行配对,配上了就保留到instance.props
里,否则放到attrs
里。
那么回过头,我们是不是少了什么东西?我们更新数据的update:xx=() => {}
怎么不见了!
因为props
不需要这个,所以被去掉了。
那么哪里需要呢?自然是emit
。
我们需要在emit
的时候去执行来自prop
里的update:xx=() => {}
的() => {}
。
但是emit
要从哪里开始分析呢?
还记得createComponentInstance
里的instance.emit = emit.bind(null, instance)
么?
(注意这里bind
第二个参数会作为函数调用的第一个参数,具体可见:Function.prototype.bind() - JavaScript | MDN (mozilla.org))
然后再回看下前面是怎么调用emit
的:
没错,这里的setup.emit
就是上面instance.emit
。
所以我们来看下emit
函数做了什么
不过这个调试需要触发click
事件才行,那么我们模拟下:
然后就可以了,我们来看下emit:
handler
:这个就是我们的update:xx=()=>{}
。Once
:这个是事件修饰符.once
在编译阶段变化成的,比如@click.once
会变成onClickOnce
。可见:Event Handling | Vue.js (vuejs.org)这个函数没啥好说的,就是runtime
调用这个emit
的时候去判断是否是update:xx
,然后和prop
配对下,如果对上了就执行,这就达成了对父组件的数据更新。
噢,貌似一直没说,这里执行的函数是($event) => ($setup.xxx = $event)
。$setup
此时已经响应式了,所以父组件的数据是同步更新。
ok,props
和emit
双向更新我们已经说完了,可能有些点我没点出来,那么就在这总结的地方一起说出来:
先说props
的
normalizePropsOptions
时整理自己定义的prop
props
都已处理好,子组件实例可以直接用。initProps
让子组件定义的props
和父组件给的props
配对,配上了就作为最终的props
,(浅)包裹一层响应式。xxx
的时候会刷新视图里的$setup.xxx
,然后父组件进入更新渲染阶段,又触发子组件更新,此时props
也跟着刷新,这就达成了父组件数据变化带动子组件prop
数据变化。然后是emit
的:
v-model
拆分成两个prop
,一个作为数据传递给子组件,另一个则是函数update:xx=($event) => ($setup.xxx = $event)
。instance.emit = emit.bind()
,这个instance.emit
则是$setup.emit
的emit
。initProps
时并不会将这个事件整理到props
click
事件后执行$setup.emit
,此时才从prop
里拿这个函数(($event) => ($setup.xxx = $event)
, $setup
是响应式的),然后配对执行。至此完成了子组件更新数据触发父组件数据更新。那么组件的双向绑定至此结束。
有了前面的分析了解,相信大家对这个问题应该都有了一个大概的答案。
所以如何更新input
我们就不讨论了,因为和组件是一样的。
我们要分析的是原生元素是如何emit
的,不对,比如说是在哪里绑定事件给emit
的。
既然涉及到dom
的事件监听,那么就应该在生成真实dom
的时候,也就是patch
的diff
阶段。
和之前patchEvent
不同,这次不是在这里处理的,虽然位置差不多。
我们直接在packages\runtime-dom\src\modules\events.ts
文件中的addEventListener
添加断点,因为我们确信原生元素是必然得通过事件监听来实现双向绑定的,所以我们直接在这打断点等它自己送上门来,然后我们就可以直接看调用栈
发现是在processElement
里的invokeDirectiveHook
里,我们过去看下:
我们来看下数据:
其实就是前面renderComponentRoot
即执行render function
的产物,在那里就完成了转换。
这里面包含了两步:
mount
即渲染时set
值。change
:元素created
的时候监听change
事件,然后更新数据。其中change
中重点的assign
:
那么原生元素的双向绑定至此也明白是怎么个回事了。里面具体逻辑以及如何生成的就不是本篇重点了,感兴趣可自行打断点去看。
本次我们从编译和runtime
两个角度分析了v-model
。
其中编译简单的回顾了下是如何处理的以及最终编译的产物。
而runtime
费了一定的时间去实现测试用例,就是为了舒服的在vscode
中调试。
runtime
中我们做了三步:
render function
执行的位置,以及这个过程中做了什么props
和emits
,解释了如何组件是如何双向绑定的:简单地说,props
因为渲染流程的原因,是顺序
的,只要父组件刷新数据,视图也跟着刷新,子组件也跟着刷新,自然子组件的props
也跟着刷新。而emit
则是调用父组件从prop
传入的($event) => ($setup.xx = $event)
(其中$setup是响应式的)来实现更新父组件数据。dom
的时候绑定change
事件,监听数据变化,触发($event) => ($setup.xx = $event)
,它所在的组件数据变化的时候触发视图更新,它自然也重新刷新,在mount
中set
最新值。篇幅没想到会这么长。。。。
如果对你有所帮助,那么不甚荣幸!