浅谈浏览器实时构建

前言

在遥远的几个月前,还在上家公司的时候,老板突发奇想,想要搞个代码片段平台,类似于 snipit,实现代码片段的复用。本身这个需求并不难实现——简单的前端界面 + 简单的 node CURD,搞定收工,下班回家。

但是,在实际使用中,发现了一个使用痛点——没有在线调试功能,所有代码只能 copy 到本地,在本地进行调试。本着发现痛点就要解决痛点的指导思想,我当时思考了一段时间,希望寻找一个合适的解决方案来完美的解决这个痛点。总体来说,分为两种方案:

  1. 服务端构建方案:最常见也是最成熟的解决方案,每次修改代码时,将代码传给服务端,由服务端构建后,最终进行代码替换。由于是在服务端进行打包,所以被称为服务端构建方案。在前几年,客户端涉及到构建问题一般采用服务端构建方案。
  2. 浏览器实时构建方案:当前前端最热门的方向之一,在浏览器端进行代码构建,基本不需要与服务端交互(具体分不同细分方案),所以被称为浏览器实时构建。目前各个大厂的 Web IDE 基本采用这种方案。

相比于服务端构建方案,浏览器实时构建方案的优势在于:即时、高效以及最宝贵的——可离线运行(前提是做了合适的缓存方案)。

最终,出于各种因素,最终我选择了浏览器实时构建方案。

浏览器实时构建方案

浏览器实时构建是最近两年前端的热门方向,所以也涌现了一大批成熟的解决方案。

个人总结为:bundle 方案以及 unbundle 方案两种。

bundle 方案(类 CodeSandBox 方案)

大部分投入生产环境的浏览器实时构建方案都采用了该方案,该方案基本采用了 codesandbox-client 的方案,所以我一般称之为类 CodeSandBox 方案。

bundle 方案的核心在于在浏览器上实现一个打包工具,如 webpack,配合 indexDB 进行本地文件存储。当然,不仅仅是这么简单,由于在浏览器端做构建工作效率相对较低,所以需要大量的性能优化,比如 CodeSandBox 在浏览器端实现了一个线程池,当进行构建时,从线程池中取出线程,从而实现多线程打包。

总的来说,bundle 方案依然没有跳出构建的思路,当项目较为复杂时,依然会出现构建工具遇到的那个问题——慢。针对 bundle 方案的缺点,业界推出了 unbundle 方案(当然最主要还得感谢浏览器的支持)。

unbundle方案

unbundle 方案的出现,需要感谢 ESM。啥是 ESM 呢?ESM 全称为 ECMAScript modules,即浏览器原生支持模块化规范。效果如下:

<script type="module">
  // 引用别的模块
  import { util } from './utils.js';
	// 使用别的模块中的函数
  util()
</script>

当浏览器解析到 import 语句时,会像开发态一样自动引入对应模块。得益于 ESM,我们可以不经过构建即可直接在浏览器端运行模块化代码。

相比于 bundle 方案,unbundle 方案在各种意义上都快了许多,特别当项目复杂度上来以后,这个差异将会异常明显;另一方面,unbundle 不需要在浏览器端实现一个打包工具,对于快速实现浏览器实时构建也有着很大的意义。

由于 unbundle 的各种优点,最终我选择了使用 unbundle 方案来实现浏览器实时构建。

实现一个单文件浏览器实时构建

接下来,到了实战环节,我们来尝试实现一个单文件浏览器实时构建。

该系统分为两部分:

  • 客户端(浏览器实时构建)
  • 服务端(依赖服务器)

客户端

首先是 UI 部分,UI 简单的分成三部分:代码编辑器(如:monaco editor),按钮(用于执行实时构建函数)以及构建结果展示部分,主要负责调试代码并展示结果。

当点击按钮后,触发构建函数,开始执行构建逻辑,整个构建流程主要分为以下几步:

  1. 将 Vue 文件拆分成 template、script 以及 style
  2. 对 script 进行处理
    • 初始化 es-module-lexer,解析出所有导入语句(import)
    • 重写 import,将请求地址指向依赖服务器
    • 开始生成最终在浏览器中执行的代码,将重写后的 script 写入
  3. 解析模板,生成 render 函数,将 render 函数挂载到 script 上
  4. 解析 style,更新样式有两种方案
  5. 最终,新建一个 script,去除原有的 script,插入最新的 script

总体思路其实和 vite 非常像,也可以认为是在浏览器端实现了一个小 vite。详细的代码可以参考 vite 源码,在此不再赘述。

服务端

服务端的功能非常简单,即接收请求,返回对应的依赖打包文件。由于 ESM 只支持 ESM 规范,所以,需要将各种模块规范(主要指的是 commonjs)统一转为 ESM。

当依赖服务器接收到客户端的请求时,具体工作流程如下:

  1. 服务端安装依赖
  2. 通过 es-build 将依赖转为 esm
  3. 将依赖返回给客户端

未来的路

通过上面的思路,我们就可以实现一个最简单的单文件浏览器实时构建了,但是其实有非常多的问题,比如:

  1. 如何进行依赖版本控制?
  2. sourceMap 的问题怎么解决?
  3. 服务端可以继续优化吗?
  4. 可以纯浏览器实时构建(即没有服务端)吗?
  5. 如何实现多文件浏览器实时构建?

本来我很想直接来一句:这些问题留作课后思考[doge],但是怕被打,所以接下来就聊下我对这几个问题的看法吧。

  1. 用户可以采用注释标注版本,解析时,使用正则或 babel 解析即可,思路类似于 magic comments
  2. 多次映射即可解决
  3. 依赖服务器优化的主要思路可以放在缓存管理上,试想,如果不做缓存处理,每次用户请求都需要重新下载、转换、打包依赖,当用户量增大时,服务器的压力该有多大?
  4. 依赖请求使用 unpkg,不过,这个相当于从个人写的依赖服务器转换到了公司提供的 unpkg 服务器,只不过理论上确实不用自己写依赖服务器了[doge]。
  5. 如果能够实现多文件浏览器实时构建,可以解决非常多单文件浏览器构建实现的问题,最直接的就是可以解决依赖版本问题。多文件浏览器实时构建复杂度相对于单文件浏览器实时构建高了不止一个数量级,所以这个问题我思考了非常久。最常见的方案就是之前说过的,CodeSandBox 方案,但是复杂度实在太高。所以要想降低复杂度,还需要使用 unbundle 方案。经过思考,我采用了 service worker,service worker 可以拦截所有请求,拦截后,判断是否请求的是本地文件,如果是本地文件,发消息给本地,如果是第三方依赖,请求依赖服务器。但是 service worker 有个致命的问题,第一次请求不能拦截,只有第二次之后的请求才能拦截(毕竟 service worker 本质是个缓存)。

以上就是我的思路,如果大佬们有不同的思路,欢迎一起探讨。

最后

按照惯例,发个招聘帖:

字节跳动招人啦,HC 巨多,北上广深杭皆有坑位。

团队详情见://webinfra.org/about

提供内推及面试辅导服务,目前我内推的几个同学皆通过了面试,欢迎咨询~

暂时不看机会,之后有想法来字节试试的同学,也一样欢迎你加入 😁。

有意者可发送邮件到 [email protected]