小程式依賴分析實踐

語言: CN / TW / HK

用過 webpack 的同學肯定知道 webpack-budle-analyzer ,可以用來分析當前專案 js 檔案的依賴關係。

webpack-bundle-analyzer

因為最近一直在做小程式業務,而且小程式對包體大小特別敏感,所以就想著能不能做一個類似的工具,用來檢視當前小程式各個主包與分包之間的依賴關係。經過幾天的折騰終於做出來了,效果如下:

小程式依賴關係

今天的文章就帶大家來實現這個工具。

小程式入口

小程式的頁面通過 app.jsonpages 引數定義,且 pages 內的每個頁面,小程式都會去尋找對應的 .json, .js, .wxml, .wxss 四個檔案進行處理。

pages引數

為了方便演示,我們想到 fork 一份小程式的官方demo,然後新建一個檔案 depend.js,依賴分析相關的工作就在這個檔案裡面實現。

$ git clone [email protected]: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 })
 }
複製程式碼

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')
複製程式碼

image-20201101134549678

獲取 .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)      
    })
  }
}
複製程式碼

image-20201101205623259

獲取分包依賴

熟悉小程式的同學肯定知道,小程式提供了分包機制。使用分包後,分包內的檔案會被打包成一個單獨的包,在用到的時候才會載入,而其他的檔案則會放在主包,小程式開啟的時候就會載入。

分包配置

所以我們在執行的時候,除了要拿到 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 的案例,很符合我們的預期。

ECharts

ECharts 的配置這裡就不再贅述,按照官網的 demo 即可,我們需要把 tree. json 的資料轉化為 ECharts 需要的格式就行了,完整的程式碼放到 codesandbod 了,去下面的線上地址就能看到效果了。

線上地址:http://codesandbox.io/s/cold-dawn-kufc9

最後效果

總結

這篇文章比較偏實踐,所以貼了很多的程式碼,另外本文對各個檔案的依賴獲取提供了一個思路,雖然這裡只是用檔案樹構造了一個這樣的依賴圖。

在業務開發中,小程式 IDE 每次啟動都需要進行全量的編譯,開發版預覽的時候會等待較長的時間,我們現在有檔案依賴關係後,就可以只選取目前正在開發的頁面進行打包,這樣就能大大提高我們的開發效率。如果有對這部分內容感興趣的,可以另外寫一篇文章介紹下如何實現。