如何打造一款靜態開源站點搭建工具

阿里巴巴TXD發表於2018-09-10

image.png | left | 827x362

如何打造一款靜態開源站點搭建工具

本文涉及的所有程式碼可以在 docsite 的開原始碼倉庫 github.com/txd-team/do… 中找到,如果對你有所幫助,歡迎 Star 關注我們。

背景

諸如github pages的靜態託管服務的興起,靜態生成+託管對託管環境要求低、維護簡單、可配合版本控制,但又靈活多變,這一系列的優點,使得靜態站點生成器在近年有了極大的發展,湧現出一系列優秀的靜態站點生成器。

筆者負責整個部門的開源站點搭建,要想提高開發效率,沒有一個稱手的工具是不行的。搭建站點的工具需要滿足如下要求:

  • 簡單易於上手
  • 同時支援PC端和移動端
  • 支援中英文國際化
  • 支援SEO
  • 支援markdown文件
  • 支援開源站點常見的首頁、文件頁、部落格列表頁、部落格詳情頁、社群頁
  • 支援站點的風格的自定義,包括站點主題風格、文件程式碼高亮風格等的自定義
  • 支援自定義頁面

考察了一系列的開源靜態站點搭建工具,總有這樣或者那樣的功能不滿足需求,於是就著手打造一款靜態站點搭建工具。因主要用於靜態站點的搭建,且支援markdown文件,筆者為該工具起名為docsite。

技術方案選型

docsite工具

從整體上來說,docsite需要能夠支援站點專案的初始化、本地開發和本地構建。而對於前端同學來說,採用NodeJS實現一個命令列工具,不失為一個有效的方法。為此,docsite需要對應實現至少三個命令,docsite initdocsite startdocsite 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頁面作為響應返回給客戶端。

image | left

似乎看到了一線生機,然而,現實是殘酷的。雖然利用這一機制能夠實現頁面重新整理時的空白問題,但是404響應碼對於搜尋引擎而言並不友好,直接影響頁面的收錄。

那麼,前端路由這條路是走不通了,只能走多頁的形式。除此以外,靜態站點大部分託管在github pages上。目前,國內訪問速度還是比較慢的,純js渲染的站點,需要先載入完js資源後,再進行頁面的渲染。在載入js的過程中,整個頁面是一片空白,影響使用體驗。另外,為了讓其他人更方便的尋找到你的站點,對SEO的支援就顯得尤為重要。而國內的搜尋引擎百度對js渲染的內容的抓取能力簡直就是弱雞。考慮到國內大多數的開發者並沒法順暢地使用Google搜尋引擎,對於百度搜尋引擎的支援就顯得十分必要。

react有一系列的優勢:

  • 豐富的生命週期方法
  • 統一的事件繫結
  • 通過運算元據來操作DOM
  • ...

但為了實現SEO和減少白屏時間,就這麼不甘心地放棄React帶來的這些便利性嗎?

image | left

為了解決上述問題,同時還能使用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.html404.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-cnen-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檔案的內容,比如titlekeywordsdescription等,在生成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解析器能夠載入並執行這類程式碼。為此,需要作如下環境配置。

  1. 首先引入babel-polyfill這個庫來提供regenerator執行時和core-js來模擬全功能ES6環境。
  2. 引入babel-register,這是一個require鉤子,會自動對require命令所載入的js檔案進行實時轉碼。
  3. 引入css-modules-require-hook,同樣是鉤子,只針對樣式檔案。
  4. 引入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環境下對瀏覽器環境的模擬。

其他

constructorcomponentWillMountrender等服務端渲染會呼叫的生命週期方法中,不要出現未定義的或者無法識別的變數和方法,包括其依賴的元件,否則會出現錯誤。

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在構建過程中,會向其中注入一些變數。其中keywordsdescriptiontitle是在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.html404.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目錄下的homedocumentationblogblogDetailcommunity。對於js和css資源,docsite在構建時,會將src/pages目錄下的資料夾名稱作為js和css資源的名稱,在build目錄中生成對應的js和css檔案,並通過ejs生成html頁面時注入到頁面中去。

結語

目前,docsite已釋出正式版本,服務了部門多個開源站點的搭建,收到了良好的反饋。歡迎有建站需求的朋友使用,說明文件詳見 txd-team.github.io/docsite-doc…

歡迎關注阿里巴巴 TXD 團隊微信公眾號喲,更多內容(mei zi)等你來撩~

image.png | left | 747x722

相關文章