模板化使网络运转起来。将数据和结构合成为内容。这是我们作为开发者最酷的超能力--抓取一些数据,然后让它为我们工作,以我们需要的任何表现形式。一个对象的数组可以变成一个表格,一个卡片列表,一个图表,或者任何我们认为对用户最有用的东西。无论数据是我们自己的Markdown文件中的博客文章,还是最新的全球汇率,标记和产生的用户体验都取决于我们这些前端开发者。
PHP是一种神奇的模板语言,提供了许多将数据与标记合并的方法。让我们在这篇文章中讨论一个使用数据建立HTML表单的例子。
想马上动手吗?那就跳到执行部分吧。
在PHP中,我们可以将变量内联到使用双引号的字符串字面,所以如果我们有一个变量$name = 'world' ,我们可以写成echo "Hello, {$name}" ,它就会打印出预期的Hello, world 。对于更复杂的模板,我们总是可以将字符串连接起来,比如。echo "Hello, " . $name . ".".
对于老派的人来说,还有printf("Hello, %s", $name) 。对于多行字符串,你可以使用Heredoc(开头像<<<MYTEXT )。最后,但肯定不是最不重要的,我们可以在HTML中撒上PHP变量,如<p>Hello, <?= $name ?></p> 。
所有这些选择都很好,但是当需要大量的内联逻辑时,事情会变得很混乱。如果我们需要建立复合的HTML字符串,比如说一个表单或导航,其复杂性可能是无限的,因为HTML元素可以相互嵌套。
我们要避免的是
在我们继续做我们想做的事情之前,值得花点时间考虑一下我们不想做的事情。考虑一下下面这段从WordPress Core的经文中节选的文字,class-walker-nav-menu.php ,第170-270节。
<?php // class-walker-nav-menu.php
// ...
$output .= $indent . '<li' . $id . $class_names . '>';
// ...
$item_output = $args->before;
$item_output .= '<a' . $attributes . '>';
$item_output .= $args->link_before . $title . $args->link_after;
$item_output .= '</a>';
$item_output .= $args->after;
// ...
$output .= apply_filters( 'walker_nav_menu_start_el', $item_output, $item, $depth, $args );
// ...
$output .= "</li>{$n}";
为了在这个函数中建立出一个导航<ul> ,我们使用了一个变量,$output ,这是一个很长的字符串,我们不断地向其中添加东西。这种类型的代码有一个非常具体和有限的操作顺序。如果我们想在<a> ,我们必须在这个运行之前访问$attributes 。如果我们想在<a> 内选择性地嵌套一个<span> 或<img> ,我们就需要编写一个全新的代码块,用大约4-10行来取代第7行的中间部分,这取决于我们想要添加的具体内容。现在想象一下,你需要选择性地添加<span> ,然后再选择性地添加<img> ,要么在<span> 里面,要么在它后面。仅此一项就有三个if 语句,使代码更加难以辨认。
像这样的串联很容易出现字符串的意大利面条,说起来很有趣,维护起来也很痛苦。
问题的实质是,当我们试图推理HTML元素时,我们没有考虑到字符串。碰巧的是,字符串是浏览器的消耗品,也是PHP的输出品。但我们的心智模型更像DOM--元素被排列成一棵树,每个节点都有许多潜在的属性,属性,和子节点。
如果有一种结构化的、有表现力的方法来构建我们的树,那不是很好吗?
进入...
DOMDocument 类
PHP 5在它的Not So Strictly Typed™类型名册中增加了DOM 模块。它的主要入口是DOMDocument 类,它有意地与Web API的JavaScriptDOM 相似。如果你曾经使用过 document.createElement或者,对于我们这些有一定年龄的人来说,jQuery的$('<p>Hi there!</p>') 语法,这可能会感觉很熟悉。
我们首先初始化一个新的DOMDocument 。
$dom = new DOMDocument();
现在我们可以向它添加一个DOMElement 。
$p = $dom->createElement('p');
字符串'p' 代表我们想要的元素类型,所以其他有效的字符串是'div','img', 等等。
一旦我们有了一个元素,我们就可以设置它的属性。
$p->setAttribute('class', 'headline');
我们可以向它添加子元素。
$span = $dom->createElement('span', 'This is a headline'); // The 2nd argument populates the element's textContent
$p->appendChild($span);
最后,一次性获得完整的HTML字符串。
$dom->appendChild($p);
$htmlString = $dom->saveHTML();
echo $htmlString;
请注意,这种编码方式使我们的代码按照我们的心智模式组织起来--文档有元素;元素可以有任何数量的属性;元素之间相互嵌套,不需要了解彼此的情况。一旦我们的结构到位,整个 "HTML只是一个字符串 "的部分就会在最后出现。
这里的 "文档 "与实际的DOM有点不同,因为它不需要代表整个文档,只是代表一个HTML块。事实上,如果你需要创建两个类似的元素,你可以使用saveHTML() ,保存一个HTML字符串,再修改一下DOM "文档",然后再次调用saveHTML() ,保存一个新的HTML字符串。
获取数据和设置结构
假设我们需要使用来自CRM供应商的数据和我们自己的标记在服务器上建立一个表单。来自CRM的API响应看起来像这样。
{
"submit_button_label": "Submit now!",
"fields": [
{
"id": "first-name",
"type": "text",
"label": "First name",
"required": true,
"validation_message": "First name is required.",
"max_length": 30
},
{
"id": "category",
"type": "multiple_choice",
"label": "Choose all categories that apply",
"required": false,
"field_metadata": {
"multi_select": true,
"values": [
{ "value": "travel", "label": "Travel" },
{ "value": "marketing", "label": "Marketing" }
]
}
}
]
}
这个例子并没有使用任何特定的CRM的确切数据结构,但它相当有代表性。
让我们假设我们希望我们的标记看起来像这样。
<form>
<label class="field">
<input type="text" name="first-name" id="first-name" placeholder=" " required>
<span class="label">First name</span>
<em class="validation" hidden>First name is required.</em>
</label>
<label class="field checkbox-group">
<fieldset>
<div class="choice">
<input type="checkbox" value="travel" id="category-travel" name="category">
<label for="category-travel">Travel</label>
</div>
<div class="choice">
<input type="checkbox" value="marketing" id="category-marketing" name="category">
<label for="category-marketing">Marketing</label>
</div>
</fieldset>
<span class="label">Choose all categories that apply</span>
</label>
</form>
那是什么placeholder=" " ?这是一个小技巧,允许我们在CSS中跟踪字段是否为空,而不需要JavaScript。只要输入是空的,它就与input:placeholder-shown ,但用户不会看到任何可见的占位符文本。这正是我们控制标记时可以做的事情。
现在我们知道我们想要的结果是什么了,下面是游戏计划。
- 从API中获取字段定义和其他内容
- 初始化一个
DOMDocument - 遍历字段并根据需要建立每个字段
- 获得HTML输出
所以,让我们把我们的过程写出来,把一些技术性的东西写出来。
<?php
function renderForm ($endpoint) {
// Get the data from the API and convert it to a PHP object
$formResult = file_get_contents($endpoint);
$formContent = json_decode($formResult);
$formFields = $formContent->fields;
// Start building the DOM
$dom = new DOMDocument();
$form = $dom->createElement('form');
// Iterate over the fields and build each one
foreach ($formFields as $field) {
// TODO: Do something with the field data
}
// Get the HTML output
$dom->appendChild($form);
$htmlString = $dom->saveHTML();
echo $htmlString;
}
到目前为止,我们已经得到了数据并对其进行了解析,初始化了我们的DOMDocument ,并对其输出进行了呼应。我们要对每个字段做什么呢?首先,让我们建立容器元素,在我们的例子中,它应该是一个<label> ,以及所有字段类型共有的标签<span> 。
<?php
// ...
// Iterate over the fields and build each one
foreach ($formFields as $field) {
// Build the container `<label>`
$element = $dom->createElement('label');
$element->setAttribute('class', 'field');
// Reset input values
$label = null;
// Add a `<span>` for the label if it is set
if ($field->label) {
$label = $dom->createElement('span', $field->label);
$label->setAttribute('class', 'label');
}
// Add the label to the `<label>`
if ($label) $element->appendChild($label);
}
因为我们是在一个循环中,而PHP在循环中不对变量进行范围控制,所以我们在每次迭代时都要重置$label 元素。然后,如果该字段有一个标签,我们就建立这个元素。在最后,我们把它附加到容器元素上。
请注意,我们使用setAttribute 方法设置类。与Web API不同的是,不幸的是,没有对类列表进行特殊处理。它们只是另一个属性。如果我们有一些非常复杂的类逻辑,因为这只是PHP™,我们可以创建一个数组,然后把它内联:
$label->setAttribute('class', implode($labelClassList))
。
单一输入
既然我们知道API只会返回特定的字段类型,我们就可以切换类型,为每个字段编写特定的代码。
<?php
// ...
// Iterate over the fields and build each one
foreach ($formFields as $field) {
// Build the container `<label>`
$element = $dom->createElement('label');
$element->setAttribute('class', 'field');
// Reset input values
$input = null;
$label = null;
// Add a `<span>` for the label if it is set
// ...
// Build the input element
switch ($field->type) {
case 'text':
case 'email':
case 'telephone':
$input = $dom->createElement('input');
$input->setAttribute('placeholder', ' ');
if ($field->type === 'email') $input->setAttribute('type', 'email');
if ($field->type === 'telephone') $input->setAttribute('type', 'tel');
break;
}
}
现在我们来处理文本区、单选框和隐藏字段。
<?php
// ...
// Iterate over the fields and build each one
foreach ($formFields as $field) {
// Build the container `<label>`
$element = $dom->createElement('label');
$element->setAttribute('class', 'field');
// Reset input values
$input = null;
$label = null;
// Add a `<span>` for the label if it is set
// ...
// Build the input element
switch ($field->type) {
//...
case 'text_area':
$input = $dom->createElement('textarea');
$input->setAttribute('placeholder', ' ');
if ($rows = $field->field_metadata->rows) $input->setAttribute('rows', $rows);
break;
case 'checkbox':
$element->setAttribute('class', 'field single-checkbox');
$input = $dom->createElement('input');
$input->setAttribute('type', 'checkbox');
if ($field->field_metadata->initially_checked === true) $input->setAttribute('checked', 'checked');
break;
case 'hidden':
$input = $dom->createElement('input');
$input->setAttribute('type', 'hidden');
$input->setAttribute('value', $field->field_metadata->value);
$element->setAttribute('hidden', 'hidden');
$element->setAttribute('style', 'display: none;');
$label->textContent = '';
break;
}
}
注意到我们为复选框和隐藏的情况所做的新事情了吗?我们不仅仅是创建了<input> 元素;我们还对容器 <label> 元素进行了修改!对于一个单一的复选框字段,我们要修改容器的类别,这样我们就可以将复选框和标签水平对齐;一个隐藏的<input> ,其容器也应该是完全隐藏的。
现在,如果我们只是串联字符串,在这一点上就不可能改变。我们将不得不在块的顶部添加一堆关于元素类型及其元数据的if 语句。或者,也许更糟糕的是,我们提前开始switch ,然后在每个分支之间复制粘贴大量的通用代码。
而这里是使用像DOMDocument 这样的构建器的真正好处--直到我们碰到那个saveHTML() ,一切都仍然是可编辑的,一切都仍然是结构化的。
嵌套的循环元素
让我们来添加<select> 元素的逻辑。
<?php
// ...
// Iterate over the fields and build each one
foreach ($formFields as $field) {
// Build the container `<label>`
$element = $dom->createElement('label');
$element->setAttribute('class', 'field');
// Reset input values
$input = null;
$label = null;
// Add a `<span>` for the label if it is set
// ...
// Build the input element
switch ($field->type) {
//...
case 'select':
$element->setAttribute('class', 'field select');
$input = $dom->createElement('select');
$input->setAttribute('required', 'required');
if ($field->field_metadata->multi_select === true)
$input->setAttribute('multiple', 'multiple');
$options = [];
// Track whether there's a pre-selected option
$optionSelected = false;
foreach ($field->field_metadata->values as $value) {
$option = $dom->createElement('option', htmlspecialchars($value->label));
// Bail if there's no value
if (!$value->value) continue;
// Set pre-selected option
if ($value->selected === true) {
$option->setAttribute('selected', 'selected');
$optionSelected = true;
}
$option->setAttribute('value', $value->value);
$options[] = $option;
}
// If there is no pre-selected option, build an empty placeholder option
if ($optionSelected === false) {
$emptyOption = $dom->createElement('option');
// Set option to hidden, disabled, and selected
foreach (['hidden', 'disabled', 'selected'] as $attribute)
$emptyOption->setAttribute($attribute, $attribute);
$input->appendChild($emptyOption);
}
// Add options from array to `<select>`
foreach ($options as $option) {
$input->appendChild($option);
}
break;
}
}
好的,所以这里有很多事情要做,但基本逻辑是一样的。在设置了外层的<select> ,我们做了一个<option>的数组来追加到它里面。
我们在这里还做了一些<select> 的特定技巧。如果没有预选的选项,我们就添加一个空的占位符选项,这个选项已经被选中,但不能被用户选中。我们的目标是使用CSS将我们的<label class="label"> 作为一个 "占位符",但这种技术对所有类型的设计都很有用。通过在附加其他选项之前将其附加到$input ,我们确保了它是标记中的第一个选项。
现在让我们来处理单选按钮和复选框的<fieldset>s。
<?php
// ...
// Iterate over the fields and build each one
foreach ($formFields as $field) {
// Build the container `<label>`
$element = $dom->createElement('label');
$element->setAttribute('class', 'field');
// Reset input values
$input = null;
$label = null;
// Add a `<span>` for the label if it is set
// ...
// Build the input element
switch ($field->type) {
// ...
case 'multiple_choice':
$choiceType = $field->field_metadata->multi_select === true ? 'checkbox' : 'radio';
$element->setAttribute('class', "field {$choiceType}-group");
$input = $dom->createElement('fieldset');
// Build a choice `<input>` for each option in the fieldset
foreach ($field->field_metadata->values as $choiceValue) {
$choiceField = $dom->createElement('div');
$choiceField->setAttribute('class', 'choice');
// Set a unique ID using the field ID + the choice ID
$choiceID = "{$field->id}-{$choiceValue->value}";
// Build the `<input>` element
$choice = $dom->createElement('input');
$choice->setAttribute('type', $choiceType);
$choice->setAttribute('value', $choiceValue->value);
$choice->setAttribute('id', $choiceID);
$choice->setAttribute('name', $field->id);
$choiceField->appendChild($choice);
// Build the `<label>` element
$choiceLabel = $dom->createElement('label', $choiceValue->label);
$choiceLabel->setAttribute('for', $choiceID);
$choiceField->appendChild($choiceLabel);
$input->appendChild($choiceField);
}
break;
}
}
所以,首先我们要确定这个字段集是用于复选框还是单选按钮。然后,我们相应地设置容器类,并建立<fieldset> 。之后,我们遍历可用的选项,并为每个选项建立一个<div> ,并有一个<input> 和一个<label> 。
注意,我们在第21行使用常规的PHP字符串插值来设置容器类,并在第30行为每个选择创建一个唯一的ID。
碎片
我们要添加的最后一种类型比它看起来要稍微复杂一些。许多表单包括指示字段,这不是输入,只是我们需要在其他字段之间打印的一些HTML。
我们需要达到另一种DOMDocument 方法,createDocumentFragment() 。这允许我们在不使用DOM结构化的情况下添加任意的HTML。
<?php
// ...
// Iterate over the fields and build each one
foreach ($formFields as $field) {
// Build the container `<label>`
$element = $dom->createElement('label');
$element->setAttribute('class', 'field');
// Reset input values
$input = null;
$label = null;
// Add a `<span>` for the label if it is set
// ...
// Build the input element
switch ($field->type) {
//...
case 'instruction':
$element->setAttribute('class', 'field text');
$fragment = $dom->createDocumentFragment();
$fragment->appendXML($field->text);
$input = $dom->createElement('p');
$input->appendChild($fragment);
break;
}
}
在这一点上,你可能会想,我们怎么会发现自己有一个叫做$input 的对象,它实际上代表一个静态的<p> 元素。我们的目标是为字段循环的每一次迭代使用一个共同的变量名,所以在最后我们总是可以用$element->appendChild($input) ,而不管实际的字段类型如何。所以,是的,给东西命名是很难的。
验证
我们所使用的API为每个必填字段提供了一个单独的验证信息。如果有一个提交错误,我们可以在字段中显示错误,而不是在底部显示一个通用的 "哎呀,你的错误 "信息。
让我们为每个元素添加验证文本。
<?php
// ...
// Iterate over the fields and build each one
foreach ($formFields as $field) {
// build the container `<label>`
$element = $dom->createElement('label');
$element->setAttribute('class', 'field');
// Reset input values
$input = null;
$label = null;
$validation = null;
// Add a `<span>` for the label if it is set
// ...
// Add a `<em>` for the validation message if it is set
if (isset($field->validation_message)) {
$validation = $dom->createElement('em');
$fragment = $dom->createDocumentFragment();
$fragment->appendXML($field->validation_message);
$validation->appendChild($fragment);
$validation->setAttribute('class', 'validation-message');
$validation->setAttribute('hidden', 'hidden'); // Initially hidden, and will be unhidden with Javascript if there's an error on the field
}
// Build the input element
switch ($field->type) {
// ...
}
}
这就够了!不需要摆弄字段类型的逻辑--只要为每个字段有条件地建立一个元素。
把所有的东西放在一起
那么,在我们建立了所有的字段元素之后会发生什么呢?我们需要将$input,$label, 和$validation 对象添加到我们正在构建的DOM树中。我们还可以利用这个机会来添加共同的属性,如required 。然后我们要添加提交按钮,它与本API中的字段是分开的。
<?php
function renderForm ($endpoint) {
// Get the data from the API and convert it to a PHP object
// ...
// Start building the DOM
$dom = new DOMDocument();
$form = $dom->createElement('form');
// Iterate over the fields and build each one
foreach ($formFields as $field) {
// Build the container `<label>`
$element = $dom->createElement('label');
$element->setAttribute('class', 'field');
// Reset input values
$input = null;
$label = null;
$validation = null;
// Add a `<span>` for the label if it is set
// ...
// Add a `<em>` for the validation message if it is set
// ...
// Build the input element
switch ($field->type) {
// ...
}
// Add the input element
if ($input) {
$input->setAttribute('id', $field->id);
if ($field->required)
$input->setAttribute('required', 'required');
if (isset($field->max_length))
$input->setAttribute('maxlength', $field->max_length);
$element->appendChild($input);
if ($label)
$element->appendChild($label);
if ($validation)
$element->appendChild($validation);
$form->appendChild($element);
}
}
// Build the submit button
$submitButtonLabel = $formContent->submit_button_label;
$submitButtonField = $dom->createElement('div');
$submitButtonField->setAttribute('class', 'field submit');
$submitButton = $dom->createElement('button', $submitButtonLabel);
$submitButtonField->appendChild($submitButton);
$form->appendChild($submitButtonField);
// Get the HTML output
$dom->appendChild($form);
$htmlString = $dom->saveHTML();
echo $htmlString;
}
为什么我们要检查$input 是否是真实的?因为我们在循环的顶部将其重置为null ,并且只在类型符合我们预期的切换情况下才建立它,这样可以确保我们不会意外地包含我们的代码无法正确处理的意外元素。
嘿嘿,一个自定义的HTML表单!
奖励积分:行和列
正如你可能知道的,许多表单生成器允许作者为字段设置行和列。例如,一个行可能包含名字和姓氏字段,每个字段都在一个50%宽度的列中。那么,你会问,我们如何去实现这一点呢?当然是通过示范(再一次)DOMDocument ,它是多么的便于循环。
我们的API响应包括像这样的网格数据。
{
"submit_button_label": "Submit now!",
"fields": [
{
"id": "first-name",
"type": "text",
"label": "First name",
"required": true,
"validation_message": "First name is required.",
"max_length": 30,
"row": 1,
"column": 1
},
{
"id": "category",
"type": "multiple_choice",
"label": "Choose all categories that apply",
"required": false,
"field_metadata": {
"multi_select": true,
"values": [
{ "value": "travel", "label": "Travel" },
{ "value": "marketing", "label": "Marketing" }
]
},
"row": 2,
"column": 1
}
]
}
我们假设添加一个data-column 属性就可以实现宽度的风格化,但是每一行都需要是它自己的元素(也就是说,没有CSS网格)。
在我们深入研究之前,让我们想一想,为了增加行,我们需要什么。基本的逻辑是这样的。
- 追踪最近遇到的行。
- 如果当前的行比较大,即我们已经跳到了下一行,就创建一个新的行元素,并开始向其添加,而不是之前的行。
现在,如果我们是串联字符串,我们会怎么做呢?可能是每当我们到达一个新的行时,添加一个类似'</div><div class="row">' 的字符串。这种 "反转的HTML字符串 "总是让我很困惑,所以我只能想象我的IDE的感受。而最重要的是,由于浏览器自动关闭开放标签,一个错别字就会导致无数个嵌套的<div>s。就像好玩一样,但却相反。
那么,有什么结构化的方法来处理这个问题呢?谢谢你的提问。首先,让我们在我们的循环之前添加行跟踪,并建立一个额外的行容器元素。$rowElement 然后我们要确保将每个容器$element ,而不是直接追加到$form 。
<?php
function renderForm ($endpoint) {
// Get the data from the API and convert it to a PHP object
// ...
// Start building the DOM
$dom = new DOMDocument();
$form = $dom->createElement('form');
// init tracking of rows
$row = 0;
$rowElement = $dom->createElement('div');
$rowElement->setAttribute('class', 'field-row');
// Iterate over the fields and build each one
foreach ($formFields as $field) {
// Build the container `<label>`
$element = $dom->createElement('label');
$element->setAttribute('class', 'field');
$element->setAttribute('data-row', $field->row);
$element->setAttribute('data-column', $field->column);
// Add the input element to the row
if ($input) {
// ...
$rowElement->appendChild($element);
$form->appendChild($rowElement);
}
}
// ...
}
到目前为止,我们只是在字段周围添加了另一个<div> 。让我们为循环内的每一行建立一个新的行元素。
<?php
// ...
// Init tracking of rows
$row = 0;
$rowElement = $dom->createElement('div');
$rowElement->setAttribute('class', 'field-row');
// Iterate over the fields and build each one
foreach ($formFields as $field) {
// ...
// If we've reached a new row, create a new $rowElement
if ($field->row > $row) {
$row = $field->row;
$rowElement = $dom->createElement('div');
$rowElement->setAttribute('class', 'field-row');
}
// Build the input element
switch ($field->type) {
// ...
// Add the input element to the row
if ($input) {
// ...
$rowElement->appendChild($element);
// Automatically de-duped
$form->appendChild($rowElement);
}
}
}
我们所要做的就是把$rowElement 对象覆盖为一个新的DOM元素,PHP会把它当作一个新的唯一的对象。所以,在每个循环的末尾,我们只是附加上当前的任何$rowElement - 如果它仍然是上一次迭代中的那个,那么表单就会被更新;如果它是一个新的元素,它就会被附加在末尾。
我们从哪里开始呢?
表单是面向对象模板的一个很好的用例。思考一下WordPress Core的那个片段,可以认为嵌套菜单也是一个很好的用例。任何标记遵循复杂逻辑的任务都是这种方法的良好候选者。DOMDocument ,可以输出任何XML,所以你也可以用它来从帖子数据中建立一个RSS提要。
这里是我们的表单的整个代码片段。你可以自由地把它改编成你发现自己要处理的任何表单API。这是官方文档,对了解可用的API很有帮助。
我们甚至没有提到DOMDocument 可以解析现有的HTML和XML。然后你可以使用XPath API查找元素,这有点类似于document.querySelector ,或者Node.js上的cheerio 。有一点学习曲线,但这是一个处理外部内容的超级强大的API。
**有趣的(?)事实是:**以x 结尾的微软办公室文件(例如.xlsx)是XML文件。不要告诉市场部,但在服务器上解析Word文档并输出HTML是可能的。
最重要的是要记住,模板化是一种超级能力。能够在正确的情况下建立正确的标记,可以是一个伟大的用户体验的关键。