1. 정밀한 데이터 바인딩
정밀한 데이터 바인딩이란 데이터 변화가 뷰에 미치는 영향을 정확히 예측할 수 있어, 추가적인 확인 작업(서브트리 더티 체킹, 서브트리 diff)을 통한 추가 확인이 필요하지 않은 상태를 의미합니다.
애플리케이션 구조를 다음과 같이 2개의 레이어로 나누어 봅시다.
视图层
---
数据层
데이터 바인딩은 데이터 레이어와 뷰 레이어 사이의 연결을 구축하는 것입니다(양방향 데이터 바인딩 시나리오에서는 역방향 연결도 필요합니다). 즉, 데이터에서 뷰로의 매핑 관계인 view = f(data)를 찾아내는 것입니다. 정밀한 데이터 바인딩은 미세한 단위(Fine-grained)로 이루어지며, 원자 단위의 데이터 업데이트는 원자 단위의 뷰 업데이트와 대응되어야 합니다. 예를 들어:
<!-- 视图结构 -->
<div id="app">
<span bind:class="counter % 2 === 0 ? 'even' : 'odd'">{{counter}}</span>
</div>
// 初始数据
app.data = {
counter: 0,
other: {
/*...*/
}
};
<!-- 初始视图 -->
<div id="app">
<span class="even">0</span>
</div>
뷰 구조에서 data.counter에 의존하는 부분은 span의 class와 텍스트 내용 두 곳입니다. 그렇다면 data.counter가 변할 때 이 두 부분만 직접 다시 계산하여 뷰를 업데이트해야 합니다.
// 数据更新
data.counter++;
// 对应的视图更新操作
$span.className = eval("counter % 2 === 0 ? 'even' : 'odd'");
$span.textContent = eval("counter");
<!-- 更新后的视图 -->
<div id="app">
<span class="odd">1</span>
</div>
이러한 뷰 업데이트는 매우 정확합니다. 데이터가 변했음을 감지하면 즉시 해당 데이터에 의존하는 각 표현식을 다시 평가하고 새 값을 뷰 레이어에 동기화합니다. 이러한 수준의 정확한 업데이트를 수행하려면 다음과 같이 미세한 단위의 정밀한 의존성 관계를 미리 찾아내야 합니다.
data.counter 有2处依赖该项数据,分别是
$span.className 关系f=counter % 2 === 0 ? 'even' : 'odd'
$span.textContent 关系f=counter
만약 이러한 정밀한 의존성 관계를 미리 찾아낼 수 없다면 정밀한 업데이트가 불가능하며, 정밀한 데이터 바인딩이라고 할 수 없습니다. 예를 들어 angular는 컴포넌트 수준의 $scope 아래 모든 속성을 다시 계산하고 전후 변화를 비교해야만 뷰의 어느 부분을 업데이트할지 결정할 수 있습니다. react는 컴포넌트 수준에서 하위로 다시 계산을 수행하고 상태 diff를 통해 적절한 뷰 업데이트 작업을 찾아낸 뒤, 실제 DOM 트리에 패치로 적용해야 합니다. 이들은 데이터 변화가 발생하기 전까지 데이터와 뷰의 매핑 관계를 알 수 없으므로 정밀한 데이터 바인딩이 아닙니다.
데이터와 뷰 사이의 의존성 관계를 확인하는 방법이 바로 의존성 수집 과정이며, 이는 정밀한 데이터 바인딩의 전제이자 기초입니다.
2. 의존성 수집
의존성 수집은 컴파일 타임과 런타임의 두 부분으로 나뉩니다. 전자는 정적 검사(코드 스캔)를 통해 의존성을 발견하고, 후자는 코드 조각을 실행하여 런타임 컨텍스트에 따라 의존성 관계를 확정합니다.
컴파일 타임 의존성 수집
코드를 스캔하여 의존성을 발견합니다. 가장 간단한 패턴 매칭(또는 더 강력한 구문 트리 분석)을 예로 들 수 있습니다.
let view = '<span>{{counter}}</span>';
const REGS = {
textContent: /<([^>\s]+).*>\s*{{([^}]*)}}\s*<\/\1>/gm
};
let deps = [];
for (let key in REGS) {
let match = REGS[key].exec(view);
if (match) {
deps.push({
data: match[2],
view: match[1],
rel: key
});
}
}
이를 통해 의존성 관계 deps를 얻습니다.
[{
data: "counter",
rel: "textContent",
view: "span"
}]
이 방식은 상대적으로 간단하지만, 표현식과 같은 복잡한 시나리오에서는 정규식 매칭으로 의존성을 수집하는 것이 비현실적입니다. 예를 들어:
<span bind:class="10 % 2 === 0 ? classA : classB">conditional class</span>
표현식을 지원하는 조건부 시나리오에서는 컴파일 타임에 의존성 관계를 확정할 수 없습니다. 따라서 보통 이러한 기능 지원을 포기하거나, 정밀한 데이터 바인딩을 포기해야 합니다. react는 JSX 템플릿이 임의의 JS 표현식을 지원하는 강력한 기능을 위해 정밀한 데이터 바인딩을 포기하는 쪽을 선택했습니다.
사실 세 번째 선택지가 있습니다. 두 마리 토끼를 모두 잡는 방법입니다.
런타임 의존성 수집
위의 조건부 class 예시처럼 정적 검사로 의존성 관계를 얻을 수 없는 경우, 런타임에 실행 환경을 통해 확정해야 합니다.
위의 예시는 다음과 같습니다.
<span bind:class="getClass()">conditional class</span>
app.getClass = () => 10 % 2 === 0 ? app.data.classA : app.data.classB;
span.className의 데이터 의존성이 classA인지 classB인지 알기 위해서는 표현식을 평가해야 합니다. 즉, app.getClass()를 실행해야 합니다. span.className이 classA에 의존한다는 정보를 얻은 뒤에야, classA가 변할 때 의존성 관계에 따라 span.className을 업데이트할 수 있습니다.
그렇다면 런타임에서 어떻게 의존성을 수집할 것인가?
span의 class 표현식 getClass()를 평가하는 과정에서 data.classA에 접근할 때 data의 getter가 트리거됩니다. 이때 실행 컨텍스트는 app.getClass이므로, data.classA가 span의 class 속성과 연관되어 있으며 그 관계가 f=app.getClass임을 알 수 있습니다.
시뮬레이션 시나리오는 다음과 같습니다.
// view
let spanClassName = {
value: '',
computedKey: 'getClass'
};
// data
let app = {
data: {
classA: 'a',
classB: 'b'
},
getClass() {
return 10 % 2 === 0 ? app.data.classA : app.data.classB;
}
};
먼저 데이터 속성에 getter&setter를 걸어 Subject로 만듭니다.
// attach getter&setter to app.data
for (let key in app.data) {
let value = app.data[key];
Object.defineProperty(app.data, key, {
enumerable: true,
configurable: true,
get() {
console.log(`${key} was accessed`);
if (deps.length === 0) {
console.log(`dep collected`);
deps.push({
data: key,
view: view,
rel: computedKey
});
}
return value;
},
set(newVal) {
value = newVal;
console.log(`${key} changed to ${value}`);
deps.forEach(dep => {
if (dep.data === key) {
console.log(`reeval ${dep.rel} and update view`);
dep.view.value = app[dep.rel]();
}
})
}
})
}
그런 다음 뷰를 초기화하고 표현식을 평가함과 동시에 getter를 트리거하여 의존성을 수집합니다.
// init view
let deps = [];
let view = spanClassName;
let computedKey = view.computedKey;
let initValue = app[computedKey]();
view.value = initValue;
console.log(view);
이때 다음과 같은 출력이 나오며, 런타임에 의존성 수집에 성공했음을 나타냅니다.
classA was accessed
dep collected
Object {value: "a", computedKey: "getClass"}
이어서 데이터를 수정하면 setter가 재평가를 시작하여 뷰를 업데이트합니다.
// update data
app.data.classA = 'newA';
// view updated automaticly
console.log(spanClassName);
다음 로그를 통해 뷰가 자동으로 성공적으로 업데이트되었음을 알 수 있습니다.
classA changed to newA
reeval getClass and update view
classA was accessed
Object {value: "newA", computedKey: "getClass"}
과정 중에 classB에 대한 검사나 평가는 이루어지지 않았습니다. 데이터 업데이트 -> 뷰 업데이트 과정에 불필요한 작업이 없어 매우 정밀합니다.
이러한 동적 의존성 수집 메커니즘 덕분에 템플릿은 임의의 JS 표현식을 지원하면서도 정밀한 데이터 바인딩을 구현할 수 있습니다.
P.S. 물론 위의 구현은 핵심적인 부분일 뿐이며, 런타임 의존성 수집 메커니즘은 최소한 다음 사항들을 고려해야 합니다.
-
하위 의존성(계산된 속성이 다른 계산된 속성에 의존하는 경우)
-
의존성 유지관리(동적 추가/삭제)
동일한 시점에는 반드시 하나의 실행 컨텍스트만 존재합니다(전역 target으로 활용 가능). 하지만 하위 의존성 시나리오에서는 중첩된 실행 컨텍스트가 존재하므로 컨텍스트 스택(targetStack)을 수동으로 관리해야 합니다. 계산된 속성을 평가하기 전에 스택에 넣고(push), 계산이 끝나면 꺼내야(pop) 합니다.
3. 의존성 수집과 캐싱
매우 전형적인 Vue 예시가 있습니다.
<div id="app">
<div>{{myComputed}}</div>
</div>
let flag = 1;
var runs = 0;
var vm = new Vue({
el: "#app",
data: {
myValue: 'x',
myOtherValue: 'y'
},
computed: {
myComputed: function() {
runs++;
console.log("This function was called " + runs + " times");
// update flag
let self = this;
setTimeout(function() {
flag = 2;
console.log('flag changed to ' + flag);
// self.myValue = 'z';
}, 2000)
if (flag == 1)
return this['my' + 'Value']
else
return this['my' + 'Other' + 'Value']
}
}
})
2초 후에 flag = 2로 변경되지만 myComputed는 자동으로 다시 계산되지 않으며 뷰도 변하지 않습니다.
내부적으로 myComputed를 캐싱하고 있어 flag를 바꿔도 캐시된 값을 사용하는 것처럼 보이지만, 실제로는 런타임 의존성 수집 메커니즘에 의해 결정되는 것이며 캐싱 메커니즘과는 무관합니다. 다음과 같은 두 가지 해결책을 쉽게 찾을 수 있습니다.
-
flag를data안에 넣어 반응형 데이터로 만듭니다. -
의존하는 데이터를 업데이트하여(
self.myValue = 'z') 재평가를 트리거합니다.
런타임 의존성 수집 관점에서 보면, myComputed를 처음 계산할 때(초기 뷰 계산 시) 다음과 같은 의존성 관계를 얻습니다.
$div.textContent - myComputed - myValue
이 관계는 한 번 확정되면 다시는 변하지 않습니다. 따라서 myValue가 변하지 않는 한 myComputed를 다시 계산하지 않습니다. 그래서 myValue를 바꿔서 재평가를 트리거하는 해결책이 나온 것입니다.
반면, flag의 변화가 뷰에 영향을 준다면 아예 flag를 myComputed의 데이터 의존성으로 삼으면 됩니다. 이것이 flag를 data에 넣는 이유입니다.
P.S. 캐시는 실제로 존재합니다. 값을 할당할 때 setter에서 더티 체킹을 수행하며, 새 값이 캐시된 값과 완전히 동일하면 의존 항목의 재계산을 트리거하지 않습니다. 따라서 self.myValue = self.myValue와 같은 해결책은 통하지 않습니다.
아직 댓글이 없습니다