彻底解决不渲染

学会解决所有“数据更新不渲染”的问题,是你离开 Vue 新手村之前接到的最后一个主线任务。解完这个任务,你就会离开新手村,来到大陆上最大的城市爪哇斯克瑞普,开始真正的前端大冒险。

要解掉这个任务,你必须:

  1. 理解 Vue 双向绑定的实现机制
  2. 能够使用工具检查双向绑定
  3. 推测代码是否有问题

如果你能好好读懂 Vue 的代码,自然这些难点都困不住你,不过作为屠龙技,这个时候去学习,对于我们来说为时尚早。那么,就请随我按照由浅入深的方式来解决这个任务吧。

神奇的 MVVM

我们将时间稍稍往回拨一点,看看 UI 技术的发展,和 MVVM 的诞生。

图形界面诞生后,软件行业将越来越多现实中存在已久的东西搬到计算机屏幕上,比如表单、比如各类印刷内容。接下来,更简单更好维护的描述性语言(HTML、CSS)登场,写 UI 不再是专业程序员的工作(我当年也是作为页面仔入行的)。再接下来,大家发现,无论你的业务逻辑如何变化,但是大体上,抽象的数据流动只有两个方向:

  1. 用户操作视觉元素,产生事件,侦听这些事件,我们得知用户想要怎样操作数据
  2. 数据更新后,更新视图,让用户能够看到最新的数据

前者即“视图 => 数据”绑定,后者即“数据 => 视图”绑定。二者合二为一,就是我们常说的“数据、视图双向绑定”,简称“双向绑定”。这是 MVVM 模式的基础。MVVM 模式希望把“双向绑定”在框架里实现,这样开发者就可以忽略这部分代码,放心大胆的搞业务逻辑去了。事实证明,基于 MVVM 的代码行数,基本只有基于 MVC/MVP 的 1/10,效果非常明显。

要实现这一点并不容易。更新视图不难,HTML 是描述性语言,本身没有逻辑,非常容易解析,也非常容易模板化。实现 MVVM 的难点主要有两个:

  1. 如何准确地最小化更新视图
  2. 如何知道数据更新了

这当然难不倒已经摸清大方向的开发者,很快,数种不同的 Web 前端数据双向绑定方案开始齐头并进。它们的基础逻辑是相似的:

  1. 使用模板而不是 HTML 写页面
  2. 模板会被编译成 JS 函数
  3. 对函数进行预处理,提取依赖(即某些 DOM 属性和内容依赖哪些变量的值)
  4. 想办法侦听依赖变化
  5. 依赖的值变化时,更新需要变化的视图

又经过几年发展,在“难点1”上,众 MVVM 框架殊途同归,都选择“虚拟 DOM diff”作为最小化更新视图的基础。大家的差别主要在于处理“难点2”的方式。

Vue 神力的源泉:Object.defineProperty

比较各框架的入门难度,Vue 是最低的。一方面,Vue 被设计成开箱即用,避免引入太多眼花缭乱的新概念。另一方面,Vue 判断依赖更新的方式很独特:

  1. 实例化时,将表示数据的属性逐一进行替换
  2. 具体来说,就是位于 data 里的属性
  3. 这些属性会被替换成 get 方法和 set 方法,真实值会被隐藏起来
  4. 之后访问属性,其实是调用对应的 get 方法和 set 方法
  5. 于是,赋值时触发依赖变化,就很容易

在我们普通开发者看来,修改数据,触发视图更新,就是直接 this.foo = 'bar' 这般简单,如前文所说,简直如同魔法。

魔力的源泉,是新的对象模型,和 Object.defineProperty,建议大家到 MDN 上一探究竟。(这些都是 ES5 规范的内容,所以在早期浏览器(IE 8-)里无法正常使用。)

Vue 文档称此为“响应式”,即界面会响应变量的变化而变化。作为前端的大家需要把它和基于 Media Query 的响应式区分开哦,后者通常用来实现移动端和桌面端多端布局。本文提到的响应式,则都是指 Vue 的响应式。

注意:Vue 3 将用 ES2015 中的 Proxy 进行底层重构,届时,Vue 将不再依赖 Object.defineProperty 实现依赖变更的捕获。不过,就像用电一样,用火电还是用水电,对家用电器来讲是一样的;用何种底层数据侦听机制,对于我们开发者来说也是一样的。大家注意一下以后面试的时候不要说错即可。

那么,问题来了

假设我们有这样一个 Vue 类:

#app {{message}}
const app = new Vue({
  el: '#app',
  data: {
    message: 'Hello Vue!',
  },
});

它的运行结果会如同官网范例所示。

假如我们修改模板,增加一个对象的显示:

#app {{message}}, {{name}}

然后赋值给 app

// .... 重复前面的代码
app.name = 'Meathill';

这个时候,我们预期文字变成:

Hello Vue! meathill

但它实际运行结果还是

Hello Vue!

为什么呢?

首先,对于任意一个 JS plain object,除非我们主动标明某个属性为“只读”(或称“不可写”,writable: true),不然都是可以写入的。所以 app.name = 'meathill'; 是合法操作。不产生任何错误,而且接下来检查 app.name,还能得到 meathill 这个刚刚赋进去的值。

但是 Vue 只会为预先声明的属性做好准备,也就是 data 里的那些。这些值,无论你声明成什么,在实例化时都会被替换成 getter/setter。没有事先声明的属性,自然没有 setter,JS 也没有提供监控对象属性增删的方法,所以给未初始化的变量赋值,不会触发依赖变化的监控,也就没有后面的视图自动刷新。

解决方案

知道症结所在,我们就可以对症下药。

方案一

确保所有会用到的变量都事先声明。包括业务逻辑相关的变量和界面控制变量,这里尤其要注意后者,比如 isLoading 控制加载状态、isSubmitting 表示正在提交表单等,因为这些变量的变化多半都会触发界面变化,是最依赖响应式的。

如果变量特别多,可能会不太好维护。这个时候我们可以把变量分组,然后用展开运算符 ... 合并输出,比如

data() {
  const ui = {
    isLoading: false,
    isSaving: false,
    isChecking: false,
    // ...
  };
  const work = {
    userInfo: null,
    posts: null,
    comments: null,
    // ...
  };
  return {
    ...ui,
    ...work,
  };
}

方案二

使用 Vue.set 或者 vm.$set 显示增加响应式变量。

有时候,可能会有不定项的变量需要响应式,全部写一遍很烦,也不够灵活,这个时候我们可以用 Vue.set 或者 vm.$set 在运行时动态声明。

比较常见的场景是一个配置页面,里面有若干配置项,大多数是可选,后端只返回用户配置过的部分。这个时候如果我们一个一个写就很累人。同时配置项多半不太需要响应式,这时用 Vue.set 也是不错的选择。

检查响应式

那么如何检查一个值是否被正确初始化呢?通过浏览器的开发者工具就可以做到。

首先,打开开发者工具。

接下来,找到我们要检查的类。

开发工具截图1

在正确的位置打上断点。

开发工具截图2

触发函数执行,开发者工具会在断点处中断。

展开实例属性,找到对应的值,你会发现,所有值都是 (...),这是因为它已经被替换成 getter/setter,必须点一下,浏览器执行它的 get 方法才能得到真实值。

开发工具截图3

如果你看到的是普通的字符、数值等常规类型,那就说明这个变量未被初始化,也就不能响应式了。

数组和对象

我们知道,JS 的数据类型可以简单分为两大类:简单数据类型和复杂数据类型。前者包含字符、数值、布尔、null;后者则包罗万象,数组、对象、各种实例均是。

前面我们说到的会被替换成 set/get 的是“简单数据类型”。对复杂数据类型的操作更复杂一些,Vue 会递归遍历对象的所有属性,对其进行替换,将所有简单数据类型都换成 set/get

比如有一个帖子列表类,它有一个属性 posts 用来存放所有帖子信息:

export default {
  data() {
    return {
      posts: null,
    };
  },
}

当我们使用

this.posts = [
  {
    id: 1,
    title: 'to be a better vuer',
  },
  // 其它帖子信息
];

对其进行赋值时,后面这个数组的所有元素的所有属性,都会被替换。所以如果我们赋值之后修改 this.posts[0].title = 'hello world',因为 posts[0].title 已经是 setter 函数,所以 Vue 就会收集到依赖变动,继而重新渲染视图。那么这个帖子的标题就会变成 'hello world'

想象这种需求:我们要给每个帖子实现删除按钮,点击删除按钮触发 DELETE 请求,并且在请求成功时把这一行干掉。它的模板应该是这样的:

tr(v-for="post in posts", :key="post.id")
  td {{post.id}}
  td {{post.title}}
  td
    button.btn.btn-danger(
      type="button",
      :disabled="post.isDeleting",
      @click="doDelete(post)",
    )
      i.fas.fa-spin.fa-spinner(v-if="post.isDeleting")
      i.fas.fa-trash(v-else)
      span.ml-2 删除

此时如果我们这样写方法,就不会看到预期的效果:

async doDelete(post) {
  post.isDeleting = true;
  await http.delete(post.id);
  post.isDeleting = false;
}

因为帖子列表数据 posts 被赋给本地模板依赖属性 this.posts 时,没有 isDeleting 这个属性,所以也不存在对应的 setter,所以 Vue 不知道我们修改了它的值,也不知道该重新渲染界面。

解决方法比较简单,就是在赋值的时候,先进行一步处理,把可能用到的界面渲染要素都加进去:

this.posts = [/*....*/].map(item => {
  return {
    ...item,
    isDeleting,
    isSaving,
    isToggling,
    // 有多少写多少
  };
});

总结

基本上,只要你:

  1. 理解了 Vue 响应式渲染的原理;
  2. 知道如何检查一个属性是否被替换成 getter/setter
  3. 在使用中提前赋值,或者用 Vue.set 增加响应式属性

基本上就能解决所有页面不渲染问题了。

另外,官方文档:深入响应式原理也一定要仔细读几遍。

results matching ""

    No results matching ""