likes
comments
collection
share

带你一步步搭建 Vue2 SSR 的工程

作者站长头像
站长
· 阅读数 33

开篇

先看文档: 了解什么是 SSR;为什么要使用 SSR

Vue.js 服务器端渲染指南 | Vue SSR 指南 (vuejs.org)

使用 vue-server-renderer 去渲染 vue 组件

  1. 了解 vue-server-renderer 的使用;

// 第 1 步:创建一个 Vue 实例
const Vue = require('vue')
const app = new Vue({
  template: `<div>Hello World</div>`
})

// 第 2 步:创建一个 renderer
const renderer = require('vue-server-renderer').createRenderer()

// 第 3 步:将 Vue 实例渲染为 HTML
renderer.renderToString(app, (err, html) => {
  if (err) throw err
  console.log(html)
  // => <div data-server-rendered="true">Hello World</div>
})

// 在 2.5.0+,如果没有传入回调函数,则会返回 Promise:
renderer.renderToString(app).then(html => {
  console.log(html)
}).catch(err => {
  console.error(err)
})
  1. 与服务器集成
const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer()

server.get('*', (req, res) => {
  const app = new Vue({
    data: {
      url: req.url
    },
    template: `<div>访问的 URL 是: {{ url }}</div>`
  })

  renderer.renderToString(app, (err, html) => {
    if (err) {
      res.status(500).end('Internal Server Error')
      return
    }
    res.end(`
      <!DOCTYPE html>
      <html lang="en">
        <head><title>Hello</title></head>
        <body>${html}</body>
      </html>
    `)
  })
})

server.listen(8080)
  1. 引入一个模版

index.template.html

<!DOCTYPE html>
<html>
  <head>
    <!-- 使用双花括号(double-mustache)进行 HTML 转义插值(HTML-escaped interpolation) -->
    <title>{{ title }}</title>

    <!-- 使用三花括号(triple-mustache)进行 HTML 不转义插值(non-HTML-escaped interpolation) -->
    {{{ meta }}}
  </head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

完整代码如下:

const Vue = require('vue');
const server = require('express')();

const template = require('fs').readFileSync('./index.template.html', 'utf-8');

const renderer = require('vue-server-renderer').createRenderer({
  template,
});

const context = {
    title: 'vue ssr',
    meta: `
        <meta name="keyword" content="vue,ssr">
        <meta name="description" content="vue srr demo">
    `,
};

server.get('*', (req, res) => {
  const app = new Vue({
    data: {
      url: req.url
    },
    template: `<div>访问的 URL 是: {{ url }}</div>`,
  });

  renderer
  .renderToString(app, context, (err, html) => {
    console.log(html);
    if (err) {
      res.status(500).end('Internal Server Error')
      return;
    }
    res.end(html);
  });
})

server.listen(8080);

编写通用代码

我们搭建一个本地项目,首先我们需要一个入口文件,app.js。

import Vue from 'vue'
import App from './App.vue'
//这里需要返回一个函数,避免单例状态,我们需要知道 node 服务是一个长期运行的进程,当我们的代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享。
export function createApp() {
    const app = new Vue({
        store,
        router,
        render: h => h(App)
    })
    return { app }
}

创建 client-entry.js 用于服务端渲染后客户端激活。

import { createApp } from './app.js';
const { app } = createApp();
app.$mount('#app');

它需要做的事情很简单,直接挂载到根标签即可。

创建 server-entry.js 用于服务端渲染

import { createApp } from './app.js';
export default function (context) {
    //这个方法服务端渲染会调用 renderer.renderToString() 时调用
    return new Promise((reslove, reject
) => {
        const { app } = createApp();
        reslove(app)
    })
};

和客户端代码不同的是,这里需要返回一个工厂函数,保证用户每次访问服务端都是一个全新的 vue。

创建 index.template.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="app">
        <!--vue-ssr-outlet-->
    </div>
</body>
</html>

服务端会把 server-entry.js 里的 vue 对象通过 vue-server-renderer 解析成字符串放在这里 <!--vue-ssr-outlet-->

创建 build 文件夹,使用 webpack 打包客户端代码 vue 和 服务端 vue

创建 webpack.base.config.js ( 通用配置 )

const VueLoaderPlugin = require('vue-loader/lib/plugin')
const path = require('path');
module.exports = {
    resolve: {
        extensions: ['.js', '.vue', '.json'],// 自动补全后缀
        alias: {
            '@': path.resolve(__dirname, '../src'),// 路径别名
        }
    },
    mode: 'development',
    output: {//打包的出口
        path: path.resolve(__dirname, '../dist'),
        filename: '[name].js'
    },
    module: {
        rules: [
            {// 配置 vue-loader 才能正常解析 .vue 文件
                test: /.vue$/,
                loader: 'vue-loader'
            },
            {// vue-style-loader 支持服务端渲染啊 style 样式
                test: /.css$/,
                use: ['vue-style-loader', 'css-loader']
            },
            {// 配置 babel es6 转 es5
                test: /.js$/,
                loader: 'babel-loader',
                exclude: /node_modules/, // 排除第三方的包
                options: {
                    presets: [
                        [
                            '@babel/preset-env',
                            {
                                modules: false, // 不转换成 commonjs 模块
                                useBuiltIns: 'usage', // 按需 polyfill
                                corejs: 3,
                            }
                        ]
                    ]
                }
            }
        ]
    },
    plugins: [
        new VueLoaderPlugin(), // 这个和 vue-loader 一起的用于解析 .vue 文件
    ]
};

创建 webpack.client.config.js ( 打包客户端代码 )

const { merge } = require('webpack-merge');
const base = require('./webpack.base.config.js');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
module.exports = merge(base, {
    entry: {
        client: path.join(__dirname, '../src/client-entry.js'), // 指定客户端代码入口
    },
    plugins: [
        new HtmlWebpackPlugin({ //使用 index.template.html 模板
            template: path.join(__dirname, '../src/index.template.html'),
            filename: 'index.ssr.html',
            minify: false,
        })
    ]
})

创建 webpack.server.config.js( 打包服务端代码给 node 使用 )

const { merge } = require('webpack-merge');
const base = require('./webpack.base.config.js');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
module.exports = merge(base, {
    target: 'node', // 指定是node环境
    entry: { // 指定客户端代码入口
        server: path.join(__dirname, '../src/server-entry.js')
    },
    output: {
        libraryTarget: 'commonjs2' // 必须按照 commonjs规范打包才能被服务器调用。
    },
})

创建 server.js 服务端渲染

当然,要返回的html字符串可以是由vue模板生成的,这就需要用到vue-server-renderer,它会基于Vue实例生成html字符串,是Vue SSR的核心。server.js中使用

const fs = require('fs');
const path = require('path');
const express = require('express');
const server = express();
//设置静态目录
server.use(express.static('dist'));
//获取到打包后的服务端vue代码
const bundle = fs.readFileSync(path.resolve(__dirname, './dist/server.js'), 'utf-8');
//拿着vue代码和模板生成字符串
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
    template: fs.readFileSync(path.resolve(__dirname, './dist/index.ssr.html'), 'utf-8'),
});
server.get('*', (req, res) => {
    if (req.url !== "/favicon.ico") {
    	// 调用 server-entry.js 触发工厂函数,服务端渲染数据
        renderer.renderToString().then((html) => {
            res.end(html)
        })
    }
});
server.listen(8011, () => {
    console.log('http://127.0.0.1:8011');
});

此时一个简单的模块化SSR已经好了,但是目前还不支持vue-router状态管理 vuex ,现在对上面的代码改造一下。

在 src 下创建 router 和 store。

// router.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import baz from '@/components/baz'
import foo from '@/components/foo'
Vue.use(VueRouter)
// 返回一个函数,避免单例状态
export default function createRouter() {
    return new VueRouter({
        mode: "history",
        routes: [
            {
                path: "/",
                name: 'baz',
                component: baz
            },
            {
                path: "/foo",
                name: 'foo',
                component: foo
            }
        ]
    })
}
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
// 返回一个函数,避免单例状态
export default function createStore() {
    return new Vuex.Store({
        state: {
            article: '数据'
        },
        actions: {
            GET_ARTICLE({ commit }) {
                return new Promise((r) => {
                    setTimeout(() => {
                        commit('SET_ARTICLE', 'vuex数据')
                        r()
                    }, 1000);
                })
            }
        },
        mutations: {
            SET_ARTICLE(state, data) {
                state.article = data
            }
        }
    })
}

router 和 vuex 需要给服务端使用,返回一个函数,避免单例状态。

对 app.js 改造

import Vue from 'vue'
import App from './App.vue'
import createStore from './store/index.js';
import createRouter from './router/index.js';
//每次访问都创建一个新的vue 主要用于服务端
export function createApp() {
	//创建 store
    const store = createStore();
    //创建 router
    const router = createRouter();
    const app = new Vue({
        store,
        router,
        render: h => h(App)
    })
    return { app, store, router }
}

对 server-entry 改造

import { createApp } from './app.js';
// 服务端渲染会调用此方法
export default function (context) {
    return new Promise((reslove, reject) => {
        const { app, store, router } = createApp();
        //context.url => renderer.renderToString({url: req.url})
        //服务端跳转页面
        router.push(context.url);
        //服务端跳转页面完成
        router.onReady(() => {
            //获取页面级组件
            const matchedComponents = router.getMatchedComponents();
			//没有匹配成功返回 404
            if (!matchedComponents.length) {
                return reject({ code: 404 })
            }
            //对所有匹配的路由组件调用 `asyncData()`
            //拿到页面组件上的asyncData调用
            Promise.all(matchedComponents.map(Component => {
                if (Component.asyncData) {
                    return Component.asyncData({
                        store,
                    })
                }
            })).then(() => {
                // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
                context.state = store.state
                reslove(app)
            })
        })
    })
};

对 client-entry 改造

import { createApp } from './app.js';
const { app, store } = createApp();
//客户端激活时替换state状态
if (window.__INITIAL_STATE__) {
    store.replaceState(window.__INITIAL_STATE__);
};
app.$mount('#app');

对 server.js 改造

const fs = require('fs');
const path = require('path');
const express = require('express');
const server = express();
server.use(express.static('dist'));
//获取到服务端vue代码
const bundle = fs.readFileSync(path.resolve(__dirname, './dist/server.js'), 'utf-8');
//拿着js代码和模板生成字符串
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
    template: fs.readFileSync(path.resolve(__dirname, './dist/index.ssr.html'), 'utf-8'), // 服务端渲染数据
});
server.get('*', (req, res) => {
    if (req.url !== "/favicon.ico") {
        renderer.renderToString({// 这个 url 会传给 server-entry.js 里的 context
            url: req.url
        }).then((html) => {
            res.end(html)
        }).catch((error) => {
            if (error.code == 404) {
                res.writeHead(404, {
                    "content-type": "text/html;charset=utf8"
                })
                res.end('找不到页面')
            }
        })
    }
});
server.listen(8011, () => {
    console.log('http://127.0.0.1:8011');
});

vue-ssr 本质上就是通过 webpack 打包 client-entry.js 和 server-entry.js 代码,首次进入页面通过 vue-server-renderer 把 server-entry.js 的 vue 生成字符串返回给客户端渲染,后续通过 client-entry.js 进行客户端激活。

所谓客户端激活,指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。

带你一步步搭建 Vue2 SSR  的工程

  1. 现在项目可以 build 出来文件,进行 server render;

  2. 进行开发的话,肯定是不适合的,并不会热更新,我们来搭建本地开发服务;

搭建本地开发服务

  1. 这块直接照抄vue-hackernews-2.0的思路。

已知,SSR的渲染关键在于renderer.renderToStringrenderer是由createBundleRenderer创建而来,createBundleRenderer函数需要传入bundle clientManifest等实参。

因此,如果想在改完代码后拿到最新的内容,我们需要做以下事情:

  1. 声明一个renderer
  2. 构建server,在文件修改时重新编译,构建完成时想办法拿到最新的bundle
  3. 构建client,在文件修改时重新编译,构建完成时想办法拿到最新的clientManifest
  4. 拿到最新的bundleclientManifest后,通过createBundleRendererrenderer替换
  5. 拿到最新的renderer执行renderer.renderToString

以上就是setup-dev-server.js要做的工作。

setup-dev-server.js

通常情况下我们构建webpack, 通过命令指定一个wepback配置文件进行构建。

webpack --config webpack.xxx.config.js

renderer的更新关键在setupDevServer传入的实参cb的执行时机。

  1. 使用到了两个 插件 "webpack-dev-middleware": "^3.7.3","webpack-hot-middleware": "^2.25.4", 复制添加到 package.json 里,然后 npm i 安装一下,使用 webpack-dev-middleware 时注意!!!

webpack-dev-middleware旧版本的fileSystem直接挂在devMiddleware下。

新版本通过devMiddleware.context.outputFileSystem访问

const webpack = require('webpack')
const fs = require('fs')
const path = require('path')
const middleware = require("webpack-dev-middleware")
const HMR = require("webpack-hot-middleware")
const MFS = require('memory-fs')

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), 'utf8')
    } catch (error) {
        console.log('error: ', error);
    }
}

const setupServer = (app, templatePath, cb) => {
    let bundle
    let clientManifest
    let template
    let ready
    const readyPromise = new Promise(r => ready = r)

    template = fs.readFileSync(templatePath, 'utf8')
    const update = () => {
        if (bundle && clientManifest) {
            // 通知 server 进行渲染
            // 执行 createRenderer -> RenderToString
            ready()
            cb(bundle, {
                template,
                clientManifest
            })
        }
    }

    // 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
	//  console.log("clientCompiler start")
    const clientCompiler = webpack(clientConfig);
    const devMiddleware = middleware(clientCompiler, {
        noInfo: true, 
        publicPath: clientConfig.output.publicPath, 
        logLevel: 'silent',
        serverSideRender: true
    })
    app.use(devMiddleware);

    clientCompiler.hooks.done.tap('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(HMR(clientCompiler));

    // webpack -> entry-server -> bundle
    const mfs = new MFS();
    const serverCompiler = webpack(serverConfig);
    // watch and update server renderer
    serverCompiler.outputFileSystem = mfs;
    serverCompiler.watch({}, (err, stats) => {
        if (err) throw err
        // 之后读取输出:
        stats = stats.toJson()
        stats.errors.forEach(err => console.error(err))
        stats.warnings.forEach(err => console.warn(err))
        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
}

module.exports = setupServer
  1. 然后修改 server.js
// server.js
const app = require('express')()
const { createBundleRenderer } = require('vue-server-renderer')
const fs = require('fs')
const path = require('path')
const chalk = require("chalk")
const address = require("address")
const resolve = file => path.resolve(__dirname, file)

const isProd = process.env.NODE_ENE === "production"

const createRenderer = (bundle, options) => {
    return createBundleRenderer(bundle, Object.assign(options, {
        basedir: resolve('./dist'),
        runInNewContext: false,
    }))
}

let renderer, readyPromise
const templatePath = resolve('../src/index.template.html')

<>
if (isProd) {
    const bundle = require('./dist/vue-ssr-server-bundle.json')
    const clientManifest = require('./dist/vue-ssr-client-manifest.json')
    const template = fs.readFileSync(templatePath, 'utf-8')

    renderer = createRenderer(bundle, {
        // 推荐
        template, // (可选)页面模板
        clientManifest // (可选)客户端构建 manifest
    })
} else {
    // 开发模式
    readyPromise = require('../config/setup-dev-server')(app, templatePath, (bundle, options) => {
        renderer = createRenderer(bundle, options)
    })
}



const render = (req, res) => {
    const context = {
        title: 'hello ssr with webpack',
        meta: `
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <meta http-equiv="X-UA-Compatible" content="ie=edge">
    `,
        url: req.url
    }
    renderer.renderToString(context, (err, html) => {
        if (err) {
            if (err.code === 404) {
                res.status(404).end('Page not found')
            } else {
                res.status(500).end('Internal Server Error')
            }
        } else {
            res.end(html)
        }
    })
}

// 在服务器处理函数中……
app.get('*', isProd ? render : (req, res) => {
    readyPromise.then(() => render(req, res))
})

const port = '8081';
app.listen(port, () => {
    const appRunningStr = chalk.green("App running at")
    const LOCAL_IP = address.ip()
    const localStr = chalk.green("Local:  ") + chalk.cyan(`http://localhost:${port}`)
    const netWorkStr = chalk.green("Network:") + chalk.cyan(`http://${LOCAL_IP}:${port}`)
    console.log(appRunningStr)
    console.log(localStr)
    console.log(netWorkStr)
}) 

综上一个基础的 webpack5 + Vue2 + express 的 SSR 项目就搭建好了

源码地址:vue-ssr-test: 使用 webpack 5 + express 搭建vue ssr

如果服务端改成 koa 渲染时, 需要注意

由于webpack-dev-middleware是一个标准的express中间件,在 Koa 中不能直接使用它,因此需要将webpack-dev-middleware封装一下,以便Koa能够直接使用。

koa-dev-middleware

const koaDevMiddleware = (expressDevMiddleware) => {
    return function middleware(ctx, next) {
        return new Promise((resolve, reject) => {
            expressDevMiddleware(ctx.req, {
                end: (content) => {
                    ctx.body = content
                    resolve()
                },
                setHeader: (name, value) => {
                    ctx.set(name, value)
                }
            }, reject)
        }).catch(next)
    }
}

module.exports = koaDevMiddleware

koa-dev-middleware

const koaDevMiddleware = (expressDevMiddleware) => {
    return function middleware(ctx, next) {
        return new Promise((resolve, reject) => {
            expressDevMiddleware(ctx.req, {
                end: (content) => {
                    ctx.body = content
                    resolve()
                },
                setHeader: (name, value) => {
                    ctx.set(name, value)
                }
            }, reject)
        }).catch(next)
    }
}

module.exports = koaDevMiddleware

koa-hot-middleware

const koaHotMiddleware = (expressHotMiddleware) => {
    return function middleware(ctx, next) {
        return new Promise((resolve) => {
            expressHotMiddleware(ctx.req, ctx.res, resolve)
        }).then(next)
    }
}
module.exports = koaHotMiddleware;

vue-server-renderer源码解析

  1. 创建渲染器createBundleRenderer是创建渲染器的主要方法。它接受一个包含服务器端编译好的Vue包的bundle对象,以及一个可选的模板。renderToString函数需要访问到组件的代码,所以需要预编译生成bundle。

  2. 渲染流程 (1) bundleRender函数会先初始化一个沙盒环境,然后执行bundle代码,生成根组件的vnode。(2) renderToString函数会递归遍历vnode树,生成HTML字符串。过程中会调用组件生命周期钩子,管理状态等。

  3. 组件生命周期 (1) beforeRender和afterRender钩子会在渲染前后调用。 (2) 会再次执行beforeCreate和created钩子,因为服务端和客户端都是独立运行的。

  4. 状态管理initState可以注入预设的状态,实现服务端和客户端状态一致。renderContext允许挂载共享的数据,实现状态共享。

  5. 异步组件支持返回Promise的组件,等待Promise resolve后再渲染,用于处理异步数据。

  6. 流式渲染renderToStream方法会以流的方式写入渲染结果,比直接生成字符串更高效。

使用 koa 当做开发服务器请看这里 vue-ssr-test: 使用 webpack 5 + koa2 搭建vue ssr

参考文章:

转载自:https://juejin.cn/post/7267737437953228839
评论
请登录