前言
做C端产品免不了SEO(百度),SEO有个很关键点是非SPA页面,以目前前端的趋势都是SPA,因为SPA用户体验更好。而对于产品来说SEO很重要,又不想回到JQuery时代或者PHP,那么,只能搞搞SSR了。
建议:根据官方推荐,如果没特殊需要,还是使用nuxt.js
吧,简单直接
SSR简要分析
1、概述
- 用户通过url请求页面;
- 服务器根据url指向ssr渲染的node服务;
- node服务根据vue ssr插件生成的JSON执行vue代码(这里需要push url到vue-router);
- vue生成当前url的html的页面源码字符串,将store的数据放在
window.__INITIAL_STATE__
里; - node服务接收该字符串,并返回给浏览器;
- 浏览器渲染当前页面,检测是否有
window.__INITIAL_STATE__
,有则还原到vuex里 - 前端跳转到其他页面,则不进行ssr渲染,是客户端渲染(vue拦截了),但,刷新是服务端渲染
所以,ssr实际上是服务端渲染当前url的页面,直到下次刷新;因此,完美应用ssr能力,应该是传统的页面架构,即:点击开一个新tab的页面架构。
2、SSR构建后的基本文件
- index.html文件在SSR下是无用的,但是SPA下是根文件
- index.template.html是服务端渲染的页面模板
- server.js是服务端渲染的node执行文件
- vue-ssr-client-manifest.json是vue-server-renderer/client-plugin插件生成的客户端构建清单
- vue-ssr-server-bundle.json是vue-server-renderer/server-plugin插件生成的服务器配置传递给
createBundleRenderer
的 server bundle - 其他文件属于正常的源码文件,提供给
vue-ssr-server-bundle.json
生成页面使用的
完整项目server.js包含压缩、缓存等功能,这里仅做基础功能
3、用户请求
sequenceDiagram
用户 ->> 服务器: 请求图片
服务器 ->> node: 请求链接
node -->> 服务器: 返回图片
服务器 -->> 用户: 返回图片
用户 ->> 服务器: 请求页面
服务器 ->> node: 请求链接
node ->> node: 服务端运行vue代码(只执行到created生命周期)
node ->> node: 将vm通过vue-server-renderer/renderToString生成页面
node -->> 服务器: html字符串
服务器 --) 用户: 返回页面
4、node渲染页面过程(vue官网基本用法)
const path = require('path') // node path
const resolve = file => path.resolve(__dirname, file)
// 1.
// 引入index.template.html模板(包含:<!--vue-ssr-outlet-->,这个程序插入代码的地方)
// 引入createBundleRenderer、vue-ssr-server-bundle.json、vue-ssr-client-manifest.json
const template = fs.readFileSync(resolve('./index.template.html'), 'utf-8')
const { createBundleRenderer } = require('vue-server-renderer')
const bundle = require(resolve('./vue-ssr-server-bundle.json'))
const clientManifest = require(resolve('./vue-ssr-client-manifest.json'))
// 2.
// 创建 BundleRenderer 实例
const renderer = createBundleRenderer(bundle, {
runInNewContext: false,
template,
clientManifest
})
// 3.
// 生成页面, context是上下文
renderer.renderToString(context).then(html => {
console.log(html) // 这即是页面,返回给浏览器即可
}).catch(err => {
console.error(err)
})
上面代码只是分析过程,后面才是可运行代码。细心的同学发现,这里并没有引入页面代码,怎么渲染出页面的呢?其实只要打开
vue-ssr-server-bundle.json
文件看看就知道了。
生成代码及构建
生成代码
1. 使用vue create
快速生成脚手架
2. 源码结构,及代码(vue的结构主要是防止不同的用户访问到相同的内存数据)
熟悉基本原理之后,文件结构你就可以随意玩了。下面是主要改动的文件
router.js / createRouter
关键点:history模式
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/about',
name: 'About',
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
}
]
export function createRouter () {
return new VueRouter({
mode: 'history', // 这里必须是history模式,hash服务端是接收不到的
base: process.env.BASE_URL,
routes
})
}
store.js / createStore
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export function createStore () {
return new Vuex.Store({
state: {
},
mutations: {
},
actions: {
},
modules: {
}
})
}
app.js
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'
Vue.config.productionTip = false
export function createApp () {
const router = createRouter()
const store = createStore()
const app = new Vue({
router,
store,
render: h => h(App)
})
return { app, router, store }
}
entry-client.js
该文件,有些比较熟悉ssr的同学发现和官网最终的效果不一样,少了一些路由的处理,实际上,如果最终的项目架构是多页面模式(MPA),那么,这个就这样也没问题。前面我说了,ssr是渲染浏览器刷新时url的页面,之后的页面是SPA,就需要router,客户端模拟服务端请求接口的过程;如果是MPA,那么,就这样也是没问题的。
import { createApp } from './app'
const { app, store } = createApp()
// 服务端获取数据渲染给页面后,输入根据这个继续服务端的逻辑
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
app.$mount('#app')
当然,你写上,和用不用有什么关系?当然没关系。代码如下:
import { createApp } from './app.js'
// 客户端特定引导逻辑……
const { app, router, store } = createApp()
// store状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
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))
})
if (!activated.length) {
return next()
}
// 这里如果有加载指示器 (loading indicator),那么,就触发处理
Promise.all(activated.map(c => {
if (c.asyncData) {
return c.asyncData({ store, route: to })
}
})).then(() => {
// 停止加载指示器(loading indicator)
next()
}).catch(next)
})
app.$mount('#app')
})
entry-server.js
该代码的主要作用是,在渲染页面前,先请求数据,请求完数据塞进vuex里,再进行页面渲染, 如果接口卡很久,不好意思,页面就会卡很久
import { createApp } from './app.js'
export default content => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
// 请求的url,定位需要渲染的页面
router.push(content.url)
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
if (!matchedComponents.length) {
return resolve(app)
}
Promise.all(matchedComponents.map(component => {
// asyncData是页面的对象的key,跟data、created、mounted同级
// 是在页面运行前执行的代码,可以更改为其他的
if (component.asyncData) {
return component.asyncData({ store, route: router.currentRoute })
}
})).then(() => {
// content是哪里来的参数?console.log就知道了
// console.log(content)
// 该状态会存入window.__INITIAL_STATE__中,如下图
content.state = { ...store.state, desc: 'test' }
resolve(app)
}).catch(reject)
}, reject)
})
}
上面代码中content.state 对象的值:
index.template.html
别漏了<!--vue-ssr-outlet-->
,这是个标记,服务端渲染后的代码插入的地方,类似与vue的#app
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="keyword" content="{{ keyword }}">
<meta name="description" content="{{ description }}">
<title>{{ title }}</title>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
3. 更改vue.config.js
,生成服务端渲染需要的vue-ssr-server-bundle.json
和客户端需要vue-ssr-client-manifest.json
以及源码
1. 最终生成的dist文件
2. 更改package.json,生成目标代码文件
"scripts": {
"serve": "vue-cli-service serve",
"build": "npm run build:client && npm run build:server && node ./server/copy_server.js",
"build:client": "vue-cli-service build",
"build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build --no-clean",
"lint": "vue-cli-service lint"
}
node ./server/copy_server.js
是将上图server/server.js拷贝到dist文件夹下的node代码,后面用到- 执行
npm run build
生成上图除server.js外的代码 build:server
的参数--no-clean
不能去掉,否则执行npm run build:server
会删除npm run build:client
的dist
当然,你也可以更改
vue.config.js
的entry
,分别将文件放在不同的位置,just ok,以及cross-env依赖(npm install cross-env -D
)
3. 更改/新增vue.config.js,参考
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const nodeExternals = require('webpack-node-externals')
const WEBPACK_TARGET = process.env.WEBPACK_TARGET === 'node'
module.exports = {
css: {
extract: false
},
configureWebpack: {
entry: `./src/entry-${WEBPACK_TARGET ? 'server' : 'client'}.js`, // 根据关键指向不同的入口
devtool: 'source-map',
target: WEBPACK_TARGET ? 'node' : 'web',
node: WEBPACK_TARGET ? undefined : false,
output: {
libraryTarget: WEBPACK_TARGET ? 'commonjs2' : undefined // node环境源码的模块化规范
},
externals: WEBPACK_TARGET ? nodeExternals({ allowlist: [/\.css$/] }) : undefined,
optimization: {
splitChunks: undefined
},
plugins: [WEBPACK_TARGET ? new VueSSRServerPlugin() : new VueSSRClientPlugin()]
},
chainWebpack: config => {
if (WEBPACK_TARGET) {
config.optimization.delete('splitChunks')
}
config.module
.rule('vue')
.use('vue-loader')
.tap(options => {
options.optimizeSSR = false // https://vue-loader-v14.vuejs.org/zh-cn/options.html#optimizessr
return options
})
}
}
别忘了依赖: -D vue-server-renderer、-D webpack-node-externals
到这里,就可以执行npm run build
,生成除了server.js外的文件了
构建生产环境
完成以上步骤,距离我们的图中的源码仅剩一步之遥:server.js
记得
npm install koa koa-send -S
,因为node机器上有node没有koa和koa-send,当然要-S啦
1. server.js
const fs = require('fs')
const path = require('path')
const Koa = require('koa')
const send = require('koa-send')
const app = new Koa()
const resolve = file => path.resolve(__dirname, file)
// 第 2 步:获得一个createBundleRenderer
const template = fs.readFileSync(resolve('./index.template.html'), 'utf-8')
const { createBundleRenderer } = require('vue-server-renderer')
const bundle = require(resolve('./vue-ssr-server-bundle.json'))
const clientManifest = require(resolve('./vue-ssr-client-manifest.json'))
const renderer = createBundleRenderer(bundle, {
runInNewContext: false,
template,
clientManifest
})
function renderToString (content) {
return new Promise((resolve, reject) => {
renderer.renderToString(content, (err, html) => {
err ? reject(err) : resolve(html)
})
})
}
// 注入变量
app.use(async (ctx, next) => {
ctx.ssrContext = {
url: ctx.path + ctx.search,
title: 'title',
des: '是我,是我,就是我', // 这个是个测试,在entry-server.js的console content就能获取
description: 'description',
keyword: 'keyword'
}
await next()
})
// 第 3 步:添加一个中间件来处理所有请求
app.use(async (ctx, next) => {
const url = ctx.path
if (/[.](js|css|jpg|jpeg|png|gif|map|ico|cur|json|html|txt|svg|font|woff|ttf)$/.test(url)) {
console.log(`proxy ${url}`)
await send(ctx, url, { root: path.resolve(__dirname, './') })
return
}
ctx.res.setHeader('Content-Type', 'text/html')
try {
const html = await renderToString(ctx.ssrContext)
ctx.body = html
} catch (res) {
console.log(
`服务器catch异常:${
res instanceof Error ? res.stack : JSON.stringify(res)
}`
)
ctx.response.redirect(`/error/${res.code ? res.code : 500}`)
}
next()
})
const port = 3000
app.listen(port, function () {
console.log(`server started at localhost:${port}`)
})
2. copy_server.js
const fs = require('fs')
const path = require('path')
const resolve = (file) => path.resolve(__dirname, file)
const file = resolve('./server.js')
const fileName = path.basename(file)
fs.readFile(file, (err, data) => {
if (err) throw err
fs.writeFile(resolve(`../dist/${fileName}`), data, { encoding: 'utf-8' }, (err) => {
if (err) throw err
console.log('成功拷贝')
})
})
上文中的
node ./server/copy_server.js
,就是将server.js拷贝到dist文件中,所有代码添加后,执行npm run build
生成的dist文件应当与上面结果图一致;那么,就剩下最后一步了
执行server.js 查看结果
- cd dist
- node server.js
- 结果应当如下:
到这里ssr已经完成了,但是,是不是发现少了点什么?没错就是开发环境了。其实,前文讲述
entry-client.js
的时候已经描述过了,ssr只是渲染当前路由的页面,entry-client.js
的路由过滤了非服务端渲染、已渲染的组件。按照道理来说,只要在这里做一些处理就可以在客户端完美模式服务端过程,当然,需要的同构能力较强。
至于开发环境搭建,后期有空再写,如果急需,可以参考: 尤大的Demo