手写Vue2响应式框架之数据劫持

大家好,我是小傲。这是 手写 Vue2 响应式框架 系列的第一篇,本篇将讲解 Vue2 是如何通过 defineProperty 完成对数据的劫持和递归操作,所有示例代码见文末。

一、前言

1、什么是Vue

2014 年尤雨溪发布 Vue,Vue 是一套用于构建 UI 界面的 Web 框架,允许采用简洁模板语法,来声明式的将数据渲染 DOM 的系统,数据和 DOM 绑定在一起,所有的元素都是响应式的。2015 年发布了 Vue 1.0 版本,2016 年发布了 Vue 2.0 版本,做了大幅度的重构,吸收了 React 的虚拟 Dom 方案,还支持服务端渲染,2020 年 发布了 Vue 3.0 版本,进行了响应性优化和编译优化。

2、什么是响应式

响应式是 Vue 里面的核心概念,是指视图随着模型变化而变化。在 Vue 中,开发者只需将视图与对应的模型进行绑定,Vue 便能自动观测模型的变动,并重绘视图。前端里的 Vue,就好比 ios 的 SwiftUI,就好比与 Android 的 Jetpack Compose,他们都属于声明式 UI 的范畴。

一句话总结:数据驱动视图

3、响应式的优势

响应式相比于命令式, 它有以下优势:

(1)没有 UI 刷新工作

在声明式 UI 里面,开发者要做的就只是定义数据与 UI 的映射关系而已,一旦定义好了后续只需关心数据的维护,不需要关心 UI 的刷新,极大减轻了开发者在 UI 上的工作。这也就是目前前端进入 Vue 和 React 时代完成编码工作比 Android 快的原因之一。

(2)不易出错

显而易见,UI 刷新是由框架背后实现,开发者只需要刷新数据即可,框架的可靠性保证了数据与 UI 的一致性。

(3)性能提升

由于刷新由框架进行实现,大部分声明式 UI 框架不会数据一变就全量刷新,提升了性能。

4、为什么要讲Vue2

既然 Vue 都已经发布了 3.0 版本,而且 3.0 使用 proxy 来优化响应式逻辑,那为什么还要讲 Vue2 的响应式呢?

  • 要明白 3.0 在响应式设计上带来的优化效果,就必须先了解 2.0 版本的框架逻辑
  • 无论哪个版本,学习原理相关的东西,都能对研发人员在框架设计上有所启发

二、响应式例子

下面来看一个在 Vue 2 上面的响应式例子

这是一个非常简单的例子,点击按钮会触发 age++ 改变 age 数据,从而重新渲染视图。

上图是点击按钮之前,点击按钮之后,年龄加 1

三、获取数据

现在开始手写响应式框架,我们的目的是在所有能改变 data 数据的地方进行劫持,至于劫持之后再重新视图渲染的工作不再本次的讨论范围之内。

1、Vue构造函数

在 Vue2 中,Vue 就是一个构造函数,接收一个参数 options,options 是一个对象,对象里面有一个 data,data 是一个函数,函数内返回一个对象。

2、扩展原型_init

在导入 index.js 文件的时候就会执行 initMixin() 方法

当我们创建 Vue 的时候就会走到 Vue 的构造函数。

为了让我们的代码更利于维护,我们新建一个 init.js 文件,在里面进行初始化操作,其中 initMixin() 方法里面扩展原型,给 Vue 对象添加了 _init() 方法,该方法在初始化 Vue 的时候就被调用了。

_init() 方法接收参数 options,然后我们把 options 挂在 vm 的 $options,此处的 vm 就是 Vue,在 Vue 的源码里面,都是用 vm 来表达 Vue。

观察此处的输出日志,再次明确了,options 是一个对象,对象里面有一个 data 函数,函数内返回一个对象。

3、获取data数据

为了能够实现劫持 data 数据,我们必须首先拿到数据,通过 initState() 方法把 vm 对象传递到 state.js 文件中。

在 initData() 方法中,我们可以通过 $options 拿到 data,这里注意,这个 data 还只是一个函数,那么如何能拿到函数里面的返回对象呢?通过调用函数的 call() 方法便可以拿到我们定义的真正数据,然后把这个数据挂在 vm._data 上。

通过输出的日志,可以发现已经成功把 _data 挂在了 vm 上了,其中有两个字段 name 和 phone。

我们也可以在 html 文件里面,输出 vm 对象。

四、劫持对象

我们拿到了数据 data,那么现在最关键的是用户修改这个数据的时候,怎么劫持?这个是响应式的最核心的地方,在 JavaScript 中有一个方法是 defineProperty(),它就是打开 Vue 响应式的钥匙。

1、defineProperty

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。IE8 不支持 Object.defineProperty() 方法,这也就是 Vue2 不支持 IE8 以及更低版本浏览器的原因。

我们可以看到,defineProperty() 方法接收三个参数,第一个是对象,第二个是参数,第三个是描述符。

其中第三个参数里面可以定义 getter 和 setter 函数,通过这两个函数,我们可以做到劫持。

上面的 demo 可以看出,当我们在调用 student.phone 的时候触发了 get() 方法,重新给 student.phone 赋值的时候触发了 set() 方法,这样便做到了数据的劫持。

注意还有两个属性,一个是 configurable,一个是 enumerable。

configurable 默认是 false,即是不可配置,如果执行了 delete 操作则会抛出异常。当我们把它定义为 true 之后,便不会有报错。

enumerable 默认也是 false,即是不可遍历,可以发现如果是 false 状态,则 student 对象不可遍历,没有日志输出,定义为 true 之后则可以遍历。

2、defineReactive

在此 demo 中,我们用 phone 这个变量进行数据中转,这种方式非常不优雅,所以我们考虑用闭包进行优化。

使用 defineReactive() 函数对 defineProperty() 进行包裹,其中第三个参数 val 就是之前的中转变量。

3、observer

现在开始完善框架逻辑,把数据 data 通过 observe() 方法传递出去。

新建 observer 下 index.js 文件,observe() 方法里面接收参数 data,然后初始化 Observer 对象。

Observer 的构造函数里面执行 walk() 方法,遍历 data 里的每一个 key,然后调用 defineReactive() 方法,这样 data 的每一个属性都变成响应式的,每一个属性都具有了 getter 和 setter。

现在更新 html 文件来进行调试,定义两个 button,添加点击事件,分别获取手机号和设置手机号,看能不能触发 getter 和 setter,这里注意,数据是挂在 vm 的 _data 上,所以需要通过 _data 才能获取 phone。

加载页面之后不点击按钮,可以看到输出的 vm 对象里 name 属性和 phone 属性都添加了 getter 和 setter。

然后点击按钮「获取手机号」,可以看到已经触发了 getter。

点击按钮「设置手机号」,触发了 setter。

再次点击按钮「获取手机号」,可以看到获取的号码已经更新了,这样我们便完成了对数据的劫持。

4、数据代理

但是,每次拿数据都是通过 _data,这种方式既不优雅,又会让使用者感到很困惑,能不能做到越过 _data,直接通过 vm.phone 拿到数值?比如下图这种方式。

点击两个按钮,可以看到没有输出 setter 和 getter 日志,所以目前来看显然是不行了。

那如何实现呢?

同样的,既然 defineProperty() 可以做到定义一个新的属性,那么就可以用它来实现 vm.phone 变成 vm._data.phone。我们在 observe() 之前对 data 进行遍历,在 getter 和 setter 里面添加 _data 路径便行了。

重新运行之后,发现在 _data 的同级多出了 name 和 phone 属性。

点击按钮「获取手机号」,首先输出路径切换的 getter,然后输出真正的 getter。

点击按钮「设置手机号」,首先输出路径切换的 setter,然后输出真正的 setter。

五、递归劫持

1、递归劫持

刚才我们只是尝试了简单的数据,只有两个字段 name 和 phone,那当我们遇到下图 age 这种有多个层级情况的时候,就会出现问题。

在 html 中 age 的值是一个对象,保存了我们的真实年龄和在不同平台上注册的年龄,百合网、世纪佳缘、珍爱网。同时绑定两个按钮的点击事件,分别执行获取和设置百合网的年龄。

运行之后,可以发现 age 中的四个属性都没有 getter 和 setter,所以他们都不具备响应性。

点击按钮「获取百合网年龄」,日志显示只是触发了 age 的 getter,没有劫持到 baihe 这一层,点击按钮「设置百合网年龄」同理。

所以需要进行递归操作,在 defineReactive() 方法里对 val 也进行观察。

以下是递归的调用示意图

重新运行之后,发现 age 里面的每个属性都有了 getter 和 setter,他们都具备了响应性。

点击按钮也触发了 baihe 这一层的 getter 和 setter。

2、对新设置的值进行劫持

在不同平台谎报年龄毕竟是一件不诚实的事情,所以我们尝试通过 resetAge() 方法重置年龄,把 age 赋值成一个新的对象,这个对象里面就只有一个真实年龄。

点击按钮「重置年龄」,触发了 age 这一层的 setter。

点击按钮「打印VM」,可以看到 age 已经真正改变了。

点击按钮「改变年龄」,发现只是触发了 age 这一层的 getter,没有触发 real 这一层的 setter,这是为什么呢?这是因为新设置的值根本就没有调用 observer() 进行观察,所以 real 这个属性没有 getter 和 setter。

在 setter 里面对 newVal 进行 observe(),让新值也具备响应性。

点击按钮「打印VM」,可以看到 real 属性已经有了 getter 和 setter。

点击按钮「改变年龄」,也触发了 real 属性的 setter。

如此便完成了递归劫持操作

获取 Demo 源码,请关注公众号「wulittleao」后回复「202201」

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值