现代前端技术解析(3)详解架构师

MVVM数据检测

手动触发指令绑定是比较直接的实现方式,主要思路是通过在数据对象上定义get()方法和set()方法,调用时手动触发get()或set()函数来获取、修改数据,改变数据后会主动触发get()和set()函数中View层的重新渲染功能。我们来看一个栗子

   <input q-value="value" type="text" id="input"> 
   <span q-text="value" id="el"></span>
   let elems = [document.getElementById('el'), document.getElementById('input')]; 
   let data = { 
      value: 'hello' 
   }; 
   //定义Directive指令 
   let directive = { 
      text: function(text){ 
          this.innerHTML = text; 
      }, 
      value: function(value){ 
          this.setAttribute('value', value); 
      } 
   } 
   //数据绑定监听 
   if(document.addEventListener){ 
      elems[1].addEventListener('keyup', function(e){ 
          ViewModelSet('value', e.target.value); 
      }, false); 
   } else { 
      elems[1].attachEvent('onkeyup', function(e){ 
          ViewModelSet('value', e.target.value); 
      }, false); 
   } 
   //开始扫描节点 
   scan(); 
   //模拟一次用户操作,设置页面2秒后自动改变数据更新视图 
   setTimeout(function(){ 
       ViewModelSet('value', 'hello sysuzhyupeng'); 
   }, 1000) 
   function scan(){ 
       //扫描带指令的节点属性(for of是ES6中用来方便遍历数组的) 
       for(let elem of elems){ 
           elem.directive = {}; 
           for(let attr of elem.attributes){ 
               //判断是否存在q-的指令 
               if(attr.nodeName.indexOf('q-') >= 0){ 
                 //调用属性指定 
                 directive[attr.nodeName.slice(2)].call(elem, data[attr.nodeValue]); 
                 elem.directive.push(attr.nodeName.slice(2)); 
               } 
           } 
       } 
   } 
   //设置数据改变之后扫描节点 
   function ViewModelSet(key, value){ 
      data[key] = value; 
      scan(); 
   }

通过浏览器加载这个脚本之后,ViewModel的变化会自动改变输入框的内容,输入的内容的变化也会驱动ViewModel的变化。我们通过ViewModelSet() 方法改变ViewModel的数据后,需要主动调用scan()方法重新扫描HTML页面上的节点。

脏检测机制

脏检测的基本原理是在ViewModel对象的某个属性值发生变化时,找到这个属性值相关的所有元素,然后再比较数据变化,如果变化则进行Directive调用,举个栗子

   <input qg-event="value" qg-bind="value" type="text" id="input"> 
   <span qg-event="text" qg-bind="value" id="el"></span>
   let elems = [document.getElementById('el'), document.getElementById('input')]; 
   let data = { 
      value: 'hello' 
   }; 
   //定义Directive指令 
   let directive = { 
      text: function(text){ 
          this.innerHTML = text; 
      }, 
      value: function(value){ 
          this.setAttribute('value', value); 
      } 
   }; 
   scan(elems); 
   $digest('value'); 
   //数据绑定监听 
   if(document.addEventListener){ 
      elems[1].addEventListener('keyup', function(e){ 
          //改变model的值 
          data.value = e.target.value; 
          //$digest value这个字段 
          $digest(e.target.getAttribute('qg-bind')); 
      }, false); 
   } else { 
      elems[1].attachEvent('onkeyup', function(e){ 
          data.value = e.target.value; 
          $digest(e.target.getAttribute('qg-bind')); 
      }, false); 
   } 
   //模拟一次用户操作 
   setTimeout(function(){ 
       data.value = 'hello sysuzhyupeng'; 
       //执行$digest方法启动脏检测 
       $digest('value'); 
   }, 1000) 
   function scan(){ 
       //扫描带指令的节点属性 
       for(let elem of elmes){ 
           elem.directive = []; 
       } 
   } 
   //可以理解为数据劫持监听 
   function $digest(value){ 
      //找到所有绑定相同数据字段的元素 
      let list = document.querySelector('[qg-bind=' + value + ']'); 
      digest(list); 
   } 
   //脏数据循环检测 
   function digest(elems){ 
      //扫描带指令的节点属性 
      for(let elem of elmes){ 
         for(let attr of elem.attributes){ 
             if(attr.nodeName.indexOf('qg-event') >= 0){ 
                 //调用属性指令 
                 let dataKey = elem.getAttribute('qg-bind') || undefined; 
                 //进行脏数据检测,如果数据改变,则重新执行指令,否则跳过 
                 if(elem.directive[attr.nodeValue] !== data[dataKey]){ 
                    directive[attr.nodeValue].call(elem, data[dataKey]); 
                    elem.directive[attr.nodeValue] = data[dataKey]; 
                 } 
             } 
         } 
      } 
   }

这里和手动绑定不同的是,脏检测只针对可能修改的元素进行扫描,这样就提高了ViewModel内容变化后扫描视图渲染的效率

前端数据对象劫持(Hijacking)

数据劫持是目前使用比较广泛的方式,其基本思路是使用Object.defineProperty和Object.difineProperties对ViewModel数据对象进行get和set的监听,当有数据读取和赋值操作的时候则扫描元素节点,运行指定对应节点的Directive指令,这样ViewModel使用通用的等号赋值就可以了。举个栗子

   <input q-value="value" type="text" id="input"> 
   <div q-text="value" id="el"></div>
   let elems = [document.getElementById('el'), document.getElementById('input')]; 
   let data = { 
      value: 'hello' 
   }; 
   //定义Directive指令 
   let directive = { 
      text: function(text){ 
          this.innerHTML = text; 
      }, 
      value: function(value){ 
          this.setAttribute('value', value); 
      } 
   } 
   let bValue; 
   scan(); 
   //可以理解为数据劫持监听 
   defineGetAndSet(data, 'value'); 
   //数据绑定监听 
   if(document.addEventListener){ 
      elems[1].addEventListener('keyup', function(e){ 
          //直接改变model 
          data.value = e.target.value; 
      }, false); 
   } else { 
      elems[1].attachEvent('onkeyup', function(e){ 
          data.value = e.target.value; 
      }, false); 
   } 
   setTimeout(function(){ 
      data.value = 'hello sysuzhyupeng'; 
   }, 2000); 
   function scan(){ 
      //扫描带指令的节点属性 
      for(let elem of elems){ 
          elem.directive = {}; 
          for(let attr of elem.attributes){ 
               //判断是否存在q-的指令 
               if(attr.nodeName.indexOf('q-') >= 0){ 
                 //调用属性指定 
                 directive[attr.nodeName.slice(2)].call(elem, data[attr.nodeValue]); 
                 elem.directive.push(attr.nodeName.slice(2)); 
               } 
           } 
      } 
      //定义对象属性设置劫持 
      function defineGetAndSet(obj, propName){ 
         Object.defineProperty(obj, propName, { 
             get: function(){ 
                return bValue; 
             }, 
             set: function(newValue){ 
                bValue = newValue; 
                //拦截正常的set,变成更新视图 
                scan(); 
             }, 
             enumerable: true, 
             configurable: true 
         }) 
      } 
   }

需要注意的是,defineProperty只支持IE8以上和Chrome浏览器,且IE8浏览器中需要使用es5-shim来提供支持。Firefox浏览器不支持该方法,需要使用_ difineGetter_和_ difineSetter_来代替。关于对象劫持可以参考我另一篇博客 Vue 双向数据绑定原理

Virtual DOM

MVVM的前端交互模式大大提高了编程效率,自动双向数据绑定让我们可以页面逻辑实现的核心转移到数据层的修改操作上,而不是在页面中直接操作DOM。但是MVVM最终数据层反应到页面上View层的渲染和改变仍是通过对应的指令进行DOM操作来完成的,而且通常一次ViewModel的变化可能会触发页面上多个指令操作DOM的变化,带来大量的页面结构层DOM操作或渲染,先来看下面这个应用场景

   <ul id="root"> 
      <li q-repeat="list"> 
         <span q-text="value"></span> 
         <span>固定文本</span> 
      </li> 
   </ul>
   let viewModel = new VM({ 
       $el: document.getElementById('root'), 
       data: { 
           list: [{value: 1}, {value: 2}, {value: 3}] 
       } 
   });

使用MVVM框架时就生成了一个数字列表,此时如果需要显示的内容变成了list: [{value: 0}{value: 1}, {value: 2}, {value: 3}] 在没有做优化的MVVM框架中一般会重新渲染整个列表。那么该怎样将这个增加的数据反映到View层上呢?我们其实可以把新的Model data和旧的Model data进行对比,然后记录ViewModel的改变方式和位置,就知道这次View层怎样去更新
如果用js对象的属性层级结构来描述HTML DOM对象树的结构,在渲染前面对比前后两个js对象,就能找出视图改变的最小操作描述的对象。这里的HTML DOM对象树可以理解为Virtual DOM。
先总结一下使用Virtual DOM模式来控制页面DOM结构更新的过程:创建原始页面或组件的Virtual DOM结构,用户操作进行DOM更新时,生成用户操作后页面或组件的Virtual DOM结构并与之前的结构进行对比,找到最小变化的Virtual DOM的差异化描述对象,最后把差异化的Virtual DOM根据特定规则渲染到页面上。
创建Virtual DOM即把一段HTML字符串文本解析成一个能够描述它的js对象。我们很自然地想,通过浏览器提供的DOM API扫描这段DOM的节点,遍历它的属性,然后添加到js对象上即可。这似乎很合理,但其实这样是错的,这样创建Virtual DOM会直接失去Virtual DOM的优势,它是为了避免直接进行DOM操作而设计的,因为扫描过程本身使用到DOM的读取操作,这个过程很慢。所以一种更可选的方式是,自己实现上述这段HTML字符串文本的解析方式,根据标签之间的关系,读取生成Virtual DOM的结构。

   let htmlString = '<ul id="root" class="list"> 
       <li> 
         <span>1 
         <span>固定文本 
       </li> 
       <li> 
         <span>2 
         <span>固定文本 
       </li> 
       <li> 
         <span>3 
         <span>固定文本 
       </li> 
   </ul>;' 
   let ulElement = createVDOM(htmlString);

那么createVDOM就可以如下实现:逐个分析字符串中的字符,根据词法分析内容,将标签名存为tagName,属性存入attributes,子标签内容存入chidren。根据HTML字符串解析创建Virtual DOM的过程相当于实现了一个HTML文本解析器。那么在对比Virtual DOM的算法实际上是对于多叉树结构的遍历算法,对多叉树遍历就有广度优先算法和深度优先算法。我们通过Virtual DOM的方式,有效减少了DOM操作。

原创文章,作者:奋斗,如若转载,请注明出处:https://blog.ytso.com/tech/architecture/6918.html

(0)
上一篇 2021年7月17日 01:44
下一篇 2021年7月17日 01:44

相关推荐

发表回复

登录后才能评论