AngularJS 1.x平滑升级Angular实战(翻译)

本文翻译自Directly Upgrading From AngularJS 1.X To Angular Without Preparing The Existing Code Base

正文:

当我们从AngularJS 1.x升级到Angular(2/4/5)时,我们通常会先准备AngularJS 1.x的代码:

angularjs-upgrade-1

这个过程会引入像组件这类新的AngularJS 1.x技术。并且,引入TypeScript和像SystemJS或者Webpack之类的模块加载器是准备已有代码的进一步工作。这样做的目的是为了让代码更接近Angular便于更好的集成。

但是,在一些情况下,准备已有的代码成本很大。例如,试想一下这样的情形,当你不想修修改已有的AngularJS1.x的代码,并且想要写一些Angular的应用。当这样的情况在你的项目中发生,跳过准备阶段是一个好的主意。

angularjs-upgrade-2

这篇文章一步步展示如何完成这个过程。像官方的升级教程一样,包含准备代码的工作,这里也是升级流行的AngularJS 1.x 手机分类实例

即使这个实例覆盖了AngularJS 1.5中引入的组件,这里展示的对使用控制器(controller)和指令(directive)的代码也适用。

整个实例代码可以在Github 仓库中找到。为了接下来每一步更容易,我针对每一步做了一个代码提交。

第一步:创建新的Angular应用

一开始,本文假设我们使用Angular CLI来搭建一个新的Angular应用:

1
ng new migrated

为了让这个新的方案结构清晰,在src目录下创建了一个文件夹给已有的AngularJS代码,另一个文件夹给新的Angular代码。
在下面的实例中,我使用了ng1和ng2来命名:

angularjs-upgrade-3

创建完之后,移动除了tsconfig.app.json, tsconfig.spec.json, favicon.icoindex.html之外的文件到ng2文件夹中。

通过.angular-cli.json文件来通知CLI的编译任务有关修改的新代码结构。在这个文件中使用assets字段,我们也可以告诉CLI直接拷贝ng1文件夹到输出的目录中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"project": {
"name": "migrated"
},
"apps": [
{
"root": "src",
"outDir": "dist",
"assets": [
"ng1",
"assets",
"favicon.ico"
],
"index": "index.html",
"main": "ng2/main.ts",
"polyfills": "ng2/polyfills.ts",
"test": "ng2/test.ts",
"tsconfig": "tsconfig.app.json",
"testTsconfig": "tsconfig.spec.json",
"prefix": "app",
"styles": [
"ng2/styles.css"
],
"scripts": [],
"environmentSource": "ng2/environments/environment.ts",
"environments": {
"dev": "ng2/environments/environment.ts",
"prod": "ng2/environments/environment.prod.ts"
}
}
],
"e2e": {
"protractor": {
"config": "./protractor.conf.js"
}
},
"lint": [
{
"project": "tsconfig.app.json"
},
{
"project": "tsconfig.spec.json"
},
{
"project": "tsconfig.e2e.json"
}
],
"test": {
"karma": {
"config": "./karma.conf.js"
}
},
"defaults": {
"styleExt": "css",
"component": {}
}
}

现在拷贝了整个AngularJS 1.x应用到ng1文件夹中,但是忽略index.html。为了旧的应用可以在修改过的文件结构下工作,我们要做一些调整。这包括修改模板文件的引用还有JSON文件和图片文件。

之后,我们可以合并旧的index.html到文件夹src下新的文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Migrated</title>
<base href="/">

<!-- ng1 -->
<link rel="stylesheet" href="ng1/bower_components/bootstrap/dist/css/bootstrap.css" />
<link rel="stylesheet" href="ng1/app.css" />
<link rel="stylesheet" href="ng1/app.animations.css" />

<script src="ng1/bower_components/jquery/dist/jquery.js"></script>
<script src="ng1/bower_components/angular/angular.js"></script>
<script src="ng1/bower_components/angular-animate/angular-animate.js"></script>
<script src="ng1/bower_components/angular-resource/angular-resource.js"></script>
<script src="ng1/bower_components/angular-route/angular-route.js"></script>
<script src="ng1/app.module.js"></script>
<script src="ng1/app.config.js"></script>
<script src="ng1/app.animations.js"></script>
<script src="ng1/core/core.module.js"></script>
<script src="ng1/core/checkmark/checkmark.filter.js"></script>
<script src="ng1/core/phone/phone.module.js"></script>
<script src="ng1/core/phone/phone.service.js"></script>
<script src="ng1/phone-list/phone-list.module.js"></script>
<script src="ng1/phone-list/phone-list.component.js"></script>
<script src="ng1/phone-detail/phone-detail.module.js"></script>
<script src="ng1/phone-detail/phone-detail.component.js"></script>
<!-- /ng1 -->

<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body ng-app="phonecatApp">


<!-- ng1 -->
<div class="view-container">
<div ng-view class="view-frame"></div>
</div>
<!-- /ng1 -->

<app-root></app-root>


</body>
</html>

注意这个合并后的index.html包含了AngularJS 1.x应用所需要的CSS文件和脚本。还通过ng-app启动AngularJS 1.x应用,并通过包含有ng-view指令的div提供出来的壳。这个是路由激活对应配置模板的地方。

在这个文件中,我们也可以找到Angular应用的根元素。针对Angular生成打包文件的引用是不需要的,因为他们由编译任务自动生成。

当这个应用启动(ng serve),它将会将两个应用独立的加载到浏览器中。可以通过访问http://localhost:4200来查看。

angularjs-upgrade-4

由于两个应用是独立启动的,因此他们无法互相通信和交换使用服务和组件。为了使这些工作,我们需要让他们作为混合应用启动。下一章节会介绍如何做到。

第二步:启动一个AngularJS+Angular的混合应用

为了同时启动AngularJS 1.x和Angular应用,我们可以利用Angular的ngUpgrade模块:

1
npm install @angular/upgrade --save

由于我们不想启动Angular(2/4/5等)应用,我们将indexl.html文件中的根组件移除:

1
2
3
4
<!-- remove root component -->
<!--
<app-root></app-root>
-->

现在,我们可以一起同时启动两个应用。为此,引入UpgradeModule模块到Angular应用的AppModule中。从bootstrap中移除AppComponent,从而手动启动混合应用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { UpgradeModule, downgradeComponent } from '@angular/upgrade/static';
import { AppComponent } from './app.component';
import { Ng2DemoComponent } from "ng2/app/ng2-demo.component";

@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
UpgradeModule
],
providers: [],
// bootstrap: [AppComponent] // No Bootstrap-Component
})
export class AppModule {
constructor(private upgrade: UpgradeModule) { }
ngDoBootstrap() {
this.upgrade.bootstrap(document.body, ['phonecatApp'], { strictDi: true });
}
}

就像你所看到的,这个例子通过使用注入的UpgradeModule模块在ngDoBootstrap中启动混合应用。为了阻止启动AngularJS 1.x应用两次,我们需要在index.html文件中移除ng-app指令。

当我们开始应用,我们可以看到AngularJS 1.x的组件:

angularjs-upgrade-5

尽管如此,这个一个包含两个版本Angular的混合应用。为了证明这一点,下一章节将会显示如何在展示的AngularJS组件中使用Angular组件。

第三步:降级一个Angular组件

为了展示如何在混合应用的AngularJS中使用Angular组件,教程中会使用一个非常简单的组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/app/ng2-demo.component.ts

import { Component, OnInit } from '@angular/core';

@Component({
selector: 'ng2-demo',
template: `
<h3>Angular 2 Demo Component</h3>
<img width="150" src="..." />
`
})
export class Ng2DemoComponent {
}

源代码中显示的图片可以在脚手架中AppComponent找到。

为了在AngularJS模板中使用这个组件,我们需要降级它。ngUpgrade提供了一个函数downgradeComponent来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { UpgradeModule, downgradeComponent } from '@angular/upgrade/static';
import { AppComponent } from './app.component';
import { Ng2DemoComponent } from "ng2/app/ng2-demo.component";

declare var angular: any;

angular.module('phonecatApp')
.directive(
'ng2Demo',
downgradeComponent({component: Ng2DemoComponent})
);

@NgModule({
declarations: [
AppComponent,
Ng2DemoComponent
],
imports: [
BrowserModule,
UpgradeModule
],
entryComponents: [
Ng2DemoComponent // Don't forget this!!!
],
providers: [],
// bootstrap: [AppComponent] // No Bootstrap-Component
})
export class AppModule {
constructor(private upgrade: UpgradeModule) { }
ngDoBootstrap() {
this.upgrade.bootstrap(document.body, ['phonecatApp'], { strictDi: true });
}
}

就如你在例子中看到的,这个降级的组件在AngularJS 1.x模块中注册为一个指令。为了做到,我们利用全局的变量angular。为了告诉TypeScript这个已存在的变量,我们需要使用declare关键字。

之后,我们可以在AngularJS 1.x模板中调用Angular组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!-- src/ng1/phone-list/phone-list.template.html -->

<div class="row">
<div class="col-md-2">
<!--Sidebar content-->

<p>
Search:
<input ng-model="$ctrl.query" />
</p>

<p>
Sort by:
<select ng-model="$ctrl.orderProp">
<option value="name">Alphabetical</option>
<option value="age">Newest</option>
</select>
</p>

<p>

<!-- Angular 2 Component -->
<ng2-demo></ng2-demo>

</p>

</div>

如通常的AnguarJS中,我们在HTML文件中需要使用kebab惯例,而在JavaScript部分中注册指令时使用正常的命名。后者使用驼峰命名法。

当我们重新加载应用,将会同时显示AngularJS 1.x 电话列表和我们的Angular样例组件:

angularjs-upgrade-6

你可能会好奇一个新的Angular组件怎么使用AngularJS 1.x服务提供的应用逻辑,阅读下一章节来获得答案。

第四步:升级一个服务

为了能在一个新的Angular组件中使用既存的AngularJS 1.x服务,我们需要升级它。根据官方的文档,我们必须要使用factory创建一个Angular服务provider。这个factory获取一个AngularJS 1.x注入器($injector)的引用,并使用它获取服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/ng2/app/phone.service.ts

import { InjectionToken } from "@angular/core";

export const PHONE_SERVICE = new InjectionToken<any>('PHONE_SERVICE');

export function createPhoneService(i) {
return i.get('Phone');
}

export const phoneServiceProvider = {
provide: PHONE_SERVICE,
useFactory: createPhoneService,
deps: ['$injector']
}

正常情况,在provide属性中我们可以使用服务的类型作为依赖注入符号。但是在这个例子中,我们决定不对既有的AngularJS 1.x代码升级到TypeScript,因此我们没有任何类型。因此,这个例子使用了一个基于常量的符号叫做PHONE_SERVICE。在Angular 4+中提供的类型为InjectionToken,在Angular 2中我们可以使用OpaqueToken代替。InjectionToken使用一个类型参数来判断它指向的服务类型。如提到的,我们没有这个服务的类型,因此我们仅使用any

这个讨论的服务provider必须要在我们的Angular模块中注册:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/ng2/app/phone.service.ts

import { InjectionToken } from "@angular/core";

export const PHONE_SERVICE = new InjectionToken<any>('PHONE_SERVICE');

export function createPhoneService(i) {
return i.get('Phone');
}

export const phoneServiceProvider = {
provide: PHONE_SERVICE,
useFactory: createPhoneService,
deps: ['$injector']
}

之后,我们可以注入phoneService到我们的组件Ng2DemoComponent中,并使用它价值所有的电话信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { Component, OnInit, Inject } from '@angular/core';
import { PHONE_SERVICE } from "ng2/app/phone.service";

@Component({
selector: 'ng2-demo',
template: `
<h3>Angular 2 Demo Component</h3>
<img width="150" src="[...]" />
<p>
{{phones.length}} Phones found.
</p>
`
})
export class Ng2DemoComponent implements OnInit {

phones: any[] = [];

constructor(
@Inject(PHONE_SERVICE) private phoneService: any) {
}

ngOnInit() {
this.phones = this.phoneService.query();
}

}

由于我们的符号是一个常量,这个实例使用Inject装饰器来指向它。加载电话后,就可以显示数量了。

重新加载应用后,我们可以看到:

angularjs-upgrade-7

注意我们有一个Angular 1.x的组件和一个Angular组件并使用AngularJS 1.x服务提供的数据进行显示。

不仅仅是嵌套AngularJS 1.x和Angular的东西,我们还需要从各自的版本激活路由。下一节会处理相关内容。

第五步:导航到Angular组件

让AngularJS 1.x的路由来激活Angular组件是很简单的。我们仅需要配置一个路由的模板指向相应的模板即可:

1
2
3
4
5
6
7
8
9
10
$routeProvider.
when('/phones', {
template: '<phone-list></phone-list>' // AngularJS 1.x template
}).
when('/phones/:phoneId', {
template: '<phone-detail></phone-detail>' // AngularJS 1.x template
}).
when('/ng2-demo', {
template: '<ng2-demo></ng2-demo>' // Angular component
})

这样就允许使用Angular组件和AngularJS的路由一起使用,并可以和传统的指令和组件一起使用。

这里要强调一下,同样使用流行的UI-Router。

这个方案简单的同时,同时也有一个缺点:我们不能利用Angular路由来使用新写的组件。为了让这个成为可能,我们会实现Victor Savkin提出的Sibling Outlet approach,使两种路由共存。实现的基础是他提出的升级壳模式(Upgrade Shell pattern)。下两章会介绍如何实现这里的想法。

第六步:使用Victor Savkin的升级壳模式

Angular的主策划之一Victor Savkin提出了升级壳模式。他在他的电子书和博客中描述了升级壳模式。它正视了Angular组件在混合应用的顶层。这是升级壳包含了AngularJS构建块(指令、组件和控制器)和Angular组件。

为了实现这个模式,在开始实现文章中的努力时我们可以使用CLI生成的AppComponent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/ng2/app/app.component.html
<!--The whole content below can be removed with the new code.-->
<div style="text-align:center">
<h1>
Welcome to {{title}}!!
</h1>

</div>

<!-- ng1 -->
<div class="view-container">
<div ng-view class="view-frame"></div>
</div>
<!-- /ng1 -->

注意Angular组件中包含了AngularJS 1.x路由的ng-view

为了让这个组件作为我们应用的最顶层,我们需要直接启动它。我们需要将它放到AppModulebootstrap数组中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// src/ng2/app/app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule, InjectionToken } from '@angular/core';
import { UpgradeModule, downgradeComponent } from '@angular/upgrade/static';
import { AppComponent } from './app.component';
import { Ng2DemoComponent } from "ng2/app/ng2-demo.component";
import { phoneServiceProvider } from "ng2/app/phone.service";

declare var angular: any;

angular.module('phonecatApp')
.directive(
'ng2Demo',
downgradeComponent({component: Ng2DemoComponent})
);

@NgModule({
declarations: [
AppComponent,
Ng2DemoComponent
],
imports: [
BrowserModule,
UpgradeModule
],
entryComponents: [
Ng2DemoComponent // Don't forget this!!!
],
providers: [
phoneServiceProvider
],
bootstrap: [AppComponent]
})

export class AppModule {
// Remove code for bootstrapping hybrid app manually !!!
/*
constructor(private upgrade: UpgradeModule) { }
ngDoBootstrap() {
this.upgrade.bootstrap(document.body, ['phonecatApp'], { strictDi: true });
}
*/
}

请注意,我们也需要移除手动启动应用的代码。代码被移动到AppComponent中,并在升级壳启动后开始干活。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/ng2/app/app.component.ts

import { Component, Inject } from '@angular/core';
import { PHONE_SERVICE } from "ng2/app/phone.service";
import { UpgradeModule } from "@angular/upgrade/static";

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'app';

phones: any[] = [];

constructor(private upgrade: UpgradeModule) { }

ngOnInit() {
this.upgrade.bootstrap(document.body, ['phonecatApp']);
}
}

并且,确保index.html引用了我们的升级壳:

1
2
3
4
<!-- src/index.html -->
<body>
<app-root></app-root>
</body>

重新加载应用,并查看包含AngularJS 1.x应用的升级壳。

当这个开始工作,我们就提供了下一章目标的基础:同时使用AngularJS 1.x和Angular路由。

第七步:使用Victor Savkin的兄弟姐妹出口来同时使用两种路由

Victor Savkin的兄弟姐妹出口描述了一种同时使用两个版本Angular的路由方法。为了实现这一点,我们需要加载Angular路由:

1
npm install @angular/router --save

之后,扩展app.component.html。它会各种路由获取一个出口。针对AngularJS 1.x路由我们使用带有ng-viewdiv,针对Angular路由是一个router-outlet元素:

1
2
3
4
5
<!-- src/ng2/app/app.component.html -->
<div class="view-container">
<div ng-view class="view-frame"></div>
<router-outlet></router-outlet>
</div>

当激活了一个基于AngularJS 1的路由,第一个获得一个模板;当激活了一个Angular路由,后者被使用。

现在,让我们配置Angular路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// src/ng2/app/app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule, InjectionToken } from '@angular/core';
import { RouterModule} from '@angular/router';

import { UpgradeModule, downgradeComponent } from '@angular/upgrade/static';
import { AppComponent } from './app.component';
import { Ng2DemoComponent } from "ng2/app/ng2-demo.component";
import { phoneServiceProvider } from "ng2/app/phone.service";

declare var angular: any;

angular.module('phonecatApp')
.directive(
'ng2Demo',
downgradeComponent({component: Ng2DemoComponent})
);

@NgModule({
declarations: [
AppComponent,
Ng2DemoComponent
],
imports: [
BrowserModule,
UpgradeModule,
RouterModule.forRoot([
{
path: '',
pathMatch: 'full',
redirectTo: 'ng2-route'

},
{
path: 'ng2-route',
component: Ng2DemoComponent
}
],
{
useHash: true
}
)
],
entryComponents: [
Ng2DemoComponent
],
providers: [
phoneServiceProvider
],
bootstrap: [AppComponent]
})

export class AppModule {
}

就如你所看的,在这个例子中刚刚定义了Angular路由的配置。作为补充,为了两个版本的一致性,这里使用了哈希策略。

我们需要确保当AngularJS 1.x路由激活时,Angular路由不做任何事。为了做到这一点,Victor建议使用一个定制的UrlHandlingStrategy

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/ng2/app/app.module.ts

import { RouterModule, UrlHandlingStrategy } from '@angular/router';

[...]

export class CustomHandlingStrategy implements UrlHandlingStrategy {
shouldProcessUrl(url) {
return url.toString().startsWith("/ng2-route") || url.toString() === "/";
}
extract(url) { return url; }
merge(url, whole) { return url; }
}

这个策略需要注册到*AppModule:

1
2
3
4
5
6
7
8
9
10
11
// src/ng2/app/app.module.ts
@NgModule({
[...]
providers: [
phoneServiceProvider,
{ provide: UrlHandlingStrategy, useClass: CustomHandlingStrategy }
],
bootstrap: [AppComponent]
})
export class AppModule {
}

之后,我们需要对AngularJS 1.x的路由配置做一些小修改。首先,我们必须要移除配置的哈希前缀,因为这会影响Angular路由。我们必须要使用otherwise加载一个空白模板来添加一个默认路由到版本1的出口中,当路由被其他路由处理的时候:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/app1/app.config.js

// No Prefix for the sake of uniformity
// $locationProvider.hashPrefix('!');

$routeProvider.
when('/phones', {
template: '<phone-list></phone-list>'
}).
when('/phones/:phoneId', {
template: '<phone-detail></phone-detail>'
}).
when('/ng2-demo', {
template: '<ng2-demo></ng2-demo>'
})
.otherwise({template : ''});

就如前面提的,使用AngularJS 1.x路由显示的一切也对流行的UI-Router适用。

之后,添加一些菜单到AppComponent中来允许在基于AngularJS 1.x和Angular的路由间切换。

1
2
3
4
<!-- src/app2/app.component.html -->

<a routerLink="ng2-route">ng2-route</a> |
<a href="#/phones">Phones</a>

加载应用后,我们就可以在我们的路由间切换:

angularjs-upgrade-8

[结束]

您的支持将鼓励我继续创作!
0%