Vanilla JavaScript로 구현한 SPA에 PWA를 적용해보았다.
1. 패키지 설치
npm i -D webpack-pwa-manifest workbox-webpack-plugin
2. Manifest 설정
// webpack.common.js
const WebpackPwaManifest = require('webpack-pwa-manifest');
...
plugin: [
...
new WebpackPwaManifest({
name: 'yummy',
short_name: 'yummy',
description: '맛집 정보 공유 웹앱',
background_color: '#ffffff',
crossorigin: 'anonymous',
dir: 'ltr',
display: 'standalone',
orientation: 'any',
scope: '/',
start_url: '/',
theme_color: '#ffffff',
icons: [
{
src: path.resolve(__dirname, 'public/logo-512x512.png'),
type: 'image/png',
sizes: [72, 96, 128, 144, 192, 384, 512],
},
],
filename: 'manifest.json',
}),
]
3. 모바일(IOS) 환경 padding 설정
manifest에서 display를 standalone으로 설정하면 모바일 웹의 주소창이 사라진, 네이티브 앱과 같은 모습의 UI를 가지게 된다. 그런데 이때 하단 내비게이션이나 버튼과 같은 요소가 하단에 있다면 스마트폰(아이폰)의 하단바와 영역이 겹치게 된다. 이를 해결하기 위해 모든 컨텐츠를 안전 영역(Safe Area) 안에 넣을 필요가 있다.
html {
padding-bottom: env(safe-area-inset-bottom);
}
#navbar {
background-color: white;
height: calc(env(safe-area-inset-bottom) + 60px);
padding-bottom: env(safe-area-inset-bottom);
}
필요한 영역에 env(safe-area-inset-bottom)을 padding-bottom으로 추가해주면 된다.
4. Offline 환경 캐시 설정
4-1. Offline 환경일 경우 Offline Fallback 보여주기
1) Service Worker 등록하기
// index.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('./sw.js')
.then((registration) => console.log('DONE: ', registration))
.catch((e) => console.log('SW registration failed:', e));
});
}
2) Webpack에서 Service Worker 설정하기
// webpack.common.js
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
...
plugin: [
...
new CopyWebpackPlugin({
patterns: [{ from: './offline.html', to: 'offline.html' }],
}), // dist 폴더에 offline.html 복사
new WorkboxWebpackPlugin.InjectManifest({
swSrc: './src/sw.js',
swDest: 'sw.js',
maximumFileSizeToCacheInBytes: 1024 * 1024 * 10,
}),
]
3) offline.html
// offline.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>인터넷 연결 없음</title>
<style>
* {
box-sizing: border-box;
margin: 0;
}
body {
height: 100dvh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
font-family:
'Pretendard Variable',
'Pretendard',
-apple-system,
system-ui,
sans-serif;
}
h1 {
font-size: 40px;
color: #ffcd00;
}
p {
font-size: 16px;
color: #555;
}
</style>
</head>
<body>
<h1>⚠️</h1>
<p>오프라인 상태예요.</p>
<p>인터넷 연결을 확인해 주세요.</p>
</body>
</html>
4) Service Worker 작성
처음 코드
// src/sw.js
const CACHE_NAME = 'yummy-cache-v1';
const FILES_TO_CACHE = ['/', './manifest.json', '/offline.html'];
self.addEventListener('install', async (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(FILES_TO_CACHE)),
);
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') {
return;
}
event.respondWith(
fetch(event.request).catch(() =>
caches.open(CACHE_NAME).then((cache) => cache.match('/offline.html')),
),
);
});
결과
이렇게 설정하면 끝인 줄 알았는데... production build를 하니
위와 같이 Can't find self.__WB_MANIFEST in your SW source. 라는 오류가 발생했다.
self.__WB_MANIFEST는 Webpack에서 설정한 precache 항목들이다. precacheAndRoute를 이용하여 프리캐싱 될 수 있도록 한다.
// src/sw.js
import { precacheAndRoute } from 'workbox-precaching';
precacheAndRoute(self.__WB_MANIFEST);
...
Service Worker가 설치되면 위와 같이 workbox-precache-v2라는 이름의 캐시가 생성된다.
4-2. 오프라인에서 서버 데이터 가져오기 (데이터 캐싱)
서버 데이터를 캐싱하여 오프라인일 때 캐싱된 데이터를 보여주면 마치 온라인 환경인 것처럼 동작하게 할 수 있다.
나는 firebase의 firestore, cloud storage에서 데이터 및 이미지를 가져오고 있다. firestore의 경우 DB를 초기화할 때 오프라인 지속성을 설정해주면 서버에서 수신된 데이터를 오프라인 액세스용으로 캐싱하여 오프라인 환경에서 사용할 수 있도록 한다. (이것도 모르고 workbox에서 삽질을 했다...)
import { initializeFirestore, persistentLocalCache } from 'firebase/firestore';
const db = initializeFirestore(app, {
localCache: persistentLocalCache({}), // 다른 옵션도 있다.
});
자세한 내용은 링크 참고
이미지와 CSS, Script 등 정적 파일을 캐시하기 위해 Workbox의 레시피를 이용하였다.
레시피를 사용하는 과정에서 위에서 구현한 Offline Fallback을 아주 간단하게 바꾸었다.
// src/sw.js
import { precacheAndRoute } from 'workbox-precaching';
import { NetworkFirst } from 'workbox-strategies';
import { registerRoute, setDefaultHandler } from 'workbox-routing';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { ExpirationPlugin } from 'workbox-expiration';
import { offlineFallback, staticResourceCache } from 'workbox-recipes';
setDefaultHandler(new NetworkFirst());
precacheAndRoute(self.__WB_MANIFEST);
// css, script 정적 파일 캐시
staticResourceCache();
// 이미지 캐시
registerRoute(
({ request }) => request.destination === 'image',
new NetworkFirst({
cacheName: 'images',
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxAgeSeconds: 24 * 60 * 60,
maxEntries: 50,
}),
],
}),
);
// 캐싱된 응답이 없을 때 offline.html 제공
offlineFallback();
이미지의 경우도 workbox-recipes에서 제공하는 imageCache를 이용하면 아주 간단하게 캐싱할 수 있다. imageCache는 CacheFirst 전략을 기본으로 한다. 나는 NetworkFirst 전략을 사용하려 했고, maxAgeSeconds 값도 바꾸고 싶어서 패턴을 구현했다.
이렇게 구현한 결과, 온라인 환경에서 한 번이라도 방문한 페이지는 데이터가 캐싱되어 오프라인 환경에서도 온라인처럼 보이게 할 수 있다. 캐싱된 데이터가 없는 경우 offline fallback을 보여준다.
캐싱 결과
PWA도 캐싱도 처음이었는데 시간은 걸렸지만 정말 뿌듯하고 좋은 경험이었다.
잘못된 부분이나 개선할 점이 있으면 코멘트 남겨주세요!
'Develop > JavaScript' 카테고리의 다른 글
[JavaScript] Firestore 검색 구현하기 with Aloglia (0) | 2024.04.19 |
---|---|
[JavaScript] 카카오맵 API를 이용한 장소 검색 및 위치 추가 구현하기 (0) | 2024.03.19 |
Vanilla JavaScript 프로젝트 초기 설정하기 (ES6) (0) | 2024.02.06 |
[Jest] 자바스크립트 테스트 코드 작성해 보기 (0) | 2023.09.07 |
[JavaScript] localStorage 사용하기 (0) | 2023.02.22 |