In the previous part we got us a challenge: update the h1
component's style
, too!
The most obvious place to take care of this problem is in render
. So far we've only taken care of rendering the root element and ignored it's children. Adding a loop that recursively calls render for the remaining child nodes does the magic for us:
function render(element) {
if (!propsStore.has(element)) return
updateProps(element)
for (let child of element.childNodes) {
render(child)
}
}
We use render
because we aren't guaranteed that the child element is created or managed by our library. Also, calling render
ensures we also call children of the child.
To make use of this change to the library we also need to update our application code. Using white text color for red background might work nicely!
const ref = (
<div style={() => `background-color: ${props.dark ? 'red' : 'wheat'}; padding: 5px;`}>
<h1 style={() => `color: ${props.dark ? 'white' : '#333'};`}>
Hello world!
</h1>
<button onclick={changeColor}>Change color</button>
</div>
)
And as a result our h1
element should now update:
Which it does :) In the CodePen sample I've added some console.log
to updateProps
that makes it now easier to see all the mutations applied. You can already find some improvements to be made: for example, wheat background color is set twice despite no effective changes. For the moment we let that be (you can do otherwise, of course!).
A good reason for ignoring optimization now is that we don't have a complete feature set yet. The more code we have the harder it becomes to implement new features. And optimizations tend to be tricky on their own: it would make sense to have tests before going all-in with optimizations.
At this point we're still in the early phase of adding in all the basic features that we need to have a "complete" usable React-like library.
So, where should we go next? It does itch a lot to go ahead and remove the final annoyance of render(ref)
and seemingly be "feature complete" with our current application code where it could truly be an independent component with minimal boilerplate required by the application side developer.
But there is actually an issue with components at the moment. We can reveal this when we abstract h1
to it's own component:
function HelloWorld(props) {
return (
<h1 style={() => `color: ${props.dark ? 'white' : '#333'};`}>
Hello world!
</h1>
)
}
// and in Component replace h1 with...
<HelloWorld dark={() => props.dark} />
Our text is always white! Why? If we debug props.dark
inside HelloWorld
, we notice one thing: is is a function. This means it gets passed through untouched instead of being managed. And we must pass it as a function to the component in order to be able to update dark
value. It won't ever get updated if we don't use a function to help us out due to the limitation of what we have.
Managing components
Our component abstraction is clearly not up to the task. When we look into dom
we notice that we omit all props management of components: if (!isFn) propsStore.set(element, props)
. Also, all our current rendering code assumes only native DOM nodes.
We also still have a feature we'd like to have: passing component's props as input to the attribute functions. One reason we like this is that it would allow optimization of those functions (such as memoize), which would be great in cases where execution of the function is costly.
We have a few requirements in order to manage components:
- Something needs to link elements and their related components with.
- We need to store component props somewhere so that we can pass them.
For the first thing we can't use the component's function as a reference because we might use the same component multiple times. To ease figuring out this problem we could take a step back. What does dom
need to output? A valid DOM node. Is there something we could use that can wrap other DOM nodes?
Fragments! Fragments are special DOM nodes in that they only ever exist at the top of the tree. Fragments can't exist as child nodes: their child nodes are always automatically added instead, and removed from the fragment.
The second point is now easier to answer: we can use the existing propsStore
and use a fragment as our reference. We can now go ahead and start implementing a code that marks elements to belong into a component so that we can then give component's props as input for the attribute functions of those elements.
Huh. That is some complexity! We are now going to go through a lot of changes to the existing library methods, and have a couple of new internal helper functions to look at.
Changes to dom
From here on I'm switching from Codepen to Codesandbox as the amount of code is starting to exceed one file. The library part of the code will reign on library.js
and will export
two methods: dom
and render
.
Before going through the methods, we've added two new WeakMaps:
const componentPropsStore = new WeakMap()
const parentComponents = new WeakMap()
Now let's go ahead and see what new we have.
export function dom(component, props, ...children) {
props = { ...props }
const isComponent = typeof component === 'function'
// create the output DOM element
const element = isComponent
? document.createDocumentFragment()
: document.createElement(component)
if (isComponent) {
// remember original props
componentPropsStore.set(element, props)
// create new object that gets the updates of function calls
const exposedProps = updateComponentProps({}, props)
// store like normal element props
propsStore.set(element, exposedProps)
// call component to create it's output
element.appendChild(component(exposedProps))
// mark each DOM node created by us to this component
for (let child of element.childNodes) {
setParentComponent(child, element, exposedProps)
}
} else {
propsStore.set(element, props)
updateProps(element)
}
// untouched here, so we're gonna have problems at some point :)
return children.reduce(function(el, child) {
if (child instanceof Node) el.appendChild(child)
else el.appendChild(document.createTextNode(String(child)))
return el
}, element)
}
One function and we already have two new functions introduced!
-
updateComponentProps
manages calling functions and updating the resulting state, which is then exposed to the component -
setParentComponent
marks all children of the called component to that component, including another components
But we're not yet ready going through changes to the existing methods.
Changes to render
export function render(element, fragment, componentProps) {
if (!propsStore.has(element)) return
// detect parent component so that we can notice if context changes
const parent = parentComponents.get(element)
if (parent !== fragment) {
// the context changed
fragment = parent
// update component props by calling functions
const props = componentPropsStore.get(fragment)
if (props) {
componentProps = updateComponentProps(
propsStore.get(fragment),
props,
componentProps
)
}
}
// we now pass the relevant componentProps here!
updateProps(element, componentProps)
for (let child of element.childNodes) {
render(child, fragment, componentProps)
}
}
Here we update component props upon render. Instead of creating the props again and again we do the work only when the component changes.
Changes to updateProps
The least changes have happened here.
function updateProps(element, componentProps) {
const props = propsStore.get(element)
Object.entries(props).forEach(([key, value]) => {
if (typeof value === 'function') {
if (key.slice(0, 2) === 'on') {
if (element[key] !== value) {
element[key] = value
}
return
}
// no component props known, no game!
if (!componentProps) return
value = value.call(element, componentProps)
} else if (componentProps) {
// this is an optimization that reduces work
// but: maybe it introduces bugs later on!
return
}
if (element[key] !== value) {
element[key] = value
}
})
}
For the most part we're simply passing through the props that interest us.
The new methods
We have two new methods and here are both:
function setParentComponent(element, fragment, componentProps) {
// already marked to someone else?
if (parentComponents.has(element)) {
// check if the parent component of this element has a parent
const parent = parentComponents.get(element)
if (!parentComponents.has(parent))
parentComponents.set(parent, fragment)
return
}
// are we tracking this element?
if (!propsStore.has(element)) return
// mark parent and manage props, then continue to children
parentComponents.set(element, fragment)
updateProps(element, componentProps)
for (let child of element.childNodes) {
setParentComponent(child, fragment, componentProps)
}
}
function updateComponentProps(componentProps, props, parentProps = {}) {
return Object.entries(props).reduce((componentProps, [key, value]) => {
if (typeof value === 'function' && key.slice(0, 2) !== 'on') {
componentProps[key] = value(parentProps)
}
return componentProps
}, componentProps)
}
And that is the final piece of the puzzle completed. Summary of what have been achieved:
- Components are rendered as fragments
- Components now know each of their children, including other components
- We can pass component's props to their child functions
- Components can update as their props change
The library has now gained a lot of functionality while still being less than 100 lines of total code! Let's have a look at a working application:
Time for some reflection. I know that this article series isn't teaching in a convenient step-by-step manner: I'm not getting too much stuck on details and instead steamroll with working code. However, I hope the contents so far have given some insight into how an experienced developer approaches things and how building an idea into a fully working library comes together. Feel free to throw questions, feedback and critique in the comments!
In the next part it is time to manage the last piece of annoyance in the current application side code: getting rid of render
and ref
!
Top comments (0)