Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option to reuse pre-rendered markup & to reuse DOM nodes #423

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ dist
lib
es
npm-debug.log
.idea/
13 changes: 9 additions & 4 deletions docs/api/create-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ Returns a `render` function that you can use to render elements within `DOMEleme
### Arguments

1. `el` _(HTMLElement)_: A container element that will have virtual elements rendered inside of it. The element will never be touched.
2. `dispatch` _(Function)_: A function that can receive actions from the interface. This function will be passed into every component. It usually takes an [action](http://redux.js.org/docs/basics/Actions.html) that can be handled by a [store](http://redux.js.org/docs/basics/Store.html)
2. `dispatch` _(Function)_ [optional]: A function that can receive actions from the interface. This function will be passed into every component. It usually takes an [action](http://redux.js.org/docs/basics/Actions.html) that can be handled by a [store](http://redux.js.org/docs/basics/Store.html)
3. `options` _(Object)_ [optional]: A plain object that contains renderer options:
- `reuseMarkup: true` will cause deku to reuse container contents when possible. This may be useful for isomorphic apps.
- `enableNodeRecycling: true` will enable pooling/recycling of existing DOM nodes. This should lead to reduced GC and memory usage.

### Returns

Expand Down Expand Up @@ -34,7 +37,9 @@ render(<App size="large" />)

### Notes

The container DOM element should:

* **Not be the document.body**. You'll probably run into problems with other libraries. They'll often add elements to the `document.body` which can confuse the diff algorithm.
* **Be empty**. All elements inside of the container will be removed when a virtual element is rendered into it. The renderer needs to have complete control of all of the elements within the container.
* You should **avoid using document.body as the container element**. You'll probably run into problems with other libraries. They'll often add elements to the `document.body` which can confuse the diff algorithm.

* When the container element is not empty, **deku may try to reuse container contents**. Read [this page](/deku/docs/tips/pre-rendered.md) to learn more about working with pre-rendered elements.

. All elements inside of the container will be removed when a virtual element is rendered into it, unless markup reuse option is on. The renderer needs to have complete control of all of the elements within the container.
29 changes: 29 additions & 0 deletions docs/tips/pre-rendered.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Pre-rendered HTML Elements

When the browser requests the HTML file, some of the elements may have been pre-rendered on the server-side. This can be done using deku's [`string.render`](/deku/docs/api/string.html).


```html
<div id="container"> <p>pre-rendered text</p> </div>
```

On the client side, if we just create a render function as usual, the first call to the render function would not do anything. This is because deku would assume that the container's pre-rendered content is properly rendered.

```js
var render = createApp(document.getElementById("container"), { reuseMarkup: true })
render(<p>pre-rendered text</p>) // do nothing, just assign event listeners, if any
```

If the virtualDOM describes a different HTML element, deku will rerender it completely, even if `reuseMarkup` flag is set.

```js
var render = createApp(document.getElementById("container"), { reuseMarkup: true })
render(<p>Meow!</p>) // will perform full rerender
```

### Notes

- To avoid injecting 'react-id'-like attributes into tags, Deku hardly relies on order of pre-rendered nodes.
- If starting part of pre-rendered nodes matches virtualDOM, Deku will reuse this part, but will rerender the rest.
- So this leads to the tip: if some components of your app are to be rendered on client-side only, it would be wise to place their markup closer to the end of the container to avoid full rerender of all other components.

5 changes: 4 additions & 1 deletion examples/basic/index.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import {createApp} from '../../src'
import {view, update, init} from './app'

var div = document.createElement("div");
document.body.appendChild(div);

/**
* Create a DOM renderer for vnodes. It accepts a dispatch function as
* a second parameter to handle all actions from the UI.
*/

let render = createApp(document.body)
let render = createApp(div)

/**
* Update the UI with the latest state.
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@
"@f/set-attribute": "1.0.1",
"@f/to-array": "1.1.1",
"dift": "0.1.12",
"index-of": "0.2.0",
"setify": "1.0.3",
"union-type": "0.1.6"
},
Expand Down
36 changes: 29 additions & 7 deletions src/app/index.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import * as dom from '../dom'
import {diffNode} from '../diff'
import empty from '@f/empty-element'
import noop from '@f/noop'
import empty from '@f/empty-element'

/**
* Create a DOM renderer using a container element. Everything will be rendered
* inside of that container. Returns a function that accepts new state that can
* replace what is currently rendered.
* inside of that container. Can reuse markup inside the container (if any).
* Returns a function that accepts new state that can replace what is currently rendered.
*
* Options:
* - reuseMarkup (bool): try to reuse already rendered markup inside the container.
* This might be useful for isomorphic apps.
* - enableNodeRecycling (bool): try to reuse existing DOM nodes to minimize DOM GC
*/

export function createApp (container, handler = noop, options = {}) {
Expand All @@ -15,9 +20,11 @@ export function createApp (container, handler = noop, options = {}) {
let rootId = options.id || '0'
let dispatch = effect => effect && handler(effect)

if (container) {
empty(container)
if (typeof handler !== 'function' && typeof handler === 'object') {
options = handler
handler = noop
}
dom.enableNodeRecycling(options.enableNodeRecycling || false)

let update = (newVnode, context) => {
let changes = diffNode(oldVnode, newVnode, rootId)
Expand All @@ -28,14 +35,29 @@ export function createApp (container, handler = noop, options = {}) {

let create = (vnode, context) => {
node = dom.createElement(vnode, rootId, dispatch, context)
if (container) container.appendChild(node)
if (container) {
empty(container)
container.appendChild(node)
}
oldVnode = vnode
return node
}

let createWithReuse = (vnode, context) => {
let {DOMnode, attachEvents} = dom.createElementThenEvents(vnode, rootId, dispatch, context, container.firstChild)
node = DOMnode
attachEvents(container.firstChild)
oldVnode = vnode
return node
}

return (vnode, context = {}) => {
return node !== null
? update(vnode, context)
: create(vnode, context)
: (
!container || container.childNodes.length === 0 || !options.reuseMarkup
? create(vnode, context)
: createWithReuse(vnode, context)
)
}
}
189 changes: 153 additions & 36 deletions src/dom/create.js
Original file line number Diff line number Diff line change
@@ -1,48 +1,108 @@
import createNativeElement from '@f/create-element'
import {createPath} from '../element'
import {createPath} from '../element/index'
import {setAttribute} from './setAttribute'
import isUndefined from '@f/is-undefined'
import isString from '@f/is-string'
import isNumber from '@f/is-number'
import isNull from '@f/is-null'
const cache = {}
import Pool from './pool'

const cache = new Pool()

// node manipulation action types
const CREATE = 'create'
const REPLACE = 'replace'
const NOOP = 'noop'

export default function createElement (vnode, path, dispatch, context) {
let DOM = createWithSideEffects(vnode, path, dispatch, context)
runSideEffects(DOM.sideEffects)
return DOM.element
}

export function createElementThenEvents (vnode, path, dispatch, context, container = null) {
let DOM = createWithSideEffects(vnode, path, dispatch, context, {originNode: container})
runSideEffects(DOM.sideEffects, {noEventListeners: true})
return {
DOMnode: DOM.element,
attachEvents (PreRenderedElement) {
runSideEffects(DOM.sideEffects, {onlyEventListeners: true}, PreRenderedElement)
}
}
}

export function enableNodeRecycling (flag) {
cache.enableRecycling(flag)
}

export function storeInCache (node) {
cache.store(node)
}

/**
* Create a real DOM element from a virtual element, recursively looping down.
/* Recursively traverse a tree of side effects created by `createWithSideEffects`, and run each side effect.
* Passing a third parameter DOMElement and it will traverse the DOMElement as well.
*
* A tree of side effects either takes in the value `null` or is a JSON object in the form
* {ofParent : P, ofChildren : C}
* where P is either an empty array or an array of functions
* and C is either an empty array or an array of trees of side effects
*/
function runSideEffects (sideEffects, option, DOMElement) {
if (sideEffects) {
sideEffects.ofParent.map((sideEffect) => { sideEffect(option, DOMElement) })
sideEffects.ofChildren.map((child, index) => {
if (DOMElement) {
runSideEffects(child, option, DOMElement.childNodes[index])
} else {
runSideEffects(child, option)
}
})
}
}

/**
* Create a DOM element with a tree of side effects from a virtual element by recursion.
* When it finds custom elements it will render them, cache them, and keep going,
* so they are treated like any other native element.
*/

export function createElement (vnode, path, dispatch, context) {
export function createWithSideEffects (vnode, path, dispatch, context, options = {}) {
switch (vnode.type) {
case 'text':
return createTextNode(vnode.nodeValue)
return createTextNode(vnode, options)
case 'empty':
return getCachedElement('noscript')
return {element: cache.get('noscript'), sideEffects: null, action: CREATE}
case 'thunk':
return createThunk(vnode, path, dispatch, context)
return createThunk(vnode, path, dispatch, context, options)
case 'native':
return createHTMLElement(vnode, path, dispatch, context)
return createHTMLElement(vnode, path, dispatch, context, options)
}
}

function getCachedElement (type) {
let cached = cache[type]
if (isUndefined(cached)) {
cached = cache[type] = createNativeElement(type)
function createTextNode ({nodeValue}, {originNode}) {
let value = isString(nodeValue) || isNumber(nodeValue)
? nodeValue
: ''

// Determine action to perform after creating a text node
let action = CREATE
if (originNode && originNode.nodeValue) {
action = NOOP
if (originNode.nodeValue !== value) {
action = REPLACE
}
}
return cached.cloneNode(false)
}

function createTextNode (text) {
let value = isString(text) || isNumber(text)
? text
: ''
return document.createTextNode(value)
return {
element: action !== NOOP ? document.createTextNode(value) : null,
sideEffects: null,
action
}
}

function createThunk (vnode, path, dispatch, context) {
function createThunk (vnode, path, dispatch, context, options) {
let { props, children } = vnode
let effects = {ofParent: [], ofChildren: []}

let { onCreate } = vnode.options
let model = {
children,
Expand All @@ -53,29 +113,86 @@ function createThunk (vnode, path, dispatch, context) {
}
let output = vnode.fn(model)
let childPath = createPath(path, output.key || '0')
let DOMElement = createElement(output, childPath, dispatch, context)
let { element, sideEffects, action } = createWithSideEffects(output, childPath, dispatch, context, options)
effects.ofChildren.push(sideEffects)

if (onCreate) dispatch(onCreate(model))
vnode.state = {
vnode: output,
model: model
model
}
return DOMElement
return {element, sideEffects: effects, action}
}

function createHTMLElement (vnode, path, dispatch, context) {
let { tagName, attributes, children } = vnode
let DOMElement = getCachedElement(tagName)
function createHTMLElement (vnode, path, dispatch, context, {originNode}) {
let DOMElement
let sideEffects = {ofParent: [], ofChildren: []}
let action = NOOP

if (!originNode) { // no such element in markup -> create & append
DOMElement = cache.get(vnode.tagName)
action = CREATE
} else if (!originNode.tagName || originNode.tagName.toLowerCase() !== vnode.tagName.toLowerCase()) {
// found element, but with wrong type -> recreate & replace
let newDOMElement = cache.get(vnode.tagName)
Array.prototype.forEach.call(originNode.childNodes, (nodeChild) => {
newDOMElement.appendChild(nodeChild)
})

for (let name in attributes) {
setAttribute(DOMElement, name, attributes[name])
DOMElement = newDOMElement
action = REPLACE
} else { // found node and type matches -> reuse
DOMElement = originNode
}

children.forEach((node, index) => {
if (isNull(node) || isUndefined(node)) return
let childPath = createPath(path, node.key || index)
let child = createElement(node, childPath, dispatch, context)
DOMElement.appendChild(child)
// traverse and [re]create child nodes if needed
let updateChild = nodesUpdater(DOMElement, path, dispatch, context)
vnode.children.forEach((node, index) => updateChild(node, index, sideEffects))

let attributes = Object.keys(vnode.attributes)
sideEffects.ofParent = attributes.map((name) => {
return function (option, element = DOMElement) {
setAttribute(element, name, vnode.attributes[name], null, option)
}
})

return DOMElement
return {element: DOMElement, sideEffects, action}
}

/**
* Nodes updater factory: creates vnode for every found real DOM node.
*
* Hardly relies on order of child nodes, so it may cause big overhead even
* on minor differences, especially when difference occurs close to the
* beginning of the document.
*
* @returns {Function}
*/
function nodesUpdater (parentNode, path, dispatch, context) {
let i = 0

return (node, index, sideEffects) => {
if (isNull(node) || isUndefined(node)) return

const originNode = parentNode.childNodes[i++] || null
const DOM = createWithSideEffects(
node,
createPath(path, node.key || index),
dispatch,
context,
{originNode}
)

sideEffects.ofChildren.push(DOM.sideEffects)

switch (DOM.action) {
case CREATE:
parentNode.appendChild(DOM.element)
break
case REPLACE:
cache.store(parentNode.replaceChild(DOM.element, originNode))
break
default:
}
}
}
Loading