上一篇文章 Tree-Shaking效能優化實踐 - 原理篇 介紹了 tree-shaking 的原理,本文主要介紹 tree-shaking 的實踐
三. tree-shaking實踐
webpack2 釋出,宣佈支援tree-shaking,webpack 3釋出,支援作用域提升,生成的bundle檔案更小。 再沒有升級webpack之前,增幻想我們的效能又要大幅提升了,對升級充滿了期待。實際上事實是這樣的
升級完之後,bundle檔案大小並沒有大幅減少,當時有較大的心理落差,然後去研究了為什麼效果不理想,原因見 Tree-Shaking效能優化實踐 - 原理篇 。
優化還是要繼續的,雖然工具自帶的tree-shaking不能去除太多無用程式碼,在去除無用程式碼這一方面也還是有可以做的事情。我們從三個方面做裡一些優化。
(1)對元件庫引用的優化
先來看一個問題
當我們使用元件庫的時候,import {Button} from 'element-ui',相對於Vue.use(elementUI),已經是具有效能意識,是比較推薦的做法,但如果我們寫成右邊的形式,具體到檔案的引用,打包之後的區別是非常大的,以antd為例,右邊形式bundle體積減少約80%。
這個引用也屬於有副作用,webpack不能把其他元件進行tree-shaking。既然工具本身是做不了,那我們可以做工具把左邊程式碼自動改成右邊程式碼這種形式。這個工具antd庫本身也是提供的。我在antd的工具基礎上做了少量的修改,不用任何配置,原生支援我們自己的元件庫, wui 和 xcui 以及一些其他常用的庫
babel-plugin-import-fix ,縮小引用範圍
下面介紹一下原理
這是一個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"]
}
複製程式碼
其實是想把所有常用的庫都預設支援,但很多常用的庫卻不支援縮小引用範圍。因為沒有獨立輸出各個子模組,不能把引用修改為對單個子模組的引用。
(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" */ ,雖然看著不舒服,但是管用。
貼一個專案的優化效果對比圖
優化效果還是比較明顯。
最後思考一個問題:
不同entry模組或按需載入的非同步模組需不需要提取通用模組?
這個需要看場景了,比如模組都是線上載入的,如果通用模組提取粒度過小,會導致首頁首屏需要的檔案變多,很多可能是首屏用不到的,導致首屏過慢,二級或三級頁面載入會大幅提升。所以這個就需要根據業務場景做權衡,控制通用模組提取的粒度。
百度外賣的移動端應用場景是這樣的,我們所有的移動端頁面都做了離線化的處理。離線之後,載入本地的js檔案,與網路無關,基本上可以忽略檔案大小,所以更關注整個離線包的大小。離線包越小,耗費使用者的流量就越小,使用者體驗更好,所以離線化的場景是非常適合最小粒提取通用模組的,即將所有entry模組和非同步載入模組的引用大於2的模組都提取,這樣能獲得最小的輸出檔案,最小的離線包。
1月20日,我將在掘金分享《百度外賣前端離線化實踐》,有興趣的可以關注一下。
文字提到的外掛都是開源的,連結彙總,歡迎交流,歡迎戳❤
lin-xi/babel-plugin-import-fix