• 本文参加了由公众号@若川视野发起的每周源码共读活动, 点击了解详情一起参与。
  • 这是源码共读的第 34 期,链接:juejin.cn/post/710910…。

前言

在我们给我们的组件库添砖加瓦的时候,经常会有一些重复的工作,比如组件本身的文件、单元测试、使用示例、组件文档等的创建,那有没有更加优雅、方便快捷的方式去创建这些文件呢?答案是肯定的,我在 tdesing-vue 组件库中找到了答案,下面让我们一起来学习一下它是如何实现的。

源码地址

  • github仓库地址github.com/Tencent/tde…

一、使用

1.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

1.3 新增组件

// xiaolong 为新增的组件名
node init/index.js xiaolong
  • 效果

如何在项目中更加优雅的新增、删除组件

1.4 删除组件

// xiaolong 为需要删除的组件名
node init/index.js xiaolong del
  • 效果

如何在项目中更加优雅的新增、删除组件

二、源码解读

2.1 流程

如何在项目中更加优雅的新增、删除组件

2.2 init 方法解读

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);
  }
}
  1. 通过process.argv获取输入参数:组件名称component、是否执行删除动作isDeleted
  2. component 必传参数是否传入,未传入则给出错误提示,已传入则继续向下执行;
  3. 获取 indexPath ,指定组件的插入或移除的具体文件地址;
  4. 通过config.getToBeCreatedFiles 生成需要创建的文件信息对象 toBeCreatedFiles
  5. isDeleted === 'del' 则执行删除组件方法deleteComponentinsertComponentToIndex,否则执行新增组件的方法 addComponentinsertComponentToIndex

2.3 新增组件

2.3.1 addComponent

添加新增组件所需文件夹及具体文件

function addComponent(toBeCreatedFiles, component) {
  // At first, we need to create directories for components.
  // 根据 toBeCreatedFiles 创建所需的文件夹
  Object.keys(toBeCreatedFiles).forEach((dir) => {
    // cwdPath 为当前工作目录
    const _d = path.resolve(cwdPath, dir);
    // recursive: 指示是否应创建父文件夹。 如果创建了文件夹,将返回第一个创建的文件夹的路径。
    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);
        }
      });
    });
  });
}

2.3.2 insertComponentToIndex

将新增组件插入到指定文件中(场景:组件的统一导出,使用时的导入)

function insertComponentToIndex(component, indexPath) {
  // 通过 getFirstLetterUpper 方法,将组件名首字母大写
  const upper = getFirstLetterUpper(component);
  // last import line pattern
  const importPattern = /import.*?;(?=nn)/;
  // 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');
    }
  });
}

2.4 删除组件

2.4.1 deleteComponent

删除组件相关的文件及文件夹

function deleteComponent(toBeCreatedFiles, component) {
  // 通过 getSnapshotFiles 获取需要删除的组件快照相关文件信息
  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 {
      // 没有指定 deleteFiles 时,通过递归的形式去删除当前文件夹下的文件
      utils.deleteFolderRecursive(dir);
    }
  });
  utils.log('All radio files have been removed.', 'success');
}

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

2.5 其它辅助方法

2.5.1 getToBeCreatedFiles

通过传入的组件名生成需要新增或删除的文件信息

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',
        },
      ],
    },
    [`examples/${component}`]: {
      desc: 'component API',
      files: [
        {
          file: `${component}.md`,
          template: 'component.md.tpl',
        },
      ],
    },
    [`examples/${component}/demos`]: {
      desc: 'component demo code',
      files: [
        {
          file: 'base.vue',
          template: 'base.demo.tpl',
        },
      ],
    },
    [`test/unit/${component}`]: {
      desc: 'unit test',
      files: [
        {
          file: 'index.test.js',
          template: 'index.test.tpl',
        },
        {
          file: 'demo.test.js',
          template: 'demo.test.tpl',
        },
      ],
    },
    [`test/e2e/${component}`]: {
      desc: 'e2e test',
      files: [`${component}.spec.js`],
    },
  };
}

2.5.2 outputFileWithTemplate

通过模板来创建所需文件,item.template 指定了所需的具体模板,而所有相关的模板则放到了 tpl 文件夹中

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

2.5.3 getFirstLetterUpper

将传入的组件名首字母转成大写

function getFirstLetterUpper(a) {
  return a[0].toUpperCase() + a.slice(1);
}

2.5.4 getImportStr

拼接组件导入的文本信息

function getImportStr(upper, component) {
  return `import ${upper} from './${component}';`;
}

2.5.5 getSnapshotFiles

获取组件快照文件信息

function getSnapshotFiles(component) {
  return {
    [`test/unit/${component}/__snapshots__/`]: {
      desc: 'snapshot test',
      files: ['index.test.js.snap', 'demo.test.js.snap'],
    },
  };
}

2.5.6 utils.deleteFolderRecursive

递归删除文件夹

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

三、总结

通过阅读上述的源码,我们可以通过修改 getToBeCreatedFiles 方法返回的配置项以及 tpl 模版文件夹里的模版,获得符合我们自己业务场景的新增、删除组件的脚本。

另外,在技能知识点上,加深了对 fs 模块相关 API 的了解

  • fs.mkdir 异步创建文件夹
  • fs.existsSync 判断传入路径是否存在的同步版本
  • fs.unlinkSync 同步删除文件或符号链接
  • fs.rmdirSync 同步删除文件夹
  • fs.statSync 同步获取文件状态
  • fs.readFileSync 同步读取文件内容
  • fs.writeFile 异步写入文件内容