diff --git a/src/main.ts b/src/main.ts index 35b7ada..808ef7b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { parseTemplate } from './utils/parseTemplate' +import utils from './utils/index' interface ComponentOptions { tag: string @@ -140,10 +140,24 @@ export default (options: ComponentOptions) => { this.shadowRoot?.appendChild(styleElement) } - const rootElement = parseTemplate(template) + const rootElement = utils.parseTemplate(template) shadow.appendChild(rootElement) - this._processTemplateMacros(rootElement) + utils.processTemplateMacros(rootElement, this, { + updateTextNode: this._updateTextNode.bind(this), + setupAttributeBinding: this._setupAttributeBinding.bind(this), + setupArrowFunctionHandler: this._setupArrowFunctionHandler.bind(this), + setupFunctionCallHandler: this._setupFunctionCallHandler.bind(this), + setupExpressionHandler: this._setupExpressionHandler.bind(this), + setupTwoWayBinding: this._setupTwoWayBinding.bind(this), + setupConditionRendering: this._setupConditionRendering.bind(this), + setupListRendering: this._setupListRendering.bind(this), + stateToElementsMap: this._stateToElementsMap, + textBindings: this._textBindings, + availableFuncs: Object.getOwnPropertyNames(Object.getPrototypeOf(this)).filter( + name => typeof (this as Record)[name] === 'function' && name !== 'constructor' + ), + }) } private _triggerDomUpdates(keyPath: string) { @@ -215,190 +229,6 @@ export default (options: ComponentOptions) => { } } - private _processTemplateMacros(element: Element) { - /* - * We define that those prefix are available as macros: - * - @ means event binding macro, such as @click="handleClick" - * - : means dynamic attribute macro, such as :src="imageUrl" - * - % means component controlling macro, such as %if="condition", %for="item in items" and %connect="stateName" - */ - - // 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 - }> = [] - const ifDirectivesToProcess: Array<{ element: Element; expr: string }> = - [] - - // Traverse the DOM tree - let currentNode: Node | null - let flag = true - while (flag) { - currentNode = walker.nextNode() - if (!currentNode) { - flag = false - break - } - - // 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) { - for (const match of matches) { - // 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) { - const currentElementNode = currentNode as Element // Renamed to avoid conflict with outer 'element' - - // Traverse all macro attributes - - // Detect :attr="" bindings, such as :src="imageUrl" - for (const attr of Array.from(currentElementNode.attributes)) { - if (attr.name.startsWith(':')) { - const attrName = attr.name.substring(1) // Remove ':' - const expr = attr.value.trim() - - // Remove the attribute, as it is not a standard HTML attribute - currentElementNode.removeAttribute(attr.name) - - // Set up attribute binding - this._setupAttributeBinding( - currentElementNode, - attrName, - expr, - attr.value, - ) - } - } - - // Process @event bindings, such as @click="handleClick" - const eventBindings = Array.from( - currentElementNode.attributes, - ).filter((attr) => attr.name.startsWith('@')) - // eventBindings.forEach((attr) => { - for (const attr of eventBindings) { - const eventName = attr.name.substring(1) // Remove '@' - const handlerValue = attr.value.trim() - - // Remove the attribute, as it is not a standard HTML attribute - currentElementNode.removeAttribute(attr.name) - - // Handle different types of event handlers - if (handlerValue.includes('=>')) { - // Handle arrow function: @click="e => setState('count', count + 1)" - this._setupArrowFunctionHandler( - currentElementNode, - eventName, - handlerValue, - ) - } else if ( - handlerValue.includes('(') && - handlerValue.includes(')') - ) { - // Handle function call: @click="increment(5)" - this._setupFunctionCallHandler( - currentElementNode, - eventName, - handlerValue, - ) - } else if ( - typeof (this as Record)[handlerValue] === - 'function' - ) { - // Handle method reference: @click="handleClick" - currentElementNode.addEventListener( - eventName, - ( - this as unknown as Record< - string, - (...args: unknown[]) => void - > - )[handlerValue].bind(this), - ) - } else { - // Handle simple expression: @click="count++" or @input="name = $event.target.value" - this._setupExpressionHandler( - currentElementNode, - eventName, - handlerValue, - ) - } - } - - // Process %-started macros, such as %connect="stateName", %if="condition", %for="item in items" - const macroBindings = Array.from( - currentElementNode.attributes, - ).filter((attr) => attr.name.startsWith('%')) - - // macroBindings.forEach((attr) => { - for (const attr of macroBindings) { - const macroName = attr.name.substring(1) // Remove '%' - const expr = attr.value.trim() - - // Remove the attribute, as it is not a standard HTML attribute - currentElementNode.removeAttribute(attr.name) - - // Handle different types of macros - if (macroName === 'connect') - // Handle state connection: %connect="stateName" - this._setupTwoWayBinding(currentElementNode, expr) - else if (macroName === 'if') { - ifDirectivesToProcess.push({ element: currentElementNode, expr }) - } else if (macroName === 'for') - this._setupListRendering(currentElementNode, expr) - else if (macroName === 'key') continue - else console.warn(`Unknown macro: %${macroName}`) - } - } - } - - // Save text binding relationships for updates - this._textBindings = textBindings - - // Process all collected %if directives after the main traversal - for (const { element: ifElement, expr } of ifDirectivesToProcess) { - this._setupConditionRendering(ifElement, expr) - } - } - // Handle two-way data binding (%connect macro) private _setupTwoWayBinding(element: Element, expr: string) { // Get the initial value @@ -527,12 +357,12 @@ export default (options: ComponentOptions) => { // Determine the key for this item const key = keyAttr ? this._evaluateExpressionWithItemContext( - keyAttr ?? '', - item, - index, - itemVar, - indexVar ? indexVar : undefined, - ) + keyAttr ?? '', + item, + index, + itemVar, + indexVar ? indexVar : undefined, + ) : index // Check if we can reuse an existing element @@ -617,7 +447,7 @@ export default (options: ComponentOptions) => { itemContext: Record, ) { // 1. Store the item context of the element so that subsequent updates can find it - ;(element as { _itemContext?: Record })._itemContext = + ; (element as { _itemContext?: Record })._itemContext = itemContext // 2. Process bindings in text nodes diff --git a/src/utils/index.ts b/src/utils/index.ts index a6b382d..bc56952 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,7 @@ import { parseTemplate } from './parseTemplate' +import { processTemplateMacros } from './processTemplateMarco' export default { parseTemplate, + processTemplateMacros, } diff --git a/src/utils/processTemplateMarco.ts b/src/utils/processTemplateMarco.ts new file mode 100644 index 0000000..ca9f1b2 --- /dev/null +++ b/src/utils/processTemplateMarco.ts @@ -0,0 +1,218 @@ +export function processTemplateMacros( + element: Element, + context: CustomElement, + options: { + updateTextNode: (node: Text, expr: string, originalContent: string) => void + setupAttributeBinding: ( + element: Element, + attrName: string, + expr: string, + attrValue: string, + ) => void + setupArrowFunctionHandler: ( + element: Element, + eventName: string, + handlerValue: string, + ) => void + setupFunctionCallHandler: ( + element: Element, + eventName: string, + handlerValue: string, + ) => void + setupExpressionHandler: ( + element: Element, + eventName: string, + handlerValue: string, + ) => void + setupTwoWayBinding: (element: Element, expr: string) => void + setupConditionRendering: (element: Element, expr: string) => void + setupListRendering: (element: Element, expr: string) => void + stateToElementsMap: Record> + textBindings: { + node: Text + expr: string + originalContent: string + }[], + availableFuncs: string[] + }, +) { + /* + * We define that those prefix are available as macros: + * - @ means event binding macro, such as @click="handleClick" + * - : means dynamic attribute macro, such as :src="imageUrl" + * - % means component controlling macro, such as %if="condition", %for="item in items" and %connect="stateName" + */ + + // 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 + }> = [] + const ifDirectivesToProcess: Array<{ element: Element; expr: string }> = [] + + // Traverse the DOM tree + let currentNode: Node | null + let flag = true + while (flag) { + currentNode = walker.nextNode() + if (!currentNode) { + flag = false + break + } + + // 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) { + for (const match of matches) { + // 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 + options.updateTextNode(textNode, expr, originalContent) + + // Add dependency relationship for this state path + if (!options.stateToElementsMap[expr]) + options.stateToElementsMap[expr] = new Set() + + options.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) { + const currentElementNode = currentNode as Element // Renamed to avoid conflict with outer 'element' + + // Traverse all macro attributes + + // Detect :attr="" bindings, such as :src="imageUrl" + for (const attr of Array.from(currentElementNode.attributes)) { + if (attr.name.startsWith(':')) { + const attrName = attr.name.substring(1) // Remove ':' + const expr = attr.value.trim() + + // Remove the attribute, as it is not a standard HTML attribute + currentElementNode.removeAttribute(attr.name) + + // Set up attribute binding + options.setupAttributeBinding( + currentElementNode, + attrName, + expr, + attr.value, + ) + } + } + + // Process @event bindings, such as @click="handleClick" + const eventBindings = Array.from( + currentElementNode.attributes, + ).filter((attr) => attr.name.startsWith('@')) + // eventBindings.forEach((attr) => { + for (const attr of eventBindings) { + const eventName = attr.name.substring(1) // Remove '@' + const handlerValue = attr.value.trim() + + // Remove the attribute, as it is not a standard HTML attribute + currentElementNode.removeAttribute(attr.name) + + // Handle different types of event handlers + if (handlerValue.includes('=>')) { // Handle arrow function: @click="e => setState('count', count + 1)" + options.setupArrowFunctionHandler( + currentElementNode, + eventName, + handlerValue, + ) + } else if ( + handlerValue.includes('(') && + handlerValue.includes(')') + ) { // Handle function call: @click="increment(5)" + options.setupFunctionCallHandler( + currentElementNode, + eventName, + handlerValue, + ) + } else if ( + options.availableFuncs.includes(handlerValue) && + typeof (context as unknown as Record)[ + handlerValue + ] === 'function' + ) { // Handle method reference: @click="handleClick" + currentElementNode.addEventListener( + eventName, + ( + context as unknown as Record< + string, + (...args: unknown[]) => void + > + )[handlerValue].bind(context), + ) + } else { + // Handle simple expression: @click="count++" or @input="name = $event.target.value" + options.setupExpressionHandler( + currentElementNode, + eventName, + handlerValue, + ) + } + } + + // Process %-started macros, such as %connect="stateName", %if="condition", %for="item in items" + const macroBindings = Array.from( + currentElementNode.attributes, + ).filter((attr) => attr.name.startsWith('%')) + + // macroBindings.forEach((attr) => { + for (const attr of macroBindings) { + const macroName = attr.name.substring(1) // Remove '%' + const expr = attr.value.trim() + + // Remove the attribute, as it is not a standard HTML attribute + currentElementNode.removeAttribute(attr.name) + + // Handle different types of macros + if (macroName === 'connect') + // Handle state connection: %connect="stateName" + options.setupTwoWayBinding(currentElementNode, expr) + else if (macroName === 'if') { + ifDirectivesToProcess.push({ element: currentElementNode, expr }) + } else if (macroName === 'for') + options.setupListRendering(currentElementNode, expr) + else if (macroName === 'key') continue + else console.warn(`Unknown macro: %${macroName}`) + } + } + } + + // Save text binding relationships for updates + options.textBindings = textBindings + + // Process all collected %if directives after the main traversal + for (const { element: ifElement, expr } of ifDirectivesToProcess) { + options.setupConditionRendering(ifElement, expr) + } +} \ No newline at end of file