作者 |?馬可 ? ? ?
小程序編譯器是百度開發(fā)者工具中的編譯構(gòu)建模塊,用來將小程序代碼轉(zhuǎn)換成運(yùn)行時(shí)代碼。舊版編譯器由于業(yè)務(wù)發(fā)展,存在編譯慢、內(nèi)存占用高的問題,我們對編譯器做了一次大規(guī)模的重構(gòu),采用自研架構(gòu),做了多線程、代碼緩存、sourcemap 等多項(xiàng)優(yōu)化,在性能和內(nèi)存占用上都有很大提升。全文介紹了新版編譯器的設(shè)計(jì)思路和優(yōu)化方法,以及一些能夠用在通用打包工具里的技術(shù)點(diǎn)。? ?
01
前言
小程序編譯器在小程序開發(fā)、預(yù)覽、發(fā)布各個(gè)階段都需要使用,因此編譯器性能會直接影響到開發(fā)者開發(fā)效率,也會影響到開發(fā)者工具的使用體驗(yàn)。 由于舊版的編譯器(基于 webpack4)在構(gòu)建大型項(xiàng)目時(shí)會很慢,內(nèi)存占用也高,一直被開發(fā)者吐槽。我們經(jīng)過大量的調(diào)研和開發(fā),最后采用完全自研架構(gòu)做新編譯,針對小程序項(xiàng)目構(gòu)建做了大量優(yōu)化,基本解決了舊編譯存在的問題。 下圖是部分項(xiàng)目構(gòu)建時(shí)間對比:
新版編譯器相對于舊版實(shí)現(xiàn)了 2~7 倍的性能提升,并且支持實(shí)時(shí)編譯、熱重載等特性,內(nèi)存占用更少,構(gòu)建產(chǎn)物更優(yōu)。
下面從 框架選型、新編譯器工作原理、性能和產(chǎn)物優(yōu)化方法 等方面介紹新版編譯器的成長之路。
GEEK TALK
02
框架選型
在進(jìn)行新版編譯器設(shè)計(jì)時(shí),需要明確當(dāng)前的痛點(diǎn)問題:性能,優(yōu)先解決性能問題。其他新技術(shù)和新想法對編譯器有幫助的也一起實(shí)施。
舊版編譯器基于 webpack4 存在如下幾個(gè)問題:
大型項(xiàng)目構(gòu)建速度太慢。
dev 啟動慢、增量編譯慢,僅支持 loader 緩存,bundle 無緩存也比較慢。
基于 webpack4 做擴(kuò)展開發(fā),需要 patch 部分模塊才能工作,維護(hù)困難。
部分 webpack bundle 過程無法針對小程序代碼結(jié)構(gòu)進(jìn)行優(yōu)化,存在無效構(gòu)建。
新編譯的設(shè)計(jì)目標(biāo):
更快的全量編譯速度,消除 webpack 存在的無效構(gòu)建過程。
支持全緩存,加快首次和增量編譯速度。
支持實(shí)時(shí)編譯,減少 dev 啟動和二次編譯時(shí)間。
支持多線程編譯加速,支持頁面熱重載。
優(yōu)化產(chǎn)物結(jié)構(gòu),減少產(chǎn)物體積。
2.1 主流構(gòu)建工具
下面介紹的是我們調(diào)研過的主流前端構(gòu)建工具,每個(gè)工具都有適用場景和優(yōu)缺點(diǎn)。
在新版本編譯器架構(gòu)設(shè)計(jì)時(shí),其他構(gòu)建工具的設(shè)計(jì)理念和技術(shù)特點(diǎn)都值得參考。
Webpack 構(gòu)建過程:
Webpack 優(yōu)點(diǎn):功能完善、社區(qū)活躍、可配置性強(qiáng)、有很強(qiáng)的擴(kuò)展性。
Webpack 缺點(diǎn):配置復(fù)雜、構(gòu)建速度慢,二次開發(fā)困難。
Parcel 構(gòu)建過程:
Parcel 優(yōu)點(diǎn):無需配置,構(gòu)建速度快,原生支持多線程和全緩存,多線程之間共享數(shù)據(jù)通過 lmdb 進(jìn)行,避免跨線程通信開銷。
Parcel 缺點(diǎn):生態(tài)小,自定義性有限,大量采用 Node 插件,兼容性也差一些。
Vite 構(gòu)建過程:
Vite 優(yōu)點(diǎn):配置較為簡單,按需編譯,啟動快,dev 時(shí)有不錯(cuò)的體驗(yàn)。
Vite 缺點(diǎn):生態(tài)小,dev 和 發(fā)布走兩套構(gòu)建流程。
其他小程序平臺:
微信基于 gulp 和 C++?模塊做小程序構(gòu)建,并且對 npm 模塊做了預(yù)構(gòu)建,在性能和開發(fā)體驗(yàn)上做的比較好。
支付寶基于 webpack 做小程序構(gòu)建,并且使用了 esbuild 加速代碼壓縮。
抖音小程序使用自研編譯器,構(gòu)建流程比較簡單。
2.2 新版編譯器
在設(shè)計(jì)新編譯框架時(shí),借鑒了主流打包工具的工作流程,結(jié)合小程序代碼特點(diǎn),決定不做通用打包工具,重點(diǎn)優(yōu)化小程序打包性能。
最終選擇了自研編譯器的方案,并做了大量優(yōu)化工作,新版編譯器優(yōu)化點(diǎn)有如下幾個(gè)方面:
1.支持多 Compiler 協(xié)同工作,將動態(tài)庫開發(fā)等多類型項(xiàng)目構(gòu)建解耦。
2.編譯階段全流程緩存,節(jié)省二次構(gòu)建時(shí)間 90% 以上。
3.dev 開發(fā)默認(rèn)采用按需編譯,提升單頁編譯性能。
4.支持 babel 和 swc 多線程編譯,提升全量編譯速度 2 ~ 7 倍。
5.采用新版 sourcemap 協(xié)議,移除非必要解析合并,將 bundle 階段耗時(shí)大幅縮減。
6.對 js、css、swan 模板編譯均做了構(gòu)建時(shí)標(biāo)記優(yōu)化,減少 bundle 合并耗時(shí)。
7.對于預(yù)覽、發(fā)布階段的 js 壓縮和混淆,采用了 terser 和 esbuild 并行方案,esbuild 用于快速打出預(yù)覽包,terser 可以保證壓縮率用于發(fā)布包。
從結(jié)果看,新編譯器從速度、資源占用和可維護(hù)性上相對于舊版都有顯著的提升。
GEEK TALK
03
新版編譯器工作原理
新編譯器的處理流程和 parcel 比較類似,Compiler 控制處理流程,Processor 進(jìn)行代碼轉(zhuǎn)換,基本流程如下:
其中幾個(gè)重要的模塊:
CompileEntry 編譯器為入口模塊,包含 cli 通信、dev server 通信、命令調(diào)用等。
CompileManager 為編譯管理器,用于依賴資源下載和管理以及多個(gè) Compiler 協(xié)同構(gòu)建。
Compiler 為編譯器模塊,用于將項(xiàng)目源碼編譯成運(yùn)行時(shí)代碼,項(xiàng)目構(gòu)建時(shí) Compiler 可能有多個(gè)。
Processor 為單元處理器,用于處理 代碼轉(zhuǎn)換、代碼合并 等單個(gè)編譯任務(wù)。
注:小程序 App 項(xiàng)目有 1 個(gè)Compiler,動態(tài)庫和動態(tài)擴(kuò)展項(xiàng)目 2 個(gè)Compiler。
3.1 Compiler 編譯器
用于編譯單個(gè)小程序項(xiàng)目,將開發(fā)者原始代碼編譯為可運(yùn)行代碼。
工作職能:
1.創(chuàng)建運(yùn)行上下文,提供 config、fs 文件處理、watcher 監(jiān)控、logger 等模塊,給 Processor 使用。
2.全量編譯、文件變更時(shí)二次編譯;這里二次編譯也是走一遍全量編譯流程,不過大部分用的是緩存結(jié)果。
3.管理、調(diào)度、運(yùn)行 Processor 處理單元。
4.維護(hù) Processor 依賴關(guān)系和結(jié)果緩存。
特點(diǎn):
1.實(shí)現(xiàn)全流程緩存,將每個(gè) Processor 的輸入參數(shù)、輸出結(jié)果寫入緩存,在有緩存情況下二次編譯時(shí)長可減少 90% 。
2.支持按需編譯,每次按需單頁編譯、增量編譯、全量編譯 都走同樣的 Processor 處理流程。
3.通過 Proxy 機(jī)制自動計(jì)算緩存參數(shù)依賴,不用手動為每個(gè) Processor 生成緩存 hash,相對于 webpack 或 parcel 減少 bug 產(chǎn)生。
4.僅維護(hù) Processor 依賴關(guān)系,不維護(hù) ModuleGraph,簡化處理流程。
關(guān)于全流程緩存每家打包器都有自己的實(shí)現(xiàn)方案,基本原理是根據(jù)當(dāng)前輸入?yún)?shù)和依賴情況為處理單元生成一個(gè)唯一 hash,hash 一致則結(jié)果一致。
webpack 和 parcel 由于維護(hù)了 ModuleGraph,緩存的計(jì)算和重用會復(fù)雜一些。小程序編譯器僅根據(jù) Processor 入?yún)⒑驼{(diào)用依賴進(jìn)行計(jì)算。
3.2?Processor 單元處理器
Processor 有如下特性:
1.在輸入?yún)?shù)一致的情況下,保證輸出一致,輸入和輸出都必須可序列化為 json ,實(shí)現(xiàn)了 Processor 全緩存。
2.Processor 中的 uri 為構(gòu)建 ID,在單次構(gòu)建過程中 ID 一致則處理結(jié)果一致,例如處理 app.js 文件,uri 為:js:app.js,好處是可以統(tǒng)一 Processor 資源處理路徑。
3.Processor 之間支持互相調(diào)用:processWith 調(diào)用并繼續(xù)執(zhí)行,processWithResult 調(diào)用并等待返回結(jié)果。
注意:這里的輸入?yún)?shù)包含 uri、app config, contextFreeData。
幾種常用的 Processor:
1.JS Processor 將 es6 代碼轉(zhuǎn)換成 es5 代碼,這是最耗時(shí)的模塊。
2.Swan Processor 將 swan 模板代碼轉(zhuǎn)換成 view 層 js 代碼。
3.Css Processor 使用 postcss 處理 css 中的單位轉(zhuǎn)換、依賴收集等工作。
4.Bundle Processor 將前面 transformer 處理結(jié)果按照 bundle 算法合并文件并輸出結(jié)果。
Processor 工作流程:
Processor 處理流程需要經(jīng)過 transform -> bundle 的過程,在小程序里 js, css, swan 模板的 bundle 可以分開并行處理,這里和 webpack 的處理模式不一樣,和 parcel 的 pipeline 類似。
3.3?性能和產(chǎn)物優(yōu)化方法
3.3.1 多核心編譯優(yōu)化
由于 Node 中多線程模塊初始化速度和通信效率比多進(jìn)程好一些,新編譯選擇使用 多線程 做多核心優(yōu)化。
多線程編譯有 2 種方案選擇:
方案1:基于 processor 做多線程調(diào)度,由于 processor 間支持相互調(diào)用,實(shí)際處理會很復(fù)雜且有通信成本。
舊的編譯器做過基于webpack 的 workerthread-loader,性能提升有限(10%~15%)。
parcel 基于 lmdb 公共緩存消除線程間通信,保證讀寫效率,是一個(gè)比較好的解決方法。
方案2:僅對 js 轉(zhuǎn)譯做多線程調(diào)度,僅有一來一回 2 次通信成本。
使用 jest-worker 和 babel transform 做 js 多線程轉(zhuǎn)譯或者用 swc 多線程做 js 轉(zhuǎn)譯。
由于大部分構(gòu)建時(shí)間在 js 轉(zhuǎn)譯這里(js 中有大量 node_modules 依賴,均需要轉(zhuǎn)換),css 和 swan 模塊轉(zhuǎn)換耗時(shí)少。
最終選擇方案2 僅做 js 多線程轉(zhuǎn)譯,處理流程簡單且收益較好,整體提升如下:
使用 jest-worker 多線程 babel 轉(zhuǎn)譯,4 線程可提升 1 倍以上速度。
使用 swc 做 js 轉(zhuǎn)譯,4 線程提升 4 倍以上速度。
JS Processor 多線程處理:
其中:
uri:?為處理器構(gòu)建 ID
contextFreeData:?單次構(gòu)建中不可變數(shù)據(jù),例如 app.json 中的配置項(xiàng)
context args:全局參數(shù),例如優(yōu)化實(shí)驗(yàn)開關(guān)、多線程開關(guān)等
在 js 轉(zhuǎn)換處理時(shí)規(guī)定了 transformer 統(tǒng)一轉(zhuǎn)換接口,基于接口實(shí)現(xiàn)了 babel 單線程、babel 多線程、swc 轉(zhuǎn)換 3 種處理器,并且可隨時(shí)做處理器切換。
對于不同的編譯環(huán)境可以做到靈活設(shè)置:
1.開發(fā)者工具中開發(fā)者根據(jù)機(jī)器配置情況可以切換 多線程、swc 編譯模式,提升效率。
2.云編譯流水線默認(rèn)開多線程編譯提高性能。
3.webIDE 默認(rèn)開單線程降低資源消耗。
3.3.2 SWC 編譯優(yōu)化
新編譯器多線程模式相對于舊編譯提升了 1 倍左右,在 dev 開發(fā)時(shí)一些大型項(xiàng)目頁面首次編譯還是有些慢,需要10秒以上,主要耗時(shí)在 js transform 這里。
swc 目前在 js 轉(zhuǎn)譯上基本成熟了,且大部分場景能提升 4 倍以上轉(zhuǎn)譯速度,因此增加了 swc 多線程轉(zhuǎn)譯支持,將大型項(xiàng)目頁面首次編譯控制在了 5 秒以內(nèi)。
需要編寫 2 個(gè) swc 插件來適配 swc 轉(zhuǎn)譯:
@swanide/swc-require-rename 將 require/import/export 中的模塊提取路徑信息,以便于后續(xù)在 js 中分析模塊依賴關(guān)系。
@swanide/swc-web-debug 對 js 代碼進(jìn)行插樁處理,用來支持真機(jī)調(diào)試中的斷點(diǎn)調(diào)試。
swc 編譯帶來的性能提升是巨大的,在使用中也發(fā)現(xiàn)了一些問題:
1.swc 存在內(nèi)存泄露,在 dev 階段如果全量編譯次數(shù)過多,會導(dǎo)致內(nèi)存占用很高,需手動重啟編譯器。
2.swc 插件支持的 api 較少,一部分 babel 容易實(shí)現(xiàn)的功能,在 swc 中很難處理。
3.swc 由于使用 rust 編寫插件,插件在不同 @swc/core 版本間不能通用,需要為不同平臺生成 swc 插件,在部署上會麻煩一些。
在實(shí)際使用中,對于一部分 swc 不能很好處理的場景,會降級到 babel 處理。
3.3.3 代碼壓縮和運(yùn)行時(shí)緩存
在 dev 階段,編譯后的代碼是沒有經(jīng)過壓縮的,可以在模擬器中運(yùn)行。在預(yù)覽發(fā)布階段由于限制了包體積,需要做代碼壓縮以減少產(chǎn)物體積。
可選的代碼壓縮工具有如下 3 個(gè):
1.terser 壓縮率高,產(chǎn)物體積小,速度最慢。
2.swc 壓縮快,mangle 支持不完善,壓縮率較差。
3.esbuild 壓縮最快(比 terser 快了 10 倍以上),支持 mangle,代碼壓縮率不如 terser。
最后經(jīng)過對比考慮,選擇了如下壓縮方案:
1.預(yù)覽階段由于不需要 sourcemap,移除 sourcemap,并使用 esbuild 做代碼壓縮,提高預(yù)覽速度(對于自動預(yù)覽場景有很大提升)。
2.發(fā)布階段使用 terser 做多線程壓縮,并保留 sourcemap。
運(yùn)行時(shí)緩存 指的是構(gòu)建過程的中間結(jié)果都在內(nèi)存中做了緩存,包括 Processor 處理結(jié)果 和 代碼壓縮結(jié)果,在二次構(gòu)建時(shí)可以節(jié)省大部分重新構(gòu)建時(shí)間。由于緩存中保留的是字符串和 json 對象,相對于基于 webpack 的舊版編譯器有 40% ~ 60% 的內(nèi)存節(jié)省,在內(nèi)存占用上處于可接受范圍。
3.3.4 Swan 模板處理優(yōu)化
舊的 swan 模板處理使用 swan-loader 進(jìn)行模板轉(zhuǎn)換,由于設(shè)計(jì)時(shí)沒有處理好模板 import 作用域,導(dǎo)致 標(biāo)簽以及 filter 過濾器函數(shù)只能內(nèi)聯(lián)到頁面代碼中,如果模板中大量使用了 template 和 filter,最終生成的代碼體積會非常大。
新編編譯器糾正了 import 作用域關(guān)系,將編譯產(chǎn)物中的 template 、 filter 生成模式由內(nèi)聯(lián)改為 require 引用,然后在 bundle 階段做代碼合并,使相同模塊能夠得到重用,算是填了一個(gè)大坑。
新編譯器 swan 模板處理流程:
單個(gè) swan 文件經(jīng)過 Processor 處理后可能的產(chǎn)物有:
component 組件模塊,用于生成頁面和自定義組件
template 模塊
filter 過濾器函數(shù)、sjs 過濾器函數(shù)
transformed document 中間代碼
將 swan 模板轉(zhuǎn)換成不同類型的 js module,并維護(hù)依賴關(guān)系,便于后續(xù)的代碼合并時(shí)更精細(xì)化的控制。
由于歷史原因 import/include 中包含 sjs 或者 template 引用時(shí)不能直接生成 template 模塊,需要在最后入口模板中生成。新編譯也提供了 template靜態(tài)編譯選項(xiàng),將嚴(yán)格限制 import 作用域,可直接生成 template 模塊代碼,對于 taro 生成的小程序項(xiàng)目可以節(jié)約 30% 左右的產(chǎn)物大小。
3.3.5 Sourcemap 優(yōu)化
由于編譯器需要支持 js 代碼調(diào)試以及運(yùn)行時(shí) error 跟蹤,在 dev 和發(fā)布階段都需要生成 sourcemap。
在 webpack 中生成代碼時(shí)需要對 sourcemap 進(jìn)行合并計(jì)算,較大的項(xiàng)目 sourcemap 合并會占用很長時(shí)間,并且每次重新編譯都要重新計(jì)算 sourcemap。
調(diào)研時(shí)發(fā)現(xiàn)瀏覽器 devtools 對? sourcemap 協(xié)議?的 index map 支持非常好, 新編譯器基于 index map 協(xié)議做了 sourcemap 合并優(yōu)化,由之前的多文件 sourcemap 合并計(jì)算,變成了計(jì)算生成 offset map 并拼接內(nèi)容,這樣 js bundle 耗時(shí)就由原來的 幾秒到幾十秒變?yōu)榱斯潭?3 秒以內(nèi)。?
一個(gè)有意思的事情是 vscode 的 js-debugger 直到 22 年 6 月份才支持 index map 調(diào)試(index map 2011 年發(fā)布的),微軟的動作稍微慢了一些。
3.3.6 后續(xù)工作
在新編譯器開發(fā)完成之后的推廣中,采用了漸進(jìn)式推廣方式:
第一階段,開發(fā)者工具新舊編譯器共存,dev、預(yù)覽使用新編譯器,發(fā)布使用舊編譯器。
第二階段,內(nèi)部 pipeline 預(yù)覽和發(fā)布全量使用新編譯。
第三階段,開發(fā)者工具全部切換到新編譯器。
新版編譯實(shí)際上線后還存在一些小的兼容性問題,需要盡量提前暴露問題才能做發(fā)布全量替換。
針對小程序項(xiàng)目,新編譯做了大量的優(yōu)化工作,部分優(yōu)化工作還沒有完成開發(fā),包括:
hmr 熱重載:開發(fā)中,由于 運(yùn)行時(shí)框架、開發(fā)者工具均需要做接口適配,需要較長時(shí)間調(diào)試才能達(dá)到預(yù)期。
tree-shaking 代碼消除:對于 es6 模塊在 transform 階段可以做 tree-shaking 消減代碼。
scope-hoisting?作用域提升:理論可行,需要驗(yàn)證代碼縮減效果。
新版編譯器由于需要完全兼容舊版編譯器構(gòu)建結(jié)果,在 bundle 打包場景還存在優(yōu)化空間,我們在后續(xù)工作中配合運(yùn)行時(shí)框架可以做更多打包產(chǎn)物優(yōu)化。
GEEK TALK
04
總結(jié)
新版編譯器采用自研打包方案,對比基于 webpack 的舊編譯器實(shí)現(xiàn)了巨大的性能提升,徹底解決了編譯慢、資源占用高的問題,相對友商的編譯器也有不錯(cuò)的性能優(yōu)勢。
一些新編譯引入的優(yōu)化手段如 swc 轉(zhuǎn)譯、esbuild 壓縮、sourcemap 優(yōu)化 也能用在其他前端項(xiàng)目構(gòu)建中,并起到加速效果。
在新編譯器項(xiàng)目中每個(gè)同學(xué)都非常努力,貢獻(xiàn)了很多奇妙的點(diǎn)子,遇到的大部分難題都有效解決了。我們會繼續(xù)堅(jiān)持性能和產(chǎn)物優(yōu)化這兩個(gè)方向,不斷提升開發(fā)者體驗(yàn)和運(yùn)行時(shí)效率。
編輯:黃飛
?
評論
查看更多