Files
el/ui/runtime/src/renderer.js
T

145 lines
5.3 KiB
JavaScript

/**
* renderer.js — DOM rendering and patching for el-ui.
*
* v0.1 strategy: full string re-render with event rebinding.
* The architecture is designed for future targeted DOM patching:
* `patch()` will accept the activated node set and only update
* DOM subtrees whose data-el-tag corresponds to an activated node.
*
* Event binding uses data-el-* attributes set by the compiler,
* which avoids innerHTML XSS risks for handler references while
* keeping the runtime small (no virtual DOM, no event delegation table).
*/
export class Renderer {
/**
* @param {HTMLElement} root the mount point
* @param {import('./index.js').Component} component component instance
*/
constructor(root, component) {
this.root = root;
this.component = component;
this._currentHtml = '';
this._boundHandlers = new WeakMap();
}
/**
* Initial mount — render and bind events.
*/
mount() {
this._currentHtml = this.component.render();
this.root.innerHTML = this._currentHtml;
this._bindEvents();
if (typeof this.component.onMount === 'function') {
this.component.onMount();
}
}
/**
* Patch the DOM after a state change.
* Called by the component when spreading activation completes.
*
* @param {Set<string>} [_activatedNodes] future: targeted patching by node set
*/
patch(_activatedNodes) {
const newHtml = this.component.render();
if (newHtml !== this._currentHtml) {
// Preserve scroll position and focused element identity
const focused = document.activeElement;
const focusedId = focused && focused !== document.body ? focused.id : null;
this.root.innerHTML = newHtml;
this._bindEvents();
this._currentHtml = newHtml;
// Restore focus if element still exists
if (focusedId) {
const el = this.root.querySelector(`#${CSS.escape(focusedId)}`);
if (el) el.focus();
}
}
}
/**
* Bind all data-el-* event handlers declared by the compiler.
*
* The compiler emits attributes like:
* data-el-click="__self.handleClick()"
* data-el-input="(e) => __self.setState('value', e.target.value)"
*
* We eval these in the context of the component instance.
* (For production use, a full CSP-compatible binding would generate
* handler functions at compile time.)
*
* @private
*/
_bindEvents() {
const comp = this.component;
const eventNames = ['click', 'input', 'change', 'mouseenter', 'mouseleave',
'keydown', 'keyup', 'keypress', 'focus', 'blur',
'submit', 'mousedown', 'mouseup', 'dblclick', 'contextmenu'];
for (const eventName of eventNames) {
const attr = `data-el-${eventName}`;
this.root.querySelectorAll(`[${attr}]`).forEach(el => {
const handlerExpr = el.getAttribute(attr);
if (!handlerExpr) return;
// Build a handler function in the component's scope
const handler = this._compileHandler(handlerExpr, comp);
if (handler) {
// Remove previous binding if any
const prev = this._boundHandlers.get(el);
if (prev) el.removeEventListener(eventName, prev[eventName]);
el.addEventListener(eventName, handler);
if (!this._boundHandlers.has(el)) this._boundHandlers.set(el, {});
this._boundHandlers.get(el)[eventName] = handler;
}
});
}
}
/**
* Compile a handler expression string into a callable function.
* The expression is evaluated with `__self` bound to the component.
*
* @private
* @param {string} expr
* @param {object} comp
* @returns {function|null}
*/
_compileHandler(expr, comp) {
try {
// Decode HTML entities (attrs may have &quot; etc.)
const decoded = expr
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
// If it looks like an arrow function or function reference, wrap it
const isArrow = decoded.includes('=>');
const isCall = decoded.includes('(');
if (isArrow) {
// Arrow function: `(e) => __self.setState(...)` or `() => ...`
// eslint-disable-next-line no-new-func
const fn = new Function('__self', `return ${decoded}`)(comp);
return typeof fn === 'function' ? fn : null;
} else if (isCall) {
// Method call: `__self.handleClick()`
// eslint-disable-next-line no-new-func
return new Function('__self', 'event', `${decoded}`).bind(null, comp);
} else {
// Bare identifier: method name on the component
return comp[decoded] ? comp[decoded].bind(comp) : null;
}
} catch (e) {
console.warn(`el-ui: failed to compile handler: ${expr}`, e);
return null;
}
}
}