框架设计里面到处体现了权衡的艺术。
在框架设计之初,我们的最初的构想往往是“既要…又要…”,但是往往现实是非常残酷的, 因此我们需要处处作出权衡。
这里只是举了一部分例子,但是从这些例子也可以看出,处处都需要权衡。
从编程范式来看,可以分为两大编程范式:
命令式编程
// 计算数组中偶数的和
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:
因此,最终就落在了框架开发者需要作出权衡。
目前,在现代前端框架中,最终,大家都选择了声明式。因为项目的规模越来越大,声明式相比命令式更加好维护。假设采用的是命令式,需要维护的是整个实现目标的过程,声明式需要维护的最终想要的结果。
在进行框架设计的时候,究竟是设计成纯运行时,还是设计成纯编译时,还是设计成运行时+编译时,也需要框架设计者进行权衡。
假设我们设计了一个框架,里面有一个 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 方法,那么此时我们的框架就变成了一个纯编译时框架。
下面是关于框架设计时一些核心要素。
我们用 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 这个概念最早是 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 = () => {
// 错误处理程序
};
还要很多其他的点: