Vanilla JS
로 Single Page Application(SPA)을 구현하는 도중 발생한 문제다.
webpack
은 의존 관계에 있는 모듈들을 하나로 번들링 해주는 모듈 번들러다. 그래서 각 view
에 맞춰서 css
를 작성하고 번들링 하게 되면, 서로 다른 css
에서 작성한 class name이 충돌하는 경우가 있다.
이렇게 되면 css
의 캐스케이딩 규칙에 의해 페이지가 의도하지 않은대로 보여질 수도 있다. 물론 각각의 css
의 네이밍을 다르게 할 수도 있지만, 이미 작성된 css
의 경우 하나하나 바꿔주는 것도 여간 귀찮은 일이 아니다.
그래서 위와 같은 상황을 회피하기 위해 webpack
의 css-loader
는 옵션을 통해 class name의 중복을 회피하는 방법을 제공해준다.
그러면 아래에서 간단한 SPA 예제와 함께 어떤 문제가 일어나는지 직접 확인해보자!!
예제에서 사용할 파일의 구조는 다음과 같다.
static
은 기존 정적 파일들을 담고있는 디렉터리고, dist
는 webpack
을 통해 번들링된 파일들을 담고있는 디렉터리이다.
번들링 하기 전에 각 파일들의 내용들을 한 번 훑어보자.
router.js
import MainView from '../view/MainView.js';
import ContentView from '../view/ContentView.js';
const router = async () => {
const routes = [
{ path : '/main', view : MainView },
{ path : '/content', view : ContentView },
];
let match = routes.filter( route => location.pathname === route.path )[0];
if(!match) {
match = routes[0];
}
const view = new match.view();
document.querySelector('.app-root').innerHTML = await view.getHtml();
}
const navigate = (url) => {
history.pushState(null, null, url);
router();
}
window.addEventListener('popstate', router);
window.onload = () => {
document.body.addEventListener('click', (e) => {
if(e.target.matches('.nav-link')) {
e.preventDefault();
navigate(e.target.href);
}
});
router();
}
SPA
구현을 위해 만들어진 간단한 라우팅 코드다. MainView
와 ContentView
를 모듈로 사용하고 있다.
MainView.js
import AbstractView from './AbstractView.js';
import '../css/main.css';
export default class extends AbstractView {
constructor() {
super();
this.setTitle('MainView');
}
async getHtml() {
return `<h1 class="title">I'm MainView</h1>`;
}
}
/main
경로에서 보여줄 MainView
다. AbstractView
와 main.css
를 모듈로 사용하고 있다.
ContentView.js
import AbstractView from './AbstractView.js';
import '../css/content.css';
export default class extends AbstractView {
constructor() {
super();
this.setTitle('ContentView');
}
async getHtml() {
return `<h1 class="title">I'm ContentView</h1>`;
}
}
/content
경로에서 보여줄 ContentView
다. 위와 마찬가지로 상속에 필요한 js
와 css
를 모듈로 사용한다.
AbstractView.js
export default class {
constructor() {}
setTitle(title) { document.title = title; }
async getHtml() { return "" };
}
모든 view
클래스의 부모가 되는 클래스이다.
css
파일은 충돌이 일어나는 파일만 보도록 하자!
main.css
.title {
color : red;
}
content.css
.title {
color : blue;
}
각 view
에서 사용할 css
파일들이다. 이제 webpack
을 이용해서 번들링을 진행해보자! 설정은 다음과 같이 한다.
webpack.config.js
const path = require('path');
const miniCssExtract = require('mini-css-extract-plugin');
module.exports = {
mode : 'production',
entry : './resource/static/js/router.js',
output : {
filename : 'bundle.js',
path : path.resolve(__dirname, 'resource', 'dist', 'js')
},
module : {
rules : [{
test : /\.css$/,
use : [
miniCssExtract.loader,
'css-loader'
]
}]
},
plugins : [ new miniCssExtract( { filename : '../css/bundle.css' }) ]
}
번들링된 css
를 별도의 파일로 분리하기 위해 mini-css-extract-plugin
을 사용했다. 이제 결과를 확인해보자.
bundle.css
.title {
color : red;
}
.title {
color : blue;
}
번들링한 결과 각 파일에서 설정한 class name
이 충돌한 것을 볼 수 있다.
이렇게 되면 css의 캐스케이딩 규칙에 따라 동일한 선택자일 경우, 아래에 있는것이 더 높은 우선순위를 가지기 때문에 모든 .title
은 파란색이 될 것이다.
실제로 그렇게 되는지 직접 파일을 로드해서 확인해보자! 로컬 서버 설정과 html
파일은 다음과 같다.
server.js
const express = require('express');
const app = express();
const path = require('path');
app.use('/resource', express.static(path.resolve(__dirname, 'resource')));
app.get('/*', (req, res) => {
res.sendFile(path.resolve(__dirname, 'resource', 'index.html'));
});
app.listen(8082, () => { console.log('port 8082 is running.....') });
index.html
<!DOCTYPE html>
<html lang=en>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="/resource/static/css/frame.css">
<link rel="stylesheet" href="/resource/dist/css/bundle.css">
</head>
<body>
<nav class="navigation">
<div class="nav-wrap">
<a href="/main" class="nav-link">Main</a>
<a href="/content" class="nav-link">Content</a>
</div>
</nav>
<div class="app-root">
</div>
</body>
<script type="module" src="/resource/dist/js/bundle.js"></script>
</html>
localhost:8082/main
localhost:8082/content
역시 예상대로 모든 view
에서 title
class를 가진 요소의 색은 모두 파란색으로 통일돼있다.
하지만 걱정하지 말자! 간단한 webpack
설정을 통해 손쉽게 해결이 가능하다.
일단, webpack
설정을 다음과 같이 변경하자.
const path = require('path');
const miniCssExtract = require('mini-css-extract-plugin');
module.exports = {
mode : 'production',
entry : './resource/static/js/router.js',
output : {
filename : 'bundle.js',
path : path.resolve(__dirname, 'resource', 'dist', 'js')
},
module : {
rules : [{
test : /\.css$/,
use : [
miniCssExtract.loader,
{
loader : 'css-loader',
options : {
modules : {
localIdentName : "[local]--[hash:base64:5]"
}
}
}
]
}]
},
plugins : [ new miniCssExtract( { filename : '../css/bundle.css' }) ]
}
변경된 내용은 css-loader
에 옵션으로 localIdentName
을 부여했다. 이 옵션을 통해 각 CSS Module
이 고유한 네이밍을 가질 수 있도록 만드는 것이 가능하다.
이 외에도 해쉬 함수를 변경한다던가, 네이밍에 필요한 다양한 템플릿 문자열을 지원하고 있다. 더욱 자세한 내용은 css-loader | webpack을 참고하도록 하자!
설정을 위와 같이 변경했다면, 다시 번들링을 진행해보자.
bundle.css
.title--mRqqC {
color : red;
}
.title--rn3oI {
color : blue;
}
class name 뒤에 추가적으로 해쉬값이 들어간 것을 확인할 수 있다.
이렇게 되면 충돌 걱정은 없어졌는데, 어떻게 해당 class을 일일이 입력하지 않고 사용할 수 있을까? 방법은 다음과 같다.
MainView.js
import AbstractView from './AbstractView.js';
import main from '../css/main.css';
export default class extends AbstractView {
constructor() {
super();
this.setTitle('MainView');
}
async getHtml() {
return `<h1 class="${main.title}">I'm MainView</h1>`;
}
}
ContentView.js
import AbstractView from './AbstractView.js';
import content from '../css/content.css';
export default class extends AbstractView {
constructor() {
super();
this.setTitle('ContentView');
}
async getHtml() {
return `<h1 class="${content.title}">I'm ContentView</h1>`;
}
}
변경된 내용은 css
모듈에 이름을 부여하고, 적용하려는 요소의 class
를 ModuleName.ClassName
에 따라 변경해주면 된다. CSS Module
에 대한 자세한 내용은 css-modules를 참고하자.
이제 실제로 적용되었는지 확인해보자!
localhost:8082/main
localhost:8082/content
고유한 class name이 성공적으로 생성 및 적용된것을 볼 수 있다.
참고자료
css-loader | webpack
[https://webpack.js.org/loaders/css-loader/]
css-modules
[https://github.com/css-modules/css-modules]