在平时写业务或者是写玩具的时候为了方便,我们会使用各种各样的组件库。虽然说基本需求看文档就可以了,但是文档中提供的方法和业务需求相比肯定是有一定差距的,这时候就需要自己封装组件了;并且,在写了一些代码后感觉,其实在不同的项目中写过功能差不多相同的代码,那为什么不封装一下方便以后、或者是其他人使用呢?写这篇博客的时候参考了b站up主樱满空的视频。
文章内容会不断的更新,每一节内容分为
假设现在你已经在项目中安装了element-ui,此时打开node_modules目录往下翻,可以看到一个名为element-ui的文件夹。
package.json
中指定 typing 字段的值为 声明的入口文件才能生效。在分析packegs文件夹中的各个组件源码之前,我们先看看src中的入口文件index.js。
// 部分引入
import Pagination from '../packages/pagination/index.js';
import Dialog from '../packages/dialog/index.js';
import Autocomplete from '../packages/autocomplete/index.js';
import Dropdown from '../packages/dropdown/index.js';
import DropdownMenu from '../packages/dropdown-menu/index.js';
// 将引入的文件名放在一个数组中
const components = [
Pagination,
Dialog,
Autocomplete,
Dropdown,
DropdownMenu
]
// Element暴露出去一个install函数,Element本身就是一个插件
const install = function(Vue, opts = {}) {
locale.use(opts.locale);
locale.i18n(opts.i18n);
// 通过对组件使用forEach方法,将所有的组件进行注册
components.forEach(component => {
Vue.component(component.name, component);
});
Vue.use(InfiniteScroll);
Vue.use(Loading.directive);
Vue.prototype.$ELEMENT = {
size: opts.size || '',
zIndex: opts.zIndex || 2000
};
Vue.prototype.$loading = Loading.service;
Vue.prototype.$msgbox = MessageBox;
Vue.prototype.$alert = MessageBox.alert;
Vue.prototype.$confirm = MessageBox.confirm;
Vue.prototype.$prompt = MessageBox.prompt;
Vue.prototype.$notify = Notification;
Vue.prototype.$message = Message;
};
我们在调用Vue.use(ElementUI)
注册时,本质上就是调用这个install
函数。由于Vue.use接收一个对象,这个对象必须具有install方法,Vue.use函数内部会调用参数的install方法。如果插件没有被注册过,那么注册成功之后会给插件添加一个installed的属性值为true。Vue.use方法内部会检测插件的installed属性,从而避免重复注册插件。
插件的install方法将接收两个参数,第一个是参数是Vue,第二个参数是配置项options(就是这里的opts)对象。
从Vue.prototype.$ELEMENT
这一句来看,传入的参数可以有size
和zIndex
属性,size
用于改变组件的默认尺寸,zIndex
设置弹框的初始 z-index(默认值:2000)。我们可以手动向options中传入size和zInde,保存到Vue.prototype.$ELEMENT
全局配置中,这样在组件中我们就可以根据size和zIndex进行不同组件尺寸的展示。
import Element from 'element-ui';
Vue.use(Element, { size: 'small', zIndex: 3000 });
在入口文件中我们可以通过forEach
循环遍历进行大部分组件的注册,小部分如InfiniteScroll
和Loading
在全局注册指令,通过v-infinite-scroll
和v-loading
等指令式来调用;也有如msgbox
、alert
等在全局Vue.prototype添加方法,可以通过函数进行调用。
先看看基础布局里面提供的代码,对于Layout布局的部分,我们需要使用到el-row和el-col的嵌套子组件。
<el-row>
<el-col :span="24"><div class="grid-content bg-purple-dark">div>el-col>
el-row>
<el-row>
<el-col :span="12"><div class="grid-content bg-purple">div>el-col>
<el-col :span="12"><div class="grid-content bg-purple-light">div>el-col>
el-row>
打开路径node_modules -> element-ui -> package -> el-row -> src -> row.js
查看组件的逻辑和页面部分
打开路径node_modules -> element-ui -> packages -> theme-chalk -> src -> row.scss
查看el-row的样式部分
查阅官方文档查看el-row有那些属性
和传统的vue文件中template模板不同的是,el-row组件是以渲染函数的方式编写的
export default {
// 组件名
name: 'ElRow',
// 这个选项并非Vue官方提供的API,而是Element团队自定义的属性
// 在查阅 Vue2 官方文档的时候可以看到,vm.$options的api可以用于当前Vue实例的初始化选项
// 所有我们写的Vue选项都会放到Vue实例属性$options中
// 比如之后可以通过this.$options.componentName获取到这里的属性值
componentName: 'ElRow',
props: {
tag: {
type: String,
default: 'div'
},
gutter: Number,
type: String,
justify: {
type: String,
default: 'start'
},
align: String
},
computed: {
style() {
const ret = {};
if (this.gutter) {
ret.marginLeft = `-${this.gutter / 2}px`;
ret.marginRight = ret.marginLeft;
}
return ret;
}
},
render(h) {
return h(this.tag, {
class: [
'el-row',
this.justify !== 'start' ? `is-justify-${this.justify}` : '',
this.align ? `is-align-${this.align}` : '',
{ 'el-row--flex': this.type === 'flex' }
],
style: this.style
}, this.$slots.default);
}
};
tag:用来自定义元素标签,默认是div。我们可以看到tag属性用在了render函数中,render函数的参数h就是createElement函数的别名,也就是说默认情况下每个渲染出来的el-row是一个div。
type、justify、align:这三个属性都与flex布局相关,type属性可选flex布局,后面两个属性用于垂直水平的布局,在render函数中查看class,可以通过垂直水平的属性判断元素所在的位置。顺便介绍下render函数的最后一个参数,this.$slots.default
用的是default插槽的内容,也就是在el-row标签中写的内容。
gutter:列间距。这个属性用在了computed中计算style,在我们手动传入了gutter的情况下,会给el-row左右两侧各添加一个gutter值除以2的负外边距。这么做是因为 el-col 的左右两侧都会添加一个gutter除以2的内边距。如果不追加这个负外边距的话会导致行的左右两侧也有间距,导致el-col无法和外层元素边缘对齐。
至于为什么需要这样做,我们可以看看这个例子
<div class="box">
<div class="son">div>
test
<div class="son">div>
div>
<style>
body{
background-color: coral;
}
.box {
width: 100%;
height: 300px;
background-color: aquamarine;
}
.son {
height: 100px;
background-color: black;
}
style>
现在为son设置一个margin-top:100px看看,可以明显的看到,父元素的box元素也被强制向下移动了100px
现在为父元素设置margin-top: -100px,整体元素就成功上移了。所以说在el-row中添加负外边距是为了保证子元素设置外边距时,不会影响整体行位置上的改变。
打开row.scss文件
@import "common/var";
@import "mixins/mixins";
@import "mixins/utils";
@include b(row) {
position: relative;
box-sizing: border-box;
@include utils-clearfix;
@include m(flex) {
display: flex;
&:before,
&:after {
display: none;
}
@include when(justify-center) {
justify-content: center;
}
@include when(justify-end) {
justify-content: flex-end;
}
@include when(justify-space-between) {
justify-content: space-between;
}
@include when(justify-space-around) {
justify-content: space-around;
}
@include when(align-top) {
align-items: flex-start;
}
@include when(align-middle) {
align-items: center;
}
@include when(align-bottom) {
align-items: flex-end;
}
}
}
在第四行中我们可以看到使用了@include指令,这个指令是搭配mixin使用的,现在它混入了一个名为b的mixin,并且传递了一个参数值为row,接下来通过@import "mixins/mixins"点进去查看,这个名为b的mixin做了什么工作。
/* BEM
-------------------------- */
@mixin b($block) {
$B: $namespace+'-'+$block !global;
.#{$B} {
@content;
}
}
在这个混入中首先定义了一个$B的变量,值是namespace + ‘-’ + 传入的变量。这个namespace在config.scss中有定义
$namespace: 'el';
$element-separator: '__';
$modifier-separator: '--';
$state-prefix: 'is-';
对于这些定义需要了解一下采用BEM的Class命名风格
现在我们回到b的mixin中,#{}是使用变量定义的意思,这里用$B的值定义了一个class,其中使用了@content来将我们使用混入时写在大括号中间的内容放到这个class中。
@include b(row) {
position: relative;
box-sizing: border-box;
...
}
最终渲染到页面上名为el-row的class中。
接着row.scss往下看,又include了一个混入 @include utils-clearfix
,首部utils-名称表示我们需要到utils.scss中来看。
@mixin utils-clearfix {
$selector: &;
@at-root {
#{$selector}::before,
#{$selector}::after {
display: table;
content: "";
}
#{$selector}::after {
clear: both
}
}
}
从display:tabel和clear:both可以明显的看出来该方法用于清除浮动。
回到row.scss,发现存在一个名为m的混入@include m(flex) ,通过mixin.scss中我们可以看到,这个是生成修饰符class用的混入,使用@each遍历我们传入的修饰符,生成class名,然后拼接每个修饰符class,最后把给混入传递的内容放到这些class中。
@mixin m($modifier) {
$selector: &;
$currentSelector: "";
@each $unit in $modifier {
$currentSelector: #{$currentSelector + & + $modifier-separator + $unit + ","};
}
@at-root {
#{$currentSelector} {
@content;
}
}
}
这里的@at-root是让后面的样式跳出目前层级到顶层,由于我们之前在el-row的class下调用m混入,所以默认会把这些样式添加父类选择器el-row。使用@at-root后,这些修饰符选择器就可以和el-row class平级了。
最后还剩下一堆when的混入,其实就是用来生成is-开头表示状态用的class
@mixin when($state) {
@at-root {
&.#{$state-prefix + $state} {
@content;
}
}
}
span表示列宽,默认情况下一列的宽度为24占一整行
offset、pull、push表示栅格在一行中的便宜位置
xs、sm、md、lg、xl用于表示响应式布局,他们可以接收Number或者是Object类型。接收Number类型的时候相当于对应画面大小时的span;接收Object类型时对象的键可以是span、offset、pull、push
在计算属性中有一个gutter属性,通过this.$parend获取到当前el-col的父组件,下面的while循环表示,在el-col的祖先结点中查找到离当前节点距离最近的el-row组件。如果找到了最近的el-col祖先组件,就返回父组件身上所绑定的gutter值,否则为0,Element经常使用这个方法去查找某个组件的最近祖先元素,比如el-from,el-form-item。
computed: {
gutter() {
let parent = this.$parent;
while (parent && parent.$options.componentName !== 'ElRow') {
parent = parent.$parent;
}
return parent ? parent.gutter : 0;
}
}
通过props属性以及计算属性,可以在render函数中渲染结点
render(h) {
let classList = [];
let style = {};
if (this.gutter) {
style.paddingLeft = this.gutter / 2 + 'px';
style.paddingRight = style.paddingLeft;
}
['span', 'offset', 'pull', 'push'].forEach(prop => {
if (this[prop] || this[prop] === 0) {
classList.push(
prop !== 'span'
? `el-col-${prop}-${this[prop]}`
: `el-col-${this[prop]}`
);
}
});
['xs', 'sm', 'md', 'lg', 'xl'].forEach(size => {
// 如果传递进来进行响应式处理的是具体的数字
if (typeof this[size] === 'number') {
classList.push(`el-col-${size}-${this[size]}`);
}
// 如果传递进来的是一个数组
else if (typeof this[size] === 'object') {
let props = this[size];
// 通过Object.keys()拿到键值对,其实感觉用for...in...会更方便
Object.keys(props).forEach(prop => {
classList.push(
prop !== 'span'
? `el-col-${size}-${prop}-${props[prop]}`
: `el-col-${size}-${props[prop]}`
);
});
}
});
return h(this.tag, {
class: ['el-col', classList],
style
}, this.$slots.default);
}
在theme-chalk文件夹下找到col.scss文件,在文件的起始部分为每一个el-col开头的元素设置了左浮动和border-box属性
[class*="el-col-"] {
float: left;
box-sizing: border-box;
}
接下来通过一个循环从0-24设置span、offset、pull、push的样式
// span为0的样式会额外设置一个display:none
.el-col-0 {
display: none;
}
@for $i from 0 through 24 {
.el-col-#{$i} {
width: (1 / 24 * $i * 100) * 1%;
}
.el-col-offset-#{$i} {
margin-left: (1 / 24 * $i * 100) * 1%;
}
.el-col-pull-#{$i} {
position: relative;
right: (1 / 24 * $i * 100) * 1%;
}
.el-col-push-#{$i} {
position: relative;
left: (1 / 24 * $i * 100) * 1%;
}
}
紧接着的会通过名为res的混入生成各个size的响应式布局样式
@include res(xs) {
.el-col-xs-0 {
display: none;
}
@for $i from 0 through 24 {
.el-col-xs-#{$i} {
width: (1 / 24 * $i * 100) * 1%;
}
...
}
}
我们来看一下这个名为res的混入,这个混入接收两个参数,$key表示传入响应式的key值(xs、sm等)
/* Break-points
-------------------------- */
@mixin res($key, $map: $--breakpoints) {
// 循环断点Map,如果存在则返回
@if map-has-key($map, $key) {
@media only screen and #{inspect(map-get($map, $key))} {
@content;
}
} @else {
@warn "Undefeined points: `#{$map}`";
}
}
如果没有传入第二个参数 m a p , 会 使 用 默 认 值 map,会使用默认值 map,会使用默认值–breakpoints
/* Break-point
--------------------------*/
$--sm: 768px !default;
$--md: 992px !default;
$--lg: 1200px !default;
$--xl: 1920px !default;
$--breakpoints: (
'xs' : (max-width: $--sm - 1),
'sm' : (min-width: $--sm),
'md' : (min-width: $--md),
'lg' : (min-width: $--lg),
'xl' : (min-width: $--xl)
);
回到res混入中,通过@if
和 scss内置的函数方法map-has-key
判断key值是否在 m a p 中 , 如 果 k e y 值 在 map中,如果key值在 map中,如果key值在map中存在,就会生成一个媒体查询。这里的inspect也是scss的内置函数,用来将变量值转换为字符串形式;map-get函数用来获取map中key所对应的value值。
举个栗子,当我们使用
<el-col :xs="8" :sm="6" :md="4" :lg="3" :xl="1"><div class="grid-content bg-purple-light">div>el-col>
就会分别生成max-width: 765px,min-width: 766px、992px、1200px、1920px的媒体查询,根据画面的不同展示不同的样式,他们对应每一列的宽度也会不一样。
官方文档给定el-row的最大宽度有24列,现在如果将其扩展为48列应该如何操作呢?
在前面分析样式文件中,每一列的宽度都是在一个从1至24的循环中设置,最小列宽为 (1 / 24 * 1 * 100) * 1% = (1 / 24)%,如果想要扩展列数,我们只需要将循环的上限扩大为48,以及每一列改为 (1 / 48 * i * 100)* 1%即可。
这样做似乎不行,在页面上显示的时候仍按照24的宽度,期待大佬分享正确改法。
思路的确是这样的,还记得最开始分析目录结构的时候介绍的lib文件夹吗,通过element-ui->lib->theme-chalk->col.css
可以看到打包完成的element col的样式。
可以明显的看到列的最大值为24,具体每一列的宽度也计算好了放在文件的后面。而我之前一直是在scss文件中去修改它的循环条件,计算结果最终却没有重新打包,即引用的是未经过打包的、没有修改的css文件,所以最终导致无法显示。也许你会想,那直接修改打包后的文件可以吗?答案是不行,vue项目中的node-module->element-ui
文件夹中没有build文件夹。所以无法直接修改项目中的element-ui。
首先将ElementUI的源码clone下来并安装依赖
git clone https://github.com/ElemeFE/element.git
cd element
npm install
然后在packages文件夹中去修改目标文件的源代码结构以及theme-chalk下的样式,修改完毕后执行npm run dist进行打包
使用dist
打包原因来自官方文档:https://github.com/ElemeFE/element/blob/master/.github/CONTRIBUTING.zh-CN.md
打包结束会生成一个lib文件夹,将他替换掉项目中node_modules->element-ui
下的lib文件夹即可(我之前使用的是element 12+,打包后lib文件夹中只有一个index.js,将版本回退到2.4.5的时候打包结果和项目中文件结构相同)
打包的过程中如果你的 node版本 ≥ 12.0 并且 gulp版本 < 4.0,会遇到下面的报错
面对版本冲突错误,我选择升级gulp的版本,方法可以看gulp官方文档:https://gulpjs.com/docs/en/getting-started/quick-start/
在stackoverflow上面看到了另外一种解决办法:https://stackoverflow.com/questions/55921442/how-to-fix-referenceerror-primordials-is-not-defined-in-node-js,通过在package.json文件里修改配置实现兼容
解决版本冲突后重新打包,将打包后的lib文件夹,替换掉项目中lib文件夹后重启项目,就可以正常使用了。
用于布局的容器组件,方便快速搭建页面的基本结构
打开pakages文件找到contianer组件所在的位置,可以看到这个组件是通过模板编写的
// 如果存在插槽 && 存在默认插槽
return this.$slots && this.$slots.default
? this.$slots.default.some(vnode => {
// 1 && function, 会执行后面的函数
// 0 && function, 不会执行
const tag = vnode.componentOptions && vnode.componentOptions.tag;
// 对结点进行判断是否是el-header || el-footer
return tag === 'el-header' || tag === 'el-footer';
})
: false;
@import "mixins/mixins";
@include b(container) {
display: flex;
flex-direction: row;
flex: 1;
flex-basis: auto;
box-sizing: border-box;
min-width: 0;
@include when(vertical) {
flex-direction: column;
}
}
首先看到的还是首先将contianer传入混入b,最终生成el-container的类名以及属性。整体来看属性值就是border-box盒子模型以及flex布局,需要多介绍的是min-width
属性,使用了这个属性可以让外部盒子元素缩短到比元素中的内容还短。
之后是when混入,负责生成is-vertical
的状态判断属性,这里是将元素设置为列排序。
在container布局里面剩下的几个组件(el-header/aside/footer/main)内容相仿,所以单独挑出main进行举例介绍。
el-header、aside、footer都会接收height或者是width的prop用来设置内联样式。如果不想设置这些属性,你也可以显示的将这个属性设置为null,这样每一个组件的大小就可以由子内容撑开。
@import "mixins/mixins";
@import "common/var";
@include b(main) {
// IE11 supports the element partially https://caniuse.com/#search=main
display: block;
flex: 1;
flex-basis: auto;
overflow: auto;
box-sizing: border-box;
padding: $--main-padding;
}
el-aside、main将overflow设置为auto,使内容超出容器的情况下显示滚动条而不是溢出。
Button是常用的操作按钮,我们经常会对Button组件进行封装
在进行props属性分析之前我们先看看 script 部分。
这里复习一下inject和provide的使用方法。首先看看官网API中的定义
单纯看概念可能还是会有些生疏,所以借助代码实例来理解:
// 父级组件提供 'foo'
// 父组件通过provide提供一个对象,这个对象的key是foo,value是'bar'
var Provider = {
provide: {
foo: 'bar'
},
// ...
}
// 子组件注入 'foo'
// inject注入字符串数组,数组项中存放的就是provide提供的key,之后再子组件中通过this.key可以拿到父组件中的value
var Child = {
inject: ['foo'],
created () {
console.log(this.foo) // => "bar"
}
// ...
}
// inject注入一个对象,本地的key名是foo,value的默认值是待注入的 'foo'
// 需要注意的是通过inject注入属性的时候,default的value值也需要加上单引号来表示,引用的是某个provide提供的key
const Child2 = {
inject: {
foo: { default: 'foo' }
}
// ...
}
// 如果在不同的组件中提供了相同名字的 provide,
// 在子组件注册的时候可以使用 from 来表示其源 property
const Child3 = {
inject: {
foo: {
from: 'bar',
default: 'foo'
}
}
// ...
}
所以在这里,最开始的时候通过inject
注入了el-form和el-form-item的组件实例本身(因为他们provide都是自身的this),如果之后需要使用他们身上属性的话,直接通过 this.elForm.xxx | this.elFormItem.xxx就可以直接使用了,仿佛是在button组件中直接操作form-item中的数据,也的确如此!
element的表单控件组件基本上都会使用这种方式来访问form或者from-item身上的实例属性或者是方法。
// button.vue
inject: {
elForm: {
default: ''
},
elFormItem: {
default: ''
}
}
// el-form-item.vue
provide() {
return {
elFormItem: this
};
}
props里面接收到的参数:
el-button-${type}
属性'is-'
属性
:class="[ 普通的css | 三目表达式的css , { 通过bool值判断是否添加的css }]"
接下来分析computed中的计算属性
computed: {
_elFormItemSize() {
return (this.elFormItem || {}).elFormItemSize;
},
buttonSize() {
return this.size || this._elFormItemSize || (this.$ELEMENT || {}).size;
},
buttonDisabled() {
return this.disabled || (this.elForm || {}).disabled;
}
},
// el-form-item.vue 里的 computed
_formSize() {
return this.elForm.size;
},
elFormItemSize() {
return this.size || this._formSize;
},
buttonSize:
Vue.use(Element, { size: 'small', zIndex: 3000 })
,这也是为什么最后写的是(this.$ELEMENT || {}).sizebuttonDisabled:现在我们看到的是2.4.5版本的Element-ui,该属性会首先获取button->props身上的disabled属性,如果为false,向上查找注入的elForm身上的disabled属性。
buttonDisabled() {
return this.$options.propsData.hasOwnProperty('disabled') ?
this.disabled :
(this.elForm || {}).disabled
}
这样修改的话会首先判断实例button身上的$options.propsData是否有自有属性disabled,来判断组件是否被传入了disabled属性,如果传入了就使用elForm身上的disabled属性。