【源码共读】组件太多,重复工作量大?这次一行命令带你解放双手!

1,918 阅读7分钟

我正在参与掘金会员专属活动-源码共读第一期,点击参与

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.argvnode环境下的全局变量,用来获取命令行参数。

然后再看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,就会创建组件。

注意:不能cdscript/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是必传的,如果不传直接退出程序。

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

可以看到,这个函数会读取模板文件,然后编译模板,然后传入模板变量,最后创建文件。

_.templatelodash的一个方法,传入模板字符串,返回一个编译函数,然后调用这个函数传入模板变量,就可以得到编译后的字符串。

参考:lodash.template

模板文件长这样:

import <%= upperComponent %> from './<%= component %>';

export default <%= upperComponent %>;

就是通过<%= xxx %>这种形式来引用模板变量,然后lodash.template返回的编译函数就会把这些变量替换掉。

这里面有两个模板变量,componentupperComponent

  • 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目录,然后里面会生成一堆文件:

image.png

这一步只是创建,接下来就是注册组件了。

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操作,我们可以封装一个脚手架,来帮助我们完成这些重复的操作;

首先分析需求,我们需要做的事情是:

  1. 有哪些层级的文件夹需要创建;
    • src/api:接口层;
    • src/pages:页面层;
  2. 每个文件夹下需要创建哪些文件;
    • src/api增删改查四个接口
    • src/pages:列表页面,有增删改查四个功能
  3. 每个文件需要写入哪些内容;
    • 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的动态注入组件的源码,学习到了通过模板文件来生成代码的思路,对于一些重复性的工作,可以通过这种方式来减少重复性的工作,提高效率。

对应动态修改文件内容,也提供了一个思路,通过正则匹配,然后替换,这样就可以动态修改文件内容了。

正则确实有点烧脑,换我的话,我应该会用注释内容当做一个占位符,然后直接找到这个占位符,通过占位符来定位替换的位置,这样就可以减少很多心智上的负担了,缺点就是担心协助被误删。