tdesign-vue教会我新增组件(页面)也可以很优雅

2,562 阅读4分钟

本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。

任务来源:juejin.cn/post/710910…

前言

在项目中,当我们需要创建一个组件,往往需要创建一些依赖的文件,比如组件实例、在主入口引入组件、单元测试等。如果我们有那么一个脚本,可以通过命令行方式就完成这件事情,肯定会是一个很好的体验。本文通过阅读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方法可以了解脚本的大概执行脉络:

  1. 获取参数
  2. 获取配置
  3. 新增或删除组件
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.jsAPI:

  • 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');
    }
  });
}

这里最难理解的是两个正则表达式:

  1. /import.*?;(?=\n\n)/
    1. 这个正则分两段看import.*?;(?=\n\n)
    2. 第一段中的.*?表示非贪婪模式下去匹配\n之外的任何单字符串
    3. 第二段符合 exp1(?=exp2)表示在exp2匹配的前面去找exp1
    4. 合起来的意思就是在两个回车字符前去匹配出带有import+ 最短非\n内容+;的字符串,通俗来讲及时将组件引入放到引入语句的最后一行
  2. /(?<=const components = {\n)[.|\s|\S]*?(?=};\n)/:
    1. 这个正则分三段看(?<=const components = {\n)[.|\s|\S]*?(?=};\n)
    2. 第一段符合(?<=exp2)exp1表示查找 exp2 后面的 exp1
    3. 第二段表示非贪婪模式下去匹配任何字符
    4. 第三段符合exp1(?=exp2)表示查找 exp2 前面的 exp1
    5. 合起来的意思就是找前面扶着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');
    }
  });
},

总结

  1. 这个脚本有很多值得我们学习的优点:
    1. 通过配置化的方式来执行脚本,拓展性很好
    2. 文件的内容支持模板是一个很好的思路
    3. 对于内容匹配的正则处理,比如?=、?<=、?!、?<!,确实很简洁实用
  2. 理解了这个脚本的优点,那么我们日常的项目中其实也是可以复刻一样的脚本的。比如在B端,往往一个产品需求下来,我们需要新建列表页面、编辑表单页面、添加相关路由和添加api文件等文件或引用。对于这些相对是比较固定的初始化操作,我们不妨写一个初始化脚本,这样对日常开发会有一个不错的效率提升

参考