Level 3: Splitting Code Into Components
Introduction
Now that we are able to define elements in SJDON, it's a good moment to think about organizing the code of our elements.
Imagine defining the whole structure of a website inside a single, static SJDON array. As you can imagine, this array will soon become quite big and messy. Also, we might need the same functionality multiple times on a page, so it would be nice if we could re-use those elements. This is where Components come in.
First, let's start with a simple example of something we could call a Static Component.
const reusableParagraph = [
'p',
'This paragraph is so nice, we want to reuse it'
]
const sjdon = [
'div',
{ className: 'container' },
[ 'h1', { 'id': 'page-title' }, 'Welcome to SJDON' ],
[ 'p', 'SJDON stands for Simple JavaScript DOM Notation.' ],
reusableParagraph
]
Here, we can basically just define an SJDON structure in an array, and then insert this into another SJDON array, wherever we want to include the component. But as you can imagine, the use of this is quite limited, because it might not happen that often that you want to include exactly the same element multiple times.
So it would be useful if we could have some logic inside our components. This is where Functional Components come in. A functional component is actually just a JavaScript function which returns data in a specific way, in this case SJDON. This means that instead of defining the structure of an element from scratch every time we use it, we put the functionality inside a function and return the code for the element from there instead. This makes it easier to keep some order inside our code base, as we no longer have to define everything inside one huge array, but instead our definitions spread across multiple cohesive functions. More importantly, this also allows for easy re-use of elements, without defining them every time separately.
Goal
Let's look at the index.js
file of the demo for this level.
index.js
import { createElement } from '../../lib/js/fiber.js'
import { parseSjdon } from '../../lib/js/sjdon.js'
import { render } from '../../lib/js/render.js'
function DateComponent({ size }) {
const date = new Date().toUTCString()
const style = {
fontWeight: 'bold',
fontSize: size == 'big' ? '18px' : 'inherit'
}
return [
'p',
'The current date is: ',
[ 'span', { style }, date ]
]
}
const sjdon = [
'div',
{ className: 'container' },
[ 'h1', { 'id': 'page-title' }, 'Welcome to SJDON' ],
[ 'p', 'SJDON stands for Simple JavaScript DOM Notation.' ],
[ DateComponent, { size: 'big' } ]
]
render(
parseSjdon(sjdon, createElement),
document.getElementById('app')
)
We call this DateComponent
function a functional component (or also functional element). Functional components are powerful as they're configurable via props, similar to static elements. The difference is that the props are not directly assigned to the HTML element, but passed as the first parameter to the function.
If you're confused with the syntax
{ size }
which is used as the function parameter, this is called parameter destructuring. The first parameter is expected to be an object and its propertysize
is directly accessed. It would be the same thing to have one parameterprops
and accessingsize
viaprops.size
, it's just more convenient the other way.
If we look at the return value of DateComponent
, we see that it returns an array with the structure of SJDON. If we would now like to use this component inside our SJDON structure, we can do so by using the SJDON element [ DateComponent ]
. As with static elements, we can also define props and children on functional components. They are passed to the function as the first argument. In this example we assign the props { size: big }
. If we add children to the array, like [ DateComponente, 'child']
, they are passed inside the props with the key children
.
When the DateComponent
is rendered, it will result in the function call DateComponent({ size: 'big' })
. This will then display an HTML paragraph saying "The current date is: Sun, 11 Dec 2022 13:12:56 GMT", where the date, printed in bold, corresponds to the current date when the function DateComponent
was executed. Depending on the value of size
the font size would be set to 24px
or kept the same with inherit
.
Revisiting the parseSjdon
Function
As seen in the example, we encounter a new type of element [ DateComponent ]
. In this case, the first item of the SJDON array is a function, not just a string like it was before. We have to update the parseSjdon
function to make this work.
export function parseSjdon<T>([type, ...rest]: SjdonElement, create: CreateElementFunction<T>): T {
const propsArray = rest.filter(isSjdonProps)
const props: Props = Object.assign({}, ...propsArray)
const children = rest.filter(isSjdonChild)
if (typeof type == 'string') {
const parsedChildren = children.map(child => (isSjdonElement(child) ? parseSjdon(child, create) : child))
return create(type, props, ...parsedChildren)
} else {
const fiberFunction = (props?: Record<string, unknown>) => parseSjdon(type({ ...props, children }), create)
return create(fiberFunction, props)
}
}
To handle the new functional elements, we have to distinguish between two cases. If the type
, the first entry in the SJDON array, is a string, the same logic as before applies. However, if the type
is a function, we can't just simply parse the SjdonElement
. We have to "wait" until the function is executed and returns the generated SJDON array. To achieve that, we create a wrapper function, which executes the type
function and calls parseSjdon
with its return value. This wrapper function is called fiberFunction
and passed as the first argument to the create
function. You can also see that, as explained in the example, the props and the children are passed to the type
function, so they're available to use inside the functional component.
Revisiting the createElement
Function
As seen in the parseSjdon
function, we now call createElement
with a function as the first parameter type
if the SJDON element happens to be a functional component. In the first level, type
could only be a valid HTML tag. That is why, in this level, we are going to revisit the createElement
function in its full functionality, which allows us to not only use strings for the type
parameter, but also functions.
Below, you can find the full implementation of createElement
. As you can see, there is an additional part, starting at the line if (type instanceof Function) {
, which adds some logic in case of the parameter type
being an instance of a Function
.
export const createElement: CreateElementFunction<Fiber> = (type, props, ...children) => {
const mappedChildren = mapChildren(children)
const safeProps = props ?? {}
// If the type is a function, create a functional fiber.
if (type instanceof Function) {
// A functional element can not have direct children, it is a function,
// which can take in children as a prop and return a static element with children.
if (mappedChildren.size > 0) throw new Error('A functional element can not have children.')
return {
fiberFunction: type,
functionProps: safeProps,
memorizedStates: [],
}
}
// Otherwise create a static fiber.
else return { type, props: safeProps, children: mappedChildren }
}
As it is invalid for a Fiber
with a type
of Function
to have children at this level of the rendering cycle, an error will be thrown in that case. Otherwise, an object is returned, which satisfies the FunctionalFiber
type.
If you're confused why there can't be any children (at this stage) inside a
FunctionalFiber
, take a look again at the updatedparseSjdon
function. As the children are still in the SJDON format, theFunctionalFiber
never really sees them. They are actually never passed to thecreateElement
function, but rather directly passed to the functional component, so it can use it somewhere in its SJDON structure.
Next, let's take a look at the type definition of FuntionalFiber
.
export type FunctionalFiber = {
/**
* The `fiberFunction` contains the information to generate the fiber.
* It takes the `functionProps` as an argument, calls hooks and returns the generated `Fiber`.
*/
fiberFunction: FiberFunction
/**
* The `functionProps` are the props which are passed to the `fiberFunction`. Differently to the
* normal `props`, these are not added to the DOM and only used inside of the `fiberFunction`.
* The normal `props` will be defined by the `StaticFiber`, which will be returned from executing
* the `fiberFunction`.
* These will be the `props` that will be added to the DOM node.
* @see {@link StaticFiber}
*/
functionProps: Readonly<Props>
/**
* The `memorizedStates` array contains all stored values of the component's hooks.
*/
memorizedStates: unknown[]
} & Partial<StaticFiber>
As you can see in the last line of the type definition of FunctionalFiber
, objects of this type can optionally contain all properties of a StaticFiber
, indicated by & Partial<StaticFiber>
. Additionally, there are three new properties, namely fiberFunction
, functionProps
and memorizedStates
, which are specific to the type FunctionalFiber
.
When we look again at the return statement of createElement
for Functional Fibers, we see that we set the parameter type
as the property fiberFunction
of the FunctionalFiber
. Additionally, we assign safeProps
to the property functionProps
. When we later execute the function stored in fiberFunction
, we will pass it the parameters stored in functionProps
. Lastly, we initialize memorizedStates
as an empty array. We will explore the use of memorizedStates
in the next level, for now we just notice it is there.
Revisiting the renderFiber
Function
Now that we have looked at the properties of a Functional Fiber, we will explore how Functional Fibers are rendered to the DOM.
For that, we look at an updated implementation of renderFiber
, which we have also only explored partially in the first level of this tutorial.
function renderFiber(fiber: Fiber, container: HTMLElement, previousVersion?: Fiber, nextSibling?: Fiber) {
// If the component is a functional fiber, execute its fiberFunction
// to get the unwrapped StaticFiber properties merged into the same object.
if (isFunctionalFiber(fiber)) unwrapFunctionalFiber(fiber, container, previousVersion)
// After unwrapping, the fiber must contain all properties of a static fiber.
if (!isStaticFiber(fiber)) throw new Error('Fiber did not contain all StaticFiber properties after unwrapping.')
replaceFiberInDom(fiber, container, previousVersion, nextSibling)
expandChildFibers(fiber, previousVersion)
}
We detect that the first line of the function has been added since the last time. It checks whether the fiber
that was passed is of type FunctionalFiber
. In that case, we unwrap the FunctionalFiber
using the unwrapFunctionalFiber
function, which basically means that we execute the function stored in fiberFuntion
, which should then return a StaticFiber
. Now, you might also better understand why we have the check in the following line, whether the fiber
is now a StaticFiber
, as this should be the case after unwrapping the FunctionalFiber
. If that is not the case, we ran into some unexpected state that should actually never occur, so we'll throw an error.
Unwrapping Functional Fibers
To understand what's exactly happening when unwrapping a FunctionalFiber
, we are going to have a closer look at a slightly simplified implementation of unwrapFunctionalFiber
now.
function unwrapFunctionalFiber(fiber: FunctionalFiber, container: HTMLElement, previousVersion?: Fiber) {
// Unwrap fibers until the fiberFunction returns a StaticFiber.
let unwrappedFiber = fiber.fiberFunction(fiber.functionProps)
while (isFunctionalFiber(unwrappedFiber))
unwrappedFiber = unwrappedFiber.fiberFunction(unwrappedFiber.functionProps)
// Merge all properties of the unwrappedFiber into the functional fiber.
Object.assign(fiber, unwrappedFiber)
}
We can see that we execute the fiberFunction
of the fiber
, pass it functionProps
as its argument and assign it to unwrappedFiber
.
In the following line, we check if unwrappedFiber
is still a FunctionalFiber
, which could happen, as a FunctionalFiber
could in theory return another FunctionalFiber
. We repeat the action of executing the fiberFunction
so many times, until unwrappedFiber
is no longer a FunctionalFiber
, meaning it's now of type StaticFiber
.
What happens then might actually be a bit confusing at first sight. We use Object.assign
to copy all properties of unwrappedFiber
into our original fiber
. This means that fiber
is now actually both a StaticFiber
and a FunctionalFiber
.
At the moment, you might not understand why we do this, instead of just returning the unwrappedFiber
. However, it will become quite important later, when we look at how components can be re-rendered, if their state changes. For the moment, you should just understand what is happening, and we'll explain why it's happening in the following level.
Note that since we are assigning the properties of unwrappedFiber
to the same fiber
we passed to the function, we don't need to return anything, as we have a reference to this object already where we called unwrapFunctionalFiber
, just with the difference that the fiber
now contains some more properties.
Review
Except of unwrapping Functional Fibers in renderFiber
, there is no additional functionality needed for supporting Functional Fibers in the form we have looked at so far, since our Functional Fibers become Static Fibers as well. Thus, rendering works almost the same as for normal Static Fibers, which we have already covered in the first level.
The next big enhancement will be the introduction of state in fibers using hooks. And this is exactly what we are going to look at in the next level.