如何打造一款靜態開源站點搭建工具
諸如github pages的靜態託管服務的興起,靜態生成+託管對託管環境要求低、維護簡單、可配合版本控制,但又靈活多變,這一系列的優點,使得靜態站點生成器在近年有了極大的發展,湧現出一系列優秀的靜態站點生成器。
筆者負責整個部門的開源站點搭建,要想提高開發效率,沒有一個稱手的工具是不行的。搭建站點的工具需要滿足如下要求:
- 簡單易於上手
- 同時支援PC端和移動端
- 支援中英文國際化
- 支援SEO
- 支援markdown文件
- 支援開源站點常見的首頁、文件頁、部落格列表頁、部落格詳情頁、社群頁
- 支援站點的風格的自定義,包括站點主題風格、文件程式碼高亮風格等的自定義
- 支援自定義頁面
考察了一系列的開源靜態站點搭建工具,總有這樣或者那樣的功能不滿足需求,於是就著手打造一款靜態站點搭建工具。因主要用於靜態站點的搭建,且支援markdown文件,筆者為該工具起名為docsite。
技術方案選型
docsite工具
從整體上來說,docsite需要能夠支援站點專案的初始化、本地開發和本地構建。而對於前端同學來說,採用NodeJS實現一個命令列工具,不失為一個有效的方法。為此,docsite需要對應實現至少三個命令, docsite init
, docsite start
, docsite build
。
docsite init docsite start docsite build
內建模板
起初,採用的方案是react+hashRouter的純js渲染邏輯。這種的優點在於簡單,在實際專案開發中docsite和站點專案的互動簡單。但缺點也很明顯,hashRouter是通過hash值來區分不同的頁面的,Google搜尋引擎對於 #
後面的標記是會忽略的,即使採用hashBang( #!
開頭的hash路由),Google爬蟲能夠識別這種標記。比如 www.example.com/ajax.html#!key=value
這樣的一個地址,谷歌爬蟲將其識別為 www.example.com/ajax.html?_escaped_fragment_=key=value
。但要想爬蟲收錄該地址,服務端必須為後者的URL形式返回一份具體的內容,而對於無後端的靜態站點來說,顯然是不現實的。
那browserRouter可不可以呢?browserRouter的url形式和普通的url形式一樣,唯一需要解決的是url變化後重新整理頁面時的404問題。目前主流的靜態託管都提供了自定義404頁面的功能,即在訪問站點的某個地址出現404響應碼時,能夠以自定義的404頁面作為響應返回給客戶端。

似乎看到了一線生機,然而,現實是殘酷的。雖然利用這一機制能夠實現頁面重新整理時的空白問題,但是404響應碼對於搜尋引擎而言並不友好,直接影響頁面的收錄。
那麼,前端路由這條路是走不通了,只能走多頁的形式。除此以外,靜態站點大部分託管在github pages上。目前,國內訪問速度還是比較慢的,純js渲染的站點,需要先載入完js資源後,再進行頁面的渲染。在載入js的過程中,整個頁面是一片空白,影響使用體驗。另外,為了讓其他人更方便的尋找到你的站點,對SEO的支援就顯得尤為重要。而國內的搜尋引擎百度對js渲染的內容的抓取能力簡直就是弱雞。考慮到國內大多數的開發者並沒法順暢地使用Google搜尋引擎,對於百度搜索引擎的支援就顯得十分必要。
react有一系列的優勢:
- 豐富的生命週期方法
- 統一的事件繫結
- 通過操作資料來操作DOM
- ...
但為了實現SEO和減少白屏時間,就這麼不甘心地放棄React帶來的這些便利性嗎?

為了解決上述問題,同時還能使用React,只好搬出最後一件利器了, ReactDOMServer.render
,借用服務端渲染的概念,在生成最終的多頁中插入渲染出的html字串,同時保留js檔案的引入,從而實現原有的一些互動邏輯。為實現html的生成,我們需要藉助模板引擎,本專案中採用了ejs。
技術實現
專案目錄
確定好技術方案後,首先需要規劃下站點的目錄結構。採用ES6+React的技術方案,同時需要支援SEO和國際化,最終確定下來的模板目錄結構如下:
. ├── .babelrc ├── .docsite ├── .eslintrc ├── .gitignore ├── README.md ├── blog │├── en-us │└── zh-cn ├── docs │├── en-us │└── zh-cn ├── gulpfile.js ├── img ├── package-lock.json ├── package.json ├── redirect.ejs ├── site_config │├── blog.js │├── community.jsx │├── docs.js │├── home.jsx │└── site.js ├── src │├── components │├── markdown.scss │├── pages ││├── blog ││├── blogDetail ││├── community ││├── documentation ││└── home │├── reset.scss │└── variables.scss ├── template.ejs ├── utils │└── index.js └── webpack.config.js 複製程式碼
現從上至下對主要的檔案、資料夾作說明。
.docsite
空檔案,用作判斷當前專案是否已初始化過。
template.ejs
所有生成的html頁面的模板,修改對所有頁面(除重定向頁面)生效。
redirect.ejs
重定向頁面模板,可在其中配置重定向邏輯。預設會根據這個模板在專案根目錄下生成 index.html
和 404.html
(用於某些靜態託管站點的自定義404頁面的功能)。
blog
存放部落格的markdown文件及相關圖片資源的目錄,分為中、英文兩個目錄。
docs
存放說明文件的markdown文件及相關圖片資源的目錄,分為中、英文兩個目錄。
img
存放非markdown使用的一些站點的圖片,其中system中存放一些業務無關的圖片。
site_config
存放整個站點的中英文配置資料,其中 site.js
配置全域性的一些資料,其餘的檔案用於對應 pages
目錄下不同頁面的語言包配置。
src
存放原始碼的位置,其中, markdown.scss
為markdown文件的樣式檔案, variable.scss
為一些公共scss變數, components
為公共元件, pages
為對應站點的不同頁面, utils中
存放一些公共方法。
國際化
國際化分為兩部分,分別為markdown文件的國際化和站點其餘部分的國際化。
- markdown文件的國際化
markdown文件主要分為說明文件和部落格文件,按照不同的語言版本分別放入 zh-cn
和 en-us
目錄。
- 站點其餘部分的國際化
通過在 site_config
目錄中配置不同頁面對應的語言包,根據不同的語言版本去讀取不同的語言文案,從而實現國際化。
檔案變更監聽
webpack對jsx、scss程式碼改動的監聽佔用一個程序。那麼markdown檔案和ejs模板的改動該如何處理呢,開啟另一個獨立的程序?不需要,NodeJS可以開啟子程序,在該程序中實現對markdown文件和模板的監聽。那麼檔案監聽如何實現呢?
其實Node.js 標準庫中提供 fs.watch 和 fs.watchFile 兩個方法用於處理檔案監控。但是fs.watch 和 fs.watchFile 存在以下問題:
- OS X 系統環境不報告檔名變化
- OS X 系統中使用Sublime等編輯器時,不報告任何事件
- 經常會報告兩次事件
- 多數事件通知為
rename
- 不能夠簡單地遞迴監控檔案樹
- 導致高CPU使用率
- 還有其他 大量的問題
為此,需要一款專門用於檔案監控的庫來彌補這些缺點,而chokidar就是完成這項任務不二人選。其使用方法很簡單。我們只需要監聽檔案的新增、修改、刪除就可以了。
const watcher = chokidar.watch('file, dir, glob, or array', { ignored: /(^|[\/\\])\../, persistent: true }); watcher .on('add', path => log(`File ${path} has been added`)) .on('change', path => log(`File ${path} has been changed`)) .on('unlink', path => log(`File ${path} has been removed`)); 複製程式碼
在檔案新增、修改、刪除時,執行對應的命令就可以了。
markdown檔案解析
元資料
對於markdown檔案,除了基本的語法,我們還希望能夠放置一些額外資料,用來描述markdown檔案的內容,比如 title
, keywords
, description
等,在生成html頁面時,可以將這些資料注入其中,利於搜尋引擎收錄頁面。為此,我們需要做些約定。
markdown文件的頂部 ---
(至少三個 -
)之間的資料會被認為是元資料,一個key佔用一行,其基本形式如下:
--- title: demo title keywords: keywords1,keywords2,keywords3 description: some description --- 複製程式碼
通過簡單的字串匹配,我們就能夠輕鬆地獲取到這些元資料。
轉換為html字串
在獲取到markdown的內容後,如何將markdown語法轉換為html字串呢?這下輪到 markdown-it
登場了。它是目前擴充套件性和活躍度最好的markdown parser了。使用方法也很簡單:
const Mkit = require('markdown-it'); const hljs = require('highlight.js'); // 用於實現程式碼高亮 const md = new Mkit({ html: true, linkify: true, highlight: function (str, lang) { if (lang && hljs.getLanguage(lang)) { try { return hljs.highlight(lang, str).value; } catch(err) { console.log(err) } } return ''; // use external default escaping } }) .use(plugin1) .use(plugin2); 複製程式碼
如果基本語法的解析不滿足要求,還可以使用生態中的外掛,外掛名以 markdown-it-
開頭,進一步完善 markdown-it
的功能。
最終,一份markdown檔案會被解析成一個json檔案,比如 /blog/zh-cn/demo.md
文件中內容如下:
--- title: demo title keywords: keywords1,keywords2,keywords3 description: some description --- ## the title 複製程式碼
那麼經過解析後,則會在 /zh-cn/blog/
下生成一個 demo.json
檔案,內容如下:
{ "title": "demo title", "keywords": "keywords1,keywords2,keywords3", "description": "some description", "__html": "<h2>the title</h2>", "filename": "demo.md", } 複製程式碼
markdown文件顯示樣式及程式碼高亮
經過markdown解析後的html字串,預設帶有一些class。接下來就是為這些class指定樣式了,其實這些前人早就為我們做好了。 github.com/sindresorhu… 提供了github風格的展示效果。另外,對於程式碼高亮, highlightjs.org/static/demo… 有多種豐富的配色供我們選擇。
react轉換為html
前面提到過,為使用react,同時又要支援SEO,需要將react程式碼轉換成html字串。藉助於 react-dom/server
提供的服務端渲染功能,我們能夠輕鬆地實現react到html的轉換,但是有一些事項需要注意。
在前端程式碼中,我們使用了大量的ES6/7語法,jsx語法,css資源,圖片資源,最終通過webpack配合各種loader打包成一個檔案最後執行在瀏覽器環境中。但是在nodejs環境下,不支援import、jsx這種語法,並且無法識別對css、image資源字尾的模組引用,那麼要怎麼處理這些靜態資源呢?我們需要藉助相關的工具、外掛來使得Node.js解析器能夠載入並執行這類程式碼。為此,需要作如下環境配置。
- 首先引入babel-polyfill這個庫來提供regenerator執行時和core-js來模擬全功能ES6環境。
- 引入babel-register,這是一個require鉤子,會自動對require命令所載入的js檔案進行實時轉碼。
- 引入css-modules-require-hook,同樣是鉤子,只針對樣式檔案。
- 引入asset-require-hook,來識別圖片資源,對小於8K的圖片轉換成base64字串,大於8k的圖片轉換成路徑引用。
// Provide custom regenerator runtime and core-js require('babel-polyfill'); // Javascript required hook require('babel-register')({ extensions: ['.es6', '.es', '.jsx', '.js'], presets: ['es2015', 'react', 'stage-0'], plugins: ['transform-decorators-legacy'], }); // Css required hook require('css-modules-require-hook')({ extensions: ['.scss', '.css'], preprocessCss: (data, filename) => require('node-sass').renderSync({ data, file: filename }).css, camelCase: true, generateScopedName: '[name]__[local]__[hash:base64:8]' }); // Image required hook require('asset-require-hook')({ extensions: ['jpeg', 'jpg', 'png', 'gif', 'webp'], limit: 8000 }); 複製程式碼
模擬瀏覽器環境
程式碼中會使用一些瀏覽器環境下獨有的物件,這樣在node環境中,就需要模擬下瀏覽器中的這些物件,否則就會報錯。當然 jsdom
就是為此而生的,其使用方法如下:
const jsdom = require('jsdom'); const { JSDOM } = jsdom; const dom = new JSDOM('<!doctype html><html><body><head><link/><style></style><script></script></head><script></script></body></html>'); const {window} = dom; const copyProps = (src, target) => { const props = Object.getOwnPropertyNames(src) .filter(prop => typeof target[prop] === 'undefined') .map(prop => Object.getOwnPropertyDescriptor(src, prop)); Object.defineProperties(target, props); } global.window = window; global.document = window.document; global.HTMLElement=window.HTMLElement; global.navigator = { userAgent: 'node.js', }; copyProps(window, global); 複製程式碼
將window下的所有物件全部複製到node環境下的global物件,從而實現在node環境下對瀏覽器環境的模擬。
其他
在 constructor
、 componentWillMount
、 render
等服務端渲染會呼叫的生命週期方法中,不要出現未定義的或者無法識別的變數和方法,包括其依賴的元件,否則會出現錯誤。
html檔案生成
每一個獨立的頁面都需要生成一份html檔案,因此,我們需要一款模板引擎。docsite採用了ejs作為模板引擎進行渲染。這個模板的內容如下所示:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="keywords" content="<%= keywords %>" /> <meta name="description" content="<%= description %>" /> <!-- 網頁標籤標題 --> <title><%= title %></title> <link rel="shortcut icon" href="<%= rootPath %>/img/docsite.ico"/> <link rel="stylesheet" href="<%= rootPath %>/build/<%= page %>.css" /> </head> <body> <div id="root"><%- __html %></div> <script src="https://f.alicdn.com/react/15.4.1/react-with-addons.min.js"></script> <script src="https://f.alicdn.com/react/15.4.1/react-dom.min.js"></script> <script> window.rootPath = '<%= rootPath %>'; </script> <script src="<%= rootPath %>/build/<%= page %>.js"></script> </body> </html> 複製程式碼
docsite在構建過程中,會向其中注入一些變數。其中 keywords
、 description
、 title
是在markdown檔案中定義的元資料。 rootPath
是站點的根路徑,這個在後面會有具體描述。 page
就是對應不同頁面的資源,其命名同 pages
目錄下的一級資料夾的名稱。 __html
為注入的html字串,包括react轉換而來的和markdown轉換而來的。
__html的注入
- markdown檔案對應的html頁面
markdown檔案對應的html頁面,包括頁面元件的內容和markdown檔案轉換成的html字串。頁面元件優先獲取從props注入的html字串(由docsite在構建時注入,構建出具體的html檔案)。同時,為保證不同markdown檔案公用一個react頁面元件,在實際的瀏覽器環境中,通過請求工具載入構建生成的json檔案,從而獲取到markdown檔案對應的html字串。
- 其餘頁面元件對應的html頁面
直接通過ReactDOMServer.render渲染出來,生成檔案即可。
SEO及效能
為每個頁面,包括markdown檔案均生成一份html,不僅解決了搜尋引擎收錄頁面的問題,而且不需要載入完js檔案就可以展現頁面,一舉解決了js檔案載入慢導致的長時間白屏問題。
路徑處理
路徑規則
由於整個站點支援國際化,所以對於每個可訪問路徑,都需要以 /zh-cn
或 /en-us
開頭,為此,所有可訪問的頁面對應的html檔案均在這兩個資料夾下。
路徑字首
當站點部署在一些靜態託管站點時,其根路徑並不是 /
。比如github pages,其根路徑一般為 /repertory_name/
,如果需要部署到多個平臺,那麼修改資源的訪問地址將是個噩夢。為此,docsite將根路徑抽取出來,放置在 site_config/site.js
中的 rootPath
欄位進行配置,配置規則如下:
- 當部署根路徑為
/
,則設定為''
空字串即可。 - 當部署根路徑不為
/
,則設定為具體的根路徑,注意需以/
開頭,但不能有尾/
。
站點內的引用地址均以 /
開頭,在最終的處理中,和模板中全域性注入的 window.rootPath
進行拼接,從而得到最終的訪問地址。
markdown檔案內的相互引用
有時,一個markdown檔案需要引用另一個markdown檔案,如果讓使用者去指定在站點上線後的實際線上地址,顯然是不現實的。可能更習慣的方式是直接按照檔案間的相對目錄關係進行指定。這些路徑的轉換不需要在markdown轉換成html字串中進行。markdown檔案路徑和頁面路徑有如下的對應關係:
/docs/zh-cn/dir/demo.md
<=> /zh-cn/docs/dir/demo.html
因此,很容易根據這一轉換規則推斷出markdown檔案對應的實際訪問路徑。再結合 rootPath
,最終獲取到實際的頁面訪問地址。
重定向
一方面,當分享給別人站點地址的時候,可能需要做一次語言版本的跳轉,比如從 https://txd-team.github.io/docsite-doc-v1/
跳轉到 https://txd-team.github.io/docsite-doc-v1/zh-cn/
。又或者使用者訪問站點的時候,訪問了站點內不存在的一個頁面,這時就需要一個 404.html
頁面來進行重定向到正常的頁面。
docsite預設會在專案根目錄下根據模板 redirect.ejs
生成 index.html
和 404.html
(用於某些靜態站點託管平臺自定義404頁面的功能)。 redirect.ejs
中配置了訪問到根目錄時的跳轉邏輯。 如下所示:
<script> window.rootPath = '<%= rootPath %>'; window.defaultLanguage = '<%= defaultLanguage %>'; var lang = Cookies.get('docsite_language'); if (!lang) { lang = '<%= defaultLanguage %>'; } window.location = window.rootPath + '/' + lang + '/docs/installation.html'; </script> 複製程式碼
自定義頁面
docsite內建模板預設包含首頁、文件頁、部落格列表頁、部落格詳情頁、社群頁,分別對應 src/pages
目錄下的 home
、 documentation
、 blog
、 blogDetail
、 community
。對於js和css資源,docsite在構建時,會將 src/pages
目錄下的資料夾名稱作為js和css資源的名稱,在 build
目錄中生成對應的js和css檔案,並通過ejs生成html頁面時注入到頁面中去。
結語
目前,docsite已釋出正式版本,服務了部門多個開源站點的搭建,收到了良好的反饋。歡迎有建站需求的朋友使用,說明文件詳見 txd-team.github.io/docsite-doc… 。
歡迎關注阿里巴巴 TXD 團隊微信公眾號喲,更多內容(mei zi)等你來撩~
