From 841f02bb3847008ec16656cc78c3c9c72719458f Mon Sep 17 00:00:00 2001 From: Astrian Zheng Date: Wed, 14 May 2025 20:48:30 +1000 Subject: [PATCH] Enhance state management in CustomElement: add state listeners, improve DOM update logic, and implement text and attribute binding mechanisms --- src/main.ts | 236 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 229 insertions(+), 7 deletions(-) diff --git a/src/main.ts b/src/main.ts index e39e653..669173d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,6 +21,11 @@ export default (options: ComponentOptions) => { class CustomElementImpl extends HTMLElement { private _states: Record = {} + private _stateToElementsMap: Record> = {} + private _currentRenderingElement: HTMLElement | null = null + private _statesListeners: Record = {} + private _textBindings: Array<{ node: Text, expr: string, originalContent: string }> = [] + private _attributeBindings: Array<{ element: Element, attrName: string, expr: string, template: string }> = [] constructor() { super() @@ -41,7 +46,10 @@ export default (options: ComponentOptions) => { currentTarget = currentTarget[key] } } - // TODO: trigger dom updates + // trigger dom updates + this._triggerDomUpdates(keyPath) + if (this._statesListeners[keyPath]) + this._statesListeners[keyPath](value) // trigger state update events if (statesListeners && statesListeners[keyPath]) { @@ -51,6 +59,13 @@ export default (options: ComponentOptions) => { return true }, get: (target: Record, keyPath: string) => { + // collect state dependencies + if (this._currentRenderingElement) { + if (!this._stateToElementsMap[keyPath]) + this._stateToElementsMap[keyPath] = new Set() + this._stateToElementsMap[keyPath].add(this._currentRenderingElement) + } + const valueRoute = keyPath.split('.') let currentTarget = target for (let i in valueRoute) { @@ -68,14 +83,14 @@ export default (options: ComponentOptions) => { } }) - // initialize shadow dom - this.attachShadow({ mode: 'open' }) - // initialize dom tree and append to shadow root this._initialize() } private _initialize() { + // initialize shadow dom + const shadow = this.attachShadow({ mode: 'open' }) + if (style) { const styleElement = document.createElement('style') styleElement.textContent = style @@ -86,15 +101,222 @@ export default (options: ComponentOptions) => { const doc = parser.parseFromString(template, 'text/html') const mainContent = doc.body.firstElementChild + let rootElement + if (mainContent) { - this.shadowRoot?.appendChild(document.importNode(mainContent, true)) + rootElement = document.importNode(mainContent, true) + shadow.appendChild(rootElement) } else { const container = document.createElement('div') container.innerHTML = template - this.shadowRoot?.appendChild(container) + rootElement = container + shadow.appendChild(container) } - // TODO: generate a dom tracking machanism + this._processTemplateBindings(rootElement) + } + + private _triggerDomUpdates(keyPath: string) { + if (this._stateToElementsMap[keyPath]) { + const updateQueue = new Set() + + this._stateToElementsMap[keyPath].forEach(element => { + updateQueue.add(element) + }) + + this._scheduleUpdate(updateQueue) + } + + // Update text bindings that depend on this state + if (this._textBindings) { + this._textBindings.forEach(binding => { + if (binding.expr === keyPath || binding.expr.startsWith(keyPath + '.')) { + this._updateTextNode(binding.node, binding.expr, binding.originalContent) + } + }) + } + + // Update attribute bindings that depend on this state + if (this._attributeBindings) { + this._attributeBindings.forEach(binding => { + if (binding.expr === keyPath || binding.expr.startsWith(keyPath + '.')) { + const value = this._getNestedState(binding.expr) + if (value !== undefined) { + binding.element.setAttribute(binding.attrName, String(value)) + } + } + }) + } + } + + private _scheduleUpdate(elements: Set) { + requestAnimationFrame(() => { + elements.forEach(element => { + this._updateElement(element) + }) + }) + } + + private _updateElement(element: HTMLElement) { + const renderFunction = (element as any)._renderFunction + if (renderFunction) { + // Set rendering context + this._currentRenderingElement = element + + // Execute rendering + const result = renderFunction() + + // Update DOM + if (typeof result === 'string') { + element.innerHTML = result + } else if (result instanceof Node) { + element.innerHTML = '' + element.appendChild(result) + } + + // Clear rendering context + this._currentRenderingElement = null + } + } + + private _processTemplateBindings(element: Element) { + // Traverse all child nodes, including text nodes + const walker = document.createTreeWalker( + element, + NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, + null + ) + + // Store nodes and expressions that need to be updated + const textBindings: Array<{ node: Text, expr: string, originalContent: string }> = [] + + // Traverse the DOM tree + let currentNode: Node | null + while (currentNode = walker.nextNode()) { + // Handle text nodes + if (currentNode.nodeType === Node.TEXT_NODE) { + const textContent = currentNode.textContent || '' + const textNode = currentNode as Text + + // Check if it contains Handlebars expressions {{ xxx }} + if (textContent.includes('{{')) { + // Save the original content, including expressions + const originalContent = textContent + + // Record nodes and expressions that need to be updated + const matches = textContent.match(/\{\{\s*([^}]+)\s*\}\}/g) + if (matches) { + matches.forEach(match => { + // Extract the expression content, removing {{ }} and spaces + const expr = match.replace(/\{\{\s*|\s*\}\}/g, '').trim() + + // Store the node, expression, and original content for later updates + textBindings.push({ node: textNode, expr, originalContent }) + + // Set the initial value + this._updateTextNode(textNode, expr, originalContent) + + // Add dependency relationship for this state path + if (!this._stateToElementsMap[expr]) { + this._stateToElementsMap[expr] = new Set() + } + this._stateToElementsMap[expr].add(textNode as unknown as HTMLElement) + }) + } + } + } + + // Handle element nodes (can extend to handle attribute bindings, etc.) + else if (currentNode.nodeType === Node.ELEMENT_NODE) { + // Handle element attribute bindings, such as + const element = currentNode as Element + + // Traverse all attributes + Array.from(element.attributes).forEach(attr => { + const value = attr.value + if (value.includes('{{')) { + // Extract the expression + const matches = value.match(/\{\{\s*([^}]+)\s*\}\}/g) + if (matches) { + matches.forEach(match => { + const expr = match.replace(/\{\{\s*|\s*\}\}/g, '').trim() + + // For attribute bindings, we need a special update function + this._setupAttributeBinding(element, attr.name, expr, value) + + // Record dependency relationship + if (!this._stateToElementsMap[expr]) { + this._stateToElementsMap[expr] = new Set() + } + this._stateToElementsMap[expr].add(element as HTMLElement) + }) + } + } + }) + + // Can also handle event bindings and other features here + } + } + + // Save text binding relationships for updates + this._textBindings = textBindings + } + + // Update text node + private _updateTextNode(node: Text, expr: string, template: string) { + // Replace all expressions with the current state value + let newContent = template + + const replaceExpr = (match: string, expr: string) => { + // Get the value of the expression + const value = this._getNestedState(expr.trim()) + return value !== undefined ? String(value) : '' + } + + // Replace all {{ xxx }} expressions + newContent = newContent.replace(/\{\{\s*([^}]+)\s*\}\}/g, replaceExpr) + + // Update node content + node.textContent = newContent + } + + // Set up attribute binding + private _setupAttributeBinding(element: Element, attrName: string, expr: string, template: string) { + // Initialize attribute value + const value = this._getNestedState(expr) + + // Set the initial attribute + if (value !== undefined) { + element.setAttribute(attrName, String(value)) + } + + // Add update function to the map + if (!this._attributeBindings) { + this._attributeBindings = [] + } + + this._attributeBindings.push({ + element, + attrName, + expr, + template + }) + } + + // Get nested state value + private _getNestedState(path: string): any { + // Handle nested paths, such as "profile.name" + const parts = path.split('.') + let result = this._states + + for (const part of parts) { + if (result === undefined || result === null) { + return undefined + } + result = result[part] + } + + return result } connectedCallback() {