跳至主要內容

Vue3源码解析

yyshino大约 15 分钟FrontEndVue源码分析

Vue响应式

原理

Proxy与Object.defineProperty

  1. Proxy
    1. Proxy 将代理一个对象(被代理对象),得到一个新的对象(代理对象),同时拥有被代理对象中所有的属性。
    2. 当想要修改对象的指定属性时,我们应该使用代理对象进行修改
    3. 代理对象 的任何一个属性都可以触发 handler 的 getter 和 setter
  2. Object.defineProperty
    1. Object.defineProperty 为指定对象的指定属性设置属性描述符
    2. 当想要修改对象的指定属性时,可以使用原对象进行修改
    3. 通过属性描述符,只有 被监听 的指定属性,才可以触发 getter 和 setter

所以当 vue3 通过 Proxy 实现响应性核心 API 之后,vue 将 不会 再存在新增属性时失去响应性的问题。

Reflect

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Reflect

Reflect+Proxy

当我们期望监听代理对象的 getter 和 setter 时,不应该使用 target[key] ,因为它在某些时刻(比如 fullName)下是不可靠的。而 应该使用 Reflect ,借助它的 get 和 set 方法,使用receiver (proxy 实例) 作为 this,已达到期望的结果(触发三次 getter)

最后如果我们想要“安全”的使用Proxy,还需要配合Reflect一起才可以,因为一旦我们在被代理对象的内部,通过 this 触发 getter 和 setter 时,也需要被监听到。

Reactive的响应式 源码实现

核心

  1. 创建 proxy
  2. 收集 effect 的依赖
  3. 触发收集的依赖

reactive方法

  1. 触发 reactive 方法

  2. 创建reactive对象: return createReactiveObject

  3. 进入 new Proxy

    1. 第一个参数 target:为传入的对象
    2. 第二个参数 handler: TargetType.COLLECTION = 2, targetType = 1, 所以 handler为 baseHandlers
    3. 那这个 baseHandlers 是什么呢?
  4. 在 reactive 方法中可知, baseHandlers 是触发 createReactiveObject 传递的第三个参数: mutableHandlers

  5. 而 mutableHandlers 则是 packages/reactivity/src/baseHandlers.ts 中导出的对象

  6. 所以我们到 packages/reactivity/src/baseHandlers.ts 中,为它的 get (createGetter)和 set (createSetter) 分别打入一个断点

  7. 我们知道 get 和 set 会在 取值 和 赋值 时触发,所以此时这两个断点 不会执行

  8. 最后 reactive方法内执行了 proxyMap.set(target, proxy)方法

  9. 最后返回了代理对象。

  10. 那么至此 reactive 方法执行完成。

由以上执行逻辑可知,对于 reactive 方法而言,其实做的事情非常简单:

  1. 创建了 proxy
  2. 把 proxy 加到了 proxyMap 里面
  3. 最后返回了 proxy

effect

  1. 在 packages/reactivity/src/effect.ts 第 170 行可以找到 effect 方法,在这里给一个断点
  2. 执行 new ReactiveEffect(fn),其中的 fn 就是我们传入的匿名函数:
    1. 这里涉及到了一个类 ReactiveEffect
    2. 查看该类可知,内部实现了两个方法:
      1. run
      2. stop
    3. 我们分别为这两个方法 增加断点
  3. 代码继续进行
  4. 可以发现执行了 run 方法,进入方法内部:
    1. 执行 activeEffect = this,赋值完成之后, activeEffect为传入的匿名函数fn
    2. 然后执行 return this.fn() 触发 fn 函数
    3. 我们知道 fn 函数其实就是 传入的匿名函数,所以document.querySelector('#app').innerText = obj.name
  5. 但是大家不要忘记,obj 是一个 proxy,obj.name会 触发 getter,所以接下来我们就会进入到 mutableHandlers 的 createGetter 中
    1. 在该代码中,触发了该方法 const res = Reflect.get(target, key, receiver)
    2. 此时的 res 即为 张三
    3. 注意:接下来触发了 track 函数,该函数是一个重点函数,track 在此为 跟踪 的意思,我们来看它内部都做了什么:
      1. 在 4-1 步中,为 activeEffect 进行了赋值,我们知道 activeEffect 代表的就是fn 函数
      2. 执行代码可知,rack 内部主要做了两件事情:
        1. 为 targetMap 进行赋值,targetMap 的组成比较复杂:
          1. key; target
          2. value: Map
            1. key: key
            2. value: Set
        2. 最后执行了 trackEffects(dep, eventInfo)1,其中eventInfo是一个对象,内部包含四个属性:其中 effect即为activeEffect 即 fn 函数
        3. 在 trackEffects 函数内部,核心也是做了两件事情:
          1. 为dep (targetMap[target][key]得到的Set实例) 添加了activeEffect函数
          2. 为activeEffect函数的静态属性deps,增加了一个值dep
          3. 即:建立起了dep和activeEffect的联系
    4. 那么至此,整个 track 的核心逻辑执行完成
    5. 我们可以把整个 track 的核心逻辑说成:收集了 activeEffect(即: fn)
  6. 最后在 createGetter 函数中返回了 res (即:张三)
  7. 至此,整个 effect 执行完成

由以上逻辑可知,整个 effect 主要做了3 件事情:

  1. 生成ReactiveEffect实例
  2. 触发fn方法,从而激活getter
  3. 简历了targetMap和activeEffect之间的联系
    1. dep.add(activeEffect)
    2. activeEffect.deps.push(dep)

那么至此:页面中即可展示 obj.name ,但是不要忘记,等待两秒之后,我们会修改 obj.name 的值,我们知道,这样会触发 setter ,那么我们接下来来看 setter中又做了什么呢?

  1. 两秒之后触发 setter ,会进入到 packages/reactivity/src/baseHandlers.ts 中的的createSetter 方法中
  2. 创建变量: oldValue =张三
  3. 创建变量: value=李四、
  4. 执行 const result = Reflect,set(target, key, value, receiver),即: 修改了 obj 的值为“李四”I
  5. 触发: trigger(target, TriggerOpTypes.SET, key, value, oldvalue),此时各参数的值为:
  6. trigger 在这里为 触发 的意思,那么我们来看 trigger 内部做了什么?
    1. 首先执行: const depsMap = targetMap.get(target),其中 targetMap 即我们在track 函数中,保存 activeEffect 的 targetMap
    2. 然后代码执行到: deps.push(depsMap.get(key))。 depsMap.get(key)获取到的即为之前保存的 activeEffect,即 fn 函数
    3. 然后触发 triggerEffects(deps[0],eventInfo),我们来看 triggerEffects 中做了什么:
      1. 声明常量: const effects = isAray(dep) ? dep : [...dep],此时的 effects 保存的为 fn 的集合
      2. 遍历 effects,执行:triggerEffect(effect, debuggerEventExtraInfo) 方法,那么我们来看 triggerEffect 做了什么
        1. 执行 effect.run()方法,已知: effect 是一个ReactiveEffect 类型的对象,则run 方法会触发 ReactiveEffect 的 run ,那么我们接下来来看 这一次 进入run 方法时,内部做了什么?
          1. 首先还是为activeEffect = this赋值,但是要注意:此时的this不再是一个 fn,而是一个复杂对象:
          2. 最后执行this.fn() 即:effect时传入的匿名函数
          3. 至此,fn执行,意味着:document.querySelector('#app').innerText = 李四,页面将发生变化
      3. triggerEffects 完成
    4. triggerEffects 完成
  7. trigger 完成
  8. setter回调完成

由以上逻辑可知,整个setter主要做了2件事情:

  1. 修改obj的值
  2. 触发targetMap下保存的fn函数
image-20240425162252067
image-20240425162252067

Ref 复杂数据类型的响应性

  1. 对于ref函数,会返回RefImpl类型的实例
  2. 在该实例中,会根据传入的数据类型进行分开处理
    1. 复杂数据类型:转化为reactive返回的proxy实例
    2. 简单数据类型:不做处理
  3. 无论我们执行obj.value.name还是obj.value.name = xxx本质上都是触发了get value
  4. 之所以会进行 响应性 是因为 obj.value 是一个 reactive 函数生成的 proxy

总结

那么到这里我们就已经完成了 ref 响应性函数的构建,那么大家还记不记得开篇时所问的三个问题:

  1. ref 函数是如何进行实现的呢?
  2. ref 可以构建简单数据类型的响应性吗?
  3. 为什么 ref 类型的数据,必须要通过 .value 访问值呢?

大家现在再次面对这三个问题,是否能够回答出来呢?

  1. 问题一: ref 函数是如何进行实现的呢?
    1. ref 函数本质上是生成了一个 RefImpl 类型的实例对象,通过 get 和 set 标记处理了value 函数
  2. 问题二: ref 可以构建简单数据类型的响应性吗?
    1. 是的。 ref 可以构建简单数据类型的响应性
  3. 问题三:为什么 ref 类型的数据,必须要通过 .value 访问值呢?
    1. 因为 ref 需要处理简单数据类型的响应性,但是对于简单数据类型而言,它无法通过proxy 建立代理。
    2. 所以vue通过get value() 和 set value() 定义了两个属性函数,通过主动触发这两个函数(属性调用)的形式来进行依赖收集和依赖触发

computed && watch

computed

debugged调试,具体流程

  1. 整个事件有obj.name开始
  2. 触发 proxy 实例的 setter
  3. 执行 trigger,第一次触发依赖
  4. 注意,此时 effect 包含调度器属性,所以会触发调度器
  5. 调度器指向ComputedRefImpl的构造函数中传入的匿名函数
  6. 在匿名函数中会:再次触发依赖
  7. 即:两次触发依赖
  8. 最后执行:computed中的回调函数

总结

接下来我们来总结一下计算属性实现的重点:

  1. 计算属性的实例,本质上是一个ComputedRefImpl的实例
  2. ComputedRefImpl 中通过 dirty 变量来控制 run 的执行和 triggerRefValue 的触发
  3. 想要访问计算属性的值,必须通过 .value ,因为它内部和 ref 一样是通过get value来进
  4. 每次 .value 时都会触发 trackRefValue 即:收集依赖
  5. 在依赖触发时,需要谨记,先触发 computed 的 effect ,再触发非 computed 的 effect行实现的

Vue运行时核心

基础概念

虚拟DOM

虚拟 DOM (Virtual DOM,简称 VDOM) 是一种编程概念,意为将目标所需的 UI 通过数据结构“虚拟”地表示出来,保存在内存中,然后将真实的 DOM 与之保持同步。这个概念是由 Reactopen in new window 率先开拓,随后被许多不同的框架采用,当然也包括 Vue。

渲染管线open in new window

从高层面的视角看,Vue 组件挂载时会发生如下几件事:

  1. 编译:Vue 模板被编译为渲染函数:即用来返回虚拟 DOM 树的函数。这一步骤可以通过构建步骤提前完成,也可以通过使用运行时编译器即时完成。
  2. 挂载:运行时渲染器调用渲染函数,遍历返回的虚拟 DOM 树,并基于它创建实际的 DOM 节点。这一步会作为响应式副作用open in new window执行,因此它会追踪其中所用到的所有响应式依赖。
  3. 更新:当一个依赖发生变化后,副作用会重新运行,这时候会创建一个更新后的虚拟 DOM 树。运行时渲染器遍历这棵新树,将它与旧树进行比较,然后将必要的更新应用到真实 DOM 上去。

...

h函数

查看 packages/runtime-core/src/renderer.ts 中第 354 patch 方法的代码可知,Vue 总共处理了:

  1. Text: 文本节点
  2. Comment:注释节点
  3. Static: 静态 DOM 节点
  4. Fragment:包含多个根节点的模板被表示为一个片段(fragment)括
  5. ELEMENT: DOM 节点Imooc
  6. COMPONENT:组件
  7. TELEPORT:新的内置组件
  8. SUSPENSE:新的 内置组件
  9. ...

在vue中,组件本质上是 一个对象或一个函数(Function Component)

shapeFlag

组件的设计原理与渲染

简介

组件本身是一个对象(仅考虑对象的情况,忽略函数式组件)。它必须包含一个render函数,该函数决定了它的渲染内容。

如果我们想要定义数据,那么需要通过 data 选项进行注册。 data 选项应该是一个 函数,并且renturn一个对象,对象中包含了所有的响应性数据。

除此之外,我们还可以定义例如 生命周期、计算属性、 watch 等对应内容。

无状态基础挂着逻辑组件

Vue 中通常把 状态 比作 数据 的意思。我们所谓的无状态,指的就是 无数据 的意思。

Diff算法核心实现

前置知识

那么到目前为止,我们已经完成了 4 种 diff 场景的对应处理,经过前面的学习我们可以知道,对于前四种 diff 场景而言, diff 的处理本质上是比较简单的:

  1. 自前向后的 diff对比:主要处理从前到后的相同 VNode。例如: (a b) c 对应(a b) d e
  2. 自后向前的 diff对比:主要处理从后到前的相同VNode。例如: a (b c)对应d e (b c)
  3. 新节点多余旧节点的 diff 对比:主要处理新增节点。
  4. 旧节点多余新节点的 diff 对比:主要处理删除节点。

但是仅靠前四种场景的话,那么是无法满足实际开发中的所有更新逻辑的。所以我们还需要最关键的一种场景需要处理,那就是 乱序场景。

最长递增子序列

  1. 什么是最长递增子序列
  2. 最长递增子序列在diff 中的作用是什么

维基百科 -最长递增子序列

在一个给定的数值序列中,找到一个子序列,使得这个子序列元素的数值依次递增,并且这个子序列的长度尽可能地大。

乱序下的diff比对

  1. diff指的就是:添加、删除、打补丁、移动这四个行为
  2. 最长递增子序列 是什么,如何计算的,以及在 diff 中的作用

  3. 场景五的乱序,是最复杂的场景,将会涉及到 添加、删除、打补丁、移动 这些所有场景。

总结

  1. 首先我们讲解了dom、节点、节点树和虚拟DOM,虚拟节点之间的概念。
  2. 然后我们说明了 render 函数和 h 函数各自的作用。我们知道 h 函数可以得到一个 vnode ,而 render 函数可以渲染一个 vnode
  3. 接下来我们讲解了挂载、更新、卸载,这三组概念。也知道了针对于不同的vnode节点,那么他们的挂载、更新、卸载方式也都是不同的。
  4. 下面我们讲解了组件,我们知道组件本质上是一个对象(或函数) ,组件的挂载本质上是render 函数的挂载。
  5. 组件内部维护了一个effect对象,以达到响应性渲染的效果。
  6. 而针对于 setup 函数而言,它在实现上反而更加简单,因为我们不需要改变 this 指向了。
  7. 结合所学,新旧节点的所有挂载和更新情况,可以被分为九种场景:
    1. 旧节点为纯文本时:
      1. 新节点为纯文本:执行文本替换操作
      2. 新节点为空:删除旧节点
      3. 新节点为数组:清空文本,添加多个新节点2.
    2. 旧节点为空时:
      1. 新节点为纯文本:添加新节点
      2. 新节点为空:不做任何事情
      3. 新节点为数组时:添加多个新节点
    3. 旧节点是数组时:
      1. 新节点为纯文本:删除所有旧节点,添加新节点
      2. 新节点为空:删除所有旧节点
      3. 新节点为数组时:进行 diff 操作
  8. 最后的 diff 分为 5 种场景,最后一种场景还是比较复杂的。

编译时核心设计原则

前言

模版编译的核心流程

正常流程

image-20240503142131784
image-20240503142131784

Vue中

image-20240503142248493
image-20240503142248493

框架实现

接下来我们通过parseChildren方法处理所有的子节点,整个处理的过程分为两大块:

  1. 构建有限自动状态机解析模板
  2. 扫描 token 生成 AST 结构

总结

我们知道,整个 compiler 的过程,就是一个把:源代码(template)转化为目标代码(render函数)的过程。

在这个过程中,主要经历了三个大的步骤:

  1. 解析( parse ) template模板,生成AST
  2. 转化(transform) AST,得到JavaScript AST
  3. 生成(generate) render 函数这

三步是非常复杂的一个过程,内部的实现涉及到了非常复杂的计算方法,并且会涉及到一些我们现在还没有了解过得概念,比如:自动状态机。

这些内容我们都会放到下一章进行讲解,本章我们只需要知道 compiler 的作用,以及三大步骤即可都在干什么即可。

构建compile编译器

总结

我们知道整个编辑器的处理过程分成了三部分:

  1. 解析模板 template 为 AST1,在这一步过程中,我们使用了
    1. 有限自动状态机解析模板得到了 tokens
      1. 通过扫描 tokens 最终得到了`AST王
  2. 转化 AST 为 JavaScript AST
    1. 这一步是为了最终生成 render 函数做准备
    2. 利用了深度优先的方式,进行了自下向上的逐层转化
  3. 生成 render 函数
    1. 这一步是最后的解析环节,我们需要对JavaScript AST进行处理,得到最终的render函数

深入编辑器处理逻辑

参考

  • https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy
  • https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
  • https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Reflect
  • https://developer.mozilla.org/zh-CN/docs/Web/API/Document_Object_Model
  • https://cn.vuejs.org/guide/extras/rendering-mechanism.html#virtual-dom