實現一個簡潔版 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

點擊便可完成加一操做!

相關文章
相關標籤/搜索