手写mini-vue

275 阅读9分钟

准备工作

话不多说,直接分析代码,首先找一个空文件夹初始化一个项目

npm init -y

然后创建一个index.html和一个main.js,安装vite

npm install vite -D

此时的项目目录结构是这样的

|-- node_modules  
|-- index.html  
|-- main.js  
|-- package.json

然后在index.html中引入main.js

// 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>Document</title>
</head>
<body>
    <div id="app"></div>
    <script type="module" src="./main.js"></script>
</body>
</html>

注意type="module"加上这个之后我们才可以使用熟悉的ESModule

然后还要下载一个vue3.0的一个模块@vue/reactivity,我们的mini-vue需要依赖于这一个模块来实现

npm install @vue/reactivity -S

到此准备工作就准备完毕了

实现createApp

我们知道在vue3.0中,不再是new一个Vue实例了,而是使用一个createApp的方法来创建vue,我们先创建一个mini-vue文件夹,然后在下面创建一个createApp.js,此时的目录结构是这样的

|-- mini-vue  +
    |-- createApp.js  +
|-- node_modules  
|-- index.html  
|-- main.js  
|-- package.json

然后我们在main.js中写上下面代码

// main.js
import { createApp } from './mini-vue/createApp.js'


createApp().mount('#app')

由于我们还没写createApp函数,这样肯定是会报错的,我们先不管,先分析一下,createApp是需要传入一个组件的,而现在我们还没有这个组件,所以我们先在根目录创建一个App.js作为我们的组件

|-- mini-vue
    |-- createApp.js
|-- node_modules  
|-- App.js  +
|-- index.html  
|-- main.js  
|-- package.json
// App.js
export const App = {
    render(){
        const h1 = document.createElement('h1')
        h1.textContent = `Hello mini-vue`;
        return h1
    }
}

我们在Apprender方法中创建一个h1标签,然后再完善一下main.js

// main.js
import { createApp } from './mini-vue/createApp.js'
import {App} from './App'

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

然后我们修改package.json中的脚本,跑起项目

// package.json
"scripts": {
    "dev": "vite"
},

跑起项目之后我们发现报错了,报错的原因是因为我们的createApp返回的对象并没有mount方法,所以我们写一下createApp方法

// createApp.js
export function createApp(rootComponent){
    return {
        mount(rootContainer){
            
        }
    }
}

这样就不会报错了,但是页面里什么都没有,我们的目的是把创建的h1标签渲染出来插入到我们的容器内部故完善一下代码

// createApp.js
export function createApp(rootComponent){
    return {
        mount(rootContainer){
            const root = document.querySelector(rootContainer)
            const el = rootComponent.render()
            root.append(el)
        }
    }
}

这样render函数里创建的视图就展现出来了,我们还知道在vue3.0中组件都有一个setup方法,这个方法会把我们的数据返回故我们加一下setup方法

// App.js
import { ref } from './node_modules/@vue/reactivity/dist/reactivity.esm-browser.js'

export const App = {
    render(context){
        const h1 = document.createElement('h1')
        h1.textContent = `Hello mini-vue ${context.a.value}`;
        return h1;
    },
    setup(){
        const a = ref(10);
        window.a = a; //便于我们调试
        return {
            a
        }
    }
}

但是这样上面导入ref的路径太长了,不太方便(后续多个地方要用),所以我们修改一下,在mini-vue文件夹下创建一个index.js作为全部依赖的出口

|-- mini-vue
    |-- createApp.js
    |-- index.js  +
|-- node_modules  
|-- App.js  +
|-- index.html  
|-- main.js  
|-- package.json
// index.js
export * from '../node_modules/@vue/reactivity/dist/reactivity.esm-browser.js'

export * from './createApp'

然后App.js中的导入就可以换成

//App.js
import { ref } from './mini-vue/index'

...

修改完毕之后我们发现上面多给Apprender方法添加了一个参数context,而这个context我们希望是setup方法返回的对象,这样的话就可以在render中使用setup里定义的变量了,所以createApp继续完善

//createApp.js
export function createApp(rootComponent) {
    return {
        mount(rootContainer){
            const root = document.querySelector(rootContainer)
            const context = rootComponent.setup()
            const el = rootComponent.render(context)
            root.append(el)
        }
    }
}

这样的话a变量也在视图中了,由于我们上面把a赋值给了window.a所以我们可以在控制台操作一下window.a发现视图并没变化,也就是这里的数据并不是响应式的,如何把数据变成响应式的呢?这里又得使用vue3.0自带的一个方法effect,这个方法可以收集依赖,并在依赖改变时做出更新,故在createApp中导入effect

//createApp.js
import { effect } from './index'

export function createApp(rootComponent) {
    return {
        mount(rootContainer){
            const root = document.querySelector(rootContainer)
            const context = rootComponent.setup()

            effect(()=>{
                const el = rootComponent.render(context)
                root.append(el)
            })
        }
    }
}

然后我们在控制台操作a会发现视图虽然改变了,但是效果不太好,因为是一直叠加的,所以我们需要把之前的旧节点删除,再插入新节点

import { effect } from './index'

export function createApp(rootComponent) {
    return {
        mount(rootContainer){
            const root = document.querySelector(rootContainer)
            const context = rootComponent.setup()

            effect(()=>{
                root.textContent = ``; //更新前删除旧节点
                const el = rootComponent.render(context)
                root.append(el)
            })
        }
    }
}

这样我们再操作window.a就会发现视图可以比较完美的更新了。

实现虚拟dom

上面的代码虽然可以通过操作数据去更新视图了,但是我们发现操作的还是原生dom节点,这样的性能显然是不太好的,所以我们做一下处理,将App.js中的节点换成虚拟dom再渲染出来。

对于vue学的稍微深一点或者使用过iView组件库的朋友们来说,都知道vue中有一个h函数,这个函数能生成虚拟dom节点,那么什么是虚拟dom呢,虚拟dom其实就是使用js对象来模拟dom节点,我们先分析一个最简单的dom结构

//虚拟dom
{
    tag:"div",
    props:{
        class:"box"
    },
    children:"我是div"
}

我们用上面这种类型的对象来模拟一下dom,这就是虚拟dom,也就是我们的h函数需要返回这么一个对象, 我们写一下这个h函数

|-- mini-vue
    |-- createApp.js
    |-- index.js  
    |-- h.js  +
|-- node_modules  
|-- App.js  
|-- index.html  
|-- main.js  
|-- package.json
//h.js
export function h(tag, props, children) {
    return {
        tag,
        props,
        children
    }
}

这样虽然有了虚拟dom,但是还需要一个方法来对虚拟dom进行渲染,我们把渲染逻辑写在renderer.js中,先创建renderer.js

|-- mini-vue
    |-- createApp.js
    |-- index.js  
    |-- h.js  
    |-- renderer.js  +
|-- node_modules  
|-- App.js  
|-- index.html  
|-- main.js  
|-- package.json
//render.js
export function mountElement(vnode, container) {
    //渲染视图的方法 vnode -> dom
}

我们需要使用这个方法去把虚拟dom节点转换为真实dom节点,所以createApp.js需要修改一下

//createApp.js
import { effect } from './index'
import { mountElement } from './renderer'

export function createApp(rootComponent) {
    return {
        mount(rootContainer){
            const root = document.querySelector(rootContainer)
            const context = rootComponent.setup()

            effect(()=>{
                root.textContent = ``;
                const el = rootComponent.render(context)
                // root.append(el)
                mountElement(el, root) //使用这个方法渲染虚拟dom
            })
        }
    }
}

修改完毕之后我们再来想办法实现mountElement方法,我们再分析一下,操作步骤

  1. 创建dom节点
  2. 给dom添加属性
  3. 插入子元素

ok,分析完之后开干

//renderer.js
export function mountElement(vnode, container) {
    const { tag, props, children } = vnode;
    // 1. 创建dom
    const el = document.createElement(tag)
    vnode.el = el // 存储一下真实dom节点,后续会用到

    // 2. 给dom添加属性
    Object.keys(props).forEach(key => {
        el.setAttribute(key, props[key])
    })

    // 3. 处理子节点,要注意,子节点可能是多个dom组成的数组也可能是文本
    if(typeof children === 'string' || typeof children === 'number'){
        //子节点是文本节点,文本可能是字符串也可能是数字
        const textNode = document.createTextNode(children)
        el.append(textNode)
    }else if(Array.isArray(children)){
        //子节点是多个dom组成的数组 递归渲染
        children.forEach(v => mountElement(v, el))
    }
    
    //将创建的dom插入容器节点
    container.append(el)
}

然后我们修改一下App.js看看效果

// App.js
import { ref } from './vue/index'
import {h} from './vue/h'

export const App = {
    render(context){
        return h('div', { class:'red' }, [
            h('h1', {}, 10),
            h('h2', {}, 'hello vue'),
            h('h1', {}, context.a.value)
        ] );
    },
    setup(){
        const a = ref(10);
        window.a = a;
        return {
            a
        }
    }
}

我们会发现视图被渲染了,并且操作window.a视图也会发生改变,效果非常不错,这下效果就非常不错了,然后我们对renderer.js的代码进行抽离

//renderer.js

//创建dom
function createElement(tag) {
    return document.createElement(tag)
}

//操作属性
function patchProp(el, key, prevValue, nextValue) {
    if(nextValue == null){
        el.removeAttribute(key)
        return
    }
    el.setAttribute(key, nextValue)
}

//创建文本节点
function createTextNode(text) {
    return document.createTextNode(text)
}

//插入容器
function insert(el, parent) {
    parent.append(el)
}

export function mountElement(vnode, container) {
    const { tag, props, children } = vnode;
    // 1. 创建dom
    const el = createElement(tag)
    vnode.el = el // 存储一下真实dom节点,后续会用到

    // 2. 给dom添加属性
    Object.keys(props).forEach(key => {
        patchProp(el, key, null, props[key])
    })

    // 3. 处理子节点,要注意,子节点可能是多个dom组成的数组也可能是文本
    if(typeof children === 'string' || typeof children === 'number'){
        //子节点是文本节点,文本可能是字符串也可能是数字
        const textNode = createTextNode(children)
        insert(textNode, el)
    }else if(Array.isArray(children)){
        //子节点是多个dom组成的数组 递归渲染
        children.forEach(v => mountElement(v, el))
    }

    insert(el, container)
}

到此就完成了虚拟dom

完成diff算法

前面代码虽然实现了虚拟dom到真实dom的渲染,但是操作数据会发现页面其实是把所有元素都删除了然后重新渲染的,这样必然不是我们想要的,我们想要的是只让改变的元素进行更新,所以这里我们再实现一个简单版的diff来按需更新

// renderer.js

...

//n1 旧虚拟dom节点,n2 新虚拟dom节点
export function diff(n1, n2){
    //diff算法
}

然后修改一下createApp.js

// createApp.js
import { effect } from './index'
import { mountElement, diff } from './renderer'

export function createApp(rootComponent) {
    return {
        mount(rootContainer){
            const root = document.querySelector(rootContainer)
            const context = rootComponent.setup()
            let isMount = false
            let prevVnode

            effect(()=>{
                if(!isMount){
                    //第一次渲染,插入元素
                    isMount = true
                    root.textContent = ``;
                    const el = rootComponent.render(context)
                    prevVnode = el //将第一次的虚拟节点存储起来
                    mountElement(el, root)
                }else{
                    //不是第一次渲染用diff算法,只更新修改的元素
                    const nextVNode = rootComponent.render(context)
                    diff(prevVnode, nextVNode)
                    prevVnode = nextVNode //将这次的虚拟节点存储起来
                }
            })
        }
    }
}

ok, 准备工作完毕,接下来分析一下diff中应该进行那些操作

  1. 比较标签名,不一样直接替换
  2. 比较属性,将属性值改变的属性进行更新
  3. 比较子节点,需考虑子节点的数据类型

分析完毕之后开干

//renderer.js

...

//n1 旧虚拟dom节点,n2 新虚拟dom节点
export function diff(n1, n2){
    if(n1.tag !== n2.tag){
        //这里是el是我们上面mountElement里存下的el,因为是引用类型数据,所以在这里能直接使用
        n1.el.replaceWith(n1.el, createElement(n2.tag))
    }else{
        //标签名一样,开始比较属性
        const el = n1.el;
        n2.el = el; //存起来,和mountElement里同理,方便递归时使用
        const { props: oldProps } = n1;
        const { props: newProps } = n2;

        Object.keys(newProps).forEach(key => {
            if(oldProps[key] !== newProps[key]){
                // 如果属性值不一样,更新属性值
                patchProp(el, key, oldProps[key], newProps[key])
            }
        })

        Object.keys(oldProps).forEach(key => {
            if(!(key in newProps)){
                //新属性里没有旧属性的某个属性,删除该属性
                patchProp(el, key, oldProps[key], newProps[key])
            }
        })

        //处理子节点
        const {children: oldChildren} = n1;
        const {children: newChildren} = n2;
        if(typeof newChildren === 'string' || typeof newChildren === 'number'){
            if(typeof oldChildren === 'string' || typeof oldChildren === 'number'){
                // 如果新旧子节点都是文本节点
                if(newChildren !== oldChildren){
                    el.textContent = newChildren
                }
            }else{
                // 新节点是文本节点,旧节点不是文本节点,直接覆盖
                el.textContent = newChildren
            }
        }else if(Array.isArray(newChildren)){
            if(typeof oldChildren === 'string' || typeof oldChildren === 'number'){
                // 新节点是数组,旧节点是文本,重新渲染
                el.textContent = ``
                newChildren.forEach(v => mountElement(v, el))
            }else if(Array.isArray(oldChildren)){
                //对新旧节点中比较短的进行遍历
                const length = Math.min(newChildren.length, oldChildren.length);
                for(let i = 0; i < length; i++){
                    const oldChild = oldChildren[i];
                    const newChild = newChildren[i];
                    diff(oldChild, newChild); //递归比较子节点
                }

                // 删除多余节点
                if(oldChildren.length > length){
                    for(let i = length; i < oldChildren.length; i++){
                        const vnode = oldChildren[i];
                        el.removeChild(vnode.el)
                    }
                }
                
                // 插入未比较的新节点
                if(newChildren.length > length){
                    for(let i = length; i < newChildren.length; i++){
                        const vnode = newChildren[i];
                        mountElement(vnode, el)
                    }
                }
            }
        }
    }
}

至此就完成了简单版的diff,到这里简单的mini-vue就写好了,其实还有很多功能没有实现,比如模板编译以及各种指令等,通过手写mini-vue黑鸡也是更加理解了vue的渲染步骤和原理

项目地址