大多数 WordPress 插件开发都沿着同一条弧线走。有人需要一个预约表单、一个内容抓取器,或者结算页上的一个额外字段,开发者写了出来,它能用,大家各自忙别的去了。两年后,这个站点被困在一个旧版本的 WordPress 上,因为没人有把握那个插件能挺过一次更新,而写它的人早就走了。
原因很少是核心跑得太快。WordPress 在破坏兼容这件事上非常保守,五年前写得像样的插件,今天不改一行仍然跑在 WordPress 7.1 上。插件会坏,坏在头一个星期定下的几个决定:功能被放进了主题,该挂钩子的地方直接改了核心文件,数据被塞进了手边最方便的结构,以及从来没有人拿候选发布版试过。
一个定制 WordPress 插件靠什么才能扛住核心更新? 靠四件事。代码住在插件里而不是主题里。它通过动作和过滤器扩展 WordPress,而不是改核心文件。每一份数据都存放在与这份数据的形状相符的结构里。以及在每个版本正式发布之前,有人拿它的候选发布版做过测试。
为什么 WordPress 插件开发属于插件而不属于主题
定制代码的默认落脚点是主题的 functions.php,因为它本来就在那儿,而且本来就会执行。它同时也是下一次改版时会消失的那个文件。
主题负责呈现。换掉主题,旧主题所做的一切都会停下。自定义文章类型不再被注册,内容就带着没有后台界面、没有固定链接的状态躺在数据库里。短代码在页面中间以纯文本的样子显示出来。统计代码、结构化数据标记,还有每晚发给 CRM 的那次调用,全都随之消失,而且不会报任何错。
这条规则简单到可以直接写进需求书。凡是改版之后仍然必须成立的东西,都属于插件:自定义文章类型和分类法、与任何外部系统的对接、短代码和区块、业务规则、定时任务,以及一切会写数据库的东西。主题只留下模板、样式和模板片段。
账单来得很晚。到下一次改版的时候,你要么再花一次钱把已经存在的东西重做一遍,要么把旧的 functions.php 原样搬过去。在一个积攒了好几年代码片段的站点上,这就是几千英镑本可以省下的工作量,也是改版报价回来时是客户预期两倍的原因。子主题依然是主题。
扩展模型,以及唯一真正要紧的那条规则
WordPress 天生就是要从外部改动的。这个机制就是钩子,钩子文档把它们描述为一段代码可以与另一段代码交互或对其做出修改的预设位置。动作在某个确定的时刻触发,让你去做一件事:在文章发布之后发通知,或者注册一个文章类型。过滤器把一个值交给你,期待你改动它或者原样放过,然后期待你把它交还回去。
由此得出的规则是绝对的。如果你正在编辑 wp-admin、wp-includes 或者别的插件目录里的文件,你已经输了。那些改动会被下一次更新抹掉,没有警告,没有报错,而且通常要等到客户反馈某个功能不工作了才会被发现。雇人之前,把这个问题直接问出来。
当你需要的钩子并不存在时,可以包裹既有行为而不是替换它,可以往上退到一个更宽的钩子,可以在版本控制之下 fork 那个第三方插件并把差异记录下来,或者向上游请求增加这个钩子,现在大多数钩子就是这么来的。
命名、前缀,以及一个非常拥挤的命名空间
WordPress 里的 PHP 运行在一个单一的全局命名空间中,与核心、当前主题以及其他每一个启用的插件共用。两个都声明了 get_settings() 函数的插件不会客气地互相谦让:第二个会变成致命错误,站点一片空白。
前缀比你以为的要长
手册的插件最佳实践页面要求给每一个全局可访问的东西加上唯一前缀,至少四个字符,最好五个,避开常见英文单词,并且绝对不要用 wp_、_ 或者 WordPress 本身。在有几万个插件流通的情况下,拿客户名字的三个首字母当前缀就是在掷硬币。
命名空间与自动加载
现代做法解决了问题的一半。声明一个 PHP 命名空间,一个文件放一个类,让 PSR-4 自动加载器去找它们,于是不再需要手写的 require 语句,类名也没有机会和别的插件撞上。这还让代码变得可测试,因为通过构造函数接收依赖的类不必加载 WordPress 就能实例化。
自动加载解决不了两个插件各自附带同一个库的不同版本这件事。先加载的那个赢。凡是要对外分发的东西,在构建阶段就给第三方库的命名空间加上前缀。
命名空间帮不上忙的那些字符串
命名空间管的是 PHP 符号。插件注册的东西里有很大一部分并不是 PHP 符号,而是写进共享注册表的字符串,那些仍然需要老式的前缀约定:钩子名、选项和瞬态的键、文章元数据的键、文章类型和分类法的名称、短代码标签、定时任务名称、REST 命名空间,以及自建表的表名。它们住在一个扁平的空间里,要么最后注册的那个赢,要么两个插件悄悄地共用同一份状态。
给任何东西命名之前,有两个限制值得先知道。文章类型的键不得超过 20 个字符,分类法的键不得超过 32 个,两者都只能用小写字母数字加连字符和下划线。一个五字符的前缀给文章类型名留下 15 个字符,这比听上去要局促得多。
选择数据住在哪里
这是尾巴拖得最长的一个决定。选错了,插件在上线时一切正常,随着数据增长每个月都慢一点,等到有人注意到的时候,修法已经不是改代码而是做数据迁移了。
选项与瞬态
选项是给全站设置用的:键就那么几个,值很小,大多数请求都会读到。陷阱在于自动加载,因为每一个被标记为自动加载的选项,都会在每一次请求里被取出来,包括 admin-ajax 和 REST 调用,不管有没有东西真的要用它。
WordPress 6.6 改了这套机制,Make WordPress Core 关于为大选项关闭自动加载的文章把细节写清楚了。现在存下来的值是 on、off 或 auto,大于 150,000 字节的选项默认不再自动加载,而这个阈值可以通过 wp_max_autoloaded_option_size 过滤器调整。把它当成上限,不要当成目标。瞬态就是带过期时间的选项,凡是从外部取回来的东西都该放在那里。
文章元数据不是键值存储
文章元数据是用来描述某一篇文章的属性的:副标题、价格、供应商编号。它不是通用的键值存储,原因在表结构里看得一清二楚。wp_postmeta 表有四个字段和三个键。建了索引的只有 post_id 和 meta_key 的前 191 个字符。meta_value 字段是 longtext,上面完全没有索引。
因此,按元数据的值来筛选的查询根本用不上索引。元数据查询里每多一个条件就多一次连接,在一个有 50,000 篇文章、每篇带 20 行元数据的站点上,这张表有一百万行。三个条件就意味着每次打开页面都要对一百万行做三次连接。这是一个第一年很快的站点到了第三年变得没法用的最常见原因之一,在WooCommerce 性能优化的工作里反复出现。
自定义文章类型与分类法
当那个东西本身就是内容时,自定义文章类型才是对的。它需要自己的列表页、固定链接、修订记录和编辑流程,并且作为一个别人可以访问的页面是说得通的。当你需要一套共用词汇去归拢这些东西、而且这套词汇值得拥有自己的归档页时,自定义分类法才是对的。
两者都免费带来一整套机器:后台界面、权限、搜索、区块编辑器和 REST API。把 show_in_rest 设为 true,否则区块编辑器不会处理这个类型;两者都要在 init 钩子上注册,绝不能更早。
什么时候你真的需要自己的表
当数据不是内容时,自己的表才是对的:事件日志、导入队列、价格历史、审计轨迹这类只追加的大量记录,或者任何你要按非文章字段去筛选和排序的东西。超过几十万行、并且按它自己的字段来查询之后,一张索引建对了的表会以数量级的差距胜过文章元数据,而且随着增长依然可预测。
代价是所有东西都归你自己管:建表和带版本的迁移、uninstall.php 里的清理、自己的后台界面、REST 端点和缓存。所以对大多数插件来说,诚实的答案仍然是自定义文章类型。
安全是四个习惯,其中三个会被跳过
WordPress 安全手册把原则说得很直白:不要相信用户输入,不要相信第三方 API,也不要相信已经躺在你数据库里的数据。四个习惯承担了几乎全部风险,而在我们审计过的插件里,它们被跳过的顺序高度一致。先是权限检查,其次是 nonce,第三是输出转义。预处理语句排在最后,因为漏掉一个会在代码评审里被抓出来。
权限检查
每一个会改动东西的处理函数都必须先问一句这个用户是否被允许,也就是带上具体权限调用 current_user_can(),并且是在处理函数内部检查,而不是只在调用它的那个按钮周围检查。
is_admin() 不是权限检查。它报告的是这个请求落在站点的哪一侧,对任何一个访问 admin-ajax 端点的已登录订阅者都会返回 true。一个没有权限检查的 admin_post_ 或 wp_ajax_ 动作,对每一个注册用户都是可达的,在商店里这意味着每一个下过单的顾客。围绕这一点的站点层面控制,我们在WordPress 安全加固清单里讲过。
nonce
nonce 保护一个表单或一个网址,免于用户并不打算发出的请求。在表单里用 wp_nonce_field(),在处理函数里用 check_admin_referer(),AJAX 则用 check_ajax_referer()。别被名字骗了,它们并不是一次性的:它们是在一个时间窗口内有效的哈希,默认是一天,由于采用两格计时的方案,真实寿命落在 12 小时到 24 小时之间。
nonce 文档明确写着,绝不能把认证、授权或访问控制寄托在它们身上。nonce 确立的是这个请求来自你的表单,它对这个人是否应该被允许做这件事只字未提。
进来时净化,出去时转义
能验证的地方就验证,因为验证是具体的:一个邮编要么符合规则,要么不符合。不能验证的地方就净化,按字段的不同选用 sanitize_text_field()、sanitize_email()、sanitize_key()、absint() 或 wp_kses_post()。
然后在输出的那一刻转义,每一次都转义,用 esc_html()、esc_attr()、esc_url() 或 wp_kses_post()。转义文档要求尽可能晚地做这件事,这样评审的人能在同一行里同时看到转义和输出。
转义比任何别的环节都更常被跳过,因为跳过它的时候看不出任何异常。页面会一直显示得好好的,直到有人在某个输入框里塞进一个 script 标签。
预处理语句
任何你自己写的查询都要走 $wpdb->prepare(),它用 %d 表示整数,%f 表示浮点数,%s 表示字符串,%i 表示表名列名这类标识符。占位符不要加引号,字面的百分号要写两遍,LIKE 的通配符要放进替换参数里传进去,而不是直接敲在查询语句上。把变量拼进 SQL 不是风格之争,它就是那个漏洞。
REST API 与区块编辑器
今年写出来的插件,应该通过 REST API 暴露它的数据,通过编辑器暴露它的设置,而不是靠一个手工搭的选项页。
路由在 rest_api_init 钩子上用 register_rest_route() 注册。从 WordPress 5.5 起,permission_callback 参数是必填的,省略它会触发一条点名该路由的 _doing_it_wrong() 通知。一个真的要公开的端点用 __return_true,这正是这个设计的用意:把一个路由变成公开的,从一次疏漏变成了一行刻意写下的代码。自定义端点文档还讲了参数模式,净化和验证回调应当放在那里,这样脏数据永远到不了你的处理函数。
设置用 register_setting() 注册,并把 show_in_rest 设为 true。这样它们就出现在核心的设置端点上,区块编辑器或者外部脚本可以通过一个已经处理好认证、权限和验证的接口去读写它们。这省掉了一个选项页、它的 nonce、它的表单处理函数,以及住在里面的那些 bug。
区块从一个 block.json 文件注册,自 WordPress 5.8 起这是推荐的规范做法。区块元数据文档说明了好处:在那里声明的资源只会在这个区块真正出现的页面上加载,而不是因为插件被启用就在全站加载。
插件内部的性能纪律
我们在审计中发现的、由插件造成的慢,大部分可以归到四件事上,四件都是躲开很便宜、事后补救很贵。第一件是自动加载的选项,因为它们会在此后的每一次请求里持续收费。
第二件是页面加载期间未经缓存的远程请求。向供应商 API 发一个没有缓存的 wp_remote_get(),意味着每一位访客都在等那家供应商。供应商慢,你的站点就慢;供应商挂了,你的站点就一直卡到超时为止。把响应缓存进瞬态,设一个明确的超时时间,并且提前想好这次调用失败时页面显示什么。
第三件是循环里的查询。为 200 行数据里的每一行调用 get_post_meta(),在元数据缓存没有预热的情况下就是 200 次往返,而只要你不去拦着,WP_Query 会替你把它预热好。修法通常是别再去关掉某个东西,这一点对Core Web Vitals 审计里的大多数发现同样成立。
第四件是被塞进某个人的页面请求里去做的活。WP-Cron 不是系统 cron:它由页面加载触发,所以一个定时任务是在访客的请求里跑的,而在一个冷清的站点上,两点该跑的任务要等到五点有人来访才会跑。定义 DISABLE_WP_CRON,用真正的系统调度器去驱动 wp-cron.php,并且让每个任务保持短小、可重复执行而结果不变。
挺过核心更新,没人会为此留预算的那部分
核心很少直接删掉什么。函数会被标为废弃,继续工作,并发出一条通知,这正是让预发布环境开着 WP_DEBUG 运行成为现成最便宜的预警系统的原因。一条废弃通知,是一封带日期的邀请函,请你趁修起来还便宜的时候动手。
那套能避免意外的流程,一个季度大约花一小时。跟着核心开发博客走,好知道什么时候出了 beta、什么时候出了候选发布版。读候选发布阶段发布的字段指南,那里列着这个版本面向开发者的新特性和破坏性变更。然后把候选发布版装到一份预发布副本上,按一份写下来的冒烟测试跑一遍插件的真实功能。
支持的版本和代码本身一样要紧。WordPress 要求的 PHP 绝对下限是 7.4,推荐 8.3 或更新,同时要求 MariaDB 10.11 或 MySQL 8.0。在插件头部老老实实写上 Requires PHP 和 Requires at least,然后拿你声明的最低版本去测,而不是拿开发者笔记本上跑的那个版本去测。
给你自己的插件打语义化版本号,并且认真对待它。补丁版修好某个东西,次版本在不破坏任何东西的前提下增加行为,主版本可以破坏,前提是它说清楚破坏了什么。开着自动更新的客户,靠的就是这个承诺。
分发、许可,以及更新怎样到达站点
WordPress 以 GPL 第二版或更新版本发布,wordpress.org 的许可页面阐述了项目的立场,即插件和主题是继承该许可的衍生作品,同时也承认在什么算作衍生这件事上存在法律灰色地带。
你永远拿得到源码,也可以雇任何别人来改它。GPL 不做的事情是强迫你把它公开,所以为一家企业做的插件可以一直是私有的。它同样不阻止开发者把同一份成果卖给别人。如果独占性很重要,那是合同条款,不是许可条款。
如果插件要进公开目录,它必须满足插件目录规范,一共 18 条。第一条要求包里的所有东西,包括图片,都使用与 GPL 兼容的许可。其他条款排除了试用件,也就是把功能锁在付费或升级之后,禁止混淆过的代码,禁止未经同意追踪用户,并且禁止未经许可在公开站点上添加链接或署名。
如果它保持私有,更新就成了你自己的问题。设置 Update URI 头,它存在的意义就是防止一个私有插件被目录里名字相近的插件覆盖掉,然后从你自己的端点提供更新。把这件事留到最后,客户就会落到用 FTP 更新的地步。
一个定制 WordPress 插件要花多少钱
下面这些区间是英国代理商以英镑计的价格,针对的是按本文所述标准交付的工作:带测试、带文档,并且上线之后有一位指名的负责人。一位胜任 WordPress 与 PHP 的开发者大约按每天 £400 到 £600 计费,所以这些数字说的是工作范围,不是费率。
一个小型工具类插件在 £1,500 到 £3,000 之间。做一件事,挂几个钩子,也许有一个设置开关:处理重定向、在订单上加一个字段、每晚向供应商导出一次数据。
一个中等规模的对接在 £3,000 到 £15,000 之间。带认证、重试和错误处理的第三方 API,一个自定义文章类型,若干后台界面,以及后台处理。这是最常被委托的规模,也是最常被低估的规模,因为对接本身是一周,把失败处理干净是两周。
一个成规模的产品级插件在 £20,000 到 £75,000 甚至更高。它有自己的数据表、区块编辑器界面、授权与更新基础设施、多站点支持,以及从上线第一天就开始的支持负担。
按低价成交并不自动等于错。它错在这个价格来自一份悄悄剔除了下述交付物的工作范围。我们关于WordPress 开发者报价与该问什么的指南讲了怎么读一份报价,而我们的WordPress 开发页面写明了我们如何界定这类工作。
交付物里应该有什么
在开工之前把下面每一项都写进书面约定,因为它们每一项在当下加入都很便宜,事后补上都很贵。源码,放在你自己拥有的代码仓库里,提交历史完整,而不是最后一天用邮件发来的一个压缩包。业务规则的单元测试,以及任何会写数据库或调用外部服务的部分的集成测试,正是这些东西让两年后一位当初不在场的人也敢动它。
一份说明文档,写清楚这个插件做什么、挂了哪些钩子、把什么存在哪里、调用哪些外部服务,以及每一个外部服务失败时会发生什么。两页就足够,而它的缺席正是插件被替换而不是被维护的原因。一个会清掉选项、数据表、定时任务和元数据的 uninstall.php。还有一份指名到人的支持约定,覆盖针对每一个核心版本的测试,以及修复测试发现的问题。
把它做出来
Mecanik 承接上述三种规模的插件开发,也接手别人写的插件,后者往往是更有价值的合作。我们的WordPress 开发者与软件开发页面说明了我们如何界定范围与交付。如果你手上已经有一个没人愿意碰的插件,按上述实践做一次审计大约需要一天,它会告诉你这个插件是可修的还是该换的。
常见问题
定制功能应该放在插件里还是主题里? 放在插件里,除非它纯粹是呈现层的东西。主题会在下一次改版时被换掉,它当时做的一切都会停下:自定义文章类型失去后台界面,短代码变成纯文本显示,对接则悄无声息地不再运行。凡是改版之后仍然必须成立的东西,都属于插件。
在英国做一个定制 WordPress 插件要花多少钱? 一个小型工具类插件通常是 £1,500 到 £3,000,一个带第三方 API 和后台界面的中等规模对接是 £3,000 到 £15,000,一个自带数据表与更新基础设施的产品级插件是 £20,000 到 £75,000 或更高。能胜任这类工作的开发者大约按每天 £400 到 £600 计费。
有没有可以接受的情况去修改 WordPress 核心或别的插件的文件? 没有。那些改动会被下一次更新抹掉,不会报错,而且通常要等到某个功能不工作了才会被发现。请改用动作和过滤器。如果你需要的钩子不存在,就包裹既有行为,在版本控制之下 fork 那个插件,或者向上游请求增加这个钩子。
我花钱请人做的插件属于我吗? 你拥有你手上的这一份,以及合同里写明的那些权利。GPL 给你源码、修改它的权利,以及雇任何别人来维护它的权利,并且不要求你公开它,所以为一家企业做的插件可以一直是私有的。它不阻止开发者把同一份成果再卖一次,所以如果独占性重要,就把它写进合同。
怎样才能让插件在 WordPress 更新时不出问题? 在每个版本正式发布之前,先在预发布副本上拿它的候选发布版测一遍;让预发布环境开着 WP_DEBUG 运行,好让废弃通知尽早浮现;并在插件头部声明它支持的 PHP 与 WordPress 版本。WordPress 要求 PHP 7.4 作为下限,推荐 8.3 或更新。
评论