2022 新书《Vue.js 设计与实现》读书笔记

4,283 阅读11分钟

开始

《Vue.js 设计与实现》是 2022 年出版的新书,价格也比较贵,但物有所值,作者肯定是花费了大量的精力来编写。

标题是“设计和实现”,这本书讲的都是 Vue3 的原理,如何设计的,以及如何通过代码实现最基本的功能。所以,看这本书必须要先熟悉 Vue3 应用,否则看起来会很被动。

image.png

Vue3 的主要模块

从这本书的目录可以看出

  • 响应系统:监听可变数据,数据变化时触发回调函数
  • 渲染器:将 VDOM 挂载或更新为真实 DOM ,其中涉及到 diff 算法
  • 组件化:支持把一个大型系统拆分为若干组件,形成组件树
  • 编译器:把 Vue 模板编译为 JS 代码 (对应 React 中的 JSX)

知识点记录

命令式和声明式

  • 命令式 - 关注过程,例如使用 jQuery 进行 DOM 操作
  • 声明式 - 关注结果,例如 Vue 模板中绑定事件、使用插值和指令

前端开发中,无论是 jQuery Vue 还是 React ,其实都是两者的结合:用声明式去写 UI 配置,用命令式去做业务逻辑处理。

但 Vue 更倾向于声明式,这主要得益于它的响应式。定义好 data 和模板,直接修改 data 属性值即可,不用执行什么特殊的命令。反观 React 的 setState 就是命令式的,当然 JSX 是声明式的。

从原理上来讲,命令式的性能会更好,因为你可以直接操作基础 API ,简单粗暴。但声明式却更易于扩展和维护,而且性能也不会差。

为何使用 VDOM

同理,VDOM 的性能不会比直接操作 DOM 更好,越基础、越底层的 API 性能越好。

但 VDOM 结合 diff 算法可以在大量 DOM 更新时取得优势,因为此时如果直接进行 DOM 操作会带来极高的复杂度,开发难度很大,维护成本也很高。

响应式的基本设计思路

  • 使用 Proxy 监听数据属性的 get set
  • get 时记录 effectFn 到一个 WeakMap (按属性分别记录)—— 所以,想实现响应式,要先执行一次 effectFn ,即 touch
  • set 时找到 Map 中所有的 effectFn ,然后分别触发
bucket: WeakMap {
    target1: Map {
        key1: Set[
            fn1,
            fn2,
            fn3
        ],
        key1: Set[ ... ]
    },
    target2: Map { ... }
}

还有很多其他的情况,如:三元表达式、嵌套 effectFn 、循环调用等,书中都做了详细的讲解。

computed 基本原理

响应式支持自行配置“执行调度”功能,可以传入 lazy 这样 effectFn 不会立刻触发,而需要手动执行。

effect(
 () => { console.log(obj.foo) },
 {
     lazy: true // obj.foo 被修改时,函数不会被触发
 }
)

Vue 中的 computed 也是这样被动触发的,不是主动执行的。可以这样定义 computed

function computed(getter) {
    const effectFn = effect(getter, { lazy: true })
    
    const obj = {
        get value() {
            return effectFn() // 当读取 .value 时再执行 effctFn
        }
    }
    return obj
}

computed 另一个重要功能是缓存计算结果,可以结合 scheduler 调度功能来实现缓存,非常简洁

function computed(getter) {
    let value
    let dirty = true // 默认为缓存失效

    const effectFn = effect(getter, {
        lazy: true, // 修改数据不会触发 getter
        scheduler() {
            dirty = true // 修改数据会触发 scheduler ,让之前的缓存失效
        }
    })
    
    const obj = {
        get value() {
            if (dirty) {
                value = effectFn() // 重新计算,并记录缓存
                return value
            }
            return value // 缓存未失效
        }
    }
    return obj
}

watch 基本原理

watch 可以使用 lazyscheduler 调度功能来实现。 watch 和 computed 在内部实现上有相关性。

function watch(source, cb) {
    let newValue, oldValue

    const effectFn = effect(
        () => source.foo, // 要监听的数据
        {
            lazy: true, // 用于下面的被动调用,获取 newValue
            scheduler() {
                newValue = effectFn()
                cb(newValue, oldValue) // 执行 watch 回调函数
                oldValue = newValue // 更新旧值
            }
        }
    )
    oldValue = effectFn()
}

Reflect API 的作用

执行如下代码,返回结果见注释

const obj = {
    _name: 'xxx',
    get name() {
        console.log('this', this) // obj 对象
        return this._name
    }
}
const p = new Proxy(obj, {
    get(target, key, receiver) {
        console.log('key', key) // 'name' (没有 '_name')
        return target[key] // 【注意】这里不用 Reflect.get
    }
})
p.name

把以上代码做简单修改,使用 return Reflect.get(target, key, receiver) ,结果就不一样了

const obj = {
    _name: 'xxx',
    get name() {
        console.log('this', this) // p 对象
        return this._name
    }
}
const p = new Proxy(obj, {
    get(target, key, receiver) {
        console.log('key', key) // 'name' 和 '_name'
        return return Reflect.get(target, key, receiver) // 【注意】使用了 Reflect.get
    }
})
p.name

这两者有什么区别呢?

  • 第一种情况,无法监听 _name 属性 get ,不符合预期
  • 第二种情况,可以监听 _name 属性 get ,符合预期

Reflect API 的作用就是:能改变对象 getter 里的 this

注意,这仅仅是针对对象的 getter ,如果把 get name() 换成一个普通的函数 getName() 就不会有这个问题了。

const obj = {
    _name: 'xxx',
    getName() {
        console.log(this) // p 对象
        return this._name
    }
}

const p = new Proxy(obj, {
    get(target, key, receiver) {
        console.log('key', key) // 'name' 和 '_name'
        
        // 以下两个,打印的效果一样
        return target[key]
        // return Reflect.get(target, key, receiver)
    }
})

遇事要查标准规范

当遇到语法或 API 的问题时,最先想到的方式应该是查询业界标准规范,而不是自己试验或者上网查各种博客资料

  • 自己试验会受限于自己的浏览器或系统版本,或者自己考虑不全面
  • 网络上的资料也有很多不对的,或者过时的

书中提到了几种规范

  • ECMA-262 规范。如使用 Proxy 监听对象属性时,如何监听 infor...in ,监听数组时如何监听 for...oflength
  • WHATWG 规范。编译器解析 Vue 模板时,可参考 HTML 的规范。

同理,我们遇到 Vue 使用的问题,最快的解决方式就是看 Vue 官方文档,保证全面无坑。

ref 的本质

ref 的本质就是 reactive

function ref(val) {
    const wrapper = {
        value: val
    }
    
    // 定义一个 ref 的标记。在模板中可直接使用 ref 而不用 value ,就根据这个标记判断 (这跟响应式无关)
    // 【注意】使用 defineProperty 定义属性,只定义一个 value ,其他的(configurable, enumerable, writable)都默认是 false
    Object.defineProperty(wrapper, '__v_isRef', { value: true })
    
    return reactive(wrapper) // 使用 reactive 做响应式
}

toRef 也是一样的

function toRef(obj, key) {
    // obj 本身是 reactive() 封装过的
    const wrapper = {
        get value() {
            return obj[key]
        }
        set value(v) {
            obj[key] = v
        }
    }
    Object.defineProperty(wrapper, '__v_isRef', { value: true }) // 标记为 ref
    return wrapper
}

toRefs 就是遍历属性,挨个执行 toRef

渲染时高效切换 DOM 事件

Vue 模板中绑定了事件,那渲染为真实 DOM 也需要绑定 DOM 事件。如果事件更新了,按照一般的思路是先 removeEventListener 然后再 addEventListener ,就是两次 DOM 操作 —— DOM 操作是昂贵的。

Vue 对此进行了优化,极大减少了 DOM 操作。其实很简单:

invoker = { value: someFn }

elem.addEventListener(type, invoker.value)

// 如果事件更新,只修改 invoker.value 即可,不用进行 DOM 操作

Diff 算法

Vue2 使用双端比较的 diff 算法,参考了 snabbdom.js 。

Vue3 使用了快速 diff 算法,参考了 ivi 和 inferno 。思路是:

  • 先进行双端比较
  • 剩余的部分计算出最长递增子序列(一个很常见的算法),以找到不用重建和移动的节点
  • 最后处理剩余部分

异步更新

响应式原本是同步的,即 data 属性变化之后,effectFn 会同步触发执行。
但如果多次修改 data 属性,会同步触发多次 effectFn 执行,如果用于渲染 DOM 就太浪费性能了。

所以,Vue 在此基础上进行了优化,改为异步渲染,即多次修改 data 属性,只会在最后一次触发 effectFn 执行,中间不会连续触发。

const queue = new Set() // 任务队列。Set 可自动去重,这很重要,否则重复添加 fn 将导致重复执行
let isFlushing = false // 标记是否正在刷新
const p = new Promise()

function queueJob(job) {
    queue.add(job) // 添加任务
    
    // 如果还没有开始刷新,则启动
    if (!isFlushing) {
        isFlushing = true  // 标记为刷新中
        p.then(() => {
            try {
                queue.forEach(job => job())
            } finally {
                isFlushing = false // 标记为刷新完成
                queue.length = 0 // 清空任务队列
            }
        })
    }
}

实现方式如上述代码,先把 effectFn 缓存到一个任务队列中(要去重),然后触发一个 promise then 的回调,通过 isFlushing 标记只触发一次。最后,通过 queueJob 函数触发 effectFn 执行即可。

Composition API 如何得到组件实例

onMounted 可以在组件内部使用,也可以在组件外部使用(但必须在 setup 中触发)。当它在组件外部使用时,怎么知道当前是哪个组件呢?

Vue 是定义了一个全局变量 currentInstance ,该变量就存储着当前正在执行 setup 的组件实例,执行结束即清空。

let currentInstance = null // 全局变量,存储当前正在 setup 的组件实例

// 挂载组件
function mountComponent(vnode, container, anchor) {
    const instance = { ... } // 当前组件实例
    
    currentInstance = instance // 存储到全局变量
    
    // 执行组件的 setup 函数,其中可能会调用 onMounted
    
    currentInstance = null // 清空全局变量
    
}

function onMounted(fn) {
    if (currentInstance == null) throw new Error('找不到组件实例') // 说明 onMounted 没有在 setup 中触发

    // 把 fn 记录到当前组件的 mounted 函数列表中,等待 mounted 之后被触发
    currentInstance.mounted.push(fn)
}

至此,就明白为什么 onMounted 必须要在 setup 内部触发了。

关于 Vue 函数组件

Vue 函数组件是无状态组件,只有 props ,没有 data 和生命周期。Composition API 还是用于普通组件,不能用于函数组件。这一点和 React Hooks 不一样。

Vue2 函数组件比普通组件性能好。而 Vue3 的普通组件初始化也很快,所以用函数组件主要是为了简单,没有性能的优势。

keep-alive 缓存原理

keep-alive 内部的组件和 DOM elem 会被缓存起来,切换时只触发 activate 和 deactivate 生命周期,不会重复创建。

缓存不能无限制扩展,需要有一个裁剪机制,Vue 通过 LRU 算法进行裁剪。可以通过 max include exclude 来自行控制缓存。

编译器流程

编译器就是将 Vue 模板生成 JS 代码,即 render 函数

  • 输入模板字符串 parse(tplStr) 生成模板 AST
  • 输入模板 AST transform(ast) 生成 JS AST
  • 输入 JS AST generate(JSAST) 生成 JS 代码

Vue 采用“编译时 + 运行时”模式,既可以在开发环境直接编译模板,也可以在运行环境编译模板。一般使用前者。

Vue3 编译优化

编译优化是所有编译器都会做的事情,例如 JS 代码中写 const a = 10; const b = a + 10; 编译之后就会是 const b = 20; ,这是最简单的优化。

Vue3 在编译时也做了很多优化:第一,提高执行 render 函数生成 vnode 的效率;第二,提高 diff 算法的执行效率。

  • patchFlag 补丁标记 - 区分静态节点和动态节点,diff 时可以只对比动态节点
  • 静态提升 - 把静态节点的生成提升到 render 函数外部,这样只执行一次即可,不用每次 render 时都执行
  • 缓存内联事件 - 把模板内联事件缓存,不用每次 render 时都重新生成事件

对 SSR 的误解

严格来说应该叫同构,不是真正的 SSR (如 PHP JSP)。

首次渲染时服务端返回:1. 纯静态的页面;2. 打包好的 JS 和 CSS 代码。浏览器会直接展示静态页面,然后再加载 JS 和 CSS 代码,待加载完比、执行 JS 完毕之后,网页才算是真正可用。

所以,同构渲染只是能解决首次渲染时网页白屏的问题,以及对 SEO 比较友好。但它不能提升可交互时间(TTI),因为还需要下载并执行 JS ,网页才能真正可交互。这个时间和 CSR 相差无几。

SSR 组件的生命周期

SSR 生成是当前组件的“快照”,纯静态的 HTML 代码,生成之后立刻返回给客户端。

服务端没法渲染 DOM ,也就没有 beforeMount 和 mounted ,同理也没有 beforeDestroy 和 destroyed 。服务端也不需要绑定 DOM 事件,只有客户端才能执行事件。服务端也没必要监听响应式,所以也没有 beforeUpdate 和 updated 。

所以,SSR 组件生命周期只有 beforeCreate 和 created ,其他都没有。

SSR 组件在客户端的激活操作

SSR 返回的是组件快照,纯静态 HTML 代码,没有 DOM 事件,到浏览器中被渲染为真实 DOM 。此时还需要在客户端进行激活 hydrate ,激活之后网页才真正可用。主要两件事:

  • 关联真实 DOM 和 VDOM ,即 vnode.el = elem
  • 绑定 DOM 事件

另外,由于真实 DOM 已经被渲染,所以此时 Vue 不会在重新渲染 DOM ,只激活即可。

总结

如果你想了解 Vue3 原理,这本书真的是最佳选择,当然需要你提前熟悉 Vue3 的常见应用。

不过,看书有技巧,书里的细节非常多,不要指望全部都能看懂。其实 Vue 等第三方框架的研发,这属于一个独立的细分领域,和我们日常的业务开发关联并不是那么大。所以,我们学习 Vue 的原理和实现方式,扩展我们的技术视野,这是没错的。但千万千万不要误入细节,更不要妄想自己也去造一个一模一样的轮子 —— 知道原理和实现,离着一个成熟可用的框架还差很多很多。

加油,共勉~