[황준일]Vanilla Javascript로 상태 관리 시스템 만들기를 읽은 후기

jaemin·2023년 5월 1일
0

observer pattern에 대한 이해

Obserber pattern은 객체의 상태 변화를 관찰하는 옵저버들의 목록을 객체에 등록해서 상태 변화가 발생할 때마다 메서드 등을 통해 객체가 직접 목록의 각 옵저버들에게 통지하도록 하는 디자인 패턴.

Object.defineProperty()

객체에 새로운 속성을 직접 정의하거나 이미 존재하는 속성을 수정한 후, 해당 객체를 반환한다.

Object.defineProperty(obj, attributeName, descriptor)

매개변수

  • obj : 속성을 정의할 객체
  • attributeName : 새로 정의하거나 수정하려는 속성의 이름 또는 Symbol
  • descriptor : 새로 정의하거나 수정하려는 속성에 접근하거나 추가하려는 동작

descriptor

descriptor는 데이터 서술자(data descriptors)접근자 서술사(access descriptors) 두 가지 형식을 취할 수 있다. 데이터 서술자는 값을 가지는 속성을 기술할 때 사용한다. 접근자 서술자는 getter-setter 함수를 한 쌍으로 가지는 속성을 기술할 때 사용한다. 서술자는 두 유형 중 하나여야 하며, 두 유형을 동시에 나타낼 순 없다.
데이터 서술자와 접근자 서술자는 모두 객체로, configurable, enumerable 이 두 가지 키를 선택적으로 가질 수 있다.
configurable : 속성 값을 변경할 수 있고, 객체 삭제 가능하면 true. 기본 값은 false.
enumerable : 객체 속성 열거 시 노출되면 true. 기본 값은 false.

  • 데이터 서술자
    데이터 서술자가 가질 수 있는 키는 다음과 같다.(선택적으로)
    value : 속성에 연관된 값. javascript 리터럴 값은 모두 사용할 수 있다. 기본 값은 undefined.
    writable : 할당 연산자로 속성의 값을 바꿀 수 있으면 true이다. 기본 값은 false.

  • 접근자 서술자
    접근자 서술자가 가질 수 있는 키는 다음과 같다.(선택적으로)
    get : 속성의 접근자로 사용할 함수.
    set : 속성의 설정자로 사용할 함수.

value또는 writableget 또는 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와 동일함

vanilla javascript로 observer pattern 만들기

블로그의 코드를 잘게 뜯어서 이해해보자.

  • 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.definePropertgetter를 사용하여 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());
    },
};
//...

Flux Pattern

DOM에서 observer pattern을 붙여 간단한 store를 만들어 사용할 수 있다. 여기에 Flux pattern을 붙인다면 ReduxVuex가 된다.

Flux의 가장 큰 특징은 단방향 데이터 흐름이다.

Action
어떤 이벤트가 발생했을 때 그 이벤트에 대한 정보를 가진 액션 객체를 만들어 Dispatcher에게 전달한다.

Dispatcher
들어오는 Action 객체를 받아 데이터를 어떻게 바꿔줄 것인지, 어떤 행동을 할지 결정하는 곳.

Store
데이터, 즉 상태를 가지고 있는 곳.

View
우리가 알고 있는 보여주는 View의 역할.

데이터의 흐름은 다음과 같다.

  • Dispatcher => Store
  • Store => View
  • View => Action
  • Action => Dispatcher

Vuex

  • actions, mutations, state를 묶어서 store라고 보면 된다.
  • state를 변화시킬 수 있는 것은 오직 mutations다.
  • actions는 backend api를 가져온 다음에 mutations를 이용하여 데이터를 변경한다.
  • state가 변경되면 state를 사용 중인 컴포넌트를 업데이트한다.

Redux 만들기에서 frozenState를 반환하는 이유

  • 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로만 상태를 변경할 수 있게 했다.

Observableobserver를 사용할 때 고려해야 할 것들

1.최적화

  1. 상태가 변경되어 render를 해야한다. 그런데 변경된 상태가 이전 상태와 값이 똑같은 경우는 어떻게 해야할까?
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로 변환되지 않는다. 이런 경우 추가적인 검사 로직이 필요하다.

동일한 Set의 리렌더링 방지하기

new Set()을 JSON.stringify로 변환하면 모든 Set은 {}로 변환되어 모두 동일하게 판별된다. 동일한 Set인지 판별하기 위해 함수를 따로 만들었다.

  • src/observer.js
    동일한 set인지 확인하는 함수
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도 이와 같은 방식으로 방어 로직을 짤 수 있을 것 같다.

2. 연속으로 변경되는 상태

여러 가지 상태가 연속적으로 변경되는 경우에는 어떻게 해야 좋을까?

state.a = 1;
state.b = 2;

단순히 console.log를 찍는 경우라면 상관없지만, 브라우저에 DOM으로 렌더링 되는 경우라면 이야기가 다르다. 이럴 때 requestAnimationFramedebounce를 이용하여 한 프레임에 한 번만 렌더링 되도록 만들어 주어야 한다.

requestAnimationFrame이란?

requestAnimationFramesetTimeout처럼 동작하지만 mozilla에 의해 개선된 function이다.

requestAnimationFrame은 다음과 같은 순서로 동작한다.

  1. 브라우저에게 수행하기를 원하는 애니메이션을 알린다.
  2. 다음 렌더링(다음 리페인트)가 진행되기 전에 해당 애니메이션을 업데이트 하는 함수를 호출하게 한다.
    requestAnimationFrame은 리렌더링 되기 전에 실행할 콜백함수를 인자로 받는다.

requestAnimationFrame() 함수는 보통 1초에 60회 호출되지만, 일반적으로 대부분의 브라우저에서는 W3C의 권장사항에 따라 호출 횟수가 디스플레이 주사율과 일치하게 된다.

쉽게 말해서, requestAnimationFrame은 1프레임에 1회 호출된다. 보통 1초에 60프레임이다.

setTimeout을 쓰지 않는 이유

그렇다면 익숙한 setTimeout을 사용하지 않는 이유는 뭘까. 비슷하게 동작하는데 requestAnimationFrame을 사용하는 이유에 대해서 알아 보았다.

setTimeoutrequestAnimationFrame의 가장 큰 차이점은 다음과 같다.
우선 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

  • 브라우저에서 애니메이션을 최적화할 수 있다.
  • 비활성 탭의 애니메이션이 중지되므로 CPU를 보다 효율적으로 사용할 수 있다.
  • 배터리 친화적이다.

Optimize Javascript execution에서는 자바스크립트 실행 최적화를 위해 아래와 같이 권장한다.
1. 시각적 업데이트를 위해 setTimeout 또는 setInterval을 사용하지 말 것. 대신 항상 requestAnimationFrame을 사용할 것.
2. 오래 실행되는 JavaScript를 기본 스레드에서 Web Workers로 이동할 것.
3. 마이크로 작업을 사용하여 여러 프레임에 걸쳐 DOM을 변경할 것.
4. Chrome DevTools의 타임라인 및 JavaScript 프로파일러를 사용하여 JavaScript의 영향을 평가할 것.

적용하기

requestAnimationFramedebounce를 사용하여 직접 적용해보자.

  • 어떻게 동작하는지 먼저 이해하기.
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프레임 당 한 번만 렌더링 되기 때문에 최적화에 도움이 된다.

3.Proxy

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;
    },
  });

}

Proxy란?

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
profile
프론트엔드 개발자가 되기 위해 공부 중입니다.

0개의 댓글