前言
通过上一篇我相信大家已经知道了vue3大致是怎样创造虚拟dom的,但也留下了一个伏笔,就是跟渲染有关的patch方法。接下来我们就通过patch方法来介绍vue3是怎么把虚拟dom转化为真实dom的。在这之前,我们先定义一些操作dom的API,这跟平台有关,在源码中是定义在runtime-dom模块中的。
// nodeOps对象存放一些操作dom的方法
export const nodeOps = {
insert: (child, parent, anchor = null) => { // 插入有追加的功能 child:孩子节点, parent: 父节点 , anchor:在插入父节点时,以哪个子节点作为参照物
parent.insertBefore(child, anchor); // anchor为null时,相当于parent.appendChild(child)
},
remove: child => { // 从父节点移除哪个子节点
const parent = child.parentNode;
if (parent) {
parent.removeChild(child);
}
},
createElement: tag => document.createElement(tag), // 创造元素节点
createText: text => document.createTextNode(text), // 创造文本节点
setElementText: (el, text) => el.textContent = text, // 给元素节点添加文本内容
parentNode: node => node.parentNode, // 获取节点的父节点
nextSibling: node => node.nextSibling, // 获取节点的兄弟节点
querySelector: selector => document.querySelector(selector) // 获取dom元素
}
// patchProp:比较dom元素更行前后props的不同,并更行dom元素上的props
export const patchProp = (el, key, prevValue, nextValue) => { el:dom元素,key: 属性名称, prevValue:dom更新之前的值, nextValue: dom更新之后的值
if (key === 'class') { // 当key为类名时
patchClass(el, nextValue); // 更新类名的方法
} else if (key === 'style') { // 当key为样式时
patchStyle(el, prevValue, nextValue); // 更新样式值的方法
} else if (/^on[^a-z]/.test(key)) { // 当为onXxx(事件)时
// 如果有事件 addEventListener 如果没事件 应该用removeListener
patchEvent(el, key, nextValue);
// 绑定一个 换帮了一个 在换绑一个
} else {
// 其他属性 setAttribute
patchAttr(el, key, nextValue);
}
}
// 需要比对属性 diff算法 属性比对前后值
function patchClass(el, value) { // 属性为class时,直接判断更新后有值就替换之前的,为null就直接删除class属性
if (value == null) {
el.removeAttribute('class');
} else {
el.className = value;
}
}
function patchStyle(el, prev, next) {
const style = el.style; // 操作的是样式
// 最新的肯定要全部加到元素上
for (let key in next) {
style[key] = next[key];
}
// 新的没有 但是老的有这个属性, 将老的移除掉
if (prev) {
for (let key in prev) {
if (next[key] == null) {
style[key] = null;
}
}
}
}
function createInvoker(value) {
const invoker = (e) => { // 每次事件触发调用的都是invoker
invoker.value(e)
}
invoker.value = value; // 存储这个变量, 后续想换绑 可以直接更新value值
return invoker
}
// 更新事件时,稍微复杂点
function patchEvent(el, key, nextValue) {
// vei vue event invoker 缓存绑定的事件
const invokers = el._vei || (el._vei = {}); // 在元素上绑定一个自定义属性 用来记录绑定的事件
let exisitingInvoker = invokers[key]; // 先看一下有没有绑定过这个事件
if (exisitingInvoker && nextValue) { // 换绑逻辑
exisitingInvoker.value = nextValue
} else {
const name = key.slice(2).toLowerCase(); // eventName
if (nextValue) {
const invoker = invokers[key] = createInvoker(nextValue); // 返回一个引用,这样做的好处是每次换事件不用先解绑,再重新绑定,因为绑定的一直都是invoker,只是换了里面执行的方法
el.addEventListener(name, invoker); // 正规的时间 onClick =(e)=>{}
} else if (exisitingInvoker) {
// 如果下一个值没有 需要删除
el.removeEventListener(name, exisitingInvoker);
invokers[key] = undefined; // 解绑了
}
// else{
// // 压根没有绑定过 事件就不需要删除了
// }
}
}
function patchAttr(el, key, value) {
if (value == null) {
el.removeAttribute(key)
} else {
el.setAttribute(key, value)
}
}
以上介绍了一些渲染时会用到的相关API和方法,接下来正式介绍patch方法。
const render = (vnode, container) => { // 将虚拟节点 转化成真实节点渲染到容器中
// 后续还有更新 patch 包含初次渲染 还包含更新
patch(null, vnode, container);// 后续更新 prevNode nextNode container
}
const patch = (n1, n2, container, anchor = null) => {
// 两个元素 完全没用关系
if (n1 && !isSameVNodeType(n1, n2)) { // n1有值,说明为更新操作,这时如果n1和n2为不同类型的节点,直接把n1(旧的节点)删除,再走初始化逻辑
unmount(n1);
n1 = null;
}
// 如果前后元素不一致 需要删除老的元素 换成新的元素
if (n1 == n2) return;
const { shapeFlag, type } = n2; // createApp(组件)
switch (type) {
case Text:
processText(n1, n2, container); // 文本节点
break;
default:
if (shapeFlag & ShapeFlags.COMPONENT) { // 组件节点
processComponent(n1, n2, container); // 渲染组件节点,我们的vue项目初始化渲染就是一个App组件,我们先从processComponent这个方法分析
} else if (shapeFlag & ShapeFlags.ELEMENT) { // 元素节点
processElement(n1, n2, container, anchor); // 渲染元素节点
}
}
}
const processComponent = (n1, n2, container) => {
if (n1 == null) {
// 组件的初始化
mountComponent(n2, container);
} else {
// 组件的更新
}
}
const mountComponent = (initialVNode, container) => { // 组件的挂载流程
// 根据组件的虚拟节点 创造一个真实节点 , 渲染到容器中
// 1.我们要给组件创造一个组件的实例
const instance = initialVNode.component = createComponentInstance(initialVNode);
// 2. 需要给组件的实例进行赋值操作
setupComponent(instance); // 给实例赋予属性
// 3.调用render方法实现 组件的渲染逻辑。 如果依赖的状态发生变化 组件要重新渲染
// 数据和视图是双向绑定的 如果数据变化视图要更新 响应式原理
// effect data effect 可以用在组件中,这样数据变化后可以自动重新的执行effect函数
setupRenderEffect(initialVNode, instance, container); // 渲染effect
}
export function createComponentInstance(vnode){ // 创造一个组件实例
const type = vnode.type; // 用户自己传入的属性
const instance = {
vnode, // 实例对应的虚拟节点
type, // 组件对象
subTree: null, // 组件渲染的内容 vue3中组件的vnode 就叫vnode 组件渲染的结果 subTree
ctx: {}, // 组件上下文
props: {}, // 组件属性
attrs: {}, // 除了props中的属性
slots: {}, // 组件的插槽
setupState: {}, // setup返回的状态
propsOptions: type.props, // 属性选项
proxy: null, // 实例的代理对象
render:null, // 组件的渲染函数
emit: null, // 事件触发
exposed:{}, // 暴露的方法
isMounted: false // 是否挂载完成
}
instance.ctx = {_:instance}; // 稍后会说 , 后续会对他进行代理
return instance;
}
// 组件实例创造好了之后,需要对instance实例赋值
export function setupComponent(instance){
const {props,children} = instance.vnode;
// 组件的props 做初始化 attrs也要初始化
initProps(instance,props)
// 插槽的初始化
setupStatefulComponent(instance); // 这个方法的目的就是调用setup函数 拿到返回值 给
}
export function initProps(instance,rawProps){
const props = {};
const attrs = {};
const options = Object.keys(instance.propsOptions); // 用户注册过的, 校验类型
if(rawProps){
for(let key in rawProps){
const value = rawProps[key];
if(options.includes(key)){
props[key] = value;
}else{
attrs[key] = value
}
}
}
instance.props = reactive(props); // 组件的props是响应式的,因此使用reactive
instance.attrs = attrs; // 这个attrs 是非响应式的
}
export function setupStatefulComponent(instance){
// 核心就是调用组件的setup方法
const Component = instance.type; // App对象
const {setup} = Component; // vue3中vue文件中会有一个setup方法
instance.proxy = new Proxy(instance.ctx,PublicInstanceProxyHandlers); // proxy就是代理的上下文
if(setup){
const setupContext = createSetupContext(instance); // 创建执行上下文,为setup方法的第二个参数,有emit,expose等方法
let setupResult = setup(instance.props,setupContext); /// 获取setup的返回值
if(isFunction(setupResult)){
instance.render = setupResult; // 如果setup返回的是函数那么就是render函数
}else if(isObject(setupResult)){
instance.setupState = setupResult;
}
}
if(!instance.render){
// 如果 没有render 而写的是template 可能要做模板编译 下个阶段 会实现如何将template -》 render函数 (耗性能)
instance.render = Component.render; // 如果setup没有写render 那么就采用组件本身的render
}
}
function createSetupContext(instance){
return {
attrs:instance.attrs,
slots:instance.slots,
emit:instance.emit,
expose:(exposed) =>instance.exposed = exposed || {}
}
}
以上组件实例就创建完毕了, 接下来就是渲染了
const setupRenderEffect = (initialVNode, instance, container) => {
// 创建渲染effect
// 核心就是调用render,数据变化 就重新调用render
const componentUpdateFn = () => {
let { proxy } = instance; // render中的参数
if (!instance.isMounted) { // 组件初始化
// 组件初始化的流程
// 调用render方法 (渲染页面的时候会进行取值操作,那么取值的时候会进行依赖收集 , 收集对应的effect,稍后属性变化了会重新执行当前方法)
const subTree = instance.subTree = instance.render.call(proxy, proxy); // 渲染的时候会调用h方法,这是的subTree就类似于 h('div', {style: {color: red}}, '张三')
// 真正渲染组件 其实渲染的应该是subTree
// 重点:这时开始递归去渲染组件内的dom元素了
patch(null, subTree, container); // 稍后渲染完subTree 会生成真实节点之后挂载到subTree
initialVNode.el = subTree.el
instance.isMounted = true;
} else {
// 组件更新的流程 。。。
// 我可以做 diff算法 比较前后的两颗树
const prevTree = instance.subTree;
const nextTree = instance.render.call(proxy, proxy);
patch(prevTree, nextTree, container); // 比较两棵树
}
}
const effect = new ReactiveEffect(componentUpdateFn); // 这里用到之前我们讲响应式那块用到的ReactiveEffect类,主要是为了响应式变量跟新后,触发componentUpdateFn,执行patch方法去更新,这里不清楚可以去effect那章看看
// 默认调用update方法 就会执行componentUpdateFn
const update = effect.run.bind(effect);
update();
}
以上是组件的的初始化和更新,接下来看看元素的初始化和更新。
const patch = (n1, n2, container, anchor = null) => {
// 两个元素 完全没用关系
if (n1 && !isSameVNodeType(n1, n2)) { // n1 有值 再看两个是否是相同节点
unmount(n1);
n1 = null;
}
// 如果前后元素不一致 需要删除老的元素 换成新的元素
if (n1 == n2) return;
const { shapeFlag, type } = n2; // createApp(组件)
switch (type) {
case Text:
processText(n1, n2, container);
break;
default:
if (shapeFlag & ShapeFlags.COMPONENT) {
processComponent(n1, n2, container);
} else if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1, n2, container, anchor); // 现在走到元素渲染中来了
}
}
}
const processElement = (n1, n2, container, anchor) => { // 组件对应的返回值的初始化
if (n1 == null) {
// 初始化
mountElement(n2, container, anchor);
} else {
// 这个是元素的更新操作,涉及到diff,下章会重点介绍
patchElement(n1, n2); // 更新两个元素之间的差异
}
}
const mountElement = (vnode, container, anchor) => {
// vnode中的children 可能是字符串 或者是数组 对象数组 字符串数组
let { type, props, shapeFlag, children } = vnode; // 获取节点的类型 属性 儿子的形状 children
let el = vnode.el = hostCreateElement(type) // 创造dom元素
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
hostSetElementText(el, children) // 给元素增加文本内容
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { // 按位与
mountChildren(children, el); // 节点的孩子是数组
}
// 处理属性
if (props) {
for (const key in props) {
hostPatchProp(el, key, null, props[key]); // 给元素添加属性
}
}
hostInsert(el, container, anchor); // 把创建好的真实dom元素插入container(父节点中)
}
const mountChildren = (children, container) => {
// 如果是一个文本 可以直接 el.textContnt = 文本2
// ['文本1','文本2'] 两个文本 需要 创建两个文本节点 塞入到我们的元素中
// 孩子是数组,遍历孩子节点,并且运行patch方法递归去创建真实dom元素,再插入container中
for (let i = 0; i < children.length; i++) {
const child = (children[i] = normalizeVNode(children[i]));
patch(null, child, container); // 如果是文本需要特殊处理
}
}
总结
在渲染阶段,最重要的就是patch方法,vue就是利用递归调用patch方法实现把虚拟dom转化为真实dom插入页面中的。