mcpField
H
hyva-alpine-component

hyva-alpine-component

github.com/hyva-themes开发851.3k 次安装13 周前更新
代码仓库

关于 hyva-alpine-component

面向 Hyvä 主题开发者的技能,指导编写兼容 CSP 的 Alpine.js 组件。内容涵盖组件注册、数据绑定、事件处理及 Hyvä 工具函数的使用。

主要功能

  1. 01Alpine CSP 组件结构与注册模式
  2. 02用方法替代内联表达式和变更
  3. 03以 data 属性传递方法参数
  4. 04用事件处理器替代 x-model
  5. 05Hyvä 表单、Cookie 与 DOM 工具

适用场景

  1. 01为支付页面编写 CSP 兼容组件
  2. 02实现输入框、复选框和选择框的数据绑定
  3. 03在 Hyvä 主题中处理自定义事件

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/hyva-themes/hyva-ai-tools --skill hyva-alpine-component

安装后在 Claude Code 中输入/hyva-alpine-component确认已加载。

详细介绍

Hyvä Alpine 组件

概述

本技能介绍如何在 Hyvä 主题中编写兼容 CSP 的 Alpine.js 组件。Alpine CSP 是 Alpine.js 的专用构建版本,无需 unsafe-eval CSP 指令即可运行;支付相关页面必须使用该构建版本才能符合 PCI-DSS 4.0 要求(自 2025 年 4 月 1 日起强制执行)。

**核心原则:**兼容 CSP 的代码可在标准构建版本和 Alpine CSP 构建版本中运行。为确保代码适应未来需求,请使用 CSP 模式编写所有 Alpine 代码。

CSP 限制摘要

功能标准 AlpineAlpine CSP
属性读取x-show="open"相同
否定x-show="!open"方法:x-show="isNotOpen"
修改@click="open = false"方法:@click="close"
方法参数@click="setTab('info')"Dataset:@click="setTab" data-tab="info"
x-model可用不支持——使用 :value + @input
范围迭代x-for="i in 10"不支持

组件结构模式

Hyvä 中的每个 Alpine 组件都遵循以下结构:

<div x-data="initComponentName">
    
</div>
<script>
    function initComponentName() {
        return {
            // Properties
            propertyName: initialValue,

            // Lifecycle
            init() {
                // Called when component initializes
            },

            // Methods for state access
            isPropertyTrue() {
                return this.propertyName === true;
            },

            // Methods for mutations
            setPropertyValue() {
                this.propertyName = this.$event.target.value;
            }
        }
    }
    window.addEventListener('alpine:init', () => Alpine.data('initComponentName', initComponentName), {once: true})
</script>
<?php $hyvaCsp->registerInlineScript() ?>

关键要求:

  1. 在 alpine:init 事件监听器中使用 Alpine.data() 注册构造函数
  2. 使用 {once: true} 防止重复注册
  3. 每个 <script> 块后都调用 $hyvaCsp->registerInlineScript()
  4. 对 JavaScript 字符串中的 PHP 值使用 $escaper->escapeJs()
  5. 对数据属性使用 $escaper->escapeHtmlAttr()(而非 escapeJs)

构造函数

基本注册
function initMyComponent() {
    return {
        open: false
    }
}
window.addEventListener('alpine:init', () => Alpine.data('initMyComponent', initMyComponent), {once: true})

**为什么使用具名全局函数?**构造函数以具名函数的形式声明在全局作用域中(而不是内联在 Alpine.data() 回调中),这样就可以在其他模板中对其进行代理和扩展。这是 Hyvä Themes 的一项可扩展性功能——其他模块或子主题可以在这些函数注册到 Alpine 之前对其进行包装或覆盖。

组合多个对象

组合多个对象时(例如使用 hyva.modal),请在构造函数中使用展开语法:

function initMyModal() {
    return {
        ...hyva.modal.call(this),
        ...hyva.formValidation(this.$el),
        customProperty: '',
        customMethod() {
            // Custom logic
        }
    };
}

使用 .call(this) 将 Alpine 上下文传递给组合函数。

属性访问模式

使用点号表示法读取值属性
return {
    item: {
        is_visible: true,
        title: 'Product'
    }
}
<span x-show="item.is_visible" x-text="item.title"></span>
转换值(否定、条件)

CSP 不允许内联转换。请改用方法:

错误(不兼容 CSP):

<span x-show="!item.deleted"></span>
<span x-text="item.title || item.value"></span>

正确:

<span x-show="isItemNotDeleted"></span>
<span x-text="itemLabel"></span>
return {
    item: { deleted: false, title: '', value: '' },

    isItemNotDeleted() {
        return !this.item.deleted;
    },
    itemLabel() {
        return this.item.title || this.item.value;
    }
}
否定方法简写

对于简单的布尔否定,请使用括号表示法:

return {
    deleted: false,
    ['!deleted']() {
        return !this.deleted;
    }
}
<template x-if="!deleted">
    <div>The item is present</div>
</template>

属性修改模式

将修改操作提取到方法中

错误(不兼容 CSP):

<button @click="open = !open">Toggle</button>

正确:

<button @click="toggle">Toggle</button>
return {
    open: false,
    toggle() {
        this.open = !this.open;
    }
}
通过 Dataset 传递参数

错误(不兼容 CSP):

<button @click="selectItem(123)">Select</button>

正确:

<button @click="selectItem" data-item-id="<?= $escaper->escapeHtmlAttr($itemId) ?>">Select</button>
return {
    selected: null,
    selectItem() {
        this.selected = this.$el.dataset.itemId;
    }
}

**重要:**数据属性请使用 escapeHtmlAttr,而非 escapeJs。

在方法中访问事件和循环变量

方法可以访问 Alpine 的特殊属性:

return {
    onInput() {
        // Access event
        const value = this.$event.target.value;
        this.inputValue = value;
    },
    getItemUrl() {
        // Access x-for loop variable
        return `${BASE_URL}/product/id/${this.item.id}`;
    }
}

x-model 的替代方案

Alpine CSP 中不提供 x-model。请改用双向绑定模式。

文本输入框
<input type="text"
       :value="username"
       @input="setUsername">
return {
    username: '',
    setUsername() {
        this.username = this.$event.target.value;
    }
}
数字输入框

数字值请使用 hyva.safeParseNumber():

return {
    quantity: 1,
    setQuantity() {
        this.quantity = hyva.safeParseNumber(this.$event.target.value);
    }
}
文本区域
<textarea @input="setComment" x-text="comment"></textarea>
return {
    comment: '',
    setComment() {
        this.comment = this.$event.target.value;
    }
}
复选框
<input type="checkbox"
       :checked="isSubscribed"
       @change="toggleSubscribed">
return {
    isSubscribed: false,
    toggleSubscribed() {
        this.isSubscribed = this.$event.target.checked;
    }
}
复选框数组
<template x-for="option in options" :key="option.id">
    <input type="checkbox"
           :value="option.id"
           :checked="isOptionSelected"
           @change="toggleOption"
           :data-option-id="option.id">
</template>
return {
    selectedOptions: [],
    isOptionSelected() {
        return this.selectedOptions.includes(this.option.id);
    },
    toggleOption() {
        const optionId = this.$el.dataset.optionId;
        const index = this.selectedOptions.indexOf(optionId);
        if (index === -1) {
            this.selectedOptions.push(optionId);
        } else {
            this.selectedOptions.splice(index, 1);
        }
    }
}
选择元素
<select @change="setCountry">
    <template x-for="country in countries" :key="country.code">
        <option :value="country.code"
                :selected="isCountrySelected"
                x-text="country.name"></option>
    </template>
</select>
return {
    selectedCountry: '',
    isCountrySelected() {
        return this.selectedCountry === this.country.code;
    },
    setCountry() {
        this.selectedCountry = this.$event.target.value;
    }
}

x-for 模式

基本迭代
<template x-for="(product, index) in products" :key="index">
    <div x-text="product.name"></div>
</template>
在循环中使用方法

循环变量(product、index)可在方法中访问:

<template x-for="(product, index) in products" :key="index">
    <span :class="getItemClasses" @click="goToProduct" x-text="product.name"></span>
</template>
return {
    products: [],
    getItemClasses() {
        return {
            'font-bold': this.index === 0,
            'text-gray-500': this.product.disabled
        };
    },
    goToProduct() {
        window.location.href = `${BASE_URL}/product/${this.product.url_key}`;
    }
}
将函数用作值提供器

值提供器可以是一个方法(调用时不带括号):

<template x-for="(item, index) in getFilteredItems" :key="index">
    <div x-text="item.name"></div>
</template>
return {
    items: [],
    filter: '',
    getFilteredItems() {
        return this.items.filter(item => item.name.includes(this.filter));
    }
}

**注意:**Alpine CSP 不支持范围迭代(x-for="i in 10")。

Hyva 工具函数

全局 hyva 对象提供以下实用函数:

表单与安全性
  • hyva.getFormKey() - 获取或生成 POST 请求的表单密钥
  • hyva.getUenc() - 对当前 URL 进行 Base64 编码,以用于重定向
  • hyva.postForm({action, data, skipUenc}) - 以编程方式提交 POST 表单
Cookie
  • hyva.getCookie(name) - 获取 Cookie 值(遵循同意设置)
  • hyva.setCookie(name, value, days, skipSetDomain) - 设置 Cookie
  • hyva.setSessionCookie(name, value, skipSetDomain) - 设置会话 Cookie
格式化
  • hyva.formatPrice(value, showSign, options) - 格式化货币
  • hyva.str(template, ...args) - 使用 %1、%2 占位符插入字符串
  • hyva.strf(template, ...args) - 从 0 开始的字符串插入(%0、%1)
数字
  • hyva.safeParseNumber(rawValue) - 安全地解析数字(用于替代 x-model.number)
DOM
  • hyva.replaceDomElement(selector, content) - 使用 HTML 内容替换 DOM 元素
  • hyva.trapFocus(rootElement) - 将焦点限制在元素内(用于模态框)
  • hyva.releaseFocus(rootElement) - 解除焦点限制
存储
  • hyva.getBrowserStorage() - 安全地获取 localStorage/sessionStorage
布尔对象辅助函数

对于切换组件,请使用 hyva.createBooleanObject:

function initToggle() {
    return {
        ...hyva.createBooleanObject('open', false),
        // Additional methods
    };
}

此方法会生成:open()、notOpen()、toggleOpen()、setOpenTrue()、setOpenFalse()

Alpine 初始化
hyva.alpineInitialized(fn)  // Run callback after Alpine initializes

事件模式

监听自定义事件
<div x-data="initMyComponent"
     @private-content-loaded.window="onPrivateContentLoaded"
     @update-gallery.window="onGalleryUpdate">
return {
    onPrivateContentLoaded() {
        const data = this.$event.detail.data;
        // Handle customer data
    },
    onGalleryUpdate() {
        const images = this.$event.detail;
        this.images = images;
    }
}
分发事件
return {
    updateQuantity() {
        this.qty = newValue;
        this.$dispatch('update-qty-' + this.productId, this.qty);
    }
}
常见的 Hyvä 事件
  • private-content-loaded - 客户区段数据已加载
  • reload-customer-section-data - 请求刷新客户数据
  • update-gallery - 产品图库图片已更改
  • reset-gallery - 将图库重置为初始状态

事件监听器对象模式

对于多个 window/document 事件监听器,请使用 x-bind 模式:

<div x-data="initGallery" x-bind="eventListeners">
return {
    eventListeners: {
        ['@keydown.window.escape']() {
            if (!this.fullscreen) return;
            this.closeFullScreen();
        },
        ['@update-gallery.window'](event) {
            this.receiveImages(event.detail);
        },
        ['@keyup.arrow-right.window']() {
            if (!this.fullscreen) return;
            this.nextItem();
        }
    }
}

动态类模式

从方法返回类对象:

<div :class="containerClasses">
return {
    fullscreen: false,
    containerClasses() {
        return {
            'w-full h-full fixed top-0 left-0 bg-white z-50': this.fullscreen,
            'relative': !this.fullscreen
        };
    }
}

将 PHP 数据传递给组件

通过数据属性
<div x-data="initProductList"
     data-products="<?= $escaper->escapeHtmlAttr(json_encode($products)) ?>"
     data-config="<?= $escaper->escapeHtmlAttr(json_encode($config)) ?>">
return {
    products: [],
    config: {},
    init() {
        this.products = JSON.parse(this.$root.dataset.products || '[]');
        this.config = JSON.parse(this.$root.dataset.config || '{}');
    }
}
通过内联 JavaScript(含转义)
function initComponent() {
    return {
        productId: '<?= (int) $product->getId() ?>',
        productName: '<?= $escaper->escapeJs($product->getName()) ?>',
        config: <?= /* @noEscape */ json_encode($config) ?>
    }
}

完整示例:数量选择器

<?php
declare(strict_types=1);

use Hyva\Theme\ViewModel\HyvaCsp;
use Magento\Framework\Escaper;

/** @var Escaper $escaper */
/** @var HyvaCsp $hyvaCsp */

$productId = (int) $product->getId();
$minQty = 1;
$maxQty = 100;
$defaultQty = 1;
?>
<div x-data="initQtySelector">
    <label for="qty-<?= $productId ?>" class="sr-only">
        <?= $escaper->escapeHtml(__('Quantity')) ?>
    </label>
    <div class="flex items-center">
        <button type="button"
                class="btn"
                @click="decrement"
                :disabled="isMinQty"
                :class="decrementClasses">
            -
        </button>
        <input type="number"
               id="qty-<?= $productId ?>"
               name="qty"
               :value="qty"
               @input="onInput"
               min="<?= $minQty ?>"
               max="<?= $maxQty ?>"
               class="form-input w-16 text-center">
        <button type="button"
                class="btn"
                @click="increment"
                :disabled="isMaxQty"
                :class="incrementClasses">
            +
        </button>
    </div>
</div>
<script>
    function initQtySelector() {
        return {
            qty: <?= (int) $defaultQty ?>,
            minQty: <?= (int) $minQty ?>,
            maxQty: <?= (int) $maxQty ?>,
            productId: '<?= $productId ?>',

            onInput() {
                let value = hyva.safeParseNumber(this.$event.target.value);
                if (value < this.minQty) value = this.minQty;
                if (value > this.maxQty) value = this.maxQty;
                this.qty = value;
                this.$dispatch('update-qty-' + this.productId, this.qty);
            },

            increment() {
                if (this.qty < this.maxQty) {
                    this.qty++;
                    this.$dispatch('update-qty-' + this.productId, this.qty);
                }
            },

            decrement() {
                if (this.qty > this.minQty) {
                    this.qty--;
                    this.$dispatch('update-qty-' + this.productId, this.qty);
                }
            },

            isMinQty() {
                return this.qty <= this.minQty;
            },

            isMaxQty() {
                return this.qty >= this.maxQty;
            },

            decrementClasses() {
                return { 'opacity-50 cursor-not-allowed': this.isMinQty() };
            },

            incrementClasses() {
                return { 'opacity-50 cursor-not-allowed': this.isMaxQty() };
            }
        }
    }
    window.addEventListener('alpine:init', () => Alpine.data('initQtySelector', initQtySelector), {once: true})
</script>
<?php $hyvaCsp->registerInlineScript() ?>

参考资料

评论

支持 Markdown