SFCJS的实现思路

单文件组件(SFC)及语法

Vue 和 svelte 都具有自己的文件格式,在 .vue 或 .svelte 文件中,我们描述了一个组件的完整上下文。单文件组件这种组织方式特别适合单一功能的组件发布,你可以通过比较少的代码量,表达一个 UI 交互要做什么事情。

因此,我在写 sfcjs 时借鉴 svelte 的语法设计,在不改变原始 html, js 和 css 语法的基础上,增加了一些特定的形式。一个 sfc 包含三个部分 script, style, html,具体如下:

<script>
let a = 10;functionincrease() {
  a ++;
}
</script>

<style>
.foo {
  font-size: [[a]]px;
}
</style>

<div>
  <span class="foo">{{a}}</span>
  <button @click="increase()">inc</button>
</div>

这份示例代码中包含了组件内变量的声明和使用、动态 css 语法、动态 html 语法。由于 sfcjs 只是一个 POC,很多细节没有深入,所以只能展示一些比较粗浅的例子。

我们将上面这段代码放在一个 .htm 文件中,这样编辑器可以帮助我们为代码高亮。这个 .htm 文件即我们的一个组件文件。

基于AMD加载组件

我一开始想直接用 seajs 做加载引擎,但后来觉得没必要,因为我不需要严格的遵循 AMD 规范。但基于其实现原理,我们可以在需要的时候加载 sfc,实现文件异步加载和组件异步实例化。

在一个组件中引用另外一个组件,需要特殊的引用协议。

<script>
import SomeComponent from 'sfc:../components/some-component.htm'

const title = 'xxx'
</script>

<div>
  <some-component :title="title"></some-component>
</div>

在 import from 时,使用 sfc: 作为协议前缀,遇到一个路径时就认为是一个组件,然后在 html 模板中使用这个组件。SomeComponent 如果依赖了其他组件,它不会立即去请求这些组件的 sfc,而是会等到 SomeComponent 实例化的时候,再去请求。

运行时编译

组件 sfc 的语法显然是无法在浏览器直接运行的,我们需要做一个编译,而这个编译,我们直接在前端做。我们利用 webworker 或 webassembly 等技术,不占用 js 运行时线程,编译完之后,输出编译好的 js 脚本。输出的脚本以 blob 的形式,作为 script 进行载入,此时,利用 AMD 的 define,就可以让这个组件加入到我们的备选组件中。

整个加载编译过程如下:

这里的问题在于:1. 运行时编译,会不会性能不好?2. 安全性?

我相信你了解过 vite,简单说,vite 将 bundle 转化为单一文件网络,对每个文件进行编译,当该模块被使用时,发出 http 请求该文件,http 到达服务器时,对该文件进行编译后返回编译后的代码。从这个点上讲,运行时编译用的是客户端的资源,可能比 vite 还快。相反,前端的性能瓶颈,可能在加载巨大的 bundle 的时候更明显。至于安全性,在 compile 之前做一次 check 也是应该的,这看具体场景,因为 sfc.htm 的内容不是直接运行的脚本,只有它被编译为 js 之后,才会真正运行。

利用 web components 的设计

和 react 要用 render 函数来启动应用不同,sfcjs 如下启动一个组件:

<sfc-view src="./app.htm"></sfc-view>

理论上,你可以在任意 web 应用中使用这个句式来渲染一个 sfcjs 的组件,包括但不限于在 react 或 vue 中。组件的渲染结果,将被放在该元素的 shadowRoot 中,包含样式。

样式隔离

腾讯前端框架 Omi 是基于 web components 的,可以比较好的隔离样式,你可以借助它开发自己的 custom element 来实现自己的 UI 组件。但是,这里有一个问题在于,如果一个组件依赖于另一个组件,那么必须把这两个组件打包在一起,甚至,被依赖的组件在特定条件下并不会被用到。几乎大部分框架和 omi 一样,都不是开箱即用的(除了 jquery 或 alpine.js),我们没法马上体验它的开发效果,而且,更要命的是,部分框架具有传染性,某些情况下,你只需要其中的某一个功能或组件,但是,你不得不在外面给它一个很大的运行时(例如 react-dom),并且它不兼容(或很难)其他的技术栈。而基于 web components 则可以比较好的实现外围运行时无关,无论你在 react 中,还是 angular 中,都可以使用基于 web components 写的组件。

我在写 sfcjs 时,想到这一点,组件应该是自治的,开发者不应该去思考,当前这个组件是否会(在样式上)影响其他组件的运行。你可以看下基于 sfcjs 写的两个组件:

<style>
.bar {
  color: #ccc;
}
</style>

<button class="bar">ok</button>
<style>
.bar {
  color: #999;
}
</style>

<button class="bar">yes</button>

上面这两个组件都定义了 .bar 这个类,但是它们在同一个页面中使用时,不会相互污染。

它的原理是,基于 shadowDOM 完成渲染,因此,样式不会对其他元素产生影响。

响应式动态样式

通过修改变量可以让布局的部分变更,那样式呢?在 vue 的一个提案中,提出可以通过某种方式实现样式的响应式编程,具体如下:

<template>
  <div class="text">hello</div>
</template>

<script>
export default {
  data() {
    return {
      color: 'red'
    }
  }
}
</script>

<style vars="{ color }">
.text {
  color: var(--color);
}
</style>

也就是通过在 css 中使用 var 来标记变量。它基于css 变量这个被广泛支持和接受的技术,但 vue 的提案我觉得仍然暴露太多实现细节,在 sfcjs 中,结合语法,其响应式写法如下:

<script>
  let age = 10;

  function grow(e) {
    // change variables may cause rerender
    age ++;
  }
</script>

<style>
  .name {
    color: #ffe;
  }

  .age {
    color: rgb(
      /* use js expression with `var('{{ ... }}')` */
      var('{{ age * 5 > 255 ? 255 : age * 5 }}'),
      var('{{ age * 10 > 255 ? 255 : age * 10 }}'),
      var('{{ age * 3 > 255 ? 255 : age * 3 }}')
    );
    /* use variables with [[]] */
    font-size: [[age]]px;
  }
</style>

在 sfc 文件中,上下文是流畅的一体,在 <script> 中定义的变量,在 style 和 html 中直接使用。当变量变化时,对应的样式值也发生变化。

其具体原理是,我在 shadowRoot 中建立了两块 <style> 一块用于提供变量(会被动态更新内部文本),另一块使用这些变量(永远不变)。

在对应的变量发生变化的时候,第一块整个内容都会被重建,而第二块内容中会使用重建后的样式重新渲染,从而达到动态更新样式的效果。

无 virtual dom 的响应式

进入 2021 年,新出现的几个热门框架都不在基于 virtual dom 实现响应式,一个重要的原因在于 virtual dom 的成本巨大,且性能上并不占优。

和 solidjs 一样,sfcjs 在编译时收集每一个 DOM 节点所依赖的变量,当对应的变量发生变化的时候,去重新计算对应的值,并与当前状态进行比较,最后更新 DOM 节点。这里的不同在于两点:1. 发生变化的变量对应的 DOM 节点才会去计算,其他 DOM 节点即使依赖变量,但没有变化,不用计算。2. 直接对单个 DOM 节点进行是否需要更新的计算,而不再拿整个组件的节点树来进行 diff。

这个实现不算创新,甚至在早几年的时候就有人这么做过。

对于 sfcjs 来说,魔法在于,基于原始语法不需要做过多的编译,和 svelte 不提供(或几乎没有)运行时不同,sfcjs 在运行时前置了一个很小的响应式内核框架,所有组件的编译结果代码实质上是依赖于这个内核框架的。而这个内核框架在设计 API 的时候,完全是为了搭配编译过程设计,所以,编译过程,就是把源代码通过匹配、token 化等处理,做了代码转化,甚至都没有用到 AST 那一套。

当然,这样做,并不是为了性能,完全是为了尝试一条无 virtual dom 的道路,为了乐趣。