查看原文
其他

【第2167期】埋点自动收集方案-路由依赖分析

刁文豪 前端早读课 2021-01-16

前言

前一段时间有看到相关需求,只不过在某团队已经开发完了。今日前端早读课文章由转转@刁文豪,公号:大转转FE授权分享。

@刁文豪,目前就职于转转平台运营部,负责C2C业务线前端工作。参与过两次创业,做过项目经理,喜欢挖掘业务痛点,同时深爱技术研究,致力于通过技术手段改进产品及用户体验、推动业务增长。

正文从这开始~~

对于这两个问题,我们先思考一会。

1、一个项目总共有多少组件?每个页面又有多少组件构成?

2、有哪些组件是公共组件,它们分别被哪些页面引用?

跟随这篇文章我们一起探讨下,希望能帮你找到答案。

背景

随着组件化思想深入人心,开发中遇到特定的功能模块或UI模块,我们便会想到抽成组件,高级一点的做法就是把多个页面相似的部分抽成公共的组件。

组件化的“诅咒”

但是往往对一件事物依赖越强,越容易陷入它的“诅咒”当中。当项目有越多的组件时,开发者越不容易建立它们之间的关系,特别当改动了某个组件的一行代码,甚至不能准确的判断由于这行代码变动,都影响了哪些页面。我暂且称之为“组件化的诅咒”。如果我们有个完整的组件依赖关系,就可以很好的解决这个问题。

我们以下面的场景为例,看一看依赖分析的重要性和必要性。

在整个方案中,埋点的数据源很重要,而数据源与页面的对应关系又是保证数据源完整性的关键。比如:首页和个人主页的商品流都采用相同的商品卡片,开发者自然会将商品卡片抽离为一个公共组件。如下:

  1. //Index.vue 首页

  2. import Card from './common/Card.vue' //依赖商品卡片组件


  3. //Home.vue 个人主页

  4. import Card from './common/Card.vue' //依赖商品卡片组件


  5. //Card.vue 商品卡片组件

  6. goDetail(item) {

  7. /**

  8. * @mylog 商品卡片点击

  9. */

  10. this.$log('card-click') // 埋点发送

  11. }

这就带来一个问题:商品卡片的点击信息(埋点的数据源),既可能是首页的,也可能是个人主页的,而jsdoc搜集埋点注释时,对这种归属情况的判断无能为力。所以必须找到一种方法可以拿到组件和页面的映射关系。

期望效果

项目中的实际依赖关系:

对应的依赖分析关系:(每个组件,与引用它的页面路由的映射)

方案思考

那么,怎么做依赖分析?在思考这个问题之前,我们先看一看有哪些常见的建立依赖的语法。

  1. //a.ts

  2. import B from './b.ts'

  3. import getCookie from '@/libs/cookie.ts'


  4. //c.ts

  5. const C = require('./b.ts')


  6. //b.ts

  7. div {

  8. background: url('./assets/icon.png') no-repeat;

  9. }

  10. import './style.css'

  11. // c.vue

  12. import Vue from Vue

  13. import Card from '@/component/Card.vue'

这里给出三种依赖分析的思路:

递归解析

从项目的路由配置文件开始,分别对每个路由页面,进行依赖的递归解析。这种思路想法简单直接,但实现起来可能较为繁琐,需要解析页面中所有形式的依赖关系。

借助webpack工具的统计分析数据,进行二次加工

实际项目中我们都是采用webpack打包工具,而它的一大特点就是会自动帮开发者做依赖分析(独立的enhanced-resolve库)。相较于第一种重写解析的方法,为何不站在webpack的肩膀上解决问题呢。

先来看下webpack的整体编译流程:

可以看到,每一个文件都会经过resolve阶段,最终在编译结束后,得到本次编译的统计分析信息。

  1. //done是compiler的钩子,在完成一次编译结束后的会执行

  2. compiler.hooks.done.tapAsync("demoPlugin",(stats,cb)=>{

  3. fs.writeFile(appRoot+'/stats.json', JSON.stringify(stats.toJson(),'','\t'), (err) => {

  4. if (err) {

  5. throw err;

  6. }

  7. })

  8. cb()

  9. })

详细的编译数据,就是done事件中的回调参数stats,经过处理后,大致如下:

通过对这份统计分析信息的二次加工和分析,也可以得到预期的依赖关系(插件webpack-bundle-analyzer也是基于这份数据生成的分析图表)。这份数据看上去更像基本chunk和module的依赖分析,对于组件或公共组件的依赖关系问题,需要对chunks和modules综合分析才能解决。同时我们还发现,这份数据的数据量相当大,且有大量开发者不关心的数据(截图是只有两个路由页面的情况下的数据量)。接下来讨论的方案是作者实际采用的方案,也是基于webpack,不同之处在于分析和收集依赖关系的时机。

在webpack的解析阶段,分析并收集依赖

我们看到虽然webpack的分析数据非常臃肿,但是它确实帮助开发者做了这份繁重的工作。只是我们希望能定制数据的范围,主动收集期望数据,所以推想,可否在每个文件解析阶段进行一定的“干预”,即通过条件判断或过滤筛选达成目的。那么问题来了,应该在resolve的哪个阶段进行“干预”,如何“干预”?

好,我们先要总览下webpack事件流过程:

很显然,afterResolve是每个文件解析阶段的最后,应该就从这里下手啦。

具体实现

先奉上流程图

初始化

首先这是一个webpack插件,在初始化阶段,指定解析的路由文件地址(比如src/route)以及排除解析的文件地址(比如src/lib、src/util),原因是这些排除的文件不会存在埋点数据。

收集依赖关系

在afterResolve钩子函数中,获取当前被解析文件的路径及其父级文件路径。

  1. apply(compiler) {

  2. compiler.hooks.normalModuleFactory.tap(

  3. "demoPlugin",

  4. nmf => {

  5. nmf.hooks.afterResolve.tapAsync(

  6. "demoPlugin",

  7. (result, callback) => {

  8. const { resourceResolveData } = result;

  9. // 当前文件的路径

  10. let path = resourceResolveData.path;

  11. // 父级文件路径

  12. let fatherPath = resourceResolveData.context.issuer;

  13. callback(null,result)

  14. }

  15. );

  16. }

  17. )

  18. }

建立依赖树

根据上一步获取的引用关系,生成依赖树。

  1. // 不是nodemodule中的文件,不是exclude中的文件,且为.js/.jsx/.ts/.tsx/.vue

  2. if(!skip(this.ignoreDependenciesArr,this.excludeRegArr,path, fatherPath) && matchFileType(path)){

  3. if(fatherPath && fatherPath != path){ // 父子路径相同的排除

  4. if(!(fatherPath.endsWith('js') || fatherPath.endsWith('ts')) || !(path.endsWith('js') || path.endsWith('ts'))){

  5. // 父子同为js文件,认为是路由文件的父子关系,而非组件,故排除

  6. let sonObj = {};

  7. sonObj.type = 'module';

  8. sonObj.path = path;

  9. sonObj.deps = []

  10. // 如果本次parser中的path,解析过,那么把过去的解析结果copy过来。

  11. sonObj = copyAheadDep(this.dependenciesArray,sonObj);

  12. let obj = checkExist(this.dependenciesArray,fatherPath,sonObj);

  13. this.dependenciesArray = obj.arr;

  14. if(!obj.fileExist){

  15. let entryObj = {type:'module',path:fatherPath,deps:[sonObj]};

  16. this.dependenciesArray.push(entryObj);

  17. }

  18. }

  19. } else if(!this.dependenciesArray.some(it => it.path == path)) {

  20. // 父子路径相同,且在this.dependenciesArray不存在,认为此文件为依赖树的根文件

  21. let entryObj = {type:'entry',path:path,deps:[]};

  22. this.dependenciesArray.push(entryObj);

  23. }

  24. }

那么这时生成的依赖树如下:

解析路由信息

通过上一步基本上得到组件的依赖树,但我们发现对于公共组件Card,它只存在首页的依赖中,却不见在个人主页的依赖中,这显然不符合预期(在第6步中专门解释)。那么接下来就要找寻,这个依赖树与路由信息的关系。

  1. compiler.hooks.done.tapAsync("RoutePathWebpackPlugin",(stats,cb)=>{

  2. this.handleCompilerDone()

  3. cb()

  4. })

  1. // ast解析路由文件

  2. handleCompilerDone(){

  3. if(this.dependenciesArray.length){

  4. let tempRouteDeps = {};

  5. // routePaths是项目的路由文件数组

  6. for(let i = 0; i < this.routePaths.length;i ++){

  7. let code = fs.readFileSync(this.routePaths[i],'utf-8');

  8. const tsParsedScript = ts.transpileModule(code, { compilerOptions: {target: 'ES6' }});

  9. code = tsParsedScript.outputText;

  10. let ast = Parser.parse(code,{'sourceType':'module',ecmaVersion:11});

  11. const walk = inject(acornWalk);

  12. let that = this;

  13. walk.ancestor(ast,{

  14. Literal(_, ancestors) {

  15. // 以下操作为获取单独的route配置文件中,name和页面的映射关系

  16. ……

  17. }

  18. }

  19. })

  20. }

  21. // 合并多个路由文件的映射关系

  22. let tempDeps = []

  23. for(let arr of Object.values(tempRouteDeps)){

  24. tempDeps = tempDeps.concat(arr)

  25. }

  26. this.routeDeps = tempDeps.filter(it=>it && Object.prototype.toString.call(it) == "[object Object]" && it.components);

  27. // 获取真实插件传入的router配置文件的依赖,除去main.js、filter.js、store.js等文件的依赖

  28. this.dependenciesArray =

  29. getRealRoutePathDependenciesArr(this.dependenciesArray,this.routePaths);

  30. }

  31. }

通过这一步ast解析,可以得到如下路由信息:

  1. [

  2. {

  3. "name": "index",

  4. "route": "/index",

  5. "title": "首页",

  6. "components": ["../view/newCycle/index.vue"]

  7. },

  8. {

  9. "name": "home",

  10. "route": "/home",

  11. "title": "个人主页",

  12. "components": ["../view/newCycle/home.vue"]

  13. }

  14. ]

对依赖树和路由信息进行整合分析
  1. // 将路由页面的所有依赖组件deps,都存放在路由信息的components数组中

  2. const getEndPathComponentsArr = function(routeDeps,dependenciesArray) {

  3. for(let i = 0; i < dependenciesArray.length; i ++){//可能存在多个路由配置文件

  4. let pageArr = dependenciesArray[i].deps;

  5. pageArr.forEach(page=>{

  6. routeDeps = routeDeps.map(routeObj=>{

  7. if(routeObj && routeObj.components){

  8. let relativePath =

  9. routeObj.components[0].slice(routeObj.components[0].indexOf('/')+1);

  10. if(page.path.includes(relativePath.split('/').join(path.sep))){

  11. // 铺平依赖树的层级

  12. routeObj = flapAllComponents(routeObj,page);

  13. // 去重操作

  14. routeObj.components = dedupe(routeObj.components);

  15. }

  16. }

  17. return routeObj;

  18. })

  19. })

  20. }

  21. return routeDeps;

  22. }

  23. //建立一个map数据结构,以每个组件为key,以对应的路由信息为value

  24. // {

  25. // 'path1' => Set { '/index' },

  26. // 'path2' => Set { '/index', '/home' },

  27. // 'path3' => Set { '/home' }

  28. // }

  29. const convertDeps = function(deps) {

  30. let map = new Map();

  31. ......

  32. return map;

  33. }

整合分析后依赖关系如下:

  1. {

  2. A: ["index&_&首页&_&index"],// A代表组件A的路径

  3. B: ["index&_&首页&_&index"],// B代表组件B的路径

  4. Card: ["index&_&首页&_&index"],

  5. // 映射中只有和首页的映射

  6. D: ["index&_&首页&_&index"],// D代表组件D的路径

  7. E: ["home&_&个人主页&_&home"],// E代表组件E的路径

  8. }

因为上一步依赖收集部分,Card组件并没有成功收集到个人主页的依赖中,所以这步整合分析也无法建立准确的映射关系。且看下面的解决。

修改unsafeCache配置

为什么公共组件Card在收集依赖的时候,只收集到一次?这个问题如果不解决,意味着只有首页的商品点击埋点被收集到,其他引用这个组件的页面商品点击就会丢失。有问题,就有机会,机会意味着解决问题的可能性。

webpack4提供了resolve的配置入口,开发者可以通过几项设置决定如何解析文件,比如extensions、alias等,其中有一个属性——unsafeCache成功引起了作者的注意,它正是问题的根结。

unsafeCache是webpack提高编译性能的优化措施。

unsafeCache默认为true,表示webpack会缓存已经解析过的文件依赖,待再次需要解析此文件时,直接从缓存中返回结果,避免重复解析。

我们看下源码:

  1. //webpack/lib/WebpackOptionsDefaulter.js

  2. this.set("resolveLoader.unsafeCache", true);

  3. //这是webpack初始化配置参数时对unsafeCache的默认设置


  4. //enhanced-resolve/lib/Resolverfatory.js

  5. if (unsafeCache) {

  6. plugins.push(

  7. new UnsafeCachePlugin(

  8. "resolve",

  9. cachePredicate,

  10. unsafeCache,

  11. cacheWithContext,

  12. "new-resolve"

  13. )

  14. );

  15. plugins.push(new ParsePlugin("new-resolve", "parsed-resolve"));

  16. } else {

  17. plugins.push(new ParsePlugin("resolve", "parsed-resolve"));

  18. }

  19. //前面已经提到,webpack将文件的解析独立为一个单独的库去做,那就是enhanced-resolve。

  20. //缓存的工作是由UnsafeCachePlugin完成,代码如下:

  21. //enhanced-resolve/lib/UnsafeCachePlugin.js

  22. apply(resolver) {

  23. const target = resolver.ensureHook(this.target);

  24. resolver

  25. .getHook(this.source)

  26. .tapAsync("UnsafeCachePlugin", (request, resolveContext, callback) => {

  27. if (!this.filterPredicate(request)) return callback();

  28. const cacheId = getCacheId(request, this.withContext);

  29. // !!划重点,当缓存中存在解析过的文件结果,直接callback

  30. const cacheEntry = this.cache[cacheId];

  31. if (cacheEntry) {

  32. return callback(null, cacheEntry);

  33. }

  34. resolver.doResolve(

  35. target,

  36. request,

  37. null,

  38. resolveContext,

  39. (err, result) => {

  40. if (err) return callback(err);

  41. if (result) return callback(null, (this.cache[cacheId] = result));

  42. callback();

  43. }

  44. );

  45. });

  46. }

在UnsafeCachePlugin的apply方法中,当判断有缓存过的文件结果,直接callback,没有继续后面的解析动作。

这对我们收集依赖有什么影响?

缓存了解析过的文件,意味着与这个文件再次相遇时,事件流将被提前终止,afterResolve的钩子自然也就不会执行到,那么我们的依赖关系就无从谈起。

其实webpack的resolve 过程可以看成事件的串联,当所有串联在一起的事件执行完之后,resolve 就结束了。我们看下原理:

用来解析文件的库是enhanced-resolve,在Resolverfatory生成resolver解析对象时,进行了大量plugins的注册,正是这些plugins形成一系列的解析事件。

  1. //enhanced-resolve/lib/Resolverfatory.js

  2. exports.createResolver = function(options) {

  3. ......

  4. let unsafeCache = options.unsafeCache || false;

  5. if (unsafeCache) {

  6. plugins.push(

  7. new UnsafeCachePlugin(

  8. "resolve",

  9. cachePredicate,

  10. unsafeCache,

  11. cacheWithContext,

  12. "new-resolve"

  13. )

  14. );

  15. plugins.push(new ParsePlugin("new-resolve", "parsed-resolve"));

  16. // 这里的事件流大致是:UnsafeCachePlugin的事件源(source)是resolve,

  17. //执行结束后的目标事件(target)是new-resolve。

  18. //而ParsePlugin的事件源为new-resolve,所以事件流机制刚好把这两个插件串联起来。

  19. } else {

  20. plugins.push(new ParsePlugin("resolve", "parsed-resolve"));

  21. }

  22. ...... // 各种plugin

  23. plugins.push(new ResultPlugin(resolver.hooks.resolved));


  24. plugins.forEach(plugin => {

  25. plugin.apply(resolver);

  26. });


  27. return resolver;

  28. }

每个插件在执行自己的逻辑后,都会调用resolver.doResolve(target, ...),其中的target是触发下一个插件的事件名称,如此往复,直到遇到事件源为result,递归终止,解析结束。

resolve的事件串联流程图大致如下:

UnsafeCachePlugin插件在第一次解析文件时,因为没有缓存,就会触发target为new-resolve的事件,也就是ParsePlugin,同时将解析结果记入缓存。当判断该文件有缓存结果,UnsafeCachePlugin的apply方法会直接callback,而没有继续执行resolver.doResolve(),意味着整个resolve事件流在UnsafeCachePlugin就终止了。这就解释了,为什么只建立了首页与Card组件的映射,而无法拿到个人主页与Card组件的映射。

解决办法

分析了原因后,就好办了,将unsafeCache设置为false(嗯,就这么简单)。这时你可能担心会降低工程编译速度,但深入一步想想,依赖分析这件事完全可以独立于开发阶段,只要在我们需要它的时候执行这个能力,比如由开发者通过命令行参数来控制。

  1. //package.json

  2. "analyse": "cross-env LEGO_ENV=analyse vue-cli-service build"


  3. //vue.config.js

  4. chainWebpack(config) {

  5. // 这一步解决webpack对组件缓存,影响最终映射关系的处理

  6. config.resolve.unsafeCache = process.env.LEGO_ENV != 'analyse'

  7. }

最终依赖关系
  1. {

  2. A: ["index&_&首页&_&index"],// A代表组件A的路径

  3. B: ["index&_&首页&_&index"],// B代表组件B的路径

  4. Card: ["index&_&首页&_&index",

  5. "home&_&个人主页&_&home"],

  6. // Card组件与多个页面有映射关系

  7. D: ["index&_&首页&_&index"],// D代表组件D的路径

  8. E: ["home&_&个人主页&_&home"],// E代表组件E的路径

  9. }

可以看到,与公共组件Card关联的映射页面中,多了个人主页的路由信息,这才是准确的依赖数据。在埋点自动收集项目中,这份依赖关系数据交由jsdoc处理,就可以完成所有埋点信息与页面的映射关系。

one more thing

webpack5,它来了,它带着持久化缓存策略来了。前面提到的unsafeCache虽然可以提升应用构建性能,但是它牺牲了一定的 resolving 准确度,同时它意味着持续性构建过程需要反复重新启动决断策略,这就要收集文件的寻找策略(resolutions)的变化,要识别判断文件 resolutions 是否变化,这一系列过程也是有成本的,这就是为什么叫unsafeCache,而不是safeCache(安全的)。

webpack5规定在配置信息的cache对象的type,可以设置为memory和fileSystem两种方式。memory是指之前的unsafeCache缓存,fileSystem是指相对安全的磁盘持久化缓存。

  1. module.exports = {

  2. cache: {

  3. // 1. Set cache type to filesystem

  4. type: 'filesystem',


  5. buildDependencies: {

  6. // 2. Add your config as buildDependency to get cache invalidation on config change

  7. config: [__filename]


  8. // 3. If you have other things the build depends on you can add them here

  9. // Note that webpack, loaders and all modules referenced from your config are automatically added

  10. }

  11. }

  12. };

所以针对webpack5,如果需要做完整的依赖分析,只需将cache.type动态设置为memory,resolve.unsafeCache设置为false即可。(感兴趣的童鞋可以试一试)

总结

以上,我们解释了组件化可能带来的隐患,提到了路由依赖分析的重要性,给出三种依赖分析的思路,并基于埋点自动收集项目重点阐述了其中一种方案的具体实现。在此与你分享,期待共同成长~

关于本文 作者:@刁文豪 原文:https://mp.weixin.qq.com/s/Y7i63EOm2JhWdIgXA2Gt1w

为你推荐


【第1922期】从vue-router看前端路由的两种实现


【第2154期】EMP微前端解决方案


欢迎自荐投稿,前端早读课等你来

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存