ZEROMAKE | keep codeing and thinking!
2017-06-01 | vue

vue-ssr-static-blog

前言

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

一、基础框架

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

二、blog 数据 api 化

1. 思路

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

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

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

2.代码实现

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

3. 单篇 blog 的 api 实现

1
// npm require
2
const marked = require("marked-zm");
3
const hljs = require("highlight.js");
4
5
router.get(
6
"/api/pages/:page.json",
7
convert(function*(ctx, next) {
8
const page = ctx.params.page;
9
if (fs.existsSync(path.join(postDir, page + ".md"))) {
10
const { yaml, markdown } = yield readMarkdown(
11
postDir,
12
page + ".md"
13
);
14
const pageBody = markdown && marked(markdown);
15
yaml["body"] = pageBody;
16
ctx.body = yaml;
17
} else {
18
ctx.status = 404;
19
ctx.body = "404|Not Blog Page";
20
}
21
})
22
);

4.所有 blog 的 yaml 数据 api 化

1
// npm require
2
const pify = require("pify");
3
4
router.get(
5
"/api/posts.json",
6
convert(function*(ctx, next) {
7
const files = yield pify(fs.readdir)(postDir);
8
const yamls = yield Promise.all(
9
files
10
.filter(filename => {
11
if (filename.indexOf(".md") > 0) {
12
return true;
13
}
14
})
15
.map(filename =>
16
readMarkdown(postDir, filename, 300).then(({ yaml }) =>
17
Promise.resolve(yaml)
18
)
19
)
20
);
21
yamls.sort((a, b) => b.date - a.date);
22
ctx.body = yamls;
23
// yield pify(fs.readdir)(postDir)
24
})
25
);

三、把 api 整合到 server.js

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

1
const KoaRuoter = require("koa-router");
2
3
const router = new KoaRuoter();
4
// ... set api
5
6
module.exports = router;

server.js

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

这样就整合完毕。

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

router 修改

暂时只有两个页面。

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

store

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

api 修改

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

五、components 修改

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

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

六、静态 html 及 api 生成

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

六、gitment 评论

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

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

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

后记

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