如何在 WordPress 主题中构建 Vue 组件

Avatar of Jonathan Land
Jonathan Land on

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

对标题感到好奇,只想看看一些代码? 跳过.

本教程是为 Vue 2 编写的,并使用“内联模板”。 Vue 3 已弃用此功能,但有 替代方法(例如将模板放入脚本标签中),您可以将该理念转换为这些方法。

几个月前,我正在构建一个需要带有许多花哨的条件字段的表单的 WordPress 网站。 不同的选项和信息需要针对表单上不同的选择,并且我们的客户需要对所有字段有完全控制权 1。 此外,该表单需要出现在每个页面的多个位置,并具有略微不同的配置。

并且表单的标题实例需要与汉堡菜单互斥,因此打开一个会关闭另一个。

并且表单包含与 SEO 相关的文本内容。

并且我们希望服务器响应呈现一些可爱的动画反馈。

(呼)

整个过程感觉足够复杂,以至于我不想手动处理所有这些状态。 我记得读过 Sarah Drasner 的文章 “用 Vue.js 替换 jQuery:无需构建步骤”,其中展示了如何用简单的 Vue 微型应用程序替换经典的 jQuery 模式。 这似乎是一个不错的起点,但我很快意识到事情会在 WordPress 的 PHP 端变得很混乱。

我真正需要的是可重用组件

PHP → JavaScript

我喜欢 Jamstack 工具(如 Nuxt)的静态优先方法,并且希望在这里做一些类似的事情——从服务器发送完整的内容,并在客户端进行渐进增强。

但是 PHP 没有内置的方法来处理组件。 但是,它确实支持在其他文件中 require-ing 文件 2。 WordPress 有一个 require 的抽象称为 get_template_part,它相对于主题文件夹运行,并且更容易使用。 将代码划分为模板部分是 WordPress 提供的最接近组件的东西 3

另一方面,Vue 都是关于组件的——但是它只能在 页面加载完毕且 JavaScript 运行后执行其操作。

这种范式结合的秘密原来是鲜为人知的 Vue 指令 inline-template。 它强大的力量使我们能够使用我们已经拥有的标记来定义 Vue 组件。 它是从服务器获取静态 HTML 和在客户端挂载动态 DOM 元素之间的完美折衷方案。

首先,浏览器获取 HTML,然后 Vue 使其执行操作。 由于标记是由 WordPress 而不是由浏览器中的 Vue 构建的,因此组件可以轻松使用网站管理员可以编辑的任何信息。 并且,与 .vue 文件(非常适合构建更多应用程序式的东西)相反,我们可以保持与我们用于整个网站相同的关注点分离——PHP 中的结构和内容、CSS 中的样式以及 JavaScript 中的功能。

为了展示这一切是如何结合在一起的,我们将为食谱博客构建一些功能。 首先,我们将添加一个用户对食谱评分的方法。 然后,我们将根据该评分构建一个反馈表单。 最后,我们将允许用户根据标签和评分过滤食谱。

我们将构建一些在同一页面上共享状态并处于活动状态的组件。 为了使它们能够很好地协同工作——以及为了将来轻松添加其他组件——我们将把整个页面设为我们的 Vue 应用程序,并在其中注册组件。

每个组件都将位于其自己的 PHP 文件中,并使用 get_template_part 包含在主题中。

奠定基础

在将 Vue 应用于现有页面时,需要考虑一些特殊事项。 首先,Vue 不希望您在其内部加载脚本——如果您这样做,它会向控制台发送不祥的错误。 避免这种情况最简单的方法是在每个页面的内容周围添加一个包装器元素,然后在包装器元素之外加载脚本(这已经是各种原因的常见模式)。 类似于以下内容

<?php /* header.php */ ?>

<body <?php body_class(); ?>>
<div id="site-wrapper">
<?php /* footer.php */ ?> 

</div> <!-- #site-wrapper -->
<?php wp_footer(); ?>

第二个考虑因素是,Vue 必须在 body 元素的末尾被调用,以便它在 DOM 可用以解析之后加载。 我们将传递 true 作为 wp_enqueue_script 函数的第五个参数 (in_footer)。 此外,为了确保 Vue 首先加载,我们将将其注册为主要脚本的依赖项。

<?php // functions.php

add_action( 'wp_enqueue_scripts', function() {
  wp_enqueue_script('vue', get_template_directory_uri() . '/assets/js/lib/vue.js', null, null, true); // change to vue.min.js for production
  wp_enqueue_script('main', get_template_directory_uri() . '/assets/js/main.js', 'vue', null, true);

最后,在主脚本中,我们将使用 site-wrapper 元素初始化 Vue。

// main.js

new Vue({
  el: document.getElementById('site-wrapper')
})

星级评分组件

我们的单个帖子模板目前看起来像这样

<?php /* single-post.php */ ?>

<article class="recipe">
  <?php /* ... post content */ ?>

  <!-- star rating component goes here -->
</article>

我们将注册星级评分组件并添加一些逻辑来管理它

// main.js

Vue.component('star-rating', {
  data () {
    return {
      rating: 0
    }
  },
  methods: {
    rate (i) { this.rating = i }
  },
  watch: {
    rating (val) {
      // prevent rating from going out of bounds by checking it to on every change
      if (val < 0) 
        this.rating = 0
      else if (val > 5) 
        this.rating = 5

      // ... some logic to save to localStorage or somewhere else
    }
  }
})

// make sure to initialize Vue after registering all components
new Vue({
  el: document.getElementById('site-wrapper')
})

我们将使用单独的 PHP 文件编写组件模板。 该组件将包含六个按钮(一个用于未评分,五个带有星形)。 每个按钮将包含一个具有黑色或透明填充的 SVG。

<?php /* components/star-rating.php */ ?>

<star-rating inline-template>
  <div class="star-rating">
    <p>Rate recipe:</p>
    <button @click="rate(0)">
      <svg><path d="..." :fill="rating === 0 ? 'black' : 'transparent'"></svg>
    </button>
    <button v-for="(i in 5)" @click="rate(i)">
      <svg><path d="..." :fill="rating >= i ? 'black' : 'transparent'"></svg>
    </button>
  </div>
</star-rating>

作为经验法则,我喜欢为组件的顶级元素赋予一个与组件本身相同的类名。 这使得在标记和 CSS 之间推理变得容易(例如,<star-rating> 可以被认为是 .star-rating)。

现在我们将它包含在我们的页面模板中。

<?php /* single-post.php */ ?>

<article class="recipe">
  <?php /* post content */ ?>

  <?php get_template_part('components/star-rating'); ?>
</article>

模板中的所有 HTML 都是有效的,并且浏览器可以理解,除了 <star-rating>。 我们可以通过使用 Vue 的 is 指令来更进一步解决这个问题

<div is="star-rating" inline-template>...</div>

现在假设最大评分不一定是 5,而是由网站编辑使用 Advanced Custom Fields(一个流行的 WordPress 插件,用于添加页面、帖子和其他 WordPress 内容的自定义字段)控制。 我们所需要做的就是将该值作为我们称为 maxRating 的组件的道具注入

<?php // components/star-rating.php

// max_rating is the name of the ACF field
$max_rating = get_field('max_rating');
?>
<div is="star-rating" inline-template :max-rating="<?= $max_rating ?>">
  <div class="star-rating">
    <p>Rate recipe:</p>
    <button @click="rate(0)">
      <svg><path d="..." :fill="rating === 0 ? 'black' : 'transparent'"></svg>
    </button>
    <button v-for="(i in maxRating) @click="rate(i)">
      <svg><path d="..." :fill="rating >= i ? 'black' : 'transparent'"></svg>
    </button>
  </div>
</div>

然后,在我们的脚本中,让我们注册该道具并替换神奇数字 5

// main.js

Vue.component('star-rating', {
  props: {
    maxRating: {
      type: Number,
      default: 5 // highlight
    }
  },
  data () {
    return {
      rating: 0
    }
  },
  methods: {
    rate (i) { this.rating = i }
  },
  watch: {
    rating (val) {
      // prevent rating from going out of bounds by checking it to on every change
      if (val < 0) 
        this.rating = 0
      else if (val > maxRating) 
        this.rating = maxRating

      // ... some logic to save to localStorage or somewhere else
    }
  }
})

为了保存特定食谱的评分,我们需要传入帖子的 ID。 同样,相同的想法

<?php // components/star-rating.php

$max_rating = get_field('max_rating');
$recipe_id = get_the_ID();
?>
<div is="star-rating" inline-template :max-rating="<?= $max_rating ?>" recipe-id="<?= $recipe_id ?>">
  <div class="star-rating">
    <p>Rate recipe:</p>
    <button @click="rate(0)">
      <svg><path d="..." :fill="rating === 0 ? 'black' : 'transparent'"></svg>
    </button>
    <button v-for="(i in maxRating) @click="rate(i)">
      <svg><path d="..." :fill="rating >= i ? 'black' : 'transparent'"></svg>
    </button>
  </div>
</div>
// main.js

Vue.component('star-rating', {
  props: {
    maxRating: { 
      // Same as before
    },
    recipeId: {
      type: String,
      required: true
    }
  },
  // ...
  watch: {
    rating (val) {
      // Same as before

      // on every change, save to some storage
      // e.g. localStorage or posting to a WP comments endpoint
      someKindOfStorageDefinedElsewhere.save(this.recipeId, this.rating)
    }
  },
  mounted () {
    this.rating = someKindOfStorageDefinedElsewhere.load(this.recipeId)    
  }
})

现在,我们可以在归档页面(帖子的循环)中包含相同的组件文件,无需任何其他设置

<?php // archive.php

if (have_posts()): while ( have_posts()): the_post(); ?>
<article class="recipe">
  <?php // Excerpt, featured image, etc. then:
  get_template_part('components/star-rating'); ?>
</article>
<?php endwhile; endif; ?>

反馈表单

用户对食谱评分的那一刻是一个寻求更多反馈的好机会,因此让我们添加一个在设置评分后立即出现的简短表单。

// main.js

Vue.component('feedback-form', {
  props: {
    recipeId: {
      type: String,
      required: true
    },
    show: { type: Boolean, default: false }
  },
  data () {
    return {
      name: '',
      subject: ''
      // ... other form fields
    }
  }
})
<?php // components/feedback-form.php

$recipe_id = get_the_ID();
?>
<div is="feedback-form" inline-template recipe-id="<?= $recipe_id ?>" v-if="showForm(recipe-id)">
  <form class="recipe-feedback-form" id="feedback-form-<?= $recipe_id ?>">
    <input type="text" :id="first-name-<?= $recipe_id ?>" v-model="name">
    <label for="first-name-<?= $recipe_id ?>">Your name</label>
    <?php /* ... */ ?>
  </form>
</div>

请注意,我们在每个表单元素的 ID 中追加了一个唯一的字符串(在本例中为 recipe-id)。 这是为了确保它们都具有唯一的 ID,即使页面上有多个表单副本。

那么,我们希望这个表单在哪里呢? 它需要知道食谱的评分,这样它就知道需要打开。 我们只是在构建老式组件,因此让我们使用组合将该表单放在 <star-rating>

<?php // components/star-rating.php

$max_rating = get_field('max_rating');
$recipe_id = get_the_ID();
?>
<div is="star-rating" inline-template :max-rating="<?= $max_rating ?>" recipe-id="<?= $recipe_id ?>">
  <div class="star-rating">
    <p>Rate recipe:</p>
    <button @click="rate(0)">
      <svg><path d="..." :fill="rating === 0 ? 'black' : 'transparent'"></svg>
    </button>
    <button v-for="(i in maxRating) @click="rate(i)">
      <svg><path d="..." :fill="rating >= i ? 'black' : 'transparent'"></svg>
    </button>
    <?php get_template_part('components/feedback-form'); ?>
  </div>
</div>

如果您此时在想,“我们真的应该将这两个组件组合成一个处理评分状态的单个父组件”,那么请给自己 10 分并耐心等待。

我们可以添加的小型渐进增强功能是,为了使该表单在没有 JavaScript 的情况下可用,我们可以为其提供传统的 PHP 操作,然后在 Vue 中覆盖它。 我们将使用 @submit.prevent 来阻止原始操作,然后运行一个 submit 方法来在 JavaScript 中发送表单数据。

<?php // components/feedback-form.php

$recipe_id = get_the_ID();
?>
<div is="feedback-form" inline-template recipe-id="<?= $recipe_id ?>">
  <form action="path/to/feedback-form-handler.php" 
      @submit.prevent="submit"
      class="recipe-feedback-form" 
      id="feedback-form-<?= $recipe_id ?>">
    <input type="text" :id="first-name-<?= $recipe_id ?>" v-model="name">
    <label for="first-name-<?= $recipe_id ?>">Your name</label>
   <!-- ... -->
  </form>
</div>

然后,假设我们想使用 fetch,我们的 submit 方法可以类似于以下内容

// main.js

Vue.component('feedback-form', {
  // Same as before

  methods: {
    submit () {
      const form = this.$el.querySelector('form')
      const URL = form.action
      const formData = new FormData(form)
      fetch(URL, {method: 'POST', body: formData})
        .then(result => { ... })
        .catch(error => { ... })
    }
  }
})

好的,那么我们想在 .then.catch 中做什么? 让我们添加一个组件来显示表单提交状态的实时反馈。 首先,让我们添加状态来跟踪发送、成功和失败,以及一个告诉我们我们是否正在等待结果的计算属性。

// main.js

Vue.component('feedback-form', {
  // Same as before

  data () {
    return {
      name: '',
      subject: ''
      // ... other form fields
      sent: false,
      success: false,
​​      error: null
    }
  },
  methods: {
    submit () {
      const form = this.$el.querySelector('form')
      const URL = form.action
      const formData = new FormData(form)
      fetch(URL, {method: 'POST', body: formData})
        .then(result => { 
          this.success = true
         })
        .catch(error => { 
          this.error = error
         })
      this.sent = true
    }
  }
})

为了添加每种消息类型(成功、失败、待处理)的标记,我们可以像我们迄今为止构建的其他组件一样制作另一个组件。 但由于这些消息在服务器呈现页面时毫无意义,因此最好仅在必要时呈现它们。 为此,我们将把标记放在一个本机 HTML <template> 标签中,该标签不会在浏览器中呈现任何内容。 然后,我们将使用 ID 作为组件的模板引用它。

<?php /* components/form-status.php */ ?>

<template id="form-status-component" v-if="false">
  <div class="form-message-wrapper">
    <div class="pending-message" v-if="pending">
      <img src="<?= get_template_directory_uri() ?>/spinner.gif">
      <p>Patience, young one.</p>
    </div>
    <div class="success-message" v-else-if="success">
      <img src="<?= get_template_directory_uri() ?>/beer.gif">
      <p>Huzzah!</p>
    </div>
    <div class="success-message" v-else-if="error">
      <img src="<?= get_template_directory_uri() ?>/broken.gif">
      <p>Ooh, boy. It would appear that: {{ error.text }}</p>
    </div>
  </div
</template>

你问为什么要在顶部添加 v-if="false"? 这是一个棘手的事情。 一旦 Vue 获取到 HTML <template>,它会立即将其视为 Vue <template> 并呈现它。 除非,你猜对了,我们告诉 Vue 不要呈现它。 这有点像黑客,但就是这样。

由于我们只需要在页面上使用一次此标记,因此我们将把 PHP 组件包含在页脚中。

<?php /* footer.php */ ?>

</div> <!-- #site-wrapper -->
<?php get_template_part('components/form-status'); ?>
<?php wp_footer(); ?>

现在,我们将使用 Vue 注册该组件……

// main.js

Vue.component('form-status', {
  template: '#form-status-component'
  props: {
    pending: { type: Boolean, required: true },
    success: { type: Boolean, required: true },
    error: { type: [Object, null], required: true },
  }
})

…并在我们的表单组件中调用它

<?php // components/feedback-form.php

$recipe_id = get_the_ID();
?>
<div is="feedback-form" inline-template recipe-id="<?= $recipe_id ?>">
  <form action="path/to/feedback-form-handler.php" 
        @submit.prevent="submit"
        class="recipe-feedback-form" 
        id="feedback-form-<?= $recipe_id ?>">
    <input type="text" :id="first-name-<?= $recipe_id ?>" v-model="name">
    <label for="first-name-<?= $recipe_id ?>">Your name</label>
    <?php // ... ?>
  </form>
  <form-status v-if="sent" :pending="pending" :success="success" :error="error" />
</div>

由于我们使用 Vue.component 注册了 <form-status> ,因此它可以在全局范围内使用,无需在父组件的 components: { } 中显式包含它。

过滤食谱

现在,用户可以在我们的博客上个性化部分体验,我们可以添加各种有用的功能。具体来说,让我们允许用户设置他们想要查看的最低评分,使用页面顶部的输入框。
我们首先需要一些全局状态来跟踪用户设置的最低评分。由于我们从在整个页面上初始化 Vue 应用程序开始,全局状态将只是 Vue 实例上的数据。

// main.js
// Same as before

new Vue({
  el: document.getElementById('site-wrapper'),
  data: {
    minimumRating: 0
  }
})

我们可以在哪里放置更改此状态的控件呢?由于整个页面就是应用程序,答案几乎是 任何地方 。例如,在档案页面的顶部。

<?php /* archive.php */ ?>

<label for="minimum-rating-input">Only show me recipes I've rated at or above:</label>
<input type="number" id="minimum-rating-input" v-model="minimumRating">

<?php if (have_posts()): while ( have_posts()): the_post(); ?>
<article class="recipe">
  <?php /* Post excerpt, featured image, etc. */ ?>

  <?php get_template_part('components/star-rating'); ?>
</article>
<?php endwhile; endif; ?>

只要它位于我们的 site-wrapper 内,而不是另一个组件内,它就会正常工作。如果需要,我们还可以构建一个过滤组件,该组件将更改全局状态。如果我们想要更高级的功能,我们甚至可以 添加 Vuex 到混合中 (因为 Vuex 默认情况下无法在页面之间持久化状态,我们可以添加一些类似 vuex-persist 的东西来使用 localStorage)。

因此,现在我们需要根据过滤器隐藏或显示食谱。为此,我们需要将食谱内容包装在它自己的组件中,并使用 v-show 指令。最好在单页面和存档页面都使用相同的组件。不幸的是,既不是 require 也不是 get_template_part 可以将参数传递给被调用的文件 — 但我们可以使用 global 变量。

<?php /* archive.php */ ?>

<label for="minimum-rating-input">Only show me recipes I've rated at or above:</label>
<input type="number" id="minimum-rating-input" v-model="minimumRating">

<?php 
$is_archive_item = true;
if (have_posts()): while ( have_posts()): the_post();
  get_template_part('components/recipe-content');
endwhile; endif; ?>

然后,我们可以在 PHP 组件文件中使用 $is_archive_item 作为 global 变量来检查它是否已设置并为 true。由于我们不需要在单篇文章页面上隐藏内容,因此我们将有条件地添加 v-show 指令。

<?php  // components/recipe-content.php

global $is_archive_item; ?>
<div is="recipe-content">
  <article class="recipe" 
    <?php if ($is_archive_item): ?>
       v-show="show"
    <?php endif; ?>
  >
    <?php
    if ($is_archive_item):
      the_excerpt();
    else
      the_content();
    endif;
    
    get_template_part('components/star-rating');
    ?>
  </article>
</div>

在这个具体的例子中,我们也可以在组件内部使用  is_archive() 进行测试,但在大多数情况下,我们需要设置显式道具。

我们需要将 rating 状态和逻辑移到 <recipe-content> 组件中,这样它就可以知道是否需要隐藏自己。在 <star-rating> 中,我们将通过用 value 替换 rating 以及将 this.rating = i 替换为  $emit('input', i) 来创建一个自定义的 v-model 。因此,我们的组件注册现在将如下所示

// main.js

Vue.component('recipe-content', {
  data () {
    rating: 0
  },
  watch: {
    rating (val) {
      // ...
    }
  },
  mounted () {
    this.rating = someKindOfStorageDefinedElsewhere.load(this.recipeId)    
  }
})

Vue.component('star-rating', {
  props: {
    maxRating: { /* ... */ },
    recipeId: { /* ... */ },
    value: { type: Number, required: true }
  },
  methods: {
    rate (i) { this.$emit('input', i) }
  },
})

我们将在 star-rating.php 中添加 v-model 并将 rating 更改为 value。此外,我们现在可以将 <feedback-form> 移到 <recipe-content> 中。

<?php // components/star-rating.php

$max_rating = get_field('max_rating');
$recipe_id = get_the_ID();
?>
<div is="star-rating" 
  inline-template 
  :max-rating="<?= $ max_rating ?>" 
  recipe-id="<?= $recipe_id ?>" 
  v-model="value"
>
  <div class="star-rating">
    <p>Rate recipe:</p>
    <button @click="rate(0)">
      <svg><path d="..." :fill="value === 0 ? 'black' : 'transparent'"></svg>
    </button>
    <button v-for="(i in maxRating) @click="rate(i)">
      <svg><path d="..." :fill="value >= i ? 'black' : 'transparent'"></svg>
    </button>
  </div>
</div>
<?php // components/recipe-content.php

global $is_archive_item; ?>
<div is="recipe-content">
  <article class="recipe" 
    <?php if ($is_archive_item): ?>
       v-show="show"
    <?php endif; ?>
  >
    
    <?php
    if ($is_archive_item):
      the_excerpt();
    else
      the_content();
    endif;
    
    get_template_part('components/star-rating');
    get_template_part('components/feedback-form');
    ?>
  </article>
</div>

现在一切都已设置好,因此初始渲染将显示所有食谱,然后用户可以根据他们的评分过滤它们。展望未来,我们可以添加各种参数来过滤内容。它不一定要基于用户输入 — 我们可以通过将数据从 PHP 传递到 Vue 来允许根据内容本身进行过滤 (例如,配料数量或烹饪时间)。

结论

好吧,这是一段漫长的旅程,但看看我们构建了什么: 在 WordPress 主题中独立、可组合、可维护、交互式、渐进式增强的组件 。我们汇集了所有世界的精华!

我已经在生产中使用这种方法一段时间了,我喜欢它允许我分析主题不同部分的方式。我希望我能激励你也尝试一下。


  1. 当然,在发布前的两天,客户的法律部门决定他们不想收集所有这些信息。目前,实时表单只是其开发形式的影子。
  2. 有趣的事实: Rasmus Lerdorf 说 他最初的意图是让 PHP 只进行模板化,所有业务逻辑都在 C 中处理。让这句话在脑海中沉淀一会儿。然后从你的日程安排中腾出一个小时来观看整个演讲。
  3. 有一些第三方 WordPress 模板引擎可以编译成优化的 PHP。 Twig ,例如,就浮现在脑海中。我们试图走反方向,并将 纯净的 PHP 发送到 JavaScript 进行处理。