Firebase + Angular Universal = невозможное возможно
- пятница, 27 октября 2017 г. в 03:12:50
Firebase
отличный инструмент для быстрой разработки приложений. Однако при использовании Firebase
и Angular Universal
могут возникнуть следущие вопросы:
Когда мы приступили к разработке нашего 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, для нас это было крайне неприятным фактом. Поэтому мы хотим рассказать о некоторых подводных камнях, которые мы встретили в процессе разработки и о том, как мы их решили.
Пакет "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
со всем необходимым функционалом.
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
довольно не очевидно, но есть способы, как с этим жить.