Svelte 不支持小程序开发?我不允许!

lxf2023-03-14 13:23:01

Svelte 好火啊!

年末了,看到组内的同学在整理 2022 年流行的前端技术的文章,在里面看到大篇幅对 Svelte 优缺点和原理介绍。读完之后,对这个没有虚拟 DOM,通过重度依赖编译阶段实现响应式数据的框架产生了极大的兴趣。

于是开始对其进行学习并做了一些尝试,在我之前的工作经历中,经常会使用 Taro 这个框架来开发小程序,了解到 svelte-native 这个能够让 Svelte 开发原生应用的库后,立马着手尝试将这个框架用于小程序的开发。

现在,基于 Taro 框架,通过插件的形式,让 Svelte 拥有开发小程序的能力。项目位于github.com/SyMind/taro… ,你可以阅读 README 来安装并使用它!

Svelte 不支持小程序开发?我不允许!

如何让 Svelte 进行跨平台渲染

面临的问题

不同于 React 和 Vue 这种拥有虚拟 DOM 的技术,Svelte 本身并没有进行平台无关的抽象,在实现上与 DOM API 强耦合。

Svelte 自身是一个编译器,它的输入是编写的 *.svelete 文件,输出是导出一个 class*.js 文件。

<h1>Hello world!</h1>

上面是一个极简单的 Svelte 组件,它仅渲染一个文本内容为 Hello world!h1 标签,它的编译结果如下:

/* App.svelte generated by Svelte v3.55.1 */
import {
	SvelteComponent,
	detach,
	element,
	init,
	insert,
	noop,
	safe_not_equal
} from "svelte/internal";

function create_fragment(ctx) {
	let h1;

	return {
		c() {
			h1 = element("h1");
			h1.textContent = "Hello world!";
		},
		m(target, anchor) {
			insert(target, h1, anchor);
		},
		p: noop,
		i: noop,
		o: noop,
		d(detaching) {
			if (detaching) detach(h1);
		}
	};
}

class App extends SvelteComponent {
	constructor(options) {
		super();
		init(this, options, null, create_fragment, safe_not_equal, {});
	}
}

export default App;

可以看到 create_fragment 方法用于创建组件 DOM 节点,在方法中 h1 标签通过 element() 方法创建出来,然后使用 h1.textContent 属性赋予文本内容。显然 element() 就是 Svelte 内容用于创建 DOM 节点的方法,而其内部正是调用了我们再熟悉不过的 document.createElement() 方法。

export function element<K extends keyof HTMLElementTagNameMap>(name: K) {
	return document.createElement<K>(name);
}

所以 Svelte 要实现跨平台渲染,没有办法像 React 那样直接通过 react-reconciler 这样的包,然后传入使用平台特定 API 实现的配置,即可实现一个特定平台的自定义渲染器。Svelte 甚至都没有将浏览器端的 document 抽象出来,所以为了让 Svelte 实现跨平台渲染,需要一些较为 Hack 的手段,以下是我能想到的方法。

方法1:替换 svelte/internal

因为 element() DOM 操作是从 svelte/internal 包中导出的,我们可以通过 Babel 等编译器对 Svelte 的产出进行修改,将其中的 svelte/internal 包替换为我们自己编写的文件路径。在该文件中会重新引入 svelte/internal 并导出,但此时我们可以对需要修改的方法进行重新实现,比如 element() 方法。

import * as internal from 'svelte/internal'

internal.element = function() {
    // 新的逻辑
}

export internal

但需要注意的是,svelte/internal 中存在其他依赖 element() 的方法也需要同步进行更改,所以这个方法的工作量估计不少。

Svelte 之所以抽象出 element() 方法,而非在结果代码中直接使用 document.createElement() 主要的目的应该是为了考虑代码压缩,而并非抽离平台特定的 API,但我想之后一定它会做这件事。

方法2:伪造 document 和 window

第二种方法是自己伪造全局的 documentwindow 对象,其中 svelte-native 就是通过这种方法实现的,下面是 svelte-native 中的代码:

function installGlobalShims(): SvelteNativeDocument {
    // 通过全局的 document 导出伪造的 DOM API
    let window = global as any;

    window.window = global;
    window.document = new SvelteNativeDocument();

    window.getComputedStyle = (node: NativeViewElementNode<View>) => {
        return node.nativeView.style;
    };

    window.performance = {
        now() {
            return Date.now();
        },
    };

    window.CustomEvent = class {
        detail: any;
        eventName: string;
        type: string;
        constructor(name: string, detail: any = null) {
            this.eventName = name; //event name for nativescript
            this.type = name; // type for svelte
            this.detail = detail;
        }
    };

    window.dispatchEvent = function (event: CustomEvent) {
        logger.info(() => `Event dispatched ${event}`);
    };

    return window.document;
}

小程序的环境比较特殊,无法在 global 对象上挂载其他东西,此时可以通过 webpack 的 ProvidePlugin 来处理。Taro 本身就对 webpack 增加了以下配置,所以使用 Taro 框架来开发小程序时可以直接使用 DOM API!

webpack.ProvidePlugin({
    window: ['@tarojs/runtime', 'window'],
    document: ['@tarojs/runtime', 'document'],
    navigator: ['@tarojs/runtime', 'navigator'],
    requestAnimationFrame: ['@tarojs/runtime', 'requestAnimationFrame'],
    cancelAnimationFrame: ['@tarojs/runtime', 'cancelAnimationFrame'],
    Element: ['@tarojs/runtime', 'TaroElement'],
    SVGElement: ['@tarojs/runtime', 'SVGElement'],
    MutationObserver: ['@tarojs/runtime', 'MutationObserver']
})

Svelte Component 还是 Web Component

我们需要考虑该以何种形式暴露 Taro 组件,使用 React 开发 Taro 应用时,通过从 @tarojs/components 包中导入 React 组件,最终渲染成对应的小程序标签。

import { View } from '@tarojs/components'

实际上导入的 View 只是一个值为 view字符串变量,React 在运行时会将类型为字符串的节点通过 Taro 提供的 document.createElement() 方法构造出小程序的 view 标签。而 Svelte 是一个编译时框架,在看到大写字符开头时会直接认为这是一个 Svelte Component 即一个 class,导致组件会在运行时报错

我们当然可以直接将 View 实现为一个 Svelte Component,但是这会增加编译后项目整体的代码量,如最开始那个极简单的 Svelte 组件的例子,每个 Svelte 组件都会编译成一个 class 徒增很多体积,所以在实现 Svelte 的 Taro 插件时我选择以 Web Components 的形式来暴露 Taro 组件。

<taro-view>hello world!</taro-view>

这面临着另一个问题,Svelte 编译后的产出创建标签的代码为 element('taro-view'),而目标的小程序标签是 view,所以我们期望的产出是 element('view')

我们当然可以通过替换 svelte/internal方式,来修改 element() 方法,以在运行时调用 document.createElement() 方法前移除 taro- 掉前缀。但我们有更好的选择,对 Svelte 的产出再次进行编译,遍历到 nameelementCallExpression,若其参数为 taro-view 我们就移除掉它的前缀,这不会增加任何多余的运行时代码!

避免 innerHTML 优化

Svelte 通过编译分析,会对不会更新的节点直接通过 innerHTMl 的方式进行批量创建,下面是一个例子。

<h1>
    <span>Hello</span>
    <span>World</span>
</h1>

对于标签的渲染,Svelte 会编译成以下的代码:

function create_fragment(ctx) {
	let h1;

	return {
		c() {
			h1 = element("h1");

			h1.innerHTML = `<span>Hello</span> 
	<span>World</span>`;
		},
		m(target, anchor) {
			insert(target, h1, anchor);
		},
		p: noop,
		i: noop,
		o: noop,
		d(detaching) {
			if (detaching) detach(h1);
		}
	};
}

这是 Svelte 非常激动人心的优化,提升组件运行时的速度,但这个优化对于处理跨平台渲染来说就十分痛苦了,因为实现 innerHTML 功能的成本很高,需要在运行时进行解析,然后进行遍历来创建各个节点。

幸运的是 Svelte 编译器有一个 hydratable 参数,它原本的目的是为了 SSR 挂载时能够复用已有节点,为了这个目的 Svelte 此时生成的代码不会产生使用 innerHTML 批量生成节点的操作,以此也让我们能够绕开这个问题。

写在最后

从前我一直认为虚拟 DOM 的优点之一是具有跨平台的能力,但在接触过 Svelte 后明白虚拟 DOM 只是为了在运行时进行节点 diff,跨平台的基础是分离平台特定 API。

甚至就算像 Svelte 这种内部与 DOM API 高耦合的框架,我们依旧可以通过运行时伪造或编译时代理 window 和 document 对象来进行跨平台!

之后我可能会继续探索更多 Svelte 的渲染器,比如让其渲染在 terminal、three.js 等,这应该会非常有趣。