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

效果

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

怎么做?

看了上面效果预览图中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:

    js 复制代码
    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的文件内容。

js 复制代码
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钩子就会被触发执行。

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

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

js 复制代码
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自定义插件示例:

ts 复制代码
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大概长这样:

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模块。

html 复制代码
<script src="index.js" type="module"></script>

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

相关推荐
慧一居士1 小时前
flex 布局完整功能介绍和示例演示
前端
DoraBigHead1 小时前
小哆啦解题记——两数失踪事件
前端·算法·面试
一斤代码6 小时前
vue3 下载图片(标签内容可转图)
前端·javascript·vue
中微子6 小时前
React Router 源码深度剖析解决面试中的深层次问题
前端·react.js
光影少年6 小时前
从前端转go开发的学习路线
前端·学习·golang
中微子7 小时前
React Router 面试指南:从基础到实战
前端·react.js·前端框架
3Katrina7 小时前
深入理解 useLayoutEffect:解决 UI "闪烁"问题的利器
前端·javascript·面试
前端_学习之路8 小时前
React--Fiber 架构
前端·react.js·架构
伍哥的传说8 小时前
React 实现五子棋人机对战小游戏
前端·javascript·react.js·前端框架·node.js·ecmascript·js
qq_424409198 小时前
uniapp的app项目,某个页面长时间无操作,返回首页
前端·vue.js·uni-app