Enhance state management in CustomElement: add state listeners, improve DOM update logic, and implement text and attribute binding mechanisms
This commit is contained in:
parent
b9cb3f455c
commit
841f02bb38
236
src/main.ts
236
src/main.ts
|
@ -21,6 +21,11 @@ export default (options: ComponentOptions) => {
|
||||||
|
|
||||||
class CustomElementImpl extends HTMLElement {
|
class CustomElementImpl extends HTMLElement {
|
||||||
private _states: Record<string, any> = {}
|
private _states: Record<string, any> = {}
|
||||||
|
private _stateToElementsMap: Record<string, Set<HTMLElement>> = {}
|
||||||
|
private _currentRenderingElement: HTMLElement | null = null
|
||||||
|
private _statesListeners: Record<string, Function> = {}
|
||||||
|
private _textBindings: Array<{ node: Text, expr: string, originalContent: string }> = []
|
||||||
|
private _attributeBindings: Array<{ element: Element, attrName: string, expr: string, template: string }> = []
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
|
@ -41,7 +46,10 @@ export default (options: ComponentOptions) => {
|
||||||
currentTarget = currentTarget[key]
|
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
|
// trigger state update events
|
||||||
if (statesListeners && statesListeners[keyPath]) {
|
if (statesListeners && statesListeners[keyPath]) {
|
||||||
|
@ -51,6 +59,13 @@ export default (options: ComponentOptions) => {
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
get: (target: Record<string, any>, keyPath: string) => {
|
get: (target: Record<string, any>, 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('.')
|
const valueRoute = keyPath.split('.')
|
||||||
let currentTarget = target
|
let currentTarget = target
|
||||||
for (let i in valueRoute) {
|
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
|
// initialize dom tree and append to shadow root
|
||||||
this._initialize()
|
this._initialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
private _initialize() {
|
private _initialize() {
|
||||||
|
// initialize shadow dom
|
||||||
|
const shadow = this.attachShadow({ mode: 'open' })
|
||||||
|
|
||||||
if (style) {
|
if (style) {
|
||||||
const styleElement = document.createElement('style')
|
const styleElement = document.createElement('style')
|
||||||
styleElement.textContent = style
|
styleElement.textContent = style
|
||||||
|
@ -86,15 +101,222 @@ export default (options: ComponentOptions) => {
|
||||||
const doc = parser.parseFromString(template, 'text/html')
|
const doc = parser.parseFromString(template, 'text/html')
|
||||||
|
|
||||||
const mainContent = doc.body.firstElementChild
|
const mainContent = doc.body.firstElementChild
|
||||||
|
let rootElement
|
||||||
|
|
||||||
if (mainContent) {
|
if (mainContent) {
|
||||||
this.shadowRoot?.appendChild(document.importNode(mainContent, true))
|
rootElement = document.importNode(mainContent, true)
|
||||||
|
shadow.appendChild(rootElement)
|
||||||
} else {
|
} else {
|
||||||
const container = document.createElement('div')
|
const container = document.createElement('div')
|
||||||
container.innerHTML = template
|
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<HTMLElement>()
|
||||||
|
|
||||||
|
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<HTMLElement>) {
|
||||||
|
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 <img src="{{ imageUrl }}">
|
||||||
|
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() {
|
connectedCallback() {
|
||||||
|
|
Loading…
Reference in New Issue
Block a user