1.一個項目總共有多少組件?每個頁面又有多少組件構成?
2.有哪些組件是公共組件,它們分別被哪些頁面引用?
對於這兩個問題,我們先思考一會。sleep……
跟隨這篇文章我們一起探討下,希望能幫你找到答案。
隨著組件化思想深入人心,開發中遇到特定的功能模塊或UI模塊,我們便會想到抽成組件,高級一點的做法就是把多個頁面相似的部分抽成公共的組件。
組件化的「詛咒」
但是往往對一件事物依賴越強,越容易陷入它的「詛咒」當中。當項目有越多的組件時,開發者越不容易建立它們之間的關係,特別當改動了某個組件的一行代碼,甚至不能準確的判斷由於這行代碼變動,都影響了哪些頁面。我暫且稱之為「組件化的詛咒」。如果我們有個完整的組件依賴關係,就可以很好的解決這個問題。
我們以下面的場景為例,看一看依賴分析的重要性和必要性。
通過前一篇文章,想必大家對埋點自動收集方案有了宏觀且全面的了解。在這裡再簡單概述下:
埋點自動收集方案是基於jsdoc對注釋信息的搜集能力,通過給路由頁面中所有埋點增加注釋的方式,在編譯時建立起頁面和埋點信息的對應關係。
點擊查看《埋點自動收集方案-概述》
在整個方案中,埋點的數據源很重要,而數據源與頁面的對應關係又是保證數據源完整性的關鍵。比如:首頁和個人主頁的商品流都採用相同的商品卡片,開發者自然會將商品卡片抽離為一個公共組件。如下:
//Index.vue 首頁
import Card from './common/Card.vue' //依賴商品卡片組件
//Home.vue 個人主頁
import Card from './common/Card.vue' //依賴商品卡片組件
//Card.vue 商品卡片組件
goDetail(item) {
/**
* @mylog 商品卡片點擊
*/
this.$log('card-click') // 埋點發送
}這就帶來一個問題:商品卡片的點擊信息(埋點的數據源),既可能是首頁的,也可能是個人主頁的,而jsdoc搜集埋點注釋時,對這種歸屬情況的判斷無能為力。所以必須找到一種方法可以拿到組件和頁面的映射關係。
項目中的實際依賴關係:
對應的依賴分析關係:(每個組件,與引用它的頁面路由的映射)那麼,怎麼做依賴分析?在思考這個問題之前,我們先看一看有哪些常見的建立依賴的語法。
//a.ts
import B from './b.ts'
import getCookie from '@/libs/cookie.ts'
//c.ts
const C = require('./b.ts')
//b.ts
div {
background: url('./assets/icon.png') no-repeat;
}
import './style.css'
// c.vue
import Vue from Vue
import Card from '@/component/Card.vue'這裡給出三種依賴分析的思路:
1 遞歸解析
從項目的路由配置文件開始,分別對每個路由頁面,進行依賴的遞歸解析。這種思路想法簡單直接,但實現起來可能較為繁瑣,需要解析頁面中所有形式的依賴關係。
2 藉助webpack工具的統計分析數據,進行二次加工
實際項目中我們都是採用webpack打包工具,而它的一大特點就是會自動幫開發者做依賴分析(獨立的enhanced-resolve庫)。相較於第一種重寫解析的方法,為何不站在webpack的肩膀上解決問題呢。
先來看下webpack的整體編譯流程:
可以看到,每一個文件都會經過resolve階段,最終在編譯結束後,得到本次編譯的統計分析信息。
//done是compiler的鉤子,在完成一次編譯結束後的會執行
compiler.hooks.done.tapAsync("demoPlugin",(stats,cb)=>{
fs.writeFile(appRoot+'/stats.json', JSON.stringify(stats.toJson(),'','\t'), (err) => {
if (err) {
throw err;
}
})
cb()
})詳細的編譯數據,就是done事件中的回調參數stats,經過處理後,大致如下:
通過對這份統計分析信息的二次加工和分析,也可以得到預期的依賴關係(插件webpack-bundle-analyzer也是基於這份數據生成的分析圖表)。這份數據看上去更像基本chunk和module的依賴分析,對於組件或公共組件的依賴關係問題,需要對chunks和modules綜合分析才能解決。同時我們還發現,這份數據的數據量相當大,且有大量開發者不關心的數據(截圖是只有兩個路由頁面的情況下的數據量)。接下來討論的方案是作者實際採用的方案,也是基於webpack,不同之處在於分析和收集依賴關係的時機。
3 在webpack的解析階段,分析並收集依賴
我們看到雖然webpack的分析數據非常臃腫,但是它確實幫助開發者做了這份繁重的工作。只是我們希望能定製數據的範圍,主動收集期望數據,所以推想,可否在每個文件解析階段進行一定的「幹預」,即通過條件判斷或過濾篩選達成目的。那麼問題來了,應該在resolve的哪個階段進行「幹預」,如何「幹預」?
好,我們先要總覽下webpack事件流過程:
很顯然,afterResolve是每個文件解析階段的最後,應該就從這裡下手啦。先奉上流程圖
1 初始化
首先這是一個webpack插件,在初始化階段,指定解析的路由文件地址(比如src/route)以及排除解析的文件地址(比如src/lib、src/util),原因是這些排除的文件不會存在埋點數據。
2 收集依賴關係
在afterResolve鉤子函數中,獲取當前被解析文件的路徑及其父級文件路徑。
apply(compiler) {
compiler.hooks.normalModuleFactory.tap(
"demoPlugin",
nmf => {
nmf.hooks.afterResolve.tapAsync(
"demoPlugin",
(result, callback) => {
const { resourceResolveData } = result;
// 當前文件的路徑
let path = resourceResolveData.path;
// 父級文件路徑
let fatherPath = resourceResolveData.context.issuer;
callback(null,result)
}
);
}
)
}3 建立依賴樹
根據上一步獲取的引用關係,生成依賴樹。
// 不是nodemodule中的文件,不是exclude中的文件,且為.js/.jsx/.ts/.tsx/.vue
if(!skip(this.ignoreDependenciesArr,this.excludeRegArr,path, fatherPath) && matchFileType(path)){
if(fatherPath && fatherPath != path){ // 父子路徑相同的排除
if(!(fatherPath.endsWith('js') || fatherPath.endsWith('ts')) || !(path.endsWith('js') || path.endsWith('ts'))){
// 父子同為js文件,認為是路由文件的父子關係,而非組件,故排除
let sonObj = {};
sonObj.type = 'module';
sonObj.path = path;
sonObj.deps = []
// 如果本次parser中的path,解析過,那麼把過去的解析結果copy過來。
sonObj = copyAheadDep(this.dependenciesArray,sonObj);
let obj = checkExist(this.dependenciesArray,fatherPath,sonObj);
this.dependenciesArray = obj.arr;
if(!obj.fileExist){
let entryObj = {type:'module',path:fatherPath,deps:[sonObj]};
this.dependenciesArray.push(entryObj);
}
}
} else if(!this.dependenciesArray.some(it => it.path == path)) {
// 父子路徑相同,且在this.dependenciesArray不存在,認為此文件為依賴樹的根文件
let entryObj = {type:'entry',path:path,deps:[]};
this.dependenciesArray.push(entryObj);
}
}那麼這時生成的依賴樹如下:
4 解析路由信息
通過上一步基本上得到組件的依賴樹,但我們發現對於公共組件Card,它只存在首頁的依賴中,卻不見在個人主頁的依賴中,這顯然不符合預期(在第6步中專門解釋)。那麼接下來就要找尋,這個依賴樹與路由信息的關係。
compiler.hooks.done.tapAsync("RoutePathWebpackPlugin",(stats,cb)=>{
this.handleCompilerDone()
cb()
})// ast解析路由文件
handleCompilerDone(){
if(this.dependenciesArray.length){
let tempRouteDeps = {};
// routePaths是項目的路由文件數組
for(let i = 0; i < this.routePaths.length;i ++){
let code = fs.readFileSync(this.routePaths[i],'utf-8');
const tsParsedScript = ts.transpileModule(code, { compilerOptions: {target: 'ES6' }});
code = tsParsedScript.outputText;
let ast = Parser.parse(code,{'sourceType':'module',ecmaVersion:11});
const walk = inject(acornWalk);
let that = this;
walk.ancestor(ast,{
Literal(_, ancestors) {
// 以下操作為獲取單獨的route配置文件中,name和頁面的映射關係
……
}
}
})
}
// 合併多個路由文件的映射關係
let tempDeps = []
for(let arr of Object.values(tempRouteDeps)){
tempDeps = tempDeps.concat(arr)
}
this.routeDeps = tempDeps.filter(it=>it && Object.prototype.toString.call(it) == "[object Object]" && it.components);
// 獲取真實插件傳入的router配置文件的依賴,除去main.js、filter.js、store.js等文件的依賴
this.dependenciesArray =
getRealRoutePathDependenciesArr(this.dependenciesArray,this.routePaths);
}
}通過這一步ast解析,可以得到如下路由信息:
[
{
"name": "index",
"route": "/index",
"title": "首頁",
"components": ["../view/newCycle/index.vue"]
},
{
"name": "home",
"route": "/home",
"title": "個人主頁",
"components": ["../view/newCycle/home.vue"]
}
]5 對依賴樹和路由信息進行整合分析
// 將路由頁面的所有依賴組件deps,都存放在路由信息的components數組中
const getEndPathComponentsArr = function(routeDeps,dependenciesArray) {
for(let i = 0; i < dependenciesArray.length; i ++){//可能存在多個路由配置文件
let pageArr = dependenciesArray[i].deps;
pageArr.forEach(page=>{
routeDeps = routeDeps.map(routeObj=>{
if(routeObj && routeObj.components){
let relativePath =
routeObj.components[0].slice(routeObj.components[0].indexOf('/')+1);
if(page.path.includes(relativePath.split('/').join(path.sep))){
// 鋪平依賴樹的層級
routeObj = flapAllComponents(routeObj,page);
// 去重操作
routeObj.components = dedupe(routeObj.components);
}
}
return routeObj;
})
})
}
return routeDeps;
}
//建立一個map數據結構,以每個組件為key,以對應的路由信息為value
// {
// 'path1' => Set { '/index' },
// 'path2' => Set { '/index', '/home' },
// 'path3' => Set { '/home' }
// }
const convertDeps = function(deps) {
let map = new Map();
.
return map;
}整合分析後依賴關係如下:
{
A: ["index&_&首頁&_&index"],// A代表組件A的路徑
B: ["index&_&首頁&_&index"],// B代表組件B的路徑
Card: ["index&_&首頁&_&index"],
// 映射中只有和首頁的映射
D: ["index&_&首頁&_&index"],// D代表組件D的路徑
E: ["home&_&個人主頁&_&home"],// E代表組件E的路徑
}因為上一步依賴收集部分,Card組件並沒有成功收集到個人主頁的依賴中,所以這步整合分析也無法建立準確的映射關係。且看下面的解決。
6 修改unsafeCache配置
為什麼公共組件Card在收集依賴的時候,只收集到一次?這個問題如果不解決,意味著只有首頁的商品點擊埋點被收集到,其他引用這個組件的頁面商品點擊就會丟失。有問題,就有機會,機會意味著解決問題的可能性。
webpack4提供了resolve的配置入口,開發者可以通過幾項設置決定如何解析文件,比如extensions、alias等,其中有一個屬性——unsafeCache成功引起了作者的注意,它正是問題的根結。
6.1 unsafeCache是webpack提高編譯性能的優化措施。
unsafeCache默認為true,表示webpack會緩存已經解析過的文件依賴,待再次需要解析此文件時,直接從緩存中返回結果,避免重複解析。
我們看下源碼:
//webpack/lib/WebpackOptionsDefaulter.js
this.set("resolveLoader.unsafeCache", true);
//這是webpack初始化配置參數時對unsafeCache的默認設置
//enhanced-resolve/lib/Resolverfatory.js
if (unsafeCache) {
plugins.push(
new UnsafeCachePlugin(
"resolve",
cachePredicate,
unsafeCache,
cacheWithContext,
"new-resolve"
)
);
plugins.push(new ParsePlugin("new-resolve", "parsed-resolve"));
} else {
plugins.push(new ParsePlugin("resolve", "parsed-resolve"));
}
//前面已經提到,webpack將文件的解析獨立為一個單獨的庫去做,那就是enhanced-resolve。
//緩存的工作是由UnsafeCachePlugin完成,代碼如下:
//enhanced-resolve/lib/UnsafeCachePlugin.js
apply(resolver) {
const target = resolver.ensureHook(this.target);
resolver
.getHook(this.source)
.tapAsync("UnsafeCachePlugin", (request, resolveContext, callback) => {
if (!this.filterPredicate(request)) return callback();
const cacheId = getCacheId(request, this.withContext);
// !!劃重點,當緩存中存在解析過的文件結果,直接callback
const cacheEntry = this.cache[cacheId];
if (cacheEntry) {
return callback(null, cacheEntry);
}
resolver.doResolve(
target,
request,
null,
resolveContext,
(err, result) => {
if (err) return callback(err);
if (result) return callback(null, (this.cache[cacheId] = result));
callback();
}
);
});
}在UnsafeCachePlugin的apply方法中,當判斷有緩存過的文件結果,直接callback,沒有繼續後面的解析動作。
6.2 這對我們收集依賴有什麼影響?
緩存了解析過的文件,意味著與這個文件再次相遇時,事件流將被提前終止,afterResolve的鉤子自然也就不會執行到,那麼我們的依賴關係就無從談起。
其實webpack的resolve 過程可以看成事件的串聯,當所有串聯在一起的事件執行完之後,resolve 就結束了。我們看下原理:
用來解析文件的庫是enhanced-resolve,在Resolverfatory生成resolver解析對象時,進行了大量plugins的註冊,正是這些plugins形成一系列的解析事件。
//enhanced-resolve/lib/Resolverfatory.js
exports.createResolver = function(options) {
.
let unsafeCache = options.unsafeCache || false;
if (unsafeCache) {
plugins.push(
new UnsafeCachePlugin(
"resolve",
cachePredicate,
unsafeCache,
cacheWithContext,
"new-resolve"
)
);
plugins.push(new ParsePlugin("new-resolve", "parsed-resolve"));
// 這裡的事件流大致是:UnsafeCachePlugin的事件源(source)是resolve,
//執行結束後的目標事件(target)是new-resolve。
//而ParsePlugin的事件源為new-resolve,所以事件流機制剛好把這兩個插件串聯起來。
} else {
plugins.push(new ParsePlugin("resolve", "parsed-resolve"));
}
. // 各種plugin
plugins.push(new ResultPlugin(resolver.hooks.resolved));
plugins.forEach(plugin => {
plugin.apply(resolver);
});
return resolver;
}每個插件在執行自己的邏輯後,都會調用resolver.doResolve(target, ...),其中的target是觸發下一個插件的事件名稱,如此往復,直到遇到事件源為result,遞歸終止,解析結束。
resolve的事件串聯流程圖大致如下:
UnsafeCachePlugin插件在第一次解析文件時,因為沒有緩存,就會觸發target為new-resolve的事件,也就是ParsePlugin,同時將解析結果記入緩存。當判斷該文件有緩存結果,UnsafeCachePlugin的apply方法會直接callback,而沒有繼續執行resolver.doResolve(),意味著整個resolve事件流在UnsafeCachePlugin就終止了。這就解釋了,為什麼只建立了首頁與Card組件的映射,而無法拿到個人主頁與Card組件的映射。6.3 解決辦法
分析了原因後,就好辦了,將unsafeCache設置為false(嗯,就這麼簡單)。這時你可能擔心會降低工程編譯速度,但深入一步想想,依賴分析這件事完全可以獨立於開發階段,只要在我們需要它的時候執行這個能力,比如由開發者通過命令行參數來控制。
//package.json
"analyse": "cross-env LEGO_ENV=analyse vue-cli-service build"
//vue.config.js
chainWebpack(config) {
// 這一步解決webpack對組件緩存,影響最終映射關係的處理
config.resolve.unsafeCache = process.env.LEGO_ENV != 'analyse'
}7 最終依賴關係
{
A: ["index&_&首頁&_&index"],// A代表組件A的路徑
B: ["index&_&首頁&_&index"],// B代表組件B的路徑
Card: ["index&_&首頁&_&index",
"home&_&個人主頁&_&home"],
// Card組件與多個頁面有映射關係
D: ["index&_&首頁&_&index"],// D代表組件D的路徑
E: ["home&_&個人主頁&_&home"],// E代表組件E的路徑
}可以看到,與公共組件Card關聯的映射頁面中,多了個人主頁的路由信息,這才是準確的依賴數據。在埋點自動收集項目中,這份依賴關係數據交由jsdoc處理,就可以完成所有埋點信息與頁面的映射關係。
one more thing
webpack5,它來了,它帶著持久化緩存策略來了。前面提到的unsafeCache雖然可以提升應用構建性能,但是它犧牲了一定的 resolving 準確度,同時它意味著持續性構建過程需要反覆重新啟動決斷策略,這就要收集文件的尋找策略(resolutions)的變化,要識別判斷文件 resolutions 是否變化,這一系列過程也是有成本的,這就是為什麼叫unsafeCache,而不是safeCache(安全的)。
webpack5規定在配置信息的cache對象的type,可以設置為memory和fileSystem兩種方式。memory是指之前的unsafeCache緩存,fileSystem是指相對安全的磁碟持久化緩存。
module.exports = {
cache: {
// 1. Set cache type to filesystem
type: 'filesystem',
buildDependencies: {
// 2. Add your config as buildDependency to get cache invalidation on config change
config: [__filename]
// 3. If you have other things the build depends on you can add them here
// Note that webpack, loaders and all modules referenced from your config are automatically added
}
}
};所以針對webpack5,如果需要做完整的依賴分析,只需將cache.type動態設置為memory,resolve.unsafeCache設置為false即可。(感興趣的童鞋可以試一試)
以上,我們解釋了組件化可能帶來的隱患,提到了路由依賴分析的重要性,給出三種依賴分析的思路,並基於埋點自動收集項目重點闡述了其中一種方案的具體實現。在此與你分享,期待共同成長~
1.看到這裡了就點個在看支持下吧,你的「點讚,在看」是我創作的動力。
2.關注公眾號
程式設計師成長指北,回復「1」加入高級前端交流群!「在這裡有好多 前端 開發者,會討論 前端 Node 知識,互相學習」!
3.也可添加微信【ikoala520】,一起成長。
「在看轉發」是最大的支持