Safari에서 Virtual DOM :last-child 스타일 버그
Vue.js로 만들어진 페이지에서 아이폰에서만 테두리가 잘못 나온다고 전달을 받았다.
확인해보니 맥 Safari에서도 재현이 되는데 li:last-child
에 지정해놓은 스타일이 마지막이 아닌 마지막 바로 위에 있는 li
에도 적용되고 있었다. 조금 더 관찰해보니 다음과 같은 특징이 있었다.
- 여러번 테스트해본 결과 해당 영역에 새로운 DOM이 추가되는 변화가 있을 때 발생했다.
- 기존 DOM과 새로 추가된 DOM에 각각
:last-child
가 이중으로 적용되는 현상이다. - 개발자 도구로 CSS에 엔터만 넣는 식의 스타일 변화없이 리로드만 시키는 수정만 해도 정상적으로 노출된다.
- Safari에서만 발생한다. (테스트 버전 12버전대)
서비스 오픈하자마자 발견된 사항이라 빠른 해결이 필요했고 해결 가능한 방법은 우연히 알아냈다.
javascript – iOS8 Safari pushState 후 : nth-child () 셀렉터가 작동하지 않습니다.
힌트가 된 글인데 현재 글 쓰는 시점에서 iOS가 12버전인 시대에서 iOS8 버전의 문제는 큰 도움이 되진 않지만 :nth-child
가 문제를 일으킨 전적이 있다는 점에 주목하여 마찬가지로 :last-of-type
으로 지정했더니 해당 문제는 더이상 발생하지 않았고 이 변화로 다른 문제도 발생하지 않았다.
그리고 팀 보고 후 팀장님이 하드웨어 가속과 관련하여 해결할 수 있는 사항이 아닌지 언급하셔서 가볍게 테스트해봤더니 이렇게도 해결이 가능하여 하드웨어 가속과 관련한 사항이라고 정리를 하려고 했었다.
확실한 정리를 위해 해당 프로젝트가 아닌 별도의 테스트용 뷰 프로젝트를 만들어 테스트를 시작했고, 최대한 가볍게 만들어보니 재현이 쉽지 않았다. 그도 그럴 것이 이렇게 크리티컬한 문제라면 개선됐어야 했고 개선이 안됐다 하더라도 구글링해서 확인할 길이 있었어야 하는데 아무도 이 문제에 대해 이야기하고 있지 않았다. 그리고 한참 삽질한 끝에 정확하게 이 상황을 재현할 수 있는 방법을 알아냈다.
:last-child에 해당하는 DOM이 아닌 이 엘리먼트의 자식 엘리먼트에만 스타일이 지정되어 있을 경우 재현이 된다.
즉, 추가 조건으로 li:last-child
에 스타일이 걸려있을 때 문제가 아니라 li:last-child a
와 같이 자식에게 스타일이 지정되어 있을때 재현이 됐다. 그리고 한번 이상 가상돔 변화가 있는 영역에서만 발생했다.
다시 정리해보면,
:last-child
에 해당하는 엘리먼트 자체가 아닌 이 엘리먼트의 자식 엘리먼트에만 스타일이 지정되어 있을 경우 재현이 된다.- 1회 이상 가상돔 변화가 이미 있었던 영역에서 발생한다.
- 운영에 적용한 해결방법대로
last-of-type
으로 해결 가능하다. transform: translateZ(0)
같이 하드웨어 가속을 임의로 하게 하는 핵을 써도 해결이 가능하다.
그리고 글을 쓰기 위해 정리하다가 하드웨어 가속 핵을 쓰지 않아도 해결이 가능하다는 것을 알았다. li:last-child
자체에 의미 없는 스타일을 지정해도 해결이 가능하다.
최종 정리하면,
- 정확한 재현 방법은 새로운 DOM이 추가되는 이벤트가 있어야하고 스타일은
:last-child a
처럼 마지막 엘리먼트의 자식에만 스타일을 지정해야한다. - 해결방법으로는
:last-child
대신:last-of-type
으로 바꾸는 것으로 해결이 가능하다. :last-child
를 유지하며 해결하는 방법은:last-child
자체에도 (의미가 없더라도) 최소한 1개의 스타일을 넣어준다.- 굳이 하드웨어 가속 핵을 사용하지 않아도 된다.
간단히 vue-cli로 프로젝트를 생성하여 재현한 코드는 다음이다.
<template>
<div id="app">
<button type="button" @click="listToggle">Toggle Button</button>
<section>
<div :class="'sandbox type' + i" v-for="(p, i) in type" :key="i">
<h1>{{ p }}</h1>
<ul>
<li v-for="(t, j) in items" :key="j">
<span>{{ t }}</span>
</li>
</ul>
</div>
</section>
</div>
</template>
<script>
export default {
name: 'app',
data() {
return {
type: [
':last-child',
':last-of-type',
':last-child + translate3d',
':last-child + ANYONE'
],
items: [
'1st', '2nd'
]
}
},
methods: {
listToggle: function() {
let arr = this._data.items;
if (arr.length > 2) {
arr.pop();
} else {
arr.push('3rd', '4th', '5th');
}
}
}
}
</script>
<style lang="scss">
body {
background: #eee;
}
button {
appearance: none;
display: block;
border: 0;
width: 100%;
height: 100px;
font-size: 100%;
}
section {
display: flex;
margin-top: 20px;
.sandbox {
flex: 1;
padding: 0 .5vw;
h1 {
height: 60px;
padding: 10px 0;
margin: 0;
border-bottom: 5px double #000;
text-align: center;
}
}
}
ul {
margin: 0;
padding: 0;
li {
list-style: none;
height: 100px;
line-height: 100px;
text-align: center;
border-bottom: 5px double #aaa;
span {
display: block;
background: #fff;
}
.type0 &:last-child span {
background: #faa;
}
.type1 &:last-of-type span {
background: #afa;
}
.type2 &:last-child {
transform: translateZ(0);
span {
background: #aaf;
}
}
.type3 &:last-child {
pointer-events: none;
span {
background: #faf;
}
}
}
}
</style>