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 XML操作 封装类
Jul 01 Javascript
Prototype Function对象 学习
Jul 12 Javascript
了解jQuery技巧来提高你的代码(个人觉得那个jquery的手册很不错)
Feb 10 Javascript
JS随即打乱数组实现代码
Dec 03 Javascript
浅谈JavaScript字符串拼接
Jun 25 Javascript
JS实现的不规则TAB选项卡效果代码
Sep 18 Javascript
jQuery中的通配符选择器使用总结
May 30 Javascript
深入理解jQuery()方法的构建原理
Dec 05 Javascript
Vue.js原理分析之observer模块详解
Feb 17 Javascript
详解webpack4升级指南以及从webpack3.x迁移
Jun 12 Javascript
使用vscode快速建立vue模板过程详解
Oct 10 Javascript
JS实现百度搜索框
Feb 25 Javascript
小程序指纹验证的实现代码
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
PHP Class&amp;Object -- 解析PHP实现二叉树
2013/06/25 PHP
ThinkPHP内置jsonRPC的缺陷分析
2014/12/18 PHP
PHP+Mysql基于事务处理实现转账功能的方法
2015/07/08 PHP
PHP中header函数的用法及其注意事项详解
2016/06/13 PHP
如何使Chrome控制台支持多行js模式——意外发现
2013/06/13 Javascript
jQuery支持动态参数将函数绑定到事件上的方法
2015/03/17 Javascript
Javascript实现计算个人所得税
2015/05/10 Javascript
jQuery判断元素是否显示 是否隐藏的简单实现代码
2016/05/19 Javascript
JQuery Mobile 弹出式登录框的实现方法
2016/05/28 Javascript
jQuery插件passwordStrength密码强度指标详解
2016/06/24 Javascript
JavaScript定时器实现的原理分析
2016/12/06 Javascript
jQuery File Upload文件上传插件使用详解
2016/12/06 Javascript
Bootstrap下拉菜单Dropdowns的实现代码
2017/03/17 Javascript
NodeJS自定义模块写法(详解)
2017/06/27 NodeJs
Angularjs按需查询实例代码
2017/10/30 Javascript
AngularJS创建一个上传照片的指令实例代码
2018/02/24 Javascript
Puppeteer环境搭建的详细步骤
2018/09/21 Javascript
使用vue-cli webpack 快速搭建项目的代码
2018/11/21 Javascript
基于ajax及jQuery实现局部刷新过程解析
2020/09/12 jQuery
python模拟登陆阿里妈妈生成商品推广链接
2014/04/03 Python
在Docker上部署Python的Flask框架的教程
2015/04/08 Python
Python 爬虫学习笔记之单线程爬虫
2016/09/21 Python
详解python 发送邮件实例代码
2016/12/22 Python
Python中的pygal安装和绘制直方图代码分享
2017/12/08 Python
pandas 选择某几列的方法
2018/07/03 Python
详解python中的装饰器
2018/07/10 Python
python实现简单的学生管理系统
2021/02/22 Python
HTML5操作WebSQL数据库的实例代码
2017/08/26 HTML / CSS
超市实习总结自我鉴定
2013/09/19 职场文书
党课学习思想汇报
2014/01/02 职场文书
平安校园建设方案
2014/05/02 职场文书
铅球加油稿100字
2014/09/26 职场文书
2015年党员个人自我评价
2015/03/03 职场文书
2015年林业工作总结
2015/05/14 职场文书
爱国主义教育主题班会
2015/08/13 职场文书
zabbix配置nginx监控的实现
2022/05/25 Servers