nano-data-binding
Version:
Simple, barebone data binding for web components
254 lines (203 loc) • 10 kB
text/typescript
import { DataBind, Listener, Listeners, Subscriptions } from '../interfaces/nano-data-binding'
import { ORIGIN, RULE } from '../constants/nano-data-binding.const'
import * as utils from './utils'
import * as parser from './parser'
// Services
import { templates } from './cache'
// Debug
let Debug = require('debug'), debug = Debug ? Debug('ndb:Bind') : () => {}
debug('Instantiate Bind')
/**
* ====== BIND ======
* After selecting the target elements we need to bind the parent and the children contexts.
* This is the core of the entire system
*
* <!> Several operations are performed:
* // Copying references to methods so that they can be invoked by inline event handlers such ast `onclick`. // DEPRECATED
* Building the `dataBind` descriptor object that is used to transfer information between the various stages of the process.
* Setting up DOM caches for the rules that require them (IF and FOR).
* Whatching for changes in the source values (context properties, events, observables).
* If a method is not defined in the child context than it is looked-up in the parent context and if found the reference is copied.
* Reacting to any changed values by executing a certain behavior for each rule.
*
* <!> The original approach was to copy references from parent context to the child context.
* This approach had a long list a drawbacks.
* - Possiblity of collisions between members of parent and child contexts.
* - Members added to the parent context at runtime are ignored.
* - References to primitives form the parent context were copied and then remaind stale`.
* - Setters and getters could not by copied, it was necessary to use `Object.defineProperty()`.
*/
/**
* <!> The event handler will be evaluated in the context of the child element
* <!> All event listeners are automatically cleaned up when the component is destroyed
*/
export function initDataBinds(parent: HTMLElement, children: HTMLElement[]): void {
children.forEach(child => {
// Prevent double init of the same element
if ((<any>child)._nano_dataBind) {
if (parent.hasAttribute('no-auto-bind')) {
debug('Data bind already initialised', {dataBind: this._nano_dataBind})
} else {
console.warn('Data bind already initialised', {dataBind: this._nano_dataBind})
}
return
}
let attributes: Attr[] = Array.from(child.attributes),
dataBind: DataBind = <DataBind>{ parent, child },
listeners: Listeners = {},
subscriptions: Subscriptions = {},
hostEl: Node, // child or in the case of the IF rule the placehodler comment
// TODO, find a better way, this approach is not simple and easy. The other rules may execute first.
hasPlaceholder: boolean // If placehodler is already defined, than skip the data bind process
attributes.forEach(attr => {
// Ignore other attributes
if (!utils.isAttrDataBind(attr)) {return}
// Parse
Object.assign(dataBind, getDataBindFromAttribute(attr))
debug('Data bind', {dataBind})
// Cache
hasPlaceholder = cacheInitialState(dataBind)
if (hasPlaceholder === true) return // Prevent double init of IF rule
hostEl = dataBind.rule === RULE.If ? dataBind.placeholder : dataBind.child
// debug('Host element', {hostEl}) // Verbose
;(hostEl as any)._nano_dataBind = dataBind
// Watch
let refs = watchForValueChanges(dataBind)
if (dataBind.origin === ORIGIN.Event) Object.assign(listeners, refs)
if (dataBind.origin === ORIGIN.Observable) Object.assign(subscriptions, refs)
})
if (hasPlaceholder === true) return // Prevent double init of IF rule
// <!> Provide an easy method for removing all custom listeners when the child element is destroyed
;(hostEl as any)._nano_listeners = listeners
;(hostEl as any)._nano_subscriptions = subscriptions
})
}
export function getDataBindFromAttribute (attribute: Attr): DataBind {
let dataBind: DataBind = <DataBind>{
origin: utils.getDataBindOrigin(attribute),
rule: utils.getDataBindRule(attribute),
source: utils.getDataBindSource(attribute),
code: utils.getDataBindCode(attribute)
}
// debug('Get data bind from attribute', {dataBind}) // Verbose
return dataBind
}
/**
* IF rule sets up a placeholder comment
* FOR rule cached the initial template
*/
export function cacheInitialState (dataBind: DataBind): boolean {
debug('Cache initial state', { dataBind })
let { child } = dataBind,
placeholderIndex: number, // Used to identify the position of the targeted IF element and then find the placeholder comment
placeholder: Node, // A placeholder comment will be present if the data bind was already initialised
isComment: boolean // Double check that the placeholder is the right node
if (dataBind.rule === RULE.If) {
// n-if uses a placehodler comment that will control the visibility of the target/child element
placeholderIndex = Array.prototype.indexOf.call(child.parentElement.childNodes, child) - 1
placeholder = child.parentElement.childNodes[placeholderIndex]
isComment = placeholder.nodeType === 8
debug('Recover IF data bind placeholder', { isComment, placeholderIndex, placeholder })
// Create placeholder only once
if (isComment !== true) parser.setupIfDataBindPlaceholder(dataBind)
} else if (dataBind.rule === RULE.For) {
// Cache original html for reuse when the list is updated
let tplId = +Array.from(child.attributes).find( attr => attr.nodeName === `tpl` ).nodeValue
// debug(`Cached template (retrieved after preprocessing)`, tplId, templates[tplId]) // Verbose
dataBind.template = templates[tplId]
// dataBind.template = child.innerHTML // DEPRECATE Retrieved from the cached templates (after preprocessing)
// child.innerHTML = '' // DEPRECATE Already done in preprocessing
}
return isComment
}
// TODO Break in smaller parts
export function watchForValueChanges (dataBind: DataBind): Listeners | Subscriptions {
debug('Watch for value changes', {dataBind})
let { origin, parent, source } = dataBind,
listeners: Listeners = {},
subscriptions: Subscriptions = {}
if (origin === ORIGIN.Property) {
let value: any,
proto: any,
_desc: PropertyDescriptor,
desc: PropertyDescriptor,
_set: any, // Originals
set: any, // Wrappers
_get: any,
get: any
proto = Object.getPrototypeOf(parent)
_desc = Object.getOwnPropertyDescriptor(parent, source)
// Original or new set
_set = proto.__lookupSetter__(source)
if (!_set) {
if (_desc) _set = _desc.set
else _set = function (val: any) { value = val }
}
// Original or new get
_get = proto.__lookupGetter__(source)
if (!_get) {
if (_desc) _get = _desc.get
else _get = function () { return value }
}
// Cache original property value
if ((<any>parent)[source] !== undefined) {
value = (<any>parent)[source]
// <!> Evaluate data bind with first value
evaluateDataBind(dataBind)
}
// Wrappers
set = (val: any) => {
_set.call(parent, val)
evaluateDataBind(dataBind)
}
get = () => { return _get.call(parent) }
// Bind
desc = { set , get }
Object.defineProperty(parent, source, desc)
} else if (origin === ORIGIN.Event) {
// Prepare the event handlers
let eventHandler: Listener = () => evaluateDataBind(dataBind)
// Cache the handlers so they ca be removed later
listeners[source] = eventHandler
// Add the custom event listener
document.addEventListener(source, eventHandler)
debug('Added custom event listener', source)
return listeners
} else if (origin === ORIGIN.Observable) {
// To implement
return subscriptions
}
}
/**
* Evaluate the method provided in the attribute
* <!> Establishing in which context the evaluated method is executed is very easy, just switch between normal functions and lamba functions
* In time if VScode gets better language support for template strings, even auto complete should work.
*/
export function evaluateDataBind(dataBind: DataBind): void {
dataBind.event = event as CustomEvent
debug('Evaluate data bind', { dataBind })
let { rule } = dataBind
// Evaluate the attribute value depending on the rule (attribute name)
switch (dataBind.rule) {
case RULE.Data:
parser.bindDataToElem(dataBind)
break
case RULE.If:
parser.toggleIfDataBindElement(dataBind)
break
case RULE.For:
parser.updateItemsInForList(dataBind)
break
case RULE.Class:
parser.addCssClassesToElem(dataBind)
break
// case RULE.Class:
// parser.addCssClassesToElem(dataBind)
// break
case RULE.Call:
parser.callChildContextMethod(dataBind)
break
default:
console.warn(`Data bind rule "${rule}" is invalid. Accepted rules are: n-data, n-if, n-for, n-class, n-call`)
}
}