@ordojs/core
Version:
Core compiler and runtime for OrdoJS framework
734 lines (626 loc) • 21 kB
text/typescript
/**
* @fileoverview OrdoJS DOM Optimizer - Generates efficient DOM update code
*/
import {
DirectiveType,
ExpressionType,
type AttributeNode,
type ComponentAST,
type ComponentNode,
type ExpressionNode,
type HTMLElementNode,
type InterpolationNode,
type MarkupBlockNode,
type ReactiveVariableNode
} from '../types/index.js';
import {
type DependencyGraph
} from './dependency-analyzer.js';
/**
* DOM update operation
*/
export interface DOMUpdateOperation {
id: string;
type: DOMUpdateType;
selector: string;
property: string;
expression: string;
dependencies: string[];
batchGroup?: string;
}
/**
* Types of DOM update operations
*/
export enum DOMUpdateType {
TEXT_CONTENT = 'TEXT_CONTENT',
ATTRIBUTE = 'ATTRIBUTE',
PROPERTY = 'PROPERTY',
CLASS_TOGGLE = 'CLASS_TOGGLE',
STYLE = 'STYLE',
VISIBILITY = 'VISIBILITY',
LIST_UPDATE = 'LIST_UPDATE',
CONDITIONAL_RENDER = 'CONDITIONAL_RENDER'
}
/**
* Batched update group
*/
export interface UpdateBatch {
id: string;
operations: DOMUpdateOperation[];
dependencies: string[];
priority: number;
}
/**
* Two-way binding configuration
*/
export interface TwoWayBinding {
elementSelector: string;
variableName: string;
eventType: string;
propertyName: string;
transformFunction?: string;
}
/**
* DOM optimizer for efficient DOM manipulation code generation
*/
export class DOMOptimizer {
private componentId: string = '';
private updateOperations: Map<string, DOMUpdateOperation[]> = new Map();
private updateBatches: UpdateBatch[] = [];
private twoWayBindings: TwoWayBinding[] = [];
private elementCounter: number = 0;
/**
* Optimize DOM updates for a component
*/
optimize(ast: ComponentAST, dependencyGraph: DependencyGraph): {
updateCode: string;
setupCode: string;
cleanupCode: string;
} {
this.reset();
this.componentId = `ordojs_${ast.component.name}_${Date.now().toString(36)}`;
// Analyze the component for DOM update opportunities
this.analyzeComponent(ast.component, dependencyGraph);
// Generate batched update operations
this.generateUpdateBatches();
// Generate the optimized update code
const updateCode = this.generateUpdateCode();
const setupCode = this.generateSetupCode();
const cleanupCode = this.generateCleanupCode();
return {
updateCode,
setupCode,
cleanupCode
};
}
/**
* Generate selective DOM updates (only changed elements)
*/
generateSelectiveUpdates(
ast: ComponentAST,
dependencyGraph: DependencyGraph,
changedVariables: string[]
): string {
this.reset();
this.componentId = `ordojs_${ast.component.name}_${Date.now().toString(36)}`;
// Only analyze operations that depend on changed variables
this.analyzeComponent(ast.component, dependencyGraph, changedVariables);
this.generateUpdateBatches();
return this.generateUpdateCode();
}
/**
* Reset optimizer state
*/
private reset(): void {
this.componentId = '';
this.updateOperations.clear();
this.updateBatches = [];
this.twoWayBindings = [];
this.elementCounter = 0;
}
/**
* Analyze component for DOM update opportunities
*/
private analyzeComponent(
component: ComponentNode,
dependencyGraph: DependencyGraph,
changedVariables?: string[]
): void {
if (!component.markupBlock) return;
// Analyze markup block for update operations
this.analyzeMarkupBlock(component.markupBlock, dependencyGraph, changedVariables);
// Analyze reactive variables for two-way bindings
if (component.clientBlock) {
this.analyzeTwoWayBindings(component.markupBlock, component.clientBlock.reactiveVariables);
}
}
/**
* Analyze markup block for DOM updates
*/
private analyzeMarkupBlock(
markupBlock: MarkupBlockNode,
dependencyGraph: DependencyGraph,
changedVariables?: string[]
): void {
// Analyze interpolations
for (const interpolation of markupBlock.interpolations) {
this.analyzeInterpolation(interpolation, dependencyGraph, changedVariables);
}
// Analyze HTML elements
for (const element of markupBlock.elements) {
this.analyzeHTMLElement(element, dependencyGraph, changedVariables);
}
}
/**
* Analyze HTML element for DOM updates
*/
private analyzeHTMLElement(
element: HTMLElementNode,
dependencyGraph: DependencyGraph,
changedVariables?: string[],
elementPath: string = ''
): void {
const elementId = this.generateElementId();
const currentPath = elementPath ? `${elementPath} > ${element.tagName}` : element.tagName;
// Analyze attributes for reactive updates
for (const attr of element.attributes) {
this.analyzeAttribute(attr, elementId, dependencyGraph, changedVariables);
}
// Recursively analyze children
for (let i = 0; i < element.children.length; i++) {
const child = element.children[i];
if (child.type === 'HTMLElement') {
this.analyzeHTMLElement(
child as HTMLElementNode,
dependencyGraph,
changedVariables,
`${currentPath}:nth-child(${i + 1})`
);
} else if (child.type === 'Interpolation') {
this.analyzeInterpolation(
child as InterpolationNode,
dependencyGraph,
changedVariables,
`${currentPath}:nth-child(${i + 1})`
);
}
}
}
/**
* Analyze attribute for reactive updates
*/
private analyzeAttribute(
attr: AttributeNode,
elementId: string,
dependencyGraph: DependencyGraph,
changedVariables?: string[]
): void {
if (!attr.isDirective || typeof attr.value === 'string') {
return;
}
const expression = attr.value as ExpressionNode;
const dependencies = this.extractDependencies(expression, dependencyGraph);
// Filter by changed variables if specified
if (changedVariables && !dependencies.some(dep => changedVariables.includes(dep))) {
return;
}
const selector = `[data-ordojs-id="${elementId}"]`;
switch (attr.directiveType) {
case DirectiveType.BIND:
this.createTwoWayBinding(attr, elementId, dependencies);
break;
case DirectiveType.ON:
// Event handlers don't need DOM updates, just setup
break;
case DirectiveType.CLASS:
this.createClassUpdate(attr, selector, expression, dependencies);
break;
case DirectiveType.STYLE:
this.createStyleUpdate(attr, selector, expression, dependencies);
break;
default:
this.createAttributeUpdate(attr, selector, expression, dependencies);
break;
}
}
/**
* Analyze interpolation for reactive updates
*/
private analyzeInterpolation(
interpolation: InterpolationNode,
dependencyGraph: DependencyGraph,
changedVariables?: string[],
elementPath?: string
): void {
const dependencies = this.extractDependencies(interpolation.expression, dependencyGraph);
// Filter by changed variables if specified
if (changedVariables && !dependencies.some(dep => changedVariables.includes(dep))) {
return;
}
const elementId = this.generateElementId();
const selector = `[data-ordojs-interpolation="${elementId}"]`;
const operation: DOMUpdateOperation = {
id: this.generateOperationId(),
type: DOMUpdateType.TEXT_CONTENT,
selector,
property: 'textContent',
expression: this.generateExpressionCode(interpolation.expression),
dependencies
};
this.addUpdateOperation(dependencies[0] || 'default', operation);
}
/**
* Create two-way binding
*/
private createTwoWayBinding(
attr: AttributeNode,
elementId: string,
dependencies: string[]
): void {
const bindProperty = attr.name.substring(5); // Remove 'bind:' prefix
const selector = `[data-ordojs-id="${elementId}"]`;
// Determine event type based on property
let eventType = 'input';
let propertyName = 'value';
switch (bindProperty) {
case 'checked':
eventType = 'change';
propertyName = 'checked';
break;
case 'value':
eventType = 'input';
propertyName = 'value';
break;
default:
eventType = 'input';
propertyName = bindProperty;
}
const binding: TwoWayBinding = {
elementSelector: selector,
variableName: dependencies[0] || '',
eventType,
propertyName
};
this.twoWayBindings.push(binding);
// Also create a property update operation
const operation: DOMUpdateOperation = {
id: this.generateOperationId(),
type: DOMUpdateType.PROPERTY,
selector,
property: propertyName,
expression: `component.state.${binding.variableName}`,
dependencies
};
this.addUpdateOperation(binding.variableName, operation);
}
/**
* Create class update operation
*/
private createClassUpdate(
attr: AttributeNode,
selector: string,
expression: ExpressionNode,
dependencies: string[]
): void {
const className = attr.name.substring(6); // Remove 'class:' prefix
const operation: DOMUpdateOperation = {
id: this.generateOperationId(),
type: DOMUpdateType.CLASS_TOGGLE,
selector,
property: className,
expression: this.generateExpressionCode(expression),
dependencies
};
this.addUpdateOperation(dependencies[0] || 'default', operation);
}
/**
* Create style update operation
*/
private createStyleUpdate(
attr: AttributeNode,
selector: string,
expression: ExpressionNode,
dependencies: string[]
): void {
const styleProperty = attr.name.substring(6); // Remove 'style:' prefix
const operation: DOMUpdateOperation = {
id: this.generateOperationId(),
type: DOMUpdateType.STYLE,
selector,
property: styleProperty,
expression: this.generateExpressionCode(expression),
dependencies
};
this.addUpdateOperation(dependencies[0] || 'default', operation);
}
/**
* Create attribute update operation
*/
private createAttributeUpdate(
attr: AttributeNode,
selector: string,
expression: ExpressionNode,
dependencies: string[]
): void {
const operation: DOMUpdateOperation = {
id: this.generateOperationId(),
type: DOMUpdateType.ATTRIBUTE,
selector,
property: attr.name,
expression: this.generateExpressionCode(expression),
dependencies
};
this.addUpdateOperation(dependencies[0] || 'default', operation);
}
/**
* Analyze two-way bindings
*/
private analyzeTwoWayBindings(
markupBlock: MarkupBlockNode,
reactiveVariables: ReactiveVariableNode[]
): void {
// This is handled in analyzeAttribute method
// Additional analysis could be added here if needed
}
/**
* Extract dependencies from expression
*/
private extractDependencies(
expression: ExpressionNode,
dependencyGraph: DependencyGraph
): string[] {
const dependencies: string[] = [];
const extractFromExpr = (expr: ExpressionNode): void => {
switch (expr.expressionType) {
case ExpressionType.IDENTIFIER:
if (expr.identifier && dependencyGraph.nodes.has(expr.identifier)) {
dependencies.push(expr.identifier);
}
break;
case ExpressionType.BINARY:
case ExpressionType.ASSIGNMENT:
if (expr.left) extractFromExpr(expr.left);
if (expr.right) extractFromExpr(expr.right);
break;
case ExpressionType.UNARY:
if (expr.right) extractFromExpr(expr.right);
break;
case ExpressionType.CALL:
if (expr.callee) extractFromExpr(expr.callee);
if (expr.arguments) {
expr.arguments.forEach(arg => extractFromExpr(arg));
}
break;
case ExpressionType.MEMBER:
if (expr.object) extractFromExpr(expr.object);
// For member expressions, we don't extract dependencies from the property
// since it's usually a literal property name, but we still need to check
// if the property itself is an identifier that references a reactive variable
if (expr.property && expr.property.expressionType === ExpressionType.IDENTIFIER) {
if (expr.property.identifier && dependencyGraph.nodes.has(expr.property.identifier)) {
dependencies.push(expr.property.identifier);
}
}
break;
}
};
extractFromExpr(expression);
return [...new Set(dependencies)]; // Remove duplicates
}
/**
* Generate expression code
*/
private generateExpressionCode(expression: ExpressionNode): string {
switch (expression.expressionType) {
case ExpressionType.LITERAL:
return typeof expression.value === 'string'
? `"${expression.value}"`
: String(expression.value);
case ExpressionType.IDENTIFIER:
return `component.state.${expression.identifier}`;
case ExpressionType.BINARY:
if (expression.left && expression.right && expression.operator) {
const left = this.generateExpressionCode(expression.left);
const right = this.generateExpressionCode(expression.right);
return `(${left} ${expression.operator} ${right})`;
}
return '';
case ExpressionType.UNARY:
if (expression.right && expression.operator) {
const right = this.generateExpressionCode(expression.right);
return `(${expression.operator}${right})`;
}
return '';
case ExpressionType.CALL:
if (expression.callee && expression.arguments) {
const callee = this.generateExpressionCode(expression.callee);
const args = expression.arguments
.map(arg => this.generateExpressionCode(arg))
.join(', ');
return `${callee}(${args})`;
}
return '';
case ExpressionType.MEMBER:
if (expression.object && expression.property) {
const object = this.generateExpressionCode(expression.object);
// For member expressions, the property should be treated as a literal property name
const property = expression.property.expressionType === ExpressionType.IDENTIFIER
? expression.property.identifier
: this.generateExpressionCode(expression.property);
return `${object}.${property}`;
}
return '';
default:
return '';
}
}
/**
* Add update operation to the appropriate group
*/
private addUpdateOperation(variableName: string, operation: DOMUpdateOperation): void {
// Use the first dependency as the key, or 'default' if no dependencies
const key = operation.dependencies.length > 0 ? operation.dependencies[0] : 'default';
if (!this.updateOperations.has(key)) {
this.updateOperations.set(key, []);
}
this.updateOperations.get(key)!.push(operation);
}
/**
* Generate batched update operations
*/
private generateUpdateBatches(): void {
let batchId = 0;
for (const [variableName, operations] of this.updateOperations) {
// Create a single batch per variable to reduce the number of batches
const batch: UpdateBatch = {
id: `batch_${batchId++}`,
operations,
dependencies: [variableName],
priority: Math.min(...operations.map(op => this.getUpdatePriority(op.type)))
};
this.updateBatches.push(batch);
}
// Sort batches by priority
this.updateBatches.sort((a, b) => a.priority - b.priority);
}
/**
* Get update priority for operation type
*/
private getUpdatePriority(type: DOMUpdateType): number {
switch (type) {
case DOMUpdateType.VISIBILITY:
return 1;
case DOMUpdateType.CLASS_TOGGLE:
return 2;
case DOMUpdateType.STYLE:
return 3;
case DOMUpdateType.ATTRIBUTE:
return 4;
case DOMUpdateType.PROPERTY:
return 5;
case DOMUpdateType.TEXT_CONTENT:
return 6;
case DOMUpdateType.LIST_UPDATE:
return 7;
case DOMUpdateType.CONDITIONAL_RENDER:
return 8;
default:
return 9;
}
}
/**
* Generate optimized update code
*/
private generateUpdateCode(): string {
const lines: string[] = [];
lines.push('// Optimized DOM update functions');
lines.push('const updateFunctions = {');
for (const batch of this.updateBatches) {
lines.push(` ${batch.id}: function() {`);
lines.push(' // Batch DOM updates for better performance');
// Group operations by selector for efficiency
const operationsBySelector = new Map<string, DOMUpdateOperation[]>();
for (const operation of batch.operations) {
if (!operationsBySelector.has(operation.selector)) {
operationsBySelector.set(operation.selector, []);
}
operationsBySelector.get(operation.selector)!.push(operation);
}
// Generate update code for each selector
for (const [selector, operations] of operationsBySelector) {
lines.push(` const elements = document.querySelectorAll('${selector}');`);
lines.push(' elements.forEach(el => {');
for (const operation of operations) {
lines.push(` // ${operation.type}: ${operation.property}`);
switch (operation.type) {
case DOMUpdateType.TEXT_CONTENT:
lines.push(` el.textContent = ${operation.expression};`);
break;
case DOMUpdateType.ATTRIBUTE:
lines.push(` el.setAttribute('${operation.property}', ${operation.expression});`);
break;
case DOMUpdateType.PROPERTY:
lines.push(` el.${operation.property} = ${operation.expression};`);
break;
case DOMUpdateType.CLASS_TOGGLE:
lines.push(` el.classList.toggle('${operation.property}', !!(${operation.expression}));`);
break;
case DOMUpdateType.STYLE:
lines.push(` el.style.${operation.property} = ${operation.expression};`);
break;
}
}
lines.push(' });');
}
lines.push(' },');
}
lines.push('};');
lines.push('');
// Generate main update function
lines.push('function updateDOM(component, changedVariables = []) {');
lines.push(' // Execute update batches based on changed variables');
for (const batch of this.updateBatches) {
const condition = batch.dependencies
.map(dep => `changedVariables.includes('${dep}')`)
.join(' || ');
lines.push(` if (changedVariables.length === 0 || ${condition}) {`);
lines.push(` updateFunctions.${batch.id}();`);
lines.push(' }');
}
lines.push('}');
return lines.join('\n');
}
/**
* Generate setup code for two-way bindings
*/
private generateSetupCode(): string {
if (this.twoWayBindings.length === 0) {
return '';
}
const lines: string[] = [];
lines.push('// Setup two-way data bindings');
lines.push('function setupTwoWayBindings(component) {');
for (const binding of this.twoWayBindings) {
lines.push(` const ${binding.variableName}Elements = document.querySelectorAll('${binding.elementSelector}');`);
lines.push(` ${binding.variableName}Elements.forEach(el => {`);
lines.push(` el.addEventListener('${binding.eventType}', (event) => {`);
if (binding.transformFunction) {
lines.push(` const value = ${binding.transformFunction}(event.target.${binding.propertyName});`);
} else {
lines.push(` const value = event.target.${binding.propertyName};`);
}
lines.push(` component.state.${binding.variableName} = value;`);
lines.push(' });');
lines.push(' });');
}
lines.push('}');
return lines.join('\n');
}
/**
* Generate cleanup code
*/
private generateCleanupCode(): string {
if (this.twoWayBindings.length === 0) {
return '';
}
const lines: string[] = [];
lines.push('// Cleanup event listeners');
lines.push('function cleanupBindings() {');
lines.push(' // Event listeners will be automatically cleaned up when elements are removed');
lines.push(' // Additional cleanup logic can be added here if needed');
lines.push('}');
return lines.join('\n');
}
/**
* Generate unique element ID
*/
private generateElementId(): string {
return `${this.componentId}_el_${this.elementCounter++}`;
}
/**
* Generate unique operation ID
*/
private generateOperationId(): string {
return `op_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`;
}
}