前言
为什么要优化网页的载入速度?大家开发产品时,总是把使用者体验挂在嘴边,但却常常忽略一个事实:载入速度和使用者体验息息相关。根据Amazon内部统计,网页载入的速度每增加100ms,营收就减少1%。网站速度越慢,客户越不愿意掏出钱来买你的东西。另外网页载入速度也会影响SEO。
因此身为一个专业的前端工程师,不仅得开发出方便好用的使用者介面,也得留心网站载入速度。
这篇文章会用真实案例跟大家分享优化网页载入速度的技巧,主角是我参与开发的手游直播社群Omlet Arcade,框架是React.js,打包工具是webpack,但本篇的概念适用于各种框架或打包工具。
先讲结果:经过优化,JavaScript bundle大小减少43%,载入度减少30%。
想知道怎么做到的朋友?那就跟我一起看下去吧!
优化载入速度的第一步:善用网页测速工具
使用者抱怨网页载入速度很慢,怎么办?如果不知道问题出在哪,尝试任何效能优化的解决方案都可能是徒劳无功的。
因此,第一步我们要来善用诊断工具,找出问题。
网路上有很多网页速度的诊断工具,我建议可以先试试最多人也最知名的Google的PageSpeed Insight。
诊断工具:Google PageSpeed Insight
PageSpeed Insight是一套测速用的工具,Google会用bot造访你的网站,模拟使用者感受到的载入速度,并分析你的网站效能有哪些可以改进的部分。
举个例子,我们把自家网站喂进去,可以看到PageSpeed Insight洋洋洒洒列出建议的清单。
我们可以看到,最佳化建议的第一个项目建议我们移除未使用的JavaScript,可以省去3.6 秒的载入时间。按照它的建议,就可以找出初步着手的方向了。
至于评分的部分,我个人觉得参考就好,特别是手机版的评分满严格的,毕竟手机的网路速度限制比起桌机要严苛许多。
如何决定网页速度优化的优先顺序?
看完PageSpeed Insight 给了一大堆建议,一时间你可能会不知道该从哪里下手才好!
我建议从两个方向下手:「修正以后改善最多的项目」,或是「你业务范围中能够改动的项目」。
修正以后改善最多的项目才看得到明显的成效,这是我们要做效能优化时的首选。
但有时候事情无法尽如人意,例如你要改的部分需要AWS 权限,或是其他工程师的业务范围但他们没办法配合。这时候可以退而求其次,从你业务范围内能够修改的地方着手。如果做出一点成绩,之后要做更复杂的修改时主管也会更有信心让你做。
举这次优化公司产品为例,PageSpeed 建议的方向有两个:
1.图片尺寸没有优化
2.JavaScript bundle 过大。
如何规划优先顺序呢?以下是我的思路:
图片尺寸牵涉到每种客户端(Android/iOS/web) 在上传的时候、以及在server 端都需要额外的处理,很难一开始就说服所有人去配合改动。另一方面,JavaScript bundle 过大的问题,前端工程师比较有自主权去改动,不需要其他人的配合。
至此排出我的优先顺序:首先会帮JavaScript bundle 减肥;有多余时间及其他工程师的支援才会考虑优化图片的尺寸。
我的JavaScript bundle 很肥,会怎样吗?
现代前端专案大部分遵循模组化架构,并使用打包工具将JavaScript 程式码压缩成单一档案(bundle)。然而随着专案成长,业务逻辑以及第三方套件不可避免地会跟着增加,不知不觉中导致肥大的JavaScript bundle,拖累网页效能。
肥大的JavaScript bundle 是网页载入速度缓慢的元凶之一。
「JS Bundle 越大,载入越慢」 为什么JavaScript bundle 的大小会影响载入速度呢?
让我解释浏览器的运作原理:浏览器会解析HTML 以建出DOM tree,完整的DOM tree 产生后才能画出第一个画面。第一个画面越快被画出来,使用者感觉网页载入速度越快、效能越好。
然而,浏览器在建造DOM tree 的过程中,遇到JavaScript 时必须要把它下载完并停下来执行。如果JavaScript bundle size 非常大,就得花很多时间下载,拖慢第一个画面被画出来的时间。
Code Splitting:JS bundle 肥大的救星
Webpack提供了强大的code splitting功能,可以将单一档案拆分成许多小块(chunk)。
这些小块可以平行地被载入,或是有需要时才动态载入,也可以各自被快取,因此可以加快浏览器下载的速度。
接下来我将介绍code splitting 的第一个技巧:拆分出vendor bundle。
拆分vendor bundle
这个章节我会介绍vendor bundle 是什么,有什么好处,以及我如何拆分出专案的vendor bundle。
「什么是vendor bundle?」 用Webpack 打包的专案,为了效能考量,会将打包出来的JavaScript bundle 分成三个部分:
Application bundle:也就是产品的UI 跟商业逻辑等。 Vendor bundle:你的产品依赖的第三方套件,例如React.js 或是各种npm 上的套件。 Webpack runtime and manifest:负责所有模组之间的互动,一般来说体积很小可以忽略不计。 运用code splitting 的技巧,将第三方套件额外拆分成额外的bundle,就是vendor bundle。
那拆出vendor bundle 有什么好处呢?
答案是「容易被快取」,因为第三方套件不太会频繁更动,如果使用者不是第一次造访我们网站,浏览器快取很有可能已经下载过vendor bundle,只要下载包含业务逻辑变化的application bundle就好。
Vendor bundle 规划实例
那我们的产品bundle 是如何规划的呢?
除了webpack 的经典配置之外,我们额外多出一块第一方套件,一共规划成四块:
manifest.js arcade.js:商业逻辑 vendor.js:第三方套件 omlib.js:第一方(公司内部) 套件,主要定义和server API 沟通用的规格。 我们来检视开始优化前的大小吧!
manifest.js 很小可以忽略不计,所以优化前的bundle 大小如下:
arcade.js: 585KB, gzipped vendor.js: 366KB, gzipped omlib.js: 205KB, gzipped 共计1156KB。
手机版:
arcade.js: 426KB, gzipped vendor.js: 366KB, gzipped omlib.js: 205KB, gzipped 共计997KB。
接着我们要来检查这样的配置是否合理,还有没有改进的空间。
运用webpack-bundle-analyzer 作分析
接下来会用到,它是webpack的plugin,可以将bundle内容按照档案大小排列,做视觉化呈现,方便我们分析。webpack-bundle-analyzer
这是优化前的bundle 内容:
可以注意到application bundle 包含很大一块node_modules,也就是第三方套件。
观察前人的作法,可以发现他们将几个比较大的第三方套件拆分成vendor bundle。
这样的做法并不能说是错的,但我认为将整个node_modules 统一包成vendor bundle 会更有效益。如同前面提到的,vendor bundle 并不常变动,所以将所有第三方套件都完整包进vendor bundle 会让快取更有效率、效能更好。
所以修改webpack.config.js 如下:
// webpack.config.js
optimization : {
splitChunks : {
cacheGroups : {
vendor : {
test : /[\\/]node_modules[\\/]/,
name : 'vendor' ,
},
omlib : {
test : /[\\/]libs[\\/]/,
name : 'omlib' ,
chunks : 'all' ,
},
},
},
},
修改后bundle 大小如下:
arcade.js: 310KB, gzipped vendor.js: 632KB, gzipped omlib.js: 205KB, gzipped 整体:1156KB,不变
「application bundle: 585KB -> 310KB。」 手机版:
arcade.js: 218KB, gzipped vendor.js: 632KB, gzipped omlib.js: 205KB, gzipped 整体:997KB -> 1055KB (因为包含了桌机的library,所以反而变得肥大了,后面会修正这个问题)
「application bundle: 426KB -> 218KB。」
可以看到修改完之后application bundle 变小很多!
总结这个改动的效果:减小application bundle的大小,让有快取的情况下使用者进站的速度变快。
看到这里,你可能会想问:需要下载的整体大小没变,那第一次进站的使用者还是一样慢呀?
我接下来要提到的dynamic import 技巧,可以改善这个问题。
「根据路径作Dynamic imports」 Webpack的code splitting支援dynamic imports,可以动态地下载需要的module。
这段会介绍如何根据路径作dynamic import,以达成进入页面时才下载该页程式码的效果。
什么是Dynamic Import?如何使用?
Dynamic import 就是不把程式码打包进一开始的bundle,只有在真正用到这段程式码时,才透过网路下载。
要使用dynamic import很简单,只需要在程式码中使用import()的语法。
举个例子,我们用import()语法载入第三方套件lodash:
function getComponent () {
return import ( /* webpackChunkName: "lodash" */ 'lodash' )
.then( ( { default : _ } ) => {
const element = document .createElement( 'div' );
element.innerHTML = _.join([ 'Hello' , 'webpack' ], ' ' );
return element;
})
.catch( error => 'An error occurred while loading the component' );
}
getComponent()
.then( component => {
document .body.appendChild(component);
})
Webpack看到import()语法便会将lodash独立打包,等到呼叫getComponent()的时候才载入。
...
Asset Size Chunks Chunk Names
index .bundle.js 7.88 KiB index [emitted] index
vendors~lodash.bundle.js 547 KiB vendors~lodash [emitted] vendors~lodash
Entrypoint index = index .bundle.js
...
根据路径作Dynamic Imports - 实战篇
接下来我会介绍如何根据路径作dynamic import,以达成进入一个页面时才下载该页程式码的效果。
为什么要尝试对所有的路径作dynamic import 呢?
理由是根据GA 数据,使用者大部分会停留在热门的几个页面,换页次数较少,因此只在换页的时候下载需要的程式码会有更好的效能。
实作方法也很简单,只需修改routing 如下:
<Route path= "/" component={AppRoot}>
<IndexRoute
getComponent={() => {
return import ( 'containers/HomeContainer' ).then(({ default : HomeContainer }) => HomeContainer);
}}
/>
<Route path= "/games"
getComponent={() => {
return import ( 'containers/GamesContainer' ).then(({ default : GamesContainer }) => GamesContainer);
}}
/>
{ /* Other routes */ }
</Route>
经过code splitting 之后,单页bundle 均在20KB 以下。
arcade.js: 215KB, gzipped vendor.js: 630KB, gzipped omlib.js: 205KB, gzipped 「Application bundle: 310 -> 215 + 20 = 235KB (dynamic import)」 手机版:
arcade.js: 169KB, gzipped vendor.js: 630KB, gzipped omlib.js: 205KB, gzipped 「Application bundle: 218 -> 169 + 20 = 189KB (dynamic import)」
「根据路径作dynamic import 可以大约减少25% (桌机) / 14% (手机) 的app bundle 下载量,以及6% (桌机) / 2% (手机) 的整体下载量。」
总结这个改动的效果,对于有快取的旧使用者能减少一定下载量,但对于新使用者效果则不显著,原因是页面之间共用程度(包含商业逻辑及component 等)比预想中多,因此没办法很干净地根据页面拆出bundle。
看到这里你一定想:好烂喔!骗我!等等先不要急着离开,接下来会示范另一种dynamic import 的技巧,效果会比这个改动更好。
对肥大第三方套件作dynamic imports
仔细观察vendor bundle 内容,你可能会注意到某些第三方套件占据很大的体积,因此接下来我们要处理这些肥大的第三方套件,让它们只在需要的时候载入。

举个例子,某个不常被使用的元件里使用了第三方套件jszip,我们希望这个元件被用到时才载入jszip,程式码范例如下:
class DropZone extends Component {
componentDidMount() {
this .importJSZip = import ( 'jszip /dist/jszip.min.js').then(({ default : JSZip }) => JSZip );
}
onDrop() {
this .importJSZip.then( JSZip => {
const newZip = new JSZip ();
//开始压缩
});
}
}
接着我们手动将大型第三方套件从vendor bundle 中排除:
// webpack.config.js
splitChunks : {
cacheGroups : {
vendor : {
test : /[\\/]node_modules[\\/](?!jszip)/, // Exclude modules that need to be dynamically loaded
name : ' vendor' ,
},
},
}.
以下是我们动态载入的第三方套件:
hls .js
moment .js
JSZip
另外,以下是我们发现可以被移除掉的第三方套件:
request: 70KB (过去专案同时支援node和浏览器两种环境,所以使用request;目前只有纯浏览器端使用,所以替换成原生的fetchAPI。)
瘦身完之后:
arcade.js: 215KB, gzipped vendor.js: 267KB, gzipped omlib.js: 205KB, gzipped 手机版:
arcade.js: 169KB, gzipped vendor.js: 267KB, gzipped omlib.js: 205KB, gzipped 「vendor bundle: 630KB -> 267KB」 总结一下这个改动的效果,以使用频率最高的首页为例:原本1156KB变成205 (omlib) + 267 (vendor) + 215 (app) + 20 (home) + 77 (hls.js) = 784KB,「减少32 %下载量」。手机版也从一开始的997KB减少成205 + 267 + 215 + 20 = 707KB,「减少29%下载量」,对于提升网页效能很有帮助!
看到这里,大家应该迫不及待想检视自己专案的第三方套件有没有优化的空间吧!
接下来我要介绍的技巧也非常有效,让我们继续看下去!
使用Tree Shaking 移除没用到的程式码
Tree Shaking指的是把没用到的code从JavaScript bundle中移除。这个功能可以实现是因为ES2015模组语法import跟export的静态结构。
Webpack 使用tree shaking 的方法是:
1.将CommonJS的require跟module.exports语法改写成import跟export 2.在package.json 中标示出具有side effect 的模组(通常指的是CSS 档案):
// Package.json
"sideEffects" : [
"*.css" ,
"*.scss"
],
所谓的side effect指的是当一个模组被import的时候,会有一些额外的操作对环境造成影响,不应该在tree shaking的过程中被移除,例如:import 'xxx.css';的语法会用javascript注入样式。
这个技巧说来轻松,但是实际执行起来可能会非常辛苦,因为我们的专案历史悠久,其中大量使用commonjs 语法,修改这些上古文物时得确保既有行为不变,着实吃了不少苦头(叹) 。
使用tree shaking 的技巧之后,我们的内部library 体积大幅缩小:
「omlib.js: 205KB -> 86.65KB」 总结一下这个改动的效果,虽然改写的过程非常痛苦,但是最后的成果相当不错,减少了约10%的大小,如果你的专案有很多commonjs的语法,可以试试看tree shaking!
接下来我会介绍一些对于改善效能不无小补的技巧!
「第三方library 使用CDN」 大家很常使用到的library 像是jQuery、React 等,可用CDN 提供的版本。
使用CDN 的好处是,因为可能在浏览别的网站的时候就先快取了一份,所以有机会第一次进站的时候就省下流量,提升效能。
像是大家都会用到的react-dom可以节省36KB。
「使用preset-env 减少polyfill 体积」 Babel loader搭配preset-2015可以把ES2015的语法转换成浏览器支援的语法,而preset-env是preset-2015的加强版,用来取代preset-2015。
使用preset-env的好处是它的打包过程很智慧,会根据你需要支援的浏览器,只包含必要的plugin以及polyfill;换句话说,如果你不需要支援一些使用率很低的旧浏览器,那打包出来的bundle理论上会小很多。
详细的设定可以参考这篇:[教学] @babel/preset-env设定
我们网站原本依靠polyfill.js 大约31KB,改用preset-env 之后polyfill 大小只需要18KB。
结论
87 + 267 + 36 (React) + 202 + 77 (hls.js) + 20 = 689KB (-41%) 注意这是worst case,其中每一项都是可以因为快取而省下的。手机版: * arcade.js: 151KB, gzipped * vendor.js: 267KB, gzipped * omlib.js: 87KB, gzipped 997KB -> 87 + 267 + 36 (React) + 151 + 20 = 561KB (-44%) - ->
">检视一下改动完成的效果,使用频率最高的首页,原本JavaScript bundle下载量从1156KB变成666KB**,累积减少43%下载量**。手机版首页也从一开始的997KB减少成589KB,累积减少41%下载量」,效果挺不错的! >另外根据GA数据统计,平均载入速度减少约30%」!效能的提升满显著的。 ">优化完成后,桌机版网页在PageSpeed 拿到80 的高分!对比原本不及格的分数,算是进步很多了;手机版则还有很多进步空间,因为手机的网路速度慢很多,尤其是在一些网路基础建设比较落后的国家。 >最后总结一下,这篇介绍了各种优化网页效能,特别是减少JavaScript bundle 大小的技巧,包含: ">取出vendor bundle 根据路径去做dynamic imports 对肥大第三方套件作dynamic imports 使用Tree Shaking 移除没用到的程式码 很多人在用的第三方套件使用CDN 版本 使用preset-env 减少polyfill 体积