手撕Mini-vue简洁版

239 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第5天,点击查看活动详情

实现一个简洁版的Mini-Vue框架,包括三个模块:

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

Ps:理解mini-vue的实现流程后,便于理解Vuejs框架的设计,也便于源码的阅读。本次demo中几乎每行都带有注释非常方便理解,案例实现源码附于文章最后,欢迎大家采摘。

渲染系统

该模块主要包含三个功能:

  • 功能一:h函数,用于返回一个VNode对象;
  • 功能二:mount函数,用于将VNode挂载到DOM上;
  • 功能三:patch函数,用于对两个VNode进行对比,决定如何处理新的VNode;
h函数的实现:

这里非常的简单,其实就是返回一个vnode

const h=(tag,props,children)=>{
    // vnode -->javascript ->{}
  return {
      tag,
      props,
      children
  }
}
mount函数的实现:
  • 第一步:根据tag,创建HTML元素,并且存储到vnode的el中;

  • 第二步:处理props属性

    1. 如果以on开头,那么监听事件;
    2. 普通属性直接通过 setAttribute 添加即可;
  • 第三步:处理子节点

    • 如果是字符串节点,那么直接设置textContent;

    • 如果是数组节点,那么遍历调用 mount 函数;

const mount = (vnode,container)=>{
    // 创建真实的 dom
    // 1.  vnode --》 el ,并且在vnode中保留一份
    const el  = vnode.el = document.createElement(vnode.tag);
    // 2.处理 props
    if(vnode.props){
        for(const key in vnode.props){
            const value = vnode[key];
            // edge processing
            if(key.startsWith('on')){
                el.addEventListener(key.slice(2).toLowerCase(),value)
            }else{
                el.setAttribute(key,value)
            }
            
        }
    }
    // 3.处理children  
    if(vnode.children){
        if(typeof vnode.children === 'string'){
            el.textContent = vnode.children
        }else{
            vnode.children.forEach(item => {
                mount(item,el)
            });
​
        }
    }
    // 4.将 el 挂载到 container 中
    container.appendChild(el)
}
patch函数的实现:

Ps: patch是一个非常重要的函数,在Vue源码里面将近2k行代码,mini-vue中我们先简单处理下业务场景,方便以后我们阅读源码。

patch函数的实现,分为两种情况

  • n1和n2是不同类型的节点:

    • 找到n1的el父节点,删除原来的n1节点的el;
    • 挂载n2节点到n1的el父节点上;
  • n1和n2节点是相同的节点:

    • 处理props的情况

      • 先将新节点的props全部挂载到el上;

      • 判断旧节点的props是否不需要在新节点上,如果不需要,那么删除对应的属性;

  • 处理children的情况

    • 如果新节点是一个字符串类型,那么直接调用 el.textContent = newChildren;

    • 如果新节点不同一个字符串类型:

    • 旧节点是一个字符串类型

      1. 将el的textContent设置为空字符串;
      2. 旧节点是一个字符串类型,那么直接遍历新节点,挂载到el上;
    • 旧节点也是一个数组类型

      1. 取出数组的最小长度;
      2. 遍历所有的节点,新节点和旧节点进行patch操作;
      3. 如果新节点的length更长,那么剩余的新节点进行挂载操作;
      4. 如果旧节点的length更长,那么剩余的旧节点进行卸载操作;
const patch = (n1,n2)=>{
   if(n1.tag !== n2.tag){
    //直接移除
    const n1ElParent = n1.el.parentElement;
    n1ElParent.removeChild(n1.el);
    mount(n2,n1ElParent)
​
   }else{
    //   1.取出 el对象并且在n2 中保存一份
    const el = n2.el = n1.el  
    //   2.处理 props
    const oldProps = n1.props || {}
    const newProps = n2.props || {}
        // 2.1 将所有newPros 添加到 n1 中
        for(const key in newProps){
            const oldValue = oldProps[key];
            const newValue = newProps[key];
            if(newValue !== oldValue){
               if(key.startsWith('on')){
                   el.addEventListener(key.slice(2).toLowerCase(),newValue)
               }else{
                   el.setAttribute(key,newValue)
               }
            }
        }   
​
        // 2.2 删除 旧的props ,如果新的node没有 旧中的属性,旧中就移除
        for(const key in oldProps){
           if(!(key in newProps)){
               if(key.startsWith('on')){
                   const value = oldProps[key]
                   el.removeEventListener(key.slice(2).toLowerCase(),value)
               }else{
                   el.removeAttribute(key)
               }
           }
        }
​
​
    //  3处理 children
    const oldChidren = n1.children || [];
    const newChidren = n2.children || [];
   
    if(typeof newChidren === 'string'){ // first case: newChildren is string
        // TODO: edge case  边界情况的处理
        // el.innerHTML = newChidren
        if(typeof oldChidren === 'string'){
            if(newChidren !== oldChidren){
                el.textContent = newChidren
            }
        }else{
            el.innerHTML = newChidren
        }
    }else{ // second case: newChildren is array
      
       if(typeof oldChidren === 'string'){
           el.innerHTML = '';
           newChidren.forEach(item =>{
               mount(item,el)
           })
       }else{
        //    oldChildren:[v1,v2,v3]
        //    newChildren:[v1,v4,v5,v6,v7]
        // 1.前面有相同节点的情况处理
        const commonLength = Math.min(oldChidren.length,newChidren.length)
        for(let i=0;i<commonLength;i++){
            patch(oldChidren[i],newChidren[i])
        }
        // 2.newChildren > oldChildren
        if(newChidren.length > oldChidren.length){
            newChidren.slice(oldChidren.length).forEach(item=>{
                mount(item,el)
            })
        }
        // 3.newChildren < oldChildren
        if(newChidren.length < oldChidren.length){
            // unmount
            oldChidren.slice(newChidren.length).forEach(item=>{
                el.removeChild(item.el);
            })
        }
​
       }
    }
​
   }
}

响应系统

有多个地方都依赖Data数据的时候,将相关依赖的函数进行收集,放入到deps中。可以是Array、Map、set等

当Data数据发生变化的时候,遍历deps中依赖,执行相关函数。

响应式.png

依赖收集

依赖我们可以理解为,数据之间的联系。

// 依赖

class Dep {

constructor() {

// 添加订阅者,并删除重复依赖

this.subsribers = new Set();

}

// 添加effect 的重构

depend() {

if (activeEffect) {

this.subsribers.add(activeEffect);

}

}

// notify

notify() {

this.subsribers.forEach((effect) => {

effect();

});

}

}

// 执行dep 的方法时,不需要依赖effect。依然可以添加到subscribers中

let activeEffect = null;

function watchEffect(effect) {

activeEffect = effect;


// deep:true

effect();

activeEffect = null;

}
数据劫持,响应式

dep单独更新修改.png


// 通过数据结构来管理 dep

const targetMap = new WeakMap();

function getDep(target, key) {

// 根据target对象取出 对应的Map对象

let depsMap = targetMap.get(target);

if (!depsMap) {

depsMap = new Map();

targetMap.set(target, depsMap);

}

// 取出对应的dep

let dep = depsMap.get(key);

if (!dep) {

dep = new Dep();

depsMap.set(key, dep);

}

return dep;

}


Vue2 数据劫持

vue2数据劫持.png

Vue3-数据劫持

Ps:WeakMap key可以存储对象类型,方便内存的回收。 数据劫持-vue3.png

defineProperty 与Proxy 的区别

1、 Object.definedProperty 是劫持对象的属性时,如果新增元素:那么Vue2需要再次 调用definedProperty,而 Proxy 劫持的是整个对象,不需要做特殊处理;

2、 修改对象的不同:

  • 使用 defineProperty 时,我们修改原来的 obj 对象就可以触发拦截;

  • 而使用 proxy,就必须修改代理对象,即 Proxy 的实例才可以触发拦截;

3、Proxy 能观察的类型比 defineProperty 更丰富

  • has:in操作符的捕获器;

  • deleteProperty:delete 操作符的捕捉器,以及其他操作;

4、 Proxy 作为新标准将受到浏览器厂商重点持续的性能优化;

5、缺点:Proxy 不兼容IE,也没有 polyfill, defineProperty 能支持到IE9

应用程序入口

//非常熟悉的入口
vue.createApp().mount

这样我们就知道了,从框架的层面来说,我们需要提供createApp、mount方法

有两部分内容:

  • createApp用于创建一个app对象;

  • 该app对象有一个mount方法,可以将根组件挂载到某一个dom元素上;

入口.jpg

mini-Vue传送门