使用js手写个简易版本vue 了解下vue3其真实过程

103 阅读11分钟

Vue 三大核心系统

Compiler模块

  • 定义:编译模块系统 负责template模版的代码转换成render函数然后通过createVnode函数创建出来Vnode返回 其实是交给了webpack中的vue-loader这个loader。这个loader其中内部使用了**@vue/compiler-sfc**的库转换成了render函数
  • 作用:compiler ->读取了template模版中大量的标签 通过parser 来进行词法分析/语法分析形成一个旧的ast抽象语法树->通过大量的正则校验过滤->形成新的ast ->生成Vnode代码

Runtime 模块(运行时) :

  • 定义:其实内部是一个Renderer(渲染器)模块
  • 作用:真正的进行渲染模块

Reactivity 模块

作用:响应式系统

虚拟 dom 的渲染过程

template - > compiler(编译器)-> 形成渲染函数 render() 里面放着 h 函数 会执行 createVnode 函数 ->Vnode 形成 ->真实DOM ->浏览器展示

下面代码实现了vue的三个系统模块

  • 渲染模块系统
  • 可响应式系统
  • 应用程序入口模块

渲染系统实现三个功能

  • h函数 用于返回一个Vnode对象
  • mount函数 用于将Vnode挂载到真实DOM上
  • patch函数 用于将新老Vnode进行diff对比 然后渲染最新的Vnode到DOM

渲染器实现代码

// 渲染器

// 渲染器

// // 1.通过h函数来返回一个Vnode
// const vnode = h('div', { class: 'old' }, [
//   h('span', null, '11'),
//   h('span', null, '22'),
// ]);

// // 2. 可以通过mount函数 将vnode挂在到#app上面 这一步就是吧虚拟dom转换成真实dom的过程
// mount(vnode, document.querySelector('#app'));

// // 3.创建一个新的vnodeNew  让新的vnode通过diff算法来修改来的旧的vnode 来更新对应的界面
// const vnodeNew = h('div', { class: 'new' }, [
//   h('span', null, 'ssdj'),
//   h('span', null, '41231'),
// ]);

// // 这个时候就要调用patch函数 根据diff这两个节点进行筛选对比
// patch(vnode, vnodeNew);

// // 1.h函数来返回一个Vnode 因为他的children里面嵌套的也是h函数 所以解析children也是调用了这个方法
const h = (tag, props, children) => {
  // Vnode其实就是一个js对象 所以直接返回一个对象
  return {
    tag,
    props,
    children,
  };
};

const mount = (vnode, container) => {
  // vnode->真实的element     vnode.tag其实就是h函数的第一个参数 标签
  // 1.创建出真实的元素 并且在vnode上面保存el
  const el = (vnode.el = document.createElement(vnode.tag));
  // 2.处理props
  if (vnode.props) {
    // 遍历props的key  因为他可能有添加很多属性
    for (const key in vnode.props) {
      // 通过key来获取props的value
      const value = vnode.props[key];

      // 边界判断 可能会传入一个事件 所以不能直接添加属性
      if (key.startsWith('on')) {
        // 给这个元素添加事件 并且进行截取前两个字母也就是on 并且转换成小写 因为在h函数中事件都是驼峰命名的
        el.addEventListener(key.slice(2).toLowerCase(), value);
      } else {
        // 给这个元素设置好他的attribute 也就是class啊 style 以及一些属性值
        el.setAttribute(key, value);
      }
    }
  }
  // 3.处理子节点children
  // 判断是否有子节点
  if (vnode.children) {
    // 判断类型 是不是一个直接输出的值 也就是一个字符串
    if (typeof vnode.children === 'String') {
      // 直接赋值
      el.textContent = vnode.children;
    } else {
      // 说明他有子节点 这个时候就是循环children并且进行递归处理 挂载的是当前el元素上面
      vnode.children.forEach(item => {
        mount(item, el);
      });
    }
  }

  // 4.将el挂在到container上面
  container.appendChild(el);
};

// 通过传入的n1就是oldVnode,n2就是NewVnode 然后通过diff找到不同的vnode 然后更新不同的vnode到真实dom上
const patch = (n1, n2) => {
  // 1.首先进行判断vnode两个标签是否相同  如果连标签都不相同的话 他直接用新的vnode来全量替换老的vnode
  if (n1.tag !== n2.tag) {
    //1.1 首先考虑如何先删除老的节点 (n1.el因为在老节点挂载的时候把元素赋予给el并且返回了 所以在这个地方可以拿到) 如果想删除一个dom节点 需要拿到他的父元素 这样才可以根据父元素删除他的指定子节点
    const n1Elparent = n1.el.parentElement;
    // 使用removeChild删除父节点下面的子节点
    n1Elparent.removeChild(n1.el);
    // 这个时候因为Vnode还没有创建并且挂载到真实dom上 并且还没有进行分析vnode 所以直接调用mount函数 挂载新的vnode到真实dom上
    mount(n2, n1Elparent);
  }
  // 如果标签相同 才会进行详细的对比
  else {
    // 1.取出element对象 并且在el2中进行保存   因为这个一个对象的浅引用 他们在内存中指向的都是同一个内存地址 所以当我修改了el n2/n1的el也都会进行修改
    const el = (n2.el = n1.el);

    // 2.处理props 取出新/老的vnode对象中的props属性 有可能没有设置 为空
    const oldProps = n1.props || [];
    const newProps = n2.props || [];

    //  首先进行新增操作 遍历新的vnode中的props 进行对比值 不相等的话来进行新增到元素上
    for (const key in newProps) {
      // 根据key 来取对应的value值
      const oldValue = oldProps[key];
      const newValue = newProps[key];
      // 判断新的值和老的值如果不想等 那就是说这个props是新增上去的并且需要添加到dom上面的  (直接通过获取值来判断是否新增 因为如果新props的key有这个值 但是老props的没有这个值 那就是null和value的判断 也会进行新增处理,然后这是遍历新的props 所以不会出现老的props有 但是新的props上面没有 导致进入新增判断)
      if (oldValue !== newValue) {
        // 边界判断 可能会传入一个事件 所以不能直接添加属性
        if (key.startsWith('on')) {
          // 给这个元素添加事件 并且进行截取前两个字母也就是on 并且转换成小写 因为在h函数中事件都是驼峰命名的
          el.addEventListener(key.slice(2).toLowerCase(), newValue);
        } else {
          // 给这个元素设置好他的attribute 也就是class啊 style 以及一些属性值
          el.setAttribute(key, newValue);
        }
      }
    }
    // 遍历老的props 筛选出老props在新的props没有的属性
    for (const key in oldProps) {
      // 因为函数每次都是一个新的对象空间 所以在新增的时候重复了 在这里吧重复的删除一遍 不是最优化的解决
      if (key.startsWith('on')) {
        const oldValue = oldProps[key];
        // 给这个元素添加事件 并且进行截取前两个字母也就是on 并且转换成小写 因为在h函数中事件都是驼峰命名的
        el.removeEventListener(key.slice(2).toLowerCase(), oldValue);
      }

      // 通过一个取反 遍历老的props 不再新的props里面的 这样就代表着是删除节点
      if (!(key in newProps)) {
        // // 边界判断 可能会传入一个事件 所以不能直接添加属性
        // if (key.startsWith('on')) {
        //   const oldValue = oldProps[key];
        //   // 给这个元素添加事件 并且进行截取前两个字母也就是on 并且转换成小写 因为在h函数中事件都是驼峰命名的
        //   el.removeEventListener(key.slice(2).toLowerCase(), oldValue);
        // } else {
        // 给这个元素设置好他的attribute 也就是class啊 style 以及一些属性值
        el.removeAttribute(key, oldValue);
      }
    }
  }

  // 3. 处理children 首先获取到新老的children 然后判断类型以及筛选对比
  const oldChildren = n1.children || [];
  const newChildren = n2.children || [];
  // 情况一 新的children是一个字符串 直接是一个值的话
  if (typeof newChildren === 'string') {
    // 判断老children是否也是字符串 是否都是同一类型字符串
    if (typeof oldChildren === 'string') {
      // 如果他俩字符串不相等 那么就等于更新他的节点值 如果他俩想等的话 说明该节点不用进行更新
      if (newChildren !== oldChildren) {
        el.textContent = newChildren;
      }
      // 说明老节点他不是一个直接输出的值string的类型 这个时候直接替换了
    } else {
      el.innerHtml = newChildren;
    }
  }
  // 一般情况下children如果是一个object的话 那么就说明他的标签是组件 里面是它组件的插槽
  else if (typeof newChildren === 'object') {
  } else {
    // 这种情况就是一个数组类型 里面放着h函数 正常在vue源码中是会根据key来对比的 如果没有key他就根据前后顺序来一一对比  根据key来查找新的vnode对应到老的vnode 这样效率是最好的
    // 但是咱们在这就不考虑key的情况 根据没有key的方式来判断
    // oldChildren:[v1,v2,v3]
    // newChildren:[v1,v6,v3,v4,v5]
    // 首先获取到最小长度的数组长度 这样就可以比较他们相同的部分 这样就可以先筛选一下相同长度的vnode是否有过修改
    const commonLength = Math.min(oldChildren.length, newChildren.length);
    // 遍历 通过调用patch 传入新老节点 来进行对比递归 来筛选出他俩相等长度的vnode
    for (const i = 0; i < commonLength; i++) {
      patch(oldChildren[i], newChildren[i]);
    }

    // oldChildren:[v1,v2,v3]
    // newChildren:[v1,v6,v3,v4,v5]
    // 寻找不相同长度的vnode(相同长度的vnode已经在上面处理完了 所以不需要处理)
    // 在判断如果新Children的长度大于老的Children的话 就说明剩下的是新增到dom节点上面的
    if (newChildren.length > oldChildren.length) {
      // 通过slice切割掉相同长度的地方 剩下的就是还没处理的新的vnode 通过循环调用mount 处理挂载
      newChildren.slice(oldChildren.length).forEach(item => {
        mount(item, el);
      });
    }

    // oldChildren:[v1,v2,v3,v4,v5]
    // newChildren:[v1,v6,v3]
    // 在判断如果老Children的长度大于新的Children的话 就说明剩下的是新的children没有的 需要从dom删除
    if (oldChildren.length > newChildren) {
      // 通过slice切割掉相同长度的地方 剩下的就是还没处理的新的vnode 通过循环调用removeChild 根据父元素删除对应的子元素
      oldChildren.slice(newChildren.length).forEach(item => {
        el.removeChild(item.el);
      });
    }
  }
};


响应式系统实现

// 1. 把对象在封装的reactive函数里面 在函数里面可以使用Proxy或者Object.defineProperty来进行响应式

// 将传入的对象进行封装操作 vue3

let activeReactiveFn = null;
// 方便存储收集到的依赖 并且触发 每一个对象都有自己的类
class depend {
  constructor() {
    //  这块创建依赖数组 用Set不用Arry 因为Set有自动去重 防止加入重复的依赖函数
    this.reactiveFns = new Set();
  }

  depend() {
    // 这块使用一个全局变量 他的用处就是获取每一次的方法依赖
    if (activeReactiveFn) {
      this.reactiveFns.add(activeReactiveFn);
    }
  }

  //  循环遍历出发依赖中的函数
  notify() {
    this.reactiveFns.forEach(fn => {
      fn();
    });
  }
}

function reactive(obj) {
  return new Proxy(obj, {
    // 当获取值的时候 获取到对应的依赖 当你创建了变量 他会先获取值
    get(target, key, receiver) {
      //  获取到该对象所改变的key 所对应的依赖
      const depend = getDepend(target, key);
      depend.depend();
      return Reflect.get(target, key, receiver);
    },
    //  当改变值的时候触发 触发获取到对应依赖
    set(target, key, newValue, oldValue) {
      console.log(newValue);
      //  改变代理对象的值
      Reflect.set(target, key, newValue);
      //  获取到该对象所改变的key所对应的依赖类
      const depend = getDepend(target, key);
      depend.notify();
    },
  });
}

// 对传入对象进行封装操作 Vue2 用的是Object.defineProperty()
function reactive1(obj) {
  // {name: "why", age: 18}
  // ES6之前, 使用Object.defineProperty
  Object.keys(obj).forEach(key => {
    let value = obj[key];
    Object.defineProperty(obj, key, {
      get: function () {
        const depend = getDepend(obj, key);
        depend.depend();
        return value;
      },
      set: function (newValue) {
        value = newValue;
        const depend = getDepend(obj, key);
        depend.notify();
      },
    });
  });
  return obj;
}

// 封装一个depend函数 为了整合对象中的依赖数据格式 主要用的是weakMap和Map
// 使用weakMap 他是个弱引用 方便于回收
const targetWeakMap = new WeakMap();
function getDepend(target, key) {
  // 首先获取到传入的对象。获取到它内部key所有的依赖
  let map = targetWeakMap.get(target);

  // 第一次没有的话 给这个对象创建一个空的依赖进去
  if (!map) {
    map = new Map();
    targetWeakMap.set(target, map);
  }
  // 通过key获取到该key所有的依赖函数
  let depend1 = map.get(key);
  if (!depend1) {
    // 第一次没有的话 给该对象创建一个空的依赖进去

    depend1 = new depend();
    map.set(key, depend1);
  }
  return depend1;
}

function watchEffect(effect) {
  activeReactiveFn = effect;
  effect();
  activeReactiveFn = null;
}


程序入口的实现

// 创建app 需要rootComponent就是我们创建的组件 也就是那些页面需要创建的元素 然后 它需要返回一个对象 对象里面有mount的方法的参数是我们根节点
function createApp(rootComponent) {
  return {
    // 返回一个mount的函数 是为了指定挂载到哪个根节点
    mount(selector) {
      // 首先获取到根节点元素
      const container = document.querySelector(selector);
      // 使用一个变量 来判断是否第一次加载还是变量进行了修改 然后第一次加载完了 把变量设置为true 这要就避免修改的话也多次挂载
      let isMounted = false;
      // 保存旧的Vnode 当第一次加载完会给他赋值 并且如果修改完了 会把新的Vnode赋值给oldVnode
      let oldVnode = null;
      // 监听响应式变化
      watchEffect(function () {
        // 第一次挂载
        if (!isMounted) {
          // 从我们定义好的对象中取到render函数的返回值 返回值就是h函数 并且赋值给oldVnode
          oldVnode = rootComponent.render();
          // 调用mount挂载函数 实在渲染器中的mount 传入需要挂载的Vnode和他的挂载哪个元素下面
          mount(oldVnode, container);
          // 设置变量为true 防止下载修改的时候再次重复挂载
          isMounted = true;
        } else {
          // 修改时候 取到我们需要修改的对象中的render函数   这里面的render其实就是在template中通过vue-loader插件中的@vue/compiler-sfc这个插件库中通过compiler编译器生成好的vnode节点
          const newVnode = rootComponent.render();
          // 进行渲染器中的patch操作来进行diff对比 渲染到页面上 传入新老Vnode
          patch(oldVnode, newVnode);
          // 渲染完了 新的Vnode 赋值给oldVnode  方便下一次更新操作
          newVnode = oldVnode;
        }
      });
    },
  };
}


html调用入口

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="./渲染器实现/render.js"></script>
    <script src="./响应式实现/reactive.js"></script>
    <script src="./mini-vue实现/index.js"></script>

    <script>
      const App = {
        data: reactive({
          counter: 0,
        }),
        render() {
          return h('div', null, [
            // 在使用this.data.counter这个时候 就会触发响应式中对象get方法来收集对应的依赖
            h('h2', null, `当前计数器:${this.data.counter}`),
            h(
              'button',
              {
                // 这块需要使用箭头函数 如果是正常的function 他的this指向是window 属于一个全局执行 如果 使用箭头函数他会寻找上层作用域 也就是谁调用的render函数
                onClick: () => {
                  this.data.counter++;
                },
              },
              '+1'
            ),
          ]);
        },
      };
      // 进行创建挂载
      createApp(App).mount('#app');

      /**
       * 但是问题 这个就是当渲染器中的patch操作diff对比的时候 对于function的话 他每一次都是创建一个新的值 因为每一次增加或者删除都是一个新的内存空间 所以在对比function的时候 他俩的值不是完全相等的 这就会操成他每一次都多成倍添加事件然后就会每次触发事件就会造成多次
       * 这个也有个粗略的解决办法 在patch 遍历老的props的话 因为在遍历新的props时候多添加一次 所以在遍历老的props在删除一遍 这虽然能解决元素监听重复问题 但是也造成了重复操作
       */
    </script>
  </body>
</html>

重点事项

vue源码会有大量的判断和边界的判断 但是整体的思路是一致的但是还有有小的地方需要优化 ,这个后续再去看看vue源码他是如何操作的