基于vue的UI组件库开发(工程

1,077 阅读6分钟

一个UI组件库,应该包含下载安装、ui组件、文档、测试(如果想面向更多的人)等等。

开发一个组件库,前期需要理清一些工程化方面的问题。其中有一个我觉得非常重要的问题,就是组件的按需引入和完整引入。

  • 按需引入,就是用到了哪个组件就引入哪个组件,项目里没有用到的组件,则不打包到项目文件中。
// 如:element-ui
import Button from 'dd-ui-vue/lib/button'
// 也可以
import { Button } from 'dd-ui-vue';

Vue.use(Button) // 或在局部注册
  • 完整引入,就是在打包的项目文件中,包含所有的组件。
import ddUi from 'dd-ui-vue';
Vue.use(ddUi)

我们参考一下element的按需引入是怎么做的,它是将每个组件都单独打包了,借助 babel-plugin-component 实现的,将 .babelrc 修改为:

// .babelrc
{
  "presets": ["@vue/cli-plugin-babel/preset"], // 这个是vue/cli创建项目时创建的
  "plugins": [
    [
      "component",
      {
        "libraryName": "dd-ui-vue",
        "style": false // 如果单个组件文件夹中没有style.css文件,则填false。由于我在按需引入的时候也是把样式文件都全部引入的,所以打包的文件里只有一个全部引入的样式文件。如果你需要样式也按需引入,则需要把每个组件的样式单独打包。
      }
    ]
  ]
}

babel-plugin-component规定了组件目录结构和主题库目录结构,我这里不需要组题库,只提供修改主题变量,这部分会在主题系统的地方说明。

// babel-plugin-component插件规定的组件目录结构
- lib // 'libDir'
  - index.js // 全部加载的入口js
  - style.css //  全部加载的入口css
  - componentA // 组件
    - index.js
    - style.css // 我建议不要按需加载单个组件样式,后面在主题系统的地方会说明原因
  - componentB
    - index.js
    - style.css

也就是我只要最终的打包文件按这个目录结构,使用babel-plugin-component就可以实现组件按需引入。

文件目录

跟element-ui差不多。

这里是修改@vue/cli生成的文件夹

|____lib                      // 打包后的库文件
|____build                    // 打包命令执行的文件
|____dist                     // 打包示例文件
|____examples                 // 组件示例。生成使用文档。
|  |____apiDoc                //组件属性,方法说明
|  |____App.vue               
|  |____assets                //示例静态资源
|  |____changeTheme           //切换主题配置
|  |____components            //组件使用示例
|  |____favicon.ico 
|  |____index.html
|  |____main.js
|  |____router                //示例网站路由
|  |____store                 //vuex       
|  |____utils                 //示例工具包
├── packages                  // 组件文件夹
│   |____button               // 按钮
│   |    |____src             // 组件
│   |    |    |____button.vue // 组件
│   |    |____ index.js       // 组件入口文件
│   |____form                 // 表单
│   |____icon                 // 图标
│   |____input                // 输入框
│   |____toast                // 提示
│   |____result               // 结果反馈
│   |____theme                // 样式文件夹
│     |____common             // 公用变量文件夹
│     |  |____colors.scss     // 颜色变量
│     |  |____minxin.scss     // 公用样式块
│     |  |____var.scss        // 全局变量
│     |____index.scss         // 样式主文件
│     |____button.scss        // 按钮样式文件
|____src                     
│     |____ local            
│     |     |____lang         // 国际化
│     |____ index.js          // 入口文件
|____tests                    // 单元测试
|____.eslintrc
|____.gitignore               // git忽略文件
|____.npmignore               // npm忽略文件。库文件上传到npm的时候用到
|____babel.config.js
|____vue.config.js            //vue-cli3 webpack配置文件
|____package-lock.json
|____package.json

制定规范

一个组件库不可能由一个人来开发,规范必不可少。[详见,待发布]

主题系统

项目使用sass写样式。

这里不准备搞太复杂的主题系统,只提供修改变量。

在组件库定义变量的时候,使用的是一个默认值,如果在默认值之前有值,这默认值无效,使用定制的值(sass)。

// var.scss
$--color-primary: #409EFF !default; // sass的默认值

使用

// style.scss 类似element-variables.scss或别的什么名字
// 参考element-ui
/* 改变主题色变量 */
$--color-primary: teal;

/* 改变 icon 字体路径变量,必需 */ // 我还没提供字体,暂时用不上
// $--font-path: '~dd-ui-vue/lib/theme-chalk/fonts';

@import "~dd-ui-vue/packages/theme-chalk/index";

组件编写

组件都在packages目录下。组件需要支持按需引入,设计的是一个组件一个文件夹,并且都提供一个install方法。这里先简单实现一下。

├── packages                  // 组件文件夹
    |____button               // 按钮
         |____src             // 组件
         |    |____button.vue // 组件
         |____ index.js       // 组件入口文件

button按钮组件

// button.vue
<template>
  <button :class="classSet" @click="handleClick">
    <slot></slot>  
  </button>  
</template>

<script>
export default {
  name: 'i-button',
  props: {
    type: {
      type: String,
      default: 'primary'
    }
  },
  computed: {
    classSet() {
      return `d-button d-button--${this.type}`
    }
  },
  methods: {
    handleClick() {
      this.$emit('click');
    }
  }
}
</script>

// button/index.js
// 配置对外引用
import Button from './src/button.vue';

// 提供install方法
// 这里提供一次install是为了便于单独引入buttton组件时进行注册
Button.install = function(Vue) {
  Vue.component(Button.name, Button);
};

// 默认导出方式导出
export default Button;

// button.scss
.d-button {
  padding: 0 6px;
  border-radius: 4px;
}

toast提示组件

// toast.vue
<template>
  <div v-if="show" class="i-toast--mask">
    <div class="i-toast--dialog">
      <p class="i-toast--content">{{ message }}</p>
    </div>
  </div>
</template>

<script>
export default {
  name: 'i-toast',
  data() {
    return {
      show: false,
      message: ''
    }
  },
}
</script>
// toast/index.js
import Vue from 'vue';
import Toast from './src/toast'


const toastConstructor =  Vue.extend(Toast);
let instance;

let toast = function(options = {}) {
  if(!instance) {
    instance = new toastConstructor({
      el: document.createElement('div')
    });
  }

  if(instance.show === true) return;

  instance.message = options.message;
  instance.show = true;
  document.body.appendChild(instance.$el)

  let timer = setTimeout(() => {
    instance.show = false;
    clearTimeout(timer);
  }, options.duration || 2000);
}

export default toast

// toast.scss
.i-toast--mask {
  position: fixed;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  z-index: 100;
  background-color: rgba(0, 0, 0, 0.15);
  display: flex;
  justify-content: center;
  align-items: center;
}
.i-toast--dialog {
  color: $--color-primary;
  max-width: 80vw;
  // background-color: #333;
  background-color: $--color-black;
  padding: 16px;
  box-sizing: border-box;
  border-radius: 4px;
  /* animation: zoomIn .3s ease-out 0s forwards; */
}
.i-toast--content {
  font-size: 16px;
  color: #fff;
}

组件总入口

// src/index.js

// 导入按钮组件
import Button from '../packages/button'

import Toast from '../packages/toast'

import '../packages/theme-chalk/index.scss'

import { version } from '../package.json'

// 存储组件列表
const components = [
  Button
]

// 定义 install 方法,接收 Vue 作为参数。如果使用 use 注册插件,则所有的组件都将被注册
const install = function (Vue) {
  // 遍历注册全局组件
  components.forEach(component => {
    Vue.component(component.name, component)
  })

  Vue.prototype.$toast = Toast
  // Vue.prototype.$message = Message
  // Vue.prototype.$confirm = Modal.confirm
  // Vue.prototype.$info = Modal.info
  // Vue.prototype.$success = Modal.success
  // Vue.prototype.$error = Modal.error
  // Vue.prototype.$warning = Modal.warning
  // Vue.prototype.$destroyAll = Modal.destroyAll
  // Vue.prototype.$notification = Notification
}
// 判断是否是直接引入文件
if (typeof window !== 'undefined' && window.Vue) {
  install(window.Vue)
}

export {
  Button
}

export default {
  version: version,
  // 导出的对象必须具有 install,才能被 Vue.use() 方法安装
  install
}

组件示例

创建examples文件夹用于本地组件示例和文档输出。

需要处理一下vue.config.js,设置示例入口文件为examples/main.js,现在可以使用npm run serve启动示例了。使用npm run build打包完,就可以作为官网文档使用了。

// vue.config.js
var path = require('path')

module.exports = {
  configureWebpack: config => {
    config.entry.app = ['./examples/main.js']

    // config.module.rules.push(
    //   {
    //     // 处理markdown文件
    //     test: /\.md$/,
    //     use: [
    //       {
    //         loader: 'vue-loader'
    //       },
    //       {
    //         loader: require.resolve('./markdownLoader')
    //       }
    //     ]
    //   }
    // )
    // config.plugins.push(createThemeColorReplacerPlugin())
  },
  // 扩展 webpack 配置,使 packages 加入编译
  chainWebpack: config => {
    // @ 默认指向 src 目录,这里要改成 examples
    // 另外也可以新增一个 ~ 指向 packages
    config.resolve.alias
      .set('@', path.resolve('examples'))
      .set('~', path.resolve('src'))
    
   config
      .plugin('webpack-bundle-analyzer')
      .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin)
  },
  // 以下是pwa配置
  pwa: {
    iconPaths: {
      favicon32: './examples/favicon.ico',
      favicon16: './examples/favicon.ico',
      appleTouchIcon: './examples/favicon.ico',
      maskIcon: './examples/favicon.ico',
      msTileImage: './examples/favicon.ico'
    }
  }
}

在views文件夹下创建button.vuetoast

├──views                      // 视图
    |____button               // 按钮页面
    |____toast                // 提示页面

可以根据views文件夹自动生成路由。

// router/index.js
import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

let routes = [
  {
    path: '/',
    name: 'index',
    component: () => import('@/App.vue')
  }
]

const pages = require.context('../views/', false, /\.vue$/)

pages.keys().forEach(item => {
  /\/(\w+)\.\w+$/.test(item)
  let name = RegExp.$1.trim()
  routes.push({
    path: `/${name}`,
    name: name,
    component: () => import(`@/views/${name}.vue`)
  })
})

const router = new Router({
  mode: 'hash',
  routes: routes
})

export default router

文档(使用文档)

运行npm run build可以打包示例作为使用文档,还有一些接口说明文档书写起来比较费劲,我们需要.vue文件里能使用.md文件。在plugins文件夹下创建markdownLoader.js

├──plugins                      // 插件
    |____markdownLoader.js      // 用于加载.md文件
// vue.config.js  添加如下代码
config.module.rules.push(
  {
    // 处理markdown文件
    test: /\.md$/,
    use: [
      {
        loader: 'vue-loader'
      },
      {
        loader: require.resolve('./plugins/markdownLoader')
      }
    ]
  }
)
// markdownLoader.js
const markdown = require('markdown-it')

function markLoader (src) {
  this.cacheable(false)
  const md = markdown({
    html: true,
    typographer: true
  })
  const html = encodeURIComponent(md.render(src))
  // const html = md.render(src.replace(/</g, '&lt;').replace(/`.*?&lt;.*?`/g, $0 => $0.replace(/&lt;/g, '<')))
  return `
<template>
  <div class="markdown api-container" id="API" data-html="${html}"></div>
</template>
<script>
  export default {
    mounted () {
      this.$el.innerHTML = decodeURIComponent(this.$el.getAttribute('data-html'))
      this.$el.setAttribute('data-html', '')
    }
  }
</script>
`
}

module.exports = markLoader

然后我们再创建一个展示.md文件的vue组件

// components/common/MarkdownRender.vue
<template>
  <component :is="dynamicComponent"></component>
</template>
<script>
let dynamicComponent
export default {
  name: 'markdownRender',
  props: ['mdFileName'],
  data () {
    return {
      dynamicComponent: 'button'
    }
  },
  created () {
    let mdFileName = this.mdFileName
    dynamicComponent = this.dynamicComponent = () => import(`@/apiDoc/${mdFileName}.md`)
  },
  components: {
    dynamicComponent
  },
  beforeDestroy () {
    dynamicComponent = null
  }
}
</script>

<style lang="scss">
// css文件到项目里查看
  @import './markdown'
</style>

全局注册MarkdownRender组件 在apiDoc文件下创建button.md文件

// 使用
<MarkdownRender mdFileName="button" />

还有为方便在示例文档里拷贝示例代码,也做了相应处理,详见github。

运行npm run build打包输出dist,一个简单的文档就可以用了。(也可以用vuepress生成文档)。

如果有md文件的eslint报错,在根目录创建.eslintignore文件

// .eslintignore
**/*.md

库的打包输出

我们需要打包出一个包含所有组件的入口文件,还需要把每个组件单独打包。所有的都输出到lib文件夹里就可以了,这在文件目录的时候就定好的。

新建webpack.lib.base.js,需要把vue排除。

// webpack.lib.base.js
// 引入vue-loader插件
const VueLoaderPlugin = require('vue-loader/lib/plugin');
// 引入清除打包后文件的插件(最新版的需要解构,不然会报不是构造函数的错,而且名字必须写CleanWebpackPlugin)
// const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
  // 我们打包组件库时不需要把Vue打包进去
  externals: {
    'vue': {
      root: 'Vue',
      commonjs: 'vue',
      commonjs2: 'vue',
      amd: 'vue',
    }
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: 'babel-loader'
          }	
        ]
      },
      {
        test: /\.vue$/,
        use: [
          {
            loader: 'vue-loader',
            options: {
              compilerOptions: {
                preserveWhitespace: false
              }
            }
          }
        ]
      },
      {
        test: /\.(jpe?g|png|gif)$/i,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 5120,
              esModule: false,
              fallback: 'file-loader',
              name: 'images/[name].[ext]'
            }
          }
        ]
      }
    ]
  },
  plugins: [
    // new CleanWebpackPlugin(),
    new VueLoaderPlugin()
  ],
  resolve: {
		alias: {
      'vue$': 'vue/dist/vue.runtime.esm.js',
    },
    extensions: ['*', '.js', '.vue']
	}
};

打包所有组件到一个

// webpack.lib.prod.js
// 打包所有
// node.js里面自带的操作路径的模块
const path = require("path");
const merge = require('webpack-merge');
const webpackLibBaseConfig = require('./webpack.lib.base.js');
// 用于提取css到文件中
const miniCssExtractPlugin = require('mini-css-extract-plugin');
// 用于压缩css代码
const optimizeCssnanoPlugin = require('@intervolga/optimize-cssnano-plugin');

module.exports = merge(webpackLibBaseConfig, {
  mode: 'production',
  // devtool: 'source-map',
  devtool: 'none',
  entry: {
    index: path.resolve(__dirname, "../src/index.js")
  },
  output: {
    // 打包过后的文件的输出的路径
    path: path.resolve(__dirname, "../lib"),
    // 打包后生成的js文件
    filename: "[name].js",
    publicPath: "/",
    library: 'dd-ui',
    libraryTarget: 'umd',
    libraryExport: 'default',
    umdNamedDefine: true
  },
  module: {
    rules: [
      {
        test: /\.(scss|sass)$/,
        use: [
          {
            loader: miniCssExtractPlugin.loader, // 使用miniCssExtractPlugin.loader代替style-loader
          },
          {
            loader: 'css-loader',
          },
          {
            loader: 'sass-loader',
            // options: {
            //   implementation: require('dart-sass')
            // }
          }
          // {
          //   loader: 'postcss-loader'
          // }
        ]
      },
      {
        test: /\.(woff|woff2|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/,
        loader: 'file-loader',
        options: {
          name: "./fonts/[name].[ext]",
          publicPath: './'
        }
      }
    ]
  },
  plugins: [
    // 新建miniCssExtractPlugin实例并配置
    new miniCssExtractPlugin({
      filename: 'style.css'
    }),
    // 压缩css
    new optimizeCssnanoPlugin({
      sourceMap: true,
      cssnanoOptions: {
        preset: ['default', {
          discardComments: {
            removeAll: true,
          },
        }],
      },
    }),
  ]
})

单组件打包

// webpack.lib.prod.disperse.js
// 用于对组件单独打包,便于按需加载
// 用于拷贝的插件
const path = require('path');
const miniCssExtractPlugin = require('mini-css-extract-plugin');
const optimizeCssnanoPlugin = require('@intervolga/optimize-cssnano-plugin');
const merge = require('webpack-merge');
const webpackLibBaseConfig = require('./webpack.lib.base.js');
// 引入入口配置文件
const entryConfig = require('../componentList.js');

//定义入口
let entry =  {};
entryConfig.list.map((item) => {
  let componentName = item.name.toLowerCase();
  entry[componentName] = path.resolve(__dirname, '../packages/' + componentName + '/index.js');
});

module.exports = merge(webpackLibBaseConfig, {
  mode: 'production',
  // devtool: '#source-map',
  devtool: 'none',
  entry,
  output: {
    // 打包过后的文件的输出的路径
    path: path.resolve(__dirname, "../lib"),
    // 打包后生成的js文件
    // 解释下这个[name]是怎么来的,它是根据你的entry命名来的,入口叫啥,出口的[name]就叫啥
    filename: "[name]/index.js",
    // 我这儿目前还没有资源引用
    publicPath: "/",
    library: '[name]',
    libraryTarget: 'umd',
    libraryExport: 'default',
    umdNamedDefine: true
  },
  module: {
    rules: [
      {
        test: /\.(scss|sass)$/,
        use: [
          {
            loader: miniCssExtractPlugin.loader, // 使用miniCssExtractPlugin.loader代替style-loader
          },
          {
            loader: 'css-loader',
          },
          {
            loader: 'sass-loader',
            // options: {
            //   implementation: require('dart-sass')
            // }
          },
          // {
          //   loader: 'postcss-loader'
          // }
        ]
      },
    ]
  },
  plugins: [
    // 新建miniCssExtractPlugin实例并配置
    new miniCssExtractPlugin({
      filename: '[name]/style.css'
    }),
    // 压缩css
    new optimizeCssnanoPlugin({
      sourceMap: true,
      cssnanoOptions: {
        preset: ['default', {
          discardComments: {
            removeAll: true,
          },
        }],
      },
    }),
  ]
})

在根目录创建componentList.js,用于单组件打包时遍历。

// componentList.js
module.exports = {
  list: [
    {
      name: 'button',
      author: 'z'
    },
    {
      name: 'toast',
      author: 'z'
    },
    {
      name: 'icon',
      author: 'z'
    },
    {
      name: 'result',
      author: 'z'
    },
    {
      name: 'form',
      author: 'z'
    },
    {
      name: 'input',
      author: 'z'
    }
  ]
}

最后在package.json中加入命令

"lib:all": "webpack --config ./build/webpack.lib.prod.js",
"lib:disp": "webpack --config ./build/webpack.lib.prod.disperse.js",
"clean": "rimraf lib",
"dist": "npm run clean && npm run lib:all && npm run lib:disp"

执行npm run dist就可以打包了。

安装和使用

// 安装
npm i -S dd-ui-vue

// 全量引入
import ddUi from 'dd-ui-vue'
import 'dd-ui-vue/lib/style.css'

Vue.use(ddUi)

// 按需引入
import { Button } from 'dd-ui-vue'
import 'dd-ui-vue/lib/style.css'

Vue.use(Button)

单元测试

运行vue ui,在插件中添加插件@vue/cli-plugin-unit-jest,项目里会生成tests文件夹,这里就是放单元测试的地方。

详细内容参考 [vue单元测试,待发布]

国际化

详细内容参考 [国际化,待发布]

发布

npm login // 登录npm
npm publish // 发布 包名重复的话发布会失败

在发布前设置.npmignore文件,必须发布的文件包含libpackagespackage.jsonREADME.md

packages里用到的是theme-chalk文件夹,用来定义主题变量的,也可以把theme-chalk复制到lib文件夹下,这样packages文件夹就不用发布到npm了。

package.json里也有一些必设的值,如:nameversionmain

.DS_Store
node_modules
dist
build
examples
public
src

# local env files
.env.local
.env.*.local

# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*

babel.config.js
vue.config.js
componentList.js
postcss.config.js
教程.MD
.gitignore
*.map
*.html

总结

组件库是一个持续的开发过程,需要在过程中慢慢完善。这里写下的是当下的开发过程,希望没有误人。如有任何建议,请发邮件给我 [ rd_1985@163.com ]。很多地方都是参考 element-ui,依葫芦画瓢。

github地址: dd-ui-vue