WordPress 开发:第一个主题

打算用 WordPress 开发网站,准确的说是加企业网站后台。WordPress 主题控制着网站的前端。学习 WordPress 开发,第一步就是要学习怎么开发 WordPress 主题,也就是怎么套现成的前端模版。

目标小一点

由于是刚开始接触 WordPress,尽量把第一个主题的目标定小一点,只考虑传统主题,且只支持几个页面:

  • 首页
  • 单页面,比如关于我们页面
  • 列表页,比如分类列表页
  • 详情页,比如详情页

据说 WordPress 占用率高达 40%,但找一个不太老旧的主题开发教程可不轻松,这是我正在用或者打算用的参考:

搭建本地开发环境

在尝试 Docker 受挫之后(猜测部分原因来自于 Apple M1 芯片,一些 docker image 不支持 ARM64?),我打算用传统方法。

WordPress 官方建议 PHP 7.4+, MySQL 5.7+,兼顾日后部署环境,就不用最新版了。服务器采用 Apache,安装他们:

1brew install php@7.4 mysql@5.7 apache2

Apache 支持 PHP 还需要设置下,采用 Apache 内置的 mod_php 方案简单些(我参考了 macOS 12.0 Monterey Apache Setup: Multiple PHP Versions | Grav CMS

1# /opt/homebrew/etc/httpd/httpd.conf
2
3LoadModule rewrite_module lib/httpd/modules/mod_rewrite.so
4LoadModule php7_module /opt/homebrew/opt/php@7.4/lib/httpd/modules/libphp7.so
5
6<IfModule dir_module>
7 DirectoryIndex index.php index.html
8</IfModule>
9
10<FilesMatch \.php$>
11 SetHandler application/x-httpd-php
12</FilesMatch>

然后启动服务:

1# 启动 Apache (httpd 和 apache 相同)
2brew services start httpd
3 
4# 启动 MySQL,默认用户 root 密码为空
5brew services start mysql@5.7

主题开发

WordPress 不是 Web Framework

现代 Web Framework(如 Laravel)常常采用 MVC 方式,开发者自定义路由,指定控制器来处理,在控制器里获得模型最后传给模版渲染。

WordPress 不同,WordPress 里采用约定俗成的规定——Template Hierarchy,主题开发者需要根据这些规定来命名模版的文件名。

按照这个规定,我们的主题包含这几个文件:

  • style.css 必须,用来设置主题的元数据(主题名称、版本信息等等)
  • index.php 必须,fallback template,如果其他的模版不匹配,就用这个模版
  • functions.php 调整主题的功能
  • home.php 首页模版
  • page.php 单页面模版
  • archive.php 列表页模版
  • single.php 详情页模版

撕裂

我们有四个模版需要共享同一个 HTML 结构。正式的模版语言,如 Laravel 的 Blade 或者 Symfony 的 Twig,支持继承扩展。但是原生的 PHP 模版不支持,由于缺少对 HTML 的理解,只能硬生生把完整的 HTML 结构撕开:

1<!doctype html>
2<html lang="zh">
3<head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1">
6 <title>WordPress 主题开发</title>
7 <link rel="stylesheet" href="<?= get_stylesheet_uri() ?>">
8</head>
9<body>
10 
11<header></header>
12 
13<main></main>
14 
15<footer></footer>
16 
17<script src="<?= get_template_directory_uri() . '/script.js' ?>"></script>
18</body>
19</html>

按 WordPress 的习惯,分成 header.php 和 footer.php。好在后面使用起来没有这种撕裂,比如这是我们的首页:

1<!-- home.php -->
2<?php get_header() ?>
3 
4<main>
5 This is the homepage!
6</main>
7 
8<?php get_footer() ?>

上面我也没有用 wp_head wp_footer 来通过 WordPress 自带机制添加 <title>, CSS, JS,因为一来我不大了解[^wp_head],二来我想先保持简单,能自己实现就自己实现。

[^wp_head]: 的确,后来发现不用的话,WordPress 富文本编辑器的居中对齐,前端无效了,是因为不用 wp_head 的话,aligncenter 类就没有定义了。

奇怪的循环

模版准备好了,就该展示内容了。每一个模版预设获得的内容也是 WordPress 事先规定好的,比如首页会获得最新的 10 篇文章。通过循环获得:

1<!-- home.php -->
2<?php get_header() ?>
3 
4<main>
5 <h1>最新文章</h1>
6 
7 <?php if ( have_posts() ): ?>
8 <ul>
9 <?php while ( have_posts() ): the_post(); ?>
10 <li>
11 <h3><?php the_title(); ?></h3>
12 <p><?= get_the_excerpt() ?></p>
13 <a href="<?= get_permalink() ?>">Read More</a>
14 </li>
15 <?php endwhile; ?>
16 </ul>
17 <?php endif; ?>
18 
19</main>
20 
21<?php get_footer() ?>

这段代码是这样工作的:

  • 依赖全局变量,$wp_query$post
  • have_posts() 是循环结束条件
  • the_post() 迭代一次,更新 $post
  • the_title()/get_the_excerpt()/get_permalink() 获取从全局变量 $post 中获得信息

这样的代码看起来怪,也不直观。一定要用全局变量呢?为什么不用 foreach 呢?

更奇怪是即便只有一篇文章,比如详情页,也得用这个循环:

1<!-- single.php -->
2<?php get_header() ?>
3 
4<main>
5 <?php if ( have_posts() ): ?>
6 <?php while ( have_posts() ): the_post(); ?>
7 <h1><?php the_title(); ?></h1>
8 <time><?php the_date() ?></time>
9 <div class="user-rich-content">
10 <?php the_content(); ?>
11 </div>
12 <?php endwhile; ?>
13 <?php endif; ?>
14</main>
15 
16<?php get_footer() ?>

有了循环,内容太多时,就需要分页了,在列表页中调用 the_posts_pagination() 会生成分页器;而在详情页里要生成上一篇下一篇,调用 previous_post_link() / next_post_link()

生成标题

页面标题 <title></title> 需要动态生成,第一种方法是让 WordPress 帮我们生成,调用 wp_head(),再配合上:

1<!-- functions.php -->
2<?php
3 
4add_action( 'after_setup_theme', fn() => add_theme_support( 'title-tag' ) );

另一种方法,是手动生成,由于我上面就没有调用 wp_head(),我们手动生成,定义函数:

1<!-- functions.php -->
2<?php
3 
4function myfirsttheme_get_page_title() {
5 if ( is_home() ) {
6 return get_bloginfo( 'name' );
7 }
8 if ( is_single() ) {
9 return get_the_title();
10 }
11 if ( is_page() ) {
12 return get_the_title();
13 }
14 if ( is_category() ) {
15 return single_cat_title( '', false );
16 }
17 
18 return 'ERROR: Cannot get the page title';
19}

之后在 header.php 里调用就行了。

生成导航菜单

WordPress 自带让用户编辑菜单的功能,首先,主题需要注册菜单显示的位置,假设我们页眉和页脚需要导航,注册这 2 个位置:

1add_action( 'init', fn() => register_nav_menus( [
2 'header-menu' => __( 'Header Menu' ),
3 'footer-menu' => __( 'Footer Menu' ),
4] ) );

之后,进入后台,会发现多了个 Menu 入口。分别给这两个位置加一个菜单,菜单栏目可以包含子菜单栏目,是个树结构。

页眉菜单

最后,我们在主题里合适位置调用 wp_nav_menu() 打印他们。

1// header.php
2<?php wp_nav_menu( array( 'theme_location' => 'header-menu' ) ); ?>
3 
4// footer.php
5<?php wp_nav_menu( array( 'theme_location' => 'footer-menu' ) ); ?>

下一步

我们上面定的小目标已经完成了,那下一步呢?

首先,有一个样式丢失问题:我在后台编辑了篇文章,插入了一张图片,居中显示,但是前端却没有居中。那是因为这个居中依赖于 aligncenter,但是我们有意不引入 WordPress 的 CSS,所以就少了这个类定义。这个问题留着以后研究也不迟。

然后,默认文章作为新闻差不多够了,但是像产品这样的内容,需要创建专门的文章类型,顺便还要了解下自定义字段。