坏蛋Dan
知乎@坏蛋Dan
发布时间:2024.3.21

前言

这回的bug有点幼稚,但是算是一个常见的。

bug算是引子,我们主要是学习v-model的源码

突然发现知乎支持了markdown实时转换了?!

bug

环境

  • bug环境vue2.6.14
  • 源码探讨环境vue3.2.41(因为要用到之前的配置,就不根据最新的代码了)

代码

原因

这个bug有点幼稚,不过归根到底其实是一种误区 :如果这里return了,那么selectedTabId就不会变。

这里的handleClick是回调!回调的意思是 完成之后才触发!

就这么简单。

处理方案

这个难度看具体业务,简单的就是调整下,不通过v-model的方式去处理,但如果业务错综复杂,selectedTabId不能轻易改动,那么这个时候可以考虑使用computed代理:

那么这个bug到此为止,我们进入主题,来学习下v-model的源码

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上,这样就不需要额外的代码,很是舒服

既然它是作为一个宏存在的,那么它在编译阶段就应该展开。

展开的自然就是我们熟悉的写法:

  • 一个名为:modelValueprop,接收父组件的值,是同步的
  • 一个emit事件update:modelValue,抛出新的值给父组件

另外既然是prop,那它自然可以设置默认值和是否必要:

不过默认值这个我们需要特别注意,来看个例子:

父组件没有提供任何的值,子组件设置了默认值1。这种情况下父组件和子组件就去同步了(de-synchronization

当然,如果我们存在多个v-model呢?这种时候自然需要区分才行:

相当于:

整体简洁了很多。

**注意:**如果没有指定参数即v-model:argsargs,那就是使用默认的,需要在defineProps里声明modelValue,因为没命名。

然后我们还可以设置修饰符(modifier),比如.trim.number.lazy等,然后我们甚至可以自定义修饰符!

比如:

capitalize是我们自定义的修饰符,在子组件中我们可以获取到:

有了这修饰符信息,我们可以做一些自定义的行为:

另外这是原来的写法:

方便了不是一点,当然,代价就是编译那边要塞一堆代码来处理这种场景。

ok,用法就了解到这里。接下来我们来探讨下源码。


简单的回顾下编译阶段源码

宏的展开我们就不看了,我们还是聚焦于v-model自身。

如果看过往期代码分析的vue3源码学习--编译阶段汇总 - 知乎 (zhihu.com),那你应该知道这块编译阶段是在哪里处理以及如何处理的。

实际上分成两部分,核心部分是在compiler-core处理的,然后compiler-dom做一些额外处理。

**补充一下compiler-domcompiler-core的关系:**实际上compiler-sfc调用的是compiler-dom,而compiler-dom是封装的compiler-core(调用compiler-core之前封装数据,补充一些方法再传给compiler-core),执行顺序不是compiler-dom先于compiler-core,而是compiler-coreparse之后transform的过程中调用compiler-dom封装的一些方法做额外处理比如处理v-model使用在input的场景等。

貌似有点乱,不过看一下compiler-dom的入口就知道了,篇幅问题,就不贴代码了,位置:packages\compiler-dom\src\index.ts

回到我们的分析中:

compiler-core-vModel.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的一种优化,将节点、属性等markNOT_CONSTANTCAN_SKIP_PATCHCAN_HOISTCAN_STRINGIFY这四种标记,在patch(指的是更新而非创建)判断是否需要更新。

其它没什么好说的了,compiler-core部分结束。

在开始compiler-core之前,我们看下几种写法对应的编译产物(中间阶段,仅指vue编译阶段)。

组件中使用(动态propsName + 带有修饰符):

忘了说了,这里type: 16NodeTypes.JS_PROPERTY,注意这里的Node不是我们传统的domNode,而是vue自己的定义的,一个指令也可以是一个Node

然后是原生的input绑定v-model(静态):

没啥好说的

compiler-dom-vmodel.ts

然后回到compiler-dom,同样是省略掉处理错误的代码:

  • const baseResult = baseTransform(dir, node, context):第一行便是调用compiler-corebaseTransform也就是上面说的那个方法。这里拿的是没有处理修饰符的。
  • 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.jsmodeproduction

你应该发现了render function不见了,只剩一个setup。其实render function是被整合进setup里了。

为啥要这么做呢(前面mark下的问题):

因为要实现热更新(HMR)!

如果只是改动template,那么script部分完全没必要重新编译。当然,之所以把render function单独拿出来应该还有其它考量,这里我就不多说了。这里只需要记住和热更新有关即可。

然后我们顺便看下vue编译的最终产物(不包含webpack其它流程):

然后运行这个测试用例,拿到vue编译的最终产物

那些以_开头的就是runtime helper

ok,编译阶段到此就结束了,接下来我们来看下runtime部分的源码。


runtime源码分析

这块调试会比较麻烦一些,毕竟我们编译阶段是通过测试用例来调试的,而编译的产物并不能直接用于runtime的测试用例调试。

不过在开始写测试用例之前,我们需要思考下我们要看的runtime代码是哪些,这会直接影响到我们分析流程:

  1. 在哪里执行render function,这一步非常重要,意味着组件实例的创建,也就是(局部)渲染的开头。
  2. 组件使用v-model是怎么把属性传递给子组件即数据更新时如何从props传递数据给子组件,又是怎么做到数据变化时子组件通知父组件的即如何emit给父组件。这里有两个地方需要关注,propemit。(实际上就是分析组件的props注入和emit提交)。
  3. input或者自定义元素使用v-model是如何实现数据变化监听的(这个其实不是很必要,相信大家都知道是通过绑定原生事件来实现,不过这里还是看一下,因为要配合下面一点)
  4. **最重要的一点:**是怎么做到setup数据变化时更新视图,视图中交互时更新数据的。(这一点是包含在上面第二第三部分里的,不单独分析)

目前就分为上面几点,然后我们来定义调试方案。

寻找调试方案

虽然包里自带的jest环境是支持client的,但是写一大串内容也是很累的。。

要么我们调整链路,把包的引用改成本地路径(就像我们前面compiler-sfc引用compiler-dom那么做),但是runtime涉及的包引用是很多的,和编译阶段不同(形象一点的说:编译阶段是一条链路层层套下去的,runtime则是扁平的,虽然渲染这块流程也是固定的,但是辅助函数等其它不是。。),改起来也挺累,并且可能遗漏某个流程就麻烦了。

貌似也可以直接通过h函数来实现,不过写起来也有些麻烦,另外就看不到转换的过程了。

当然,也可以直接采用暴力的方案:直接去之前看编译产物的项目中看!

简单地说就是跑到浏览器上去debugger,属于是回归传统,毕竟是runtime环境。

low,但是省事!

不过我们还是选择写测试用例,因为代码和调试都在同一个屏里,这样会直观一些。

那么我们就需要之前拿到的编译产物,然后将它稍微调整下(去除webpack相关的处理):

webpackmodule名去掉即可,然后引入vue相关的api

这样我们就可以调试了。

如果你有好的方案和简洁的配置,请务必评论区里说下,不胜感激!

render function在哪执行

从入口createApp这一块到patch中间一大段这里不分析,如果对这块感兴趣可以去看往期分析:vue runtime源码分析学习——patch汇总 - 知乎 (zhihu.com)

ok,我们来开始旅途。

先抛开我们以前的知识,我们根据常理来看,render function执行的时机应该得是在函数创建/更新的时候!

那么又根据我们以前分析得到的知识:

我们知道组件创建的地方是在patch阶段,那么具体在patch阶段的哪里呢?直接从patch函数往里走就能看到了:processComponent

然后我们进入到processComponent中,看到:

因为我们是创建,所以走的是mount,如果是更新,则执行updateComponent函数。

这里的createComponentInstance就是我们组件实例创建的入口。

代码不是很长,所以全放出来了。

  • appContext:组件创建时需要的上下文数据,如果此时存在父组件,自然是用父组件的上下文。
  • uid:组件的id,每个组件都有自己的id,作为scopedid,也是作为HMR需要的id
  • provides:你可能会想,这货是不是provide/inject?是的:

  • EMPTY_OBJ:后面一大串的这玩意儿都是初始化的数据,现在还不能填充。
  • EffectScopeReactiveEffect是它的一个字段,切勿搞混。是父组件收集子组件的,父组件更新也会触发子组件更新。
  • normalizePropsOptions:和normalizeEmitOptions这俩就不说了,就是整理下propsemit而已。
  • createDevRenderContext:这货是用来跟踪渲染上下文的,这里可以简单理解为和sourcemap相同效果的吧,用__DEV__区分开来的原因前面说过了,开发阶段会单独把render的拿出来。
  • instance.emit = emit.bind(null, instance):绑定emit的上下文,另外这个emit我们等会也要说一下,也是我们要分析的一部分。
  • ce:这玩意儿是自定义元素的,这里就不多说了。

然后我们回到mountComponent那里,我们还需要初始化setup函数:

  • initProps这里先不说,后面分析第二部分的时候我们再分析,比较复杂
  • isStateful:表示组件的状态,其它比如还有SUSPENSEKEPT_ALIVE等。

然后我们来看下setupStatefulComponent

  • markRaw:这个是标记这个对象不需要被监听,不参与响应式流程。
  • PublicInstanceProxyHandlers:这个简单说就是代理组件实例的一些属性,比如$attrs等的访问。代码量较大所以不贴了,感兴趣自行去看。
  • exposePropsOnRenderContext:这个还是单独拎出来说下,也是前面说到的dev下会单独拿出render function,那么就需要有个数据作为scripttemplate之间的桥梁,即instace.ctx。这个函数是将ctx[key]代理到instance.props[key]
  • createSetupContext:创建setup上下文,代理attrs(只读)、exposeemit以及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:代码就不看了,在这里面prodsetup的结果赋值给renderdev则是把setupResult代理到instance.setupState,然后再用ctx代理一次,前面是代理prop,这次是代理data(按vue2.x的说法,实际上就是setup返回出去的数据)。
  • finishComponentSetup:结束setup初始化的最后一步,也不看代码,简单说下里面做了什么,如果是on-the-fly(也就是runtime-compoiled),那么template的解析将在这里处理。然后最终的render赋值到instance.render

那么至此,setup就初始化完毕了。

开始有些乱了,这里简单的总结下setupComponent里面做了什么,方面后续分析:

  1. 调用initPropsinitSlots初始化组件实例的的propslotinitProps我们之后再分析,至于initSlot就不说了,不是我们此次的范围。
  2. 如果是dev,则需要在执行setup之前代理propctxdev中沟通templatescript的桥梁)上。
  3. 创建setup上下文,代理一些属性比如$attrs
  4. 执行setup函数,得到返回值setupResultprod此时拿到的setupResult就是render。如果是异步的,需要在then中才能释放currentInstance
  5. prodsetupResult赋值给instace.renderdev则是代理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:这个提一嘴,它们是一种特殊的用法,是通过父组件注册函数的方式来监听子组件的生命周期,vue3vue2有差异,具体看文章:VNode Lifecycle Events | Vue 3 Migration Guide (vuejs.org)
  • initialVNode:呃,这个也提一下吧,这个得在patch递归之前就创建完毕,调用createVNode创建vnode,这货代表newVNode。既然有new,自然就有oldold是之前就存在的,具体是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.mountapp.mount实际上是包裹一层的render(注意这个render不是组件自带的render,而是外部定义的一个普通函数名叫render而已,其内部实际上是调用patch)那么就进入了patch不断递归自己的流程,在patch过程中判断不同的type,然后分类去patch且渲染。组件的创建和渲染都是在patch阶段中实现。

  1. 因为是创建,所以调用的是patch阶段里的 processComponent,在里面判断是否是更新,如果是更新,则调用updateComponent,否则调用mountComponent
  2. mountComponent里我们调用createComponentInstance创建组件的实例
  3. 接着调用setupComponent函数执行setup函数,初始数据。
  4. setupComponent函数中我们调用了initProps初始化props,然后调用setupStatefulComponent函数,在里面执行setup函数并把结果抛给instance.renderprod),使用ctx代理props以及setupState,作为templatescript编译之后的数据桥梁(dev)。到这setup函数执行完毕,只差执行render function
  5. 调用setupRenderEffect函数,创建组件的响应式effectReactiveEffect),注册更新组件(渲染组件)的函数。然后执行第一次渲染,调用渲染函数componentUpdateFn,在里面调用renderComponentRoot函数。
  6. renderComponentRoot函数执行render function,创建vnode
  7. 拿到new VNode之后可以递归进入下一次patch了,然后一边diff,一边渲染,一边递归patch直到结束。

组件是怎么做到双向绑定的

实际上双向绑定涉及到两个点:propsemit,所以我们来分析这俩块。

到这里已经5w字了。。嘴太碎没办法。。所以可能得大幅减少流程。。

initProps

在看initProps之前,我们还需要了解下normalizePropsOptions做了什么,前面我们只是说它里面整理了下props,但是没说清楚。

normalizedPropsOptions整理的是组件自身的props字段,比如:

会被整理成:[normalized, needCastKeys]

然后initProps里的props并不是这个,而是来自于父组件的,回看下我们前面拿到的new VNodedynamicChildren第一个里的props,我们在initProps里拿到的vnode.props就是这个:

而这个数据的前身是:

它是在render function时执行的,这个normalizeProps是一个runtime helper

另外这里还要提一嘴:

当前的instance子组件,但是我们前面又说过父组件走到一半可能会遇到子组件,然后把xxx上下文交给子组件。

那这不就冲突了吗?

实际上我没说清楚,创建vnode顺序的,即父组件创建完了再创建子组件的,可以回看下前面renderComponentRoot的产物即new VNode,可以发现子组件还没创建组件实例。

但是渲染不是,先渲染父组件再渲染子组件这句话本身就是有悖的,即你渲染完了父组件,那么子组件必定也渲染完了,是的特性,后进先出,子组件必定先于父组件渲染完毕。

那么响应式更新也是如此,如果一个子组件进入任务调度队列先于父组件(在同一批次队列中排队等待更新),那么这个子组件很有可能会二次渲染。

ok,扯远了。

我们来看下initProps的代码:

  • propsOptions:这个就是前面提到的normalizedPropsOptions的产物
  • rawProps:传递给initPropsprops
  • defObject.defineProperty设置__vInternal1

我们来看下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,propsemit双向更新我们已经说完了,可能有些点我没点出来,那么就在这总结的地方一起说出来:

先说props

  1. 子组件创建实例的时候调用normalizePropsOptions时整理自己定义的prop
  2. 父组件在进入渲染阶段时会创建子组件实例,此时父组件准备给子组件的props都已处理好,子组件实例可以直接用。
  3. 调用initProps让子组件定义的props和父组件给的props配对,配上了就作为最终的props,(浅)包裹一层响应式。
  4. 父组件数据更新xxx的时候会刷新视图里的$setup.xxx,然后父组件进入更新渲染阶段,又触发子组件更新,此时props也跟着刷新,这就达成了父组件数据变化带动子组件prop数据变化。

然后是emit的:

  1. 父组件编译阶段会将v-model拆分成两个prop,一个作为数据传递给子组件,另一个则是函数update:xx=($event) => ($setup.xxx = $event)
  2. 组件创建实例的时候执行instance.emit = emit.bind(),这个instance.emit则是$setup.emitemit
  3. initProps时并不会将这个事件整理到props
  4. 触发click事件后执行$setup.emit,此时才从prop里拿这个函数(($event) => ($setup.xxx = $event), $setup是响应式的),然后配对执行。至此完成了子组件更新数据触发父组件数据更新。

那么组件的双向绑定至此结束。

原生元素如何双向更新

有了前面的分析了解,相信大家对这个问题应该都有了一个大概的答案。

所以如何更新input我们就不讨论了,因为和组件是一样的。

我们要分析的是原生元素是如何emit的,不对,比如说是在哪里绑定事件给emit的。

既然涉及到dom的事件监听,那么就应该在生成真实dom的时候,也就是patchdiff阶段。

和之前patchEvent不同,这次不是在这里处理的,虽然位置差不多。

我们直接在packages\runtime-dom\src\modules\events.ts文件中的addEventListener添加断点,因为我们确信原生元素是必然得通过事件监听来实现双向绑定的,所以我们直接在这打断点等它自己送上门来,然后我们就可以直接看调用栈

发现是在processElement里的invokeDirectiveHook里,我们过去看下:

我们来看下数据:

其实就是前面renderComponentRoot即执行render function的产物,在那里就完成了转换。

这里面包含了两步:

  1. mount即渲染时set值。
  2. change:元素created的时候监听change事件,然后更新数据。

其中change中重点的assign

那么原生元素的双向绑定至此也明白是怎么个回事了。里面具体逻辑以及如何生成的就不是本篇重点了,感兴趣可自行打断点去看。


总结

本次我们从编译和runtime两个角度分析了v-model

其中编译简单的回顾了下是如何处理的以及最终编译的产物。

runtime费了一定的时间去实现测试用例,就是为了舒服的在vscode中调试。

runtime中我们做了三步:

  1. 分析render function执行的位置,以及这个过程中做了什么
  2. 分析组件的propsemits,解释了如何组件是如何双向绑定的:简单地说,props因为渲染流程的原因,是顺序的,只要父组件刷新数据,视图也跟着刷新,子组件也跟着刷新,自然子组件的props也跟着刷新。而emit则是调用父组件从prop传入的($event) => ($setup.xx = $event)(其中$setup是响应式的)来实现更新父组件数据。
  3. 简单分析了原生元素是如何双向绑定的:创建dom的时候绑定change事件,监听数据变化,触发($event) => ($setup.xx = $event),它所在的组件数据变化的时候触发视图更新,它自然也重新刷新,在mountset最新值。

篇幅没想到会这么长。。。。

如果对你有所帮助,那么不甚荣幸!