1. 权衡的艺术
一、命令式与声明式
- 命令式:更关注于过程,如jQuery。
const div = document.querySelector('#app') // 获取 div
div.innerText = 'hello world' // 设置文本内容
div.addEventListener('click', () => { alert('ok') }) // 绑定点击事件
- 声明式:只声明目标不指定细节,如Vue。Vue.js 帮我们封装了实现的过程,其内部是命令式而暴露出来的是声明式。
- 声明式代码的性能不优于命令式代码的性能(声明式代码会比命令式代码多出找出差异的性能消耗),但声明式的代码可维护性更强。
- 如果我们把直接修改的性能消耗定义为 A,把找出差异的性能消耗定义为 B
命令式代码的更新性能消耗 = A
声明式代码的更新性能消耗 = B + A
二、真实DOM与虚拟DOM
- 虚拟dom:virtual Dom虚拟dom,用普通js对象来描述dom结构,因为不是真实dom,所以称之为虚拟dom,虚拟 DOM,就是为了最小化找出差异这一步的性能消耗而出现(声明式代码的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗)。
- 虚拟dom是相对于浏览器所渲染出来的真实dom而言的,在react,vue等技术出现之前,我们要改变页面展示的内容只能通过遍历查询dom树的方式找到需要修改的dom然后修改样式行为或者解构,从而达到更新UI的目的。
- 这种方式相当消耗计算资源,因为每次查询dom几乎都需要遍历整颗dom树,如果监理一个与dom树对应的虚拟dom对象,来表示dom树以及层级结构,那么每次dom的更改就变成了对js对象的属性的增删改,这样一来查找js对象的属性变化要比查询dom树的性能开销小。
- 区别:虚拟dom不会进行排版与重绘操作,而真实dom会频繁重排与重绘。 虚拟dom的总损耗是“虚拟都能增删改+真实dom差异增删改+排版与重绘”,真实dom的总损耗是“真实dom完全增删改+排版与重绘”。
- 虚拟 DOM创建过程:虚拟 DOM 创建页面的需要重新创建 JavaScript 对象(虚拟 DOM 树),然后比较新旧虚拟 DOM,找到变化的元素并更新它。
- 第一步是创建 JavaScript 对象,这个对象可以理解为真实 DOM 的描述;
- 第二步是递归地遍历虚拟 DOM 树并创建真实 DOM。
innerHTML 和虚拟 DOM 在创建页面时的性能
虚拟 DOM 和 innerHTML 在更新页面时的性能
三、运行时和编译时
当设计一个框架的时候,我们有三种选择:纯运行时的、运行时 + 编译时的或纯编译时的。
1. 纯运行时框架
提供一个 Render 函数,用户可以为该函数提供一个树型结构的数据对 象,然后 Render 函数会根据该对象递归地将数据渲染成 DOM 元素。
缺点:
- 需用户手写树形结构的数据对象,不够直观;
- 没有编译的过程,我们没办法分析用户提供的内容 ;
/* Render函数 */
function Render(obj, root) {
const el = document.createElement(obj.tag)
if (typeof obj.children === 'string') {
const text = document.createTextNode(obj.children)
el.appendChild(text)
} else if (obj.children) {
// 数组,递归调用 Render,使用 el 作为 root 参数
obj.children.forEach(child => Render(child, el))
}
// 将元素添加到 root
root.appendChild(el)
}
/* 使用 */
const obj = {
tag: 'div',
children: [
{ tag: 'span', children: 'hello world' }
]
}
// 渲染到 body 下
Render(obj, document.body)
2. 运行时+编译时框架
先通过 Compiler 编译得到树型结构的数据对象,再调用Render函数进行渲染
const html = `
<div>
<span>hello world</span>
</div>
`
// 调用 Compiler 编译得到树型结构的数据对象
const obj = Compiler(html)
// 再调用 Render 进行渲染
Render(obj, document.body)
它既支持运行时,用户可以直接提供数据对象 从而无须编译;又支持编译时,用户可以提供 HTML 字符串,我们将其编译为数据对象后再交给运行时处理。
准确地说,上面的代码其实 是运行时编译,意思是代码运行的时候才开始编译,而这会产生一定的性能开销,因此我们也可以在构建的时候就执行 Compiler 程序将用户提供的内容编译好,等到运行时就无须编译了,这对性能是非常友好的。
3. 纯编译时框架
我们只需要一个 Compiler 函数就可以了,连 Render 都不 需要了。其实这就变成了一个纯编译时的框架,因为我们不支持任何 运行时内容,用户的代码通过编译器编译后才能运行。
缺点: 性能可能会更好,但是这种做法有损灵活性,即用户提供的内容必须编译后才能用。
4. Vue3(编译时+运行时)
Vue.js 3 是一 个编译时 + 运行时的框架( 它既支持运行时,用户可以直接提供数据对象 从而无须编译;又支持编译时,用户可以提供 HTML 字符串,我们将其编译为数据对象后再交给运行时处理 ),它在保持灵活性的基础上,还能够通过编 译手段分析用户提供的内容,从而进一步提升更新性能。
2. 框架设计的核心要素
一、提升用户的开发体验
衡量一个框架是否足够优秀的指标之一就是看它的开发体验如何,在框架设计和开发过程中,提供友好的警告信息至关重要。同样,框架错误处理机制的好坏直接决定了用户应用程序的健壮性,还决定了用户开发时处理 错误的心智负担。此外,Vue3具备良好的 TypeScript 类型支持,一定程度上能够避免低级 bug、使得代码的可维护性更强。
二、控制框架代码体积
框架的大小也是衡量框架的标准之一。在实现同样功能的情况下,当然是用的代码越少越好,这样体积就会越小,最后浏览器加载资源的时间也就越少。
而为了提供完善的警告信息,需要编写更多的代码,Vue3是这么平衡的:
- 通过rollup.js 的插件配置来预定义__DEV__,在warn 函数的调用时(警告信息)检查__DEV__的值
- 当 Vue.js 构建用于开发环境的资源时,会把 DEV 常量设置为 true,而构建生产环境的资源时,会把 DEV 常量设置为false
- 当__DEV__为false时,下面的代码不会执行,在构建资源的时候就会被移除
if (false && !res) {
warn(`Failed to mount app: mount target selector "${container}" returned null.`)
}
三、Tree-Shaking
- 简单地说,Tree-Shaking指的就是消除那些永远不会被执行的代码(要实现 Tree-Shaking,模块必须是 ESM(ES Module),因为 Tree-Shaking 依赖于 ESM 的静态结构),也就是排除 dead code。
- 值得注意的是,如果一个函数调用会产生副作用(当调用函数的时候会对外部产生影响,例如修改了全局变量),那么就不能将其移除。
- 在Vue3中,通过使用
/*#__PURE__*/注释来告诉rollup该函数的调用不会产生副作用。
import {foo} from './utils'
/*#__PURE__*/ foo()
注:Vue提供特性开关,比如,为了兼容 Vue2,在 Vue3 中仍然可以使用选项 API 的方 式编写代码。但是如果明确知道自己不会使用选项 API,用户就可以使 用 VUE_OPTIONS_API 开关来关闭该特性,这样在打包的时候 Vue.js 的这部分代码就不会包含在最终的资源中,从而减小资源体积。
3. Vue3的设计思路
一、声明式描述UI
Vue3是一个声明式的 UI 框架,哪怕是事件,都有与之对应的描述方式,用户不需要手写任何命令式代码。
实现声明式方案如下:
- 使用与 HTML 标签一致的方式来描述 DOM 元素,例如描述一个 div 标签时可以使用
<div></div> - 使用与 HTML 标签一致的方式来描述属性,例如
<div id="app"></div> - 使用 : 或 v-bind 描述动态绑定的属性,例如
<div :id="dynamicId"></div> - 使用 @ 或 v-on 描述事件,例如点击事件
<div @click="handler"></div> - 使用与 HTML 标签一致的方式描述层级结构,例如一个具有 span 子节点的 div 标签
<div><span></span></div>
除了上面这种使用模板来声明式地描述 UI 之外,我们还可以用 JavaScript 对象来描述(虚拟Dom),注意:使用 JavaScript 对象描述 UI 比使用模板更加灵活
const title = {
tag: 'h1', // 标签名称
props: { onClick: handler }, // 标签属性
children: [ { tag: 'span' } ] // 子节点
}
// 等价于如下Vue模板
<h1 @click="handler"><span></span></h1>
二、渲染器
渲染器的作用就是把虚拟 DOM 渲染为真实 DOM , 渲染器 renderer 的实现思路分为三步:创建元素、为元素添加属性和事件、处理children。
// 虚拟DOM
const vnode = {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click again' // 从 click me 改成 click again
}
// renderer函数
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 ---> click
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函数
renderer(vnode, document.body) // body 作为挂载点
三、模板的工作原理
无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器再把渲染函数返回的虚拟 DOM 渲染为真实 DOM,这就是模板的工作原理,也是 Vue.js 渲染页面的流程。