Compare commits

..

3 Commits

5 changed files with 340 additions and 251 deletions

View File

@ -1,3 +1,5 @@
import utils from './utils/index'
interface ComponentOptions {
tag: string
template: string
@ -57,6 +59,11 @@ export default (options: ComponentOptions) => {
constructor() {
super()
// initialize dom tree and append to shadow root, as well as initialize state
this._initialize()
}
private _initState() {
// copy state from options
this._states = new Proxy(
{ ...(states || {}) },
@ -78,7 +85,14 @@ export default (options: ComponentOptions) => {
}
}
// trigger dom updates
this._triggerDomUpdates(keyPath)
utils.triggerDomUpdates(keyPath, {
stateToElementsMap: this._stateToElementsMap,
textBindings: this._textBindings,
attributeBindings: this._attributeBindings,
updateTextNode: this._updateTextNode.bind(this),
getNestedState: this._getNestedState.bind(this),
scheduleUpdate: this._scheduleUpdate.bind(this),
})
if (this._statesListeners[keyPath])
this._statesListeners[keyPath](value)
@ -118,12 +132,12 @@ export default (options: ComponentOptions) => {
},
},
)
// initialize dom tree and append to shadow root
this._initialize()
}
private _initialize() {
// initialize state
this._initState()
// initialize shadow dom
const shadow = this.attachShadow({ mode: 'open' })
@ -133,64 +147,26 @@ export default (options: ComponentOptions) => {
this.shadowRoot?.appendChild(styleElement)
}
const parser = new DOMParser()
const doc = parser.parseFromString(template, 'text/html')
const rootElement = utils.parseTemplate(template)
shadow.appendChild(rootElement)
const mainContent = doc.body.firstElementChild
let rootElement: Element
if (mainContent) {
rootElement = document.importNode(mainContent, true)
shadow.appendChild(rootElement)
} else {
const container = document.createElement('div')
container.innerHTML = template
rootElement = container
shadow.appendChild(container)
}
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<string, unknown>)[name] === 'function' && name !== 'constructor'
),
})
}
private _triggerDomUpdates(keyPath: string) {
if (this._stateToElementsMap[keyPath]) {
const updateQueue = new Set<HTMLElement>()
for (const element of this._stateToElementsMap[keyPath]) {
updateQueue.add(element)
}
this._scheduleUpdate(updateQueue)
}
// Update text bindings that depend on this state
if (this._textBindings) {
// this._textBindings.forEach((binding) => {
for (const binding of this._textBindings)
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) {
for (const binding of this._attributeBindings)
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<HTMLElement>) {
requestAnimationFrame(() => {
@ -221,190 +197,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<string, unknown>)[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
@ -533,12 +325,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
@ -623,7 +415,7 @@ export default (options: ComponentOptions) => {
itemContext: Record<string, unknown>,
) {
// 1. Store the item context of the element so that subsequent updates can find it
;(element as { _itemContext?: Record<string, unknown> })._itemContext =
; (element as { _itemContext?: Record<string, unknown> })._itemContext =
itemContext
// 2. Process bindings in text nodes
@ -1201,4 +993,4 @@ export default (options: ComponentOptions) => {
}
customElements.define(tag, CustomElementImpl)
}
}

9
src/utils/index.ts Normal file
View File

@ -0,0 +1,9 @@
import parseTemplate from './parseTemplate'
import processTemplateMacros from './processTemplateMarco'
import triggerDomUpdates from './triggerDomUpdates'
export default {
parseTemplate,
processTemplateMacros,
triggerDomUpdates,
}

View File

@ -0,0 +1,16 @@
export default function parseTemplate(template: string): Element {
const parser = new DOMParser()
const doc = parser.parseFromString(template, 'text/html')
const mainContent = doc.body.firstElementChild
let rootElement: Element
if (mainContent) rootElement = document.importNode(mainContent, true)
else {
const container = document.createElement('div')
container.innerHTML = template
rootElement = container
}
return rootElement
}

View File

@ -0,0 +1,218 @@
export default 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<string, Set<HTMLElement>>
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<string, unknown>)[
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)
}
}

View File

@ -0,0 +1,54 @@
export default function triggerDomUpdates(keyPath: string, ops: {
stateToElementsMap: Record<string, Set<HTMLElement>>,
scheduleUpdate: (elements: Set<HTMLElement>) => void,
textBindings: Array<{
node: Text
expr: string
originalContent: string
}> | undefined,
attributeBindings: Array<{
element: Element
attrName: string
expr: string
template: string
}> | undefined,
updateTextNode: (node: Text, expr: string, template: string) => void,
getNestedState: (path: string) => unknown,
}) {
if (ops.stateToElementsMap[keyPath]) {
const updateQueue = new Set<HTMLElement>()
for (const element of ops.stateToElementsMap[keyPath])
updateQueue.add(element)
ops.scheduleUpdate(updateQueue)
}
// Update text bindings that depend on this state
if (ops.textBindings) {
// this._textBindings.forEach((binding) => {
for (const binding of ops.textBindings)
if (
binding.expr === keyPath ||
binding.expr.startsWith(`${keyPath}.`)
)
ops.updateTextNode(
binding.node,
binding.expr,
binding.originalContent,
)
}
// Update attribute bindings that depend on this state
if (ops.attributeBindings) {
for (const binding of ops.attributeBindings)
if (
binding.expr === keyPath ||
binding.expr.startsWith(`${keyPath}.`)
) {
const value = ops.getNestedState(binding.expr)
if (value !== undefined)
binding.element.setAttribute(binding.attrName, String(value))
}
}
}