话不多说,我们直奔主题,从0开始手写实现Vue3
初始化流程!
Vue3初始化流程
在手写实现之前,我们首先来看看Vue3
的初始化流程,为了方便观察,这里直接构建一个Vue3
项目
创建Vue3项目
官方提供了多种构建方式,我这里选择使用vite
,如下:
$ npm init vite-app mini-vue3
$ cd mini-vue3
$ npm install
$ npm run dev
出现如下提示,表示运行成功
分析初始化整个流程
首先,我们进入项目的index.html
文件
Vite App
可以看到index.html
代码内容就两点:
- 创建了一个
id
为app
的div
元素 - 页面引入了一个
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
件事:
- 通过
createApp
创建应用程序实例 - 将创建好的应用程序实例,通过
mount
方法挂载到id
为app
的元素上
因此我们引出几个待办项:
-
createApp
来源于vue
,所以首先创建vue
对象 - 实现
createApp
方法 - 实现
mount
方法 - 另外
creatApp
接受一个App
,这个里面具体是啥,我们得去看仔细
也不着急,我们从简倒繁,一步一步来。先去看./App.vue
文件
//App.vue文件
其实里面就是很普通的vue
组件,只是里面还引入了另外一个组件HelloWorld
,我们不妨一路走到底,再进去看看HelloWorld.vue
//HelloWorld.vue文件
{{ msg }}
Edit components/HelloWorld.vue
to test hot module replacement.
可以看到HelloWorld.vue
这个组件由template
和script
两部分组成
-
template
里面简单做了2
个事:
-
h1
元素的内容为使用组件时传递的属性msg
的值 -
button
元素绑定了一个事件,当按钮被click
时,让count++
- 而
script
中就直接导入一个配置对象,里头声明了2
个东西:
-
msg
属性 - 响应式数据
count
事实上,这里的msg
和count
会跟template
中的msg
和count
对应。有些人可能有疑问,为什么template
中的msg
和count
会知道去找script
中对应的地方的数据。这其实是vue
的一些默认机制,根据这些机制和规则它就总能找到对应的数据。
通过上述摸瓜过程,我们可以大致总结Vue3
的核心初始化过程:
通过vue中的createApp方法创建一个应用程序实例,并通过应用程序实例的mount方法,将实例挂载 到对应的宿主元素中。
因此,我们接下来要分析和实现核心函数createApp
和mount
实现核心函数
为了不乱,我们一步一步来,首先创建vue
,然后实现createApp
,最后实现挂载方法mount
测试用例
我们直接创建一个单独的文件好了,比如mini.html
。写了一个基本的测试用例,如下:
const { createApp } = Vue
const app = createApp({
data() {
return {
count: 0
}
}
});
app.mount('#app');
如上所示,我们分一下几个步骤思考:
-
createApp
来源于Vue
,我们是不是要有一个const Vue = {...}
- 通过
createApp
创建app
实例 - 通过
mount
方法挂载
手动实现createApp和mount
- 首先,创建一个
Vue
const Vue = { }
需要思考:通过createApp
返回的应用程序实例时什么样的?
首先,当调用createApp
之后,会返回应用实例,里面至少有个mount
方法,所以我们的基本结构明朗了,如下
const Vue = {
createApp: function (ops) {
return {
mount() {...}
}
}
其中mount
方法,接受一个选择器,可以让我们把引用实例挂载到对应的元素中
到这里,我们还需要解答几个问题
就是
mount
具体做了什么事情,或者说它的目标是什么?
其实回想app
实例的挂载过程,我们希望我们的配置渲染到#app
所关联的宿主中!因此在这之前我们需要将组件的配置解析为dom,即组件配置---->解析---->dom----->将dom渲染当宿主元素配置组件中的数据将来要放在哪?
因为浏览器只把{{"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
,我开始回到主线逻辑
- 找到宿主元素
const parent = document.querySelector(selector)
- 使用渲染函数
render
得到dom
,同时混入相关配置数据
if (!ops.render) {
ops.render = this.compile(parent.innerHTML)
}
const el = ops.render.call(ops.data())
- 将的到的
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
为真,则说明这里使用了vue3
的composition 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.querySelector
、appendChild
等等。所以我们希望给用户提供一套创建渲染器的API
如createRenderer
,然后然后用户通过这套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初始化流程,最终实现了
createApp
、createRenderer
、mount
、compile
等方法 - 这里简单小结mount的作用,它其实就是根据用户传入的选择器去获取当前的宿主元素,然后拿到当前宿主元素的
innerHTML
作为模板template
,然后经过编译变成渲染函数,通过执行渲染函数render
可以得到真正的dom
节点,并且就是在渲染函数执行时,将用户配置的数据和状态传入,最后将得到最终dom
节点后进行追加
end~