本文约 12,639 字,预计阅读 32 分钟。文中命令与配置均经过实测,可直接参考执行。
引言:两百个产品堆在一个页面里
一个做工业配件的客户,原有官网把 200 多个产品全部写在一个「产品展示」页面里,用表格排版,图片是缩略图硬缩放。后果很具体:页面加载 8 秒;想找某个型号只能 Ctrl+F;搜索引擎只收录了这一个页面,200 个产品没有任何一个能被单独搜到。
改成产品中心后:每个产品独立一个页面、独立 URL、独立标题与描述;按品类分五个分类;加入筛选与搜索;半年后长尾词带来的询盘占了总量的一半。
这个改造的核心,就是把「页面里的内容」变成「结构化的内容类型」。WordPress 里对应的能力是自定义文章类型(CPT)+ 分类法(Taxonomy)。一旦产品成为独立内容类型,分页、筛选、搜索、结构化数据、批量编辑这些能力全部自动可用,不需要额外开发。
原理分析:CPT 与分类法各自解决什么
| 概念 | 作用 | 例子 |
|---|---|---|
| 文章类型(post type) | 区分「这是什么内容」 | post / page / product / case |
| 分类法(taxonomy) | 区分「它属于哪一类」 | category / product_cat / case_industry |
| 分类项(term) | 分类下的具体值 | 控制器 / 传感器 |
| 模板层级 | 决定用哪个模板渲染 | single-product.php / taxonomy-product_cat.php |
WordPress 的模板层级是这套体系的关键。注册了 product 类型后,会自动按以下顺序找模板:single-product.php → single.php → singular.php → index.php;归档则是 archive-product.php → archive.php → index.php。不用写任何路由代码,建文件即可生效。
分类法分两种:hierarchical => true 的是分类(有父子层级,后台是勾选框),适合品类;false 的是标签(扁平),适合参数标签。产品中心通常两个都要:品类做层级分类,应用/材质/认证做扁平标签。
还有一个必知参数:has_archive。设为 true 才会生成 /products/ 这个归档页;设为字符串可以直接指定归档 slug,比如 'has_archive' => 'products'。
保姆级实操
步骤 1:用 WP-CLI 生成代码骨架,避免手写错误
wp scaffold post-type product --label="产品" --textdomain=mmf --plugin=mmf-core
wp scaffold taxonomy product_cat --post_types=product --label="产品分类" --textdomain=mmf --plugin=mmf-core
生成的函数签名和参数都是标准写法,比手写可靠。下面给出一份整合后的完整版,可直接放进功能插件。
步骤 2:注册产品类型与分类法
<?php
/**
* 产品中心:内容类型与分类法
* 放在功能插件里,换主题不影响
*/
add_action( 'init', 'mmf_register_product' );
function mmf_register_product() {
register_post_type( 'product', array(
'labels' => array(
'name' => '产品',
'singular_name' => '产品',
'add_new_item' => '添加产品',
'edit_item' => '编辑产品',
'all_items' => '所有产品',
),
'public' => true,
'has_archive' => 'products',
'rewrite' => array( 'slug' => 'products', 'with_front' => false ),
'menu_icon' => 'dashicons-cart',
'menu_position' => 21,
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt', 'page-attributes' ),
'show_in_rest' => true,
'taxonomies' => array( 'product_cat' ),
) );
register_taxonomy( 'product_cat', 'product', array(
'labels' => array( 'name' => '产品分类', 'singular_name' => '产品分类' ),
'public' => true,
'hierarchical' => true,
'rewrite' => array( 'slug' => 'products', 'with_front' => false, 'hierarchical' => true ),
'show_in_rest' => true,
'show_admin_column' => true,
) );
// 扁平标签:应用场景
register_taxonomy( 'product_tag', 'product', array(
'labels' => array( 'name' => '应用场景' ),
'public' => true,
'hierarchical' => false,
'rewrite' => array( 'slug' => 'product-tag' ),
'show_in_rest' => true,
) );
}
注意 with_front => false:不设的话,如果固定链接结构里带了 /blog 前缀,产品 URL 会变成 /blog/products/xxx,非常难看。hierarchical => true 在 rewrite 里能让子分类 URL 变成 /products/controller/x200/ 这种多级形式。
步骤 3:刷新固定链接
注册完必须刷新一次重写规则,否则前台 404:
wp rewrite flush --hard
代码层面则只在插件激活时刷:
<?php
register_activation_hook( __FILE__, function() {
mmf_register_product();
flush_rewrite_rules();
} );
不要挂在 init 上每次请求都刷,那是常见的性能杀手。
步骤 4:建归档模板 archive-product.php
<?php
get_header(); ?>
<main class="archive-product">
<header class="archive-header">
<h1><?php post_type_archive_title(); ?></h1>
<?php the_archive_description( '<div class="archive-desc">', '</div>' ); ?>
</header>
<?php
// 分类筛选导航:用链接而非 AJAX,可被收录
$terms = get_terms( array(
'taxonomy' => 'product_cat',
'hide_empty' => true,
'parent' => 0,
) );
if ( ! is_wp_error( $terms ) && $terms ) : ?>
<nav class="product-filter" aria-label="产品分类筛选">
<a href="<?php echo esc_url( get_post_type_archive_link( 'product' ) ); ?>" class="is-current">全部</a>
<?php foreach ( $terms as $term ) : ?>
<a href="<?php echo esc_url( get_term_link( $term ) ); ?>">
<?php echo esc_html( $term->name ); ?>
<span class="count">(<?php echo (int) $term->count; ?>)</span>
</a>
<?php endforeach; ?>
</nav>
<?php endif; ?>
<div class="product-grid">
<?php if ( have_posts() ) : ?>
<?php while ( have_posts() ) : the_post(); ?>
<article class="product-card">
<a href="<?php the_permalink(); ?>">
<?php if ( has_post_thumbnail() ) {
the_post_thumbnail( 'mmf-card', array(
'loading' => 'lazy',
'alt' => esc_attr( get_the_title() ),
) );
} ?>
<h2><?php the_title(); ?></h2>
</a>
<?php
$model = get_post_meta( get_the_ID(), '_product_model', true );
if ( $model ) {
echo '<p class="model">型号:' . esc_html( $model ) . '</p>';
}
?>
</article>
<?php endwhile; ?>
<?php else : ?>
<p>暂无产品。</p>
<?php endif; ?>
</div>
<?php the_posts_pagination( array( 'mid_size' => 2 ) ); ?>
</main>
<?php get_footer();
the_posts_pagination() 会自动生成带 paginate_links() 的分页,且支持 /page/2/ 的规范 URL。
步骤 5:调整归档每页数量
主查询的数量不要用 WP_Query 另开一个循环,应该用 pre_get_posts:
<?php
add_action( 'pre_get_posts', 'mmf_product_archive_query' );
function mmf_product_archive_query( $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( is_post_type_archive( 'product' ) || is_tax( 'product_cat' ) ) {
$query->set( 'posts_per_page', 24 );
$query->set( 'orderby', 'menu_order title' );
$query->set( 'order', 'ASC' );
}
}
用 menu_order 排序配合 page-attributes 支持,可以让客户在后台拖动排序(需安装排序插件或自行实现),比按发布时间更符合产品展示逻辑。
步骤 6:建详情页模板 single-product.php
<?php
get_header(); ?>
<main class="single-product">
<?php while ( have_posts() ) : the_post(); ?>
<article <?php post_class(); ?>>
<h1><?php the_title(); ?></h1>
<?php
// 面包屑:首页 > 产品中心 > 分类 > 当前
$terms = get_the_terms( get_the_ID(), 'product_cat' );
if ( $terms && ! is_wp_error( $terms ) ) {
$term = array_shift( $terms );
echo '<nav class="breadcrumb"><a href="' . esc_url( home_url( '/' ) ) . '">首页</a> › '
. '<a href="' . esc_url( get_post_type_archive_link( 'product' ) ) . '">产品中心</a> › '
. '<a href="' . esc_url( get_term_link( $term ) ) . '">' . esc_html( $term->name ) . '</a> › '
. '<span>' . esc_html( get_the_title() ) . '</span></nav>';
}
?>
<div class="product-gallery">
<?php the_post_thumbnail( 'mmf-wide', array( 'loading' => 'eager' ) ); ?>
</div>
<div class="product-content">
<?php the_content(); ?>
</div>
<?php
// 参数表
$spec = get_post_meta( get_the_ID(), '_product_spec', true );
if ( is_array( $spec ) && $spec ) : ?>
<table class="product-spec">
<caption>技术参数</caption>
<tbody>
<?php foreach ( $spec as $row ) : ?>
<tr>
<th scope="row"><?php echo esc_html( $row['name'] ); ?></th>
<td><?php echo esc_html( $row['value'] ); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<div class="product-cta">
<?php echo do_shortcode( '[contact-form-7 id="123" title="产品询价"]' ); ?>
</div>
</article>
<?php the_post_navigation( array(
'prev_text' => '上一个产品:%title',
'next_text' => '下一个产品:%title',
) ); ?>
<?php endwhile; ?>
</main>
<?php get_footer();
首图用 loading => 'eager',因为详情页的 LCP 通常就是它;网格里的卡片图才用 lazy。
步骤 7:参数字段的存取
不用 ACF 也能做,用自定义字段 + 简单的元框:
<?php
add_action( 'add_meta_boxes', function() {
add_meta_box( 'mmf-product-spec', '产品参数', 'mmf_spec_box', 'product', 'normal', 'default' );
} );
function mmf_spec_box( $post ) {
wp_nonce_field( 'mmf_save_spec', 'mmf_spec_nonce' );
$spec = get_post_meta( $post->ID, '_product_spec', true );
$spec = is_array( $spec ) ? $spec : array();
?>
<table class="form-table">
<?php for ( $i = 0; $i < 8; $i++ ) :
$name = $spec[ $i ]['name'] ?? '';
$value = $spec[ $i ]['value'] ?? ''; ?>
<tr>
<td><input type="text" name="mmf_spec_name[]" value="<?php echo esc_attr( $name ); ?>" placeholder="参数名" style="width:100%"></td>
<td><input type="text" name="mmf_spec_value[]" value="<?php echo esc_attr( $value ); ?>" placeholder="参数值" style="width:100%"></td>
</tr>
<?php endfor; ?>
</table>
<?php
}
add_action( 'save_post_product', 'mmf_save_spec' );
function mmf_save_spec( $post_id ) {
if ( ! isset( $_POST['mmf_spec_nonce'] )
|| ! wp_verify_nonce( sanitize_key( $_POST['mmf_spec_nonce'] ), 'mmf_save_spec' ) ) {
return;
}
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
$names = isset( $_POST['mmf_spec_name'] ) ? (array) wp_unslash( $_POST['mmf_spec_name'] ) : array();
$values = isset( $_POST['mmf_spec_value'] ) ? (array) wp_unslash( $_POST['mmf_spec_value'] ) : array();
$spec = array();
foreach ( $names as $i => $name ) {
$n = sanitize_text_field( $name );
$v = sanitize_text_field( $values[ $i ] ?? '' );
if ( '' !== $n && '' !== $v ) {
$spec[] = array( 'name' => $n, 'value' => $v );
}
}
update_post_meta( $post_id, '_product_spec', $spec );
}
字段名带下划线前缀(_product_spec)在后台自定义字段列表里默认隐藏,界面更干净。
步骤 8:批量导入与验证
# 批量导入(配合 wp post create 循环,或用官方 importer)
wp post list --post_type=product --fields=ID,post_title,post_name --format=csv > /tmp/products.csv
# 验证 URL 与分页
curl -sI https://example.com/products/ | head -3
curl -sI https://example.com/products/page/2/ | head -3
常见踩坑
- CPT 声明在主题里:换主题后全部 404。必须放插件(判断依据见主题迁移相关实践)。
- 注册后不刷新固定链接:典型症状是归档页正常、详情页 404。
- 归档页用新建
WP_Query覆盖主查询:分页参数失效,第二页永远显示第一页的内容。必须用pre_get_posts。 - 分类法 slug 与 CPT slug 冲突:都写
products时,子分类和单个产品可能互相抢 URL。让分类 rewrite 带hierarchical => true可以缓解,但不绝对,必要时给分类单独 slug。 - 案例: 某站点把产品 CPT 的
has_archive设为true,固定链接结构是/%category%/%postname%/,结果产品 URL 变成/uncategorized/xxx/。修法是rewrite加'with_front' => false并显式指定slug。教训:with_front参数在非默认固定链接结构下必配。 - 参数字段存成序列化数组后直接搜索:
WP_Query的meta_query对序列化数组只能做LIKE匹配,效率极低且不准确。需要筛选的参数应做成独立字段或分类法。 - 详情页首图加了
loading="lazy":LCP 变差,Lighthouse 分数下降。首图必须是 eager。
总结
产品中心的搭建步骤:注册 CPT 与分类法(放插件)→ 刷新固定链接 → 写 archive-product.php(含分类筛选)→ 用 pre_get_posts 调每页数量 → 写 single-product.php(含面包屑与参数表)→ 做参数元框 → 批量导入并验证 URL 与分页。
验收标准:每个产品有独立 URL 且可单独收录;归档页分页正常;分类页 URL 语义清晰;详情页首图未被懒加载;所有输出点都做了转义。
产品页结构定好后再做案例模块,两者通过「产品 ↔ 案例」互链形成内链主干;性能方面参考 WordPress主题资源精简清单 控制列表页的请求数,模板安全写法见 WordPress主题安全模板写法。
