runtime-core(一)
初始化 component 流程
我们先创建 example 目录,先写好示例文件,其中包括以下三个文件
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>
<style>
.red {
color: red;
}
.green {
color: green;
}
</style>
<div id="app"></div>
<script src="main.js" type="module"></script>
</body>
</html>
Main.js
import { createApp } from '../lib/guide-mini-vue.esm.js';
import { App } from './App.js'
const root = document.querySelector('#app');
createApp(App).mount(root)
App.js
import { h } from '../lib/guide-mini-vue.esm.js';
export const App = {
render() {
return h('div',
{
id: 'root',
class: ['red'],
},
'Hi, ' + this.msg
)
},
setup() {
// composition API
return {
msg: 'mini-vue',
}
}
}
有了 example 我们来实现其功能,首先创建 createApp.ts 文件
import { render } from "./renderer"
import { createVNode } from "./vnode"
export function createApp(rootComponent: any) {
return {
mount(rootContainer: any) {
// 首先转 vNode
// component => vNode
// 所有的逻辑都基于 vNode 操作
const vNode = createVNode(rootComponent)
render(vNode, rootContainer)
}
}
}
这里用到了 render 和 createVNode,我们来创建 vnode.ts 和 renderer.ts 文件来完成这两部分
export function createVNode(type: any, props?: any, children?: any) {
const vNode = {
type, props, children
}
return vNode
}
export function render(vNode: any, container: any) {
// patch 为了方便后续递归
patch(vNode, container)
}
这里 render 函数为了方便后续的递归处理,将所有的逻辑抽离了出去
function patch(vNode: any, container: any) {
// TODO component 和 Element 需要区分开
processComponent(vNode, container)
}
function processComponent(vNode: any, container: any) {
mountComponent(vNode, container)
}
function mountComponent(vNode: any, container: any) {
const instance = createComponentInstance(vNode)
setupComponent(instance)
setupRenderEffect(instance, container)
}
function setupRenderEffect(instance: any, container: any) {
const subTree = instance.render()
// vNode -> patch
// vNode -> element -> mountElement
patch(subTree, container)
}
然后还有对组件的处理函数,我们将他们提取到 component.ts 中
export function createComponentInstance(vNode: any) {
const component = {
vNode,
type: vNode.type
}
return component
}
export function setupComponent(instance: any) {
// TODO
// initProps()
// initSlots()
setupStatefulComponent(instance)
}
function setupStatefulComponent(instance: any) {
const Component = instance.vNode.type
const { setup } = Component
if (setup) {
// function | object
// function: 组件的 render 函数
// object: 把 object 注入到上下文
const setupResult = setup()
handleSetupResult(instance, setupResult)
}
}
function handleSetupResult(instance: any, setupResult: any) {
// TODO function
if (typeof setupResult === 'object') {
instance.setupState = setupResult
}
finishComponentSetup(instance)
}
function finishComponentSetup(instance: any) {
const Component = instance.type
if (Component.render) {
instance.render = Component.render
}
}
这里大体的流程我们已经走完了,现在还不能实现效果,因为浏览器不认识 typescript,所以我们需要使用打包工具将源代码进行打包构建
Rollup 打包
安装 rollup
$ yarn add rollup --dev
在 package.json 中添加构建脚本
"scripts": {
"test": "jest",
"build": "rollup -c rollup.config.js"
},
在项目根目录创建 rollup.config.js
import typescript from '@rollup/plugin-typescript'
export default {
input: './src/index.ts', // 输入文件
// 输出相关
output: [
// 1. cjs
{
format: 'cjs',
file: 'lib/guide-mini-vue.cjs.js'
},
// 2. mjs
{
format: 'es',
file: 'lib/guide-mini-vue.esm.js'
}
],
// typescript 语法
plugins: [typescript()]
}
在入口文件导出模块
export * from './runtime-core'
由于我们使用了 ts,所以需要配置 ts 语法解析,并且需要额外安装 tslib
$ yarn add @rollup/plugin-typescript tslib --dev
然后我们再执行 yarn build
,此时已经可以成功打包
初始化 Element
经过上一小节的打包之后我们并不能成功的运行,因为我们并没有对 element 元素进行处理,导致在代码执行过程中发生了报错
通过调试我可能够定位到问题发生在 element 元素上,element 元素并没有 render 方法,所以我们需要在 patch 的时候根据 vNode 的类型进行不同的处理
function patch(vNode: any, container: any) {
/*
* 处理组件
* 判断是不是 element,
* 如果是 element 则处理 element,
* 如果是 component 就处理 component
*/
// console.log(vNode.type);
if (typeof vNode.type === 'string') {
// element
processElement(vNode, container)
} else if (isObject(vNode.type)) {
// component
processComponent(vNode, container)
}
}
function processElement(vNode: any, container: any) {
mountElement(vNode, container)
}
function mountElement(vNode: any, container: any) {
const el = document.createElement(vNode.type)
// 内容
const {children, props} = vNode
// string
el.textContent = children
// props
for (let key in props) {
const val = props[key]
el.setAttribute(key, val)
}
container.appendChild(el)
}
此时浏览器打开 index.html 已经可以看到效果了(可以开启 rollup 的监听模式,yarn build –watch)
可以发现class 已经添加上了,至于这里为什么是 undefined ,因为我们在 main.js 中写的是 this.msg,但这我们目前并没有绑定 this,所以这里的 this.msg获取不到值
挂载 this 的逻辑后面再搞,这里我们先处理其他情况。
这里我们只是做了一个children 是字符串的例子,真实的场景是 children可能是一个 vnode 或者多个 vnode
所以我们需要对这两种情况做一下适配
function mountElement(vNode: any, container: any) {
const el = document.createElement(vNode.type)
// 内容
const {children, props} = vNode
if (typeof children === 'string') {
// string
el.textContent = children
} else if (Array.isArray(children)) {
// array
children.forEach(child => {
patch(child, el)
})
} else {
// vnode
patch(children, el)
}
// props
for (let key in props) {
const val = props[key]
el.setAttribute(key, val)
}
container.appendChild(el)
}
这里对 vnode 的处理方式是使用 patch,如果是数组就遍历 patch
我们再修改一下 App.js 来测试一下
export const App = {
render() {
return h('div',
{
id: 'root',
class: ['bg'],
},
// 'Hi, ' + this.msg,
h('p', { class: ['green'] },
[h('p', { class: ['red'] }, 'hi'), h('p', { class: ['green'] }, 'mini-vue')]
),
)
},
setup() {
// composition API
return {
msg: 'mini-vue',
}
}
}
完成
至此我们已经完成了流程图中款选的部分,下一节我们将处理组件的渲染。
组件代理
上一节留下了一个获取不到 this.msg 的问题,这里我们来解决一下,我们其实只需要将 setup 的返回值挂载到函数的 this上即可,但是这个过程并不止绑定 setup 返回值这一点,还有其他属性比如$el
、$data
……
所以我们使用组建代理的形式来将这些属性添加到 this 上
首先我们来处理 setup,将 component.ts 中的setupStatefulComponent函数稍作修改
function setupStatefulComponent(instance: any) {
const Component = instance.vNode.type
instance.proxy = new Proxy({}, {
get(target, key) {
const {setupState} = instance
if (key in setupState) {
return setupState[key]
}
}
})
const { setup } = Component
if (setup) {
// function | object
// function: 组件的 render 函数
// object: 把 object 注入到上下文
const setupResult = setup()
handleSetupResult(instance, setupResult)
}
}
除了这里还要将 renderer.ts 中的setupRenderEffect 函数改一下
function setupRenderEffect(instance: any, container: any) {
const {proxy} = instance
const subTree = instance.render.call(proxy)
// vNode -> patch
// vNode -> element -> mountElement
patch(subTree, container)
}
将 render 时的 this 显示绑定为上面创建的 proxy 对象
此时页面上已经可以正确显示了
接下来我们来处理$el
,先来看一下文档中对于这个属性的功能描述
组件实例正在使用的根 DOM 元素。
对于使用了片段的组件,
$el
是占位 DOM 节点,Vue 使用它来跟踪组件在 DOM 中的位置。建议使用模板引用来直接访问 DOM 元素,而不是依赖于$el
。
知道了功能,我们就会发现其实这里我们很好实现,因为我们之前处理过 el
这是我们上一小节处理 element 流程时添加的代码,在这里我们只需要将 el 元素保存到 vNode 对象中就可以传递出去
const el = vNode.el = document.createElement(vNode.type)
然后再判断 proxy 中 get 的 key 是否为$el即可
function setupStatefulComponent(instance: any) {
const Component = instance.vNode.type
instance.proxy = new Proxy({}, {
get(target, key) {
const {setupState} = instance
if (key in setupState) {
// setup 中返回的属性
return setupState[key]
}
if (key === '$el') {
// $el
return instance.vNode.el
}
}
})
然后我们在浏览器控制台中看一下效果
这里看到 self 已经拿到了刚才设置的 proxy 对象,但是$el并没有成功地获取到根元素,然后我们可以断点调试一下
这里可以看见 vNode 中 el 是 null,即没有成功的赋值,可是我们上面已经有过赋值了啊!这里其实是因为我们赋值的元素并不是 instance.vNode,我们赋值的过程在处理 element 元素中。
那么要怎么传递这个 el 元素呢?这里我们可以思考一下这个 mount 的过程。
我们在哪里调用的 mountElement 呢?顺着我们之前的代码往前找可以发现实在 patch 中最终调用了mountElement。那么 component 的 patch 什么情况下会走到 createElement 呢?答案是 render 完成之后。
render 之后会重新调用 patch,这时的组件已经被render 为 element,所以 patch 会走到 createElement 函数
这里的 subTree 其实就是我们需要获取的 el 元素
function mountComponent(vNode: any, container: any) {
const instance = createComponentInstance(vNode)
setupComponent(instance)
setupRenderEffect(instance, vNode, container)
}
function setupRenderEffect(instance: any,vNode: any, container: any) {
const {proxy} = instance
const subTree = instance.render.call(proxy)
// vNode -> component -> render -> patch
// vNode -> element -> mountElement
patch(subTree, container)
vNode.el = subTree
}
这时候已经能够正确的拿到 el 了
后续还有有其他的api 绑定
这一小节我们完成了组件的代理,下一小节我们将会进行 shapeFlags 的处理
shapeFlags
vue3 中使用位运算处理 shapeFlags,提高了性能,简化了逻辑。位运算执行效率高我们都知道,那么我们来了解一下位运算怎么简化代码
如果我们不使用位运算的形式,我们来看一下应该用什么代码处理
const shapeFlags = {
element: 0,
stateful_component: 0,
text_children: 0,
array_children: 0,
}
// 修改
shapeFlags.element = 1
shapeFlags.stateful_component = 1
// 查找
if (shapeFlags.element) { }
if(shapeFlags.stateful_component) {}
这样处理的话当条件多了就会看起来非常的繁杂,我们再来看一下位运算的形式怎么处理这些逻辑,我们需要提前定义好各种元素对应的数
// element -> 0001
// stateful_component -> 0010
// text_children -> 0100
// array_children -> 1000
// 修改
0000 | element => 0001
// 查找
0001 & element => 0001
0010 & element => 0000
下面我们就将之前的形式修改为位运算的形式
新建/src/shared/ShapeFlags.ts
export const enum ShapeFlags {
ELEMENT = 1, // 0001
STATEFUL_COMPONENT = 1 << 1, // 0010
TEXT_CHILDREN = 1 << 2, // 0100
ARRAY_CHILDREN = 1 << 3 // 1000
}
然后在创建 vnode 的时候将 shapeFlags 注入
import { ShapeFlags } from '../shared/ShapeFlags'
export function createVNode(type: any, props?: any, children?: any) {
const vNode = {
type,
props,
children,
shapeFlags: getShapeFlags(type),
el: null as Element
}
// children
if (typeof children === 'string') {
vNode.shapeFlags |= ShapeFlags.TEXT_CHILDREN
} else if (Array.isArray(children)) {
vNode.shapeFlags |= ShapeFlags.ARRAY_CHILDREN
}
return vNode
}
function getShapeFlags(type: any) {
return typeof type === 'string'
? ShapeFlags.ELEMENT
: ShapeFlags.STATEFUL_COMPONENT
}
然后将 render 中的判断方法改为位运算形式
function patch(vNode: any, container: any) {
const { shapeFlags } = vNode
// console.log(vNode.type);
if (shapeFlags & ShapeFlags.ELEMENT) {
// element
processElement(vNode, container)
} else if (shapeFlags & ShapeFlags.STATEFUL_COMPONENT) {
// component
processComponent(vNode, container)
}
}
function mountElement(vNode: any, container: any) {
const el = (vNode.el = document.createElement(vNode.type))
// 内容
const { children, props, shapeFlags } = vNode
// here ↓↓↓↓↓
if (shapeFlags & ShapeFlags.TEXT_CHILDREN) {
// string
el.textContent = children
} else if (shapeFlags & ShapeFlags.ARRAY_CHILDREN) {
// array
children.forEach((child: any) => {
patch(child, el)
})
} else {
// vnode
patch(children, el)
}
// props
for (let key in props) {
const val = props[key]
el.setAttribute(key, val)
}
container.appendChild(el)
}
注册事件
我们在使用 vue 时会用到@click
或者v-on:click
,这是绑定事件,在处理模板的时候需要把绑定事件进行注册。
在处理注册事件的时候我们要在处理 props 的时候拦截指定的 key,判断 key 是否是事件规范,如果是事件 API 则进行注册事件。
function mountElement(vNode: any, container: any) {
const el = (vNode.el = document.createElement(vNode.type))
// 内容
const { children, props, shapeFlags } = vNode
if (shapeFlags & ShapeFlags.TEXT_CHILDREN) {
// string
el.textContent = children
} else if (shapeFlags & ShapeFlags.ARRAY_CHILDREN) {
// array
children.forEach((child: any) => {
patch(child, el)
})
} else {
// vnode
patch(children, el)
}
// props
for (let key in props) {
const val = props[key]
if (isOn(key)) {
const eventName = key.slice(2).toLowerCase()
el.addEventListener(eventName, val)
} else {
el.setAttribute(key, val)
}
}
container.appendChild(el)
}
// 判断是否为事件
export const isOn = (key: string) => {
return /^on[A-Z]/.test(key)
}
就这么简单我们已经完成了注册事件。
组件 props
我们都知道组件之间的通信主要是通过 props 的形式实现的,那么这一节我们就来实现一下组件的 props 功能。
首先新建一个组件作为测试用例
import { h } from "../lib/guide-mini-vue.esm.js";
export const Foo = {
setup(props) {
console.log(props);
},
render() {
h('div', {}, 'foo: ' + this.count)
}
}
这里我们的 props 有三个要求:
- 在 setup 中作为参数接收
- 可以使用 this 访问到 props 属性
- props 是只读的
然后在 App 组件中引入 Foo 组件
import { h } from '../lib/guide-mini-vue.esm.js';
import { Foo } from './Foo.js'
export const App = {
render() {
return h('div',
{
id: 'root',
class: ['bg'],
},
[
h('div', {}, 'Hi, ' + this.msg),
h(Foo, { count: 1 })
]
)
},
setup() {
return {
msg: 'mini-vue',
}
}
}
此时若访问页面将会看到结果为 undefined。
我们之前留下过一个 TODO initProps
,我们只需要将其实现一下并将 props 作为参数传递到 setup 中即可
export function initProps(instance: any, rawProps: any) {
instance.props = rawProps || {}
// attrs
}
这时候已经完成了第一步,接下来我们要把 props 的属性绑定到组件的 this 上,这里继续使用之前绑定 state 的方式将 props也挂载到组件 this 上
export const publicInstanceProxyHandlers = {
get({ _: instance }: { _: any }, key: string | symbol) {
const { setupState, props } = instance
if (hasOwn(setupState, key)) {
// setup 中返回的属性
return setupState[key]
} else if (hasOwn(props, key)) {
// props 中的属性
return props[key]
}
const publicGetter = publicPropertiesMap[key]
if (publicGetter) {
return publicGetter(instance)
}
}
}
// 检查制定对象上是否有 key
export const hasOwn = (target: any, key: string | symbol) =>
Object.prototype.hasOwnProperty.call(target, key)
此时已经可以通过 this 获取 props 的值了
下一步我们要将这个值变为 readonly,这里在向 setup 传值的时候调用一下我们之前reactive 模块的 readonly 即可,这里 vue 源码其实用的是 shallowReadonly,那我们也是用 shallowReadonly 即可。
function setupStatefulComponent(instance: any) {
const Component = instance.vNode.type
instance.proxy = new Proxy({ _: instance }, publicInstanceProxyHandlers)
const { setup } = Component
if (setup) {
const setupResult = setup(shallowReadonly(instance.props))
handleSetupResult(instance, setupResult)
}
}
OK,我们已经完成了对 props 的处理
组件 emit
emit 是组件通信的另一个利器,有了 props 我们实现了组件之间的单向通信,而 emit 可以实现反向发送数据,这一小节,我们将实现 emit 的功能。
首先我们准备一个带有 emit 功能的 Foo 组件
mport { h } from "../lib/guide-mini-vue.esm.js";
export const Foo = {
setup(props, { emit }) {
function emitAdd() {
console.log('emit add');
emit('add', 2)
}
return { emitAdd }
},
render() {
const btn = h('button', {
onClick: this.emitAdd
}, '+')
const foo = h(
'p',
{},
'foo count: + 'this.count
)
return h('div', {}, [btn, foo])
}
}
组件中有两个元素,一个 p 标签用于显示 props 接受的 count 值,一个 button 按钮用于触发 emit 事件。
然后在 App 组件进行调用
export const App = {
render() {
return h('div',
[
h('div', {}, 'Hi, ' + this.msg),
h(Foo, {
count: this.count,
onAdd(num) {
console.log('on-add', num);
}
})
]
)
},
setup() {
return {
msg: 'mini-vue',
count: 1
}
}
}
从这个用例我们来看一下 emit 需要实现哪些功能点:
- setup 的第二个参数是一个对象,可以从这个对象中获取 emit 方法;
- emit 第一个参数是一个事件名,用于触发父组件绑定的事件;
- emit 函数可以接收参数,从第二个参数开始就是事件的参数。
第一步我们将 emit 方法传入 setup
function setupStatefulComponent(instance: any) {
const Component = instance.vNode.type
instance.proxy = new Proxy({ _: instance }, publicInstanceProxyHandlers)
const { setup } = Component
if (setup) {
const setupResult = setup(shallowReadonly(instance.props), {
emit: instance.emit
})
handleSetupResult(instance, setupResult)
}
}
export function createComponentInstance(vNode: any) {
const component = {
vNode,
type: vNode.type,
setupState: {},
props: {},
emit: (args: any): any => {}
}
component.emit = emit // 组合方式从外部引入
return component
}
这里的 emit 我们将其初始化在 component 中,然后通过组合的方式将其赋值
export function emit(eventName: string) {
console.log('init event')
}
此时我们点击按钮就可以看待事件触发了
然后我们要使用 emit 来触发父组件绑定的事件
绑定事件获取在 props 中,所以我们处理绑定事件时需要拿到 props,我们可以直接将 instance 传到 emit 中,但是每次调用都传一个 instance 的体验非常不好,所以我们可以使用 bind 来直接传入
export function createComponentInstance(vNode: any) {
const component = {
vNode,
type: vNode.type,
setupState: {},
props: {},
emit: (args: any): any => {}
}
component.emit = emit.bind(null, component)
return component
}
然后我们来处理 emit 函数
import { capitalize } from '../shared'
export function emit(instance: any, eventName: string) {
console.log('init event')
const { props } = instance
const toHandlerKey = (str: string) => (str ? 'on' + capitalize(str) : '')
const handler = props[toHandlerKey(eventName)]
handler && handler()
}
// 首字母转为大写
export const capitalize = (str: string) =>
str.charAt(0).toUpperCase() + str.slice(1)
这里我们已经可以触发组件上绑定的事件了
最后一步,处理传参,这一步也很简单,只需要将函数的参数全部转发给执行函数即可
export function emit(instance: any, eventName: string, ...args: any[]) {
console.log('init event')
const { props } = instance
const toHandlerKey = (str: string) => (str ? 'on' + capitalize(str) : '')
const handler = props[toHandlerKey(eventName)]
handler && handler(...args)
}
完成
slots 插槽
插槽我们都使用过,是父组件向子组件传递组件参数的一种形式,子组件可以设置 slot 的默认值,如果父组件声明了组件则覆盖。
那都是 template 的写法,需要 sfc 解析配合,其实 vue 提供了$slots 获取插槽
首先我们准备一个 slots 的 example
// App.js
export const App = {
render() {
return h('div',
{},
[
h('div', {}, 'App'),
h(Foo, {}, h('p', {}, '1213'))
]
)
},
setup() {
return {
}
}
}
// Foo.js
export const Foo = {
setup() {
return {}
},
render() {
const foo = h(
'p',
{},
'foo')
console.log(this.$slots);
return h('div', {}, [foo, this.$slots])
}
}
还记得我们之前在 component 中的另一个 TODO 吗,initSlots
export function createComponentInstance(vNode: any) {
const component = {
vNode,
type: vNode.type,
setupState: {},
props: {},
slots: {},
emit: (args: any): any => {}
}
component.emit = emit.bind(null, component)
return component
}
export function setupComponent(instance: any) {
initProps(instance, instance.vNode.props)
initSlots(instance, instance.slots.children)
setupStatefulComponent(instance)
}
我们先来直接将 children 赋值给 slots 看一下,这里只是看一下效果
export function initSlots(instance: any, children: any[]) {
instance.slots = children
}
这只是看一下效果,接下来顺着这个思路继续,slots 的位置在子组件中是任意摆放的,如果取下标就太麻烦了。如果你刚才仔细啊看了官网文档的截图,你会发现使用 slots 时是用 object 的方式来取值,所以我们对刚才的代码稍加修改。
export function initSlots(instance: any, children: any[]) {
// instance.slots = Array.isArray(children) ? children : [children]
const slots: Record<string, any> = {}
for (const key in children) {
const value = children[key]
// slot
slots[key] = Array.isArray(value) ? value : [value]
}
instance.slots = slots
}
然后就可以在任意位置摆放插槽了
export const Foo = {
setup() {
return {}
},
render() {
const foo = h(
'p',
{},
'foo')
console.log(this.$slots);
return h('div',
{},
[
renderSlots(this.$slots, 'main'),
foo,
renderSlots(this.$slots, 'footer')
])
}
}
renderSlots 只是一个工具,作用类似于 this.$slots.main
。
export function renderSlots(
slots: Record<string, any>,
name: string
) {
const slot = slots[name]
if (slot) {
return createVNode('div', {}, slot)
}
}
现在我们实现的就是具名插槽,slots 还有另一种作用域插槽,可以将子组件的数据传到父组件slot 作用域中。
在刚才的官网文档截图中,还有一个细节,每个 slot 是函数调用的形式来渲染的,所以,通过函数调用我们可以实现将子组件的数据放到 slot 作用域中。
我们继续对刚才的代码动刀。
修改刚才的 App 组件,同时在 Foo 组件中将 slots 替换为函数调用形式
export const App = {
render() {
return h('div',
{},
[
h('div', {}, 'App'),
h(Foo, {}, {
main: ({ age }) => h('p', {}, 'age: ' + age),
footer: () => h('div', {}, '456')
})
]
)
},
setup() {
return {
}
}
}
初始化插槽时将参数传入
function normalizeObjectSlots(children: any[], slots: Record<string, any>) {
for (const key in children) {
const value = children[key]
// slot
slots[key] = (props: any) => normalizeSlotValue(value(props))
}
}
这样就实现了作用域插槽