大多數 WordPress 外掛開發都走同一條弧線。有人需要一份預約表單、一個內容匯入器,或是結帳頁上多一個欄位,開發者把它寫出來,它能用,大家各自忙別的去了。兩年後,這個網站被困在一個舊版本的 WordPress 上,因為沒有人有把握那個外掛能撐過一次更新,而寫它的人早就離開了。

原因很少是核心跑得太快。WordPress 在破壞相容性這件事上非常保守,五年前寫得像樣的外掛,今天一行都不用改仍然跑在 WordPress 7.1 上。外掛會壞,壞在第一個星期定下的幾個決定:功能被放進佈景主題、該掛鉤的地方直接改了核心檔案、資料被塞進手邊最方便的結構,以及從來沒有人拿發行候選版本試過。

一個客製化 WordPress 外掛靠什麼才能撐過核心更新? 靠四件事。程式碼住在外掛裡而不是佈景主題裡。它透過動作與過濾器擴充 WordPress,而不是去改核心檔案。每一份資料都放在與這份資料的形狀相符的結構裡。以及在每個版本正式推出之前,有人拿它的發行候選版本測過。


為什麼 WordPress 外掛開發屬於外掛而不屬於佈景主題

客製程式碼的預設落腳處是佈景主題的 functions.php,因為它本來就在那裡,而且本來就會執行。它同時也是下一次改版時會消失的那個檔案。

佈景主題負責呈現。換掉佈景主題,舊的佈景主題所做的一切都會停下。自訂文章類型不再被註冊,內容就帶著沒有後台畫面、沒有永久連結的狀態躺在資料庫裡。簡碼在頁面中間以純文字的樣子顯示出來。分析程式碼、結構化資料標記,還有每晚發給 CRM 的那次呼叫,全都跟著消失,而且不會出現任何錯誤。

這條規則簡單到可以直接寫進需求書。凡是改版之後仍然必須成立的東西,都屬於外掛:自訂文章類型與分類法、與任何外部系統的整合、簡碼與區塊、業務規則、排程工作,以及一切會寫進資料庫的東西。佈景主題只留下範本、樣式與範本片段。

帳單來得很晚。到了下一次改版,你要嘛再付一次錢把已經存在的東西重做一遍,要嘛把舊的 functions.php 原封不動搬過去。在一個累積了好幾年程式碼片段的網站上,這就是好幾千英鎊本來可以省下的工作量,也是改版報價回來時是客戶預期兩倍的原因。子佈景主題仍然是佈景主題。

擴充模型,以及唯一真正要緊的那條規則

WordPress 天生就是要從外部改動的。這個機制就是掛鉤,掛鉤說明文件把它們描述為一段程式碼可以與另一段程式碼互動或加以修改的預設位置。動作在某個確定的時刻觸發,讓你去做一件事:在文章發佈之後送出通知,或是註冊一個文章類型。過濾器把一個值交給你,期待你改動它或原樣放過,然後期待你把它交還回來。

由此得出的規則是絕對的。如果你正在編輯 wp-adminwp-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 關於為大型選項關閉自動載入的文章把細節寫清楚了。現在存下來的值是 onoffauto,大於 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、它的表單處理函式,以及住在裡面的那些錯誤。

區塊從一個 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 PHPRequires 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 或更新。