來源:Alibaba F2E公衆号
作者:燒鵝
如果你看過 Vue.js 的紀錄片,就會發現一個開源産品的成功不僅僅是優質的代碼,而且還需要:清晰的文檔、不土的審美、持續的疊代、定期的布道、大佬的站台……今天來聊聊“文檔”這件看似簡單卻需要精心雕琢的小事。

圖檔來源于網絡
衆所周知,寫完代碼和單測隻是保證了功能的實作,而面向使用者最重要的東西就是文檔。文檔在我們生活中是最司空見慣的東西,比如你買了一台吸塵器,一定會有一本說明書告訴你怎麼操作,而不是跟你說:“想知道怎麼操作嗎?把吸塵器拆開自己看看工作原理吧!”。然而在軟體領域,讓使用者看源碼這種荒唐的事天天在發生。懂得文檔重要性的程式員才是好的推銷員。
言歸正傳,當開始營運團隊裡的開源圖形引擎 Oasis Engine 的時候,我才發現做出一個簡潔好用的文檔比想象得難很多。
Oasis Engine: https://oasisengine.cn/
選型
在開源之前,我們把文檔放在語雀上。語雀作為知識管理平台确實很不錯,如果維護得當,積矽步可以至千裡。但它也有兩方面的缺點:
- 無法承載 API 文檔、代碼示例等複雜形态的需求;
- 無法滿足個性化的設計。總而言之,一個開源産品如果連個獨立的網站都沒有,有點說不過去。
通過了解一些知名的開源作品的網站後,我發現他們大部分都選擇把網站部署在 Github Pages 上。這很符合我們團隊的想法,既然引擎都開源了,文檔當然也應該開源,讓全世界的開發者一起來維護。經過調研,我找到了一些能夠把搭建 GIthub Pages 的靜态網站架構:
- Jekyll:Github Pages 官方推薦的老牌架構,但是它依賴 Ruby,我覺得在 Mac OSX 下使用 Ruby 很麻煩,遂放棄。
- Vuepress:我的同僚 ZS 開發的基于 Vue 的架構,雖然我對 Vue 很推崇并且敬佩 ZS 的實力,但是它的資料源限于 Markdown。
- Dumi:也是我廠另一位大神開發的基于 React 的架構,它為元件開發場景而生,其中的 dumi-theme-mobile 挺打動我的,但它的 Demo 預覽能力群組件是耦合的,并不能滿足圖形引擎中非元件 Demo 的預覽;另外它的預設樣式有點粗糙。
- Docsify:一個非常小清晰的網站生成器(它的 logo 太可愛了),之是以沒有提到 Gitbook,是因為 Docsify 不僅擁有 Gitbook 的功能,而且可以直接在運作時解析 Markdown 檔案,不需要編譯環節,使用起來非常簡單。
開源在即,為了快速上線網站,我最終選擇了 Docsify。事實證明,這是個倉促的選擇。當時用了三套工程方案把三種完全不同的資料都編譯成 HTML 後組合在一起:Docsify 解決了把文檔編譯成 HTML 的問題,Typedoc 解決了把 API 編譯成 HTML 的問題,Demosify 解決了把示例編譯成 HTML 的問題。
除了三個工程強扭在一起之後整個網站風格不統一之外,更要命的是我們還把 API(在引擎倉庫)、教程文檔、示例放在三個 Github 倉庫(美其名曰“潔癖”),每次更新都需要從三個倉庫拷貝内容到網站倉庫,維護成本非常高。開源之後第一個裡程碑疊代完,團隊已經被文檔折騰得筋疲力盡,完善文檔的積極性也降低了。
初心
2021年四月,距離 Oasis 引擎開源已經過去兩個月了,熱潮消退,褒貶的聲音已經淡去。期間有不少人回報我們部署在 Github Pages 上的站點打開很慢,尤其是示例頁面不翻牆根本打不開,我們沒有認識到網站工程的臃腫導緻了通路慢,還傻傻地以為 Github 就是慢,于是在 Gitee 上又部署了一個國内鏡像來緩解這個問題。
看了行業裡成功的圖形/遊戲引擎的網站:Unity、Unreal、Cocos、LayaAir、ThreeJS、BabylonJS,他們根據自己的定位、主打産品、發展階段、商業政策展現出不同的資訊架構和風格。而我們應該做成什麼樣呢?
回歸初心吧,少年!Oasis 引擎想成為前端友好、高性能的移動端圖形引擎,那麼我們的網站必須給人簡潔、可靠、極速的印象。四月快結束的時候,我猛然意識到:既然我們定位是面向前端的圖形引擎,為啥不朝着前端架構的模式做呢?我重新梳理一下網站的需求:
- 一體化:把 API 文檔(TypeDoc)、教程文檔(Markdown)、示例(Typescript)等不同格式的資料源放到一個站點,并且支援全局内容搜尋;
- 示例嵌入:支援在教程文檔中嵌入功能示例,并且支援跳轉到 Codepen 等流行線上開發環境中編輯;
- 多版本:不同引擎版本的文檔同時存在,支援版本切換;
- 國際化:支援中英語言。
梳理完畢,我發現要做的其實是個類似 Ant Design 的站點。這裡有個誤解,前文中提到 Dumi 的 dumi-theme-mobile,我錯誤地以為 Ant Design Mobile 的網站是基于 Dumi 實作的(而且 Ant Design Mobile 的作者也推薦我用 Dumi),而 Dumi 已經是調研過後的放棄的方案,又由于 Ant Design Mobile 和 Ant Design 的網站風格相似,我仍以為 Ant Design 也是用 Dumi 做的,直到發現 Ant Design Pro 的網站源碼,我才知道是基于 Gatsby 實作的。
開搞
接下去的内容雖然是這篇文章的主題,但可能比較無聊,事實上我完全可以省略上述心路曆程,把文章的标題改成《如何用 Gatsby 實作一個文檔網站》。然而,我想強調的是當一個人面對一個陌生的領域,勢必會走彎路,當回頭看的時候,這些彎路都是收獲。
發現 Gatsby 的時候,我十分興奮,以至于五一假期五天時間都在搗鼓這個工具;假期結束的時候,同僚們驚訝地發現我已經把網站的功能基本寫完了。那麼,Gatsby 到底是個什麼東西呢?它和上述選型中的其他方案有什麼差別呢?
我認為最本質的差別是:Gatsby 有一個叫 GraphQL 的中間資料層。不管你的輸入是什麼格式,隻要能轉成 GraphQL 格式的資料,就能在 Gatsby 中通過查詢語句擷取資料,最後渲染成 React 元件。比如,Oasis 引擎的官網就希望把 TypeDoc、Markdown、Typescript 格式的檔案資料轉成 React 元件:
相當完美的流程!這意味着資料和樣式解耦,原先各種格式都要通過不同的工具編譯成 HTML,現在可以通過一個工具轉成 React 元件,而 React 元件的樣式可以統一管控。
處理 TypeDoc 資料
TypeScript(TS) 是近幾年最流行的前端開發語言,出于代碼品質和可維護性的考慮,Oasis 引擎也采用了 TypeScript 編寫。TypeDoc 是社群中比較優秀的生成 TS API 文檔的工具,它能夠讀取 Typscript 的聲明資料并生成 HTML 網頁,但似乎很少人知道它其實有 Node module——也就是說隻用它的 Node API 讀取資料,渲染交給其他工具。
至此,聰明的讀者想必已經知道了:找一個 TypeDoc 轉 GraphQL 的工具。幸運的是,我在 Gatsby 的社群就找到一個 gatsby-source-typedoc 插件(Gatsby 的插件生态很茂盛),順藤摸瓜,又找了該插件作者寫的文章。有趣的是,文章作者是一個叫 Excalibur.js 的遊戲引擎的開發者,所謂同是引擎開發者,相逢何必曾相識,這就是猿糞啊。但是,我高興得有點早,因為文章提供的資訊非常有限。這個插件僅僅是幫你讀取 TypeDoc 的資料轉成 GraphQL,然後你自己
JSON.parse
資料,再然後 Please do something with that data by yourself...
export const pageQuery = graphql`
typedoc(typedocId: { eq: "default" }) {
internal {
content
}
}
`
export default function MyPage({ data: { typedoc } }) {
const typedocContent = JSON.parse(typedoc?.internal.content);
// do something with that data...
}
當時的想法是,反正 TypeDoc 的預設樣式也不好看,我就重寫一個渲染器吧......萬萬沒想到,這一重寫就是五一三天假期😭。主要原因是 TypeDoc 的資料類型挺複雜的,比如類型就有這麼多(可能還沒列全,終于能夠了解為啥 TypeDoc 官方的渲染器每次更新都有不小的變化):
export enum Kinds {
MODULE = 1,
ENUM = 4,
CLASS = 128,
INTERFACE = 256,
TYPE_ALIAS = 4194304,
FUNCTION = 64,
PROPERTY = 1024,
CONSTRUCTOR = 512,
ACCESSOR = 262144,
METHOD = 2048,
GET_SIGNATURE = 524288,
SET_SIGNATURE = 1048576,
PARAMETER = 32768,
TYPE_PARAMETER = 131072,
}
這裡說一下具體的步驟:
1.從 Oasis 引擎倉庫擷取資料源,就是入口級别的 index.ts 檔案。由于 Oasis Engine 是一個 monorepo 倉庫,要擷取每個子倉庫的 index.ts 的路徑,最後寫入到一個臨時檔案 tsfiles.js 裡:
const glob = require('glob');
const fs = require('fs');
glob(`${EngineRepoPath}/packages/**/src/index.ts`, {realpath: true}, function(er, files) {
var re = new RegExp(/([^test]+).ts/);
var tsFiles = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
var res = re.exec(file);
console.log('[Typedoc entry file]:', file);
if (!res) continue;
tsFiles.push(`"${file}"`);
}
fs.writeFile('./scripts/typedoc/tsfiles.js', `module.exports = [${tsFiles.join(',')}];`, function(err) {});
});
2.在 gatsby-config.js 中配置插件:
const DTS = require('./scripts/typedoc/tsfiles');
module.exports = {
plugins: [
{
resolve: "gatsby-source-typedoc",
options: {
src: DTS,
typedoc: {
tsconfig: `${typedocSource}/tsconfig.json`
}
}
}
]
}
3.打開
http://localhost:8000/___graphql 如果看到左側面闆中有 typedoc 說明資料讀取已經成功,勾選一下 internal> content 執行查詢,可以到詳細的資料:
4.接下去就是使用 gatsby 建立頁面,gatsby 提供了 createPages.js 入口編寫建立頁面的代碼,以下就是插件作者在文章中省略的
do something with that data...
部分的代碼:
async function createAPI(graphql, actions) {
const { createPage } = actions;
const apiTemplate = resolve(__dirname, '../src/templates/api.tsx');
const typedocquery = await graphql(
`
{
typedoc {
internal {
content
}
}
}
`,
);
let apis = JSON.parse(typedocquery.data.typedoc.internal.content);
// do something with that data...
const packages = apis.children.map((p) => {
return {
id: p.id,
kind: p.kind,
name: p.name.replace('/src', '')
};
});
if (apis) {
apis.children.forEach((node, i) => {
const name = node.name.replace('/src', '');
// 索引頁
createPage({
path: `${version}/api/${name}/index`,
component: apiTemplate,
context: { node, type: 'package', packages }
});
// 詳情頁
if (node.children) {
node.children.forEach((child) => {
createPage({
path: `${version}/api/${name}/${child.name}`,
component: apiTemplate,
context: { node: child, type: 'module', packages, packageIndex: i }
});
})
}
});
}
}
最終的結果,可以通路
https://oasisengine.cn/0.3/api/core/index。樣式是不是比 TypeDoc 預設的好看一點?可能有人會問:Typdoc 也可以直接轉成 Markdown,你為什麼大費周折呢?因為一個圖形引擎的複雜度相當高,API 有成千上萬個,如果用 Markdown 展示是非常難看的,是以 TypeDoc 的存在是有意義的。
在 Markdown 中嵌入 Demo
這是一個很樸素的需求,就是希望能在文檔中嵌入 Demo, 友善開發者了解文檔中描述的功能,增強文檔和示例的關聯性。這也是我們做面向前端的引擎必須具備的優勢,市面上大部分引擎網站都是文檔和示例分離的,更别說一些 Native 引擎想在網頁裡渲染都很難呢。比如材質文檔中講到 PBRMaterial,總得展示一下 PBR 材質的樣子吧。我們是搞圖形學的,又不是搞服務端的,隻是文字描述多麼幹澀啊。
可以負責任地告訴大家,我的五一假期剩餘兩天就是被這個功能消耗掉的😭。接下來說一下具體的實作思路。
首先,我想讓維護文檔的同學輕松一點,在 Markdown 檔案中嵌入一個 Demo 應該是一行代碼的事情,比如:
<playground src="pbr-helmet.ts"></playground>
多麼簡單優雅!可是問題來了:怎麼從 Markdown 中“提取”出這行代碼并最終渲染成想要的樣子呢?不要忘了 Markdown 本來就是 gatsby 的一項資料源,gatsby 正是通過 gatsby-transformer-remark 插件解析資料的,而資料的解析從原理上繞不過抽象文法樹,看了一下 graphiQL 果然有 AST 資料:
1.第一步,在文法樹中找到 <
playground
> 标簽替換成我想要的資料。于是,我就開始了人生的第一個 gatsby 插件 gatsby-remark-oasis 的編寫:
// `gatsby-remark-oasis` plugin:
// Extract <playground> from markdown AST and replace the content
const visit = require('unist-util-visit');
const fs = require('fs');
const Prism = require('prismjs');
module.exports = ({ markdownAST }, { api, playground, docs }) => {
visit(markdownAST, 'html', (node) => {
if (node.value.includes('<playground')) {
const src = /src="(.+)"/.exec(node.value);
if (src && src[1]) {
const name = src[1];
const path = `playground/${name}`
const code = fs.readFileSync(`./${path}`, { encoding: 'utf8' });
node.value = `<playground name="${name}"><textarea>${code}</textarea>${Prism.highlight(code, Prism.languages.javascript, 'javascript')}</playground>`;
}
}
});
return markdownAST;
};
這裡有人可能會覺得奇怪,既然已經把源碼塞入到 <
textarea
> (為了省去轉義的工作)中,為何引入一個 Prsimjs 再把代碼解析成 HTML 片段呢?
如果你分析一下上文中 Demo 的構成,會發現有兩部分構成:左邊是一個預覽,右邊是代碼片段,這個代碼片段就是通過 Prsimjs 美化生成的。如果我們在運作時使用 Prsimjs 也是可以的,但我們在插件裡完成解析就相當于在編譯期完成這項工作,可以避免運作時引入一個 Prsimjs 的包增加網頁體積。
2.完成上一步之後,資料終于到了 React 中,但 React 也不認識 <
playground
> 這個元件。于是,我們就需要另一個插件 gatsby-remark-component-parent2div 來把 <
playground
> 聲明成 React 元件:
{
resolve: 'gatsby-transformer-remark',
options: {
plugins: [
// Extract <playground> from html markdwon AST and replace the content
{
resolve: 'gatsby-remark-oasis',
options: {
api: `/${version}/api/`,
playground: `/${version}/playground/`,
docs: `/${version}/docs/`,
}
},
// convert <playground> to React Componennt
{
resolve: "gatsby-remark-component-parent2div",
options: {
components: ["Playground"],
verbose: true
}
},
],
},
},
注意這兩個插件使用的是 gatsby-transformer-remark 插件生成的資料,是以插件配置要嵌套在 gatsby-transformer-remark 的 plugins 裡,這是一條資料處理管線。
3.最後一步,我們在 React 代碼中把 <
playground
> 替換成真正的 <
Playground /
> React 元件,這一步通過使用 rehype-react 來實作:
import RehypeReact from "rehype-react";
import Playground from "../Playground";
const renderAst = new RehypeReact({
createElement: React.createElement,
components: { "playground": Playground }
}).Compiler;
export default class Article extends React.PureComponent<ArticleProps> {
render () {
return renderAst(this.props.content.htmlAst);
}
}
至于 <
Playground /
> 元件本身的編寫就相對簡單了。值得提一下的是這裡的左側 Demo 預覽其實是一個 iframe 嵌入的 html 頁面,為此我也通過 gatsby 的 createPages API 建立了很多 Demo 頁面。為了把 Typescript 示例檔案編譯成 React 頁面,我寫了第二個 gatsby 插件(實際更複雜,這裡隻展示最重要的 babel transform 部分,感興趣的可以看一下插件源碼):
// gatsby-node.js
const babel = require("@babel/core");
exports.onCreateNode = module.exports.onCreateNode = async function onCreateNode(
{ node, loadNodeContent, actions, createNodeId, reporter, createContentDigest }
) {
const { createNode } = actions
const content = await loadNodeContent(node)
// 省略了 babel 配置
const result = babel.transformSync(content, {...});
const playgroundNode = {
internal: {
content: result.code,
type: `Playground`,
},
}
playgroundNode.internal.contentDigest = createContentDigest(playgroundNode)
createNode(playgroundNode)
return playgroundNode
}
主體的功能完成之後,又加了一些小功能,比如在二維碼預覽、新頁面打開,以及 CodePen、CodeSandBox、Stackblitz 的跳轉編輯。這些小功能非常實用,既可以驗證功能的可靠性,又可以增強開發者的互動。
全局搜尋
前面說了圖形引擎的功能和 API 是非常多,特别對于深度使用引擎的開發者來說,如果沒有搜尋真的很痛苦。一開始我覺得這是個小功能,後來我發現确實也隻是個小功能:)不過這個功能讓我苦苦等了 20 天😵。
這裡用到了 Algolia Docsearch。Algolia 是一家提供雲搜尋服務的公司,簡單來說,Docsearch 的伺服器會每隔 24 小時爬取網站的資料,然後網站引入 Docsearch 的前端 SDK 通路爬取的資料。實作這樣的搜尋需要兩步:
1.去官網申請後,會收到一份郵件詢問你是否是網站管理者,是否能夠引入 Docsearch 的 前端SDK:
我自信地回複郵件“Yes, I can...”,然而從此以後杳無音信。過了半個月,此時我已經回複了三封郵件,依然沒有收到回複。于是我換了個郵箱申請,過了幾天終于收到了确認郵件,裡面包含了配置設定給 oasisengine.cn 的 apiKey。
2.收到 apiKey 後,我第一時間去驗證功能,發現搜尋結果并不是我期望的。和早期 SEO 優化一樣,想讓搜尋結果滿意,要麼網站根據爬蟲的預設規則修改網站内容,要麼修改爬蟲的爬取規則。Docsearch 為開發者提供了後者的選項,隻要提供一個配置檔案到這個 docsearch-configs 倉庫就可以。這裡展示一下比較關鍵的字段:
{
// 要爬取的頁面 url 比對規則
"start_urls": [
{
"url": "https://oasisengine.cn/(?P<version>.*?)/docs/.+?-cn",
"variables": {
"version": [
"0.3"
]
},
"tags": [
"cn"
]
},
],
// 爬取頁面中哪些 HTML 标簽的資料
"selectors": {
// 一級類目,這個很關鍵,搜尋的結果分類就可以根據這個實作的
"lvl0": {
"selector": ".docsearch-lvl0",
"global": true,
"default_value": "Documentation"
},
"lvl1": "article h1",
"lvl2": "article h2",
"lvl3": "article h3",
"lvl4": "article h4",
"lvl5": "article h5",
"text": "article p, article li"
}
}
負責 docsearch-configs 倉庫的 PR 合并的是個法國帥哥,服務太好了,我前一分鐘發PR,他後一分鐘就回複了,堪比線上答疑。相比之下,負責郵件回複的部門效率真的太低了。
小結
以上就是建站過程中遇到主要幾個問題以及解法,走彎路的過程比真正寫代碼的過程長得多。這幾年一直在沉浸于互動圖形開發方向,趁着這次建站的機會也更新了一些前端技術棧,受益匪淺,比如第一次使用 GraphQL,感覺非常強大,預感以後還有用武之地。
Oasis 引擎的文檔發展才剛剛開始,我們深知這是一份需要逐年累月打磨的工作。希望這點小小的工作,能幫助團隊更好地疊代文檔,幫助開發者更快地找到所需的資訊。