乐趣区

关于前端:手把手教你搭建-Vue-服务端渲染项目

倡议先浏览官网指南——Vue.js 服务器端渲染指南,再回到本文开始浏览。

本文将分成以下两局部:

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

好了,上面开始注释。

简述 Vue SSR 过程

客户端渲染过程

  1. 拜访客户端渲染的网站。
  2. 服务器返回一个蕴含了引入资源语句和 <div id="app"></div> 的 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 一起应用。

打包文件 vue-ssr-client-manifest.jsonvue-ssr-server-bundle.json

webpack 须要对源码打包两次,一次是为客户端环境打包的,一次是为服务端环境打包的。

为客户端环境打包的文件,和以前咱们打包的资源一样,不过多出了一个 vue-ssr-client-manifest.json 文件。服务端环境打包只输入一个 vue-ssr-server-bundle.json 文件。

vue-ssr-client-manifest.json 蕴含了客户端环境所需的资源名称:

从上图中能够看到有三个关键词:

  1. all,示意这是打包的所有资源。
  2. initial,示意首页加载必须的资源。
  3. async,示意须要异步加载的资源。

vue-ssr-server-bundle.json 文件:

  1. entry,服务端入口文件。
  2. files,服务端依赖的资源。

填坑记录

1. [vue-router] failed to resolve async component default: referenceerror: window is not defined

因为在一些文件或第三方文件中可能会用到 window 对象,并且 node 中不存在 window 对象,所以会报错。
此时可在 src/app.js 文件加上以下代码进行判断:

// 在 app.js 文件增加上这段代码,对环境进行判断
if (typeof window === 'undefined') {global.window = {}
}

2. mini-css-extract-plugin 插件造成 ReferenceError: document is not defined

应用 mini-css-extract-plugin 插件打包的的 server bundle,会应用到 document。因为 node 环境中不存在 document 对象,所以报错。

解决方案:款式相干的 loader 不要放在 webpack.base.config.js 文件,将其分拆到 webpack.client.config.jswebpack.client.server.js 文件。其中 mini-css-extract-plugin 插件要放在 webpack.client.config.js 文件配置。

base

module: {
    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]'
        },
    ]
}

client

module: {
    rules: [
        {
            test: /\.css$/,
            use: [
                {
                    loader: MiniCssExtractPlugin.loader,
                    options: {// 解决 export 'default' (imported as 'mod') was not found
                        esModule: false,
                    },
                },
                'css-loader'
            ]
        }
    ]
}

server

module: {
    rules: [
        {
            test: /\.css$/,
            use: [
                'vue-style-loader',
                'css-loader'
            ]
        }
    ]
}

3. 开发环境下跳转页面款式不失效,但生产环境失常。

因为开发环境应用的是 memory-fs 插件,打包文件是放在内存中的。如果此时 dist 文件夹有方才打包留下的资源,就会应用 dist 文件夹中的资源,而不是内存中的资源。并且开发环境和打包环境生成的资源名称是不一样的,所以就造成了这个 BUG。

解决办法是执行 npm run dev 时,删除 dist 文件夹。所以要在 npm run dev 对应的脚本中加上 rimraf dist

"dev": "rimraf dist && node ./server/dev-server.js --mode development",

4. [vue-router] Failed to resolve async component default: ReferenceError: document is not defined

不要在有可能应用到服务端渲染的页面拜访 DOM,如果有这种操作请放在 mounted() 钩子函数里。

如果你引入的数据或者接口有拜访 DOM 的操作也会报这种错,在这种状况下能够应用 require()。因为 require() 是运行时加载的,所以能够这样应用:

<script>
// 原来报错的操作,这个接口有 DOM 操作,所以这样应用的时候在服务端会报错。import {fetchArticles} from '@/api/client'

export default {
  methods: {getAppointArticles() {
      fetchArticles({
        tags: this.tags,
        pageSize: this.pageSize,
        pageIndex: this.pageIndex,
      })
      .then(res => {this.$store.commit('setArticles', res)
      })
    },
  }
}
</script>

批改后:

<script>
// 先定义一个内部变量,在 mounted() 钩子里赋值
let fetchArticles
export default {mounted() {// 因为服务端渲染不会有 mounted() 钩子,所以在这里能够保障是在客户端的状况下引入接口
      fetchArticles = require('@/api/client').fetchArticles
  },
  methods: {getAppointArticles() {
      fetchArticles({
        tags: this.tags,
        pageSize: this.pageSize,
        pageIndex: this.pageIndex,
      })
      .then(res => {this.$store.commit('setArticles', res)
      })
    },
  }
}
</script>

批改后能够失常应用。

5. 开发环境下,开启服务器后无任何反馈,也没见控制台输入报错信息。

这个坑其实是有报错信息的,然而没有输入,导致认为没有谬误。

setup-dev-server.js 文件中有一行代码 if (stats.errors.length) return,如果有报错就间接返回,不执行后续的操作。导致服务器没任何反馈,所以咱们能够在这打一个 console.log 语句,打印报错信息。

小结

这个 DEMO 是基于官网 DEMO vue-hackernews-2.0 革新的。不过官网 DEMO 发表于 4 年前,最近批改工夫是 2 年前,很多选项参数曾经过期了。并且官网 DEMO 须要翻墙能力应用。所以我在此基础上对其进行了革新,革新后的 DEMO 放在 Github 上,它是一个比较完善的 DEMO,能够在此基础上进行二次开发。

如果你不仅仅满足于一个 DEMO,倡议看一看我的集体博客我的项目,它原来是客户端渲染的我的项目,起初重构为服务端渲染,相对实战。

参考资料

  • Vue.js 服务器端渲染指南
  • vue-ssr 服务端渲染透析
  • webpack-dev-middleware 详解

更多文章,敬请关注

退出移动版