VUE双向绑定原理

前言

在之前面试中,有被问到这个问题,虽然了解过是劫持Object.defineProperty方法,但是其细节并不太清楚,于是遭到了面试官的鄙视👎,只能回头认真在网上看一下。

刚开始看了很多文章,还是没看懂。

最后我是看这篇文章看懂的,其他的要么略过太多细节,看着有种断层感,根本不知道怎么突然到这一步了。有些要么跟着代码讲思路,有点乱。

这篇文章已经讲解得很好了,但是作为一个小白,我还是看了老半天才懂,原因就是看的源码少,水平不够。

所以我决定重新捋一捋里面的思想,把细节尽可能说清楚,让跟我一样没学过任何源码的人也能搞清楚。

补充一下个人想法,对于这些精妙的思维接触不多,而这些往往是决定我们的高度的,是一个使用者还是研究者?有时候眼光的高低,决定着我们未来道路的长短。

大致原理

vue的响应原理可以从下面官网的分析图大致了解。

官网的解释是这样的:

每个组件实例都有相应的 watcher 实例对象,它会在组件渲染的过程中把属性记录为依赖,之后当依赖项的 setter 被调用时,会通知 watcher 重新计算,从而致使它关联的组件得以更新。

image.png

看不懂?没关系,有个大概印象就可以了。

defineProperty是什么鬼?

为什么要先从这里说起?因为这是众所周知vue双向绑定的原理。

MDN解释在这里

简单地说,就是对于我们的对象的属性,我们可以通过defineProperty来设置它的getset方法,一旦获取值,就会触发get方法,一旦修改值,就会触发set方法。

比如下面简单的例子

var obj = {name:'zeller'};

Object.defineProperty(obj,'name',{
  get:function(){
    console.log(`你正在获取obj的name值.`)
  },
  set:function(newVal){
    console.log(`name值修改中,新的name值是${newVal}`)
  },
})

obj.name//"你正在获取obj的name值."
obj.name = 'atoms'//"name值修改中,新的name值是atoms"
image.png

codepen在线预览

用defineProperty实现一个极简的双向绑定例子

既然这个方法这么有用,我们设置一个容器obj,直接在set里面渲染我们的html,然后监听input的keyup事件,当事件触发时,修改obj对应的值,从而再触发html的改变。

既然大概思路有了,我们可以尝试一下.

<!--html-->
<input type="text" id="content">请输入内容
<br><br>
他输入的内容是:<p id="reflect" style="color:red;"></p>
var obj={};
//假设我们监听'hello'这个属性
Object.defineProperty(obj,'hello',{
  set:function(newVal){
    var p = document.getElementById('reflect');
    p.innerHTML = newVal;
  }
})

var input = document.getElementById('content');
input.addEventListener('keyup',function(e){
  obj.hello = e.target.value;
})
image.png

在线预览

分解实际任务

虽然上面的简单演示我们貌似做出来了,但是与实际的样子却不一样。我们看看。

image.png
image.png

实际是上面这样子调用的,所以我们需要分析一下,该如何实现。

首先,我们要在初次渲染html能拿到data的数据

其次,输入框输入内容变化时,data中的相应属性也能变化

最后,data中的数据变化时,html能实时跟着变化

所以我们大概可以分为3个任务

  • 1、输入框以及文本节点与data中的数据绑定(初始渲染)

  • 2、输入框内容变化时,data中的数据同步变化。即view => model的变化。

  • 3、data中的数据变化时,文本节点的内容同步变化。即model => view的变化。

任务1:初始加载渲染data里的属性

既然要加载data里的属性值,我们就要考虑两种情况,app里的子节点的类型,

  • 当childNode是文本节点,而我们匹配到{{attr}}时,我们需要去找vue里面绑定的data的attr属性,把它的值替换给文本节点.
  • 当childNode是元素节点时,比如<input v-model="attr">,我们就要去找vue.data.attr的值,并赋给childNode

因此可以看出,我们需要先把所有子节点遍历出来,看看有没有符合以下两个规则的内容:

  • 文本节点,含有
    {{attr}}
  • 元素节点,含有v-model

这样把值替换完我们就可以返回去了,但是考虑到多次操作dom的开销,我们用createDocumentFragment()

它相当与创建一个仓库,每次把子节点修改完,我们不直接插入父节点(#app),而是放入仓库,最后直接把仓库里的东西替换掉就可以了。

创建fragment仓库

function nodeToFragment (node, vm) {
        var flag = document.createDocumentFragment();
        var child;
        // 许多同学反应看不懂这一段,这里有必要解释一下
        // 首先,所有表达式必然会返回一个值,赋值表达式亦不例外
        //child = node.firstChild返回的是赋值的node.firstChild
        //即只要firstChild存在,就把firstChild赋给child
        // 理解了上面这一点,就能理解 while (child = node.firstChild) 这种用法
        // 其次,appendChild 方法有个隐蔽的地方,就是调用以后 child 会从原来 DOM 中移除
        // 所以,第二次循环时,node.firstChild 已经不再是之前的第一个子元素了
        while (child = node.firstChild) {
          compile(child,vm)//讲data转化为html
          flag.appendChild(child); // 将子节点劫持到文档片段中
        }
        return flag
    }

compile方法在下面解释

替换html

这里主要用的是正则表达式的检测方法,其中对RegExp.$1的用法不了解的同学可以Google一下,这是正则一个非常巧妙而且强大的地方。

function compile (node, vm) {
        var reg = /\{\{(.*)\}\}/;
        // 节点类型为元素
        if (node.nodeType === 1) {
            var attr = node.attributes;
            // 解析属性
            for (var i = 0; i < attr.length; i++) {
                if (attr[i].nodeName == 'v-model') {
                    var name = attr[i].nodeValue; // 获取 v-model 绑定的属性名
                   node.value = vm.data[name];
                   node.removeAttribute('v-model')
                }
            };

        }
        // 节点类型为 text
        if (node.nodeType === 3) {
            if (reg.test(node.nodeValue)) {
                var name = RegExp.$1; // 获取匹配到的字符串
                name = name.trim();
                node.nodeValue = vm.data[name]
            }
        }
    }

我们看看上面的代码,主要就是判断子节点的类型,一旦是元素节点,我们就给它的input事件绑定方法,把input的value传给vm.data[name],如果是文本节点,就直接替换.
这里要注意,element节点我们用的是node.value,text节点我们用的是node.nodeValue,这两个写法的区别可以自行Google一下.

最后再创建一个Vue实例


image.png

下面是codepen的实例


image.png

codepen

任务2:响应式的数据绑定

再来看任务二的实现思路:当我们在输入框输入数据的时候,首先触发input事件(或者keyup、change事件),在相应的事件处理程序中,我们获取输入框的value并赋值给vm实例的text属性。我们会利用defineProperty将data中的text劫持为vm的访问器属性,因此给vm.data.text赋值,就会触发set方法。在set方法中主要做两件事,第一是更新属性的值,第二留到任务三再说。

具体怎么做呢?

监听input事件

input节点

当我们触发input时,要在dom节点上绑定事件?
怎么绑定呢?记得我们前面的nodeToFragment函数吗?就是用于遍历所有的子节点,进行节点修改的。

而里面具体干活的是compile函数,nodeToFragment只是一个包工头。
这样,我们就可以在v-model的标签里监听input事件

if (attr[i].nodeName == 'v-model') {
    var name = attr[i].nodeValue; // 获取 v-model 绑定的属性名
node.addEventListener('input', function (e) {
    // 给相应的 data 属性赋值,进而触发该属性的 set 方法
    vm.data[name] = e.target.value;
});

node.value = vm.data[name]; // 将 data 的值赋给该 node
node.removeAttribute('v-model');

我们看看逻辑,一开始就是从vm.data[name]获取value,一旦自己的内容改变了(e.target.value),就把这个值告诉(赋值)给vm.data[name]

文本节点

而对于文本节点,是不需要的,我们只需要从vm.data获取数据就可以了。因为它不是可以通过input改变内容的。

node.nodeValue = vm.data[name];

劫持get和set方法

想想我们的思路,我们input触发时,是这样修改data值的

 vm.data[name] = e.target.value;

我们希望触发点东西,但那是下一章的内容,无论如何,我们先劫持这些vm.data的所有属性的get和set方法。
以后究竟要怎么搞事我们再决定。

怎么劫持呢?

我们只有在Vue中写入一个observe,用于遍历所有属性,进行get和set的劫持。

function Vue (options) {
        this.data = options.data;
        var data = this.data;

        observe(data, this);

        var id = options.el;
        var dom = nodeToFragment(document.getElementById(id), this);

        // 编译完成后,将 dom 返回到 app 中
        document.getElementById(id).appendChild(dom);
    }

接下来就是怎么写这个observe。

首先必须遍历所有节点。
然后用defineProperty设置get和set方法,这是我们暂且在set时打印新值,看看data是否真的改变了

function observe (obj, vm) {
        Object.keys(obj).forEach(function (key) {
            defineReactive(vm.data, key, obj[key]);
        })
}

function defineReactive (obj, key, val) {
        Object.defineProperty(obj, key, {
            get: function () {
                return val
            },
            set: function (newVal) {
                if (newVal === val) return
                val = newVal;
                console.log(obj[key])
            }
        });
    }

以上就是我们的第二部分,主要实现两部分:

1、设置观察函数observe,改写get和set
2、监听元素节点的input,当符合条件(匹配正则)时,首先从vm.data.key获取相应属性的值,触发get。
当input的内容发生改变时,把该值赋给vm.data.key,触发set。

codepen完整代码在这里

image.png

可以看到当input的值发生改变时,vm.data.key也发生改变,这里我们先用console来判断这个值是否改变了。

至此,第二部分已经完成。

任务3:把data的值渲染到dom里面

上面已经实现了值的双向传递,我们主要用了属性劫持和方法监听(input)。

接下来想想我们该如何把data渲染进dom。

记得我们刚开始的极简版demo吗?

Object.defineProperty(obj,'hello',{
 set:function(newVal){
   var p = document.getElementById('reflect');
   p.innerHTML = newVal;
 }
})

我们是通过找到p元素,当data改变时,直接把新值传给p元素。

但是有一个问题,我们这里假设已经知道p元素与data双向绑定了。

如果我们不知道呢?
仔细看看这句代码p.innerHTML = newVal;
到底哪一个元素的innerHTML才是newVal?

所以我们的关键是找到哪一个节点的对应哪一个属性(vm.data)

这是vue最核心的部分之一

假设我们有一个容器,当我们get内容时,那这个节点肯定与data绑定了,此时我们把这个节点push进这个容器,这样只要每次data改变,我们遍历所有的节点不就可以了吗?

vue管这个容器叫"依赖"(dep),或许表示所有dep里的节点都会依赖这个容器dep。

这么说有点绕口,比如这样,我们在每个属性上绑定一个容器dep,容器上有个数组subs,当有节点get这个属性的值时,我们就记录下这个节点,push进subs。

而当我们的data改变时,就可以遍历所有的节点,让他们更新dom了。

意思就是连接节点和data的基本思路。具体怎么实现呢?

首先我们每个属性各自都需要一个依赖dep,我们可以写一个构造函数Dep,实例对象维护一个数组,用于存放节点。

function Dep () {
        this.subs = []
}

这个依赖还必须有两个功能,添加和更新。
有节点绑定了,就把它添加到数组。
有内容(data)更新了,就”告诉“所有节点去更新dom

所以原型还需要添加这两个方法:

Dep.prototype = {
   addSub: function(sub) {
       this.subs.push(sub);
   },

   notify: function() {
       this.subs.forEach(function(sub) {
           sub.update();
       });
   }
}

这个dep是跟着属性走的,所以我们需要在遍历属性时创建。

function defineReactive (obj, key, val) {
   var dep = new Dep();

   Object.defineProperty(obj, key, {
       get: function () {
           // 添加订阅者 watcher 到主题对象 Dep
           if (添加一个条件) dep.addSub();
           return val
       },
       set: function (newVal) {
           if (newVal === val) return
           val = newVal;
           // 作为发布者发出通知
           dep.notify();
       }
   });

这里的get我们应该把节点push进容器数组,但是想一想,是不是连接建立后我们才要把这个节点push进去呢?怎么判断是不是建立连接了呢?

记得我们的compile函数吗?

if (attr[i].nodeName == 'v-model') {
    var name = attr[i].nodeValue; // 获取 v-model 绑定的属性名
    node.addEventListener('input', function (e) {
       // 给相应的 data 属性赋值,进而触发该属性的 set 方法
       vm.data[name] = e.target.value;
    });
    node.value = vm.data[name]; // 将 data 的值赋给该 node
    node.removeAttribute('v-model');
}

此时是不是通过判断节点是否有”v-model“,但有时,从data里获取v-model绑定的属性值?

这是连接建立的关键,所以再这之后,我们可以判断可以把节点push进去了。

但是想想,光是节点够吗?我们是否还需要更新的函数?能否写在一起?
所以我们可以建立一个Watcher函数,用于更新dom,这样当有data改变时,只要dep告诉我们去更新所有的Watcher就可以了。

这个Watcher就相当于一个容易,包裹了dom元素的内容还有更新方法。

所以我们push进dep的是一个个的Watcher,有更新就调用Watcher的update方法就可以了。

Watcher应该像下面这么写

function Watcher (vm, node, name, nodeType) {
        Dep.target = this;
        this.name = name;
        this.node = node;
        this.vm = vm;
        this.nodeType = nodeType;
        this.update();
        Dep.target = null;
    }

    Watcher.prototype = {
        update: function () {
            this.get();
            if (this.nodeType == 'text') {
                this.node.nodeValue = this.value;
            }
            if (this.nodeType == 'input') {
                this.node.value = this.value;
            }
        },
        // 获取 data 中的属性值
        get: function () {
            this.value = this.vm.data[this.name]; // 触发相应属性的 get
        }
    }

这里的Dep.target是作为节点与data绑定的标志,一旦这个存在了,说明我们要去get方法那里push Watcher了。
之后我们要清除这个Dep.target,有其他Watcher实例对象创建时再赋值,传给dep.
因此相当于一个临时的标志容器,且是全局的。

现在看看上面劫持get时的if条件,应该知道怎么写了吧。
就是Dep.target存在的时候

get: function () {
 // 添加订阅者 watcher 到主题对象 Dep
 if (Dep.target) dep.addSub();
 return val
}

至此,我们的程序就完成了。
测试是没有问题的。

image.png

下面是我画的流程图,可以帮助理解。

image.png

完整示例在这里

回顾

我们创建了一个类似vue的双向绑定机制,怎么实现的呢?

我想从data获取数值,于是我们改变dom,通过匹配正则,符合条件的把data的值赋给dom的value或nodeValue

我们想把内容变更传递给data,于是我们改造所有的data.
各自给它们一个容器dep的数组subs,当连接建立(标志是)同样是正则匹配上了。

此时新建一个watcher,用于标识dom和存放更新dom的方法。

当input的内容改变时,触发obj的set方法,set方法命令subs更新dom,subs遍历所有watcher,让所有watcher中的方法去更新自己的dom。

初次写这么长的文章,刚开始理解这个机制对我来说也有点吃力,但总算搞懂了。

以上,我的解释还有许多不足,欢迎指教,感谢阅读。

如果有可取之处,一个赞便感激不已。🙂

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266