如何快速為團隊打造自己的組件庫(上)—— Element 源碼架構(gòu)

當學習成為了習慣蜀涨,知識也就變成了常識承绸。感謝各位的 點贊收藏評論注整。

新視頻和文章會第一時間在微信公眾號發(fā)送,歡迎關(guān)注:李永寧lyn

文章已收錄到 github,歡迎 Watch 和 Star肿轨。

封面

element-ui

簡介

詳細講解了 ElementUI 的源碼架構(gòu)寿冕,為下一步基于 ElementUI 打造團隊自己的組件庫打好堅實的基礎(chǔ)。

如何快速為團隊打造自己的組件庫椒袍?

組件庫是現(xiàn)代前端領(lǐng)域中不可缺少的一項基建驼唱。它可以提高代碼的復用性、可維護性槐沼,提高團隊的生產(chǎn)效率曙蒸,更好的服務于未來。

那么如何為團隊打造自己的組件庫呢岗钩? 最理想的方案是借用社區(qū)的能力,去裁剪一個優(yōu)秀的開源庫肖油,只保留你需要的東西兼吓,比如它的架構(gòu)、工程化和文檔能力森枪,以及部分基礎(chǔ)組件视搏,在裁剪的過程中你可能會發(fā)現(xiàn)它的一些問題,然后在你的組件庫中去優(yōu)化并解決县袱。

Element 源碼架構(gòu)

因為團隊的技術(shù)棧是 Vue浑娜,所以選擇基于 element 進行二次開發(fā),在開始前先對 element 框架源碼進行詳細的刨析式散,為打造組件庫做知識儲備筋遭。element 框架源碼由工程化、官網(wǎng)暴拄、組件庫漓滔、測試和類型聲明這 5 部分組成。

工程化

element 的架構(gòu)是真的優(yōu)秀乖篷,通過大量的腳本實現(xiàn)優(yōu)秀的工程化响驴,致力于讓組件庫的開發(fā)者專注于事情本身。比如添加新組件時撕蔼,一鍵生成組件所有文件并完成這些文件基本結(jié)構(gòu)的編寫和相關(guān)的引入配置豁鲤,總共涉及 13 個文件的添加和改動,而你只需完成組件定義這件事即可鲸沮。element 的工程化由 5 部分組成:build 目錄下的工程化配置和腳本琳骡、eslint、travis ci诉探、Makefile日熬、package.json 的 scripts。

build

build 目錄存放工程化相關(guān)配置和腳本。比如 /build/bin 目錄下的 JS 腳本讓組件庫開發(fā)者專注于組件的開發(fā)竖席,除此之外不需要管其他任何事情耘纱;build/md-loader 是官網(wǎng)組件頁面根據(jù) markdown 實現(xiàn)組件 demo + 文檔 的關(guān)鍵;還有比如持續(xù)集成毕荐、webpack 配置等束析,接下來就詳細介紹這些配置和腳本。

/build/bin/build-entry.js

組件配置文件(components.json)結(jié)合字符串模版庫憎亚,自動生成 /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 形式的字符串轉(zhuǎn)換為 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 = [];
// 組件名數(shù)組
var installTemplate = [];
// 組件名數(shù)組
var listTemplate = [];

// 遍歷所有的包名
ComponentNames.forEach(name => {
  // 將連字符格式的包名轉(zhuǎn)換成大駝峰形式,就是組件名省古,比如 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 數(shù)組中剔除豺妓,不需要全局注冊惜互,采用掛載到原型鏈的方式,在模版字符串的 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);

/build/bin/build-locale.js

通過 babel 將 ES Module 風格的所有翻譯文件(/src/locale/lang)轉(zhuǎn)譯成 UMD 風格载佳。

/**
 * 通過 babel 將 ES Module 風格的翻譯文件轉(zhuǎn)譯成 UMD 風格
 */
var fs = require('fs');
var save = require('file-save');
var resolve = require('path').resolve;
var basename = require('path').basename;

// 翻譯文件目錄,這些文件用于官網(wǎng)
var localePath = resolve(__dirname, '../../src/locale/lang');
// 得到目錄下的所有翻譯文件
var fileList = fs.readdirSync(localePath);

// 轉(zhuǎn)換函數(shù)
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');

    // 調(diào)用轉(zhuǎn)換函數(shù)蔫慧,將轉(zhuǎn)換后的代碼寫入到 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);
      }
    });
  });

/build/bin/gen-cssfile.js

自動在 /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);
// 所有組件包的基礎(chǔ)路徑盟蚣,/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';
  // 導入基礎(chǔ)樣式文件 @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 不存在蔼两,則認為其被遺漏了甩鳄,創(chuàng)建該文件
    var filePath = path.resolve(basepath, theme, 'src', fileName);
    if (!fileExists(filePath)) {
      fs.writeFileSync(filePath, '', 'utf8');
      console.log(theme, ' 創(chuàng)建遺漏的 ', fileName, ' 文件');
    }
  });
  // 生成 /packages/theme-chalk/src/index.scss|css,負責引入所有組件包的樣式
  fs.writeFileSync(path.resolve(basepath, theme, 'src', isSCSS ? 'index.scss' : 'index.css'), indexContent);
});

/build/bin/i18n.js

根據(jù)模版(/examples/pages/template)生成四種語言的官網(wǎng)頁面的 .vue 文件额划。

'use strict';

var fs = require('fs');
var path = require('path');
// 官網(wǎng)頁面翻譯配置妙啃,內(nèi)置了四種語言
var langConfig = require('../../examples/i18n/page.json');

// 遍歷所有語言
langConfig.forEach(lang => {
  // 創(chuàng)建 /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 }`));
  }

  // 遍歷所有的頁面俊戳,根據(jù) 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]);
    });

    // 將替換后的內(nèi)容寫入 vue 文件
    fs.writeFileSync(outputPath, content);
  });
});

/build/bin/iconInit.js

根據(jù) icon.scss 樣式文件中的選擇器,通過正則匹配的方式抑胎,匹配出所有的 icon 名稱燥滑,然后將這些 icon 名組成數(shù)組,將數(shù)組寫入到 /examples/icon.json 文件中圆恤,該文件在官網(wǎng)的 icon 圖標頁用來自動生成所有的 icon 圖標突倍。

'use strict';

/**
 * 根據(jù) icon.scss 樣式文件中的選擇器,通過正則匹配的方式盆昙,匹配出所有的 icon 名稱,
 * 然后將所有 icon 名組成的數(shù)組寫入到 /examples/icon.json 文件中
 * 該文件在官網(wǎng)的 icon 圖標頁用來自動生成所有的 icon 圖標
 */
var postcss = require('postcss');
var fs = require('fs');
var path = require('path');
// icon.scss 文件內(nèi)容
var fontFile = fs.readFileSync(path.resolve(__dirname, '../../packages/theme-chalk/src/icon.scss'), 'utf8');
// 得到樣式節(jié)點
var nodes = postcss.parse(fontFile).nodes;
var classList = [];

// 遍歷所有的樣式節(jié)點
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 名稱寫入數(shù)組,
  if (arr && arr[1]) {
    classList.push(arr[1]);
  }
});

classList.reverse(); // 希望按 css 文件順序倒序排列

// 將 icon 名組成的數(shù)組寫入 /examples/icon.json 文件
fs.writeFile(path.resolve(__dirname, '../../examples/icon.json'), JSON.stringify(classList), () => {});

/build/bin/new-lang.js

為組件庫添加新語言,比如 fr(法語)诵闭,分別為涉及到的文件(components.json炼团、page.json、route.json疏尿、nav.config.json瘟芝、docs)設置該語言的相關(guān)配置,具體的配置項默認為英語褥琐,你只需要在相應的文件中將這些英文配置項翻譯為對應的語言即可锌俱。

'use strict';

/**
 * 為組件庫添加新語言,比如 fr(法語)
 *  分別為涉及到的文件(components.json敌呈、page.json贸宏、route.json、nav.config.json磕洪、docs)設置該語言的相關(guān)配置
 *  具體的配置項默認為英語吭练,你只需要在相應的文件中將這些英文配置項翻譯為對應的語言即可
 */

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!');

/build/bin/new.js

為組件庫添加新組件時會使用該腳本析显,一鍵生成組件所有文件并完成這些文件基本結(jié)構(gòu)的編寫和相關(guān)的引入配置鲫咽,總共涉及 13 個文件的添加和改動,比如:make new city 城市列表。該腳本的存在分尸,讓你為組件庫開發(fā)新組件時锦聊,只需專注于組件代碼的編寫即可,其它的一概不用管寓落。

'use strict';

/**
 * 添加新組件
 *  比如:make new city 城市列表
 *  1括丁、在 /packages 目錄下新建組件目錄,并完成目錄結(jié)構(gòu)的創(chuàng)建
 *  2伶选、創(chuàng)建組件文檔史飞,/examples/docs/{lang}/city.md
 *  3、創(chuàng)建組件單元測試文件仰税,/test/unit/specs/city.spec.js
 *  4构资、創(chuàng)建組件樣式文件,/packages/theme-chalk/src/city.scss
 *  5陨簇、創(chuàng)建組件類型聲明文件吐绵,/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;
// 將組件名稱轉(zhuǎn)換為大駝峰形式俊犯,city => City
const ComponentName = uppercamelcase(componentname);
// 組件包目錄抵恋,/packages/city
const PackagePath = path.resolve(__dirname, '../../packages', componentname);
// 需要添加的文件列表和文件內(nèi)容的基本結(jié)構(gòu)
const Files = [
  // /packages/city/index.js
  {
    filename: 'index.js',
    // 文件內(nèi)容庞萍,引入組件惹苗,定義組件靜態(tài)方法 install 用來注冊組件套耕,然后導出組件
    content: `import ${ComponentName} from './src/main';

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

export default ${ComponentName};`
  },
  // 定義組件的基本結(jié)構(gòu)构灸,/packages/city/src/main.vue
  {
    filename: 'src/main.vue',
    // 文件內(nèi)容件已,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`),
    // 文件內(nèi)容兄猩,給出測試文件的基本結(jié)構(gòu)
    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`),
    // 文件基本結(jié)構(gòu)
    content: `@import "mixins/mixins";
@import "common/var";

@include b(${componentname}) {
}`
  },
  // 組件類型聲明文件
  {
    filename: path.join('../../types', `${componentname}.d.ts`),
    // 類型聲明文件基本結(jié)構(gòu)
    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 數(shù)組厦滤,創(chuàng)建列出的所有文件并寫入文件內(nèi)容
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 即可添瓷。

/build/bin/template.js

監(jiān)聽 /examples/pages/template 目錄下的所有模版文件,當模版文件發(fā)生改變時自動執(zhí)行 npm run i18n值纱,即執(zhí)行 i18n.js 腳本鳞贷,重新生成四種語言的 .vue 文件。

/**
 * 監(jiān)聽 /examples/pages/template 目錄下的所有模版文件虐唠,當模版文件發(fā)生改變時自動執(zhí)行 npm run i18n搀愧,
 * 即執(zhí)行 i18n.js 腳本,重新生成四種語言的 .vue 文件
 */

const path = require('path');
// 監(jiān)聽目錄
const templates = path.resolve(process.cwd(), './examples/pages/template');

// 負責監(jiān)聽的庫
const chokidar = require('chokidar');
// 監(jiān)聽模板目錄
let watcher = chokidar.watch([templates]);

// 當目錄下的文件發(fā)生改變時疆偿,自動執(zhí)行 npm run i18n
watcher.on('ready', function() {
  watcher
    .on('change', function() {
      exec('npm run i18n');
    });
});

// 負責執(zhí)行命令
function exec(cmd) {
  return require('child_process').execSync(cmd).toString().trim();
}

/build/bin/version.js

根據(jù) /package.json 文件咱筛,自動生成 /examples/version.json,用于記錄組件庫的版本信息杆故,這些版本洗洗在官網(wǎng)組件頁面的頭部導航欄會用到迅箩。

/**
 * 根據(jù) package.json 自動生成 /examples/version.json,用于記錄組件庫的版本信息
 * 這些版本信息在官網(wǎng)組件頁面的頭部導航欄會用到
 */
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));

/build/md-loader

它是一個 loader处铛,官網(wǎng)組件頁面的 組件 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) 規(guī)則的處理值依,先通過 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 的具體實現(xiàn)感興趣可以自行深入閱讀辆亏。

/build/config.js

webpack 的公共配置,比如 externals鳖目、alias 等扮叨。通過 externals 的配置解決了組件庫部分代碼的冗余問題,比如組件和組件庫公共模塊的代碼领迈,但是組件樣式冗余問題沒有得到解決彻磁;alias 別名配置為開發(fā)組件庫提供了方便碍沐。

/**
 * 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 時,會不會產(chǎn)生冗余代碼
 *     如果沒有以下內(nèi)容的的話磁浇,會斋陪,這時候你會看到有兩份 Checkbox 組件代碼。
 *     包括 locale置吓、utils无虚、mixins、transitions 這些公共內(nèi)容交洗,也會出現(xiàn)冗余代碼
 *     但有了 externals 的設置骑科,就會將告訴 webpack 不需要將這些 import 的包打包到 bundle 中,運行時再從外部去
 *     獲取這些擴展依賴构拳。這樣就可以在打包后 /lib/tables.js 中看到編譯后的 table.js 對 Checkbox 組件的依賴引入:
 *     module.exports = require("element-ui/lib/checkbox")
 *     這么處理之后就不會出現(xiàn)冗余的 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/;

/build/deploy-ci.sh

和 travis ci 結(jié)合使用的持續(xù)集成腳本,這個腳本在 .travis.yml 文件中被執(zhí)行凫海,代碼被提交到 github 倉庫以后會自動被 Tavis CI 執(zhí)行呛凶,ci 會自動找項目中的 .travis.yml 文件,并執(zhí)行里面的命令行贪。但這個我們可能用不到漾稀,一般團隊內(nèi)部都會有自己的持續(xù)集成方案。

/build/git-release.sh

這里主要是和遠程的 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;

/build/release.sh

腳本完成了以下工作:

  • 合并 dev 分支到 master崭捍、

  • 修改樣式包和組件庫的版本號

  • 發(fā)布樣式包和組件庫

  • 提交 master 和 dev 分支到遠程倉庫

該腳本在發(fā)布組件庫時可以使用,特別是其中自動更改版本號的功能(每次 publish 時都忘改版本號)啰脚。這里提交代碼到遠程倉庫的日志很簡單殷蛇,更詳細的提交日志時通過更新日志文件 CHANGELOG.{lang}.md 提供的。

#!/usr/bin/env sh
set -e

# 合并 dev 分支到 master
# 編譯打包
# 修改樣式包和組件庫的版本號
# 發(fā)布樣式包和組件庫
# 提交 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"
  # 發(fā)布主題
  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

  # 發(fā)布組件庫
  if [[ $VERSION =~ "beta" ]]
  then
    npm publish --tag beta
  else
    npm publish
  fi
fi

/build/webpack.xx.js
  • webpack.common.js,構(gòu)建 commonjs2 規(guī)范的包荸实,會打一個全量的包

  • webpack.component.js匀们,構(gòu)建 commonjs2 規(guī)范的包,支持按需加載

    支持按需加載的重點在于 entry 和 ouput 的配置泪勒,將每個組件打成單獨的包

  • webpack.conf.js昼蛀,構(gòu)建 UMD 規(guī)范的包宴猾,會打一個全量的包

  • webpack.demo.js,官網(wǎng)項目的 webpack 配置

  • webpack.extension.js叼旋,主題編輯器的 chorme 插件項目的 webpack 配置仇哆,項目在 extension 目錄下

  • webpack.test.js,這個文件沒什么用夫植,不過看命名讹剔,應該是想用于測試項目的 webpack 配置,不過現(xiàn)在測試用的是 karma 框架

eslint

element 通過 eslint 來保證代碼風格的一致性详民,還專門編寫了 elemefe 作為 eslint 的擴展規(guī)則配置延欠。為了保證官網(wǎng)項目的質(zhì)量,在 /build/webpack.demo.js 中配置了 eslint-loader 規(guī)則沈跨,在項目啟動時強制檢查代碼質(zhì)量由捎。但是 element 在代碼質(zhì)量控制這塊兒做的還是不夠,比如:代碼自動格式化能力太弱饿凛、只保證了 /src狞玛、/test、/packages涧窒、/build 目錄下的代碼質(zhì)量心肪,對于官網(wǎng)項目做的不夠,特別是 文檔格式的限制纠吴。這里建議大家再集成一個 prettier 專門去做格式限制硬鞍,讓 eslint 專注于代碼語法的限制,可以參考 搭建自己的 typescript 項目 + 開發(fā)自己的腳手架工具 ts-cli 中的 代碼質(zhì)量 部分去配置戴已。

travis ci

travis ci 結(jié)合腳本的方式來完成持續(xù)集成的工作固该,不過這個可能對于內(nèi)部項目用不上,因為 travis ci 只能用于 github糖儡,內(nèi)部一般使用 gitlab蹬音,也有配套的持續(xù)集成

Makefile

make 命令的配置文件,寫過 C休玩、C++ 的同學應該比較熟悉。

執(zhí)行 make 命令可以看到詳細的幫助信息劫狠。比如:執(zhí)行 make install 裝包拴疤、make dev 啟動本地開發(fā)環(huán)境、make new comp-name 中文名 新建組件等独泞。使用 make 命令相較于 npm run xx 更方便呐矾、清晰、簡單懦砂,不過其內(nèi)部也是依賴于 npm run xx 來完成真正的工作蜒犯,相當于為了更好的開發(fā)體驗组橄,將眾多 npm run cmd 提供了一層封裝。

image-20220210083138040

package.json -> scripts

elemnt 編寫了很多 npm scripts罚随,這些 script 結(jié)合 /build 中的眾多腳本實現(xiàn)通過腳本來自動完成大量重復的體力勞動玉工,比人工靠譜且效率更高,這個設計我覺得是 element 中最值得大家學習的地方淘菩,可以將這樣的設計應用到自己的項目中遵班,助力業(yè)務提效。

{
  // 裝包
  "bootstrap": "yarn || npm i",
  // 通過JS腳本潮改,自動生成以下文件:生成 examples/icon.json 文件 && 生成 src/index.js 文件 && 生成四種語言的官網(wǎng)的 .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",
  // 構(gòu)建主題樣式:在 index.scss 中自動引入各個組件的樣式文件 && 通過 gulp 將 scss 文件編譯成 css 并輸出到 lib 目錄 && 拷貝基礎(chǔ)樣式 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",
  // 清除構(gòu)建產(chǎn)物
  "clean": "rimraf lib && rimraf packages/*/lib && rimraf test/**/coverage",
  // 構(gòu)建官網(wǎng)項目
  "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",
  // 構(gòu)建主題插件
  "deploy:extension": "cross-env NODE_ENV=production webpack --config build/webpack.extension.js",
  // 啟動主題插件的開發(fā)環(huán)境
  "dev:extension": "rimraf examples/extension/dist && cross-env NODE_ENV=development webpack --watch --config build/webpack.extension.js",
  // 啟動組件庫的本地開發(fā)環(huán)境翰萨。執(zhí)行 build:file,自動化生成一些文件 && 啟動 example 項目糕殉,即官網(wǎng) && 監(jiān)聽 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",
  // 構(gòu)建組件庫
  "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",
  // 生成四種語言的官網(wǎng)的 .vue 文件
  "i18n": "node build/bin/i18n.js",
  // lint,保證項目代碼質(zhì)量
  "lint": "eslint src/**/* test/**/* packages/**/* build/**/* --quiet",
  // 裝包 && 合并遠程倉庫的 dev 分支 && 合并 dev 分支到 master赡磅、打包編譯魄缚、修改樣式包和組件庫的版本號、發(fā)布樣式包和組件庫焚廊、提交代碼到遠程倉庫冶匹。使用時注掉最后一個腳本,那個腳本有問題
  "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"
}

官網(wǎng)

element 的官網(wǎng)是和組件庫在一個倉庫內(nèi)袒餐,官網(wǎng)的所有東西都放在 /examples 目錄下飞蛹,就是一個 vue 項目。

entry.js

官網(wǎng)項目的入口灸眼,在這里全量引入組件庫卧檐,及其樣式。

// 官網(wǎng)項目的入口焰宣,就是一個普通的 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';
// 官網(wǎng)項目的一些組件
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 文檔中被使用盈罐,在官網(wǎng)的 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');

nav.config.json

官網(wǎng)組件頁面的側(cè)邊導航欄配置榜跌,一定要了解該 json 文件的結(jié)構(gòu),才能看懂 route.config.js 文件中生成組件頁面所有路由的代碼盅粪。

route.config.js

根據(jù)路由配置自動生成官網(wǎng)項目的路由配置钓葫。

// 根據(jù)路由配置自動生成官網(wǎng)項目的路由
import navConfig from './nav.config';
// 支持的所有語言
import langs from './i18n/route';

// 加載官網(wǎng)各個頁面的 .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);
};

// 加載官網(wǎng)組件頁面各個組件的 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 文件的結(jié)構(gòu)
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) {
        // 該項為開發(fā)指南
        nav.children.forEach(nav => {
          addRoute(nav, lang, index);
        });
      } else {
        // 其它戚揭,比如更新日志诱告、Element React、Element Angular
        addRoute(nav, lang, index);
      }
    });
  });
  // 生成子路由配置民晒,并填充到 children 中
  function addRoute(page, lang, index) {
    // 根據(jù) 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;
};

// 得到組件頁面所有側(cè)邊欄的路由配置
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

包括 play.jsplay/index.vue精居,示例項目,比如你想看一個 element 中某個組件的效果潜必,特別是組件按需加載時的顯示效果靴姿,可以在 play/index.vue 中引入使用,使用 npm run dev:play 命令啟動項目磁滚,也是在 /build/webpack.demo.js 中通過環(huán)境變量來配置的佛吓。

// 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="請輸入內(nèi)容"></el-input>
  </div>
</template>

<script>
  export default {
    data() {
      return {
        input: 'Hello Element UI!'
      };
    }
  };
</script>

pages

官網(wǎng)的各個頁面都在這里,通過 i18n.js 腳本 結(jié)合 pages/template 目錄下的各個模版文件自動在 pages 目錄下生成四種語言的 .vue 文件垂攘,這些 vue 文件會在 route.config.js 中被加載维雇。

i18n

官網(wǎng)頁面的翻譯配置文件都在這里。

  • component.json晒他,組件頁面的翻譯配置
  • page.json吱型,其它頁面的一些翻譯配置,比如首頁陨仅、設計頁等
  • route.json津滞,語言配置,表示組件庫目前都支持那些語言
  • theme-editor.json灼伤,主題編輯器頁面的翻譯配置
  • title.json据沈,官網(wǎng)各個頁面在 tab 標簽中顯示的 title 信息

extension

主題編輯器的 chrome 插件項目。

dom

定義了 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

組件文檔目錄,默認提供了四種語言的文檔发皿,目錄結(jié)構(gòu)為:docs/{lang}/comp-name.md崔慧。這些文檔在組件頁面加載(在 route.config.js 中有配置),先交給 md-loader 處理穴墅,提取其中的 vue 代碼惶室,然后交給 vue-loader 去處理,最后渲染到頁面形成組件 demo + 文檔玄货。

demo-style

組件頁面中顯示的 組件 demo 的排版樣式皇钞,和組件自身的樣式無關(guān),就像你業(yè)務代碼中給組件定義排版樣式一樣松捉。因為組件在有些場景下直接顯示效果不好夹界,所以就需要經(jīng)過一定的排版,比如 button 頁面隘世、icon 頁面等可柿。

components

官網(wǎng)項目存放一些全局組件的目錄。

assets

官網(wǎng)項目的靜態(tài)資源目錄

組件庫

element 組件庫由兩部分組成:/src/packages 丙者。

src

利用模塊化的開發(fā)思想复斥,把組件依賴的一些公共模塊放在 /src 目錄下,并依據(jù)功能拆分出以下模塊:

  • utils械媒,定義了一些工具方法
  • transitions目锭,動畫
  • mixins,全局混入的一些方法
  • locale滥沫,國際化功能以及各種語言的 部分組件 的翻譯文件
  • directives侣集,指令

/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';
// ...

// 組件數(shù)組,有些組件沒在里面臀玄,這些組件不需要通過 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,
  // ...
}

為了減少篇幅叠穆,只貼出文件的一部分少漆,但足以說明一切。

/packages

element 將組件全部都放在了 /packages 目錄下硼被,每個組件以目錄為單位示损,目錄結(jié)構(gòu)以及其中的基本代碼是通過腳本 /build/bin/new.js 自動生成的。目錄結(jié)構(gòu)為:

  • package-name嚷硫,連字符形式的包名
    • index.js检访,組件的 install 方法,表示組件是以 Vue 插件的形式存在
    • src仔掸,組件的源碼目錄
      • main.vue 組件的基本結(jié)構(gòu)已經(jīng)就緒

比如新建的 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 打包双泪,并支持獨立發(fā)布,其目錄結(jié)構(gòu)是這樣的:

  • theme-chalk

    • src密似,組件樣式的源碼目錄

      • index.scss焙矛,引入目錄下所有的樣式文件
      • comp.scss,組件樣式文件残腌,比如:button.scss
      • other村斟,比如:字體、公共樣式抛猫、變量蟆盹、方法等
    • .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 項目使用組件庫時有更好的代碼提示。

結(jié)束

到這里 element 的源碼架構(gòu)分析就結(jié)束了败匹,建議讀者參照文章寨昙,親自去閱讀框架源碼并添加注釋,這樣理解會更深掀亩,也更利于后續(xù)工作的開展舔哪。下一篇將詳細講解 基于 Element 為團隊打造組件庫 的過程。

鏈接

  • Element 源碼架構(gòu) 思維導圖版
  • Element 源碼架構(gòu) 視頻版槽棍,關(guān)注微信公眾號捉蚤,回復: "Element 源碼架構(gòu)視頻版" 獲取
  • 組件庫專欄
    • 如何快速為團隊打造自己的組件庫(下)—— 基于 element-ui 為團隊打造自己的組件庫
  • github

感謝各位的:點贊抬驴、收藏評論,我們下期見缆巧。


當學習成為了習慣怎爵,知識也就變成了常識。感謝各位的 點贊盅蝗、收藏評論

新視頻和文章會第一時間在微信公眾號發(fā)送姆蘸,歡迎關(guān)注:李永寧lyn

文章已收錄到 github墩莫,歡迎 Watch 和 Star。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末逞敷,一起剝皮案震驚了整個濱河市狂秦,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌推捐,老刑警劉巖裂问,帶你破解...
    沈念sama閱讀 206,311評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異牛柒,居然都是意外死亡堪簿,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,339評論 2 382
  • 文/潘曉璐 我一進店門皮壁,熙熙樓的掌柜王于貴愁眉苦臉地迎上來椭更,“玉大人,你說我怎么就攤上這事蛾魄÷瞧伲” “怎么了?”我有些...
    開封第一講書人閱讀 152,671評論 0 342
  • 文/不壞的土叔 我叫張陵滴须,是天一觀的道長舌狗。 經(jīng)常有香客問我,道長扔水,這世上最難降的妖魔是什么痛侍? 我笑而不...
    開封第一講書人閱讀 55,252評論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮铭污,結(jié)果婚禮上恋日,老公的妹妹穿的比我還像新娘。我一直安慰自己嘹狞,他們只是感情好岂膳,可當我...
    茶點故事閱讀 64,253評論 5 371
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著磅网,像睡著了一般谈截。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,031評論 1 285
  • 那天簸喂,我揣著相機與錄音毙死,去河邊找鬼。 笑死喻鳄,一個胖子當著我的面吹牛扼倘,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播除呵,決...
    沈念sama閱讀 38,340評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼再菊,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了颜曾?” 一聲冷哼從身側(cè)響起纠拔,我...
    開封第一講書人閱讀 36,973評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎泛豪,沒想到半個月后稠诲,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,466評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡诡曙,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,937評論 2 323
  • 正文 我和宋清朗相戀三年臀叙,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片岗仑。...
    茶點故事閱讀 38,039評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡匹耕,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出荠雕,到底是詐尸還是另有隱情稳其,我是刑警寧澤,帶...
    沈念sama閱讀 33,701評論 4 323
  • 正文 年R本政府宣布炸卑,位于F島的核電站既鞠,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏盖文。R本人自食惡果不足惜嘱蛋,卻給世界環(huán)境...
    茶點故事閱讀 39,254評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望五续。 院中可真熱鬧洒敏,春花似錦、人聲如沸疙驾。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,259評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽它碎。三九已至函荣,卻和暖如春显押,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背傻挂。 一陣腳步聲響...
    開封第一講書人閱讀 31,485評論 1 262
  • 我被黑心中介騙來泰國打工乘碑, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人金拒。 一個月前我還...
    沈念sama閱讀 45,497評論 2 354
  • 正文 我出身青樓兽肤,卻偏偏與公主長得像,于是被迫代替她去往敵國和親绪抛。 傳聞我的和親對象是個殘疾皇子轿衔,可洞房花燭夜當晚...
    茶點故事閱讀 42,786評論 2 345

推薦閱讀更多精彩內(nèi)容