state를 바꾼 직후 DOM을 읽었더니 이전 값이 나온 이유
문제 발생
목록에 항목을 추가한 뒤 바로 맨 아래로 스크롤하려 했는데, 항상 한 칸 모자란 위치에 멈췄습니다.
function addMessage(text) {
messages.value.push(text);
list.value.scrollTop = list.value.scrollHeight; // 방금 추가한 항목이 안 보임
}scrollHeight를 찍어 보니 추가 전의 높이였습니다.
원인 분석
Vue에서 반응형 상태를 바꾸면 DOM은 자동으로 갱신되지만, 공식 문서가 명시하듯 그 갱신은 동기적으로 적용되지 않습니다. Vue는 갱신을 다음 틱(next tick) 까지 모아 두었다가 한 번에 처리합니다. 상태를 몇 번을 바꾸든 컴포넌트가 한 번만 갱신되게 하기 위해서입니다.
그래서 push 바로 다음 줄에서 DOM을 읽으면 아직 이전 상태의 DOM입니다. 값이 잘못된 것이 아니라 시점이 이른 것입니다.
이 버퍼링은 성능상 이득이 큽니다. 반복문 안에서 상태를 100번 바꿔도 실제 DOM 조작은 한 번입니다. 대신 "상태를 바꾼 직후의 DOM"을 읽고 싶을 때는 명시적으로 기다려야 합니다.
해결 방안
nextTick()을 기다립니다. Promise를 반환하므로await로 씁니다.
import { nextTick } from "vue";
async function addMessage(text) {
messages.value.push(text);
await nextTick();
list.value.scrollTop = list.value.scrollHeight; // 갱신 이후
}콜백 형태도 가능합니다.
nextTick(() => {
list.value.scrollTop = list.value.scrollHeight;
});- 상태 변경에 반응해 매번 DOM을 읽어야 하면
flush: "post"감시자를 씁니다. 호출부마다nextTick을 흩뿌리지 않아도 됩니다.
watch(
() => messages.value.length,
() => {
list.value.scrollTop = list.value.scrollHeight;
},
{ flush: "post" },
);watchPostEffect()도 같은 타이밍의 축약형입니다.
- 측정이 필요한 작업은 전부 같은 원칙입니다. 방금 렌더된 요소의 크기·위치를 읽는 코드는 모두 갱신 이후여야 합니다.
async function focusNewInput() {
fields.value.push(createField());
await nextTick();
inputRefs.value.at(-1)?.focus();
}-
onMounted안이라고 안전한 것은 아닙니다. 마운트 시점의 DOM은 있지만, 그 안에서 상태를 바꾸고 다시 읽으려면 여전히 기다려야 합니다. -
템플릿 ref는 렌더 이후에 채워집니다.
ref로 선언만 하고 setup 본문에서 바로 읽으면null입니다.onMounted나nextTick이후에 접근합니다.
const list = ref(null);
onMounted(() => {
console.log(list.value); // 여기서는 실제 요소
});nextTick을 반복해서 겹쳐 부르지 않습니다. 두 번 기다려야 동작한다면 대개 원인이 다른 곳(자식 컴포넌트의 비동기 렌더,<Suspense>, 이미지 로드)입니다. 타이밍을 늘려 덮는 대신 그 원인에 맞는 이벤트를 기다립니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.