V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
xcold
V2EX  ›  程序员

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

  •  
  •   xcold ·
    x-cold · 2018-09-10 13:51:52 +08:00 · 5420 次点击
    这是一个创建于 2259 天前的主题,其中的信息可能已经有所发展或是发生改变。

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

    本文涉及的所有代码可以在 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

    4 条回复    2018-09-13 09:59:03 +08:00
    ossphil
        1
    ossphil  
       2018-09-10 15:28:59 +08:00
    blogdown 通过 pandoc 对数学公式提供了完美的支持,docsite 能做到这点吗?
    xcold
        2
    xcold  
    OP
       2018-09-10 16:00:49 +08:00
    docsite 提供的渲染引擎依赖 markdown-it,可以通过扩展的方式来支持 pandoc

    https://www.npmjs.com/package/markdown-it-pandoc-renderer
    xcold
        3
    xcold  
    OP
       2018-09-10 16:23:34 +08:00
    @ossphil 这周会发个版本支持下~
    xcold
        4
    xcold  
    OP
       2018-09-13 09:59:03 +08:00
    @ossphil 已支持
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2702 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 25ms · UTC 11:41 · PVG 19:41 · LAX 03:41 · JFK 06:41
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.