createRenderer
自上一期完成provide/inject后,我们还实现了createRenderer。
- 目的:支持多平台渲染,之前只支持dom,但是还能支持canvas等,不同平台结点的生成、props的更新,以及结点的插入方式的api不一样,不同平台对此应该传入不同的接口。
- 实现:把render.ts下的所有方法放进createRenderer函数,构成闭包。createRenderer接受options参数,从中可以取出三个函数,对应上面的三个接口。之后实现这三个函数,并传入options对象。由于render函数不再直接暴露出去,createApp函数不能直接调用render,所以createApp外面又套了一层createAppAPI,用于拿到render。createRenderer的返回值是一个对象,调用createAppAPI并传入render。
import { createRenderer } from '../runtime-core';
// 抽出来的逻辑
function createElement(type) {
return document.createElement(type);
}
function patchProp(el, key, value) {
const isOn = (key) => /^on[A-Z]/.test(key);
if (isOn(key)) {
const event = key.slice(2).toLocaleLowerCase();
el.addEventListener(event, value);
} else {
el.setAttribute(key, value);
}
}
function insert(el, parent) {
parent.append(el);
}
// renderer对象,含有createApp方法
export const renderer: any = createRenderer({
createElement,
patchProp,
insert
});
// 暴露给用户的接口
export function createApp(...args) {
return renderer.createApp(...args);
}
export * from '../runtime-core';
render.ts:
export function createRenderer(options) {
const {
createElement: hostCreateElement,
patchProp: hostPatchProp,
insert: hostInsert
} = options;
// 构成闭包
function render(){}
function patch(){}
// ...
return {
createApp: createAppAPI(render)
};
}
更新 element 流程
先提供组件:
import { h, ref } from '../../lib/my-mini-vue.esm.js';
export const App = {
name: 'App',
setup() {
const count = ref(0);
const onClick = () => {
count.value++;
};
return {
count,
onClick
};
},
render() {
return h(
'div',
{
id: 'root'
},
[
h('div', {}, 'count:' + this.count), // 依赖收集
h(
'button',
{
onClick: this.onClick
},
'click'
)
]
);
}
};
想要实现的效果,是点击按钮count值自增。
从组件我们可以发现,count是ref,但render函数中访问时却没用this.count.value,根据组件代理的知识,instance.setupState里保存了setup的返回值,而且它也是一个objectWithRefs,使用proxyRefs,返回值无需.value的方式访问。
function handleSetupResult(instance, setupResult: any) {
if (typeof setupResult === 'object') {
// proxyRefs
instance.setupState = proxyRefs(setupResult);
}
finishComponentSetup(instance);
}
只有修改了这一步,上面组件的count才能渲染为0,否则是[object Object]。
然后来思考更新的实现,当组件内ref的值改变了,毫无疑问需要更新,更新就需要调用render生成新的vnode,而render是在setupRenderEffect中调用的。
该函数内裹一层effect,这样该effect就会被添加到ref的dep集合中,当触发ref的value setter,会重新render生成新的vnode。
给instance添加isMounted属性,初始化为false,表示组件还未挂载。初始化的时候会挂载,之后依赖触发时,由于已经挂载,会进入更新的逻辑。
还需要给instance添加subTree属性,表示旧的vnode,然后把新旧的vnode都传入patch。
function setupRenderEffect(instance: any, initialVNode, container) {
effect(() => {
const { proxy, isMounted } = instance;
if (!isMounted) {
const subTree = (instance.subTree = instance.render.call(proxy));
patch(null, subTree, container, instance);
initialVNode.el = subTree.el;
instance.isMounted = true;
} else {
const subTree = instance.render.call(proxy); // 新的vnode
patch(instance.subTree, subTree, container, instance);
instance.subTree = subTree;
}
});
}
显然,patch新增了一个参数,现在第一个参数n1表示旧vnode,第二个参数n2表示新vnode,这一函数签名的变化将引起一连串的修改。如果我们判断某处的patch是初始化操作,就给n1传null,此处不过多分析。
给processElement函数也添加参数n1,该函数根据n1是否为null,判断此时进行的是mount操作还是更新操作。
function processElement(n1, n2, container, parentComponent) {
if (!n1) {
mountElement(n2, container, parentComponent);
} else {
patchElement(n1, n2, container);
}
}
更新流程搭建完毕,之后更新的具体操作只需要在函数patchElement中实现即可。
更新 props
更新props的要求如下:
- 之前的值和现在不一样 -> 修改
- 新值为undefined / null -> 删除
- key在新的props中不存在 -> 删除
组件:
import { h, ref } from '../../lib/my-mini-vue.esm.js';
export const App = {
name: 'App',
setup() {
const count = ref(0);
const onClick = () => {
count.value++;
};
const props = ref({
foo: 'foo',
bar: 'bar'
});
const onChangePropsDemo1 = () => {
props.value.foo = 'new-foo';
};
const onChangePropsDemo2 = () => {
props.value.foo = undefined;
};
const onChangePropsDemo3 = () => {
props.value = {
foo: 'foo'
};
};
return {
count,
onClick,
onChangePropsDemo1,
onChangePropsDemo2,
onChangePropsDemo3,
props
};
},
render() {
return h(
'div',
{
id: 'root',
...this.props
},
[
h('div', {}, 'count:' + this.count),
h(
'button',
{
onClick: this.onClick
},
'click'
),
h(
'button',
{
onClick: this.onChangePropsDemo1
},
'changeProps - 值改变了 - 修改'
),
h(
'button',
{
onClick: this.onChangePropsDemo2
},
'changeProps - 值变成了 undefined - 删除'
),
h(
'button',
{
onClick: this.onChangePropsDemo3
},
'changeProps - key 在新的里面没有了 - 删除'
)
]
);
}
};
实现patchProps函数:
function patchElement(n1, n2, container) {
const prevProps = n1.props || EMPTY_OBJ;
const nextProps = n2.props || EMPTY_OBJ;
// 细节,n2是新的vnode,需要保存.el
// 下次更新的时候,之前的n2就会变为n1
const el = (n2.el = n1.el);
patchProps(el, prevProps, nextProps);
}
function patchProps(el, oldProps, newProps) {
if (oldProps !== newProps) {
for (const key in newProps) {
hostPatchProp(el, key, oldProps[key], newProps[key]);
}
}
if (oldProps !== EMPTY_OBJ) {
for (const key in oldProps) {
if (!(key in newProps)) {
hostPatchProp(el, key, oldProps[key], null);
}
}
}
}
hostPatchProp是createRender的options中拿到的函数之一,用于props操作。之前只接收三个参数,现在新增参数oldValue。
// hostPatchProp是解构赋值后的名称,其实就是patchProp函数
function patchProp(el, key, oldValue, newValue) {
const isOn = (key) => /^on[A-Z]/.test(key);
if (isOn(key)) {
const event = key.slice(2).toLocaleLowerCase();
el.addEventListener(event, newValue);
} else {
// 新值是undefined或null,就删除
if (newValue === undefined || newValue === null) {
el.removeAttribute(key);
} else {
el.setAttribute(key, newValue);
}
}
}
在mount中调用hostPatchProp时,oldValue传null即可。
更新children
概述
更新children共有四种情况:
- oldChildren是string,newChildren是array。
- oldChildren是string,newChildren是string。
- oldChildren是array,newChildren是string。
- oldChildren是array,newChildren是array。
最后一种情况最复杂,这一小节处理前三种情况。
实现patchChildren,并且在patchElement中调用:
function patchChildren(n1, n2, container, parentComponent) {
const { shapeFlag: prevShapeFlag, children } = n1;
const { shapeFlag, children: c2 } = n2;
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 情况三
unmountChildren(children);
}
// 情况二
hostSetElementText(container, c2);
} else {
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 情况一
hostSetElementText(container, '');
// 第一个参数从vnode改为children
mountChildren(c2, container, parentComponent);
} else {
// TODO
}
}
}
function unmountChildren(children) {
for (let i = 0; i < children.length; i++) {
hostRemove(children[i].el);
}
}
- 情况一:将elementText设置为空,mount新children
- 情况二:将elementText设置为新的
- 情况三:将旧children的结点卸载,将elementText设置为新的
createRenderer的options中新增两个接口,分别是remove和setElementText:
function remove(child) {
const parent = child.parentNode;
if (parent) {
parent.removeChild(child);
}
}
function setElementText(el, text) {
el.textContent = text;
}