Implementing a Virtual DOM in Rust

In this article, we explore the implementation of a basic virtual DOM for parsing and rendering HTML elements using Rust.

Implementing a Virtual DOM in Rust

In this article, we explore the implementation of a basic virtual DOM for parsing and rendering HTML elements using Rust.

We start by defining essential structures for representing virtual nodes and rendering them into HTML. We enhance our implementation with a straightforward diffing algorithm, enabling efficient DOM updates by applying only necessary changes.

To introduce flexibility, we demonstrate parsing a virtual DOM structure from a JSON script, facilitating dynamic UI construction.

Let’s jump right in! 🦀

The Virtual DOM

Implementing a basic virtual DOM (VDOM) in Rust involves creating a simplified representation of the DOM that can be used to efficiently update the browser’s actual DOM.

The VDOM will include structures to represent elements and their properties, and functions to render these virtual elements into HTML strings. This basic implementation will cover creating elements and rendering them, but won’t include advanced features like diffing algorithms for optimal updates.

Step 1: Define the Virtual DOM Structures

First, let’s define the structures that will represent virtual DOM nodes and their properties. For simplicity, this example will handle a limited set of HTML elements and attributes.

// Define the types of nodes in our virtual DOM 
enum NodeType { 
    Text(String), 
    Element(ElementData), 
} 
 
// Data for element nodes (tags like <div>, <p>, etc.) 
struct ElementData { 
    tag_name: String, 
    attributes: Vec<(String, String)>, // Attributes as key-value pairs 
    children: Vec<VNode>, // Children can be any type of node 
} 
 
// A virtual DOM node can be either an element or a text node 
struct VNode { 
    node_type: NodeType, 
} 
 
impl VNode { 
    // Constructor for text nodes 
    fn new_text(text: String) -> Self { 
        VNode { 
            node_type: NodeType::Text(text), 
        } 
    } 
 
    // Constructor for element nodes 
    fn new_element(tag_name: String, attributes: Vec<(String, String)>, children: Vec<VNode>) -> Self { 
        VNode { 
            node_type: NodeType::Element(ElementData { 
                tag_name, 
                attributes, 
                children, 
            }), 
        } 
    } 
}

Step 2: Rendering the Virtual DOM to HTML

Next, implement a function that takes a VNode and recursively converts it into an HTML string. This function will handle different types of nodes (text and elements) and assemble the HTML.

impl VNode { 
    // Render a VNode and its children to an HTML string 
    fn render(&self) -> String { 
        match &self.node_type { 
            NodeType::Text(text) => text.clone(), 
             
            NodeType::Element(data) => { 
                let attrs = data.attributes.iter() 
                    .map(|(key, value)| format!("{}=\"{}\"", key, value)) 
                    .collect::<Vec<_>>() 
                    .join(" "); 
 
                let children = data.children.iter() 
                    .map(|child| child.render()) 
                    .collect::<Vec<_>>() 
                    .join(""); 
                format!("<{} {}>{}</{}>", data.tag_name, attrs, children, data.tag_name) 
            }, 
        } 
    } 
}

Step 3: Using the Virtual DOM

Finally, we can use the virtual DOM by creating a simple virtual tree and rendering it to HTML.

fn main() { 
    // Create a simple virtual DOM tree 
    let vdom = VNode::new_element( 
        "div".to_string(), 
        vec![("class".to_string(), "container".to_string())], 
        vec![ 
            VNode::new_element("h1".to_string(), vec![], vec![ 
                VNode::new_text("Hello, Rust!".to_string()) 
            ]), 
            VNode::new_element("p".to_string(), vec![], vec![ 
                VNode::new_text("This is a simple virtual DOM example.".to_string()) 
            ]), 
        ], 
    ); 
 
    // Render the virtual DOM to an HTML string 
    let html_output = vdom.render(); 
    println!("Rendered HTML:\n{}", html_output); 
}

Step 4: Implementing a Diffing Algorithm

The diffing algorithm will compare the old and new VNode trees and produce a set of changes that need to be applied to update the actual DOM. This simplistic version will only handle changes in text nodes and the addition or removal of elements, not attributes or reordering of child nodes.

enum Patch { 
    ChangeText(String), 
    AddNode(VNode), 
    RemoveNode, 
} 
 
impl VNode { 
    // Diff the current VNode against a new VNode, producing a list of patches 
    fn diff(&self, new_node: &VNode) -> Vec<Patch> { 
        let mut patches = Vec::new(); 
        match (&self.node_type, &new_node.node_type) { 
            (NodeType::Text(old_text), NodeType::Text(new_text)) => { 
                if old_text != new_text { 
                    patches.push(Patch::ChangeText(new_text.clone())); 
                } 
            } 
 
            (NodeType::Element(old_data), NodeType::Element(new_data)) => { 
                if old_data.tag_name != new_data.tag_name { 
                    patches.push(Patch::AddNode(new_node.clone())); 
                } else { 
                     
                    // Compare children 
                    let old_children = &old_data.children; 
                    let new_children = &new_data.children; 
                    let common_length = old_children.len().min(new_children.len()); 
 
                    // Diff each pair of existing children 
                    for i in 0..common_length { 
                        let child_patches = old_children[i].diff(&new_children[i]); 
                        patches.extend(child_patches); 
                    } 
 
                    // If new children are added 
                    for new_child in new_children.iter().skip(common_length) { 
                        patches.push(Patch::AddNode(new_child.clone())); 
                    } 
 
                    // If old children are removed 
                    if old_children.len() > new_children.len() { 
                        for _ in common_length..old_children.len() { 
                            patches.push(Patch::RemoveNode); 
                        } 
                    } 
                } 
            } 
            // Handle cases where node types are different 
            _ => patches.push(Patch::AddNode(new_node.clone())), 
        } 
        patches 
    } 
}

Step 5: Applying Patches to the Actual DOM

For a complete virtual DOM library, we’d need a way to apply these patches to the actual DOM. In this Rust example, we’ll simulate this step by showing how the patches could be interpreted, as actual DOM manipulation would require integration with a web environment, typically through WebAssembly and JavaScript interop.

impl Patch { 
    // Simulate applying a patch to the DOM 
    fn apply(&self, target: &mut String) { 
        match self { 
            Patch::ChangeText(new_text) => { 
                *target = new_text.clone(); 
            } 
            Patch::AddNode(new_node) => { 
                *target += &new_node.render(); 
            } 
            Patch::RemoveNode => { 
                *target = String::new(); 
            } 
        } 
    } 
}

Parsing JSON for building the DOM

To enable our Rust-based virtual DOM implementation to parse a script from JSON, we need to extend our code with functionality for deserializing JSON into our VNode structures. This will involve using a JSON parsing library in Rust, such as serde_json, along with serde for deserialization.

Step 1: Add Dependencies

First, add serde, serde_json, and serde_derive to your Cargo.toml to handle JSON parsing:

[dependencies] 
serde = { version = "1.0", features = ["derive"] } 
serde_json = "1.0" 
serde_derive = "1.0"

Step 2: Define Deserializable Structures

We need to adjust our VNode, ElementData, and NodeType structures to be deserializable from JSON. This involves annotating the structures with serde macros to define how they should be interpreted from JSON.

use serde::{Deserialize, Serialize}; 
 
#[derive(Serialize, Deserialize, Clone, Debug)] 
#[serde(tag = "type", rename_all = "lowercase")] 
enum NodeType { 
    Text(String), 
    Element(ElementData), 
} 
 
#[derive(Serialize, Deserialize, Clone, Debug)] 
struct ElementData { 
    tag_name: String, 
    #[serde(default)] 
    attributes: Vec<(String, String)>, 
    #[serde(default)] 
    children: Vec<VNode>, 
} 
 
#[derive(Serialize, Deserialize, Clone, Debug)] 
struct VNode { 
    #[serde(flatten)] 
    node_type: NodeType, 
}

Step 3: Parse JSON to Create the Virtual DOM

Implement a function to parse a JSON string and convert it into a VNode structure. This function uses serde_json to deserialize the JSON into our Rust data structures.

fn parse_json_to_vdom(json: &str) -> Result<VNode, serde_json::Error> { 
    serde_json::from_str::<VNode>(json) 
}

Step 4: Example Usage

Now, you can create a JSON representation of your virtual DOM and parse it to render HTML. Here’s an example JSON script that represents a simple virtual DOM structure:

{ 
  "type": "element", 
  "tag_name": "div", 
  "attributes": [["class", "container"]], 
  "children": [ 
    { 
      "type": "element", 
      "tag_name": "h1", 
      "children": [ 
        { 
          "type": "text", 
          "value": "Hello, Rust!" 
        } 
      ] 
    }, 
    { 
      "type": "element", 
      "tag_name": "p", 
      "children": [ 
        { 
          "type": "text", 
          "value": "This is a virtual DOM parsed from JSON." 
        } 
      ] 
    } 
  ] 
}

To parse this JSON and render the resulting virtual DOM to HTML in Rust:

fn main() { 
    let json = r#" 
    { 
        "type": "element", 
        "tag_name": "div", 
        "attributes": [["class", "container"]], 
        "children": [ 
            { 
                "type": "element", 
                "tag_name": "h1", 
                "children": [ 
                    { 
                        "type": "text", 
                        "value": "Hello, Rust!" 
                    } 
                ] 
            }, 
            { 
                "type": "element", 
                "tag_name": "p", 
                "children": [ 
                    { 
                        "type": "text", 
                        "value": "This is a virtual DOM parsed from JSON." 
                    } 
                ] 
            } 
        ] 
    }"#; 
 
    match parse_json_to_vdom(json) { 
        Ok(vdom) => { 
            let html_output = vdom.render(); 
            println!("Rendered HTML:\n{}", html_output); 
        } 
        Err(e) => println!("Failed to parse JSON: {}", e), 
    } 
} 

Reading from an external JSON file

To load JSON from a file and parse it into a virtual DOM structure in Rust, you will need to perform file I/O operations alongside JSON parsing. The serde_json library will still be used for parsing, and Rust's standard library provides functionality for reading files.

Step 1: Read JSON from a File

First, create a function to read the contents of a JSON file into a String. This function will use std::fs::read_to_string to read the file.

use std::fs; 
 
fn read_json_from_file(file_path: &str) -> Result<String, std::io::Error> { 
    fs::read_to_string(file_path) 
}

Step 2: Integrate File Reading with JSON Parsing

Modify the main function to read the JSON from a file and then parse it into the virtual DOM structure.

fn main() { 
    let file_path = "path/to/your/file.json"; // Update this path to your JSON file's location 
 
    // Read JSON from the file 
    let json_result = read_json_from_file(file_path); 
    match json_result { 
        Ok(json) => { 
            // Parse the JSON into the virtual DOM 
            match parse_json_to_vdom(&json) { 
                Ok(vdom) => { 
                    // Render the virtual DOM to HTML 
                    let html_output = vdom.render(); 
                    println!("Rendered HTML:\n{}", html_output); 
                } 
                Err(e) => println!("Failed to parse JSON: {}", e), 
            } 
        } 
        Err(e) => println!("Failed to read file: {}", e), 
    } 
}

Complete Example

Here’s how the complete Rust program might look, integrating file reading, JSON parsing, and virtual DOM rendering:

use serde::{Deserialize, Serialize}; 
use std::fs; 
 
#[derive(Serialize, Deserialize, Clone, Debug)] 
#[serde(tag = "type", rename_all = "lowercase")] 
enum NodeType { 
    Text(String), 
    Element(ElementData), 
} 
 
#[derive(Serialize, Deserialize, Clone, Debug)] 
struct ElementData { 
    tag_name: String, 
    #[serde(default)] 
    attributes: Vec<(String, String)>, 
    #[serde(default)] 
    children: Vec<VNode>, 
} 
 
#[derive(Serialize, Deserialize, Clone, Debug)] 
struct VNode { 
    #[serde(flatten)] 
    node_type: NodeType, 
} 
 
impl VNode { 
    fn render(&self) -> String { 
        match &self.node_type { 
            NodeType::Text(text) => text.clone(), 
            NodeType::Element(data) => { 
                let attrs = data.attributes.iter() 
                    .map(|(key, value)| format!("{}=\"{}\"", key, value)) 
                    .collect::<Vec<_>>() 
                    .join(" "); 
                let children = data.children.iter() 
                    .map(|child| child.render()) 
                    .collect::<Vec<_>>() 
                    .join(""); 
                format!("<{} {}>{}</{}>", data.tag_name, attrs, children, data.tag_name) 
            }, 
        } 
    } 
} 
 
fn parse_json_to_vdom(json: &str) -> Result<VNode, serde_json::Error> { 
    serde_json::from_str::<VNode>(json) 
} 
 
fn read_json_from_file(file_path: &str) -> Result<String, std::io::Error> { 
    fs::read_to_string(file_path) 
} 
 
fn main() { 
    let file_path = "path/to/your/file.json"; // Update this path to your JSON file's location 
 
    // Read JSON from the file 
    let json_result = read_json_from_file(file_path); 
    match json_result { 
        Ok(json) => { 
 
            // Parse the JSON into the virtual DOM 
            match parse_json_to_vdom(&json) { 
                Ok(vdom) => { 
                    // Render the virtual DOM to HTML 
                    let html_output = vdom.render(); 
                    println!("Rendered HTML:\n{}", html_output); 
                } 
                Err(e) => println!("Failed to parse JSON: {}", e), 
            } 
        } 
        Err(e) => println!("Failed to read file: {}", e), 
    } 
}

Remember to replace "path/to/your/file.json" with the actual path to your JSON file. This program will read the JSON script from the file, parse it into a virtual DOM structure, and then render that structure to an HTML string.

🚀 Explore More by Luis Soares

📚 Learning Hub: Expand your knowledge in various tech domains, including Rust, Software Development, Cloud Computing, Cyber Security, Blockchain, and Linux, through my extensive resource collection:

  • Hands-On Tutorials with GitHub Repos: Gain practical skills across different technologies with step-by-step tutorials, complemented by dedicated GitHub repositories. Access Tutorials
  • In-Depth Guides & Articles: Deep dive into core concepts of Rust, Software Development, Cloud Computing, and more, with detailed guides and articles filled with practical examples. Read More
  • E-Books Collection: Enhance your understanding of various tech fields with a series of free e-Books, including titles like “Mastering Rust Ownership” and “Application Security Guide” Download eBook
  • Project Showcases: Discover a range of fully functional projects across different domains, such as an API Gateway, Blockchain Network, Cyber Security Tools, Cloud Services, and more. View Projects
  • LinkedIn Newsletter: Stay ahead in the fast-evolving tech landscape with regular updates and insights on Rust, Software Development, and emerging technologies by subscribing to my newsletter on LinkedIn. Subscribe Here

🔗 Connect with Me:

  • Medium: Read my articles on Medium and give claps if you find them helpful. It motivates me to keep writing and sharing Rust content. Follow on Medium
  • Personal Blog: Discover more on my personal blog, a hub for all my Rust-related content. Visit Blog
  • LinkedIn: Join my professional network for more insightful discussions and updates. Connect on LinkedIn
  • Twitter: Follow me on Twitter for quick updates and thoughts on Rust programming. Follow on Twitter

Wanna talk? Leave a comment or drop me a message!

All the best,

Luis Soares
luis.soares@linux.com

Senior Software Engineer | Cloud Engineer | SRE | Tech Lead | Rust | Golang | Java | ML AI & Statistics | Web3 & Blockchain

Read more