阅读 2373

js打包时间缩短90%,bundleless生产环境实践总结


    最近尝试将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的支持情况如下:

Lark20210825-203949

    除了IE以外,大部分浏览器对http2的支持性都很好,因为我的项目不需要兼容IE,同时也不需要兼容低版本浏览器,不需要考虑不支持http2的场景。(这也是我们能将不bundle的代码用于线上生产环境的前提之一)

1.2 浏览器esm

    对于es modules,我们并不陌生,什么是es modules也不是本文的重点,一些流行的打包构建工具比如babel、webpack等早就支持es modules。

    我们来看一个最简单的es modules的写法:

//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

<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的支持情况:

Lark20201119-151747

    从上图可以看出来,主流的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相应的模版:

npx create-snowpack-app myproject --template @snowpack/app-template-react-typescript
复制代码

snowpack构建工具内置了tsc,可以处理tsx等后缀的文件。上述就完成了项目初始化。

2.2 前端路由处理

    前端路由我们直接使用react-router或者vue-router等,需要注意的时,如果是在开发环境,那么必须要指定在snowpack.config.mjs配置文件,在刷新时让匹配到前端路由:

snowpack.config.mjs
...
  routes: [{ match: 'routes', src: '.*', dest: '/index.html' }],
...
复制代码

类似的配置跟webpack devserver等一样,使其在后端路由404的时候,获取前端静态文件,从而执行前端路由匹配。

2.3 css、jpg等模块的处理

    在snowpack中同样也自带了对css和image等文件的处理。

  • css

以sass为例,

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中的。

   //index.module.css文件
    
   .container{
       padding: 20px;
   }
复制代码

snowpack构建处理后的css.proxy.js文件为:


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.

//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地址。

比如:

//config.map.json
{
  "react": "https://cdn.skypack.dev/react@17.0.2",
  "react-dom": "https://cdn.skypack.dev/react-dom@17.0.2",
}
复制代码

通过这个map文件,不管是在开发还是线上,只要把:

import React from 'react'

复制代码

替换成

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:

// 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为例:

npx snowpack ramda
复制代码

在snowpack.deps.json中会生成:

{
  "dependencies": {
    "ramda": "^0.27.1",
  },
  "lock": {
    "ramda#^0.27.1": "ramda@v0.27.1-3ePaNsppsnXYRcaNcaWn",
  }
}

复制代码

安装过程的命令行如下所示:

飞书20210831-211844

从上图可以看出来,通过npx snowpack安装的依赖是从skypack cdn直接请求的。

特别的,如果项目需要支持typescript,那么我们需要将相应的npm包的声明文件types下载到本地,skypack同样也支持声明文件的下载,只需要在snowpack的配置文件中增加:

// snowpack.config.mjs
export default {
  packageOptions: {
    source: 'remote',
    types:true //增加type=true
  },
};
复制代码

snowpack会把types文件下载到本地的.snowpack目录下,因此在tsc编译的时候需要指定types的查找路径,在tsconfig.json中增加:

//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后的线上代码举例如下:

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之后的代码,通过插件将:

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秒。

飞书20210901-165311

    同一个项目,用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环境构建工具
snowpackrollup(或者使用Streaming imports)
viteesbuild

build生产环境:

产品build构建工具
snowpack1.unbundle(esbuild) 2.rollup 3.webpack...
viterollup(且不支持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托管公共库。

文章分类
前端
文章标签