手工搭建简易SSR

简介

最近开始学习 nuxt,先自己搭建一个简易的 SRR,来体验体验,并了解它的运行原理。

搭建 服务端

安装

初始化项目:npm init -y

安装依赖:

  • npm i vue express

  • npm i -D nodemon vue-loader babel-loader @babel/preset-env webpack webpack-cli webpack-merge webpack-node-externals

  • vue: 前端框架

  • express:服务器

  • nodemon: 监听文件变化,自动重启服务器

  • vue-loader: 加载 .vue 文件

  • babel-loader: 加载 ES6 语法

  • @babel/preset-env: 转换 ES6 语法

  • webpack: 打包工具

  • webpack-cli: 命令行工具

  • webpack-merge: 合并配置文件

  • webpack-node-externals: 排除 node 模块

目录结构

  • config
    • server.config.js
  • node_modules
  • src
    • demo
      • index.html
    • server
      • index.js
    • App.vue
    • app.js
  • package-lock.json
  • package.json

编写代码

App.vue

<template>
<div style="border: 1px solid #eee">
<h1>{{ count }}</h1>
<button @click="addHandler">点击增加</button>
</div>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(100)
const addHandler = () => {
count.value++
}
</script>

<style scoped></style>

app.js

返回函数的原因是避免跨请求状态污染

import { createSSRApp } from 'vue'
import App from './App.vue'

// 返回函数,避免跨请求状态污染
export default function createApp() {
const app = createSSRApp(App)
return app
}

src/demo/index.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>custon-ssr</title>
</head>
<body>
<h1>手动搭建ssr</h1>
<div id="app"></div>
</body>
</html>

src/server/index.js

const express = require('express')
const server = express()
// 下面的文件导出方式是 ES6 的 export default 语法,所以这里需要用 import 语句导入
// 将 vue 实例转化为 html 字符串
import { renderToString } from '@vue/server-renderer'
import createApp from '../app.js'

server.get('/', async (req, res) => {
const app = createApp()
// renderToString 是一个异步方法,返回一个 Promise 对象
const appStringHtml = await renderToString(app) // 生成字符串形式的 HTML
// html 代码是 src/demo/inde.html 的内容
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>custon-ssr</title>
</head>
<body>
<h1>手动搭建ssr</h1>
<div id="app">
${appStringHtml}
</div>
</body>
</html>
`)
})

server.listen(3000, () => {
console.log('Server is running on http://localhost:3000')
})

config/server.config.js

const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')
const nodeExternals = require('webpack-node-externals') // 忽略node_modules

module.exports = {
entry: './src/server/index.js',
output: {
filename: 'server_bundle.js',
path: path.resolve(__dirname, '../build/server')
},
module: {
rules: [
{
test: /\.js$/i,
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
},
{
test: /\.vue$/i,
loader: 'vue-loader'
}
]
},
resolve: {
extensions: ['.js', '.vue'] // import引入文件的时候不用加后缀
},
plugins: [new VueLoaderPlugin()], // vue-loader插件
externals: [nodeExternals()], // 忽略node_modules
target: 'node', // 服务端渲染
mode: 'development'
}

entry 字段讲解

  • config 目录和 src 目录是同级的,为什么 entry 是 ./ 而不是 ../?
  • 因为 webpack 的 entry 是相对于配置文件的 webpack.config.js
  • webpack 配置文件中的 entry 字段是相对于项目根目录的路径,而不是相对于配置文件本身
  • 因此,无论配置文件在哪个目录下,entry 都应该是相对于项目根目录的路径。
  • 所以这里的 ./src/client/index.js 是相对于项目根目录的路径。

package.json

package.json 的 script 添加下面字段。

"script": {
"build:server": "webpack --config ./config/server.config.js --watch",
"start": "nodemon ./build/server/server_bundle.js"
}

查看结果

命令:

  • 打包服务端:npm run build:server
  • 运行:npm run start

ctrl + 鼠标左键单击 终端输出的 http:localhost:3000 可在浏览器中看到静态效果,不过点击按钮是无反应的。下面我们接着写客户端配置。

搭建 客户端

新增目录结构

  • config
    • client.config.js
  • src
    • client
      • index.js

编写代码

src/client/index.js

import { createApp } from 'vue'
import App from '../App.vue'

const app = createApp(App)
app.mount('#app')

config/client.config.js

const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')

module.exports = {
entry: './src/client/index.js',
output: {
filename: 'client_bundle.js',
path: path.resolve(__dirname, '../build/client')
},
module: {
rules: [
{
test: /\.js$/i,
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
},
{
test: /\.vue$/i,
loader: 'vue-loader'
}
]
},
resolve: {
extensions: ['.js', '.vue']
},
plugins: [new VueLoaderPlugin()],
target: 'web',
mode: 'development'
}

进行 Hydration 水合

服务器端渲染页面 + 客户端激活页面,是页面有交互效果(这个过程称为:Hydration 水合)

Hydration 的具体步骤如下:

  1. 开发一个 App 应用,比如 App.vue。
  2. 将 App.vue 打包为一个客户端的 client_bundle.js 文件。用来激活应用,使页面有交互效果。
  3. 将 App.vue 打包为一个服务器端的 server_bundle.js 文件。用来在服务器端动态生成页面的 HTML。
  4. server_bundle.js 渲染的页面 + client_bundle.js 文件进行 Hydration。

src/server/index.js 添加下面代码


server.use(express.static('build')) // 静态资源目录
// express.static() 是一个 Express 内置的中间件函数,用于提供静态资源
// build 目录下的文件会被映射到根路径下。
// 例如 build/client/client_bundle.js 可以通过 http://localhost:3000//client/client_bundle.js 访问
// server.use(express.static()) 使用中间件函数

server.get('/', async (req, res) => {
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>custon-ssr</title>
</head>
<body>
<h1>手动搭建ssr</h1>
<div id="app">
${appStringHtml}
</div>
<script src="/client/client_bundle.js"></script>
</body>
</html>
`)
})

package.json

package.json 的 script 添加下面字段。

"script": {
"build:client": "webpack --config ./config/client.config.js --watch"
}

查看结果

命令:

  • 打包客户端:npm run build:client
  • 打包服务端:npm run build:server
  • 运行:npm run start

ctrl + 鼠标左键单击 终端输出的 http:localhost:3000 可在浏览器中看到效果,此时点击按钮是有反应的。

去除控制台警告

控制台报警告:

index.js:6 Feature flags __VUE_OPTIONS_API__, __VUE_PROD_DEVTOOLS__, __VUE_PROD_HYDRATION_MISMATCH_DETAILS__ are not explicitly defined. You are running the esm-bundler build of Vue, which expects these compile-time feature flags to be globally injected via the bundler config in order to get better tree-shaking in the production bundle. For more details, see https://link.vuejs.org/feature-flags.

去除警告,config/client.config.js 添加下面代码:

const { DefinePlugin } = require('webpack')

module.exports = {
plugins: [
new VueLoaderPlugin(),
// 定义环境变量,关闭 vue 的调试提示
new DefinePlugin({
__VUE_OPTIONS_API__: false,
__VUE_PROD_DEVTOOLS__: false,
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false
})
],
}

提取重复配置代码

现在的 config/server.config.js 和 config/client.js 有很多的重复代码,我们可以将其重复代码剔除为一个单独的文件,再在两个文件中引入。

base.config.js

const { VueLoaderPlugin } = require('vue-loader')

module.exports = {
module: {
rules: [
{
test: /\.js$/i,
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
},
{
test: /\.vue$/i,
loader: 'vue-loader'
}
]
},
resolve: {
extensions: ['.js', '.vue']
},
plugins: [new VueLoaderPlugin()],
mode: 'development'
}

server.config.js

const path = require('path')
const nodeExternals = require('webpack-node-externals') // 忽略node_modules
const baseConfig = require('./base.config')
const { merge } = require('webpack-merge')

module.exports = merge(baseConfig, {
entry: './src/server/index.js',
output: {
filename: 'server_bundle.js',
path: path.resolve(__dirname, '../build/server')
},
externals: [nodeExternals()], // 忽略node_modules
target: 'node' // 服务端渲染
})

client.config.js

const path = require('path')
const { DefinePlugin } = require('webpack')
const baseConfig = require('./base.config')
const { merge } = require('webpack-merge')

module.exports = merge(baseConfig, {
entry: './src/client/index.js',
output: {
filename: 'client_bundle.js',
path: path.resolve(__dirname, '../build/client')
},
plugins: [
// 定义环境变量,关闭 vue 的调试提示
new DefinePlugin({
__VUE_OPTIONS_API__: false,
__VUE_PROD_DEVTOOLS__: false,
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false
})
],
target: 'web'
})

搭配 vue-router

安装:npm i vue-router

注意:为了避免跨请求状态污染,我们需要在每一个请求中都创建一个全新的 Router,也就是生成函数。

目录

  • src
    • pages
      • home.html
      • about.html
    • router
      • index.js

代码编写

src/pages/home.vue

<template>
<div>
<h1>这里是 Home 页面</h1>
<div style="border: 1px solid #eee">
<h1>{{ count }}</h1>
<button @click="addHandler">点击增加</button>
</div>
</div>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(100)
const addHandler = () => {
count.value++
}
</script>

src/pages/about.vue

<template>
<div>
<h1>这里是 About 页面</h1>
<div style="border: 1px solid #eee">
<h1>{{ count }}</h1>
<button @click="addHandler">点击增加</button>
</div>
</div>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(1000)
const addHandler = () => {
count.value++
}
</script>

src/App.vue

<template>
<div>
<div style="border: 1px solid #eee">
<h1>{{ count }}</h1>
<button @click="addHandler">点击增加</button>
</div>
<hr />
<div>
<router-link to="/">首页</router-link>
<router-link to="/about">关于</router-link>
</div>
<router-view></router-view>
</div>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(100)
const addHandler = () => {
count.value++
}
</script>

src/router/index.js

import { createRouter } from 'vue-router'

const routes = [
{
path: '/',
component: () => import('../pages/home.vue')
},
{
path: '/about',
component: () => import('../pages/about.vue')
}
]

export default function (history) {
return createRouter({
routes,
history
})
}

src/server/index.js

注意:这里的 get('/') 变为了 get('/*'),因为在地址栏中 about 页面也需要通过这里访问。

const express = require('express')
const server = express()
// 下面的文件导出方式是 ES6 的 export default 语法,所以这里需要用 import 语句导入
import { renderToString } from '@vue/server-renderer'
import createApp from '../app.js'
import createRouter from '../router/index.js'
import { createMemoryHistory } from 'vue-router'

server.use(express.static('build'))

server.get('/*', async (req, res) => {
const app = createApp()
const router = createRouter(createMemoryHistory())
app.use(router)
// 有页面则跳转该页面,无页面就跳转首页
// router.push() 是异步操作
await router.push(req.url || '/')
// 用于等待所有异步导航钩子和异步组件加载完成。
// 在服务器端渲染时非常重要,因为需要确保所有异步操作完成后再进行渲染。
// 在客户端 和服务端我们都需要 等待路由器 先解析 异步路由组件
// router.isReady() 是一个异步操作
await router.isReady()
// renderToString 是一个异步方法,返回一个 Promise 对象
const appStringHtml = await renderToString(app) // 生成字符串形式的 HTML
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>custon-ssr</title>
</head>
<body>
<h1>手动搭建ssr</h1>
<div id="app">
${appStringHtml}
</div>
<script src="/client/client_bundle.js"></script>
</body>
</html>
`)
})

server.listen(3000, () => {
console.log('Server is running on http://localhost:3000')
})

src/client/index.js

import { createApp } from 'vue'
import App from '../App.vue'
import createRouter from '../router/index'
import { createWebHistory } from 'vue-router'

const app = createApp(App)
const router = createRouter(createWebHistory())
app.use(router).mount('#app')

查看结果

命令:

  • 打包客户端:npm run build:client
  • 打包服务端:npm run build:server
  • 运行:npm run start

ctrl + 鼠标左键单击 终端输出的 http:localhost:3000 可在浏览器中看到效果。

搭配 pinia

安装:npm i pinia

目录

  • src
    • store
      • index.js

代码编写

src/store/index.js

import { defineStore } from 'pinia'

export const useCountStore = defineStore('count', {
state() {
return {
count: 50
}
},
actions: {
addHandler() {
this.count++
}
},
getters: {
countDouble() {
return this.count * 2
}
}
})

src/pages/home.vue

<template>
<div>
<h1>这里是 Home 页面</h1>
<div style="border: 1px solid #eee">
<h1>{{ count }}</h1>
<button @click="addHandler">点击增加</button>
</div>
</div>
</template>

<script setup>
// import { ref } from 'vue'
import { useCountStore } from '../store'
import { storeToRefs } from 'pinia'

// const count = ref(100)
const countStore = useCountStore()
const { count } = storeToRefs(countStore)
const addHandler = () => {
count.value++
}
</script>

src/pages/about.vue

<template>
<div>
<h1>这里是 About 页面</h1>
<div style="border: 1px solid #eee">
<h1>{{ count }}</h1>
<button @click="addHandler">点击增加</button>
</div>
</div>
</template>

<script setup>
// import { ref } from 'vue'
import { useCountStore } from '../store'
import { storeToRefs } from 'pinia'

// const count = ref(1000)
const countStore = useCountStore()
const { count } = storeToRefs(countStore)
const addHandler = () => {
count.value++
}
</script>

src/server/index.js

const express = require('express')
const server = express()
// 下面的文件导出方式是 ES6 的 export default 语法,所以这里需要用 import 语句导入
import { renderToString } from '@vue/server-renderer'
import createApp from '../app.js'
import createRouter from '../router/index.js'
import { createMemoryHistory } from 'vue-router'
import { createPinia } from 'pinia'

server.use(express.static('build'))

server.get('/*', async (req, res) => {
const app = createApp()
const router = createRouter(createMemoryHistory())
app.use(router).use(createPinia())
// 有页面则跳转该页面,无页面就跳转首页
// router.push() 是异步操作
await router.push(req.url || '/')
// 用于等待所有异步导航钩子和异步组件加载完成。
// 在服务器端渲染时非常重要,因为需要确保所有异步操作完成后再进行渲染。
// 在客户端 和服务端我们都需要 等待路由器 先解析 异步路由组件
// router.isReady() 是一个异步操作
await router.isReady()
// renderToString 是一个异步方法,返回一个 Promise 对象
const appStringHtml = await renderToString(app) // 生成字符串形式的 HTML
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>custon-ssr</title>
</head>
<body>
<h1>手动搭建ssr</h1>
<div id="app">
${appStringHtml}
</div>
<script src="/client/client_bundle.js"></script>
</body>
</html>
`)
})

server.listen(3000, () => {
console.log('Server is running on http://localhost:3000')
})

src/client/index.js

import { createApp } from 'vue'
import App from '../App.vue'
import createRouter from '../router/index'
import { createWebHistory } from 'vue-router'
import { createPinia } from 'pinia'

const app = createApp(App)
const router = createRouter(createWebHistory())
app.use(router).use(createPinia()).mount('#app')

查看结果

命令:

  • 打包客户端:npm run build:client
  • 打包服务端:npm run build:server
  • 运行:npm run start

ctrl + 鼠标左键单击 终端输出的 http:localhost:3000 可在浏览器中看到效果。