# vue 散记

# 深入理解 Vue2 响应式

# 响应式数据为何不响应

先看下面的代码,组件在挂载后请求了接口,更改了数据,那视图为何不更新呢?update 相关钩子也没有触发。看完代码会有对应的解释。

<template>
  <div class="outer">
    <p>this.parentObj数据:</p>
    <div>a--: {{ parentObj.a }}</div>
    <div>b--: {{ parentObj.b }}</div>
    <br />
    <p>this.parentArr数据:</p>
    <ul>
      <li v-for="item in parentArr" :key="item.key">
        <input v-model="item.value" />
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    console.log("父组件初始化data");
    return { parentObj: {}, parentArr: [] };
  },
  beforeMount() {
    console.log("父组件开始挂载");
  },
  mounted() {
    console.log("父组件挂载了");
    // 模拟接口请求
    console.log("父组件在mounted里面的接口开始请求");
    setTimeout(() => {
      console.log("父组件在mounted里面的接口请求成功");
      this.parentObj.a = "a";
      this.parentObj.b = "b";
      this.parentArr[0] = { key: "c", value: "c" };
      this.parentArr[1] = { key: "d", value: "d" };
    }, 3000);
  },
  beforeUpdate() {
    console.log("父组件准备更新");
  },
  updated() {
    console.log("父组件更新了");
  },
};
</script>

<style scoped>
.outer {
  padding: 10px;
  background-color: dimgray;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

为何视图不更新

如果有读过前面写的1.vue 基础里的第七章内容Vue 的数据监视,就会知道上面例子不更新视图的原因了。下面就简单说一下。

在执行组件构造函数时(初始化时),会对传进来的 option 配置项(data、props、methods 等)进行“解读”,其中将 data 和 props 就使用了Object.defineProperty()做成了响应式数据。响应式数据,我们常用的简单类型就不用说了,这里要说的就是上面例子中的对象数组(特殊对象)。

vue 会遍历(深层次的是递归)对象的属性,将它的属性使用Object.defineProperty()里的gettersetter,也就是在访问修改对象的属性时就会“响应”。

至于数组,它是比较特殊的、聚合式的对象。vue 对数组每一项这项本身不会做成响应式,但是这项如果是个对象,就会对这个对象的属性使用Object.defineProperty()做成了响应式的;那数组每项本身怎么办呢?每一项都是带了下标索引的,vue 对数组的一些操作方法“动了手脚”,例如push()pop()shift()unshift()splice()sort()reverse()方法在被人使用时会“响应式”。

说到这里,你就应该明白Object.defineProperty()一直是围绕着对象属性来实现响应式的。那么,对象和数组如果作为了另一个响应式对象属性,那么这个对象和数组就是响应式的(注意,数组某索引位置上的对象不是这种情况)。data()return的对象就是,比如上面那个例子,parentObjparentArr就是作为data() {return {}}return的对象的属性(叫做根级响应式 property),那么parentObjparentArr本身是响应式的。

上面的文字要仔细思考和理解。那我们改下代码,让组件的视图可以进行更新。数组parentArr的可以改成this.parentArr.push({ key:"c", value: "c" }),属于那 8 中方式之一,效果如下。

数组push等方法能触发响应式

数组parentArr的还可以改成this.parentArr = [{ key:"c", value: "c" }, { key:"d", value: "d" }]。这种刚刚也讲了,parentArr本身是datareturn的对象的属性(根级响应式 property),效果如下。

根级别property-数组

对象parentObj的改法,this.parentObj={a:'a',b:'b'},它受限于data()里定义时的情况,给它声明了属性就可以更改该属性进行视图重新渲染,你一个属性都没设置那目前就只能替换整个对象(接下来会说额外加响应式属性),效果如下。

根级别property-对象

最后要说的一点就是,对象在组件初始化时(执行构造函数),没有预先设置属性(像上面的parentObj在最初一个属性都没有),我现在突然想加上新的响应式属性要怎么做?如果你是想加根级响应式 property,这个是不允许的,只能在data()里提前定义(声明)好根级响应式 property。如果你想加在一个已存在的响应式对象上,那么可以使用Vue.set或者vm.$set(修改已有的也行)。

Vue.set添加响应式属性-对象

对于数组,Vue.set或者vm.$set可以为它添加或修改某索引上的那项,如果那项是个对象,那么会将该对象的属性做成一个响应式的。但是,那项本身不是响应式的,例如this.parentArr[0] = { key: "e", value: "e" }这样的还是不行。

Vue.set添加响应式属性-数组

最后的最后再啰嗦一遍,Object.defineProperty()是围绕对象属性实现响应式的,这是在组件初始化时做的(组件构造函数只会最初执行一次)。后面没有通过 vue 提供的方法来加的属性没有响应式,没有响应式!没有响应式!如果要(修改值不算),就一定得通过 Vue 提供的方法(你想无中生有,不可能!得补票!)。所以,要么你在 data/props 里声明好对象有哪些属性,要么后面自己用Vue.set或者vm.$set(切记不能加根级响应式 property)!

# props 与响应式

我们经常对组件进行拆分,然后使用 prop 进行传参,子组件用 props 进行接收。子组件的 props 也会像 data 一样做成响应式数据。只是 props 是不允许修改的,我们经常将 props 赋给 data 里的数据。来看一些问题。

# 模板要怎样才重新渲染

<template>
  <div class="outer">
    <p>this.parentObj数据:</p>
    <div>a--: {{ parentObj.a }}</div>
    <div>b--: {{ parentObj.b }}</div>
    <br />
    <p>this.parentArr数据:</p>
    <ul>
      <li v-for="item in parentArr" :key="item.key">
        <input v-model="item.value" />
      </li>
    </ul>
    <Inner :childObj="childObj" :childArr="childArr" />
    <!-- <Inner /> -->
  </div>
</template>

<script>
import Inner from "./components/Inner.vue";
export default {
  name: "App",
  components: { Inner },
  data() {
    console.log("父组件初始化data");
    return {
      parentObj: { a: "a", b: "b" },
      parentArr: [
        { key: "c", value: "c" },
        { key: "d", value: "d" },
      ],
      childObj: {},
      childArr: [],
    };
  },
  beforeMount() {
    console.log("父组件开始挂载");
  },
  mounted() {
    console.log("父组件挂载了");
    // 模拟接口请求
    console.log("父组件在mounted里面的接口开始请求");
    setTimeout(() => {
      console.log("父组件在mounted里面的接口请求成功");
      this.childObj = { e: "e", f: "f" };
      this.childArr = [
        { key: "g", value: "g" },
        { key: "h", value: "h" },
      ];
    }, 3000);
  },
  beforeUpdate() {
    console.log("父组件准备更新");
  },
  updated() {
    console.log("父组件更新了");
  },
};
</script>

<style scoped>
.outer {
  padding: 10px;
  background-color: dimgray;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
<template>
  <div class="inner">模板没有使用props和data</div>
</template>

<script>
export default {
  name: "Inner",
  props: ["childObj", "childArr"],
  data() {
    console.log("子组件初始化data");
    return { myChildObj: {}, myChildArr: [] };
  },
  watch: {
    childObj: {
      handler(newValue, oldValue) {
        console.log("子组件watch childObj:新值newValue是", newValue, "旧值oldValue是", oldValue);
      },
    },
    childArr: {
      handler(newValue, oldValue) {
        console.log("子组件watch childArr:新值newValue是", newValue, "旧值oldValue是", oldValue);
      },
    },
  },
  beforeMount() {
    console.log("子组件开始挂载");
  },
  mounted() {
    console.log("子组件挂载了");
  },
  beforeUpdate() {
    console.log("子组件准备更新");
  },
  updated() {
    console.log("子组件更新了");
  },
};
</script>

<style>
.inner {
  padding: 20px;
  margin: 20px;
  background-color: forestgreen;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

我们会发现,在接口请求后,数据发生了改变,父组件进行了重新渲染,而子组件并没有,效果图如下。

组件何时重新渲染

这是因为组件的重新渲染,是 template 里用的数据发生变化时,才会重新渲染,否则是不会进行重新渲染的(当然,响应式数据变了会“通知”组件去重新解析模板,至于页面重不重新渲染是元素 diff 比较结果而决定的)。像上面的例子,父组件的<Inner />处就因为childObjchildArr的数据变化而导致重新渲染,如果此时去掉这两个,那么父组件在 mounted 后就不会引起视图重新渲染了。子组件更加明显,它的 template 根本就没有用到任何数据,就更不用谈重新渲染视图了。

# props 数据变了但页面未变

上面这个都是小问题。如果将 props 赋给 data 里的属性,会怎样?看下面的代码。

<template>
  <div class="outer">
    <p>this.parentObj数据:</p>
    <div>a--: {{ parentObj.a }}</div>
    <div>b--: {{ parentObj.b }}</div>
    <p>this.parentArr数据:</p>
    <ul>
      <li v-for="item in parentArr" :key="item.key">
        <input v-model="item.value" />
      </li>
    </ul>
    <Inner :childObj="childObj" :childArr="childArr" />
  </div>
</template>

<script>
import Inner from "./components/Inner.vue";
export default {
  name: "App",
  components: { Inner },
  data() {
    console.log("父组件初始化data");
    return {
      parentObj: { a: "a", b: "b" },
      parentArr: [
        { key: "c", value: "c" },
        { key: "d", value: "d" },
      ],
      childObj: {},
      childArr: [],
    };
  },
  beforeMount() {
    console.log("父组件开始挂载");
  },
  mounted() {
    console.log("父组件挂载了");
    // 模拟接口请求
    console.log("父组件在mounted里面的接口开始请求");
    setTimeout(() => {
      console.log("父组件在mounted里面的接口请求成功");
      this.childObj = { e: "e", f: "f" };
      /* this.$set(this.childObj, "e", "e");
      this.$set(this.childObj, "f", "f"); */
    }, 3000);
  },
  beforeUpdate() {
    console.log("父组件准备更新");
  },
  updated() {
    console.log("父组件更新了");
  },
};
</script>
<style scoped>
.outer {
  padding: 5px;
  background-color: dimgray;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<template>
  <div class="inner">
    <ul>
      <li v-for="item in myChildObj.childObj" :key="item">{{ item }}</li>
    </ul>
  </div>
</template>

<script>
export default {
  name: "Inner",
  props: ["childObj", "childArr"],
  data() {
    console.log("子组件初始化data");
    return { myChildObj: { childObj: this.childObj }, myChildArr: [] };
  },
  beforeMount() {
    console.log("子组件开始挂载");
  },
  mounted() {
    console.log("子组件挂载了");
  },
  beforeUpdate() {
    console.log("子组件准备更新");
  },
  updated() {
    console.log("子组件更新了");
  },
};
</script>
<style>
.inner {
  padding: 20px;
  margin: 20px;
  background-color: forestgreen;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

我们会发现,在接口请求后,数据发生了改变,父组件进行了重新渲染,而子组件并没有,效果图如下。

用data转存props但并没有重新渲染

这是因为myChildObj.childObj一直都是{}空字面量对象,即使myChildObjchildObj本身被做成了响应式属性,但这又如何?父组件的 mounted 中改了childObj的地址值(引用),让它指向一个新对象;在子组件中,props 中的childObj也会随着父组件变化,它也指向了一个新对象(因为直接将childObj的新引用传进来了);那么子组件的 data 里的myChildObj.childObj也会指向新对象吗?

当然不会,它们之间只是初始化时data(){}中进行了一个“地址值”(引用)的复制,对象中的:就是赋值,对象引用赋给了myChildObj.childObj。总的来说,子组件的 props 的this.childObj指向的对象已经变了,而 data 的this.myChildObj.childObj还是指向的是老对象,子组件的 template 里用的数据是this.myChildObj.childObj而不是this.childObj,所以子组件不会重新渲染

# 怎样修改前面的代码

那么怎样更改呢?将父组件代码中的this.childObj = { e: "e", f: "f" }进行注释,它下面的两行代码的注释放开即可。也就是加了this.$set(this.childObj, "e", "e")this.$set(this.childObj, "f", "f"),看过上一小节就知道childObj在父组件初始化时没有ef两个响应式属性,所以 mounted 后要加上两个新响应式属性就得用this.$set。只要不是更改父组件的 childObj 的引用,那么父组件childObj和子组件的 props 的childObj以及 data 的myChildObj.childObj就会指向同一个对象,对象添加新响应式属性也就是响应式数据变化了,那么父组件和子组件都会进行重新渲染。效果图如下。

用data转存props重新渲染了

还有另一种改法,在子组件了监听childObj的变化,一旦它变了,就将它重新赋值给myChildObj.childObj。并由于myChildObj.childObj是个响应式属性,它变了就能引起子组件重新渲染。效果图如下。

用data转存props重新渲染了2

# prop 传数组是同样的问题

经常会遇到,在父组件请求接口得到数据,经过简单处理后,截取其中的数组类型的表格数据,将该数据通过 prop 传递给子组件。然后这个表格还会包含有 Input 控件,也就是说数据模型是个表单数据。例如this.form.list,这个 list 实际是通过 prop 传过来的。

它遇到的问题可能和上一小节差不多。出现问题的原因可能是处理接口响应时,中途没有一条一条 push 进父组件的this.list,而是在最后处理完直接this.list = list。list 引用对象被替换成新的了,子组件还是用的旧的,这就导致子组件表格渲染不出来。push可以解决,this.$set(this.list, index, obj)也能解决,在子组件写watch也能解决。

<template>
  <div class="outer">
    我是父组件:父组件暂时不显示相关数据
    <Inner :childObj="childObj" :childArr="childArr" />
  </div>
</template>

<script>
import Inner from "./components/Inner.vue";
export default {
  name: "App",
  components: { Inner },
  data() {
    console.log("父组件初始化data");
    return {
      childObj: {},
      childArr: [],
    };
  },
  beforeMount() {
    console.log("父组件开始挂载");
  },
  mounted() {
    console.log("父组件挂载了");
    // 模拟接口请求
    console.log("父组件在mounted里面的接口开始请求");
    setTimeout(() => {
      console.log("父组件在mounted里面的接口请求成功");
      this.childArr.push({ key: "a", value: "a" });
      this.childArr.push({ key: "b", value: "b" });
      /* this.$set(this.childArr, 0, { key: "a", value: "a" });
      this.$set(this.childArr, 1, { key: "b", value: "b" }); */
    }, 3000);
  },
  beforeUpdate() {
    console.log("父组件准备更新");
  },
  updated() {
    console.log("父组件更新了");
  },
};
</script>

<style scoped>
.outer {
  padding: 5px;
  background-color: dimgray;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<template>
  <div class="inner">
    <ul>
      <li v-for="item in myChildObj.childArr" :key="item.key"><input v-model="item.value" /> | {{ item.value }}</li>
    </ul>
  </div>
</template>

<script>
export default {
  name: "Inner",
  props: ["childObj", "childArr"],
  data() {
    console.log("子组件初始化data");
    return {
      myChildObj: {
        childArr: this.childArr,
      },
      myChildArr: [],
    };
  },
  beforeMount() {
    console.log("子组件开始挂载");
  },
  mounted() {
    console.log("子组件挂载了");
  },
  beforeUpdate() {
    console.log("子组件准备更新");
  },
  updated() {
    console.log("子组件更新了");
  },
};
</script>

<style>
.inner {
  padding: 20px;
  margin: 20px;
  background-color: forestgreen;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

用data转存props重新渲染了-数组

如果这个子组件是个弹窗 dialog,在没有真正保存到服务器时,这个数组类型的表格数据还要有最原始的一份(或者将数据还原)。其实和上面一样,只是多了将数组复制一份(例如 concat 等)这个步骤,将原始数据和正在操作的数据分隔即可。用 props 方式,如果在父组件里复制 list 再传进去,那么会在 data有两个类似的 list;要是在传到子组件里再复制,它可能通过props 能修改对象的 bug就你的原始 list 弄乱(props 原则上是不允许修改的)。那么最好还是在父组件复制了再传进去。如果你实在不想在父组件里多处一个“正操作”的 list,那么可以考虑使用 EventBus全局事件总线

如果子组件不是弹窗而改用 EventBus 那就有点没必要了,孙组件或者兄弟组件可以用 EventBus;如果是自上而下传方法,可以考虑provide/inject;如果是小范围组件的数据共享,可以考虑使用Vue.observable( object ),它是小型的 Vuex。