搭建ui组件库

1,731 阅读2分钟

更多文章

前言

最近帮公司搭建ui组件库,主要考虑如下:

  • 沉淀业务组件,避免高质量组件流失
  • 形成文档,减少对组件内部代码关注,通过文档demo以及暴露出的属性方法使用组件
  • 通过npm安装组件,更好的管理项目&组件
  • 提升前端组件封装能力

技术

目前公司绝大多数项目是vue2.0 + element2.x的东西,已经做得非常成熟了,大概率不会升级vue3.0,使用技术&工具如下:

  • vue2.x
  • markdown-it
  • markdown-it-container: 处理markdown块
  • markdown-it-front-matter:处理front matter
  • highlight.js:代码高亮
  • standard-version:版本&日志
  • rimraf:删除文件
  • gulp:打包scss
  • cp-cli:copy文件

目录

关于ui展示平台内容均在src下,组件相关内容均在packages下,将ui文档平台和ui组件分离开来

qjd-ui
├── build
│   ├── md_loader              markdown文件解析
|   |—— lib.config.js          组件打包配置
|   |—— utils.js               工具
├── packages                   基础&业务组件
│   ├── theme-default          组件样式统一编写打包
├── src
│   ├── components             ui展示平台组件
│   │   ├── demo-block.vue     md展示demo模板
│   ├── consts
│   │   ├── slider.js          导航栏路由信息
│   ├── docs                   各个组件文档
│   │   ├── button.md 
│   ├── pages                  ui平台页面
│       ├── test               测试代码
│   ├── router                 路由
│   └── styles                 css
│       ├── md                 markdown样式
│   ├── utils                  ui平台工具
│       ├── getTestRoutes.js   动态匹配测试组件,无需手动配置

markdown相关

文档使用markdown编写,根据路由信息匹配对应.md文件即可,如下:

{
  path: `/main/${e.key}`,
  name: e.key,
  // 日志在根目录
  component: resolve => e.key == 'CHANGELOG' ? require([`../../CHANGELOG.md`], resolve) : require([`@/docs/${e.key}.md`], resolve)
}

markdown-it

我们需要借助markdown-it等工具帮助vue识别.md文件:

// index.js
const markdown = require('markdown-it');
const hljs = require('highlight.js');
const md = markdown({
  html: true,
  typographer: true,
  // 处理代码高亮
  highlight: function (str, lang) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return `<pre class="hljs" v-pre>
          <code>
            ${hljs.highlight(lang, str, true).value}
          </code>
        </pre>`
      } catch (error) {
        console.log('error:' + error)
      }
    }
    return `<pre class="hljs">
      <code>${md.utils.escapeHtml(str)}</code>
    </pre>`
  }
})

const html = md.render(src)

return (
  `<template>\n
      <div class="markdown">
        ${html}
      </div>\n
  </template>\n
  `
)

markdown-it-container

通过markdown-it-container解析demo并通过已写好的插槽(src/components/demo-block)插入

// containerjs
const container = require('markdown-it-container')

module.exports = md => {
  md.use(...createDemo('demo'))
}

function createDemo(kclass) {
  return [container, kclass, {
    validate: params => params.trim().match(/^demo\s*(.*)$/),
    render: function (tokens, idx) {
      const token = tokens[idx]
      const info = token.info.replace('demo', '').trim()
      let desc = info ? `<div class="demo_desc" slot="desc" >${info}</div>` : null
      if (token.nesting === 1) {
        return `<demo-block>
          ${desc}
          <div slot="code">
        `
      }
      return '</div></demo-block>\n'
    }
  }]
}
// 然后再index.js中引入
const markdown = require('markdown-it');
const containers = require('./container');
const md = markdown({...}).use(containers)

之后在.md文件中就可以这样写demo了

<div class="demo_block">
我是代码
</div>

:::demo
```html

<!-- 我是代码 -->
```
:::

markdown-it-front-matter

借助markdown-it-front-matter处理front matter

use(require('markdown-it-front-matter'), function (fm) {
  const data = fm.split('\n')
  data.forEach((item, index) => data[index] = item.split(':'))
  data.map(item => result += `<span>${kinds[item[0]]}: ${item[1]}</span>`)
});

return (
    `<template>\n
        <div class="markdown">
          <div class="demo-info">
            ${result}
          </div>
          ${html}
        </div>\n
    </template>\n
    `
  )

之后在.md文件中就可以这样写front matter

---
author: xxx
create: xxxx-xx-xx
update: xxxx-xx-xx
---

抽离script&style

抽离css和js,一个markdown文件仅允许有一个script和style

// 抽离
const scriptRe = /^<script(?=(\s|>|$))/i;
const styleRe = /^<style(?=(\s|>|$))/i;

md.renderer.rules.html_block = (tokens, idx) => {
    const content = tokens[idx].content
    if (scriptRe.test(content.trim())) {
        scriptContent = content;
        return ''
    } if(styleRe.test(content.trim())) {
        styleContent = content;
        return ''
    } else {
        return content
    }
}
return (
  `<template>\n
      <div class="markdown">
        <div class="demo-info">
          ${result}
        </div>
        ${html}
      </div>\n
  </template>\n
  ${scriptContent}
  ${styleContent}
  `
)

配置loader

最后配置一下loader

{
  test: /\.md$/,
  use: [
    { loader: 'vue-loader' },
    {
      loader: require.resolve('./md_loader')
    }
  ]
}

完成上述配置就可以在.md文件中编写文档了

完整md_loader

// index.js
const markdown = require('markdown-it');
const hljs = require('highlight.js');
const kinds = require('./consts');

const containers = require('./container');
const scriptRe = /^<script(?=(\s|>|$))/i;
const styleRe = /^<style(?=(\s|>|$))/i;

module.exports = function (src) {
  let result = '', scriptContent = '', styleContent = '';

  const md = markdown({
    html: true,
    typographer: true,
    highlight: function (str, lang) {
      if (lang && hljs.getLanguage(lang)) {
        try {
          return `<pre class="hljs" v-pre>
            <code>
              ${hljs.highlight(lang, str, true).value}
            </code>
          </pre>`
        } catch (error) {
          console.log('error:' + error)
        }
      }
      return `<pre class="hljs">
        <code>${md.utils.escapeHtml(str)}</code>
      </pre>`
    }
  })
    .use(containers)
    .use(require('markdown-it-front-matter'), function (fm) {
      const data = fm.split('\n')
      data.forEach(item => {
        item = item.split(':')
        result += `<span>${kinds[item[0]]}: ${item[1] ? item[1] : '--'}</span>`
      })
    });

  md.renderer.rules.html_block = (tokens, idx) => {
    const content = tokens[idx].content
    if (scriptRe.test(content.trim())) {
      scriptContent = content;
      return ''
    } if (styleRe.test(content.trim())) {
      styleContent = content;
      return ''
    } else {
      return content
    }
  }

  const html = md.render(src)

  return (
    `<template>\n
        <div class="markdown">
          <div class="demo-info">
            ${result}
          </div>
          ${html}
        </div>\n
    </template>\n
    ${scriptContent}
    ${styleContent}
    `
  )
}

// container.js
const container = require('markdown-it-container')

module.exports = md => {
  md.use(...createDemo('demo'))
}

function createDemo(kclass) {
  return [container, kclass, {
    validate: params => params.trim().match(/^demo\s*(.*)$/),
    render: function (tokens, idx) {
      const token = tokens[idx]
      const info = token.info.replace('demo', '').trim()
      let desc = info ? `<div class="demo_desc" slot="desc" >${info}</div>` : null
      if (token.nesting === 1) {
        return `<demo-block>
          ${desc}
          <div slot="code">
        `
      }
      return '</div></demo-block>\n'
    }
  }]
}
// consts.js
module.exports = {
  author: '作者',
  create: '创建时间',
  update: '更新时间'
}

ui组件

packages下统一编写组件,theme-default/src下编写样式,通过gulp打包样式

按需加载需要对各个组件单独打包,我们会在打包时会动态匹配packages.vue组件,所以各个组件都必须建立相应的文件并建立index.js组件并暴露出组件,否则会导致按需引入时失败,即使像ButtonGroup这样的组件,虽然实现在button文件夹下也仍需建立button-group文件夹

entry

// 动态匹配各组件
exports.getEntries = () => {
  const componentsContext = requireContext('../packages', true, /\.vue$/, __dirname).keys();
  const defaultCom = { "qjd-ui": "./packages/index.js" }; // 批量打包
  let coms = {}; // 存储各个组件用于独自打包
  componentsContext.forEach(item => {
    const keys = item ? item.split('\\') : [];
    const key = keys[keys.length - 1] ? keys[keys.length - 1].split('.')[0] : '';
    if (key) {
      coms[key] = `./packages/${key}`;
    }
  });
  return { ...defaultCom, ...coms }
}
// webpack中配置entry
{
  entry: utils.getEntries(),
}

output

我们的组件最终会发布到npm,所以跟普通打包不一样的是我们的入口需要配置library

{
  output: {
		path: path.resolve(__dirname, '../lib'),
		publicPath: '/',
		filename: '[name].js', // [入口名称].js
		library: '[name]', // 暴露出的名称
		libraryTarget: 'umd', // 通常选择umd,umd支持各个环境
		umdNamedDefine: true
	}
}

externals

因为是基于element-ui基础组件进行二次封装的组件,我们在打包时对于第三方工具通常不会进行打包,如果在项目中使用,需在项目中自行安装element-ui并引入相应的组件

externals: {
  vue: {
    root: 'Vue',
    commonjs: 'vue',
    commonjs2: 'vue',
    amd: 'vue'
  },
  'element-ui': 'ELEMENT'
}

批量打包

这个是我们批量打包组件是的入口文件

import Button from './button'

const components = {
    Button,
}

const install = function (Vue) {
    if (install.installed) return
    Object.keys(components).forEach(key => {
        Vue.component(components[key].name, components[key])
    })
}

if (typeof window !== 'undefined' && window.Vue) {
    install(window.Vue)
}

const API = {
    install,
    ...components
}

module.exports = API

单独打包

这里是我们单独打包时对应的各个组件入口,为方便按需引入组件,为每个组价单独注入install方法:

// install.js
export default el => el.install = Vue => Vue.component(el.name, el);
// 以button为例
import install from '../utils/install'
install(Button)

完整配置

// lib.config.js
const path = require('path');
const webpack = require('webpack');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const utils = require('./utils')

module.exports = {
	entry: utils.getEntries(),
	output: {
		path: path.resolve(__dirname, '../lib'),
		publicPath: '/',
		filename: '[name].js',
		library: '[name]',
		libraryTarget: 'umd',
		umdNamedDefine: true
	},
	externals: {
		vue: {
			root: 'Vue',
			commonjs: 'vue',
			commonjs2: 'vue',
			amd: 'vue'
		},
		'element-ui': 'ELEMENT'
	},
	resolve: {
		extensions: ['.js', '.vue']
	},
	module: {
		loaders: [{
			test: /\.vue$/,
			loader: 'vue-loader',
			options: {
				loaders: {
					css: 'vue-style-loader!css-loader',
					sass: 'vue-style-loader!css-loader!sass-loader'
				},
				postLoaders: {
					html: 'babel-loader'
				}
			}
		}, {
			test: /\.js$/,
			loader: 'babel-loader',
			exclude: /node_modules/
		}, {
			test: /\.css$/,
			use: [
				'style-loader',
				'css-loader'
			]
		}, {
			test: /\.(gif|jpg|png|woff|svg|eot|ttf)\??.*$/,
			loader: 'url-loader?limit=8192'
		}]
	},
	plugins: [
		new webpack.optimize.ModuleConcatenationPlugin(),
		new webpack.optimize.UglifyJsPlugin({
			uglifyOptions: {
				ie8: false,
				output: {
					comments: false,
					beautify: false,
				},
				mangle: {
					keep_fnames: true
				},
				compress: {
					warnings: false,
					drop_console: true
				}
			}
		}),
		new CopyWebpackPlugin([
			{
				from: `./packages`,
				to: `./packages`,
				ignore: [
						'theme-default/**'
					]
			}
		]),
	]
}
// util.js
exports.getEntries = () => {
  const componentsContext = requireContext('../packages', true, /\.vue$/, __dirname).keys();
  const defaultCom = { "qjdui": "./packages/index.js" }; // 批量打包
  let coms = {}; // 存储各个组件用于独自打包
  componentsContext.forEach(item => {
    const keys = item ? item.split('\\') : [];
    const key = keys[keys.length - 1] ? keys[keys.length - 1].split('.')[0] : '';
    if (key) {
      coms[key] = `./packages/${key}`;
    }
  });
  return { ...defaultCom, ...coms }
}

打包命令

{
  "clean": "rimraf lib",
  "build": "node build/build.js",
  "build:theme": "gulp build --gulpfile packages/theme-default/gulpfile.js && cp-cli packages/theme-default/lib lib/theme-default",
  "build:packages": "webpack --config build/lib.config.js",
  "build:ui": "npm run clean && npm run build:theme && npm run build:packages"
}

ui展示平台打包结果存放于dist文件夹,命令:

npm run build

组件库打包结果存放于lib文件夹,命令:

npm run build:ui

commit规范

好的commit规范可以提升团队开发效率,以Angularcommit规范为模板制定规范,Commitizen可以帮助约束自身提交规范,日志的生成也会依赖commit内容

type类型

type描述
feat新功能
fix修复bug
refactor代码重构
docs文档
test测试代码
pref优化
chore构建过程或辅助工具的变动

模块

每次commit涉及的模块

描述

每次改动的描述

示例

一次完整的commit如下:

git commit -m 'feat(button): 为button添加disabled效果'
git commit -m 'docs(button): 编写button组件文档'

版本&日志

使用standard-version控制版本&生成日志,commit类型为featfix会出现在日志中,分别对应FeaturesBug Fixes模块

  • major:主版本
  • minor:次版本
  • patch:修订版
  • npm run release -- 1.0.0:执行该命令, 自定义版本为 1.0.0
  • npm run release:100:执行该命令, 如果当前版本是 1.0.0 那么版本将被提升至 2.0.0
  • npm run release:010: 执行该命令, 如果当前版本是 1.0.0 那么版本将被提升至 1.1.0
  • npm run release:001: 执行该命令, 如果当前版本是 1.0.0 那么版本将被提升至 1.0.1

执行上述命令时,会有三个动作:生成版本、打tag、生成日志,日志存放于CHANGELOG.md中,具体效果见日志部分

安装

npm install qjd-ui --save

使用

CDN引入

<!-- css -->
<link rel="stylesheet" href="https://unpkg.com/qjd-ui/lib/theme-default/index.css">/
<!-- html -->
<div id="app">
  <xw-button>默认按钮</xw-button>
  <xw-button type="primary">主要按钮</xw-button>
  <xw-button type="success">成功按钮</xw-button>
  <xw-button type="info">信息按钮</xw-button>
  <xw-button type="warning">警告按钮</xw-button>
  <xw-button type="error">危险按钮</xw-button>
</div>
<!-- js -->
<script src="https://unpkg.com/vue/dist/vue.js"></script>
<script src="https://unpkg.com/qjd-ui/lib/qjdui.js"></script>
<script>
  new Vue({
    el: '#app'
  })
</script>

批量引入

import qjdui from 'qjd-ui'

import 'qjd-ui/lib/theme-default/index.css'

Vue.use(qjdui)

按需引入

方式一(需手动引入组件&css)

import Button from 'qjd-ui/lib/button'

import 'qjd-ui/lib/theme-default/button.css'

Vue.component(Button.name, Button)

方式二

// 安装babel-plugin-import
yarn add babel-plugin-import -D
// 配置babel.config.js
{
  "plugins": [
    [
      "import",
      {
        libraryName: 'qjd-ui',
        customStyleName: (name) => {
          return `qjd-ui/lib/theme-default/${name}.css`;
        },
      },
    ]
  ]
}

完成上述配置后引入方式如下:

import { Button, Input } from 'qjd-ui';

Vue.component(Button.name, Button)

Vue.component(Input.name, Input)

或者

Vue.use(Button)

Vue.use(Input)

结语

目前只是完成了初版,参考了emelent-ui结构和css的处理方式