DDR爱好者之家 Design By 杰米

不知不觉接触前端的时间已经过去半年了,越来越发觉对知识的学习不应该只停留在会用的层面,这在我学jQuery的一段时间后便有这样的体会。

虽然jQuery只是一个JS的代码库,只要会一些JS的基本操作学习一两天就能很快掌握jQuery的基本语法并熟练使用,但是如果不了解jQUery库背后的实现原理,相信只要你一段时间不再使用jQuery的话就会把jQuery忘得一干二净,这也许就是知其然不知其所以然的后果。

最近在学vue的时候又再一次经历了这样的困惑,虽然能够比较熟练的掌握vue的基本使用,也能够对MV*模式、数据劫持、双向数据绑定、数据代理侃上两句。但是要是稍微深入一点就有点吃力了。所以这几天痛下决心研究大量技术文章(起初尝试看早期源码,无奈vue与jQuery不是一个层级的,相比于jQuery,vue是真正意义上的前端框架。只能无奈弃坑转而看技术博客),对vue也算有了一个管中窥豹的认识。最后尝试实践一下自己学到的知识,基于数据代理、数据劫持、模板解析、双向绑定实现了一个小型的vue框架。

温馨提示:文章是按照每个模块的实现依赖关系来进行分析的,但是在阅读的时候可以按照vue的执行顺序来分析,这样对初学者更加的友好。推荐的阅读顺序为:实现VMVM、数据代理、实现Observe、实现Complie、实现Watcher。

源码:https://github.com/yuliangbin/MVVM

功能演示如下所示:

vue实现简单的MVVM框架

数据代理

以下面这个模板为例,要替换的根元素“#mvvm-app”内只有一个文本节点#text,#text的内容为{{name}}。我们就以下面这个模板详细了解一下VUE框架的大体实现流程。

<body>
 <div id="mvvm-app">
  {{name}}
 </div>
 <script src="/UploadFiles/2021-04-02/observer.js">

数据代理

1、什么是数据代理

在vue里面,我们将数据写在data对象中。但是我们在访问data里的数据时,既可以通过vm.data.name访问,也可以通过vm.name访问。这就是数据代理:在一个对象中,可以动态的访问和设置另一个对象的属性。

2、实现原理

我们知道静态绑定(如vm.name = vm.data.name)可以一次性的将结果赋给变量,而使用Object.defineProperty()方法来绑定则可以通过set和get函数实现赋值的中间过程,从而实现数据的动态绑定。具体实现如下:

let obj = {};
let obj1 = {
 name: 'xiaoyu',
 age: 18,
}
//实现origin对象代理target对象
function proxyData(origin,target){
 Object.keys(target).forEach(function(key){
  Object.defineProperty(origin,key,{//定义origin对象的key属性
   enumerable: false,
   configurable: true,
   get: function getter(){
    return target[key];//origin[key] = target[key];
   },
   set: function setter(newValue){
    target[key] = newValue;
   }
  })
 })
}

vue中的数据代理也是通过这种方式来实现的。

function MVVM(options) {
 this.$options = options || {};
 var data = this._data = this.$options.data;
 var _this = this;//当前实例vm

 // 数据代理
 // 实现 vm._data.xxx -> vm.xxx 
 Object.keys(data).forEach(function(key) {
  _this._proxyData(key);
 });
 observe(data, this);
 this.$compile = new Compile(options.el || document.body, this);

}

MVVM.prototype = {
_proxyData: function(key) {
 var _this = this;
 if (typeof key == 'object' && !(key instanceof Array)){//这里只实现了对对象的监听,没有实现数组的
  this._proxyData(key);
 }
 Object.defineProperty(_this, key, {
  configurable: false,
  enumerable: true,
  get: function proxyGetter() {
   return _this._data[key];
  },
  set: function proxySetter(newVal) {
   _this._data[key] = newVal;
  }
 });
},
};

实现Observe

1、双向数据绑定

数据变动  --->  视图更新

视图更新  --->  数据变动

要想实现当数据变动时视图更新,首先要做的就是如何知道数据变动了,可以通过Object.defineProperty()函数监听data对象里的数据,当数据变动了就会触发set()方法。所以我们需要实现一个数据监听器Observe,来对数据对象中的所有属性进行监听,当某一属性数据发生变化时,拿到最新的数据通知绑定了该属性的订阅器,订阅器再执行相应的数据更新回调函数,从而实现视图的刷新。

当设置this.name = 'hello vue'时,就会执行set函数,通知订阅器里的订阅者执行相应的回调函数,实现数据变动,对应视图更新。

function observe(data){
 if (typeof data != 'object') {
  return ;
 }
 return new Observe(data);
}

function Observe(data){
 this.data = data;
 this.walk(data);
}

Observe.prototype = {
 walk: function(data){
  let _this = this;
  for (key in data) {
   if (data.hasOwnProperty(key)){
    let value = data[key];
    if (typeof value == 'object'){
     observe(value);
    }
    _this.defineReactive(data,key,data[key]);
   }
  }
 },
 defineReactive: function(data,key,value){
  Object.defineProperty(data,key,{
   enumerable: true,//可枚举
   configurable: false,//不能再define
   get: function(){
    console.log('你访问了' + key);return value;
   },
   set: function(newValue){
    console.log('你设置了' + key);
    if (newValue == value) return;
    value = newValue;
    observe(newValue);//监听新设置的值
   }
  })
 }
}

2、实现一个订阅器

要想通知订阅者,首先得要有一个订阅器(统一管理所有的订阅者)。为了方便管理,我们会为每一个data对象的属性都添加一个订阅器(new Dep)。

订阅器里存着的是订阅者Watcher(后面会讲到),由于订阅者可能会有多个,我们需要建立一个数组来维护。一旦数据变化,就会触发订阅器的notify()方法,订阅者就会调用自身的update方法实现视图更新。

function Dep(){
 this.subs = [];
}
Dep.prototype = {
 addSub: function(sub){this.subs.push(sub);
 },
 notify: function(){
  this.subs.forEach(function(sub) {
   sub.update();
  })
 }
}

每次响应属性的set()函数调用的时候,都会触发订阅器,所以代码补充完整。

Observe.prototype = {
 //省略的代码未作更改
 defineReactive: function(data,key,value){
  let dep = new Dep();//创建一个订阅器,会被闭包在key属性的get/set函数内,因此每个属性对应唯一一个订阅器dep实例
  Object.defineProperty(data,key,{
   enumerable: true,//可枚举
   configurable: false,//不能再define
   get: function(){
    console.log('你访问了' + key);
    return value;
   },
   set: function(newValue){
    console.log('你设置了' + key);
    if (newValue == value) return;
    value = newValue;
    observe(newValue);//监听新设置的值
    dep.notify();//通知所有的订阅者
   }
  })
 }
}

实现Complie

compile主要做的事情是解析模板指令,将模板中的data属性替换成data属性对应的值(比如将{{name}}替换成data.name值),然后初始化渲染页面视图,并且为每个data属性添加一个监听数据的订阅者(new Watcher),一旦数据有变动,收到通知,更新视图。

遍历解析需要替换的根元素el下的HTML标签必然会涉及到多次的DOM节点操作,因此不可避免的会引发页面的重排或重绘,为了提高性能和效率,我们把根元素el下的所有节点转换为文档碎片fragment进行解析编译操作,解析完成,再将fragment添加回原来的真实dom节点中。

注:文档碎片本身也是一个节点,但是当将该节点append进页面时,该节点标签作为根节点不会显示html文档中,其里面的子节点则可以完全显示。

Compile解析模板,将模板内的子元素#text添加进文档碎片节点fragment。

function Compile(el,vm){
 this.$vm = vm;//vm为当前实例
 this.$el = document.querySelector(el);//获得要解析的根元素 
 if (this.$el){
  this.$fragment = this.nodeToFragment(this.$el);
  this.init();
  this.$el.appendChild(this.$fragment);
 } 
}
Compile.prototype = {
 nodeToFragment: function(el){
  let fragment = document.createDocumentFragment();
  let child;
  while (child = el.firstChild){
   fragment.appendChild(child);//append相当于剪切的功能
  }
  return fragment;
  
 },
};

compileElement方法将遍历所有节点及其子节点,进行扫描解析编译,调用对应的指令渲染函数进行数据渲染,并调用对应的指令更新函数进行绑定,详看代码及注释说明:

因为我们的模板只含有一个文本节点#text,因此compileElement方法执行后会进入_this.compileText(node,reg.exec(node.textContent)[1]);//#text,'name'

Compile.prototype = {
 nodeToFragment: function(el){
  let fragment = document.createDocumentFragment();
  let child;
  while (child = el.firstChild){
   fragment.appendChild(child);//append相当于剪切的功能
  }
  return fragment;
  
 },
 
 init: function(){
  this.compileElement(this.$fragment);
 },
 
 compileElement: function(node){
  let childNodes = node.childNodes;
  const _this = this;
  let reg = /\{\{(.*)\}\}/g;
  [].slice.call(childNodes).forEach(function(node){
   
   if (_this.isElementNode(node)){//如果为元素节点,则进行相应操作
    _this.compile(node);
   } else if (_this.isTextNode(node) && reg.test(node.textContent)){
    //如果为文本节点,并且包含data属性(如{{name}}),则进行相应操作
    _this.compileText(node,reg.exec(node.textContent)[1]);//#text,'name'
   }
   
   if (node.childNodes && node.childNodes.length){
    //如果节点内还有子节点,则递归继续解析节点
    _this.compileElement(node);
    
   }
  })
 },
 compileText: function(node,exp){//#text,'name'
   compileUtil.text(node,this.$vm,exp);//#text,vm,'name'
 },};

CompileText()函数实现初始化渲染页面视图(将data.name的值通过#text.textContent = data.name显示在页面上),并且为每个DOM节点添加一个监听数据的订阅者(这里是为#text节点新增一个Wather)。

let updater = {
 textUpdater: function(node,value){ 
  node.textContent = typeof value == 'undefined' "htmlcode">
//Watcher
function Watcher(vm, exp, cb) {
 this.vm = vm;
 this.cb = cb;
 this.exp = exp;
 this.value = this.get();//初始化时将自己添加进订阅器
};

Watcher.prototype = {
 update: function(){
  this.run();
 },
 run: function(){
  const value = this.vm[this.exp];
  //console.log('me:'+value);
  if (value != this.value){
   this.value = value;
   this.cb.call(this.vm,value);
  }
 },
 get: function() { 
  Dep.target = this; // 缓存自己
  var value = this.vm[this.exp] // 访问自己,执行defineProperty里的get函数   
  Dep.target = null; // 释放自己
  return value;
 }
}

//这里列出Observe和Dep,方便理解
Observe.prototype = {
 defineReactive: function(data,key,value){
  let dep = new Dep();
  Object.defineProperty(data,key,{
   enumerable: true,//可枚举
   configurable: false,//不能再define
   get: function(){
    console.log('你访问了' + key);
    //说明这是实例化Watcher时引起的,则添加进订阅器
    if (Dep.target){
     //console.log('访问了Dep.target');
     dep.addSub(Dep.target);
    }
    return value;
   },
  })
 }
}

Dep.prototype = {
 addSub: function(sub){this.subs.push(sub);
 },
}

我们知道在Observe()函数执行时,我们为每个属性都添加了一个订阅器dep,而这个dep被闭包在属性的get/set函数内。所以,我们可以在实例化Watcher时调用this.get()函数访问data.name属性,这会触发defineProperty()函数内的get函数,get方法执行的时候,就会在属性的订阅器dep添加当前watcher实例,从而在属性值有变化的时候,watcher实例就能收到更新通知。

那么Watcher()函数中的get()函数内Dep.taeger = this又有什么特殊的含义呢?我们希望的是在实例化Watcher时将相应的Watcher实例添加一次进dep订阅器即可,而不希望在以后每次访问data.name属性时都加入一次dep订阅器。所以我们在实例化执行this.get()函数时用Dep.target = this来标识当前Watcher实例,当添加进dep订阅器后设置Dep.target=null。

实现VMVM

MVVM作为数据绑定的入口,整合Observer、Compile和Watcher三者,通过Observer来监听自己的model数据变化,通过Compile来解析编译模板指令,最终利用Watcher搭起Observer和Compile之间的通信桥梁,达到数据变化 -> 视图更新;视图交互变化(input) -> 数据model变更的双向绑定效果。

function MVVM(options) {
 this.$options = options || {};
 var data = this._data = this.$options.data;
 var _this = this;
 // 数据代理
 // 实现 vm._data.xxx -> vm.xxx 
 Object.keys(data).forEach(function(key) {
  _this._proxyData(key);
 });
 observe(data, this);
 this.$compile = new Compile(options.el || document.body, this);
}

DDR爱好者之家 Design By 杰米
广告合作:本站广告合作请联系QQ:858582 申请时备注:广告合作(否则不回)
免责声明:本站资源来自互联网收集,仅供用于学习和交流,请遵循相关法律法规,本站一切资源不代表本站立场,如有侵权、后门、不妥请联系本站删除!
DDR爱好者之家 Design By 杰米

稳了!魔兽国服回归的3条重磅消息!官宣时间再确认!

昨天有一位朋友在大神群里分享,自己亚服账号被封号之后居然弹出了国服的封号信息对话框。

这里面让他访问的是一个国服的战网网址,com.cn和后面的zh都非常明白地表明这就是国服战网。

而他在复制这个网址并且进行登录之后,确实是网易的网址,也就是我们熟悉的停服之后国服发布的暴雪游戏产品运营到期开放退款的说明。这是一件比较奇怪的事情,因为以前都没有出现这样的情况,现在突然提示跳转到国服战网的网址,是不是说明了简体中文客户端已经开始进行更新了呢?