IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> JavaScript知识库 -> 【Vue原理】Vue的双向数据绑定原理 -> 正文阅读

[JavaScript知识库]【Vue原理】Vue的双向数据绑定原理

一 前言

  • 本文介绍的是vue中双向数据绑定的基本原理,文中将通过手写代码的形式体会数据绑定的流程,在实现细节上可能和vue源码有所差异,但是数据绑定的核心代码和基本原理是一致的。
  • 由于文中的重点是双向数据绑定原理,所以下面出现的模板编译函数不是我们的重点,所以这里的模板编译函数是一个经过简化的版本,实际上vue中的模板编译需要经历至少三个阶段。
  • 文中的模板编译采取简单的正则匹配,只能识别template中的{{}},而不能识别v-for这样的循环,但是这不是文章的重点,这里只需要读者知道这点即可。
  • 学习本文,你可以收获如下知识点:发布订阅模式、数据劫持、数据代理、文档碎片、数据绑定的基本原理和流程
  • 参考视频:vue双向数据绑定原理

二 前置知识

2.1 学会使用reduce

reduce是什么?

  • reduce() 是数组的一个方法,对数组中的每个元素按序执行一个由您提供的 reducer 函数;
  • 每一次运行 reducer 会将先前元素的计算结果作为参数传入,最后将其结果汇总为单个返回值。
  • 其使用的场景通常是累计,更通俗的讲,如果当前运算需要上一次的返回结果,那么将会考虑使用reduce()

reduce的参数

下面展示的是reduce的参数,reduce接收两个参数:

  • 第一个是回调函数reducer,而reducer可以接收4个参数,重点掌握前两个。
  • 第二是初始值initialValue,相当于第一次的返回值。
    在这里插入图片描述
    如果对reduce还不是很熟悉,可以参考MDN文档,或者学习下面的应用实例。

使用reduce链式获取对象属性

我们学习reduce方法的目的,就是为了链式获取对象的属性,这在后面的学习会经常用到。

const obj = {
    name: 'zs',
    info: {
        address: {
            location: '北京顺义'
        },
    },
}
// 需要获取对象中的location属性
const attrStr = 'info.address.location'
// 先用split方法分割成数组,再调用reduce方法进行获取
// 第一次回调的返回值 obj.info
// 第二次回调的返回值 obj.info.address
// 第三次回调的返回值 obj.info.address.location
console.log(attrStr.split('.'));
const location = attrStr.split('.').reduce((newObj, key) => {return newObj[key]}, obj);
// 简写形式:
// const location = attrStr.split('.').reduce((newObj, key) => newObj[key], obj);
console.log(location);

2.2 发布订阅模式

发布订阅模式是什么?

发布订阅模式是实现双向数据绑定的一个重要知识点。
该模式主要由依赖(订阅者)收集器Dep和订阅者Watcher组成。
以下是Dep类Watcher类需要具备的功能。

Dep和Wacther的功能

vue中发布订阅模式如何运作

发布订阅模式并不难理解,但是它在实际的场景是怎么运用的呢?

  • vue中,只要我们为vuedata数据重新赋值,这个赋值的动作,会被vue监听到
  • 然后vue要把数据的变化,通过dep,通知到每个订阅者 ! ! !
  • 接下来,订阅者(DOM元素)要根据最新的数据,更新自己的内容

实现一个简单的发布订阅模式

// 收集依赖/订阅者的类
class Dep {
    // subs用于收集订阅者的数组
    constructor() {
        this.subs = []
    }
    // 添加订阅者信息的方法
    addSub(wacther) {
        this.subs.push(wacther)
    }
    // 发布通知的方法
    notify() {
        this.subs.forEach((wacther) => wacther.update)
    }
}
// 订阅者的类
class Wacther {
	// 订阅者需要回调函数cb
	// 以便在dep通知后,调用回调函数进行更新
    constructor(cb) {
        this.cb = cb
    }
    // 触发回调的方法
    update() {
        this.cb()
    }
}

//创建出两个订阅者
const w1 = new Wacther(console.log("我是第一个订阅者"))
const w2 = new Wacther(console.log("我是第二个订阅者"))
//创建出订阅者(依赖)收集器
const dep = new Dep()
//收集订阅者
dep.addSub(w1)
dep.addSub(w2)
//通知订阅者,触发回调
dep.notify()

在这里插入图片描述

三 数据劫持和数据代理

3.1 Object.defineProperty()和Object.keys()

学习数据代理和数据劫持之前,必须先了解Object.defineProperty()Object.keys()

Object.defineProperty()

  • Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
  • Object.defineProperty() 有三个参数,重点是第三个参数descriptor,是一个对象,有多个可选键值,重点是getter函数setter函数
  • 更多请见MDN文档
    在这里插入图片描述

Object.keys()

Object.keys() 方法会返回一个由一个给定对象的自身可枚举属性组成的数组,数组中属性名的排列顺序和正常循环遍历该对象时返回的顺序一致 。
在这里插入图片描述

3.2 文件目录结构

前面的基础知识已经介绍的差不多了,接下来开始逐步实现数据的双向绑定;
这里我们需要一个src目录,以下有两个文件index.html (用于页面显示和测试),和vue.js(用于存放我们的代码,实质上是一个只实现了数据绑定功能的简易vue);
下面给出index.html的模板代码,和vue的初步框架。

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="app">
        <p>name:  {{ name }}</p>
        <p>age:  {{ age }}</p>
        <p>info.a: {{info.a}}</p>
        <div>name的值:<input type="text" v-model="name"/></div>
        <div>info.a的值:<input type="text" v-model="info.a"/></div>
    </div>
    <script src="./vue.js"></script>
    <script>
        const vm = new Vue({
            el: '#app',
            data: {
                name: '张三',
                age: 18,
                info: {
                    a: 'a',
                    b: 'b'
                }
            }

        })
    </script>
</body>
</html>

vue.js

class Vue {
    constructor(options) {
        this.$data = options.data
        // ① 调用数据劫持的方法
        // ② 进行数据/属性代理
        // ③ 调用compile,进行初次模板编译
    }
}

3.3 数据劫持

数据劫持是什么?

  • 数据劫持,指的是在访问或者修改对象的某个属性时,通过一段代码拦截这个行为,进行额外的操作或者修改返回结果
  • vue2.x中使用的是Object.defineProperty()实现;而在vue3.x改用Proxy进行实现。
  • 之所以需要数据劫持,是因为在vue中,在读取和设置data中的数据时,需要一些额外的操作。
  • 在具体实现上,就是给数据添加上gettersetter函数,在函数中,进行基本的数据读取和设置,以及调取额外的操作。

实现Observe方法

function Observe(obj) {
    // 如果obj为空或者不是对象,则返回
    if(!obj || typeof obj !== 'object') return
    // 调用Object.keys(obj) 可以获取对象obj的每个属性
    // console.log(Object.keys(obj));
    Object.keys(obj).forEach(key => {
        let value = obj[key]
        // value可能是对象也可能是单纯的属性值
        // 如果是对象,需要递归设置setter和getter
        Observe(value)
        Object.defineProperty(obj, key, {
            enumerable: true, // 设置可迭代
            configurable: true,// 设置可delete
            get() {
                console.log("调用了getter,在vue中要添加依赖,再返回该值")
                return value
            },
            set(newValue) {
                console.log("调用了setter,要设置新值,在vue中还要通知订阅者进行更新操作");
                value = newValue
                // 此行代码可以保证重新赋值的对象也有setter和getter
                Observe(value)
            }
        })

    })

}

在vue类中调用。

class Vue {
    constructor(options) {
        this.$data = options.data
        // ① 调用数据劫持的方法
        Observe(this.$data)
        // ② 进行数据/属性代理
        // ③ 调用compile,进行初次模板编译
    }
}

在读取和设置时顺利进行了额外的操作:

在这里插入图片描述

嵌套对象中的属性也有setter和getter:

在这里插入图片描述

重新赋值的对象也有setter和getter:

在这里插入图片描述

3.4 数据代理

数据代理是什么?

  • 通过一个对象代理对另一个对象中的属性的操作(读/写),就是数据代理。
  • 上面我们都是通过vm.$data.name的方式实现数据获取,而在vue中,实际可以通过vm.name直接获取数据。
  • 也就是说,vue实例代理了对data中属性的操作(读/写)。

数据代理的实现

class Vue {
    constructor(options) {
        this.$data = options.data
        // ① 调用数据劫持的方法
        Observe(this.$data)
        // ② 进行数据/属性代理
        Object.keys(this.$data).forEach(key => {
            Object.defineProperty(this, key, {
                enumerable: true,
                configurable: true,
                get() {
                    return this.$data[key]
                },
                set(newValue) {
                    this.$data[key] = newValue
                }
            })
        })
        // ③ 调用compile,进行初次模板编译
    }
}

代理效果:

在这里插入图片描述

四 模板编译的简易实现

4.1 介绍文档碎片的作用

  • 当我们对模板进行编译,最后将数据渲染到页面时, 每一次对 DOM 的操作都将发生 “重排”(位置发生变化)或 “重绘”(内容发生变化),这严重消耗性能,一般通常的做法是减少dom 操作, 减少发生重排或重绘的做法

  • 文档碎片是一个容器用于暂时存放创建的 DOM 元素。

  • 如果我们将需要添加的大量元素,先添加到文档碎片中,再将文档碎片添加到需要插入的位置, 可以大大减少 DOM 操作, 提高性能

4.2 创建文档碎片

// 对 HTML 结构进行模板编译的方法
function Compile(el, vm) {
    // 获取el对应的DOM元素
    vm.$el = document.querySelector(el)
    // 创建文档碎片,提高DOM操作的性能
    const fragement = document.createDocumentFragment()
	// 使用文档碎片暂存DOM节点
    while((childNode = vm.$el.firstChild)) {
        fragement.appendChild(childNode)
    }
    // 模板编译
    replace(fragement)
    
    vm.$el.appendChild(fragement)
}

在vue类中调用,传入参数

class Vue {
    constructor(options) {
        this.$data = options.data
        // ① 调用数据劫持的方法
        Observe(this.$data)
        // ② 进行数据/属性代理
        Object.keys(this.$data).forEach(key => {
            Object.defineProperty(this, key, {
                enumerable: true,
                configurable: true,
                get() {
                    return this.$data[key]
                },
                set(newValue) {
                    this.$data[key] = newValue
                }
            })
        })
        // ③ 调用compile,进行初次模板编译
        Compile(options.el, this)
    }
}

4.3 实现replace()方法

关于replace()方法

  • Compile()函数中,实现模板编译的核心函数是replace()
  • replace()函数的定义可以放在Compile()内部,其功能是匹配模板中的插值语法{{ }},完成数据的注入。
  • replace()函数主要步骤如下:
    • 递归获取所有纯文本节点
    • 正则匹配和替换文本子节点

需要的正则知识

正则表达式

  • 我们要匹配的是插值语法,正则表达式为:/\{\{\s*(\S+)\s*\}\}/
  • / /中间为正则的具体内容,\{\{ \}\}匹配的是双大括号,\s*表示匹配零个或多个空白字符, \S+表示匹配非空白字符1个或多个,()表示分组捕获。
  • 所以该正则表达式匹配的字符串是{{name}}或者{{ name }}这样的。

RegExp.prototype.exec()

exec() 方法在一个指定字符串中执行一个搜索匹配。返回一个结果数组或 null。

下面给出返回的数组各项内容:

在这里插入图片描述

relace()的实现

    // 进行模板编译的方法
    function replace(node) {
        // 定义匹配插值表达式的正则
        const regMustache = /\{\{\s*(\S+)\s*\}\}/
        // 证明当前的 node 节点是一个文本子节点,需要进行正则的替换
        if(node.nodeType === 3) {
            // 获取文本子节点的字符串内容
            const text = node.textContent
            // 进行字符串的正则匹配与提取
            const execResult = regMustache.exec(text)
            // 有些输出的是null,原因是模板中存在大量的空白和换行字符
            console.log(execResult);
            if(execResult) {
                const value = execResult[1].split('.').reduce((newObj, k) => newObj[k], vm)
                node.textContent = text.replace(regMustache, value)
            }
            // 递归结束
            return;
        }
 
        // 证明不是文本节点,可能是一个DOM元素,需要进行递归处理
        node.childNodes.forEach((child) => replace(child))
    }

五 Dep类和Wacther类

5.1 创建Dep类和Watcher类

  • 前面已经介绍了发布订阅在vue中如何运作。
  • 下面就要开始用代码实现前面介绍的发布订阅模式了。
// 收集依赖的类
class Dep {
    constructor() {
        this.subs = []
    }
    // 添加订阅者信息的方法
    addSub(wacther) {
        this.subs.push(wacther)
    }
    // 发布通知的方法
    notify() {
        this.subs.forEach((wacther) => wacther.update())
    }
}

// 订阅者的类
class Wacther {
    // 1 需要知道如何更新,也就是需要回调函数 cb
    // 2 需要拿到最新的数据去更新,也就是需要 vm
    // 3 需要知道更新的是哪里,也就是需要具体的 key
    constructor(vm, key, cb) {
        this.vm = vm
        this.key = key
        this.cb = cb
    }
    // 触发回调的方法
    update() {
        const value = this.key.split('.').reduce((newObj, k) => newObj[k], this.vm)
        this.cb(value)
    }
}

5.2 创建Wacther类的实例

创建Wacther类实例的时机是当我们进行模板编译的时候
在初次进行模板编译的时候,就创建出订阅者,然后添加到依赖收集器dep中。
这样在后续数据更新的时候,dep就可以通知订阅者们进行相应的update()操作。

    // 进行模板编译的方法
    function replace(node) {
        // 定义匹配插值表达式的正则
        const regMustache = /\{\{\s*(\S+)\s*\}\}/
        // 证明当前的 node 节点是一个文本子节点,需要进行正则的替换
        if(node.nodeType === 3) {
            // 获取文本子节点的字符串内容
            const text = node.textContent
            // 进行字符串的正则匹配与提取
            const execResult = regMustache.exec(text)
            console.log(execResult);
            if(execResult) {
                const value = execResult[1].split('.').reduce((newObj, k) => newObj[k], vm)
                node.textContent = text.replace(regMustache, value)
                // 在这个时候,创建Watcher类的实例
                new Wacther(vm, execResult[1], (newValue) => {
                    node.textContent  = text.replace(regMustache, newValue)
                })
            }
            // 递归结束
            return;
        }
 
        // 证明不是文本节点,可能是一个DOM元素,需要进行递归处理
        node.childNodes.forEach((child) => replace(child))
    }
}

5.3 三行神奇的代码

  • 上面讲了创建订阅者的时机,但是还没有说明证明添加依赖到dep中。
  • 添加依赖需要用到三行比较巧妙的代码。

首先,我们需要在Wacther的构造函数增加这三行代码,使其在创建实例时,顺便完成添加依赖操作。

// 订阅者的类
class Wacther {
    // 1 需要知道如何更新,也就是需要回调函数 cb
    // 2 需要拿到最新的数据去更新,也就是需要 vm
    // 3 需要知道更新的是哪里,也就是需要具体的 key
    constructor(vm, key, cb) {
        this.vm = vm
        this.key = key
        this.cb = cb
        //下面三行代码,负责将创建的Watcher实例存到Dep实例的subs数组中
        // ① 先添加自定义属性target到Dep类上面
        //  此时的Dep.target指向需要添加到的dep的订阅者
        Dep.target = this
        // ② 读取属性key对应的value(具体的添加依赖/订阅者操作在getter里)
        key.split('.').reduce((newObj, k) => newObj[k], vm)
        // ③ 追加依赖后,令Dep.target指向空对象
        Dep.target = null
    }
    // 触发回调的方法
    update() {
        const value = this.key.split('.').reduce((newObj, k) => newObj[k], this.vm)
        this.cb(value)
    }
}

前面数据劫持中,说过了我们需要通过gettersetter做一些额外的操作,也通过打印的方式说明这些操作是什么。
接下来要完善代码,实现这些额外的操作。

// 数据劫持方法
function Observe(obj) {
    let dep = new Dep()
    // 如果obj为空或者不是对象,则返回
    if(!obj || typeof obj !== 'object') return
    // 调用Object.keys(obj) 可以获取对象obj的属性
    // console.log(Object.keys(obj));
    Object.keys(obj).forEach(key => {
        let value = obj[key]
        Observe(value)
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get() {
                // console.log("调用了getter,在vue中要添加依赖,再返回该值")
                Dep.target && dep.addSub(Dep.target)
                return value
            },
            set(newValue) {
                value = newValue
                Observe(value)
                dep.notify()
                // console.log("调用了setter,要设置新值,在vue中还要通知订阅者进行更新操作");
            }
        })

    })

}

六 数据绑定的实现

6.1 data到view视图的单向数据绑定

  • 此时,已经有单向数据绑定的效果了。
  • 当修改data时,执行setter,dep调用notify,订阅者执行update操作,调取回调函数,实现页面数据更新。

在这里插入图片描述

6.2 文本框的单向数据绑定

  • 前面replace()中只判断了文本节点,这里需要再判断input框的情况。
  • replace()方法判断完文本节点后添加如下代码:
// 判断当前的node节点是否为input输入框
if (node.nodeType === 1 && node.tagName.toUpperCase() === 'INPUT') {
    // 得到当前元素的所有属性
    const attrs = Array.from(node.attributes)
    const findResult = attrs.find((x) => x.name ===  'v-model')
    if(findResult) {
        // 获取到当前v-model属性的值 v-model="name" / v-model="info.a"
        const expStr = findResult.value
        const value = expStr.split('.').reduce((newObj, k) => newObj[k], vm)
        node.value = value
        //创建Watcher的实例
        new Wacther(vm, expStr, (newValue) => {
            node.value = newValue
        })
    }
}

6.3 文本框的双向数据绑定

  • 要实现修改页面的数据,更新data中的数据,那么要监听修改操作。
  • 在上述代码创建Wacther的实例后面添加如下代码实现双向数据绑定。
//监听文本框的input输入事件,拿到文本框最新的值,把最新的值,更新到vm上即可
node.addEventListener('input', (e) => {
    const keyArr = expStr.split('.')
    const obj = keyArr.slice(0, keyArr.length - 1).reduce((newObj, k) => newObj[k], vm)
    obj[keyArr[keyArr.length - 1]] = e.target.value
})

七 数据绑定的总结

  • 看完上述的代码,我们已经对vue中双向数据绑定的原理有所理解,下面进行总结。
  • 前面有些不太懂的流程,看完下面的总结,应该可以搞清楚。

Vue.js 是采用数据劫持结合发布者-订阅者模式的方式,通过Object.defineProperty()来劫持各个属性的setter,getter,在数据变动时发布消息给订阅者,触发相应的监听回调。主要分为以下几个步骤:

  1. 需要observe的数据对象进行递归遍历,包括子属性对象的属性,都加上setter和getter这样的话,给这个对象的某个值赋值,就会触发setter,那么就能监听到了数据变化
  2. compile解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图
  3. Watcher订阅者是Observer和Compile之间通信的桥梁,主要做的事情是: ①在自身实例化时往属性订阅器(dep)里面添加自己 ②自身必须有一个update()方法 ③待属性变动dep.notice()通知时,能调用自身的update()方法,并触发Compile中绑定的回调,则功成身退。
  4. MVVM作为数据绑定的入口,整合Observer、Compile和Watcher三者,通过Observer来监听自己的model数据变化,通过Compile来解析编译模板指令,最终利用Watcher搭起Observer和Compile之间的通信桥梁,达到数据变化 -> 视图更新;视图交互变化(input) -> 数据model变更的双向绑定效果。
    在这里插入图片描述

备注:使用 Object.defineProperty() 来进行数据劫持有什么缺点?

在对一些属性进行操作时,使用这种方法无法拦截,比如通过下标方式修改数组数据或者给对象新增属性,这都不能触发组件的重新渲染,因为 Object.defineProperty 不能拦截到这些操作。更精确的来说,对于数组而言,大部分操作都是拦截不到的,只是 Vue 内部通过重写函数的方式解决了这个问题。

在 Vue3.0 中已经不使用这种方式了,而是通过使用 Proxy 对对象进行代理,从而实现数据劫持。使用Proxy 的好处是它可以完美的监听到任何方式的数据改变,唯一的缺点是兼容性的问题,因为 Proxy 是 ES6 的语法。

  JavaScript知识库 最新文章
ES6的相关知识点
react 函数式组件 & react其他一些总结
Vue基础超详细
前端JS也可以连点成线(Vue中运用 AntVG6)
Vue事件处理的基本使用
Vue后台项目的记录 (一)
前后端分离vue跨域,devServer配置proxy代理
TypeScript
初识vuex
vue项目安装包指令收集
上一篇文章      下一篇文章      查看所有文章
加:2022-06-01 15:07:38  更:2022-06-01 15:09:22 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2025年1日历 -2025/1/11 7:50:20-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码