shikiji-dom-loader:基于 shiki(ji) 的 DOM 代码块自动高亮工具

写这个东西的原因是,之前用的 PrismJS 的代码高亮没有办法满足我的需求,魔改了一版也是一样,然后想起来 Vitepress 使用的代码高亮工具十分的有意思,于是就想着能不能给这玩意写一个在 Web 加载的工具。主要手里面的你看到的博客还是 WordPress,要是 SSG 的话还是会好很多,就不用写这玩意了。

首先就是要解决一个问题,就是,我发现用 ESBuild 把 shikiji 的所有文件打包在一起的大小有近 12MB。加载一个 12MB 的脚本显然是不现实的,而且里面有 80% 的语言都用不到;想着分模块加载似乎又不是很好做……

直到我换成 rollup 对还没写完的 main.js 进行了打包。rollup 告诉我,这玩意必须分块分包啥的,打包不了 iife/umd。这不正好!印象中 SystemJS 似乎是有一个全局对象,所以就让 rollup 打包成了 SystemJS 格式的一个个的 modules。正合我意。于是便有了下面的想法:

Dynamic Script Tag -> Mount on global object -> export / import / execute

整个机制是借鉴 Prism 的 autoloader。最后写出来也是和这个 autoloader 比较像的(

显然,如果直接使用 System 作为这个插件在全局作用域还是太草率了,毕竟是 SystemJS 自己的名称。所以这里使用了 @rollup/plugin-replace 插件替换掉 System 这一名称。

接下来就是人工实现一版 SystemJS 的过程;且称之为 preloader。

观察 SystemJS 打包出来的东西:

ShikijiDOMLoader.register(['./javascript.js', './typescript.js', './coffee.js', './stylus.js', './sass.js', './css.js', './scss.js', './less.js', './postcss.js', './pug.js', './markdown.js', './html.js'], (function (exports) {
  'use strict';
  var javascript, typescript, coffee, stylus, sass, css, scss, less, postcss, pug, markdown;
  return {
    setters: [function (module) {
      javascript = module.default;
    }, function (module) {
      typescript = module.default;
    }, function (module) {
      coffee = module.default;
    }, function (module) {
      stylus = module.default;
    }, function (module) {
      sass = module.default;
    }, function (module) {
      css = module.default;
    }, function (module) {
      scss = module.default;
    }, function (module) {
      less = module.default;
    }, function (module) {
      postcss = module.default;
    }, function (module) {
      pug = module.default;
    }, function (module) {
      markdown = module.default;
    }, null],
    execute: (function () {
      const lang = {}; // 已经省略
      var svelte = exports("default", [
        ...javascript,
        ...typescript,
        ...coffee,
        ...stylus,
        ...sass,
        ...css,
        ...scss,
        ...less,
        ...postcss,
        ...pug,
        ...markdown,
        lang
      ]);
    })
  };
}));

main.js 还有动态导入:

ShikijiDOMLoader.register([], (function (exports, module) {
  'use strict';
  return {
    execute: (async function () {

      const bundledLanguagesInfo = [
        {
          "id": "abap",
          "name": "ABAP",
          "import": () => module.import('./abap.js')
        }
      ]
    })
  }
}));

大概不难猜到是下面的流程:

type Module = { import: (url: string) => Promise<Record<string, unknown>>; };
type Exporter = (name: string, data: unknown) => void;
type Loader = () => {
  execute: () => Promise<unknown>;
  setters: ((module: unknown) => void | null)[];
}
register(deps: string[], loader: (exports: Exporter, module: Module) => Loader) => void

每一个 deps[i] 对应一个 setter[i]deps[i] 的导入结果传给 setter[i];很明显了吧。

然后通过事件给动态标签的脚本加载(串行)做了一个 Promise 的封装:

(不串行加载会出现一个问题:在 register 的时候不能判断 moduleUrl,因此只能串行了,对网络条件不好的用户来说体验会差一点)

window.ShikijiDOMLoader = new (class {
    loadingScript = "";
    /** @type {Record<string, Record<string, unknown>>} */
    modulesData = {};
    /** @param {string} moduleUrl  */
    import = (moduleUrl) => {
        const self = this;
        return new Promise((resolve, reject) => {
            const moduleKey = self.getModuleUrl(moduleUrl);
            this.loadingScript = moduleKey;

            const el = document.createElement("script");
            el.src = moduleKey;

            function resolver() {
                document.body.removeChild(el);
                resolve(self.modulesData[moduleKey]);
                removeEventListener(
                    `_ShikijiDOMLoader__register__module__{moduleKey}`,
                    resolver
                );
            }
            addEventListener(
                `_ShikijiDOMLoader__register__module__{moduleKey}`,
                resolver
            );

            el.onerror = (e) => {
                document.body.removeChild(el);
                reject(e);
            };

            document.body.appendChild(el);
        });
    };
    /**
     * @param {string[]} dependencies
     * @param {import("../preloader").ModuleLoaderFn} loader
     */
    register = async (dependencies, loader) => {
        const registerModuleKey = this.loadingScript;
        // ...
        dispatchEvent(
            new CustomEvent(
                `_ShikijiDOMLoader__register__module__${registerModuleKey}`
            )
        );
    };
})();

稍微模拟下,整个 preloader 就写好了。现在的问题是,preloader 似乎没法和 main.js 合在一起通过多入口构建。还是希望整个项目是一个单文件的;于是这里采用了一个笨办法:

(构建脚本)

const f = fs.openSync("temp/main.js", "w");
fs.writeSync(
    f,
    fs.readFileSync("src/preloader.js").toString() +
        "\n\n" +
        fs.readFileSync("dist/main.js").toString()
);

然后对 temp/main.jsrollup。这样就有了!

另外是记一下 JSDoc + TS 的一些 trick:

Window 可以这样写:

// preloader.d.ts
export declare global {
    interface Window {
        ShikijiDOMLoader: any;
    }
}
// preloader.js
/// <reference path="./preloader.d.ts" />
window.ShikijiDOMLoader = ...

源代码放在 https://github.com/immccn123/shikiji-dom-loader 了。还是很有意思的。

不过现在看起来这个代码高亮还是有点 CSS 要修复,不过问题不大啦(有点溢出)。周末再修(