大规模应用TypeScript「2019 JSConf -Brie Bunge」

3,632 阅读21分钟

特别说明

这是一个由 simviso 团队对 JSConf.Hawaii 中关于大规模应用Typescript相关话题进行翻译的文档,内容并非直译,其中有一些是译者自身的思考。分享者是Brie Bunge,Airbnb高级前端工程师。

视频地址:大规模应用TypeScript「2019 JSConf -Brie Bunge」

视频翻译文字版权归 simviso 所有,微信公众号:Simviso未经授权严禁转载

本次参与翻译人员

前言

大家好,我的名字是 Bree,我在 Airbnb 工作。

在大公司中进行大的改革很难。这需要去说服很多人,同时又需要涉及大量的代码迁移。我想要与大家分享我们是如何将 TypeScript 应用到 Airbnb这个公司的日常开发中的。我感谢你们能在这里,我知道你们完全可以披着时髦的毛巾在海边娱乐。

我设想这里的每个人都会有这样一些问题, 你要为你的公司进行重大的变革, 同时这可能会被作为一个案例进行研究, 你现在是否在公司内部积极的推动将项目开发迁移到 TypeScript,为此我将提供一些技术和工具上的帮助。

或许,你已经听过一些 TypeScript 的内容,并且想了解更多。

首先,我们会介绍 TypeScript 是什么?规模化又意味着什么? 对于将Typescript规模化的过程又有什么建议? 基于这些问题和疑问,我会给出相应的解答, 我们该通过什么样的迁移策略将 JavaScript 逐步迁移到 TypeScript。

请大家快速举手示意一下以方便我知道大概有多少人之前使用过 TypeScript, cool,有这么多人。 还有一部分人没有举手, 那么,让我们快速介绍一下, 这样每个人都在同一起跑线上了。

类型检查

假如我们有这么一个 greeter 方法, 它接收一个name参数,然后返回 "hello" + name。 So,如果我们传入的是"JSConf",它会和气的说"hello JSConf"

将刚才的代码用 TypeScript 来表达就是这个样子, 可以看到他们很像, 唯一的区别是我们在它的参数这里使用了类型注释, 所以如果我们在我们的 TypeScript 项目中使用这个函数同时我们传入一个字符串,可以看到编译一切正常。

但是在这种情况下,如果我们传递的参数类型不是字符串,而是一个字符串数组,那么 TypeScript, 就会给我们一个错误。即这是一个字符串数组,函数接收的参数类型是 string,不支持该分配。

我们不需要再通过点击刷新页面这一流程来从我们的控制台中查看错误并确定该错误所发生的位置。 可以看到,在我们输入后,立即就从编辑器中得到了这个错误。

我们也可以表达其他对象类型。 这个接口描述了一个包含名字和姓氏的person对象。同时,你可以定义更复杂的构造类型。 Typescript通常带有一个编译器, 当出现问题时就可以立马告诉你。 它还有一个可以与编辑器挂钩的语言服务器,可以帮我们进行自动编译,提供重构相关提示等等

在这个例子中,我们已经在这个组件中导入了withStyles react 来进行类型绑定。 这样就可以自动导入它所需要的数百个CSS属性,包括内联文档。

神奇吧!我没必要来回浏览文档页面,我在编辑器中就可以得到这些所有。 通过在我们的代码中使用类型,我们可以做更多的事情。

我们只是稍微接触了下TypeScript的类型限制,但是可以通过这个来帮你捕获这种类型的错误,以及支持它的工具。

关于TypeScript的类型检查内容就到此,那关于规模化应用这一部分呢?

恩?这有个什么问题? 感谢Typescript帮我找出了这个错误。

如果你一时疏忽,输入了一个错误的变量,那你将会得到这样的一个错误提示,so,在 TypeScript 中真的会发生这样的事。 所以,让我们来修复下。

规模化应用

规模化会改变我们的交流方式。 我以前在小团队的时候,如果你想要使用TypeScript。是的,听起来很酷,那我们就用吧。

但当团队规模达到数百位工程师,同时代码量也越来越多,那么交流方式就要发生改变。 我们需要进行一些改变,即当我们想要在我们的主仓库中使用TypeScript的时候(这里指Powers | airbnb.com的主仓库), 同时让TypeScript成为前端主要开发语言。 这个改变影响的人越多,那么必须迁移的代码也就越多,那我们用数字来说明规模化意味着什么。

Airbnb(我的公司)拥有大量的JS代码, 在我们的主仓库中有两百万行以上的JS代码,以及100多个内部NPM包。 我们有几个分离的仓库,通过打包到内部的NPM注册中心,这样就可以跨仓库共享。

这真的有很多代码,我甚至能看到一些Backbone的代码。可以想一下JavaScript走过了多少年,它在Airbnb这里也走过了十多年。所以我们有大量的开发人员在维护这些代码,

目前公司有超过1300名开发人员,其中有200个是前端, 这些前端工程师大多数都参与了主仓库的贡献。

这些数字给我们展现了当时我们提议要使用TypeScript时所面临的大环境。 那么在这种规模下,我们当时是一个什么样子呢?

每个月,我们都会将所有的前端工程师聚在一起开个比较有意思的会,一起讨论新的前端技术和选型。 为了可以做到深思远虑,我们起草了提案,它对某个新技术进行了诸如优点、权衡、替代方案、针对退出方案的思考以及长期拥有者等方面概述。

大家会权衡这些提议的利弊, 我们会站在团队的角度去决定向前迈出这步是否有意义。 这确保了我们可以作为一个集思广益的团队,对所做的事情做出深思熟虑的决定,这样可以避免在没有正当技术理由的情况下就“上车”。

从2016年起,Airbnb 已经在一些小规模团队中探索使用 TypeScript。在2017年的前端调查中,静态类型系统呼声最高。 基于这个信号,Joe(第二排那个Joe)和我起草了一个关于TypeScript的提案,并将它交给前端工作组。 这项提案详细说明了为什么在Airbnb使用TypeScript是有意义的。

让我来讲讲主要的原因。 airbnb的使命是要建立一个让每一个人都感受到世界处处都是家(airbnb是一家旅游住宿的公司), 用户对我们的产品提出的每一个建议都能会让我们向着这个目标更进一步, 对于你正在开发的产品也是如此。

TypeScript可以帮助我们阻止bug的发生, TypeScript还为开发人员提供大量的生产力效益和工具,像我们之前看到,自动编译和重构。

使用TypeScript,工程师可以更安全快速的迁移代码。 在Airbnb我们引入了GraphQL 和Apollo,它可以使我们通过自定义的GraphQL模板来生成TypeScript类型

这意味着我们可以得到端到端之间的类型安全, 因为前端和后端所使用的数据类型共享了同一个事实上的定义源, 后端工程师能够在不影响客户端的情况下对API进行修改,而前端工程师则可以确信哪些数据将从服务器返回。

类型不匹配一直是我们的主要bug所在。 所以,这种端到端的类型安全性是一个主要的卖点。

这听起来很棒, 但对于我们的初步提案,还有很多问题和疑虑。 让我们更深入地了解其中的一些。 让我们来对TypeScript进行更深入的了解,

我们的主仓库依赖了一些我们内部的NPM包。 为了获得自动完成和类型检查的能力,我们需要先将它们转换成TypeScript,这样做是否值得? 这也是我们目前面临的困境,我们的TypeScript项目依赖于一个JS NPN包, 那我们该如何获取这个包的类型?

似乎需要先将包转换为 TypeScript。 这看起来像是需要首先将这个包转换为TypeScript 但这里有个问题,因为维护人员不允许我们对它做TS转换操作,可能他们也不情愿这么做。 因为在我们提案的早期,我们并不确定是否要一直按照这个提案走下去。

类型声明文件

但是从另一个层面来讲,我们使用TypeScript是为了可以让开发人员可以有更好的体验。 我们需要TypeScript提供的类型安全性, 那么我们该如何解决看似鸡和蛋的问题呢?

TypeScript有一个叫做声明文件的功能, 即一个以.d.ts为扩展名的文件,通过它我们可以为JavaScript文件定义类型。

让我们来看一个例子, 一起来看我们之前看到的greeter方法, 它上面是对应的.d.ts文件。 方法里没有实现细节, 它只描述了类型。 TypeScript将它们组合到一起,这样,在编译是使用这个声明文件,在运行时使用这个原生的JS文件。

所以,让我们回到最初的场景,看看声明文件是如何提供帮助的。 那么我们回到我们刚才提的问题(要不要一开始就转换),看看声明文件是如何提供帮助的。 当然,如果那个项目已转换为 TypeScript,我们就没有必要再生成一个.d.ts文件来作为TypeScript构建时的一部分(因为在使用TS编程的时候,要通过它对生成的JS进行调用)。

但我们认为这不止一种选择。 相反,我们可以将声明文件放在我们的 TypeScript 项目中。 另一个选择则是我们可以创建一个单独的NPM包,并将所有声明文件放入其中 。 这很棒,因为我们现在可以跨多个仓库共享这些声明文件。

DefinitelyTyped仓库

这是如何在react使用类型。 通过这些你可以在使用类似React时,进行相应的类型检查, 在安装React的同时你可以安装@types/react包

在这个@types/react包中针对React的5000个常用包做了类型声明, @types/react与其他5000个其他包都在DefinitelyTyped 这个仓库中,它由社区在维护

在我们的主仓库中,绝大多数的公共依赖都已经由DefinitelyTyped 做到了类型声明, 有活跃的社区氛围是TypeScript 的一个主要卖点。我们也回馈了一点力量,我相信在这个房间里也有人做出了贡献,谢谢。 这些公共的NPM包的类型声明已经有DefinitelyTyped在做了,但那些内部的包该怎么办?

我们自己安装了一个DefinitelyTyped镜像,在它通过创建一个单独的NPM域(@airbnb-types/*),这样,你只需要安装@airbnb-types即可 仓库的设置类似于类型的明确性。 这个仓库的设置与DefinitelyTyped类似 所以我们可以在里面添加并发布这些内部类型。 我们开源了一个starter 工具包,如果你又兴趣的话,可以来参与下。 它里面没有类型定义,它只是在教你如何进行一些配置以便于进行测试或者发布自己的类型定义。

那么 TypeScript 究竟能帮忙避免多少 bugs 呢? 近期,一个叫做“该不该做类型定义”的研究表明,在选择了TypeScript 的 github 仓库中,有 15% 的 bugs 得到了避免。 在我们内部,有一个记录生产环境事故的流程。

这个流程的本意并不是为了责怪谁,而是要从错误中进行学习,这样我们之后就不会再犯类似的错误。 所以我坐下来读了六个月的总结报告。 阅读这些总结报告很有意思, 我最喜欢的就是未捕获的异常以及危险的参数计算。 好吧,也许这些错误的名字并没有那么令人激动。 无论如何,我将这些错误归类为与 JavaScript 相关或无关,以此确定哪些错误可以通过使用Typescript 来避免

实例讲解

让我们一起看个例子,使用TypeScript会带来哪些帮助 我们对所分享的这个 Input 组件进行修改,通过一些设置来还原bug。 用户无法提交表单是因为它不再能通过验证。

这是所分享的 Input 组件更改前的简化版本, 它接收一个叫onBlur的变量,并将其直接传递给input元素。 所做的改变就是添加一个新的onBlur 事件处理。

但这里有一个不明显的bug, 你能发现它吗? 就是事件参数不再传递给onBlur prop。 这就导致在好几个不同仓库中都出现了这同一个问题 这里 Input 组件作为Redux Form的一部分进行使用,期望得到一个事件或值,以便验证正常工作。

如果没有该事件,表单将不再通过验证,这就意味着提交按钮始终处于禁用状态。 TypeScript在这里是如何帮到我们的?

从文档中我们可以看到Redux Form有类型捕获约束, onBlur 事件属性必须传递一个事件或值。

因此,如果我们使用了TypeScript下的Redux Form,那么在函数调用那里就可以看到一个当没有传递事件参数时所产生的错误。

其他特性

另一类常见的问题就是涉及严格的空值检查,即对使用属性来构造或尝试调用可能为null或undefined的内容进行检查。 你可能以前有见过这个错误,

另一种是类型不匹配。当我们尝试使用彼此不匹配的类型时,TypeScript就会提示我们。 所以现在我们对常见的检查出来的问题有了更好的理解,TypeScript可以帮助预防这种bug。

实际效果

那总体百分率是多少?(那个事故日志所表现的)。

38%!

我们发现有38%的事故导致了生产阶段的bug。 这些对我们用户产生实际影响的bug,可以使用TypeScript来阻止。 这对我们来说是个巨大的发现。 它有助于实现影响。 它有助于将这种(积极)效果转换到现实中。 我们复制了一些BUG事件,并向大家展示了TypeScript所给出的Error提示,然后对bug进行修复(也就是我们看到的bug提示灯泡灭掉了)

的确,我们也可以通过写测试代码来捕捉这些,但是通过静态类型检查可以额外增加一层保护层。 因此,如果你所在公司有类似的历史,那么你可能就有必要和懂Typescript小伙伴一起来看一下这些问题在你们的代码里所占的比例。

团队构建

那么团队是否希望切换到Typescript呢? 我们在几个团队试用了Typescript,专门针对之前没有使用过Typescript的团队来获取更多的使用反馈经验。 我们帮他们设置好 Typescript 环境,然后收集他们的反馈。

在用了一段时间后,我们向他们发送了一份调查问卷,询问他们是否应继续使用Typescript。 反馈结果是非常肯定的。 我们建议使用这种试用期(的形式,其实就是金丝雀模式)来测试新技术或模式。 前端工作组的开发也是基于这个形式来进行的,因为它是独立的,它可以很容易回滚到之前的状态。 这也对提案很有帮助,因为我们可以判断团队是否真的喜欢使用Typescript。

这里可能会有一些关于构建时间上的担忧, 我们测量了,发现并没有明显的影响。

我们在主仓库启用超过了500条eslint规则,也使用typescript eslint解析器,我们很高兴地发现它们中的大多数都可以工作。 如果我们在将来要弃用Typescript的话,我们可以剥离类型,并最终得到大致相同的JavaScript。

所以我们逐一记录、思考、跟进,并且针对提出的问题和担忧找到解决办法。 与批评者合作并听取他们的担忧对我们来说非常重要。 最后这些批评者中的大部分转而会支持我们, 我们的提案也从他们的反馈中变得更为健壮。

在充分解决了这些问题之后,针对所有前端工程师进行了我们是否应该采用Typescript的调查。 我们收到了肯定的回答之后,我们有足够的证据向前推进,并通过这项提案。

渐进迁移

在此基础上,我们逐步扩大了采用范围 此时,我们已经度过了试验阶段,这对于验证 typescript 和打好基础是很有用的。

我们已经解决了早期的矛盾,并改进了工具和文档,所以之后团队成员会更容易入门。

我们一直与 typescript 团队保持着持续联系, 并帮忙解决一些问题,比如,更好的默认属性优先级处理。

在这个阶段,我们自己内部的 typescript 社区也得到了发展。 但是大部分 Airbnb 的员工还不知道 typescript, 这也意味着更多的人可以去帮助和回答他们的问题。 接下来我们将会进入测试状态,团队可以选择使用它。 为了帮助团队,我们创建了内部文档和风格指南,并举办了一些学习课程。

我们建立了一个聊天组,一个内部的类Stack Overflow,谷歌Email主题,github组,来供组内成员交流。 我们想确保人们能得到他们需要的帮助, 最后一步是将 typescript 完全普及化。 此时就意味着它是稳定状态,每个人都应该开始使用它, 我们目前正在努力地去接近这个目标。 剩下的步骤就是巩固风格指南、文档、加强内部培训和迁移更多代码。 到目前为止,我们大约有50%的团队使用 typescript,在主仓库中,有10%的文件已经被转换成 typescript 通过这种渐进的方法,使团队迁移至 typescript 的过程更加顺畅。 如果从第一天开始就要求每个人应该使用TypeScript, 那么接下来的每个人都会遇到同样的问题。 相反,我们先在小范围内使用 typescript ,然后总结一些经验技巧,当我们准备把它大规模推广时,这些经验技巧也会用得上。

迁移策略

我们已经探索出了几种将代码迁移至 typescript 的方式。

我们最初的迁移策略的是混合使用 JavaScript 、TypeScript。 让我们看看,在主仓库中这个策略是如何进行的, 这是我在 airbnb.com 上找到的一个简化版本,并且给它们起了一个比较合理的名字, 所以这里不存在公司的隐私信息。 让我们放大homes 项目,看看使用混合策略转换它会是什么样子。

我们添加了一个typescript配置文件,并将各个文件从js重命名为ts或jsx重命名为tsx。 typescript报错了, 那我们动手去修复他们吧。 typescript 的一个很棒的特性是,在编译和运行之前,并不需要转换所有代码。 这个配置选项(allowJS)允许 javascript 和 typescript 文件共存, 在这一点上,我们可以看到网站仍能继续运行。 我们不需要暂停开发而去迁移整个项目,我们可以一步步来, 我们可以挨个迁移文件。 我们会重复这个过程,直到整个项目被迁移。

迁移技巧

在关于迁移的话题上,我想花些时间和大家分享一些我们认为有用的技巧。

第一个是$TSFixMe, 我们通过TypeScript的any类型添加了一个全局类型别名,这意味着它可以为任何类型。 我们将它称之为$TSFixedMe,表明在代码向TypeScript迁移完成后,再来将类型修正 平时最佳实践是避免使用any,因为它会造成类型安全丢失,但它在迁移过程中会很有帮助。

使用@ts-ignore注解可以做到忽略下一行错误。 正确地输入一个文件可能涉及一些深层依赖链解析(类似于复杂对象)。 我们可以尝试通过首先转换子文件来避免这种情况,但有时这是不可避免的。 因此,$TSFixedMe和@ts-ignore注解能够帮助拆分这些内容,同时则会增加这些检查工作。 这些都是暂时的,我们计划添加类型覆盖工具,并在后面我们改进类型时提供帮助

在JSX中,我们在React组件上使用propTypes 进行运行时类型检查。 在将jsx转换为tsx的时候,我们可以删除proptypes直接用typescript,也可以在proptypes基础上添加typescript 在我们所分享的react项目中,我们想保留传参类型,以便别人使用的时候仍然可以获得运行时检查。

为了避免重复两次类型声明,那就需要与这些类型保持同步。 我们创建了一个Props类型,通过它将给定的propTypesdefaultProps来派生出一个TypeScript类型。

这样,propTypesdefaultProps组合并得到这个最终类型。 如果你好奇它是如何工作的,你可以查看我在gist上分享的 代码片段

最近我们已经在使用修订迁移策略All-in TS进行实验, 让我们回过来在看看这个Homes项目,然后对它们进行使用all in策略,然后在看它工作怎么样。
我们从js形式的文件开始, 我们把所有的文件都改成TS形式的。 然后让项目编译,可能我们使用一些比我们想要的更宽松的类型。 但其实我们已经开启了TS最严格的检查配置。 然后我们接下来再继续改进类型, 移除ts fix语句,比如@ts-ignore(@ts-ignore 注释隐藏 .ts 文件中的错误), 这与js和ts混合策略相比起来有一些优势。

通过类型逐步改进比通过文件逐步改进更为简单(两种策略的对比), 如果你正在开发一个新功能,你只需要关注新添加的类型,然后简单的修复这个它即可。而不是先转换整个文件来修复所有错误,然后再添加你所需要类型。 不用重命名文件也意味着更方便查看。 有时候,如果一个文件在一次提交被重命名,然后在别的提交中修改。他们会在code review中单独出现,程序员必须要合在一起看才能知道变化了什么, 后一种策略还能清楚地知道缺少哪些类型。 TypeScript类型推导能力十分强大,我们可以在编写代码的时候大量使用它。

为了通过编译,有些文件需要进行一些TS Fixed, ts就可以推断出剩余部分。

还有就是开发者们可能有一个固定的思维模式, 他们并不会根据文件扩展名来切换思维,于是就出现了比如为什么我不能在这里添加类型,为什么我不能在那里得到编译错误的疑问(js和ts混用)。 那些类型在所有文件中都可以添加、使用、检查。

大型项目迁移策略

这听起来很不错,但是我们该如何迁移我们整个代码呢? 对于大规模代码修改而言,Codemod是一种十分强大的工具。 拿最简单的形式来说,就好比是我们在我们的项目中所使用的全局搜索和替换。 你也许在你之前的IDE里面干过这件事(全局查找和替换), 这些Codemod库可以通过正则来替换。 但它们很不稳定,可能会因细微的代码风格变化而终止。

或者我们可以使用之前某人已经讲过的抽象语法树, so,这就是这段代码用AST(抽象语法树)来表达的形式。 如图所示,左侧的代码都一 一对应着右侧抽象语法树上的节点, 所以为了好玩,我们想写一个Codemod来反转代码中的所有标识符。

我们将我们的代码作为输入参数, 根据这个创建出AST(抽象语法树), 修改AST树然后产生新的代码。

这里的关键是我们以编程方式进行此更改。 如果你手上需要修改的文件数并不多的话,我们可以一个个的去修改, 但如果一旦文件数量达到数千个以上,这种手动去修改的想法可能会令人感到十分心累。

因此我们Airbnb采用了Facebook的Jscodeshift来进行这种大量的代码重构, 这个转换库可以捕获我们刚刚对该ast进行的修改并且反转标识符。

我们找到与标识符对应的所有节点, 用名字反转,用新节点去替换这些节点, 然后得到新的代码。 Missy Elliott(歌手)也将会我们感到自豪, 所以我们反转了它。 大家笑了,很棒! 我在演讲就像Joy Division做的那样, 我们拿到了代码并且重新改装,找到了成员的标识符然后翻转它。yeah! astexplorer.net这个网站无法帮你掌握好说唱技巧,但可以帮助你查看你的Codemods。

在这个网页下,它有一个可以通过源代码输出对应的AST树的功能,以及在你对代码的改变同时反映到AST树上。 我也在DefinitelyTyped 这个库提交了关于Jscodeshift的pr,这样的话可以来降低大家在使用TypeScript与Codemod的交互门槛,

迁移模式

在将JavaScript代码迁移到typescript时,有这几种模式。

对于react组件,我们一次次的将静态类属性移动到class body里面。 创建一个PropsType表示react生命周期方法。 我们将它们编码为Codemod,以便我们可以在更多代码上重复运行它们。 我们通过使用一个叫作TS Migrate的工具来将它们进行打包, 这个工具的功能是传入一个JS项目,然后得到一个编译好的ts项目。

随着时间的推移,你仍然需要慢慢找到类型,但它为你提供了一个工作前提。 我们将此工具应用于我们的内部分享的React组件库,现在在我们的网站上已经频繁地使用。 我们有内部的类型定义库(DefinitelyTyped),但是因为react分享组件库的快速发展,所以做到与时俱进地更新太难了, 所以,我们想直接从源码类型出发。所以这是我们迁移TS的第一个目标。

我们已经将超过3万行以上的代码都进行了TypeScript化。你们可能认为我们整个团队花了四周的时间才能完成这个。事实上,我们用了一套我们自己的Codemod工具,仅需数分钟就完成了。 我们使用来自proptypes的类型信息,同时使用$TSFixMe,并基于此来继续进行优化, 但即便如此,我们也生成了有意义的TypeScript声明文件,这样我们可以在其他仓库中进行使用。

在这个例子中,我们可以看到需要合并的代码行数多的有点可怕, 通过使用TypeScript编译器以及在可视化回归测试的帮助下,我们将在CI上运行测试。 通过这些测试我可以很自信的说,我的这些改变不会对原来的系统产生任何不利的影响, 当然我们还能确保我们的站点仍旧在正常工作。 并不需要回滚代码, 不可思议。 我们现在已经将TS Migrate运用在其它的一些地方,同时也在不断优化和迭代它。我们计划在以后会将它运用于更多的代码上(JS代码), 我们打算之后将它开源,这样你们也能将它运用在你们的自己的代码迁移上。

心得感想

感谢你们听我讲了这么久, 我想给你一些我们可以从typescript迁移中得出关键点,并且是可以广泛应用的。

在大型组织中实施变革可能是一项挑战,但强有力的事实依据和相关问题和担忧的解决,可以使我们信服。 采用逐步变化的方式有助于减少摩擦并证明其价值。 一条明确的迁移路线能帮助团队更好的转向新的模式,同时好的工具也能促进这个过渡的过程, 我之所以开始这个工作,是因为之前有个产品组对我的工具感到失望。 当我得知公司内部其他人也有这种改变的想法的时候,我便与他们合作并将之进行下去。

与其怨天尤人接受现状, 只有通过行动才能发生积极的改变。 所以我鼓励你去追求那些可以让你对组织充满激情的事情,让你和你周围的人的生活变得更好。

结语

感谢大家的倾听,同时感谢AirBnb为这个项目作出贡献的每一个人,尤其是台下的Joe和Mohsen。还有对其他一些优秀的Airbnb工程师表示感谢。 我手上也有些TypeScript主题的小便签和一些钥匙链,先到先得,只限前30人哦!

感谢大家的倾听。

视频中涉及的相关链接