本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。。
这是源码共读的第15期,链接:element 初始化组件功能。
说明
刚读这一期源码共读的时候,看了川哥开头的描述,其实我是不懂该期是要干啥的,在我看到要输入 make new终端命令时,我还是一脸懵逼,以至于我停了下来,去学习其他东西去了......
当然,今天我耐着性子打算再看一遍,便直接把项目运行起来了,然后终端输入node build/bin/new.js Ali 阿离组件才发现,页面左侧菜单组件最下面多了一个【Ali 阿离组件】,原来这个make new终端命令的目的是为了动态创建组件啊。
等等,这难道不就是部分脚手架支持终端输入命令,创建组件的操作吗?当然,有些脚手架自动化创建组件是基于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))
这边就不单独创建项目引用element的源码了,直接用川哥的仓库代码了,大家直接拉代码运行即可。
// 克隆代码
git clone https://github.com/lxchuan12/element-analysis.git
// cd到element目录下,npm run dev跑项目
cd element && npm run dev
注意,此处运行dev脚本,会先运行yarn装依赖的,因此不必单独npm i装依赖。
一、分析前
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进入到调试模式;
- 第二种是点击左侧调试图标,选择【launch program】、点击右侧设置图标,进入调试项目配置页面,默认【program】就会显示当前项目的目录,平时的话如果不需要携带参数,则可以直接点击左侧刚才【launch program】上的启动按钮,就会开启调试模式,但是该终端命令明显需要我们传入组件名称的参数,因此手动添加了【args】参数,如下图,然后点击启动按钮,进入调试模式;
- 第三种还是点击左侧调试图标,此时选择的是【JavaScript Debug Terminal】,点击左边的启动按钮,进入到调试终端,然后终端命令进入到当前项目目录,输入
node .\build\bin\new.js Ali 阿离组件,进入调试模式;
以上三种方式,大家选择自己喜欢的一种即可。
接下来我们开始分析具体的代码。
二、源码分析
由于该文件代码量并不多,此处还是把完整代码贴出来吧,然后取里面的关键性代码分析,读者可先忽略下面完整代码,跟着分析过程单独截取其中的代码加以理解。
但是依旧建议读者自行按照上面说明的方式拉取代码,在自己的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');
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对于写入文件的操作;