오프라인 설명서

Jake Archibald
Jake Archibald

서비스 워커를 통해 Google은 오프라인으로 해결하려는 시도를 포기하고 개발자가 스스로 해결할 수 있는 수단을 제공했습니다. 이를 통해 캐싱 및 요청 처리 방법을 제어할 수 있습니다. 즉, 자신만의 패턴을 만들 수 있습니다. 가능한 몇 가지 패턴을 따로 살펴보겠지만 실제로는 URL 및 컨텍스트에 따라 여러 패턴을 함께 사용할 것입니다.

이러한 일부 패턴의 실제 데모를 보려면 Trained-to-thrill을 참고하고 성능 영향을 보여주는 이 동영상을 참고하세요.

Service Worker를 사용하면 캐싱과 독립적으로 요청을 처리할 수 있으므로 별도로 살펴보겠습니다. 먼저, 캐싱은 언제 해야 하나요?

설치 시 - 종속 항목으로
설치 시 - 종속 항목으로.

Service Worker는 install 이벤트를 제공합니다. 이를 사용하면 다른 이벤트를 처리하기 전에 준비해야 하는 항목들을 준비할 수 있습니다. 이 경우 이전 버전의 Service Worker가 여전히 실행 중이며 페이지를 제공하고 있으므로 여기에서 수행하는 작업이 이를 방해해서는 안 됩니다.

적합한 대상: CSS, 이미지, 글꼴, JS, 템플릿… 기본적으로 사이트의 해당 '버전'에 정적인 것으로 간주되는 모든 것

이들은 가져오기에 실패할 경우 사이트가 완전히 작동하지 못하게 하고, 해당 플랫폼별 앱이 초기 다운로드에 포함하는 것들입니다.

self.addEventListener('install', function (event) {
  event
.waitUntil(
    caches
.open('mysite-static-v3').then(function (cache) {
     
return cache.addAll([
       
'/css/whatever-v3.css',
       
'/css/imgs/sprites-v6.png',
       
'/css/fonts/whatever-v8.woff',
       
'/js/all-min-v4.js',
       
// etc.
     
]);
   
}),
 
);
});

event.waitUntil는 프라미스를 사용하여 설치의 길이와 성공을 정의합니다. 프라미스가 거부되면 설치가 실패로 간주되어 이 서비스 워커가 중단됩니다 (이전 버전이 실행 중인 경우 그대로 유지됨). caches.open()cache.addAll()는 프로미스를 반환합니다. 리소스를 가져오지 못하면 cache.addAll() 호출이 거부됩니다.

trained-to-thrill에서 이를 사용하여 정적 자산을 캐시합니다.

설치 시(종속 항목이 아님)

설치 시 - 종속 항목이 아님
설치 시 - 종속 항목이 아님

위와 비슷하지만 설치가 지연되지 않으며 캐싱이 실패할 경우 설치가 실패하지 않습니다.

적합한 경우: 게임의 후반부 레벨을 위한 애셋과 같이 즉시 필요하지 않은 더 큰 리소스

self.addEventListener('install', function (event) {
  event
.waitUntil(
    caches
.open('mygame-core-v1').then(function (cache) {
      cache
       
.addAll
       
// levels 11–20
       
();
     
return cache
       
.addAll
       
// core assets and levels 1–10
       
();
   
}),
 
);
});

위 예에서는 레벨 11~20의 cache.addAll 약속을 event.waitUntil로 다시 전달하지 않으므로 실패하더라도 게임을 오프라인에서 계속 사용할 수 있습니다. 물론 이러한 수준이 존재하지 않을 가능성에 대비하고 누락된 경우 캐싱을 다시 시도해야 할 것입니다.

Service Worker는 이벤트 처리를 마쳤으므로 레벨 11~20이 다운로드되는 동안 종료될 수 있습니다. 즉, 해당 이벤트는 캐시되지 않습니다. 향후 Web Periodic Background Synchronization API가 이와 같은 사례와 영화와 같은 대용량 다운로드를 처리할 예정입니다. 이 API는 현재 Chromium 포크에서만 지원됩니다.

활성화 시

활성화 시
활성화 시.

적합한 경우: 정리 및 이전

새 서비스 워커가 설치되고 이전 버전이 사용되지 않으면 새 버전이 활성화되고 activate 이벤트가 발생합니다. 이전 버전이 방해하지 않으므로 IndexedDB의 스키마 마이그레이션을 처리하고 사용되지 않는 캐시를 삭제할 수 있는 좋은 기회입니다.

self.addEventListener('activate', function (event) {
  event
.waitUntil(
    caches
.keys().then(function (cacheNames) {
     
return Promise.all(
        cacheNames
         
.filter(function (cacheName) {
           
// Return true if you want to remove this cache,
           
// but remember that caches are shared across
           
// the whole origin
         
})
         
.map(function (cacheName) {
           
return caches.delete(cacheName);
         
}),
     
);
   
}),
 
);
});

활성화하는 동안 fetch와 같은 다른 이벤트가 큐에 배치되므로 긴 활성화로 인해 페이지 로드가 잠재적으로 차단될 수 있습니다. 활성화를 최대한 간결하게 유지하고 이전 버전이 활성화되어 있을 때 할 수 없는 작업에만 사용하세요.

trained-to-thrill에서 이를 사용하여 이전 캐시를 삭제합니다.

사용자 상호작용 시

사용자 상호작용 시
사용자 상호작용 시.

적합한 경우: 전체 사이트를 오프라인으로 만들 수 없으며 사용자가 오프라인에서 사용할 콘텐츠를 선택하도록 허용하도록 선택한 경우 예를 들어 YouTube 등의 동영상, Wikipedia의 글, Flickr의 특정 갤러리 등이 있습니다.

사용자에게 '나중에 읽기' 또는 '오프라인 저장' 버튼을 제공합니다. 이 버튼을 클릭하면 네트워크에서 필요한 항목을 가져와 캐시에 넣습니다.

document.querySelector('.cache-article').addEventListener('click', function (event) {
  event
.preventDefault();

 
var id = this.dataset.articleId;
  caches
.open('mysite-article-' + id).then(function (cache) {
    fetch
('/get-article-urls?id=' + id)
     
.then(function (response) {
       
// /get-article-urls returns a JSON-encoded array of
       
// resource URLs that a given article depends on
       
return response.json();
     
})
     
.then(function (urls) {
        cache
.addAll(urls);
     
});
 
});
});

caches API는 서비스 워커뿐만 아니라 페이지에서도 사용할 수 있으므로 페이지에서 직접 캐시에 추가할 수 있습니다.

네트워크 응답 시

네트워크 응답 시
네트워크 응답 시.

적합한 리소스: 사용자의 받은편지함 또는 기사 콘텐츠와 같이 자주 업데이트되는 리소스 아바타와 같이 비필수적인 콘텐츠에도 유용하지만 주의가 필요합니다.

요청이 캐시의 내용과 일치하지 않으면 네트워크에서 요청을 가져와서 페이지로 보내고 그와 동시에 캐시에 추가합니다.

아바타와 같이 다양한 URL에 대해 이 작업을 수행하는 경우 원본 저장소를 부풀리지 않도록 조심해야 합니다. 사용자가 디스크 공간을 다시 확보해야 하는 경우 기본 후보가 되기 원치 않습니다. 더 이상 필요하지 않은 캐시 항목은 제거합니다.

self.addEventListener('fetch', function (event) {
  event
.respondWith(
    caches
.open('mysite-dynamic').then(function (cache) {
     
return cache.match(event.request).then(function (response) {
       
return (
          response
||
          fetch
(event.request).then(function (response) {
            cache
.put(event.request, response.clone());
           
return response;
         
})
       
);
     
});
   
}),
 
);
});

효율적인 메모리 사용을 위해 응답/요청의 본문을 한 번만 읽을 수 있습니다. 위의 코드는 .clone()를 사용하여 별도로 읽을 수 있는 추가 사본을 만듭니다.

trained-to-thrill에서 이를 사용하여 Flickr 이미지를 캐시합니다.

Stale-while-revalidate

Stale-while-revalidate
Stale-while-revalidate

적합한 경우: 최신 버전을 유지하는 것이 필수적이지 않은 자주 업데이트되는 리소스 아바타가 이 범주에 해당될 수 있습니다.

캐시된 버전이 있으면 사용하되 다음을 위해 업데이트를 가져옵니다.

self.addEventListener('fetch', function (event) {
  event
.respondWith(
    caches
.open('mysite-dynamic').then(function (cache) {
     
return cache.match(event.request).then(function (response) {
       
var fetchPromise = fetch(event.request).then(function (networkResponse) {
          cache
.put(event.request, networkResponse.clone());
         
return networkResponse;
       
});
       
return response || fetchPromise;
     
});
   
}),
 
);
});

이는 HTTP의 stale-while-revalidate와 매우 유사합니다.

푸시 메시지

푸시 메시지
푸시 메시지에서

Push API는 Service Worker 위에 빌드된 또 다른 기능입니다. 이렇게 하면 서비스 워커가 OS의 메시징 서비스의 메시지에 대한 응답으로 활성화될 수 있습니다. 이는 사용자가 사이트에 대해 탭을 열지 않은 경우에도 발생합니다. 서비스 워커만 깨워집니다. 페이지에서 이 작업을 수행할 수 있는 권한을 요청하면 사용자에게 메시지가 표시됩니다.

적합한 콘텐츠: 채팅 메시지, 속보 또는 이메일과 같은 알림 관련 콘텐츠 그리고 할 일 목록 업데이트 또는 달력 변경과 같은 즉각적인 동기화를 활용하는 드물게 변경되는 콘텐츠.

일반적인 최종 결과는 탭할 때 관련 페이지를 열거나 포커스를 받는 알림이지만 이 상황이 발생하기 전에 캐시를 업데이트하는 것은 매우 중요합니다. 사용자는 푸시 메시지를 수신할 때 분명히 온라인 상태이지만 알림과 최종 상호작용할 때에는 온라인 상태가 아니기 때문에 이 콘텐츠를 오프라인에서 사용할 수 있도록 만드는 것이 중요합니다.

다음 코드는 알림을 표시하기 전에 캐시를 업데이트합니다.

self.addEventListener('push', function (event) {
 
if (event.data.text() == 'new-email') {
    event
.waitUntil(
      caches
       
.open('mysite-dynamic')
       
.then(function (cache) {
         
return fetch('/inbox.json').then(function (response) {
            cache
.put('/inbox.json', response.clone());
           
return response.json();
         
});
       
})
       
.then(function (emails) {
          registration
.showNotification('New email', {
            body
: 'From ' + emails[0].from.name,
            tag
: 'new-email',
         
});
       
}),
   
);
 
}
});

self
.addEventListener('notificationclick', function (event) {
 
if (event.notification.tag == 'new-email') {
   
// Assume that all of the resources needed to render
   
// /inbox/ have previously been cached, e.g. as part
   
// of the install handler.
   
new WindowClient('/inbox/');
 
}
});

백그라운드 동기화 시

백그라운드 동기화 시
백그라운드 동기화 시.

백그라운드 동기화는 서비스 워커 위에 빌드된 또 다른 기능입니다. 이 기능을 사용하여 일회성으로 또는 (매우 경험적인) 간격으로 백그라운드 데이터 동기화를 요청할 수 있습니다. 이는 사용자가 사이트에 대해 탭을 열지 않은 경우에도 발생합니다. 서비스 워커만 깨워집니다. 페이지에서 이 작업을 수행할 수 있는 권한을 요청하면 사용자에게 메시지가 표시됩니다.

적합한 케이스: 긴급하지 않은 업데이트, 특히 소셜 일정이나 뉴스 기사와 같이 업데이트별 푸시 메시지가 사용자에게 너무 자주 전송되는 업데이트

self.addEventListener('sync', function (event) {
 
if (event.id == 'update-leaderboard') {
    event
.waitUntil(
      caches
.open('mygame-dynamic').then(function (cache) {
       
return cache.add('/leaderboard.json');
     
}),
   
);
 
}
});

캐시 지속성

원본에는 적당한 특정 여유 공간이 제공됩니다. 이 여유 공간은 모든 원본 저장소((로컬) 저장소, IndexedDB, 파일 시스템 액세스, 물론 캐시) 간에 공유됩니다.

지급되는 금액은 지정되지 않습니다. 기기와 저장소 상태에 따라 다릅니다. 다음을 통해 여유 공간을 확인할 수 있습니다.

if (navigator.storage && navigator.storage.estimate) {
 
const quota = await navigator.storage.estimate();
 
// quota.usage -> Number of bytes used.
 
// quota.quota -> Maximum number of bytes available.
 
const percentageUsed = (quota.usage / quota.quota) * 100;
  console
.log(`You've used ${percentageUsed}% of the available storage.`);
 
const remaining = quota.quota - quota.usage;
  console
.log(`You can write up to ${remaining} more bytes.`);
}

그러나 모든 브라우저 저장소와 마찬가지로 브라우저는 기기가 저장 압력을 받게 되면 데이터를 버릴 수 있습니다. 불행히도 브라우저는 꼭 보존하고 싶은 영화와 별로 관심 없는 게임을 구분하지 못합니다.

이 문제를 해결하려면 StorageManager 인터페이스를 사용하세요.

// From a page:
navigator
.storage.persist()
.then(function(persisted) {
 
if (persisted) {
   
// Hurrah, your data is here to stay!
 
} else {
   
// So sad, your data may get chucked. Sorry.
});

물론 사용자가 권한을 부여해야 합니다. 이를 위해 Permissions API를 사용하세요.

이제 사용자가 삭제를 제어할 수 있기 때문에 이 흐름에 사용자를 포함하는 것이 중요합니다. 기기가 저장 압력을 받는 상태에서 비필수적인 데이터를 지워도 해결되지 않는 경우 사용자가 보존하고 삭제할 항목을 판단하게 됩니다.

이를 위해 브라우저를 단일 항목으로 보고하지 않고 저장 공간 분석에서 '지속형' 출처를 플랫폼별 앱과 동일한 것으로 취급하는 운영체제가 필요합니다.

제안 제공 - 요청에 응답

얼마나 많이 캐시를 수행하는가는 중요하지 않습니다. 시간과 방법을 지정하지 않으면 서비스 워커가 캐시를 사용하지 않습니다. 다음은 요청 처리를 위한 몇 가지 패턴입니다.

캐시 전용

캐시 전용
캐시 전용입니다.

이상적인 대상: 사이트의 특정 '버전'에 정적인 것으로 간주되는 모든 것 설치 이벤트에서 이를 캐시해야 했으므로 거기에 있는 것에 의존할 수 있습니다.

self.addEventListener('fetch', function (event) {
 
// If a match isn't found in the cache, the response
 
// will look like a connection error
  event
.respondWith(caches.match(event.request));
});

…이 경우를 구체적으로 처리해야 할 필요는 좀처럼 없지만 캐시 요청 시도 후 네트워크로 복귀에서 이 문제를 다루고 있습니다.

네트워크 전용

네트워크만
네트워크 전용입니다.

적합한 경우: 분석 핑, 비 GET 요청과 같이 상응하는 오프라인 요소가 없는 항목

self.addEventListener('fetch', function (event) {
  event
.respondWith(fetch(event.request));
 
// or simply don't call event.respondWith, which
 
// will result in default browser behavior
});

…이 경우를 구체적으로 처리해야 할 필요는 좀처럼 없지만 캐시 요청 시도 후 네트워크로 복귀에서 이 문제를 다루고 있습니다.

캐시, 네트워크로 폴백

캐시, 네트워크로 대체
캐시 요청 시도 후 네트워크로 복귀

적합한 경우: 오프라인 우선 빌드 이 경우 대부분의 요청을 다음과 같이 처리합니다. 다른 패턴은 들어오는 요청에 기반한 예외가 됩니다.

self.addEventListener('fetch', function (event) {
  event
.respondWith(
    caches
.match(event.request).then(function (response) {
     
return response || fetch(event.request);
   
}),
 
);
});

이렇게 하면 캐시 항목에 대한 '캐시 전용' 동작과 캐시되지 않은 항목 (캐시할 수 없는 모든 비 GET 요청 포함)에 대한 '네트워크 전용' 동작이 제공됩니다.

캐시 및 네트워크 경합

캐시 및 네트워크 경합
캐시 및 네트워크 경합

적합한 케이스: 디스크 액세스 속도가 느린 기기에서 성능을 추적 중인 소규모 애셋

오래된 하드 드라이브, 바이러스 스캐너 및 고속 인터넷 연결을 조합하면 네트워크에서 리소스를 가져오는 것이 디스크로 이동하는 것보다 빠를 수 있습니다. 그러나 사용자의 기기에 있는 콘텐츠를 가져오기 위해 네트워크로 이동하는 것은 데이터 낭비에 해당할 수 있으므로 주의해야 합니다.

// Promise.race is no good to us because it rejects if
// a promise rejects before fulfilling. Let's make a proper
// race function:
function promiseAny(promises) {
 
return new Promise((resolve, reject) => {
   
// make sure promises are all promises
    promises
= promises.map((p) => Promise.resolve(p));
   
// resolve this promise as soon as one resolves
    promises
.forEach((p) => p.then(resolve));
   
// reject if all promises reject
    promises
.reduce((a, b) => a.catch(() => b)).catch(() => reject(Error('All failed')));
 
});
}

self
.addEventListener('fetch', function (event) {
  event
.respondWith(promiseAny([caches.match(event.request), fetch(event.request)]));
});

네트워크가 캐시로 대체됨

네트워크가 캐시로 대체됩니다.
네트워크가 캐시로 대체됩니다.

적합한 경우: 사이트의 '버전' 범위 밖에서 자주 업데이트되는 리소스에 대한 빠른 수정 기사, 아바타, 소셜 미디어 타임라인, 게임 리더보드 등이 여기에 해당합니다.

즉, 온라인 사용자는 최신 콘텐츠를 제공받지만 오프라인 사용자는 오래된 버전의 캐시를 사용합니다. 네트워크 요청이 성공하면 캐시 항목을 업데이트해야 할 것입니다.

그러나 이 방법에는 결함이 있습니다. 연결이 원활하지 않거나 느린 경우 사용자는 네트워크가 실패할 때까지 기다렸다가 기기에 이미 있는 완벽하게 허용되는 콘텐츠를 얻어야 합니다. 이 작업은 매우 오래 걸릴 수 있으며 사용자에게 불편을 끼칠 수 있습니다. 훨씬 나은 해결책에 대해서는 다음 패턴인 캐시 요청 후 네트워크를 참고하세요.

self.addEventListener('fetch', function (event) {
  event
.respondWith(
    fetch
(event.request).catch(function () {
     
return caches.match(event.request);
   
}),
 
);
});

캐시 요청 후 네트워크

캐시 요청 후 네트워크
캐시한 후 네트워크를 사용합니다.

적합한 콘텐츠: 자주 업데이트되는 콘텐츠 기사, 소셜 미디어 타임라인, 게임 순위표 등이 여기에 해당합니다.

이를 위해서는 페이지가 두 개의 요청(캐시에 요청, 네트워크에 요청)을 해야 합니다. 캐시된 데이터를 먼저 표시한 다음 네트워크 데이터가 도착하면 페이지를 업데이트하는 방식입니다.

때로는 새로운 데이터 (예: 게임 순위표)가 도착할 때 현재 데이터를 바꿀 수 있지만 훨씬 큰 콘텐츠로 인해 혼란스러울 수 있습니다. 기본적으로 사용자가 읽고 있거나 상호작용하고 있을 수 있는 항목을 '사라지게' 하지 마세요.

Twitter는 이전 콘텐츠 위에 새 콘텐츠를 추가하고 사용자를 방해하지 않도록 스크롤 위치를 조정합니다. Twitter는 일반적으로 콘텐츠를 선형 순서로 유지하기 때문에 이것이 가능합니다. 콘텐츠를 최대한 빨리 화면에 표시하면서 최신 콘텐츠가 도착하는 즉시 표시하기 위해 trained-to-thrill의 이 패턴을 복사했습니다.

페이지의 코드:

var networkDataReceived = false;

startSpinner
();

// fetch fresh data
var networkUpdate = fetch('/data.json')
 
.then(function (response) {
   
return response.json();
 
})
 
.then(function (data) {
    networkDataReceived
= true;
    updatePage
(data);
 
});

// fetch cached data
caches
 
.match('/data.json')
 
.then(function (response) {
   
if (!response) throw Error('No data');
   
return response.json();
 
})
 
.then(function (data) {
   
// don't overwrite newer network data
   
if (!networkDataReceived) {
      updatePage
(data);
   
}
 
})
 
.catch(function () {
   
// we didn't get cached data, the network is our last hope:
   
return networkUpdate;
 
})
 
.catch(showErrorMessage)
 
.then(stopSpinner);

서비스 워커의 코드:

캐시는 항상 네트워크로 이동할 때 업데이트해야 합니다.

self.addEventListener('fetch', function (event) {
  event
.respondWith(
    caches
.open('mysite-dynamic').then(function (cache) {
     
return fetch(event.request).then(function (response) {
        cache
.put(event.request, response.clone());
       
return response;
     
});
   
}),
 
);
});

trained-to-thrill에서 fetch 대신 XHR을 사용하고 Accept 헤더를 사용하여 서비스 워커에게 결과를 가져올 위치(페이지 코드, 서비스 워커 코드)를 알려줌으로써 이 문제를 해결했습니다.

일반 대체

일반 대체
일반적인 대체.

캐시 및/또는 네트워크에서 응답이 없으면 일반적인 폴백을 제공하는 것이 좋습니다.

적합한 경우: 아바타, 실패한 POST 요청, '오프라인에서 사용할 수 없음' 페이지와 같은 보조 이미지

self.addEventListener('fetch', function (event) {
  event
.respondWith(
   
// Try the cache
    caches
     
.match(event.request)
     
.then(function (response) {
       
// Fall back to network
       
return response || fetch(event.request);
     
})
     
.catch(function () {
       
// If both fail, show a generic fallback:
       
return caches.match('/offline.html');
       
// However, in reality you'd have many different
       
// fallbacks, depending on URL and headers.
       
// Eg, a fallback silhouette image for avatars.
     
}),
 
);
});

폴백할 대상 항목은 설치 종속 항목일 가능성이 있습니다.

페이지에 이메일을 게시 중인 경우 서비스 워커가 IndexedDB '보낼 편지함'에 이메일을 저장하는 작업으로 폴백하고 보내기가 실패했지만 데이터를 성공적으로 보존했음을 알려줍니다.

서비스 워커 측 템플릿

ServiceWorker 측 템플릿
ServiceWorker 측 템플릿.

적합한 페이지: 서버 응답을 캐시할 수 없는 페이지

서버에서 페이지를 렌더링하면 작업 속도가 빨라지지만, 캐시에서 이해할 수 없는 상태 데이터(예: '로그인한 사용자')가 포함될 수 있습니다. 페이지가 서비스 워커에 의해 제어되는 경우 그 대신에 JSON 데이터를 템플릿과 함께 요청하여 렌더링할 수 있습니다.

importScripts('templating-engine.js');

self
.addEventListener('fetch', function (event) {
 
var requestURL = new URL(event.request.url);

  event
.respondWith(
   
Promise.all([
      caches
.match('/article-template.html').then(function (response) {
       
return response.text();
     
}),
      caches
.match(requestURL.path + '.json').then(function (response) {
       
return response.json();
     
}),
   
]).then(function (responses) {
     
var template = responses[0];
     
var data = responses[1];

     
return new Response(renderTemplate(template, data), {
        headers
: {
         
'Content-Type': 'text/html',
       
},
     
});
   
}),
 
);
});

종합

이러한 방법 중 하나로 제한되지는 않습니다. 실제로 요청 URL에 따라 여러 메서드를 사용할 수 있습니다. 예를 들어 trained-to-thrill은 다음을 사용합니다.

요청을 살펴보고, 무엇을 할지 결정하세요.

self.addEventListener('fetch', function (event) {
 
// Parse the URL:
 
var requestURL = new URL(event.request.url);

 
// Handle requests to a particular host specifically
 
if (requestURL.hostname == 'api.example.com') {
    event
.respondWith(/* some combination of patterns */);
   
return;
 
}
 
// Routing for local URLs
 
if (requestURL.origin == location.origin) {
   
// Handle article URLs
   
if (/^\/article\//.test(requestURL.pathname)) {
      event
.respondWith(/* some other combination of patterns */);
     
return;
   
}
   
if (/\.webp$/.test(requestURL.pathname)) {
      event
.respondWith(/* some other combination of patterns */);
     
return;
   
}
   
if (request.method == 'POST') {
      event
.respondWith(/* some other combination of patterns */);
     
return;
   
}
   
if (/cheese/.test(requestURL.pathname)) {
      event
.respondWith(
       
new Response('Flagrant cheese error', {
          status
: 512,
       
}),
     
);
     
return;
   
}
 
}

 
// A sensible default pattern
  event
.respondWith(
    caches
.match(event.request).then(function (response) {
     
return response || fetch(event.request);
   
}),
 
);
});

…이해하셨을 것입니다.

크레딧

…아름다운 아이콘:

'게시'하기 전에 많은 오류를 찾아 주신 제프 포스닉님께도 감사드립니다.

추가 자료