webpack을 사용한 SPA 구현에서 class name 중복 피하기

JaeungE·2021년 11월 30일
0

개발자의 친구들

목록 보기
5/5
post-thumbnail

왜 필요한가?

Vanilla JSSingle Page Application(SPA)을 구현하는 도중 발생한 문제다.

webpack은 의존 관계에 있는 모듈들을 하나로 번들링 해주는 모듈 번들러다. 그래서 각 view에 맞춰서 css를 작성하고 번들링 하게 되면, 서로 다른 css에서 작성한 class name이 충돌하는 경우가 있다.

이렇게 되면 css의 캐스케이딩 규칙에 의해 페이지가 의도하지 않은대로 보여질 수도 있다. 물론 각각의 css의 네이밍을 다르게 할 수도 있지만, 이미 작성된 css의 경우 하나하나 바꿔주는 것도 여간 귀찮은 일이 아니다.

그래서 위와 같은 상황을 회피하기 위해 webpackcss-loader는 옵션을 통해 class name의 중복을 회피하는 방법을 제공해준다.

그러면 아래에서 간단한 SPA 예제와 함께 어떤 문제가 일어나는지 직접 확인해보자!!



class name 중복의 예

예제에서 사용할 파일의 구조는 다음과 같다.

static은 기존 정적 파일들을 담고있는 디렉터리고, distwebpack을 통해 번들링된 파일들을 담고있는 디렉터리이다.

번들링 하기 전에 각 파일들의 내용들을 한 번 훑어보자.



js

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 구현을 위해 만들어진 간단한 라우팅 코드다. MainViewContentView를 모듈로 사용하고 있다.


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다. AbstractViewmain.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다. 위와 마찬가지로 상속에 필요한 jscss를 모듈로 사용한다.


AbstractView.js

export default class {
    constructor() {}

    setTitle(title) { document.title = title; }

    async getHtml() { return "" };
}

모든 view 클래스의 부모가 되는 클래스이다.



css

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 모듈에 이름을 부여하고, 적용하려는 요소의 classModuleName.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]

0개의 댓글