模拟实现Vue3初始化流程

3,275 阅读8分钟

话不多说,我们直奔主题,模拟实现Vue3初始化流程!

Vue3初始化流程

在手写实现之前,我们首先来看看Vue3的初始化流程,为了方便观察,这里直接构建一个Vue3项目

创建Vue3项目

官方提供了多种构建方式,我这里选择使用vite,如下:

$ npm init vite-app mini-vue3
$ cd mini-vue3
$ npm install
$ npm run dev

出现如下提示,表示运行成功

分析初始化整个流程

首先,我们进入项目的index.html文件

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <link rel="icon" href="/favicon.ico" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vite App</title>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/src/main.js"></script>
</body>
</html>

可以看到index.html代码内容就两点:

  1. 创建了一个idappdiv元素
  2. 页面引入了一个main.js,但它的类型为module,说明文件里头是一些模块化的东西

于是,我们顺藤摸瓜,来到src目录下的main.js文件。详细内容如下

//src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'

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

可以看见main.js只做了2件事:

  1. 通过createApp创建应用程序实例
  2. 将创建好的应用程序实例,通过mount方法挂载到idapp的元素上

因此我们引出几个待办项:

  1. createApp来源于vue,所以首先创建vue对象
  2. 实现createApp方法
  3. 实现mount方法
  4. 另外creatApp接受一个App,这个里面具体是啥,我们得去看仔细

也不着急,我们从简倒繁,一步一步来。先去看./App.vue文件

//App.vue文件
<template>
  <img alt="Vue logo" src="./assets/logo.png" />
  <HelloWorld msg="Hello Vue 3.0 + Vite" />
</template>

<script>
import HelloWorld from './components/HelloWorld.vue'

export default {
  name: 'App',
  components: {
    HelloWorld
  }
}
</script>

其实里面就是很普通的vue组件,只是里面还引入了另外一个组件HelloWorld,我们不妨一路走到底,再进去看看HelloWorld.vue

//HelloWorld.vue文件
<template>
  <h1>{{ msg }}</h1>
  <button @click="count++">count is: {{ count }}</button>
  <p>Edit <code>components/HelloWorld.vue</code> to test hot module replacement.</p>
</template>

<script>
export default {
  name: 'HelloWorld',
  props: {
    msg: String
  },
  data() {
    return {
      count: 0
    }
  }
}
</script>

可以看到HelloWorld.vue这个组件由templatescript两部分组成

  1. template里面简单做了2个事:
  • h1元素的内容为使用组件时传递的属性msg的值
  • button元素绑定了一个事件,当按钮被click时,让count++
  1. script中就直接导入一个配置对象,里头声明了2个东西:
  • msg属性
  • 响应式数据count

事实上,这里的msgcount会跟template中的msgcount对应。有些人可能有疑问,为什么template中的msgcount会知道去找script中对应的地方的数据。这其实是vue的一些默认机制,根据这些机制和规则它就总能找到对应的数据。

通过上述摸瓜过程,我们可以大致总结Vue3的核心初始化过程:

通过vue中的createApp方法创建一个应用程序实例,并通过应用程序实例的mount方法,将实例挂载 到对应的宿主元素中。

因此,我们接下来要分析和实现核心函数createAppmount

实现核心函数

为了不乱,我们一步一步来,首先创建vue,然后实现createApp,最后实现挂载方法mount

测试用例

我们直接创建一个单独的文件好了,比如mini.html。写了一个基本的测试用例,如下:

const { createApp } = Vue
const app = createApp({
    data() {
        return {
            count: 0
        }
    }
});
app.mount('#app');

如上所示,我们分一下几个步骤思考:

  1. createApp来源于Vue,我们是不是要有一个const Vue = {...}
  2. 通过createApp创建app实例
  3. 通过mount方法挂载

手动实现createApp和mount

  1. 首先,创建一个Vue
const Vue = { }

需要思考:通过createApp返回的应用程序实例时什么样的?

首先,当调用createApp之后,会返回应用实例,里面至少有个mount方法,所以我们的基本结构明朗了,如下

const Vue = {
  createApp: function (ops) {
    return {
      mount() {...}
  }
}

其中mount方法,接受一个选择器,可以让我们把引用实例挂载到对应的元素中

到这里,我们还需要解答几个问题

  1. 就是mount具体做了什么事情,或者说它的目标是什么? 其实回想app实例的挂载过程,我们希望我们的配置渲染到#app所关联的宿主中!因此在这之前我们需要将组件的配置解析为dom,即组件配置---->解析---->dom----->将dom渲染当宿主元素

  2. 配置组件中的数据将来要放在哪? 因为浏览器只把{{"count:"+count}}当成字符串处理,所以这里我们需要增加一个重要的操作,就是编译compile,同时将数据配入。事实上,编译的作用是,将上面的模板通过编译变成渲染函数

我们的结构变成如下的样子

const Vue = {
  createApp: function (ops) {
    return {
      mount(selector) {...},
      compile(template) {...}
  }
}

于是,我们先来实现编译函数compile

我们知道compile接收一个模板,将模板变成渲染函数render,当应用程序实例挂载时,能够执行该渲染函数,将界面渲染出来。

此处暂时有所简化,在实际的vue中,会变成虚拟dom。这里就直接简化成直接描述视图,相当于vue中编译后的结果

compile(template) {
    return function render() {
        //简化
        const h1 = document.createElement('h1')
        h1.textContent = this.count
        return h1;
    }
}

有了compile,我开始回到主线逻辑

  1. 找到宿主元素
const parent = document.querySelector(selector)
  1. 使用渲染函数render得到dom,同时混入相关配置数据
if (!ops.render) {
    ops.render = this.compile(parent.innerHTML)
}
const el = ops.render.call(ops.data())
  1. 将的到的dom追加到页面中
parent.innerHTML = ''
parent.appendChild(el)

完整代码如下

const Vue = {
    createApp: function (ops) {    
        return {            
            mount(selector) {
                const parent = document.querySelector(selector)
                if (!ops.render) {                    
                    ops.render = this.compile(parent.innerHTML)
                }               
                const el = ops.render.call(ops.data())
                parent.innerHTML = ''
                parent.appendChild(el)
            },
            compile(template) {                
                return function render() {                    
                    const h1 = document.createElement('h1')
                    h1.textContent = this.count
                    return h1;
                }
            }
        }
    }
}

兼容Vue2的options API和Vue3的composition API

测试用例增加composition API

如下

const { createApp } = Vue
const app = createApp({
    data() {
        return {
            count: 0
        }
    },
    //composition API
    setup() {
        return {
            count: 1
        }
    }
});
app.mount('#app');

通过代理来确定数据来源

这里我们需要判断,数据来源于data还是setup

if (ops.setup) {
    this.setupState = ops.setup()
} else {
    this.data = ops.data()
}

ops.setup为真,则说明这里使用了vue3composition API,于是数据来源于ops.setup(),否则来源于ops.data()

但是这里有个问题,就是render函数怎么知道数据来源于data还是setup,这里使用巧妙的方式,利用代理Proxy,代理的是当前应用实例

this.proxy = new Proxy(this, {
    get(target, key) {
        if (key in target.setupState) {
            // setup的优先级更高
            return target.setupState[key]
        } else {
            //否则是,使用options api
            return target.data[key]
        }
    },
    set(target, key, val) {
        if (key in target.setupState) {
            target.setupState[k] = val
        } else {
            target.data[key] = val
        }
    }
})

上面的这个proxy,会作为render函数的上下文传入

由于当前的实例被代理了,所以render函数中去访问this的时候,相当于就是访问ge函数

const el = ops.render.call(this.proxy)

实现createRenderer

createRenderer主要用于实现多平台行扩展性,其实就是实现一个渲染器的机制。

我们回到我们的createApp函数就知道,该函数用到了与浏览器平台相关的代码,比如document.querySelectorappendChild等等。所以我们希望给用户提供一套创建渲染器的APIcreateRenderer,然后然后用户通过这套API来作渲染器的创建。这样的话这个渲染器里面的通用逻辑是一样的,但是具体怎么干活,我们写在createRenderer的内部,告诉渲染器怎么去干活。这样的话,我可以非常方便的对应那些通用逻辑进行扩展。

讲起来可能比较费劲,我们看看在代码中怎么体现

首先为了能够实现扩展,通常会将createApp做成一个高阶函数。

然后,我们创建一个创建自定义渲染器的函数createRenderer,这个函数将来接收参数,进行一系列的操作,包括各种节点操作等,但是这个节点操作会随着平台的不同而变化,这样它就能实现多平台扩展。

因此,我们将通用的代码移动到createRenderer中,该方法返回自定义渲染器,而返回的自定义渲染器,其实根我们之前写的createApp做的事一样,只是将里面平台特有的代码抽离出来了,与平台相关的代码由createRenderer传递的参数提供,因此该函数整体实现

createRenderer({ querySelector, insert }) {
    return {
        createApp(ops) {
            return {         
                mount(selector) {
                    const parent = querySelector(selector)
                    if (!ops.render) {
                        ops.render = this.compile(parent.innerHTML)
                    }                   
                    if (ops.setup) {
                        this.setupState = ops.setup()
                    } else {
                        this.data = ops.data();
                    }                   
                    this.proxy = new Proxy(this, {
                        get(target, key) {
                            if (key in target.setupState) {
                                return target.setupState[key]
                            } else {
                                return target.data[key]
                            }
                        },
                        set(target, key, val) {
                            if (key in target.setupState) {
                                target.setupState[k] = val
                            } else {
                                target.data[key] = val
                            }
                        }
                    })
                    const el = ops.render.call(this.proxy)
                    parent.innerHTML = ''
                    insert(el, parent)

                },
                compile(template) {                    
                    return function render() {                        
                        const h1 = document.createElement('h1')
                        h1.textContent = this.count
                        return h1;
                    }
                }
            }
        }
    }
}

然而,我们的createApp则由这个createRenderer,并提供一些web平台相关的操作即可。如下

createApp(ops) {
    const renderer = Vue.createRenderer({
        querySelector(selector) {
            return document.querySelector(selector)
        },
        insert(child, parent, anchor) {
            parent.insertBefore(child, anchor || null)
        }
    })
    return renderer.createApp(ops)
}

于是我们实现了多平台的扩展性

最终的代码如下

<!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-vue3</title>
</head>

<body>
    <div id="app">
        <!-- <h1>{{"count:"+count}}</h1> -->
    </div>
    <script>
        // 思考,接口怎么向外面暴露
        // 创建一个Vue
        const Vue = {
            // 需要思考:
            // 1.通过createApp返回的应用程序实例时什么样
            // 首先,当调用createApp之后,会返回应用实例,里面至少有个mount方法,所以
            createApp(ops) {
                // 暴露给web浏览器平台的,所以它专注于浏览器平台。它会调用createRenderer,它需要传递对应平台所使用的节点操作,
                // 此处仅传递本例所使用的节点操作
                const renderer = Vue.createRenderer({
                    querySelector(selector) {
                        return document.querySelector(selector)
                    },
                    insert(child, parent, anchor) {
                        parent.insertBefore(child, anchor || null)
                    }
                })
                return renderer.createApp(ops)
            },


            // 为了能够实现扩展,通常会将createApp做成一个高阶函数。
            // 我们创建一个创建自定义渲染器的函数createRenderer,这个函数将来接收参数,进行一系列的操作,包括各种节点操作等
            // ,但是这个节点操作会随着平台的不同而变化,这样它就能实现多平台扩展。
            createRenderer({ querySelector, insert }) {
                // 返回自定义渲染器
                return {
                    createApp(ops) {
                        // 返回app实例对象
                        return {
                            // 里面有个mount方法,接受一个选择器,可以让我们把引用实例挂载到对应的元素中
                            mount(selector) {
                                // 需要思考:mount具体做了什么事情,或者说它的目标是什么?
                                // 回想app实例的挂载过程,我们是希望我们的配置渲染到#app所关联的宿主中,因此在这之前我们需要将组件的配置解析为dom
                                // 即组件配置---->解析---->dom----->将dom渲染当宿主元素
                                // 但是,还有一个问题,就是配置组件中的数据将来要放在哪?
                                // 因为浏览器只把{{"count:"+count}}当成字符串。所以这里我们需要而我一个操作,就是:编译compile
                                // 编译的作用是,将上面的模板通过编译变成渲染函数
                                // 1.找到宿主元素
                                // const parent = document.querySelector(selector)
                                const parent = querySelector(selector)
                                // 2.使用渲染函数render
                                if (!ops.render) {
                                    //如果渲染函数不存在
                                    ops.render = this.compile(parent.innerHTML)
                                }
                                //3.有了渲染函数,接着就调用,并且在这个操作中,需要执行一下实例中的data函数,data返回的数据,就是我们要的数据,得到el

                                // 3.1 兼容vue2和vue3
                                if (ops.setup) {
                                    this.setupState = ops.setup()
                                } else {
                                    this.data = ops.data();
                                }
                                // 这里需要代理一下,目的确定render函数中,数据从哪里获取?
                                this.proxy = new Proxy(this, {
                                    get(target, key) {
                                        // console.log(key, target)
                                        if (key in target.setupState) {
                                            // setup的优先级更高
                                            return target.setupState[key]
                                        } else {
                                            //否则是,使用options api
                                            return target.data[key]
                                        }
                                    },
                                    set(target, key, val) {
                                        if (key in target.setupState) {
                                            target.setupState[k] = val
                                        } else {
                                            target.data[key] = val
                                        }
                                    }
                                })
                                // 上面的这个proxy,会作为render函数的上下文传入
                                // 由于当前的实例被代理了,所以render函数中去访问this的时候,相当于就是访问get函数
                                const el = ops.render.call(this.proxy)

                                //4.有了dom元素el,接下来追加到页面中
                                parent.innerHTML = ''
                                // parent.appendChild(el)
                                insert(el, parent)

                            },
                            compile(template) {
                                // compile接收一个模板,将模板变成渲染函数render,当应用程序实例挂载时,能够执行该渲染函数,将界面渲染出来。
                                // 即数据--->真实dom (此处暂时有所简化,在实际的vue中,会变成虚拟dom)
                                return function render() {
                                    // 注意:由于编译template的过程设计的东西比较复杂,这里简化了,直接描述视图,相当于vue中编译后的结果
                                    const h1 = document.createElement('h1')
                                    h1.textContent = this.count
                                    return h1;
                                }
                            }
                        }

                    }
                }
            }

        }

    </script>
    <script>
        // 测试用例如下
        // 首先,createApp来源于Vue
        const { createApp } = Vue
        //然后使用createApp创建app实例
        const app = createApp({
            data() {
                return {
                    count: 0
                }
            },
            //这我们要加一个vue3新增的函数:setup,即composition API入口函数
            setup() {
                let count = 1
                return { count }
            }
        });
        // 挂载
        app.mount('#app');
    </script>
</body>
</html>

测试代码,运行结果也是成功的!

经过上述一系列的过程,我们已经手动实现Vue3的初始化流程

总结

  • 我们从0开始手写实现Vue3初始化流程,最终实现了createAppcreateRenderermountcompile等方法
  • 这里简单小结mount的作用,它其实就是根据用户传入的选择器去获取当前的宿主元素,然后拿到当前宿主元素的innerHTML作为模板template,然后经过编译变成渲染函数,通过执行渲染函数render可以得到真正的dom节点,并且就是在渲染函数执行时,将用户配置的数据和状态传入,最后将得到最终dom节点后进行追加

end~