前言
本文是《Vue.js设计与实现》第一篇 框架设计概览 学习摘要笔记。
权衡的艺术
命令式和声明式
视图层框架通常分为命令式和声明式,命令式更加关注过程,而声明式更加关注结果。
命令式代码示例:
const div = document.querySelector('#app') // 获取 div
div.innerText = 'hello world' // 设置文本内容
div.addEventListener('click', () => { alert('ok') }) // 绑定点击事件
声明式代码示例:
<div @click="() => alert('ok')">hello world</div>
性能与可维护性
innerHTML、虚拟DOM、原生 JavaScript 在更新页面时的性能
声明式的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗。 虚拟 DOM 的意义就在于使找出差异的性能消耗最小化。
运行时和编译时
代码运行的时候才开始编译,而这会产生一定 的性能开销,因此我们也可以在构建的时候就执行 Compiler 程序将 用户提供的内容编译好,等到运行时就无须编译了,这对性能是非常友好的。
纯运行时的框架。没有编译的过程,没办法分析用户提供的内容。
加入编译步骤,我们可以分析用户提供的内容,看看哪些内容未来可能会改变,哪些内容永远不会改变,这样我们就可以在编译的时候提取这些信 息,然后将其传递给 Render 函数,Render 函数得到这些信息之 后,就可以做进一步的优化。
纯编译时的框架,那么它也可以分析用户提供的内容。由于不需要任何运行时, 而是直接编译成可执行的 JavaScript 代码,因此性能可能会更好,但是 这种做法有损灵活性,即用户提供的内容必须编译后才能用。
实际上,在这三个方向上业内都有探索,其中 Svelte 就是纯编译时的框架,但是它的真实性能可能达不到理论高度。Vue.js 3 仍然保持了运行时 + 编译时的架构,在保持灵活性的基础上能够尽可能地去优化。等Vue.js设计与实现到后面讲解 Vue.js 3 的编译优化相关内容时,你会看到 Vue.js 3 在保留运行时的情况下,其性能甚至不输纯编译时的框架。
框架设计的核心要素
提升用户的开发体验
rollup.js 配置
// rollup.config.js
const config = {
input: 'input.js',
output: {
file: 'output.js',
format: 'iife' //指定模块格式 cjs => Node.js 环境 esm => ESM 格式
}
}
export default config
错误处理
为用户提供统一的错误处理接口
// utils.js
let handleError = null
export default {
foo(fn) {
callWithErrorHandling(fn)
},
// 用户可以调用该函数注册统一的错误处理函数
registerErrorHandler(fn) {
handleError = fn
}
}
function callWithErrorHandling(fn) {
try {
fn && fn()
} catch (e) {
// 将捕获到的错误传递给用户的错误处理程序
handleError(e)
}
}
在Vue.js注册统一的错误处理函数:
import App from 'App.vue'
const app = createApp(App)
app.config.errorHandler = () => {
// 错误处理程序
}
声明式地描述 UI
虚拟 DOM 描述 UI:
import { h } from 'vue'
export default {
render() { return h('h1', { onClick: handler }) // 虚拟 DOM
}
}
h 函数的返回值就是一个对象,其作用是让我们编写虚拟 DOM 变得更加轻松。
把上面 h 函数调用的代码改成 JavaScript 对象:
export default {
render() {
return {
tag: 'h1', props: { onClick: handler }
}
}
}
组件的渲染函数: 一个组件要渲染的内容是通过渲染函数来描述的,也就是上面代码中的 render 函数,Vue.js 会根据组件的 render 函数的返回值拿到虚拟 DOM,然后就可以把组件的内容渲染出来了。
简单渲染器
const vnode = {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
function renderer(vnode, container) {
// 使用 vnode.tag 作为标签名称创建 DOM 元素
const el = document.createElement(vnode.tag)
// 遍历 vnode.props,将属性、事件添加到 DOM 元素
for (const key in vnode.props) {
if (/^on/.test(key)) {
// 如果 key 以 on 开头,说明它是事件
el.addEventListener(
key.substr(2).toLowerCase(), // 事件名称 onClick
vnode.props[key] // 事件处理函数
)
}
}
// 处理 children
if (typeof vnode.children === 'string') {
// 如果 children 是字符串,说明它是元素的文本子节点
el.appendChild(document.createTextNode(vnode.children))
} else if (Array.isArray(vnode.children)) {
// 递归地调用 renderer 函数渲染子节点,使用当前元素 el 作为挂载点
vnode.children.forEach(child => renderer(child, el))
}
// 将元素添加到挂载点下
container.appendChild(el)
}
renderer(vnode, document.body)
渲染器的精髓都在更新节点的阶段。
组件的本质
一句话总结:组件就是一组 DOM 元素的封装,这组 DOM 元素就是组件要渲染的内容,因此我们可以定义一个函数来代表组件,而函数的返回值就代表组件要渲染的内容。
const MyComponent = function () {
return {
tag: "div",
props: {
onClick: () => alert("hello"),
},
children: "click me",
};
};
组件的返回值也是虚拟 DOM,它代表组件要渲染的内容。
让虚拟 DOM 对象中的 tag 属性来存储组件函数:
const vnode = {
tag: MyComponent
}
修改 renderer 函数支持渲染组件
function renderer(vnode, container) {
if (typeof vnode.tag === 'string') {
// 说明 vnode 描述的是标签元素
mountElement(vnode, container)
} else if (typeof vnode.tag === 'function') {
// 说明 vnode 描述的是组件
mountComponent(vnode, container)
}
}
其中 mountElement 函数与上文中 renderer 函数一致
mountComponent 实现:
function mountComponent(vnode, container) {
// 调用组件函数,获取组件要渲染的内容(虚拟 DOM)
const subtree = vnode.tag()
// 递归地调用 renderer 渲染 subtree
renderer(subtree, container)
}
模板的工作原理
编译器的作用其实就是将模板编译为渲染函数
<template>
<div @click="handler">click me</div>
</template>
<script>
export default {
data() { /* ... */ },
methods: {
handler: () => { /* ... */ },
},
};
</script>
编译器会把模板内容 编译成渲染函数并添加到 <script> 标签块的组件对象上
export default {
data() {/* ... */},
methods: {
handler: () => {/* ... */}
},
render() {
return h('div', { onClick: handler }, 'click me')
}
}
Vue.js 是各个模块组成的有机整体
组件的实现依赖于渲染器,模板的编译依赖于编译器,并且编译后生成的代码是根据渲染器和虚拟 DOM 的设计决定。
总结
- Vue.js 是一个声明式的框架。声明式的好处在于,它直接描述结果,用 户不需要关注过程。Vue.js 采用模板的方式来描述 UI,但它同样支持 使用虚拟 DOM 来描述 UI。虚拟 DOM 要比模板更加灵活,但模板要 比虚拟 DOM 更加直观。
- 渲染器的作用是把虚拟 DOM 对象渲染为真实 DOM 元素。它的工作原理是,递归地遍历虚拟 DOM 对象,并调用原生 DOM API 来完成真实 DOM 的创建。渲染器的精髓在于后续的更新,它会通过 Diff 算法找出变更点,并且只会更新需要更新的内容。后面我们会专门讲解渲染器的相关知识。
- 组件的本质其实就是一组虚拟 DOM 元素的封装,它可以是一个返回虚拟 DOM 的函数,也可以是一个对象,但这个对象下必须要有一个函数用来产出组件要渲染的虚拟 DOM。渲染器在渲染组件时,会先获取组件要渲染的内容,即执行组件的渲染函数并得到其返回值,我们称之为 subtree,最后再递归地调用渲染器将 subtree 渲染出来即可。
- Vue.js 的模板会被编译器编译为渲染函数,编译器、渲染器都是 Vue.js 的 核心组成部分,它们共同构成一个有机的整体,不同模块之间互相配合,进一步提升框架性能。