mutant
Version:
Create observables and map them to DOM elements. Massively inspired by hyperscript and observ-*. No virtual dom, just direct observable bindings. Unnecessary garbage collection is avoided by using mutable objects instead of blasting immutable junk all ove
373 lines (327 loc) • 9.23 kB
JavaScript
var applyProperties = require('./lib/apply-properties')
var isObservable = require('./is-observable')
var parseTag = require('./lib/parse-tag')
var walk = require('./lib/walk')
var watch = require('./watch')
var caches = new global.WeakMap()
var bindQueue = []
var currentlyBinding = false
var watcher = null
var releaseNextTick = require('./lib/release-next-tick')
module.exports = function (tag, attributes, children) {
return Element(global.document, null, tag, attributes, children)
}
module.exports.forDocument = function (document, namespace) {
return Element.bind(this, document, namespace)
}
function Element (document, namespace, tagName, properties, children) {
if (!children && (Array.isArray(properties) || isText(properties) || isNode(properties) || isObservable(properties))) {
children = properties
properties = null
}
checkWatcher(document)
properties = properties || {}
var tag = parseTag(tagName, properties, namespace)
var node = namespace
? document.createElementNS(namespace, tag.tagName)
: document.createElement(tag.tagName)
if (tag.id) {
node.id = tag.id
}
if (tag.classes && tag.classes.length) {
node.className = tag.classes.join(' ')
}
var data = {
targets: new Map(),
observing: false,
bindings: [],
hookBindings: []
}
if ('intersectionBindingViewport' in properties) {
var options = properties.intersectionBindingViewport
delete properties.intersectionBindingViewport
if (options && global.IntersectionObserver) {
node.__mutantIntersectionBindingViewport = true
data.intersectionObserver = new global.IntersectionObserver(onIntersection, {
root: node,
rootMargin: options.rootMargin || '0px',
threshold: [0, 0.1]
})
}
}
caches.set(node, data)
applyProperties(node, properties, data)
if (children != null) {
appendChild(document, node, data, children)
}
maybeBind(document, node)
return node
}
function appendChild (document, target, data, node) {
if (Array.isArray(node)) {
node.forEach(function (child) {
appendChild(document, target, data, child)
})
} else if (isObservable(node)) {
var nodes = getNodes(document, resolve(node))
nodes.forEach(append, { target: target, document: document })
data.targets.set(node, nodes)
data.bindings.push(new Binding(document, node, data))
} else {
node = getNode(document, node)
target.appendChild(node)
if (getRootNode(node) === document) {
walk(node, rebind)
}
}
}
function append (child) {
this.target.appendChild(child)
if (getRootNode(child) === this.document) {
walk(child, rebind)
}
}
function maybeBind (document, node) {
bindQueue.push([document, node])
if (!currentlyBinding) {
currentlyBinding = true
setImmediate(flushBindQueue)
}
}
function flushBindQueue () {
currentlyBinding = false
while (bindQueue.length) {
var item = bindQueue.shift()
var document = item[0]
var node = item[1]
if (getRootNode(node) === document) {
walk(node, rebind)
}
}
}
function checkWatcher (document) {
if (!watcher && global.MutationObserver) {
watcher = new global.MutationObserver(onMutate)
watcher.observe(document, {subtree: true, childList: true})
}
}
function onMutate (changes) {
changes.forEach(handleChange)
}
function onIntersection (changes) {
changes.forEach(handleIntersect)
}
function handleIntersect (change) {
if (change.isIntersecting) {
if (change.intersectionRatio >= 0.1) {
enterViewport(change.target)
}
} else {
exitViewport(change.target)
}
}
function getRootNode (el) {
var element = el
while (element.parentNode) {
element = element.parentNode
}
return element
}
function getIntersectionObserver (el) {
var element = el
while (element.parentNode) {
element = element.parentNode
if (element.__mutantIntersectionBindingViewport) {
var data = caches.get(element)
if (data) {
return data.intersectionObserver
}
}
}
}
function enterViewport (node) {
if (node.nodeType === 1) {
var data = caches.get(node)
if (data) {
if (data.observing) {
data.bindings.forEach(invokeBind)
}
}
}
}
function exitViewport (node) {
if (node.nodeType === 1) {
var data = caches.get(node)
if (data) {
if (data.observing) {
data.bindings.forEach(invokeUnbind)
}
}
}
}
function handleChange (change) {
for (var i = 0; i < change.addedNodes.length; i++) {
// if parent is a mutant element, then safe to assume it has already been bound
var node = change.addedNodes[i]
if (!caches.has(node.parentNode) || !isBound(node)) {
walk(node, rebind)
}
}
for (var i = 0; i < change.removedNodes.length; i++) {
var node = change.removedNodes[i]
walk(node, unbind)
}
}
function indexOf (target, item) {
return Array.prototype.indexOf.call(target, item)
}
function replace (oldNodes, newNodes) {
var parent = oldNodes[oldNodes.length - 1].parentNode
var nodes = parent.childNodes
var startIndex = indexOf(nodes, oldNodes[0])
// avoid reinserting nodes that are already in correct position!
for (var i = 0; i < newNodes.length; i++) {
if (nodes[i + startIndex] === newNodes[i]) {
continue
} else if (nodes[i + startIndex + 1] === newNodes[i]) {
parent.removeChild(nodes[i + startIndex])
continue
} else if (nodes[i + startIndex] === newNodes[i + 1] && newNodes[i + 1]) {
parent.insertBefore(newNodes[i], nodes[i + startIndex])
} else if (nodes[i + startIndex]) {
parent.insertBefore(newNodes[i], nodes[i + startIndex])
} else {
parent.appendChild(newNodes[i])
}
walk(newNodes[i], rebind)
}
oldNodes.filter(function (node) {
return !~newNodes.indexOf(node)
}).forEach(function (node) {
if (node.parentNode) {
parent.removeChild(node)
}
walk(node, unbind)
})
}
function isText (value) {
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
}
function isNode (value) {
// for some reason, img elements are not instances of Node
return value instanceof global.Node || (global.HTMLElement && value instanceof global.HTMLElement)
}
function getNode (document, nodeOrText) {
if (nodeOrText == null) {
return document.createTextNode('')
} else if (isText(nodeOrText)) {
return document.createTextNode(nodeOrText.toString())
} else {
return nodeOrText
}
}
function getNodes (document, nodeOrNodes) {
if (Array.isArray(nodeOrNodes)) {
if (nodeOrNodes.length) {
var result = []
for (var i = 0; i < nodeOrNodes.length; i++) {
var item = nodeOrNodes[i]
if (Array.isArray(item)) {
getNodes(document, item).forEach(push, result)
} else {
result.push(getNode(document, item))
}
}
return result.map(getNode.bind(this, document))
} else {
return [getNode(document, null)]
}
} else {
return [getNode(document, nodeOrNodes)]
}
}
function rebind (node) {
if (node.nodeType === 1) {
var data = caches.get(node)
if (data) {
var intersectionObserver = getIntersectionObserver(node)
if (!data.observing && intersectionObserver) {
data.observing = true
intersectionObserver.observe(node)
} else {
data.bindings.forEach(invokeBind)
}
data.hookBindings.forEach(invokeBind)
}
}
}
function unbind (node) {
if (node.nodeType === 1) {
var data = caches.get(node)
if (data) {
var intersectionObserver = getIntersectionObserver(node)
if (intersectionObserver && data.observing) {
data.observing = false
intersectionObserver.unobserve(node)
}
data.bindings.forEach(invokeUnbind)
data.hookBindings.forEach(invokeUnbind)
}
}
}
function isBound (node) {
if (node.nodeType === 1) {
var data = caches.get(node)
if (data) {
return data.observing || data.bindings.some(getBound) || data.hookBindings.some(getBound)
}
}
}
function getBound (binding) {
return binding.bound
}
function invokeBind (binding) {
binding.bind()
}
function invokeUnbind (binding) {
binding.unbind()
}
function push (item) {
this.push(item)
}
function resolve (source) {
return typeof source === 'function' ? source() : source
}
function Binding (document, obs, data) {
this.document = document
this.obs = obs
this.data = data
this.bound = false
this.update = function (value) {
var oldNodes = data.targets.get(obs)
var newNodes = getNodes(document, value)
if (oldNodes) {
replace(oldNodes, newNodes)
data.targets.set(obs, newNodes)
}
}
// listen immediately, but if not bound before next tick, release
this.release = obs(this.update)
releaseNextTick(this)
}
Binding.prototype = {
bind: function () {
if (!this.bound) {
if (!this.release) {
this.release = watch(this.obs, this.update)
}
this.bound = true
}
},
unbind: function () {
if (this.bound) {
this.bound = false
releaseNextTick(this)
}
}
}