티스토리 뷰

728x90
SMALL

마법을 부리기 전 코드

마법을 부리는 코드를 이해하기 위해서는 마법을 부리기전의 코드를 먼저 이해해야한다.

store

abstract class Store<Snapshot> {
  public listeners = new Set<() => void>();
  public snapshot = {} as Snapshot;

  public addListener(listener: () => void) {
    this.listeners.add(listener);
  }

  public removeListener(listener: () => void) {
    this.listeners.delete(listener);
  }

  public getSnapshot() {
    return this.snapshot;
  }

  public publish() {
    this.listeners.forEach((listener) => listener());
  }
}

export default Store;

Store라는 추상클래스에서 addListener로 구독하고 removeListener로 구독을 취소하고 getSnapshot으로 스냅샷을 얻고 publish로 publish를 해준다. 그냥 흔히 사용하는 옵저버 패턴이다. 옵저버패턴에 대해서는 따로 설명하지 않을것이다.

 

useTodoStore

import { useSyncExternalStore } from 'react';

import TodoStore, { TodoStoreSnapShot } from 'store/TodoStore';

const todoStore = new TodoStore();

export function useTodoStore(): [TodoStoreSnapShot, TodoStore] {
  const snapshot = useSyncExternalStore(
    (onStoreChange) => {
      todoStore.addListener(onStoreChange);
      return () => todoStore.removeListener(onStoreChange);
    },
    () => todoStore.getSnapshot(),
  );
  return [snapshot, todoStore];
}

리액트 18부터 기본적으로 제공하는 useSyncExternalStore를 이용해서 store에 연결해준다. 사용법은 공식문서를 읽어보면 된다. 사실 읽어보지 않아도 유추할 수 있을만큼 쉬운 코드다.

https://ko.reactjs.org/docs/hooks-reference.html#usesyncexternalstore

 

마법 코드

StoreGlue

import { areEqual, getPrototypeOf, ownKeys, getGetterKeys, takeSnapshot, attachGetters } from './utils';

export default class StoreGlue {
  propertyKeys: (string | symbol)[];

  getterKeys: (string | symbol)[];

  listeners = new Set<() => void>();

  snapshot = {};

  constructor(store: object) {
    this.propertyKeys = ownKeys(store);
    this.getterKeys = getGetterKeys(store);
  }

  subscribe(onChange: () => void): () => void {
    this.listeners.add(onChange);
    return () => {
      this.listeners.delete(onChange);
    };
  }

  getSnapshot() {
    return this.snapshot;
  }

  update(store: object): void {
    const snapshot = takeSnapshot(store, this.propertyKeys);

    if (areEqual(snapshot, this.snapshot)) {
      return;
    }

    if (this.getterKeys.length) {
      attachGetters(snapshot, {
        obj: getPrototypeOf(store),
        keys: this.getterKeys,
      });
    }

    this.snapshot = snapshot;
    this.listeners.forEach((listener) => listener());
  }
}

StoreGlue 무슨 뜻일까? 이름을 봤을때는 스토어를 풀로 연결해주는? 대충 그런뜻같다.

subscribe에서는 listener에 추가하고 클린업할때 listener에서 제거해준다. 아마 리액트 훅의 생명주기와 동일한 생명주기를 갖는 것 같다.

getSnapshot은 그냥 snapshot을 얻는 코드다. 왜 getter를 사용하지 않은지는 잘 모르겠다. 그냥 별다른 이유가 없는건지 다른 이유가 있는건지.. 사실 뭘쓰든 별 상관 없기는 하다.

update에서는 말그대로 업데이트를 한다. 먼저 프로퍼티키로 스냅샷을 얻고 getter를 붙이고 listener를 실행해준다. 다 유틸함수에서 설명한 내용이라서 1편을 잘 봤다면 바로 이해할 수 있을것이다.

 

테스트 코드

import StoreGlue from './StoreGlue';
import { Store } from './decorators';

@Store()
class MyStore {
  name: string;

  constructor({ name }: { name: string }) {
    this.name = name;
  }
}

test('StoreGlue', () => {
  const name = 'Peter Parker';
  const handleChange = jest.fn();

  const target = new MyStore({ name });

  const glue = new StoreGlue(target);

  glue.subscribe(handleChange);

  glue.update(target);

  const snapshot = {
    name,
  };

  expect(handleChange).toHaveBeenCalled();

  expect(glue.getSnapshot()).toEqual(snapshot);
});

먼저 의문이 드는 점은 Store데코레이터를 MyStore에 붙인점이다. 없어도 될것같은데? Store 데코레이터에 대한 코드는 다음에 설명할 것이다.

다시 생각해보니 필요할것같다. 항상 우리는 Store라는 데코레이터를 붙이기 때문에. StoreGlue의 인자로 클래스 인스턴스를 넣는 것과는 조금 다르다. 결론적으로는 MyStore가 StoreGlue에 의해 두번 감싸졌지만 그건 별로 중요하지 않다. 마지막에 감싼 StoreGlue로 테스트를 돌렸으니 말이다. 

 

StoreGlue에 MyStore의 클래스 인스턴스를 넣고 subscribe, update를 차례로 해준다. 그러면 구독한 handleChange라는 함수는 실행될 것이고 glue의 스냅샷에는 target의 프로퍼티값과 동일한 값이 들어간다.

 

여기서 또 하나의 의문점이 든다. update를 할 때 새로운 타겟을 만들어서 넣어줘야지 값 비교가 정확히 되는게 아닐까? 나라면 아래와 같이 변경된 target을 넣어서 비교했을 것이다. 이건 진짜 빠트린거 아닌가?

const newName = 'Peter';

  const newTarget = new MyStore({ name: newName });

  glue.update(newTarget);

  const snapshot = {
    name: newName,
  };

  expect(handleChange).toHaveBeenCalled();

  expect(glue.getSnapshot()).toEqual(snapshot);

decorators.ts

import { STORE_GLUE_PROPERTY_KEY } from './contants';
import StoreGlue from './StoreGlue';

type Klass = { new (...args: any[]): {} };

export function Store() {
  return function decorator<T extends Klass>(klass: T) {
    return class extends klass {
      constructor(...args: any[]) {
        super(...args);
        const glue = new StoreGlue(this);
        Reflect.set(this, STORE_GLUE_PROPERTY_KEY, glue);
        glue.update(this);
      }
    };
  };
}

export function Action() {
  return (target: object, propertyKey: string, descriptor: PropertyDescriptor) => {
    const method = descriptor.value;
    descriptor.value = function decorator(...args: unknown[]) {
      const returnValue = method.apply(this, args);
      Reflect.get(this, STORE_GLUE_PROPERTY_KEY).update(this);
      return returnValue;
    };
  };
}

먼저 데코레이터에대한 이해가 필요하다. 데코레이터를 모른다면 아래 블로그 글을 읽어보자. 개인적으로 공식문서보다 이해하기 쉽다. 데코레이터 문법적인 부분은 설명을 모두 생략하겠다.

https://m.blog.naver.com/pjt3591oo/222120496022

 

Store부터 보자.먼저 glue인스턴스를 만든다. 그리고 this의 STORE_GLUE_PROPERTY_KEY에 glue를 할당한다. 그리고 glue를 업데이트한다. this는 객체이기 때문에 Reflect.set은 glue에 영향이간다. 그리고 마지막으로 업데이트한다. 업데이트를 빼먹으면 절대 안된다.

 

Action을 보자. 여기서는 STORE_GLUE_PROPERTY_KEY에 해당하는 프로퍼티를 가져온다. 그리고 update에 this를 넣어서 실행시킨다. 조금 의문점이 드는점은 returnValue를 하는 코드다. 로그를 찍어보니 returnValue는 undefined다. 그냥 리턴을 안해도 되는거 아닌가? 잘 모르겠다. 그리고 애초에 함수를 실행시키고 리턴값을 받을 필요도 없어보인다.

 

store.test.ts

import { STORE_GLUE_PROPERTY_KEY } from './contants';
import { Store, Action } from './decorators';

@Store()
class MyStore {
  firstName = '';

  lastName = '';

  get fullName() {
    return `${this.firstName} ${this.lastName}`;
  }

  @Action()
  changeFirstName(firstName: string) {
    this.firstName = firstName;
  }

  @Action()
  changeLastName(lastName: string) {
    this.lastName = lastName;
  }
}

const context = describe;

describe('action', () => {
  const firstName = 'Peter';
  const handleChange = jest.fn();

  let store: MyStore;

  beforeEach(() => {
    jest.clearAllMocks();

    store = new MyStore();

    const glue = Reflect.get(store, STORE_GLUE_PROPERTY_KEY);
    glue.subscribe(handleChange);
  });

  context('with different state', () => {
    it('calls onChange handler', () => {
      store.changeFirstName(firstName);

      expect(store.firstName).toBe(firstName);

      expect(handleChange).toHaveBeenCalled();
    });
  });

  context('with same state', () => {
    it("doesn't calls onChange handler", () => {
      store.changeFirstName('');

      expect(handleChange).not.toHaveBeenCalled();
    });
  });
});

describe('snapshot', () => {
  let store: MyStore;

  beforeEach(() => {
    jest.clearAllMocks();

    store = new MyStore();
    store.changeFirstName('Peter');
    store.changeLastName('Parker');
  });

  function getSnapshot() {
    const glue = Reflect.get(store, STORE_GLUE_PROPERTY_KEY);
    return glue.getSnapshot();
  }

  it('contains properties', () => {
    const snapshot = getSnapshot();

    expect(snapshot).toEqual({
      firstName: 'Peter',
      lastName: 'Parker',
    });
  });

  it('has getters that uses cache', () => {
    const snapshot = getSnapshot();

    expect(snapshot.fullName).toBe('Peter Parker');
  });
});

 

조금 길어서 기본 모킹이나 beforeEach, 클래스 선언등은 설명을 생략한다.

 

  context('with different state', () => {
    it('calls onChange handler', () => {
      store.changeFirstName(firstName);

      expect(store.firstName).toBe(firstName);

      expect(handleChange).toHaveBeenCalled();
    });
  });

store에 액션 데코레이터를 붙인 메서드를 실행하면 glue에서 구독한 handleChange함수가 실행된다. + 메서드실행결과 테스트

 

  context('with same state', () => {
    it("doesn't calls onChange handler", () => {
      store.changeFirstName('');

      expect(handleChange).not.toHaveBeenCalled();
    });
  });

이번에는 상태가 바뀌지 않는 경우다. StoreGlue의 update에서 snapshot이 동일하다면 early return한다. 그렇기 때문에 handleChange는 실행되지 않는다.

 

describe('snapshot', () => {
  let store: MyStore;

  beforeEach(() => {
    jest.clearAllMocks();

    store = new MyStore();
    store.changeFirstName('Peter');
    store.changeLastName('Parker');
  });

  function getSnapshot() {
    const glue = Reflect.get(store, STORE_GLUE_PROPERTY_KEY);
    return glue.getSnapshot();
  }

  it('contains properties', () => {
    const snapshot = getSnapshot();

    expect(snapshot).toEqual({
      firstName: 'Peter',
      lastName: 'Parker',
    });
  });

  it('has getters that uses cache', () => {
    const snapshot = getSnapshot();

    expect(snapshot.fullName).toBe('Peter Parker');
  });
});

snapshot에 프로퍼티가 제대로 있는지 getter가 제대로 있는지 확인하는 테스트다.

 

useStore

import { useSyncExternalStore } from 'react';

import { STORE_GLUE_PROPERTY_KEY } from './contants';
import { NonFunctionProperties } from './types';

export default function useStore<Store extends object>(store: Store): [NonFunctionProperties<Store>, Store] {
  const glue = Reflect.get(store, STORE_GLUE_PROPERTY_KEY);
  if (!glue) {
    throw new Error('Cannot find store glue');
  }

  const snapshot: NonFunctionProperties<Store> = useSyncExternalStore(
    glue.subscribe.bind(glue),
    glue.getSnapshot.bind(glue),
  );

  return [snapshot, store];

드디어 훅이 나왔다. useStore라는 훅을 하나만 만들고 사용할때는 데코레이터를 이용한 클래스 인스턴스를 인자에 넣어서 사용하면 끝이다. 조금 특이한 점은 bind를 사용했다. this에 glue를 전달한다? glue store에 직접 접근하는게 아니라 프로퍼티로 접근을하다보니 바딩을 새롭게 해줘야한다. 안하면 undefined라서 오류뜬다. 아 힘들다 힘들어...

 

테스트 코드

import { renderHook } from '@testing-library/react';

import useStore from './useStore';
import { Store } from './decorators';

const context = describe;

@Store()
class MyStore {
  name = 'Peter';
}

describe('useStore', () => {
  context('with correct store', () => {
    it('returns the store and the state as snapshot', () => {
      const store = new MyStore();

      const { result } = renderHook(() => useStore(store));

      expect(result.current).toEqual([{ name: 'Peter' }, store]);
    });
  });

  context('with incorrect store', () => {
    let consoleError: (...args: unknown[]) => void;

    beforeEach(() => {
      consoleError = console.error;
      console.error = jest.fn();
    });

    afterEach(() => {
      console.error = consoleError;
    });

    it('throws error', () => {
      const store = {};

      expect(() => {
        renderHook(() => useStore(store));
      }).toThrow();
    });
  });
});

테스트가 엄청 간단해서 설명할건없다. 신경쓸부분은 context를 나눠서 진행했다는점과 에러가발생했을때 로그를 처리하는 방식이다.

이건 유용해서 앞으로 자주 사용해야겠다. 로그를 처리하는 코드를 입력하지 않으면 테스트창에 로그가 엄청 많이 뜬다.

 

렌더링 테스트

import { render, screen, act } from '@testing-library/react';

import { Store, Action, useStore } from '.';

@Store()
class MyStore {
  name = '';

  get displayName() {
    return this.name.toUpperCase();
  }

  @Action()
  changeName(name: string) {
    this.name = name;
  }
}

const MyComponent = ({ store }: { store: MyStore }) => {
  const [{ name, displayName }] = useStore(store);

  return (
    <div>
      <div>{name}</div>
      <div>{displayName}</div>
    </div>
  );
};

test('example', () => {
  const store = new MyStore();

  render(<MyComponent store={store} />);

  act(() => {
    store.changeName('Peter Parker');
  });

  screen.getByText('Peter Parker');
  screen.getByText('PETER PARKER');
});

 

진짜 마지막 테스트다. 근데 너무 간단한 코드다. store에서 changeName 메서드를 호출하고 텍스트가 바뀌었는지 테스트를 돌리면 끝이다.

 

후기

좀 힘들었지만 가치있는 시간이였다. 이제 변경할부분을 찾아봐야겠다. 다음편에서...

그냥 여기다 적겠다. 투두리스트를 만들었는데 Action 데코레이터를 붙였음에도 리렌더링이 안되는 현상을 발견했다. 디버깅해보니 areEqual에서 true가 나와서 렌더링 업데이트가 되지 않는 것이였다. 나는 store를 immutable하게 만들지 않았다. mutable하게 만들었기 때문에 객체는 같은 reference를 바라보고 있고 상태가 변하지 않았다고 판단이 된 것이다. 사실 immutable한게 일반적일 수도 있지만 요즘은 mutable하게 만드는 경우도 많다. 이럴땐 내가 원하는대로 소스코드를 변경하면 된다. 

다음과 같이 areEqual함수를 변경해줬다.

export function areEqual(a: object, b: object) {
  const keys = Reflect.ownKeys(a);
  return (
    keys.length === Reflect.ownKeys(b).length &&
    keys.every(
      (key) =>
        typeof Reflect.get(a, key) !== 'object' &&
        typeof Reflect.get(b, key) !== 'object' &&
        Reflect.get(a, key) === Reflect.get(b, key),
    )
  );
}

 

 

728x90
LIST
댓글
공지사항