因为目前的 代码,在数据层面上是很混乱的!
所以我们需要对页面做代码分层 -> 用 MVC 思想过来搞 -> 关于这个思想,你记得越模糊越好!如 M 负责数据层, V 负责视图层, C 负责其它的业务逻辑!
目前代码有视图层 template ,也有控制器(一个个更新组件状态的方法),但唯独就是差了个 Model ,不过,实际上,我们的 Money.vue 是有 Model 层面上的代码的!
如:
初始化数据,从数据库里边拿数据:
const recordList: Record[] = JSON.parse(
window.localStorage.getItem("recordList") || "[]"
);
保存数据到数据库里边:
window.localStorage.setItem("recordList", JSON.stringify(this.recordList));
MVC 这个思想可以影响一个前端工程师的整个生涯,因为你只要写网页,那么就会用到 MVC,毕竟它实在是太经典了,而你写页面,不可能不会用到数据
如何封装一个
model?
思路:

我们
push一笔账({})到recordList([]结构的表)里边,@Watch是可以watch到recordList的变化的!
代码:Demo
1、本来的做法 model.js
创建了一个model.js (src这个根目录下)-> 把获取数据和保存数据的代码扔进去:

接下来我们要在 ts 里边导入 model.js,可是 ts 里边真得用 js吗?
测试发现,我们不能通过 import导入这个model.js,因为这种姿势与ts不相容!
而用了require这种姿势却是可行的!

require一开始是不支持export defalut xxx这种defalut姿势的,之后为了兼容就导出了带有defalut的!
所以,如果你在require的时候,就require('xxx.js').default这样搞!
而前端界里边有一个大佬呼吁大家不要用 export defalut xxx,而是用具名的姿势:export {model}(就是导入列表姿势啦!) -> 引入的时候就 require('xxx.js').model(表示我要这个xxx文件里的model)
可以用析构语法:
const {model} = require('model.js')
总之,不管你用 export defalut model 也好,还是用 export {model}也罢,都随你的便! -> 个人建议用后一种,因为语义性更强!
我们在
ts里边引入js-> 使用导入进来的model-> 在用 vscode 写代码时是没有提示它有哪些 API 的!(webstorm 可以猜出model的类型信息) -> 这就是为啥不用model.js而用model.ts的原因!
对了,用了这种姿势,你还得测试一下使用了导入进来的model,Money.vue是否正常运行!(一定要测试代码是否正常运行,这一点贼重要)
小结:不推荐使用 JS,因为这是懦夫行为,当然万不得已你可以这样!
接下来看看,如何把 JS 改写成 TS!
2、model.ts
把.js后缀改写成.ts后缀 -> 这一步就完成了 99%的工作,剩下的工作就是为变量加: 类型了!
🤔:原先代码的失误
我们之前局部定义了一个Record类型,但该类型是 TS 默认就有的高级类型,所以我们是不能把天生自带的,定义成自己的
总之,Record局部声明你随便改没问题,但全局声明就有问题了,毕竟Record这个名字已经被占用了!不然就会冲突
所以我们改成是一个不冲突的名字就可以了,如RecordItem
由于现在要用
RecordItem,而之前我们都是用Record的,所以有什么快捷方式能快速让Record变为RecordItem? -> 编辑器都提供了一个叫「重构」的功能!
💡:如何让一个自定义类型能在所有文件里边使用?
创建src/custom.d.ts-> ts发现.d.ts这个后缀文件,就知道这是一个全局的声明文件
把需要全局使用的类型声明扔到这个文件里边……

一个小技巧:

你写到每个
RecordItem类型都会关联到这个custom.d.ts文件!-> 关于custom这个名字,不是固定的,你也可以叫xxx.d.ts,但是custom这个名字语义性更好,表示这是我们自定义的类型!
💡:用了model.ts就可以用import语法了吗?
当然可以:
// Money.vue
import model from "@/model";
// model.ts-> 可以使用默认导出了!
export default model;
当然,你也可以用有名字的导出,不过,这就需加{}了:
// Money.vue
import {model} from "@/model";
// model.ts -> or export model -> 推荐带 {} 导出,因为可以一眼看清楚输出了哪些变量。
export {model};
简单区分一下默认导出和有名字导出:
import时不需要加{}import时需要加{},而且{}里的名字要与export的名字保持一致!💡:如何确定fetch的返回值类型?或者说整个model.ts里边的类型?

随便搞个 x 变量,就能得出一个个很长的代码的返回值类型了!
可以看到这是一个any类型
有两种姿势可以改变x的类型:

关于姿势二,注意不要把类型强制错了(出错了,就是你活该!),因为 tsc 就是根据你强制出来的类型来判断JSON.parse()返回值的类型的!
TS 很简单,把类型说清楚就完事儿了!-> 说不清楚,那你就用回 JS 吧!
为什么要指定model.fetch()的返回值类型?也就是给返回值一个as xxx这样的断言?

方便了我们之后不用再写:RecordItem[]了,不然,每个变量都得写一遍 :RecordItem[]……
最开始的地方写对了,那么后边用到了就会自动推测,千万不要为了图个方便,就不给
model.fetch()的返回值一个断言(as RecordItem[]),不然,你每次需要用到model.fetch()的返回值,都得加上:RecordItem[]
💡:为啥要把 JSON.parse(JSON.stringify(xxx)) 这个操作也封装到model.clone了?
因为搞个副本对象这种需求,有很多种姿势可以做到,而我们并不关心是用哪种姿势做到的,反正你调用一下model.clone能完成这种需求就可以了,至于clone的背后用了啥,一点都不关心,既然不关心,那就封装一下……这样代码看起来就很语义化了

感觉封装就是为了让我们写的代码语义性更强!更能读懂代码在做什么!-> 你想想
JSON.parse(……)好懂,还是clone()好懂?-> 就像是你叫「名字」好懂人家叫谁,还是叫「头发短的、小眼睛、大嘴巴」好懂人家叫谁?
后边会把这个model.ts给删掉!
看着设计稿怼就行了!

标签页里的每个标签都是可以 click 的,click之后就跳到标签的标签的「编辑页」,注意,标签名是不可以重复的(我们没有用id来区分所有标签)
点击「新建标签」button -> 与 Money 页面的「新增标签」功能是完全一样的!
点击「删除标签」button -> 回到「标签页」列表

代码:Demo
写 HTML -> 写 CSS -> 写 JS
💡:为啥要用一个span标签包裹一个文本?

💡:页面的背景色写在哪儿?
写在 body 里边!
min-height、padding等 -> 有关布局元素上下之间的间距用
padding,因为margin会有塌陷,不过padding也会影响background的范围……当然也可以结合起来使用!
💡:一个你使用scss,但一直误会的点?

&这个符号的作用,可以把嵌套的写法编译成平级的写法:
#main {
color: black;
a {
font-weight: bold;
&:hover { color: red; }
}
}
编译为
#main {
color: black; }
#main a {
font-weight: bold; }
#main a:hover {
color: red; }
如果咩有&,那就是存粹的后代选择器了!
总之,被嵌套的也有可能是爸爸级元素!
💡:标签存在哪儿?
之前我们用了一个model来专门存储一笔账!而现在要存储的东西是「标签」呀!
为啥要存储标签呢?
我们在Money.vue里边是这样写的:
tags = ['衣', '食', '住', '行', '彩票'];
显然,这标签是写死的!不管你新建了多少个标签,页面一刷新,新建的标签都会统统消失掉!
所以我们搞了一个叫 tagListModel 的东西
之前,我们只有一个model,所以就创建了一个叫model.ts文件,而现在还需要model,所以现在的目录结构变成这样了:

本来文件名是没有后缀
Model的,但不加后缀的话,在使用的时候会有名字冲突! -> 所以就随便加了一个后缀!
💡:tagListModel.ts如何写?
结构基本与recordListModel.ts一样,所以 clone 一份 recordListModel.ts里边的内容粘贴过来,然后修改就好了!
结构很像 -> 意味着重复 -> 之后会试着重构一下,如把这两个
model合成一个! -> 而目前我们先把功能完成再说!
💡:每个组件的template里边,基本不会出现写死的数据?

💡:往tagListModel对象里边封装了很多操作?
如fetch(从localStorage里边拿到['11','222'])、create(创建标签,如'3333')、save(把['11','222']保存到localStorage)等这样的操作
💡:对象有个属性data,可它的类型该怎么声明呢?
这样做?

我们想要这个data的类型是元素是字符串的数组,而不是元素是对象的数组之类的……
所以,我们搞了个类型声明定义:
type tagListModel = {
data: string[];
fetch: () => string[];
create: (name: string) => "success" | "duplicated"; //联合类型
save: () => void;
};
=>左边(name: string)里边的内容是输入的类型(输入咩有就是一个空的()),=>右边的string[]则是输出的结果的类型 -> void是不返回
这可不是箭头函数语法哈!
不要问为什么要这样写,因为语法就这样规定的!你看着方方写的代码依葫芦画瓢就行了,下次遇到同样的场景,就照着这个姿势去处理就对了! -> 当你写多了,你也就感觉也就那样了,这语法没啥奇怪的!
💡:为啥要在tagListModel对象里边写个data属性?
因为tagListModel想自己维护data,如果你Labels页面想要操作data,那么你就得用tagListModel提供的 API 来操作!而不是让页面直接操作用户数据!
这种处理,跟recordListModel对比是不同的:

tagListModel这样做的好处:

更像 MVC 了,
Labels组件的tags状态值,只是视图渲染的数据源!而C则是负责调度M去操作data

别忘了数据分为 UI 数据 和 用户数据
💡:方方喜欢把创建的东西return回去!

💡:用户在创建出来的标签重复了,我们该如何提示用户「标签重复了」?
新手做法:创建 成功/失败 就返回ture/false -> 根据返回值提示用户
老手做法(考虑很多):比如为啥创建失败?需要把失败原因给返回出去!
姿势有两种:
姿势一:

姿势二:
按照方方的经验来说,最好是返回一个数字或者返回一个对象!
如:
01、2、3、4、5、6、7、8、9、10 -> 1表示name重复……2表示xxx错误……返回数字这种姿势可行,但数字这种姿势语义性不好呀! -> 很容易遗忘……
所以我们用了字符串姿势来表示错误原因!如返回'duplicate'就是重复错误,而'success'表示创建成功!
返回对象的姿势是这样的:
{
// 错误码
code: number,
// 错误码所代表的含义
message: string
}
这种返回值也是 ok 的 -> 为了简单起见,返回字符串!
注意,返回字符串这种姿势,很容易会写错单词,所以我们需要统一规定一下:

当你拼错单词的时候,tsc就会爆红:

联合类型是字符串的子类型 -> 也就是说,不是所有的字符串,而是就那两个字符串! -> 这是不属于那 7 大类型里边的其中一个,类似于枚举

顶部导航栏 -> 方便回退到「标签页」 -> 如果咩有这个那就只能点击底部的那三个导航了,而这贼其不方便!
代码:
💡:我们要编辑一个标签需要用到它的id?
id一般是数据库自动生成的,在这里我们自己造一个假的id(方便起见,把标签的name当作id)!
正常的
id是一串数字或者一串数字字符串!而我们这里则是非数字的字符串!
之后会改成id生成器!
💡:数组 API map的用法?

💡:在「标签页」里边点击了某个标签,然后就会跳转到「标签编辑页」,那么这个「标签编辑页」是如何知道我们刚刚点击了哪个标签?

路由配置一下:
{ path: "/labels/edit/:id", component: EditLabel }
:id是占位符 -> 可以是1、2、3等这样的值!
话又说回来,我们该如何获取路由信息?也就是如何获取那个:id值?
用 Vue 提供的钩子 created 来获取 -> 为什么? -> 因为我们需要用到this呀!
this.$route.params // {id: '1'}
一般跟路由相关的信息是放在
$route里边的(拿到路由路径信息之类的),而跟路由器相关的信息则放在$router里边(处理转发之类的)!
注意,如果你写的是:fuck,那么你就得这样做了:
this.$route.params // {fuck: '1'}
所以:id并不是固定的写法 -> 它相当于是给这个值一个名字!
方方的一个习惯,如果不确定一个东西是哪儿来的,那就把这个东西改成
fuck! -> 如我们不确定parms所返回的{id:'1'}里边的id是哪儿来的,于是就用了:fuck测试一下! -> 得出结论:{id:'1'}里边的id来自于路由配置里边的:id
透过这些 API ,「标签编辑页」就知道用户要编辑的是哪一个标签了!
话说,params为啥会有id这个参数?不需要声明它的类型吗?

Dictionary类型意味着params这个{}里边可以是任意的键值!
为什么this会有个$route的东西? -> 因为我们安装了一个库 vue-router

这两个属性 API 的作用 -> 无非就是在收集一些信息罢了!我们无须关心它们的源码实现!
💡:this.$router.push("/404") & this.$router.replace("/404") 的区别?
前者跳转后,不能点击<-回退到上一个页面,而后者则可以点击<-回退到上一个页面!
replace的效果:

💡:如何把「标签页」和「标签编辑页」关联起来?
代码:Demo
很简单,对「标签页」里边的每个item使用 router-link 包裹一下就好了
目前,每个item是这样的结构:ol > li
我们不能改成这样:ol > router-lin1k -> 因为ol的儿子只能是li,所以我们改ol为div就好了
所以,最后改成这样了:

图中那个
class是在a标签上的

总之,我们把div > router-link 看成是原先的 ol > li 就好了! -> 在样式方面都是用class来管控的,而不是用标签,所以更改标签基本上对样式无影响!
💡:#/labels是标签,tags也是标签,那么在命名方面能否统一起来?
💡:如何找到对称的 Icon ?
我们很难找到相匹配的 Icon,所以我们可以把right.svg上传到 iconfont.cn ,然后借助它的 svg 编辑器旋转修改!
我的做法是这样的(不用上传):

旋转处理:

方方的一个习惯:
在上传文件给iconfont.cn的时候,首先把源文件拷贝一份到桌面(桌面上的文件是不重要的!可以随便删!),然后再上传
💡:EditLabel.vue用到了类似Money.vue里边的那个Notes.vue,那么我们能否把这个Notes组件封装成更为通用的组件呢?
Money页面需要用到输入框,EditLabel页面同样也需要用到输入框
对于这整个记账应用来说,输入框都应该是同一个组件才对!而不是拷贝一份Notes组件代码,修改一下,扔到EditLabel.vue里边直接用就完事儿了……
EditLabel 里边使用 Notes 组件Notes组件 -> 用字段提示信息是由外界提供的Label.vue出问题了 -> 修复代码:Demo
💡:@Prop({required:true}) fieldName!: string;
你这样定义
fieldName属性,那么在传值时,你得这样来:field-name
这个字段是必填的 -> 加!是表示我不需要初始值,因为用户必填有一个值!
如果不加!,你必须给个初始值!不然,tsc会报错!
总之,!是非null和非undefined的类型断言!
另一种理解姿势:

➹:【前端资讯】TypeScript 2.7 发布 - 知乎
💡:@Prop() placeholder?: string
?用于属性定义时,表示该字段有可能不存在!而属性读取时,该值可能为空值(nullorundefined),所以需要做判断!
如果你用!,那么意味着你不传placeholder字段,它就是默认的undefined值!而我们并不想要这样,所以就用?
我测试了一下,不管用!还是?都是一样的!用户不传placeholder,那么这个Notes组件的placeholder属性就是undefined值了! -> 我还以为用了?,用户不传placeholder,那么这个Notes组件就咩有这个placeholder属性!实际上,不管传不传都会有这个属性!
➹:对比理解 Typescript 中的 as、问号与叹号 - 掘金
💡:关于Notes.vue这个名字?
其实这个Notes名字不怎么合适,因为它的功能就是个输入框,所以可以叫做EditItem or InputItem 之类的……
重命名这个Notes.vue(功能必须先完成,而且还得先提交一份代码,然后再考虑重命名):
在 VSCode 里边,全局搜索Notes这个名字,然后替换成FormItem就好了!(只改使用到的组件名以及class名,其它的方法名,字段名还是用notes)
把Button也改成通用组件! -> 为啥要改? -> 因为「标签页」有个「新建标签」的Button,而「标签编辑页」也有个叫「删除标签」的Button!
💡:新建了一个Button.vue,可是原生标签是button呀!难道这不会冲突吗?
不会的!因为一个是大写B,一个是小写b -> 最终会编译这个Button组件标签!
💡:改用为Button组件标签后,如何触发它的点击事件?
我们监听了大Button的点击事件,但用户在页面上点击的是编译过后的小button -> 用户点击小button的时候,触发大Button的点击事件!
小button:
<button class="button" @click="$emit('click',$event)">
<slot />
</button>
大Button:
<Button class="createTag" @click="createTag">新建标签</Button>
话说,如果还要监听其它事件呢? -> 那我岂不是还需要写很多次$emit()?
尤雨溪发明了另一种语法:
<!-- template -->
<button class="button">
<slot />
</button>
<!-- use -->
<Button class="createTag" @click.native="createTag">新建标签</Button>
这种姿势也是可以的!.native相当于是一种语法糖! -> 一种封装操作!
不过,这种姿势很少人知道,所以一般都会用上边那一种,即内部通知外部触发click事件!
总之,不管怎样,整体上看这个Button组件标签,我们都可以把它当作像使用普通的button原生标签这样去使用它!
一个权衡:
Button很容易会被使用到,那么我们是否可以全局引用它? -> 随便……
始终谨记:先搞定 HTML,再去写 CSS,最后再搞 JS
代码:Demo
💡:HTML 结构?

HTML 代码重复 2 次及以上,就应该考虑组件化!
💡:navBar的布局思路?
思路一:
左边Icon绝对定位,而title则是绝对居中 -> 为啥不左中右布局呢?因为右边没东西呀!不然,就很不对称了! -> 但这有个弊端,那就是Icon上下不好居中!

思路二:
flex布局! -> 还是用了左中右布局,因为Icon不好上下居中!
💡:关于方方写样式时的一个比较喜欢的习惯?

💡:方方在 CSS 的时候,其大脑在干什么?

从中可以看到Icon并咩有上下对齐! -> 利用 flex 提供的那几个属性可以实现上下居中!
💡:为什么会有个没有内容的标签:<div class="rightIcon"></div>?

💡:为什么方方写的 CSS 代码经常性的出现一个class为xxx-wrapper嵌套div?
因为不想直接在:
<FormItem field-name="标签名" placeholder="请输入标签名" />
上给背景色,而是给它的爸爸一个背景色!
为啥不叫container? -> 因为这单词比wrapper长!
💡:一个不成文的规定?

让一个元素看起来变高(用背景色衬托),可以给它的爸爸加个上下
padding,如Money.vue里边的Input框就是这样做的!
💡:button元素上下距离的处理?

具体测试效果:

写这个代码之前,一定要先把之前写的代码给提交了! -> 你做了一件事情就要
commit一次,而不是做了多件事之后再提交!
功能:把用户选择的标签展示到「标签编辑页」的输入框

思路:EditLabel.vue是FormItem.vue的父组件,所以我们只需要传一个value给FormItem就行了
💡:我们要让Input框里边的内容从外部传进来?(把用户选中的标签名传进来)
代码:Demo
💡:为什么要写单元测试?
你会经常性地改代码,改完之后,还得测试一下项目是否有报错! -> 如何测试呢? -> 让项目运行一下
而有了单元测试,就不用项目运行了,直接跑测试代码就行了!
💡:为什么不推荐用v-model?
我们给FormItem.vue一个Prop:
@Prop({ default: "" }) value!: string
结果在测试(输入内容)的时候报错了:

为啥会报错呢?(具体来说应该是警告)

所以对于Prop,那就不要用v-model了,如果是data,那倒是可以使用!
消除警告:

为了稳妥起见,我们对Prop加了个readonly,以防他人对该value属性在子作用域直接赋值!
@Prop({ default: "" }) readonly value!: string;
如果你不加的话,那么只能让控制台提示你这儿有错误了,如果加上的话,tsc 就能直接提示了有错误了!
我测试了一下,加了readonly,但是还是原来的v-model,结果tsc并没有爆红! -> 为啥呢? -> 因为 tsc 检查不到 template 里边的内容!
但不管怎样,加上readonly是很有必要的! -> 因为你有可能在方法里边出现对value赋值的情况! -> 而一旦出现了,就会爆红!
代码:Demo
💡:如何展示标签名?
代码:Demo
再次解释一下这样的代码:tag?: { id: string; name: string } = undefined
它表示tag这个属性是可选的,如果你要给tag值,而且你没有给?,那么你就不能给undefined值了!
总之,你加了?就相当于是把tag当作是union类型,即相当于你这样{ id: string; name: string } | undefined 给 tag 类型!
所以这就是我们可以给tag赋值一个undefined作为初始值的原因!


代码:Demo
💡:对于@update:value="update",为啥不这样写update($event)?
你不写 Vue 默认会传个事件对象!
不过,在这里第一个参数值,是内部抛出来的值!
💡:tagListModel.ts里边的update(id,name)的逻辑?
id和name(修改的值)id,那就不用更新name了! -> 返回'not found'id,而且这个name与表里的name重复了,那就返回'duplicated'id,而且这个name与表里的name不重复,那就修改这个id所对应的name值,然后save一下表 -> 返回'success'注意,我们只是更改id所对应的name -> 不会更改id!
这个代码写得不是很好,还有优化的可能!
💡:为啥要判断this.tag是否存在?


本质是删除一个数组元素(标签表
[{id,name},{},{}])

思路:还是交给Model层去处理 -> V和C层才不会去管数据处理呢!
💡:tagListModel.remove(this.tag.id)的逻辑?
要删谁? -> 传个id就知道要删谁了!

💡:id重复的问题

我们原先在创建标签的时候,判断标签名重复是根据name值来判断的,而不是根据id! -> 原先的做法是很取巧的!
要解决这个问题很简单,生成随机id!
💡:路由返回的问题

路由代码是这样的:
this.$router.back()
这个问题不需要解决,因为用户一般都是从「标签页」进入到「标签编辑页」的!可不会打开谷歌页面 -> 敲下「标签编辑页」的地址 -> 回车!

代码:Demo
一般来说,每个数据都应该有id -> 咩有id,就咩有办法定位到这个数据!
有以下两个原则:
id,就不要修改 -> 如果修改了,那么原先可以访问到的id就找不到标签了!id 不能重复! -> 如果重复,就不知道同样的id所对应的到底是哪个标签了!总之,请遵循以上这两个原则!
在 JS 里边,
Number类型的值就是有边界的(超出边界可视为爆栈),即达到多少位数字后,计算就不准确了! -> 当然,这并不是我们应该考虑的问题!因为标签不可能有那么多个!更何况如果真有那么多的话,还有BigInt这个数据类型呢!

💡:关于src旗下的lib目录?
lib目录 -> 存放着自己写给自己使用的库!
💡:为啥要在localStorage里边存id?
以防页面刷新后,id重置为0!
关于这行代码:
let id: number = parseInt(window.localStorage.getItem("_idMax") || "0") || 0;
第一个字符串"0"是保底值,保底第一次存储时,从0开始!
第二个数字0是为了防止其它人手贱搞了个'xxx'的值,如parseInt('xxx' || '0')的返回值是NaN!
💡:什么时候生成id?
临近用的时候再生成id!

💡:点击「删除标签」按钮,可以自动回到「标签页」?

目前,我们已经搞定了标签的查看、新建、编辑、删除!
💡:标签页与记账页 -> 标签同步的问题

需要用到vuex!
💡:原先用了「衣服」这个标签作为一笔账的标签,但是现在我改了这个标签名,我是否要同步更新一下?

export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能
一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用export关键字输出该变量。
// profile.js
export var firstName = 'Michael';
export var lastName = 'Jackson';
export var year = 1958;
等价姿势:
// profile.js
var firstName = 'Michael';
var lastName = 'Jackson';
var year = 1958;
// 导出列表
export { firstName, lastName, year };
import命令接受一对大括号,里面指定要从其他模块导入的变量名。大括号里面的变量名,必须与被导入模块(profile.js)对外接口的名称相同。
export命令后面,使用大括号指定所要输出的一组变量-> 我很好奇这是不是在导出一个对象?其实这并不是,因为你有见过这样的语法吗?
// 重命名导出
export { variable1 as name1, variable2 as name2, …, nameN };
如果是一个对象的话,那就不能用 as name1 这样的语法了,所以 export 后边跟着的东西是特殊的语法
而对于「导出列表」这种姿势,外部的import的姿势则是 import {xxx} from 'x.js'
千万不要认为 {xxx}是一种解析赋值呀!-> 这只是样子上看上去是一样罢了!(require那种姿势才是解析赋值)
总之,存在两种 exports 导出方式:
➹:export default function 和 export function 的区别_export__开猿笔记💤
➹:Module 的语法 - ECMAScript 6 入门
➹:巧用 ES 系列 4: TypeScript 中的问号 ? 与感叹号 ! 是什么意思? · Issue #9 · e2tox/blog