前端面试常见问题,双向数据绑定实现的原理。
重点1:通过Object.defineProperty 为每个数据节点设置get,set方法,实现对数据的劫持,数据内容改变必须经过set方法,因此就可以在数据改动经过set方法时,去改变页面数据显示。以此实现了数据到页面的单向绑定。
重点2:通过监听input等页面元素的changge,input等事件,node.addEventListener("input",e=>{});可以得到页面输入容的变化,将变化的内容再修改js数据对象中。以此完成页面输入值更新到内存数据对象的操作。
注:通过1,2两点已经可以完成双向数据的绑定。第3点并不是必须的,只是用于提升性能,提高实用性的,因为如果没有第3点的虚拟文档功能,每次数据变更都进行大量dom的渲染,性能消耗过大,页面将无法流程运行。
重点3:虚拟文档,或者叫文档碎片 ,document.createDocumentFragment() 可以创建一个虚拟文档对象,可以像操作页面dom元素一样操作这个虚拟文档,却别是操作页面文档会进行实时的文档渲染显示,性能消耗非常大, 而虚拟文档节点只是内存对象,并不会进入任何渲染计算,可以高效的进行对虚拟文档中的节点进行操作。当需要做的处理全部完成后,再将虚拟文档中的节点一次性加载到页面dom中,页面只需要渲染一次,就可以完成所有的更新。
下面是代码实现,此实现只提供实现逻辑,并未完善所以的情况,不可用于真实生产情况。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>简单的数据双向绑定实现</title>
</head>
<body>
<div id="app">
<span>名称:{{name }}({{sex}})</span>
<input type="text" v-model="name" placeholder="名称"/>
<input type="text" v-model="sex" placeholder="性别"/>
<h1></h1>
<span>更多:{{ more.like }}</span>
<input type="text" v-model="more.like" />
</div>
<script >
class Vue{
constructor(obj_instance) {
this.$data = obj_instance.data;//将传入的初始化数据对象保存到内部对象中
this.$el = document.querySelector(obj_instance.el);//获取 主节点的 node对象
this.$watchers = new WatchersManager();//观察者,简单来说就是页面中动态绑定数据的node 对应的 数据key 列表,如果数据更新了,就在这个列表中把页面动态数据的地方也同步修改,
this.Observer(this.$data);//遍历data中的所有属性,并通过 Object.defineProperty 重写对象的 get 和set 方法,从而监听 数据的变化,在更新到页面上去
this.Compile();//初始化编译页面,用data中的数据去填充页面元素的占位符{{}} 或者 v-model的值,并建立input监听 以及 数据观察机制。
}
/**
* 监听数据变化
* 重点1:这里是双向绑定最重要的点,利用 Object.defineProperty 重写对象属性的功能,对数据对象进行劫持,也就是重写数据的get set方法。
* 1.通过 Object.defineProperty 重写 get set方法,在数据读取和写入的时候,均会触发重写的 get set 函数执行。就可以知道数据什么时候发生变化了
* 2.set 修改属性值被调用后, 去 watchers 通过属性名称,可以找到所有使用此属性的node节点,对节点内的数据进行更新即可。
* 注意:Observer是函数内部存在递归调用,因为data对象可能存在多级结构,比如 data.user , user.name, data.user.wallet.money 存在很多级的数据结构。
* 所以每一级别对象调用Observer方法后,将递归查询所有子对象 再次执行Observer方法。达到绑定所有对象的目的。
**/
Observer(data_instance,partenDirectory){//所有父级调用层级
if(!data_instance || typeof data_instance !== 'object'){//如果传入的参数是对象,则通过Object.defineProperty 重写get set方法,否则是单个值了的话就不用重写了。
return ;
}
const $this = this;
Object.keys(data_instance).forEach(key => {//遍历对象中所有的子对象
let thisDirec = partenDirectory ? partenDirectory+"."+key : key ;//记录一个当前值的访问路径。 如 user ,user.name 等
console.log("为指定数据绑定get,set方法=》"+thisDirec);
let value = data_instance[key];//将原值获取并保存到零时变量 value中。
this.Observer(value,thisDirec);//如果将当前对象 低估嗲用绑定操作,直到所有的子对象对绑定get set方法后 继续向下执行
Object.defineProperty(data_instance,key,{ //为对象重新定义属性,包括get set方法
enumerble:true,//表示能否通过for in循环访问属性,默认值为true
configurable:true,//表示能否通过delete删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性,默认值为true。
get(){//获取值,通过任意方式获取属性值时触发此方法,并返回值
console.log("触发"+key+" get方法 =>",value)
return value;//返回属性值
},
set(newVal){//set方法,修改属性值时会触发此方法,并在方法中修改属性的值
$this.Observer(newVal,thisDirec);//注意,这里是防止 修改一整个对象,比如 user.name 和 user.age 都是user对象中的子对象,如果直接 user = {} 一个新对象,那 user中的name 等子对象就绑定的get set 监听会丢失。 所以需在这里为子对象重新绑定。
console.log("触发"+thisDirec+" set方法 =>" +newVal)
value = newVal;//更新 新传入的值
$this.$watchers.notify(thisDirec);//通过观察者管理类,传入修改的 元素对象的值,触发使用此数据的 node节点进行数据更新
}
})
})
}
/**
* 页面初始化方法,将 data中的数据 展示到 {{xxx}} 占位符或者 v-model绑定值的input中。
* 重点2:文档碎片 DocumentFragment, 通过 document.createDocumentFragment() 可以创建一个虚拟文档。
* 操作这个虚拟文档中的内容与 操作页面中显示的文档元素是一样的,但是最大的区别就是虚拟文档只在内存中构建节点对象,并不进行页面渲染,所以修改虚拟文档要快很多。如果直接去修改页面文档,网页响应速度会非常缓慢。
* 所以vue等均采用虚拟文档建立好元素结构之后,再 append 到页面中。
* 1.获取页面中所有的元素节点node。将节点添加到虚拟文档中。
*/
Compile(){//页面初始化编译方法
const fragment = document.createDocumentFragment();//创建一个文档碎片 或者说是 虚拟文档对象
let child;
while(child = this.$el.firstChild){//遍历 this.$el 也就是 div#app 这个dom元素下的所有节点,并将节点赋值给 child, 注意:firstChild 就是获取当前节点下的第一个子节点元素
fragment.append(child);// 将获取的到得一个子节点元素 添加到 虚拟文档的最后面。 特别注意:child只想的是页面中的元素,如果将child append到虚拟文档中,页面中的节点将被删除。
//因为append 后,原页面节点被删除了,所以 后续while循环获取第一个节点的时候,才会不断的获取到新的节点。
}
//所有原页面中的所有节点就被移动到了 虚拟文档fragment中了。次是如果打断点的话,会看到页面中无任何内容显示了
console.log(fragment)
this.fragment_compile(fragment);//处理虚拟文档,替换节点 中的 {{xxx}}占位符 和 v-moled,替换值
this.$el.append(fragment)//将虚拟文档重新添加会页面显示。
}
/**
* 1.遍历所有的node节点,查看text节点中是否有 {{xxx}} 占位符,如果有就用data中的值去替换占位符。
* 2.变量所有的node节点,查看是否有 属性 v-model 对应的input节点,如果有就将data中的值赋值给input。
* 3.上面 2.3两点中的text 节点和 input节点,都生成对应的 watcher观察者对象,其实就是将node节点和 属性名称封装到 watcher对象中,
* 并添加到 当前class 的 $watchers观察者管理器中,用于后面数据变化后,直接通过属性名称就找到观察者并通知修改属性
*/
fragment_compile(node){
console.log("all node=>",node)
if(node.nodeType === 3){//如果是text 节点
let oldNodeValue = node.nodeValue;//获取原始的text节点中的字符串,因为这里面包含 {{xxx}} 占位符,必须保留旧字符串,否则一旦 占位符替换后,就无法进行二次解析了。
this.changePlachHolderToVal(node,oldNodeValue,this,true);//替换 node 文本中的 占位符为真实 数据值, 因为一个节点可能用到多个值,所以用单独的方法去处理
return ;
}
if(node.nodeType === 1 && node.nodeName==='INPUT'){//如果是input节点,
if(node.hasAttribute("v-model")){//判断input 节点上是否绑定有 v-model 属性
let valKey = node.getAttribute("v-model");//获取 v-model 绑定的 data中的属性的值
//为input 绑定input 事件,触发了input 事件时,会调用set方法去修改 data中数据的值,从而通知观察者进行数据更新
node.addEventListener("input",e=>{//绑定input事件
let nameSplit = valKey.split(".");//data中对象的 属性访问名称数组
nameSplit.reduce((upVal,currentKey,currentIndex)=>{//通过reduce归纳函数的层级执行特性,逐级获取到data中对应属性的值
if(currentIndex==nameSplit.length-1){//最后一层时,不用在返回对象,而是直接进行赋值
upVal[currentKey] = node.value;
}
return upVal[currentKey];//非最后一级的情况,返回当前获取到的属性对象,给与下一级继续执行
},this.$data);
})
node.value = valKey.split(".").reduce((upVal,currentKey)=>{ return upVal[currentKey]; },this.$data);//reduce 层级获取data中的数据,赋值给node节点的value属性
const $this = this;
$this.$watchers.add(valKey,new Watcher(this,valKey,function(){//以属性访问节点为索引 user.name 等,为节点建立观察者对象并加入观察者管理器,只要data中属性发生变化,将通知观察者执行回调函数进行更新
node.value = valKey.split(".").reduce((upVal,currentKey)=>{ return upVal[currentKey]; },$this.$data);//观察者对象回调函数,从data中获取新的值赋值到node节点上,完成更新
}));
}
return ;
}
node.childNodes.forEach(child=>this.fragment_compile(child,this));//通过递归,查找节点下是否有子节点,如果有,递归解析内容,直到所有的虚拟节点均处理完成
//注意:真实的vue可能会涉及到其它情况,需要进行值解析,这里不再全部实现了。比如 或存在select 回选,checkbox等,有必要自行改造吧
}
//占位符 转换成 data中的真实值
/**
* @param {Object} node 虚拟文档中的元素节点
* @param {Object} oldNodeValue 节点中的原生字符串,未解析过{{xxx}}占位符的时候
* @param {Object} vm 当前vue实例对象本身
* @param {Object} iscompile 是否首次编译,因为这个解析值方法是公用的,首次编译也用,后续data中数据变化也是调用此方法更新数据,所以要区分是否是首次编译
*/
changePlachHolderToVal(node,oldNodeValue,vm , iscompile){
let reg = /\{\{\s*(\S+)\s*\}\}/;//正则表达式 匹配字符串中的 {{xxx.xxx}} 包括一些书写变种 {{ xxx.xxx }} {{xxx.xxx }}
let result_regex ;//定义一个零时变量存储 正则表达式的匹配结果
const $this = this;
let tempOldNodeValue = oldNodeValue;//建立一个零时参数存储原始的 元素节点字符串,后续都要用到,所以不可直接修改原始字符串中的占位符
while(result_regex= reg.exec(tempOldNodeValue)){//正则表达式while循环匹配所有的 {{xxx.xxx}} 占位符,如果有,就替换
console.log("原始占位odeValue=>",tempOldNodeValue)
console.log("匹配是否有动态绑定数据=>",result_regex)
let props = result_regex[1].split(".");//result_regex[1] 是正则表达式匹配的结果,获得的内容是 {{xxx.xxx}} 中的 xxx.xxx这个部分,split 以后,就可以获得属性访问的属性名称数组,如user.name 得到的就是 ['user','name]
let lastval = props.reduce(function(qv,key){
return qv[key];
},vm.$data);//通过reduce 层级执行 获得 data中 user.name 属性的值 存到 lastval中
console.log("动态绑定数据为=>",lastval)
tempOldNodeValue = tempOldNodeValue.replace(result_regex[0],lastval);//替换零时字符串 tempOldNodeValue 中的占位符 为data中的真实数据。
if(iscompile){//如果是首次编译处理数据显示,就需要建立观察者对象,当 xxx.xxx属性发生变化时,通知观察者进行数据更新
$this.$watchers.add(result_regex[1],new Watcher(vm,result_regex[1],function(){ //建立观察者对象,并将对象放到观察者管理器中,如果data中的 xxx.xxx数据发生变化,就通知此观察者更新数据
$this.changePlachHolderToVal(node,oldNodeValue,vm,false);//调用 changePlachHolderToVal 函数 重新更新绑定的数据
}));
}
reg.lastIndex = 0;//此处是一个特殊点,正则表达式匹配 exec 执行后,如果找到了匹配值,匹配器中将记录 当前索引到的下标,存放到 lastIndex中,下次在执行exce匹配是,只会从上次查到数据的位置开始匹配,会出现bug,所以这里将lastIndex=0,确保后续匹配从0开始,不会漏掉
}
node.nodeValue = tempOldNodeValue;//字符串中所有占位符绑定的数据都解析好了,在更新到节点对象中,因为节点已经append到了网页中进行显示,所以这里只要复制完成,页面会直接更新,也就是局部更新了。
}
}
/**
* 观察者管理类,管理观察者列表
* add 添加 指定属性的观察者
* notify 触发指定属性 的观察 进行数据更新
*/
class WatchersManager{
constructor() {
this.$watchers = [];
}
changeDataKeyFramte(dataKey){//将 user.name 这种链式访问的属性名转换为 key,watchkey_user_point_name
return "watchkey_"+dataKey.split(".").join("_point_");
}
add(dataKey,watcher){//添加观察者到管理列表,通过将属性访问名称 转换为key,同一个属性名的对象装进同一个列表,之后触发更新只要获取相应列表进行更新即可
let watchers = this.$watchers[this.changeDataKeyFramte(dataKey)];
if(!watchers){
watchers = [];
}
watchers.push(watcher);
this.$watchers[this.changeDataKeyFramte(dataKey)] = watchers;
}
notify(dataKey){//通过属性名 获得 此属性的观察者列表,forEach遍历观察者列表,并调用观察者的更新操作
let watchers = this.$watchers[this.changeDataKeyFramte(dataKey)];
if(!watchers){
return ;
}
watchers.forEach(watcher => watcher.update() )
}
}
/**
* 观察者 对象
* 主要就用于包装回调函数
*/
class Watcher{
constructor(vm, key, callback) {
this.vm = vm;
this.key = key;
this.callback = callback;
}
update(){
this.callback();
}
}
</script>
<script>
const vm = new Vue(
{
el:'#app',
data:{
name:"小明",
sex:"男",
more:{
like:"大家好,这是测试数据",
abc:"1234"
}
}
}
);
</script>
</body>
</html>