blog

void el.offsetHeight는 왜 쓸까? 강제 리플로우로 트랜지션 시작하기

Front-end / JavaScript#CSS transition#reflow#DOM#렌더링

CSS 애니메이션 코드에서 종종 void el.offsetHeight; 같은, 아무 일도 하지 않는 것처럼 보이는 줄을 만난다. 이 줄은 브라우저에게 지금까지의 스타일 변경을 즉시 계산하라고 강제하는 역할을 한다.

offsetHeight를 읽으면 생기는 일

브라우저는 DOM·스타일 변경을 바로 처리하지 않고 모아 두었다가 다음 렌더링 시점에 한꺼번에 계산한다. 그런데 offsetHeight, getBoundingClientRect(), getComputedStyle()처럼 현재 레이아웃 값을 읽는 API를 호출하면, 정확한 값을 돌려주기 위해 모아 둔 변경을 그 자리에서 계산(스타일 재계산 + 레이아웃, 흔히 강제 리플로우)한다.

void 연산자

void는 식을 평가한 뒤 결과를 버리고 undefined를 반환한다.

void 0; // undefined

void el.offsetHeight는 "값은 필요 없고, 읽는 행위로 계산만 일으키겠다"는 의도를 드러낸다. 린터의 "사용하지 않는 식" 경고를 피하는 효과도 있다.

트랜지션이 동작하지 않는 이유

.box {
  opacity: 0;
  transition: opacity 0.3s;
}

.box.visible {
  opacity: 1;
}
const el = document.createElement('div');
el.className = 'box';
document.body.appendChild(el);

el.classList.add('visible'); // 트랜지션 없이 바로 보임

요소를 추가하고 같은 작업 안에서 visible을 붙이면, 브라우저가 스타일을 계산하는 시점에는 이미 최종 상태(opacity: 1)다. 시작 상태(opacity: 0)가 한 번도 계산된 적이 없으니 "0에서 1로 변했다"는 트랜지션도 일어나지 않는다.

해결: 시작 상태를 강제로 계산시키기

document.body.appendChild(el);

void el.offsetHeight; // 여기서 opacity: 0 상태가 계산됨

el.classList.add('visible'); // 0 → 1 트랜지션 실행

강제 리플로우로 시작 상태를 먼저 확정했기 때문에, 이후 클래스 변경이 "변화"로 인식되어 트랜지션이 실행된다. 같은 애니메이션을 다시 재생하려고 클래스를 뺐다가 바로 다시 붙일 때도 같은 원리를 쓴다.

주의할 점과 대안

  • 강제 리플로우는 비용이 크다. 반복문 안에서 쓰기와 읽기를 번갈아 하면 레이아웃 스래싱(layout thrashing)이 생긴다. 필요한 곳에 한 번만 쓴다.
  • 렌더링 프레임을 넘겨서 해결할 수도 있다.
requestAnimationFrame(() => {
  requestAnimationFrame(() => el.classList.add('visible'));
});
  • 시작 상태를 CSS로 선언할 수 있다면 @starting-style(최신 브라우저)이나 CSS @keyframes 애니메이션으로 JS 트릭 없이 처리할 수 있다.
  • Vue <Transition>, React Transition Group 같은 라이브러리도 내부에서 같은 원리(강제 리플로우나 프레임 넘기기)를 사용한다.