本文来自平台前端团队逸飞。
效果
左侧是编辑区域,右边是预览区域。代码编辑后会实时编译、运行、在预览区域展示运行结果。
怎么做?
看了上面效果预览图中index.tsx的代码,你可能会产生如下疑问:
- JS模块能够在浏览器中运行吗?
- 这段代码扔浏览器里能直接运行吗?
- 导入的quarkc依赖会被打包进最终的bundle吗?打包...包的内容从哪儿来?
- import导入CSS文件要如何处理?
- 装饰器语法...能直接在浏览器中执行吗?
- JSX语法怎么处理?
- ......?
思路梳理
首先,我们的playground是建立在现代浏览器能直接运行原生JS模块这一点之上的,但是很明显上述代码不能作为原生JS模块直接在浏览器中运行,原因有几点:
- 原生JS modules 默认 情况下不支持使用无修饰模块名来引用模块,也就是那些没有点号
.
和文件后缀的模块标识符,也称为裸模块,在这段代码里对应的是quarkc
。 - CSS文件不能直接作为JS模块使用
import
语句导入 - 装饰器语法目前在所有浏览器中都不能直接运行
- 浏览器JS引擎不识别JSX语法,需要转译
那么我们就必须在运行前编译转换这段代码:
- 将裸模块标识符转换成URL,比如说使用CDN:
import Module from "https://esm.sh/PKG@SEMVER[/PATH]";
- 将CSS文件转换成JS模块
- 对装饰器语法做降级处理,让其能够直接在浏览器中运行
- 对JSX语法做转译——视使用框架不同,我们需要应用不同的jsxFactory配置
- 使用quarkc编写组件时,我们需要将其转换成
QuarkElement.h
调用 - 使用React编写组件时,我们需要将其转换成
React.createElement
调用 - 使用preact等等...
- 使用quarkc编写组件时,我们需要将其转换成
以上所有这些工作都可以使用esbuild来完成,后面会在编译打包这一节做详细介绍。
应用流程
读取驻留在内存中的文件内容 => 初始化编辑器 => (用户编辑文件内容) => 请求编译入口HTML(在线编辑器中index.html
的内容) => 解析HTML生成AST语法树 => 遍历AST => 将AST节点依次转换成HTML元素 => 插入至预览iframe => 预览iframe呈现运行结果
其中比较核心的一环的是“将AST节点转换成HTML元素”,根据AST节点类型的不同,我们会有不同的处理:
- AST节点类型为script且src为相对路径
- 其他AST节点直接转换成HTML插入到iframe中
虚拟文件系统
一个web应用通常由三部分组成:HTML、CSS样式表和JS脚本,我们的虚拟文件系统至少需要支持这三种类型的文件。为了简化playground的编写,可以先固定只有index.html
、index.css
和index.tsx
这三个入口文件。如果有需要的话,可以使用组合模式(Composite Pattern)这种设计模式来设计树形的文件系统目录结构。至于文件的存储和读取,如果没有保存和加载编辑进度的需求的话使用内存存储即可,否则可以考虑使用localstorage或indexedDB。
编辑
代码编辑功能很简单,哪怕是用textarea
做也可以。但是为了编码时的体验,我们需要能够支持代码的换行、缩进、高亮等功能的编辑器。市面上目前比较常用的有monaco-editor和CodeMirror。前者是微软出品,除了基本的代码高亮等功能外,还涵盖了绝大部分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调用示例中,我们导入了模块quarkc
和index.css
。但是原生ESM默认并不支持 裸模块(bare imports) 的导入,也不支持将其他类型的文件视为ESM进行导入。这里我们需要借助esbuild的插件机制来控制build过程中的模块解析和代码转换。
模块解析
不同于Node.js环境,web环境JS引擎不能直接访问主机的文件系统,因此我们需要接管ESM的模块解析——将裸模块(bare imports)重写为虚拟文件系统上的一个文件地址又或者是ESM能够识别的模块标识符(指向ESM下载地址的相对路径/绝对路径)。像quarkc和react这种第三方依赖,我们可以考虑用unpkg或esm这种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)。