前端效能優化—js程式碼打包

無名小貝勒發表於2018-09-09

現在的 web 應用,內容一般都很豐富,站點需要載入的資源也特別多,尤其要載入很多 js 檔案。js 檔案從服務端獲取,體積大小決定了傳輸的快慢;瀏覽器端拿到 js 檔案之後,還需要經過解壓縮、解析、編譯、執行操作,所以,控制 js 程式碼的體積以及按需載入對前端效能以及使用者體驗是十分的重要。

本文從 Tree Shaking程式碼分割 兩部分介紹 js 打包優化,有興趣的可以跟著一起實踐。 clone 以下專案 github.com/jasonintju/…,就是個簡單的 React SPA,一看就懂。

Tree Shaking

Tree Shaking 簡單理解就是:打包時把一些沒有用到的程式碼刪除掉,保證打包後的程式碼體積最小化。其詳細的介紹可以參考 Tree-Shaking效能優化實踐 - 原理篇

專案 clone、安裝依賴後,先 npm run build 打包初始程式碼,大小及分佈如下(其中 src/utils/utils.js 這個檔案打包後大小為11.72Kb):

前端效能優化—js程式碼打包

src/containers/About/test.js只引用但是沒有使用到,src/utils/utils.js 這個檔案是個工具函式集,有很多很多函式,而我們只用到了其中的一個。預設情況下,整個檔案都被打包進 main.js 了,顯然,這是很大的冗餘,正好可以使用 Tree Shaking 優化。

修改 .babelrc

{
  "presets": [["env", { "modules": false }], "react", "stage-0"]
}
複製程式碼

修改 package.json

{
  "name": "optimizing-js",
  "version": "1.0.0",
  "sideEffects": false
}
複製程式碼

這樣設定之後,表示所有的 module 都是無副作用的,沒有使用到的 module 都可以刪掉,此時打包結果如下:

前端效能優化—js程式碼打包

import React from 'react';
// 只引入了 arraySum, utils.js 中的其他方法不會被打包
import { arraySum } from '@utils/utils';
import './test'; // 引用,“未使用”,不會被打包
import './About.scss'; // 引用,“未使用”,不會被打包

class About extends React.Component {
  render() {
    const sum = arraySum([12, 3]);
    return (
      <div className="page-about">
        <h1>About Page</h1>
        <div> 12 plus 3 equals {sum}</div>
      </div>
    );
  }
}
export default About;
複製程式碼

如上面註釋所說,Tree Shaking 認為這些是沒有被使用的程式碼,所以可以刪掉。但事實上我們知道不是這樣的,test.js 可以刪掉,但是 css、scss 是有用的程式碼,我們只需引入即可。因此,需要修改一下 sideEffects 的值:

{
  "sideEffects": [
    "*.css", "*.scss", "*.sass"
  ]
}
複製程式碼

表示,除了[]中的檔案(型別),其他檔案都是無副作用的,可以放心刪掉。此時打包結果:

前端效能優化—js程式碼打包

可以看到,css 等樣式檔案現在如期打包進去了。如果有其他型別的檔案有副作用,但是也希望打包進去,在 sideEffects: [] 中新增即可,可以是具體的某個檔案或者某種檔案型別。

關於為什麼修改這兩個地方就可以實現 Tree Shaking 的效果了,可以參考一下 developers.google.com/web/fundame… 或者其他文章,這裡不做詳細解釋了。

程式碼分割

單頁應用,如果所有的資源都打包在一個 js 裡面,毫無疑問,體積會非常龐大,首屏載入會有很長時間白屏,使用者體驗極差。所以,要程式碼分割,分成一個一個小的 js,優化載入時間。

分離第三方庫程式碼

第三方庫程式碼單獨提取出來,和業務程式碼分離,減少 js 檔案體積。在 webpack.base.conf.js 中增加:

module: {...},
optimization: {
  splitChunks: {
    cacheGroups: {
      venders: {
        test: /node_modules/,
        name: 'vendors',
        chunks: 'all'
      }
    }
  }
},
plugins: ...
複製程式碼

前端效能優化—js程式碼打包

動態匯入

使用 ECMAScript 提案dynamic import 語法可以非同步載入業務中的元件。使用方法如下:

// src/containers/App/App.js

// 註釋掉此行程式碼
// import About from '@containers/About/About';

// 修改模組為動態匯入形式
<Route path="/about" render={() => import(/* webpackChunkName: "about" */ '@containers/About/About').then(module => module.default)}/>
複製程式碼

此時打包結果:

前端效能優化—js程式碼打包

能看到,<About> 元件已經被 webpack 單獨打包出對應的 js 檔案了。同時,結合 react-router,分離 <About> 元件的同時也做到了按需載入:當訪問 About 頁面時,about.js 才會被瀏覽器載入。

注意,我們現在只是簡單地使用了 dynamic import,很多邊界情況沒考慮進去,比如:載入進度、載入失敗、超時等處理。可以開發一個高階元件,把這些異常處理都包含進去。社群有個很棒的 react-loadable,大樹底下好乘涼~

npm i react-loadable

// src/containers/App/App.js
import Loadable from 'react-loadable';

// 程式碼分割 & 非同步載入
const LoadableAbout = Loadable({
  loader: () => import(/* webpackChunkName: "about" */ '@containers/About/About'),
  loading() {
    return <div>Loading...</div>;
  }
});

class App extends React.Component {
  render() {
    return (
      <BrowserRouter>
        <div>
          <Header />

          <Route exact path="/" component={Home} />
          <Route path="/docs" component={Docs} />
          <Route path="/about" component={LoadableAbout} />
        </div>
      </BrowserRouter>
    );
  }
}
複製程式碼

react-loadable 還提供了 preload 功能。假如有統計資料顯示,使用者在進入首頁之後大概率會進入 About 頁面,那我們就在首頁載入完成的時候去載入 about.js,這樣等使用者跳到 About 頁面的時候,js 資源都已經載入好了,使用者體驗會更好。

// src/containers/App/App.js
componentDidMount() {
  LoadableAbout.preload();
}
複製程式碼

前端效能優化—js程式碼打包

如果有同學對Network皮膚不是很熟悉,可以看一下 Chrome DevTools — Network

提取複用的業務程式碼

第三方庫程式碼已經單獨提取出來了,但是業務程式碼中也會有一些複用的程式碼,典型的比如一些工具函式庫 utils.js。現在,About 元件Docs 元件都引用了 utils.js,webpack 只打包了一份 utils.jsmain.js 裡面,main.js 在首頁就被載入了,其他頁面有使用到 utils.js 自然可以正常引用到,符合我們的預期。但是目前我們只是把 About 頁面非同步載入了,如果把 Docs 頁面也非同步載入了會怎麼樣呢?

// src/containers/App/App.js
// 註釋掉此行程式碼
// import Docs from '@containers/Docs/Docs';

const LoadableDocs = Loadable({
  loader: () => import(/* webpackChunkName: "docs" */ '@containers/Docs/Docs'),
  loading() {
    return <div>Loading...</div>;
  }
});

class App extends React.Component {
  render() {
    return (
      <BrowserRouter>
        <div>
          <Header />

          <Route exact path="/" component={Home} />
          <Route path="/docs" component={LoadableDocs} />
          <Route path="/about" component={LoadableAbout} />
        </div>
      </BrowserRouter>
    );
  }
}
複製程式碼

此時打包結果:

前端效能優化—js程式碼打包

能夠看到,about.js 和 docs.js 裡面都打包了 utils.js,重複了! 在 webpack.base.conf.js 中增加:

module: {...},
optimization: {
  splitChunks: {
    cacheGroups: {
      venders: {
        test: /node_modules/,
        name: 'vendors',
        chunks: 'all'
      },
      default: {
        minSize: 0,
        minChunks: 2,
        reuseExistingChunk: true,
        name: 'utils'
      }
    }
  }
},
plugins: ...
複製程式碼

再打包看結果:

前端效能優化—js程式碼打包

utils.js 也被單獨打包出來了,達到了預期。

分離非首頁使用且複用程度小的第三方庫

假如,現在 Docs.js 引用了 lodash 這個三方庫:

import React from 'react';
import _ from 'lodash';
import { arraySum } from '@utils/utils';
import './Docs.scss';

class Docs extends React.Component {
  render() {
    const sum = arraySum([1, 3]);
    const b = _.sum([1, 3]);
    return (
      <div className="page-docs">
        <h1>Docs Page</h1>
        <div> 1 plus 3 equals {sum}</div>
        <br />
        <div>use _.sum, 1 plus 3 equals {b} too.</div>
      </div>
    );
  }
}
export default Docs;
複製程式碼

打包結果:

前端效能優化—js程式碼打包

lodash.js 只在 Docs 頁面使用,而且可能 Docs 頁面訪問量很少,把 lodash.js 打包在首頁就會載入的 venders.js 裡面,實在不是明智之舉。

修改 webpack.base.conf.js

...
venders: {
  test: /node_modules\/(?!(lodash)\/)/, // 去除 lodash,剩餘的第三方庫打成一個包,命名為 vendors-common
  name: 'vendors-common',
  chunks: 'all'
},
lodash: {
  test: /node_modules\/lodash\//, // lodash 庫單獨打包,並命名為 vender-lodash
  name: 'vender-lodash'
},
default: {
  minSize: 0,
  minChunks: 2,
  reuseExistingChunk: true,
  name: 'utils'
}
...
複製程式碼

此時把 lodash 單獨打成了一個包,且配合 Docs 頁面的按需載入,達到了理想的載入效果。

前端效能優化—js程式碼打包

快取

專案打包後,資源部署在伺服器端,客戶端需要向伺服器請求下載這些資源,使用者才能看到內容。使用快取,客戶端可以大大減少不必要的請求和時間耽擱,只有當資源有更新時,再去下載。區分一個檔案是否有更新,使用 檔名 + hash 可以達到目的。本案例中,已經使用了 '[name].[contenthash:8].js'

然而,在打包的時候,webpack的執行時程式碼有時候會導致某些情況出現,如:什麼內容都沒改,兩次 build 程式碼的 hash 不一樣;或者是,修改了 a 檔案的程式碼,卻導致了某些未修改程式碼檔案的 hash 也發生了變化。This is caused by the injection of the runtime and manifest which changes every build.

注意:使用的 webpack 版本不同,可能會導致打包出的結果不一樣。較新的版本或許沒有這種 hash 問題,但為了安全起見,還是建議按照下面的步驟處理一下。

分離 webpack runtimeChunk code

// webpack.base.conf.js
optimization: {
  runtimeChunk: {
    name: 'manifest'
  },
  splitChunks: {...}
}
複製程式碼

此時,能達到:修改某個檔案,只有這個檔案和 manifest.js 檔案的 hash 會發生變化,其他檔案的 hash 不變。 打包前:

前端效能優化—js程式碼打包

// About.scss
.page-about {
  padding-left: 30px;
  color: #545880; // 修改字型顏色
}
複製程式碼

修改後:

前端效能優化—js程式碼打包

HashedModuleIdsPlugin

增加、刪除一些模組,可能會導致不相關檔案的 hash 發生變化,這是因為 webpack 打包時,按照匯入模組的順序,module.id 自增,會導致某些模組的 module.id 發生變化,進而導致檔案的 hash 變化。

解決方式: 使用 webpack 內建的 HashedModuleIdsPlugin,該外掛基於匯入模組的相對路徑生成相應的 module.id,這樣如果內容沒有變化加上 module.id 也沒變化,則生成的 hash 也就不會變化了。

// webpack.prod.conf.js
const webpack = require('webpack');
...
plugins: [new webpack.HashedModuleIdsPlugin(), new BundleAnalyzerPlugin()]
複製程式碼

完整的優化程式碼見 github.com/jasonintju/…


有用的文章: webpack分離第三方庫及公用檔案
developers.google.com/web/fundame…

相關文章