腾讯一面:除了webContainer之外,还有什么办法能在浏览器上运行node代码🫣🫣🫣

因为我的简历上面写了一个在线代码编辑器,而且这个使用的是 webContainer,这家伙能直接在浏览器上面允许 node 代码并执行一些 node 和 npm 命令。

于是便有了像标题里面中提到的这个问题。

JavaScript 开发人员可能希望在浏览器中运行 Node.js 代码的原因有多种,包括:

  • 想要使用 Node.js API,例如 buffer, crypto, fork, events, streams 等等。
  • 更喜欢用 require 编写 CommonJS 风格的模块。
  • 在线代码编辑器允许 node 代码。

现有选项

现在有许多解决方案至少满足上述一些要求,例如 webpack.js.orgbrowserify.org 或 nodular.js。

然而,所有这些方法都基于某种形式的源代码转换------要么提前 webpack 和 browserify,要么在运行时 nodular。

这些解决方案似乎没有什么问题,足以满足你的目的,但实际上他们只是试图模仿或模仿某些行为。

创建在浏览器中表现不一样的 Node.js 程序是很容易的:

Browserify 通过查看 require 调用来尝试提前解析和捆绑引用的模块;上面,由于 require 的模糊用法而失败:lib.js 不会被捆绑,导致运行时错误。

Webpack 可以说是最复杂的产品之一,它在处理 CommonJS 模块方面表现出色,并且在模仿 Node.js API 方面做得相当不错。尽管它会提前编译你的源代码,但多亏了源映射(source maps),你仍然可以调试正在运行的应用程序,这对于源代码的调试非常有帮助。然而,最终,API 中的微妙差异(例如 process.nextTick 与 setImmediate 与 setTimeout 的确切时机)可能会很容易地暴露出根本性的差异。

动机

Node.js 的 API 正在不断发展,虽然几乎可以为任何功能提供兼容性填充,但我喜欢直接使用原始 API 的想法。Node.js 的特性和细微差别应该自然地供脚本使用。此外,我希望能够直接运行 Node.js 脚本,无需进行任何预处理。这使得交互式解释器(REPL)和在线集成开发环境(IDE)能够在不依赖服务器进行繁重工作的情况下运行。

如下图所示:

Node.js 的一些核心模块都能直接在我浏览器上面允许起来了。

除此之外,我内心深处渴望看到这两个 JavaScript 世界(Node.js 和浏览器)的融合。对我来说,代码转换更像是一种权宜之计,而不是一种解决方案。想象一下,如果要在 64 位计算机上运行 32 位应用程序,需要进行 AOT 编译,而不是实际的硬件和操作系统支持,那会是多么不方便。

挑战:CommonJS 语义

当然,其中一个最大的挑战是正确处理 CommonJS 模块和 require。Webpack 和 Browserify 通过捆绑(实际上是预加载)所有可能在运行时被 require 的脚本来支持 require 的同步特性。

Node.js 通过同步文件系统操作来实现 require,所以让我们停下来思考一下可选方案:

  • 浏览器无论如何都无法直接访问主机的文件系统(这没关系),所以这不是问题。
  • 可以创建一个内存文件系统(使用变量或本地存储),这显然支持同步访问。实际上,这就是现有解决方案通过提前捆绑文件所做的事情。
  • 可以将网络资源视为文件系统。普通的 URL 就像只读文件,而云存储(GitHub、OneDrive、Google Drive、iCloud 等)可以作为具有写访问权限的文件系统。但是,网络请求总是异步的,对吗?不对!需要明确指出的是,我不建议在主线程上使用(现在已弃用的)函数,但在工作线程中是允许的并且可行的 - 我们应该在工作线程中启动 Node.js,但稍后再谈。
  • 2 和 3 的任意组合。例如,就像在实际的计算机上一样,写入可以首先命中一些内存缓存(因此非常快速),然后在后台持久化。

因此,当在工作线程中运行时,似乎可以在不进行任何捆绑的情况下使用 require!我只需将所有写入定向到内存,并通过首先在内存中查找路径,然后在失败时进行 HTTP 请求(针对从路径计算出的 URL)来处理读取。非常基本但目前已足够。

结构

从极其简单的意义上来说,Node.js 看起来如下:

绑定通过进程对象暴露给库.

标准库是用 JavaScript 编写的,它提供了你熟悉的高级 Node.js API,就像你平常使用的那些功能一样。这个 API 是建立在本地、低级别的绑定之上的,这些绑定让你能够访问操作系统的特性,比如文件系统或进程控制。这些绑定通过一个叫做"process"的对象与标准库进行通信。换句话说,"process"是 Node.js 进程中唯一来自于非 JavaScript 部分的东西,稍后我们会详细讨论这个。

V8 是用于执行任何 JavaScript 代码的引擎,也就是说,它是负责运行标准库和所有 JavaScript 脚本的核心引擎。就像你的浏览器使用 V8 引擎来运行网页上的 JavaScript 代码一样。

战略

为了完全在浏览器中运行,C/C++ 部分必须替换为 JavaScript 实现:

具体来说,V8 引擎的部分基本上是免费的,因为浏览器可以直接执行 JavaScript 代码(在 Chrome 的情况下,使用的就是 V8 引擎 😏)。但是替换这些绑定意味着我们需要在我们这一方进行实际工作。

我很高兴看到大部分的实现细节确实是标准库的一部分,而不是绑定的一部分 --- 绑定基本上是尽可能低级的,同时仍然保持平台独立性。

模范启动

如前所述,绑定是通过 process 对象提供的。标准库有一个引导函数,它以 process 为参数,并准备了大量的 API。具体来说,有一个名为 process.binding 的函数,它接受一个像'constants'、'buffer'、'fs'、'os'、'tcp_wrap'或'constants'这样的名称,并返回一个具有本地函数的对象 --- 这个机制类似于 require 返回模块,但在更低层次上。

我观察到,错误地调用 process.binding 提供的本地函数通常会导致进程崩溃,例如 process.binding('os').getCPUs()。我假设没有执行参数数量和类型检查。当然,这不是问题,因为标准库会正确调用这些函数,并不会将它们直接暴露给程序员。

对于我的移植工作,实际上我开始时只是将 process = {} 传递给引导函数,对工作原理或预期结果一无所知。我让标准库抛出的错误来指导我。最初的一个错误是由于缺少函数绑定。我实现了一个函数绑定,每当它以我以前未见过的名称被调用时,就会抛出一个错误。在这种错误的情况下,我只需添加代码来返回该名称的空对象。如果该对象上缺少某些内容,将会抛出错误,以此类推。这种懒惰的方法的好处是错误位置通常会准确指示下一步需要实现什么。不需要事先的调查或上下文。如果对函数的实现不确定,可以将其实现为 debugger;,然后等待调试器在它上面中断。检查参数可能会给你一些线索。但是有些函数会修改全局状态(例如 process.binding('fs').stat),在这种情况下,我会查看 C 源代码,以了解预期发生的情况。

在某个时候,调用引导函数将不再引发错误,喜大普奔!

接下来该干什么?

没什么,你就完成了!引导程序还负责引导引导后应该发生的任何事情。如果 process.argv 包含脚本作为参数,则将调用该脚本。如果没有,REPL 就会启动。光滑。

web Worker

回想一下,我们在工作线程中运行所有内容都是为了获取同步 HTTP 请求。无论如何,这实际上是一个很棒的概念,原因有几个:

  • 隔离:在某种程度上,一个引导的 Node.js 实例不会在同一个上下文/线程中运行,与主进程(可以扮演操作系统的角色)分离开来有一定道理。与 UI 的交互可以通过消息传递来处理,这本来就是 Node.js 的核心概念。
  • 隔离:如果多个 Node.js 实例应该同时运行,全局范围必须以某种方式分离,一个实例中的无限循环不应该影响其他实例等等。
  • 隔离:工作线程与 child_process API 协同工作得很好。尽管到目前为止我还没有涉及到这些,但将工作线程视为进程是非常合理的。你可以创建它们并摧毁它们,而不考虑它们的内部状态。Node.js 实例拥有资源,比如消息队列和计时器,将它们所在的整个世界摧毁是确保这些资源不会导致内存泄漏的最简单方法。

局限性

Node.js 提供了低级别的网络 API,包括 TCP 套接字等功能。浏览器中并没有这种低级别的 API,你只能使用高级别的 API,比如 HTTP 或 Web 套接字。由于 Node.js 的 HTTP API 依赖于低级别的 API,因此它也不能真正使用。我猜在这种情况下,注入像 browserify 的 http polyfill 是合适的。另一方面,实际上可以为在工作线程之间进行通信而实现基于内存传递的 TCP!针对外部世界的请求可以被解封,检查是否为 HTTP(就像防火墙的作用),然后使用浏览器的本机 HTTP API 进行重播。本质上是通过仅支持 HTTP 的防火墙模拟 NAT?

vm API 提供了在不同上下文中执行 JavaScript 的方法。这也是如何执行所需的脚本或如何使 REPL 工作的方式。从某种意义上说,这就是 eval 所做的事情(这也是我目前用来模拟 vm 的方式)。然而,实际上 vm 还有更多的功能,当查看 REPL 时就会显现出来:执行 const x = 3 将声明变量 x 并使其在后续语句中可用。这在使用 eval 时是不可能的,因为 const 会自动激活严格模式,而 eval 在严格模式下不会将变量引入到周围的作用域中。具体来说,eval("var x = 3"); eval("x");将按预期运行,但 eval("const x = 3"); eval("x");则不会。令人沮丧。也许可以在另一个工作线程中使用 importScripts 来进行一些技巧操作,不太确定。这也可能解决下一个问题,即 eval 是不可中断的。在 REPL 中,Ctrl+C 应该能够中断 while(true);。幸运的是,eval 似乎对 require 来说已经足够了,所以目前这个问题在某种程度上只是个外观问题。

执行原理

我们先来看看源码先:

ts 复制代码
class VirtualMachine {
  public constructor(
    private fs: VirtualFileSystem,
    private terminal: Terminal
  ) {}

  private syscall(origin: Worker, func: string, arg: any): void {
    switch (func) {
      case "stdout":
        this.terminal.write(arg);
        document.getElementById("stdout")!.textContent += arg;
        break;
      case "stderr":
        this.terminal.write(arg);
        document.getElementById("stderr")!.textContent += arg;
        break;
      case "error":
        this.terminal.write("[Runtime Error]\n");
        this.terminal.write(arg + "\n");
        if (arg.stack) this.terminal.write(arg.stack + "\n");
        break;
      case "WRITE":
        this.fs[arg.path] = arg.content;
        break;
      case "EXIT":
        const exitCode = arg;
        origin.terminate();
        break;
    }
  }
  public node(args: string[], keepAlive: boolean = false): void {
    document.getElementById("stdout")!.textContent = "";
    document.getElementById("stderr")!.textContent = "";
    this.terminal.clear();
    const vm = this;
    const worker = new Worker("/bin/node/app.js");
    if (keepAlive) (self as any)._keepAlive = worker;
    worker.onmessage = function (ev: MessageEvent) {
      const { f, x } = ev.data;
      vm.syscall(this, f, x);
    };
    // worker.onerror = function (ev: ErrorEvent) { console.error(JSON.stringify(ev, null, 2)); };
    const env: Environment = { fs: this.fs, cwd: "/cwd" };
    worker.postMessage({ type: "start", args, env });

    this.terminal.onData((ch: string) => {
      if (ch.length > 8) {
        worker.postMessage({
          type: "stdin",
          ch: ch,
        });
      }
      if (ch.length === 1) {
        switch (ch.charCodeAt(0)) {
          case 3: // Ctrl + C
            break;
          case 22: // Ctrl + V
            break;
        }
      }
    });
    this.terminal.onKey(({ key, domEvent }) => {
      // console.log(key, domEvent.key, domEvent.code);
      worker.postMessage({
        type: "stdin",
        ch: key,
        key: {
          name: domEvent.key.toLowerCase().replace(/^arrow/, ""),
          ctrl: domEvent.ctrlKey,
          shift: domEvent.shiftKey,
          meta: domEvent.metaKey,
          alt: domEvent.altKey,
        },
      });
    });
  }
}

在上面的代码中,我们来看看几个重要的步骤。当你调用 node 方法时,它执行以下一系列操作来模拟虚拟机中运行 Node.js 代码的过程:

  • 准备环境:
  • 清空网页上的 stdout 和 stderr 区域,以确保输出是干净的。 清空终端的内容,以便开始一个新的虚拟机会话。 创建 Worker:
  • 使用 new Worker 创建一个新的 Worker 对象,它会在后台运行一个 JavaScript 文件,即 "/bin/node/app.js"。 如果 keepAlive 参数为 true,会将 Worker 对象存储在 _keepAlive 属性中,以保持其活动状态。 设置消息监听:
  • 使用 worker.onmessage 来监听从 Worker 发出的消息。 当 Worker 发送消息时,该函数会被触发,并提供消息中的函数名称和参数。 发送启动消息:
  • 向 Worker 发送一个启动消息,该消息包括类型为 "start" 的信息,以及要传递给 Node.js 的参数 (args) 和环境 (env)。 这实际上启动了虚拟机中的 Node.js 进程。

总的来说,这个 node 方法创建了一个虚拟机环境,使用 Worker 来模拟运行 Node.js 代码。它通过监听终端输入和按键事件,实现了与虚拟机中运行的 Node.js 代码的交互。当 Node.js 代码通过系统调用请求标准输出、标准错误、文件写入等操作时,这个方法会将这些请求转发给终端和网页上的输出区域,以便在浏览器中模拟完整的 Node.js 运行环境。

虚拟机中的 Node.js 进程是怎么来的

虚拟机中的 Node.js 进程是通过创建一个 Web Worker 来实现的。Web Workers 是 JavaScript 中的一项技术,它允许在后台线程中运行脚本,以避免阻塞主线程。在这种情况下,虚拟机模拟了一个 Node.js 环境,而这个环境在 Web Worker 中运行。

具体来说,以下是创建虚拟机中的 Node.js 进程的步骤:

  • 创建 Worker 对象:

    • 在虚拟机的 node 方法中,通过 new Worker 创建一个新的 Web Worker 对象。
    • 这个 Worker 对象会在后台运行一个 JavaScript 文件,通常是 "/bin/node/app.js",这是虚拟机中模拟 Node.js 环境的入口点。
  • 设置消息监听:

    • 使用 worker.onmessage 来监听从 Worker 发出的消息。这个消息监听器会在 Worker 发送消息时触发。
    • 消息通常包括函数名称和参数,用于模拟系统调用。
  • 发送启动消息:

    • 在虚拟机中,向 Worker 发送一个启动消息,该消息告诉 Worker 开始模拟 Node.js 进程。
      • 这个启动消息通常包括以下信息:
        • 类型为 "start" 的消息类型。
          • 要传递给 Node.js 的参数,例如命令行参数。
          • 环境信息,例如虚拟文件系统和当前工作目录。
  • Node.js 进程的模拟:

    • Worker 接收到启动消息后,会开始模拟 Node.js 进程。
    • 它会执行传递给它的 JavaScript 代码,这些代码通常包括模拟 Node.js 的核心功能,例如文件系统操作、标准输入输出等。
    • Worker 通过模拟系统调用来与虚拟机的终端进行通信,并模拟 Node.js 进程的行为。

虚拟机中的 Node.js 进程是通过创建一个后台运行的 Web Worker 来实现的。Worker 接收启动消息后,会开始执行模拟的 Node.js 代码,以模拟标准 Node.js 进程的行为,包括处理输入、输出、文件系统操作等。

这些都是一些 node 相关的 polyfill:

参考文献

总结

通过 web worker 的方式去启动一个虚拟机,该虚拟机模拟了一个 Node.js 环境,从而实现在浏览器上面允许 node 的代码。

那么问题来了,如何在浏览器上面运行世界上最好的语言呢???

最后分享两个我的两个开源项目,它们分别是:

这两个项目都会一直维护的,如果你也喜欢,欢迎 star 🥰🥰🥰

相关推荐
崔庆才丨静觅16 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606117 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了17 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅17 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅17 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅18 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment18 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅18 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊18 小时前
jwt介绍
前端
爱敲代码的小鱼18 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax