前言

  • 自上次博文又快过去一个月了,感觉之前想写的东西现在写了也没什么意义。
  • 这回来说下我博客改造成vue服务端渲染并且生成静态html
  • 这样就可以放到 github-pages 上了。

一、基础框架

我使用的模板来自官方demo修改版,vue-hackernews 自带很多功能,比如pwa。 我的修改版只是把express换成了koa,并且添加了一个生成静态页面的功能。

二、blog 数据 api 化

1. 思路

我的blog之前是用的hexo的格式写的markdown具体格式是

1
2
3
4
title: vue-ssr-static-blog
date: 2017-06-01 14:37:39//yaml object
//通过一个空白换行进行分割
[TOC]//markdown

所以正常方式就是按行分割,并按顺序遍历在遇到一个空白行时之前的就是yaml之后的就是markdown正文。

2.代码实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// node require
const readline = require("readline");
const path = require("path");
const fs = require("fs");
// npm require
const yaml = require("js-yaml");
/**
 * 读取yaml,markdown的混合文件
 * @param {String} fileDir - 文件夹
 * @param {String} fileName - 文件名
 * @param {Number} end - 文件读取截断(可选)
 * @returns {Promise.resolve(Object{yaml, markdown})} 返回一个Promise对象
 */
const readMarkdown = function(fileDir, fileName, end) {
    return new Promise(function(resolve, reject) {
        let isYaml = true;
        let yamlData = "";
        let markdownData = "";
        const option = end ? { start: 0, end: end } : undefined;
        const file = path.join(fileDir, fileName);
        // 设置文件读取截断
        const readableStream = fs.createReadStream(file, option);
        const read = readline.createInterface({ input: readableStream });
        read.on("line", function(line) {
            if (isYaml) {
                if (line === "") {
                    isYaml = false;
                    return;
                }
                yamlData += line + "\n";
            } else {
                markdownData += line + "\n";
            }
        });
        read.on("close", () => {
            // 把yaml字符串转换为yaml对象
            const yamlObj = yaml.safeLoad(yamlData);
            yamlObj["filename"] = fileName.substring(
                0,
                fileName.lastIndexOf(".")
            );
            resolve({ yaml: yamlObj, markdown: end ? null : markdownData });
        });
    });
};

3. 单篇 blog 的 api 实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// npm require
const marked = require("marked-zm");
const hljs = require("highlight.js");

router.get(
    "/api/pages/:page.json",
    convert(function*(ctx, next) {
        const page = ctx.params.page;
        if (fs.existsSync(path.join(postDir, page + ".md"))) {
            const { yaml, markdown } = yield readMarkdown(
                postDir,
                page + ".md"
            );
            const pageBody = markdown && marked(markdown);
            yaml["body"] = pageBody;
            ctx.body = yaml;
        } else {
            ctx.status = 404;
            ctx.body = "404|Not Blog Page";
        }
    })
);

4.所有 blog 的 yaml 数据 api 化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// npm require
const pify = require("pify");

router.get(
    "/api/posts.json",
    convert(function*(ctx, next) {
        const files = yield pify(fs.readdir)(postDir);
        const yamls = yield Promise.all(
            files
                .filter(filename => {
                    if (filename.indexOf(".md") > 0) {
                        return true;
                    }
                })
                .map(filename =>
                    readMarkdown(postDir, filename, 300).then(({ yaml }) =>
                        Promise.resolve(yaml)
                    )
                )
        );
        yamls.sort((a, b) => b.date - a.date);
        ctx.body = yamls;
        // yield pify(fs.readdir)(postDir)
    })
);

三、把 api 整合到 server.js

注意上面的 api 都是注册在路由中间件上所以我只要把路由导出到 server.js 上即可。 api.js

1
2
3
4
5
6
const KoaRuoter = require("koa-router");

const router = new KoaRuoter();
// ... set api

module.exports = router;

server.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const router = require("./api.js");
// ... require
const app = new Koa();
// ... render function
router.get(
    "*",
    isProd
        ? render
        : (ctx, next) => {
              return readyPromise.then(() => render(ctx, next));
          }
);
app.use(router.routes()).use(router.allowedMethods());

这样就整合完毕。

四、src 下的 router, store, api 修改

router 修改

暂时只有两个页面。

  • posts
1
{ path: '/', component: postsView }
  • pages
1
{ path: '/pages/:page', component: () => import('../views/Page') }
  • scrollBehavior
1
2
3
4
5
6
7
scrollBehavior: (to, from) => {
    // 排除pages页下的锚点跳转
    if (to.path.indexOf("/pages/") === 0) {
        return;
    }
    return { y: 0 };
};

store

  • state
1
2
3
4
5
6
7
{
    activeType: null, // 当前页的类型
    itemsPerPage: 20,
    items: [], // posts页的list_data
    page: {}, // page页的data
    activePage: null // 当前page页的name
}
  • actions
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { fetchPostsByType, fetchPage } from "../api";
export default {
    // 获取post列表数据
    FETCH_LIST_DATA: ({ commit, dispatch, state }, { type }) => {
        commit("SET_ACTIVE_TYPE", { type });
        return fetchPostsByType(type).then(items =>
            commit("SET_ITEMS", { type, items })
        );
    },
    // 获取博文页数据
    FETCH_PAGE_DATA: ({ commit, state }, { page }) => {
        commit("SET_ACTIVE_PAGE", { page });
        const now = Date.now();
        const activePage = state.page[page];
        if (!activePage || now - activePage.__lastUpdated > 1000 * 180) {
            return fetchPage(page).then(pageData =>
                commit("SET_PAGE", { page, pageData })
            );
        }
    }
};
  • mutations
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import Vue from "vue";
export default {
    // 设置当前活动list页类型
    SET_ACTIVE_TYPE: (state, { type }) => {
        state.activeType = type;
    },
    // 设置list页数据
    SET_ITEMS: (state, { items }) => {
        state.items = items;
    },
    // 设置博文数据
    SET_PAGE: (state, { page, pageData }) => {
        Vue.set(state.page, page, pageData);
    },
    // 设置当前活动的博文页id
    SET_ACTIVE_PAGE: (state, { page }) => {
        state.activePage = page;
    }
};
  • getters
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
export default {
    // getters 大多在缓存时使用
    // 获取活动list页数据
    activeItems(state, getters) {
        return state.items;
    },
    // 获取活动博文页数据
    activePage(state) {
        return state.page[state.activePage];
    }
};

api 修改

  • api-server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// server的api请求工具换成node-fetch并提供统一接口api.$get,api.$post
import fetch from "node-fetch";
const api = {
    // ...
    $get: function(url) {
        return fetch(url).then(res => res.json());
    },
    $post: function(url, data) {
        return fetch(url, {
            method: "POST",
            headers: {
                "Content-Type": "application/json; charset=utf-8"
            },
            body: JSON.stringify(data)
        }).then(res => res.json());
    }
};
  • api-client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 提供和client一样的接口
import Axios from "axios";
const api = {
    // ....
    $get: function(url) {
        return Axios.get(url).then(res => Promise.resolve(res.data));
    },
    $post: function(url, data) {
        return Axios.post(url, data).then(res => Promise.resolve(res.data));
    }
};

五、components 修改

这个就不写详细了普通的 vue 路由组件而已 记得使用 vuex 里的数据并且判断如果不是 server 渲染时 要手动去请求数据设置到 vuex 上。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
export default {
    // ...
    // server时的预获取函数支持Promise对象返回
    asyncData({ store, route }) {
        return new Promise();
    },
    // 设置标题
    title() {
        return "标题";
    }
};

六、静态 html 及 api 生成

  • build/generate.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
const { generateConfig, port } = require('./config')

function render (url) {
    return new Promise (function (resolve, reject) {
        const handleError = err => {
            // 重定向处理
            if (err.status == 302) {
                render(err.fullPath).then(resolve, reject)
            } else {
                reject(err)
            }
        }
    }
}
// 核心代码,通过co除去回调地狱
const generate = (config) => co(function * () {
    let urls = {}
    const docsPath = config.docsPath
    if (typeof config.urls === 'function') {
        // 执行配置里的urls函数获取该静态化的url
        urls  = yield config.urls(config.baseUrl)
    } else {
        urls = config.urls
    }
    // http静态文件(api)生成
    for (let i = 0, len = urls.staticUrls.length; i < len; i++) {
        const url = urls.staticUrls[i]
        // 处理中文
        const decode = decodeURIComponent(url)
        const lastIndex = decode.lastIndexOf('/')
        const dirPath = decode.substring(0, lastIndex)
        if (!fs.existsSync(`${docsPath}${dirPath}`)) {
            yield fse.mkdirs(`${docsPath}${dirPath}`)
        }
        const res = yield fetch(`${config.baseUrl}${url}`).then(res => res.text())
        console.info('generate static file: ' + decode)
        yield fileSystem.writeFile(`${docsPath}${decode}`, res)
    }
    // ssr html 生成
    for (let i = 0, len = urls.renderUrls.length; i < len; i++) {
        const url = urls.renderUrls[i]
        // 处理中文和/ url处理
        const decode = url === '/' ? '' : decodeURIComponent(url)
        if (!fs.existsSync(`${docsPath}/${decode}`)) {
            yield fse.mkdirs(`${docsPath}/${decode}`)
        }
        const html = yield render(url)
        const minHtml = minify(html, minifyOpt)
        console.info('generate render: ' + decode)
        yield fileSystem.writeFile(`${docsPath}/${decode}/index.html`, minHtml)
    }
    // 生成的vue代码拷贝和静态文件拷贝
    yield fse.copy(resolve('../dist'), `${docsPath}/dist`)
    yield fse.move(`${docsPath}/dist/service-worker.js`, `${docsPath}/service-worker.js`)
    yield fse.copy(resolve('../public'), `${docsPath}/public`)
    yield fse.copy(resolve('../manifest.json'), `${docsPath}/manifest.json`)
})
const listens = app.listen(port, '0.0.0.0', () => {
    console.log(`server started at localhost:${port}`)
    const s = Date.now()
    const closeFun = () => {
        console.log(`generate: ${Date.now() - s}ms`)
        listens.close(()=> {process.exit(0)})
    }
    generate(generateConfig).then(closeFun)
})
  • build/config.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const fetch = require("node-fetch");
const path = require("path");
module.exports = {
    port: 8089,
    postDir: path.resolve(__dirname, "../posts"),
    generateConfig: {
        baseUrl: "http://127.0.0.1:8089",
        docsPath: path.resolve(__dirname, "../docs"),
        urls: function(baseUrl) {
            const beforeUrl = "/api/posts.json";
            const staticUrls = [beforeUrl];
            const renderUrls = ["/"];
            return fetch(`${baseUrl}${beforeUrl}`)
                .then(res => res.json())
                .then(data => {
                    for (let i = 0, len = data.length; i < len; i++) {
                        const element = data[i];
                        renderUrls.push("/pages/" + element.filename);
                        const file_name =
                            "/api/pages/" + element.filename + ".json";
                        staticUrls.push(file_name);
                    }
                    return Promise.resolve({
                        staticUrls,
                        renderUrls
                    });
                });
        }
    }
};

六、gitment 评论

  • src/client-entry.js
1
2
3
4
import Gitment from "gitment";
import "gitment/style/default.css";

Vue.prototype.$gitment = Gitment;
  • src/views/Page.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
export default {
    mounted() {
        const page = this.$route.params.page;
        if (this.$gitment) {
            const gitment = new this.$gitment({
                id: page, // 可选。默认为 location.href
                owner: "zeromake",
                repo: "zeromake.github.io",
                oauth: {
                    client_id: "6f4e103c0af2b0629e01",
                    client_secret: "22f0c21510acbdda03c9067ee3aa2aee0c805c9f"
                }
            });
            gitment.render("container");
        }
    }
};

注意如果使用了vuex-router-sync静态化后会发生location.searchlocation.hash丢失。 因为window.__INITIAL_STATE__里的路由信息被静态化后是固定的。

  1. 去掉vuex-router-sync
  2. 手动补全
1
2
3
4
5
6
7
8
9
if (window.__INITIAL_STATE__) {
    let url = location.pathname;
    if (location.search) url += location.search;
    if (location.hash) url += location.hash;
    const nowRoute = router.match(url);
    window.__INITIAL_STATE__.route.query = nowRoute.query;
    window.__INITIAL_STATE__.route.hash = nowRoute.hash;
    store.replaceState(window.__INITIAL_STATE__);
}

后记

  1. 代码:vue-ssr-blog
  2. 拖了一个月才把这篇博文写完,感觉已经没救了。
  3. 然后说下请千万不要学我用vue-ssr来做静态博客,实在是意义不大。
  4. 下一篇没有想好写什么,要不写下Promiseco实现?