원문: https://dio.la/article/lexical-state-updates
이 포스팅은 원문 저자의 허락을 받아 번역 후 게시되었습니다.
Lexical1은 유명한 JavaScript 텍스트 에디터 프레임워크다.
상태(state) 업데이트는 Lexical의 핵심 개념 중 하나인데, Lexical에서 매우 독특한 방식으로 다뤄진다. 이 글에선 Lexical에서 상태 업데이트가 어떻게 동작하는 지 밝혀본다.
얻게 될 것: Lexical 사용자는 Lexical을 더 깊게 이해해서 더 좋은 텍스트 에디터를 만들 수 있음. Lexical 사용자가 아니라도 유용한 Javascript 테크닉을 하나 알게 됨.
업데이트란?
Lexical 상태(EditorState
)에는 중요한 데이터가 두 가지 있다: 현재 노드 트리 그리고 selection2 상태. 에디터 인스턴스(LexicalEditor
)는 항상 “활성화된(active)” 상태(EditorState
)를 갖는다.
이 상태가 에디터의 진실의 원천(the source of truth)이다. (DOM이 원천이 아님.) DOM은 항상 상태로부터 계산되며 그 반대로 계산되지 않는다.
좀 더 그럴듯하게 말해보면:
view (DOM)는 상태(
EditorState
)의 함수다.
상태가 업데이트되면, Lexical은 이전 상태와 다음 상태 사이의 차이를 계산해서 DOM에 반영한다. 이 과정을 “재조정(reconciliation)“이라고 한다.
간단하게 만들어본 예시인데, 이전 상태에선 두 개의 paragraph가 있지만, 다음 상태에는 하나만 있는 상황이다. Lexical은 이 둘을 비교해서 두 번째 paragraph가 없어져야 하는 것을 알게되고, DOM에서 제거한다.
실제로는 많이 다르겠지만, React같은 프로젝트의 “가상 DOM 비교(diffing)” 개념과 비슷해 보인다.3
Lexical은 여러 상황에서 내부적으로 업데이트를 트리거한다. 몇 가지 예시:
- 사용자가 에디터에 텍스트를 입력할 때 처럼 이벤트에 응답할 때.
LexicalEditor.focus
가 호출될 때, selection 상태를 올바르게 설정하려고.- 유틸함수
markAllNodesAsDirty
같은 내부 로직에서.
LexicalEditor.update()
콜백 함수(“업데이트 함수”)를 LexicalEditor.update
메서드에 넘겨서 직접 상태를 업데이트할 수도 있다. 공식 문서의 예시:
tsTry
editor .update (() => {// EditorState에서 RootNode를 가져옴constroot =$getRoot ();// 새로운 ParagraphNode 생성constparagraphNode =$createParagraphNode ();// 새로운 TextNode 생성consttextNode =$createTextNode ("Hello world");// TextNode를 paragraphNode에 추가paragraphNode .append (textNode );// 마지막으로, paragraph를 root에 추가root .append (paragraphNode );});
tsTry
editor .update (() => {// EditorState에서 RootNode를 가져옴constroot =$getRoot ();// 새로운 ParagraphNode 생성constparagraphNode =$createParagraphNode ();// 새로운 TextNode 생성consttextNode =$createTextNode ("Hello world");// TextNode를 paragraphNode에 추가paragraphNode .append (textNode );// 마지막으로, paragraph를 root에 추가root .append (paragraphNode );});
프로그래밍 방식으로(programatically) 상태를 업데이트하는 건 Lexical에서 가장 중요한 기능 중 하나다.
예를 들어서 “이미지”나 “비디오” 같은 노드를 “추가”해주는 버튼을 만들려면, 클릭 시 상태를 업데이트 하도록 만들어야 한다.*
* 실제로 구현한다면 “추가” 커맨드를 디스패치(dispatch)하는 방식일 텐데, 이 때는 커맨드 리스너가 상태를 업데이트하게 된다.
위의 코드 예시를 보면 몇 가지 의문점이 생긴다:
리턴하는 것도 없고,
setState
같은 함수를 호출하지도 않는데요.업데이트한 상태는 어떻게 저장되는 거에요?
앱에 로드된 다른 에디터 인스턴스가 있을 수 있잖아요.
$getRoot()
는 루트 노드를 어떤 에디터에서 가져올지 어떻게 아는 거죠?…이거 PHP에요? jQuery인가?!?
$
메서드들은 뭐에요?
곧 답을 알려 줄 테니, 차근차근 시작해보자. (스포일러: 모두 렉시컬 스코프와 관련이 있음)
업데이트의 라이프 사이클
쉽게 생각하면 상태는 “업데이트”되거나 “변경”되는 것이다. 개념적으로는 그렇게 되는게 맞긴하다.
실제로는 에디터 인스턴스의 상태는 업데이트되는 게 아니라 새로운 상태로 대체된다. 에디터 상태는 기본적으로는 불변(immutable)하며 바로 변경(alter)할 수 없다.
업데이트는 대략 다음 단계로 일어난다:
- 현재 상태가 복제되고, 이 복제된 상태가 “대기 상태(pending state)“가 됨
- 대기 상태가 ✔ 변경 가능(mutable)하게 됨
- 업데이트 함수가 대기 상태에 변경 사항을 적용함
- 업데이트 함수가 완료되면, 대기 상태가 🔒 잠김 (변경 불가능(immutable)한 상태가 됨)
- 재조정(reconciliation)이 일어나고, 결과적인 변경 사항이 DOM에 반영됨
- 대기 상태가 현재 상태로 설정되면서, 실질적으로 대체 하게 됨
이걸로는 답이 되기 좀 모자라지만, 여기서 힌트는 얻을 수 있다: 업데이트 함수가 실행되는 동안은 대기 상태를 변경할 수 있다는 것.
이제 대기 상태를 변경하는 게 가능하다는 건 알겠는데, 어떤 식으로 그게 가능한 건지는 아직 모르겠다.
업데이트의 해부
Lexical에서 업데이트를 실행하는 메서드를 간단한 버전으로 만들어 보면서, 어떤 일이 일어나는 지 명확하게 이해해보자. LexicalEditor.update()
는 내부적으로 이 메서드를 호출한다.
기본적인 구현은 다음과 같다:
tsTry
functionupdateEditor (editor :LexicalEditor ,updateFn : () => void) {// 현재 상태를 가져옴constcurrentEditorState =editor ._editorState ;// 현재 상태를 복제해서 대기 상태로 설정editor ._pendingEditorState =cloneEditorState (currentEditorState );// 변경 가능하게 설정isReadOnlyMode = false;// 업데이트 함수 실행updateFn ();// 변경 불가능하게 설정isReadOnlyMode = true;// 대기 중인 업데이트 커밋commitPendingUpdates (editor );}
tsTry
functionupdateEditor (editor :LexicalEditor ,updateFn : () => void) {// 현재 상태를 가져옴constcurrentEditorState =editor ._editorState ;// 현재 상태를 복제해서 대기 상태로 설정editor ._pendingEditorState =cloneEditorState (currentEditorState );// 변경 가능하게 설정isReadOnlyMode = false;// 업데이트 함수 실행updateFn ();// 변경 불가능하게 설정isReadOnlyMode = true;// 대기 중인 업데이트 커밋commitPendingUpdates (editor );}
두 가지 단계로 나눠볼 수 있다:
- 업데이트 단계: 상태가 복제되고 업데이트 함수가 실행된다.
- 커밋 단계: 재조정이 일어나고, 새로운 상태로 이전 상태를 대체한다.
재조정은 대기 상태와 현재 상태를 비교해서, 필요한 변경 사항을 DOM에 적용하는 과정이다.
두 번째 단계는 전부 commitPendingUpdates
의 내부에서 이루어진다. 이 함수를 정말 간단하게 요약해보면 다음과 같다:
tsTry
functioncommitPendingUpdates (editor :LexicalEditor ) {// 현재 상태를 가져옴constcurrentState =editor ._editorState ;// 대기 상태를 가져옴constpendingState =editor ._pendingEditorState !;// DOM 재조정reconcileDOM (currentState ,pendingState );// 대기 상태를 현재 상태로 설정editor ._editorState =pendingState ;}
tsTry
functioncommitPendingUpdates (editor :LexicalEditor ) {// 현재 상태를 가져옴constcurrentState =editor ._editorState ;// 대기 상태를 가져옴constpendingState =editor ._pendingEditorState !;// DOM 재조정reconcileDOM (currentState ,pendingState );// 대기 상태를 현재 상태로 설정editor ._editorState =pendingState ;}
렉시컬 스코프
위에서 한 가지 주목해야 할 건 isReadOnlyMode
인데, 얘는 어디서 온걸까?
간단하다. 모듈 수준(module level)에서 정의되어 있다. LexicalUpdates.ts
모듈에는 isReadOnlyMode
변수 선언과 updateEditor
함수가 모두 들어있다:
tsTry
letisReadOnlyMode = true;export functionupdateEditor (editor :LexicalEditor ,updateFn : () => void) {// ...isReadOnlyMode = false;// ...isReadOnlyMode = true;}
tsTry
letisReadOnlyMode = true;export functionupdateEditor (editor :LexicalEditor ,updateFn : () => void) {// ...isReadOnlyMode = false;// ...isReadOnlyMode = true;}
이게 유용한건 렉시컬 스코프 덕분이다.
재밌는 사실!
Lexical은 원래 “Outline”이라는 이름이었는데, 중간에 “Lexical”로 이름을 바꿨다. JavaScript 렉시컬 스코프를 참고한 것 이다.
이 아이디어를 떠올린 Dominic Gannaway가 말하길:
“이게 바로 이 프로젝트를 Lexical이라고 부르는 이유입니다. 두 가지 의미가 있는 거죠. Lexical의 핵심적인 동작들이 렉시컬 스코프를 통해 이루어진다는 것, 또 Lexical이라는 단어의 뜻처럼 언어와 어휘에 대한 프로젝트인 점을 의미하는 거에요.”
렉시컬 스코프 개념이 익숙하지 않다면, 이어서 읽기 전에 이걸 먼저 배우고 오자.
특히 Lexical은 모듈 스코프를 활용한다. 변수가 선언된 모듈 어디서든 그 변수에 접근할 수 있다는 점을 활용한다는 의미다.
그런데 흥미로운 점이 하나 있다. Lexical을 사용해봤다면, LexicalEditor.update()
콜백 외부에서 node.insertAfter()
같은 메서드를 호출하려다가 다음과 같은 에러를 봤을지도 모른다:
tsTry
// 이건 잘 동작함editor .update (() => {node .insertAfter (paragraph );});// 이건 실패함node .insertAfter (paragraph );// error: "Cannot use method in read-only mode"
tsTry
// 이건 잘 동작함editor .update (() => {node .insertAfter (paragraph );});// 이건 실패함node .insertAfter (paragraph );// error: "Cannot use method in read-only mode"
insertAfter
는 읽기전용 모드(read-only mode) 상태를 어떻게 아는 걸까? insertAfter
는 isReadOnlyMode
와 같은 파일에서 선언된 적 없다. 어떻게 동작하는 걸까?
이제 정답에 거의 다 왔다.
업데이트는 동기적으로 동작한다
updateEditor
를 다시 한 번 보자. 이 함수는 동기적(synchronous)이므로 우리는 연산 순서를 확신할 수 있다. 좀 더 설명해보면:
tsTry
letisReadOnlyMode = true;export functionupdateEditor (editor :LexicalEditor ,updateFn : () => void) {console .log (isReadOnlyMode ); // > trueisReadOnlyMode = false;console .log (isReadOnlyMode ); // > falseisReadOnlyMode = true;console .log (isReadOnlyMode ); // > true}
tsTry
letisReadOnlyMode = true;export functionupdateEditor (editor :LexicalEditor ,updateFn : () => void) {console .log (isReadOnlyMode ); // > trueisReadOnlyMode = false;console .log (isReadOnlyMode ); // > falseisReadOnlyMode = true;console .log (isReadOnlyMode ); // > true}
단순하지만 중요한 부분! 여기서 핵심은 업데이트 콜백이 호출될 때까지는 isReadOnlyMode
가 false
라는 것. 대기 상태가 실제로 변경 가능한 상태라는 의미다:
tsTry
letisReadOnlyMode = true;export functionupdateEditor (editor :LexicalEditor ,updateFn : () => void) {isReadOnlyMode = false;updateFn (); // 이 함수가 실행되는 동안, isReadOnlyMode가 false 임isReadOnlyMode = true;}
tsTry
letisReadOnlyMode = true;export functionupdateEditor (editor :LexicalEditor ,updateFn : () => void) {isReadOnlyMode = false;updateFn (); // 이 함수가 실행되는 동안, isReadOnlyMode가 false 임isReadOnlyMode = true;}
모듈 스코프는 외부에서 접근할 수 있다
모듈은 export
문을 통해 외부로 값을 노출할 수 있다. 이게 결국 모듈의 목적인 것!
tsTry
// @filename: module-one.tsconstprivateConst = "my constant";letprivateLet = "my variable";functionprivateFunction () {// ...}export constpublicConst = "my public constant";export letpublicLet = "my public variable";export functionpublicFunction () {// ...}
tsTry
// @filename: module-one.tsconstprivateConst = "my constant";letprivateLet = "my variable";functionprivateFunction () {// ...}export constpublicConst = "my public constant";export letpublicLet = "my public variable";export functionpublicFunction () {// ...}
공개(public) 모듈은 이런 식으로 임포트(import)할 수 있다:
tsTry
// @filename: module-two.tsimport {publicConst ,publicLet ,publicFunction } from "./module-one";
tsTry
// @filename: module-two.tsimport {publicConst ,publicLet ,publicFunction } from "./module-one";
내보내지 않은(not exported) 값은 “비공개(private)“다. 다른 모듈에서 임포트할 수 없다.
tsTry
// @filename: module-two.tsModule '"./module-one"' declares 'privateConst' locally, but it is not exported.
Module '"./module-one"' declares 'privateLet' locally, but it is not exported.
Module '"./module-one"' declares 'privateFunction' locally, but it is not exported.2459
2459
2459Module '"./module-one"' declares 'privateConst' locally, but it is not exported.
Module '"./module-one"' declares 'privateLet' locally, but it is not exported.
Module '"./module-one"' declares 'privateFunction' locally, but it is not exported.import {privateConst ,privateLet ,privateFunction } from "./module-one";
tsTry
// @filename: module-two.tsModule '"./module-one"' declares 'privateConst' locally, but it is not exported.
Module '"./module-one"' declares 'privateLet' locally, but it is not exported.
Module '"./module-one"' declares 'privateFunction' locally, but it is not exported.2459
2459
2459Module '"./module-one"' declares 'privateConst' locally, but it is not exported.
Module '"./module-one"' declares 'privateLet' locally, but it is not exported.
Module '"./module-one"' declares 'privateFunction' locally, but it is not exported.import {privateConst ,privateLet ,privateFunction } from "./module-one";
isReadOnlyMode
변수를 다시 살펴보자. 이 변수는 내보내지 않았기 때문에 다른 모듈에서 직접 사용할 수 없다:
tsTry
// @filename: LexicalUpdates.tsModule '"./LexicalUpdates"' declares 'isReadOnlyMode' locally, but it is not exported.2459Module '"./LexicalUpdates"' declares 'isReadOnlyMode' locally, but it is not exported.letisReadOnlyMode = true; // "export" 키워드가 없다!export functionupdateEditor () {}// @filename: a-module-somewhere.tsimport {isReadOnlyMode } from "./LexicalUpdates";function$mutateStateSomehow () {if (isReadOnlyMode ) {throw newError ("Cannot use method in read-only mode");}}
tsTry
// @filename: LexicalUpdates.tsModule '"./LexicalUpdates"' declares 'isReadOnlyMode' locally, but it is not exported.2459Module '"./LexicalUpdates"' declares 'isReadOnlyMode' locally, but it is not exported.letisReadOnlyMode = true; // "export" 키워드가 없다!export functionupdateEditor () {}// @filename: a-module-somewhere.tsimport {isReadOnlyMode } from "./LexicalUpdates";function$mutateStateSomehow () {if (isReadOnlyMode ) {throw newError ("Cannot use method in read-only mode");}}
그래도 모듈 내부에서 이 변수를 반환하는 함수를 만드는 건 가능하다. Lexical에서 사용하는게 이런 패턴이다:
tsTry
// @filename: LexicalUpdates.tsletisReadOnlyMode = true;export functionisCurrentlyReadOnlyMode (): boolean {returnisReadOnlyMode ;}// @filename: a-module-somewhere.tsimport {isCurrentlyReadOnlyMode } from "./LexicalUpdates";function$mutateStateSomehow () {if (isCurrentlyReadOnlyMode ()) {throw newError ("Cannot use method in read-only mode");}}
tsTry
// @filename: LexicalUpdates.tsletisReadOnlyMode = true;export functionisCurrentlyReadOnlyMode (): boolean {returnisReadOnlyMode ;}// @filename: a-module-somewhere.tsimport {isCurrentlyReadOnlyMode } from "./LexicalUpdates";function$mutateStateSomehow () {if (isCurrentlyReadOnlyMode ()) {throw newError ("Cannot use method in read-only mode");}}
Lexical에서는 아예 isReadOnlyMode
값을 확인하고 에러를 던져주는 용도로 함수를 만들어 내보낸다:
tsTry
// @filename: LexicalUpdates.tsletisReadOnlyMode = true;export functionerrorOnReadOnly (): void {if (isReadOnlyMode ) {throw newError ("Cannot use method in read-only mode.");}}// @filename: a-module-somewhere.tsimport {errorOnReadOnly } from "./LexicalUpdates";function$mutateStateSomehow () {errorOnReadOnly ();}
tsTry
// @filename: LexicalUpdates.tsletisReadOnlyMode = true;export functionerrorOnReadOnly (): void {if (isReadOnlyMode ) {throw newError ("Cannot use method in read-only mode.");}}// @filename: a-module-somewhere.tsimport {errorOnReadOnly } from "./LexicalUpdates";function$mutateStateSomehow () {errorOnReadOnly ();}
모듈 스코프 + 동기적 실행 = 성공적
이제 지금까지 나온 개념들을 연결해보자!
이전 섹션에서 만든 $mutateStateSomehow
함수는 호출되면, LexicalUpdates.ts
모듈의 isReadOnlyMode
값을 확인한다.
이 함수는 updateEditor
내부에서 (보통 LexicalEditor.update()
을 통해서) 호출해야 한다. 예시:
tsTry
editor .update (() => {$mutateStateSomehow ();});
tsTry
editor .update (() => {$mutateStateSomehow ();});
우리가 전달한 업데이트 콜백은 동기적으로 실행되고, isReadOnlyMode
가 true
가 되기 바로 전에 실행되는 것을 기억하자:
tsTry
letisReadOnlyMode = true;export functionupdateEditor (editor :LexicalEditor ,updateFn : () => void) {isReadOnlyMode = false;updateFn (); // $mutateStateSomehow는 여기서 실행 됨isReadOnlyMode = true;}
tsTry
letisReadOnlyMode = true;export functionupdateEditor (editor :LexicalEditor ,updateFn : () => void) {isReadOnlyMode = false;updateFn (); // $mutateStateSomehow는 여기서 실행 됨isReadOnlyMode = true;}
즉, $mutateStateSomehow
가 호출될 때는 isReadOnlyMode
가 false
일 것이고, 에러가 발생하지 않을 것이다.
이제 Lexical이 이런 (동기적 실행과 모듈 스코프의)동작 방식을 어떻게 활용하는 지 보일 것이다.
일종의 “전역(global) 상태”를 만들 때 사용하고 있다. 정확히는 에디터가 특정 시점에 읽기전용 모드인지를 확인할 때 인데, 에디터 상태를 변경하려는 함수들이 이 방식으로 isReadOnlyMode
상태를 확인하게 된다.
이제 마지막 질문에 답을 할 수 있다:
이
$
메서드들은 뭐에요?
$
접두사는LexicalUpdate.ts
의 렉시컬 스코프에 의존하는 함수를 의미하는 Lexical의 컨벤션입니다. 따라서LexicalEditor.update()
(또는EditorState.read()
!) 콜백에서만 호출할 수 있습니다.
Lexical 패키지에는 $
함수들이 많다. 예를 들어, lexical
패키지에서 내보내는 $
함수들은 다음과 같다:
tsTry
import {$ } from "lexical";
tsTry
import {$ } from "lexical";
Lexical로 작업할 때, 직접 만든 함수에 이 컨벤션을 사용하는 것도 좋다. $
함수를 호출하는 함수를 만들면, 그 함수도 $
로 접두사를 붙인다고 생각하면 된다.4
활성 에디터와 상태
Lexical은 어떤 에디터와 에디터 상태가 활성(active) 중 인지 (현재 작업 대상인지) 찾을 때도 동일한 테크닉을 사용한다.
tsTry
letisReadOnlyMode = true;letactiveEditorState : null |EditorState = null;letactiveEditor : null |LexicalEditor = null;export functionisCurrentlyReadOnlyMode (): boolean {returnisReadOnlyMode ;}export functiongetActiveEditorState ():EditorState {if (activeEditorState === null) {throw newError ("Can only be used in the callback of" +"editor.update() or editorState.read()");}returnactiveEditorState ;}export functiongetActiveEditor ():LexicalEditor {if (activeEditor === null) {throw newError ("Can only be used in the callback of editor.update()");}returnactiveEditor ;}export functionupdateEditor (editor :LexicalEditor ,updateFn : () => void) {constcurrentEditorState =editor ._editorState ;editor ._pendingEditorState =cloneEditorState (currentEditorState );// 렉시컬 스코프의 변수들에 값을 설정isReadOnlyMode = false;activeEditor =editor ;activeEditorState =editor ._pendingEditorState ;// 업데이트 콜백을 실행함updateFn ();// 렉시컬 스코프의 변수들을 초기화isReadOnlyMode = true;activeEditor = null;activeEditorState = null;// 대기 중인 업데이트를 커밋commitPendingUpdates (editor );}
tsTry
letisReadOnlyMode = true;letactiveEditorState : null |EditorState = null;letactiveEditor : null |LexicalEditor = null;export functionisCurrentlyReadOnlyMode (): boolean {returnisReadOnlyMode ;}export functiongetActiveEditorState ():EditorState {if (activeEditorState === null) {throw newError ("Can only be used in the callback of" +"editor.update() or editorState.read()");}returnactiveEditorState ;}export functiongetActiveEditor ():LexicalEditor {if (activeEditor === null) {throw newError ("Can only be used in the callback of editor.update()");}returnactiveEditor ;}export functionupdateEditor (editor :LexicalEditor ,updateFn : () => void) {constcurrentEditorState =editor ._editorState ;editor ._pendingEditorState =cloneEditorState (currentEditorState );// 렉시컬 스코프의 변수들에 값을 설정isReadOnlyMode = false;activeEditor =editor ;activeEditorState =editor ._pendingEditorState ;// 업데이트 콜백을 실행함updateFn ();// 렉시컬 스코프의 변수들을 초기화isReadOnlyMode = true;activeEditor = null;activeEditorState = null;// 대기 중인 업데이트를 커밋commitPendingUpdates (editor );}
남은 질문들도 답을 해보자:
업데이트한 상태는 어떻게 저장되는 거에요?
대기 상태는
activeEditorState
에 담아둡니다. 이 상태는LexicalEditor.update()
콜백이 실행되는 동안 변경 가능(mutable)하고 수정할 수 있습니다. 나중에commitPendingUpdates
가 현재 상태를 대기 상태로 대체하면서, 실질적으로 “저장”합니다.
$getRoot()
는 루트 노드를 어떤 에디터에서 가져올지 어떻게 아는 거죠?
LexicalEditor.update()
가 호출될 때 에디터 인스턴스는activeEditor
에 저장됩니다. 이렇게 하면$getRoot()
같은 함수가 (getActiveEditor()
를 통해) 그 인스턴스에 접근할 수 있게 됩니다.
EditorState.read()
상태를 변경하지 않고 읽기만 하고 싶은 경우도 있다.
모듈 스코프에 의존하기 때문에, updateEditor
처럼 렉시컬 스코프를 설정해줄 readEditorState
함수가 필요하다. 대충 이런 함수다:
tsTry
export functionreadEditorState <V >(editorState :EditorState ,callbackFn : () =>V ):V {constpreviousActiveEditorState =activeEditorState ;constpreviousReadOnlyMode =isReadOnlyMode ;constpreviousActiveEditor =activeEditor ;activeEditorState =editorState ;isReadOnlyMode = true;activeEditor = null;constresult =callbackFn ();activeEditorState =previousActiveEditorState ;isReadOnlyMode =previousReadOnlyMode ;activeEditor =previousActiveEditor ;returnresult ;}
tsTry
export functionreadEditorState <V >(editorState :EditorState ,callbackFn : () =>V ):V {constpreviousActiveEditorState =activeEditorState ;constpreviousReadOnlyMode =isReadOnlyMode ;constpreviousActiveEditor =activeEditor ;activeEditorState =editorState ;isReadOnlyMode = true;activeEditor = null;constresult =callbackFn ();activeEditorState =previousActiveEditorState ;isReadOnlyMode =previousReadOnlyMode ;activeEditor =previousActiveEditor ;returnresult ;}
위 코드는 쉽게 이해하기위해 간단하게 만든 버전이고, 실제 함수는 조금 다른 패턴으로 구현되어 있다. 이에 대한 글:
커밋 스케줄링과 배칭
업데이트의 커밋 단계(commitPendingUpdates
)는 기본적으로 마이크로태스크(microtask)로 스케줄링된다. 즉, 다른 동기적인 코드의 실행이 모두 끝나야 커밋이 일어난다.
연속해서 여러 업데이트를 동기적으로 실행하면, (현재 틱이 끝날 때) 결과적인 상태가 한 번에 커밋된다.
중첩된(nested) 업데이트에서도 마찬가지. 중첩된 업데이트는 다른 업데이트의 실행 중에 만들어진 업데이트를 말한다. 즉, LexicalEditor.update()
콜백에서 다른 LexicalEditor.update()
을 호출하는 경우다.
예시를 보자:
tsTry
editor .update (() => {}); // A// > 업데이트 A가 실행됨editor .update (() => {}); // B// > 업데이트 B가 실행됨editor .update (() => {}); // C// > 업데이트 C가 실행됨// (다른 동기적인 코드 실행...)// --- 동기적인 코드 실행이 끝남 ---// > 업데이트 A, B, C 커밋
tsTry
editor .update (() => {}); // A// > 업데이트 A가 실행됨editor .update (() => {}); // B// > 업데이트 B가 실행됨editor .update (() => {}); // C// > 업데이트 C가 실행됨// (다른 동기적인 코드 실행...)// --- 동기적인 코드 실행이 끝남 ---// > 업데이트 A, B, C 커밋
이전 섹션들에서 배운 업데이트 아키텍처 덕분에 이게 가능하다.
여러 업데이트가 동기적으로 일어나면, 모두 동일한 대기 상태를 연달아 업데이트하게 된다. 첫 번째 업데이트의 출력이 두 번째 업데이트의 입력이 되는 개념이다.
모든 업데이트가 끝난 후에, 대기 상태가 커밋되고, 에디터 상태가 대체된다. 재조정은 무거워 질 수 있는 프로세스이므로 이런 식(배칭)의 성능 최적화가 중요하다.
개별적인(discrete) 업데이트
보통은 커밋 스케줄링과 배칭이 우리가 원하는게 맞지만, 방해가 되는 경우도 있다.
예를 들어 서버 환경에서 에디터 상태를 조작하고, 그 상태를 데이터베이스에 저장하는 경우를 생각해보자.
tsTry
editor .update (() => {// 상태를 조작함...});saveToDatabase (editor .getEditorState ().toJSON ());
tsTry
editor .update (() => {// 상태를 조작함...});saveToDatabase (editor .getEditorState ().toJSON ());
이 코드는 기대한 대로 동작하지 않는다. saveToDatabase
호출이 끝난 다음에 상태가 커밋되기 때문이다. 업데이트 이전의 상태가 저장될 것이다.
다행히도, discrete: true
옵션5으로 LexicalEditor.update
가 업데이트를 즉시 커밋하도록 강제 할 수 있다.6
tsTry
editor .update (() => {// 상태를 조작함...},{discrete : true });saveToDatabase (editor .getEditorState ().toJSON ());
tsTry
editor .update (() => {// 상태를 조작함...},{discrete : true });saveToDatabase (editor .getEditorState ().toJSON ());
이제 제대로 동작한다!
결론
Lexical에서 업데이트가 어떻게 동작하는지 이해하니까 작업할 때 도움이 많이 됐습니다.
처음 Lexical을 접했을 땐, $
함수 접두사 같은 것도 무섭고 막연했습니다. 업데이트가 언제, 어떻게 적용되는지 확신할 수 없어서 눈앞이 캄캄했어요.
한번 이해하고 나니까, 버그가 나와도 원인을 찾고 수정하는 일이 훨씬 쉬웠습니다.
이 글이 업데이트를 이해하는 데 도움이 되었으면 합니다. Lexical에 관한 더 많은 글을 기대해주세요!
보너스! Lexical 메인테이너들에게 받은 피드백:
- “이건 너무 잘썻는데요.” - Dominic Gannaway
- ”정말 멋지네요 Dani! 정리해 줘서 고마워요! (예제 상호작용도 최고임). 제가 보기엔 매우 정확하고 좋은 글입니다.” - John Flockton
역자 주
Footnotes
-
이 글에서 라이브러리을 지칭하는 경우 “Lexical”, Javascript의 문법적 개념을 지칭하는 경우 “렉시컬”로 표기합니다. ↩
-
Lexical 에디터에서 선택된 범위나 노드 등의 데이터를 위한 객체 - Lexical 문서 ↩
-
참고 - React 레거시 문서 ↩
-
이 옵션이 추가된 PR - @facebook#3119 ↩
-
onUpdate
콜백을 이용한 다른 해결방법 - @facebook#5664 (comment) ↩