vue的编译器,渲染器

0 阅读8分钟

虚拟DOM的性能,虚拟DOM是肯定要慢于原生DOM的,在初始的创建阶段,可以说虚拟DOM和原生DOM操作是一致的,js层面的vnode对象创建这些性能开销是可以忽略的,因为纯js操作层面要比dom操作快非常多, 在更新阶段就体现差异了,虚拟DOM会进行diff算法判断出应该更新的DOM, 这个时候就不如直接操作DOM了,因为虚拟DOM多了个找出前后差异的过程,但是在开发中更新dom元素的时候,会经常的时候到innerHTML, innerHTML可以很方便的在一个容器内渲染出大量DOM元素,方便程度是直接操作DOM插入做不到的,而innerHTML在更新的时候会先销毁所有的旧DOM,然后再新建所有的DOM元素,这就会导致很多没有必要更新的元素被强制更新了,基于dom要远远慢于js操作的结论,可以说innerHTML是不如虚拟DOM的,而虚拟DOM是不如直接操作DOM元素的,但是在操作难度上,直接操作DOM是最麻烦的,而vue声明式的语法是和innerHTML一样简单的,所以我们可以说,虚拟DOM是在性能和可维护性的衡量上能得到的最优解了,而且vue不是一个纯运行时框架,它可以在编译html模板的时候提供编译时信息给虚拟dom,这样性能上也能使得我们声明式语法接近命令式了

custom formatter

vue的响应式数据是基于Proxy的,而且vue在实现的过程也会加入自己的一些属性,所以console.log这些数据的时候就会多出很多无关的属性信息,好在浏览器提供了custom formatter api允许你自定义打印格式

联想截图_20260328142217.png

把这个定义格式化程序勾选上就可以使用vue提供的自定义格式器了, 在chrome浏览器里window.devtoolsFormatters用于存储所有的格式器对象数组,一开始是没有这个属性的,所以得自己赋个数组, window.devtoolsFormatters = [], 这个数组的每一项都是一个格式器对象,这个对象包含三个属性方法,header, hasbody, body, header就表示着打印出的对象内容, hasbody表示这个数据是否能展开,返回false,点击打印出的数据就没办法展开了,返回true,则会使用body返回的内容展示,除了hasbody, body和header都要求返回符合JSONML格式的数组,JSON就是拿符合JSON格式的数据描述html结构, 基于前面说的,我们可以在除了vue矿建定义好的格式外写一套我们自己的,请看下面的例子

控制框架体积

vue通过在esbuild配置中预设一些全局常量用于控制框架体积,类似于

if (__DEV__) {
  console.log("welcome to Vue");
}

要是构建用于开发的资源,DEV__就设置为true, 在开发阶段就能看到打印的内容了, 要是构建用于生产的资源,设置__DEV, if (false)被打包工具判断为dead code, 就会被tree shaking掉, 体积就下来了,tree shaking指的是小智那些永远不会被执行的代码,也就是排除dead code,可以看下面的例子, 使用rollup打包

export function foo(obj) {
  console.log("S");
  obj && obj.foo; 

}

export function bar(obj) {
  obj && obj.foo;
  console.log("S");
}
import { foo, bar } from "./utils.js";


foo();

打包出来的结果如下,

function foo(obj) {
  console.log("S");
}

foo();

可以看到bar作为dead code被tree shakding掉了,obj.foo是一个没有副作用的操作,也被筛掉了,然后是函数的参数obj, rollup关注点在模块级别,它看那些被导入了是没有被使用的,然后才筛选掉,也会对函数内部一些没有副作用的表达式筛掉,但是我们并没有去掉参数对吧,因为去了参数函数的length就改变了,外部依赖这个函数的length的代码就错误了,而且我们知道argument和函数的参数是绑定的,参数去掉了argument对象也会被影响,而且要是函数形参是两个,你去了一个,接口都不一样了,后面传递调用参数传递也会导致意外错误, 我们要是确保某段代码对项目运行没有影响,可以使用/PURE/强制tree shaking掉一些代码,

import { foo, bar } from "./utils.js";


/*#__PURE__*/foo();

这里输出的文件内容是空,根据上面的内容,我们可以知道tree shaking是一个好用的压缩项目体积的利器, Vue就利用预先给你设置全局常量的接口,比如某个特定的功能,要是不想用,可以使用vue提供的特定的全局常量把这个功能的代码置为dead code,从而被tree shaking掉, vue管这个叫特性开关,vue提供了一个名为__VUE_OPTIONS__API_的特性开关用于在于你判断项目不需要选项式语法的时候去除选项式语法兼容, 请看下面的一个例子

<script >
import HelloWorld from './components/HelloWorld.vue'

export default {
  components: {
    HelloWorld
  },
  data () {
    return  {
      data: 0
    }
  },
  methods: {
    addCount() {
      this.data += 1;
    }
  },
  computed: {
    showData() {
      return "结果时" + this.data;
    }
  }
}
</script>

<template>
<div>
  <div>{{ data }}</div>
  <span @click="addCount">+</span>
  <span>{{ showData }}</span>
<HelloWorld />

</div>
</template>

这个就是使用vite构建项目的时候产生的项目结构,可以看到我只是把app.vue的script改成选项式部分了,其它的我什么都没动, 我们直接执行pnpm/npm run build代码, 然后我们在vite.config.js中预设上面说的这个特性开关__VUE_OPTIONS_API__, 这个开关正常是支持选项式语法的,我们设置为false,

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vite.dev/config/
export default defineConfig({
  plugins: [vue()],
})

然后我们打包,可以看到前后项目体积如下图

联想截图_20260328161759.png

可以看到少了大约8KB的体积,这个体积是压缩过后了,但是就算压缩前估计也大不到哪里去,这也能说明vue文档说的选项式 API 是在组合式 API 的基础上实现的, 选项式的处理大概也只是把代码转换为组合式api然后运行,我们看看下面两个包的运行结果

联想截图_20260328162401.png

联想截图_20260328162425.png

尽管在控制台中没有报错,但这个效果也显然不是我们想要的

模板,渲染器, 编译器

vue内部是使用虚拟DOM来映射真实DOM的,一个虚拟DOM对象格式大致如下

const title = {
  tag: "h1",
  props: {
    onClick: handler
    },
   children: [
     {tag:"span"}
   ]
}

上面的代码对应在模板中写法为

<h1 @click="handler"><span></span></h1>

使用虚拟DOM对象描述结构比模板要更加灵活,举个例子, 在我们上面的tag起始也可以换成一个模板字符串形式如h${level}动态的去根据level决定标签名吧,换到模板要实现一样的功能就如下

<h1 v-if="level === 1"></h1>
<h2 v-if="level === 1"></h2>
<h3 v-if="level === 1"></h3>
<h4 v-if="level === 1"></h4>
<h5 v-if="level === 1"></h5>
<h6 v-if="level === 1"></h6>

正是因为手写模板的方式没有那么灵活,所以vue提供了h函数帮助你去使用js对象的形式描述组件, h函数的能提供一个友好的方式让开发者手写虚拟DOM,

const title = {
  render: {
    return h(`h${level}`, {onClick:handler);
  }
}

这个title对象可以直接导入作为组件对象使用, 可以看到这里是一个对象包含了一个render方法,vue会根据render方法的返回值拿到虚拟DOM,然后就可以把内容渲染出来了,

渲染器

渲染器用于把上面的虚拟DOM解析为实际的DOM元素插入页面, 看下面的例子

vue中的组件就是一个包含了render方法的对象,要求这个render方法调用返回虚拟DOM

编译器

前面我们说vue提供了h允许手写渲染函数,这种写法灵活度高,但是html结构一大,就不是很好懂了,而且书写难度也比较高,vue允许我们像写html那样声明式的描述ui, 但是渲染器只认虚拟DOM,所以我们需要把模板编译成虚拟DOM,

联想截图_20260328164511.png

可以看到我们template部分被编译成了一个render函数,对于这样的模板,我们可以很容易的分析出text并没有绑定任何变量对吧,每次组件更新的时候我们完全通知渲染器可以跳过这部分, 查看这一段,

_cache[1] || (_cache[1] = _createElementVNode("span", null, "text", -1 /* CACHED */))

我们可以知道这里每次执行的时候都是先判断是否在_cache中缓存了,如果缓存了就使用缓存的,-1 /* CACHED */是告诉渲染器, 这是一个静态虚拟节点。在组件更新的时候跳过这个节点,因为静态内容永远不需要更新, 其中我们可以看到 1 /TEXT/ , 表示这个节点是一个有动态textContent的元素,后面要是msg更新了,渲染器根据这个信息,也不用判断你是不是class更新,或v-bind属性更新什么的,知道一定是文本内容更新,直接定向地更新文本就好,性能自然就提升了,这些就是编译器提供给渲染器的编译时信息,可以看到,编译器和渲染器是一个有机的整体,你要是使用手写渲染函数的方式就没有这样的编译时信息了,渲染器也没办法优化,而且编译器不是只能接受一个这样的编译信息,你如动态文本是1, 其实应该说它是1 << 0, class是2 应该说它是1 << 1, STYLE是1 << 2一直到11,给你一个6, 可以很容易的判断它是00000110, 也就是 1<< 1, 和1 << 2,那不就说明这个节点是有动态class和动态style吗,所以在更新的时候,渲染器检索这两个位置就好了,其它的都是不变的

联想截图_20260328170539.png 可以看到这里的编译信息果然是6, CLASS, STYLE, 总之,我们根据上面可以知道,渲染器和编译器是一个有机的整体,编译器可以在编译的时候为渲染器提供信息帮助渲染器快速跳过一些没必要的检查,这使得vue使用虚拟DMOM+diff的性能接近原生DOM的修改