JavaScript框架中的编译机制概览
什么是编译型 JavaScript 框架?
优化模板
超越模板
超越模块化?
结论
2017年,汤姆·戴尔写了《编译器是新的框架》一文。他的观点是正确的。2017年,事情就已经开始朝着这个方向发展,而且此后这种趋势一直在持续。
如果你纵观我们使用的所有构建工具,你会发现每个框架都通过某种预构建流程得到增强。如果你想将其推向极致,你可能会像@swyx在他的文章《语言服务器是新的框架》中所做的那样,最终将其延伸到语言本身。
但这条路上还有更多路要走。JavaScript 作为一门独立的语言,其 UI 框架的发展趋势由来已久。Elm (2012)、Marko (2014) 和Imba (2015) 只是其中的几个例子。而到了 2021 年,我们已经拥有了更多此类库。
因此,熟悉 JavaScript 框架中的编译机制至关重要。你需要了解它们的工作原理,更重要的是,了解它们的功能和局限性。
什么是编译型 JavaScript 框架?
最终用户代码会通过编译器编译生成最终输出。公平地说,这种定义可能有点宽泛,但我想要说明的是,这种方法是一个连续谱,而非单一目标。这个术语最常与Svelte或Marko之类的框架联系在一起,在这些框架中,所有代码最终都会被编译。但几乎所有流行的框架都会在其模板上使用某种形式的预编译(AOT)。
原因很简单。当系统中的输入可以来自多个点,并传递到许多相关或不相关的输出时,声明式接口更容易理解。大多数编译型框架都是其模板语言的扩展,因此从模板语言入手是最合理的。
多年来,编译型框架领域出现了一些不同的方法,但目前主要有两种方法脱颖而出:一种是像Svelte、Vue和Marko这样的 HTML 优先模板语言,另一种是像JSX这样的 JavaScript 优先模板语言。
<section>
<h1>My favorite color</h1>
<div>${input.color.toUpperCase()}</div>
</section>
<shared-footer/>
HTML优先的模板语言将源文件视为HTML的增强部分,如果与纯HTML一起使用,通常可以作为完全有效的HTML局部视图。一些早期的HTML优先模板语言使用HTML字符串属性来表示表达式,但现在大多数都使用JavaScript表达式作为绑定语法。
export default FavoriteColor(props) {
return <>
<section>
<h1>My favorite color</h1>
<div>{props.color.toUpperCase()}</div>
</section>
<SharedFooter />
</>;
}
JSX 提供类似 HTML 的语法,可以将表达式内联到 JavaScript 代码中。你可以把它看作是函数调用的一种不同语法,在很多情况下,它本质上就是函数调用。但 JSX 并非 JavaScript 标准的一部分,因此许多框架实际上像 HTML 模板一样利用了它定义完善的语法。
优化模板
编译型框架的出现很大程度上源于对模板进行进一步优化的愿望。但基础模板语言本身也蕴藏着巨大的潜力。它们可以针对服务器端和浏览器端进行不同的编译。它们可以作为特征检测和积极进行摇树优化(tree shaking)的工具。许多框架还利用模板语言进行预先静态分析,从而优化生成的代码以提高性能。
大多数模板生成的代码都是创建逻辑,无论是虚拟 DOM 节点还是实际的 DOM 节点。查看模板时,几乎可以立即识别出哪些部分永远不会改变,例如属性中的字面值或元素的固定分组。对于任何模板方法来说,这都是唾手可得的优化点。
像Inferno这样的 VDOM 库会利用这些信息将 JSX 直接编译成预优化的节点结构。Marko会将静态 VDOM 节点提升到组件外部,这样每次渲染时就不会产生重新创建它们的开销。Vue更进一步,会收集动态节点,从而将后续更新限制在这些节点上。
Svelte将代码分离为创建和更新生命周期。Solid更进一步,将 DOM 创建提升到可克隆的模板元素中,从而在一次调用中创建 DOM 的整个部分,顺便一提,这是一种运行时技术,被@webreflection的uhtml和Lit等标签模板字面量库所使用。
// Solid's compiled output
const _tmpl$ = template(
`<section><h1>My favorite color</h1><div></div></section>`
);
function FavoriteColor(props) {
const _el$ = _tmpl$.cloneNode(true),
_el$2 = _el$.firstChild,
_el$3 = _el$2.nextSibling;
insert(_el$3, () => props.color.toUpperCase());
return [_el$, createComponent(SharedFooter, {})];
}
export default FavoriteColor;
对于像Svelte或Solid这样的非 VDOM 库,由于框架并非基于差异引擎构建,我们可以进一步优化更新操作。我们可以使用静态已知的属性信息,并直接将模板表达式与它们关联起来,而无需深入了解这些表达式。这本质上就是循环展开。我们不再遍历未知属性列表,而是直接编译内联更新表达式。你可以这样理解:
if (isDirty(title)) el.setAttribute("title", title);
在某些情况下,我们甚至可以根据输入数据做出一些进一步的假设。例如,Solid的编译器知道简单的变量绑定不是响应式的,因为跟踪系统依赖于 getter 方法。因此,它可以选择不将这部分代码放在更新路径下。
预先分析的内容仍然存在局限性。像Svelte或Vue这样的动态组件一样<svelte:component>, Spread 组件最终也需要采用运行时方法。<component>
循环和条件语句等其他动态部分在所有框架中都是运行时处理的。我们无法在构建时进行差异比较,只能缩小运行时可能出现的情况范围。但对于列表管理这类操作,没有捷径可走。它们的协调方法会占用任何框架运行时环境的很大一部分。是的,即使是编译型框架也有运行时环境。
超越模板
现在,对于单文件组件来说,是否应该将整个文件视为模板,以及像Svelte或Marko这样的库基本上是如何处理的,都存在争议。当知道你的文件代表一个单独的组件时,可以做出一些假设。
对于Svelte来说,这决定了响应式跟踪边界。文件中声明的所有响应式原子在更改时都会通知组件进行更新。因此,Svelte基本上可以通过简单地在每个赋值语句中添加一个更新组件的调用,来编译掉其响应式系统,从而无需管理任何订阅$$invalidate。
// excerpt from Svelte's compiled output
function instance($$self, $$props, $$invalidate) {
let { color } = $$props;
$$self.$$set = $$props => {
if ("color" in $$props)
$$invalidate(0, color = $$props.color);
};
return [color];
}
对于静态分析来说,这相对容易,因为可以通过查看变量在作用域中的定义位置并更新所有使用它们的地方来做出决定。但是,当这些响应式原子需要出现在模板之外时,自动完成这项工作就困难得多。Svelte使用命名$约定来标识 store,以便编译器知道如何设置订阅。
Marko 也采用了类似的局部优化方法,通过检查组件中的类来判断它们是否是有状态的。根据组件的生命周期以及模板中使用的绑定类型,可以确定这些组件是需要发送到浏览器还是仅包含在服务器端。这种简单的启发式方法结合一些打包工具的技巧,为部分水合提供了一种简便的方法。
这两种方法都使用特定的语法来表示对自身状态的理解。它们的数据已经成为其语言的一部分。虽然并非强制要求,但你是否曾想过Reactuse Hooks中的前缀可能具有的价值?
超越模块化?
编译的最大局限在于其能够合理分析的范围。虽然我们可以像Svelte那样使用一些技巧来告知编译器,$但我们往往只能看到import语句之外的内容。这意味着在查看组件的输入时(例如,输入是否动态),我们不得不做出最坏的假设。我们无法得知子组件是否会以动态的方式使用我们的状态数据。
这阻碍了我们高效组合代码的能力。我们通常需要借助不同的运行时机制来弥补这一缺陷,而不是利用编译器的优势。试想一下,如果在编译时就能知道一条数据会如何影响整个应用程序,那该有多好?
因此,我们主要关注局部优化。然而,打包工具和压缩工具最终会处理输出代码。虽然我们可以预先做很多工作来生成有利于它们优化的输出,但编译器最终还是会参与进来。
我们通过使用特定语言,是为了更好地理解开发者的意图,尤其是在大量使用声明式结构的情况下。这些信息在各个阶段都非常有用。而通用编程语言则很难做到这一点。
结论
我们对编译型 JavaScript 框架的了解还只是冰山一角,但那些我们通常与纯编译型框架联系在一起的技术正在逐渐渗透到其他框架中。例如,Vue在其单文件组件中探索了一种新的数据级语言。由于基础架构已经搭建完成,所以实现起来也很容易。
每个框架在模板处理方面采用的方法(HTML优先还是JS优先)大多只是表面上的区别,本质上差别不大。但魔鬼藏在细节里,尤其是在功能支持方面。每个框架都有不得不依赖运行时库的地方,而这些限制在任何大型应用中都经常出现。因此,代码量的大小也并非绝对优势。
编译的优势在于抽象化复杂性。从更简洁的数据交互和更新语法,到针对服务器端和浏览器端的专用输出,编译都能发挥作用。它就像打包工具开发服务器上的热模块替换一样,是一个优秀的开发者体验工具。由于程序能更好地理解你的意图,因此编译有助于提升集成开发环境(IDE)的支持。此外,它还能带来性能提升。
如今,编译型方法的最大局限在于其模块作用域。如果编译型方法想要像运行时方法那样扩展,这将是我们必须克服的障碍。就目前而言,混合方法或许是最佳解决方案。但即便在今天,编译器的功能也如此强大,很难想象没有编译器的未来会是什么样子。
文章来源:https://dev.to/this-is-learning/a-look-at-compilation-in-javascript-frameworks-3caj
