Enjoy SFV More の技術解説 2020.08.03 更新

概要

f:id:tiwu:20200708204601p:plain

Enjoy SFV More という、ストリートファイターV をより楽しむためのサービスを開発しました👊

日本版

enjoy-sfv-more.com

英語版

enjoy-sfv-more.com

Twitter

https://twitter.com/EnjoySFVMore

サイトの特徴は

  • 静的なページ(Firebase Hosting)と動的なページ(Firebase Functions)で構成
  • Eleventy を利用し静的なページを生成
  • 動的なページや APINode.js + express を利用
  • フロントは Web Components を利用
  • 全て TypeScript で記述
  • React, Vue, jQuery といったフレームワークやライブラリは利用をしていない
  • PWA を導入
  • 一部ページは AMP 対応

といったところです。

この記事では利用している技術について解説していきたいと思います(変更などがあれば本記事を随時更新していきます)

ディレクト

まずはじめにディレクトリ構成です。

f:id:tiwu:20200707211253p:plain

firebase cli で作りました。

更新履歴

  • 2020.08.03 Eleventy 記事を追加

HTML

Web Components で書いている以外特別何かしているということはあまりないです。

CSS

CSS のライブラリなどや SCSS などは導入しておらず、普通に書いています。

display:flex が好きなので、どんなレイアウトでも display:flex で何とかしています🤔

ShadowDOM を利用しているので、FLOCSS のような書き方をしていますが、結構適当な命名ルールで書いてます😇

developers.google.com

github.com

JavaScript

できるだけ標準の機能を使おうと思っているので、axios などといったライブラリなどは導入していません(Fetch API 使ってリクエスト投げたりしています)

github.com

developer.mozilla.org

package.json

Firebase Hosting 側の package.json

開発時は npm run servenpm run watch を実行して開発しています。

スマホデバッグしたい時は npm run dev-servengrok を起動させています。

{
  "scripts": {
    "build": "NODE_ENV=production webpack --mode production",
    "watch": "NODE_ENV=development webpack --mode development --watch",
    "serve": "firebase emulators:start --only functions,hosting",
    "dev-serve": "ngrok http 5000",
    "test": "jest"
  },
  "devDependencies": {
    "@types/jest": "^24.9.1",
    "@typescript-eslint/eslint-plugin": "^2.31.0",
    "@typescript-eslint/parser": "^2.31.0",
    "eslint": "^6.8.0",
    "eslint-config-airbnb-base": "^14.1.0",
    "eslint-config-prettier": "^6.11.0",
    "eslint-loader": "^3.0.4",
    "eslint-plugin-prettier": "^3.1.3",
    "html-webpack-plugin": "^3.2.0",
    "jest": "^24.9.0",
    "prettier": "^1.19.1",
    "script-ext-html-webpack-plugin": "^2.1.4",
    "ts-jest": "^24.3.0",
    "ts-loader": "^6.2.2",
    "typescript": "^3.8.3",
    "webpack": "^4.43.0",
    "webpack-cli": "^3.3.11"
  },
  "dependencies": {
    "@types/google.analytics": "0.0.40",
    "@types/gtag.js": "0.0.3",
    "firebase": "^7.14.2",
    "street-fighter-v-data": "github:tiwuofficial/street-fighter-v-data#master"
  }
}

Firebase Functions 側の package.json

開発時は npm run watchtypescriptコンパイルだけ動かしています(なぜか firebase cli で作られる package.json に無い🤔 )

{
  "scripts": {
    "lint": "tslint --project tsconfig.json",
    "build": "tsc",
    "watch": "tsc --watch",
    "serve": "npm run build && firebase serve --only functions",
    "shell": "npm run build && firebase functions:shell",
    "start": "npm run shell",
    "deploy": "firebase deploy --only functions",
    "logs": "firebase functions:log"
  },
  "engines": {
    "node": "10"
  },
  "dependencies": {
    "canvas": "^2.6.1",
    "ejs": "^3.1.2",
    "express": "^4.17.1",
    "firebase-admin": "^8.6.0",
    "firebase-functions": "^3.3.0",
    "googleapis": "^47.0.0",
    "sitemap": "^5.1.0",
    "street-fighter-v-data": "github:tiwuofficial/street-fighter-v-data#master",
    "zlib": "^1.0.5"
  },
  "devDependencies": {
    "@types/node": "^13.13.5",
    "firebase-functions-test": "^0.1.6",
    "tslint": "^5.12.0",
    "typescript": "^3.2.2"
  }
}

Firebase Hosting でも Firebase Functions でも同じクラスなどを扱うために street-fighter-v-data リポジトリを読み込んでいます。

github.com

他に良い方法があるのかもしれないのですが、浮かばなかったのでこのような構成にしています。

一度 npm i でインストールした後、リポジトリを更新して反映を取り込もうとして npm i を実行しても取り込めないので、毎回 node_modules からディレクトリを削除して npm i を実行して取り込んでいます😇

良い方法知っている方いたら教えていただけると🙏

TypeScript

Firebase Hosting, Firebase Functions, street-fighter-v-data など全て TypeScript で書いています。

street-fighter-v-data では Firebase Functions で読み込むために JavaScript にビルドしています。

型定義ファイルも出力するために declarationtrue にしています。

street-fighter-v-datatsconfig.json

{
  "compilerOptions": {
    "outDir": "dist",
    "module": "commonjs",
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "sourceMap": true,
    "target": "es2017",
    "moduleResolution": "node",
    "declaration": true
  },
  "compileOnSave": true,
  "exclude": [
    "__tests__"
  ]
}

Firebase Hostingtsconfig.json

{
  "compilerOptions": {
    "sourceMap": true,
    "target": "ES6",
    "module": "es2015",
    "lib": [
      "dom",
      "esnext",
      "webworker"
    ]
  },
  "exclude": [
    "functions"
  ]
}

Firebase Functionstsconfig.json

{
  "compilerOptions": {
    "module": "commonjs",
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "outDir": "lib",
    "sourceMap": true,
    "target": "es2017"
  },
  "compileOnSave": true,
  "include": [
    "src"
  ]
}

まだそこまで使いこなせておらず、簡単な型の定義くらいしか書いていません。

唯一工夫したのは Service Worker 関連です。

Service Worker 関連の関数(skipWaiting)の型を効かせるために webworkerlib に指定して、declare let self: ServiceWorkerGlobalScope; を定義して型を効かせています。

declare let self: ServiceWorkerGlobalScope;

self.addEventListener("install", async event => {
  self.skipWaiting();
});

Web Components

サイトのメインどころである Web Components です。わからないことや困ったらよく下記の Google の記事を読んでいます。

developers.google.com

LitElement という GooglePolymer プロジェクトが作っている Web Components ライブラリは利用していません。

lit-element.polymer-jp.org

ディレクトリ構成

ディレクトリ構成は web-components ディレクトリ内に、component (components にすべきと書きながら気づきました😇 )、pageraw-components としています。

f:id:tiwu:20200707211953p:plain

今見るとキャメル、スネークごちゃまぜになっている 😇

raw-components

raw-components は何も import などしていない単体で成立する下記のような Web Components を置いています。

class Button extends HTMLElement {
  constructor() {
    super();

    this.attachShadow({ mode: "open" }).innerHTML = `
      <style>
       :host {
          background-color: #888;
          padding: 10px 0;
          font-size: 15px;
          border: none;
          border-radius: 24px;
          display: flex;
          justify-content: center;
          align-items: center;
          margin: 0 auto;
          color: #fff;
          width: 100%;
          cursor: pointer;
        }
      </style>
      <slot></slot>
    `;
  }
}
customElements.define("enjoy-sfv-more-button", Button);

上記はシンプルな Web Components で下記のようにタグで囲ったテキストが <slot></slot> に展開されます。

<enjoy-sfv-more-button>もっとキャラを探す!</enjoy-sfv-more-button>

f:id:tiwu:20200708211507p:plain

textarea など入力を受け付ける Web ComponentsShadowDOM でタグが隠されているので、独自でイベントを発火させています。

class Textarea extends HTMLElement {
  constructor() {
    super();

    this.attachShadow({ mode: "open" }).innerHTML = `
      <textarea id="textarea" cols="30" rows="5"></textarea>
    `;

    this.shadowRoot.getElementById("textarea").addEventListener("input", () => {
      const elm: HTMLTextAreaElement = this.shadowRoot.getElementById("textarea") as HTMLTextAreaElement;
      this.dispatchEvent(
        new CustomEvent("input", {
          detail: {
            value: elm.value
          }
        })
      );
    });
  }
}
customElements.define("enjoy-sfv-more-textarea", Textarea);

イベントは他のタグと同様に addEventListener で処理をしています。

this.shadowRoot.querySelector("enjoy-sfv-more-textarea").addEventListener("input", (e: CustomEvent) => {
  console.log(e.detail.value);
});

componet

raw-components を組み合わせたりほかコードを import したりしている Web Components を置いています。

キャラクター選択モーダルは1つの Web Components として作成しています。

下記2画面で使われているキャラクター選択モーダルは同じ Web Components が使われています。

https://enjoy-sfv-more.com/combos/create

https://enjoy-sfv-more.com/lounge/create

f:id:tiwu:20200708211941p:plain

import { characters } from "characters";
class CharacterSelectModal extends HTMLElement {
 constructor() {
    super();
    this.attachShadow({ mode: "open" }).innerHTML = `
      <enjoy-sfv-more-modal>
        <enjoy-sfv-more-p class="title">キャラクターを選択してください</enjoy-sfv-more-p>
        <div id="list"></div>
      </enjoy-sfv-more-modal>
    `;

    const characterClick = (e): void => {
      this.shadowRoot.host.removeAttribute("open");
      this.dispatchEvent(
        new CustomEvent("enjoy-sfv-more-character-select-modal-selected", {
          detail: {
            characterId: e.currentTarget.getAttribute("character-id")
          }
        })
      );
    };

    characters.characterForEach(character => {
      const elm = document.createElement("enjoy-sfv-more-p");
      elm.textContent = character.name;
      elm.setAttribute("character-id", character.id);
      elm.addEventListener("click", characterClick);
      this.shadowRoot.getElementById("list").appendChild(elm);
    });
  }
}
customElements.define("enjoy-sfv-more-character-select-modal", CharacterSelectModal);

キャラクターが選択されたらモーダルを閉じて(上記からはコードを省いています)、enjoy-sfv-more-character-select-modal-selected イベントを発火するようにしています。

this.shadowRoot.querySelector("enjoy-sfv-more-character-select-modal").addEventListener("enjoy-sfv-more-character-select-modal-selected", e => {
  console.log(e.detail.characterId);
});

受け取り側はカスタムイベントから e.detail.characterId で選択されたキャラクターのIDを取得ができます。

page

Enjoy SFV More は各画面下記のように、enjoy-sfv-more-body タグを共通で記述し、子のタグに各画面に対応する Web Components を記述しています。

<body>
  <enjoy-sfv-more-body>
    <enjoy-sfv-more-frames></enjoy-sfv-more-frames>
  </enjoy-sfv-more-body>
</body>
<body>
  <enjoy-sfv-more-body top>
    <enjoy-sfv-more-top></enjoy-sfv-more-top>
  </enjoy-sfv-more-body>
</body>

raw-componentscomponet ディレクトリにある Web Components を利用して画面を作り上げます。

その他資料

speakerdeck.com

PWA

PWA を利用してアプリのようにサイトを開発しています。

Install

Enjoy SFV More はインストールをしてホーム画面に追加することができます。

インストールできるようにするためには manifest.json を置いて、空で Service Workerfetch を登録すれば最小限で実現できます。

{
  "name": "Enjoy SFV More",
  "short_name": "Enjoy SFV More",
  "icons": [{
    "src": "/src/img/icon/logo-192x192.png",
    "sizes": "192x192",
    "type": "image/png"
  },{
    "src": "/src/img/icon/logo-512x512.png",
    "sizes": "512x512",
    "type": "image/png"
  }],
  "start_url": "/",
  "display": "standalone",
  "background_color": "#3E4EB8",
  "theme_color": "#2F3BA2"
}
self.addEventListener("fetch", e => {});

最小限の構成で実装するとインストールを促す mini-infobar などの表示タイミングはブラウザに委ねられるので、ボタンクリックなど任意のタイミングでインストールできるように実装しています。

window.addEventListener("beforeinstallprompt", e => {
  e.preventDefault();
  this.deferredPrompt = e;
});

まずは、 beforeinstallprompt イベントで mini-infobar が表示されないように e.preventDefault(); を実行しイベントのオブジェクトを保持します。

document.getElementById('button').addEventListener('click', (e) => {
  this.deferredPrompt.prompt();
  deferredPrompt.userChoice.then((choiceResult) => {
    if (choiceResult.outcome === 'accepted') {
      console.log('accepted');
    } else {
      console.log('dismissed');
    }
  });
});

用意したインストールボタンのクリックイベントなどで、保持していた beforeinstallprompt イベントのオブジェクトの prompt を実行することで mini-infobar などが表示されます。

userChoice を経由すればユーザーが許可したか拒否したか取得できます。

window.addEventListener('appinstalled', () => {
  console.log('install');
});

インストール数を取得したい場合は、appinstalled がインストール時に発生するイベントなどでこちらを利用するのが正確に取れるかと思います。

その他資料

speakerdeck.com

Cache

Cache API + Service Worker を利用しキャッシュによる高速な表示を実装しています。

Cache API は困ったら MDN の記事をよく読んでいます。

developer.mozilla.org

Enjoy SFV More では画像と HTML, JavaScript を分けて考えキャッシュしています。

import { CACHE_NAME } from "./env-sw-cache";
const ASSET_CACHE_NAME = "asset-v1";
const IMGS = [
  "src/img/hero-logo.png",
  "src/img/icon/punch.svg"
];
const FILES_TO_CACHE = ["/", "/lounge/create"];

async function onInstall(): Promise<void> {
  self.skipWaiting();

  const imgCache = await caches.open(ASSET_CACHE_NAME);
  await imgCache.addAll(IMGS);

  const cache = await caches.open(CACHE_NAME);
  return cache.addAll(FILES_TO_CACHE);
}

self.addEventListener("install", async event => {
  event.waitUntil(onInstall());
});

async function onActivate(): Promise<boolean[]> {
  const keys = await caches.keys();
  return await Promise.all(
    keys.map(key => {
      if (key !== CACHE_NAME && key !== ASSET_CACHE_NAME) {
        return caches.delete(key);
      }
    })
  );
}

self.addEventListener("activate", event => {
  event.waitUntil(onActivate());
});

install イベント内で、画像と HTML をまるごとキャッシュに保存します。

activate イベント内では古いキャッシュがあれば削除しています。

async function onFetch(request): Promise<Response> {
  const cache = await caches.open(CACHE_NAME);

  return caches.match(request).then(response => {
    return (
      response || fetch(request).then(response => {
        if (response.type === "basic" && response.url.indexOf("src/js/index.js") >= 0) {
          cache.put(request, response.clone());
        }
        return response;
      })
    );
  });
}

self.addEventListener("fetch", event => {
  event.respondWith(onFetch(event.request));
});

fetch イベントではキャッシュにあればキャッシュから返し、src/js/index.js であればキャッシュに保存、それ以外はネットワークから取得するようにしています。

src/js/index.js?hash=154722cfd9ffa6be04b2 といったハッシュにしており、本来 install 内でハッシュ付き index.js を読み込むべきなのですが、webpack とうまく連携ができておらずこのような形に落ち着きました。

JavaScript の更新

JavaScript のハッシュが更新された時、HTMLscript タグのハッシュを変更しただけではキャッシュは更新されません。

HTML をまるごとキャッシュしているため新しくビルドした HTML をネットワーク経由で取得をしないためです。

旧ハッシュが書かれている HTML を破棄するために、キャッシュ名の CACHE_NAME を変更し、Serivce Worker の更新処理を動かします。

Install で新しい HTML を新しいキャッシュ名に保存し、古いキャシュ名にある古い HTMLActivate で削除します。

現状キャッシュ名はビルド時に手動で変更をしているので、webpack のハッシュ名などにして自動化をしたほうが良さそうです🤔

その他資料

qiita.com

speakerdeck.com

Share API

コンボの詳細画面では Share API を使ってネイティブアプリのようにシェアをすることができます。

const newVariable: any = window.navigator;
if (newVariable && newVariable.share) {
  this.shadowRoot.querySelector("button").classList.remove("is-hidden");
  this.shadowRoot.querySelector("a").classList.add("is-hidden");
  this.shadowRoot.querySelector("button").addEventListener("click", () => {
    newVariable
      .share({
        title: "Enjoy SFV More",
        text: this.getAttribute("share-text"),
        url: document.location.href
      })
      .then(() => console.log("Successful share"))
      .catch(error => console.log("Error sharing", error));
  });
} else {
  this.shadowRoot.querySelector("a").setAttribute("href", encodeURI(`https://twitter.com/share?url=${document.location.href}&text=${this.getAttribute("share-text")}`));
}

実装はかなり簡単で share が存在すれば利用して、なければツイッターで共有するようにしています。

その他資料

speakerdeck.com

AMP

Enjoy SFV More のキャラクターのフレームのページは AMP に対応しています。

Enjoy SFV More では通常のページと AMP のページを作っています。

https://enjoy-sfv-more.com/frames/dhalsim

https://enjoy-sfv-more.com/amp/frames/dhalsim

もともと通常のページは JavaScript でタイトルやメタタグや中身をレンダリングしていたのですが、amphtml タグは JavaScriptレンダリングすると認識しないので、ExpressSSR する方針に途中で変更しました。

並び替えや絞り込みを実現させるためにいろいろな AMP のタグを利用しています。

モーダル表示

まず、並び替えや絞り込みを選択するモーダルの表示には、amp-lightbox を利用しています。

f:id:tiwu:20200717164037p:plain

<div on="tap:sort-lightbox" role="button" tabindex="0">
  <p>フレームの並び替え</p>
  <p [text]="sortText">デフォルト</p>
</div>

<amp-lightbox id="sort-lightbox" layout="nodisplay">
  <div on="tap:sort-lightbox.close" role="button" tabindex="2"></div>
  <div>
    <p>並び替える条件を選択してください</p>
    <amp-selector layout="container" on="select: AMP.setState({ 
        listHeight: <%= height %>,
        sortId: event.targetOption,
        sortText: frameSortsState[event.targetOption],
        defaultListClass: 'is-hidden',
      }),
        list.changeToLayoutContainer(),
        sort-lightbox.close
      ">
        <p option="0">デフォルト</p>
        <p option="1">発生の早い順</p>
        <p option="2">発生の遅い順</p>
    </amp-selector>
  </div>
</amp-lightbox>

on="tap:sort-lightbox"id="sort-lightbox"amp-lightbox が表示され、on="tap:sort-lightbox.close" で非表示になります。

要素の選択

並び替えや絞り込みの選択は amp-selector を利用しています。

並び替えのような1つしか選択できないものは、on="select: " 内で event.targetOption 経由で選択した要素の option が取得できます。

フレームの並び替えは、 AMP.setState を利用して選択した並び替えのID (sortId: event.targetOption)やテキスト(sortText: frameSortsState[event.targetOption])を変数に格納しています。

絞り込みのような複数選択できるものは、multiple 属性を付与します。

f:id:tiwu:20200717165508p:plain

<amp-selector layout="container" multiple on="select: AMP.setState({ 
    selectedIds: event.selectedOptions
  })
">
  <p option="1">通常技</p>
  <p option="2">ジャンプ技</p>
  <p option="3">特殊技</p>
</amp-selector>

選択された要素の optionon="select: " 内で、event.selectedOptions 経由で配列で取得できます。

フレームの絞り込みではAMP.setStateを利用して選択された絞り込みのID(selectedIds: event.selectedOptions)を変数に格納しています。

変数の利用

並び替えや絞り込みの選択時 amp-bind を利用して選択した値を AMP.setState を実行し変数に格納しています。

並び替え

<amp-state id="frameSortsState">
  <script type="application/json">
      {
        "0":"デフォルト",
        "1":"発生の早い順",
        "2":"発生の遅い順"
       }    
  </script>
</amp-state>

<p [text]="sortText">デフォルト</p>

<amp-selector layout="container"  on="select: AMP.setState({ 
    sortId: event.targetOption,
    sortText: frameSortsState[event.targetOption],
  }),
  ">
  <p option="0">デフォルト</p>
  <p option="1">発生の早い順</p>
  <p option="2">発生の遅い順</p>
</amp-selector>

amp-state を利用して、並び替えのオブジェクトを frameSortsState 変数に格納します。

並び替え「発生の早い順」が選択されたら sortText 変数には frameSortsState[1] の値(発生の早い順)が格納され、[text]="sortText" により「デフォルト」と表示している p は「発生の早い順」が表示されます。

絞り込み

<amp-state id="frameFilterState">
  <script type="application/json">
      {
        "1":"通常技",
        "2":"ジャンプ技",
        "3":"特殊技"
      }
  </script>
</amp-state>

<p [text]="filterIds.map(id => frameFilterState[id]).join(', ') || 'なし'">なし</p>

<amp-selector layout="container" multiple
  on="select: AMP.setState({ 
    selectedIds: event.selectedOptions
  })
  ">
  <p option="1">通常技</p>
  <p option="2">ジャンプ技</p>
  <p option="3">特殊技</p>
</amp-selector>

<p class="button" role="button" tabindex="4"
  on="tap:AMP.setState({
    filterIds: selectedIds,
  })
">検索する</p>

amp-state を利用して、絞り込みのオブジェクトを frameFilterState 変数に格納します。

絞り込みを複数選択し検索ボタンをクリックすると、filterIds に選択された絞り込みの ID が格納され、 filterIds.map(id => frameFilterState[id]).join(', ') により選択した絞り込みが表示されます。

フレーム一覧の表示

フレーム一覧は、初期表示と並び替え or 絞り込み による検索で表示方法が違います。

初期表示は AMP Cache による高速な表示のため SSR して表示させています。

並び替え or 絞り込み による検索では amp-list を利用して表示させています。

<amp-list id="list"
    [src]="'/api/frames?characterId=<%= characterId %>&sortId=' + sortId + '&filterIds=' + filterIds"
    height="0"
    [height]="listHeight"
    width="auto">
  <template type="amp-mustache">
    <div class="p-frame-cassette">
      <a href="{{ url }}" class="name">{{ name }}</a>
    </div>
  </template>
</amp-list>

amp-list[src] にフレーム一覧のデータを返す APIURL を定義します。

{
  "items":
  [
    {
      "url":"https://enjoy-sfv-more.com/frames/dhalsim/24",
      "name":"ヨガロケット"
    },
    {
      "url":"https://enjoy-sfv-more.com/frames/dhalsim/25",
      "name":"ヨガフープ"
      }
    ]
}

API は上記のような構成でデータを返します。itemsamp-list のデフォルトなため構成を変更したい場合は、items 属性を利用して変更ができます。

繰り返されるフレームは<template type="amp-mustache"> を利用して {{ url }}{{ name }}といったように表示させます。

初期表示では SSR したフレーム一覧を表示するため height="0" により amp-list のフレーム一覧は非表示にしています。

<p class="button" role="button" tabindex="4"
  on="tap:AMP.setState({ 
    listHeight: 100,
    filterIds: selectedIds,
    defaultListClass: 'is-hidden'
  }),
  list.changeToLayoutContainer(),
  filter-lightbox.close
">検索する</p>

並び替えや絞り込みで sortIdfilterIds に変更があった場合、amp-listAPI からデータを取得しフレーム一覧を表示します。

このとき、 SSR しているフレーム一覧は defaultListClass: 'is-hidden' によって非表示に、amp-list のフレーム一覧は listHeight: 100 により一旦 100px になった後、list.changeToLayoutContainer() によりフレーム一覧の要素がちょうど表示される高さに調整されます。

i18n

Enjoy SFV More は英語版があります。

enjoy-sfv-more.com

下記を参考に、/en 配下にページを作り、<link rel="alternate" hreflang="ja" href="https://enjoy-sfv-more.com/">, <link rel="alternate" hreflang="en" href="https://enjoy-sfv-more.com/en"> といったタグを仕込んでいます。

support.google.com

support.google.com

Web Components は可能な限り使い回しをしていて、${this.hasAttribute("lang-en") ? "Damage" : "ダメージ"} といったように、属性を元に表示するテキストを判定しています。

class ComboListLinkItem extends HTMLElement {
  constructor() {
    super();
  }

  connectedCallback(): void {
    this.attachShadow({ mode: "open" }).innerHTML = `
      <a href='' class="js-link">
        <enjoy-sfv-more-p id="title">${this.getAttribute("title")}</enjoy-sfv-more-p>
        <enjoy-sfv-more-p class="character-title">${this.hasAttribute("lang-en") ? "Character" : "キャラクター"} : ${this.getAttribute("character-name")}</enjoy-sfv-more-p>
        <div class="info">
          <enjoy-sfv-more-p>${this.hasAttribute("lang-en") ? "Damage" : "ダメージ"} : ${this.getAttribute("damage")}</enjoy-sfv-more-p>
          <enjoy-sfv-more-p>${this.hasAttribute("lang-en") ? "Stun" : "スタン"} : ${this.getAttribute("stun")}</enjoy-sfv-more-p>
        </div>
        <enjoy-sfv-more-p class="combo-title">${this.hasAttribute("lang-en") ? "Combo" : "コンボ"}</enjoy-sfv-more-p>
        <div id="combo"></div>
      </a>
    `;
  }
}
customElements.define("enjoy-sfv-more-combo-list-link-item", ComboListLinkItem);

Eleventy

フレームのページは Express による SSR から Eleventy を使った静的サイトジェネレート(言わない?)にリファクタリングしました。

詳しくは下記記事をご覧ください🙇‍♂️

tiwu.hatenablog.com

webpack

教科書どおりに typescriptコンパイルしています。

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const ScriptExtHtmlWebpackPlugin = require("script-ext-html-webpack-plugin");

module.exports = [
  {
    entry: "./src/js/index.ts",
    module: {
      rules: [
        {
          enforce: "pre",
          test: /\.ts$/,
          exclude: /node_modules/,
          use: {
            loader: "eslint-loader",
            options: {
              fix: true
            }
          }
        },
        {
          test: /\.ts$/,
          use: "ts-loader"
        }
      ]
    },
    resolve: {
      extensions: [".ts"]
    },
    plugins: [
      new HtmlWebpackPlugin({
        template: "./src/html/lounge/index.html",
        filename: "lounge/index.html"
      }),
      new ScriptExtHtmlWebpackPlugin({
        defaultAttribute: "async"
      })
    ],
    output: {
      filename: "src/js/index.js?hash=[contenthash]",
      path: path.resolve(__dirname, "public"),
      publicPath: "/"
    }
  },
  {
    entry: "./src/js/en/index.ts",
    module: {
      rules: [
        {
          enforce: "pre",
          test: /\.ts$/,
          exclude: /node_modules/,
          use: {
            loader: "eslint-loader",
            options: {
              fix: true
            }
          }
        },
        {
          test: /\.ts$/,
          use: "ts-loader"
        }
      ]
    },
    resolve: {
      extensions: [".ts"]
    },
    plugins: [
      new HtmlWebpackPlugin({
        template: "./src/html/en/index.html",
        filename: "en/index.html"
      }),
      new ScriptExtHtmlWebpackPlugin({
        defaultAttribute: "async"
      })
    ],
    output: {
      filename: "src/js/en/index.js?hash=[contenthash]",
      path: path.resolve(__dirname, "public"),
      publicPath: "/"
    }
  },
  {
    entry: "./src/js/sw.ts",
    module: {
      rules: [
        {
          enforce: "pre",
          test: /\.ts$/,
          exclude: /node_modules/,
          use: {
            loader: "eslint-loader",
            options: {
              fix: true
            }
          }
        },
        {
          test: /\.ts$/,
          use: "ts-loader"
        }
      ]
    },
    resolve: {
      extensions: [".ts"]
    },
    output: {
      filename: "sw.js",
      path: path.resolve(__dirname, "public")
    }
  }
];

html-webpack-plugin

html-webpack-plugin を利用して webpack が吐き出すハッシュ付きの JavaScript ファイルを HTML に埋め込んでいます。

github.com

script-ext-html-webpack-plugin

埋め込まれる script タグに async が付かないので、script-ext-html-webpack-plugin を利用して付与しています。

github.com

Express

SSR したり、 API 開発のために Express を利用しています。

特別な何かはしていないですが、OGP 自動生成を下記を参考に実装しました。

FirebaseCloudFunctionsとcanvasだけで動的にOGP画像を生成し2020年を生き延びよう - Qiita

FirebaseCloudFunctionsとcanvasだけで動的にOGP画像を生成し2020年を生き延びよう - Qiita

作成処理

const splitByMeasureWidth = (str: string, maxWidth: number, context: { measureText: CallableFunction }): Array<string> => {
  // サロゲートペアを考慮した文字分割
  const chars = Array.from(str);
  let line = "";
  const lines = [];
  for (const char of chars) {
    if (maxWidth <= context.measureText(line + char).width) {
      lines.push(line);
      line = char;
    } else {
      line += char;
    }
  }
  lines.push(line);
  return lines;
};

const canvasData = {
  width: 1200,
  height: 630,
  padding: 60,
  titleMargin: 40,
  bodyMargin: 20,
  comboMargin: 20,
  lineWidth: (): number => {
    return canvasData.width - canvasData.padding * 2;
  }
};

// タイトル部分の文字スタイル
const titleFontStyle = {
  font: 'bold 73px "Noto Sans CJK JP"',
  lineHeight: 80,
  color: "#ffffff"
};

// 本文部分の文字スタイル
const bodyFontStyle = {
  font: '40px "Noto Sans CJK JP"',
  lineHeight: 60,
  color: "#ffffff"
};

const createOgp = async (title: string, body: string, combo: string, memo: string, docId: string): Promise<void> => {
  const loaclTargetPath = "/tmp/target.png";
  const targetPath = `ogps/${docId}.png`;

  // canvasの作成
  const canvas = createCanvas(canvasData.width, canvasData.height);
  const ctx = canvas.getContext("2d");

  // -----
  // タイトル描画
  // -----
  // フォント設定
  ctx.font = titleFontStyle.font;
  // 行数の割り出し
  const titleLines = splitByMeasureWidth(title, canvasData.lineWidth(), ctx);
  // タイトル分の高さ
  const titleHeight = titleLines.length * titleFontStyle.lineHeight;

  // -----
  // 本文部分描画
  // -----
  // フォント設定
  ctx.font = bodyFontStyle.font;
  // 行数の割り出し
  const comboLines = splitByMeasureWidth(combo, canvasData.lineWidth(), ctx);

  const comboHeight = comboLines.length * bodyFontStyle.lineHeight;
  const memoLines = splitByMeasureWidth(memo, canvasData.lineWidth(), ctx);

  // 背景画像の描画
  const gradient = ctx.createLinearGradient(canvasData.width, 0, 0, canvasData.height);
  gradient.addColorStop(0, "#FF1763");
  gradient.addColorStop(0.75, "#F98F78");
  gradient.addColorStop(1, "#FFC778");
  ctx.fillStyle = gradient;
  ctx.fillRect(0, 0, canvasData.width, canvasData.height);
  // 文字描画のベースラインを設定
  ctx.textBaseline = "top";

  // タイトルを描画
  ctx.fillStyle = titleFontStyle.color;
  ctx.font = titleFontStyle.font;
  for (let index = 0; index < titleLines.length; index++) {
    ctx.fillText(titleLines[index], canvasData.padding, canvasData.padding + titleFontStyle.lineHeight * index);
  }

  // 本文を描画
  ctx.fillStyle = bodyFontStyle.color;
  ctx.font = bodyFontStyle.font;
  // キャラクター : ${req.body.character}, ダメージ : ${req.body.damage}, スタン : ${req.body.stun} を描画
  ctx.fillText(body, canvasData.padding, canvasData.padding + titleHeight + canvasData.titleMargin);

  // combo を描画
  for (let index = 0; index < comboLines.length; index++) {
    ctx.fillText(comboLines[index], canvasData.padding, canvasData.padding + titleHeight + canvasData.titleMargin + canvasData.bodyMargin + bodyFontStyle.lineHeight * (index + 1));
  }

  // memo を描画
  for (let index = 0; index < memoLines.length; index++) {
    ctx.fillText(
      memoLines[index],
      canvasData.padding,
      canvasData.padding + titleHeight + comboHeight + canvasData.titleMargin + canvasData.bodyMargin + canvasData.comboMargin + bodyFontStyle.lineHeight * (index + 1)
    );
  }

  // tmpディレクトリへの書き込み
  const buf = canvas.toBuffer();
  fs.writeFileSync(loaclTargetPath, buf);

  // Storageにアップロード
  await bucket.upload(loaclTargetPath, { destination: targetPath });

  // tmpファイルの削除
  fs.unlinkSync(loaclTargetPath);
};

取得処理

const checkIsExists = async (id: string): Promise<boolean> => {
  const filePath = `ogps/${id}.png`;
  const isExists = await bucket.file(filePath).exists();
  return isExists[0];
};

const getUrl = async (id: string): Promise<string> => {
  const isExists = await checkIsExists(id);
  if (isExists) {
    const url = `ogps/${id}.png`;
    return `https://firebasestorage.googleapis.com/v0/b/enjoy-sfv-more.appspot.com/o/${encodeURIComponent(url)}?alt=media`;
  }
  return "https://enjoy-sfv-more.com/src/img/ogp.png";
};

EJS

SSR には EJS を利用しています。

<enjoy-sfv-more-combo-show
  combo-title="<%= combo.title %>"
  character-name="<%= character.name %>"
  character-en-name="<%= character.enName %>"
  damage="<%= combo.damage %>"
  stun="<%= combo.stun %>"
  memo="<%= combo.memo %>"
  memo="<%= combo.memo %>"
  frames="<%= JSON.stringify(frames) %>"
  user-name="<%= user.displayName %>"
  user-img="<%= user.photoURL %>"
></enjoy-sfv-more-combo-show>

レンダリングWeb Components に任せているので属性を渡すだけやってます。

GitHub Actions

リリースは GitHub Actions を利用しています。

name: Build and Deploy
on:
  push:
    branches:
      - master

jobs:

  build_and_deploy:
    name: Build&Deploy
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repo
        uses: actions/checkout@master
      - uses: actions/setup-node@v1
      - name: Install Dependencies
        run: npm install
      - name: Install Functions Dependencies
        run: cd functions && npm install
      - name: Install firebase-tools
        run: npm install -g firebase-tools
      - name: Deploy to Firebase
        run: firebase deploy --token $FIREBASE_TOKEN
        env:
          FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}

もともと GitHub Actions MarketPlace にある Firebase を利用していました。

github.com

しかし OGP 自動生成の際に Node 環境で canvas を利用するよう修正したところ下記のようなエラーが出てリリースできなくなってしまいました。

Error loading shared library http://ld-linux-x86-64.so.2: No such file or directory (needed by /github/workspace/functions/node_modules/canvas/build/Release/libpixman-1.so.0)

原因としては GitHub Action for Firebase の環境で canvas のインストールに必要なライブラリたちがインストールされてなかったためです(たぶん)

なので、GitHub Action for Firebase を利用をやめ、直接リリースできるよう修正しました💪

Fireabase

Firebase 気に入っているので、できるだけ完結するようにしています。

Hosting

静的リソースはキャッシュを強くするために Cache-Control を設定しています。

rewrites でルーティングを行っていて、glob パターン マッチングでゴリッと書いています。

{
  "functions": {
    "predeploy": [
      "npm --prefix \"$RESOURCE_DIR\" run lint",
      "npm --prefix \"$RESOURCE_DIR\" run build"
    ]
  },
  "hosting": {
    "trailingSlash": false,
    "public": "public",
    "headers": [
      {
        "source" : "**/*.@(jpg|jpeg|gif|png|svg|js)",
        "headers" : [
          {
            "key" : "Cache-Control",
            "value" : "max-age=31536000,immutable"
          }
        ]
      }
    ],
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "predeploy": [
      "npm run build"
    ],
    "rewrites": [
      {
        "source": "/en",
        "destination": "/en/index.html"
      },
      {
        "source": "/combos",
        "destination": "/combo/index.html"
      },
      {
        "source": "/combos/my",
        "destination": "/combo/my.html"
      },
      {
        "source": "/combos/my/*",
        "destination": "/combo/myShow.html"
      },
      {
        "source": "/combos/create",
        "destination": "/combo/create.html"
      },
      {
        "source": "/combos/*",
        "function": "app"
      },
      {
        "source": "/combos/*/*",
        "function": "app"
      },
    ]
  },
  "emulators": {
    "functions": {
      "port": 5001
    },
    "hosting": {
      "port": 5000
    }
  }
}

firebase.google.com

Functions

SSRAPI などで利用しています。

無料プランでは外部 API は叩け無いので(ただし YouTube など同じ Google のサービスであれば叩ける)ので、Twitter を利用する APIVercel を利用しています。

Database

募集やコンボの投稿は Database に保存しています。

単純な投稿と検索しかないので今の所、特に困ってはないです🤔

Storage

OGP の自動生成で Storage に保存しています。

こちらも壁にぶつかるほど現状は使い込んでないです🙄

Vercel

Functions の無料プランでは Google 以外の外部 API を叩け無いので Vercel を利用しています。

vercel.com

全然理解が浅いのですが Functions のようなサービスなので、 Express を利用して Twitter API を実行する API を実装しています。

Jest

まだ Web Components のテストは書いていないのですが、独自クラスなどは書いています。

street-fighter-v-data/Lp.test.ts at master · tiwuofficial/street-fighter-v-data · GitHub

eslint

特別な何かはしていない(と思います)  

{
  "extends": [
    "plugin:@typescript-eslint/recommended",
    "plugin:prettier/recommended",
    "prettier/@typescript-eslint"
  ],
  "env": {
    "browser": true,
    "es6": true,
    "node": true
  },
  "parserOptions": {
    "sourceType": "module"
  }
}

prettier

幅の調整だけやっています🤔

{
  "printWidth": 200,
}

今後

まず、SSR しているところは eleventy を利用して SSG しようと思います。

www.11ty.dev

あとは、Web Components だけの repository を作って Storybook などで管理していければと思ってます。

まとめ

Web ComponentsPWAAMP 辺りに力を入れているので話のネタとしては盛り沢山な気がします👍

質問や間違っている点など何かあれば連絡ください🎉

https://twitter.com/tiwu_dev