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で、届くと無料になり、案内の行が消えます。
Change the codeコードを変える
Add five getters to the state, after cart: []. The handlers stay as they were.
stateのcart: []の後ろにgetterを5つ加えます。ハンドラはそのままです。
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を読みます。
<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を表示し、一覧の後ろに合計を置きます。
<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:の枝の中にあります。
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を書くことです。
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を表示
<!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>