用過 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 每次啓動都須要進行全量的編譯,開發版預覽的時候會等待較長的時間,咱們如今有文件依賴關係後,就能夠只選取目前正在開發的頁面進行打包,這樣就能大大提升咱們的開發效率。若是有對這部份內容感興趣的,能夠另外寫一篇文章介紹下如何實現。