Рендеринг на стороне сервера Vue (SSR)

сервер JavaScript Vue.js HTML

Рендеринг на стороне сервера Vue (SSR)

Что такое рендеринг на стороне сервера, простое понимание состоит в том, что компонент или страница генерирует html-строку через сервер, затем отправляет ее в браузер и, наконец, «смешивает» статическую разметку в полностью интерактивное приложение на клиенте. По сравнению с традиционным SPA (одностраничным приложением), рендеринг на стороне сервера может быть лучше для SEO и сократить время загрузки первого экрана страницы.Конечно, для разработки нам нужно изучить больше знаний для поддержки рендеринга на стороне сервера. . В то же время рендеринг на стороне сервера сильно нагружает сервер: по сравнению с простым выводом статических файлов сервером явно дорого рендерить страницу через узел и затем передавать ее клиенту. необходимо обратить внимание на подготовку соответствующей нагрузки сервера.

1. Простой пример

// 第 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>
})

В приведенном выше примере используетсяvue-server-rendererПакет npm рендерит пример vue и, наконец, рендерит кусок html. Рендеринг сервера легко достигается путем отправки этого HTML-кода клиенту.

const server = require('express')()
server.get('*', (req, res) => {
  // ... 生成 html
  res.end(html)
})
server.listen(8080)

2. Официальные этапы рендеринга

Хотя приведенный выше пример прост, в реальных проектах необходимо учитывать маршрутизацию, данные, компонентизацию и т. д., поэтому рендеринг на стороне сервера не использует только одинvue-server-rendererПакет npm можно легко сделать Вот схематическая диаграмма официального серверного рендеринга Vue:

Блок-схема примерно означает: упаковать исходный код (исходный код) в два пакета через веб-пакет, из которых серверный пакет предназначен для сервера, а сервер использует средство визуализации bundleRenderer для генерации html из пакета для браузера; другой клиентский пакет предназначен для просмотра. Для использования в браузере не забывайте, что сервер генерирует только HTML-код, необходимый для первой страницы экрана на ранней стадии, а для более позднего взаимодействия и обработки данных по-прежнему требуется клиентский пакет, который может поддерживать сценарии браузера для завершения.

В-третьих, как добиться

Процесс реализации заключается в преобразовании приведенной выше схематической диаграммы в реализацию кода, но этот процесс все еще немного сложен, и требуется больше терпения, чтобы тщательно изучить каждую деталь.

1. Сначала реализуйте базовую версию

Пример структуры проекта:

├── build
│   ├── webpack.base.config.js     # 基本配置文件
│   ├── webpack.client.config.js   # 客户端配置文件
│   ├── webpack.server.config.js   # 服务端配置文件
└── src
    ├── router          
    │    └── index.js              # 路由
    └── views             
    │    ├── comp1.vue             # 组件
    │    └── copm2.vue             # 组件
    ├── App.vue                    # 顶级 vue 组件
    ├── app.js                     # app 入口文件
    ├──  client-entry.js           # client 的入口文件
    ├──  index.template.html       # html 模板
    ├──  server-entry.js           # server 的入口文件
├──  server.js           # server 服务

в:

(1), компоненты comp1.vue и copm2.vue
<template>
    <section>组件 1</section>
</template>
<script>
    export default {
        data () {
            return {
                msg: ''
            }
        }
    }
</script>
(2), компонент vue верхнего уровня App.vue
<template>
    <div id="app">
        <h1>vue-ssr</h1>
        <router-link class="link" to="/comp1">to comp1</router-link>
        <router-link class="link" to="/comp2">to comp2</router-link>

        <router-view class="view"></router-view>
    </div>
</template>

<style lang="stylus">
    .link
        margin 10px
</style>
(3), html-шаблон index.template.html
<!DOCTYPE html>
<html lang="zh_CN">
<head>
    <title>{{ title }}</title>
    <meta charset="utf-8"/>
    <meta name="mobile-web-app-capable" content="yes"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge, chrome=1"/>
    <meta name="renderer" content="webkit"/>
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui"/>
    <meta name="theme-color" content="#f60"/>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
(4), приведенный выше базовый код не объясняется, давайте посмотрим

маршрутизатор маршрутизации

import Vue from 'vue'
import Router from 'vue-router'
import comp1 from '../views/comp1.vue'
import comp2 from '../views/comp2.vue'
Vue.use(Router)
export function createRouter () {
    return new Router({
        mode: 'history',
        scrollBehavior: () => ({ y: 0 }),
        routes: [
            {
                path: '/comp1',
                component: comp1
            },
            {
                path: '/comp2',
                component: comp2
            },
            { path: '/', redirect: '/comp1' }
        ]
    })
}

файл записи приложения app.js

import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'

export function createApp (ssrContext) {
    const router = createRouter()
    const app = new Vue({
        router,
        ssrContext,
        render: h => h(App)
    })
    return { app, router }
}

мы проходимcreateAppПредоставьте корневой экземпляр Vue, чтобы гарантировать, что каждый пользователь может получить новый экземпляр и избежать загрязнения состояния, поэтому мы написали Заводская функция, которая может выполняться многократноcreateApp. Тот же маршрутизатор маршрутизации, мы также имеем дело с таким же образомcreateRouterвыставить экземпляр маршрутизатора

(5) файл записи клиента client-entry.js
import { createApp } from './app'

const { app, router } = createApp()
router.onReady(() => {
    app.$mount('#app')
})

Код клиента должен смонтировать приложение под тегом #app после завершения синтаксического анализа маршрута.

(7) Файл входа сервера server-entry.js
import { createApp } from './app'

export default context => {
    // 因为这边 router.onReady 是异步的,所以我们返回一个 Promise
    // 确保路由或组件准备就绪
    return new Promise((resolve, reject) => {
        const { app, router } = createApp(context)
        router.push(context.url)
        router.onReady(() => {
            resolve(app)
        }, reject)
    })
}

Входной файловый сервер, мы вернули обещание

2. Упаковка

На первом этапе мы потратили много времени на реализацию кода шаблона ежедневной функции с маршрутизацией, затем нам нужно использовать веб-пакет для упаковки вышеуказанного кода из кодов ключей сервера и клиента.server-entry.jsа такжеclient-entry.js

(1), конфигурация сборки веб-пакета

Общая конфигурация разбита на три файла: base, client и server. Базовая конфигурация содержит конфигурацию, общую для обеих сред, например выходные пути, псевдонимы и загрузчики. Конфигурация сервера и конфигурация клиента, базовая конфигурация может быть легко расширена с помощью webpack-merge.

файл конфигурации webpack.base.config.js

const path = require('path')
const webpack = require('webpack')
const ExtractTextPlugin = require('extract-text-webpack-plugin')

module.exports = {
    devtool: '#cheap-module-source-map',
    output: {
        path: path.resolve(__dirname, '../dist'),
        publicPath: '/',
        filename: '[name]-[chunkhash].js'
    },
    resolve: {
        alias: {
            'public': path.resolve(__dirname, '../public'),
            'components': path.resolve(__dirname, '../src/components')
        },
        extensions: ['.js', '.vue']
    },
    module: {
        noParse: /es6-promise\.js$/,
        rules: [
            {
                test: /\.(js|vue)/,
                use: 'eslint-loader',
                enforce: 'pre',
                exclude: /node_modules/
            },
            {
                test: /\.vue$/,
                use: {
                    loader: 'vue-loader',
                    options: {
                        preserveWhitespace: false,
                        postcss: [
                            require('autoprefixer')({
                                browsers: ['last 3 versions']
                            })
                        ]
                    }
                }
            },
            {
                test: /\.js$/,
                use: 'babel-loader',
                exclude: /node_modules/
            },
            {
                test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
                use: {
                    loader: 'url-loader',
                    options: {
                        limit: 10000,
                        name: 'img/[name].[hash:7].[ext]'
                    }
                }
            },
            {
                test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
                use: {
                    loader: 'url-loader',
                    options: {
                        limit: 10000,
                        name: 'fonts/[name].[hash:7].[ext]'
                    }
                }
            },
            {
                test: /\.css$/,
                use: ['vue-style-loader', 'css-loader']
            },
            {
                test: /\.json/,
                use: 'json-loader'
            }
        ]
    },
    performance: {
        maxEntrypointSize: 300000,
        hints: 'warning'
    },
    plugins: [
        new webpack.optimize.UglifyJsPlugin({
            compress: { warnings: false }
        }),
        new ExtractTextPlugin({
            filename: 'common.[chunkhash].css'
        })
    ]
}

Файл конфигурации webpack.client.config.js

const path = require('path')
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const glob = require('glob')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

const config = merge(base, {
    entry: {
        app: './src/client-entry.js'
    },
    resolve: {
        alias: {
            'create-api': './create-api-client.js'
        }
    },
    plugins: [
        new webpack.DefinePlugin({
            'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
            'process.env.VUE_ENV': '"client"',
            'process.env.DEBUG_API': '"true"'
        }),
        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            minChunks: function (module) {
                return (
                    /node_modules/.test(module.context) && !/\.css$/.test(module.require)
                )
            }
        }),
        new webpack.optimize.CommonsChunkPlugin({
            name: 'manifest'
        }),
        // 这是将服务器的整个输出
        // 构建为单个 JSON 文件的插件。
        // 默认文件名为 `vue-ssr-server-bundle.json`
        new VueSSRClientPlugin()
    ]
})
module.exports = config

Файл конфигурации webpack.server.config.js

const path = require('path')
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const nodeExternals = require('webpack-node-externals')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

module.exports = merge(base, {
    target: 'node',
    devtool: '#source-map',
    entry: './src/server-entry.js',
    output: {
        filename: 'server-bundle.js',
        libraryTarget: 'commonjs2'
    },
    resolve: {
        alias: {
            'create-api': './create-api-server.js'
        }
    },
    externals: nodeExternals({
        whitelist: /\.css$/
    }),
    plugins: [
        new webpack.DefinePlugin({
            'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
            'process.env.VUE_ENV': '"server"'
        }),
        new VueSSRServerPlugin()
    ]
})

Конфигурация webpack завершена, но есть не так много вещей, все из которых являются обычными конфигурациями. должен быть в курсеwebpack.server.config.jsКонфигурация, выход - создать библиотеку commonjs,VueSSRServerPluginДля этого используется плагин, который собирает весь вывод сервера в один файл JSON.

(2), сборка веб-пакета poj

код сборки

webpack --config build/webpack.client.config.js
webpack --config build/webpack.server.config.js

После упаковки будут сгенерированы некоторые файлы упаковки, из которых после упаковки будет сгенерирован server.configvue-ssr-server-bundle.jsonфайл, этот файл дляcreateBundleRendererИспользуется для рендеринга html-файлов на стороне сервера.

const { createBundleRenderer } = require('vue-server-renderer')
const renderer = createBundleRenderer('/path/to/vue-ssr-server-bundle.json', {
  // ……renderer 的其他选项
})

Если вы будете внимательны, вы также обнаружите, что client.config не только генерирует файл js, используемый клиентом, но также генерируетvue-ssr-client-manifest.jsonфайл, этот файл является списком сборки на стороне клиента, сервер получает этот список сборки, находит скрипт js или css для инициализации, внедряет его в html и вместе отправляет в браузер.

(3), рендеринг на стороне сервера

На самом деле вышеперечисленное - все подготовительные работы. Наиболее важный шаг - отправить код ресурса, созданного WebPack, к серверу для генерации HTML. Нам нужно использовать узел для записи приложения Server-Side, генерируйте HTML из упакованных ресурсов и отправьте его в браузер

server.js

const fs = require('fs')
const path = require('path')
const Koa = require('koa')
const KoaRuoter = require('koa-router')
const serve = require('koa-static')
const { createBundleRenderer } = require('vue-server-renderer')
const LRU = require('lru-cache')

const resolve = file => path.resolve(__dirname, file)
const app = new Koa()
const router = new KoaRuoter()
const template = fs.readFileSync(resolve('./src/index.template.html'), 'utf-8')

function createRenderer (bundle, options) {
    return createBundleRenderer(
        bundle,
        Object.assign(options, {
            template,
            cache: LRU({
                max: 1000,
                maxAge: 1000 * 60 * 15
            }),
            basedir: resolve('./dist'),
            runInNewContext: false
        })
    )
}

let renderer
const bundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
renderer = createRenderer(bundle, {
    clientManifest
})

/**
 * 渲染函数
 * @param ctx
 * @param next
 * @returns {Promise}
 */
function render (ctx, next) {
    ctx.set("Content-Type", "text/html")
    return new Promise (function (resolve, reject) {
        const handleError = err => {
            if (err && err.code === 404) {
                ctx.status = 404
                ctx.body = '404 | Page Not Found'
            } else {
                ctx.status = 500
                ctx.body = '500 | Internal Server Error'
                console.error(`error during render : ${ctx.url}`)
                console.error(err.stack)
            }
            resolve()
        }
        const context = {
            title: 'Vue Ssr 2.3',
            url: ctx.url
        }
        renderer.renderToString(context, (err, html) => {
            if (err) {
                return handleError(err)
            }
            console.log(html)
            ctx.body = html
            resolve()
        })
    })
}

app.use(serve('/dist', './dist', true))
app.use(serve('/public', './public', true))

router.get('*', render)
app.use(router.routes()).use(router.allowedMethods())

const port = process.env.PORT || 8089
app.listen(port, '0.0.0.0', () => {
    console.log(`server started at localhost:${port}`)
})

Здесь мы используем то, что использовали в начальной демонстрации.vue-server-rendererпакет npm, прочитавvue-ssr-server-bundle.jsonа такжеvue-ssr-client-manifest.jsonрендерер файлов из html и, наконец,ctx.body = htmlв браузер, пробуемconsole.log(html)Вытащите html и посмотрите, что такое рендеринг на стороне сервера:

<!DOCTYPE html>
<html lang="zh_CN">
<head>
    <title>Vue Ssr 2.3</title>
    <meta charset="utf-8"/>
    <meta name="mobile-web-app-capable" content="yes"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge, chrome=1"/>
    <meta name="renderer" content="webkit"/>
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui"/>
    <meta name="theme-color" content="#f60"/>
<link rel="preload" href="/dist/manifest-56dda86c1b6ac68c0279.js" as="script"><link rel="preload" href="/dist/vendor-3504d51340141c3804a1.js" as="script"><link rel="preload" href="/dist/app-ae1871b21fa142b507e8.js" as="script"><style data-vue-ssr-id="41a1d6f9:0">
.link {
  margin: 10px;
}
</style><style data-vue-ssr-id="7add03b4:0"></style></head>
<body>

<div id="app" data-server-rendered="true">
<h1>vue-ssr</h1>
<a href="/comp1" class="link router-link-exact-active router-link-active">to comp1</a>
<a href="/comp2" class="link">to comp2</a>
<section class="view">组件 1</section>
</div>

<script src="/dist/manifest-56dda86c1b6ac68c0279.js" defer>
</script>
<script src="/dit/vendor-3504d51340141c3804a1.js" defer></script>
<script src="/dist/app-ae1871b21fa142b507e8.js" defer></script>
</body>
</html>

Вы можете видеть, что сервер маршрутизировал组件 1Он также визуализируется вместо динамической загрузки клиента, а во-вторых, html также внедряется с некоторыми тегами

2018-5-28 Обновление

Исправлена ​​ошибка, упомянутая в области комментариев.Если есть другие проблемы, вы можете перейти в соответствующий проект github, чтобы дописать задачи.Выпуски github имеют формат уценки, что удобно для описания проблемы и обсуждения.Описание проблемы может быть максимально понятен, и автор может проверить причину проблемы.

2018-7-14 Обновление

Решить проблему Неожиданный токен

Причина ошибки в том, что путь к ресурсу JavaScript html неверен.

HTML-код, отображаемый сервером, имеет тег script, а путь к статическому ресурсу .js, соответствующий запросу сервера, неверен.

<script src="/dist/manifest-56dda86c1b6ac68c0279.js" defer>
</script>
<script src="/dit/vendor-3504d51340141c3804a1.js" defer></script>
<script src="/dist/app-ae1871b21fa142b507e8.js" defer></script>

В области комментариев модификация упоминается следующим образом, спасибо за указание на ошибку @wfz

//webpack.base.config.js 配置文件

output: {
        - publicPath: '/dit/',
        + publicPath: '/',
    },
//server.js

- app.use(serve('/dist', './dist', true))
- app.use(serve('/public', './public', true))
+ app.use(serve(__dirname + '/dist'))

Соответствует выходу html

<script src="/manifest-56dda86c1b6ac68c0279.js" defer>
</script>
<script src="/vendor-3504d51340141c3804a1.js" defer></script>
<script src="/app-ae1871b21fa142b507e8.js" defer></script>

запустить проект

npm run install
npm run build:client  // 生成 clientBundle
npm run build:server  // 生成 serverBundle
npm run dev           // 启动 node 渲染服务

open http://localhost:8089/