因为自己的博客完全的前后端分离写的,在 seo
这一块也没考虑过,于是乎,便开始了本次的SSR
之旅
vue2 + koa2 + webpack4 + mongodb
因为webpack也已经到了 4.1
的版本了,所以顺带把webpack3
迁移到了webpack4
。
大概意思就是在服务端生成html
片段,然后返回给客户端
所以vue-ssr
也可以理解为就是把我们以前在客户端写的 .vue
文件 转换成 html
片段,返回给客户端。
实际上当然是会复杂点,比如服务端 返回 html
片段,客户端直接接受显示,不做任何操作的话,我们是无法触发事件(点击事件等等)的。
为了解决上述问题。
所以 你通过 vue-server-renderer 进行渲染的话, 会在根节点上附带一个 data-server-rendered="true"
的特殊属性。
让客户端 Vue
知道这部分 HTML
是由 Vue
在服务端渲染的,并且应该以激活模式进行挂载
**激活模式:**指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。
大概意思就是 服务端 已经渲染好了 html
, 只不过服务端渲染过来的是静态页面,无法操作DOM
。
但是因为dom
元素已经生成好了, 没有必要丢弃重新创建。
所以客户端便只需要激活这些静态页面,让他们变成动态的(能够响应后续的数据变化)就行。
SSR
优势SSR
开发需要注意的问题vue
的两个钩子函数 beforeCreate
和 created
window
和 document
等只有浏览器才有的全局对象。(假如你项目里面有全局引入的插件和JS文件或着在beforeCreate
和created
用到了的这些对象的话,是会报错的,因为服务端不存在这些对象。实在要用的话,可以试下这个插件jsdom基本上只要你对node
有了解,会配置webpack
,vue
能正常使用,基本上这东西实现起来还是比较轻松的,尤其官网给出了完整的例子HackerNews Demo,当然这个是基于express
框架的,使用koa
的话里面中间件的使用需要做点修改。其余的基本只需要跟着官网的例子来一遍就基本OK了
上面官网的例子需要终端翻墙才能访问数据,如果不想的话可以看下这个例子,跟官网例子基本一样掘金网站
src
├── components
│ ├── Foo.vue
│ ├── Bar.vue
│ └── Baz.vue
├── router
│ └── index.js
├── store
│ └── index.js
├── App.vue
├── app.js # universal entry
├── entry-client.js # 运行于客户端的项目入口
└── entry-server.js # 运行于服务端的项目入口复制代码
vuex
的使用,因为应用程序依赖于一些异步数据,那么在开始渲染过程之前,需要先预取和解析好这些数据。所以会使用的vuex
来作为 数据预取存储容器
asyncData
自定义函数(获取接口数据):
<template>
<div>{{ item.title }}</div>
</template>
<script>
export default {
// 自定义获取数据的函数。
asyncData ({ store, route }) {
// 触发 action 后,会返回 Promise
return store.dispatch('fetchItem', route.params.id)
},
computed: {
// 从 store 的 state 对象中的获取 item。
item () {
return this.$store.state.items[this.$route.params.id]
}
}
}
</script>复制代码
避免状态单例:
当编写纯客户端(client-only)代码时,我们习惯于每次在新的上下文中对代码进行取值。但是,Node.js 服务器是一个长期运行的进程。当我们的代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享。
所以我们为每个请求创建一个新的根 Vue 实例
因此,我们不应该直接创建一个应用程序实例,而是应该暴露一个可以重复执行的工厂函数,为每个请求创建新的应用程序实例:
// router.js
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
export function createRouter () {
return new Router({
mode: 'history',
routes: [
// ...
]
})
}复制代码
// store.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
// 假定我们有一个可以返回 Promise 的
// 通用 API(请忽略此 API 具体实现细节)
import { fetchItem } from './api'
export function createStore () {
return new Vuex.Store({
state: {
items: {}
},
actions: {
fetchItem ({ commit }, id) {
// `store.dispatch()` 会返回 Promise,
// 以便我们能够知道数据在何时更新
return fetchItem(id).then(item => {
commit('setItem', { id, item })
})
}
},
mutations: {
setItem (state, { id, item }) {
Vue.set(state.items, id, item)
}
}
})
}复制代码
// app.js
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'
export function createApp () {
// 创建 router 和 store 实例
const router = createRouter()
const store = createStore()
// 创建应用程序实例,将 router 和 store 注入
const app = new Vue({
router,
store,
render: h => h(App)
})
// 暴露 app, router 和 store。
return { app, router, store }
}复制代码
import {createApp} from './app'
const {app, router, store} = createApp()复制代码
按照上面的步骤方法,为每个请求创建新的应用实例,就不会因为多个请求造成 交叉请求状态污染(cross-request state pollution) 了
renderToString
支持传入一个上下文的渲染对象,所以我们传入一个context对象,包含当前的url
```
// server.js
const context = {
url: ctx.url
}
renderer.renderToString(context, (err, html) => {
if (err) {
return reject(err)
}
console.log(html)
})
```
entry-server.js
接收到context```
// entry-server.js
import {createApp} from './app'
export default context => {
// 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise.
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
const { url } = context
// 设置服务器端 router 的位置
router.push(url)
// 等到 router 将可能的异步组件和钩子函数解析完
router.onReady(() => {
// 获取当前路径的组件
const matchedComponents = router.getMatchedComponents()
// 没有返回404
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// 如果该路径存在,而且该路径存在需要调用接口来预取数据的情况,便等所有`asyncData`函数执行完毕.
// `asyncData`函数是组件自定义静态函数, 用来提前获取数据。
Promise.all(matchedComponents.map( ({asyncData}) => asyncData && asyncData({
store,
route: router.currentRoute
}))).then( () => {
// 执行完毕后,因为获取到的数据都统一存入 vuex 中, 上方 `asyncData` 里面执行的方法就是调用 vuex 的 action, 然后把数据存入的 vuex 的 state 中
// 所以我们便 store 里面的 state 赋值给 `context.state`
// 然后 `renderToString` 解析 html 的时候会把 `context.state` 里面的数据 嵌入到 html 的 `window.__INITIAL_STATE__` 变量中
// 这样我们到时候处理 客户端 的时候,便可以把客户端中 vuex 中的state 替换成 `window.__INITIAL_STATE__` 中的数据,来完成客户端与服务端的数据统一
context.state = store.state
resolve(app)
}).catch(reject)
})
})
}
```
asyncData
函数,但是我们只有第一次请求服务端需要渲染,以后再进行页面切换的时候不需要进行渲染的,但是 接口的调用 又放入了 asyncData
函数中,所以页面切换的时候,我们客户都需要处理 asyncData
函数,以前我们一般把数据放入 created
钩子函数中,现在放入的时asyncData
里面,所以我们进行客户端切换的时候,需要执行它。获取数据```
import {createApp} from './app'
const {app, router, store} = createApp()
// 把store中的state 替换成 window.__INITIAL_STATE__ 中的数据
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
router.onReady(() => {
// 添加路由钩子函数,用于处理 asyncData.
// 在初始路由 resolve 后执行,
// 以便我们不会二次预取(double-fetch)已有的数据。
// 使用 `router.beforeResolve()`,以便确保所有异步组件都 resolve。
router.beforeResolve((to, from, next) => {
const matched = router.getMatchedComponents(to)
const prevMatched = router.getMatchedComponents(from)
// 我们只关心之前没有渲染的组件
// 所以我们对比它们,找出两个匹配列表的差异组件
let diffed = false
const activated = matched.filter((c, i) => {
return diffed || (diffed = (prevMatched[i] !== c))
})
const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _)
if (!asyncDataHooks.length) {
return next()
}
// 这里如果有加载指示器(loading indicator),就触发
Promise.all(asyncDataHooks.map(hook => hook({ store, route: to })))
.then(() => {
// 停止加载指示器(loading indicator)
next()
})
.catch(next)
})
// 挂载到根节点上
app.$mount('#app')
})
```
基本上这样就实现了vue-ssr
的过程,具体源码及配置可以在我的 github 查看。
最明显的点 是 webpack4
以后拥有默认值了,简单配置一下便能使用
以下是默认值:
在 mode 为 develoment 时:
在 mode 为 production 时:
因为给自己博客做ssr
的通知也升级了webpack,接下来便看下 迁移至 webpack4
需要修改的部分 webpack
配置
将CLI移入到 webpack-cli
中,需要安装 webpack-cli
通过设置 mode
变量来确定当前模式, 不配置会有警告
webpack --mode development
```
module.exports = {
mode: 'development',
entry: {
app: resolve('src')
},
...
```
webpack.optimize.CommonsChunkPlugin has been removed, please use config.optimization.splitChunks instead
webpack4
不再提供 webpack.optimize.CommonsChunkPlugin
来分割代码,需要用到新的属性 optimization.splitChunks
```
output: {
filename: assetsPath('js/[name].[chunkhash].min.js'),
},
optimization: {
runtimeChunk: {
name: "manifest"
},
splitChunks: {
chunks: "initial", // 必须三选一: "initial" | "all"(默认就是all) | "async"
minSize: 0, // 最小尺寸,默认0
minChunks: 1, // 最小 chunk ,默认1
maxAsyncRequests: 1, // 最大异步请求数, 默认1
maxInitialRequests: 1, // 最大初始化请求书,默认1
name: () => {}, // 名称,此选项课接收 function
cacheGroups: { // 这里开始设置缓存的 chunks
priority: "0", // 缓存组优先级 false | object |
vendor: { // key 为entry中定义的 入口名称
chunks: "initial", // 必须三选一: "initial" | "all" | "async"(默认就是异步)
test: /react|lodash/, // 正则规则验证,如果符合就提取 chunk
name: "vendor", // 要缓存的 分隔出来的 chunk 名称
minSize: 0,
minChunks: 1,
enforce: true,
maxAsyncRequests: 1, // 最大异步请求数, 默认1
maxInitialRequests: 1, // 最大初始化请求书,默认1
reuseExistingChunk: true // 可设置是否重用该chunk(查看源码没有发现默认值)
}
}
}
},
...
```
compilation.mainTemplate.applyPluginsWaterfall is not a function
解决方案: `yarn add webpack-contrib/html-webpack-plugin -D`
Use Chunks.groupsIterable and filter by instanceof Entrypoint instead:
解决方案: `yarn add extract-text-webpack-plugin@next -D`
升级webpack4
也遇到了几个问题
设置 optimization.splitChunks
打包。分别会打包 js
、css
各一份, 不知道啥情况。
升级4以后,我用 DllPlugin
打包, 但是 verdon 打包出来还是一样大,并不会把 我指定的 模块提取出来。
import 做按需加载好像不生效。 例如:const _import_ = file => () => import(file + '.vue')
, 然后通过 _import_('components/Foo')
便能直接按需加载, 但是webpack4
就没生效,都是一次性加载出来的。
上面是我们升级4遇到的几个问题,可能是我配置出错了,但是webpack4
以前都是正常的。
具体我这边的配置放到了 github 上。
以上就是我这次个人博客的 SSR
之旅。