├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Makefile ├── README.md ├── index.html ├── index.js └── src └── main.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "simple-virtual-dom" 3 | version = "0.1.0" 4 | 5 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "simple-virtual-dom" 3 | version = "0.1.0" 4 | authors = ["Richard "] 5 | 6 | [dependencies] 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | cargo +nightly build --release --target wasm32-unknown-unknown 3 | lint: 4 | cargo fmt 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple Virtual DOM in Rust + WASM 2 | 3 | This project is my attempt as simply implementing a very basic virtual DOM from scratch. There's a few interesting challenges in talking with javascript as well as the algorithm itself. 4 | 5 | Let's talk first about the challenges of WASM interacting with DOM. Since web assembly doesn't have any API for interacting with the DOM, we must interact with the DOM through Javascript. There is however an additional difficulty in that WASM-JS communication can only be done through simple number types (integers and floats). This brings the first question of how do we pass a string from WASM to javascript? 6 | 7 | Our one saving grace is that javascript has access to the memory of our WASM application. So instantiating a string in WASM can be viewed in JS if we know two things: 8 | 9 | 1) the start of the string 10 | 2) the length of the string 11 | 12 | In this project, it might be easiest to see how this is done by looking at the `log` function in WASM. We have a helper function `log` that calls a javascript function `js_log`. `log` creates a C string type, and we can get a pointer which represents the memory location to send to javascript. Javascript can then iterate through those characters and form a string of it's own to do some action with (see `extractStringFromMemory` in `index.js`). 13 | 14 | We'll be using multiple functions that pass along strings to javascript to perform various DOM manipulation, so whenever you see a start and length its talking about a string being sent over the WASM-JS bridge. 15 | 16 | Example: 17 | 18 | ```rust 19 | extern { 20 | fn js_log(start:*mut c_char,len:usize); 21 | ... 22 | } 23 | 24 | pub fn log(msg:&str) { 25 | let s = CString::new(msg).unwrap(); 26 | let l = msg.len(); 27 | unsafe { 28 | js_log(s.into_raw(),l); 29 | } 30 | } 31 | ``` 32 | 33 | # DOM management 34 | 35 | Since WASM can't pass around DOM elements directly, what we need is some sort of system for talking about the DOM we are going to operate on. In this project, whenever DOM is queried or created, we give that piece of DOM an integer ID. 36 | 37 | For instance, if we queried the `body` element, we assign that element a number and store that in an dictionary `number -> Element`. Let's assum the number we get for referring to the `body` is 123. Now whenever we perform DOM operations on the body, say, setting the innerHTML. We can simply call `set_inner_html(123,"hello!")`. 38 | 39 | ```rust 40 | type DomNode = i32; 41 | ``` 42 | 43 | # Virtual DOM 44 | 45 | There is a great (but incomplete article) https://medium.com/@deathmood/how-to-write-your-own-virtual-dom-ee74acc13060 that describe the process of creating a Virtual DOM from scratch. 46 | 47 | The important thing to remember is that we are trying to do as minimal DOM operations as possible. Manipulating the DOM is incredibly expensive, so if we can find any way of avoiding interactions with it the better. How virtual DOM accomplishes this is by representing our DOM as a tree of nodes. Then each time we render, we compare the tree of nodes we currently have to the new tree of nodes, and we can determine what real DOM needs to be created,removed, replaced, or modified. 48 | 49 | In this example i'm making a pretty massive simplification: **this is a virtual DOM for elements with NO attributes or event handlers** 50 | 51 | This simplification makes it alot easier to see the basic operations going on. In Rust we represent VirtualDom as follows. 52 | 53 | ```Rust 54 | // VirtualElementNode represents an html element 55 | struct VirtualElementNode { 56 | node_type: String, 57 | children: Vec 58 | } 59 | 60 | // VirtualTextNode represents text that is mixed in with elements 61 | struct VirtualTextNode { 62 | text: String, 63 | } 64 | 65 | // We use an enumeration to represent these two 66 | // plus an empty DOM node to represent nothing 67 | enum VirtualDomNode { 68 | Empty, 69 | VirtualElementNode(VirtualElementNode), 70 | VirtualTextNode(VirtualTextNode), 71 | } 72 | 73 | // VirtualDom represents a virtual dom tree 74 | struct VirtualDom { 75 | node:VirtualDomNode 76 | } 77 | 78 | impl VirtualDom { 79 | // new creates an empty VirtualDom 80 | fn new() -> VirtualDom { 81 | VirtualDom { 82 | node: VirtualDomNode::Empty 83 | } 84 | } 85 | 86 | // Compares two virtual dom tree structures and updates the real DOM 87 | // then stores the new dom tree for future comparisons 88 | fn render(&mut self, el:DomNode, new_node:VirtualDomNode){ 89 | // TODO: some magical comparisons that updates the contents of el 90 | self.node = new_node; 91 | } 92 | } 93 | ``` 94 | 95 | For a simple html: 96 | 97 | ```html 98 |
99 |

hello!

100 |
101 | ``` 102 | 103 | A simple tree of DOM might be represented thus as: 104 | 105 | ```rust 106 | VirtualDomNode::VirtualElementNode(VirtualElementNode{ 107 | node_type: String::from("div"), 108 | children: vec![ 109 | VirtualDomNode::VirtualElementNode(VirtualElementNode{ 110 | node_type: String::from("h1"), 111 | children: vec![ 112 | VirtualDomNode::VirtualTextNode(VirtualTextNode{ 113 | text: String::from("hello"), 114 | }) 115 | ] 116 | } 117 | ] 118 | }) 119 | ``` 120 | 121 | This is a little verbose though, we we have two helper functions: 122 | 123 | ```rust 124 | fn h(node_type:&str,children:Vec)->VirtualDomNode { 125 | VirtualDomNode::VirtualElementNode(VirtualElementNode{ 126 | node_type: String::from(node_type), 127 | children: children 128 | }) 129 | } 130 | 131 | fn t(text:&str)->VirtualDomNode { 132 | VirtualDomNode::VirtualTextNode(VirtualTextNode{ 133 | text: String::from(text) 134 | }) 135 | } 136 | ``` 137 | 138 | So we can easily represent virtual DOM 139 | 140 | ```rust 141 | h("div",vec![ 142 | h("h1",vec![ 143 | t("hello!") 144 | ]) 145 | ]) 146 | ``` 147 | 148 | This allows us to easily interact with our virtual DOM: 149 | 150 | ```rust 151 | // Let's get a handle to our body element 152 | let body = query_selector("body"); 153 | 154 | // Let's create our empty virtual dom 155 | let mut vd = VirtualDom::new(); 156 | 157 | // Render a simple list to the body element 158 | vd.render(body, h("div",vec![ 159 | h("h1",vec![t("1")]), 160 | h("h2",vec![t("2")]), 161 | h("h3",vec![t("3")]) 162 | ])); 163 | 164 | // Render a new virtual dom tree to the body element that is the reverseof the list 165 | vd.render(body, h("div",vec![ 166 | h("h1",vec![t("3")]), 167 | h("h2",vec![t("2")]), 168 | h("h3",vec![t("1")]) 169 | ])) 170 | // ONLY h1 and h3's text node should change 171 | ``` 172 | 173 | Let's consider what happens on the first rendering. We have a virtual dom tree with an `Empty` node in it, and some new virtual dom tree coming in that has elements and text. Our tree comparison is simple in this first rendering since we only have all new nodes we need to create real DOM elements for. So let's look how we might create that tree of real DOM. We have three scenerios to handle: 174 | 175 | ```rust 176 | fn create_element_from_node(node:&VirtualDomNode) -> DomNode { 177 | match node { 178 | VirtualDomNode::VirtualElementNode(vnode) => { 179 | let el = create_element(&vnode.node_type); 180 | // Recursively create child nodes as well 181 | for c in vnode.children.iter() { 182 | let child_element = create_element_from_node(c); 183 | append_element(el,child_element); 184 | } 185 | el 186 | }, 187 | VirtualDomNode::VirtualTextNode(text_node) => { 188 | let el = create_text_element(&text_node.text); 189 | el 190 | }, 191 | VirtualDomNode::Empty => { 192 | let el = create_text_element(""); 193 | el 194 | } 195 | } 196 | 197 | } 198 | ``` 199 | 200 | Finally once we create this tree of nodes, we simply attach the top most element to the body element. 201 | 202 | The real trickiness of the virtual DOM algorithm occurs when comparing two virtual DOM trees that are structurally different. We walk down the tree of both DOMs and are looking for any differances if any and determine what to do! There aren't that many scenerios to handle. Let's take a look: 203 | 204 | ``` 205 | let body = query_selector("body"); 206 | let start_vdom = VirtualDOM::Empty; 207 | let next_vdom = h("div", vec![t("hello!")]) 208 | update_element(body,0,&new_vdom,&self.root_node); 209 | ``` 210 | 211 | ```rust 212 | fn update_element(parent:DomNode, child_index:usize, new_node:&VirtualDomNode, old_node:&VirtualDomNode){ 213 | //child_index represents what child of the parent we are trying to determine what to do with 214 | match old_node { 215 | VirtualDomNode::Empty => { 216 | // If our old node was empty, the new node should be created and added to the parent 217 | // This is likely what will happen on our first render 218 | let child = create_element_from_node(&new_node); 219 | append_element(parent,child); 220 | }, 221 | VirtualDomNode::VirtualTextNode(old_text_node)=> { 222 | match new_node { 223 | VirtualDomNode::Empty => { 224 | // if a text node is being replaced with nothing 225 | // just remove that real DOM child 226 | remove_child(parent,child_index) 227 | }, 228 | VirtualDomNode::VirtualElementNode(_)=> { 229 | // if a text node is being replaced with an element node 230 | // create that real DOM element 231 | let child = create_element_from_node(new_node); 232 | // and replace the text node real DOM with it 233 | replace_child(parent,child_index,child); 234 | }, 235 | VirtualDomNode::VirtualTextNode(new_text_node)=> { 236 | // If a text node is being replaced with another text node 237 | // Check if they are different 238 | if old_text_node.text != new_text_node.text { 239 | // if so create a new text node real DOM 240 | let child = create_element_from_node(new_node); 241 | // and replace the old text node real DOM 242 | replace_child(parent,child_index,child); 243 | } 244 | } 245 | } 246 | }, 247 | VirtualDomNode::VirtualElementNode(old_vnode)=> { 248 | match new_node { 249 | // If an element is being replaced with nothing, remove the real DOM child 250 | VirtualDomNode::Empty => { 251 | remove_child(parent,child_index) 252 | }, 253 | VirtualDomNode::VirtualTextNode(_)=> { 254 | // If a real dom element is being replaced with a text node 255 | // create the text node 256 | let child = create_element_from_node(new_node); 257 | // and replace the real DOM child with it 258 | replace_child(parent,child_index,child); 259 | }, 260 | VirtualDomNode::VirtualElementNode(new_vnode)=> { 261 | // if an element node is being replaced with another element node 262 | // see if they are even the same elemen 263 | if old_vnode.node_type != new_vnode.node_type { 264 | // if they are a different element, create the new element real DOM 265 | let child = create_element_from_node(new_node); 266 | // replace the old element real DOM 267 | replace_child(parent,child_index,child); 268 | } else { 269 | // if they are the same 270 | let new_length = new_vnode.children.len(); 271 | let old_length = old_vnode.children.len(); 272 | let min_length = cmp::min(new_length,old_length); 273 | 274 | // loop through the children nodes of both the old and new element and recursively update and replace 275 | for i in 0..min_length { 276 | let child = get_child(parent,child_index); 277 | update_element( 278 | child, 279 | i, 280 | &new_vnode.children[i], 281 | &old_vnode.children[i] 282 | ); 283 | } 284 | // if we have more node children on the new element add them to the real DOM 285 | if new_length > old_length { 286 | let child = get_child(parent,child_index); 287 | for i in min_length..new_length { 288 | let new_child = create_element_from_node(&new_vnode.children[i]); 289 | append_element(child,new_child); 290 | } 291 | } 292 | // if we have less node children than the old node, remove excess real DOM children 293 | if old_length > new_length { 294 | let child = get_child(parent,child_index); 295 | for i in min_length..old_length { 296 | remove_child(child,i) 297 | } 298 | } 299 | } 300 | } 301 | } 302 | } 303 | } 304 | } 305 | ``` 306 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | function getStringFromWasm(ptr, len) { 2 | return cachedTextDecoder.decode(getUint8Memory().subarray(ptr, ptr + len)); 3 | } 4 | 5 | var linearMemory; 6 | 7 | function extractStringFromMemory(offset,len){ 8 | const stringBuffer = new Uint8Array(linearMemory.buffer, offset,len); 9 | 10 | // create a string from this buffer 11 | let str = ''; 12 | for (let i=0; i response.arrayBuffer()) 22 | .then(bytes => WebAssembly.instantiate(bytes, { 23 | env: { 24 | js_log: function(start,len){ 25 | console.log(extractStringFromMemory(start,len)); 26 | }, 27 | js_query_selector: function(start,len){ 28 | var query = extractStringFromMemory(start,len); 29 | var el = document.querySelector(query) 30 | var index = elementCache.length; 31 | elementCache.push(el); 32 | return index; 33 | }, 34 | js_create_element: function(start,len){ 35 | var parentElement = elementCache[parent]; 36 | var text = extractStringFromMemory(start,len); 37 | var el = document.createElement(text); 38 | var index = elementCache.length; 39 | elementCache.push(el); 40 | return index; 41 | }, 42 | js_create_text_element: function(start,len){ 43 | var parentElement = elementCache[parent]; 44 | var type = extractStringFromMemory(start,len); 45 | var el = document.createTextNode(type); 46 | var index = elementCache.length; 47 | elementCache.push(el); 48 | return index; 49 | }, 50 | js_append_element: function(parent,child){ 51 | var parentElement = elementCache[parent]; 52 | var childElement = elementCache[child]; 53 | parentElement.append(childElement); 54 | }, 55 | js_remove_child: function(parent,childIndex){ 56 | var parentElement = elementCache[parent]; 57 | parentElement.removeChild( 58 | parentElement.childNodes[childIndex] 59 | ); 60 | }, 61 | js_get_child: function(parent,childIndex){ 62 | var parentElement = elementCache[parent]; 63 | var el = parentElement.childNodes[childIndex]; 64 | var index = elementCache.length; 65 | elementCache.push(el); 66 | return index; 67 | }, 68 | js_replace_child: function(parent, childIndex, child){ 69 | var parentElement = elementCache[parent]; 70 | var childElement = elementCache[child]; 71 | parentElement.replaceChild(childElement,parentElement.childNodes[childIndex]); 72 | }, 73 | } 74 | })) 75 | .then(results => { 76 | linearMemory = results.instance.exports.memory; 77 | results.instance.exports.start() 78 | }); 79 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | use std::cmp; 3 | use std::ffi::CString; 4 | use std::os::raw::c_char; 5 | 6 | // We're going to need a number of helper functions to talk to javascript 7 | // so we can create, remove, modify our elements. Since in WASM you can 8 | // only pass around numbers, some of these functions hand off memory 9 | // positions of strings. 10 | // Also, since we can't pass around elements, was pass around int32 handles 11 | // to DOM elements that exist in javascript. Look for a variable named 12 | // elementCache. 13 | 14 | // DomNode represents a handle to a real DOM node 15 | type DomNode = i32; 16 | 17 | extern "C" { 18 | fn js_log(start: *mut c_char, len: usize); 19 | fn js_query_selector(start: *mut c_char, len: usize) -> DomNode; 20 | fn js_create_element(start: *mut c_char, len: usize) -> DomNode; 21 | fn js_create_text_element(start: *mut c_char, len: usize) -> DomNode; 22 | fn js_append_element(parent: DomNode, child: DomNode); 23 | fn js_remove_child(parent: DomNode, child_index: usize); 24 | fn js_replace_child(parent: DomNode, child_index: usize, child: DomNode); 25 | fn js_get_child(parent: DomNode, child_index: usize) -> DomNode; 26 | } 27 | 28 | pub fn log(msg: &str) { 29 | let s = CString::new(msg).unwrap(); 30 | let l = msg.len(); 31 | unsafe { 32 | js_log(s.into_raw(), l); 33 | } 34 | } 35 | 36 | pub fn query_selector(msg: &str) -> DomNode { 37 | let s = CString::new(msg).unwrap(); 38 | let l = msg.len(); 39 | unsafe { js_query_selector(s.into_raw(), l) } 40 | } 41 | 42 | fn create_element(msg: &str) -> DomNode { 43 | let s = CString::new(msg).unwrap(); 44 | let l = msg.len(); 45 | unsafe { js_create_element(s.into_raw(), l) } 46 | } 47 | 48 | fn create_text_element(msg: &str) -> DomNode { 49 | let s = CString::new(msg).unwrap(); 50 | let l = msg.len(); 51 | unsafe { js_create_text_element(s.into_raw(), l) } 52 | } 53 | 54 | fn append_element(parent: DomNode, child: DomNode) { 55 | unsafe { 56 | js_append_element(parent, child); 57 | } 58 | } 59 | 60 | fn remove_child(parent: DomNode, child_index: usize) { 61 | unsafe { 62 | js_remove_child(parent, child_index); 63 | } 64 | } 65 | 66 | fn replace_child(parent: DomNode, child_index: usize, child: DomNode) { 67 | unsafe { 68 | js_replace_child(parent, child_index, child); 69 | } 70 | } 71 | 72 | fn get_child(parent: DomNode, child_index: usize) -> DomNode { 73 | unsafe { js_get_child(parent, child_index) } 74 | } 75 | 76 | // A virtual dom tree is comprised of two types of nodes 77 | 78 | // VirtualElementNode represents a DOM element (div, h1, etc.) 79 | struct VirtualElementNode { 80 | node_type: String, 81 | children: Vec, 82 | } 83 | 84 | // VirtualTextNode represents text that is mixed in with elements 85 | struct VirtualTextNode { 86 | text: String, 87 | } 88 | 89 | // We use an enumeration to represent these two 90 | // plus an empty DOM node to represent nothing 91 | enum VirtualDomNode { 92 | Empty, 93 | VirtualElementNode(VirtualElementNode), 94 | VirtualTextNode(VirtualTextNode), 95 | } 96 | 97 | // These are helper functions to create virtual dom nodes 98 | fn h(node_type: &str, children: Vec) -> VirtualDomNode { 99 | VirtualDomNode::VirtualElementNode(VirtualElementNode { 100 | node_type: String::from(node_type), 101 | children: children, 102 | }) 103 | } 104 | 105 | fn t(text: &str) -> VirtualDomNode { 106 | VirtualDomNode::VirtualTextNode(VirtualTextNode { 107 | text: String::from(text), 108 | }) 109 | } 110 | 111 | // create_element_from_node is a helper for creating real DOM from virtual DOM 112 | fn create_element_from_node(node: &VirtualDomNode) -> DomNode { 113 | match node { 114 | VirtualDomNode::VirtualElementNode(vnode) => { 115 | let el = create_element(&vnode.node_type); 116 | for c in vnode.children.iter() { 117 | let child_element = create_element_from_node(c); 118 | append_element(el, child_element); 119 | } 120 | el 121 | } 122 | VirtualDomNode::VirtualTextNode(text_node) => { 123 | let el = create_text_element(&text_node.text); 124 | el 125 | } 126 | VirtualDomNode::Empty => { 127 | let el = create_text_element(""); 128 | el 129 | } 130 | } 131 | } 132 | 133 | fn update_element( 134 | parent: DomNode, 135 | child_index: usize, 136 | new_node: &VirtualDomNode, 137 | old_node: &VirtualDomNode, 138 | ) { 139 | match old_node { 140 | VirtualDomNode::Empty => { 141 | let child = create_element_from_node(&new_node); 142 | append_element(parent, child); 143 | } 144 | VirtualDomNode::VirtualElementNode(old_vnode) => match new_node { 145 | VirtualDomNode::Empty => remove_child(parent, child_index), 146 | VirtualDomNode::VirtualElementNode(new_vnode) => { 147 | if old_vnode.node_type != new_vnode.node_type { 148 | let child = create_element_from_node(new_node); 149 | replace_child(parent, child_index, child); 150 | } else { 151 | let new_length = new_vnode.children.len(); 152 | let old_length = old_vnode.children.len(); 153 | let min_length = cmp::min(new_length, old_length); 154 | for i in 0..min_length { 155 | let child = get_child(parent, child_index); 156 | update_element(child, i, &new_vnode.children[i], &old_vnode.children[i]); 157 | } 158 | if new_length > old_length { 159 | let child = get_child(parent, child_index); 160 | for i in min_length..new_length { 161 | let new_child = create_element_from_node(&new_vnode.children[i]); 162 | append_element(child, new_child); 163 | } 164 | } 165 | if old_length > new_length { 166 | let child = get_child(parent, child_index); 167 | for i in min_length..old_length { 168 | remove_child(child, i) 169 | } 170 | } 171 | } 172 | } 173 | VirtualDomNode::VirtualTextNode(_) => { 174 | let child = create_element_from_node(new_node); 175 | replace_child(parent, child_index, child); 176 | } 177 | }, 178 | VirtualDomNode::VirtualTextNode(old_text_node) => match new_node { 179 | VirtualDomNode::Empty => remove_child(parent, child_index), 180 | VirtualDomNode::VirtualElementNode(_) => { 181 | let child = create_element_from_node(new_node); 182 | replace_child(parent, child_index, child); 183 | } 184 | VirtualDomNode::VirtualTextNode(new_text_node) => { 185 | if old_text_node.text != new_text_node.text { 186 | let child = create_element_from_node(new_node); 187 | replace_child(parent, child_index, child); 188 | } 189 | } 190 | }, 191 | } 192 | } 193 | 194 | struct VirtualDom { 195 | node: VirtualDomNode, 196 | } 197 | 198 | impl VirtualDom { 199 | fn new() -> VirtualDom { 200 | VirtualDom { 201 | node: VirtualDomNode::Empty, 202 | } 203 | } 204 | 205 | fn render(&mut self, el: DomNode, new_node: VirtualDomNode) { 206 | update_element(el, 0, &new_node, &self.node); 207 | self.node = new_node; 208 | } 209 | } 210 | 211 | #[no_mangle] 212 | pub fn start() -> () { 213 | // Let's get a handle to our body element 214 | let body = query_selector("body"); 215 | 216 | // Let's create our empty virtual dom 217 | let mut vd = VirtualDom::new(); 218 | 219 | // Render a simple list to the body element 220 | vd.render( 221 | body, 222 | h( 223 | "div", 224 | vec![ 225 | h("h1", vec![t("1")]), 226 | h("h2", vec![t("2")]), 227 | h("h3", vec![t("3")]), 228 | ], 229 | ), 230 | ); 231 | 232 | // Render a new virtual dom tree to the body element 233 | vd.render( 234 | body, 235 | h( 236 | "div", 237 | vec![ 238 | h("h1", vec![t("3")]), 239 | h("h2", vec![t("2")]), 240 | h("h3", vec![t("1")]), 241 | ], 242 | ), 243 | ) 244 | } 245 | --------------------------------------------------------------------------------