第一篇:框架设计概览
第一章:权衡的艺术
声明式与命令式
视图层框架的设计往往分为命令式与声明式。
- 命令式设计关注于过程,注重于代码与逻辑的对应关系,其代表是jQuery。
- 声明式设计关注于结果,其中封装了实现的过程,只暴露最后的声明式操作给用户,其代表是Vue.js。
声明式设计的性能并不优于命令式设计。对于一次更新操作而言,命令式代码可以直接进行修改,而声明式的设计本质上是封装了命令式代码后的结果,其需要首先找到前后差异,然后再执行对应的修改过程。声明式代码的更新性能损耗 = 找出差异的性能损耗 + 直接修改的性能损耗。因此可知,声明式设计本质是用更多的性能损耗换取了可维护性和对用户更直观的操作体验。
理论上尽可能缩小“找出差异的性能损耗”可以最大限度上提升声明式设计的性能,因此可以引入“虚拟DOM”。
未来真的还会有命令式的框架吗
为什么选择虚拟DOM
对于创建并更新一些DOM元素,我们可以直接采用命令式的方法,使用document.createElement这样直接进行DOM操作的原生方法,显然这是性能最好的,但同样式心智负担最大的。另外两种方法,一个是是采用“模板”,维护一个字符串,使用innerHTML进行渲染,一个是采用虚拟DOM的方式,维护JS对象(VNode)然后再进行DOM操作。对于创建DOM元素来说,两者的性能相差不大,本质都是JS运算 + DOM操作。而在更新页面过程中,前者会先更新字符串,然后销毁所有DOM再创建所有DOM,而后者只需要经过一次Diff然后更新变化的部分就可以了。显然,一次Diff操作只是JS层面的运算,它的性能损耗是远小于全量DOM更新的。因此可知虚拟DOM可维护性强,性能也不错,实现了一种在可维护性与性能间的“权衡”。
这里描述了三种直接或间接操作DOM的方法,想知道是否还存在其他方法呢
运行时与编译时
设计一个框架时,有三种选择,分别是:纯运行时的,纯编译时的,运行时+编译时。
所谓纯运行时,就是我们的框架有一个render函数,它可以获取一个树形结构的数据对象,然后使用DOM操作将其渲染出来。用户需要的就是写出这个树形结构的对象。但纯运行时对用户不太友好,因为对用户来说手写这个树形对象并不直观,他们可能更愿意写熟悉的HTML。我们就可以引入Compiler,将用户所写的HTML的进行一个编译转换成我们所需要的树形结构对象,然后再render出来,这就是运行时+编译时。至于纯编译时,那么就是将HTML字符串直接转化为命令式代码。
相比较而言,纯编译时的设计理论性能更好,其中的代表是Svelte。Vue.js则采用了运行时+编译时,更具灵活性。
Todo: 了解更多Svelte相关
第二章:框架设计的核心要素
提升用户的开发体验
所谓提升用户的体验,其实主要就是要做到始终提供友好的警告消息。Vue.js有一个专门的warn函数用来提供告警信息(其实最后就是调用console.warn)。
除了提供足够的告警信息之外,还有其他的切入点,比如编写自定义的formatter,使得在浏览器调试过程中查看输出更方便。
控制框架代码体积
框架的大小是衡量框架的标准之一。这里主要提到的就是尽可能隔离开发环境和生产环境,类似于告警信息这样的代码不应当出现在生产环境的最终产物里。
良好的Tree-Shaking
所谓的Tree-Shaking就是指消除永远不会执行的代码(dead code),比如一个组件并没有被用户使用到,那么这个组件的相关代码就不应该出现在最终产物里。
一般rollup.js / webpack之类的工具都可以做到Tree-Shaking,框架设计开发中所需要做的就是利用好打包压缩工具,尽可能精简最终产物。
构建产物类型
除了区分开发环境和生产环境之外,我们还需要为框架做好不同使用场景的区分和适配。
首先是script标签引入框架的场景,这种场景需求下我们需要打包输出一个IIFE格式的资源,这样函数表达式执行后Vue全局变量就可用了。另外类似的就是ESM格式,用来让用户可以通过<script type="module">的方法来导入。另一个场景就是服务端渲染,这时候需要CommonJS的模块格式。Vue.js是通过rollup的配置来完成上述场景的区分。
特性开关
特性开关就是通过设置一些变量可以控制是否导入一些功能。该机制不仅有利于缩小包体积,而且也为后续更新迭代提供了更多灵活性。
错误处理
错误处理是框架设计中非常重要的一部分,它决定了用户应用程序的健壮性和开发过程中的心智负担。我们一般可以设计一个捕捉并传递错误信息的监控系统:
let handleError = null;
export default {
foo(fn) {
callWithErrorHandling(fn);
}
registerErrorHandler(fn) {
handeError = fn;
}
}
function callWithErrorHandling(fn) {
try {
fn && fn();
} catch (e) {
handleError(e);
}
}
上面代码中我们设计了一个错误捕捉函数callWithErrorHandling,收集到可能的错误信息后传递给handleError,这个handleError可以由用户自己注册定义。
这里还有一节关于TS类型支持的内容,略过没有记
这章大体上就几个方面的细节进行了介绍和探讨,聚焦于为开发者提供更好的开发体验,主要从错误收集与处理、产物的体积与类型等方面入手。
第三章 Vue.js的设计思路
声明式地描述UI
对于一个前端界面来说,主要有以下几个部分:DOM元素、属性、事件、层级。
Vue.js是一个声明式的框架,主要有两种方式。一个是模板式的,DOM元素/层级与HTML标签表述保持一致,而属性和事件则对应v-bind和v-on指令。另一种是通过创建一个JS对象,比如:
const title = {
tag: 'h1',
props: {
onClick: handler
},
children: [
{ tag: 'span' }
]
}
相比较而言,使用对象来进行描述更灵活一些,而这个对象其实也就是虚拟DOM。
渲染器简介
渲染器的作用就是将虚拟DOM渲染为真实DOM。一个简单的渲染器如下,它接受一个虚拟DOM节点和对应的挂载点,将真实DOM渲染在该挂载点下:
function renderer(vnode, container) {
const el = document.createElement(vnode.tag);
// 监听事件
for (const key in vnode.props) {
if (/^on/.test(key)) {
el.addEventListner(
key.substr(2).toLowerCase(),
vnode.props[key]
)
}
}
// 处理子节点
if (typeof vnode.children === 'string') {
// 处理文本子节点
el.appendChildren(document.createTextNode(vnode.children));
}else if(Array.isArray(vnode.children)) {
// 递归处理子节点
vnode.children.forEach(child => renderer(child, el))
}
container.appendChild(el);
}
以上代码简单来说分三步:1. 创建元素 2. 添加属性和事件 3. 处理children。这样就完成了一个虚拟DOM节点到真实DOM的渲染过程。
组件的本质
组件就是一组DOM元素的封装,虚拟DOM可以模拟真实DOM,也就可以描述组件。一种常见的组件形式就是一个返回渲染内容的函数:
const myComponent = function() {
return {
tag: 'div',
props: {},
children: ''
}
}
同样的,tag: 'div'可以用来描述一个DOM标签,可以用tag: MyComponent来描述一个组件。可以通过改造渲染器,添加对组件的识别来完成这个功能:
function renderer(vnode, container) {
if(typeof vnode.tag === 'string') {
mountElement(vnode, container);
} else if(typeof vnode.tag === 'function') {
mountComponent(vnode, container);
}
}
function mountComponent(vnode, container) {
const subtree = vnode.tag();
renderer(subtree, container);
}
在mountComponent实现中,subtree其实就是直接调用函数组件返回的虚拟DOM本身,然后把这个虚拟DOM直接再传给renderer渲染器完成渲染即可。
除了函数之外,也可以使用一个对象来代表一个组件,形式和函数类似,给这个对象添加一个叫render的函数返回虚拟DOM即可。
const MyComponent = {
render() {
return {
tag: 'div',
props: {};
}
}
}
模板的工作原理
Vue.js中另一个重要组成部分就是编译器,它会将模板编译为一个与之功能相同的渲染函数,并添加到组件对象上,之后再由渲染器把渲染函数返回的虚拟DOM渲染为真实DOM。
模板的工作其实就是通过编译器把模板编译为渲染函数,这一节并没有介绍编译器的工作原理,暂时先略过了