Introduction
Vue.js templates are powerful and can meet most of our application needs. However, in certain scenarios, such as creating dynamic components based on input or slot values, the render function can be a more flexible solution.
Developers familiar with the React ecosystem might already be acquainted with render functions, commonly used in JSX to construct React components. While Vue render functions can also be written in JSX, this discussion focuses on using plain JavaScript. This approach simplifies understanding the fundamental concepts of the Vue component system.
Every Vue component includes a render function. Most of the time, this function is created by the Vue compiler. When a template is specified for a component, the Vue compiler processes the template’s content, ultimately generating a render function. This render function produces a virtual DOM node, which Vue renders in the browser DOM.
This brings us to the concept of the virtual DOM. But what exactly is the virtual DOM?
The virtual Document Object Model (or “DOM”) enables Vue to render components in its memory before updating the browser. This approach enhances speed and avoids the high cost associated with re-rendering the DOM. Since each DOM node object contains numerous properties and methods, pre-rendering them in memory using the virtual DOM eliminates the overhead of creating DOM nodes directly in the browser.
When Vue updates the browser DOM, it compares the updated virtual DOM with the previous virtual DOM. Only the modified parts of the virtual DOM are used to update the actual DOM, reducing the number of element changes and enhancing performance. The render function returns virtual DOM nodes, often referred to as VNodes in the Vue ecosystem. These objects enable Vue to write these nodes into the browser DOM. They contain all the necessary information Vue needs.
Mounting Child Nodes and Element Attributes
When vnode.children
is a string, it sets the element’s text content. An element can have multiple child elements besides text nodes. To describe an element’s child nodes, vnode.children
needs to be defined as an array:
const vnode = {
type: 'div',
children: [
{
type: 'p',
children: 'hello'
}
]
};
In the above code, we describe “a div
tag with a child node, which is a p
tag.” As seen, vnode.children
is an array, and each element of the array is an independent virtual node object. This creates a tree-like structure, or a virtual DOM tree.
To render child nodes, we need to modify the mountElement
function, as shown in the following code:
function mountElement(vnode, container) {
const el = createElement(vnode.type);
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children);
} else if (Array.isArray(vnode.children)) {
// If `children` is an array, iterate through each child node and call the `patch` function to mount them
vnode.children.forEach(child => {
patch(null, child, el);
});
}
insert(el, container);
}
In this code, we have added a new conditional branch. We use the Array.isArray
function to check if vnode.children
is an array. If it is, we loop through each child node and call the patch
function to mount the virtual nodes in the array. During mounting of child nodes, we need to pay attention to two points:
The first argument passed to the
patch
function isnull
. Since this is the mounting phase and there is no oldvnode
, we only need to passnull
. This way, when thepatch
function is executed, it will recursively call themountElement
function to mount the child nodes.The third argument passed to the
patch
function is the mounting point. Since the child elements being mounted are child nodes of thediv
element, thediv
element created earlier serves as the mounting point to ensure that these child nodes are mounted in the correct position.
After mounting the child nodes, let’s look at how to describe the attributes of an element using vnode
and how to render these attributes. We know that HTML elements have various attributes, some of which are common, such as id
and class
, while others are specific to certain elements, such as the action
attribute for form
elements. In this discussion, we will focus on the basic attribute handling.
To describe the attributes of an element, we need to define a new field in the virtual DOM called vnode.props
, as shown in the following code:
const vnode = {
type: 'div',
props: {
id: 'foo'
},
children: [
{
type: 'p',
children: 'hello'
}
]
};
vnode.props
is an object where the keys represent the attribute names of the element, and the values represent the corresponding attribute values. This way, we can iterate through the props
object and render these attributes onto the element, as shown in the following code:
function mountElement(vnode, container) {
const el = createElement(vnode.type);
// Skip children handling for now
// Only handle `vnode.props` if it exists
if (vnode.props) {
// Iterate through `vnode.props` object
for (const key in vnode.props) {
// Use `setAttribute```javascript
// Use `setAttribute` to set attributes on the element
el.setAttribute(key, vnode.props[key]);
}
}
insert(el, container);
}
In this code snippet, we first check if vnode.props
exists. If it does, we iterate through the vnode.props
object and use the setAttribute
function to set attributes on the element. This approach ensures that the attributes are rendered onto the element during the mounting process.
When dealing with attributes, it’s essential to understand the distinction between HTML Attributes and DOM Properties. HTML Attributes are the attributes defined in the HTML tags, such as id="my-input"
, type="text"
, and value="foo"
. When the browser parses this HTML code, it creates a corresponding DOM element object, which we can access using JavaScript code:
const el = document.querySelector('#my-input');
Now, let’s talk about DOM Properties. Many HTML Attributes have corresponding DOM Properties on the DOM element object, such as id="my-input"
corresponding to el.id
, type="text"
corresponding to el.type
, and value="foo"
corresponding to el.value
. However, the names of DOM Properties don’t always exactly match HTML Attributes:
<div class="foo"></div>
In this case, class="foo"
corresponds to the DOM Property el.className
. Additionally, not all HTML Attributes have corresponding DOM Properties:
<div aria-valuenow="75"></div>
Attributes with the aria-*
prefix do not have corresponding DOM Properties.
Similarly, not all DOM Properties have corresponding HTML Attributes. For example, you can use el.textContent
to set the element’s text content, but there is no equivalent HTML Attribute for this operation.
The values of HTML Attributes and DOM Properties are related. For example, consider the following HTML snippet:
<div id="foo"></div>
This snippet defines a div
element with an id
attribute. The corresponding DOM Property is el.id
, and its value is the string 'foo'
. We consider this situation as a direct mapping, where the HTML Attribute and DOM Property have the same name (id
in this case).
However, not all HTML Attributes and DOM Properties have a direct mapping relationship. For example:
<input value="foo" />
Here, the input
element has a value
attribute set to 'foo'
. If the user does not modify the input field, accessing el.value
would return the string 'foo'
. If the user changes the input value to 'bar'
, accessing el.value
would return 'bar'
. But if you run the following code:
console.log(el.getAttribute('value')); // Still 'foo'
console.log(el.value); // 'bar'
You’ll notice that modifying the input value does not affect the return value of el.getAttribute('value')
. This behavior indicates the meaning behind HTML Attributes. Essentially, HTML Attributes are used to set the initial value of corresponding DOM Properties. Once the value changes, the DOM Properties always store the current value, while getAttribute
retrieves the initial value.
However, you can still access the initial value using el.defaultValue
, as shown below:
el.getAttribute('value'); // Still 'foo'
el.value; // 'bar'
el.defaultValue; // 'foo'
This example illustrates that an HTML Attribute can be associated with multiple DOM Properties. In this case, value="foo"
is related to both el.value
and el.defaultValue
.
Although HTML Attributes are considered as setting the initial values of corresponding DOM Properties, some values are restricted. It’s as if the browser internally checks for default value validity. If the value provided through HTML Attributes is invalid, the browser uses a built-in valid value for the corresponding DOM Properties. For example:
<input type="foo" />
We know that specifying the string 'foo'
for the type
attribute of the <input/>
tag is invalid. Therefore, the browser corrects this invalid value. When you try to read el.type
, you actually get the corrected value, which is 'text'
, not 'foo'
:
console.log(el.type); // 'text'
From the analysis above, we can see that the relationship between HTML Attributes and DOM Properties is complex. However, the core principle to remember is this: HTML Attributes are used to set the initial values of corresponding DOM Properties.
How to Properly Set Element Attributes
In the previous discussion, we explored how HTML Attributes and DOM Properties are handled in Vue.js single-file components’ templates. In regular HTML files, the browser automatically parses HTML Attributes and sets the corresponding DOM Properties. However, in Vue.js templates, the framework needs to handle the setting of these attributes manually.
Firstly, let’s consider a disabled button as an example in plain HTML:
<button disabled>Button</button>
The browser automatically disables this button and sets its corresponding DOM Property el.disabled
to true
. However, if the same code appears in a Vue.js template, the behavior would be different.
In Vue.js templates, the HTML template is compiled into virtual nodes (vnode
). The value of props.disabled
in the virtual node is an empty string. If you use the setAttribute
function directly to set the attribute, unexpected behavior occurs, and the button becomes disabled. For example, in the following template:
<button disabled="false">Button</button>
The corresponding virtual node is:
const button = {
type: 'button',
props: {
disabled: false
}
};
If you use the setAttribute
function to set the attribute value to an empty string, it is equivalent to:
el.setAttribute('disabled', '');
However, the el.disabled
property is of boolean type and does not care about the specific value of HTML Attributes; it only checks for the existence of the disabled
attribute. So, the button becomes disabled. Therefore, renderers should not always use the setAttribute
function to set attributes from the vnode.props
object.
To solve this issue, a better approach is to prioritize setting the element’s DOM Properties. However, if the value is an empty string, manually correct it to true
. Here is an implementation example:
function mountElement(vnode, container) {
const el = createElement(vnode.type);
if (vnode.props) {
for (const key in vnode.props) {
if (key in el) {
const type = typeof el[key];
const value = vnode.props[key];
if (type === 'boolean' && value === '') {
el[key] = true;
} else {
el[key] = value;
}
} else {
el.setAttribute(key, vnode.props[key]);
}
}
}
insert(el, container);
}
In this code, we first check if the property exists on the DOM element. If it does, we determine the type of the property and the value from vnode.props
. If the property is of boolean type and the value is an empty string, we correct it to true
. If the property does notexist on the DOM element, we use the setAttribute
function to set the attribute.
However, there are still issues with this implementation. Some DOM Properties are read-only, such as el.form
. To address this problem, we can create a helper function, shouldSetAsProps
, to determine whether an attribute should be set as DOM Properties. If the property is read-only or requires special handling, we should use the setAttribute
function to set the attribute.
Finally, to make the attribute setting operation platform-agnostic, we can extract the attribute-related operations into the renderer options. Here is the updated code:
const renderer = createRenderer({
createElement(tag) {
return document.createElement(tag);
},
setElementText(el, text) {
el.textContent = text;
},
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor);
},
patchProps(el, key, prevValue, nextValue) {
if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key];
if (type === 'boolean' && nextValue === '') {
el[key] = true;
} else {
el[key] = nextValue;
}
} else {
el.setAttribute(key, nextValue);
}
}
});
In the mountElement
function, we only need to call the patchProps
function and pass the appropriate parameters. This way, we’ve extracted the attribute-related rendering logic from the core renderer, making it more maintainable and flexible.
Please note that the shouldSetAsProps
function should be implemented according to your specific requirements and the DOM properties you want to handle differently.
Comments