Those of you who have used Webpack know that Webpack-bundle-Analyzer can be used to analyze the dependencies of your current project’s JS files.

Because recently have been doing small program business, and small program is particularly sensitive to package size, so I think can do a similar tool, used to view the current small program each main package and subcontracting between the dependency relationship. After several days of torturing finally made, the effect is as follows:

Today’s article will take you through implementing this tool.

Applets entry

The pages of the applet are defined by the pages parameter of app.json, which is used to specify which pages the applet consists of. Each item corresponds to the path (including the file name) information of a page. Each page in pages, small program will find the corresponding JSON, JS, WXML, WXSS four files for processing.

For example:

├ ─ ─ app. Js ├ ─ ─ app. Json ├ ─ ─ app. WXSS ├ ─ ─ pages │ │ ─ ─ index │ │ ├ ─ ─ but WXML │ │ ├ ─ ─ index. The js │ │ ├ ─ ─ index. The json │ │ └ ─ ─ index. WXSS │ └ ─ ─ logs │ ├ ─ ─ logs. WXML │ └ ─ ─ logs. The js └ ─ ─ utilsCopy the code

You need to write in app.json:

{
  "pages": ["pages/index/index", "pages/logs/logs"]
}
Copy the code

For the sake of demonstration, we’ll fork an official demo of the applet and create a new file called depend. Js where the dependency analysis work will be done.

$ git clone [email protected]:wechat-miniprogram/miniprogram-demo.git
$ cd miniprogram-demo
$ touch depend.js
Copy the code

Its rough directory structure is as follows:

Using app.json as the entry point, we can get all the pages under the main package.

const fs = require('fs-extra') const path = require('path') const root = process.cwd() class Depend { constructor() { This.context = path.join(root, 'miniprogram')} // Get the absolute address getAbsolute(file) {return path.join(this.context, file) } run() { const appPath = this.getAbsolute('app.json') const appJson = fs.readJsonSync(appPath) const { pages } = AppJson // all pages of the main package}}Copy the code

Each page will correspond to json, JS, WXML and WXSS files:

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, Mypages. ForEach (page => {// getAbsolute address const absPath = this.getabsolute (page) Extends. ForEach (ext => {const filePath = this.replaceExt(absPath, ext) if (fs.existsSync(filePath)) { this.files.add(filePath) } }) }) } }Copy the code

Pages files are now stored in the files field.

Construct a tree structure

Once we have the files, we need to construct a tree structure for each file to show the dependencies later.

Suppose we have a pages directory. In the Pages directory there are two pages: detail and index. There are four corresponding files in these two page folders.

├── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ├─ index.txt └─ index.txt ├─ index.txtCopy the code

According to the directory structure above, we construct a file tree structure as follows, size is used to represent the size of the current file or folder, children stores the files under the folder, if it is a file, there is no children attribute.

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}}}}}Copy the code

We first construct a tree field in the constructor to store the data of the file tree, and then we pass each file to the addToTree method to add the file to the tree.

class Depend { constructor() { this.tree = { size: 0, children: {} } this.files = new Set() this.context = path.join(root, 'miniProgram ')} run() {my.foreach (page => {const absPath = this.getabsolute (page) Extends.forEach(ext => { const filePath = this.replaceExt(absPath, Ext) if (fs.existssync (filePath)) {// Call addToTree this.addToTree(filePath)}})}}Copy the code

Next, implement the addToTree method:

GetRelative (file) {return path.relative(this.context, file)} return path.relative(this.context, file)} Unit KB getSize(file) {const stats = fs.statsync (file) return stats.size / 1024} addToTree(filePath) {if (this.files.has(filePath)) {// If the file has already been added, Return} const size = this.getSize(filePath) const relPath = this.getRelative(filePath) // Convert file paths to arrays // '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}) // Add files this.files.add(filePath)}}Copy the code

We can output the file to tree.json after we run it.

 run() {
   // ...
   pages.forEach(page => {
     //...
   })
   fs.writeJSONSync('tree.json', this.tree, { spaces: 2 })
 }
Copy the code

Getting dependencies

The above steps seem fine, but one important thing is missing: we need to get the dependencies of each file before we can construct the file tree, so that the output is the complete file tree of the applet. File dependencies need to be divided into four parts, respectively, js, JSON, WXML, WXSS these four types of file dependency acquisition methods.

Get.js file dependencies

Applet supports CommonJS for modularization. If es6 is enabled, it can also support ESM for modularization. If you want to obtain a dependency on a JS file, first of all, we must specify three ways to write js file import module, for the following three syntax, we can introduce Babel to obtain dependency.

import a from './a.js'
export b from './b.js'
const c = require('./c.js')
Copy the code

Pass the code to AST with @babel/ Parser, then traverse the AST node with @babel/traverse to get the values of the above three import methods and place them in the array.

const { parse } = require('@babel/parser') const { default: traverse } = require('@babel/traverse') class Depend { // ... JsDeps (file) {const deps = [] const dirName = path.dirname(file) const content = fs.readfilesync (file, // Convert code to AST const AST = parse(content, {sourceType: 'module', plugins: ['exportDefaultFrom']}) // traverse(AST, {ImportDeclaration: ({node}) => {// import from address const {value} = node.source const jsFile = this.transformScript(dirName, value) if (jsFile) { deps.push(jsFile) } }, ExportNamedDeclaration: ({node}) => {// get export from address 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) {// Get Require address const [{value}] = node.arguments const jsFile = this.transformscript (dirName, value) if (jsFile) { deps.push(jsFile) } } } }) return deps } }Copy the code

After obtaining the path of the dependent module, the path cannot be added to the dependency array immediately, because the js suffix can be omitted according to the module syntax. In addition, when require’s path is a folder, the index.js file in that folder will be imported by default.

Class Depend {// Obtain a script file transformScript(url) {const ext = path.extname(url) // If there is a suffix, If (ext === '.js' && fs.existssync (url)) {return url} // a/b/c => a/b/c. s 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) {... }}Copy the code

We can create a js to see if the output deps is correct:

/ / file path: / Users/shenfq/Code/fork/miniprogram - demo/import from a '. / a. s' export from b '.. /b.js' const c = require('.. /.. /c.js')Copy the code

Get.json file dependencies

Json files themselves do not support modularity, but applets can import custom components from JSON files by declaring references to usingComponents in the JSON file on the page. UsingComponents is an object whose key is the tag name of the custom component and whose value is the path to the custom component file:

{
  "usingComponents": {
    "component-tag-name": "path/to/the/custom/component"
  }
}
Copy the code

The custom component, like the applet page, has four files, so we need to get all the dependencies in the JSON usingComponents, determine if the four files for each component exist, and then add them to the dependencies.

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, // Each component needs to check whether the js/json/ WXML/WXSS file Extends. ForEach ((ext) => {const file = this.replaceext (component, ext) if (fs.existsSync(file)) { deps.push(file) } }) }) } return deps } }Copy the code

Get.wxml file dependencies

WXML provides two file references: import and include.

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

WXML files are essentially HTML files, so WXML files can be parsed by HTML Parser. For the principles of HTML Parser, please refer to my previous article “Principles of Vue Template Compilation”.

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 } }Copy the code

Get.wxss file dependencies

Finally, the WXSS file import style is consistent with the CSS syntax, and the @import statement can be used to import the external style sheet.

@import "common.wxss";
Copy the code

It is possible to parse the WXSS file using PostCSS and get the address of the import file, but here we will be lazy and do this directly through simple re matching.

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 } }Copy the code

Add dependencies to the tree structure

Now we need to modify the addToTree method.

Class Depend {addToTree(filePath) {// If the file has been added, 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) // ===== get file dependencies, ===== const deps = this.getDeps(filePath) deps.foreach (dep => {this.addToTree(dep)})}}Copy the code

Obtaining subcontracting dependencies

Those of you familiar with applets will know that they provide a subcontracting mechanism. With subcontracting, files in the subcontracting package are packaged into a separate package that is loaded when needed, while other files are placed in the main package and loaded when the applet is opened. In subpackages, each subpackage has the following configurations:

field

type

instructions

root

String

Subcontract root

name

String

Subcontract alias, which can be used when subcontracting pre-download

pages

StringArray

The subcontract page path, as opposed to the subcontract root directory

independent

Boolean

Whether subcontracting is independent subcontracting

So when we run, we need to fetch all pages under Pages and all pages in subpackages. Since we only cared about the contents of the main package, there was only one file tree under this.tree. Now we need to mount multiple file trees under this.tree. We need to create a separate file tree for the main package first, and then one for each subpackage.

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') // Create file tree pages. 'main')}) app.json subPackages and app.packages are valid Which is used which const subPkgs = subPackages | | subPackages / / subcontract traverse exists only when subPkgs && subPkgs. ForEach (({root, Pages}) => {root = root.split('/').join(path.sep) this.createTree(root) This. AddPage (' ${root}${path.sep}${page} ', PKG)})}) // Fs.writejsonsync ('tree.json', this.tree, {Spaces: 2})}}Copy the code

The addToTree method also needs to be modified to determine which tree to add the current file to based on the PKG passed in.

Class Depend {addToTree(filePath, PKG = 'main') {if (this.files.has(filePath)) { Return} let relPath = this.getRelative(filePath) if (PKG! == 'main' && relPath.indexOf(pkg) ! == 0) {// If the document does not begin with the subcontract name, proving that the document is not subcontracted, PKG = 'main'} const tree = this.tree[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) // ===== get file dependencies, ===== const deps = this.getDeps(filePath) deps.foreach (dep => {this.addToTree(dep)})}}Copy the code

One thing to note here is that if the package/ A subpackage depends on a file that is not in the Package/A folder, the file needs to be placed in the main package’s file tree.

Drawing by EChart

After the above process, we end up with a JSON file as follows:

Next, we take advantage of ECharts’ graphing capabilities to present this JSON data as a chart. We can see an example of Disk Usage in the example provided by ECharts, which fits our expectations.

We need to convert tree. json data into ECharts format. The complete code is put into Codesandbod. Go to the following online address to see the effect.

IO/S /cold…

Related sharing code implementation

1. HTML file content

<! DOCTYPE html><html> <head> <title>Parcel Sandbox</title> <meta charset="UTF-8" /> </head> <body> <div id="app" style="width: 100vw; height: 100vh;" > < / div > < script SRC = "https://cdn.jsdelivr.net/npm/[email protected]/dist/echarts.min.js" > < / script > < script src="src/index.js"></script> </body></html>Copy the code

2. SRC file content

src/index.js

function flatDependency(map, arr) { Object.keys(map).forEach((name) => { const { size, children } = map[name]; if (! children) { return; } const flatChildren = []; arr.push({ name, value: size, children: flatChildren }); flatDependency(children, flatChildren); }); }const data = []; const treeJson = require("./tree.json"); flatDependency(treeJson, data); const eChart = echarts.init(document.getElementById("app")); const formatUtil = echarts.format; Const option = {backgroundColor: "#333", title: {text: "applet dependency distribution ", left: "center", textStyle: {color: "#fff" } }, tooltip: { formatter: function (info) { const treePath = []; const { value, treePathInfo } = info; const pathDeep = treePathInfo.length; if (pathDeep <= 2) { treePath.push(treePathInfo[1] && treePathInfo[1].name); } else { for (var i = 2; i < pathDeep; i++) { treePath.push(treePathInfo[i].name); } } return [ '<div class="tooltip-title">' + formatUtil.encodeHTML(treePath.join("/")) + "</div>", "Disk Usage: " + value.toFixed(2) + " KB" ].join(""); } }, series: [ { type: "treemap", name: "Dependency", data: data, radius: "100%", visibleMin: 300, label: { show: true, formatter: "{b}" }, itemStyle: { borderColor: "#fff" }, levels: [ { itemStyle: { gapWidth: 1, borderWidth: 0, borderColor: "#777" } }, { itemStyle: { gapWidth: 1, borderWidth: 5, borderColor: "#555" }, upperLabel: { show: true } }, { itemStyle: { gapWidth: 1, borderWidth: 5, borderColor: "#888" }, upperLabel: { show: True}}, {colorSaturation: [0.35, 0], itemStyle: {gapWidth: 1, borderWidth: 5, borderColorSaturation: 0.4}, upperLabel: {show: true}}]}}; eChart.setOption(option);Copy the code

3, the SRC/style. The CSS

body { margin: 0; padding: 0; font-family: sans-serif; }Copy the code

SRC /tree.json instance file

Refer to the address: https://codesandbox.io/s/cold-dawn-kufc9? file=/src/tree.json:0-100441Copy the code

conclusion

This article is more partial to practice, so posted a lot of code, in addition, this article provides an idea for obtaining the dependency of each file, although here only with the file tree structure of a dependency map.

In business development, small program IDE needs to be fully compiled every time it starts, the development version of the preview will wait for a long time, we now have file dependencies, we can only select the page is currently under development for packaging, which can greatly improve our development efficiency. If you are interested in this section, you can write a separate article explaining how to implement it.