之前做了活动投放页面在百度、360等渠道投放,采用 koa2 + 模版引擎的方式。发现几个问题
- 相较于框架开发页面效率较低,维护性差
- 兼容性问题,在页面中添加埋点后发现有些用户的数据拿不到,排查后发现通过各个渠道过来的用户的设备中仍然包含大量低版本的浏览器。
服务端渲染
服务端渲染和单页面渲染区别
查看下面两张图,可以看到如果是服务端渲染,那么在浏览器中拿到的直接是完整的 html 结构。而单页面是一些 script 标签引入的js文件,最终将虚拟dom去挂在到 #app
容器上。
@vue/cli 4 来构建项目结构
下面代码使用最精简的实例完整代码会放到 github 上
step1 安装最新的脚手架初始化项目
yarn global add @vue/cli
step2 添加服务端文件
启动一个 web 服务下方代码中 http://localhost:9000
就是我们最终要访问到地址
const Koa = require('koa') const path = require('path') const resolve = file => path.resolve(__dirname, file) const app = new Koa() const router = require('./router') const port = 9000 app.listen(port, () => { console.log(`server started at localhost:${port}`) }) module.exports = app
这里只是启动了服务,我们需要在去读取服务端和客户端到文件,下面代码就是服务端渲染的关键步骤
const fs = require('fs') const path = require('path') const send = require('koa-send') const Router = require('koa-router') const router = new Router() // 获取当前文件的绝对路径 const resolve = file => path.resolve(__dirname, file) const { createBundleRenderer } = require('vue-server-renderer') const bundle = require('../dist/vue-ssr-server-bundle.json') const clientManifest = require('../dist/vue-ssr-client-manifest.json') // 创建一个 BunleRender 实例用于 renderer.renderToString 将 bundle 渲染为字符串 const renderer = createBundleRenderer(bundle, { runInNewContext: false, template: fs.readFileSync(resolve('../src/index.temp.html'), 'utf-8'), clientManifest: clientManifest }) const handleRequest = async ctx => { ctx.res.setHeader('Content-Type', 'text/html') // 在 2.5.0+ 版本中,此 callback 回调函数是可选项。在不传递 callback 时,此方法返回一个 Promise 对象,在其 resolve 后返回最终渲染的 HTML。 ctx.body = await renderer.renderToString(Object.assign({}, ctx.state.deliver, { url })) } router.get('/home',handleRequest) module.exports = router
vue-server-render
提供一个名为 createBundleRenderer 的 API 使用方法如下
const { createBundleRenderer } = require('vue-server-renderer') const renderer = createBundleRenderer(serverBundle, { runInNewContext: false, // 推荐 template, // (可选)页面模板 clientManifest // (可选)客户端构建 manifest })
通过上面的 createBundleRenderer 方法生产 render 对象最终将 bunlde 渲染为字符串,将最终的 html 返回给客户端。
bundleRenderer.renderToString([context, callback]): "color: #ff0000">step3 添加 entry-client.js,entry-server.js 入口文件在 src 中除了这两个入口文件,其他的文件都是在客户端和服务端公用的。来看下这两个入口文件中分别干了什么。
大体的流程就是:服务端创建 vue 实例,将页面中的异步请求的数据拿到存储在容器中 --> 客户端接收到服务端发送的 html 以激活模式进行挂载,自动给根元素 #app 上添加
data-server-rendered="true"
特殊属性main.js
import Vue from 'vue' import App from './App.vue' ... export function createApp() { // ... const app = new Vue({ router, store, render: h => h(App) }) return { app, router, store } }entry-server.js
import { createApp } from './main.js' export default context => { // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise, // 以便服务器能够等待所有的内容在渲染前, // 就已经准备就绪。 return new Promise((resolve, reject) => { const { app, router, store } = createApp() // 设置服务器端 router 的位置 router.push(context.url) // 等到 router 将可能的异步组件和钩子函数解析完 router.onReady(() => { const matchedComponents = router.getMatchedComponents() // 匹配不到的路由,执行 reject 函数,并返回 404 if (!matchedComponents.length) { return reject({ code: 404 }) } Promise.all( matchedComponents.map(component => { if (component.asyncData) { return component.asyncData({ store, context, route: router.currentRoute }) } }) ).then(() => { // 在所有预取钩子(preFetch hook) resolve 后, // 我们的 store 现在已经填充入渲染应用程序所需的状态。 // 当我们将状态附加到上下文, // 并且 `template` 选项用于 renderer 时, // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。 // 否则会导致客户端和服务端数据不统一造成渲染错误 context.state = store.state resolve(app) }).catch(reject) }, reject) }) }entry-client.js
import { createApp } from './main' const { app, router, store } = createApp() if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__) } router.onReady(() => { 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() } Promise.all( activated.map(component => { if (component.asyncData) { component.asyncData({ store, route: to }) } }) ) .then(() => { next() }) .catch(next) }) app.$mount('#app') })最后
完整代码参考 github地址
顺便贴上这张图