【第2109期】CodeSandbox是如何让npm上的模块直接在浏览器端运行的
前言
前几天无意中看到CSB,有点意思。今日早读文章由字节跳动@众乐翻译授权分享。
@众乐(Ryan),目前就职于今日头条前端业务架构团队,通过平台、基建、工具、文档规范等手段不断挖掘业务中台通用能力,赋能上层业务及团队研发。同时,也在前端智能化、中后台研发提效、Serverless落地业务等方向执着探索。
正文从这开始~~
下文中提到的类似 支持npm相关的词都是指 在端实现对npm模块的加载、编译、打包、渲染&预览。
在开发CodeSandbox的初期,我总是将npm依赖模块的支持列在我们的支持范围之外。我认为在浏览器中安装任意数量的npm包是不可能实现的,即使去想一下我都觉得绝不会有实现思路。
而今天,对npm的支持已经是CodeSandbox最核心的特性之一,所以不管怎么样,我们都已经实现了它。我们做了大量的迭代来让它在任意的场景下都能正常运行;同时,我们也做了非常多的重写,甚至是在该功能已经在线上顺畅运行的今天,我们依然可以对其实现逻辑做改进。在这篇文章中,我首先会介绍刚开始我们是怎么做的,现在我们做到了一个什么程度,以及我们还可以继续做哪些改进。
'第一个'版本
当时我真的不知道如何去达成这一切,所以我做了一个非常简单的版本来在浏览器端能支持npm:
这个版本对npm的支持非常简单。它甚至不是真正的npm支持,我只是局部安装了npm依赖,然后以已经安装的某个依赖项为入口找到它所有的依赖项,然后去一个个的执行(英文原文:I just installed the dependencies locally and stubbed every dependency call with an already installed dependency. 我理解其实现过程大致如下,比如package.on依赖了a模块,a本身依赖了b跟c,c依赖了d,在执行a的时候就需要先找到a的所有依赖项b,c,d,然后一层层的执行下去,有点像cmd规范的sea.js或者commonjs规范的require.js这些模块加载器的执行机制)。这种实现方式,对于依赖模块达到4000个并且有不同版本诉求的场景的话,很明显是非常不具备可扩展性的。
尽管这个版本不是很具备可用性,但是它却给了我勇气让我可以看到,在一个沙箱环境中至少是可以让两个依赖模块正常运行起来的。
webpack版本
我其实对第一个版本非常满意,并且我觉得它值得一个MVP(对于CodeSandbox的第一版发布)。我甚至认为,如果不施展魔法的话,在浏览器端去安装任意的一个模块是不可能的,直到偶然的机会我看到了https://esnextb.in。他们已经支持从npm上安装任意模块,你只需要在package.json中定义他们,就可以魔法般的正常运行了!
对于我来说,这是一个重要的学习时刻!我从来甚至都不敢去想能支持npm,因为始终觉得那不可能。只有在看到对于“可能”的活生生的证据之后,我才开始对它进行更多的思考。在我消除 支持npm这一想法之前,我确实应该首先去探索下它的可能性才对。
那么理所当然的,我开始思考如何去达成这一目的。而且刚开始我觉得把它给过渡复杂化了。第一个版本我觉得仅仅是在脑子里想的话我是完全hold不住的,因此我不得不把它画下来:
这种过于复杂的方案却有一个优点,那就是:最终的实现要比预想的简单的多!
我了解到,webpack的DLLPlugin可以打包依赖项,并且使用一个manifest清单来标记打出的js包包含哪些依赖项。这份清单看起来是这样的:
{
"name": "dll_bundle",
"content": {
"./node_modules/fbjs/lib/emptyFunction.js": 0,
"./node_modules/fbjs/lib/invariant.js": 1,
"./node_modules/fbjs/lib/warning.js": 2,
"./node_modules/react": 3,
"./node_modules/fbjs/lib/emptyObject.js": 4,
"./node_modules/object-assign/index.js": 5,
"./node_modules/prop-types/checkPropTypes.js": 6,
"./node_modules/prop-types/lib/ReactPropTypesSecret.js": 7,
"./node_modules/react/cjs/react.development.js": 8
}
}
每一个路径都映射一个模块id。如果我想引入 React,我只需要调用 dll_bundle(3),然后我就得到了React!这对我们的需求来说简直就是完美,于是我开始行动,并思考出了一个下面的系统:
注:服务的源代码可以在这里找到:https://github.com/CompuIves/codesandbox-bundler 这个服务还包含了将任意的CodeSandbox项目代码发布到npm上(这真的很酷)的代码,我们后来取消了这个功能。
对于打包的每一个请求,我将在 tmp/:hash下面创建一个新的目录,接着运行 yarn add ${dependencyList},然后让 webpack做打包处理即可。同时作为一种缓存方案,我会将打出的新包保存至gcloud。这看起来比上面的方案图要简单的多,更多的是因为我使用yarn来安装依赖模块并使用 webpack做打包来作为前一个实现版本中的替代方案。
译者注:这里可能比较难理解,稍作解释。在作者刚开始画的方案图中,依赖项的递归查找跟打包都是作者自己想的一个方案,然后作者在了解了webpack的DLLPlugin方案后,开始使用这套方案来作为替代方案,来做这块事情。
当你在开始运行一个用例的时候,在真正执行(代码)之前我们会首先确认我们必须有manifest以及js bundle(译者注:这两个物料都是webpack dllplugin打包出来的)。在真正执行的过程中,对于每一个依赖项,我们会通过 dll_bundle(:id)来获取对应的代码并执行。这一切都工作的很好,我实现了有临时加载npm模块依赖的第一个实现版本!
但是,这套系统仍然有一个非常大的限制,它不支持引入 不在webpack依赖关系图中的文件。这就意味着像是下面的这个例子:
require('react-icons/lib/fa/fa-beer')
将不能正常运行,因为从依赖项的入口开始自始至终都不需要它,也就不会被打包进去。(译者注:webpack的打包是基于package.json里的依赖模块以及各个依赖模块的依赖项去做打包的,不被包含在这个体系里的文件则不会被打包进去)
尽管如此,我还是发布了这个版本的CodeSandbox,并且跟WebpackBin的作者Christian Alfoni取得了联系。我们使用了非常类似的系统来支持npm依赖项的获取,并且我们有相同的局限性。所以我们决定联手打造终极打包方案!
带有入口的webpack(webpack with entries)
“终极”打包方案保留了跟我们原来的打包方案相同的功能,除了Christian创建了一个算法,会按照依赖项的重要性,将文件加入到最终打包出的bundle中这一个差别。这意味着我们手动的增加了入口配置,以确保 webpack也可以将这些文件能够打包进去。在对这个方案做了非常多的调整之后,这个系统已经可以支持任意(?译者注:作者这里加了问号,表示并不太确定支持任意)组合的打包需求。因此你也可以去加载 react-icons,css文件也是可以的。🎉
We did it! @christianalfoni and I combined forces and created a common NPM bundler for WebpackBin and CodeSandbox! 1/2 pic.twitter.com/6X3hxamLyN — Ives van Hoorne (@CompuIves) April 22, 2017
译者注:以上为当时作者发的twitter,大概意思是:我们做到了!christianalfoni跟我联手创建了一个通用的NPM打包器,这套打包器对于WebpackBin以及CodeSandbox都是适用的!我们好牛逼!之类的。
新的系统的架构也到了升级:我们有一个DLL的服务,用作负载均衡器和缓存。然后我们有很多个打包程序提供打包服务,这些打包程序可以动态添加。
我们还想让这个打包服务对于每个人都可用。这也是为什么我们搭建了一个网站,来解释我们的服务是如何运行的,以及你可以如何使用它。这是一个巨大的成功,甚至在CodePen blog博客中都被提到!
我们的“终极”打包方案还是存在一些局限性跟缺点。随着我们的服务变得越来越流行,服务器所花费的费用也越来越多,而且我们是通过依赖组合来做的缓存,这意味着,如果你添加了依赖项,我们必须对整个组合重新进行打包。
接入serverless
我一直都非常想尝试这个被称为serverless的非常酷的技术。基于serverless,你可以定义一个函数,该函数会在服务器被请求的时候触发执行:该函数会首先被启动,然后处理请求,并在一段时间后杀掉并释放自己。这也就意味着你会有非常高的可伸缩性:如果你的服务器同时有1000个请求过来,你可以立即启动1000个服务。这也意味着你仅仅需要按照实际运行时间付费即可。
对于我们的服务来说,serverless听起来简直就是完美:服务不是一直都在运行的,而且如果同时有多个请求,我们需要高并发性。于是我开始非常急切的使用一个叫做Serverless的框架。
得益于Serverless,我们的服务迁移非常顺畅,我在两天内就有了一个可以工作的版本。我创建了三个serverless函数:
一个源数据解析器:此服务用于解析版本和peerDependencies,并请求打包函数;
一个打包器:此服务用于实际的依赖项的安装及打包工作;
一个丑化器(压缩&混淆):负责异步丑化打包生成的包。
我把新的服务跟旧的服务一起运行(译者注:过渡阶段,用户可以选使用新的服务还是旧的服务),它真的运行的很好。我们的预计费用是每月0.18美元(对比老的服务方式是$100),而我们的响应时间则提高了40%到700%。
尽管如此,几天后我还是发现了一个限制:一个lambda函数最大只能拥有500M的磁盘空间,这就意味着一些组合的依赖项无法进行安装(译者注:后端在做打包构建的时候需要将所有的依赖项的代码加载到内存中来进行)。这真的是一个毁灭性的限制,我不得不将服务切回原来的实现。
重回serverless
几个月过去后,我发布了一个新的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的打包构建会从依赖入口开始递归遍历所有依赖然后进行打包构建,而浏览器的打包构建只是 按需打包构建。所以会更快的原因有二,一是浏览器端打包构建就不需要服务端再做打包构建了,服务端只是纯粹的依赖项的递归获取,然后发送给浏览器端,这样就节省了服务端打包构建的时间,也节省了服务器开销;二是浏览器端的打包构建是按需构建而非全量构建。但是限于浏览器端环境的限制,在执行性能以及空间限制上,打包构建也许会比较慢,所以只能说可能比原来的方案快。在实际的实现中,也可以发现作者对浏览器端实现打包构建做了极致的性能优化,包括:Service Worker,Web Worker等的方案都有使用。笔者也在想,对于这种高性能损耗的实现,WebAssembly的方案是否能帮助其彻底的解决性能瓶颈呢?如果你觉得可行,并对这套方案有兴趣,可以去研究起来,并且可以为社区贡献你自己的一份力量哦~
这个方案有一个非常大的优势:我们可以实现对依赖项单独的安装及缓存(译者注:就是对依赖项实现 一个一个的安装及缓存,而不是原来的 多个依赖项组合的方式),然后我们在端上实现对依赖项的合并(merge)就好了。这就意味着,如果在现有所有依赖项的基础上再请求一个新的依赖项,则只需要为新的依赖项收集文件即可!这将很好地解决AWS Lambda500M内存限制的局限,因为我们在服务端只是会安装一个依赖模块而已。我们也可以在打包器中舍弃 webpack,因为现在打包器只全权负责找出被依赖的相关的那些文件并把它们发送给浏览器端。
注意:我们其实也可以丢掉服务端的打包器,然后动态的从unpkg.com上请求每一个文件内容即可。这也许会比我的新方案更快。我决定还是保留打包器(至少是为了编辑器),因为我想提供离线支持。这个只有在你本地拥有所有的可能相关的文件的基础上才能实现。
译者注:作者说不采取从unpkg.com上直接动态请求文件的方案,是因为想支持离线方案,即即使你没有网络你也可以实现浏览器端的编译打包构建预览,前提是你已经在浏览器端做了相关文件的本地缓存。基于作者实现的服务端单个依赖打包的方案是将整个依赖模块的所有文件全部缓存在了本地浏览器,而基于动态的从unpkg.com上请求文件是单个的请求某个依赖模块里的单个文件,很容易出现某个依赖文件不存在的情况。
如何在实践中发挥作用
当我们请求依赖项的组合时,我们首先检查这个组合是否已经存储在了S3上。如果不在S3上,我们就从API服务上请求这个组合;这个服务请求所有的 为每一个依赖项单独请求打包内容 的打包器(即每一个依赖项都对应一个打包器)。只要我们得到200 OK的响应,我们会再次请求S3,将请求回的组合的代码保存过去。
打包器使用 yarn来安装依赖,从入口文件开始,通过遍历目录中所有文件的AST(抽象语法树)来找到所有的相关文件。通过解析AST搜寻所有的require语句来找到依赖项的依赖项,然后把它们加到文件列表中。该操作是基于递归实现的,因此最终我们能拿到一个依赖关系图(依赖树)。一个 react@latest的输出的例子是:
{
"aliases": {
"asap": "asap/browser-asap.js",
"asap/asap": "asap/browser-asap.js",
"asap/asap.js": "asap/browser-asap.js",
"asap/raw": "asap/browser-raw.js",
"asap/raw.js": "asap/browser-raw.js",
"asap/test/domain.js": "asap/test/browser-domain.js",
"core-js": "core-js/index.js",
"encoding": "encoding/lib/encoding.js",
"fbjs": "fbjs/index.js",
"iconv-lite": "iconv-lite/lib/index.js",
"iconv-lite/extend-node": false,
"iconv-lite/streams": false,
"is-stream": "is-stream/index.js",
"isomorphic-fetch": "isomorphic-fetch/fetch-npm-browserify.js",
"js-tokens": "js-tokens/index.js",
"loose-envify": "loose-envify/index.js",
"node-fetch": "node-fetch/index.js",
"object-assign": "object-assign/index.js",
"promise": "promise/index.js",
"prop-types": "prop-types/index.js",
"react": "react/index.js",
"setimmediate": "setimmediate/setImmediate.js",
"ua-parser-js": "ua-parser-js/src/ua-parser.js",
"whatwg-fetch": "whatwg-fetch/fetch.js"
},
"contents": {
"fbjs/lib/emptyFunction.js": {
"content": "/* code */",
"requires": []
},
"fbjs/lib/emptyObject.js": {
"content": "/* code */",
"requires": []
},
"fbjs/lib/invariant.js": {
"content": "/* code */",
"requires": []
},
"fbjs/lib/warning.js": {
"content": "/* code */",
"requires": ["./emptyFunction"]
},
"object-assign/index.js": {
"content": "/* code */",
"requires": []
},
"prop-types/checkPropTypes.js": {
"content": "/* code */",
"requires": [
"fbjs/lib/invariant",
"fbjs/lib/warning",
"./lib/ReactPropTypesSecret"
]
},
"prop-types/lib/ReactPropTypesSecret.js": {
"content": "/* code */",
"requires": []
},
"react/index.js": {
"content": "/* code */",
"requires": ["./cjs/react.development.js"]
},
"react/package.json": {
"content": "/* code */",
"requires": []
},
"react/cjs/react.development.js": {
"content": "/* code */",
"requires": [
"object-assign",
"fbjs/lib/warning",
"fbjs/lib/emptyObject",
"fbjs/lib/invariant",
"fbjs/lib/emptyFunction",
"prop-types/checkPropTypes"
]
}
},
"dependency": {
"name": "react",
"version": "16.0.0"
},
"dependencyDependencies": {
"asap": "2.0.6",
"core-js": "1.2.7",
"encoding": "0.1.12",
"fbjs": "0.8.16",
"iconv-lite": "0.4.19",
"is-stream": "1.1.0",
"isomorphic-fetch": "2.2.1",
"js-tokens": "3.0.2",
"loose-envify": "1.3.1",
"node-fetch": "1.7.3",
"object-assign": "4.1.1",
"promise": "7.3.1",
"prop-types": "15.6.0",
"setimmediate": "1.0.5",
"ua-parser-js": "0.7.14",
"whatwg-fetch": "2.0.3"
}
}
优势
节省开支
两天前我已经将新的打包方案部署上线,整整两天我们也就花了0.02美元!而这还是为了搭建缓存储存而花费的。相比于之前的100美金一个月的开销,这的确是一比巨大的节省。
高性能
现在,你可以在3秒内获取一个新的依赖模块的组合,什么组合都可以!而对于老的系统来说,这将花费一分钟的时间。如果这个组合(的代码)被缓存了,在有一个比较快的连接的基础上(网速好),50毫秒的时间就能获取到它!我们在世界范围内通过使用Amazon Cloudfront实现依赖缓存。我们的沙箱运行的也很快,因为我们现在只解析和执行你沙箱里用到的相关JS文件。
更大的灵活性
我们的构建器现在像处理本地文件一样处理依赖项。这也就意味着我们的错误堆栈追踪现在会变得更清晰,而且我们也可以处理任意类型的文件(比如:.scss, vue等等),同时我们也可以很容易地支持别名之类的事情。它的工作方式就好像依赖项是本地安装的一样(就像本地的webpack打包一样)。
发布
两天前,我开始使用新的打包器与旧的打包器同时在线运行,来构建缓存。到目前已经缓存了2,000余个不同的依赖组合,以及1400个不同的依赖。我想在实际迁移新的方案之前对新版本进行广泛全面的测试。你可以在偏好设置中来启动并尝试。
同时,如果你对实现源码感兴趣,你可以到这里查看。
加油,Serverless!
我对Serverless留下了深刻的印象,它使得服务器的可扩展性和管理变得异常简单。唯一阻碍我使用serverless的事情就是它非常复杂的设置问题,但是serverless.com的人员对此做了改进,让这一切变得异常简单。我非常感激他们的工作,我也坚信serverless是许多不同应用范式的未来。
未来规划
我们仍然可以在很多方面来改进这个系统,我非常渴望去探索动态的请求依赖并嵌入本地缓存,同时保持离线的方案。这确实很难保持平衡,但这应该是可以实现的。我们还可以在浏览器中独立地缓存依赖项,取决于浏览器允许缓存的内容。在这种情况下,你在访问一个有着不同依赖项组合的新的沙箱的时候,很多时候你是不需要往服务端请求加载依赖的。我还将进一步探讨依赖关系解决方案,这有可能会与新系统有版本上的冲突(与目前的新系统的方案不兼容),在我继续全力往下走之前要先解决掉这个问题。
不管怎么样,我对CodeSandbox新版本非常满意,也将继续为CodeSandbox开发新的东西!
如果你对CodeSandbox感兴趣,我们90%的代码都是开源的!更新最频繁的部分在这里。
为你推荐
欢迎自荐投稿,前端早读课等你来