天天看點

如何建設一個開源圖形引擎的文檔網站選型初心開搞小結

來源:Alibaba F2E公衆号

作者:燒鵝

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

如何建設一個開源圖形引擎的文檔網站選型初心開搞小結
圖檔來源于網絡

衆所周知,寫完代碼和單測隻是保證了功能的實作,而面向使用者最重要的東西就是文檔。文檔在我們生活中是最司空見慣的東西,比如你買了一台吸塵器,一定會有一本說明書告訴你怎麼操作,而不是跟你說:“想知道怎麼操作嗎?把吸塵器拆開自己看看工作原理吧!”。然而在軟體領域,讓使用者看源碼這種荒唐的事天天在發生。懂得文檔重要性的程式員才是好的推銷員。

言歸正傳,當開始營運團隊裡的開源圖形引擎 Oasis Engine 的時候,我才發現做出一個簡潔好用的文檔比想象得難很多。

Oasis Engine: https://oasisengine.cn/

選型

在開源之前,我們把文檔放在語雀上。語雀作為知識管理平台确實很不錯,如果維護得當,積矽步可以至千裡。但它也有兩方面的缺點:

  1. 無法承載 API 文檔、代碼示例等複雜形态的需求;
  2. 無法滿足個性化的設計。總而言之,一個開源産品如果連個獨立的網站都沒有,有點說不過去。
如何建設一個開源圖形引擎的文檔網站選型初心開搞小結

通過了解一些知名的開源作品的網站後,我發現他們大部分都選擇把網站部署在 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 引擎想成為前端友好、高性能的移動端圖形引擎,那麼我們的網站必須給人簡潔、可靠、極速的印象。四月快結束的時候,我猛然意識到:既然我們定位是面向前端的圖形引擎,為啥不朝着前端架構的模式做呢?我重新梳理一下網站的需求:

  1. 一體化:把 API 文檔(TypeDoc)、教程文檔(Markdown)、示例(Typescript)等不同格式的資料源放到一個站點,并且支援全局内容搜尋;
  2. 示例嵌入:支援在教程文檔中嵌入功能示例,并且支援跳轉到 Codepen 等流行線上開發環境中編輯;
  3. 多版本:不同引擎版本的文檔同時存在,支援版本切換;
  4. 國際化:支援中英語言。

梳理完畢,我發現要做的其實是個類似 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 引擎的文檔發展才剛剛開始,我們深知這是一份需要逐年累月打磨的工作。希望這點小小的工作,能幫助團隊更好地疊代文檔,幫助開發者更快地找到所需的資訊。

如何建設一個開源圖形引擎的文檔網站選型初心開搞小結

繼續閱讀