Add up the cartカートを集計する

Line totals, the item count, shipping and the total are all derived from the cart. You write each one once, as a getter, and wcstack keeps them current. The cart also learns to say when it is empty.

行ごとの小計、品数、送料、合計は、どれもカートから導かれる値です。それぞれをgetterとして1度書けば、wcstackが最新に保ちます。カートが空のときの表示も加えます。

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7

The resultできあがり

The badge now counts items, not lines. Shipping is $5 until the subtotal reaches $50; then it's free and the hint disappears.

バッジは行数ではなく品数を数えるようになります。送料は小計が$50に届くまで$5で、届くと無料になり、案内の行が消えます。

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

Change the codeコードを変える

Add five getters to the state, after cart: []. The handlers stay as they were.

stateのcart: []の後ろにgetterを5つ加えます。ハンドラはそのままです。

index.html — <wcs-state>new追加
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;
},

The badge reads itemCount:

バッジはitemCountを読みます。

index.html — <header>changed変更
<p class="cart-badge">Cart: <span data-wcs="textContent: itemCount"></span></p>

In the cart panel, the list goes into an if: / else: pair, each line shows .lineTotal, and the totals follow the list:

カートの欄では、一覧をif: / else:の組に入れ、各行に.lineTotalを表示し、一覧の後ろに合計を置きます。

index.html — <aside class="cart">replaces the panel欄を置き換え
<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>
          <!-- … -->
        </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>

<!-- … --> stands for the stepper and the Remove button of step 4, unchanged.

<!-- … -->の部分は、ステップ4の増減ボタンとRemoveボタンのまま変わりません。

How it worksしくみ

A path getter adds a property to every lineパスgetterはすべての行にプロパティを足す

get "cart.*.lineTotal"() is a getter whose name is a path with *. It defines lineTotal on every element of cart. Inside it, this["cart.*.price"] and this["cart.*.quantity"] are the price and quantity of the line being computed. The markup reads the result like stored data, {{ .lineTotal }}, and never knows it is computed.

get "cart.*.lineTotal"()は、名前が*を含むパスになっているgetterです。cartのすべての要素にlineTotalを定義します。中のthis["cart.*.price"]とthis["cart.*.quantity"]は、計算している行の価格と数量です。マークアップは結果を保存されたデータと同じように{{ .lineTotal }}で読み、それが計算値だとは知りません。

$getAll collects a column$getAllは列を集める

this.$getAll("cart.*.quantity", []) returns the quantity of every line as an array; the [] means “all lines”. reduce adds them up. subtotal does the same over lineTotal — a getter can read other getters.

this.$getAll("cart.*.quantity", [])は、すべての行の数量を配列で返します。[]は「すべての行」という意味です。それをreduceで足し合わせます。subtotalは同じことをlineTotalに対して行います。getterはほかのgetterを読めます。

Only what changed is recomputed変わったところだけ計算し直す

wcstack records which paths each getter read. Press + on one line, and that line's lineTotal is recomputed, then itemCount, subtotal, shipping and total; the other lines' totals come from the cache. You never list dependencies yourself.

wcstackは、各getterがどのパスを読んだかを記録しています。ある行で+を押すと、その行のlineTotalが計算し直され、続いてitemCount・subtotal・shipping・totalが計算し直されます。ほかの行の小計はキャッシュから使われます。依存関係を自分で書き並べることはありません。

Filters or getters?フィルタかgetterか

A filter takes one value and formats or tests it: toFixed(2), eq(0). Anything that combines values — price × quantity, a sum, a threshold — goes into a getter. Logic then lives in JavaScript, where you can read and test it, and data-wcs stays what it is: wiring between a path and an element.

フィルタは1つの値を受け取って、整形したり判定したりします(toFixed(2)、eq(0))。価格×数量、合計、しきい値のように値を組み合わせるものはgetterに書きます。そうするとロジックは読めてテストもできるJavaScriptの中に置かれ、data-wcsはパスと要素をつなぐ配線のままでいられます。

if: and else:if:とelse:

A <template> with if: renders its content while the value is true; the else: template right after it renders the rest of the time. Here the empty message and the lines-with-totals take turns. Templates nest: the for: cart loop sits inside the else: branch.

if:の付いた<template>は値が真の間だけ中身を描画し、直後のelse:のテンプレートはそれ以外のときに描画されます。ここでは空のメッセージと、一覧と合計が入れ替わります。テンプレートは入れ子にでき、for: cartのループはelse:の枝の中にあります。

Try itやってみよう

Lower the free-shipping line: change this.subtotal >= 50 to this.subtotal >= 30, and the hint text to match. Two House Blend and a Measuring Spoon ($34) now ship free. Only shipping changed; total followed on its own.

送料無料の線を下げてみましょう。this.subtotal >= 50をthis.subtotal >= 30にし、案内の文もそれに合わせます。House Blend 2つとMeasuring Spoon($34)が送料無料になります。変えたのはshippingだけで、totalはひとりでについてきます。

Common mistakesよくある間違い

Expressions in bindingsバインディングの中の式

{{ subtotal + shipping }} is not an expression: a binding takes a path and filters, nothing else, so this is read as a path named subtotal + shipping, which does not exist. The checker reports it; the fix is a getter, like total.

{{ subtotal + shipping }}は式ではありません。バインディングが受け取るのはパスとフィルタだけなので、これはsubtotal + shippingという名前の(存在しない)パスとして読まれます。検査器が指摘します。直し方はtotalのようなgetterを書くことです。

npx @wcstack/lint index.html
warning wcs/binding-path-missing Path "subtotal + shipping" does not exist in the state definition

Writing state in a gettergetterの中でstateに書く

A getter answers a question; it should not change the state. Keep assignments in methods like addToCart, and keep getters to reading and returning a value. Then a getter can run as often — or as rarely — as wcstack needs, without side effects.

getterは問いに答えるもので、stateを変えるべきではありません。代入はaddToCartのようなメソッドに置き、getterは読んで値を返すだけにします。そうしておけば、wcstackが必要なだけ(あるいは必要なときだけ)getterを実行しても、副作用は起きません。

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 5ステップ5
<!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>
</head>
<body>

<wcs-state>
  <script type="module">
    export default {
      products: [
        { id: 1, name: "House Blend", description: "Chocolate, caramel, a hint of citrus.", price: 14, stock: 12, icon: "☕" },
        { id: 2, name: "Ethiopia Yirgacheffe", description: "Bright and floral, with jasmine and lemon.", price: 18, stock: 5, icon: "☕" },
        { id: 3, name: "Colombia Supremo", description: "Nutty and smooth, with a cocoa finish.", price: 16, stock: 8, icon: "☕" },
        { id: 4, name: "Sencha", description: "Fresh, grassy Japanese green tea.", price: 12, stock: 20, icon: "🍵" },
        { id: 5, name: "Matcha", description: "Stone-ground, ceremonial grade.", price: 24, stock: 0, icon: "🍵" },
        { id: 6, name: "Measuring Spoon", description: "One scoop, one cup.", price: 6, stock: 30, icon: "🥄" },
        { id: 7, name: "Milk Pitcher", description: "Stainless steel, 350 ml.", price: 15, stock: 4, icon: "🥛" }
      ],
      cart: [],

      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>

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

<main class="shop">
  <section class="catalog">
    <h2>Products</h2>
    <div class="grid">
      <template data-wcs="for: products">
        <article class="card" data-wcs="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>
  </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>