小程序 foreach_【第2106期】小程序依赖分析实践
前言
這種可視化分析圖還是很直觀的,很有趣。今日早讀文章由自然醒授權分享。
正文從這開始~~
用過 webpack 的同學肯定知道 webpack-bundle-analyzer ,可以用來分析當前項目 js 文件的依賴關系。
webpack-bundle-analyzer因為最近一直在做小程序業務,而且小程序對包體大小特別敏感,所以就想著能不能做一個類似的工具,用來查看當前小程序各個主包與分包之間的依賴關系。經過幾天的折騰終于做出來了,效果如下:
小程序依賴關系今天的文章就帶大家來實現這個工具。
小程序入口
小程序的頁面通過 app.json 的 pages 參數定義,用于指定小程序由哪些頁面組成,每一項都對應一個頁面的路徑(含文件名) 信息。 pages 內的每個頁面,小程序都會去尋找對應的 json, js, wxml, wxss 四個文件進行處理。
如開發目錄為:
├── 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,然后新建一個文件 depend.js,依賴分析相關的工作就在這個文件里面實現。
$ git clone git@github.com:wechat-miniprogram/miniprogram-demo.git
$ cd miniprogram-demo
$ touch depend.js
其大致的目錄結構如下:
目錄結構以 app.json 為入口,我們可以獲取所有主包下的頁面。
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 四個文件:
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 })
}
tree.json獲取依賴關系
上面的步驟看起來沒什么問題,但是我們缺少了重要的一環,那就是我們在構造文件樹之前,還需要得到每個文件的依賴項,這樣輸出的才是小程序完整的文件樹。文件的依賴關系需要分成四部分來講,分別是 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 文件導入自定義組件,只需要在頁面的 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 文件依賴
wxml 提供兩種文件引用方式 import 和 include。
src="a.wxml"/>
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)
})
}
}
獲取分包依賴
熟悉小程序的同學肯定知道,小程序提供了分包機制。使用分包后,分包內的文件會被打包成一個單獨的包,在用到的時候才會加載,而其他的文件則會放在主包,小程序打開的時候就會加載。 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 文件夾下,則該文件需要放入主包的文件樹內。
通過 EChart 畫圖
經過上面的流程后,最終我們可以得到如下的一個 json 文件:
tree.json接下來,我們利用 ECharts 的畫圖能力,將這個 json 數據以圖表的形式展現出來。我們可以在 ECharts 提供的實例中看到一個 Disk Usage 的案例,很符合我們的預期。
EChartsECharts 的配置這里就不再贅述,按照官網的 demo 即可,我們需要把 tree.json 的數據轉化為 ECharts 需要的格式就行了,完整的代碼放到 codesandbod 了,去下面的線上地址就能看到效果了。
線上地址:https://codesandbox.io/s/cold-dawn-kufc9
最后效果總結
這篇文章比較偏實踐,所以貼了很多的代碼,另外本文對各個文件的依賴獲取提供了一個思路,雖然這里只是用文件樹構造了一個這樣的依賴圖。
在業務開發中,小程序 IDE 每次啟動都需要進行全量的編譯,開發版預覽的時候會等待較長的時間,我們現在有文件依賴關系后,就可以只選取目前正在開發的頁面進行打包,這樣就能大大提高我們的開發效率。如果有對這部分內容感興趣的,可以另外寫一篇文章介紹下如何實現。
關于本文 作者:@自然醒 原文:https://blog.shenfq.com/2020/小程序依賴分析/
為你推薦
【第1806期】高德JS依賴分析工程及關鍵原理
【第2030期】JavaScript 啟動性能瓶頸分析與解決方案
歡迎自薦投稿,前端早讀課等你來
總結
以上是生活随笔為你收集整理的小程序 foreach_【第2106期】小程序依赖分析实践的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: java中i+=2什么意思_三分钟看懂J
- 下一篇: 地下城与勇士删除鬼剑士角色时候忘了名字怎