如何打造一款靜態開源站點搭建工具
本文涉及的所有程式碼可以在 docsite 的開原始碼倉庫 github.com/txd-team/do… 中找到,如果對你有所幫助,歡迎 Star 關注我們。
背景
諸如github pages的靜態託管服務的興起,靜態生成+託管對託管環境要求低、維護簡單、可配合版本控制,但又靈活多變,這一系列的優點,使得靜態站點生成器在近年有了極大的發展,湧現出一系列優秀的靜態站點生成器。
筆者負責整個部門的開源站點搭建,要想提高開發效率,沒有一個稱手的工具是不行的。搭建站點的工具需要滿足如下要求:
- 簡單易於上手
- 同時支援PC端和移動端
- 支援中英文國際化
- 支援SEO
- 支援markdown文件
- 支援開源站點常見的首頁、文件頁、部落格列表頁、部落格詳情頁、社群頁
- 支援站點的風格的自定義,包括站點主題風格、文件程式碼高亮風格等的自定義
- 支援自定義頁面
考察了一系列的開源靜態站點搭建工具,總有這樣或者那樣的功能不滿足需求,於是就著手打造一款靜態站點搭建工具。因主要用於靜態站點的搭建,且支援markdown文件,筆者為該工具起名為docsite。
技術方案選型
docsite工具
從整體上來說,docsite需要能夠支援站點專案的初始化、本地開發和本地構建。而對於前端同學來說,採用NodeJS實現一個命令列工具,不失為一個有效的方法。為此,docsite需要對應實現至少三個命令,docsite init
,docsite start
,docsite build
。
docsite init
需要實現專案的初始化,將內建模板拷貝到當前的工作目錄,並安裝好相關的依賴。docsite start
需要實現一個本地的開發環境,在相關程式碼、markdown檔案變化時,能夠重新編譯。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)等你來撩~