webpack 應用編譯優化之路

滴滴WebApp架構組發表於2017-10-10

作者:苗典

目前大家使用最多也是最廣泛的應用打包工具就是 webpack 了,除去 webpack 本身已經提供的優化能力(例如,Tree Shaking、Code Splitting 等)之外,我們還能做哪些事情呢,本篇主要就為大家介紹下滴滴 WebApp 團隊在這條路上的一些探索。

前言

現在越來越多的專案都使用 ES2015+ 開發,並且搭配 webpack + babel 作為工程化基礎,並通過 NPM 去載入第三方依賴庫。同時為了達到程式碼複用的目的,我們會把一些自己開發的元件庫或者是 JSSDK 抽成獨立的倉庫維護,並通過 NPM 去載入。

大部分人已經習慣了這樣的開發方式,並且覺得非常方便實用。但在方便的背後,卻隱藏了兩個問題:

  • 程式碼冗餘

    一般來說,這些 NPM 包也是基於 ES2015+ 開發的,每個包都需要經過 babel 編譯釋出後才能被主應用使用,而這個編譯過程往往會附加很多“編譯程式碼”;每個包都會有一些相同的編譯程式碼,這就造成大量程式碼的冗餘,並且這部分冗餘程式碼是不能通過 Tree Shaking 等技術去除掉的。

  • 非必要的依賴

    考慮到元件庫的場景,通常我們為了方便一股腦引入了所有元件;但實際情況下對於一個應用而言可能只是用到了部分元件,此時如果全部引入,也會造成程式碼冗餘。

程式碼的冗餘會造成靜態資源包載入時間變長、執行時間也會變長,進而很直接的影響效能和體驗。既然我們已經認識到有此類問題,那麼接下來看看如何解決這兩個問題。

核心

我們對於上述的 2 個問題,核心的解決優化方案是:後編譯按需引入

效果

先來看下滴滴車票專案(用票人)優化前後的資料(非 gzip,壓縮後整個專案的大小):

  • 普通打包:455 KB
  • 後編譯:423 KB
  • 後編譯 & 按需引入:388 KB
  • 後編譯 & 按需引入 & babel-preset-env:377 KB

最終減少了約 80 KB,優化效果還是相當可觀的。

上邊的資料主要是對元件庫和一些內部通用 JSSDK 採用後編譯按需引入策略後的效果,需要注意的是按需引入的效果是要視專案情況而定的,這裡的資料僅供參考。

下面就分別來看看這兩個點的具體細節。

後編譯

先來解釋下:

後編譯:指的是應用依賴的 NPM 包並不需要在釋出前編譯,而是隨著應用編譯打包的時候一塊編譯。

後編譯的核心在於把編譯依賴包的時機延後,並且統一編譯;先來看看它的 webpack 配置。

配置

對具體專案應用而言,做到後編譯,其實不需要做太多,只需要在 webpack 的配置檔案中,包含需要我們去後編譯的依賴包即可(webpack 2+):

// webpack.config.js
module.exports = {
  // ...
  module: {
    rules: [
      // ...
      {
        test: /\.js$/,
        loader: 'babel-loader',
        // 注意這裡的 include
        // 除了 src 還包含了額外的 node_modules 下的兩個包
        include: [
            resolve('src'),
            resolve('node_modules/A'),
            resolve('node_modules/B')
          ]
      },
      // ...
    ]
  },
  // ...
}複製程式碼

我們只需要把後編譯的模組 A 和 B 通過 webpack 的 include 配置包含進來即可。

但是這裡會存在一些問題,舉個例子,如下圖:

webpack-app
webpack-app

上述所示的應用中依賴了需要後編譯的包 A 和 B,而 A 又依賴了需要後編譯的包 C 和 D,B 依賴了不需要後編譯的包 E;重點來看依賴包 A 的情況:A 本身需要後編譯,然後 A 的依賴包 C 和 D 也需要後編譯,這種場景我們可以稱之為巢狀後編譯,此時如果依舊通過上邊的 webpack 配置方式的話,還必須要顯示的去 include 包 C 和 D,但對於應用而言,它只知道自身需要後編譯的包 A 和 B,並不知道 A 也會有需要後編譯的包 C 和 D,所以應用不應該顯示的去 include 包 C 和 D,而是應該由 A 顯示的去宣告自己需要哪些後編譯模組。

為了解決上述巢狀後編譯問題,我們開發了一個 webpack 外掛 webpack-post-compile-plugin,用於自動收集後編譯的依賴包以及其巢狀依賴;來看下這個外掛的核心程式碼:

var util = require('./util')

function PostCompilePlugin (options) {
  // ...
}

PostCompilePlugin.prototype.apply = function (compiler) {
  var that = this
  compiler.plugin(['before-run', 'watch-run'], function (compiler, callback) {
    // ...
    var dependencies = that._collectCompileDependencies(compiler)
    if (dependencies.length) {
      var rules = compiler.options.module.rules
      rules && rules.forEach(function (rule) {
        if (rule.include) {
          if (!Array.isArray(rule.include)) {
            rule.include = [rule.include]
          }
          rule.include = rule.include.concat(dependencies)
        }
      })
    }
    callback()
  })
}複製程式碼

原理就是在 webpack compiler 的 before-runwatch-run 事件鉤子中去收集依賴然後附加到 webpack module.rule 的 include 上;收集的規則就是查詢應用或者依賴包的 package.json 中宣告的 compileDependencies 作為後編譯依賴。

所以對於上述應用的情況,使用 webpack-post-compile-plugin 外掛的 webpack 配置:

var PostCompilePlugin = require('webpack-post-compile-plugin')
// webpack.config.js
module.exports = {
  // ...
  module: {
    rules: [
      // ...
      {
        test: /\.js$/,
        loader: 'babel-loader',
        include: [
            resolve('src')
          ]
      },
      // ...
    ]
  },
  // ...
  plugins: [
    new PostCompilePlugin()
  ]
}複製程式碼

當前專案的 package.json 中新增 compileDependencies 欄位來指定後編譯依賴包:

// app package.json
{
  // ...
  "compileDependencies": ["A", "B"]
  // ...
}複製程式碼

A 還有後編譯依賴,所以需要在包 A 的 package.json 中指定 compileDependencies:

// A package.json
{
  // ...
  "compileDependencies": ["C", "D"]
  // ...
}複製程式碼

優點

  • 公共的依賴可以實現共用,只此一份,重要的是隻編譯一次,建議通過 peerDependencies 管理依賴。
  • babel 轉換 API(例如 babel-plugin-transform-runtime 或者 babel-polyfill)部分的程式碼只有一份。
  • 不用每個依賴包都需要配置編譯打包環節,甚至可以直接原始碼級別釋出。

PS: 關於 babel-plugin-transform-runtime 和 babel-polyfill 的選擇問題,對於應用而言,我們建議的是採用 babel-polyfill。因為一些第三方包的依賴會判斷全域性是否支援某些特性,而不去做 polyfill 處理。例如:vuex 會檢查是否支援 Promise,如果不支援則會報錯;或者說在程式碼中有類似 "foobar".includes("foo") 的程式碼的話 babel-plugin-transform-runtime 也是不能正確處理的。

當然,後編譯的技術方案肯定不是完美無瑕的,它也會有一些缺點。

缺點

  • 主應用的 babel 配置需要能相容依賴包的 babel 配置。
  • 依賴包不能使用 alias、不能方便的使用 DefinePlugin(可以經過一次簡單編譯,但是不做 babel 處理)。
  • 應用編譯時間會變長。

雖然有一些缺點,但是綜合考慮到成本/收益,目前來看採用後編譯仍不失為一種不錯的選擇。

按需引入

後編譯主要解決的問題是程式碼冗餘,而按需引入主要是用來解決非必要的依賴的問題。

按需引入針對的場景主要是元件庫、工具類依賴包。因為不管是元件庫還是依賴包,往往都是“大而全”的,而在開發應用的時候,我們可能只是使用了其一部分能力,如果全部引入的話,會有很多資源浪費。

為了解決這個問題,我們需要按需引入。目前主流元件庫或者工具包也都是提供按需引入能力的,但是基本都是提供對編譯後模組引入。

而我們推薦的是對原始碼的按需引入,配合後編譯的打包方案 。

但是實際上我們可能會遇到一些向後相容問題,不能一竿子打死,例如之前已經建立的專案,目前沒有人力或者時間去做對應的升級改造,那麼我們對內的一些元件庫或者工具包目前需要做一點犧牲:提供兩個入口,一個編譯後的入口,一個原始碼入口。

入口之爭

這裡涉及到一個 NPM 包有兩個入口的問題,不過還好這個問題 webpack 2+ 或者 rollup 已經幫我們處理了,即編譯後入口依舊使用 package.json 中的 main 欄位,然後原始碼的入口使用 module 欄位,可以參見 rollup pkg.module wiki。這樣我們就能實現兩個入口共享,既能保證向後相容,又可以保證使用 webpack 2+ 或者 rollup 的入口直接指向的就是原始碼,在這樣的基礎上可以很直接的利用後編譯了。

Vue 元件庫編譯

後編譯按需引入一個最最典型的場景就是我們的元件庫,這裡分享下我們對於元件庫(基於 Vue)的實踐經驗。

按需引入,在沒有後編譯的時候,其實我們已經實現了在編譯釋出的時候直接做到自動根據各模組分別編譯,這樣使用方就可以直接引入對應目錄的入口檔案。這個原理很簡單:遍歷原始碼目錄下的模組目錄,得到各個入口,動態修改了元件庫 webpack 配置的入口。而這個過程在後編譯場景中就不存在了,可以直接引入到原始碼所對應的模組入口,因為後編譯不需要依賴包自己編譯,只需要應用去編譯就好了。

對於元件而言,如果是前編譯的話,一般我們會編譯出入口 JS 檔案,以及樣式 CSS 檔案,這樣如果來實現按需引入的話,可能是這樣的:

import Dialog from 'cube-ui/lib/dialog'
import 'cube-ui/lib/dialog/style.css'複製程式碼

即使是在後編譯場景下,雖然不需要處理樣式問題了,但是還是會遇到按需引入的時候,路徑不夠優雅:

import Dialog from 'cube-ui/src/modules/dialog'複製程式碼

以上不管是哪種,總是不夠優雅,幸好有一個 babel 外掛 babel-plugin-transform-imports 來幫助我們優雅的按需引入。但是對於我們編譯後的場景,還需要引入樣式,為此,我們對其做了統一,在 babel-plugin-transform-imports 上做了增強的 babel-plugin-transform-modules 外掛,增設了 style 配置項。

所以不管是不是使用了後編譯,我們想要做到按需引入,只需要:

import { Dialog } form 'cube-ui'複製程式碼

這樣寫就可以了,如果你是使用的後編譯,直接引入的是原始碼,那麼只需要在 .babelrc 檔案中增加如下配置:

"plugins": [
  ["transform-modules", {
     "cube-ui": {
       "transform": "cube-ui/src/modules/${member}",
       "preventFullImport": true,
       "kebabCase": true
     }
  }]
]複製程式碼

而如果是 webpack 1 或者說使用的元件庫是已經編譯後的,那隻需要增設 style 配置項即可:

"plugins": [
  ["transform-modules", {
     "cube-ui": {
       "transform": "cube-ui/lib/${member}",
       "preventFullImport": true,
       "kebabCase": true,
       "style": true
     }
  }]
]複製程式碼

這樣我們就通過一個外掛實現了優雅的按需引入,不管是不是使用了後編譯,對於開發者而言只需要修改下 babel 的配置即可,而不需要大肆去修改原始碼中的引入路徑。

總結

以上就是我們基於 webpack 的編譯優化的一點探索,這裡可以總結下使用 webpack 做應用編譯打包的“最佳實踐”:

後編譯 + 按需引入

再搭配上 babel-preset-env, babel-plugin-transform-modules 開發體驗以及收益效果更好。


歡迎大家關注滴滴FE部落格: github.com/DDFE/DDFE-b…

相關文章