阅读 1915

跟着Element源码学组件化

  ElementUI作为当前最流行的Vue组件库,以其丰富多样的组件和可配置化程度高吸引着众多的开发者;今天我们就深入它的源码,看一下它内部众多组件究竟是如何来实现的,以便我们在今后的开发中写出通用化程度更高的组件,同时也加深对其组件用法的理解。

本文首发于公众号【前端壹读】,更多精彩内容敬请关注公众号最新消息。

  老规矩,还是先来看一下官网的slogan:

Element,一套为开发者、设计师和产品经理准备的基于Vue 2.0的桌面端组件库

  可以看出,Element的使用范围涵盖了大部分的研发人员;产品经理可以用来参考逻辑交互,设计师可以借鉴图标和组件设计,开发者可以使用它来布局页面。

项目结构

  我们从github将整个项目clone下来后,来看一下有哪些目录文件:

|-- .github # 存放贡献指南以及issue、PR模板
|-- build # 存放打包工具的配置文件
|-- examples # 存放组件示例
|-- packages # 存放组件源码,也是我们分析的主要目录
|-- src # 存放入口文件以及各种工具文件
    |-- directives # 滚轮优化和避免重复点击
    |-- locale # 国际化功能
    |-- mixins # 混入实例
    |-- transition # 过度效果
    |-- utils # 工具文件
|-- test # 存放单元测试文件
|-- types # 存放typescript声明文件
|-- components.json # 完整组件列表
复制代码

  因此packages和src目录是我们需要关注的两个重要的目录;大致了解了目录结构后,下一个需要关注的就是package.json文件,这个文件包括了一些项目描述、项目依赖以及脚本命令等;有时候我们第一眼找不到项目的入口文件,就可以从这里来找。

  首先是"main":"lib/element-ui.common.js",main字段定义了npm包的入口文件,我们在项目中require("element-ui"),其实就是引用了element-ui.common.js文件;然后我们来看一下有哪些脚本命令,这里引用了重要的几个命令:

{
  "scripts": {
    "build:file": "node build/bin/iconInit.js & node build/bin/i18n.js",
    "build:theme": "gulp build --gulpfile packages/theme-chalk/gulpfile.js",
    "build:utils": "cross-env BABEL_ENV=utils babel src --out-dir lib --ignore src/index.js",
    "build:umd": "node build/bin/build-locale.js",
    "dist": "webpack --config build/webpack.conf.js && webpack --config build/webpack.common.js && webpack --config build/webpack.component.js"
  }
}
复制代码

  可以看出来前面的几个build命令是用来构建一些工具、样式的,主要是dist命令,通过webpack进行打包,还进行了三次打包,我们分别来看下这三次打包分别是打包什么文件的;首先我们来看下前两个配置文件webpack.conf.js和webpack.common.js,这里只截取配置文件的部分代码:

//build/webpack.conf.js
module.exports = {
  mode: 'production',
  entry: {
    app: ['./src/index.js']
  },
  output: {
    path: path.resolve(process.cwd(), './lib'),
    publicPath: '/dist/',
    filename: 'index.js',
    chunkFilename: '[id].js',
    libraryTarget: 'umd',
    libraryExport: 'default',
    library: 'ELEMENT',
    umdNamedDefine: true,
    globalObject: 'typeof self !== \'undefined\' ? self : this'
  },
  optimization: {
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          output: {
            comments: false
          }
        }
      })
    ]
  },
}
//build/webpack.common.js
module.exports = {
  mode: 'production',
  entry: {
    app: ['./src/index.js']
  },
  output: {
    path: path.resolve(process.cwd(), './lib'),
    publicPath: '/dist/',
    filename: 'element-ui.common.js',
    chunkFilename: '[id].js',
    libraryExport: 'default',
    library: 'ELEMENT',
    libraryTarget: 'commonjs2'
  },
  optimization: {
    minimize: false
  },
}
复制代码

  发现两个文件的入口都是src/index.js,不同的是webpack.conf.js打包的是umd规范,而且通过minimizer进行了压缩;而webpack.common.js打包的是commonjs规范,并且没有进行压缩;通过两种规范来打包的主要原因也是因为Element安装方式的不同,umd规范主要针对CDN引入的方式,在页面上引入js和css:

<!-- 引入样式 -->
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<!-- 引入组件库 -->
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
复制代码

  而webpack.common.js打包出来的element-ui.common.js则是针对npm的引入方式:

import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
复制代码

  那么最后一个build/webpack.component.js也不难猜到了,是为了在npm引入时,只引入需要的部分组件,而不对整体进行打包:

const Components = require('../components.json');
module.exports = {
  mode: 'production',
    entry: Components,
    output: {
    path: path.resolve(process.cwd(), './lib'),
    publicPath: '/dist/',
    filename: '[name].js',
    chunkFilename: '[id].js',
    libraryTarget: 'commonjs2'
  },
  optimization: {
    minimize: false
  },
}
复制代码

  这里components.json就是每个组件所在的入口文件。

入口文件

  我们在上面一节通过查看webpack的配置文件找到了入口文件/src/index.js,那么我们就来看一下Element在入口是如何来注册这么多组件的。

//截取了部分组件
import Button from '../packages/button/index.js';
import Input from '../packages/input/index.js';
import MessageBox from '../packages/message-box/index.js';
import Loading from '../packages/loading/index.js';
import InfiniteScroll from '../packages/infinite-scroll/index.js';
import Notification from '../packages/notification/index.js';
import Message from '../packages/message/index.js';

const components = [
  Button,
  Input
]
const install = function(Vue, opts = {}) {
  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;
};
//浏览器环境自动调用注册组件
if (typeof window !== 'undefined' && window.Vue) {
  install(window.Vue);
}
export default {
  install,
  Button,
  Input,
  MessageBox,
  Notification
}
复制代码

  这里Element暴露出去一个install函数,这是因为Element本身就是一个插件,我们在调用Vue.use(ElementUI)注册时,本质上就是调用这个install函数;那么Vue.use是如何注册插件的呢?

  1. Vue.use接收一个对象,这个对象必须具有install方法,Vue.use函数内部会调用参数的install方法。
  2. 如果插件没有被注册过,那么注册成功之后会给插件添加一个installed的属性值为true。Vue.use方法内部会检测插件的installed属性,从而避免重复注册插件。
  3. 插件的install方法将接收两个参数,第一个是参数是Vue,第二个参数是配置项options。
  4. 在install方法内部可以添加全局方法或者属性、全局指令、mixin混入、添加实例方法、使用Vue.component()注册组件等。

  在Element的install函数中,我们发现从传入的options参数中取出size和zIndex,存到Vue.prototype.$ELEMENT全局配置中,这样在组件中我们就可以获取size和zIndex,根据size进行不同组件尺寸的展示。

  在Element文档全局配置中,也指出了可以在引入Element时覆写全局配置:

import Element from 'element-ui';
Vue.use(Element, { size: 'small', zIndex: 3000 });
复制代码

组件

  在上面install函数中,我们发现Element注册插件有三种方式,第一种是像Button和Input,在数组循环遍历,通过Vue.component中注册成全局组件,就可以在页面直接引用;第二种是InfiniteScroll和Loading,在全局注册指令,通过v-infinite-scrollv-loading等指令式来调用;第三种是MessageBox、Notification和Message,在全局Vue.prototype添加了方法,可以通过函数进行调用。

全局组件

Container和Header

  首先我们从几个简单的布局容器组件开始,我们简单看一下demo回顾一下这几个组件的使用方法:

<el-container>
  <el-aside width="200px">Aside</el-aside>
  <el-container>
    <el-header>Header</el-header>
    <el-main>Main</el-main>
    <el-footer>Footer</el-footer>
  </el-container>
</el-container>
复制代码

  我们先来看下el-header的源码,实现逻辑也很简单,通过slot插槽将元素进行渲染;(el-footer和el-aside也是同样的,这里不再展示了):

//packages/header/src/main.vue
<template>
  <header class="el-header" :style="{ height }">
    <slot></slot>
  </header>
</template>
<script>
  export default {
    name: 'ElHeader',
    componentName: 'ElHeader',
    props: {
      height: {
        type: String,
        default: '60px'
      }
    }
  };
</script>
复制代码

  这里传参的props和文档中给出也是一致的,el-header、el-footer和el-aside三个组件都是类似,都传width或者height等一些宽高的字符串数值。

  我们重点来看下el-container的代码,接收一个参数direction,可选值horizontal/vertical,不过它的默认值比较特殊,文档中是这么说的:

子元素中有el-headerel-footer时为vertical,否则为horizontal

  了解了它的传参逻辑,我们来看下源码是如何来实现的:

//packages/container/src/main.vue
<template>
  <section class="el-container" :class="{ 'is-vertical': isVertical }">
    <slot></slot>
  </section>
</template>
<script>
export default {
  name: "ElContainer",
  componentName: "ElContainer",
  props: {
    direction: String,
  },
  computed: {
    isVertical() {
      if (this.direction === "vertical") {
        return true;
      } else if (this.direction === "horizontal") {
        return false;
      }
      return this.$slots && this.$slots.default
        ? this.$slots.default.some((vnode) => {
            const tag = vnode.componentOptions && vnode.componentOptions.tag;
            return tag === "el-header" || tag === "el-footer";
          })
        : false;
    },
  },
};
</script>
复制代码

  代码中比较难理解的是isVertical中的逻辑判断,我们一段一段来看;this.$slots是用来获取组件中所有的插槽组件,和this.$refs有点像,都是对象,用来存放多个插槽对象;而this.$slots.default是获取默认的那个插槽,它是一个数组,存放是插槽中的节点;然后some函数中的判断就很好理解了,用来判断数组中的vnode节点的是否有el-header或者el-footer两个标签,有的话就返回true,就会渲染is-vertical类名。

Row和Col

  我们再来看两个布局组件Row和Col,用于创建栅格布局,我们还是简单的看一下这两个组件的用法:

<el-row :gutter="20">
  <el-col :span="6">
    <div class="grid-content"></div>
  </el-col>
  <el-col :span="6">
    <div class="grid-content"></div>
  </el-col>
  <el-col :span="6">
    <div class="grid-conten"></div>
  </el-col>
  <el-col :span="6">
    <div class="grid-content"></div>
  </el-col>
</el-row>
复制代码

  看用法这两个组件也是在页面通过插槽的方式渲染页面,不过当我们来看源码会发现它的插槽和上面组件的插槽用法还不一样:

//packages/row/src/row.js
export default {
  name: 'ElRow',
  props: {
    tag: {
      type: String,
      default: 'div'
    },
    gutter: Number,
    type: String,
    justify: {
      type: String,
      default: 'start'
    },
    align: {
      type: String,
      default: 'top'
    }
  },
  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 !== 'top' ? `is-align-${this.align}` : '',
        { 'el-row--flex': this.type === 'flex' }
      ],
      style: this.style
    }, this.$slots.default);
  }
};
复制代码

  我们发现el-row插件没有模板template渲染,而是通过render渲染函数来渲染页面的;但是这里为什么需要用到渲染函数呢?这里和el-row的参数有关,在传参列表中我们可以看到参数中有一个tag自定义元素标签,也就是定义最外层的标签类型;如果通过模板渲染的话肯定需要多个if判断,比较繁琐,但是通过渲染函数,就直接渲染标签了;渲染函数有关使用方法可以查看官网文档

  Col组件和Row类似,都是通过render函数进行渲染,不过Col组件获取父级组件Row的参数方式值的我们来学习一下,这里贴上部分代码:

//packages/col/src/col.js
export default {
  name: "ElCol",
  computed: {
    gutter() {
      let parent = this.$parent;
      while (parent && parent.$options.componentName !== "ElRow") {
        parent = parent.$parent;
      }
      return parent ? parent.gutter : 0;
    },
  },
  render(h) {
    let classList = [];
    let style = {};

    if (this.gutter) {
      style.paddingLeft = this.gutter / 2 + "px";
      style.paddingRight = style.paddingLeft;
    }
    return h(this.tag, {
        class: ["el-col", classList],
        style,
      },
      this.$slots.default
    );
  },
};
复制代码

  由于Row组件传入的gutter表示栅格间隔,因此Rol组件也需设置一定的padding,但是怎么能从父组件获取参数呢?Col通过一个while循环,不断向上获取父组件并且判断组件名称。

Form和Form-Item

  看完布局组件,我们来看一下表单组件,表单最顶层的是Form和Form-Item组件,我们可以通过向Form传入参数rules来校验表单中的Input输入框或者其他组件的值,首先来看下Form的源码,由于篇幅问题这里只贴出部分源码:

//packages/form/src/form.vue
<template>
  <form class="el-form" :class="[
    labelPosition ? 'el-form--label-' + labelPosition : '',
    { 'el-form--inline': inline }
  ]">
    <slot></slot>
  </form>
</template>
<script>
  export default {
    name: 'ElForm',
    provide() {
      return {
        elForm: this
      };
    },
    watch: {
      rules() {
        // remove then add event listeners on form-item after form rules change
        this.fields.forEach(field => {
          field.removeValidateEvents();
          field.addValidateEvents();
        });

        if (this.validateOnRuleChange) {
          this.validate(() => {});
        }
      }
    },
    data() {
      return {
        fields: [],
        potentialLabelWidthArr: [] // use this array to calculate auto width
      };
    },
    created() {
      this.$on('el.form.addField', (field) => {
        if (field) {
          this.fields.push(field);
        }
      });
      /* istanbul ignore next */
      this.$on('el.form.removeField', (field) => {
        if (field.prop) {
          this.fields.splice(this.fields.indexOf(field), 1);
        }
      });
    },
    methods: {
      validate(callback) {
        let promise;
        if (typeof callback !== 'function' && window.Promise) {
          promise = new window.Promise((resolve, reject) => {
            callback = function(valid) {
              valid ? resolve(valid) : reject(valid);
            };
          });
        }
        let valid = true;
        let count = 0;
        let invalidFields = {};
        this.fields.forEach(field => {
          field.validate('', (message, field) => {
            if (message) {
              valid = false;
            }
            invalidFields = objectAssign({}, invalidFields, field);
            if (typeof callback === 'function' && ++count === this.fields.length) {
              callback(valid, invalidFields);
            }
          });
        });
        if (promise) { return promise; }
      },
    }
  };
</script>
复制代码

  我们看到Form的页面结构非常简单,只有一个form标签,而且props也只用到了labelPositioninline两个,其他的属性会在Form-Item中用到;Form中还用到了一个provide函数,在Vue中组件通信方式中,我们介绍过provide/inject,主要是用来跨多层组件通信的,在后面组件的介绍中,我们会取出来用到。

  重点我们看下常用的表单校验函数validate是如何来实现的;在created中,我们看到在注册了两个事件:addFieldremoveField,这是用来在所有的子组件Form-Item初始化时调用,进行一个收集存储,存到fields数组中,那么这里为什么不用$children呢?因为页面结构的不确定,Form下一级子组件不一定就是Form-Item,如果进行循环的话比较费时费力,而且对子组件管理操作也比较频繁,因此通过事件的方式;收集所有的Form-Item后,我们就可以对每个表单元素遍历并且校验。

  接着就是Form-Item,来看下它的源码:

//packages/form/src/form-item.vue
<template>
  <div class="el-form-item" :class="[{
      'el-form-item--feedback': elForm && elForm.statusIcon,
      'is-error': validateState === 'error',
      'is-validating': validateState === 'validating',
      'is-success': validateState === 'success',
      'is-required': isRequired || required,
      'is-no-asterisk': elForm && elForm.hideRequiredAsterisk
    },
    sizeClass ? 'el-form-item--' + sizeClass : ''
  ]">
    <label-wrap
      :is-auto-width="labelStyle && labelStyle.width === 'auto'"
      :update-all="form.labelWidth === 'auto'">
      <label :for="labelFor" class="el-form-item__label" :style="labelStyle" v-if="label || $slots.label">
        <slot name="label">{{label + form.labelSuffix}}</slot>
      </label>
    </label-wrap>
    <div class="el-form-item__content" :style="contentStyle">
      <slot></slot>
      <transition name="el-zoom-in-top">
        <slot
          v-if="validateState === 'error' && showMessage && form.showMessage"
          name="error"
          :error="validateMessage">
          <div
            class="el-form-item__error"
            :class="{
              'el-form-item__error--inline': typeof inlineMessage === 'boolean'
                ? inlineMessage
                : (elForm && elForm.inlineMessage || false)
            }"
          >
            {{validateMessage}}
          </div>
        </slot>
      </transition>
    </div>
  </div>
</template>
<script>
  import AsyncValidator from 'async-validator';
  import emitter from 'element-ui/src/mixins/emitter';
  import objectAssign from 'element-ui/src/utils/merge';
  import { noop, getPropByPath } from 'element-ui/src/utils/util';
  export default {
    name: 'ElFormItem',
    componentName: 'ElFormItem',
    mixins: [emitter],
    provide() {
      return {
        elFormItem: this
      };
    },
    inject: ['elForm'],
    computed: {
      fieldValue() {
        const model = this.form.model;
        if (!model || !this.prop) { return; }
        let path = this.prop;
        if (path.indexOf(':') !== -1) {
          path = path.replace(/:/, '.');
        }
        return getPropByPath(model, path, true).v;
      },
      _formSize() {
        return this.elForm.size;
      },
      elFormItemSize() {
        return this.size || this._formSize;
      },
      sizeClass() {
        return this.elFormItemSize || (this.$ELEMENT || {}).size;
      }
    },
    data() {
      return {
        validateState: '',
        validateMessage: '',
        validateDisabled: false,
        validator: {},
        isNested: false,
        computedLabelWidth: ''
      };
    },
    methods: {
      validate(trigger, callback = noop) {
        this.validateDisabled = false;
        const rules = this.getFilteredRule(trigger);
        if ((!rules || rules.length === 0) && this.required === undefined) {
          callback();
          return true;
        }
        this.validateState = 'validating';
        const descriptor = {};
        if (rules && rules.length > 0) {
          rules.forEach(rule => {
            delete rule.trigger;
          });
        }
        descriptor[this.prop] = rules;
        const validator = new AsyncValidator(descriptor);
        const model = {};

        model[this.prop] = this.fieldValue;

        validator.validate(model, { firstFields: true }, (errors, invalidFields) => {
          this.validateState = !errors ? 'success' : 'error';
          this.validateMessage = errors ? errors[0].message : '';

          callback(this.validateMessage, invalidFields);
          this.elForm && this.elForm.$emit('validate', this.prop, !errors, this.validateMessage || null);
        });
      },
      getRules() {
        let formRules = this.form.rules;
        const selfRules = this.rules;
        const requiredRule = this.required !== undefined ? { required: !!this.required } : [];

        const prop = getPropByPath(formRules, this.prop || '');
        formRules = formRules ? (prop.o[this.prop || ''] || prop.v) : [];

        return [].concat(selfRules || formRules || []).concat(requiredRule);
      },
      onFieldBlur() {
        this.validate('blur');
      },
      onFieldChange() {
        if (this.validateDisabled) {
          this.validateDisabled = false;
          return;
        }
        this.validate('change');
      },
      addValidateEvents() {
        const rules = this.getRules();
        if (rules.length || this.required !== undefined) {
          this.$on('el.form.blur', this.onFieldBlur);
          this.$on('el.form.change', this.onFieldChange);
        }
      },
    },
    mounted() {
      if (this.prop) {
        this.dispatch('ElForm', 'el.form.addField', [this]);
        this.addValidateEvents();
      }
    },
    beforeDestroy() {
      this.dispatch('ElForm', 'el.form.removeField', [this]);
    }
  };
</script>
复制代码

  我们看到这里还是用了provide/inject来处理跨组件的数据通信,将Form引入,用到了Form的几个props值来渲染类名,同时将本身inject向下传递。在sizeClass中我们看到获取size也是向上渐进获取的一个过程,首先是Form-Item本身的size,然后是Form的size,最后才是我们挂载在全局$ELEMENT的size,我们去查看其他组件例如Input、Button、Radio,都是通过这种方式来渲染size。

  在Form-Item生命周期函数中我们也看到了,通过触发了Form的addField和removeField来进行表单的收集,不过通过一个dispatch函数,这个函数既不是vue官网中的,在methods中也没有进行定义,那么它是如何来触发的呢?我们仔细看代码,会发现一个mixins:[emitter]数组,原来Form-Item是通过mixins将一些公共的函数提取出来,那么我们来看一下emitter里面是做了哪些操作:

function broadcast(componentName, eventName, params) {
  this.$children.forEach(child => {
    var name = child.$options.componentName;
    if (name === componentName) {
      child.$emit.apply(child, [eventName].concat(params));
    } else {
      broadcast.apply(child, [componentName, eventName].concat([params]));
    }
  });
}
export default {
  methods: {
    dispatch(componentName, eventName, params) {
      var parent = this.$parent || this.$root;
      var name = parent.$options.componentName;
      while (parent && (!name || name !== componentName)) {
        parent = parent.$parent;
        if (parent) {
          name = parent.$options.componentName;
        }
      }
      if (parent) {
        parent.$emit.apply(parent, [eventName].concat(params));
      }
    },
    broadcast(componentName, eventName, params) {
      broadcast.call(this, componentName, eventName, params);
    }
  }
};
复制代码

  我们看到dispatch是用来向父组件派发事件,也是通过while向上遍历循环,而broadcast是向子组件广播事件的。

Button和Button-Group

  Button是我们常用的组件,我们来看下它的源码:

//packages/button/src/button.vue
<template>
  <button
    class="el-button"
    @click="handleClick"
    :disabled="buttonDisabled || loading"
    :autofocus="autofocus"
    :type="nativeType"
    :class="[
      type ? 'el-button--' + type : '',
      buttonSize ? 'el-button--' + buttonSize : '',
      {
        'is-disabled': buttonDisabled,
        'is-loading': loading,
        'is-plain': plain,
        'is-round': round,
        'is-circle': circle
      }
    ]"
  >
    <i class="el-icon-loading" v-if="loading"></i>
    <i :class="icon" v-if="icon && !loading"></i>
    <span v-if="$slots.default"><slot></slot></span>
  </button>
</template>
<script>
  export default {
    name: 'ElButton',
    inject: {
      elForm: {
        default: ''
      },
      elFormItem: {
        default: ''
      }
    },
    props: {
      type: {
        type: String,
        default: 'default'
      },
      size: String,
      icon: {
        type: String,
        default: ''
      },
      nativeType: {
        type: String,
        default: 'button'
      },
      loading: Boolean,
      disabled: Boolean,
      plain: Boolean,
      autofocus: Boolean,
      round: Boolean,
      circle: Boolean
    },
    computed: {
      _elFormItemSize() {
        return (this.elFormItem || {}).elFormItemSize;
      },
      buttonSize() {
        return this.size || this._elFormItemSize || (this.$ELEMENT || {}).size;
      },
      buttonDisabled() {
        return this.disabled || (this.elForm || {}).disabled;
      }
    },
    methods: {
      handleClick(evt) {
        this.$emit('click', evt);
      }
    }
  };
</script>
复制代码

  我们看到Button逻辑相较于上面的组件很简单,通过computed计算了buttonSize和buttonDisabled进行类名渲染,同时依赖了注入的elForm和elFormItem中的数据;在点击时触发了click事件;我们还可以通过Button-Group将多个按钮进行嵌套,来看下它的源码:

//packages/button/src/button-group.vue
<template>
  <div class="el-button-group">
    <slot></slot>
  </div>
</template>
<script>
  export default {
    name: 'ElButtonGroup'
  };
</script>
复制代码

  它的代码更简单,只用了一个slot嵌套了所有的Button。

指令式组件

  指令式组件通过Vue.directive(name, opt)来注册,name就是我们要注册的指令名称,而opt是一个对象,包含了5个钩子函数,我们可以根据需要只写其中的几个函数:

Vue.directive("demo", {
  //只调用一次,指令第一次绑定到元素时调用
  bind:function(el,binding,vnode){ },
  //被绑定元素插入父节点时调用
  inserted:function(el,binding,vnode){ },
  //所在组件的 VNode 更新时调用
  update:function(el,binding,vnode){ },
  //指令所在组件的 VNode 及其子 VNode 全部更新后调用
  componentUpdated:function(el,binding,vnode){ },
  //只调用一次,指令与元素解绑时调用。
  unbind:function(el,binding,vnode){ },
})
复制代码

  每个钩子函数都有三个回调参数,el表示了指令所绑定的元素,可以用来直接DOM操作;而binding就是我们的绑定信息了,它是一个对象,包含以下属性:

  • name:指令名,不包括v-前缀。
  • value:指令的绑定值,比如v-demo="num",邦定值为num值,2。
  • expression:字符串形式的指令表达式,比如v-demo="num",表达式为num。
  • modifiers:一个包含修饰符的对象,比如v-demo.foo.bar修饰符对象为 { foo: true, bar: true }
  • rawName:完整的指令修饰

InfiniteScroll

  InfiniteScroll无限滚动组件的用法也很简单,在我们想要滚动加载的列表上加上v-infinite-scroll,赋值自定义加载的函数,在列表滚动到底部时就会自动触发函数,我们来看一个官方的Demo:

<template>
  <ul class="infinite-list" v-infinite-scroll="load" style="overflow:auto">
    <li v-for="i in count" :key="i" class="infinite-list-item">{{ i }}</li>
  </ul>
</template>
<script>
export default {
  data() {
    return {
      count: 0,
    }
  },
  methods: {
    load() {
      this.count += 2;
    },
  }
}
</script>
复制代码

  在ul滚动到最底部时,触发load函数,加载更多的数据;因此这个指令的实现原理也很简单,就是监听容器滚动,滚动到底部时进行函数调用,我们来看下具体是怎么来实现的:

import throttle from 'throttle-debounce/debounce';
import {
  getScrollContainer
} from 'element-ui/src/utils/dom';

const attributes = {
  delay: {
    type: Number,
    default: 200
  },
  distance: {
    type: Number,
    default: 0
  },
  disabled: {
    type: Boolean,
    default: false
  },
  immediate: {
    type: Boolean,
    default: true
  }
};
const getScrollOptions = (el, vm) => {
  if (!isHtmlElement(el)) return {};
  return entries(attributes).reduce((map, [key, option]) => {
    const { type, default: defaultValue } = option;
    let value = el.getAttribute(`infinite-scroll-${key}`);
    value = isUndefined(vm[value]) ? value : vm[value];
    switch (type) {
      case Number:
        value = Number(value);
        value = Number.isNaN(value) ? defaultValue : value;
        break;
      case Boolean:
        value = isDefined(value) ? value === 'false' ? false : Boolean(value) : defaultValue;
        break;
      default:
        value = type(value);
    }
    map[key] = value;
    return map;
  }, {});
};

const handleScroll = function(cb) {
  const { el, vm, container, observer } = this[scope];
  const { distance, disabled } = getScrollOptions(el, vm);
  if (disabled) return;
  const containerInfo = container.getBoundingClientRect();
  if (!containerInfo.width && !containerInfo.height) return;
  let shouldTrigger = false;
  if (container === el) {
    const scrollBottom = container.scrollTop + getClientHeight(container);
    shouldTrigger = container.scrollHeight - scrollBottom <= distance;
  } else {
    const heightBelowTop = getOffsetHeight(el) + getElementTop(el) - getElementTop(container);
    const offsetHeight = getOffsetHeight(container);
    const borderBottom = Number.parseFloat(getStyleComputedProperty(container, 'borderBottomWidth'));
    shouldTrigger = heightBelowTop - offsetHeight + borderBottom <= distance;
  }
  if (shouldTrigger && isFunction(cb)) {
    cb.call(vm);
  } else if (observer) {
    observer.disconnect();
    this[scope].observer = null;
  }
};

export default {
  name: 'InfiniteScroll',
  inserted(el, binding, vnode) {
    const cb = binding.value;

    const container = getScrollContainer(el, true);
    const { delay, immediate } = getScrollOptions(el, vm);
    const onScroll = throttle(delay, handleScroll.bind(el, cb));

    if (container) {
      container.addEventListener('scroll', onScroll);

      if (immediate) {
        const observer = el[scope].observer = new MutationObserver(onScroll);
        observer.observe(container, { childList: true, subtree: true });
        onScroll();
      }
    }
  },
  unbind(el) {
    const { container, onScroll } = el[scope];
    if (container) {
      container.removeEventListener('scroll', onScroll);
    }
  }
};
复制代码

  在inserted函数中逻辑也很简单,首先cb就是我们自定义的回调函数,用来触发;通过getScrollContainer判断我们的el是否是滚动容器,然后将handleScroll处理滚动逻辑的函数用节流函数throttle进行封装成onScroll,绑定到容器的滚动事件上去。

  我们在文档中还看到有四个参数,都是以infinite-scroll-开头的,用来控制触发加载函数的时间;在源码中我们看到它是通过getScrollOptions函数来进行获取,定义了一个对象用来存储这四个参数的名称、类型和默认值,用Object.keys变成数组后再通过reduce函数处理变成map对象返回。

函数调用组件

  函数组件调用后会将组件插入到body或者其他节点中去,就不能够通过Vue.component注册到全局组件;而是通过Vue.extend创建一个子类构造器,参数是包含组件选项的对象,构造器实例化后通过$mount挂载到页面元素上去。

var MyMessage = Vue.extend({
  template: '<div>number:{{number}}</div>',
  data() {
    return {
      number: 1
    }
  }
})
let instance = new MyMessage()
instance.$mount('#components')
复制代码

  或者实例化后通过$mount获取到DOM结构,然后挂载到body上:

let instance = new MyMessage()
instance.$mount()
document.body.appendChild(instance.$el)
复制代码

Message

  Message组件在入口文件中就挂载到全局变量message上,然后通过this.message上,然后通过``this.message()来进行调用,还能通过this.$message.error()``的方式来调用,因此我们猜测Message肯定是一个函数,在这个函数上面还挂载了success、error等函数来复用Message函数本身;我们来看下Message源码(部分):

//packages/message/src/main.js
import Main from './main.vue';
import { isVNode } from 'element-ui/src/utils/vdom';
let MessageConstructor = Vue.extend(Main);

let instance;
let instances = [];
let seed = 1;

const Message = function(options) {
  options = options || {};
  if (typeof options === 'string') {
    options = {
      message: options
    };
  }
  let id = 'message_' + seed++;
  instance = new MessageConstructor({
    data: options
  });
  instance.id = id;
  if (isVNode(instance.message)) {
    instance.$slots.default = [instance.message];
    instance.message = null;
  }
  instance.$mount();
  document.body.appendChild(instance.$el);
  let verticalOffset = options.offset || 20;
  instances.forEach(item => {
    verticalOffset += item.$el.offsetHeight + 16;
  });
  instance.verticalOffset = verticalOffset;
  instance.visible = true;
  instances.push(instance);
  return instance;
};

['success', 'warning', 'info', 'error'].forEach(type => {
  Message[type] = options => {
    if (typeof options === 'string') {
      options = {
        message: options
      };
    }
    options.type = type;
    return Message(options);
  };
});
export default Message;
复制代码

  我们发现在Message的构造函数中首先对options进行一个处理,因为可以传入字符串或者对象两种调用方式,因此首先把字符串的options统一成对象形式;然后通过Vue.extend创建的构造函数实例化一个instance,将所有的参数options传到instance中进行渲染,最后把instance.$el插入到页面上去渲染;这里为了对所有的实例进行管理,比如根据所有实例个数,渲染最后一个实例的高度还有关闭实例对象等操作,因此维护了一个数组instances来进行管理,还给每个实例一个自增id方便进行查找。

  然后我们来看下Vue.extend传入的参数main.vue的源码(部分):

//packages/message/src/main.vue
<template>
  <transition name="el-message-fade" @after-leave="handleAfterLeave">
    <div
      :class="[
        'el-message',
        type && !iconClass ? `el-message--${ type }` : '',
        center ? 'is-center' : '',
        showClose ? 'is-closable' : '',
        customClass
      ]"
      :style="positionStyle"
      v-show="visible"
      @mouseenter="clearTimer"
      @mouseleave="startTimer"
      role="alert">
      <i :class="iconClass" v-if="iconClass"></i>
      <i :class="typeClass" v-else></i>
      <slot>
        <p v-if="!dangerouslyUseHTMLString" class="el-message__content">{{ message }}</p>
        <p v-else v-html="message" class="el-message__content"></p>
      </slot>
      <i v-if="showClose" class="el-message__closeBtn el-icon-close" @click="close"></i>
    </div>
  </transition>
</template>
<script type="text/babel">
  export default {
    data() {
      return {
        visible: false,
        message: '',
        duration: 3000,
        type: 'info',
        iconClass: '',
        customClass: '',
        onClose: null,
        showClose: false,
        closed: false,
        verticalOffset: 20,
        timer: null,
        dangerouslyUseHTMLString: false,
        center: false
      };
    },
    methods: {
      handleAfterLeave() {
        this.$destroy(true);
        this.$el.parentNode.removeChild(this.$el);
      },
    },
  };
</script>
复制代码

  我们看到这里data参数和文档中给出的参数是一样的,在上面构造函数中正是通过options传入进来进行覆盖;这里关闭组件是通过watch监听closed是否为true,然后再给visible赋值false;那么页面上明明渲染的是visible,为什么这里不直接给visible赋值呢?个人猜测是为了在关闭的同时触发回调函数onClose;在组件动画结束后也调用了parentNode.removeChild将组件从body中移除,

总结

  我们从webpack配置文件入手,找到了入口文件进行组件的注册配置和导出,对众多的组件进行了分类,归为三大类;由于文章篇幅有限,这里只展示了每一类组件中部分组件的源码,像很多常用的组件,比如Input、Radio和Checkbox等很多组件都是大同小异,大家可以继续深入学习其源码;在看源码的同时建议可以查看官方文档中的参数以及参数说明,这样能够更好地理解源码中的思想逻辑,不至于看的一头雾水。

更多前端资料请关注公众号【前端壹读】

如果觉得写得还不错,请关注我的掘金主页。更多文章请访问谢小飞的博客

文章分类
前端
文章标签