스크롤 바로 인한 overflow 해결방법

스크롤바로 인해 발생하는 overflow를 해결한 과정을 정리해보았다.

첫 번째는 CSS를 사용하여 스크롤바 자체를 보이지 않게하는 방법이다.

1
2
3
4
5
6
7
.box {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.box::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera*/
}

하지만 웹접근성을 고려하면 좋은 방법은 아니다.

두 번째는 overflow:overlay를 통해 스크롤바 자체를 띄우는 방법이다.

1
2
3
.box {
overflow: overlay;
}

하지만 이 방법은 webkit기반의 일부 브라우저에만 적용된다.

생각보다 해결 방법은 간단하였다.

react를 사용하여 spa를 만들경우 body 바로 아래에 div 태그를 만들어 거기에 동적으로 html을 삽입하게 된다.

보통 id를 root로 하여 사용하게 되는데, 이 root 아래의 컨텐츠의 overflow를 사용하기 위해 width와 height를 브라우저의 너비와 같도록 설정하게 된다.

너비를 기준으로 할 때엔 다음과 같은 두 가지 방법이 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#root {
width: 100vw;
overflow-y: scroll;
}
/* 또는 */

html,
body,
#root {
width: 100%;
}
#root {
overflow-y: scroll;
}

나는 첫 번째 방법을 사용하고 있었는데, 100vw는 스크롤의 너비까지 포함된 값이였고, 100%는 스크롤의 너비를 포함하지 않는 값이기 때문에 아래와 같이 수정 해주었다.

스크롤바로 인해 발생하는 reflow를 없앨 수 있었다.

HTML5 pre태그

HTML <pre> 요소는 미리 서식을 지정한 텍스트를 나타내며, HTML에 작성한 내용 그대로 표현합니다. 텍스트는 보통 고정폭 글꼴을 사용해 렌더링하고, 요소 내 공백문자를 그대로 유지합니다.

  L          TE
    A       A
      C    V
       R A
       DOU
       LOU
      REUSE
      QUE TU
      PORTES
    ET QUI T'
    ORNE O CI
     VILISÉ
    OTE-  TU VEUX
     LA    BIEN
    SI      RESPI
            RER       - Apollinaire

history api 알아보기

history API는 html5에 도입되었다.

SPA의 라우팅 로직에 주로 쓰인다.

histroy API 는 history객체(window.history)를 활용하여 주소를 바꾼다.

여러 메소드들이 있지만 SPA에서 쓰이는 메소드는 다음과 같다.

history.pushState(state,title,url)

history.pushState() 메서드는 브라우저의 세션 기록 스택에 상태를 추가하는 함수이다.

state

state는 바뀐 주소와 저장할 데이터 객체이다.
history의 상태가 바뀔 때마다 popstate 이벤트가 발생하는데, 이때 이벤트 객체의 state 속성에 해당 상태의 복제본이 담기게 된다.

주소와 함께 데이터를 history.state에 저장할 수 있기 때문에 유용하다.

바뀔 페이지의 정보들을 담아두고 history.state에 접근하여 새로운 페이지를 렌더링하면 된다.

title

제목이지만 대부분의 브라우저에서 title을 지원하지 않는다. 빈 문자열을 제공하면 된다.

url

바뀔 주소이다.

절대경로를 제공하려면 /url 상대경로를 제공하려면 ‘url’의 형태로 제공하면 된다.

history.replaceState(state,title,obj)

history.replaceState()는 현재 history를 수정해 stateObj,title,URL로 대체한다. history.pushState()는 추가를 하지만 history.replaceState는 업데이트한다.

popstate

popstate이벤트는 pushStatereplaceState후에 뒤로가기나 앞으로가기를 할 경우에 발생한다.

Ref

Single Page Application & Routing

함수형 컴포넌트의 생명주기

lifecycle

함수형 컴포넌트의 생명주기는 다음과 같다.

별건 아닌데 react-redux 내부의 useEffect에서 ref와 useEffect를 통해 이전 상태를 저장하는 트릭이 여러 라이브러리나 커스텀 훅에 많이 쓰여서 작성한다.

1
2
3
4
5
6
7
8
9
function usePreviousState(value) {
const ref = useRef(value);

useEffect(() => {
ref.current = value;
}, [value]);

return ref.current;
}

대충 간단한 커스텀 훅을 작성해보았다.

이 커스텀 훅은 이렇게 사용된다.

1
2
3
4
const Component = () => {
const [value, setValue] = useState(1);
const prev = usePreviousState(value);
};

커스텀 훅을 풀어보면 다음과 같다.

1
2
3
4
5
6
7
8
const Component = () => {
const [value, setValue] = useState(1);
const ref = useRef(value);

useEffect(() => {
ref.current = value;
}, [value]);
};

value가 바뀌었을 때 위 코드는 다음과 같은 순서로 동작한다.

  1. setValue가 렌더링을 트리거
  2. 가상돔의 변경사항을 DOM에 업데이트
  3. useEffect가 실행되며 ref의 값을 업데이트(ref 값의 변경은 렌더링을 트리거하지 않는다.)
  4. 다음 렌더링시에는 ref는 이전 value를 참조

이전의 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값은 계속 증가하는 값이기 때문에 나누기 연산으로 계산해주었다.