最近尝试将bundleless的构建结果直接用到了线上生产环境,因为bundleless只会编译代码,不会打包,因此构建速度极快,同比bundle模式时间缩短了90%以上。得益于大部分浏览器都已经支持了http2和浏览器的es module,对于我们没有强兼容场景的中后台系统,将bundleless构建结果应用于线上是有可能的。本文主要介绍一下,本人在使用bundleless构建工具实践中遇到的问题。
- 起源
- 结合snowpack实践
- snowpack的Streaming Imports
- 性能比较
- 总结
- 附录snowpack和vite的对比
本文原文来自我的博客: github.com/fortheallli…
一、起源
1.1 从http2谈起
以前因为http1.x不支持多路服用, HTTP 1.x 中,如果想并发多个请求,必须使用多个 TCP 链接,且浏览器为了控制资源,还会对单个域名有 6-8个的TCP链接请求限制.因此我们需要做的就是将同域的一些静态资源比如js等,做一个资源合并,将多次请求不同的js文件,合并成单次请求一个合并后的大js文件。这就是webpack等bundle工具的由来。
而从http2开始,实现了TCP链接的多路复用,因此同域名下不再有请求并发数的限制,我们可以同时请求同域名的多个资源,这个并发数可以很大,比如并发10,50,100个请求同时去请求同一个服务下的多个资源。
因为http2实现了多路复用,因此一定程度上,将多个静态文件打包到一起,从而减少请求次数,就不是必须的
主流浏览器对http2的支持情况如下:
除了IE以外,大部分浏览器对http2的支持性都很好,因为我的项目不需要兼容IE,同时也不需要兼容低版本浏览器,不需要考虑不支持http2的场景。(这也是我们能将不bundle的代码用于线上生产环境的前提之一)
1.2 浏览器esm
对于es modules,我们并不陌生,什么是es modules也不是本文的重点,一些流行的打包构建工具比如babel、webpack等早就支持es modules。
我们来看一个最简单的es modules的写法:
代码语言:javascript复制//main.js
import a from 'a.js'
console.log(a)
//a.js
export let a = 1
复制代码
上述的es modules就是我们经常在项目中使用的es modules,这种es modules,在支持es6的浏览器中是可以直接使用的。
我们来举一个例子,直接在浏览器中使用es modules
代码语言:javascript复制<html lang="en">
<body>
<div id="container">my name is {name}</div>
<script type="module">
import Vue from 'https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.esm.browser.js'
new Vue({
el: '#container',
data:{
name: 'Bob'
}
})
</script>
</body>
</html>
复制代码
上述的代码中我们直接可以运行,我们根据script的type="module"可以判断浏览器支不支持es modules,如果不支持,该script里面的内容就不会运行。
首先我们来看主流浏览器对于ES modules的支持情况:
从上图可以看出来,主流的Edge, Chrome, Safari, and Firefox ( 60)等浏览器都已经开始支持es modules。
同样的因为我们的中后台项目不需要强兼容,因此不需要兼容不支持esm的浏览器(这也是我们能将不bundle的代码用于线上生产环境的前提之二)。
1.3 小结
浏览器对于http2和esm的支持,使得我们可以减少模块的合并,以及减少对于js模块化的处理。
- 如果浏览器支持http2,那么一定程度上,我们不需要合并静态资源
- 如果浏览器支持esm,那么我们就不需要通过构建工具去维护复杂的模块依赖和加载关系。
这两点正是webpack等打包工具在bundle的时候所做的事情。浏览器对于http2和esm的支持使得我们减少bundle代码的场景。
二、结合snowpack实践
我们比较了snowpack和vite,最后选择采用了snowpack(选型的原因以及snowpack和vite的对比看最后附录),本章节讲讲如何结合snowpack构建工具,构建出不打包形式的线上代码。
2.1 snowpack的基础用法
我们的中后台项目是react和typescript编写的,我们可以直接使用snowpack相应的模版:
代码语言:javascript复制npx create-snowpack-app myproject --template @snowpack/app-template-react-typescript
复制代码
snowpack构建工具内置了tsc,可以处理tsx等后缀的文件。上述就完成了项目初始化。
2.2 前端路由处理
前端路由我们直接使用react-router或者vue-router等,需要注意的时,如果是在开发环境,那么必须要指定在snowpack.config.mjs配置文件,在刷新时让匹配到前端路由:
代码语言:javascript复制snowpack.config.mjs
...
routes: [{ match: 'routes', src: '.*', dest: '/index.html' }],
...
复制代码
类似的配置跟webpack devserver等一样,使其在后端路由404的时候,获取前端静态文件,从而执行前端路由匹配。
2.3 css、jpg等模块的处理
在snowpack中同样也自带了对css和image等文件的处理。
- css
以sass为例,
代码语言:javascript复制snowpack.config.mjs
plugins: [
'@snowpack/plugin-sass',
{
/* see options below */
},
],
复制代码
只需要在配置中增加一个sass插件就能让snowpack支持sass文件,此外,snowpack也同样支持css module。.module.css或者.module.scss命名的文件就默认开启了css module。此外,css最后的结果都是通过编译成js模块,通过动态创建style标签,插入到body中的。
代码语言:javascript复制 //index.module.css文件
.container{
padding: 20px;
}
复制代码
snowpack构建处理后的css.proxy.js文件为:
代码语言:javascript复制export let code = "._container_24xje_1 {n padding: 20px;n}";
let json = {"container":"_container_24xje_1"};
export default json;
// [snowpack] add styles to the page (skip if no document exists)
if (typeof document !== 'undefined') {
const styleEl = document.createElement("style");
const codeEl = document.createTextNode(code);
styleEl.type = 'text/css';
styleEl.appendChild(codeEl);
document.head.appendChild(styleEl);
}
复制代码
上述的例子中我们可以看到。最后css的构建结果是一段js代码。在body中动态插入了style标签,就可以让原始的css样式在系统中生效。
- jpg,png,svg等
如果处理的是图片类型,那么snowpack同样会将图片编译成js.
代码语言:javascript复制//logo.svg.proxy.js
export default "../dist/assets/logo.svg";
复制代码
snowpack没有对图片做任何的处理,只是把图片的地址,包含到了一个js模块文件导出地址中。值得注意的是在浏览器es module中,import 动作类似一个get请求,import from可以是一个图片地址,浏览器es module自身可以处理图片等形式。因此在.js文件结尾的模块中,export 的可以是一个图片。
snowpack3.5.0以下的版本在使用css module的时候会丢失hash,需要升级到最新版本。
2.4 按需加载处理
snowpack默认是不打包的。只对每一个文件都做一些简单的模块处理(将非js模块转化成js模块)和语法处理,因此天然支持按需加载,snowpack支持React.lazy的写法,在react的项目中,只要正常使用React.Lazy就能实现按需加载。
2.5 文件hash处理
在最后构建完成后,在发布构建结果的时候,为了处理缓存,常见的就是跟静态文件增加hash,snowpack也提供了插件机制,插件会处理snowpack构建前的所有文件的内容,做为content转入到插件中,经过插件的处理转换后得到新的content.
可以通过[snowpack-files-hash][1]插件来实现给文件增加hash。
2.6 公用esm模块托管
snowpack对于项目构建的bundleless的代码可以直接跑在线上,在bundless的构建结果中,我们想进一步减少构建结果文件大小。以bundleless的方式构建的代码,默认在处理三方npm包依赖的时候,虽然不会打包,snowpack对项目中node_modules中的依赖重新编译成esm形式,然后放在一个新的静态目录下。因此最后构建的代码包含了两个部分:
项目本身的代码,将node_modules中的依赖处理成esm后的静态文件。
其中node_modules中的依赖处理成esm后的静态文件,可以以cdn或者其他服务形式来托管。这样我们每次都不需要在构建的时候处理node_modules中的依赖。在项目本身的代码中,如果引用了npm包,只需要将其指向一个cdn地址即可。这样处理后的,构建的代码就变成:
只有项目本身的代码(项目中对于三方插件的引入,直接使用三方插件的cdn地址)。
进一步想,如果我们使用了托管所有npm包(es module形式)的cdn地址之后,那么在本地开发或者线上构建的过程中,我们甚至不需要去维护本地的node_modules目录,以及yarn-lock或者package-lock文件。我们需要做的,仅仅是一个map文件进行版本管理。保存项目中的npm包名和该包相对应的cdn地址。
比如:
代码语言:javascript复制//config.map.json
{
"react": "https://cdn.skypack.dev/react@17.0.2",
"react-dom": "https://cdn.skypack.dev/react-dom@17.0.2",
}
复制代码
通过这个map文件,不管是在开发还是线上,只要把:
代码语言:javascript复制import React from 'react'
复制代码
替换成
代码语言:javascript复制import React from "https://cdn.skypack.dev/react@17.0.2"
复制代码
就能让代码在开发环境或者生产环境中跑起来。如此简化之后,我们不论在开发环境还是生产环境都不需要在本地维护node_modules相关的文件,进一步可以减少打包时间。同时包管理也更加清晰,仅仅是一个简单的json文件,一对固定意义的key/value,简单纯粹。
我们提到了一个托管了的npm包的有es module形式的cdn服务,上述以skypack为例,这对比托管了npm包cjs形式的cdn服务unpkg,两者的区别就是,unpkg所托管的npm包,大部分是cjs形式的,cjs形式的npm包,是不能直接用于浏览器的esm中的。skypack所做的事情就是将大部分npm包从cjs形式转化成esm的形式,然后存储和托管esm形式的结果。
三、snowpack的Streaming Imports
在2.7中我们提到了在dev开发环境使用了skypack,那么本地不需要node_modules,甚至不需要yarn-lock和package-lock等文件,只需要一个json文件,简单的、纯粹的,只有一对固定意义的key/value。在snowpack3.x就提供了这么一个功能,称之为Streaming Imports。
3.1 snowpack和skypack
在snowpack3.x在dev环境支持skypack:
代码语言:javascript复制// snowpack.config.mjs
export default {
packageOptions: {
source: 'remote',
},
};
复制代码
如此,在dev的webserver过程中,就是直接下载skypack中相应的esm形式的npm包,放在最后的结果中,而不需要在本地做一个cjs到esm的转换。这样做有几点好处:
- 速度快: 不需要npm install一个npm包,然后在对其进行build转化成esm,Streaming Imports可以直接从一个cdn地址直接下载esm形式的依赖
- 安全:业务代码中不需要处理公共npm包cjs到esm的转化,业务代码和三方依赖分离,三方依赖交给skypack处理
3.2 依赖控制
Streaming Imports自身也实现了一套简单的依赖管理,有点类似go mod。是通过一个叫snowpack.deps.json文件来实现的。跟我们在2.7中提到的一样,如果使用托管cdn,那么本地的pack-lock和yarn-lock,甚至node_modules是不需要存在的,只需要一个简单纯粹的json文件,而snowpack中就是通过snowpack.deps.json来实现包的依赖管理的。
我们安装一个npm包时,我们以安装ramda为例:
代码语言:javascript复制npx snowpack ramda
复制代码
在snowpack.deps.json中会生成:
代码语言:javascript复制{
"dependencies": {
"ramda": "^0.27.1",
},
"lock": {
"ramda#^0.27.1": "ramda@v0.27.1-3ePaNsppsnXYRcaNcaWn",
}
}
复制代码
安装过程的命令行如下所示:
从上图可以看出来,通过npx snowpack安装的依赖是从skypack cdn直接请求的。
特别的,如果项目需要支持typescript,那么我们需要将相应的npm包的声明文件types下载到本地,skypack同样也支持声明文件的下载,只需要在snowpack的配置文件中增加:
代码语言:javascript复制// snowpack.config.mjs
export default {
packageOptions: {
source: 'remote',
types:true //增加type=true
},
};
复制代码
snowpack会把types文件下载到本地的.snowpack目录下,因此在tsc编译的时候需要指定types的查找路径,在tsconfig.json中增加:
代码语言:javascript复制//tsconfig.json
"paths": {
"*":[".snowpack/types/*"]
},
复制代码
3.3 build环境
snowpack的Streaming Imports,在dev可以正常工作,dev的webserver中在请求npm包的时候会将请求代理到skypack,但是在build环境的时候,还是需要其他处理的,在我们的项目中,在build的时候可以用一个插件[snowpack-plugin-skypack-replacer][2],将build后的代码引入npm包的时候,指向skypack。
build后的线上代码举例如下:
代码语言:javascript复制import * as __SNOWPACK_ENV__ from '../_snowpack/env.271340c8a413.js';
import.meta.env = __SNOWPACK_ENV__;
import ReactDOM from "https://cdn.skypack.dev/react-dom@^17.0.2";
import App from "./App.e1841499eb35.js";
import React from "https://cdn.skypack.dev/react@^17.0.2";
import "./index.css.proxy.9c7da16f4b6e.js";
const start = async () => {
await ReactDOM.render(/* @__PURE__ */ React.createElement(App, null), document.getElementById("root"));
};
start();
if (undefined /* [snowpack] import.meta.hot */ ) {
undefined /* [snowpack] import.meta.hot */ .accept();
}
复制代码
从上述可以看出,build之后的代码,通过插件将:
代码语言:javascript复制import React from 'react'
//替换成了
import React from "https://cdn.skypack.dev/react@^17.0.2";
复制代码
四、性能比较
4.1 lighthouse对比
简单的使用lighthouse来对比bundleless和bundle两种不同构建方式网页的性能。
- bundleless的前端简单性能测试:
- bundle的前端性能测试:
对比发现,这里两个网站都是同一套代码,相同的部署环境,一套是构建的时候是bundleless,利用浏览器的esm,另一个是传统的bundle模式,发现性能上并没有明显的区别,至少bundleless简单的性能测试方面没有明显差距。
4.2构建时间对比
bundleless构建用于线上,主要是减少了构建的时间,我们传统的bundle的代码,一次编译打包等可能需要几分钟甚至十几分钟。在我的项目中,bundleless的构建只需要4秒。
同一个项目,用webpack构建bundle的情况下需要60秒左右。
4.3构建产物体积对比
bundleless构建出的产物,一般来说也只有bundle情况下的1/10.这里不一一举例。
五、总结
在没有强兼容性的场景,特别是中后台系统,bundleless的代码直接跑在线上,是一种可以尝试的方案,上线的时间会缩短90%,不过也有一些问题需要解决,首先需要保证托管esm资源的CDN服务的稳定性,且要保障被托管的esm资源在浏览器运行不会出现异常。我们运行了一些常见的npm包,发现并没有异常情况,不过后续需要更多的测试。
六、附录:snowpack和vite的对比
6.1 相同点
snowpack和vite都是bundleless的构建工具,都利用了浏览器的es module来减少对静态文件的打包,从而减少热更新的时间,从而提高开发体验。原理都是将本地安装的依赖重新编译成esm形式,然后放在本地服务的静态目录下。snowpack和vite有很多相似点
- 在dev环境都将本地的依赖进行二次处理,对于本地node_module目录下的npm包,通过其他构建工具转换成esm。然后将所有转换后的esm文件放在本地服务的静态目录下
- 都支持css、png等等静态文件,不需要安装其他插件。特别对于css,都默认支持css module
- 默认都支持jsx,tsx,ts等扩展名的文件
- 框架无关,都支持react、vue等主流前端框架,不过vite对于vue的支持性是最好的。
6.2 不同点
dev构建: snowpack和vite其实大同小异,在dev环境都可以将本地node_modules中npm包,通过esinstall等编译到本地server的静态目录。不同的是在dev环境
- snowpack是通过rollup来将node_modules的包,重新进行esm形式的编译
- vite则是通过esbuild来将node_modules的包,重新进行esm形式的编译
因此dev开发环境来看,vite的速度要相对快一些,因为一个npm包只会重新编译一次,因此dev环境速度影响不大,只是在初始化项目冷启动的时候时间有一些误差,此外snowpack支持Streaming Imports,可以在dev环境直接用托管在cdn上的esm形式的npm包,因此dev环境性能差别不大。
build构建:
在生产环境build的时候,vite是不支持unbundle的,在bundle模式下,vite选择采用的是rollup,通过rollup来打包出线上环境运行的静态文件。vite官方支持且仅支持rollup,这样一定程度上可以保持一致性,但是不容易解耦,从而结合非rollup构建工具来打包。而snowpack默认就是unbundle的,这种unbundle的默认形式,对构建工具就没有要求,对于线上环境,即可以使用rollup,也可以使用webpack,甚至可以选择不打包,直接使用unbundle。
可以用两个表格来总结如上的结论:
dev开发环境:
产品 | dev环境构建工具 |
---|---|
snowpack | rollup(或者使用Streaming imports) |
vite | esbuild |
build生产环境:
产品 | build构建工具 |
---|---|
snowpack | 1.unbundle(esbuild) 2.rollup 3.webpack... |
vite | rollup(且不支持unbundle) |
6.3 snowpack支持Streaming Imports
Streaming Imports是一个新特性,他允许用户,不管是生产环境还是开发环境,都不需要在本地使用npm/yarn来维护一个lock文件,从而下载应用中所使用的npm包到本地的node_module目录下。通过使用Streaming Imports,可以维护一个map文件,该map文件中的key是包名,value直接指向托管该npm包esm文件形式的cdn服务器的地址。
6.4 vite的一些优点
vite相对于snowpack有一下几个优点,不过个人以为都不算特别有用的一些优点。
- 多页面支持,除了根目录的root/index.html外,还支持其他根目录以外的页面,比如nest/index.html
- 对于css预处理器支持更好(这点个人没发现)
- 支持css代码的code-splitting
- 优化了异步请求多个chunk文件(不分场景可以同步进行,从而一定程度下减少请求总时间)
6.5 总结
如果想在生产环境使用unbundle,那么vite是不行的,vite对于线上环境的build,是必须要打包的。vite优化的只是开发环境。而snowpack默认就是unbundle的,因此可以作为前提在生产环境使用unbundle.此外,snowpack的Streaming Imports提供了一套完整的本地map的包管理,不需要将npm包安装到本地,很方便我们在线上和线下使用cdn托管公共库。