Obserber pattern은 객체의 상태 변화를 관찰하는 옵저버들의 목록을 객체에 등록해서
상태 변화가 발생할 때마다 메서드 등을 통해 객체가 직접 목록의 각 옵저버들에게 통지하도록 하는 디자인 패턴.
객체에 새로운 속성을 직접 정의하거나 이미 존재하는 속성을 수정한 후, 해당 객체를 반환한다.
Object.defineProperty(obj, attributeName, descriptor)
매개변수
obj
: 속성을 정의할 객체attributeName
: 새로 정의하거나 수정하려는 속성의 이름 또는 Symboldescriptor
: 새로 정의하거나 수정하려는 속성에 접근하거나 추가하려는 동작descriptor는 데이터 서술자(data descriptors)
와 접근자 서술사(access descriptors)
두 가지 형식을 취할 수 있다. 데이터 서술자는 값을 가지는 속성을 기술할 때 사용한다. 접근자 서술자는 getter-setter 함수를 한 쌍으로 가지는 속성을 기술할 때 사용한다. 서술자는 두 유형 중 하나여야 하며, 두 유형을 동시에 나타낼 순 없다.
데이터 서술자와 접근자 서술자는 모두 객체로, configurable
, enumerable
이 두 가지 키를 선택적으로 가질 수 있다.
configurable
: 속성 값을 변경할 수 있고, 객체 삭제 가능하면 true. 기본 값은 false.
enumerable
: 객체 속성 열거 시 노출되면 true. 기본 값은 false.
데이터 서술자
데이터 서술자가 가질 수 있는 키는 다음과 같다.(선택적으로)
value
: 속성에 연관된 값. javascript 리터럴 값은 모두 사용할 수 있다. 기본 값은 undefined.
writable
: 할당 연산자로 속성의 값을 바꿀 수 있으면 true이다. 기본 값은 false.
접근자 서술자
접근자 서술자가 가질 수 있는 키는 다음과 같다.(선택적으로)
get
: 속성의 접근자로 사용할 함수.
set
: 속성의 설정자로 사용할 함수.
value
또는 writable
을 get
또는 set
키와 함께 가지고 있으면 오류 발생.
const obj = {};
Object.defineProperty(obj, 'a', {
value: 37,
writable: true,
enumerable: true,
configurable: true,
});
// 'a' 속성이 obj 객체에 존재하고 값은 37
const bValue = 38;
Object.defineProperty(obj, 'b', {
get() {
console.log(bValue);
return bValue;
},
set(newValue) {
bValue = newValue;
console.log(bValue);
},
enumerable: true,
configurable: true,
});
// 'b' 속성이 obj 객체에 존재하고 값은 38
// obj.b를 재정의 하지 않는 이상(set 하지 않는 이상)
// obj.b의 값은 항상 bValue와 동일함
블로그의 코드를 잘게 뜯어서 이해해보자.
observable
const observable = storeObj => {
Object.keys(obj).forEach(key => {
let _value = obj[key];
const observers = new Set();
Object.defineProperty(storeObj, key, {
get () {
if (currentObserver) observers.add(currentObserver);
return _value;
},
set (value) {
_value = value;
observers.forEach(fn => fn());
}
})
})
return obj;
}
옵저버들의 목록을 저장할 객체 observers
생성한다. 중복으로 생성될 것을 방지하기 위해 Set 객체를 사용해준다.
const observable = storeObj => {
// ...
const observers = new Set();
// ...
};
Object.definePropert
의 getter
를 사용하여 store 객체에 들어있는 상태에 접근할 수 있도록 한다.
const observable = storeObj => {
//...
Object.defineProperty(storeObj, key, {
get() {
return _value;
},
});
// ...
};
옵저버 함수를 생성할 때마다 observers
객체에 등록해주는 로직이 반복되기 때문에 observers
객체에 옵저버 함수를 추가하는 로직을 getter
에 넣는다.
let currnetObserver = null;
const observe = fn => {
// currentObserver라는 변수로 현재 옵저브 함수를 등록 후 호출
currentObserver = fn;
fn();
currentObserver = null;
}
const observable = storeObj => {
// ...
Object.defineProperty(storeObj, key, {
get() {
if (currentObserver) observers.add(currentObserver);
return stateValue;
},
})
// ...
};
setter
에는 파라미터로 새로운 값을 받아 state를 변경해주고 observers
객체의 observer들을 모두 호출해 상태가 변경됐음을 알린다.
//...
const observable = storeObj => {
// ...
get() {... },
set(value) {
_value = value;
observers.forEach(fn => fn());
},
};
//...
DOM에서 observer pattern을 붙여 간단한 store를 만들어 사용할 수 있다. 여기에 Flux pattern
을 붙인다면 Redux
나 Vuex
가 된다.
Flux의 가장 큰 특징은 단방향 데이터 흐름이다.
Action
어떤 이벤트가 발생했을 때 그 이벤트에 대한 정보를 가진 액션 객체를 만들어 Dispatcher
에게 전달한다.
Dispatcher
들어오는 Action
객체를 받아 데이터를 어떻게 바꿔줄 것인지, 어떤 행동을 할지 결정하는 곳.
Store
데이터, 즉 상태를 가지고 있는 곳.
View
우리가 알고 있는 보여주는 View의 역할.
데이터의 흐름은 다음과 같다.
Dispatcher
=> Store
Store
=> View
View
=> Action
Action
=> Dispatcher
src/core/Store.js
const createStore = (reducer) => {
const state = observable(reducer());
const frozenState = {};
Object.keys(state).forEach(key => {
Object.defineProperty(frozenState, key, {
get: () => state[key],
});
});
// ...생략
const getState = () => frozenState;
return { getState, dispatch };
};
실제 state를 반환하는 것이 아닌 frozen state를 만들어 반환하는 이유를 생각해보았다. observable이 반환한 결과인 state는 getter와 setter가 모두 등록된 객체이다. 이 객체를 반환하게 되면 dispatcher 뿐만 아니라 직접적으로 상태를 할당하여 변경할 수 있게 된다. 이를 방지하게 위해 getter만 설정된 frozen state를 반환해 dispatcher로만 상태를 변경할 수 있게 했다.
Observable
과 observer
를 사용할 때 고려해야 할 것들state.a = 1;
state.a = 1;
state.a = 1;
state.a = 1;
이런 경우 다시 렌더링 되지 않도록 방어로직이 필요하다.
export const observable = obj => {
Object.keys(obj).forEach(key => {
let _value = obj[key];
const observers = new Set();
Object.defineProperty(obj, key, {
get() {
// ...
},
set(value) {
// 원시타입, 객체 타입으로 나누어 값이 같은지 검사.
if (_value === value) return;
if (JSON.stringify(_value) ===JSON.stringify(value)) return;
//...
}
})
})
};
그런데, Set, Map, WeekSet, WeekMap 같은 것들은 JSON.stringify
로 변환되지 않는다. 이런 경우 추가적인 검사 로직이 필요하다.
new Set()
을 JSON.stringify로 변환하면 모든 Set은 {}
로 변환되어 모두 동일하게 판별된다. 동일한 Set
인지 판별하기 위해 함수를 따로 만들었다.
src/observer.js
const isSameSet = (set1, set2) => {
if (!(set2 instanceof Set && set1 instanceof Set)) return false;
if (set1.size !== set2.size) return false;
const haveEverySameElement = [...set1].every(element => set2.has(element));
if (haveEverySameElement) return true;
};
const observable = obj => {...};
observable 함수 내에 적용
const observable = obj => {
// ...
return new Proxy(obj, {
get(target, name) {
//...
},
set(target, name, value) {
if (target[name] === value) return true;
if (JSON.stringify(target[name]) === JSON.stringify(value)) return true;
if (isSameSet(target[name], value)) return true;
target[name] = value;
observerMap[name].forEach(fn => fn());
return true;
},
});
};
이렇게 추가하니 앞에 있는
if (JSON.stringify(target[name]) === JSON.stringify(value)) return true;
배열, 객체 검사 로직에 항상 true로 걸리는 문제가 있어, 배열 객체 검사도 하나의 함수로 분리해주었다.
src/observer.js
const isSameObject = (object1, object2) => {
if (object1 instanceof Set || object2 instanceof Set) return false;
if (JSON.stringify(object1) === JSON.stringify(object2)) return true;
};
const observable = obj => {
return new Proxy(obj, {
// ...
set(target, name, obj) {
if (target[name] === value) return true;
if (isSameObject(target[name], value)) return true;
if (isSameSet(target[name], value)) return true;
// ...
}
})
};
Map, WeakSet, WeakMap도 이와 같은 방식으로 방어 로직을 짤 수 있을 것 같다.
여러 가지 상태가 연속적으로 변경되는 경우에는 어떻게 해야 좋을까?
state.a = 1;
state.b = 2;
단순히 console.log를 찍는 경우라면 상관없지만, 브라우저에 DOM으로 렌더링 되는 경우라면 이야기가 다르다. 이럴 때 requestAnimationFrame
과 debounce
를 이용하여 한 프레임에 한 번만 렌더링 되도록 만들어 주어야 한다.
requestAnimationFrame
은 setTimeout
처럼 동작하지만 mozilla에 의해 개선된 function이다.
requestAnimationFrame
은 다음과 같은 순서로 동작한다.
requestAnimationFrame
은 리렌더링 되기 전에 실행할 콜백함수를 인자로 받는다.requestAnimationFrame()
함수는 보통 1초에 60회 호출되지만, 일반적으로 대부분의 브라우저에서는 W3C의 권장사항에 따라 호출 횟수가 디스플레이 주사율과 일치하게 된다.
쉽게 말해서, requestAnimationFrame
은 1프레임에 1회 호출된다. 보통 1초에 60프레임이다.
그렇다면 익숙한 setTimeout
을 사용하지 않는 이유는 뭘까. 비슷하게 동작하는데 requestAnimationFrame
을 사용하는 이유에 대해서 알아 보았다.
setTimeout
과 requestAnimationFrame
의 가장 큰 차이점은 다음과 같다.
우선 setTimeout
은 정확한 시간에 맞춰 콜백 함수를 호출하지 않는다.
const foo = () => console.log('foo');
const bar = () => console.log('bar');
setTimeout(foo, 0);
bar();
이 코드를 실행해보면 foo는 0초 후에 콘솔에 찍히지 않는다. 먼저 setTimeout 함수가 실행되면 콜백 함수 foo를 호출 스케줄링하고 종료되어 콜 스택에서 pop된다. 이후 bar가 호출 되어 콜 스택에 들어감과 동시에 콜백 함수 foo가 태스크 큐에 푸시되는 것이 동시에 일어난다. 이 예제는 지연 시간이 0이지만, 지연 시간이 4ms 이하인 경우 최소 지연 시간이 4ms가 지정된다.
따라서, delay를 0초로 설정했음에도 불구하고 항상 4ms의 지연 시간을 갖는다. 또한, 지연 시간이 지났음에도 현재 콜스택에 실행 중인 함수가 있다면 대기해야 한다.
setTimeout
을 사용하지 않는 이유를 정리하면 다음과 같다.
1. 정확한 시간에 호출되지 않을 수 있다.
2. setTimeout
은 브라우저에서 일어나는 일을 고려하지 않는다.
페이지가 비활성화 되어 있을 때도 CPU를 독차지 하고 있을 수 있다.
3. setTimeout
이 원할 때 화면을 업데이트 한다.
열악한 브라우저의 경우, repaint 하는 동안 setTimeout으로 인해 또 다시 repaint 될 수 있다.
반면, requestAnimationFrame
은 미리 지정된 시간에 repaint 하는 것이 아니라, 브라우저가 다음 repaint 할 때 예약한다. 브라우저의 상황에 맞게 동작을 예약할 수 있는 것이다.
정리하면 requestAnimationFrame
은
Optimize Javascript execution에서는 자바스크립트 실행 최적화를 위해 아래와 같이 권장한다.
1. 시각적 업데이트를 위해 setTimeout 또는 setInterval을 사용하지 말 것. 대신 항상 requestAnimationFrame을 사용할 것.
2. 오래 실행되는 JavaScript를 기본 스레드에서 Web Workers로 이동할 것.
3. 마이크로 작업을 사용하여 여러 프레임에 걸쳐 DOM을 변경할 것.
4. Chrome DevTools의 타임라인 및 JavaScript 프로파일러를 사용하여 JavaScript의 영향을 평가할 것.
requestAnimationFrame
과 debounce
를 사용하여 직접 적용해보자.
const debounceFrame = (callback) => {
let currentCallback = -1;
return () => {
// 이전에 등록된 callback이 있다면 취소
cancelAnimationFrame(currentCallback);
// 1프레임 뒤에 실행되도록 한다.
currentCallback = requestAnimationFrame(callback);
};
};
debounceFrame(() => console.log(1));
debounceFrame(() => console.log(2));
debounceFrame(() => console.log(3));
debounceFrame(() => console.log(4));
debounceFrame(() => console.log(5)); // 이것만 실행된다.
여러 번 debounceFrame 함수가 실행된다는 건 여러 번의 상태가 변경되어 render 함수를 호출되는 경우라고 생각할 수 있다.
여러 번 render 함수가 호출될 때, 하나하나 반영되지 않고 제일 마지막으로 호출된 것만 실행하도록 해야 한다.
const debounceFrame = (callback) => {
let currentCallback = -1;
return () => {
cancelAnimationFrame(currentCallback);
currentCallback = requestAnimationFrame(callback);
};
};
const observe = fn => {
// fn은 렌더를 일으키는 것과 관련있는 함수이다.
currentObserver = debounceFrame(fn);
fn();
currentObserver = null;
};
이렇게 상태가 변경될 때마다 렌더링 되는 것이 아니라, 1프레임 당 한 번만 렌더링 되기 때문에 최적화에 도움이 된다.
Proxy
를 사용하는 것은 성능보다는 가독성에 더 도움이 된다. Proxy
는 이전에 사용하던 Object.defineProperty
를 대신하여 사용할 수 있다.
Object.defineProperty
는 IE를 지원하기 위해 사용하는 API이다. 최신 브라우저에서는 Proxy
를 사용한다면 더 쉽게 Observable
을 만들 수 있다.
const observable = obj => {
const observerMap = {};
return new Proxy(obj, {
get (target, name) {
observerMap[name] = observerMap[name] || new Set();
if (currentObserver) observerMap[name].add(currentObserver)
return target[name];
},
set (target, name, value) {
if (target[name] === value) return true;
if (JSON.stringify(target[name]) === JSON.stringify(value)) return true;
target[name] = value;
observerMap[name].forEach(fn => fn());
return true;
},
});
}
Object.defineProperty
와 비슷하게 한 객체에 대한 기본 작업을 가로채고 재정의하는 프록시를 만들 수 있다. Proxy
는 두 매개변수를 받는다.
target: 프록시할 원본 객체
handler: 가로채는 작업과 가로채는 작업을 재정의하는 방법을 정의하는 객체
사용 예시
const target = {
message1: "hello",
message2: "everyone"
};
const handler2 = {
get(target, prop, receiver) {
return "world";
}
};
const proxy2 = new Proxy(target, handler2);
target
객체의 속성을 가로채는 get()
처리기를 사용했다.
console.log(proxy2.message1); // world
console.log(proxy2.message2); // world