深入vue笔记-从官方文档开始体验 - Shiina's Blog

深入vue笔记-从官方文档开始体验

2023 年 3 月 8 日 - 04:32:35 发布
5.8K 字 20分钟
本作品采用 CC BY-NC-SA 4.0 进行许可。
本文距离首次发布已经过去了 430 天,请注意文章的时效性!

深入vue笔记-从官方文档开始体验

最近这几天笔者开始研究 vue 源码了,因为暂时没有大项目要做,一时间脑子也没有什么好的 idea,就研究来玩玩,反正以后迟早要看,不如早点看看有个思路体验

说实话,看了之后才明白 vue3 为什么说相比 vue2 会有更好的性能提升,光是从官方文档下的渲染机制就可以感受得出来,也印证了之前有的人说:vue 在很多底层地方都帮你处理好了,不需要你手动去优化,比较省心

但相对的,vue 这样子也缺少了一些灵活性,应对一些大型业务场景可能更加乏力,需要往底层方向走一下,利用一些底层 api 去实现需求

接下来开始正式的解读

虚拟 DOM

虚拟 DOM 的解释:本质上是一种模式,由 react 官方提出的,他们在不同的框架中均有不同的实现,所以说这不是一种技术,而是一种模式

虚拟 DOM 好比一种数据结构,将真实 DOM 进行抽象,比如转化为 js 对象,并在对象上添加属性来完善这个虚拟 DOM 的信息,使其身上的信息更贴合真实 DOM

接下来展示一个 vue 中的虚拟 DOM 节点:

const vnode = {
  type: 'div',
  props: {
    id: 'hello'
  },
  children: [
    // ...other vnode
  ]
}

通过上面的例子不难看出:这是一个 div 节点的抽象表达,其中 id 是 hello,子节点是一个数组,存放其他的 vnode

因此我们可以很容易联想到真实 DOM 长这样:

<div id="hello"></div>

vue 底层就是通过 vnode + render 函数将这种虚拟节点转化为真实 DOM,并在 children 下放置更多的 vnode,以此形成一个树形结构,其 app 是根实例

当我们首次在网页中通过渲染器渲染 vnode ,并构建真实 DOM 树的这个过程,被叫做 mount,即挂载的意思

当数据或者依赖发生更新,我们获得了一份新的 vnode 时,渲染器会进行深度递归遍历,对所有的虚拟节点进行比较,找出区别并进行更新,这个过程被称之为 patch,即补丁,或者称作更新

这样操作最大程度的避免了开发者需要手动操作真实 DOM 的需要,使得开发者能更专注于内部业务逻辑的完成

渲染管线

一个 vue 实例或者组件挂载时可以看作以下 3 个流程:

  • 编译 将你的 vue 单文件组件内的模板编译为渲染函数,这一步可以在脚手架中提前完成,也可以在运行时完成
  • 挂载 在页面中调用渲染函数,并根据虚拟 DOM 树生成真实 DOM
  • 更新 当数据发生变化并被监听到后,会执行重新渲染的函数,这个时候会生成一份新的虚拟 DOM 树,同时渲染器对新旧 DOM 树进行对比,然后将需要更新的地方应用到真实 DOM 中 这种更新的粒度级别是组件级别的,具体实现需要到源码中查看

模板与渲染函数

平时开发时我们使用的都是模板,只有少数场景才需要使用渲染函数去实现,如果你不知道渲染函数是什么,可以点击 这里 进行了解

文档中官方推荐使用模板去描述真实 DOM,在看理由之前可以自己先尝试着想一想为什么模板比直接使用渲染函数好?

官方理由如下:

  • 模板比渲染函数更直接,更贴近真实 DOM 与开发逻辑,并且更容易理解业务逻辑
  • 由于模板是静态的,vue 的模板编译器更容易对其进行静态分析,这样也就意味着可以通过编译器找到更多的优化点,实现实际开发中的性能提升

当然这也意味着牺牲了一些灵活性,笔者在此写一些自己遇到的情况:

  • v-ifv-for 不能同时使用,需要通过 <template> 去使用内部指令来进行处理,而使用 jsx 可以直接写完,更直观一点

当然 vue 也支持 jsx 的使用,文档:https://cn.vuejs.org/guide/extras/render-function.html

通过编译时预处理提升页面性能

预处理静态模板

如果你使用 react,你可能知道 react 在没有使用 useMemo 等优化 API 时,更新一个组件的时机往往是指定的数据发生变化后就立即触发的,因为渲染器不知道到底是哪个虚拟节点发生了变化,所以必须从头开始对比每个虚拟 DOM 节点,这样就会进行大量不必要的对比,比如静态节点,他没有动态插值,所以值是一直不变的,但是也被列入虚拟节点对比了,带来了不必要的性能消耗

可以看看鱼皮写的这篇文章中的静态模块部分:https://mp.weixin.qq.com/s/oLo6vJDCjcx10QYSXqUUXA

或者查看这个官方用例:https://template-explorer.vuejs.org/#eyJzcmMiOiI8ZGl2PlxuICA8ZGl2PmZvbzwvZGl2PiA8IS0tIGhvaXN0ZWQgLS0+XG4gIDxkaXY+YmFyPC9kaXY+IDwhLS0gaG9pc3RlZCAtLT5cbiAgPGRpdj57eyBkeW5hbWljIH19PC9kaXY+XG4gIDxkaXY+e3toaWhpfX08L2Rpdj5cbjwvZGl2PlxuIiwib3B0aW9ucyI6eyJob2lzdFN0YXRpYyI6dHJ1ZX19

接下来我们对这个官方用例进行解释:

以下是一个单文件组件的模板部分:

<template>
  <div>
    <div>foo</div> <!-- hoisted -->
    <div>bar</div> <!-- hoisted -->
    <div>{{ dynamic }}</div>
    <div>{{hihi}}</div>
  </div>
</template>

以下是上面的模板编译出来的渲染函数:

import { createElementVNode as _createElementVNode, createCommentVNode as _createCommentVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

// 两个静态的节点被发现并打上了标记
const _hoisted_1 = /*#__PURE__*/_createElementVNode("div", null, "foo", -1 /* HOISTED */)
const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", null, "bar", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _hoisted_1,
    _createCommentVNode(" hoisted "),
    _hoisted_2,
    _createCommentVNode(" hoisted "),
    _createElementVNode("div", null, _toDisplayString(_ctx.dynamic), 1 /* TEXT */),
    _createElementVNode("div", null, _toDisplayString(_ctx.hihi), 1 /* TEXT */)
  ]))
}

可以注意到,编译器编译模板时注意到 foobar 两部分是静态的,于是将他们独立到渲染函数外,这样就不需要在每次生成静态节点时重新构造新的虚拟 DOM,减少性能消耗

这里笔者有点好奇的就是注释节点为什么是直接生成,弄个静态节点也应该没问题吧,可能是对性能影响微乎其微和没必要才这么做的,经过实际测试 Text 节点也是每次渲染时生成,尽管他是静态的

还有一个部分就是:当连续静态节点足够多时,他们会被压缩成一个静态 vnode,里面是这些节点的纯 HTML 字符串,挂载时直接通过 innerHTML 进行挂载,同时也会进行节点缓存,而不是再通过 innerHTML 挂载,内部会将这部分节点分配为 Static 类型

节点类型标记

令我比较震撼的是不仅仅是静态节点有包含分析,部分具有动态插值的节点同样也有静态分析:

<template>
  <!-- 仅含 class 绑定 -->
  <div :class="{ active }"></div>

  <!-- 仅含 id 和 value 绑定 -->
  <input :id="id" :value="value">

  <!-- 仅含文本子节点 -->
  <div>{{ dynamic }}</div>
</template>

以上三个节点 vue 的编译器均可分析出来他们单独的变化地方,不需要进行完全对比:

// 编译后的节点
createElementVNode("div", {
  class: _normalizeClass({ active: _ctx.active })
}, null, 2 /* CLASS */)

最后面的 2 是 vue 内部的一个更新类型标记,比如 class 的更新类型是 2,在运行时渲染器也会通过位运算来检查被标记的元素依赖的数据是否有更新,并更新相应的 DOM

官方示例:https://template-explorer.vuejs.org/#eyJzcmMiOiI8ZGl2IDpjbGFzcz1cInsgYWN0aXZlIH1cIj48L2Rpdj5cblxuPGlucHV0IDppZD1cImlkXCIgOnZhbHVlPVwidmFsdWVcIj5cblxuPGRpdj57eyBkeW5hbWljIH19PC9kaXY+Iiwib3B0aW9ucyI6e319

位运算是 js 语言的底层操作,其速度比一般的等于号判断快得多

不仅仅元素有这种 patchFlag,vnode 本身也有,比如 Fragment,大多情况下 Fragment 的子元素顺序都是不变的,这样我们就可以通过类型标记来对 Fragment 组件跳过元素顺序协调步骤

树结构打平

另外一个让我感到比较震撼的地方就是树结构打平,简单来说就是将虚拟 DOM 内包含了动态数据节点的部分提取出来,放入一个数组中;当这个组件需要重新渲染时,只需要遍历这个数组,对其进行更新,而非遍历整颗树,这大大减少了我们在虚拟 DOM 协调时需要遍历的节点数量。模板中任何的静态部分都会被高效地略过。

示例:

<template>
  <div> <!-- root block -->
    <div>...</div>         <!-- 不会追踪 -->
    <div :id="id"></div>   <!-- 要追踪 -->
    <div>                  <!-- 不会追踪 -->
      <div>{{ bar }}</div> <!-- 要追踪 -->
    </div>
  </div>
</template>

可以想象以下被打平的数组

div (root)
- div :id
- div {{ bar }}

这代表真正意义上实现了只对具有动态插值的元素进行更新的目标

此时更新一个组件所需要消耗的性能取决于这个模板内有多少动态插值的元素,而不是像 React 由整个组件的大小进行决定

总结

笔者在写这篇文章之前的时候就开始学习 vue 源码了,目前的进度是自己手搓了一个 runtime-dom 部分,他可以通过类似 vnode 的结构实现 原生 + 文本 节点的挂载与更新,但优化部分还是使用的 full diff 算法,即每个节点都会进行完全对比,就跟 vue2 和 React 一样,更新性能消耗取决于组件的大小,更小粒度级别的还需要长时间学习才能实现,慢慢来吧,也不急着找工作养活自己,看看这些还是挺有意思的

看完整个渲染机制部分,最震撼到我的还是其中的更新类型标记与树结构打平部分,之前曾经浅浅的猜想 vue 对静态节点会做优化,但没想到的是不仅优化了,而且优化程度到了 attribute 级别的地步;动态插值部分也是用了笔者从没想到过的优化模式,让人十分甚至九分惊喜。

下一篇博客的话会写一下自己手写 vue 渲染虚拟 DOM 部分的心得与体会,同时也会大概讲解一下从构建虚拟节点到在页面中渲染部分的简化流程

个人信息
avatar
Shiinafan
文章
38
标签
52
归档
4
导航