小程序依赖分析实践

网络 通信技术
在业务开发中,小程序 IDE 每次启动都需要进行全量的编译,开发版预览的时候会等待较长的时间,我们现在有文件依赖关系后,就可以只选取目前正在开发的页面进行打包,这样就能大大提高我们的开发效率。

[[350074]]

 用过 webpack 的同学肯定知道 webpack-bundle-analyzer ,可以用来分析当前项目 js 文件的依赖关系。

webpack-bundle-analyzer

 

因为最近一直在做小程序业务,而且小程序对包体大小特别敏感,所以就想着能不能做一个类似的工具,用来查看当前小程序各个主包与分包之间的依赖关系。经过几天的折腾终于做出来了,效果如下:

[[350075]]

小程序依赖关系

今天的文章就带大家来实现这个工具。

小程序入口

小程序的页面通过 app.json 的 pages 参数定义,用于指定小程序由哪些页面组成,每一项都对应一个页面的路径(含文件名) 信息。 pages 内的每个页面,小程序都会去寻找对应的 json, js, wxml, wxss 四个文件进行处理。

如开发目录为:

  1. ├── app.js 
  2. ├── app.json 
  3. ├── app.wxss 
  4. ├── pages 
  5. │   │── index 
  6. │   │   ├── index.wxml 
  7. │   │   ├── index.js 
  8. │   │   ├── index.json 
  9. │   │   └── index.wxss 
  10. │   └── logs 
  11. │       ├── logs.wxml 
  12. │       └── logs.js 
  13. └── utils 

则需要在 app.json 中写:

  1.   "pages": ["pages/index/index""pages/logs/logs"

为了方便演示,我们先 fork 一份小程序的官方demo,然后新建一个文件 depend.js,依赖分析相关的工作就在这个文件里面实现。

  1. $ git clone git@github.com:wechat-miniprogram/miniprogram-demo.git 
  2. $ cd miniprogram-demo 
  3. $ touch depend.js 

其大致的目录结构如下:

目录结构

以 app.json 为入口,我们可以获取所有主包下的页面。

  1. const fs = require('fs-extra'
  2. const path = require('path'
  3.  
  4. const root = process.cwd() 
  5.  
  6. class Depend { 
  7.   constructor() { 
  8.     this.context = path.join(root, 'miniprogram'
  9.   } 
  10.   // 获取绝对地址 
  11.   getAbsolute(file) { 
  12.     return path.join(this.context, file) 
  13.   } 
  14.   run() { 
  15.     const appPath = this.getAbsolute('app.json'
  16.     const appJson = fs.readJsonSync(appPath) 
  17.     const { pages } = appJson // 主包的所有页面 
  18.   } 

每个页面会对应 json, js, wxml, wxss 四个文件:

  1. const Extends = ['.js''.json''.wxml''.wxss'
  2. class Depend { 
  3.   constructor() { 
  4.     // 存储文件 
  5.     this.files = new Set() 
  6.     this.context = path.join(root, 'miniprogram'
  7.   } 
  8.   // 修改文件后缀 
  9.   replaceExt(filePath, ext = '') { 
  10.     const dirName = path.dirname(filePath) 
  11.     const extName = path.extname(filePath) 
  12.     const fileName = path.basename(filePath, extName) 
  13.     return path.join(dirName, fileName + ext) 
  14.   } 
  15.   run() { 
  16.     // 省略获取 pages 过程 
  17.     pages.forEach(page => { 
  18.       // 获取绝对地址 
  19.       const absPath = this.getAbsolute(page) 
  20.       Extends.forEach(ext => { 
  21.         // 每个页面都需要判断 js、json、wxml、wxss 是否存在 
  22.         const filePath = this.replaceExt(absPath, ext) 
  23.         if (fs.existsSync(filePath)) { 
  24.           this.files.add(filePath) 
  25.         } 
  26.       }) 
  27.     }) 
  28.   } 

现在 pages 内页面相关的文件都放到 files 字段存起来了。

构造树形结构

拿到文件后,我们需要依据各个文件构造一个树形结构的文件树,用于后续展示依赖关系。 

假设我们有一个 pages 目录,pages 目录下有两个页面:detail、index ,这两个 页面文件夹下有四个对应的文件。

  1. pages 
  2. ├── detail 
  3. │   ├── detail.js 
  4. │   ├── detail.json 
  5. │   ├── detail.wxml 
  6. │   └── detail.wxss 
  7. └── index 
  8.     ├── index.js 
  9.     ├── index.json 
  10.     ├── index.wxml 
  11.     └── index.wxss 

依据上面的目录结构,我们构造一个如下的文件树结构,size 用于表示当前文件或文件夹的大小,children 存放文件夹下的文件,如果是文件则没有 children 属性。

  1. pages = { 
  2.   "size": 8, 
  3.   "children": { 
  4.     "detail": { 
  5.       "size": 4, 
  6.       "children": { 
  7.         "detail.js": { "size": 1 }, 
  8.         "detail.json": { "size": 1 }, 
  9.         "detail.wxml": { "size": 1 }, 
  10.         "detail.wxss": { "size": 1 } 
  11.       } 
  12.     }, 
  13.     "index": { 
  14.       "size": 4, 
  15.       "children": { 
  16.         "index.js": { "size": 1 }, 
  17.         "index.json": { "size": 1 }, 
  18.         "index.wxml": { "size": 1 }, 
  19.         "index.wxss": { "size": 1 } 
  20.       } 
  21.     } 
  22.   } 

我们先在构造函数构造一个 tree 字段用来存储文件树的数据,然后我们将每个文件都传入 addToTree 方法,将文件添加到树中 。

  1. class Depend { 
  2.   constructor() { 
  3.     this.tree = { 
  4.       size: 0, 
  5.       children: {} 
  6.     } 
  7.     this.files = new Set() 
  8.     this.context = path.join(root, 'miniprogram'
  9.   } 
  10.    
  11.   run() { 
  12.     // 省略获取 pages 过程 
  13.     pages.forEach(page => { 
  14.       const absPath = this.getAbsolute(page) 
  15.       Extends.forEach(ext => { 
  16.         const filePath = this.replaceExt(absPath, ext) 
  17.         if (fs.existsSync(filePath)) { 
  18.           // 调用 addToTree 
  19.           this.addToTree(filePath) 
  20.         } 
  21.       }) 
  22.     }) 
  23.   } 

接下来实现 addToTree 方法:

  1. class Depend { 
  2.   // 省略之前的部分代码 
  3.  
  4.   // 获取相对地址 
  5.   getRelative(file) { 
  6.     return path.relative(this.context, file) 
  7.   } 
  8.   // 获取文件大小,单位 KB 
  9.   getSize(file) { 
  10.     const stats = fs.statSync(file) 
  11.     return stats.size / 1024 
  12.   } 
  13.  
  14.   // 将文件添加到树中 
  15.   addToTree(filePath) { 
  16.     if (this.files.has(filePath)) { 
  17.       // 如果该文件已经添加过,则不再添加到文件树中 
  18.       return 
  19.     } 
  20.     const size = this.getSize(filePath) 
  21.     const relPath = this.getRelative(filePath) 
  22.     // 将文件路径转化成数组 
  23.     // 'pages/index/index.js' => 
  24.     // ['pages''index''index.js'
  25.     const names = relPath.split(path.sep) 
  26.     const lastIdx = names.length - 1 
  27.  
  28.     this.tree.size += size 
  29.     let point = this.tree.children 
  30.     names.forEach((name, idx) => { 
  31.       if (idx === lastIdx) { 
  32.         point[name] = { size } 
  33.         return 
  34.       } 
  35.       if (!point[name]) { 
  36.         point[name] = { 
  37.           size, children: {} 
  38.         } 
  39.       } else { 
  40.         point[name].size += size 
  41.       } 
  42.       point = point[name].children 
  43.     }) 
  44.     // 将文件添加的 files 
  45.     this.files.add(filePath) 
  46.   } 

我们可以在运行之后,将文件输出到 tree.json 看看。

  1. run() { 
  2.   // ... 
  3.   pages.forEach(page => { 
  4.     //... 
  5.   }) 
  6.   fs.writeJSONSync('tree.json', this.tree, { spaces: 2 }) 

tree.json

获取依赖关系

上面的步骤看起来没什么问题,但是我们缺少了重要的一环,那就是我们在构造文件树之前,还需要得到每个文件的依赖项,这样输出的才是小程序完整的文件树。文件的依赖关系需要分成四部分来讲,分别是 js, json, wxml, wxss 这四种类型文件获取依赖的方式。

获取 .js 文件依赖

小程序支持 CommonJS 的方式进行模块化,如果开启了 es6,也能支持 ESM 进行模块化。我们如果要获得一个 js 文件的依赖,首先要明确,js 文件导入模块的三种写法,针对下面三种语法,我们可以引入 Babel 来获取依赖。

  1. import a from './a.js' 
  2. export b from './b.js' 
  3. const c = require('./c.js'

通过 @babel/parser 将代码转化为 AST,然后通过 @babel/traverse 遍历 AST 节点,获取上面三种导入方式的值,放到数组。

  1. const { parse } = require('@babel/parser'
  2. const { default: traverse } = require('@babel/traverse'
  3.  
  4. class Depend { 
  5.   // ... 
  6.  jsDeps(file) { 
  7.     const deps = [] 
  8.     const dirName = path.dirname(file) 
  9.     // 读取 js 文件内容 
  10.     const content = fs.readFileSync(file, 'utf-8'
  11.     // 将代码转化为 AST 
  12.     const ast = parse(content, { 
  13.       sourceType: 'module'
  14.       plugins: ['exportDefaultFrom'
  15.     }) 
  16.     // 遍历 AST 
  17.     traverse(ast, { 
  18.       ImportDeclaration: ({ node }) => { 
  19.         // 获取 import from 地址 
  20.         const { value } = node.source 
  21.         const jsFile = this.transformScript(dirName, value) 
  22.         if (jsFile) { 
  23.           deps.push(jsFile) 
  24.         } 
  25.       }, 
  26.       ExportNamedDeclaration: ({ node }) => { 
  27.         // 获取 export from 地址 
  28.         const { value } = node.source 
  29.         const jsFile = this.transformScript(dirName, value) 
  30.         if (jsFile) { 
  31.           deps.push(jsFile) 
  32.         } 
  33.       }, 
  34.       CallExpression: ({ node }) => { 
  35.         if ( 
  36.           (node.callee.name && node.callee.name === 'require') && 
  37.           node.arguments.length >= 1 
  38.         ) { 
  39.           // 获取 require 地址 
  40.           const [{ value }] = node.arguments 
  41.           const jsFile = this.transformScript(dirName, value) 
  42.           if (jsFile) { 
  43.             deps.push(jsFile) 
  44.           } 
  45.         } 
  46.       } 
  47.     }) 
  48.     return deps 
  49.   } 

在获取依赖模块的路径后,还不能立即将路径添加到依赖数组内,因为根据模块语法 js 后缀是可以省略的,另外 require 的路径是一个文件夹的时候,默认会导入该文件夹下的 index.js 。

  1. class Depend { 
  2.   // 获取某个路径的脚本文件 
  3.   transformScript(url) { 
  4.     const ext = path.extname(url) 
  5.     // 如果存在后缀,表示当前已经是一个文件 
  6.     if (ext === '.js' && fs.existsSync(url)) { 
  7.       return url 
  8.     } 
  9.     // a/b/c => a/b/c.js 
  10.     const jsFile = url + '.js' 
  11.     if (fs.existsSync(jsFile)) { 
  12.       return jsFile 
  13.     } 
  14.     // a/b/c => a/b/c/index.js 
  15.     const jsIndexFile = path.join(url, 'index.js'
  16.     if (fs.existsSync(jsIndexFile)) { 
  17.       return jsIndexFile 
  18.     } 
  19.     return null 
  20.   } 
  21.  jsDeps(file) {...} 

我们可以创建一个 js,看看输出的 deps 是否正确:

  1. // 文件路径:/Users/shenfq/Code/fork/miniprogram-demo/ 
  2. import a from './a.js' 
  3. export b from '../b.js' 
  4. const c = require('../../c.js'

image-20201101134549678

获取 .json 文件依赖

json 文件本身是不支持模块化的,但是小程序可以通过 json 文件导入自定义组件,只需要在页面的 json 文件通过 usingComponents 进行引用声明。usingComponents 为一个对象,键为自定义组件的标签名,值为自定义组件文件路径:

  1.   "usingComponents": { 
  2.     "component-tag-name""path/to/the/custom/component" 
  3.   } 

自定义组件与小程序页面一样,也会对应四个文件,所以我们需要获取 json 中 usingComponents 内的所有依赖项,并判断每个组件对应的那四个文件是否存在,然后添加到依赖项内。

  1. class Depend { 
  2.   // ... 
  3.   jsonDeps(file) { 
  4.     const deps = [] 
  5.     const dirName = path.dirname(file) 
  6.     const { usingComponents } = fs.readJsonSync(file) 
  7.     if (usingComponents && typeof usingComponents === 'object') { 
  8.       Object.values(usingComponents).forEach((component) => { 
  9.         component = path.resolve(dirName, component) 
  10.         // 每个组件都需要判断 js/json/wxml/wxss 文件是否存在 
  11.         Extends.forEach((ext) => { 
  12.           const file = this.replaceExt(component, ext) 
  13.           if (fs.existsSync(file)) { 
  14.             deps.push(file) 
  15.           } 
  16.         }) 
  17.       }) 
  18.     } 
  19.     return deps 
  20.   } 

获取 .wxml 文件依赖

wxml 提供两种文件引用方式 import 和 include。

  1. <import src="a.wxml"/> 
  2. <include src="b.wxml"/> 

wxml 文件本质上还是一个 html 文件,所以可以通过 html parser 对 wxml 文件进行解析,关于 html parser 相关的原理可以看我之前写过的文章 《Vue 模板编译原理》。

  1. const htmlparser2 = require('htmlparser2'
  2.  
  3. class Depend { 
  4.   // ... 
  5.  wxmlDeps(file) { 
  6.     const deps = [] 
  7.     const dirName = path.dirname(file) 
  8.     const content = fs.readFileSync(file, 'utf-8'
  9.     const htmlParser = new htmlparser2.Parser({ 
  10.       onopentag(name, attribs = {}) { 
  11.         if (name !== 'import' && name !== 'require') { 
  12.           return 
  13.         } 
  14.         const { src } = attribs 
  15.         if (src) { 
  16.           return 
  17.         } 
  18.        const wxmlFile = path.resolve(dirName, src) 
  19.         if (fs.existsSync(wxmlFile)) { 
  20.          deps.push(wxmlFile) 
  21.         } 
  22.       } 
  23.     }) 
  24.     htmlParser.write(content) 
  25.     htmlParser.end() 
  26.     return deps 
  27.   } 

获取 .wxss 文件依赖

最后 wxss 文件导入样式和 css 语法一致,使用 @import 语句可以导入外联样式表。

  1. @import "common.wxss"

可以通过 postcss 解析 wxss 文件,然后获取导入文件的地址,但是这里我们偷个懒,直接通过简单的正则匹配来做。

  1. class Depend { 
  2.   // ... 
  3.   wxssDeps(file) { 
  4.     const deps = [] 
  5.     const dirName = path.dirname(file) 
  6.     const content = fs.readFileSync(file, 'utf-8'
  7.     const importRegExp = /@import\s*['"](.+)['"];*/g 
  8.     let matched 
  9.     while ((matched = importRegExp.exec(content)) !== null) { 
  10.       if (!matched[1]) { 
  11.         continue 
  12.       } 
  13.       const wxssFile = path.resolve(dirName, matched[1]) 
  14.       if (fs.existsSync(wxmlFile)) { 
  15.         deps.push(wxssFile) 
  16.       } 
  17.     } 
  18.     return deps 
  19.   } 

获取 .wxss 文件依赖

最后 wxss 文件导入样式和 css 语法一致,使用 @import 语句可以导入外联样式表。

  1. class Depend { 
  2.   addToTree(filePath) { 
  3.     // 如果该文件已经添加过,则不再添加到文件树中 
  4.     if (this.files.has(filePath)) { 
  5.       return 
  6.     } 
  7.  
  8.     const relPath = this.getRelative(filePath) 
  9.     const names = relPath.split(path.sep) 
  10.     names.forEach((name, idx) => { 
  11.       // ... 添加到树中 
  12.     }) 
  13.     this.files.add(filePath) 
  14.  
  15.     // ===== 获取文件依赖,并添加到树中 ===== 
  16.     const deps = this.getDeps(filePath) 
  17.     deps.forEach(dep => { 
  18.       this.addToTree(dep)       
  19.     }) 
  20.   } 

获取分包依赖

熟悉小程序的同学肯定知道,小程序提供了分包机制。使用分包后,分包内的文件会被打包成一个单独的包,在用到的时候才会加载,而其他的文件则会放在主包,小程序打开的时候就会加载。subpackages 中,每个分包的配置有以下几项:

所以我们在运行的时候,除了要拿到 pages 下的所有页面,还需拿到 subpackages 中所有的页面。由于之前只关心主包的内容,this.tree 下面只有一颗文件树,现在我们需要在 this.tree 下挂载多颗文件树,我们需要先为主包创建一个单独的文件树,然后为每个分包创建一个文件树。

  1. class Depend { 
  2.   constructor() { 
  3.     this.tree = {} 
  4.     this.files = new Set() 
  5.     this.context = path.join(root, 'miniprogram'
  6.   } 
  7.   createTree(pkg) { 
  8.     this.tree[pkg] = { 
  9.       size: 0, 
  10.       children: {} 
  11.     } 
  12.   } 
  13.   addPage(page, pkg) { 
  14.     const absPath = this.getAbsolute(page) 
  15.     Extends.forEach(ext => { 
  16.       const filePath = this.replaceExt(absPath, ext) 
  17.       if (fs.existsSync(filePath)) { 
  18.         this.addToTree(filePath, pkg) 
  19.       } 
  20.     }) 
  21.   } 
  22.   run() { 
  23.     const appPath = this.getAbsolute('app.json'
  24.     const appJson = fs.readJsonSync(appPath) 
  25.     const { pages, subPackages, subpackages } = appJson 
  26.      
  27.     this.createTree('main') // 为主包创建文件树 
  28.     pages.forEach(page => { 
  29.       this.addPage(page, 'main'
  30.     }) 
  31.     // 由于 app.json 中 subPackages、subpackages 都能生效 
  32.     // 所以我们两个属性都获取,哪个存在就用哪个 
  33.     const subPkgs = subPackages || subpackages 
  34.     // 分包存在的时候才进行遍历 
  35.     subPkgs && subPkgs.forEach(({ root, pages }) => { 
  36.       root = root.split('/').join(path.sep) 
  37.       this.createTree(root) // 为分包创建文件树 
  38.       pages.forEach(page => { 
  39.         this.addPage(`${root}${path.sep}${page}`, pkg) 
  40.       }) 
  41.     }) 
  42.     // 输出文件树 
  43.     fs.writeJSONSync('tree.json', this.tree, { spaces: 2 }) 
  44.   } 

addToTree 方法也需要进行修改,根据传入的 pkg 来判断将当前文件添加到哪个树。

  1. class Depend { 
  2.   addToTree(filePath, pkg = 'main') { 
  3.     if (this.files.has(filePath)) { 
  4.       // 如果该文件已经添加过,则不再添加到文件树中 
  5.       return 
  6.     } 
  7.     let relPath = this.getRelative(filePath) 
  8.     if (pkg !== 'main' && relPath.indexOf(pkg) !== 0) { 
  9.       // 如果该文件不是以分包名开头,证明该文件不在分包内, 
  10.       // 需要将文件添加到主包的文件树内 
  11.       pkg = 'main' 
  12.     } 
  13.  
  14.     const tree = this.tree[pkg] // 依据 pkg 取到对应的树 
  15.     const size = this.getSize(filePath) 
  16.     const names = relPath.split(path.sep) 
  17.     const lastIdx = names.length - 1 
  18.  
  19.     tree.size += size 
  20.     let point = tree.children 
  21.     names.forEach((name, idx) => { 
  22.       // ... 添加到树中 
  23.     }) 
  24.     this.files.add(filePath) 
  25.  
  26.     // ===== 获取文件依赖,并添加到树中 ===== 
  27.     const deps = this.getDeps(filePath) 
  28.     deps.forEach(dep => { 
  29.       this.addToTree(dep)       
  30.     }) 
  31.   } 

这里有一点需要注意,如果 package/a 分包下的文件依赖的文件不在 package/a 文件夹下,则该文件需要放入主包的文件树内。

通过 EChart 画图

经过上面的流程后,最终我们可以得到如下的一个 json 文件:

tree.json

 

接下来,我们利用 ECharts 的画图能力,将这个 json 数据以图表的形式展现出来。我们可以在 ECharts 提供的实例中看到一个 Disk Usage 的案例,很符合我们的预期。

ECharts

 

ECharts 的配置这里就不再赘述,按照官网的 demo 即可,我们需要把 tree. json 的数据转化为 ECharts 需要的格式就行了,完整的代码放到 codesandbod 了,去下面的线上地址就能看到效果了。

线上地址:https://codesandbox.io/s/cold-dawn-kufc9

最后效果

总结

这篇文章比较偏实践,所以贴了很多的代码,另外本文对各个文件的依赖获取提供了一个思路,虽然这里只是用文件树构造了一个这样的依赖图。

 在业务开发中,小程序 IDE 每次启动都需要进行全量的编译,开发版预览的时候会等待较长的时间,我们现在有文件依赖关系后,就可以只选取目前正在开发的页面进行打包,这样就能大大提高我们的开发效率。如果有对这部分内容感兴趣的,可以另外写一篇文章介绍下如何实现。

 

责任编辑:姜华 来源: 更了不起的前端
相关推荐

2022-05-06 12:01:01

优化小程序

2023-04-14 10:29:24

小程序实践

2017-06-09 12:58:20

微信小程序架构分析

2017-06-09 10:06:54

微信小程序架构分析

2017-06-09 10:40:00

微信小程序架构分析

2021-09-14 19:01:56

ClickHouse京东小程序

2012-05-18 12:00:27

Fedora 17桌面程序

2024-02-20 13:08:00

2016-11-04 10:30:17

微信小程序

2019-06-21 10:40:25

微信小程序前端

2023-04-28 09:05:20

魔方基础流程

2017-01-10 17:38:37

微信小程序

2017-06-16 09:39:32

优酷实践阿里云

2017-05-08 15:03:07

微信小程序开发实战

2018-06-13 11:36:26

WeexUI渲染魅族

2017-01-18 17:25:46

小程序青雀

2017-01-12 10:38:04

TalkingData小程序

2023-12-27 19:12:42

OLAP自助分析

2017-01-10 10:51:06

腾讯云

2023-02-25 10:00:19

开源项目小程序
点赞
收藏

51CTO技术栈公众号