vue3 底层实现感悟

2024/03/20 文章推荐 共 7377 字,约 22 分钟

前几天读《Vue.js 设计与实现》,不仅get到很多基础的知识点,还惊叹于他的实现原理

设计思路

声明是UI(推荐使用): 即 template 形式(模板,模板的编译依赖于编译器),会被编译器的程序编译为渲染函数,再由渲染器渲染为真实 DOM

编译器:将模板编译为渲染函数,在编译的过程中编译器有能力分析动态内容,并在编译阶段把这些信息提取出来,把附带静动态属性的内容交给渲染器(patchFlags),在更新的阶段,渲染器只需对动态属性进行查找和更新,性能自然就提升了。(后面diff算法实现了各种优化,靶向更新,预字符串化,静态提升….

渲染器:把虚拟 DOM 对象渲染为真实 DOM 元素。它的工作原理是,递归地遍历虚拟 DOM 对象,并调用原生 DOM API 来完成真实 DOM 的创建。渲染器的精髓在于后续的更新,它会通过 Diff 算法找出变更点,并且只会更新需要更新的内容虚拟 DOM 是变成真实 DOM 并渲染到浏览器页面中(依托强大的编译器)

无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器再把渲染函数返回的虚拟 DOM 渲染为真实 DOM,这就是模板的工作原理,也是 Vue.js 渲染页面的流程。

响应式系统

利用响应系统的能力,我们可以做到,当响应式数据变化时自动完成页面更新(或重新渲染,执行副作用函数)

Vue.js 3 的响应式数据是基于 Proxy 实现的

原理:拦截一个对象的读取和设置操作,读取的时候把副作用函数存储到一个“桶”里(track,收集依赖集合),设置的时候执行从“桶”里取出并执行(trigger),从而实现了响应式变化

桶的设置:

new WeakMap() 用来存储代理对象(key 是代理对象,value是属性)=> 对 key 是弱引用,不影响垃圾回收器的工作

new Map() 用来存储代理属性(key 是属性,value 是副作用函数)

new Set() 用来存储副作用函数

在实现的过程中需要注意的点和在我们的日常开发中能够受启示的点

  1. 分支切换的时候,某些字段变化不需要在触发副作用函数的执行。解决方案:每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除,然后再重新收集依赖

  2. 嵌套执行出现内部副作用函数的执行会覆盖外部的。解决方案:需要额外添加一个副作用函数栈 effectStack,在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让 activeEffect 指向栈顶的副作用函数

  3. 避免无限递归,解决方案:如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行

  4. 调度执行:指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。(computed,watch

Proxy 和 Reflect

Proxy:对一个对象基本操作的代理,它允许我们拦截并重新定义这个对象的基本操作,Proxy 只能代理对象,无法代理非对象值,例如字符串、布尔值等,(非基本操作,比如调用一个对象下的方法,是由两个基本语义组成的。第一个基本语义是 get,即先通过 get 操作得到 obj.fn 属性。第二个基本语义是函数调用,即通过 get 得到 obj.fn 的值后再调用它,也就是我们上面说到的 apply这个属于复合操作,Proxy 无法代理)

原生Proxy对象提供的是“浅代理”,无法直接响应深层次引用类型的属性修改。

Reflect:它的功能就是直接调用对象的基本操作(内部方法),使用它的还有一个原因是 Reflect.get 函数还能接收第三个参数,即指定接收者 receiver,你可以把它理解为函数调用过程中的this,避免代理对象的 this 丢失(指向原对象,而不是代理对象)

额外知识:

代理大概可以捕获13种不同的基本操作

如何区分一个对象是普通对象还是函数呢?一个对象在什么情况下才能作为函数调用呢?

答案是,通过内部方法和内部槽来区分对象,例如函数对象会部署内部方法 [[Call]]和[[Construct]],而普通对象则不会(内部方法即基本操作)

读操作:访问属性,in ,for in

对于原始类型的数据,使用一个非原始值去“包裹”原始值,例如使用一个对象包裹原始值(ref 本质上是一个”包裹对象”)

// 封装一个 ref 函数
function ref(val) {
  // 在 ref 函数内部创建包裹对象
  const wrapper = {
  value: val
  }
  // 将包裹对象变成响应式数据
  return reactive(wrapper)
}

代理的问题与不足

  1. 代理中的this 问题 => 通过Reflect.get/set等方法的第三个参数receiver来解决或者函数的call/apply 方法来解决

  2. 代理与内置引用类型(比如Array)的实例不匹配

有些ECMAScript内置类型可能会依赖代理无法控制的机制,结果导致在代理上调用某些方法会出错。一个典型的例子就是Date类型。根据ECMAScript规范,Date类型方法的执行依赖this值上的内部槽位[[NumberDate]]。代理对象上不存在这个内部槽位,而且这个内部槽位的值也不能通过普通的get()和set()操作访问到,于是代理拦截后本应转发给目标对象的方法会抛出TypeError

通过重新实现或者改变this的指向,保证不报错并且proxy能够拦截到

object.defineProperty和Proxy的区别

都是用于对象属性拦截,以实现数据响应式更新。都只能代理对象,无法代理非对象值,例如字符串、布尔值等

兼容性: Object.defineProperty 兼容性好,可以兼容ie9和一些比较老的浏览器

劫持范围:

Object.defineProperty 是劫持对象的某一个属性,没办法劫持整个对象,而Proxy 可以对整个对象进行拦截,并且可以劫持对象所有的属性和方法

劫持能力: Oject.defineProperty 无法监听属性的删除和对象上添加新属性的操作,但是Proxy可以,而且更加灵活

劫持时机 Object.defineProperty只有在访问属性时才会触发,而Proxy可以在任何属性和任何操作都被触发

Reflect 和 直接操作对象的区别

Reflect对象提供了一组严谨、统一的方法来进行对象操作,而直接操作对象时需要调用对象的方法或直接访问其属性

Reflect方法通常会返回一个操作结果,而直接操作对象时则没有返回值或返回一个undefined。

报错机制。使用Reflect操作对象时,对于一些非法操作,例如对非对象调用方法,无法访问不可配置的属性等,会抛出TypeError或ReferenceError。而直接操作对象时,则会返回undefined或执行失败。

拦截劫持。使用Proxy类结合Reflect对象可以劫持对象的一些操作,比如get、set等方法,而直接操作对象时则不支持拦截和劫持操作。

渲染器

渲染器是用来执行渲染任务的。在浏览器平台上,用它来渲染其中的真实 DOM 元素。渲染器不仅能够渲染真实DOM 元素,它还是框架跨平台能力的关键

渲染器把虚拟 DOM 节点渲染为真实 DOM 节点的过程叫作挂载

挂载子节点,以及节点的属性。对于子节点,只需要递归地调用 patch 函数完成挂载即可。而节点的属性比想象中的复杂,它涉及两个重要的概念:HTML Attributes 和 DOM Properties。为元素设置属性时,我们不能总是使用 setAttribute 函数,也不能总是通过元素的DOM Properties 来设置。至于如何正确地为元素设置属性,取决于被设置属性的特点。例如,表单元素的 el.form 属性是只读的,因此只能使用 setAttribute 函数来设置

HTML Attributes 与 DOM Properties 的区别

HTML Attributes 就是指定义在HTML标签上的属性

DOM Properties 就是指通过js获取/设置的属性

大部分情况下他们都是一一对应的,但是有些不是总一样的,比如说 DOM Properties 中的className 和 HTML Attributes 中的class

aria-* 类的HTML Attributes 就没有与之对应的DOM Properties

el.textContent (DOM properties),就没有与之对应的HTMLAttribute

HTML Attributes 的作用是设置与之对应的DOM Properties的初始值,一旦值改变,那么DOM properties 始终存储着当前的值,而通过getAttribute函数得到的仍然是初始值

所以vue在属性设置的时候,如果是布尔类型,并且value是空字符串,则矫正为true el[key]=true,为了纠正<button disable=''> 类型

  // 是否是只读属性
  function shouldSetAsProps(el,key,value){
    // 特色处理
    if(key==='form'&&el.tagName==='INPUT') return false
    // 用in 操作符判断key是否存在对应的DOM properties上面
    return key in el
  }
  // 将属性设置相关操作封装到patchProps函数中,并作为选渲染器传递
  patchProps(el,key,preValue,nextValue){

    if(shouldSetAsProps(el,key,nextValue)){
          const type = typeof el[key]
              // on 开头的属性,视为事件
        if(/^on/.test(key)){
          const name = key.slice(2).toLowerCase()
          // 移除上一次绑定事件处理函数
          preValue&&el.removeEventListener(name,preValue)
          // 绑定事件
          el.addEventListener(name,nextValue)
        }else if(key==='class'){
            el.className=nextValue||''
          }else if(type ==='boolean' && value===''){
          // 如果是布尔类型,并且value是空字符串,则矫正为true
            el[key]=true
          }else{
            el[key]=nextValue
          }
        }else{
          // 如果设置的属性没有DOM properties,则使用setAttribute函数设置属性
          el.setAttribute(key,nextValue)
        }
  }

用户编写在 Vue.js 的单文件组件中的模板不会被浏览器解析,这意味着,原本需要浏览器来完成的工作,现在需要框架来完成 。所以我们需要正确地设置元素属性事件,这点特别重要,比如说设置元素的 class 需要将值归一化为统一的字符串形式。

卸载的时候不能使用 el.innerHTML=’‘,因为使用 innerHTML 清空容器元素内容的另一个缺陷是,它不会移除绑定在 DOM 元素上的事件处理函数,也没有生命周期和任何可以执行的钩子函数。所以可以调用 removeChild 方法

diff 更新

双端 Diff 算法:在新旧两组子节点的四个端点之间分别进行比较,并试图找到可复用的节点。通过不断的比较和移动,最终完成了新旧两组子节点的更新

快速 Diff 算法:它借鉴了文本 Diff 中的预处理思路,先处理新旧两组子节点中相同的前置节点和相同的后置节点。当前置节点和后置节点全部处理完毕后,如果无法简单地通过挂载新节点或者卸载已经不存在的节点来完成更新,则需要根据节点的索引关系,构造出一个最长递增子序列。最长递增子序列所指向的节点即为不需要移动的节点

编译优化

编译优化的核心在于,区分动态节点与静态节点。Vue.js 3 会为动态节点打上补丁标志,即 patchFlag。同时,Vue.js 3 还提出了 Block 的概念,一个 Block 本质上也是一个虚拟节点,但与普通虚拟节点相比,会多出一个 dynamicChildren 数组。该数组用来收集所有动态子代节点,这利用了 createVNode 函数和 createBlock 函数的层层嵌套调用的特点,即以“由内向外”的方式执行。再配合一个用来临时存储动态节点的节点栈,即可完成动态子代节点的收集

由于 Block 会收集所有动态子代节点,所以对动态节点的比对操作是忽略 DOM 层级结构的。这会带来额外的问题,即 v-if、v-for 等结构化指令会影响 DOM 层级结构,使之不稳定。这会间接导致基于 Block 树的比对算法失效。而解决方式很简单,只需要让带有 v-if、v-for 等指令的节点也作为 Block 角色即可。除了 Block 树以及补丁标志之外,Vue.js 3 在编译优化方面还做了其他努力,具体如下。

靶向更新:把它的动态子节点提取出来,并将其存储到该虚拟节点的dynamicChildren 数组内,数组对象中的 patchFlag 就是动态内容的标志(补丁),不同的数值,赋予不同的含义。我们把带有该属性的虚拟节点称为“块”,即 Block

在更新的时候我们优先检测虚拟 DOM 是否存在动态节点集合,即 dynamicChildren 数组。动态节点集合能够使得渲染器在执行更新时跳过静态节点,但对于单个动态节点的更新来说,由于它存在对应的补丁标志,因此我们可以针对性地完成靶向更新

静态提升(属性节点):能够减少更新时创建虚拟 DOM 带来的性能开销和内存占用。

预字符串化:在静态提升的基础上,对静态节点进行字符串化。这样做能够减少创建虚拟节点产生的性能开销以及内存占用。

缓存内联事件处理函数:避免造成不必要的组件更新。

v-once 指令:缓存全部或部分虚拟节点,能够避免组件更新时重新创建虚拟 DOM 带来的性能开销,也可以避免无用的Diff 操作。

组件

一个组件必须包含一个渲染函数,即 render 函数,并且渲染函数的返回值应该是虚拟 DOM

通过组件的选项对象取得 data 函数并执行,然后调用reactive 函数将 data 函数返回的状态包装为响应式数据

将组件的整个渲染任务包装到一个 effect 中,当组件状态发生变化时,effect 就会被重新执行,从而完成组件的自更新

我们需要实现一个调度器,当副作用函数需要重新执行时,我们不会立即执行它,而是将它缓冲到一个微任务队列中,等到执行栈清空后,再将它从微任务队列中取出并执行。有了缓存机制,我们就有机会对任务进行去重,从而避免多次执行副作用函数带来的性能开销

组件实例本质上就是一个状态集合(或一个对象),它维护着组件运行过程中的所有信息,例如注册到组件的生命周期函数、组件渲染的子树(subTree)、组件是否已经被挂载、组件自身的状态(data)

我们可以在需要的时候,任意地在组件实例 instance 上添加需要的属性。但需要注意的是,我们应该尽可能保持组件实例轻量,以减少内存占用

我们把由父组件自更新所引起的子组件更新叫作子组件的被动更新。当子组件发生被动更新时,我们需要做的是:● 检测子组件是否真的需要更新,因为子组件的 props 可能是不变的;● 如果需要更新,则更新子组件的 props、slots 等内容

setup 函数主要用于配合组合式 API,为用户提供一个地方,用于建立组合逻辑、创建响应式数据、创建通用函数、注册生命周期钩子等能力。在组件的整个生命周期中,setup 函数只会在被挂载时执行一次,它的返回值可以有两种情况:一种是一个对象,另一种是一个函数

任何没有显式地声明为 props 的属性都会存储到 attrs 中

函数式组件,主要是因为它的简单性,而不是因为它的性能好

因为对于函数式组件来说,它无须初始化 data 以及生命周期钩子。因此函数式组件的初始化性能消耗小于有状态组件

例如异步组件设计

先设计好用户接口,然后具体实现

允许用户指定加载出错时要渲染的组件;

允许用户指定 Loading 组件,以及展示该组件的延迟时间

允许用户设置加载组件的超时时长;

组件加载失败时,为用户提供重试的能力

服务端渲染

SSR 是在服务端完成页面渲染的,所以它需要消耗更多服务端资源。

CSR 则能够减少对服务端资源的消耗。对于用户体验,由于 CSR 不需要进行真正的“跳转”,用户会感觉更加“流畅

事实是同构渲染仍然需要像 CSR 那样等待JavaScript 资源加载完成,并且客户端激活完成后,才能响应用户操作。因此,理论上同构渲染无法提升可交互时间

当首次访问或者刷新页面时,整个页面的内容是在服务端完成渲染的,浏览器最终得到的是渲染好的 HTML 页面。但是该页面是纯静态的,这意味着用户还不能与页面进行任何交互,因为整个应用程序的脚本还没有加载和执行。

另外,该静态的 HTML 页面中也会包含 link、script 等标签。除此之外,同构渲染所产生的 HTML 页面与 SSR 所产生的 HTML 页面有一点最大的不同,即前者会包含当前页面所需要的初始化数据。直白地说,服务器通过 API 请求的数据会被序列化为字符串并拼接到静态的 HTML 字符串中,最后一并发送给浏览器。这么做实际上是为了后续的激活操作

服务端渲染的是应用的当前快照,它不存在数据变更后重新渲染的情况。因此,所有数据在服务端都无须是响应式的。利用这一点,我们可以减少服务端渲染过程中创建响应式数据对象的开销。

服务端渲染只需要获取组件要渲染的 subTree 即可,无须调用渲染器完成真实 DOM 的创建。因此,在服务端渲染时,可以忽略“设置 render effect 完成渲染”这一步。

客户端激活的原理:

在页面中的 DOM 元素与虚拟节点对象之间建立联系;

为页面中的 DOM 元素添加事件绑定。

渲染副作用执行挂载操作时,我们优先检查虚拟节点的 vnode.el 属性是否已经存在,如果存在,则意味着无须进行全新的挂载,只需要进行激活操作即可,否则仍然按照之前的逻辑进行全新的挂载。最后一个关键点是,组件的激活操作需要在真实 DOM 与 subTree 之间进行

需要注意点

组件的生命周期

跨平台的API

特定端的实现(cookie的读取

避免交叉请求引起状态污染

可以写一个单独的 ClientOnly 组件,就只在浏览器平台渲染(利用生命周期的不同)


在技术的历史长河中,虽然我们素未谋面,却已相识已久,很微妙也很知足。互联网让世界变得更小,你我之间更近。

在逝去的青葱岁月中,虽然我们未曾相遇,却共同经历着一样的情愫。谁的青春不曾迷茫或焦虑亦是无奈,谁不曾年少过

在未来的日子里,让我们共享好的文章,共同学习进步。有不错的文章记得分享给我,我不会写好的文章,所以我只能做一个搬运工

我叫 sunseekers(张敏) ,千千万万个张敏与你同在,18年电子商务专业毕业,毕业后在前端搬砖

如果喜欢我的话,恰巧我也喜欢你的话,让我们手拉手,肩并肩共同前行,相互学习,互相鼓励

文档信息

Search

    Table of Contents