Практика мини-программного анализа зависимостей

внешний интерфейс Апплет WeChat

Студенты, которые использовали 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"]
}

Чтобы облегчить демонстрацию, мы сначала разветвляем официальную демоверсию апплета, а затем создаем новый файл.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содержание,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Суффикс можно не указывать.Кроме того, если путь для запроса является папкой, файлы в папке будут импортированы по умолчанию.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файл импорта пользовательских компонентов, только нужно быть на странице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.

<import src="a.wxml"/>
<include src="b.wxml"/>

Файл wxml по сути представляет собой файл html, поэтому файл wxml можно проанализировать с помощью анализатора html. Принципы работы с анализатором html можно найти в статье, которую я написал ранее."Принцип компиляции шаблона 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

Получить зависимости подпакета

Учащиеся, знакомые с мини-программами, должны знать, что мини-программа обеспечиваетМеханизм субподряда. После использования субпакета файлы внутри субконтракта будут упакованы в отдельный пакет, который будет загружаться только при использовании, а остальные файлы будут помещены на мастер, а апплет будет загружен.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Случай соответствует нашим ожиданиям.

ECharts

Настройка ECharts здесь повторяться не будет, просто следуйте демо на официальном сайте, нам нужно поставитьtree. jsonПросто преобразуйте данные в формат, требуемый ECharts, поместите полный код в коды и bod и перейдите по указанному ниже онлайн-адресу, чтобы увидеть результат.

Интернет-адрес:код sandbox.io/is/cold-dawn…

最后效果

Суммировать

Эта статья более практична, поэтому выложено много кода.Кроме того, в этой статье представлена ​​идея получения зависимостей каждого файла, хотя здесь только дерево файлов для построения такого графа зависимостей.

В бизнес-разработке мини-программа IDE должна быть полностью скомпилирована при каждом запуске. Предварительный просмотр версии для разработки займет много времени. Теперь, когда у нас есть зависимости файлов, мы можем выбрать для упаковки только те страницы, которые в настоящее время разрабатываются. Это может значительно повысить эффективность нашей разработки. Если вам интересна эта часть, вы можете написать еще одну статью о том, как ее реализовать.