WP DEVELOP

EP201-204. “三层嵌套 Block 实现幻灯片(slideshow / slide)”

首页 WordPress 开发课程 BLOCK THEME(2024 最佳实践) · EP201-204
约 31 分钟· #EP201-204#BLOCK THEME(2024 最佳实践)
🔒 登录后可标记已读

📌 说明:EP202 的 transcript 在「Now we just need to adjust this output to actually look like a slide.」这句话结束,EP204 开头是同一句话的重复接续,中间夹了 EP203 一条不含实操步骤的「Quick Note」——三篇合并成一篇笔记。EP201(页头/页脚)内容相对独立,一并收在这篇笔记的前半段。

📌 并入 EP203 的提醒slide.php 里判断有没有自定义背景图时,if (!$attributes['imgURL']) 在较新 PHP 版本下如果这个键完全不存在会报警告,要改成 if (!isset($attributes['imgURL']))——课程配套的参考代码压缩包已经统一改成这个写法。


先用 EP199-200 建立的 PlaceholderBlock 套路,把全站通用的页头(logo/导航/搜索图标)和页脚做成两个可以插入任意模板的占位 Block(headerfooter),顺带讨论了一下"官方标准做法"(2022 主题用的 parts/ 文件夹机制)跟自己这套做法的区别。然后是这一章份量最重的一个练习:把首页幻灯片改造成三层嵌套的 Block 结构——最外层 slideshow(只允许嵌套 slide)→ 中间层 slide(复制 banner 的代码改一下,负责单张幻灯片的背景图 + 只允许嵌套标题/按钮)→ 最内层复用已经写好的 genericheading/genericbutton。同时把原本只认「页面上第一个」滑动组件的 JS 脚本,改造成能同时正确驱动「一个页面上多个独立幻灯片实例」。


涉及文件

  • wp-content/themes/fictional-university-block-theme/functions.php (修改,新增 4 个 Block 注册)
  • wp-content/themes/fictional-university-block-theme/our-blocks/header.js / header.php (新建)
  • wp-content/themes/fictional-university-block-theme/our-blocks/footer.js / footer.php (新建)
  • wp-content/themes/fictional-university-block-theme/our-blocks/slideshow.js / slideshow.php (新建)
  • wp-content/themes/fictional-university-block-theme/our-blocks/slide.js / slide.php (新建,复制自 banner.js/banner.php
  • wp-content/themes/fictional-university-block-theme/package.json (修改,start 脚本新增两个入口)
  • wp-content/themes/fictional-university-block-theme/src/modules/HeroSlider.js (修改,支持多实例)

代码实现

functions.php:新增页头/页脚占位 Block + 幻灯片两层 Block

new PlaceholderBlock("eventsandblogs");
new PlaceholderBlock("header");
new PlaceholderBlock("footer");

// ...

new JSXBlock('banner', true, ['fallbackimage' => get_theme_file_uri('/images/library-hero.jpg')]);
new JSXBlock('genericheading');
new JSXBlock('genericbutton');
new JSXBlock('slideshow', true);
new JSXBlock('slide', true);

our-blocks/header.js / our-blocks/footer.js:跟 eventsandblogs.js 同样的极简占位写法

wp.blocks.registerBlockType("ourblocktheme/header", {
  title: "Fictional University Header",
  edit: function () {
    return wp.element.createElement("div", { className: "our-placeholder-block" }, "Header Placeholder")
  },
  save: function () {
    return null
  }
})

our-blocks/header.php / our-blocks/footer.php:原样搬运传统主题 header.php/footer.php<header>/<footer> 元素内部的既有代码(此处从略,跟原 fictional-university 传统主题的对应文件一致,不做任何改动)

our-blocks/slideshow.js(新建,最外层容器,只允许嵌套 slide

import { InnerBlocks } from "@wordpress/block-editor"
import { registerBlockType } from "@wordpress/blocks"

registerBlockType("ourblocktheme/slideshow", {
  title: "Slideshow",
  supports: {
    align: ["full"]
  },
  attributes: {
    align: { type: "string", default: "full" }
  },
  edit: EditComponent,
  save: SaveComponent
})

function SaveComponent() {
  return <InnerBlocks.Content />
}

function EditComponent() {
  return (
    <div style={{ backgroundColor: "#333", padding: "35px" }}>
      <p style={{ textAlign: "center", fontSize: "20px", color: "#FFF" }}>Slideshow</p>
      <InnerBlocks allowedBlocks={["ourblocktheme/slide"]} />
    </div>
  )
}

our-blocks/slideshow.php(新建,搬运传统主题 hero-slider 结构,去掉写死的三张幻灯片,改用 $content 动态输出)

<div class="hero-slider">
    <div data-glide-el="track" class="glide__track">
    <div class="glide__slides">
    
    <?php echo $content; ?>
  
    </div>
      <div class="slider__bullets glide__bullets" data-glide-el="controls[nav]">
      </div>
    </div>
  </div>

our-blocks/slide.js(新建,几乎整份复制自 banner.js,只改了 Block 名字/标题和最外层 class)

import apiFetch from "@wordpress/api-fetch"
import { Button, PanelBody, PanelRow } from "@wordpress/components"
import { InnerBlocks, InspectorControls, MediaUpload, MediaUploadCheck } from "@wordpress/block-editor"
import { registerBlockType } from "@wordpress/blocks"
import { useEffect } from "@wordpress/element"

registerBlockType("ourblocktheme/slide", {
  title: "Slide",
  supports: {
    align: ["full"]
  },
  attributes: {
    align: { type: "string", default: "full" },
    imgID: { type: "number" },
    imgURL: { type: "string", default: banner.fallbackimage }
  },
  edit: EditComponent,
  save: SaveComponent
})

function EditComponent(props) {
  useEffect(
    function () {
      if (props.attributes.imgID) {
        async function go() {
          const response = await apiFetch({
            path: `/wp/v2/media/${props.attributes.imgID}`,
            method: "GET"
          })
          props.setAttributes({ imgURL: response.media_details.sizes.pageBanner.source_url })
        }
        go()
      }
    },
    [props.attributes.imgID]
  )

  function onFileSelect(x) {
    props.setAttributes({ imgID: x.id })
  }

  return (
    <>
      <InspectorControls>
        <PanelBody title="Background" initialOpen={true}>
          <PanelRow>
            <MediaUploadCheck>
              <MediaUpload
                onSelect={onFileSelect}
                value={props.attributes.imgID}
                render={({ open }) => {
                  return <Button onClick={open}>Choose Image</Button>
                }}
              />
            </MediaUploadCheck>
          </PanelRow>
        </PanelBody>
      </InspectorControls>

      <div className="hero-slider__slide" style={{ backgroundImage: `url('${props.attributes.imgURL}')` }}>
        <div className="hero-slider__interior container">
          <div className="hero-slider__overlay t-center">
            <InnerBlocks allowedBlocks={["ourblocktheme/genericheading", "ourblocktheme/genericbutton"]} />
          </div>
        </div>
      </div>
    </>
  )
}

function SaveComponent() {
  return <InnerBlocks.Content />
}

our-blocks/slide.php(新建,跟 banner.php 结构一致,只是最外层 class 换成幻灯片专属的)

<?php

if (!isset($attributes['imgURL'])) {
  $attributes['imgURL'] = get_theme_file_uri('/images/library-hero.jpg');
}

?>

      <div class="hero-slider__slide" style="background-image: url('<?php echo $attributes['imgURL'] ?>')">
        <div class="hero-slider__interior container">
          <div class="hero-slider__overlay t-center">
            <?php echo $content; ?>
          </div>
        </div>
      </div>

src/modules/HeroSlider.js:从「只处理页面第一个滑动组件」改成「遍历处理页面上所有滑动组件」

import Glide from "@glidejs/glide"

class HeroSlider {
  constructor() {
    const allSlideshows = document.querySelectorAll(".hero-slider")
    allSlideshows.forEach(function (currentSlideshow) {
      const dotCount = currentSlideshow.querySelectorAll(".hero-slider__slide").length

      let dotHTML = ""
      for (let i = 0; i < dotCount; i++) {
        dotHTML += `<button class="slider__bullet glide__bullet" data-glide-dir="=${i}"></button>`
      }

      currentSlideshow.querySelector(".glide__bullets").insertAdjacentHTML("beforeend", dotHTML)

      var glide = new Glide(currentSlideshow, {
        type: "carousel",
        perView: 1,
        autoplay: 3000
      })

      glide.mount()
    })
  }
}

export default HeroSlider

关键改动点:

  • 页头/页脚沿用 EP199-200 的 PlaceholderBlock 套路:新建占位 Block、复制传统主题 header.php/footer.php<header>/<footer> 元素内部的代码(掐头去尾,不要 <!DOCTYPE>/<head>/<body> 这些),跟事件博文区域一模一样的模式,不再赘述
  • 「官方标准做法」vs 这里的做法:2022 默认主题用 parts/ 文件夹(比如 parts/header.html)存放「一组核心 Block 的固定组合」,再用 <!-- wp:template-part {"slug":"header"} /--> 这样的注释在各模板里引用——但这套机制骨子里还是「core Block 的排列组合」;这门课选择做一个自定义 Block(our-blocks/header.js/.php),因为要的是自己的 HTML/CSS/查询逻辑,不是核心 Block 拼出来的结果,两种思路的本质差异不是「有没有用 parts/」,而是「内容到底是不是纯核心 Block 组合」
  • 三层嵌套结构的设计slideshow(最外层,只允许嵌套 slideInnerBlocks allowedBlocks={["ourblocktheme/slide"]})→ slide(中间层,几乎是 banner 的复制品:同样支持全宽、同样有背景图选择器,只允许嵌套 genericheading/genericbutton)→ 已有的标题/按钮 Block——三层各自只关心自己那一层的职责,不需要重新发明标题/按钮的编辑逻辑
  • slideshowedit 只给一层视觉提示,没有任何自定义交互:灰底深色背景 + 一行「Slideshow」标签文字,纯粹是为了在编辑器里能看出「这是幻灯片容器,内部嵌套的才是每一页」,避免用户困惑于分不清哪层是哪层
  • slide 几乎是完整复制 banner.js/banner.php,只改了名字和最外层 class:因为两者需求高度重合(都需要背景图选择器、都只允许嵌套标题/按钮)——作者坦言「理想情况下应该抽一个共用的基类,这门课不追求那种程度的极致整洁」,直接复制粘贴改名字更实际
  • slideshow.php/slide.php 的 HTML 结构原样借用传统主题已经调试好的 Glide.js 幻灯片标记规范hero-slider/glide__track/glide__slides/glide__bullets 这些 class 名字),因为这是第三方 JS 插件 Glide 认识的固定结构,不是 WordPress 的规则,这部分代码本身就跟 WordPress 无关,是通用前端知识
  • slideshow.php 里把原本写死的三张幻灯片全部删掉,只保留最外层结构,中间用 <?php echo $content; ?> 动态输出——不管用户实际插入了两张还是十张 slide,都由这一行处理
  • HeroSlider.js 的多实例改造:原本用 document.querySelector(".hero-slider") 只抓页面上第一个实例来初始化,改成 document.querySelectorAll(".hero-slider") 抓全部实例,再用 .forEach() 遍历——原本发生在最外层的选择逻辑(数幻灯片张数、生成导航圆点 HTML、初始化 Glide)整段搬进 forEach 回调里,所有原本 document.querySelector(...) 的地方都要改成 currentSlideshow.querySelector(...)(限定在「当前这个幻灯片实例」内部查找,而不是整个页面)——这样页面上有几个 slideshow 实例,就各自独立初始化几次,互不干扰
  • 验证多实例效果的操作细节:在编辑器里用「列表视图」图标选中整个 slideshow Block,用右键菜单/三个点的「Duplicate」直接复制出第二份,比手动重新插入、重新配置省事很多;改造前会看到「圆点数量算错、幻灯片互相干扰」的 bug,改造后两个独立的幻灯片各自正确显示两个圆点、各自独立轮播

Hook / Function 速查

名称类型用途
document.querySelectorAll(selector).forEach(callback)浏览器原生 API遍历页面上所有匹配的元素,让脚本支持同一页面多个组件实例
元素.querySelector(selector)(限定在某元素内部查找)浏览器原生 API相比 document.querySelector,把查找范围限定在指定元素内部,避免抓到别的实例的子元素

常见坑

  • JS 组件脚本只用 document.querySelector(单数)初始化——一个页面有多个该组件的实例时,只有第一个能正常工作,其余的完全没反应或者互相干扰
  • 多实例改造时,内部查找逻辑忘记从 document.querySelector 换成 currentSlideshow.querySelector——依然会抓到全局第一个匹配元素,而不是当前正在处理的这个实例内部的元素,导致圆点数量、幻灯片内容全部错位
  • slideshow.php 里依然保留写死的固定张数幻灯片,没有换成 echo $content——用户想加第三张、第四张幻灯片时不会生效
  • slide.php 的判断依然用 !$attributes['imgURL'] 而不是 isset() 版本——较新 PHP 环境下会触发未定义键警告(EP203 提醒)

[截图:前台首页同时出现两个独立轮播的 Slideshow(各自的圆点导航数量正确、互不干扰),验证多实例改造生效]


延伸 / 后续讲座会用到

首页模板到此基本完工,下一讲开始搭建单篇文章/页面、归档页等其余模板文件。


Sources

Udemy:

  • Become a WordPress Developer: Unlocking Power With Code — Section 28, EP201, EP202, EP203, EP204