在 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);
});

效果

1638637891720

在应用中使用脚本

现在 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));
});

1638638313624

使用 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 服务

1638639258659

使用 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

1638641182603

结语

ok,看来在浏览器上(纯本地)编写简单的 spa 应用应该可行,至少走通了第一步,虽然后面还差了好多步就是了。


在 web 中开发 web 应用
https://blog.rxliuli.com/p/00e0fd89d8f6486099d150b242a436d7/
作者
rxliuli
发布于
2021年12月5日
许可协议