前端的编译器,除了可以做转换,是否还有其它用途?
由于现在国内“前端主流框架”的称号基本被 React、Vue、Angular 占领,前二者在渲染层的技术,本质上都是极为相似的:在内存中维护 Virtual DOM,通过某些方式获取到变量被修改,然后利用 DOM Diff 算法(以及一些优化)计算出需要对 DOM 做的操作,再 Patch 到真实的 DOM 上。相信大家(尤其是准备过面试的小伙伴)一定对这些内容比较了解。
Angular 的最新版使用了一个叫 Ivy 的引擎。对于 Angular 来说,脏值检测跟之前的方法类似(监听用户输入、劫持 setTimeout
等),渲染方面则是用了 incremental-dom 的思想做增量渲染。
我第一次听说 Svelte(读作 [svɛlt]),也只是在两个月前。我刚看到 Svelte 3 文章时,就被“编译型框架”的字样吸引住了。据说它的编译器帮忙做了很多工作,可以不用脏值检测、setState、Proxy 等方式来触发视图更新,也没有了 DOM Diff 的代价。我决定好好研究一番。
Svelte 3 的特点
在翻阅 Svelte 的博客时,很容易翻到这篇文章:Virtual DOM is pure overhead。通读下来,作者的观点是,Virtual DOM 可以做到状态驱动 UI,性能也不差,并且代码的 Bug 更少;但 Svelte 不用 Virtual DOM 也可以做到类似的效果,并且性能更高。
模板语法、模板数据分离
本文的重点不是详细讲述 Svelte 3 的语法(照着文档来就可以了),这里只举一些最常用也最简单的例子,一个组件基本长这样,跟 Vue 的组件长得差不多,但有点区别。我列举了变量、双向绑定、事件处理、Watch、常见的控制语句、生命周期这几个特性:
<!-- App.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
// 1:变量、双向绑定、事件处理
let name = 'world';
const onClick = () => name = 'Rex';
// 2:`if` 块、Watch
let x = 0;
const incX = () => x++;
// 这就像 Vue 中的 `watch` 功能
// 语法很奇怪,但是是合法的
$: doubleX = x * 2;
// 3:`each` 块
let characters = [
{ name: 'Aroma White' },
{ name: 'Neko Asakura' },
{ name: 'ROBO Head' }
];
const append = (name: string) => {
if (!characters.find(t => t.name === name)) {
characters = [...characters, { name }];
}
};
// 生命周期
onMount(async () => {
const res = await fetch(`/photos`);
const photos = await res.json();
console.log(photos);
});
</script>
<main>
<section class="my-section-1">
<h1>Hello {name}!</h1>
<input bind:value={name}>
<button on:click={onClick}>I'm Rex</button>
</section>
<section class="my-section-2">
{#if x > 10}
<p>{x} is greater than 10</p>
{:else if x < 5}
<p>{x} is less than 5</p>
{:else}
<p>{x} is between 5 and 10</p>
{/if}
<p>Double x is {doubleX}</p>
<button on:click={incX}>x++</button>
</section>
<section class="my-section-3">
<button on:click={() => append('Simon Jackson')}>
Add Simon
</button>
<ul>
{#each characters as c, i}
<li>{i + 1}: {c.name}</li>
{/each}
</ul>
</section>
</main>
<style>
main {
font-size: 18px;
}
</style>
单看 <main>
的部分,是不是感觉找回了上古时期写 PHP 和 Django 模板的感觉?虽然语法比较复古,但其实现代前端支持的东西,它基本都能做:
- 组件化:每个
.svelte
文件就是一个组件,并且也完整支持 Props、Context、Slot; - 完整的生命周期:
onMount
、onDestroy
、beforeUpdate
、afterUpdate
。
利用编译器实现响应式
那么它是怎么做的呢?记得我之前年少轻狂写 rsjs
的时候(那时候的主流还是 Angular 1),由于水平太菜,迟迟不知道如何做到 Angular 的脏值检测,于是只好退而求其次,搞了个 setData
函数,并且每次数据更新都是完整渲染,可以说是效率很低了。这其实跟 React 更贴近一些……
其实 Svelte 以前也是这样做的,但 Svelte 3: Rethinking reactivity 这篇文章的意思是,由于 Svelte 3 在编译期间就可以知道每个变量在模板的什么地方被用到,因此只要变量有被模板用到,编译器就会在该变量的所有“直接赋值”的语句外面包一个 $$invalidate
函数,例如这样:
// 原始代码:
count += 1;
// 会被编译成这样的形式:
$$invalidate('count', count += 1);
至于 Array.prototype.push
之类的函数,以及修改引用值,Svelte 没有像 Vue 那样劫持原生函数,也没有在编译器中做引用判断,因此需要开发者变通一下,转换成对变量直接赋值的语句。鉴于 React 也是这样的要求(每次 setState
都需要设置一个新对象),因此这一点我觉得是可以接受的。
局部更新和直接操作 DOM
由于 Svelte 3 使用的是类似上古时期的模板语法,没有 TSX Render Function 这么灵活的功能,因此可以更好地被用作静态分析。
我我把刚刚的代码编译了一下(详见彩蛋),在未压缩的模式下,很容易找出每个函数和变量在源代码中的位置。大概看了一眼,能得出这样的结论:
- 数据会被统一打包到一个数组中方便管理,类似 React Hooks 的线性存储方式;
- 模板被转换成渲染函数,每个 if 和 each 块也被转换成渲染函数,方便局部更新;
- 对于每个渲染函数,有 Create、Make Tree、Patch、Detach 四个操作;
- 没有 Virtual DOM,而是为每个块定制 Create 和 Make Tree 操作,直接修改 DOM;
- Vue 通过 Proxy 来实现的监听,在 Svelte 3 中成了为每个块定制的 Patch 操作;
- 在卸载的时候,会先卸载组件,再执行清理函数(取消监听器之类的)。
其它
对 Svelte 3 的更具体的分析,由于网上已经有人写了(详见文末的参考资料),这里就不多提了。
我从中得到的启示
1. Virtual DOM 并不是唯一出路
由于我和我的前端朋友们几乎全都是写 React 和 Vue 的,因此我们很自然地认为 Virtual DOM、TSX 等方式已经足够了。但是 Svelte 3 跟快被我们忽略的 Angular,坚持采用模板的方式,没有拥抱 Virtual DOM,并且也取得了不错的成果。这说明 Virtual DOM 并不是唯一出路。
个人觉得,Angular 现在之所以没有成为主流,是因为它是一整套服务,上手难度高,而非因为没有拥抱 Virtual DOM。
2. 无论如何编码,总会有 Tradeoff
我们应该听到过很多“最佳实践”,或者是“错误的写法”,例如:
- 在 React 中,不管是
setState
还是 Reducer,每次必须返回一个新的对象; - 在 React Hooks 中,一个 Hooks 必须只能写在函数式组件里或另一个 Hooks 里;
- 在 Vue 的
data
中必须要声明所有用到的字段,否则必须要用$set
来更新; - 在 Vue 的模板中,不能直接使用模块内变量或全局变量,需要通过
data
传过去; - 在 immerjs 中,不能认为
a=b
之后修改a
对象就是修改b
,它俩不是同一个东西。
这些都是为了实现库或框架的功能而必须的 Tradeoff,因为过度自由的编码,会导致你的代码无法通过统一的算法来检测和优化,也无法让你的代码更加工程化。举个例子,我在写面单编辑器的时候针对业务数据做了一些限制,否则不可能一个人在一周的时间内快速完成;TypeScript 的代码可达性检测也不能检测停机问题。
Svelte 3 针对编译器的功能,限制了用户使用 Render Function 以及 Virtual DOM,以及不能对需要响应变化的变量用 push
这些可以原地操作数据的函数(对象的深层次赋值是可以的),虽然会失去一定的灵活性,但可以让编译器帮你做更多的事情。
3. 编译器能做的事情,比我认为的要多
其实这一点,搞二进制开发、逆向的大佬们感触应该会更深。例如:
- C 语言程序开始执行后并不会先进入
main
函数,而是一个叫做_start
的函数; - 上古时期
x * 9
会被开发者写成x << 3 + x
,但现在大部分优化都被编译器做了(而且绝对比你手工优化做的好); - 事实上,C 编译的过程就是把支持了很多特性的高级语言,转换成只支持基本指令的机器语言,源代码和编译产物往往很不相似(这也是为什么 C 逆向比 JS 逆向难得多)。
前端编译器最开始的作用,无非是转换一些语法、添加一些 Runtime;后来逐渐出现了各种 Webpack Loader 和 Rollup 插件,支持了更多的文件格式(.vue
文件转换前后整个结构就变了),这也在某种程度上反映出前端在复杂度和工程化程度上的提升。这些东西也成了一些公司面试的必考点,用来区分普通程序员和高级工程师。
这次 Svelte 3 的编译器,在保留了“响应式前端”概念的基础之上,进一步让代码得到了简化,并且也极大减少了运行时代码的执行,为世界的碳排放做出了一定的贡献。(咦?)
我有一个小预测:随着前端复杂度和工程化程度的进一步提升,未来的编译器一定可以帮我们做更多的事情,现有的框架可能也会将一部分在运行时的功能使用编译器辅助实现。由于编译器的功能越来越多,面试的要点会逐渐从框架的原理,变成编译器的原理。
4. 优化除了提高算法效率,还有预处理
在以前当赛棍的时候,经常会提到两个概念:时间复杂度、空间复杂度。利用空间换时间的技术一般被叫做“预处理”,即提前计算好一些东西,用的时候直接调用。
谷歌出的一个代码压缩器 GCC(Google Closure Compiler),可以帮忙做一些 Inline 的操作,以及提前计算,避免在运行的过程中重复取值。不过一些优化过于激进,可能会导致代码无法运行,加上后来出现的 Terser 等工具做的确实不错,因此 GCC 没有被广泛使用。
而 Svelte 3 的做法,则是利用编译器直接计算出每个值改变时需要做什么,相当于直接把脏值检测跟 DOM Diff 计算后的执行流程写死到了组件的代码里,免去了在运行时计算的过程。虽然代码会比较长,但由于重复操作多,信息量小,因此压缩后并不会占用太多的空间。这是个既省空间又省时间的做法。
P.S. 三年前我面试 Shopee 的时候,老板问了我一个主观问题(之前的文章可以看 这里):
如果单从修改 DOM 结构来看,React + DOM Diff 算法与纯 jQuery 直接操作(假设已经知道该如何最快的 Patch)相比,哪个效率比较高?
我的答案是:当结构不复杂的时候,DOM Diff 的耗时占比重是不能被忽略的,此时当然是 jQuery 快;但是当结构复杂了之后,DOM Diff 的耗时占比重就比较低了,更多的时间被消耗在 DOM 的操作上——React 是直接 Patch 到 DOM 上,但 jQuery 是在原生 DOM 上面封装了一层,所以会再慢一些,这个的时间是要多于 DOM Diff 的。
当时我并不知道标准答案是什么,现在看前端的趋势也没必要再考虑 jQuery 了。但如果再加上 Svelte 3,问题就有意思起来了。Svelte 由于把预处理的时间放到了编译期间,在运行时也只对 DOM 操作做了最简单的封装,因此既没有 React 的 DOM Diff 时间,又比 jQuery 要快。
究竟封装有多简单呢?可以看一下 Svelte 3 的工具函数,基本相当于没有封装啊……估计是为了以后添加更多的钩子吧:
function append(target, node) {
target.appendChild(node);
}
function insert(target, node, anchor) {
target.insertBefore(node, anchor || null);
}
function detach(node) {
node.parentNode.removeChild(node);
}
function element(name) {
return document.createElement(name);
}
function text(data) {
return document.createTextNode(data);
}
function space() {
return text(' ');
}
function listen(node, event, handler, options) {
node.addEventListener(event, handler, options);
return () => node.removeEventListener(event, handler, options);
}
function attr(node, attribute, value) {
if (value == null)
node.removeAttribute(attribute);
else if (node.getAttribute(attribute) !== value)
node.setAttribute(attribute, value);
}
function children(element) {
return Array.from(element.childNodes);
}
function set_input_value(input, value) {
input.value = value == null ? '' : value;
}
彩蛋
到底该信哪一份数据
我在统计框架使用量的时候,一开始认为开发者提的问题越多,代表这个框架越火。根据 StackOverflow 在 2020 年对接近 65000 个开发者的调查(传送门),可以发现最主流的几个框架依次是 Angular/Angular.js、React、Vue。这跟我的认知不符,因为在国内我几乎没有遇到多少 Angular 的开发者,反而因为 Vue 的上手难度低,导致中小型公司更喜欢用 Vue。
我在想,可能是因为幸存者偏差,可以再看看 NPM 上面的下载量。于是我用 npm-stat 统计了一下三大框架在 2020 年的下载量,结果是这样的:
可以看到 React 基本占了主流,跟 Angular 简直是数量级的差距。
但是 NPM 的下载量就一定能说明结果吗?是否有可能因为 Angular 的使用者由于构建优化的更好,避免了每次流水线都从 NPM 下载?我不了解,也不知道从何了解。
编译结果究竟长啥样
答案:长这样,长相并不好看。(摊手)
我已经尽可能添加注释帮助理解了,如果不愿意看代码,把注释当文章来读也是可以的。
// 一堆通用的工具函数,用来创建 DOM、设置数据等
import { ... } from "svelte/internal";
import { onMount } from "svelte";
// 一个取决于组件数据的工具函数
// 用于找到 `each` 块中的 Context
function get_each_context(ctx, list, i) {
const child_ctx = ctx.slice();
child_ctx[9] = list[i];
child_ctx[11] = i;
return child_ctx;
}
// 第 48 行的 else 块,主要包含了四个函数
function create_else_block(ctx) {
// 这些是每一个 DOM 元素
let p;
let t0;
let t1;
return {
// c:创建(Create)
// 调用工具函数,依次创建每一个 DOM 元素
c() {
p = element("p");
// 这里 ctx[0] 就对应了 x,编译器的注释标的恰到好处
t0 = text(/*x*/ ctx[0]);
t1 = text(" is between 5 and 10");
},
// m:建树(Make Tree)
// 通过创建好的 DOM 元素,将 DOM 树构建好
m(target, anchor) {
insert(target, p, anchor);
append_1(p, t0);
append_1(p, t1);
},
// p:更新(Patch)
// 在这个 else 块中,只依赖了一个 x,因此只有一个 if 语句
// 若组件被标记为 dirty,且 x 被改
// 则调用工具函数 set_data 直接更新 DOM 元素中的 innerText
// Svelte 将“某变量被修改”的标记用位运算做了压缩,详见参考资料
p(ctx, dirty) {
if (dirty & /*x*/ 1) set_data(t0, /*x*/ ctx[0]);
},
// d:卸载(Detach)
// 当组件被卸载时需要做的事情,一般是卸载元素
// 如果有事件监听的话也会取消监听
d(detaching) {
if (detaching) detach(p);
}
};
}
// 接下来的两个函数分别是:
// 第 46 行的 else 块
// 第 44 行的 if 块
// 里面长得跟刚才基本一样,不多提了
function create_if_block_1(ctx) { ... }
function create_if_block(ctx) { ... }
// 第 59 行的 each 块
// 稍微有一点区别,因为有 key
// 并且模板渲染用到的数据不是 JS 的基本类型
function create_each_block(ctx) {
let li;
// 因为在模板中我们写了 i + 1
let t0_value = /*i*/ ctx[11] + 1 + "";
let t0;
let t1;
// 因为在模板中我们写了 c.name
let t2_value = /*c*/ ctx[9].name + "";
let t2;
return {
c() {
li = element("li");
// 这里直接调用 i + 1 的值
t0 = text(t0_value);
t1 = text(": ");
// 这里直接调用了 c.name 的值
t2 = text(t2_value);
},
m(target, anchor) { ... },
p(ctx, dirty) {
// 这里除了判断组件是 dirty 和 characters 被修改以外
// 还多判断了一步 characters[i].name 是否被修改
if (
dirty & /*characters*/ 4 &&
t2_value !== (t2_value = /*c*/ ctx[9].name + "")
) {
set_data(t2, t2_value);
}
},
d(detaching) { ... }
};
}
// 整个组件的创建函数
function create_fragment(ctx) {
// 依旧是各种 DOM 元素
// ...为了简洁,我只保留了 main,省略其它所有变量
let main;
// 这个用来判断组件是否已经挂载结束
let mounted;
// 这个用来收集在卸载时需要做的事情,是一个数组
// Svelte 的监听器跟 Angular、React.useEffect 类似
// 都是返回一个卸载函数
let dispose;
// 第 44 行的 if 块,在这里被转换成了真正的 JS
// 这里判断该使用哪个渲染函数来渲染 if 块
function select_block_type(ctx, dirty) {
if (/*x*/ ctx[0] > 10) return create_if_block;
if (/*x*/ ctx[0] < 5) return create_if_block_1;
return create_else_block;
}
// 第 44 行的 if 块,需要用这个渲染函数来渲染
let current_block_type = select_block_type(ctx, -1);
let if_block = current_block_type(ctx);
// 第 59 行的 each 块,需要转换为若干个渲染函数
// 每个函数都只负责渲染其中的一次迭代
let each_value = /*characters*/ ctx[2];
let each_blocks = [];
for (let i = 0; i < each_value.length; i += 1) {
each_blocks[i] = create_each_block(
get_each_context(ctx, each_value, i)
);
}
// 跟刚才类似的几个函数,只不过复杂了很多
return {
// 这里的 c 里面除了创建基本的元素以外
// 还要调用刚刚 if、each 相关的 c 函数
c() {
main = element("main"); // ...省略其它
// 这里调用了 if 块的 c 函数
if_block.c();
t7 = space(); // ...省略其它
// 这里调用了 each 数组中每个元素的 c 函数
for (let i = 0; i < each_blocks.length; i += 1) {
each_blocks[i].c();
}
// 为刚创建好的 DOM 元素设置属性
attr(section0, "class", "my-section-1");
attr(section1, "class", "my-section-2");
attr(section2, "class", "my-section-3");
// 组件自身的 Scoped CSS 在编译阶段即完成
attr(main, "class", "svelte-5if2as");
},
// 跟之前类似,利用创建好的 DOM 元素,构建 DOM 树结构
// 这里也会调用每个 if、each 块中的 m 函数
m(target, anchor) {
insert(target, main, anchor); // ...省略其它
// 模板中的 input 有个双向绑定
set_input_value(input, /*name*/ ctx[1]);
append_1(section0, t4); // ...省略其它
for (let i = 0; i < each_blocks.length; i += 1) {
each_blocks[i].m(ul, null);
}
// 执行到这里,其实已经挂载完成了
// 收集一下卸载时需要执行的函数,然后标记为 mounted
if (!mounted) {
dispose = [
listen(input, "input", /*input_input_handler*/ ctx[7]),
listen(button0, "click", /*onClick*/ ctx[4]),
listen(button1, "click", /*incX*/ ctx[5]),
listen(button2, "click", /*click_handler*/ ctx[8])
];
mounted = true;
}
},
// 需要 Patch 的变量变得多了起来
// 可以看出来,这是依据组件内容生成的代码
// 随着组件中用到的变量增多,这个函数会变得越来越大
p(ctx, [dirty]) {
if (dirty & /*name*/ 2) set_data(t1, /*name*/ ctx[1]);
// 这里是双向绑定
if (dirty & /*name*/ 2 && input.value !== /*name*/ ctx[1]) {
set_input_value(input, /*name*/ ctx[1]);
}
// ...省略其它
},
i: noop,
o: noop,
// 在卸载的时候:
// 先卸载组件
// 再卸载所有块
// 最后执行之前收集的清理函数
d(detaching) {
if (detaching) detach(main);
if_block.d();
destroy_each(each_blocks, detaching);
mounted = false;
run_all(dispose);
}
};
}
// 这一段是源代码中的 <script> 部分
// 编译器很贴心,把我在源代码中写的注释都给保留了
function instance($$self, $$props, $$invalidate) {
let doubleX;
let name = "world";
const onClick = () => $$invalidate(1, name = "Rex");
// 2:`if` 块、Watch
let x = 0;
const incX = () => $$invalidate(0, x++, x);
// 3:`each` 块
let characters = [
{ name: "Aroma White" },
{ name: "Neko Asakura" },
{ name: "ROBO Head" }
];
const append = name => {
if (!characters.find(t => t.name === name)) {
$$invalidate(2, characters = [...characters, { name }]);
}
};
// 生命周期
onMount(async () => {
const res = await fetch(`/photos`);
const photos = await res.json();
console.log(photos);
});
function input_input_handler() {
name = this.value;
$$invalidate(1, name);
}
const click_handler = () => append("Simon Jackson");
// 补充:
// 在这里 doubleX 被直接套在了一个叫 update 的函数中
// 并且也用跟 p 函数类似的方式来判断是否需要更新(如果 x 被改变)
// 如果有其它 Watch 的变量,也会被放在这里
$$self.$$.update = () => {
if ($$self.$$.dirty & /*x*/ 1) {
// 这就像 Vue 中的 `watch` 功能
// 语法很奇怪,但是是合法的
$: $$invalidate(3, doubleX = x * 2);
}
};
return [x, name, characters, doubleX, onClick, incX, append, input_input_handler, click_handler];
}
// 很简单地对外暴露一个实例
class App extends SvelteComponent {
constructor(options) {
super();
init(this, options, instance, create_fragment, safe_not_equal, {});
}
}
export default App;
参考资料
- 【译】Angular Ivy的变更检测执行:你准备好了吗? | tc9011's
- 都2020年了你还不知道Svelte(2)—— 更新渲染原理 | 码农家园
- Advanced Compilation | Closure Compiler | Google Developers
Update 2021-01-18
经大佬提示,有个叫 State of JS 的网站会对开发者做调查,并发布一份报告。在链接中,可以看到目前用途最广泛的是 React,然后 Angular 比 Vue 略高一筹。此外,Svelte 无论是满意度、使用量还是价值,都在稳步提升,公众对它也始终保持很高的兴趣。
Update 2021-01-21
从另一个大佬那里得知了 Vue 有两个新提案:New script setup and ref sugar,第一个是为了直接用最新的 setup
函数替代之前的组件创建流程,第二个是跟 Svelte 类似,利用 JS Label 的语法来做响应式的语法糖。
它俩分别是这样用的:
<script setup>
// imported components are also directly usable in template
import Foo from './Foo.vue'
import { ref } from 'vue'
// write Composition API code just like in a normal setup()
// but no need to manually return everything
const count = ref(0)
const inc = () => { count.value++ }
</script>
<template>
<Foo :count="count" @click="inc" />
</template>
<script setup>
// declaring a variable that compiles to a ref
ref: count = 1
function inc() {
// the variable can be used like a plain value
count++
}
// access the raw ref object by prefixing with $
console.log($count.value)
</script>
<template>
<button @click="inc">{{ count }}</button>
</template>
跟大佬聊了一下,我的观点主要是这样的:
- 我对这提案的看法,和对 Svelte 的看法类似,甚至我都觉得 Evan You 也发现了编译器可能是个未来的优化点。
- 至于它破坏了 JS 语义的问题,我觉得与其让 Label 这个语法被忘记,不如赋予它新的意义,因为目前写 JS 已经基本离不开编译器了(例如 TypeScript、JSX、Vue Loader)。
- 此外,语言本身就是在变化中的,JS 正是因为以前的语法不好,才有了那么多提案和 Linter。
- 在其它行业,Lisp 有 Clojure、Common Lisp、Scheme 这样一堆方言,Java“语系”中的 Groovy、Scala、Kotlin 也差不多能算是方言,我觉得 JS 演变出 React、Vue、Angular、Svelte 几种方言是完全合理的。
之后,大佬拿出了另外一份 JS 的提案:Reference (ref) declarations and expressions,这样确实不会 Break the Web 了,不过看起来有点像 C 中的 int const a = 1
或者 Rust 中的 let mut x = 5
,这在我看来也不是坏事。
当然,语言不可能无限添加 Feature,否则容易长成这样:cRAzY eSnEXt (*all* proposals mixed in)……