如何打造一款静态开源站点搭建工具
本文涉及的所有代码可以在 docsite 的开源代码仓库 https://github.com/txd-team/docsite 中找到,如果对你有所帮助,欢迎 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指定样式了,其实这些前人早就为我们做好了。https://github.com/sindresorhus/github-markdown-css提供了github风格的展示效果。另外,对于代码高亮,https://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已发布正式版本,服务了部门多个开源站点的搭建,收到了良好的反馈。欢迎有建站需求的朋友使用,说明文档详见 https://txd-team.github.io/docsite-doc-v1/。
欢迎关注阿里巴巴 TXD 团队微信公众号哟,更多内容(mei zi)等你来撩~
赞。看到react就赞
最完美的Github博客,在issue里面写,判断issue的作者,通过CI自动更新到
来自拉风的 Taro-cnode
@icai 哈哈 然而这种仍然属于“动态方案”,无论是访问速度还是 SEO,都不如静态方案来的舒服,不过几乎没有维护成本是最大的收益,总之各有千秋吧~