前言
大家好,我是为梦执守。本人最近在阅读霍春阳著作的《vue.js的设计和实现》这本书,这本书写的非常的好,很多业界大佬也在强烈推荐,工作中使用到vue的同学可以购买正版进行阅读,以下内容是自己最近在学习过程中的一些总结笔记,有哪些理解错误的欢迎大家评论留言哦
第一章 权衡的艺术
建议阅读原版书籍了解具体实现。
1.1 什么是命令式和声明式
命令式
命令式框架,最大的特点就是关注过程,我们需要考虑每一步的实现,一步错就导致程序奔溃了
以jQuery为例:
$('#app').text('hello world').on('click', () => { alert('OK') })
以JavaScript为例:
const div = document.querySelector('#app')
div.innerText = 'hello world'
div.addEventListener('click', () => { alert('OK') })
这行代码就是典型的命令式框架写法,会关注过程的每一步实现,那么当实现的逻辑及项目复杂度剧增的时候,开发者的维护和开发心智巨大,那么有没有什么方法让我们不这么累呢?当然是有的,下面我们看看声明式框架的特点:
声明式
声明式框架,最大的特点就是关注结果,不用在像命令式一样关注每一步的实现,只需要关系最终结果就可以了
<div @click='() => alert('OK')'>hello world</div>
以上是这段类HTML的模板就是vue.js实现的方式。可以看到,我们不用在关注过程是怎么去实现的,只有在意最终输出的结果就行。
其实vue.js内部是通过命令式的方式帮我们实现了这个过程,用户通过声明式的方式进行交互,那么相对于用户的体验上来说,声明式优于命令式,性能及可维护性谁更具有优势呢?
1.2 性能和可维护性的权衡
关于性能,书中结论为:声明式代码的性能不优于命令式代码的性能,前面的代码例子为例,我们执行一次更新操作,如果直接修改的性能消耗定义为A,把找到差异的性能消耗定义为B,如下:
// 命令式,直接更改文本内容,性能最优
// 更新消耗性能 = A
div.textContent = 'hello world3'
// 声明式
// 更新消耗性能 = B + A
<div @click="() => alert('OK')" >hello world3</div>
以上例子也说明最开始的结论是正确的,框架本身就是封装了命令式代码才实现了面向用户的声明式。说完了性能,那么vue.js为什么会选择声明式的设计方案呢?主要在于声明式的可维护性更强。
1.3 虚拟DOM的性能
前面我们说到,声明式代码的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗,如何最小化找出差异的性能消耗,就可以让声明式代码的性能无限接近命令式代码的性能。而我们所说的虚拟DOM,就是为了最小化找出差异这一步性能消耗而出现的,所以我们应该明白采用虚拟DOM的更新技术的性能理论上是不可能比原生JavaScript操作DOM更高的(也只是理论上而已,大多数情况我们无法做到极致性能优化的命令式代码)
DOM运算和JavaScript层面的计算对比:
// 纯JavaScript计算
console.time("Array initialize");
var arr = [];
for(var i=0; i<100000; i++){
const div = {tag:'div'}
arr.push(div);
}
console.timeEnd("Array initialize");
// 耗时 Array initialize: 24.919ms
// DOM操作计算
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id='app'></div>
</body>
<script>
console.time("Array initialize");
const app = document.querySelector("#app");
for (let i = 0; i<100000; i++){
const div = document.createElement('div');
app.appendChild(div)
};
console.timeEnd("Array initialize");
// 耗时 Array initialize: 42.591064453125 ms
</script>
</html>
大家可以看见,循环10000次,JavaScript层面确实比DOM操作快得多,innerHTML创建页面的性能 = HTML字符串拼接的计算量 + innerHTML的DOM计算量
虚拟DOM创建页面的过程为:
-
创建JavaScript对象,这个对象可以理解为真实DOM的描述
-
递归地遍历虚拟DOM树并创建真实DOM
即创建JavaScript对象的计算量 + 创建真实DOM的计算量
到这里,整体看下来,虚拟DOM的性能其实要差很多,我们再来看看它们在更新页面时的性能,使用innerHTML更新页面的过程是重新构建HTML字符串,再重新设置DOM元素的innerHTML属性,就是只是修改一个文字内容,也需要重新设置innerHTML属性,而重新设置innerHTML属性等价于销毁所有旧的DOM元素,再全量创建新的DOM元素(这其实就不是我们想要的呢,对不对);虚拟DOM是通过找到变化的元素更新它。
那么当更新的数量级很大的时候,虚拟DOM的优势就完全凸显出来了,页面结构很复杂庞大时,虚拟DOM对比更新的节点(常说的Diff阶段,会消耗部分性能)只会对对应的节点进行更新,而innerHTML是全量销毁在创建,中间的性能差距将是巨大的。
最后,从性能、心智负担、可维护性方面来看一下
1.4 运行时和编译时
框架设计可以选择:纯运行时、运行时 + 编译时、纯编译时
纯运行时:给用户提给一个Render函数,用户根据提供规则达到预期的内容(AngularJS)
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{
// 数组,递归调用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)
那么用户不按照指定的规则将得不到预期的结果
运行时 + 编译时:将用户的HTML标签Compiler函数编译成树型结构数据后返还给用户(Vue.js3框架)
<div>
<span> hello world </span>
</div>
||
|| 编译
||
const obj = {
tag:'div',
children:[
{ tag:'span',children:'hello world'}
]
}
最后用户调用Render函数输出预期结果
纯编译时:提供一个Compiler函数将用户的HTML标签直接编译成预期的结果(Svelte框架)
<div>
<span> hello world </span>
</div>
||
|| 编译
||
const div = document.createElement("div");
const span = document.createElement("span");
span.innerText = "hello world";
div.appendChild(span);
document.body.appendChild(div);
第二章 框架设计的核心要素
建议阅读原版书籍了解具体实现。
2.1 提升用户的开发体验
衡量一个框架是否足够优秀的指标之一就是看它的而开发体验如何。提供友好的警告信息至关重要,这有助于开发者快速定位问题,因为大多数情况下“框架要比开发者更清楚问题出在哪里,因此在框架层面抛出有意义的警告信息是非常有必要的。但是是不是越详细的提示信息越好呢?答案肯定是否定的
2.2 控制框架代码的体积
框架的大小也是衡量框架的标准之一
if (__DEV__) {
warn(
`App has already been mounted.\n` +
`If you want to remount the same app, move your app creation logic ` +
`into a factory function and create fresh app instances for each ` +
`mount - e.g. \`const createMyApp = () => createApp(App)\``
)
}
源码中大量存在这样的代码,该变量是一个布尔值,构建生产环境资源时为false,构建开发环境时为true,这样做到了在开发环境中为用户提供友好的警告信息的同时,不会增加生产环境代码的体积
2.3 框架要做到良好的Tree-Shaking
什么是Tree-Shaking呢?在前端领域,这个概念因rollup.js而普及。简单来说,Tree-Shaking指的是消除哪些永远不会被执行的代码,也就是排查 dead code,现在无论是rollup.js还是webpack,都支持Tree-Shaking。想实现Tree-Shaking必须满足: 模块必须是ESM(ES Module),Tree-Shaking依赖ESM的静态结构:
|—— demo
| —— package.json
| —— input.js
| —— utils.js
了解更多Tree-Shaking,来这里 。
2.4 框架应该输出怎样的构建产物
不同类型的构建产物是为了满足不同的需求,为了用户能够通过<script>标签直接应用并使用,需要输出IIFE(立即调用的函数表达式)格式的资源;为了让用户可以直接通过<script type='module'>引用并使用,需要输出ESM格式的资源,EMS格式的资源有两种: 用于浏览器的 ems-browser.js 和用于打包工具的 ems-bundler.js。
2.5 特殊开关及良好的TypeScript类型支持
框架会提供多种能力或者功能,有时出于灵活性和兼容性考虑,对于同样的任务,框架提供了两种解决方案,例如vue.js中选项对象式API和组合式API都能用来完成页面的开发,两者虽然不互斥,但是从框架设计的角度看,这完全是基于兼容性考虑的。有时用户明确知道自己仅会使用组合式API,而不会使用选项用户就可以通过特殊开关关闭对应的特性,这样打包的时候,关于实现关闭功能的代码会被Tree-Shaking机制排除。
vue.js团队花费很多精力做类型推导,从而做到更好的类型支持外,还要考虑对TSX的支持;但是需要说明的是TS编写代码和对TS类型支持友好是两回事
vue.js3的设计思路
建议阅读原版书籍了解具体实现。
3.1 声明式地描述UI
大家思考一个问题: 编写一个前端页面需要涉及哪些内容?
DOM元素: 例如是div标签还是a标签
属性: 如a标签的href属性, 再如id、 class等通用属性
事件: 如click、keydown等
元素的层级结构: DOM数的层级结构, 既有子节点, 又有父节点
接着思考第二个问题,从框架的设计者角度触发,如何声明式的描述上述的内容呢?
以vue.js3为例:
使用与HTML标签一致的方式来描述DOM元素和属性:
<div id='app'></div>
使用: 或 v-bind来描述动态绑定的属性:
<div :id='dynamicId'></div>
使用 @ 或 v-on来描述事件,如点击事件:
<div @click='handleClick'></div>
使用与HTML标签一致的方式来描述层级结构:
<div><span></span></div>
以上方式都属于使用模板来声明式的描述UI,下面再以JavaScript对象来描述UI:
const title = {
// 标签名称
tag: 'h1',
// 标签属性
props: {
onClick: handler
},
// 子节点
children: [
{ tag: 'span' }
]
}
||
|| 相当于vue.js模板
||
<h1 @click='handler'><span></span></h1>
那么,JavaScript对象描述UI和使用模板有什么区别呢?假设需要描述h1-h6标签
JavaScript对象形式:
// h 标签的级别
let level = 3
const title = {
tag:`h${level}`,// h3 标签
}
模板形式:
<h1 v-if= "level === 1"></h1>
<h2 v-else-if="level === 2" ></h2>
<h3 v-else-if="level === 3"></h3>
<h4 v-else-if="level=== 4" ></h4>
<h5 v-else-if="level=== 5" ></h5>
<h6 v-else-if="level=== 6" ></h6>
答案是不是显而易见,JavaScript对象更具有灵活性,使用JavaScript对象来描述UI的方式其实就是虚拟DOM。
vue.js3支持使用模板描述UI,也支持使用虚拟DOM描述UI:
import { h } from 'vue'
export default {
render() {
return h('h1', { onClick: handler }) //虚拟 DOM
}
}
h函数其实只是一个语法糖,底层实现的返回值还是一个JavaScript对象形式的虚拟DOM结构:
export default {
render() {
return {
tag: 'h1',
props: { onClick: handler }
}
}
}
那么现在通过一系列的努力,咱们已经拿到虚拟DOM的数据结构了,怎么把组件的内容渲染出来呢?上面的render函数就是来渲染UI的,称之为渲染器
3.2 初识渲染器
渲染器的主要作用就是把虚拟DOM渲染成真是DOM:
虚拟DOM
h('div', 'hellow') ---> 渲染器 ---> 真实DOM
如下例子:
// 虚拟DOM
const vnode = {
tag: 'div', // tag 描述标签名称, 如tag: 'div'描述一个<div>标签
props: { // props 为一个对象, 用来描述<div>等标签的属性, 事件等内容
onClick : () => alert('hello')
},
children : '点我' // children用来描述标签的子节点
}
再来编写一个渲染器:
// renderer 和 render 不是一个概念, 前者是名词(渲染器), 后者是动词(渲染)
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(
// 将事件明 onClick ===> click
key.substr(2).toLowerCase(),
// 事件处理函数
vnode.props[key]
)
}
}
// 处理children
if (typeof vnode.children === 'string') {
// 说明元素是文本子节点
el.appendChild(document.createTextNode(vnode.children))
} else if (Array.isArray(vnode.children)) {
// 递归调用 render 函数渲染子节点, 使用当前元素el作为挂载点
vnode.children.forEach(child => render(child, el))
}
// 将元素添加到挂载点下
container.appendChild(el)
}
远行上面实现的渲染器,页面展示预期的结果
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id='app'></div>
</body>
<script>
const vnode = {
tag: 'div', // tag 描述标签名称, 如tag: 'div'描述一个<div>标签
props: { // props 为一个对象, 用来描述<div>等标签的属性, 事件等内容
onClick : () => alert('hello')
},
children : '点我' // children 用来描述标签的子节点
}
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(
// 将事件明 onClick ===> click
key.substr(2).toLowerCase(),
// 事件处理函数
vnode.props[key]
)
}
}
// 处理children
if (typeof vnode.children === 'string') {
// 说明元素是文本子节点
el.appendChild(document.createTextNode(vnode.children))
} else if (Array.isArray(vnode.children)) {
// 递归调用 render 函数渲染子节点, 使用当前元素 el 作为挂载点
vnode.children.forEach(child => render(child, el))
}
// 将元素添加到挂载点下
container.appendChild(el)
}
renderer(vnode, document.querySelector("#app"))
</script>
</html>
根据上面的步骤我们实现了一个简单的渲染器渲染页面,至少从上面的实现来看,我们揭开了渲染器的神秘面纱,我们实现了针对单个标签元素的渲染,那么怎么渲染组件呢?
3.3 组件的本质
上文说到 { tag: 'div' } 用来描述<div>标签,但是组件是什么?一个<div>标签是一个组件,多个div标签组合而成的也是一个组件,组件就是一组DOM元素的封装,如下:
// 一个返回一个DOM结构函数
const MyCompontent = function () {
return {
tag: 'div',
props: {
onClick : () => alert('hello')
},
children : 'click me'
}
}
// 使用虚拟DOM对象表示
const vnode = {
tag: MyCompontent
}
将我们实现的渲染器调整一下:
function renderer(vnode, container) {
if (typeof vnode.tag === 'string') {
// 说明 vnode 描述的是标签元素
mountElement(vnode, container)
} else if (typeof vnode.tag === 'function') {
// 说明 vnode 描述的是组件
mountCompontent(vnode, container)
}
}
function mountCompontent(vnode, container) {
// 调用组件函数,获取组件要渲染的内容即虚拟DOM
const subtree = vnode.tag()
// 递归调用 renderer 渲染 subtree
renderer(subtree, container)
}
function mountElement(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(
// 将事件明 onClick ===> click
key.substr(2).toLowerCase(),
// 事件处理函数
vnode.props[key]
)
}
}
// 处理children
if (typeof vnode.children === 'string') {
// 说明元素是文本子节点
el.appendChild(document.createTextNode(vnode.children))
} else if (Array.isArray(vnode.children)) {
// 递归调用 render 函数渲染子节点, 使用当前元素 el 作为挂载点
vnode.children.forEach(child => render(child, el))
}
// 将元素添加到挂载点下
container.appendChild(el)
}
从上面的实现,细心的同学应该发现了,我们的组件就只能是函数形式的吗?能不能是对象呢?
// 一个对象的 render 函数方法返回一个DOM结构
const MyCompontent = {
render () {
return {
tag: 'div',
props: {
onClick : () => alert('hello')
},
children : 'click me'
}
}
}
修改renderer渲染器
function renderer(vnode, container) {
if (typeof vnode.tag === 'string') {
// 说明 vnode 描述的是标签元素
mountElement(vnode, container)
} else if (typeof vnode.tag === 'object') {
// 如果是对象,说明 vnode 描述的是组件
mountCompontent(vnode, container)
}
}
修改mountCompontent函数
function mountCompontent(vnode, container) {
// vnode.tag 是组件对象,调用 tag 的 render 函数得到组件要渲染的内容即虚拟DOM
const subtree = vnode.tag.render()
// 递归调用 renderer 渲染 subtree
renderer(subtree, container)
}
花费很小的代价就完成了修改,那么还有没有其他形式的表达形式呢?
不管是虚拟DOM还是使用模板,都属于声明式的描述UI,前面我们说到虚拟DOM是如何渲染成真实DOM的,那么模板又是如何工作的呢?下面介绍一下编译器及模板的工作原理
3.4 模板的工作原理
编译器的作用是将模板编译成渲染函数,对于编译器而已模板就是一个普通的字符串,它会分析该字符串并生成一个功能与之相同的渲染函数:
<div @click='handler'>
click me
</div>
render() {
return h('div', { onClick: handler }, 'click me')
}
以.vue文件为例,一个vue文件就是一个组件:
<template>
<div @click='handler'>
click me
</div>
</template>
<script>
export default {
data() {},
methods: {
handler() {}
}
}
</script>
其中<template>标签就是模板内容,编译器会吧模板内容编译为渲染函数并添加到<script>标签块的组件对象上,所以最终在浏览器里运行的代码是:
export default {
data() {},
methods: {
handler() {}
},
render() {
return h('div', { onClick: handler }, 'click me')
}
}
无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后通过渲染器再把渲染函数返回的虚拟DOM渲染为真实DOM,这就是模板的工作原理,也是vue.js渲染页面的流程。各个部分相互关联,互相制约,共同构成了一个有机整体
总结
通过上面的梳理,框架的设计概览这块的内容大致就总结完了,通过学习明白是什么是命令式框架和声明式框架,虚拟DOM及最终选择虚拟DOM作为框架数据结构的来龙去脉,什么是运行时和编译时,以及框架设计的核心要素,然后通过简单的例子实现了最简单的渲染器和编译器,明白了模板的工作原理,明白了vue.js3的设计思路。
写这篇文章之前,心里想简单对书中内容做个总结学习,但是写的过程中发现很多东西都是需要自己去梳理准备的,这样的方式让自己对这些技术点的认识更加的深入,后面自己学会坚持对学到的技术点通过文章表述的形式输出,希望大家能够提出宝贵的建议哦
路虽远行则将至,事虽难做则必成!