经过上一期,我们大概知道了runtime-core模块是如何初始化vnode、instance,以及渲染到页面上的,实现了组件代理,并且使用位运算优化了判断类型的实现。这一次在搭好的大框架的基础上,实现props、emit以及slot。
props
新增Foo.js组件,并且在App中使用:
const Foo = {
setup(props) {
console.log(props);
// shallow readonly
props.count++;
console.log(props);
},
render() {
return h('div', {}, 'foo: ' + this.count);
}
};
export const App = {
render() {
window.self = this;
return h(
'div',
{
id: 'root',
class: 'red',
},
[
h('div', {}, 'hi,' + this.msg),
h(Foo, {
count: 1
})
]
);
},
setup() {
return {
msg: 'mini-vue'
};
}
};
props共有三点需要实现:
- 能在setup中获取到props。
- 假设 props === { foo: 1},能通过this.foo拿到1,实现组件代理。
- 不能修改props的值。
首先我们要思考,如何才能在setup中获取到props?很显然,props应该是作为参数传给setup的,那么就要找setup初始化的位置,根据上一节,可知是函数setupStatefulComponent。
我们把props保存在instance中,让initProps函数完成。
export function setupComponent(instance) {
initProps(instance, instance.vnode.props);
// initSlots()
setupStatefulComponent(instance);
}
export function initProps(instance, rawProps) {
// 有时候component vnode不会传props
// 本例中,Foo传了props,但App没有传
// 如果props为undefind,之后访问props的属性会报错,所以此时要初始化为空对象
instance.props = rawProps || {};
}
function setupStatefulComponent(instance: any) {
const Component = instance.type;
instance.proxy = new Proxy({ _: instance }, PublicInstanceProxyHandlers);
const { setup } = Component;
if (setup) {
// props要求是shallowReadonly类型,从而实现第三点要求
const setupResult = setup(shallowReadonly(instance.props));
handleSetupResult(instance, setupResult);
}
}
不能修改props靠套一层shallowReadonly实现,这样就实现了第一、三个要求。
然后修改组件代理,主要是给proxy新增逻辑,当某个属性不在setupState而在props中,就从props里取。
const hasOwn = (val, key) =>
Object.prototype.hasOwnProperty.call(val, key);
const PublicInstanceProxyHandlers = {
get({ _: instance }, key) {
const { setupState, props } = instance;
if (hasOwn(setupState, key)) {
return setupState[key];
} else if (hasOwn(props, key)) { // 新增
return props[key];
}
const publicGetter = publicPropertiesMap[key];
if (publicGetter) {
return publicGetter(instance);
}
}
};
emit
emit:父组件把函数传给孩子的props,孩子可以在setup中拿到emit,通过emit(函数名)的方式调用父组件传来的函数,从而影响父组件的状态。
emit中传入的函数名不一定要和父组件传来的函数名一致,可以用烤肉串命名方式,比如父亲传来的函数叫fooBar,子组件想要调用它时,可以使用emit('foo-bar')。
修改父组件如下:
export const App = {
render() {
window.self = this;
return h(
'div',
{
id: 'root',
},
[
h('div', {}, 'hi,' + this.msg),
h(Foo, {
count: 1,
// 函数作为props
add: (a, b) => {
console.log('add', a, b);
},
someEvent: () => {
console.log('some event');
}
})
]
);
},
setup() {
return {
msg: 'mini-vue'
};
}
};
子组件:
export const Foo = {
setup(props, { emit }) {
// 拿到emit,调用函数
emit('add', 1, 2);
emit('some-event');
},
render() {
return h('div', {}, 'foo: ' + this.count);
}
};
现在思考如何实现emit,第一步是要往setup中传入emit函数,不难想到把emit保存在instance上,然后在setStatefulComponent中:
const setupResult = setup(shallowReadonly(instance.props), {
emit: instance.emit
});
然后在初始化instance时,要设置emit:
// 用于把烤肉串转为驼峰,因为父组件传的props中,函数名都用驼峰
const camelize = (str) => {
return str.replace(/-(\w)/g, (_, c) => {
return c ? c.toUpperCase() : '';
});
};
// 需要instance,因为需要拿到props,然后从props中获取函数
function emit(instance, event, ...args) {
const { props } = instance;
const handler = props[camelize(event)];
handler && handler(...args);
}
function createComponentInstance(vnode) {
// 这里的component就是instance
const component = {
vnode,
type: vnode.type,
setupState: {},
props: {},
emit: () => {}
};
// 初始化,使用bind将instance传入,这样用户就不用传了
component.emit = emit.bind(null, component) as any;
return component;
}
上面顺便做了烤肉串向驼峰的转换,这样用户就可以在setup中成功拿到emit,并且传入驼峰或烤肉串式函数名,执行函数。
slot
slot的实现有以下目标:
- 实现基本的插槽
- 具名插槽
- 作用域插槽
基本的插槽
component vnode的children属性会作为插槽,所以应该先获取vnode的children。在instance上保存slots属性,在initSlots函数中,先简单实现为instance.slots = instance.vnode.children。
现在,instance上已经保存了slots,this.$slots应当暴露给用户,这样用户在子组件Foo中就可以获取插槽中的结点,调用render生成vnode,最后被渲染出来。根据前面的博客,此处需要给组件代理的map新增成员:
const publicPropertiesMap = {
// 从component类型的vnode获取el
$el: (i) => i.vnode.el,
$slots: (i) => i.slots
};
现在,假设给子组件传的children是vnode类型(string属于文本结点),可以这样使用插槽:
render() {
const foo = h('p', {}, 'foo');
const age = 18;
return h('div', {}, [this.$slots, foo]);
}
如果给子组件传的children是array类型,则不能成功渲染,可以再创建一个vnode,然后把插槽作为其children,children允许为array类型,但是这样写之后,children为vnode或string又不能成功渲染。
render() {
const foo = h('p', {}, 'foo');
const age = 18;
return h('div', {}, [h('div', {}, this.$slots), foo]);
}
在initSlots中,如果children不是数组,instance.slots = [children]。从而保证this.$slots始终是数组。
至此,基本的插槽实现完毕。
具名插槽
具名插槽中,在拿到插槽vnode的基础上,还要考虑位置问题。刚才说给子组件传的children可以有array/string/vnode类型,现在修改为传一个对象。
父组件这样传:
const app = h('div', {}, 'hi, ' + this.msg);
const foo = h(
Foo,
{},
{
header: h('div', {}, 'header ' + age),
footer: h('div', {}, 'footer')
}
);
return h(
'div',
{
id: 'root'
},
[app, foo]
);
},
子组件这样用:
return h('div', {}, [
renderSlots(this.$slots, 'header'),
foo,
renderSlots(this.$slots, 'footer')
]);
先来看看renderSlots的逻辑,核心是构造一个新的vnode,并把slots作为其children,但是由于是具名插槽,只会从this.$slots中取出名字相符的slot。
export function renderSlots(slots, name) {
const slot = slots[name];
if (slot) {
return createVNode('div', {}, slot);
}
}
之前,this.$slots是一个数组,现在能通过slots[name]的方式获取到slot,数组显然不合适,所以instance.slots也是一个对象。
export function initSlots(instance, children) {
const { vnode } = instance;
// 新增ShapeFlags.SLOT_CHILDREN,判断有没有slot
if (vnode.shapeFlag & ShapeFlags.SLOT_CHILDREN) {
normalizeObjectSlots(instance.slots, children);
}
}
function normalizeObjectSlots(slots, children) {
for (const key in children) {
const value = children[key];
// instance.slots是一个对象,给每个key都初始化好slot
slots[key] = normalizeSlotValue(value);
}
}
function normalizeSlotValue(value) {
// 上面说了slot要转为数组的原因
return Array.isArray(value) ? value : [value];
}
现在,renderSlots(this.$slots, 'header')就可以从instance.slots上拿到header对应的slot,生成vnode了。footer也同理,具名插槽实现完毕。
作用域插槽
所谓作用域插槽,指的是插槽中可以传入变量,比如父组件给子组件的插槽内使用了变量age,age要能渲染出来。
插槽对象内的成员改为函数,函数的返回值为vnode:
render() {
const app = h('div', {}, 'hi, ' + this.msg);
const foo = h(
Foo,
{},
{
// 解构出age。为了拿到age,需要使用函数
header: ({ age }) => h('div', {}, 'header ' + age),
footer: () => h('div', {}, 'footer')
}
);
return h(
'div',
{
id: 'root'
},
[app, foo]
);
},
在子组件中,给renderSlots加入第三个参数,用于作用域插槽:
render() {
const foo = h('p', {}, 'foo');
const age = 18;
return h('div', {}, [
// 第三个参数传一个对象,对象里是一些props
renderSlots(this.$slots, 'header', { age }),
foo,
renderSlots(this.$slots, 'footer')
]);
}
然后就是修改之前的逻辑,因为slot成为了函数的返回值,之前用到slot的地方,都需要改为slot(),并且将props传入。
function renderSlots(slots, name, props) {
// slot从this.$slots拿到,也是函数
const slot = slots[name];
if (slot) {
// slot(props)
return createVNode('div', {}, slot(props));
}
}
这里也需要修改:
function normalizeObjectSlots(slots, children) {
for (const key in children) {
const value = children[key]; // value就是slot(函数)
// 修改原函数,让其返回值改为数组
slots[key] = (props) => normalizeSlotValue(value(props));
}
}
function normalizeSlotValue(value) {
return Array.isArray(value) ? value : [value];
}
到此作用域插槽也实现完毕,过程较为漫长,大家辛苦了!
fragment
由上方可知,renderSlots中需要把插槽作为一个结点children,type传入的是'div',这将导致插槽元素总是被div包裹:
想要去掉外边的div,需要使用fragment。定义变量 Fragment = Symbol('Fragment'),修改renderSlots:
export function renderSlots(slots, name, props) {
const slot = slots[name];
if (slot) {
return createVNode(Fragment, {}, slot(props));
}
}
patch的作用是判断结点的类型,再做不同的处理,所以现在要根据vnode的type进行判断:
function patch(vnode, container) {
const { type } = vnode;
switch (type) {
case Fragment:
mountChildren(vnode, container);
break;
// 处理text结点,待会讲
case Text:
processText(vnode, container);
break;
default:
if (vnode.shapeFlag & ShapeFlags.ELEMENT) {
processElement(vnode, container);
} else if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
processComponent(vnode, container);
}
}
}
当判断结点是Fragment类型时,不用把它当一般的element vnode处理,只用渲染它的孩子即可。
现在插槽外面就不会有div了。
textNode
根据之前mountElement的逻辑:
if (vnode.shapeFlag & ShapeFlags.TEXT_CHILDREN) {
el.textContent = children;
} else if (vnode.shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(vnode, el);
}
能渲染成功的children类型有两种,要么children只有一个字符串,要么children是一个数组,数组里全都是使用h函数生成的vnode。如果数组里有字符串,不能被正常渲染出来,比如这样:
// 这两种 element vnode中的文本结点都不能正常渲染。
const bar = h('div', {}, ['str1', 'str2', 'str3']);
const baz = h('div', {}, ['text', h('span', {}, 'after')]);
所以需要单独添加对文本结点的处理。首先定义const Text = Symbol('Text'),并且在patch的switch中添加case,然后实现processText:
function processText(vnode, container) {
const { children } = vnode;
const textNode = document.createTextNode(children);
container.append(textNode);
}
给用户提供创建文本结点的api:
// 类型是Text
export function createTextVNode(text) {
return createVNode(Text, {}, text);
}
然后用户这样使用:
const bar = h('div', {}, [h('p', {}, 'test'), createTextVNode('text结点')]);
现在文本结点'text结点'就能正常渲染了。