likes
comments
collection
share

js手写(四):简单实现一个mini-vue

作者站长头像
站长
· 阅读数 11

1.实现思路分析

1.1 入口分析

分析思路时,通常从当前语法是如何使用的入手,我们在使用Vue3创建App组件时,使用如下方式:

createApp(App).mount("#app")

由此可以看出,首先需要完成以下工作:

  1. 需要创建一个App对象,App对象即我们平时使用的App根组件,其本质即为一个普通对象,里面包含众多属性和方法,如data,render函数(平时我们写template模板时最终都会被编译成render函数,这个render函数也是关键之一)

  2. 需要实现一个createApp函数,该函数传入一个App对象

  3. createApp函数的返回值为一个对象,且该对象中拥有一个mount方法

// createApp.js

function createApp(rootComponent) {
  return {
    mount(selector) {
      const rootNode = document.querySelector(selector)
      let isMounted = false
      let oldVnode = null
      
      // 在回调函数中,首先判断组件是否已经挂载(即isMounted变量),如果组件还没有挂载,则调用rootComponent的render方法生成旧的虚拟DOM节点,然后使用mount函数将该节点挂载到容器中。这里的render方法是组件中必须实现的方法之一,用于生成组件的虚拟DOM节点。在生成节点的过程中,会访问组件中的响应式数据,因此watchEffect就会监听这些数据。

      // 当组件已经挂载时,回调函数会调用rootComponent的render方法生成新的虚拟DOM节点,并使用patch函数将新旧节点进行对比并更新DOM。同样地,在生成新节点的过程中,也会访问组件中的响应式数据,因此watchEffect也会监听这些数据。
      watchEffect(() => { 
        // 如果首次加载,则挂载到根节点,如果不是首次加载,则调用patch函数更新节点
        if(!isMounted) {
          oldVnode = rootComponent.render()
          mount(oldVnode, rootNode)
          isMounted = true
        } else {
          let newVnode = rootComponent.render()
          patch(oldVnode, newVnode)
          oldVnode = newVnode
        }
      })
    }
  }
}

1.2 响应式系统设计分析

响应式系统作用就是把数据变为响应式的,即数据变化时视图可自动更新,主要实现了reactive函数及其依赖,该步骤想看详细说明的请移步上一篇文章,专门讲了响应式的实现思路juejin.cn/post/724178…

// reactive.js
// 1.创建一个类用于收集依赖和触发依赖
class Dependence {
  constructor() {
    this.subscribers = new Set()
  }
  depend() {
    if(activeFn) {
      this.subscribers.add(activeFn)
    }
  }
  notify() {
    this.subscribers.forEach(fn => fn())
  }
}

// 2.自动调用的函数
let activeFn = null
function watchEffect(fn) {
  activeFn = fn
  fn()
  activeFn = null
}

// 3.获取依赖的函数,保证每次读取属性都可以获取到空依赖或者上次的依赖
const targetMap = new WeakMap()
function getDependence(target, key) {
  let map = targetMap.get(target)
  if(!map) {
    map = new Map()
    targetMap.set(target, map)
  }
  let dependence = map.get(key)
  if(!dependence) {
    dependence = new Dependence()
    map.set(key, dependence)
  }
  return dependence
}

// 4.响应式函数
function reactive(raw) {
  return new Proxy(raw, {
    get(target, key) {
      const depend = getDependence(target, key)
      depend.depend()
      return target[key]
    },
    set(target, key, newValue) {
      const depend = getDependence(target, key)
      target[key] = newValue
      depend.notify()
    }
  })
}

1.3 渲染器系统设计

渲染器系统主要实现了三个函数:

(1)h函数:返回一个Vnode;

(2)mount函数:经h函数处理后的Vnode会作为mount函数的入参,mount函数将传过来的Vnode对象转为真实dom并挂载到节点上;

(3)patch函数:数据发生变化时,会根据传入的新旧Vnode,逐一对比,以更新有差异的节点

// renderer.js

// h函数返回一个vnode,是一个js对象
const h = (tag, props, children) => {
  return {
    tag,
    props,
    children
  }
}

// 将传过来的vnode对象转为真实dom并挂载到节点上渲染
const mount = (vnode, container) => {
  // 1.处理tag,并在vnode上保留一份节点信息
  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).toLocaleLowerCase(), value)
      } else {
        el.setAttribute(key, value)
      }
    }
  }

  // 3.处理chridlren
  if(vnode.children) {
    if(typeof vnode.children === 'string') {
      el.textContent = vnode.children
    } else {
      vnode.children.forEach(item => {
        mount(item, el)
      })
    }
  }

  // 4.添加el到容器节点中
  container.appendChild(el)
}

const patch = (n1, n2) => {
  if(n1.tag !== n2.tag) {
    // 如果标签不一样,直接销毁旧元素,创建新元素
    const n1ElParent = n1.el.parentNode
    n1ElParent.removeChild(n1.el)
    mount(n2, n1ElParent)
  } else {
    // 1.保存节点并备份到n2中
    const el = n2.el = n1.el
    // 2.处理props
    // 2.1遍历新的props,如果存在同样属性,则新的vnode属性替换旧的,如果没有则添加
    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)
        }
      }
    }

    // 2.2 遍历旧的props,如果有多余属性删除
    for(const key in oldProps) {
      // 以on开头就直接移除,因为每次onClick传过来的函数都是不同的函数,如果不移除,在遍历新的props时会不停添加事件监听
      if(key.startsWith('on')) {
        el.removeEventListener(key.slice(2).toLowerCase(), oldProps[key])
      }
      if(!(key in newProps)) {
        el.removeAttribute(key)
      }
    }

    // 3.处理children
    const newChildren = n2.children || []
    const oldChildren = n1.children || []
    if(typeof newChildren === 'string') {
      if(typeof oldChildren === 'string') {
        el.textContent = newChildren
      } else {
        el.innerHTML = newChildren
      }
    } else {
      if(typeof oldChildren === 'string') {
        el.innerHTML = ''
        newChildren.forEach(item => mount(item, el))
      } else {
        const commonLength = Math.min(newChildren.length, oldChildren.length)
        // 长度相同的几个节点递归进行patch调用
        for(let i = 0; i < commonLength; i++) {
          patch(oldChildren[i], newChildren[i])
        }
        if(newChildren.length > commonLength) {
          newChildren.slice(oldChildren.length).forEach(item => mount(item, el))
        }
        if(oldChildren.length > newChildren.length) {
          oldChildren.slice(newChildren.length).forEach(item => el.removeChild(item.el))
        }
      }
    }
  }
}


1.4 测试

可以将上述三个模块的代码依次保存至一个js文件中,本文以前端比较常用的案例,点击时count++的效果。注意,创建App对象时data属性要用reactive方法修饰,以使数据变为响应式,App对象中的render函数返回一个h函数,h函数的入参有3个,分别是,标签名、属性、子元素。然后将上述js文件分别引入,即可模拟Vue使用createApp方法。

<!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="./js/reactive.js"></script>
  <script src="./js/renderer.js"></script>
  <script src="./js/createApp.js"></script>

  <script>
    const App = {
      data: reactive({
        counter: 0
      }),
      render() {
        return h('div', null, [
          h('h2', null, `当前计数为${this.data.counter}`),
          h('button', {
            onClick: () => this.data.counter++
          }, '+1')
        ])
      }
    }

    createApp(App).mount("#app")
  </script>
</body>
</html>

2.效果展示

js手写(四):简单实现一个mini-vue