如何快速為團隊打造自己的元件庫(上)—— Element 原始碼架構

李永寧發表於2022-02-11

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

簡介

詳細講解了 ElementUI 的原始碼架構,為下一步基於 ElementUI 打造團隊自己的元件庫打好堅實的基礎。

如何快速為團隊打造自己的元件庫?

元件庫是現代前端領域中不可缺少的一項基建。它可以提高程式碼的複用性、可維護性,提高團隊的生產效率,更好的服務於未來。

那麼如何為團隊打造自己的元件庫呢? 最理想的方案是借用社群的能力,去裁剪一個優秀的開源庫,只保留你需要的東西,比如它的架構、工程化和文件能力,以及部分基礎元件,在裁剪的過程中你可能會發現它的一些問題,然後在你的元件庫中去優化並解決。

Element 原始碼架構

因為團隊的技術棧是 Vue,所以選擇基於 element 進行二次開發,在開始前先對 element 框架原始碼進行詳細的刨析,為打造元件庫做知識儲備。element 框架原始碼由工程化、官網、元件庫、測試和型別宣告這 5 部分組成。

工程化

element 的架構是真的優秀,通過大量的指令碼實現優秀的工程化,致力於讓元件庫的開發者專注於事情本身。比如新增新元件時,一鍵生成元件所有檔案並完成這些檔案基本結構的編寫和相關的引入配置,總共涉及 13 個檔案的新增和改動,而你只需完成元件定義這件事即可。element 的工程化由 5 部分組成:build 目錄下的工程化配置和指令碼、eslint、travis ci、Makefile、package.json 的 scripts。

build

build 目錄存放工程化相關配置和指令碼。比如 /build/bin 目錄下的 JS 指令碼讓元件庫開發者專注於元件的開發,除此之外不需要管其他任何事情;build/md-loader 是官網元件頁面根據 markdown 實現元件 demo + 文件 的關鍵;還有比如持續整合、webpack 配置等,接下來就詳細介紹這些配置和指令碼。

/build/bin/build-entry.js

元件配置檔案(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);

/build/bin/build-locale.js

通過 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);
      }
    });
  });

/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);
// 所有元件包的基礎路徑,/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);
});

/build/bin/i18n.js

根據模版(/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);
  });
});

/build/bin/iconInit.js

根據 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), () => {});

/build/bin/new-lang.js

為元件庫新增新語言,比如 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!');

/build/bin/new.js

為元件庫新增新元件時會使用該指令碼,一鍵生成元件所有檔案並完成這些檔案基本結構的編寫和相關的引入配置,總共涉及 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 即可。

/build/bin/template.js

監聽 /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();
}

/build/bin/version.js

根據 /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));

/build/md-loader

它是一個 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 的具體實現感興趣可以自行深入閱讀。

/build/config.js

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

/build/deploy-ci.sh

和 travis ci 結合使用的持續整合指令碼,這個指令碼在 .travis.yml 檔案中被執行,程式碼被提交到 github 倉庫以後會自動被 Tavis CI 執行,ci 會自動找專案中的 .travis.yml 檔案,並執行裡面的命令。但這個我們可能用不到,一般團隊內部都會有自己的持續整合方案。

/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、

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

  • 釋出樣式包和元件庫

  • 提交 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

/build/webpack.xx.js
  • 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 框架

eslint

element 通過 eslint 來保證程式碼風格的一致性,還專門編寫了 elemefe 作為 eslint 的擴充套件規則配置。為了保證官網專案的質量,在 /build/webpack.demo.js 中配置了 eslint-loader 規則,在專案啟動時強制檢查程式碼質量。但是 element 在程式碼質量控制這塊兒做的還是不夠,比如:程式碼自動格式化能力太弱、只保證了 /src、/test、/packages、/build 目錄下的程式碼質量,對於官網專案做的不夠,特別是 文件格式的限制。這裡建議大家再整合一個 prettier 專門去做格式限制,讓 eslint 專注於程式碼語法的限制,可以參考 搭建自己的 typescript 專案 + 開發自己的腳手架工具 ts-cli 中的 程式碼質量 部分去配置。

travis ci

travis ci 結合指令碼的方式來完成持續整合的工作,不過這個可能對於內部專案用不上,因為 travis ci 只能用於 github,內部一般使用 gitlab,也有配套的持續整合

Makefile

make 命令的配置檔案,寫過 C、C++ 的同學應該比較熟悉。

執行 make 命令可以看到詳細的幫助資訊。比如:執行 make install 裝包、make dev 啟動本地開發環境、make new comp-name 中文名 新建元件等。使用 make 命令相較於 npm run xx 更方便、清晰、簡單,不過其內部也是依賴於 npm run xx 來完成真正的工作,相當於為了更好的開發體驗,將眾多 npm run cmd 提供了一層封裝。

image-20220210083138040

package.json -> scripts

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 專案。

entry.js

官網專案的入口,在這裡全量引入元件庫,及其樣式。

// 官網專案的入口,就是一個普通的 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 檔案中生成元件頁面所有路由的程式碼。

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

包括 play.jsplay/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>

pages

官網的各個頁面都在這裡,通過 i18n.js 指令碼 結合 pages/template 目錄下的各個模版檔案自動在 pages 目錄下生成四種語言的 .vue 檔案,這些 vue 檔案會在 route.config.js 中被載入。

i18n

官網頁面的翻譯配置檔案都在這裡。

  • component.json,元件頁面的翻譯配置
  • page.json,其它頁面的一些翻譯配置,比如首頁、設計頁等
  • route.json,語言配置,表示元件庫目前都支援那些語言
  • theme-editor.json,主題編輯器頁面的翻譯配置
  • title.json,官網各個頁面在 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

元件文件目錄,預設提供了四種語言的文件,目錄結構為:docs/{lang}/comp-name.md。這些文件在元件頁面載入(在 route.config.js 中有配置),先交給 md-loader 處理,提取其中的 vue 程式碼,然後交給 vue-loader 去處理,最後渲染到頁面形成元件 demo + 文件。

demo-style

元件頁面中顯示的 元件 demo 的排版樣式,和元件自身的樣式無關,就像你業務程式碼中給元件定義排版樣式一樣。因為元件在有些場景下直接顯示效果不好,所以就需要經過一定的排版,比如 button 頁面、icon 頁面等。

components

官網專案存放一些全域性元件的目錄。

assets

官網專案的靜態資源目錄

元件庫

element 元件庫由兩部分組成:/src/packages

src

利用模組化的開發思想,把元件依賴的一些公共模組放在 /src 目錄下,並依據功能拆分出以下模組:

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

// 元件陣列,有些元件沒在裡面,這些元件不需要通過 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 目錄下,每個元件以目錄為單位,目錄結構以及其中的基本程式碼是通過指令碼 /build/bin/new.js 自動生成的。目錄結構為:

  • package-name,連字元形式的包名
    • index.js,元件的 install 方法,表示元件是以 Vue 外掛的形式存在
    • src,元件的原始碼目錄
      • main.vue 元件的基本結構已經就緒

比如新建的 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,元件樣式的原始碼目錄

      • 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 專案使用元件庫時有更好的程式碼提示。

結束

到這裡 element 的原始碼架構分析就結束了,建議讀者參照文章,親自去閱讀框架原始碼並新增註釋,這樣理解會更深,也更利於後續工作的開展。下一篇將詳細講解 基於 Element 為團隊打造元件庫 的過程。

連結

  • Element 原始碼架構 思維導圖版
  • Element 原始碼架構 視訊版,關注微信公眾號,回覆: "Element 原始碼架構視訊版" 獲取
  • 元件庫專欄
    • 如何快速為團隊打造自己的元件庫(下)—— 基於 element-ui 為團隊打造自己的元件庫
  • github

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

相關文章