查看原文
其他

【第1208期】AngularJS 1.x平滑升级Angular实战

Perry 前端早读课 2019-06-26

前言

不知道还有多少人的项目还在用Angular,这篇升级实战可以看看,今日早读文章由@Perry翻译投稿分享。

正文从这开始~

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

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

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

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

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

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

第一步:创建新的Angular应用

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

ng new migrated

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

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

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

{
"$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下新的文件中。

<!doctype html>
<htmllang="en">
<head>
   
<metacharset="utf-8">
   
<title>Migrated</title>
   
<basehref="/">
   
<!-- 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-viewclass="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+Angular的混合应用

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

npm install @angular/upgrade --save

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

<!-- remove root component -->
<!--
 <app-root></app-root> -->

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

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的组件:

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

第三步:降级一个Angular组件

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

// 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来实现:

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";
declarevar 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组件:

<!-- 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样例组件:

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

第四步:升级一个服务

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

// 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模块中注册:

// 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中,并使用它价值所有的电话信息:

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装饰器来指向它。加载电话后,就可以显示数量了。

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

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

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

第五步:导航到Angular组件

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

$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

// 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-viewclass="view-frame"></div>
</div>
<!-- /ng1 -->

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

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

// 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";
declarevar 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中,并在升级壳启动后开始干活。

// 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引用了我们的升级壳:

<!-- src/index.html -->
<body>
<app-root></app-root>
</body>

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

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

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

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

npm install @angular/router --save

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

<!-- src/ng2/app/app.component.html -->
<div class="view-container">
<div ng-viewclass="view-frame"></div>
<router-outlet></router-outlet>
</div>

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

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

// 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";
declarevar 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

// 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:

// src/ng2/app/app.module.ts
@NgModule({
 
[...]
 providers
: [
   phoneServiceProvider
,
   
{ provide: UrlHandlingStrategy, useClass: CustomHandlingStrategy }
 
],
 bootstrap
: [AppComponent]
})
export class AppModule {
}

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

// 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的路由间切换。

<!-- src/app2/app.component.html -->
<a routerLink="ng2-route">ng2-route</a> |
<a href="#/phones">Phones</a>

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

最后,为你推荐

【第1190期】完美升级 AngularJS 至 Angular

【第950期】Angular组件间通信

关于本文

译者:@Perry
译文:http://bookcell.org/2018/02/19/directly-upgrading-from-angularjs-to-angular-without-preparing-the-exiting-code-base/index.html
作者:@ManfredSteyer
原文:https://www.softwarearchitekt.at/post/2017/07/14/directly-upgrading-from-angularjs-1-x-to-angular-by-skipping-preparation.aspx

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存