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

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

圖標

三. tree-shaking實踐

webpack2 發佈,宣佈支持tree-shaking,webpack 3發佈,支持做用域提高,生成的bundle文件更小。 再沒有升級webpack以前,增幻想咱們的性能又要大幅提高了,對升級充滿了期待。實際上事實是這樣的html

升級完以後,bundle文件大小並無大幅減小,當時有較大的心理落差,而後去研究了爲何效果不理想,緣由見 Tree-Shaking性能優化實踐 - 原理篇前端

優化仍是要繼續的,雖然工具自帶的tree-shaking不能去除太多無用代碼,在去除無用代碼這一方面也仍是有能夠作的事情。咱們從三個方面作裏一些優化。vue



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

先來看一個問題node

當咱們使用組件庫的時候,import {Button} from 'element-ui',相對於Vue.use(elementUI),已是具備性能意識,是比較推薦的作法,但若是咱們寫成右邊的形式,具體到文件的引用,打包以後的區別是很是大的,以antd爲例,右邊形式bundle體積減小約80%。react

這個引用也屬於有反作用,webpack不能把其餘組件進行tree-shaking。既然工具自己是作不了,那咱們能夠作工具把左邊代碼自動改爲右邊代碼這種形式。這個工具antd庫自己也是提供的。我在antd的工具基礎上作了少許的修改,不用任何配置,原生支持咱們本身的組件庫, wuixcui 以及一些其餘經常使用的庫webpack

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

圖標
lin-xi/babel-plugin-import-fix


下面介紹一下原理github

這是一個babel的插件,babel經過核心babylon將ES6代碼轉換成AST抽象語法樹,而後插件遍歷語法樹找出相似import {Button} from 'element-ui'這樣的語句,進行轉換,最後從新生成代碼。web

babel-plugin-import-fix默認支持antd,element,meterial-UI,wui,xcui和d3,只須要再.babelrc中配置插件自己就能夠。

.babelrc

{
  "presets": [
    ["es2015", { "modules": false }], "react"
  ],
  "plugins": ["import-fix"]
}
複製代碼

實際上是想把全部經常使用的庫都默認支持,但不少經常使用的庫卻不支持縮小引用範圍。由於沒有獨立輸出各個子模塊,不能把引用修改成對單個子模塊的引用。



(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進行精確修改的目的。

總體又是一個webpack的插件,架構圖以下:

主要流程:

  • 插件監聽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中,能夠這麼寫

這樣是比較推薦的方式,選擇器做爲字符或變量名出如今代碼中,下面這樣動態生成選擇器的狀況就會致使匹配失敗

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中全部的模塊的構成的各構成的大小。

其次,需求對通用模塊進行提取,CommonsChunkPlugin是最被人熟知的用於提供通用模塊的插件。早期的時候,我並不徹底瞭解他的功能,並無發揮最大的功效。

下面介紹CommonsChunkPlugin的正確用法

自動提取全部的node_moudles或者引用次數兩次以上的模塊

minChunks能夠接受一個數值或者函數,若是是函數,可自定義打包規則

但使用上面記載的配置以後,並不能高枕無憂。由於這個配置只能提取全部entry打包後的文件中的通用模塊。而現實是,有了提升性能,咱們會按需加載,經過webpack提供的import(...)方法,這種按需加載的文件並不會存在於entry之中,因此按需加載的異步模塊中的通用模塊並無提取。

如何提取按需加載的異步模塊裏的通用模塊呢?

配置另外一個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" */ ,雖然看着不舒服,可是管用。

貼一個項目的優化效果對比圖

優化效果仍是比較明顯。

優化前bundle
優化後bundle



最後思考一個問題:

不一樣entry模塊或按需加載的異步模塊需不須要提取通用模塊?

這個須要看場景了,好比模塊都是在線加載的,若是通用模塊提取粒度太小,會致使首頁首屏須要的文件變多,不少多是首屏用不到的,致使首屏過慢,二級或三級頁面加載會大幅提高。因此這個就須要根據業務場景作權衡,控制通用模塊提取的粒度。

百度外賣的移動端應用場景是這樣的,咱們全部的移動端頁面都作了離線化的處理。離線以後,加載本地的js文件,與網絡無關,基本上能夠忽略文件大小,因此更關注整個離線包的大小。離線包越小,耗費用戶的流量就越小,用戶體驗更好,因此離線化的場景是很是適合最小粒提取通用模塊的,即將全部entry模塊和異步加載模塊的引用大於2的模塊都提取,這樣能得到最小的輸出文件,最小的離線包。

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


文本提到的插件都是開源的,連接彙總,歡迎交流,歡迎戳❤

圖標

lin-xi/babel-plugin-import-fix
相關文章
相關標籤/搜索