文章已收录到 github,欢迎 Watch 和 Star。
详细讲解了 ElementUI 的源码架构,为下一步基于 ElementUI 打造团队自己的组件库打好坚实的基础。
组件库是现代前端领域中不可缺少的一项基建。它可以提高代码的复用性、可维护性,提高团队的生产效率,更好的服务于未来。
那么如何为团队打造自己的组件库呢? 最理想的方案是借用社区的能力,去裁剪一个优秀的开源库,只保留你需要的东西,比如它的架构、工程化和文档能力,以及部分基础组件,在裁剪的过程中你可能会发现它的一些问题,然后在你的组件库中去优化并解决。
因为团队的技术栈是 Vue,所以选择基于 element 进行二次开发,在开始前先对 element 框架源码进行详细的刨析,为打造组件库做知识储备。element 框架源码由工程化、官网、组件库、测试和类型声明这 5 部分组成。
element 的架构是真的优秀,通过大量的脚本实现优秀的工程化,致力于让组件库的开发者专注于事情本身。比如添加新组件时,一键生成组件所有文件并完成这些文件基本结构的编写和相关的引入配置,总共涉及 13 个文件的添加和改动,而你只需完成组件定义这件事即可。element 的工程化由 5 部分组成:build 目录下的工程化配置和脚本、eslint、travis ci、Makefile、package.json 的 scripts。
build 目录存放工程化相关配置和脚本。比如 /build/bin
目录下的 JS 脚本让组件库开发者专注于组件的开发,除此之外不需要管其他任何事情;build/md-loader
是官网组件页面根据 markdown 实现组件 demo + 文档 的关键;还有比如持续集成、webpack 配置等,接下来就详细介绍这些配置和脚本。
组件配置文件(components.json)结合字符串模版库,自动生成 /src/index.js 文件,避免每次新增组件时手动在 /src/index.js 中引入和导出组件。
/** * 生成 /src/index.js * 1、自动导入组件库所有组件 * 2、定义全量注册组件库组件的 install 方法 * 3、导出版本、install、各个组件 */ // key 为包名、路径为值 var Components = require('../../components.json'); var fs = require('fs'); // 模版库 var render = require('json-templater/string'); // 负责将 comp-name 形式的字符串转换为 CompName var uppercamelcase = require('uppercamelcase'); var path = require('path'); var endOfLine = require('os').EOL; // 输出路径 /src/index.js var OUTPUT_PATH = path.join(__dirname, '../../src/index.js'); // 导入模版,import CompName from '../packages/comp-name/index.js' var IMPORT_TEMPLATE = 'import {{name}} from \'../packages/{{package}}/index.js\';'; // ' CompName' var INSTALL_COMPONENT_TEMPLATE = ' {{name}}'; // /src/index.js 的模版 var MAIN_TEMPLATE = `/* Automatically generated by './build/bin/build-entry.js' */ {{include}} import locale from 'element-ui/src/locale'; import CollapseTransition from 'element-ui/src/transitions/collapse-transition'; const components = [ {{install}}, CollapseTransition ]; const install = function(Vue, opts = {}) { locale.use(opts.locale); locale.i18n(opts.i18n); components.forEach(component => { Vue.component(component.name, component); }); Vue.use(InfiniteScroll); Vue.use(Loading.directive); Vue.prototype.$ELEMENT = { size: opts.size || '', zIndex: opts.zIndex || 2000 }; Vue.prototype.$loading = Loading.service; Vue.prototype.$msgbox = MessageBox; Vue.prototype.$alert = MessageBox.alert; Vue.prototype.$confirm = MessageBox.confirm; Vue.prototype.$prompt = MessageBox.prompt; Vue.prototype.$notify = Notification; Vue.prototype.$message = Message; }; /* istanbul ignore if */ if (typeof window !== 'undefined' && window.Vue) { install(window.Vue); } export default { version: '{{version}}', locale: locale.use, i18n: locale.i18n, install, CollapseTransition, Loading, {{list}} }; `; delete Components.font; // 得到所有的包名,[comp-name1, comp-name2] var ComponentNames = Object.keys(Components); // 存放所有的 import 语句 var includeComponentTemplate = []; // 组件名数组 var installTemplate = []; // 组件名数组 var listTemplate = []; // 遍历所有的包名 ComponentNames.forEach(name => { // 将连字符格式的包名转换成大驼峰形式,就是组件名,比如 form-item =》 FormItem var componentName = uppercamelcase(name); // 替换导入语句中的模版变量,生成导入语句,import FromItem from '../packages/form-item/index.js' includeComponentTemplate.push(render(IMPORT_TEMPLATE, { name: componentName, package: name })); // 这些组件从 components 数组中剔除,不需要全局注册,采用挂载到原型链的方式,在模版字符串的 install 方法中有写 if (['Loading', 'MessageBox', 'Notification', 'Message', 'InfiniteScroll'].indexOf(componentName) === -1) { installTemplate.push(render(INSTALL_COMPONENT_TEMPLATE, { name: componentName, component: name })); } // 将所有的组件放到 listTemplates,最后导出 if (componentName !== 'Loading') listTemplate.push(` ${componentName}`); }); // 替换模版中的四个变量 var template = render(MAIN_TEMPLATE, { include: includeComponentTemplate.join(endOfLine), install: installTemplate.join(',' + endOfLine), version: process.env.VERSION || require('../../package.json').version, list: listTemplate.join(',' + endOfLine) }); // 将就绪的模版写入 /src/index.js fs.writeFileSync(OUTPUT_PATH, template); console.log('[build entry] DONE:', OUTPUT_PATH);
通过 babel 将 ES Module 风格的所有翻译文件(/src/locale/lang)转译成 UMD 风格。
/** * 通过 babel 将 ES Module 风格的翻译文件转译成 UMD 风格 */ var fs = require('fs'); var save = require('file-save'); var resolve = require('path').resolve; var basename = require('path').basename; // 翻译文件目录,这些文件用于官网 var localePath = resolve(__dirname, '../../src/locale/lang'); // 得到目录下的所有翻译文件 var fileList = fs.readdirSync(localePath); // 转换函数 var transform = function(filename, name, cb) { require('babel-core').transformFile(resolve(localePath, filename), { plugins: [ 'add-module-exports', ['transform-es2015-modules-umd', {loose: true}] ], moduleId: name }, cb); }; // 遍历所有文件 fileList // 只处理 js 文件,其实目录下不存在非 js 文件 .filter(function(file) { return /\.js$/.test(file); }) .forEach(function(file) { var name = basename(file, '.js'); // 调用转换函数,将转换后的代码写入到 lib/umd/locale 目录下 transform(file, name, function(err, result) { if (err) { console.error(err); } else { var code = result.code; code = code .replace('define(\'', 'define(\'element/locale/') .replace('global.', 'global.ELEMENT.lang = global.ELEMENT.lang || {}; \n global.ELEMENT.lang.'); save(resolve(__dirname, '../../lib/umd/locale', file)).write(code); console.log(file); } }); });
自动在 /packages/theme-chalk/src/index.scss|css
中引入各个组件包的样式,在全量注册组件库时需要用到这个样式文件,即 import 'packages/theme-chalk/src/index.scss
。
/** * 自动在 /packages/theme-chalk/src/index.scss|css 中引入各个组件包的样式 * 在全量注册组件库时需要用到该样式文件,即 import 'packages/theme-chalk/src/index.scss */ var fs = require('fs'); var path = require('path'); var Components = require('../../components.json'); var themes = [ 'theme-chalk' ]; // 得到所有的包名 Components = Object.keys(Components); // 所有组件包的基础路径,/packages var basepath = path.resolve(__dirname, '../../packages/'); // 判断指定文件是否存在 function fileExists(filePath) { try { return fs.statSync(filePath).isFile(); } catch (err) { return false; } } // 遍历所有组件包,生成引入所有组件包样式的 import 语句,然后自动生成 packages/theme-chalk/src/index.scss|css 文件 themes.forEach((theme) => { // 是否是 scss,element-ui 默认使用 scss 编写样式 var isSCSS = theme !== 'theme-default'; // 导入基础样式文件 @import "./base.scss|css";\n var indexContent = isSCSS ? '@import "./base.scss";\n' : '@import "./base.css";\n'; // 遍历所有组件包,并生成 @import "./comp-package.scss|css";\n Components.forEach(function(key) { // 跳过这三个组件包 if (['icon', 'option', 'option-group'].indexOf(key) > -1) return; // comp-package.scss|css var fileName = key + (isSCSS ? '.scss' : '.css'); // 导入语句,@import "./comp-package.scss|css";\n indexContent += '@import "./' + fileName + '";\n'; // 如果该组件包的样式文件不存在,比如 /packages/form-item/theme-chalk/src/form-item.scss 不存在,则认为其被遗漏了,创建该文件 var filePath = path.resolve(basepath, theme, 'src', fileName); if (!fileExists(filePath)) { fs.writeFileSync(filePath, '', 'utf8'); console.log(theme, ' 创建遗漏的 ', fileName, ' 文件'); } }); // 生成 /packages/theme-chalk/src/index.scss|css,负责引入所有组件包的样式 fs.writeFileSync(path.resolve(basepath, theme, 'src', isSCSS ? 'index.scss' : 'index.css'), indexContent); });
根据模版(/examples/pages/template
)生成四种语言的官网页面的 .vue 文件。
'use strict'; var fs = require('fs'); var path = require('path'); // 官网页面翻译配置,内置了四种语言 var langConfig = require('../../examples/i18n/page.json'); // 遍历所有语言 langConfig.forEach(lang => { // 创建 /examples/pages/{lang},比如: /examples/pages/zh-CN try { fs.statSync(path.resolve(__dirname, `../../examples/pages/${ lang.lang }`)); } catch (e) { fs.mkdirSync(path.resolve(__dirname, `../../examples/pages/${ lang.lang }`)); } // 遍历所有的页面,根据 page.tpl 自动生成对应语言的 .vue 文件 Object.keys(lang.pages).forEach(page => { // 比如 /examples/pages/template/index.tpl var templatePath = path.resolve(__dirname, `../../examples/pages/template/${ page }.tpl`); // /examples/pages/zh-CN/index.vue var outputPath = path.resolve(__dirname, `../../examples/pages/${ lang.lang }/${ page }.vue`); // 读取模版文件 var content = fs.readFileSync(templatePath, 'utf8'); // 读取 index 页面的所有键值对的配置 var pairs = lang.pages[page]; // 遍历这些键值对,通过正则匹配的方式替换掉模版中对应的 key Object.keys(pairs).forEach(key => { content = content.replace(new RegExp(`<%=\\s*${ key }\\s*>`, 'g'), pairs[key]); }); // 将替换后的内容写入 vue 文件 fs.writeFileSync(outputPath, content); }); });
根据 icon.scss 样式文件中的选择器,通过正则匹配的方式,匹配出所有的 icon 名称,然后将这些 icon 名组成数组,将数组写入到 /examples/icon.json
文件中,该文件在官网的 icon 图标页用来自动生成所有的 icon 图标。
'use strict'; /** * 根据 icon.scss 样式文件中的选择器,通过正则匹配的方式,匹配出所有的 icon 名称, * 然后将所有 icon 名组成的数组写入到 /examples/icon.json 文件中 * 该文件在官网的 icon 图标页用来自动生成所有的 icon 图标 */ var postcss = require('postcss'); var fs = require('fs'); var path = require('path'); // icon.scss 文件内容 var fontFile = fs.readFileSync(path.resolve(__dirname, '../../packages/theme-chalk/src/icon.scss'), 'utf8'); // 得到样式节点 var nodes = postcss.parse(fontFile).nodes; var classList = []; // 遍历所有的样式节点 nodes.forEach((node) => { // 从选择器中匹配出 icon 名称,比如 el-icon-add,匹配得到 add var selector = node.selector || ''; var reg = new RegExp(/\.el-icon-([^:]+):before/); var arr = selector.match(reg); // 将 icon 名称写入数组, if (arr && arr[1]) { classList.push(arr[1]); } }); classList.reverse(); // 希望按 css 文件顺序倒序排列 // 将 icon 名组成的数组写入 /examples/icon.json 文件 fs.writeFile(path.resolve(__dirname, '../../examples/icon.json'), JSON.stringify(classList), () => {});
为组件库添加新语言,比如 fr(法语),分别为涉及到的文件(components.json、page.json、route.json、nav.config.json、docs)设置该语言的相关配置,具体的配置项默认为英语,你只需要在相应的文件中将这些英文配置项翻译为对应的语言即可。
'use strict'; /** * 为组件库添加新语言,比如 fr(法语) * 分别为涉及到的文件(components.json、page.json、route.json、nav.config.json、docs)设置该语言的相关配置 * 具体的配置项默认为英语,你只需要在相应的文件中将这些英文配置项翻译为对应的语言即可 */ console.log(); process.on('exit', () => { console.log(); }); if (!process.argv[2]) { console.error('[language] is required!'); process.exit(1); } var fs = require('fs'); const path = require('path'); const fileSave = require('file-save'); const lang = process.argv[2]; // const configPath = path.resolve(__dirname, '../../examples/i18n', lang); // 添加到 components.json const componentFile = require('../../examples/i18n/component.json'); if (componentFile.some(item => item.lang === lang)) { console.error(`${lang} already exists.`); process.exit(1); } let componentNew = Object.assign({}, componentFile.filter(item => item.lang === 'en-US')[0], { lang }); componentFile.push(componentNew); fileSave(path.join(__dirname, '../../examples/i18n/component.json')) .write(JSON.stringify(componentFile, null, ' '), 'utf8') .end('\n'); // 添加到 page.json const pageFile = require('../../examples/i18n/page.json'); // 新语言的默认配置为英语,你只需要去 page.json 中将该语言配置中的应为翻译为该语言即可 let pageNew = Object.assign({}, pageFile.filter(item => item.lang === 'en-US')[0], { lang }); pageFile.push(pageNew); fileSave(path.join(__dirname, '../../examples/i18n/page.json')) .write(JSON.stringify(pageFile, null, ' '), 'utf8') .end('\n'); // 添加到 route.json const routeFile = require('../../examples/i18n/route.json'); routeFile.push({ lang }); fileSave(path.join(__dirname, '../../examples/i18n/route.json')) .write(JSON.stringify(routeFile, null, ' '), 'utf8') .end('\n'); // 添加到 nav.config.json const navFile = require('../../examples/nav.config.json'); navFile[lang] = navFile['en-US']; fileSave(path.join(__dirname, '../../examples/nav.config.json')) .write(JSON.stringify(navFile, null, ' '), 'utf8') .end('\n'); // docs 下新建对应文件夹 try { fs.statSync(path.resolve(__dirname, `../../examples/docs/${ lang }`)); } catch (e) { fs.mkdirSync(path.resolve(__dirname, `../../examples/docs/${ lang }`)); } console.log('DONE!');
为组件库添加新组件时会使用该脚本,一键生成组件所有文件并完成这些文件基本结构的编写和相关的引入配置,总共涉及 13 个文件的添加和改动,比如:make new city 城市列表
。该脚本的存在,让你为组件库开发新组件时,只需专注于组件代码的编写即可,其它的一概不用管。
'use strict'; /** * 添加新组件 * 比如:make new city 城市列表 * 1、在 /packages 目录下新建组件目录,并完成目录结构的创建 * 2、创建组件文档,/examples/docs/{lang}/city.md * 3、创建组件单元测试文件,/test/unit/specs/city.spec.js * 4、创建组件样式文件,/packages/theme-chalk/src/city.scss * 5、创建组件类型声明文件,/types/city.d.ts * 6、配置 * 在 /components.json 文件中配置组件信息 * 在 /examples/nav.config.json 中添加该组件的路由配置 * 在 /packages/theme-chalk/src/index.scss 文件中自动引入该组件的样式文件 * 将类型声明文件在 /types/element-ui.d.ts 中自动引入 * 总之,该脚本的存在,让你只需专注于编写你的组件代码,其它的一概不用管 */ 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'); // 组件名称,比如 city const componentname = process.argv[2]; // 组件的中文名称 const chineseName = process.argv[3] || componentname; // 将组件名称转换为大驼峰形式,city => City const ComponentName = uppercamelcase(componentname); // 组件包目录,/packages/city const PackagePath = path.resolve(__dirname, '../../packages', componentname); // 需要添加的文件列表和文件内容的基本结构 const Files = [ // /packages/city/index.js { filename: 'index.js', // 文件内容,引入组件,定义组件静态方法 install 用来注册组件,然后导出组件 content: `import ${ComponentName} from './src/main'; /* istanbul ignore next */ ${ComponentName}.install = function(Vue) { Vue.component(${ComponentName}.name, ${ComponentName}); }; export default ${ComponentName};` }, // 定义组件的基本结构,/packages/city/src/main.vue { filename: 'src/main.vue', // 文件内容,sfc content: `<template> <div class="el-${componentname}"></div> </template> <script> export default { name: 'El${ComponentName}' }; </script>` }, // 四种语言的文档,/examples/docs/{lang}/city.md,并设置文件标题 { 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}` }, // 组件测试文件,/test/unit/specs/city.spec.js { 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; }); }); ` }, // 组件样式文件,/packages/theme-chalk/src/city.scss { 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,{ City: './packages/city/index.js' } 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'); // 遍历 Files 数组,创建列出的所有文件并写入文件内容 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!');
这里有个缺点就是,新建组件时不会自动重新生成 /src/index.js,也就是说不会将新生成的组件自动在组件库入口中引入。这也简单,只需要配置下 Makefile 即可,将 new 命令改成 node build/bin/new.js $(filter-out $@,$(MAKECMDGOALS)) && npm run build:file
即可。
监听 /examples/pages/template
目录下的所有模版文件,当模版文件发生改变时自动执行 npm run i18n
,即执行 i18n.js 脚本,重新生成四种语言的 .vue 文件。
/** * 监听 /examples/pages/template 目录下的所有模版文件,当模版文件发生改变时自动执行 npm run i18n, * 即执行 i18n.js 脚本,重新生成四种语言的 .vue 文件 */ const path = require('path'); // 监听目录 const templates = path.resolve(process.cwd(), './examples/pages/template'); // 负责监听的库 const chokidar = require('chokidar'); // 监听模板目录 let watcher = chokidar.watch([templates]); // 当目录下的文件发生改变时,自动执行 npm run i18n watcher.on('ready', function() { watcher .on('change', function() { exec('npm run i18n'); }); }); // 负责执行命令 function exec(cmd) { return require('child_process').execSync(cmd).toString().trim(); }
根据 /package.json
文件,自动生成 /examples/version.json
,用于记录组件库的版本信息,这些版本洗洗在官网组件页面的头部导航栏会用到。
/** * 根据 package.json 自动生成 /examples/version.json,用于记录组件库的版本信息 * 这些版本信息在官网组件页面的头部导航栏会用到 */ var fs = require('fs'); var path = require('path'); var version = process.env.VERSION || require('../../package.json').version; var content = { '1.4.13': '1.4', '2.0.11': '2.0', '2.1.0': '2.1', '2.2.2': '2.2', '2.3.9': '2.3', '2.4.11': '2.4', '2.5.4': '2.5', '2.6.3': '2.6', '2.7.2': '2.7', '2.8.2': '2.8', '2.9.2': '2.9', '2.10.1': '2.10', '2.11.1': '2.11', '2.12.0': '2.12', '2.13.2': '2.13', '2.14.1': '2.14' }; if (!content[version]) content[version] = '2.15'; fs.writeFileSync(path.resolve(__dirname, '../../examples/versions.json'), JSON.stringify(content));
它是一个 loader,官网组件页面的 组件 demo + 文档的模式一大半的功劳都是源自于它。
可以在 /examples/route.config.js
中看到 registerRoute
方法生成组件页面的路由配置时,使用 loadDocs
方法加载/examples/docs/{lang}/comp.md
。注意,这里加载的 markdown
文档,而不是平时常见的 vue 文件,但是却能想 vue 文件一样在页面上渲染成一个 Vue 组件,这是怎么做到的呢?
我们知道,webpack 的理念是一切资源都可以 require,只需配置相应的 loader 即可。在 /build/webpack.demo.js
文件中的 module.rules
下可以看到对 markdow(.md) 规则的处理,先通过 md-loader
处理 markdown
文件,从中解析出 vue 代码,然后交给 vue-loader,最终生成 sfc(vue 单文件组件)渲染到页面。这就能看到组件页面的文档 + 组件 demo 展示效果。
{ test: /\.md$/, use: [ { loader: 'vue-loader', options: { compilerOptions: { preserveWhitespace: false } } }, { loader: path.resolve(__dirname, './md-loader/index.js') } ] }
如果对 loader 的具体实现感兴趣可以自行深入阅读。
webpack 的公共配置,比如 externals、alias 等。通过 externals 的配置解决了组件库部分代码的冗余问题,比如组件和组件库公共模块的代码,但是组件样式冗余问题没有得到解决;alias 别名配置为开发组件库提供了方便。
/** * webpack 公共配置,比如 externals、alias */ var path = require('path'); var fs = require('fs'); var nodeExternals = require('webpack-node-externals'); var Components = require('../components.json'); var utilsList = fs.readdirSync(path.resolve(__dirname, '../src/utils')); var mixinsList = fs.readdirSync(path.resolve(__dirname, '../src/mixins')); var transitionList = fs.readdirSync(path.resolve(__dirname, '../src/transitions')); /** * externals 解决组件依赖其它组件并按需引入时代码冗余的问题 * 比如 Table 组件依赖 Checkbox 组件,在项目中如果我同时引入 Table 和 Checkbox 时,会不会产生冗余代码 * 如果没有以下内容的的话,会,这时候你会看到有两份 Checkbox 组件代码。 * 包括 locale、utils、mixins、transitions 这些公共内容,也会出现冗余代码 * 但有了 externals 的设置,就会将告诉 webpack 不需要将这些 import 的包打包到 bundle 中,运行时再从外部去 * 获取这些扩展依赖。这样就可以在打包后 /lib/tables.js 中看到编译后的 table.js 对 Checkbox 组件的依赖引入: * module.exports = require("element-ui/lib/checkbox") * 这么处理之后就不会出现冗余的 JS 代码,但是对于 CSS 部分,element-ui 并未处理冗余情况。 * 可以看到 /lib/theme-chalk/table.css 和 /lib/theme-chalk/checkbox.css 中都有 Checkbox 组件的样式 */ var externals = {}; Object.keys(Components).forEach(function(key) { externals[`element-ui/packages/${key}`] = `element-ui/lib/${key}`; }); externals['element-ui/src/locale'] = 'element-ui/lib/locale'; utilsList.forEach(function(file) { file = path.basename(file, '.js'); externals[`element-ui/src/utils/${file}`] = `element-ui/lib/utils/${file}`; }); mixinsList.forEach(function(file) { file = path.basename(file, '.js'); externals[`element-ui/src/mixins/${file}`] = `element-ui/lib/mixins/${file}`; }); transitionList.forEach(function(file) { file = path.basename(file, '.js'); externals[`element-ui/src/transitions/${file}`] = `element-ui/lib/transitions/${file}`; }); externals = [Object.assign({ vue: 'vue' }, externals), nodeExternals()]; exports.externals = externals; // 设置别名,方便使用 exports.alias = { main: path.resolve(__dirname, '../src'), packages: path.resolve(__dirname, '../packages'), examples: path.resolve(__dirname, '../examples'), 'element-ui': path.resolve(__dirname, '../') }; exports.vue = { root: 'Vue', commonjs: 'vue', commonjs2: 'vue', amd: 'vue' }; exports.jsexclude = /node_modules|utils\/popper\.js|utils\/date\.js/;
和 travis ci 结合使用的持续集成脚本,这个脚本在 .travis.yml 文件中被执行,代码被提交到 github 仓库以后会自动被 Tavis CI 执行,ci 会自动找项目中的 .travis.yml 文件,并执行里面的命令。但这个我们可能用不到,一般团队内部都会有自己的持续集成方案。
这里主要是和远程的 dev 分支做 diff 然后合并。
#!/usr/bin/env sh # 这里主要是和远程的 dev 分支做 diff 然后合并 git checkout dev if test -n "$(git status --porcelain)"; then echo 'Unclean working tree. Commit or stash changes first.' >&2; exit 128; fi if ! git fetch --quiet 2>/dev/null; then echo 'There was a problem fetching your branch. Run `git fetch` to see more...' >&2; exit 128; fi if test "0" != "$(git rev-list --count --left-only @'{u}'...HEAD)"; then echo 'Remote history differ. Please pull changes.' >&2; exit 128; fi echo 'No conflicts.' >&2;
脚本完成了以下工作:
合并 dev 分支到 master、
修改样式包和组件库的版本号
发布样式包和组件库
提交 master 和 dev 分支到远程仓库
该脚本在发布组件库时可以使用,特别是其中自动更改版本号的功能(每次 publish 时都忘改版本号)。这里提交代码到远程仓库的日志很简单,更详细的提交日志时通过更新日志文件 CHANGELOG.{lang}.md
提供的。
#!/usr/bin/env sh set -e # 合并 dev 分支到 master # 编译打包 # 修改样式包和组件库的版本号 # 发布样式包和组件库 # 提交 master 和 dev 分支到远程仓库 # 合并 dev 分支到 master git checkout master git merge dev # 版本选择 cli VERSION=`npx select-version-cli` # 是否确认当前版本信息 read -p "Releasing $VERSION - are you sure? (y/n)" -n 1 -r echo # (optional) move to a new line if [[ $REPLY =~ ^[Yy]$ ]] then echo "Releasing $VERSION ..." # build,编译打包 VERSION=$VERSION npm run dist # ssr test node test/ssr/require.test.js # publish theme echo "Releasing theme-chalk $VERSION ..." cd packages/theme-chalk # 更改主题包的版本信息 npm version $VERSION --message "[release] $VERSION" # 发布主题 if [[ $VERSION =~ "beta" ]] then npm publish --tag beta else npm publish fi cd ../.. # commit git add -A git commit -m "[build] $VERSION" # 更改组件库的版本信息 npm version $VERSION --message "[release] $VERSION" # publish,将 master 推到远程仓库 git push eleme master git push eleme refs/tags/v$VERSION git checkout dev git rebase master git push eleme dev # 发布组件库 if [[ $VERSION =~ "beta" ]] then npm publish --tag beta else npm publish fi fi
webpack.common.js,构建 commonjs2 规范的包,会打一个全量的包
webpack.component.js,构建 commonjs2 规范的包,支持按需加载
支持按需加载的重点在于 entry 和 ouput 的配置,将每个组件打成单独的包
webpack.conf.js,构建 UMD 规范的包,会打一个全量的包
webpack.demo.js,官网项目的 webpack 配置
webpack.extension.js,主题编辑器的 chorme 插件项目的 webpack 配置,项目在 extension 目录下
webpack.test.js,这个文件没什么用,不过看命名,应该是想用于测试项目的 webpack 配置,不过现在测试用的是 karma 框架
element 通过 eslint 来保证代码风格的一致性,还专门编写了 elemefe 作为 eslint 的扩展规则配置。为了保证官网项目的质量,在 /build/webpack.demo.js
中配置了 eslint-loader
规则,在项目启动时强制检查代码质量。但是 element 在代码质量控制这块儿做的还是不够,比如:代码自动格式化能力太弱、只保证了 /src、/test、/packages、/build 目录下的代码质量,对于官网项目做的不够,特别是 文档格式的限制。这里建议大家再集成一个 prettier
专门去做格式限制,让 eslint
专注于代码语法的限制,可以参考 搭建自己的 typescript 项目 + 开发自己的脚手架工具 ts-cli 中的 代码质量
部分去配置。
travis ci 结合脚本的方式来完成持续集成的工作,不过这个可能对于内部项目用不上,因为 travis ci 只能用于 github,内部一般使用 gitlab,也有配套的持续集成
make 命令的配置文件,写过 C、C++ 的同学应该比较熟悉。
执行 make 命令可以看到详细的帮助信息。比如:执行 make install 装包、make dev 启动本地开发环境、make new comp-name 中文名 新建组件等。使用 make 命令相较于 npm run xx
更方便、清晰、简单,不过其内部也是依赖于 npm run xx
来完成真正的工作,相当于为了更好的开发体验,将众多 npm run cmd
提供了一层封装。
elemnt 编写了很多 npm scripts,这些 script 结合 /build 中的众多脚本实现通过脚本来自动完成大量重复的体力劳动,比人工靠谱且效率更高,这个设计我觉得是 element 中最值得大家学习的地方,可以将这样的设计应用到自己的项目中,助力业务提效。
{ // 装包 "bootstrap": "yarn || npm i", // 通过JS脚本,自动生成以下文件:生成 examples/icon.json 文件 && 生成 src/index.js 文件 && 生成四种语言的官网的 .vue 文件 && 生成 examples/version.json 文件,包含了组件库的版本信息 "build:file": "node build/bin/iconInit.js & node build/bin/build-entry.js & node build/bin/i18n.js & node build/bin/version.js", // 构建主题样式:在 index.scss 中自动引入各个组件的样式文件 && 通过 gulp 将 scss 文件编译成 css 并输出到 lib 目录 && 拷贝基础样式 theme-chalk 到 lib/theme-chalk "build:theme": "node build/bin/gen-cssfile && gulp build --gulpfile packages/theme-chalk/gulpfile.js && cp-cli packages/theme-chalk/lib lib/theme-chalk", // 通过 babel 编译 src 目录,然后将编译后的文件输出到 lib 目录,忽略 /src/index.js "build:utils": "cross-env BABEL_ENV=utils babel src --out-dir lib --ignore src/index.js", // 将 ES Module 风格的翻译文件编译成 UMD 风格 "build:umd": "node build/bin/build-locale.js", // 清除构建产物 "clean": "rimraf lib && rimraf packages/*/lib && rimraf test/**/coverage", // 构建官网项目 "deploy:build": "npm run build:file && cross-env NODE_ENV=production webpack --config build/webpack.demo.js && echo element.eleme.io>>examples/element-ui/CNAME", // 构建主题插件 "deploy:extension": "cross-env NODE_ENV=production webpack --config build/webpack.extension.js", // 启动主题插件的开发环境 "dev:extension": "rimraf examples/extension/dist && cross-env NODE_ENV=development webpack --watch --config build/webpack.extension.js", // 启动组件库的本地开发环境。执行 build:file,自动化生成一些文件 && 启动 example 项目,即官网 && 监听 examples/pages/template 目录下所有模版文件的变化,如果改变了则重新生成 .vue", "dev": "npm run bootstrap && npm run build:file && cross-env NODE_ENV=development webpack-dev-server --config build/webpack.demo.js & node build/bin/template.js", // 组件测试项目,在 examples/play/index.vue 中可以引入组件库任意组件,也可以直接使用 dev 启动的项目,在文档中使用组件 "dev:play": "npm run build:file && cross-env NODE_ENV=development PLAY_ENV=true webpack-dev-server --config build/webpack.demo.js", // 构建组件库 "dist": "npm run clean && npm run build:file && npm run lint && webpack --config build/webpack.conf.js && webpack --config build/webpack.common.js && webpack --config build/webpack.component.js && npm run build:utils && npm run build:umd && npm run build:theme", // 生成四种语言的官网的 .vue 文件 "i18n": "node build/bin/i18n.js", // lint,保证项目代码质量 "lint": "eslint src/**/* test/**/* packages/**/* build/**/* --quiet", // 装包 && 合并远程仓库的 dev 分支 && 合并 dev 分支到 master、打包编译、修改样式包和组件库的版本号、发布样式包和组件库、提交代码到远程仓库。使用时注掉最后一个脚本,那个脚本有问题 "pub": "npm run bootstrap && sh build/git-release.sh && sh build/release.sh && node build/bin/gen-indices.js", // 生成测试报告,不论是 test 还是 test:watch,生成一次测试报告耗时太长了 "test": "npm run lint && npm run build:theme && cross-env CI_ENV=/dev/ BABEL_ENV=test karma start test/unit/karma.conf.js --single-run", // 启动测试项目,可以检测测试文件的更新 "test:watch": "npm run build:theme && cross-env BABEL_ENV=test karma start test/unit/karma.conf.js" }
element 的官网是和组件库在一个仓库内,官网的所有东西都放在 /examples 目录下,就是一个 vue 项目。
官网项目的入口,在这里全量引入组件库,及其样式。
// 官网项目的入口,就是一个普通的 vue 项目 import Vue from 'vue'; import entry from './app'; import VueRouter from 'vue-router'; // 引入组件库,main 是别名,在 /build/config.js 中有配置 import Element from 'main/index.js'; import hljs from 'highlight.js'; // 路由配置 import routes from './route.config'; // 官网项目的一些组件 import demoBlock from './components/demo-block'; import MainFooter from './components/footer'; import MainHeader from './components/header'; import SideNav from './components/side-nav'; import FooterNav from './components/footer-nav'; import title from './i18n/title'; // 组件库样式 import 'packages/theme-chalk/src/index.scss'; import './demo-styles/index.scss'; import './assets/styles/common.css'; import './assets/styles/fonts/style.css'; // 将 icon 信息挂载到 Vue 原型链上,在 markdown 文档中被使用,在官网的 icon 图标 页面展示出所有的 icon 图标 import icon from './icon.json'; Vue.use(Element); Vue.use(VueRouter); Vue.component('demo-block', demoBlock); Vue.component('main-footer', MainFooter); Vue.component('main-header', MainHeader); Vue.component('side-nav', SideNav); Vue.component('footer-nav', FooterNav); const globalEle = new Vue({ data: { $isEle: false } // 是否 ele 用户 }); Vue.mixin({ computed: { $isEle: { get: () => (globalEle.$data.$isEle), set: (data) => {globalEle.$data.$isEle = data;} } } }); Vue.prototype.$icon = icon; // Icon 列表页用 const router = new VueRouter({ mode: 'hash', base: __dirname, routes }); router.afterEach(route => { // https://github.com/highlightjs/highlight.js/issues/909#issuecomment-131686186 Vue.nextTick(() => { const blocks = document.querySelectorAll('pre code:not(.hljs)'); Array.prototype.forEach.call(blocks, hljs.highlightBlock); }); const data = title[route.meta.lang]; for (let val in data) { if (new RegExp('^' + val, 'g').test(route.name)) { document.title = data[val]; return; } } document.title = 'Element'; ga('send', 'event', 'PageView', route.name); }); new Vue({ // eslint-disable-line ...entry, router }).$mount('#app');
官网组件页面的侧边导航栏配置,一定要了解该 json 文件的结构,才能看懂 route.config.js
文件中生成组件页面所有路由的代码。
根据路由配置自动生成官网项目的路由配置。
// 根据路由配置自动生成官网项目的路由 import navConfig from './nav.config'; // 支持的所有语言 import langs from './i18n/route'; // 加载官网各个页面的 .vue 文件 const LOAD_MAP = { 'zh-CN': name => { return r => require.ensure([], () => r(require(`./pages/zh-CN/${name}.vue`)), 'zh-CN'); }, 'en-US': name => { return r => require.ensure([], () => r(require(`./pages/en-US/${name}.vue`)), 'en-US'); }, 'es': name => { return r => require.ensure([], () => r(require(`./pages/es/${name}.vue`)), 'es'); }, 'fr-FR': name => { return r => require.ensure([], () => r(require(`./pages/fr-FR/${name}.vue`)), 'fr-FR'); } }; const load = function(lang, path) { return LOAD_MAP[lang](path); }; // 加载官网组件页面各个组件的 markdown 文件 const LOAD_DOCS_MAP = { 'zh-CN': path => { return r => require.ensure([], () => r(require(`./docs/zh-CN${path}.md`)), 'zh-CN'); }, 'en-US': path => { return r => require.ensure([], () => r(require(`./docs/en-US${path}.md`)), 'en-US'); }, 'es': path => { return r => require.ensure([], () => r(require(`./docs/es${path}.md`)), 'es'); }, 'fr-FR': path => { return r => require.ensure([], () => r(require(`./docs/fr-FR${path}.md`)), 'fr-FR'); } }; const loadDocs = function(lang, path) { return LOAD_DOCS_MAP[lang](path); }; // 添加组件页的各个路由配置,以下这段代码要看懂必须明白 nav.config.json 文件的结构 const registerRoute = (navConfig) => { let route = []; // 遍历配置,生成四种语言的组件路由配置 Object.keys(navConfig).forEach((lang, index) => { // 指定语言的配置,比如 lang = zh-CN,navs 就是所有配置项都是中文写的 let navs = navConfig[lang]; // 组件页面 lang 语言的路由配置 route.push({ // 比如: /zh-CN/component path: `/${ lang }/component`, redirect: `/${ lang }/component/installation`, // 加载组件页的 component.vue component: load(lang, 'component'), // 组件页的所有子路由,即各个组件,放这里,最后的路由就是 /zh-CN/component/comp-path children: [] }); // 遍历指定语言的所有配置项 navs.forEach(nav => { if (nav.href) return; if (nav.groups) { // 该项为组件 nav.groups.forEach(group => { group.list.forEach(nav => { addRoute(nav, lang, index); }); }); } else if (nav.children) { // 该项为开发指南 nav.children.forEach(nav => { addRoute(nav, lang, index); }); } else { // 其它,比如更新日志、Element React、Element Angular addRoute(nav, lang, index); } }); }); // 生成子路由配置,并填充到 children 中 function addRoute(page, lang, index) { // 根据 path 决定是加载 vue 文件还是加载 markdown 文件 const component = page.path === '/changelog' ? load(lang, 'changelog') : loadDocs(lang, page.path); let child = { path: page.path.slice(1), meta: { title: page.title || page.name, description: page.description, lang }, name: 'component-' + lang + (page.title || page.name), component: component.default || component }; // 将子路由添加在上面的 children 中 route[index].children.push(child); } return route; }; // 得到组件页面所有侧边栏的路由配置 let route = registerRoute(navConfig); const generateMiscRoutes = function(lang) { let guideRoute = { path: `/${ lang }/guide`, // 指南 redirect: `/${ lang }/guide/design`, component: load(lang, 'guide'), children: [{ path: 'design', // 设计原则 name: 'guide-design' + lang, meta: { lang }, component: load(lang, 'design') }, { path: 'nav', // 导航 name: 'guide-nav' + lang, meta: { lang }, component: load(lang, 'nav') }] }; let themeRoute = { path: `/${ lang }/theme`, component: load(lang, 'theme-nav'), children: [ { path: '/', // 主题管理 name: 'theme' + lang, meta: { lang }, component: load(lang, 'theme') }, { path: 'preview', // 主题预览编辑 name: 'theme-preview-' + lang, meta: { lang }, component: load(lang, 'theme-preview') }] }; let resourceRoute = { path: `/${ lang }/resource`, // 资源 meta: { lang }, name: 'resource' + lang, component: load(lang, 'resource') }; let indexRoute = { path: `/${ lang }`, // 首页 meta: { lang }, name: 'home' + lang, component: load(lang, 'index') }; return [guideRoute, resourceRoute, themeRoute, indexRoute]; }; langs.forEach(lang => { route = route.concat(generateMiscRoutes(lang.lang)); }); route.push({ path: '/play', name: 'play', component: require('./play/index.vue') }); let userLanguage = localStorage.getItem('ELEMENT_LANGUAGE') || window.navigator.language || 'en-US'; let defaultPath = '/en-US'; if (userLanguage.indexOf('zh-') !== -1) { defaultPath = '/zh-CN'; } else if (userLanguage.indexOf('es') !== -1) { defaultPath = '/es'; } else if (userLanguage.indexOf('fr') !== -1) { defaultPath = '/fr-FR'; } route = route.concat([{ path: '/', redirect: defaultPath }, { path: '*', redirect: defaultPath }]); export default route;
包括 play.js
和 play/index.vue
,示例项目,比如你想看一个 element 中某个组件的效果,特别是组件按需加载时的显示效果,可以在 play/index.vue
中引入使用,使用 npm run dev:play
命令启动项目,也是在 /build/webpack.demo.js
中通过环境变量来配置的。
// play.js import Vue from 'vue'; // 全量引入组件库和其样式 import Element from 'main/index.js'; import 'packages/theme-chalk/src/index.scss'; import App from './play/index.vue'; Vue.use(Element); new Vue({ // eslint-disable-line render: h => h(App) }).$mount('#app');
<!-- play/index.vue --> <template> <div style="margin: 20px;"> <el-input v-model="input" placeholder="请输入内容"></el-input> </div> </template> <script> export default { data() { return { input: 'Hello Element UI!' }; } }; </script>
官网的各个页面都在这里,通过 i18n.js 脚本 结合 pages/template 目录下的各个模版文件自动在 pages 目录下生成四种语言的 .vue 文件,这些 vue 文件会在 route.config.js 中被加载。
官网页面的翻译配置文件都在这里。
主题编辑器的 chrome 插件项目。
定义了 dom 样式操作方法,包括判断是否存在指定的样式、添加样式、移除样式、切换样式。
// dom/class.js export const hasClass = function(obj, cls) { return obj.className.match(new RegExp('(\\s|^)' + cls + '(\\s|$)')); }; export const addClass = function(obj, cls) { if (!hasClass(obj, cls)) obj.className += ' ' + cls; }; export const removeClass = function(obj, cls) { if (hasClass(obj, cls)) { const reg = new RegExp('(\\s|^)' + cls + '(\\s|$)'); obj.className = obj.className.replace(reg, ' '); } }; export const toggleClass = function(obj, cls) { if (hasClass(obj, cls)) { removeClass(obj, cls); } else { addClass(obj, cls); } };
组件文档目录,默认提供了四种语言的文档,目录结构为:docs/{lang}/comp-name.md
。这些文档在组件页面加载(在 route.config.js 中有配置),先交给 md-loader 处理,提取其中的 vue 代码,然后交给 vue-loader 去处理,最后渲染到页面形成组件 demo + 文档。
组件页面中显示的 组件 demo 的排版样式,和组件自身的样式无关,就像你业务代码中给组件定义排版样式一样。因为组件在有些场景下直接显示效果不好,所以就需要经过一定的排版,比如 button 页面、icon 页面等。
官网项目存放一些全局组件的目录。
官网项目的静态资源目录
element 组件库由两部分组成:/src
和 /packages
。
利用模块化的开发思想,把组件依赖的一些公共模块放在 /src
目录下,并依据功能拆分出以下模块:
部分组件
的翻译文件/src/index.js
是通过脚本 /build/bin/build-entry.js
脚本自动生成,是组件库的入口。负责自动导入组件库的所有组件、定义全量注册组件库组件的 install 方法,然后导出版本信息、install 和 各个组件。
/* 通过 './build/bin/build-entry.js' 文件自动生成 */ // 引入所有组件 import Pagination from '../packages/pagination/index.js'; import Dialog from '../packages/dialog/index.js'; // ... // 组件数组,有些组件没在里面,这些组件不需要通过 Vue.use 或者 Vue.component 的方式注册,直接挂载到 Vue 原型链上 const components = [ Pagination, Dialog, // ... ] // 定义 install 方法,负责全量引入组件库 const install = function(Vue, opts = {}) { locale.use(opts.locale); locale.i18n(opts.i18n); // 全局注册组件 components.forEach(component => { Vue.component(component.name, component); }); Vue.use(InfiniteScroll); Vue.use(Loading.directive); // 在 Vue 原型链上挂点东西 Vue.prototype.$ELEMENT = { size: opts.size || '', zIndex: opts.zIndex || 2000 }; // 这些组件不需要 Vue.prototype.$loading = Loading.service; Vue.prototype.$msgbox = MessageBox; Vue.prototype.$alert = MessageBox.alert; Vue.prototype.$confirm = MessageBox.confirm; Vue.prototype.$prompt = MessageBox.prompt; Vue.prototype.$notify = Notification; Vue.prototype.$message = Message; }; // 通过 CDN 引入组件库时,走下面这段代码,全量注册组件库 if (typeof window !== 'undefined' && window.Vue) { install(window.Vue); } // 导出版本信息、install 方法、各个组件 export default { version: '2.15.0', locale: locale.use, i18n: locale.i18n, install, CollapseTransition, Loading, // ... }
为了减少篇幅,只贴出文件的一部分,但足以说明一切。
element 将组件全部都放在了 /packages
目录下,每个组件以目录为单位,目录结构以及其中的基本代码是通过脚本 /build/bin/new.js
自动生成的。目录结构为:
比如新建的 city 组件的目录及文件是这样的:
city
index.js
import City from './src/main'; /* istanbul ignore next */ City.install = function(Vue) { Vue.component(City.name, City); }; export default City;
src
main.vue
<template> <div class="el-city"></div> </template> <script> export default { name: 'ElCity' }; </script>
其实 /packages
目录下除了组件之外,还有一个特殊的目录 theme-chalk
,它是组件库的样式目录,所有组件的样式代码都在这里,element 的组件文件中没有定义样式。theme-chalk
目录也是一个项目,通过 gulp
打包,并支持独立发布,其目录结构是这样的:
theme-chalk
src,组件样式的源码目录
.gitignore
gulpfile.js
'use strict'; // gulp 配置文件 const { series, src, dest } = require('gulp'); const sass = require('gulp-sass'); const autoprefixer = require('gulp-autoprefixer'); const cssmin = require('gulp-cssmin'); // 将 scss 编译成 css 并压缩,最后输出到 ./lib 目录下 function compile() { return src('./src/*.scss') .pipe(sass.sync()) .pipe(autoprefixer({ browsers: ['ie > 9', 'last 2 versions'], cascade: false })) .pipe(cssmin()) .pipe(dest('./lib')); } // 拷贝 ./src/fonts 到 ./lib/fonts function copyfont() { return src('./src/fonts/**') .pipe(cssmin()) .pipe(dest('./lib/fonts')); } exports.build = series(compile, copyfont);
package.json
README.md
组件库的测试项目,使用 karma
框架
每个组件的类型声明文件,TS 项目使用组件库时有更好的代码提示。
到这里 element 的源码架构分析就结束了,建议读者参照文章,亲自去阅读框架源码并添加注释,这样理解会更深,也更利于后续工作的开展。下一篇将详细讲解 基于 Element 为团队打造组件库
的过程。