《从零开始搭建UI组件库》
# 1. 基本思路
- 规划目录结构
- 文档站点、示例站点搭建
- 组件开发
- 打包构建
- 发布到npm
- 安装与引入
# 2. 目录结构
├── build # 打包构建有关
├── config # 配置文件
├── docs # 文档站点、示例站点
│ └── src
│ ├── desktop # pc 端站点
| ├── mobile # 移动端示例站点
│ └── doc.config.js # 文档导航配置
├── packages # 组件源码
│ ├── button
│ │ ├── demo # UI组件使用示例
│ │ ├── index.js # 组件入口
│ │ ├── package.json # 组件描述文件(未来可能会单独发布到npm)
│ │ ├── readme.md # UI组件使用说明
│ │ └── src # 组件源码
│ └── style # UI组件 公共样式库 如果是独立按需引入组件,该模块也需要单独引入
│ ├── index.js
│ ├── package.json # 样式包描述文件(未来可能会单独发布到npm)
│ └── src
└── src
└── index.js # 组件库入口
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 3. 文档站点
在目录 /docs/src/desktop
下新增以下文件,作为 pc 端显示的站点项目
├── desktop
│ ├── views
│ ├── APP.vue
│ ├── index.html
│ ├── main.js
└── └── router.js
2
3
4
5
6
# 3.1 路由
desktop 的路由有三种,分别是:
- desktop 本身个性化页面/组件
- desktop 内 其他 .md 介绍文件
- 组件内的 readme.md 文件
在 /docs/src/desktop/routers.js
分别注册这三种类型的路由
import docConfig from '../../doc.config';
import importContext from '../utils/importContext';
import addRoute from '../utils/addRoute';
// 读取 组件包中的readme.md文件,挂载编译生成Component
const packagesComponent = importContext(require.context('pkg', true, /readme\.md$/));
// 读取 文档中的 markdown 通用文档目录,挂载编译生成 Component
const markdownComponent = importContext(require.context('./markdown', true, /\.md$/));
// 读取 文档中 views 部分的个性化文档,挂载编译生成Component
const viewsComponent = importContext(require.context('./views', true, /\.vue$/));
const routes = [
{
path: '/cuteui/desktop/',
redirect: () => '/cuteui/desktop/intro'
},
{
path: '*',
redirect: () => '/cuteui/desktop/404'
}
];
// 根据文档配置,注册 route
[...docConfig.nav, ...docConfig.desktop].forEach(({ groups, mobile }) => {
if (groups) {
groups.forEach(group => {
if (group.list) {
group.list.forEach(item => {
registerRoute(mobile, item);
});
} else {
registerRoute(mobile, group);
}
});
}
});
function registerRoute(mobile, { path, meta, component }) {
meta = Object.assign({ mobile }, meta || {});
component = component ||
packagesComponent[`.${path}/readme.md`] ||
markdownComponent[`.${path}.md`] ||
viewsComponent[`.${path}.vue`] ||
'';
component && addRoute(routes, 'desktop', {
path,
component: component.default || component,
meta
});
}
export default {
mode: 'history',
baseUrl: '/cuteui',
routes
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# 3.2 doc.config.js
在 3.1 中的路由示例中可以看到有 /docs/src/doc.config.js
文件,它是为文档添加配置,以提供更为精确的文档导航。
该文件配置直接影响顶部和左侧导航。
export default {
header: {
title: 'CUTE-UI'
},
desktop: [
{
groups: [
{
title: '404',
path: '/404'
}
]
}
],
nav: [
{
title: '开发指南',
groups: [
{
title: '介绍',
path: '/intro',
},
]
},
{
title: '组件',
mobile: true,
groups: [
{
title: '基础组件',
list: [
{
title: 'Button',
path: '/button'
}
]
},
]
}
]
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# 4. 示例站点
在目录 /docs/sr/mobile
下新增以下文件,作为移动端组件使用的示例站点
├── desktop
│ ├── views
│ ├── APP.vue
│ ├── index.html
│ ├── main.js
└── └── router.js
2
3
4
5
6
# 4.1 路由
mobile 的路由有两种,分别是:
- mobile 本身的个性化页面/组件
- 组件内的 demo 示例
在 /docs/src/desktop/routers.js
分别注册这两种类型的路由
import docConfig from '../../doc.config';
import importContext from '../utils/importContext';
import addRoute from '../utils/addRoute';
import List from './views/list';
const routes = [
{
path: `/cuteui/mobile/list`,
component: List,
name: 'list'
}
];
// 读取 组件包中的demo/index.vue文件,挂载编译生成Component
const demoComponentList = importContext(require.context('pkg', true, /demo\/index\.vue$/));
// 根据 /docs/src/doc.config.js 配置的导航生成路由
docConfig.nav.forEach(({ groups, mobile }) => {
if (groups) {
groups.forEach(group => {
if (group.list) {
group.list.forEach(item => {
registerRoute(mobile, item);
});
} else {
registerRoute(mobile, group);
}
});
}
});
function registerRoute(mobile, { path, title, meta }) {
meta = Object.assign({ mobile, title }, meta || {});
const component = demoComponentList[`.${path}/demo/index.vue`] || '';
if (component && meta.mobile) {
addRoute(routes, 'mobile', {
path,
component: component.default,
meta
});
}
}
export default {
mode: 'history',
baseUrl: '/cuteui',
routes
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# 5. 挂载示例站点
示例站点的展示是使用 iframe
实现的。
/docs/src/desktop/components/Preview.vue
<template>
<div
v-if="showIframe"
class="docs-preview"
>
<iframe
class="docs-preview__iframe"
:src="demoLink"
frameborder="0"
/>
</div>
</template>
<script>
export default {
name: 'docs-preview',
data() {
return {
demoLink: '/cuteui/mobile/list'
};
},
computed: {
showIframe() {
const meta = this.$route.meta || {};
return !!meta.mobile;
}
}
};
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 6. 文档站点和示例站点路由动态匹配
期望效果:
- 点击左侧导航时,右侧的示例站点能跳转到对应的 demo 页
- 点击右侧组件 demo 时,左侧文档能定位到对应的 readme 说明
实现方案:
- 当点击跳转时,两边都进行检测,然后将 desktop/mobile 替换为 mobile/desktop,再跳转到对应的路由页
注意:
- 组件readme 在 desktop 中注册的路径要和 mobile 中组件 demo 路径一致
# 6.1 postRouter
postRouter 文件中提供了 syncPath
方法,用于同步路由
/docs/src/utils/postRouter
function iframeReady(iframe, callback) {
const doc = iframe.contentDocument || iframe.contentWindow.document;
const interval = () => {
if (iframe.contentWindow && iframe.contentWindow.changePath) {
callback();
} else {
setTimeout(() => {
interval();
}, 50);
}
};
if (doc.readyState === 'complete') {
interval();
} else {
iframe.onload = interval;
}
}
export default function (router) {
// 路由同步
window.syncPath = function () {
const isInIframe = window !== window.top;
const currentDir = router.history.current.path;
if (!isInIframe) {
const iframe = document.querySelector('iframe');
if (iframe) {
iframeReady(iframe, () => {
const path = currentDir.replace('desktop', 'mobile');
iframe.contentWindow.changePath(path);
});
}
} else {
const path = currentDir.replace('mobile', 'desktop');
const isList = /list/.test(path);
!isList && window.top.changePath(path);
}
};
window.changePath = function (path = '') {
if (router.currentRoute.path !== path) {
router.replace(path);
}
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# 6.2 使用 postRouter
在 /docs/src/desktop/main.js
和 /docs/src/mobile/main.js
下分别引入使用
import postRouter from '../utils/postRouter';
const router = new VueRouter(routerConfig);
postRouter(router);
router.afterEach(() => {
Vue.nextTick(() => window.syncPath()); // 同步路由
});
2
3
4
5
6
7
8
# 7. 组件开发
所有的组件源码都是放在 packages
文件夹下。组件下文件目录结构如下:
├── button
│ ├── demo # UI组件使用示例
│ ├── index.js # 组件入口
│ ├── package.json # 组件描述文件(未来可能会单独发布到npm)
│ ├── readme.md # UI组件使用说明
└── └── src # 组件源码
2
3
4
5
6
组件的开发跟普通组件没有区别,主要是文件的命名要遵循规范,避免生成路由和示例站点时出现错误
# 7.1 button/index.js
每个组件下的 index.js
是单个组件的入口文件,便于引入单个组件使用
import Button from './src';
Button.install = function (Vue) {
// 避免重复
if (Button.install.installed) {
return;
}
Vue.component(Button.name, Button);
Button.install.installed = true;
};
export default Button;
2
3
4
5
6
7
8
9
10
11
# 7.2 src/index.js
/packages/src/index.js
文件是作为整个组件库的入口文件,在这个文件中会安装所有的组件,便于打包后全局引入
import 'pkg/style/index';
import Button from 'pkg/button/index';
const components = [
Button
];
const install = function (Vue, option) {
if (install.installed) return;
// 注册全局组件
components.forEach(component => Vue.use(component));
install.installed = true;
};
// auto install 重要
if (typeof window !== 'undefined' && window.Vue) {
install(window.Vue);
}
export default {
install,
Button
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 8. 打包构建
- 组件库构建打包 —— webpack.lib.conf.js
- 单组件构建打包 —— webpack.package.config.js
- 文档构建打包 —— webpack.docs.config.js
# 8.1 组件库构建打包
webpack.lib.conf.js
// ...
const webpackConfig = webpackMerge(webpackBaseConfig, {
mode: 'production',
entry: util.resolve('src/index.js'), // 入口文件
output: {
path: util.resolve('lib'),
publicPath: '/lib/',
library: 'CUTEUI', // 库名,引入时需对应
libraryTarget: 'umd', // 打包模式
libraryExport: 'default',
filename: 'index.js',
umdNamedDefine: true,
globalObject: 'this',
pathinfo: false
},
// ...
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 8.2 单组件构建打包
webpack.package.config.js
// ...
const webpackConfig = webpackMerge(webpackBaseConfig, {
mode: 'production',
output: {
path: util.resolve('lib'),
publicPath: '/lib/',
filename: '[name]/index.js',
library: ['CUTEUI', '[name]'], // 库名,引入时需对应
libraryTarget: 'umd', // 打包模式
libraryExport: 'default',
umdNamedDefine: true,
globalObject: 'this',
pathinfo: false
},
// ...
});
// 每个组件都是有一个 entry
let components = glob.sync('packages/*');
let entry = {};
components.forEach(component => {
let entryName = component.substr(component.lastIndexOf('/'));
entry[entryName] = util.resolve(component + '/index.js');
});
webpackConfig.entry = entry;
module.exports = webpackConfig
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 8.3 文档构建打包
webpack.docs.config.js
// ...
module.exports = function () {
const webpackConfig = webpackMerge(webpackBaseConfig, {
mode: 'production',
entry: {
'desktop': util.resolve('docs/src/desktop/main.js'),
'mobile': util.resolve('docs/src/mobile/main.js')
},
output: {
path: util.resolve('docs/dist'),
publicPath: '/cuteui/',
filename: '[name]/[name].[chunkhash].js'
},
devtool: '',
resolveLoader: {
modules: [
util.resolve('/build/rules/'),
'node_modules'
]
},
module: {
rules: [ // md 文件的转换
{
test: /\.md$/,
use: [
'vue-loader',
'vue-markdown-loader'
]
}
]
},
plugins: [
new webpack.HashedModuleIdsPlugin(),
new MiniCssExtractPlugin({
filename: '[name]/[name].[contenthash].css',
allChunks: true
}),
new HTMLWebpackPlugin({
template: 'docs/src/desktop/index.html',
filename: 'desktop/index.html',
chunks: ['runtime', 'vendor', 'common', 'desktop'],
inject: true,
icon: '/public/logo_icon.png',
minify: {
removeComments: true,
collapseWhitespace: true,
removeAttributeQuotes: true
},
}),
new HTMLWebpackPlugin({
template: 'docs/src/mobile/index.html',
filename: 'mobile/index.html',
chunks: ['runtime', 'vendor', 'common', 'mobile'],
inject: true,
icon: '/public/logo_icon.png',
minify: {
removeComments: true,
collapseWhitespace: true,
removeAttributeQuotes: true
},
}),
new CopyWebpackPlugin([
{ from: util.resolve('docs/public'), to: util.resolve('docs/dist/public') }
]),
new FriendlyErrorPlugin()
]
// ...
});
return webpackConfig;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# 8.4 markdown-it
在 8.3 的配置文件中,可以看到多加了一个 .md
文件的 rules。 这个 rules 使用到了 vue-markdown-loader
。vue-markdown-loader
是使用 markdown-it
封装的一个小插件。
# 8.5 umd 模式构建
在前面的构建文件中,使用了 output.library
和 output.libraryTarget
属性。在一般的项目中使用 webpack 不需要关注这两个属性,但是如果是开发类库,那么这两个属性就是必须了解的。
具体使用和介绍请看:webpack 官网介绍 - expose-the-library
在本次的打包构建中,选中的是 umd
模式,这种模式支持CommonJS
、AMD
、CMD
方式引入使用。
const webpackConfig = webpackMerge(webpackBaseConfig, {
mode: 'production',
output: {
// ...
library: ['CUTEUI', '[name]'], // 库名,引入时需对应
libraryTarget: 'umd', // 打包模式
},
externals: { // externals 会从输出的 bundle 中排除 vue
vue: {
root: 'Vue',
commonjs: 'vue',
commonjs2: 'vue',
amd: 'vue'
}
},
// ...
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 9. 发布
发布到 npm
npm login
npm publish
2
发布到 cnpm 的命令也是一样的,只需切换 npm 源之后,再执行以上命令。
如果想了解私有 cnpm 如何搭建,可以看《cnpm搭建》
# 10. 安装
npm install -S @cute/cute-ui
# 10.1 全局引入
import Vue from 'vue';
import CUTEUI from '@cute/cute-ui';
Vue.use(CUTEUI);
2
3
4
需要值得注意的是,CUTEUI
的样式文件是需要单独引入的。
import '@cute/cute-ui/lib/style.css';
# 10.2按需加载
实现按需加载,需求借助 babel-plugin-component
, 只引入需要的组件,以达到减小项目体积的目的。
首先,安装babel-plugin-component
;
npm install -D babel-plugin-component
然后,对 .babelrc
或babel.config.js
文件 进行修改, 添加插件:
{
"plugins": [
[
"component",
{
"libraryName": "@cute/cute-ui",
"style": true
}
]
]
}
2
3
4
5
6
7
8
9
10
11
接下来,如果你只希望引入部分组件,比如Button和 Dialog, 在你的入口js文件写入以下内容:
需要注意的是,单独的组件时不会引入基础样式的, 基础样式需要单独引入 Style 模块。
import Vue from 'vue';
import "@cute/cute-ui/lib/style.css";
import {
Button,
Dialog
} from '@cute/cute-ui';
Vue.use(Button);
Vue.use(Dialog);
2
3
4
5
6
7
8
9
10
也可在vue文件引入部分组件(建议优先在入口文件引入)
import "@cute/cute-ui/lib/style/style.css";
import { Button } from '@cute/cute-ui';
export default {
components: {
'cu-button': Button
}
}
2
3
4
5
6
7