前端工程化之组件化开发

抽离封装并使用 Storybook 管理前端组件

隔一年,Bit 情理之中地没有🔥起来。So, 我们团队经过对其它组件管理工具的 POC,同时把 Bit 做不到的事情作为重点来考量,确定了 Storybook + NPM 的技术方案来开发和管理前端共用组件,并且正巧撞上了 Storybook 6.0,组件的定义方式和组件文档的撰写方式都有很大程度的突破,更适合前端开发人员了[1],这点我是通过不同版本的使用切实体会到了👍。6.0 同时应广大使用者的吐槽重写了 Storybook 的官方文档,可在目前来看依然存在一些问题,比如没有中文版本、没有 Vue 的示例代码,着实让我们在实践的过程中费了不少功夫,但好在终九转功成,集成组件预览、动态更新、参数说明等功能的自动化前端共用组件平台成功搭建运转🎉。

组件化其实并不难,只需要考虑三个问题:

  1. 如何定义组件?
  2. 如何展示组件?
  3. 如何共用组件?

1难在如何根据业务维度抽离组件,同时也考验组件封装的能力,也就是代码的基本功了;2难在展示工具的选取和实际用法,很庆幸,这趟浑水我已经替看到这篇文章的你淌了😌,答案就是 Storybook;第3个问题先抛开技术难度,它更考量整个团队的协作度去实际运用抽离的组件,共同推进组件化进程,这无疑才是最难实现的,否则整个工程化又有什么意义呢?(自省)

初始化组件文档

组件文档的目的是展示共用的 UI 组件,将来需要部署至线上,因此它本质上就是一个站点,而 Storybook 就是生成这个站点工具,它能与项目的主程序隔离构建和运行。Storybook 做的其实很简单,它能给你的每一个组件添加一个故事,在这个故事里可任由你对组件点缀发挥,最后它将这一个个故事放在生成的站点上,就组成了《组件文档》。

创建项目,安装依赖

既然组件文档本质就是站点,那么按一个 Vue 项目来创建,再添加需要用到的依赖项:

1
2
3
4
$ vue create my-storybook
$ cd my-storybook
$ vue add vuetify
$ npx sb init # 安装Storybook

本文基于 Vuetify 2.4.0 进行组件抽离封装,其它 UI 组件库需自行验证与 Storybook 的结合方式。

成功后执行 $ npm run storybook 会启动 Storybook 并自动在浏览器打开网页,默认使用的是6006端口,不与 Vue 冲突,所以开发组件时可以使用 Vue 项目实时预览组件的改动,开发完成后再引入 Storybook。

目录及配置

Storybook 配置

.storybook 目录下存放 Storybook 的配置文件,修改主配置文件 main.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* 文件位置:.storybook/main.js */

const path = require('path');

module.exports = {
  // 故事文件的放置目录
  stories: ['../src/stories/**/*.stories.js'],
  // 要用到的插件
  addons: ['@storybook/addon-actions', '@storybook/addon-links'],
  // Storybook的webpack配置
  webpackFinal: async(config, { configType }) => {
    // 配置可在故事文件里使用别名
    config.resolve.alias['@'] = path.resolve(__dirname, '..', 'src');
    // 配置解析 Sass
    config.module.rules.push({
      test: /\.s(a|c)ss$/,
      use: ['style-loader', 'css-loader', 'sass-loader'],
      include: path.resolve(__dirname, '../'),
    });
    return config;
  }
};

Vuetify 配置

主题色、辅色等 Vuetify 相关的配置在自动生成的 src/plugins/vuetify.js 文件中。这里建议直接引入 vuetify/lib,因为默认的 vuetify/lib/framework 内不包含样式部分,Vue 里是通过 vuetify-loader 编译的,而后面 Storybook 里没有 vuetify-loader,会导致样式失常,直接导入 vuetify/lib 一劳永逸😎。

 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
/* 文件位置:src/plugins/vuetify.js */

import Vue from 'vue';
import Vuetify from 'vuetify/lib';

Vue.use(Vuetify);

const theme = {
  primary: '#6DA4D8',
  secondary: '#FDDB55',
  error: '#EF3A61',
  success: '#51AD5A',
  info: '#6DA4D8'
};

export const options = {
  theme: {
    themes: {
      dark: theme,
      light: theme,
    },
  },
  icons: {
    iconfont: 'mdi'
  }
}

export default new Vuetify(options);

装饰器

基于特定 UI 库的组件需要搭配装饰器 Decorators 来在 Storybook 中渲染,创建全局装饰器文件:

 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
/* 文件位置:.storybook/preview.js */

import Vue from 'vue';
import Vuetify from 'vuetify';
import { options } from '@/plugins/vuetify'

Vue.use(Vuetify)

export const parameters = {
  // 自动为组件文档中的事件匹配参数
  actions: { argTypesRegex: "^on[A-Z].*" },
}

const vuetify = new Vuetify(options)

export const decorators = [
  (story, context) => {
    // 包装组件
    const wrapped = story(context)
    // 返回 Vue 子类,表示每一个故事在Storybook里渲染出来都是一个完整的Vue实例
    return Vue.extend({
      vuetify,
      components: { wrapped },
      template: `
        <v-app>
          <v-main>
            <wrapped />
          </v-main>
        </v-app>
      `
    })
  },
]

如果你在 Vuetify 的配置文件里非要引入 vuetify/lib/framework,那么需在这里另外引入样式文件 import 'vuetify/dist/vuetify.min.css';


上面就是确保 Storybook 可正常渲染 Material Design 风格组件最基本的配置了,当前步骤的目录可参考:

.
├─ .storybook/
│   ├─ preview.js
│   └─ main.js
├─ doc/
├─ node_modules
├─ public/
├─ src/
│   ├─ assets/
│   ├─ components/
│   ├─ plugins/
│   │   └─ vuetify.js
│   ├─ stories/
│   ├─ App.vue
│   └─ main.js
└─ README.md

抽离封装组件

前端抽离划分组件的目的是降低页面的耦合度,解决页面内或页面间的代码复用性问题,但并不是说要划分得越细越好,无脑地抽离导致遍地皆组件没有什么意义,而是一定要从业务出发,考虑业务使用的场景和逻辑的合理性,不同公司、不同业务甚至不同项目之间组件的可代入性和可替换性都是无法确定的。所以这里只从技术角度出发,以按钮组件 Button 重点看看封装组件的部分。

设置全局组件

为了不用在抽离组件时频繁引入组件、注册组件来预览,先配置 src/componnets/ 目录下所有组件为全局组件:

  1. 安装 lodash:
1
$ npm install lodash
  1. 创建文件 src/utils/global.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import Vue from 'vue';
import upperFirst from 'lodash/upperFirst';
import camelCase from 'lodash/camelCase';

// 引入目录下的全部组件
const requireComponent = require.context('@/components', true, /\.vue$/); 

requireComponent.keys().forEach(fileName => {
  // 获取组件配置
  const componentConfig = requireComponent(fileName);
  // 处理获取组件名
  const componentName = upperFirst(camelCase(fileName.split('/').pop().replace(/\.\w+$/, '')));
  // 全局注册组件
  Vue.component(`Z${componentName}`, componentConfig.default || componentConfig)
});
  1. 引入文件:
1
2
3
/* 文件位置:src/main.js */

++ import './utils/global.js';

这样处理后,在组件目录下编写的所有 Vue 组件在任意位置都可直接使用:

1
2
3
4
5
<template>
  <div>
    <z-button />
  </div>
</template>

封装 Button 组件

二次封装要求一定要对原组件属性和方法的使用了如指掌,必要时也需要深入源码探究组件功能的实现方式。先从业务角度出发确定按钮的使用场景,随之也就基本确定了需要用到的属性和方法,如:

  • color:按钮颜色
  • width:宽度
  • disabled:禁用
  • icon:指定为图标按钮
  • ...

所以,Component Z-Button v0.0.1:

 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
<template>
  <v-btn 
    :color="color" 
    :width="width" 
    :color="color" 
    :width="width" 
    :x-small="xSmall"
    :small="small"
    :large="large"
    :x-large="xLarge"
    :icon="icon" 
    :disabled="disabled" 
    v-on="$listeners"
  >
    <slot></slot>
  </v-btn>
</template>

<script>
export default {
  name: "Button",
  props: {
    color: {
      type: String,
      default: 'primary'
    },
    width: [String, Number],
    icon: Boolean,
    disabled: Boolean,
    small: Boolean,
    xSmall: Boolean,
    large: Boolean,
    xLarge: Boolean
  },
};
</script>

其中,v-on="$listeners" 直接监听组件的原生事件,比如点击事件直接在使用时绑定即可。<slot></slot> 插槽给组件提供了较高的可自定义程度。预览组件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* 文件位置:src/App.vue */

<template>
  <v-app>
    <v-main>
      <z-btn color="info" width="100" @click="hello">
        <v-icon>mdi-heart</v-icon>
      </z-btn>
    </v-main>
  </v-app>
</template>

<script>
export default {
  name: "App",
  methods: {
    hello(){
      console.log('hello');      
    }
  },
};
</script>

编写组件文档

抽离封装好组件后再转到 Storybook 的部分,开始之前先完善两件事:

1. 安装用于生成文档的插件

1
$ npm install --save-dev @storybook/addon-essentials
1
2
3
4
5
6
/* 文件位置:.storybook/main.js */

module.exports = {
  ...
  addons: ['@storybook/addon-actions', '@storybook/addon-links', '@storybook/addon-essentials'],
}

是的,这是 Storybook 6.0 的第一个“哇塞”,它可以让你零配置即可体验组件文档的奇妙,包括自动生成的文档、参数控制、操作记录等。

2. 处理 icon

默认使用的 icon 是通过 public/index.html 引入的,所以 Storybook 内无法显示,需手动安装:

1
$ npm i @mdi/font

src/main.js.storybook/preview.js分别引入:

1
++ import '@mdi/font/css/materialdesignicons.css';

然后为你的组件创建故事吧🤡:

 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
/* 文件位置:src/stories/Button.stories.js */

// 导入事件处理
import { action } from '@storybook/addon-actions';
// 导入组件
import ZBtn from '@/components/Btn.vue';

export default {
  title: 'Vuetify Components/Button',
  component: ZBtn,
  // 详细的参数文档
  argTypes: {
    color: {
      description: '组件颜色,默认为主题色',
      table: {
        defaultValue: { summary: 'primary' }
      },
    },
    width: {
      description: '组件宽度'
    },
    xSmall: {
      name: 'x-small',
      description: '小尺寸按钮'
    }
  }
};

// 定义组件模板
const Template = (args, { argTypes }) => ({
  components: { ZBtn },
  props: Object.keys(argTypes),
  template: '<z-btn v-bind="$props" @click="clickBtn">按钮</z-btn>',
  methods: { clickBtn: action('click')}
})

// 导出默认的组件
export const Default = Template.bind({});

// 添加不同风格的组件
export const SizeBtn = () => ({
  components: { ZBtn },
  template: `<v-row align="center">
              <v-col> <z-btn x-small @click="click">x-small</z-btn> </v-col>
              <v-col> <z-btn small>small</z-btn> </v-col>
              <v-col> <z-btn>default</z-btn> </v-col>
              <v-col> <z-btn large>large</z-btn> </v-col>
              <v-col> <z-btn x-large>x-large</z-btn> </v-col>
              <v-col> <z-btn disabled x-large>x-large</z-btn> </v-col>
            </v-row>`,
  methods: { click: action('click')}
});

export const IconBtn = () => ({
  components: { ZBtn },
  template: `<v-row align="center">
              <v-col> <z-btn icon x-small><v-icon>mdi-plus</v-icon></z-btn> </v-col>
              <v-col> <z-btn icon small><v-icon>mdi-plus</v-icon></z-btn> </v-col>
              <v-col> <z-btn icon><v-icon>mdi-plus</v-icon></z-btn> </v-col>
              <v-col> <z-btn icon large> <v-icon>mdi-plus</v-icon> </z-btn> </v-col>
              <v-col> <z-btn icon x-large><v-icon>mdi-plus</v-icon></z-btn> </v-col>
              <v-col> <z-btn disabled icon x-large><v-icon>mdi-plus</v-icon></z-btn> </v-col>
            </v-row>`,
  methods: { click: action('click')},
});

这时会发现用于预览组件的容器太高了,没有自适应组件的高度,原因是 .storybook/preview.js 文件中用于包裹组件的 <v-app></v-app> 有最低高度 100vh 的默认样式,修改之:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
/* 文件位置:src/assets/sass/main.js */

// 取消最低高度
.v-application--wrap {
  min-height: 0;
}

// 取消按钮英文大写
.v-btn {
  text-transform: none;
}
1
2
3
/* 文件位置:src/plugins/vuetify.js */

++ import '@/assets/sass/main.scss';

然后,输入 $ npm run storybook 启动 Storybook 欣赏你的组件故事吧🎉🎉:

button.gif◎ Button 组件文档

结语

完成组件和故事的编写后可将 Storybook 构建成静态 Web 应用程序来发布,以供团队之间共享,还可以利用 GitLab CI/CD 为其设置自动构建及部署,总之,它能做的真的很多,网上也不乏很多优质的组件文档值得我们学习。

写到这里发现篇幅拉的有些大了,而创建 NPM 私有仓库、将组件发布为 Package 以及组件的使用要说的还不少😌……也罢,那就且听下回分解。本文案例放在了我的 GitHub 以供参考:

References & Resources

  1. Configuring Storybook 6 for Vue 2 + Vuetify 2.3 | Morgan Benton

  1. "The 6.0 release retools Storybook for professional frontend developers." ——Storybook 6.0

updatedupdated2021-04-282021-04-28
docs: new post
加载评论
点击刷新