本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。
前言
在项目中,当我们需要创建一个组件,往往需要创建一些依赖的文件,比如组件实例、在主入口引入组件、单元测试等。如果我们有那么一个脚本,可以通过命令行方式就完成这件事情,肯定会是一个很好的体验。本文通过阅读tdesign-vue的init脚本源码来看看优秀的开源项目实现类似的功能的。
1. 目录结构
脚本涉及相关目录结构如下:
init
├── config.js # 配置文件
├── index.js # 入口文件
└── tpl # 模板文件夹
├── base.demo.tpl
├── component.md.tpl
├── component.tsx.tpl
├── demo.test.tpl
├── index.test.tpl
├── index.ts.tpl
└── ssr.demo.test.tpl
2. 从入口文件出发
打开index.js
,脚本会执行init
方法,通过阅读init
方法可以了解脚本的大概执行脉络:
- 获取参数
- 获取配置
- 新增或删除组件
function init() {
// 1. 获取命令行参数,获取组件名(component)和删除还是新增(isDeleted)参数
const [component, isDeleted] = process.argv.slice(2);
if (!component) {
console.error('[组件名]必填 - Please enter new component name');
process.exit(1);
}
// 2. 获取配置
const indexPath = path.resolve(cwdPath, 'src/index.ts');
const toBeCreatedFiles = config.getToBeCreatedFiles(component);
// 3. 删除组件或者新增组件
if (isDeleted === 'del') {
deleteComponent(toBeCreatedFiles, component);
deleteComponentFromIndex(component, indexPath);
} else {
addComponent(toBeCreatedFiles, component);
insertComponentToIndex(component, indexPath);
}
}
// 默认执行init方法
init();
这里涉及3个
node.js
API:
process.exit
:用于终结程序;path.resolve
:路径解析,通过给定的路径参数合成出绝对路径,为后面文件操作使用;process.argv
:返回命令行数组,从第三个参数开始才是我们传入命令行的参数,所以代码中利用process.argv.slice(2)
截取。
3. 获取配置
获取配置的方法在config.js
中,下面截取了两种配置模式方便总结:
function getToBeCreatedFiles(component) {
// 配置中有很多对象,每一个对象的键是文件夹,值是一个描述要生成文件的对象
// desc: 用于打印显示
// files: 要生成文件夹列表,支持两种模式:模板模式和非模板模式
return {
// 第一种配置是模板模式
[`src/${component}`]: {
desc: 'component source code',
files: [
{
// 要生成的文件名
file: 'index.ts',
// 通过模板生成文件内容
template: 'index.ts.tpl',
},
{
file: `${component}.tsx`,
template: 'component.tsx.tpl',
},
],
},
//...省略了部分
// 第二种配置是非模板模式
[`test/e2e/${component}`]: {
desc: 'e2e test',
// 生成的文件内容为空
files: [`${component}.spec.js`],
},
};
}
下面我们会详细说说模板是如何使用的。
4. 新增组件
新增组件主要做了两件事:
addComponent(toBeCreatedFiles, component); // 通过配置文件创建文件夹和文件
insertComponentToIndex(component, indexPath); // 插入组件引用到项目入口文件
addComponent
function addComponent(toBeCreatedFiles, component) {
// 遍历配置获取目录
Object.keys(toBeCreatedFiles).forEach((dir) => {
const _d = path.resolve(cwdPath, dir);
// 创建目录
fs.mkdir(_d, { recursive: true }, (err) => {
if (err) {
utils.log(err, 'error');
return;
}
console.log(`${_d} directory has been created successfully!`);
// 获取具体要创建的文件配置
const contents = toBeCreatedFiles[dir];
// 遍历要生成的文件
contents.files.forEach((item) => {
if (typeof item === 'object') {
// 创建文件,内容是模板
if (item.template) {
outputFileWithTemplate(item, component, contents.desc, _d);
}
} else {
// 创建文件,内容是空内容
const _f = path.resolve(_d, item);
createFile(_f, '', contents.desc);
}
});
});
});
}
通过模板创建文件,我们单独拿出来看下:
function outputFileWithTemplate(item, component, desc, _d) {
const tplPath = path.resolve(__dirname, `./tpl/${item.template}`);
// 读取模板文件内容
let data = fs.readFileSync(tplPath).toString();
// 生成模板编译器
const compiled = _.template(data);
// 编译出最终的内容
data = compiled({
component,
upperComponent: getFirstLetterUpper(component),
});
// 创建文件
const _f = path.resolve(_d, item.file);
createFile(_f, data, desc);
}
模板的编译,使用了lodash的模板方法,支持多种分隔符来定义模板。而在tdesign-vue中,用了_"interpolate" 分隔符_
insertComponentToIndex
function insertComponentToIndex(component, indexPath) {
// 获取首字母大写组件名
const upper = getFirstLetterUpper(component);
// 引入组件的正则
const importPattern = /import.*?;(?=\n\n)/;
// 组件对象中添加组件的正则
const cmpPattern = /(?<=const components = {\n)[.|\s|\S]*?(?=};\n)/g;
// 检测是否已经在入口文件添加引入组件
const importPath = getImportStr(upper, component);
const desc = '> insert component into index.ts';
let data = fs.readFileSync(indexPath).toString();
if (data.match(new RegExp(importPath))) {
utils.log(`there is already ${component} in /src/index.ts`, 'notice');
return;
}
// 通过正则添加引入组件和放到组件对象中添加组件
data = data.replace(importPattern, (a) => `${a}\n${importPath}`).replace(cmpPattern, (a) => `${a} ${upper},\n`);
fs.writeFile(indexPath, data, (err) => {
if (err) {
utils.log(err, 'error');
} else {
utils.log(`${desc}\n${component} has been inserted into /src/index.ts`, 'success');
}
});
}
这里最难理解的是两个正则表达式:
/import.*?;(?=\n\n)/
:- 这个正则分两段看
import.*?;
和(?=\n\n)
- 第一段中的
.*?
表示非贪婪模式下去匹配\n
之外的任何单字符串 - 第二段符合
exp1(?=exp2)
表示在exp2
匹配的前面去找exp1
- 合起来的意思就是在两个回车字符前去匹配出带有
import
+ 最短非\n
内容+;
的字符串,通俗来讲及时将组件引入放到引入语句的最后一行。
- 这个正则分两段看
/(?<=const components = {\n)[.|\s|\S]*?(?=};\n)/
:- 这个正则分三段看
(?<=const components = {\n)
、[.|\s|\S]*?
和(?=};\n)
- 第一段符合
(?<=exp2)exp1
表示查找 exp2 后面的 exp1 - 第二段表示非贪婪模式下去匹配任何字符
- 第三段符合
exp1(?=exp2)
表示查找 exp2 前面的 exp1 - 合起来的意思就是找前面扶着
const components = {\n
,非贪婪模式去匹配中间任意字符,后面必须是};\n
结尾
- 这个正则分三段看
5. 删除组件
删除组件也是做了两件事:
deleteComponent(toBeCreatedFiles, component); // 通过配置删除文件和文件夹
deleteComponentFromIndex(component, indexPath); // 在项目入口文件中删除组件引入
deleteComponent
function deleteComponent(toBeCreatedFiles, component) {
// 配置文件加上快照文件(貌似没用上)作为要删除文件列表
const snapShotFiles = getSnapshotFiles(component);
const files = Object.assign(toBeCreatedFiles, snapShotFiles);
// 遍历文件列表删除,支持两种模式:
Object.keys(files).forEach((dir) => {
const item = files[dir];
if (item.deleteFiles && item.deleteFiles.length) {
// 模式1. 删除配置指定的文件列表
item.deleteFiles.forEach((f) => {
fs.existsSync(f) && fs.unlinkSync(f);
});
} else {
// 模式2. 删除目录下所有文件
utils.deleteFolderRecursive(dir);
}
});
utils.log('All radio files have been removed.', 'success');
}
deleteComponentFromIndex
function deleteComponentFromIndex(component, indexPath) {
// 获取首字母大写组件名
const upper = getFirstLetterUpper(component);
const importStr = `${getImportStr(upper, component)}\n`;
let data = fs.readFileSync(indexPath).toString();
// 删除组件引入和组件对象中组件名,这里相比新增时正则匹配规则简单很多
data = data.replace(new RegExp(importStr), () => '').replace(new RegExp(` ${upper},\n`), '');
fs.writeFile(indexPath, data, (err) => {
if (err) {
utils.log(err, 'error');
} else {
utils.log(`${component} has been removed from /src/index.ts`, 'success');
}
});
},
总结
- 这个脚本有很多值得我们学习的优点:
- 通过配置化的方式来执行脚本,拓展性很好
- 文件的内容支持模板是一个很好的思路
- 对于内容匹配的正则处理,比如
?=、?<=、?!、?<!
,确实很简洁实用
- 理解了这个脚本的优点,那么我们日常的项目中其实也是可以复刻一样的脚本的。比如在B端,往往一个产品需求下来,我们需要新建列表页面、编辑表单页面、添加相关路由和添加api文件等文件或引用。对于这些相对是比较固定的初始化操作,我们不妨写一个初始化脚本,这样对日常开发会有一个不错的效率提升