我正在参与掘金会员专属活动-源码共读第一期,点击参与
在tdesign-vue的源码,有一个初始化的脚本文件,script/init/index.js,在这个文件中,就是这个文件让他拥有了动态创建组件和删除组件的能力。
今天通过学习这个工具的源码,来学习一下如何动态创建组件,让我们也可以在项目中使用这个功能。
源码地址:
使用
README.md中并没有关于这一块的使用说明,我们直接通过源码来看如何使用。
打开script/init/index.js文件,直接拉到最底下,可以看到下面的代码:
function init() {
const [component, isDeleted] = process.argv.slice(2);
if (!component) {
console.error('[组件名]必填 - Please enter new component name');
process.exit(1);
}
const indexPath = path.resolve(cwdPath, 'src/index.ts');
const toBeCreatedFiles = config.getToBeCreatedFiles(component);
if (isDeleted === 'del') {
deleteComponent(toBeCreatedFiles, component);
deleteComponentFromIndex(component, indexPath);
} else {
addComponent(toBeCreatedFiles, component);
insertComponentToIndex(component, indexPath);
}
}
init();
最底下看到直接执行了init()函数,在init函数中,看第一行代码就知道这个是运行在node环境下的,因为process.argv是node环境下的全局变量,用来获取命令行参数。
然后再看package.json文件,可以看到script中有一个init命令,这个命令就是用来执行script/init/index.js文件的。
{
"scripts": {
"init": "node script/init"
}
}
可以省略
index.js,因为node会默认去找index.js文件
通过这些线索,我们可以知道运行这个脚本的命令是:
npm run init <组件名> <del>
# or
node script/init/index.js <组件名> <del>
效果都是一样的,都是执行script/init/index.js文件,然后传入两个参数,第一个参数是组件名,第二个参数是del,如果传入了del,就会删除组件,如果没有传入del,就会创建组件。
注意:不能
cd到script/init目录下执行,因为这样node的执行目录就是script/init目录,而不是项目根目录,所以会找不到src目录
源码分析
动态创建组件
1. 创建组件
上面我们已经知道了,如果不传入del,那就现从创建组件开始分析,先简化一下代码:
function init() {
const [component] = process.argv.slice(2);
if (!component) {
console.error('[组件名]必填 - Please enter new component name');
process.exit(1);
}
const indexPath = path.resolve(cwdPath, 'src/index.ts');
const toBeCreatedFiles = config.getToBeCreatedFiles(component);
addComponent(toBeCreatedFiles, component);
insertComponentToIndex(component, indexPath);
}
首先可以看到component是必传的,如果不传直接退出程序。
indexPath是src/index.ts文件的路径;
toBeCreatedFiles是通过config.getToBeCreatedFiles(component)获取的,来看看这个函数:
function getToBeCreatedFiles(component) {
// keys are directories, values are files.
// desc - directory description
// files - will be created
// dirDeletable - if this directory can be deleted.
return {
[`src/${component}`]: {
desc: 'component source code',
files: [
{
file: 'index.ts',
template: 'index.ts.tpl',
},
{
file: `${component}.tsx`,
template: 'component.tsx.tpl',
},
],
},
// 省略其他代码
};
}
module.exports = {
getToBeCreatedFiles,
};
这个在script/init/config.js文件中;
可以看到这个函数返回了一个对象,这个对象的键是目录,值是文件,这个对象的目的就是用来动态创建组件的。
比如我们传入的组件名是Button,那么这个函数就会返回一个对象:
var data = {
'src/Button': {
desc: 'component source code',
files: [
{
file: 'index.ts',
template: 'index.ts.tpl',
},
{
file: 'Button.tsx',
template: 'component.tsx.tpl',
},
],
},
}
继续往下看,addComponent函数:
function addComponent(toBeCreatedFiles, component) {
// At first, we need to create directories for components.
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!`);
// Then, we create files for components.
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);
}
});
});
});
}
这个就是用来给组件创建目录和文件的,首先会遍历toBeCreatedFiles对象,然后创建目录,然后创建文件。
代码太多,先简化一下:
function addComponent(toBeCreatedFiles, component) {
// 组件目录
const dir = 'src/Button';
// 组件目录的绝对路径
const _d = path.resolve(cwdPath, dir);
// 创建目录
fs.mkdirSync(dir, { recursive: true });
// 组件目录下的文件
const contents = {
desc: 'component source code',
files: [
{
file: 'index.ts',
template: 'index.ts.tpl',
},
{
file: 'Button.tsx',
template: 'component.tsx.tpl',
},
],
};
// 遍历文件
contents.files.forEach((item) => {
// item是一个对象
if (typeof item === 'object') {
// 如果有template属性
if (item.template) {
// 通过模板创建文件
outputFileWithTemplate(item, component, contents.desc, _d);
}
} else {
// item是一个字符串, 直接创建文件
const _f = path.resolve(_d, item);
createFile(_f, '', contents.desc);
}
});
}
上面的简化只是将入参固定,移除了最外层的遍历,在外面循环执行我这个简化的函数也是可以的,然后将fs.mkdir改成了fs.mkdirSync,这样看着会更清晰一些。
可以看到最后会遍历contents.files,然后根据item的类型来创建文件,如果是对象,那么就会调用outputFileWithTemplate函数,这个函数的作用是根据模板创建文件:
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);
}
简化代码,将入参固定:
function outputFileWithTemplate(item, component, desc, _d) {
// 模板路径
const tplPath = path.resolve(__dirname, `./tpl/index.ts.tpl`);
// 读取模板内容
let data = fs.readFileSync(tplPath).toString();
// 编译模板
const compiled = _.template(data);
// 传入模板变量
data = compiled({
component: 'Button',
upperComponent: getFirstLetterUpper('Button'),
});
// 创建文件
const _f = path.resolve(_d, item.file);
createFile(_f, data, desc);
}
可以看到,这个函数会读取模板文件,然后编译模板,然后传入模板变量,最后创建文件。
_.template是lodash的一个方法,传入模板字符串,返回一个编译函数,然后调用这个函数传入模板变量,就可以得到编译后的字符串。
模板文件长这样:
import <%= upperComponent %> from './<%= component %>';
export default <%= upperComponent %>;
就是通过<%= xxx %>这种形式来引用模板变量,然后lodash.template返回的编译函数就会把这些变量替换掉。
这里面有两个模板变量,component和upperComponent;
-
component就是组件名 -
upperComponent见名知意,就是组件名的首字母大写,是通过getFirstLetterUpper函数来实现的:
function getFirstLetterUpper(a) {
return a[0].toUpperCase() + a.slice(1);
}
非常简单的一个函数,就是把首字母大写,然后拼接上剩下的字符串。
继续往下就是创建文件了,调用了createFile函数:
function createFile(path, data = '', desc) {
fs.writeFile(path, data, (err) => {
if (err) {
utils.log(err, 'error');
} else {
utils.log(`> ${desc}\n${path} file has been created successfully!`, 'success');
}
});
}
就是调用fs.writeFile来创建文件,这里面有两个参数,一个是文件路径;
desc用于打印日志,这里面会打印出文件的描述,然后文件的路径。
回到addComponent函数,可以看到item如果不是对象,就直接调用createFile函数来创建文件,不过内容是空的;
部分代码:
contents.files.forEach((item) => {
if (typeof item === 'object') {
// 省略...
} else {
// item是一个字符串, 直接创建文件
const _f = path.resolve(_d, item);
createFile(_f, '', contents.desc);
}
});
当addComponent函数执行完毕,就会在node执行脚本的目录下创建一个src目录,然后里面会生成一堆文件:
这一步只是创建,接下来就是注册组件了。
2. 注册组件
上面执行完addComponent函数后,还有一个insertComponentToIndex函数,这个函数的作用就是把组件注册到index.js文件中:
/**
* @param component 组件名
* @param indexPath 是根目录下的 src/index.js 文件路径
*/
function insertComponentToIndex(component, indexPath) {
const upper = getFirstLetterUpper(component);
// last import line pattern
const importPattern = /import.*?;(?=\n\n)/;
// components pattern
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;
}
// insert component at last import and component lines.
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');
}
});
}
第一行就是将首字母大写,刚才已经讲过了,先把代码整理一下:
/**
* @param component 组件名
* @param indexPath 是生成的组件下的index.js文件的路径
*/
function insertComponentToIndex(component, indexPath) {
// 首字母大写
const upper = getFirstLetterUpper(component);
// 拼接import语句
const importPath = getImportStr(upper, component);
// 读取index.ts文件
let data = fs.readFileSync(indexPath).toString();
// 如果index.ts文件中已经存在了该组件,就不再插入
if (data.match(new RegExp(importPath))) {
utils.log(`there is already ${component} in /src/index.ts`, 'notice');
return;
}
// 找到 import 语句,然后在后面插入 import 语句
const importPattern = /import.*?;(?=\n\n)/;
data = data.replace(importPattern, (a) => `${a}\n${importPath}`);
// 找到 components 对象,然后在后面插入组件
const cmpPattern = /(?<=const components = {\n)[.|\s|\S]*?(?=};\n)/g;
data = data.replace(cmpPattern, (a) => `${a} ${upper},\n`);
// 写入文件
fs.writeFile(indexPath, data, (err) => {
if (err) {
utils.log(err, 'error');
} else {
const desc = '> insert component into index.ts';
utils.log(`${desc}\n${component} has been inserted into /src/index.ts`, 'success');
}
});
}
importPath是通过getImportStr函数来获取的,这个函数的作用就是拼接import语句:
function getImportStr(upper, component) {
return `import ${upper} from './${component}';`;
}
这里会判断index.ts文件中是否已经存在了该组件,如果存在就不再插入,否则就会在index.ts文件中插入import语句和组件名。
然后找到入口文件的import语句,分号结尾,后面有两个换行符,然后在后面插入import语句:
const importPattern = /import.*?;(?=\n\n)/;
// 会匹配到
import { App } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import 文本.....
.....
......
....;
// 上面有两个换行符就会配到这里,然后在后面插入 import 语句
import 文本.....
.....
......
....;
import 组件名 from './组件名';
然后找到components对象,然后在后面插入组件名:
const cmpPattern = /(?<=const components = {\n)[.|\s|\S]*?(?=};\n)/g;
// 道理是一样的,就是边界情况考虑的比较多
const components = {
文本.....
.....
.....
....
};
// 或者
const components = { 文本.....};
// 最后都有一个换行,开始的花括号后面可以是任意字符,也可以是空格,就不一一列举了
// 然后添加组件名
const components = {
文本.....
.....
.....
....
组件名,
};
最后写入文件,就完成了组件的注册。
删除组件
最开始的init函数还有一个删除的分支,回顾一下:
function init() {
const [component, isDeleted] = process.argv.slice(2);
const indexPath = path.resolve(cwdPath, 'src/index.ts');
const toBeCreatedFiles = config.getToBeCreatedFiles(component);
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) {
item.deleteFiles.forEach((f) => {
fs.existsSync(f) && fs.unlinkSync(f);
});
} else {
utils.deleteFolderRecursive(dir);
}
});
utils.log('All radio files have been removed.', 'success');
}
这次就不简化代码了,可以自行尝试一下;
先看第一行代码,这里会获取快照文件,通过getSnapshotFiles函数:
function getSnapshotFiles(component) {
return {
[`test/unit/${component}/__snapshots__/`]: {
desc: 'snapshot test',
files: ['index.test.js.snap', 'demo.test.js.snap'],
},
};
}
这个快照信息的结构和toBeCreatedFiles是一样的,然后和toBeCreatedFiles合并;
然后遍历合并后的对象,如果有deleteFiles属性,就删除这些文件,否则就删除整个文件夹;
可以看一下配置文件,是没有deleteFiles属性的,所以这里就是删除整个文件夹;
deleteFolderRecursive函数是在utils文件中定义的,代码如下:
function deleteFolderRecursive(path) {
if (fs.existsSync(path)) {
fs.readdirSync(path).forEach((file) => {
const current = `${path}/${file}`;
if (fs.statSync(current).isDirectory()) {
deleteFolderRecursive(current);
} else {
fs.unlinkSync(current);
}
});
fs.rmdirSync(path);
}
}
这里就是递归删除文件夹,如果是文件就直接删除,如果是文件夹,就递归删除;
fs.unlinkSync是删除文件,fs.rmdirSync是删除文件夹;
然后还要删除index.ts文件中的组件注册信息,这里就是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');
}
});
}
实现逻辑和注册组件的时候是相同的,只不过注册组件是增加,删除组件就是删除,主要是下面这一段代码:
data = data.replace(new RegExp(importStr), () => '').replace(new RegExp(` ${upper},\n`), '');
这里是用正则匹配要删除的内容,然后替换为空字符串,就完成了删除组件的操作。
自己动手
上面其实并不是很复杂,我们自己也可以实现一个类似的功能,就比如我们日常开发中,经常会做重复的CRUD操作,我们可以封装一个脚手架,来帮助我们完成这些重复的操作;
首先分析需求,我们需要做的事情是:
- 有哪些层级的文件夹需要创建;
src/api:接口层;src/pages:页面层;
- 每个文件夹下需要创建哪些文件;
src/api:增删改查四个接口src/pages:列表页面,有增删改查四个功能
- 每个文件需要写入哪些内容;
src/api:增删改查四个接口src/pages:列表页面,有增删改查四个功能src/router.js:路由配置
我这里就简单的实现一下:
- template 文件夹
- api.js
import request from '@/utils/request';
// list
export function list(params) {
return request.post('/api/<%= moduleName>/list', params);
}
// info
export function info(params) {
return request.get('/api/<%= moduleName>/info', {
params
});
}
// save
export function save(data) {
return request.post('/api/<%= moduleName>/save', data);
}
// delete
export function del(params) {
return request.delete('/api/<%= moduleName>/delete', {
params
});
}
- pages.vue
<template>
<div>
<div class="search-form">
<el-form label-width="auto" inline @submit.prevent>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="toolbar">
<el-button type="primary" @click="handleAdd">新增</el-button>
<el-button type="danger" @click="handleDelete" :disabled="selection.length === 0">批量删除</el-button>
</div>
<div class="main-container">
<el-table
:data="tableData"
v-loading="loading"
element-loading-text="拼命加载中..."
border
stripe
style="width: 100%;"
@selection-change="val => selection = val"
>
<el-table-column type="selection" width="55"></el-table-column>
<el-table-column label="操作" width="180">
<template #default="{row}">
<el-button type="text" size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button type="text" size="small" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup>
import {ref} from 'vue';
import { list, info, save, del } from '@/api/<%= moduleName %>';
const searchForm = ref({});
const tableData = ref([]);
const loading = ref(false);
const handleSearch = () => {
loading.value = true;
list(searchForm).then(res => {
tableData.value = res.data;
}).finally(() => {
loading.value = false;
});
};
const handleReset = () => {
searchForm.value = {};
handleSearch();
};
const handleAdd = () => {
// TODO
};
const handleEdit = (row) => {
// TODO
};
const handleDelete = (row) => {
// TODO
};
</script>
- router.js
import { createRouter, createWebHashHistory } from 'vue-router';
import Home from '../pages/Home.vue';
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
];
const router = createRouter({
history: createWebHashHistory(),
routes
});
上面两个文件是模板文件,router.js是本来就应该存在的。
现在开始实现:
- init.js
const fs = require('fs');
const path = require('path');
const cwd = process.cwd();
const agrv = process.argv.slice(2);
const name = agrv[0];
const apiPath = path.resolve(cwd, 'src/api');
const pagesPath = path.resolve(cwd, 'src/pages');
const routerPath = path.resolve(cwd, 'src/router.js');
function init() {
if (!name) {
console.log('请输入模块名称');
return;
}
// 获取模板文件
const apiTemplate = fs.readFileSync(path.resolve(__dirname, 'template/api.js'), 'utf-8');
const pagesTemplate = fs.readFileSync(path.resolve(__dirname, 'template/pages.vue'), 'utf-8');
// 转换模板文件
const apiContent = _.template(apiTemplate)({moduleName: name});
const pagesContent = _.template(pagesTemplate)({moduleName: name});
// 获取写入的文件目录
const apiFile = path.resolve(apiPath, `${name}.js`);
const pagesFile = path.resolve(pagesPath, `${name}.vue`);
// 写入文件
fs.writeFileSync(apiFile, apiContent);
fs.writeFileSync(pagesFile, pagesContent);
// 修改路由文件
const routerContent = fs.readFileSync(routerPath, 'utf-8');
const routerReg = /(?<=const routes = [\n)[.|\s|\S]*?(?=];\n)/;
// 添加路由
routerContent = routerContent.replace(routerReg, (a) => {
return `${a} {
path: '/${name}',
name: '${name}',
component: () => import('@/pages/${name}.vue')
},\n`;
});
console.log('over ~');
}
init();
这样我们就可以通过命令行来生成模板文件了。
node init.js <moduleName>
上面的代码没有经过测试,可能有问题,只是跟着思路写的,正则是测试过的没问题,有一颗开源的心但是懒,愿意可以自己完善一下。
总结
通过tdesign-vue的动态注入组件的源码,学习到了通过模板文件来生成代码的思路,对于一些重复性的工作,可以通过这种方式来减少重复性的工作,提高效率。
对应动态修改文件内容,也提供了一个思路,通过正则匹配,然后替换,这样就可以动态修改文件内容了。
正则确实有点烧脑,换我的话,我应该会用注释内容当做一个占位符,然后直接找到这个占位符,通过占位符来定位替换的位置,这样就可以减少很多心智上的负担了,缺点就是担心协助被误删。