跳到主要内容

在浏览器中编译运行 React

· 阅读需 7 分钟
Skyone

一直很好奇 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 代码

有两个选择,一个是使用 BabelTSX 代码转换为 JS 代码,但是这个过程中并没有进行任何类型检查,只是做语法检查。不像在 IDE 中会有实时的 typescript 类型检查,浏览器中只进行类型检查还不如直接使用 javascript

另一个是先用 typescriptTSX 转换为 ESNext 代码,然后再使用 BabelESNext 转换为 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/react@18.2.0.min.js"></script>
<script src="/script/react-dom@18.2.0.min.js"></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,欢迎大家提出建议。