Skip to content

Latest commit

 

History

History
499 lines (393 loc) · 16.4 KB

2019-06-27__组件库的那些事.md

File metadata and controls

499 lines (393 loc) · 16.4 KB

我认为的best practice

组件目录和文件结构

+ build
|   - webpack.config.js
+ demo
+ lib
|   - button.js
|   - icon.js
+ public
|   + button
|       - button.md
+ src
|   + button
|       - button.vue
|       - index.js
+ test
|   + unit
|       + spec
|            - button.spec.js
组件源码位于src目录下,除了每个组件单独一个文件夹之外,还有一个utils文件夹,用于存放公共工具函数和公共组件等等

开发 ThunderUI 组件库的组件应该遵循以下几个约定:

  • 一个组件应该单独一个文件夹,该文件夹需以组件的名称命名,使用中线连接

  • 一个组件一般由三个文件构成但不限于这三个文件,如下:

文件 必需 作用
index.js 注册组件为 Vue 的一个插件,并导出该组件
xxx.js | xxx.vue 组件结构与逻辑,其中 xxx 为组件的名称
  • 每个组件都必需提供 name 属性,并且 name 属性应该由 Td 前缀加上组件名称组成,如下
// menu.js
export default {
  name: `${NAMESPACE}-button`,
}
  • 组件引用另外一个组件的时候,引入依赖组件时应该使用下面的方式
// good
import Icon from 'src/icon';

// bad
import Icon from '../icon';
  • 因为采用rollup打包,只添加了类似src/icon进行切割,所以引入文件时不要使用后缀,比如不要这样写src/icon/index.js

开发体验

为了兼顾开发者的开发体验,需要在开发环境中给予必要的错误提示,注意添加环境,比如

if (someWarn) {
  process.env.NODE_ENV !== 'production' && warn(something)
  return
}

if (someError) {
  process.env.NODE_ENV !== 'production' && error(something)
  return
}

i18n

TODO

依赖组件的可扩展性

居多组件会依赖于其他组件,这个时候考虑到依赖组件的灵活性,可以使用一个对象来作为组件的props,如果是使用vue模板可以使用v-bind,如果是jsx可以使用{...props}

<template>
  <tooltip v-bind="tooltipProps" />
</template>
<script>

export default {
  props: {
    tooltipProps: Object
  }
}
</script>
export default {
  props: {
    tooltipProps: Object
  },
  render() {
    return <Tooltip {...props: this.tooltipProps} />
  }
}

单元测试

每个组件开发完成后需要对所有功能进行测试,包括props event api等,单元测试存放在test/unit/specs文件夹下,使用组件名.spec.js命名,目前还差大量的组件单元测试

工具函数

如果只是某个组件内使用的工具函数,可以直接存放在当前组件下,引入时不需要被external,如果是公共工具函数,需要存放到src/utils下,引入时也需要注意,必须通过src/utils/xx来引入,这样才能达到external的效果

组件文档

打包相关

传统的vue-cli生成的配置,使用webpack,这个没啥好介绍的

生产环境使用rollup进行构建,主要考虑文件体积,特别是按需加载

打包体积优化

最开始打包是基于webpack的,在按需加载上存在的体积冗余会比较大,如:

  • webpack打包特有的模块加载器函数,这部分其实有些多余,最好去掉
  • 使用babel转码时,babel带来的helper函数全部是内联状态,需要转成importrequire来引入
  • 使用transform-rumtime对一些新特性添加polyfill,也是内联状态,需要转成importrequire来引入
  • vue-loader带来的额外代码,如normalizeComponent、ssr处理、style处理等等,不做处理也是内联
  • transform-vue-jsx带来的额外函数引入,如mergeJSXProps,不做处理也是内联

以上几个问题,如果只是一份代码,那不会有太大问题,但是如果是按需加载,用户一旦引入多个组件,以上的代码就会出现多份,带来严重的影响

寻找解决方案

采用后编译

采用后编译可以解决上面的各种问题,也有组件库是这样做的,比如cube-ui,但是这样有些不方便,因为用户需要设置各种alias,还要保证好各种编译环境,如jsx,使用太不方便,所以暂时不考虑

使用rollup打包,设置external(当然webpack也可以)外联helper函数

使用rollup打包,可以直接解决问题1和问题4,设置external可以解决transform-runtime等带来的helper,这取决于相关插件实现时是不是通过importrequire来添加helper的,如果是直接copy的话,那就还得另找办法。最后决定就这种方案进行尝试

使用rollup对打包进行重构

使用rollup打包可能某些习惯和webpack有些出入,在这里很多事需要引入插件来完成,比如引入node_modules中的模块的话,需要加入rollup-plugin-node-resolve,加载commonjs模块需要引入rollup-plugin-commonjs等等。另外还有些比较麻烦的,比如经常会这样写

import xx from './xx-folder'

然后希望模块打包器可以识别成

import xx from './xx-folder/index.js'

rollup里还是需要用插件来完成这件事,找到的插件都没能满足各种需求,比如还需要对alias也能识别然后加上index.js,最后还是需要自己实现这个插件

基本的rollup配置应该差不多是这样的

{
  output: {
    format: 'es',
    // file: xx,
    // paths: 
  },
  input: 'xxx',
  plugins: [
    vue({
      compileTemplate: true,
      htmlMinifier: {
        customAttrSurround: [[/@/, new RegExp('')], [/:/, new RegExp('')]],
        collapseWhitespace: true,
        removeComments: true
      }
    }),
    babel({
      ...babelrc({
        addModuleOptions: false,
        addExternalHelpersPlugin: false
      }),
      exclude: 'node_modules/**',
      runtimeHelpers: true
    }),
    localResolve({
      components: path.resolve(__dirname, '../src')
    }),
    alias({
      components: path.resolve(__dirname, '../src'),
      resolve: ['.js', '.vue']
    }),
    replace({
      'process.env.NODE_ENV': JSON.stringify('production')
    })
  ],
  // external
}

这里采用的rollup-plugin-vue的版本是v3.0.0,不采用v4,因为打包出来的体积更小,功能完全满足组件库需要。因为会存在各种约定,比如组件肯定是存在render函数(不一定指的就是手写renderjsx,只是不会有在js中使用template这种情况,这样的好处是可以使用runtime-onlyvue),组件肯定不存在style部分等等。

babel的配置上基本不会有改变,只是rollup-plugin-babel加上了runtimeHelpers,用来开启transform-runtme的。可能你会觉得为了更精简体积,应该去掉transform-runtime,这里使用transform-runtime的主要作用是为了接管babel-helpers,因为这个babel-helpers无法被external。另外整个组件库用到的babel-runtime其实也不多,主要是类似Object.assign这样的函数,像这些函数,使用的话还是需要加上transform-runtime的,或者需要自己实现,感觉没什么必要。类似Array.prototype.includes这种无法被transform-runtime处理的还是会避免使用的

localResolve是自己实现的插件,用来添加index.js,并且能支持alias

alias插件用来添加alias,并且需要设置后缀

replace插件用来替换一些环境变量,比如开发环境会有错误提示,生成环境不会有,这里展示的是生产环境的配置。

配置external

所有优化的关键在于external上,除了最基本引入的第三方库,比如vue,需要external外,还有比如Dialog组件内部依赖了Button组件,那是需要把Button组件external

// Dialog 组件
import Button from 'src/Button'

其实就是所有的组件和共用的util函数都需要external,另外,

主要还需要处理的是babel-helperhelper函数,但是这里不能做到,我也没有去了解babel是如何对这块进行处理的,最后还是需要transform-runtime来接管它。

rollupexternal配置是支持函数类型的,大概看tranform-runtime这个插件源码可以找到addImport这些方法,可以知道polyfill是通过import来引入的,可以被external,所以只需要在rollup配置的external添加上类似函数就可以达到我们想要的效果

{
  external (id) {
    // 对babel-runtime进行external
    return /^babel-runtime/.test(id) // 当然别忘了还有很多 比如vue等等,这里就不写了
  }
}

这里就可以解决问题2和问题3

另外问题5,这个是如何来的呢,比如在写jsx时,可能会这样写


在某个组件中依赖了另一个组件,考虑到扩展性,是支持对另一个组件进行`props`设置的,所以经常会这样写,在`template`中的话就类似于`v-bind="tolltipProps"`

这个时候`transform-vue-jsx`插件是会引入一个`helper`函数的,也就是`babel-helper-vue-jsx-merge-props`,大概看看`transform-vue-jsx`源码也可以得知,这个`helper`也是`import`进来的,所以可以把`external`改成

```js
{
  external (id) {
    return /^babel/.test(id)
  }
}

这样就可以做到对所有helper都使用import的形式来引入,而且使用rollup打包后的代码更可读,大概长这样

// Input组件
import _Object$assign from 'babel-runtime/core-js/object/assign';

var NAMESPACE = 'td';

var Input = { render: function render() {
    var _this = this;

    var _vm = this;var _h = _vm.$createElement;var _c = _vm._self._c || _h;return _vm.type === 'text' ? _c('label', { staticClass: "td-input", class: { 'is-warn': _vm.warn, 'is-disabled': _vm.disabled } }, [_c('span', { staticClass: "td-input__label" }, [_vm._v(_vm._s(_vm.label))]), _vm._v(" "), _c('input', _vm._g(_vm._b({ ref: "input", staticClass: "td-input__inner", attrs: { "disabled": _vm.disabled }, domProps: { "value": _vm.value }, on: { "blur": function blur(e) {
          _this.$emit('blur', e);
        }, "focus": function focus(e) {
          _this.$emit('focus', e);
        } } }, 'input', _vm.$attrs, false), _vm.inputListeners))]) : _vm.type === 'textarea' ? _c('label', { staticClass: "td-textarea", class: { 'is-warn': _vm.warn, 'is-disabled': _vm.disabled } }, [_c('span', { staticClass: "td-textarea__label" }, [_vm._v(_vm._s(_vm.label))]), _vm._v(" "), _c('textarea', _vm._g(_vm._b({ ref: "input", staticClass: "td-textarea__inner", attrs: { "disabled": _vm.disabled }, domProps: { "value": _vm.value } }, 'textarea', _vm.$attrs, false), _vm.inputListeners))]) : _vm._e();
  }, staticRenderFns: [],
  name: NAMESPACE + '-input',

  props: {
    type: {
      type: String,
      default: 'text'
    },
    value: [Number, String],
    label: String,
    disabled: {
      type: Boolean,
      default: false
    },
    warn: {
      type: Boolean,
      default: false
    }
  },

  computed: {
    inputListeners: function inputListeners() {
      return _Object$assign({}, this.$listeners, {
        input: this.handleInput
      });
    }
  },

  methods: {
    select: function select() {
      this.$refs.input.select();
    },
    handleInput: function handleInput(e) {
      this.$emit('input', e.target.value);
    }
  }
};

Input.install = function (Vue) {
  Vue.component(Input.name, Input);
};

export default Input;

vue插件把vue组件中的template转成render函数,babel插件做语法转换,因为external的存在,保留了模块关系,整个代码看起来很清晰,很舒服,不像webpack,都会添加一个模块加载函数...

最后贴下完整配置

// rollup.config.js
import path from 'path'
import resolve from 'rollup-plugin-node-resolve'
import babel from 'rollup-plugin-babel'
import babelrc from 'babelrc-rollup'
import vue from 'rollup-plugin-vue'
import alias from 'rollup-plugin-alias'
import localResolve from './local-resolve'
import replace from 'rollup-plugin-replace'
import deepClone from 'lodash.clonedeep'
import { uglify } from 'rollup-plugin-uglify'
import { minify } from 'uglify-es'
import commonjs from 'rollup-plugin-commonjs'

const utils = require('./utils')

// 获取所有的组件
const components = utils.readComponents('src')

// 获取 directives, mixins, utils 目录下的文件列表
const utilList = ['directives', 'mixins', 'utils'].reduce(
  (list, util) => list.concat(utils.readUtils(`src/${util}`, util)),
  []
)

const config = {
  output: {
    format: 'es'
  },
  plugins: [
    vue({
      compileTemplate: true,
      htmlMinifier: {
        customAttrSurround: [[/@/, new RegExp('')], [/:/, new RegExp('')]],
        collapseWhitespace: true,
        removeComments: true
      }
    }),
    babel({
      ...babelrc({
        addModuleOptions: false,
        addExternalHelpersPlugin: false
      }),
      exclude: 'node_modules/**',
      runtimeHelpers: true
    }),
    localResolve({
      src: path.resolve(__dirname, '../src')
    }),
    resolve({
      extensions: ['.js', '.vue']
    }),
    alias({
      src: path.resolve(__dirname, '../src'),
      resolve: ['.js', '.vue']
    }),
    commonjs({
      include: 'node_modules/**'
    })
  ]
}

const configs = []
const output = process.env.TARGET === 'public' ? 'public/lib' : 'lib'

function genConfig() {
  if (process.env.TARGET !== 'public') {
    const externals = {}
    const external = ['vue']

    components.forEach(component => {
      const isDir = component.indexOf('.') === -1
      const key = `src/${component}`

      if (isDir) {
        // 重写引入路径
        externals[key] = `@xunlei/thunder-ui-vue/lib/${component}.js`
        // 所有组件需要external
        external.push(key)
      }
    })

    utilList.forEach(item => {
      const name = item.split('.')[0]
      const key = `src/${name}`

      // 重写引入路径
      externals[key] = `@xunlei/thunder-ui-vue/lib/${item}`
      // 所有util需要external
      external.push(key)
    })

    config.output.paths = externals

    config.external = id => {
      // 将依赖external
      return (
        external.indexOf(id) >= 0 || /^babel/.test(id) || /^\@xunlei/.test(id)
      )
    }

    components.forEach(component => {
      const isDir = component.indexOf('.') === -1
      const name = component.split('.')[0]
      const key = `src/${name}`
      let _config = deepClone(config)

      if (isDir) {
        // 写入入口文件
        _config.input = `src/${component}/index.js`
        // 输出位置
        _config.output.file = `${output}/${component}.js`
        configs.push(_config)
      }
    })

    utilList.forEach(item => {
      const name = item.split('.')[0]
      const key = `src/${name}`

      let _config = deepClone(config)
      // 写入入口文件
      _config.input = `${key}.js`
      // 输出位置
      _config.output.file = `${output}/${name}.js`
      configs.push(_config)
    })

    // 所有组件的引入入口,ES Module 格式
    let esmGlobalConfig = deepClone(config)

    esmGlobalConfig.input = 'src/index.js'
    esmGlobalConfig.output.file = `${output}/index.js`
    configs.push(esmGlobalConfig)
  }

  let globalConfig = deepClone(config)

  // 所有组件的引入入口 打包成一个包含所有组件的js文件
  globalConfig.input = 'src/index.js'
  globalConfig.output.file = `${output}/thunder-ui-vue.js`
  globalConfig.output.format = 'umd'
  globalConfig.output.name = 'ThunderUIVue'
  globalConfig.plugins.push(
    uglify(
      {
        compress: {
          warnings: false
        }
      },
      minify
    )
  )
  globalConfig.plugins.push(
    replace({
      'process.env.NODE_ENV': JSON.stringify('production')
    })
  )
  // 对于全局引入的方式 内部组件不需要external
  delete globalConfig.output.paths
  delete globalConfig.external
  configs.push(globalConfig)
}

genConfig()

export default configs

优化后和优化前的体积对比

下面的截图是生产环境的版本,也就是没有了代码提示,也已经压缩混淆后的代码体积对比 左边是优化前,右边是优化后

optimize.jpg

版本发布

  • node build/release xxVersion 发布代码到xnpm 需要遵守SemVer