Vue源码解析之数组变异的实现


Posted in Javascript onDecember 04, 2018

力有不逮的对象

众所周知,在 Vue 中,直接修改对象属性的值无法触发响应式。当你直接修改了对象属性的值,你会发现,只有数据改了,但是页面内容并没有改变。

这是什么原因?

原因在于: Vue 的响应式系统是基于Object.defineProperty这个方法的,该方法可以监听对象中某个元素的获取或修改,经过了该方法处理的数据,我们称其为响应式数据。但是,该方法有一个很大的缺点,新增属性或者删除属性不会触发监听,举个栗子:

var vm = new Vue({
 data () {
  return {
   obj: {
    a: 1
   }
  }
 }
})
// `vm.obj.a` 现在是响应式的

vm.obj.b = 2
// `vm.obj.b` 不是响应式的

原因在于,在 Vue 初始化的时候, Vue 内部会对 data 方法的返回值进行深度响应式处理,使其变为响应式数据,所以, vm.obj.a 是响应式的。但是,之后设置的 vm.obj.b 并没有经过 Vue 初始化时响应式的洗礼,所以,理所应当的不是响应式。

那么,vm.obj.b可以变成响应式吗?当然可以,通过 vm.$set 方法就可以完美地实现要求,在此不再赘述相关原理了,之后应该会写一篇文章讲述 vm.$set 背后的原理。

更凄惨的数组

上面说了这么多,还没有提到本篇文章的主角——数组,现在该主角出场了。

比起对象,数组的境遇更加凄惨一些,看看官方文档:

由于 JavaScript 的限制, Vue 不能检测以下变动的数组:

  1. 当你利用索引直接设置一个项时,例如:vm.items[indexOfItem] = newValue
  2. 当你修改数组的长度时,例如:vm.items.length = newLength

有可能官方文档不是很清晰,那我们继续举个栗子:

var vm = new Vue({
  data () {
    return {
      items: ['a', 'b', 'c']
    }
  }
})
vm.items[1] = 'x' // 不是响应性的
vm.items.length = 2 // 不是响应性的

也就是说,数组连自身元素的修改也无法监听,原因在于, Vue 对 data 方法返回的对象中的元素进行响应式处理时,如果元素是数组时,仅仅对数组本身进行响应式化,而不对数组内部元素进行响应式化。

这也就导致如官方文档所写的后果,无法直接修改数组内部元素来触发响应式。

那么,有没有破解方法呢?

当然有,官方规定了 7 个数组方法,通过这 7 个数组方法,可以很开心地触发数组的响应式,这 7 个数组方法分别是:

  • push()
  • pop()
  • shift()
  • unshift()
  • splice()
  • sort()
  • reverse()

可以发现,这 7 个数组方法貌似就是原生的那些数组方法,为什么这 7 个数组方法可以触发应式,触发视图更新呢?

你是不是心里想着:数组方法了不起呀,数组方法就可以为所欲为啊?

骚瑞啊,这 7 个数组方法是真的可以为所欲为的。

因为,它们是变异后的数组方法。

数组变异思路

什么是变异数组方法?

变异数组方法即保持数组方法原有功能不变的前提下对其进行功能拓展,在 Vue 中这个所谓的功能拓展就是添加响应式功能。

将普通的数组变为变异数组的方法分为两步:

  • 功能拓展
  • 数组劫持

功能拓展

先来个思考题:

有这样一个需求,要求在不改变原有函数功能以及调用方式的情况下,使得每次调用该函数都能在控制台中打印出'HelloWorld'

其实思路很简单,分为三步:

  • 使用新的变量缓存原函数
  • 重新定义原函数
  • 在新定义的函数中调用原函数

看看具体的代码实现:

function A () {
  console.log('调用了函数A')
}

const nativeA = A
A = function () {
  console.log('HelloWorld')
  nativeA()
}

可以看到,通过这种方式,我们就保证了在不改变 A 函数行为的前提下对其进行了功能拓展。

接下来,我们使用这种方法对数组原本方法进行功能拓展:

// 变异方法名称
const methodsToPatch = [
 'push',
 'pop',
 'shift',
 'unshift',
 'splice',
 'sort',
 'reverse'
]

const arrayProto = Array.prototype
// 继承原有数组的方法
const arrayMethods = Object.create(arrayProto)

mutationMethods.forEach(method => {
  // 缓存原生数组方法
  const original = arrayProto[method]
  arrayMethods[method] = function (...args) {
    const result = original.apply(this, args)
    
    console.log('执行响应式功能')
    
    return result
  }
})

从代码中可以看出来,我们调用 arrayMethods 这个对象中的方法有两种情况:

  1. 调用功能拓展方法:直接调用 arrayMethods 中的方法
  2. 调用原生方法:这种情况下,通过原型链查找定义在数组原型中的原生方法

通过上述方法,我们实现了对数组原生方法进行功能的拓展,但是,有一个巨大的问题摆在面前:我们该如何让数组实例调用功能拓展后数组方法呢?

解决这一问题的方法就是:数组劫持。

数组劫持

数组劫持,顾名思义就是将原本数组实例要继承的方法替换成我们功能拓展后的方法。

想一想,我们在前面实现了一个功能拓展后的数组 arrayMethods ,这个自定义的数组继承自数组对象,我们只需要将其和普通数组实例连接起来,让普通数组继承于它即可。

而想实现上述操作,就是通过原型链。

实现方法如下代码所示:

let arr = []
// 通过隐式原型继承arrayMethods
arr.__proto__ = arrayMethods

// 执行变异后方法
arr.push(1)

通过功能拓展和数组劫持,我们终于实现了变异数组,接下来让我们看看 Vue 源码是如何实现变异数组的。

源码解析

我们来到 src/core/observer/index.js 中在 Observer 类中的 constructor 函数:

constructor (value: any) {
  this.value = value
  this.dep = new Dep()
  this.vmCount = 0
  def(value, '__ob__', this)
  // 检测是否是数组
  if (Array.isArray(value)) {
    // 能力检测
    const augment = hasProto
    ? protoAugment
    : copyAugment
    // 通过能力检测的结果选择不同方式进行数组劫持
    augment(value, arrayMethods, arrayKeys)
    // 对数组的响应式处理
    this.observeArray(value)
  } else {
    this.walk(value)
  }
}

Observer 这个类是 Vue 响应式系统的核心组成部分,在初始化阶段最主要的功能是将目标对象进行响应式化。在这里,我们主要关注其对数组的处理。

其对数组的处理主要是以下代码

// 能力检测
const augment = hasProto
? protoAugment
: copyAugment
// 通过能力检测的结果选择不同方式进行数组劫持
augment(value, arrayMethods, arrayKeys)
// 对数组的响应式处理,很本文关系不大,略过
this.observeArray(value)

首先定义了 augment 常量,这个常量的值由 hasProto 决定。

我们来看看 hasProto

export const hasProto = '__proto__' in {}

可以发现, hasProto 其实就是一个布尔值常量,用来表示浏览器是否支持直接使用 __proto__ (隐式原型) 。

所以,第一段代码很好理解:根据根据能力检测结果选择不同的数组劫持方法,如果浏览器支持隐式原型,则调用 protoAugment 函数作为数组劫持的方法,反之则使用 copyAugment

不同的数组劫持方法

现在我们来看看 protoAugment 以及 copyAugment

function protoAugment (target, src: Object, keys: any) {
 /* eslint-disable no-proto */
 target.__proto__ = src
 /* eslint-enable no-proto */
}

可以看到, protoAugment 函数极其简洁,和在数组变异思路中所说的方法一致:将数组实例直接通过隐式原型与变异数组连接起来,通过这种方式继承变异数组中的方法。

接下来我们再看看 copyAugment

function copyAugment (target: Object, src: Object, keys: Array<string>) {
 for (let i = 0, l = keys.length; i < l; i++) {
  const key = keys[i]
  // Object.defineProperty的封装
  def(target, key, src[key])
 }
}

由于在这种情况下,浏览器不支持直接使用隐式原型,所以数组劫持方法要麻烦很多。我们知道该函数接收的第一个参数是数组实例,第二个参数是变异数组,那么第三个参数是什么?

// 获取变异数组中所有自身属性的属性名
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)

arrayKeys 在该文件的开头就定义了,即变异数组中的所有自身属性的属性名,是一个数组。

回头再看 copyAugment 函数就很清晰了,将所有变异数组中的方法,直接定义在数组实例本身,相当于变相的实现了数组的劫持。

实现了数组劫持后,我们再来看看 Vue 中是怎样实现数组的功能拓展的。

功能拓展

数组功能拓展的代码位于 src/core/observer/array.js ,代码如下:

import { def } from '../util/index'

// 缓存数组原型
const arrayProto = Array.prototype
// 实现 arrayMethods.__proto__ === Array.prototype
export const arrayMethods = Object.create(arrayProto)

// 需要进行功能拓展的方法
const methodsToPatch = [
 'push',
 'pop',
 'shift',
 'unshift',
 'splice',
 'sort',
 'reverse'
]

/**
 * Intercept mutating methods and emit events
 */
methodsToPatch.forEach(function (method) {
 // cache original method
 // 缓存原生数组方法
 const original = arrayProto[method]
 // 在变异数组中定义功能拓展方法
 def(arrayMethods, method, function mutator (...args) {
  // 执行并缓存原生数组方法的执行结果
  const result = original.apply(this, args)
  // 响应式处理
  const ob = this.__ob__
  let inserted
  switch (method) {
   case 'push':
   case 'unshift':
    inserted = args
    break
   case 'splice':
    inserted = args.slice(2)
    break
  }
  if (inserted) ob.observeArray(inserted)
  // notify change
  ob.dep.notify()
  // 返回原生数组方法的执行结果
  return result
 })
})

可以发现,源码在实现的方式上,和我在数组变异思路中采用的方法一致,只不过在其中添加了响应式的处理。

总结

Vue 的变异数组从本质上是来说是一种装饰器模式,通过学习它的原理,我们在实际工作中可以轻松处理这类保持原有功能不变的前提下对其进行功能拓展的需求。希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
javaScript 判断字符串是否为数字的简单方法
Jul 25 Javascript
深入认识javascript中的eval函数
Nov 02 Javascript
JavaScript与DOM组合动态创建表格实例
Dec 23 Javascript
返回上一页并自动刷新的JavaScript代码
Feb 19 Javascript
js实现图片和链接文字同步切换特效的方法
Feb 20 Javascript
JS实现带关闭功能的阿里妈妈网站顶部滑出banner工具条代码
Sep 17 Javascript
javascript实现简单的on事件绑定
Aug 23 Javascript
jquery中done和then的区别(详解)
Dec 19 jQuery
Vue项目查看当前使用的elementUI版本的方法
Sep 27 Javascript
记录微信小程序 height: calc(xx - xx);无效问题
Dec 30 Javascript
微信小程序 wx.getUserInfo引导用户授权问题实例分析
Mar 09 Javascript
Vue3.0 手写放大镜效果
Jul 25 Vue.js
小程序指纹验证的实现代码
Dec 04 #Javascript
js实现下拉框二级联动
Dec 04 #Javascript
解决JS表单验证只有第一个IF起作用的问题
Dec 04 #Javascript
详解基于Vue,Nginx的前后端不分离部署教程
Dec 04 #Javascript
浅析Vue.js中v-bind v-model的使用和区别
Dec 04 #Javascript
在vue项目中优雅的使用SVG的方法实例详解
Dec 03 #Javascript
React事件处理的机制及原理
Dec 03 #Javascript
You might like
phpExcel导出大量数据出现内存溢出错误的解决方法
2013/02/28 PHP
jQuery实现id模糊查询的小例子
2013/03/19 Javascript
asp.net中System.Timers.Timer的使用方法
2013/03/20 Javascript
jquery实现ajax提交form表单的方法总结
2014/03/03 Javascript
javascript使用prototype完成单继承
2014/12/24 Javascript
javascript中Math.random()使用详解
2015/04/15 Javascript
js拖拽的原型声明和用法总结
2016/04/04 Javascript
带有定位当前位置的百度地图前端web api实例代码
2016/06/21 Javascript
jQuery中的AjaxSubmit使用讲解
2016/09/25 Javascript
JQuery查找子元素find()和遍历集合each的方法总结
2017/03/07 Javascript
解决vue里碰到 $refs 的问题的方法
2017/07/13 Javascript
vue模块拖拽实现示例代码
2019/03/09 Javascript
微信小程序通过websocket实时语音识别的实现代码
2020/08/19 Javascript
[02:04]2014DOTA2国际邀请赛 DK一个时代的落幕
2014/07/21 DOTA
在Django中管理Users和Permissions以及Groups的方法
2015/07/23 Python
浅谈python多线程和队列管理shell程序
2015/08/04 Python
使用Python判断质数(素数)的简单方法讲解
2016/05/05 Python
Python max内置函数详细介绍
2016/11/17 Python
利用Opencv中Houghline方法实现直线检测
2018/02/11 Python
Python之循环结构
2019/01/15 Python
DRF跨域后端解决之django-cors-headers的使用
2019/01/27 Python
解析pip安装第三方库但PyCharm中却无法识别的问题及PyCharm安装第三方库的方法教程
2020/03/10 Python
使用HTML5的File实现base64和图片的互转
2013/08/01 HTML / CSS
可能这些是你想要的H5软键盘兼容方案(小结)
2019/04/23 HTML / CSS
Dyson戴森波兰官网:Dyson.pl
2019/08/05 全球购物
西安众合通用.net笔试题
2013/03/18 面试题
英语演讲稿范文
2014/01/03 职场文书
采购类个人求职的自我评价
2014/02/18 职场文书
商铺租赁意向书
2014/04/01 职场文书
2014年生产部工作总结
2014/12/17 职场文书
美术教师个人总结
2015/02/06 职场文书
王亚平太空授课观后感
2015/06/12 职场文书
校运会宣传稿大全
2015/07/23 职场文书
2016年感恩母亲节活动总结
2016/04/01 职场文书
大学社团活动总结怎么写
2019/06/21 职场文书
为什么mysql字段要使用NOT NULL
2021/05/13 MySQL