We have now reached a point where complexity will increase a great deal compared to the simplicity of the first part. This complexity is caused by two things:
- We want to be React-like in making changes to the DOM tree via single JSX representation.
-
dom()
must output DOM nodes only
Setting a target
In the first part we ended up with this application code:
function Component(props) {
function changeColor() {
render(ref, { style: 'background: red; padding: 5px;' })
}
const ref = (
<div style={props.style}>
<h1>Hello world!</h1>
<button onclick={changeColor}>Change color</button>
</div>
)
return ref
}
const App = <Component style="background: gray; padding: 5px;" />
document.body.appendChild(App)
We want to get rid of some issues here:
- There should be no need to capture a local
ref
- Our component
props
shouldn't be direct DOM element attributes -
changeColor
shouldn't need to know aboutrender
In short we want to transition from pure DOM mutation into state mutation where the developer using the library can focus on what he is doing and not care too much about the library. Or put other way: use components to describe what things should be like instead of manually writing DOM manipulation code.
How could we mangle the JSX so that we could as library authors get something to work with? If we look at React, it renders component render methods all the time. As such we do not have a render method at the moment. We need to add function somewhere. So how about...
function Component(props) {
function changeColor() {
props.dark = !props.dark
}
return (
<div style={() => `background-color: ${props.dark ? 'red' : 'wheat'}; padding: 5px;`}>
<h1>Hello world!</h1>
<button onclick={changeColor}>Change color</button>
</div>
)
}
const App = <Component dark={false} />
document.body.appendChild(App)
Doesn't this look good? We now have a function in style
attribute which we can call. We also have local state with the component which we can mutate because it is something we own. And best of all the syntax is quite readable, easy to reason about and there are no signs of library.
This does give challenges and questions: shouldn't we distinguish between functions like onclick
and style
? How do we render again after changes to state?
Dealing with the functions
From now on there is quite a lot of code to work with, so to ease following here is the complete code from part 1:
From here let's adjust the application code to add features step-by-step. Our initial step is to introduce functions!
// --- Application ---
function Component(props) {
function changeColor() {
props.dark = !props.dark
render(ref)
}
const ref = (
<div style={() => `background-color: ${props.dark ? 'red' : 'wheat'}; padding: 5px;`}>
<h1>Hello world!</h1>
<button onclick={changeColor}>Change color</button>
</div>
)
return ref
}
const App = <Component dark={false} />
document.body.appendChild(App)
We got pretty close to what we want! Now the only bad thing is that we have render
and that we need to manually track ref
. We'll deal with these issues later on.
As such the application is now "broken", because style
is clearly not working. We need to start managing our props, our one-liner Object.assign(element, props)
is no longer fit for our needs.
We have two pieces of code that use this call. This means we need to build a new function that manages this specific task! We shall call this method updateProps
. Before we write that we can update the calling methods and as we go there is no longer need to pass nextProps
to render:
// --- Library ---
const propsStore = new WeakMap()
function updateProps(element) {
const props = propsStore.get(element)
}
function render(element) {
if (!propsStore.has(element)) return
updateProps(element)
}
function dom(component, props, ...children) {
props = { ...props }
const element = typeof component === 'function'
? component(props)
: document.createElement(component)
propsStore.set(element, props)
updateProps(element)
return children.reduce(function(el, child) {
if (child instanceof Node) el.appendChild(child)
else el.appendChild(document.createTextNode(String(child)))
return el
}, element)
}
updateProps
only needs to take element
in as we can simply get reference to props
. There is no reason to do this when calling it.
render
will be a public method, while updateProps
is intended to be internal to the library. This is why render
does a check for existance of the element in the propsStore
.
It is time to write some logic to handle the functions!
function updateProps(element) {
const props = propsStore.get(element)
Object.entries(props).forEach(([key, value]) => {
if (typeof value === 'function') {
// use event handlers as they are
if (key.slice(0, 2) === 'on') {
if (element[key] !== value) element[key] = value
return
}
// call the function: use element as this and props as first parameter
value = value.call(element, props)
}
// naively update value if different
if (element[key] !== value) {
element[key] = value
}
})
}
And now when we run the app we should have a wheat colored background. Do we?
Success! However... why doesn't the button work? We have to debug. So, good old console logging: console.log('updateProps', element, props)
before Object.entries
should show us what is wrong.
And the result:
"<div style='background-color: wheat; padding: 5px;'>...</div>" Object {
dark: true
}
Well darn! We no longer get style
props here, instead we get the component's props! We do need component's props to pass them as first parameter to the function as that will be useful for currently unrelated reasons, but we also need to distinguish between component and element.
Our line to blame is in dom
method: there we set propsStore
without checking if we already have a reference. This gets called twice: first when dom
creates div
element and a second time for the same div
when Component
is called.
A simple solution to this is to ignore components:
function dom(component, props, ...children) {
props = { ...props }
const isFn = typeof component === 'function'
const element = isFn ? component(props) : document.createElement(component)
if (!isFn) propsStore.set(element, props)
updateProps(element)
return children.reduce(function(el, child) {
if (child instanceof Node) el.appendChild(child)
else el.appendChild(document.createTextNode(String(child)))
return el
}, element)
}
And does our code work?
It does! The button now correctly swaps between two colors. This brings us to the end of the second part.
There are further challenges to solve:
- Component props would be nice to pass to the attribute prop functions.
- We still need to call
render
manually and keepref
. - If we move
style
toh1
element then our click no longer works :(
The first and second are challenging; the third should be an easier one to solve. Can you solve it before the next part is out?
Top comments (0)