You replaced the filter and orderBy filters with bindings to the getPhones() controller method, which implements the filtering and ordering logic inside the component itself.
getPhones(): PhoneData[] { return this.sortPhones(this.filterPhones(this.phones)); } private filterPhones(phones: PhoneData[]) { if (phones && this.query) { return phones.filter(phone => { let name = phone.name.toLowerCase(); let snippet = phone.snippet.toLowerCase(); return name.indexOf(this.query) >= 0 || snippet.indexOf(this.query) >= 0; }); } return phones; } private sortPhones(phones: PhoneData[]) { if (phones && this.orderProp) { return phones .slice(0) // Make a copy .sort((a, b) => { if (a[this.orderProp] < b[this.orderProp]) { return -1; } else if ([b[this.orderProp] < a[this.orderProp]]) { return 1; } else { return 0; } }); } return phones; }
Now you need to downgrade the Angular component so you can use it in AngularJS. Instead of registering a component, you register a phoneListdirective, a downgraded version of the Angular component.
The as angular.IDirectiveFactory cast tells the TypeScript compiler that the return value of the downgradeComponent method is a directive factory.
强制类型转换 as angular.IDirectiveFactory 告诉 TypeScript 编译器 downgradeComponent 方法 的返回值是一个指令工厂。
declare var angular: angular.IAngularStatic; import { downgradeComponent } from '@angular/upgrade/static'; /* . . . */ @Component({ selector: 'phone-list', templateUrl: './phone-list.template.html' }) export class PhoneListComponent { /* . . . */ } angular.module('phoneList') .directive( 'phoneList', downgradeComponent({component: PhoneListComponent}) as angular.IDirectiveFactory );
The new PhoneListComponent uses the Angular ngModel directive, located in the FormsModule. Add the FormsModule to NgModule imports, declare the new PhoneListComponent and finally add it to entryComponents since you downgraded it:
The AngularJS injector has an AngularJS router dependency called $routeParams, which was injected into PhoneDetails when it was still an AngularJS controller. You intend to inject it into the new PhoneDetailsComponent.
Unfortunately, AngularJS dependencies are not automatically available to Angular components. You must upgrade this service via a factory provider to make $routeParams an Angular injectable. Do that in a new file called ajs-upgraded-providers.ts and import it in app.module.ts:
You've removed the $ctrl. prefix from all expressions.
你从所有表达式中移除了 $ctrl. 前缀。
You've replaced ng-src with property bindings for the standard src property.
正如你在电话列表中做过的那样,你把 ng-src 替换成了标准的 src 属性绑定。
You're using the property binding syntax around ng-class. Though Angular does have a very similar ngClassas AngularJS does, its value is not magically evaluated as an expression. In Angular, you always specify in the template when an attribute's value is a property expression, as opposed to a literal string.
You've replaced ng-click with an event binding for the standard click.
你把 ng-click 替换成了一个到标准 click 事件的绑定。
You've wrapped the whole template in an ngIf that causes it only to be rendered when there is a phone present. You need this because when the component first loads, you don't have phone yet and the expressions will refer to a non-existing value. Unlike in AngularJS, Angular expressions do not fail silently when you try to refer to properties on undefined objects. You need to be explicit about cases where this is expected.
The AngularJS directive had a checkmarkfilter. Turn that into an Angular pipe.
AngularJS 指令中有一个 checkmark过滤器,把它转换成 Angular 的管道。
There is no upgrade method to convert filters into pipes. You won't miss it. It's easy to turn the filter function into an equivalent Pipe class. The implementation is the same as before, repackaged in the transform method. Rename the file to checkmark.pipe.ts to conform with Angular conventions:
These files need to be copied together with the polyfills. The files the application needs at runtime, like the .json phone lists and images, also need to be copied.
At this point, you've replaced all AngularJS application components with their Angular counterparts, even though you're still serving them from the AngularJS router.
Like all routers, it needs a place in the UI to display routed views. For Angular that's the <router-outlet> and it belongs in a root component at the top of the applications component tree.
You don't yet have such a root component, because the app is still managed as an AngularJS app. Create a new app.component.ts file with the following AppComponent class:
A router needs configuration whether it's the AngularJS or Angular or any other router.
无论在 AngularJS 还是 Angular 或其它框架中,路由器都需要进行配置。
The details of Angular router configuration are best left to the Routing documentation which recommends that you create a NgModule dedicated to router configuration (called a Routing Module).
This module defines a routes object with two routes to the two phone components and a default route for the empty path. It passes the routes to the RouterModule.forRoot method which does the rest.
Now update the AppModule to import this AppRoutingModule and also the declare the root AppComponent as the bootstrap component. That tells Angular that it should bootstrap the app with the rootAppComponent and insert its view into the host web page.
import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { FormsModule } from '@angular/forms'; import { HttpModule } from '@angular/http'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { CheckmarkPipe } from './core/checkmark/checkmark.pipe'; import { Phone } from './core/phone/phone.service'; import { PhoneDetailComponent } from './phone-detail/phone-detail.component'; import { PhoneListComponent } from './phone-list/phone-list.component'; @NgModule({ imports: [ BrowserModule, FormsModule, HttpModule, AppRoutingModule ], declarations: [ AppComponent, PhoneListComponent, CheckmarkPipe, PhoneDetailComponent ], providers: [ Phone ], bootstrap: [ AppComponent ] }) export class AppModule {}
And since you are routing to PhoneListComponent and PhoneDetailComponent directly rather than using a route template with a <phone-list> or <phone-detail> tag, you can do away with their Angular selectors as well.
You no longer have to hardcode the links to phone details in the phone list. You can generate data bindings for each phone's id to the routerLink directive and let that directive construct the appropriate URL to the PhoneDetailComponent:
在电话列表中,你不用再被迫硬编码电话详情的链接了。 你可以通过把每个电话的 id 绑定到 routerLink 指令来生成它们了,该指令的构造函数会为 PhoneDetailComponent 生成正确的 URL:
The Angular router passes route parameters differently. Correct the PhoneDetail component constructor to expect an injected ActivatedRoute object. Extract the phoneId from the ActivatedRoute.snapshot.params and fetch the phone data as before:
It is time to take off the training wheels and let the application begin its new life as a pure, shiny Angular app. The remaining tasks all have to do with removing code - which of course is every programmer's favorite task!
If you haven't already, remove all references to the UpgradeModule from app.module.ts, as well as any factory provider for AngularJS services, and the app/ajs-upgraded-providers.ts file.
Also remove any downgradeInjectable() or downgradeComponent() you find, together with the associated AngularJS factory or directive declarations. Since you no longer have downgraded components, you no longer list them in entryComponents.
The external typings for AngularJS may be uninstalled as well. The only ones you still need are for Jasmine and Angular polyfills. The @angular/upgrade package and its mapping in systemjs.config.js can also go.
Tests can not only be retained through an upgrade process, but they can also be used as a valuable safety measure when ensuring that the application does not break during the upgrade. E2E tests are especially useful for this purpose.
The PhoneCat project has both E2E Protractor tests and some Karma unit tests in it. Of these two, E2E tests can be dealt with much more easily: By definition, E2E tests access the application from the outside by interacting with the various UI elements the app puts on the screen. E2E tests aren't really that concerned with the internal structure of the application components. That also means that, although you modify the project quite a bit during the upgrade, the E2E test suite should keep passing with just minor modifications. You didn't change how the application behaves from the user's point of view.
During TypeScript conversion, there is nothing to do to keep E2E tests working. But when you change the bootstrap to that of a Hybrid app, you must make a few changes.
Update the protractor-conf.js to sync with hybrid apps:
再对 protractor-conf.js 做下列修改,与混合应用同步:
ng12Hybrid: true
When you start to upgrade components and their templates to Angular, you'll make more changes because the E2E tests have matchers that are specific to AngularJS. For PhoneCat you need to make the following changes in order to make things work with Angular:
by.repeater('phone in $ctrl.phones').column('phone.name')
by.css('.phones .name')
The repeater matcher relies on AngularJS ng-repeat
repeater 匹配器依赖于 AngularJS 中的 ng-repeat
by.repeater('phone in $ctrl.phones')
by.css('.phones li')
The repeater matcher relies on AngularJS ng-repeat
repeater 匹配器依赖于 AngularJS 中的 ng-repeat
by.model('$ctrl.query')
by.css('input')
The model matcher relies on AngularJS ng-model
model 匹配器依赖于 AngularJS 中的 ng-model
by.model('$ctrl.orderProp')
by.css('select')
The model matcher relies on AngularJS ng-model
model 匹配器依赖于 AngularJS 中的 ng-model
by.binding('$ctrl.phone.name')
by.css('h1')
The binding matcher relies on AngularJS data binding
binding 匹配器依赖于 AngularJS 的数据绑定
When the bootstrap method is switched from that of UpgradeModule to pure Angular, AngularJS ceases to exist on the page completely. At this point, you need to tell Protractor that it should not be looking for an AngularJS app anymore, but instead it should find Angular apps from the page.
Replace the ng12Hybrid previously added with the following in protractor-conf.js:
替换之前在 protractor-conf.js 中加入 ng12Hybrid,象这样:
useAllAngular2AppRoots: true,
Also, there are a couple of Protractor API calls in the PhoneCat test code that are using the AngularJS $location service under the hood. As that service is no longer present after the upgrade, replace those calls with ones that use WebDriver's generic URL APIs instead. The first of these is the redirection spec:
同样,PhoneCat 的测试代码中有两个 Protractor API 调用内部使用了 $location。该服务没有了, 你就得把这些调用用一个 WebDriver 的通用 URL API 代替。第一个 API 是“重定向(redirect)”规约:
For instance, in the phone detail component spec, you can use ES2015 features like arrow functions and block-scoped variables and benefit from the type definitions of the AngularJS services you're consuming:
describe('phoneDetail', () => { // Load the module that contains the `phoneDetail` component before each test beforeEach(angular.mock.module('phoneDetail')); // Test the controller describe('PhoneDetailController', () => { let $httpBackend: angular.IHttpBackendService; let ctrl: any; let xyzPhoneData = { name: 'phone xyz', images: ['image/url1.png', 'image/url2.png'] }; beforeEach(inject(($componentController: any, _$httpBackend_: angular.IHttpBackendService, $routeParams: angular.route.IRouteParamsService) => { $httpBackend = _$httpBackend_; $httpBackend.expectGET('phones/xyz.json').respond(xyzPhoneData); $routeParams['phoneId'] = 'xyz'; ctrl = $componentController('phoneDetail'); })); it('should fetch the phone details', () => { jasmine.addCustomEqualityTester(angular.equals); expect(ctrl.phone).toEqual({}); $httpBackend.flush(); expect(ctrl.phone).toEqual(xyzPhoneData); }); }); });
Once you start the upgrade process and bring in SystemJS, configuration changes are needed for Karma. You need to let SystemJS load all the new Angular code, which can be done with the following kind of shim file:
Once done, you can load SystemJS and other dependencies, and also switch the configuration for loading application files so that they are not included to the page by Karma. You'll let the shim and SystemJS load them.
Since the HTML templates of Angular components will be loaded as well, you must help Karma out a bit so that it can route them to the right paths:
由于 Angular 组件中的 HTML 模板也同样要被加载,所以你得帮 Karma 一把,帮它在正确的路径下找到这些模板:
// proxied base paths for loading assets proxies: { // required for component assets fetched by Angular's compiler "/phone-detail": '/base/app/phone-detail', "/phone-list": '/base/app/phone-list' },
The unit test files themselves also need to be switched to Angular when their production counterparts are switched. The specs for the checkmark pipe are probably the most straightforward, as the pipe has no dependencies:
import { CheckmarkPipe } from './checkmark.pipe'; describe('CheckmarkPipe', function() { it('should convert boolean values to unicode checkmark or cross', function () { const checkmarkPipe = new CheckmarkPipe(); expect(checkmarkPipe.transform(true)).toBe('\u2713'); expect(checkmarkPipe.transform(false)).toBe('\u2718'); }); });
The unit test for the phone service is a bit more involved. You need to switch from the mocked-out AngularJS $httpBackend to a mocked-out Angular Http backend.
For the component specs, you can mock out the Phone service itself, and have it provide canned phone data. You use Angular's component unit testing APIs for both components.
import { ActivatedRoute } from '@angular/router'; import { Observable, of } from 'rxjs'; import { async, TestBed } from '@angular/core/testing'; import { PhoneDetailComponent } from './phone-detail.component'; import { Phone, PhoneData } from '../core/phone/phone.service'; import { CheckmarkPipe } from '../core/checkmark/checkmark.pipe'; function xyzPhoneData(): PhoneData { return { name: 'phone xyz', snippet: '', images: ['image/url1.png', 'image/url2.png'] }; } class MockPhone { get(id: string): Observable<PhoneData> { return of(xyzPhoneData()); } } class ActivatedRouteMock { constructor(public snapshot: any) {} } describe('PhoneDetailComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ CheckmarkPipe, PhoneDetailComponent ], providers: [ { provide: Phone, useClass: MockPhone }, { provide: ActivatedRoute, useValue: new ActivatedRouteMock({ params: { 'phoneId': 1 } }) } ] }) .compileComponents(); })); it('should fetch phone detail', () => { const fixture = TestBed.createComponent(PhoneDetailComponent); fixture.detectChanges(); let compiled = fixture.debugElement.nativeElement; expect(compiled.querySelector('h1').textContent).toContain(xyzPhoneData().name); }); });import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Observable, of } from 'rxjs'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { SpyLocation } from '@angular/common/testing'; import { PhoneListComponent } from './phone-list.component'; import { Phone, PhoneData } from '../core/phone/phone.service'; class ActivatedRouteMock { constructor(public snapshot: any) {} } class MockPhone { query(): Observable<PhoneData[]> { return of([ {name: 'Nexus S', snippet: '', images: []}, {name: 'Motorola DROID', snippet: '', images: []} ]); } } let fixture: ComponentFixture<PhoneListComponent>; describe('PhoneList', () => { beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ PhoneListComponent ], providers: [ { provide: ActivatedRoute, useValue: new ActivatedRouteMock({ params: { 'phoneId': 1 } }) }, { provide: Location, useClass: SpyLocation }, { provide: Phone, useClass: MockPhone }, ], schemas: [ NO_ERRORS_SCHEMA ] }) .compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(PhoneListComponent); }); it('should create "phones" model with 2 phones fetched from xhr', () => { fixture.detectChanges(); let compiled = fixture.debugElement.nativeElement; expect(compiled.querySelectorAll('.phone-list-item').length).toBe(2); expect( compiled.querySelector('.phone-list-item:nth-child(1)').textContent ).toContain('Motorola DROID'); expect( compiled.querySelector('.phone-list-item:nth-child(2)').textContent ).toContain('Nexus S'); }); xit('should set the default value of orderProp model', () => { fixture.detectChanges(); let compiled = fixture.debugElement.nativeElement; expect( compiled.querySelector('select option:last-child').selected ).toBe(true); }); });
Finally, revisit both of the component tests when you switch to the Angular router. For the details component, provide a mock of Angular ActivatedRoute object instead of using the AngularJS $routeParams.