Vue 3 組件庫:element-plus 源碼分析

基於 Vue3 的組件庫 element-plus 正式發佈,element-plus 是一個使用 TypeScript + Composition API 重構的全新項目。官方列出了下面幾項主要更新,本文會閱讀 element-plus 的源碼,從如下幾個方面在總體和細節上來分析重構以後的源碼,建議閱讀本文前先 clone 組件代碼。javascript

  • 使用 TypeScript 開發
  • 使用 Vue 3.0 Composition API 下降耦合,簡化邏輯
  • 使用 Vue 3.0 Teleport 新特性重構掛載類組件
  • Vue 2.0 全局 API 切換爲 Vue 3.0 實例API
  • 國際化處理
  • 官方文檔網站打包
  • 組件庫和樣式打包
  • 使用 Lerna 維護和管理項目

Typescript 相關

element-plus 引入了 typescript, 除了配置對應的 eslint 校驗規則、插件,定義 tsconfig.json 以外,打包 es-module 格式組件庫的時候的時候使用到了一些 rollup 插件。css

  • @rollup/plugin-node-resolve
  • rollup-plugin-terser
  • rollup-plugin-typescript2
  • rollup-plugin-vue
// build/rollup.config.bundle.js
import { nodeResolve } from '@rollup/plugin-node-resolve'
import { terser } from 'rollup-plugin-terser'
import typescript from 'rollup-plugin-typescript2'
const vue = require('rollup-plugin-vue')
export default [{
  // ... 省略前面部份內容 
  plugins: [
   terser(),
   nodeResolve(),
   vue({
     target: 'browser',
     css: false,
     exposeFilename: false,
   }),
   typescript({
     tsconfigOverride: {
       'include': [
         'packages/**/*',
         'typings/vue-shim.d.ts',
       ],
       'exclude': [
         'node_modules',
         'packages/**/__tests__/*',
       ],
     },
   }),
 ],
}]
複製代碼

@rollup/plugin-node-resolve 打包依賴的 npm 包html

rollup-plugin-terser 壓縮代碼vue

rollup-plugin-vue 打包 vue 文件, css 樣式交給了後續會提到的 gulp 來處理。java

rollup-plugin-typescript2 是用了編譯 typescript 的, 配置中排除了 node-modules 和測試相關文件, include 除了包含組件實現,還包含了 typings/vue-shim.d.ts 文件。node

插件中使用到的 typings/vue-shim.d.ts 類型聲明文件( 以 .d.ts 結尾的文件會被自動解析 ),定義了一些全局的類型聲明,能夠直接在 ts 或者 vue 文件中使用這些類型約束變量。還使用擴展模板對 import XX from XX.vue 的引入變量給出類型提示。webpack

// typings/vue-shim.d.ts
declare module '*.vue' {
  import { defineComponent } from 'vue'
  const component: ReturnType<typeof defineComponent>
  export default component
}
declare type Nullable<T> = T | null;
declare type CustomizedHTMLElement<T> = HTMLElement & T
declare type Indexable<T> = {
  [key: string]: T
}
declare type Hash<T> = Indexable<T>
declare type TimeoutHandle = ReturnType<typeof global.setTimeout>
declare type ComponentSize = 'large' | 'medium' | 'small' | 'mini'
複製代碼

除了 d.ts 文件以外,element-plus 中對於 props 的類型聲明使用了 vue3 的 propType。以 下面的 Alert 爲例, 使用了 PropType 的 props 類型會執行符合咱們自定義的規則的構造函數,而後結合 typescript 作類型校驗。其餘非 props 中的類型聲明則是使用了 interfacegit

import { PropType } from 'vue'
export default defineComponent({
  name: 'ElAlert',
  props: {
    type: {
      type: String as PropType<'success' | 'info' | 'error' | 'warning'>,
      default: 'info',
    }
  }
})
複製代碼

更多 vue3 的 typescript 支持能夠查看官方文檔github

Composition API

官方說明使用了 Vue 3.0 Composition API 下降耦合,簡化邏輯。Composition API 的使用和 hooks 的複用 vue-3-playground 中經過一個購物車 demo 的實現提供了一個直觀和簡潔的示例。web

關於經常使用的 Composition API 的用法,能夠查看這篇總結得比較好的文章,快速使用Vue3最新的15個經常使用API

除了使用新的 Composition API 來改寫組件以外,element-plus 中 packages/hooks 目錄下抽取了幾個可複用的 hooks 文件

以 autocomplete, input 等控件使用到的 use-attrs 爲例, 主要作的事情是繼承綁定的屬性和事件,相似於 $attrs$listener 功能,可是作了一些篩選,去掉了一些不須要繼承的屬性和事件綁定。

watchEffect(() => {
    const res = entries(instance.attrs)
      .reduce((acm, [key, val]) => {
        if (
          !allExcludeKeys.includes(key) &&
          !(excludeListeners && LISTENER_PREFIX.test(key))
        ) {
          acm[key] = val
        }
        return acm
      }, {})
    attrs.value = res
  })
複製代碼

Vue3 中仍然保留了 mixin,咱們能夠在特定組件或者是全局使用 mixin 來複用邏輯,同時也引入了 hooks 來改善 mixin 存在的一些問題

  1. 渲染上下文中公開的屬性的來源不清楚。 例如,當使用多個 mixin 讀取組件的模板時,可能很難肯定從哪一個 mixin 注入了特定的屬性。
  2. 命名空間衝突。 Mixins 可能會在屬性和方法名稱上發生衝突

Hooks 帶來的好處是

  1. 暴露給模板的屬性具備明確的來源,由於它們是從 Hook 函數返回的值。
  2. Hook 函數返回的值能夠任意命名,所以不會發生名稱空間衝突。

Teleport 的使用

element-plus 對幾個掛載類組件使用了 vue3 的新特性 Teleport,這個新特性能夠幫咱們把其包裹的元素移動到咱們指定的節點下。

Teleport 提供了一種乾淨的方法,容許咱們控制在 DOM 中哪一個父節點下呈現 HTML,而沒必要求助於全局狀態或將其拆分爲兩個組件。-- Vue 官方文檔

查看官網咱們會發現 Dialog,Drawer,以及使用了 Popper 的 Tooltip 和 Popover 都新增了一個 append-to-body 屬性。咱們以 Dialog 爲例: appendToBody 爲 false, Teleport 會被 disabled, DOM 仍是在當前位置渲染,當爲 true 時, dialog 中的內容放到了 body 下面。

<template>
  <teleport to="body" :disabled="!appendToBody"> <transition name="dialog-fade" @after-enter="afterEnter" @after-leave="afterLeave" > ... </transition> </teleport>
</tamplate>
複製代碼

在原來的 element-ui 中,Tooltip 和 Popover 也是直接放在了 body 中,原來是經過 vue-popper.js 來使用 document.body.appendChild 來添加元素到 body 下的,element-plus 使用 Teleport 來實現相關邏輯。

全局 API - 實例 API

當咱們安裝好組件庫,use 方法會執行 install 方法去全局掛載組件。 咱們先來看一下 Vue 2.x element-ui 中全局 API 的寫法:
Vue.component 方法綁定全局組件
Vue.use 綁定全局自定義指令
Vue.prototype 綁定全局變量和全局方法

const install = function(Vue, opts = {}) {
  locale.use(opts.locale);
  locale.i18n(opts.i18n);
  
  // Vue.component 方法綁定全局組件
  components.forEach(component => {
    Vue.component(component.name, component);
  });
  
  // Vue.use 綁定全局自定義指令
  Vue.use(InfiniteScroll);
  Vue.use(Loading.directive);
 
  // Vue.prototype 綁定全局變量和全局方法
  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;
};
複製代碼

可是在 vue 3.0 中,任何全局改變 Vue 行爲的 API 如今都會移動到應用實例上,也就是 createApp 產生的 app 上了, 對應的 API 也作了相應的變化。

再來看使用 Vue 3.0 的 element-plus 中,全局 API 改寫成了實例 API。

import type { App } from 'vue'
const plugins = [
  ElInfiniteScroll,
  ElLoading,
  ElMessage,
  ElMessageBox,
  ElNotification,
]
const install = (app: App, opt: InstallOptions): void => {
  const option = Object.assign(defaultInstallOpt, opt)
  use(option.locale)
  app.config.globalProperties.$ELEMENT = option    // 全局設置默認的size屬性和z-index屬性
  // 全局註冊全部除了plugins以外的組件
  components.forEach(component => {
    app.component(component.name, component)
  })
  plugins.forEach(plugin => {
    app.use(plugin as any)
  })
}
複製代碼

除此以外寫法上有一些不一樣的是,消息類組件添加 $ 全局方法在 element-plus 中被移動到了 index.ts 裏面, 幾個消息通知類型的組件都放到了 plugins,使用 app.use 會調用對應組件 index.ts 中的 install 方法,代碼以下:

(Message as any).install = (app: App): void => {
  app.config.globalProperties.$message = Message
}
複製代碼

國際化

packages 下有一個 locale 文件夾,控制語言切換 packages/locale/index.ts 中拋出了 2 個方法,方法 t 和方法 use, t 控制 vue 文件中文本的翻譯替換,use 方法修改全局語言

// packages/locale/index.ts
export const t = (path:string, option?): string => {
  let 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 template(value, option)
    if (!value) return ''
    current = value
  }
  return ''
}
複製代碼

會在 vue 文件中引入 locale 中的 t 方法

import { t } from '@element-plus/locale'
複製代碼

而後就能夠在 template 使用多語言 key 值了,例如:label="t('el.datepicker.nextMonth')"t 方法會幫你找到對應的語言文件中的對應值。
再來看看 use 方法,拋出的 use 方法能夠設置全局語言種類,也修改 day.js 的語言配置。 element-plus 中引入了 day.js 替換原來的 moment.js 來作時間的格式化和時區信息等的處理。

export const use = (l): void => {
  lang = l || lang
  if (lang.name) {
    dayjs.locale(lang.name)
  }
}
複製代碼

咱們的業務組件引入 element-plus 以後,會使用這個 use 方法來設置語言種類,可參照官方文檔

Website 打包

website, 也就是文檔網站,提供各個控件的使用示例。website/entry.js 中的

import ElementPlus from 'element-plus'
複製代碼

其實應該是引入了 packages/element-plus/index.ts 文件,而後就能夠在 md 中使用 packages 中的各個組件了,組件邏輯修改也能夠當即生效。

和 element-ui 一致,element-plus 的 website dev 起服務和打包都是用的 webpack,使用到了 vue-loader 來處理 vue 文件,使用 babel-loader 處理 js/ts 文件,樣式文件和字體圖標分別使用了對應的 css-loaderurl-loader 等。

相關配置在 website/webpack.config.js

文檔展現主要的 md 文件,使用了 website/md-loader/index.js 本身實現的 md-loader, 分別從 md 中提取出 <template><script> 內容 ,將 md 中的組件示例轉化成了 vue 的字符串,而後再經過 vue-loader 來處理。

rules: [
  {
    test: /\.vue$/,
    use: 'vue-loader',
  },
  {
    test: /\.(ts|js)x?$/,
    exclude: /node_modules/,
    loader: 'babel-loader',
  },
  {
    test: /\.md$/,
    use: [
      {
        loader: 'vue-loader',
        options: {
          compilerOptions: {
            preserveWhitespace: false,
          },
        },
      },
      {
        loader: path.resolve(__dirname, './md-loader/index.js'),
      },
    ],
  },
  {
    test: /\.(svg|otf|ttf|woff2?|eot|gif|png|jpe?g)(\?\S*)?$/,
    loader: 'url-loader',
    // todo: 這種寫法有待調整
    query: {
      limit: 10000,
      name: path.posix.join('static', '[name].[hash:7].[ext]'),
    },
  },
]
複製代碼

組件庫和樣式打包

element-plus 的打包命令有這麼一長串,其中 yarn build:libyarn build:lib-full 是用到了 webpack 打 umd 格式的全量包。其他的則是分別使用到了 rollup 和 gulp。

"build": "yarn bootstrap && yarn clean:lib && yarn build:esm-bundle && yarn build:lib && yarn build:lib-full && yarn build:esm && yarn build:utils && yarn build:locale && yarn build:locale-umd && yarn build:theme"
複製代碼

使用 rollup 打包組件 bundle

除了使用 webpack 來打包組件以外,element-plus 還提供了另一種 es-module 的打包方式,最後發佈到 npm 的既有 webpack 打包的成果物,也有 rollup 打包的 es-module bundle。
rollup 相關的邏輯在 build/rollup.config.bundle.js 文件中
入口爲 export 全部組件的 /packages/element-plus/index.ts , 採用 es-module 規範最終打包到 lib/index.esm.js 中。因爲打包時使用了 Typescript 插件,最後生成的文件除了全量的 index.esm.js,還有每一個組件單獨的 lib 文件。

// build/rollup.config.bundle.js
export default [
  {
    input: path.resolve(__dirname, '../packages/element-plus/index.ts'),
    output: {
      format: 'es',    // 打包格式爲 es,可選cjs(commonJS) ,umd 等
      file: 'lib/index.esm.js',
    },
    external(id) {
      return /^vue/.test(id)
        || deps.some(k => new RegExp('^' + k).test(id))
    },
  }
]
複製代碼

使用 gulp 打包樣式文件和字體圖標

和 element-ui 同樣,樣式文件和字體圖標的打包使用的是 packages/theme-chalk/gulpfile.js, 把每一個 scss 文件打包成單獨的 css, 其中包含了通用的 base 樣式,還有每一個組件的樣式文件。

// packages/theme-chalk/gulpfile.js
function compile() {
  return src('./src/*.scss')
    .pipe(sass.sync())
    .pipe(autoprefixer({ cascade: false }))
    .pipe(cssmin())
    .pipe(rename(function (path) {
      if(!noElPrefixFile.test(path.basename)) {
        path.basename = `el-${path.basename}`
      }
    }))
    .pipe(dest('./lib'))
}
function copyfont() {
  return src('./src/fonts/**')
    .pipe(cssmin())
    .pipe(dest('./lib/fonts'))
}
複製代碼

再經過 npm script 中一些文件拷貝和刪除操做,打包以後的樣式和字體圖標文件最終會放到 lib/theme-chalk 目錄下。

cp-cli packages/theme-chalk/lib lib/theme-chalk && rimraf packages/theme-chalk/lib
複製代碼

小結

咱們看到一個組件庫使用了 3 種打包工具:rollupwebpackgulp
VueReact 等開源庫開始採用 rollup,構建會更快,而後應用類工程仍是主要使用 webpack,由於 webpack 能用插件和各類 loader 處理其餘非 javascript 類型的資源。而皮膚包和字體文件採用 gulp 多是 gulp 的配置比 webpack 更簡潔,不須要引入 url-loadercss-loader 等。個人理解是,webpack 是全套方案,功能齊全可是配置麻煩。 rollup 和 gulp 適用於打包需求比較單一的狀況去使用,更輕便和定製化。

引入 lerna

總體上的一點改動,element-plus 採用了 lerna 進行包管理,lerna 能夠負責 element-plus 版本和組件版本管理,還能夠將每一個組件單獨發佈成 npm 包(不過 element-plus 目前 npm 上只有全量包, 單個組件的皮膚包和多語言文件如今也是放在了一個文件夾下而不是每一個組件當中)。 每一個組件都有這樣一個 package.json 文件

{
  "name": "@element-plus/message",
  "version": "0.0.0",
  "main": "dist/index.js",
  "license": "MIT",
  "peerDependencies": {
    "vue": "^3.0.0"
  },
  "devDependencies": {
    "@vue/test-utils": "^2.0.0-beta.3"
  }
}
複製代碼

而後使用了 workspaces 匹配 packages 目錄,依賴會統一放在根目錄下的 node-modules,而不是每一個組件下都有,這樣相同的依賴能夠複用,目錄結構也更加清晰。

// package.json
  "workspaces": [
    "packages/*"
  ]
複製代碼

element-plus 的 script 中還提供了一個 shell 腳本用於開發新組件的時候生成基礎文件, 使用 npm run gen 能夠在 packages 下生成一個基礎的組件文件夾。

"gen": "bash ./scripts/gc.sh",
複製代碼

最後

element-plus 如今天天的 commit 比較多,有些功能還在不斷的完善中,咱們能夠經過閱讀組件庫的源碼來學習組件設計和 Vue3 的新特性,也能夠給官方提 Pull Request 來參與貢獻。

推薦閱讀

相關文章
相關標籤/搜索