使用confirm及alert来进行弹窗提醒虽然方便,但浏览器为我们提供的弹窗着实欠缺一些友好性。本节来实现一个看起来更漂亮的弹窗。 # 实现原理 人类文明中充满着各种"trick 戏法",比如你在电脑上看电影时其实是在观看一张张快速切换的照片,由于这些照片播放的太快了使得我们就像看到了真实的物体在变化一样;再比如各种第一视角的游戏(比如绝地求生),其实画面中的"你"一直都是位于屏幕中央的位置而从未动过,由于"你"所处的周边环境的画面在变化而使得你感觉就像自己在动一下;再比如被点亮的灯泡的亮度其实一在不停地变化,只是由于变化的频率太快,从而使得你感觉其亮度是一直不变的而已。 弹窗的实现,也是一种"trick 戏法"。 # 图层 如果你有一些PS基础,或使用过其它一些图片(视频)编辑软件,对这个概念一定不会陌生。在进行图片处理时,会将多个图层由上至下排列,图片的最终效果则是这些图片由上及下的合成效果。 ![](https://img.kancloud.cn/b2/f6/b2f676f44d06fcc2f1d0fa6a6672ff9f_429x387.png) 比如下图便是按上面的理论使用制图软件绘制的一条小鱼: ![](https://img.kancloud.cn/da/77/da774b18486f017b4ef54be76c79cfed_618x317.png) 其实视频软件中像"添加字幕"、"画中画"的功能也是类似。如果向上追溯的话,笔者在上世纪90年代读初中时,曾经接触过一种叫做幻灯机的东西,至令印象颇深。 ![](https://img.kancloud.cn/bc/80/bc80787996282316e3843e6cd9777c4c_288x377.png) >[info] PPT又被称为"幻灯片"。^_^,你想到了什么? 它的原理如下: ![](https://img.kancloud.cn/67/ce/67ceddefb9feb588c7e6bc289a1f46a0_465x420.png) 再往上追溯还可以追溯到我国的传统艺术:皮影。 CSS中其实也有"图层"的概念,它在CSS中的名字叫:position 定位。 # 弹窗原理 ![](https://img.kancloud.cn/67/8b/678bf4bf52c1af3e47f1abbf5d174ee3_346x280.gif) # 实践 有了原理以后,开始分步尝试开发,拟分为以下几步: 1. 建立两个div。其中一个起半透明的遮罩层的作用;第二个用于定制弹出窗口 2. 用户点击删除时,显示这两个DIV。 3. 定制第一个DIV,完成其半透明的遮罩层功能。 4. 定制第二个DIV,使其显示在遮罩层上方,并且居中显示 5. 给第二个DIV添加一个说明,一个确认按钮,一个取消按钮 6. 给确认按钮及取消按钮分别加入对应的功能 7. 集成测试 ## 建立DIV 开启集成测试模式,并来到学生管理界面。找到对应的V层,新建两个DIV。 src/app/student/index/index.component.html ```html <div>这是遮罩层</div> <div>这是弹出窗口</div> <form (ngSubmit)="onQuery()"> ... ``` ![](https://img.kancloud.cn/c8/88/c8887932be6de935dd7d451c1bd07741_556x196.png) ## 用户删除时显示DIV 首先将其设置为默认隐藏。 src/app/student/index/index.component.html ```html <div *ngIf="showPopWindow">这是遮罩层</div> <div *ngIf="showPopWindow">这是弹出窗口</div> <form (ngSubmit)="onQuery()"> ``` src/app/student/index/index.component.ts ```javascript export class IndexComponent implements OnInit { ... showPopWindow = false; ``` 接着当用户点击删除时,设置`showPopWindow`的值为true,同时为了避免发起真实的删除操作,在删除方法中暂时添加return语句。 src/app/student/index/index.component.ts ```javascript onDelete(student: Student): void { this.showPopWindow = true; return; const result = confirm('这里是提示的消息'); ... ``` ![](https://img.kancloud.cn/03/86/03861a466768a9b0eb24cb0c2bce25b4_1052x303.gif) ## 定制遮罩层 遮罩层有以下个特点: * 位于主体窗口之上 * 大小与浏览器窗口相同 * 有个灰色的背景该背景透明 而以上几个特点则都是由CSS来控制实现的,实现如下: 设置class src/app/student/index/index.component.html ```html <div *ngIf="showPopWindow" class="mask">这是遮罩层</div> ... ``` src/app/student/index/index.component.sass ```sass ... .mask position: fixed background-color: green ``` * 使用position: fixed将该div设置为新的图层 * 设置个背景色以在开发过程中观察该DIV的大小 测试: ![](https://img.kancloud.cn/f7/df/f7df7bc27cbb15e04356838dfaaeb8b2_360x194.png) 此时:遮罩层遮挡住了"这是弹出窗口"所在的DIV,表明其位于主体窗口之上成功。但大小不符合要求,继续设置如下: src/app/student/index/index.component.sass ```sass ... .mask position: fixed background-color: green height: 100% width: 100% ``` 测试: ![](https://img.kancloud.cn/17/09/170999e9f874a7cb333d2f49bd9ec3f4_1428x396.png) * 该DIV的起始位置处于原位置(未设置fixed属性前的位置),导致未能占满整个屏幕。 * 有些元素位于遮罩层之上 问题一,使用top,left自定义该图层距离浏览器上方及左侧的距离: src/app/student/index/index.component.sass ```sass ... .mask position: fixed background-color: green height: 100% width: 100% top: 0px left: 0px ``` 问题二:各个图层(position: fixed)将z-index的值由大到小,进行由上到下排列,当图层的z-index值相同时按后出现的图层排到之前图层之上。所以,解决该问题的方法是:将mask的index设置为一个较大的值。 src/app/student/index/index.component.sass ```sass ... .mask position: fixed background-color: green height: 100% width: 100% top: 0px left: 0px z-index: 1000 ``` 测试通过。 ## 使弹出窗口位于遮罩层上方 有了刚刚的经验这个就不太难了,实现代码如下: src/app/student/index/index.component.html ```html <div *ngIf="showPopWindow" class="mask">这是遮罩层</div> <div *ngIf="showPopWindow" class="popWindow">这是弹出窗口</div> <form (ngSubmit)="onQuery()"> ``` src/app/student/index/index.component.sass ```sass .popWindow position: fixed z-index: 1001 ``` ## 弹窗样式 src/app/student/index/index.component.sass ```sass .popWindow position: fixed top: 50% left: 50% z-index: 1001 ``` * 将top与left设置为50%,以达到居中的目的 ![](https://img.kancloud.cn/cb/56/cb56d62c2dc64076aac4b6cece8dada3_1182x344.png) ## 添加说明、按钮细化样式 src/app/student/index/index.component.html ```html <div *ngIf="showPopWindow" class="mask">这是遮罩层</div> <div *ngIf="showPopWindow" class="popWindow"> <h5>这里是弹窗说明</h5> <hr> <div class="text-right"> <button class="btn btn-sm btn-warning">取消</button> <button class="btn btn-sm btn-primary">确认</button> </div> </div> <form (ngSubmit)="onQuery()"> ``` 细化样式如下: src/app/student/index/index.component.sass ```sass .popWindow position: fixed width: 300px min-height: 140px top: calc(50% - 70px) left: calc(50% - 150px) z-index: 1001 background-color: aliceblue padding: 20px 20px 10px .popWindow h5 min-height: 50px .popWindow button margin: auto 8px ``` * 使用calc运算符将弹窗进行居中。请思索:为什么要分别减70px及150px 其它的属性请依次添加后分步查看添加后效果,最终效果如下: ![](https://img.kancloud.cn/bb/ad/bbad0adbe0892e34608c48471678c564_977x369.png) ## 加入对应的功能 实现删除的方法有很多种,在此给出实现简单的一种,具体的流程如下: ![](https://img.kancloud.cn/87/f2/87f243b42df0af756221c766a471fda8_421x498.png) 按此流程,依次完善V层及C层代码: src/app/student/index/index.component.html ``` <div class="text-right"> <button class="btn btn-sm btn-warning" type="button" (click)="cancel()">取消</button> <button class="btn btn-sm btn-primary" type="button" (click)="confirm()">确认</button> </div> ``` src/app/student/index/index.component.ts ```javascript /*缓存要删除的学生*/ cacheDeleteStudent: Student; /** * 删除学生 * @param student 学生 */ onDelete(student: Student): void { this.cacheDeleteStudent = student; this.showPopWindow = true; } /** * 删除缓存的学生后,隐藏弹窗 */ deleteCacheStudent() { const student = this.cacheDeleteStudent; this.studentService.deleteById(student.id) .subscribe(() => { this.pageStudent.content.forEach((value, key) => { if (value === student) { this.pageStudent.content.splice(key, 1); } }); }); } /** * 点击确认 */ confirm() { this.deleteCacheStudent(); this.showPopWindow = false; } /** * 点击取消 */ cancel() { this.showPopWindow = false; } ``` ## 集成测试 ![](https://img.kancloud.cn/e7/0f/e70f7d47fe29bb9e2778701a8bcd0c6a_1391x337.gif) 集成测试过程中发现以下问题: 1. 弹窗说明的文字为:这里是弹窗说明。正确的应该改为:请您再次确认 2. 遮罩层的背景颜色为绿色。正确的应为灰色(介于黑与白之间) 3. 遮罩层没有半透明设置。正确的应为半透明 4. 左上角遮罩层有测试文字:这是遮罩层。正确的应该没有文字。 对于1,4两点请自行修正。对于2,3两点修正如下: src/app/student/index/index.component.sass ```sass .mask position: fixed background-color: gray ➊ height: 100% width: 100% top: 0px left: 0px z-index: 1000 opacity: 80% ➋ ``` * ➊ 背景色灰色 * ➋ 不透明度80% 最终效果如下: ![](https://img.kancloud.cn/db/ca/dbcac90666aeb19816a34b269056fd9a_1391x337.gif) ## 单元测试 在加入功能的环节中,我们:增加了3个方法,修改了1个方法。对应增加3个测试用例如下: src/app/student/index/index.component.spec.ts ```javascript fit('deleteCacheStudent', () => { }); fit('confirm', () => { }); fit('cancel', () => { }); ``` ### deleteCacheStudent 此方法实际上是将原onDelete方法的部分逻辑进行迁移,在原来onDelete方法的基础上稍做修正: src/app/student/index/index.component.spec.ts ```javascript fit('deleteCacheStudent', () => { // 替身及模似数据的准备 const studentService = TestBed.get(StudentService); const subject = new BehaviorSubject<void>(undefined); spyOn(studentService, 'deleteById').and.returnValue(subject); // 调用方法,删除第一个学生 const student = component.pageStudent.content[0]; component.cacheDeleteStudent = student; // ➊ component.deleteCacheStudent(); // 断言删除的学生成功的由前台移除 let found = false; component.pageStudent.content.forEach(value => { if (value === student) { found = true; } }); expect(found).toBeFalsy(); }); ``` * ➊ 设置缓存的要删除的学生 ### confirm src/app/student/index/index.component.spec.ts ```javascript fit('confirm', () => { // 替身及数据准备 spyOn(component, 'deleteCacheStudent'); component.showPopWindow = true; // 调用 component.confirm(); // 断言 expect(component.showPopWindow).toBeFalsy(); expect(component.deleteCacheStudent).toHaveBeenCalled(); }); ``` ### cancel src/app/student/index/index.component.spec.ts ```javascript fit('cancel', () => { // 替身及数据准备 component.showPopWindow = true; // 调用 component.cancel(); // 断言 expect(component.showPopWindow).toBeFalsy(); }); ``` ### onDelete src/app/student/index/index.component.spec.ts ```javascript it('onDelete -> 确认删除', () => { // 替身及模似数据的准备 component.showPopWindow = false; const student = new Student(); // 调用 component.onDelete(student); // 断言 expect(component.cacheDeleteStudent).toBeTruthy(student); expect(component.showPopWindow).toBeTruthy(); }); ``` 最后,将所有的`f`去除,做全局测试: 错误一: ![](https://img.kancloud.cn/51/43/5143d1e98ffa4390a2bb05d75c03aef1_850x143.png) 错误原因:只有单独进行某个单元测试时,才可以使用root1根选择器。在多个单元测试共同进行时,angular会为每个单元测试生成唯一的root编号,比如:root2,root3,root4等。 修正如下: src/app/student/index/index.component.spec.ts ```javascript FormTest.clickButton(fixture, '#root1 > table > tr:nth-child(2) > td:nth-child(6) > button'); ✘ FormTest.clickButton(fixture, 'table > tr:nth-child(2) > td:nth-child(6) > button'); ✚ ``` 错误二: ![](https://img.kancloud.cn/a7/2b/a72b1f5c7aaa39ee2acb6aac35ed7835_422x129.png) 找到对应的测试用例,将`it`变更为`fit`,再次测试: ![](https://img.kancloud.cn/c3/86/c386d9eec1bc6bad07ae8f8b37d2db46_626x467.png) 排查看确认是由于校验规则失效导致C层对应的`submit`方法未生效,修正单元测试如下: src/app/student/edit/edit.component.spec.ts ```javascript fit('点击保存按钮', () => { spyOn(component, 'onSubmit'); component.formGroup.get('name').setValue('123'); ✚ component.formGroup.get('sno').setValue('123421'); ✚ fixture.detectChanges(); ✚ const button: HTMLButtonElement = fixture.debugElement.query(By.css('button')).nativeElement; button.click(); expect(component.onSubmit).toHaveBeenCalled(); }); ``` >[success] 单元测试的职责正是如此。当某些逻辑变更时,历史的单元测试会及时的发出警告信息。 修正后单元测试全部通过,保证了未因增加新功能而对历史功能的造成影响。 # 参考文档 | 名称 | 链接 | 预计学习时长(分) | | --- | --- | --- | | 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.8.5](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.8.5) | - | | CSS Position(定位) | [https://www.runoob.com/css/css-positioning.html](https://www.runoob.com/css/css-positioning.html) | 5 | | CSS z-index 属性 | [https://www.runoob.com/cssref/pr-pos-z-index.html](https://www.runoob.com/cssref/pr-pos-z-index.html) | 5 | | CSS3 opacity 属性 | [https://www.runoob.com/cssref/css3-pr-opacity.html](https://www.runoob.com/cssref/css3-pr-opacity.html) | 5 | | CSS calc() 函数 | [https://www.runoob.com/cssref/func-calc.html](https://www.runoob.com/cssref/func-calc.html) | 5 |