Тщательно понять Vue SSR

Vue.js
Тщательно понять Vue SSR

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

Сторона сервера отображает компоненты Vue в виде строк HTML, отправляет строки HTML непосредственно в браузер и, наконец, «активирует» эти статические теги в полностью интерактивное приложение на стороне клиента.

преимущество

  • Улучшение SEO, так как сканеры поисковых систем могут напрямую просматривать полностью обработанные страницы.
  • Быстрое время прибытия контента

недостаток

  • Условия развития ограничены. (Сервер выполняет только функции жизненного цикла beforeCreated и created, а окна, DOM, BOM и т. д. отсутствуют).
  • Дополнительные требования, связанные с установкой и развертыванием сборки, должны быть в рабочей среде сервера узла.
  • большая нагрузка на сервер

Суть ССР

  • Сервер отображает компонент Vue в виде строки HTML и отправляет строку HTML непосредственно в браузер.
  • Отдельные экземпляры приложений, чтобы не было загрязнения состояния перекрестными запросами.

Демонстрация в следующих случаях

  • Визуализация компонентов Vue непосредственно в строки html и возврат их в браузер
  • Рендеринг маршрутизаторов на стороне сервера
  • Рендеринг на стороне сервера, для которого требуются инициализированные данные (полный Vue SSR)

Сначала создайте простой проект vueКод Адрес 01

|—— components  //  子组件
|   |—— Foo.vue   
|   |—— Bar.vue
|   
|—— App.vue    // 根组件
|—— index.js   // 入口文件
|—— webpack.config.js

Код очень простой, это самый обычный проект Vue (включая некоторые события кликов, привязку данных), типичный рендеринг на стороне клиента.

Визуализация компонентов Vue непосредственно в строки html и возвратКодовый адрес 02/демо

Когда я впервые столкнулся с веб-разработкой, я использовал html-страницы в качестве шаблонов и помещал внутренние данные в шаблоны, такие как файлы .php и .jsp. Есть также artTemplate, ejs и т. д., используемые в сочетании с node.

Рендеринг Vue на стороне сервера также делится на два этапа:

  1. Разбирать файлы Vue (файлы шаблонов) в статические файлы html, css, js
  2. Возврат статических файлов клиенту

Официально предоставляет подключаемый модуль vue-server-renderer, который может напрямую отображать экземпляры vue в теги Dom.

demo1

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

demo2

Совместно с сервером html страница возвращается по запросу

   const Vue = require('vue')
   const Koa = require('koa');
   const Router = require('koa-router');
   const renderer = require('vue-server-renderer').createRenderer()
   
   const app = new Koa();
   const router = new Router();
   
   router.get('*', async (ctx, next) => {
     const app = new Vue({
       data: {
         url: ctx.request.url
       },
       template: `<div>访问的 URL 是: {{ url }}</div>`
     })
   
     renderer.renderToString(app, (err, html) => {
       if (err) {
         ctx.status(500).end('Internal Server Error')
         return
       }
       ctx.body = `
         <!DOCTYPE html>
         <html lang="en">
           <head><title>Hello</title></head>
           <body>${html}</body>
         </html>
       `
     })
   })
   
   app
     .use(router.routes())
     .use(router.allowedMethods());
   app.listen(8080, () => {
     console.log('listen 8080')
   })

Из demo1 видно, что метод vue-server-renderer возвращает html-фрагмент, официально называемый разметкой, а не полную html-страницу. Мы должны обернуть контейнер дополнительной HTML-страницей, как в demo2, чтобы обернуть сгенерированную HTML-разметку.

Мы можем предоставить шаблон страницы. Например

<!DOCTYPE html>
<html lang="en">
 <head><title>Hello</title></head>
 <body>
   <!--vue-ssr-outlet-->  
 </body>
</html>

Уведомление<!--vue-ssr-outlet-->Комментарии Здесь будет внедрена HTML-разметка приложения. Это обеспечивается плагином, если нет<!--vue-ssr-outlet-->Тоже возможно, тогда придется разбираться с этим самостоятельно. например демо3

demo3

 <!DOCTYPE html>
   <html lang="en">
     <head><title>Hello</title></head>
     <body>
       {injectHere}
     </body>
   </html>
demo3.js
const template = require('fs').readFileSync(path.resolve(__dirname, './index.template.html'), 'utf-8')
ctx.body = template.replace('{injectHere}', html)

Обратите внимание, что:

  • Приложение Vue.js, отображаемое на сервере, также можно считать «изоморфным» или «универсальным», поскольку большая часть кода приложения может выполняться как на сервере, так и на клиенте.
  • В клиентском приложении каждый пользователь использует новый экземпляр приложения в своем браузере. Для рендеринга на стороне сервера мы хотим того же: каждый запрос должен быть свежим, независимым экземпляром приложения, чтобы не было загрязнения состояния перекрестного запроса.
Для первого пункта:
  • Поскольку он может работать как на клиенте, так и на сервере, должно быть два входных файла. Некоторые операции Dom и Bom определенно невозможны на стороне сервера.

  • Обычно приложения Vue создаются с помощью webpack и vue-loader, и многие специфические функции webpack не работают напрямую в Node.js (например, импорт файлов через файловый загрузчик, CSS через css-загрузчик).

По второму пункту:
  • Его нужно обернуть в фабричную функцию, которая генерирует новый корневой компонент каждый раз, когда он вызывается.

app.js

    import Vue from 'vue'
    import App from './App.vue'
    
    export function createApp() {
        const app = new Vue({
            render: h => h(App)
        })
        return { app }
    }

enter-client.js

    import { createApp } from './app.js'
    
    const { app } = createApp()
    
    // App.vue 模板中根元素具有 `id="app"`
    app.$mount('#app')

enter-server.js

    import { createApp } from './app.js';
    
    export default context => { // koa 的 context
        const { app } = createApp()
        return app
    }
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <meta http-equiv="X-UA-Compatible" content="ie=edge">
      <title>服务端渲染</title>
    </head>
    <body>
      <!--vue-ssr-outlet-->
      <!-- 引入客户端打包后的js文件(client.bundle.js) -->
      <script type="text/javascript" src="<%= htmlWebpackPlugin.options.files.js %>"></script>
    </body>
    </html>

webpack.server.config.js

    const path = require('path');
    const merge = require('webpack-merge');
    const HtmlWebpackPlugin = require('html-webpack-plugin');
    const base = require('./webpack.base.config');
    
    module.exports = merge(base, {
        // 这允许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
        // 并且还会在编译 Vue 组件时,
        // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
      target: 'node',
      entry: {
        server: path.resolve(__dirname, '../entry-server.js')
      },
      output: {
          // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)
        libraryTarget: 'commonjs2'
      },
      plugins: [
        new HtmlWebpackPlugin({
          template: path.resolve(__dirname, '../../index.ssr.html'),
          filename: 'index.ssr.html',
          files: {
            js: 'client.bundle.js' // index.ssr.html 中引入的js文件是客户端打包出来的client.bundle.js。这是因为 Vue 需要在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM。这个过程官方称为客户端激活
          },
          excludeChunks: ['server']
        })
      ]
    });

webpack.client.config.js

    const path = require('path')
    const merge = require('webpack-merge')
    const HtmlWebpackPlugin = require('html-webpack-plugin')
    const base = require('./webpack.base.config')
    
    module.exports = merge(base, {
        entry: {
            client: path.resolve(__dirname, '../entry-client.js')
        },
        plugins: [
            new HtmlWebpackPlugin({
                template: path.resolve(__dirname, '../../index.html'),
                filename: 'index.html'
            })
        ]
    })

Это относительно полный пример захвата на стороне клиента статического HTML, отправленного экземпляром Vue, отображаемым на сервере, и динамическим Dom, управляемым Vue.полный код 03

Рендеринг на стороне сервера для представления маршрутизаторов

Управление маршрутизацией проекта Vue осуществляется vue-router.Как и проект 02, сервер возвращает отрендеренный html, а все остальное остается на Vue.

router.js

    import Vue from 'vue'
    import Router from 'vue-router'
    import Bar from "./components/Bar.vue";
    import Foo from "./components/Foo.vue";
    const routes = [
      { path: '/foo', component: Foo },
      { path: '/bar', component: Bar }
    ]
    
    Vue.use(Router)
    
    export function createRouter() {
      // 创建 router 实例,然后传 `routes` 配置
      // 你还可以传别的配置参数, 不过先这么简单着吧。
      return new Router({
        mode: 'history',
        routes
      })
    }

app.js представляет маршрутизатор

    import Vue from 'vue'
    import App from './App.vue'
    import { createRouter } from './router'
    
    // 导出一个工厂函数,用于创建新的
    // 应用程序、router 和 store 实例
    export function createApp() {
        // 创建 router 实例
        const router = createRouter()
        const app = new Vue({
            // 注入 router 到根 Vue 实例
            router,
            // 根实例简单的渲染应用程序组件。
            render: h => h(App)
        })
        return { app, router }
    }

Этого достаточно?Очевидно, этого недостаточно.Для оптимизации Vue мы обычно выбираем ленивую загрузку компонентов вместо того, чтобы загружать их все сразу. Затем нам нужно просто изменить файлы entry-server.js и router.js.

router.js

    import Vue from 'vue'
    import Router from 'vue-router'
    
    const routes = [
     // webpack.base.config.js 中需要配置 @babel/plugin-syntax-dynamic-import
      { path: '/foo', component: () => import('./components/Foo.vue') }, 
      { path: '/bar', component: () => import('./components/Bar.vue') }
    ]
    
    Vue.use(Router)
    
    export function createRouter() {
      // 创建 router 实例,然后传 `routes` 配置
      // 你还可以传别的配置参数, 不过先这么简单着吧。
      return new Router({
        mode: 'history',
        routes
      })
    }

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

entry-server.js

    import { createApp } from './app.js';
    
    export default context => {
        // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
        // 以便服务器能够等待所有的内容在渲染前,
        // 就已经准备就绪。
        return new Promise((resolve, reject) => {
            const { app, router } = createApp()
            if (context.url.indexOf('.') === -1) { // 防止匹配 favicon.ico   *.js 文件
                router.push(context.url)
            }
            // 设置服务器端 router 的位置
    
            console.log(context.url, '******')
            // 等到 router 将可能的异步组件和钩子函数解析完
            router.onReady(() => {
                const matchedComponents = router.getMatchedComponents()
                // 匹配不到的路由,执行 reject 函数,并返回 404
                if (!matchedComponents.length) {
                    return reject({ code: 404 })
                }
    
                // Promise 应该 resolve 应用程序实例,以便它可以渲染
                resolve(app)
            }, reject)
        })
    }

entry.client.js

    import { createApp } from './app.js'
    
    const { app, router } = createApp()
    
    router.onReady(() => {
      // 这里假定 App.vue 模板中根元素具有 `id="app"`
      app.$mount('#app')
    })

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

Итак, здесь мы используем плагин vue-server-renderer/server-plugin под vue-server-renderer, чтобы упаковать файл server.entry.js в файл json, и файл json будет содержать все асинхронные компоненты и связанные js один за другим. карта.

Рендеринг на стороне сервера, для которого требуются инициализированные данные

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

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

Чтобы решить эту проблему, извлеченные данные должны быть расположены вне компонента представления, то есть помещены в специальное хранилище данных или «контейнер состояния». Во-первых, на стороне сервера мы можем выполнять предварительную выборку данных перед рендерингом и заполнять хранилище данными. Кроме того, мы сериализуем и встроим предустановленное состояние в HTML. Таким образом, встроенное предустановленное состояние можно получить непосредственно из хранилища перед подключением к клиентскому приложению.

То есть, после разрешения всех хуков preFetch наше хранилище уже заполнено состоянием, необходимым для рендеринга приложения. когда мы присоединяем состояние к контексту, иtemplateКогда этот параметр используется в средстве визуализации, состояние автоматически сериализуется вwindow.__INITIAL_STATE__и внедрить HTML. На стороне клиента мы можем получить данные через глобальную переменную window.__INITIAL_STATE__.

Мы используем VueX, официальную библиотеку управления состоянием.

store.js

    import Vue from 'vue'
    import Vuex from 'vuex'
    
    Vue.use(Vuex)
    
    // 一个可以返回 Promise 的 API
    import { fetchItem } from './api'
    
    export function createStore () {
      return new Vuex.Store({
        state: {
          items: {}
        },
        actions: {
          fetchItem ({ commit }, id) {
            // `store.dispatch()` 会返回 Promise,
            // 以便我们能够知道数据在何时更新
            return fetchItem(id).then(item => {
              commit('setItem', { id, item })
            })
          }
        },
        mutations: {
          setItem (state, { id, item }) {
            Vue.set(state.items, id, item)
          }
        }
      })
    }

app.js

    import Vue from 'vue'
    import App from './App.vue'
    import { createRouter } from './router'
    import { createStore } from './store'
    
    // 导出一个工厂函数,用于创建新的
    // 应用程序、router 和 store 实例
    export function createApp() {
        const router = createRouter()
        const store = createStore()
        const app = new Vue({
            router,
            store,
            // 根实例简单的渲染应用程序组件。
            render: h => h(App)
        })
        return { app, router, store }
    }

Итак, куда мы поместим код для «действия предварительной выборки данных отправки»?

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

Мы предоставим пользовательскую статическую функцию asyncData для компонента маршрутизации. Обратите внимание, что поскольку эта функция вызывается до создания экземпляра компонента, она не имеет к этому доступа. Информация о хранилище и маршрутизации должна быть передана в качестве параметров, поэтому теперь наш entry-server.js выглядит так:

entry-server.js

    import { createApp } from './app.js';

    export default context => {
      // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
      // 以便服务器能够等待所有的内容在渲染前,
      // 就已经准备就绪。
      return new Promise((resolve, reject) => {
        const { app, router, store } = createApp()
        if (context.url.indexOf('.') === -1) {
          // 设置服务器端 router 的位置
          router.push(context.url)
        }
        
        console.log(context.url, '******')
        // 等到 router 将可能的异步组件和钩子函数解析完
        router.onReady(() => {
          const matchedComponents = router.getMatchedComponents()
          // 匹配不到的路由,执行 reject 函数,并返回 404
          if (!matchedComponents.length) {
            router.push('/foo')   // 可以加个默认页面, 或者是404页面
            // return reject({ code: 404 })
          }
    
          Promise.all(matchedComponents.map(component => {
            if (component.asyncData) {
              return component.asyncData(
                {
                  store,
                  route: router.currentRoute
                })
            }
          })).then(() => {
            // 当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态,
            //自动嵌入到最终的 HTML 中。而在客户端,在挂载到应用程序之前,store 就应该获取到状态
            context.state = store.state
            // Promise 应该 resolve 应用程序实例,以便它可以渲染
            resolve(app)
          }).catch(reject)
        }, reject)
      })
    }

Сервер сохраняет данные в context.state, а клиентwindow.__INITIAL_STATE__получить данные

entry.client.js

    import { createApp } from './app.js'
    
    const { app, router, store } = createApp()
    // 还原状态
    if(window.__INITAL__STATE__) {
        store.replaceState(window.__INITAL__STATE)
    }
    router.onReady(() => {
      // 这里假定 App.vue 模板中根元素具有 `id="app"`
      app.$mount('#app')
    })

Как загрузить функцию asyncData на страницу ниже сгиба

Когда есть функции asyncData на нескольких страницах, как выполнить функцию asyncData на страницах, которые не загружаются на первом экране?После загрузки первого экрана перейти к другим страницам.Выполнение asyncData других страниц находится в beforeMount функция ловушки Выполняется, микшируется через миксин.

Vue.mixin({
    beforeMount() {
        const { asyncData } = this.$options
        if(asyncData) {
            this.dataPromise = asyncData({
                store: this.$store,
                route: this.$route
            })
        }
    }
})

следовать за

Теперь его нельзя обновить в режиме реального времени, и его необходимо оптимизировать.