本文是lhyt本人原创,希望用通俗易懂的方法来理解一些细节和难点。转载时请注明出处。文章最早出现于本人github
0.剧透
vue的实现,分为M-V,V-M,M-V三个阶段,第一个阶段主要利用fragement文档片段来节点劫持,使得M和V层关联起来。第二阶段,利用defineProperty使得V层的变化能让M层检测到并更新M层。第三阶段,利用了发布-订阅模式,让M层的变化实时反映到V层中,实现了手写的v-model
1.场景
首先,抛出一个问题,在一个ul下面创建100个li,并且编号。于是,就有
var ul = document.getElementByTarName("ul");
for (var i = 0; i < 100; i++) {
var li = document.createElement('li');
li.innerHTML = i+1;
ul.appendChild(li)
}
看起来操作是很容易的,但是每一次插入都会引起重新渲染,会重新重绘页面,因此会影响性能的
于是又有另一种方法,弄一个中转站,最后一次性放进去
var ul = document.getElementByTarName("ul");
var inHtml = '';
for (var i = 0; i <100; i++) {
inHtml +="
"+(i+1)+" ";}
ul.innerHTML = inHtml;
然而这种方法不灵活,如果面对多变的dom结构,就难以操作
2.documentFragment
于是就有一种叫做文档片段的东西documentFragment,是没有父节点的最小文档对象,常用于存储html和xml文档,有Node的所有属性和方法,完全可以操作Node那样操作。
DocumentFragment文档片段是存在于内存中的,没有在DOM中,所以将子元素插入到文档片段中不会引起页面回流,因此使用DocumentFragment可以起到性能优化作用。
上面的问题就可以进一步优化。
var ul = document.getElementByTarName("ul");
var frag = document.createDocumentFragment();
var ihtml = '';
for (var i = 0; i < 100; i++) {
var li = document.createElement('li');
li.innerHTML = "index: " + i;
frag.appendChild(li);
}
ul.appendChild(frag);
3.节点劫持
既然有这样的一个中转站,那么他还可以做更多的事情。在开发中,随着代码量增加,越来越需要讲究性能,那么如果遇到需要操作很多节点的时候,直接创建节点的时候,页面就不断重排重绘,GPU负担越来越大。这时候,需要一个中转站,将需要用到的节点劫持,让他不在dom中
html部分:
你看见我了hi
js部分:
function myFragment(node){
var frag = document.createDocumentFragment()
var child
while(child = node.firstChild){//有子节点的时候,就给child赋值
frag.appendChild(child)//追加到frag,子节点少一个
}
return frag
}
var DOM = myFragment(document.getElementById('app'))
console.log(DOM)
console.log('这是innerHTML:'+document.getElementById('app').innerHTML)
控制台
先创建一个文档片段,再将节点的第一个子节点添加到文档片段里面,再第二个......直到没有,跳出循环,此时innerhtml没有内容,都在文档片段里面了。这就是节点劫持,无论怎么改样式,整个div没有内容高度也是0。
4.看看劫持的是什么(扫描)
在上面的基础上,我们可以看一下每一个标签、每一个属性的怎样的
html:
在frag.appendChild(child)这句前面加上一段代码来看一下里面的节点
js:
function myFragment(node){
var frag = document.createDocumentFragment()
var child
while(child = node.firstChild){
if(child.nodeType === 1){//如果是元素节点
var attr = child.attributes //将元素节点所有的属性集合存放在attr
console.log(child.attributes)
}
frag.appendChild(child)//将子节点追加到文档片段。非常重要,没有这句就死循环
}
return frag
}
myFragment(document.getElementById('app'))
手滑,不小心写多了一个v-model="text",不过还是被显示到了
v-model?这不就是vue的一个指令吗
既然能拿到他,那么我们现在开始手写一个迷你版vue试试看
5.迷你版vue准备工作
一贯使用的IIFE
对于全局环境,存在exports对象的话,说明引入环境是node或者其他commonjs环境。如果是amd标准,如requirejs,就用define(factory)引入逻辑代码
(function(global,factory){
typeof exports === 'object'&& typeof module !== 'undefined'?module.exports = factory():
typeof define === 'function' && define.amd?define(factory) :
(global.Vue = factory())
})(this,function(){
//主体在这里
})
这段国际常规的hello word代码放在最后
var app = new Vue({
el:"app",
data:{
text:"hello word",
message:{name:'pp'}
}
})
6.M-V绑定
data中的值,反映到input中,也就是M->V层的过程
html:
{{text}}
6.1定义Vue构造函数
传入的参数就是new Vue里面的对象,获得el、data,再劫持id为app的元素里面的节点,并进行操作
var Vue = function(opts){
var id = opts.el||body
this.data = opts.data||{}
var DOM = myFragment(document.getElementById(id),this)
document.getElementById(id).appendChild(DOM)//劫持到节点,添加到app上
}
6.2myFragment方法的完善
上面已经讲到怎么劫持节点,并console看到了节点的内容
遍历attr,如果发现v-model这个属性,就给他赋值,此时输入框内容就是hello word
for(var i = 0;i
if(attr[i].nodeName == 'v-model'){
var name = attr[i].nodeValue
console.log(name) //text
node.value = vm.data[name]//输入框内容:hello word
}
}
6.3替换mustache的内容
已经搞定了输入框,接下来就是双大括号了{{ }},继续在扫描的方法中添加另一个分支:当扫描到文本节点,就使用正则匹配双大括号并进行替换
if(node.nodeType === 3){//匹配文本节点
if(/{{(.*)}}/.test(node.nodeValue)){
var name = RegExp.$1//获得文本内容
console.log(name)
name = name.trim()
node.nodeValue = vm.data[name]//替换双大括号的内容
}
}
现在,文本框和双大括号值都是hello world 了
注意:vm.data[name]可以理解为初步绑定,他就是data里面的text的内容,接下来肯定不是绑死他的
6.4数据监听
定义一个observer函数,彻底地监听每一个数据,而且需要无视对象中的对象。先检测obj是不是对象类型,如果不是就跳出(此时已经是对象多层嵌套的最里面那层的key),如果是对象,就调用calation方法递归。
function observer(obj,vm){
if(typeof obj!=='object'){return}
Object.keys(obj).forEach(function(key){
console.log(key)//text,message,name
calation(vm,obj,key,obj[key])
})
}
function calation(vm,obj,key,value){
observer(value,vm)
}
综上,在IIFE主体里面添加下面代码,这部分是M->V的过程
var Vue = function (opts) {
var id = opts.el || body
this.data = opts.data || {}
var DOM = myFragment(document.getElementById(id), this)
document.getElementById(id).appendChild(DOM)
}
function myFragment(node, vm) {
var frag = document.createDocumentFragment()
var child
while (child = node.firstChild) {
comp(child, vm)
frag.appendChild(child)
}
return frag
}
function comp(node, vm) {
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
console.log(name)
node.value = vm.data[name]
}
}
}
if (node.nodeType === 3) {
if (/{{(.*)}}/.test(node.nodeValue)) {
var name = RegExp.$1
console.log(name)
name = name.trim()
node.nodeValue = vm.data[name]
}
}
}
function observer(obj, vm) {
if (typeof obj !== 'object') { return }
Object.keys(obj).forEach(function (key) {
console.log(key)
calation(vm, obj, key, obj[key])
})
}
function calation(vm, obj, key, value) {
observer(value, vm)
}
return Vue
第一次M-V绑定,可以说是初始化,就是让input和Vue的实例对象里面传入的参数中的data联系起来,也就是‘’搭建起沟通的桥梁‘’
7.V-M绑定
用户输入改变input的值(V层)时,data中(M层)也改变对应的值
7.1关于defineProperty
终于到了江湖中流传的defineProperty了,这个api究竟是怎么用的,先举个小栗子
var obj = {name:'pp'}
console.log(obj.name)//pp
Object.defineProperty(obj,'name',{
get:function(){
return 1
},
set:function(newVal){
console.log(newVal)
}
})
console.log(obj.name)//1
obj.name = 2;//2
console.log(obj.name)//1
当访问这个属性的时候,调用的是get方法,这里输出1,当试图改变属性的值的时候,调用的是set方法,console这个值,也就是这里输出2的原因。再次回头访问,还是输出1。(我这里set方法只是console而已,再回头看obj.name当然还是1)
7.2小型双向绑定demo
html:
js:
document.getElementById('app').addEventListener('input',function(e){
document.getElementById('p').innerHTML=e.target.value;
})
回过头来,我们的vue也是要这样做的
7.3在带有属性v-model上添加事件监听
在comp函数里面,匹配到了v-model=‘text’ 这个属性时,取得v-model的属性的值text,Vue的实例对象vm的text属性的值,等于输入框更新的值。输入框输入什么,这个
data:{
text:"hello word",
message:{name:'pp'}
}
里面的 text就是什么,不再是helloworld了(前面数据监听的时候,有做过observer的递归,所以无论多少层嵌套对象,总会能彻底取得key-value的形式)
if(attr[i].nodeName == 'v-model'){
var name = attr[i].nodeValue
node.addEventListener('input',function(e){
vm[name]=e.target.value;//Vue的实例对象vm的text属性的值,赋值并触发该属性的set函数
});
接着,把输入框改变的值赋值node.value = vm[name],前面是node.value = vm.data[name]的初步尝试,让input和data关联起来,现在需要改
同理,文本节点那里也要改(为最后一步做铺垫,当然现在还是没有效果)
通过正则获得双大括号里面的值(text),定义一个name='text' ,从而能改变双大括号的值
node.nodeValue=vm[name];
7.4监听属性
再定义一个监听器defineReactive,在observer里面执行,用到了Object.defineProperty
function defineReactive(obj,key,val){
Object.defineProperty(obj,key,{
get:function(){
return val
},
set:function(newVal){
if(newVal===val)return ;
val=newVal;//数据在改变
console.log(val)
}
})
}
递归完成后就开始监听属性
function observer(obj,vm){
if(typeof obj!=='object'){return}
Object.keys(obj).forEach(function(key){
console.log(key)
calation(vm,obj,key,obj[key])
defineReactive(vm,key,obj[key])
})
}
现在,输入框写了什么,就console了什么
8.M-V再次绑定
这次是,当用户主动改变M层数据,V层也跟着改变,第一次是默认的,只是让他们建立起关联。(其实这就是鸡生蛋,蛋生鸡的过程,总得有一个开头吧,为什么不VMMV而是MVVM,也可以想到,难道一个软件需要用户设置初始值?那么真的需要用户设置初始值呢?那就第一次MV给他设置默认值为空,前面也有处理)
8.1初探发布-订阅模式
它是一种一对多的关系,让多个订阅者(也可以叫观察者)者对象同时监听某一个主题对象,当一个主题对象发生改变时,发布者将会发布变化的通知,所有依赖于它的对象都(订阅者)将得到通知。多个订阅者对象监视主题对象,当发生变化,就由发布者通知订阅者
//定义2个订阅者
var subscriber1 = {update:function(){console.log(1)}}
var subscriber2 = {update:function(){console.log(2)}}
var pub = {//定义发布者
publish:function(){
dep.notify()//主题对象的实例调用发布通知
}
}
function Dep(){//主题对象构造函数
this.subs=[ subscriber1, subscriber2]
}
Dep.prototype.notify = function(){//主题对象的原型上定义通知函数
this.subs.forEach(function(sub){//通知每一个订阅者并执行相应的方法
sub.update()
})
}
var dep = new Dep()//主题对象实例化
pub.publish()//发布者发布信息
最后控制台打印结果就是1,2
8.2监听器defineReactive中绑定主题对象与订阅者
data每一个属性被监听的时候添加一个主题对象,当data发生改变将触发Object.defineProperty里面的set方法,去通知订阅者们
function Dep(){
this.subs=[];//订阅者集合
}
Dep.prototype={
addSub:function(sub){//主题对象的原型上添加订阅者的方法
this.subs.push(sub);
},
notify:function(){ //发布信息
this.subs.forEach(function(sub){
sub.update();//订阅者的方法
})
}
}
在Object.defineProperty方法前面实例化Dep:var dep=new Dep();
那么sub.update()的订阅者方法呢,接下来将会解释
8.3订阅者的定义
观察主题对象(有v-model属性的input)变化,将变化展示到视图层(双大括号里面)
function Watcher(vm,node,name){
Dep.target=this;//Dep的静态属性target指向当前订阅者的实例
this.name=name;
this.node=node;
this.vm=vm;
this.update(); //先初始化视图
Dep.target=null;
}
Watcher.prototype={
get:function(){
this.value=this.vm[this.name]//得到实例对象的属性的值
},
update:function(){
this.get();
this.node.nodeValue=this.value;
}
}
再回到获得文本节点的时候(if(node.nodeType === 3))
在内部最后一句加上 new Watcher(vm,node,name); 实例化订阅者
8.4 监听器defineReactive的get与set
在comp方法中,通过初始化value值,触发set函数,在set函数中为主题对象添加订阅者。
在defineProperty的get方法中当某个订阅者存在,就添加订阅者
get:function(){
if(Dep.target){dep.addSub(Dep.target)}
return val
},
set方法改变了数据后,主题对象的实例发布通知
set:function(newVal){
if(newVal===val){return ;}
val=newVal;
console.log(val)
dep.notify();
}
9.大功告成
终于全部搞定了,上完整代码
html:
< div id="app" >
< input v-model="text" type="text" name="n" size="10" >
{{text}}