手写MVVM的实现原理

Posted 十九万里

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了手写MVVM的实现原理相关的知识,希望对你有一定的参考价值。

一、学习目标

了解object,definProperty vue2.0使用都是这个响应式的监听
手写并优化MVVM底层实现 实现v-text和v-model原理

二、 数据响应式

1数据响应式是什么

数据发生变化,我们可以马上知道,并且做一些事情,关键在于知道数据发生变化并做出对应的操作

2、如何实现一个数据响应 及区别

vue2.0 对象属性拦截:Object.defineProperty (原生js的方法) 全局拦截

vue3.0版本中:对象属性拦截 Proxy对象代理 部分拦截

两者实现响应式的原理是一样的都是javascript的原生写法

区别:

vue2.0的配置项中,只要放到date中的数据,不管有没有用到或者数据有多深入,vue都会对这个数据进行递归处理。所以尽量不要放很多嵌套深的数据

vue3.0中的proxy 劫持对象整体,惰性处理,只有在data中的数据被 用到的时候,才会进行处理 减少了无端性能的消耗。

代码实现:

// 定义对象方式:
// 1、字面量定义 
let data = {
   name:"pc" 
}
// 如果修改name属性 其实并不知道name属性发生了变化
// 三个参数:定义的对象 声明的属性 page对象对于当前属性的精细化对象 里面主要注意get和set函数
Object.definePerty(data,'name',{
    // get属性就是当我们访问data1的name属性是后自动调用的方法 get函数的返回值就是拿到的值
    get(){
        console.log ('访问到data1中的值')
        return "pc"
    },
     // 当我们修改设置name属性的时候,会自动调用函数 属性最新的值会被当成实参传进来 这个newValue就是最新的值
   set (newValue ){
       consloe.log('修改了data1中的值,newValue就是修改之后最新的值,被定义为一个新的传参')
   }
}) 
// 访问的时候会调取get方法 修改的时候会调取set方法并返回一个传参 可以在访问属性和设置属性的时候调用对应的函数
// 注:set方法的特点是一旦修改某个属性就会立即执行 数据更新就会执行set函数 可以在函数里面添加新的动作。
// 如果想在数据变化的时候完成一些自己的事情 都可以放到set函数里面执行。
// 例如 ajax()  操作一些dom区域,

3、优化get和set联动

// 优化就是在使用set修改之后 第二次使用get时拿到就是修改后的值
// 没有做修改的话第二次修改就还是之前的第一次原有的值 所以要进行优化联动 set和get函数原始的并未发现联动问题
// 联动思路 是因为我们的代码中set和get中没有任何的关联 需要修改get中返回的值应该改为动态的。
// 解决方法: 定义中间变量 _name 把修改后返回的参数newValue赋值给 _name 这样get在执行的时候放回的就是最新修改的newValue 这样的话set和get操作的就是一个数据了
let data = {}
let _name = 'pc'
Object.definePerty(data,'name',{
    get(){
        console.log ('访问到data1中的值')
        return _name
    },
   set (newValue ){
       consloe.log('修改了data1中的值,newValue就是修改之后最新的值,被定义为一个新的传参',newValue)
       _name = newValue
   }
}) 

4 、优化对象数据劫持与转化

在使用vue开发的过程中 ,都是提前把响应式数据放到data配置中,一般来说 响应式数据有很多个,对象的属性很多,不止一个,上面写的对象只有一个,不太方便,我们现在要做的事情就是如何把提前声明好的对象,要把所有 属性编程set和get的形式,目标就是访问定义好的data中的属性和改变。初步想法是遍历

let data = {
    name = 'pc',
    age = '16',
    height = '177'
}
// 1、由于有多个属性需要进行对象的遍历
// key可以代表data对象的每一个属性名
// data[key]代表data对象每一个属性队形的value值 其实就是键值对的关系,data 源对象。Object.keys(data)是对象的遍历方法 得到一个数组所以使用数据的forEach方法来遍历。

// 处理每一个对象的key  转变为响应式 这样的话就每一个属性都进行了set和get的转化
Object.keys(data).forEach(key)=>{
    console.log(key,data[key])
    observe(data,key,data[key])
}
// data 要处理的对象
// 可以要处理的对象属性名 (键)
// value 要处理的对象属性名对应的初始值  (值)
// 2、把对象的属性转化为get和set的形式 让后调用 
function observe(data,key,value){
    Object.defineProperty(data,name,{
     get(){
        return value
    },
    set (newValue){
        value = newValue
    }
    })
}

4、扩展理解作用域闭包的独立性

每一个函数作用域希望是一个独立的功能,

总结:1、尽量不要在一个函数中写大量的逻辑,而是通过传参的形式来分成多个函数。
2、属性遍历时,每遍历一次调用一次definReactive函数,形成多个独立的函数作用域,每一个函数作用域中 get和set中的value都是独立的,互不影响。

三、数据的变化反应到视图

本质上还是操作dom

1、命令式操作视图

命令式的操作视图就是手动操作




// 1.准备数据
let data = {
    name = 'pc'
}
// 2、把数据转化成为响应式
Object.keys(data).forEach(key)=>{
    observe(data,key,data[key])
}
function observe(data,key,value){
    Object.defineProperty(data,name,{
      get(){
          console.log('访问l数据',key)
        return value
    },
     set (newValue){
           // 优化性能 主动判断是否修改了值 需要主动去判断。如果没有修改直接return出去
         if (newValue === value){
             return
         }
          console.log('修改了数据',key)
        value = newValue
         // 把最新的值放到视图中 这里是关键的位置 核心是操作dom api 把最新的值操作上去, 
         // html部分我就不写拿到app b并调用innerText把最新的值赋值上去,只要修改name属性就会修改最新的值。
         document.querySelector('#app p').innerText = newValue
    }
    })
}

2、声明事操作视图

实现v-text 声明式的指令版本

目标:一但data中的那么发生变化后 标记的v-text的文本内容就会立即得到更新
实现指令的核心:不管是指令也好还是插值表达式也好,他们都是数据和视图之间建立的关联的标志
所以本质上就是通过一定的手段找到符合表示的dom元素,然后把数据放上去 数据改变的时候重新执行一次dom操作
<body id = "app">
    <p v-text="name">
        
    </p>
</body>
<script>
    let data = {
        name :'pc'
    }
    // 实现步骤:
    //  1.先通过标识查找把数据放到对应的dom中显示出来
    function compile(){
        let app = document.getElemetById('app')
        xonst childNodes = app.childNodes  // 拿到的所有节点 包括文本节点和标签节点
        console.log(chilNodes)   
        // 筛选出来目标节点 去除没用的文本节点。
        chilidNodes.forEach(node=>{
            if(node.nodeType === 1){
                // 表示这里拿到的是p标签节点
                // 筛选v-text属性,标签上可能有p id v-text等属性 我们只要需要的 使用属性attributes拿到对应的标签
                // 拿到所有的标签属性 
                cosnt =attrs = node.attributes
                // 遍历所有的标签并判断 Array.from是es6中的方法,可以把类数组转化成为数组
                Array.from(attrs).forEach(attr=>{
                    //这里拿到的其实是一个对象 包括键和值
                    const nodeName = attr.nodeName // 对象名 (键 我们需要找到v-text
                    const nodeValue = attr.nodeValue // 对象值 (值 其实就是data中对应的key
                    // 把date中的数据放到满足标识的dom上
                    if(nodeName === 'v-text'){
                        node.innertText = data[nodeValue]
                    }
                    
                    
                    
                })
                
            }
        })
    }
    compile()
    // 2.数据变化之后在此执行将数据放到最新的dom上
</script>
62b6d7264a315cb3
T0014

3、总结

四、发布订阅模式实现

浏览器内部的实现过程分析

1、浏览器实现了一个方法叫做addEventListener

2、这个方法接受了两个参数,参数1代表事件类型,参数二代表回调函数

3、为了实现一对多的架构 在内部绑定了多个回调函数数组。

4、当鼠标点击的时候 通过事件类型click去数据结构中去找到存放所有相关回调函数的数组并遍历,都执行一遍 从而实现一对多

实现一个自己的自定义事件收集和触发机构

//1 。定义一个方法接受两个参数参数一为事件类型 参数2位回调函数 只要方法一执行 就收集回调函数到对应的位置上去,几个参数的含义:【eventName:事件名称;fn :回调函数
const map ={
    
}
function collect(eventName,fn){
    // 如果当前map中已经初始化好了click:[]
    //就直接往里面push,如果没有初始化 就先进行初始化。
    if(!map[eventName]){
        map[eventName] = []
    }
    map[eventName].push(fn)
}
collect('cp',()=>{
console.log("收集成功了cp")
})
collect('cp',()=>{
console.log("第二次收集成功了cp")
})
console.log(map)
//以上完成了事件的收集操作,实现了一对多的存储架构
//上面事件收集好了 下面需要模拟鼠标点击,注定去通过程序触发收集起来的事件。需要通过事件名称 找到对应的回调函数 然后遍历执行即可
function trigger(eventName){
    map[eventName].forEach(fn => fn())
}
trigger('cp')
//上面的函数中 事件函数都暴露在全局作用域中,优化是把所有的事件相关的数据结构添加到一个对象中去
const Dep = {
    map:{},
    //收集事件的方法
    collect(eventName,fn){
         if(!map[eventName]){
        map[eventName] = []
    }
    map[eventName].push(fn)
    }
},
      trigger(eventName){
          this.map [eventNmae].forEach(fn => fn())
      }


五、总结

1、Object.defineProperty ES6中原生的方法

object.defineProperty(data,'name',{
    get(){
      return "查看的值"  
    }
	set(newValue){
         // 设置之后最新的值作为参数返回。
    // 可以在set函数里做一些操作 ajax 操作dom等等
	}
    
})

2、数据反应到视图

数据的变化可以引起视图的变化(通过操作dom你数据放到对应的位置上去,如果数据变化之后就用最新的数据再重新做一遍)

方案一 :命令式操作视图

1、documnet.querySelector('#app p ').innerText = data.name
2set 函数中在执行一遍documnet.querySelector('#app p ').innerText = data.name

方案二:声明式渲染 (v-text方式的实现)

核心逻辑:通过“模板编译”找到标记了v-text的元素,然后把对应的数据通过操作dom的方式放上去

1、通过app根元素找到所有的子节点(元素节点 文本节点) dom.nodeChilds

2、通过节点类型筛选出元素节点 nodeType 1元素节点 3文本节点

3、通过v-text找到需要设置的具体节点

4、找到绑定了v-text的标记元素,拿到他身上所有的属性

5、通过v-text=“name”拿到指令类型“v-text” 拿到需要绑定的数据属性名’name‘

6、判断当前是v-text指令 然后通过操作domapi 把name属性对应的值放上去,node.innerText = data[name]

v-model原理和v-text一样 需要改变的是56 除了指令类型和domapi不一样 其他的的都是一致的。

5、通过v-model= “name”拿到v-model拿到所需要绑定的数据属性名‘name’

6、判断当前是v-model指令,然后通过操作DOMapi 把name属性对应的值放上去 node.value = data[name]

3、 V -> M

本质:事件监听在回到函数中拿到哦input中输入的最新值,然后赋值给绑定的属性

node.addEventListener('input',(e)=>{
    data[name] = e.target.vaule
})

4、优化工作

//1、通用的数据响应式处理
data(){
	return{
		name:'pc',
		age: 23	
	}
}
//基于现成的数据,处理成响应式
Object.keys(data)//由所有对象的key组成的数组
Object.keys(data).forEach(key=>{
    //key 属性名
    // data[key]属性值
    //data 原对象
    //将所有的key都转化为get和set的形式所以需要定义一个 defineReactive函数,传回三个参数
    defineReactive(data,key,data[key])
})
function  defineReactive(data,key,value){
    object.defineProperty(data,key,{
        get(){
            return value
        }
        set(newValue){
        value = newValue
    }
    })
}


2、发布订阅模式
/问题:
<div>
    <p v-text="name">  </p>
    <p v-text="name">  </p>
    <div v-text="age"> </div>
</div>
name发生变化之后,我们需要做的是更新属性为name的标签,现在的情况是无论哪个属性发生了变化,所有的标签都会跟着变化。无法做到准确更新

解决问题的思路:
1、数据发生变化之后 最关键的代码:node.innerText = data[name]
2/设计一个存储结构
每一个响应式数据可能被多个标签绑定 是一种一对多的关系
{
name :[()=>{ndoe(p1)innerText = data[name],()=>{ndoe(p2)innerText = data[name]...]
}
发布订阅模式  解决的是我们一对多的问题

实现简单的发布订阅模式:浏览器事件模型
dom.addEventListener('click',()=>{})
只要调用click事件 所有绑定的回调函数都会执行 其实就是有一对多的关系
const Dep = {
map:{},
collect(evnetName,fn){
// 如果没有调用过事件先初始化成数组
if(!this.map[eventName]){
this.map[eventName] = []
}
// 已经调用过事件 直接往里面push添加数组
this.map[eventName].push(fn)
}
    //trigger函数是用来触发事件
    trigger(eventName){
        this.map[eventName].forEach(fn =>fn())
    }
}
 //使用发布订阅优化现存问题 
   // 之前的写法不管是哪个数据发生变化 我们都是粗暴的执行一下complie函数即可
       // 现在的写法 我们在complie函数储值执行的时候 完成更新函数的收集,然后再数据变化的时候通过数据的key找到对应的更新函数 依次执行达到精准更新的效果。

以上是关于手写MVVM的实现原理的主要内容,如果未能解决你的问题,请参考以下文章

mvvm双向绑定机制的原理和代码实现

手写数字识别——基于全连接层和MNIST数据集

剖析Vue原理&实现双向绑定MVVM

剖析Vue原理&实现双向绑定MVVM

MobileNet原理+手写python代码实现MobileNet

第十一篇 手写原理代码