2021年了,新的一年应该有一个新的开始,那就从博客更新开始吧!趁着早点的时候,早点更新免得落下延迟更新的毛病,一年到头了也应该有自己的目标和上一年的总结,这个来的有点迟,不过好饭不怕晚是吧?虽然晚但是得到,这是至理名言。nice!新的一年肯定啊努力挣钱努力攒钱 ,毕竟还是得有以后啊。第二个薪资达到自己期望的值,不行就换!第三个当然也是最重要的如果明年疫情过去,条件允许当然是出去玩啊。希望明年再来看这个的时候已经达到了自己满意的地步。好了也该言归正传了,上一篇讲了ssr的理解,当然也应该具体实现一下是吧?这一趴就带你去感受vue实际怎么用ssr,大神们 的react自行度娘吧。
-
同构项目
我们构思怎么写ssr的过程中,我们想怎么在我们的项目中写一个ssr,也就是说怎么将服务端渲染在vue项目中进行启动渲染 ,前边一篇文章也说了,前端人员不安分,搞搞搞然后搞出了前端各种,然后又有了Nodejs,最后发现首屏渲染有问题哈,自己选的路跪着也要走完呀,前端用服务端渲染当然还是在前端搞啊,也就是说在原项目的基础上vue中 夹杂一个服务端,那我们就用node吧!不然还会什么?既然在vue项目中写服务端代码,那不能再用hbs模版或者ejs了吧?这样一套代码也不能同时在客户端跑又在服务端跑啊。写不一样的那就需要维护两边啦,那就提出了同构的概念,一份代码在服务端执行的时候负责渲染页面,然后代码在浏览器执行的时候负责交互。 至于 有人说异构渲染?那我不知道了,爱折腾的前端人员还没搞出来呢吧 哈哈哈
-
node如何渲染Vue结构的代码
官方提供了一个插件vue-server-renderer可以直接将vue实例渲染成Dom标记
const Vue =require('vue'); const app = new Vue({ template:`<div>Hello World</div>` }) //第二步 :创建一个renderer const renderer = require('vue-server-renderer').createRenderer(); // 第三步:将Vue实例渲染为HTML renderer.renderToString(app,(err,html)=>{ if (err) throw err console.log(html); }) // 在2.5.0+, 如果没有传入回调函数,则会返回Promise renderer.renderToString(app).then(html=>{ console.log(html); }).catch(err=>{ console.error(err); })
-
Vue中服务端怎么写?
const Vue = require('vue') const Koa = require('koa'); const renderer = require('vue-server-renderer').createRenderer() const app = new Koa(); const Router = require('koa-router') const router = new Router() // /home router.get('/(.*)', async (ctx, next) => { const app = new Vue({ data: { url: ctx.request.url }, template: `<div>访问的 URL 是: {{ url }}</div>` }) renderer.renderToString(app, (err, html) => { if (err) { ctx.status(500).end('Internal Server Error') return } ctx.body = ` <!DOCTYPE html> <html lang="en"> <head><title>Hello</title></head> <body>${html}</body> </html> ` }) }) app .use(router.routes()) .use(router.allowedMethods()); app.listen(8080, () => { console.log('listen 8080') })
上边的例子中可以看出vue-server-renderer 返回的是一个html 片段,官方叫标记(markup),并不是完整的html 页面。我们必须像上边的例子那样用一个额外的HTML也米娜包裹容器来包裹生成的HTML标记
我们可以提供一个模版页面 。 像 vue中的app页面的包裹元素。
<!DOCTYPE html> <html lang="en"> <head><title>Hello</title></head> <body> <!--vue-ssr-outlet--> </body> </html>
注意: 注视这里将是应用程序HTML标记注入的地方。这是插件提供的,不用问为什么这就是规定哈哈哈哈,如果不用当然也可以啦 ,那你就自己去处理一下吧。大神们我当然管不了
-
以后的思路注意点
- 服务端渲染的vue.js应用程序也可以被认为是“同构”或者是 “通用”,因为应用程序的大部分代码都可以在服务器和客户端上运行。
- 在纯客户端应用程序中,每个用户会在他们各自的浏览器中使用新的应用程序案例。对于服务端渲染来,我们也希望如此:每个应用应该是全新的、独立的应用程序案例,以便不会造成交叉请求造成的状态污染。
对于第一点来说
- 我们之前一直在铺垫,服务端和客户端共用一套代码,既然在客户端和服务端都能运行,那应该有两个入口文件,一些DOM和BOM的操作在服务端肯定是不能够的。
- 通常Vue的应用程序是由webpack和vue-loader 构建,并且许多的webpack特定功能不能在Node.js中运行
对于第二点来说我们想一下原来对于客户端,我们每一个人的电脑上对应的是一个独立的客户端,也就是说你自己运行的应用程序是独立的和别人没有关系,对于服务端来说也是一样的,每个请求每个人发来的请求都应该是全新的独立的应用程序案例,以便不会有交叉造成状态污染
-
改造正式开始
-
我们再来看一下 上边的服务端代码
const Vue = require('vue') const Koa = require('koa'); const renderer = require('vue-server-renderer').createRenderer() const app = new Koa(); const Router = require('koa-router') const router = new Router() // /home router.get('/(.*)', async (ctx, next) => { const vm = new Vue({ data: { url: ctx.request.url }, template: `<div>访问的 URL 是: {{ url }}</div>` }) // 当为其他的url的时候 ,我们获取到访问的url链接,然后再通过某种机制让当前的vm切换到这个url对应的内容区 renderer.renderToString(vm, (err, html) => { if (err) { ctx.status(500).end('Internal Server Error') return } ctx.body = ` <!DOCTYPE html> <html lang="en"> <head><title>Hello</title></head> <body>${html}</body> </html> ` }) }) app .use(router.routes()) .use(router.allowedMethods()); app.listen(8080, () => { console.log('listen 8080') })
上边的例子写了一个template:但是我们不可能每个url都渲染某一个固定的模版吧?那不是就炸了。所以要先搞定路由
-
改造路由实例 router/index.js
import Vue from 'vue' import VueRouter from 'vue-router' import Home from '../views/Home.vue' import About from '../views/About.vue' Vue.use(VueRouter) const routes = [ { path: '/', name: 'Home', component: Home }, { path: '/about', name: 'About', component:About } ] // 导出工厂函数,他可以返回新的 Router 实例 export function createRouter(){ return new VueRouter({ mode: 'history', base: process.env.BASE_URL, routes }) }
疑惑了吧?为什么和脚手架开始的router 不一样,export 一个工厂函数,这里要着重了解一下:
原来的spa应用,在客户端每个人一个router 实例,不会产生污染,但在服务端大家都来访问服务器的router 不能只有一个router实例吧?这样会产生变量啊 一些东西的污染,举例子来说 客户端上你自己有一根雪糕,你自己吃,但是放到服务器上还是一根雪糕,别人来了舔一口,你还愿意吃么?哈哈哈很恶心是吧。那这就需要每个人发一根雪糕了。
服务端的每个请求,都有一个单独的实例避免在请求过程中,产生状态污染,这就是用工厂函数的意义。
-
构建流程
-
改造创建vue实例的main.js,用于创建实例
import Vue from "vue"; import App from "./App.vue"; import { createRouter } from "./router"; export function createApp(context) { const router = createRouter(); const app = new Vue({ router, context, // 这个context 是一个约定,就叫这个名字 render: h => h(App) }); return { app, router }; }
router实例不是单一的,那当然createApp 也不应该是单一的,所以依旧用了工厂函数,但是中间有一个context参数,context上下文 是服务器传递给vue实例的参数对象 , 之后我们调用的时候会进行参数的传递
-
entry-client 客户端入口,用于静态的内容进行激活
// 挂载创建的Vue的实例 。 将来在浏览器执行 // 返回给浏览器的是静态页面需要激活 import {createApp} from './main' const { app,router } = createApp() ; // App.vue 模版中跟元素具有`id= app` // 路由就绪,执行挂载(激活过程) router.onReady(()=>{ app.$mount('#app') // 宿主问 })
-
Entry-server 服务端的入口用于首屏内容的渲染
//给服务器提供一个方法,可以根据接受url 设置路由地址,然后返回创建vue实例 // 在服务器执行 import { createApp } from './main' export default context =>{ //koa的context,由服务给vue的 return new Promise((resolve,reject)=>{ // 获取vue实例和router实例 const { app ,router} = createApp(context); // 跳转至首屏 router.push(context.url) // 前端路由跳转 // onReady 完成时,异步任务都会结束, 由于有异步任务,比如首屏有请求的情况下 router.onReady(()=>{ resolve(app); },reject) }) }
-
分析一下流程上边的流程
request => Koa => main.js => router/index.js
请求 处理请求 生成vue实例 生成router实例
-
进行我们的打包配置 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 merge = require("lodash.merge"); // 根据传入环境变量决定入口文件和相应配置项 const TARGET_NODE = process.env.WEBPACK_TARGET === "node"; const target = TARGET_NODE ? "server" : "client"; module.exports = { css: { extract: false }, outputDir: "./dist/" + target, configureWebpack: () => ({ // 将 entry 指向应用程序的 server / client 文件 entry: `./src/entry-${target}.js`, // 对 bundle renderer 提供 source map 支持 devtool: "source-map", // target设置为node使webpack以Node适用的方式处理动态导入, // 并且还会在编译Vue组件时告知`vue-loader`输出面向服务器代码。 target: TARGET_NODE ? "node" : "web", // 是否模拟node全局变量 node: TARGET_NODE ? undefined : false, output: { // 此处使用Node风格导出模块 libraryTarget: TARGET_NODE ? "commonjs2" : undefined }, // https://webpack.js.org/configuration/externals/#function // https://github.com/liady/webpack-node-externals // 外置化应用程序依赖模块。可以使服务器构建速度更快,并生成较小的打包文件。 externals: TARGET_NODE ? nodeExternals({ // 不要外置化webpack需要处理的依赖模块。 // 可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件, // 还应该将修改`global`(例如polyfill)的依赖模块列入白名单 whitelist: [/\.css$/] }) : undefined, optimization: { splitChunks: undefined }, // 这是将服务器的整个输出构建为单个 JSON 文件的插件。 // 服务端默认文件名为 `vue-ssr-server-bundle.json` // 客户端默认文件名为 `vue-ssr-client-manifest.json`。 plugins: [TARGET_NODE ? new VueSSRServerPlugin() : new VueSSRClientPlugin()] }), chainWebpack: config => { config.module .rule("vue") .use("vue-loader") .tap(options => { merge(options, { optimizeSSR: false }); }); } };
注意: 上边的根据环境变量决定入口文件和相应配置项 如果TARGET_NODE为node 那么target 为entry-server.js 入口,否则则是entry-client.js 入口
-
package.json 修改一下,也就是需要 再执行npm run build 的时候需要 同时打包服务端和客户端
{ // 主要修改 打包入口 npm run build 执行npm run build:server 和npm run build:client" 同时在打包 //"build:server"时传递一个变量 WEBPACK_TARGET=node 这样入口文件就是entry-server.js了 //--mode server设置成了服务端渲染的模式了 // cross-env 用于跨平台设置环境变量 "scripts": { "serve": "vue-cli-service serve", "build:client": "vue-cli-service build", "build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build --mode server", "build": "npm run build:server && npm run build:client", "lint": "vue-cli-service lint" }, }
-
打包之后的目录结构
-dist
-client
- js
- index.html
- vue-ssr-client-manifest.json
-server
-vue-ssr-server-bundle.json
-
宿主文件 :服务端渲染还是需要一个宿主文件的 ./public/index.html
<!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"> <title>服务端渲染</title> </head> <body> <!--vue-ssr-outlet--> 是服务端渲染的插口 </body> </html>
-
上边的服务端代码并不完整,只是引出 router的改造,所以最后我们需要写 服务端代码
// 加载本地文件 const fs = require("fs"); // 处理url const path = require("path"); const express = require('express') const app = express() // 获取绝对路径 const resolve = dir => { return path.resolve(__dirname, dir) } // 第 1 步:开放dist/client目录,由于在改目录下有需要加载的js文件,需要开放改目录 关闭默认下载index页的选项,不然到不了后面路由 // static 特性 只要url 中有/ 默认访问index.html ,恰好该目录下有一个index.html ,避免直接访问了index.html app.use(express.static(resolve('../dist/client'), {index: false})) // 第 2 步:获得一个createBundleRenderer const { createBundleRenderer } = require("vue-server-renderer"); // 第 3 步:导入服务端打包文件 const bundle = require(resolve("../dist/server/vue-ssr-server-bundle.json")); // 第 4 步:创建渲染器 const template = fs.readFileSync(resolve("../public/index.html"), "utf-8"); const clientManifest = require(resolve("../dist/client/vue-ssr-client-manifest.json")); const renderer = createBundleRenderer(bundle, { runInNewContext: false, // https://ssr.vuejs.org/zh/api/#runinnewcontext template, // 宿主文件 clientManifest // 客户端清单 }); // 路由是通配符,表示所有url都接受 app.get('*', async (req,res)=>{ console.log(req.url); // 设置url和title两个重要参数 const context = { title:'ssr test', url:req.url // 首屏地址 } const html = await renderer.renderToString(context); res.send(html) }) const port = 3001; app.listen(port, function() { // eslint-disable-next-line no-console console.log(`server started at localhost:${port}`); });
-
-
总结流程
request 发送请求之后 => 到达 服务端Koa ,服务端进行处理路由,在处理路由的过程中,createBundleRenderer会处理将context发送给main.js => main.js 拿到之后进行服务端的将context 传给vue实例 ,然后对 写的vue 文件 renderToString。
总之总体来说就是 服务端用原来写的vue组件什么的 渲染出首屏,之后客户端进行激活,之后切换路由就是客户端的单页面应用,
url不变的情况下。如果url产生变化那么再次渲染,这也是首屏的理解,首屏并不是首页。