话不多说,我们直奔主题,从0开始手写实现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
代码内容就两点:
- 创建了一个
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文件
<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
这个组件由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)
}
于是我们实现了多平台的扩展性
最终的代码如下
<!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初始化流程,最终实现了
createApp
、createRenderer
、mount
、compile
等方法 - 这里简单小结mount的作用,它其实就是根据用户传入的选择器去获取当前的宿主元素,然后拿到当前宿主元素的
innerHTML
作为模板template
,然后经过编译变成渲染函数,通过执行渲染函数render
可以得到真正的dom
节点,并且就是在渲染函数执行时,将用户配置的数据和状态传入,最后将得到最终dom
节点后进行追加
end~