实现一个简洁版 Mini-Vue

跟着 coderwhy 的vue3视频 一步一步实现的javascript

mini-vue 源码连接html

Vue 三大核心系统

Vue源码包含三大核心:前端

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

三大模块系统.png

Mini-Vue

实现一个简洁版的 Mini-Vue, 包含三个模块:vue

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

渲染系统模块

虚拟DOM的优点

在传统的前端开发中,咱们编写本身的HTML,最终被渲染到浏览器上。java

而目前框架都会引入虚拟DOM来对真实的DOM进行抽象,这样作有不少的好处node

  1. 首先是能够对真实的元素节点进行抽象,抽象成VNode(虚拟节点),这样方便后续对其进行操做
    1. 由于对于直接操做DOM来讲是有不少限制的,好比diff、clone等等,可是使用js来操做这些就会变得简单
    2. 可使用js来表达很是多的逻辑,而对于DOM自己来讲是很是不方便的
  2. 其次是方便实现跨平台,包括你能够将VNode节点渲染成任意你想要的节点
    1. 好比渲染在WebGL,SSR,Native(ios,Android)上等等
    2. 而且Vue容许你开发属于本身的渲染器(renderer),在其余的平台上渲染

渲染系统的实现

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

  1. h 函数用于返回一个VNode对象ios

  2. mount函数: 用于将VNode挂载在DOM上git

  3. patch函数: 用于俩个VNode进行对比,判断如何处理新的VNodegithub

h 函数的实现

h函数的做用就是 生成VNode, 而vnode本质上是一个JavaScript对象

实现一个h 函数很简单,直接返回一个VNode对象便可

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

  return {
    tag,
    props,
    children
  }
}
复制代码

mount 函数的实现

mount 函数的做用就是 挂载VNode, 将vnode挂载DOM元素上并显示在浏览器上

实现思路:

  1. 根据 tag , 建立HTML元素,而且存到 vnode的el中 (目前只考虑 标签 ,不考虑组件)

  2. 处理 props 属性 (目前只考虑俩种状况)

    1. 若是以 on 开头,那么就是监听事件
    2. 若是是普通属性直接经过 setAttribute 添加便可
  3. 处理子节点(只考虑俩种状况:字符串和数组)

    1. 若是是 字符串, 那么就直接设置 textContent
    2. 若是数组,那么就遍历中调用 mount 函数

代码以下:

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

  return {
    tag,
    props,
    children
  }
}


const mount = (vnode, container) => {
  // 1. 建立 html 元素
  const el = vnode.el = document.createElement(vnode.tag)

  // 2. 处理 props属性
  if (vnode.props) {
    for (const key in vnode.props) {
      if (!vnode.props.hasOwnProperty(key)) {return}
      const value = vnode.props[key]

      if (key.startsWith('on')) {
        el.addEventListener(key.slice(2).toLowerCase(), value)
      } else {
        el.setAttribute(key, value)
      }
    }
  }

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

  container.appendChild(el)
}

复制代码

这样就能实现简单的渲染啦~

<body>
<div id="app"></div>
<script src="renderer.js"></script>
<script> const vnode = h('div', { class: 'wangpf' }, [ h('h2', { class: 'title' }, 'hello,I am wangpf'), h('div', null, '当前计数:100'), h('button', { class: 'btn' }, '+1') ]) mount(vnode, document.querySelector('#app')) </script>
</body>
复制代码

test

patch 函数

patch 函数做用就是 对比俩个新旧vnode,将不一样的给替换掉,运用到了 diff 。

对 patch 函数的实现,分为俩种状况 (n1为旧的vnode,n2为新的vnode)

  • n1 和 n2 是不一样类型的节点 (删除n1,挂载n2)
    • 找到 n1 的 el 父节点,删除原来 n1 节点的el
    • 挂载 n2 节点 到 n1的el父节点上
  • n1 和 n2 是相同的节点
    • 处理 props 的状况
      • 先将新节点的 props 所有挂载到 el 上
      • 判断旧节点的 props 是否不须要在新节点上, 若是不须要,那么删除对应的属性
    • 处理 children 的状况
      • 若是新阶段是一个字符串类型,那么直接替换
      • 若是新节点是不一样一个字符串类型
        • 旧节点是一个字符串类型
          • 将el 内容 设为 空字符串
          • 遍历新节点,挂载到el上
        • 旧节点是一个数组类型
          • 取出数组最小长度
          • 遍历全部节点,新节点和旧节点进行 patch 操做
          • 若是新节点长度大于旧节点,那么剩余的新节点就挂载
          • 若是旧节点长度大于新节点,那么剩余的旧节点就卸载

代码实现:

const patch = (n1, n2) => {
  if (n1.tag !== n2.tag) {
    const n1ElParent = n1.el.parentElement
    n1ElParent.removeChild(n1.el)
    mount(n2, n1ElParent)
  } else {
    // 1.取出 element对象,而且在 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 newProps) {
      if (!newProps.hasOwnProperty(key)) {return}
      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) {
      if (!newProps.hasOwnProperty(key)) {return}
      if (key.startsWith('on')) {
        el.removeEventListener(key.slice(2).toLowerCase())
      }
      if (!(key in newProps)) {
        el.removeAttribute(key)
      }
    }

    // 3. 处理 children
    const oldChildren = n1.children || []
    const newChildren = n2.children || []
    if (typeof newChildren === 'string') { // 状况一
      if (typeof oldChildren === 'string') {
        if (newChildren !== oldChildren) {
          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]
        // newChildren : [v1,v5,v7,v8,v9]
        const commonLength = Math.min(newChildren.length, oldChildren.length)
        for (let i = 0; i < commonLength; i++) {
          patch(oldChildren[i], newChildren[i])
        }

        if (newChildren.length > oldChildren.length) {
          newChildren.slice(oldChildren.length).forEach(item => {
            mount(item, el)
          })
        }

        if (newChildren.length < oldChildren.length) {
          oldChildren.slice(newChildren.length).forEach(item => {
            el.removeChild(item.el)
          })
        }
      }
    }
  }
}
复制代码
<body>
<div id="app"></div>
<script src="renderer.js"></script>
<script> // 1.经过 h 函数建立一个 vnode const vnode = h('div', { class: 'wangpf', id: 'aaa' }, [ h('h2', { class: 'title' }, 'hello,I am wangpf'), h('div', null, '当前计数:100'), h('button', { class: 'btn' }, '+1') ]) // 2. 经过 mount 函数, 将 vnode 挂载在div#app上 mount(vnode, document.querySelector('#app')) // 3. 建立新的 vnode setTimeout(() => { const newVnode = h('div', { class: 'pf', id: 'aaa' }, [ h('h2', { class: 'title' }, 'hello,I am wangpf'), h('div', null, '当前计数:0'), h('button', { class: 'btn222' }, '-1') ]) patch(vnode, newVnode) }, 2000) </script>
</body>
复制代码

当定时器达到2s后, 新的vnode 会替换掉旧的vnode,经过 ptach 函数来diff出不一样的地方进行替换。

大体上这就这样简单的实现一下渲染系统模块,分别有 h函数(返回vnode对象)、mount函数(用于挂载到页面上)、patch函数(对比新旧vnode,更新为最新的)

响应式系统模块的实现

响应式模块是vue的重中之重,vue2版本是经过 Object.defineProperty 来进行对数据进行依赖收集劫持的 , vue3版本是经过 proxy 来实现的

为何使用proxy的缘由

深刻响应式原理 — Vue.js (vuejs.org)

换为 proxy 缘由在于 defineProperty 这个API虽然兼容性好,可是不能检测到对象和数组的变化,好比对对象的新增属性,咱们须要去手动的给该属性收集依赖(经过**$set**),才能实现响应式。 对于 proxy来讲, Proxy 是劫持的整个对象,不须要作特殊处理 (我以为这个为何换为 proxy 的根本缘由)

代码实现思路

雏形的响应式系统: 发布订阅的思想

// 响应式系统模块
class Dep {
  constructor() {
    this.subscribers = new Set()
  }

  addEffect(effect) {
    this.subscribers.add(effect)
  }

  notify() {
    this.subscribers.forEach(effect => {
      effect()
    })
  }
}


const info = { counter: 100 }
const doubleCounter = () => {
  console.log(info.counter * 2)
}

const multiplyCounter = () => {
  console.log(info.counter * info.counter)
}

const dep = new Dep()
dep.addEffect(doubleCounter)
dep.addEffect(multiplyCounter)


setInterval(() => {
  info.counter++
  dep.notify()
}, 2000)
复制代码

上述代码有不少不足之处,只要数据发生变化就得手动去调用。

咱们但愿数据只要一发生变化,那么就自动的去收集依赖并执行

因此改进了以下:

const dep = new Dep()

const watchEffect = (effect) => {
  dep.addEffect(effect)
}

const info = { counter: 100 }

watchEffect(() => {
  console.log(info.counter * 2)
})
watchEffect(() => {
  console.log(info.counter * info.counter)
})


setInterval(() => {
  info.counter++
  dep.notify()
}, 2000)
复制代码

我就用 watchEffect 来统一管理它, 只不过须要在 watchEffect 函数中执行逻辑。

但这仍是有些不足,好比不知道是谁的逻辑,并且并非自动收集依赖

所以,再次进行改进,以下:

// 响应式系统模块
class Dep {
  constructor() {
    this.subscribers = new Set()
  }

  depend() {
    if (activeEffect) {
      this.subscribers.add(activeEffect)
    }
  }

  notify() {
    this.subscribers.forEach(effect => {
      effect()
    })
  }
}

const dep = new Dep()
let activeEffect = null
const watchEffect = (effect) => {
  activeEffect = effect
  dep.depend()
  activeEffect = null
}

const info = { counter: 100 }

watchEffect(() => {
  console.log(info.counter * 2)
})
watchEffect(() => {
  console.log(info.counter * info.counter)
})


setInterval(() => {
  info.counter++
  dep.notify()
}, 2000)
复制代码

用 depend 来取代替 addEffect , 这样作的目的是 不须要去知道 subscribers 添加的具体是什么

可是呢, 这样作会使得对 info 整个有依赖, 若是我想监听 info 的某一个属性,全部咱们须要有一个数据劫持的方法来实现。

这时候就能够用vue2响应式原理的思想来实现了, 经过 Object.defineProperty (Vue2响应式原理的核心)

使用 Object.defineProperty 来实现

// 响应式系统模块
class Dep {
  constructor() {
    this.subscribers = new Set()
  }

  depend() {
    if (activeEffect) {
      this.subscribers.add(activeEffect)
    }
  }

  notify() {
    this.subscribers.forEach(effect => {
      effect()
    })
  }
}

let activeEffect = null
const watchEffect = (effect) => {
  activeEffect = effect
  effect()  // vue3 中 watchEffect 就会默认执行一次
  activeEffect = null
}


const targetMap = new WeakMap()

const getDep = (target, key) => {
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }

  let dep = depsMap.get(key)
  if (!dep) {
    dep = new Dep()
    depsMap.set(key, dep)
  }
  return dep
}


// vue2 数据劫持原理
const 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 })

watchEffect(() => {
  console.log(info.counter * 2)
})
watchEffect(() => {
  console.log(info.counter * info.counter)
})

info.counter++

// 70 10000
// 67 202
// 70 10201
复制代码

defineProperty 已经说过了,因此咱们可使用proxyreactive 函数进行重构

使用 Proxy 来实现

// vue3 proxy 数据劫持
const reactive = (raw) => {
  return new Proxy(raw, {
    get(target, p, receiver) {
      const dep = getDep(target, p)
      dep.depend()
      return target[p]
    },
    set(target, p, newValue, receiver) {
      const dep = getDep(target, p)
      target[p] = newValue
      dep.notify()
    }
  })
}
复制代码

应用程序入口模块的实现

上述已经实现了 渲染系统模块和响应式系统模块,接下来咱们就差最后一步了,模仿一下vue3 使用 createApp函数 做为入口 以及mount函数将其挂载到页面上

从框架的层面来讲,咱们须要有俩部份内容:

  • createApp 用于建立一个app对象
  • 该app对象有一个 mount 方法,能够将根组件挂载到某一个dom元素上。
const createApp = (rootComponent) => {
  return {
    mount(selector) {
      let isMounted = false
      let preVnode = null

      watchEffect(() => {
        // 首次须要挂载, 后边就 patch
        if (!isMounted) {
          preVnode = rootComponent.render()
          mount(preVnode, document.querySelector(selector))
          isMounted = true
        } else {
          const newVnode = rootComponent.render()
          patch(preVnode, newVnode)
          preVnode = newVnode
        }
      })
    }
  }
}
复制代码

实现效果

// 1. 建立根组件
  const App = {
    data: reactive({
      counter: 0
    }),
    render() {
      return h('div', null, [
        h('h2', null, `计数:${this.data.counter}`),
        h('button', {
          onClick: () => {this.data.counter++}
        }, '+1')
      ])
    }
  }
  
  // 2. 挂载根组件
  createApp(App).mount('#app')
复制代码

test1

点击便可完成加一操做!

相关文章
相关标签/搜索