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 来参与贡献。

推荐阅读

相关文章
相关标签/搜索