Tree-Shaking效能優化實踐 - 實踐篇

百度外賣大前端技術團隊發表於2018-01-04

上一篇文章 Tree-Shaking效能優化實踐 - 原理篇 介紹了 tree-shaking 的原理,本文主要介紹 tree-shaking 的實踐

圖示

三. tree-shaking實踐

Tree-Shaking效能優化實踐 - 實踐篇

webpack2 釋出,宣佈支援tree-shaking,webpack 3釋出,支援作用域提升,生成的bundle檔案更小。 再沒有升級webpack之前,增幻想我們的效能又要大幅提升了,對升級充滿了期待。實際上事實是這樣的

Tree-Shaking效能優化實踐 - 實踐篇

升級完之後,bundle檔案大小並沒有大幅減少,當時有較大的心理落差,然後去研究了為什麼效果不理想,原因見 Tree-Shaking效能優化實踐 - 原理篇

優化還是要繼續的,雖然工具自帶的tree-shaking不能去除太多無用程式碼,在去除無用程式碼這一方面也還是有可以做的事情。我們從三個方面做裡一些優化。



(1)對元件庫引用的優化

先來看一個問題

Tree-Shaking效能優化實踐 - 實踐篇

當我們使用元件庫的時候,import {Button} from 'element-ui',相對於Vue.use(elementUI),已經是具有效能意識,是比較推薦的做法,但如果我們寫成右邊的形式,具體到檔案的引用,打包之後的區別是非常大的,以antd為例,右邊形式bundle體積減少約80%。

這個引用也屬於有副作用,webpack不能把其他元件進行tree-shaking。既然工具本身是做不了,那我們可以做工具把左邊程式碼自動改成右邊程式碼這種形式。這個工具antd庫本身也是提供的。我在antd的工具基礎上做了少量的修改,不用任何配置,原生支援我們自己的元件庫, wuixcui 以及一些其他常用的庫

babel-plugin-import-fix ,縮小引用範圍

Tree-Shaking效能優化實踐 - 實踐篇
圖示
lin-xi/babel-plugin-import-fix


下面介紹一下原理

Tree-Shaking效能優化實踐 - 實踐篇

這是一個babel的外掛,babel通過核心babylon將ES6程式碼轉換成AST抽象語法樹,然後外掛遍歷語法樹找出類似import {Button} from 'element-ui'這樣的語句,進行轉換,最後重新生成程式碼。

babel-plugin-import-fix預設支援antd,element,meterial-UI,wui,xcui和d3,只需要再.babelrc中配置外掛本身就可以。

.babelrc

{
  "presets": [
    ["es2015", { "modules": false }], "react"
  ],
  "plugins": ["import-fix"]
}
複製程式碼
Tree-Shaking效能優化實踐 - 實踐篇

其實是想把所有常用的庫都預設支援,但很多常用的庫卻不支援縮小引用範圍。因為沒有獨立輸出各個子模組,不能把引用修改為對單個子模組的引用。



(2)CSS Tree-shaking

我們前面所說的tree-shaking都是針對js檔案,通過靜態分析,儘可能消除無用的程式碼,那對於css我們能做tree-shaking嗎?

隨著CSS3,LESS,SASS等各種css預處理語言的普及,css檔案在整個工程中佔比是不可忽視的。隨著大專案功能的不停迭代,導致css中可能就存在著無用的程式碼。我實現了一個webpack外掛來解決這個問題,找出css程式碼無用的程式碼。

webpack-css-treeshaking-plugin,對css進行tree-shaking

圖示
webpack-css-treeshaking-plugin


下面介紹一下原理

整體思路是這樣的,遍歷所有的css檔案中的selector選擇器,然後去所有js程式碼中匹配,如果選擇器沒有在程式碼出現過,則認為該選擇器是無用程式碼。

首先面臨的問題是,如何優雅的遍歷所有的選擇器呢?難道要用正規表示式很苦逼的去匹配分割嗎?

babel是js世界的福星,其實css世界也有利器,那就是postCss。

PostCSS 提供了一個解析器,它能夠將 CSS 解析成AST抽象語法樹。然後我們能寫各種外掛,對抽象語法樹做處理,最終生成新的css檔案,以達到對css進行精確修改的目的。

Tree-Shaking效能優化實踐 - 實踐篇

整體又是一個webpack的外掛,架構圖如下:

Tree-Shaking效能優化實踐 - 實踐篇

主要流程:

  • 外掛監聽webapck編譯完成事件,webpack編譯完成之後,從compilation中找出所有的css檔案和js檔案
apply (compiler) {
    compiler.plugin('after-emit', (compilation, callback) => {

      let styleFiles = Object.keys(compilation.assets).filter(asset => {
        return /\.css$/.test(asset)
      })

      let jsFiles = Object.keys(compilation.assets).filter(asset => {
        return /\.(js|jsx)$/.test(asset)
      })

     ....
}
複製程式碼
  • 將所有的css檔案送至postCss處理,找出無用程式碼
   let tasks = []
    styleFiles.forEach((filename) => {
        const source = compilation.assets[filename].source()
        let listOpts = {
          include: '',
          source: jsContents,  //傳入全部js檔案
          opts: this.options   //外掛配置選項
        }
        tasks.push(postcss(treeShakingPlugin(listOpts)).process(source).then(result => {       
          let css = result.toString()  // postCss處理後的css AST  
          //替換webpack的編譯產物compilation
          compilation.assets[filename] = {
            source: () => css,
            size: () => css.length
          }
          return result
        }))
    })
複製程式碼
  • postCss 遍歷,匹配,刪除過程
 module.exports = postcss.plugin('list-selectors', function (options) {
    // 從根節點開始遍歷
    cssRoot.walkRules(function (rule) {
      // Ignore keyframes, which can log e.g. 10%, 20% as selectors
      if (rule.parent.type === 'atrule' && /keyframes/.test(rule.parent.name)) return
      
      // 對每一個規則進行處理
      checkRule(rule).then(result => {
        if (result.selectors.length === 0) {
          // 選擇器全部被刪除
          let log = ' ✂️ [' + rule.selector + '] shaked, [1]'
          console.log(log)
          if (config.remove) {
            rule.remove()
          }
        } else {
          // 選擇器被部分刪除
          let shaked = rule.selectors.filter(item => {
            return result.selectors.indexOf(item) === -1
          })
          if (shaked && shaked.length > 0) {
            let log = ' ✂️ [' + shaked.join(' ') + '] shaked, [2]'
            console.log(log)
          }
          if (config.remove) {
            // 修改AST抽象語法樹
            rule.selectors = result.selectors
          }
        }
      })
    })
複製程式碼

checkRule 處理每一個規則核心程式碼

let checkRule = (rule) => {
      return new Promise(resolve => {
        ...
        let secs = rule.selectors.filter(function (selector) {
          let result = true
          let processor = parser(function (selectors) {
            for (let i = 0, len = selectors.nodes.length; i < len; i++) {
              let node = selectors.nodes[i]
              if (_.includes(['comment', 'combinator', 'pseudo'], node.type)) continue
              for (let j = 0, len2 = node.nodes.length; j < len2; j++) {
                let n = node.nodes[j]
                if (!notCache[n.value]) {
                  switch (n.type) {
                    case 'tag':
                      // nothing
                      break
                    case 'id':
                    case 'class':
                      if (!classInJs(n.value)) {
                        // 呼叫classInJs判斷是否在JS中出現過
                        notCache[n.value] = true
                        result = false
                        break
                      }
                      break
                    default:
                      // nothing
                      break
                  }
                } else {
                  result = false
                  break
                }
              }
            }
          })
          ...
        })
        ...
      })
    }
複製程式碼

可以看到其實我只處理裡 id選擇器和class選擇器,id和class相對來說副作用小,引起樣式異常的可能性相對較小。

判斷css是否再js中出現過,是使用正則匹配。

其實,後續還可以繼續優化,比如對tag類的選擇器,可以配置是否再html,jsx,template中出現過,如果出現過,沒有出現過也可以認為是無用程式碼。

當然,外掛能正常工作還是的有一些前提和約束。我們可以在程式碼中動態改變css,比如再react和vue中,可以這麼寫

Tree-Shaking效能優化實踐 - 實踐篇

這樣是比較推薦的方式,選擇器作為字元或變數名出現在程式碼中,下面這樣動態生成選擇器的情況就會導致匹配失敗

render(){
  this.stateClass = 'state-' + this.state == 2 ? 'open' : 'close'
  return <div class={this.stateClass}></div>
}
複製程式碼

其中這樣情況很容易避免

render(){
  this.stateClass = this.state == 2 ? 'state-open' : 'state-close'
  return <div class={this.stateClass}></div>
}
複製程式碼

所以有一個好的編碼規範的約束,外掛能更好的工作。


(3)webpack bundle檔案去重

如果webpack打包後的bundle檔案中存在著相同的模組,也屬於無用程式碼的一種。也應該被去除掉

首先我們需要一個能對bundle檔案定性分析的工具,能發現問題,能看出優化效果。

webpack-bundle-analyzer這個外掛完全能滿足我們的需求,他能以圖形化的方式展示bundle中所有的模組的構成的各構成的大小。

Tree-Shaking效能優化實踐 - 實踐篇

其次,需求對通用模組進行提取,CommonsChunkPlugin是最被人熟知的用於提供通用模組的外掛。早期的時候,我並不完全瞭解他的功能,並沒有發揮最大的功效。

下面介紹CommonsChunkPlugin的正確用法

自動提取所有的node_moudles或者引用次數兩次以上的模組

Tree-Shaking效能優化實踐 - 實踐篇

minChunks可以接受一個數值或者函式,如果是函式,可自定義打包規則

但使用上面記載的配置之後,並不能高枕無憂。因為這個配置只能提取所有entry打包後的檔案中的通用模組。而現實是,有了提高效能,我們會按需載入,通過webpack提供的import(...)方法,這種按需載入的檔案並不會存在於entry之中,所以按需載入的非同步模組中的通用模組並沒有提取。

如何提取按需載入的非同步模組裡的通用模組呢?

Tree-Shaking效能優化實踐 - 實踐篇

配置另一個CommonsChunkPlugin,新增async屬性,async可以接受布林值或字串。當時字串時,預設是輸出檔案的名稱。

names是所有非同步模組的名稱

這裡還涉及一個給非同步模組命名的知識點。我是這樣做的:

const Edit = resolve => { import( /* webpackChunkName: "EditPage" */ './pages/Edit/Edit').then((mod) => { resolve(mod.default); }) };
const PublishPage = resolve => { import( /* webpackChunkName: "Publish" */ './pages/Publish/Publish').then((mod) => { resolve(mod); }) };
const Models = resolve => { import( /* webpackChunkName: "Models" */ './pages/Models/Models').then((mod) => { resolve(mod.default); }) };
const MediaUpload = resolve => { import( /* webpackChunkName: "MediaUpload" */ './pages/Media/MediaUpload').then((mod) => { resolve(mod); }) };
const RealTime = resolve => { import( /* webpackChunkName: "RealTime" */ './pages/RealTime/RealTime').then((mod) => { resolve(mod.default); }) };
複製程式碼

沒錯,在import裡新增註釋。/* webpackChunkName: "EditPage" */ ,雖然看著不舒服,但是管用。

貼一個專案的優化效果對比圖

Tree-Shaking效能優化實踐 - 實踐篇

優化效果還是比較明顯。

Tree-Shaking效能優化實踐 - 實踐篇
優化前bundle
Tree-Shaking效能優化實踐 - 實踐篇
優化後bundle



最後思考一個問題:

不同entry模組或按需載入的非同步模組需不需要提取通用模組?

這個需要看場景了,比如模組都是線上載入的,如果通用模組提取粒度過小,會導致首頁首屏需要的檔案變多,很多可能是首屏用不到的,導致首屏過慢,二級或三級頁面載入會大幅提升。所以這個就需要根據業務場景做權衡,控制通用模組提取的粒度。

百度外賣的移動端應用場景是這樣的,我們所有的移動端頁面都做了離線化的處理。離線之後,載入本地的js檔案,與網路無關,基本上可以忽略檔案大小,所以更關注整個離線包的大小。離線包越小,耗費使用者的流量就越小,使用者體驗更好,所以離線化的場景是非常適合最小粒提取通用模組的,即將所有entry模組和非同步載入模組的引用大於2的模組都提取,這樣能獲得最小的輸出檔案,最小的離線包。

1月20日,我將在掘金分享《百度外賣前端離線化實踐》,有興趣的可以關注一下。


文字提到的外掛都是開源的,連結彙總,歡迎交流,歡迎戳❤

圖示

lin-xi/babel-plugin-import-fix


相關文章