使用 DOMDocument 在 PHP 中构建表单

Avatar of Jonathan Land
Jonathan Land

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

模板让网络运转起来。数据和结构合成为内容。这是我们作为开发人员最酷的超能力——获取一些数据,然后让它为我们工作,以我们需要的任何形式呈现。对象数组可以变成表格、卡片列表、图表或任何我们认为对用户最有用的东西。无论数据是我们自己的 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 核心圣经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>,我们需要编写一个全新的代码块,该代码块将替换第 7 行的中间部分,大约需要 4 到 10 行新代码,具体取决于我们想要添加的内容。现在想象一下,您需要选择性地添加<span>,然后选择性地添加<img>,或者在<span>内部或之后添加。仅此一项就需要三个if语句,使代码更难阅读。

当像这样连接字符串时,很容易最终得到字符串意大利面,说起来很有趣,但维护起来却很痛苦。

问题的本质在于,当我们尝试推断 HTML 元素时,我们不是在考虑字符串。浏览器消耗和 PHP 输出的恰好是字符串。但我们的心理模型更像是 DOM——元素排列成树状结构,每个节点都有许多潜在的属性、特性和子节点。

如果有一种结构化、表达性的方法来构建我们的树,那不是很好吗?

进入…

DOMDocument

PHP 5 将DOM模块添加到其非严格类型™类型的名单中。它的主要入口点是DOMDocument,它有意与 Web API 的 JavaScript DOM相似。如果您曾经使用过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匹配,但用户看不到任何可见的占位符文本。当我们控制标记时,您可以做的事情就是这样!

现在我们知道了我们想要的结果,以下是游戏计划

  1. 从 API 获取字段定义和其他内容
  2. 初始化DOMDocument
  3. 迭代字段并根据需要构建每个字段
  4. 获取 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>

<?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>`。之后,我们遍历可用的选项,并为每个选项构建一个包含 `<input>` 和 `<label>` 的 `<div>`。

请注意,我们在第 21 行使用普通的 PHP 字符串插值来设置容器类,并在第 30 行创建每个选项的唯一 ID。

片段

我们还需要添加的最后一种类型比看起来稍微复杂一点。许多表单包含说明字段,这些字段不是输入,而只是我们需要在其他字段之间打印的一些 HTML。

我们需要使用另一个 `DOMDocument` 方法 `createDocumentFragment()`。这允许我们添加任意 HTML,而无需使用 DOM 结构。

<?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`,并且仅在类型符合我们预期的 switch case 时才构建它,这确保我们不会意外地包含代码无法正确处理的意外元素。

瞧,一个自定义的 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 网格)。

在深入研究之前,让我们思考一下为了添加行我们需要什么。基本逻辑如下所示。

  1. 跟踪遇到的最新行。
  2. 如果当前行更大,即我们跳到了下一行,则创建一个新的行元素并开始向其中添加内容,而不是添加到前一个元素中。

现在,如果我们正在连接字符串,我们会怎么做?可能是在到达新行时添加一个类似于 `'</div><div class="row">'` 的字符串。这种“反向 HTML 字符串”对我来说总是非常令人困惑,所以我只能想象我的 IDE 的感受。最重要的是,由于浏览器自动关闭打开的标签,一个简单的错误会导致无数嵌套的 `<div>`。就像很有趣,但恰恰相反。

那么结构化的处理方法是什么?感谢您的提问。首先,让我们在循环之前添加行跟踪,并构建一个额外的行容器元素。然后,我们将确保将每个容器 `$element` 附加到其 `$rowElement` 而不是直接附加到 `$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 核心中的那个代码片段,也可以认为嵌套菜单也是一个很好的用例。任何标记遵循复杂逻辑的任务都是这种方法的良好候选者。`DOMDocument` 可以输出任何 XML,因此您还可以使用它根据帖子数据构建 RSS 提要。

这是我们表单的完整代码片段。 随意将其调整到您遇到的任何表单 API。 这是官方文档,它有助于了解可用的 API。

我们甚至没有提到 `DOMDocument` 可以解析现有的 HTML 和 XML。然后,您可以使用 XPath API 查找元素,这有点类似于 Node.js 上的 `document.querySelector` 或 `cheerio`。学习曲线有点陡峭,但它是一个非常强大的 API,用于处理外部内容。

有趣(?)的事实:以 `x` 结尾的 Microsoft Office 文件(例如 `xlsx`)是 XML 文件。不要告诉营销部门,但可以在服务器上解析 Word 文档并输出 HTML。

最重要的是要记住,模板化是一种强大的功能。能够为合适的情况构建正确的标记可能是实现出色用户体验的关键。