小程序依賴分析實(shí)踐
用過 webpack 的同學(xué)肯定知道 webpack-bundle-analyzer ,可以用來分析當(dāng)前項(xiàng)目 js 文件的依賴關(guān)系。
webpack-bundle-analyzer
因?yàn)樽罱恢痹谧鲂〕绦驑I(yè)務(wù),而且小程序?qū)Πw大小特別敏感,所以就想著能不能做一個(gè)類似的工具,用來查看當(dāng)前小程序各個(gè)主包與分包之間的依賴關(guān)系。經(jīng)過幾天的折騰終于做出來了,效果如下:
小程序依賴關(guān)系
今天的文章就帶大家來實(shí)現(xiàn)這個(gè)工具。
小程序入口
小程序的頁面通過 app.json 的 pages 參數(shù)定義,用于指定小程序由哪些頁面組成,每一項(xiàng)都對(duì)應(yīng)一個(gè)頁面的路徑(含文件名) 信息。 pages 內(nèi)的每個(gè)頁面,小程序都會(huì)去尋找對(duì)應(yīng)的 json, js, wxml, wxss 四個(gè)文件進(jìn)行處理。
如開發(fā)目錄為:
- ├── app.js
 - ├── app.json
 - ├── app.wxss
 - ├── pages
 - │ │── index
 - │ │ ├── index.wxml
 - │ │ ├── index.js
 - │ │ ├── index.json
 - │ │ └── index.wxss
 - │ └── logs
 - │ ├── logs.wxml
 - │ └── logs.js
 - └── utils
 
則需要在 app.json 中寫:
- {
 - "pages": ["pages/index/index", "pages/logs/logs"]
 - }
 
為了方便演示,我們先 fork 一份小程序的官方demo,然后新建一個(gè)文件 depend.js,依賴分析相關(guān)的工作就在這個(gè)文件里面實(shí)現(xiàn)。
- $ git clone git@github.com:wechat-miniprogram/miniprogram-demo.git
 - $ cd miniprogram-demo
 - $ touch depend.js
 
其大致的目錄結(jié)構(gòu)如下:
目錄結(jié)構(gòu)
以 app.json 為入口,我們可以獲取所有主包下的頁面。
- const fs = require('fs-extra')
 - const path = require('path')
 - const root = process.cwd()
 - class Depend {
 - constructor() {
 - this.context = path.join(root, 'miniprogram')
 - }
 - // 獲取絕對(duì)地址
 - getAbsolute(file) {
 - return path.join(this.context, file)
 - }
 - run() {
 - const appPath = this.getAbsolute('app.json')
 - const appJson = fs.readJsonSync(appPath)
 - const { pages } = appJson // 主包的所有頁面
 - }
 - }
 
每個(gè)頁面會(huì)對(duì)應(yīng) json, js, wxml, wxss 四個(gè)文件:
- const Extends = ['.js', '.json', '.wxml', '.wxss']
 - class Depend {
 - constructor() {
 - // 存儲(chǔ)文件
 - 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 => {
 - // 獲取絕對(duì)地址
 - const absPath = this.getAbsolute(page)
 - Extends.forEach(ext => {
 - // 每個(gè)頁面都需要判斷 js、json、wxml、wxss 是否存在
 - const filePath = this.replaceExt(absPath, ext)
 - if (fs.existsSync(filePath)) {
 - this.files.add(filePath)
 - }
 - })
 - })
 - }
 - }
 
現(xiàn)在 pages 內(nèi)頁面相關(guān)的文件都放到 files 字段存起來了。
構(gòu)造樹形結(jié)構(gòu)
拿到文件后,我們需要依據(jù)各個(gè)文件構(gòu)造一個(gè)樹形結(jié)構(gòu)的文件樹,用于后續(xù)展示依賴關(guān)系。
假設(shè)我們有一個(gè) pages 目錄,pages 目錄下有兩個(gè)頁面:detail、index ,這兩個(gè) 頁面文件夾下有四個(gè)對(duì)應(yīng)的文件。
- pages
 - ├── detail
 - │ ├── detail.js
 - │ ├── detail.json
 - │ ├── detail.wxml
 - │ └── detail.wxss
 - └── index
 - ├── index.js
 - ├── index.json
 - ├── index.wxml
 - └── index.wxss
 
依據(jù)上面的目錄結(jié)構(gòu),我們構(gòu)造一個(gè)如下的文件樹結(jié)構(gòu),size 用于表示當(dāng)前文件或文件夾的大小,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 }
 - }
 - }
 - }
 - }
 
我們先在構(gòu)造函數(shù)構(gòu)造一個(gè) tree 字段用來存儲(chǔ)文件樹的數(shù)據(jù),然后我們將每個(gè)文件都傳入 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)) {
 - // 調(diào)用 addToTree
 - this.addToTree(filePath)
 - }
 - })
 - })
 - }
 - }
 
接下來實(shí)現(xiàn) addToTree 方法:
- class Depend {
 - // 省略之前的部分代碼
 - // 獲取相對(duì)地址
 - 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)) {
 - // 如果該文件已經(jīng)添加過,則不再添加到文件樹中
 - return
 - }
 - const size = this.getSize(filePath)
 - const relPath = this.getRelative(filePath)
 - // 將文件路徑轉(zhuǎn)化成數(shù)組
 - // '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)
 - }
 - }
 
我們可以在運(yùn)行之后,將文件輸出到 tree.json 看看。
- run() {
 - // ...
 - pages.forEach(page => {
 - //...
 - })
 - fs.writeJSONSync('tree.json', this.tree, { spaces: 2 })
 - }
 
tree.json
獲取依賴關(guān)系
上面的步驟看起來沒什么問題,但是我們?nèi)鄙倭酥匾囊画h(huán),那就是我們?cè)跇?gòu)造文件樹之前,還需要得到每個(gè)文件的依賴項(xiàng),這樣輸出的才是小程序完整的文件樹。文件的依賴關(guān)系需要分成四部分來講,分別是 js, json, wxml, wxss 這四種類型文件獲取依賴的方式。
獲取 .js 文件依賴
小程序支持 CommonJS 的方式進(jìn)行模塊化,如果開啟了 es6,也能支持 ESM 進(jìn)行模塊化。我們?nèi)绻@得一個(gè) js 文件的依賴,首先要明確,js 文件導(dǎo)入模塊的三種寫法,針對(duì)下面三種語法,我們可以引入 Babel 來獲取依賴。
- import a from './a.js'
 - export b from './b.js'
 - const c = require('./c.js')
 
通過 @babel/parser 將代碼轉(zhuǎn)化為 AST,然后通過 @babel/traverse 遍歷 AST 節(jié)點(diǎn),獲取上面三種導(dǎo)入方式的值,放到數(shù)組。
- const { parse } = require('@babel/parser')
 - const { default: traverse } = require('@babel/traverse')
 - class Depend {
 - // ...
 - jsDeps(file) {
 - const deps = []
 - const dirName = path.dirname(file)
 - // 讀取 js 文件內(nèi)容
 - const content = fs.readFileSync(file, 'utf-8')
 - // 將代碼轉(zhuǎn)化為 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
 - }
 - }
 
在獲取依賴模塊的路徑后,還不能立即將路徑添加到依賴數(shù)組內(nèi),因?yàn)楦鶕?jù)模塊語法 js 后綴是可以省略的,另外 require 的路徑是一個(gè)文件夾的時(shí)候,默認(rèn)會(huì)導(dǎo)入該文件夾下的 index.js 。
- class Depend {
 - // 獲取某個(gè)路徑的腳本文件
 - transformScript(url) {
 - const ext = path.extname(url)
 - // 如果存在后綴,表示當(dāng)前已經(jīng)是一個(gè)文件
 - 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) {...}
 - }
 
我們可以創(chuàng)建一個(gè) js,看看輸出的 deps 是否正確:
- // 文件路徑:/Users/shenfq/Code/fork/miniprogram-demo/
 - import a from './a.js'
 - export b from '../b.js'
 - const c = require('../../c.js')
 
image-20201101134549678
獲取 .json 文件依賴
json 文件本身是不支持模塊化的,但是小程序可以通過 json 文件導(dǎo)入自定義組件,只需要在頁面的 json 文件通過 usingComponents 進(jìn)行引用聲明。usingComponents 為一個(gè)對(duì)象,鍵為自定義組件的標(biāo)簽名,值為自定義組件文件路徑:
- {
 - "usingComponents": {
 - "component-tag-name": "path/to/the/custom/component"
 - }
 - }
 
自定義組件與小程序頁面一樣,也會(huì)對(duì)應(yīng)四個(gè)文件,所以我們需要獲取 json 中 usingComponents 內(nèi)的所有依賴項(xiàng),并判斷每個(gè)組件對(duì)應(yīng)的那四個(gè)文件是否存在,然后添加到依賴項(xiàng)內(nèi)。
- 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)
 - // 每個(gè)組件都需要判斷 js/json/wxml/wxss 文件是否存在
 - Extends.forEach((ext) => {
 - const file = this.replaceExt(component, ext)
 - if (fs.existsSync(file)) {
 - deps.push(file)
 - }
 - })
 - })
 - }
 - return deps
 - }
 - }
 
獲取 .wxml 文件依賴
wxml 提供兩種文件引用方式 import 和 include。
- <import src="a.wxml"/>
 - <include src="b.wxml"/>
 
wxml 文件本質(zhì)上還是一個(gè) html 文件,所以可以通過 html parser 對(duì) wxml 文件進(jìn)行解析,關(guān)于 html parser 相關(guān)的原理可以看我之前寫過的文章 《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 文件導(dǎo)入樣式和 css 語法一致,使用 @import 語句可以導(dǎo)入外聯(lián)樣式表。
- @import "common.wxss";
 
可以通過 postcss 解析 wxss 文件,然后獲取導(dǎo)入文件的地址,但是這里我們偷個(gè)懶,直接通過簡單的正則匹配來做。
- 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
 - }
 - }
 
獲取 .wxss 文件依賴
最后 wxss 文件導(dǎo)入樣式和 css 語法一致,使用 @import 語句可以導(dǎo)入外聯(lián)樣式表。
- class Depend {
 - addToTree(filePath) {
 - // 如果該文件已經(jīng)添加過,則不再添加到文件樹中
 - 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)
 - })
 - }
 - }
 
獲取分包依賴
熟悉小程序的同學(xué)肯定知道,小程序提供了分包機(jī)制。使用分包后,分包內(nèi)的文件會(huì)被打包成一個(gè)單獨(dú)的包,在用到的時(shí)候才會(huì)加載,而其他的文件則會(huì)放在主包,小程序打開的時(shí)候就會(huì)加載。subpackages 中,每個(gè)分包的配置有以下幾項(xiàng):
所以我們?cè)谶\(yùn)行的時(shí)候,除了要拿到 pages 下的所有頁面,還需拿到 subpackages 中所有的頁面。由于之前只關(guān)心主包的內(nèi)容,this.tree 下面只有一顆文件樹,現(xiàn)在我們需要在 this.tree 下掛載多顆文件樹,我們需要先為主包創(chuàng)建一個(gè)單獨(dú)的文件樹,然后為每個(gè)分包創(chuàng)建一個(gè)文件樹。
- 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') // 為主包創(chuàng)建文件樹
 - pages.forEach(page => {
 - this.addPage(page, 'main')
 - })
 - // 由于 app.json 中 subPackages、subpackages 都能生效
 - // 所以我們兩個(gè)屬性都獲取,哪個(gè)存在就用哪個(gè)
 - const subPkgs = subPackages || subpackages
 - // 分包存在的時(shí)候才進(jìn)行遍歷
 - subPkgs && subPkgs.forEach(({ root, pages }) => {
 - root = root.split('/').join(path.sep)
 - this.createTree(root) // 為分包創(chuàng)建文件樹
 - pages.forEach(page => {
 - this.addPage(`${root}${path.sep}${page}`, pkg)
 - })
 - })
 - // 輸出文件樹
 - fs.writeJSONSync('tree.json', this.tree, { spaces: 2 })
 - }
 - }
 
addToTree 方法也需要進(jìn)行修改,根據(jù)傳入的 pkg 來判斷將當(dāng)前文件添加到哪個(gè)樹。
- class Depend {
 - addToTree(filePath, pkg = 'main') {
 - if (this.files.has(filePath)) {
 - // 如果該文件已經(jīng)添加過,則不再添加到文件樹中
 - return
 - }
 - let relPath = this.getRelative(filePath)
 - if (pkg !== 'main' && relPath.indexOf(pkg) !== 0) {
 - // 如果該文件不是以分包名開頭,證明該文件不在分包內(nèi),
 - // 需要將文件添加到主包的文件樹內(nèi)
 - pkg = 'main'
 - }
 - const tree = this.tree[pkg] // 依據(jù) pkg 取到對(duì)應(yīng)的樹
 - 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)
 - })
 - }
 - }
 
這里有一點(diǎn)需要注意,如果 package/a 分包下的文件依賴的文件不在 package/a 文件夾下,則該文件需要放入主包的文件樹內(nèi)。
通過 EChart 畫圖
經(jīng)過上面的流程后,最終我們可以得到如下的一個(gè) json 文件:
tree.json
接下來,我們利用 ECharts 的畫圖能力,將這個(gè) json 數(shù)據(jù)以圖表的形式展現(xiàn)出來。我們可以在 ECharts 提供的實(shí)例中看到一個(gè) Disk Usage 的案例,很符合我們的預(yù)期。
ECharts
ECharts 的配置這里就不再贅述,按照官網(wǎng)的 demo 即可,我們需要把 tree. json 的數(shù)據(jù)轉(zhuǎn)化為 ECharts 需要的格式就行了,完整的代碼放到 codesandbod 了,去下面的線上地址就能看到效果了。
線上地址:https://codesandbox.io/s/cold-dawn-kufc9
最后效果
總結(jié)
這篇文章比較偏實(shí)踐,所以貼了很多的代碼,另外本文對(duì)各個(gè)文件的依賴獲取提供了一個(gè)思路,雖然這里只是用文件樹構(gòu)造了一個(gè)這樣的依賴圖。
在業(yè)務(wù)開發(fā)中,小程序 IDE 每次啟動(dòng)都需要進(jìn)行全量的編譯,開發(fā)版預(yù)覽的時(shí)候會(huì)等待較長的時(shí)間,我們現(xiàn)在有文件依賴關(guān)系后,就可以只選取目前正在開發(fā)的頁面進(jìn)行打包,這樣就能大大提高我們的開發(fā)效率。如果有對(duì)這部分內(nèi)容感興趣的,可以另外寫一篇文章介紹下如何實(shí)現(xiàn)。
























 
 
 












 
 
 
 