为了一盘醋,开了一家速冻水饺厂:前端框架 Laterano 开发手记 cover

为了一盘醋,开了一家速冻水饺厂:前端框架 Laterano 开发手记

自己造轮子的最大意义,大概就是知道轮子是怎么被造出来的。

在二十年前,前端开发还没有 fancy 概念的时候,手搓 JavaScript DOM(Document Object Model,网页对象模型)然后通过 document.element() 选择器来控制网页内容是一个十分基本的操作。前端虽然有一些诸如 jQuery 的开发框架,但这些框架更多的是对 DOM 的语法糖封装,并没有更改网页开发整体的操作逻辑。

而如今,当类似 VueReact 的前端框架流行之后,依靠框架进行开发已经是一个标准「起手式」。我个人也不例外:如果要做一些前端项目,起手就是用 Vite 一把梭,直接配置好一个空白的项目,然后通过组件化和 MVVM 的范式,行云流水般做出一个 fancy 的网页。在现在的前端开发的概念中,「组件化」已经和这些前端框架深度绑定。

实际上,在纯原生的 JavaScript 开发领域有一种跨平台支持的组件化的标准,被称作 Web Component。这个在 2011 年提出草案的标准,直到 2018 年才被各大浏览器广泛支持,相比起开发流程无需协调多个浏览器厂商指定标准而更轻快的第三方前端框架而言,原生组件化标准实在是有些姗姗来迟。同时,缺乏对 MVVM 范式的支持,也让 Web Component 对开发者的吸引力并没有多少改善。

当我刚开始接触 Web Component 的时候,我浏览的是一个名为 Plain Vanilla 的介绍网页。这个网站非常详细地介绍了 Web Component 在实战中的应用。这个网站已经讲得十分深入浅出,但我依然对纯原生的 Web Component 有更高的期待——譬如让它更接近现代 MVVM 理念。

于是,我开始尝试开发一款基于 Web Component 的前端框架——除了将原生 Web Component 封装起来以外,我的额外目标就是新增一个状态(state)管理功能,并让它可以根据状态的变化,动态更新、控制视图。

小试牛刀:为 Web Component 添加一个状态管理器

首先要做的事情,就是将 Web Component 本身封装起来。Web Component 不仅提供了组件化的 HTML 封装工具,同时也允许开发者直接在 Web Component 的定义中定义生命周期钩子,不需要额外的代码。甚至组件内的视图也可以直接输入 HTML 代码直接控制。如果你打算用 Web Component 做个网站,并且熟悉并不介意直接控制 DOM,那么直接上手不会遇到太多障碍。

最开始封装好的 Web Component 如下。

export default (options: ComponentOptions) => {
const { tag, template, style, onMount, onUnmount, onAttributeChanged } = options
class CustomElement extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
this.shadowRoot!.innerHTML = `
<style>${style}</style>
${template}
`
}
connectedCallback() {
if (onMount) onMount()
}
disconnectedCallback() {
if (onUnmount) onUnmount()
}
static get observedAttributes() {
return ['data-attribute']
}
attributeChangedCallback(attrName: string, oldValue: string, newValue: string) {
if (onAttributeChanged) onAttributeChanged(attrName, oldValue, newValue)
}
}
customElements.define(tag, CustomElement)
}

对于 Web Component 原生已经实现的功能(例如生命周期和样式隔离等等),我确实不需要再费脑子想怎么去实现对应的功能了,反正把已经包好的东西打包暴露出去我的任务就算完成了(逃)。

那么接下来的任务,就是初步实现一个简单的状态管理系统。

所谓的「状态管理」,不仅仅是指要在组件中有一个「存储空间」,更是需要让这个「存储空间」内的值可被监听和控制,这样才能做到视图控制、监听和其他高级功能。这就需要使用到 JavaScript 中的代理(Proxy)对象,它的作用是在外部对象读或写自身的时候进行行为拦截并执行一些钩子操作。

那么,首先我就需要在这个 Web Component 内部,创建一个用于状态管理的代理对象。具体做法是:

  1. 先通过组件选项,让用户将初始 state 结构和值传入
  2. 在内部创建一个 Record<string, unknown> 类型的 _states 变量
  3. 初始化时,使用 new Proxy() 函数创建代理对象,设置读写钩子
  4. 拷贝用户传入的 state 对象,再赋值给内部的 _states 状态代理
export default (options: ComponentOptions) => {
const { states, stateListeners, ...rest } = options
class CustomElementImpl extends HTMLElement {
private _states: Record<string, unknown> = {}
constructor() {
super()
this._states = new Proxy(
{ ...(states || {}) },
{
set: (
target: Record<string, unknown>,
keyPath: string,
value: unknown,
) => {
// Set hook, for example, a state listener
stateListeners?.[keyPath]?.(value)
return true
},
get: (target: Record<string, unknown>, keyPath: string) => {
// Get hook
return undefined
},
},
)
}
}
}

通过这种方式,我们就可以允许开发者在更改状态内的值的时候,按照开发者要求发送监听信号并触发相应函数。

地狱难度:自实现模板引擎,并根据状态更新视图

经过上面一番操作,我感觉,好像做一个前端框架也不难(下次还填非常简单.jpg)。于是我对开发一个根据状态动态视图更新的功能十分有信心。但很快,在概念实施阶段就遇到了滑铁卢。

我们先来看看,单纯依靠 vanilla 风格的 JavaScript,如何动态地更改 DOM。

<!-- 伪代码 -->
<div>
<div>Click count: <span id="counter">0</span></div>
<button onclick="counterAddOne">Plus One on the Counter</button>
</div>
<script>
let counter = 0
function counterAddOne() {
document.element('#counter').innerHTML = counter++
}
</script>

也就是说,如果需要通过操作 DOM 的方式来更改 HTML 视图,那么我们至少需要告诉 DOM 到底哪一个视图对象什么需要更新,比如在上面的例子中,#counter 就是需要更新的那一个对象。而在 MVVM 范式中,框架不应该根据用户显式声明的锚点(例如 ID 值等)来判断需要更新哪个视图(要不然我用 jQuery 不香吗),而是应该根据对象的调用链来判断当状态更新时,应该更新哪些视图对象。

这时候,我原本代码中的通过直接输入 template 字符串给 Web Component 的 Shadow DOM 的逻辑就显得不合时宜了:不仅是因为我需要为模板添加诸如 handlebar-styled 的动态字符串代码(例如 {{counter}}),更是因为我需要读取模板中的 DOM 结构,以便构建一个从内部状态映射到 DOM 的表。

通过读 React 和 Vue 的源码,加上一些 AI 分析,我了解到这些现代框架内部都实现了一个 DOM 树表。树表中的结构反应了视图内部的结构,同时在内部存储应该监听哪一个状态变量。因为太过于复杂,我暂时没有实现这样的结构。

最后我想到的一种方案是,在组件内部建立一个平铺结构的 private _stateToElementsMap: Record<string, Set<HTMLElement>> 对象,这个对象存储两个东西:一个是需要监听的状态路径,另一个是一个集合,集合内是与这个状态有关的所有 DOM。同时,我在内部还提供了一个锚点变量private _currentRenderingElement: HTMLElement,这个指针的作用是指示「目前正在渲染或解析的 DOM」。

那么,接下来的事情就比较好解决了:

  1. 在渲染 DOM 的时候,每处理下一个对象时,先将锚点 _currentRenderingElement 指向目标对象,再继续处理
  2. 如果检测到有宏(即条件渲染语法或列表渲染语法)或是模板语法时,读取一次 state
  3. _stateget 钩子中检测渲染状态,如果确认正在渲染且 _currentRenderingElement 不为空,则将其读取的路径和 DOM 本体写入到 _stateToElementsMap 中,再返回值
  4. _stateset 钩子中,添加一个判断逻辑,即如果写入的对象与 _stateToElementsMap 中的某个集合有关,则读取对应集合,重新渲染。

这样,我们就可以解决渲染模板和宏的问题了。而且出来的效果也相当不错。不过,我还没有测试过通过这种方法来更新 DOM 对性能的影响几何1。但是作为一个以学习为主要目的的框架来说,能在短时间内实现一个拥有基础 MVVM 范式的框架,并且了解到现代前端框架的背后逻辑,已经是最大的收获了。

接下来

在撰写这篇文章的途中,我还为 Laterano 做了一些小的维护层面的改动,包括加入 Rollup 压缩和 Biome 语法检查 功能,让其更接近大型开源社区项目。

在此之后,我可能要先扩展一下模板和宏相关的功能。我将 Laterano 中的动态渲染语句,例如条件渲染语法 <element %if="expr" /><element %for="item in items" />,以及双向绑定语法 <input %connect="" /> 等,统称为「宏」(marco)。而目前的条件渲染宏和列表渲染宏与 Vue 对应语法一样,是作为「被控方的属性」出现,而非像 JSX 或 TSX 那样是「容纳被控方的容器」2。这在框架渲染过程中造成了一些不大不小的麻烦。之后可能会考虑单独将它们的语法做一些改进。

彩蛋:Laterano 名称的由来

这个名字是手游《明日方舟》中的移动城市 拉特兰 的英文名。

在建立这个框架的时候,我想尽可能在名字中体现「Vanilla」(意为「香草味的;没有特点的」,描述为味道时通常指的是雪糕的口味。在前端领域中引申为「原生 JavaScript 语法的」、「无框架的」)这个元素,暗示这个框架的语法会尽可能贴合原生风格。后来想到拉特兰城本身是一个和甜品和雪糕有着不解之缘的城市,因此便采用了拉特兰的英文名 Laterano 为框架命名。


  1. 考虑到 Vue 和 React 都以虚拟 DOM 树来实现对应功能,我觉得平铺结构很可能会有一些性能方面的问题。顺便一提,平铺结构另一个重要问题是,它需要在 HTML 中插入注释锚点才能获知 DOM 的具体位置。

  2. 这么说会显得对 JSX 有点误解,因为在逻辑上 JSX 语法的条件渲染与列表渲染都是通过 JavaScript 逻辑的语法实现的,而不是一个 HTML 容器。但既然它们的语法都是用花括号包裹的,你在理解上就这么理解就可以了。