我正在参加「掘金·启航计划」
先写总结
我们都是以一个最简单的例子,描述了一个组件挂载到页面的流程,都是简化了一些代码逻辑,对基本流程进行了描述,重点关注以下几个方面
createVNode配合shapFlags对类型的标记render函数中调用的h所生成的vnodepatch函数中对组件,元素,文本的不同处理方法setup调用时,对实例的创建和对参数的处理和代理过程
在从runtime-dom模块对core模块的调用及runtime-dom模块函数实现中,讲述整个流程
-
在
runtime-core模块中以createRenderer函数作为切入点,它需要返回一个创建Vue应用实例的函数:createApp,这个应用实例会返回一个拥有若干个方法和属性的实例对象,我们会利用其中的mount函数来挂载页面-
在调用
mount函数的时候需要做的两件事分别是创建组件的虚拟节点和利用createRenderer函数提供的render函数挂载页面 -
createVNode函数用于创建虚拟节点,虚拟节点技术是Vue实现高性能和跨平台的支撑点。虚拟节点会利用shapFlags配合位运算标记出节点及后代的类型,针对不同的类型,渲染函数会采取不同的处理方式 -
在渲染函数
render中,会将接收到的虚拟节点作为调用patch函数的入参,render函数利用patch函数进行节点的类别判定及分别处理 -
patch函数是进行节点的类型判别和派发处理函数的调度函数,除了render函数为了挂载根组件可以调用patch函数以外,其他任意的处理函数都可以为了处理节点而调用patch函数,实际上我们在代码中也是这样做的。 -
根据不同的节点类型在我们的代码中,处理函数被分为了三种(源码更全面,我们不考虑):
processText、processElement、processElementprocessText函数所面临的处理内容就是单纯的文本节点,这种情况下只需要利用平台提供的文本节点创建函数直接创建后插入目标容器中即可processElement函数所面临的处理内容就是一般的元素(在WEB中就是div....)由于元素类型可能存在子代元素,因此在这个过程中我们需要对子代进行分别的处理,这个处理指的是:如果是简单的文本节点那么直接插入容器即可;如果后代元素是数组的内容,且这个数组的元素无论是不是文本节点我们都要再对此进行额外的处理processComponent函数所面临的处理内容就是组件,身为组件就需要具备组件自身的实例,利用createComponentInstance函数创建出组件实例之后,后续的操作就是尝试解析setup函数到实例中,并且准备好实例的render函数,因为组件挂载的虚拟节点的来源就是render函数的返回值
-
1.总览
我们先看一下Vue3模块间关系的结构图,会发现Vue的核心部分其实不就是三个部分,compiler,runtime,reactivity这三个部分,前面已经完成了reactivity的讲解,接下来只需搞定compiler和runtime就行了
compiler
compiler顾名思义就是编译模块,所谓的编译就是帮助我们将组件进行转成为我们Vue里面所创建的函数的API可以操作,最后转换成为浏览器所认识的dom
举个简单的例子:
<template>
</template>
<script setup>
</script>
<style>
</style>
这是Vue中最简单的SFC代码
SFC:Single File Component 单文件组件指扩展名为.vue的文件
简单来说,就是系统根本不认识你写的template是什么,所以需要对模块进行一个编译,因为本文主要说的是runtime,所以这里就跳过。
runtime
在编译后,肯定肯定是需要挂载,需要更新等操作,那么这个时候,runtime的作用就出来,runtime模块会对render的内容做出处理并帮我们挂载到页面上
举个例子:
<template>
<div id="app">
1
</div>
</template>
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.mount("#app")
编译后的结果就是这样的:
import { createApp } from 'vue'
// 编译后
const App = {
render(proxy) {
return h('div', {id: "app"}, 1)
}
}
const app = createApp(App)
app.mount("#app")
编译后的产物就是一个对象,其中一个属性就是render函数,在调用app实例的mount方法时,runtime就会通过调用编译产物的render方法进行节点挂载
2.理论准备
通过上面的一张图,我们可以清楚的看到runtime模块被分为了两个部分,分别是runtime-dom和runtime-core两大部分
问题:为什么用来渲染的runtime模块会被划分?
首先知道,Vue编写的代码是能够跨平台使用的,比如你可以用Vue来开发Web,微信小程序,native等...
为什么可以实现跨平台?就拿Web和小程序来说,他们创建元素的方式都是不一样的,怎么可以通用一套东西实现元素的创建?
runtime模块分为两个模块的原因就很清晰了。
runtime-core就是核心的渲染模块,他不关心平台,他只做虚拟节点的操作(Vnode)runtime-dom提供对应的元素和属性操作方法,比如Web端就是runtime-dom,对于小程序就是runtime-wx
总结runtime模块的组成:
runtime-core模块是真正的渲染核心,它并不关心是什么平台,其内部应用了虚拟节点技术,对你所要渲染的节点进行了特征描述,为跨平台提供了可能行runtime-dom模块是平台提供的节点操作模块,在这个模块中定义了节点的增删改查等方法,用来操作元素和属性
接下来我们就开始真正的开始探索组件如何实现的过程
3.组件创建过程
1.将传入的组件转换成为Vnode
1.理论准备
在这之前,先说一下Vue3中对元素,组件,文本等属性的判断,采用的是位运算的方式来标记类型
export const enum ShapeFlags {
ELEMENT = 1,// 1
FUNCTIONAL_COMPONENT = 1 << 1, // 2 00000010
STATEFUL_COMPONENT = 1 << 2, // 4 00000100
TEXT_CHILDREN = 1 << 3, // 8 00001000
ARRAY_CHILDREN = 1 << 4,
SLOTS_CHILDREN = 1 << 5,
TELEPORT = 1 << 6,
SUSPENSE = 1 << 7,
COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,
COMPONENT_KEPT_ALIVE = 1 << 9,
COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT
}
位运算,是前人总结出来做权限判断和类型的最佳实践
先简单说一下二进制,一个字节由8个位组成,8个位最大都是1
00000001 1 * 2^0 // 1
00000010 1 * 2^1 + 0 * 2^0 //2
00000100 1 * 2^2 + 0 * 2^1 + 0 * 2^0 //4
那我们用位运算来做标识位
如果要判断是是否是个组件
00000100 | 00000010 // |运算,有一个是1就是 1
00000110 = componet
//想判断他是否是组件
00000100 & 00000110 // 全1 才是1 =》 00000100 true
00001000 & 00000110 => 00000000
总结:
- | 有一个是1 就是1
- & 都是1 才是1
2.创建vnode
举个简单的例子作为入口
let { createApp, h, reactive } = VueRuntimeDOM;
let App = {
// getCurrentInstance = > instance
setup(props, context) {
// instance 里面包含的内容 会提取一些传递给context
let state = reactive({ name: "zf" });
return () => {
return h("div", state.name);
};
},
};
let app = createApp(App, { name: "zf", age: 12 });
app.mount("#app");
将传入的App组件转换成为虚拟节点,调用createVNode方法
//createVNode(App,{ name: "zf", age: 12 })
export const createVNode = (type,props,children = null) =>{
// 根据type来区分 是元素还是组件
// 给虚拟节点加一个类型
const shapeFlag = isString(type) ?
ShapeFlags.ELEMENT : isObject(type)?
ShapeFlags.STATEFUL_COMPONENT : 0
const vnode = { // 一个对象来描述对应的内容 , 虚拟节点有跨平台的能力
__v_isVnode:true,// 他是一个vnode节点
type,
props,
children,
component:null, // 存放组件对应的实例
el:null, // 稍后会将虚拟节点和真实节点对应起来
key: props && props.key,// diff算法会用到key
shapeFlag // 判断出当前自己的类型 和 儿子的类型
}
normalizeChildren(vnode,children);
return vnode;
}
function normalizeChildren(vnode,children){
let type = 0;
if(children == null){ // 不对儿子进行处理
} else if(isArray(children)){
type = ShapeFlags.ARRAY_CHILDREN;
} else{
type = ShapeFlags.TEXT_CHILDREN;
}
vnode.shapeFlag |= type
}
最后转换得到的Vnode
{
"__v_isVnode": true, //Vnode的标识
"type": {},
"props": { //传入的属性
"name": "zf",
"age": 12
},
"children": null,
"component": null,
"el": null,
"shapeFlag": 4 //表示是个组件
}
2.render函数
- render函数肯定是effect函数,因为里面的数据变化后,要重新渲染
- core的核心, 根据不同的虚拟节点 创建对应的真实元素
- 默认是初始化渲染,所以没有节点的对比过程
const render = (vnode, container) => {
// 默认调用render 可能是初始化流程
patch(null, vnode, container)
}
patch方法
- 默认是初始化流程,所以没有上一次的vnode
- 对比上一次和这一次的虚拟节点,然后根据是文本,元素还是组件分别做不同的处理,我们现在只针对于组件来处理,着重关注
processComponent方法 - 再后面我们在对文本和元素进行处理
const patch = (n1, n2, container) => {
// 针对不同类型 做初始化操作
const { shapeFlag,type } = n2;
if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
processComponent(n1, n2, container);
}
}
processComponent方法
- 组件的渲染和更新方法
if (n1 == null) { // 组件没有上一次的虚拟节点
mountComponent(n2, container);
} else {
// 组件更新流程
}
我们现在只对组件的初次渲染进行操作,我们就没有对比虚拟dom的操作
mountComponent
组件的渲染流程 : 最核心的就是调用 setup拿到返回值,获取render函数返回的结果来进行渲染
1.先创建组件的实例
2.将需要的数据解析到实例上面
3.创建一个effect函数,让render执行
我们知道了组件的创建流程,那么来开始实现
mountComponent方法
// initialVNode 初始化的vnode
const mountComponent = (initialVNode, container) => {
const instance = (initialVNode.component = createComponentInstance(initialVNode))
setupComponent(instance);
setupRenderEfect(instance, container);
}
1.createComponentInstance创建实例
我们知道在组件的实例上面有props,attrs,data,slots等等属性挂载上面,那我们可以创建一个实例,将这些属性挂载上去
export function createComponentInstance(vnode) {
const instance = { // 组件的实例
vnode,
type: vnode.type, // 用户写的对象
props: {}, // props attrs 有什么区别 vnode.props
attrs: {},
slots: {},
ctx: {},
data:{},
setupState: {}, // 如果setup返回一个对象,这个对象会作为setUpstate
render: null,
subTree:null, // render函数的返回结果就是subTree
isMounted: false // 表示这个组件是否挂载过
}
//这个后面用来做代理,暂时先保存在这里
instance.ctx = { _: instance } // instance.ctx._
return instance;
}
2.setupComponent
- 实例创建之后,将需要的数据解析到实例上面
- 我们知道需要的数据都在vnode上面,解析出props和children,挂载到实例上面
- 判断是不是有状态的组件,然后调用setup方法,用setup的返回值,填充setupState和对应的render方法
export function setupComponent(instance) {
const { props, children } = instance.vnode; // {type,props,children}
// 根据props 解析出 props 和 attrs,将其放到instance上
instance.props = props; // initProps()
instance.children = children; // 插槽的解析 initSlot()
// 需要先看下 当前组件是不是有状态的组件, 函数组件
let isStateful = instance.vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT
if (isStateful) { // 表示现在是一个带状态的组件
// 调用 当前实例的setup方法,用setup的返回值
// 填充 setupState和对应的render方法
setupStatefulComponent(instance)
}
}
setupStatefulComponent方法
作用:调用setup方法,拿到setup的返回值
先举个简单的例子看看setup中的参数和render函数中的参数
let { createApp, h, reactive } = VueRuntimeDOM
let App = {
props: {
name: String,
age: Number
},
data() {
return {
test: '1'
}
},
setup(props, context) {
console.log(props, context)
let state = reactive({ name: 'zf' })
return (proxy) => {
console.log(proxy)
return h('div' state.name)
}
}
}
let app = createApp(App, { name: 'zf', age: 12 })
app.mount('#app')
- 可以看到setup中props和context的输入如下
- 在看render中proxy中的参数,可以看到可以取传入的props的参数,也可以取到data中的参数,如果setup中返回的是对象,也可以取到对象中参数
1.1.代理instance.proxy,在取值时,分别从data,setupState,props中取值
function setupStatefulComponent(instance) {
// 1.代理 传递给render函数的参数
instance.proxy = new Proxy(instance.ctx, PublicInstanceProxyHandlers as any)
}
PublicInstanceProxyHandlers.ts
- 解构实例中的props,setupState,data,判断取值的,然后从对应的对象中把值取出来
import { hasOwn } from "@vue/shared/src"
export const PublicInstanceProxyHandlers = {
get({ _: instance }, key) {
// 取值时 要访问 setUpState, props ,data
const { setupState, props, data } = instance;
if (key[0] == '$') {
return; // 不能访问$ 开头的变量
}
if (hasOwn(setupState, key)) {
return setupState[key];
} else if (hasOwn(props, key)) {
return props[key];
} else if (hasOwn(data, key)) {
return data[key];
}
},
set({ _: instance }, key, value) {
const { setupState, props, data } = instance;
if (hasOwn(setupState, key)) {
setupState[key] = value;
} else if (hasOwn(props, key)) {
props[key] = value;
} else if (hasOwn(data, key)) {
data[key] = value;
}
return true;
}
}
1.2 获取组件的类型 拿到组件的setup方法
function setupStatefulComponent(instance) {
// 2.获取组件的类型 拿到组件的setup方法
let Component = instance.type
let { setup } = Component;
// ------ 没有setup------
if(setup){
let setupContext = createSetupContext(instance);
const setupResult = setup(instance.props, setupContext); // instance 中props attrs slots emit expose 会被提取出来,因为在开发过程中会使用这些属性
handleSetupResult(instance,setupResult)
}
}
- 我们知道setup中有两个参数,一个props,一个是Setup上下文对象
createSetupContext方法,创建Setup的上下文,具体实现暂时先不管,先知道是怎么来的
function createSetupContext(instance) {
return {
attrs: instance.attrs,
slots: instance.slots,
emit: () => { },
expose: () => { }
}
}
- 调用
setup方法,得到setup返回的结果,处理setup返回的结果 - 判断
setup的返回值,看返回的是对象还是函数,如果返回的对象,那么就将其存在instance的setupState中,如果返回的是函数,那么就将其存在instance上的render上面
function handleSetupResult(instance,setupResult){
if(isFunction(setupResult)){
instance.render = setupResult
}else if(isObject(setupResult)){
instance.setupState = setupResult
}
finishComponentSetup(instance);
}
finishComponentSetup方法
- 判断如果实例上面没有render函数,那么就对template模块进行编译
- 将编译后的render挂载到实例上面
function finishComponentSetup(instance){
let Component = instance.type
if(!instance.render){
---- 对template模板进行编译 产生render函数 -----
if(!Component.render && Component.template){
// 编译 将结果 赋予给Component.render
}
instance.render = Component.render;
}
}
3.setupRenderEfect函数
- 创建一个effect函数,让render函数执行
const setupRenderEfect = (instance, container) => {
// 需要创建一个effect 在effect中调用 render方法,这样render方法中拿到的数据会收集这个effect,属性更新时effect会重新执行
instance.update = effect(function componentEffect() { // 每个组件都有一个effect, vue3 是组件级更新,数据变化会重新执行对应组件的effect
if (!instance.isMounted) {
let proxyToUse = instance.proxy;
let subTree = instance.subTree = instance.render.call(proxyToUse, proxyToUse);
patch(null, subTree, container);
instance.isMounted = true;
}
});
}
- 先看render函数的返回值
{
"__v_isVnode": true,
"type": "div",
"props": {},
"children": "zf",
"component": null,
"el": null,
"shapeFlag": 9
}
- 在render函数执行时,调用的就是h函数
render(){
return h("div", state.name);
}
h函数:创建虚拟DOM节点(vnode),不懂的可以点击官网去看看
- 第一个参数既可以是一个字符串 (用于原生元素) 也可以是一个 Vue 组件定义。第二个参数是要传递的 prop,第三个参数是子节点
- 除了第一个参数外,其他参数都是可选的
export function h(type, propsOrChildren, children) {
const l = arguments.length; // 儿子节点要么是字符串 要么是数组 针对的是createVnode
if (l == 2) { // 类型 + 属性 、 类型 + 孩子
// 如果propsOrChildren 是数组 直接作为第三个参数
if (isObject(propsOrChildren) && !isArray(propsOrChildren)) {
if(isVnode(propsOrChildren)){
return createVNode(type,null,[propsOrChildren]);
}
return createVNode(type,propsOrChildren)
} else {
// 如果第二个参数 不是对象 那一定是孩子
return createVNode(type, null, propsOrChildren);
}
}else{
if(l > 3){
children = Array.prototype.slice.call(arguments,2);
}else if(l === 3 && isVnode(children)){
children = [children]
}
return createVNode(type,propsOrChildren,children);
}
}
- 最后在调用
patch方法,对元素和文本转换成为真实dom
4.rumtime-dom的方法
- 通过上面我们知道,我们需要将虚拟dom转换成为真实dom
那我们就的知道节点的一些操作方法
- DOM相关的创建,删除,查询,节点的content文本内容更新
- 节点的属性,style,class,event的创建和更新
1.节点操作:nodeOps
- 只写一些常见的操作
export const nodeOps = {
// createElement, 不同的平台创建元素方式不同
// 元素
createElement: tagName => document.createElement(tagName), // 增加
remove: child => { // 删除
const parent = child.parentNode;
if (parent) {
parent.removeChild(child)
}
},
insert: (child, parent, anchor = null) => { // 插入
parent.insertBefore(child, anchor); // 如果参照物为空 则相当于appendChild
},
querySelector: selector => document.querySelector(selector),
setElementText: (el, text) => el.textContent = text,
// 文本操作 创建文本
createText: text => document.createTextNode(text),
setText: (node, text) => node.nodeValue = text
}
2.属性操作:patchProps
- 针对于属性操作,我们分为四大模块,包括
attr,class,style,event的处理
patchProps.ts
- 简单来说,就是我们对遇到的不同的属性,做出来不同的处理操作
import { patchAttr } from './modules/attrs'
import { patchClass } from './modules/class'
import { patchStyle } from './modules/styles'
import { patchEvent } from './modules/event'
//// 如果是以on开头的文本就返回true(绑定的事件)
const isOn = v => /^on[^a-z]/.test(v)
const patchProps = (el, key, prevProps, nextProps) => {
if(key === 'class') {
//// 类名的修改
patchClass(el, nextValue)
} else if(key === 'style') {
//// 样式的修改
patchStyle(el, prevValue, nextValue)
} else if(isOn(key)) {
//// 使用isOn判断是否是一个事件的绑定
//// 因为绑定的事件都是以on开头 --> onClick
patchEvent(el, key, nextValue)
} else {
//// 属性的修改
patchAttr(el, key, nextValue)
}
}
patchClass.ts
export const patchClass = (el,value) =>{
if(value == null){
value = '';
}
el.className = value
}
patchStyle.ts
针对于style,大致就分为三种情况
- 新赋值的style为空,那我们就直接移除掉style
- 老的里面有,新的没有,就移除掉老的里面有的
- 新的有,老的没有,就把新的加上去
export const patchStyle = (el,prev,next) =>{ // cssText;
const style = el.style; //获取样式
if(next == null){
el.removeAttribute('style') // {style:{}} {}
}else{
// 老的里新的有没有
if(prev){ // {style:{color}} => {style:{background}}
for(let key in prev){
if(next[key] == null){ // 老的里有 新的里没有 需要删除
style[key] = '';
}
}
}
// 新的里面需要赋值到style上
for(let key in next){ // {style:{color}} => {style:{background}}
style[key] = next[key];
}
}
}
patchAttr.ts
export const patchAttr = (el,key,value) =>{
if(value == null){
el.removeAttribute(key);
}else {
el.setAttribute(key,value);
}
}
patchEvent.ts
- 给元素缓存一个绑定事件的列表
- 如果缓存中没有缓存过的,而且value有值 需要绑定方法,并且缓存起来
- 以前绑定过需要删除掉,删除缓存
- 如果前后都有,直接改变invoker中value属性指向最新的事件 即可
export const patchEvent = (el,key,value) =>{ // vue指令 删除和添加
// 对函数的缓存
const invokers = el._vei || (el._vei = {});
const exists = invokers[key]; // 如果不存在
if(value && exists){ // 需要绑定事件 而且还存在的情况下
exists.value = value;
}else{
const eventName = key.slice(2).toLowerCase();
if(value){ // 要绑定事件 以前没有绑定过
let invoker = invokers[key] = createInvoker(value);
el.addEventListener(eventName,invoker)
}else{ // 以前绑定了 当时没有value
el.removeEventListener(eventName,exists);
invokers[key] = undefined;
}
}
}
function createInvoker(value){
const invoker = (e) =>{ invoker.value(e);}
invoker.value = value; // 为了能随时更改value属性
return invoker;
}
3.总结
简单点:这就是runtime-dom里面的东西,然后传递给rumtime-core,我们把上面写的对节点和属性的操作方法,最后合并成为一个rendererOptions,这就是我们说的,不同平台有不同平台的操作,然后我们把这些通用的方法,传递过去
5.将虚拟节点转换成为真实节点
- 我们可以看到在组件的初始化中,我们最后再次调用patch方法
- 我们对文本
processText和元素processElement进行处理
// -----------------文本处理-----------------
const patch = (n1, n2, container) => {
// 针对不同类型 做初始化操作
const { shapeFlag,type } = n2;
switch (type) {
case Text:
processText(n1, n2, container);
break;
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1, n2, container);
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
processComponent(n1, n2, container);
}
}
}
处理前,先结构出来我们的操作方法
const {
insert: hostInsert,
remove: hostRemove,
patchProp: hostPatchProp,
createElement: hostCreateElement,
createText: hostCreateText,
createComment: hostCreateComment,
setText: hostSetText,
setElementText: hostSetElementText,
} = rendererOptions
1.文本处理:processText
- 将文本插入到对应的元素中
const processText = (n1,n2,container) =>{
if(n1 == null){
hostInsert((n2.el = hostCreateText(n2.children)),container)
}
}
2.元素处理
- 只针对渲染流程
const processElement = (n1, n2, container) => {
if (n1 == null) {
mountElement(n2, container);
} else {
// 元素更新
}
}
mountElement
{
"__v_isVnode": true,
"type": "div",
"props": {},
"children": "zf",
"component": null,
"el": null,
"shapeFlag": 9
}
根据上面最简单的vnode,我们来说下步骤
- 找到当前元素的type,创建元素节点
- 对props进行处理,利用
patchProp方法 - 判断是否是文本节点,调用文本处理方法
- 判断儿子是否是数组,是否要递归处理
- 将生成的元素,直接插入到container中
- 页面完成
const mountElement = (vnode, container) => {
// 递归渲染
const { props, shapeFlag, type, children } = vnode;
let el = (vnode.el = hostCreateElement(type));
if (props) {
for (const key in props) {
hostPatchProp(el, key, null, props[key]);
}
}
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
hostSetElementText(el, children);// 文本比较简单 直接扔进去即可
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(children, el);
}
hostInsert(el, container);
}