Load the catalog from a file商品一覧をファイルから読む

The products move out of the page into a JSON file, and a <wcs-fetch> tag loads them. You add a tag and a few lines of state — no fetch(), no await, no loading flag to manage by hand.

商品をページの外のJSONファイルに移し、<wcs-fetch>タグで読み込みます。増えるのはタグ1つとstateの数行です。fetch()もawaitも、手で管理する読み込み中フラグもいりません。

  1. 8
  2. 9
  3. 10
  4. 11
  5. 12

The resultできあがり

It looks like the shop of step 6 — that is the point. The products now come from a file on the server. While the request is on its way, the page says “Loading products…”; on a fast connection you will hardly see it.

ステップ6のショップと同じに見えます。それが狙いです。商品はサーバー上のファイルから届くようになりました。リクエストが届くまでの間、ページには「Loading products…」と出ます。速い回線ではほとんど見えないでしょう。

Live — this step's pageライブ — このステップのページOpen in a new tab ↗新しいタブで開く ↗

The catalog file商品一覧のファイル

The seven products of Part 1 are in https://wcstack.github.io/tutorial/data/products.json, as JSON:

Part 1の7つの商品は、https://wcstack.github.io/tutorial/data/products.jsonにJSONで置いてあります。

products.json
[
  { "id": 1, "name": "House Blend", "category": "coffee", "description": "Chocolate, caramel, a hint of citrus.", "price": 14, "stock": 12, "icon": "☕" },
  …
]

Your page reads it by its full URL, so it works even when you open index.html by double-clicking it: the site lets any page read its files. To use a file of your own, put products.json next to index.html, write url="products.json", and open the folder through a local server (npx serve, for example) — a page opened as a file may not read other files.

ページはこのファイルを完全なURLで読むので、index.htmlをダブルクリックで開いても動きます(このサイトは、どのページにもファイルの読み取りを許しています)。自分のファイルを使うなら、products.jsonをindex.htmlの隣に置いてurl="products.json"と書き、フォルダをローカルサーバー(たとえばnpx serve)で開きます。ファイルとして開いたページは、ほかのファイルを読めないことがあるからです。

Change the codeコードを変える

Start from your page of step 6. In <head>, load the fetch package after the state package:

ステップ6のページから始めます。<head>で、stateのパッケージの後にfetchのパッケージを読み込みます。

index.html — <head>new line1行追加
<script type="module" src="https://esm.run/@wcstack/state@4/auto"></script>
<script type="module" src="https://esm.run/@wcstack/fetch@4/auto"></script>

In the state, the products array goes away. Put a type comment above export default, and replace the array with catalog, loading and two getters:

stateからはproductsの配列がなくなります。export defaultの上に型のコメントを置き、配列をcatalog・loadingとgetter 2つに置き換えます。

index.html — <wcs-state>changed変更
/** @typedef {{ id: number, name: string, category: string, description: string, price: number, stock: number, icon: string }} Product */

export default {
  /** @type {Product[] | null} */
  catalog: null,
  loading: false,
  cart: [],
  query: "",
  category: "all",

  get products() {
    return this.catalog ?? [];
  },
  get catalogReady() {
    return this.catalog !== null;
  },

In <body>, add the <wcs-fetch> tag after </wcs-state>, a loading line after the header, and hide the shop until the catalog is there:

<body>では、</wcs-state>の後に<wcs-fetch>タグを、ヘッダーの後に読み込み中の行を加え、商品一覧が届くまでショップを隠します。

index.html — <body>changed変更
<wcs-fetch url="https://wcstack.github.io/tutorial/data/products.json"
  data-wcs="value: catalog; loading: loading"></wcs-fetch>

<header class="shop-header">
  <!-- … -->
</header>

<p class="status" data-wcs="hidden: loading|not">Loading products…</p>

<main class="shop" data-wcs="hidden: catalogReady|not">

Nothing else changes: not the catalog template, not products.*.matches, not addToCart.

ほかは何も変わりません。商品一覧のテンプレートも、products.*.matchesも、addToCartもそのままです。

How it worksしくみ

A tag with propertiesプロパティを持つタグ

<wcs-fetch> is an element like <input>: you configure it with attributes and bind what changes. Its url attribute says what to load, and it loads as soon as it is on the page. Two of its properties are outputs: value, the parsed JSON once the response arrives, and loading, true while the request is in flight. value: catalog carries the first into state the way value: query carried the search box in step 6; the element announces each change with an event, and data-wcs listens. The tag shows nothing itself — it is plumbing, written in HTML.

<wcs-fetch>は<input>と同じような要素です。属性で設定し、変わるものをバインドします。url属性が読むものを決め、ページに置かれるとすぐに読み込みます。プロパティのうち2つは出力です。valueは応答が届いたときのパース済みのJSON、loadingはリクエストの間trueになります。value: catalogは、ステップ6でvalue: queryが検索欄を運んだのと同じように、前者をstateへ運びます。要素は変化のたびにイベントで知らせ、data-wcsがそれを聞いています。タグ自体は何も表示しません。HTMLで書いた配管です。

A getter in front of the dataデータの前に置いたgetter

products used to be data; now it is a getter that returns catalog, or an empty list until there is one. The rest of the page didn't notice: for: products, the row getter products.*.matches and this.products[index] in addToCart read a path, and a path doesn't care whether its value is stored or computed. The ?? [] matters: catalog is null before the response — and, in the next step, after a failed one — and the getter keeps that null away from everything that expects a list.

productsはデータでしたが、catalogを返す(まだ無ければ空のリストを返す)getterになりました。ページの残りはそれに気づいていません。for: productsも、行getterのproducts.*.matchesも、addToCartのthis.products[index]もパスを読んでいて、パスはその値が保存されたものか計算されたものかを問わないからです。?? []には意味があります。catalogは応答の前(次のステップでは、失敗した後も)nullで、getterがそのnullを、リストを期待するすべての場所から遠ざけます。

Show the shop when there is something to show見せるものがあるときにショップを見せる

Before the response, the catalog is empty, so “No products match.” would show. catalogReady is true once catalog holds a list, and hidden: catalogReady|not keeps the whole shop out of sight until then. The loading line does the opposite with loading. Neither needs code that runs at the right moment: each is a value derived from state, and the page follows.

応答の前は商品一覧が空なので、「No products match.」が出てしまいます。catalogReadyはcatalogがリストを持った時点でtrueになり、hidden: catalogReady|notはそれまでショップ全体を隠します。読み込み中の行はloadingで逆のことをします。どちらにも、ちょうどよいときに実行するコードはいりません。それぞれstateから導いた値で、ページはそれに従います。

Tell the checker the shape検査器に形を教える

In step 7 the checker knew .name because it could read the products in your state. Now they are in a file the checker never sees. The two comments say what will arrive: @typedef names the shape of one product, and @type {Product[] | null} says catalog holds a list of them, or null. These are standard JSDoc — VS Code understands them too — and they change nothing at runtime. They need @wcstack/lint 4.0.2 or later.

ステップ7で検査器が.nameを知っていたのは、stateの中の商品を読めたからです。商品は今、検査器が見ることのないファイルの中にあります。2つのコメントが、何が届くかを伝えます。@typedefが商品1つの形に名前を付け、@type {Product[] | null}がcatalogはそのリストかnullを持つと言います。標準のJSDocで(VS Codeも理解します)、実行時には何も変えません。@wcstack/lint 4.0.2以降が必要です。

Try itやってみよう

Watch the loading line. Open the browser's developer tools, set the network speed to “Slow 4G” on the Network tab, and reload: “Loading products…” stays for a moment, then the shop appears.

読み込み中の行を見てみましょう。ブラウザの開発者ツールを開き、Networkタブで通信速度を「Slow 4G」にして再読み込みすると、「Loading products…」がしばらく表示されてからショップが現れます。

Common mistakesよくある間違い

Leaving out the type型を書かない

The page runs the same without the @type line. The checker, though, now sees catalog: null and nothing else, and every field you read in a product card is unknown to it:

@typeの行がなくても、ページは同じように動きます。ただ検査器にはcatalog: nullしか見えず、商品カードで読むフィールドはどれも知らないものになります。

npx @wcstack/lint index.html
index.html:102:79 warning wcs/binding-path-missing Path ".stock" does not exist in the state definition (expanded: products.*.stock)
index.html:103:37 warning wcs/binding-path-missing Path ".icon" does not exist in the state definition (expanded: products.*.icon)
index.html:104:18 warning wcs/binding-path-missing Path ".name" does not exist in the state definition (expanded: products.*.name)
…
0 error(s), 7 warning(s), 0 info

Seven warnings that are not mistakes hide the one that is. With the type, a typo such as .nmae is the only warning again.

間違いではない7つの警告が、本当の間違い1つを隠してしまいます。型があれば、.nmaeのような打ち間違いがまた唯一の警告になります。

A relative URL in a page opened as a fileファイルとして開いたページで相対URL

With url="products.json" and index.html opened by double-clicking, the page stays on “Loading products…” for an instant and then shows nothing: the browser refuses to let a file read another file, and says so in the console (Chrome: URL scheme "file" is not supported). Keep the full URL, or serve the folder.

url="products.json"と書いてindex.htmlをダブルクリックで開くと、ページは一瞬「Loading products…」になった後、何も表示しません。ブラウザがファイルからほかのファイルを読むことを拒み、そのことをコンソールに出します(ChromeではURL scheme "file" is not supported)。完全なURLのままにするか、フォルダをサーバーで開きます。

The whole fileファイル全体

The complete index.html of this step. npx @wcstack/lint index.html reports 0 error(s), 0 warning(s), 0 info for it.

このステップのindex.html全体です。npx @wcstack/lint index.htmlの結果は0 error(s), 0 warning(s), 0 infoです。

Show index.htmlindex.htmlを表示
index.htmlstep 8ステップ8
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Little Roastery</title>
  <link rel="stylesheet" href="https://wcstack.github.io/tutorial/shop.css">
  <script type="module" src="https://esm.run/@wcstack/state@4/auto"></script>
  <script type="module" src="https://esm.run/@wcstack/fetch@4/auto"></script>
</head>
<body>

<wcs-state>
  <script type="module">
    /** @typedef {{ id: number, name: string, category: string, description: string, price: number, stock: number, icon: string }} Product */

    export default {
      /** @type {Product[] | null} */
      catalog: null,
      loading: false,
      cart: [],
      query: "",
      category: "all",

      get products() {
        return this.catalog ?? [];
      },
      get catalogReady() {
        return this.catalog !== null;
      },
      get "products.*.matches"() {
        const query = this.query.trim().toLowerCase();
        return (this.category === "all" || this["products.*.category"] === this.category) &&
          this["products.*.name"].toLowerCase().includes(query);
      },
      get matchCount() {
        return this.$getAll("products.*.matches", []).filter(Boolean).length;
      },

      get "cart.*.lineTotal"() {
        return this["cart.*.price"] * this["cart.*.quantity"];
      },
      get itemCount() {
        return this.$getAll("cart.*.quantity", []).reduce((sum, n) => sum + n, 0);
      },
      get subtotal() {
        return this.$getAll("cart.*.lineTotal", []).reduce((sum, n) => sum + n, 0);
      },
      get shipping() {
        return this.subtotal >= 50 ? 0 : 5;
      },
      get total() {
        return this.subtotal + this.shipping;
      },

      addToCart(event, index) {
        const product = this.products[index];
        const line = this.cart.findIndex(item => item.id === product.id);
        if (line >= 0) {
          this[`cart.${line}.quantity`] += 1;
        } else {
          this.cart = this.cart.concat({ id: product.id, name: product.name, price: product.price, quantity: 1 });
        }
      },
      increase(event, index) {
        this[`cart.${index}.quantity`] += 1;
      },
      decrease(event, index) {
        this[`cart.${index}.quantity`] -= 1;
      },
      remove(event, index) {
        this.cart = this.cart.toSpliced(index, 1);
      }
    };
  </script>
</wcs-state>

<wcs-fetch url="https://wcstack.github.io/tutorial/data/products.json"
  data-wcs="value: catalog; loading: loading"></wcs-fetch>

<header class="shop-header">
  <h1>Little Roastery</h1>
  <p class="cart-badge">Cart: <span data-wcs="textContent: itemCount"></span></p>
</header>

<p class="status" data-wcs="hidden: loading|not">Loading products…</p>

<main class="shop" data-wcs="hidden: catalogReady|not">
  <section class="catalog">
    <h2>Products</h2>
    <div class="toolbar">
      <input type="search" placeholder="Search products" aria-label="Search products" data-wcs="value: query">
      <fieldset class="categories">
        <legend class="visually-hidden">Category</legend>
        <label><input type="radio" name="category" value="all" data-wcs="radio: category"> All</label>
        <label><input type="radio" name="category" value="coffee" data-wcs="radio: category"> Coffee</label>
        <label><input type="radio" name="category" value="tea" data-wcs="radio: category"> Tea</label>
        <label><input type="radio" name="category" value="tools" data-wcs="radio: category"> Tools</label>
      </fieldset>
    </div>
    <div class="grid">
      <template data-wcs="for: products">
        <article class="card" data-wcs="hidden: .matches|not; class.sold-out: .stock|eq(0)">
          <div class="card-icon">{{ .icon }}</div>
          <h3>{{ .name }}</h3>
          <p>{{ .description }}</p>
          <p class="price">${{ .price|toFixed(2) }}</p>
          <p class="badge" data-wcs="hidden: .stock|ne(0)">Sold out</p>
          <button type="button" data-wcs="onclick: addToCart; disabled: .stock|eq(0)">Add to cart</button>
        </article>
      </template>
    </div>
    <p class="no-results" data-wcs="hidden: matchCount|ne(0)">No products match.</p>
  </section>

  <aside class="cart">
    <h2>Cart</h2>
    <template data-wcs="if: cart.length|eq(0)">
      <p class="empty">Your cart is empty.</p>
    </template>
    <template data-wcs="else:">
      <ul class="cart-lines">
        <template data-wcs="for: cart">
          <li>
            <span class="line-name">{{ .name }}</span>
            <span class="line-total">${{ .lineTotal|toFixed(2) }}</span>
            <span class="stepper">
              <button type="button" aria-label="Decrease" data-wcs="onclick: decrease; disabled: .quantity|le(1)">−</button>
              <span>{{ .quantity }}</span>
              <button type="button" aria-label="Increase" data-wcs="onclick: increase">+</button>
            </span>
            <button type="button" class="remove" data-wcs="onclick: remove">Remove</button>
          </li>
        </template>
      </ul>
      <dl class="totals">
        <dt>Subtotal</dt><dd>${{ subtotal|toFixed(2) }}</dd>
        <dt>Shipping</dt><dd>${{ shipping|toFixed(2) }}</dd>
        <dt class="grand">Total</dt><dd class="grand">${{ total|toFixed(2) }}</dd>
      </dl>
      <p class="hint" data-wcs="hidden: shipping|eq(0)">Free shipping on orders of $50 or more.</p>
    </template>
  </aside>
</main>

</body>
</html>