这篇文章也可以在我的博客中查看
页面路由是指从url顺着网线砍到网站内容的途径,说人话就是地址与页面的映射。
就像真实世界的地址一样,我要找你,必须知道你的地址。
在网站中,通过地址找内容的机制,就称为页面路由(Route)
常见的路由有两种方式:
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不提供,但我们仍然需要自定义的页面路由
我们需要漏油!!!
我们需要漏油!!!
我们需要漏油!!!
当然,我们也可以使用查询字符串的形式添加新的路由
可以,但不优雅
因为这始终是污染查询字符串的结构了:
?the_long_long_query_var=1
会被偶然发现?xid=1
与其它插件注册的查询字符串冲突因此本文:
WordPress已经有20年历史了,byd这种做法一直是实现页面路由的主流做法
但不管怎么说,这种查询字符串标记的做法算是鼻祖了
简单来说就是:
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');
template_include
hook中,查找该标志,然后跳转到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
方法,负责:
对于每个注册的路由,都需要各自定义这些功能函数
这些函数都是上下文(注册的路径)有关的,因此此时闭包很有用!
public static function registerRoute(string $routePath, string $template, ?callable $redirect = null)
{
}
接下来逐个分析需要的几个闭包函数
add_rewrite_rule
index.php
'top'
,否则大概率不生效add_action('init', fn () => add_rewrite_rule($routePath, 'index.php', 'top'));
parse_request
:add_action('parse_request', function (wp) use (routePath) {
if (preg_match("" , wp->request))
Router::setActivePath(routePath);
});
add_filter('template_include', fn ($tpl) => Router::atPath($routePath) ? $template : $tpl);
注意:重定向后需要退出本次执行,但该函数内不得获知能否成功重定向,因此这需要在传入的回调函数中处理(见后文示例)
if ($redirect)
add_action('template_redirect', fn () => Router::atPath($routePath) && $redirect());
有时候会有从url读取参数值的需求
借鉴WordPress REST API的做法,我们可以用正则表达式的分组匹配实现参数提取
在上步parse_request
hook中,增加接收匹配结果的$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_404
hook,将它设置为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,主要是因为有两个条件:
$wp_query->is_home === true
然而经过我们前面的一堆优化,两个条件都不再满足
因此WordPress会在handle_404()
直接返回404 not found
!
那我们抑制它的作用就好了
同样在pre_handle_404
hook。修改原有的代码,返回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
需要刷新永久链接缓存才生效
如果你还是忘记了,那只能祝你好运了:)