이전의 React-redux에서 Context API의 렌더링이슈를 해결한 방법

react-redux는 구독/발행 패턴으로 구현되었다.

React와 사용하기 위해 ContextAPI를 사용한다.

Context API는 렌더링마다 새 객체를 생성하게 되어 하위 컴포넌트들이 모두 리렌더링 되는 이슈가 있다.

React-redux에서는 shallowEqual이라는 내장함수를 제공하는데, 이를 통해 Context API의 렌더링 이슈를 해결할 수 있다.

동작 방식

react-redux에서는 useSelector의 equalityFn은 이전값과 다음값을 비교하여 true일경우 리렌더링을 하지 않고 false가 나오면 리렌더링을 한다.

리렌더링 방식은 다음과 같다.

useSelectorWithStoreAndSubscription Hook에는 강제로 렌더링을 트리거하는 forceRender함수가 존재한다.

그리고 store또는 subscription이 변경될 경우 실행되는 useEffect Hook이 존재한다.

이 Hook에서는 parameter로 전달받은 equalityFn을 사용하여 이전값과 업데이트될 값을 비교한다.

1
type equalityFn = (left: any, right: any) => booleant;

즉 equalityFn에서 true를 반환할 경우 아무 작업도 하지 않고 useEffect를 탈출한다. 값이 다르다면 state를 업데이트 한 후 forceRender 함수를 실행한다.

여기서 state는 useRef를 통해서 관리되고 있기 떄문에 forceRender 함수를 실행하여 강제로 렌더링을 하지 않는다면 단순 state의 변화로 렌더링이 일어나지 않는다.

Object.is

Object는 형변환이 일어나지 않는 얕은비교를 한다.

즉 형변환 없이 같은 값인지를 반환한다.

예를들어 -0과 +0또한 다른값으로 한다는 이야기이다.

mdn의 폴리필은 다음과 같다.

1
2
3
4
5
6
7
8
9
if (!Object.is) {
Object.is = function (x, y) {
if (x === y) {
return x !== 0 || 1 / x === 1 / y;
} else {
return x !== x && y !== y;
}
};
}

ShallowEqual

ShallowEqual은 객체의 depth가 1인 값들을 모두 비교한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
function is(x, y) {
if (x === y) {
return x !== 0 || y !== 0 || 1 / x === 1 / y;
} else {
return x !== x && y !== y;
}
}

function shallowEqual(objA, objB) {
if (is(objA, objB)) return true;
// is를 사용한 얕은비교
if (
typeof objA !== "object" ||
objA === null ||
typeof objB !== "object" ||
objB === null
) {
return false;
}
// 얕은 비교가 끝났는데 객체가 아니거나 null이면 false
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
// 객체의 key를 가져옴
if (keysA.length !== keysB.length) return false;
// key의 길이가 다를경우 false
for (let i = 0; i < keysA.length; i++) {
if (
!Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
!is(objA[keysA[i]], objB[keysA[i]])
// objB에 keysA[i]가 존재하지 않을 경우
// objA[keysA[i]]와 objB[keysA[i]]가 is로 비교하였을 때 다른 값일 경우
// false
) {
return false;
}
}
return true;
}

ShallowEqual을 사용하여 객체의 reference의 변경만으로 렌더링을 트리거하지 않도록 할 수 있게 되었다.

reat-redux는 현재 React 18부터 제공되는 useSyncExternalStoreHook을 사용하도록 변경되었다.

하지만 구독/발행 패턴에서 기존의 ContextAPI의 렌더링이슈를 해결한 방법에 대해서 다룰 가치가 있다고 생각하여 다루어보았다.

위에서 언급한 useSelectorWithStoreAndSubscription의 전체 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
function useSelectorWithStoreAndSubscription(
selector,
equalityFn,
store,
contextSub
) {
const [, forceRender] = useReducer((s) => s + 1, 0);
const subscription = useMemo(
() => new Subscription(store, contextSub),
[store, contextSub]
);
const latestSelector = useRef();
const latestSelectedState = useRef();
let selectedState;
if (selector !== latestSelector.current) {
// 첫 상태
selectedState = selector(store.getState());
} else {
// 첫 상태가 아닐경우
selectedState = latestSelectedState.current;
}
useEffect(() => {
// 렌더링마다 참조를 변경
latestSelector = selector;
latestSelectedState = selectedState;
});

useEffect(() => {
function checkForUpdates() {
const newSelectedState = latestSelector.current(store.getState());
if (equalityFn(newSelectedState, latestSelectedState.current)) {
// 같을경우 렌더링 안함
return;
}
latestSelectedState.current = newSelectedState;
// 다를경우 state를 업데이트하고 렌더링을 트리거
forceRender({});
}
subscription.onStateChange = checkForUpdates;
subscription.trySubscribe();
checkForUpdates();
return () => subscription.tryUnsubscribe();
}, [store, subscription]);
return selectedState;
}

useSyncExternalStore가 개선한 사항들은 여기여기서 볼 수 있다.

다음에는 zustand 코드를 보고 ContextAPI를 사용하지 않고 상태관리를 한 방법에 대해서 알아보려고 한다.

React에서 webp 사용하기

호환성을 고려하여 web를 사용하는데에는 여러 방법이 있다.

picture태그

picture태그 내부에 img태그를 넣어 fallback 이미지를 제공할 수 있다.

1
2
3
4
5
<picture>
<source type="image/webp" srcset="flower.webp">
<source type="image/jpeg" srcset="flower.jpg">
<img src="flower.jpg" alt="">
</picture>

css background로 사용하기

1px x 1px webp 이미지를 만들고 로드하여 결과에 따라 body에 webp혹은 no-webp 클래스를 추가하여 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export function detectWebpSupport() {
const image = new Image();
// 1px x 1px WebP 이미지
const webpdata =
"data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA=";
const callback = (event) => {
if (result) {
document.body.classList.add("webp");
} else {
document.body.classList.add("no-webp");
}
};
image.onerror = callback;
image.onload = callback;
image.src = webpdata;
}

이후 다음과같이 css를 작성하면 된다.

1
2
3
4
5
6
.webp .bg {
background-image: url("/main/top-image.webp");
}
.no-webp .bg {
background-image: url("/main/top-image.png");
}

혹은 Context API를 사용하여 props로 넘겨주면 된다.

absolute 이용해서 레이아웃만들기

개념적으로 알고있는 내용이였는데, 과제를 진행하면서 직접 하게되어서 정리해보려고 한다.

다음과 같은 화면 구현인데, 레이아웃을 구분하기 위해서 빨간 선을 만들어두었다.

example

한 쪽 요소를 absolute로 쌓임맥락에서 벗어나게 한 다음 반대 쪽 요소를 랩핑한 후 padding을 이용하여 정렬하는 방법이다.

flex를 쓰기 위해서 필요없는 <div>태그가 많이 들어갈 때가 있는데, 그럴 경우 훨씬 더 적은 태그로 배열할 수 있기 때문에 좋은 방법 같다.

js substr substring slice 차이점

자바스크립트에는 문자열을 자르는 여러 함수들이 있다.

이 함수들을 비교해보자.

String.slice(beginIndex,endIndex)

beginIndex에서 endIndex 전까지의 부분 문자열을 반환한다.

beginIndex

beginIndex는 시작점이 0부터 시작하는 인덱스이다.
만약 음수라면 beginIndex는 String.length+beginIndex로 취급된다.

beginIndex가 String.length보다 크거나 같은 경우 빈 문자열을 반환한다.

endIndex

endIndex는 옵션값이며 0부터 시작하는 추출 종료점 인덱스로, 그 직전까지 추출된다 즉 인덱스 위치의 문자는 포함되지 않는다.

endIndex가 생략될경우 마지막 문자까지 추출하며, 마찬가지로 음수일 경우엔 String.length+endIndex로 취급된다.

1
2
3
4
5
let str = "안녕하세요.";
console.log(str.slice(0, 1)); //안
console.log(str.slice(0, 2)); //안녕
console.log(str.slice(-3)); //세요.
console.log(str.slice(-3, -2)); //세

String.substr(start,length)

바람직하지 않은 특징을 갖고있어 사용처가 없어지면 명세에서 제거될 함수라고 한다.

start에서 length만큼의 문자를 반환하는 함수이다.

start

start는 추출하고자 하는 문자들의 시작위치이다.

음수가 주어다면 문자열 총길이 + start 값으로 취급한다.

start가 양수이고 문자열 전체길이보다 크기가 같거나 클 경우 빈 문자열을 반환한다.

length

length는 옵션값으로 추출한 문자들의 길이이다.

length가 0 또는 음수일 경우 빈 문자열을 반환하며,

length가 생략되면 끝까지 추출하여 반환한다.

1
2
3
4
5
let str = "안녕하세요.";
console.log(str.substr(0, 1)); //안
console.log(str.substr(0, 2)); //안녕
console.log(str.substr(-3)); //세요.
console.log(str.substr(-3, -2)); //

String.substring(indexStart,indexEnd)

시작 인덱스부터 종료 인덱스 전까지 문자열의 부분 문자열을 반환한다.

indexStart

반환 문자열의 시작 인덱스이다.

0보다 작을 경우 0으로 String.length보다 클 경우 String.length로 취급된다. NaN값은 0으로 처리된다.

indexEnd

indexEnd는 옵션값이며, 반환 문자열의 마지막 인덱스이다.

indexEnd의 직전까지 추출한다. 즉 인덱스 위치의 문자는 포함되지 않는다.

indexEnd가 생략되었을 경우, 끝까지 모든 문자를 반환한다.

만약 indexStart와 같을 경우 빈 문자열을 반환한다.

indexStart보다 작을 경우 String.substring(indexEnd,indexStart)처럼 작동한다.

0보다 작을 경우 0으로 String.length보다 클 경우 String.length로 취급된다. NaN값은 0으로 처리된다.

1
2
3
4
5
6
let str = "안녕하세요.";
console.log(str.substring(0, 1)); //안
console.log(str.substring(1, 0)); //안
console.log(str.substring(0, 2)); //안녕
console.log(str.substring(-3)); //안녕하세요.
console.log(str.substring(-3, -2)); //

CRA + emotion 패키지 설정

JSX Pragma를 사용하려면 최상단에 다음과 같은 주석을 달아주면 된다.

1
/** @jsxImportSource @emotion/react */

JSX Pragma없이 사용하려면 babel을 수정해주어야 하는데, CRA는 커스텀을 막고있다. 때문에 외부 패키지를 설치해서 수정해주어야 한다.

1
npm i react-app-rewired customize-cra

babel 플러그인도 설치하자.

1
npm i @emotion/babel-plugin-jsx-pragmatic @babel/plugin-transform-react-jsx

이후 루트폴더에 두 가지 파일을 만든다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// config-overrides.js

const {
useBabelRc,
removeModuleScopePlugin,
override,
} = require('customize-cra')

module.exports = override(useBabelRc(), removeModuleScopePlugin())
// .babelrc

{
"plugins": [
[
"@emotion/babel-plugin-jsx-pragmatic",
{
"export": "jsx",
"import": "__to_avoid_prepending_/** @jsxImportSource @emotion/react */",
"module": "@emotion/react"
}
],
[
"@babel/plugin-transform-react-jsx",
{
"pragma": "__to_avoid_prepending_/** @jsxImportSource @emotion/react */"
}
]
]
}

package.json도 수정해준다.

1
2
3
4
5
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
},

JSX Pragma없이 사용할 수 있다.

requestAnimationFrame 활용하기

animation을 사용할 때에는 Timer 함수보다는 requestAnimationFrame을 사용해야 한다.

js의 메인쓰레드가 아닌 애니메이션 프레임에서 실행되기 때문에 애니메이션 프레임이 소실되거나 여러개의 애니메이션을 실행할 경우에 서로 충돌하는 경우를 방지하기 위해서이다.

최근 숫자가 증가하는 애니메이션을 구현하였는데, 요구사항은 다음과 같다.

  1. 증가량이 점점 감소할 것(목표치에 가까워지면 조금씩 증가)
  2. 2초 동안 동작할 것

1번 요구사항때문에 increment라는 변수를 두어 step 함수의 실행마다 step*=0.9를 해주고 setCurNumber(maxNumber-increment)를 통해 구현하였다.

그리고 2번 요구사항으로 인해 Timer 함수가 아닌 requestAnimationFrame을 사용하게 되었는데, 이렇게 구현하게 되면 2번 요구사항을 만족할 수 없었다.

requestAnimationFrame의 호출주기는 주사율에 따라 결정되고 주사율은 다 다르기 떄문에 기기마다 같은 시간동안 호출되는 횟수도 다르고 시간제한을 넣게되면 호출 횟수, 시간, 현재 값의 세 변수에 따라 실행하게 되는데 이러한 점화식을 찾지 못하였다.

결론적으로 말하자면 위에서 말한 점화식(ease함수)를 만들어 가중치를 계산하여 증가속도를 구현하였다.

ease함수는 여기를 참조하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { useState, useEffect, useRef, useCallback } from "react";

function easeOutExpo(t) {
return t == 1 ? 1 : -Math.pow(2, -10 * t) + 1;
}

export const useIncreaseNumber = (
maxNumber,
initialNumber = 0,
duration = 2000
) => {
const [curCount, setcurCount] = useState(initialNumber);
const curTime = useRef(null);

const step = useCallback((timeStamp) => {
if (curTime.current === null) curTime.current = timeStamp;

const progress = timeStamp - curTime.current;

const nextCount = Math.ceil(
(maxNumber - initialNumber) * easeOutExpo(progress / duration)
);

const nextCurCount = nextCount > maxNumber ? maxNumber : nextCount;

setcurCount(nextCurCount);

if (progress < duration) {
window.requestAnimationFrame(step);
}
}, []);

useEffect(() => {
window.requestAnimationFrame(step);
}, []);

return curCount;
};

주사율을 제한하여 구현하는 아이디어도 생각했는데, 이에 대해서 정리해보려고 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import { useState, useEffect, useRef, useCallback } from "react";

function easeOutExpo(t) {
return t == 1 ? 1 : -Math.pow(2, -10 * t) + 1;
}

export const useIncreaseNumber2 = (
maxNumber,
initialNumber = 0,
duration = 1000,
fpsHz = 1000 / 30
) => {
const [curCount, setcurCount] = useState(initialNumber);
const curTime = useRef(null);
const lastTime = useRef(null);
const cnt = useRef(0);

const step = useCallback((timeStamp) => {
if (curTime.current === null && lastTime.current === null) {
curTime.current = window.performance.now();
lastTime.current = window.performance.now();
}

const totalElapsed = window.performance.now() - curTime.current;
// 총 실행시간으로 duration과 비교하여 rAF를 탈출하기위해 사용한다.

if (totalElapsed > duration) {
return;
}
// duration일경우 탈출

const elapsed = timeStamp - lastTime.current;
// 마지막 상태변경과의 시간 차이

if (elapsed >= fpsHz) {
// 마지막 상태변경과의 시간차이가 주사율 이상일 경우에만 값을 반영한다.
const nextCount = Math.ceil(
(maxNumber - initialNumber) * easeOutExpo(totalElapsed / duration)
);
// ease 함수를 이용하여 가중치를 계산하여 반영한다.

const nextCurCount = nextCount > maxNumber ? maxNumber : nextCount;
// 호출 횟수에 따른 보정을 해준다.

console.log(++cnt.current);
// 몇 번 렌더링 되었는지 출력한다.
setcurCount(nextCurCount);
// state를 바꾼다
lastTime.current = timeStamp - (elapsed % fpsHz);
// 마지막 상태변경시간을 업데이트한다.
}
window.requestAnimationFrame(step);
}, []);

useEffect(() => {
window.requestAnimationFrame(step);
}, []);

return curCount;
};
// cnt.current의 마지막 값은 29가 출력된다.

setInterval의 경우 두 번째 parameter가 호출 주기이기 때문에 반환값을 통해서 쓰로틀링을 충분히 구현할 수 있지만, requestAnimationFrame의 경우 window.perfomance.now()를 통해 이를 직접 구현해줘야한다.

상태를 변경하여 브라우저에 변경사항이 반영되는 횟수를 제한하였다.

신경써야 할 점은 lastTime.current 계산식이다.

timeStamp는 마지막 호출의 시간이고 elapsed는 timeStamp와 마지막 상태 변경시간의 차이이다.

상태를 변경하는 조건은 elapsed가 fpsHz보다 클 경우이므로 lastTime을 timeStamp로 하게 되면 elapsed-fpsHz만큼의 값이 축적되어 계산되게 된다.

lastTime.current에 timeStamp를 그냥 저장하게 되면 elapsed-fpsHz만큼의 값이 축적되어 계산되게 된다. 떄문에 lastTime.current에는 timeStamp-(elapsed-fpsHz)를 저장해주어야 한다.

elapsed값은 계속 증가하는 값이기 때문에 나누기 연산으로 계산해주었다.

반응형 rem 미세팁

웹 접근성과 반응형을 위해 마진, 패딩등의 단위로 rem을 많이 사용한다.

rem은 html의 font-size 기준으로 기본 font-size는 16px이다.

그래서 계산이 조금 어려운데, 다음과 같이 수정해주면 계산을 쉽게 할 수 있다.

1
2
3
html {
font-size: 10px;
}

html의 font-size가 10px이 되기 때문에 계산을 좀 더 편하게 할 수 있다.

하지만 이렇게 고정값을 줄 경우 브라우저에서 font-size 관련 설정을 larger 등으로 바꾸어뒀을 경우에도 고정되는 문제가 생긴다.

1
2
3
4
html {
font-size: 62.5%;
}
/* 16*0.625===10 */

다음과 같이 작성해주면 브라우저의 larger 설정으로 font-size가 20이 되더라도 그걸 반영하여 적용되게 된다.

바닐라 js로 browser router 만들기

SPA를 만들기 위해선 router 구현이 필요하다.

vanillaJS로 browser router를 구현해보았다.

기본적으로 SPA의 동작원리는 다음과 같다.

key를 url path로 value를 component로 하는 객체 리터럴을 만든다.

페이지 이동을 할 때에 이 객체 리터럴에서 페이지에서 파싱한 값을 통해 component를 조회하여 렌더링한다

페이지 이동을 정의하는 방법엔 두 가지가 있다. 함수를 통해 pushState로 바꿔주고 innerHTML을 바꾸어주거나 혹은 이 작업을 커스텀 이벤트로 정의하는 방식이다.

두 번째( 커스텀 이벤트를 정의하는 방식 )으로 browser router를 구현해보았다.

Router 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Router {
constructor() {}
// 생성자
initEvent() {}
// 이벤트 초기화
onRouterChangeHandler() {}
// route가 변경되었을 때의 이벤트 핸들러
hasRoute() {}
// 올바른 라우트인지 검증
getRoute() {}
// 해당 라우트 가져오기
renderPage() {}
// 페이지 렌더링
push() {}
// 라우터 push
}

Router 초기화

1
2
3
4
5
6
7
8
9
10
11
12
13
constructor({
$app,routes,fallback='/'
}){
this.$app=$app;
this.fallback=fallback;
this.routes=[];

routes.forEach(route=>{
this.routes=[route.path]=route.page;
})

this.initEvent();
}

$app,fallback,route를 초기화하고 initEvent를 실행한다.

커스텀 이벤트 추가

browser router는 history API를 사용하여 주소를 변경한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
initEvent() {
document.addEventListener(
"moveRoutes",
this.moveRoutesHandler.bind(this) as EventListener
);
}

moveRoutesHandler(event: CustomEvent) {
const path: string = event.detail.path;
history.pushState(event.detail, "", path);

this.renderPage(path);
}

moveRoutes라는 이벤트를 추가해주어야 한다.

1
2
3
4
5
6
7
export const customEventEmitter = (eventType: string, detail?: object) => {
document.dispatchEvent(
new CustomEvent(eventType, {
detail,
})
);
};

커스텀 이벤트를 정의하는 함수를 만든다.

페이지 렌더링

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
hasRoute(path: string) {
return typeof this.routes[path] !== "undefined";
}

getRoute(path: string) {
return this.routes[path];
}

renderPage(path: string) {
let route;
const regex = /\w{1,}$/;

if (this.hasRoute(path)) {
route = this.getRoute(path);
// 경로가 존재할경우
} else if (regex.test(path)) {
route = this.getRoute(path.replace(regex, ":id"));
// 경로가 존재하지 않을 경우 동적 라우팅(id)
} else {
route = this.getRoute(this.fallback);
// 그 외의 주소에 대해서는 fallback 실행
}
new route(this.$app, {});
// route 객체의 인스턴스를 만듬
}

push(path: string) {
customEventEmitter("moveRoutes", {
...history.state,
path,
});
}

라우터 export

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export let router;

export function initRouter($app, routes, fallback) {
const routerObj = new Router($app, routes, fallback);

router = {
push: (path) => routerObj.push(path),
};

customEventEmitter(
"moveRoutes",
history.state ?? {
path: "/",
}
);
}

모듈패턴을 이용하여 push를 제외한 나머지는 private로 두었다.

그리고 초기에 customEventEmitter를 통해 루트 페이지가 렌더링 될 수 있도록 하였다.

사용하기

1
2
3
4
5
6
7
const routes = [
{ path: "/", page: App },
{ path: "/detail/:id", page: Detail },
];

const target = document.querySelector(".App");
initRouter(target, routes);

Ref

바닐라JS(TS)로 리액트 SPA 구현하기 | (4) 클래스로 BrowserRouter 구현
Build a custom SPA Router using VanillaJS

Typescript readonly와 const의 차이

readonly와 const의 차이에 대해 알아보았다.

readonly

  • 값을 속성을 읽기 전용으로 설정하는 Typescript의 타입이다.
  • 컴파일 시에 타입검사한다.
  • 얕게 동작한다.

즉 다음과 같은 코드는 에러가 발생하지 않는다.

1
2
const datas: readonly number[][] = [[1], [2], [3], [4]];
datas[0][0] = 1;
  • 객체의 속성까지 readonly를 적용하기 위해선 객체를 as const로 타입 단언하면 된다.

const

  • 변수를 읽기전용으로 설정한다. 즉 변수에 다른 값을 할당할 수 없는 상수를 만든다.
  • 런타임에도 읽기전용으로 작동하게 한다.

결론

  • 의도치 않은 동작을 방지하기 위해 readonly도 적용해 주는 것이 좋다.

input 태그의 width

input에 width:100%을 주어도 overflow가 일어난다.

1
2
3
input {
width: 100%;
}

input태그는 기본적으로 border가 있기 때문에 box-sizing을 바꾸어주어야 한다.

1
2
3
input {
box-sizing: border-box;
}