尝试做了一个简单的基于 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 也是无法生效的。
|
|
然后又发现 Scss 中的 @import
路径报错,于是又去网上搜了一圈,用 null-loader
解决了。
接着就是想给 collapse
组件加个动画,网上查到的很多方案效果都很不好,因为我的 collapse-item
的高度是不确定的,所以直接使用 height
做动画效果不好,如果设置 height: auto
就没什么用;把 max-height
设置得特别高也不行,因为动画计算的缘故,会非常卡……
于是我就使用了 JS 来获取 height 的方法,代码如下:
|
|
除此之外还做了一些其他样式和动画的优化,详细内容可以看看我的 Commit 记录。
按钮与按钮组
按钮组件相对比较简单,在这里我们需要的特别考虑的有以下几个点:
如何控制 icon 的位置
可以在组件的最外层加上一个动态绑定的 class
:
|
|
这样就可以通过传入 iconPosition
这个 Prop 来给组件动态地加上一个 icon-left
或者 icon-right
的类,然后使用 flex
的 order
来控制里面 icon 的位置了。
如何让自定义组件响应原生的 click 事件
为了让用户使用的时候不需要使用 .native
,在这里我的处理方式比较单纯,就是让 button
在被点击的时候出发点击事件:
|
|
如何让按钮合并为按钮组
这个只需要新增加一个按钮组组件,然后在这个按钮组组件里面修改 :first-child
:last-child
和 :not:first-child
的样式即可
输入框
如何让输入框能够使用 v-model
为了让自定义的输入框组件能够使用 Vue 提供的 v-model
,需要给里面的原生 input
组件加上几个事件监听:
|
|
网格系统
如何平均分配每个 col
的宽度
如果在 row
上使用 display: flex
的话,默认是不会换行,并且每个 col
的宽度是一样的,但是考虑到在有些时候,我们可能需要让他换行、或者自行控制他是否能换行(比如在 PC 端我们横向布局的模块,换到移动端可能需要变成纵向布局等),因此我考虑使用 auto
这一个属性来进行控制,在源代码中实际上是控制了 flex-wrap
。
|
|
最终得到的效果是:如果用户需要在同一行自动平均分配宽度,则使用 auto
;若用户想要完全手动分配跨度比例则不使用 auto
,在这种情况下,若总宽度超过一行,将会引起换行。
如何将一行划分成 24 份
这其实是一个比较麻烦的问题,我们先假定用户可以用这种方式来使用我们的组件:
|
|
可以传入一个 span
的 Prop 来告诉我们用户想让这个 col
占据二十四分之几的总宽度。那么比较简单粗暴的做法就是在这三个 col
上直接分别加 col-4
col-12
和 col-8
的类,对应样式分别是 width: 4 / 24 * 100%
width: 12 / 24 * 100%
和 width: 8 / 24 * 100%
。我们可以借助 SCSS 的循环来帮我们自动加样式。
|
|
这样就写好了 col-1
到 col-24
的样式,我们只需要加上对应的 class
就行了。同样,我们的 offset
也是这样处理的。
响应式布局参数的校验
在文档示例中的响应式部分我们可以看到这样的用法:
|
|
为了保证用户传入的 pad
narrow-pc
pc
wide-pc
的值的有效性,我们需要做一些校验。
|
|
如何动态添加类
如果只有一种或两种类需要动态添加上去还比较好处理,比如说可以这样写:
|
|
但是现在情况比较麻烦,我们的用户可能会传入响应式布局所需要的一些对象值。我们可以考虑用两个函数来完成这个处理过程:
首先使用用户输入的 propsObj
创建出我们想要加上去的 classArray
:
|
|
然后我们再借助计算属性来将 classArray
加到组件上:
|
|
|
|
弹出信息
如何通过函数来触发 toast
为了让用户更方便地使用 toast
,我采取了插件的形式来提供 toast
,将他放在了 Vue.prototype
上:
|
|
autoClose
的两种用法
我将其设计为了如果 autoClose
为 false
则不自动关闭,否则就需要传入一个数字以指定自动关闭的时间,那么我们可以这样编写 autoClose
的验证:
|
|
enableHtml
怎么实现
slot
里面是不支持 HTML 的,我采用了比较粗暴的 v-if
来控制是否显示 HTML:
|
|
如何实现不同时出现多个 toast
这个时候我们就不能直接试用刚才的函数了,我们得把创建好的 toast
组件给记下来,然后在创建下一个组件之前销毁他:
|
|
标签
怎样切换标签页
先看看用法示例以方便理解,由于我们的 tabs-item
和 tabs-pane
分属于不同的组件,所以就没办法简单地用 v-if
来实现这个标签页的切换效果。
|
|
我选择了 eventBus 来解决这个问题。
- 点击
tabs-item
的时候,在 eventBus 上触发事件update:selected
,并且将tabs-item
的name
和组件本身(vm
)都传出去; tabs-item
监听事件,当事件中的name
与自己的name
相同,则激活这个tabs-item
(加上class
);tabs-pane
监听事件,当事件中的name
与自己的name
相同,则激活这个tabs-pane
(显示出来);tabs-head
监听事件,获取事件中的vm
,以做出线在不同的tabs-item
之间来回滑动的感觉。
这样,就基本实现了标签页的切换功能。
设置默认标签页
有些时候,我们还需要指定一个默认的标签页,在这里我认为直接以 Prop 的形式传给 tabs
比较合适:
- 当拿到
selected
值的时候,遍历tabs
的子元素,找到tabs-head
; - 再在
tabs-head
中遍历子元素,找到name
与selected
相同的那一项; - 触发 eventBus 上的
update:selceted
事件,并且将selected
和这个子元素(child
)都传出去。
如何实现 .sync
语法
我们有时候需要动态获取到 selected
的值,Vue 为我们提供了很方便的 .sync
语法,为了兼容这个语法,tabs
组件也必须监听 eventBus 上的 update:selected
事件,然后再在 tabs
组件上触发 update:selected
事件,并且把 name
传出去。这样 Vue 就会帮我们监听这个事件,并且动态更新 selected
的值。
|
|
如何切换 horizontal
和 vertical
同样我也借助了 eventBus,在组件里面共享了 direction
这个值,各个部分拿到 direction
值之后对样式进行了修改。
气泡卡片
如何确定气泡卡片的位置?
由于气泡卡片的内容实际上是在点击按钮之后 append
到 body
里面的(这样做是为了防止气泡卡片被父元素遮住),所以我们需要给气泡卡片确定位置。所以实际上要经历以下两个过程:
- 用户点击按钮,
visible
变为true
,气泡卡片出现 给气泡卡片定位
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
事件,我们需要处理这样几个问题:
- 点击
button
,若此时无卡片,则弹出卡片;若此时有卡片,则关闭卡片 - 卡片弹出后点击
document
,关闭卡片(但是这个事件要排除之前的按钮上的事件,防止重复关闭) - 卡片弹出后点卡片里面的内容,不关闭卡片,方便用户复制
对于 hover
事件,我们考虑的问题也不一样:
- 鼠标移入
button
,弹出卡片;鼠标移出button
,关闭卡片 - 卡片弹出后,200ms 内鼠标移入卡片,卡片保持不关闭
- 鼠标移出卡片,卡片关闭
这里面的 EventListener
需要及时 remove
掉,否则就会出现很多多余的监听器
卡片里面的关闭按钮如何实现
可以借助 Vue 中作用域插槽的使用,在组件里面将写好的 close
方法给传出去:
|
|
这样在组件外面就可以这样来使用 close
方法:
|
|
或者使用 ES 6 的解构语法:
|
|
折叠面板
如何实现折叠面板的效果
同样,我们需要借助 eventBus 来进行信息的传递,eventBus 上有这样几个事件:
update:selected
collapse-item
会监听来自于父组件 collapse
的 update:selected
事件和一个数组 selectedNames
,这个数组包含了当前被选中的(展开的)所有 collapse-item
的 name
。
若 collapse-item
发现自己的 name
存在于 selectedNames
之中,就会展开。
add:selected
当 collapse-item
被点击后,如果是没有展开的状态,就触发事件 add:selected
,并传出自己的 name
,这个事件将在父组件 collapse
中得到处理。
父组件监听 add:selected
事件,但需要注意的是,父组件不能直接修改用户传进来的 selected
数组, 一来是这样不优雅,Vue 不建议我们修改用户传入的数据;二来是这样直接的修改并不能让外界(collapse
组件之外,以及 collapse
的各个子组件)感知到,并作出反应。因此,在作出一切的修改之前,我们需要对传入的 selected
数组做一次深拷贝:
|
|
父组件在收到 add:selected
事件之后,根据用户是否传入 alone
来选择更新的方式:
|
|
然后,再触发 update:selected
事件,让子组件 collapse-item
自己处理。当然,在这里为了支持 .sync
语法,collapse
还需要在自己(而不是 eventBus 上)触发一个update:selected
事件。
remove:selected
对应的,当 collapse-item
被点击后,如果是已经展开的状态,就触发事件 remove:selected
,并传出自己的 name
,这个事件将在父组件 collapse
中得到处理。
然后 collapse
中移除对应的 name
,然后触发 update:selected
事件来更新。
一键注册
我选择 index.js
作为项目的入口文件,为了给用户更好的使用体验,提供了两种使用的方式,首先是按需引入,只是提供一个汇总的入口,用户需要 import
之后自己手动注册组件或是使用插件:
|
|
同时也提供了全局自动安装的方式,将所有的组件封装成一个插件:
|
|
这样用户就可以使用 Vue.use(HaisUI)
一键注册了。
单元测试
我使用了 Karma + Mocha 来进行单元测试,还 Sinon 来帮助完成测试。
|
|
比如在 test/button.test.js
文件中
|
|
持续集成
项目使用 Travis-CI 进行持续集成,编写 .travis.yml
文件如下:
|
|