前言
看完了vue3源码,第一遍看明白了,但是又忘记了,所以看第二遍的时候,打算把一些细节记下来。本系列分成3章,第一章是vue如何初始化到渲染到页面到过程,第二章是它的diff算法,第三章是更新与nextTick。
第一章
我们从一个简单的demo开始
我们从简单的来,打开mini-vue源码,在mini-vue/packages/vue/example/helloWorld,文件主要有三个——main.js,index.html和App.js
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="root"></div>
<!-- 引入下面的main.js文件 -->
<script src="main.js" type="module"></script>
</body>
</html>
大致看了这个文件,就是把App文件export出来的函数作为参数给到createApp这个vue的api,然后再mount(挂载)到index.html的id为root的元素上。那么createApp里面做了些什么呢?我们继续分析
// main.js
import { createApp } from "../../dist/mini-vue.esm-bundler.js";
import App from "./App.js";
// 获取index.html中的根元素root
const rootContainer = document.querySelector("#root");
// createApp调用的是packages/runtime-dom/src/index.ts中createApp方法
/**
* createApp返回的是
* {
render,
createApp: createAppAPI(render),
}
* createAppAPI来自packages/runtime-core/src/createApp.ts的方法
* */
createApp(App).mount(rootContainer);
// App.js
/** h函数可以理解为把vue文件的template标签转为了对应的h函数 */
import { h, ref } from "../../dist/mini-vue.esm-bundler.js";
const count = ref(0);
const HelloWorld = {
name: "HelloWorld",
setup() {},
// TODO 第一个小目标
// 可以在使用 template 只需要有一个插值表达式即
// 可以解析 tag 标签
// template: `
// <div>hi {{msg}}</div>
// 需要编译成 render 函数
// `,
render() {
return h(
"div",
{ tId: "helloWorld" },
`hello world: count: ${count.value}`
);
},
};
export default {
name: "App",
setup() {},
render() {
return h("div", { tId: 1 }, [h("p", {}, "主页"), h(HelloWorld)]);
},
};
createApp函数
createAppAPI来自packages/runtime-dom/src/index.ts的方法,方法如下,首先执行createApp方法,然后执行ensureRenderer方法,如果本来已经把函数初始化了,那么renderer就会有值,但是这里是从零开始的,所以renderer一开始没值,只能走createRenderer逻辑
function ensureRenderer() {
// 如果 renderer 有值的话,那么以后都不会初始化了
/** createRenderer调用的是packages/runtime-core/src/renderer.ts的createRenderer方法 */
// 传入createRenderer的参数都是一些操作DOM的元素,这样的意义就是,构建的renderer函数中随时就可以使用这些函数
return (
renderer ||
(renderer = createRenderer({
createElement, // 内部函数其实是document.createElement(type), type是html元素
createText, // document.createTextNode(text);
setText, // node.nodeValue = text; 这个是给注释节点或者文本节用的。html节点给nodeValue赋值没意义,读取时返回为null
setElementText, // el.textContent = text; 设置节点的文本
patchProp, // 这个函数是对新旧的DOM元素的属性和事件进行对比,然后更新或者删除属性和事件,主要函数是setAttribute和removeAttribute,addEventListener和removeEventListener
insert, // parent.insertBefore(child, anchor); // 在父节点里面,且在anchor节点前插入child节点(anchor值为null的话,那么child就是parent的第一个子节点)
remove, // parent.removeChild(child);
}))
);
}
// vue3的createApp就是从这里开始进入
export const createApp = (...args) => {
return ensureRenderer().createApp(...args);
};
ensureRenderer()返回的是{ render, createApp: createAppAPI(render), },然后又调用createApp(其实就是createAppAPI方法)方法,那么这个createAppAPI在哪里呢?就是在packages/runtime-core/src/createApp.ts文件里,如下图:
// createVNode方法主要用于创建虚拟DOM节点,其实就是一个js对象而已
import { createVNode } from "./vnode";
export function createAppAPI(render) {
/** rootComponent就是App组件 */
// 直接返回一个函数,我们可以看到函数里面有个app的对象,里面有个mount函数,这个就是main.js调用的createApp(App).mount这个函数了
return function createApp(rootComponent) {
const app = {
// rootComponent就是App.js导出的函数
_component: rootComponent,
// rootContainer是root根元素,就是index.html定义的那个根元素
mount(rootContainer) {
console.log("基于根组件创建 vnode");
/** createVNode创建vnode虚拟dom对象,这里其实只是构建了根组件(App)的虚拟节点,根组件下面的子节点如果是组件节点,那么就会在render函数里面生成虚拟DOM */
/**
* createVNode返回的对象如下:
* {
* type, // 是函数或者是html标签字符串,比如div,或者{ name: 'Home', render: fn, setup: fn, }
* props, // 属性,包括事件
* children, // 子节点对象
* key, // 经典的key值
* ...
* }
* */
const vnode = createVNode(rootComponent);
// vnode虚拟节点(js对象来的)生成好后,就开始进行渲染到页面了,调用render方法
render(vnode, rootContainer);
},
};
return app;
};
}
createApp方法其实就是构建出一个根组件虚拟节点,然后进入到render逻辑,那么下一步,我们来看看render函数怎么实行虚拟节点和真实节点的映射
render函数
render函数就是packages/runtime-core/src/renderer.ts文件中的createRenderer函数中的render,如下图代码段,我们可以看到,render函数调用了patch方法(耳熟能详了),传入了null,vnode,container,这里可以看下下面的注释
const render = (vnode, container) => {
console.log("调用 patch")
// null代表没有旧节点,所以不是走更新逻辑而是走直接渲染逻辑,一般第一次初始化页面才会传null走这个逻辑,vnode就是上面所说的构建好了的App的vnode,container是index.html的id为root的DOM元素
patch(null, vnode, container);
};
patch函数(关键)
patch函数就是用来渲染vnode节点的,根据vnode对象中的type属性,走不同的渲染逻辑,如下图:
/** n1是旧节点vdom,n2是新节点的vdom */
function patch(
n1,
n2,
container = null,
anchor = null,
parentComponent = null
) {
// 基于 n2 的类型来判断
// 因为 n2 是新的 vnode
const { type, shapeFlag } = n2;
switch (type) {
// 文本节点渲染
case Text:
processText(n1, n2, container);
break;
// 其中还有几个类型比如: static fragment comment
case Fragment:
// Fragment,vue3新增的,使用该节点可以解决vue2中一个vue文件只能有一个根元素的问题
processFragment(n1, n2, container);
break;
default:
// 这里就基于 shapeFlag 来处理
if (shapeFlag & ShapeFlags.ELEMENT) {
console.log("处理 element");
/** 对原生dom进行处理 */
processElement(n1, n2, container, anchor, parentComponent);
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
// 处理组件,组件就是h(Helloworld) Helloworld就是一个组件的构造函数
console.log("处理 component");
processComponent(n1, n2, container, parentComponent);
}
}
}
这里我们关键看处理dom元素和处理组件的逻辑,也就是default那段逻辑了,最简单的是processElement函数,也就是处理原生DOM函数,那我们就从简单的开始吧
processElement
如果n1为null,则跳到mountElement函数逻辑,因为我们这个是第一章,讲的是如何初始化和渲染DOM,不涉及更新,所以我们跳过updateElement逻辑(将在第三章讲解)
function processElement(n1, n2, container, anchor, parentComponent) {
if (!n1) {
mountElement(n2, container, anchor);
} else {
// todo
updateElement(n1, n2, container, anchor, parentComponent);
}
}
进入mountElement函数,如下图: 这里主要看vnode和container这两个参数就行了,做个标记:vnode就是App.js导出的函数,而container就是html文件的id为root的根元素
function mountElement(vnode, container, anchor) {
const { shapeFlag, props } = vnode;
// 1. 先创建 element,并赋值到vnode的el属性上
// 基于可扩展的渲染 api
/** hostCreateElement创建DOM元素,里面的逻辑就是document.createElement(vnode,type) */
const el = (vnode.el = hostCreateElement(vnode.type));
// 如果是子节点是文本节点
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 本质就是el.textContent = vnode.children;
hostSetElementText(el, vnode.children);
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 举个栗子
// render(){
// Hello 是个 component
// return h("div",{},[h("p"),h(Hello)])
// }
// 这里 children 就是个数组了,就需要依次调用 patch 递归来处理
// 注意,这里的el不是html的根元素了,而是上面创建出来的元素,也不是虚拟节点,是真实DOM元素
/** el相当于是父元素(vnode对应的真实节点),用来给vnode.children进行挂载的 */
mountChildren(vnode.children, el);
}
// 处理 props
if (props) {
for (const key in props) {
// todo
// 需要过滤掉vue自身用的key
// 比如生命周期相关的 key: beforeMount、mounted
const nextVal = props[key];
// 更新dom元素属性和事件
hostPatchProp(el, key, null, nextVal);
}
}
// todo
// 触发 beforeMount() 钩子
// 子beforeMount -> 父beforeMount
console.log("vnodeHook -> onVnodeBeforeMount");
console.log("DirectiveHook -> beforeMount");
console.log("transition -> beforeEnter");
// 插入
// 本质调用了container.insertBefor(el, anchor)
hostInsert(el, container, anchor);
// todo
// 触发 mounted() 钩子
// 子mount -> 父mount
console.log("vnodeHook -> onVnodeMounted");
console.log("DirectiveHook -> mounted");
console.log("transition -> enter");
}
这里有个地方要注意: 1、首先在执行这段代码,是走beforeCreate和created方法(vue3的options api写法还是存在这两个钩子函数的),然后再递归执行vnode.children(mountChildren的逻辑),然后children里面又会执行beforeCreate和created方法,然后再执行beforeMount和mounted方法,再执行父元素的beforeMount和mounted方法,这个是面试经常问的,源码中就是这样体现了
我们可以看到,对于原生DOM元素,通过processElement函数,里面执行const el = document.createElement(type) -> container.insertBefore(el, anchor)就渲染到页面上了,所以源码其实也就那样子,比较简单,我们接下来看看processComponent函数逻辑,看看对于组件是怎么渲染的
processComponent
我们会到patch函数,找到下图的processComponent的逻辑:
然后点击进去看看processComponent代码,如下图,其实和processElement差不多,我们重点看下mountComponent函数
function processComponent(n1, n2, container, parentComponent) {
// 如果 n1 没有值的话,那么就是 mount
if (!n1) {
// 初始化 component
mountComponent(n2, container, parentComponent);
} else {
updateComponent(n1, n2, container);
}
}
mountComponent
该方法创建了一个instance的组件实例(其实这个instance就是我们在vue文件中经常使用到的this,不过它将会被proxy代理) 使用createComponentInstance创建了一个对象,返回的结构下图注释代码上有写的了
function mountComponent(initialVNode, container, parentComponent) {
/** initialVNode为 { type, setup, render } */
// 1. 先创建一个 component instance
/**
* 返回的对象
* instance = {
* type: '',
* vnode: vnode,
* next,
* props,
* ctx: { _: instance },
* setupState, // 存储setup的返回值
* }
* */
/** initialVNode = vnode */
const instance = (initialVNode.component = createComponentInstance(
initialVNode,
parentComponent
));
console.log(`创建组件实例:${instance.type.name}`);
// 2. 给 instance 加工加工
/** 用来初始化props和slot */
/** 是的instance挂上了setupState和render函数
* instance: { ...instance, setupState, render, ctx: { _: instance, }, }
* */
setupComponent(instance);
/** 配置组件渲染逻辑执行componentUpdateFn函数 */
setupRenderEffect(instance, initialVNode, container);
}
创建完instance对象后,会继续执行setupComponent函数,再对instance对象增加一些属性,比如props和slot(插槽),setupComponent方法是在packages/runtime-core/src/component.ts文件中
export function setupComponent(instance) {
// 1. 处理 props
// 取出存在 vnode 里面的 props
const { props, children } = instance.vnode;
// 初始化props
initProps(instance, props);
// 2. 处理 slots
initSlots(instance, children);
// 源码里面有两种类型的 component
// 一种是基于 options 创建的
// 还有一种是 function 的
// 这里处理的是 options 创建的
// 叫做 stateful 类型
/** 这里开始创建代理Proxy */
setupStatefulComponent(instance);
}
然后执行setupStatefulComponent函数,里面就会执行组件的setup函数,并进行一系列proxy化:
function setupStatefulComponent(instance) {
// todo
// 1. 先创建代理 proxy
console.log("创建 proxy");
// proxy 对象其实是代理了 instance.ctx 对象
// 我们在使用的时候需要使用 instance.proxy 对象
// 因为 instance.ctx 在 prod 和 dev 坏境下是不同的
// instance.ctx = { _: instance },这里其实就是代理了instance对象,我也不知道为什么写的那么复杂,还要instance.ctx._这样子
instance.proxy = new Proxy(instance.ctx, PublicInstanceProxyHandlers);
// 用户声明的对象就是 instance.type
// const Component = {setup(),render()} ....
const Component = instance.type;
// 2. 调用 setup
// 调用 setup 的时候传入 props
const { setup } = Component;
if (setup) {
// 设置当前 currentInstance 的值
// 必须要在调用 setup 之前
setCurrentInstance(instance);
/** 初始化setupContext
* setupContext = {
attrs: instance.attrs,
slots: instance.slots,
emit: instance.emit,
expose: () => {},
}
*/
const setupContext = createSetupContext(instance);
// 真实的处理场景里面应该是只在 dev 环境才会把 props 设置为只读的
/** props属性都不能被子组件设置值,所以在子组件的props属性的值,都是不会进行收集依赖的 */
/** 到这里还没开始收集依赖 */
/**
* 返回的setupResult,比如:
* {
* msg: { _value, _raw_value, deps: [] }, // RefImpl对象,已经变成了代理对象了
* change: Fn,
* }
* */
const setupResult =
setup && setup(shallowReadonly(instance.props), setupContext);
setCurrentInstance(null);
// 3. 处理 setupResult
// 得到的setupResult将会赋值给instance.setupState
handleSetupResult(instance, setupResult);
} else {
finishComponentSetup(instance);
}
}
经过上面的一系列操作后,到了handleSetupResult函数,该函数主要是用来处理setup函数返回的对象,一个常用的术语就是解构它里面的值,这里我稍微举个例子,再开始看handleSetupResult的源码:
setup () {
// msg经过ref后,变成了一个{ _value, _raw_value, deps: [] }对象
const msg = ref(123); // 这里获取到msg其实是一个RefImpl对象,里面包含了_value的属性值,并且把123赋值给_value属性值
return { msg };
}
如上面所述,如果我们要使用this.msg获取到值123的话,那该怎么办呢?于是handleSetupResult帮我们做了这件事,源码如下,当看到setup函数返回的是object类型时,就会instance.setupState = proxyRef(setupResults),而proxyRef做的事情就是解构出_value值 这里要注意的是:在setup函数中使用msg只能获取到ref对象,所以需要使用msg.value去获取值,但是经过下面的setup函数执行完,然后执行handleSetupResult函数后,这个msg就被代理解构了,所以在template标签里面可以直接使用{{ msg }}而不是{{ msg.value }}
function handleSetupResult(instance, setupResult) {
// setup 返回值不一样的话,会有不同的处理
// 1. 看看 setupResult 是个什么
if (typeof setupResult === "function") {
// 如果返回的是 function 的话,那么绑定到 render 上
// 认为是 render 逻辑
// setup(){ return ()=>(h("div")) }
instance.render = setupResult;
} else if (typeof setupResult === "object") {
// 返回的是一个对象的话
// 先存到 setupState 上
// 先使用 @vue/reactivity 里面的 proxyRefs
// 后面我们自己构建
// proxyRefs 的作用就是把 setupResult 对象做一层代理
// 方便用户直接访问 ref 类型的值
// 比如 setupResult 里面有个 count 是个 ref 类型的对象,用户使用的时候就可以直接使用 count 了,而不需要在 count.value
// 这里也就是官网里面说到的自动结构 Ref 类型
/**
* setupResult = {
* msg: RefImpl代理对象,
* change: Fn
* }
* */
instance.setupState = proxyRefs(setupResult);
}
finishComponentSetup(instance);
}
解构的关键代码如下:
proxyRefs = new Proxy(target, {
get(target, key, receiver) {
// 如果里面是一个 ref 类型的话,那么就返回 .value
// 如果不是的话,那么直接返回value 就可以了
return unRef(Reflect.get(target, key, receiver));
},
set(target, key, value, receiver) {
const oldValue = target[key];
if (isRef(oldValue) && !isRef(value)) {
return (target[key].value = value);
} else {
return Reflect.set(target, key, value, receiver);
}
},
})
// 把 ref 里面的值拿到
export function unRef(ref) {
return isRef(ref) ? ref.value : ref;
}
export function isRef(value) {
return !!value.__v_isRef;
}
解构完成后,就执行finishComponentSetup,把render函数赋值给instance
function finishComponentSetup(instance) {
// 给 instance 设置 render
// 先取到用户设置的 component options
const Component = instance.type;
...
if (!instance.render) {
// 相当于把render函数给了instance
instance.render = Component.render;
}
}
于是整个代理过程就结束了,总结一下,就是创建了一个instance实例,然后对这个实例进行代理(这个代理就是我们经常使用的this),并且处理setup函数的返回值,对setup函数里面的值进行解构,然后把组件的render函数赋值给instance.render
最后进行组件渲染,就是执行所谓副作用(effect),把组件渲染成真实DOM,我们回到packages/runtime-core/src/renderer.ts文件的mountComponent函数当中,找到setupRenderEffect函数,我们开始研究这个函数
setupRenderEffect
该函数的作用渲染组件的DOM元素了
function setupRenderEffect(instance, initialVNode, container) {
function componentUpdateFn() {
// instance.isMounted肯定一开始是false的,所以直接进入该段逻辑
if (!instance.isMounted) {
/** instance.proxy就是instance的代理对象,然后传给render函数中,render函数的this值就是这个instance.proxy对象 */
const proxyToUse = instance.proxy;
// 可在 render 函数中通过 this 来使用 proxy
/** instance.render.call执行的其实就是组件的render方法:h("div", { tId: 1 }, [h("p", {}, "主页"), h(HelloWorld)])函
* 最终得到的是subTree是:
* {
* type: 'div',
* props: {
* tId: 1,
* },
* children: [
* { type: 'p', props: null, children: '主页' },
* { type: { name: 'Helloworld', render, setup }, }
* ]
* }
* */
/** render函数里面凡是用到this地方,都是指向proxyUse
* 所以当render函数中使用this.msg,或者this.change等函数,都会进入proxy里面的代理函数
* */
// 这个subTree就是通过组件的render方法,构建出来的vnode对象({ type, props, children, })
const subTree = (instance.subTree = normalizeVNode(
instance.render.call(proxyToUse, proxyToUse)
));
console.log("subTree", subTree);
// todo
console.log(`${instance.type.name}:触发 beforeMount hook`);
console.log(`${instance.type.name}:触发 onVnodeBeforeMount hook`);
// 得到了组件的vnode后,进入patch逻辑渲染成真实DOM
patch(null, subTree, container, null, instance);
// 把 root element 赋值给 组件的vnode.el ,为后续调用 $el 的时候获取值
initialVNode.el = subTree.el;
console.log(`${instance.type.name}:触发 mounted hook`);
instance.isMounted = true;
}
// 在 vue3.2 版本里面是使用的 new ReactiveEffect
// 至于为什么不直接用 effect ,是因为需要一个 scope 参数来收集所有的 effect
// 而 effect 这个函数是对外的 api ,是不可以轻易改变参数的,所以会使用 new ReactiveEffect
// 因为 ReactiveEffect 是内部对象,加一个参数是无所谓的
// 后面如果要实现 scope 的逻辑的时候 需要改过来
// 现在就先算了
/** 相当于创建effect对象 */
/** effect(xx)已经开始执行componentUpdateFn函数了,ReactiveEffect对象调用的run方法触发componentUpdateFn方法 */
/** effect返回一个runner函数,runner.effect = 新建的ReactiveEffect对象 */
/** 在这里执行了effect函数,其内部就开始执行以下逻辑
* shouldTrack = true, activeEffect = componentUpdateFn;
* 然后就会执行componentUpdateFn函数,而这个函数又会执行render函数
* 执行render函数的时候就会触发读取this.msg
* 于是把这个componentUpdateFn这个依赖收集到了msg的dep当中
* */
instance.update = effect(componentUpdateFn, {
scheduler: () => {
// 把 effect 推到微任务的时候在执行
// queueJob(effect);
queueJob(instance.update);
},
});
}
我们可以看到上面的instance.update = effect(componentUpdateFn)方法,其实effect(() => {})这种形式,多数是用来收集依赖了,那究竟怎么收集依赖呢,我看了下源码,感觉如果讲给别人听可能不好理解,于是我写了自己的一段伪代码,如下图:
let activeEffect = null;
let shouldTrack = false;
function effect (fn) {
if (!activeEffect) return; // 这里是为了防止多次console.log某个值导致触发get代理,从而重复多次收集
shouldTrack = true;
activeEffect = fn;
fn();
shouldTrack = false;
activeEffect = null;
}
当shouldTrack为true且activeEffect有值时,就会触发setup返回的每个属性的get,set代理,于是他们就会在get代理中收集这个activeEffect,然后等到改变它的值的时候,触发set代理,执行这个effect函数
未完待续...