让我们一起用 JS 写一个 mini-vue 吧

本文将带你深入理解Vue,从构建一个mini-Vue开始,探讨虚拟DOM的优势,解析Vue的主要组成部分,包括Compiler、Runtime和Reactivity模块,详细讲解Vue的工作流程,并实现渲染器与响应式系统。我们将比较proxy和Object.defineProperty在数据劫持中的优缺点,最后通过入口文件梳理整个迷你版Vue的实现过程。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

mini-Vue

前置知识

  • Vue中使用了虚拟DOM。

The virtual DOM (VDOM) is a programming concept where an ideal, or “virtual”, representation of a UI is kept in memory and synced with the “real” DOM by a library such as ReactDOM. This process is called reconciliation. – React官方文档

可以看到 React 也使用了虚拟 DOM 😃

虚拟DOM(VDOM)是一种编程概念,其中UI“虚拟”表示保存在内存中,并通过ReactDOM之类的库与“真实”DOM同步。

  • 虚拟 DOM 可以为我们带来什么?
  1. 操作便利:因为对于直接操作DOM来说是有很多的限制的,比如diff、clone等等,但是使用JavaScript编程语言来操作这

    些,就变得非常的简单;

  2. 性能优势:频繁的操作真实 DOM 会在性能方面造成极大的损耗,我们可以采用 虚拟 DOM 中的 diff 算法渲染真实DOM,提升了性能。

  3. 跨平台性:可以将VNode节点渲染成任意你想要的节点,如渲染在canvas、WebGL、SSR、Native(iOS、Android)上,并且Vue允许你开发属于自己的渲染器(renderer),在其他的平台上渲染;

Vue主要由哪几部分构成?

  1. Compiler模块:编译模板系统;
  2. Runtime模块:也可以称之为Renderer模块,真正渲染的模块;
  3. Reactivity模块:响应式系统;

Vue工作流程

  1. 通过h函数创建出 vnode (虚拟节点)
  2. 如果没啥变化,直接渲染上去
  3. 当数据被修改了,会创建一个 newVnode,通过 diff 算法与 oldVnode 进行比对,然后进行渲染。

Part One 渲染器的实现

该模块主要包括三个功能

  • h函数,用于返回一个VNode对象;
  • mount函数,用于将VNode挂载到DOM上;
  • patch函数,用于对两个VNode进行对比,决定如何处理新的VNode;
// 首先实现h函数,h函数就是为了让我们返回一个 JavaScript 对象的,则:
const h = (tag, props, children) => {
  return {
    tag,
    props,
    children
  }
}

// 其次我们需要实现一个 mount 函数,将我们的虚拟节点转化成真实的节点,并且挂载到container上
const mount = (vnode, container) => {
  // 1. 创建出真实的原生,并在 vnode 上保留 el
  const el = vnode.el = document.createElement(vnode.tag)
  // 2. 处理 props
  if(vnode.props) {
    for(const key in vnode.props) {
      // 获取值
      const value = vnode.props[key]
      // 如果是事件的话
      if(key.startsWith("on")) {
       	el.addEventListener(key.slice(2).toLowerCase(), value)
      } else {
        // 如果只是属性的话
        el.setAttribute(key, value);
      }
    }
  }
  // 3. 处理 chilren
  if(vnode.chilren) {
    if(typeof vnode.chilren === "string") {
      el.textContent = vnode.children
    } else {
      vnode.chilren.forEach((item) => {
        mount(item, el)
      })
    }
  }
}
// 最后就是要实现一个进行 diff 比对的 patch 函数
// 此处传入两个 vnode
const patch = (n1, n2) => {
    if(n1.tag !== n2.tag) {
      const n1ElParent = n1.el.parentElement;
      n1ElParent.removeChild(n1.el);
      mount(n2, n1ElParent)
    } else {
      // 取出 element 对象,并且在 n2 中进行保存
      const el = n1.el = n2.el
      // 为了获取 value
      const oldProps = n1.props || []
      const newProps = n2.props || []
      // 遍历新对象
      for(const key in newProps) {
        const oldValue = oldProps[key]
        const newValue = newProps[key]
        if(oldValue != newValue) {
          if(key.startsWith("on")) {
            el.addEventListener(key.slice(2).toLowerCase(), newValue);
          } else {
            el.setAttribute(key, newValue);
          }
        }
      }
      // 删除旧的 props
      for(const key in oldProps) {
        if(key.startsWith("on")) {
          const value = oldProps[key];
          // removeEventListener(type, listener)
          // type ==> 一个字符串,表示需要移除的事件类型 | listener ==> 需要从目标事件中移除的事件处理函数。
          el.removeEventListener(key.slice(2).toLowerCase(), value)
        } 
        if(!(key in newProps)) {
          el.removeAttribute(key)
        }
      }
      
      // 3. 处理children
      const oldChildren = n1.children || []
      const newChildren = n2.children || []
      // 如果新的 node 的 children 只是一个字符串,那直接覆盖就可以了
      if (typeof newChildren === 'string') {
        // 如果旧的也是字符串,那就判断一下是不是一样的,然后覆盖一下
        if (typeof oldChildren === 'string') {
          if (newChildren != oldChildren) {
            el.textContent = newChildren
          }
        } else {
          // 如果新的是很多很多东西,我直接覆盖就可以了
          el.innerHTML = newChildren
        }
      } else {
        // 这种情况新的 node 并不是字符串,是数组啥的
        // 然后判断一下旧的的情况
        // 如果是字符串
        if(typeof oldChildren === 'string') {
          el.innerHTML = ""
          newChildren.forEach((item) => {
            mount(item, el)
          })
        } else {
          // 如果都不是字符串的话
          // 拿到共同长度
          const commonLength = Math.min(oldChildren.length, newChildren.length)
          for(let i=0;i<commonLength;i++) {
            patch(oldChildren[i], newChildren[i])
          }
          // 如果新的节点的子节点多一些的话,那就全部挂载上去
          if(newChildren.length > oldChildren.length) {
            newChildren.slice(commonLength).forEach((item) => {
              mount(item, el)
            })
          }
          // 如果旧的节点的子节点多一些,那就一个个删除!
          if(newChildren.length > oldChildren.length) {
            oldChildren.slice(commonLength).forEach((item) => {
              el.removeChild(item)
            })
          }
        }
      }
    }
  }

Part Two 实现响应式

  • 依赖收集过程

WeakMap ==> Map ==> set

const targetMap = new WeakMap()
function getDep(target, key) {
  // 根据对应的 target 取出对应的Map对象
  let depsMap = targetMap.get(target)
  if(!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }
  // 取出具体的 deps 对象
  let dep = depsMap.get(key)
  if(!deps) {
    dep = new Dep()
    depsMap.set(key, dep)
  }
  return dep
}

class Dep {
  constructor() {
    this.subscribers = []
  }
  depend() {
    if(activeEffect) {
      this.subscribers.add(activeEffect);
    }
  }
  // 更新视图
  notify() {
    this.subscribers.forEach(effect => {
      effect();
    })
  }
}
  • 监听函数过程
let activeEffect = null
function watchEffect(effect) {
  activeEffect = effect
  effect()
  activeEffect = null
}
  • 数据劫持过程
// vue3对raw进行数据劫持
function reactive(raw) {
  return new Proxy(raw, {
    get(target, key) {
      const dep = getDep(target, key);
      dep.depend();
      return target[key];
    },
    set(target, key, newValue) {
      const dep = getDep(target, key);
      target[key] = newValue;
      dep.notify();
    }
  })
}

其实 Vue2 也可以使用 Object.defineProperty 进行数据劫持

function reactive(raw) {
  Object.keys(raw).forEach(key => {
    const dep = getDep(raw, key);
    let value = raw[key];

    Object.defineProperty(raw, key, {
      get() {
        // 对于响应式对象,当他获取这个值的时候,就会把依赖进行收集
        dep.depend();
        return value;
      },
      set(newValue) {
        if (value !== newValue) {
          value = newValue;
          dep.notify();
        }
      }
    })
  })
  return raw;
}

所以我们需要来比对一下 Object.defineProperty 和 proxy

proxy 与 Object.defineProperty的优缺点

  1. Object.defineProperty做的是拦截,对一个对象中的某个属性进行拦截,然后实现响应式,所以如果这个对象新增了属性,Object.definProperty无法监测到,需要使用 $set 强制添加才可以。Proxy则没有这个问题。
  2. 对于数组的 push、pop 等操作,Object.defineProperty 无法监测到,自然没法响应式的变更,Proxy也没这个问题
  3. Proxy 能观察的类型比 defineProperty 更丰富:「has:in操作符的捕获器;」「deleteProperty:delete 操作符的捕捉器;」
  4. Proxy 作为新标准将受到浏览器厂商重点持续的性能优化;
  5. 缺点:Proxy 不兼容IE,也没有 polyfill, defineProperty 能支持到IE9

Part Three 入口文件

function createApp(rootComponent) {
  return {
    mount(selector) {
      const container = document.querySelector(selector);
      let isMounted = false;
      let oldVNode = null;
      watchEffect(() => {
        if(!isMounted) {
          isMounted = true
          oldVNode = rootComponent.render()
          mount(oldVNode, container)
        } else {
          let newVnode = rootComponent.render()
          patch(oldVNode, newVnode)
          oldVNode = newVnode
        }
      })
    }
  }
}
  • 至此,一个超精简版的Vue就实现啦!
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值