少年,你想不想实现Mini-Vue?

142 阅读10分钟

实现Mini-Vue

它包含三个模块:

  • 渲染系统模块(runtime)
  • 响应式系统模块(reactivity)
  • 应用程序入口模块

渲染系统模块

该模块负责vnode转化成真实DOM

包含三个功能:

功能一:h() ,用于返回一个VNode对象;

功能二:mount() ,用于将VNode挂载到DOM上

功能三:patch() ,用于对比两个VNode,决定如何处理新的VNode

h()

返回一个vnode对象

目标

创建一个vnode

const vnode = h('div', {class: 'zsf'}, [
  h('h2', null, '当前计数:100'),
  h('button', null, '+1')
])
console.log(vnode)

参数

参数1 html元素(组件暂不考虑)

参数2 元素的属性

参数3 元素内容(子节点)

返回值

一个vnode对象

const h = (tag, props, children) => {
  return {
    tag,
    props,
    children
  }
}

mount()

将VNode挂载到DOM上

目标

使用mount()将vnode挂载在div#app上

mount(vnode, document.querySelector('#app'))

参数

参数1 vnode

参数2 父元素

4步关键

第1步,创建出真实元素el,并在vnode上保留一份(后面通过vnode拿到真实el方便);

第2步,处理props遍历这个对象,如果是属性就使用setAttribute() 给el设置属性;如果是事件就使用addEventListener() 给el添加事件监听;

第3步,处理children,如果vnode.children是字符串,直接赋值给el.textContent就行;如果vnode.children有子节点遍历那些子节点,并递归调用mount()

第4步,使用appendChild() 将el挂载到container

const mount = (vnode, container) => {
  // 1.vnode -> element,创建出真实元素,并且在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).toLocaleLowerCase(), 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()

目标

创建新的vnode,然后新旧vnode使用diff算法,找出要修改真实DOM的哪个地方

// 1.使用h()创建一个vnode
const vnode = h('div', {class: 'zsf', id: 'aa'}, [
  h('h2', null, '当前计数:100'),
  h('button', null, '+1')
])
// 2.使用mount()将vnode挂载在div#app上
mount(vnode, document.querySelector('#app'))
​
// 3.创建新的vnode
const vnode1 = h('div', {class: 'hhh', id: 'aa'}, '哈哈哈')
patch(vnode, vnode1)

参数

参数1 旧vnode

参数2 新vnode

const patch = (n1, n2) => {
  // 先看看类型是否一样,不一样直接移除旧vnode,然后挂载新vnode
  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 获取所有的newProps,添加到el
    for (const key in oldProps) {
      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 删除oldProps
    for (const key in oldProps) {
      // 如果旧vnode有的属性在新的vnode中并没有,直接移除
      if (key.startsWith('on')) { // 对事件监听的判断
          const value = oldProps[key]
          el.removeEventListener(key.slice(2).toLowerCase(), value)
      } 
      if(!(key in newProps)) {
        el.removeAttribute(key)
      }
    }
    // 3.处理children
    const oldChildren = n1.children || []
    const newChildren = n2.children || []
​
    if (typeof oldChildren === 'string') { // newChildren本身是一个string
      if (typeof oldChildren === 'string') {
        el.textContent = newChildren
      } else {
        el.innerHTML = newChildren
      }
    } else { // newChildren本身是一个数组
      if (typeof oldChildren === 'string') {
        el.innerHTML = ''
        newChildren.forEach((item) => {
          mount(item, el)
        })
      } else {
        // oldChildren: [v1, v2, v3 v8, v9]
        // newChildren: [v1, v5, v6]
        const commonLength = Math.min(oldChildren.length, newChildren.length)
        // 1.前面有相同节点进行patch操作
        for (let i = 0; i < commonLength; i++) {
          patch(oldChildren[i], newChildren[i])
        }
​
        // 2.newChildren > oldChildren,新的长,patch完进行添加操作
        if (newChildren.length > oldChildren.length) {
          newChildren.slice(oldChildren.length).forEach((item) => {
            mount(item, el)
          })
        }
​
        // 2.oldChildren > newChildren,旧的长,patch完进行移除操作
        if (newChildren.length < oldChildren.length) {
          oldChildren.slice(newChildren.length).forEach((item) => {
            el.removeChild(item.el)
          })
        }
      }
    }
  }
}

为什么需要commonLength?

如果oldChildren比newChildren长,diff完需要移除操作,比如下面的情况

oldChildren: [v1, v2, v3, v8, v9]
newChildren: [v1, v5, v6]

如果newChildren比oldChildren长,diff完需要添加操作,比如下面的情况

oldChildren: [v1, v2, v3]
newChildren: [v1, v5, v6, v8, v9]

响应式系统模块

该模块负责数据的响应式

收集依赖

如何收集依赖?

看这么一段代码

class Dep {
  constructor() {
    this.subscribers = new Set()
  }
  // 收集订阅者(依赖函数或者副作用)
  addEffect(effect) {
    this.subscribers.add(effect)
  }
}
function watchEffect(effect) {
  dep.addEffect(effect)
}
​
const dep = new Dep()
​
const info = {
  counter: 100
}
​
watchEffect(() => {
  console.log(info.counter)
})

Dep类中存放一个收集依赖的subscribers集合

watchEffect() 传入对响应式对象有依赖的函数,然后将该函数视为订阅者加入subscribers集合;

subscribers为什么使用集合?

集合有一个特点,就是不允许重复

当某个函数对某个响应式对象重复依赖时,只需要加入一次subscribers即可。

但是实际上,我们采用另一种写法

class Dep {
  constructor() {
    this.subscribers = new Set()
  }
  // 收集订阅者(依赖函数或者副作用)
  depend() {
    if (activeEffect) {
      this.subscribers.add(activeEffect)
    }
  }
}
​
let activeEffect = null
function watchEffect(effect) {
  activeEffect = effect
  dep.depend()
  effect()
  activeEffect = null
}
​
const dep = new Dep()
​
const info = {
  counter: 100
}
​
watchEffect(() => {
  console.log(info.counter * 2)
})
​
info.counter++

与上一种写法不同的是

维护一个指向订阅者的指针activeEffect

当订阅者加入subscribers集合activeEffect指向null,为下一个订阅者服务;

当然,那些函数加入到订阅者后,在通知发布之前,默认先执行一次,获取所依赖响应式对象的初始值;

但是,还存在一个问题

响应式对象有很多属性时,某个函数只是依赖了counter,当响应式对象的其它属性发生改变时,难道也要执行notify()? 当然不要!所以要维护一个专门的dep。比如counter有它的dep,其它属性有只属于他们的dep

怎么维护一个属性专门的dep呢?

vue2数据劫持

讨论维护一个专门的dep之前,先聊聊数据劫持

获取一个对象的某个属性,比如info.name,内部就会调用name属性的get() ;

修改一个对象的某个属性,比如info.name = 'lll' ,内部就会调用name属性的set() ;

数据劫持并不会对该对象有什么影响,但是我们可以在劫持的get()和set() 中做点事情,比如针对性地收集依赖,换句话说,每个属性都有属于自己的dep,这样收集起来后,就可以针对性去通知,就不会出现我对a有依赖,b变了,然后通知我:b变了...

const targetMap = new WeakMap()
function getDep(target, key) {
  // 1.根据对象(target)取出depsMap对象
  let depsMap = targetMap.get(target)
  // 一开始targetMap是空的,所以depsMap也是空的
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }
  // 根据depsMap对象,取出dep对象
  let dep = depsMap.get(key)
  if (!dep) {
    dep = new Dep()
    depsMap.set(key, dep)
  }
  return dep
}
​
// vue2进行数据劫持
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
}

这里面牵扯的东西有点多,静下心来,我慢慢给你梳理~

1.获取每个属性专门的dep,所以需要getDep(obj, key) :获取obj的一个key专属的dep;

2.一个对象obj有很多dep,他们之间的关系用一个weakMap存起来,weakMap大概是这样的:

{ {dep1, dep2, ...}: obj },因为还有其它对象也要这样存,所以使用WeakMap再合适不过了,并且WeakMap是弱引用,也有助于垃圾回收~,这里就不多说了;

3.所以通过targetMap.get(obj) 就可以获得obj所有的dep啦;

4.但一开始targetMap是空的,所以targetMap.set(obj, depsMap)

5.已经拿到obj所有的dep,需要获取key对应的dep,所以有depsMap.get(key) ,depsMap大概是这样的:

{key1: dep1, key2: dep2,...}

6.但是一开始dep也可能是空的,所以需要depsMap.set(key, dep) ;

7.最后,在属性的get()触发时,就可以执行dep.depend() ,将依赖该属性的函数放入该属性专属的订阅者;

8.在属性的set()触发时,就可以执行dep.notify() ,只通知依赖了该属性的订阅者

当你理解到这里,有没有被这种设计所惊艳到?

测试

class Dep {
  constructor() {
    this.subscribers = new Set()
  }
  // 收集订阅者(依赖函数或者副作用)
  depend() {
    if (activeEffect) {
      this.subscribers.add(activeEffect)
    }
    
  }
  // 发布通知,每位订阅者执行一次
  notify() {
    this.subscribers.forEach((effect) => {
      effect()
    })
  }
}
​
let activeEffect = null
function watchEffect(effect) {
  activeEffect = effect
  effect()
  activeEffect = null
}
​
const targetMap = new WeakMap()
function getDep(target, key) {
  // 1.根据对象(target)取出Map对象
  let depsMap = targetMap.get(target)
  // 一开始targetMap是空的,所以depsMap也是空的
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }
  // 根据Map对象,取出dep对象
  let dep = depsMap.get(key)
  if (!dep) {
    dep = new Dep()
    depsMap.set(key, dep)
  }
  return dep
}
​
// vue2进行数据劫持
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
}
const info = reactive({counter: 100, name: 'zsf'})
const foo = reactive({height: 1.88})
​
watchEffect(() => {
  console.log(info.counter * 2)
})
watchEffect(() => {
  console.log(foo.height)
})
​
info.counter++
​

你会发现只有依赖counter的函数重新执行了

vue3数据劫持

// vue3进行数据劫持
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相差无几,不过使用了Proxy做一层代理,更加灵活

为什么vue3选择Proxy?

  • defineProperty劫持的是对象的属性,需要多次劫持,而Proxy劫持的是整个对象;
  • Proxy能观察的类型比defineProperty更丰富,捕获器更多(has、deleteProperty等等);

发布通知

当响应式对象发生改变,如何通知各位订阅者呢?

看这么一段代码

class Dep {
  constructor() {
    this.subscribers = new Set()
  }
  // 收集订阅者(依赖函数或者副作用)
  addEffect(effect) {
    this.subscribers.add(effect)
  }
  // 发布通知,每位订阅者执行一次
  notify() {
    this.subscribers.forEach((effect) => {
      effect()
    })
  }
}
​
function watchEffect(effect) {
  dep.addEffect(effect)
}
​
const dep = new Dep()
​
const info = {
  counter: 100
}
​
watchEffect(() => {
  console.log(info.counter * 2)
})
​
info.counter++
dep.notify()

Dep类中有个发布通知的方法notify()

当响应式对象发生改变时,通知每位订阅者,也就是对该响应式对象有依赖的所有函数都执行一次

应用程序入口模块

该模块实现createApp(App).mount('#app')

createApp()

参数 根组件

返回值 一个有mount() 的对象

function createApp(rootComponent) {
  return {
    mount(selector) {
    }
  }
}

mount()

参数 选择器

将根组件挂载在该选择器上

function createApp(rootComponent) {
  return {
    mount(selector) {
      const container = document.querySelector(selector)
      let isMounted = false
      let oldVNode = null
​
      watchEffect(function() {
        if (!isMounted) {
          oldVNode = rootComponent.render()
          mount(oldVNode, container)
          isMounted = true
        } else {
          const newVNode = rootComponent.render()
          patch(oldVNode, newVNode)
          oldVNode = newVNode
        }
      })
    }
  }
}

如果第一次调用app.mount(),挂载;

第二次调用,已经挂载,要刷新;

所以要设置个状态isMounted,初始值为false,默认未挂载;

挂载之后,isMounted为true

如果数据发生更新,要对oldVNodenewVNode进行patch() 的操作;

当数据更新完,newVNode变oldVNode,等待下一次patch();

每次收集完依赖,VNode已更新,要执行一次上述过程,所以要将上述过程传入watchEffect()

完整代码

index.html

<div id="app"></div>
<script src="./reactivity.js"></script>
<script src="./renderer.js"></script>
<script src="./index.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++
            console.log(this.data.counter)
          }
        }, '+1')
      ])
    }
  }
  // 挂载根组件
  const app = createApp(App)
  app.mount('#app')
</script>

index.js

function createApp(rootComponent) {
  return {
    mount(selector) {
      const container = document.querySelector(selector)
      let isMounted = false
      let oldVNode = null
​
      watchEffect(function() {
        if (!isMounted) {
          oldVNode = rootComponent.render()
          mount(oldVNode, container)
          isMounted = true
        } else {
          const newVNode = rootComponent.render()
          patch(oldVNode, newVNode)
          oldVNode = newVNode
        }
      })
    }
  }
}

reactivity.js

class Dep {
  constructor() {
    this.subscribers = new Set()
  }
  // 收集订阅者(依赖函数或者副作用)
  depend() {
    if (activeEffect) {
      this.subscribers.add(activeEffect)
    }
  }
  // 发布通知,每位订阅者执行一次
  notify() {
    this.subscribers.forEach((effect) => {
      effect()
    })
  }
}
​
let activeEffect = null
function watchEffect(effect) {
  activeEffect = effect
  effect()
  activeEffect = null
}
​
const targetMap = new WeakMap()
function getDep(target, key) {
  // 1.根据对象(target)取出Map对象
  let depsMap = targetMap.get(target)
  // 一开始targetMap是空的,所以depsMap也是空的
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }
  // 根据Map对象,取出dep对象
  let dep = depsMap.get(key)
  if (!dep) {
    dep = new Dep()
    depsMap.set(key, dep)
  }
  return dep
}
​
// vue3进行数据劫持
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()
    }
  })
}
​
​

renderer.js

/**
 * h函数
 * @param {*} tag 
 * @param {*} props 
 * @param {*} children 
 * @returns 一个VNode,也就是js对象
 */
const h = (tag, props, children) => {
  return {
    tag,
    props,
    children
  }
}
/**
 * mount函数
 * @param {*} vnode 
 * @param {*} container 
 */
const mount = (vnode, container) => {
  // 1.vnode -> element,创建出真实元素,并且在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).toLocaleLowerCase(), 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) 
}
/**
 * 
 * @param {*} n1 
 * @param {*} n2 
 */
const patch = (n1, n2) => {
  // 先看看类型是否一样,不一样直接移除旧vnode,然后挂载新vnode
  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 获取所有的newProps,添加到el
    for (const key in oldProps) {
      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 删除oldProps
    for (const key in oldProps) {
      // 如果旧vnode有的属性在新的vnode中并没有,直接移除
      if (key.startsWith('on')) { // 对事件监听的判断
          const value = oldProps[key]
          el.removeEventListener(key.slice(2).toLowerCase(), value)
      } 
      if(!(key in newProps)) {
        el.removeAttribute(key)
      }
    }
    // 3.处理children
    const oldChildren = n1.children || []
    const newChildren = n2.children || []
​
    if (typeof oldChildren === 'string') { // newChildren本身是一个string
      if (typeof oldChildren === 'string') {
        el.textContent = newChildren
      } else {
        el.innerHTML = newChildren
      }
    } else { // newChildren本身是一个数组
      if (typeof oldChildren === 'string') {
        el.innerHTML = ''
        newChildren.forEach((item) => {
          mount(item, el)
        })
      } else {
        // oldChildren: [v1, v2, v3 v8, v9]
        // newChildren: [v1, v5, v6]
        const commonLength = Math.min(oldChildren.length, newChildren.length)
        // 1.前面有相同节点进行patch操作
        for (let i = 0; i < commonLength; i++) {
          patch(oldChildren[i], newChildren[i])
        }
​
        // 2.newChildren > oldChildren,新的长,patch完进行添加操作
        if (newChildren.length > oldChildren.length) {
          newChildren.slice(oldChildren.length).forEach((item) => {
            mount(item, el)
          })
        }
​
        // 2.oldChildren > newChildren,旧的长,patch完进行移除操作
        if (newChildren.length < oldChildren.length) {
          oldChildren.slice(newChildren.length).forEach((item) => {
            el.removeChild(item.el)
          })
        }
      }
    }
  }
}

其中renderer.jspatch() 部分较为复杂