书格前端

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


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应用:

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文件夹到输出的目录中。

{
  "$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>
<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模块:

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

angularjs-upgrade-5

尽管如此,这个一个包含两个版本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";

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

  <!-- 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)的引用,并使用它获取服务:

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

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

angularjs-upgrade-7

注意我们有一个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-view class="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";

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

// 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-view class="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";

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

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

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

angularjs-upgrade-8

[结束]