javascript

Firebase + Angular Universal = невозможное возможно

  • пятница, 27 октября 2017 г. в 03:12:50
https://habrahabr.ru/post/341044/
  • Разработка под e-commerce
  • Разработка веб-сайтов
  • Node.JS
  • JavaScript
  • AngularJS


image
Firebase отличный инструмент для быстрой разработки приложений. Однако при использовании Firebase и Angular Universal могут возникнуть следущие вопросы:


  • Какой пакет firebase использовать в браузере пользователя и какой использовать на сервере?
  • Какой механизм использовать для асинхронных операций?
  • Как передать данные с сервера браузеру, избегая дублирования запросов?

Когда мы приступили к разработке нашего Angular Commerce, основным требованием была SEO оптимизация приложения для поисковых систем. Для этого поисковый робот должен суметь распознать контент на посещенных страницах. Сервер приложения возвращает внешние данные (XMLHttpRequest, fetch) по запросу пользователя, встраивая их результаты непосредственно в HTML. Но когда мы имеем дело с сложными SPA мы делаем розличные асинхронные запросы. Поэтому, когда вы откроете исходный код страницы SPA, вы увидите нечто близкое к:


<!DOCTYPE html>
<html lang="en">
    <head>
        <title>Home Page</title>
        <base href="/">
    </head>
    <body>
        <app-root></app-root>
        <script type="text/javascript" src="inline.8f218e778038a291ea60.bundle.js"></script>
        <script type="text/javascript" src="polyfills.bfe9b544d7ff1fc5d078.bundle.js"></script>
        <script type="text/javascript" src="main.5092b018fbcb0e59195e.bundle.js"></script>
    </body>
</html>

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


Мы начали искать решение для серверного рендеринга SPA и наткнулись на Angular Universal. Это универсальная(изоморфная) поддержка JavaScript для Angular. Другими словами наше Angular приложение рендерится на сервере и браузер в ответ на запрос получает SEO дружественные страницы. Вы можете добавлять метатеги на свою страницу, получать асинхронные данные из базы данных или api и вся эта информация будет представлена в отрендереной странице.


Однако у нас возникла неожиданная проблема, на которой мы застряли. Angular Universal не дружит с WebSockets. Так как мы используем Firebase как бэкенд для нашего проекта Angular Commerce, для нас это было крайне неприятным фактом. Поэтому мы хотим рассказать о некоторых подводных камнях, которые мы встретили в процессе разработки и о том, как мы их решили.


Использование клиентского и серверного Firebase


Пакет "client Firebase" открывает WebSocket для авторизации ('firebase.auth()') который поддерживает постоянное соединение, поэтому Universal не знает, когда завершить рендеринг на сервере. Таким образом процесс рендеринга не завершался при запросе некоторых страниц.


Мы решили это, используя пакет client Firebase в клиентском модуле и node Firebase в серверном модуле. Таким образом провайдеры в app.module.ts выглядят так:


import * as firebaseClient from 'firebase';

@NgModule(
  // declarations, imports and others...
  providers: [
    {
      provide: 'Firebase', useFactory: firebaseFactory
    }
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

export function firebaseFactory() {
  const config = {
    apiKey: 'API_KEY',
    authDomain: 'authDomain',
    databaseURL: 'databaseURL',
    projectId: 'projectId',
    storageBucket: 'storageBucket',
    messagingSenderId: 'messagingSenderId'
  };

  firebaseClient.initializeApp(config);

  return firebaseClient;
}

И в app.server.module.ts:


import * as firebaseServer from 'firebase-admin';

@NgModule(
  // declarations, imports and others...
  providers: [
    {
      provide: 'Firebase', useFactory: firebaseFactory
    }
  ],
  bootstrap: [AppComponent]
})
export class AppServerModule { }

export function firebaseFactory() {
  return firebaseServer;
}

Инициализация firebaseServer представлена в server.ts. Так же вы должны предоставить ключ учетных данных из Firebase Console (серверная и клиентская версии библиотеки используют разные механизмы инициализации):


import * as firebaseServer from 'firebase-admin';

firebaseServer.initializeApp({
  credential: firebase.credential.cert('./src/app/key.json'),
  databaseURL: 'https://pond-store.firebaseio.com'
});

Теперь на сервере мы используем пакет node Firebase только вместе с Firebase Realtime Database, а client Angular использует пакет client Firebase со всем необходимым функционалом.


Использование Observables вместо Promises


Angular Universal не очень хорошо работает с promises. Но методы запросов библиотеки Firebase Realtime Database возвращают promises. По этому мы обернули эти вызовы методов в RxJs Observables. Ниже приведен пример простого запроса к базе Firebase Realtime Database:


const ref = this.firebase.database().ref('products');
Observable
    .fromPromise(ref.once('value'))
    .map(data => data.val())
    .subscribe( products => {
        this.products = products;
        ref.off(); // closing listener of reference
    });

Переход к observables позволил Angular Universal правильно определять момент заверешения запроса.


Передача данных сервера браузеру


Как работает Angular Universal? Когда пользователь делает запрос, сервер запускает метод renderModuleFactory, который рендерит бандл приложения собранный для сервера и сразу же возвращает html с отрендеренными данными на странице. Затем браузер начинает рендерить браузерую сборку Angular. Когда рендеринг завершен, Universal заменит отрендеренный на сервере код новым, отрендеренным в браузере. Интересно, что серверная и браузерная сборки выполняют практически одиннаковую работу. Это заметно по асинхронным запросам к базе данных, когда данные видны, затем изчезают(потому что Universal удаляет код отрендеренный на сервере), а потом снова появляются по заверешении нового запроса в браузере.


В Angular 5 в модуле @angular/platform-server есть TransferStateModule. Этот модуль помогает вам передать состояние с сервера в браузер, устранив необходимоть повторного запроса данных в браузере.


Так как мы работаем с Angular 4, мы нашли решение как передать данные с сервера браузеру без Transfer State.


У renderModuleFactory method есть необязательный аргумент options:


export declare function renderModuleFactory<T>(moduleFactory: NgModuleFactory<T>, options: {
    document?: string;
    url?: string;
    extraProviders?: Provider[];
}): Promise<string>;

Через extraProviders вы можете передавать провайдеры, которые будут добавлены в серверный модуль и будут присутствовать в серверном приложении. Таким образом мы создали объект serverStore, который будет хранить данные, которые мы хотим передать клиентскому приложению.


Callback app.engine в server.ts будет выглядить следующим образом:


app.engine('html', (_, options, callback) => {
  let serverStore = {};
  const opts = {
    document: template,
    url: options.req.url,
    extraProviders: [
      {
        provide: 'serverStore',
        useValue: {
        set: (data) => {
          serverStore = data;
        }
      }
    }
    ]
  };

  renderModuleFactory(AppServerModuleNgFactory, opts)
      .then( (html: string) => {
        const position = html.indexOf('</app-root>');
        html = [
          html.slice(0, position),
          `<script type="text/javascript">window.store = ${JSON.stringify(serverStore['store'])}</script>`,
          html.slice(position)]
        .join('');
        callback(null, html);
      });
});

Наконец, мы устанавливаем наш serverStore в Window.store, прежде чем импортировать другие скрипты. Получение данных в браузере происходит в AppModule:


@NgModule({
  // declarations, imports and others...
  providers: [
    {
      provide: 'store', useValue: window['store']
    }
  ]
})
export class AppModule {}

Получение серверных данных в браузере


Как насчет использования предворительно отрендеренных данных в компонентах? При рендеренге на сервере данные получают и устанавливают их в хранилище. В браузерном преложении мы должны проверить, есть ли необходимая информация в хранилище и получить ее. Ниже представлен простой пример, как получить продукты из Firebase Realtime Database в методе ngOnInit:


@Component({
  selector: 'app-home',
  templateUrl: './home.component.html',
  styleUrls: ['./home.component.scss']
})
export class HomeComponent implements OnInit {

  public products: any;

  constructor(
    @Inject(PLATFORM_ID) private platformId,
    @Inject('Firebase') private firebase,
    @Optional() @Inject('serverStore'
    ) private serverStore,
    @Inject('store') private store
  ) { }

  ngOnInit() {
    if (isPlatformServer(this.platformId)) {
      const ref = this.firebase.database().ref('products');
      const obs = Observable.fromPromise(ref.once('value'))
        .subscribe( data => {
          data = data['val']();
          if (data) {
            const products = Object.keys(data).map(key => data[key]);
            this.products = products;
            this.serverStore.set(products);
          }
          ref.off();
          obs.unsubscribe();
      });
    } else {
      this.products = this.store;
    }
  }

}

Обратите внимание, что провайдер serverStore существует только в server build, поэтому он декларируется с помощью декоратора @Optional для предотвращения ошибки в браузере:


No provider for serverStore!

Таким образом использование Angular Universal вместе с Firebase довольно не очевидно, но есть способы, как с этим жить.