codesandbox是如何在浏览器中运行npm模块
目前,跑在浏览器端的web IDE产品越来越多,按照他们的功能特性来做划分的话,目前的web IDE可以分为两种,一种是将本地IDE的功能基本原封不动的迁移到了web端的IDE,像是目前最流行的前端IDE VS Code,借助于云+容器化的能力,在浏览器端VS Code拥有着跟本地IDE几乎完全一样的功能;还有一种web IDE则更多的聚焦于 页面开发与实时的代码解析、编译、预览
的呈现,代码打包构建的实现则并不局限于在服务端(如基于Docker容器等)实现,部分的产品实现了 基于浏览器端的代码编译、打包、构建、运行
的功能,而这一切在我们原来的开发体系里是只有基于 本地IDE+Node本地构建、本地服务+浏览器访问预览
才有的能力。此类产品的代表是CodeSandbox,codepen,StackBlitz,JSFiddle等。
也就是说前者只是将代码的编辑放在了web段,其实背后借助了云去做代码的存储,项目的编译打包运行等,这种方式下,我们最后得到的和本地开发并无区别,对我们的区别只是不用特地下个编辑器了。
后者则是将一部分的编译打包功能以及最终的运行放在了浏览器,且由于浏览器的限制,其所能支持的应用大小是有限制的(PS:最近遇到一个问题,看错误应该是代码大小超过了500K,留个坑,留待以后解决,嘿嘿)
能将 类似基于本地webpack打包构建
的能力迁移到浏览器端看起来是一件非常不可思议的事情,上面也已经讲到,实现方式往往有两种方式,一种是基于服务端的webpack的打包构建,构建完后将构建出的代码再转交给浏览器端解析执行,相关实践如:基于webpack打造前端在线编译器,还有一种实现则是由服务端提供依赖包的代码(从npm安装拉取)返回给客户端,打包构建则完全是在浏览器端实现,实现了浏览器端的’webpack’,比如CodeSandbox就是这种模式的实现。今天我们就来一起看下CodeSandbox的作者这篇文章的介绍,这一切究竟是如何实现的。
这里注意,codesandbox的实现经历了多次迭代,但改变的只是服务端如何提供依赖,从服务端加载依赖以后返回给客户端使用这一点是没有变的。
内容梳理
因为下面很多内容是翻译自codesandbox作者原文,有些地方比较难理解,我先梳理一下整个过程
- 第一版,需要事先将依赖下载到本地,在运行时动态递归分析require的依赖,然后将require给stub本地实现下载的依赖,不仅无法支持所有依赖,而且递归分析性能有瓶颈
- 借助webpack DllPlugin的思想,首先将依赖关系发送给后台,根据依赖关系的hash,查找后台是否有缓存,如果没有,再分析依赖,通过yarn下载,然后打包成一个dll发回给调用者调用。这个版本一个问题是如果没有在依赖关系中明确定义,是无法被打包的,而且缓存是以依赖关系为键的,如果两个不同的依赖关系树中有相同的package,是不会重用的
- 为了解决上面一版的第一个问题,作者实现了一个版可以自己添加入口的webpack 打包器
- 为了解决第二个问题,作者结合serverless,并对依赖项进行了拆分,服务器缓存的是一个一个独立的依赖,服务器只是把下载好的依赖返回给前端,真正负责打包的是前端,这样前端就可以实现按需打包,这一点在后端是无法实现的,因为后端并没有实际的代码,所以没有这个“需”。
- 然后为了实现离线版本,作者又在前端做了一层缓存
- 至此,实现了我们目前使用的codesandbox
第一个版本
这个版本的codesandbox只是自己实现了一个算法,利用了类似require的加载方式,去一个个加载依赖到本地(这个本地我个人认为应该指的是用户个人的浏览器)。codesandbox的作者个人认为第一版还不能算是npm的完整支持.
也就是说这一版并不是实时根据代码中的依赖去npm仓库加载依赖,而是事先将依赖下载到本地,然后stub代码中的require,所以作者说这一版并不能支持所有的npm依赖。
而且这个版本应该是具体require a的时候才去分析a依赖了什么,然后一层层递归进去,这种递归如果项目依赖复杂,性能上也有很大的瓶颈。
This version of npm support was very simple. It wasn’t even really npm support, I just installed the dependencies locally and stubbed every dependency call with an already installed dependency. This, of course, is absolutely not scalable to 400,000 packages with different versions.
Even though this version was not very usable, it was encouraging to see that I was able to at least make two dependencies work in a sandbox environment.
webpack版本
第一个版本作者认为完整支持npm是不可能的,直到有人真正实现了。
所以作者就在考虑如何也实现一般出来,他一开始设计了一个算法,不过这个算法比较复杂,最终也没有实际使用,就不赘述了,感兴趣的可以去看文末的参考链接。
紧接着,作者参考了webpack的DLLPlugin插件的实现方式。
简单来说,DllPlugin做的事情就是把一个项目打包成一个dll依赖,将项目中的依赖关系包装在dll中,然后对外暴露接口。这是官方文档地址:DllPlugin文档
webpack的DLLPlugin可以打包依赖项,并且使用一个manifest清单来标记打出的js包包含哪些依赖项。这份清单看起来是这样的:
1 | { |
每一个路径都映射一个模块id。如果我想引入 React
,我只需要调用 dll_bundle(3)
,然后我就得到了React!这对需求来说简直就是完美,
于是作者开始行动,基于Webpack DllPlugin的思想,思考出了一个下面的系统:
对于打包的每一个请求,我将在 tmp/:hash
下面创建一个新的目录,接着运行 yarn add ${dependencyList}
,然后让 webpack
做打包处理即可。同时作为一种缓存方案,我会将打出的新包保存至gcloud。这看起来比上面的方案图要简单的多,更多的是因为我使用yarn来安装依赖模块并使用 webpack
做打包来作为前一个实现版本中的替代方案。
这里可能有些难以理解,我来说一下我的看法,在上一版本,也就是第一个版本,codesandbox是需要事先把依赖下载到本地,然后在真正运行到require的时候,才会去递归分析依赖,然后将require给stub到本地的已经下载好的依赖中
而这个版本,借助webpack DllPlugin的思想,codesandbox是先分析依赖关系,然后为这个依赖关系创建一个hash值,去缓存中查看是否有相同的hash,如果有的话,直接返回打包好的Dll,如果没有,再根据依赖关系去npm源下载所有的依赖,暂存在
tmp/:hash
下面,下载完后,然后将所有的依赖打包成一个Dll,缓存并返回给使用者,然后清除刚刚创建的tmp/hash
文件夹。
但是,这套系统仍然有一个非常大的限制,它不支持引入 不在webpack依赖关系图
中的文件。这就意味着像是下面的这个例子:
1 | require('react-icons/lib/fa/fa-beer') |
将不能正常运行,因为从依赖项的入口开始自始至终都不需要它,也就不会被打包进去。(webpack的打包是基于package.json里的依赖模块以及各个依赖模块的依赖项去做打包的,不被包含在这个体系里的文件则不会被打包进去)
带入口的webpack
为了解决刚才说的那个限制,也就是说不在webpack依赖关系中的文件无法打包进入最后的dll。
手动的增加了入口配置,以确保 webpack
也可以将这些文件能够打包进去。在对这个方案做了非常多的调整之后,这个系统已经可以支持任意(?译者注:作者这里加了问号,表示并不太确定支持任意)组合的打包需求。因此你也可以去加载 react-icons,css文件也是可以的。
接入Serverless
什么是serverless
基于serverless,你可以定义一个函数,该函数会在服务器被请求的时候触发执行:该函数会首先被启动,然后处理请求,并在一段时间后杀掉并释放自己。这也就意味着你会有非常高的可伸缩性:如果你的服务器同时有1000个请求过来,你可以立即启动1000个服务。这也意味着你仅仅需要按照实际运行时间付费即可。
如何结合serverless
对于我们的服务来说,serverless听起来简直就是完美:服务不是一直都在运行的,而且如果同时有多个请求,我们需要高并发性。于是我开始非常急切的使用一个叫做Serverless的框架。
得益于Serverless,我们的服务迁移非常顺畅,我在两天内就有了一个可以工作的版本。我创建了三个serverless函数:
- 1、一个源数据解析器:此服务用于解析版本和peerDependencies,并请求打包函数;
- 2、一个打包器:此服务用于实际的依赖项的安装及打包工作;
- 3、一个丑化器(压缩&混淆):负责异步丑化打包生成的包。
几天后我还是发现了一个限制:一个lambda函数最大只能拥有500M的磁盘空间,这就意味着一些组合的依赖项无法进行安装(译者注:后端在做打包构建的时候需要将所有的依赖项的代码加载到内存中来进行)。这真的是一个毁灭性的限制,我不得不将服务切回原来的实现。
几个月过去后,我发布了一个新的CodeSandbox的构建器(I released a new bundler for CodeSandbox)。这个构建器非常强大,可以很容易的让我们来支持更多的像是Preact或者Vue的框架。通过支持这些框架,我们的服务收到了一些非常有意思的请求。比如:如果你想在Preact中使用React,你需要将 require('react')
重命名为: require('preact-compat')
。对Vue来说,你可能会引入 @/components/App.vue
作为你沙箱里的文件。我们服务端的打包器(packager)不会处理这类的事情,但是我们浏览器端的构建器(bundler)会。
就在那时,我开始想我们也许可以让浏览器端构建器做实际的打包。如果服务端只是将相关文件发送到浏览器(而不做服务端打包构建的事情),然后我们用浏览器端构建器对依赖模块进行实际的打包,这样处理应该会更快,因为我们没有处理整个的大包,只是部分包。
服务端基于webpack DLLPLugin的打包构建会从依赖入口开始递归遍历所有依赖然后进行打包构建,而浏览器的打包构建只是 按需
打包构建。所以会更快的原因有二,一是浏览器端打包构建就不需要服务端再做打包构建了,服务端只是纯粹的依赖项的递归获取,然后发送给浏览器端,这样就节省了服务端打包构建的时间,也节省了服务器开销;二是浏览器端的打包构建是按需构建而非全量构建。
这个方案有一个非常大的优势:我们可以实现对依赖项单独的安装及缓存(还记得webpack版本那里所讲吗,从那个版本开始,我们缓存的不是一个个的依赖,而是一个个依赖关系组合中所有的依赖),然后我们在端上实现对依赖项的合并(merge)就好了。这就意味着,如果在现有所有依赖项的基础上再请求一个新的依赖项,则只需要为新的依赖项收集文件即可!这将很好地解决AWS Lambda500M内存限制的局限,因为我们在服务端只是会安装一个依赖模块而已。我们也可以在打包器中舍弃 webpack
,因为现在打包器只全权负责找出被依赖的相关的那些文件并把它们发送给浏览器端。
加入浏览器缓存
作者说不采取从unpkg.com上直接动态请求文件的方案,是因为想支持离线方案,即即使你没有网络你也可以实现浏览器端的编译打包构建预览,前提是你已经在浏览器端做了相关文件的本地缓存。基于作者实现的服务端单个依赖打包的方案是将整个依赖模块的所有文件全部缓存在了本地浏览器,而基于动态的从unpkg.com上请求文件是单个的请求某个依赖模块里的单个文件,很容易出现某个依赖文件不存在的情况。
也就是说,每次去请求单独的依赖时,去后台获取依赖之前会先看一下本地是不是有缓存。
最终版本
CodeSandbox 打包和运行并不依赖于服务器, 只是如果你所需的依赖在客户端没有缓存,需要去服务器请求
- Editor: 编辑器。主要用于修改文件,CodeSandbox这里集成了
VsCode
, 文件变动后会通知Sandbox
进行转译。 - Sandbox: 代码运行器。Sandbox 在一个单独的 iframe 中运行, 负责代码的转译(Transpiler)和运行(Evalation). 如最上面的图,左边是Editor,右边是Sandbox
- Packager 包管理器。类似于yarn和npm,负责拉取和缓存 npm 依赖
CodeSandbox 的作者 Ives van Hoorne 也尝试过将 Webpack
移植到浏览器上运行,因为现在几乎所有的 CLI 都是使用 Webpack 进行构建的,如果能将 Webpack 移植到浏览器上, 可以利用 Webpack 强大的生态系统和转译机制(loader/plugin),低成本兼容各种 CLI.
然而 Webpack 太重了😱,压缩过后的大小就得 3.5MB,这还算勉强可以接受吧;更大的问题是要在浏览器端模拟 Node 运行环境,这个成本太高了,得不偿失。
所以 CodeSandbox 决定自己造个打包器,这个打包器更轻量,并且针对 CodeSandbox 平台进行优化. 比如 CodeSandbox 只关心开发环境的代码构建, 目标就是能跑起来就行了, 跟 Webpack 相比裁剪掉了以下特性:
- 生产模式. CodeSandbox 只考虑 development 模式,不需要考虑 production一些特性,比如
- 代码压缩,优化
- Tree-shaking
- 性能优化
- 代码分割
- 文件输出. 不需要打包成chunk
- 服务器通信. Sandbox直接原地转译和运行, 而Webpack 需要和开发服务器建立一个长连接用于接收指令,例如 HMR.
- 静态文件处理(如图片). 这些图片需要上传到 CodeSandbox 的服务器
- 插件机制等等.
所以可以认为CodeSandbox是一个简化版的Webpack, 且针对浏览器环境进行了优化,比如使用worker来进行并行转译
项目构建过程
1 | packager -> transpilation -> evaluation |
Sandbox 构建分为三个阶段:
- Packager 包加载阶段,下载和处理所有npm模块依赖
- Transpilation 转译阶段,转译所有变动的代码, 构建模块依赖图
- Evaluation 执行阶段,使用
eval
运行模块代码进行预览
Packer
由于 CodeSandbox 已经包揽了代码构建的部分,所以我们并不需要devDependencies
, 也就是说 在CodeSandbox 中我们只需要安装所有实际代码运行需要的依赖,这可以减少成百上千的依赖下载. 所以暂且不用担心浏览器会扛不住.
而在Packer下载依赖之前其实先经过了Transpilation转移阶段去按需分析依赖,然后再拿分析产物去Packer
Transpilation
这个阶段从应用的入口文件开始, 对源代码进行转译, 解析AST,找出下级依赖模块,然后递归转译,最终形成一个’依赖图’:
CodeSandbox 的整个转译器是在一个单独的 iframe 中运行的:
Editor 负责变更源代码,源代码变更会通过 postmessage 传递给 Compiler,这里面会携带 Module+template
- Module 中包含所有源代码内容和模块路径,其中还包含 package.json, Compiler 会根据 package.json 来读取 npm 依赖;
- template 表示 Compiler 的 Preset,例如
create-react-app
、vue-cli
, 定义了一些 loader 规则,用来转译不同类型的文件, 另外preset也决定了应用的模板和入口文件。 通过上文我们知道, 这些 template 目前的预定义的.
大局上基本上可以划分为以下四个阶段:
- 配置阶段:配置阶段会创建 Preset 对象,确定入口文件等等. CodeSandbox 目前只支持限定的几种应用模板,例如 vue-cli、create-react-app。不同模板之间目录结构的约定是不一样的,例如入口文件和 html 模板文件。另外文件处理的规则也不一样,比如 vue-cli 需要处理
.vue
文件。 - 依赖下载阶段: 即 Packager 阶段,下载项目的所有依赖,生成 Manifest 对象
- 变动计算阶段:根据 Editor 传递过来的源代码,计算新增、更新、移除的模块。
- 转译阶段:真正开始转译了,首先重新转译上个阶段计算出来的需要更新的模块。接着从入口文件作为出发点,转译和构建新的依赖图。这里不会重复转译没有变化的模块以及其子模块
Evaluation
虽然称为打包器(bundler), 但是 CodeSandbox 并不会进行打包,也就是说他不会像 Webpack 一样,将所有的模块都打包合并成 chunks 文件.
Transpilation
从入口文件
开始转译, 再分析文件的模块导入规则,递归转译依赖的模块. 到Evaluation
阶段,CodeSandbox 已经构建出了一个完整的依赖图. 现在要把应用跑起来了
参考链接:
https://www.yuque.com/wangxiangzhong/aob8up/uf99c5?language=en-us