표현식 해석 로직이 엔진 전역에 복제되면서 복사본마다 기능 집합과 판정 기준이 갈라졌고, 같은 작성이 어느 렌더 경로를 타느냐에 따라 다른 결과를 냈다. 증상 대부분이 예외가 아니라 조용한 오답 또는 값 소실이라 재현 위치를 특정하기 어려웠다. 판정만 BindingShape 단일 출처로 모으고 실행은 지점별로 유지했다. 전면 통합은 회귀 위험이 크고, 갈라진 것은 실행이 아니라 판정이었기 때문이다. - 파이프 평가를 문자열 보간에 위임하면서 생긴 비대칭 3건 해소 (중괄호 노출 / _computed stale 전파 / 원본 타입 소실) - 파이프 분기 누락 8지점 보강 — 평가 지점 모집단을 스캔해 가드를 강제하는 회귀선 신설 - 단일 바인딩·복잡식 판정 14곳을 정본으로 치환, 빈 바인딩 봉인 - BINDING_PATTERN 정규식을 수동 스캐너로 교체, resolveObject 를 key 단위 예외 격리로 전환 - iteration + 항목 참조 if 가 목록 전체를 지우던 결함 해소, 반복 경로 기능 격차 정렬 - 템플릿 컴포넌트의 new Function 자체 평가기 제거 → G7Core.evaluateCondition 위임 - 컨텍스트에 식별자가 아닌 키가 하나라도 있으면 그 컨텍스트의 모든 식이 평가되지 못하던 결함 수정 - SEO 봇 렌더러도 같은 규칙으로 정렬 — React 만 고치면 봇 화면에서만 목록이 빈다 - audit walker 가 레이아웃의 74%(partial 루트 444/598)로 내려가지 못하던 사각 해소, 신규 룰 5종 등재
1728 lines
60 KiB
TypeScript
1728 lines
60 KiB
TypeScript
/**
|
|
* 그누보드7 템플릿 엔진 - 데이터 바인딩 엔진
|
|
*
|
|
* {{variable}} 문법을 파싱하고 실제 데이터로 치환하는 엔진
|
|
*
|
|
* @package G7
|
|
* @subpackage Template Engine
|
|
* @since 1.0.0
|
|
*/
|
|
|
|
import { createLogger } from '../utils/Logger';
|
|
import { TranslationEngine } from './TranslationEngine';
|
|
import { hasPipes, splitPipes, executePipeChain } from './PipeRegistry';
|
|
import { classifyExpression, extractSingleBinding, isComplexExpression, LITERALS, scanBindings } from './BindingShape';
|
|
import { RAW_PREFIX, wrapRaw, wrapRawDeep } from './rawMarkers';
|
|
import type { G7DevToolsInterface } from './G7CoreGlobals';
|
|
|
|
const logger = createLogger('DataBindingEngine');
|
|
|
|
/**
|
|
* 함수 파라미터 이름으로 쓸 수 있는 식별자 패턴.
|
|
*
|
|
* 표현식 평가는 컨텍스트 키를 `new Function` 의 파라미터로 넘긴다. 파라미터가 될 수 없는
|
|
* 이름이 하나라도 섞이면 함수 생성이 통째로 실패하므로 미리 걸러낸다.
|
|
*/
|
|
const VALID_IDENTIFIER_PATTERN = /^[$A-Za-z_][$A-Za-z0-9_]*$/;
|
|
|
|
/** 파라미터 이름으로 쓸 수 없는 예약어 (엄격 모드 예약어 포함) */
|
|
const RESERVED_WORDS = new Set([
|
|
'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default',
|
|
'delete', 'do', 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for',
|
|
'function', 'if', 'implements', 'import', 'in', 'instanceof', 'interface', 'let',
|
|
'new', 'null', 'package', 'private', 'protected', 'public', 'return', 'static',
|
|
'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void',
|
|
'while', 'with', 'yield', 'arguments', 'eval',
|
|
]);
|
|
|
|
/**
|
|
* G7Core.devTools 인터페이스 가져오기
|
|
*
|
|
* G7DevToolsCore.getInstance() 직접 호출 대신 G7Core.devTools를 사용합니다.
|
|
* DevTools가 비활성화되거나 초기화되지 않은 경우 안전하게 undefined 반환
|
|
*/
|
|
function getDevTools(): G7DevToolsInterface | undefined {
|
|
try {
|
|
const G7Core = (window as any).G7Core;
|
|
return G7Core?.devTools;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 바인딩 컨텍스트 인터페이스
|
|
*
|
|
* 데이터 바인딩 시 사용되는 컨텍스트 객체
|
|
*/
|
|
export interface BindingContext {
|
|
[key: string]: any;
|
|
}
|
|
|
|
/**
|
|
* 해석된 값 타입
|
|
*
|
|
* 바인딩 결과로 반환되는 값
|
|
*/
|
|
export type ResolvedValue = string | number | boolean | null | undefined | object | Array<any>;
|
|
|
|
/**
|
|
* 바인딩 옵션 인터페이스
|
|
*/
|
|
export interface BindingOptions {
|
|
/**
|
|
* 기본값 (변수를 찾을 수 없을 때 반환)
|
|
*/
|
|
defaultValue?: any;
|
|
|
|
/**
|
|
* null-safe 모드 활성화
|
|
*/
|
|
nullSafe?: boolean;
|
|
|
|
/**
|
|
* 순환 참조 감지 활성화
|
|
*/
|
|
detectCircular?: boolean;
|
|
|
|
/**
|
|
* 최대 중첩 깊이 (순환 참조 방지)
|
|
*/
|
|
maxDepth?: number;
|
|
|
|
/**
|
|
* 캐시 사용 여부 (기본값: true)
|
|
* false로 설정하면 캐싱을 건너뜀
|
|
* 반복 렌더링(DataGrid의 cellChildren 등)에서 같은 경로가 다른 값을 가져야 할 때 사용
|
|
*/
|
|
skipCache?: boolean;
|
|
}
|
|
|
|
/**
|
|
* DevTools 표현식 추적 정보
|
|
*
|
|
* 평가 지점(컴포넌트/prop)을 DevTools 로그에 남기기 위한 부가 정보.
|
|
* 값 해석 결과에는 영향을 주지 않는다.
|
|
*/
|
|
export interface ExpressionTrackingInfo {
|
|
/**
|
|
* 평가를 요청한 컴포넌트 ID
|
|
*/
|
|
componentId?: string;
|
|
|
|
/**
|
|
* 평가를 요청한 컴포넌트 이름
|
|
*/
|
|
componentName?: string;
|
|
|
|
/**
|
|
* 평가 대상 prop 이름
|
|
*/
|
|
propName?: string;
|
|
|
|
/**
|
|
* DevTools 로그에 기록할 평가 진입점 이름 (기본: 호출된 메서드명)
|
|
*/
|
|
method?: string;
|
|
|
|
/**
|
|
* DevTools 로그에 표시할 원본 식 (기본: `{{expr}}`)
|
|
*/
|
|
displayExpression?: string;
|
|
}
|
|
|
|
/**
|
|
* 캐시 엔트리 인터페이스
|
|
*/
|
|
interface CacheEntry {
|
|
/**
|
|
* 캐시된 값
|
|
*/
|
|
value: ResolvedValue;
|
|
|
|
/**
|
|
* 캐시 생성 시간
|
|
*/
|
|
timestamp: number;
|
|
}
|
|
|
|
/**
|
|
* 데이터 바인딩 엔진 에러 클래스
|
|
*/
|
|
export class DataBindingError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly path: string,
|
|
public readonly context?: any,
|
|
) {
|
|
super(message);
|
|
this.name = 'DataBindingError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 데이터 바인딩 엔진 클래스
|
|
*
|
|
* {{variable}} 문법을 파싱하고 실제 데이터로 치환
|
|
*/
|
|
export class DataBindingEngine {
|
|
/**
|
|
* 경로 파싱 정규식
|
|
*
|
|
* 중첩 객체 접근 (.) 및 배열 인덱스 ([n]) 파싱
|
|
*/
|
|
private static readonly PATH_SEGMENT_PATTERN = /([^.[\]]+)|\[(\d+)\]/g;
|
|
|
|
/**
|
|
* 캐시 만료 시간 (밀리초)
|
|
*
|
|
* 성능 최적화를 위해 30초로 설정
|
|
* - 바인딩 경로 재계산 빈도 감소
|
|
* - _global 경로는 캐싱되지 않으므로 동적 데이터 처리에 영향 없음
|
|
*/
|
|
private static readonly CACHE_EXPIRY = 30000; // 30초
|
|
|
|
/**
|
|
* 값 캐시 맵
|
|
*
|
|
* key: 바인딩 경로, value: 캐시 엔트리
|
|
*/
|
|
private cache: Map<string, CacheEntry> = new Map();
|
|
|
|
/**
|
|
* 표현식 함수 캐시
|
|
*
|
|
* key: 전처리된 표현식, value: 컴파일된 Function
|
|
* - Function 생성자 비용이 높으므로 동일 표현식 재사용 시 캐시에서 조회
|
|
* - 표현식 자체만 캐싱하고, 컨텍스트 값은 실행 시 전달
|
|
*/
|
|
private expressionFnCache: Map<string, Function> = new Map();
|
|
|
|
/**
|
|
* 렌더 사이클 캐시
|
|
*
|
|
* 같은 렌더 사이클 내에서 동일 표현식 재평가 방지
|
|
* - 렌더 시작 시 startRenderCycle()로 클리어
|
|
* - _local, _global 등 동적 경로도 같은 렌더 내에서는 캐시 사용
|
|
* - 65,496회 → ~5,000회 바인딩 평가 감소 목표
|
|
*/
|
|
private renderCycleCache: Map<string, any> = new Map();
|
|
|
|
/**
|
|
* 현재 렌더 사이클 ID
|
|
*
|
|
* 매 렌더 시작 시 증가하여 캐시 유효성 판단에 사용
|
|
*/
|
|
private currentRenderCycleId: number = 0;
|
|
|
|
/**
|
|
* 기본 옵션
|
|
*/
|
|
private defaultOptions: BindingOptions = {
|
|
defaultValue: undefined,
|
|
nullSafe: true,
|
|
detectCircular: true,
|
|
maxDepth: 10,
|
|
};
|
|
|
|
/**
|
|
* 순환 참조 추적용 Set (각 resolve 호출마다 로컬로 생성)
|
|
*/
|
|
// 인스턴스 레벨에서 제거 - 각 resolvePath 호출에서 로컬로 관리
|
|
|
|
/**
|
|
* 생성자
|
|
*
|
|
* @param options 기본 옵션
|
|
*/
|
|
constructor(options?: Partial<BindingOptions>) {
|
|
if (options) {
|
|
this.defaultOptions = { ...this.defaultOptions, ...options };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 새 렌더 사이클 시작 - 렌더 사이클 캐시 클리어
|
|
*
|
|
* DynamicRenderer 루트 컴포넌트에서 렌더링 시작 시 호출됩니다.
|
|
* 이를 통해 같은 렌더 사이클 내에서 동일 표현식 재평가를 방지하면서도,
|
|
* 새 렌더 시 최신 상태값으로 평가됩니다.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // DynamicRenderer.tsx 루트 컴포넌트
|
|
* useLayoutEffect(() => {
|
|
* if (!parentComponentContext) {
|
|
* bindingEngine.startRenderCycle();
|
|
* }
|
|
* });
|
|
* ```
|
|
*/
|
|
public startRenderCycle(): void {
|
|
this.currentRenderCycleId++;
|
|
this.renderCycleCache.clear();
|
|
}
|
|
|
|
/**
|
|
* 렌더 사이클 캐시 활성화 여부 확인
|
|
*
|
|
* startRenderCycle()이 호출된 적이 있어야 렌더 사이클 캐시 활성화
|
|
* 테스트 환경 등에서 startRenderCycle()을 호출하지 않으면 기존 동작 유지
|
|
*/
|
|
private isRenderCycleCacheActive(): boolean {
|
|
return this.currentRenderCycleId > 0;
|
|
}
|
|
|
|
/**
|
|
* 렌더 사이클 캐시에서 값 조회
|
|
*
|
|
* @param key 캐시 키 (표현식 문자열)
|
|
* @returns 캐시된 값 또는 undefined (렌더 사이클이 활성화되지 않으면 항상 undefined)
|
|
*/
|
|
private getFromRenderCycleCache(key: string): any | undefined {
|
|
if (!this.isRenderCycleCacheActive()) {
|
|
return undefined;
|
|
}
|
|
return this.renderCycleCache.get(key);
|
|
}
|
|
|
|
/**
|
|
* 렌더 사이클 캐시에 값 저장
|
|
*
|
|
* @param key 캐시 키 (표현식 문자열)
|
|
* @param value 저장할 값
|
|
* @note 렌더 사이클이 활성화되지 않으면 저장하지 않음
|
|
*/
|
|
private saveToRenderCycleCache(key: string, value: any): void {
|
|
if (!this.isRenderCycleCacheActive()) {
|
|
return;
|
|
}
|
|
this.renderCycleCache.set(key, value);
|
|
}
|
|
|
|
/**
|
|
* 문자열 내의 모든 바인딩 표현식을 데이터로 치환
|
|
*
|
|
* @param template 바인딩 표현식이 포함된 문자열
|
|
* @param context 데이터 컨텍스트
|
|
* @param options 바인딩 옵션
|
|
* @param trackingInfo DevTools 표현식 추적 정보 (선택적)
|
|
* @returns 치환된 문자열
|
|
*
|
|
* @example
|
|
* const engine = new DataBindingEngine();
|
|
* const result = engine.resolveBindings("Hello {{user.name}}", { user: { name: "홍길동" } });
|
|
* // 결과: "Hello 홍길동"
|
|
*/
|
|
public resolveBindings(
|
|
template: string,
|
|
context: BindingContext,
|
|
options?: BindingOptions,
|
|
trackingInfo?: { componentId?: string; componentName?: string; propName?: string },
|
|
): string {
|
|
const opts = { ...this.defaultOptions, ...options };
|
|
const devTools = getDevTools();
|
|
|
|
// $computed alias 추가
|
|
// layout-json-features.md 문서에 따라 $computed.xxx 형식으로 computed 값에 접근
|
|
// 내부적으로 _computed에 저장되므로 $computed를 _computed의 alias로 설정
|
|
const extendedContext: BindingContext = context._computed && !context.$computed
|
|
? { ...context, $computed: context._computed }
|
|
: context;
|
|
|
|
const replaceBinding = (_match: string, path: string): string => {
|
|
// 공백 제거
|
|
const trimmedPath = path.trim();
|
|
|
|
// raw: 접두사 감지 — 번역 면제 바인딩
|
|
// @since engine-v1.27.0
|
|
let isRawBinding = false;
|
|
let effectivePath = trimmedPath;
|
|
if (trimmedPath.startsWith(RAW_PREFIX)) {
|
|
isRawBinding = true;
|
|
effectivePath = trimmedPath.slice(RAW_PREFIX.length);
|
|
}
|
|
|
|
// 파이프 함수 처리 (예: {{post.created_at | date}})
|
|
// 평가 자체는 evaluatePipeExpression 이 담당하고, 여기서는 보간의 목적에 맞게
|
|
// 결과를 문자열로 서식한다. @since engine-v1.54.9
|
|
if (hasPipes(effectivePath)) {
|
|
try {
|
|
const result = this.evaluatePipeExpression(effectivePath, extendedContext, opts, {
|
|
...trackingInfo,
|
|
method: 'resolveBindings',
|
|
displayExpression: `{{${trimmedPath}}}`,
|
|
});
|
|
const formatted = this.formatValue(result as ResolvedValue);
|
|
return isRawBinding ? wrapRaw(formatted) : formatted;
|
|
} catch (error) {
|
|
logger.error('Pipe expression evaluation failed:', effectivePath, error);
|
|
return _match; // 원본 반환
|
|
}
|
|
}
|
|
|
|
// 삼항 연산자나 복잡한 표현식 감지 (?, :, ||, &&, !, 산술 연산자, 함수 호출 등)
|
|
// 대괄호 안에 따옴표가 있는 문자열 키 접근도 표현식으로 처리 (예: query['filters[0][field]'])
|
|
// 함수 호출 패턴도 표현식으로 처리 (예: $localized(...))
|
|
// 주의: 단일 | (파이프)는 위에서 먼저 처리되므로 여기서는 || 만 표현식으로 인식
|
|
// 판정은 BindingShape 정본을 쓴다 — 종전에는 이 자리의 문자 집합이 다른 렌더
|
|
// 경로들과 달라, 같은 식이 여기서는 경로 탐색으로 가고 저기서는 표현식으로 가는
|
|
// 비대칭이 있었다. 파이프는 위에서 이미 갈라졌다. @since engine-v1.55.0
|
|
const isExpression = isComplexExpression(effectivePath);
|
|
|
|
if (isExpression) {
|
|
// JavaScript 표현식으로 평가
|
|
const exprStartTime = devTools?.isEnabled() ? performance.now() : 0;
|
|
try {
|
|
let value: any;
|
|
let fromCache = false;
|
|
|
|
// skipCache가 아니면 렌더 사이클 캐시 사용
|
|
if (!opts.skipCache) {
|
|
const exprCacheKey = `expr:${effectivePath}`;
|
|
const exprCached = this.getFromRenderCycleCache(exprCacheKey);
|
|
if (exprCached !== undefined) {
|
|
value = exprCached;
|
|
fromCache = true;
|
|
} else {
|
|
value = this.evaluateExpression(effectivePath, extendedContext);
|
|
this.saveToRenderCycleCache(exprCacheKey, value);
|
|
}
|
|
} else {
|
|
value = this.evaluateExpression(effectivePath, extendedContext);
|
|
}
|
|
|
|
// DevTools: 복잡한 표현식 평가 추적
|
|
if (devTools?.isEnabled()) {
|
|
const duration = performance.now() - exprStartTime;
|
|
devTools.trackExpressionEval({
|
|
expression: `{{${trimmedPath}}}`,
|
|
result: this.sanitizeResultForTracking(value),
|
|
resultType: this.getResultType(value),
|
|
componentId: trackingInfo?.componentId,
|
|
componentName: trackingInfo?.componentName,
|
|
propName: trackingInfo?.propName,
|
|
fromCache,
|
|
duration,
|
|
method: 'resolveBindings',
|
|
skipCache: opts.skipCache,
|
|
});
|
|
}
|
|
|
|
const formatted = this.formatValue(value);
|
|
return isRawBinding ? wrapRaw(formatted) : formatted;
|
|
} catch (error) {
|
|
logger.error('Expression evaluation failed:', effectivePath, error);
|
|
return _match; // 원본 반환
|
|
}
|
|
}
|
|
|
|
// skipCache 옵션이면 모든 캐시 사용 안 함
|
|
if (opts.skipCache) {
|
|
const startTime = devTools?.isEnabled() ? performance.now() : 0;
|
|
const value = this.resolvePath(effectivePath, extendedContext, opts);
|
|
|
|
// DevTools: 표현식 평가 추적
|
|
if (devTools?.isEnabled()) {
|
|
const duration = performance.now() - startTime;
|
|
devTools.trackExpressionEval({
|
|
expression: `{{${trimmedPath}}}`,
|
|
result: this.sanitizeResultForTracking(value),
|
|
resultType: this.getResultType(value),
|
|
componentId: trackingInfo?.componentId,
|
|
componentName: trackingInfo?.componentName,
|
|
propName: trackingInfo?.propName,
|
|
fromCache: false,
|
|
duration,
|
|
method: 'resolveBindings',
|
|
skipCache: true,
|
|
});
|
|
}
|
|
|
|
const formatted = this.formatValue(value);
|
|
return isRawBinding ? wrapRaw(formatted) : formatted;
|
|
}
|
|
|
|
// 동적 경로 여부 판단
|
|
const isDynamicPath = effectivePath.startsWith('_global') ||
|
|
effectivePath.startsWith('_local') ||
|
|
effectivePath.startsWith('_isolated') ||
|
|
effectivePath.startsWith('$parent');
|
|
|
|
const startTime = devTools?.isEnabled() ? performance.now() : 0;
|
|
let value: any;
|
|
let fromCache = false;
|
|
|
|
if (isDynamicPath) {
|
|
// 동적 경로: 렌더 사이클 캐시 사용
|
|
const renderCycleCached = this.getFromRenderCycleCache(effectivePath);
|
|
if (renderCycleCached !== undefined) {
|
|
value = renderCycleCached;
|
|
fromCache = true;
|
|
} else {
|
|
value = this.resolvePath(effectivePath, extendedContext, opts);
|
|
this.saveToRenderCycleCache(effectivePath, value);
|
|
}
|
|
} else {
|
|
// 정적 경로: 영구 캐시 사용
|
|
const cached = this.getFromCache(effectivePath);
|
|
if (cached !== undefined) {
|
|
value = cached;
|
|
fromCache = true;
|
|
} else {
|
|
value = this.resolvePath(effectivePath, extendedContext, opts);
|
|
this.saveToCache(effectivePath, value);
|
|
}
|
|
}
|
|
|
|
// DevTools: 표현식 평가 추적
|
|
if (devTools?.isEnabled()) {
|
|
const duration = performance.now() - startTime;
|
|
devTools.trackExpressionEval({
|
|
expression: `{{${trimmedPath}}}`,
|
|
result: this.sanitizeResultForTracking(value),
|
|
resultType: this.getResultType(value),
|
|
componentId: trackingInfo?.componentId,
|
|
componentName: trackingInfo?.componentName,
|
|
propName: trackingInfo?.propName,
|
|
fromCache,
|
|
duration,
|
|
method: 'resolveBindings',
|
|
skipCache: opts.skipCache,
|
|
});
|
|
}
|
|
|
|
const formatted = this.formatValue(value);
|
|
return isRawBinding ? wrapRaw(formatted) : formatted;
|
|
};
|
|
|
|
// 바인딩 위치는 수동 스캐너로 찾는다 — 정규식(`\{\{([^}]+)\}\}`)은 식 안에 `}` 가
|
|
// 들어가면 매칭에 실패하고, `String.replace` 는 그때 입력을 그대로 돌려주어
|
|
// 원본 `{{...}}` 문자열이 조용히 화면에 노출됐다. @since engine-v1.55.1
|
|
const bindings = scanBindings(template);
|
|
if (bindings.length === 0) return template;
|
|
|
|
let output = '';
|
|
let cursor = 0;
|
|
for (const binding of bindings) {
|
|
output += template.slice(cursor, binding.start);
|
|
output += replaceBinding(template.slice(binding.start, binding.end), binding.expr);
|
|
cursor = binding.end;
|
|
}
|
|
return output + template.slice(cursor);
|
|
}
|
|
|
|
/**
|
|
* 파이프(`|`)가 포함된 단일 바인딩 식을 평가해 **원본 타입 그대로** 반환
|
|
*
|
|
* `resolveBindings` 는 보간(문자열 산출)이 목적이라 결과에 `formatValue` 를 적용한다.
|
|
* 반면 prop 값·`if` 조건·반복 렌더처럼 값 자체가 필요한 자리에서는 배열이
|
|
* `"[\"a\",\"b\"]"` 로, `false` 가 `"false"`(truthy) 로 바뀌면 안 된다.
|
|
* 그런 지점은 `resolveBindings(\`{{...}}\`)` 로 우회하지 말고 이 메서드를 직접 호출한다.
|
|
*
|
|
* 문자열 조립을 거치지 않으므로 `{{(row.meta ?? {}) | json}}` 처럼 식 안에 중괄호가
|
|
* 있는 경우에도 정상 평가된다 — `BINDING_PATTERN` 은 `}` 를 포함한 식을 매칭하지 못해
|
|
* 위임 방식에서는 입력 문자열이 그대로 화면에 노출됐다.
|
|
*
|
|
* @param expr `{{ }}` 와 `raw:` 접두사를 제거한 파이프 식 (예: `row.tags | slice(0,3)`)
|
|
* @param context 데이터 컨텍스트
|
|
* @param options 바인딩 옵션 (skipCache 등)
|
|
* @param trackingInfo DevTools 표현식 추적 정보 (선택적)
|
|
* @returns 파이프 체인 실행 결과 (원본 타입)
|
|
* @throws 기본 식 평가 또는 파이프 실행 실패 시 그대로 전파 — 호출 지점이 자신의
|
|
* 실패 정책(경고 후 undefined 등)을 적용한다
|
|
*
|
|
* @since engine-v1.54.9
|
|
*/
|
|
public evaluatePipeExpression(
|
|
expr: string,
|
|
context: BindingContext,
|
|
options?: BindingOptions,
|
|
trackingInfo?: ExpressionTrackingInfo,
|
|
): unknown {
|
|
const opts = { ...this.defaultOptions, ...options };
|
|
const devTools = getDevTools();
|
|
const pipeStartTime = devTools?.isEnabled() ? performance.now() : 0;
|
|
|
|
// $computed alias 추가 (resolveBindings 와 동일 규칙)
|
|
const extendedContext: BindingContext = context._computed && !context.$computed
|
|
? { ...context, $computed: context._computed }
|
|
: context;
|
|
|
|
const [baseExpr, pipes] = splitPipes(expr);
|
|
|
|
// 기본 표현식 평가 (파이프 적용 전)
|
|
let value: any;
|
|
let fromCache = false;
|
|
const isBaseExpression = isComplexExpression(baseExpr);
|
|
// 동적 경로 여부 판단
|
|
const isDynamicPipeBase = baseExpr.startsWith('_global') ||
|
|
baseExpr.startsWith('_local') ||
|
|
baseExpr.startsWith('_isolated') ||
|
|
baseExpr.startsWith('$parent');
|
|
|
|
if (opts.skipCache) {
|
|
// skipCache: 모든 캐시 무시
|
|
if (isBaseExpression) {
|
|
value = this.evaluateExpression(baseExpr, extendedContext);
|
|
} else {
|
|
value = this.resolvePath(baseExpr, extendedContext, opts);
|
|
}
|
|
} else if (isBaseExpression) {
|
|
// 복잡한 표현식: 렌더 사이클 캐시 사용
|
|
const exprCached = this.getFromRenderCycleCache(`expr:${baseExpr}`);
|
|
if (exprCached !== undefined) {
|
|
value = exprCached;
|
|
fromCache = true;
|
|
} else {
|
|
value = this.evaluateExpression(baseExpr, extendedContext);
|
|
this.saveToRenderCycleCache(`expr:${baseExpr}`, value);
|
|
}
|
|
} else if (isDynamicPipeBase) {
|
|
// 동적 경로: 렌더 사이클 캐시 사용
|
|
const renderCycleCached = this.getFromRenderCycleCache(baseExpr);
|
|
if (renderCycleCached !== undefined) {
|
|
value = renderCycleCached;
|
|
fromCache = true;
|
|
} else {
|
|
value = this.resolvePath(baseExpr, extendedContext, opts);
|
|
this.saveToRenderCycleCache(baseExpr, value);
|
|
}
|
|
} else {
|
|
// 정적 경로: 영구 캐시 사용
|
|
const cached = this.getFromCache(baseExpr);
|
|
if (cached !== undefined) {
|
|
value = cached;
|
|
fromCache = true;
|
|
} else {
|
|
value = this.resolvePath(baseExpr, extendedContext, opts);
|
|
this.saveToCache(baseExpr, value);
|
|
}
|
|
}
|
|
|
|
// 파이프 체인 실행
|
|
const result = executePipeChain(value, pipes);
|
|
|
|
// DevTools: 파이프 표현식 평가 추적
|
|
if (devTools?.isEnabled()) {
|
|
const duration = performance.now() - pipeStartTime;
|
|
devTools.trackExpressionEval({
|
|
expression: trackingInfo?.displayExpression ?? `{{${expr}}}`,
|
|
result: this.sanitizeResultForTracking(result),
|
|
resultType: this.getResultType(result),
|
|
componentId: trackingInfo?.componentId,
|
|
componentName: trackingInfo?.componentName,
|
|
propName: trackingInfo?.propName,
|
|
fromCache,
|
|
duration,
|
|
method: trackingInfo?.method ?? 'evaluatePipeExpression',
|
|
skipCache: opts.skipCache,
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 단일 바인딩 경로의 값을 해석
|
|
*
|
|
* @param path 바인딩 경로 (예: "user.profile.name")
|
|
* @param context 데이터 컨텍스트
|
|
* @param options 바인딩 옵션
|
|
* @param trackingInfo DevTools 표현식 추적 정보 (선택적)
|
|
* @returns 해석된 값
|
|
*
|
|
* @example
|
|
* const engine = new DataBindingEngine();
|
|
* const value = engine.resolve("user.name", { user: { name: "홍길동" } });
|
|
* // 결과: "홍길동"
|
|
*/
|
|
public resolve<T = ResolvedValue>(
|
|
path: string,
|
|
context: BindingContext,
|
|
options?: BindingOptions,
|
|
trackingInfo?: { componentId?: string; componentName?: string; propName?: string },
|
|
): T {
|
|
const opts = { ...this.defaultOptions, ...options };
|
|
const devTools = getDevTools();
|
|
const startTime = devTools?.isEnabled() ? performance.now() : 0;
|
|
|
|
// skipCache 옵션이 true면 모든 캐시 사용 안 함 (반복 렌더링에서 같은 경로가 다른 값을 가져야 할 때)
|
|
if (opts.skipCache) {
|
|
const value = this.resolvePath(path, context, opts);
|
|
|
|
// DevTools: 표현식 평가 추적
|
|
if (devTools?.isEnabled()) {
|
|
devTools.trackExpressionEval({
|
|
expression: path,
|
|
result: this.sanitizeResultForTracking(value),
|
|
resultType: this.getResultType(value),
|
|
componentId: trackingInfo?.componentId,
|
|
componentName: trackingInfo?.componentName,
|
|
propName: trackingInfo?.propName,
|
|
fromCache: false,
|
|
duration: performance.now() - startTime,
|
|
method: 'resolve',
|
|
skipCache: true,
|
|
});
|
|
}
|
|
|
|
return value as T;
|
|
}
|
|
|
|
// 동적 경로 여부 판단
|
|
// - _global, _local, _isolated, $parent: 동적으로 자주 변경되므로 영구 캐시 사용 안 함
|
|
// - 단, 같은 렌더 사이클 내에서는 렌더 사이클 캐시 사용 (성능 최적화)
|
|
const isDynamicPath = path.startsWith('_global') || path.startsWith('_local') || path.startsWith('_isolated') || path.startsWith('$parent');
|
|
let fromCache = false;
|
|
|
|
// 1. 렌더 사이클 캐시 확인 (동적 경로용)
|
|
// 같은 렌더 사이클 내에서 동일 표현식 재평가 방지
|
|
if (isDynamicPath) {
|
|
const renderCycleCached = this.getFromRenderCycleCache(path);
|
|
if (renderCycleCached !== undefined) {
|
|
fromCache = true;
|
|
|
|
// DevTools: 렌더 사이클 캐시 히트 추적
|
|
if (devTools?.isEnabled()) {
|
|
devTools.trackExpressionEval({
|
|
expression: path,
|
|
result: this.sanitizeResultForTracking(renderCycleCached),
|
|
resultType: this.getResultType(renderCycleCached),
|
|
componentId: trackingInfo?.componentId,
|
|
componentName: trackingInfo?.componentName,
|
|
propName: trackingInfo?.propName,
|
|
fromCache: true,
|
|
duration: performance.now() - startTime,
|
|
method: 'resolve',
|
|
skipCache: opts.skipCache,
|
|
});
|
|
}
|
|
|
|
return renderCycleCached as T;
|
|
}
|
|
} else {
|
|
// 2. 영구 캐시 확인 (정적 경로용)
|
|
const cached = this.getFromCache(path);
|
|
if (cached !== undefined) {
|
|
fromCache = true;
|
|
|
|
// DevTools: 영구 캐시 히트 추적
|
|
if (devTools?.isEnabled()) {
|
|
devTools.trackExpressionEval({
|
|
expression: path,
|
|
result: this.sanitizeResultForTracking(cached),
|
|
resultType: this.getResultType(cached),
|
|
componentId: trackingInfo?.componentId,
|
|
componentName: trackingInfo?.componentName,
|
|
propName: trackingInfo?.propName,
|
|
fromCache: true,
|
|
duration: performance.now() - startTime,
|
|
method: 'resolve',
|
|
skipCache: opts.skipCache,
|
|
});
|
|
}
|
|
|
|
return cached as T;
|
|
}
|
|
}
|
|
|
|
// 값 해석
|
|
const value = this.resolvePath(path, context, opts);
|
|
|
|
// 캐시 저장
|
|
if (isDynamicPath) {
|
|
// 동적 경로: 렌더 사이클 캐시에 저장
|
|
this.saveToRenderCycleCache(path, value);
|
|
} else {
|
|
// 정적 경로: 영구 캐시에 저장
|
|
this.saveToCache(path, value);
|
|
}
|
|
|
|
// DevTools: 표현식 평가 추적
|
|
if (devTools?.isEnabled()) {
|
|
devTools.trackExpressionEval({
|
|
expression: path,
|
|
result: this.sanitizeResultForTracking(value),
|
|
resultType: this.getResultType(value),
|
|
componentId: trackingInfo?.componentId,
|
|
componentName: trackingInfo?.componentName,
|
|
propName: trackingInfo?.propName,
|
|
fromCache,
|
|
duration: performance.now() - startTime,
|
|
method: 'resolve',
|
|
skipCache: opts.skipCache,
|
|
});
|
|
}
|
|
|
|
return value as T;
|
|
}
|
|
|
|
/**
|
|
* 경로를 파싱하여 중첩된 값을 해석
|
|
*
|
|
* @param path 바인딩 경로
|
|
* @param context 데이터 컨텍스트
|
|
* @param options 바인딩 옵션
|
|
* @param depth 현재 중첩 깊이
|
|
* @param visitedObjects 순환 참조 추적용 Set (내부 재귀 호출용)
|
|
* @returns 해석된 값
|
|
* @throws {DataBindingError} 순환 참조 감지 시 또는 최대 깊이 초과 시
|
|
*/
|
|
private resolvePath(
|
|
path: string,
|
|
context: BindingContext,
|
|
options: BindingOptions,
|
|
depth: number = 0,
|
|
visitedObjects: WeakSet<object> = new WeakSet(),
|
|
): ResolvedValue {
|
|
// 빈 경로 안전망 — 세그먼트가 하나도 없으면 아래 루프를 돌지 않아
|
|
// 컨텍스트 객체 **전체**가 값으로 반환된다. 그 값이 문자열로 서식되면
|
|
// 전역 상태가 통째로 화면·요청에 실릴 수 있다. @since engine-v1.55.0
|
|
if (typeof path !== 'string' || path.trim() === '') {
|
|
logger.warn('resolvePath: 빈 경로가 전달되었습니다 — undefined 로 해석합니다.');
|
|
return undefined;
|
|
}
|
|
|
|
// 최대 깊이 체크 (순환 참조 방지)
|
|
if (options.maxDepth && depth > options.maxDepth) {
|
|
if (options.detectCircular) {
|
|
throw new DataBindingError(
|
|
`Maximum depth exceeded (${options.maxDepth}). Possible circular reference.`,
|
|
path,
|
|
context,
|
|
);
|
|
}
|
|
return options.defaultValue;
|
|
}
|
|
|
|
// 경로 세그먼트 파싱
|
|
const segments = this.parsePath(path);
|
|
|
|
// 값 추출
|
|
let current: any = context;
|
|
|
|
for (let i = 0; i < segments.length; i++) {
|
|
const segment = segments[i];
|
|
|
|
// null/undefined 체크 (null-safe)
|
|
if (current == null) {
|
|
if (options.nullSafe) {
|
|
return options.defaultValue;
|
|
}
|
|
throw new DataBindingError(
|
|
`Cannot read property '${segment}' of ${current}`,
|
|
path,
|
|
context,
|
|
);
|
|
}
|
|
|
|
// 순환 참조 감지 (객체인 경우만)
|
|
if (options.detectCircular && typeof current === 'object') {
|
|
if (visitedObjects.has(current)) {
|
|
throw new DataBindingError(
|
|
`Circular reference detected at path: ${segments.slice(0, i + 1).join('.')}`,
|
|
path,
|
|
context,
|
|
);
|
|
}
|
|
visitedObjects.add(current);
|
|
}
|
|
|
|
// 다음 값으로 이동
|
|
current = current[segment];
|
|
// 디버깅 로그
|
|
if (path === '_global.toasts') {
|
|
logger.log(`[DataBindingEngine] segment: ${segment}, current:`, current);
|
|
}
|
|
}
|
|
|
|
// 값이 없으면 기본값 반환
|
|
return current !== undefined ? current : options.defaultValue;
|
|
}
|
|
|
|
/**
|
|
* 경로 문자열을 세그먼트 배열로 파싱
|
|
*
|
|
* @param path 바인딩 경로
|
|
* @returns 세그먼트 배열
|
|
*
|
|
* @example
|
|
* parsePath("user.profile.name") // ["user", "profile", "name"]
|
|
* parsePath("items[0].name") // ["items", "0", "name"]
|
|
* parsePath("data[1][2]") // ["data", "1", "2"]
|
|
*/
|
|
private parsePath(path: string): string[] {
|
|
const segments: string[] = [];
|
|
let match: RegExpExecArray | null;
|
|
|
|
// 정규식 초기화
|
|
DataBindingEngine.PATH_SEGMENT_PATTERN.lastIndex = 0;
|
|
|
|
while ((match = DataBindingEngine.PATH_SEGMENT_PATTERN.exec(path)) !== null) {
|
|
// match[1]: 일반 프로퍼티 (예: "user", "name")
|
|
// match[2]: 배열 인덱스 (예: "0", "1")
|
|
const segment = match[1] || match[2];
|
|
if (segment) {
|
|
segments.push(segment);
|
|
}
|
|
}
|
|
|
|
return segments;
|
|
}
|
|
|
|
/**
|
|
* 값을 문자열로 포맷
|
|
*
|
|
* @param value 해석된 값
|
|
* @returns 문자열 표현
|
|
*/
|
|
private formatValue(value: ResolvedValue): string {
|
|
// null 또는 undefined
|
|
if (value == null) {
|
|
return '';
|
|
}
|
|
|
|
// 객체 또는 배열
|
|
if (typeof value === 'object') {
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch (error) {
|
|
return '[Object]';
|
|
}
|
|
}
|
|
|
|
// 기본 타입 (string, number, boolean)
|
|
return String(value);
|
|
}
|
|
|
|
/**
|
|
* 캐시에서 값 조회
|
|
*
|
|
* @param path 바인딩 경로
|
|
* @returns 캐시된 값 (없으면 undefined)
|
|
*/
|
|
private getFromCache(path: string): ResolvedValue | undefined {
|
|
const entry = this.cache.get(path);
|
|
const devTools = getDevTools();
|
|
|
|
if (!entry) {
|
|
// DevTools: 캐시 미스 기록
|
|
if (devTools?.isEnabled()) {
|
|
devTools.recordCacheMiss();
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// 캐시 만료 체크
|
|
const now = Date.now();
|
|
if (now - entry.timestamp > DataBindingEngine.CACHE_EXPIRY) {
|
|
this.cache.delete(path);
|
|
// DevTools: 만료된 캐시는 미스로 기록
|
|
if (devTools?.isEnabled()) {
|
|
devTools.recordCacheMiss();
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// DevTools: 캐시 히트 기록
|
|
if (devTools?.isEnabled()) {
|
|
devTools.recordCacheHit();
|
|
}
|
|
|
|
return entry.value;
|
|
}
|
|
|
|
/**
|
|
* 캐시에 값 저장
|
|
*
|
|
* @param path 바인딩 경로
|
|
* @param value 저장할 값
|
|
*/
|
|
private saveToCache(path: string, value: ResolvedValue): void {
|
|
this.cache.set(path, {
|
|
value,
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 객체가 액션 정의(ActionDefinition)인지 판별합니다.
|
|
*
|
|
* 액션 정의는 렌더 시점에 resolveObject로 선평가하면 안 됩니다.
|
|
* 내부 템플릿 문자열(예: "{{_local.checkout}}")이 실행 시점에
|
|
* ActionDispatcher.resolveParams()에서 최신 상태로 해소되어야 합니다.
|
|
*
|
|
* 콜백 prop(onAddressSelect 등)에 포함된 액션 정의가 resolveObject를 거치면
|
|
* {{}} 표현식이 렌더 시점 JS 객체로 고정되어 stale 값이 전송됩니다.
|
|
* 컴포넌트 레벨 actions 배열은 bindComponentActions에서 별도 처리되므로 비해당.
|
|
*
|
|
* @param obj 검사할 객체
|
|
* @returns 액션 정의이면 true
|
|
*/
|
|
public isActionDefinition(obj: Record<string, any>): boolean {
|
|
if (typeof obj.handler !== 'string') return false;
|
|
return (
|
|
obj.params !== undefined ||
|
|
Array.isArray(obj.actions) ||
|
|
typeof obj.target === 'string' ||
|
|
obj.onSuccess !== undefined ||
|
|
obj.onError !== undefined
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 객체 내의 모든 바인딩 표현식을 데이터로 치환
|
|
*
|
|
* @param obj 바인딩 표현식이 포함된 객체
|
|
* @param context 데이터 컨텍스트
|
|
* @param options 바인딩 옵션
|
|
* @returns 치환된 객체
|
|
*
|
|
* @example
|
|
* const engine = new DataBindingEngine();
|
|
* const result = engine.resolveObject(
|
|
* { style: { color: "{{theme.color}}" } },
|
|
* { theme: { color: "red" } }
|
|
* );
|
|
* // 결과: { style: { color: "red" } }
|
|
*/
|
|
public resolveObject(
|
|
obj: Record<string, any>,
|
|
context: BindingContext,
|
|
options?: BindingOptions & { skipBindingKeys?: string[] },
|
|
): Record<string, any> {
|
|
// $switch 특수 객체 처리
|
|
// { $switch: "{{key}}", $cases: { ... }, $default: value }
|
|
if (this.isSwitchExpression(obj)) {
|
|
const switchDef = obj as { $switch: string; $cases: Record<string, any>; $default?: any };
|
|
return this.resolveSwitch(switchDef, context, options) as Record<string, any>;
|
|
}
|
|
|
|
// 액션 정의 객체 감지 → 선평가 건너뛰기
|
|
// 콜백 prop(onAddressSelect 등)에 포함된 액션 정의는 렌더 시점에 해소하면
|
|
// 내부 {{_local.xxx}} 표현식이 stale 객체로 고정됨.
|
|
// ActionDispatcher.resolveParams()에서 실행 시점에 최신 상태로 해소해야 함.
|
|
if (this.isActionDefinition(obj)) {
|
|
return { ...obj };
|
|
}
|
|
|
|
// iteration이 있는 ComponentDefinition 감지 → 선평가 건너뛰기 (engine-v1.19.1)
|
|
// footerCells/footerCardChildren 등 DataGrid prop 내부의 컴포넌트 정의에서
|
|
// iteration이 있으면 source, text, children, props 모두 iteration 변수를 참조할 수 있음.
|
|
// DynamicRenderer 시점에는 iteration 변수가 존재하지 않으므로 선평가하면 에러 발생.
|
|
// renderItemChildren에서 적절한 iteration 컨텍스트와 함께 런타임에 해석되어야 함.
|
|
if ('iteration' in obj) {
|
|
return { ...obj };
|
|
}
|
|
|
|
const result: Record<string, any> = {};
|
|
|
|
// 기본 skipBindingKeys (하위 호환성)
|
|
// 컴포넌트별 설정은 DynamicRenderer에서 처리
|
|
// resolveObject는 범용 메서드이므로 기본값만 유지
|
|
const DEFAULT_SKIP_BINDING_KEYS = ['cellChildren', 'expandChildren', 'expandContext', 'render'];
|
|
|
|
// 옵션으로 전달된 skipBindingKeys와 기본값 병합
|
|
const skipBindingKeys = [
|
|
...DEFAULT_SKIP_BINDING_KEYS,
|
|
...(options?.skipBindingKeys || []),
|
|
];
|
|
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
// cellChildren 등 특정 키는 원본 그대로 전달
|
|
if (skipBindingKeys.includes(key)) {
|
|
result[key] = value;
|
|
continue;
|
|
}
|
|
|
|
// 해석 실패는 key 단위로 격리한다 — 이 메서드에는 상위 catch 가 없어서
|
|
// 예외가 밖으로 나가면 그 컴포넌트의 props 해석 **전체**가 중단되고,
|
|
// 표현식 하나의 실수가 화면 전체를 날린다. @since engine-v1.55.1
|
|
try {
|
|
this.resolveObjectEntry(result, key, value, context, options);
|
|
} catch (error) {
|
|
logger.warn(`resolveObject: 값 해석 실패 (key: ${key}):`, error);
|
|
result[key] = undefined;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* `resolveObject` 의 key 하나를 해석해 결과 객체에 기록
|
|
*
|
|
* 실패 격리 단위를 key 로 두기 위해 분리했다. 이 메서드는 예외를 잡지 않는다 —
|
|
* 호출자(`resolveObject`)가 key 단위로 격리한다.
|
|
*
|
|
* @param result 결과 객체 (직접 기록)
|
|
* @param key 대상 key
|
|
* @param value 원본 값
|
|
* @param context 데이터 컨텍스트
|
|
* @param options 바인딩 옵션
|
|
*
|
|
* @since engine-v1.55.1
|
|
*/
|
|
private resolveObjectEntry(
|
|
result: Record<string, any>,
|
|
key: string,
|
|
value: any,
|
|
context: BindingContext,
|
|
options?: BindingOptions & { skipBindingKeys?: string[] },
|
|
): void {
|
|
{
|
|
if (typeof value === 'string') {
|
|
// 전체가 {{variable}} 패턴인 경우 원본 값(배열/객체) 반환.
|
|
// `[^}]+` 는 `{{error.errors ?? {}}}` 처럼 식 안에 `}` 가 든 경우(빈 객체 fallback 등)
|
|
// 첫 `}` 에서 끊겨 매칭에 실패 → 원본 문자열이 그대로 새어 나오던 결함이 있었다.
|
|
// extractSingleBinding 은 외곽 `{{ }}` 안의 중괄호 균형을 추적해 단일 바인딩 식을 정확히 추출한다.
|
|
const singleBindingPath = extractSingleBinding(value);
|
|
if (singleBindingPath !== null) {
|
|
const path = singleBindingPath.trim();
|
|
|
|
// raw: 접두사 감지 @since engine-v1.27.0
|
|
let isRawBinding = false;
|
|
let effectivePath = path;
|
|
if (path.startsWith(RAW_PREFIX)) {
|
|
isRawBinding = true;
|
|
effectivePath = path.slice(RAW_PREFIX.length);
|
|
}
|
|
|
|
// 라우팅 판정은 BindingShape 정본을 쓴다 (파이프 → 표현식 → 경로).
|
|
// 파이프를 evaluateExpression 으로 보내면 `value | pipe` 가 JS 비트 OR 로 평가되어
|
|
// 인자 있는 파이프는 예외를 던지고(props 해석 전체가 중단), 인자 없는
|
|
// 파이프는 포맷이 적용되지 않은 오답이 된다. @since engine-v1.54.3
|
|
// 값이 필요한 자리이므로 서식(formatValue)을 적용하지 않는다. @since engine-v1.54.9
|
|
// 실행(캐시·예외 격리)은 이 지점의 정책을 유지한다. @since engine-v1.55.0
|
|
let resolved: any;
|
|
switch (classifyExpression(effectivePath)) {
|
|
case 'empty':
|
|
logger.warn(`resolveObject: 빈 바인딩 \`{{}}\` (key: ${key}) — undefined 로 해석합니다.`);
|
|
resolved = undefined;
|
|
break;
|
|
case 'literal':
|
|
resolved = LITERALS.get(effectivePath);
|
|
break;
|
|
case 'pipe':
|
|
resolved = this.evaluatePipeExpression(effectivePath, context, options);
|
|
break;
|
|
case 'expression':
|
|
resolved = this.evaluateExpression(effectivePath, context, options);
|
|
break;
|
|
default:
|
|
resolved = this.resolve(effectivePath, context, options);
|
|
}
|
|
result[key] = isRawBinding && resolved != null ? wrapRawDeep(resolved) : resolved;
|
|
} else {
|
|
// 문자열 보간
|
|
result[key] = this.resolveBindings(value, context, options);
|
|
}
|
|
} else if (Array.isArray(value)) {
|
|
result[key] = value.map((item) =>
|
|
typeof item === 'string'
|
|
? this.resolveBindings(item, context, options)
|
|
: typeof item === 'object' && item !== null
|
|
? this.resolveObject(item, context, options)
|
|
: item,
|
|
);
|
|
} else if (value && typeof value === 'object') {
|
|
result[key] = this.resolveObject(value, context, options);
|
|
} else {
|
|
result[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 캐시 초기화
|
|
*
|
|
* 바인딩 값 캐시와 표현식 함수 캐시를 모두 초기화합니다.
|
|
*/
|
|
public clearCache(): void {
|
|
this.cache.clear();
|
|
this.expressionFnCache.clear();
|
|
}
|
|
|
|
/**
|
|
* 특정 키로 시작하는 캐시 항목 삭제
|
|
*
|
|
* 데이터가 업데이트될 때 해당 키와 관련된 캐시만 선택적으로 무효화합니다.
|
|
* 예: invalidateCacheByKeys(['users']) 호출 시
|
|
* 'users', 'users.data', 'users.data.data', 'users[0]' 등 삭제
|
|
*
|
|
* @param keys 삭제할 최상위 키 배열
|
|
*/
|
|
public invalidateCacheByKeys(keys: string[]): void {
|
|
for (const path of this.cache.keys()) {
|
|
for (const key of keys) {
|
|
if (path === key || path.startsWith(`${key}.`) || path.startsWith(`${key}[`)) {
|
|
this.cache.delete(path);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 만료된 캐시 항목 제거
|
|
*/
|
|
public pruneCache(): void {
|
|
const now = Date.now();
|
|
|
|
for (const [path, entry] of this.cache.entries()) {
|
|
if (now - entry.timestamp > DataBindingEngine.CACHE_EXPIRY) {
|
|
this.cache.delete(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 캐시 통계 조회
|
|
*
|
|
* @returns 캐시 크기 및 만료된 항목 수
|
|
*/
|
|
public getCacheStats(): { size: number; expired: number } {
|
|
const now = Date.now();
|
|
let expired = 0;
|
|
|
|
for (const entry of this.cache.values()) {
|
|
if (now - entry.timestamp > DataBindingEngine.CACHE_EXPIRY) {
|
|
expired++;
|
|
}
|
|
}
|
|
|
|
return {
|
|
size: this.cache.size,
|
|
expired,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* JavaScript 표현식 평가 (공개 메서드)
|
|
*
|
|
* 삼항 연산자, 논리 연산자 등 복잡한 표현식을 평가합니다.
|
|
*
|
|
* @param expr 평가할 표현식 (예: "!_global.sidebarOpen", "_global.sidebarOpen ? 'a' : 'b'")
|
|
* @param context 데이터 컨텍스트
|
|
* @param trackingInfo DevTools 표현식 추적 정보 (선택적)
|
|
* @returns 평가 결과
|
|
* @throws {Error} 표현식 평가 실패 시
|
|
*/
|
|
public evaluateExpression(
|
|
expr: string,
|
|
context: BindingContext,
|
|
trackingInfo?: { componentId?: string; componentName?: string; propName?: string; skipCache?: boolean },
|
|
): any {
|
|
// DevTools: 바인딩 평가 추적
|
|
const devTools = getDevTools();
|
|
const startTime = devTools?.isEnabled() ? performance.now() : 0;
|
|
|
|
if (devTools?.isEnabled()) {
|
|
devTools.trackBindingEval();
|
|
}
|
|
|
|
try {
|
|
// 1. 표현식 전체에서 변수 접근을 optional chaining으로 변환
|
|
// 예: perm.identifier -> perm?.identifier
|
|
// 이를 통해 iteration 변수가 undefined일 때 에러 방지
|
|
let processedExpr = this.preprocessOptionalChaining(expr);
|
|
|
|
// 2. $t: 패턴을 문자열로 자동 변환 (예: $t:common.error -> "$t:common.error")
|
|
// 이를 통해 사용자가 따옴표 없이 $t: 문법을 사용할 수 있음
|
|
processedExpr = this.preprocessTranslationTokens(processedExpr);
|
|
|
|
// 표현식에서 사용되는 최상위 변수 추출
|
|
const usedVariables = this.extractVariablesFromExpression(processedExpr);
|
|
|
|
// 컨텍스트에 없는 변수를 undefined로 추가
|
|
const extendedContext = { ...context };
|
|
for (const varName of usedVariables) {
|
|
if (!(varName in extendedContext)) {
|
|
extendedContext[varName] = undefined;
|
|
}
|
|
}
|
|
|
|
// $computed alias 추가
|
|
// layout-json-features.md 문서에 따라 $computed.xxx 형식으로 computed 값에 접근
|
|
// 내부적으로 _computed에 저장되므로 $computed를 _computed의 alias로 설정
|
|
if (context._computed && !extendedContext.$computed) {
|
|
extendedContext.$computed = context._computed;
|
|
}
|
|
|
|
// 활성 로케일 / 템플릿 ID 해석 — $localized 와 $t 가 공유한다.
|
|
//
|
|
// @since engine-v1.38.2 ActionDispatcher 경로 fallback — bindActionsToProps
|
|
// 이후 createHandler 에서 빌드된 action data context 는 `$templateId`/`$locale`
|
|
// 을 명시적으로 포함하지 않을 수 있다. 이 경우 `window.__templateApp.getConfig()`
|
|
// 로부터 회수하여 `{{$event.target.checked ? '$t:A' : '$t:B'}}` 같은 조건부
|
|
// $t: 평가가 raw key 를 반환하던 버그를 해결한다.
|
|
//
|
|
// @since engine-v1.52.1 회수 결과를 `$localized` 도 공유한다. 이전에는 `$localized`
|
|
// 가 `$t` 보다 먼저 `context.$locale || 'ko'` 로 로케일을 확정해, ActionDispatcher
|
|
// 경로(init_actions 파생 등)에서 항상 ko 값을 반환했다. `$locale` 이 컨텍스트에
|
|
// 없는 것은 로케일이 ko 라는 뜻이 아니라 "이 경로가 로케일을 안 넘긴다"는 뜻이다.
|
|
let templateId = context.$templateId;
|
|
let resolvedLocale = context.$locale;
|
|
if (typeof window !== 'undefined' && (!templateId || !resolvedLocale)) {
|
|
const appConfig = (window as any).__templateApp?.getConfig?.();
|
|
templateId ??= appConfig?.templateId;
|
|
resolvedLocale ??= appConfig?.locale;
|
|
}
|
|
const locale = resolvedLocale || 'ko';
|
|
|
|
// $localized 헬퍼 함수 추가
|
|
// 다국어 객체에서 현재 로케일에 해당하는 값을 반환
|
|
// 사용법:
|
|
// $localized(value) - 객체면 로케일 우선순위로 반환, 문자열이면 그대로
|
|
// $localized(value, fallbackKey) - 활성 로케일 키 부재 시 $t(fallbackKey) 호출
|
|
// (settings JSON 등 시스템 카탈로그의 lang pack 보강용)
|
|
extendedContext.$localized = (value: any, fallbackKey?: string): string => {
|
|
if (value == null && !fallbackKey) {
|
|
return '';
|
|
}
|
|
if (typeof value === 'string') {
|
|
return value;
|
|
}
|
|
// 1. 객체의 활성 로케일 키
|
|
if (value && typeof value === 'object' && value[locale]) {
|
|
return value[locale];
|
|
}
|
|
// 2. fallbackKey 가 있으면 $t() 로 lang pack 보강 시도
|
|
if (fallbackKey && typeof fallbackKey === 'string') {
|
|
const translated = extendedContext.$t ? extendedContext.$t(fallbackKey) : fallbackKey;
|
|
if (translated && translated !== fallbackKey) {
|
|
return translated;
|
|
}
|
|
}
|
|
// 3. 객체의 ko / en / 첫 번째 값
|
|
if (value && typeof value === 'object') {
|
|
return value.ko || value.en || Object.values(value)[0] || '';
|
|
}
|
|
if (value == null) {
|
|
return '';
|
|
}
|
|
return String(value);
|
|
};
|
|
|
|
// $uuid 헬퍼 함수 추가
|
|
// UUID v4 형식의 고유 식별자를 생성하여 반환
|
|
// 사용법: $uuid() - "550e8400-e29b-41d4-a716-446655440000" 형식의 문자열 반환
|
|
const G7Core = typeof window !== 'undefined' ? (window as any).G7Core : undefined;
|
|
extendedContext.$uuid = (): string => {
|
|
if (G7Core?.uuid) {
|
|
return G7Core.uuid();
|
|
}
|
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
return crypto.randomUUID();
|
|
}
|
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
const r = (Math.random() * 16) | 0;
|
|
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
return v.toString(16);
|
|
});
|
|
};
|
|
|
|
// $t 헬퍼 함수 추가
|
|
// 번역 키를 사용하여 다국어 텍스트를 반환
|
|
// 사용법: $t('admin.settings.info.write_only') - 번역된 텍스트 반환
|
|
//
|
|
// templateId / locale 은 위에서 해석된 값을 그대로 사용한다($localized 와 공유).
|
|
const translationContext = {
|
|
templateId: templateId || '',
|
|
locale,
|
|
};
|
|
extendedContext.$t = (key: string): string => {
|
|
if (!key || typeof key !== 'string') {
|
|
return '';
|
|
}
|
|
try {
|
|
const translationEngine = TranslationEngine.getInstance();
|
|
return translationEngine.translate(key, translationContext);
|
|
} catch (error) {
|
|
logger.warn('$t() translation failed for key:', key, error);
|
|
return key; // 번역 실패 시 키 자체 반환
|
|
}
|
|
};
|
|
|
|
// $get 헬퍼 함수 추가
|
|
// 깊은 객체 경로 접근과 폴백 값 지원
|
|
// 사용법: $get(product.prices, [currency, 'formatted'], defaultPrice)
|
|
extendedContext.$get = (obj: any, path: string | string[], fallback: any = undefined): any => {
|
|
// null/undefined 객체는 즉시 폴백 반환
|
|
if (obj === null || obj === undefined) {
|
|
return fallback;
|
|
}
|
|
|
|
// 경로를 배열로 정규화
|
|
const keys = Array.isArray(path) ? path : [path];
|
|
|
|
// 빈 경로는 객체 자체 반환
|
|
if (keys.length === 0) {
|
|
return obj;
|
|
}
|
|
|
|
let current = obj;
|
|
|
|
for (const key of keys) {
|
|
// 현재 위치가 null/undefined이면 폴백 반환
|
|
if (current === null || current === undefined) {
|
|
return fallback;
|
|
}
|
|
|
|
// 키가 null/undefined이면 폴백 반환
|
|
if (key === null || key === undefined) {
|
|
return fallback;
|
|
}
|
|
|
|
// 다음 레벨로 이동
|
|
current = current[key];
|
|
}
|
|
|
|
// 최종 값이 null/undefined이면 폴백 반환
|
|
return current ?? fallback;
|
|
};
|
|
|
|
// 확장된 컨텍스트의 키를 변수로 사용할 수 있도록 준비.
|
|
//
|
|
// 함수 파라미터가 될 수 없는 키(`sales_status[]`, `data-id`, 예약어 등)는 제외한다.
|
|
// 하나라도 섞이면 `new Function` 생성 자체가 SyntaxError 로 실패해 **그 컨텍스트에서
|
|
// 평가되는 모든 표현식**이 통째로 죽는다 — 식이 그 키를 쓰지 않아도 마찬가지다.
|
|
// 예외도 화면 오류도 없이 값만 사라지므로(catch → 폴백) 원인 파악이 어렵다.
|
|
//
|
|
// 제외해도 잃는 것은 없다 — 그런 키는 애초에 식 안에서 맨이름으로 참조할 수 없었고
|
|
// (`sales_status[]` 는 식별자가 아니다), 실제 작성은 `query['sales_status[]']` 처럼
|
|
// 상위 객체를 거치므로 그대로 동작한다.
|
|
// @since engine-v1.56.2
|
|
const contextKeys: string[] = [];
|
|
const contextValues: unknown[] = [];
|
|
for (const [key, value] of Object.entries(extendedContext)) {
|
|
if (!VALID_IDENTIFIER_PATTERN.test(key) || RESERVED_WORDS.has(key)) {
|
|
continue;
|
|
}
|
|
contextKeys.push(key);
|
|
contextValues.push(value);
|
|
}
|
|
|
|
// 캐시 키 생성: 전처리된 표현식 + 컨텍스트 키 조합
|
|
// 같은 표현식이라도 컨텍스트 키가 다르면 다른 함수가 필요
|
|
const cacheKey = `${processedExpr}|${contextKeys.join(',')}`;
|
|
|
|
// 캐시된 함수 조회 또는 새로 생성
|
|
let evaluator = this.expressionFnCache.get(cacheKey);
|
|
const fromCache = !!evaluator;
|
|
if (!evaluator) {
|
|
// Function 생성자를 사용하여 표현식 평가
|
|
// 예: expr = "!_global.sidebarOpen"
|
|
// contextKeys = ["_global", "user", ...]
|
|
// contextValues = [{sidebarOpen: false}, {...}, ...]
|
|
// eslint-disable-next-line no-new-func
|
|
evaluator = new Function(...contextKeys, `return (${processedExpr});`);
|
|
this.expressionFnCache.set(cacheKey, evaluator);
|
|
}
|
|
|
|
const result = evaluator(...contextValues);
|
|
|
|
// DevTools: 표현식 평가 추적
|
|
if (devTools?.isEnabled()) {
|
|
const duration = performance.now() - startTime;
|
|
devTools.trackExpressionEval({
|
|
expression: expr,
|
|
result: this.sanitizeResultForTracking(result),
|
|
resultType: this.getResultType(result),
|
|
componentId: trackingInfo?.componentId,
|
|
componentName: trackingInfo?.componentName,
|
|
propName: trackingInfo?.propName,
|
|
fromCache,
|
|
duration,
|
|
method: 'evaluateExpression',
|
|
skipCache: trackingInfo?.skipCache,
|
|
});
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Failed to evaluate expression "${expr}": ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 결과 값의 타입을 문자열로 반환
|
|
*/
|
|
private getResultType(value: any): string {
|
|
if (value === null) return 'null';
|
|
if (value === undefined) return 'undefined';
|
|
if (Array.isArray(value)) return 'array';
|
|
return typeof value;
|
|
}
|
|
|
|
/**
|
|
* 추적을 위해 결과 값을 안전하게 정리
|
|
* (순환 참조 제거, 크기 제한 등)
|
|
*/
|
|
private sanitizeResultForTracking(value: any): any {
|
|
if (value === null || value === undefined) return value;
|
|
if (typeof value !== 'object') return value;
|
|
|
|
try {
|
|
// 객체/배열의 경우 간략화된 정보만 저장
|
|
if (Array.isArray(value)) {
|
|
if (value.length > 10) {
|
|
return `[Array(${value.length})]`;
|
|
}
|
|
return value.slice(0, 10);
|
|
}
|
|
|
|
// 객체의 경우 키 개수 제한
|
|
const keys = Object.keys(value);
|
|
if (keys.length > 20) {
|
|
return `{Object(${keys.length} keys)}`;
|
|
}
|
|
|
|
// 순환 참조 방지를 위해 JSON 변환 시도
|
|
JSON.stringify(value);
|
|
return value;
|
|
} catch {
|
|
return '[Complex Object]';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 표현식 내 $t: 토큰을 처리
|
|
*
|
|
* 1. 따옴표 안의 $t:key → $t('key') 함수 호출로 변환 (computed expressions용)
|
|
* 2. 따옴표 없는 $t:key → "$t:key" 문자열로 변환 (static JSON용)
|
|
*
|
|
* @param expr 원본 표현식
|
|
* @returns 변환된 표현식
|
|
*
|
|
* @example
|
|
* preprocessTranslationTokens("'$t:common.all'")
|
|
* // 결과: "$t('common.all')"
|
|
*
|
|
* @example
|
|
* preprocessTranslationTokens("_global.msg || $t:common.error")
|
|
* // 결과: '_global.msg || "$t:common.error"'
|
|
*/
|
|
private preprocessTranslationTokens(expr: string): string {
|
|
// 1. 먼저 따옴표 안의 $t: 패턴을 $t() 함수 호출로 변환
|
|
// '$t:common.all' → $t('common.all')
|
|
// "$t:common.all" → $t('common.all')
|
|
// '$t:sirsoft-page.admin.key' → $t('sirsoft-page.admin.key') (하이픈 포함 모듈 키 지원)
|
|
// @since engine-v1.28.1 하이픈(-) 지원 추가
|
|
expr = expr.replace(/(['"])(\$t:([a-zA-Z_][a-zA-Z0-9_.\-]*))\1/g, (_match, _quote, _fullKey, keyOnly) => {
|
|
return `$t('${keyOnly}')`;
|
|
});
|
|
|
|
// 2. 기존 로직: 따옴표 없는 $t: 패턴을 문자열로 변환
|
|
// $t:key.path → "$t:key.path"
|
|
// @since engine-v1.28.1 하이픈(-) 지원 추가
|
|
return expr.replace(/(?<!['"]\s*)(\$t:[a-zA-Z_][a-zA-Z0-9_.\-]*)(?!\s*['"])/g, '"$1"');
|
|
}
|
|
|
|
/**
|
|
* 표현식 전체에서 변수 접근을 optional chaining으로 변환
|
|
*
|
|
* iteration 변수 등이 undefined일 때 에러를 방지합니다.
|
|
* 문자열 리터럴 내부는 변환하지 않습니다.
|
|
*
|
|
* @param expr 원본 표현식
|
|
* @returns 변환된 표현식
|
|
*
|
|
* @example
|
|
* preprocessOptionalChaining("perm.identifier")
|
|
* // 결과: "perm?.identifier"
|
|
*
|
|
* preprocessOptionalChaining("$localized(perm.name) + ' (' + perm.identifier + ')'")
|
|
* // 결과: "$localized(perm?.name) + ' (' + perm?.identifier + ')'"
|
|
*
|
|
* preprocessOptionalChaining("_global.user.name")
|
|
* // 결과: "_global?.user?.name"
|
|
*/
|
|
private preprocessOptionalChaining(expr: string): string {
|
|
// 문자열 리터럴을 임시 토큰으로 대체
|
|
const stringLiterals: string[] = [];
|
|
let tokenizedExpr = expr.replace(/(['"])(?:(?!\1|\\).|\\.)*\1/g, (match) => {
|
|
stringLiterals.push(match);
|
|
return `__STRING_LITERAL_${stringLiterals.length - 1}__`;
|
|
});
|
|
|
|
// $t: 패턴을 임시 토큰으로 대체 (번역 키 경로는 변환하지 않음)
|
|
// @since engine-v1.28.1 하이픈(-) 지원 추가
|
|
const translationTokens: string[] = [];
|
|
tokenizedExpr = tokenizedExpr.replace(/\$t:[a-zA-Z_][a-zA-Z0-9_.\-]*(?:\|[^'"\s,)]+)?/g, (match) => {
|
|
translationTokens.push(match);
|
|
return `__TRANSLATION_TOKEN_${translationTokens.length - 1}__`;
|
|
});
|
|
|
|
// 변수.속성 패턴을 변수?.속성으로 변환 (이미 ?. 인 경우 제외)
|
|
// 단, 숫자 뒤의 .은 변환하지 않음 (예: 1.5)
|
|
tokenizedExpr = tokenizedExpr.replace(/([a-zA-Z_$][a-zA-Z0-9_$]*)\.(?!\?)/g, '$1?.');
|
|
|
|
// $t: 패턴 복원
|
|
tokenizedExpr = tokenizedExpr.replace(/__TRANSLATION_TOKEN_(\d+)__/g, (_match, index) => {
|
|
return translationTokens[parseInt(index, 10)];
|
|
});
|
|
|
|
// 문자열 리터럴 복원
|
|
tokenizedExpr = tokenizedExpr.replace(/__STRING_LITERAL_(\d+)__/g, (_match, index) => {
|
|
return stringLiterals[parseInt(index, 10)];
|
|
});
|
|
|
|
return tokenizedExpr;
|
|
}
|
|
|
|
/**
|
|
* 표현식에서 사용되는 최상위 변수명 추출
|
|
*
|
|
* @param expr 표현식
|
|
* @returns 변수명 배열
|
|
*/
|
|
private extractVariablesFromExpression(expr: string): string[] {
|
|
// JavaScript 키워드 및 예약어
|
|
const reserved = new Set([
|
|
'true', 'false', 'null', 'undefined', 'NaN', 'Infinity',
|
|
'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'continue', 'return',
|
|
'function', 'class', 'const', 'let', 'var', 'new', 'delete', 'typeof', 'instanceof',
|
|
'this', 'super', 'import', 'export', 'default', 'try', 'catch', 'finally', 'throw',
|
|
// 내장 객체
|
|
'Math', 'Date', 'JSON', 'Array', 'Object', 'String', 'Number', 'Boolean', 'RegExp',
|
|
// 컬렉션/표준 내장 객체 (@since engine-v1.50.0)
|
|
// 이들이 reserved 에 없으면 컨텍스트 변수로 오인되어 undefined 로 가려지고
|
|
// new Function 본문에서 `new Set(...)` 가 `new undefined()` 가 되어 실패한다.
|
|
// eval/Function/globalThis/window 등 위험 전역은 의도적으로 제외한다.
|
|
'Set', 'Map', 'WeakSet', 'WeakMap', 'Symbol', 'Promise', 'BigInt', 'Error',
|
|
// 전역 함수
|
|
'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'encodeURI', 'decodeURI',
|
|
'encodeURIComponent', 'decodeURIComponent',
|
|
]);
|
|
|
|
// 식별자 패턴 (변수명 시작 위치)
|
|
// 식별자는 문자, $, _로 시작하고 문자, 숫자, $, _로 계속됨
|
|
const identifierPattern = /(?<![.\w$])([a-zA-Z_$][a-zA-Z0-9_$]*)/g;
|
|
|
|
const variables = new Set<string>();
|
|
let match;
|
|
|
|
while ((match = identifierPattern.exec(expr)) !== null) {
|
|
const varName = match[1];
|
|
// 예약어가 아니고, 이미 추출되지 않았다면 추가
|
|
if (!reserved.has(varName)) {
|
|
variables.add(varName);
|
|
}
|
|
}
|
|
|
|
return Array.from(variables);
|
|
}
|
|
|
|
/**
|
|
* $switch 표현식인지 확인
|
|
*
|
|
* 객체가 $switch 키를 가지고 있으면 $switch 표현식으로 간주합니다.
|
|
*
|
|
* @param obj 확인할 객체
|
|
* @returns $switch 표현식 여부
|
|
*
|
|
* @example
|
|
* isSwitchExpression({ $switch: "{{tab}}", $cases: { ... } }) // true
|
|
* isSwitchExpression({ type: "basic", name: "Div" }) // false
|
|
*/
|
|
public isSwitchExpression(obj: any): boolean {
|
|
return (
|
|
obj !== null &&
|
|
typeof obj === 'object' &&
|
|
!Array.isArray(obj) &&
|
|
'$switch' in obj &&
|
|
'$cases' in obj
|
|
);
|
|
}
|
|
|
|
/**
|
|
* $switch 표현식 해석
|
|
*
|
|
* 동적 값에 따라 다른 값을 반환합니다.
|
|
* 중첩 삼항 연산자를 대체하는 선언적 분기 표현식입니다.
|
|
*
|
|
* 트러블슈팅 주의사항:
|
|
* - Stale Closure 방지: $switch 키 평가 시 항상 skipCache: true 적용
|
|
* - 상태 변경 후 즉시 반영을 위해 최신 컨텍스트에서 평가
|
|
*
|
|
* @param switchDef $switch 정의 객체
|
|
* @param context 데이터 컨텍스트
|
|
* @param options 바인딩 옵션
|
|
* @returns 매칭되는 case의 값 또는 default 값
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const switchDef = {
|
|
* $switch: "{{tab}}",
|
|
* $cases: {
|
|
* products: "shopping-bag",
|
|
* contents: "book-open",
|
|
* policies: "file-check"
|
|
* },
|
|
* $default: "file-text"
|
|
* };
|
|
*
|
|
* resolveSwitch(switchDef, { tab: "products" });
|
|
* // 결과: "shopping-bag"
|
|
* ```
|
|
*/
|
|
public resolveSwitch(
|
|
switchDef: { $switch: string; $cases: Record<string, any>; $default?: any },
|
|
context: BindingContext,
|
|
options?: BindingOptions,
|
|
): any {
|
|
// 항상 skipCache: true로 평가 (Stale Closure 방지)
|
|
const evalOptions = { ...options, skipCache: true };
|
|
|
|
// 1. $switch 키 표현식 평가
|
|
let keyValue: string;
|
|
try {
|
|
const resolved = this.resolveBindings(switchDef.$switch, context, evalOptions);
|
|
// 빈 문자열이거나 undefined/null인 경우 처리
|
|
keyValue = resolved?.toString()?.trim() ?? '';
|
|
} catch (error) {
|
|
logger.warn('resolveSwitch: $switch 키 평가 실패:', switchDef.$switch, error);
|
|
keyValue = '';
|
|
}
|
|
|
|
// 2. $cases에서 매칭되는 값 찾기
|
|
let result: any;
|
|
if (keyValue && switchDef.$cases && keyValue in switchDef.$cases) {
|
|
result = switchDef.$cases[keyValue];
|
|
} else if ('$default' in switchDef) {
|
|
// 매칭되는 case가 없으면 $default 사용
|
|
result = switchDef.$default;
|
|
} else {
|
|
// $default도 없으면 undefined 반환
|
|
return undefined;
|
|
}
|
|
|
|
// 3. 결과 값도 바인딩 해석 (중첩 표현식 지원)
|
|
if (typeof result === 'string') {
|
|
// 바인딩 표현식이 있으면 해석
|
|
if (result.includes('{{')) {
|
|
return this.resolveBindings(result, context, evalOptions);
|
|
}
|
|
return result;
|
|
} else if (result !== null && typeof result === 'object') {
|
|
// 중첩 객체인 경우 재귀적으로 해석
|
|
return this.resolveObject(result, context, evalOptions);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 전역 데이터 바인딩 엔진 인스턴스
|
|
*
|
|
* @example
|
|
* import { dataBindingEngine } from './DataBindingEngine';
|
|
* const result = dataBindingEngine.resolve("user.name", context);
|
|
*/
|
|
export const dataBindingEngine = new DataBindingEngine();
|