Книга: Build a Frontend Web Framework (From Scratch)
Назад: 8.2.6 Patching child nodes
Дальше: 11.2.4 Emitting events

<p>A</p>

<p>B</p>

<span>C</span>

Text

Fragment

<p>D</p>

"A"

</div>

<p>

<span>

<p>

The component’s HTML

Text

Text

Text

"B"

"C"

"D"

i = 0 (in fragment)

i = 1 (in fragment)

i = 2 (in fragment)

i = 1 (in div)

i = 2 (in div)

i = 3 (in div)

Figure 9.12

Adding a node to the component’s view with an offset

Listing 9.10

Patching the children using the offset (patch-dom.js)

function patchChildren(oldVdom, newVdom, hostComponent) {

const oldChildren = extractChildren(oldVdom)

const newChildren = extractChildren(newVdom)

Adds the host

const parentEl = oldVdom.el

component as an argument

to the patchChildren() function

const diffSeq = arraysDiffSequence(

oldChildren,

newChildren,

areNodesEqual

)

for (const operation of diffSeq) {

Gets the host component’s

const { from, index, item } = operation

offset, if there is one; offset

is zero otherwise

const offset = hostComponent?.offset ?? 0

switch (operation.op) {

case ARRAY_DIFF_OP.ADD: {

232

CHAPTER 9

Stateful components

mountDOM(item, parentEl, index + offset, hostComponent)

break

}

When a node is added, takes

account of the host

case ARRAY_DIFF_OP.REMOVE: {

component’s offset

destroyDOM(item)

break

}

When a node is moved, uses

the offset to find the correct

case ARRAY_DIFF_OP.MOVE: {

position in the DOM

const el = oldChildren[from].el

const elAtTargetIndex = parentEl.childNodes[index + offset]

parentEl.insertBefore(el, elAtTargetIndex)

patchDOM(

oldChildren[from],

newChildren[index],

parentEl,

hostComponent

Passes the host

)

component back to the

patchDOM() function

break

}

case ARRAY_DIFF_OP.NOOP: {

patchDOM(

oldChildren[from],

newChildren[index],

Passes the host

parentEl,

component to the

patchChildren() function

hostComponent

)

break

}

}

}

}

With this nuance out of the way, we can focus on the component’s methods.

Exercise 9.3

Can you explain why the problem of the component’s offset doesn’t arise when the component’s view is a single root element?

Find the solution .

Summary

 A stateful component can be modeled as a class that can be instantiated.

 Each instance of a component has its own state and lifecycle, and it manages its own DOM subtree.

 To allow users to create component classes with custom states, render functions, and other methods, you can use a factory function.

Summary

233

 The state of a component is created at instantiation time, and you can update it by calling the updateState() method.

 When the updateState() method is called, the component patches its view.

 The render() function and event handlers of a component need to be explicitly bound to the component instance so that this refers to the component instance inside them.

Component methods

This chapter covers

 Implementing component methods to handle

events

 Binding a method’s context to the component

 Passing the host component reference to mount

and patch functions

What happens when you’re handling an event in a component that requires a couple of lines of code? One-liners, like this one, look fine inside the virtual Document Object Model (DOM) definition:

render() {

const { count } = this.state

Handles the click

return hFragment([

event by decrementing

h(

the count

'button',

{ on: { click: () => this.updateState({ count: count - 1 }) }}

['Decrement']

),

h('span', {}, [count]),

h(

234

235

'button',

{ on: { click: () => this.updateState({ count: count + 1 }) }}

['Increment']

)

Handles the click

])

event by incrementing

}

the count

But what happens when you want to do more than you can fit in a single line? Well, you could fit the code inside the virtual DOM (vdom) definition, but then the code will be harder to read. Look at the following example list, in which clicking a button loads more items from the server:

render() {

const { items, offset, isLoading } = this.state

return hFragment([

h(

'ul',

{},

items.map((item) => h('li', {}, [item])),

),

isLoading

? h('p', {}, ['Loading...'])

: h(

'button',

{

The event handler spans

multiple lines, making the

on: {

code harder to read.

click: async () => {

const { items, offset } = this.state

this.updateState({ isLoading: true })

const newItems = await fetchItems(offset)

this.updateState({

items: [...items, ...newItems],

isLoading: false,

offset: offset + newItems.length,

})

},

},

},

['Load more'],

),

])

}

This code doesn’t look as nice as the preceding example. Instead, we want to move the event-handler code to a method of the component and reference the method as the handler for the event. This approach results in much cleaner code: render() {

const { items, offset, isLoading } = this.state

236

CHAPTER 10

Component methods

return hFragment([

h(

'ul',

{},

items.map((item) => h('li', {}, [item])),

),

isLoading

? h('p', {}, ['Loading...'])

: h(

'button',

Now the event handler

is a reference to the

{

loadMore() method.

on: { click: this.loadMore },

},

['Load more'],

),

])

}

So a loadMore() method inside the component would handle the event. You need a way to add custom methods to the component. How do you go about it? You can pass the defineComponent() function together with the render() and state() functions, which are other arbitrary methods. In the preceding example, you’d define the component as follows:

const InfiniteList = defineComponent(

state() { ... },

The loadMore() method

is defined as part of the

render() { ... },

component.

async loadMore() {

const { items, offset } = this.state

this.updateState({ isLoading: true })

const newItems = await fetchItems(offset)

this.updateState({

items: [...items, ...newItems],

isLoading: false,

offset: offset + newItems.length,

})

},

)

The objective of this chapter is to modify the defineComponent() function so that it can receive custom methods to handle events. The changes are straightforward, but the methods won’t be bound to the component’s context correctly when they’re used as event handlers. As you’ll see soon, you need to explicitly bind the event handlers to the component’s context. This topic will take up most of the chapter.

NOTE

You can find all the listings in this chapter in the listings/ch10 directory of the book’s repository (The code you write in this chapter is for the framework’s version v3.0, which you’ll publish in chapter 12.

10.1

Component methods

237

Therefore, the code in this chapter can be checked out from the ch12 label ( $ git switch --detach ch12.

Code catch-up

In chapter 9, you wrote the defineComponent() function, which receives the render() and state() functions as arguments and returns a Component class. You implemented the following methods in the Component class:

 render()—Renders the component’s virtual DOM

 mount()—Mounts the component’s view into the DOM

 unmount()—Unmounts the component from the DOM

 #patch()—Patches the component’s view in the DOM

 updateState()—Updates the component’s state and patches the view in the DOM

You also modified the patchChildren() function to receive one more argument: the host component (the component that owns the children). You needed this component to calculate the offset of the children so that the operations that require moving nodes in the DOM could be performed correctly. For the patchChildren() function to receive the host component, you also had to modify the patchDOM() function, adding the hostComponent argument to it.

10.1

Component methods

We need to update our analogy from chapter 9, in which defineComponent() was a component factory. This factory, in addition to the render() and state() functions, can receive other methods that will be added to the component’s prototype (figure 10.1).

Component

state = state()

render()

render()

state()

mount()

foo()

unmount()

bar()

...

foo()

defineComponent()

bar()

...

Other user-defined methods

included in the component class

Figure 10.1

Using defineComponent() to define a component with custom methods

238

CHAPTER 10

Component methods

To add custom methods to the component, you can use JavaScript’s prototypal inheritance ). Classes in JavaScript are syntactic sugar for prototypes, and extending a prototype to include a new method is as simple as

class Component {}

Component.prototype.loadMore = function () { ... }

But you want to prevent the user from redefining a method that already exists in the component, such as updateState() or mount(). Overriding these methods would break the component. First, you want to use the hasOwnProperty() method to check whether the method already exists in the component’s prototype. Unfortunately, this method isn’t safe to use (a malicious user could use it to do some harm), and ESLint will complain about it. Fortunately for us, the same ESLint rule that complains about hasOwnProperty() also provides a suggestion for a safe implementation of it.

NOTE

If you want to understand better why using an object’s hasOwnProperty() method is unsafe, see You may also want to learn more about JavaScript’s prototypal inheritance. If so, I recommend that you take some time to read .

Inside the utils/objects.js file, add the hasOwnProperty() function as shown in the following listing.

Listing 10.1

A safe implementation of hasOwnProperty() (utils/objects.js) export function hasOwnProperty(obj, prop) {

return Object.prototype.hasOwnProperty.call(obj, prop)

}

Now go back to the component.js file. Write the code shown in bold in the following listing to add custom methods to the component.

Listing 10.2

Adding custom methods to the component (component.js)

import { destroyDOM } from './destroy-dom'

import { DOM_TYPES, extractChildren } from './h'

import { mountDOM } from './mount-dom'

Destructures the rest

import { patchDOM } from './patch-dom'

of the methods passed

import { hasOwnProperty } from './utils/objects'

to defineComponent()

export function defineComponent({ render, state, ...methods }) {

class Component {

// --snip-- //

Iterates over the

}

method names

Ensures that the

component doesn’t

for (const methodName in methods) {

already have a method

with the same name

if (hasOwnProperty(Component, methodName)) {

throw new Error(

`Method "${methodName}()" already exists in the component.`

10.1

Component methods

239

)

}

Adds the

method to the

Component.prototype[methodName] = methods[methodName]

component’s

}

prototype

return Component

}

This code is great, but the InfiniteList component from the preceding example won’t work now:

const InfiniteList = defineComponent(

state() { ... },

render() {

const { items, offset, isLoading } = this.state

return hFragment([

h(

'ul',

{},

items.map((item) => h('li', {}, [item])),

),

isLoading

? h('p', {}, ['Loading...'])

: h(

'button',

{

on: { click: this.loadMore }

},

['Load more'],

),

])

},

async loadMore() { ... },

)

We get an error because this is bound to the <button> element that’s emitting the click event, not to the component. Hence, this.loadMore() is undefined because buttons in the DOM don’t include a loadMore() method; if they did, you wouldn’t see any error, but your loadMore() method would never be called. The culprit is the line in bold:

h(

'button',

{

// Fails, because 'this' is bound to the <button> element

on: { click: this.loadMore },

},

['Load more'],

)

240

CHAPTER 10

Component methods

When you add an event handler by using the addEventHandler() method, the this keyword of the function is bound to the element that emitted the event—in the preceding example, the <button> element. You don’t want the this keyword to be bound to the element; you want it to be bound to the component so that the methods inside it can be resolved correctly, as shown in figure 10.2.

Items

We don’t want the method’s context

bound to the button element.

Component

render()

loadMore()

Load more

...

Binding

!

'click' : loadMore

Binding

The method’s context should be

bound to the component instance.

Figure 10.2

Binding of the loadMore() method’s this keyword

You can get around this problem quickly by wrapping the event handler in an arrow function:

h(

'button',

{

// Works by wrapping the event handler in an arrow function

on: { click: () => this.loadMore() },

},

['Load more'],

)

NOTE

Arrow functions can’t have their own this keyword, so the this keyword inside the arrow function is inherited from the containing function.

Arrow functions are lexically scoped, which means that the this keyword is bound to the value of this in the enclosing (function or global) scope.

In our case, this function is the render() function, and the this keyword is bound to the component, which is why you had to do render.call(this) inside the component’s render method. Arrow functions use lexical scoping (), so the addEventListener() method can’t bind the this keyword to the element that emitted the event.

10.2

Binding event handlers to the component

241

We don’t want to force the user to wrap the event handler in an arrow function, however. We want them to be able to reference a method from the component and have it work. For that purpose, we need to explicitly bind the event handler to the component so that the this keyword is bound to the component.

10.2

Binding event handlers to the component

Open the events.js file. You need to make some modifications to the addEventListener() function. You want to pass the host component reference to the function, which can be null in case the event handler isn’t bound to a component. If the hostComponent argument isn’t null, you want to bind the event handler to the component by using the .apply() method. This method is similar to .call(), but you can pass it an array of arguments instead of passing arguments one by one. When the hostComponent is null, you want to call the event handler as is:

hostComponent

? handler.apply(hostComponent)

: handler()

Keep in mind that an event handler can receive arguments, so you need to pass those arguments to the event handler when you call it. You can wrap the code from the preceding code snippet in a new handler function that passes its arguments to the original handler function:

function boundHandler() {

hostComponent

? handler.apply(hostComponent, arguments)

: handler(...arguments)

}

NOTE

If you’re not familiar with the arguments object, you can read more about it In a nutshell, arguments is an arraylike object that contains the arguments passed to a function. Bear in mind that inside an arrow function, the arguments object isn’t available.

Now that the plan is clear, modify your code inside the events.js file as shown in the following listing.

Listing 10.3

Event handlers bound to the component (events.js)

export function addEventListeners(

listeners = {},

Adds the host

component argument

el,

to the function

hostComponent = null

) {

const addedListeners = {}

Object.entries(listeners).forEach(([eventName, handler]) => {

const listener = addEventListener(

242

CHAPTER 10

Component methods

eventName,

handler,

Passes the host component

to the addEventListener()

el,

function

hostComponent

)

addedListeners[eventName] = listener

})

return addedListeners

}

export function addEventListener(

eventName,

handler,

Adds the host

component argument

el,

to the function

hostComponent = null

) {

el.addEventListener(eventName, handler)

return handler

function boundHandler() {

If a host component

exists, binds it to the

hostComponent

event handler context...

? handler.apply(hostComponent, arguments)

: handler(...arguments)

...otherwise, calls

}

the event handler

el.addEventListener(eventName, boundHandler)

Adds the bound

event listener to

return boundHandler

the element

}

Now that your event handlers are properly bound to the component—in case there is one—you need to modify the mountDOM() and patchDOM() functions so that they pass the host component to the addEventListener() function.

10.3

Mounting the DOM with a host component

Open the mount-dom.js file to modify the mountDOM() function. Add the code shown in bold in the following listing so that the host component can be passed as an argument.

Listing 10.4

Passing the host component to the mountDOM() function (mount-dom.js) export function mountDOM(

vdom,

parentEl,

Adds the host component

as an argument to the

index,

function

hostComponent = null

) {

switch (vdom.type) {

case DOM_TYPES.TEXT: {

createTextNode(vdom, parentEl, index)

break

}

10.3

Mounting the DOM with a host component

243

case DOM_TYPES.ELEMENT: {

createElementNode(vdom, parentEl, index, hostComponent)

break

}

Passes the host component to the

createElementNode() function

case DOM_TYPES.FRAGMENT: {

createFragmentNodes(vdom, parentEl, index, hostComponent)

break

}

Passes the host component to the

createFragmentNodes() function

default: {

throw new Error(`Can't mount DOM of type: ${vdom.type}`)

}

}

}

You passed the hostElement reference down to the createElementNode() and createFragmentNodes() functions. Now modify them to accept that argument. First, add the hostComponent argument to the createElementNode() function, as shown in the following listing. From there, you can pass the hostComponent argument to the addProps() function and back to the mountDOM() function.

Listing 10.5

Passing the host component (mount-dom.js)

function createElementNode(vdom, parentEl, index, hostComponent) {

const { tag, props, children } = vdom

Adds the host component as an

argument to the function

const element = document.createElement(tag)

addProps(element, props, vdom, hostComponent)

Passes the host

vdom.el = element

component to the

addProps() function

children.forEach((child) =>

mountDOM(child, element, null, hostComponent)

)

Passes a null index and

insert(element, parentEl, index)

the host component to the

}

mountDOM() function

Now modify the addProps() function so that the passed-in hostComponent argument is passed to the addEventListeners() function, as shown in the following listing. This change is the most important one in this section because it’s where you pass the host component to the addEventListener() function you modified earlier.

Listing 10.6

Passing the host component (mount-dom.js)

Adds the host component

function addProps(el, props, vdom, hostComponent) {

as an argument to the

const { on: events, ...attrs } = props

function

vdom.listeners = addEventListeners(events, el, hostComponent) setAttributes(el, attrs)

Passes the host component to the

}

addEventListeners() function

244

CHAPTER 10

Component methods

Last, modify the createFragmentNodes() function so that the hostComponent argument is passed back to the mountDOM() function, as shown in the following listing.

Listing 10.7

Passing the host component (mount-dom.js)

function createFragmentNodes(

vdom,

parentEl,

Adds the host component

as an argument to the

index,

function

hostComponent

) {

const { children } = vdom

vdom.el = parentEl

children.forEach((child) =>

mountDOM(

child,

parentEl,

Passes the host

component to the

index ? index + i : null,

mountDOM() function

hostComponent

)

)

}

Great! Let’s end the chapter by modifying the patchDOM() function so that the host component is passed and can be used to patch event handlers.

10.4

Patching the DOM with a host component

If you recall, you modified the patchDOM() function in chapter 9 so that it passes the host component to the patchChildren() function. You made this change so you could pass it to the patchChildren() function, taking the component’s offset into account.

Open the patch-dom.js file again, and this time, pass the hostComponent reference to the mountDOM() function (the case when the DOM is re-created) and to the patchElement() function, as shown in the following listing.

Listing 10.8

Passing the host component (patch-dom.js)

export function patchDOM(

oldVdom,

newVdom,

parentEl,

hostComponent = null

) {

if (!areNodesEqual(oldVdom, newVdom)) {

const index = findIndexInParent(parentEl, oldVdom.el)

destroyDOM(oldVdom)

mountDOM(newVdom, parentEl, index, hostComponent)

Passes the host

component to

return newVdom

the mountDOM()

}

function

10.4

Patching the DOM with a host component

245

newVdom.el = oldVdom.el

switch (newVdom.type) {

case DOM_TYPES.TEXT: {

patchText(oldVdom, newVdom)

return newVdom

}

Passes the host

component to the

case DOM_TYPES.ELEMENT: {

patchElement()

patchElement(oldVdom, newVdom, hostComponent)

function

break

}

}

patchChildren(oldVdom, newVdom, hostComponent)

return newVdom

}

Let’s modify the patchElement() function so that it receives the hostComponent argument, as shown in the following listing. You also want to pass it down to the patchEvents() function, which you’ll modify next.

Listing 10.9

Passing the host component (patch-dom.js)

function patchElement(oldVdom, newVdom, hostComponent) {

Adds the host

const el = oldVdom.el

component as an

const {

argument to the

class: oldClass,

function

style: oldStyle,

on: oldEvents,

...oldAttrs

} = oldVdom.props

const {

class: newClass,

style: newStyle,

on: newEvents,

...newAttrs

} = newVdom.props

const { listeners: oldListeners } = oldVdom

patchAttrs(el, oldAttrs, newAttrs)

patchClasses(el, oldClass, newClass)

patchStyles(el, oldStyle, newStyle)

newVdom.listeners = patchEvents(

el,

oldListeners,

oldEvents,

Passes the host

component to the

newEvents,

patchEvents() function

hostComponent

)

}

246

CHAPTER 10

Component methods

Now add the hostComponent argument to the patchEvents() function, as shown in the following listing. When an event handler is added or updated, the addEventListener() is called, so remember to pass the hostComponent argument to it.

Listing 10.10

Passing the host component (patch-dom.js)

function patchEvents(

el,

oldListeners = {},

oldEvents = {},

Adds the host

component as an

newEvents = {},

argument to the function

hostComponent

) {

const { removed, added, updated } = objectsDiff(oldEvents, newEvents) for (const eventName of removed.concat(updated)) {

el.removeEventListener(eventName, oldListeners[eventName])

}

const addedListeners = {}

for (const eventName of added.concat(updated)) {

const listener = addEventListener(

eventName,

newEvents[eventName],

Passes the host component

to the addEventListener()

el,

function

hostComponent

)

addedListeners[eventName] = listener

}

return addedListeners

}

Finally, go back to the component.js file. Modify the mount() function so that it passes the hostComponent argument to the mountDOM() function, as shown in the following listing.

Listing 10.11

Component’s mount() passing this reference (component.js) export function defineComponent({ render, state, ...methods }) {

class Component {

// --snip-- //

mount(hostEl, index = null) {

if (this.#isMounted) {

throw new Error('Component is already mounted')

}

Passes the component

reference to the

this.#vdom = this.render()

mountDOM() function

mountDOM(this.#vdom, hostEl, index, this)

10.4

Patching the DOM with a host component

247

this.#hostEl = hostEl

this.#isMounted = true

}

// --snip-- //

}

// --snip-- //

return Component

}

You’re done! Before we close the chapter, let’s review what should result when the defineComponent() function returns a Component class. Then I’ll give you a challenge exercise to test your knowledge.

Your component.js file should look like the following listing. Make sure that you got all the details right before moving on to chapter 11. Can you explain what each property and method of the Component class does?

Listing 10.12

Result of the defineComponent() function (component.js)

import { destroyDOM } from './destroy-dom'

import { DOM_TYPES, extractChildren } from './h'

import { mountDOM } from './mount-dom'

import { patchDOM } from './patch-dom'

import { hasOwnProperty } from './utils/objects'

export function defineComponent({ render, state, ...methods }) {

class Component {

#isMounted = false

#vdom = null

#hostEl = null

constructor(props = {}) {

this.props = props

this.state = state ? state(props) : {}

}

get elements() {

if (this.#vdom == null) {

return []

}

if (this.#vdom.type === DOM_TYPES.FRAGMENT) {

return extractChildren(this.#vdom).map((child) => child.el)

}

return [this.#vdom.el]

}

get firstElement() {

return this.elements[0]

}

248

CHAPTER 10

Component methods

get offset() {

if (this.#vdom.type === DOM_TYPES.FRAGMENT) {

return Array.from(this.#hostEl.children).indexOf(this.firstElement)

}

return 0

}

updateState(state) {

this.state = { ...this.state, ...state }

this.#patch()

}

render() {

return render.call(this)

}

mount(hostEl, index = null) {

if (this.#isMounted) {

throw new Error('Component is already mounted')

}

this.#vdom = this.render()

mountDOM(this.#vdom, hostEl, index, this)

this.#hostEl = hostEl

this.#isMounted = true

}

unmount() {

if (!this.#isMounted) {

throw new Error('Component is not mounted')

}

destroyDOM(this.#vdom)

this.#vdom = null

this.#hostEl = null

this.#isMounted = false

}

#patch() {

if (!this.#isMounted) {

throw new Error('Component is not mounted')

}

const vdom = this.render()

this.#vdom = patchDOM(this.#vdom, vdom, this.#hostEl, this)

}

}

for (const methodName in methods) {

if (hasOwnProperty(Component, methodName)) {

throw new Error(

`Method "${methodName}()" already exists in the component.`

Summary

249

)

}

Component.prototype[methodName] = methods[methodName]

}

return Component

}

Now it’s time for the challenge exercise!

Exercise 10.1: Challenge

A free API returns random cocktails. Try it in your browser to see what it returns: GET https://www.thecocktaildb.com/api/json/v1/1/random.php

For this challenge exercise, I want you to write the code for a component that fetches a random cocktail and displays it. When the component is mounted, it should show HTML similar to the following:

<h1>Random cocktail</h1>

<button>Get a cocktail</button>

When the button is clicked, the component should fetch a random cocktail and display it. You should display a Loading . . . message while the cocktail is being fetched and maybe add an artificial timeout of 1 or 2 seconds for the message to have time to be displayed. When the cocktail is fetched, display its name (strDrink field), image (strDrinkThumb field), and preparation instructions (strInstructions field). Include a button to load another cocktail:

<h1>Cocktail name</h1>

<p>Preparation instructions</p>

<img src="cocktail-image-url" />

<button>Get another cocktail</button>

Copy and paste the code for your component (and the framework code that’s needed for it to work) into the console of your browser, and mount it inside a website of your choice.

Find the solution at .

Summary

 Methods are convenient for handling events in components. When the handling logic has more than a couple of lines, it’s cleaner to define a method for it.

 Methods can be used as event handlers in the on object of the h() function.

They can be referenced as this.methodName in the on object.

 Functions added as event handlers have to be explicitly bound to the component instance so that this inside the function refers to the component.

Subcomponents:

Communication

via props and events

This chapter covers

 Adding a new virtual DOM type to represent

components

 Implementing subcomponents

 Passing data from a parent component to its

children using props

 Communicating among components by using

events

What’s the value of a component if it can’t include other components? The Component class that you implemented in chapters 9 and 10 can’t return other components from its render() method; it can return only fragments, elements, or text nodes.

But, as you can imagine, building a complex application by using a single component that renders the entire view isn’t very practical.

In this chapter, you learn how to add subcomponents (components inside other components) to your framework and how to communicate among them by using props and events. A parent component can pass data to its children by using props, and children can communicate with their parents by emitting events, as illustrated in figure 11.1.

250

251

Component

Events move up the

component hierarchy.

state:

- foo: 3

Props are data that flow

- bar: 5

down the view hierarchy.

Event

payload

<div>

The props of a component

come from its parent component.

Component

Component

props:

props:

- foo: 3

- bar: 5

events:

- click

Figure 11.1

Props flow down; events flow up.

Let’s briefly review the “Anatomy of a stateful component” diagram from chapter 9 and the annotated properties and methods you’ll implement in this chapter (figure 11.2).

In this chapter, you’ll add the following private properties to the Component class:

 parentComponent—A reference to the component that contains the current component. This property is null for the top-level component.

 dispatcher—An instance of the Dispatcher class used to emit events and subscribe handlers to them.

 subscriptions—An array of subscriptions of event handlers to events emitted by the component. These subscriptions need to be unsubscribed when the component is unmounted.

You’ll also implement two important methods:

 updateProps()—This method is called when the component receives new props; it updates the component’s props property and triggers a re-render. I’ll show you an optional optimization that you can implement to avoid re-rendering the component if the new props are the same as the current ones.

 emit()—This method is used to emit events from the component.

When you finish this chapter, you’ll have a fully functional component prototype that handles its own state and lifecycle and that can include other components. That work is an enormous step forward in the development of your framework, so let’s get started!

252

CHAPTER 11

Subcomponents: Communication via props and events

Anatomy of a stateful component

Component

Private

isMounted

The component that contains

vdom

this component (null if root)

hostEl

parentComponent

Dispatcher used to emit events

dispatcher

to the parent component

subscriptions

patch()

List of subscriptions of the

parent component to the events

Public

props

state

get elements

get firstElement

get offset

Updates the props and

triggers a render cycle

updateProps(props)

updateState(state)

async onMounted()

async onUnmounted()

Emits an event for the parent

component to handle

emit(eventName, payload)

render()

mount(hostEl, index)

unmount()

<<custom methods>>

Figure 11.2

Anatomy of a stateful component

NOTE

You can find all the listings in this chapter in the listings/ch11 directory of the book’s repository ). The code you’ll write in this chapter is for the framework’s version v3.0, which you’ll publish in chapter 12. Therefore, the code in this chapter can be checked out from the ch12

label ( $ git switch --detach ch12.

Code catch-up

In chapter 10, you modified the defineComponent() function to include custom methods in the component’s prototype. You had to modify the addEventListeners() function so that if the element to which the listener is to be attached has a host component, the listener is bound to it. This change required you to modify the mountDOM(),

11.1

Adding components as a new virtual DOM type

253

createElementNode(), and createFragmentNodes() functions. You also modified the patchDOM(), patchElement(), and patchEvents() functions in all cases to add the host component argument that needs to be passed around so that the addEventListeners() function can access it.

11.1

Adding components as a new virtual DOM type

The reason why your component—as implemented in the preceding two chapters—

can’t return other components is that you currently have no way to include components in the virtual DOM tree. Ideally, you’d want to use the h() function to create component nodes, like this:

const FooComponent = defineComponent({ ... })

const BarComponent = defineComponent({ ... })

const ParentComponent = defineComponent({

render() {

return hFragment([

h(FooComponent),

h(BarComponent),

])

}

})

The render() method of the ParentComponent returns a fragment with two component nodes: FooComponent and BarComponent. The virtual DOM tree could look something like the following example:

{

type: 'fragment',

children: [

{ type: 'component', tag: FooComponent, props: {}, children: [] },

{ type: 'component', tag: BarComponent, props: {}, children: [] },

]

}

Notice that the type property of the component nodes is 'component' and that the tag property contains the component prototype instead of a string with the HTML tag name. But the h() function doesn’t know how to mount component nodes—at least not yet, so let’s see how to do it.

To add components inside a virtual DOM tree, you need to add a new virtual DOM

type to the DOM_TYPES constant to represent those components. Then you need to teach the h() function how to differentiate between element nodes and component nodes. One way is to check whether the tag argument is a string, in which case you know that the node must be an element node. In JavaScript, the typeof operator returns 'function' for classes, as you can check yourself in the console:

254

CHAPTER 11

Subcomponents: Communication via props and events

class Component {}

typeof Component // 'function'

You could use this operator to check whether the tag argument is a component, but I chose to check whether the tag argument is a string. Both approaches work the same way.

Open the h.js file, and add the COMPONENT constant to the DOM_TYPES object. Then add the condition to check whether the tag argument is a string, as the code shown in bold in the following listing.

Listing 11.1

Adding components as a new virtual DOM type (h.js)

export const DOM_TYPES = {

TEXT: 'text',

ELEMENT: 'element',

FRAGMENT: 'fragment',

COMPONENT: 'component',

}

export function h(tag, props = {}, children = []) {

const type =

typeof tag === 'string' ? DOM_TYPES.ELEMENT : DOM_TYPES.COMPONENT

return {

tag,

props,

type,

children: mapTextNodes(withoutNulls(children)),

type: DOM_TYPES.ELEMENT,

}

}

Now that virtual node trees can include component nodes, you want to update the Component class’s elements getter to return the elements of a component node.

11.1.1 Updating the elements getter

You’ll recall that in chapter 9, we discussed the elements getter, which retrieves the top-level HTML elements of the virtual node tree representing the component’s view.

You used this getter to calculate the offset property of the component. This offset was crucial for accurately updating the component’s DOM when the top-level node was a fragment.

You need to modify the case when the top-level node is a fragment to account for the subcomponents that might be inside the fragment. In this case, you call the elements getter recursively, as illustrated in figure 11.3. Look at the figure from bottom to top. Each component in the hierarchy assembles an array of its view’s first-level elements, and if it finds a component, it calls the elements getter—hence, the recur-sion. As you can see, the topmost component has a fragment at the root of its view, so it looks inside it to find the first-level elements. First, it finds a component that has two

11.1

Adding components as a new virtual DOM type

255

Component

[ <h1> <p> <div> <span> ]

elements

First

Second

component

component

Fragment

[ <h1> <p> ]

[ <span> ]

Component

<div>

Component

elements

elements

Fragment

Text

<span>

"C"

<h1>

<p>

Text

"D"

...

...

Figure 11.3

Calling the elements getter recursively to get the parent component’s elements elements in its first level (inside the fragment): an <h1> and a <p> element. Then it finds a <div> element, which is the second element in the fragment. Finally, it finds another component, which has a single <p> element in its first level.

The component’s elements getter returns an array, not a single element, so you want to use the flatMap() method to flatten the array of arrays. In the component.js file, make the changes shown in bold in the following listing.

Listing 11.2

Extracting the elements of a component (component.js)

export function defineComponent({ render, state, ...methods }) {

class Component {

// --snip-- //

get elements() {

if (this.#vdom == null) {

return []

Flat-maps

}

the arrays

if (this.#vdom.type === DOM_TYPES.FRAGMENT) {

return extractChildren(this.#vdom).map((child) => child.el)

return extractChildren(this.#vdom).flatMap((child) => {

if (child.type === DOM_TYPES.COMPONENT) {

Checks whether

return child.component.elements

the node is a

}

component

return [child.el]

Otherwise, returns

Calls the elements

})

the node’s element

getter recursively

}

inside an array

256

CHAPTER 11

Subcomponents: Communication via props and events

return [this.#vdom.el]

}

// --snip-- //

}

// --snip-- //

return Component

}

With this small but necessary change out of the way, let’s focus on mounting, destroying, and patching component nodes.

11.1.2 Mounting component virtual nodes

To mount component nodes, first you need to include the DOM_TYPES.COMPONENT case in the mountDOM() function’s switch statement. The boldface code in the following listing shows how. Make this change in the mount-dom.js file.

Listing 11.3

Mounting component virtual nodes (mount-dom.js)

export function mountDOM(vdom, parentEl, index, hostComponent = null) {

switch (vdom.type) {

case DOM_TYPES.TEXT: {

createTextNode(vdom, parentEl, index)

break

}

case DOM_TYPES.ELEMENT: {

createElementNode(vdom, parentEl, index, hostComponent)

break

}

case DOM_TYPES.FRAGMENT: {

createFragmentNodes(vdom, parentEl, hostComponent)

break

}

Checks whether

the node is a

component

case DOM_TYPES.COMPONENT: {

createComponentNode(vdom, parentEl, index, hostComponent) break

}

Mounts the

component node

default: {

throw new Error(`Can't mount DOM of type: ${vdom.type}`)

}

}

}

The job of mounting the component is delegated to a function called createComponentNode(), which you need to implement next. Let’s see what this function needs to do.

11.1

Adding components as a new virtual DOM type

257

First, you want to extract the component’s instance from the virtual node’s tag key and instantiate it, using code similar to the following:

const vnode = {

type: 'component',

tag: FooComponent,

props: {},

}

const Component = vnode.tag

const component = new Component()

In chapter 9, you learned that you can instantiate a component class by passing it a props object. These props objects contain data received from the parent component.

The virtual node you defined also includes a props object. Initially, you used this object to pass attributes and event handlers to element nodes; you can use it to pass props and event handlers to component nodes as well.

Let’s explore an example that demonstrates how to achieve this goal. Suppose that you’ve defined a component called Greetings that receives a name prop and renders a paragraph with a greeting message:

const Greetings = defineComponent({

render() {

const { name } = this.props

return h('p', {}, [`Hello, ${name}!`])

}

})

To instantiate this component including the name prop, you can do something like the following:

const vnode = {

type: 'component',

tag: Greetings,

props: { name: 'Jenny' },

}

const Component = vnode.tag

const component = new Component(vnode.props)

Next, you want to mount the component. To do so, call the component instance’s mount() method, which renders its view via the render() method and passes the resulting virtual DOM tree to the mountDOM() function. Whenever another component is found in the virtual DOM tree, the process is repeated. Figure 11.4 illustrates this cycle.

Keep in mind one very important thing when you mount component nodes: you want to keep the instance of the component in the virtual node. Components have state—state that changes as the user interacts with its view. You want to keep this state

258

CHAPTER 11

Subcomponents: Communication via props and events

Component

1. Mount component.

3. Call mountDOM().

mount()

function mountDOM() {

.

...

2. Render.

render()

component.mount()

<div>

...

}

mountDOM()

<h1>

Component

6. Call

mount()

4. Mount component.

render()

Text

"A"

<p>

...

5. Render.

Figure 11.4

Mounting a component virtual node

“alive” throughout the application’s lifecycle. If the component is instantiated every time the DOM is patched, the state is lost. This internal state is the key difference between your new stateful components and the previous version, which were pure functions without internal state. By saving the component instance in the virtual node, you can access it inside the patchDOM() function and, thus, don’t need to instantiate it again. Figure 11.5 shows how the component instance is saved in the virtual node.

Component

new Component(...)

C

<div>

The component instance is

el

saved into the virtual node.

<h1>

<p>

el

el

Text

Text

el

el

"A"

"B"

Figure 11.5

A reference to the component instance is saved in the

virtual node.

11.1

Adding components as a new virtual DOM type

259

Following the preceding example, after you instantiate and mount the component, you save the instance in the virtual node inside a property called component, as follows: const Component = vnode.tag

const component = new Component(vnode.props)

component.mount(parentEl)

vdom.component = component

Don’t forget to keep a reference to the component’s DOM element in the virtual node’s el property (figure 11.6).

new Component(...)

Component

C

el

<div>

el

Figure 11.6

A reference to the

The component DOM element

component’s DOM element is saved

...

is referenced in the node.

in the virtual node.

But wait! Again, you’ll recall from chapter 9 that if a component’s view is a fragment, it doesn’t have one single HTML element as its root, but an array of elements. What do you do in this case?

The use case for the el reference is the reconciliation algorithm. In the case of a component, the el reference’s sole use is to serve as a reference node to insert elements before the component. To move a node to a different position, use the insertBefore() method, which requires a reference node after the location where the node will be inserted. To insert an element before a component, you should make sure that the el property points to the component’s first element (figure 11.7).

[ <h1> <p> <div> <span> ]

Component

C

elements

el

The component’s first DOM element

is referenced in the node.

Fragment

...

Figure 11.7

The el property points to the component’s first element.

By keeping the el reference of a component node pointing to the first element, you can move DOM nodes before the component, as shown in figure 11.8. In this case, the

260

CHAPTER 11

Subcomponents: Communication via props and events

view of the component is made of a fragment containing an <h1> and a <p> element.

A <div> outside the component is moved before the component, for which purpose the component’s first element—the <h1> element—is used as the reference node.

The parent node

The moving node

The component’s

<body>

first node

Component

body.insertBefore( <div> , <h1> )

<h1>...</h1>

<p>...</p>

<div>...</div>

Component

<h1>...</h1>

</body>

<p>...</p>

<body>

<div>...</div>

Component

The <div> is correctly placed before

<h1>...</h1>

the component’s first element (<h1>).

<p>...</p>

</body>

Figure 11.8

Moving a component to a different position in the DOM tree

You’re ready to implement the createComponentNode() function. Inside the mount-dom.js file, add the code in the following listing.

Listing 11.4

Mounting a component node (mount-dom.js)

function createComponentNode(vdom, parentEl, index, hostComponent) {

const Component = vdom.tag

Extracts the

const props = vdom.props

component’s class

const component = new Component(props)

Extracts the props

component.mount(parentEl, index)

vdom.component = component

vdom.el = component.firstElement

Instantiates the component

}

passing it the props

Saves the component’s first DOM

element in the virtual node

Mounts the component at

the given parent and index

Saves the component

instance in the virtual node

NOTE

You passed the hostComponent as the fourth argument to the create-

ComponentNode() function, but you haven’t used it yet; you’ll use it in section

11.1

Adding components as a new virtual DOM type

261

11.2 to implement the event listeners. I thought it would be convenient to have the function in place before you get to that point.

Hooray! Now you can mount component nodes, which means that you can have subcomponents inside your components. These child components can be rendered only once—when the component is mounted (because your patching algorithm doesn’t handle components at the moment)—but this is a good start. In section 11.1.3, you find out how to destroy component nodes.

Exercise 11.1

Write a component called List that renders a list of items inside a <ul> element.

These items are passed to the component as a prop called items, which is an array of strings. Each item should be rendered using another component called ListItem, which renders a <li> element with the item’s text.

The result of mounting the component as follows

const items = ['foo', 'bar', 'baz']

const list = new List({ items })

list.mount(document.body)

should be the following HTML:

<ul>

<li>foo</li>

<li>bar</li>

<li>baz</li>

</ul>

Look at the answer in the following link if you get stuck. You must understand how to define subcomponents as you move forward in this chapter.

Find the solution at

11.1.3 Destroying component virtual nodes

Once again, destroying is always easier than creating. Destroying a component is as simple as calling its unmount() method. You implemented the unmount() method in chapter 9, and, if you recall, that method calls the destroyDOM() by passing it the component’s virtual DOM tree. If the component has subcomponents, the destroyDOM() method is called recursively (figure 11.9).

Because you saved the component instance in the virtual node, now you can access it inside the destroyDOM() function, which is a straightforward addition to the destroyDOM() method. Open the destroy-dom.js file, and add the code shown in bold in the following listing.

262

CHAPTER 11

Subcomponents: Communication via props and events

Component

1. Unmount

function destroyDOM() {

component.

2. Call

.

destroyDOM()

unmount()

...

.

component.unmount()

<div>

...

}

destroyDOM()

<h1>

Component

4. Call

3. Unmount component.

unmount()

Text

"A"

<p>

...

Figure 11.9

Destroying a component’s DOM tree

Listing 11.5

Destroying the component (destroy-dom.js)

export function destroyDOM(vdom) {

const { type } = vdom

switch (type) {

case DOM_TYPES.TEXT: {

removeTextNode(vdom)

break

}

case DOM_TYPES.ELEMENT: {

removeElementNode(vdom)

break

}

case DOM_TYPES.FRAGMENT: {

removeFragmentNodes(vdom)

break

}

Calls the node’s

component instance

case DOM_TYPES.COMPONENT: {

unmount() method

vdom.component.unmount()

break

}

default: {

throw new Error(`Can't destroy DOM of type: ${type}`)

}

}

11.1

Adding components as a new virtual DOM type

263

delete vdom.el

}

11.1.4 Patching component virtual nodes

Now let’s look at how you can patch component nodes. When the state of a component changes or when it receives new props, this component patches its view and instructs its subcomponents to do the same. The view of a component can change only if its state changes or its props change. So when the state changes or the props of a component change, the component patches its view and passes the new props to its subcomponents. The subcomponents state doesn’t change, but their props might. Figure 11.10 illustrates how a component whose state changes notifies its subcomponents to patch their views.

Component

Component

The state of the parent

state:

state:

component changes.

- foo: 3

- foo: 8

- bar: 5

- bar: 7

The new state flows

<div>

<div>

down the view.

Component

Component

Component

Component

props:

props:

props:

props:

- foo: 3

- bar: 5

- foo: 8

- bar: 7

The new state is received as props

in the child components.

Figure 11.10

The state of a component changes, and its subcomponents receive new props.

Then, to patch the subcomponents inside the virtual DOM tree of a component, you need to extract the new props passed to them and call their updateProps() method, which internally calls the patch() method of the component. Let’s implement the updateProps() method in the Component class to update the props of the component and re-render it. Add the boldface code in the following listing to the component.js file.

Listing 11.6

Updating the props of a component (component.js)

export function defineComponent({ render, state, ...methods }) {

class Component {

// --snip-- //

264

CHAPTER 11

Subcomponents: Communication via props and events

updateProps(props) {

this.props = { ...this.props, ...props }

Merges the new

this.#patch()

props with the

}

old ones

updateState(state) {

Re-renders the

this.state = { ...this.state, ...state }

component

this.#patch()

}

// --snip-- //

}

// --snip-- //

}

Now add the code to patch component nodes (shown in bold in the following listing) to the patch-dom.js file. You add a case inside the switch statement that checks what type of node you’re patching.

Listing 11.7

Adding the component case to the patchDOM() function (patch-dom.js) export function patchDOM(

oldVdom,

newVdom,

parentEl,

hostComponent = null,

) {

if (!areNodesEqual(oldVdom, newVdom)) {

const index = findIndexInParent(parentEl, oldVdom.el)

destroyDOM(oldVdom)

mountDOM(newVdom, parentEl, index, hostComponent)

return newVdom

}

newVdom.el = oldVdom.el

switch (newVdom.type) {

case DOM_TYPES.TEXT: {

patchText(oldVdom, newVdom)

return newVdom

}

case DOM_TYPES.ELEMENT: {

patchElement(oldVdom, newVdom, hostComponent)

break

}

Calls the method

that patches the

case DOM_TYPES.COMPONENT: {

component

patchComponent(oldVdom, newVdom)

break

}

}

11.1

Adding components as a new virtual DOM type

265

patchChildren(oldVdom, newVdom, hostComponent)

return newVdom

}

// TODO: implement patchComponent()

Next, you implement the patchComponent() function. This function is in charge of extracting the new props from the virtual node and calling the component’s updateProps() method. The function should also save the component instance in the new virtual node so that it can be accessed later. Because components can change their top-level element between renders, you want to save the component’s first DOM

element in the node’s el property. Write the code in the following listing inside the patch-dom.js file.

Listing 11.8

Patching a component node (patch-dom.js)

Extracts the component instance

function patchComponent(oldVdom, newVdom) {

from the old virtual node

const { component } = oldVdom

const { props } = newVdom

Extracts the new props

from the new virtual node

component.updateProps(props)

Updates the component’s props

newVdom.component = component

newVdom.el = component.firstElement

Saves the component instance

}

in the new virtual node

Saves the component’s first DOM

element in the new virtual node

And just like that, you can use child components inside your components and patch them when the state of the parent component changes. If you’re interested in optimizing the patching of components, avoiding unnecessary re-renders, you can read section 11.1.5, which is optional. Otherwise, jump to section 11.2, which discusses how child components can communicate with their parent components via events.

11.1.5 A rendering optimization (optional)

You may have realized that you have a good opportunity here to optimize the patching of components. Can you guess what it is? Exactly: you can check whether the props of the component have changed and re-render only if they have. By comparing the old and new prop objects, you can avoid patching the component—and all its subcomponents—if its props haven’t changed, as shown in figure 11.11.

This approach may eliminate the need to check entire (and potentially long) branches of the virtual DOM tree. But this benefit comes at a cost: you need to compare the old and new props objects, which can be expensive if the objects are large.

As it turns out, comparing objects in JavaScript isn’t as straightforward as comparing primitive values. The === operator returns true only if both objects are the same object (reference equality). When the render() function of a component is called, it

266

CHAPTER 11

Subcomponents: Communication via props and events

Component

The state of the parent

component changes.

state:

- foo: 8

- bar: 5

The new state flows

down the view.

<div>

The new state is received as

props in the child component.

The props of this component

don’t change.

Component

Component

props:

props:

- foo: 8

- bar: 5

The component view doesn’t

The component’s view

need to be re-rendered.

needs to be re-rendered.

<div>

<div>

...

...

Figure 11.11

The state of a component changes, but not all the props of its child components change.

produces a fresh virtual DOM object, which means that the old and new props objects will never be the same object.

This situation isn’t a big deal because the community has satisfied this need by creating libraries that handle deep-object comparison. One such library is fast-deep-equal (which is small and does exactly what we need.

This optimization is optional, so feel free to skip this section if you’re not interested in it. First, you need to add the fast-deep-equal library to your runtime package as follows (making sure that you’re in the packages/runtime folder): npm install fast-deep-equal

Then you need to import the library into the component.js file and use it to compare the old and new props objects inside the component’s updateProps() method: import equal from 'fast-deep-equal'

// --snip-- //

export function defineComponent({ render, state, ...methods }) {

class Component {

// --snip-- //

updateProps(props) {

Creates a new props

object by merging the

#this.props = { ...this.props, ...props }

old and new props

const newProps = { ...this.props, ...props }

11.1

Adding components as a new virtual DOM type

267

if (equal(this.props, newProps)) {

Compares the old and new

return

props objects; returns if

}

they’re equal

this.props = newProps

Updates the

this.#patch()

component’s props

}

// --snip-- //

}

}

With this simple change, your components will be patched only if their props have changed. But to bundle the code in the fast-deep-equal library, you need to install two extra rollup plugins:

 @rollup/plugin-commonjs (—Transforms the fast-deep-equal library code from CommonJS to ES syntax, allowing Rollup to bundle the library code.

 @rollup/plugin-node-resolve (Resolves the fast-deep-equal library, which is a third-party module, and includes it in the bundle.

By default, Rollup resolves only modules that are part of your project. This plugin allows you to resolve third-party modules and include them in the bundle.

Install the libraries in the packages/runtime folder as dev dependencies: $ npm install --save-dev @rollup/plugin-commonjs @rollup/plugin-node-resolve Now you need to edit the rollup configuration file, packages/runtime/rollup.config.js, to add the plugins to the plugins array:

import commonjs from '@rollup/plugin-commonjs'

import { nodeResolve } from '@rollup/plugin-node-resolve'

import cleanup from 'rollup-plugin-cleanup'

import filesize from 'rollup-plugin-filesize'

export default {

input: 'src/index.js',

plugins: [commonjs(), nodeResolve(), cleanup()],

output: [

{

file: 'dist/fe-fwk.js',

format: 'esm',

plugins: [filesize()],

},

],

}

Run the npm run build script to make sure that your configuration is correct and the output JavaScript file is generated correctly. If you open the bundled JavaScript file for

268

CHAPTER 11

Subcomponents: Communication via props and events

your framework, inside the dist folder, you should see the fast-deep-equal library code included in the bundle. Look for a function called fastDeepEqual(): var fastDeepEqual = function equal(a, b) {

if (a === b) return true;

if (a && b && typeof a == 'object' && typeof b == 'object') {

if (a.constructor !== b.constructor) return false;

// --snip-- //

}

// --snip-- //

}

With this small change, you’ve added a useful optimization to your framework.

11.2

Events

A component can pass information to its child components via props, which is how a parent component communicates with its children. But how can a child component communicate with its parent? It communicates via events that the parent component can listen to, as shown in figure 11.12.

Component

Events move up the

The parent component

component hierarchy.

can handle the event.

handle( )

Event

payload

<div>

Component

Component

events:

- click

Figure 11.12

A child component communicates with its parent via events.

The parent component defines the event-handler function and registers it to listen to events emitted by the child component. The events emitted by a child component aren’t DOM events; they’re custom events identified by a name and containing a payload. The child component that receives the event-handler function from the parent component needs to save that function in a registry, where it can be called when the event is emitted. (If this process reminds you a lot of your Dispatcher class, you’re headed in the right direction.)

11.2

Events

269

Events vs. callbacks

If you’ve used React, you’re probably used to passing callbacks from parent to child components:

function MyButton({ onClick }) {

return <button onClick={onClick}>Click me</button>

}

Then the parent component defines a function that will be called when the button is clicked and passes it to the child component:

function App() {

function handleClick() {

console.log('Ooh la la!')

}

return <MyButton onClick={handleClick} />

}

In other frameworks, such as Vue, you emit an event from the child component instead and never receive the callback function from the parent component:

<template>

<button @click="$emit('click')">Click me</button>

</template>

The $emit() method, which is similar to what you’ll implement in this section, emits a custom (non-DOM) event. Then the parent component listens to that event:

<script setup>

import MyButton from './MyButton.vue'

function handleClick() {

console.log('Ooh la la!')

}

</script>

<template>

<MyButton @click="handleClick" />

</template>

Although these two approaches seem to be different, you’ll see as you implement this feature that they’ve very similar. The difference is mostly syntactical, related to how a framework decides to expose the feature to the developer.

In the case of React, using JSX (which is a more convenient way of defining the virtual DOM tree for your view), you operate at the virtual DOM level. Thus, you must explicitly pass the event-handler function from the parent to the child component.

270

CHAPTER 11

Subcomponents: Communication via props and events

(continued)

In the case of Vue, you work with a template. It feels natural to add event handlers inside the template of a component to handle the events that its child components emit. But under the hood, Vue compiles your template into a render function that operates at the virtual DOM level, where the event handler is passed to the child component as a prop—the same thing that happens in React.

A parent component listening to an event from a child component fits well with the way you’ve implemented the virtual DOM in your framework. If you recall from chapter 3, to add an event listener function to an element node, you include it inside the on object of the props parameter of the h() function:

h(

'button',

{ on: { click: () => console.log('Ooh la la!') } },

['Click me']

)

You can make your components emit events. These events won’t be instances of the Event interface (), which represents events arising from the user’s interaction with the DOM; they’ll be simple JavaScript objects with the desired payload. The key idea is that you can add event listeners to your components by using exactly the same syntax that you use to add event listeners to DOM elements. Here’s an example:

h(

MyFancyButton,

{ on: { btnClicked: () => console.log('Much fancy, too cool, wow!') } },

['Click me']

)

Next, you’ll see how to implement this feature in your Component prototype.

11.2.1 Saving the event handlers inside the component

You want to save those event listeners passed by the parent component inside the component that emits the event (the subcomponent or child component). Suppose that you have a MyFancyButton component emitting a custom event called btnClicked, and its parent component renders it like so:

h(

MyFancyButton,

{

on: {

btnClicked: () => { ... }

}

},

11.2

Events

271

['Click me']

)

You want to save the passed-in btnClicked event handler inside the MyFancyButton component. The component can accept that event-handler object as a parameter in its constructor and save it in a private property called #eventHandlers.

Then, when the child component (MyFancyButton) emits an event, it can look up the event handler in the #eventHandlers object and call it. Do you recall the Dispatcher class that you implemented in chapter 5? It’s the perfect fit for this task—registering handlers for events and calling them when the event is emitted. You used Dispatcher to dispatch and handle commands, but you can use it to dispatch and handle events as well. (Remember that the difference between commands and events is mostly in the nomenclature: events represent something that happened in the past, whereas commands represent something that should happen in the future.) You need to take one important subtlety into account, though: when a parent component declares an event handler attached to a child component, it expects the event handler to be called with the parent component as the this value. A common use case is when the event handler modifies the state of the parent component: const ParentComponent = defineComponent({

state() {

return { counter: 0 }

},

render() {

const { counter } = this.state

return hFragment([

h('p', {}, [The count is: ${counter}]),

h(

MyFancyButton,

{

on: {

btnClicked: () => {

this.updateState({ counter: counter + 1 })

Inside the handler,

},

this should be

},

ParentComponent.

},

['Increment']

)

])

}

})

You want to explicitly bind the this value of the event handler to the parent component, not the child component executing the event handler (figure 11.13). This point is very important, so keep it in mind: the this value of the event handler should be the parent component, not the child component calling the event handler.

272

CHAPTER 11

Subcomponents: Communication via props and events

Component

We don’t want the method’s context

Items

bound to the button element.

render()

handle( )

Component

Load more

render()

Binding !

The event handler is

events:

defined by the parent

- btnClicked

'btnClicked' : handle

component.

Binding

The method’s context should be

'this' shouldn’t be bound

bound to the parent component.

to the child component.

Figure 11.13

The this value of the event handler should be the parent component.

Let’s add the #eventHandlers property to the Component prototype. We’ll also pass the reference to the parent component to the constructor so that we can use it to explicitly bind the event handler’s context. Inside the component.js file, add the code shown in bold in the following listing.

Listing 11.9

Passing event handlers to a component (component.js)

export function defineComponent({ render, state, ...methods }) {

class Component {

#isMounted = false

#vdom = null

Declares the eventHandlers

#hostEl = null

private property

#eventHandlers = null

#parentComponent = null

Declares the parentComponent

private property

constructor(

props = {},

eventHandlers = {},

parentComponent = null,

) {

this.props = props

Saves the passed-in

this.state = state ? state(props) : {}

event handlers

this.#eventHandlers = eventHandlers

this.#parentComponent = parentComponent

Saves the passed-in

}

parent component

reference

// --snip-- //

}

// --snip-- //

return Component

}

11.2

Events

273

Before you implement the wiring of the event handlers, you want to go back to the mount-dom.js and patch-dom.js files, and pass the event handlers and the parent component reference to the component constructor.

11.2.2 Extracting the props and events for a component

As you probably remember from destructuring the props and events for an element node (refer to the mount-dom.js file inside the addProps() function), separating the props from the events is as simple as this:

const { on: events, ...props } = vdom.props

You could simply add that same line to the createComponentNode() function (in the mount-dom.js file) and the patchComponent() function (in the patch-dom.js file) functions. But you’ll add some extra logic to the props and events extraction in chapter 12 when you learn about keyed lists. (You want to remove the key prop, but let’s not get ahead of ourselves.) To avoid duplicating the same logic in two places, I prefer to create a separate file inside the utils/ directory, call it props.js, and write a function that extracts the props and events for a component. In that utils/props.js file, add the extractPropsAndEvents() function shown in the following listing.

Listing 11.10

Extracting the props and events for a component (utils/props.js)

export function extractPropsAndEvents(vdom) {

const { on: events = {}, ...props } = vdom.props

return { props, events }

}

Now open the mount-dom.js file, import the extractPropsAndEvents() function, and use it inside the createComponentNode() function to extract the props and events.

Then you want to pass the props, event handlers, and parent component to the component constructor. The parent component is the fourth hostComponent parameter passed to the createComponentNode() function. You added this parameter in listing 11.4 but didn’t use it then. Implement these changes as shown in bold in the following listing.

Listing 11.11

Extracting the props and events (mount-dom.js)

import { extractPropsAndEvents } from './utils/props'

// --snip-- //

function createComponentNode(vdom, parentEl, index, hostComponent) {

const Component = vdom.tag

const props = vdom.props

Extracts the

const component = new Component(props)

props and events

const { props, events } = extractPropsAndEvents(vdom)

274

CHAPTER 11

Subcomponents: Communication via props and events

const component = new Component(props, events, hostComponent) component.mount(parentEl, index)

Passes the props, event handlers,

vdom.component = component

and parent component to the

vdom.el = component.firstElement

component constructor

}

Similarly, open the patch-dom.js file, import the extractPropsAndEvents() function, and use it inside the patchComponent() function, as shown in the following listing. In this case, you pass only the props; we’re expecting that the event handlers won’t change between patches.

Listing 11.12

Extracting the props and events (patch-dom.js)

import { extractPropsAndEvents } from './utils/props'

// --snip--

function patchComponent(oldVdom, newVdom) {

Назад: 8.2.6 Patching child nodes
Дальше: 11.2.4 Emitting events