比全局组件更高效,从Vant的Toast组件聊聊Vue插件的使用场景

2,217 阅读2分钟

Vant的Toast组件写的比较有意思,最大的特点是简洁的函数式调用。本文将通过梳理Toast组件实现函数式调用的整套逻辑来介绍Vue插件的使用场景。

首先,我们找到Toast引入的地方,相关代码如下:

import { Toast } from 'vant'
Vue.use(Toast)

很明显我们发现这是一个Vue插件,我们看下这个插件做了什么:

import VueToast from './Toast';
Toast.install = function () {
  Vue.use(VueToast);
}
Vue.prototype.$toast = Toast;
export default Toast;

这里的VueToast其实就是我们日常写的SFC(single file component)。一般来说,我们会把SFC作为局部或者全局组件注册,并在父组件中调用。然而这里并没有这样使用,而是声明了一个Toast函数,我们看看Toast函数做了什么:

import _extends from "@babel/runtime/helpers/esm/extends";
function Toast(options) {
  if (options === void 0) {
    options = {};
  }
  var toast = createInstance(); // should add z-index if previous toast has not disappeared
  if (toast.value) {
    toast.updateZIndex();
  }
  options = parseOptions(options);
  options = _extends({}, currentOptions, defaultOptionsMap[options.type ||  currentOptions.type], options);
  if (process.env.NODE_ENV === 'development' && options.mask) {
    console.warn('[Vant] Toast: "mask" option is deprecated, use "overlay" option instead.');
  }
  
  options.clear = function () {
    toast.value = false;
    if (options.onClose) {
      options.onClose();
      options.onClose = null;
    }
    if (multiple && !isServer) {
      toast.$on('closed', function () {
        clearTimeout(toast.timer);
        queue = queue.filter(function (item) {
          return item !== toast;
        });
        removeNode(toast.$el);
        toast.$destroy();
      });
    }
  };
​
  _extends(toast, transformOptions(options));
  clearTimeout(toast.timer);
  if (options.duration > 0) {
    toast.timer = setTimeout(function () {
      toast.clear();
    }, options.duration);
  }
  return toast;
}

这部分代码包含三个步骤:第一,创建toast实例;第二,扩展Toast的options;第三,将扩展后的options合入toast实例中并返回实例。

非常有意思的是,Vant中调用Vue组件的方式跟我们日常的方式完全不一样。日常的开发是在SFC中去更改vue组件实例的属性,例如:

<template>
  <div /> 
</template>
<script>
export default {
  data() {
    a: 1
  },
  created() {
    this.a = 2
  }
}  
</script>

而Vant则是拿到vue组件实例后,用_extend函数去批量更新组件实例的属性。_extend函数来自babel,源码如下:

export default function _extends() {
  _extends = Object.assign || function (target) {
    for (var i = 1; i < arguments.length; i++) {
      var source = arguments[i];
      for (var key in source) {
        if (Object.prototype.hasOwnProperty.call(source, key)) {
          target[key] = source[key];
        }
      }
    }
    return target;
  };
  return _extends.apply(this, arguments);
}

其实就是批量更新Vue实例的相关属性。这是题外话,只是因为跟日常开发方式不一样,个人觉得比较有意思,记录一下。

言归正传,虽然我们看懂了Toast源码的三步操作。但是为什么做了这三步操作之后,页面就能够展示出Toast组件呢?我们得看看createInstance函数到底做了什么:

function createInstance() {
  /* istanbul ignore if */
  if (isServer) {
    return {};
  }
  
  queue = queue.filter(function (item) {
    return !item.$el.parentNode || isInDocument(item.$el);
  });
  
  if (!queue.length || multiple) {
    var toast = new (Vue.extend(VueToast))({
      el: document.createElement('div')
    });
    toast.$on('input', function (value) {
      toast.value = value;
    });
    queue.push(toast);
  }

  return queue[queue.length - 1];
}

createInstance函数对Vue扩展,获得了一个新的、基于Vue的构造函数。实例化以后,实例被推到队列中并返回,这就是这套代码的全部逻辑了。

虽然创建了实例,但是我们并没有看到实例对应的DOM被放到文档中。实例对应的DOM是什么时候被放到文档中,并展示出弹窗的呢?其实我们上面已经看到了:

_extends(toast, transformOptions(options));

由于options合并了defaultOptions,而defaultOptions中有getContainer这一属性:

var defaultOptions = {
  icon: '',
  type: 'text',
  // @deprecated
  mask: false,
  value: true,
  message: '',
  className: '',
  overlay: false,
  onClose: null,
  onOpened: null,
  duration: 2000,
  iconPrefix: undefined,
  position: 'middle',
  transition: 'van-fade',
  forbidClick: false,
  loadingType: undefined,
  getContainer: 'body',
  overlayStyle: null,
  closeOnClick: false,
  closeOnClickOverlay: false
}

getContainer的初始值为undefined,通过_extends方法处理后,getContainer的值变为body。我们通过Toast.js==>PopupMixin==>PortalMixin(顺便说一句,这个调用栈极其蛋疼)找到了getContainer的watcher:

watch: {
    getContainer: 'portal'
  },
  mounted: function mounted() {
    if (this.getContainer) {
      this.portal();
    }
  },
  methods: {
    portal: function portal() {
      var getContainer = this.getContainer;
      var el = ref ? this.$refs[ref] : this.$el;
      var container;
​
      if (getContainer) {
        container = getElement(getContainer);
      } else if (this.$parent) {
        container = this.$parent.$el;
      }
​
      if (container && container !== el.parentNode) {
        container.appendChild(el);
      }
​
      if (afterPortal) {
        afterPortal.call(this);
      }
    }
  }

getContainer的值更新以后,触发了portal回调,将组件实例对应的DOM推到了文档中,页面完成Toast组件内容展示。这就是Toast组件的整个逻辑过程。

通过梳理Vant的Toast组件,我们发现了一种实现Vue插件的思路:先创建一个SFC,然后引入该SFC,对Vue构造函数进行extend,获得一个新的、基于我们想使用的组件的构造函数。实例化后通过函数传参更新组件属性,即可达到在父组件中通过props传参给子组件同样的效果。

这种函数式调用相比全局组件来说更加简洁。正如Vant的Toast组件,一般被用于展示弹窗这样需要高频调用的全局组件。如果在开发业务中发现某个全局组件被频繁使用而且有很多props需要不断更新,那么就可以考虑使用Vue插件。