写点什么

vue 的两种服务器端渲染方案

  • 2023-02-27
    四川
  • 本文字数:10158 字

    阅读完需:约 33 分钟

vue的两种服务器端渲染方案

作者:京东零售 姜欣


关于服务器端渲染方案,之前只接触了基于 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 必须匹配版本)

npm init -yyarn add vue vue-server-renderer -Syarn add express -Syarn 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 -Dyarn add clean-webpack-plugin @babel/preset-env -Dyarn add rimraf // 模拟linx的删除命令,在build时先删除distyarn add webpack-dev-middleware webpack-hot-middleware -Dyarn add chokidar  -D //监听变化yarn add memory-fs -Dyarn add nodemon -D...实在太多,如有缺失可以在package.json中查找另外:我现在用的"vue-loader": "^15.9.0"版本,之前用的是"vue-loader": "^17.0.1",报了一个styles的错
复制代码

(2)配置 app.js,entry-client.js,entry-server.js,将官网参考中的示例代码(传送门: 构建配置 )拷贝至对应文件。

app.js


import Vue from 'vue'import App from './App.vue'import { createRouter } from './router'import { createStore } from './store'import { sync } from 'vuex-router-sync'
// 导出一个工厂函数,用于创建新的// 应用程序、router 和 store 实例export function createApp () { // 创建 router 和 store 实例 const router = createRouter() const store = createStore() sync(store, router)
const app = new Vue({ router, store, render: h => h(App) })
return { app, router, store }}
复制代码


entry-client.js


import Vue from 'vue'import { createApp } from './app'
Vue.mixin({ beforeMount () { const { asyncData } = this.$options if (asyncData) { this.dataPromise = asyncData({ store: this.$store, route: this.$route }) } }})
const { app, router, store } = createApp()
if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__)}
router.onReady(() => { // 在初始路由 resolve 后执行, // 使用 `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() }
Promise.all(activated.map(c => { if (c.asyncData) { return c.asyncData({ store, route: to }) } })).then(() => { next() }).catch(next) })
app.$mount('#app')})
复制代码


entry-server.js


import { createApp } from './app'
export default context => { // 返回一个promise,服务器能够等待所有的内容在渲染前,已经准备就绪, return new Promise((resolve, reject) => { const { app, router, store } = createApp() router.push(context.url)
router.onReady(() => { const matchedComponents = router.getMatchedComponents() if (!matchedComponents.length) { return reject({ code: 404 }) }
// 对所有匹配的路由组件调用 `asyncData()` Promise.all(matchedComponents.map(Component => { if (Component.asyncData) { return Component.asyncData({ store, route: router.currentRoute }) } })).then(() => { context.state = store.state
resolve(app) }).catch(reject) }, reject) })}
复制代码

(3)在根目录下创建 server.js 文件

其中一个非常重要的 api:createBundleRenderer,这个 api 上面有一个方法 renderToString 将代码转化成 html 字符串,主要功能就是把用 webpack 把打包后的服务端代码渲染出来。具体了解可看官网 bundle renderer 指引(传送门: bundle renderer指引


// server.jsconst app = require('express')()const { createBundleRenderer } = require('vue-server-renderer')const fs = require('fs')const path = require('path')const resolve = file => path.resolve(__dirname, file)
const isProd = process.env.NODE_ENE === "production"
const createRenderer = (bundle, options) => { return createBundleRenderer(bundle, Object.assign(options, { basedir: resolve('./dist'), runInNewContext: false, }))}
let renderer, readyPromiseconst templatePath = resolve('./src/index.template.html')if (isProd) { const bundle = require('./dist/vue-ssr-server-bundle.json') const clientManifest = require('./dist/vue-ssr-client-manifest.json') const template = fs.readFileSync(templatePath, 'utf-8')
renderer = createRenderer(bundle, { // 推荐 template, // (可选)页面模板 clientManifest // (可选)客户端构建 manifest })} else { // 开发模式 readyPromise = require('./config/setup-dev-server')(app, templatePath, (bundle, options) => { renderer = createRenderer(bundle, options) })}
const render = (req, res) => { const context = { title: 'hello ssr with webpack', meta: ` <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> `, url: req.url } renderer.renderToString(context, (err, html) => { if (err) { if (err.code === 404) { res.status(404).end('Page not found') } else { res.status(500).end('Internal Server Error') } } else { res.end(html) } })}
// 在服务器处理函数中……app.get('*', isProd ? render : (req, res) => { readyPromise.then(() => render(req, res))})
app.listen(8080) // 监听的是8080端口
复制代码

(4)接下来是 config 配置

在根目录新增 config 文件夹,然后新增四个配置文件:webpack.base.config,webpack.client.config,webpack.server.config,setup-dev-server(此方法是一个封装,为了配置个热加载,差点没搞明白,参考了好多)(官网传送门: 构建配置


大部分官网有示例代码,但是要在基础上进行一些更改


webpack.base.config


// webpack.base.configconst path = require('path')// 用来处理后缀为.vue的文件const { VueLoaderPlugin } = require('vue-loader')const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin')// 定位到根目录const resolve = (dir) => path.join(path.resolve(__dirname, "../"), dir)
// 打包时会先清除一下// const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const isProd = process.env.NODE_ENV === "production"
module.exports = { mode: isProd ? 'production' : 'development', output: { path: resolve('dist'), publicPath: '/dist/', filename: '[name].[chunk-hash].js' }, resolve: { alias: { 'public': resolve('public') } }, module: { noParse: /es6-promise.js$/, rules: [ { test: /.vue$/, loader: 'vue-loader', options: { compilerOptions: { preserveWhiteSpace: false } } }, { test: /.js$/, loader: 'babel-loader', exclude: /node_modules/ }, { test: /.(png|jpg|gif|svg)$/, loader: 'url-loader', options: { limit: 10000, name: '[name].[ext]?[hash]' } }, { test: /.s(a|c)ss?$/, use: ['vue-style-loader', 'css-loader', 'sass-loader'] } ] }, performance: { hints: false }, plugins:[ new VueLoaderPlugin(), // 编译后的友好提示,比如编译完成或者编译有错误 new FriendlyErrorsWebpackPlugin(), // 打包时会先清除一下 // new CleanWebpackPlugin() ]}
复制代码


webpack.client.config


// webpack.client.configconst {merge} = require('webpack-merge')const baseConfig = require('./webpack.base.config.js')const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
module.exports = merge(baseConfig, { entry: { app: './src/entry-client.js' }, optimization: { // 重要信息:这将 webpack 运行时分离到一个引导 chunk 中, // 以便可以在之后正确注入异步 chunk。 // 这也为你的 应用程序/vendor 代码提供了更好的缓存。 splitChunks: { name: "manifest", minChunks: Infinity } }, plugins: [ // 此插件在输出目录中 // 生成 `vue-ssr-client-manifest.json`。 new VueSSRClientPlugin() ]})
复制代码


webpack.server.config


// webpack.server.configconst {merge} = require('webpack-merge')const nodeExternals = require('webpack-node-externals')
// webpack的基础配置,比如sass,less预编译等const baseConfig = require('./webpack.base.config.js')const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
module.exports = merge(baseConfig, { // 将 entry 指向应用程序的 server entry 文件 entry: './src/entry-server.js',
target: 'node',
// 对 bundle renderer 提供 source map 支持 devtool: 'source-map',
// 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports) output: { libraryTarget: 'commonjs2' },
// https://webpack.js.org/configuration/externals/#function // https://github.com/liady/webpack-node-externals // 外置化应用程序依赖模块。可以使服务器构建速度更快, // 并生成较小的 bundle 文件。 externals: nodeExternals({ // 不要外置化 webpack 需要处理的依赖模块。 // 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件, // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单 allowlist: /.css$/ }),
// 这是将服务器的整个输出 // 构建为单个 JSON 文件的插件。 // 默认文件名为 `vue-ssr-server-bundle.json` plugins: [ new VueSSRServerPlugin() ]})
复制代码


setup-dev-server:封装 createRenderer 方法


const webpack = require('webpack')const fs = require('fs')const path = require('path')const chokidar = require('chokidar')const middleware = require("webpack-dev-middleware")const HMR = require("webpack-hot-middleware")const MFS = require('memory-fs')
const clientConfig = require('./webpack.client.config')const serverConfig = require('./webpack.server.config')
const readFile = (fs, file) => { try { return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf8') } catch (error) {
}}
const setupServer = (app, templatePath, cb) => { let bundle let clientManifest let template let ready const readyPromise = new Promise(r => ready = r)
template = fs.readFileSync(templatePath, 'utf8') const update = () => { if (bundle && clientManifest) { // 通知 server 进行渲染 // 执行 createRenderer -> RenderToString ready() cb(bundle, { template, clientManifest }) } } // webpack -> entry-server -> bundle const mfs = new MFS(); const serverCompiler = webpack(serverConfig);
serverCompiler.outputFileSystem = mfs; serverCompiler.watch({}, (err, stats) => { if (err) throw err // 之后读取输出: stats = stats.toJson() stats.errors.forEach(err => console.error(err)) stats.warnings.forEach(err => console.warn(err)) if (stats.errors.length) return bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json')) update() });
clientConfig.plugins.push( new webpack.HotModuleReplacementPlugin() ) clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app] clientConfig.output.filename = '[name].js'
const clientCompiler = webpack(clientConfig);
const devMiddleware = middleware(clientCompiler, { noInfo: true, publicPath: clientConfig.output.publicPath, logLevel: 'silent' }) app.use(devMiddleware);
app.use(HMR(clientCompiler));
clientCompiler.hooks.done.tap('clientsBuild', stats => { stats = stats.toJson() stats.errors.forEach(err => console.error(err)) stats.warnings.forEach(err => console.warn(err)) if (stats.errors.length) return clientManifest = JSON.parse(readFile( devMiddleware.fileSystem, 'vue-ssr-client-manifest.json' )) update() })
// fs -> templatePath -> template chokidar.watch(templatePath).on('change', () => { template = fs.readFileSync(templatePath, 'utf8') console.log('template is updated'); update() })
return readyPromise}
module.exports = setupServer
复制代码

(5)配置搞完了接下来是代码渲染

在 src 目录下,新增 index.template.html 文件,将官网中的例子(地址:使用一个页面模板 )复制,并进行一些更改


<html><head>    <!-- 使用双花括号(double-mustache)进行 HTML 转义插值(HTML-escaped interpolation) -->    <title>{{ title }}</title>
<!-- 使用三花括号(triple-mustache)进行 HTML 不转义插值(non-HTML-escaped interpolation) --> {{{ meta }}}</head><body><!--这个是告诉我们在哪里插入正文的内容--><!--vue-ssr-outlet--></body></html>
复制代码

(6)再搞个 store 和 api 模拟一下数据请求

这里介绍一下一个很重要的东西 asyncData 预取数据,预取数据是在 vue 挂载前,所以下文这里用了上下文来获取 store 而不是 this


asyncData: ({ store }) => { return store.dispatch('getDataAction') },
复制代码


在 src 下创建 api 文件夹,并在下面创建 data.js 文件


// data.jsconst getData = () => new Promise((resolve) => {    setTimeout(() => {        resolve([            {                id: 1,                item: '测试1'            },            {                id: 2,                item: '测试2'            },        ])    }, 1000)})
export { getData}
复制代码


在 src 下创建 store 文件夹,并在下面创建 index.js 文件


// store.jsimport Vue from 'vue'import Vuex from 'vuex'
Vue.use(Vuex)
import { getData } from '../api/data'
export function createStore () { return new Vuex.Store({ state: { lists: [] }, actions: { getDataAction ({ commit }) { return getData().then((res) => { commit('setData', res) }) } }, mutations: { setData (state, data) { state.lists = data } } })}
复制代码

(7)编写组件,在 src/components 文件夹下写两个组件,在 app.vue 中引用一下,用上刚写的模拟数据

Hello.vue


<template>  <div>    这里是测试页面一    <p>{{item}}</p>    <router-link to="/hello1">链接到测试页面二</router-link>  </div></template>
<script>export default { asyncData: ({ store }) => { return store.dispatch('getDataAction') }, computed: { item () { return this.$store.state.lists } }}</script>
<style lang="scss" scoped></style>
复制代码


Hello1.vue


<template>  <div>这里是测试页面二{{item}}</div></template>
<script>export default { asyncData: ({ store }) => { return store.dispatch('getDataAction') }, computed: { item () { return this.$store.state.lists } }}</script>
<style lang="scss" scoped></style>
复制代码

(8)配置路由并在 app.vue 使用路由

router.js


import Vue from 'vue'import Router from 'vue-router'
Vue.use(Router)
export function createRouter () { return new Router({ mode: 'history', routes: [ { path: '/hello', component: () => import('./components/Hello.vue') }, { path: '/hello1', component: () => import('./components/Hello1.vue') }, ] })}
复制代码


app.vue


<template>  <div id="app">    <router-view></router-view>  </div></template>
<script>
export default { name: 'App',}</script>
<style lang="scss" scoped></style>
复制代码

(9)根目录下创建一个.babelrc,进行配置

{  "presets": [    [      "@babel/preset-env",      {        "modules": false      }    ]  ]}
复制代码

(10)改写 package.json 执行命令

"dev": "nodemon server.js","build": "rimraf dist && npm run build:client && npm run build:server","build:client": "webpack --config config/webpack.client.config.js","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


先对比一下两种方案的差别


1.vue初始化虽然有cli,但是nuxt.js的cli更加完备2.nuxt有更合理的工程化目录,vue过于简洁,比如一些component,api文件夹都是要手动创建的3.路由配置:传统应用需要自己来配置,nuxt.js自动生成4.没有统一配置,需手动创建。nuxt.js会生成nuxt.config.js5.传统不易与管理底层框架逻辑(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 文件,模拟几个接口


{  "post": [{"id": 1, "title": "json-server", "author": "jx"}],  "comments": [{"id": 1, "body": "some comment", "postId": 1}],  "profile": {"name": "typicode"}}
复制代码


运行命令 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'],
复制代码


随便找个接口调用一下


<template>  <div>    <div>      这里是测试页面一    </div>    接口返回数据:{{posts}}  </div></template>
<script>export default { name: 'IndexPage', async asyncData({$axios}){ const result = await $axios.get('http://localhost:8000/post') return{ posts: result.data } }}</script>
复制代码


刷新下页面就可以看到效果了,这里注意axios.get 还会返回头部等信息,另一个get 只返回结果

总结:

从页面篇幅上应该也能看到哪个容易上手了,nuxt 相对于插件来说限定了文件夹的结构,并通过此预定了一些功能,更好上手。预设了利用 vue.js 开发服务端渲染所需要的各种配置,并且提供了提供了静态站点,异步数据加载,中间件支持,布局支持等

发布于: 刚刚阅读数: 5
用户头像

拥抱技术,与开发者携手创造未来! 2018-11-20 加入

我们将持续为人工智能、大数据、云计算、物联网等相关领域的开发者,提供技术干货、行业技术内容、技术落地实践等文章内容。京东云开发者社区官方网站【https://developer.jdcloud.com/】,欢迎大家来玩

评论

发布
暂无评论
vue的两种服务器端渲染方案_Vue_京东科技开发者_InfoQ写作社区