尝试接触Angular2的服务器端渲染

首先

在这篇文章中,我想要谈论Angular2和服务器端渲染(以下简称SSR)。

为了实现在Angular2中的SSR,有一组由angular-universal开发的模块。我们将提供关于如何在代码级别使用这些模块的方法,但是API和可用选项的细节可能会随时变化。目前,我认为了解Angular2开发团队的目标和支持该目标的架构对于灵活应对未来的变化很重要。在本篇文章中,我将努力以此为基础进行编写。

本文在前半介绍了关于SSR机制的Angular Universal设计文档,并在后半讲述了如何与Express集成以运行Angular Universal。如果你希望尽快开始实际操作Angular Universal,请阅读针对希望立即了解Angular Universal的人们准备的部分。

目标读者 (mù dú zhě)

在这个条目中,我们的目标读者是以下的人。

    • Angular2をTypeScriptで触ったことがある人

 

    • Angular2にはDIの機構があることを知っている人

 

    Angular2のRouterを触ったことがある人

如果以Qiita为例,只需阅读以下的帖子就足够了:

    • npm i angular2してHello World!するところまで【改】

 

    • Angular2のDIを知る

 

    Angular2のRouterを触ってみる

为什么选择SSR?

首先,“为什么需要SSR?”这个问题,我们要从根本上思考。

在Design Doc的Use Cases中,列出了Angular2开发团队设想的SSR使用案例。

如果要简要总结的话,可以如下所示(括号内为设计文档的标题):

性能的角度(感知加载时间,实际加载时间,客户端性能)

为了防止用户直接离开,初始视图应尽可能快地显示。
由于初始渲染不需要大部分打包在客户端Web应用中的功能,所以在初始视图渲染完成后可以缓慢加载。
在大多数情况下,用户在初始页面视图之后触发的事件可以通过API调用+ DOM部分重写来处理,但是例如在可视化处理中从大量数据中绘制简单图形等情况下,通过SSR来完成部分渲染也是一个选择。

爬虫的角度(SEO,预览链接)
即使是Google的爬虫,也无法完全捕捉到在客户端上更改后的DOM。通过SSR,爬虫可以获取完整的HTML。同样的原因,使用SSR可以在SNS等场景中预览带有应用程序的链接时,也可以获得完整的HTML。

浏览器支持
如果使用服务器端渲染(SSR)生成的HTML,用户可以在不受浏览器JavaScript执行引擎限制的情况下查看。此外,与在客户端渲染的内容中一样,一般的屏幕阅读器善于处理在服务器端渲染的内容,这可以提高可访问性。

根据上述的用例,设计文档规定了Angular2 SSR的以下三个要求。这些也是解释angular-universal功能时非常重要的内容,因此我将它们摘要如下:

“只要运行”
Angular2应用在服务器端的JavaScript环境中能够正常运行。不一定需要始终以完美和最优化的视图呈现,但开发者可以获得SSR的基准线,并且只要开发者不明确引用DOM和window对象,应用在服务器端和客户端一样都可以进行渲染。

无缝状态传递
从SSR渲染的初始页面视图无缝地启动Web应用。启动过程中的任何用户交互都不能阻碍用户屏幕的自由操作。例如,如果用户在搜索框中输入了内容,这些输入的文字和焦点状态应该在Web应用启动后继续保留。

性能
正如我们在用例中提到的,许多开发者寻求SSR的理由之一是性能改进。Angular2需要具备明确的性能目标。下面是测试的条件:

– 最新款Macbook Pro上的桌面浏览器
– 延迟时间<10ms – 服务器相当于一个AWS t2实例 – 单个请求 – 下载速度>11 Mbps的互联网连接

Angular2的SSR机制需要达到以下目标:

– 服务器渲染时间<100ms
– 感知到的加载时间<1秒
– 实际加载时间<3秒

其他目标

我們到目前為止主要關注設計文檔的內容,但既然這樣,我們也想考慮一下設計文檔中未提及的內容。

HTML邮件

在我的日常工作中,我使用AngularJS 1.x来开发单页应用程序。

在运营的服务中,存在将以SPA形式提供的页面内容通过HTML电子邮件通知给终端用户的功能。这是一个常见的功能。

然而,由于Angular1完全依赖于angular.element(jqLite),如果没有浏览器,它将无法运行。而且,内置服务如$window和$http也需要在浏览器中才能正常工作,因此Angular1不是全能的。
因此,尽管我们努力创建的指令仅限于在浏览器中使用,对于HTML邮件,我们需要完全不同的实现。尽管最终生成的HTML结构大部分是相同的。

如果能够通过Angular实现SSR,那么在邮件和SPA的Web应用中,应该也能够重复使用组件和服务。

API文档

另外一个可以考虑的应用方式是,将SSR机制用作静态HTML生成器的模式。

在AngularJS 1.x中,为了创建我们自己编写的AngularJS Directive、Service和Provider的ngDoc API文档,通常会使用一个叫做dgeni的生成器(很遗憾并不是非常流行的库,所以很多人可能即使知道AngularJS 1.x也不熟悉dgeni)。

dgeni可以运行的模板引擎是Nunjucks,它是与AngularJS完全无关的库。这是AngularJS的文档, 但是却介绍了一个不相干的库。
虽然没有要求用Angular2来构建CMS,但是您觉得在Angular2中能够创建像文档生成器这样小工具是很有魅力的吗?

Angular Universal 的模块

我想要重点谈谈这里的主角-angular-universal提供的功能。

在https://github.com/angular/universal/tree/master/modules的目录下,正在开发各种模块,截至2016年03月,下面的模块已包括在内(括号内为相应的npm模块名称):

    • universal(angular2-universal):

 

    • SSR機構のコア。先述した要件 1.の大半が含まれます。現時点ではドキュメントの類がほぼ皆無なため、使い方を詳しく追いたい時はこのモジュールのソースコードを読むのが一番理解の助けになりました。

 

    • express-engine, hapi-engine, grunt-prerender, gulp-prerender, webpack-prerender:

 

    • 上記のコア機能をNode.jsの様々なツール上で動かすためのアダプタ。モジュール名を見ると何となく想像が付きますね。今回のエントリでは一番使いそうなexpress-engineについてのみ書きます。

 

    • preboot:

 

    • 先述した要件 2.の為の機能。詳細は後述します。

 

    • pollyfills(angular2-universal-pollyfills):

 

    ぽりふぃる。読み込ませるだけの代物なので、こいつについては特に語りません。

如何实现通用渲染

AngularJS 1.x的SSR实现困难的原因是,UI的渲染相关功能和jQuery(jqLite)紧密耦合,导致在没有DOM(渲染引擎)的环境下难以运行。
相反,Angular2在包括UI渲染相关功能在内的所有功能中都采用了适配器模式,因此可以实现与操作环境无关的UI渲染。

应用程序本身并不直接涉及,但是客户端和服务器端都可以适当地更换适配器。以下是大致的堆栈图像:

universal_stack01.png
    • Renderer: UI描画において最も抽象度の高いclass. UI コンポーネントに対する操作を行う(createElementやeventハンドラ等)。その気になればCanvasだって扱える代物です。

DomRenderer: 要素実体としてDOMを採用したRenderer実装。DOMへのアクセスはDomAdapterクラスを経由するように実装されています。

NodeDomRenderer: DomRendererを継承して少しカスタマイズされたNode.js向けRenderer実装です。

DomAdapter: W3Cの定義とは異なるものの、DOM APIをまとめたAbstract Class。

BrowserDomAdapter: ブラウザ向けDomAdapter実装。document 等、ブラウザ固有の機能に直接触ることを許された存在です。

Parse5DomAdapter: Server Side向けDomAdapter実装。その名の通り、parse5を利用してDOM treeの構築を行います。

此外,DomAdapter在DI的管理之外,并通过变量DOM被DomRenderer访问。因此,有人需要通过设置DomAdapter实体来设置变量DOM,但在客户端,这个过程是通过bootstrap函数进行的。然而,在服务器端,Angular Universal提供的Bootloader类承担了类似的角色。如果将Bootloader包含在先前的图示中,可能会是以下的样子:

universal_stack02.png

为了想要立即开始使用Angular Universal的人们

我建议你克隆名为angular/universal-starter的存储库。这个存储库是由PatrickJS,一位angular-universal的贡献者,自己创建的。因此,它能够有效地跟上angular2和angular-universal的频繁版本更新。

有时候,仅通过git clone和npm install进行的操作并不会使得提交(commit)生效,这可能会出现在主分支(master),我自己也遇到过两次。

为了这个原因,我创建了一个停止在fork状态下运行的版本。

    • angular2(@angular/core): 2.0.0-rc.1

 

    • zone.js: 0.6.12

 

    angular2-universal: 0.100.3

在接下来的说明中,假设您已经克隆了fork版本的代码库,我们将进行代码的解释。

暂时让它运动

克隆代码,运行 npm install,然后运行 npm start,Webpack将打包服务器和客户端,并启动Express。访问 http://localhost:3000,应该可以看到渲染结果。

以下是应用的主要组件的代码,但是App类中的name属性被插值到了标签中。

/* 一部抜粋 */
@Component({
  selector: 'app',
  directives: [
    ...ROUTER_DIRECTIVES,
    XLarge
  ],
  styles: [`
    .router-link-active {
      background-color: lightgray;
    }
  `],
  template: `
  <div>
    <nav>
      <a [routerLink]=" ['./Home'] ">Home</a>
      <a [routerLink]=" ['./About'] ">About</a>
    </nav>
    <div>
      <span x-large>Hello, {{ name }}!</span>
    </div>

    name: <input type="text" [value]="name" (input)="name = $event.target.value" autofocus>
    <main>
      <router-outlet></router-outlet>
    </main>
  </div>
  `
})
@RouteConfig([
  { path: '/', component: Home, name: 'Home', useAsDefault: true },
  { path: '/home', component: Home, name: 'Home' },
  { path: '/about', component: About, name: 'About' },
  { path: '/**', redirectTo: ['Home'] }
])
export class App {
  name: string = 'Angular 2';
}

在这个应用中,上述组件在服务器端进行渲染后,以客户端单页应用程序的形式启动。可以称之为同构应用程序。

点击”About”链接,您可以确认页面在Angular2的路由器上进行了转换。

好的,为了确认是否真正通过SSR进行了渲染,我将在Chrome开发者工具的网络面板中进行确认。

localhost_3000_と_universal-starter_app_component_ts_at_master_·_angular_universal-starter.png

上面的图表并不是Elements面板。它是从服务器返回的响应中捕获的。从一开始就可以看出name属性已经被渲染。

在Express引擎中的SSR基础知识.

好吧,让我们继续追踪一下SSR的机制。

这段代码是使用Express接收HTTP请求,并与Angular Universal进行协作的部分。

/* 一部抜粋 */

// Express View
app.engine('.html', expressEngine);
app.set('views', __dirname);
app.set('view engine', 'html');

function ngApp(req: express.Request, res) {
  let baseUrl = '/';
  let url = req.originalUrl || '/';
  res.render('index', {
    directives: [ App, HtmlHead, ServerOnlyApp],
    platformProviders: [
      provide(ORIGIN_URL, {useValue: 'http://localhost:3000'}),
      provide(BASE_URL, {useValue: baseUrl}),
    ],
    providers: [
      provide(REQUEST_URL, {useValue: url}),
      NODE_ROUTER_PROVIDERS,
    ],
  });
}

// Routes with html5pushstate
app.use('/', ngApp);
app.use('/about', ngApp);
app.use('/home', ngApp);

只需要一个选项,将以下内容以中文进行改述:
将Angular Universal提供的expressEngine传递给Express的Rendering Engine,并进行Express的Routing设置。在Express中,res.render(…)的第一个参数是模板文件的内容如下所示。

<!doctype html>
<html lang="en">
  <head>
    <html-head></html-head>
  </head>
  <body>
    <app>
      Loading...
    </app>

    <server-only-app>
      Loading...
    </server-only-app>
    <script async src="/dist/client/bundle.js"></script>
  </body>
</html>

在这里,我们将渲染三个选择器(即对应Angular2组件的选择器):html-head、app和server-only-app。

res.render(…) 的第二个参数是 Angular2 应用的启动代码。
这个方法将其操作委托给 angular-universal-preview/Bootloader 类,但从接受的参数来看,它与客户端端的启动代码(bootstrap from ‘angular2/platform/browser)并没有太大的区别。

directives: RenderingすべきComponent

platformProviders, providers: DIがSSRのコンテキストで利用するprovider達

需要注意的是providers的内容吗?由于应用程序使用了Router,所以需要提供Router专用的Provider。NODE_ROUTER_PROVIDERS是由angular-universal提供的用于Router的Provider。

在客户端运行Router时,将使用浏览器的location和History API进行路由,但在Node.js环境中无法使用这些API。angular-universal会为我们处理这种差异。具体来说,它提供了在Node.js环境中运行的Router。然而,开发人员仍需要设置一些特定于universal的信息,如BASE_URL和REQUEST_URL。

然而,在platformProviders和providers这两个名称相似的键中,有两种不同类型出现了。类似于BASE_URL这样不依赖于请求的信息应该被记录在platformProviders中,而其他的信息则应该被记录在providers中。

保持您的应用程序的普适性

如前所述,Angular 2对Renderer进行了抽象化,浏览器环境和Node.js环境使用不同的Renderer实例(DomAdapter)进行操作。

基本上,開發人員不需要意識到各自的渲染器實體,但如果要開發通用應用程式,則應通過渲染器的API進行元素操作。
尤其是自己創建指令時,可以從ElementRef訪問DOM元素,但如果想要保持應用程式的通用性,最好將元素的操作委託給渲染器。

@Directive({
  selector: '[x-large]'
})
export class XLarge {
  constructor(element: ElementRef, renderer: Renderer) {
    renderer.setElementStyle(element.nativeElement, 'fontSize', 'x-large');
  }
}

如果按照下面所写的话,并不知道会发生什么。

@Directive({
  selector: '[x-large]'
})
export class XLarge {
  constructor(element: ElementRef) {
    element.nativeElement.styles.fontSize = 'x-large';
  }
}

按照设计文档的描述,Angular Universal的功能只有在开发者没有明确参考DOM或窗口对象的情况下才能得到保证。

当然,我们不仅不能直接使用document、location和navigator等对象,还需要确保第三方Angular2服务和指令也能在Universal中正确运行,这一点需要特别注意。

当我们自己开发服务和指令时,我们可以通过将与执行环境相关的部分拆分为单独的提供者,并注入适当的实现来实现依赖注入。然而,如果在第三方库中直接引用浏览器API,除了提交PR并修复错误外,我们没有其他办法来控制这些库的行为。

在SSR中进行API调用

在开发Isomorphic的Web应用程序时,无法回避的话题是“如何集成API调用部分”。

大多数的SPA应用都是通过Ajax与后端准备的API进行通信,并将结果渲染到组件中。而API调用的时间大多是在组件初始化时。
例如,如果是一个用户账户管理应用程序,必须从登录后立即开始渲染用户的头像和诸如“欢迎Quramy先生”的文字。

我们需要的功能是怎样的呢?在同构的Web应用程序中,调用API的要求可以归纳如下:

    1. 在应用程序中,希望尽可能使用通用的JavaScript代码来编写API调用部分。

 

    1. 在需要通过SSR调用API的情况下,渲染器需要等待API的信息获取结果。

 

    在SSR中已经通过GET方法获取并注入到组件中的信息,希望在客户端应用启动时也被视为已经获取完成。

在Angular Universal中的HTTP_PROVIDERS

Angular2提供了Http Service作为其标准的Ajax功能。如果在浏览器环境中,只需从angular2/http模块中导入HTTP_PROVIDERS并将其设置为Provider,即可使用。

一方,Angular Universal中还包含着一个名为NODE_HTTP_PROVIDERS的提供程序。

import { NODE_HTTP_PROVIDERS } from 'angular2-universal';

/** 中略 **/

function ngApp(req: express.Request, res) {
  let baseUrl = '/';
  let url = req.originalUrl || '/';
  res.render('index', {
    directives: [ App, HtmlHead, ServerOnlyApp],
    platformProviders: [
      provide(ORIGIN_URL, {useValue: 'http://localhost:3000'}),
      provide(BASE_URL, {useValue: baseUrl}),
    ],
    providers: [
      provide(REQUEST_URL, {useValue: url}),
      NODE_ROUTER_PROVIDERS,
      NODE_HTTP_PROVIDERS,
    ],
    async: true,
    preboot: false,
  });
}

/** 中略 **/

// ExpressのAPIエンドポイントを作成
let router = express.Router();
router.get('/message', (req, res) => {
    res.send('XHR message!');
});
app.use('/api/v1', router);

以下是重点的三点:

1.
2.
3.

NODE_HTTP_PROVIDERS をproviderとして設定することで、Node.js環境でも Http serviceが利用可能になる
クライアントから見たexpressのURLをORIGIN_URLにDIしておく
renderのオプションでasyncを有効にすることで、非同期な呼び出しを含むSSRが可能

如果在这种状态下,使用应用程序端代码中的初始化系的LifeCycle Hook,那么在执行异步API之后,会渲染出组件。

@Component({
  selector: 'about',
  template: `
  About
  <span>{{xhrMessage}}</span>
  `
})
export class About implements OnActivate, OnInit {
    private xhrMessage: string;
    constructor(private http: Http) {
    }

    ngOnInit() {
      return this.http.get('/api/v1/message').subscribe(res => {
        this.xhrMessage = res.text();
      });
    }

    // こちらも可
    routerOnActivate() {
      return this.http.get('/api/v1/message').subscribe(res => {
        this.xhrMessage = res.text();
      });
    }
}

ngOnInit和routerOnActivate分别是Component和Router的生命周期钩子方法。虽然用途有些不同,但大致都可以用作”初始化后处理”的地方。

刚刚我用了一个含糊不清的措辞,说到了”包含异步调用”,但是通过查看 Bootloader class 的实现,我发现它在 NgZone 的 onStable 事件中引发了一个触发器。在事件处理程序中,它会计算未完成的 Http 调用数量,并且只有当计数为0时才会执行渲染操作。

注意(2016.03.30)
angular-universal-preview 0.82.xでは「Node.js側で取得したcacheをブラウザで使いまわす」という機能は未実装です。
https://github.com/angular/universal/issues/338, https://github.com/angular/universal/issues/271 あたりが実装されれば実現できそう。

预启动

也许你已经厌倦了”preload”、”precache”或者其他以”pre”开头的称呼。最后,我会简要提到”preboot”的事情。

preboot模块是为了实现设计文档中所描述的通过SSR渲染的初始页面视图无缝启动Web应用程序的目标而创建的功能。它是在angular-universal存储库中开发的,但preboot模块不依赖于Angular2的任何功能,因此可以在使用其他框架的SSR(例如React等)中使用。

(Alternative: preboot模块的作用是达到设计文档中描述的从SSR渲染的初始页面视图无缝启动Web应用程序的目标。它是在angular-universal存储库中开发的,但preboot模块与Angular2的任何功能都无关,可以在其他框架的SSR(例如React等)中使用。)

典型的的preboot用例如下所示:

    1. 通过Web服务器进行HTTP访问。

 

    1. 在服务器端生成和返回初始页面的HTML。

 

    1. 在浏览器中渲染初始页面(此时仅为静态HTML)。

 

    1. 开始捕捉复选框操作等用户事件(预启动)。

 

    1. 启动客户端应用程序(引导)。

 

    1. 在第4~5步之间回放捕捉到的用户事件。

 

    处理应用程序启动前的事件。

主要任务是preboot模块中的加粗部分。可以类比为BrowserSync。BrowserSync用于在网络上同步不同浏览器的用户事件,而preboot用于在同一浏览器中跨越时间同步用户事件。

我认为实际体验一下会更容易理解。为了模拟HTML绘制完成后,等待一定时间(数百毫秒至数秒),然后再在客户端上启动Angular2,我们可以使用setTimeout来延迟启动。

setTimeout(() => {
    bootstrap(App, [
        ...ROUTER_PROVIDERS
    ]).then(() => {
        prebootComplete();
    });
}, 2000);

我们会稍微修改Express的渲染部分:

function ngApp(req: express.Request, res) {
  let baseUrl = '/';
  let url = req.originalUrl || '/';
  res.render('index', {
    directives: [ App, HtmlHead, ServerOnlyApp],
    platformProviders: [
      provide(ORIGIN_URL, {useValue: 'http://localhost:3000'}),
      provide(BASE_URL, {useValue: baseUrl}),
    ],
    providers: [
      provide(REQUEST_URL, {useValue: url}),
      NODE_ROUTER_PROVIDERS,
      NODE_HTTP_PROVIDERS,
    ],
    async: true,
    // preboot: false,
    preboot: {
      appRoot: 'app',
      uglify: false,
      debug: true
    }
  });
}

在Express-Engine的情况下,可以将preboot的设置描述为render方法的参数。

请先在preboot: false的状态下访问localhost:3000。
这个示例中包含一个元素(App.prototype.name)。通过SSR,应该以绑定了初始值的状态进行渲染。请在Angular2在Client Side启动之前随便更改这个值。
在Angular2在Client Side启动后,这个值应该已经返回到Angular 2了。

请以预启动启用的状态访问,像上面的代码示例一样,然后进行相同的操作。与之前不同的是,Angular2启动后输入的值应该仍然存在,并且应该进行”Hello, Angular 2!”的内插到span元素。

由於本次啟動 preboot 以調試模式,所以在瀏覽器中打開開發者工具,應該會在控制台日誌中輸出 preboot 捕獲的事件(可能是焦點或鍵盤按鍵事件)。

这次我们使用preboot进行了回放示例,并且还考虑了类似的使用方法,例如在事件发生时显示spinner,并在Angular2的客户端启动完成后解除spinner(冻结策略)。

总结

如果我继续写下去的话会变得相当长。总结我在这篇文章中写的内容如下:

    • Angular2は実行環境依存箇所をアダプタとして切り出すことで、Universalに動作するアプリケーションのフレームワークとなっている

Angular UniversalはNode.js向けのRendering Engineと、各種Node.jsのツールからRendering Engineを呼びだすモジュールを提供している
nativeElementを直接操作しない。操作が必要であればRendererのAPIを用いる
RoutingやAjax機能は、対応するProviderをNode.js用に差し替える事で、各Serviceに依存したアプリケーションがUniversalに動作するようにする

Angular2では「SSR描画結果からSPAの起動完了をシームレスに統合すること」が考慮されている

このためにprebootが開発されている。prebootを利用すると、イベントのキャプチャ&プレイバックが実現できる

最后。若有困難,请阅读代码。谈话将在那之后进行。

关于包含在模块名称中的”Universal”一词,建议您阅读有关”Universal JavaScript”的内容,以了解它与”Isomorphic”这个词的关系。