在 web 中开发 web 应用
本文最后更新于:2021年12月5日 凌晨
场景
最近在基于 web 实现扩展系统,也由于看到了 stackblitz 在博客中阐述的 web container 概念,于是想尝试在 web 中本地开发一个 web 应用。
这个 idea 的实现基于以下以下几个事实
- iframe 支持 blob url
- vscode 开源了 monaco-editor
- esbuild-wasm 在浏览器可用
- 浏览器支持 esm/wasm
ps: 吾辈确实也没想到现在浏览器的功能已经强到了如此地步。。。
将 html 代码显示到 iframe 中
主要是基于 Blob/URL.createObjectURL
这两个 API,下面是简单的示例
const initHTMLCode = `<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='UTF-8' />
<meta
name='viewport'
content='width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0'
/>
<meta http-equiv='X-UA-Compatible' content='ie=edge' />
<title>iframe</title>
</head>
<body>
<div id="app">测试 iframe 页面</div>
</body>
</html>
`;
function renderPreview(code: string) {
const $iframe = document.querySelector("#preview") as HTMLIFrameElement;
const blob = new Blob([code], {
type: "text/html",
});
$iframe.src = URL.createObjectURL(blob);
}
window.addEventListener("load", async () => {
renderPreview(initHTMLCode);
});
效果
在应用中使用脚本
现在 web 应用中不可能没有脚本,这里添加一个简单的脚本来完成 html 与 js 脚本代码的粘合函数。
// 其他代码。。。
//language=javascript
const initScriptCode = `document.querySelector("#app").textContent = "使用脚本修改的文本";`;
function concatCode(htmlCode: string, jsCode: string) {
const domParser = new DOMParser();
const $document = domParser.parseFromString(htmlCode, "text/html");
const $script = $document.createElement("script");
$script.text = jsCode;
$document.body.appendChild($script);
return $document.documentElement.innerHTML;
}
// 其他代码。。。
window.addEventListener("load", async () => {
renderPreview(concatCode(initHTMLCode, initScriptCode));
});
使用 npm 包
上面的脚本只是演示,但实际应用中不可能不使用 npm 包,幸运的是,浏览器原生支持 esm 模块,我们仅仅需要编译 react jsx。看起来目前可以使用 typescript/babel-jsx 之类的 js 库来编译,但考虑到最终目标,我们还是需要一个构建工具,这里选择 esbuild,因为它使用 golang 编写很快而且通过 wasm 支持在浏览器中使用。
下面是用 jsx 编写的一个示例,我们尝试在浏览器中构建它。
import React, { useState } from "https://esm.sh/react";
import ReactDOM from "https://esm.sh/react-dom";
import { useEffectOnce } from "https://esm.sh/react-use";
function useNow() {
const [now, setNow] = useState(new Date());
useEffectOnce(() => {
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
});
return now;
}
function App() {
const now = useNow();
return <div>当前时间:{now.toLocaleString()}</div>;
}
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.querySelector("#app")
);
实现构建工具类 src/util/BrowserScriptBuilder.ts
import { build, initialize, Plugin } from "esbuild-wasm";
import wasmUrl from "esbuild-wasm/esbuild.wasm?url";
interface Module {
name: string;
code: string;
isEntry: boolean;
}
/**
* esbuild 的浏览器文件系统插件
* copy: https://github.com/hyrious/esbuild-repl/blob/main/src/helpers/fs.ts
* @param modules
*/
export function esbuildPluginFs(modules: Module[]): Plugin {
return {
name: "esbuild-plugin-fs",
setup({ onResolve, onLoad }) {
onResolve({ filter: /()/ }, (args) => {
const name = args.path.replace(/^\.\//, "");
const mod = modules.find((e) => e.name === name);
if (mod) {
return { path: name, namespace: "fs", pluginData: mod };
} else {
return { path: args.path, external: true };
}
});
// noinspection ES6ShorthandObjectProperty
onLoad({ filter: /()/, namespace: "fs" }, (args) => {
const mod: Module = args.pluginData;
if (mod) {
return { contents: mod.code, loader: "default" };
}
return;
});
},
};
}
export class BrowserScriptBuilder {
private flag = false;
/**
* 初始化
*/
async init() {
if (this.flag) {
return;
}
await initialize({
wasmURL: wasmUrl,
worker: true,
});
this.flag = true;
}
/**
* 构建多个模块
* @param modules
*/
async build(modules: Module[]): Promise<string> {
return (
await build({
entryPoints: modules
.filter((item) => item.isEntry)
.map((item) => item.name),
bundle: true,
sourcemap: "inline",
format: "esm",
plugins: [esbuildPluginFs(modules)],
})
).outputFiles![0].text;
}
}
import { BrowserScriptBuilder } from "./util/BrowserScriptBuilder";
// 其他代码。。。
function concatCode(htmlCode: string, jsCode: string) {
const domParser = new DOMParser();
const $document = domParser.parseFromString(htmlCode, "text/html");
const $script = $document.createElement("script");
// 修改为 module 类型
$script.type = "module";
$script.text = jsCode;
$document.body.appendChild($script);
return $document.documentElement.innerHTML;
}
const browserScriptBuilder = new BrowserScriptBuilder();
window.addEventListener("load", async () => {
const jsCode = await browserScriptBuilder.build([
{ name: "main.jsx", code: initScriptCode, isEntry: true },
]);
renderPreview(concatCode(initHTMLCode, jsCode));
});
这里有一些需要关注的点
- esbuild-wasm 在 bundle 多个文件时,需要通过插件指明 resolve 规则,因为浏览器默认不存在文件系统
- script 使用 module 类型以告诉浏览器使用 esm 模块
- 使用 esm.sh cdn 服务
使用 monaco-editor 作为在线编辑器
目前已经可以编辑了,现在使用 monaco-editor 来让我们可以编辑代码并立刻看到效果更有 web 开发的感觉
修改一下页面的布局和样式
<div id="app">
<div class="container">
<div id="htmlEditor"></div>
<div id="scriptEditor"></div>
</div>
<iframe id="preview"></iframe>
</div>
html,
body,
#app {
margin: 0;
padding: 0;
height: 100%;
}
* {
box-sizing: border-box;
}
#app {
display: grid;
grid-template-columns: repeat(2, 1fr);
}
.container {
display: grid;
grid-template-rows: repeat(2, 1fr);
}
#htmlEditor {
width: 100%;
height: 100%;
}
#preview {
width: 100%;
height: 100%;
border: none;
}
import { editor } from "monaco-editor";
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
import IStandaloneCodeEditor = editor.IStandaloneCodeEditor;
// 其他代码
Reflect.set(window, "MonacoEnvironment", {
getWorker(_: any, label: string) {
if (label === "html" || label === "handlebars" || label === "razor") {
return new htmlWorker();
}
if (label === "typescript" || label === "javascript") {
return new tsWorker();
}
return new editorWorker();
},
});
function initEditorAutoFormat(editor: IStandaloneCodeEditor) {
editor.onKeyDown((e) => {
if (e.ctrlKey && e.code === "KeyS") {
editor.getAction("editor.action.formatDocument").run();
}
});
}
const htmlEditor = editor.create(document.querySelector("#htmlEditor")!, {
value: initHTMLCode,
language: "html",
});
const scriptEditor = editor.create(document.querySelector("#scriptEditor")!, {
value: initScriptCode,
language: "javascript",
});
initEditorAutoFormat(htmlEditor);
initEditorAutoFormat(scriptEditor);
document.addEventListener("keydown", async (ev) => {
if (ev.ctrlKey && ev.key === "s") {
ev.preventDefault();
renderPreview(await buildScript());
}
});
document.addEventListener("keydown", async (ev) => {
if (ev.ctrlKey && ev.key === "s") {
ev.preventDefault();
const jsCode = await browserScriptBuilder.build([
{ name: "main.jsx", code: initScriptCode, isEntry: true },
]);
renderPreview(concatCode(initHTMLCode, jsCode));
}
});
这里我们并未调用 URL.revokeObjectURL 销毁 blob url,因为当我们将之关联到 iframe.src 时,浏览器会自动处理引用,可以在 开发者控制台 > 应用 > 帧 中看到 blob 每次变化之前的就不存在了。事实上,一旦关联到 iframe.src,我们主动调用 URL.revokeObjectURL(iframe.src)
都无法销毁掉 Blob。
使用 vite 开发时引入 monaco 出现问题请参考作者的回答:https://github.com/vitejs/vite/discussions/1791#discussioncomment-321046
结语
ok,看来在浏览器上(纯本地)编写简单的 spa 应用应该可行,至少走通了第一步,虽然后面还差了好多步就是了。
- 更好的编辑器(vscode for web)
- 基于浏览器的文件系统支持,可能是基于 indexeddb 或 File System Access API
- 通过 Broadcast Channel/postMessage 实现热更新
- 解决 monaco editor 不支持 jsx 的问题
- 解决 TypeScript 目前不支持 esm http import 的问题
- 支持运行 server(似乎可以基于 service worker 完成?)