探索基于 JSX 构建用户脚本 UI


开发用户脚本的时候,难免会遇到在网页上动态创建元素的需求。我一直认为 JSX 很合适这个场景,但是引入额外的 React、Vue 之类的运行时又显得有些过重。于是我进行了一些探索,发现了几种有趣的路径。

背景

当我们在开发用户脚本的时候,难免会遇到对网页的 UI 修改或添加自定义组件的需求。假如不引入额外的框架,则大概率需要写很多 document.createElement 再各种 appendChild 之类的。

「太适合用 JSX 了」——在面临这种场景的时候,我总是冒出这样的想法。但是我一向认为,用户脚本额外引入运行时——无论是 React 还是 Vue,都似乎显得有些过重。尽管也有 Svelte 这种编译时的框架,但是他的产出会显得有些臃肿。(尽管「现在大家都不缺这点儿内存和存储空间了」

什么是 JSX

对于不熟悉 JSX 的朋友,基本上来说 JSX 就是 JavaScript 的一种语法扩展,允许我们在 JavaScript 代码中直接编写类似 HTML 的标签。它通常与 React 一起使用,但也可以与其他库或框架结合使用。JSX 代码在编译时会转换为 JavaScript 函数调用,从而创建和操作 DOM 元素。 他的形式大概如下:

const element = (
<div>
<h1>Hello, world!</h1>
<p>This is a JSX example.</p>
</div>
)

这段代码在编译后会变成:

const element = React.createElement('div', null,
React.createElement('h1', null, 'Hello, world!'),
React.createElement('p', null, 'This is a JSX example.')
)

NOTE

即使不考虑动态创建元素,我一直也想要这样一种东西:使用起来就像 JSX 或 Vue 的模板语法,最后仍然会编译成 HTML 模板 + JavaScript 代码。

后面我发现真正完美实现了我所说的事情的是 Astro 的模板语法。模板是 JSX-Like,通过 Frontmatter 分离逻辑和生成代码,最后编译成 HTML + JS,Frontmatter 前的代码会在编译时运行,填充到模板中。

可惜目前他的编译器仍然要依赖于 Astro 的服务器,无法单独使用,期待一下吧。

最初,我一直在思考:有没有可能将 JSX 在编译期完全转换成直接的原生 DOM 操作?可惜目前还没有找到完美实现这个想法的工具,而这个方向的实现对我而言有些超出能力范围,所以我转而探索其他可行的方案。

基于其他 JSX 运行时

实际上,许多项目都有他们自己的 JSX 运行时实现,例如 Preact、Vue 之类的。所以我在想是否有一个足够轻量的运行时,最好是没有额外抽象的那种。

JSX-DOM

我首先找到了 JSX-DOM。它提供了一个简单的 JSX 运行时实现,不包含 virtual DOM、diff 或状态系统。而是直接调用 document.createElementappendChild 来创建和操作 DOM 元素。

但是 JSX-DOM 并不能直接用于用户脚本,因为作者仅提供了 ESM 和 CJS 的版本,而一般用户脚本目前只能通过 // @require 引入 UMD 或 IIFE 的 js 文件。如果我们完整 bundle 它,似乎还不如用 Svelte 或 Solid。作者似乎也无意做这项工作,而是推荐用当时还能使用的 CDN 服务进行转换——现在已经无法访问了。

正好我想尝试一下 Rslib——实际上用啥可能都大差不差,过程还是很简单的,基本就是写写配置:

rslib.config.ts
import { defineConfig } from '@rslib/core'
export default defineConfig({
lib: [{
format: 'umd',
umdName: 'jsxDOM',
syntax: 'es2021',
dts: true,
// 目标环境是浏览器
output: { target: 'web' },
}, { // 我们保留 ESM,方便开发环境使用
format: 'esm',
syntax: 'es2021',
bundle: false,
dts: true,
}]
})

打包后,我把它发布到了 NPM,命名为 @maxchang/JSX-DOM。在用户脚本中你可以直接使用以下方式引入:

// @require https://fastly.jsdelivr.net/npm/@maxchang/JSX-DOM@8.1.6/dist/index.min.js

引入后,你可以通过 jsxDOM.default 访问所有功能。

如果你使用 vite-plugin-monkey,则可以很方便配置:

vite.config.ts
// ...
build: {
externalGlobals: {
'@maxchang/JSX-DOM':
cdn.jsdelivrFastly(`jsxDOM.default`, `dist/index.min.js`),
},
},
// ...

我又构建了一个模板项目,把上面所说的整合到了一起:userscript-tsx-starter

vm-DOM

实际上,把 JSX 用于用户脚本的 UI 开发并不是一个新鲜事。比如 violentmonkey 团队开发的 vm-dom。它基于 @gera2ld/JSX-DOM,另一个类似于 JSX-DOM 的项目。但相比于原项目,它在功能和类型上都没那么丰富。

更多选择

在我继续探索的过程中,发现了一些其他可用于用户脚本开发的轻量级(类) JSX 解决方案。

nanoJSX

nanoJSX 提供了一个更加完整的 JSX 实现,同时出乎意料的是它的包体积竟然比 JSX-DOM 还要小。它不基于 Virtual DOM,但是基本的状态管理也都有。同时支持通过模板字符串来使用。

但是他的浏览器的 bundle 似乎只有模板字符串的版本……同时宣称的 1kb 也有些争议……

hastscript

hastscript 同样支持 JSX,它是一个用于创建 HAST(Hypertext Abstract Syntax Tree)的工具。虽然它的主要用途是在 Node.js 环境中处理 HTML,但也可以与 JSX 结合使用:

/** @jsxImportSource hastscript */
console.log(
<div class="foo" id="some-id">
<span>some text</span>
<input type="text" value="foo" />
<a class="alpha bravo charlie" download>
deltaecho
</a>
</div>
)

总结

当然,以上访问的缺点也很明显:

  • 对于 JSX-DOM 这一类 JS2DOM 的实现。他们毕竟还是没有 reactive,尽管我们一开始说了不想要臃肿,但是写起来还是会有些割裂,并且没法在后期「渐进增强」;
  • 对于所有的 jsx Runtime 实现来说:
    • 仍然需要额外的运行时依赖,无法做到纯编译期转换。
    • 大部分都没有 UMD 打包,用户脚本没法友好地引入。
      • 所以打包完自己的 JSX-DOM 后,我也未再尝试其他的方案。

不过,应付简单的场景还是足够的了。

基于模板字符串

另一个值得探索的方向是基于模板字符串的解决方案,如 lit-htmlhtm。这些库不需要额外的编译步骤,可以直接在 JavaScript 中使用类似 JSX 的语法。

import {html, render} from 'lit-html'
const name = 'world'
const sayHi = html`<h1>Hello ${name}</h1>`
render(sayHi, document.body)

上次使用类似东西,还是开发 VS Code 插件时用了现在已经被微软砍刀部砍掉的 vscode-webview-ui-toolkit,所以接触到了 FAST。有一说一,其实还挺好用的……在配套设施友好的情况下……

基于网站自身环境

也许,可以用 React……?

这个灵感来自我在重构一个先前实现的用户脚本——知乎历史记录

知乎首页的信息流是可以直接查看完整文章的,但是却没有提供类似 App 的浏览记录的功能,所以在此之前经常出现一个场景就是——在首页刷到的文章,正在看着,一不小心刷新了,就再也找不到了。

所以这个脚本的功能就是记录你在首页上点击的文章,并提供了一个弹出层展示这些记录,效果如下:

当时写的时候比较粗糙,同时结构有些混乱。而且用户脚本要动态创建元素,免不了各种重复的工作,总觉得可以更 neat 些,所以一直想要着手重构。

在当我要考虑上面的方案时,我发现了一件意外之喜:知乎本身使用的是 UMD 版本的 React,我们可以直接从 window.Reactwindow.ReactDOM 获取全套 API!

这给了我一些灵感——我们完全可以复用全局对象的 React,这样就可以在不用额外引入任何运行时,还可以获得完整的 React 体验。

我使用了 vite-plugin-monkey 来开发这个用户脚本,对比提供的 React 样例,我们只需要同步知乎的 React 版本,在 build 配置中指定 externalGlobals (当然,需要开启 unsafeWindow 访问完整的 Window)就可以在打包 React 和 ReactDOM 时使用我们指定的值了:

vite.config.ts
// ...
build: {
externalGlobals: {
// react and react-dom are already mounted on the page
react: 'unsafeWindow.React',
'react-dom': 'unsafeWindow.ReactDOM',
},
},
// ...

由此,我们可以在不额外引入运行时的情况下,在用户脚本(仅限这个场景)中使用 React 与 JSX 开发了!

通过 vite-plugin-monkey 的一点小小魔法,当我们写一个这样的 tsx 文件时:

src/main.tsx
// ...
const mountApp = () => {
const app = document.querySelector('#app')
ReactDOM.render(<App />, app)
}
mountApp()

我们就可以得到一个类似这样的产出:

dist/xxx.user.js
(function (require$$1, ReactDOM) {
'use strict';
// ...
const mountApp = () => {
const app = document.querySelector('#app');
ReactDOM.render(/* @__PURE__ */ jsxRuntimeExports.jsx(App, {}), app);
};
mountApp();
})(unsafeWindow.React, unsafeWindow.ReactDOM);

当然,这种做法也不是完全没有缺点…比如 react/jsx-runtime 仍会被额外打包进去,虽然代码量很小,也可以替换为其他人打包好的版本。我们也可以通过查找 webpackChunkheifetz 来拿到他,尽管有些 Hack。

另外很明显的一点是,这种技巧仅限于网页能暴露出来的 React 和 ReactDOM 的时候。通过 这篇文章 我们可以知道,在某一个时间点之前,知乎还是不能直接获取的。但我们也应该可以通过 Hook 的方式拿到,以后再尝试吧。

在此之前我从未写过 React,所以某些地方也许写的比较奇怪,同时目前略显「为了用而用」。但我认为在需求变复杂时是完全值得的。如果你感兴趣的话,可以在 Github 查看源代码。

总之,我们以极小的代价获得了 React 的所有能力,JSX 的编译结果可读性也不错。但是很明显的一件事情是,React 18 之后的版本已经不再支持 UMD 了,这个故事很可能会在某一次知乎升级依赖的时候就消失了……或者至少变的没有现在那么舒服了。

总结

好像也没啥总结的,先这样吧。