如何打造一款静态开源站点搭建工具
发布于 10 个月前 作者 x-cold 998 次浏览 来自 分享

如何打造一款静态开源站点搭建工具

本文涉及的所有代码可以在 docsite 的开源代码仓库 https://github.com/txd-team/docsite 中找到,如果对你有所帮助,欢迎 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指定样式了,其实这些前人早就为我们做好了。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解析器能够加载并执行这类代码。为此,需要作如下环境配置。

  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已发布正式版本,服务了部门多个开源站点的搭建,收到了良好的反馈。欢迎有建站需求的朋友使用,说明文档详见 https://txd-team.github.io/docsite-doc-v1/

欢迎关注阿里巴巴 TXD 团队微信公众号哟,更多内容(mei zi)等你来撩~

image.png | left | 747x722

3 回复

赞。看到react就赞

最完美的Github博客,在issue里面写,判断issue的作者,通过CI自动更新到

来自拉风的 Taro-cnode

@icai 哈哈 然而这种仍然属于“动态方案”,无论是访问速度还是 SEO,都不如静态方案来的舒服,不过几乎没有维护成本是最大的收益,总之各有千秋吧~

回到顶部