分享一个 iView 调试案例

3,269 阅读7分钟

当我们遇到开源框架的 bug 时,应该如何处理? Google?查文档?提 issue ? 或许,我们还能尝试自己在源码中找出 bug 的原因!

本文分享笔者在工作中,遇到一个 iview 的 bug 时, 如何根据问题的表征,一步一步推导出问题的源头,最终找出解决方案的过程。 希望对尊贵的读者朋友们有些许启发。

版本升级触发的 bug

最近抽空对项目用的 iView 版本做升级,从 2.6.0 升到 2.14.2,原以为不会有太大问题,但在 AutoComplete 组件上报了一些错误:

异常信息截图

分析调用的上下文代码后,发现在将 AutoComplete 组件的值设置为 null 时,会触发这个异常,我简化了调用的上下文代码,做了一个 Demo,代码如下所示。我在 codepen 上放了实例,建议点进去看看,直观感受下, 记住:请打开控制台看报错信息

<template>
  <div>
    <AutoComplete v-model="value">
      <Option v-for="item in persons" :value="item" :key="item">
        <span>{{item}}</span>
      </Option>
    </AutoComplete>
    <Button type="primary" @click="reset">test</Button>
  </div>
</template>

<script type="text/ecmascript-6">
const PERSONS = ["tec", "van", "is", "good"];

export default {
  data() {
    return { value: "" };
  },
  computed: {
    persons() {
      return PERSONS;
    }
  },
  methods: {
    reset() {
      // 当设置 AutoComplete 组件值为null时
      // 会触发文中所说的bug
      this.value = null;
    }
  }
};
</script>

从异常信息看,问题的核心是 i-select 组件发生 TypeError: Cannot read property 'propsData' of undefined,也就是有一些 undefined 值在预期之外地流入 i-select 逻辑中。搜了一圈,没找到解决方案,github 上的 issue 也没什么相关的问题,只好自己解决了。

从源码 Debug

首先,从报错堆栈找到抛出异常的位置,代码如下:

var applyProp = function(node, propName, value) {
  (0, _newArrowCheck3.default)(undefined, undefined);

  return (0, _extends4.default)({}, node, {
    componentOptions: (0, _extends4.default)({}, node.componentOptions, {
      propsData: (0, _extends4.default)(
        {},
        // 这一句抛出了 undefined 异常
        node.componentOptions.propsData,
        (0, _defineProperty3.default)({}, propName, value)
      )
    })
  });
}.bind(undefined);

Tips:

报错语句中的 propsData 属性,在日常开发中接触的不多, vue 官网解释的有点粗略:

创建实例时传递 props。主要作用是方便测试

简单的说,就是 vue 会将父级组件传递的 props 属性合并成一个对象,该对象即为 propsData 属性。

这是一段 babel 编译过的代码,看起来真心有些吃力,所以强烈建议先 配置 iview 调试环境,配置后,可以看到错误发生在 src/components/select.vue 文件,源码为:

const applyProp = (node, propName, value) => {
  return {
    ...node,
    componentOptions: {
      ...node.componentOptions,
      propsData: {
        ...node.componentOptions.propsData,
        [propName]: value
      }
    }
  };
};

嗯,这样看起来清爽多了。上面的代码就只是个很简单的属性合并函数,确实是在这个函数触发的 bug,但问题的重点是调用方传递过来的参数,不符合该函数的预期,追本溯源,接下来我们从几个方面分析问题:

  1. 何时调用 applyProp 函数?
  2. 参数 node 是为何物?
  3. 为什么会出现 node.componentOptions 值为 undefined 的情况?

1. 何时调用 applyProp 函数?

src/components/select.vue 文件中搜索,发现该函数只在 计算器 selectOptions 被调用,简化后的调用代码:

selectOptions() {
    // ...
    const slotOptions = (this.slotOptions || []);
    // ...
    if (this.autoComplete) {
        // ...

        return slotOptions.map(node => {
            if (node === selectedSlotOption || getNestedProperty(node, 'componentOptions.propsData.value') === this.value) {
                return applyProp(node, 'isFocused', true);
            }
            return copyChildren(node, (child) => {
                if (child !== selectedSlotOption) return child;
                return applyProp(child, 'isFocused', true);
            });
        });
    }
    // ...
},

结合 applyProp 源码,推测这里的意图是给符合条件的 slotOptions 项加上 isFocused 属性。 从 src/components/select.vue 组件的代码推断,计算器 selectOptions 用于表示组件选项中,被选中的项。

selectOptions 计算器代码太长,有太多的依赖: slotOptionsfocusIndexvaluesautoComplete...根据计算属性的特性可以推断,这些依赖的变更会触发 selectOptions 计算器重新执行计算,进而隐式调用了 applyProp 函数,也就是说,有太多种可能性,会导致 applyProp 被调用。如果要一一追溯这些可能性,问题会变的非常庞大,所以这里换一种思路: selectOptions 计算器会将什么样的参数传递给 applyProp 函数?

2. 参数 node 是为何物?

在上面的 selectOptions 计算器源码的最后,会迭代 slotOptions 数组,将数组项以 node 参数形式,传入 applyProp 函数,那么我们只需要确定 slotOptions 是什么类型的数组就可以确定 node 参数的类型。在源码中搜索,确定 slotOptions 属性分别在两个地方被赋值:

data () {
    return {
        // ...
        slotOptions: this.$slots.default,
        // ...
    };
},
methods:{
    // ...
    updateSlotOptions(){
        this.slotOptions = this.$slots.default;
    },
    // ...
}

可以看出, slotOptions 只是 this.$slots.default ,即组件的默认插槽的一个 替身,打印 slotOptions 值得:

slotOptions 值示例

可以看到,这是一个个 vnode 实例

这里可以引发另外一个思考:为什么要通过 data 属性,保存 this.$slots.default 值?

组件渲染时,调用相应的 render 函数,生成 vnode 树,但 vnode 树并不在 vue 的响应系统中 —— vnode 的变化无法被捕捉, 所以,为了监控 vnode 的变化,iview 将其加入 data 属性,为此,iview 还专门加了一个组件 functional-options.vuevnode 更新的检测。

当我们需要监控响应系统外的变化时,这种方案确实简单粗暴直接,但在 i-select 这种场景下,其实有更优雅的解决方案:

使用 slot-scope 替代简单的 slot

这是事关组件设计,非常值得思考的一个点,回头另起一篇文章讨论。

3. 为什么会出现 node.componentOptions 值为 undefined 的情况?

我们回头看看 slotOptions 值:

slotOptions 值示例

数组的第一项有些奇怪, tag 值为 undefined?展开此项,对象的属性值如下:

slotOptions 第一项值

Tips: tagundefined 的节点是通过 _t 函数创建的,即 vnode 中的 text 节点。

在该项上执行计算属性 selectOptions 中的求值函数

getNestedProperty(node, "componentOptions.propsData.value");

取得的值刚好为 null。好了,问题到这里已经找到了,异常触发的步骤如下:

  1. 计算器 selectOptions 遍历 this.$slots.default 节点
  2. 查找 vnode 节点中,值等于 value 属性的节点
  3. value 值为 null 时, $slots.default 第一个文本节点符合条件
  4. 将该文本节点传入 applyProps 函数
  5. applyProps 函数执行 node.componentOptions.propsData 求值,但文本节点的 componentOptions 属性为 undefined
  6. 触发 TypeError: Cannot read property 'propsData' of undefined 异常

解决方案

综上,我们已经找到了问题触发的步骤、场景,找到问题所在,接下来,该撸起袖子改代码了。针对这个 bug,我粗浅地想到了几个解决方案:

1. 在代码中避免使用 null

这应该是用户侧能想到的最靠谱,成本最低,也是最无奈的方案了:既然框架不靠谱,那就限制我自己的用法好了。 确实,大多数应用场景中是可以避免使用 null 作为 key, 如果这是 By Designed,那只要把文档补充完整,把事情说清楚,我相信大多数开发者是能够接受的; 但这个行为完全是预期外的 —— 没有任何官方文档说明这个问题,在 git 上相关的 issue 也不多。 作为用户,也只能默默遇坑,填坑,从错误中学习。

2. 过滤掉 tag 为 undefined 的 node

既然问题出现在计算 selectOptions 属性时,传递了错误的 vnode 节点,那修改 i-select 组件代码,在调用 applyProps 之前做好参数判断即可,示例代码:

selectOptions() {
    // ...
    const slotOptions = (this.slotOptions || []);
    // ...
    if (this.autoComplete) {
        // ...

        return slotOptions.map(node => {
            // 加一段判断语句,当 tag 不在预期时,简单返回该node
            if (typeof node.tag === "undefined") return node;
            if (node === selectedSlotOption || getNestedProperty(node, 'componentOptions.propsData.value') === this.value) {
                return applyProp(node, 'isFocused', true);
            }
            return copyChildren(node, (child) => {
                if (child !== selectedSlotOption) return child;
                return applyProp(child, 'isFocused', true);
            });
        });
    }
    // ...
},

修改成本很低,也很有效,但这种方式却预定了不能传入 tagundefined 的值,比如:

<AutoComplete v-model="value" placeholder="input">
  <template v-for="item in persons">{{ item }}</template>
</AutoComplete>

即不能以字符串方式传递 slot 值,这一点与 iview 官网声称调用方式兼容,所以问题不大,勉强可以接受。

总结

问题到这里结束了吗?并没有!这么一个小小的 bug,其实可以引发非常多值得思考的点:

  1. iview 中为什么需要监控 vnode 值的变化?
  2. 为什么 $slots.default 值会出现预期外的文本节点?
  3. 如何用 slot-scope 方式,实现更优雅的代码结构?

相关的讨论已经在编写中,我们下次再会。

欢迎关注我的 github,未来会持续不断写一些原创技术文章,不毒舌,不吐槽,只是专注于技术本身。