用过 webpack 的同学必定晓得 webpack-budle-analyzer ,能够用来剖析以后我的项目 js 文件的依赖关系。

因为最近始终在做小程序业务,而且小程序对包体大小特地敏感,所以就想着能不能做一个相似的工具,用来查看以后小程序各个主包与分包之间的依赖关系。通过几天的折腾终于做进去了,成果如下:

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

小程序入口

小程序的页面通过 app.jsonpages 参数定义,且 pages 内的每个页面,小程序都会去寻找对应的 .json, .js, .wxml, .wxss 四个文件进行解决。

为了不便演示,咱们想到 fork 一份小程序的官网demo,而后新建一个文件 depend.js,依赖剖析相干的工作就在这个文件外面实现。

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

其大抵的目录构造如下:

app.json 为入口,咱们能够获取所有主包下的页面。

const fs = require('fs')const path = require('path')const root = process.cwd()class Depend {  constructor() {    this.context = path.join(root, 'miniprogram')  }  // 获取相对地址  getAbsolute(file) {    return path.join(this.context, file)  }  run() {    const appPath = this.getAbsolute('app.json')    const appFile = fs.readFileSync(appPath, 'utf-8')    const appJson = JSON.parse(appFile)    const { pages } = appJson // 主包的所有页面  }}

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

const Extends = ['.js', '.json', '.wxml', '.wxss']class Depend {  constructor() {    // 存储文件    this.files = new Set()    this.context = path.join(root, 'miniprogram')  }  // 批改文件后缀  replaceExt(filePath, ext = '') {    const dirName = path.dirname(filePath)    const extName = path.extname(filePath)    const fileName = path.basename(filePath, extName)    return path.join(dirName, fileName + ext)  }  run() {    // 省略获取 pages 过程    pages.forEach(page => {      // 获取相对地址      const absPath = this.getAbsolute(page)      Extends.forEach(ext => {        // 每个页面都须要判断 js、json、wxml、wxss 是否存在        const filePath = this.replaceExt(absPath, ext)        if (fs.existsSync(filePath)) {          this.files.add(filePath)        }      })    })  }}

当初 pages 内页面相干的文件都放到 files 字段存起来了。

结构树形构造

拿到文件后,咱们须要根据各个文件结构一个树形构造的文件树,用于后续展现依赖关系。

假如咱们有一个 pages 目录,pages 目录下有两个页面:detailindex ,这两个 页面文件夹下有四个对应的文件。

pages├── detail│   ├── detail.js│   ├── detail.json│   ├── detail.wxml│   └── detail.wxss└── index    ├── index.js    ├── index.json    ├── index.wxml    └── index.wxss

根据下面的目录构造,咱们结构一个如下的文件树结构,size 用于示意以后文件或文件夹的大小,children 寄存文件夹下的文件,如果是文件则没有 children 属性。

pages = {  "size": 8,  "children": {    "detail": {      "size": 4,      "children": {        "detail.js": { "size": 1 },        "detail.json": { "size": 1 },        "detail.wxml": { "size": 1 },        "detail.wxss": { "size": 1 }      }    },    "index": {      "size": 4,      "children": {        "index.js": { "size": 1 },        "index.json": { "size": 1 },        "index.wxml": { "size": 1 },        "index.wxss": { "size": 1 }      }    }  }}

咱们先在构造函数结构一个 tree 字段用来存储文件树的数据,而后咱们将每个文件都传入 addToTree 办法,将文件增加到树中 。

class Depend {  constructor() {    this.tree = {      size: 0,      children: {}    }    this.files = new Set()    this.context = path.join(root, 'miniprogram')  }    run() {    // 省略获取 pages 过程    pages.forEach(page => {      const absPath = this.getAbsolute(page)      Extends.forEach(ext => {        const filePath = this.replaceExt(absPath, ext)        if (fs.existsSync(filePath)) {          // 调用 addToTree          this.addToTree(filePath)        }      })    })  }}

接下来实现 addToTree 办法:

class Depend {  // 省略之前的局部代码  // 获取绝对地址  getRelative(file) {    return path.relative(this.context, file)  }  // 获取文件大小,单位 KB  getSize(file) {    const stats = fs.statSync(file)    return stats.size / 1024  }  // 将文件增加到树中  addToTree(filePath) {    if (this.files.has(filePath)) {      // 如果该文件曾经增加过,则不再增加到文件树中      return    }    const size = this.getSize(filePath)    const relPath = this.getRelative(filePath)    // 将文件门路转化成数组    // 'pages/index/index.js' =>    // ['pages', 'index', 'index.js']    const names = relPath.split(path.sep)    const lastIdx = names.length - 1    this.tree.size += size    let point = this.tree.children    names.forEach((name, idx) => {      if (idx === lastIdx) {        point[name] = { size }        return      }      if (!point[name]) {        point[name] = {          size, children: {}        }      } else {        point[name].size += size      }      point = point[name].children    })    // 将文件增加的 files    this.files.add(filePath)  }}

咱们能够在运行之后,将文件输入到 tree.json 看看。

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

获取依赖关系

下面的步骤看起来没什么问题,然而咱们短少了重要的一环,那就是咱们在结构文件树之前,还须要失去每个文件的依赖项,这样输入的才是小程序残缺的文件树。文件的依赖关系须要分成四局部来讲,别离是 .js, .json, .wxml, .wxss 这四种类型文件获取依赖的形式。

获取 .js 文件依赖

小程序反对 CommonJS 的形式进行模块化,如果开启了 es6,也能反对 ESM 进行模块化。咱们如果要取得一个 .js 文件的依赖,首先要明确,js 文件导入模块的三种写法,针对上面三种语法,咱们能够引入 Babel 来获取依赖。

import a from './a.js'export b from './b.js'const c = require('./c.js')

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

const { parse } = require('@babel/parser')const { default: traverse } = require('@babel/traverse')class Depend {  // ...    jsDeps(file) {    const deps = []    const dirName = path.dirname(file)    // 读取 js 文件内容    const content = fs.readFileSync(file, 'utf-8')    // 将代码转化为 AST    const ast = parse(content, {      sourceType: 'module',      plugins: ['exportDefaultFrom']    })    // 遍历 AST    traverse(ast, {      ImportDeclaration: ({ node }) => {        // 获取 import from 地址        const { value } = node.source        const jsFile = this.transformScript(dirName, value)        if (jsFile) {          deps.push(jsFile)        }      },      ExportNamedDeclaration: ({ node }) => {        // 获取 export from 地址        const { value } = node.source        const jsFile = this.transformScript(dirName, value)        if (jsFile) {          deps.push(jsFile)        }      },      CallExpression: ({ node }) => {        if (          (node.callee.name && node.callee.name === 'require') &&          node.arguments.length >= 1        ) {          // 获取 require 地址          const [{ value }] = node.arguments          const jsFile = this.transformScript(dirName, value)          if (jsFile) {            deps.push(jsFile)          }        }      }    })    return deps  }}

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

class Depend {  // 获取某个门路的脚本文件  transformScript(url) {    const ext = path.extname(url)    // 如果存在后缀,示意以后曾经是一个文件    if (ext === '.js' && fs.existsSync(url)) {      return url    }    // a/b/c => a/b/c.js    const jsFile = url + '.js'    if (fs.existsSync(jsFile)) {      return jsFile    }    // a/b/c => a/b/c/index.js    const jsIndexFile = path.join(url, 'index.js')    if (fs.existsSync(jsIndexFile)) {      return jsIndexFile    }    return null  }    jsDeps(file) {...}}

咱们能够创立一个 .js,看看输入的 deps 是否正确:

// 文件门路:/Users/shenfq/Code/fork/miniprogram-demo/import a from './a.js'export b from '../b.js'const c = require('../../c.js')

获取 .json 文件依赖

.json 文件自身是不反对模块化的,然而小程序能够通过 .json 文件导入自定义组件。

自定义组件与小程序页面一样,也会对应四个文件,所以咱们须要获取 .jsonusingComponents 内的所有依赖项,并判断每个组件对应的那四个文件是否存在,而后增加到依赖项内。

class Depend {  // ...  jsonDeps(file) {    const deps = []    const dirName = path.dirname(file)    const { usingComponents } = fs.readJsonSync(file)    if (usingComponents && typeof usingComponents === 'object') {      Object.values(usingComponents).forEach((component) => {        component = path.resolve(dirName, component)        // 每个组件都须要判断 js/json/wxml/wxss 文件是否存在        Extends.forEach((ext) => {          const file = this.replaceExt(component, ext)          if (fs.existsSync(file)) {            deps.push(file)          }        })      })    }    return deps  }}

获取 .wxml 文件依赖

wxml 提供两种文件援用形式 importinclude

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

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

const htmlparser2 = require('htmlparser2')class Depend {  // ...    wxmlDeps(file) {    const deps = []    const dirName = path.dirname(file)    const content = fs.readFileSync(file, 'utf-8')    const htmlParser = new htmlparser2.Parser({      onopentag(name, attribs = {}) {        if (name !== 'import' && name !== 'require') {          return        }        const { src } = attribs        if (src) {          return        }          const wxmlFile = path.resolve(dirName, src)        if (fs.existsSync(wxmlFile)) {            deps.push(wxmlFile)        }      }    })    htmlParser.write(content)    htmlParser.end()    return deps  }}

获取 .wxss 文件依赖

最初 wxss 文件导入款式和 css 语法统一,应用 @import 语句能够导入外联样式表。

@import "common.wxss";

能够通过 postcss 解析 wxss 文件,而后获取导入文件的地址,然而这里咱们偷个懒,间接通过简略的正则匹配来做。

class Depend {  // ...  wxssDeps(file) {    const deps = []    const dirName = path.dirname(file)    const content = fs.readFileSync(file, 'utf-8')    const importRegExp = /@import\s*['"](.+)['"];*/g    let matched    while ((matched = importRegExp.exec(content)) !== null) {      if (!matched[1]) {        continue      }      const wxssFile = path.resolve(dirName, matched[1])      if (fs.existsSync(wxmlFile)) {        deps.push(wxssFile)      }    }    return deps  }}

将依赖增加到树结构中

当初咱们须要批改 addToTree 办法。

class Depend {  addToTree(filePath) {    // 如果该文件曾经增加过,则不再增加到文件树中    if (this.files.has(filePath)) {      return    }    const relPath = this.getRelative(filePath)    const names = relPath.split(path.sep)    names.forEach((name, idx) => {      // ... 增加到树中    })    this.files.add(filePath)    // ===== 获取文件依赖,并增加到树中 =====    const deps = this.getDeps(filePath)    deps.forEach(dep => {      this.addToTree(dep)          })  }}

获取分包依赖

相熟小程序的同学必定晓得,小程序提供了分包机制。应用分包后,分包内的文件会被打包成一个独自的包,在用到的时候才会加载,而其余的文件则会放在主包,小程序关上的时候就会加载。

所以咱们在运行的时候,除了要拿到 pages 下的所有页面,还需拿到 subpackages 中所有的页面。因为之前只关怀主包的内容,this.tree 上面只有一颗文件树,当初咱们须要在 this.tree 下挂载多颗文件树,咱们须要先为主包创立一个独自的文件树,而后为每个分包创立一个文件树。

class Depend {  constructor() {    this.tree = {}    this.files = new Set()    this.context = path.join(root, 'miniprogram')  }  createTree(pkg) {    this.tree[pkg] = {      size: 0,      children: {}    }  }  addPage(page, pkg) {    const absPath = this.getAbsolute(page)    Extends.forEach(ext => {      const filePath = this.replaceExt(absPath, ext)      if (fs.existsSync(filePath)) {        this.addToTree(filePath, pkg)      }    })  }  run() {    const appPath = this.getAbsolute('app.json')    const appJson = fs.readJsonSync(appPath)    const { pages, subPackages, subpackages } = appJson        this.createTree('main') // 为主包创立文件树    pages.forEach(page => {      this.addPage(page, 'main')    })    // 因为 app.json 中 subPackages、subpackages 都能失效    // 所以咱们两个属性都获取,哪个存在就用哪个    const subPkgs = subPackages || subpackages    // 分包存在的时候才进行遍历    subPkgs && subPkgs.forEach(({ root, pages }) => {      root = root.split('/').join(path.sep)      this.createTree(root) // 为分包创立文件树      pages.forEach(page => {        this.addPage(`${root}${path.sep}${page}`, pkg)      })    })    // 输入文件树    fs.writeJSONSync('tree.json', this.tree, { spaces: 2 })  }}

addToTree 办法也须要进行批改,依据传入的 pkg 来判断将以后文件增加到哪个树。

class Depend {  addToTree(filePath, pkg = 'main') {    if (this.files.has(filePath)) {      // 如果该文件曾经增加过,则不再增加到文件树中      return    }    let relPath = this.getRelative(filePath)    if (pkg !== 'main' && relPath.indexOf(pkg) !== 0) {      // 如果该文件不是以分包名结尾,证实该文件不在分包内,      // 须要将文件增加到主包的文件树内      pkg = 'main'    }    const tree = this.tree[pkg] // 根据 pkg 取到对应的树    const size = this.getSize(filePath)    const names = relPath.split(path.sep)    const lastIdx = names.length - 1    tree.size += size    let point = tree.children    names.forEach((name, idx) => {      // ... 增加到树中    })    this.files.add(filePath)    // ===== 获取文件依赖,并增加到树中 =====    const deps = this.getDeps(filePath)    deps.forEach(dep => {      this.addToTree(dep)          })  }}

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

通过 EChart 画图

通过下面的流程后,最终咱们能够失去如下的一个 json 文件:

接下来,咱们利用 ECharts 的画图能力,将这个 json 数据以图表的模式展示进去。咱们能够在 ECharts 提供的实例中看到一个 Disk Usage 的案例,很合乎咱们的预期。

ECharts 的配置这里就不再赘述,依照官网的 demo 即可,咱们须要把 tree. json 的数据转化为 ECharts 须要的格局就行了,残缺的代码放到 codesandbod 了,去上面的线上地址就能看到成果了。

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

总结

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

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