如何搭建一个代码实时编辑、编译打包、运行预览的web playground

avatar
@哈啰

本文来自平台前端团队逸飞。

效果

左侧是编辑区域,右边是预览区域。代码编辑后会实时编译、运行、在预览区域展示运行结果。

preview

怎么做?

看了上面效果预览图中index.tsx的代码,你可能会产生如下疑问:

  • JS模块能够在浏览器中运行吗?
  • 这段代码扔浏览器里能直接运行吗?
  • 导入的quarkc依赖会被打包进最终的bundle吗?打包...包的内容从哪儿来?
  • import导入CSS文件要如何处理?
  • 装饰器语法...能直接在浏览器中执行吗?
  • JSX语法怎么处理?
  • ......?

思路梳理

首先,我们的playground是建立在现代浏览器能直接运行原生JS模块这一点之上的,但是很明显上述代码不能作为原生JS模块直接在浏览器中运行,原因有几点:

  1. 原生JS modules 默认 情况下不支持使用无修饰模块名来引用模块,也就是那些没有点号.和文件后缀的模块标识符,也称为裸模块,在这段代码里对应的是quarkc
  2. CSS文件不能直接作为JS模块使用import语句导入
  3. 装饰器语法目前在所有浏览器中都不能直接运行
  4. 浏览器JS引擎不识别JSX语法,需要转译

那么我们就必须在运行前编译转换这段代码:

  1. 将裸模块标识符转换成URL,比如说使用CDN:
    import Module from "https://esm.sh/PKG@SEMVER[/PATH]";
    
  2. 将CSS文件转换成JS模块
  3. 对装饰器语法做降级处理,让其能够直接在浏览器中运行
  4. 对JSX语法做转译——视使用框架不同,我们需要应用不同的jsxFactory配置
    1. 使用quarkc编写组件时,我们需要将其转换成QuarkElement.h调用
    2. 使用React编写组件时,我们需要将其转换成React.createElement调用
    3. 使用preact等等...

以上所有这些工作都可以使用esbuild来完成,后面会在编译打包这一节做详细介绍。

应用流程

读取驻留在内存中的文件内容 => 初始化编辑器 => (用户编辑文件内容) => 请求编译入口HTML(在线编辑器中index.html的内容) => 解析HTML生成AST语法树 => 遍历AST => 将AST节点依次转换成HTML元素 => 插入至预览iframe => 预览iframe呈现运行结果

其中比较核心的一环的是“将AST节点转换成HTML元素”,根据AST节点类型的不同,我们会有不同的处理:

  • AST节点类型为script且src为相对路径
    • script标签引用的脚本位置在虚拟文件系统上。需要读取它的内容并进行编译,编译完成后将其转换成内联script标签并插入到iframe中运行脚本
  • 其他AST节点直接转换成HTML插入到iframe中

虚拟文件系统

一个web应用通常由三部分组成:HTML、CSS样式表和JS脚本,我们的虚拟文件系统至少需要支持这三种类型的文件。为了简化playground的编写,可以先固定只有index.htmlindex.cssindex.tsx这三个入口文件。如果有需要的话,可以使用组合模式(Composite Pattern)这种设计模式来设计树形的文件系统目录结构。至于文件的存储和读取,如果没有保存和加载编辑进度的需求的话使用内存存储即可,否则可以考虑使用localstorageindexedDB

编辑

代码编辑功能很简单,哪怕是用textarea做也可以。但是为了编码时的体验,我们需要能够支持代码的换行、缩进、高亮等功能的编辑器。市面上目前比较常用的有monaco-editorCodeMirror。前者是微软出品,除了基本的代码高亮等功能外,还涵盖了绝大部分vscode支持的功能——如语法错误校验、代码提示补全等,理论上来说是能够提供最佳的在线编码体验。但是在笔者编写这篇文章时,monaco-editor最新的0.45.0版本仍然存在着诸多悬而未决的问题,也如它的大版本号所示,monaco-editor远还未达到能够正式发布的阶段。退而求其次,这边笔者选择了CodeMirror,在接入成本比较小的情况下可以满足一些常用的编码需求。

编译打包

如果入口脚本使用原生JS书写,那么这一步可以先跳过。

当我们在使用quarkc编写web components,又或者是使用React等框架时,我们就需要引入编译打包步骤———通常是对\.(j|t)sx?或者.vue格式的文件进行编译和打包。文章主要会对TSX文件的编译打包做介绍。

在本地开发阶段时,我们最常用到的打包工具主要有webpack和vite。

编译

webpack用ts-loader来完成typescript的编译,用babel-loader来完成对JSX语法的编译。而vite使用了esbuild来完成这两项工作。而这些工具都是运行在Node.js环境中的。那么在web浏览器环境中,我们要如何去编译ts和jsx呢?有用javascript编写的typescript compiler吗?答案是有的,但是毫无疑问编译的性能会比较糟糕,解释执行的编译器性能上肯定不如编译执行的。那么问题又来了,web浏览器环境有执行机器码的能力吗?答案是有的,现如今主流的web浏览器中支持直接运行webassembly——web(类)汇编语言,是一种二进制指令格式,在web环境能够提供接近原生的执行速度。而上面提到的esbuild这个打包工具就提供了wasm版本的包esbuild-wasm,使我们能够以相对较快的速度在web环境中完成typescript和jsx的编译。

打包

在本地开发阶段,我们可能不需要打包这个功能。通过借鉴vite的no bundle思路——即可以把所有的“模块”均转换成可以直接在浏览器中运行的ESModule。

esbuild的build API支持直接将字符串作为输入进行打包构建,那么在这里我们的输入就是index.tsx的文件内容。

esbuild.build({
  bundle: true,
  format: 'esm',
  // stdin选项能被用来打包不存在于文件系统上的模块
  stdin: {
    contents: `
      // ...contents of index.tsx...
      import {
        QuarkElement,
        customElement,
      } from 'quarkc';
      import style from './index.css?inline';

      @customElement({
        tag: "quark-greeting",
        style,
      })
      class QuarkGreeting extends QuarkElement {
        render() {
          return (
            <p>Hello, world!</p>
          );
        }
      }
    `,
    loader: 'tsx',
  },
  // * 转译JSX语法,配置jsxFactory
  jsxFactory: 'QuarkElement.h',
  // * 新版本的esbuild-wasm需要配置experimentalDecorators为true,否则装饰器语法不会被降级处理
  tsConfigRaw: `{ compilerOptions: { "experimentalDecorators": true } }`
  // ...other options
})

代码转换

在上面的打包API调用示例中,我们导入了模块quarkcindex.css。但是原生ESM默认并不支持 裸模块(bare imports) 的导入,也不支持将其他类型的文件视为ESM进行导入。这里我们需要借助esbuild的插件机制来控制build过程中的模块解析代码转换

模块解析

不同于Node.js环境,web环境JS引擎不能直接访问主机的文件系统,因此我们需要接管ESM的模块解析——将裸模块(bare imports)重写为虚拟文件系统上的一个文件地址又或者是ESM能够识别的模块标识符(指向ESM下载地址的相对路径/绝对路径)。像quarkc和react这种第三方依赖,我们可以考虑用unpkgesm这种CDN来下载,当然也可以自己搭建一个静态文件服务器来托管。我们只需要把ESM中的裸模块引用标识符重写成CDN地址即可,esbuild提供了onResolve钩子来帮助我们完成这一步工作。在esbuild的编译构建过程中,每当遇到ESM模块引用时,onResolve钩子就会被触发执行。

build.onResolve({
  // * 通过filter参数可以过滤出符合条件的模块引用
  // * 这里过滤出对裸模块标识符的引用
  filter: /^[\w@][^:]/,
}, ({ path }) => {
  // * 重写引用
  return {
    // * 标记引用模块为外部资源,这样esbuild就不会尝试去读取这个模块的内容
    external: true,
    // * 重写引用路径为CDN下载地址,默认使用三方依赖的@latest版本
    path: `https://esm.sh/${path}`,
  };
});

编译结束后,之前示例中quarkc的模块引用路径会被重写为:

import {
  QuarkElement,
  customElement,
} from 'https://esm.sh/quarkc'

这样,第三方模块依赖就能够正常被下载并解析了。当然除了用onResolve钩子去重写这种方法以外,还可以使用importmap来达成类似的效果,这里不再单独做介绍。

转换

那么CSS文件的导入要如何处理呢?我们需要将这些非ESM文件转换成ESM。之前实例中我们导入了虚拟文件系统上的index.css,是因为我们在创建quarkc组件时需要将组件样式表的内容传入customElement类装饰器,也就是index.css?inline的默认导出。在这里?inline查询参数是借鉴了vite的做法,用于获取样式表文件内容字符串,如果不加这个查询参数的话,样式表默认会被插入到文档中。那么根据?inline查询参数的有无,我们转换后的ESM有两种——1、读取index.css文件的内容并将其作为默认导出。2、读取index.css文件的内容,创建内联style并将其设置为内容然后插入到文档。

除了onResolve钩子外,我们还需要用到onLoad钩子,下面是一个完整的esbuild自定义插件示例:

const customCssPlugin: esbuild.Plugin = {
  name: 'customCss',
  setup(build) {
    const NAMESPACE = 'custom-css';
    // * 过滤出所有.css后缀文件并归类至命名空间custom-css
    build.onResolve({ filter: /\.css(?:$|\?)/ }, (args) => {
      return {
        path: args.path,
        namespace: NAMESPACE,
      };
    });
    // * 转换文件内容
    build.onLoad({
      filter: /.*/,
      namespace: NAMESPACE,
    }, ({ path }) => {
      // * 读取文件内容
      const code = read(cleanPath(path));
      // * 转换成ESM
      let contents = `export default ${JSON.stringify(code)};\n`;
      // 如果有需要的话,可以通过判断path中是否包含?inline查询参数
      // 来决定是否需要修改contents字符串的内容(改成向文档插入内联style的脚本)
      // 这里留给读者来实现
      return {
        contents,
        loader: 'js',
      };
    });
  },
}

其他类型的文件也同理,比如说JSON文件也只需要读取内容并将其转换成默认导出即可。而less、sass这种比起css只需要在默认导出前多一步编译预处理语法工作即可。

编译结果

最终编译生成的JS大概长这样:

var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {
  // ... 此处省略装饰器帮助函数的代码 ...
};

// <stdin>
import {
  QuarkElement,
  property,
  customElement
} from "https://esm.sh/quarkc";

// custom-css:./index.css
var index_default = ":host p {\n  color: #0088ff\n}\n";

// <stdin>
var QuarkGreeting = class extends QuarkElement {
  name = "World";
  render() {
    return /* @__PURE__ */ QuarkElement.h("p", null, " Hello ", this.name, " ");
  }
};
__decorateClass([
  property()
], QuarkGreeting.prototype, "name", 2);
QuarkGreeting = __decorateClass([
  customElement({
    tag: "quark-greeting",
    style: index_default
  })
], QuarkGreeting);

运行预览

这里需要一个隔离的环境来运行编译后生成的脚本和样式表,一般做法是可以用iframe。理论上利用web components的shadow dom特性也能做到,这里留给读者自行去思考并实践。

我们需要用预览iframe去展示实时编辑的index.html的内容。笔者的做法是将index.html的内容转换成AST语法树然后去遍历它,将AST上的每一个节点转换成对应的HTML元素并插入到iframe中。在遍历的过程中如果遇到对虚拟文件系统上文件的引用,比如说<script type="module" src="./index.js"></script>,我们就需要先对这个文件进行编译,获取编译结果并将其转换成内联script后再插入到iframe中,然后再继续AST的遍历。遍历结束后,我们的iframe就已经正确呈现出了我们的预览页面。

用户每次编辑修改入口HTML/JS/CSS的内容后,我们都需要重新加载iframe并重复上述流程。

结语

至此,一个简单的playground就完成了。在这之上,我们可以完善的东西还有很多。比如说,我们可以再引入package.json来支持让用户指定第三方依赖的版本、支持虚拟文件系统的目录结构——支持文件(夹)的新增和删除、支持更多类型文件的ESM import等等...

esbuild是一个非常优秀的打包工具,vite内置了它来实现开发阶段的JSX和TS转译以及依赖扫描,它的工作机制和rollup比较相似,熟悉它有助于我们去了解后两者是如何工作的。

具体的代码示例可以查看我们在github上的项目quark-playground

知识补充

原生ESM

符合es modules规范并能在浏览器中直接运行的JS模块。

<script src="index.js" type="module"></script>

浏览器会异步下载index.js模块并解析它的所有依赖——递归地下载这些依赖和依赖的依赖...最终构成了模块依赖树,也就是我们常说的模块依赖图(module dependency graph)。