您的位置:首页 > Web前端 > Vue.js

快速搭建“服务端渲染”的网站 vue ssr

2019-12-20 12:05 1666 查看

【推荐】2019 Java 开发者跳槽指南.pdf(吐血整理) >>>

 

一、什么是“服务端渲染”?

1. 传统ssr:

当客户端浏览器发起一个地址请求时,服务端直接返回完整的HTML内容给浏览器进行渲染。

2. vue ssr:

将原本Vue.js (构建客户端应用程序的框架)输出在浏览器中的 Vue 组件由服务器端()渲染为 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。

二、什么情况需要“服务端渲染”?

1. 相比传统 SPA (单页应用程序 (Single-Page Application)) ,服务器端渲染 (SSR) 的优势主要在于:

  • 更好的 SEO(搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。目前Google 和 Bing 可以很好对同步 JavaScript 应用程序进行索引):

    如果你的应用程序初始展示 loading 菊花图,然后通过 Ajax 获取内容,抓取工具并不会等待异步完成后再行抓取页面内容。也就是说,如果 SEO 对你的站点至关重要,而你的页面又是异步获取内容,则你可能需要服务器端渲染(SSR)解决此问题。

  • 更快的内容到达时间 (time-to-content,无需等待所有的 js都下载并执行完,才显示完整的数据,所以用户将会更快速地看到完整渲染的页面):

    网络或设备运行缓慢的情况通常可以改善的用户体验,并且对于那些「内容到达时间(time-to-content) 与转化率直接相关」的应用程序而言,服务器端渲染 (SSR) 至关重要,可以帮助你实现最佳的初始加载性能。

2. 服务器端渲染 (SSR)需要注意:

  • 开发条件所限。浏览器特定的代码,只能在某些生命周期钩子函数 (lifecycle hook) 中使用;一些外部扩展库 (external library) 可能需要特殊处理,才能在服务器渲染应用程序中运行。

  • 涉及构建设置和部署的更多要求。与可以部署在任何静态文件服务器上的完全静态单页面应用程序 (SPA) 不同,服务器渲染应用程序,需要处于 Node.js server 运行环境。

  • 更多的服务器端负载。在 Node.js 中渲染完整的应用程序,显然会比仅仅提供静态文件的 server 更加大量占用 CPU 资源 (CPU-intensive - CPU 密集),因此如果你预料在高流量环境 (high traffic) 下使用,请准备相应的服务器负载,并明智地采用缓存策略。

三、“预渲染”VS“服务端渲染”

如果你的项目只有少数营销页面需要SEO ,那么你可能只需要预渲染。在构建时 (build time) 针对特定路由简单地生成静态 HTML 文件。预渲染优点是:设置更简单,并可以将你的前端作为一个完全静态的站点,无需使用 web 服务器实时动态编译 HTML。

四、快速搭建vue ssr

1. 一个简单好理解的demo

准备:

  • 推荐使用 Node.js 版本 6+。
  • vue-server-renderer 和 vue 必须匹配版本。
  • vue-server-renderer 依赖一些 Node.js 原生模块,因此只能在 Node.js 中使用。
npm install vue
npm install vue vue-server-renderer --save
npm install express --save

开始:server.js

//引入
const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer()

server.get('*', (req, res) => {
// 第 1 步:创建一个 Vue 实例
const app = new Vue({
data: {
hello: 'hello,vue ssr'
},
template: `<div>{{ hello }}</div>`
})
// 第 3 步:将 Vue 实例渲染为 HTML 字符串
renderer.renderToString(app, (err, html) => {
if (err) {
res.status(500).end('Internal Server Error')
return
}
//第 4 步:将拼接好的完整HTML发送给客户端让浏览器直接渲染
res.end(`
<!DOCTYPE html>
<html lang="en">
<head><title>Hello</title></head>
<body>${html}</body>
</html>
`)
})
})
//监听端口
server.listen(8080)

运行:

node server.js

结果:可以看到服务器返回给浏览器的HTML有个data-server-rendered="true"表示这段内容是服务端渲染

 

2.结合webpack的完整demo

结合官网示例,操作需要注意的说明都有打注释,没有出现在代码里的注意项会单独写出来。这里只贴出了与SPA项目不同的代码。

项目结构:

开发环境运行配置示例:build/setup-dev-server.js

const fs = require('fs')
const path = require('path')
const MFS = require('memory-fs')
const webpack = require('webpack')
/*chokidar 是封装 Node.js 监控文件系统文件变化功能的库。解决nodeJs原生监控文件系统的问题:
* 1.事件处理有大量问题
* 2.不提供递归监控文件树功能
* 3.导致 CPU 占用高
*/
const chokidar = require('chokidar')
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), 'utf-8')
} catch (e) {}
}

module.exports = function setupDevServer (app, templatePath, cb) {
let bundle
let template
let clientManifest

let ready
const readyPromise = new Promise(r => { ready = r })
const update = () => {
if (bundle && clientManifest) {
ready()
cb(bundle, {
template,
clientManifest
})
}
}

// read template from disk and watch
template = fs.readFileSync(templatePath, 'utf-8')
chokidar.watch(templatePath).on('change', () => {
template = fs.readFileSync(templatePath, 'utf-8')
console.log('index.html template updated.')
update()
})

// modify client config to work with hot middleware
clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
clientConfig.output.filename = '[name].js'
clientConfig.plugins.push(
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin()
)

// dev middleware
const clientCompiler = webpack(clientConfig)
const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
publicPath: clientConfig.output.publicPath,
noInfo: true
})
app.use(devMiddleware)
clientCompiler.plugin('done', 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()
})

// hot middleware
app.use(require('webpack-hot-middleware')(clientCompiler, { heartbeat: 5000 }))

// watch and update server renderer
const serverCompiler = webpack(serverConfig)
const mfs = new MFS()
serverCompiler.outputFileSystem = mfs
serverCompiler.watch({}, (err, stats) => {
if (err) throw err
stats = stats.toJson()
if (stats.errors.length) return

// read bundle generated by vue-ssr-webpack-plugin
bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
update()
})

return readyPromise
}

生产环境客户端打包配置示例:build/webpack.client.config.js:

const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
//用于使用service workers缓存您的外部项目依赖项。它将使用sw-precache生成一个服务工作者文件,并将其添加到您的构建目录中。
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

const config = merge(base, {
entry: {
app: './src/entry-client.js'
},
optimization: {
splitChunks: {
cacheGroups: {
commons: {
name: 'vendor',
minChunks: 1
}
}
}
},
plugins: [
new webpack.DefinePlugin({
'process.env.VUE_ENV': '"client"'
}),
// 此插件在输出目录中
// 生成 `vue-ssr-client-manifest.json`。
new VueSSRClientPlugin()
]
})

module.exports = config

生产环境服务端打包配置示例:build/webpack.server.config.js

const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
//用于使用service workers缓存您的外部项目依赖项。它将使用sw-precache生成一个服务工作者文件,并将其添加到您的构建目录中。
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

const config = merge(base, {
entry: {
app: './src/entry-client.js'
},
optimization: {
splitChunks: {
cacheGroups: {
commons: {
name: 'vendor',
minChunks: 1
}
}
}
},
plugins: [
new webpack.DefinePlugin({
'process.env.VUE_ENV': '"client"'
}),
// 此插件在输出目录中
// 生成 `vue-ssr-client-manifest.json`。
new VueSSRClientPlugin()
]
})

module.exports = config

状态管理模块示例:src/store/modules/test.js

export default {
namespaced: true,
// 重要信息:state 必须是一个函数,
// 因此可以创建多个实例化该模块
state: () => ({
count: 1
}),
actions: {
inc: ({ commit }) => commit('inc')
},
mutations: {
inc: state => state.count++
}
}

状态管理使用示例:src/views/Home.vue

<template>
<section>
这里是:views/Home.vue
状态管理数据{{fooCount}}
<hello-world></hello-world>
</section>
</template>

<script>
import HelloWorld from '../components/HelloWorld.vue'
// 在这里导入模块,而不是在 `store/index.js` 中
import fooStoreModule from '../store/modules/test'

export default {
asyncData ({ store }) {
store.registerModule('foo', fooStoreModule);
return store.dispatch('foo/inc')
},

// 重要信息:当多次访问路由时,
// 避免在客户端重复注册模块。
destroyed () {
this.$store.unregisterModule('foo')
},

computed: {
fooCount () {
return this.$store.state.foo.count
}
},
components: {
HelloWorld
}
}
</script>

通用入口:src/app.js:

注意:router、store、vue实例的创建要封装成构造函数,以便每次访问时服务端返回的是一个全新的实例对象

/*app.js通用入口。
*核心作用是创建Vue实例。类似SPA的main.js。
*/
import Vue from 'vue'
//导入跟页面
import App from './App.vue'
// 导入路由生成器
import {createRouter} from "./router";
// 导入状态管理生成器
import {createStore} from "./store";
import {sync} from 'vuex-router-sync'

//创建并导出 vue实例生成器
export function createApp() {
// 生成路由器
let router = createRouter();
// 生成状态管理器
let store = createStore();
// 同步路由状态(route state)到 store
sync(store, router);
let app = new Vue({
//将路由器挂载到vue实例
router,
//将状态管理器挂载到vue实例
store,
// 生成App渲染
render: h => h(App)
});
//返回生成的实例们
return {app, router, store}
}

客户端渲染入口文件:src/entry-client.js

/** entry-client.js客户端入口。
* 仅运行于浏览器
* 核心作用:挂载、激活app。将服务器刚刚返回给浏览器的完整HTML替换为spa
*/
// 导入App生成器
import {createApp} from "./app";
//创建实例们
const {app, router,store} = createApp();
//当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中。而在客户端,在挂载到应用程序之前,store 就应该获取到状态
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实例挂载到#app对应的DOM节点。在没有 data-server-rendered 属性的元素上向 $mount 函数的 hydrating 参数位置传入 true,强制使用应用程序的激活模式:app.$mount('#app', true)
app.$mount('#app');
});

服务端渲染入口文件:src/entry-server.js

/** entry-server.js服务端入口。
* 仅运行于服务器。
* 核心作用是:拿到App实例生成HTML返回给浏览器渲染首屏
*/
//导入App生成器
import {createApp} from "./app";
/*
context:“服务器”调用上下文。如:访问的url,根据url决定将来createApp里路由的具体操作
*/
export default context => {
return new Promise((resolve, reject) => {
//创建App实例,router实例
const {app, router, store} = createApp();
//进入首屏:约定node服务器会将浏览器请求的url放进上下文context中,使用router.push()将当前访问的url对应的vue组件路由到App实例当前页
router.push(context.url);
//路由准备就绪后
router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
// 匹配不到的路由,执行 reject 函数,并返回 404
if (!matchedComponents.length) {
return reject({code: 404})
}
// 对所有匹配的路由组件调用 `asyncData()`
Promise.all(matchedComponents.map(Component => {
if (Component.asyncData) {
return Component.asyncData({
store,
route: router.currentRoute
})
}
})).then(() => {
// 在所有预取钩子(preFetch hook) resolve 后,
// 我们的 store 现在已经填充入渲染应用程序所需的状态。
// 当我们将状态附加到上下文,
// 并且 `template` 选项用于 renderer 时,
// 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
context.state = store.state;
context.title = router.currentRoute.name;
//将渲染出来的App返回
resolve(app);
}, reject)
});
});
}

服务端渲染模板:index.template.html

注意:data-server-rendered 特殊属性,让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式进行挂载。注意,这里并没有添加 id="app",而是添加 data-server-rendered 属性:你需要自行添加 ID 或其他能够选取到应用程序根元素的选择器,否则应用程序将无法正常激活。

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta http-equiv="X-UA-Compatible" content="ie=edge"/>
<title>vue ssr</title>
</head>
<body>
<div id="app">
<!--vue-ssr-outlet-->
</div>
</body>

项目运行入口文件:server.js

//nodeJs 服务器
const fs = require('fs');
const path = require('path');
const express = require('express');
//创建 express实例
const server = express();
//导入渲染器插件
const { createBundleRenderer } = require('vue-server-renderer');
const resolve = file => path.resolve(__dirname, file);
const templatePath = resolve('./src/index.template.html');
//获取 npm run 后面的命令
const isProd = process.env.NODE_ENV === 'production';
/**
* 创建Renderer渲染器
*/
function createRenderer(bundle, options) {
return createBundleRenderer(
bundle,
Object.assign(options, {
runInNewContext: false
})
);
}
let renderer;
//生产环境
if (isProd) {
const template = fs.readFileSync(templatePath, 'utf-8');
const serverBundle = require('./dist/vue-ssr-server-bundle.json');
const clientManifest = require('./dist/vue-ssr-client-manifest.json');
renderer = createRenderer(serverBundle, {
template,
clientManifest
});
} else {
readyPromise = require('./build/setup-dev-server.js')(
server,
templatePath,
(bundle, options) => {
renderer = createRenderer(bundle, options);
}
);
}
//当浏览器请求 *(任意接口)时
server.get('*', async (req, res) => {
try {
const context = {
url: req.url
};
//将url对应的vue组件渲染为HTML
const html = await renderer.renderToString(context);
//将HTML返回给浏览器
res.send(html);
} catch (e) {
console.log(e);
res.status(500).send('服务器内部错误');
}
});
//监听浏览器8080端口
server.listen(8080, () => {
console.log('监听8000,服务器启动成功')
});

package.json:

{
"name": "webpackstudy",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "nodemon server",
"build": "npm run build:client && npm run build:server",
"build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js --progress --hide-modules",
"build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js --progress --hide-modules",
"mock": "webpack-dev-server --progress --color"
},
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^0.19.0",
"body-parser": "^1.19.0",
"cheerio": "^1.0.0-rc.3",
"cookie-parser": "^1.4.4",
"cookie-session": "^1.3.3",
"cors": "^2.8.5",
"express": "^4.17.1",
"jsonwebtoken": "^8.5.1",
"mongoose": "^5.7.7",
"multer": "^1.4.2",
"nodemailer": "^6.3.1",
"redis": "^2.8.0",
"request": "^2.88.0",
"util": "^0.12.1",
"vue-router": "^3.1.2",
"vuex": "^3.1.1",
"ws": "^7.2.0"
},
"devDependencies": {
"@babel/core": "^7.5.5",
"@babel/preset-env": "^7.5.5",
"@vue/cli-plugin-typescript": "^4.0.5",
"autoprefixer": "^9.6.1",
"babel-loader": "^8.0.6",
"clean-webpack-plugin": "^3.0.0",
"compression": "^1.7.4",
"cross-env": "^6.0.3",
"css-loader": "^3.2.0",
"extract-text-webpack-plugin": "^3.0.2",
"file-loader": "^4.2.0",
"friendly-errors-webpack-plugin": "^1.7.0",
"fs": "0.0.1-security",
"html-webpack-plugin": "^3.2.0",
"html-withimg-loader": "^0.1.16",
"install": "^0.13.0",
"jsonc": "^2.0.0",
"less": "^3.10.2",
"less-loader": "^5.0.0",
"lru-cache": "^5.1.1",
"memory-fs": "^0.5.0",
"mini-css-extract-plugin": "^0.8.0",
"mocker-api": "^1.8.1",
"npm": "^6.13.3",
"optimize-css-assets-webpack-plugin": "^5.0.3",
"postcss-loader": "^3.0.0",
"route-cache": "^0.4.4",
"serve-favicon": "^2.5.0",
"style-loader": "^1.0.0",
"sw-precache-webpack-plugin": "^0.11.5",
"terser-webpack-plugin": "^1.4.1",
"uglifyjs-webpack-plugin": "^2.2.0",
"url-loader": "^2.1.0",
"vue": "^2.6.10",
"vue-loader": "^15.7.1",
"vue-server-renderer": "^2.6.10",
"vue-style-loader": "^4.1.2",
"vue-template-compiler": "^2.6.10",
"vuex-router-sync": "^5.0.0",
"webpack": "^4.39.2",
"webpack-cli": "^3.3.7",
"webpack-dev-server": "^3.8.0",
"webpack-hot-middleware": "^2.25.0",
"webpack-merge": "^4.2.2",
"webpack-node-externals": "^1.7.2"
}
}

这里仅提供简单的可运行的代码,详细了解参见官网

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: