Last active
February 11, 2024 07:31
-
-
Save jung-han/62bbbf669926853be94086cc1f34c25e to your computer and use it in GitHub Desktop.
packages/core/src/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// core v1.5.1 기준 | |
function cycleDetected(): never { | |
throw new Error("Cycle detected"); | |
} | |
function mutationDetected(): never { | |
throw new Error("Computed cannot have side-effects"); | |
} | |
const identifier = Symbol.for("preact-signals"); | |
// Computed와 Effect에 사용되는 플래그 | |
const RUNNING = 1 << 0; | |
const NOTIFIED = 1 << 1; | |
const OUTDATED = 1 << 2; | |
const DISPOSED = 1 << 3; | |
const HAS_ERROR = 1 << 4; | |
const TRACKING = 1 << 5; | |
// 종속성(소스)과 종속 요소(대상)를 추적하는 데 사용되는 링크된 목록 노드입니다. | |
// 타깃이 마지막으로 본 소스의 버전 번호를 기억하는 데에도 사용됩니다. | |
type Node = { | |
// 타겟이 의존성을 갖는 값의 소스입니다. | |
_source: Signal; | |
_prevSource?: Node; | |
_nextSource?: Node; | |
// 소스에 의존하는 대상이며, 소스가 변경되면 알려야 하는 대상입니다. | |
_target: Computed | Effect; | |
_prevTarget?: Node; | |
_nextTarget?: Node; | |
// 타겟이 마지막으로 본 소스의 버전 번호입니다. 우리는 소스 값을 저장하는 대신 버전 번호를 사용합니다. | |
// 왜냐하면 소스 값은 임의의 양의 메모리를 차지할 수 있고 계산된 값은 느리게 평가되기 때문에 영원히 유지될 수 있기 때문입니다. | |
// 잠재적으로 사용되지 않지만 재활용 가능한 노드를 표시하려면 특수 값 -1을 사용하십시오. | |
_version: number; | |
// 새로운 평가 컨텍스트에 들어가고 나갈 때 소스의 이전 `._node` 값을 기억하고 롤백하는 데 사용됩니다. | |
_rollbackNode?: Node; | |
}; | |
function startBatch() { | |
batchDepth++; | |
} | |
function endBatch() { | |
if (batchDepth > 1) { | |
batchDepth--; | |
return; | |
} | |
let error: unknown; | |
let hasError = false; | |
while (batchedEffect !== undefined) { | |
let effect: Effect | undefined = batchedEffect; | |
batchedEffect = undefined; | |
batchIteration++; | |
while (effect !== undefined) { | |
const next: Effect | undefined = effect._nextBatchedEffect; | |
effect._nextBatchedEffect = undefined; | |
effect._flags &= ~NOTIFIED; | |
if (!(effect._flags & DISPOSED) && needsToRecompute(effect)) { | |
try { | |
effect._callback(); | |
} catch (err) { | |
if (!hasError) { | |
error = err; | |
hasError = true; | |
} | |
} | |
} | |
effect = next; | |
} | |
} | |
batchIteration = 0; | |
batchDepth--; | |
if (hasError) { | |
throw error; | |
} | |
} | |
function batch<T>(callback: () => T): T { | |
if (batchDepth > 0) { | |
return callback(); | |
} | |
/*@__INLINE__**/ startBatch(); | |
try { | |
return callback(); | |
} finally { | |
endBatch(); | |
} | |
} | |
// 현재 수행된(evaluated) computed 또는 effect입니다. | |
let evalContext: Computed | Effect | undefined = undefined; | |
let untrackedDepth = 0; | |
function untracked<T>(callback: () => T): T { | |
if (untrackedDepth > 0) { | |
return callback(); | |
} | |
const prevContext = evalContext; | |
evalContext = undefined; | |
untrackedDepth++; | |
try { | |
return callback(); | |
} finally { | |
untrackedDepth--; | |
evalContext = prevContext; | |
} | |
} | |
// 배치로 수집된 effect | |
let batchedEffect: Effect | undefined = undefined; | |
let batchDepth = 0; | |
let batchIteration = 0; | |
// 전역적으로 변경된 사항이 없을 때 반복되는 계산. | |
// peek()/computed.value 호출을 빠르게 경로 지정하는 데 사용되는 signal의 전역 버전 번호입니다. | |
let globalVersion = 0; | |
function addDependency(signal: Signal): Node | undefined { | |
if (evalContext === undefined) { | |
return undefined; | |
} | |
let node = signal._node; | |
if (node === undefined || node._target !== evalContext) { | |
/** | |
* `signal`은 새로운 종속성입니다. 새 종속성 노드를 생성하고 | |
* 이를 현재 컨텍스트 종속성 목록의 꼬리로 설정합니다. 예: | |
* { A <-> B } | |
* ↑ ↑ | |
* tail node (new) | |
* ↓ | |
* { A <-> B <-> C } | |
* ↑ | |
* tail (evalContext._sources) | |
*/ | |
node = { | |
_version: 0, | |
_source: signal, | |
_prevSource: evalContext._sources, | |
_nextSource: undefined, | |
_target: evalContext, | |
_prevTarget: undefined, | |
_nextTarget: undefined, | |
_rollbackNode: node, | |
}; | |
if (evalContext._sources !== undefined) { | |
evalContext._sources._nextSource = node; | |
} | |
evalContext._sources = node; | |
signal._node = node; | |
// effect를 수행하거나 구독자가 있는 computed signal를 수행하는 경우 | |
// 이 종속성에서 변경 알림을 구독하세요. | |
if (evalContext._flags & TRACKING) { | |
signal._subscribe(node); | |
} | |
return node; | |
} else if (node._version === -1) { | |
// `signal`은 이전 수행의 기존 종속성입니다. 재사용하세요. | |
node._version = 0; | |
/** | |
* '노드'가 아직 종속성 목록의 현재 마지막(꼬리)이 아닌 경우(즉, 목록에 다음 노드가 있는 경우) | |
* '노드'를 새 마지막 노드로 만듭니다. eg | |
* | |
* { A <-> B <-> C <-> D } | |
* ↑ ↑ | |
* node ┌─── tail (evalContext._sources) | |
* └─────│─────┐ | |
* ↓ ↓ | |
* { A <-> C <-> D <-> B } | |
* ↑ | |
* tail (evalContext._sources) | |
*/ | |
if (node._nextSource !== undefined) { | |
node._nextSource._prevSource = node._prevSource; | |
if (node._prevSource !== undefined) { | |
node._prevSource._nextSource = node._nextSource; | |
} | |
node._prevSource = evalContext._sources; | |
node._nextSource = undefined; | |
evalContext._sources!._nextSource = node; | |
evalContext._sources = node; | |
} | |
// 현재 계산된 effect/computed signal은 필요한 경우 | |
// 'signal'의 변경 알림을 이미 구독했다고 가정할 수 있습니다. | |
return node; | |
} | |
return undefined; | |
} | |
// @ts-ignore 내부 Signal은 함수로 간주합니다. | |
declare class Signal<T = any> { | |
/** @internal */ | |
_value: unknown; | |
/** | |
* @internal | |
* 버전 번호는 항상 0보다 커야 합니다. | |
* 특수 값 -1은 잠재적으로 사용되지 않지만 재활용 가능한 노드를 나타내기 위해 노드에서 사용되기 때문입니다. | |
*/ | |
_version: number; | |
/** @internal */ | |
_node?: Node; | |
/** @internal */ | |
_targets?: Node; | |
constructor(value?: T); | |
/** @internal */ | |
_refresh(): boolean; | |
/** @internal */ | |
_subscribe(node: Node): void; | |
/** @internal */ | |
_unsubscribe(node: Node): void; | |
subscribe(fn: (value: T) => void): () => void; | |
valueOf(): T; | |
toString(): string; | |
toJSON(): T; | |
peek(): T; | |
brand: typeof identifier; | |
get value(): T; | |
set value(value: T); | |
} | |
/** @internal */ | |
// @ts-ignore 내부 Signal은 함수로 간주합니다. | |
function Signal(this: Signal, value?: unknown) { | |
this._value = value; | |
this._version = 0; | |
this._node = undefined; | |
this._targets = undefined; | |
} | |
Signal.prototype.brand = identifier; | |
Signal.prototype._refresh = function () { | |
return true; | |
}; | |
Signal.prototype._subscribe = function (node) { | |
if (this._targets !== node && node._prevTarget === undefined) { | |
node._nextTarget = this._targets; | |
if (this._targets !== undefined) { | |
this._targets._prevTarget = node; | |
} | |
this._targets = node; | |
} | |
}; | |
Signal.prototype._unsubscribe = function (node) { | |
// signal에 구독자가 있는 경우에만 구독 취소 단계를 실행하세요. | |
if (this._targets !== undefined) { | |
const prev = node._prevTarget; | |
const next = node._nextTarget; | |
if (prev !== undefined) { | |
prev._nextTarget = next; | |
node._prevTarget = undefined; | |
} | |
if (next !== undefined) { | |
next._prevTarget = prev; | |
node._nextTarget = undefined; | |
} | |
if (node === this._targets) { | |
this._targets = next; | |
} | |
} | |
}; | |
Signal.prototype.subscribe = function (fn) { | |
const signal = this; | |
return effect(function (this: Effect) { | |
const value = signal.value; | |
const flag = this._flags & TRACKING; | |
this._flags &= ~TRACKING; | |
try { | |
fn(value); | |
} finally { | |
this._flags |= flag; | |
} | |
}); | |
}; | |
Signal.prototype.valueOf = function () { | |
return this.value; | |
}; | |
Signal.prototype.toString = function () { | |
return this.value + ""; | |
}; | |
Signal.prototype.toJSON = function () { | |
return this.value; | |
}; | |
Signal.prototype.peek = function () { | |
return this._value; | |
}; | |
Object.defineProperty(Signal.prototype, "value", { | |
get() { | |
const node = addDependency(this); | |
if (node !== undefined) { | |
node._version = this._version; | |
} | |
return this._value; | |
}, | |
set(this: Signal, value) { | |
if (evalContext instanceof Computed) { | |
mutationDetected(); | |
} | |
if (value !== this._value) { | |
if (batchIteration > 100) { | |
cycleDetected(); | |
} | |
this._value = value; | |
this._version++; | |
globalVersion++; | |
/**@__INLINE__*/ startBatch(); | |
try { | |
for ( | |
let node = this._targets; | |
node !== undefined; | |
node = node._nextTarget | |
) { | |
node._target._notify(); | |
} | |
} finally { | |
endBatch(); | |
} | |
} | |
}, | |
}); | |
function signal<T>(value: T): Signal<T> { | |
return new Signal(value); | |
} | |
function needsToRecompute(target: Computed | Effect): boolean { | |
// 변경된 값에 대한 종속성을 확인하십시오. 종속성 목록은 이미 사용 순서대로 되어 있습니다. | |
// 따라서 여러 종속성에서 값이 변경된 경우 이 시점에서는 처음 사용된 종속성만 다시 평가됩니다. | |
for ( | |
let node = target._sources; | |
node !== undefined; | |
node = node._nextSource | |
) { | |
// 새로 고침 전이나 후에 종속성의 새 버전이 있거나 | |
// 종속성에 전혀 새로 고침을 차단하는 요소가 있는 경우(예: 종속성 주기) 다시 계산해야 합니다. | |
if ( | |
node._source._version !== node._version || | |
!node._source._refresh() || | |
node._source._version !== node._version | |
) { | |
return true; | |
} | |
} | |
// 마지막 재계산 이후 종속 항목 중 값이 변경되지 않은 경우 | |
// 다시 계산할 필요가 없습니다. | |
return false; | |
} | |
function prepareSources(target: Computed | Effect) { | |
/** | |
* 1. 현재 소스를 모두 재사용 가능한 노드로 표시합니다(버전: -1). | |
* 2. 현재 노드가 다른 컨텍스트에서 사용되고 있는 경우 롤백 노드를 설정합니다. | |
* 3. 이중 연결 목록의 꼬리에 'target._sources'를 지정합니다. 예: | |
* | |
* { undefined <- A <-> B <-> C -> undefined } | |
* ↑ ↑ | |
* │ └──────┐ | |
* target._sources = A; (node is head) │ | |
* ↓ │ | |
* target._sources = C; (node is tail) ─┘ | |
*/ | |
for ( | |
let node = target._sources; | |
node !== undefined; | |
node = node._nextSource | |
) { | |
const rollbackNode = node._source._node; | |
if (rollbackNode !== undefined) { | |
node._rollbackNode = rollbackNode; | |
} | |
node._source._node = node; | |
node._version = -1; | |
if (node._nextSource === undefined) { | |
target._sources = node; | |
break; | |
} | |
} | |
} | |
function cleanupSources(target: Computed | Effect) { | |
let node = target._sources; | |
let head = undefined; | |
/** | |
* 이 시점에 'target._sources'는 이중 링크드 리스트의 꼬리를 가리킵니다. | |
* 기존 소스 + 신규 소스를 모두 사용 순서대로 포함하고 있습니다. | |
* 이전 종속성을 삭제하면서 헤드 노드를 찾을 때까지 뒤로 반복합니다. | |
*/ | |
while (node !== undefined) { | |
const prev = node._prevSource; | |
/** | |
* 노드가 재사용되지 않았습니다. 변경 알림 구독을 취소하고 이중 링크드 리스트에서 노드 자체를 제거합니다. 예: | |
* | |
* { A <-> B <-> C } | |
* ↓ | |
* { A <-> C } | |
*/ | |
if (node._version === -1) { | |
node._source._unsubscribe(node); | |
if (prev !== undefined) { | |
prev._nextSource = node._nextSource; | |
} | |
if (node._nextSource !== undefined) { | |
node._nextSource._prevSource = prev; | |
} | |
} else { | |
/** | |
* 새 헤드는 이중 링크드 리스트 에서 제거/구독 취소되지 않은 마지막 노드입니다. 예: | |
* | |
* { A <-> B <-> C } | |
* ↑ ↑ ↑ | |
* │ │ └ head = node | |
* │ └ head = node | |
* └ head = node | |
*/ | |
head = node; | |
} | |
node._source._node = node._rollbackNode; | |
if (node._rollbackNode !== undefined) { | |
node._rollbackNode = undefined; | |
} | |
node = prev; | |
} | |
target._sources = head; | |
} | |
declare class Computed<T = any> extends Signal<T> { | |
_compute: () => T; | |
_sources?: Node; | |
_globalVersion: number; | |
_flags: number; | |
constructor(compute: () => T); | |
_notify(): void; | |
get value(): T; | |
} | |
function Computed(this: Computed, compute: () => unknown) { | |
Signal.call(this, undefined); | |
this._compute = compute; | |
this._sources = undefined; | |
this._globalVersion = globalVersion - 1; | |
this._flags = OUTDATED; | |
} | |
Computed.prototype = new Signal() as Computed; | |
Computed.prototype._refresh = function () { | |
this._flags &= ~NOTIFIED; | |
if (this._flags & RUNNING) { | |
return false; | |
} | |
// 이 computed signal가 종속성(TRACKING 플래그 설정)의 업데이트를 구독하고 | |
// 그 중 어느 것도 변경 사항을 알리지 않은 경우(OUTDATED 플래그가 설정되지 않음) 계산된 값은 변경될 수 없습니다. | |
if ((this._flags & (OUTDATED | TRACKING)) === TRACKING) { | |
return true; | |
} | |
this._flags &= ~OUTDATED; | |
if (this._globalVersion === globalVersion) { | |
return true; | |
} | |
this._globalVersion = globalVersion; | |
// 값 변경에 대한 종속성을 확인하기 전에 이 computed signal이 실행 중임을 표시하여 | |
// RUNNING 플래그를 사용하여 순환 종속성을 확인할 수 있도록 합니다. | |
this._flags |= RUNNING; | |
if (this._version > 0 && !needsToRecompute(this)) { | |
this._flags &= ~RUNNING; | |
return true; | |
} | |
const prevContext = evalContext; | |
try { | |
prepareSources(this); | |
evalContext = this; | |
const value = this._compute(); | |
if ( | |
this._flags & HAS_ERROR || | |
this._value !== value || | |
this._version === 0 | |
) { | |
this._value = value; | |
this._flags &= ~HAS_ERROR; | |
this._version++; | |
} | |
} catch (err) { | |
this._value = err; | |
this._flags |= HAS_ERROR; | |
this._version++; | |
} | |
evalContext = prevContext; | |
cleanupSources(this); | |
this._flags &= ~RUNNING; | |
return true; | |
}; | |
Computed.prototype._subscribe = function (node) { | |
if (this._targets === undefined) { | |
this._flags |= OUTDATED | TRACKING; | |
// computed signal은 첫 번째 구독자를 얻을 때 종속성을 느리게 구독합니다. | |
for ( | |
let node = this._sources; | |
node !== undefined; | |
node = node._nextSource | |
) { | |
node._source._subscribe(node); | |
} | |
} | |
Signal.prototype._subscribe.call(this, node); | |
}; | |
Computed.prototype._unsubscribe = function (node) { | |
// computed signal에 구독자가 있는 경우에만 구독 취소 단계를 실행하세요. | |
if (this._targets !== undefined) { | |
Signal.prototype._unsubscribe.call(this, node); | |
// computed signal은 마지막 구독자를 잃으면 종속성 구독을 취소합니다. | |
// 이를 통해 computed signal의 참조 해제된 하위 그래프를 가비지 수집할 수 있습니다. | |
if (this._targets === undefined) { | |
this._flags &= ~TRACKING; | |
for ( | |
let node = this._sources; | |
node !== undefined; | |
node = node._nextSource | |
) { | |
node._source._unsubscribe(node); | |
} | |
} | |
} | |
}; | |
Computed.prototype._notify = function () { | |
if (!(this._flags & NOTIFIED)) { | |
this._flags |= OUTDATED | NOTIFIED; | |
for ( | |
let node = this._targets; | |
node !== undefined; | |
node = node._nextTarget | |
) { | |
node._target._notify(); | |
} | |
} | |
}; | |
Computed.prototype.peek = function () { | |
if (!this._refresh()) { | |
cycleDetected(); | |
} | |
if (this._flags & HAS_ERROR) { | |
throw this._value; | |
} | |
return this._value; | |
}; | |
Object.defineProperty(Computed.prototype, "value", { | |
get() { | |
if (this._flags & RUNNING) { | |
cycleDetected(); | |
} | |
const node = addDependency(this); | |
this._refresh(); | |
if (node !== undefined) { | |
node._version = this._version; | |
} | |
if (this._flags & HAS_ERROR) { | |
throw this._value; | |
} | |
return this._value; | |
}, | |
}); | |
interface ReadonlySignal<T = any> extends Signal<T> { | |
readonly value: T; | |
} | |
function computed<T>(compute: () => T): ReadonlySignal<T> { | |
return new Computed(compute); | |
} | |
function cleanupEffect(effect: Effect) { | |
const cleanup = effect._cleanup; | |
effect._cleanup = undefined; | |
if (typeof cleanup === "function") { | |
/*@__INLINE__**/ startBatch(); | |
// 항상 컨텍스트 외부에서 정리 함수를 실행하세요. | |
const prevContext = evalContext; | |
evalContext = undefined; | |
try { | |
cleanup(); | |
} catch (err) { | |
effect._flags &= ~RUNNING; | |
effect._flags |= DISPOSED; | |
disposeEffect(effect); | |
throw err; | |
} finally { | |
evalContext = prevContext; | |
endBatch(); | |
} | |
} | |
} | |
function disposeEffect(effect: Effect) { | |
for ( | |
let node = effect._sources; | |
node !== undefined; | |
node = node._nextSource | |
) { | |
node._source._unsubscribe(node); | |
} | |
effect._compute = undefined; | |
effect._sources = undefined; | |
cleanupEffect(effect); | |
} | |
function endEffect(this: Effect, prevContext?: Computed | Effect) { | |
if (evalContext !== this) { | |
throw new Error("Out-of-order effect"); | |
} | |
cleanupSources(this); | |
evalContext = prevContext; | |
this._flags &= ~RUNNING; | |
if (this._flags & DISPOSED) { | |
disposeEffect(this); | |
} | |
endBatch(); | |
} | |
type EffectCleanup = () => unknown; | |
declare class Effect { | |
_compute?: () => unknown | EffectCleanup; | |
_cleanup?: () => unknown; | |
_sources?: Node; | |
_nextBatchedEffect?: Effect; | |
_flags: number; | |
constructor(compute: () => unknown | EffectCleanup); | |
_callback(): void; | |
_start(): () => void; | |
_notify(): void; | |
_dispose(): void; | |
} | |
function Effect(this: Effect, compute: () => unknown | EffectCleanup) { | |
this._compute = compute; | |
this._cleanup = undefined; | |
this._sources = undefined; | |
this._nextBatchedEffect = undefined; | |
this._flags = TRACKING; | |
} | |
Effect.prototype._callback = function () { | |
const finish = this._start(); | |
try { | |
if (this._flags & DISPOSED) return; | |
if (this._compute === undefined) return; | |
const cleanup = this._compute(); | |
if (typeof cleanup === "function") { | |
this._cleanup = cleanup as EffectCleanup; | |
} | |
} finally { | |
finish(); | |
} | |
}; | |
Effect.prototype._start = function () { | |
if (this._flags & RUNNING) { | |
cycleDetected(); | |
} | |
this._flags |= RUNNING; | |
this._flags &= ~DISPOSED; | |
cleanupEffect(this); | |
prepareSources(this); | |
/*@__INLINE__**/ startBatch(); | |
const prevContext = evalContext; | |
evalContext = this; | |
return endEffect.bind(this, prevContext); | |
}; | |
Effect.prototype._notify = function () { | |
if (!(this._flags & NOTIFIED)) { | |
this._flags |= NOTIFIED; | |
this._nextBatchedEffect = batchedEffect; | |
batchedEffect = this; | |
} | |
}; | |
Effect.prototype._dispose = function () { | |
this._flags |= DISPOSED; | |
if (!(this._flags & RUNNING)) { | |
disposeEffect(this); | |
} | |
}; | |
function effect(compute: () => unknown | EffectCleanup): () => void { | |
const effect = new Effect(compute); | |
try { | |
effect._callback(); | |
} catch (err) { | |
effect._dispose(); | |
throw err; | |
} | |
// `() => effect._dispose()`와 같은 래퍼 대신 바인딩된 함수를 반환합니다. | |
// 바인딩된 함수도 마찬가지로 빠르고 메모리를 훨씬 적게 차지하는 것처럼 보이기 때문입니다. | |
return effect._dispose.bind(effect); | |
} | |
export { | |
signal, | |
computed, | |
effect, | |
batch, | |
Signal, | |
ReadonlySignal, | |
untracked, | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment