每次新增页面都要复制粘贴?tdesign-vue 中 100 多行源码的告诉你如何减少重复工作

188 阅读8分钟


学习目标

tdesign-vue 组件库中封装了一个 init 命令,这个命令用于自动创建一个组件所需要的全部文件并且更新有引用关系的文件。

代码地址:Tencent/tdesign-vue - script/init/index.js

你可以执行这个命令来新增一个组件:

npm run init 组件名

如果在组件名后面加上 del 就变成了删除这个组件:

npm run init 组件名 del

为什么要用这种方式创建组件

这跟我们平时开发模式好像不一样啊,有的同学可能要问:为什么要用这种方式创建组件?

想要什么文件直接新建不就好了吗,如果想提高效率可以利用 vscode 的代码段功能甚至把原来的组件代码拷贝一份改吧改吧不也挺快的吗?现在搞得这么复杂,要写脚本然后封装成命令,然后再用命令去创建文件,图啥呢?

那我们先来看看代码段功能,代码段可以根据输入的一个关键词生成一段预设的代码,例如我设置的输入 np 后按下 tab 键直接生成创建 Promise 实例的代码:

new Promise((resolve, reject) => {
  
})

所以代码段功能其实更适合用来生成一小段代码而非文件。

那手动拷贝或者创建文件又如何呢?

回答问题之前不妨先以 Vuejs 项目为例来梳理一下当我新建一个页面或者公用组件时,我需要新建或者修改哪些文件:

  1. 页面文件 index.vue
  2. 页面样式文件 index.css
  3. 接口文件 api.js
  4. 如果是页面,路由文件要增加相应路由配置
  5. 可能还需要 vuex
  6. 如果是公用组件,可能要注册为全局组件
  7. 单元测试文件
  8. e2e 相关文件
  9. i18n 相关文件
  10. storybook 相关文件

乖乖,不看不知道,一看吓一跳,这文件可不少,更要命的这些文件还分别位于不同的目录下。

你当然可以耐心的拷贝或者创建这些文件,但是如果有了这个自动创建文件的脚本来帮助我们执行这些重复繁琐却又不可避免的工作,那我们的精力可以花费在更有价值更有创造性的事情上。

而且当你的项目越复杂,开发周期越长,这个脚本帮你的价值就越大,用的次数越多它越有用。甚至只要我们将来还在不断写代码,就可以将这个脚本移植到新项目里,我们可以一直从中受益,这样想来这个脚本真乃居家必备开发神器~

那接下来咱们就一起来看看 tdesign-vue 是怎么实现这个功能的吧~

代码思路

程序的宏观思路

  1. 入口程序 init()
    1. 获取参数:模块名 componentName 和操作类型(增加或删除)isDeleted
    2. 根据模块名计算涉及到的文件信息:toBeCreatedFiles,后面创建或删除文件都要用到
  2. 如果操作类型是新增
    1. 调用 addComponent() 创建相关文件
    2. 调用 insertComponentToIndex()src/index.ts 中插入 import 语句
  3. 如果操作类型是删除
    1. 调用 deleteComponent() 删除相关文件
    2. 调用 deleteComponentFromIndex()src/index.ts 中删除涉及到该模块的代码

这就完了,其实程序的思路不复杂,一眼就看能明白,整个程序都是围绕着文件的增删改来执行的,文件相关的信息主要有这几点:

  1. 创建/删除的文件具体是哪些,用代码怎么表示?
  2. 这些文件的目录是什么
  3. 这些文件的内容是什么,是否预设好模板

根据这 3 点,可以很容易得到这样的一个对象

const toBeCreatedFiles = {
  '存放目录AA': {
    desc: '这是组件源码',
    files: [
      { file: '文件a1.vue', tpl: '模板a1.vue.tpl' },
      { file: '文件a2.js', tpl: '模板a2.js.tpl' },
    ]
  }
}

对象 toBeCreatedFiles 的键表示目录,值用来存放这个目录下的文件信息。

上面代码表示我们将创建 存放目录AA 目录,desc 属性对这个目录下文件简单描述,files 表示在 存放目录AA 这个目录下将会创建两个文件:

  1. 模板a1.vue.tpl 为模板创建的文件 文件a1.vue
  2. 模板a2.js.tpl 为模板创建的文件 文件a2.js

实际上目录和文件名中可能需要出现模块名,所以需要用函数来动态创建一个 toBeCreatedFiles 对象,我们来看 scripts/init/config.js 中代码:

function getToBeCreatedFiles(component) {
  return {
    [`src/${component}`]: {
      desc: 'component source code',
      files: [
        {
          file: 'index.ts',
          template: 'index.ts.tpl',
        },
        {
          file: `${component}.tsx`,
          template: 'component.tsx.tpl',
        },
      ],
    }
  }
}

我在一开始看到数组作为对象属性名的时候没反应过来为啥这么写,后来才反应过来这是为了让变量作为对象属性,例如

const key = 'name'

const perseon = {
  [key]: '贞子'
}

// perseon: {name: '贞子'}

数据格式有了之后,剩下的事情就简单了。

如果你的操作是创建,那就先遍历这个对象创建相应目录,同时根据其 files 属性拿到模板文件使用 lodash 的模板引擎对模板进行编译,编译后写入到新文件中即可。

如果你都操作是删除,那就更简单了,遍历这个对象拿到每个目录地址,直接将目录下所有文件全部删除即可。

如何处理有引用关系的文件

引用到新组件中的文件只有 src/index.ts ,相关代码在 deleteComponentFromIndexinsertComponentToIndex 两个函数中,思路也很简单:

  1. 读取相关文件,将内容转成字符串
  2. 通过正则找到合适位置插入或者是删除之前插入的代码语句

举例来说,如果原来 index.ts 的代码是这样的:

import Button from './button';
import Icon from './icon';


const components = {
  Button,
  Icon,
}

而我想要的组件名是 component ,那我现在要想办法将上面的内容变成下面这样:

import Button from './button';
import Icon from './icon';
import Component from './component';


const components = {
  Component,
  Button,
  Icon,
}

对比之下,后面比前面多了两行代码,这两行代码所在位置分别有特征,一个是在 import 语句下有两个空行,另一个是有 const components = { 下面。

所以使用 replace 方法配合正则对原来内容进行替换,将替换后拿到的结果再写入到 index.ts 中就可以了。

// upper 和 importStr 分别是组件名和导入组件的代码语句
const upper = getFirstLetterUpper(component);
const importStr = `${getImportStr(upper, component)}\n`;

// import 下面有两个空行
const importPattern = /import.*?;(?=\n\n)/;

// 匹配 const components = {
const cmpPattern = /(?<=const components = {\n)[.|\s|\S]*?(?=};\n)/g;

// data 是 src/index.ts 内容
data = data
  .replace(importPattern, (a) => `${a}\n${importPath}`)
  .replace(cmpPattern, (a) => `${a}  ${upper},\n`)

如果是删除操作,直接将上面创建操作中增加的语句替换为空即可:

// upper 和 importStr 分别是组件名和导入组件的代码语句

data = data = data
  .replace(new RegExp(importStr), () => '')
  .replace(new RegExp(`  ${upper},\n`), '');

知识补充环节

要完全看懂其中代码需要比较多的基础知识,这里我总结一下我认为可能会阻碍本次看源码的一些知识点。

nodejs 中文件操作相关 api

这个脚本中主要用到了这些文件操作 api,方法名带有 Sync 表示同步方法,没有则为异步。

描述方法
创建文件fs.writeFile
创建文件fs.readFileSync
删除文件fs.unlinkSync
读取目录rs.readdirSync
创建目录fs.mkdir
删除目录fs.rmdirSync
判断是否存在fs.existsSync
读取文件/目录状态fs.statSync
  1. fs.existsSync(path) 判断如果路径存在则返回 true,否则返回 falsefs.existsSync(path) | Node.js API 文档 (nodejs.cn)
  2. fs.readdirSync() 方法将返回一个包含“指定目录下所有文件名称”的数组对象
  3. fs.readFileSync(src) 得到的是文件流,需要再次调用 toString 方法转为字符串
  4. fs.statSync() 返回有关给定文件路径的信息
// Getting information for a file 
statsObj = fs.statSync("test_file.txt"); 
  
console.log(statsObj);  
console.log("Path is file:", statsObj.isFile()); 
console.log("Path is directory:", statsObj.isDirectory()); 

更多细节请翻阅文档:fs 文件系统 | Node.js API 文档 (nodejs.cn)

nodejs 中路径相关 api

(1)获取目录

process.cwd() 返回当前工作目录,也就是你在终端执行命令时所在的目录,__dirname 返回源代码所在的目录。

以本文中的命令为例,你在项目根目录下执行 npm run init 组件名 命令时,实际上是在执行 script/init/index.js 文件,那么 process.cwd() 就是项目根目录,而 __dirname 则是文件所在目录 script/init

(2)拼接路径

path.resolve 的作用参数拼接成绝对路径:

path.resolve(cwdPath, 'src/index.ts');

nodejs 进程参数

process.argv 属性返回一个数组,由命令行执行脚本时的各个参数组成。它的第一个成员总是 node,第二个成员是脚本文件名,其余成员是脚本文件的参数。可以参看 process对象 -- JavaScript 标准参考教程(alpha) (ruanyifeng.com)

正则

正则表达式的内容三言两语说不清楚,我认为还是专门看比较完整的教程比较好。这里贴几个链接希望对你有帮助:

教程类:

工具类:

最后

本文主要讲这个自动创建模块代码文件脚本的意义和代码实现的大致思路和代码实现的几个关键点,并没有逐行解析,如果有兴趣不妨打开代码地址 Tencent/tdesign-vue - script/init/index.js 一起来品味其中妙处。