vue3学习 --- 核心功能的简单实现

605 阅读5分钟

我们传统的前端开发中,我们是编写自己的HTML,最终被渲染到浏览器上的

IbkR7t.png

但是这样存在一个问题,就是dom元素本身是非常庞大的,每一个node节点上的属性是非常多的。

频繁操作dom必然是比较损耗性能 ,所以目前框架都会引入虚拟DOM来对真实的DOM进行抽象

VDOM

  • vdom和dom中的每一个节点都是一一映射的,每一个vnode对应一个node

  • vdom是使用JavaScript表示的树结构,所以相比直接操作dom,使用JavaScript来操作vdom更为的灵活,限制更少

    更方便对节点进行逻辑操作

  • 每一个vnode上只存在必要的属性,所以相对于node而言,vnode是十分轻量级的

  • vdom本质上是一个中间代码,我们可以将VNode节点渲染成任意你想要的节点

    • 如渲染在canvas、WebGL、SSR、Native(iOS、Android),浏览器上
    • Vue允许你开发属于自己的渲染器(renderer),在其他的平台上渲染

IbkW2z.png

三大核心系统

  • Compiler模块:编译模板系统

  • Runtime模块:

    • 在运行的时候,将vdom转换为具体的dom或控件等
    • 也可以称之为Renderer模块,真正渲染的模块
  • Reactivity模块:响应式系统

真实DOM渲染

runtime.js

// 生成对应的vnode
const h = (tag, props, children) => ({
  tag,
  props,
  children
})


// vdom => dom
function mount(vnode, container) {
  // 挂载节点,并在虚拟节点上存储一份
  const el = vnode.el = document.createElement(vnode.tag)

  if (vnode.props) {
    for (const key in vnode.props) {
      const value = vnode.props[key]

      // 事件处理,例如onClick
      if (key.startsWith('on')) {
        const eventName = key.slice(2).toLowerCase()
				
        // 添加事件
        el.addEventListener(eventName, value)
      } else {
        // 挂载属性
        el.setAttribute(key, value)
      }
    }
  }

  if (vnode.children) {
    // 纯文本 --- 例如 '当前计数...'
    if (typeof vnode.children === 'string') {
      el.textContent = vnode.children
    } else {
      // 存在子节点
      vnode.children.forEach(item => mount(item, el))
    }
  }

  // 将生成的真实DOM 挂载到 挂载点
  container.appendChild(el)
}

使用

<body>
 <div id="app"></div>

 <script src="./runtime.js"></script>

 <script>
   // 虚拟dom
   const vnode = h('div', { class: 'foo' }, [
     h('div', null, '当前计数: 0'),
     h('button', {
       onClick: () => console.log('clicked')
     }, 'increment')
   ])

   // 挂载
   mount(vnode, document.getElementById('app'))
 </script>
</body>

渲染器

更新操作

// 过2s后,修改vnode数据,进行patch操作
setTimeout(() => {
  const newVnode = h('div', { class: 'baz', id: 'foo' },  [
    h('h2', null, 'klaus'),
    h('button', {
      onClick: () => console.log('clicked')
    }, 'click me')
  ])
  patch(vNode, newVnode)
}, 2000)

patch函数

// patch --- 更新节点
function patch(oldNode, newNode) {
  // 新旧节点类型不同 直接替换
  if (oldNode.tag !== newNode.tag) {
    const parent = oldNode.el.parentElement

    parent.removeChild(oldNode.el)
    mount(newNode, parent)
  } else {
    const el = newNode.el = oldNode.el

    // patch props
    const newProps = newNode.props || {}
    const oldProps = oldNode.props || {}

    // 新节点属性 一定在patch后的元素上
    for (const key in newProps) {
      const newValue = newProps[key]
      const oldValue = oldProps[key]

      // 属性值可能是函数,此时newValue 和 oldValue恒不等
      // 因此需要将其转换为字符串后再判断前后函数的函数体是否一致
      if (newValue.toString() !== oldValue.toString()) {
        if (key.startsWith('on')) {
          const eventName = key.slice(2).toLowerCase()
          el.addEventListener(eventName, newValue)
        } else {
          el.setAttribute(key, newValue)
        }
      }
    }

    // 移除无用的旧节点
    for(const key in oldProps) {
      if (!(key in newProps)) {
        if (key.startsWith('on')) {
          const eventName = key.slice(2).toLowerCase()
          el.removeEventListener(eventName, oldProps[key])
        } else {
          el.removeAttribute(key)
        }
      }
    }

    // patch children
    if(typeof newNode.children === 'string') {
      // 排除类似 newValue.children = 'aaa'
      // oldNode.children = 'aaa' 的情况
      if (newNode.children !== oldNode.children) {
        el.innerHTML = newNode.children
      }
    } else {
      const newChildren = newNode.children || []
      const oldChildren = oldNode.children || []

      // newNode.children是数组
      const length = Math.min(newChildren.length, oldChildren.length)

      for(let i = 0; i < length; i++) {
        patch(oldChildren[i], newChildren[i])
      }

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

响应式系统

v1

class Dep {
  constructor() {
    // 保证所有的依赖都只会被添加一次
    // 避免因为依赖的重复添加,导致数据发生重复更新
    this.subscribers = new Set()
  }

  // 值的改变会引起依赖的改变
  // 所以可以认为 依赖随值的改变 是 值改变产生的副作用
  addEffect(effect) {
    this.subscribers.add(effect)
  }

  // 更新所有依赖
  notify() {
    this.subscribers.forEach(effect => effect())
  }
}

const dep = new Dep()

// 测试代码
let info = { num: 0, name: 'coderwxf' }

function double() {
  console.log(info.num * 2)
}

function power() {
  console.log(info.num * info.num)
}

function changeName() {
  info.name = 'Klaus'
  console.log(info.name)
}

// 手动添加依赖
dep.addEffect(double)
dep.addEffect(power)

// 因为只有一个dep,所以所有的依赖都会添加到一个dep中
// 当num发生改变的时候,changeName这个副作用函数是不应该被执行的
// 但是此时却被执行了,所以不同的依赖应该添加到不同的Dep实例中
dep.addEffect(changeName)


info.num++
dep.notify()

v1存在着几个问题:

  1. 所有的依赖都是要手动添加的,而且更新的时候也都是要手动通知更新依赖

  2. 一个副作用函数中,可能存在不止一个依赖,不同依赖发生改变的时候,需要更新的副作用函数也是不一样的

    但是v1中如果某一个依赖发生了改变,那么所有的副作用函数都会重新被触发

V2

class Dep {
  constructor() {
    // 收集依赖的set集合
    this.subscribers = new Set()
  }

  depend() {
    // 如果有副作用函数,那么就存储副作用函数
    if (activeEffect) {
      this.subscribers.add(activeEffect)
    }
  }

  notify() {
    // 通知所有的依赖,更新数据
    this.subscribers.forEach(effect => effect())
  }
}

// 设置全局effect,目的是为了让dep和副作用函数解耦
let activeEffect = null

function watchEffect(effect) {
  activeEffect = effect
  // 刚开始的时候就要执行一次,以便于收集所有的依赖
  effect()
  activeEffect = null
}

// 最外层需要定义为WeakMap,以便于对应的map不会成为target在gc中的依赖
// 也就是说方便我们直接使用类似info = null的方式去移除数据,
// 而不会因为targetMap中存在依赖,而无法移除数据
// 另一方面 weakMap的key需要设置成对象,而target本身就是一个对象 
const targetMap = new WeakMap()

// 获取dep实例
function getDep(target, key) {
  let depMap = targetMap.get(target)

  if (!depMap) {
    depMap = new Map()
    targetMap.set(target, depMap)
  }

  let dep = depMap.get(key)

  if (!dep) {
    dep = new Dep()
    depMap.set(key, dep)
  }

  return dep
}

// vue2的方式,使用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 (newValue !== value) {
          value = newValue
          dep.notify()
        }
      }
    })
  })

  return raw
}

// test code
let info = reactive({ num: 0, name: 'coderwxf' })
let foo = reactive({ width: 200, height: 300 })

watchEffect(function () {
  console.log("effect1:", info.num * 2)
})

watchEffect(function () {
  console.log("effect2:", info.num * info.num)
})

watchEffect(function () {
  console.log("effect3:", info.name)
})

// info.num++
info.name = 'Alex'

在vue3, 使用proxy来取代了definedProperty

function reactive(raw) {
  // 返回的是一个新的代理对象
  return new Proxy(raw, {
    // target 指代的就是代理对象,在这里就是raw对象
    // key是对象的每一个属性
    get(target, key) {
      const dep = getDep(target, key)
      dep.depend()
      // 因为返回的是原始对象上的值,而不是代理对象上的值,因此不会产生死循环
      return target[key]
    },

    set(target, key, newV) {
      const dep = getDep(target, key)
      // 修改完值以后,再更新数据
      target[key] = newV
      dep.notify()
    }
  })
}

框架外层API设计

index.js

function createApp(root) {
  return {
    mount (selector) {
      const el = document.querySelector(selector)

      let isMounted = false
      let oldNode = null

      // 设置watch监听
      // 第一次 --- mount
      // 第二次 --- patch
      watchEffect(() => {
        if (!isMounted) {
          oldNode = root.render()
          mount(oldNode, el)
          isMounted = true
        } else {
          const newNode = root.render()
          patch(oldNode, newNode)
        }
      })
    }
  }
}

index.html

<!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>Mini Vue</title>
</head>
<body>
 <div id="app"></div>

 <script src="./runtime.js"></script>
 <script src="./reactive.js"></script>
 <script src="./index.js"></script>

 <script>
   const App = {
     data: reactive({
       count: 0
     }),

     // 使用render函数来替换template
     render() {
      return h('div', { class: 'old', id: 'foo' }, [
        h('div', null, `当前计数: ${this.data.count}`),
        h('button', {
          // 事件是被绑定到元素上的
          // 所以为了获取正确的this,这里需要使用箭头函数
          onClick: () => this.data.count++
        }, 'increment')
      ])
     }
   }

   const app = createApp(App)
   app.mount('#app')
 </script>
</body>
</html>