Минималистская версия демонстрации VUE SSR

сервер JavaScript Webpack

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

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

* e06aee792a59ffd9018aea1e3601e220c37fedbd (HEAD -> master, origin/master) 优化:添加缓存
* c65f08beaff1dea1eaf05d02fb30a7e8776ce289 程序开发:初步完成demo
* 2fb0d28ee6d84d2b1bdbbe419c744efdad3227de 程序开发:完成store定义,api编写和程序同步
* 9604aec0de526726f4fe435385f7c2fa4009fa63 程序开发:第一个可独立运行版本,无store
* 7d567e254fc9dc5a1655d2f0abbb4b8d53bccfce 构建配置:webpack配置、server.js后端入口文件编写
* 969248b64af82edd07214a621dfd19cf357d6c53 构建配置:babel 配置
* a5453fdeb20769e8c9e9ee339b624732ad14658a 初始化项目,完成第一个可运行demo

При чтении и тестировании можно пройтиgit reset --hard commitidЧтобы переключаться между различными этапами, см. конкретную реализацию.

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

Vue.js — это фреймворк для создания клиентских приложений. По умолчанию компоненты Vue могут выводиться в браузере для создания DOM и управления DOM. Однако также возможно отображать тот же компонент в виде строк HTML на стороне сервера, отправлять их непосредственно в браузер и, наконец, «активировать» эти статические теги как полностью интерактивное приложение на стороне клиента.

Приложение Vue.js, отображаемое на сервере, также можно считать «изоморфным» или «универсальным», поскольку большую часть кода приложения можно найти всервера такжеклиентзапускать на.

Зачем использовать рендеринг на стороне сервера (SSR)?

По сравнению с традиционным SPA (одностраничным приложением) преимущества рендеринга на стороне сервера (SSR) в основном заключаются в следующем:

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

Основное использование

Установите необходимые шаблоны

npm install vue vue-server-renderer express --save

новый/server.js,/src/index.template.html

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

const Renderer = require('vue-server-renderer').createRenderer({
  template:fs.readFileSync('./src/index.template.html', 'utf-8')
})

server.get('*', (req, res) => {

  const app = new Vue({
    data: {
      name: 'vue app~',
      url: req.url
    },
    template:'<div>hello from {{name}}, and url is: {{url}}</div>'
  })
  const context = {
    title: 'SSR test#'
  }
  Renderer.renderToString(app, context, (err, html) => {
    if(err) {
      console.log(err)
      res.status(500).end('server error')
    }
    res.end(html)
  })
})

server.listen(4001)
console.log('running at: http://localhost:4001');

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

Но в то же время видно, что вывод представляет собой статическую чистую html-страницу.Поскольку файлы javascript не загружены, взаимодействие с интерфейсом пользователя не реализовано, поэтому приведенная выше демонстрация является лишь минимальным примером.Для достижения полная программа VUE ssr, также необходимо использоватьVueSSRClientPlugin(vue-server-renderer/client-plugin) Скомпилируйте файл в файл vue-ssr-client-manifest.json и js, css и другие файлы, которые могут быть запущены внешним браузером,VueSSRServerPlugin(vue-server-renderer/server-plugin) Скомпилируйте файл в vue-ssr-server-bundle.json, который может быть вызван узлом

Прежде чем начать, вам нужно понять некоторые концепции

написать общий код

Ограничения при «универсальном» коде — т. е. коде, который выполняется на сервере и клиенте, наш код не будет точно таким же при работе в разных средах из-за различий в вариантах использования и API-интерфейсах платформы.

Ответ данных на сервере

Каждый запрос должен быть новым независимым экземпляром приложения, чтобы не было загрязнения состояния перекрестного запроса.

Хуки жизненного цикла компонентов

Поскольку динамического обновления нет, из всех функций хуков жизненного цикла только beforeCreate и created будут вызываться во время рендеринга на стороне сервера (SSR).

Доступ к API для конкретных платформ

Общий код не поддерживает API-интерфейсы для конкретных платформ, поэтому, если ваш код напрямую использует глобальные переменные, такие как окно или документ, которые доступны только для браузера, при выполнении в Node.js будет выдана ошибка, и наоборот.

конфигурация сборки

Как обслуживать одно и то же приложение Vue на сервере и клиенте. Для этого нам нужно использовать webpack для упаковки приложения Vue.

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

  • Хотя последняя версия Node.js полностью поддерживает функции ES2015, нам по-прежнему необходимо транспилировать код на стороне клиента для поддержки старых браузеров. Это также включает этапы сборки.

Таким образом, основная идея заключается в том, что как для клиентского приложения, так и для серверного приложения мы используем веб-пакет для упаковки — серверу нужен «серверный пакет», который затем используется для рендеринга на стороне сервера (SSR), а «клиентский пакет» отправляется в браузер, для смешивания статической разметки.

构建过程

Давайте посмотрим на конкретный процесс реализации

Вавилонская конфигурация

новый/.babelrcнастроить

// es6 compile to es5 相关配置
{
  "presets": [
    [
      "env",
      {
        "modules": false
      }
    ]
  ],
  "plugins": ["syntax-dynamic-import"]
}

npm i -D babel-loader@7 babel-core babel-plugin-syntax-dynamic-import babel-preset-env

Конфигурация WebPack

Создайте папку Build для храненияwebpackсвязанные файлы конфигурации

/
├── build
│   ├── setup-dev-server.js  # 设置 webpack-dev-middleware 开发环境
│   ├── webpack.base.config.js # 基础通用配置
│   ├── webpack.client.config.js  # 编译出 vue-ssr-client-manifest.json 文件和 js、css 等文件,供浏览器调用
│   └── webpack.server.config.js  # 编译出 vue-ssr-server-bundle.json 供 nodejs 调用

Сначала установите соответствующие пакеты

Установите пакеты, связанные с веб-пакетом

npm i -D webpack webpack-cli webpack-dev-middleware webpack-hot-middleware webpack-merge webpack-node-externals

Установить зависимости сборки

npm i -D chokidar cross-env friendly-errors-webpack-plugin memory-fs rimraf vue-loader

Затем посмотрите на конкретное содержимое каждого файла:

webpack.base.config.js

const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')

const isProd = process.env.NODE_ENV === 'production'
module.exports = {
  context: path.resolve(__dirname, '../'),
  devtool: isProd ? 'source-map' : '#cheap-module-source-map',
  output: {
    path: path.resolve(__dirname, '../dist'),
    publicPath: '/dist/',
    filename: '[name].[chunkhash].js'
  },
  resolve: {
    // ...
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          compilerOptions: {
            preserveWhitespace: false
          }
        }
      }
      // ...
    ]
  },
  plugins: [new VueLoaderPlugin()]
}

webpack.base.config.jsЭто общая конфигурация, которая в основном такая же, как и наша предыдущая конфигурация разработки SPA.

webpack.client.config.js

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

const config = merge(base, {
  mode: 'development',
  entry: {
    app: './src/entry-client.js'
  },
  resolve: {},
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(
        process.env.NODE_ENV || 'development'
      ),
      'process.env.VUE_ENV': '"client"'
    }),
    new VueSSRClientPlugin()
  ]
})
module.exports = config

webpack.client.config.jsВ основном выполнил две задачи

  • определить файл вводаentry-client.js
  • через плагинVueSSRClientPluginгенерироватьvue-ssr-client-manifest.json

На этот файл manifest.json ссылается server.js

const { createBundleRenderer } = require('vue-server-renderer')

const template = require('fs').readFileSync('/path/to/template.html', 'utf-8')
const serverBundle = require('/path/to/vue-ssr-server-bundle.json')
const clientManifest = require('/path/to/vue-ssr-client-manifest.json')

const renderer = createBundleRenderer(serverBundle, {
  template,
  clientManifest
})

С указанными выше настройками HTML-код, отображаемый на сервере, созданный с использованием функции разделения кода, внедряется автоматически.

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

module.exports = merge(base, {
  mode: 'production',
  target: 'node',
  devtool: '#source-map',
  entry: './src/entry-server.js',
  output: {
    filename: 'server-bundle.js',
    libraryTarget: 'commonjs2'
  },
  resolve: {},
  externals: nodeExternals({
    whitelist: /\.css$/ // 防止将某些 import 的包(package)打包到 bundle 中,而是在运行时(runtime)再去从外部获取这些扩展依赖
  }),
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
      'process.env.VUE_ENV': '"server"'
    }),
    new VueSSRServerPlugin()
  ]
})

webpack.server.config.jsОсновная проделанная работа:

  • пройти черезtarget: 'node'Сообщите веб-пакету, что скомпилированный код каталога является приложением node.
  • пройти черезVueSSRServerPluginплагин, который компилирует код вvue-ssr-server-bundle.json

в созданииvue-ssr-server-bundle.jsonПосле этого просто передайте путь к файлуcreateBundleRenderer,существуетserver.jsреализуется следующим образом:

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

На данный момент строительство в основном завершено

Завершите первый работающий экземпляр

Установите зависимости, связанные с VUE

npm i axios vue-template-compiler vue-router vuex vuex-router-sync

Добавьте и улучшите следующие файлы:

/
├── server.js # 实现长期运行的 node 程序
├── src
│   ├── app.js # 新增
│   ├── router.js # 新增 定义路由
│   ├── App.vue # 新增
│   ├── entry-client.js # 浏览器端入口
│   ├── entry-server.js # node程序端入口
└── views
    └── Home.vue # 首页

Затем просмотрите эти файлы один за другим:

server.js

const fs = require('fs');
const path = require('path');
const express = require('express');
const { createBundleRenderer } = require('vue-server-renderer');
const devServer = require('./build/setup-dev-server')
const resolve = file => path.resolve(__dirname, file);

const isProd = process.env.NODE_ENV === 'production';
const app = express();

const serve = (path, cache) =>
  express.static(resolve(path), {
    maxAge: cache && isProd ? 1000 * 60 * 60 * 24 * 30 : 0
  });
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 startTime = Date.now();
  res.setHeader('Content-Type', 'text/html');

  const context = {
    title: 'SSR 测试', // default title
    url: req.url
  };
  renderer.renderToString(context, (err, html) => {
    res.send(html);
  });
}

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

if (isProd) {
  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文件注入到页面中
  renderer = createRenderer(bundle, {
    template,
    clientManifest
  });
} else {
  readyPromise = devServer( app, templatePath, (bundle, options) => {
      renderer = createRenderer(bundle, options);
    }
  );
}

app.get('*',isProd? render : (req, res) => {
        readyPromise.then(() => render(req, res));
      }
);

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

server.jsВ основном выполнены следующие работы

  • при исполненииnpm run devпри звонке/build/setup-dev-server.jsЗапустите промежуточное ПО для разработки webpack-dev-middleware.
  • пройти черезvue-server-rendererСкомпилировано перед вызовомvue-ssr-server-bundle.jsonзапустить службу узла
  • Будуvue-ssr-client-manifest.jsonвводить вcreateRendererРеализовать автоматическую инъекцию внешних ресурсов в
  • пройти черезexpressиметь дело сhttpпросить

server.jsЭто входная программа всего сайта, она вызывает скомпилированный файл и, наконец, выводит его на страницу, которая является ключевой частью всего проекта.

app.js

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

export function createApp(context) {
  const router = createRouter();

  const app = new Vue({
    router,
    render: h => h(App)
  });
  return { app, router };
};

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

entry-client.js

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

entry-client.jsРегулярно создавайте экземпляры объектов vue и монтируйте их на странице.

entry-server.js

import { createApp } from './app';

export default context => {
  // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
  // 以便服务器能够等待所有的内容在渲染前,
  // 就已经准备就绪。
  return new Promise((resolve, reject) => {
    const { app, router } = createApp(context);

    // 设置服务器端 router 的位置
    router.push(context.url);

    // 等到 router 将可能的异步组件和钩子函数解析完
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents();

      // 匹配不到的路由,执行 reject 函数,并返回 404
      if (!matchedComponents.length) {
        return reject({ code: 404 });
      }

      resolve(app);
    });
  });
};

entry-server.jsКак сервер зайти, и наконец пройтиVueSSRServerPluginплагин, скомпилированный вvue-ssr-server-bundle.jsonдляvue-server-rendererпередача

router.jsа такжеHome.vueдля обычногоvueПроцедура, которая не будет дальше расширена здесь.

На данный момент мы завершили первый полностью скомпилированный и запущенныйvue ssrПример

Предварительная выборка данных и управление состоянием

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

Во время рендеринга на стороне сервера (SSR) мы, по сути, рендерим «моментальный снимок» нашего приложения, поэтому, если приложение зависит от некоторых асинхронных данных, его необходимо предварительно загрузить и проанализировать перед запуском процесса рендеринга.

Контейнер хранилища предварительной выборки данных (хранилище данных)

Сначала определите получение данныхapi.js,использоватьaxios:

import axios from 'axios';

export function fetchItem(id) {
  return axios.get('https://api.mimei.net.cn/api/v1/article/' + id);
}
export function fetchList() {
  return axios.get('https://api.mimei.net.cn/api/v1/article/');
}

Мы будем использовать официальную библиотеку управления состоянием Vuex. Сначала создадим файл store.js, который будет получать список файлов и получать содержимое статьи по id:

import Vue from 'vue';
import Vuex from 'vuex';
import { fetchItem, fetchList } from './api.js'

Vue.use(Vuex);


export function createStore() {
  return new Vuex.Store({
    state: {
      items: {},
      list: []
    },
    actions: {
      fetchItem({commit}, id) {
        return fetchItem(id).then(res => {
          commit('setItem', {id, item: res.data})
        })
      },
      fetchList({commit}){
        return fetchList().then(res => {
          commit('setList', res.data.list)
        })
      }
    },
    mutations: {
      setItem(state, {id, item}) {
        Vue.set(state.items, id, item)
      },
      setList(state, list) {
        state.list = list
      }
    }
  });
}

затем изменитьapp.js:

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

import { sync } from 'vuex-router-sync'

export function createApp(context) {
  const router = createRouter();
  const store = createStore();

  sync(store, router)

  const app = new Vue({
    router,
    store,
    render: h => h(App)
  });
  return { app, router, store };
};

Компоненты с логической конфигурацией

store actionПосле завершения определения давайте посмотрим, как инициировать запрос.Официальная рекомендация — поместить его в компонент маршрутизации. Далее см.Home.vue:

<template>
  <div>
    <h3>文章列表</h3>
    <div class="list" v-for="i in list">
      <router-link :to="{path:'/item/'+i.id}">{{i.title}}</router-link>
      </div>
  </div>
</template>
<script>
export default {
  asyncData ({store, route}){
    return store.dispatch('fetchList')
  },
  computed: {
    list () {
      return this.$store.state.list
    }
  },
  data(){
    return {
      name:'wfz'
    }
  }
}
</script>

Предварительная выборка данных на стороне сервера

существуетentry-server.js, мы можем получить путем маршрутизации с помощьюrouter.getMatchedComponents()соответствующий компонент, если компонент предоставляетasyncData, мы называем этот метод. Затем нам нужно присоединить проанализированное состояние к контексту рендеринга.

// entry-server.js
import { createApp } from './app';

export default context => {
  // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
  // 以便服务器能够等待所有的内容在渲染前,
  // 就已经准备就绪。
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp(context);

    // 设置服务器端 router 的位置
    router.push(context.url);

    // 等到 router 将可能的异步组件和钩子函数解析完
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents();

      // 匹配不到的路由,执行 reject 函数,并返回 404
      if (!matchedComponents.length) {
        return reject({ code: 404 });
      }

      Promise.all(
        matchedComponents.map(component => {
          if (component.asyncData) {
            return component.asyncData({
              store,
              route: router.currentRoute
            });
          }
        })
      ).then(() => {
        context.state = store.state
        // Promise 应该 resolve 应用程序实例,以便它可以渲染
        resolve(app);
      });
    });
  });
};

когда используешьtemplateчас,context.stateбудет использоваться какwindow.__INITIAL_STATE__состояние, автоматически встроенное в окончательный HTML. На стороне клиента хранилище должно получить состояние перед его подключением к приложению:

// entry-client.js

const { app, router, store } = createApp()

if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

Предварительная выборка данных клиента

На стороне клиента предварительная выборка данных обрабатывается двумя разными способами:在路由导航之前解析数据а также匹配要渲染的视图后,再获取数据, мы используем первую схему в нашей демонстрации:

// entry-client.js
import { createApp } from './app';
const { app, router, store } = createApp();

if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__);
}

router.onReady(() => {
  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();
    }

    Promise.all(
      activated.map(component => {
        if (component.asyncData) {
          component.asyncData({
            store,
            route: to
          });
        }
      })
    )
      .then(() => {
        next();
      })
      .catch(next);
  });
  app.$mount('#app');
});

Путем проверки соответствия компонентов и выполнения в глобальной функции маршрутизацииasyncDataФункция получает данные интерфейса.

В связи с этимdemoЭто две страницы, что нужноrouter.jsДобавьте информацию о маршрутизации, добавьте компонент маршрутизацииItem.vue, пока базовыйVUE SSRпример.

оптимизация кеша

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

const microCache = LRU({
  max: 100,
  maxAge: 1000 // 重要提示:条目在 1 秒后过期。
})

const isCacheable = req => {
  // 实现逻辑为,检查请求是否是用户特定(user-specific)。
  // 只有非用户特定(non-user-specific)页面才会缓存
}

server.get('*', (req, res) => {
  const cacheable = isCacheable(req)
  if (cacheable) {
    const hit = microCache.get(req.url)
    if (hit) {
      return res.end(hit)
    }
  }

  renderer.renderToString((err, html) => {
    res.end(html)
    if (cacheable) {
      microCache.set(req.url, html)
    }
  })
})

В основном, поnginxи кеш, который может в значительной степени решить проблему узких мест производительности.