数据响应式是啥?
先来说说vue框架,它本质上是一个MVVM框架,而MVVM框架的三要素:数据响应式、模板引擎、渲染
数据响应式:监听数据变化并在视图中更新
Object.defifineProperty()
Proxy
模版引擎:提供描述视图的模版语法
插值:{{}}
指令:v-bind,v-on,v-model,v-for,v-if
渲染:如何将模板转换为html
模板 => vdom => dom
所以简单来说,数据变更能够响应在视图中,就是数据响应式。本文暂不涉及虚拟dom,先就vue中的数据响应式和模板引擎做一个简单的解析和实践。
原理分析
首先,我们先看一段vue实际使用的代码,从中分析双向绑定的实现方式和原理。
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Title</title></head><body><div id="app"><p l-text="count"></p><p l-html="desc"></p></div><script src="lCompile.js"></script><script src="lvue.js"></script><script>const app = new LVue({el:'#app',data:{count:1,desc:'<span style="color:red">这是lvue?</span>'}}})</script></body></html>

1. new LVue() 首先执行初始化,对data执行响应化处理,这个过程发生在Observer中
2. 同时对模板执行编译,找到其中动态绑定的数据,从data中获取并初始化视图,这个过程发生在 Compile中
3. 同时定义一个更新函数和Watcher,将来对应数据变化时Watcher会调用更新函数
4. 由于data的某个key在一个视图中可能出现多次,所以每个key都需要一个管家Dep来管理多个 Watcher
5. 将来data中数据一旦发生变化,会首先找到对应的Dep,通知所有Watcher执行更新函数
好了我知道你们不想看文字,然后我花了大力气画的图,好好看!肯定能看懂!看不懂找我!


好了,下面开始代码部分,代码部分这里先直接贴一个链接,大家可以直接去看。代码
具体实现
LVue
相当于入口文件吧,处理整体逻辑,把数据存起来,模板拿过来处理。
class LVue{constructor(options){this.$options = optionsthis.$data = options.data// 代理方法proxy(this,'$data')// 创建observe观察者observe(this.$data)// 编译模板,下面写new Compile(options.el, this)}}// 代理方法,目的是可以直接用this访问到$data中的内容function proxy(vm,str) {Object.keys(vm[str]).forEach(val=>{Object.defineProperty(vm,val,{get(){return vm[str][val]},set(newVal){vm[str][val] = newVal}})})}// 就简单的看一下是不是对象,因为defineReactive是对象的方法,// 至于数组则是通过重写数组操作方法实现数据劫持的,不过vue3中使用了ES6的Proxyfunction observe(obj) {if (typeof obj !== 'object' || obj == null) {// 希望传入的是objreturn}// 创建Observer实例,进行数据劫持,下面写new Observer(obj)}
Observer(数据劫持)
我们知道可以利用**Obeject.defineProperty()**来监听属性变动,但是你不能简单的对那个对象监听一下,万一对象内部属性还是个对象呢??所以需要将observe的数据对象进行递归遍历,包括子属性对象的属性,都加上setter和getter,这样的话,给这个对象的某个值赋值,就会触发setter,那么就能监听到了数据变化。
class Observe {constructor(value){this.value = valuethis.walk(value)}// 对传入的参数进行劫持walk(obj){// 因为前面已经判断过是对象了,直接循环执行数据劫持方法就行了Object.keys(obj).forEach(key => {defineReactive(obj, key, obj[key])})}}// 对象的响应式function defineReactive(obj,key,val){observe(val)const dep = new Dep() // 这里是消息订阅器,用来建立数据更新与页面更新的对应关系。// 所有dep都先不看,下面会具体分析Object.defineProperty(obj,key,{get:function(){Dep.target&&dep.addDep(Dep.target)return val},set:function (newVal) {//当给data属性中设置值的时候, 更改获取的属性的值if (newVal !== val) {observe(newVal)val = newValdep.notify() // 改变值时触发dep内部的循环更新}}})}

监听到变化之后就是怎么通知订阅者Watcher了,有人说,那就直接通知Watcher不就好了?!那下面,说一下依赖收集。
视图中会用到vue的data中的某个值,这称为依赖。同一个值,可能会出现很多次,每次出现都需要将它收集出来,用一个Watcher进行维护,这就是依赖收集。 而某个值出现多次,则需要多个Watcher,这时候我们就需要一个Dep来管理,我们在修改数据时由Dep通知Watcher批量更新。
来个简单版解释: 代码中某个值,在很多地方使用,每个使用的地方对应一个更新操作。需要把这些操作放到一个盒子里,值改变的时候把盒子里所有更新操作触发一下。
watcher
好了,大概知道Watcher和Dep是什么了,那下面给大家先来个实现思路:
- 劫持时defifineReactive为每一个key创建一个Dep(就是上面说的那个管理Watcher的东西)实例。
- 初始化视图时每一次读取某个key,例如name1,创建一个watcherName1。
- 此时就会触发key(name1)的getter方法,所以就可以在getter方法中将watcherName1添加到name1对应的Dep中。
- 当key(name1)更新时,setter触发,此时便可通过对应Dep通知其管理所有Watcher更新,这样Dep中所有的watcher都触发一次更新,就实现了数据的响应。
这时候看完这些再回去理解前面劫持部分的代码有关dep的部分是不是就理解了呢。那下面我们来看一下实现:
class Watcher {constructor(vm,key,updateFn){// vue实例this.vm = vm// 可触发/依赖 的keythis.key = key// 更新函数this.updateFn = updateFn// 下面两行要回去对照数据劫持getter部分看一下Dep.target = this // 把Watcher存一下,get中直接dep.addDep(Dep.target)存进去this.vm[this.key]; // 这里的意思就行调用了一下对应的key,这样就能出发getter方法了Dep.target = null}update(){this.updateFn.call(this.vm,this.vm[this.key])}}
Dep
Dep就相对简单多了,本质上就是维护了一组Watcher,有一个更新事假,执行的是循环出发Watcher中的更新方法。代码如下:
class Dep{constructor(){this.deps = []}addDep(dep){this.deps.push(dep)}notify(){// deps里面是一个一个的 watch , 改变值后循环触发update方法this.deps.forEach(dep => dep.update());}}
Compile
接下来是编译部分,说实话这一部分理解起来特别简单,就是以传进来的根节点为基础遍历整个dom,然后找出节点上属性中“v-”,”{{}}”等等这一类的标识属性,挨个创建Watcher来监听他们就好了。虽然理解简单,但是代码却很多,所以代码细节就不一行一行的解释了,大家可以仔细看看代码,我还是写了不少注释的~ 有疑问也欢迎骚扰~
class Compile {constructor(el, vm){this.$el = document.querySelector(el)this.$vm = vmif (this.$el){this.compile(this.$el)}}// 编译,vue的语法compile(el){const childNodes = el.childNodesArray.from(childNodes).forEach(node=>{if (this.isElement(node)){// console.log("编译元素" + node.nodeName)this.compileElement(node)} else if(this.isInterpolation(node)){// console.log("编译差值文本" + node.textContent)this.compileText(node)}if (node.childNodes&&node.childNodes.length>0) {this.compile(node)}})}isElement(node){return node.nodeType === 1}isInterpolation(node){return node.nodeType === 3 &&/\{\{(.*)\}\}/.test(node.textContent)}// node为元素时编译方法compileElement(node){let nodeAttrs = node.attributesArray.from(nodeAttrs).forEach(attr=>{let attrName = attr.namelet exp = attr.valueconsole.log(exp)// 属性名以 l- 开头时处理if (attrName.indexOf("l-")===0){let dir = attrName.substring(2)// 拿出后面的html、text 等,html、text会被在内部定义方法this[dir]&&this[dir](node,exp)}// 时间处理if(this.isEvent(attrName)){// @click = onClickconst dir = attrName.substring(1) // click// 事件监听this.eventHandler(node,exp,dir)}})}isEvent(dir){return dir.indexOf('@') == 0}eventHandler(node,exp,dir){const fn = this.$vm.$options.methods &&this.$vm.$options.methods[exp]node.addEventListener(dir,fn.bind(this.$vm))}// node为文本时处理compileText(node){this.update(node,RegExp.$1,'text')}// 初始化时执行 更新方法,并传入texttext(node,exp){this.update(node,exp,'text')}// 初始化时执行 更新方法 目的是 update中创建了Watcher,可以传入改变方法,在数据监听时就可执行了html(node,exp){this.update(node,exp,'html')}model(node,exp){// update方法只完成赋值和更新this.update(node,exp,'model')// 所以还需要事件监听node.addEventListener('input',e=>{// 将新的值赋值给数据即可this.$vm[exp]=e.target.value})}// 创建更新函数,和watcher绑定update(node,exp,dir){const fn = this[dir+'Updater']fn && fn(node,this.$vm[exp])new Watcher(this.$vm,exp,function (val) {fn && fn(node,val)})}// v-text 绑定text方法textUpdater(node,val){node.textContent = val}// v-html 绑定html方法htmlUpdater(node,val){node.innerHTML = val}modelUpdater(node,val){// 多用在表单元素,暂时只考虑表单元素赋值node.value = val}}
结语
这篇文章主要是介绍了一下Vue的数据响应式和他的原理以及实现。有疑问欢迎提问,当然发现问题也欢迎随之指正。
