阅读 90

Vue演变史 —— 2.0(原理刨析、代码实现)

阅读这篇文章之前建议大家跟先阅读我之前的文章Vue1.0原理刨析、代码实现,从根本上对比Vue1.0和Vue2.0的区别,以及各方面优化。

一些对比

一句话清楚Vue1.0和2.0的区别

本质区别就是:Watcher内部update函数作用不一样。

  • 1.0 update:调用函数直接更新dom。(这篇Vue1.0原理刨析、代码实现有明确讲解。)
  • 2.0 update:初始化时:将虚拟dom转化成真实dom并添加到页面当中。更新时:将oldVnode与newVnode进行diff算法,完成更新。

Vue1.0的劣势与Vue2.0的优势

比如我代码是这样的:

<template>
    <p>{{bar}}</p>
    <p>{{foo}}</p>
    <p>{{bar}}</p>
</template>

new Vue({
    data(){
        bar:"bar",
        foo:"foo"
    }
})
复制代码

在Vue1.0中:当前组件就会生成3个Watcher

核心就是将每个节点对应的update函数通过闭包保存下来,set的时候通过dep.notify()内部调用Watcher内部的更新函数执行更新。(这一点在开始提到的Vue1.0原理刨析、代码实现可以看到)

这样的更新可以说很精确,但是你想想项目大的时候,这么多闭包,不崩才怪。所以我之前也听过Vue只适合做小项目,不适合做大项目就是因为这个原因。

在Vue2.0中:这个组件只会创建一个Watcher

核心就是初始化之后将当前组件的虚拟dom保存下来,set的时候通过dep.notify()内部调用Watcher执行更新,但是Watcher调用内部更新函数执行更新的时候,更新函数会将newVnodeoldVnode通过diff算法进行对比,以实现精确更新。

从字面意思我们也可以知道Vue2.0的性能更更更更更强,准确性也完全不输Vue1.0。

虚拟Dom的优势

  1. 可以很方便实现跨平台,uni-app就是由虚拟Dom衍生出来的技术。
  2. 很轻量,虚拟Dom名字高大上,其实就是一个对象。

代码实现Vue2.0

我们Vue2.0的实现分如下几步:

  • 接收用户参数
  • 响应式处理
  • 代理
  • 依赖收集的俩个关键类:Watcher、Dep
  • 虚拟dom的初始化以及diff算法更新dom

前期准备

一个html文件,一个js文件即可

写代码之前,我们首先要明白的一点,我们平时在vue中写temllate的意义最终都是为了生成render函数,如果有render,最终会以render为准。将不再解析template

// 在index.html文件中
<div id="app">
</div>

<script>
    const app = new Vue({
        el: "#app",
        data: {
            count: 1
        },
        render(h) {
            return h("div", { class: "box" }, [
                h("p", null, this.count + ""),
                h("p", null, this.count + ""),
                h("p", null, this.count + ""),
                h("p", null, this.count + ""),
            ]);

        }
    })
    setInterval(() => {
        app.count++
    }, 1000)
</script>
复制代码
// 在Vue.js文件中
class Vue{
    ...
}
复制代码

1.接收用户参数

options就是用户传入的参数,也就是new Vue({})时传入构造函数的参数。

代码实现:

class Vue {
  constructor(options) {
    // 1.接收用户参数
    this.$options = options;
    this.$data = options.data;
    
    // 2.响应式处理
    
    // 3.代理 
    
    // 4.虚拟dom的初始化以及更新
  }
}

复制代码

2.响应式处理

Vue是典型的MVVM框架,当我们this.count = xxx时,页面会重新渲染,而想要实现这一功能,响应式处理是必不可少的一步,也可以叫做属性拦截。

这一步的核心就是利用Object.defineProperty()这个api来实现。

代码实现:

class Vue {
  constructor(options) {
    // 1.接收用户参数
    this.$options = options;
    this.$data = options.data;

    // 2.响应式处理
    observe(this.$data);

    // 3.代理

    // 4.虚拟dom的初始化以及更新
  }
}

function observe(obj) {
  if (Array.isArray(obj)) {
    // TODO 数组的响应式处理(本文暂时只做对象的响应式处理)
  } else if (typeof obj === "object") {
    Object.keys(obj).forEach((key) => {
      defineReactive(obj, key, obj[key]);
    });
  }
}
function defineReactive(obj, key, value) {
  Object.defineProperty(obj, key, {
    get() {
      console.log("get", key);// 如果访问这个属性控制到会输出
      return value;
    },
    set(newVal) {
      if (newVal !== value) {
        console.log("set", key);// 如果设置这个属性控制台会输出
        value = newVal;
      }
    },
  });
}
复制代码

3.代理

我们可以通过app.xxx访问到data中的某个属性,这就是因为代理的作用。

const app = new Vue({
    data:{
        count:1
    }
    
})
console.log(app.count)//1
复制代码

代码实现:

class Vue {
  constructor(options) {
    // 1.接收用户参数
    this.$options = options;
    this.$data = options.data;

    // 2.响应式处理
    observe(this.$data);

    // 3.代理
    proxy(this);

    // 4.虚拟dom的初始化以及更新
  }
}

function observe(obj) {...
}
function defineReactive(obj, key, value) {...
}

function proxy(instance) {
  Object.keys(instance.$data).forEach((key) => {
    defineReactive(instance, key, instance.$data[key]);
  });
}
复制代码

到这一步,你就可以访问app.count,而不是app.$data.count

4.实现Dep、Watcher

DepWatcher作为依赖收集的核心类,我们这里先将他实现,后续在挂载、更新的时候,会详细讲到如何进行依赖收集,我们先将准备工作做好。

代码实现

// Dep
class Dep {
  constructor() {
    this.deps = new Set();// 存储Watcher的数组
  }
  
  // 添加Watcher的方法
  addDep(watcher) {
    this.deps.add(watcher);
  }
  
  // 通知Watcher更新方法
  notify() {
    this.deps.forEach((fn) => {
      fn && fn.update();
    });
  }
}

// Watcher
class Watcher {
  constructor(vm, fn) {
    this.$vm = vm; // Vue实例
    this.$fn = fn; // 更新方法(1.0和2.0的主要区别就在这)
    this.getter(); 
  }
  
  
  getter() {
    // 触发依赖收集
    Dep.target = this;
    this.$fn.call(this.$vm);
    Dep.target = null;
  }

  update() {
    this.getter();
  }
}
复制代码

修改defineReactive方法,在里面实例化一个Dep,用于之后的更新,因为当前是闭包环境,所以dep实例会被保存下来。

function defineReactive(obj, key, value) {
  const dep = new Dep(); // 实例化Dep
  Object.defineProperty(obj, key, {
    get() {
      Dep.target && dep.addDep(Dep.target); // 访问到当前key的时候,将Watcher添加到dep当中
      return value;
    },
    set(newVal) {
      if (newVal !== value) {
        value = newVal;
        dep.notify(); // set的时候通知Watcher更新
      }
    },
  });
}
复制代码

5.挂载、更新

这一步主要是将生成虚拟dom挂载到宿主元素中,以及更新时跟oldVnode进行对比,如果子节点不是文本,则进行diff算法。

Vue构造函数主要实现以下方法:

  1. $mount方法:就是我们熟悉的new Vue().$mount,接收一个元素选择器,挂载到宿主节点上。
  2. h方法:传入参数,返回虚拟dom。
  3. _update方法:传入虚拟Dom,判断是更新还是初始化,给patch传入相应的参数。
  4. _patch_方法:更新和初始化真正执行的函数。
  5. updateChildren方法:进行diff算法,更新节点。
  6. $createElement方法:传入虚拟Dom,返回真实dom。

一、实现h$mount方法

参数:

  • $mount
    • el:元素选择器
  • h
    • tag:标签
    • props:属性参数
    • children:子节点。可以是文本或者子节点数组

重点:

这一步还需要额外做的就是依赖收集,将updateComponent这个更新函数保存到Watcher实例。 并且,Watcher做俩件事:

  1. 调用updateComponent(),执行更新函数
  2. updateComponent执行时,先调用render函数,当前key被读取(示例代码中是this.count),并触发当前key的get方法,从而完成依赖收集。

代码实现:

class Vue {
  constructor(options) {
    ...,
    // 4.虚拟dom的初始化以及更新
    if (options.el) {
      this.$mount(options.el);
    }
  }
  
  $mount(el) {
    // 获取宿主节点,并保存到当前实例上
    this.$el = document.querySelector(el);
    
    // 生成updateComponent函数
    const updateComponent = function () {
      const { render } = this.$options; // 读取render函数
      const vnode = render.call(this, this.h); // 生成虚拟dom
      this._update(vnode); // 直接调用update函数,进行更新
    };
    
    // 创建Watcher实例,添加到dep当中
    new Watcher(this, updateComponent);
  }

  h(tag, props, children) {
    return { tag, props, children };
  }
}
复制代码

二、实现_update方法

参数:

  • vnode:虚拟dom

重点:

这一步主要是判断当前实例有没有oldVnode,如果有就更新,如果没有就初始化,从而给_patch_传入相应的参数,真正的执行函数是patch

代码实现:

class Vue {
  ...,
  _update(vnode) {
    // 主要做:区分”初始化“,还是”更新“之后调用patch
    const prevVnode = this.prevVnode;
    if (prevVnode) {
      // TODO更新
      this._patch_(prevVnode, vnode);
    } else {
      // 初始化
      this._patch_(this.$el, vnode);
    }
  }
}

复制代码

三、_patch_方法实现

参数:

  • oldVnode:旧虚拟dom(初始化时是真实dom、更新时是之前存储的旧虚拟dom)
  • newVnode:新虚拟dom

思路:

_patch_函数分三步:

  • 初始化:将newVnode转换成真实dom,并插入,同时移除旧节点
  • 更新:双方的子元素进行对比,如果双方都是子节点数组,则进行diff比对
  • 最后统一在当前实例保存新的虚拟dom,用于下一次更新做对比

代码实现:

class Vue {
  ...,
  _patch_(oldVnode, newVnode) {
    if (oldVnode.nodeType) {
      // 初始化走这里
      const parent = oldVnode.parentElement; // 找到父节点
      const ele = this.$createElement(newVnode);// 将newVnode转化为真实dom,同时赋值给ele
      parent.insertBefore(ele, oldVnode.nextSibling);// 父节点插入新生成的真实dom
      parent.removeChild(oldVnode);// 移除老的真实dom
    } else {
      // 更新时走这里
      const oldCh = oldVnode.children; // 获取oldVnode的子节点
      const newCh = newVnode.children; // 获取newVnode的子节点
      const el = (newVnode.el = oldVnode.el); // 将oldVnode的真实dom节点赋值给newVnode。因为oldVnode马上要被替换了
      // 如果newCh是字符
      if (typeof newCh === "string") {
        // 如果old也是字符,进行文本更新
        if (typeof oldCh === "string") {
          if (oldCh != newCh) {
            el.textContent = newCh;
          }
        } else {
          // oldCh是数组也同样进行文本替换
          el.textContent = newCh;
        }
      } else {
        // 如果newCh是节点数组,oldCh是文本,先清空oldCh,再添加新节点
        if (typeof oldCh === "string") {
          el.innerHTML = "";
          newCh.forEach((child) => {
            el.appendChild(this.$createElement(child));
          });
        } else {
          // 如果双方都是子节点数组,进行diff算法
          this.updateChildren(el, oldCh, newCh);
        }
      }
    }
    this.prevVnode = newVnode;// 保存newVnode
  }
}
复制代码

四、updateChildren实现

参数:

  • el:真实dom节点
  • oldCh:旧虚拟dom数组
  • newCh:新虚拟dom数组

讲解:

我们看diff算法,首先要明白的一点就是:最终结果一切以newCh为准,

这个函数里面,我只是简单的实现了diff算法,没有做Vue那么多优化,不过也实现了对应的更新功能。如果想去看详细的diff算法详解,请参考我的这篇文章Vue2.0详解diff算法。源码里面也一样都是在updateChildren里面做diff。

代码实现:

class Vue {
  ...,
  updateChildren(el, oldCh, newCh) {
    const parentElement = el.parentElement; // 获取该dom的父元素
    const len = Math.min(oldCh.length, newCh.length); // 找出长度最少的数组
    for (let i = 0; i < len; i++) {
      this._patch_(oldCh[i], newCh[i]); // 不找相同节点,直接进行更新
    }
    
    // 扫尾工作
    if (newCh.length < oldCh.length) {
     // 如果newCh长度小于oldCh,说明是删除操作
      oldCh.slice(len).forEach((child) => {
        parentElement.removeChild(child.el);
      });
    } else if (newCh.length > oldCh.length) {
      // 如果newCh长度大于oldCh,说明是添加操作,遍历newCh,然后每个vnode,调用$createElement()生成真实dom,添加到父节点上
      newCh.slice(len).forEach((child) => {
        const ele = this.$createElement(child);
        parentElement.appendChild(ele);
      });
    }
  }
}
复制代码

五、$createElement实现

参数:

  • vnode:虚拟dom

描述:

$createElement主要做:

{tag:"div",props:{class:"container"},children:1}
// 转变成
<div class="container">1</div>
复制代码

考虑到children是数组的情况,所以要进行递归遍历。

代码实现:

class Vue {
  ...,
  // 传入vnode,转换为真实dom
  $createElement(vnode) {
    const el = document.createElement(vnode.tag); // 创建元素

    // 设置属性
    Object.keys(vnode.props || {}).forEach((key) => {
      el.setAttribute(key, vnode.props[key]);
    }); 

    if (vnode.children) {
      if (
        typeof vnode.children === "string" ||
        typeof vnode.children === "number"
      ) {
        el.textContent = vnode.children; //子元素是字符,直接设置节点内容
      } else if (Array.isArray(vnode.children)) {
        vnode.children.forEach((child) => {
          el.appendChild(this.$createElement(child));
        });
      }
    }
    vnode.el = el; //保存真实节点,diff的时候要用
    return el;
  }
}
复制代码

附录:

贴上一份完整代码,可以直接复制粘贴运行:

class Vue {
  constructor(options) {
    // 1.接收用户参数
    this.$options = options;
    this.$data = options.data;

    // 2.响应式处理
    observe(this.$data);

    // 3.代理
    proxy(this);

    // 4.虚拟dom的初始化以及更新
    if (options.el) {
      this.$mount(options.el);
    }
  }
  $mount(el) {
    // 获取宿主节点
    this.$el = document.querySelector(el);

    // 生成updateComponent函数
    const updateComponent = function () {
      const { render } = this.$options; // 读取render函数
      const vnode = render.call(this, this.h); // 生成虚拟dom
      this._update(vnode); // 直接调用update函数
    };

    // 创建Watcher实例,添加到dep当中
    new Watcher(this, updateComponent);
  }

  h(tag, props, children) {
    return { tag, props, children };
  }

  _update(vnode) {
    // 主要做:区分”初始化“,还是”更新“之后调用patch
    const prevVnode = this.prevVnode;
    if (prevVnode) {
      // TODO更新
      this._patch_(prevVnode, vnode);
    } else {
      // 初始化
      this._patch_(this.$el, vnode);
    }
  }

  _patch_(oldVnode, newVnode) {
    if (oldVnode.nodeType) {
      // 初始化
      const parent = oldVnode.parentElement; // 找到父节点
      const ele = this.$createElement(newVnode);
      parent.insertBefore(ele, oldVnode.nextSibling);
      parent.removeChild(oldVnode);
    } else {
      const oldCh = oldVnode.children;
      const newCh = newVnode.children;
      const el = (newVnode.el = oldVnode.el); //保存dom节点,因为oldVnode马上要被替换了

      // 如果newCh是字符
      if (typeof newCh === "string") {
        // 如果old也是字符,进行文本更新
        if (typeof oldCh === "string") {
          if (oldCh != newCh) {
            el.textContent = newCh;
          }
        } else {
          // oldCh是数组也同样进行文本替换
          el.textContent = newCh;
        }
      } else {
        // 如果newCh是节点数组,oldCh是文本,先清空再添加节点
        if (typeof oldCh === "string") {
          el.innerHTML = "";
          newCh.forEach((child) => {
            el.appendChild(this.$createElement(child));
          });
        } else {
          // 如果双方都是子节点数组,进行diff算法
          this.updateChildren(el, oldCh, newCh);
        }
      }
    }
    this.prevVnode = newVnode;
  }

  updateChildren(el, oldCh, newCh) {
    const parentElement = el.parentElement;

    const len = Math.min(oldCh.length, newCh.length);
    for (let i = 0; i < len; i++) {
      this._patch_(oldCh[i], newCh[i]);
    }
    if (newCh.length < oldCh.length) {
      oldCh.slice(len).forEach((child) => {
        parentElement.removeChild(child.el);
      });
    } else if (newCh.length > oldCh.length) {
      newCh.slice(len).forEach((child) => {
        const ele = this.$createElement(child);
        parentElement.appendChild(ele);
      });
    }
  }

  // 传入vnode,转换为真实dom
  $createElement(vnode) {
    const el = document.createElement(vnode.tag); // 创建元素

    Object.keys(vnode.props || {}).forEach((key) => {
      el.setAttribute(key, vnode.props[key]);
    }); // 设置属性

    if (vnode.children) {
      if (
        typeof vnode.children === "string" ||
        typeof vnode.children === "number"
      ) {
        console.log("子节点");
        el.textContent = vnode.children; //子元素是字符,直接设置节点内容
      } else if (Array.isArray(vnode.children)) {
        vnode.children.forEach((child) => {
          el.appendChild(this.$createElement(child));
        });
      }
    }
    vnode.el = el; //保存真实节点,diff的时候要用
    return el;
  }
}

/**
 * 响应式处理
 */
function observe(obj) {
  if (Array.isArray(obj)) {
    // TODO 数组的响应式处理
  } else if (typeof obj === "object") {
    Object.keys(obj).forEach((key) => {
      defineReactive(obj, key, obj[key]);
    });
  }
}
function defineReactive(obj, key, value) {
  const dep = new Dep();
  Object.defineProperty(obj, key, {
    get() {
      Dep.target && dep.addDep(Dep.target);
      return value;
    },
    set(newVal) {
      if (newVal !== value) {
        value = newVal;
        dep.notify();
      }
    },
  });
}

/**
 * 代理
 */
function proxy(instance) {
  Object.keys(instance.$data).forEach((key) => {
    defineReactive(instance, key, instance.$data[key]);
  });
}

/**
 * 依赖收集
 */
class Dep {
  constructor() {
    this.deps = new Set();
  }
  addDep(watcher) {
    this.deps.add(watcher);
  }
  notify() {
    this.deps.forEach((fn) => {
      fn && fn.update();
    });
  }
}

class Watcher {
  constructor(vm, fn) {
    this.$vm = vm;
    this.$fn = fn;
    this.getter();
  }
  getter() {
    Dep.target = this;
    this.$fn.call(this.$vm);
    Dep.target = null;
  }

  update() {
    this.getter();
  }
}

复制代码

看一下成果:

image.png

总结:

文章写完了,代码量不多,但可以看到Vue整个初始化和更新的过程,建议大家一步一步调试看一下代码是如何运行的。如果大家有什么问题,欢迎大家下方评论,我看到会立马回复,谢谢大家。

最后附上最近新写的两篇文章,可以结合一起看:

  1. Vue1.0原理刨析、代码实现
  2. Vue2.0详解diff算法
文章分类
前端
文章标签