框架设计的权衡
框架设计里面到处体现了权衡的艺术。
在框架设计之初,我们的最初的构想往往是“既要….又要….”,但是往往现实是非常残酷的, 因此我们需要处处作出权衡。
- 框架的设计应该将其设计为命令式还是声明式 ?
- 框架需要设计成纯运行时还是纯编译时,还是设计为运行时 + 编译时 ?
这里只是举了一部分例子,但是从这些例子也可以看出,处处都需要权衡。
范式的权衡
从编程范式来看,可以分为两大编程范式:
命令式编程强调的是 How
- 入门门槛是比较低,这也是为什么最初流行都是命令式的编程语言
// 计算数组中偶数的和
const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let total = 0;
for(let i=0; i<arr.length; i++){
if(arr[i] % 2 === 0){
total += arr[i]
}
}
console.log(total);
/*
* 需求:
* 获取 id 为 app 的 div
* 它的文本内容为 hello world
* 为其绑定点击事件
* 当点击时弹出提示 ok
*/
const div = document.querySelector("#app");
div.innerText = 'hello world';
div.addEventListener("click", ()=>alert("ok"));
// 这样的编程范式,思路倒是非常清晰
// 但是项目一大,使用起来非常痛苦

声明式编程
// 计算数组中偶数的和
const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
console.log(arr.filter(i => i % 2 === 0).reduce((a, b) => a + b));
<div @click="()=>alert('ok')">hello world</div>

目前看上去声明式相比命令式要好得多,但是命令式真的就是一无是处么 ?
非也。
声明式代码的性能不可能比命令式更高的,声明式的背后仍然是命令式。
举个具体的例子:我们要将 div 的文本内容修改为 hello vue3,命令式操作如下:
div.textContent = 'hello vue3';
在命令式中,明确的知道了哪些地方发生变化,要做的事情仅仅是做必要的修改即可。
但是声明式就做不到这一点,因为声明描述的是结果:
<!-- 修改前 -->
<div @click="()=>alert('ok')">hello world</div>
<!-- 修改后 -->
<div @click="()=>alert('ok')">hello vue3</div>
既然描述的结果,因此对于框架来讲,需要去找到发生变化,然后最终变化的改变仍然是上面的那一句命令式代码。
假设直接修改性能消耗为 A,找差异的性能消耗为 B:
- 命令式更新的性能消耗 = A
- 声明式更新的性能消耗 = B + A
因此,最终就落在了框架开发者需要作出权衡。
目前,在现代前端框架中,最终,大家都选择了声明式。因为项目的规模越来越大,声明式相比命令式更加好维护。假设采用的是命令式,需要维护的是整个实现目标的过程,声明式需要维护的最终想要的结果。
运行时和编译时的权衡
在进行框架设计的时候,究竟是设计成纯运行时,还是设计成纯编译时,还是设计成运行时+编译时,也需要框架设计者进行权衡。
纯运行时
假设我们设计了一个框架,里面有一个 Render 函数,其他框架的使用者可以调用这个 Render 函数,在调用的时候,可以传入一个树形的数据对象,然后 Render 函数就会根据开发者传入的数据对象进行 DOM 渲染。
假设用户(开发者、框架使用者)提供的数据对象如下:
const obj = {
tag: 'div',
children: [
{
tag: 'span',
children: 'hello world'
}
]
}
我们的框架提供的 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);
}
Render(obj, document.body);
此时,我们所设计的这个框架,就是一个纯运行时框架,里面没有涉及到任何的编译操作,开发者所写好的代码,直接扔给我们的框架运行即可。
运行时 + 编译时
对于框架使用者来讲,通过数据对象来描述 UI,当 UI 规模一大,简直是一场灾难,我们需要提供一种类 HTML 的形式
<div>
<span>hello world</span>
</div>
上面的这种 UI 描述方式,对于框架使用者来讲很爽,但是我们的 Render 不认识这种方式,因此这里涉及到了编译。
所谓编译,就是将 A 语言翻译成 B 语言。
因此我们需要在我们的框架里面,设计一个编译器,假设叫做 Compiler
const html = `
<div>
<span>hello world</span>
</div>
`;
// 先编译
const obj = Compiler(html);
// 再运行
Render(obj, document.body);
此时,我们的框架就变成了运行时 + 编译时的框架。
纯编译时
上面的步骤是先编译成数据对象,然后再交给 Render 运行。也许我们可以一步到位
<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);
此时我们的框架就只需要 Compiler 这个方法进行编译,不再需要 Render 方法,那么此时我们的框架就变成了一个纯编译时框架。
- Vue:运行时 + 编译时
- 编译时:将模板编译成 vnode
- 运行时:将 vnode 交给渲染器进行一个渲染
- Svelte:纯编译时
- 当初的一个噱头就是无虚拟 DOM
框架设计相关要素
下面是关于框架设计时一些核心要素。
给用户良好的反馈
我们用 Vue3 举一个例子:
create(App).mount('#non-exist');
如果传入的节点并不存在,那么 Vue 会给我们一个警告:
[Vue warn]: Failed to mount app: mount target selector "#non-exist" returned null
这条错误实际上就是 Vue 内部所给出的错误警告,这里对失败的原因进行了详细了说明,那么用户也能够快速定位问题所在。
假设 Vue 内部针对这个错误没有任何的处理,那么最终用户那边得到的就是:
TypeError: Cannot read property 'xxx' of null
这样对于用户而言,很难定位错误。
控制框架代码的体积
一般来讲,要给用户提供良好的开发体验,那么自然框架内部的代码就会越多,但是这里又要控制代码的体积,感觉有点冲突
实际上,可以通过一些打包工具的特性来很好的做到一个平衡。
例如在 Vue 或者 React 源码中,当调用 warn 函数来输出一个警告的时候,通常会配合一个 __DEV__ 常量:
if(__DEV__ && !res){
warn(`Failed to mount app: mount target selector "${container}" returned null`);
}
__DEV__ 实际上就是看你是否处于开发环境,最终在进行构建的时候
如果是开发环境,那么 __DEV__ 会被设置为 true,那么最终构建的开发环境资源就会包含这部分代码
如果是生产环境,那么 __DEV__ 就会被设置为 false,那么最终的构建产物就不包含这部分代码
这样我们就做到了 在开发环境为用户提供友好的警告信息,但是在生产环境能够缩小体积。
良好的Tree-shaking
Tree-shaking 这个概念最早是 rollup 所提出的,简单来讲就是消除 dead code,目前无论是 rollup 还是 webpack 都支持 Tree-shaking。
构建产物
我们所设计的框架,往往用户所使用的场景是多种多样的。
一个 Vue 既可以通过 CDN 的方式来引入:
<body>
<script src="path/to/vue.js"></script>
<script>
const { createApp } = Vue
// ...
</script>
</body>
也可以通过 ESM 的方式来引入:
<script src="path/to/vue.esm-brower.js" type="module"></script>
甚至还有的用户需要通过 require 的方式来引入:
const Vue = require('vue');
因此,这就要求我们在对框架进行构建的时候,需要输出不同的包。幸亏有现代现代打包工具,无论是 rollup 还是 webpack,都能很轻松的输出不同格式的包,还可以一次性指定多种格式:
// rollup.config.js
import somePlugin from 'some-rollup-plugin'; // 例子中的插件
export default [
// IIFE 配置
{
input: 'src/index.js',
output: {
file: 'dist/bundle.iife.js',
format: 'iife',
name: 'MyBundle'
},
plugins: [somePlugin()]
},
// ESM 配置
{
input: 'src/index.js',
output: {
file: 'dist/bundle.esm.js',
format: 'esm'
},
plugins: [somePlugin()]
},
// CJS 配置
{
input: 'src/index.js',
output: {
file: 'dist/bundle.cjs.js',
format: 'cjs'
},
plugins: [somePlugin()]
}
];
错误处理
这个也是衡量一个框架是否成熟的一个重要标志。框架的错误处理机制的好坏直接决定了用户应用程序的健壮性,以及用户在处理错误时的一个心智负担。
举个例子,假设我们的框架提供一个工具模块:
export default {
foo(fn){
// 其他逻辑...
fn && fn()
}
}
用户在使用这个工具的时候,大概率就是这样使用的:
import utils from 'utils';
utils.foo(()=>{
// 用户的逻辑
})
接下来思考一个问题🤔
假设用户所提供的回调函数在执行的时候,报错了,该怎么办 ?
方案一
既然是用户的回调出错了,用户自己处理,执行 try…catch
import utils from 'utils';
utils.foo(()=>{
try{
// ...
} catch(e){
// ...
}
})
我们的方案一,用户需要自己手动的添加 try…catch 来捕获错误,这样实际上会增加用户的心智负担。
方案二
由我们来代替用户提供统一的错误处理方案:
export default {
foo(fn){
// 其他逻辑...
try{
fn && fn()
}catch(e){
// ...
}
},
bar(fn){
// 其他逻辑...
try{
fn && fn()
}catch(e){
// ...
}
}
}
我们可以针对上面的方案做一个改进:
export default {
foo(fn){
// 其他逻辑...
callWithErrorHandler(fn)
},
bar(fn){
// 其他逻辑...
callWithErrorHandler(fn)
}
}
function callWithErrorHandler(fn){
try{
fn && fn()
}catch(e){
// ...
}
}
方案三
还可以继续优化,目前是我们框架内部提供了统一的错误处理机制,但是有些时候,用户想要自定义错误处理,所以我们可以再次改造:
let handleError = null;
export default {
foo(fn){
// 其他逻辑...
callWithErrorHandler(fn)
},
bar(fn){
// 其他逻辑...
callWithErrorHandler(fn)
},
// 多提供一个方法
registerErrorHandler(fn){
// fn 就是用户所提供的自定义错误处理机制
handleError = fn;
}
}
function callWithErrorHandler(fn){
try{
fn && fn()
}catch(e){
if(handleError){
// 说明用户是自定义了错误处理机制的
handleError(e);
return
}
// ... 使用框架内部的错误处理机制
}
}
那么此时,用户在使用我们的框架的时候,就可以自定义错误处理:
import utils from 'utils';
utils.registerErrorHandler(function(){
// 用户自定义的错误处理机制
});
utils.foo(()=>{
// 用户的逻辑
})
实际上,在 Vue 的内部,也提供了类似了的机制,让用户可以自己来注册统一的错误处理函数:
import App from 'App.vue'
const app = createApp(App)
app.config.errorHandler = () => {
// 错误处理程序
}
还要很多其他的点:
- 良好的 TS 支持
- 源码管理方式
- 是否提供脚手架
- 实际上,脚手架的原理非常的简单,脚手架的本质是一个命令行工具,在这个命令行工具里面,可以读取用户的选择,之后根据用户的选择,从远程仓库克隆对应类型的项目到你本地。
发表回复