埋點(diǎn)自動(dòng)收集方案-路由依賴分析
1.一個(gè)項(xiàng)目總共有多少組件?每個(gè)頁(yè)面又有多少組件構(gòu)成?
2.有哪些組件是公共組件,它們分別被哪些頁(yè)面引用?
對(duì)于這兩個(gè)問題,我們先思考一會(huì)。sleep……
跟隨這篇文章我們一起探討下,希望能幫你找到答案。
背景
隨著組件化思想深入人心,開發(fā)中遇到特定的功能模塊或UI模塊,我們便會(huì)想到抽成組件,高級(jí)一點(diǎn)的做法就是把多個(gè)頁(yè)面相似的部分抽成公共的組件。
組件化的“詛咒”
但是往往對(duì)一件事物依賴越強(qiáng),越容易陷入它的“詛咒”當(dāng)中。當(dāng)項(xiàng)目有越多的組件時(shí),開發(fā)者越不容易建立它們之間的關(guān)系,特別當(dāng)改動(dòng)了某個(gè)組件的一行代碼,甚至不能準(zhǔn)確的判斷由于這行代碼變動(dòng),都影響了哪些頁(yè)面。我暫且稱之為“組件化的詛咒”。如果我們有個(gè)完整的組件依賴關(guān)系,就可以很好的解決這個(gè)問題。
我們以下面的場(chǎng)景為例,看一看依賴分析的重要性和必要性。
通過前一篇文章,想必大家對(duì)埋點(diǎn)自動(dòng)收集方案有了宏觀且全面的了解。在這里再簡(jiǎn)單概述下:
埋點(diǎn)自動(dòng)收集方案是基于jsdoc對(duì)注釋信息的搜集能力,通過給路由頁(yè)面中所有埋點(diǎn)增加注釋的方式,在編譯時(shí)建立起頁(yè)面和埋點(diǎn)信息的對(duì)應(yīng)關(guān)系。
點(diǎn)擊查看《埋點(diǎn)自動(dòng)收集方案-概述》
在整個(gè)方案中,埋點(diǎn)的數(shù)據(jù)源很重要,而數(shù)據(jù)源與頁(yè)面的對(duì)應(yīng)關(guān)系又是保證數(shù)據(jù)源完整性的關(guān)鍵。比如:首頁(yè)和個(gè)人主頁(yè)的商品流都采用相同的商品卡片,開發(fā)者自然會(huì)將商品卡片抽離為一個(gè)公共組件。如下:
//Index.vue?首頁(yè)
import?Card?from?'./common/Card.vue'?//依賴商品卡片組件
//Home.vue?個(gè)人主頁(yè)
import?Card?from?'./common/Card.vue'?//依賴商品卡片組件
//Card.vue?商品卡片組件
goDetail(item)?{
????/**
????*?@mylog?商品卡片點(diǎn)擊
????*/
????this.$log('card-click')?//?埋點(diǎn)發(fā)送
}
這就帶來一個(gè)問題:商品卡片的點(diǎn)擊信息(埋點(diǎn)的數(shù)據(jù)源),既可能是首頁(yè)的,也可能是個(gè)人主頁(yè)的,而jsdoc搜集埋點(diǎn)注釋時(shí),對(duì)這種歸屬情況的判斷無能為力。所以必須找到一種方法可以拿到組件和頁(yè)面的映射關(guān)系。
期望效果
項(xiàng)目中的實(shí)際依賴關(guān)系:

對(duì)應(yīng)的依賴分析關(guān)系:(每個(gè)組件,與引用它的頁(yè)面路由的映射)

方案思考
那么,怎么做依賴分析?在思考這個(gè)問題之前,我們先看一看有哪些常見的建立依賴的語(yǔ)法。
//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 遞歸解析
從項(xiàng)目的路由配置文件開始,分別對(duì)每個(gè)路由頁(yè)面,進(jìn)行依賴的遞歸解析。這種思路想法簡(jiǎn)單直接,但實(shí)現(xiàn)起來可能較為繁瑣,需要解析頁(yè)面中所有形式的依賴關(guān)系。
2 借助webpack工具的統(tǒng)計(jì)分析數(shù)據(jù),進(jìn)行二次加工
實(shí)際項(xiàng)目中我們都是采用webpack打包工具,而它的一大特點(diǎn)就是會(huì)自動(dòng)幫開發(fā)者做依賴分析(獨(dú)立的enhanced-resolve庫(kù))。相較于第一種重寫解析的方法,為何不站在webpack的肩膀上解決問題呢。
先來看下webpack的整體編譯流程:

可以看到,每一個(gè)文件都會(huì)經(jīng)過resolve階段,最終在編譯結(jié)束后,得到本次編譯的統(tǒng)計(jì)分析信息。
//done是compiler的鉤子,在完成一次編譯結(jié)束后的會(huì)執(zhí)行
compiler.hooks.done.tapAsync("demoPlugin",(stats,cb)=>{
??fs.writeFile(appRoot+'/stats.json',?JSON.stringify(stats.toJson(),'','\t'),?(err)?=>?{
??????if?(err)?{
??????????throw?err;
??????}
??})
??cb()
})
詳細(xì)的編譯數(shù)據(jù),就是done事件中的回調(diào)參數(shù)stats,經(jīng)過處理后,大致如下:

通過對(duì)這份統(tǒng)計(jì)分析信息的二次加工和分析,也可以得到預(yù)期的依賴關(guān)系(插件webpack-bundle-analyzer也是基于這份數(shù)據(jù)生成的分析圖表)。這份數(shù)據(jù)看上去更像基本chunk和module的依賴分析,對(duì)于組件或公共組件的依賴關(guān)系問題,需要對(duì)chunks和modules綜合分析才能解決。同時(shí)我們還發(fā)現(xiàn),這份數(shù)據(jù)的數(shù)據(jù)量相當(dāng)大,且有大量開發(fā)者不關(guān)心的數(shù)據(jù)(截圖是只有兩個(gè)路由頁(yè)面的情況下的數(shù)據(jù)量)。接下來討論的方案是作者實(shí)際采用的方案,也是基于webpack,不同之處在于分析和收集依賴關(guān)系的時(shí)機(jī)。
3 在webpack的解析階段,分析并收集依賴
我們看到雖然webpack的分析數(shù)據(jù)非常臃腫,但是它確實(shí)幫助開發(fā)者做了這份繁重的工作。只是我們希望能定制數(shù)據(jù)的范圍,主動(dòng)收集期望數(shù)據(jù),所以推想,可否在每個(gè)文件解析階段進(jìn)行一定的“干預(yù)”,即通過條件判斷或過濾篩選達(dá)成目的。那么問題來了,應(yīng)該在resolve的哪個(gè)階段進(jìn)行“干預(yù)”,如何“干預(yù)”?
好,我們先要總覽下webpack事件流過程:

很顯然,afterResolve是每個(gè)文件解析階段的最后,應(yīng)該就從這里下手啦。
具體實(shí)現(xiàn)
先奉上流程圖

1 初始化
首先這是一個(gè)webpack插件,在初始化階段,指定解析的路由文件地址(比如src/route)以及排除解析的文件地址(比如src/lib、src/util),原因是這些排除的文件不會(huì)存在埋點(diǎn)數(shù)據(jù)。
2 收集依賴關(guān)系
在afterResolve鉤子函數(shù)中,獲取當(dāng)前被解析文件的路徑及其父級(jí)文件路徑。
apply(compiler)?{
??compiler.hooks.normalModuleFactory.tap(
????"demoPlugin",
????nmf?=>?{
??????nmf.hooks.afterResolve.tapAsync(
????????"demoPlugin",
????????(result,?callback)?=>?{
??????????const?{?resourceResolveData?}?=?result;
??????????//?當(dāng)前文件的路徑
??????????let?path?=?resourceResolveData.path;?
??????????//?父級(jí)文件路徑
??????????let?fatherPath?=?resourceResolveData.context.issuer;?
??????????callback(null,result)
????????}
??????);
????}
??)
}
3 建立依賴樹
根據(jù)上一步獲取的引用關(guān)系,生成依賴樹。
//?不是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文件,認(rèn)為是路由文件的父子關(guān)系,而非組件,故排除
??????let?sonObj?=?{};
??????sonObj.type?=?'module';
??????sonObj.path?=?path;
??????sonObj.deps?=?[]
??????//?如果本次parser中的path,解析過,那么把過去的解析結(jié)果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不存在,認(rèn)為此文件為依賴樹的根文件
????let?entryObj?=?{type:'entry',path:path,deps:[]};
????this.dependenciesArray.push(entryObj);
??}
}
那么這時(shí)生成的依賴樹如下:

4 解析路由信息
通過上一步基本上得到組件的依賴樹,但我們發(fā)現(xiàn)對(duì)于公共組件Card,它只存在首頁(yè)的依賴中,卻不見在個(gè)人主頁(yè)的依賴中,這顯然不符合預(yù)期(在第6步中專門解釋)。那么接下來就要找尋,這個(gè)依賴樹與路由信息的關(guān)系。
compiler.hooks.done.tapAsync("RoutePathWebpackPlugin",(stats,cb)=>{
??this.handleCompilerDone()
??cb()
})
//?ast解析路由文件
handleCompilerDone(){
??if(this.dependenciesArray.length){
????let?tempRouteDeps?=?{};
????//?routePaths是項(xiàng)目的路由文件數(shù)組
????for(let?i?=?0;?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)?{
????????????????//?以下操作為獲取單獨(dú)的route配置文件中,name和頁(yè)面的映射關(guān)系
????????????????……
????????????????}
????????????}
????????})
????}
????//?合并多個(gè)路由文件的映射關(guān)系
????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);
????//?獲取真實(shí)插件傳入的router配置文件的依賴,除去main.js、filter.js、store.js等文件的依賴
????this.dependenciesArray?=?
????getRealRoutePathDependenciesArr(this.dependenciesArray,this.routePaths);
??}
}
通過這一步ast解析,可以得到如下路由信息:
[
??{
????"name":?"index",
????"route":?"/index",
????"title":?"首頁(yè)",
????"components":?["../view/newCycle/index.vue"]
??},
??{
????"name":?"home",
????"route":?"/home",
????"title":?"個(gè)人主頁(yè)",
????"components":?["../view/newCycle/home.vue"]
??}
]
5 對(duì)依賴樹和路由信息進(jìn)行整合分析
//?將路由頁(yè)面的所有依賴組件deps,都存放在路由信息的components數(shù)組中
const?getEndPathComponentsArr?=?function(routeDeps,dependenciesArray)?{
??for(let?i?=?0;?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))){
????????????//?鋪平依賴樹的層級(jí)
????????????routeObj?=?flapAllComponents(routeObj,page);
????????????//?去重操作
????????????routeObj.components?=?dedupe(routeObj.components);
??????????}
????????}
????????return?routeObj;
??????})
????})
??}
??return?routeDeps;
}
//建立一個(gè)map數(shù)據(jù)結(jié)構(gòu),以每個(gè)組件為key,以對(duì)應(yīng)的路由信息為value
//??{
//????'path1'?=>?Set?{?'/index'?},
//????'path2'?=>?Set?{?'/index',?'/home'?},
//????'path3'?=>?Set?{?'/home'?}
//??}
const?convertDeps?=?function(deps)?{
????let?map?=?new?Map();
????......
????return?map;
}
整合分析后依賴關(guān)系如下:
{
????A:?["index&_&首頁(yè)&_&index"],//?A代表組件A的路徑
????B:?["index&_&首頁(yè)&_&index"],//?B代表組件B的路徑
????Card:?["index&_&首頁(yè)&_&index"],
????//?映射中只有和首頁(yè)的映射
????D:?["index&_&首頁(yè)&_&index"],//?D代表組件D的路徑
????E:?["home&_&個(gè)人主頁(yè)&_&home"],//?E代表組件E的路徑
}
因?yàn)樯弦徊揭蕾囀占糠?,Card組件并沒有成功收集到個(gè)人主頁(yè)的依賴中,所以這步整合分析也無法建立準(zhǔn)確的映射關(guān)系。且看下面的解決。
6 修改unsafeCache配置
為什么公共組件Card在收集依賴的時(shí)候,只收集到一次?這個(gè)問題如果不解決,意味著只有首頁(yè)的商品點(diǎn)擊埋點(diǎn)被收集到,其他引用這個(gè)組件的頁(yè)面商品點(diǎn)擊就會(huì)丟失。有問題,就有機(jī)會(huì),機(jī)會(huì)意味著解決問題的可能性。
webpack4提供了resolve的配置入口,開發(fā)者可以通過幾項(xiàng)設(shè)置決定如何解析文件,比如extensions、alias等,其中有一個(gè)屬性——unsafeCache成功引起了作者的注意,它正是問題的根結(jié)。
6.1 unsafeCache是webpack提高編譯性能的優(yōu)化措施。
unsafeCache默認(rèn)為true,表示webpack會(huì)緩存已經(jīng)解析過的文件依賴,待再次需要解析此文件時(shí),直接從緩存中返回結(jié)果,避免重復(fù)解析。
我們看下源碼:
//webpack/lib/WebpackOptionsDefaulter.js
this.set("resolveLoader.unsafeCache",?true);
//這是webpack初始化配置參數(shù)時(shí)對(duì)unsafeCache的默認(rèn)設(shè)置
//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"));
}
//前面已經(jīng)提到,webpack將文件的解析獨(dú)立為一個(gè)單獨(dú)的庫(kù)去做,那就是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);
???//?!!劃重點(diǎn),當(dāng)緩存中存在解析過的文件結(jié)果,直接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方法中,當(dāng)判斷有緩存過的文件結(jié)果,直接callback,沒有繼續(xù)后面的解析動(dòng)作。
6.2 這對(duì)我們收集依賴有什么影響?
緩存了解析過的文件,意味著與這個(gè)文件再次相遇時(shí),事件流將被提前終止,afterResolve的鉤子自然也就不會(huì)執(zhí)行到,那么我們的依賴關(guān)系就無從談起。
其實(shí)webpack的resolve 過程可以看成事件的串聯(lián),當(dāng)所有串聯(lián)在一起的事件執(zhí)行完之后,resolve 就結(jié)束了。我們看下原理:
用來解析文件的庫(kù)是enhanced-resolve,在Resolverfatory生成resolver解析對(duì)象時(shí),進(jìn)行了大量plugins的注冊(cè),正是這些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,
??//執(zhí)行結(jié)束后的目標(biāo)事件(target)是new-resolve。
??//而ParsePlugin的事件源為new-resolve,所以事件流機(jī)制剛好把這兩個(gè)插件串聯(lián)起來。
?}?else?{
??plugins.push(new?ParsePlugin("resolve",?"parsed-resolve"));
?}
?......?//?各種plugin
?plugins.push(new?ResultPlugin(resolver.hooks.resolved));
?plugins.forEach(plugin?=>?{
??plugin.apply(resolver);
?});
?return?resolver;
}
每個(gè)插件在執(zhí)行自己的邏輯后,都會(huì)調(diào)用resolver.doResolve(target, ...),其中的target是觸發(fā)下一個(gè)插件的事件名稱,如此往復(fù),直到遇到事件源為result,遞歸終止,解析結(jié)束。
resolve的事件串聯(lián)流程圖大致如下:

UnsafeCachePlugin插件在第一次解析文件時(shí),因?yàn)闆]有緩存,就會(huì)觸發(fā)target為new-resolve的事件,也就是ParsePlugin,同時(shí)將解析結(jié)果記入緩存。當(dāng)判斷該文件有緩存結(jié)果,UnsafeCachePlugin的apply方法會(huì)直接callback,而沒有繼續(xù)執(zhí)行resolver.doResolve(),意味著整個(gè)resolve事件流在UnsafeCachePlugin就終止了。這就解釋了,為什么只建立了首頁(yè)與Card組件的映射,而無法拿到個(gè)人主頁(yè)與Card組件的映射。
6.3 解決辦法
分析了原因后,就好辦了,將unsafeCache設(shè)置為false(嗯,就這么簡(jiǎn)單)。這時(shí)你可能擔(dān)心會(huì)降低工程編譯速度,但深入一步想想,依賴分析這件事完全可以獨(dú)立于開發(fā)階段,只要在我們需要它的時(shí)候執(zhí)行這個(gè)能力,比如由開發(fā)者通過命令行參數(shù)來控制。
//package.json
"analyse":?"cross-env?LEGO_ENV=analyse?vue-cli-service?build"
//vue.config.js
chainWebpack(config)?{
????//?這一步解決webpack對(duì)組件緩存,影響最終映射關(guān)系的處理
????config.resolve.unsafeCache?=?process.env.LEGO_ENV?!=?'analyse'
}
7 最終依賴關(guān)系
{
????A:?["index&_&首頁(yè)&_&index"],//?A代表組件A的路徑
????B:?["index&_&首頁(yè)&_&index"],//?B代表組件B的路徑
????Card:?["index&_&首頁(yè)&_&index",
????"home&_&個(gè)人主頁(yè)&_&home"],
????//?Card組件與多個(gè)頁(yè)面有映射關(guān)系
????D:?["index&_&首頁(yè)&_&index"],//?D代表組件D的路徑
????E:?["home&_&個(gè)人主頁(yè)&_&home"],//?E代表組件E的路徑
}
可以看到,與公共組件Card關(guān)聯(lián)的映射頁(yè)面中,多了個(gè)人主頁(yè)的路由信息,這才是準(zhǔn)確的依賴數(shù)據(jù)。在埋點(diǎn)自動(dòng)收集項(xiàng)目中,這份依賴關(guān)系數(shù)據(jù)交由jsdoc處理,就可以完成所有埋點(diǎn)信息與頁(yè)面的映射關(guān)系。
one more thing
webpack5,它來了,它帶著持久化緩存策略來了。前面提到的unsafeCache雖然可以提升應(yīng)用構(gòu)建性能,但是它犧牲了一定的 resolving 準(zhǔn)確度,同時(shí)它意味著持續(xù)性構(gòu)建過程需要反復(fù)重新啟動(dòng)決斷策略,這就要收集文件的尋找策略(resolutions)的變化,要識(shí)別判斷文件 resolutions 是否變化,這一系列過程也是有成本的,這就是為什么叫unsafeCache,而不是safeCache(安全的)。
webpack5規(guī)定在配置信息的cache對(duì)象的type,可以設(shè)置為memory和fileSystem兩種方式。memory是指之前的unsafeCache緩存,fileSystem是指相對(duì)安全的磁盤持久化緩存。
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
????}
??}
};
所以針對(duì)webpack5,如果需要做完整的依賴分析,只需將cache.type動(dòng)態(tài)設(shè)置為memory,resolve.unsafeCache設(shè)置為false即可。(感興趣的童鞋可以試一試)
總結(jié)
以上,我們解釋了組件化可能帶來的隱患,提到了路由依賴分析的重要性,給出三種依賴分析的思路,并基于埋點(diǎn)自動(dòng)收集項(xiàng)目重點(diǎn)闡述了其中一種方案的具體實(shí)現(xiàn)。在此與你分享,期待共同成長(zhǎng)~
??愛心三連擊 1.看到這里了就點(diǎn)個(gè)在看支持下吧,你的「點(diǎn)贊,在看」是我創(chuàng)作的動(dòng)力。
2.關(guān)注公眾號(hào)
程序員成長(zhǎng)指北,回復(fù)「1」加入高級(jí)前端交流群!「在這里有好多 前端?開發(fā)者,會(huì)討論?前端 Node 知識(shí),互相學(xué)習(xí)」!3.也可添加微信【ikoala520】,一起成長(zhǎng)。
“在看轉(zhuǎn)發(fā)”是最大的支持
