关于 hyva-alpine-component
面向 Hyvä 主题开发者的技能,指导编写兼容 CSP 的 Alpine.js 组件。内容涵盖组件注册、数据绑定、事件处理及 Hyvä 工具函数的使用。
主要功能
- 01Alpine CSP 组件结构与注册模式
- 02用方法替代内联表达式和变更
- 03以 data 属性传递方法参数
- 04用事件处理器替代 x-model
- 05Hyvä 表单、Cookie 与 DOM 工具
适用场景
- 01为支付页面编写 CSP 兼容组件
- 02实现输入框、复选框和选择框的数据绑定
- 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 限制摘要
| 功能 | 标准 Alpine | Alpine 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() ?>
关键要求:
- 在
alpine:init事件监听器中使用Alpine.data()注册构造函数 - 使用
{once: true}防止重复注册 - 每个
<script>块后都调用$hyvaCsp->registerInlineScript() - 对 JavaScript 字符串中的 PHP 值使用
$escaper->escapeJs() - 对数据属性使用
$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)- 设置 Cookiehyva.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() ?>
参考资料
- Hyvä CSP 文档:https://docs.hyva.io/hyva-themes/writing-code/csp/alpine-csp.html
- Alpine.js 文档:https://alpinejs.dev/
- 示例组件:
vendor/hyva-themes/magento2-default-theme-csp/ - 核心实用工具:
vendor/hyva-themes/magento2-theme-module/src/view/frontend/templates/page/js/hyva.phtml