01
背景
这段时间在搞一个出码项目,出码后支持编辑代码并可以在右侧实时预览,其ui如下,在调研了几个开源(react-playground,react-live,minisandbox)的在线编辑运行react代码的库后,把所得所想分享给大家。
02
react-playground
使用方式:
const files = {
'App.tsx': `import {title} from './const'
function App() {
return <h1>this is {title}</h1>
}
export default App
`,
'const.ts': {
code: 'export const title = "demo2"',
},
}
<PlaygroundSandbox
width={700}
height={400}
files={files}
theme='dark'
/>
2.1 编辑器部分
开源的使用最多的编辑器:Monaco Editor、Ace 和 Code Mirror。
Monaco Editor生态丰富功能强大,还是vscode同款编辑器,带代码提示等功能,开发友好。
react-playgound是用的就是 @monaco-editor/react
<MonacoEditor
width='600px'
height='800px'
onChange={(newCode) => setCode(newCode)}
defaultValue={code}
defaultLanguage='javascript'
/>
(比较简单,也不是本次讲解重点)
2.2 代码预览部分
项目中如果有全局样式,全局变量,会影响到实时代码运行效果,所以运行时需要一个干净的环境去运行代码,react-playground 选用的方案是iframe。
- 浏览器运行jsx
浏览器不认识jsx,所以我们需要把jsx转为js,这个转换通常是用babel实现的,react-playground采用的是 @babel/standalone( babel的浏览器版)
import { transform } from '@babel/standalone'
const babelTransform = (code: string) => {
return transform(code, {
presets: ['react', 'typescript'],
}).code
}
const compliedCode = babelTransform(jsxCode)
经过编译,我们的代码会变成
- 代码编译的任务交给谁处理
交给主线程?可能会导致页面阻塞,编辑时感到卡顿,所以好的解决方案是 新开一个web worker用于处理代码编译操作。
// compiler.worker.ts
self.addEventListener('message', async ({ data }) => {
// 2. 接收到源代码后编译
const compiledCode = babelTransform(jsxCode)
//3. 编译完成后,发送数据给index.tsx
self.postMessage(compiledCode)
})
// index.tsx
compilerRef.current = new CompilerWorker()
useEffect(()=>{
// 1. 源代码变更后,发送给worker去编译
compilerRef.current?.postMessage(jsxCode)
},[jsxCode])
compilerRef.current.addEventListener('message', ({ data }) => {
// 4. 接收到web worker编译后的代码,发送到iframe中
iframeRef.current?.contentWindow?.postMessage(data)
})
// iframe.html
window.addEventListener('message', ({data}) => {
// 6. 接收到编译后的代码执行
// 代码插入script标签中或者转为临时文件地址赋值给script标签
})
支持第三方模块包引入
由于浏览器原生支持esm,我们选择使用esm格式的第三方依赖包。有一些非esm的包,可以通过esm.sh 找到其对应的的esm格式文件地址。
然后通过importmap 映射模块地址。
importmap 允许您在浏览器环境中指定模块路径映射,import "react" 时,会按importmap 中指定的 URL 来加载 react 模块。
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/[email protected]",
"react-dom/client": "https://esm.sh/[email protected]",
}
}
</script>
引入本地模块
我们在本地项目中执行这么一行代码 import a from './A',这样的导入语句通常由构建工具和模块打包器处理的(如webpack,vite等)
- esm 模块
依赖编译时生成的树形结构,寻找到对应路径。会以此寻找A.js,A.jsx,A.ts… 。然后打包到一个文件或独立文件中。
- commonjs模块
Node.js 提供了一个文件系统模块,它允许你在服务器端环境中访问和操作本地文件系统。
那么在浏览器中,我们如何解析 import a from './A' 对应的模块?
浏览器绝大部分支持esm模块,支持的是URL文件,所以我们可以把'./A'替换成url地址,浏览器就可以使用了
import a from './A' --> import a from 'http://xxx/A'
这由于我们没有一个服务来提供文件,可以使用 URL.createObjectURL 生成临时URL文件。
// 把代码编译后转换成url文件
const A = URL.createObjectURL(
new Blob([babelTransform(code)], {
type: 'application/javascript'
})
)
// 转换后的地址
// blob:https://localhost:3000/e4ef352f-1c5f-414e-8009-33514b300842
// 替换 './A'
import a from 'blob:https://localhost:3000/e4ef352f-1c5f-414e-8009-33514b300842'
按照这个思路,在引入本次文件时,我们只要分析import语句,把对应模块代码在编译时替换为本地URL地址。
怎么做呢?用babel插件。
babel 插件就是在 transform 的阶段增删改 AST 的:
const transformImportSourcePlugin: PluginObj = {
visitor: {
ImportDeclaration(path) {
path.node.source.value = url;
}
},
}
const res = transform(code, {
presets: ['react', 'typescript'],
filename: 'test.ts',
plugins: [transformImportSourcePlugin]
}
path.node.source.value就对应ast树的如下值
至此,react-playground 的原理已经分析清楚了。分别用 Blob + URL.createBlobURL 和 import maps 来做。
引入样式文件
(() => {
let stylesheet = document.getElementById('style');
if (!stylesheet) {
stylesheet = document.createElement('style')
stylesheet.setAttribute('id', 'style')
document.head.appendChild(stylesheet)
}
const styles = document.createTextNode(`${css}`)
stylesheet.innerHTML = ''
stylesheet.appendChild(styles)
})()
03
react-live
我们再来看下react-live方案,在github上4.2k star
相比 react-playground,有两点不同:
1)编译后的js代码,不是通过script标签插入的形式做的,而是通过new Function/eval 的方式变为可执行代码。
2)依赖的处理不同。
function evalCode(code: string, scope: Record<string, any>) {
const scopeKeys = Object.keys(scope)
const scopeValues = Object.values(scope)
// eslint-disable-next-line no-new-func
return new Function(...scopeKeys, code)(...scopeValues)
}
function generateNode(props) {
const { code = '', scope = {} } = props、
// 删除末尾分号,因为下边会在 code 外包装一个 return (code) 的操作,有分号会导致语法错误
const codeTrimmed = code.trim().replace(/;$/, '')
const opts = { transforms: ['jsx', 'imports'] as Transform[] }
// 前边补上一个 return,以便下边 evalCode 后能正确拿到生成的组件
const transformed = transform(`return (${codeTrimmed})`, opts).code.trim()
// 编译后只是一个字符串,我们通过 evalCode 函数将它变成可执行代码
return evalCode(transformed, { React, ...scope })
}
重点说下,依赖的处理
1)import-map方案
- 优点:简单易用。
- 缺点:
- 只支持esm模块地址;
- 如果一个A包内部引了B包,只在importmap中定义A包的映射地址,在实际运行A包逻辑时,会报Uncaught TypeError: Failed to resolve module specifier “B” 这样的错误,所以 对于依赖复杂的包,使用importmap方案不够友好,需要分析依赖,然后每个都在importmap定义映射。
2)scope 方案
实际传入的是一个上下文对象,它定义了当前代码可以访问的所有外部资源。react-live 会将代码中的标识符与 scope 进行匹配,以找到这些标识符的定义。
- 优点:直接提供变量和组件,简化了依赖管理
- 缺点:不能自动解析复杂的模块依赖,依赖关系需要手动管理。
介绍和对比了react-playground和react-live两种方案,他们主要的使用场景在 「代码片段实时预览」 。如组件文档实例、在线调试代码等。那有没有更强大的前端编辑器和实时预览方案,答案是肯定的。
比如codesandbox、stackblitz。
04
codesandbox
codesandbox 开源了 @codesandbox/sandpack-react库,这个React库提供了很多开箱即用的codesandbox模块。
对应codesanbox的面板来看,分别是以下组件
各个组件通过postMessage与SandackPreview渲染的iframe交互。
codesandbox的两种运行环境:
1)纯前端项目(比如React项目、纯JS项目)使用Browser Sandbox
2)需要服务端运行环境(比如Docker项目、全栈框架项目)使用Cloud Sandbox(他底层使用的是MicroVM)
对于browser sandbox来说,由于浏览器端并没有 Node 环境,所以 CodeSandbox 自己实现了一个可以跑在浏览器端的简化版 webpack。称为 mini webpack。
4.1 原理
关于codesandbox的原理,文章有很多。我这里不重点解释了
CodeSandbox 构建项目过程
构建过程主要包括了三个步骤:
- Packager--npm 包加载阶段:下载 npm 包并递归查找所有引用到的文件,然后提供给下个阶段进行编译
- Transpilation--编译阶段:编译所有代码, 构建模块依赖图
- Evaluation--执行阶段:使用 eval 运行编译后的代码,实现项目预览
Packager--npm包加载阶段
codesandbox受WebpackDllPlugin启发。DllPlugin 会将所有依赖都打包到一个dll文件中(存储预打包模块),并创建一个 manifest 文件来描述dll的元数据(描述模块映射)。
{
"name": "dll_bundle",
"content": {
"./node_modules/fbjs/lib/emptyFunction.js": 0,
"./node_modules/fbjs/lib/invariant.js": 1,
"./node_modules/fbjs/lib/warning.js": 2,
"./node_modules/fbjs/lib/react.development.js": 3,
"..."
每一个路径都映射一个模块id。如果我想引入 React,我只需要调用 dll_bundle(3),然后我就得到了React。
基于这个思想, CodeSandbox 构建了自己的在线打包服务, 和WebpackDllPlugin不一样的是,CodeSandbox是在服务端预先构建Manifest文件的。
这个包叫,packager ,是基于 express框架提供的服务,其流程是,比如我现在有一个 react包16.8.0版本,首先 express 框架接收到请求中的包名以及包版本, react、16.8.0。然后通过 yarn 下载 react 以及 react 的依赖包到磁盘上,通过读取 npm 包的 package.json 文件中的 module、main 等字段找到 npm 包入口文件,然后解析 AST 中所有的 require 语句,将被 require 的文件内容添加到 manifest 文件中,并且递归执行刚才的步骤,最终形成依赖图。
这样就实现将 npm 包文件内容转移到 manifest.json 上的目的,同时也实现了剔除 npm 模块中多余的文件的目的。最后返回给 Sandbox 进行编译。
manifest 长这个样子:
Transpilation--编译阶段
先从 Packager 服务下载 npm 依赖包对应的 manifest 文件,接着前端项目的入口文件开始对项目进行编译,并解析 AST 递归编译被 require 的文件,形成依赖图。原理同webpack。
Evaluation--执行阶段
项目入口文件对应的编译后的模块开始,递归调用 eval 执行所有被引用到的模块。
总结一下:Browser Sandbox 页面通过内置的mini webpack与其他工具(比如babel),编译并执行代码。
代码编译、执行的信息也会通过通信协议传递回各个需要的模块。比如,控制台模块可以根据type为console的信息打印消息。
05
stackblitz
stackblitz 核心技术是webcontainers。
5.1 什么是webcontainers?
WebContainer 是由 StackBlitz 团队开发的一项革命性技术,它允许我们在浏览器中运行完整的 Node.js 环境。这使得我们可以在浏览器中运行现代 JavaScript 开发工具,如 Webpack、Vite 和各种 npm 包,而无需安装任何本地依赖。
以前,我们如果想要在浏览器内实现本地化,主流想法是以electron为例,把浏览器的内核引擎与node环境进行搭配,实现了web应用的本地化,但是随着wasm的技术的发展,浏览器的运算能力已经能够比肩系统级,因此,也就能够支持起来将node移植到浏览器内的可行性。这种技术 就是webcontainers。
以前需要云虚拟机来执行用户代码的应用程序,现在在 WebContainers 中可以完全在浏览器中运行。
简而言之:webContainer 就是一个可以运行在浏览器页面中的微型操作系统,提供了文件系统、运行进程的能力,同时内置了 nodejs、npm/yarn/pnpm 等包管理器。
主要特性:
- 能够在浏览器中运行 node.js 及其工具链(如:webpack、vite 等)
- 灵活:在 WebContainers 支持下,编码体验将会大幅提升
- 安全:所有内容都运行在浏览器页面中,天然隔离,非常安全
- 快速:毫秒级启动整个开发环境。
- 始终开源免费
- 零延时。离线工作。
热更新从代码编写,到编译打包,完全在浏览器中闭环,只要打开一个浏览器就完成所有的动作。
5.2 了解 WebAssembly
WebAssembly 是一种运行在现代网络浏览器中的新型代码,并且提供新的性能特性和效果。它设计的目的不是为了手写代码而是为诸如 C、C++和 Rust 等低级源语言提供一个高效的编译目标。
WebAssembly 是一门低级的类汇编语言。它有一种紧凑的二进制格式,使其能够以接近原生性能的速度运行。
WebAssembly 是 WebContainers 能够在浏览器中运行的核心。通过将 Node.js 编译为 WebAssembly,WebContainers 能够在浏览器中提供一个完整的开发环境,包括运行 Node.js 代码、安装和管理 npm 包等功能。
5.3 案例:在浏览器中运行一个简单的 Node.js 应用
第一步:创建WebContainer实例,并启动
import { WebContainer } from '@webcontainer/api';
// 启动 WebContainer 实例
const webContainerInstance = await WebContainer.boot();
第二步:创建文件系统,并挂载到webcontainer实例上
await webContainerInstance.mount(projectFiles);
第三步:以编程方式下载依赖
const install = await webContainerInstance.spawn('npm', ['i']);
await install.exit;
第四步:启动服务
await webContainerInstance.spawn('npm', ['run', 'dev']);
这样,我们就使用webcontainers开发了一个简单的node应用。
我们可以从 stackblitz体验webcontainers案例。
5.4 原理
一句话就是,在 service worker 中,借助 wasm 实现一个 js runtime。
利用 WebAssembly 来实现一个浏览器环境中不存在的 API(如 Node.js 的 readFile),并将其功能注入到全局对象供 JavaScript 使用。
5.5 使用场景&支持度
1)webIDE,如stackblitz;
2)bug复现片段;
3、)实验功能,无需本地新启一个项目,费时费力。
WebContainers 在基于 Chromium 的桌面浏览器上开箱即用,在 Safari 16.4 和 Firefox 上也受支持。
参考链接
[01] React Playground在线Dome
https://fewismuch.github.io/react-playground/#eNqNVd1u2zYUfhVOQWEbi37sxmmmJUG7bsB60Q1oL3Yx74ImjyQ2FCmQlGPX8LvvkJJsOXWBQjZAfec7
[02] @babel/standalone( babel的浏览器版)
https://link.juejin.cn/?target=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40babel%2Fstandalone
[03] @codesandbox/sandpack-react库
https://www.npmjs.com/package/@codesandbox/sandpack-react
[04] express
https://expressjs.com/
[05] mini webpack
https://juejin.cn/post/6844903880652750862?from=search-suggest
[06] 离线工作
https://mmbiz.qpic.cn/mmbiz_gif/meG6Vo0MevhvJfkvEYBMuCrCvjbDn6d37PcBH4tcP4c62kOhUfLHLQSOD8lhv8HR7SQDF9KA2JoIzpJkuNqgUQ/640?wx_fmt=gif&tp=webp&wxfrom=5&wx_lazy=1
[07] stackblitz
https://stackblitz.com/edit/stackblitz-webcontainer-api-starter-4vmxqn?file=main.js
作者:若笙
来源-微信公众号:阿里技术
出处:https://mp.weixin.qq.com/s/VEV6RmOdZpAg7RBQzDmfUA