前言
这本书是个人认为对vue.js的原理解释比较好的一本书。就像书中所说
它从高层的设计角度探讨框架需要关注的问题,从而帮助读者更好地理解一些具体的实现为何要做出这样的选择。
前端是一个变化很快的领域,新的技术不断出现。这本书也可以作为现代前端框架设计的一个非常有价值的参考。
在前言中说了很多vue3的优点。在 2020 年 9 月 18 日,正式迎来了它的 3.0 版本。得益于 Vue.js 2 的设计经验,Vue.js 3.0 不仅带来了诸多新特性,还在框架设计与实现上做了很多创新。在一定程度上,我们可以认为 Vue.js 3.0“还清”了在 Vue.js 2 中欠下的技术债务。
- Vue.js 3.0 在模块的拆分和设计上做得非常合理
- Vue.js 3.0 在设计内建组件和模块时也花费了很多精力,配合构建工具以及 Tree-Shaking 机制,实现了内建能力的按需引入,从而实现了用户 bundle 的体积最小化
- Vue.js 3.0 的扩展能力非常强
第一篇 框架设计概览
第 1 章 权衡的艺术
“框架设计里到处都体现了权衡的艺术。”
作为框架设计者,一定要对框架的定位和方向拥有全局的把控,这样才能做好后续的模块设计和拆分。
同样,作为学习者,我们在学习框架的时候,也应该从全局的角度对框架的设计拥有清晰的认知,否则很容易被细节困住,看不清全貌。
1.1 命令式和声明式
从范式上来看,视图层框架通常分为命令式和声明式,他们各自有各自的优点和缺点。
我们先来看看命令式框架和声明式框架的概念。早年间流行的 jQuery 就是典型的命令式框架。命令式框架的一大特点就是关注过程。例如,我们把下面这段话翻译成对应的代码:
01 - 获取 id 为 app 的 div 标签
02 - 它的文本内容为 hello world
03 - 为其绑定点击事件
04 - 当点击时弹出提示:ok
对应的代码为:
/**
* 01 - 获取 id 为 app 的 div 标签
* 02 - 它的文本内容为 hello world
* 03 - 为其绑定点击事件
* 04 - 当点击时弹出提示:ok
*/
$('#app')//获得div
.text('hello world')//设置文本内容
.on('click',()=> {
alert('ok')
})//绑定事件
以上就是 jQuery 的代码示例,考虑到有些读者可能没有用过jQuery,因此我们再用原生 JavaScript 来实现同样的功能:
/**
* 01 - 获取 id 为 app 的 div 标签
* 02 - 它的文本内容为 hello world
* 03 - 为其绑定点击事件
* 04 - 当点击时弹出提示:ok
* js版本
*/
const div=document.querySelector('#app')//获得div
div.innerText='hello world!'//设置文本内容
div.addEventListener('click',()=>{
alert('ok')
})//绑定点击事件
自然语言描述能够与代码产生一一对应的关系,代码本身描述的是“做事的过程”,这符合我们的逻辑直觉。
那么,什么是声明式框架呢?声明式框架更加关注结果
<div @click="() => alert('ok')">hello world</div>
这段类 HTML 的模板就是 Vue.js 实现如上功能的方式。可以看到,我们提供的是一个“结果”,至于如何实现这个“结果”,我们并不关心,这就像我们在告诉 Vue.js:“嘿,Vue.js,看到没,我要的就是一个div,文本内容是 hello world,它有个事件绑定,你帮我搞定
吧。”至于实现该“结果”的过程,则是由 Vue.js 帮我们完成的。换句话说,Vue.js 帮我们封装了过程。因此,我们能够猜到 Vue.js 的内部实现一定是命令式的,而暴露给用户的却更加声明式。
1.2 性能与可维护性的权衡
命令式和声明式各有优缺点,这里书中给了一个结论:
声明式代码的性能不优于命令式代码的性能。
假设现在我们要将 div 标签的文本内容修改为 hello vue3,那么如何用命令式代码实现呢?
div.textContent = 'hello vue3' // 直接修改
这个修改就是一个最佳性能的做法了。
但是声明式代码不一定能做到这一点,因为它描述的是结果:
<!-- 之前: -->
<div @click="() => alert('ok')">hello world</div>
<!-- 之后: -->
<div @click="() => alert('ok')">hello vue3</div>
对于框架来说,为了实现最优的更新性能,它需要找到前后的差异并只更新变化的地方,但是最终完成这次更新的代码仍然是
div.textContent = 'hello vue3' // 直接修改
如果我们把直接修改的性能消耗定义为 A,把找出差异的性能消
耗定义为 B,那么有:
- 命令式代码的更新性能消耗 = A
- 声明式代码的更新性能消耗 = B + A
Vue.js选择声明式的原因也很简单,为了更好的维护。声明式代码展示的就是我们要的结
果
1.3 虚拟 DOM 的性能到底如何
再创建页面时
可以看到,无论是纯 JavaScript 层面的计算,还是 DOM 层面的计算,其实两者差距不大。
在更新页面时
在更新页面时加上性能因素
因此得到的结论:
1.4 总结
- 讨论了命令式和声明式这两种范式的差异,其中命令式更加关注过程,而声明式更加关注结果
- 讨论了虚拟 DOM 的性能,并给出了一个公式:声明式的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗。
第 2 章 框架设计的核心要素
2.1 提升用户的开发体验
例如当我们去挂载一个不存在的Dom节点的时候。
createApp(App).mount('#not-exist')
就会出现上面的警告。这样就可以让我们更加清晰的定位到问题所在
在 Vue.js 的源码中,我们经常能够看到 warn 函数的调用
例如刚才的例子,就是由下面的函数进行打印的。
warn$1(`Failed to mount app: mount target selector "${container}" returned null.`);
为什么这里是warn$1呢,因为在vue源码中
exports.warn = warn$1;
有着这样一行代码
除了提供必要的警告信息外,还有很多其他方面可以作为切入口,进一步提升用户的开发体验。
const count = ref(0)
console.log(count)
可以发现不是很直观,这个时候需要我们进行一些设置。
勾选这个就可以了。
2.2 控制框架代码的体积
如果我们去看 Vue.js 3 的源码,就会发现每一个 warn 函数的调用都会配合 DEV 常量的检查,例如:
if (__DEV__ && !res) ?
warn(
`Failed to mount app: mount target selector "$?container?"
returned null.`
)
}
这样写的好处很简单,可以通过dev为true或者false,来缩短体积。需要注意的是:
它不会出现在最终产物中,这样就做到了在开发环境中为用户提供友好的警告信息的
同时,不会增加生产环境代码的体积。
2.3 框架要做到良好的 Tree-Shaking
Tree-Shaking 指的就是消除那些永远不会被执行的代码,也就是排除 dead code,
2.4错误处理
假设我们开发了一个工具模块,代码如下:
// utils.js
export default {
foo(fn) {
fn && fn()
}
}
最好的优化方案就是我们能为用户提供统一的错误处理接口
//utils.js
let handleError = null
export default {
foo(fn) {
callWithErrorHandling(fn)
},
//用户可以调用该函数注册同意的错误处理函数
registerProtocolHandler(fn) {
handleError = fn
}
}
function callWithErrorHandling(fn) {
try {
fn && fn()
} catch (e) {
handleError(e)
}
}
我们提供了 registerErrorHandler 函数,用户可以使用它注册错误处理程序,然后在 callWithErrorHandling 函数内部捕获错误后,把错误传递给用户注册的错误处理程序。
import utils from "./test"
utils.registerProtocolHandler((e)=>{
console.log(e)
})
utils.foo(()=>{/*...*/})
utils.bar(()=>{/*...*/})
实际上,这就是 Vue.js 错误处理的原理,你可以在源码中搜索到callWithErrorHandling 函数,另外我们也可以注册统一的错误处理函数
import App from "App.vue"
const app = createApp(App);
app.config.errorHandler=()=>{
//错误处理程序
}
2.5良好的 TypeScript 类型支持
在编写大型框架时,想要做到完善的 TS 类型支持很不容易,大家可以查看 Vue.js 源码中的 runtime-core/src/apiDefineComponent.ts 文件,整个文件里真正会在浏览器中运行的代码其实只有 3 行,但是全部的代码接近 200 行,其实这些代码都是在为类型支持服务。由此可见,框架想要做到完善的类型支持,需要付出相当大的努力。
第 3 章 Vue.js 3 的设计思路
3.1 声明式地描述 UI
我们要是编写一个前端页面就要涉及到下面的内容
- DOM元素
- 属性
- 事件
- 元素的层级结构
下面是我们用JavaScript对象来描述的
const title={
//标签名称
tag : "h1",
//标签属性
props:{
onClick:handler
},
//子节点
children : [
{tag : "span"}
]
}
对应到vue.js里面就是
<h1 ?click="handler"><span></span></h1>
使用 JavaScript 对象描述 UI 更加灵活
使用 JavaScript 对象来描述 UI的方式,其实就是所谓的虚拟 DOM
3.2 初识渲染器
虚拟 DOM 是如何变成真实DOM 并渲染到浏览器页面中的呢?
这个时候就用到了渲染器,渲染器的作用就是,把虚拟DOM渲染成为真实的DOM
如下图所示
这本书给的例子非常的不错,例如我们有如下虚拟DOM
const vnode={
tag : "div",
props : {
onClick : () => {
alert("hello")
},
},
children : 'click me'
}
之后写一个渲染器,把上面这段虚拟DOM渲染为真实的DOM
function renderer(vnode,container){
//使用vnode.tag作为标签名称来创建DOM元素
const el=document.createElement(vnode.tag);
//遍历vnode.props,将属性和事件都添加到DOM元素
for (const key in vnode.props){
if (/^no/.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)){
//递归调用render函数渲染子节点,使用当前元素el作为挂载点
vnode.children.forEach(child=>renderer(child,el))
}
//将元素挂在到挂载点上
container.appendChild(el)
}
这里的vnode就是虚拟DOM对象
container就是一个真实DOM元素,作为挂载点。
总的来说,renderer的实现思路,总体来说分为三大步
- 创建元素
- 为元素添加属性和事件
- 处理children
3.3 组件的本质
其实组件的返回值也是虚拟DOM,它代表组件要渲染的内容。
这个时候,我们就需要修改之前的渲染器
function renderer(vnode,container){
if (typeof vnode.tag==="string"){
mountElement(vnode,container)
} else if(typeof vnode.tag==="function"){
//说明是组件
mountCompoent(vnode,container)
}
}
mountElement就是我们之前所写的内容。而mountCompoent就是我们需要对组件进行处理的函数
function mountCompoent(vnode, container) {
const subtree = vnode.tag();//返回值是虚拟DOM
renderer(subtree,container)
}
因此我们可以举一反三,如果说,使用一个对象来表示组件呢?
const MYComponent={
render(){
return{
tag:'div',
props:{
onClick : () => {
alert('hello')
}
},
children : 'click me'
}
}
}
else if (typeof vnode.tag === 'object')
这个时候只需要我们简单的修改mountCompoent这个函数
function mountCompoent(vnode, container) {
// vnode.tag 是组件对象,调用它的 render 函数得到组件要渲染的内容(虚拟 DOM
const subtree = vnode.tag.render();//返回值是虚拟DOM
renderer(subtree,container)
}