Skip to content

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

· 12 min

背景#

开发用户脚本(Userscript)时,网页 UI 改造或自定义组件的添加是绕不开的需求。若不引入额外框架,往往需要编写大量繁琐的 document.createElementappendChild 代码。

面对这种场景,我心中总会浮现一个念头:「这太适合用 JSX 了。」 JSX 提供的声明式 UI 描述能力,能极大地降低描述 DOM 操作的复杂度。

但我始终认为,为用户脚本引入 React 或 Vue 这类重型运行时(Runtime)显得有些得不偿失。尽管也有 Svelte 这种编译时的框架,但是他的产出会显得有些臃肿(尽管「现在大家都不缺这点儿内存和存储空间了」)。并且,有时候我不想要响应式的动态功能,只是想要一种 document.createElement 的简洁写法。

NOTE

抛开动态逻辑不谈,我一直渴望一种工具:它拥有类 JSX 或 Vue 的模板语法,由于不需要复杂的响应式动态功能,在编译阶段即可直接转化为纯粹的 HTML + JavaScript。 . 后来我发现,Astro 的模板语法近乎完美地实现了这一构想。它采用 JSX-like 语法,通过 Frontmatter 分离逻辑与结构,编译后生成静态 HTML。遗憾的是,其编译器目前仍高度耦合在 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 解决方案。但是他们也都没提供 UMD 的版本,所以打包完自己的 JSX-DOM 后,我也未再尝试其他的方案。

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 这类库虽然直接将 JSX 转换为 DOM 操作,但缺乏响应式特性。尽管我们一开始就想避免引入臃肿的框架,但使用时仍会感到体验割裂,且无法实现「渐进增强」。同时,当我们提到 JSX 运行时就意味着额外的依赖是无法避免的,无法做到纯编译期转换。大多数库也没有提供 UMD 打包版本,对用户脚本的集成并不友好。

尽管如此,对于相对简单的 UI 场景,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 的浏览记录的功能,所以在此之前经常出现一个场景就是——在首页刷到的文章,正在看着,一不小心刷新了,就再也找不到了。

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

当时写得比较匆忙,结构也有些混乱。自然也出现了不少重复的动态 DOM 创建的操作,总感觉可以更 neat 些,因此一直想着要进行重构。

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

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

我使用了 vite-plugin-monkey 来开发这个用户脚本,对比提供的样例,我们只需要同步知乎的 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 了,这个故事很可能会在某一次知乎升级依赖的时候就消失了……或者至少变的没有现在那么舒服了。

总结#

以上提供了三种在用户脚本中应用 JSX 的方案,严格来说能直接用上的只有两种,通用的只有一种。很明显,我们的终极目标并没有实现。希望有朝一日我能完成这项工作吧。