element-ui中关于make new自动化生成组件

976 阅读5分钟

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

这是源码共读的第15期,链接:element 初始化组件功能

说明

刚读这一期源码共读的时候,看了川哥开头的描述,其实我是不懂该期是要干啥的,在我看到要输入 make new终端命令时,我还是一脸懵逼,以至于我停了下来,去学习其他东西去了......

当然,今天我耐着性子打算再看一遍,便直接把项目运行起来了,然后终端输入node build/bin/new.js Ali 阿离组件才发现,页面左侧菜单组件最下面多了一个【Ali 阿离组件】,原来这个make new终端命令的目的是为了动态创建组件啊。

image.png

等等,这难道不就是部分脚手架支持终端输入命令,创建组件的操作吗?当然,有些脚手架自动化创建组件是基于ejs模块的,而elemnt库采用的是fs对文件的直接读写。

PS:查看发现package.json貌似没有暴露这个终端脚本,所以不看川哥写的,我也不知道有这个命令,猜测是element作者扩展这个功能是给自己创建新组件用的。当然,我们可以参考借鉴这种功能,以后开发自己npm工具时可以用上。

make new后面接两个参数,第一个必传,第二个不传默认会取第一个。

make new 自定义组件名 中文名(用于官网左侧菜单栏展示)

当然,我们查看Makefile文件发现,make new执行的其实是node build/bin/new.js命令,后面调试我们直接使用这个命令。

new:
	node build/bin/new.js $(filter-out $@,$(MAKECMDGOALS))

项目地址:github.com/lxchuan12/e…

这边就不单独创建项目引用element的源码了,直接用川哥的仓库代码了,大家直接拉代码运行即可。

// 克隆代码
git clone https://github.com/lxchuan12/element-analysis.git

// cd到element目录下,npm run dev跑项目
cd element && npm run dev

注意,此处运行dev脚本,会先运行yarn装依赖的,因此不必单独npm i装依赖。

image.png

一、分析前

1.最终结果简述

在分析源码前,在此顺便把这个命令执行之后,实际都做了哪些事情提一下,这样后面读者在看分析的时候心里有个准备。

当然,代码的执行逻辑并不是立即创建涉及到新组件的那些文件,可能会先创建那些文件的内容,然后后面才创建并写入到文件中,关于顺序问题等下分析具体讲解,此处只描述最终结果。

假设终端输入node .\build\bin\new.js Ali 阿离组件,即指定要创建的组件名叫Ali,则,

生成下面文件:

  • 组件本身main.vue文件;
  • 对应该组件导出入口文件index.js文件;
  • ali.scss样式文件;
  • ali.spec.js组件测试文件;
  • ali.d.ts对组件ts的声明文件;
  • 四种语言的ali.md文件,注意官网的文档是利用md插件解析md文件展示的;

修改下面文件:

  • components.json文件,里面存放了所有组件信息,新增的Ali组件需要更新进去;
  • index.scss文件,里面汇总了所有组件的scss样式文件,新增的ali.scss也要在里面导入;
  • element-ui.d.ts文件,里面暴露所有组件的ts声明,新增的ali.d.ts声明内容要在里面统一暴露;
  • nav.config.json文件,官网左侧组件菜单的渲染是基于该文件遍历的,新增的Ali组件也要加进去;

以上,就是执行该命令的最终结果,后面我们会具体分析代码执行逻辑。

2.关于调试方式

通过上面的分析,我们知道新建组件的功能,其实执行的是build/bin/new.js这个文件,我们可以进到这个文件中随便打上断点,进行调试,注意一定要先打上断点。

现在我们有几种方式进入到调试模式:

  • 第一种是按照以往我们用过的,在package.json点击调试按钮,选择dev进入到调试模式; image.png
  • 第二种是点击左侧调试图标,选择【launch program】、点击右侧设置图标,进入调试项目配置页面,默认【program】就会显示当前项目的目录,平时的话如果不需要携带参数,则可以直接点击左侧刚才【launch program】上的启动按钮,就会开启调试模式,但是该终端命令明显需要我们传入组件名称的参数,因此手动添加了【args】参数,如下图,然后点击启动按钮,进入调试模式; image.png
  • 第三种还是点击左侧调试图标,此时选择的是【JavaScript Debug Terminal】,点击左边的启动按钮,进入到调试终端,然后终端命令进入到当前项目目录,输入node .\build\bin\new.js Ali 阿离组件,进入调试模式; image.png

以上三种方式,大家选择自己喜欢的一种即可。

接下来我们开始分析具体的代码。

二、源码分析

由于该文件代码量并不多,此处还是把完整代码贴出来吧,然后取里面的关键性代码分析,读者可先忽略下面完整代码,跟着分析过程单独截取其中的代码加以理解。

但是依旧建议读者自行按照上面说明的方式拉取代码,在自己的vscode运行代码调试,然后打开new.js文件,跟着下面过程分析,这样整体代码不会有被割裂,如果只看我下面贴出来的代码,再翻上翻下看分析,并不友好。(当前读者也可以自行分析整个过程,代码并不复杂)

'use strict';

console.log();
process.on('exit', () => {
  console.log();
});

if (!process.argv[2]) {
  console.error('[组件名]必填 - Please enter new component name');
  process.exit(1);
}

const path = require('path');
const fs = require('fs');
const fileSave = require('file-save');
const uppercamelcase = require('uppercamelcase');
const componentname = process.argv[2];
const chineseName = process.argv[3] || componentname;
const ComponentName = uppercamelcase(componentname);
const PackagePath = path.resolve(__dirname, '../../packages', componentname);
const Files = [
  {
    filename: 'index.js',
    content: `import ${ComponentName} from './src/main';

/* istanbul ignore next */
${ComponentName}.install = function(Vue) {
  Vue.component(${ComponentName}.name, ${ComponentName});
};

export default ${ComponentName};`
  },
  {
    filename: 'src/main.vue',
    content: `<template>
  <div class="el-${componentname}"></div>
</template>

<script>
export default {
  name: 'El${ComponentName}'
};
</script>`
  },
  {
    filename: path.join('../../examples/docs/zh-CN', `${componentname}.md`),
    content: `## ${ComponentName} ${chineseName}`
  },
  {
    filename: path.join('../../examples/docs/en-US', `${componentname}.md`),
    content: `## ${ComponentName}`
  },
  {
    filename: path.join('../../examples/docs/es', `${componentname}.md`),
    content: `## ${ComponentName}`
  },
  {
    filename: path.join('../../examples/docs/fr-FR', `${componentname}.md`),
    content: `## ${ComponentName}`
  },
  {
    filename: path.join('../../test/unit/specs', `${componentname}.spec.js`),
    content: `import { createTest, destroyVM } from '../util';
import ${ComponentName} from 'packages/${componentname}';

describe('${ComponentName}', () => {
  let vm;
  afterEach(() => {
    destroyVM(vm);
  });

  it('create', () => {
    vm = createTest(${ComponentName}, true);
    expect(vm.$el).to.exist;
  });
});
`
  },
  {
    filename: path.join('../../packages/theme-chalk/src', `${componentname}.scss`),
    content: `@import "mixins/mixins";
@import "common/var";

@include b(${componentname}) {
}`
  },
  {
    filename: path.join('../../types', `${componentname}.d.ts`),
    content: `import { ElementUIComponent } from './component'

/** ${ComponentName} Component */
export declare class El${ComponentName} extends ElementUIComponent {
}`
  }
];

// 添加到 components.json
const componentsFile = require('../../components.json');
if (componentsFile[componentname]) {
  console.error(`${componentname} 已存在.`);
  process.exit(1);
}
componentsFile[componentname] = `./packages/${componentname}/index.js`;
fileSave(path.join(__dirname, '../../components.json'))
  .write(JSON.stringify(componentsFile, null, '  '), 'utf8')
  .end('\n');

// 添加到 index.scss
const sassPath = path.join(__dirname, '../../packages/theme-chalk/src/index.scss');
const sassImportText = `${fs.readFileSync(sassPath)}@import "./${componentname}.scss";`;
fileSave(sassPath)
  .write(sassImportText, 'utf8')
  .end('\n');

// 添加到 element-ui.d.ts
const elementTsPath = path.join(__dirname, '../../types/element-ui.d.ts');

let elementTsText = `${fs.readFileSync(elementTsPath)}
/** ${ComponentName} Component */
export class ${ComponentName} extends El${ComponentName} {}`;

const index = elementTsText.indexOf('export') - 1;
const importString = `import { El${ComponentName} } from './${componentname}'`;

elementTsText = elementTsText.slice(0, index) + importString + '\n' + elementTsText.slice(index);

fileSave(elementTsPath)
  .write(elementTsText, 'utf8')
  .end('\n');

// 创建 package
Files.forEach(file => {
  fileSave(path.join(PackagePath, file.filename))
    .write(file.content, 'utf8')
    .end('\n');
});

// 添加到 nav.config.json
const navConfigFile = require('../../examples/nav.config.json');

Object.keys(navConfigFile).forEach(lang => {
  let groups = navConfigFile[lang][4].groups;
  groups[groups.length - 1].list.push({
    path: `/${componentname}`,
    title: lang === 'zh-CN' && componentname !== chineseName
      ? `${ComponentName} ${chineseName}`
      : ComponentName
  });
});

fileSave(path.join(__dirname, '../../examples/nav.config.json'))
  .write(JSON.stringify(navConfigFile, null, '  '), 'utf8')
  .end('\n');

console.log('DONE!');

1.部分模块引用说明

// 注册监听进程退出
process.on('exit', () => {
  console.log();
});
//如果第一个参数组件名没有传,则直接退出进程(大家调试的时候可以看process.argv是什么)
if (!process.argv[2]) {
  console.error('[组件名]必填 - Please enter new component name');
  process.exit(1);
}

const path = require('path');
const fs = require('fs');
const fileSave = require('file-save'); // 该插件是对文件进行写入操作的
const uppercamelcase = require('uppercamelcase'); // 首字母转大写
const componentname = process.argv[2];
const chineseName = process.argv[3] || componentname; // 第二个参数中文名未传则默认取第一个组件名参数
const ComponentName = uppercamelcase(componentname); // 转大写获取大驼峰组件名,如ali => Ali
const PackagePath = path.resolve(__dirname, '../../packages', componentname); // 拼接组件存放的目录绝对路径

2.新增文件的内容

为了代码可读性,下面我会对这段代码进行格式的调整,让看起来更清晰(可能会忽略实际渲染需要的空行、缩进、空格等等,具体原格式请看原代码)。

这段代码定义了一个数组Files,数组每个元素都是对象,对象有两个属性:filename和content,其中filename是需要新增文件的文件路径,而content为该文件的内容,因为内容需要插入我们创建组件传的组件名参数,因此用了` `模板字符串形式。

需要创建的文件,包含前面【说明】中提到的组件本身main.vue、对外暴露的index.js、组件样式scss文件、四种语言的md文件、spec.js组件测试文件、组件的ts声明文件.d.ts

(注意,创建文件并把内容插入到文件的操作在后面执行,并不是立即进行了操作,此处只是创建了这么一个数组对象)

const Files = [
  {
    filename: 'index.js',
    content: `
            import ${ComponentName} from './src/main';
            /* istanbul ignore next */
            ${ComponentName}.install = function(Vue) {
              Vue.component(${ComponentName}.name, ${ComponentName});
            };
            export default ${ComponentName};
            `
  },
  {
    filename: 'src/main.vue',
    content: `
            <template>
              <div class="el-${componentname}"></div>
            </template>
            <script>
            export default {
              name: 'El${ComponentName}'
            };
            </script>
            `
  },
  {
    filename: path.join('../../examples/docs/zh-CN', `${componentname}.md`),
    content: `## ${ComponentName} ${chineseName}`
  },
  {
    filename: path.join('../../examples/docs/en-US', `${componentname}.md`),
    content: `## ${ComponentName}`
  },
  {
    filename: path.join('../../examples/docs/es', `${componentname}.md`),
    content: `## ${ComponentName}`
  },
  {
    filename: path.join('../../examples/docs/fr-FR', `${componentname}.md`),
    content: `## ${ComponentName}`
  },
  {
    filename: path.join('../../test/unit/specs', `${componentname}.spec.js`),
    content: `
            import { createTest, destroyVM } from '../util';
            import ${ComponentName} from 'packages/${componentname}';
            describe('${ComponentName}', () => {
              let vm;
              afterEach(() => {
                destroyVM(vm);
              });

              it('create', () => {
                vm = createTest(${ComponentName}, true);
                expect(vm.$el).to.exist;
              });
            });
            `
  },
  {
    filename: path.join('../../packages/theme-chalk/src', `${componentname}.scss`),
    content: `
             @import "mixins/mixins";
             @import "common/var";
             @include b(${componentname}) {}
             `
  },
  {
    filename: path.join('../../types', `${componentname}.d.ts`),
    content: `
            import { ElementUIComponent } from './component'
            /** ${ComponentName} Component */
            export declare class El${ComponentName} extends ElementUIComponent {
            }
            `
  }
];

3.修改components.json

components.json存放所有组件的映射信息,先匹配当前组件是否存在,不存在时把新增的组件映射信息追加写入该文件。 注意该写入是异步的,调试可以发现,执行下面代码会清空该文件,而不会立即写入,会等到js线程同步代码执行完才会真正执行异步的写入。

// 添加到 components.json
const componentsFile = require('../../components.json');
if (componentsFile[componentname]) {
  console.error(`${componentname} 已存在.`);
  process.exit(1);
}
componentsFile[componentname] = `./packages/${componentname}/index.js`;
fileSave(path.join(__dirname, '../../components.json'))
  .write(JSON.stringify(componentsFile, null, '  '), 'utf8')
  .end('\n');

这里有一点要注意,end方法并不是fs.createWriteStream创建的写入流上的方法(fileSave内部是使用fs.createWriteStream进行文件写入的),而是Stream原型上的方法。当然由于fs.createWriteStream返回类型是WriteStream的实例,而WriteStream继承Writable,Writable继承Stream,自然就可以使用end方法在关闭流之前写入一个新的数据块。

此处写入的是\n空行,为了方便下一次追加组件数据进去时处于换行。

4.修改index.scss

// 添加到 index.scss
const sassPath = path.join(__dirname, '../../packages/theme-chalk/src/index.scss');
const sassImportText = `${fs.readFileSync(sassPath)}@import "./${componentname}.scss";`;
fileSave(sassPath)
  .write(sassImportText, 'utf8')
  .end('\n');

5.修改element.d.ts

此处要说明下,该文件上面都是使用import导入组件声明,下面都用export导出组件的继承类(具体内容暂时不管,我也不是很清楚为啥这样)。

总之上面是import,下面都是export。由于要插入的代码有import和export语句,因此下面处理逻辑就是把export插入到文件最后,而import插入到所有import的最后,在export之前,就是为了格式清晰。

// 添加到 element-ui.d.ts
const elementTsPath = path.join(__dirname, '../../types/element-ui.d.ts');

// 直接把这段export代码追加到文件末尾
let elementTsText = `${fs.readFileSync(elementTsPath)}
/** ${ComponentName} Component */
export class ${ComponentName} extends El${ComponentName} {}`;

const index = elementTsText.indexOf('export') - 1;
const importString = `import { El${ComponentName} } from './${componentname}'`;
// 获取第一个export的位置,即上面都是import的代码,然后把要插入的import代码写到所有的inport最后
elementTsText = elementTsText.slice(0, index) + importString + '\n' + elementTsText.slice(index);

fileSave(elementTsPath)
  .write(elementTsText, 'utf8')
  .end('\n');

image.png

6.创建所有要新增的文件

到这里,才遍历最早定义的需要新增文件的数组,写入到新的文件中。

// 创建 package
Files.forEach(file => {
  fileSave(path.join(PackagePath, file.filename))
    .write(file.content, 'utf8')
    .end('\n');
});

7.修改nav.config.json文件

const navConfigFile = require('../../examples/nav.config.json');

// 遍历该文件的内容,分别插入到四种语言中存储组件的list数组内(大家可以自行查看下nav.config.json数据结构,此处不再赘述)
Object.keys(navConfigFile).forEach(lang => {
  let groups = navConfigFile[lang][4].groups;
  groups[groups.length - 1].list.push({
    path: `/${componentname}`,
    title: lang === 'zh-CN' && componentname !== chineseName
      ? `${ComponentName} ${chineseName}`
      : ComponentName
  });
});

fileSave(path.join(__dirname, '../../examples/nav.config.json'))
  .write(JSON.stringify(navConfigFile, null, '  '), 'utf8')
  .end('\n');

console.log('DONE!');

以上就是关于element通过命令,自动化新增组件的分析过程。

三、感想和收获

1.感想

其实关于element新增组件这个代码逻辑并不复杂,主要就是定义好数据结构,并不断使用path.resolve拼接好文件的绝对路径,最后使用fs.createWriteStream对文件进行写入。

建议读者创建个空项目,平时遇到比较有意思的代码,可在这个项目上单独测试相应的功能,时间久了就会发现,原来写了这么多的demo了。

2.收获

  • 现在掌握了三种进入调试模式的方法了;
  • 再一次提升了源码阅读的能力;
  • 加深了fs对于写入文件的操作;