表單元件 (Input、Select、Textarea) 常需添加標籤、驗證、錯誤顯示、CSRF 保護等功能。若用繼承會導致類別爆炸 (LabelledInput、ValidatedInput、LabelledValidatedInput...)。裝飾器模式優雅解決此問題。
BaseWidget ├── TextInput ├── Select ├── Textarea │ 繼承添加 Label: ├── LabelledTextInput ├── LabelledSelect ├── LabelledTextarea │ 繼承添加 Validation: ├── ValidatedTextInput ├── ValidatedSelect ├── ValidatedTextarea │ 繼承添加 Label + Validation: ├── LabelledValidatedTextInput ├── LabelledValidatedSelect ├── LabelledValidatedTextarea ... 組合數 = 元件類型 × 功能組合 = 爆炸性增長
<?php
// 1. 統一介面
interface Widget {
public function render(): string;
public function getName(): string;
public function getValue(): mixed;
public function setValue(mixed $value): void;
public function isValid(): bool;
public function getErrors(): array;
}
// 2. 基礎元件
abstract class BaseWidget implements Widget {
protected string $name;
protected mixed $value;
protected array $errors = [];
protected array $attributes = [];
public function __construct(string $name, array $attributes = []) {
$this->name = $name;
$this->attributes = $attributes;
}
public function getName(): string { return $this->name; }
public function getValue(): mixed { return $this->value; }
public function setValue(mixed $value): void { $this->value = $value; }
public function isValid(): bool { return empty($this->errors); }
public function getErrors(): array { return $this->errors; }
public function addError(string $error): void { $this->errors[] = $error; }
public function setAttribute(string $key, string $value): void { $this->attributes[$key] = $value; }
protected function buildAttributes(): string {
return implode(' ', array_map(fn($k,$v) => "$k=\"$v\"", $this->attributes));
}
}
class TextInput extends BaseWidget {
public function render(): string {
return "name}\" value=\"" . htmlspecialchars((string)$this->value) . "\" " . $this->buildAttributes() . ">";
}
}
class Select extends BaseWidget {
private array $options = [];
public function setOptions(array $options): void { $this->options = $options; }
public function render(): string {
$html = "";
}
}
// 3. 裝飾者基底
abstract class WidgetDecorator implements Widget {
protected Widget $widget;
public function __construct(Widget $widget) {
$this->widget = $widget;
}
// 預設轉發
public function getName(): string { return $this->widget->getName(); }
public function getValue(): mixed { return $this->widget->getValue(); }
public function setValue(mixed $value): void { $this->widget->setValue($value); }
public function isValid(): bool { return $this->widget->isValid(); }
public function getErrors(): array { return $this->widget->getErrors(); }
abstract public function render(): string;
}
// 4. 具體裝飾者:標籤
class LabelledWidget extends WidgetDecorator {
private string $label;
public function __construct(Widget $widget, string $label) {
parent::__construct($widget);
$this->label = $label;
}
public function render(): string {
return " " . $this->widget->render() . "
";
}
}
// 5. 具體裝飾者:驗證
class ValidatedWidget extends WidgetDecorator {
private array $validators = []; // callable
public function __construct(Widget $widget, array $validators = []) {
parent::__construct($widget);
$this->validators = $validators;
}
public function addValidator(callable $validator): void {
$this->validators[] = $validator;
}
public function isValid(): bool {
$this->widget->setValue($this->widget->getValue()); // 觸發驗證
foreach ($this->validators as $validator) {
$error = $validator($this->widget->getValue());
if ($error) $this->widget->addError($error);
}
return parent::isValid();
}
public function render(): string {
$html = $this->widget->render();
if (!$this->widget->isValid()) {
$html .= "" . implode('
', $this->widget->getErrors()) . "";
}
return $html;
}
}
// 6. 具體裝飾者:CSRF 保護 (針對表單)
class CsrfProtectedWidget extends WidgetDecorator {
public function render(): string {
$token = $_SESSION['csrf_token'] ??= bin2hex(random_bytes(32));
return $this->widget->render() . "";
}
}
?>
<?php
// 建立基礎元件
$emailInput = new TextInput('email', ['placeholder' => 'your@email.com']);
$countrySelect = new Select('country');
$countrySelect->setOptions(['TW' => '台灣', 'US' => '美國', 'JP' => '日本']);
// 動態添加功能:標籤 + 驗證 + CSRF
$emailField = new CsrfProtectedWidget(
new ValidatedWidget(
new LabelledWidget($emailInput, '電子郵件:'),
[
fn($v) => empty($v) ? '必填' : null,
fn($v) => filter_var($v, FILTER_VALIDATE_EMAIL) ? null : '格式錯誤',
]
)
);
$countryField = new LabelledWidget(
new ValidatedWidget($countrySelect, [
fn($v) => empty($v) ? '請選擇國家' : null,
]),
'國家:'
);
// 表單渲染
echo "";
// 伺服器端驗證
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$emailField->isValid(); // 觸發驗證
$countryField->isValid();
if ($emailField->isValid() && $countryField->isValid()) {
echo "驗證通過!Email: {$emailField->getValue()}, Country: {$countryField->getValue()}";
} else {
echo "表單有誤,請檢查。";
// 重新渲染會顯示錯誤訊息
}
}
?>
| 方案 | 靈活性 | 類別數量 | 執行時組合 | 型別安全 |
|---|---|---|---|---|
| 繼承 | 低 | 爆炸 | 否 | 高 |
| Trait | 中 | 固定 | 否 (編譯時) | 中 |
| 裝飾器 | 高 | 線性增長 | 是 | 高 (介面) |
<?php
// 只有管理員顯示的欄位
$adminField = new ConditionalWidget(
new LabelledWidget(new TextInput('api_key'), 'API Key:'),
fn() => $_SESSION['user']->isAdmin ?? false
);
// 條件裝飾者
class ConditionalWidget extends WidgetDecorator {
private callable $condition;
public function __construct(Widget $widget, callable $condition) {
parent::__construct($widget);
$this->condition = $condition;
}
public function render(): string {
return ($this->condition)() ? $this->widget->render() : '';
}
public function isValid(): bool {
return ($this->condition)() ? $this->widget->isValid() : true;
}
}
?>