vue3 源码学习

69 阅读24分钟

源码调试

 pnpm install
 ​
 // 配置package.json
 "dev": "node scripts/dev.js --sourcemap"
 ​
 // 打包
 pnpm dev
 ​
 // 起服务
 pnpm serve

学习方法

  • 调试法 - 单步调试(F11)
  • 查看调用栈信息
  • 读源码,快速走通主线,不细究分支

整体架构

image-20230321170809383

  • compiler core - 所谓编译,就是把模板字符串转化为渲染函数。compiler-core 包含了与平台无关的编译器核心代码实现,包括编译器的基础编译流程:解析模板生成AST - AST 的节点转换 - 根据AST 生成代码。
  • compiler dom - 它是在compiler-core 的基础上进行的封装。compiler-dom 包含了专门针对浏览器的转换插件。
  • compiler ssr - 它也是在compiler-core 的基础上进行的封装,也依赖了compiler dom 提供的一部分辅助转换函数。compiler ssr 包含了专门针对服务端渲染的转换插件。
  • compiler sfc - .vue 文件类型是不能直接被浏览器解析的,需要编译。为了处理.vue 文件,会借助如webpack 的vue-loader 这样的处理器。它会先解析.vue 文件,把template、script、style 部分抽离出来,然后各个模块 运行各自的解析器,单独解析。.vue 文件的解析,以及template、script、style 的解析的相关代码都是有compiler sfc 模块实现的。
  • runtime core - 包含了与平台无关的运行时核心实现,包括虚拟DOM 的渲染器、组件实现和一些全局的JS API。
  • runtime dom - 基于runtime core 创建的以浏览器为目标的运行时,包括对原声DOM API、属性、样式、事件等的管理。
  • runtime test - 用于测试runtime core 的轻量级运行时。
  • reactivity - 包含了响应式系统的实现,它是runtime core包的依赖,也可以作为与框架无关的包独立使用。
  • template explorer - 用于调试模板编译输出的工具。先在源码根目录下运行yarn dev-compiler(运行前需要执行yarn 的安装依赖),再去根目录下运行yarn open,就可以打开模板编译暑促工具,调试编译结果了。官方提供了在线版本Vue Template Explorer,可以用来调试模板的编译输出。
  • sfc playground - 用于调试SFC 编译输出的工具。官方提供了在线版本Vue SFC Playground。
  • shared - 包含了多个包共享的内部使用工具库。
  • size check - 用于检测tree-shaking 后Vue.js 运行时的代码体积。
  • server renderer - 包含了服务端渲染的核心实现,是用户在使用Vue.js 实现服务端渲染时所需要依赖的包。
  • vue - vue 是面向用户的完整构建,包括运行时版本和带编译器的版本。
  • vue compat - 它是vue3 的一个构建版本,提供可配置的vue.js 2.x 兼容行为。

【不同构建版本Vue.js 使用】

CDN 直接使用的:global 版本和esm-browser 版本。

配合打包工具:esm-bundler 版本。

服务端渲染:cjs 版本。

入口文件

 // core/rollup.config.js
 let entryFile = /runtime$/.test(format) ? `src/runtime.ts` : `src/index.ts`

core/packages/vue/src/index.ts - compileToFunction - 封装一个编译函数,用于web 平台

初始化流程

 import { createApp } from 'vue'
 import App from './app'
 const app = createApp(App)
 app.mount('#app')

Vue.js 3.0 初始化应用的方式和 Vue.js 2.x 差别并不大,本质上都是把 App 组件挂载到 id 为 app 的 DOM 节点上。

应用程序实例创建

createApp - 入口函数:创建app 对象和重写app.mount 方法 –>

=> const app = ensureRenderer().createApp(...args) - 创建app 对象(实例) –>

createRenderer –> baseCreateRenderer - 返回 return { render, hydrate, createApp: createAppAPI(render, hydrate) }

createAppAPI() –> createApp()

【实例长啥样】createApp 中有各种方法use() mixin() component() directive() mount() unmount() provide()

在 Vue.js 3.0 内部通过 createRenderer 创建一个渲染器,这个渲染器内部会有一个 createApp 方法,它是执行 createAppAPI 方法返回的函数,接受了 rootComponent 和 rootProps 两个参数,在应用层面执行 createApp(App) 方法时,会把 App 组件对象作为根组件传递给 rootComponent。这样,createApp 内部就创建了一个 app 对象,它会提供 mount 方法,这个方法是用来挂载组件的。

在整个 app 对象创建过程中,Vue.js 利用闭包和函数柯里化的技巧,很好地实现了参数保留。比如,在执行 app.mount 的时候,并不需要传入渲染器 render,这是因为在执行 createAppAPI 的时候渲染器 render 参数已经被保留下来了。

=> app.mount - 重写

createApp 函数内部的 app.mount 方法是一个标准的可跨平台的组件渲染流程。需要在外部重写这个方法,来完善 Web 平台下的渲染逻辑。

–> const container = normalizeContainer(containerOrSelector)

首先是通过 normalizeContainer 标准化容器(这里可以传字符串选择器或者 DOM 对象,但如果是字符串选择器,就需要把它转成 DOM 对象,作为最终挂载的容器),然后做一个 if 判断,如果组件对象没有定义 render 函数和 template 模板,则取容器的 innerHTML 作为组件模板内容;接着在挂载前清空容器内容,最终再调用 app.mount 的方法走标准的组件渲染流程。

在这里,重写的逻辑都是和 Web 平台相关的,所以要放在外部实现。此外,这么做的目的是既能让用户在使用 API 时可以更加灵活,也兼容了 Vue.js 2.x 的写法,比如 app.mount 的第一个参数就同时支持选择器字符串和 DOM 对象两种类型。

挂载

从 app.mount 开始,才算真正进入组件渲染流程,核心渲染流程做的两件事情:创建 vnode 和渲染 vnode。

app.mount –> mount() - 传入组件数据和状态转换为dom,并追加到宿主元素

–> createVNode() - 创建根节点vnode

–> render(vnode, container, …) - 执行render 函数,它和组件的render 函数(为了生成组件的虚拟dom)不是一个概念,它做的事是生成vnode 传递给patch 函数转换为真实dom –> patch(container._vnode || null, vnode, container, …)

Patch 函数有两个功能:

  • 根据vnode 挂载DOM
  • 根据新旧anode 更新DOM

对于初次渲染,这里只关注创建过程。

@Declare: patch(n1, n2, container, …) - 【首次patch 过程

1、由于传入第一个参数是null,所以首次patch 实际上是一次挂载过程,不是更新过程

2、第二个参数n2 表示新的vnode 节点,后续会根据这个vnode 类型执行不同的处理逻辑

3、第三个参数container 表示DOM 容器,也就是vonden 渲染生成DOM 后,会挂载到container 下面

patch 的作用:把传入的vnode 变成真实dom,然后挂载给render 函数的参数2 container(即mount(‘#app’) 中的宿主元素‘#app’)

这里会根据不同的类型(Text、Comment、Static、Fragment、default)走不同的分支逻辑;default 中又分ELEMENTCOMPONENTTELEPORTSUSPENSE

Fragment】- 抽象父节点

–> processFragment() - 这里根据PatchFlags.STABLE_FRAGMENT 分两种情况:

1、–> patchBlockChildren(n1.dynamicChildren, dynamicChildren, container, …) –> patch(oldVNode, newVNode, container, …)

2、-> patchChildren(n1, n2, container, …) –> patchKeyedChildren() / patchUnkeyedChildren()

COMPONENT

–> processComponent() - 一切递归的开始,也是根组件初始化的开始 –>

<1、初始化 - 挂载>

=> mountComponent() –>

1、const instance: ComponentInternalInstance = ... createComponentInstance(initialVNode, parentComponent, …) 这个instance 中的ctx 才是组件实例上下文 - this

  • propsOptions: normalizePropsOptions(type, appContext) - 先对props 配置对象做一层标准化:数组形式的props 标准化程对象形式

2、=> setupComponent(instance) - 组件的初始化:

  • –> initProps(instance, props, …) - 初始化props

    • validateProps - 校验props 值的合法性
    • shallowReactive(props) - reactive() 的浅层作用形式。一个浅层响应式对象里只有根级别的属性是响应式的。
  • -> initSlots(instance, …)

  • –> setupStatefulComponent() –> handleSetupResult() / finishComponentSetup()

  • handleSetupResult 函数最后又会调用finishComponentSetup()

  • if (!isSSR && compile && !Component.render) { … Component.render = compile(template, finalCompilerOptions) … }

  • support for 2.x options:applyOptions(instance)

3、=> setupRenderEffect(instance, …) - 安装组件渲染函数的副作用函数(渲染函数内跟很多响应式数据间是有依赖的,当数据发生变化时,希望能够重新渲染一次,作为副作用此时是需要执行一次的)

  • 创建组件更新函数 - componentUpdateFn

    • 以下为挂载组件的逻辑
    • 生成子树vnode - const subTree 当前组件内部整个DOM 节点对应的vnode 执行 renderComponentRoot 渲染生成对应的 subTree
    • 调用patch 函数吧子树vnode 挂载到container 中 - patch(null, subTree, container, …) - 根据不同的类型(递归过程)
    • 更新组件的逻辑放在更新过程中讨论
  • 为组件渲染创建一个响应式的副作用函数 - const effect = (instance.effect = new ReactiveEffect(componentUpdateFn, () => queueJob(update), …))

  • 创建更新函数并立刻执行一次:

    • const update: SchedulerJob = (instance.update = () => effect.run())
    • update() 即componentUpdateFn 函数

<2、更新> - 更新流程中讨论

组件的重新渲染会触发patch 过程。然后遍历子节点,递归patch,在遇到组件节点时,会执行updateComponent()

ELEMENT】如 section

–> processElement() –>

如果是第一次会执行mountElement() –>

  • 真正地创建dom 元素节点 - hostCreateElement()
  • 处理 props,比如 class、style、event 等属性
  • 如果有孩子,先深度优先去创建孩子 –> mountChildren() –> 循环遍历去patch(null, subTree, container, …)
  • 把创建的 DOM 元素节点挂载到 container 上 - hostInsert(el, container, …)

否则的话走更新元素节点逻辑 –> patchElement()

初始化流程的变化及原因

 // 1、函数创建实例 - TS 强类型支持更好
 Vue2:new Vue({})
 Vue3: createApp({})
 ​
 // 2、实例方法 - 避免全局污染
 const app = Vue.createApp({})
 app.component(‘comp’, {
     template: ‘div’
 })
 ​
 // 3、挂载mout - API 简化,一致性增强
 new Vue({}).$mount()
 app.mount('#app')

思考题 - 组件的拆分力度是越细越好?

在日常开发中,通常会根据页面的布局或功能,将整个页面拆分成不同的模块组件,在实现这些模块组件时,如果发现有相同或相似的功能点,将其抽离出来写成公共组件,然后在各个模块中引用。

无论是模块组件还是公共组件,拆分组件的出发点都和组件的大小粒度无关。可维护性和复用性才是拆分组件的出发点。

对于组件的渲染,会先通过renderComponentRoot 去生成组件的子树vnode,再递归patch 去处理这个子树vnode。也就是说,对于同样一个div,如果将其封装成组件的话,会比直接渲染一个div要多执行一次生成组件的子树vnode的过程。并且还要设置并运行带副作用的渲染函数。也就是说渲染组件比直接渲染元素要耗费更多的性能。如果组件过多,这些对应的过程就越多。如果按照组件粒度大小去划分组件的话会多出很多没有意义的渲染子树和设置并运行副作用函数的过程。

更新流程

更新机制的建立

mountComponent() –> setupRenderEffect(instance, …) - 异步任务队列的创建

1、–> const componentUpdateFn - 关注更新组件部分的逻辑

  • 更新组件 vnode 节点信息 - updateComponentPreRender(instance, next, optimized)

    • 包括更改组件实例的vnode 指针、更新props 和更新插槽等一系列操作,因为组件在稍后执行 renderComponentRoot 时会重新渲染新的子树 vnode ,它依赖了更新后的组件 vnode 中的 props 和 slots 等数据。
  • 渲染新的子树 vnode

  • 根据新旧子树 vnode 执行 patch 逻辑 - patch(prevTree, nextTree, hostParentNode(…), …)

根据patch 函数参数2 的节点类型做不同的处理:

COMPONENT

–> processComponent() - 关注的是更新部分的逻辑:本质上就是去判断子组件是否需要更新,如果需要则递归执行子组件的副作用渲染函数来更新,否则仅仅更新一些 vnode 的属性,并让子组件实例保留对组件 vnode 的引用,用于子组件自身数据变化引起组件重新渲染的时候,在渲染函数内部可以拿到新的组件 vnode。

=> updateComponent() –>

shouldUpdateComponent(n1, n2, optimized)

  • 根据新旧子组件vnode 判断是否需要更新子组件,该函数内部会对props、children 等属性进行对比。如果值发生改变,shouldUpdateComponent 函数会返回true,这样就会把新的子组件vnode 赋值给instance.next
  • 然后执行子组件的副作用渲染函数instance.update() 触发子组件的重新渲染

ELEMENT

–> processElement() –>

更新元素节点逻辑 –> patchElement() - 更新 props 和更新子节点

  • 如果有dynamicChildren,走**patchBlockChildren()** –> patch(oldVNode, newVNode, …)

  • 否则patchChildren() - 更新子节点:

    • patchKeyedChildren() / patchUnkeyedChildren()

【patchChildren】

  • 首先判断两个节点是否为相同同类节点,不同则删除重新创建

  • 如果双方都是文本则更新文本内容

  • 如果双方都是元素节点则递归更新子元素,同时更新元素属性

  • 更新子节点时又分为:

    • 新的子节点是文本,老的子节点是数组,则清空,并设置文本
    • 新的子节点是文本,老的子节点是文本,则直接更新文本
    • 新的子节点是数组,老的子节点是文本,则清空文本,并创建新子节点数组中的子元素
    • 新的子节点是vnode 数组,老的子节点也是vnode 数组,完整diff 子节点(核心diff 算法)

核心 diff 算法,就是在已知旧子节点的 DOM 结构、vnode 和新子节点的 vnode 情况下,以较低的成本完成子节点的更新为目的,求解生成新子节点 DOM 的系列操作。

【patchKeyedChildren】

类似于vue2 中的updateChildren(elm, oldCh, ch, …)

1、sync from start - 同步头部节点

2、sync from end - 同步尾部节点

3、common sequence + mount - 新增节点

4、common sequence + unmount - 删除节点

5、unknown sequence - 未知子序列

  • 空间换时间 - 建立索引图

    • 对于新旧子序列中的节点,认为 key 相同的就是同一个节点,直接执行 patch 更新即可。
    • 建立一个 keyToNewIndexMap 的 Map<key, index> 结构,遍历新子序列,把节点的 key 和 index 添加到这个 Map 中。keyToNewIndexMap 存储的就是新子序列中每个节点在新子序列中的索引。
    • 接下来,遍历旧子序列,有相同的节点就通过 patch 更新,并且移除那些不在新子序列中的节点,同时找出是否有需要移动的节点
    • 建立一个 newIndexToOldIndexMap 的数组,来存储新子序列节点的索引和旧子序列节点的索引之间的映射关系,用于确定最长递增子序列,这个数组的长度为新子序列的长度,每个元素的初始值设为 0, 它是一个特殊的值,如果遍历完了仍有元素的值为 0,则说明遍历旧子序列的过程中没有处理过这个节点,这个节点是新添加的。
    • 正序遍历旧子序列,根据前面建立的 keyToNewIndexMap 查找旧子序列中的节点在新子序列中的索引,如果找不到就说明新子序列中没有该节点,就删除它;如果找得到则将它在旧子序列中的索引更新到 newIndexToOldIndexMap 中。
  • 最长递增子序列 - 做最少的移动操作 –> getSequence((newIndexToOldIndexMap))

    • 贪心 + 二分查找 - 对应时间复杂度O(n) 和O(log2n),所以总时间负责度是O(nlog2n)
    • 动态规划(时间复杂度更高,vue 3中未采纳该解法)

image-20230319104633711

更新过程

anonymous()(假设变量更新是被赋新值,那么在调用栈里通常是个匿名函数)–> set() –> set() –> trigger(target, TriggerOpTypes.SET, key, value, oldValue) –> triggerEffects(deps[0]) –> triggerEffect() –> effect.scheduler() - (anonymous) 这里会回到() => queueJob(update) –> queueFlush() 这里会通过异步的方式(微任务,Promise.then(async))- 这样的异步设计可以保证在一个Tick 内,即使多次执行queueJob 或者queueCb 去添加任务,也只是在宏任务执行完毕后的微任务阶段执行一次flushJobs –>

—— async ——

–> flushJobs() - 在开始执行的时候会把isFlushPending 重置为false,把isFlushing 设置为true,表示正在执行异步任务队列

  • 在执行异步任务队列queue 前,会先执行flushPreFlushCbs 来处理所有的预处理任务队列
  • 遍历执行异步任务队列queue:先做一次从小到大的排序 - 创建组件的顺序是由父到子,所以创建组件副作用渲染函数的顺序也是先父后子,父组件的副作用渲染函数的effect id 是小于子组件的。
  • 执行flushPostFlushCbs - 执行一些后处理任务

–> effect.run() –> effect.fn() - componentUpdateFn() –> patch()

componentUpdateFn 这里走更新流程分支(updateComponent 的逻辑),即else 分支,然后调用**patch(prevTree, nextTree, …)**

Composition API

  • 可维护性 - 以前需要反复横跳
  • 代码复用
  • 消灭this

从语法上看,它提供了一个 setup 启动函数作为逻辑组织的入口,暴露了响应式 API 为用户所用,也提供了生命周期函数以及依赖注入的接口,可以不依托于 Options API 也可以完成一个组件的开发,并且更有利于代码逻辑的组织和复用。

setup

 setup(props, ctx)
 setup(props, { emit, slots, attr })
 // ctx 一般会用解构的方式 { emit, slots, attr } 来书写,可从源码中获知:https://github1s.com/vuejs/core/blob/main/packages/runtime-core/src/component.ts#L702

生命周期钩子

processComponent() –> mountComponent() –>

  • const instance - 创建一个组件实例,准确的说是根组件实例
  • –> setupComponent(instance) - 组件的初始化,等效于vue2源码中 _init()

setupComponent(instance) –> setupStatefulComponent() –>

  • 创建渲染上下文代理
  • 判断处理setup 函数
  • 完成组件实例设置

1、new Proxy(instance.ctx, PublicInstanceProxyHandlers) - 创建公共实例/渲染函数代理

2、如果用户设置了setup 函数

  • –> createSetupContext() - 创建setup 函数的上下文对象
  • –> setCurrentInstance(instance) - 设置当前组件实例对象,配合getCurrentInstance 使用
  • –> const setupResult = callWithErrorHandling(setup, instance, …) - 调用setup 函数

3、–> handleSetupResult() 【setupResult】 如果setup 函数返回的结果不是一个Promise,则执行结果处理函数

  • 首先判断返回的结果是不是函数,如果是函数,则作为render 函数处理

  • 如果是对象如 return { counter },则转换setupResult 这个对象为响应式对象,将来组件渲染函数中会首先从setupState 里面去获取值 - instance.setupState = proxyRefs(setupResult)

  • 最后依然要执行组件,安装完成组件实例的设置,里面主要是支持vue2 的options api :–> finishComponentSetup(instance, isSSR) –>

Setup 执行时刻早于beforeCreate 和created 之类的传统生命周期钩子。实际上在setup 函数执行的时候,组件实例已经创建了,所以在setup 中去处理beforeCreate 和created 是没有意义的。

如果与data 这些数据发生冲突,两者共存,但setup 的优先级更高。vue3 处理行为:对组件实例上下文instance.ctx 做代理,在PublicInstanceProxyHandlers 的get 中会做逻辑判断处理,优先从setupState 中获取,其次是data,然后是props,最后是ctx。

  • setupState 就是setup 函数返回的数据。
  • ctx 包括了计算属性、组件方法和用户自定义的一些数据。
实现

createHook() –> injectHook(lifecycle, …)

思考题 - 使用 callWithErrorHandling 把 setup 包装一层的好处

1、setup 函数是用户自己写的,很容易出现错误。因此要对 setup 函数的错误进行捕获,以此达到不阻塞后面vue 逻辑的执行(如:finishComponentSetup)。

2、需要对 setup 不同的入参情况分隔出不同的逻辑,这一部分封装在 callWithErrorHandling 中,符合开放封闭原则,之后如果有扩展内容,可以直接补充在 callWithErrorHandling,而不是写在setupStatefulComponent中,逻辑多了会导致臃肿。

3、setupStatefulComponent 函数负责处理的是 创建渲染上下文代理、判断处理 setup 函数和完成组件实例设置这种比较宏观的职责,对于 setup,setupStatefulComponent 只关心执行结果即可,具体处理setup结果的职责应该交给其他函数——callWithErrorHandling。

4、封装的逻辑,保留了复用潜力,防止一次改到处改。

getCurrentInstance

Provide/Inject

provide

  • provide 函数提供的数据主要保存在组件实例的provides 对象上。在组件创建实例时,组件实例的provides 对象直接指向父组件实例的provides 对象。
  • 但是当组件实例需要提供自己的值时,也就是调用provide 函数时,它会使用父级provides 对象作为原型对象创建自己的provides 对象,然后再给自己的provides 添加新的属性值。通过这种方式,不仅仅可以提供新的数据,还可以保证在inject 阶段,可以通过原型链来查找来自父级的数据。

inject

  • Inject 主要是通过注入的key,来访问其祖先组件实例中的provides 对象对应的值
  • 如果某个祖先组件中执行了provide(key, value),那么在inject(key) 的过程中,先从其父组件的provides 对象本身去查找这个key,如果找到则返回对应的数据;如果找不到,则通过provides 的原型查找这个key,而provides 的原型指向的是它父级provides 对象
  • 正是因为这种查找方式,如果组件实例提供的数据和腹肌provides 中数据的key 相同,则可以覆盖父级提供的数据

Reactivity API

reactive()

reactive() –> createReactiveObject(target, false, mutableHandlers, mutableCollectionHandlers, …) - 将传入的普通对象转换为响应式对象 –>

–> new Proxy(target, collectionHandlers / baseHandlers) - 创建一个代理对象,将传入原始对象作为代理目标:如果是Map 或Set,则调用collectionHandlers;否则就调用baseHandlers

【createReactiveObject】

1、mutableHandlers - 普通的对象的代理处理器

  • 访问对象属性会触发 get 函数
  • 设置对象属性会触发 set 函数
  • 删除对象属性会触发 deleteProperty 函数
  • in 操作符会触发 has 函数
  • 通过 Object.getOwnPropertyNames 访问对象属性名会触发 ownKeys 函数

依赖收集 - get 函数

执行createGetter 函数的的返回值

–> createGetter()

<1> 对特殊的 key 做代理

<2> 通过 Reflect.get 方法求值

当 target 是一个数组的时候,去访问 target.includes、target.indexOf 或者 target.lastIndexOf 就会执行 arrayInstrumentations 代理的函数,除了调用数组本身的方法求值外,还对数组每个元素做了依赖收集。

<3> 执行 track 函数收集依赖

收集的依赖就是数据变化后执行的副作用函数。

–> track(target, TrackOpTypes.GET, key) –>

把 target 作为原始的数据,key 作为访问的属性。

创建全局的 targetMap 作为原始数据对象的 Map,它的键是 target,值是 depsMap,作为依赖的 Map;这个 depsMap 的键是 target 的 key,值是 dep 集合,dep 集合中存储的是依赖的副作用函数。

  • targetMap 的结构 - { target: { key: [componentUpdateFn] } } - 此例中target = {counter: 2},key = counterimage-20230317234646695
  • –> trackEffects(dep, eventInfo)dep - Set 结构({ReactiveEffect})即组件的更新函数

所以每次 track ,就是把当前激活的副作用函数 activeEffect 作为依赖,然后收集到 target 相关的 depsMap 对应 key 下的依赖集合 dep 中。

<4> 对计算的值 res 进行判断,如果它也是数组或对象,则递归执行 reactive 把 res 变成响应式对象。

因为 Proxy 劫持的是对象本身,并不能劫持子对象的变化,这点和 Object.defineProperty API 一致。但是 Object.defineProperty 是在初始化阶段,即定义劫持对象的时候就已经递归执行了,而 Proxy 是在对象属性被访问的时候才递归执行下一步 reactive,这其实是一种延时定义子对象响应式的实现,在性能上会有较大的提升。

派发通知 - set 函数

执行createSetter 函数的返回值。

–> createSetter() –>

<1> 通过 Reflect.set 求值

<2> 通过 trigger 函数派发通知

–> trigger(target, TriggerOpTypes.SET, key, value, oldValue)

  • 通过 targetMap 拿到 target 对应的依赖集合 depsMap
  • 创建运行的 effects 集合
  • 根据 key 从 depsMap 中找到对应的 effects 添加到 effects 集合
  • 遍历 effects 执行相关的副作用函数:triggerEffects(createDep(effects)) –> triggerEffect() - 然后分异步/同步分别调用effect.scheduler() / effect.run()

所以每次 trigger 函数就是根据 target 和 key ,从 targetMap 中找到相关的所有副作用函数遍历执行一遍。

2、mutableCollectionHandlers - Map/Set

ref()

ref() –> createRef(value, false)- 创建RefImpl 实例 –> new RefImpl(rawValue, shallow) –> 如果有某个激活的activeEffect 副作用函数(可能是组件的渲染函数或者是watch 函数等)触发了RefImpl 里的拦截操作

=> get –> trackRefValue(this)- 依赖跟踪收集 –>

  • ref.dep = createDep() - 为Ref 实例创建一个依赖,dep 是一个由ReactiveEffect 构成的Set
  • –> trackEffects() - 开始真正的依赖收集,添加当前的副作用函数到dep 依赖集中,函数也会保存和它有关的dep(双向保存)

=> set –> triggerRefValue(this, newVal) - 触发副作用 –> triggerEffects() - 遍历去执行响应式副作用函数

【activeEffect】 是**ReactiveEffect** 的实例。

setupRenderEffect() –>

const effect = (instance.effect = new ReactiveEffect(componentUpdateFn, () => queueJob(update), instance.scope))

const update: SchedulerJob = (instance.update = () => effect.run())

update() - 立刻执行一次effect.run(),实际调用的是ReactiveEffect 中的run 方法

unref

表现:访问一个ref 对象的值,必须访问ref.value,但在模板中访问ref 对象不用.value。

官方说明

  • 当 ref 在模板中作为顶层属性被访问时,它们会被自动“解包”,所以不需要使用 .value
  • 仅当 ref 是模板渲染上下文的顶层属性时才适用自动“解包”。

编译后的render 函数访问的是_ctx.count,并没有访问数据.value 属性,其中_ctx代表组件的实例对象。

setup 函数的返回结果setupResult 会做一层响应式处理。通过new Proxy() 做一层劫持,get 函数对返回的对象数据做了一层unref 处理。如果发现数据拥有__v_isRef 属性,则返回它的.value 属性。

shallowReactive vs shallowRef

ref is for tracking a single value whereas reactive is for tracking an object or array. The implication on this on the shallow versions is that shallowRef will only trigger a listener if the value itself changes, and shallowReactive will only trigger on the first level of values (so the direct keys).

 const s1 = Vue.shallowRef({ x: 1, y: { a: 1 } })
 const r1 = Vue.ref({ x: 1, y: { a: 1 } })
 const s2 = Vue.shallowReactive({ x: 1, y: { a: 1 } })
 const r2 = Vue.reactive({ x: 1, y: { a: 1 } })
 ​
 Vue.watch(r1, () => console.log("ref (obj) changed"))
 Vue.watch(s1, () => console.log("shallowRef (obj) changed"))
 Vue.watch(s2, () => console.log("shallowReactive changed"))
 Vue.watch(r2, () => console.log("reactive changed"))
 ​
 setTimeout(() => {
     console.log("=== update shallow object ===")
     r1.value.x = 2
     s1.value.x = 2
     s2.x = 2
     r2.x = 2
 }, 1000)
 ​
 setTimeout(() => {
     console.log("=== update nested y.a values ===")
     r1.value.y.a = 2
     s1.value.y.a = 2
     s2.y.a = 2
     r2.y.a = 2
 }, 2000)
 ​
 // 输出
 === update shallow object ===
 shallowReactive changed
 reactive changed
 === update nested y.a values ===
 reactive changed

computed()

computed() –>

const cRef = new ComputedRefImpl(getter, setter, …) –>

1、创建响应式副作用:

this.effect = new ReactiveEffect(getter, () => {… triggerRefValue(this) …})

ReactiveEffect:当依赖的数据发生变化的时候,提供指定的机制(即第二个参数)去触发getter - getter 函数(通常在调用栈内表现为一个anonymous 函数)内部的响应式数据与getter 之间的关系

() => {… triggerRefValue(this) …} - 首次执行时会跳过这里,不进这里的逻辑,走的是原始方法 - get 中的effect.run()

2、get value() - 根组件的渲染函数、当前组件的更新函数会触发get

=> trackRefValue(self) - 首次执行,收集依赖 - 计算属性的值与组件更新函数之间的关系

=> self.effect.run() - 立刻执行一次副作用:执行ReactiveEffect 的run 方法,它内部执行的是传入第一个参数fn 即getter

3、set value()

当值被改变时,会触发set,然后触发trigger –> triggerEffects,然后会按照之前指定的机制去触发更新 – triggerRefValue()

计算属性的核心就是延时计算和缓存,当它的依赖发生变化时,仅仅会标记计算属性内部的_dirty值,计算属性并不会重新计算。当计算属性值被访问时,就会判断内部的_dirty值,如果为false,则直接返回上一次的计算结果;如果为true,则运行内部的effect.run 函数,重新计算计算属性的值。

watch()

watch(source, cb, options) –>

source 可以是个函数,也可以是多个Ref 实例构成的数组,也可以是单个Ref,或者是computedRef,或者响应式对象,当它们变化会重新执行cb

cb 回调函数有newValue/oldValue/onInvalidate 三个参数

–> doWatch(source, cb, …) –>

1、source 标准化

  • 根据source 类型,生成标准化后的getter 函数

    • 如果source 是ref 对象,则创建一个访问source.value 的getter 函数

    • 如果source 是reactive 对象,则创建一个访问source 的getter 函数,并设置deep 为true - 深度监听

      • deep 为true 时,生成的getter 函数会被traverse 函数包装一层
      • 执行 watch 函数时,如果侦听的是一个reactive 对象,那么内部会设置deep 为true,然后执行traverse 去递归访问对象深层子属性,这个时候就会触发依赖收集,这里收集的依赖是watcher 内部创建的effect 对象。因此当去修改某个子属性的值时,就会通知这个effect 对象,最终会执行watcher 的回调函数。
      • 如果对象的嵌套层级很深,那么递归traverse 就会有一定的性能耗时。处理方式:侦听一个getter 函数。
    • 如果source 是一个函数,getter 就是一个简单的对source 函数封装的函数

    • 如果是一个数组,生成的getter 函数内部就会通过source.map 函数映射出一个新的数组,它会判断每个数组元素的类型,映射规则与前面的source 的规则类似

2、创建计划任务 - job,它是对cb 回调函数做的一层封装,维护新值旧值的计算和存储,以及是否要执行回调函数,当侦听的值发生变化时就会执行job。

  • 如果cb 存在,会先执行effect.run 函数求得新值,就是执行前面创建的getter 函数求新值
  • allowRecurse 属性

3、创建scheduler - 根据某种调度方式去执行某种函数

4、创建effect - const effect = new ReactiveEffect(getter, scheduler) - 在内部去生成一个副作用,把标准化的getter 和scheduler 调度函数作为参数传入

  • effect.run - 当回调函数cb 存在且immediate 为false时,会首次执行effect.run 函数求旧值,函数内部会执行getter 函数,访问响应式数据并作依赖收集。注意,此时activeEffect 就是watcher 内部创建的effect 对象,这样在后面更新响应式数据时,就可以触发effect 对象的scheduler 函数,以一种调度方式来执行job 函数
  • 对immediate 的处理:当配置了immediate,创建完watcher 会立刻执行job 函数,此时oldValue 还是初始值,在job 执行时也会执行effect.run,进而执行前面的getter 函数做依赖收集,并求得新值

5、返回销毁函数 - watch API 执行后返回的函数,可以通过调用它来停止watcher 对数据的侦听。

  • 销毁函数内部会执行effect.stop 让effect 失活,并清理effect 的相关依赖,这样就可以停止对数据的侦听。

插槽

插槽的实现实际上就是一种延时渲染,把父组件中编写的插槽内容保存到一个对象上,并且把具体渲染DOM 的代码用函数的方式封装,然后在子组件渲染的时候,根据插槽名称在对象中找到对应的函数,再执行这些函数生成对应的vnode。

普通插槽渲染时的数据作用域和父组件相同,如果想要在插槽渲染时使用子组件的数据,可以通过作用域插槽的方式,让子组件在渲染插槽的时候,通过函数的参数传递给子组件的数组。

子组件插槽部分的DOM 主要是通过renderSlot 函数渲染生成。拥有5 个参数:

  • slots 是子组件初始化时获取的插槽对象
  • name 表示插槽名称
  • props 是插槽的数据,主要用于作用域插槽

作用域插槽编译生成的render 函数中renderSlot 函数的第三个参数props 就是slotProps,它是在执行slot 插槽函数的时候作为参数传入的,通过这种方式,就可以把子组件中的数据传递给父组件中定义的插槽函数了。

异步更新策略

nextTick(this, fn) - 这个函数vue3 已经非常弱化它的存在了。

作用:将传入fn 插入到正在激活的组件更新任务之后,从而执行一个异步任务。

获取一个promise - currentFlushPromise / resolvedPromise

currentFlushPromise = resolvedPromise.then(flushJobs) –> flushJobs()

image-20230319002443816

编译器原理

编译器会将template 编译为render。

  • 前端程序员描述视图时候更喜欢html
  • 开发效率更高
  • 性能优化(可以通过app._instance.render 查找到当前template 转换后的render 函数,里面能看到如静态缓存等优化手段)

vue template vs react jsx

目的都是为了产生虚拟dom,提高前端程序员视图开发效率。

jsx: 会通过babel 语言转换工具将编写的jsx 转换成一个函数的调用(create()),最后返回的结果是一个虚拟dom。

template: compile –> render –> … –> vnode

【执行时刻】

vue 根据版本喝执行环境的不同可以分为预编译和运行时:

  • 预编译 - webapck 通过vue-loader 将SFC 变异成js
  • 运行时 - global/esm-browser 版本,写template 选项,在挂载阶段才会执行组件的编译,执行时刻显然是较晚的,对性能有一定影响

React jsx - 转译transpiler 类似于vue 中的预编译

编译过程解析

运行时编译

setupComponent(instance) –> –> setupStatefulComponent(instance, …) –> finishComponentSetup(instance, …) –>

Component.render = compile(template, finalCompilerOptions)

–> compileToFunction() - 返回真正的渲染函数

1、const { code } = compile(template, opts) - 这里的code 已经经过parse - transform - generate 三个流程处理

–> baseCompile()

2、new Function(code)() - code 里面是函数体内的若干代码段,须先转成一个函数,先new Function(code),然后code 最后是返回了一个render 函数,想要执行就得再调用下 - ()。

vue/src/index.ts

=> compileToFunction

=> registerRuntimeCompiler(compileToFunction)- registerRuntimeCompiler(_compile)compile = _compile

runtime-only 和 runtime-compiled 的主要区别在于是否注册了这个 compile 方法。

【如何运行】

baseCompile() –>

=> 1、解析template 生成AST:const ast = isString(template) ? baseParse(template, options) : template

  • AST 是树状结构,树中的每个节点会有type 字段描述节点的类型,tag 字段描述节点的标签,props 字段描述节点的属性,loc 字段描述节点代码位置相关信息,children 字段指向它的子节点对象数组
  • baseParse:创建解析上下文、解析子节点、创建AST 根节点

=> 2、AST 转换:transform(ast, …) - 通过语法分析,创建语义和信息更加丰富的代码生成节点codegenNode,便于后续生成代码

=> 3、生成代码:generate(ast, …)

优化策略

查看工具:Vue Template Explorer

1、静态节点提升 - 内存(空间)换时间

2、事件处理器缓存 - onClick:_cache[0] || (_cache[0] = ()=>_ctx.console.log(_ctx.xx))

3、patchFlags - 打补丁标记,精确更新节点,不需要执行额外代码的都可以跳过

4、block - 划分区块,同一处理,从一颗树的遍历降为遍历一个数组。

代码层面如何利用编译器优化

1、shapeFlag

const { type, ref, shapeFlag } = n2

if (shapeFlag & ShapeFlags.ELEMENT) {…}

shapeFlag 是个枚举类型,里面有10 种类型 - 位运算

2、patchFlag

14 种类型

【参考资料】

《vue.js 技术内幕》

《拉勾:Vue.js 3.0 核心源码解析》