본문으로 건너뛰기

Records & Tuples for React Proposal

· 약 32분
Dongmin Yu

**매우 흥미로운 제안인 레코드 & 튜플이 **TC39**에서 2단계에 도달했습니다.

레코드와 튜플은 불변 데이터 구조를 자바스크립트로 가져옵니다.

하지만 React매우 흥미로운 동등성(equality) 속성을 간과해서는 안 됩니다.

React 버그의 많은 범주는 불안정한 객체 아이덴티티와 관련이 있습니다:

  • 성능: 피할 수 있는 재렌더링
  • 동작: 쓸모없는 이펙트 재실행, 무한 루프
  • API 표면: 안정적인 객체 아이덴티티가 중요한 경우 표현할 수 없음

레코드와 튜플의 기초를 설명하고, 이를 통해 어떻게 현실의 React 문제를 해결할 수 있는지 설명하겠습니다.

레코드와 튜플 101

이 글은 React의 레코드와 튜플에 관한 글입니다. 여기서는 기본적인 내용만 다루겠습니다.

레코드와 튜플은 # 접두사가 붙은 일반적인 객체와 배열처럼 보입니다.

const record = #{a: 1, b: 2};
record.a;
// 1

const updatedRecord = #{...record, b: 3};
// #{a: 1, b: 3};
const tuple = #[1, 5, 2, 3, 4];
tuple[1];
// 5

const filteredTuple = tuple.filter(num => num > 2)
// #[5, 3, 4];

They are deeply immutable by default.

const record = #{a: 1, b: 2};
record.b = 3;
// throws TypeError

이들은 **"복합 프리미티브"**로 볼 수 있으며 값으로 비교할 수 있습니다.

매우 중요: 두 개의 완전히 동일한 레코드는 ===와 함께 true항상 반환합니다.

const record = JSON.parseImmutable("{a: 1, b: [2, 3]}");
// #{a: 1, b: #[2, 3]}
JSON.stringify(record);
// '{a: 1, b: [2, 3]}'

어떻게든 레코드의 아이덴티티는 일반 JS 프리미티브와 마찬가지로 실제 값이라고 생각할 수 있습니다.

이 프로퍼티는 앞으로 살펴볼 것처럼 React에 심각한 영향을 미칩니다.

JSON과 상호 운용이 가능합니다:

const record1 = #{
  a: {
    regular: 'object'
  },
};
// throws TypeError, because a record can't contain an object

const record2 = #{
  b: new Date(),
};
// throws TypeError, because a record can't contain a Date

const record3 = #{
  c: new MyClass(),
};
// throws TypeError, because a record can't contain a class

const record4 = #{
  d: function () {
    alert('forbidden');
  },
};
// throws TypeError, because a record can't contain a function

다른 레코드와 튜플 또는 프리미티브 값만 포함할 수 있습니다.

참고: 심볼을 WeakMap 키(별도 제안)로 사용하고 레코드의 심볼을 참조하여 레코드에 이러한 변경 가능한 값을 추가할 수 있습니다.

더 알고 싶으신가요? 제안을 직접 읽어보시거나 Axel Rauschmayer의 기사를 읽어보세요.

React를 위한 레코드와 튜플

React 개발자들은 이제 **불변성(immutability)**에 익숙해졌습니다.

어떤 상태를 불변의 방식으로 업데이트할 때마다 새로운 객체 아이덴티티를 생성합니다.

안타깝게도 이 불변성 모델은 React 애플리케이션에 완전히 새로운 종류의 버그와 성능 문제를 일으켰습니다. 때로는 컴포넌트가 올바르고 성능 좋은 방식으로 작동하지만, 이는 소품이 시간이 지나도 최대한 아이덴티티를 보존한다는 가정 하에서만 가능합니다.

저는 레코드와 튜플을 객체 아이덴티티를 보다 "안정적으로"** 만드는 편리한 방법으로 생각하고 싶습니다.

실제 사용 사례를 통해 이 제안이 React 코드에 어떤 영향을 미치는지 살펴봅시다.

참고: React를 실행할 수 있는 레코드 & 튜플 플레이그라운드가 있습니다.

불변성

불변성을 적용하는 것은 재귀적인 Object.freeze() 호출로 달성할 수 있습니다.

하지만 실제로는 업데이트할 때마다 Object.freeze()를 적용하는 것이 편리하지 않기 때문에 불변성 모델을 너무 엄격하게 적용하지 않고 사용하는 경우가 많습니다. 하지만 상태를 직접 변경하는 것은 초보 React 개발자들이 흔히 저지르는 실수입니다.

레코드 및 튜플 제안은 불변성을 강제하고 일반적인 상태 변경 실수를 방지합니다:

const Hello = ({ profile }) => {
  // prop mutation: throws TypeError
  profile.name = 'Sebastien updated';
  return <p>Hello {profile.name}</p>;
};
function App() {
  const [profile, setProfile] = React.useState(#{
    name: 'Sebastien',
  });
  // state mutation: throws TypeError
  profile.name = 'Sebastien updated';
  return <Hello profile={profile} />;
}

불변 업데이트

React에서 불변 상태 업데이트를 수행하는 방법에는 여러 가지가 있습니다: vanilla JS, Lodash set, ImmerJS, ImmutableJS...

레코드와 튜플은 ES6 객체와 배열에서 사용하는 것과 동일한 종류의 불변 업데이트 패턴을 지원합니다:

const initialState = #{
  user: #{
    firstName: "Sebastien",
    lastName: "Lorber"
  }
  company: #{
    name: "Lambda Scale",
  }
};
const updatedState = {
  ...initialState,
  company: {
    ...initialState.company,
    name: 'Freelance',
  },
};

지금까지는 중첩된 속성을 처리하는 단순성과 일반 JS 코드와의 상호 운용성 덕분에 ImmerJS가 불변성 업데이트 전쟁에서 승리했습니다.

Immer가 레코드 및 튜플과 어떻게 작동할 수 있는지는 아직 명확하지 않지만, 제안서 작성자들이 연구하고 있는 부분입니다.

별개로 Michael Weststrate는 관련된 제안이 [레코드 및 튜플에 ImmerJS를 **불필요하게 만들 수 있다]고 강조했습니다:

const initialState = #{
  counters: #[
    #{ name: "Counter 1", value: 1 },
    #{ name: "Counter 2", value: 0 },
    #{ name: "Counter 3", value: 123 },
  ],
  metadata: #{
    lastUpdate: 1584382969000,
  },
};
// Vanilla JS updates
// using deep-path-properties-for-record proposal
const updatedState = #{
  ...initialState,
  counters[0].value: 2,
  counters[1].value: 1,
  metadata.lastUpdate: 1584383011300,
};

useMemo

useMemo()는 비용이 많이 드는 계산을 메모하는 것 외에도 트리의 더 깊은 곳에서 쓸모없는 계산, 재렌더링 또는 이펙트 실행을 트리거할 수 있는 새 객체 ID 생성을 피하는 데에도 유용합니다.

여러 필터가 있는 UI가 있고 백엔드에서 일부 데이터를 가져오고자 하는 사용 사례를 고려해 보겠습니다.

기존 React 코드베이스에는 다음과 같은 코드가 포함될 수 있습니다:

// Don't change apiFilters object identity,
// unless one of the filter changes
// Not doing this is likely to trigger a new fetch
// on each render
const apiFilters = useMemo(
  () => ({ userFilter, companyFilter }),
  [userFilter, companyFilter],
);
const { apiData, loading } = useApiData(apiFilters);

레코드 및 튜플을 사용하면 간단히 이렇게 됩니다:

const {apiData,loading} = useApiData(#{ userFilter, companyFilter })

useEffect

API 필터 사용 사례를 계속 살펴보겠습니다:

const apiFilters = { userFilter, companyFilter };
useEffect(() => {
  fetchApiData(apiFilters).then(setApiDataInState);
}, [apiFilters]);

안타깝게도 이 컴포넌트가 리렌더링할 때마다 apiFilters 객체의 ID가 변경되기 때문에 가져오기 효과가 재실행됩니다. setApiDataInState는 리렌더링을 트리거하고, 결국 무한한 불러오기/렌더링 루프가 발생하게 됩니다.

이 실수는 React 개발자들 사이에서 매우 흔한 실수이며, useEffect 무한 루프에 대한 구글 검색 결과만 수천 개가 넘습니다.

Kent C Dodds는 개발 과정에서 무한 루프를 끊는 도구를 만들기도 했습니다.

매우 일반적인 해결책: 이펙트의 콜백에 직접 apiFilters를 생성합니다:

useEffect(() => {
  const apiFilters = { userFilter, companyFilter };
  fetchApiData(apiFilters).then(setApiDataInState);
}, [userFilter, companyFilter]);

트위터에서 찾을 수 있는 또 다른 창의적인 솔루션(성능은 좋지 않음)입니다:

const apiFiltersString = JSON.stringify({
  userFilter,
  companyFilter,
});
useEffect(() => {
  fetchApiData(JSON.parse(apiFiltersString)).then(setApiDataInState);
}, [apiFiltersString]);

그 중 가장 제 마음에 드는 기능입니다:

// We already saw this somewhere, right? :p
const apiFilters = useMemo(
  () => ({ userFilter, companyFilter }),
  [userFilter, companyFilter],
);
useEffect(() => {
  fetchApiData(apiFilters).then(setApiDataInState);
}, [apiFilters]);

이 문제를 해결하는 멋진 방법이 많이 있지만 필터 수가 증가함에 따라 모두 귀찮아지는 경향이 있습니다.

use-deep-compare-effect(Kent C Dodds)가 덜 성가시겠지만, 리렌더링할 때마다 딥 이퀄리티를 실행하는 데는 지불하고 싶지 않은 비용이 듭니다.

레코드 및 튜플보다 훨씬 장황하고 관용적이지 않습니다:

const apiFilters = #{ userFilter, companyFilter };
useEffect(() => {
  fetchApiData(apiFilters).then(setApiDataInState);
}, [apiFilters]);

Props와 React.memo

프로포즈에서 객체 아이덴티티를 보존하는 것은 React 퍼포먼스에도 매우 유용합니다.

또 다른 매우 흔한 성능 실수는 렌더링에서 새로운 객체 아이덴티티를 생성하는 것입니다.

const Parent = () => {
  useRerenderEverySeconds();
  return (
    <ExpensiveChild
      // someData props object is created "on the fly"
      someData={{ attr1: "abc", attr2: "def" }}
    />
  );
};
const ExpensiveChild = React.memo(({ someData }) => {
  return <div>{expensiveRender(someData)}</div>;
});

대부분의 경우 이것은 문제가 되지 않습니다. React는 충분히 빠르니까요.

하지만 때때로 앱을 최적화하려는 경우, 이 새로운 객체 생성은 React.memo()를 쓸모없게 만듭니다. 최악의 경우, 실제로 애플리케이션을 조금 느리게 만듭니다(항상 거짓을 반환하는 추가적인 얕은 동등성 검사를 실행해야 하기 때문입니다).

클라이언트 코드베이스에서 자주 볼 수 있는 또 다른 패턴입니다:

const currentUser = { name: "Sebastien" };
const currentCompany = { name: "Lambda Scale" };
const AppProvider = () => {
  useRerenderEverySeconds();
  return (
    <MyAppContext.Provider
      // the value prop object is created "on the fly"
      value={{ currentUser, currentCompany }}
    />
  );
};

currentUser 또는 currentCompany업데이트되지 않는다는 사실에도 불구하고, 이 공급자가 리렌더링할 때마다 컨텍스트 값이 변경되어 모든 컨텍스트 구독자의 리렌더링을 트리거합니다.

이러한 모든 문제는 메모화를 통해 해결할 수 있습니다:

const someData = useMemo(() => ({ attr1: "abc", attr2: "def" }), []);
<ExpensiveChild someData={someData} />;
const contextValue = useMemo(
  () => ({ currentUser, currentCompany }),
  [currentUser, currentCompany],
);
<MyAppContext.Provider value={contextValue} />;

레코드 및 튜플을 사용하면 성능이 뛰어난 코드를 작성하는 것이 관용적입니다:

<ExpensiveChild someData={#{ attr1: 'abc', attr2: 'def' }} />;
<MyAppContext.Provider value={#{ currentUser, currentCompany }} />;

불러오기 및 다시 불러오기

React에서 데이터를 불러오는 방법은 여러 가지가 있습니다: useEffect, HOC, Render props, Redux, SWR, React-Query, Apollo, Relay, Urql, ...

대부분의 경우, 우리는 요청을 백엔드에 전달하고 JSON 데이터를 반환받습니다.

이 섹션을 설명하기 위해 제가 만든 아주 간단한 불러오기 라이브러리인 react-async-hook을 사용하겠지만, 다른 라이브러리에도 적용될 수 있습니다.

API 데이터를 가져오는 고전적인 비동기 함수를 고려해 봅시다:

const fetchUserAndCompany = async () => {
  const response = await fetch(`https://myBackend.com/userAndCompany`);
  return response.json();
};

이 앱은 데이터를 가져오고, 시간이 지나도 이 데이터가 오래된 데이터가 아닌 '최신' 상태로 유지되도록 합니다:

const App = ({ id }) => {
  const { result, refetch } = useAsync(fetchUserAndCompany, []);
  // We try very hard to not display stale data to the user!
  useInterval(refetch, 10000);
  useOnReconnect(refetch);
  useOnNavigate(refetch);
  if (!result) {
    return null;
  }
  return (
    <div>
      <User user={result.user} />
      <Company company={result.company} />
    </div>
  );
};
const User = React.memo(({ user }) => {
  return <div>{user.name}</div>;
});
const Company = React.memo(({ company }) => {
  return <div>{company.name}</div>;
});

문제: 성능상의 이유로 React.memo를 사용했지만, 다시 불러올 때마다 가져온 데이터가 이전과 동일(완전히 동일한 페이로드)임에도 불구하고 새로운 아이덴티티를 가진 새로운 JS 객체가 생성되고 모든 것이 리렌더링됩니다.

이 시나리오를 상상해 봅시다:

  • "Stale-While-Revalidate" 패턴(캐시된/부실 데이터를 먼저 표시한 다음 백그라운드에서 데이터를 새로 고침)을 사용합니다.
  • 페이지가 복잡하고 렌더링 집약적이며 많은 백엔드 데이터가 표시됩니다.

캐시된 데이터로 인해 처음 렌더링하는 데 이미 많은 비용이 드는 페이지로 이동합니다. 1초 후 새로 고쳐진 데이터가 다시 표시됩니다. 캐시된 데이터와 거의 동일함에도 불구하고 모든 것이 리렌더링됩니다. 동시성 모드와 타임 슬라이싱이 없으면 일부 사용자는 수백 밀리초 동안 UI가 멈추는 현상을 경험할 수도 있습니다.

이제 가져오기 함수를 레코드를 반환하도록 변환해 보겠습니다:

const fetchUserAndCompany = async () => {
  const response = await fetch(`https://myBackend.com/userAndCompany`);
  return JSON.parseImmutable(await response.text());
};

우연히도 JSON은 레코드 및 튜플과 호환되며, JSON.parseImmutable을 사용하여 모든 백엔드 응답을 레코드로 변환할 수 있어야 합니다.

참고: 제안 작성자 중 한 명인 Robin Ricard는 새로운 response.immutableJson() 함수를 추진하고 있습니다.

레코드 및 튜플을 사용하면 백엔드에서 동일한 데이터를 반환하면 아무것도 리렌더링하지 않습니다!

또한 응답의 한 부분만 변경된 경우에도 응답의 다른 중첩 객체는 여전히 아이덴티티를 유지합니다. 즉, user.name만 변경된 경우 User 컴포넌트는 리렌더링되지만 Company 컴포넌트는 렌더링되지 않습니다!

"Stale-While-Revalidate"와 같은 패턴이 점점 더 대중화되고 있고 SWR, React-Query, Apollo, Relay와 같은 라이브러리에서 기본으로 제공된다는 점을 고려할 때 이 모든 것이 성능에 미치는 영향을 상상해 보겠습니다.

쿼리 문자열 읽기

검색 UI에서는 쿼리 문자열에 필터의 상태를 보존하는 것이 좋습니다. 그러면 사용자는 링크를 복사/붙여넣거나 페이지를 새로고침하거나 북마크할 수 있습니다.

필터가 1~2개라면 간단하지만, 검색 UI가 복잡해지면(필터가 10개 이상, AND/OR 로직으로 쿼리를 작성하는 기능 등) 쿼리 문자열을 관리하기 위해 좋은 추상화를 사용하는 것이 좋습니다.

저는 개인적으로 qs를 좋아합니다. 중첩 객체를 제대로 처리하는 몇 안 되는 라이브러리 중 하나이기 때문입니다.

const queryStringObject = {
  filters: {
    userName: "Sebastien",
  },
  displayMode: "list",
};
const queryString = qs.stringify(queryStringObject);
const queryStringObject2 = qs.parse(queryString);
assert.deepEqual(queryStringObject, queryStringObject2);
assert(queryStringObject !== queryStringObject2);

queryStringObjectqueryStringObject2는 매우 동일하지만, qs.parse가 새 객체를 생성하기 때문에 더 이상 동일하지 않습니다.

쿼리 문자열 구문 분석을 후크에 통합하고 useMemo() 또는 use-memo-value와 같은 라이브러리를 사용하여 쿼리 문자열 객체를 "안정화"할 수 있습니다.

const useQueryStringObject = () => {
  // Provided by your routing library, like React-Router
  const { search } = useLocation();
  return useMemo(() => qs.parse(search), [search]);
};

이제 트리의 더 깊은 곳을 상상해 보세요:

const { filters } = useQueryStringObject();
useEffect(() => {
  fetchUsers(filters).then(setUsers);
}, [filters]);

이것은 약간 지저분하지만 같은 문제가 반복해서 발생합니다.

queryStringObject 아이덴티티를 보존하기 위해 useMemo()를 사용했음에도 불구하고 원치 않는 fetchUsers 호출이 발생하게 됩니다.

사용자가 (다시 불러오기를 트리거하지 않고 렌더링 로직만 변경해야 하는) displayMode를 업데이트하면 쿼리 문자열이 변경되어 쿼리 문자열이 다시 구문 분석되고 filter 속성에 대한 새로운 객체 ID가 생성되어 원치 않는 useEffect 실행으로 이어집니다.

다시 말하지만, 레코드 및 튜플은 이러한 일이 발생하지 않도록 방지합니다.

// This is a non-performant, but working solution.
// Lib authors should provide a method such as qs.parseRecord(search)
const parseQueryStringAsRecord = (search) => {
  const queryStringObject = qs.parse(search);
  // Note: the Record(obj) conversion function is not recursive
  // There's a recursive conversion method here:
  // https://tc39.es/proposal-record-tuple/cookbook/index.html
  return JSON.parseImmutable(JSON.stringify(queryStringObject));
};
const useQueryStringRecord = () => {
  const { search } = useLocation();
  return useMemo(() => parseQueryStringAsRecord(search), [search]);
};

이제 사용자가 displayMode를 업데이트하더라도 filters 객체는 그 아이덴티티를 유지하며 쓸모없는 리페치를 트리거하지 않습니다.

참고: 레코드 및 튜플 제안이 받아들여지면 qs와 같은 라이브러리에서 qs.parseRecord(search) 메서드를 제공할 가능성이 높습니다.

매우 동등한 JS 변환

컴포넌트에서 다음과 같은 JS 변환을 상상해 보세요:

const AllUsers = [
  { id: 1, name: "Sebastien" },
  { id: 2, name: "John" },
];
const Parent = () => {
  const userIdsToHide = useUserIdsToHide();
  const users = AllUsers.filter((user) => !userIdsToHide.includes(user.id));
  return <UserList users={users} />;
};
const UserList = React.memo(({ users }) => (
  <ul>
    {users.map((user) => (
      <li key={user.id}>{user.name}</li>
    ))}
  </ul>
));

filter는 항상 새 배열 인스턴스를 반환하기 때문에 Parent 컴포넌트가 리렌더링될 때마다 UserList 컴포넌트도 리렌더링됩니다.

이는 userIdsToHide가 비어 있고 AllUsers 아이덴티티가 안정된 경우에도 마찬가지입니다! 이 경우 필터 연산은 실제로 아무것도 필터링하지 않고, 단지 쓸모없는 배열 인스턴스를 새로 생성하여 React.memo 최적화를 선택 해제합니다.

이러한 종류의 변환은 components, reducers, selectors, Redux 등 map 또는 filter와 같은 연산자를 사용하는 React 코드베이스에서 매우 흔합니다.

메모화를 통해 이 문제를 해결할 수 있지만, 레코드와 튜플을 사용하는 것이 더 관용적입니다:

const AllUsers = #[
  #{ id: 1, name: 'Sebastien' },
  #{ id: 2, name: 'John' },
];
const filteredUsers = AllUsers.filter(() => true);
AllUsers === filteredUsers;
// true

React 키로써의 레코드

렌더링할 항목 목록이 있다고 가정해 봅시다:

const list = [
  { country: "FR", localPhoneNumber: "111111" },
  { country: "FR", localPhoneNumber: "222222" },
  { country: "US", localPhoneNumber: "111111" },
];

어떤 키를 사용하시겠습니까?

countrylocalPhoneNumber가 모두 목록에서 독립적으로 고유하지 않다는 점을 고려하면 두 가지 선택지가 있습니다.

배열 인덱스 키:

<>
  {list.map((item, index) => (
    <Item key={`poormans_key_${index}`} item={item} />
  ))}
</>

이 방법은 항상 작동하지만, 특히 목록의 항목이 재정렬된 경우에는 이상적이지 않습니다.

복합 키:

<>
  {list.map((item) => (
    <Item key={`${item.country}_${item.localPhoneNumber}`} item={item} />
  ))}
</>

이 솔루션은 **목록 재순서를 더 잘 처리하지만 **커플/튜플이 고유하다는 것을 확실히 알고 있는 경우에만 가능합니다.

이런 경우에는 레코드를 키로 직접 사용하는 것이 **더 편리하지 않을까요?

const list = #[
  #{ country: 'FR', localPhoneNumber: '111111' },
  #{ country: 'FR', localPhoneNumber: '222222' },
  #{ country: 'US', localPhoneNumber: '111111' },
];
<>
  {list.map((item) => (
    <Item key={item} item={item} />
  ))}
</>

이것은 Morten Barklund제안했습니다.

명시적 API 서페이스

이 TypeScript 컴포넌트를 살펴봅시다:

const UsersPageContent = ({ usersFilters }: { usersFilters: UsersFilters }) => {
  const [users, setUsers] = useState([]);
  // poor-man's fetch
  useEffect(() => {
    fetchUsers(usersFilters).then(setUsers);
  }, [usersFilters]);
  return <Users users={users} />;
};

이 코드는 앞서 살펴본 것처럼 usersFilters 프로퍼티의 안정성에 따라 무한 루프를 생성할 수도 있고 생성하지 않을 수도 있습니다. 이렇게 하면 상위 컴포넌트의 구현자가 문서화하여 명확하게 이해해야 하는 암시적 API 계약이 생성되며, TypeScript를 사용하지만 유형 시스템에는 반영되지 않습니다.

다음은 무한 루프로 이어질 수 있지만 TypeScript에는 이를 방지할 방법이 없습니다:

<UsersPageContent usersFilters={{ nameFilter, ageFilter }} />

레코드 및 튜플을 사용하면 TypeScript가 레코드를 예상하도록 지시할 수 있습니다:

const UsersPageContent = ({
  usersFilters,
}: {
  usersFilters: #{nameFilter: string, ageFilter: string}
}) => {
  const [users, setUsers] = useState([]);
  // poor-man's fetch
  useEffect(() => {
    fetchUsers(usersFilters).then(setUsers);
  }, [usersFilters]);
  return <Users users={users} />;
};

참고: #{nameFilter: string, ageFilter: string}은 제가 직접 고안한 것입니다. 아직 TypeScript 구문이 어떻게 될지 알 수 없습니다.

다음 코드는 TypeScript 컴파일이 실패합니다:

<UsersPageContent usersFilters={{ nameFilter, ageFilter }} />

다음 코드는 컴파일을 문제없이 허용하지만요.

<UsersPageContent
  usersFilters={#{ nameFilter, ageFilter }}
/>

레코드와 튜플을 사용하면 컴파일 시간에 이러한 무한 루프를 방지할 수 있습니다.

컴파일러에 구현이 객체 식별에 민감(또는 값별 비교에 의존)하다는 것을 명시적으로 알릴 수 있는 방법이 있습니다.

참고: readonly은 이 문제를 해결하지 못합니다. 변이를 방지할 수는 있지만 안정적인 아이덴티티를 보장하지 못하기 때문입니다.

직렬화 보장

팀의 개발자가 직렬화할 수 없는 것을 글로벌 앱 상태에 넣지 않도록 하고 싶을 수 있습니다. 이는 상태를 백엔드로 보내거나 localStorage(또는 React-Native 사용자의 경우 AsyncStorage)에 로컬로 유지하려는 경우 중요합니다.

이를 보장하려면 루트 객체가 레코드인지 확인하기만 하면 됩니다. 이렇게 하면 중첩된 레코드와 튜플을 포함한 모든 중첩된 어트리뷰트도 프리미티브가 됩니다.

다음은 시간이 지나도 Redux 저장소를 계속 직렬화할 수 있도록 Redux와 통합한 예시입니다:

if (process.env.NODE_ENV === "development") {
  ReduxStore.subscribe(() => {
    if (typeof ReduxStore.getState() !== "record") {
      throw new Error(
        "Don't put non-serializable things in the Redux store! " +
          "The root Redux state must be a record!",
      );
    }
  });
}

참고: 레코드에 넣을 수 있으면서도 직렬화될 수는 없는 심볼이 있으므로 완벽하게 안정된 보장은 아닙니다.

CSS-in-JS 성능

인기 있는 라이브러리에서 css 프로퍼티를 사용한 CSS-in-JS를 살펴봅시다:

const Component = () => (
  <div
    css={{
      backgroundColor: "hotpink",
    }}
  >
    This has a hotpink background.
  </div>
);

CSS-in-JS 라이브러리는 리렌더링할 때마다 새 CSS 객체를 받습니다.

처음 렌더링할 때 이 객체를 고유한 클래스 이름으로 해시하고 CSS를 삽입합니다. 스타일 객체는 리렌더링할 때마다 다른 아이덴티티를 가지며, CSS-in-JS 라이브러리는 이를 해시하고 또 해시해야 합니다.

const insertedClassNames = new Set();
function handleStyleObject(styleObject) {
  // computeStyleHash re-executes every time
  const className = computeStyleHash(styleObject);
  // only insert the css for this className once
  if (!insertedClassNames.has(className)) {
    insertCSS(className, styleObject);
    insertedClassNames.add(className);
  }
  return className;
}

레코드 및 튜플을 사용하면 이러한 스타일 객체의 ID가 시간이 지나도 보존됩니다.

const Component = () => (
  <div
    css={#{
      backgroundColor: 'hotpink',
    }}
  >
    This has a hotpink background.
  </div>
);

레코드 및 튜플을 맵 키로 사용할 수 있습니다. 이렇게 하면 CSS-in-JS 라이브러리를 더 빠르게 구현할 수 있습니다:

const insertedStyleRecords = new Map();
function handleStyleRecord(styleRecord) {
  let className = insertedStyleRecords.get(styleRecord);
  if (!className) {
    // computeStyleHash is only executed once!
    className = computeStyleHash(styleRecord);
    insertCSS(className, styleRecord);
    insertedStyleRecords.add(styleRecord, className);
  }
  return className;
}

레코드 및 튜플의 성능에 대해서는 아직 알 수 없지만(브라우저 공급업체 구현에 따라 달라질 수 있음), 동등한 객체를 생성한 다음 클래스 이름으로 해싱하는 것보다 빠를 것이라고 말하는 것이 안전할 것 같습니다.

참고: 좋은 Babel 플러그인이 포함된 일부 CSS-in-JS 라이브러리는 컴파일 시 정적 스타일 객체를 상수로 변환할 수 있지만, 동적 스타일에서는 변환하는 데 어려움을 겪을 수 있습니다.

const staticStyleObject = { backgroundColor: "hotpink" };
const Component = () => (
  <div css={staticStyleObject}>This has a hotpink background.</div>
);

결론

많은 React 성능 및 동작 문제는 객체 ID와 관련이 있습니다.

레코드와 튜플은 일종의 "자동 메모화"를 제공함으로써 객체 아이덴티티가 기본적으로 "더 안정적"이 되도록 보장하고, 이러한 React 문제를 더 쉽게 해결할 수 있도록 도와줍니다.

TypeScript를 사용하면 API 표면이 객체 ID에 민감하다는 것을 더 잘 표현할 수 있습니다.

여러분도 저만큼이나 이 제안에 흥미를 느끼셨으면 좋겠습니다!

읽어주셔서 감사합니다!

이 멋진 제안서를 작성하고 제 글을 검토해 주신 Robin Ricard, Rick Button, Daniel Ehrenberg, Nicolò Ribaudo, Rob Palmer에게 감사의 인사를 전합니다.

마음에 드신다면 트위터, 개발자, 레딧 또는 해커뉴스에서 널리 알려주세요.

브라우저 코드 데모 또는 블로그 저장소에서 내 게시물 오타 수정하기

이와 같은 더 많은 콘텐츠를 보시려면 This Week In React를 구독하고 트위터에서 저를 팔로우하세요.