SPA 외 - PWA용 대체 아키텍처

아키텍처에 대해 이야기해 볼까요?

중요하지만 오해의 소지가 있는 주제인 웹 앱에 사용하는 아키텍처, 특히 프로그레시브 웹 앱을 빌드할 때 아키텍처 결정이 어떻게 작용하는지 살펴보겠습니다.

'아키텍처'는 모호하게 들릴 수 있으며, 이 문제가 왜 중요한지 바로 알 수 없을 수도 있습니다. 아키텍처를 생각하는 한 가지 방법은 다음과 같은 질문을 스스로에게 던지는 것입니다. 사용자가 내 사이트의 페이지를 방문할 때 어떤 HTML이 로드되나요? 그런 다음 사용자가 다른 페이지를 방문할 때 로드되는 항목은 무엇인가요?

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

자체 PWA를 빌드할 때 내 접근 방식을 자유롭게 사용할 수 있지만 항상 다른 유효한 대안이 있습니다. 이 모든 요소가 어떻게 서로 연결되는지 확인하면 영감을 얻고 필요에 맞게 맞춤설정할 수 있을 것입니다.

Stack Overflow PWA

이 도움말과 함께 Stack Overflow PWA를 빌드했습니다. Stack Overflow를 읽고 기여하는 데 많은 시간을 할애하고 있으며, 특정 주제에 대해 자주 묻는 질문을 쉽게 탐색할 수 있는 웹 앱을 빌드하고 싶었습니다. 공개 Stack Exchange API를 기반으로 빌드됩니다. 오픈소스이며 GitHub 프로젝트를 방문하여 자세히 알아볼 수 있습니다.

여러 페이지 앱 (MPA)

구체적인 내용을 설명하기 전에 몇 가지 용어를 정의하고 기본 기술을 설명하겠습니다. 먼저 '다중 페이지 앱' 또는 'MPA'라고 부르는 앱에 대해 설명하겠습니다.

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

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

안정적으로 빠른 속도

저를 비롯한 수많은 사람들이 '프로그레시브 웹 앱' 또는 PWA라는 문구를 사용하는 것을 들어보셨을 것입니다. 이 사이트의 다른 곳에서 이미 배경 자료를 접했을 수도 있습니다.

PWA는 최고 수준의 사용자 환경을 제공하고 사용자의 홈 화면에 표시될 만한 웹 앱이라고 생각하면 됩니다. 빠르고 통합적이며 신뢰할 수 있고 매력적인의 약자인 'FIRE'는 PWA를 빌드할 때 고려해야 하는 모든 속성을 요약합니다.

이 도움말에서는 빠른 속도안정성이라는 속성에 중점을 두겠습니다.

빠름: '빠름'은 상황에 따라 다른 의미를 갖지만 여기서는 네트워크에서 가능한 한 적게 로드할 때의 속도 이점을 다루겠습니다.

안정성: 하지만 단순히 속도만으로는 충분하지 않습니다. PWA처럼 느껴지려면 웹 앱이 안정적이어야 합니다. 네트워크 상태와 관계없이 맞춤 오류 페이지라도 항상 무언가를 로드할 수 있을 만큼 복원력이 있어야 합니다.

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

지원 기술: 서비스 워커 + 캐시 스토리지 API

PWA는 속도와 복원력에 대한 높은 기준을 도입합니다. 다행히도 웹 플랫폼은 이러한 유형의 성능을 실현할 수 있는 몇 가지 빌딩 블록을 제공합니다. 서비스 워커Cache Storage API를 말씀드리는 것입니다.

수신 요청을 수신 대기하고 일부를 네트워크에 전달하며 Cache Storage API를 통해 향후 사용을 위해 응답 사본을 저장하는 서비스 워커를 빌드할 수 있습니다.

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

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

캐시 스토리지 API를 사용하여 네트워크를 우회하고 응답하는 서비스 워커

가능한 한 네트워크를 피하는 것은 안정적으로 빠른 성능을 제공하는 데 매우 중요합니다.

'동형' JavaScript

마지막으로 다룰 개념은 '동형' 또는 '유니버설' JavaScript라고도 하는 것입니다. 간단히 말해 동일한 JavaScript 코드를 여러 런타임 환경 간에 공유할 수 있다는 아이디어입니다. PWA를 빌드할 때 백엔드 서버와 서비스 워커 간에 JavaScript 코드를 공유하고 싶었습니다.

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

서버

이러한 개념과 용어를 염두에 두고 Stack Overflow PWA를 실제로 빌드한 방법을 살펴보겠습니다. 먼저 백엔드 서버를 다루고 이것이 전체 아키텍처에 어떻게 적합한지 설명하겠습니다.

동적 백엔드와 정적 호스팅을 함께 사용하고 싶었고 Firebase 플랫폼을 사용하기로 했습니다.

Firebase Cloud Functions는 수신 요청이 있을 때 Node 기반 환경을 자동으로 시작하고 제가 이미 잘 알고 있는 인기 있는 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'), as>ync (req, res) = {
  // Templating logic.
});

서버 측 템플릿

템플릿 로직은 어떤 모습일까요? 부분 HTML 프래그먼트를 순서대로 하나씩 이어 붙이는 방식을 사용했습니다. 이 모델은 스트리밍에 적합합니다.

서버는 일부 초기 HTML 보일러플레이트를 즉시 다시 보내고 브라우저는 해당 부분 페이지를 바로 렌더링할 수 있습니다. 서버가 나머지 데이터 소스를 조합하면 문서가 완료될 때까지 브라우저로 스트리밍합니다.

예를 들어 경로 중 하나의 Express 코드를 살펴보세요.

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)}"< Qu>estions/h3`;
  cons<t form = `form me>tho<d=&qu>ot;GET".../form`;
  const questionCards = i>tems
    .map(item =
      questionCard({
        id: item.question_id,
        title: item.title,
      })
    )
    .join('&<#39;);
  const que>stions = `div id<=&qu>ot;questions"${questionCards}/div`;
  return title + form + questions;
}

이러한 템플릿 함수는 순수 JavaScript이며, 적절한 경우 로직을 더 작은 도우미 함수로 분리하는 것이 유용합니다. 여기서는 API 응답에서 반환된 각 항목을 이러한 함수 중 하나에 전달하여 모든 적절한 속성이 설정된 표준 HTML 요소를 만듭니다.

function questionCard({id, title}) {
  return `<a class="card"
             href="/questions/${id}"
             data-cache-url=>"${<qu>estionUrl(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라는 상위 수준 라이브러리 집합을 기반으로 서비스 워커를 빌드할 것입니다. 서비스 워커의 캐싱, 라우팅, 응답 생성 로직을 위한 견고한 기반을 제공합니다.

라우팅

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

저는 각 Express 경로를 해당하는 정규 표현식으로 변환하여 regexparam라는 유용한 라이브러리를 사용하는 방식을 택했습니다. 이 변환이 완료되면 Workbox의 정규식 라우팅에 대한 기본 지원을 활용할 수 있습니다.

정규식이 있는 모듈을 가져온 후 Workbox의 라우터에 각 정규식을 등록합니다. 각 경로 내에서 맞춤 템플릿 로직을 제공하여 응답을 생성할 수 있습니다. 서비스 워커의 템플릿은 백엔드 서버보다 약간 더 복잡하지만 Workbox를 사용하면 많은 작업을 쉽게 처리할 수 있습니다.

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

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

정적 애셋 캐싱

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

구성 파일을 사용하여 Workbox에 미리 캐시할 URL을 알려줍니다. 구성 파일은 모든 로컬 애셋이 포함된 디렉터리와 일치시킬 패턴 집합을 가리킵니다. 이 파일은 사이트를 다시 빌드할 때마다 실행되는 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를 사용하면 이 작업을 수행할 수 있습니다.

스트림 API에 대해 들어보셨을 수도 있습니다. 동료인 제이크 아치볼드는 몇 년 동안 이 기능을 칭찬해 왔습니다. 그는 2016년이 웹 스트림의 해가 될 것이라는 대담한 예측을 했습니다. Streams API는 2년 전과 마찬가지로 오늘날에도 유용하지만 중요한 차이점이 있습니다.

당시에는 Chrome만 스트림을 지원했지만 이제 스트림 API가 더 널리 지원됩니다. 전반적으로 긍정적이며 적절한 대체 코드를 사용하면 오늘 서비스 워커에서 스트림을 사용하는 데 아무런 문제가 없습니다.

하지만 스트림 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의 stale-while-revalidate 캐싱 전략에 대한 기본 지원을 사용하여 웹 앱의 저장소가 무한대로 늘어나지 않도록 합니다.

스트리밍 응답을 구성하는 다양한 소스를 처리하기 위해 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개 항목에 도달하면 최소 최근 사용 캐시 만료와 함께 stale-while-revalidate 캐싱 로직을 구현합니다.

이제 이러한 전략이 마련되었으므로 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로 응답을 처리합니다.

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

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

코드를 공유하면 동기화 상태를 유지할 수 있습니다.

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

동적 점진적 개선

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

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

페이지 메타데이터

내 앱은 클라이언트 측 JavaScript를 사용하여 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].filte>r(card = {
  return !cachedUrls.includes(card.dataset.cacheUrl);
});

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

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

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

일반적인 문제

이제 다중 페이지 PWA를 빌드하는 접근 방식을 둘러보았습니다. 자체 접근 방식을 고안할 때 고려해야 할 요소가 많으며, 저와 다른 선택을 할 수도 있습니다. 이러한 유연성은 웹용으로 빌드할 때의 장점 중 하나입니다.

자체 아키텍처 결정을 내릴 때 발생할 수 있는 몇 가지 일반적인 실수가 있으며, 이러한 실수를 피할 수 있도록 도와드리고자 합니다.

전체 HTML을 캐시하지 않음

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

더 중요한 점은 사이트의 공유 HTML 구조를 변경하는 경우 이전에 캐시된 페이지는 모두 이전 레이아웃으로 유지된다는 것입니다. 재방문자가 이전 페이지와 새 페이지가 혼합된 페이지를 본다고 생각해 보세요.

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

피해야 할 또 다른 함정은 서버와 서비스 워커가 동기화되지 않는 것입니다. 내 접근 방식은 동형 JavaScript를 사용하여 동일한 코드가 두 위치에서 모두 실행되도록 하는 것이었습니다. 기존 서버 아키텍처에 따라 항상 가능한 것은 아닙니다.

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

최악의 시나리오

일관성 없는 레이아웃 / 디자인

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

최악의 시나리오: 라우팅이 중단됨

또는 사용자가 서버에서 처리하지만 서비스 워커에서는 처리하지 않는 URL을 발견할 수도 있습니다. 좀비 레이아웃과 막다른 길이 가득한 사이트는 신뢰할 수 있는 PWA가 아닙니다.

성공을 위한 도움말

하지만 혼자서 해결하지 않아도 됩니다. 다음 팁을 참고하면 이러한 함정을 피할 수 있습니다.

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

JavaScript 구현이 있는 템플릿 및 라우팅 라이브러리를 사용해 보세요. 모든 개발자가 현재 웹 서버와 템플릿 언어를 이전할 수 있는 것은 아닙니다.

하지만 여러 인기 템플릿 및 라우팅 프레임워크는 여러 언어로 구현되어 있습니다. JavaScript와 현재 서버의 언어를 모두 지원하는 라이브러리를 찾을 수 있다면 서비스 워커와 서버를 동기화하는 데 한 걸음 더 가까워집니다.

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

다음으로, 연속된 템플릿을 사용하여 하나씩 스트리밍하는 것이 좋습니다. HTML의 초기 부분을 최대한 빨리 스트리밍할 수 있다면 페이지의 후반부에서 더 복잡한 템플릿 로직을 사용해도 괜찮습니다.

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

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

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

또한 캐시에서 응답을 스트리밍할 수 없는 경우에만 네트워크에서 차단해야 합니다. 캐시된 API 응답을 즉시 표시하면 최신 데이터를 기다리는 것보다 사용자 환경이 개선되는 경우가 많습니다.

리소스