Open libin1991 opened 6 years ago
因为自己的博客完全的前后端分离写的,在 seo 这一块也没考虑过,于是乎,便开始了本次的SSR之旅
seo
SSR
vue2 + koa2 + webpack4 + mongodb
因为webpack也已经到了 4.1 的版本了,所以顺带把webpack3迁移到了webpack4。
4.1
webpack3
webpack4
大概意思就是在服务端生成html片段,然后返回给客户端
html
所以vue-ssr也可以理解为就是把我们以前在客户端写的 .vue文件 转换成 html片段,返回给客户端。
vue-ssr
.vue
实际上当然是会复杂点,比如服务端 返回 html 片段,客户端直接接受显示,不做任何操作的话,我们是无法触发事件(点击事件等等)的。 为了解决上述问题。 所以 你通过 vue-server-renderer 进行渲染的话, 会在根节点上附带一个 data-server-rendered="true" 的特殊属性。 让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式进行挂载
data-server-rendered="true"
Vue
HTML
**激活模式:**指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。 大概意思就是 服务端 已经渲染好了 html, 只不过服务端渲染过来的是静态页面,无法操作DOM 。 但是因为dom元素已经生成好了, 没有必要丢弃重新创建。 所以客户端便只需要激活这些静态页面,让他们变成动态的(能够响应后续的数据变化)就行。
DOM
dom
vue
beforeCreate
created
window
document
基本上只要你对node有了解,会配置webpack,vue能正常使用,基本上这东西实现起来还是比较轻松的,尤其官网给出了完整的例子HackerNews Demo,当然这个是基于express框架的,使用koa的话里面中间件的使用需要做点修改。其余的基本只需要跟着官网的例子来一遍就基本OK了 上面官网的例子需要终端翻墙才能访问数据,如果不想的话可以看下这个例子,跟官网例子基本一样掘金网站
node
webpack
express
koa
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来作为 数据预取存储容器
vuex
asyncData自定义函数(获取接口数据):
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
url
``` // server.js const context = { url: ctx.url } renderer.renderToString(context, (err, html) => { if (err) { return reject(err) } console.log(html) }) ```
entry-server.js
``` // 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) }) }) } ```
``` 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 配置
ssr
将CLI移入到 webpack-cli 中,需要安装 webpack-cli
webpack-cli
通过设置 mode 变量来确定当前模式, 不配置会有警告
mode
webpack --mode development
``` module.exports = { mode: 'development', entry: { app: resolve('src') }, ... ```
webpack.optimize.CommonsChunkPlugin has been removed, please use config.optimization.splitChunks instead
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 各一份, 不知道啥情况。
js
css
升级4以后,我用 DllPlugin打包, 但是 verdon 打包出来还是一样大,并不会把 我指定的 模块提取出来。
DllPlugin
import 做按需加载好像不生效。 例如:const _import_ = file => () => import(file + '.vue'), 然后通过 _import_('components/Foo') 便能直接按需加载, 但是webpack4就没生效,都是一次性加载出来的。
const _import_ = file => () => import(file + '.vue')
_import_('components/Foo')
上面是我们升级4遇到的几个问题,可能是我配置方面出错了,但是webpack4 以前都是正常的。 具体我这边的配置放到了 github 上。
以上就是我这次个人博客的 SSR 之旅。
github
博客地址
前言
因为自己的博客完全的前后端分离写的,在
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
优势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
entry-server.js
接收到contextasyncData
函数,但是我们只有第一次请求服务端需要渲染,以后再进行页面切换的时候不需要进行渲染的,但是 接口的调用 又放入了asyncData
函数中,所以页面切换的时候,我们客户都需要处理asyncData
函数,以前我们一般把数据放入created
钩子函数中,现在放入的时asyncData
里面,所以我们进行客户端切换的时候,需要执行它。获取数据基本上这样就实现了
vue-ssr
的过程,具体源码及配置可以在我的 github 查看。webpack4
最明显的点 是
webpack4
以后拥有默认值了,简单配置一下便能使用 以下是默认值:在 mode 为 develoment 时:
在 mode 为 production 时:
因为给自己博客做
ssr
的通知也升级了webpack,接下来便看下 迁移至webpack4
需要修改的部分webpack
配置将CLI移入到
webpack-cli
中,需要安装webpack-cli
通过设置
mode
变量来确定当前模式, 不配置会有警告webpack --mode development
webpack.optimize.CommonsChunkPlugin has been removed, please use config.optimization.splitChunks instead
webpack4
不再提供webpack.optimize.CommonsChunkPlugin
来分割代码,需要用到新的属性optimization.splitChunks
compilation.mainTemplate.applyPluginsWaterfall is not a function
Use Chunks.groupsIterable and filter by instanceof Entrypoint instead:
升级
webpack4
也遇到了几个问题设置
optimization.splitChunks
打包。分别会打包js
、css
各一份, 不知道啥情况。升级4以后,我用
DllPlugin
打包, 但是 verdon 打包出来还是一样大,并不会把 我指定的 模块提取出来。import 做按需加载好像不生效。 例如:
const _import_ = file => () => import(file + '.vue')
, 然后通过_import_('components/Foo')
便能直接按需加载, 但是webpack4
就没生效,都是一次性加载出来的。上面是我们升级4遇到的几个问题,可能是我配置方面出错了,但是
webpack4
以前都是正常的。 具体我这边的配置放到了 github 上。总结
以上就是我这次个人博客的
SSR
之旅。