大家好,今天我们来谈谈前端每个人都在写的东西,组件。

在当下react和vue这么流行的大环境下,相信你一定写过组件。如果你感觉你是职场小白,只从github或者npm下载过一些组件使用,自己没有写过,那么你就错了,你一定写过。因为往大一点说,一个页面可以是一个组件,往小了说,一个按钮也是一个组件,你写的区别只在于它的扩展性是不是好,通用性是不是强。所以,首先不要畏惧,我们每个人其实都在写组件,今天的分享,就是如何让我们更好的写组件

一、需求分析-构思组件

从接手项目,带你开启组件封装第一步:

1. 需求分析

拿到项目之后,这里以一个新项目为例,首先,全局概览。看一下这个页面总共分几部分。各个部分之间有什么相同之处,这里举例(边距:考虑封装一个共有的盒子、色系:考虑用父元素继承的方式获取、title、排列方式、图文规划、功能区块等);初步划分功能,列出几个可能要封装的组件。
示例

2. 综合考虑

联系其他页面,再次分类,调整组件的分类,尽可能做到组件公用,扩张组件的可配置项(例如,title文案前可能有icon图标,尾部可能有更多箭头,文案等,尽可能完善这个组件)。

3. 归置组件

综合页面需求及过往经验,将组件划分为基础组件业务组件

针对不同类型组件,有不同的封装要求。公共类组件,可配置项一定要多,即要活,放哪里都可以用。业务类组件,可以适当地写死一些东西,因为确定这个组件是轻易不会变动的,例如(流程说明、提示楼层、价格楼层等)
示例

4. 使用说明

封装好的组件,最好有使用说明,有使用demo最好,使小白只要粘贴代码,就可以跑起来,首先增加使用者的自信心,可以轻易看到组件(默认样式及功能可以展示与使用)。

二、组件封装要求

组件封装,需要有一些强制的要求,才能保证我们的组件库在日益壮大的同时,不会因为各种不统一和零散化,导致组件库越来越难维护。

1. 关于组件规模

  • 基础组件:被拆解的很零散的组件;例如一个按钮、一个input框、一个title、一个盒子

一般而言,一些基础的、使用频率比较高的组件,适合做成‘零散型’组件,即一个小的单位,例如,一个toast,一个input,一个btn一个置顶按钮等。但是越是‘零散’的组件,其可配置项越要灵活,功能性越要完善;包括样式、入参等。
优点:可使用范围广,通用性、扩展性强。
缺点:组件很小,只能局部使用,需要和页面其他元素联动使用。

  • 模块组件:归整的很完善的组件;例如一个完整的轮播图、一个完整的筛选项、一个分页list。

相对于基础组件,为了更高效的开发,我们有时候会把业务性比较强,功能比较统一的一些‘大型模块’封装为一个组件。例如整个筛选功能,整个分页加载。整个图文混排等‘模块’,封装为一个组件。

优点:组件功能完善,不需要组装配合,直接可以构建成完整的页面,开发效率高。
缺点:业务定制化程度高,相对来说,可配置项少。

2. 关于class命名

  1. 语义化
    命名一定要语义化,让使用的人看到类名,就知道这个是组件的那个元素。(例如title_icon title_more_btn)
    禁止使用中文拼写,要求英文翻译。

  2. 风格统一
    同一个组件,命名一定要风格一致。(下划线、短横杠、驼峰式)

  3. 业务分类
    业务性强的组件,class命名的时候paimai_button_small

  4. 功能分类
    组件公用性强的,使用公用class命名,例如confim_submit_button

3. 关于样式书写

  1. 百分比
    内部样式用100%来处理,外部套一个父盒子,父盒子的宽高使用传入样式

  2. 作用域
    样式书写最好使用父套子的方式,给类名加权重和作用域,以保证绝对的样式‘内部性’。

  3. 容错
    样式也要有容错,不能差几像素,就乱了,这里指用flex布局,不要写死宽高。

  4. 禁忌写死
    样式避免使用!important写死样式。

  5. 范围
    必要时使用最大限制max或者min,防止外部没有定义,宽高超出组件内部。

  6. 托底
    书写托底的默认样式,且风格保持一致。
    我们定义页面一级父盒子,padding的留白统一为25像素。

  7. 语言
    建议统一使用scss书写,虽然目前组件库内部webpack配置会处理好几种css语言,但是,为统一代码,目前建议使用scss。

4. 关于mock数据结构

  1. 大众化
    数据结构大众化。即数据类型符合一般渲染逻辑;例如:轮播图使用数组结构、基本信息使用对象、图片地址使用字符串等

  2. 容错处理
    内部数据结构要紧凑、稳固,接收外部传入参数时,做各种格式化处理,此处包括(托底,容错,类型判断,防止因外部输入格式不对导致组件内部报错)。
    例如:入参是数组的要求,就需要判断来源数据类型,不是数组的,格式化为数组,不可以格式化的,直接走托底。

  3. 可扩展
    为后续组件扩展留余地,不把数据结构写的太死。例如二位数组处理,数据分组格式化等。

5. 关于变量参数

  1. 语义化
    例如:size,表示组件的大小)
    示例

  2. 多参
    只供组件内部使用的变量,可以内部定义。尽可能多的由外部传入参数,因为这样可以尽可能的做到可配置。

  3. 托底值
    不管是内部定义参数,还是外部传参都必须有默认值,即托底逻辑处理,不要存在必传参数,必要时做提示处理。

6. 关于方法暴露

  1. 内部方法暴露
    尽可能多的暴露方法,关于内部的一些方法定义,如果可以使组件更具灵活性,可以考虑将内部方法暴露出来。例如:功能强大的swiper组件,就暴露了很多内部定义的方法,供用户灵活的定制自己的组件。
    示例

  2. 业务逻辑不要有
    在方法中,一切业务逻辑不要有,尽可能的、可配置的透传参数,以便父级元素做数据处理。

  3. 命名
    方法名称命名标准:
    点击事件 **Click
    格式化方法 **Format
    初始化方法 **Init
    方法名称不怕长,就怕看不懂功能,分不清你我

三、示例解析

1.放大镜组件(vue)

组件示例

  • 组件内部代码
1
2
3
4
5
6
<template>
<div class="magnifier-box" :class="vertical?'vertical':''" :ref="id" @mousemove="mousemove" @mouseover="mouseover" @mouseleave="mouseleave">
<img v-show="showImg" :src="imgUrl" alt="">
<div class="mouse-cover"></div>
</div>
</template>
  • 组件变量定义
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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
export default {
props: {
scale: {
type: Number,
default: 2
},
scalebox: {
type: Number,
default: 1
},
url: {
type: String,
required: true
},
bigUrl: {
type: String,
default: null
},
scroll: {
type: Boolean,
default: false
}
},
data () {
return {
id: null,
cover: null,
imgbox: null,
imgwrap: null,
orginUrl: null,
bigImgUrl: null,
bigOrginUrl: null,
imgUrl: null,
img: null,
canvas: null,
ctx: null,
rectTimesX: 0,
rectTimesY: 0,
imgTimesX: 0,
imgTimesY: 0,
init: false,
step: 0,
bigStep: 0,
vertical: false,
showImg: true
}
},
created () {
var $chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678'; /****默认去掉了容易混淆的字符oOLl,9gq,Vv,Uu,I1****/
var maxPos = $chars.length
var str = ''
for (let i = 0; i < 10; i++) {
str += $chars.charAt(Math.floor(Math.random() * maxPos));
}
this.id = str
this.imgUrl = this.url
this.orginUrl = this.url
this.bigImgUrl = this.bigUrl
this.bigOrginUrl = this.bigUrl
},
watch: {
url: function (val) {
this.imgUrl = val
this.orginUrl = val
this.initTime()
},
bigUrl: function () {
this.bigImgUrl = bigUrl
this.bigOrginUrl = bigUrl
this.initTime()
}
},
mounted () {
this.$nextTick(() => {
this.initTime()
})
},
methods: {
initTime () {
this.init = false
let box = this.$refs[this.id]
this.imgbox = box
this.cover = box.querySelector('.mouse-cover')
this.cover.style.width = (this.imgbox.offsetWidth / this.scale) + 'px'
this.cover.style.height = (this.imgbox.offsetHeight / this.scale) + 'px'
this.cover.style.left = '-100%'
this.cover.style.top = '-100%'
this.imgwrap = box.querySelector('img')
let imgsrc;
if (this.bigImgUrl) {
imgsrc = this.bigImgUrl
} else {
imgsrc = this.imgUrl
}
this.img = new Image()
this.img.src = imgsrc
this.img.onload = () => {
this.vertical = this.img.width < this.img.height
this.init = true
}
if (this.canvas) {
this.canvas.parentNode.removeChild(this.canvas)
this.canvas = null
}
this.canvas = document.createElement('canvas')
this.canvas.className = 'mouse-cover-canvas'
this.canvas.style.position = 'absolute'
this.canvas.style.left = this.imgbox.offsetLeft + this.imgbox.offsetWidth + 10 + 'px'
this.canvas.style.top = this.imgbox.offsetTop + 'px'
this.canvas.style.border = '1px solid #eee'
this.canvas.style.zIndex = '99999'
this.canvas.height = this.imgbox.offsetHeight * this.scalebox
this.canvas.width = this.imgbox.offsetWidth * this.scalebox
this.canvas.style.display = 'none'
this.imgbox.parentNode.append(this.canvas)
this.ctx = this.canvas.getContext("2d")
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
},
mousemove (e) {
if (!this.init) {
return false
}
let _this = this
//获取实际的offset
function offset (curEle) {
var totalLeft = null, totalTop = null, par = curEle.offsetParent;
//首先加自己本身的左偏移和上偏移
totalLeft += curEle.offsetLeft;
totalTop += curEle.offsetTop
//只要没有找到body,我们就把父级参照物的边框和偏移也进行累加
while (par) {
if (navigator.userAgent.indexOf("MSIE 8.0")===-1) {
//累加父级参照物的边框
totalLeft += par.clientLeft;
totalTop += par.clientTop
}

//累加父级参照物本身的偏移
totalLeft += par.offsetLeft;
totalTop += par.offsetTop
par = par.offsetParent;
}

return {
left: totalLeft,
top: totalTop
}
}

function getXY (eve) {
return {
x: eve.clientX - (_this.cover.offsetWidth / 2),
y: eve.clientY - (_this.cover.offsetHeight / 2)
};
}
let oEvent = e || event;
let pos = getXY(oEvent);
let imgwrap = offset(this.imgwrap)
let range = {
minX: imgwrap.left,
maxX: imgwrap.left + this.imgwrap.offsetWidth - this.cover.offsetWidth,
minY: imgwrap.top - document.documentElement.scrollTop,
maxY: imgwrap.top - document.documentElement.scrollTop + this.imgwrap.offsetHeight - this.cover.offsetHeight,
}
if (pos.x > range.maxX) {
pos.x = range.maxX
}
if (pos.x < range.minX) {
pos.x = range.minX
}
if (pos.y > range.maxY) {
pos.y = range.maxY
}
if (pos.y < range.minY) {
pos.y = range.minY
}
this.cover.style.left = pos.x + 'px'
this.cover.style.top = pos.y + 'px'
this.ctx.clearRect(0, 0, this.imgwrap.offsetWidth, this.imgwrap.offsetHeight);
let startX = pos.x - (imgwrap.left - document.documentElement.scrollLeft),
startY = pos.y - (imgwrap.top - document.documentElement.scrollTop)
// 重新初始化图片的宽高
this.rectTimesX = (this.imgbox.offsetWidth / this.scale) / this.imgwrap.offsetWidth,
this.rectTimesY = (this.imgbox.offsetHeight / this.scale) / this.imgwrap.offsetHeight
this.imgTimesX = this.img.width / this.imgwrap.offsetWidth,
this.imgTimesY = this.img.height / this.imgwrap.offsetHeight
this.ctx.drawImage(
this.img,
startX * this.imgTimesX,
startY * this.imgTimesY,
this.img.width * this.rectTimesX,
this.img.height * this.rectTimesY,
0,
0,
this.canvas.width,
this.canvas.height
);
},
mouseover (e) {
if (!this.init) {
return false
}
e = e || event
if (!this.scroll) {
e.currentTarget.addEventListener('mousewheel', function (ev) {
ev.preventDefault()
}, false)

e.currentTarget.addEventListener('DOMMouseScroll', function (ev) {
ev.preventDefault()
}, false)
}

this.cover.style.display = 'block'
this.canvas.style.display = 'block'
},
mouseleave () {
if (!this.init) {
return false
}
this.cover.style.display = 'none'
this.canvas.style.display = 'none'
},

}
}
</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
29
30
31
32
33
34
35

<style lang="scss" scoped>
.magnifier-box{
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
position: relative;
img{
width: 100%;
/*height: 100%;*/
};
.mouse-cover{
position: fixed;
background-color: rgba(255,255,255,0.5);
cursor:move
};
.mouse-cover-canvas{
position:fixed;
left:100%;
top:0;
width:100%;
height:100%;
}
&.vertical{
img{
height: 100%;
width: auto
}
}
}
</style>

2.消息框组件(eleemnt-ui)

  • 组件Dom
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
71
<template>
<transition name="msgbox-fade">
<div
class="el-message-box__wrapper"
tabindex="-1"
v-show="visible"
@click.self="handleWrapperClick"
role="dialog"
aria-modal="true"
:aria-label="title || 'dialog'">
<div class="el-message-box" :class="[customClass, center && 'el-message-box--center']">
<div class="el-message-box__header" v-if="title !== null &&title">
<div class="el-message-box__title">
<div
:class="['el-message-box__status', icon]"
v-if="icon && center">
</div>
<span>{{ title }}</span>
</div>
</div>
<div class="el-message-box__content">
<div
:class="['el-message-box__status', icon]"
v-if="icon && !center && message !== ''">
</div>
<div class="el-message-box__message" v-if="message !== ''">
<slot>
<div v-if="!dangerouslyUseHTMLString">
<em>{{ mainMessage }}</em>
<p>{{ message }}</p>
</div>
<p v-else v-html="message"></p>
</slot>
</div>
<div class="el-message-box__input" v-show="showInput">
<el-input
v-model="inputValue"
:type="inputType"
@keydown.enter.native="handleInputEnter"
:placeholder="inputPlaceholder"
ref="input"></el-input>
<div class="el-message-box__errormsg" :style="{ visibility: !!editorErrorMessage ? 'visible' : 'hidden' }">{{ editorErrorMessage }}</div>
</div>
</div>
<div class="el-message-box__btns">
<el-button
:loading="cancelButtonLoading"
:class="[ cancelButtonClasses ]"
v-if="showCancelButton"
:round="roundButton"
size="small"
@click.native="handleAction('cancel')"
@keydown.enter="handleAction('cancel')">
{{ cancelButtonText || t('el.messagebox.cancel') }}
</el-button>
<el-button
:loading="confirmButtonLoading"
ref="confirm"
:class="[ confirmButtonClasses ]"
v-show="showConfirmButton"
:round="roundButton"
size="small"
@click.native="handleAction('confirm')"
@keydown.enter="handleAction('confirm')">
{{ confirmButtonText || t('el.messagebox.confirm') }}
</el-button>
</div>
</div>
</div>
</transition>
</template>
  • 组件变量定义
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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
props: {
modal: {
default: true
},
lockScroll: {
default: true
},
showClose: {
type: Boolean,
default: true
},
closeOnClickModal: {
default: true
},
closeOnPressEscape: {
default: true
},
closeOnHashChange: {
default: true
},
center: {
default: false,
type: Boolean
},
roundButton: {
default: false,
type: Boolean
}
},

components: {
ElInput
},

computed: {
icon() {
const { type, iconClass } = this;
return iconClass || (type && typeMap[type] ? `el-icon-${ typeMap[type] }` : '');
},

confirmButtonClasses() {
return `el-button--primary ${ this.confirmButtonClass }`;
},
cancelButtonClasses() {
return `${ this.cancelButtonClass }`;
}
},

methods: {
doClose() {
if (!this.visible) return;
this.visible = false;
this._closing = true;

this.onClose && this.onClose();
messageBox.closeDialog(); // 解绑

this.opened = false;
this.doAfterClose();
setTimeout(() => {
if (this.action) this.callback(this.action, this);
});
},

handleWrapperClick() {
if (this.closeOnClickModal) {
this.handleAction(this.distinguishCancelAndClose ? 'close' : 'cancel');
}
},

handleInputEnter() {
if (this.inputType !== 'textarea') {
return this.handleAction('confirm');
}
},

handleAction(action) {
if (this.$type === 'prompt' && action === 'confirm' && !this.validate()) {
return;
}
this.action = action;
if (typeof this.beforeClose === 'function') {
this.close = this.getSafeClose();
this.beforeClose(action, this, this.close);
} else {
this.doClose();
}
},

getFirstFocus() {
const btn = this.$el.querySelector('.el-message-box__btns .el-button');
const title = this.$el.querySelector('.el-message-box__btns .el-message-box__title');
return btn || title;
},
getInputElement() {
const inputRefs = this.$refs.input.$refs;
return inputRefs.input || inputRefs.textarea;
}
},

watch: {
inputValue: {
immediate: true,
handler(val) {
this.$nextTick(_ => {
if (this.$type === 'prompt' && val !== null) {
this.validate();
}
});
}
}
},

data() {
return {
uid: 1,
title: undefined,
mainMessage: '',
message: '',
type: '',
iconClass: '',
customClass: '',
showInput: false,
inputValue: null,
inputPlaceholder: '',
inputType: 'text',
inputPattern: null,
inputValidator: null,
inputErrorMessage: '',
showConfirmButton: true,
showCancelButton: false,
action: '',
confirmButtonText: '',
cancelButtonText: '',
confirmButtonLoading: false,
cancelButtonLoading: false,
confirmButtonClass: '',
confirmButtonDisabled: false,
cancelButtonClass: '',
editorErrorMessage: null,
callback: null,
dangerouslyUseHTMLString: false,
focusAfterClosed: null,
isOnComposition: false,
distinguishCancelAndClose: false
};
}
  • 组件样式书写参考
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
.el-message-box{
border-radius: 2px;
em{
font-size: 14px;
color: $color-title;
line-height: 15px;
}
p{
font-size: 12px;
color: $color-default;
line-height: 15px;
}
&__title{
font-size: 14px;
color: $color-title;
line-height: 20px;
font-weight: bold;
}
&__header{
padding: 9px 15px;
background: $color-bg;
border-bottom: 1px solid $color-border;
}
&__headerbtn{
top: 12px;
}
&__content{
padding: 28px 30px;
}
&__status{
font-size: 30px;
margin-right: 10px;
&.el-icon-warning{
color: $color-warning;
}
&.el-icon-success{
color: $color-success;
}
&.el-icon-info{
color: $color-info;
}
&.el-icon-error{
color: $color-error;
}
&+.el-message-box__message{
padding-left: 40px;
}
}
&__btns{
margin-top: 12px;
margin-bottom: 5px;
}
}

总结

相信任何一个好的组件,都是经过实践历练,调试出来的,开始封装的组件没有那么多功能,没有那么完善不要灰心,坚持用,不好的地方我们可以在使用中改,经过多次使用的组件,一定会慢慢丰富和强健起来。最终可能经过3,5年时间,我们做的的组件库,可以达到项目的70%利用率,同时,我们的开发效率也会大大提高。代码更强健。
最后,本文档作为前端组件基本标准,我们会越来越细化它的要求。希望大家一起献计献策,完善我们的组件封装标准。