手把手教你搭建 Vue 服务端渲染项目(上)

简介: 手把手教你搭建 Vue 服务端渲染项目

建议先阅读官方指南——Vue.js 服务器端渲染指南,再回到本文开始阅读。

本文将分成以下两部分:

  1. 简述 Vue SSR 过程
  2. 从零开始搭建 SSR 项目

好了,下面开始正文。

简述 Vue SSR 过程

客户端渲染过程

  1. 访问客户端渲染的网站。
  2. 服务器返回一个包含了引入资源语句和

    的 HTML 文件。
  3. 客户端通过 HTTP 向服务器请求资源,当必要的资源都加载完毕后,执行 new Vue() 开始实例化并渲染页面。

服务端渲染过程

  1. 访问服务端渲染的网站。
  2. 服务器会查看当前路由组件需要哪些资源文件,然后将这些文件的内容填充到 HTML 文件。如果有 asyncData() 函数,就会执行它进行数据预取并填充到 HTML 文件里,最后返回这个 HTML 页面。
  3. 当客户端接收到这个 HTML 页面时,可以马上就开始渲染页面。与此同时,页面也会加载资源,当必要的资源都加载完毕后,开始执行 new Vue() 开始实例化并接管页面。

从上述两个过程中,可以看出,区别就在于第二步。客户端渲染的网站会直接返回 HTML 文件,而服务端渲染的网站则会渲染完页面再返回这个 HTML 文件。

这样做的好处是什么?是更快的内容到达时间 (time-to-content)

假设你的网站需要加载完 abcd 四个文件才能渲染完毕。并且每个文件大小为 1 M。

这样一算:客户端渲染的网站需要加载 4 个文件和 HTML 文件才能完成首页渲染,总计大小为 4M(忽略 HTML 文件大小)。而服务端渲染的网站只需要加载一个渲染完毕的 HTML 文件就能完成首页渲染,总计大小为已经渲染完毕的 HTML 文件(这种文件不会太大,一般为几百K,我的个人博客网站(SSR)加载的 HTML 文件为 400K)。这就是服务端渲染更快的原因

客户端接管页面

对于服务端返回来的 HTML 文件,客户端必须进行接管,对其进行 new Vue() 实例化,用户才能正常使用页面。

如果不对其进行激活的话,里面的内容只是一串字符串而已,例如下面的代码,点击是无效的:

<button @click="sayHi">如果不进行激活,点我是不会触发事件的button>

那客户端如何接管页面呢?下面引用一篇文章中的内容:

客户端 new Vue() 时,客户端会和服务端生成的DOM进行Hydration对比(判断这个DOM和自己即将生成的DOM是否相同(vuex store 数据同步才能保持一致)

如果相同就调用app.$mount('#app')将客户端的vue实例挂载到这个DOM上,即去“激活”这些服务端渲染的HTML之后,其变成了由Vue动态管理的DOM,以便响应后续数据的变化,即之后所有的交互和vue-router不同页面之间的跳转将全部在浏览器端运行。

如果客户端构建的虚拟 DOM 树与服务器渲染返回的HTML结构不一致,这时候,客户端会请求一次服务器再渲染整个应用程序,这使得ssr失效了,达不到服务端渲染的目的了

小结

不管是客户端渲染还是服务端渲染,都需要等待客户端执行 new Vue() 之后,用户才能进行交互操作。但服务端渲染的网站能让用户更快的看见页面

从零开始搭建 SSR 项目

配置 weback

webpack 配置文件共有 3 个:

  1. webpack.base.config.js,基础配置文件,客户端与服务端都需要它。
  2. webpack.client.config.js,客户端配置文件,用于生成客户端所需的资源。
  3. webpack.server.config.js,服务端配置文件,用于生成服务端所需的资源。

webpack.base.config.js 基础配置文件

const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')
const isProd = process.env.NODE_ENV === 'production'
function resolve(dir) {
    return path.join(__dirname, '..', dir)
}
module.exports = {
    context: path.resolve(__dirname, '../'),
    devtool: isProd ? 'source-map' : '#cheap-module-source-map',
    output: {
        path: path.resolve(__dirname, '../dist'),
        publicPath: '/dist/',
        // chunkhash 同属一个 chunk 中的文件修改了,文件名会发生变化 
        // contenthash 只有文件自己的内容变化了,文件名才会变化
        filename: '[name].[contenthash].js',
        // 此选项给打包后的非入口js文件命名,与 SplitChunksPlugin 配合使用
        chunkFilename: '[name].[contenthash].js',
    },
    resolve: {
        extensions: ['.js', '.vue', '.json', '.css'],
        alias: {
            public: resolve('public'),
            '@': resolve('src')
        }
    },
    module: {
        // https://juejin.im/post/6844903689103081485
        // 使用 `mini-css-extract-plugin` 插件打包的的 `server bundle` 会使用到 document。
        // 由于 node 环境中不存在 document 对象,所以报错。
        // 解决方案:样式相关的 loader 不要放在 `webpack.base.config.js` 文件
        // 将其分拆到 `webpack.client.config.js` 和 `webpack.client.server.js` 文件
        // 其中 `mini-css-extract-plugin` 插件要放在 `webpack.client.config.js` 文件配置。
        rules: [
            {
                test: /\.vue$/,
                loader: 'vue-loader',
                options: {
                    compilerOptions: {
                        preserveWhitespace: false
                    }
                }
            },
            {
                test: /\.js$/,
                loader: 'babel-loader',
                exclude: /node_modules/
            },
            {
                test: /\.(png|svg|jpg|gif|ico)$/,
                use: ['file-loader']
            },
            {
                test: /\.(woff|eot|ttf)\??.*$/,
                loader: 'url-loader?name=fonts/[name].[md5:hash:hex:7].[ext]'
            },
        ]
    },
    plugins: [new VueLoaderPlugin()],
}

基础配置文件比较简单,output 属性的意思是打包时根据文件内容生成文件名称。module 属性配置不同文件的解析 loader。

webpack.client.config.js 客户端配置文件

const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const CompressionPlugin = require('compression-webpack-plugin')
const WebpackBar = require('webpackbar')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
const isProd = process.env.NODE_ENV === 'production'
const plugins = [
    new webpack.DefinePlugin({
        'process.env.NODE_ENV': JSON.stringify(
            process.env.NODE_ENV || 'development'
        ),
        'process.env.VUE_ENV': '"client"'
    }),
    new VueSSRClientPlugin(),
    new MiniCssExtractPlugin({
        filename: 'style.css'
    })
]
if (isProd) {
    plugins.push(
        // 开启 gzip 压缩 https://github.com/woai3c/node-blog/blob/master/doc/optimize.md
        new CompressionPlugin(),
        // 该插件会根据模块的相对路径生成一个四位数的hash作为模块id, 用于生产环境。
        new webpack.HashedModuleIdsPlugin(),
        new WebpackBar(),
    )
}
const config = {
    entry: {
        app: './src/entry-client.js'
    },
    plugins,
    optimization: {
        runtimeChunk: {
            name: 'manifest'
        },
        splitChunks: {
            cacheGroups: {
                vendor: {
                    name: 'chunk-vendors',
                    test: /[\\/]node_modules[\\/]/,
                    priority: -10,
                    chunks: 'initial',
                },
                common: {
                    name: 'chunk-common',
                    minChunks: 2,
                    priority: -20,
                    chunks: 'initial',
                    reuseExistingChunk: true
                }
            },
        }
    },
    module: {
        rules: [
            {
                test: /\.css$/,
                use: [
                    {
                        loader: MiniCssExtractPlugin.loader,
                        options: {
                            // 解决 export 'default' (imported as 'mod') was not found
                            // 启用 CommonJS 语法
                            esModule: false,
                        },
                    },
                    'css-loader'
                ]
            }
        ]
    },
}
if (isProd) {
    // 压缩 css
    config.optimization.minimizer = [
        new CssMinimizerPlugin(),
    ]
}
module.exports = merge(base, config)

客户端配置文件中的 config.optimization 属性是打包时分割代码用的。它的作用是将第三方库都打包在一起。

其他插件作用:

  1. MiniCssExtractPlugin 插件, 将 css 提取出来单独打包。
  2. CssMinimizerPlugin 插件,压缩 css。
  3. CompressionPlugin 插件,将资源压缩成 gzip 格式(大大提升传输效率)。另外还需要在 node 服务器上引入 compression 插件配合使用。
  4. WebpackBar 插件,打包时显示进度条。

webpack.server.config.js 服务端配置文件

const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const nodeExternals = require('webpack-node-externals') // Webpack allows you to define externals - modules that should not be bundled.
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const WebpackBar = require('webpackbar')
const plugins = [
    new webpack.DefinePlugin({
        'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
        'process.env.VUE_ENV': '"server"'
    }),
    new VueSSRServerPlugin()
]
if (process.env.NODE_ENV == 'production') {
    plugins.push(
        new WebpackBar()
    )
}
module.exports = merge(base, {
    target: 'node',
    devtool: '#source-map',
    entry: './src/entry-server.js',
    output: {
        filename: 'server-bundle.js',
        libraryTarget: 'commonjs2'
    },
    externals: nodeExternals({
        allowlist: /\.css$/ // 防止将某些 import 的包(package)打包到 bundle 中,而是在运行时(runtime)再去从外部获取这些扩展依赖
    }),
    plugins,
    module: {
        rules: [
            {
                test: /\.css$/,
                use: [
                    'vue-style-loader',
                    'css-loader'
                ]
            }
        ]
    },
})

服务端打包和客户端不同,它将所有文件一起打包成一个文件 server-bundle.js。同时解析 css 需要使用 vue-style-loader,这一点在官方指南中有说明:

配置服务器

生产环境

pro-server.js 生产环境服务器配置文件

const fs = require('fs')
const path = require('path')
const express = require('express')
const setApi = require('./api')
const LRU = require('lru-cache') // 缓存
const { createBundleRenderer } = require('vue-server-renderer')
const favicon = require('serve-favicon')
const resolve = file => path.resolve(__dirname, file)
const app = express()
// 开启 gzip 压缩 https://github.com/woai3c/node-blog/blob/master/doc/optimize.md
const compression = require('compression')
app.use(compression())
// 设置 favicon
app.use(favicon(resolve('../public/favicon.ico')))
// 新版本 需要加 new,旧版本不用
const microCache = new LRU({
    max: 100,
    maxAge: 60 * 60 * 24 * 1000 // 重要提示:缓存资源将在 1 天后过期。
})
const serve = (path) => {
    return express.static(resolve(path), {
        maxAge: 1000 * 60 * 60 * 24 * 30
    })
}
app.use('/dist', serve('../dist', true))
function createRenderer(bundle, options) {
    return createBundleRenderer(
        bundle,
        Object.assign(options, {
            basedir: resolve('../dist'),
            runInNewContext: false
        })
    )
}
function render(req, res) {
    const hit = microCache.get(req.url)
    if (hit) {
        console.log('Response from cache')
        return res.end(hit)
    }
    res.setHeader('Content-Type', 'text/html')
    const handleError = err => {
        if (err.url) {
            res.redirect(err.url)
        } else if (err.code === 404) {
            res.status(404).send('404 | Page Not Found')
        } else {
            res.status(500).send('500 | Internal Server Error~')
            console.log(err)
        }
    }
    const context = {
        title: 'SSR 测试', // default title
        url: req.url
    }
    renderer.renderToString(context, (err, html) => {
        if (err) {
            return handleError(err)
        }
        microCache.set(req.url, html)
        res.send(html)
    })
}
const templatePath = resolve('../public/index.template.html')
const template = fs.readFileSync(templatePath, 'utf-8')
const bundle = require('../dist/vue-ssr-server-bundle.json')
const clientManifest = require('../dist/vue-ssr-client-manifest.json') // 将js文件注入到页面中
const renderer = createRenderer(bundle, {
    template,
    clientManifest
})
const port = 8080
app.listen(port, () => {
    console.log(`server started at localhost:${ port }`)
})
setApi(app)
app.get('*', render)

从代码中可以看到,当首次加载页面时,需要调用 createBundleRenderer() 生成一个 renderer,它的参数是打包生成的 vue-ssr-server-bundle.jsonvue-ssr-client-manifest.json 文件。当返回 HTML 文件后,页面将会被客户端接管。

在文件的最后有一行代码 app.get('*', render),它表示所有匹配不到的请求都交给它处理。所以如果你写了 ajax 请求处理函数必须放在前面,就像下面这样:

app.get('/fetchData', (req, res) => { ... })
app.post('/changeData', (req, res) => { ... })
app.get('*', render)

否则你的页面会打不开。

开发环境

开发环境的服务器配置和生产环境没什么不同,区别在于开发环境下的服务器有热更新。

一般用 webpack 进行开发时,简单的配置一下 dev server 参数就可以使用热更新了,但是 SSR 项目需要自己配置。

由于 SSR 开发环境服务器的配置文件 setup-dev-server.js 代码太多,我对其进行简化后,大致代码如下:

// dev-server.js
const express = require('express')
const webpack = require('webpack')
const webpackConfig = require('../build/webpack.dev') // 获取 webpack 配置文件
const compiler = webpack(webpackConfig)
const app = express()
app.use(require('webpack-hot-middleware')(compiler))
app.use(require('webpack-dev-middleware')(compiler, {
    noInfo: true,
    stats: {
        colors: true
    }
}))

同时需要在 webpack 的入口文件加上这一行代码 webpack-hot-middleware/client?reload=true

// webpack.dev.js
const merge = require('webpack-merge')
const webpackBaseConfig = require('./webpack.base.config.js') // 这个配置和热更新无关,可忽略
module.exports = merge(webpackBaseConfig, {
    mode: 'development',
    entry: {
        app: ['webpack-hot-middleware/client?reload=true' , './client/main.js'] // 开启热模块更新
    },
    plugins: [new webpack.HotModuleReplacementPlugin()]
})

然后使用 node dev-server.js 来开启前端代码热更新。

热更新主要使用了两个插件:webpack-dev-middlewarewebpack-hot-middleware。顾名思义,看名称就知道它们的作用,

webpack-dev-middleware 的作用是生成一个与 webpack 的 compiler 绑定的中间件,然后在 express 启动的 app 中调用这个中间件。

这个中间件的作用呢,简单总结为以下三点:通过watch mode,监听资源的变更,然后自动打包; 快速编译,走内存;返回中间件,支持express 的 use 格式。

webpack-hot-middleware 插件的作用就是热更新,它需要配合 HotModuleReplacementPluginwebpack-dev-middleware 一起使用。

目录
相关文章
|
3天前
|
JavaScript
vue消息订阅与发布
vue消息订阅与发布
|
2天前
|
JavaScript
vue尚品汇商城项目-day07【vue插件-50.(了解)表单校验插件】
vue尚品汇商城项目-day07【vue插件-50.(了解)表单校验插件】
11 4
|
2天前
|
JavaScript
vue尚品汇商城项目-day07【51.路由懒加载】
vue尚品汇商城项目-day07【51.路由懒加载】
12 4
|
4天前
|
JavaScript 前端开发
Vue学习笔记8:解决Vue学习笔记7中用v-for指令渲染列表遇到两个问题
Vue学习笔记8:解决Vue学习笔记7中用v-for指令渲染列表遇到两个问题
|
2天前
|
JavaScript
vue尚品汇商城项目-day07【vue插件-54.(了解)生成二维码插件】
vue尚品汇商城项目-day07【vue插件-54.(了解)生成二维码插件】
8 2
|
JavaScript 测试技术 容器
Vue2+VueRouter2+webpack 构建项目
1). 安装Node环境和npm包管理工具 检测版本 node -v npm -v 图1.png 2). 安装vue-cli(vue脚手架) npm install -g vue-cli --registry=https://registry.
1039 0
|
5天前
|
JavaScript
vue组件中的插槽
本文介绍了Vue中组件的插槽使用,包括单个插槽和多个具名插槽的定义及在父组件中的使用方法,展示了如何通过插槽将父组件的内容插入到子组件的指定位置。
|
4天前
|
JavaScript 前端开发 IDE
Vue学习笔记5:用Vue的事件监听 实现数据更新的实时视图显示
Vue学习笔记5:用Vue的事件监听 实现数据更新的实时视图显示
|
4天前
|
JavaScript 前端开发 API
Vue学习笔记4:用reactive() 实现数据更新的实时视图显示
Vue学习笔记4:用reactive() 实现数据更新的实时视图显示
|
4天前
|
JavaScript 前端开发 API
Vue学习笔记7:使用v-for指令渲染列表
Vue学习笔记7:使用v-for指令渲染列表
下一篇
无影云桌面