【WordPress】如何在WordPress中实现真·页面路由

这篇文章也可以在我的博客中查看

页面路由

是什么

页面路由是指从url顺着网线砍到网站内容的途径,说人话就是地址与页面的映射。
就像真实世界的地址一样,我要找你,必须知道你的地址。

在网站中,通过地址找内容的机制,就称为页面路由(Route)

常见的路由有两种方式:

  • 页面地址映射到文件夹层级地址
  • 页面地址映射到数据结构

WordPress的路由

WordPress采用的是第二种。
在WordPress里,几乎所有的页面访问,其实都是在访问/index.php

而之所以能够输出不同的结果,是依靠读取查询字符串中的数据,再在template_includes中做判断以加载不同的模板文件。

打个比方:www.mysite.com/page/2/访问主页的第二页
事实上等价于访问www.mysite.com/index.php?paged=2
它唯一的好处就是更好看了,有利于SEO

再比如:www.mysite.com/0000/00/00/hello-world/访问第一篇文章
事实上你拿www.mysite.com/index.php?p=1也可以跳转到同样的地址

所以通过添加查询字符串标记,能够检测并跳转到不同的模板文件。
这是WordPress内置的做法。

为什么?

说了半天,为什么需要实现页面路由?
WordPress提供的路由规则不香吗?

因为WordPress只提供了默认的路由路径,而没有直接做法可以往里面添加自己的路由。

比如,我想在/game路径下添加一个很Cool的html小游戏,你会发现做不到。
WordPress中一切皆是文章,你需要展示一个东西,那它必须先成为一篇文章。

你当然也可以创建一个名为game的页面(page),然后添加自定义模板
但这一步本身有必要存在吗?

有些东西,它本身就不好被描述成文章,比如:小游戏,一些功能页面……
因此虽然WordPress不提供,但我们仍然需要自定义的页面路由

我们需要漏油!!!
我们需要漏油!!!
我们需要漏油!!!

本文内容

当然,我们也可以使用查询字符串的形式添加新的路由
可以,但不优雅

因为这始终是污染查询字符串的结构了:

  1. 虽然概率极低,但我还是并不希望丑陋的?the_long_long_query_var=1会被偶然发现
  2. 又或者是?xid=1与其它插件注册的查询字符串冲突

因此本文:

  1. 实现真·页面路由
    • 不需要创建任何page或者post文件,通过url直接定位到php模板文件
    • 相当于扩充WordPress的主题层级模板
  2. 不使用查询字符串作为标志
  3. 不破坏WordPress的生态,任何function, hook该生效的还是生效

实现路由

old-school做法

WordPress已经有20年历史了,byd这种做法一直是实现页面路由的主流做法

但不管怎么说,这种查询字符串标记的做法算是鼻祖了

简单来说就是:

  1. 使用add_rewrite_rule添加重写规则,使用一个标志查询字符串kbp_book_finder=1作为访问了www.mysite.com/bookfinder/的标志
function bookfinder_rewrite()
{
    add_rewrite_rule('^bookfinder/?$', 'index.php?kbp_book_finder=1', 'top');
}
add_action('init', 'bookfinder_rewrite');

function bookfinder_query_vars($vars)
{
    $vars[] = 'kbp_book_finder';
    return $vars;
}
add_filter('query_vars', 'bookfinder_query_vars');
  1. template_includehook中,查找该标志,然后跳转到php模板文件
function bookfinder_template($template)
{
    if (!is_404() && get_query_var('kbp_book_finder'))
        return locate_template('/path/to/your/file/book-finder.php');

    return $template;
}
add_filter('template_include', 'bookfinder_template');

本文做法

由于不爽这个查询字符串很久了,所以萌生了新的想法:
既然查询字符串的存在意义只是为了标志,那为什么不用其它变量作为标志呢?

经过反复尝试,发现是可以的,至少现在用着没出问题
出问题了再删文章()

分析问题

首先一个路由最重要的是什么?

  • 路径
  • 行为
  • 结果

没错,所以我们需要关注的是:
访问什么地址,服务器做出什么响应,然后跳转/加载到哪个页面

这几个过程分散在WordPress生命周期的不同时期,
为了实现路由的目标,我们需要程序知道目前所加载的到底是什么页面
因此为每个路径加入一个专门标志也是必须的

具体实现

位置标记

首先解决最迫切的“我在哪”问题
WordPress没有为自定义页面实现路径标记信息或系统,因此我们需要手动记录

查询字符串就是一个做法,但其实我们完全可以不用它
我们直接定义一个变量,记录目前的路由路径,并配套若干访问相关的函数:

名称空间不好存变量,我习惯使用静态类充当单例

class Router
{
    protected static ?string $activePath = null;

    protected static function setActivePath($path)
    {
        Router::$activePath = $path;
    }

    protected static function atPath($path): bool
    {
        return !is_404() && Router::$activePath === $path;
    }

    protected static function atAnyPath(): bool
    {
        return Router::$activePath !== null;
    }
}

注册路由

接下来实现最关键的是实现registerRoute方法,负责:

  1. 定义路径
  2. 路径相关的重定向
  3. 如果没重定向,加载哪个php文件

对于每个注册的路由,都需要各自定义这些功能函数
这些函数都是上下文(注册的路径)有关的,因此此时闭包很有用!


public static function registerRoute(string $routePath, string $template, ?callable $redirect = null)
{
}

接下来逐个分析需要的几个闭包函数

  1. 老朋友,add_rewrite_rule
    1.需要添加重写规则才能够访问,否则直接404。因此我们直接摆烂跳转到不附加任何参数的index.php
    2.注意优先级要设置为'top',否则大概率不生效
    3.更改重写规则需要刷新永久链接
add_action('init', fn () => add_rewrite_rule($routePath, 'index.php', 'top'));
  1. 匹配url并设置标记变量
    为了在生命周期中尽快生效,使用最早能够获得url的钩子parse_request
add_action('parse_request', function (wp) use (routePath) {
    if (preg_match("", wp->request))
    Router::setActivePath(routePath);
});
  1. 检测标记并选择性进行页面加载
add_filter('template_include', fn ($tpl) => Router::atPath($routePath) ? $template : $tpl);
  1. 加入重定向逻辑回调函数
    有时候我们希望加入与页面绑定的重定向逻辑,虽然在任何位置都可以添加重定向逻辑,但既然是页面相关的逻辑,还是提供一个专门的入口:

注意:重定向后需要退出本次执行,但该函数内不得获知能否成功重定向,因此这需要在传入的回调函数中处理(见后文示例)

if ($redirect)
    add_action('template_redirect', fn () => Router::atPath($routePath) && $redirect());

路由参数

有时候会有从url读取参数值的需求
借鉴WordPress REST API的做法,我们可以用正则表达式的分组匹配实现参数提取

在上步parse_requesthook中,增加接收匹配结果的$matches变量,并存储到Router中:

我增加了filterMatches函数,只保留命名分组

class Router
{
	public static ?array $data = null;

	public static function registerRoute(string $routePath, string $template, ?callable $redirect = null)
	{
		add_action('parse_request', function ($wp) use ($routePath) {
			if (preg_match("<$routePath>", $wp->request, $matches)) {
				Router::setActivePath($routePath);
				Router::$data = Router::filterMatches($matches);
			}
		});
	}

	protected static function filterMatches($matches)
    {
        return array_filter($matches, fn ($key) => is_string($key), ARRAY_FILTER_USE_KEY);
    }
}

此时registerRoute所有代码合在一起,是这个样子的:

public static function registerRoute(string $routePath, string $template, ?callable $redirect = null)
{
	if (!$routePath) return;

	add_action('init', fn () => add_rewrite_rule($routePath, 'index.php', 'top'));

	add_action('parse_request', function ($wp) use ($routePath) {
		if (preg_match("<$routePath>", $wp->request, $matches)) {
			Router::setActivePath($routePath);
			Router::$data = Router::filterMatches($matches);
		}
	});

	add_filter('template_include', fn ($tpl) => Router::atPath($routePath) ? $template : $tpl);

	if ($redirect)
		add_action('template_redirect', fn () => Router::atPath($routePath) && $redirect());
}

使用示意

重要提示: 更改重写规则后需要更新页面规则:
在设置菜单中找到永久链接,点击保存即可刷新

此时我们已经完成了路由的核心逻辑,可以比较方便地使用:

// https://my.site/bookfinder/
Router::registerRoute(
    '^bookfinder/?$',
    locate_template('/path/to/file1.php')
);
// https://my.site/user/
Router::registerRoute(
    '^user/?$',
    locate_template('/path/to/file2.php'),
    fn () => !is_user_logged_in() && wp_redirect(get_user_login_url()) and exit
);
// https://my.site/date/1970/01/01/
Router::registerRoute(
    '^date/(?P\d{4})/(?P\d{2})/(?P\d{2})/?$',
    locate_template('/path/to/file3.php'),
);

对于参数,可以在输出页面时按以下方式获取:

名称就是路由中的正则命名分组

print_r(Router::$data['year']);
print_r(Router::$data['month']);
print_r(Router::$data['day']);

问题修复

至此已经实现了路由功能,但(据我所测试)仍存在几个小问题

末尾斜杠

按上面的做法,url无法按照Permalink规则跳转到斜杠或非斜杠版本
无论Permalink设置,都是默认跳转到斜杠版本

为什么?

原因在于add_rewrite_rule跳转到不带参数的index.php,最后会被WordPress识别为网站首页($wp_query->is_home = true)

根据RFC 7230,http请求的path必须以/开头,即使它的内容为空。(相关资料)
因此对于形如https://mysite.com/的站点主页而言,最后的斜杠是必须

虽然目前浏览器都会在访问时自动给你补/
但WordPress很严谨地遵守了这一规定,将is_home(视为主页)的路径全部补上了斜杠!
这是他们redirect_canonical的源码:

} elseif ( is_front_page() ) {
	$redirect['path'] = trailingslashit( $redirect['path'] );
}

is_front_page()间接检测了is_home()

然而并不是所有的主页都以空path结束,也不是所有is_home的情况它就真的是home
比如我们现在的情况,我们需要加入一个不属于任何文章的页面,我们只能把它归于is_home

解决办法

找一个$wp_query->is_home被设置完后的钩子,尽快将它设置为false
WordPress使用$wp_query用作主查询,翻看源码后,发现查询后会立马调用handle_404()

因此我们可以利用这个过程的pre_handle_404hook,将它设置为false:

add_filter('pre_handle_404', function ($suppress, $wp_query) use ($routePath) {
	if (Router::atPath($routePath))
		$wp_query->init_query_flags();
	return $suppress;
}, 10, 2);

我们使用了init_query_flags(),将所有conditional tags设置为false
这是合理的,因为它本就不属于内置的任何状态
事实上受影响的只有is_home(),其它本来就是false

跳过主查询

既然自定义路由页面不存在任何post/page,我们其实没必要进行主查询
这个操作可以帮助减少5次左右查询次数

但我们希望尽可能保持WordPress原有的功能和hook,只跳过主查询

也就是跳过查询过程文档所述的4.3步骤:
4.3 Convert the query specification into a MySQL database query, and run the database query to get the list of posts, in function WP_Query->get_posts(). Save the posts in the $wp_query object to be used in the WordPress Loop.

抑制主查询

因此我们可以选择查询执行前最近的一个hook,将查询语句修改成无效:

add_filter('posts_request', fn ($request, $query) => Router::atPath($routePath) && $query->is_main_query() ? false : $request, 10, 2);

防止返回404

我们之前不会返回404,主要是因为有两个条件:

  1. 主查询有返回结果
  2. $wp_query->is_home === true

然而经过我们前面的一堆优化,两个条件都不再满足
因此WordPress会在handle_404()直接返回404 not found

那我们抑制它的作用就好了
同样在pre_handle_404hook。修改原有的代码,返回true,表示抑制404处理:

add_filter('pre_handle_404', function ($suppress, $wp_query) use ($routePath) {
	if (!Router::atPath($routePath))
		return $suppress;
	$wp_query->init_query_flags();
	return true;
}, 10, 2);

全场最佳:pre_handle_404
不起眼、看似毫不相关的hook,帮我们解决了一万个问题

条件性回调

上面的做法中,我们为每个可能的路由路径都加入了hook,并在每个回调函数中先检测自身是否为活跃路由,如果时,再执行操作。

但其实没有必要为每个路径都加入hook,因为一次访问只有1个活跃路径
因此在设置活跃路径时,它必然就是本次的路由路径,我们只为它加入hook就可以了。

不过需要注意,不能重复设置活跃路由,否则就乱套了(一般也不会出现设置多次的情况吧?)

代码就留到最后了

最终版本

经历了九九八十一难,终于修成了正果
来看看最后版本的Router吧:

class Router
{
    /**
     * 当前活跃的路由路径
     */
    protected static ?string $activePath = null;

    /**
     * 活跃路由的参数
     */
    public static ?array $data = null;

    /**
     * 注册一个路由路径
     * @param string $routePath 路径,正则表达式,站点名后的路径部分
     * @param string $template 加载的php文件
     * @param callable $redirect 可选的重定向逻辑,在该路由生效时在template_redirect触发
     */
    public static function registerRoute(string $routePath, string $template, ?callable $redirect = null)
    {
        if (!$routePath)
            return;

        /**
         * 记录到重写规则
         */
        add_action('init', fn () => add_rewrite_rule($routePath, 'index.php', 'top'));
        /**
         * 匹配url并设置标记变量,使用最早能够获得url的钩子:
         */
        add_action('parse_request', function ($wp) use ($routePath, $template, $redirect) {
            if (preg_match("<$routePath>", $wp->request, $matches)) {
                Router::setActiveRoute($routePath, $template, $redirect);
                Router::$data = Router::filterMatches($matches);
            }
        });
    }

    public static function init()
    {
        // 如果是初次:刷新
        add_action('after_switch_theme', 'flush_rewrite_rules');
    }

    protected static function setActiveRoute(string $routePath, string $template, ?callable $redirect = null)
    {
        Router::$activePath = $routePath;

        /**
         * 更改页面模板
         */
        add_filter('template_include', fn () =>  $template);

        /**
         * 加入重定向逻辑
         */
        if ($redirect)
            add_action('template_redirect', fn () => $redirect());

        /**
         * 抑制主查询
         */
        add_filter('posts_request', fn ($request, $query) => $query->is_main_query() ? false : $request, 10, 2);

        /**
         * 两件事:
         * 1. 将$wp_query->is_home设置为false,以免redirect_canonical中被标志为home页面强行加末尾斜杠
         * 2. 抑制由于“抑制主查询+is_home=false”产生的404
         */
        add_filter('pre_handle_404', fn ($_, $wp_query) => $wp_query->init_query_flags() || true, 10, 2);
    }

    protected static function filterMatches($matches)
    {
        return array_filter($matches, fn ($key) => is_string($key), ARRAY_FILTER_USE_KEY);
    }

    // 下面的函数没用到,但你可能需要这些函数做一些控制
    public static function atPath($path): bool
    {
        return !is_404() && Router::$activePath === $path;
    }

    public static function atAnyPath(): bool
    {
        return Router::$activePath !== null;
    }

    public static function activePath(): ?string
    {
        return Router::$activePath;
    }
}

其中init函数用于主题加载时刷新永久链接缓存,你也可以加入更多初始化工作

最后再强调一次:add_rewrite_rule需要刷新永久链接缓存才生效
如果你还是忘记了,那只能祝你好运了:)

参考资料

  • WordPress: How to create a rewrite rule for a file in a custom plugin
  • Disable the MySQL query in the main query
  • Query Overview
  • Trailing-slash or not in the Homepage
  • Do HTTP paths have to start with a slash?

你可能感兴趣的:(全栈,WordPress,WordPress,php,页面路由)