【轉】elementUI源碼分析- 組件庫的整體設計

作者:黃軼
鏈接:https://juejin.cn/post/6844903925632466951

需求分析

當我們去實現一個組件庫的時候,并不會一上來就擼碼,而是把它當做產品一樣,思考一下我們的組件庫的需求。那么對于 element-ui,除了基于 Vue.js 技術棧開發組件,它還有哪些方面的需求呢。

豐富的 feature:豐富的組件,自定義主題,國際化。
文檔 & demo:提供友好的文檔和 demo,維護成本小,支持多語言。
安裝 & 引入:支持 npm 方式和 cdn 方式,并支持按需引入。
工程化:開發,測試,構建,部署,持續集成。

需求有了,接下來就需要去思考如何去實現,本文會依據 element-ui 2.11.1 版本的源碼來分析這些需求是如何實現的。當然,element-ui 早期一定不是這樣子的,我們分析的這個版本已經是經過它多次迭代優化后的,如果你想去了解它的發展歷程,可以去 GitHub 搜索它的歷史版本。

豐富的feature

豐富的feature

組件庫最核心的還是組件,先來看一下 element-ui 組件的設計原則:一致、反饋、效率、可控。具體的解釋在官網有,我就不多貼了,在 element-ui 開發團隊背后,有一個強大的設計團隊,這也得益于 element-ui 的創始人 sofish 在公司的話語權和地位,爭取到這么好的資源。所以 element-ui 組件的外型、配色、交互都做的非常不錯。

作為一個基礎組件庫,還有一個很重要的方面就是組件種類豐富。element-ui 官方目前有 55 個組件,分成了 6 大類,分別是基礎組件、表單類組件、數據類組件、提示類組件、導航類組件和其它類型組件。這些豐富的基礎組件能很好地滿足大部分 PC 端 to B 業務開發需求。

開發這么多組件,需要大量的時間和精力,所以這里要非常感謝 element-ui 團隊,為我們提供了這些基礎組件,我們基于它們做二次開發,節約了非常多的時間。

element-ui 的組件源碼在 packages 目錄里維護,而并不在 src 目錄中。這么做并不是為了要采用 monorepo,我也并沒有找到 lerna 包管理工具,這么做的目的我猜測是為了讓每個組件可以單獨打包,支持按需引入。但實際上想達到這個目的也并不一定需要這么去組織維護代碼,我更推薦把組件庫中的組件代碼放在 src/components 目錄中維護,然后通過修改 webpack 配置腳本也可以做到每個組件單獨打包以及支持按需引入,源碼放在 src 目錄總是更合理的。

自定義主題

element-ui 的一大特色是支持自定義主題,你可以使用在線主題編輯器,可以修改定制 Element 所有全局和組件的 Design Tokens,并可以方便地實時預覽樣式改變后的視覺。同時它還可以基于新的定制樣式生成完整的樣式文件包,供直接下載使用,那么它是如何做到這點的呢?

image.png

element-ui 組件的樣式、公共樣式都在 packages/theme-chalk 文件中,并且它是可以獨立發布的。element-ui組件樣式中的顏色、字體、線條等等樣式都是通過變量的方式引入的,在 packages/theme-chalk/src/common/var.scss 中我們可以看到這些變量的定義,這樣就給做多主題提供了方便,因為我只要修改這些變量,就可以實現組件的主題改變。

了解了基本原理,做在線替換主題也并不是難事了,我并不會詳細去講在線定制主題前端交互部分,感興趣的同學可以自己去看源碼,都在 examples 目錄中,我這里只說一下本質的原理。

想要做到在線換膚,并且實時預覽,需要借助 server 的幫助,比如主題可以通過一個配置去維護,用戶做一系列操作后,會生成新的主題配置,把這個配置通過接口提交的方式告訴 server,然后 server 會根據這個配置做返回生成新的 CSS(具體的實施的方案未開源,大致會做一些變量替換,然后編譯),新的 CSS 的樣式就會覆蓋默認的樣式,達到了切換主題的目的。

我們可以在主題編輯頁面打開網絡面板,可以看到有 2 個 xhr 請求,如圖:

image.png

其中,updateVarible 是一個 POST 請求,他會把你修改的的主題配置提交到后端 server,提交的數據你可以自己去查看它的 Request Payload,這個 POST 請求會返回一段 CSS 文本,然后會動態插入到 head 標簽的底部,來覆蓋默認樣式,你可以通過審查元素看到 head 底部會動態插入一個 id 為 chalk-style 的標簽。

下圖就是該請求返回的樣式文本 :

image.png

相關代碼在 examples/components/theme/loader/index.vue 中。

    onAction() {
      this.triggertProgressBar(true);
      const time = +new Date();
      updateVars(this.userConfig)
        .then(res => {
          this.applyStyle(res, time);
        })
        .catch(err => {
          this.onError(err);
        })
        .then(() => {
          this.triggertProgressBar(false);
        });
    },
    applyStyle(res, time) {
      if (time < this.lastApply) return;
      this.updateDocs(() => {
        updateDomHeadStyle('chalk-style', res);
      });
      this.lastApply = time;
    }

onAction 函數中的 updateVars 就是去發送 POST 請求,而 applyStyle 函數就是去修改和覆蓋默認樣式,updateDocs 函數會去更新默認主題顏色,updateDomHeadStyle 樣式會添加或者修改 id 為 chalk-style 的 style 標簽,目的就是覆蓋默認樣式,應用新主題樣式。

updateVars 請求在頁面加載的時候會發起,在你修改完主題配置后也會發起。
再來看一下 getVarible 請求,它是一個 GET 請求,返回的內容是主題配置頁面右側配置面板的數據源,如下圖所示:

image.png

主題配置面板根據該數據源生成,并且當你去編輯其中一項的時候,又會發起 updateVars POST 請求,把更新的配置提交,然后后端會返回新的 CSS 并在前端生效。
另外,用戶修改的配置還利用了 localStorage 在本地保存了一份,這樣用戶每次編輯都可以保存一份主題,下次也可以繼續基于某個主題繼續編輯。

不過,這么實現多主題也并非完美,為了編譯加速,element-ui 把樣式部分單獨抽離出單獨的文件,這樣給開發組件的同學帶來很大的不便,當你去編寫組件的樣式的時候,需要在多個文件中來回切換,而且這樣也不符合組件就近管理的原則。但是如果把樣式寫在組件中,server 端去編譯生成單獨樣式文件的時間就會增長(需要從組件中提取 CSS),所以這是一個需要權衡的問題。

國際化

說到 Vue 的國際化方案,大家很容易會聯想到 vue-i18n 方案,element-ui 并未引入 vue-i18n,不過它是可以很好地與 vue-i18n 兼容的。
所有的國際化方案都會用到語言包,語言包通常會返回一個 JSON 格式的數據,element-ui 組件庫的語言包在 src/locale/lang 目錄下,以英語語言包為例:

export default {
  el: {
    colorpicker: {
      confirm: 'OK',
      clear: 'Clear'
    }
    // ...
  }
}

在 packages/color-picker/src/components/picker-dropdown.vue 中,我們在模板部分可以看到這個語言包的使用:

<el-button
  size="mini"
  type="text"
  class="el-color-dropdown__link-btn"
  @click="$emit('clear')">
  {{ t('el.colorpicker.clear') }}
</el-button>
<el-button
  plain
  size="mini"
  class="el-color-dropdown__btn"
  @click="confirmValue">
  {{ t('el.colorpicker.confirm') }}
</el-button>

模板中用到的 t 函數,它定義在 src/mixins/locale.js 中:

import { t } from 'element-ui/src/locale';

export default {
  methods: {
    t(...args) {
      return t.apply(this, args);
    }
  }
};

實際上是在 src/locale/index.js 中定義的 t 函數:

export const t = function(path, options) {
  let value = i18nHandler.apply(this, arguments);
  if (value !== null && value !== undefined) return value;

  const array = path.split('.');
  let current = lang;

  for (let i = 0, j = array.length; i < j; i++) {
    const property = array[I];
    value = current[property];
    if (i === j - 1) return format(value, options);
    if (!value) return '';
    current = value;
  }
  return '';
};

這個函數是根據傳入的 path 路徑,比如我們例子中的 el.colorpicker.confirm,從語言包中找到對應的文案。其中 i18nHandler 是一個 i18n 的處理函數,這塊邏輯就是用來兼容外部的 i18n 方案如 vue-i18n。

let i18nHandler = function() {
  const vuei18n = Object.getPrototypeOf(this || Vue).$t;
  if (typeof vuei18n === 'function' && !!Vue.locale) {
    if (!merged) {
      merged = true;
      Vue.locale(
        Vue.config.lang,
        deepmerge(lang, Vue.locale(Vue.config.lang) || {}, { clone: true })
      );
    }
    return vuei18n.apply(this, arguments);
  }
};

export const i18n = function(fn) {
  i18nHandler = fn || i18nHandler;
};

export const use = function(l) {
  lang = l || lang;
};

可以看到 i18nHandler 默認會嘗試去找 Vue 原型中的 t 函數,這是 vue-i18@5.x 的實現,會在 Vue 的原型上掛載t 方法。
另外它也暴露了 i18n 方法,可以外部傳入其它的 i18n 方法,覆蓋 i18nHandler。
如果沒有外部提供的 i18n 方法,那么就直接找到當前的語言包 let current = lang;,接下來的邏輯就是從這個語言包對象中讀到對應的字符串值,當然如果字符串需要格式化則調用 format 函數,這塊邏輯同學們感興趣可以自己看。
因此在使用對應的語言包的時候一定要注冊:

import lang from 'element-ui/lib/locale/lang/en'
import locale from 'element-ui/lib/locale'

// 設置語言
locale.use(lang)

這樣就注冊了英文語言包,在模板中就可以正常使用并找到對應的語言了。
如果你要開發一個國際化項目,在運行時才能知道用戶的語言,可以考慮使用異步動態加載的方式,在渲染頁面前先獲取語言包,另外也可以考慮做緩存優化,不過這個話題延伸起來就有點多了,未來我可能會單開一個主題去分享業務如何做國際化。

文檔 & Demo

作為一個優秀的開源組件庫,友好的文檔和 demo 是必不可少的,它也能幫你招攬到不少用戶。作為一個組件庫的開發者和維護者,也希望用最小的成本來維護文檔和 demo。


image.png

element-ui 的文檔和 demo 是融為一體的,我們打開它的文檔,可以看到文檔不僅介紹了每個組件的使用方式,還展示了組件的各種示例,并且還可以清楚地看到每個示例的源碼,對用戶而言非常友好。那么 element-ui 內部是如何去編寫這些 demo 和文檔的呢?實際上,每個組件的文檔和 demo 都是通過一個單獨的 .md 文件生成的,那么它又是如何做到這點的呢?

element-ui 的 demo 源碼都在 examples 目錄中維護,當我們在 element-ui 工程下運行 npm run dev 的時候,會啟動它的開發調試模式,并且運行官方文檔和 demo。
看一下 npm scripts:

"scripts": {
    "bootstrap": "yarn || npm i",
    "build:file": "node build/bin/iconInit.js & node build/bin/build-entry.js & node build/bin/i18n.js & node build/bin/version.js",
    "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",
}

我們省略了其它的 scripts,重點看 dev 和相關的幾個命令,其中 bootstrap 的作用是安裝依賴,build:file 的作用是運行 build 目錄下幾個命令,包括對 icon、entry、i18n、version 等初始化。在執行完 bootstrap 和 build:file 后,通過 webpack-dev-server 運行 build/webpack.demo.js,這個是重點,我們來看一下這個 webpack 的配置文件。

const webpackConfig = {
  mode: process.env.NODE_ENV,
  entry: isProd ? {
    docs: './examples/entry.js',
    'element-ui': './src/index.js'
  } : (isPlay ? './examples/play.js' : './examples/entry.js'),
  output: {
    path: path.resolve(process.cwd(), './examples/element-ui/'),
    publicPath: process.env.CI_ENV || '',
    filename: '[name].[hash:7].js',
    chunkFilename: isProd ? '[name].[hash:7].js' : '[name].js'
  },
  resolve: {
    extensions: ['.js', '.vue', '.json'],
    alias: config.alias,
    modules: ['node_modules']
  },
  devServer: {
    host: '0.0.0.0',
    port: 8085,
    publicPath: '/',
    hot: true
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          compilerOptions: {
            preserveWhitespace: false
          }
        }
      },
      {
        test: /\.md$/,
        use: [
          {
            loader: 'vue-loader',
            options: {
              compilerOptions: {
                preserveWhitespace: false
              }
            }
          },
          {
            loader: path.resolve(__dirname, './md-loader/index.js')
          }
        ]
      }
    ]
  }
};

由于整個配置文件內容比較長,我只保留了重點的部分,重點看一下 entry 和 module 下的 rules。
element-ui 官網本質上就是一個用 vue 開發的應用,當我們運行 npm run dev 的時候,入口文件是 examples 目錄下的 entry.js:

import Vue from 'vue';
import entry from './app';
import VueRouter from 'vue-router';
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';
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');

入口文件做的事情很簡單,全引入的方式注冊了 element-ui 組件庫,注冊了一些官網用到的組件,注冊了路由以及路由的全局鉤子函數。

這里我們要重點關注路由部分,路由的配置都在 examples/route.config.js 中:

import navConfig from './nav.config';
import langs from './i18n/route';

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

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

const registerRoute = (navConfig) => {
  let route = [];
  Object.keys(navConfig).forEach((lang, index) => {
    let navs = navConfig[lang];
    route.push({
      path: `/${ lang }/component`,
      redirect: `/${ lang }/component/installation`,
      component: load(lang, 'component'),
      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 {
        addRoute(nav, lang, index);
      }
    });
  });
  function addRoute(page, lang, index) {
    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
    };

    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;

這個路由配置文件提供了指南、組件、主題、資源等多個路由頁面的配置,并且支持了多語言,我們重點關注一下組件路由是如何生成的,它主要通過 registerRoute(navConfig) 方法生成。

其中 navConfig 讀取的是 examples/nav.config.json 文件,這個配置文件太長我就不貼了,它包括了多個語言的配置,維護了左側組件導航菜單路徑映射關系。
registerRoute 函數內部就是遍歷 navConfig,根據它內部元素的數據結構生成路由配置,如果數據中有 children 則生成子路由。

我們知道 Vue Router 的本質是根據不同的 URL path,<router-view> 組件映射到對應的路由組件,對于每一個組件的路由,都是通過 addRoute(nav, lang, index) 方法生成的,該方法內部又調用了 loadDocs(lang, page.path) 獲取到對應的路由組件。

const loadDocs = function(lang, path) {
  return LOAD_DOCS_MAP[lang](path);
};

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

以中文為例,我們獲取到某個 path 下的路由組件就是一個工廠函數,它對應加載的組件路徑是 exmaples/docs/zh-CN/${path}.md。這里要注意的是,和我們普通的異步組件加載方式不同,這里加載的居然是一個 .md 文件,而并非一個 .vue 文件,但卻能和 .vue 文件一樣能渲染成一個 Vue 組件,這是如何做到的呢?

我們知道,webpack 的理念是一切資源都可以 require,只要配置了對應的 loader。回到 build/webpack.demo.js,我們發現對于 .md 文件我們配置了相應的 loader:

  {
    test: /\.md$/,
    use: [
      {
        loader: 'vue-loader',
        options: {
          compilerOptions: {
            preserveWhitespace: false
          }
        }
      },
      {
        loader: path.resolve(__dirname, './md-loader/index.js')
      }
    ]
  }

對于 .md 文件,這里 use 數組中配置了 2 項,它們執行順序是逆序的,也就是先執行 md-loader,再執行 vue-loader,md-loader 的代碼在 build/md-loader/index.js 中:

const {
  stripScript,
  stripTemplate,
  genInlineComponentText
} = require('./util');
const md = require('./config');

module.exports = function(source) {
  const content = md.render(source);

  const startTag = '<!--element-demo:';
  const startTagLen = startTag.length;
  const endTag = ':element-demo-->';
  const endTagLen = endTag.length;

  let componenetsString = '';
  let id = 0; // demo 的 id
  let output = []; // 輸出的內容
  let start = 0; // 字符串開始位置

  let commentStart = content.indexOf(startTag);
  let commentEnd = content.indexOf(endTag, commentStart + startTagLen);
  while (commentStart !== -1 && commentEnd !== -1) {
    output.push(content.slice(start, commentStart));

    const commentContent = content.slice(commentStart + startTagLen, commentEnd);
    const html = stripTemplate(commentContent);
    const script = stripScript(commentContent);
    let demoComponentContent = genInlineComponentText(html, script);
    const demoComponentName = `element-demo${id}`;
    output.push(`<template slot="source"><${demoComponentName} /></template>`);
    componenetsString += `${JSON.stringify(demoComponentName)}: ${demoComponentContent},`;

    // 重新計算下一次的位置
    id++;
    start = commentEnd + endTagLen;
    commentStart = content.indexOf(startTag, start);
    commentEnd = content.indexOf(endTag, commentStart + startTagLen);
  }

  // 僅允許在 demo 不存在時,才可以在 Markdown 中寫 script 標簽
  // todo: 優化這段邏輯
  let pageScript = '';
  if (componenetsString) {
    pageScript = `<script>
      export default {
        name: 'component-doc',
        components: {
          ${componenetsString}
        }
      }
    </script>`;
  } else if (content.indexOf('<script>') === 0) { // 硬編碼,有待改善
    start = content.indexOf('</script>') + '</script>'.length;
    pageScript = content.slice(0, start);
  }

  output.push(content.slice(start));
  return `
    <template>
      <section class="content element-doc">
        ${output.join('')}
      </section>
    </template>
    ${pageScript}
  `;
};

webpack loader 的原理很簡單,輸入是文件的原始內容,返回的是經過 loader 處理后的內容。對于 md-loader,輸入的是 .md 文檔,輸出的則是一個 Vue SFC 格式的字符串,這樣它的輸出就可以作為下一個 vue-loader 的輸入做處理了。

我們來簡單看一下 md-loader 中間處理過程。首先執行了 md.render(source) 對 md 文檔解析,提取文檔中 :::demo {content} ::: 內容,分別生成一些 Vue 的模板字符串,然后再從這個模板字符串中循環查找 包裹的內容,從中提取模板字符串到 output 中,提取 script 到 componenetsString 中,然后構造 pageScript,最后返回的內容就是:

  return `
    <template>
      <section class="content element-doc">
        ${output.join('')}
      </section>
    </template>
    ${pageScript}
  `;

最終生成的字符串滿足我們通常編寫的 .vue SFC 格式,它會作為下一個 vue-loader 的輸入,所以這樣我們就相當于通過加載一個 .md 格式的文件的方式加載了 Vue 組件。

這里面還有很多和 .md 文件解析的細節,如果你對最終生成的 output 和 pageScript 代碼是什么感興趣,建議你自己調試一番。

element-ui 這種文檔和 demo 的實現方式是非常巧妙的,大大減少了 demo 和文檔的維護成本,并且對于用戶來說也非常友好,如果你也為自己的庫構建文檔,不妨參考它的實現。

安裝 & 引入

通常 JS 庫都會支持 npm 和 CDN 2 種安裝方式,element-ui 也不例外。

先說一下 CDN 的安裝方式,實際上 element-ui 會把所有組件打包生成一份 CSS 和 JS,官方也提供了例子:

<!-- 引入樣式 -->
<link rel="stylesheet" >
<!-- 引入組件庫 -->
<script src="https://unpkg.com/element-ui/lib/index.js"></script>

CDN 安裝方式有它的好處,不需要構建工具,開箱即用,但缺點也很明顯,全量引入了所有組件,體積非常大。

由于大部分人在開發 Vue 項目都是基于 vue-cli 腳手架初始化項目的,所以更推薦使用 npm 方式安裝。

npm i element-ui -S

說到 npm 安裝,不得不提 element-ui 提供的 2 種組件引入方式,完整引入和部分引入。

支持完整引入非常容易,把所有組件打包成一份 CSS 和 JS,并且在 package.json 中配置:

"main": "lib/element-ui.common.js"

這樣當用戶執行 import ElementUI from 'element-ui' 的時候就可以完整引入了組件的 JS 代碼了。正如我們之前說的,element-ui 會單獨發布 CSS,所以你還需要 import 'element-ui/lib/theme-chalk/index.css'。

完整引入的好處是方便,只需要 2 行代碼就可以完整地使用 element-ui 所有的組件,但缺點也很明顯,引入的組件包體積很大,通常一個項目也用不到所有的組件,會有資源浪費。

因此最佳實踐就是按需引入:

import Vue from 'vue'
import { Button } from 'element-ui'

Vue.component(Button.name, Button)

大部分人這么用的時候會覺得理所當然,不知道大家有沒有想過:為什么這種引入方式可以實現按需引入呢?要搞清楚這個問題,就要搞清楚 import { Button } from 'element-ui' 這個背后都做了什么。

其實官網已經有答案了,在使用按需引入的時候,要借助 babel-plugin-component 這個 webpack 插件,并且配置 .babelrc:

{
  "presets": [["es2015", { "modules": false }]],
  "plugins": [
    [
      "component",
      {
        "libraryName": "element-ui",
        "styleLibraryName": "theme-chalk"
      }
    ]
  ]
}

實際上它是把 import { Button } from 'element-ui' 轉換成:

var button = require('element-ui/lib/button')
require('element-ui/lib/theme-chalk/button.css')

這樣我們就精準地引入了對應 lib 下的 Button 組件的 JS 和 CSS 代碼了,也就實現了按需引入 Button 組件。

element-ui 這種按需引入的方式雖然方便,但背后卻要解決幾個問題,由于我們支持每個組件可以單獨引入,那么如果產生了組件依賴并且同時按需引入的時候,代碼冗余問題怎么解決。舉個例子,在 element-ui 中,Table 組件依賴了 CheckBox 組件,那么當我同時引入了 Table 組件和 CheckBox 組件的時候,會不會產生代碼冗余呢?

import { Table, CheckBox } from 'element-ui'

如果你不做任何處理的話,答案是會,你最終引入的包會有 2 份 CheckBox 的代碼。那么 element-ui 是怎么解決這個問題的呢?實際上只是部分解決了,它的 webpack 配置文件中配置了 externals,在 build/config.js 中我們可以看到這些具體的配置:

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()];

externals 可以防止將這些 import 的包打包到 bundle 中,并在運行時再去從外部獲取這些擴展依賴。

我們來看一下打包后的 lib/table.js,我們可以看到編譯后的 table.js 對 CheckBox 組件的依賴引入:

module.exports = require("element-ui/lib/checkbox");

這么處理的話,就不會打包生成 2 份 CheckBox JS 部分的代碼了,但是對于 CSS 部分,element-ui 并未處理冗余情況,可以看到 lib/theme-chalk/checkbox.css 和 lib/theme-chalk/table.css 中都會有 CheckBox 組件的 CSS 樣式。

其實,要解決按需引入的 JS 和 CSS 的冗余問題并非難事,可以用后編譯的思想,即依賴包提供源碼,而編譯交給應用處理,這樣不僅不會有組件冗余代碼,甚至連編譯的冗余代碼都不會有,實際上我們基于 element-uifork 的組件庫 zoom-ui 就應用了后編譯技術,之前在滴滴搞的開源組件庫cube-ui 組件庫也是這么玩的。更多后編譯相關介紹可以參考這篇文章

工程化

前端對于工程化的要求越來越高,element-ui 作為一個組件庫,它在工程化方面做了哪些事情呢?

首先是開發階段,為了保證大家代碼風格的一致性,使用了 ESLint,甚至專門寫了 eslint-config-elemefe 作為 ESint 的擴展規則配置;為了方便本地開發調試,借助了 webpack 并配置了 Hot Reload;利用模塊化開發的思想把組件依賴的一些公共模塊放在了 src 目錄,并依據功能拆分出 directives、locale、mixins、transitions、utils 等模塊。

其次是測試方面,使用了 karma 測試框架,為每一個組件編寫了單元測試,并且利用 Travis CI 集成了測試。

接著是構建方面,element-ui 編寫了很多 npm scripts,以 dist 這個 script 為例:

"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"

它內部會依次執行多個命令,最終會生成 lib 目錄和打包后的文件。我并不打算介紹所有的命令,感興趣同學可自行研究,這里我想介紹一下 build:file 這個 script 做的事情:

"build:file": "node build/bin/iconInit.js & node build/bin/build-entry.js & node build/bin/i18n.js & node build/bin/version.js",

這里會依次執行 build/bin 目錄下的一些 Node 腳本,對 icon、entry、i18n、version 等做了一系列的初始化工作,它們的內容都是根據一些規則做文件的 IO,這么做的好處就是完全通過工具的手段自動化生成文件,比人工靠譜且效率更高,這波操作非常值得我們學習和應用。

最后是部署,通過 pub 這個 npm script 完成:

"pub": "npm run bootstrap && sh build/git-release.sh && sh build/release.sh && node build/bin/gen-indices.js && sh build/deploy-faas.sh"

主要是通過運行一系列的 bash 腳本,實現了代碼的提交、合并、版本管理、npm 發布、官網發布等,讓整個發布流程自動化完成,腳本具體內容感興趣的同學可自行查看。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,119評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,382評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,038評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,853評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,616評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,112評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,192評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,355評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,869評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,727評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,928評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,467評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,165評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,570評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,813評論 1 282
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,585評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,892評論 2 372

推薦閱讀更多精彩內容