In the third part we fixed a lot of component behavior. While still not perfect we can finally get into making a dream come true that was introduced in the second part: a component without a manual keeping of ref
and calling render
!
This is now our target application code:
function HelloWorld(props) {
return (
<h1 style={() => `color: ${props.dark ? 'white' : '#333'};`}>
Hello world!
</h1>
)
}
function Component(props) {
return (
<div
style={() =>
`background-color: ${
props.dark ? 'red' : 'wheat'
}; padding: 5px;`
}
>
<HelloWorld dark={() => props.dark} />
<button onclick={() => (props.dark = !props.dark)}>
Change color
</button>
</div>
)
}
const App = <Component dark={false} />
document.body.appendChild(App)
So the cool parts:
-
changeColor
does not callrender
! It is now one line arrow function! - No local copy of
ref
!
The Plan
We've entered to a classic problem in state management: when to update? When looking into other solutions we can see that in classical React we were directed to use this.setState
. This allowed authors of React to optimize renders so that the entire tree didn't need to change, only the current branch. Unfortunatenaly this also added some extra boilerplate, for example you had to manage this
.
In the other hand this state change optimization could also be broken in React! For example in pre-hooks Redux each component that is connected will be called each time state store is changed: despite added diff checks blocking actual renders this is still extra work. Others have solved this issue in their own state solutions such as Storeon that allow for targeted re-renders.
But... if we look at what our app looks like, there is nothing! The only thing that deals with state is props
. We're quite evil, too, because we're mutating it. In React, Redux and Storeon, you're encouraged to deal with state as if it is immutable. And here we are, not doing it!
However, if we think about the actual problem, we're not rendering like React. There the virtual DOM tree is built upon each render call and any state held by the render function is lost when the next render occurs. We don't have virtual DOM, instead the function remains in use and can be a source of state, allowing us to use props
.
This is now leading to what can be a performance edge against React. Instead of a single large render function we target single attributes and render those with the help of many tiny render functions. And those functions don't waste their time dealing with virtual DOM: they cause direct mutations.
This means that even if we implemented the least optimal render strategy, to render the whole tree each time, we're likely to do less work than a similar React app would - especially if the app is large.
So it seems it might be plausible to go ahead and write a simple update strategy!
The Execution
With the actual code we can implement a simple render queue: call requestAnimationFrame
for a re-render from each change and only ever keep one upcoming render in the queue, ignoring any further requests for rendering again until render has been done.
We're also taking a very naive route: simply capture all DOM1 event handlers (onclick
etc.) and add a call to queue a render to the very root of our app. The only special case to be aware of is that we may have multiple apps running at the same time, so we need allow to queue one render for each app that we have.
const queuedRenders = new Map()
function queueRender(element) {
if (!propsStore.has(element)) return
// find the top-most element in the tree
while (element.parentNode && propsStore.has(element.parentNode)) {
element = element.parentNode
}
// find component, and if element is not in component then use that
const root = parentComponents.get(element) || element
if (queuedRenders.has(root)) return
queuedRenders.set(root, requestAnimationFrame(function() {
// allow for new render calls
queuedRenders.delete(root)
// if equal then not wrapped inside a component
if (root === element) {
if (document.documentElement.contains(root)) {
render(root)
}
} else {
// find all siblings that are owned by the same component and render
for (let child of element.parentNode.childNodes) {
if (root === parentComponents.get(child)) render(child)
}
}
}))
}
There are some things to note:
- Fragment components do not currently have a perfect record of their children, it is only the other way around, so we have to loop and check if element's parent is the same component. A bit ugly, but good enough.
- And yes, we even allow re-renders without wrapping to a component! Or, we would but there is an issue to resolve. We'll get to that a bit later!
Now that we can queue renders we should then make use of the queue, too! Let's update a part of updateProps
...
const queueFunctions = new WeakMap()
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') {
// restore cached version
if (queueFunctions.has(value)) {
const onFn = queueFunctions.get(value)
if (element[key] !== onFn) {
element[key] = onFn
}
} else {
// wrap to a function that handles queuein
const newOnFn = (...attr) => {
value.call(element, ...attr)
queueRender(element)
}
// cache it
queueFunctions.set(value, newOnFn)
element[key] = newOnFn
}
return
}
value = value.call(element, componentProps)
}
if (element[key] !== value) {
element[key] = value
}
})
}
Now when pushing a button the App updates! However, I did mention about an issue...
Refactoring mistakes
First of all, here is the shortest readable Counter sample you can probably find anywhere:
let count = 0
document.body.appendChild(
<p title={() => count}>
<button onclick={() => count++}>+</button>
<button onclick={() => count--}>-</button>
</p>
)
It uses title
attribute because we don't manage dynamic children yet. Anyway, it is short! And we want to make it work - and actually, we did make it work when updateProps
had it's checks for componentProps
removed.
Hitting this issue got me into looking at how setting parents was done, and I noticed I had been a bit silly in how it was made with looping children. Instead, a simple stack that knows the parent component at each times makes parent management much easier.
So, we throw setParentComponent
away entirely. Then we update dom
as follows:
const parentStack = []
export function dom(component, props, ...children) {
props = { ...props }
const isComponent = typeof component === 'function'
const element = isComponent
? document.createDocumentFragment()
: document.createElement(component)
// if no parent component then element is parent of itself
const parent = parentStack[0] || { component: element, props: {} }
parentComponents.set(element, parent.component)
if (isComponent) {
componentPropsStore.set(element, props)
// fixed a bug here where initial props was unset
const exposedProps = updateComponentProps({ ...props }, props)
propsStore.set(element, exposedProps)
// increase stack before calling the component
parentStack.unshift({ component: element, props: exposedProps })
// the following will cause further calls to dom
element.appendChild(component(exposedProps))
// work is done, decrease stack
parentStack.shift()
} else {
// is element independent of a component?
if (parent.component === element) {
componentPropsStore.set(element, parent.props)
}
propsStore.set(element, props)
updateProps(element, parent.props)
}
return children.reduce(function(el, child) {
if (child instanceof Node) el.appendChild(child)
else el.appendChild(document.createTextNode(String(child)))
return el
}, element)
}
As a result we reduced a bit of code! And we now have a bit clearer management of state where componentProps
is always available, thus avoiding "no initial state" issue with elements that aren't within a component.
Here, have a look at the current app - including the super short counter example!
The counter sample shows that we have not taken proper care of our children. While there are other problems remaining, for example management of element attributes could be improved a great deal, it might be for the best to push forward with taking our children seriously. So that'll be our next topic!
Top comments (0)