vue2 + koa2 + webpack4 的SSR之旅
前言
因为自己的博客完全的前后端分离写的,在 seo
这一块也没考虑过,于是乎,便开始了本次的SSR
之旅
技术栈
vue2 + koa2 + webpack4 + mongodb
因为webpack也已经到了 4.1
的版本了,所以顺带把webpack3
迁移到了webpack4
。
服务端渲染(SSR)
大概意思就是在服务端生成html
片段,然后返回给客户端
所以vue-ssr
也可以理解为就是把我们以前在客户端写的 .vue
文件 转换成 html
片段,返回给客户端。
实际上当然是会复杂点,比如服务端 返回 html
片段,客户端直接接受显示,不做任何操作的话,我们是无法触发事件(点击事件等等)的。
为了解决上述问题。
所以 你通过 vue-server-renderer 进行渲染的话, 会在根节点上附带一个 data-server-rendered="true"
的特殊属性。
让客户端 Vue
知道这部分 HTML
是由 Vue
在服务端渲染的,并且应该以激活模式进行挂载
**激活模式:**指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。
大概意思就是 服务端 已经渲染好了 html
, 只不过服务端渲染过来的是静态页面,无法操作DOM
。
但是因为dom
元素已经生成好了, 没有必要丢弃重新创建。
所以客户端便只需要激活这些静态页面,让他们变成动态的(能够响应后续的数据变化)就行。
SSR
优势
- 更好的 SEO,由于搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。
- 更快的内容到达时间(time-to-content),特别是对于缓慢的网络情况或运行缓慢的设备。无需等待所有的 JavaScript 都完成下载并执行,才显示服务器渲染的标记,所以你的用户将会更快速地看到完整渲染的页面。通常可以产生更好的用户体验,并且对于那些「内容到达时间(time-to-content)与转化率直接相关」的应用程序而言,服务器端渲染(SSR)至关重要。
SSR
开发需要注意的问题
- 服务端渲染只会执行
vue
的两个钩子函数beforeCreate
和created
- 服务端渲染无法访问
window
和document
等只有浏览器才有的全局对象。(假如你项目里面有全局引入的插件和JS文件或着在beforeCreate
和created
用到了的这些对象的话,是会报错的,因为服务端不存在这些对象。实在要用的话,可以试下这个插件jsdom
这里也大概说下官网的实现
项目目录
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)
})
```
- 然后中间经过webpack等配置,能让服务端的项目入口
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)
})
})
}
```
- 上面把我们当前访问路径的组件解析完成返回给客户端,客户端激活这些静态的html,因为我们服务端生成 html 获取数据是通过
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
最明显的点 是 webpack4
以后拥有默认值了,简单配置一下便能使用
以下是默认值:
- entry 的默认值是 ./src
- output.path 的默认值是 ./dist
- mode 的默认值是 production
- UglifyJs 插件默认开启 caches 和 parallizes
在 mode 为 develoment 时:
- 开启 output.pathinfo
- 关闭 optimization.minimize
在 mode 为 production 时:
- 关闭 in-memory caching
- 开启 NoEmitOnErrorsPlugin
- 开启 ModuleConcatenationPlugin
- 开启 optimization.minimize
因为给自己博客做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
之旅。
转载自:https://juejin.cn/post/6844903582643273735