从0开始手写实现Vue3初始化流程

话不多说,我们直奔主题,从0开始手写实现Vue3初始化流程!

Vue3初始化流程

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

创建Vue3项目

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

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

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

image

分析初始化整个流程

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




  
  
  
  Vite App


  

可以看到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文件



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

//HelloWorld.vue文件



可以看到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)
}

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

最终的代码如下





    
    
    
    mini-vue3



    

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

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

总结

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

end~

你可能感兴趣的:(从0开始手写实现Vue3初始化流程)