SPA 외 - PWA용 대체 아키텍처

건축에 대해 얘기해 봅시다.

이 글에서는 중요하지만 잘못 이해할 수 있는 주제를 다루려고 합니다. 즉 웹 앱에 사용하는 아키텍처, 특히 프로그레시브 웹 앱을 빌드할 때 아키텍처에 관한 결정이 이루어지는 방식을 다루는 것입니다.

'아키텍처'가 모호하게 들릴 수 있으며, 이것이 중요한 이유가 바로 명확하지 않을 수 있습니다. 아키텍처에 대해 생각해 볼 수 있는 한 가지 방법은 스스로에게 다음과 같은 질문을 하는 것입니다. 사용자가 내 사이트의 페이지를 방문할 때 어떤 HTML이 로드됩니까? 그리고 사용자가 다른 페이지를 방문할 때 무엇이 로드되나요?

이러한 질문에 대한 답이 항상 간단하지는 않으며, 프로그레시브 웹 앱에 대해 생각하기 시작하면 훨씬 더 복잡해질 수 있습니다. 제 목표는 제가 효과적이었던 몇 가지 아키텍처에 대해 설명하는 것입니다. 이 도움말에서는 프로그레시브 웹 앱을 빌드하기 위한 '나만의 접근 방식'으로 결정한 내용을 언급합니다.

자체 PWA를 빌드할 때 제 접근 방식을 자유롭게 사용할 수 있지만, 동시에 다른 유효한 대안이 항상 있습니다. 모든 요소가 잘 어우러지는 모습을 보면서 영감을 얻고 필요에 맞게 맞춤설정할 수 있는 자신감을 얻으셨기를 바랍니다.

Stack Overflow PWA

이 문서와 함께 Stack Overflow PWA를 만들었습니다. 저는 많은 시간을 들여 Stack Overflow를 읽고 참여하고 있으며, 특정 주제에 관한 자주 묻는 질문(FAQ)을 쉽게 찾아볼 수 있는 웹 앱을 빌드하고 싶었습니다. 공개 Stack Exchange API를 기반으로 구축되었습니다. 오픈소스이므로 GitHub 프로젝트를 방문하여 자세히 알아볼 수 있습니다.

다중 페이지 앱 (MPA)

본격적으로 시작하기 전에 몇 가지 용어를 정의하고 기본 기술의 요소를 설명하겠습니다 먼저 '멀티 페이지 앱' 또는 'MPA'가 무엇인지 살펴보겠습니다.

MPA는 웹 초창기부터 사용된 기존 아키텍처의 화려한 이름입니다. 사용자가 새 URL로 이동할 때마다 브라우저는 해당 페이지에 해당하는 HTML을 점진적으로 렌더링합니다. 탐색 간에 페이지의 상태나 콘텐츠를 보존하려는 시도는 없습니다. 새 페이지를 방문할 때마다 새로 시작됩니다.

이는 사용자가 새 섹션을 방문할 때 브라우저에서 자바스크립트 코드를 실행하여 기존 페이지를 업데이트하는 웹 앱 빌드를 위한 SPA (단일 페이지 앱) 모델과 대조됩니다. SPA와 MPA는 모두 사용할 수 있는 유효한 모델이지만 이 게시물에서는 다중 페이지 앱의 컨텍스트 내에서 PWA 개념을 살펴보고 싶었습니다.

안정적인 속도

저를 비롯해 수많은 사람들이 '프로그레시브 웹 앱' 또는 PWA를 사용한다고 들었습니다. 이 사이트의 다른 부분에서 배경 자료 중 일부를 이미 알고 계실 것입니다.

PWA는 최고 수준의 사용자 환경을 제공하고 사용자의 홈 화면에 실제로 자리 잡은 웹 앱으로 생각할 수 있습니다. Fast, Integrated, Reliable, Engaging을 의미하는 'FIRE' 약어는 PWA를 빌드할 때 고려해야 할 모든 속성을 요약합니다.

이 문서에서는 이러한 속성 중 하위 집합인 FastReliable을 중점적으로 설명합니다.

빠름: '빠름'은 상황에 따라 다른 의미를 지니지만, 여기서는 가능한 한 네트워크에서 로드하는 데 따른 속도상의 이점을 설명하겠습니다.

안정성: 하지만 원시적인 속도로는 충분하지 않습니다. PWA와 같은 느낌을 주려면 웹 앱은 안정적이어야 합니다. 네트워크 상태와 관계없이 맞춤설정된 오류 페이지일지라도 항상 무언가를 로드할 수 있을 만큼 탄력성이 있어야 합니다.

안정적인 속도: 마지막으로 PWA의 정의를 약간 바꿔보고 안정적으로 빠르게 앱을 빌드한다는 것이 무엇을 의미하는지 살펴보겠습니다. 지연 시간이 짧은 네트워크를 사용할 때만 빠르고 안정적으로 작동하기에는 충분하지 않습니다. 안정적으로 빠르다는 것은 기본 네트워크 상태와 관계없이 웹 앱의 속도가 일정함을 의미합니다.

기술 지원: 서비스 워커 + 캐시 저장소 API

PWA는 속도와 복원력을 위한 높은 기준을 도입합니다. 다행히 웹 플랫폼은 이러한 유형의 성능을 실현할 수 있는 몇 가지 구성요소를 제공합니다. 서비스 워커Cache Storage API를 가리킵니다.

Cache Storage API를 통해 들어오는 요청을 수신 대기하고, 요청을 네트워크에 전달하고, 나중에 사용할 수 있도록 응답 사본을 저장하는 서비스 워커를 빌드할 수 있습니다.

Cache Storage API를 사용하여 네트워크 응답의 사본을 저장하는 서비스 워커.

다음에 웹 앱에서 동일한 요청을 하면 서비스 워커가 캐시를 확인하고 이전에 캐시된 응답만 반환할 수 있습니다.

Cache Storage API를 사용하여 네트워크를 우회하여 응답하는 서비스 워커.

안정적인 빠른 성능을 제공하는 데 있어 가능한 한 네트워크를 피하는 것이 중요합니다.

'동형' 자바스크립트

여기서 다루고 싶은 또 다른 개념은 '등형' 또는 '범용' JavaScript라고도 합니다. 간단히 말해서 서로 다른 런타임 환경 간에 동일한 JavaScript 코드를 공유할 수 있다는 개념입니다. 저는 PWA를 빌드할 때 백엔드 서버와 서비스 워커 간에 JavaScript 코드를 공유하고 싶었습니다.

이러한 방식으로 코드를 공유하는 유효한 접근 방식이 많지만 제 접근 방식ES 모듈을 최종 소스 코드로 사용하는 것이었습니다. 그런 다음 BabelRollup의 조합을 사용하여 서버와 서비스 워커에 대해 이러한 모듈을 트랜스파일하고 번들로 묶었습니다. 이 프로젝트에서 파일 확장자가 .mjs인 파일은 ES 모듈에 있는 코드입니다.

서버

이러한 개념과 용어를 염두에 두고 Stack Overflow PWA를 실제로 어떻게 빌드했는지 자세히 살펴보겠습니다. 먼저 백엔드 서버를 살펴보고 이 서버가 전체 아키텍처에 어떻게 적용되는지 설명하겠습니다

저는 동적 백엔드와 정적 호스팅의 조합을 찾고 있었는데, 제 접근 방식은 Firebase 플랫폼을 사용하는 것이었습니다.

Firebase Cloud Functions는 수신 요청이 있을 때 자동으로 노드 기반 환경을 가동하고, 익숙한 Express HTTP 프레임워크와 통합합니다. 또한 내 사이트의 모든 정적 리소스에 대해 즉각적인 호스팅을 제공합니다. 서버가 요청을 처리하는 방식을 살펴보겠습니다

브라우저가 Google 서버에 탐색 요청을 하면 다음 흐름을 거치게 됩니다.

서버 측 탐색 응답 생성에 관한 개요입니다.

서버는 URL을 기반으로 요청을 라우팅하고 템플릿 로직을 사용하여 전체 HTML 문서를 만듭니다. Stack Exchange API의 데이터 조합뿐 아니라 서버가 로컬로 저장하는 일부 HTML 프래그먼트의 데이터를 사용합니다. 서비스 워커가 응답 방법을 알면 HTML 스트리밍을 웹 앱으로 다시 시작할 수 있습니다.

이 그림에는 라우팅과 템플릿이라는 두 가지 요소를 더 자세히 살펴볼 필요가 있습니다.

라우팅

라우팅과 관련하여 저는 Express 프레임워크의 네이티브 라우팅 구문을 사용하는 접근 방식을 사용했습니다. 단순 URL 프리픽스는 물론 매개변수를 경로의 일부로 포함하는 URL을 매칭할 수 있는 유연성이 있습니다. 여기에서는 일치시킬 기본 Express 패턴과 경로 이름 사이에 매핑을 만듭니다.

const routes = new Map([
  ['about', '/about'],
  ['questions', '/questions/:questionId'],
  ['index', '/'],
]);

export default routes;

그러면 서버의 코드에서 이 매핑을 직접 참조할 수 있습니다. 지정된 Express 패턴과 일치하는 항목이 있으면 적절한 핸들러가 일치하는 경로와 관련된 템플릿 로직으로 응답합니다.

import routes from './lib/routes.mjs';
app.get(routes.get('index'), async (req, res) => {
  // Templating logic.
});

서버 측 템플릿

템플릿 로직은 어떤 모습일까요? 여기서는 부분적인 HTML 프래그먼트를 순차적으로 결합하는 접근방식을 사용했습니다. 이 모델은 스트리밍에 적합합니다.

서버는 일부 초기 HTML 상용구를 즉시 되돌려 보냅니다. 그러면 브라우저가 해당 부분 페이지를 즉시 렌더링할 수 있습니다. 서버는 나머지 데이터 소스를 조각하면서 문서가 완료될 때까지 브라우저로 스트리밍합니다.

자세한 내용은 다음 경로에 대한 익스프레스 코드를 참조하세요.

app.get(routes.get('index'), async (req, res) => {
  res.write(headPartial + navbarPartial);
  const tag = req.query.tag || DEFAULT_TAG;
  const data = await requestData(...);
  res.write(templates.index(tag, data.items));
  res.write(footPartial);
  res.end();
});

response 객체의 write() 메서드를 사용하고 로컬에 저장된 부분 템플릿을 참조하면 외부 데이터 소스를 차단하지 않고 응답 스트림을 즉시 시작할 수 있습니다. 브라우저는 이 초기 HTML을 사용하여 의미 있는 인터페이스를 렌더링하고 메시지를 즉시 로드합니다.

다음 부분에서는 Stack Exchange API의 데이터를 사용합니다. 그 데이터를 얻는 것은 서버가 네트워크 요청을 해야 한다는 것을 의미합니다. 웹 앱은 응답을 다시 받아 처리할 때까지 다른 것을 렌더링할 수 없습니다. 그러나 적어도 사용자는 기다리는 동안 빈 화면을 보지 않습니다.

웹 앱이 Stack Exchange API로부터 응답을 수신하면 커스텀 템플릿 함수를 호출하여 API의 데이터를 해당 HTML로 변환합니다.

템플릿 언어

템플팅은 놀랄 만큼 논란의 여지가 있는 주제일 수 있습니다. 제가 선택한 방법은 여러 방법 중 하나일 뿐입니다. 특히 기존 템플릿 프레임워크에 레거시 연결이 있는 경우 자체 솔루션으로 대체하는 것이 좋습니다.

이 사용 사례에 적합한 것은 일부 로직을 도우미 함수로 분할하여 JavaScript의 템플릿 리터럴을 사용하는 것이었습니다. MPA를 빌드할 때의 좋은 점 중 하나는 상태 업데이트를 추적하고 HTML을 다시 렌더링할 필요가 없으므로 정적 HTML을 생성하는 기본 접근 방식이 적합하다는 것입니다.

다음은 웹 앱 색인의 동적 HTML 부분을 템플릿화하는 방법의 예입니다. 경로와 마찬가지로 템플릿 로직은 서버와 서비스 워커로 모두 가져올 수 있는 ES 모듈에 저장됩니다.

export function index(tag, items) {
  const title = `<h3>Top "${escape(tag)}" Questions</h3>`;
  const form = `<form method="GET">...</form>`;
  const questionCards = items
    .map(item =>
      questionCard({
        id: item.question_id,
        title: item.title,
      })
    )
    .join('');
  const questions = `<div id="questions">${questionCards}</div>`;
  return title + form + questions;
}

이러한 템플릿 함수는 순수 자바스크립트이며 필요한 경우 로직을 더 작은 도우미 함수로 분할하는 것이 유용합니다. 이제 API 응답에서 반환된 각 항목을 하나의 함수로 전달합니다. 그러면 적절한 속성이 모두 설정된 표준 HTML 요소가 생성됩니다.

function questionCard({id, title}) {
  return `<a class="card"
             href="/questions/${id}"
             data-cache-url="${questionUrl(id)}">${title}</a>`;
}

특히 참고할 사항은 각 링크 data-cache-url에 추가하는 데이터 속성입니다. 해당 질문을 표시하는 데 필요한 Stack Exchange API URL로 설정합니다. 이 점을 기억하세요. 나중에 다시 확인하겠습니다.

경로 핸들러로 돌아가서 템플릿 작성이 완료되면 페이지 HTML의 마지막 부분을 브라우저에 스트리밍하고 스트림을 종료합니다. 이는 프로그레시브 렌더링이 완료되었음을 브라우저에 알리는 신호입니다.

app.get(routes.get('index'), async (req, res) => {
  res.write(headPartial + navbarPartial);
  const tag = req.query.tag || DEFAULT_TAG;
  const data = await requestData(...);
  res.write(templates.index(tag, data.items));
  res.write(footPartial);
  res.end();
});

지금까지 제 서버 설정을 간단히 살펴봤습니다. 웹 앱을 처음 방문하는 사용자는 항상 서버로부터 응답을 받지만 방문자가 웹 앱으로 돌아오면 서비스 워커가 응답하기 시작합니다. 그럼 시작하겠습니다.

서비스 워커

서비스 워커에서 탐색 응답 생성에 관한 개요

이 다이어그램은 익숙하게 보일 것입니다. 이전에 다룬 많은 부분이 약간 다른 배열로 표시되어 있습니다. 서비스 워커를 고려하여 요청 흐름을 살펴보겠습니다.

서비스 워커는 특정 URL에 대해 수신되는 탐색 요청을 처리하며 제 서버에서와 마찬가지로 라우팅과 템플릿 로직을 조합하여 응답 방식을 파악합니다.

이 접근 방식은 이전과 동일하지만 fetch()Cache Storage API와 같은 하위 수준 프리미티브가 다릅니다. 이러한 데이터 소스를 사용하여 HTML 응답을 작성하고 서비스 워커가 웹 앱으로 다시 전달합니다.

Workbox

하위 수준 프리미티브로 처음부터 시작하는 대신 Workbox라는 상위 수준 라이브러리 집합을 기반으로 서비스 워커를 빌드해 보겠습니다. 이는 모든 서비스 워커의 캐싱, 라우팅, 응답 생성 로직을 위한 견고한 기반을 제공합니다.

라우팅

서버 측 코드와 마찬가지로 서비스 워커는 수신 요청을 적절한 응답 로직과 일치시키는 방법을 알아야 합니다.

저는 각 익스프레스 경로를 상응하는 정규 표현식으로 변환하여 regexparam라는 유용한 라이브러리를 사용하는 것이었습니다. 변환이 완료되면 Workbox에서 기본 제공하는 정규 표현식 라우팅 지원을 활용할 수 있습니다.

정규 표현식이 있는 모듈을 가져온 후 각 정규 표현식을 Workbox의 라우터에 등록합니다. 각 경로 내에서 응답을 생성하는 커스텀 템플릿 로직을 제공할 수 있습니다. 서비스 워커의 템플릿 작업은 백엔드 서버에서보다 좀 더 번거롭지만, Workbox는 많은 복잡한 작업을 처리하는 데 도움이 됩니다.

import regExpRoutes from './regexp-routes.mjs';

workbox.routing.registerRoute(
  regExpRoutes.get('index')
  // Templating logic.
);

정적 애셋 캐싱

템플릿 작성에서 중요한 부분 중 하나는 부분 HTML 템플릿을 Cache Storage API를 통해 로컬에서 사용할 수 있고 웹 앱에 변경사항을 배포할 때 최신 상태로 유지되도록 하는 것입니다. 수동으로 캐시 유지관리를 수행하면 오류가 발생하기 쉬우므로 빌드 프로세스의 일부로 Workbox에서 사전 캐싱을 처리합니다.

구성 파일을 사용하여 사전 캐시할 URL을 Workbox에 알립니다. 이 파일에서는 일치시킬 패턴 집합과 함께 모든 로컬 애셋이 포함된 디렉터리를 가리킵니다. 이 파일은 사이트를 다시 빌드할 때마다 run되는 Workbox의 CLI에서 자동으로 읽습니다.

module.exports = {
  globDirectory: 'build',
  globPatterns: ['**/*.{html,js,svg}'],
  // Other options...
};

Workbox는 각 파일 콘텐츠의 스냅샷을 만들고 URL 및 버전 목록을 최종 서비스 워커 파일에 자동으로 삽입합니다. 이제 Workbox는 사전 캐시된 파일을 항상 사용할 수 있고 최신 상태로 유지하는 데 필요한 모든 것을 갖추고 있습니다. 그 결과 다음과 유사한 항목이 포함된 service-worker.js 파일이 생성됩니다.

workbox.precaching.precacheAndRoute([
  {
    url: 'partials/about.html',
    revision: '518747aad9d7e',
  },
  {
    url: 'partials/foot.html',
    revision: '69bf746a9ecc6',
  },
  // etc.
]);

더 복잡한 빌드 프로세스를 사용하는 사용자를 위해 Workbox는 명령줄 인터페이스 외에도 webpack 플러그인일반 노드 모듈을 모두 제공합니다.

스트리밍

다음으로, 사전 캐시된 부분 HTML을 서비스 워커가 즉시 웹 앱으로 다시 스트리밍하기를 원합니다. 이는 '안정적'인 속도를 유지하는 데 중요한 부분입니다. 즉, 항상 화면에 의미 있는 내용이 즉시 표시됩니다. 다행히 서비스 워커 내에서 Streams API를 사용하면 가능합니다.

이제 전에 Streams API에 대해 들어보셨을 것입니다. 제 동료인 제이크 아치볼드는 몇 년 동안 존경을 불러일으키고 있어요. 그는 2016년이 웹 스트림의 해가 될 것이란 대담한 예측을 내렸습니다. Streams API는 2년 전과 마찬가지로 오늘날에도 훌륭하지만 중요한 차이점이 있습니다.

당시에는 Chrome에서만 Streams를 지원했지만, Streams API는 현재 더 광범위하게 지원되고 있습니다. 전반적인 스토리는 긍정적이며 적절한 대체 코드를 사용하면 오늘날 서비스 워커에서 스트림을 사용하는 데 지장이 없습니다.

음... 한 가지 장애물이 있을 수 있는데, 이것으로 Streams API가 실제로 작동하는 방식을 파악해야 합니다. 매우 강력한 프리미티브 집합을 노출하며, 이를 사용하는 데 익숙한 개발자는 다음과 같은 복잡한 데이터 흐름을 만들 수 있습니다.

const stream = new ReadableStream({
  pull(controller) {
    return sources[0]
      .then(r => r.read())
      .then(result => {
        if (result.done) {
          sources.shift();
          if (sources.length === 0) return controller.close();
          return this.pull(controller);
        } else {
          controller.enqueue(result.value);
        }
      });
  },
});

그러나 이 코드의 전체 의미를 이해하는 것이 모두에게 필요한 것은 아닙니다. 이 로직을 통해 파싱하는 대신 서비스 워커 스트리밍에 대한 저의 접근 방식에 대해 이야기해 보겠습니다.

새로운 상위 수준 래퍼인 workbox-streams를 사용하고 있습니다. 이를 통해 캐시 및 네트워크에서 올 수 있는 런타임 데이터 모두에서 혼합된 스트리밍 소스를 전달할 수 있습니다. Workbox는 개별 소스를 조율하고 단일 스트리밍 응답으로 함께 연결합니다.

또한 Workbox는 Streams API가 지원되는지 자동으로 감지하고, 지원되지 않는 경우 이에 상응하는 비 스트리밍 응답을 만듭니다. 즉, 스트림이 100% 브라우저 지원에 가까워지기 때문에 대체 쓰기에 대해 걱정할 필요가 없습니다.

런타임 캐싱

Stack Exchange API에서 서비스 워커가 런타임 데이터를 처리하는 방식을 확인해 보겠습니다. 웹 앱의 저장소가 무제한으로 늘어나지 않도록 만료와 함께 Workbox에서 기본으로 제공하는 비활성 캐싱 전략 지원을 활용하고 있습니다.

스트리밍 응답을 구성할 여러 소스를 처리하기 위해 Workbox에서 두 가지 전략을 설정했습니다. 몇 가지 함수 호출 및 구성으로 Workbox를 사용하면 수백 줄의 필기 코드가 필요했을 작업을 할 수 있습니다.

const cacheStrategy = workbox.strategies.cacheFirst({
  cacheName: workbox.core.cacheNames.precache,
});

const apiStrategy = workbox.strategies.staleWhileRevalidate({
  cacheName: API_CACHE_NAME,
  plugins: [new workbox.expiration.Plugin({maxEntries: 50})],
});

첫 번째 전략은 부분 HTML 템플릿과 같이 사전 캐시된 데이터를 읽습니다.

다른 전략은 50개 항목에 도달하면 가장 오래전에 사용된 캐시 만료와 함께 비활성 기간 재검증 캐싱 로직을 구현합니다.

이제 이러한 전략을 갖추었으므로 이제 Workbox에 전략을 사용하여 완전한 스트리밍 응답을 구성하는 방법을 알리면 됩니다. 소스 배열을 함수로 전달하면 각 함수가 즉시 실행됩니다. Workbox는 각 소스의 결과를 가져와 순서대로 웹 앱에 스트리밍하며 배열의 다음 함수가 아직 완료되지 않은 경우에만 지연됩니다.

workbox.streams.strategy([
  () => cacheStrategy.makeRequest({request: '/head.html'}),
  () => cacheStrategy.makeRequest({request: '/navbar.html'}),
  async ({event, url}) => {
    const tag = url.searchParams.get('tag') || DEFAULT_TAG;
    const listResponse = await apiStrategy.makeRequest(...);
    const data = await listResponse.json();
    return templates.index(tag, data.items);
  },
  () => cacheStrategy.makeRequest({request: '/foot.html'}),
]);

처음 두 개 소스는 Cache Storage API에서 직접 읽은 사전 캐시된 부분 템플릿이므로 항상 즉시 사용할 수 있습니다. 이렇게 하면 서버 측 코드처럼 서비스 워커 구현이 요청에 안정적으로 빠르게 응답할 수 있습니다.

다음 소스 함수는 Stack Exchange API에서 데이터를 가져오고 응답을 웹 앱에서 예상하는 HTML로 처리합니다.

비활성-while-revalidate 전략은 이 API 호출에 대해 이전에 캐시된 응답이 있는 경우 다음에 요청될 때 '백그라운드에서' 캐시 항목을 업데이트하는 동시에 페이지에 즉시 스트리밍할 수 있음을 의미합니다.

마지막으로 내 바닥글의 캐시된 사본을 스트리밍하고 최종 HTML 태그를 닫아 응답을 완료합니다.

코드를 공유하면 동기화 상태가 유지됩니다.

서비스 워커 코드의 특정 부분이 익숙하게 보일 것입니다. 서비스 워커에서 사용하는 부분 HTML 및 템플릿 로직은 서버 측 핸들러에서 사용하는 로직과 동일합니다. 이렇게 코드를 공유하면 사용자가 내 웹 앱을 처음으로 방문하든 서비스 워커에서 렌더링된 페이지로 돌아가든 일관된 환경을 경험할 수 있습니다. 이것이 이소형 자바스크립트의 장점입니다.

동적이고 점진적인 개선

PWA의 서버와 서비스 워커를 모두 살펴봤지만 마지막으로 한 가지 고려해야 할 로직이 있습니다. 완전히 스트리밍된 후 각 페이지에서 실행되는 소량의 JavaScript가 있다는 것입니다.

이 코드는 사용자 환경을 점진적으로 개선하지만 중요한 것은 아닙니다. 웹 앱을 실행하지 않아도 웹 앱이 계속 작동합니다.

페이지 메타데이터

앱에서 클라이언트 측 JavaScipt를 사용하여 API 응답을 기반으로 페이지의 메타데이터를 업데이트합니다. 각 페이지에 캐시된 HTML의 동일한 초기 비트를 사용하기 때문에 웹 앱의 경우 문서 헤드에 일반 태그가 포함됩니다. 하지만 템플릿 코드와 클라이언트 측 코드 간의 조정을 통해 페이지별 메타데이터를 사용하여 창의 제목을 업데이트할 수 있습니다.

템플릿 코드의 일부로, 올바르게 이스케이프 처리된 문자열이 포함된 스크립트 태그를 포함하는 방법을 사용합니다.

const metadataScript = `<script>
  self._title = '${escape(item.title)}';
</script>`;

그런 다음 페이지가 로드되면 문자열을 읽고 문서 제목을 업데이트합니다.

if (self._title) {
  document.title = unescape(self._title);
}

자체 웹 앱에서 업데이트하려는 다른 페이지별 메타데이터가 있는 경우 동일한 접근 방식을 따르면 됩니다.

오프라인 UX

내가 추가한 다른 점진적인 개선 사항은 오프라인 기능에 주의를 환기시키는 데 사용됩니다. 저는 안정적인 PWA를 빌드했으며, 사용자가 오프라인일 때도 이전에 방문한 페이지를 로드할 수 있음을 알려주고 싶습니다.

먼저 Cache Storage API를 사용하여 이전에 캐시된 모든 API 요청의 목록을 가져오고 이를 URL 목록으로 변환합니다.

말씀드린 특수 데이터 속성을 기억하시나요? 각 속성에는 질문을 표시하는 데 필요한 API 요청의 URL이 포함되어 있나요? 캐시된 URL 목록과 해당 데이터 속성을 상호 참조하여 일치하지 않는 모든 질문 링크의 배열을 만들 수 있습니다.

브라우저가 오프라인 상태가 되면 캐시되지 않은 링크 목록을 루프스루하여 작동하지 않는 링크를 흐리게 표시합니다. 이는 사용자에게 페이지에서 무엇을 기대할 수 있는지에 대한 시각적 힌트일 뿐이며, 실제로 링크를 사용 중지하거나 사용자가 탐색할 수 없는 것은 아닙니다.

const apiCache = await caches.open(API_CACHE_NAME);
const cachedRequests = await apiCache.keys();
const cachedUrls = cachedRequests.map(request => request.url);

const cards = document.querySelectorAll('.card');
const uncachedCards = [...cards].filter(card => {
  return !cachedUrls.includes(card.dataset.cacheUrl);
});

const offlineHandler = () => {
  for (const uncachedCard of uncachedCards) {
    uncachedCard.style.opacity = '0.3';
  }
};

const onlineHandler = () => {
  for (const uncachedCard of uncachedCards) {
    uncachedCard.style.opacity = '1.0';
  }
};

window.addEventListener('online', onlineHandler);
window.addEventListener('offline', offlineHandler);

일반적인 문제

지금까지 다중 페이지 PWA를 빌드하는 방법을 살펴봤습니다. 자체적인 접근 방식을 정할 때는 여러 요소를 고려해야 하며 제가 한 것과 다른 선택을 하게 될 수도 있습니다. 이러한 유연성은 웹용 빌드의 장점 중 하나입니다.

자체 아키텍처와 관련된 결정을 내릴 때 발생할 수 있는 몇 가지 일반적인 문제가 몇 가지 있습니다.

전체 HTML을 캐시하지 않음

전체 HTML 문서는 캐시에 저장하지 않는 것이 좋습니다. 우선 공간 낭비입니다. 웹 앱이 각 페이지에 동일한 기본 HTML 구조를 사용하는 경우 동일한 마크업의 사본이 계속해서 저장됩니다.

무엇보다도 사이트의 공유 HTML 구조에 변경사항을 배포하더라도 이전에 캐시된 모든 페이지가 이전 레이아웃에서 그대로 유지됩니다. 재방문자가 이전 페이지와 새 페이지가 섞여서 표시되는 불편함을 상상해 보세요.

서버 / 서비스 워커 드리프트

피해야 할 또 다른 함정은 서버와 서비스 워커가 동기화되지 않는 것입니다. 제 접근 방식은 동일한 코드가 두 곳 모두에서 실행되도록 등성 자바스크립트를 사용하는 것이었습니다. 기존 서버 아키텍처에 따라 가능하지 않을 수도 있습니다.

어떤 아키텍처 결정을 내리든 서버와 서비스 워커에서 동등한 라우팅 및 템플릿 코드를 실행하기 위한 몇 가지 전략이 있어야 합니다.

최악의 시나리오

일관되지 않은 레이아웃 / 디자인

이러한 함정을 무시하면 어떻게 될까요? 모든 종류의 실패가 발생할 수 있지만 최악의 시나리오는 재방문 사용자가 매우 오래된 레이아웃(예: 오래된 헤더 텍스트가 있거나 더 이상 유효하지 않은 CSS 클래스 이름)으로 캐시된 페이지를 방문하는 것입니다.

최악의 시나리오: 손상된 라우팅

또는 서비스 워커가 아닌 서버에서 처리하는 URL이 사용자에게 표시될 수 있습니다. 좀비 레이아웃과 막다른 골목으로 가득한 사이트는 신뢰할 수 있는 PWA가 아닙니다.

성공을 위한 도움말

하지만 귀사만의 문제가 아닙니다. 다음 팁은 이러한 함정을 피하는 데 도움이 될 수 있습니다.

다국어로 구현된 템플릿 및 라우팅 라이브러리 사용

JavaScript 구현이 있는 템플릿 및 라우팅 라이브러리를 사용해 봅니다. 현재 웹 서버에서 템플릿을 이전하고 템플릿을 작성할 수 있는 여유가 없는 개발자도 있을 것입니다.

그러나 널리 사용되는 여러 템플릿 및 라우팅 프레임워크에는 여러 언어로 구현이 있습니다. 현재 서버의 언어와 함께 JavaScript에서 작동하는 코드를 찾으면 서비스 워커와 서버의 동기화 상태를 유지하는 데 한 걸음 더 가까워집니다.

중첩된 템플릿보다 순차적 템플릿 선호

다음으로 차례로 스트리밍할 수 있는 일련의 순차적 템플릿을 사용하는 것이 좋습니다. 페이지의 뒷부분에서 더 복잡한 템플릿 로직을 사용해도 괜찮습니다. 단, HTML의 초기 부분에서 최대한 빠르게 스트리밍할 수 있습니다.

서비스 워커에서 정적 콘텐츠와 동적 콘텐츠 모두 캐시

최상의 성능을 위해서는 사이트의 중요한 정적 리소스를 모두 사전 캐시해야 합니다. 또한 API 요청과 같은 동적 콘텐츠를 처리하기 위해 런타임 캐싱 로직을 설정해야 합니다. Workbox를 사용하면 처음부터 모두 구현하는 대신 잘 테스트되고 프로덕션에 즉시 사용 가능한 전략을 기반으로 빌드할 수 있습니다.

꼭 필요한 경우에만 네트워크 차단

이와 관련하여 캐시에서 응답을 스트리밍할 수 없는 경우에만 네트워크에서 차단해야 합니다. 캐시된 API 응답을 즉시 표시하면 최신 데이터를 기다리는 것보다 더 나은 사용자 경험을 제공할 수 있습니다.

자료