背景#
初识用户脚本(Userscript)时,它或许只是收藏夹里的一段书签代码,亦或是某个网页的简易美化脚本。但随着需求的增加,我们开始严肃对待它时,基于单文件的开发模式便显得过于原始了。得益于 vite-plugin-monkey,我们得以使用现代技术栈开发用户脚本。在解决了工程化问题后,在其他开发体验上,我们也希望有更优雅的体验。
开发用户脚本时,对于网页 UI 改造或添加自定义组件总是绕不开的需求。若不引入额外框架,往往需要编写大量繁琐的 document.createElement 与 appendChild 代码。但是有时候,我们并不需要复杂的响应式功能,只是想要一种更简洁、声明式的方式来描述 DOM 结构和操作。
面对这种场景,我心中总会浮现一个念头:「这太适合用 JSX 了!」。
大部分情况下我们接触到的 JSX 都是绑定在 React、Vue 等框架上的,而我始终认为,为用户脚本引入 React 或 Vue 这类重型运行时(Runtime)显得有些得不偿失。尽管也有 Svelte 这种编译时的框架,但是它的产出会显得有些臃肿(尽管「现在大家都不缺这点儿了」)。
理想情况下,它应该和 Astro 的模板语法一样,直接在编译阶段将 Astro 文件转换为纯粹的 HTML + JavaScript(当然,其产物实际上需要引入运行时在水合过程中执行)。但对于用户脚本,我们总归还是需要动态创建 DOM 的。
---const name = 'world'---<h1>Hello {name}</h1>因此,退而求其次的选择是:能否在编译期将 JSX 完全映射为原生的 DOM 操作代码?但很可惜,目前尚未找到能完美达成此目标的工具,而从底层实现一套编译器又超出了我的能力范围,因此,我们只能再次退而求其次了——能否有一个足够轻量的 JSX 运行时,基本就是对 document.createElement 的简单封装?
基于轻量 JSX 运行时#
以上述目标去寻找相关的库时,其实会发现选择还是比较多的,但是多多少少都存在一些问题。下面是我找到的几个比较有代表性的库:
注意到表格里的 UMD/IIFE 一栏,如果在通常的前端项目中,我们基本不用考虑引入问题,直接安装依赖就好了。但在用户脚本中,情况就比较麻烦了。
这就要提到用户脚本开发时比较坑的一个点了。主流的用户脚本管理器都不支持直接引入 ESM 模块,只能通过 UMD 或 IIFE 将库暴露到全局对象上来使用。
JSX-DOM#
JSX-DOM 是一个简单的 JSX 运行时实现,中规中矩。在设计上贴近了 React 的 API。虽然它的大小并不太占优势,但是 API 设计与类型支持比较友好。
JSX-DOM 并没有提供 UMD 或 IIFE 的版本,而如果我们完整 bundle,似乎还不如用 Svelte 或 Solid。并且作者也无意打包 UMD 版本,而是推荐用当时还能使用的 CDN 服务进行转换——现在已经无法访问了。于是决定自己动手打包一个 UMD 版本,供用户脚本使用。
打包后,我把它发布到了 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,则可以很方便配置:
// ...build: { externalGlobals: { '@maxchang/JSX-DOM': cdn.jsdelivrFastly(`jsxDOM.default`, `dist/index.min.js`), },},// ...我构建了一个模板项目,整合了上述内容:userscript-tsx-starter,以供参考。
vm-DOM#
vm-DOM 是由 Violentmonkey 用户脚本管理器团队维护的,体积很小,但功能也相对克制。并且缺少 jsx-runtime 类型(不过想用的话可以自己补一个)。本质上可以看作是对 document.createElement 的一层薄封装,更适合对功能需求极简的场景。
由于设计上就是面向用户脚本的,vm-DOM 提供了 UMD 版本,因此可以直接引入。
nanoJSX#
nanoJSX 整体更偏向一个微型框架,功能完整,也拥有响应式等特性。官方宣称的 1kb 体积实际为 Gzip 后大小,不过在 Slim 版本下,其实际体积表现也很好,但是 API 与 React 有较大差异。
nanoJSX 提供了面向 web 的 bundle,我们可以比较容易的引入。我同样在 userscript-tsx-starter 中提供了一个使用 nanoJSX 的分支,以供参考。
hastscript#
syntax-tree 家族的 HAST(Hypertext Abstract Syntax Tree, 即对 HTML 的抽象语法树)的相关工具链中,提供了 hastscript 这个库,可以通过类似于 JSX 的 API 来创建 HAST 节点,配合 hast 生态中的其他工具,它也可以作为一个 JSX 运行时来使用。
基于网站自身环境#
但有没有可能不引入任何运行时,而是直接复用网站自身的环境呢?
某一天,我在重构一个先前实现的用户脚本——知乎历史记录。
知乎首页的信息流可以直接查看完整文章,但是没有提供类似 App 的浏览记录功能。在此之前经常会出现这样的场景:在首页刷到的文章,正在阅读时,一不小心刷新了页面,就再也找不到了。
所以这个脚本的功能就是记录你在首页上点击的文章,并提供了一个弹出层展示,效果如下:
当时写得比较匆忙,结构也有些混乱,自然出现了不少重复的动态 DOM 创建操作。总感觉可以更整洁些,因此一直想着要进行重构。
正当我要考虑上面的方案时,发现了一件意外之喜:知乎本身使用的是 UMD 版本的 React,我们可以直接从 window.React 和 window.ReactDOM 获取全套 API!
这给了我一些灵感——我们完全可以复用全局对象的 React,这样就可以在不用额外引入任何运行时,还可以获得完整的 React 体验。
对 vite-plugin-monkey 提供的 React 样例,我们只需要同步知乎的 React 版本,在 build 配置中指定 externalGlobals (需开启 unsafeWindow 以访问完整的 Window)就可以在打包 React 和 ReactDOM 时使用我们指定的值了:
// ...build: { externalGlobals: { // react and react-dom are already mounted on the page react: 'unsafeWindow.React', 'react-dom': 'unsafeWindow.ReactDOM', },},// ...由此,我们可以在不额外引入运行时的情况下,在用户脚本(仅限这个场景)中使用 React 与 JSX 开发了!
当我们写一个这样的 tsx 文件时:
// ...const mountApp = () => { const app = document.querySelector('#app')
ReactDOM.render(<App />, app)}mountApp()我们就可以得到一个类似这样的产出:
(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 和 ReactDOM 的时候。通过 这篇文章 我们可以知道,在某一个时间点之前,知乎还是不能直接获取的。但我们也应该可以通过 Hook 的方式拿到,以后再尝试吧。
在此之前我从未写过 React,所以某些地方也许写的比较奇怪,同时目前略显「为了用而用」。但我认为在需求变复杂时是完全值得的。如果你感兴趣的话,可以在 Github 查看源代码。
总之,我们以极小的代价获得了 React 的所有能力,JSX 的编译结果可读性也不错。但是很明显的一件事情是,React 18 之后的版本已经不再支持 UMD 了,这个故事很可能会在某一次知乎升级依赖的时候就消失了……或者至少变得没有现在那么舒服了。