🌞

小项目:简单的 UI 框架 - Hais UI

尝试做了一个简单的基于 Vue 的 UI 框架,包含按钮、输入框、布局、弹出信息、标签、气泡卡片、折叠面板等常用组件。这篇博客是基于源码以及在搭建项目的过程中出现的问题进行的学习及讨论,而不是使用指导,组件的预览效果和使用方法可以查看 使用文档,源代码可以查看 代码仓库

本项目基于 Vue,使用 parcel 搭建*,最后发布到 npm 上可供下载,并使用 VuePress 撰写文档。

更新

2 月 17 日更新

parcel 有个毛病,弄了很久也不知道为什么,于是就换了一个脚手架,更新了一下项目——当项目越来越大的时候,我们通常会需要一个专门的文件来存放 Scss 变量,或者进行 CSSReset 之类的操作,但是一旦把这些变量抽出来,再在 Vue 中 @import 的时候,parcel 的打包就会变得非常非常慢,几个小时可能都没反应。

但是简单迁移之后发现单元测试不能用了,于是我就用 Vue-CLI 自带的 Vue Test Utils 重新写了一下,这玩意儿确实方便,虽然断言库我还是用的 mocha + chai,但是 Vue Test Utils 提供了很多方便的 API,可以更方便地进行组件的创建和查找操作,原来的很多东西其实也是适用的,所以其实换起来也并不麻烦(其实只是想体验一下这个 Vue Test Utils)。

迁移之后还发现用 yarn test:unit 进行单元测试的时候存放 icon 的 js 文件无法通过,于是就使用了 svg-sprite-loader 来引入 svg 图标,需要注意的是,VuePress 中的 config.js 也要进行配置,否则里面组件的 icon 也是无法生效的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
  chainWebpack: (config) => {
    config.module
      .rule('svg-sprite')
      .test(/\.svg$/)
      .include.add(path.resolve(__dirname, '../../src/assets/icons')).end()
      .use('svg-sprite-loader').loader('svg-sprite-loader').options({extract: false}).end()
      .use('svgo-loader').loader('svgo-loader')
      .tap(options => ({...options, plugins: [{removeAttrs: {attrs: 'fill'}}]}))
    config.plugin('svg-sprite')
      .use(require('svg-sprite-loader/plugin'), [{plainSprite: true}])
    config.module
      .rule('svg')
      .exclude.add(path.resolve(__dirname, '../../src/assets/icons')).end()
  }

然后又发现 Scss 中的 @import 路径报错,于是又去网上搜了一圈,用 null-loader 解决了。

接着就是想给 collapse 组件加个动画,网上查到的很多方案效果都很不好,因为我的 collapse-item 的高度是不确定的,所以直接使用 height 做动画效果不好,如果设置 height: auto 就没什么用;把 max-height 设置得特别高也不行,因为动画计算的缘故,会非常卡……

于是我就使用了 JS 来获取 height 的方法,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
    methods: {
      enter(el) {
        el.style.transition = 'height .3s ease-in-out'
        el.style.overflow = 'hidden'
        el.style.height = 'auto'
        let endHeight = getComputedStyle(el).height // 原理是在这个时候其实元素的高度已经确定了,于是我们获取到他的高度
        el.style.height = '0px'
        el.offsetHeight // 这句话非常重要,否则上下两句会合并成一句
        el.style.height = endHeight
      },
      afterEnter(el) {
        el.style.height = null
      },
      leave(el) {
        el.style.height = getComputedStyle(el).height
        el.offsetHeight // 这句话非常重要,否则上下两句会合并成一句
        el.style.height = '0px'
      },
      afterLeave(el) {
        el.style.height = null
      }
    }

除此之外还做了一些其他样式和动画的优化,详细内容可以看看我的 Commit 记录

按钮与按钮组

按钮组件相对比较简单,在这里我们需要的特别考虑的有以下几个点:

如何控制 icon 的位置

可以在组件的最外层加上一个动态绑定的 class

1
2
3
4
<button :class="{[`icon-${iconPosition}`]: true}">
  <hai-icon/>
  <span class="content">按钮</span>
</button>

这样就可以通过传入 iconPosition 这个 Prop 来给组件动态地加上一个 icon-left 或者 icon-right 的类,然后使用 flexorder 来控制里面 icon 的位置了。

如何让自定义组件响应原生的 click 事件

为了让用户使用的时候不需要使用 .native,在这里我的处理方式比较单纯,就是让 button 在被点击的时候出发点击事件:

1
2
3
4
<button @click="$emit('click')">
  <hai-icon/>
  <span class="content">按钮</span>
</button>

如何让按钮合并为按钮组

这个只需要新增加一个按钮组组件,然后在这个按钮组组件里面修改 :first-child :last-child:not:first-child 的样式即可

输入框

如何让输入框能够使用 v-model

为了让自定义的输入框组件能够使用 Vue 提供的 v-model,需要给里面的原生 input 组件加上几个事件监听:

1
2
3
4
5
6
    <input type="text"
           @change="$emit('change', $event.target.value)"
           @input="$emit('input', $event.target.value)"
           @focus="$emit('focus', $event.target.value)"
           @blur="$emit('blur', $event.target.value)"
    >

网格系统

如何平均分配每个 col 的宽度

如果在 row 上使用 display: flex 的话,默认是不会换行,并且每个 col 的宽度是一样的,但是考虑到在有些时候,我们可能需要让他换行、或者自行控制他是否能换行(比如在 PC 端我们横向布局的模块,换到移动端可能需要变成纵向布局等),因此我考虑使用 auto 这一个属性来进行控制,在源代码中实际上是控制了 flex-wrap

1
2
3
4
5
6
7
    .row {
      display: flex;
      flex-wrap: wrap;
      &.auto {
        flex-wrap: nowrap;
      }
    }

最终得到的效果是:如果用户需要在同一行自动平均分配宽度,则使用 auto;若用户想要完全手动分配跨度比例则不使用 auto,在这种情况下,若总宽度超过一行,将会引起换行。

如何将一行划分成 24 份

这其实是一个比较麻烦的问题,我们先假定用户可以用这种方式来使用我们的组件:

1
2
3
4
5
    <hai-row>
      <hai-col span="4"></hai-col>
      <hai-col span="12"></hai-col>
      <hai-col span="8"></hai-col>
    </hai-row>

可以传入一个 span 的 Prop 来告诉我们用户想让这个 col 占据二十四分之几的总宽度。那么比较简单粗暴的做法就是在这三个 col 上直接分别加 col-4 col-12col-8的类,对应样式分别是 width: 4 / 24 * 100% width: 12 / 24 * 100%width: 8 / 24 * 100%。我们可以借助 SCSS 的循环来帮我们自动加样式。

1
2
3
4
5
6
7
    .col{
      @for $n from 1 through 24 {
        &.col-#{$n} {
          width: ($n / 24) * 100%;
        }
      }
    }

这样就写好了 col-1col-24 的样式,我们只需要加上对应的 class 就行了。同样,我们的 offset 也是这样处理的。

响应式布局参数的校验

在文档示例中的响应式部分我们可以看到这样的用法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
    <hai-row id="container">
      <hai-col id="item1" span="24"
             :pad="{span:8}"
             :narrow-pc="{span:6}"
             :pc="{span:4}"
             :wide-pc="{span:2}"
      ></hai-col>
      <hai-col id="item2" span="24"
             :pad="{span:15, offset:1}"
             :narrow-pc="{span:17, offset:1}"
             :pc="{span:18, offset:2}"
             :wide-pc="{span:19, offset:3}"
      ></hai-col>
    </hai-row>

为了保证用户传入的 pad narrow-pc pcwide-pc 的值的有效性,我们需要做一些校验。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    const validator = (value) => {
      let keys = Object.keys(value)
      let valid = true
      keys.forEach(key => {
        if (['span', 'offset'].indexOf(key) < 0) {
          valid = false
        }
      })
      return valid
    }

如何动态添加类

如果只有一种或两种类需要动态添加上去还比较好处理,比如说可以这样写:

1
    <div :class="span && `col-${span}`"></div>

但是现在情况比较麻烦,我们的用户可能会传入响应式布局所需要的一些对象值。我们可以考虑用两个函数来完成这个处理过程:

首先使用用户输入的 propsObj 创建出我们想要加上去的 classArray

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
    methods: {
      createClass(propsObj, device) {
        // 如果没有传值,则输出的 classArray 为空
        if(!propsObj) {
          return []
        }
        const classArray = []
        // 如果有 span 或者 offset 属性,就给 classArray 加上对应的部分
        if(propsObj.span) {
          classArray.push(`col-${device}${propsObj.span}`)
        }
        if(propsObj.offset) {
          classArray.push(`offset-${device}${propsObj.offset}`)
        }
        return classArray
      }
    }

然后我们再借助计算属性来将 classArray 加到组件上:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
    computed: {
      colClass(){
        const {span, offset, pad, narrowPc, pc, widePc} = this
        const createClass = this.createClass
        return [
          ...createClass({span, offset}),
          ...createClass(pad, 'pad-'),
          ...createClass(narrowPc, 'narrow-pc-'),
          ...createClass(pc, 'pc-'),
          ...createClass(widePc, 'wide-pc-'),
        ]
      }
    }
1
    <div :class="colClass"></div>

弹出信息

如何通过函数来触发 toast

为了让用户更方便地使用 toast,我采取了插件的形式来提供 toast,将他放在了 Vue.prototype 上:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
    export default {
      install(Vue, options) {
        Vue.prototype.$toast = function createToast({message, options}) {
          const Constructor = Vue.extend(Toast)
          const toast = new Constructor({
            data() {
              return {message}
            },
            propsData: options
          })
          toast.$slots.default = [message]
          toast.$mount()
          document.body.appendChild(toast.$el)
        }
      }
    }

autoClose 的两种用法

我将其设计为了如果 autoClosefalse 则不自动关闭,否则就需要传入一个数字以指定自动关闭的时间,那么我们可以这样编写 autoClose 的验证:

1
2
3
4
5
6
7
8
9
    props: {
      autoClose: {
        type: [Boolean, Number],
        default: 5,
        validator(value) {
          return value === false || typeof value === 'number'
        }
      }
    }

enableHtml 怎么实现

slot 里面是不支持 HTML 的,我采用了比较粗暴的 v-if 来控制是否显示 HTML:

1
2
    <slot v-if="!enableHtml"></slot>
    <div v-else v-html="message"></div>

如何实现不同时出现多个 toast

这个时候我们就不能直接试用刚才的函数了,我们得把创建好的 toast 组件给记下来,然后在创建下一个组件之前销毁他:

 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
    let currentToast
    
    export default {
      install(Vue, options) {
        Vue.prototype.$toast = function (message, options = {}) {
          // 销毁之前的 toast
          if (currentToast) {
            currentToast.close()
          }
          // 创建一个新的 toast
          currentToast = createToast({
            Vue,
            message,
            propsData: options,
            onClose: () => {
              currentToast = null
            }
          })
        }
      }
    }
    function createToast({Vue, message, propsData, onClose}) {
      const Constructor = Vue.extend(Toast)
      const toast = new Constructor({
        data() {
          return {message}
        },
        propsData
      })
      toast.$slots.default = [message]
      toast.$mount()
      toast.$on('beforeClose', onClose)
      document.body.appendChild(toast.$el)
      return toast
    }

标签

怎样切换标签页

先看看用法示例以方便理解,由于我们的 tabs-itemtabs-pane 分属于不同的组件,所以就没办法简单地用 v-if 来实现这个标签页的切换效果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
    <hai-tabs selected="sports">
    
      <hai-tabs-head>
        <hai-tabs-item name="anime">动漫</hai-tabs-item>
        <hai-tabs-item name="finance">财经</hai-tabs-item>
        <hai-tabs-item name="sports">体育</hai-tabs-item>
      </hai-tabs-head>
    
      <hai-tabs-body>
        <hai-tabs-pane name="anime">动漫相关资讯</hai-tabs-pane>
        <hai-tabs-pane name="finance">财经相关资讯</hai-tabs-pane>
        <hai-tabs-pane name="sports">体育相关资讯</hai-tabs-pane>
      </hai-tabs-body>
    
    </hai-tabs>

我选择了 eventBus 来解决这个问题。

  1. 点击 tabs-item 的时候,在 eventBus 上触发事件 update:selected,并且将 tabs-itemname 和组件本身(vm)都传出去;
  2. tabs-item 监听事件,当事件中的 name 与自己的 name 相同,则激活这个 tabs-item(加上 class);
  3. tabs-pane 监听事件,当事件中的 name 与自己的 name 相同,则激活这个 tabs-pane(显示出来);
  4. tabs-head 监听事件,获取事件中的 vm,以做出线在不同的 tabs-item 之间来回滑动的感觉。

这样,就基本实现了标签页的切换功能。

设置默认标签页

有些时候,我们还需要指定一个默认的标签页,在这里我认为直接以 Prop 的形式传给 tabs 比较合适:

  1. 当拿到 selected 值的时候,遍历 tabs 的子元素,找到 tabs-head
  2. 再在 tabs-head 中遍历子元素,找到 nameselected 相同的那一项;
  3. 触发 eventBus 上的 update:selceted 事件,并且将 selected 和这个子元素(child)都传出去。

如何实现 .sync 语法

我们有时候需要动态获取到 selected 的值,Vue 为我们提供了很方便的 .sync 语法,为了兼容这个语法,tabs 组件也必须监听 eventBus 上的 update:selected 事件,然后再在 tabs 组件上触发 update:selected 事件,并且把 name 传出去。这样 Vue 就会帮我们监听这个事件,并且动态更新 selected 的值。

1
2
3
    this.eventBus.$on('update:selected', (name) => {
      this.$emit('update:selected', name)
    })

如何切换 horizontalvertical

同样我也借助了 eventBus,在组件里面共享了 direction 这个值,各个部分拿到 direction 值之后对样式进行了修改。

气泡卡片

如何确定气泡卡片的位置?

由于气泡卡片的内容实际上是在点击按钮之后 appendbody 里面的(这样做是为了防止气泡卡片被父元素遮住),所以我们需要给气泡卡片确定位置。所以实际上要经历以下两个过程:

  1. 用户点击按钮,visible 变为 true,气泡卡片出现
  2. 给气泡卡片定位

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
    methods: {
      positioningConent() {
        const {contentWrapper, triggerWrapper} = this.$refs
        document.body.appendChild(contentWrapper)
        // 获取按钮的位置和宽高
        const {top, left, height, width} = triggerWrapper.getBoundingClientRect()
        // 获取卡片的高度
        const {height: contentHeight} = contentWrapper.getBoundingClientRect()
        const positionTable = {
          top: {
            // 为了保证即使在滚动了鼠标之后再触发卡片,位置也不会错误
            top: top + window.scrollY,
            left: left + window.scrollX
          },
          bottom: { ... },
          left: { ... },
          right: { ... }       
        }
        // 根据 this.position 的值来确定应该使用哪个气泡卡片的值
        contentWrapper.style.top = positionTable[this.position].top + 'px'
        contentWrapper.style.left = positionTable[this.position].left + 'px'
      }
    }
    

事件监听

气泡卡片组件还有一个比较麻烦的地方就是事件监听过程要考虑到的事情比较多。

比如对于 click 事件,我们需要处理这样几个问题:

  1. 点击 button,若此时无卡片,则弹出卡片;若此时有卡片,则关闭卡片
  2. 卡片弹出后点击 document,关闭卡片(但是这个事件要排除之前的按钮上的事件,防止重复关闭)
  3. 卡片弹出后点卡片里面的内容,不关闭卡片,方便用户复制

对于 hover 事件,我们考虑的问题也不一样:

  1. 鼠标移入 button,弹出卡片;鼠标移出 button,关闭卡片
  2. 卡片弹出后,200ms 内鼠标移入卡片,卡片保持不关闭
  3. 鼠标移出卡片,卡片关闭

这里面的 EventListener 需要及时 remove 掉,否则就会出现很多多余的监听器

卡片里面的关闭按钮如何实现

可以借助 Vue 中作用域插槽的使用,在组件里面将写好的 close 方法给传出去:

1
2
3
    <div ref="contentWrapper" v-if="visible">
      <slot name="content" :close="close"></slot>
    </div>=

这样在组件外面就可以这样来使用 close 方法:

1
2
3
4
    <template v-slot:content="methods">
      <div>这是气泡卡片的内容</div>
      <hai-button @click="methods.close">点击</hai-button>
    </template>

或者使用 ES 6 的解构语法:

1
2
3
4
    <template v-slot:content="{close}">
      <div>这是气泡卡片的内容</div>
      <hai-button @click="close">点击</hai-button>
    </template>

折叠面板

如何实现折叠面板的效果

同样,我们需要借助 eventBus 来进行信息的传递,eventBus 上有这样几个事件:

update:selected

collapse-item 会监听来自于父组件 collapseupdate:selected 事件和一个数组 selectedNames,这个数组包含了当前被选中的(展开的)所有 collapse-itemname

collapse-item 发现自己的 name 存在于 selectedNames 之中,就会展开。

add:selected

collapse-item 被点击后,如果是没有展开的状态,就触发事件 add:selected,并传出自己的 name,这个事件将在父组件 collapse 中得到处理。

父组件监听 add:selected 事件,但需要注意的是,父组件不能直接修改用户传进来的 selected 数组, 一来是这样不优雅,Vue 不建议我们修改用户传入的数据;二来是这样直接的修改并不能让外界(collapse 组件之外,以及 collapse 的各个子组件)感知到,并作出反应。因此,在作出一切的修改之前,我们需要对传入的 selected 数组做一次深拷贝:

1
    this.selectedCopy = JSON.parse(JSON.stringify(this.selected))

父组件在收到 add:selected 事件之后,根据用户是否传入 alone 来选择更新的方式:

1
2
3
4
5
    if (this.alone) {
      this.selectedCopy = [name]
    } else {
      this.selectedCopy.push(name)
    }

然后,再触发 update:selected 事件,让子组件 collapse-item 自己处理。当然,在这里为了支持 .sync 语法,collapse 还需要在自己(而不是 eventBus 上)触发一个update:selected 事件。

remove:selected

对应的,当 collapse-item 被点击后,如果是已经展开的状态,就触发事件 remove:selected,并传出自己的 name,这个事件将在父组件 collapse 中得到处理。

然后 collapse 中移除对应的 name,然后触发 update:selected 事件来更新。

一键注册

我选择 index.js 作为项目的入口文件,为了给用户更好的使用体验,提供了两种使用的方式,首先是按需引入,只是提供一个汇总的入口,用户需要 import 之后自己手动注册组件或是使用插件:

1
2
3
4
5
6
7
8
9
    import HaiButton from './src/button'
    import HaiButtonGroup from './src/button-group'
    import HaiToast from "./src/plugin"

    export {
      HaiButton,
      HaiButtonGroup,
      HaiToast
    }

同时也提供了全局自动安装的方式,将所有的组件封装成一个插件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
    import HaiButton from './src/button'
    import HaiButtonGroup from './src/button-group'
    import HaiToast from "./src/plugin"

    const components = {
      HaiButton,
      HaiButtonGroup
    }

    export default function(Vue, options) {
      Object.keys(components).forEach(key => {
        Vue.component(key, components[key])
      })
      Vue.use(HaiToast)
    }

这样用户就可以使用 Vue.use(HaisUI) 一键注册了。

单元测试

我使用了 Karma + Mocha 来进行单元测试,还 Sinon 来帮助完成测试。

1
yarn add -D karma karma-chrome-launcher karma-mocha karma-sinon-chai mocha sinon sinon-chai karma-chai karma-chai-spies

比如在 test/button.test.js 文件中

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const expect = chai.expect
import Vue from 'vue'
import Button from '../src/button.vue'

describe('Button', () => {
  it('存在', () => {
    expect(Button).to.be.ok
  })
  it('可以设置 icon', () => {
    const Constructor = Vue.extend(Button)
    const vm = new Constructor({
      propsData: {
        icon: 'settings'
      }
    }).$mount()
    const useElement = vm.$el.querySelector('use')
    expect(useElement.getAttribute('xlink:href')).to.equal('#icon-settings')
    vm.$destroy()
  })
})

持续集成

项目使用 Travis-CI 进行持续集成,编写 .travis.yml 文件如下:

1
2
3
4
5
6
7
8
9
language: node_js
node_js:
  - "12"
addons:
  chrome: stable
sudo: required
before_script:
  - "sudo chown root /opt/google/chrome/chrome-sandbox"
  - "sudo chmod 4755 /opt/google/chrome/chrome-sandbox"
updatedupdated2020-02-222020-02-22