PAYATHON 2026

如何通过 Select 实时更新价格且不刷新页面

数据小陈

结论

可以。PHP 在页面首次加载时输出各个计费周期的价格和购买地址。JavaScript 监听每个下拉框的 change 事件,只更新该下拉框所属的产品区域,不需要提交表单或调用 location.reload()。

如果多个下拉框中只有第一个生效,通常是因为它们使用了相同的 id。getElementById() 只会取得第一个匹配元素。应将重复的 id 改为 class,再用 closest() 定位当前产品。

PHP 数组应这样访问:

echo $price[$billing];

不能写成:

echo $price['$billing'];

后一种写法查找的是名为 $billing 的字面量键,而不是变量 $billing 对应的值。

推荐实现

假设每个产品使用以下数据结构:

<?php

$currency = 'USD';

$cycles = [
    'monthly'       => 'Monthly',
    'quarterly'     => 'Quarterly',
    'semi-annually' => 'Semi-Annually',
    'annually'      => 'Annually',
    'biennially'    => 'Biennially',
    'triennially'   => 'Triennially',
];

$products = [
    [
        'id'   => 101,
        'name' => 'Product A',
        'prices' => [
            'monthly'       => 10.00,
            'quarterly'     => 28.00,
            'semi-annually' => 54.00,
            'annually'      => 100.00,
            'biennially'    => 190.00,
            'triennially'   => 270.00,
        ],
    ],
    [
        'id'   => 102,
        'name' => 'Product B',
        'prices' => [
            'monthly'       => 20.00,
            'quarterly'     => 57.00,
            'semi-annually' => 110.00,
            'annually'      => 205.00,
        ],
    ],
];
?>

渲染产品时,将价格表和购买链接表写入当前产品容器的 data-* 属性:

<?php foreach ($products as $product): ?>
    <?php
    // 只保留当前产品实际支持的计费周期
    $prices = array_intersect_key($product['prices'], $cycles);

    if ($prices === []) {
        continue;
    }

    $defaultBilling = isset($prices['monthly'])
        ? 'monthly'
        : array_key_first($prices);

    $purchaseLinks = [];

    foreach (array_keys($prices) as $billing) {
        $purchaseLinks[$billing] = '/purchase.php?' . http_build_query([
            'product_id' => $product['id'],
            'billing'    => $billing,
            'currency'   => $currency,
        ]);
    }

    $pricesJson = htmlspecialchars(
        json_encode($prices, JSON_THROW_ON_ERROR),
        ENT_QUOTES,
        'UTF-8'
    );

    $linksJson = htmlspecialchars(
        json_encode($purchaseLinks, JSON_THROW_ON_ERROR),
        ENT_QUOTES,
        'UTF-8'
    );
    ?>

    <article
        class="product-card"
        data-prices="<?= $pricesJson ?>"
        data-links="<?= $linksJson ?>"
        data-currency="<?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?>"
    >
        <h2><?= htmlspecialchars($product['name'], ENT_QUOTES, 'UTF-8') ?></h2>

        <label>
            Billing cycle

            <select class="billing-select">
                <?php foreach ($cycles as $value => $label): ?>
                    <?php if (array_key_exists($value, $prices)): ?>
                        <option
                            value="<?= htmlspecialchars($value, ENT_QUOTES, 'UTF-8') ?>"
                            <?= $value === $defaultBilling ? 'selected' : '' ?>
                        >
                            <?= htmlspecialchars($label, ENT_QUOTES, 'UTF-8') ?>
                        </option>
                    <?php endif; ?>
                <?php endforeach; ?>
            </select>
        </label>

        <p>
            Price:
            <strong class="product-price">
                <?= htmlspecialchars(
                    $currency . ' ' . number_format((float) $prices[$defaultBilling], 2),
                    ENT_QUOTES,
                    'UTF-8'
                ) ?>
            </strong>
        </p>

        <a
            class="purchase-link"
            href="<?= htmlspecialchars(
                $purchaseLinks[$defaultBilling],
                ENT_QUOTES,
                'UTF-8'
            ) ?>"
        >
            Click here to purchase
        </a>
    </article>
<?php endforeach; ?>

再用 JavaScript 统一监听所有 .billing-select:

document.addEventListener('change', (event) => {
    if (!event.target.matches('.billing-select')) {
        return;
    }

    const select = event.target;
    const card = select.closest('.product-card');

    if (!card) {
        return;
    }

    const billing = select.value;

    let prices;
    let links;

    try {
        prices = JSON.parse(card.dataset.prices);
        links = JSON.parse(card.dataset.links);
    } catch (error) {
        console.error('Invalid product data:', error);
        return;
    }

    if (!Object.prototype.hasOwnProperty.call(prices, billing)) {
        console.error(`Unsupported billing cycle: ${billing}`);
        return;
    }

    const numericPrice = Number(prices[billing]);

    if (!Number.isFinite(numericPrice)) {
        console.error(`Invalid price for billing cycle: ${billing}`);
        return;
    }

    const formatter = new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency: card.dataset.currency
    });

    const priceElement = card.querySelector('.product-price');
    const purchaseLink = card.querySelector('.purchase-link');

    priceElement.textContent = formatter.format(numericPrice);

    if (links[billing]) {
        purchaseLink.href = links[billing];
    }
});

为什么多个下拉框可以独立工作

每个产品都包含在单独的 .product-card 中。用户更改选项后,代码先执行:

select.closest('.product-card')

找到当前产品,然后只查询这个产品内部的价格和购买链接:

card.querySelector('.product-price');
card.querySelector('.purchase-link');

因此,更改 Product B 的计费周期不会影响 Product A。页面中也没有重复的 id="form_billing"。

事件监听器绑定在 document 上。以后即使动态添加产品卡片,也不必为新卡片重新绑定监听器。

不需要 Session 和表单提交

在这套实现中,计费周期只是浏览器端的显示状态,不必在用户每次切换时写入:

$_SESSION['billing']

也不必执行:

document.getElementById('form_billing').submit();

如果需要在刷新页面后保留用户的选择,再考虑使用 Session、Cookie 或 localStorage。保留选择和实时更新价格是两个独立需求。

安全注意事项

用户可以修改前端显示的价格和购买链接,因此服务器不能将这些数据作为最终结算依据。purchase.php 必须重新验证以下内容:

  • product_id 是否存在;
  • billing 是否属于允许的计费周期;
  • 当前产品是否支持所选周期;
  • 实际价格是否与服务器端数据库或配置一致;
  • currency 是否受支持。

例如:

<?php

$allowedBillingCycles = [
    'monthly',
    'quarterly',
    'semi-annually',
    'annually',
    'biennially',
    'triennially',
];

$billing = $_GET['billing'] ?? '';

if (!in_array($billing, $allowedBillingCycles, true)) {
    http_response_code(400);
    exit('Invalid billing cycle');
}

// 继续根据 product_id 和 billing 从服务器端重新查询真实价格

如果价格经常变化,不适合直接嵌入页面,可以在 change 事件中通过 fetch() 请求后端接口。即使改用接口,仍应使用 .product-card、closest() 和 class 定位不同产品,不能让多个元素共用同一个 id。

备注:内容仅供参考。