1 前言

随着 Vue、React、Angularjs 等框架的诞生,数据驱动视图的理念也深入人心,就 Vue 来说,它拥有着双向数据绑定、虚拟dom、组件化、视图与数据相分离等等造福程序员的优点,那 Vue 的双向数据绑定实现原理是什么样的,如果让我们自己去实现一个这样的双向数据绑定要怎么做呢,本文就与大家分享一下 Vue 的绑定原理及其简单实现

2 核心技术

大家都知道 Vue2 双向绑定是基于 ES5的 Object.defineProperty 方法+发布订阅者模式实现的 那我们首先简单了解一下这两个模块都是做什么的,在 Vue 中充当了什么角色

2.1 Object.defineProperty

用来在对象上定义或者修改一个属性值,实现数据劫持,为修改数据后去调用视图更新做准备

  1. const obj = {}
  2. let age = 18
  3. Object.defineProperty(obj, 'age',{
  4. get() {
  5. return age
  6. },
  7. set(newVal) {
  8. age = newVal + 1
  9. },
  10. enumerable: true
  11. })
  12. console.log(obj.age) // 18
  13. obj.age = 20
  14. console.log(obj.age) // 21

2.2 发布订阅者模式

此模式简单来讲就是分为发布和订阅两个概念,订阅意思就是我们会定义很多个订阅者,每个订阅者都会有自己的 update 方法,把需要更新的订阅者放到数组中,而发布就代表通知订阅者去依次执行其 update 方法,从而实现数据更新

  1. // 定义放订阅者的数组
  2. function Dep() {
  3. this.subs = []
  4. }
  5. // 定义存放订阅者的方法
  6. Dep.prototype.addSub = function(sub) {
  7. this.subs.push(sub)
  8. }
  9. // 定义发布的方法
  10. Dep.prototype.notify = function(sub) {
  11. this.subs.forEach(sub => {
  12. // 依次通知订阅者去执行update方法
  13. sub.update()
  14. })
  15. }

写到这里我们就把发布和订阅准备好了,但是还缺少订阅者,且订阅者要保证提供一个 update 方法才行,那我们不禁想到能否去创建一个构造函数,通过这个构造函数创建的实例都会有 update 方法呢

  1. function Watcher(fn) {
  2. this.fn = fn
  3. }
  4. // 通过该构造函数创建的实例都会有update方法
  5. Watcher.prototype.update = function() {
  6. this.fn()
  7. }
  8. // new实例
  9. const watcher1 = new Watcher(() => console.log('我是watcher1'))
  10. const watcher2 = new Watcher(() => console.log('我是watcher2'))
  11. const dep = new Dep()
  12. // 把准备好的事件放入到数组中
  13. dep.addSub(watcher1)
  14. dep.addSub(watcher2)
  15. // 进行发布
  16. dep.notify()
  17. // 最终输出 我是watcher1 我是watcher2

3 具体实现

3.1 初始化

一个框架都是从它的初始化开始的,Vue 也不例外

  1. <body>
  2. <div id="app">
  3. <p>a value: {{a.a}}</p>
  4. <div>b value: {{b}}</div>
  5. <span>v-model: </span><input type="text" v-model="b">
  6. </div>
  7. <script>
  8. // 模仿 Vue 的初始化和传入的参数
  9. let vue = new Vue({
  10. el: '#app',
  11. data: {
  12. a: { a: 'is a' },
  13. b: 'is b',
  14. c: 'is c'
  15. }
  16. })
  17. </script>
  18. </body>

3.2 数据劫持 observe

Vue 中在 data 中定义的属性才可以实现双向绑定,为了实现这个功能,我们定义一个 Observe 用来劫持到对象的属性

  1. // 给对象增加数据劫持
  2. function Observe(data) {
  3. // 因 defineProperty 每次只能设置单个属性 所以需遍历
  4. for (let key in data) {
  5. let val = data[key]
  6. observe(val)
  7. Object.defineProperty(data, key, {
  8. enumerable: true,
  9. get() {
  10. return val
  11. },
  12. set(newVal) {
  13. if (newVal === val) return // 新值与旧值相等时 不做处理
  14. val = newVal // 之所以给 val 赋值 是因为取值时取得val
  15. observe(newVal) // 当给变量值赋予一个新对象时 依然需要劫持到其属性
  16. }
  17. })
  18. }
  19. }
  20. function observe(data) {
  21. if (typeof data !== 'object') return
  22. return new Observe(data)
  23. }

3.3 构造函数编写

  1. function Vue(options = {}) {
  2. // 模仿 Vue 把属性挂载到 $options 且可以通过this._data访问属性
  3. this.$options = options
  4. const data = this._data = this.$options.data
  5. observe(data) // 给 data 增加数据劫持
  6. }

上图中我们模仿 Vue 对 options 和 data 增加了一些可访问方式,给 data 增加了数据劫持,也在我们的实例中看到了效果,那这时又有了新的问题,Vue 中访问数据都是 this.xxx 可直接通过实例访问,这样比我们图中的访问方式还要更方便一些,那我们也能否把属性直接挂载到实例上呢,当然是可以的

3.4 数据代理

如果想要直接把属性挂载到实例上,那我们需要保证通过实例直接访问的属性值是实时无误的,且去修改该属性值还能够被劫持到,否则会影响后面的双向数据绑定,既然 data 中的数据我们已经通过 Observe(在3.2节)做了劫持,那我们在通过 this.xxx 直接修改属性时只需要去修改 data 对应中的属性就可以触发 Observe 劫持

  1. function Vue(options = {}) {
  2. // 模仿 Vue 把属性挂载到 $options
  3. this.$options = options
  4. const data = this._data = this.$options.data
  5. observe(data)
  6. // 将当前 this 传入方法 将属性挂载到 this 上
  7. proxyData.call(this, data)
  8. }
  9. // this 代理 this._data
  10. function proxyData(data) {
  11. const vm = this
  12. for (let key in data) {
  13. Object.defineProperty(vm, key, {
  14. enumerable: true,
  15. get() {
  16. return vm._data[key]
  17. },
  18. set(newVal) {
  19. // 直接修改 data 中对应属性 触发 data 中劫持 保持数据统一
  20. vm._data[key] = newVal
  21. }
  22. })
  23. }
  24. }

3.5 实现 compile

先在内存中创建一个文档碎片来递归所有 dom 节点,用正则匹配 {{}} 相符的节点,获取到括号里的 key,最后在 data 中拿到对应 key 的属性值,替换到节点上(因为主要是实现双向绑定,所以我们将 dom 的操作放到文档碎片中操作来代替虚拟 dom)

  1. function Compile(el, vm) {
  2. vm.$el = document.querySelector(el)
  3. // 建立文档碎片 将 el 下的所有元素挪进文档碎片 避免死循环
  4. const fragment = document.createDocumentFragment()
  5. // 将 el 中的元素都移入碎片中
  6. while (child = vm.$el.firstChild) {
  7. fragment.appendChild(child)
  8. }
  9. // 匹配节点中的{{}} 将其替换为对应的值
  10. replace(fragment)
  11. function replace(fragment) {
  12. // 循环每一层节点
  13. Array.from(fragment.childNodes).forEach(node => {
  14. const text = node.textContent
  15. // 定义正则表达式
  16. const reg = /{{(.*)}}/
  17. // 此判断为当节点是文本节点(因为变量都是文本)且被包含在{{}}中的文本节点时
  18. // 文本节点 Node.TEXT_NODE: 3
  19. if (node.nodeType === Node.TEXT_NODE && reg.test(text)) {
  20. // 以下三行为了获取到 key 对应的value值 页面初始化后正常将变量替换为值
  21. const arr = RegExp.$1.split('.') // [a, a] [b]
  22. let val = vm
  23. arr.forEach(k => (val = val[k]))
  24. node.textContent = text.replace(reg, val)
  25. }
  26. if (node.childNodes) {
  27. replace(node)
  28. }
  29. })
  30. }
  31. // 将处理好的文档碎片塞回dom中
  32. vm.$el.appendChild(fragment)
  33. }

初始化 Vue 时调用 compile

  1. function Vue(options = {}) {
  2. // 模仿 Vue 把属性挂载到 $options
  3. this.$options = options
  4. const data = this._data = this.$options.data
  5. observe(data)
  6. // 将当前 this 传入方法 将属性挂载到 this 上
  7. proxyData.call(this, data)
  8. new Compile(options.el, this)
  9. }

3.6 Model -> ViewModel -> View

目前我们已经实现的功能:数据劫持、this代理、编译模板 ,最终我们要达到修改数据、视图自动更新的效果,还需要以下工作

1)第一步我们需要创建一个订阅者,其 update 事件就是接收到我们更新后的数据值然后去更新 dom, 因为要更新 dom,所以此订阅者是在 compile 中定义的,并且大家会发现我们在编译过程中,是循环每一层节点去判断的,也就意味着我们页面有多少个符合条件的文本节点,就会新建多少个 watcher,那这时就需要把文本节点的对应 key 和 value 传入 watcher 中,用来判断更新的哪个节点值

2)既然我们的 watcher 新增了参数(vue 实例、节点变量)所以我们需要对 watcher 方法做出更改

3)当 watcher 定义好后,还需要修改下其 update 方法,因为我们的 watcher 第三个参数也就是回调函数中新增了参数,需要给其传参

  1. Watcher.prototype.update = function() {
  2. // this.exp 可取到 key 值 从 vm 中凭借 key 就可以取到属性值
  3. let val = this.vm
  4. const arr = this.exp.split('.')
  5. arr.forEach(k => (val = val[k]))
  6. this.fn(val) // 传入 newVal
  7. }

4)订阅者都准备好了,还需要添加订阅者到 dep 数组并且在数据改动后调用发布,这个过程需要在 observe 中实现

5)最终效果如图所示

3.7 View -> ViewModel -> Model

上面我们实现了从数据到视图的更新,那视图从数据的更新呢,首先我们想到一个最常见的例子(v-model), 要想实现它,我们需要做以下两步

1)把 value 值展示到绑定 v-model 的 input 中

2)其次是每次我们更改值时都应该把值更新到界面上,所以我们还需要新建一个 watcher,在绑定 v-model 的 input 上绑定事件,当输入文案时获取到输入的值,改变 data 只对应的属性值

3)最终效果如图所示

4 总结

4.1 实现思路

4.2 优缺点

优点:成功达到了数据和视图的双向驱动,像在操作表单时使用会更方便,省略了很多重复的 onChange 事件去处理数据的变化,也省略了给 dom 添加值的操作,代码量会更少,更方便维护

缺点:修改数据时会使得我们无法追踪数据的改变源头,且在数据劫持那步需要去循环,为一个对象的每一个属性增加劫持,无法直接在一个对象上增加所有属性的劫持(该缺点 Vue3 已规避,大家可自行学习)

4.3 小结

在工作中我们很多项目会用到框架 ,了解它的一些原理有助于我们更好的去使用,便于我们培养自己的‘造轮子’能力,遇到问题时能更好的解决,减少不必要的 bug,更好的去调试代码,一些很复杂的组件如果找不到开源的话,自己也能去实现不至于一头雾水

4.4 参考资料

5 思考

至此,一个双向数据绑定功能就基本实现了,本文我们的实现是基于 Vue2 的双向数据绑定原理,目前 Vue3 已经趋于稳定,我们可以思考下,如果是基于 Vue3 的原理去做,那需要怎么去实现呢。

作者:京东物流 张婷婷

来源:京东云开发者社区

手牵手带你实现mini-vue的更多相关文章

  1. 手牵手,从零学习Vue源码 系列一(前言-目录篇)

    系列文章: 手牵手,从零学习Vue源码 系列一(前言-目录篇) 手牵手,从零学习Vue源码 系列二(变化侦测篇) 手牵手,从零学习Vue源码 系列三(虚拟DOM篇) 陆续更新中... 预计八月中旬更新 ...

  2. 手牵手,从零学习Vue源码 系列二(变化侦测篇)

    系列文章: 手牵手,从零学习Vue源码 系列一(前言-目录篇) 手牵手,从零学习Vue源码 系列二(变化侦测篇) 陆续更新中... 预计八月中旬更新完毕. 1 概述 Vue最大的特点之一就是数据驱动视 ...

  3. 在addroutes后,$router.options.routes没有更新的问题(手摸手,带你用vue撸后台 读后感)

    参照<着手摸手,带你用vue撸后台>一文,本人做了前端的权限判断 https://segmentfault.com/a/1190000009275424 首先就是在addroutes后,$ ...

  4. 【转】手摸手,带你用vue撸后台 系列二(登录权限篇)

    前言 拖更有点严重,过了半个月才写了第二篇教程.无奈自己是一个业务猿,每天被我司的产品虐的死去活来,之前又病了一下休息了几天,大家见谅. 进入正题,做后台项目区别于做其它的项目,权限验证与安全性是非常 ...

  5. 【转】手摸手,带你用vue撸后台 系列三(实战篇)

    前言 在前面两篇文章中已经把基础工作环境构建完成,也已经把后台核心的登录和权限完成了,现在手摸手,一起进入实操. Element 去年十月份开始用vue做管理后台的时候毫不犹豫的就选择了Elemen, ...

  6. 【转】手摸手,带你用vue撸后台 系列四(vueAdmin 一个极简的后台基础模板)

    前言 做这个 vueAdmin-template 的主要原因是: vue-element-admin 这个项目的初衷是一个vue的管理后台集成方案,把平时用到的一些组件或者经验分享给大家,同时它也在不 ...

  7. 【转】手摸手,带你用vue撸后台 系列一

    前言 说好的教程终于来了,第一篇文章主要来说一说在开始写业务代码前的一些准备工作吧,但这里不会教你webpack的基础配置,热更新怎么做,webpack速度优化等等,有需求的请自行google. 目录 ...

  8. 手牵手,使用uni-app从零开发一款视频小程序 (系列上 准备工作篇)

    系列文章 手牵手,使用uni-app从零开发一款视频小程序 (系列上 准备工作篇) 手牵手,使用uni-app从零开发一款视频小程序 (系列下 开发实战篇) 前言 好久不见,很久没更新博客了,前段时间 ...

  9. 手牵手,使用uni-app从零开发一款视频小程序 (系列下 开发实战篇)

    系列文章 手牵手,使用uni-app从零开发一款视频小程序 (系列上 准备工作篇) 手牵手,使用uni-app从零开发一款视频小程序 (系列下 开发实战篇) 扫码体验,先睹为快 可以扫描下微信小程序的 ...

  10. 【手摸手,带你搭建前后端分离商城系统】01 搭建基本代码框架、生成一个基本API

    [手摸手,带你搭建前后端分离商城系统]01 搭建基本代码框架.生成一个基本API 通过本教程的学习,将带你从零搭建一个商城系统. 当然,这个商城涵盖了很多流行的知识点和技术核心 我可以学习到什么? S ...

随机推荐

  1. 商品获价API调用说明:获取商品历史价格信息 代码分享

    接口名称:item_history_price 公共参数 名称 类型 必须 描述 key String 是 调用key(必须以GET方式拼接在URL中)(获取测试key和secret接入) secre ...

  2. 标准正态分布表—R语言

    正态分布是最重要的一种概率分布.正态分布概念是由德国的数学家和天文学家Moivre于1733年首次提出的,但由于德国数学家Gauss率先将其应用于天文学家研究,故正态分布又叫高斯分布.高斯这项工作对后 ...

  3. k8s集群进行删除并添加node节点

    在已建立好的k8s集群中删除节点后,进行添加新的节点,可参考用于添加全新node节点,若新的node需要安装docker和k8s基础组件. 建立集群可以参考曾经的文章:CentOS8 搭建Kubern ...

  4. window远程桌面之通过修改端口链接

      windows开启及连接远程桌面 技术标签: 后端开发  windows         桌面 -> 此电脑 图标右键 -> 属性 远程设置 远程桌面 -> 修改为允许远程连接到 ...

  5. day06-SpringCloud Ribbon

    SpringCloud Ribbon 1.Ribbon介绍 1.1Ribbon是什么? 官网地址:Netflix/ribbon: Ribbon(github.com) SpringCloud Ribb ...

  6. super 与 this 关键字

    super与this用法相似: 1.普通的直接引用 2.形参与成员名字重名,用 this 来指代类本身,super指代父类 public class Students extends Person { ...

  7. 随手记:Redis 部署到linux上面后,本地无法连接

    修改redis的配置文件 redis.conf 1. bind 设置为 0.0.0.0 2. protected-mode 设置为no   (也就是关闭保护模式) 3.    daemonize 设置 ...

  8. YII框架(1.7&2.0基础版&2.0高级版)应用程序模板安装方法

    YII1.7 安装方法: ① 鼠标右键我的电脑图标-> 选择弹出窗的"属性"选项-->点击"高级"选项卡->在选项卡下面找到"环境变 ...

  9. Swift CustomStringConvertible 协议的使用

    目录 一.前言 二.使用场景 1. 整型类型的枚举使用 2. Class类型的使用 一.前言 先看一下Swift标准库中对CustomStringConvertible协议的定义 public pro ...

  10. Portainer安装

    个人博客地址: https://note.raokun.top 拥抱ChatGPT,国内访问网站:https://www.playchat.top Portainer是一个可视化的容器镜像的图形管理工 ...