分析Element-ui封装思想
在平时写业务或者是写玩具的时候为了方便,我们会使用各种各样的组件库。虽然说基本需求看文档就可以了,但是文档中提供的方法和业务需求相比肯定是有一定差距的,这时候就需要自己封装组件了;并且,在写了一些代码后感觉,其实在不同的项目中写过功能差不多相同的代码,那为什么不封装一下方便以后、或者是其他人使用呢?
写这篇博客的时候非常感谢b站up主樱满空,他的分析非常的清晰!可以去看看这位up主的视频讲解
https://space.bilibili.com/1842032?spm_id_from=333.788.b_765f7570696e666f.2
文章内容会不断的更新,每一节内容分为
希望可以在明年寒假开学前完成。
目录结构分析
假设现在你已经在项目中安装了element-ui,此时打开node_modules目录往下翻,可以看到一个名为element-ui的文件夹。
- lib文件夹存放element-ui打包后的文件,也就是项目实际依赖了的文件
- packages文件夹存放组件相关的源代码,也是之后源码分析的主要目标。
- src文件夹存放了如指令、混入、工具方法等源代码
- types文件夹存放了ts的类型声明文件,方便引入 typescript 写的项目中,需要在
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
]
const install = function(Vue, opts = {}) {
locale.use(opts.locale);
locale.i18n(opts.i18n);
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添加方法,可以通过函数进行调用。
组件分析
在这一节中我将按照Element-ui官网上的顺序逐一分析组件。
Layout布局
先看看基础布局里面提供的代码,对于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>
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);
}
};
props属性分析
-
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命名风格
- 组件名放在最前面,比如el-row,el-col,el-input
- 如果说是组件的子元素样式,会用组件名加上两个下划线再加上元素名,比如el-input__inner
- 修饰符接在最后,用两个中横线与前面的隔开,比如el-button–primary
- 表示状态的前缀 is-,比如表示禁用状态的样式is-disabled
现在我们回到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;
}
}
}
el-col
el-col和el-row一样,都是通过渲染函数编写的。
props属性分析
-
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(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文件夹后重启项目,就可以正常使用了。
|