• 主页
  • 标签
  • 归档
  • 搜索
  • Github

May 03, 2020

2020-5-2 How to write your own Virtual DOM

There are two things you need to know to build your own Virtual DOM. You do not even need to dive into React’s source. Or into source code of any other Virtual DOM implementations. They are so large and complex — but in reality the main part of Virtual DOM can be written in less than ~50 lines of code. 50. Lines. Of. Code. !!!

Here are these two concepts:

  • Virtual DOM is any kind of representation of a real DOM
  • When we change something in our Virtual DOM Tree, we get a new Virtual Tree. Algorithm compares these two trees (old and new), finds differences and makes only necessary small changes to real DOM so it reflects virtual

That’s all! Let’s dive deeper into each of these concepts.

UPDATE: The second article about setting props & events in Virtual DOM is here

Representing our DOM Tree

Well, first we need to store somehow our DOM tree in memory. And we can do that with plain old JS objects. Suppose we have this tree:

  1. 1<ul class=”list”> 

  2. 2 <li>item 1</li> 

  3. 3 <li>item 2</li> 

  4. 4</ul> 

Looks pretty simple, yeah? How could we represent that with just JS objects?

  1. 1{ type: ‘ul’, props: { ‘class’: ‘list’ }, children: [ 

  2. 2 { type: ‘li’, props: {}, children: [‘item 1’] }, 

  3. 3 { type: ‘li’, props: {}, children: [‘item 2’] } 

  4. 4] } 

Here you can notice two things:

  • We represent DOM elements with objects like
  1. 1{ type: ‘…’, props: { … }, children: [ … ] } 

  • We represent DOM text nodes with plain JS strings

But writing big trees in such way is quite difficult. So let’s write a helper function, so it will be easier for us to understand structure:

  1. 1function h(type, props, …children) { 

  2. 2 return { type, props, children }; 

  3. 3} 

Now we can write our DOM tree like this:

  1. 1h(‘ul’, { ‘class’: ‘list’ }, 

  2. 2 h(‘li’, {}, ‘item 1’), 

  3. 3 h(‘li’, {}, ‘item 2’), 

  4. 4); 

It looks a lot cleaner, yeah? But we can go even further. You’ve heard about JSX, haven’t you? Yes, I want it here too. So how does it work?

If you read official Babel JSX documentation here , you’ll know, that Babel transpiles this code:

  1. 1<ul className=”list”> 

  2. 2 <li>item 1</li> 

  3. 3 <li>item 2</li> 

  4. 4</ul> 

Into smth like this:

  1. 1React.createElement(‘ul’, { className: ‘list’ }, 

  2. 2 React.createElement(‘li’, {}, ‘item 1’), 

  3. 3 React.createElement(‘li’, {}, ‘item 2’), 

  4. 4); 

Notice any similarities? Yes, yes.. If we could just replace those React.createElement(…)’ s with our h(…)’ s calls… It turns out we can — by using smth called jsx pragma. We just need to include comment-like line at the top of our source file:

  1. 1/** @jsx h */ 

  2. 2<ul className=”list”> 

  3. 3 <li>item 1</li> 

  4. 4 <li>item 2</li> 

  5. 5</ul> 

Well, it actually tells Babel ‘hey, transpile that jsx but instead of React.createElement , put h’. You can put anything instead of h there. And that will be transpiled.

So, to sum up what I’ve said before, we will write our DOM in such way:

  1. 1/** @jsx h */ 

  2. 2const a = ( 

  3. 3 <ul className=”list”> 

  4. 4 <li>item 1</li> 

  5. 5 <li>item 2</li> 

  6. 6 </ul> 

  7. 7); 

And that will be transpiled by Babel to this code:

  1. 1const a = ( 

  2. 2 h(‘ul’, { className: ‘list’ }, 

  3. 3 h(‘li’, {}, ‘item 1’), 

  4. 4 h(‘li’, {}, ‘item 2’), 

  5. 5 ); 

  6. 6); 

When function h executes, it will return plain JS objects — our Virtual DOM representation:

  1. 1const a = ( 

  2. 2 { type: ‘ul’, props: { className: ‘list’ }, children: [ 

  3. 3 { type: ‘li’, props: {}, children: [‘item 1’] }, 

  4. 4 { type: ‘li’, props: {}, children: [‘item 2’] } 

  5. 5 ] } 

  6. 6); 

Go ahead and try that in JSFiddle (don’t forget to set Babel as your language):

  1. 1/** @jsx h */ 

  2. 2 

  3. 3function h(type, props, ...children) { 

  4. 4 return { type, props, children }; 

  5. 5} 

  6. 6 

  7. 7const a = ( 

  8. 8 <ul class="list"> 

  9. 9 <li>item 1</li> 

  10. 10 <li>item 2</li> 

  11. 11 </ul> 

  12. 12); 

  13. 13 

  14. 14console.log(a); 

Applying our DOM Representation

Ok, now we have our DOM tree represented as plain JS objects, with our own structure. That’s cool, but we need to somehow create a real DOM from it. ’Cause we can’t just append our representation into DOM.

First let’s make some assumptions and set up terminology:

  • I will write all variables with real DOM nodes (elements, text nodes) starting with $ — so $parent will be real DOM element
  • Virtual DOM representation will be in variable named node
  • Like in React, you can have only one root node — all other nodes will be inside

Ok, having that said, let us write a function createElement(…) that will take a virtual DOM node and return a real DOM node. Forget about props and children for now — we’ll set up that later:

  1. 1function createElement(node) { 

  2. 2 if (typeof node === ‘string’) { 

  3. 3 return document.createTextNode(node); 

  4. 4 } 

  5. 5 return document.createElement(node.type); 

  6. 6} 

So, because we can have both text nodes — that are plain JS strings and elements — that are JS objects of type like:

  1. 1{ type: ‘…’, props: { … }, children: [ … ] } 

Thus, we can pass here both virtual text nodes and virtual element nodes — and that will work.

Now let’s think about children — each of them is also either a text node or an element. So they can also be created with our createElement(…) function. Yeah, do you feel that? It feels recursively :)) So we can call createElement(…)
for each of element’s children and then appendChild() them into our element like this:

  1. 1function createElement(node) { 

  2. 2 if (typeof node === ‘string’) { 

  3. 3 return document.createTextNode(node); 

  4. 4 } 

  5. 5 const $el = document.createElement(node.type); 

  6. 6 node.children 

  7. 7 .map(createElement) 

  8. 8 .forEach($el.appendChild.bind($el)); 

  9. 9 return $el; 

  10. 10} 

Wow, that looks nice. Let’s put aside node props for now. We’ll talk about them later. We do not need them for understanding basic concepts of Virtual DOM but they’ll add more complexity.

Now go ahead and try that in JSFiddle:

  1. 1/** @jsx h */ 

  2. 2 

  3. 3function h(type, props, ...children) { 

  4. 4 return { type, props, children }; 

  5. 5} 

  6. 6 

  7. 7function createElement(node) { 

  8. 8 if (typeof node === 'string') { 

  9. 9 return document.createTextNode(node); 

  10. 10 } 

  11. 11 const $el = document.createElement(node.type); 

  12. 12 node.children 

  13. 13 .map(createElement) 

  14. 14 .forEach($el.appendChild.bind($el)); 

  15. 15 return $el; 

  16. 16} 

  17. 17 

  18. 18const a = ( 

  19. 19 <ul class="list"> 

  20. 20 <li>item 1</li> 

  21. 21 <li>item 2</li> 

  22. 22 </ul> 

  23. 23); 

  24. 24 

  25. 25const $root = document.getElementById('root'); 

  26. 26$root.appendChild(createElement(a)); 

Handling changes

Ok, now that we can turn our virtual DOM into a real DOM, it’s time to think about diffing our virtual trees. So basically we need to write an algo, that will compare two virtual trees — old and new — and make only necessary changes into real DOM.

How to diff trees? Well, we need to handle next cases:

  • There is no old node at some place — so node was added and we need to appendChild(…) that


  • There is no new node at some place — thus node was deleted and we need to removeChild(…)


  • There is a different node at that place — thus node changed and we need to replaceChild(…)


  • Nodes are the same — so we need to go deeper and diff child nodes


Ok, let us write a function called updateElement(…) that takes three parameters — $parent, newNode and **oldNode, **where $parent is a real DOM element-parent of our virtual node. Now we’ll see how to handle all cases that described above.

There is no old node

Well, it is pretty straightforward here, I won’t even comment:

  1. 1function updateElement($parent, newNode, oldNode) { 

  2. 2 if (!oldNode) { 

  3. 3 $parent.appendChild( 

  4. 4 createElement(newNode) 

  5. 5 ); 

  6. 6 } 

  7. 7} 

There is no new node

Here we’ve got a problem — if there is no node at current place in new virtual tree — we should remove it from a real DOM — but how should we do that? Yeah, we know parent element (it is passed to function) and thus we are supposed to call $parent.removeChild(…) and pass real DOM element reference there. But we do not have that. Well, if we knew position of our node in parent, we could get its reference with $parent.childNodes[index], where index is position of our node in parent element.

Ok let’s suppose that this index will be passed to our function (and it really will be passed — you’ll see it later). So our code will be:

  1. 1function updateElement($parent, newNode, oldNode, index = 0) { 

  2. 2 if (!oldNode) { 

  3. 3 $parent.appendChild( 

  4. 4 createElement(newNode) 

  5. 5 ); 

  6. 6 } else if (!newNode) { 

  7. 7 $parent.removeChild( 

  8. 8 $parent.childNodes[index] 

  9. 9 ); 

  10. 10 } 

  11. 11} 

Node changed

First we need to write a function that will compare two nodes (old and new) and tell us if node really changed. We should consider that it can be both elements and text nodes:

  1. 1function changed(node1, node2) { 

  2. 2 return typeof node1 !== typeof node2 || 

  3. 3 typeof node1 === ‘string’ && node1 !== node2 || 

  4. 4 node1.type !== node2.type 

  5. 5} 

And now, having index of current node in parent we can easily replace it with newly created node:

  1. 1function updateElement($parent, newNode, oldNode, index = 0) { 

  2. 2 if (!oldNode) { 

  3. 3 $parent.appendChild( 

  4. 4 createElement(newNode) 

  5. 5 ); 

  6. 6 } else if (!newNode) { 

  7. 7 $parent.removeChild( 

  8. 8 $parent.childNodes[index] 

  9. 9 ); 

  10. 10 } else if (changed(newNode, oldNode)) { 

  11. 11 $parent.replaceChild( 

  12. 12 createElement(newNode), 

  13. 13 $parent.childNodes[index] 

  14. 14 ); 

  15. 15 } 

  16. 16} 

Diff children

And last, but not least — we should go through every child of both nodes and compare them — actually call updateElement(…) for each of them. Yeah, recursion again.

But there are some things to consider here before writing the code:

  • We should compare children only if node is an element (text nodes can not have children)
  • Now we pass reference to current node as parent
  • We should compare all children one by one — even if at some point we will have undefined — it is ok — our function can handle that
  • And finally index — it is just index of child node in children array
  1. 1function updateElement($parent, newNode, oldNode, index = 0) { 

  2. 2 if (!oldNode) { 

  3. 3 $parent.appendChild( 

  4. 4 createElement(newNode) 

  5. 5 ); 

  6. 6 } else if (!newNode) { 

  7. 7 $parent.removeChild( 

  8. 8 $parent.childNodes[index] 

  9. 9 ); 

  10. 10 } else if (changed(newNode, oldNode)) { 

  11. 11 $parent.replaceChild( 

  12. 12 createElement(newNode), 

  13. 13 $parent.childNodes[index] 

  14. 14 ); 

  15. 15 } else if (newNode.type) { 

  16. 16 const newLength = newNode.children.length; 

  17. 17 const oldLength = oldNode.children.length; 

  18. 18 for (let i = 0; i < newLength || i < oldLength; i++) { 

  19. 19 updateElement( 

  20. 20 $parent.childNodes[index], 

  21. 21 newNode.children[i], 

  22. 22 oldNode.children[i], 

  23. 23 i 

  24. 24 ); 

  25. 25 } 

  26. 26 } 

  27. 27} 

Put That All Together

Yeah, this is it. We’re there. I’ve put all the code into JSFiddle and the implementation part took really 50 LOC — as I promised you. Go ahead and play with it.

  1. 1/** @jsx h */ 

  2. 2 

  3. 3function h(type, props, ...children) { 

  4. 4 return { type, props, children }; 

  5. 5} 

  6. 6 

  7. 7function createElement(node) { 

  8. 8 if (typeof node === 'string') { 

  9. 9 return document.createTextNode(node); 

  10. 10 } 

  11. 11 const $el = document.createElement(node.type); 

  12. 12 node.children 

  13. 13 .map(createElement) 

  14. 14 .forEach($el.appendChild.bind($el)); 

  15. 15 return $el; 

  16. 16} 

  17. 17 

  18. 18function changed(node1, node2) { 

  19. 19 return typeof node1 !== typeof node2 || 

  20. 20 typeof node1 === 'string' && node1 !== node2 || 

  21. 21 node1.type !== node2.type 

  22. 22} 

  23. 23 

  24. 24function updateElement($parent, newNode, oldNode, index = 0) { 

  25. 25 if (!oldNode) { 

  26. 26 $parent.appendChild( 

  27. 27 createElement(newNode) 

  28. 28 ); 

  29. 29 } else if (!newNode) { 

  30. 30 $parent.removeChild( 

  31. 31 $parent.childNodes[index] 

  32. 32 ); 

  33. 33 } else if (changed(newNode, oldNode)) { 

  34. 34 $parent.replaceChild( 

  35. 35 createElement(newNode), 

  36. 36 $parent.childNodes[index] 

  37. 37 ); 

  38. 38 } else if (newNode.type) { 

  39. 39 const newLength = newNode.children.length; 

  40. 40 const oldLength = oldNode.children.length; 

  41. 41 for (let i = 0; i < newLength || i < oldLength; i++) { 

  42. 42 updateElement( 

  43. 43 $parent.childNodes[index], 

  44. 44 newNode.children[i], 

  45. 45 oldNode.children[i], 

  46. 46 i 

  47. 47 ); 

  48. 48 } 

  49. 49 } 

  50. 50} 

  51. 51 

  52. 52// --------------------------------------------------------------------- 

  53. 53 

  54. 54const a = ( 

  55. 55 <ul> 

  56. 56 <li>item 1</li> 

  57. 57 <li>item 2</li> 

  58. 58 </ul> 

  59. 59); 

  60. 60 

  61. 61const b = ( 

  62. 62 <ul> 

  63. 63 <li>item 1</li> 

  64. 64 <li>hello!</li> 

  65. 65 </ul> 

  66. 66); 

  67. 67 

  68. 68const $root = document.getElementById('root'); 

  69. 69const $reload = document.getElementById('reload'); 

  70. 70 

  71. 71updateElement($root, a); 

  72. 72$reload.addEventListener('click', () => { 

  73. 73 updateElement($root, b, a); 

  74. 74}); 

  75. 75 

  76. 76 

  77. 77 

Open up Developer Tools and watch changes applied when you press Reload button.


Conclusion

Congratulations! We’ve done that. We’ve written our Virtual DOM implementation. And it works. I hope that, having read this article, you understood basic concepts of how Virtual DOM should work and how React works under the hood.

However there are some things that were not highlighted here (I will try to cover them in future articles):

  • Setting element attributes (props) and diffing/updating them
  • Handling events — adding event listeners to our elements
  • Making our Virtual DOM work with components, like React
  • Getting references to real DOM nodes
  • Using Virtual DOM with libraries that directly mutate real DOM — like jQuery and its plugins
  • and even more…

P.S.

If there any errors in code or article, or if there are any optimizations I can do to this code — feel free to express them in comments :)) And sorry for my English :)

UPDATE: The second article about setting props & events in Virtual DOM is here

published from :How to write your own Virtual DOM - deathmood - Medium

Tagged with 文章 | 转载 | html | javascript | English
Time Flies, No Time for Nuts
Copyright © 2020 suziwen
Build with  Gatsbyjs  and  Sculpting theme