用过 webpack 的同窗确定知道 webpack-bundle-analyzer
,能够用来分析当前项目 js 文件的依赖关系。css
由于最近一直在作小程序业务,并且小程序对包体大小特别敏感,因此就想着能不能作一个相似的工具,用来查看当前小程序各个主包与分包之间的依赖关系。通过几天的折腾终于作出来了,效果以下:html
今天的文章就带你们来实现这个工具。vue
小程序的页面经过 app.json
的 pages
参数定义,用于指定小程序由哪些页面组成,每一项都对应一个页面的路径(含文件名) 信息。 pages
内的每一个页面,小程序都会去寻找对应的 json
, js
, wxml
, wxss
四个文件进行处理。node
如开发目录为:webpack
├── app.js ├── app.json ├── app.wxss ├── pages │ │── index │ │ ├── index.wxml │ │ ├── index.js │ │ ├── index.json │ │ └── index.wxss │ └── logs │ ├── logs.wxml │ └── logs.js └── utils
则须要在 app.json 中写:git
{ "pages": ["pages/index/index", "pages/logs/logs"] }
为了方便演示,咱们先 fork 一份小程序的官方demo,而后新建一个文件 depend.js
,依赖分析相关的工做就在这个文件里面实现。es6
$ git clone git@github.com:wechat-miniprogram/miniprogram-demo.git $ cd miniprogram-demo $ touch depend.js
其大体的目录结构以下:github
以 app.json
为入口,咱们能够获取全部主包下的页面。web
const fs = require('fs-extra') 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 appJson = fs.readJsonSync(appPath) const { pages } = appJson // 主包的全部页面 } }
每一个页面会对应 json
, js
, wxml
, wxss
四个文件:apache
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
目录下有两个页面:detail
、index
,这两个 页面文件夹下有四个对应的文件。
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
这四种类型文件获取依赖的方式。
小程序支持 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
文件经过 usingComponents
进行引用声明。usingComponents
为一个对象,键为自定义组件的标签名,值为自定义组件文件路径:
{ "usingComponents": { "component-tag-name": "path/to/the/custom/component" } }
自定义组件与小程序页面同样,也会对应四个文件,因此咱们须要获取 json
中 usingComponents
内的全部依赖项,并判断每一个组件对应的那四个文件是否存在,而后添加到依赖项内。
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 提供两种文件引用方式 import
和 include
。
<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 文件导入样式和 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) }) } }
熟悉小程序的同窗确定知道,小程序提供了分包机制。使用分包后,分包内的文件会被打包成一个单独的包,在用到的时候才会加载,而其余的文件则会放在主包,小程序打开的时候就会加载。subpackages
中,每一个分包的配置有如下几项:
字段 | 类型 | 说明 |
---|---|---|
root | String | 分包根目录 |
name | String | 分包别名,分包预下载时可使用 |
pages | StringArray | 分包页面路径,相对与分包根目录 |
independent | Boolean | 分包是不是独立分包 |
因此咱们在运行的时候,除了要拿到 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
文件夹下,则该文件须要放入主包的文件树内。
通过上面的流程后,最终咱们能够获得以下的一个 json 文件:
接下来,咱们利用 ECharts 的画图能力,将这个 json 数据以图表的形式展示出来。咱们能够在 ECharts 提供的实例中看到一个 Disk Usage 的案例,很符合咱们的预期。
ECharts 的配置这里就再也不赘述,按照官网的 demo 便可,咱们须要把 tree. json
的数据转化为 ECharts 须要的格式就好了,完整的代码放到 codesandbod 了,去下面的线上地址就能看到效果了。
线上地址: https://codesandbox.io/s/cold...
这篇文章比较偏实践,因此贴了不少的代码,另外本文对各个文件的依赖获取提供了一个思路,虽然这里只是用文件树构造了一个这样的依赖图。
在业务开发中,小程序 IDE 每次启动都须要进行全量的编译,开发版预览的时候会等待较长的时间,咱们如今有文件依赖关系后,就能够只选取目前正在开发的页面进行打包,这样就能大大提升咱们的开发效率。若是有对这部份内容感兴趣的,能够另外写一篇文章介绍下如何实现。