Day014 X Code Splitting & Dynamic Import

Code Splitting 是一个非常重要的观念,现代网页程序渐渐走向使用框架以模组化方式来开发,通常会透过如 webpack 等 bundler 来 uglify、minimize、打包程序码,如果没有另外为程序码做一些设置,那产生出来的档案通常会是一个 bundle.js 或其他名字的 JavaScript 档案(开发者可以自己设定),当专案成长到一定程度时,程序的 bundle size 会变得过於肥大,导致 client side 的网页载入时间变长,严重影响使用者体验。

首先来看看 Code Splitting 的介绍:

Code Splitting 就是为了要解决单一 JS Bundle 过於肥大的问题,将原本单一的 bundle 切分成数个小 chunk,可以搭配平行载入,或者是有需要时才载入某些特定的 chink,又或是对一些不常变动的 chunk 个别做快取,来达到载入效能的优化。

Wait a minute...「有需要时才载入」,这句话怎麽好像在哪里听过...?啊!想起来了,就是在 Day 11 介绍 Lazy Loading 概念的时候提过的嘛!

的确,今天要介绍的技术也是 Lazy Loading 的一种实现, code splitting 就是为了解决 compile 後的 bundle size 过於肥大的解决方案。以现代元件化开发来说,当使用者访问 A 页面时,理应要载入 A 页面会用到的 component bundle 给他,但他未来可能不会造访 B 页面与 C 页面,所以其实没有必要在他造访 A 页面时就同时也载入 B 页面与 C 页面的 bundle。

所以说我们希望透过 Lazy Loading 的技术,等到使用者真的要造访某个页面了,再载入该页面的对应资源,然而前面也提到,bundler 预设会把所有的程序码都打包成一份 JavaScript 档案,那该怎麽做到「使用到再载入」这件事呢?这时候就得靠 Code Splitting 把各个区块的程序码在打包的时候切成独立的 JavaScript 档案,就可以做到的程序码的 lazy loading 了!

今天会介绍两种常见的 Code Splitting 技巧:

  • 抽离第三方套件
  • 动态载入功能模组 Dynamic Import

并在最後附上一个自己觉得蛮有趣的 demo 来实际体验看看 code splitting 的神奇魔力。

(今天的范例与 demo 都会使用 webpack 这个 bundler,虽然不同 bundler 操作方式会有些许不同,但概念上都是一样的)

Webpack Bundle Analyzer

在开始之前先介绍一下 Webpack Bundle Analyzer 这个 plugin,有了它,我们可以透过视觉化分析专案有哪些 bundle chunk,各个 bundle chunk 的组成又为何,再针对可以改进的 bundle 进行优化,在等等的 demo 也会透过这个 plugins 来观察 bundle 的状况。

抽离第三方套件

抽离第三方套件又可以细分两种方式:

  • 将所有第三方套件打包为单一档案
  • 将第三方套件打包为多个档案

将所有第三方套件打包为单一档案

关於 webpack 的 bundle,可以先做一个最大的拆分:

  • Application Bundle:UI 与商业逻辑,跟我们写的程序有关,是经常变动的部分。
  • Vendor Bundle:第三⽅套件 / node_modules,不太会变动。

拆分出 Vendor Bundle 是有好处的,主要是因为通常它变动的频率相对较低,因此比较适合被 cache,而在 Vendor Bundle 被 cache 的状况下由於减少了 Application Bundle 的⼤⼩,因此加快了再访者的载入速度。采用这样的方式的优点为逻辑简单,缺点为更新任何第三方套件都会使快取失效。
(关於浏览器快取,会在日後的篇章介绍)

将第三方套件打包为多个档案

采用这种方式的优点是可以根据套件关联性打包,减少套件更新时造成的延迟。缺点则是相较前面打包成单一档案的方式,这种方式需要处理的逻辑复杂许多。

透过 webpack 的 CommonsChunkPlugin 实作类似下图的 config 可以达成这个效果

动态载入功能模组 Dynamic Import

大多数状态下我们会在档案的开头引入需要用到的模组,这些模组通常在网页载入时就被引入进来,这种方式被称为 static import,然而当有以下两种状况的需求时,static import 却不能满足我们:

  • 模组名称为动态变数时
  • 需依照特定逻辑或特定时机引入时

这时候可以运用与之相对的技术: Dynamic Import。所谓 Dynamic Import 代表的即是

需要用到某段程序码时才透过网路载入 JS bundle

要实现 Dynamic Import 需要靠 ESM import 语法:

例如上图我们在 getComponent 这个函式中 import lodash 这个 package,只有当 getComponent 被呼叫时 lodash 才会被当成另外一个 chunk 载入。

目前浏览器的支援度也还算不错,我们也可以透过 webpack 等打包工具来帮助我们实现 Dynamic Import。

了解了 Dynamic Import 的概念,接下来来谈谈 Dynamic Import 的使用情境,今天主要会介绍两种情境:

  • 根据路径做 Dynamic Import
  • 针对肥大套件做 Dynamic Import

根据路径做 Dynamic Import

根据 GA 等分析工具长期分析後的数据指出,大部分的使用者只会停留在网站中的几个热门页面,如果采用 Client-Side-Rendering 的方式建置网站的话,在没有对 bundle 做额外处理的状况下会在一开始载入 JS bundle 时就载入许多页面的资源,这样会导致许多不太会被使用者浏览的页面是很有机会被载入却又没被使用的。这时候我们可以选择针对路径做 Dynamic Import,当切换到特定路径时再载入该路径会用到的资源。因为笔者擅长 React,所以就以 React 中的着名路由套件 react-router 搭配 React.lazy 来举例:

造访 / 时,Home component 将会被载入,而造访 /about 页面时则是 About Component 会被载入,这也就是基於 Route 的 Code-Splitting。

(当然 Component 的 code splitting 也不一定只能做 route based 的,开发者可以自己视情况对 component 做 Dynamic Import,例如 React.Lazy 就是为此而存在,不过 React.Lazy 目前还无法在 SSR 使用,如果使用 SSR 建构专案的读者可以参考 Loadable Components

针对肥大套件做 Dynamic Import

除了针对路径做 code splitting 以外,另一种常见的方式就是针对「肥大却又不会马上用到」的模组做 Dynamic Import,这时前面介绍过的 Webpack Bundle Analyzer 就展现价值了,有了视觉化的报表,开发者可以依据图表判断是不是有过於肥大的套件适合做 Dynamic Import。

之前工作上遇到一个例子是为了支援 Live Streaming 的 feature,必须使用 hls.js 这个 module,不过从图片中可以看到它真的很肥很肥,刚好他是在一个特定页面才会需要使用,所以就做了 dynamic import,在访问该特定页面时才会去载入这个 module 的 bundle,大幅减少了一开始载入的 bundle size。

如果用 react 的话大概长这个样子:

// 某个需要影音串流功能的特定页面
useEffect(() => {
  async loadHls() {
    await import('hls.js');
  }

  loadHls();
}, [])

有趣的 Demo Time !

我自己觉得今天的 Demo 还蛮有趣的(可能只有我自己这样觉得QQ)
今天讲了那麽多理论,不实际操作一下有点没办法对读者交代呀!那我们就马上开始吧!
(恩对,我又选择了用 react + webpack 来做示范,但我想今天的观念应该挺好懂的,就算是习惯其他框架的读者也请安心服用)

身为一个从国小就开始看球,到现在也还是把看球赛当作主要兴趣的资深 NBA 球迷,连要准备 demo 的时候也想到用 NBA 球员来当作主题应该是合情合理的吧 ?

每一个球员都有自己擅长的篮球技巧

LBJ: TwoPointShotting Passing Dunk

Curry: TwoPointShotting ThreePointShotting

KD: TwoPointShotting Dunk

今天要做一个简单的网站,展示 Stephen Curry、Lebron James、Kevin Durant 三名篮球巨星分别擅长哪些篮球技能,成果如下:

可以看到 URL 的变化,每一个球员有自己的一个 path。咦!?这样是不是可以实作上面提到的「根据路径的 Dynamic Import」?没有错!让我们先简单看一下程序架构。

路由方面了使用 React Router

除了每一个球员独立成一个 component 以外,每一个 skill 也独立成了一个 component

球员的页面只会载入擅长的技巧,例如说 Lebron James 擅长投两分球、传球跟灌篮,那麽 Lbj.js 这个 component 就会长这个样子

目前还没有做任何优化,webpack 会把 source code 全部打包成一个档案,此时的 webpack-bundler-analyzer 看起来是这个样子

Step 1. 抽离第三方套件

刚刚有提过关於抽离第三方套件有「将所有第三方套件打包为单一档案」与「拆分为多个档案」两种方式,因为这个 demo 非常简易单纯,於是选择打包为单一档案就可以了。

既然帮忙打包的是 webpack,想当然需要去提整一些 webpack 的设定,打开 webpack.config.js 然後加入以下的 config:

optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
        },
      },
    },
  },

webpack 会将 node_modules 路径下的档案额外打包成名叫 vendors.js 的 bundle。

回到 webpack-bundle-analyzer 就会看到总共产生两个 bundle,vendor.js 是第三方套件的 module(vendor bundle),main.js 则是关於我们撰写的程序码的 bundle (application bundle)。

从上图左侧可以看到 vendor bundle 相比 application bundle 肥蛮多的,相较於 application bundle,这些第三方套件的 bundle 是比较不会经常变动的,独立出单独的 bundle 後可以被浏览器 cache,提升再次访问页面的速度。

Step 2. 根据路径做 dynamic import

既然现在有三个页面:

  • /lbj
  • /curry
  • /kd

那麽就可以针对这三个页面来做 dynamic import
(其实现实上不用针对所有页面做 dynamic import,如果有些页面是大部分使用者高机率会造访的,就不必做 dynamic import。今天是为了方便 demo,实际在开发时要考虑到专案本身的状况喔!)

在 Route 外额外包一层 React.Suspense component,让 component 在动态载入时有 fallback 的画面,不至於让使用者看到一片空白。

回到 webpack bundle analyzer 看看现在 bundle 的状况

可以注意到 application bundle 的 size 变小了,然後多出了 LBJ.js、Curry.js、KD.js 三个针对页面的 bundle。

回到页面看看是不是真的造访特定页面时才会载入该页面拆出来的 bundle。

成功啦!

Step.3 拆分出共用的 chunk

我觉得,我们的 demo 还可以再变得更好!

身为联盟的巨星,又不是常驻禁区的中锋,LBJ、Curry、KD 肯定都要会投中距离两分球的,所以从上面的 analyzer 看到 LBJ、Curry、Kd 三个 bundle 都有 TwoPointsShotting 这个 component,明明三个页面都会用到,却在每次载入特定页面 bundle 时都会重新载入,有点浪费啊!

我们可以将在许多 bundle 中共用的 chunk 再独立出一个 bundle,回到 webpack 的设定

optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
        },
        // 加上这些
        default: {
          name: 'default',
          minChunks: 2,
          reuseExistingChunk: true,
          enforce: true,
          priority: -20,
        },
      },
    },
  },

在 config 中我们指定只要是至少在 2 个 bundle 中会用到的 chunk 就把它们另外打包成叫做 default.js 的 bundle。

再看看 bundle analyzer

看起来好多了,不过聪明的你应该会注意到一个问题:

因为 LBJ 跟 KD 都会 Dunk,所以 Dunk 被包进了 default common bundle 里,但是 Curry 并不会 Dunk 啊,如果使用者只造访了 /curry 页面,会需要载入根本用不到的 Dunk component。

那怎麽解决呢?我们可以把在 LBJ 与 KD component 用到的 Dunk component 改成 dynamic import 的形式(在 dynamic import 的 component 再 dynamic import 其他 component)

Dunk 就被独立成一个 chunk 了

可以看到在造访 /curry 页面时就不会载入 Dunk.js 的 chunk。

最後附上今天的 Demo 的 Source Code:https://github.com/kylemocode/it-ironman-2021/tree/master/code-splitting-demo

虽然今天这个简单的 demo 可能看不出来 code splitting 对效能有什麽影响,因为每个 component 都只由文字组成,档案都很小。不过当开发一个复杂且庞大的专案时,code splitting 可以带来不少效能的增长喔!

希望这个 demo 对你来说也是有趣又好懂(NBA 球迷请不要跟我反应说哪个球员明明还会什麽技能,只是个 demo 别太认真 ?)

本日小结

Code Splitting 与 Dynamic Import 是开发一个大型专案不可或缺的技术,现实中应该很难看到一个庞大的专案还是暴力的 bundle 成单一档案的,就算真的有,我想它的页面载入速度应该是慢的很可怕的...。

明天终於要进行到铁人赛的一半啦!说实在我真的快要累死了QQ 如果我的系列文对你有帮助,可以留言给我一点回馈,也许我会更有写下去的动力 ?

按照惯例预告一下,明天将介绍 Tree Shaking 这个优化技巧,See You Tomorrow !

References

https://github.com/rwieruch/minimal-react-webpack-babel-setup
https://webpack.js.org/guides/code-splitting/
https://medium.com/frochu/%E6%B7%BA%E8%AB%87%E5%A4%A7%E5%9E%8B-react-%E5%B0%88%E6%A1%88%E7%9A%84-code-splitting-8a258a13ac67


<<:  Day 17 Swap

>>:  DVWA练习-Cross Site Request Forgery

axios加入headers还是发生CORS状况

各位好~ 小妹我是一名刚入门工程师几个月的初学者,也是第一次发文, 爬了很多文但实在是无法解决,只好...

Day22 ( 游戏设计 ) 小玛莉游戏机

小玛莉游戏机 教学原文参考:小玛莉游戏机 这篇文章会介绍如何使用「函式」、「逻辑判断」、「当按钮按下...

27.Copmuted vs Watcher

一般情况下,使用 computed 比起 watcher 更简洁,如下: new Vue({ dat...

D04 - 从零开始的 Firmata 通讯

电子助教:「(大包小包)窝准备好了 (≖‿ゝ≖)✧」 鳕鱼:「东西放下,我们没有要转生。」 电子助教...

React和DOM的那些事-节点更新

点击进入React源码调试仓库。 React的更新最终要落实到页面上,所以本文主要讲解DOM节点(H...