vue3 全面深入原理讲解

前言

这篇文章的大部分内容成于去年8月。当时自己对 vue3 非常感兴趣,才有了这些整理与探索。其实内容放到今天也完全不过时,甚至在掘金上也很少看到有人写这些偏原理的东西,全都在讨论API。

自己最近在整理前端知识图谱,想到之前有过Vue3相关的整理,便拿了出来(比较懒,原封不动拿出来的,后续会整理整理,添加一些解释性的内容)

为什么要升级到Vue3

  1. 更小
  • 核心代码 + Composition Api: 13.5kb(vue2为 31.94kb)
  • 所有Runtime: 22.5kb(vue2 为32kb)
  1. 更快
  • SSR速度提高了2~3倍
  • 初始渲染/更新最高可提速一倍
  1. 更优

image.png

  • update性能提高1.3~2倍
  • 内存占用减小了一半
  1. 更易
  • 更好的TypeScript支持
  • 更多友好特性和检测
  • ......

Vue3有哪些新特性

  1. Tree-shaking支持 ( 按需加载 )
  2. 静态树提升
  3. 静态属性提升
  4. 虚拟 DOM 重构
  5. 插槽优化
  6. Suspense、Fragment、Teleport
  7. 支持TS ( 原生Class Api 和 TSX )
  8. 基于 Proxy 的新数据监听系统(Composition API)
  9. 自定义渲染平台(Custom Render)

......

按需加载

非常用功能可以按需加载,比如:v-model, Transition等

<div>{{ msg }}</div>
<input v-model="msg" />

//编译后代码
import { toDisplayString as _toDisplayString, createVNode as _createVNode, vModelText as _vModelText, withDirectives as _withDirectives, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createBlock(_Fragment, null, [
    _createVNode("div", null, _toDisplayString(_ctx.msg), 1 /* TEXT */),
    _withDirectives(_createVNode("input", {
      "onUpdate:modelValue": _cache[1] || (_cache[1] = $event => (_ctx.msg = $event))
    }, null, 512 /* NEED_PATCH */), [
      [_vModelText, _ctx.msg]
    ])
  ], 64 /* STABLE_FRAGMENT */))
}
复制代码
// 在 Vue2 中,初始化一个应用
import Vue from 'vue'
import App from './App'
import router from './router'
import store from './store'

const app = new Vue({
  router,
  store
  render: h => h(App)
})
app.$mount('#app')


// 在 Vue3 中,初始化一个应用
import { createApp } from 'vue'
import App from './app'
import router from './router'
import store from './store'

createApp(App).use(router).use(store).mount('#app')
复制代码

Vue2 中通过 new 一个 Vue 实例初始化,而 Vue3 通过链式调用来创建。这样可以去做tree-shaking,不需要的模块则不打包进去。而通过对象创建时webpack是无法处理动态语言对象上的属性的,而且也无法对这些属性进行优化,比如通过uglify来缩短属性名称

静态提升

<div>{{ msg }}</div>
<div class="msg2">{{ msg2 }}</div>
<div class="msg3">msg3</div>

//编译后代码
import { toDisplayString as _toDisplayString, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock } from "vue"

const _hoisted_1 = { class: "msg2" }
const _hoisted_2 = /*#__PURE__*/_createVNode("div", { class: "msg3" }, "msg3", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createBlock(_Fragment, null, [
    _createVNode("div", null, _toDisplayString(_ctx.msg), 1 /* TEXT */),
    _createVNode("div", _hoisted_1, _toDisplayString(_ctx.msg2), 1 /* TEXT */),
    _hoisted_2
  ], 64 /* STABLE_FRAGMENT */))
}
复制代码
<span>hello vue3</span>
<span>hello vue3</span>
<span>hello vue3</span>
<span>hello vue3</span>
<span>hello vue3</span>
<span>hello vue3</span>
<span>hello vue3</span>
<span>hello vue3</span>
<span>hello vue3</span>
<span>hello vue3</span>
<div>{{msg}}</div>

//编译后代码
import { createVNode as _createVNode, toDisplayString as _toDisplayString, createStaticVNode as _createStaticVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock } from "vue"

const _hoisted_1 = /*#__PURE__*/_createStaticVNode("<span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span>", 10)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createBlock(_Fragment, null, [
    _hoisted_1,
    _createVNode("div", null, _toDisplayString(_ctx.msg), 1 /* TEXT */)
  ], 64 /* STABLE_FRAGMENT */))
}
复制代码

Cache Handler

<div :class="msg1">{{ msg }}</div>
<div class="msg2">{{ msg2 }}</div>
<div class="msg3" @click="msgClickHandler">msg3</div>

//编译后代码
import { toDisplayString as _toDisplayString, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock } from "vue"

const _hoisted_1 = { class: "msg2" }

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createBlock(_Fragment, null, [
    _createVNode("div", { class: _ctx.msg1 }, _toDisplayString(_ctx.msg), 3 /* TEXT, CLASS */),
    _createVNode("div", _hoisted_1, _toDisplayString(_ctx.msg2), 1 /* TEXT */),
    _createVNode("div", {
      class: "msg3",
      onClick: _cache[1] || (_cache[1] = (...args) => (_ctx.msgClickHandler(...args)))
    }, "msg3")
  ], 64 /* STABLE_FRAGMENT */))
}
复制代码

在 Vue2 中,每次更新,render函数跑完之后 vnode绑定的事件都是一个全新生成的function,就算它们内部的代码是一样的
而在Vue3中传入的事件会自动生成并缓存一个内联函数在cache里,变为一个静态节点。这样就算我们自己写内联函数,也不会导致多余的重复渲染。类似于React中的useCallback()

享元模式:主要用于减少创建对象的数量,以减少内存占用和提高性能。这种类型的设计模式属于结构型模式,它提供了减少对象数量从而改善应用所需的对象结构的方式。享元模式尝试重用现有的同类对象,如果未找到匹配的对象,则创建新对象。

Patch Flag

<div :class="msg1" :id="msg1">{{ msg }}</div>
<div class="msg2">{{ msg2 }}</div>
<div class="msg3">msg3</div>

//编译后代码
import { toDisplayString as _toDisplayString, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock } from "vue"

const _hoisted_1 = { class: "msg2" }
const _hoisted_2 = /*#__PURE__*/_createVNode("div", { class: "msg3" }, "msg3", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createBlock(_Fragment, null, [
    _createVNode("div", {
      class: _ctx.msg1,
      id: _ctx.msg1
    }, _toDisplayString(_ctx.msg), 11 /* TEXT, CLASS, PROPS */, ["id"]),
    _createVNode("div", _hoisted_1, _toDisplayString(_ctx.msg2), 1 /* TEXT */),
    _hoisted_2
  ], 64 /* STABLE_FRAGMENT */))
}
复制代码
  const PatchFlagNames = {
    // 表示具有动态 textContent 的元素
    [1 /* TEXT */]: `TEXT`,
    
    // 表示有动态 class 的元素
    [2 /* CLASS */]: `CLASS`,
    
    // 表示动态样式
    [4 /* STYLE */]: `STYLE`,
    
    // 表示具有非类/样式动态道具的元素。
    [8 /* PROPS */]: `PROPS`,
    
    // 表示带有动态键的道具的元素,与上面三种相斥
    [16 /* FULL_PROPS */]: `FULL_PROPS`,
    
    // 表示带有事件监听器的元素
    [32 /* HYDRATE_EVENTS */]: `HYDRATE_EVENTS`,
    
    // 表示其子顺序不变的片段 
    [64 /* STABLE_FRAGMENT */]: `STABLE_FRAGMENT`,
    
    // 表示带有键控或部分键控子元素的片段。
    [128 /* KEYED_FRAGMENT */]: `KEYED_FRAGMENT`,
    
    // 表示带有无key绑定的片段
    [256 /* UNKEYED_FRAGMENT */]: `UNKEYED_FRAGMENT`,
    
    // 表示具有动态插槽的元素
    [1024 /* DYNAMIC_SLOTS */]: `DYNAMIC_SLOTS`,
    
    // 表示只需要非属性补丁的元素,例如ref或hooks
    [512 /* NEED_PATCH */]: `NEED_PATCH`,
    
    [-1 /* HOISTED */]: `HOISTED`,
    [-2 /* BAIL */]: `BAIL`
  };
复制代码

有多种 patchFlag 时进行叠加。TEXT=1, CLASS=2, PROPS=8,得到11;
之后patch函数拿到flag后,通过分别和1,2,4,8按位与,最后结果不为 0 表示含有该动态属性。

000000001  1 text 
000000010  2 class
000000100  4 style
000001000  8 props

000001011  11

11 & 1  //true
11 & 2  //true
11 & 4  //false
11 & 8  //true
复制代码

image.png

与 React 对比

image.png React走了另外一条路,既然主要问题是diff导致卡顿,于是React走了类似 cpu 调度的逻辑,把vdom这棵树微观变成了链表,利用浏览器的空闲时间来做diff,如果超过了16ms,有动画或者用户交互的任务,就把主进程控制权还给浏览器,等空闲了继续。实际上是在之前用不上的时间里做了diff操作。

时间切片

浏览器每间隔一定的时间重新绘制一下当前页面。一般来说这个频率是每秒60次。也就是说每16毫秒浏览器会有一个周期性地重绘行为,这每16毫秒我们称为一帧。这一帧的时间里面浏览器的主要工作有:

  1. 执行JS
  2. 计算Style
  3. 构建布局模型(Layout)
  4. 绘制图层样式(Paint)
  5. 组合计算渲染呈现结果(Composite)

如果这六个步骤总时间超过 16ms 了之后,用户也许就能看到卡顿。如果任务不能在50毫秒内执行完,那么为了不阻塞主线程,这个任务应该让出主线程的控制权,使浏览器可以处理其他任务,随后再回来继续执行没有执行完的任务。

image.png

Vue3放弃了时间切片支持

image.png

React为何支持
  • React的虚拟DOM操作(reconciliation )天生就比较慢
  • React使用JSX来渲染函数相对较于用模板来渲染更加难以优化,模板更易于静态分析。
  • React Hooks将大部分组件树级优化(即防止不必要的子组件的重新渲染)留给了开发人员,一个使用Hook的React应用在默认配置下会过度渲染
Vue3 为何放弃
  • 相比 React 本质上更简单,因此虚拟DOM操作更快
  • 通过分析模板进行了大量的运行前编译优化,减少了虚拟 DOM 操作的基本开销。Benchmark显示,对于一个典型的DOM代码块来说,动态与静态内容的比例大约是1:4,Vue3的原生执行速度甚至比Svelte更快,在CPU上花费的时间不到 React 的 1/10,而只有cpu 任务繁重时时间切片才有意义。
  • 智能组件树级优化通过响应式跟踪,将插槽编译成函数(避免子元素重复渲染)和自动缓存内联句柄(避免内联函数重复渲染)。除非必要,否则子组件永远不需要重新渲染。这一切不需要开发人员进行任何手动优化。
  • 时间切片增加了额外的复杂性,Vue 3的运行时仍然只有当前 React + React DOM 的1/4大小
  • Vue3通过 Proxy 响应式 + 组件内部 vdom + 静态标记,把任务颗粒度控制的足够细致,所以也不太需要 time-slice了
  • 时间切片特别解决了 React 中比其他框架更突出的问题,同时也带来了成本。对于Vue 3来说,这种权衡似乎是不值得的

插槽优化

在vue2中,当父组件数据更新的时候执行会触发重新渲染,最终执行父组件的 patch,在 patch 过程中,遇到组件 vnode,会执行新旧 vnodeprepatch,这个过程又会执行 updateChildComponent, 如果这个子组件 vnode 有插槽,会重新执行一次子组件的 forceUpdate(),这种情况下会触发子组件的重新渲染。简单来说,当父组件更新时,插槽会被重新渲染。vue3对这种场景进行了优化。

在Vue3中,所有由编译器生成的 slot 都将是函数形式,并且在子组件的 render 函数被调用过程中才被调用。这使得 slot 中的依赖项 将被作为子组件的依赖项,而不是现在的父组件;从而意味着:1)当 slot 的内容发生变动时,只有子组件会被重新渲染;2)当父组件重新渲染时,如果子组件的内容未发生变动,子组件就没必要重新渲染。

  1. 静态编译时,给一个Component打上一个PatchFlag标记---是否是DynamicSlot
  2. 遇到有传入slot的组件,它的Children不是普通的vnode数组,而是一个slot function的映射表,这些slot function用于在组件中懒生成slot中的vnodes
  3. 在子组件的render函数里面,调用相应的slot生成函数,因此这个slot函数里面的属性都会被当前的组件实例所track

Suspense

一个异步加载组件,抄自React
它可以在嵌套的组件树渲染到屏幕上之前,在内存中进行渲染,可以检测整颗树里面的异步依赖,只有当将整颗树的异步依赖都渲染完成之后,也就是resolve之后,才会将组件树渲染到屏幕上去。

<Suspense> is an experimental feature and its API will likely change.
复制代码

Fragment

抄自React
自动在template中增加一层虚拟节点,不再需要用根元素进行包裹

<div>{{ msg }}</div>
<div>{{ msg2 }}</div>

//编译后代码
import { toDisplayString as _toDisplayString, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createBlock(_Fragment, null, [
    _createVNode("div", null, _toDisplayString(_ctx.msg), 1 /* TEXT */),
    _createVNode("div", null, _toDisplayString(_ctx.msg2), 1 /* TEXT */)
  ], 64 /* STABLE_FRAGMENT */))
}
复制代码

Teleport

是一个全局组件,抄自React中的Portal
提供了一种将子节点渲染到存在于父组件以外的 DOM 节点的优秀的方案。可以应用在弹窗等需要挂载到全局的组件。

    <button @click="modalOpen = true">
        Open full screen modal! (With teleport!)
    </button>

    <teleport to="body">
      <div v-if="modalOpen" class="modal">
        <div>
          I'm a teleported modal! My parent is "body".
          <button @click="modalOpen = false">
            Close
          </button>
        </div>
      </div>
    </teleport>
复制代码

Composition API

命令式编程 --> 函数式编程

  • 更好的逻辑复用与代码组织
  • 更好的类型推导

弃用this后通过函数式的调用方式来支持Typescript image

mixin的问题:
  • 命名冲突

Vue组件的默认合并策略是本地选项将覆盖mixin选项(生命周期钩子除外)。在跨多个组件和mixin处理命名属性时,编写代码变得越来越困难。一旦第三方mixin作为带有自己命名属性的npm包被添加进来,就会特别困难,因为它们可能会导致冲突。

  • 来源不清晰

当有多层或多个mixin时,调用的属性来源不清晰

  • 隐式依赖

mixin和使用它的组件之间没有层次关系。这意味着组件可以使用mixin中定义的数据属性,但是mixin也可以使用假定在组件中定义的数据属性。如果想重构一个组件,改变了mixin需要的变量的名称,我们在看这个组件时,不会发现有什么问题。linter也不会发现它,我们只会在运行时看到错误。

mixin模式表面上看起来很安全。然而,通过合并对象来共享代码,由于它给代码增加了脆弱性,并且掩盖了推理功能的能力,因此成为一种反模式。Composition API 最聪明的部分是,它允许Vue依靠原生JavaScript中内置的保障措施来共享代码,比如将变量传递给函数和模块系统。

在大多数情况下,你坚持使用经典API是没有问题的。但是,如果你打算重用代码,Composition API无疑是优越的。

Vue2响应式实现: Object.defineProperty

简单来说就是拦截对象,给对象的属性增加setget

Object.defineProperty缺点:

  • 有时无法监听到数组的变化
  • 需要深度遍历,浪费内存
  • 对 Map、Set、WeakMap 和 WeakSet 的支持

Vue3响应式实现: Proxy

  • reactive 大致实现过程
const toProxy = new WeakMap(); // 存放被代理过的对象
const toRaw = new WeakMap(); // 存放已经代理过的对象

function reactive(target) {
    // 创建响应式对象
    return createReactiveObject(target);
}

function isObject(target) {
    return typeof target === "object" && target !== null;
}

function hasOwn(target,key){
    return target.hasOwnProperty(key);
}

function createReactiveObject(target) {
    if (!isObject(target)) {
        return target;
    }
    
    let observed = toProxy.get(target);
    if(observed){ // 判断是否被代理过
        return observed;
    }
    if(toRaw.has(target)){ // 判断是否要重复代理
        return target;
    }
    
    const handlers = {
        get(target, key, receiver) {
            // 取值
            let res = Reflect.get(target, key, receiver);
            track(target,'get',key); //依赖收集
            // 懒代理,只有当取值时再次做代理,vue2中一上来就会全部递归增加getter,setter
            return isObject(res) ? reactive(res) : res;
        },
        set(target, key, value, receiver) {
            let oldValue = target[key];
            let hadKey = hasOwn(target,key);
            let result = Reflect.set(target, key, value, receiver);
            if(!hadKey){
                trigger(target,'add',key); // 触发添加
            }else if(oldValue !== value){
                trigger(target,'set',key); // 触发修改
            }
            return result;
        },
        deleteProperty(target, key) {
            //...
            const result = Reflect.deleteProperty(target, key);
            return result;
        }
    };
    
    // 开始代理
    observed = new Proxy(target, handlers);
    toProxy.set(target,observed);
    toRaw.set(observed,target); // 做映射表
    return observed;
}
复制代码
  • effect的大致实现
const activeReactiveEffectStack = []; // 存放响应式effect

function effect(fn) {
   const effect = function() {
    // 响应式的effect
    return run(effect, fn);
  };
  effect(); // 先执行一次
  return effect;
}

function run(effect, fn) {
    try {
      activeReactiveEffectStack.push(effect);
      return fn(); // 先让fn执行,执行时会触发get方法,可以将effect存入对应的key属性
    } finally {
      activeReactiveEffectStack.pop(effect);
    }
}
复制代码
const targetMap = new WeakMap();
function track(target,type,key){
    // 查看是否有effect
    const effect = activeReactiveEffectStack[activeReactiveEffectStack.length-1];
    if(effect){
        let depsMap = targetMap.get(target);
        if(!depsMap){ // 不存在map
            targetMap.set(target,depsMap = new Map());
        }
        let dep = depsMap.get(target);
        if(!dep){ // 不存在则set
            depsMap.set(key,(dep = new Set()));
        }
        if(!dep.has(effect)){
            dep.add(effect); // 将effect添加到依赖中
        }
    }
}
复制代码
function trigger(target, type, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) {
    return;
  }
  let effects = depsMap.get(key);
  if (effects) {
    effects.forEach(effect => {
      effect();
    });
  }
  // 处理如果当前类型是增加属性,如果用到数组的length的effect应该也会被执行
  if (type === "add") {
    let effects = depsMap.get("length");
    if (effects) {
      effects.forEach(effect => {
        effect();
      });
    }
  }
}
复制代码

image.png

  • 默认为惰性监测。在 Vue2中,任何响应式数据都会在启动的时候被监测。如果数据量很大,在应用启动时,就可能造成可观的性能消耗。而在Vue3 中,只有应用的初始可见部分所用到的数据会被监测。
  • 更精准的变动通知。举个例子:在 Vue2 中,通过 Vue.set 强制添加一个新的属性,将导致所有依赖于这个对象的 watch 函数都会被执行一次;而在 Vue3 中,只有依赖于这个具体属性的 watch 函数会被通知到。
  • 不可变监测对象。我们可以创建一个对象的“不可变”版本,这种机制可以用来冻结传递到组件属性上的对象和处在 mutation 范围外的 Vuex 状态树。
  • 更良好的可调试能力。通过使用新增的 renderTrackedrenderTriggered 钩子,我们可以精确地追踪到一个组件发生重渲染的触发时机和完成时机,及其原因。

自定义渲染平台( Custom Render )

通过这个API理论上你可以自定义任意平台的渲染函数,把VNode渲染到不同的平台上,比如小程序;你可以对着@vue/runtime-dom复制一个@vue/runtime-miniprogram出来, 再比如游戏:@vue/runtime-canvas
这个 API 的到来,将使得那些如 WeexNativeScript 的“渲染为原生应用”的项目保持与 Vue 的同步更新变得更加容易。

一些讨论

  1. 现有的项目该升级吗
  • 新增的 Composition API 兼容 Vue2,只需要在项目中单独引入 @vue/composition-api 这个包就可以。
  • 2.x 的最后一个次要版本将成为 LTS,并在 3.0 发布后继续享受 18 个月的 bug 和安全修复更新。
  • 当前项目生态中的几个库都面临巨大升级,以及升级后的诸多坑要填,比如:vue-router、vuex、ElementUI/ViewUI/AntDesignVue 等
  1. element不更新后,组件库该怎么办

猜你喜欢

转载自juejin.im/post/7034417643532582926
今日推荐