vue 服务端渲染(二):入门

上一篇文章介绍了vue ssr 的实现思路和打包流程,实现了一个简单版的 ssr。这一篇将实现客户端和服务端分离打包,根据不同端的配置打包出来不同的端文件。服务端返回打包出来 html 的内容,然后配合客户端打包出来的 js 逻辑,来实现服务端渲染。

具体的实现可以参照官方文档,本文也是照搬照抄。。。
Vue SSR 指南
https://ssr.vuejs.org/zh/

新建工程

新建srcpublic目录,public 目录有两个 html 模板文件,index.ssr.html 不同的是需要引入 ssr 的标记,表示服务端渲染,目录结果如下:

.
├── public
│   └── index.html // 客户端模板
│   └── index.ssr.html // 服务端模板
├── build // webpack打包配置
│   └── webpack.base.js // 公用配置
│   └── webpack.client.js // 客户端配置
│   └── webpack.server.js // 服务端配置
├── src
│   ├── app.js  // app 入口文件
│   ├── App.vue // page入口
│   ├── client-entry.js  // 客户端打包入口文件
│   ├── server-entry.js // 服务端打包入口文件
│   ├── components
│   │   ├── Foo.vue
│   │   ├── Bar.vue
├── server.js

客户端配置

这一步先实现将客户端应用运行起来。webpack的配置需要使用如下的包:

  • webpack webpack-cli webpack-dev-server(webpack相关包)
  • html-webpack-plugin(html模板插件,将打出来的文件直接插入模板中)
  • 解析css文件: vue-style-loader(支持服务端渲染) css-loader vue-template-compiler
  • 解析js文件:@babel/core @babel/preset-env babel-loader
  • 解析vue文件:vue-loader
npm install webpack webpack-cli webpack-dev-server html-webpack-plugin vue-loader vue-style-loader css-loader vue-template-compiler @babel/core @babel/preset-env babel-loader -D

配置webpack文件

配置webpack.base.js

// webpack.base.js
const path = require('path')
const VueLoaderPlugin = require('vue-loader/lib/plugin')

const resolve = dir => {
  return path.resolve(__dirname, dir)
}

module.exports = {
  mode: 'production',
  output: {
    filename: '[name].bundle.js', // 入口文件
    path: resolve('../dist'), // 出口目录
  },
  resolve: {
    extensions: ['.js', '.vue', '.css', '.jsx'] // 引入文件时省略后缀
  },
  module: {
    rules: [
      {
        test: /\.vue$/, // 解析vue文件
        use: 'vue-loader'
      },
      {
        test: /\.css$/,
        use: ['vue-style-loader', 'css-loader'] // loader的执行顺序是从上到下,从右到左
      },
      {
        test: /\.js$/, // 解析es6以上文件
        use: {
          options: { // babel 配置
            presets: ['@babel/preset-env'] // 将es6转化为es5
          },
          loader: 'babel-loader' // babel-loader 会默认调babel-core
        },
        exclude: /node_modules/
      }
    ]
  },
  plugins: [
    new VueLoaderPlugin(),
  ]
}

配置webpack.client.js

// webpack.client.js
const base = require('./webpack.base')
const { merge } = require('webpack-merge') // 合并webpack配置
const HtmlWebpackPlugin = require('html-webpack-plugin')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') // 客户端映射插件

const path = require('path')
const resolve = dir => {
  return path.resolve(__dirname, dir)
}

module.exports = merge(base, {
  entry: {
    client: resolve('../src/client-entry.js') // 客户端打包入口文件
  },
  plugins: [
    new VueSSRClientPlugin(), // 打包出来的是一个映射json文件,不需要写死引入client.bundle.js,因为打包出来的名字可能不是固定的,带有hash值的
    // 客户端打包其实不需要html,因为用的是服务端打包出来的index.ssr.html
// 因为我们需要先预览保证客户端能跑通,所以先留着
    new HtmlWebpackPlugin({
      template: resolve('../public/index.html')
    }),
  ]
})

配置webpack.server.js

const base = require('./webpack.base')
const { merge } = require('webpack-merge')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

const path = require('path')
const { node } = require('./webpack.client')
const resolve = dir => {
  return path.resolve(__dirname, dir)
}

module.exports = merge(base, {
  entry: {
    server: resolve('../src/server-entry.js'), // server端入口文件
  },
  output: {
    libraryTarget: 'commonjs2', // 打包出来按照module.exports方式
  },
  target: 'node', // 服务端打出来的文件是要给node服务用的
  plugins: [
    new VueSSRServerPlugin(), // 打包出来的是服务端的映射
    new HtmlWebpackPlugin({
      filename: 'index.ssr.html',
      template: resolve('../public/index.ssr.html'),
      minify: false, // 不压缩,这样打包的时候就不会把ssr的注释标记给删掉。默认打出来的文件index.html
      excludeChunks: ['server'], // 排除引入文件,因为服务端引入的是客户端打包出来的文件
    }),
  ]
})

public 模板文件

index.html

// index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id="app"></div>
</body>
</html>

index.ssr.html

// index.ssr.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ssr html</title>
</head>
<body>
  <!--vue-ssr-outlet-->
</body>
</html>

components组件

这里面暂时写了两个组件,一个写了样式,一个写了事件绑定,主要是为了验证服务端渲染是不是可以用。

// Bar.vue
<template>
  <div>
    bar
  </div>
</template>
<style scoped>
div{
  background: #f00;
}
</style>
// Foo.vue
<template>
  <div @click="counter += 1">
    foo{{ counter }}
  </div>
</template>
<script>
export default {
  data() {
    return {
      counter: 0
    }
  },
  method: {
  }
}
</script>

App.vue文件

// App.vue
<template>
  <div id="app">
    <Bar />
    <Foo />
  </div>
</template>
<script>
import Bar from './components/Bar.vue'
import Foo from './components/Foo.vue'
export default {
  components: {
    Bar,
    Foo
  }
}
</script>

app.js文件

这里的写法需要注意的是,返回一个函数,函数里返回app实例,这样写是为了客户端访问服务器的时候 可以产生多个实例,这样每个实例都是独立的。之前客户端渲染不需要这样写,是因为本身每个浏览器访问都会产生不同的实例。

import Vue from 'vue'
import App from './App.vue'

// const vm = new Vue({
//   el: '#app',
//   render: h => h(App)
// })
// 1. 客户端渲染的时候每打开一个浏览器都会产生一个vue的实例,
// 而服务器如果按照这样的写法,会在所有人访问时都产生同样的实例,
// 所有app.js一定要导出一个函数,每次访问都产生新的实例

export default () => {
  const app = new Vue({    render: h => h(App)
  })

  return {// 返回一个对象,后续会加入router等
    app
  }
}

client入口 client-entry.js

// src/client-entry.js
import createApp from './app'
const { app } = createApp()

app.$mount('#app')

server入口 server-entry.js

// src/server-entry.js
import createApp from './app'

// 服务端入口导出函数,每次请求进来返回的都是全新

 export default () => {
   const { app }= createApp()
   return app
 }

server.js 启动文件

// server.js
const Koa = require('koa')
const Router = require('@koa/router')
const { createBundleRenderer } = require('vue-server-renderer')
const fs = require('fs')
const path = require('path')
const static = require('koa-static')

const app = new Koa()
const router = new Router()

// 换一种方式:json
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const template = fs.readFileSync(path.resolve(__dirname, 'dist/index.ssr.html'), 'utf8')
const render = createBundleRenderer(serverBundle, {
  template,
  clientManifest // 通过后端注入前端的js脚本
})

router.get('/', async (ctx) => {
  // 在渲染页面的时候,需要让服务器根据当前路径渲染对应的路由
  ctx.body = await render.renderToString()
})

app.use(router.routes())
app.use(static(path.resolve(__dirname, 'dist'))) // 静态文件查找路径
app.listen(3006)

配置打包命令 package.json

// package.json
"start": "nodemon server.js",
"client:dev": "webpack-dev-server --config ./build/webpack.client.js",
"client:build": "webpack --config ./build/webpack.client.js --watch",
"server:build": "webpack --config ./build/webpack.server.js --watch",
"build:all": "concurrently \"npm run client:build\" \"npm run server:build\""

因为服务端要引入客户端打包的文件,所以需要同时打包,可以使用concurrently包,这个包可以同时启动多个命令,安装npm install concurrently -D,如下命令同时启动客户端和服务端打包:
"build:all": "concurrently \"npm run client:build\" \"npm run server:build\""

启动

以上就是全部配置和代码实现,现在我们先启动npm run client:dev,访问http://localhost:8080/看看客户端跑通之后的效果:


可以看到客户端已经跑通了。
接下来再看看服务端,同时打包客户端和服务端代码,运行 npm run build:all

打包出来的文件如下:

现在运行server.js 看一下ssr的效果:
npm start,访问http://localhost:3006/

可以看到服务端也正常启动了,事件交互也没问题。

这一步基本的 ssr 实现就算通过了。

但是到这里,还是有问题的,因为服务端如果直接刷新非根路由,页面是会报 404 的,因为我们在 server.js 中只处理了/路由,下一篇我们会加上 vue-router 和 store 来完善应用。

github:https://github.com/mxcz213/vue-ssr-demo/tree/part-two

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342