学习Vue源码(二)真实DOM与虚拟DOM的转换,函数柯里化


一、将渲染出的DOM转换为虚拟DOM

Vue为了提示性能而引入了VNode,即虚拟DOM,简单来说,虚拟DOM是一个记录着真实DOM信息的对象,VNode存储在内存中,因为js操作内存的速度远比浏览器渲染刷新真实DOM的时间来的快,因此Vue用VNode来记录页面的变化并映射DOM,最后再反映到页面上。

接下来实现将DOM转换为VNode的功能:

//再次强调,因为VNode是一个对象,因此问题就在于DOM中的数据该怎么在对象中存?
//这里有以下策略:
// div(标签) -> {tag:'div'}
// class="app"(属性) -> {class:'app'}
// 文本 -> {value:''}
// 不存在的 -> undifined

代码如下:

//定义VNode类
class VNode{
  constructor(type, tag, data, value) {
    this.type = type;
    this.tag = tag && tag.toLowerCase(); //防止文本结点没有toLowerCase()方法报错
    this.data = data;
    this.value = value;
    this.children = []; //定义子结点
  }

  appendChild( vnode ){
    this.children.push( vnode );
  }
}

//DOM -> VNode function
function createVNode( node ) {
  let type = node.nodeType;
  let _vnode = null;
  if( type === 1){  //元素结点
    let attrs = node.attributes;
    let tag = node.nodeName;
    let _data = {};
    for(let i = 0; i < attrs.length; i++){  //nodeType=2,表示属性结点
      _data[attrs[i].nodeName] = attrs[i].nodeValue;
    }
    _vnode = new VNode(type, tag, _data, undefined);
    let childNodes = node.childNodes;
    childNodes.forEach(cnode => _vnode.appendChild( createVNode(cnode) ));
  }
  else if( type === 3){ //文本结点
    _vnode = new VNode(type, undefined, undefined, node.nodeValue);
  }
  return _vnode;
}

在这里插入图片描述
HTML结构还是(一)中的结构,可以看到真实DOM被转换成了一个对象,在children中有好几个undefined,那是因为HTML中的换行也被解析了。


二、将虚拟DOM转换为真实DOM

方法是一样的,直接看代码:

//VNode -> DOM function
function parseVNode( vnode ) {
  let type = vnode.type;
  let _node = null;
  if( type === 1){
    let data = vnode.data;
    let tag = vnode.tag;
    let children = vnode.children;
    _node = document.createElement(tag);
    Object.keys(data).forEach( key => {
      let attrName = key;
      let attrValue = data[key];
      _node.setAttribute(attrName, attrValue);
    })
    children.forEach( subvnode => {
      _node.appendChild( parseVNode( subvnode ) ); 
    })
  }
  else if(type === 3){
    return document.createTextNode(vnode.value);
  }
  return _node;
}

三、函数柯里化

什么是函数柯里化呢?简单来说,函数柯里化的基本形式是将一个本来接受多个参数的函数拆解为一个多层函数,在外层接收一个参数,然后在内层接收剩下的参数,并返回内层函数。即将具有多个参数的函数转换为一个单参数的函数链,这就是柯里化。
这样做有什么好处呢?函数柯里化一个显著的好处就是它能够缓存一部分数据,从而提升代码性能。在Vue中也有许多用到函数柯里化的地方:

(1)区分模板标签

我们知道,在Vue中,我们除了使用HTML标签之外,我们还可以使用自定义的组件标签,那么Vue是怎么区分这些标签的呢?
最简单的想法是循环判断:

let tags = "div,h1,h2,h3,h4,h5,h6,p,a,img".split(','); //假设有这么多的html标签
function isHTMLTag( tagName ) {
  tagName = tagName.toLowerCase();
  if( tags.indexOf( tagName ) > -1 ) return true; //如果tags中包含tagName,返回true
  return false;
}

这里我没有直接用for循环,而是用了indexOf,但是indexOf函数的内部也是使用for循环的,也就是说,我们每次判断一个模板标签,就要循环一次。而模板中的标签一般又非常多,所有的HTML标签也非常多,循环下来,性能也就大打折扣了。

因此,这种循环判断html标签的方法,并不好,现在来看看用函数柯里化怎么去解决这个问题:

let tags = "div,h1,h2,h3,h4,h5,h6,p,a,img".split(',');
function makeMap( keys ) {
  let set = {};
  tags.forEach( key => {
    set[key] = true;
  } );
  return function isHTMLTag( tagName ) {
    return !!set[tagName.toLowerCase()];
  }
}

set是一个集合,它一开始将所有的html标签设置为true并将set缓存了下来,之后返回一个函数isHTMLTag,当参数是html标签时,它返回true,不是时,返回 undefined的双重取反,即false。

这样,就把一个原先O(n)的操作降到了O(1),大大提高了性能,并且由于函数柯里化的缘故,set会被缓存下来,不需要每次调用isHTMLTag时再重新生成。

这时可能有人会问,我将set直接设置为全局变量不也行么?为什么还非要用函数柯里化,我们可以在Vue源码中看一看:
在这里插入图片描述
在这里插入图片描述
可以看到,在源码中也有大量使用makeMap来判断的用法,这不仅只限于判断HTML标签,因此也就是说,set不是唯一的,它针对不同的用法有不同的内容,因此直接在全局写死不是一个好办法,而使用函数柯里化的策略,就可以做到各个判断函数之间正常工作,互不干扰。


(2)虚拟DOM的render()方法

#1、缓存AST

在Vue中,模板被解析为DOM要经历以下几步:
(1)模板 >>> AST
(2)AST >>> VNode
(3)VNode >>> DOM

在这三步之中,第(1)步是最消耗性能的,第(2)、(3)步只是一个转换,我们在最上面也写过了它们的简化版代码,而第一步涉及到字符串的解析等,它是最消耗性能的。

那么类似第(1)步这种操作,会被执行多少次呢?
首先,项目一开始运行时会被执行一次,之后因为Vue的响应式特性,每当有数据更改,或者计算属性,或者监听器监听到数据变化时,该步骤都应该被执行。那么如此频繁的执行这一步骤,显然Vue的性能就会大打折扣。

(一)中的我们自己写的简化代码里,render()函数调用compiler()函数,而compiler()函数将结合模板和数据生成一个新的DOM并在页面上刷新,这是一个极其简化的版本,在Vue中,它还需要经历模板 >>> AST >>> VNode >>> DOM这条路径。

但是正如我们刚才说的,从模板到AST这一步骤是非常损耗性能的,那么我们为什么还要一次次的去生成它呢?因为我们的模板在运行过程中是不会变的,那么也就是说AST也是不会变的,只有数据会变化,那么我们何不将AST缓存下来,每当数据更改时,我们只需要提供数据,之后根据AST和数据生成VNode,而不是将AST再生成一遍重新提供,从而提高了效率。

但是,这里我们为了简化代码,暂且将VNode降级理解为AST,即把 AST+Data = VNode 这一过程用 带“坑”的VNode + Data = VNode 来模拟。因为AST与VNode是一一对应的关系,我们可以写一个函数,它在函数体内缓存AST,并返回一个新函数,该函数接收参数data,它负责结合AST与data生成VNode。


#2、Vue的设计结构

先看一张图:
在这里插入图片描述
怎么理解呢?首先,页面上的HTML,即真正的DOM,它有一个与之一一对应的VNode,打开我们的Vue项目,输出VNode,可以看到:
在这里插入图片描述
每个结点的VNode都有一个elm属性,该属性正是与之对应的真实DOM。

而我们知道,AST与Data可以合成VNode,我们希望缓存AST,然后一旦发生数据改动,即生成VNode,这里需要注意,这里新生成的VNode并不是和页面上的真实DOM绑定的VNode,在Vue内部,它是将新VNode与旧VNode比较,哪里不同的话去修改旧VNode,而不是直接将新的VNode整个替换上去。

这里注意,为什么不直接替换,而是比较更新呢?如果直接替换的话,旧VNode与真实DOM的一一对应的关系就需要重新加到新VNode上,这并不是一个划算的工作,因此比较更新相对来说要更好一些。

比较新旧VNode的算法是diff算法。

#3、代码实现

这里我们将之前所分析的统统用代码实现出来,如下:

//首先还是定义LnVue的构造函数
function LnVue( options ) {
  this._el = options.el;
  this._data = options.data;
  this._template = document.querySelector(this._el);

  this.mount() //挂载
}

LnVue.prototype.mount = function () {
  //提供一个render方法,生成虚拟DOM
  this.render = this.createRenderFn();
  this.mountComponent();
}

这里我们实现一个mount()方法,它主要作用是挂载VNode。

LnVue.prototype.mountComponent = function () {
  let mount = () => {
    this.update(this.render());
  }
  mount.call(this);
}

//将虚拟Dom渲染到页面上:包含Diff算法
LnVue.prototype.update = function () {

}

//本节重点
LnVue.prototype.createRenderFn = function () {
  /** 将AST与data合成生成VNode
  *  这里用带“坑”的VNode来模拟AST,即
     带“坑”的VNode + data = VNode */
  let ast = createVNode( this._template );
  return function render() {
    //将带坑的VNode转换为带数据的VNode(模拟)
    //真实Vue中render:AST+Data=VNode
    return combine(ast ,this._data);;
  }
}
//combine()函数 
function combine(vnode, data){
  //将带“坑”的VNode与Data合成带数据的VNode
  let _type = vnode.type;
  let _data = vnode.data;
  let _value = vnode.value;
  let _tag = vnode.tag;
  let _childern = vnode.children;

  let _vnode = null;

  if(_type === 3){
    let regex_hks = /\{\{(.+?)\}\}/g;
    _value = _value.replace(regex_hks , function ( _ ,g) {
      let path = g.trim();
      return getDatabyPath( data, path);
    })
    _vnode = new VNode(_type, _tag, _data, _value);
  }
  else if(_type === 1){
    _vnode = new VNode(_type, _tag, _data, _value);
    _childern.forEach( _subVnode => {
      _vnode.appendChild(combine(_subVnode, data));
    })
  }
  return _vnode;
}

暂时就是这样了,update和mount的内容就留待下次,这里主要实现了render()函数的简化版本。

全部代码:

<div id="root" class="app">
  <div>
    <div>
      <p>{{name}},{{message}}</p>
      <p>{{profile.name}},{{profile.grade}}</p>
      <p>{{profile.name}}的朋友:{{profile.friends.name}},{{profile.friends.grade}}</p>
      <p>{{profile.name}}的成绩:{{profile.score[0]}},{{profile.score[1]}},{{profile.score[2]}}</p>
    </div>
  </div>
</div>

<script>
  //层级路径拆解
  function getDatabyPath(data, path ) {
    let paths = path.trim().split(/\.|\[|\]/); //分割层级路径以及带有[]的路径
    let res = data;
    let prop;
    while( prop = paths.shift() ){ //从队首依次弹出数据
      if(prop === '') continue; //解决分割中出现 '' 的问题(分割[?]时会出现该问题)
      res = res[prop];
    }
    return res;
  }
  //定义VNode类
  class VNode{
    constructor(type, tag, data, value) {
      this.type = type;
      this.tag = tag && tag.toLowerCase(); //防止文本结点没有toLowerCase()方法报错
      this.data = data;
      this.value = value;
      this.children = []; //定义子结点
    }

    appendChild( vnode ){
      this.children.push( vnode );
    }
  }

  //DOM -> VNode function
  function createVNode( node ) {
    let type = node.nodeType;
    let _vnode = null;
    if( type === 1){  //元素结点
      let attrs = node.attributes;
      let tag = node.nodeName;
      let _data = {};
      for(let i = 0; i < attrs.length; i++){  //nodeType=2,表示属性结点
        _data[attrs[i].nodeName] = attrs[i].nodeValue;
      }
      _vnode = new VNode(type, tag, _data, undefined);
      let childNodes = node.childNodes;
      childNodes.forEach(cnode => _vnode.appendChild( createVNode(cnode) ));
    }
    else if( type === 3){ //文本结点
      _vnode = new VNode(type, undefined, undefined, node.nodeValue);
    }
    return _vnode;
  }

  function LnVue( options ) {
    this._el = options.el;
    this._data = options.data;
    this._template = document.querySelector(this._el);

    this.mount() //挂载
  }

  LnVue.prototype.mount = function () {
    //提供一个render方法,生成虚拟DOM
    this.render = this.createRenderFn();
    this.mountComponent();
  }

  LnVue.prototype.mountComponent = function () {
    let mount = () => {
      this.update(this.render());
    }
    mount.call(this);
  }

  function combine(vnode, data){
    //将带“坑”的VNode与Data合成带数据的VNode
    let _type = vnode.type;
    let _data = vnode.data;
    let _value = vnode.value;
    let _tag = vnode.tag;
    let _childern = vnode.children;

    let _vnode = null;

    if(_type === 3){
      let regex_hks = /\{\{(.+?)\}\}/g;
      _value = _value.replace(regex_hks , function ( _ ,g) {
        let path = g.trim();
        return getDatabyPath( data, path);
      })
      _vnode = new VNode(_type, _tag, _data, _value);
    }
    else if(_type === 1){
      _vnode = new VNode(_type, _tag, _data, _value);
      _childern.forEach( _subVnode => {
        _vnode.appendChild(combine(_subVnode, data));
      })
    }
    return _vnode;
  }

  LnVue.prototype.createRenderFn = function () {
    /** 将AST与data合成生成VNode
    *  这里用带“坑”的VNode来模拟AST
       带“坑”的VNode + data = VNode */
    let ast = createVNode( this._template );
    return function render() {
      //将带坑的VNode转换为带数据的VNode(模拟)
      //真实Vue中render:AST+Data=VNode
      return combine(ast ,this._data);;
    }
  }

  //将虚拟Dom渲染到页面上:Diff算法
  LnVue.prototype.update = function () {

  }

  let vm = new LnVue({
    el:'#root',
    data:{
      name:'小红帽',
      message:'喜欢采蘑菇',
      profile:{
        name:'大灰狼',
        grade:'三年级',
        friends:{
          name:'小红帽',
          grade:'二年级'
        },
        score:[98,89,92]
      }
    }
  })

</script>
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值