作者:京东零售 姜欣
关于服务器端渲染方案,之前只接触了基于react的Next.js,最近业务开发vue用的比较多,所以调研了一下vue的服务器端渲染方案。 首先:长文预警,下文包括了两种方案的实践,没有耐心的小伙伴可以直接跳到方案标题下,down代码体验一下。
前置知识:
1、什么是服务器端渲染(ssr)?
简单来说就是用户第一次请求页面时,页面上的内容是通过服务器端渲染生成的,浏览器直接显示服务端返回的完整html就可以,加快首屏显示速度。
举个栗子:
当我们访问一个商品列表时,如果使用客户端渲染(csr),浏览器会加载空白的页面,然后下载js文件,通过js在客户端请求数据并渲染页面。如果使用服务器端渲染(ssr),在请求商品列表页面时,服务器会获取所需数据并将渲染后的HTML发送给浏览器,浏览器一步到位直接展示,而不用等待数据加载和渲染,提高用户的首屏体验。
2、服务器端渲染的优缺点
优点:
(1)更好的seo:抓取工具可以直接查看完全渲染的页面。现在比较常用的交互是页面初始展示 loading 菊花图,然后通过异步请求获取内容,但是但抓取工具并不会等待异步完成后再行抓取页面内容。
(2)内容到达更快:不用等待所有的 js 都完成下载并执行,所以用户会更快速地看到完整渲染的页面。
缺点:
(1)服务器渲染应用程序,需要处于 Node.js server 运行环境
(2)开发成本比较高
总结:
总得来说,决定是否使用服务器端渲染,取决于具体的业务场景和需求。对于具有大量静态内容的简单页面,客户端渲染更合适一些,因为它可以更快地加载页面。但是对于需要从服务器动态加载数据的复杂页面,服务器端渲染可能是一个更好的选择,因为他可以提高用户的首屏体验和搜索引擎优化。
下面进入正文
方案一:vue插件vue-server-render
git 示例demo地址
结论前置:不建议用,配置成本高
官网地址:
https://v2.ssr.vuejs.org/zh/
首先要吐槽一下官网,按官网教程比较难搞,目录安排的不太合理,一顿操作项目都没起来...
并且官网示例的构建配置代码是webpack4的,现在初始化项目后基本安装的都是webpack5,有一些语法不同
(1)首先,先初始化一个npm项目,然后安装依赖得到一个基础项目 。(此处要注意vue-server-renderer 和 vue 必须匹配版本)
1npm init -y 2yarn add vue vue-server-renderer -S 3yarn add express -S 4yarn add webpack webpack-cli friendly-errors-webpack-plugin vue-loader babel-loader @babel/core url-loader file-loader vue-style-loader css-loader sass-loader sass webpack-merge webpack-node-externals -D 5yarn add clean-webpack-plugin @babel/preset-env -D 6yarn add rimraf // 模拟linx的删除命令,在build时先删除dist 7yarn add webpack-dev-middleware webpack-hot-middleware -D 8yarn add chokidar -D //监听变化 9yarn add memory-fs -D 10yarn add nodemon -D 11...实在太多,如有缺失可以在package.json中查找 12另外:我现在用的"vue-loader": "^15.9.0"版本,之前用的是"vue-loader": "^17.0.1",报了一个styles的错
(2)配置app.js,entry-client.js,entry-server.js,将官网参考中的示例代码拷贝至对应文件。
app.js
1import Vue from 'vue' 2import App from './App.vue' 3import { createRouter } from './router' 4import { createStore } from './store' 5import { sync } from 'vuex-router-sync' 6 7// 导出一个工厂函数,用于创建新的 8// 应用程序、router 和 store 实例 9export function createApp () { 10 // 创建 router 和 store 实例 11 const router = createRouter() 12 const store = createStore() 13 14 sync(store, router) 15 16 const app = new Vue({ 17 router, 18 store, 19 render: h => h(App) 20 }) 21 22 return { app, router, store } 23} 24 25
entry-client.js
1import Vue from 'vue' 2import { createApp } from './app' 3 4Vue.mixin({ 5 beforeMount () { 6 const { asyncData } = this.$options 7 if (asyncData) { 8 this.dataPromise = asyncData({ 9 store: this.$store, 10 route: this.$route 11 }) 12 } 13 } 14}) 15 16const { app, router, store } = createApp() 17 18if (window.__INITIAL_STATE__) { 19 store.replaceState(window.__INITIAL_STATE__) 20} 21 22router.onReady(() => { 23 // 在初始路由 resolve 后执行, 24 // 使用 `router.beforeResolve()`,以便确保所有异步组件都 resolve。 25 router.beforeResolve((to, from, next) => { 26 const matched = router.getMatchedComponents(to) 27 const prevMatched = router.getMatchedComponents(from) 28 29 // 找出两个匹配列表的差异组件 30 let diffed = false 31 const activated = matched.filter((c, i) => { 32 return diffed || (diffed = (prevMatched[i] !== c)) 33 }) 34 35 if (!activated.length) { 36 return next() 37 } 38 39 Promise.all(activated.map(c => { 40 if (c.asyncData) { 41 return c.asyncData({ store, route: to }) 42 } 43 })).then(() => { 44 next() 45 }).catch(next) 46 }) 47 48 app.$mount('#app') 49})
entry-server.js
1import { createApp } from './app' 2 3export default context => { 4 // 返回一个promise,服务器能够等待所有的内容在渲染前,已经准备就绪, 5 return new Promise((resolve, reject) => { 6 const { app, router, store } = createApp() 7 8 router.push(context.url) 9 10 router.onReady(() => { 11 const matchedComponents = router.getMatchedComponents() 12 if (!matchedComponents.length) { 13 return reject({ code: 404 }) 14 } 15 16 // 对所有匹配的路由组件调用 `asyncData()` 17 Promise.all(matchedComponents.map(Component => { 18 if (Component.asyncData) { 19 return Component.asyncData({ 20 store, 21 route: router.currentRoute 22 }) 23 } 24 })).then(() => { 25 context.state = store.state 26 27 resolve(app) 28 }).catch(reject) 29 }, reject) 30 }) 31}
(3)在根目录下创建server.js 文件
其中一个非常重要的api:createBundleRenderer,这个api上面有一个方法renderToString将代码转化成html字符串,主要功能就是把用webpack把打包后的服务端代码渲染出来。具体了解可看官网bundle renderer指引。
1// server.js 2const app = require('express')() 3const { createBundleRenderer } = require('vue-server-renderer') 4const fs = require('fs') 5const path = require('path') 6const resolve = file => path.resolve(__dirname, file) 7 8const isProd = process.env.NODE_ENE === "production" 9 10const createRenderer = (bundle, options) => { 11 return createBundleRenderer(bundle, Object.assign(options, { 12 basedir: resolve('./dist'), 13 runInNewContext: false, 14 })) 15} 16 17let renderer, readyPromise 18const templatePath = resolve('./src/index.template.html') 19if (isProd) { 20 const bundle = require('./dist/vue-ssr-server-bundle.json') 21 const clientManifest = require('./dist/vue-ssr-client-manifest.json') 22 const template = fs.readFileSync(templatePath, 'utf-8') 23 24 renderer = createRenderer(bundle, { 25 // 推荐 26 template, // (可选)页面模板 27 clientManifest // (可选)客户端构建 manifest 28 }) 29} else { 30 // 开发模式 31 readyPromise = require('./config/setup-dev-server')(app, templatePath, (bundle, options) => { 32 renderer = createRenderer(bundle, options) 33 }) 34} 35 36const render = (req, res) => { 37 const context = { 38 title: 'hello ssr with webpack', 39 meta: ` 40 <meta charset="UTF-8"> 41 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 42 <meta http-equiv="X-UA-Compatible" content="ie=edge"> 43 `, 44 url: req.url 45 } 46 renderer.renderToString(context, (err, html) => { 47 if (err) { 48 if (err.code === 404) { 49 res.status(404).end('Page not found') 50 } else { 51 res.status(500).end('Internal Server Error') 52 } 53 } else { 54 res.end(html) 55 } 56 }) 57} 58 59// 在服务器处理函数中…… 60app.get('*', isProd ? render : (req, res) => { 61 readyPromise.then(() => render(req, res)) 62}) 63 64app.listen(8080) // 监听的是8080端口
(4)接下来是config配置
在根目录新增config文件夹,然后新增四个配置文件:webpack.base.config,webpack.client.config,webpack.server.config,setup-dev-server(此方法是一个封装,为了配置个热加载,差点没搞明白,参考了好多)(官网传送门: 构建配置 )
大部分官网有示例代码,但是要在基础上进行一些更改
webpack.base.config
1// webpack.base.config 2const path = require('path') 3// 用来处理后缀为.vue的文件 4const { VueLoaderPlugin } = require('vue-loader') 5const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin') 6// 定位到根目录 7const resolve = (dir) => path.join(path.resolve(__dirname, "../"), dir) 8 9// 打包时会先清除一下 10// const { CleanWebpackPlugin } = require('clean-webpack-plugin') 11 12const isProd = process.env.NODE_ENV === "production" 13 14module.exports = { 15 mode: isProd ? 'production' : 'development', 16 output: { 17 path: resolve('dist'), 18 publicPath: '/dist/', 19 filename: '[name].[chunk-hash].js' 20 }, 21 resolve: { 22 alias: { 23 'public': resolve('public') 24 } 25 }, 26 module: { 27 noParse: /es6-promise\.js$/, 28 rules: [ 29 { 30 test: /\.vue$/, 31 loader: 'vue-loader', 32 options: { 33 compilerOptions: { 34 preserveWhiteSpace: false 35 } 36 } 37 }, 38 { 39 test: /\.js$/, 40 loader: 'babel-loader', 41 exclude: /node_modules/ 42 }, 43 { 44 test: /\.(png|jpg|gif|svg)$/, 45 loader: 'url-loader', 46 options: { 47 limit: 10000, 48 name: '[name].[ext]?[hash]' 49 } 50 }, 51 { 52 test: /\.s(a|c)ss?$/, 53 use: ['vue-style-loader', 'css-loader', 'sass-loader'] 54 } 55 ] 56 }, 57 performance: { 58 hints: false 59 }, 60 plugins:[ 61 new VueLoaderPlugin(), 62 // 编译后的友好提示,比如编译完成或者编译有错误 63 new FriendlyErrorsWebpackPlugin(), 64 // 打包时会先清除一下 65 // new CleanWebpackPlugin() 66 ] 67}
webpack.client.config
1// webpack.client.config 2const {merge} = require('webpack-merge') 3const baseConfig = require('./webpack.base.config.js') 4const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') 5 6module.exports = merge(baseConfig, { 7 entry: { 8 app: './src/entry-client.js' 9 }, 10 optimization: { 11 // 重要信息:这将 webpack 运行时分离到一个引导 chunk 中, 12 // 以便可以在之后正确注入异步 chunk。 13 // 这也为你的 应用程序/vendor 代码提供了更好的缓存。 14 splitChunks: { 15 name: "manifest", 16 minChunks: Infinity 17 } 18 }, 19 plugins: [ 20 // 此插件在输出目录中 21 // 生成 `vue-ssr-client-manifest.json`。 22 new VueSSRClientPlugin() 23 ] 24})
webpack.server.config
1// webpack.server.config 2const {merge} = require('webpack-merge') 3const nodeExternals = require('webpack-node-externals') 4 5// webpack的基础配置,比如sass,less预编译等 6const baseConfig = require('./webpack.base.config.js') 7const VueSSRServerPlugin = require('vue-server-renderer/server-plugin') 8 9module.exports = merge(baseConfig, { 10 // 将 entry 指向应用程序的 server entry 文件 11 entry: './src/entry-server.js', 12 13 target: 'node', 14 15 // 对 bundle renderer 提供 source map 支持 16 devtool: 'source-map', 17 18 // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports) 19 output: { 20 libraryTarget: 'commonjs2' 21 }, 22 23 // https://webpack.js.org/configuration/externals/#function 24 // https://github.com/liady/webpack-node-externals 25 // 外置化应用程序依赖模块。可以使服务器构建速度更快, 26 // 并生成较小的 bundle 文件。 27 externals: nodeExternals({ 28 // 不要外置化 webpack 需要处理的依赖模块。 29 // 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件, 30 // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单 31 allowlist: /\.css$/ 32 }), 33 34 // 这是将服务器的整个输出 35 // 构建为单个 JSON 文件的插件。 36 // 默认文件名为 `vue-ssr-server-bundle.json` 37 plugins: [ 38 new VueSSRServerPlugin() 39 ] 40})
setup-dev-server:封装createRenderer方法
1const webpack = require('webpack') 2const fs = require('fs') 3const path = require('path') 4const chokidar = require('chokidar') 5const middleware = require("webpack-dev-middleware") 6const HMR = require("webpack-hot-middleware") 7const MFS = require('memory-fs') 8 9const clientConfig = require('./webpack.client.config') 10const serverConfig = require('./webpack.server.config') 11 12const readFile = (fs, file) => { 13 try { 14 return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf8') 15 } catch (error) { 16 17 } 18} 19 20const setupServer = (app, templatePath, cb) => { 21 let bundle 22 let clientManifest 23 let template 24 let ready 25 const readyPromise = new Promise(r => ready = r) 26 27 template = fs.readFileSync(templatePath, 'utf8') 28 const update = () => { 29 if (bundle && clientManifest) { 30 // 通知 server 进行渲染 31 // 执行 createRenderer -> RenderToString 32 ready() 33 cb(bundle, { 34 template, 35 clientManifest 36 }) 37 } 38 } 39 // webpack -> entry-server -> bundle 40 const mfs = new MFS(); 41 const serverCompiler = webpack(serverConfig); 42 43 serverCompiler.outputFileSystem = mfs; 44 serverCompiler.watch({}, (err, stats) => { 45 if (err) throw err 46 // 之后读取输出: 47 stats = stats.toJson() 48 stats.errors.forEach(err => console.error(err)) 49 stats.warnings.forEach(err => console.warn(err)) 50 if (stats.errors.length) return 51 bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json')) 52 update() 53 }); 54 55 clientConfig.plugins.push( 56 new webpack.HotModuleReplacementPlugin() 57 ) 58 clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app] 59 clientConfig.output.filename = '[name].js' 60 61 const clientCompiler = webpack(clientConfig); 62 63 const devMiddleware = middleware(clientCompiler, { 64 noInfo: true, publicPath: clientConfig.output.publicPath, logLevel: 'silent' 65 }) 66 app.use(devMiddleware); 67 68 app.use(HMR(clientCompiler)); 69 70 clientCompiler.hooks.done.tap('clientsBuild', stats => { 71 stats = stats.toJson() 72 stats.errors.forEach(err => console.error(err)) 73 stats.warnings.forEach(err => console.warn(err)) 74 if (stats.errors.length) return 75 clientManifest = JSON.parse(readFile( 76 devMiddleware.fileSystem, 77 'vue-ssr-client-manifest.json' 78 )) 79 update() 80 }) 81 82 // fs -> templatePath -> template 83 chokidar.watch(templatePath).on('change', () => { 84 template = fs.readFileSync(templatePath, 'utf8') 85 console.log('template is updated'); 86 update() 87 }) 88 89 return readyPromise 90} 91 92module.exports = setupServer
(5)配置搞完了接下来是代码渲染
在src目录下,新增index.template.html文件,将官网中的例子(地址:使用一个页面模板 )复制,并进行一些更改
1<html> 2<head> 3 <!-- 使用双花括号(double-mustache)进行 HTML 转义插值(HTML-escaped interpolation) --> 4 <title>{{ title }}</title> 5 6 <!-- 使用三花括号(triple-mustache)进行 HTML 不转义插值(non-HTML-escaped interpolation) --> 7 {{{ meta }}} 8</head> 9<body> 10<!--这个是告诉我们在哪里插入正文的内容--> 11<!--vue-ssr-outlet--> 12</body> 13</html>
(6)再搞个store和api模拟一下数据请求
这里介绍一下一个很重要的东西asyncData 预取数据,预取数据是在vue挂载前,所以下文这里用了上下文来获取store而不是this
asyncData: ({ store }) => { return store.dispatch('getDataAction') },
在src下创建api文件夹,并在下面创建data.js文件
1// data.js 2const getData = () => new Promise((resolve) => { 3 setTimeout(() => { 4 resolve([ 5 { 6 id: 1, 7 item: '测试1' 8 }, 9 { 10 id: 2, 11 item: '测试2' 12 }, 13 ]) 14 }, 1000) 15}) 16 17export { 18 getData 19}
在src下创建store文件夹,并在下面创建index.js文件
1// store.js 2import Vue from 'vue' 3import Vuex from 'vuex' 4 5Vue.use(Vuex) 6 7import { getData } from '../api/data' 8 9export function createStore () { 10 return new Vuex.Store({ 11 state: { 12 lists: [] 13 }, 14 actions: { 15 getDataAction ({ commit }) { 16 return getData().then((res) => { 17 commit('setData', res) 18 }) 19 } 20 }, 21 mutations: { 22 setData (state, data) { 23 state.lists = data 24 } 25 } 26 }) 27}
(7)编写组件,在src/components文件夹下写两个组件,在app.vue中引用一下,用上刚写的模拟数据
Hello.vue
1<template> 2 <div> 3 这里是测试页面一 4 <p>{{item}}</p> 5 <router-link to="/hello1">链接到测试页面二</router-link> 6 </div> 7</template> 8 9<script> 10export default { 11 asyncData: ({ store }) => { 12 return store.dispatch('getDataAction') 13 }, 14 computed: { 15 item () { 16 return this.$store.state.lists 17 } 18 } 19} 20</script> 21 22<style lang="scss" scoped> 23</style>
Hello1.vue
1<template> 2 <div>这里是测试页面二{{item}}</div> 3</template> 4 5<script> 6export default { 7 asyncData: ({ store }) => { 8 return store.dispatch('getDataAction') 9 }, 10 computed: { 11 item () { 12 return this.$store.state.lists 13 } 14 } 15} 16</script> 17 18<style lang="scss" scoped> 19</style>
(8)配置路由并在app.vue使用路由
router.js
1import Vue from 'vue' 2import Router from 'vue-router' 3 4Vue.use(Router) 5 6export function createRouter () { 7 return new Router({ 8 mode: 'history', 9 routes: [ 10 { 11 path: '/hello', 12 component: () => import('./components/Hello.vue') 13 }, 14 { 15 path: '/hello1', 16 component: () => import('./components/Hello1.vue') 17 }, 18 ] 19 }) 20}
app.vue
1<template> 2 <div id="app"> 3 <router-view></router-view> 4 </div> 5</template> 6 7<script> 8 9export default { 10 name: 'App', 11} 12</script> 13 14<style lang="scss" scoped> 15</style>
(9)根目录下创建一个.babelrc,进行配置
1{ 2 "presets": [ 3 [ 4 "@babel/preset-env", 5 { 6 "modules": false 7 } 8 ] 9 ] 10}
(10)改写package.json执行命令
1"dev": "nodemon server.js", 2"build": "rimraf dist && npm run build:client && npm run build:server", 3"build:client": "webpack --config config/webpack.client.config.js", 4"build:server": "webpack --config config/webpack.server.config.js"
大搞告成,执行一下dev命令,可以通过访问localhost:8080端口看到页面,记得带上路由哦~
执行build命令可看到,最后dist文件下共有三个文件:main.[chunk-hash].js,
vue-ssr-client-manifest.json,vue-ssr-server-bundle.json
附上文件整体目录结构
方案二:基于vue的nuxt.js通用应用框架
git 示例demo地址
一对比,这个就显得丝滑多了~ 官网地址: nuxt.js
先对比一下两种方案的差别
11.vue初始化虽然有cli,但是nuxt.js的cli更加完备 22.nuxt有更合理的工程化目录,vue过于简洁,比如一些component,api文件夹都是要手动创建的 33.路由配置:传统应用需要自己来配置,nuxt.js自动生成 44.没有统一配置,需手动创建。nuxt.js会生成nuxt.config.js 55.传统不易与管理底层框架逻辑(nuxt支持中间件管理,虽然我还没探索过这里)
显而易见这个上手就快多了,也不需要安装一大堆依赖,如果用了sass需要安装sass和sass-loader,反正我是用了
(1)创建一个项目 可选npm,npx,yarn,具体看官方文档
npm init nuxt-app <project-name>
(2)pages下面创建几个文件
nuxt是通过pages页面形成动态的路由,不用手动配置路由。比如在pages下面新增了个文件about.vue,那么这个页面对应的路由就是/about
其实这个时候运行npm run dev 就可以看到简单的页面了
(3)模拟接口
这里介绍一个插件,可以快速创建一个服务
npm i json-server
安装完后,在根目录新增db.json文件,模拟几个接口
1{ 2 "post": [{"id": 1, "title": "json-server", "author": "jx"}], 3 "comments": [{"id": 1, "body": "some comment", "postId": 1}], 4 "profile": {"name": "typicode"} 5}
运行命令json-server --watch db.json --port=8000(不加会端口冲突),就可以看到
因为是get请求,可以直接点击访问可以看到mock的数据已经返回了
(4)页面调用
先配置一下axios,推荐使用nuxt.js封装的axios:"@nuxtjs/axios": "^5.13.6",然后再在nuxt.config.js文件中modules下面配置一下就可以使用了
modules: [ '@nuxtjs/axios'],
随便找个接口调用一下
1<template> 2 <div> 3 <div> 4 这里是测试页面一 5 </div> 6 接口返回数据:{{posts}} 7 </div> 8</template> 9 10<script> 11export default { 12 name: 'IndexPage', 13 async asyncData({$axios}){ 14 const result = await $axios.get('http://localhost:8000/post') 15 return{ 16 posts: result.data 17 } 18 } 19} 20</script>
刷新下页面就可以看到效果了,这里注意$axios有两个get方法,一个$axios.get还会返回头部等信息,另一个$axios.$get只返回结果
总结:
从页面篇幅上应该也能看到哪个容易上手了,nuxt相对于插件来说限定了文件夹的结构,并通过此预定了一些功能,更好上手。预设了利用vue.js开发服务端渲染所需要的各种配置,并且提供了提供了静态站点,异步数据加载,中间件支持,布局支持等
