《从零开始搭建UI组件库》


2020-04-23 上次更新时间:8/17/2020, 5:24:27 PM 0 工具类

# 1. 基本思路

  1. 规划目录结构
  2. 文档站点、示例站点搭建
  3. 组件开发
  4. 打包构建
  5. 发布到npm
  6. 安装与引入

# 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 # 组件库入口
1
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
1
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
};

1
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'
                        }
                    ]
                },
            ]
        }
    ]
};

1
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
1
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
};

1
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>
1
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);
        }
    };
}
1
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()); // 同步路由
});
1
2
3
4
5
6
7
8

# 7. 组件开发

所有的组件源码都是放在 packages 文件夹下。组件下文件目录结构如下:

├── button
│   ├── demo # UI组件使用示例
│   ├── index.js # 组件入口
│   ├── package.json # 组件描述文件(未来可能会单独发布到npm)
│   ├── readme.md    # UI组件使用说明
└── └── src # 组件源码
1
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;

1
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
};
1
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
    },
    // ...
});

1
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
1
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;
};
1
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-loadervue-markdown-loader 是使用 markdown-it 封装的一个小插件。

markdown-it介绍

# 8.5 umd 模式构建

在前面的构建文件中,使用了 output.libraryoutput.libraryTarget 属性。在一般的项目中使用 webpack 不需要关注这两个属性,但是如果是开发类库,那么这两个属性就是必须了解的。

具体使用和介绍请看:webpack 官网介绍 - expose-the-library

在本次的打包构建中,选中的是 umd 模式,这种模式支持CommonJSAMDCMD 方式引入使用。

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'
        }
    },
    // ...
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 9. 发布

发布到 npm

npm login
npm publish
1
2

发布到 cnpm 的命令也是一样的,只需切换 npm 源之后,再执行以上命令。

如果想了解私有 cnpm 如何搭建,可以看《cnpm搭建》

# 10. 安装

npm install -S @cute/cute-ui
1

# 10.1 全局引入

import Vue from 'vue';
import CUTEUI from '@cute/cute-ui';

Vue.use(CUTEUI);
1
2
3
4

需要值得注意的是,CUTEUI的样式文件是需要单独引入的。

import '@cute/cute-ui/lib/style.css';
1

# 10.2按需加载

实现按需加载,需求借助 babel-plugin-component, 只引入需要的组件,以达到减小项目体积的目的。

首先,安装babel-plugin-component;

npm install -D babel-plugin-component
1

然后,对 .babelrcbabel.config.js 文件 进行修改, 添加插件:

{
    "plugins": [
        [
            "component",
            {
                "libraryName": "@cute/cute-ui",
                "style": true
            }
        ]
    ]
}
1
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);

1
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
     }
}
1
2
3
4
5
6
7
上次更新时间: 8/17/2020, 5:24:27 PM