实现数据的初渲染
- 任务清单一:获取节点的模板内容(这里以
{{message}}
)为例 - 任务清单二:获取数据(以获取
new Vue({})
中data的message为例) - 任务清单三:替换HTML页面的内容显示
- 任务清单四:如果data中的message数据改变,更改页面的数据显示
HTML页面的构造代码
<div id="app">
{{message}}
<div>123</div>
<div>{{message}}</div>
</div>
依靠原生JS构造的Vue实例的调用代码
var vm=new Vue({
el:'#app',
data:{
message:'<h1>测试数据</h1>'
}
})
依靠原生JS构造的Vue实例的创建过程
- 思路一:我们观察到该HTML的构造由最外层的
<div id="app"></div>
节点包裹着,该最外层的节点可能会包裹着一系列的元素节点、属性节点、文本节点,如果这时候我们判断被包含的文本节点中有没有{{message}}
,匹配到有的话则触发视图数据渲染。 - 思路二:这时候我们并未解决在深层次中还可能存在着我们需要触发视图数据渲染的
{{message}}
,即<div id="app"><div>{{message}}</div></div>
的情况,我们一开始以最外层为起始点出发查找,能不能在我们查找子节点的时候判断最外层包裹的元素节点是否还有子节点,如果该元素节点还有子节点,那么我们现在以这个元素节点为起始点在往下递归查找{{message}}
呢? - 流程一:
class Vue{
constructor(options){
//将传入的数据挂载到类属性上
this.$options=options;
this.$data=options.data;
//初始化渲染页面的时候调用编译方法
this.compile();
}
//初次渲染时使用的两个编译方法
compile(){}
compileNodes(ele){}
}
- 流程二:
compile(){
//最先查找该<div id="app"></div>节点下的元素、属性、文本节点
//最先以该<div id="app"></div>节点为作用域查找
let ele=document.querySelector(this.$options.el);
//将查找模板{{message}}的逻辑封装在这个方法中
this.compileNodes(ele);
}
- 思路一:该
compileNodes(ele)
方法获取需要查找的作用域,可以改变作用域来递归查找更深层次的作用域,直到找到没有为止。即一开始我们查找<div id="app"></div>
节点下的元素、属性、文本节点。我们会发现这个节点又包裹着一个元素节点,这个元素节点果然有我们想要的信息:<div>{{message}}</div>
,所以我们会在这里做判断递归查找直到尽头。 - 流程三:
compileNodes(ele){
//遍历作用域(最先是#app)里面的所有节点
let childNodes=ele.childNodes;
// 元素节点1 属性节点2 文本节点3 等节点都会拿到
//[...childNodes]将类数组转为真数组
//node 就是所有遍历到的子节点
[...childNodes].forEach(node=>{
//先处理第一层(表层)的文本节点中有 {{}} 的
//用正则开始查找 {{}}
if(node.nodeType===3){
// textContent 属性设置或返回指定节点(包括它的所有后代的)的文本内容
//textContent (返回一个拼接完成的字符串)
//正则表达式判断 排除{{ me{ssage }}
//正则表达式判断 排除{{ me}ssage }}
//正则表达式判断 排除 {{ me ssa ge }}
let reg=/\{\{\s*([^\{\}\s]+)\s*\}\}/;
//如果在该文本节点下检测到了{{message}}
if(reg.test(node.textContent)){
//第一个捕获组--已经拿到了{{message}}中的message
//只要有捕获组存在就可以通过RegExp.$xx拿到跟其他方法无关
let $1=RegExp.$1;
//接着我需要拿到this.$data里面的message(判断数据有没有对应值)
//在这里会触发get()方法
// console.log(this.$data[$1])
//替换模板数据内容到HTML页面
let res=node.textContent.replace(reg,this.$data[$1]);
node.textContent=res;
}
}else if(node.nodeType===1){
//该子节点是元素节点
//这里的node就是该元素节点
//判断该元素节点有没有子节点
//如果有则以该元素节点为初始点来递归查找
//递归一定要有出口(这里的出口是该元素节点底下不再有子节点)
if(node.childNodes.length>0){
this.compileNodes(node)
}
}
})
}
实现数据的视图更新(使用观察者模式)
- 思路一:到目前为止我们已经实现了数据的初次渲染,接下来我们要实现数据响应式渲染,这里我使用了定时器,该定时器的效果是隔了一秒后,改变data中的
message
。我们希望在定时器改变数据之后,页面能够自动的更新实时的数据内容。 - 流程四:
setTimeout(()=>{
////这里触发了set()方法
vm.$data.message='修改后的数据'
// console.log(vm.$data,'data')
},1000)
- 思路一:我们需要做到观察data中的数据,可以使用
defineProperty
来做到对数据的观察。我们还需要做到在data中的数据发生改变时可以劫持数据,可以使用defineProperty
中的get和set
方法实现。最后我们还需要做到在成功劫持数据并确定数据发生改变时触发相应的数据响应事件,这里可以用浏览器自带的EventTarget
做到。 - 流程一:
class Vue extends EventTarget{
constructor(options){
//继承事件
super();
this.$options=options;
this.$data=options.data;
//这里是用来劫持this.$data 里的数据的方法
this.observe(this.$data)
this.compile();
}
- 思路一:我们选择在一开始定义一个方法来根据data数据的所有属性名作为自定义事件名来监听事件(在劫持到数据发生改变时触发set()逻辑,在set()逻辑中会去触发自定义事件)
- 流程二:
observe(data){
//拿到由属性名组成的数组
let keys=Object.keys(data);
keys.forEach(key=>{
//防止get()方法的反复调用导致死循环
let value=data[key]
//保存this指向
let _this=this;
//通过遍历循环的方式对每一个key值进行数据观察
Object.defineProperty(data,key,{
configurable:true,
enumerable:true,
//定义get()的作用为返回指定key的属性值
get(){
return value
},
//定义set的作用为在set()被触发(即data[key]发生赋值时)
set(newValue){
//触发了set逻辑说明数据更改了需要重新渲染更新视图
//触发以key值为事件名的自定义事件并把新值当作参数传递了出去
_this.dispatchEvent(new CustomEvent(key,{detail:newValue}));
//更新数据即下一次调用get()方法返回的就是新值
value=newValue
}
})
})
}
- 思路二:我们已经成功完成了数据劫持和触发自定义事件的逻辑,可是我们要在哪里订阅自定义事件呢?我们应该要在一个可以拿到data中数据属性名的地方订阅自定义事件即上文中提到的compileNodes(ele)方法
- 流程三:
compileNodes(ele){
[...childNodes].forEach(node=>{
if(node.nodeType===3){
let reg=/\{\{\s*([^\{\}\s]+)\s*\}\}/;
if(reg.test(node.textContent)){
let $1=RegExp.$1;
let res=node.textContent.replace(reg,this.$data[$1]);
node.textContent=res;
//在这里订阅自定义事件
//$1是我们通过正则表达式匹配的对应的数据属性名
this.addEventListener($1,e=>{
// console.log('这里是传递过来的新值',e.detail)
let newValue=e.detail;
//已经渲染在页面上的老值
let oldValue=this.$data[$1];
//我们要让新值替换掉页面上的老值
let reg=new RegExp(oldValue);
//一旦上头触发了set()方法里面的自定义事件这里就会用新值改变HTML视图
let res1=node.textContent.replace(reg,newValue);
node.textContent=res1;
})
}
}else if(node.nodeType===1){
if(node.childNodes.length>0){
this.compileNodes(node)
}
}
})
}
}
实现数据的视图更新(使用发布订阅模式)
- 思路三:通过new EventTarget()的观察者方式需要紧紧的依靠data中数据的属性名,因为我们以它的属性名作为了自定义事件的名称(从效果上看就像监听一个未来会发生的事情),我们考虑换一种新的发布订阅模式来改写
observe(data)
的方法。 - 思路四:我们定义一个管理器类名为
Dep
,这就像是一个鱼池子,它容纳着所有的小鱼在池子里遨游(即它的作用是收集全部的订阅者取名为Watcher),竟然在池子里装着的是一个个待触发事件的订阅者(订阅者(Watcher)身上有着待触发的订阅事件(Watcher参数中的回调函数)),因此我们会赋予该池子(Dep)有着能够发布(触发)订阅者身上事件的能力。 - 思路五:我们定义一个类名为
Watcher
的订阅者,它身上的参数分别是data(配置项中的data数据)、key(data数据中的属性名)、cb(回调函数处理数据响应式页面的逻辑)。 - 思路六:我们在初次编译的时候去实例化一个Watcher并传递一个处理个更新新数据到视图的逻辑。我们还需要在初次编译的时候通过某种手段(这里用的是在初始化Watcher的时候把其挂载在管理器Dep的静态属性target上)来把Wather一开始就放进管理器
Dep
池子里。然后等待着某个时机(这个时机就是我们通过定时器改变了数据的值)来触发Watcher的回调函数(这里封装着通过新值替换掉页面上的旧值并重新渲染更新页面的逻辑) - 效果图为:(一秒后根据新值改变视图数据)
- 流程一:
class Dep{
constructor(){
//收集订阅者的容器池子
this.subs=[];
}
//添加订阅者
//sub就是收集每一个Watcher
addSub(sub){
this.subs.push(sub)
}
//发布订阅者Watcher中的事件
notify(newValue){
//一个一个的Watcher会落入空池子中
//我们可以决定什么时候去发动空池子的notify方法
//来去触发一个个Watcher的update方法
this.subs.forEach(sub=>{
sub.update(newValue);
})
}
}
- 流程二:
//针对每一个数据生成一个对应的Watcher
class Watcher{
//data是配置项中的data数据
//key在这里是message
//cb是将要触发的回调函数
constructor(data,key,cb){
//在初次编译的时候手动的再去触发get()方法
//是为了在一开始就要收集一次Watcher
Dep.target=this; //这里的this指向了实例化的Watcher
data[key];
this.cb=cb;
//每一次Watcher收集完成之后要清空该属性
Dep.target=null;
}
update(newValue){
//我们得有一个东西去触发,然后开始执行逻辑
this.cb(newValue)
}
}
- 流程三:
setTimeout(()=>{
//这里触发了set()方法
vm.$data.message='修改后的数据'
// console.log(vm.$data,'data')
},1000)
- 流程四:(改写的observe(data)方法)
observe(data){
//任务一:数据劫持
let keys=Object.keys(data);
keys.forEach(key=>{
//防止死循环
let value=data[key]
let _this=this;
//页面一开始就生成管理器
let dep=new Dep();
// console.log(dep,'dep') //这里是0个Watcher
Object.defineProperty(data,key,{
configurable:true,
enumerable:true,
get(){
//页面一开始会被触发get()方法
//因为一开始Dep.target为true所以一开始就会收集起Watcher
if(Dep.target){
//得判断Dep的静态属性target里面有没有Watcher
//静态属性只有类能访问到
//如果里面有Watcher那么这个Watcher就要被收集到管理器池子里
//会产生一个错误就是只要我再次调用get()的话
//执行Dep.target为true就会再把一个Watcher添加到池子里
//这是因为我们每一次在实例化一个Watcher的时候都会挂载一个Dep.target
//但是我们挂载过后并没有清空Dep.target,因此我们每次调用get()方法会累加Watcher
//解决方法是在挂载后将Dep.target=null;
dep.addSub(Dep.target)
}
return value
},
set(newValue){
//触发了set逻辑说明数据更改了需要重新渲染更新视图
//这里的定时器会触发这里的set方法
//触发notify -> 触发Watcher中的update()方法 -> 触发Watcher中的cb()方法 -> 视图更新
dep.notify(newValue)
//更新数据
value=newValue
}
})
})
}
- 流程五:(改写的compileNodes(ele)方法)
compileNodes(ele){
// console.log(ele);
//遍历作用域(#app)里面的所有节点
let childNodes=ele.childNodes;
[...childNodes].forEach(node=>{
// console.log(node)
//先处理第一层(表层)的文本节点中有 {{}} 的
//开始查找 {{}}
if(node.nodeType===3){
let reg=/\{\{\s*([^\{\}\s]+)\s*\}\}/;
//如果在该文本节点下检测到了{{message}}
if(reg.test(node.textContent)){
let $1=RegExp.$1;
let res=node.textContent.replace(reg,this.$data[$1]);
node.textContent=res;
//在初次编译的时候去实例化一个Watcher,需要传递一个回调函数
new Watcher(this.$data,$1,(newValue)=>{
// console.log('我要在这里做更新视图','我得拿到oldValue'+ this.$data[$1] +'和newValue',newValue)
//已经渲染在页面上的老值
let oldValue=this.$data[$1];
//我们要让新值替换掉页面上的老值
let reg=new RegExp(oldValue);
let res1=node.textContent.replace(reg,newValue);
node.textContent=res1;
})
}
}else if(node.nodeType===1){
if(node.childNodes.length>0){
this.compileNodes(node)
}
}
})
}
实现v-html指令(使用发布订阅模式)
- 流程一:
<div id="app">
<div v-html="message"></div>
</div>
- 流程二:
setTimeout(()=>{
////这里触发了set()方法
vm.$data.message='修改后的数据'
// console.log(vm.$data,'data')
},1000)
- 流程三:(重点关注在
else if(node.nodeType===1
下封装的if(attrName='v-html')
中的逻辑)
compileNodes(ele){
let childNodes=ele.childNodes;
[...childNodes].forEach(node=>{
if(node.nodeType===3){
let reg=/\{\{\s*([^\{\}\s]+)\s*\}\}/;
if(reg.test(node.textContent)){
let $1=RegExp.$1;
let res=node.textContent.replace(reg,this.$data[$1]);
// console.log(res)
node.textContent=res;
//在初次编译的时候去实例化一个Watcher,需要传递一个回调函数
new Watcher(this.$data,$1,(newValue)=>{
// console.log('我要在这里做更新视图','我得拿到oldValue'+ this.$data[$1] +'和newValue',newValue)
//已经渲染在页面上的老值
let oldValue=this.$data[$1];
//我们要让新值替换掉页面上的老值
let reg=new RegExp(oldValue);
let res1=node.textContent.replace(reg,newValue);
node.textContent=res1;
})
}
}else if(node.nodeType===1){
//找到该元素节点的所有属性和属性值
let attrs=node.attributes;
[...attrs].forEach(attr=>{
let attrName=attr.name;
let attrValue=attr.value;
// console.log(attrName,'拿到了属性名')
// console.log(attrValue,'拿到了属性值')
if(attrName='v-html'){
//针对每一个数据去生成一个对应的Watcher
//这里的数据是attrValue也就是message
new Watcher(this.$data,attrValue,(newValue)=>{
// console.log('我要在这里做更新视图','我得拿到oldValue'+ this.$data[$1] +'和newValue',newValue)
//在定时器触发set方法之后
//会触发该Watcher方法中的这个用来更新视图的回调函数
node.innerHTML=newValue;
})
//一开始渲染上旧数据
node.innerHTML=this.$data[attrValue]
}
})
if(node.childNodes.length>0){
this.compileNodes(node)
}
}
})
}
}
- 效果图为:
实现v-model指令(使用发布订阅模式)
if(attrName='v-model'){
//当前的node节点就是input输入框
node.value=this.$data[attrValue]
//针对每一个数据去生成一个对应的Watcher
//这里的数据是attrValue也就是message
new Watcher(this.$data,attrValue,(newValue)=>{
// console.log('我要在这里做更新视图','我得拿到oldValue'+ this.$data[$1] +'和newValue',newValue)
node.value=newValue;
})
node.addEventListener('input',(e)=>{
// console.log(e.target.value)
//自动响应
//触发set()方法
this.$data[attrValue]=e.target.value;
})
}
作者:林子酱
链接:https://juejin.im/post/6894163710798118926
看完两件小事
如果你觉得这篇文章对你挺有启发,我想请你帮我两个小忙:
- 把这篇文章分享给你的朋友 / 交流群,让更多的人看到,一起进步,一起成长!
- 关注公众号 「画漫画的程序员」,公众号后台回复「资源」 免费领取我精心整理的前端进阶资源教程
本文著作权归作者所有,如若转载,请注明出处
转载请注明:文章转载自「 Js中文网 · 前端进阶资源教程 」https://www.javascriptc.com