深入解读 iView,解耦令人头疼的高度耦合复杂逻辑

2021-05-08 11:25:01 浏览数 (1)

有时候我们不知道如何去写更好的脚本,如何做功能组件之间的解耦,如何去学习更好的、更优质架构的代码,如何进行组件的提取,当我们到抓耳挠腮、苦思冥想的时候,回过头来看看我们常用的经典的框架的实现过程。

受欢迎的第三方插件,总是有它的可取之处,要么符合人们的交互的要求,要么调用起来比较方便,要么效率比较高,要么每个角度都对你很适合。

1.安装

1.1 安装 view-design

虽然提供了 CDN 的方式,但是这种方式基本上用不着,使用 npm 安装 view-design:

代码语言:javascript复制
npm install view-design --save
1.2 安装 vue-cli
代码语言:javascript复制
#查看 webpack 是否安装,以及它的对应版本
webpack -v 
#全局安装 webpack
npm install webpack -g
#全局安装 vue-cli
npm install -g vue-cli

对于 vue-cli 的安装过程,这里就不再详细描述了,可以参考如下内容:

(图片来自:http://www.fangbangxin.com)

1.3 安装 iView
代码语言:javascript复制
npm install iview

iView 和上面的 view-design 有区别么?iView 库正式更名为 view-design 库,iView 库不再维护,建议使用安装 view-design 库。

在这里提到对于 iView 的安装只是做的一个版本说明,后续的所有内容都是基于 view-design 的内容。

从图中可以看出来,使用:

代码语言:javascript复制
npm install iview

得到的是版本 3.5.4 的 iView:

对比 iView 和 view-design 的版本。

2. 查看 view-design 源码

2.1 注册插件
代码语言:javascript复制
import iview from "view-design/src/index";

通过使用 import 命令加载对应于 export 命令定义的模块的对外接口。import 的目的就是用来引入外部的模块或者另一个 scirpt 中导出的函数。它是 ES6 基于模块的体系新引入的功能。

使用 import 引入后对应的 JS 模块和 CSS 后,使用 Vue.use 使用插件。这里的插件自然是指 view-design。

2.2 设置 module.rules

将 view-design 对应的 src 目录添加到对应于 babel-loader 的 include 数组中。通过设置这儿的 module.rules,使得模块创建的时候解析过程中,使用 babel-loader 来转换 src 中的最新的 JavaScript 的写法的文件。对于 babel-loader 的说明可以参考下图:

(图片来自:https://webpack.js.org)

如果不把对应的目录添加到 babel-loader 对应的规则下,就会提示如下错误:

从上图中能够和明确的了解到对应的文件,模块无法解析。

2.3 修改输出方式

修改 export 的方式:

将原来的:

代码语言:javascript复制
module.exports.default = module.exports = API;

改为:

代码语言:javascript复制
export default API;

对于这个问题,我们反过来看,如果不进行修改的情况下会有什么状况发生:

对应于注册的时候使用 import 的引入,无法找到对应的输出。module.exports 是 Node.js 基于 CommonJS 的模块的用法。但是,在这里我们需要使用 Vue.use 对注册插件到全局进行使用,所以这里需要使用 export 输出 API,使它能够通过 import 的方式引入到 Vue 的项目中。

编译运行:

2.4 调用 tree

拷贝代码:

运行:

代码语言:javascript复制
npm run dev

运行后根据对应的 url 在浏览器中访问:

由于当前的项目是基于 Node.js 构建的,所以在使用 npm 将包安装到本地的时候,对应的文件会存在 node_modules 的目录下,在项目中对应的 node_modules 文件件下找到 view-design 后,可以看到 tree 对应的源码:

添加调试信息:

  • alert 为了确认是否经过这个地方
  • debugger 可以在开发者模式下此处中断

这里提到的调试方法,在之前的免费 Chat 文章《Visual Studio JavaScript 的前后端调试方法你真的会了么》这篇中有提到,如果是需要的同学可以去查阅。

到这里调试源码的流程描述完成,没有问题的情况下,接下来看实现过程,注意过程中的关键点,以及它是如何进行解耦的。

3. Tree 的实现过程

3.1 插件安装
代码语言:javascript复制
import ViewUI from 'view-design/src/index';
import 'view-design/dist/styles/iview.css';
Vue.use(ViewUI);

Vue.use 的实现机制是什么?如下图:

(图片来自:https://cn.vuejs.org)

另外一点,要注意的是在插件的使用的时候,要在插件中提供 install 方法,这个问题,如果我们将来要创建自己的插件的情况下,要注意。官方对应的说明可以参考:

https://cn.vuejs.org/v2/guide/plugins.html

由于前面对于这个过程已经介绍了,所以这里就不再赘述了。

3.2 Tree 组件调用
代码语言:javascript复制
<template>
  <div>
    <Tree :data="data2" show-checkbox></Tree>
  </div>
</template>

<script>
export default {
  data() {
    return {
      data2: [
        {
          title: "parent 1",
          expand: true,
          children: [
            {
              title: "parent 1-1",
              expand: true,
              children: [
                {
                  title: "leaf 1-1-1"
                },
                {
                  title: "leaf 1-1-2"
                }
              ]
            },
            {
              title: "parent 1-2",
              expand: true,
              children: [
                {
                  title: "leaf 1-2-1"
                },
                {
                  title: "leaf 1-2-1"
                }
              ]
            }
          ]
        }
      ]
    };
  }
};

这里很容易理解, 也非常简单,仅仅传入对应的参数,就得到友好的显示。这种方式也正是我们多数同学,在工作中实际需要的方案,传入数据,输出结果,减少繁杂重复的业务控制。

下面我们借助 iView 的 Tree 组件,拨开它一层一层的面纱。

3.3 Tree 组件实现
3.3.1 入口
3.3.2 created

(图片来自:https://cn.vuejs.org)

从上面的生命周期看,created 在前执行,mounted 在后执行。

代码语言:javascript复制
 created(){
            console.log('===view-design_tree-created===')
            //编译扁平状态
            this.flatState = this.compileFlatState();
            //重构树
            this.rebuildTree();
 },

created 做了什么操作:

(图片来自:https://cn.vuejs.org)

3.3.3 compileFlatState

注意如下内容中,对于代码的注释说明:

代码语言:javascript复制
compileFlatState () { // so we have always a relation parent/children of each node
    let keyCounter = 0;
    let childrenKey = this.childrenKey;//prop 中的 childrenKey 默认值是 'children'
    const flatTree = [];//扁平树
    //扁平化子节点
    function flattenChildren(node, parent) {
        //给 node 的 nodeKey 属性赋值,值为 keyCounter 的递增值
                node.nodeKey = keyCounter  ;

        //把 node 节点的节点和节点 key 值组合成对象
        //放在 flatTree 数组中
                flatTree[node.nodeKey] = { node: node, nodeKey: node.nodeKey };

        //如果存在父节点
        if (typeof parent != 'undefined') {
            flatTree[node.nodeKey].parent = parent.nodeKey;
            flatTree[parent.nodeKey][childrenKey].push(node.nodeKey);
        }

        //对应 childrenKey 的 node,childrenKey 默认值是'children',它实际上是指调用的时候传递的参数子节点的属性名称
        //查看调用的时候的数据中定义的子节点属性就能够验证此处的用途
        //如果存在子节点
        if (node[childrenKey]) {
            flatTree[node.nodeKey][childrenKey] = [];//在 flatTree 数组中找到对应于 nodeKey 的 node,并设置子节点为空数组
            node[childrenKey].forEach(child => flattenChildren(child, node));//如果有子节点的情况下,把 node 的子节点复制给 flatTree 中对应节点
        }
    }

    //遍历 stateTree 的节点,注意从上面的 watch 中对于 data 的定义来看,它是组件调用的时候传递过来的参数
    //也就是说 stateTree 是定义组件的时候的所有节点信息
    this.stateTree.forEach(rootNode => {
    //遍历每个节点
        flattenChildren(rootNode);
    });

  //上述的系列操作实际上就是将调用时传递的 data 参数传递给 flatTree
    return flatTree;
}

这个 compileFlatTree 实际上就是通过遍历把调用的时候传入进来的 data 的层级关系给打破,将节点进行编号,放在了一个一维的数组中来,使用 nodeKey 作为外键用来提取。问题来了,为什么要执行这一步的 this.compileFlatState?它的目的在哪儿?在后面的描述中找到答案。

3.3.4 rebuildTree
代码语言:javascript复制
// only called when `data` prop changes
rebuildTree () { 
  //获取选中的 checked 状态的节点     
    const checkedNodes = this.getCheckedNodes();
  //遍历 checkedNode 节点
    checkedNodes.forEach(node => {
    //更新树的当前节点及其子节点的状态【这里的 down 应该是指当前节点及其子节点】
    this.updateTreeDown(node, {checked: true});

    //上面是跟踪向下的足迹改变,下面的 propagate upwards 直译过来是向上传播
    // propagate upwards
    const parentKey = this.flatState[node.nodeKey].parent;//对应于 flatState 中对应节点的 parent 属性值
    if (!parentKey && parentKey !== 0) return;//如果 parent 不存在则直接返回,不进行后序操作,反之,则继续进行

    const parent = this.flatState[parentKey].node;//对应于 parentKey 的节点

    //有 check 访问器的子节点
    const childHasCheckSetter = typeof node.checked != 'undefined' && node.checked;
    //如果父节点的 checked 状态不等于子节点的 checked 状态, 向上更新树的节点
    if (childHasCheckSetter && parent.checked != node.checked) {
        this.updateTreeUp(node.nodeKey); // update tree upwards
    }
    });
},

通过 rebuildTree 来实现根据对应的选中节点来,更新它的父级节点以及子集节点的选中状态,表现出来的效果就是选中节点的父级选中,所有的子节点全部选中。如下图:

当选中 leaf 1-1-1 的时候对应的上层父节点和子节点全部变更状态的效果。这个效果实际上在我们日常的业务做层级类的业务的时候是很常用的,比如 OA 上的部门分支,电商项目上的商品类目,对应于我们自己的业务过程,可以反思一下,对应于选中节点的子父级状态同步的问题,有没有把它和其他的业务分离出来。

3.3.5 updateTreeUp
代码语言:javascript复制
updateTreeUp(nodeKey){
  //在 flatState 数组中找到 nodeKey 对应的节点的 parent 属性值
    const parentKey = this.flatState[nodeKey].parent;
  //如果 parent 不存在,或者 checkStrictly 为 true 则直接返回
    if (typeof parentKey == 'undefined' || this.checkStrictly) return;

  //在 flatState 中找到对应于 nodeKey 的节点
    const node = this.flatState[nodeKey].node;
  //在 flatState 中找到当前节点的父节点
    const parent = this.flatState[parentKey].node;

  //如果当前节点的 checked 顺序 ing 和父节点的 checked 属性一致,而且 indeterminate 属性一致则返回
    if (node.checked == parent.checked && node.indeterminate == parent.indeterminate) return;   // no need to update upwards

  //如果 node 的 checked 属性是 true
    if (node.checked == true) {
    //更新父节点的属性
        this.$set(parent, 'checked', parent[this.childrenKey].every(node => node.checked));
        this.$set(parent, 'indeterminate', !parent.checked);
  } else {
    //更新父节点的属性
        this.$set(parent, 'checked', false);
        this.$set(parent, 'indeterminate', parent[this.childrenKey].some(node => node.checked || node.indeterminate));
    }

  //递归实现逐级向上更新
  this.updateTreeUp(parentKey);
},

对于这个地方,它的实现看起来比较简单,那么如果我们在写这个过程的时候会不会用到递归。递归,可以说是常见的不能再常见的算法了。算法是很有用的,所以各大公司面试的时候面试算法,并不是无关痛痒的操作。无论是对于基本功,还是解决问题的能力,使用算法来考察,多是很常见的方式。

3.3.6 mounted
代码语言:javascript复制
mounted () {
  this.$on('on-check', this.handleCheck);
  this.$on('on-selected', this.handleSelect);
    this.$on('toggle-expand', node => this.$emit('on-toggle-expand', node));
}

实例被挂载后调用 mounted 方法,可以发现在进入 mounted 的时候,一部分元素已经渲染出来了。这里提到的实例的情况下,要对 el 和 $el 的概念进行区分,避免使用过程中一头雾水。

el 是什么?

(图片来自:https://cn.vuejs.org)

vm.$el 是什么?

(图片来自:https://cn.vuejs.org)

在 mounted 方法中用到了 3 次 $on,那么它又是什么意思?

$on 是什么?

(图片来自:https://cn.vuejs.org)

在 mounted 中访问 $el:

代码语言:javascript复制
mounted () {
  console.log(this.$el)
  this.$on('on-check', this.handleCheck);
  this.$on('on-selected', this.handleSelect);
    this.$on('toggle-expand', node => this.$emit('on-toggle-expand', node));
}

可以通过这种方式查看 $el 的输出:

通过使用 $on,监听当前实例上的自定义事件。

3.3.7 handleCheck
代码语言:javascript复制
handleCheck({ checked, nodeKey }) {            
    debugger;
    console.log('====处理 Check====')

    //根据 nodeKey 在 flatState 数组中找到对应的 node
    //到这一步基本上能够理解为什么要把原有的 data 的节点对象,转换成 flatState,并且命名为'flatState'
    //原来的 data 传递进来的对象形式的数据对有父子的层级关系的
    //通过 created 方法中对于数据的转换变成 flatState,flat 翻译过来是扁平的意思,实际上就相当于把一个有层级关系的节点数据对象,给展开拉平成为了一个一维的节点数组
    //这样通过使用 nodeKey,在这里也就是数组的下标随用随取,从算法的复杂度的角度,来说在数组中通过这种方式获取元素的时间复杂度是是 O(1),时间效率最高
    if (!this.flatState[nodeKey]) return;
    const node = this.flatState[nodeKey].node;//根据 nodeKey 获取节点

    //设置节点的状态
    this.$set(node, 'checked', checked);
    this.$set(node, 'indeterminate', false);

    this.updateTreeUp(nodeKey); // propagate up
    this.updateTreeDown(node, {checked, indeterminate: false}); // reset `indeterminate` when going down

    this.$emit('on-check-change', this.getCheckedNodes(), node);
}

到这儿,iView 的 Tree 插件中的 tree.vue 组件基本上读完了。下一步从在 tree.vue 中对于 node.vue 的调用来查看它的过程。

3.4 Node 组件的调用

通过使用 import 引入 Node 组件,并且命名为 TreeNode,在 template 中通过使用 Tree-node 来调用。

代码语言:javascript复制
<Tree-node
    v-for="(item, i) in stateTree"
    :key="i"
    :data="item"
    visible
    :multiple="multiple"
    :show-checkbox="showCheckbox"
    :children-key="childrenKey">
</Tree-node>
3.4.1 stateTree

从后面的代码中可以看到 stateTree 来自于 this.data:

代码语言:javascript复制
data () {
    return {
    prefixCls: prefixCls,
    stateTree: this.data,
      flatState: [],
    };
},

那么 this.data 是指哪儿呢?在 props 属性中可以看到对应的 data。

所以这里的 stateTree 实际上就是调用树组件的时候从最外层使用者传递进来的参数。

但是在打印 data 的时候,我们却发现,输出的对象并不是初次调用的时候传入的 data2。如下图:

因为在输入的时候是没有属性 nodeKey 的。那么这是什么原因呢?在组件的生命周期过程中 this.data 被改变了。

这里涉及到一个小小的点要注意,通过在浏览器的控制台中做一个小小的实验就能证明。

再对 obj 的 a 属性重新赋值后,上面的 obj 中的 a 属性的值的展开后会同样被改变。但是原有的显示依然是 {a:1,b:2},回到上面的 this.data 的输出,相比如输入时的 data2,多了一个 nodeKey,这个多出来的 nodeKey 就是后来对于 data 的重新赋值后增加的。

通过这个推断,this.data 输出的值中多的 nodeKey 属性就得到了解释。那么问题是,这个 nodeKey 是什么时候增加的呢?

用同样的方法,在第一次执行到 flattenChildren 下的 node.nodeKey=keyCount 的时候,展开控制台中的 this.data 的输出,就能够发现这个时候第一个 Node 节点多了一个 nodeKey,子节点还没有添加上 nodeKey。如图:

这儿说明了 nodeKey 是在什么情况下加上的。那么为什么遍历的是 stateTree,this.data 的值也跟着改变了呢?其实这也比较容易理解,可以去相关的书籍中查看一下,在 JavaScript 中对于引用类型的赋值的过程。比如,《JavaScript 高级程序设计》中的表述:

当从一个变量向另一个变量复制引用类型的值时,同样也会将存储在变量对象中的值复制一份放到为新变量分配的空间中。不同的是,这个值的副本实际上是一个指针,而这个指针指向存储在堆中的一个对象。复制操作结束后,两个变量实际上将引用同一个对象。因此,改变其中一个变量,就会影响另一个变量。

3.4.2 :key="i"

这里的冒号,就像 @ 符号一样,这里的冒号是 v-bind 的语法糖,也可以说成是 v-bind 的缩写,v-bind 是 Vue 的众多内置指令中的一个。它用来动态更新 HTML 元素上的属性。

冒号绑定的 key 属性的值对应的 "i",所指的内容是 stateTree 在使用 v-for 遍历过程中的 "i",它是对应于 stateTree 的索引值。

4. 解耦的关键

下面走进 node.vue 查看 node.vue 的实现过程。

mixins 是什么?参考官方说明:

https://cn.vuejs.org/v2/api/#mixins

从 Node 的实现中就可以看出来,它的方法的执行都是对应于 Node 中子元素的事件来进行驱动的。

代码语言:javascript复制
//引用 checkbox 组件
import Checkbox from "../checkbox/checkbox.vue";
//引用 icon 组件
import Icon from "../icon/icon.vue";
//引用 render,Render 起到什么作用?
import Render from "./render";
//collapse-transition 起到什么作用?
import CollapseTransition from "../base/collapse-transition";
//emitter 起到什么作用?
import Emitter from "../../mixins/emitter";
//assit 起到什么作用
import { findComponentUpward } from "../../utils/assist";

//前缀 ivu-tree
const prefixCls = "ivu-tree";

export default {
  name: "TreeNode",
  //可以通过调试查看 mixins 的执行过程,以及在这里的作用
  mixins: [Emitter],
  inject: ["TreeInstance"],
  components: { Checkbox, Icon, CollapseTransition, Render },
  props: {
    data: {
      type: Object,
      default() {
        return {};
      }
    },
    multiple: {
      type: Boolean,
      default: false
    },
    childrenKey: {
      type: String,
      default: "children"
    },
    showCheckbox: {
      type: Boolean,
      default: false
    },
    appear: {
      type: Boolean,
      default: false
    }
  },
  //区别 data()和 data
  data() {
    return {
      prefixCls: prefixCls,
      appearByClickArrow: false
    };
  },
  computed: {
    //下面的这一部分操作也比较重要
    //在组件的提取的情况下,对于动态操作 标签 属性的动作
    classes() {
      return [`${prefixCls}-children`];
    },
    selectedCls() {
      return [
        {
          [`${prefixCls}-node-selected`]: this.data.selected
        }
      ];
    },
    arrowClasses() {
      return [
        `${prefixCls}-arrow`,
        {
          [`${prefixCls}-arrow-disabled`]: this.data.disabled,
          [`${prefixCls}-arrow-open`]: this.data.expand
        }
      ];
    },
    titleClasses() {
      return [
        `${prefixCls}-title`,
        {
          [`${prefixCls}-title-selected`]: this.data.selected
        }
      ];
    },
    //显示箭头
    showArrow() {
      return (
        (this.data[this.childrenKey] && this.data[this.childrenKey].length) ||
        ("loading" in this.data && !this.data.loading)
      );
    },
    //显示加载动画
    showLoading() {
      return "loading" in this.data && this.data.loading;
    },
    //父节点渲染配置
    isParentRender() {
      const Tree = findComponentUpward(this, "Tree");
      return Tree && Tree.render;
    },
    //父节点渲染,这个和 isParentRender 有什么区别?
    parentRender() {
      const Tree = findComponentUpward(this, "Tree");
      if (Tree && Tree.render) {
        return Tree.render;
      } else {
        return null;
      }
    },
    node() {
      const Tree = findComponentUpward(this, "Tree");
      if (Tree) {
        // 将所有的 node(即 flatState)和当前 node 都传递
        return [
          Tree.flatState,
          Tree.flatState.find(item => item.nodeKey === this.data.nodeKey)
        ];
      } else {
        return [];
      }
    },
    //子节点
    children() {
      return this.data[this.childrenKey];
    },
    // 3.4.0, global setting customArrow 有值时,arrow 赋值空
    //箭头类型
    arrowType() {
      let type = "ios-arrow-forward";

      if (this.$IVIEW) {
        if (this.$IVIEW.tree.customArrow) {
          type = "";
        } else if (this.$IVIEW.tree.arrow) {
          type = this.$IVIEW.tree.arrow;
        }
      }
      return type;
    },
    //箭头类型的全局设置
    // 3.4.0, global setting
    customArrowType() {
      let type = "";

      if (this.$IVIEW) {
        if (this.$IVIEW.tree.customArrow) {
          type = this.$IVIEW.tree.customArrow;
        }
      }
      return type;
    },
    //箭头尺寸
    // 3.4.0, global setting
    arrowSize() {
      let size = "";
      //$IVIEW 是什么?可以查看 https://www.iviewui.com/docs/guide/global 对应的全局配置项
      if (this.$IVIEW) {
        if (this.$IVIEW.tree.arrowSize) {
          size = this.$IVIEW.tree.arrowSize;
        }
      }
      return size;
    }
  },
  //方法中对应动作的处理
  methods: {
    //处理展开的过程
    handleExpand() {
      const item = this.data;
      // if (item.disabled) return;

      // Vue.js 2.6.9 对 transition 的 appear 进行了调整,导致 iView 初始化时无动画,加此方法来判断通过点击箭头展开时,加 appear,否则初始渲染时 appear 为 false
      this.appearByClickArrow = true;

      //这个过程要关注的问题:
      /*
      1.箭头什么情况下变化的
      2.展开的节点是如何收起来的
      */
      if (item[this.childrenKey].length === 0) {
        //如果没有子节点
        //***向上查找,这个查找的过程,需要确认看看 findComponentUpward 的动作,宏观上可以参考下文的草图
        const tree = findComponentUpward(this, "Tree");
        if (tree && tree.loadData) {
          this.$set(this.data, "loading", true);
          tree.loadData(item, children => {
            this.$set(this.data, "loading", false);
            if (children.length) {
              this.$set(this.data, this.childrenKey, children);
              this.$nextTick(() => this.handleExpand());
            }
          });
          return;
        }
      }
      if (item[this.childrenKey] && item[this.childrenKey].length) {
        this.$set(this.data, "expand", !this.data.expand);//重置 data 的状态值
        debugger;
        this.dispatch("Tree", "toggle-expand", this.data);
      }
    },
    handleSelect() {
      if (this.data.disabled) return;
      if (this.TreeInstance.showCheckbox && this.TreeInstance.checkDirectly) {
        this.handleCheck();
      } else {
        this.dispatch("Tree", "on-selected", this.data.nodeKey);
      }
    },
    handleCheck() {
      if (this.data.disabled) return;
      const changes = {
        checked: !this.data.checked && !this.data.indeterminate,
        nodeKey: this.data.nodeKey
      };
      this.dispatch("Tree", "on-check", changes); 
    }
  }
};

实际上对于第一次阅读源码的同学来看,最难理解,也是最重要的内容,就是如下的 dispatch:

代码语言:javascript复制
this.dispatch("Tree", "toggle-expand", this.data);

它的背后发生了什么?

(注:只是个草图,不是 UML,也不是 C4Model)

在这个过程中可能比较难理解的就是 $emit 的调用过程以及其中的 invokeWithErrorHandling 方法:

为什么要用这个方法 invokeWithErrorHandling?

从代码来看,它是带有错误处理机制的 invoke,并不是对错误的处理:

代码语言:javascript复制
function invokeWithErrorHandling (
  handler,
  context,
  args,
  vm,
  info
) {
  var res;
  try {
    res = args ? handler.apply(context, args) : handler.call(context);
    if (res && !res._isVue && isPromise(res) && !res._handled) {
      res.catch(function (e) { return handleError(e, vm, info   " (Promise/async)"); });
      // issue #9511
      // avoid catch triggering multiple times when nested calls
      res._handled = true;
    }
  } catch (e) {
    handleError(e, vm, info);
  }
  return res
}

那么这里的 handle 的过程是什么呢?也就是 try 的内部过程,它实际上强调的是 invoke,而不是 error。

需要重点分析的是这一部分的内容:

代码语言:javascript复制
res = args ? handler.apply(context, args) : handler.call(context);
    if (res && !res._isVue && isPromise(res) && !res._handled) {
      res.catch(function (e) { return handleError(e, vm, info   " (Promise/async)"); });
      // issue #9511
      // avoid catch triggering multiple times when nested calls
      res._handled = true;

apply 是什么意思?call 是什么意思?

call() 方法和 apply() 方法的作用西昂同,它们的区别仅在于接收参数的方式不同。对于 call() 方法而言,第一个参数是 this 值没有变化,变化的是其余参数都直接传递给函数。换句话说,在使用 call() 方法时,传递给函数的参数必须逐个列举出来。

至于是使用 apply() 还是 call(),完全取决于你采取哪种给函数传递参数的方式最方便。如果你打算直接传入 arguments 对象,或者包含函数中先接收到的也是一个数组,那么使用 apply() 肯定更方便;否则,选择 call() 可能更合适。(在不给函数传递参数的情况下,使用哪个方法都无所谓。)

事实上,传递参数并非 apply() 和 call() 真正的用武之地;它们真正强大的地方是能够扩充函数赖以运行的作用域。这一点非常重要。

使用 call() 或者 apply() 来扩充作用域的最大好处,就是对象不需要与方法有任何耦合关系。

回到代码的分析中。

点击箭头,收缩展开的分支,这里操作的对象是 node.vue 中的 DOM 元素后触发的事件:

触发 emit:

代码语言:javascript复制
 [eventName].concat(params)

通过使用 concat 连接两个或者多个数组,返回一个新的数组,实际上就是多参数的一次整合。

parent 的节点属性:

跟踪对应的 $emit 的方法看到内部的参数监视:

参数传递进来,并且取得后,遍历事件,并使用 invokeWithErrorHandling 执行带有异常捕获处理的方法。

代码语言:javascript复制
invokeWithErrorHandling(cbs[i], vm, args, vm, info);

cbs[i]:

vm(由于对应的属性比较多,所以包含了两张图):

使用 vm.$el 就能看到对应 parent 组件的 DOM 元素,如图:

args:

info:

接下来,调用 invokeWithErrorHandling,执行第一条语句:

代码语言:javascript复制
res = args ? handler.apply(context, args) : handler.call(context);

args 为上文传入的 args 参数。handler 对应于 cbs[i],也就是对应的事件,就是说如果有参数传递,使用 apply 触发 handler 事件,反之,使用 call 直接调用。恰好,这里的 apply 和 call 与上文中的 apply 和 call 的阐述相互呼应。能够直接理解。

执行下一步会直接跳转回定义 toggle-expand 事件的地方。

代码语言:javascript复制
this.$on('toggle-expand', node => this.$emit('on-toggle-expand', node));

紧跟着会触发对应于 => 右侧的 on-toggle-expand 事件。

再次触发 emmit 的调用:

传入的 event 的值为 on-toggle-expand:

从 cbs 可以看到没有对应于 on-toggle-expand 的事件。所以上步骤中的执行结果为 undefined。

那么到这儿就奇怪了,既然没有对应的 on-toggle-expand 事件,展开的状态是如何折叠起来的呢?到这儿,似乎线索断了。

迷惑的是什么情况下,event 参数变成了如下的 MouseEvent:

这个地方应该是 event 的参数名称和全绝的 event 冲突了,显示的是全局的 event 的事件,而不是当前传入的参数值。证明一下就知道了。

实际上并没有变化,只是在调试状态下,这儿显示的错误。为了验证这个问题,使用 Firefox 浏览器,跟踪查看对应的变量值,结果如下:

触发 on-toggle-expand 的结果如下:

可以看到,知道 dispatch 方法执行完成,对应的箭头点击的分支依旧没有折叠起来。

这个时候很纳闷了,什么情况下执行的折叠呢?继续往下走。直接跳转到 watch 中了,在 watch 中根据 statTree 的值的变化,重新编译输出树的状态。这个时候,对应的树折叠起来了。

那么在 emmiter.js 中对于如下代码:

代码语言:javascript复制
parent.$emit.apply(parent, [eventName].concat(params));

它在什么情况下起的作用呢?在单击 Tree 中对应的节点中的复选框的时候,就能够看到它的作用了。它实际上是对于 parent,也就是父实例的追溯。在追溯的过程中匹配与传入的 componentName 匹配的实例。

如果根据匹配规则,找到了对应于 parent 的实例,就会触发上面的对于与 parent 的事件的触发,事件的名称就是其中的 eventName。

接下来通过查看对应于 checkbox 的操作可以进一步巩固上述 toggle 对应的流程:

这里的 parent 可以通过监视查看,它通过 while 的逐层查找,最终得到的是根节点。

在通过 emit 方法, 查找到对应于 vm._events 中的 on-check,如下:

接下来像之前一样,通过 invokeWithErrorHandling 中的 apply 或者 call,调用相应的方法。

在 node.vue 中看到对于 Check 的节点级别的处理:

代码语言:javascript复制
    handleCheck() {
      debugger;
      if (this.data.disabled) return;
      const changes = {
        checked: !this.data.checked && !this.data.indeterminate,
        nodeKey: this.data.nodeKey
      };
      this.dispatch("Tree", "on-check", changes);
    }

传递给 dispatch 方法的 params 参数的值 changes 中是 nodeKey,以及对应 nodeKey 的 checked 的状态值,如下:

通过 handleCheck 的过程,可以看到,对应于 dispatch 的结果是,找到了 tree.vue 中的 handleCheck,并且完成对于 tree.vue 中的 handleCheck 过程。

到这一步疑问又来了,这里通过 emit 触发的 on-check-change 在哪里?

首先调用 getCheckedNodes:

代码语言:javascript复制
getCheckedNodes () {
    /* public API */
    return this.flatState.filter(obj => obj.node.checked).map(obj =>obj.node);
},

筛选所有选中的节点。

随后的 emit 依然是没有做任何操作,那么既然没有任何操作,放在这儿是做什么用的?难道是提供的源码中书写这一段代码的工程师忘了删掉它的无用代码吗?

当然不是,回到官网的说明中就能够看出端倪:

这里是为了对外部事件的绑定,预留出来的接口。通过外部事件的绑定,可以在相应的动作触发的时候,一同处理自定义的事件。

到这儿基本上整个流程就完成了,看到了一个对于 Tree 的完整的实现过程。关键是它的解耦的方式,以及对外部交互的接口的设计。

5. 样式的控制

通过改变对应的属性,来驱动样式的修改,并不是通过对于样式的直接修改来进行的,这也体现了 Vue 的特点。

到这儿基本上整个 Tree 的组件的整体流程算是走完了。

关键是参考它是如何解耦的,如何预留外部事件接口的,如何使用算法简化节点提取流程的。通过这种方式,我们尝试创建属于公司自己的组件库。避免乱七八糟到处都是的业务逻辑。

在这个简单的 Tree 组件中,可以看到观察者模式、可以看到递归,可以对象转换为数组的空间换时间的降维,可以看到开放-封闭、单一职责的设计原则。

通过这种方式举一反三,相信能够逐渐形成各自公司内部的高效组件库,可以尝试去实现、扩展开源组件中无法满足的,公司内部有独立特色的相关组件。在实践中不断总结,不断分析,不断进步。

虽然篇幅较长,但是有必要仔细看完,因为几乎每一个段落,每一行代码,都带有不同的知识点。

有问题欢迎反馈,共同学习交流。

0 人点赞