paint-brush
프런트엔드 프레임워크를 작성할 때 내가 다르게 한 네 가지~에 의해@fpereiro
320 판독값
320 판독값

프런트엔드 프레임워크를 작성할 때 내가 다르게 한 네 가지

~에 의해 fpereiro17m2024/08/27
Read on Terminal Reader

너무 오래; 읽다

프런트엔드 프레임워크의 맥락에서 아마도 들어보지 못했을 네 가지 아이디어: - HTML 템플릿을 위한 객체 리터럴. - 경로를 통해 주소 지정 가능한 글로벌 스토어. - 모든 뮤테이션을 처리하는 이벤트 및 응답자. - DOM을 업데이트하는 텍스트 diff 알고리즘.
featured image - 프런트엔드 프레임워크를 작성할 때 내가 다르게 한 네 가지
fpereiro HackerNoon profile picture
0-item
1-item

2013년에 저는 웹 애플리케이션을 개발하기 위한 최소한의 도구 세트를 구축하기 시작했습니다. 아마도 그 과정에서 나온 가장 좋은 것은 2,000줄의 코드로 작성된 클라이언트 측 순수 JS 프런트엔드 프레임워크인 gotoB 였을 것입니다.


저는 매우 성공적인 프런트엔드 프레임워크의 저자들이 쓴 흥미로운 기사를 읽으며 토끼굴에 빠진 후 이 기사를 쓰게 되었습니다.


제가 이 기사에서 들떠있는 이유는 이들이 만드는 것의 이면에 있는 아이디어의 진화에 대해 이야기하기 때문입니다. 구현은 단지 이를 현실로 만드는 방법일 뿐이고, 논의되는 유일한 기능은 아이디어 그 자체를 나타내는 데 매우 필수적인 기능들입니다.


지금까지 gotoB에서 나온 것 중 가장 흥미로운 측면은 그것을 만드는 데 따른 어려움에 직면한 결과로 개발된 아이디어입니다. 여기서 다루고 싶은 것이 바로 그것입니다.


저는 프레임워크를 처음부터 만들었고, 미니멀리즘과 내부적 일관성을 모두 달성하려고 노력했기 때문에 대부분의 프레임워크가 같은 문제를 해결하는 방식과는 다른 방식으로 4가지 문제를 해결했다고 생각합니다.


이 네 가지 아이디어는 지금 제가 여러분과 공유하고 싶은 것입니다. 저는 여러분이 제 도구를 사용하도록 설득하기 위해 이렇게 하는 것이 아닙니다(물론 사용하셔도 좋습니다!). 오히려 여러분이 아이디어 자체에 관심이 있기를 바랍니다.

아이디어 1: 템플릿을 해결하기 위한 객체 리터럴

모든 웹 애플리케이션은 애플리케이션의 상태에 따라 즉석에서 마크업(HTML)을 생성해야 합니다.


이것은 예를 들어 가장 잘 설명됩니다. 매우 간단한 할 일 목록 애플리케이션에서 상태는 할 일 목록이 될 수 있습니다: ['Item 1', 'Item 2'] . 애플리케이션을 작성하고 있기 때문에(정적 페이지가 아니라) 할 일 목록은 변경될 수 있어야 합니다.


상태가 변경되므로 애플리케이션의 UI를 만드는 HTML은 상태에 따라 변경해야 합니다. 예를 들어, 할 일을 표시하려면 다음 HTML을 사용할 수 있습니다.

 <ul> <li>Item 1</li> <li>Item 2</li> </ul>


상태가 변경되고 세 번째 항목이 추가되면 상태는 다음과 같이 표시됩니다. ['Item 1', 'Item 2', 'Item 3'] ; 그러면 HTML은 다음과 같이 표시됩니다.

 <ul> <li>Item 1</li> <li>Item 2</li> <li>Item 3</li> </ul>


애플리케이션의 상태에 따라 HTML을 생성하는 문제는 일반적으로 템플릿 언어를 통해 해결되는데, 템플릿 언어는 프로그래밍 언어 구성 요소(변수, 조건문 및 루프)를 가상 HTML에 삽입하고 이를 실제 HTML로 확장합니다.


예를 들어, 다양한 템플릿 도구에서 이 작업을 수행하는 두 가지 방법은 다음과 같습니다.

 // Assume that `todos` is defined and equal to ['Item 1', 'Item 2', 'Item 3'] // Moustache <ul> {{#todos}} <li>{{.}}</li> {{/todos}} </ul> // JSX <ul> {todos.map((item, index) => ( <li key={index}>{item}</li> ))} </ul>


저는 HTML에 논리를 가져온 이런 구문을 좋아하지 않았습니다. 템플릿을 만드는 데는 프로그래밍이 필요하다는 것을 깨닫고, 별도의 구문을 피하고 싶어서, 대신 객체 리터럴을 사용하여 HTML을 js로 가져오기로 했습니다. 그래서 저는 간단히 HTML을 객체 리터럴로 모델링할 수 있었습니다.

 ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ['li', 'Item 3'], ]]


그런 다음 반복을 사용하여 목록을 생성하려면 다음과 같이 간단히 작성할 수 있습니다.

 ['ul', items.map ((item) => ['li', item])]


그리고 이 객체 리터럴을 HTML로 변환하는 함수를 사용합니다. 이런 식으로 모든 템플릿은 템플릿 언어나 트랜스파일 없이 JS에서 수행할 수 있습니다. 저는 HTML을 나타내는 이러한 배열을 설명하기 위해 liths 라는 이름을 사용합니다.


제가 아는 한, 다른 JS 프레임워크는 이런 방식으로 템플릿을 접근하지 않습니다. 저는 조금 파고들어서 JSONML 을 찾았는데, JSON 객체(JS 객체 리터럴과 거의 동일)에서 HTML을 표현하는 데 거의 동일한 구조를 사용하지만, 이를 중심으로 구축된 프레임워크는 찾지 못했습니다.


MithrilHyperapp은 제가 사용한 접근 방식과 매우 비슷하지만 여전히 각 요소에 대해 함수 호출을 사용합니다.

 // Mithril m("ul", [ m("li", "Item 1"), m("li", "Item 2") ]) // hyperapp h("ul", [ h("li", "Item 1"), h("li", "Item 2") ])


객체 리터럴을 사용하는 접근 방식은 HTML에서는 효과적이었으므로 이를 CSS로 확장하여 이제는 모든 CSS도 객체 리터럴을 통해 생성합니다.


어떤 이유로 JSX를 트랜스파일하거나 템플릿 언어를 사용할 수 없는 환경에 있고, 문자열을 연결하고 싶지 않은 경우 대신 이 방법을 사용할 수 있습니다.


Mithril/Hyperapp 방식이 제 방식보다 나은지 잘 모르겠습니다. 저는 lith를 나타내는 긴 객체 리터럴을 쓸 때 가끔 어딘가에 쉼표를 잊어버리고, 가끔은 찾기가 까다로울 수 있다는 것을 알게 되었습니다. 그 외에는 불평할 것이 없습니다. 그리고 HTML의 표현이 1) data와 2) in JS라는 점이 마음에 듭니다. 이 표현은 실제로 가상 DOM으로 기능할 수 있는데, 아이디어 #4를 살펴보면 알게 될 것입니다.


보너스 세부 정보: 객체 리터럴에서 HTML을 생성하려면 다음 두 가지 문제만 해결하면 됩니다.

  1. 문자열을 엔티티화합니다(예: 특수 문자를 이스케이프합니다).
  2. 어떤 태그를 닫아야 하고 어떤 태그를 닫지 않아야 하는지 알아보세요.

아이디어 2: 모든 애플리케이션 상태를 보관하기 위한 경로를 통해 주소 지정이 가능한 글로벌 스토어

저는 컴포넌트를 좋아한 적이 없습니다. 컴포넌트를 중심으로 애플리케이션을 구조화하려면 컴포넌트에 속한 데이터를 컴포넌트 자체 내부에 배치해야 합니다. 이렇게 하면 해당 데이터를 애플리케이션의 다른 부분과 공유하는 것이 어렵거나 불가능할 수도 있습니다.


제가 작업한 모든 프로젝트에서 저는 항상 애플리케이션 상태의 일부를 서로 꽤 멀리 떨어진 구성 요소 간에 공유해야 한다는 것을 발견했습니다. 전형적인 예는 사용자 이름입니다. 계정 섹션과 헤더에 필요할 수 있습니다. 그렇다면 사용자 이름은 어디에 속할까요?


그래서 저는 일찍이 간단한 데이터 객체( {} )를 만들고 거기에 모든 상태를 넣기로 결정했습니다. 저는 그것을 store 라고 불렀습니다. store는 앱의 모든 부분에 대한 상태를 보관하므로 모든 구성 요소에서 사용할 수 있습니다.


이런 접근 방식은 2013~2015년 당시에는 어느 정도 이단적으로 여겨졌지만, 그 이후로 널리 퍼지고 지배적인 위치를 차지하게 되었습니다.


아직도 꽤 참신하다고 생각하는 것은 내가 경로를 사용하여 스토어 내부의 모든 값에 액세스한다는 것입니다. 예를 들어, 스토어가 다음과 같은 경우:

 { user: { firstName: 'foo' lastName: 'bar' } }


B.get ('user', 'lastName') 작성하여 (예를 들어) lastName 접근하기 위해 경로를 사용할 수 있습니다. 보시다시피, ['user', 'lastName']'bar' 에 대한 경로 입니다. B.get 은 스토어에 접근하여 함수에 전달한 경로로 표시된 특정 부분을 반환하는 함수입니다.


위의 것과 대조적으로, 반응형 속성에 액세스하는 표준적인 방법은 JS 변수를 통해 참조하는 것입니다. 예를 들어:

 // Svelte let { firstName, lastName } = $props(); firstName = 'foo'; lastName = 'bar'; // Knockout const firstName = ko.observable('foo'); const lastName = ko.observable('bar'); // mobx class UserStore { firstName = 'foo'; lastName = 'bar'; constructor() { makeAutoObservable(this); } } const userStore = new UserStore(); // SolidJS const [firstName, setFirstName] = createSignal('foo'); const [lastName, setLastName] = createSignal('bar');


하지만 이렇게 하려면 firstNamelastName (또는 userStore )에 대한 참조를 필요한 모든 곳에 유지해야 합니다. 내가 사용하는 접근 방식은 스토어(전역이며 어디에서나 사용 가능)에만 액세스하면 되고 JS 변수를 정의하지 않고도 세분화된 액세스가 가능합니다.


Immutable.js와 Firebase Realtime Database는 내가 한 것과 훨씬 더 가까운 일을 하지만, 별도의 객체에서 작업하고 있습니다. 하지만 잠재적으로 모든 것을 한곳에 저장하여 세부적으로 주소 지정할 수 있습니다.

 // Immutable.js let store = Map({ user: Map({ firstName: 'foo', lastName: 'bar' }) }); const firstName = store.getIn(['user', 'firstName']); // 'foo' // Firebase const db = firebase.database(); db.ref('user').set({ firstName: 'foo', lastName: 'bar' }); db.ref('user/firstName').once('value').then(snapshot => { const firstName = snapshot.val(); // 'foo' });


내 데이터를 경로를 통해 세부적으로 액세스할 수 있는 전역적으로 액세스 가능한 저장소에 두는 것은 내가 매우 유용하다고 생각하는 패턴입니다. const [count, setCount] = ... 또는 이와 유사한 것을 쓸 때마다 중복되는 느낌이 듭니다. 필요할 때마다 count 또는 setCount 선언하고 전달하지 않고도 B.get ('count') 실행할 수 있다는 것을 알고 있습니다.

아이디어 3: 모든 변화는 이벤트를 통해 표현됩니다.

아이디어 #2(경로를 통해 접근 가능한 글로벌 스토어)가 컴포넌트에서 데이터를 해방한다면, 아이디어 #3은 제가 컴포넌트에서 코드를 해방한 방법입니다. 저에게는 이것이 이 글에서 가장 흥미로운 아이디어입니다. 시작합니다!


우리의 상태는 정의상 변경 가능한 데이터입니다(불변성을 사용하는 사람들에게는 여전히 이 주장이 유효합니다. 이전 버전의 상태 스냅샷을 보관하더라도 최신 버전의 상태가 변경되기를 원합니다). 어떻게 상태를 변경합니까?


저는 이벤트로 가기로 했습니다. 저는 이미 매장으로 가는 경로를 가지고 있었기 때문에 이벤트는 단순히 동사( set , add 또는 rem 과 같은)와 경로의 조합일 수 있습니다. 그래서 user.firstName 업데이트하고 싶다면 다음과 같이 쓸 수 있습니다.

 B.call ('set', ['user', 'firstName'], 'Foo')


이것은 분명히 다음과 같이 쓰는 것보다 더 장황합니다.

 user.firstName = 'Foo';


하지만 user.firstName 의 변경에 대응 하는 코드를 쓸 수 있었습니다. 그리고 이것이 중요한 아이디어입니다. UI에는 상태의 다른 부분에 종속된 다른 부분이 있습니다. 예를 들어, 다음과 같은 종속성이 있을 수 있습니다.

  • 헤더: usercurrentView 에 따라 다름
  • 계정 섹션: user 에 따라 다름
  • 할 일 목록: items 에 따라 다름


내가 마주한 큰 질문은 다음과 같았습니다. user 변경될 때 헤더와 계정 섹션을 업데이트하는 방법은 무엇이고, items 변경될 때는 어떻게 업데이트하지 않을까요? 그리고 updateHeader 또는 updateAccountSection 과 같은 특정 호출을 하지 않고도 이러한 종속성을 관리하는 방법은 무엇일까요? 이러한 유형의 특정 호출은 가장 유지 관리하기 어려운 "jQuery 프로그래밍"을 나타냅니다.


나에게는 다음과 같은 것이 더 나은 생각처럼 보였습니다.

 B.respond ('set', [['user'], ['currentView']], function (user, currentView) { // Update the header }); B.respond ('set', ['user'], function (user) { // Update the account section }); B.respond ('set', ['items'], function (items) { // Update the todo list });


따라서 user 에 대한 set 이벤트가 호출되면 이벤트 시스템은 해당 변경에 관심이 있는 모든 뷰(헤더 및 계정 섹션)에 알림을 보내고 다른 뷰(할 일 목록)는 그대로 둡니다. B.respond응답자 (일반적으로 "이벤트 리스너" 또는 "반응"이라고 함)를 등록하는 데 사용하는 함수입니다. 응답자는 전역이며 어떤 구성 요소에도 바인딩되지 않는다는 점에 유의하세요. 그러나 특정 경로에서 set 이벤트만 수신합니다.


이제, change 이벤트는 처음에 어떻게 호출되나요? 제가 한 방법은 다음과 같습니다.

 B.respond ('set', '*', function () { // Assume that `path` is the path on which set was called B.call ('change', path); });


조금 단순화했지만, gotoB에서는 기본적으로 이런 식으로 작동합니다.


이벤트 시스템을 단순한 함수 호출보다 더 강력하게 만드는 것은 이벤트 호출이 0, 1 또는 여러 개의 코드를 실행할 수 있는 반면 함수 호출은 항상 정확히 하나의 함수를 호출한다는 것입니다 . 위의 예에서 B.call ('set', ['user', 'firstName'], 'Foo'); 호출하면 헤더를 변경하는 코드와 계정 뷰를 변경하는 코드의 두 가지가 실행됩니다. firstName 업데이트하는 호출은 누가 이것을 듣고 있는지 "관심이 없습니다". 그저 자신의 일을 하고 응답자가 변경 사항을 선택하도록 합니다.


이벤트는 매우 강력해서, 제 경험상, 계산된 값과 반응을 대체할 수 있습니다. 다시 말해, 애플리케이션에서 발생해야 하는 모든 변경 사항을 표현하는 데 사용할 수 있습니다.


계산된 값은 이벤트 응답기로 표현할 수 있습니다. 예를 들어 fullName 을 계산하고 스토어에서 사용하지 않으려면 다음을 수행할 수 있습니다.

 B.respond ('set', 'user', function () { var user = B.get ('user'); var fullName = user.firstName + ' ' + user.lastName; // Do something with `fullName` here. });


마찬가지로, 반응은 응답자로 표현될 수 있습니다. 다음을 고려하세요.

 B.respond ('set', 'user', function () { var user = B.get ('user'); var fullName = user.firstName + ' ' + user.lastName; document.getElementById ('header').innerHTML = '<h1>Hello, ' + fullName + '</h1>'; });


문자열을 연결하여 HTML을 생성하는 당혹스러운 과정을 잠시 무시한다면, 위에서 보는 것은 "부작용"(이 경우 DOM 업데이트)을 실행하는 응답자입니다.


(참고: 웹 애플리케이션 맥락에서 부작용에 대한 적절한 정의는 무엇일까요? 저는 세 가지로 요약합니다. 1) 애플리케이션 상태 업데이트, 2) DOM 변경, 3) AJAX 호출 전송).


DOM을 업데이트하는 별도의 라이프사이클이 실제로 필요하지 않다는 것을 알게 되었습니다. gotoB에는 일부 헬퍼 함수의 도움을 받아 DOM을 업데이트하는 일부 응답자 함수가 있습니다. 따라서 user 변경되면 이에 의존하는 모든 응답자(또는 더 정확히는 DOM의 일부를 업데이트하는 작업을 맡은 응답자에게 붙인 이름인 view 함수 )가 실행되어 DOM을 업데이트하는 부작용이 발생합니다.


이벤트 시스템을 예측 가능하게 만들어서 응답자 함수를 같은 순서로, 한 번에 하나씩 실행하게 했습니다. 비동기 응답자는 여전히 동기식으로 실행될 수 있으며, 그 "뒤에" 오는 응답자는 그 응답자를 기다립니다.


DOM을 업데이트하지 않고 상태를 업데이트해야 하는 보다 정교한 패턴(일반적으로 성능 목적)은 mset 과 같은 mute 동사를 추가하여 추가할 수 있습니다. mset은 저장소를 수정하지만 응답자를 트리거하지 않습니다. 또한 다시 그리기가 발생한 DOM에서 무언가를 해야 하는 경우 해당 응답자의 우선순위가 낮고 다른 모든 응답자 이후에 실행되도록 간단히 할 수 있습니다.

 B.respond ('set', 'date', {priority: -1000}, function () { var datePicker = document.getElementById ('datepicker'); // Do something with the date picker });


동사와 경로를 사용하는 이벤트 시스템과 특정 이벤트 호출에 의해 매치(실행)되는 글로벌 응답자 집합을 갖는 위의 접근 방식은 또 다른 장점이 있습니다. 모든 이벤트 호출을 목록에 배치할 수 있습니다. 그런 다음 애플리케이션을 디버깅할 때 이 목록을 분석하고 상태의 변경 사항을 추적할 수 있습니다.


프런트엔드의 맥락에서 이벤트와 응답자가 허용하는 내용은 다음과 같습니다.

  • 매우 적은 코드로(단순한 변수 할당보다 조금 더 자세한) 스토어의 일부를 업데이트합니다.
  • DOM의 해당 부분이 의존하는 스토어의 부분에 변경이 있을 때 DOM의 일부가 자동으로 업데이트되도록 합니다.
  • 필요하지 않을 때 DOM의 어떤 부분도 자동 업데이트되지 않도록 합니다.
  • DOM을 업데이트하지 않고도 계산된 값과 반응을 가질 수 있도록 하며, 이를 응답자로 표현합니다.


(내 경험에 따르면) 그들이 허용하지 않는 것은 다음과 같습니다.

  • 라이프사이클 메서드 또는 후크.
  • 관찰 가능한 것.
  • 불변성.
  • 메모하기.


실제로는 모두 이벤트 호출과 응답자일 뿐이고, 일부 응답자는 뷰에만 관심이 있고, 다른 응답자는 다른 작업에 관심이 있습니다. 프레임워크의 모든 내부는 사용자 공간을 사용하고 있을 뿐입니다.


이것이 gotoB에서 어떻게 작동하는지 궁금하다면, 이 자세한 설명을 확인할 수 있습니다.

아이디어 4: DOM을 업데이트하기 위한 텍스트 diff 알고리즘

양방향 데이터 바인딩은 이제 꽤 오래되었다고 들립니다. 하지만 타임머신을 타고 2013년으로 돌아가서 상태가 변경될 때 DOM을 다시 그리는 문제를 처음부터 해결한다면, 무엇이 더 합리적으로 들릴까요?

  • HTML이 변경되면 JS에서 상태를 업데이트합니다. JS에서 상태가 변경되면 HTML을 업데이트합니다.
  • JS의 상태가 변경될 때마다 HTML을 업데이트합니다. HTML이 변경되면 JS의 상태를 업데이트한 다음 HTML을 다시 업데이트하여 JS의 상태와 일치시킵니다.


실제로, 상태에서 DOM으로 단방향으로 데이터가 흐르는 옵션 2는 더 복잡하고 비효율적으로 들립니다.


이제 이것을 매우 구체적으로 만들어 보겠습니다. 초점이 맞춰진 대화형 <input> 또는 <textarea> 의 경우, 모든 사용자 키 입력으로 DOM의 일부를 재생성해야 합니다! 단방향 데이터 흐름을 사용하는 경우, 입력의 모든 변경은 상태의 변경을 트리거하고, 그런 다음 <input> 을 다시 그려서 정확히 일치하도록 합니다.


이는 DOM 업데이트에 대한 매우 높은 기준을 설정합니다. 업데이트는 빠르고 대화형 요소와의 사용자 상호 작용을 방해해서는 안 됩니다. 이는 해결하기 쉬운 문제가 아닙니다.


이제, 왜 상태에서 DOM(JS에서 HTML)으로의 단방향 데이터가 승리했을까요? 추론하기 쉽기 때문입니다. 상태가 변경되면 이 변경이 어디에서 왔는지는 중요하지 않습니다(서버에서 데이터를 가져오는 AJAX 콜백일 수도 있고, 사용자 상호작용일 수도 있고, 타이머일 수도 있음). 상태는 항상 같은 방식으로 변경됩니다(또는 오히려 변형됩니다 ). 그리고 상태에서의 변경은 항상 DOM으로 흐릅니다.


그렇다면 사용자 상호작용을 방해하지 않는 효율적인 방식으로 DOM 업데이트를 수행하려면 어떻게 해야 할까요? 이는 일반적으로 작업을 완료하는 데 필요한 최소한의 DOM 업데이트를 수행하는 것으로 귀결됩니다. 이를 일반적으로 "diffing"이라고 하는데, 이는 이전 구조(기존 DOM)를 가져와 새 구조(상태가 업데이트된 후의 새 DOM)로 변환하는 데 필요한 차이점 목록을 만드는 것이기 때문입니다.


2016년경에 이 문제를 해결하기 시작했을 때, 저는 React가 무엇을 하는지 살펴보는 식으로 부정행위를 했습니다. 그들은 두 개의 트리(DOM은 트리)를 비교하는 일반화된 선형 성능 알고리즘이 없다는 중요한 통찰력을 제공했습니다. 하지만, 완고하게도 저는 여전히 비교를 수행하는 범용 알고리즘을 원했습니다. 제가 React(또는 거의 모든 프레임워크)에서 특히 싫어하는 점은 연속된 요소에 키를 사용해야 한다는 고집입니다.

 function MyList() { const items = ['Item 1', 'Item 2', 'Item 3']; return ( <ul> {items.map((item, index) => ( <li key={index}>{item}</li> ))} </ul> ); }


제 생각에는 key 지시어는 불필요합니다. DOM과 아무런 관련이 없고 프레임워크에 대한 힌트일 뿐이거든요.


그런 다음 나는 평평한 버전의 트리에 텍스트 diff 알고리즘을 시도하는 것에 대해 생각했습니다. 두 트리(내가 가지고 있던 DOM의 오래된 부분과 그것을 대체하고 싶었던 DOM의 새로운 부분)를 평평하게 만들고 그것에 diff (최소한의 편집 세트)를 계산하면 더 적은 단계로 오래된 것에서 새로운 것으로 갈 수 있을까요?


그래서 저는 git diff 실행할 때마다 사용하는 Myers 알고리즘을 가져와서 제 평평해진 트리에 적용했습니다. 예를 들어 설명해 보겠습니다.

 var oldList = ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ]]; var newList = ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ['li', 'Item 3'], ]];


보시다시피, 저는 DOM을 사용하지 않고 아이디어 1에서 본 객체 리터럴 표현을 사용합니다. 이제 목록 끝에 새 <li> 를 추가해야 한다는 것을 알 수 있을 겁니다.


납작해진 나무는 이렇게 보입니다.

 var oldFlattened = ['O ul', 'O li', 'L Item 1', 'C li', 'O li', 'L Item 2', 'C li', 'C ul']; var newFlattened = ['O ul', 'O li', 'L Item 1', 'C li', 'O li', 'L Item 2', 'C li', 'O li', 'L Item 3', 'C li', 'C ul'];


O 는 "열린 태그"를 의미하고, L "리터럴"(이 경우 일부 텍스트)을 의미하고, C "닫는 태그"를 의미합니다. 각 트리는 이제 문자열 목록이고 더 이상 중첩된 배열이 없다는 점에 유의하세요. 이것이 제가 말하는 평면화입니다.


각 요소에 대해 diff를 실행하면(배열의 각 항목을 하나의 단위로 취급) 다음과 같은 결과를 얻습니다.

 var diff = [ ['keep', 'O ul'] ['keep', 'O li'] ['keep', 'L Item 1'] ['keep', 'C li'] ['keep', 'O li'] ['keep', 'L Item 2'] ['keep', 'C li'] ['add', 'O li'] ['add', 'L Item 3'] ['add', 'C li'] ['keep', 'C ul'] ];


아마 추측하셨겠지만, 우리는 목록의 대부분을 유지하고, 목록의 끝에 <li> 추가합니다. 보이는 add 항목은 이것들입니다.


이제 세 번째 <li> 의 텍스트를 Item 3 에서 Item 4 로 변경하고 diff를 실행하면 다음과 같은 결과를 얻습니다.

 var diff = [ ['keep', 'O ul'] ['keep', 'O li'] ['keep', 'L Item 1'] ['keep', 'C li'] ['keep', 'O li'] ['keep', 'L Item 2'] ['keep', 'C li'] ['keep', 'O li'] ['rem', 'L Item 3'] ['add', 'L Item 4'] ['keep', 'C li'] ['keep', 'C ul'] ];


이 접근 방식이 수학적으로 얼마나 비효율적인지는 모르겠지만, 실제로는 꽤 잘 작동했습니다. 이 접근 방식은 많은 차이가 있는 큰 트리를 diff할 때만 성능이 좋지 않습니다. 가끔 그런 일이 발생하면 diff를 중단하고 DOM의 문제가 있는 부분을 완전히 바꾸기 위해 200ms 타임아웃을 사용합니다. 타임아웃을 사용하지 않으면 diff가 완료될 때까지 전체 애플리케이션이 잠시 멈춥니다.


Myers diff를 사용하는 행운의 이점은 삽입보다 삭제를 우선시한다는 것입니다. 즉, 항목 제거와 항목 추가 중에 똑같이 효율적인 선택이 있다면 알고리즘은 먼저 항목을 제거합니다. 실제로 이를 통해 제거된 모든 DOM 요소를 가져와 diff에서 나중에 필요할 경우 재활용할 수 있습니다. 마지막 예에서 마지막 <li> Item 3 에서 Item 4 로 내용을 변경하여 재활용됩니다. 요소를 재활용함으로써(새로운 DOM 요소를 만드는 것이 아니라) 사용자가 DOM이 지속적으로 다시 그려지고 있다는 것을 깨닫지 못할 정도로 성능이 향상됩니다.


DOM에 변경 사항을 적용하는 이 플래트닝 및 디프 메커니즘을 구현하는 것이 얼마나 복잡한지 궁금하다면, 저는 ES5 자바스크립트 500줄로 구현했고, 인터넷 익스플로러 6에서도 실행됩니다. 하지만 인정하건대, 제가 쓴 코드 중에서 가장 어려운 코드였을 겁니다. 고집이 세면 비용이 듭니다.

결론

제가 제시하고 싶었던 네 가지 아이디어입니다! 완전히 독창적이지는 않지만, 어떤 사람들에게는 참신하고 흥미로울 수 있기를 바랍니다. 읽어주셔서 감사합니다!