权衡的艺术

命令式和声明式

视图层框架通常分为命令式和声明式。命令式框架的特点是关注过程;声明式框架更加关注结果。比如要获取id为app的div,它的内容为hello world,为它绑定点击事件,点击时弹出提示ok。

  • 用原生js实现:
    1. const div = document.queryselector('#app');
    2. div.innerText = 'hello world';
    3. div.onclick = function() {
    4. alert('ok')
    5. }
  • vue实现:
    1. <div @click="() => alert('ok')">
    2. hello world
    3. </div>

    vue帮我们实现并封装了结果的过程。vue内部一定是命令式的,而暴露给用户的却更加声明式。

性能与可维护性的平衡

声明式代码的性能不优于命令式代码的性能。

以上面例子为例,假设我们现在要将div标签文本内容改为hello vue3,用命令式实现:

  1. div.textContent = 'hello vue3'

没有其他办法比上面这句代码的性能更好。理论上命令式代码可以做到极致的性能优化,因为我们明确知道哪些发生了变更,只做必要的修改就行了。但是声明式代码不一定能做到这一点,因为它描述的是结果:

  1. <div @click="() => alert('ok')">hello vue3</div>

对于框架来说,为了实现最优的更新性能,它要找到前后的差异并只更新变化的地方,但最终完成这次更新的代码仍然是上面命令式的。所以声明式代码的更新性能是直接修改的性能消耗(命令式的)+找出差异的性能消耗。

所以框架应该在保持可维护性的同时让性能损失最小。

虚拟DOM的性能到底如何

上面说到如果我们能让找出差异的性能消耗最小,就可以让声明式的代码无限接近命令式代码的性能。而虚拟DOM,就是为了最小化找出差异的性能消耗而出现的。

采用虚拟DOM的更新技术理论上不可能比原生JS操作DOM更高(比如document.createElement之类的DOM操作方法),除了innerHTML:

  • innerHTML。为了创建页面,我们需要构造一段HTML字符串:

    1. const html = `
    2. <div><span>...</span><div>
    3. `


    接着将该字符串赋值给DOM元素的innerHTML属性:
    然而这句话没有看上去这么简单。为了渲染出页面,首先要把字符串解析成DOM树。这是一个DOM层面的计算。涉及DOM的运算要远比JS层面的计算性能差。
    创建页面时的性能:HTML字符串拼接的计算量+innerHTML的DOM计算量。

  • 虚拟DOM。虚拟DOM创建页面的过程分为两步:第一步是创建JS对象,这个对象可理解为真实DOM的描述;第二步是递归地遍历虚拟DOM树并创建真实DOM。
    创建页面时的性能:创建JS对象的计算量+创建真实DOM的计算量。

其实在创建时两者差异并不大,都需要创建所有DOM元素,但在更新时的性能却不同。

  • innerHTML更新页面的过程是重新构建HTML字符串,再重新设置DOM元素的innerHTML属性。等价于销毁所有旧的DOM,再全量创建新的DOM元素。
  • 虚拟DOM需要重新创建JS对象(虚拟DOM树),然后比较新旧虚拟DOM,找到变化的元素并更新它。

所以更新页面时,虚拟DOM在JS层面的运算要比创建页面时多出一个diff的性能消耗,但也只是JS层面的运算;但对比DOM层面的运算,虚拟DOM的优势就体现出来了。

运行时和编译时

运行时的框架:假设我们设计了一个框架,它提供一个Render函数,用户可以为该函数提供一个树型结构的数据对象,然后函数会根据对象递归地将数据渲染成DOM元素。我们规定树型结构的数据对象如下:

  1. const obj = {
  2. tag:'div',
  3. children:[
  4. {
  5. tag:'span',
  6. children:'hello world'
  7. }
  8. ]
  9. }

实现Render函数:

  1. function Render(obj, root) {
  2. const el = document.createElement(obj.tag)
  3. if(typeof obj.children === 'string') {
  4. const text = document.createTextNode(obj.children)
  5. el.appendChild(text)
  6. } else if(obj.children) {
  7. //数组,递归调用Render
  8. obj.children.forEach((child) => Render(child, el))
  9. }
  10. root.appendChild(el)
  11. }

可以用Render(obj,document.body)来使用它。

以上就是一个纯运行时的框架,需要手写树型结构,很麻烦。

编译时的框架:为解决运行时框架的问题,引入编译的手段,把HTML标签编译成树型结构的数据对象。为此,创建一个Compiler的函数给用户使用,用户需要分别调用这两个函数:

  1. const html = `
  2. <div><span>hello world</span><div>
  3. `
  4. const obj = Compiler(html)
  5. Render(obj,document.body)

这时就是一个运行时+编译时的框架。既支持运行时又支持编译时。准确地说是运行时编译,意思是代码运行的时候才开始编译,这会产生一定的性能开销,因此我们也可以在构建的时候就执行Compiler将用户提供的内容编译好,运行时就无需编译了。

既然编译器可以把HTML字符串编译成数据对象,那么能不能直接编译成命令式代码呢?这样我们只需要实现一个Compiler函数就可以了,连Render都不需要。这样就是一个纯编译时框架。

对比:纯运行时的框架没有编译的过程,因此我们没法分析用户提供的内容,但如果加入编译步骤,我们就可以分析内容,哪些未来会变化或不变,然后将其传递给Render函数进一步优化。纯编译时的做法有损灵活性。

框架设计的核心要素

提升用户的开发体验

当我们创建一个vue.js应用并试图挂载到一个不存在的DOM节点时,会收到一条警告信息:

  1. createApp(App).mount('#not-exist')

image.png

在vue的源码中,我们经常看到warn函数的调用,比如上图信息就是由下面的调用打印的:

  1. warn(
  2. `Failed to mount app: mount target selector "${container}" returned null.`
  3. )

除了提供必要的警告信息外,还有很多地方可以作为切入口来进一步提升用户开发体验。比如当我们在控制台打印一个ref数据:

  1. const count = ref(0)
  2. console.log(count)

image.png

打印并不直观,当然我们可以选择直接打印count.value的值,这样只会输出0。那还有什么办法在打印时让输出的信息更友好呢?浏览器允许我们编写自定义的formatter,从而自定义输出类型。在vue3源码中可以搜索到initCustomFormatter的函数,该函数就是用来在开发环境下初始化自定义formatter的。以Chrome为例,打开DevTools的设置,然后勾选Console->Enable custom formatters。

image.png

控制框架代码的体积

在实现同样功能的情况下,用的代码越少越好,浏览器加载资源的时间也越少。vue3源码里每一个warn函数的调用都会配合DEV常量的检查,例如:

  1. if(__DEV__ && !res) {
  2. warn(
  3. `Failed to mount app: mount target selector "${container}" returned null.`
  4. )
  5. }

可以看到DEV一定要为true。

vue使用rollup.js对项目进行构建,这里的DEV常量实际上是通过rollup的插件配置来预定义的,其功能类似于webpack的DefinePlugin插件。

vue在输出资源的时候会输出两个版本,一个用于开发环境,如vue.global.js,另一个是生产版本,如vue.global.prod.js。

当vue构建用于开发环境的资源时,会把DEV常量设置为true,构建生产环境的资源时会设置为false,warn永远不会执行,这段代码称为dead code,它不会出现在最终产物中,在构建资源的时候会被移除。这样我们就做到了在开发环境中为用户提供友好的警告信息的同时不会增加生产环境代码的体积。

做到良好的Tree-Shaking

vue内建了很多组件,比如组件,如果我们项目中没有用到该组件,那么它的代码就不需要包含在项目最终的构建资源中,要用到Tree-Shaking。它指的是消除那些永远不会被执行的代码。想要实现它必须满足一个条件:模块必须是ES Module,因为它依赖ESM的静态结构。我们以rollup.js为例看看Tree-Shaking如何工作的:

新建两个文件,然后安装rollup :npm install rollup -D。

  1. //input.js
  2. import {foo} from './utils.js'
  3. foo()
  4. //utils.js
  5. export function foo(obj) {
  6. obj && obj.foo
  7. }
  8. export function bar(obj) {
  9. obj && obj.bar
  10. }

然后执行命令构建:npx rollup input.js -f esm -o bundle.js。意思是以input文件为入口,输出ESM,输出的文件叫bundle.js,内容如下:

  1. //bundle.js
  2. function foo(obj) {
  3. obj && obj.foo
  4. }
  5. foo();

并没有包含bar函数。foo函数的执行并没有什么意义,仅仅是读取了对象的值,所以它的执行似乎没什么必要。既然把这段代码删了也不会对应用程序产生影响,那么为啥rollup不把这段代码作为dead code删除呢?这就涉及Tree-Shaking第二个关键点——副作用。如果一个函数调用会产生副作用,那么就不能将其移除。什么是副作用?简单来说就是当调用函数的时候会对外部产生影响,例如修改了全局变量。上面的代码只是读取对象的值,为啥会有副作用呢?其实是有可能的,如果obj对象是一个通过Proxy创建的代理对象,那么当我们读取对象属性时,就会触发代理对象的get,在get方法中可能产生副作用的,比如我们在get中修改了某个全局变量。而到底会不会产生副作用,只有代码真正运行的时候才知道。

rollup提供了一个机制,让我们明确告诉它某段代码不会产生副作用,可以移除。

  1. //修改input.js
  2. import {foo} from './utils.js'
  3. /*#__PURE__*/ foo()
  4. foo()

此时查看bundle就会发现内容是空的。

框架应该输出怎样的构建产物

不同类型的产物一定有对应的需求背景。我们希望用户直接在HTML页面中使用script标签引入框架并使用,为实现这个需求我们需要输出IIFE格式的资源,即立即调用的函数表达式。vue.global.js就是:

  1. var Vue = (function(exports) {
  2. //...
  3. exports.createApp = createApp;
  4. //...
  5. return exports
  6. })({})

这样当我们使用script标签直接引入vue.global.js后,全局变量Vue就可用了。

在rollup中可以配置format:’iife’输出这种形式的资源:

  1. //rollup.config.js
  2. const config = {
  3. input: 'input.js',
  4. output : {
  5. file:'output.js',
  6. format:'iife'
  7. }
  8. }
  9. export default config

随着技术的发展和浏览器的支持,对原生ESM的支持都不错,所以用户可以直接引入ESM格式的资源,例如vue3还会输出vue.esm-browser.js文件,用户可以直接用