从0开始,手把手带你打造自己的UI库(附文档)
前言
本篇文章是为了锻炼自己的技术能力还有底子,模仿element-ui
进行开发的UI
库。纯属学习使用。本文利用Vue-Cli4
进行构建。css预编译器
用的sass
文档地址这里
说一下文档。进去会有点慢。
- 服务器原因。
- 没有打包 启动的node服务。(打包因为使用了
vue组件
,所以出现错误。目前我还不会解决。有大能可以帮忙解决一下最好)
github地址 这里
本次大大小小总共写了 12 组件。分别是
- Button组件
- Layout 布局组件
- Container 容器组件
- input 输入框组件
- Upload 上传组件
- DatePick 日历组件
- Switch 开关组件
- infinteScroll 无线滚动指令
- Message 通知组件
- Popover 弹出框组件
- 分页组件
- table 表格组件
大概就这么多。废话不多说,接下来开始进行每个组件的解析和创建
代码结构
ui
|-- undefined
|-- .browserslistrc
|-- .editorconfig
|-- .eslintrc.js
|-- .gitignore
|-- babel.config.js
|-- karma.conf.js //karma 配置
|-- package-lock.json
|-- package.json
|-- packeage解释.txt
|-- README.md
|-- services.js // 文件上传服务器
|-- vue.config.js
|-- dist // 打包后
| |-- ac-ui.common.js
| |-- ac-ui.common.js.map
| |-- ac-ui.css
| |-- ac-ui.umd.js
| |-- ac-ui.umd.js.map
| |-- ac-ui.umd.min.js
| |-- ac-ui.umd.min.js.map
| |-- demo.html
|-- public
| |-- 1.html
| |-- favicon.ico
| |-- index.html
|-- src // 主文件夹
| |-- App.vue
| |-- main.js
| |-- assets
| | |-- logo.png
| |-- components // 测试用例
| | |-- ButtonTest.vue
| | |-- ContainerTest.vue
| | |-- DatePickTest.vue
| | |-- FormTest.vue
| | |-- InfiniteScrollTest.vue
| | |-- LayoutTest.vue
| | |-- MessageTest.vue
| | |-- paginationTest.vue
| | |-- PopoverTest.vue
| | |-- SwitchTest.vue
| | |-- TableTest.vue
| |-- packages // UI
| | |-- index.js
| | |-- infiniteScroll.js
| | |-- progress.vue
| | |-- button
| | | |-- Button.vue
| | | |-- ButtonGroup.vue
| | | |-- Icon.vue
| | |-- container
| | | |-- aside.vue
| | | |-- container.vue
| | | |-- footer.vue
| | | |-- header.vue
| | | |-- main.vue
| | |-- datePack
| | | |-- date-pick.vue
| | | |-- date-range-pick.vue
| | |-- Form
| | | |-- ajax.js
| | | |-- input.vue
| | | |-- upLoad-drag.vue
| | | |-- upLoad.vue
| | |-- layout
| | | |-- Col.vue
| | | |-- Row.vue
| | |-- Message
| | | |-- index.js
| | | |-- Message.vue
| | |-- pagination
| | | |-- pagination.vue
| | |-- popover
| | | |-- popover.vue
| | |-- switch
| | | |-- Switch.vue
| | |-- Table
| | |-- Table.vue
| |-- styles // 全局样式
| |-- icon.js
| |-- mixin.scss
| |-- _var.scss
|-- tests // 测试用例
| |-- button.spec.js
| |-- col.spec.js
|-- uploads // 文件上传路径
|-- 1.js
通用代码
样式
// styles/_var
$border-radius: 4px;
$primary: #409EFF;
$success: #67C23A;
$warning: #E6A23C;
$danger: #F56C6C;
$info: #909399;
$primary-hover: #66b1ff;
$success-hover: #85ce61;
$warning-hover: #ebb563;
$danger-hover: #f78989;
$info-hover: #a6a9ad;
$primary-active: #3a8ee6;
$success-active: #5daf34;
$warning-active: #cf9236;
$danger-active: #dd6161;
$info-active: #82848a;
$primary-disabled: #a0cfff;
$success-disabled: #b3e19d;
$warning-disabled: #f3d19e;
$danger-disabled: #fab6b6;
$info-disabled: #c8c9cc;
$--xs: 767px !default;
$--sm: 768px !default;
$--md: 992px !default;
$--lg: 1200px !default;
$--xl: 1920px !default;
$map: (
"xs":(max-width:$--xs),
"sm":(min-width:$--sm),
"md":(min-width:$--md),
"lg":(min-width:$--lg),
"xl":(min-width:$--xl),
);
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
混入函数
//flex布局复用
@import "var";
@mixin flexSet($dis:flex,$hov:space-between,$ver:middle,$col:center) {
display: $dis;
justify-content: $hov; // 主轴对齐方式
align-items: $col;
vertical-align: $ver // 图片对其
};
@mixin position($pos:absolute,$top:0,$left:0,$width:100%,$height:100%){
position: $pos;
top: $top;
left: $left;
width: $width;
height: $height;
};
@mixin res($key) {
// inspect Map 无法转换为纯 CSS。使用一个作为 CSS 函数的变量或参数的值将导致错误。使用inspect($value)函数来生成一个对调试 map 有用的输出字符串。
@media only screen and #{inspect(map_get($map,$key))}{
@content //插槽
}
}
Button 组件
Button
首先要确认的是,Button
都有哪些常用的属性
type
类型,分别控制按钮不同的颜色icon
字体图标。看按钮是否要带有图标iconPosition
字体图标的位置。loading
加载状态disable
和loading
一起控制- 以下没有实现,感觉比较简单。所以偷个懒
size
按钮大小 (这里我就偷懒了,感觉这个比较好实现)radio
圆角 也就是加一个border-radius
暂时就想到这么多。先实现把
html结构
<template>
<button class="ac-button" :class="btnClass" :disabled="loading" @click="$emit('click',$event)">
<ac-icon v-if="icon && !loading" :icon="icon" class="icon">ac-icon>
<ac-icon v-if="loading" icon="xingzhuang" class="icon">ac-icon>
<span v-if="this.$slots.default">
<slot>slot>
span>
button>
template>
这段代码应该比较容易理解。注意点
- 我是利用
order
来进行 图标位置的前后,也可以再span
后面在加上一个ac-icon
用if
判断即可 @click
事件是需要触发 父级click
事件。如果有其他需要还可以继续添加
JS部分
ButtonGroup
这个就比较简单了。就是利用插槽
,内容填充一下。然后更改一下样式即可。
当然 也可以写一个报错信息
Layout 布局组件
参考element-ui
,有两个组件。
- 一个
row
代表行 - 一个
col
代表列
分析一下行的作用,控制元素的 排列方式,元素直接的距离等,再把里面内容展现出来
列的作用 需要控制自己所占大小,偏移。响应等
接下来开始实现。
row
html
结构简单,就是把传入的呈现出来。props
方面也比较简单,有一个 自定义校验器。前面也说过了。解释一下其他的
mounted
。里面 获取所有子元素,吧gutter
赋给他们...style
为什么要解构,防止里面有样式- 这里直接使用了 flex布局。有精力得小伙伴可以再补充一下浮动
col
这段代码的核心就是:通过计算属性把不同的class
给加入到组件上
关于下面的 res
再上面通用代码里。就是一些sass
的应用
Container 容器组件
容器组件就相对来说简单了。就是利用H5
新标签。
里面使用了flex
aside
main
header
footer
container
input 输入框组件
参考element
,应该有以下功能
- 可情况
- 密码展示
- 带图标的输入框
- 状态禁用
v-if="prefixIcon"
> :value="value"
@input="$emit('input',$event.target.value)"
:disabled="disabled" ref="input"
@change="$emit('change',$event)"
@blur="$emit('blur',$event)"
@focus="$emit('focus',$event)"
> v-if="clearable && value"
@click.native="$emit('input','')"
@mousedown.native.prevent
> v-if="ShowPassword && value"
@click.native="changeState"
> v-if="suffixIcon"
>
先看以下html
的代码结构发现并不难,利用v-if
控制 ac-icon
的隐藏。利用props
传入属性来控制。计算属性控制class
的添加
特别注意。记得在组件上写@xxx="$emit('xxx',$event)"
。否则父类触发不了事件
Upload 上传组件
html结构
ref="input" @change="handleChange">
{{ file.name }}
upLoadDrag
在后面拖拽上传
type = file
时 参考
解释一下 html
结构
- 根据传入
drag
,判断是否需要拖拽上传 - 文件列表。根据不同的状态来决定是否显示
progress
js css 结构
css
就几行。所以就直接写在这里面了
props
解释
name
输入框提交到后台的名字action
提交地址:limit
限制提交个数accept
类型:on-exceed
超过提交个数 会执行次方法:on-change
上传文件发生状态变化 会触发 选择文件 上传成功等:on-success
上传成功时候触发:on-error
上传失败时候触发:on-progress
上传过程中时候触发:before-upload
上传之前触发的函数:file-list
上传文件列表httpRequest
提供上传方法,例如aixos
默认ajax
JS
可能这一长串代码可能看的会头疼。我先来串一下流程。
- 首先把
input
隐藏。点击div
。触发handleClick
方法,作用清空值,并且click
input
- 选择文件后触发
change
handleChange
事件。获取文件列表, 开始准备上传 uploadFiles
方法,获取文件个数,通过handleFormat
格式化文件,然后通过upload
上传upload
判断是否有beforeUpload
传入,传入执行,没有就上传post
整合参数,开始上传。
拖拽上传
相比上面,这里面就是改了一些 把click
改成了drop
还有一些文件
@dragover.prevent
@dragleave.prevent
>将文件拖拽到此区域
原生ajax
export default function ajax(options) {
// 创建 对象
const xhr = new XMLHttpRequest()
const action = options.action
const fd = new FormData() // H5上传文件API
fd.append(options.fileName,options.file)
// console.log(options.fileName,options.file)
// console.log('文件名'+options.fileName,options.file)
xhr.onerror = function (err){
options.onError(err) // 触发错误回调
}
// 上传完毕后走这个方法 H5 api
xhr.onload = function (){
let text = xhr.response || xhr.responseText
options.onSuccess(JSON.parse(text))
}
xhr.upload.onprogress = function(e){
if (e.total > 0){
e.percent = e.loaded/e.total * 100
}
options.onProgress(e)
}
// 开启清求
xhr.open('post',action,true)
// 发送清求
xhr.send(fd)
return xhr
}
DatePick 日历组件
日历组件的 结构不是很难。难得是 要去计算时间
思路解释一下
input
聚焦后,执行handleFocus
函数,显示下面得日历框。点击div
外面。执行handleBlur
。关闭日历框
- 接下来是
content
的里面的。显示头部,4个icon
外加时间显示 - 接下来时日历和时间
最主要难得就时时间的显示。得一步一步算。
每个人的计算方式不一样。这里只给一个参照。
@change="handleChange">
{{ TemTime.year }}年 {{ TemTime.month+1 }} 月
{{ week }}
@click="selectDay(getCurrentMonth(i,j))"
:class="{
isNotCurrentMonth: !isCurrentMonth(getCurrentMonth(i,j)),
isToday:isToday(getCurrentMonth(i,j)),
isSelect:isSelect(getCurrentMonth(i,j))
}">
{{getCurrentMonth(i,j).getDate()}}
{{ this.TemTime.year }}年
{{ i }}
{{ startYear() }}年-{{ startYear()+10 }}年
@click="setYear(i)"
>{{ i.getFullYear() }}
Switch 开关组件
switch
就相对简单一点。纯样式控制。input
写到 label
内,不需要写for
了。通过伪类
控制。
通过computed
来控制class
样式添加
{{ activeText }}{{ inactiveText }}
infinteScroll 无限滚动指令
无限滚动不能作为一个组件。所以放成一个指令。参考地址
attributes
自定义的默认属性getScrollContainer
获取Scroll的容器元素getScrollOptions
属性合并handleScroll
控制是否Scroll
思路。插入的时候 获取fn
和vnode
.再获取容器。获取参数。绑定事件。最后解除绑定
重点说一下 MutationObserver MDN
import throttle from 'lodash.throttle'
// 自定义属性
const attributes = {
delay: {
default: 200
},
immediate: {
default: true
},
disabled: {
default: false
},
distance: {
default: 10
},
}
/**
* 获取Scroll的容器元素
* @param el 元素节点
* @returns {(() => (Node | null))|ActiveX.IXMLDOMNode|(Node & ParentNode)|Window}
*/
const getScrollContainer = (el)=>{
let parent = el
while (parent) {
if (document.documentElement === parent) {
return window
}
// 获取元素是否有 overflow属性
const overflow = getComputedStyle(parent)['overflow-y']
if (overflow.match(/scroll|auto/)) {
return parent
}
parent = parent.parentNode
}
}
/**
* 拿到传入的属性和默认属性进行比对 合并
* @param el 节点
* @param vm Vue实例
* @returns {{}} 合并后的属性
*/
const getScrollOptions = (el, vm)=>{
// entries参考网址 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/entries
return Object.entries(attributes).reduce((map, [key, option])=>{
let defaultValue = option.default
let userValue = el.getAttribute(`infinite-scroll-${ key }`)
map[key] = vm[userValue] ? vm[userValue] : defaultValue
return map
}, {})
}
const handleScroll = function(cb) {
let { container, el, vm,observer } = this['infinite-scroll'] // 绑定了this
let { disabled,distance } = getScrollOptions(el, vm)
if (disabled) return
let scrollBottom = container.scrollTop + container.clientHeight
if (container.scrollHeight - scrollBottom <= distance){
cb()
}else {
if (observer){ // 接触监控
observer.disconnect()
this['infinite-scroll'].observer = null
}
}
}
export default {
name: 'infinite-scroll',
inserted(el, bindings, vNode) { // vNode里面有context可以访问上下文
// 插入 指令生效
console.log('指令生效')
console.log(bindings.value) // 获取到fn
console.log(vNode.context) // 获取虚拟实例 里面有属性
let cb = bindings.value
let vm = vNode.context
// 1. 开始寻找循环的容器
let container = getScrollContainer(el)
console.log(container)
if (container !== window) {
console.log('绑定事件')
// 2. 获取Options
let { delay, immediate } = getScrollOptions(el, vm)
// 3. 执行函数 节流 增加滚动事件
let onScroll = throttle(handleScroll.bind(el, cb), delay)
el['infinite-scroll'] = {
container,
onScroll, el, vm
}
if (immediate) {
const observe =el['infinite-scroll'].observer= new MutationObserver(onScroll) // 观察页面是否继续加载
observe.observe(container, {
childList: true, // 监控孩子列表发生变化
subtree: true // 当子dom元素 发生变化也触发
})
onScroll() // 默认先加载
}
container.addEventListener('scroll', onScroll)
}
},
unbind(el) {
// 解除
const { container, onScroll } = el
if (container) {
container.removeEventListener('scroll', onScroll)
el['infinite-scroll'] = {}
}
}
}
Message 通知组件
这里面有两个。为什么又两个,因为message
是通过appendChild
添加到Dom
里面的
思路
- 通过
extend
方法生成一个vue
子类。然后通过$mount
生成dom对象
再添加到document
options.close
在element
方法里不是这样写的还有一部分判断等。这里接直接偷懒了,能正常使用
index
- 因为可能要有多个
message
。需要计算高度。所以使用了 数组存放。根据个数循环高度
import Vue from 'vue'
import MessageCom from './Message.vue';
let instances = []
// 生成一个vue 的 子类
let MessageConstructor = Vue.extend(MessageCom)
// 参考element 的写法 做了一定的修改和简化
const Message = (options)=>{
options.close = function() {
let length = instances.length
instances.splice(0, 1);
for (let i = 0; i 1; i++) {
let removedHeight = instances[i].$el.offsetHeight;
let dom = instances[i].$el;
dom.style['top'] =
parseInt(dom.style['top'], 10) - removedHeight - 16 + 'px';
}
}
let instance = new MessageConstructor({
data: options,
})
instance.$mount()
document.body.appendChild(instance.$el)
let verticalOffset = 20;
instances.forEach(item=>{
verticalOffset += item.$el.offsetHeight + 16; // 53 +16
});
instance.verticalOffset = verticalOffset;
instance.visible = true
instances.push(instance)
return instance
}
// 加载 'warning', 'error', 'success', 'info' 等
['warning', 'error', 'success', 'info'].forEach(type=>{
Message[type] = function(options) {
options.type = type
return Message(options)
}
})
export default Message
message
这个里面没有什么较难的内容。基本就是样式的控制
:style="messageStyle"
:
>
{{ message }}
Popover 弹出框组件
这个组件跟Message
差不多。并不难。主要对JS
三大家族的的引用。获得元素位置。根据元素位置来确定popover
的位置
@click.stop
阻止事件冒泡
个人觉得这一部分写的有一点冗余。感觉可以用offset
搞定全部的。但是没有使用。就先这样吧
:
:style="position"
ref="content"
@click.stop>
{{ title }}
{{ title }}
{{ content }}