快速搭建“服务端渲染”的网站 vue ssr

原创
2019/12/20 11:04
阅读数 1.5K

获取源码:https://github.com/tian2nian/vue-ssr

一、什么是“服务端渲染”?

1. 传统ssr:

当客户端浏览器发起一个地址请求时,服务端直接返回完整的HTML内容给浏览器进行渲染。

2. vue ssr:

将原本Vue.js (构建客户端应用程序的框架)输出在浏览器中的 Vue 组件由服务器端()渲染为 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。

二、什么情况需要“服务端渲染”?

1. 相比传统 SPA (单页应用程序 (Single-Page Application)) ,服务器端渲染 (SSR) 的优势主要在于:

  • 更好的 SEO(搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。目前Google 和 Bing 可以很好对同步 JavaScript 应用程序进行索引):

    如果你的应用程序初始展示 loading 菊花图,然后通过 Ajax 获取内容,抓取工具并不会等待异步完成后再行抓取页面内容。也就是说,如果 SEO 对你的站点至关重要,而你的页面又是异步获取内容,则你可能需要服务器端渲染(SSR)解决此问题。

  • 更快的内容到达时间 (time-to-content,无需等待所有的 js都下载并执行完,才显示完整的数据,所以用户将会更快速地看到完整渲染的页面):

    网络或设备运行缓慢的情况通常可以改善的用户体验,并且对于那些「内容到达时间(time-to-content) 与转化率直接相关」的应用程序而言,服务器端渲染 (SSR) 至关重要,可以帮助你实现最佳的初始加载性能。

2. 服务器端渲染 (SSR)需要注意:

  • 开发条件所限。浏览器特定的代码,只能在某些生命周期钩子函数 (lifecycle hook) 中使用;一些外部扩展库 (external library) 可能需要特殊处理,才能在服务器渲染应用程序中运行。

  • 涉及构建设置和部署的更多要求。与可以部署在任何静态文件服务器上的完全静态单页面应用程序 (SPA) 不同,服务器渲染应用程序,需要处于 Node.js server 运行环境。

  • 更多的服务器端负载。在 Node.js 中渲染完整的应用程序,显然会比仅仅提供静态文件的 server 更加大量占用 CPU 资源 (CPU-intensive - CPU 密集),因此如果你预料在高流量环境 (high traffic) 下使用,请准备相应的服务器负载,并明智地采用缓存策略。

三、“预渲染”VS“服务端渲染”

如果你的项目只有少数营销页面需要SEO ,那么你可能只需要预渲染。在构建时 (build time) 针对特定路由简单地生成静态 HTML 文件。预渲染优点是:设置更简单,并可以将你的前端作为一个完全静态的站点,无需使用 web 服务器实时动态编译 HTML。

四、快速搭建vue ssr

1. 一个简单好理解的demo

准备:

  • 推荐使用 Node.js 版本 6+。
  • vue-server-renderer 和 vue 必须匹配版本。
  • vue-server-renderer 依赖一些 Node.js 原生模块,因此只能在 Node.js 中使用。
npm install vue
npm install vue vue-server-renderer --save
npm install express --save

开始:server.js

//引入
const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer()

server.get('*', (req, res) => {
// 第 1 步:创建一个 Vue 实例
    const app = new Vue({
        data: {
            hello: 'hello,vue ssr'
        },
        template: `<div>{{ hello }}</div>`
    })
// 第 3 步:将 Vue 实例渲染为 HTML 字符串
    renderer.renderToString(app, (err, html) => {
        if (err) {
            res.status(500).end('Internal Server Error')
            return
        }
//第 4 步:将拼接好的完整HTML发送给客户端让浏览器直接渲染
        res.end(`
      <!DOCTYPE html>
      <html lang="en">
        <head><title>Hello</title></head>
        <body>${html}</body>
      </html>
    `)
    })
})
//监听端口
server.listen(8080)

运行:

node server.js

结果:可以看到服务器返回给浏览器的HTML有个data-server-rendered="true"表示这段内容是服务端渲染

 

2.结合webpack的完整demo

结合官网示例,操作需要注意的说明都有打注释,没有出现在代码里的注意项会单独写出来。这里只贴出了与SPA项目不同的代码。

项目结构:

开发环境运行配置示例:build/setup-dev-server.js

const fs = require('fs')
const path = require('path')
const MFS = require('memory-fs')
const webpack = require('webpack')
/*chokidar 是封装 Node.js 监控文件系统文件变化功能的库。解决nodeJs原生监控文件系统的问题:
* 1.事件处理有大量问题
* 2.不提供递归监控文件树功能
* 3.导致 CPU 占用高
*/
const chokidar = require('chokidar')
const clientConfig = require('./webpack.client.config')
const serverConfig = require('./webpack.server.config')

const readFile = (fs, file) => {
    try {
        return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8')
    } catch (e) {}
}

module.exports = function setupDevServer (app, templatePath, cb) {
    let bundle
    let template
    let clientManifest

    let ready
    const readyPromise = new Promise(r => { ready = r })
    const update = () => {
        if (bundle && clientManifest) {
            ready()
            cb(bundle, {
                template,
                clientManifest
            })
        }
    }

    // read template from disk and watch
    template = fs.readFileSync(templatePath, 'utf-8')
    chokidar.watch(templatePath).on('change', () => {
        template = fs.readFileSync(templatePath, 'utf-8')
        console.log('index.html template updated.')
        update()
    })

    // modify client config to work with hot middleware
    clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
    clientConfig.output.filename = '[name].js'
    clientConfig.plugins.push(
        new webpack.HotModuleReplacementPlugin(),
        new webpack.NoEmitOnErrorsPlugin()
    )

    // dev middleware
    const clientCompiler = webpack(clientConfig)
    const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
        publicPath: clientConfig.output.publicPath,
        noInfo: true
    })
    app.use(devMiddleware)
    clientCompiler.plugin('done', stats => {
        stats = stats.toJson()
        stats.errors.forEach(err => console.error(err))
        stats.warnings.forEach(err => console.warn(err))
        if (stats.errors.length) return
        clientManifest = JSON.parse(readFile(
            devMiddleware.fileSystem,
            'vue-ssr-client-manifest.json'
        ))
        update()
    })

    // hot middleware
    app.use(require('webpack-hot-middleware')(clientCompiler, { heartbeat: 5000 }))

    // watch and update server renderer
    const serverCompiler = webpack(serverConfig)
    const mfs = new MFS()
    serverCompiler.outputFileSystem = mfs
    serverCompiler.watch({}, (err, stats) => {
        if (err) throw err
        stats = stats.toJson()
        if (stats.errors.length) return

        // read bundle generated by vue-ssr-webpack-plugin
        bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
        update()
    })

    return readyPromise
}

生产环境客户端打包配置示例:build/webpack.client.config.js:

const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
//用于使用service workers缓存您的外部项目依赖项。它将使用sw-precache生成一个服务工作者文件,并将其添加到您的构建目录中。
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

const config = merge(base, {
    entry: {
        app: './src/entry-client.js'
    },
    optimization: {
        splitChunks: {
            cacheGroups: {
                commons: {
                    name: 'vendor',
                    minChunks: 1
                }
            }
        }
    },
    plugins: [
        new webpack.DefinePlugin({
            'process.env.VUE_ENV': '"client"'
        }),
        // 此插件在输出目录中
        // 生成 `vue-ssr-client-manifest.json`。
        new VueSSRClientPlugin()
    ]
})

module.exports = config

生产环境服务端打包配置示例:build/webpack.server.config.js

const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
//用于使用service workers缓存您的外部项目依赖项。它将使用sw-precache生成一个服务工作者文件,并将其添加到您的构建目录中。
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

const config = merge(base, {
    entry: {
        app: './src/entry-client.js'
    },
    optimization: {
        splitChunks: {
            cacheGroups: {
                commons: {
                    name: 'vendor',
                    minChunks: 1
                }
            }
        }
    },
    plugins: [
        new webpack.DefinePlugin({
            'process.env.VUE_ENV': '"client"'
        }),
        // 此插件在输出目录中
        // 生成 `vue-ssr-client-manifest.json`。
        new VueSSRClientPlugin()
    ]
})

module.exports = config

状态管理模块示例:src/store/modules/test.js

export default {
    namespaced: true,
    // 重要信息:state 必须是一个函数,
    // 因此可以创建多个实例化该模块
    state: () => ({
        count: 1
    }),
    actions: {
        inc: ({ commit }) => commit('inc')
    },
    mutations: {
        inc: state => state.count++
    }
}

状态管理使用示例:src/views/Home.vue

<template>
    <section>
        这里是:views/Home.vue
        状态管理数据{{fooCount}}
        <hello-world></hello-world>
    </section>
</template>

<script>
    import HelloWorld from '../components/HelloWorld.vue'
    // 在这里导入模块,而不是在 `store/index.js` 中
    import fooStoreModule from '../store/modules/test'

    export default {
        asyncData ({ store }) {
            store.registerModule('foo', fooStoreModule);
            return store.dispatch('foo/inc')
        },

        // 重要信息:当多次访问路由时,
        // 避免在客户端重复注册模块。
        destroyed () {
            this.$store.unregisterModule('foo')
        },

        computed: {
            fooCount () {
                return this.$store.state.foo.count
            }
        },
        components: {
            HelloWorld
        }
    }
</script>

通用入口:src/app.js:

注意:router、store、vue实例的创建要封装成构造函数,以便每次访问时服务端返回的是一个全新的实例对象

/*app.js通用入口。
 *核心作用是创建Vue实例。类似SPA的main.js。
*/
import Vue from 'vue'
//导入跟页面
import App from './App.vue'
// 导入路由生成器
import {createRouter} from "./router";
// 导入状态管理生成器
import {createStore} from "./store";
import {sync} from 'vuex-router-sync'

//创建并导出 vue实例生成器
export function createApp() {
    // 生成路由器
    let router = createRouter();
    // 生成状态管理器
    let store = createStore();
    // 同步路由状态(route state)到 store
    sync(store, router);
    let app = new Vue({
        //将路由器挂载到vue实例
        router,
        //将状态管理器挂载到vue实例
        store,
        // 生成App渲染
        render: h => h(App)
    });
    //返回生成的实例们
    return {app, router, store}
}

客户端渲染入口文件:src/entry-client.js

/** entry-client.js客户端入口。
 * 仅运行于浏览器
 * 核心作用:挂载、激活app。将服务器刚刚返回给浏览器的完整HTML替换为spa
 */
// 导入App生成器
import {createApp} from "./app";
//创建实例们
const {app, router,store} = createApp();
//当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中。而在客户端,在挂载到应用程序之前,store 就应该获取到状态
if (window.__INITIAL_STATE__) {
    store.replaceState(window.__INITIAL_STATE__);
}
// 路由就绪后
router.onReady(() => {
    // 添加路由钩子函数,用于处理 asyncData.
    // 在初始路由 resolve 后执行,
    // 以便我们不会二次预取(double-fetch)已有的数据。
    // 使用 `router.beforeResolve()`,以便确保所有异步组件都 resolve。
    router.beforeResolve((to, from, next) => {
        const matched = router.getMatchedComponents(to)
        const prevMatched = router.getMatchedComponents(from)

        // 我们只关心非预渲染的组件
        // 所以我们对比它们,找出两个匹配列表的差异组件
        let diffed = false
        const activated = matched.filter((c, i) => {
            return diffed || (diffed = (prevMatched[i] !== c))
        })

        if (!activated.length) {
            return next()
        }

        // 这里如果有加载指示器 (loading indicator),就触发

        Promise.all(activated.map(c => {
            if (c.asyncData) {
                return c.asyncData({store, route: to})
            }
        })).then(() => {

            // 停止加载指示器(loading indicator)

            next()
        }).catch(next)
    });

    // 将App实例挂载到#app对应的DOM节点。在没有 data-server-rendered 属性的元素上向 $mount 函数的 hydrating 参数位置传入 true,强制使用应用程序的激活模式:app.$mount('#app', true)
    app.$mount('#app');
});

服务端渲染入口文件:src/entry-server.js

/** entry-server.js服务端入口。
 * 仅运行于服务器。
 * 核心作用是:拿到App实例生成HTML返回给浏览器渲染首屏
 */
//导入App生成器
import {createApp} from "./app";
/*
context:“服务器”调用上下文。如:访问的url,根据url决定将来createApp里路由的具体操作
 */
export default context => {
    return new Promise((resolve, reject) => {
        //创建App实例,router实例
        const {app, router, store} = createApp();
        //进入首屏:约定node服务器会将浏览器请求的url放进上下文context中,使用router.push()将当前访问的url对应的vue组件路由到App实例当前页
        router.push(context.url);
        //路由准备就绪后
        router.onReady(() => {
            const matchedComponents = router.getMatchedComponents();
            // 匹配不到的路由,执行 reject 函数,并返回 404
            if (!matchedComponents.length) {
                return reject({code: 404})
            }
            // 对所有匹配的路由组件调用 `asyncData()`
            Promise.all(matchedComponents.map(Component => {
                if (Component.asyncData) {
                    return Component.asyncData({
                        store,
                        route: router.currentRoute
                    })
                }
            })).then(() => {
                // 在所有预取钩子(preFetch hook) resolve 后,
                // 我们的 store 现在已经填充入渲染应用程序所需的状态。
                // 当我们将状态附加到上下文,
                // 并且 `template` 选项用于 renderer 时,
                // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
                context.state = store.state;
                context.title = router.currentRoute.name;
                //将渲染出来的App返回
                resolve(app);
            }, reject)
        });
    });
}

服务端渲染模板:index.template.html

注意:data-server-rendered 特殊属性,让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式进行挂载。注意,这里并没有添加 id="app",而是添加 data-server-rendered 属性:你需要自行添加 ID 或其他能够选取到应用程序根元素的选择器,否则应用程序将无法正常激活。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8"/>
    <meta http-equiv="X-UA-Compatible" content="ie=edge"/>
    <title>vue ssr</title>
</head>
<body>
<div id="app">
    <!--vue-ssr-outlet-->
</div>
</body>

项目运行入口文件:server.js

//nodeJs 服务器
const fs = require('fs');
const path = require('path');
const express = require('express');
//创建 express实例
const server = express();
//导入渲染器插件
const { createBundleRenderer } = require('vue-server-renderer');
const resolve = file => path.resolve(__dirname, file);
const templatePath = resolve('./src/index.template.html');
//获取 npm run 后面的命令
const isProd = process.env.NODE_ENV === 'production';
/**
 * 创建Renderer渲染器
 */
function createRenderer(bundle, options) {
    return createBundleRenderer(
        bundle,
        Object.assign(options, {
            runInNewContext: false
        })
    );
}
let renderer;
//生产环境
if (isProd) {
    const template = fs.readFileSync(templatePath, 'utf-8');
    const serverBundle = require('./dist/vue-ssr-server-bundle.json');
    const clientManifest = require('./dist/vue-ssr-client-manifest.json');
    renderer = createRenderer(serverBundle, {
        template,
        clientManifest
    });
} else {
    readyPromise = require('./build/setup-dev-server.js')(
        server,
        templatePath,
        (bundle, options) => {
            renderer = createRenderer(bundle, options);
        }
    );
}
//当浏览器请求 *(任意接口)时
server.get('*', async (req, res) => {
    try {
        const context = {
            url: req.url
        };
        //将url对应的vue组件渲染为HTML
        const html = await renderer.renderToString(context);
        //将HTML返回给浏览器
        res.send(html);
    } catch (e) {
        console.log(e);
        res.status(500).send('服务器内部错误');
    }
});
//监听浏览器8080端口
server.listen(8080, () => {
    console.log('监听8000,服务器启动成功')
});

package.json:

{
  "name": "webpackstudy",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "nodemon server",
    "build": "npm run build:client && npm run build:server",
    "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js --progress --hide-modules",
    "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js --progress --hide-modules",
    "mock": "webpack-dev-server --progress --color"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "axios": "^0.19.0",
    "body-parser": "^1.19.0",
    "cheerio": "^1.0.0-rc.3",
    "cookie-parser": "^1.4.4",
    "cookie-session": "^1.3.3",
    "cors": "^2.8.5",
    "express": "^4.17.1",
    "jsonwebtoken": "^8.5.1",
    "mongoose": "^5.7.7",
    "multer": "^1.4.2",
    "nodemailer": "^6.3.1",
    "redis": "^2.8.0",
    "request": "^2.88.0",
    "util": "^0.12.1",
    "vue-router": "^3.1.2",
    "vuex": "^3.1.1",
    "ws": "^7.2.0"
  },
  "devDependencies": {
    "@babel/core": "^7.5.5",
    "@babel/preset-env": "^7.5.5",
    "@vue/cli-plugin-typescript": "^4.0.5",
    "autoprefixer": "^9.6.1",
    "babel-loader": "^8.0.6",
    "clean-webpack-plugin": "^3.0.0",
    "compression": "^1.7.4",
    "cross-env": "^6.0.3",
    "css-loader": "^3.2.0",
    "extract-text-webpack-plugin": "^3.0.2",
    "file-loader": "^4.2.0",
    "friendly-errors-webpack-plugin": "^1.7.0",
    "fs": "0.0.1-security",
    "html-webpack-plugin": "^3.2.0",
    "html-withimg-loader": "^0.1.16",
    "install": "^0.13.0",
    "jsonc": "^2.0.0",
    "less": "^3.10.2",
    "less-loader": "^5.0.0",
    "lru-cache": "^5.1.1",
    "memory-fs": "^0.5.0",
    "mini-css-extract-plugin": "^0.8.0",
    "mocker-api": "^1.8.1",
    "npm": "^6.13.3",
    "optimize-css-assets-webpack-plugin": "^5.0.3",
    "postcss-loader": "^3.0.0",
    "route-cache": "^0.4.4",
    "serve-favicon": "^2.5.0",
    "style-loader": "^1.0.0",
    "sw-precache-webpack-plugin": "^0.11.5",
    "terser-webpack-plugin": "^1.4.1",
    "uglifyjs-webpack-plugin": "^2.2.0",
    "url-loader": "^2.1.0",
    "vue": "^2.6.10",
    "vue-loader": "^15.7.1",
    "vue-server-renderer": "^2.6.10",
    "vue-style-loader": "^4.1.2",
    "vue-template-compiler": "^2.6.10",
    "vuex-router-sync": "^5.0.0",
    "webpack": "^4.39.2",
    "webpack-cli": "^3.3.7",
    "webpack-dev-server": "^3.8.0",
    "webpack-hot-middleware": "^2.25.0",
    "webpack-merge": "^4.2.2",
    "webpack-node-externals": "^1.7.2"
  }
}

这里仅提供简单的可运行的代码,详细了解参见官网

展开阅读全文
打赏
2
0 收藏
分享
加载中
写的也太好了吧
2020/12/22 16:27
回复
举报
更多评论
打赏
1 评论
0 收藏
2
分享
返回顶部
顶部