手写微信小程序摇树优化工具(二)——遍历js文件
万物作而弗始,生而弗有,为而弗恃,功成而弗居。夫唯弗居,是以不去。
github: miniapp-shaking
1 基本摇树优化实现
上一章我们已经介绍了微信小程序的基本文件结构格式以及它的依赖形式,本章我们将开始进入编码阶段,我们使用node来实现这一个过程。
1.1 配置文件
首先我们需要一个配置文件来定义项目的路径和摇树之后代码的输出目录以及一些常用的变量等。摇树优化最重要的是不能改变源码,不然遍历之后你的源码全乱了,那可不是我想要的。因此我会将摇树优化的代码拷贝至另一个目录,相当于另一个目录就是精简版的小程序了,你上传这个目录的文件就行,再此期间源码是不会动的,无论你执行多少次摇树优化。
const path = require('path');
// 静态文件扩展名
const STATIC_File_EXTENDS = ['.jpg', '.png', '.jpeg', '.gif', '.webp', '.eot', '.ttf', '.woff', '.woff2', '.svg'];
// 小程序文件扩展名
const EXTENDS = ['.js', '.json', '.wxml', '.wxss'];
// 主包名称
const MAIN_PACKAGE_NAME = 'main_package';
// 排除的文件,不需要遍历
const EXCLUDE_FILES = ['package-lock.json', 'package.json'];
// 排除的npm包
const EXCLUDE_NPM = [];
// npm包正则匹配表达式,兼容mac和window
const NPM_REGEXP = path.sep === '/' ? /miniprogram_npm\/(.*?)\// : /miniprogram_npm\\(.*?)\\/;
// 分离npm包的正则匹配表达式,兼容mac和window
const SPLIT_NPM_REGEXP = path.sep === '/' ? /_npm\/(.*?)\// : /_npm\\(.*?)\\/;
class ConfigService {
constructor(options) {
// 源代码目标
this.sourceDir = options.sourceDir;
// 代码输出目录
this.targetDir = options.targetDir;
// 分析目录输出目录
this.analyseDir = options.analyseDir;
// 组名称
this.groupName = options.groupName || 'sun';
// 静态文件扩展
this.staticFileExtends = options.staticFileExtends || STATIC_File_EXTENDS;
// 文件扩展
this.fileExtends = options.fileExtends || EXTENDS;
// 主包名称
this.mainPackageName = options.mainPackageName || MAIN_PACKAGE_NAME;
// 需要排除的文件名称
this.excludeFiles = options.excludeFiles || EXCLUDE_FILES;
// 独立分包需要排除的npm包名称
this.excludeNpms = options.excludeNpms || EXCLUDE_NPM;
// 是否需要独立分包
this.isSplitNpm = options.isSplitNpm || false;
// npm 包正则判断
this.npmRegexp = NPM_REGEXP;
// 分包名称正则判断
this.SPLIT_NPM_REGEXP = SPLIT_NPM_REGEXP;
// 是否需要微信的自定义TabBar
this.needCustomTabBar = options.needCustomTabBar || false;
}
}
module.exports = {
ConfigService,
};
这里解释一下:
- sourceDir:你的源码目录
- targetDir: 摇树之后输出的目录,最好定义在你的源码目录之外
- analyseDir:依赖图的输出目录,摇树优化之后会生成代码的依赖图,类似微信小程序工具那种,不过比他更精细。
- groupName:项目组名称,这个是有特殊用途的后面会说,对于一个大型公司来说,它的项目公组件、页面可能是有十几个项目组一起开发的,然后在分发成不同的小程序,这个项目组名称可以去除掉其他组的业务逻辑,从而大大缩小程序体积,提高性能。
- staticFileExtends:静态文件扩展名,这里面预设了一些,你也可以自己定义。
- fileExtends:小程序文件扩展名,一般不用传。
- mainPackageName:主包名称,用于依赖图显示主包的名称。子包的名称我们就使用子包的目录来命名了。
- excludeFiles:需要排除遍历的的一些文件目录,仅限于在一级目录下的文件。
- isSplitNpm: 是否需要独立分包,这个是更高级的摇树优化,后面再说。
- excludeNpms:独立分包需要排除的npm包名称,后面再说。
- needCustomTabBar:是否使用了微信的自定义tabbar,如果使用了必须设置为true,否则不会遍历。
1.2 依赖收集
首先我们定义一个基类,接下来主包的类和分包的类都会继承这个基类BaseDepend
。
const path = require('path');
const fse = require('fs-extra');
const { parse } = require('@babel/parser');
const { default: traverse } = require('@babel/traverse');
const htmlparser2 = require('htmlparser2');
const { getReplaceComponent, getGenericName } = require('./utils');
class BaseDepend {
constructor(config, rootDir = '') {
// 文件树和相应的大小,用于生成依赖图
this.tree = {
size: 0,
children: {},
};
// 基本配置
this.config = config;
// 是否是主包的标志
this.isMain = true;
// 当前包的根目录
this.rootDir = rootDir;
// 缓存所有依赖的文件
this.files = new Set();
// 当前分包依赖的npm包名称
this.npms = new Set();
// 依赖映射
this.dependsMap = new Map();
// 不需要额外统计的文件
this.excludeFiles = {};
// 当前包的上下文,即包所处的目录
this.context = path.join(this.config.sourceDir, this.rootDir);
}
针对某个文件,我们来实现依赖收集的方法
/**
* 解析当前文件的依赖,针对微信小程序的5种文件
* @param filePath
* @returns {[string]}
*/
getDeps(filePath) {
const ext = path.extname(filePath);
switch (ext) {
case '.js':
return this.jsDeps(filePath);
case '.json':
return this.jsonDeps(filePath);
case '.wxml':
return this.wxmlDeps(filePath);
case '.wxss':
return this.wxssDeps(filePath);
case '.wxs':
return this.wxsDeps(filePath);
default:
throw new Error(`don't know type: ${ext} of ${filePath}`);
}
}
1.2.1 遍历js文件
分析js文件需要使用到ast
树,不懂什么是ast树可以自己学习一下,这里不做分析。需要使用到node相关的两个包@babel/parser
,@babel/traverse
。
/**
* 解析js文件的依赖
* @param file
* @returns {[]}
*/
jsDeps(file) {
// 保存依赖
const deps = [];
// 文件所处的目录
const dirname = path.dirname(file);
// 读取js内容
const content = fse.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, 'ImportDeclaration');
if (jsFile) {
deps.push(jsFile);
}
},
ExportNamedDeclaration: ({ node }) => {
// 获取export form地址
if (!node.source) return;
const { value } = node.source;
const jsFile = this.transformScript(dirname, value, 'ExportNamedDeclaration');
if (jsFile) {
deps.push(jsFile);
}
},
CallExpression: ({ node }) => {
// 函数表达式调用,require, require.async
if (
(this.isRequireFunction(node)) && node.arguments.length > 0) {
const [{ value }] = node.arguments;
if (!value) return;
const jsFile = this.transformScript(dirname, value, 'CallExpression');
if (jsFile) {
deps.push(jsFile);
}
}
},
ExportAllDeclaration: ({ node }) => {
// 导出所有
if (!node.source) return;
const { value } = node.source;
const jsFile = this.transformScript(dirname, value, 'ExportAllDeclaration');
if (jsFile) {
deps.push(jsFile);
}
},
});
return deps;
}
这里的js文件的依赖形式就是我们第一节所介绍的那几种,由于小程序支持分包异步化之后,require函数有了几种使用方式,在这里我们也要支持一下。
/**
* 判断是否是Require函数
* @param node
* @returns {boolean}
*/
isRequireFunction(node) {
const fnName = node.callee.name;
if (fnName) {
return fnName === 'require' || fnName === 'requireAsync';
}
const obj = node.callee.object;
const property = node.callee.property;
if (obj && property) {
return obj.name === 'require' && property.name === 'async' || property.name === 'requireAsync'
}
return false;
}
在这里我特别提供了一个函数requireAsync
,这个函数是用来干嘛的呢?因为在实际使用中我发现有这样的需求,就是很多情况下需要封装一些函数或者工具类来加载文件,那这时候require.async
的参数可能是一个变量,这个时候我就无法分析这个变量的依赖的,例如:
function requireAsync(file) {
// do some thing
return require.async(file).then(res ==> {
// do some thing;
return res;
})
}
这时我无法区分这是一个正常函数的参数还是一个文件的依赖,因此我提供了requireAsync
这个特殊的方法,这个方法的参数我会把它识别为一个文件依赖。
还有另一种更加特别的情况,它不是直接传入一个字符串,而是从后台请求回来,将结果动态传入这些加载函数。这个在做静态代码分析的时候是无法分析的。因此我提供了另一种方案来解决这种特殊问题,那就是在根目录下提供一个白名单文件,依赖分析会分析根目录下的所有文件,例如:
接下来我们看下如何真正的处理js的依赖
/**
* 转化js脚本语言,处理多种导入文件类型
* @param dirname:当前文件所处的目录
* @param value:导入路径
* @returns {string}
*/
transformScript(dirname, value) {
let url;
if (value.startsWith('../') || value.startsWith('./')) {
// 相对路径
url = path.resolve(dirname, value);
} else if (value.startsWith('/')) {
// 相对于根目录的绝对路径
url = path.join(this.config.sourceDir, value.slice(1));
} else {
// 直接导入npm包
url = path.join(this.config.sourceDir, 'miniprogram_npm', value);
}
const ext = path.extname(url);
if (ext === '.js' && fse.existsSync(url)) {
// 如果存在后缀,表示当前已经是一个文件,直接返回
return url;
}
// a/b/c -> a/b/c.js
const jsFile = url + '.js';
if (fse.existsSync(jsFile)) {
return jsFile;
}
// a/b/c => a/b/c/index.js
const indexFile = path.join(url, 'index.js');
if (fse.existsSync(indexFile)) {
return indexFile;
}
return '';
}
这里的细节就很充分了,我们导入的时候使用的路径是多种多样的,例如相对路径、绝对路径、导入npm包、导入的时候没有文件扩展名,导入的是一个目录,缺省了index.js,这些都要细心的考虑到,不然摇树优化后的代码就是有问题的。
本章有点长了就写到这里吧,欲知后文请关注下一章。
连载文章链接:
转载自:https://juejin.cn/post/7167718722516287519