Component Props
Developer Interface
Let's start with props.
Let's think about the final developer interface.
Let's consider that props are passed as the first argument to the setup
function.
const MyComponent = {
props: { message: { type: String } },
setup(props) {
return () => h('div', { id: 'my-app' }, [`message: ${props.message}`])
},
}
const app = createApp({
setup() {
const state = reactive({ message: 'hello' })
const changeMessage = () => {
state.message += '!'
}
return () =>
h('div', { id: 'my-app' }, [
h(MyComponent, { message: state.message }, []),
])
},
})
Implementation
Based on this, let's think about the information we want to have in ComponentInternalInstance
.
We need the definition of props specified as props: { message: { type: String } }
, and a property to actually hold the props value, so we add the following:
export type Data = Record<string, unknown>
export interface ComponentInternalInstance {
// .
// .
// .
propsOptions: Props // Holds an object like `props: { message: { type: String } }`
props: Data // Holds the actual data passed from the parent (in this case, it will be something like `{ message: "hello" }`)
}
Create a new file called ~/packages/runtime-core/componentProps.ts
with the following content:
export type Props = Record<string, PropOptions | null>
export interface PropOptions<T = any> {
type?: PropType<T> | true | null
required?: boolean
default?: null | undefined | object
}
export type PropType<T> = { new (...args: any[]): T & {} }
Add it to the options when implementing the component.
export type ComponentOptions = {
props?: Record<string, any> // Added
setup?: () => Function
render?: Function
}
When generating an instance with createComponentInstance
, set the propsOptions to the instance when generating the instance.
export function createComponentInstance(
vnode: VNode
): ComponentInternalInstance {
const type = vnode.type as Component;
const instance: ComponentInternalInstance = {
// .
// .
// .
propsOptions: type.props || {},
props: {},
Let's think about how to form the instance.props
.
At the time of component mounting, filter the props held by the vnode based on the propsOptions.
Convert the filtered object into a reactive object using the reactive
function, and assign it to instance.props
.
Implement a function called initProps
in componentProps.ts
that performs this series of steps.
export function initProps(
instance: ComponentInternalInstance,
rawProps: Data | null,
) {
const props: Data = {}
setFullProps(instance, rawProps, props)
instance.props = reactive(props)
}
function setFullProps(
instance: ComponentInternalInstance,
rawProps: Data | null,
props: Data,
) {
const options = instance.propsOptions
if (rawProps) {
for (let key in rawProps) {
const value = rawProps[key]
if (options && options.hasOwnProperty(key)) {
props[key] = value
}
}
}
}
Actually execute initProps
at the time of mounting, and pass props to the setup
function as an argument.
const mountComponent = (initialVNode: VNode, container: RendererElement) => {
const instance: ComponentInternalInstance = (initialVNode.component =
createComponentInstance(initialVNode));
// init props
const { props } = instance.vnode;
initProps(instance, props);
const component = initialVNode.type as Component;
if (component.setup) {
instance.render = component.setup(
instance.props // Pass props to setup
) as InternalRenderFunction;
}
// .
// .
// .
}
export type ComponentOptions = {
props?: Record<string, any>
setup?: (props: Record<string, any>) => Function // Receive props
render?: Function
}
At this point, props should be passed to the child component, so let's check it in the playground.
const MyComponent = {
props: { message: { type: String } },
setup(props: { message: string }) {
return () => h('div', { id: 'my-app' }, [`message: ${props.message}`])
},
}
const app = createApp({
setup() {
const state = reactive({ message: 'hello' })
return () =>
h('div', { id: 'my-app' }, [
h(MyComponent, { message: state.message }, []),
])
},
})
However, this is not enough, as the rendering is not updated when props are changed.
const MyComponent = {
props: { message: { type: String } },
setup(props: { message: string }) {
return () => h('div', { id: 'my-app' }, [`message: ${props.message}`])
},
}
const app = createApp({
setup() {
const state = reactive({ message: 'hello' })
const changeMessage = () => {
state.message += '!'
}
return () =>
h('div', { id: 'my-app' }, [
h(MyComponent, { message: state.message }, []),
h('button', { onClick: changeMessage }, ['change message']),
])
},
})
To make this component work, we need to implement updateProps
in componentProps.ts
and execute it when the component updates.
~/packages/runtime-core/componentProps.ts
export function updateProps(
instance: ComponentInternalInstance,
rawProps: Data | null,
) {
const { props } = instance
Object.assign(props, rawProps)
}
~/packages/runtime-core/renderer.ts
const setupRenderEffect = (
instance: ComponentInternalInstance,
initialVNode: VNode,
container: RendererElement
) => {
const componentUpdateFn = () => {
const { render } = instance;
if (!instance.isMounted) {
const subTree = (instance.subTree = normalizeVNode(render()));
patch(null, subTree, container);
initialVNode.el = subTree.el;
instance.isMounted = true;
} else {
let { next, vnode } = instance;
if (next) {
next.el = vnode.el;
next.component = instance;
instance.vnode = next;
instance.next = null;
updateProps(instance, next.props); // here
If the screen is updated, it's OK.
Now, you can pass data to the component using props! Great job!
Source code up to this point:
chibivue (GitHub)
As a side note, although it's not necessary, let's implement the ability to receive props in kebab-case, just like in the original Vue.
At this point, create a directory called ~/packages/shared
and create a file called general.ts
in it.
This is the place to define general functions, not only for runtime-core
and runtime-dom
.
Following the original Vue, let's implement hasOwn
and camelize
.
~/packages/shared/general.ts
const hasOwnProperty = Object.prototype.hasOwnProperty
export const hasOwn = (
val: object,
key: string | symbol,
): key is keyof typeof val => hasOwnProperty.call(val, key)
const camelizeRE = /-(\w)/g
export const camelize = (str: string): string => {
return str.replace(camelizeRE, (_, c) => (c ? c.toUpperCase() : ''))
}
Let's use camelize
in componentProps.ts
.
export function updateProps(
instance: ComponentInternalInstance,
rawProps: Data | null,
) {
const { props } = instance
// -------------------------------------------------------------- here
Object.entries(rawProps ?? {}).forEach(([key, value]) => {
props[camelize(key)] = value
})
}
function setFullProps(
instance: ComponentInternalInstance,
rawProps: Data | null,
props: Data,
) {
const options = instance.propsOptions
if (rawProps) {
for (let key in rawProps) {
const value = rawProps[key]
// -------------------------------------------------------------- here
// kebab -> camel
let camelKey
if (options && hasOwn(options, (camelKey = camelize(key)))) {
props[camelKey] = value
}
}
}
}
Now you should be able to handle kebab-case as well. Let's check it in the playground.
const MyComponent = {
props: { someMessage: { type: String } },
setup(props: { someMessage: string }) {
return () => h('div', {}, [`someMessage: ${props.someMessage}`])
},
}
const app = createApp({
setup() {
const state = reactive({ message: 'hello' })
const changeMessage = () => {
state.message += '!'
}
return () =>
h('div', { id: 'my-app' }, [
h(MyComponent, { 'some-message': state.message }, []),
h('button', { onClick: changeMessage }, ['change message']),
])
},
})