在浏览器中编译运行 React
一直很好奇 React 官网的在线编辑器是如何实现的,于是稍微研究了一下,通过 TypeScript 和 Babel 实现在浏览器中编译 React。
这个项目本身也是使用 TypeScript, Webpack 和 Babel 构建的,源码在 react-online - Skyone Git。
在开始之前,请确保你了解了 React 的代码如何从 TSX 格式转换为浏览器可以执行的 JavaScript 代码,如果不了解,可以参考 TypeScript 官网 和 Babel 官网。当然,打包工具 Webpack 也必须要了解。
项目结构
由于整个程序就一个页面,没有路由等麻烦事,所以就按最简单的方式来实现。
项目结构差不多是这样的:
├── src
│ ├── index.ts
│ └── style.css
├── public
├── scripts
└── package.json
直到最后,我用到了如下依赖:
{
"devDependencies": {
"@babel/core": "^7.23.6",
"@babel/preset-env": "^7.23.6",
"@codemirror/commands": "^6.3.2",
"@codemirror/lang-javascript": "^6.2.1",
"@codemirror/language": "^6.9.3",
"@codemirror/view": "^6.22.3",
"@types/node": "^20.10.5",
"@types/webpack-env": "^1.18.4",
"@uiw/codemirror-theme-github": "^4.21.21",
"autoprefixer": "^10.4.16",
"babel-loader": "^9.1.3",
"clean-webpack-plugin": "^4.0.0",
"codemirror": "^6.0.1",
"copy-webpack-plugin": "^11.0.0",
"cross-env": "^7.0.3",
"css-loader": "^6.8.1",
"css-minimizer-webpack-plugin": "^5.0.1",
"html-webpack-plugin": "^5.6.0",
"mini-css-extract-plugin": "^2.7.6",
"postcss": "^8.4.32",
"postcss-loader": "^7.3.3",
"style-loader": "^3.3.3",
"tailwindcss": "^3.4.0",
"terser-webpack-plugin": "^5.3.9",
"ts-loader": "^9.5.1",
"typescript": "^5.3.3",
"webpack": "^5.89.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1",
"webpack-merge": "^5.10.0",
"webpackbar": "^6.0.0"
}
}
画 UI
首把 UI 画好再搞逻辑,这个 UI 应该有两个部分,一个是编辑器,一个是预览区域。这里我使用 tailwindcss
来实现。
奈何实在是不会设计 UI ,将就着看吧。
┌───────────────────────┬───────────────────────┐
│ Options │ │
├───────────────────────┤ │
│ │ │
│ │ Preview │
│ Editor │ │
│ │ │
│ │ │
└───────────────────────┴──────── ───────────────┘
编译 TSX
代码
有两个选择,一个是使用 Babel
将 TSX
代码转换为 JS
代码,但是这个过程中并没有进行任何类型检查,只是做语法检查。不像在 IDE 中会有实时的 typescript
类型检查,浏览器中只进行类型检查还不如直接使用 javascript
。
另一个是先用 typescript
将 TSX
转换为 ESNext
代码,然后再使用 Babel
将 ESNext
转换为 ES5
代码。这个过程中会进行类型检查。
我选择了第二种方式,因为只有这样才可以在浏览器中进行类型检查。
(移除 JSX 语法)
typescript Babel
| |
TSX -> ESNext -> ES5
查了一些这两个库的文档,不做过多其他配置的情况下,编译很简单。
其中 typescript
使用 umd
引入后会挂载到全局的 ts
字段里,编译函数是 transpileModule(code, tsconfig)
function compile(code) {
const js = window.ts.transpileModule(code, {
compilerOptions: {
target: "ESNext",
jsx: "preserve",
sourceMap: false,
},
}).outputText;
// ...
}
Babel
使用 umd
引入后会挂载到全局的 Babel
字段里,编译函数是 transform(code, options)
,所以有:
function compile(code) {
// ...
return transform(js, {presets: ["env", "react"]}).code;
}
核心代码就这几行,其他的就是一些 UI 的操作了。
语法高亮编辑器
这里我使用了 codemirror@6
来实现。说实话,我就没见到过文档写的这么乱的库,找了半天愣是找不到一个完整的例子,全是代码片段...
对于一般用户,谁会慢慢看 API Reference 啊,我只需要最简单的实现就行了,可官网上连个最简单的例子都没有。
总之,最后还是勉强整出来了,虽然功能不多,但对我来说,只有有实时的语法高亮就行了。
import {defaultKeymap, indentWithTab} from "@codemirror/commands";
import {javascript} from "@codemirror/lang-javascript";
import {indentUnit} from "@codemirror/language";
import {keymap} from "@codemirror/view";
import {githubLight} from "@uiw/codemirror-theme-github";
import {basicSetup, EditorView} from "codemirror";
const textarea = document.getElementById("input-code") as HTMLDivElement;
const editor = new EditorView({
parent: textarea,
extensions: [
basicSetup,
javascript({typescript: true, jsx: true}),
githubLight,
keymap.of([...defaultKeymap, indentWithTab]),
indentUnit.of(" "),
],
});
下面是读取和写入值的方法:
function getValue() {
return editor.state.doc.toString();
}
function setValue(value: string) {
editor.dispatch({
changes: {
from: 0,
to: editor.state.doc.length,
insert: value,
},
});
}
了解这么多应该就够用了。
实现预览
这里我使用了 iframe
来实现,因为不能让 react
等库的代码污染到全局,所以使用 iframe
来隔离环境。说的复杂,其实也就是创建一个 iframe
,然后将编译后的代码放进去就行了。
因为没有跨域问题, JavaScript 可以拿到 iframe
里的 window
对象,各种操作其实都很简单。大概像这样:
function applyChaneg() {
const code = editor.state.doc.toString();
// compile code ...
const html = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>React 演练场</title>
<script src="/script/[email protected]"></script>
<script src="/script/[email protected]"></script>
${libraries.join("\n ")}
</head>
<body>
<div id="root"></div>
<script>${result} </script>
</body>
</html>
`.trim();
const contentDocument = page.contentDocument!;
contentDocument.open();
contentDocument.write(html);
contentDocument.close();
}
// 监听 Ctrl + S 进行编译
window.addEventListener("keydown", (e) => {
if (e.ctrlKey && e.key === "s") {
e.preventDefault();
applyChaneg();
}
});
// 监听按钮点击进行编译
button.addEventListener("click", async (e) => {
applyChange();
});
总结
看到这里,你应该觉得也就这种程度嘛~,其实,在一边查资料一边写真的很浪费时间,我用了两天才写完,大概 10 个小时左右,如果你直接看这篇文章,应该只需要 1 个小时左右就能写完。
最后,贴一个例子吧:
这个项目的源码在 react-online - Skyone Git,欢迎大家提出建议。