From 8f6b4a6cd9c31234fd4f644bbaadd2a42c0a92de Mon Sep 17 00:00:00 2001 From: Astrian Zheng Date: Wed, 21 May 2025 16:00:23 +1000 Subject: [PATCH] refactor: remove list rendering setup from component options and related utility --- src/main.ts | 428 ------------------------------ src/utils/processTemplateMarco.ts | 1 - 2 files changed, 429 deletions(-) diff --git a/src/main.ts b/src/main.ts index 3ee8c33..86115ba 100644 --- a/src/main.ts +++ b/src/main.ts @@ -140,7 +140,6 @@ export default (options: ComponentOptions) => { setupArrowFunctionHandler: this._setupArrowFunctionHandler.bind(this), setupExpressionHandler: this._setupExpressionHandler.bind(this), setupFunctionCallHandler: this._setupFunctionCallHandler.bind(this), - setupListRendering: this._setupListRendering.bind(this), stateToElementsMap: this._stateToElementsMap, textBindings: this._textBindings, availableFuncs: Object.getOwnPropertyNames( @@ -189,433 +188,6 @@ export default (options: ComponentOptions) => { } } - // Handle list rendering (%for macro) - private _setupListRendering(element: Element, expr: string) { - // Parse the expression (e.g., "item in items" or "(item, index) in items") - const match = expr.match( - /(?:\(([^,]+),\s*([^)]+)\)|([^,\s]+))\s+in\s+(.+)/, - ) - if (!match) { - console.error(`Invalid %for expression: ${expr}`) - return - } - - // Extract the item variable name, index variable name (optional), and collection expression - const itemVar = match[3] || match[1] - const indexVar = match[2] || null - const collectionExpr = match[4].trim() - - // Create a placeholder comment to mark where the list should be rendered - const placeholder = document.createComment(` %for: ${expr} `) - element.parentNode?.insertBefore(placeholder, element) - - // Remove the original template element from the DOM - const template = element.cloneNode(true) as Element - element.parentNode?.removeChild(element) - - // Store current rendered items - const renderedItems: Array<{ - element: Element - key: unknown - data: unknown - index: number - }> = [] - - // Create a function to update the list when the collection changes - const updateList = () => { - const collection = this._evaluateExpression(collectionExpr) - if (!collection || !Array.isArray(collection)) { - console.warn( - `Collection "${collectionExpr}" is not an array or does not exist`, - ) - return - } - - const parentNode = placeholder.parentNode - if (!parentNode) { - console.error("Placeholder's parentNode is null. Cannot update list.") - return - } - - // Detach all currently rendered DOM items managed by this instance. - for (const item of renderedItems) - if (item.element.parentNode === parentNode) - parentNode.removeChild(item.element) - - // Get key attribute if available - const keyAttr = template.getAttribute('%key') - if (!keyAttr) - console.warn( - '%key attribute not found in the template, which is not a recommended practice.', - ) - - // Store a map of existing items by key for reuse - const existingElementsByKey = new Map() - // renderedItems.forEach((item) => { - for (const item of renderedItems) - if (item.key !== undefined) existingElementsByKey.set(item.key, item) - - // Clear rendered items - renderedItems.length = 0 - - // document fragment - const fragment = document.createDocumentFragment() - - // Create or update items in the list - collection.forEach((item, index) => { - // Determine the key for this item - const key = keyAttr - ? this._evaluateExpressionWithItemContext( - keyAttr ?? '', - item, - index, - itemVar, - indexVar ? indexVar : undefined, - ) - : index - - // Check if we can reuse an existing element - const existingItem = existingElementsByKey.get(key) - let itemElement: Element - - if (existingItem) { - // Reuse existing element - itemElement = existingItem.element - existingElementsByKey.delete(key) // Remove from map so we know it's been used - } else { - // Create a new element - itemElement = template.cloneNode(true) as Element - } - - // Update item data - renderedItems.push({ - element: itemElement, - key, - data: item, - index, - }) - - // Create item context for this item - const itemContext = { - [itemVar]: item, - } - if (indexVar) itemContext[indexVar] = index - - // insert %key attribute, which dynamically bind the key - if (keyAttr) { - const keyValue = this._evaluateExpressionWithItemContext( - keyAttr, - itemContext, - ) - itemElement.setAttribute('data-laterano-key', String(keyValue)) - } - - // remove original %key attribute - itemElement.removeAttribute('%key') - - // Apply the item context to the element - // We will use recursive processing here! - this._processElementWithItemContext(itemElement, itemContext) - - // Insert the element to the document fragment - fragment.appendChild(itemElement) - }) - - // Insert the document fragment into the DOM - placeholder.parentNode?.insertBefore(fragment, placeholder.nextSibling) - - // Remove any remaining unused items - // existingElementsByKey.forEach((item) => { - for (const item of existingElementsByKey.values()) - if (item.element.parentNode) - item.element.parentNode.removeChild(item.element) - } - - // Initial render - updateList() - - // Set up state dependency for collection changes - if (!this._stateToElementsMap[collectionExpr]) - this._stateToElementsMap[collectionExpr] = new Set() - - // Using a unique identifier for this list rendering instance - const listVirtualElement = document.createElement('div') - this._stateToElementsMap[collectionExpr].add( - listVirtualElement as HTMLElement, - ) - - // Add listener for state changes - this._statesListeners[collectionExpr] = () => { - updateList() - } - } - - // Recursively process the element and its children, applying the item context - private _processElementWithItemContext( - element: Element, - itemContext: Record, - ) { - // 1. Store the item context of the element so that subsequent updates can find it - ;(element as { _itemContext?: Record })._itemContext = - itemContext - - // 2. Process bindings in text nodes - const processTextNodes = (node: Node) => { - if (node.nodeType === Node.TEXT_NODE) { - const textContent = node.textContent || '' - if (textContent.includes('{{')) { - const textNode = node as Text - const updatedContent = textContent.replace( - /\{\{\s*([^}]+)\s*\}\}/g, - (match, expr) => { - const value = this._evaluateExpressionWithItemContext( - expr.trim(), - itemContext, - ) - return value !== undefined ? String(value) : '' - }, - ) - textNode.textContent = updatedContent - } - } - } - - // Process the text nodes of the element itself - // Array.from(element.childNodes).forEach((node) => { - for (const node of Array.from(element.childNodes)) - if (node.nodeType === Node.TEXT_NODE) processTextNodes(node) - - // 3. Process attribute bindings (:attr) - // Array.from(element.attributes).forEach((attr) => { - for (const attr of Array.from(element.attributes)) { - if (attr.name.startsWith(':')) { - const attrName = attr.name.substring(1) - const expr = attr.value.trim() - const value = this._evaluateExpressionWithItemContext( - expr, - itemContext, - ) - - if (value !== undefined) element.setAttribute(attrName, String(value)) - - // Remove the original binding attribute (execute only for cloned templates once) - element.removeAttribute(attr.name) - } - } - - // 4. Process event bindings (@event) - // Array.from(element.attributes).forEach((attr) => { - for (const attr of Array.from(element.attributes)) { - if (attr.name.startsWith('@')) { - const eventName = attr.name.substring(1) - const handlerValue = attr.value.trim() - - // Remove the original binding attribute - element.removeAttribute(attr.name) - - // Add event listener - element.addEventListener(eventName, (event: Event) => { - try { - // Create a merged context - const mergedContext = { - ...this._createHandlerContext(event, element), - ...itemContext, - $event: event, - $el: element, - } - - // Execute the expression - const fnStr = `with(this) { ${handlerValue} }` - new Function(fnStr).call(mergedContext) - } catch (err) { - console.error( - `Error executing event handler with item context: ${handlerValue}`, - err, - ) - } - }) - } - } - - // 5. Process conditional rendering (%if) - let isConditional = false - let shouldDisplay = true - - // Array.from(element.attributes).forEach((attr) => { - for (const attr of Array.from(element.attributes)) { - if (attr.name === '%if') { - isConditional = true - const expr = attr.value.trim() - - // Remove the original binding attribute - element.removeAttribute(attr.name) - - // Calculate the condition - const result = this._evaluateExpressionWithItemContext( - expr, - itemContext, - ) - shouldDisplay = Boolean(result) - - // Apply the condition (in the list item context, we use display style to simplify) - if (!shouldDisplay) (element as HTMLElement).style.display = 'none' - } - } - - // If the condition evaluates to false, skip further processing of this element - if (isConditional && !shouldDisplay) { - return - } - - // 6. Process nested list rendering (%for) - let hasForDirective = false - - // Array.from(element.attributes).forEach((attr) => { - for (const attr of Array.from(element.attributes)) { - if (attr.name === '%for') { - hasForDirective = true - const forExpr = attr.value.trim() - - // Remove the original binding attribute - element.removeAttribute(attr.name) - - // Here we will create a new nested list - // Note: We need to evaluate the collection expression through the current item context here - this._setupNestedListRendering(element, forExpr, itemContext) - } - } - - // If this element is a list element, skip child element processing (they will be processed by the list processor) - if (hasForDirective) return - - // 7. Recursively process all child elements - // Array.from(element.children).forEach((child) => { - for (const child of Array.from(element.children)) - this._processElementWithItemContext(child, itemContext) - } - - // Set up nested list rendering - private _setupNestedListRendering( - element: Element, - expr: string, - parentItemContext: Record, - ) { - // Similar to _setupListRendering, but applies to nested situations - // Parse the expression (e.g., "subItem in item.subItems") - const match = expr.match( - /(?:\(([^,]+),\s*([^)]+)\)|([^,\s]+))\s+in\s+(.+)/, - ) - if (!match) { - console.error(`Invalid nested %for expression: ${expr}`) - return - } - - // Extract the item variable name, index variable name (optional), and collection expression - const itemVar = match[3] || match[1] - const indexVar = match[2] || null - const collectionExpr = match[4].trim() - - // Evaluate the collection expression, using the parent item context - const collection = this._evaluateExpressionWithItemContext( - collectionExpr, - parentItemContext, - ) - - if (!collection || !Array.isArray(collection)) { - console.warn( - `Nested collection "${collectionExpr}" is not an array or does not exist`, - ) - return - } - - // Create a placeholder comment - const placeholder = document.createComment(` %for: ${expr} `) - element.parentNode?.insertBefore(placeholder, element) - - // Remove the original template element from the DOM - const template = element.cloneNode(true) as Element - element.parentNode?.removeChild(element) - - // Create an element for each item - collection.forEach((item, index) => { - const itemElement = template.cloneNode(true) as Element - - // Create a nested item context, merging the parent context - const nestedItemContext = { - ...parentItemContext, - [itemVar]: item, - } - - if (indexVar) { - nestedItemContext[indexVar] = index - } - - // Recursively process this item and its children - this._processElementWithItemContext(itemElement, nestedItemContext) - - // TODO: detect list items existed inside the view, use replace instead of remove and re-add, - // to improve performance - - // Insert the item element into the DOM - placeholder.parentNode?.insertBefore( - itemElement, - placeholder.nextSibling, - ) - }) - } - - // Evaluate expressions using the item context - private _evaluateExpressionWithItemContext( - expression: string, - itemContext: Record, - index?: number, - itemVar?: string, - indexVar?: string, - ): unknown { - try { - // Check if the expression directly references the item variable - if (itemVar && expression === itemVar) { - return itemContext[itemVar] - } - - // Check if the expression is an item property path - if (itemVar && expression.startsWith(`${itemVar}.`)) { - const propertyPath = expression.substring(itemVar.length + 1) - const parts = propertyPath.split('.') - let value = itemContext[itemVar] - - for (const part of parts) { - if (value === undefined || value === null) return undefined - value = (value as { [key: string]: unknown })[part] - } - - return value - } - - // Check if the expression directly references the index variable - if (indexVar && expression === indexVar) { - return index - } - - // Create a merged context (component state + item context) - const mergedContext = { ...this._states, ...itemContext } - - // Create a function to evaluate the expression - const contextKeys = Object.keys(mergedContext) - const contextValues = Object.values(mergedContext) - - // Use the with statement to allow the expression to access all properties in the context - const func = new Function(...contextKeys, `return ${expression}`) - return func(...contextValues) - } catch (error) { - console.error( - `Error evaluating expression with item context: ${expression}`, - error, - ) - return undefined - } - } - private _evaluateIfCondition(element: Element, condition: string) { const info = this._conditionalElements.get(element) if (!info) return diff --git a/src/utils/processTemplateMarco.ts b/src/utils/processTemplateMarco.ts index 14c4df5..b7f8e3e 100644 --- a/src/utils/processTemplateMarco.ts +++ b/src/utils/processTemplateMarco.ts @@ -24,7 +24,6 @@ export default function processTemplateMacros( eventName: string, handlerValue: string, ) => void - setupListRendering: (element: Element, expr: string) => void stateToElementsMap: Record> textBindings: { node: Text