pingan8787

pingan8787 查看完整档案

厦门编辑黎明大学  |  console.log 编辑EFT  |  FE 编辑 www.pingan8787.com 编辑
编辑

个人博客:http://www.pingan8787.com
Github:https://github.com/pingan8787

微信公众号【前端自习课】和千万网友一起,每日清晨,享受一篇前端优秀文章。

目前已连续推送文章 730+ 天,愿每个人的初心都能一直坚持下去!

个人动态

pingan8787 收藏了文章 · 10月30日

中了源码的毒,给你一副良药

近期阿宝哥在团队内搞了一个 如何读源码 的专题,主要目的是让团队的小伙伴们了解读源码的思路与技巧。在此期间,阿宝哥也写了 77.9K 的 Axios 项目有哪些值得借鉴的地方从 12.9K 的前端开源项目我学到了啥如何让你的 Express 飞起来 三篇源码解析的文章。其中前两篇在 掘金社区 获得不错的评价,平均 705+ 个 👍,所以阿宝哥就想写一篇文章来分享一下本人读源码的思路、技巧与工具。

好的,让我们开始出发吧!在进入正题之前,我们先来个读源码前的 灵魂四连问 热热身。

一、灵魂四连问

1.1 为什么要读源代码

1.2 如何选择项目

1.3 如何阅读源码

1.4 有实际的案例么

既然前两篇文章比较受大家喜欢,接下来阿宝哥就以最受欢迎的 Axios 为例,来分享一下读源码的思路与技巧。

二、如何品读 Axios?

2.1 走进 Axios

Axios 是一个基于 Promise 的 HTTP 客户端,同时支持浏览器和 Node.js 环境。它是一个优秀的 HTTP 客户端,被广泛地应用在大量的 Web 项目中。

由上图可知,Axios 项目的 Star 数为 78.1K,Fork 数也高达 7.3K,是一个很优秀的开源项目,所以值得大家细细品读。

2.2 发现 Axios 的美

在确认 Axios 为 “追求目标” 之后,下一步我们就需要来发现它身上的优点(特性):

每个人对 “美” 都有不同的看法,对于阿宝哥来说,我看中了图中已选中的三点。因此,它们也很光荣地成为读源码的三个切入点。当然切入点也不是越多越好,可以先找自己最感兴趣的地方作为切入点。需要注意的是,如果切入点之间有关联关系的话,建议做个简单的排序。

2.3 感受 Axios 的美

选择切入点之后,我们就可以开始逐一感受 Axios 的设计之美。以 能够拦截请求与响应 这个切入点为例,首先我们就会接触到 拦截器 的概念。所以我们需要先了解拦截器是什么、拦截器有什么作用以及如何使用拦截器,这里我们可以从项目的 官方文档 或者项目中的 README.md 文档入手。

2.3.1 拦截器的作用

Axios 提供了请求拦截器和响应拦截器来分别处理请求和响应,它们的作用如下:

  • 请求拦截器:该类拦截器的作用是在请求发送前统一执行某些操作,比如在请求头中添加 token 字段。
  • 响应拦截器:该类拦截器的作用是在接收到服务器响应后统一执行某些操作,比如发现响应状态码为 401 时,自动跳转到登录页。
2.3.2 拦截器的使用
// 添加请求拦截器 —— 处理请求配置对象
axios.interceptors.request.use(function (config) {
  config.headers.token = 'added by interceptor';
  return config;
});

// 添加响应拦截器 —— 处理响应对象
axios.interceptors.response.use(function (data) {
  data.data = data.data + ' - modified by interceptor';
  return data;
});

axios({
  url: '/hello',
  method: 'get',
}).then(res =>{
  console.log('axios res.data: ', res.data)
});

在了解完拦截器的作用和用法之后,我们就会把焦点聚焦到 axios 对象,因为注册拦截器和发送请求都与它有紧密的联系。不过在看具体源码之前,阿宝哥建议先对功能点做一下梳理。以下是阿宝哥的分析思路:

Axios 的作用是用于发送 HTTP 请求,请求拦截器和响应拦截器分别对应于 HTTP 请求的不同阶段,它们的本质是一个实现特定功能的函数。这时我们就可以按照功能把发送 HTTP 请求拆解成不同类型的子任务,比如有 用于处理请求配置对象的子任务用于发送 HTTP 请求的子任务用于处理响应对象的子任务。当我们按照指定的顺序来执行这些子任务时,就可以完成一次完整的 HTTP 请求。

既然已经提到了任务,我们就会联想到任务管理系统的基本功能:任务注册、任务编排(优先级排序)和任务调度等。因此我们就可以考虑从 任务注册、任务编排和任务调度 三个方面来分析 Axios 拦截器的实现。

2.3.3 任务注册
// 添加请求拦截器 —— 处理请求配置对象
axios.interceptors.request.use(function (config) {
  config.headers.token = 'added by interceptor';
  return config;
});

// 添加响应拦截器 —— 处理响应对象
axios.interceptors.response.use(function (data) {
  data.data = data.data + ' - modified by interceptor';
  return data;
});

lib/axios.js 路径下,我们可以找到 axios 对象的定义。为了能直观地了解对象之间的关系,阿宝哥建议大家在读源码的过程中,多动手画画图。比如阿宝哥使用下图来总结一下 Axios 对象与 InterceptorManager 对象的内部结构与关系:

2.3.4 任务编排

现在我们已经知道如何注册拦截器任务,但仅仅注册任务是不够,我们还需要对已注册的任务进行编排,这样才能确保任务的执行顺序。

同样对于任务编排,也可以使用图的形式来展现任务编排后的结果。 这里有一个小技巧,就是可以采用对比的形式来展示任务编排后的结果,这样子会更加清楚任务编排的处理逻辑。

2.3.5 任务调度

任务编排完成后,要发起 HTTP 请求,我们还需要按编排后的顺序执行任务调度。

需要注意的是:在阅读源码过程中,不要太在意细节。比如在研究 Axios 拦截器原理时,不需要再深入了解 dispatchRequest 背后的具体实现,只需知道该方法用于实现发送 HTTP 请求即可,这样才不会把整个线路拉得太长。

在分析完特定的功能点之后,也许你已经读懂的具体的源代码。但阿宝哥觉得这并不是最重要的,更重要的是思考它的设计思想,这样设计有什么好处,对于我们有没有什么值得借鉴和学习的地方。比如参考 Axios 拦截器的设计模型,我们就可以抽出以下通用的任务处理模型:

上面阿宝哥以 Axios 的拦截器为例,分享了读 Axios 源码的思路与技巧。接下来阿宝哥来分享一些读源码的建议和辅助工具。

三、读源码的建议

四、读源码辅助工具

如果你对下列辅助工具感兴趣的话,可以通过以下图片来源的链接,来直接打开每个工具的在线地址。

(图片来源:https://www.processon.com/vie...

五、总结

其实除了上面的内容之外,读优秀开源项目还有挺多值得关注的地方。阿宝哥在学习 BetterScroll 项目源码时,总结了一张思维导图

(图片来源:https://www.processon.com/vie...

下面阿宝哥用一张图来总结一下 axiosbetter-scroll 这两个开源项目的学习路线:

1、Axios 项目的切入点是从 Github 中的功能特性中筛选出来的;

2、BetterScroll 的切入点是从掘金上 BetterScroll 2.0 发布:精益求精,与你同行 这篇文章中介绍的功能亮点中找到的。

除此之外,阿宝哥也来简单总结一下本文介绍的读源码的思路与技巧:

  • 站在巨人的肩膀,提前阅读一些项目相关的优质文章;
  • 汇总学习或工作中遇到的问题,带着问题进行源码学习;
  • 明确阅读源码的主线或切入点;
  • 尽可能从简单的示例出发来分析每个功能点;
  • 先梳理清楚主要流程,不要太在意细节,避免把整个线路拉得太长;
  • 在阅读源码过程中,要多多画图,这样理解起来会更加直观。

本文阿宝哥分享了个人读源码的思路、技巧与工具,希望阅读完本文能对你有所启发或帮助。如果你有读源码更好的思路与技巧,欢迎随时跟阿宝哥交流哈。有写得不好的地方,也请各位见谅哈。

六、参考资源

查看原文

pingan8787 赞了文章 · 10月30日

中了源码的毒,给你一副良药

近期阿宝哥在团队内搞了一个 如何读源码 的专题,主要目的是让团队的小伙伴们了解读源码的思路与技巧。在此期间,阿宝哥也写了 77.9K 的 Axios 项目有哪些值得借鉴的地方从 12.9K 的前端开源项目我学到了啥如何让你的 Express 飞起来 三篇源码解析的文章。其中前两篇在 掘金社区 获得不错的评价,平均 705+ 个 👍,所以阿宝哥就想写一篇文章来分享一下本人读源码的思路、技巧与工具。

好的,让我们开始出发吧!在进入正题之前,我们先来个读源码前的 灵魂四连问 热热身。

一、灵魂四连问

1.1 为什么要读源代码

1.2 如何选择项目

1.3 如何阅读源码

1.4 有实际的案例么

既然前两篇文章比较受大家喜欢,接下来阿宝哥就以最受欢迎的 Axios 为例,来分享一下读源码的思路与技巧。

二、如何品读 Axios?

2.1 走进 Axios

Axios 是一个基于 Promise 的 HTTP 客户端,同时支持浏览器和 Node.js 环境。它是一个优秀的 HTTP 客户端,被广泛地应用在大量的 Web 项目中。

由上图可知,Axios 项目的 Star 数为 78.1K,Fork 数也高达 7.3K,是一个很优秀的开源项目,所以值得大家细细品读。

2.2 发现 Axios 的美

在确认 Axios 为 “追求目标” 之后,下一步我们就需要来发现它身上的优点(特性):

每个人对 “美” 都有不同的看法,对于阿宝哥来说,我看中了图中已选中的三点。因此,它们也很光荣地成为读源码的三个切入点。当然切入点也不是越多越好,可以先找自己最感兴趣的地方作为切入点。需要注意的是,如果切入点之间有关联关系的话,建议做个简单的排序。

2.3 感受 Axios 的美

选择切入点之后,我们就可以开始逐一感受 Axios 的设计之美。以 能够拦截请求与响应 这个切入点为例,首先我们就会接触到 拦截器 的概念。所以我们需要先了解拦截器是什么、拦截器有什么作用以及如何使用拦截器,这里我们可以从项目的 官方文档 或者项目中的 README.md 文档入手。

2.3.1 拦截器的作用

Axios 提供了请求拦截器和响应拦截器来分别处理请求和响应,它们的作用如下:

  • 请求拦截器:该类拦截器的作用是在请求发送前统一执行某些操作,比如在请求头中添加 token 字段。
  • 响应拦截器:该类拦截器的作用是在接收到服务器响应后统一执行某些操作,比如发现响应状态码为 401 时,自动跳转到登录页。
2.3.2 拦截器的使用
// 添加请求拦截器 —— 处理请求配置对象
axios.interceptors.request.use(function (config) {
  config.headers.token = 'added by interceptor';
  return config;
});

// 添加响应拦截器 —— 处理响应对象
axios.interceptors.response.use(function (data) {
  data.data = data.data + ' - modified by interceptor';
  return data;
});

axios({
  url: '/hello',
  method: 'get',
}).then(res =>{
  console.log('axios res.data: ', res.data)
});

在了解完拦截器的作用和用法之后,我们就会把焦点聚焦到 axios 对象,因为注册拦截器和发送请求都与它有紧密的联系。不过在看具体源码之前,阿宝哥建议先对功能点做一下梳理。以下是阿宝哥的分析思路:

Axios 的作用是用于发送 HTTP 请求,请求拦截器和响应拦截器分别对应于 HTTP 请求的不同阶段,它们的本质是一个实现特定功能的函数。这时我们就可以按照功能把发送 HTTP 请求拆解成不同类型的子任务,比如有 用于处理请求配置对象的子任务用于发送 HTTP 请求的子任务用于处理响应对象的子任务。当我们按照指定的顺序来执行这些子任务时,就可以完成一次完整的 HTTP 请求。

既然已经提到了任务,我们就会联想到任务管理系统的基本功能:任务注册、任务编排(优先级排序)和任务调度等。因此我们就可以考虑从 任务注册、任务编排和任务调度 三个方面来分析 Axios 拦截器的实现。

2.3.3 任务注册
// 添加请求拦截器 —— 处理请求配置对象
axios.interceptors.request.use(function (config) {
  config.headers.token = 'added by interceptor';
  return config;
});

// 添加响应拦截器 —— 处理响应对象
axios.interceptors.response.use(function (data) {
  data.data = data.data + ' - modified by interceptor';
  return data;
});

lib/axios.js 路径下,我们可以找到 axios 对象的定义。为了能直观地了解对象之间的关系,阿宝哥建议大家在读源码的过程中,多动手画画图。比如阿宝哥使用下图来总结一下 Axios 对象与 InterceptorManager 对象的内部结构与关系:

2.3.4 任务编排

现在我们已经知道如何注册拦截器任务,但仅仅注册任务是不够,我们还需要对已注册的任务进行编排,这样才能确保任务的执行顺序。

同样对于任务编排,也可以使用图的形式来展现任务编排后的结果。 这里有一个小技巧,就是可以采用对比的形式来展示任务编排后的结果,这样子会更加清楚任务编排的处理逻辑。

2.3.5 任务调度

任务编排完成后,要发起 HTTP 请求,我们还需要按编排后的顺序执行任务调度。

需要注意的是:在阅读源码过程中,不要太在意细节。比如在研究 Axios 拦截器原理时,不需要再深入了解 dispatchRequest 背后的具体实现,只需知道该方法用于实现发送 HTTP 请求即可,这样才不会把整个线路拉得太长。

在分析完特定的功能点之后,也许你已经读懂的具体的源代码。但阿宝哥觉得这并不是最重要的,更重要的是思考它的设计思想,这样设计有什么好处,对于我们有没有什么值得借鉴和学习的地方。比如参考 Axios 拦截器的设计模型,我们就可以抽出以下通用的任务处理模型:

上面阿宝哥以 Axios 的拦截器为例,分享了读 Axios 源码的思路与技巧。接下来阿宝哥来分享一些读源码的建议和辅助工具。

三、读源码的建议

四、读源码辅助工具

如果你对下列辅助工具感兴趣的话,可以通过以下图片来源的链接,来直接打开每个工具的在线地址。

(图片来源:https://www.processon.com/vie...

五、总结

其实除了上面的内容之外,读优秀开源项目还有挺多值得关注的地方。阿宝哥在学习 BetterScroll 项目源码时,总结了一张思维导图

(图片来源:https://www.processon.com/vie...

下面阿宝哥用一张图来总结一下 axiosbetter-scroll 这两个开源项目的学习路线:

1、Axios 项目的切入点是从 Github 中的功能特性中筛选出来的;

2、BetterScroll 的切入点是从掘金上 BetterScroll 2.0 发布:精益求精,与你同行 这篇文章中介绍的功能亮点中找到的。

除此之外,阿宝哥也来简单总结一下本文介绍的读源码的思路与技巧:

  • 站在巨人的肩膀,提前阅读一些项目相关的优质文章;
  • 汇总学习或工作中遇到的问题,带着问题进行源码学习;
  • 明确阅读源码的主线或切入点;
  • 尽可能从简单的示例出发来分析每个功能点;
  • 先梳理清楚主要流程,不要太在意细节,避免把整个线路拉得太长;
  • 在阅读源码过程中,要多多画图,这样理解起来会更加直观。

本文阿宝哥分享了个人读源码的思路、技巧与工具,希望阅读完本文能对你有所启发或帮助。如果你有读源码更好的思路与技巧,欢迎随时跟阿宝哥交流哈。有写得不好的地方,也请各位见谅哈。

六、参考资源

查看原文

赞 16 收藏 11 评论 4

pingan8787 赞了文章 · 10月12日

228页的 《重学TS》PDF 终于来了,一份值得一读的 TS 学习资料

228页的《重学TS》 PDF 出炉了,你值得拥有

《重学TS v1.0》 PDF 是今年阿宝哥发布的第三本电子书,前两本分别是《前端进阶篇 v1.1》(下载量近 5900) 和《了不起的 TS 和 Deno》(下载量近 2100),这里衷心感谢大家对阿宝哥的认可与支持。

在学习 TS 的过程中,阿宝哥发现阅读优秀的 TS 开源项目是一种不错的进阶方式。因此在团队内策划了 TS 项目源码学习的专题,目前已经学完 4 个开源项目。以下是阿宝哥在学习 better-scroll(13.2K Stars) 开源项目整理的思维导图,感兴趣的小伙伴可以了解一下。

image

图片来源:https://www.processon.com/vie...

好的,下面我们开始步入正题,首先阿宝哥先来个简单的自我介绍。

一、前言

大家好,我是阿宝哥。首先简单介绍下我自己,2017 年 3 月份开始在思否写 Angular 修仙之路专栏,目前已输出 149 篇原创文章。曾获得思否年度优秀文章作者及两季 Top Writer。平常活跃在各个开发社区,这里分享主要的社区地址:

1、掘金(LV5)https://juejin.im/user/764915...

2、思否(12.7K)https://segmentfault.com/a/11...

3、个人博客https://www.semlinker.com/

接着给大家分享本人 Github 上的 Awesome TypeScript 项目:Awesome TypeScript(1.9K)https://github.com/semlinker/...

二、简介

image

2.1 本书适合人群

① 具有一定的编程基础的 Web 开发者

② 想要学习 TypeScript 的初学者

③ 对 TypeScript 高级应用和设计模式感兴趣的开发者

2.2 我能学到什么

① 掌握 TypeScript 入门相关的基础知识

② 掌握 TypeScript 泛型、装饰器等高级特性

③ 掌握常用的九种设计模式及其 TypeScript 实现

④ 了解微内核架构和依赖注入的概念与实现

⑤ 了解编写高效 TypeScript 代码的一些建议

2.3 本书阅读建议

本书阿宝哥为了让大家能更好地学习每个章节的知识点,精心准备了大量的配图及示例。在学习的新知识的过程中,建议小伙伴们尽量亲自动手试一下,在学完相关的知识点可以考虑对所学的知识点进行总结和输出,可以写学习笔记、文章或跟团队内的成员分享。当然,在写作过程中,如果遇到问题,欢迎跟阿宝哥一起交流技术问题或写作技巧。

2.4 PDF 下载

原创不易,下载前,阿宝哥希望能得到你的一点鼓励,点赞、在看、分享或留言都可以。你们的认可与鼓励,是我不断前进的动力。为了感谢大家对阿宝哥的支持,阿宝哥准备了 5 个小红包,留言点赞数前 2 名,每人 1 个 10.24 元小红包🧧,点在看随机抽取 3 名,每人 1 个 8.88 元的小红包🧧。

2.4.1 获取方式

① 扫描下方二维码加 “重学TS” 学习群,凡进群者都送书籍一本。另外,对于入群的小伙伴,阿宝哥还会额外赠送《前端进阶篇 v1.1》和 《了不起的 TS 和 Deno》这两本 PDF 电子书,感兴趣的小伙伴不要错过哟。

② 扫描下方二维码,添加阿宝哥微信(semlinker)获取 PDF 书籍。

③ 全栈修仙之路公众号内回复 ts,你将获得一个 <<重学TS v1.0>> 电子书的下载链接。

image

另外,如果你在学习、成长过程中遇到什么问题,也可以添加我的微信一起交流。

三、结尾

至此本书的内容已经介绍完了,非常感谢你的阅读与支持。由于作者水平有限,书中可能会有一些描述不准确内容或出现一些错别字,请大家多多包涵。欢迎小伙伴把出现的问题反馈给我,在这本书后续版本中,会感谢帮忙勘误的小伙伴们。

2020 年 8 月 1 号(建军节),阿宝哥的第一本电子书 <<前端进阶篇>> 1.0 版本终于出来了。 2020 年 8 月 22 号,该书的 1.1 版本也按照原定的计划完成了,又是一个小小的里程碑。 2020 年 10 月 12 号,阿宝哥的第三本电子书《重学TS》1.0 版本也出来了。

《重学TS》阿宝哥还会继续更新,不断地输出有价值的内容。如果你有好的建议,欢迎小伙伴们给我留言哟。 如果你还想学习 TS 方面的其它知识,可以关注阿宝哥的 “全栈修仙之路”,阅读近 50 篇的 TS 系列教程,还会不断更新哟。最后感谢 安总阿浪小傅哥达达前端 等其他号主的支持与帮助。

查看原文

赞 21 收藏 7 评论 6

pingan8787 赞了文章 · 10月10日

手把手教你搭建React组件库(超详细)

在线预览:戳我 😘

本地预览:

git clone git@github.com:worldzhao/react-ui-library-tutorial.git
cd react-ui-library-tutorial
yarn
yarn start

按顺序执行完命令后,即可在 localhost:3000 端口看到以下内容:

preview.png

概览

本文包含以下内容:

  • prepare: 组件库前期开发准备工作。eslint/commit lint/typescript等等;
  • dev: 使用docz进行开发调试以及文档编写;
  • build: umd/cjs/esm、types、polyfill 以及按需加载;
  • test: 组件测试;
  • release: 组件库发布流程;
  • deploy: 使用now部署文档站点,待补充;
  • other: 使用plop.js快速创建组件模板。

如果本文帮助到了你请给仓库 一颗 ✨✨。

如果有错误烦请在评论区指正交流,谢谢。

准备工作

初始化项目

新建一个happy-ui文件夹,并初始化。

mkdir happy-ui

cd happy-ui

npm init --y

mkdir components && cd components && touch index.ts # 新建源码文件夹以及入口文件

代码规范

此处直接使用@umijs/fabric的配置。

yarn add @umijs/fabric --dev

yarn add prettier --dev # 因为@umijs/fabric没有将prettier作为依赖 所以我们需要手动安装

.eslintrc.js

module.exports = {
  extends: [require.resolve('@umijs/fabric/dist/eslint')],
};

.prettierrc.js

const fabric = require('@umijs/fabric');

module.exports = {
  ...fabric.prettier,
};

.stylelintrc.js

module.exports = {
  extends: [require.resolve('@umijs/fabric/dist/stylelint')],
};

想自行配置的同学可以参考以下文章:

Commit Lint

进行pre-commit代码规范检测。

yarn add husky lint-staged --dev

package.json

"lint-staged": {
  "components/**/*.ts?(x)": [
    "prettier --write",
    "eslint --fix",
    "git add"
  ],
  "components/**/*.less": [
    "stylelint --syntax less --fix",
    "git add"
  ]
},
"husky": {
  "hooks": {
    "pre-commit": "lint-staged"
  }
}

进行 Commit Message 检测。

yarn add @commitlint/cli @commitlint/config-conventional commitizen cz-conventional-changelog --dev

新增.commitlintrc.js写入以下内容

module.exports = { extends: ['@commitlint/config-conventional'] };

package.json 写入以下内容:

// ...
"scripts": {
  "commit": "git-cz",
}
// ...
"husky": {
  "hooks": {
    "commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
    "pre-commit": "lint-staged"
  }
},
"config": {
  "commitizen": {
    "path": "cz-conventional-changelog"
  }
}

后续使用 yarn commit 替代 git commit生成规范的 Commit Message,当然为了效率你可以选择手写,但是要符合规范。

TypeScript

yarn add typescript --dev

新建tsconfig.json并写入以下内容

{
  "compilerOptions": {
    "baseUrl": "./",
    "target": "esnext",
    "module": "commonjs",
    "jsx": "react",
    "declaration": true,
    "declarationDir": "lib",
    "strict": true,
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "resolveJsonModule": true
  },
  "include": ["components", "global.d.ts"],
  "exclude": ["node_modules"]
}

测试

components文件夹下新建alert文件夹,目录结构如下:

alert
    ├── alert.tsx           # 源文件
    ├── index.ts            # 入口文件
    ├── interface.ts        # 类型声明文件
    └── style
        ├── index.less      # 样式文件
        └── index.ts        # 样式文件里为什么存在一个index.ts - 按需加载样式 管理样式依赖 后面章节会提到

安装React相关依赖:

yarn add react react-dom @types/react @types/react-dom --dev # 开发时依赖,宿主环境一定存在

yarn add prop-types            # 运行时依赖,宿主环境可能不存在 安装本组件库时一起安装
此处依旧安装了prop-types这个库,因为无法保证宿主环境也使用typescript,从而能够进行静态检查,故使用prop-types保证javascript用户也能得到友好的运行时报错信息。

components/alert/interface.ts

export type Kind = 'info' | 'positive' | 'negative' | 'warning';
export type KindMap = Record<Kind, string>;

export interface AlertProps {
  /**
   * Set this to change alert kind
   * @default info
   */
  kind?: 'info' | 'positive' | 'negative' | 'warning';
}

components/alert/alter.tsx

import React from 'react';
import t from 'prop-types';

import { AlertProps, KindMap } from './interface';

const prefixCls = 'happy-alert';

const kinds: KindMap = {
  info: '#5352ED',
  positive: '#2ED573',
  negative: '#FF4757',
  warning: '#FFA502',
};

const Alert: React.FC<AlertProps> = ({ children, kind = 'info', ...rest }) => (
  <div
    className={prefixCls}
    style={{
      background: kinds[kind],
    }}
    {...rest}
  >
    {children}
  </div>
);

Alert.propTypes = {
  kind: t.oneOf(['info', 'positive', 'negative', 'warning']),
};

export default Alert;

components/alert/index.ts

import Alert from './alert';

export default Alert;

export * from './interface';

components/alert/style/index.less

@popupPrefix: happy-alert;

.@{popupPrefix} {
  padding: 20px;
  background: white;
  border-radius: 3px;
  color: white;
}

components/alert/style/index.ts

import './index.less';

components/index.ts

export { default as Alert } from './alert';
此处组件参考的docz项目typescript以及less示例。

git 一把梭,可以看到控制台已经进行钩子检测了。

git add .

yarn commit  # 或 git commit -m'feat: chapter-1 准备工作'

git push

准备工作完成。代码可以在仓库chapter-1分支获取,若存在与本文内容不符的地方,以master分支以及文章为准。

开发与调试

本节解决开发组件时的预览以及调试问题,顺路解决文档编写。

此处选择docz来辅助预览调试。

docz基于MDX(Markdown + JSX),可以在 Markdown 中引入 React 组件,使得一边编写文档,一边预览调试成为了可能。而且得益于 React 组件生态,我们可以像编写应用一般编写文档,不仅仅是枯燥的文字。docz 也内置了一些组件,比如<Playground>

安装 docz 以及自定义配置

yarn add docz --dev

yarn add rimraf --dev # 清空目录的一个辅助库

增加 npm scriptspackage.json

"scripts": {
  "dev": "docz dev", // 启动本地开发环境
  "start": "npm run dev", // dev命令别名
  "build:doc": "rimraf doc-site && docz build", // 后续会配置打包出来的文件目录名为doc-site,故每次build前删除
  "preview:doc": "docz serve" // 预览文档站点
},
注意:本节所有操作都是针对站点应用。打包指代文档站点打包,而非组件库。

新建doczrc.js配置文件,并写入以下内容:

doczrc.js

export default {
  files: './components/**/*.{md,markdown,mdx}', // 识别的文件后缀
  dest: 'doc-site', // 打包出来的文件目录名
  title: 'happy-ui', // 站点标题
  typescript: true, // 组件源文件是通过typescript开发,需要打开此选项
};

由于使用了less作为样式预处理器,故需要安装 less 插件。

yarn add less gatsby-plugin-less --dev

新建gatsby-config.js,并写入以下内容:

gatsby-config.js

module.exports = {
  plugins: ['gatsby-theme-docz', 'gatsby-plugin-less'],
};

编写文档

新建components/alert/index.mdx,并写入以下内容:

---
name: Alert 警告提示
route: /Alert
menu: 组件
---

import { Playground } from 'docz'; import Alert from './alert'; // 引入组件 import './style'; // 引入组件样式

# Alert 警告提示

警告提示,展现需要关注的信息。

## 代码演示

### 基本用法

<Playground>
  <Alert kind="warning">这是一条警告提示</Alert>
</Playground>

## API

| 属性 | 说明     | 类型                                         | 默认值 |
| ---- | -------- | -------------------------------------------- | ------ |
| kind | 警告类型 | 'info'/'positive'/'negative'/'warning'非必填 | 'info' |

执行脚本命令:

yarn start # or yarn dev

可以在localhost:3000看到如下页面 :

文档站点

现在可以在index.mdx中愉快地进行文档编写和调试了!

优化文档编写

代码演示部分的demo较多(比如基本用法、高级用法以及各种用法等等),在组件复杂的情况下,会导致文档源文件很长难以维护。那就抽离吧。

components/alert/文件夹下新建demo文件夹,存放我们在编写文档时需要引用的 demo

components/alert/demo/1-demo-basic.tsx

import React from 'react';
import Alert from '../alert';
import '../style';

export default () => <Alert kind="warning"></Alert>;

components/alert/index.mdx

- import Alert from './alert'; // 引入组件
- import './style'; // 引入组件样式
+ import BasicDemo from './demo/1-demo-basic';

...

<Playground>
- <Alert kind="warning">这是一条警告提示</Alert>
+ <BasicDemo />
</Playground>

这样我们就将 demo 与文档进行了分隔。预览如下:

文档重构

等等,代码区域显示的是<BasicDemo />,而非demo源码。

<Playground />组件暂时无法支持上述形式的展示:自定义下方展示的代码,而非<Playground />内部的代码。相关讨论如下:

其实第一条 PR 已经解决了问题,但是被关闭了,无奈。

不过既然都能引入 React 组件了,在MDX的环境下自定义一个Playground组件又有何难呢,无非就是渲染组件(MDX 自带)和展示源码,简单开放的东西大家都是喜闻乐见的,就叫HappyBox吧。

优化代码展示

编写 <HappyBox />组件

安装依赖:

yarn add react-use react-tooltip react-feather react-simple-code-editor prismjs react-copy-to-clipboard raw-loader styled-components --dev
这些依赖都是服务于文档站点应用,和组件库自身毫无关联。

最终效果如下:

最终效果

根目录下新建doc-comps文件夹,存放文档中使用的一些工具组件,比如<HappyBox />

doc-comps

├── happy-box
│   ├── style.ts
│   └── index.tsx
└── index.ts

components/doc-comps/happy-box/index.tsx

<details>
<summary>展开查看代码</summary>

import React from 'react';
import Editor from 'react-simple-code-editor';
import CopyToClipboard from 'react-copy-to-clipboard';
import { useToggle } from 'react-use';
import ReactTooltip from 'react-tooltip';
import IconCopy from 'react-feather/dist/icons/clipboard';
import IconCode from 'react-feather/dist/icons/code';
import { highlight, languages } from 'prismjs/components/prism-core';
import { StyledContainer, StyledIconWrapper } from './style';

import 'prismjs/components/prism-clike';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-markup';

require('prismjs/components/prism-jsx');

interface Props {
  code: string;
  title?: React.ReactNode;
  desc?: React.ReactNode;
}

export const HappyBox: React.FC<Props> = ({ code, title, desc, children }) => {
  const [isEditVisible, toggleEditVisible] = useToggle(false);

  return (
    <StyledContainer>
      <section className="code-box-demo"> {children}</section>
      <section className="code-box-meta">
        <div className="text-divider">
          <span>{title || '示例'}</span>
        </div>
        <div className="code-box-description">
          <p>{desc || '暂无描述'}</p>
        </div>
        <div className="divider" />
        <div className="code-box-action">
          <CopyToClipboard text={code} onCopy={() => alert('复制成功')}>
            <IconCopy data-place="top" data-tip="复制代码" />
          </CopyToClipboard>

          <StyledIconWrapper onClick={toggleEditVisible}>
            <IconCode data-place="top" data-tip={isEditVisible ? '收起代码' : '显示代码'} />
          </StyledIconWrapper>
        </div>
      </section>
      {renderEditor()}
      <ReactTooltip />
    </StyledContainer>
  );

  function renderEditor() {
    if (!isEditVisible) return null;
    return (
      <div className="container_editor_area">
        <Editor
          readOnly
          value={code}
          onValueChange={() => {}}
          highlight={code => highlight(code, languages.jsx)}
          padding={10}
          className="container__editor"
          style={{
            fontFamily: '"Fira code", "Fira Mono", monospace',
            fontSize: 14,
          }}
        />
      </div>
    );
  }
};

export default HappyBox;

</details>

components/doc-comps/happy-box/style.ts

<details>
<summary>展开查看代码</summary>

import styled from 'styled-components';

export const StyledIconWrapper = styled.div`
  display: flex;
  align-items: center;
  margin-left: 10px;
`;

export const StyledContainer = styled.div`
  position: relative;
  display: inline-block;
  width: 100%;
  margin: 0 0 16px;
  border: 1px solid #ebedf0;
  border-radius: 2px;
  transition: all 0.2s;

  .text-divider {
    display: table;

    &::before,
    &::after {
      content: '';
      position: relative;
      display: table-cell;
      transform: translateY(50%);
      content: '';
      border-top: 1px solid #e8e8e8;
    }

    &::before {
      top: 50%;
      width: 5%;
    }

    &::after {
      width: 95%;
      top: 50%;
      width: 95%;
    }

    & > span {
      display: inline-block;
      padding: 0 10px;
      font-weight: 500;
      font-size: 16px;
      white-space: nowrap;
      text-align: center;
      font-variant: tabular-nums;
      line-height: 1.5;
    }
  }

  .divider {
    margin: 0;
    background: none;
    border: dashed #e8e8e8;
    border-width: 1px 0 0;
    display: block;
    clear: both;
    width: 100%;
    min-width: 100%;
    height: 1px;
    position: relative;
    top: -0.06em;
    box-sizing: border-box;
    padding: 0;
    font-size: 14px;
    font-variant: tabular-nums;
    line-height: 1.5;
    list-style: none;
    font-feature-settings: 'tnum';
  }

  .code-box-demo {
    transition: all 0.2s;
    padding: 42px 24px 50px;
  }

  .code-box-meta {
    font-size: 14px;
    line-height: 2;
  }

  .code-box .ant-divider {
    margin: 0;
  }

  .code-box-description {
    padding: 18px 24px 12px;
  }

  .code-box-action {
    height: 40px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 16px;
  }

  .code-box-action .anticon {
    margin: 0 8px;
    cursor: pointer;
  }

  .container_editor_area {
    border-top: 1px solid rgb(232, 232, 232);
    padding: 16px;
  }

  .container__editor {
    font-variant-ligatures: common-ligatures;
    border-radius: 3px;
  }

  .container__editor textarea {
    outline: 0;
    background-color: none;
  }

  .button {
    display: inline-block;
    padding: 0 6px;
    text-decoration: none;
    background: #000;
    color: #fff;
  }

  .button:hover {
    background: linear-gradient(45deg, #e42b66, #e2433f);
  }

  /* Syntax highlighting */
  .token.comment,
  .token.prolog,
  .token.doctype,
  .token.cdata {
    color: #90a4ae;
  }
  .token.punctuation {
    color: #9e9e9e;
  }
  .namespace {
    opacity: 0.7;
  }
  .token.property,
  .token.tag,
  .token.boolean,
  .token.number,
  .token.constant,
  .token.symbol,
  .token.deleted {
    color: #e91e63;
  }
  .token.selector,
  .token.attr-name,
  .token.string,
  .token.char,
  .token.builtin,
  .token.inserted {
    color: #4caf50;
  }
  .token.operator,
  .token.entity,
  .token.url,
  .language-css .token.string,
  .style .token.string {
    color: #795548;
  }
  .token.atrule,
  .token.attr-value,
  .token.keyword {
    color: #3f51b5;
  }
  .token.function {
    color: #f44336;
  }
  .token.regex,
  .token.important,
  .token.variable {
    color: #ff9800;
  }
  .token.important,
  .token.bold {
    font-weight: bold;
  }
  .token.italic {
    font-style: italic;
  }
  .token.entity {
    cursor: help;
  }
`;

</details>

相关配置

  • 增加 alias别名,样例源码展示相对路径不够友好,让用户直接拷贝才够省心

新建gatsby-node.js,写入以下内容以开启alias

const path = require('path');

exports.onCreateWebpackConfig = args => {
  args.actions.setWebpackConfig({
    resolve: {
      modules: [path.resolve(__dirname, '../src'), 'node_modules'],
      alias: {
        'happy-ui/lib': path.resolve(__dirname, '../components/'),
        'happy-ui/esm': path.resolve(__dirname, '../components/'),
        'happy-ui': path.resolve(__dirname, '../components/'),
      },
    },
  });
};

tsconfig.json 打包时需要忽略demo,避免组件库打包生成types时包含其中,同时增加paths属性用于 vscode 自动提示:

tsconfig.json

{
  "compilerOptions": {
    "baseUrl": "./",
+   "paths": {
+     "happy-ui": ["components/index.ts"],
+     "happy-ui/esm/*": ["components/*"],
+     "happy-ui/lib/*": ["components/*"]
+    },
    "target": "esnext",
    "module": "commonjs",
    "jsx": "react",
    "declaration": true,
    "declarationDir": "lib",
    "strict": true,
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "resolveJsonModule": true
  },
  "include": ["components", "global.d.ts"],
- "exclude": ["node_modules"]
+ "exclude": ["node_modules",  "**/demo/**"]
}

新的问题出现了,vscode 的 alias 提示依赖 tsconfig.json,忽略 demo 文件夹后,demo 内的文件模块类型找不到声明(paths 失效),所以不能将 demo 在 tsconfig.json 中移除:

{
- "exclude": ["node_modules",  "**/demo/**"]
+ "exclude": ["node_modules"]
}

新建一个 tsconfig.build.json 文件:

tsconfig.build.json

{
  "extends": "./tsconfig.json",
  "exclude": ["**/demo/**", "node_modules"]
}

后续使用 tsc 生成类型声明文件指定tsconfig.build.json即可。

改造相关文件

components/alert/demo/1-demo-basic.tsx

- import Alert from '../alert';
+ import Alert from 'happy-ui/lib/alert';

- import '../style';
+ import 'happy-ui/lib/alert/style';

components/alert/index.mdx

- import { Playground } from 'docz';
+ import { HappyBox } from '../../doc-comps';

+ import BasicDemoCode from '!raw-loader!./demo/1-demo-basic.tsx';

...

- <Playground>
-   <BasicDemo />
- </Playground>

+ <HappyBox code={BasicDemoCode} title="基本用法" desc="使用kind控制Alert类型">
+  <BasicDemo />
+ </HappyBox>
yarn start卡住时尝试删除根目录.docz文件夹,而后重新执行命令。

现在可以愉快地开发组件了。代码可以在仓库chapter-2分支获取,若存在与本文内容不符的地方,以master分支以及文章为准。

组件库打包

宿主环境各不相同,需要将源码进行相关处理后发布至 npm。

明确以下目标:

  1. 导出类型声明文件;
  2. 导出 umd/Commonjs module/ES module 等 3 种形式供使用者引入;
  3. 支持样式文件 css 引入,而非只有less,减少业务方接入成本;
  4. 支持按需加载。

导出类型声明文件

既然是使用typescript编写的组件库,那么使用者应当享受到类型系统的好处。

我们可以生成类型声明文件,并在package.json中定义入口,如下:

package.json

{
  "typings": "lib/index.d.ts", // 定义类型入口文件
  "scripts": {
    "build:types": "tsc -p tsconfig.build.json && cpr lib esm" // 执行tsc命令生成类型声明文件
  }
}
值得注意的是:此处使用cpr(需要手动安装)将lib的声明文件拷贝了一份,并将文件夹重命名为esm,用于后面存放 ES module 形式的组件。这样做的原因是保证用户手动按需引入组件时依旧可以获取自动提示。

最开始的方式是将声明文件单独存放在types文件夹,但这样只有通过'happy-ui'引入才可以获取提示,而'happy-ui/esm/xxx'和'happy-ui/lib/xxx'就无法获取提示。

tsconfig.build.json

{
  "extends": "./tsconfig.json",
  "compilerOptions": { "emitDeclarationOnly": true }, // 只生成声明文件
  "exclude": ["**/__tests__/**", "**/demo/**", "node_modules", "lib", "esm"] // 排除示例、测试以及打包好的文件夹
}

执行yarn build:types,可以发现根目录下已经生成了lib文件夹(tsconfig.json中定义的declarationDir字段)以及esm文件夹(拷贝而来),目录结构与components文件夹保持一致,如下:

lib

├── alert
│   ├── alert.d.ts
│   ├── index.d.ts
│   ├── interface.d.ts
│   └── style
│       └── index.d.ts
└── index.d.ts

这样使用者引入npm 包时,便能得到自动提示,也能够复用相关组件的类型定义。

接下来将ts(x)等文件处理成js文件。

需要注意的是,我们需要输出Commonjs module以及ES module两种模块类型的文件(暂不考虑umd),以下使用cjs指代Commonjs moduleesm指代ES module。<br/> 对此有疑问的同学推荐阅读:import、require、export、module.exports 混合详解

导出 Commonjs 模块

其实完全可以使用babeltsc命令行工具进行代码编译处理(实际上很多工具库就是这样做的),但考虑到还要样式处理及其按需加载,我们借助 gulp 来串起这个流程。

babel 配置

首先安装babel及其相关依赖

yarn add @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript @babel/plugin-proposal-class-properties  @babel/plugin-transform-runtime --dev
yarn add @babel/runtime-corejs3

新建.babelrc.js文件,写入以下内容:

.babelrc.js

module.exports = {
  presets: ['@babel/env', '@babel/typescript', '@babel/react'],
  plugins: [
    '@babel/proposal-class-properties',
    [
      '@babel/plugin-transform-runtime',
      {
        corejs: 3,
        helpers: true,
      },
    ],
  ],
};

关于@babel/plugin-transform-runtime@babel/runtime-corejs3

  • helpers选项设置为true,可抽离代码编译过程重复生成的 helper 函数(classCallCheck,extends等),减小生成的代码体积;
  • corejs设置为3,可引入不污染全局的按需polyfill,常用于类库编写(我更推荐:不引入polyfill,转而告知使用者需要引入何种polyfill,避免重复引入或产生冲突,后面会详细提到)。

更多参见官方文档-@babel/plugin-transform-runtime

配置目标环境

为了避免转译浏览器原生支持的语法,新建.browserslistrc文件,根据适配需求,写入支持浏览器范围,作用于@babel/preset-env

.browserslistrc

>0.2%
not dead
not op_mini all

很遗憾的是,@babel/runtime-corejs3无法在按需引入的基础上根据目标浏览器支持程度再次减少polyfill的引入,参见@babel/runtime for target environment

这意味着@babel/runtime-corejs3 甚至会在针对现代引擎的情况下注入所有可能的 polyfill:不必要地增加了最终捆绑包的大小。

对于组件库(代码量可能很大),个人建议将polyfill的选择权交还给使用者,在宿主环境进行polyfill。若使用者具有兼容性要求,自然会使用@babel/preset-env + core-js + .browserslistrc进行全局polyfill,这套组合拳引入了最低目标浏览器不支持API的全部 polyfill

顺带一提,业务开发中,若将@babel/preset-envuseBuiltIns选项值设置为 usage,同时把node_modulesbabel-loaderexclude,会导致babel 无法检测到nodes_modules中所需要的polyfill"useBuiltIns: usage" for node_modules without transpiling #9419,在未支持该issue提到的内容之前,请将useBuiltIns设置为entry,或者不要把node_modulesbabel-loaderexclude

所以组件库不用画蛇添足,引入多余的polyfill,写好文档说明,比什么都重要(就像zentantd这样)。

现在@babel/runtime-corejs3更换为@babel/runtime,只进行helper函数抽离。

yarn remove @babel/runtime-corejs3

yarn add @babel/runtime

.babelrc.js

module.exports = {
  presets: ['@babel/env', '@babel/typescript', '@babel/react'],
  plugins: ['@babel/plugin-transform-runtime', '@babel/proposal-class-properties'],
};
@babel/transform-runtimehelper选项默认为true

gulp 配置

再来安装gulp相关依赖

yarn add gulp gulp-babel --dev

新建gulpfile.js,写入以下内容:

gulpfile.js

const gulp = require('gulp');
const babel = require('gulp-babel');

const paths = {
  dest: {
    lib: 'lib', // commonjs 文件存放的目录名 - 本块关注
    esm: 'esm', // ES module 文件存放的目录名 - 暂时不关心
    dist: 'dist', // umd文件存放的目录名 - 暂时不关心
  },
  styles: 'components/**/*.less', // 样式文件路径 - 暂时不关心
  scripts: ['components/**/*.{ts,tsx}', '!components/**/demo/*.{ts,tsx}'], // 脚本文件路径
};

function compileCJS() {
  const { dest, scripts } = paths;
  return gulp
    .src(scripts)
    .pipe(babel()) // 使用gulp-babel处理
    .pipe(gulp.dest(dest.lib));
}

// 并行任务 后续加入样式处理 可以并行处理
const build = gulp.parallel(compileCJS);

exports.build = build;

exports.default = build;

修改package.json

package.json

{
- "main": "index.js",
+ "main": "lib/index.js",
  "scripts": {
    ...
+   "clean": "rimraf lib esm dist",
+   "build": "npm run clean && npm run build:types && gulp",
    ...
  },
}

执行yarn build,得到如下内容:

lib

├── alert
│   ├── alert.js
│   ├── index.js
│   ├── interface.js
│   └── style
│       └── index.js
└── index.js

观察编译后的源码,可以发现:诸多helper方法已被抽离至@babel/runtime中,模块导入导出形式也是commonjs规范。

lib/alert/alert.js

lib/alert/alert.js

导出 ES module

生成ES module可以更好地进行tree shaking,基于上一步的babel配置,更新以下内容:

  1. 配置@babel/preset-envmodules选项为false,关闭模块转换;
  2. 配置@babel/plugin-transform-runtimeuseESModules选项为true,使用ES module形式引入helper函数。

.babelrc.js

module.exports = {
  presets: [
    [
      '@babel/env',
      {
        modules: false, // 关闭模块转换
      },
    ],
    '@babel/typescript',
    '@babel/react',
  ],
  plugins: [
    '@babel/proposal-class-properties',
    [
      '@babel/plugin-transform-runtime',
      {
        useESModules: true, // 使用esm形式的helper
      },
    ],
  ],
};

目标达成,我们再使用环境变量区分esmcjs(执行任务时设置对应的环境变量即可),最终babel配置如下:

.babelrc.js

module.exports = {
  presets: ['@babel/env', '@babel/typescript', '@babel/react'],
  plugins: ['@babel/plugin-transform-runtime', '@babel/proposal-class-properties'],
  env: {
    esm: {
      presets: [
        [
          '@babel/env',
          {
            modules: false,
          },
        ],
      ],
      plugins: [
        [
          '@babel/plugin-transform-runtime',
          {
            useESModules: true,
          },
        ],
      ],
    },
  },
};

接下来修改gulp相关配置,抽离compileScripts任务,增加compileESM任务。

gulpfile.js

// ...

/**
 * 编译脚本文件
 * @param {string} babelEnv babel环境变量
 * @param {string} destDir 目标目录
 */
function compileScripts(babelEnv, destDir) {
  const { scripts } = paths;
  // 设置环境变量
  process.env.BABEL_ENV = babelEnv;
  return gulp
    .src(scripts)
    .pipe(babel()) // 使用gulp-babel处理
    .pipe(gulp.dest(destDir));
}

/**
 * 编译cjs
 */
function compileCJS() {
  const { dest } = paths;
  return compileScripts('cjs', dest.lib);
}

/**
 * 编译esm
 */
function compileESM() {
  const { dest } = paths;
  return compileScripts('esm', dest.esm);
}

// 串行执行编译脚本任务(cjs,esm) 避免环境变量影响
const buildScripts = gulp.series(compileCJS, compileESM);

// 整体并行执行任务
const build = gulp.parallel(buildScripts);

// ...

执行yarn build,可以发现生成了lib/esm两个文件夹,观察esm目录,结构同lib一致,js 文件都是以ES module模块形式导入导出。

esm/alert/alert.js

esm/alert/alert.js

别忘了给package.json增加相关入口。

package.json

{
+ "module": "esm/index.js"
}

处理样式文件

拷贝 less 文件

我们会将less文件包含在npm包中,用户可以通过happy-ui/lib/alert/style/index.js的形式按需引入less文件,此处可以直接将 less 文件拷贝至目标文件夹。

gulpfile.js中新建copyLess任务。

gulpfile.js

// ...

/**
 * 拷贝less文件
 */
function copyLess() {
  return gulp
    .src(paths.styles)
    .pipe(gulp.dest(paths.dest.lib))
    .pipe(gulp.dest(paths.dest.esm));
}

const build = gulp.parallel(buildScripts, copyLess);

// ...

观察lib目录,可以发现 less 文件已被拷贝至alert/style目录下。

lib

├── alert
│   ├── alert.js
│   ├── index.js
│   ├── interface.js
│   └── style
│       ├── index.js
│       └── index.less # less文件
└── index.js

可能有些同学已经发现问题:若使用者没有使用less预处理器,使用的是sass方案甚至原生css方案,那现有方案就搞不定了。经分析,有以下 4 种预选方案:

  1. 告知业务方增加less-loader。会导致业务方使用成本增加;
  2. 打包出一份完整的 css 文件,进行全量引入。无法进行按需引入;
  3. css in js方案;
  4. 提供一份style/css.js文件,引入组件 css样式依赖,而非 less 依赖,组件库底层抹平差异。

重点看一看方案 3 以及方案 4。

css in js除了赋予样式编写更多的可能性之外,在编写第三方组件库时更是利器。

如果我们写一个react-use这种hooks工具库,不涉及到样式,只需要在package.json中设置sideEffectsfalse,业务方使用 webpack 进行打包时,只会打包被使用到的 hooks(优先使用 ES module)。

入口文件index.js中导出的但未被使用的其他 hooks 会被tree shaking,第一次使用这个库的时候我很好奇,为什么没有按需引入的使用方式,结果打包分析时我傻了,原来人家天生支持按需引入。

可能常用的antd以及lodash都要配一配,导致产生了惯性思维。

回到正题。如果将样式使用javascript来编写,在某种维度上讲,组件库和工具库一致了,配好sideEffects,自动按需引入,美滋滋。

而且每个组件都与自己的样式绑定,不需要业务方或组件开发者去维护样式依赖,什么是样式依赖,后面会讲到。

缺点:

  1. 样式无法单独缓存;
  2. styled-components 自身体积较大;
  3. 复写组件样式需要使用属性选择器或者使用styled-components自带方法。

需要看取舍了,偷偷说一句styled-components做主题定制也极其方便。

方案 4 是antd使用的这种方案。

在搭建组件库的过程中,有一个问题困扰了我很久:为什么需要alert/style/index.js引入less文件或alert/style/css.js引入css文件?

答案是管理样式依赖

因为我们的组件是没有引入样式文件的,需要使用者去手动引入。

假设存在以下场景:使用者引入<Button /><Button />依赖了<Icon />,则需要手动去引入调用组件的样式(<Button />)及其依赖的组件样式(<Icon />),遇到复杂组件极其麻烦,所以组件库开发者可以提供一份这样的js文件,使用者手动引入这个js文件,就能引入对应组件及其依赖组件的样式。

那么问题又来了,为什么组件不能自己去import './index.less'呢?

可以,但业务方需要配置less-loader,什么,业务方不想配,要你import './index.css'?🙃

可以,业务方爽了,组件开发者不开心。

所以我们要找一个大家都爽的方案:

  1. 组件开发者能够开心的使用预处理器;
  2. 业务方不需要额外的使用成本。

答案就是css in js单独提供一份style/css.js文件,引入的是组件 css样式文件依赖,而非 less 依赖,组件库底层抹平差异。

之前了解到father可以在打包的时候将index.less转成index.css,这倒是个好法子,但是一些重复引入的样式模块(比如动画样式),会被重复打包,不知道有没有好的解决方案。

生成 css 文件

安装相关依赖。

yarn add gulp-less gulp-autoprefixer gulp-cssnano --dev

less文件生成对应的css文件,在gulpfile.js中增加less2css任务。

// ...

/**
 * 生成css文件
 */
function less2css() {
  return gulp
    .src(paths.styles)
    .pipe(less()) // 处理less文件
    .pipe(autoprefixer()) // 根据browserslistrc增加前缀
    .pipe(cssnano({ zindex: false, reduceIdents: false })) // 压缩
    .pipe(gulp.dest(paths.dest.lib))
    .pipe(gulp.dest(paths.dest.esm));
}

const build = gulp.parallel(buildScripts, copyLess, less2css);

// ...

执行yarn build,组件style目录下已经存在css文件了。

接下来我们需要一个alert/style/css.js来帮用户引入css文件。

生成 css.js

此处参考antd-tools的实现方式:在处理scripts任务中,截住style/index.js,生成style/css.js,并通过正则将引入的less文件后缀改成css

安装相关依赖。

yarn add through2 --dev

gulpfile.js

// ...

/**
 * 编译脚本文件
 * @param {*} babelEnv babel环境变量
 * @param {*} destDir 目标目录
 */
function compileScripts(babelEnv, destDir) {
  const { scripts } = paths;
  process.env.BABEL_ENV = babelEnv;
  return gulp
    .src(scripts)
    .pipe(babel()) // 使用gulp-babel处理
    .pipe(
      through2.obj(function z(file, encoding, next) {
        this.push(file.clone());
        // 找到目标
        if (file.path.match(/(\/|\\)style(\/|\\)index\.js/)) {
          const content = file.contents.toString(encoding);
          file.contents = Buffer.from(cssInjection(content)); // 文件内容处理
          file.path = file.path.replace(/index\.js/, 'css.js'); // 文件重命名
          this.push(file); // 新增该文件
          next();
        } else {
          next();
        }
      }),
    )
    .pipe(gulp.dest(destDir));
}

// ...

cssInjection的实现:

gulpfile.js

/**
 * 当前组件样式 import './index.less' => import './index.css'
 * 依赖的其他组件样式 import '../test-comp/style' => import '../test-comp/style/css.js'
 * 依赖的其他组件样式 import '../test-comp/style/index.js' => import '../test-comp/style/css.js'
 * @param {string} content
 */
function cssInjection(content) {
  return content
    .replace(/\/style\/?'/g, "/style/css'")
    .replace(/\/style\/?"/g, '/style/css"')
    .replace(/\.less/g, '.css');
}

再进行打包,可以看见组件style目录下生成了css.js文件,引入的也是上一步less转换而来的css文件。

lib/alert

├── alert.js
├── index.js
├── interface.js
└── style
    ├── css.js # 引入index.css
    ├── index.css
    ├── index.js
    └── index.less

按需加载

在 package.json 中增加sideEffects属性,配合ES module达到tree shaking效果(将样式依赖文件标注为side effects,避免被误删除)。

// ...
"sideEffects": [
  "dist/*",
  "esm/**/style/*",
  "lib/**/style/*",
  "*.less"
],
// ...

使用以下方式引入,可以做到js部分的按需加载,但需要手动引入样式:

import { Alert } from 'happy-ui';
import 'happy-ui/esm/alert/style';

也可以使用以下方式引入:

import Alert from 'happy-ui/esm/alert'; // or import Alert from 'happy-ui/lib/alert';
import 'happy-ui/esm/alert/style'; // or import Alert from 'happy-ui/lib/alert';

以上引入样式文件的方式不太优雅,直接入口处引入全量样式文件又和按需加载的本意相去甚远。

使用者可以借助babel-plugin-import来进行辅助,减少代码编写量(说好的不加入其他使用成本的呢~)。

import { Alert } from 'happy-ui';

⬇️

import Alert from 'happy-ui/lib/alert';
import 'happy-ui/lib/alert/style';

生成 umd

没用上,这一块标记为 todo 吧。

本节代码可以在仓库chapter-3分支获取,若存在与本文内容不符的地方,以master分支以及文章为准。

组件测试

与软件操作行为越接近的测试,越能给予你信心。

本节主要讲述如何在组件库中引入jest以及@testing-library/react,而不会深入单元测试的学习。

如果你对下列问题感兴趣:

  1. What-单元测试是什么?
  2. Why-为什么要写单元测试?
  3. How-编写单元测试的最佳实践?

那么可以看看以下文章:

相关配置

安装依赖:

yarn add jest ts-jest @testing-library/react @testing-library/jest-dom identity-obj-proxy @types/jest @types/testing-library__react --dev
  • jest: JavaScript 测试框架,专注于简洁明快;
  • ts-jest:为TypeScript编写jest测试用例提供支持;
  • @testing-library/react:简单而完整的React DOM测试工具,鼓励良好的测试实践;
  • @testing-library/jest-dom:自定义的jest匹配器(matchers),用于测试DOM的状态(即为jestexcept方法返回值增加更多专注于DOMmatchers);
  • identity-obj-proxy:一个工具库,此处用来mock样式文件。

新建jest.config.js,并写入相关配置,更多配置可参考jest 官方文档-配置,只看几个常用的就可以。

jest.config.js

module.exports = {
  verbose: true,
  roots: ['<rootDir>/components'],
  moduleNameMapper: {
    '\\.(css|less|scss)$': 'identity-obj-proxy',
    '^components$': '<rootDir>/components/index.tsx',
    '^components(.*)$': '<rootDir>/components/$1',
  },
  testRegex: '(/test/.*|\\.(test|spec))\\.(ts|tsx|js)$',
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
  testPathIgnorePatterns: ['/node_modules/', '/lib/', '/esm/', '/dist/'],
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
};

修改package.json,增加测试相关命令,并且代码提交前,跑测试用例,如下:

package.json

"scripts": {
  ...
+  "test": "jest",                         # 执行jest
+  "test:watch": "jest --watch",           # watch模式下执行
+  "test:coverage": "jest --coverage",     # 生成测试覆盖率报告
+  "test:update": "jest --updateSnapshot"  # 更新快照
},
...
"lint-staged": {
  "components/**/*.ts?(x)": [
    "prettier --write",
    "eslint --fix",
+   "jest --bail --findRelatedTests",
    "git add"
  ],
  ...
}

修改gulpfile.js以及tsconfig.json,避免打包时,把测试文件一并处理了。

gulpfile.js

const paths = {
  ...
- scripts: ['components/**/*.{ts,tsx}', '!components/**/demo/*.{ts,tsx}'],
+ scripts: [
+   'components/**/*.{ts,tsx}',
+   '!components/**/demo/*.{ts,tsx}',
+   '!components/**/__tests__/*.{ts,tsx}',
+ ],
};

tsconfig.json

{
- "exclude": ["components/**/demo"]
+ "exclude": ["components/**/demo", "components/**/__tests__"]
}

编写测试用例

<Alert />比较简单,此处只作示例用,简单进行一下快照测试。

在对应组件的文件夹下新建__tests__文件夹,用于存放测试文件,其内新建index.test.tsx文件,写入以下测试用例:

components/alert/tests/index.test.tsx

import React from 'react';
import { render } from '@testing-library/react';
import Alert from '../alert';

describe('<Alert />', () => {
  test('should render default', () => {
    const { container } = render(<Alert>default</Alert>);
    expect(container).toMatchSnapshot();
  });

  test('should render alert with type', () => {
    const kinds: any[] = ['info', 'warning', 'positive', 'negative'];

    const { getByText } = render(
      <>
        {kinds.map(k => (
          <Alert kind={k} key={k}>
            {k}
          </Alert>
        ))}
      </>,
    );

    kinds.forEach(k => {
      expect(getByText(k)).toMatchSnapshot();
    });
  });
});

更新一下快照:

yarn test:update

可以看见同级目录下新增了一个__snapshots__文件夹,里面存放对应测试用例的快照文件。

生成的快照文件

再执行测试用例:

yarn test

通过测试用例

可以发现我们通过了测试用例。。。额,这里当然能通过,主要是后续我们进行迭代重构时,都会重新执行测试用例,与最近的一次快照进行比对,如果与快照不一致(结构发生了改变),那么相应的测试用例就无法通过。

对于快照测试,褒贬不一,这个例子也着实简单得很,甚至连扩展的 jest-dom提供的 matchers 都没用上。

如何编写优秀的测试用例,我也是一个新手,只能说多看多写多尝试,前面推荐的文章很不错。

本节代码可以在仓库chapter-4分支获取,若存在与本文内容不符的地方,以master分支以及文章为准。

标准化发布流程

本节主要是讲解如何通过一行命令完成以下六点内容:

  1. 版本更新
  2. 生成 CHANGELOG
  3. 推送至 git 仓库
  4. 组件库打包
  5. 发布至 npm
  6. 打 tag 并推送至 git

package.json

"scripts": {
+ "release": "ts-node ./scripts/release.ts"
},

<details>
<summary>展开查看代码</summary>

/* eslint-disable  import/no-extraneous-dependencies,@typescript-eslint/camelcase, no-console */
import inquirer from 'inquirer';
import fs from 'fs';
import path from 'path';
import child_process from 'child_process';
import util from 'util';
import chalk from 'chalk';
import semverInc from 'semver/functions/inc';
import { ReleaseType } from 'semver';

import pkg from '../package.json';

const exec = util.promisify(child_process.exec);

const run = async (command: string) => {
  console.log(chalk.green(command));
  await exec(command);
};

const currentVersion = pkg.version;

const getNextVersions = (): { [key in ReleaseType]: string | null } => ({
  major: semverInc(currentVersion, 'major'),
  minor: semverInc(currentVersion, 'minor'),
  patch: semverInc(currentVersion, 'patch'),
  premajor: semverInc(currentVersion, 'premajor'),
  preminor: semverInc(currentVersion, 'preminor'),
  prepatch: semverInc(currentVersion, 'prepatch'),
  prerelease: semverInc(currentVersion, 'prerelease'),
});

const timeLog = (logInfo: string, type: 'start' | 'end') => {
  let info = '';
  if (type === 'start') {
    info = `=> 开始任务:${logInfo}`;
  } else {
    info = `✨ 结束任务:${logInfo}`;
  }
  const nowDate = new Date();
  console.log(
    `[${nowDate.toLocaleString()}.${nowDate
      .getMilliseconds()
      .toString()
      .padStart(3, '0')}] ${info}
    `,
  );
};

/**
 * 询问获取下一次版本号
 */
async function prompt(): Promise<string> {
  const nextVersions = getNextVersions();
  const { nextVersion } = await inquirer.prompt([
    {
      type: 'list',
      name: 'nextVersion',
      message: `请选择将要发布的版本 (当前版本 ${currentVersion})`,
      choices: (Object.keys(nextVersions) as Array<ReleaseType>).map(level => ({
        name: `${level} => ${nextVersions[level]}`,
        value: nextVersions[level],
      })),
    },
  ]);
  return nextVersion;
}

/**
 * 更新版本号
 * @param nextVersion 新版本号
 */
async function updateVersion(nextVersion: string) {
  pkg.version = nextVersion;
  timeLog('修改package.json版本号', 'start');
  await fs.writeFileSync(path.resolve(__dirname, './../package.json'), JSON.stringify(pkg));
  await run('npx prettier package.json --write');
  timeLog('修改package.json版本号', 'end');
}

/**
 * 生成CHANGELOG
 */
async function generateChangelog() {
  timeLog('生成CHANGELOG.md', 'start');
  await run(' npx conventional-changelog -p angular -i CHANGELOG.md -s -r 0');
  timeLog('生成CHANGELOG.md', 'end');
}

/**
 * 将代码提交至git
 */
async function push(nextVersion: string) {
  timeLog('推送代码至git仓库', 'start');
  await run('git add package.json CHANGELOG.md');
  await run(`git commit -m "v${nextVersion}" -n`);
  await run('git push');
  timeLog('推送代码至git仓库', 'end');
}

/**
 * 组件库打包
 */
async function build() {
  timeLog('组件库打包', 'start');
  await run('npm run build');
  timeLog('组件库打包', 'end');
}

/**
 * 发布至npm
 */
async function publish() {
  timeLog('发布组件库', 'start');
  await run('npm publish');
  timeLog('发布组件库', 'end');
}

/**
 * 打tag提交至git
 */
async function tag(nextVersion: string) {
  timeLog('打tag并推送至git', 'start');
  await run(`git tag v${nextVersion}`);
  await run(`git push origin tag v${nextVersion}`);
  timeLog('打tag并推送至git', 'end');
}

async function main() {
  try {
    const nextVersion = await prompt();
    const startTime = Date.now();
    // =================== 更新版本号 ===================
    await updateVersion(nextVersion);
    // =================== 更新changelog ===================
    await generateChangelog();
    // =================== 代码推送git仓库 ===================
    await push(nextVersion);
    // =================== 组件库打包 ===================
    await build();
    // =================== 发布至npm ===================
    await publish();
    // =================== 打tag并推送至git ===================
    await tag(nextVersion);
    console.log(`✨ 发布流程结束 共耗时${((Date.now() - startTime) / 1000).toFixed(3)}s`);
  } catch (error) {
    console.log('💣 发布失败,失败原因:', error);
  }
}

main();

</details>

如果你对这一节不感兴趣,也可以直接使用np进行发布,需要自定义配置一些钩子。

初始化组件

每次初始化一个组件就要新建许多文件(夹),复制粘贴也可,不过还可以使用更高级一点的偷懒方式。

思路如下:

  1. 创建组件模板,预留动态信息插槽(组件名称,组件描述等等);
  2. 基于inquirer.js询问动态信息;
  3. 将信息插入模板,渲染至components文件夹下;
  4. 向 components/index.ts 插入导出语句。

我们只需要配置好模板以及问题,至于询问以及渲染就交给plop.js吧。

yarn add plop --dev

新增脚本命令。

package.json

"scripts": {
+ "new": "plop --plopfile ./scripts/plopfile.ts",
},

新增配置文件以及组件模板,详情可见:

结语

文章很长,也是我个人学习中的总结,如果本文帮助到了你请给仓库一颗 ✨✨ 和本文一个赞。

如果有错误烦请在评论区指正交流,谢谢。

仓库地址

查看原文

赞 52 收藏 39 评论 7

pingan8787 赞了文章 · 10月9日

图解九种常见的设计模式

在软件工程中,设计模式(Design Pattern)是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。根据模式的目的来划分的话,GoF(Gang of Four)设计模式可以分为以下 3 种类型:

image

1、创建型模式:用来描述 “如何创建对象”,它的主要特点是 “将对象的创建和使用分离”。包括单例、原型、工厂方法、抽象工厂和建造者 5 种模式。

2、结构型模式:用来描述如何将类或对象按照某种布局组成更大的结构。包括代理、适配器、桥接、装饰、外观、享元和组合 7 种模式。

3、行为型模式:用来识别对象之间的常用交流模式以及如何分配职责。包括模板方法、策略、命令、职责链、状态、观察者、中介者、迭代器、访问者、备忘录和解释器 11 种模式。

接下来阿宝哥将结合一些生活中的场景并通过精美的配图,来向大家介绍 9 种常用的设计模式。

一、建造者模式

建造者模式(Builder Pattern)将一个复杂对象分解成多个相对简单的部分,然后根据不同需要分别创建它们,最后构建成该复杂对象。

一辆小汽车 🚗 通常由 发动机、底盘、车身和电气设备 四大部分组成。汽车电气设备的内部构造很复杂,简单起见,我们只考虑三个部分:引擎、底盘和车身。

image

在现实生活中,小汽车也是由不同的零部件组装而成,比如上图中我们把小汽车分成引擎、底盘和车身三大部分。下面我们来看一下如何使用建造者模式来造车子。

1.1 实现代码
class Car {
  constructor(
    public engine: string,
    public chassis: string, 
    public body: string
  ) {}
}

class CarBuilder {
  engine!: string; // 引擎
  chassis!: string; // 底盘
  body!: string; // 车身

  addChassis(chassis: string) {
    this.chassis = chassis;
    return this;
  }

  addEngine(engine: string) {
    this.engine = engine;
    return this;
  }

  addBody(body: string) {
    this.body = body;
    return this;
  }

  build() {
    return new Car(this.engine, this.chassis, this.body);
  }
}

在以上代码中,我们定义一个 CarBuilder 类,并提供了 addChassisaddEngineaddBody 3 个方法用于组装车子的不同部位,当车子的 3 个部分都组装完成后,调用 build 方法就可以开始造车。

1.2 使用示例
const car = new CarBuilder()
  .addEngine('v12')
  .addBody('镁合金')
  .addChassis('复合材料')
  .build();
1.3 应用场景及案例
  • 需要生成的产品对象有复杂的内部结构,这些产品对象通常包含多个成员属性。
  • 需要生成的产品对象的属性相互依赖,需要指定其生成顺序。
  • 隔离复杂对象的创建和使用,并使得相同的创建过程可以创建不同的产品。
  • Github - node-sql-queryhttps://github.com/dresende/n...

二、工厂模式

在现实生活中,工厂是负责生产产品的,比如牛奶、面包或礼物等,这些产品满足了我们日常的生理需求。

image

在众多设计模式当中,有一种被称为工厂模式的设计模式,它提供了创建对象的最佳方式。工厂模式可以分为:简单工厂模式、工厂方法模式和抽象工厂模式

2.1 简单工厂

简单工厂模式又叫 静态方法模式,因为工厂类中定义了一个静态方法用于创建对象。简单工厂让使用者不用知道具体的参数就可以创建出所需的 ”产品“ 类,即使用者可以直接消费产品而不需要知道产品的具体生产细节。

image

在上图中,阿宝哥模拟了用户购车的流程,小王和小秦分别向 BMW 工厂订购了 BMW730 和 BMW840 型号的车型,接着工厂会先判断用户选择的车型,然后按照对应的模型进行生产并在生产完成后交付给用户。

下面我们来看一下如何使用简单工厂来描述 BMW 工厂生产指定型号车子的过程。

2.1.1 实现代码
abstract class BMW {
  abstract run(): void;
}

class BMW730 extends BMW {
  run(): void {
    console.log("BMW730 发动咯");
  }
}

class BMW840 extends BMW {
  run(): void {
    console.log("BMW840 发动咯");
  }
}

class BMWFactory {
  public static produceBMW(model: "730" | "840"): BMW {
    if (model === "730") {
      return new BMW730();
    } else {
      return new BMW840();
    }
  }
}

在以上代码中,我们定义一个 BMWFactory 类,该类提供了一个静态的 produceBMW() 方法,用于根据不同的模型参数来创建不同型号的车子。

2.1.2 使用示例
const bmw730 = BMWFactory.produceBMW("730");
const bmw840 = BMWFactory.produceBMW("840");

bmw730.run();
bmw840.run();
2.1.3 应用场景
  • 工厂类负责创建的对象比较少:由于创建的对象比较少,不会造成工厂方法中业务逻辑过于复杂。
  • 客户端只需知道传入工厂类静态方法的参数,而不需要关心创建对象的细节。

2.2 工厂方法

工厂方法模式(Factory Method Pattern)又称为工厂模式,也叫多态工厂(Polymorphic Factory)模式,它属于类创建型模式。

在工厂方法模式中,工厂父类负责定义创建产品对象的公共接口,而工厂子类则负责生成具体的产品对象, 这样做的目的是将产品类的实例化操作延迟到工厂子类中完成,即通过工厂子类来确定究竟应该实例化哪一个具体产品类。

image

在上图中,阿宝哥模拟了用户购车的流程,小王和小秦分别向 BMW 730 和 BMW 840 工厂订购了 BMW730 和 BMW840 型号的车子,接着工厂按照对应的模型进行生产并在生产完成后交付给用户。

同样,我们来看一下如何使用工厂方法来描述 BMW 工厂生产指定型号车子的过程。

2.2.1 实现代码
abstract class BMWFactory {
  abstract produceBMW(): BMW;
}

class BMW730Factory extends BMWFactory {
  produceBMW(): BMW {
    return new BMW730();
  }
}

class BMW840Factory extends BMWFactory {
  produceBMW(): BMW {
    return new BMW840();
  }
}

在以上代码中,我们分别创建了 BMW730FactoryBMW840Factory 两个工厂类,然后使用这两个类的实例来生产不同型号的车子。

2.2.2 使用示例
const bmw730Factory = new BMW730Factory();
const bmw840Factory = new BMW840Factory();

const bmw730 = bmw730Factory.produceBMW();
const bmw840 = bmw840Factory.produceBMW();

bmw730.run();
bmw840.run();
2.2.3 应用场景
  • 一个类不知道它所需要的对象的类:在工厂方法模式中,客户端不需要知道具体产品类的类名,只需要知道所对应的工厂即可,具体的产品对象由具体工厂类创建;客户端需要知道创建具体产品的工厂类。
  • 一个类通过其子类来指定创建哪个对象:在工厂方法模式中,对于抽象工厂类只需要提供一个创建产品的接口,而由其子类来确定具体要创建的对象,利用面向对象的多态性和里氏代换原则,在程序运行时,子类对象将覆盖父类对象,从而使得系统更容易扩展。
继续阅读:Typescript 设计模式之工厂方法

2.3 抽象工厂

抽象工厂模式(Abstract Factory Pattern),提供一个创建一系列相关或相互依赖对象的接口,而无须指定它们具体的类。

在工厂方法模式中具体工厂负责生产具体的产品,每一个具体工厂对应一种具体产品,工厂方法也具有唯一性,一般情况下,一个具体工厂中只有一个工厂方法或者一组重载的工厂方法。 但是有时候我们需要一个工厂可以提供多个产品对象,而不是单一的产品对象。

image

在上图中,阿宝哥模拟了用户购车的流程,小王向 BMW 工厂订购了 BMW730,工厂按照 730 对应的模型进行生产并在生产完成后交付给小王。而小秦向同一个 BMW 工厂订购了 BMW840,工厂按照 840 对应的模型进行生产并在生产完成后交付给小秦。

下面我们来看一下如何使用抽象工厂来描述上述的购车过程。

2.3.1 实现代码
abstract class BMWFactory {
  abstract produce730BMW(): BMW730;
  abstract produce840BMW(): BMW840;
}

class ConcreteBMWFactory extends BMWFactory {
  produce730BMW(): BMW730 {
    return new BMW730();
  }

  produce840BMW(): BMW840 {
    return new BMW840();
  }
}
2.3.2 使用示例
const bmwFactory = new ConcreteBMWFactory();

const bmw730 = bmwFactory.produce730BMW();
const bmw840 = bmwFactory.produce840BMW();

bmw730.run();
bmw840.run();
2.3.3 应用场景
  • 一个系统不应当依赖于产品类实例如何被创建、组合和表达的细节,这对于所有类型的工厂模式都是重要的。
  • 系统中有多于一个的产品族,而每次只使用其中某一产品族。
  • 系统提供一个产品类的库,所有的产品以同样的接口出现,从而使客户端不依赖于具体实现。
继续阅读:创建对象的最佳方式是什么?

三、单例模式

单例模式(Singleton Pattern)是一种常用的模式,有一些对象我们往往只需要一个,比如全局缓存、浏览器中的 window 对象等。单例模式用于保证一个类仅有一个实例,并提供一个访问它的全局访问点。

image

在上图中,阿宝哥模拟了借车的流程,小王临时有急事找阿宝哥借车子,阿宝哥家的车子刚好没用,就借给小王了。当天,小秦也需要用车子,也找阿宝哥借车,因为阿宝哥家里只有一辆车子,所以就没有车可借了。

对于车子来说,它虽然给生活带来了很大的便利,但养车也需要一笔不小的费用(车位费、油费和保养费等),所以阿宝哥家里只有一辆车子。在开发软件系统时,如果遇到创建对象时耗时过多或耗资源过多,但又经常用到的对象,我们就可以考虑使用单例模式。

下面我们来看一下如何使用 TypeScript 来实现单例模式。

3.1 实现代码
class Singleton {
  // 定义私有的静态属性,来保存对象实例
  private static singleton: Singleton;
  private constructor() {}

  // 提供一个静态的方法来获取对象实例
  public static getInstance(): Singleton {
    if (!Singleton.singleton) {
      Singleton.singleton = new Singleton();
    }
    return Singleton.singleton;
  }
}
3.2 使用示例
let instance1 = Singleton.getInstance();
let instance2 = Singleton.getInstance();

console.log(instance1 === instance2); // true
3.3 应用场景
  • 需要频繁实例化然后销毁的对象。
  • 创建对象时耗时过多或耗资源过多,但又经常用到的对象。
  • 系统只需要一个实例对象,如系统要求提供一个唯一的序列号生成器或资源管理器,或者需要考虑资源消耗太大而只允许创建一个对象。
继续阅读:TypeScript 设计模式之单例模式

四、适配器模式

在实际生活中,也存在适配器的使用场景,比如:港式插头转换器、电源适配器和 USB 转接口。而在软件工程中,适配器模式的作用是解决两个软件实体间的接口不兼容的问题。 使用适配器模式之后,原本由于接口不兼容而不能工作的两个软件实体就可以一起工作。

image

4.1 实现代码
interface Logger {
  info(message: string): Promise<void>;
}

interface CloudLogger {
  sendToServer(message: string, type: string): Promise<void>;
}

class AliLogger implements CloudLogger {
  public async sendToServer(message: string, type: string): Promise<void> {
    console.info(message);
    console.info('This Message was saved with AliLogger');
  }
}

class CloudLoggerAdapter implements Logger {
  protected cloudLogger: CloudLogger;

  constructor (cloudLogger: CloudLogger) {
    this.cloudLogger = cloudLogger;
  }

  public async info(message: string): Promise<void> {
    await this.cloudLogger.sendToServer(message, 'info');
  }
}

class NotificationService {
  protected logger: Logger;
  
  constructor (logger: Logger) {    
    this.logger = logger;
  }

  public async send(message: string): Promise<void> {
    await this.logger.info(`Notification sended: ${message}`);
  }
}

在以上代码中,因为 LoggerCloudLogger 这两个接口不匹配,所以我们引入了 CloudLoggerAdapter 适配器来解决兼容性问题。

4.2 使用示例
(async () => {
  const aliLogger = new AliLogger();
  const cloudLoggerAdapter = new CloudLoggerAdapter(aliLogger);
  const notificationService = new NotificationService(cloudLoggerAdapter);
  await notificationService.send('Hello semlinker, To Cloud');
})();
4.3 应用场景及案例
继续阅读:TypeScript 设计模式之适配器模式

五、观察者模式 & 发布订阅模式

5.1 观察者模式

观察者模式,它定义了一种一对多的关系,让多个观察者对象同时监听某一个主题对象,这个主题对象的状态发生变化时就会通知所有的观察者对象,使得它们能够自动更新自己。

在观察者模式中有两个主要角色:Subject(主题)和 Observer(观察者)。

image

在上图中,Subject(主题)就是阿宝哥的 TS 专题文章,而观察者就是小秦和小王。由于观察者模式支持简单的广播通信,当消息更新时,会自动通知所有的观察者。

下面我们来看一下如何使用 TypeScript 来实现观察者模式。

5.1.1 实现代码
interface Observer {
  notify: Function;
}

class ConcreteObserver implements Observer{
  constructor(private name: string) {}

  notify() {
    console.log(`${this.name} has been notified.`);
  }
}

class Subject { 
  private observers: Observer[] = [];

  public addObserver(observer: Observer): void {
    console.log(observer, "is pushed!");
    this.observers.push(observer);
  }

  public deleteObserver(observer: Observer): void {
    console.log("remove", observer);
    const n: number = this.observers.indexOf(observer);
    n != -1 && this.observers.splice(n, 1);
  }

  public notifyObservers(): void {
    console.log("notify all the observers", this.observers);
    this.observers.forEach(observer => observer.notify());
  }
}
5.1.2 使用示例
const subject: Subject = new Subject();
const xiaoQin = new ConcreteObserver("小秦");
const xiaoWang = new ConcreteObserver("小王");
subject.addObserver(xiaoQin);
subject.addObserver(xiaoWang);
subject.notifyObservers();

subject.deleteObserver(xiaoQin);
subject.notifyObservers();
5.1.3 应用场景及案例
继续阅读:TypeScript 设计模式之观察者模式

5.2 发布订阅模式

在软件架构中,发布/订阅是一种消息范式,消息的发送者(称为发布者)不会将消息直接发送给特定的接收者(称为订阅者)。而是将发布的消息分为不同的类别,然后分别发送给不同的订阅者。 同样的,订阅者可以表达对一个或多个类别的兴趣,只接收感兴趣的消息,无需了解哪些发布者存在。

在发布订阅模式中有三个主要角色:Publisher(发布者)、 Channels(通道)和 Subscriber(订阅者)。

image

在上图中,Publisher(发布者)是阿宝哥,Channels(通道)中 Topic A 和 Topic B 分别对应于 TS 专题和 Deno 专题,而 Subscriber(订阅者)就是小秦、小王和小池。

下面我们来看一下如何使用 TypeScript 来实现发布订阅模式。

5.2.1 实现代码
type EventHandler = (...args: any[]) => any;

class EventEmitter {
  private c = new Map<string, EventHandler[]>();

  // 订阅指定的主题
  subscribe(topic: string, ...handlers: EventHandler[]) {
    let topics = this.c.get(topic);
    if (!topics) {
      this.c.set(topic, topics = []);
    }
    topics.push(...handlers);
  }

  // 取消订阅指定的主题
  unsubscribe(topic: string, handler?: EventHandler): boolean {
    if (!handler) {
      return this.c.delete(topic);
    }

    const topics = this.c.get(topic);
    if (!topics) {
      return false;
    }
    
    const index = topics.indexOf(handler);

    if (index < 0) {
      return false;
    }
    topics.splice(index, 1);
    if (topics.length === 0) {
      this.c.delete(topic);
    }
    return true;
  }

  // 为指定的主题发布消息
  publish(topic: string, ...args: any[]): any[] | null {
    const topics = this.c.get(topic);
    if (!topics) {
      return null;
    }
    return topics.map(handler => {
      try {
        return handler(...args);
      } catch (e) {
        console.error(e);
        return null;
      }
    });
  }
}
5.2.2 使用示例
const eventEmitter = new EventEmitter();
eventEmitter.subscribe("ts", (msg) => console.log(`收到订阅的消息:${msg}`) );

eventEmitter.publish("ts", "TypeScript发布订阅模式");
eventEmitter.unsubscribe("ts");
eventEmitter.publish("ts", "TypeScript发布订阅模式");
5.2.3 应用场景
继续阅读:如何优雅的实现消息通信?

六、策略模式

策略模式(Strategy Pattern)定义了一系列的算法,把它们一个个封装起来,并且使它们可以互相替换。策略模式的重心不是如何实现算法,而是如何组织、调用这些算法,从而让程序结构更灵活、可维护、可扩展。

image

目前在一些主流的 Web 站点中,都提供了多种不同的登录方式。比如账号密码登录、手机验证码登录和第三方登录。为了方便维护不同的登录方式,我们可以把不同的登录方式封装成不同的登录策略。

下面我们来看一下如何使用策略模式来封装不同的登录方式。

6.1 实现代码

为了更好地理解以下代码,我们先来看一下对应的 UML 类图:

image

interface Strategy {
  authenticate(...args: any): any;
}

class Authenticator {
  strategy: any;
  constructor() {
    this.strategy = null;
  }

  setStrategy(strategy: any) {
    this.strategy = strategy;
  }

  authenticate(...args: any) {
    if (!this.strategy) {
      console.log('尚未设置认证策略');
      return;
    }
    return this.strategy.authenticate(...args);
  }
}

class WechatStrategy implements Strategy {
  authenticate(wechatToken: string) {
    if (wechatToken !== '123') {
      console.log('无效的微信用户');
      return;
    }
    console.log('微信认证成功');
  }
}

class LocalStrategy implements Strategy {
  authenticate(username: string, password: string) {
    if (username !== 'abao' && password !== '123') {
      console.log('账号或密码错误');
      return;
    }
    console.log('账号和密码认证成功');
  }
}
6.2 使用示例
const auth = new Authenticator();

auth.setStrategy(new WechatStrategy());
auth.authenticate('123456');

auth.setStrategy(new LocalStrategy());
auth.authenticate('abao', '123');
6.3 应用场景及案例

七、职责链模式

职责链模式是使多个对象都有机会处理请求,从而避免请求的发送者和接受者之间的耦合关系。在职责链模式里,很多对象由每一个对象对其下家的引用而连接起来形成一条链。请求在这个链上传递,直到链上的某一个对象决定处理此请求。

image

在公司中不同的岗位拥有不同的职责与权限。以上述的请假流程为例,当阿宝哥请 1 天假时,只要组长审批就可以了,不需要流转到主管和总监。如果职责链上的某个环节无法处理当前的请求,若含有下个环节,则会把请求转交给下个环节来处理。

在日常的软件开发过程中,对于职责链来说,一种常见的应用场景是中间件,下面我们来看一下如何利用职责链来处理请求。

7.1 实现代码

为了更好地理解以下代码,我们先来看一下对应的 UML 类图:

image

interface IHandler {
  addMiddleware(h: IHandler): IHandler;
  get(url: string, callback: (data: any) => void): void;
}

abstract class AbstractHandler implements IHandler {
  next!: IHandler;
  addMiddleware(h: IHandler) {
    this.next = h;
    return this.next;
  }

  get(url: string, callback: (data: any) => void) {
    if (this.next) {
      return this.next.get(url, callback);
    }
  }
}

// 定义Auth中间件
class Auth extends AbstractHandler {
  isAuthenticated: boolean;
  constructor(username: string, password: string) {
    super();

    this.isAuthenticated = false;
    if (username === 'abao' && password === '123') {
      this.isAuthenticated = true;
    }
  }

  get(url: string, callback: (data: any) => void) {
    if (this.isAuthenticated) {
      return super.get(url, callback);
    } else {
      throw new Error('Not Authorized');
    }
  }
}

// 定义Logger中间件
class Logger extends AbstractHandler {
  get(url: string, callback: (data: any) => void) {
    console.log('/GET Request to: ', url);
    return super.get(url, callback);
  }
}

class Route extends AbstractHandler {
  URLMaps: {[key: string]: any};
  constructor() {
    super();
    this.URLMaps = {
      '/api/todos': [{ title: 'learn ts' }, { title: 'learn react' }],
      '/api/random': Math.random(),
    };
  }

  get(url: string, callback: (data: any) => void) {
    super.get(url, callback);

    if (this.URLMaps.hasOwnProperty(url)) {
      callback(this.URLMaps[url]);
    }
  }
}
7.2 使用示例
const route = new Route();
route.addMiddleware(new Auth('abao', '123')).addMiddleware(new Logger());

route.get('/api/todos', data => {
  console.log(JSON.stringify({ data }, null, 2));
});

route.get('/api/random', data => {
  console.log(data);
});
7.3 应用场景
  • 可处理一个请求的对象集合应被动态指定。
  • 想在不明确指定接收者的情况下,向多个对象中的一个提交一个请求。
  • 有多个对象可以处理一个请求,哪个对象处理该请求运行时自动确定,客户端只需要把请求提交到链上即可。

八、模板方法模式

模板方法模式由两部分结构组成:抽象父类和具体的实现子类。通常在抽象父类中封装了子类的算法框架,也包括实现一些公共方法以及封装子类中所有方法的执行顺序。子类通过继承这个抽象类,也继承了整个算法结构,并且可以选择重写父类的方法。

image

在上图中,阿宝哥通过使用不同的解析器来分别解析 CSV 和 Markup 文件。虽然解析的是不同的类型的文件,但文件的处理流程是一样的。这里主要包含读取文件、解析文件和打印数据三个步骤。针对这个场景,我们就可以引入模板方法来封装以上三个步骤的处理顺序。

下面我们来看一下如何使用模板方法来实现上述的解析流程。

8.1 实现代码

为了更好地理解以下代码,我们先来看一下对应的 UML 类图:

image

import fs from 'fs';

abstract class DataParser {
  data: string = '';
  out: any = null;

  // 这就是所谓的模板方法
  parse(pathUrl: string) {
    this.readFile(pathUrl);
    this.doParsing();
    this.printData();
  }

  readFile(pathUrl: string) {
    this.data = fs.readFileSync(pathUrl, 'utf8');
  }

  abstract doParsing(): void;
  
  printData() {
    console.log(this.out);
  }
}

class CSVParser extends DataParser {
  doParsing() {
    this.out = this.data.split(',');
  }
}

class MarkupParser extends DataParser {
  doParsing() {
    this.out = this.data.match(/<\w+>.*<\/\w+>/gim);
  }
}
8.2 使用示例
const csvPath = './data.csv';
const mdPath = './design-pattern.md';

new CSVParser().parse(csvPath);
new MarkupParser().parse(mdPath);
8.3 应用场景
  • 算法的整体步骤很固定,但其中个别部分易变时,这时候可以使用模板方法模式,将容易变的部分抽象出来,供子类实现。
  • 当需要控制子类的扩展时,模板方法只在特定点调用钩子操作,这样就只允许在这些点进行扩展。

九、参考资源

十、推荐阅读

查看原文

赞 49 收藏 40 评论 5

pingan8787 关注了用户 · 10月2日

凹凸实验室 @o2team

凹凸实验室(Aotu.io,英文简称O2) 始建于2015年10月,是一个年轻基情的技术团队。

O2面向多终端技术体系,致力于构建沉淀与分享包括但不限于交互、页面制作技巧、前端开发、原生APP开发等方面的专业知识及案例。

求简历:aotu@jd.com

关注 760

pingan8787 赞了文章 · 10月2日

React 架构的演变 - 从递归到循环

这篇文章是 React 架构演变的第二篇,上一篇主要介绍了更新机制从同步修改为异步,这一篇重点介绍 Fiber 架构下通过循环遍历更新的过程,之所以要使用循环遍历的方式,是因为递归更新过程一旦开始就不能暂停,只能不断向下,直到递归结束或者出现异常。

递归更新的实现

React 15 的递归更新逻辑是先将需要更新的组件放入脏组件队列(这里在上篇文章已经介绍过,没看过的可以先看看《React 架构的演变 - 从同步到异步》),然后取出组件进行一次递归,不停向下寻找子节点来查找是否需要更新。

下面使用一段代码来简单描述一下这个过程:

updateComponent (prevElement, nextElement) {
  if (
    // 如果组件的 type 和 key 都没有发生变化,进行更新
    prevElement.type === nextElement.type &&
    prevElement.key === nextElement.key
  ) {
    // 文本节点更新
    if (prevElement.type === 'text') {
        if (prevElement.value !== nextElement.value) {
            this.replaceText(nextElement.value)
        }
    }
    // DOM 节点的更新
    else {
      // 先更新 DOM 属性
      this.updateProps(prevElement, nextElement)
      // 再更新 children
      this.updateChildren(prevElement, nextElement)
    }
  }
  // 如果组件的 type 和 key 发生变化,直接重新渲染组件
  else {
    // 触发 unmount 生命周期
    ReactReconciler.unmountComponent(prevElement)
    // 渲染新的组件
    this._instantiateReactComponent(nextElement)
  }
},
updateChildren (prevElement, nextElement) {
  var prevChildren = prevElement.children
  var nextChildren = nextElement.children
  // 省略通过 key 重新排序的 diff 过程
  if (prevChildren === null) { } // 渲染新的子节点
  if (nextChildren === null) { } // 清空所有子节点
  // 子节点对比
  prevChildren.forEach((prevChild, index) => {
    const nextChild = nextChildren[index]
    // 递归过程
    this.updateComponent(prevChild, nextChild)
  })
}

为了更清晰的看到这个过程,我们还是写一个简单的Demo,构造一个 3 * 3 的 Table 组件。

Table

// https://codesandbox.io/embed/react-sync-demo-nlijf
class Col extends React.Component {
  render() {
    // 渲染之前暂停 8ms,给 render 制造一点点压力
    const start = performance.now()
    while (performance.now() - start < 8)
    return <td>{this.props.children}</td>
  }
}

export default class Demo extends React.Component {
  state = {
    val: 0
  }
  render() {
    const { val } = this.state
    const array = Array(3).fill()
    // 构造一个 3 * 3 表格
    const rows = array.map(
      (_, row) => <tr key={row}>
        {array.map(
          (_, col) => <Col key={col}>{val}</Col>
        )}
      </tr>
    )
    return (
      <table className="table">
        <tbody>{rows}</tbody>
      </table>
    )
  }
}

然后每秒对 Table 里面的值更新一次,让 val 每次 + 1,从 0 ~ 9 不停循环。

Table Loop

// https://codesandbox.io/embed/react-sync-demo-nlijf
export default class Demo extends React.Component {
    tick = () => {
    setTimeout(() => {
      this.setState({ val: next < 10 ? next : 0 })
      this.tick()
    }, 1000)
  }
  componentDidMount() {
    this.tick()
  }
}

完整代码的线上地址: https://codesandbox.io/embed/react-sync-demo-nlijf。Demo 组件每次调用 setState,React 会先判断该组件的类型有没有发生修改,如果有就整个组件进行重新渲染,如果没有会更新 state,然后向下判断 table 组件,table 组件继续向下判断 tr 组件,tr 组件再向下判断 td 组件,最后发现 td 组件下的文本节点发生了修改,通过 DOM API 更新。

Update

通过 Performance 的函数调用堆栈也能清晰的看到这个过程,updateComponent 之后 的 updateChildren 会继续调用子组件的 updateComponent,直到递归完所有组件,表示更新完成。

调用堆栈

递归的缺点很明显,不能暂停更新,一旦开始必须从头到尾,这与 React 16 拆分时间片,给浏览器喘口气的理念明显不符,所以 React 必须要切换架构,将虚拟 DOM 从树形结构修改为链表结构。

可循环的 Fiber

这里说的链表结构就是 Fiber 了,链表结构最大的优势就是可以通过循环的方式来遍历,只要记住当前遍历的位置,即使中断后也能快速还原,重新开始遍历。

我们先看看一个 Fiber 节点的数据结构:

function FiberNode (tag, key) {
  // 节点 key,主要用于了优化列表 diff
  this.key = key
  // 节点类型;FunctionComponent: 0, ClassComponent: 1, HostRoot: 3 ...
  this.tag = tag

    // 子节点
  this.child = null
  // 父节点
  this.return = null 
  // 兄弟节点
  this.sibling = null
  
  // 更新队列,用于暂存 setState 的值
  this.updateQueue = null
  
  // 节点更新过期时间,用于时间分片
  // react 17 改为:lanes、childLanes
  this.expirationTime = NoLanes
  this.childExpirationTime = NoLanes

  // 对应到页面的真实 DOM 节点
  this.stateNode = null
  // Fiber 节点的副本,可以理解为备胎,主要用于提升更新的性能
  this.alternate = null
}

下面举个例子,我们这里有一段普通的 HTML 文本:

<table class="table">
  <tr>
    <td>1</td>
    <td>1</td>
  </tr>
  <tr>
    <td>1</td>
  </tr>
</table>

在之前的 React 版本中,jsx 会转化为 createElement 方法,创建树形结构的虚拟 DOM。

const VDOMRoot = {
  type: 'table',
  props: { className: 'table' },
  children: [
    {
      type: 'tr',
      props: { },
      children: [
        {
          type: 'td',
          props: { },
          children: [{type: 'text', value: '1'}]
        },
        {
          type: 'td',
          props: { },
          children: [{type: 'text', value: '1'}]
        }
      ]
    },
    {
      type: 'tr',
      props: { },
      children: [
        {
          type: 'td',
          props: { },
          children: [{type: 'text', value: '1'}]
        }
      ]
    }
  ]
}

Fiber 架构下,结构如下:

// 有所简化,并非与 React 真实的 Fiber 结构一致
const FiberRoot = {
  type: 'table',
  return: null,
  sibling: null,
  child: {
    type: 'tr',
    return: FiberNode, // table 的 FiberNode
    sibling: {
      type: 'tr',
      return: FiberNode, // table 的 FiberNode
      sibling: null,
      child: {
        type: 'td',
        return: FiberNode, // tr 的 FiberNode
        sibling: {
          type: 'td',
          return: FiberNode, // tr 的 FiberNode
          sibling: null,
          child: null,
          text: '1' // 子节点仅有文本节点
        },
        child: null,
        text: '1' // 子节点仅有文本节点
      }
    },
    child: {
      type: 'td',
      return: FiberNode, // tr 的 FiberNode
      sibling: null,
      child: null,
      text: '1' // 子节点仅有文本节点
    }
  }
}

Fiber

循环更新的实现

那么,在 setState 的时候,React 是如何进行一次 Fiber 的遍历的呢?

let workInProgress = FiberRoot

// 遍历 Fiber 节点,如果时间片时间用完就停止遍历
function workLoopConcurrent() {
  while (
    workInProgress !== null &&
    !shouldYield() // 用于判断当前时间片是否到期
  ) {
    performUnitOfWork(workInProgress)
  }
}

function performUnitOfWork() {
  const next = beginWork(workInProgress) // 返回当前 Fiber 的 child
  if (next) { // child 存在
    // 重置 workInProgress 为 child
    workInProgress = next
  } else { // child 不存在
    // 向上回溯节点
    let completedWork = workInProgress
    while (completedWork !== null) {
      // 收集副作用,主要是用于标记节点是否需要操作 DOM
      completeWork(completedWork)

      // 获取 Fiber.sibling
      let siblingFiber = workInProgress.sibling
      if (siblingFiber) {
        // sibling 存在,则跳出 complete 流程,继续 beginWork
        workInProgress = siblingFiber
        return;
      }

      completedWork = completedWork.return
      workInProgress = completedWork
    }
  }
}

function beginWork(workInProgress) {
  // 调用 render 方法,创建子 Fiber,进行 diff
  // 操作完毕后,返回当前 Fiber 的 child
  return workInProgress.child
}
function completeWork(workInProgress) {
  // 收集节点副作用
}

Fiber 的遍历本质上就是一个循环,全局有一个 workInProgress 变量,用来存储当前正在 diff 的节点,先通过 beginWork 方法对当前节点然后进行 diff 操作(diff 之前会调用 render,重新计算 state、prop),并返回当前节点的第一个子节点( fiber.child)作为新的工作节点,直到不存在子节点。然后,对当前节点调用 completedWork 方法,存储 beginWork 过程中产生的副作用,如果当前节点存在兄弟节点( fiber.sibling),则将工作节点修改为兄弟节点,重新进入 beginWork 流程。直到 completedWork 重新返回到根节点,执行 commitRoot 将所有的副作用反应到真实 DOM 中。

Fiber work loop

在一次遍历过程中,每个节点都会经历 beginWorkcompleteWork ,直到返回到根节点,最后通过 commitRoot 将所有的更新提交,关于这部分的内容可以看:《React 技术揭秘》

时间分片的秘密

前面说过,Fiber 结构的遍历是支持中断恢复,为了观察这个过程,我们将之前的 3 * 3 的 Table 组件改成 Concurrent 模式,线上地址:https://codesandbox.io/embed/react-async-demo-h1lbz。由于每次调用 Col 组件的 render 部分需要耗时 8ms,会超出了一个时间片,所以每个 td 部分都会暂停一次。

class Col extends React.Component {
  render() {
    // 渲染之前暂停 8ms,给 render 制造一点点压力
    const start = performance.now();
    while (performance.now() - start < 8);
    return <td>{this.props.children}</td>
  }
}

在这个 3 * 3 组件里,一共有 9 个 Col 组件,所以会有 9 次耗时任务,分散在 9 个时间片进行,通过 Performance 的调用栈可以看到具体情况:

异步模式的调用栈

在非 Concurrent 模式下,Fiber 节点的遍历是一次性进行的,并不会切分多个时间片,差别就是在遍历的时候调用了 workLoopSync 方法,该方法并不会判断时间片是否用完。

// 遍历 Fiber 节点
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress)
  }
}

同步模式的调用栈

通过上面的分析可以看出, shouldYield 方法决定了当前时间片是否已经用完,这也是决定 React 是同步渲染还是异步渲染的关键。如果去除任务优先级的概念,shouldYield 方法可以说很简单,就是判断了当前的时间,是否已经超过了预设的 deadline

function getCurrentTime() {
  return performance.now()
}
function shouldYield() {
  // 获取当前时间
  var currentTime = getCurrentTime()
  return currentTime >= deadline
}

deadline 又是如何得的呢?可以回顾上一篇文章(《React 架构的演变 - 从同步到异步》)提到的 ChannelMessage,更新开始的时候会通过 requestHostCallback(即:port2.send)发送异步消息,在 performWorkUntilDeadline (即:port1.onmessage)中接收消息。performWorkUntilDeadline 每次接收到消息时,表示已经进入了下一个任务队列,这个时候就会更新 deadline

异步调用栈

var channel = new MessageChannel()
var port = channel.port2
channel.port1.onmessage = function performWorkUntilDeadline() {
  if (scheduledHostCallback !== null) {
    var currentTime = getCurrentTime()
    // 重置超时时间 
    deadline = currentTime + yieldInterval
    
    var hasTimeRemaining = true
    var hasMoreWork = scheduledHostCallback()

    if (!hasMoreWork) {
      // 已经没有任务了,修改状态 
      isMessageLoopRunning = false;
      scheduledHostCallback = null;
    } else {
      // 还有任务,放到下个任务队列执行,给浏览器喘息的机会 
      port.postMessage (null);
    }
  } else {
    isMessageLoopRunning = false;
  }
}

requestHostCallback = function (callback) {
  //callback 挂载到 scheduledHostCallback
  scheduledHostCallback = callback
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true
    // 推送消息,下个队列队列调用 callback
    port.postMessage (null)
  }
}

超时时间的设置就是在当前时间的基础上加上了一个 yieldInterval, 这个 yieldInterval 的值,默认是 5ms。

deadline = currentTime + yieldInterval

同时 React 也提供了修改 yieldInterval 的手段,通过手动指定 fps,来确定一帧的具体时间(单位:ms),fps 越高,一个时间分片的时间就越短,对设备的性能要求就越高。

forceFrameRate = function (fps) {
  if (fps < 0 || fps > 125) {
    // 帧率仅支持 0~125
    return
  }

  if (fps > 0) {
    // 一般 60 fps 的设备
    // 一个时间分片的时间为 Math.floor(1000/60) = 16
    yieldInterval = Math.floor(1000 / fps)
  } else {
    // reset the framerate
    yieldInterval = 5
  }
}

总结

下面我们将异步逻辑、循环更新、时间分片串联起来。先回顾一下之前的文章讲过,Concurrent 模式下,setState 后的调用顺序:

Component.setState()
  => enqueueSetState()
  => scheduleUpdate()
  => scheduleCallback(performConcurrentWorkOnRoot)
  => requestHostCallback()
  => postMessage()
  => performWorkUntilDeadline()

scheduleCallback 方法会将传入的回调(performConcurrentWorkOnRoot)组装成一个任务放入 taskQueue 中,然后调用 requestHostCallback 发送一个消息,进入异步任务。performWorkUntilDeadline 接收到异步消息,从 taskQueue 取出任务开始执行,这里的任务就是之前传入的 performConcurrentWorkOnRoot 方法,这个方法最后会调用workLoopConcurrentworkLoopConcurrent 前面已经介绍过了,这个不再重复)。如果 workLoopConcurrent 是由于超时中断的,hasMoreWork 返回为 true,通过 postMessage 发送消息,将操作延迟到下一个任务队列。

流程图

到这里整个流程已经结束,希望大家看完文章能有所收获,下一篇文章会介绍 Fiber 架构下 Hooks 的实现。

image

查看原文

赞 7 收藏 4 评论 2

pingan8787 发布了文章 · 9月30日

我为 Express 开了外挂

本项目源码地址:
https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Gist/LearnSource/OvernightDemo/

随着 Nodejs 在前端涉及领域越来越广,也越来越成熟,相信很多朋友已经尝试或使用过 Nodejs 开发服务端项目了。
本文我将和大家一起回顾 Express,然后介绍一个超级外挂——OvernightJS,它强大的地方在于,它将为 Express 路由提供 TypeScript 装饰器支持,使得我们开发路由更加简单,代码复用性更好。
这里也希望帮助大家对 TypeScript 的装饰器有更深了解。

接下来跟本文主角 Leo 一起来看看这个外挂吧~

一、背景介绍

最近 Leo 打算使用 Express 来开始重构自己博客的服务端项目,经过认真研究和设计,并确定完方案,Leo 开始下手啦:

// app.ts

import express, { Application, Request, Response } from 'express';

const app: Application = express();

app.get('/', (req: Request, res: Response) => {
  res.send('Hello World!');
});

app.listen(3000, ()=> {
  console.log('Example app listening on port 3000!');
});

其中 tsconfig.json 配置如下:

{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "strict": true,
        "esModuleInterop": true,
    "experimentalDecorators": true, // 开启装饰器
    "emitDecoratorMetadata": true,  // 开启元编程
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

基本代码写完,测试能不能跑起来。
Leo 在命令行使用 ts-node 命令行执行。(ts-node 用来直接运行 ts 文件,详细介绍请查看文档,这里不细讲咯):

$ ts-node app.ts

看到命令行输出:

Example app listening on port 3000!

服务跑起来了,心情愉快。
接下来 Leo 使用 Express 的路由方法写了其他接口:

// app.ts

app.get('/article', (req: Request, res: Response) => {res.send('Hello get!')});
app.post('/article', (req: Request, res: Response) => {res.send('Hello post!')});
app.put('/article', (req: Request, res: Response) => {res.send('Hello put!')});
app.delete('/article', (req: Request, res: Response) => {res.send('Hello delete!')});
app.get('/article/list', (req: Request, res: Response) => {res.send('Hello article/list!')});
// ... 等等其他接口
Express 路由方法派生自 HTTP 方法之一,附加到 express 类的实例。 支持对应于 HTTP 方法的以下路由方法:get、post、put、head、delete、options等等。

同事 Robin 看了看代码,问到:
Overnight-Learn-1.png

随着接口越写越多,代码不免出现复杂和冗余的情况,为了解决这个问题,Leo 引入 Express 的 Router() ,来创建可安装的模块化路由处理程序。Router 实例是完整的中间件路由系统。因此,常常将其称为“微型应用程序”。

Leo 新建文件 app.router.ts ,重新实现上面接口:

// app.router.ts

import express, { Router, Request, Response } from 'express';
const router: Router = express.Router();

router.get('/', (req: Request, res: Response) => {res.send('Hello get!')});
router.post('/', (req: Request, res: Response) => {res.send('Hello post!')});
router.put('/', (req: Request, res: Response) => {res.send('Hello put!')});
router.delete('/', (req: Request, res: Response) => {res.send('Hello delete!')});
router.get('/user', (req: Request, res: Response) => {res.send('Hello api/user!')});

export default router;

接着在 app.ts 中使用,由于express.Router()是个中间件,因此可以使用 app.use() 来使用:

// app.ts

// 删除原来路由声明
import router from "../controller/app.router";
app.use('/api', router);

这里 app.use 第一个参数 /api 表示这一组路由对象的根路径,第二个参数 router 表示一组路由对象。

于是就实现了下面 API 接口:

  • /api
  • /api/user

确定所有接口正常运行后,Leo 琢磨着,既然 Express 每个路由都是由路由名称路由处理方法组成,那为什么不能给 Express 加个外挂?为每个路由添加装饰器来装饰。
幸运的是,已经有大佬实现这个外挂了,它就是今天主角——OvernightJS
下面一起看看这个很棒的 OvernightJS 吧。

二、基础知识介绍

Overnight-Learn-2.png
在开始介绍 Overnight 之前,我们先回顾下“装饰器”和“Reflect”:

1. 装饰器

1.1 什么是装饰器?

TypeScript 中,装饰器(Decorators)是一种特殊类型的声明,它能够被附加到类声明、方法、访问符、属性或参数上,本质上还是个函数
装饰器为我们在类的声明及成员上通过元编程语法添加标注提供了一种方式。

需要记住这几点:

  • 装饰器是一个声明(表达式);
  • 该表达式被执行后,返回一个函数
  • 函数的入参分别为 targetnamedescriptor
  • 执行该函数后,可能返回 descriptor 对象,用于配置 target 对象;

更多装饰器详细介绍,请阅读文档《TypeScript 装饰器》

1.2 装饰器分类

装饰器一般包括:

  • 类装饰器(Class decorators);
  • 属性装饰器(Property decorators);
  • 方法装饰器(Method decorators);
  • 参数装饰器(Parameter decorators);

1.3 示例代码

这里以类装饰器(Class decorators)为例,介绍如何使用装饰器:

function MyDecorators(target: Function): void {
  target.prototype.say = function (): void {
    console.log("Hello 前端自习课!");
  };
}

@MyDecorators
class LeoClass {
  constructor() {}
  say(){console.log("Hello Leo")}
}

let leo = new LeoClass();
leo.say(); 
// 'Hello Leo!';

1.4 编译结果

装饰器实际上非常简单,编译出来以后,只是个函数,我们接着看。
这里以《1.3 示例代码》为例,看看它的编译结果:

"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
    if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
function MyDecorators(target) {
    target.prototype.say = function () {
        console.log("Hello 前端自习课!");
    };
}
let LeoClass = class LeoClass {
    constructor() { }
    say() { console.log("Hello Leo"); }
};
LeoClass = __decorate([
    MyDecorators,
    __metadata("design:paramtypes", [])
], LeoClass);
let leo = new LeoClass();
leo.say();
// 'Hello Leo!';

其实就是 __decorate 函数啦,具体大家可以自行细看咯~
从编译后 JS 代码中可以看出,装饰器是在模块导入时便执行的。如下:

LeoClass = __decorate([
    MyDecorators,
    __metadata("design:paramtypes", [])
], LeoClass);

1.5 小结

接下来通过下图来回顾装饰器的知识。
Decorator-Introduce.png

2. Reflect Metadata API

2.1 什么是 Reflect ?

Reflect(即反射)是 ES6 新增的一个内置对象,它提供用来拦截和操作 JavaScript 对象的 API。并且 Reflect 的所有属性和方法都是静态的,就像 Math 对象( Math.random() 等)。

更多 Reflect 详细介绍,请阅读文档《MDN Reflect》

2.2 为什么出现 Reflect?

其核心目的,是为了保持 JS 的简单,让我们可以不用写很多代码,这里举个栗子🌰,看看有使用 Reflect 和没使用有什么区别:
当对象里有 Symbol 时,如何遍历对象的 keys

const s = Symbol('foo');
const k = 'bar';
const o = { [s]: 1, [k]: 1 };

// 没有使用 Reflect
const keys = Object.getOwnPropertyNames(o).concat(Object.getOwnPropertySymbols(o));

// 使用 Reflect
Reflect.ownKeys(o);

这看起来是不是简单多了?

更多 Reflect 详细介绍,请阅读文档《MDN Reflect》

2.3 什么是 Reflect Metadata

Reflect Metadata 是 ES7 的一个提案,它主要用来在声明的时添加和读取元数据。TypeScript 在 1.5+ 的版本已经支持它,你只需要:

  • npm i reflect-metadata --save
  • tsconfig.json 里配置 emitDecoratorMetadata 选项。

Reflect Metadata 可以当做装饰器使用,有两个 API:

  • 使用 Reflect.metadata() API 添加元数据
  • 使用 Reflect.getMetadata() API 读取元数据
@Reflect.metadata('inClass', 'A')
class LearnReflect {
  @Reflect.metadata('inMethod', 'B')
  public hello(): string {
    return 'hello world';
  }
}

console.log(Reflect.getMetadata('inClass', LearnReflect)); // 'A'
console.log(Reflect.getMetadata('inMethod', new LearnReflect(), 'hello')); // 'B'

当然 Reflect 提供很多其他 API:

import 'reflect-metadata';

// 定义对象或属性的元数据
Reflect.defineMetadata(metadataKey, metadataValue, target);
Reflect.defineMetadata(metadataKey, metadataValue, target, propertyKey);

// 检查对象或属性的原型链上是否存在元数据键
let result = Reflect.hasMetadata(metadataKey, target);
let result = Reflect.hasMetadata(metadataKey, target, propertyKey);

// 检查对象或属性是否存在自己的元数据键
let result = Reflect.hasMetadata(metadataKey, target);
let result = Reflect.hasMetadata(metadataKey, target, propertyKey);

// 获取对象或属性原型链上元数据键的元数据值
let result = Reflect.getMetadata(metadataKey, target);
let result = Reflect.getMetadata(metadataKey, target, propertyKey);

// 获取对象或属性的自己的元数据键的元数据值
let result = Reflect.getOwnMetadata(metadataKey, target);
let result = Reflect.getOwnMetadata(metadataKey, target, propertyKey);

// 获取对象或属性原型链上的所有元数据键
let result = Reflect.getMetadataKeys(target);
let result = Reflect.getMetadataKeys(target, propertyKey);

// 获取对象或属性的所有自己的元数据键
let result = Reflect.getOwnMetadataKeys(target);
let result = Reflect.getOwnMetadataKeys(target, propertyKey);

// 从对象或属性中删除元数据
let result = Reflect.deleteMetadata(metadataKey, target);
let result = Reflect.deleteMetadata(metadataKey, target, propertyKey);

// 通过装饰器将元数据应用于构造函数
@Reflect.metadata(metadataKey, metadataValue)
class C {
  // 通过装饰器将元数据应用于方法(属性)
  @Reflect.metadata(metadataKey, metadataValue)
  method() {
  }
}

需要记得配置 tsconfig.json:

{
  "compilerOptions": {
    "target": "es5",
    "lib": ["es6", "dom"],
    "types": ["reflect-metadata"],
    "module": "commonjs",
    "moduleResolution": "node",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

在 Overnight 中主要使用有两个 API:

  • 使用 Reflect.defineMetadata() API 添加元数据
  • 使用 Reflect.getOwnMetadata() API 读取元数据

下面以 Overnight 中类装饰器(Common Class)来介绍这两个 API 使用过程:
Reflect-Metadata-Use.png

2.4 小结

这里回顾下 Relect Metadata 的知识:
Reflect-Metadata-Introduce.png
理解清楚前面两个知识点后,我们接下来开始看看 Overnight。

三、Overnight 详解

1. 概念介绍

OvernightJS 主要是为 Express 路由提供 TypeScript 装饰器支持,通过装饰器来管理路由
是不是抽象了点?那看看下面这段代码吧:

@Controller('api/posts')
export class PostController {
    @Get(':id')
    private get(req: Request, res: Response) {
        // do something
    }
}

如上面代码所示,OvernightJS 就是这样使用,简单,明了。
另外 OvernightJS 共提供了三个库:

  • OvernightJS/core:核心库;
  • OvernightJS/logger:日志记录工具库;
  • OvernightJS/jwt:JWT 库;

接下来主要介绍 OvernightJS/core 核心库,其他两个有兴趣可以自己看哈,举一反三,其实核心一样的。

2. OvernightJS/core 快速上手

2.1 安装 OvernightJS/core

$ npm install --save @overnightjs/core express 
$ npm install --save-dev @types/express

2.2 OvernightJS/core 示例代码

首先介绍下我们示例代码需要实现的功能:

  1. UserController 类,负责管理业务逻辑的控制器;
  2. ServerController 类,负责管理服务逻辑的控制器;
  3. 执行服务启动;

第一步,导入需要的依赖:

import { Controller, Get, Server } from '@overnightjs/core';
import { Request, Response } from 'express';
import * as bodyParser from 'body-parser';
const port = 3000;

第二步,实现 UserController 类:

@Controller('/users')
class UserController {
    @Get('/:id')
    private get(req: Request, res: Response) {
        return res.send(`hello, your id is:${req.params.id}`)
    }
    @Get('/list')
    private getList(req: Request, res: Response) {
        return res.send([
          {name: "leo", age: 17},
          {name: "robin", age: 19}
        ])
    }
}

在声明 UserController 类时,使用 OvernightJS/core 的 @Controller 装饰器,使用 "/users" 路径作为参数,作用是为当前路由控制器指定一个路由地址,可以理解为这组路由的“根路径”,该类中实现的所有接口路径,都会以该“根路径”为基础。
然后在UserController 类中,通过 OvernightJS/core 提供 @Get 装饰器,分别使用 "/:id" 和 "/list" 路径作为参数,绑定路由。

最终 UserController 类实现的路由地址包括:

  • /user/:id
  • /users/list

第三步,实现 ServerController 类:

class ServerController extends Server {
    constructor() {
        super();
        this.app.use(bodyParser.json());
        super.addControllers(new UserController());
    }
    public start(port?: number): void {
        this.app.listen(port, () => {console.log('启动成功,端口号:',port)});
    }
}

ServerController 类继承 OvernightJS/core 提供的 Server 类,通过在构造函数中调用 super.addControllers(new UserController()) 来实现将前面声明好的路由控制器类,添加到OvernightJS/core 统一管理的控制器数组中。
另外在该类中,我们还声明 start 方法,用来启动服务器。

第四步,实现启动服务器逻辑:

const server = new ServerController();
server.start(port);

这里启动服务器就相当简单咯~~

 整个实现示例代码的流程如下:
声明了两个类: UserControllerServerController ,分别为业务逻辑的控制器服务逻辑的控制器,最后在主入口中去实例化,并执行实例化结果的 start 方法启动服务。
 
最后完整代码如下:

import { Controller, Get, Server } from '@overnightjs/core';
import { Request, Response } from 'express';
import * as bodyParser from 'body-parser';
const port = 3000;

@Controller('users')
class UserController {
    @Get(':id')
    private get(req: Request, res: Response) {
        return res.send(`hello, your id is:${req.params.id}`)
    }
    @Get('list')
    private get(req: Request, res: Response) {
        return res.send([
          {name: "leo", age: 17},
          {name: "robin", age: 19}
        ])
    }
}

class ServerController extends Server {
    constructor() {
        super();
        this.app.use(bodyParser.json());
        super.addControllers(new UserController());
    }
    public start(port?: number): void {
        this.app.listen(port, () => {console.log('启动成功,端口号:',port)});
    }
}

const server = new ServerController();
server.start(port);

 

3. OvernightJS/core 装饰器分析

在阅读源码过程中,我将 OvernightJS/core 中所有的装饰器按照源码目录结构维度做了分类,结果如下:
Overnight-Decorators-Classify.png
通过上图可以清晰看出,OvernightJS/core 为我们提供了四个大类的装饰器,具体的使用方式,还请看看官网文档啦~

4. OvernightJS/core 架构分析

OvernightJS/core 结构设计上还是比较简单,大致如下架构:
Overnight-Design.png
在 OvernightJS/core 中,主要提供两个大类: Server 类和 Decorators 相关方法。
其中 Server 类中的 addConterllers 方法是关键,下一节将详细介绍。哈哈

5. OvernightJS/core 与 Express 关联

回顾下 Express ,我们经常通过 app.use(path, route) 来定义一个接口:

app.use(path, route);

那么在 OvernightJS 中呢??
前一小节提到的addConterllers 方法是什么呢??

 其实 OvernightJS 本质上是通过调用 addConterllers() 方法来和 Express 做关联。
可以理解为 OvernightJS 与 Express 之间的桥梁,它将 OvernightJS/core 定义好的路由控制器作为参数,通过 Express 的 use 方法,将路由添加的 Express 中,实现 Express 路由注册。

 我们看下源码中addControllers 方法做了什么事情:

// core/lib/Server.ts

public addControllers(
    controllers: Controller | Controller[],
    routerLib?: RouterLib,
    globalMiddleware?: RequestHandler,
): void {
    controllers = (controllers instanceof Array) ? controllers : [controllers];
    const routerLibrary: RouterLib = routerLib || Router;
    controllers.forEach((controller: Controller) => {
        if (controller) {
            const routerAndPath: IRouterAndPath | null = this.getRouter(routerLibrary, controller);
            if (routerAndPath) {
                if (globalMiddleware) {
                    this.app.use(routerAndPath.basePath, globalMiddleware, routerAndPath.router);
                } else {
                    this.app.use(routerAndPath.basePath, routerAndPath.router);
                }
            }
        }
    });
}

我们简化下上面代码,保留核心功能的源码:

public addControllers(
    controllers: Controller | Controller[],
    routerLib?: RouterLib,
    globalMiddleware?: RequestHandler,
): void {
  // ... 省略其他代码
    controllers = (controllers instanceof Array) ? controllers : [controllers];
    controllers.forEach((controller: Controller) => {
        this.app.use(routerAndPath.basePath, routerAndPath.router);
    });
}

从上面代码可以看出, addControllers 方法支持传入单个 controller 或一个数组的 controller,方法内通过 forEach 遍历每个控制器,并将 path 和 router 作为参数传入 app.use 方法中,实现 Express 的路由注册。

四、Overnight VS Express

从前面概念介绍中,我们知道:OvernightJS 主要是为 Express 路由提供 TypeScript 装饰器支持,通过装饰器来管理路由。

那么使用 OvernightJS 跟没有使用有什么区别呢?
下面我们分别通过 OvernightJS 和 Express 实现相同功能,功能包括:本地启动 4000 端口,支持 api/users/:id 接口。

1. OvernightJS 实现

首先实现入口文件,其中通过实例化 ServerController 类,并执行实例化结构的 start 方法来启动服务:

// customApp.ts

import ServerController from "../controller/custom.server.controller";
const port = 4000;

const server = new ServerController();
server.start(port);

其中 tsconfig.json 配置如下:

{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

大致过程如上面代码,接下来要开始实现具体的 ServerController  类:

// controller/custom.server.controller.ts

import { Server } from "@overnightjs/core";
import RouterController from "./custom.router.controller";

class ServerController extends Server {
    constructor() {
        super();
        super.addControllers(new RouterController());
    }
    public start(port?: number): void {
        this.app.listen(port, () => {
            console.log('启动成功,端口号:',port)});
    }
}

export default ServerController;

 
最后实现 RouterController  类,该 API 下的路由方法,都定义在这个类中:

// controller/custom.router.controller.ts
import { Request, Response } from 'express';
import { Controller, Get, Put } from '@overnightjs/core';

@Controller("api/users")
class RouterController {
    @Get(":id")
    private get(req:Request, res:Response): any{
        res.send("hello leo!")
    }
}

export default RouterController;

 

2. Express 实现

跟前面一下,这里也是先实现入口文件:

// app.ts

import ServerController from "../controller/server.controller";
const port = 4000;

const server = new ServerController();
server.start(port);

然后实现具体的 ServerController  类:

// controller/server.controller/.ts

import express, { Application } from 'express';
import RouterController from "./router.controller";

class ServerController {
    app: Application = express();
    constructor(){this.addControllers()};
    public addControllers(){
        const Router = new RouterController().getController();
        this.app.use('/api/users', Router);
    }
    public start(port?: number): void {
        this.app.listen(port, () => {console.log('启动成功,端口号:',port)});
    }
}

export default ServerController;

 
最后实现 RouterController  类:

// controller/router.controller.ts

import express, { Router, Application, Request, Response, NextFunction } from "express";

class RouterController {
    router: Router = express.Router();
    constructor() { this.addControllers()};
    public getController = (): Router => this.router;
    public addControllers(): void {
        this.router.get("/:id", this.get);
    }
    public get (req: Request, res: Response, next: NextFunction){
        res.send("hello leo!")
        next();
    }
}

export default RouterController;

 

3. 两者比较

相信看到这里的朋友,对前面两种实现方法大致了解了,接下来通过一张图,来看看总结两者实现的区别吧。
Overnight-VS-Express.png

五、总结

本文主要介绍 OvernightJS 与 Express 路由功能的基本使用,然后分别用两者实现相同的路由功能,对比得出 OvernightJS 的优点,推荐使用 Express + TypeScript 的朋友可以尝试使用 OvernightJS 咯~ 

查看原文

赞 10 收藏 7 评论 2

pingan8787 赞了文章 · 9月29日

聊聊 ESM、Bundle 、Bundleless 、Vite 、Snowpack

前言

一切要都要从打包构建说起。

当下我们很多项目都是基于 webpack 构建的, 主要用于:

  • 本地开发
  • 打包上线

首先,webpack 是一个伟大的工具。

经过不断的完善,webpack 以及周边的各种轮子已经能很好的满足我们的日常开发需求。

我们都知道,webpack 具备将各类资源打包整合在一起,形成 bundle 的能力。

可是,当资源越来越多时,打包的时间也将越来越长。

一个中大型的项目, 启动构建的时间能达到数分钟之久。

拿我的项目为例, 初次构建大概需要三分钟, 而且这个时间会随着系统的迭代越来越长。

相信不少同学也都遇到过类似的问题。 打包时间太久,这是一个让人很难受的事情。

那有没有什么办法来解决呢?

当然是有的。

这就是今天的主角 ESM, 以及以它为基础的各类构建工具, 比如:

  1. Snowpack
  2. Vite
  3. Parcel

等等。

今天,我们就这个话题展开讨论, 希望能给大家一些其发和帮助。

文章较长,提供一个传送门:

  1. 什么是 ESM
  2. ESM 是如何工作的
  3. Bundle & Bundleless
  4. 实现一个乞丐版 Vite
  5. Snowpack & 实践
  6. bundleless 模式在实际开发中存在的一些问题
  7. 结论

正文

什么是 ESM

ESM 是理论基础, 我们都需要了解。

「 ESM 」 全称 ECMAScript modules,基本主流的浏览器版本都以已经支持。

image.png

ESM 是如何工作的

image.png

当使用ESM 模式时, 浏览器会构建一个依赖关系图。不同依赖项之间的连接来自你使用的导入语句。

通过这些导入语句, 浏览器 或 Node 就能确定加载代码的方式。

通过指定一个入口文件,然后从这个文件开始,通过其中的import语句,查找其他代码。

image.png

通过指定的文件路径, 浏览器就找到了目标代码文件。 但是浏览器并不能直接使用这些文件,它需要解析所有这些文件,以将它们转换为称为模块记录的数据结构。

image.png

然后,需要将 模块记录 转换为 模块实例

image.png

模块实例, 实际上是 「 代码 」(指令列表)与「 状态」(所有变量的值)的组合。

对于整个系统而言, 我们需要的是每个模块的模块实例。

模块加载的过程将从入口文件变为具有完整的模块实例图。

对于ES模块,这分为 三个步骤

  1. 构造—查找,下载所有文件并将其解析为模块记录。
  2. 实例化—查找内存中的框以放置所有导出的值(但尚未用值填充它们)。然后使导出和导入都指向内存中的那些框,这称为链接。
  3. 运行—运行代码以将变量的实际值填充到框中。

image.png

在构建阶段时, 发生三件事情:

  1. 找出从何处下载包含模块的文件
  2. 提取文件(通过从URL下载文件或从文件系统加载文件)
  3. 将文件解析为模块记录

1. 查找

首先,需要找到入口点文件。

在HTML中,可以通过脚本标记告诉加载程序在哪里找到它。

image.png

但是,如何找到下一组模块, 也就是 main.js 直接依赖的模块呢?

这就是导入语句的来源。

导入语句的一部分称为模块说明符, 它告诉加载程序可以在哪里找到每个下一个模块。

image.png

在解析文件之前,我们不知道模块需要获取哪些依赖项,并且在提取文件之前,也无法解析文件。

这意味着我们必须逐层遍历树,解析一个文件,然后找出其依赖项,然后查找并加载这些依赖项。

image.png

如果主线程要等待这些文件中的每个文件下载,则许多其他任务将堆积在其队列中。

那是因为当浏览器中工作时,下载部分会花费很长时间。

image.png

这样阻塞主线程会使使用模块的应用程序使用起来太慢。

这是ES模块规范将算法分为多个阶段的原因之一。

将构造分为自己的阶段,使浏览器可以在开始实例化的同步工作之前获取文件并建立对模块图的理解。

这种方法(算法分为多个阶段)是 ESMCommonJS模块 之间的主要区别之一。

CommonJS可以做不同的事情,因为从文件系统加载文件比通过Internet下载花费的时间少得多。

这意味着Node可以在加载文件时阻止主线程。

并且由于文件已经加载,因此仅实例化和求值(在CommonJS中不是单独的阶段)是有意义的。

这也意味着在返回模块实例之前,需要遍历整棵树,加载,实例化和评估任何依赖项。

该图显示了一个Node模块评估一个require语句,然后Node将同步加载和评估该模块及其任何依赖项

在具有CommonJS模块的Node中,可以在模块说明符中使用变量。

require在寻找下一个模块之前,正在执行该模块中的所有代码。这意味着当进行模块解析时,变量将具有一个值。

但是,使用ES模块时,需要在进行任何评估之前预先建立整个模块图。

这意味着不能在模块说明符中包含变量,因为这些变量还没有值。

使用变量的require语句很好。 使用变量的导入语句不是。

但是,有时将变量用于模块路径确实很有用。

例如,你可能要根据代码在做什么,或者在不同环境中运行来记载不同的模块。

为了使ES模块成为可能,有一个建议叫做动态导入。有了它,您可以使用类似的导入语句:

import(`${path}/foo.js`)

这种工作方式是将使用加载的任何文件import()作为单独图的入口点进行处理。

动态导入的模块将启动一个新图,该图将被单独处理。

两个模块图之间具有依赖性,并用动态导入语句标记

但是要注意一件事–这两个图中的任何模块都将共享一个模块实例。

这是因为加载程序会缓存模块实例。对于特定全局范围内的每个模块,将只有一个模块实例。

这意味着发动机的工作量更少。

例如,这意味着即使多个模块依赖该模块文件,它也只会被提取一次。(这是缓存模块的一个原因。我们将在评估部分中看到另一个原因。)

加载程序使用称为模块映射的内容来管理此缓存。每个全局变量在单独的模块图中跟踪其模块。

当加载程序获取一个URL时,它将把该URL放入模块映射中,并记下它当前正在获取文件。然后它将发出请求并继续以开始获取下一个文件。

加载程序图填充在“模块映射表”中,主模块的URL在左侧,而“获取”一词在右侧

如果另一个模块依赖于同一文件会怎样?加载程序将在模块映射中查找每个URL。如果在其中看到fetching,它将继续前进到下一个URL。

但是模块图不仅跟踪正在获取的文件。模块映射还充当模块的缓存,如下所示。

2. 解析

现在我们已经获取了该文件,我们需要将其解析为模块记录。这有助于浏览器了解模块的不同部分。

该图显示了被解析成模块记录的main.js文件

创建模块记录后,它将被放置在模块图中。这意味着无论何时从此处请求,加载程序都可以将其从该映射中拉出。

模块映射图中的“获取”占位符被模块记录填充

解析中有一个细节看似微不足道,但实际上有很大的含义。

解析所有模块,就像它们"use strict"位于顶部一样。还存在其他细微差异。

例如,关键字await是在模块的顶级代码保留,的值this就是undefined

这种不同的解析方式称为“解析目标”。如果解析相同的文件但使用不同的目标,那么最终将得到不同的结果。
因此,需要在开始解析之前就知道要解析的文件类型是否是模块。

在浏览器中,这非常简单。只需放入type="module"的script标签。
这告诉浏览器应将此文件解析为模块。并且由于只能导入模块,因此浏览器知道任何导入也是模块。

加载程序确定main.js是一个模块,因为script标签上的type属性表明是这样,而counter.js必须是一个模块,因为它已导入

但是在Node中,您不使用HTML标记,因此无法选择使用type属性。社区尝试解决此问题的一种方法是使用 .mjs扩展。使用该扩展名告诉Node,“此文件是一个模块”。您会看到人们在谈论这是解析目标的信号。目前讨论仍在进行中,因此尚不清楚Node社区最终决定使用什么信号。

无论哪种方式,加载程序都将确定是否将文件解析为模块。如果它是一个模块并且有导入,则它将重新开始该过程,直到提取并解析了所有文件。

我们完成了!在加载过程结束时,您已经从只有入口点文件变为拥有大量模块记录。

建设阶段的结果,左侧为JS文件,右侧为3个已解析的模块记录

下一步是实例化此模块并将所有实例链接在一起。

3. 实例化

就像我之前提到的,实例将代码与状态结合在一起。

该状态存在于内存中,因此实例化步骤就是将所有事物连接到内存。

首先,JS引擎创建一个模块环境记录。这将管理模块记录的变量。然后,它将在内存中找到所有导出的框。模块环境记录将跟踪与每个导出关联的内存中的哪个框。

内存中的这些框尚无法获取其值。只有在评估之后,它们的实际值才会被填写。该规则有一个警告:在此阶段中初始化所有导出的函数声明。这使评估工作变得更加容易。

为了实例化模块图,引擎将进行深度优先的后顺序遍历。这意味着它将下降到图表的底部-底部的不依赖其他任何内容的依赖项-并设置其导出。

中间的一列空内存。 计数和显示模块的模块环境记录已连接到内存中的框。

引擎完成了模块下面所有出口的接线-模块所依赖的所有出口。然后,它返回一个级别,以连接来自该模块的导入。

请注意,导出和导入均指向内存中的同一位置。首先连接出口,可以确保所有进口都可以连接到匹配的出口。

与上图相同,但具有main.js的模块环境记录,现在其导入链接到其他两个模块的导出。

这不同于CommonJS模块。在CommonJS中,整个导出对象在导出时被复制。这意味着导出的任何值(例如数字)都是副本。

这意味着,如果导出模块以后更改了该值,则导入模块将看不到该更改。

中间的内存中,有一个导出的通用JS模块指向一个内存位置,然后将值复制到另一个内存位置,而导入的JS模块则指向新位置

相反,ES模块使用称为实时绑定的东西。两个模块都指向内存中的相同位置。这意味着,当导出模块更改值时,该更改将显示在导入模块中。

导出值的模块可以随时更改这些值,但是导入模块不能更改其导入的值。话虽如此,如果模块导入了一个对象,则它可以更改该对象上的属性值。

导出模块更改内存中的值。 导入模块也尝试但失败。

之所以拥有这样的实时绑定,是因为您可以在不运行任何代码的情况下连接所有模块。当您具有循环依赖性时,这将有助于评估,如下所述。

因此,在此步骤结束时,我们已连接了所有实例以及导出/导入变量的存储位置。

现在我们可以开始评估代码,并用它们的值填充这些内存位置。

4. 执行

最后一步是将这些框填充到内存中。JS引擎通过执行顶级代码(函数外部的代码)来实现此目的。

除了仅在内存中填充这些框外,评估代码还可能触发副作用。例如,模块可能会调用服务器。

模块将在功能之外进行编码,标记为顶级代码

由于存在潜在的副作用,您只需要评估模块一次。与实例化中发生的链接可以完全相同的结果执行多次相反,评估可以根据您执行多少次而得出不同的结果。

这是拥有模块映射的原因之一。模块映射通过规范的URL缓存模块,因此每个模块只有一个模块记录。这样可以确保每个模块仅执行一次。与实例化一样,这是深度优先的后遍历。

那我们之前谈到的那些周期呢?

在循环依赖关系中,您最终在图中有一个循环。通常,这是一个漫长的循环。但是为了解释这个问题,我将使用一个简短的循环的人为例子。

左侧为4个模块循环的复杂模块图。 右侧有一个简单的2个模块循环。

让我们看一下如何将其与CommonJS模块一起使用。首先,主模块将执行直到require语句。然后它将去加载计数器模块。

一个commonJS模块,其变量是在require语句之后从main.js导出到counter.js的,具体取决于该导入

然后,计数器模块将尝试message从导出对象进行访问。但是由于尚未在主模块中对此进行评估,因此它将返回undefined。JS引擎将在内存中为局部变量分配空间,并将其值设置为undefined。

中间的内存,main.js和内存之间没有连接,但是从counter.js到未定义的内存位置的导入链接

评估一直持续到计数器模块顶级代码的末尾。我们想看看我们是否最终将获得正确的消息值(在评估main.js之后),因此我们设置了超时时间。然后评估在上恢复main.js

counter.js将控制权返回给main.js,从而完成评估

消息变量将被初始化并添加到内存中。但是由于两者之间没有连接,因此在所需模块中它将保持未定义状态。

main.js获取到内存的导出连接并填写正确的值,但是counter.js仍指向其中未定义的其他内存位置

如果使用实时绑定处理导出,则计数器模块最终将看到正确的值。到超时运行时,main.js的评估将完成并填写值。

支持这些循环是ES模块设计背后的重要理由。正是这种设计使它们成为可能。


(以上是关于 ESM 的理论介绍, 原文链接在文末)。

Bundle & Bundleless

谈及 Bundleless 的优势,首先是启动快

因为不需要过多的打包,只需要处理修改后的单个文件,所以响应速度是 O(1) 级别,刷新即可即时生效,速度很快。

image.png

所以, 在开发模式下,相比于Bundle,Bundleless 有着巨大的优势。

基于 Webpack 的 bundle 开发模式

image.png
上面的图具体的模块加载机制可以简化为下图:
image.png
在项目启动和有文件变化时重新进行打包,这使得项目的启动和二次构建都需要做较多的事情,相应的耗时也会增长。

基于 ESModule 的 Bundleless 模式

image.png
从上图可以看到,已经不再有一个构建好的 bundle、chunk 之类的文件,而是直接加载本地对应的文件。
image.png
从上图可以看到,在 Bundleless 的机制下,项目的启动只需要启动一个服务器承接浏览器的请求即可,同时在文件变更时,也只需要额外处理变更的文件即可,其他文件可直接在缓存中读取。

对比总结

image.png

Bundleless 模式可以充分利用浏览器自主加载的特性,跳过打包的过程,使得我们能在项目启动时获取到极快的启动速度,在本地更新时只需要重新编译单个文件。

实现一个乞丐版 Vite

Vite 也是基于 ESM 的, 文件处理速度 O(1)级别, 非常快。

作为探索, 我就简单实现了一个乞丐版Vite:

GitHub 地址: Vite-mini

image.png

简要分析一下。

<body>
  <div id="app"></div>
  <script type="module" data-original="/src/main.js"></script>
</body>

html 文件中直接使用了浏览器原生的 ESM(type="module") 能力。

所有的 js 文件经过 vite 处理后,其 import 的模块路径都会被修改,在前面加上 /@modules/。当浏览器请求 import 模块的时候,vite 会在 node_modules 中找到对应的文件进行返回。

image.png

其中最关键的步骤就是模块的记载和解析, 这里我简单用koa简单实现了一下, 整体结构:

const fs = require('fs');
const path = require('path');
const Koa = require('koa');
const compilerSfc = require('@vue/compiler-sfc');
const compileDom = require('@vue/compiler-dom');
const app = new Koa();

// 处理引入路径
function rewriteImport(content) {
  // ...
}

// 处理文件类型等, 比如支持ts, less 等类似webpack的loader的功能
app.use(async (ctx) => {
  // ...
}

app.listen(3001, () => {
  console.log('3001');
});

我们先看路径相关的处理:

function rewriteImport(content) {
    return content.replace(/from ['"]([^'"]+)['"]/g, function (s0, s1) {
        // import a from './c.js' 这种格式的不需要改写
        // 只改写需要去node_module找的
        if (s1[0] !== '.' && s1[0] !== '/') {
          return `from '/@modules/${s1}'`;
        }
        return s0;
    });
}

处理文件内容: 源码地址

image.png

后续的都是类似的:

image.png

这个代码只是解释实现原理, 不同的文件类型处理逻辑其实可以抽离出去, 以中间件的形式去处理。

代码实现的比较简单, 就不额解释了。

Snowpack

image.png

和 webpack 的对比:

image.png

我使用 Snowpack 做了个 demo , 支持打包, 输出 bundle。

github: Snowpack-React-Demo

image.png

能够清晰的看到, 控制台产生了大量的文件请求(也叫瀑布网络请求),

不过因为都是加载的本地文件, 所以速度很快。

配合HMR, 实现编辑完成立刻生效, 几乎不用等待:

image.png

但是如果是在生产中,这些请求对于生产中的页面加载时间而言, 就不太好了。

尤其是HTTP1.1,浏览器都会有并行下载的上限,大部分是5个左右,所以如果你有60个依赖性要下载,就需要等好长一点。

虽然说HTTP2多少可以改善这问题,但若是东西太多,依然没办法。

关于这个项目的打包, 直接执行build:

image.png

打包完成后的文件目录,和传统的 webpack 基本一致:

image.png

在 build 目录下启动一个静态文件服务:

image.png

build 模式下,还是借助了 webpack 的打包能力:

image.png

做了资源合并:

image.png

就这点而言, 我认为未来一段时间内, 生产环境还是不可避免的要走bundle模式。

bundleless 模式在实际开发中的一些问题

开门见山吧, 开发体验不是很友好,几点比较突出的问题:

  • 部分模块没有提供 ESModule 的包。(这一点尤为致命)
  • 生态不够健全,工具链不够完善;

当然还有其他方方面面的问题, 就不一一列举。

我简单改造了一个页面, 就遇到很多奇奇怪怪的问题, 开发起来十分难受, 尽管代码的修改能立刻生效。

结论

bundleless 能在开发模式下带了很大的便利。 但就目前来说,要运用到生产的话, 还是有一段路要走的。

就目当下而言, 如果真的要用的话,可能还是 bundleless(dev) + bundle(production) 的组合。

至于未来能不能全面铺开 bundleless,我认为还是有可能的, 交给时间吧。

结尾

本文主要介绍了 esm 的原理, 以及介绍了以此为基础的Vite, Snowpack 等工具, 提供了两个可运行的 demo:

  1. Vite-mini
  2. Snowpack-React-Demo

并探索了 bundleless 在生产中的可行性。

Bundleless, 本质上是将原先 Webpack 中模块依赖解析的工作交给浏览器去执行,使得在开发过程中代码的转换变少,极大地提升了开发过程中的构建速度,同时也可以更好地利用浏览器的相关开发工具。

最后,也非常感谢 ESModule、Vite、Snowpack 等标准和工具的出现,为前端开发提效。

才疏学浅, 文中若有错误,还能各位大佬指正, 谢谢。

参考资料

  1. https://hacks.mozilla.org/201...
  2. https://developer.aliyun.com/...
查看原文

赞 37 收藏 16 评论 5

pingan8787 赞了文章 · 9月27日

⚡️前端多线程大文件下载实践,提速10倍,拿捏百度云盘

背景

没错,你没有看错,是前端多线程,而不是Node。这一次的探索起源于最近开发中,有遇到视频流相关的开发需求发现了一个特殊的状态码,他的名字叫做 206~

屏幕快照 2020-09-21 23.21.05

为了防止本文的枯燥,先上效果图镇文。(以一张3.7M 大小的图片为例)。

动画效果对比(单线程-左 VS 10个线程-右)

single-vs-multiple-donwload

时间对比(单线程 VS 10个线程)

image-20200915235421355

看到这里是不是有点心动,那么请你继续听我道来,那我们先抓个包来看看整个过程是怎么发生的。

`GET /360_0388.jpg HTTP/1.1
Host: limit.qiufeng.com
Connection: keep-alive
...
Range: bytes=0-102399

HTTP/1.1 206 Partial Content
Server: openresty/1.13.6.2
Date: Sat, 19 Sep 2020 06:31:11 GMT
Content-Type: image/jpeg
Content-Length: 102400
....
Content-Range: bytes 0-102399/3670627

...(这里是文件流)` 

可以看到请求这里多出一个字段 Range: bytes=0-102399 ,服务端也多出一个字段Content-Range: bytes 0-102399/3670627,以及返回的 状态码为 206.

那么Range是什么呢?还记得前几天写过一篇文章,是关于文件下载的,其中有提到大文件的下载方式,有个叫 Range的东西,但是上一篇作为系统性地介绍文件下载的概览,因此没有对range 进行详细介绍。

以下所有代码均在 https://github.com/hua1995116/node-demo/tree/master/file-download/example/download-multiple

Range 基本介绍

Range的起源

Range是在 HTTP/1.1 中新增的一个字段,这个特性也是我们使用的迅雷等支持多线程下载以及断点下载的核心机制。(介绍性的文案,摘录了一下)

首先客户端会发起一个带有Range: bytes=0-xxx的请求,如果服务端支持 Range,则会在响应头中添加Accept-Ranges: bytes来表示支持 Range 的请求,之后客户端才可能发起带 Range 的请求。

服务端通过请求头中的Range: bytes=0-xxx 来判断是否是进行 Range 处理,如果这个值存在而且有效,则只发回请求的那部分文件内容,响应的状态码变成206,表示Partial Content,并设置Content-Range。如果无效,则返回416状态码,表明Request Range Not Satisfiable。如果请求头中不带 Range,那么服务端则正常响应,也不会设置 Content-Range 等。

image.png

Range的格式为:

Range:(unit=first byte pos)-[last byte pos]

Range: 单位(如bytes)= 开始字节位置-结束字节位置

我们来举个例子,假设我们开启了多线程下载,需要把一个5000byte的文件分为4个线程进行下载。

  • Range: bytes=0-1199 头1200个字节
  • Range: bytes=1200-2399 第二个1200字节
  • Range: bytes=2400-3599 第三个1200字节
  • Range: bytes=3600-5000 最后的1400字节

服务器给出响应:

第1个响应

  • Content-Length:1200
  • Content-Range:bytes 0-1199/5000

第2个响应

  • Content-Length:1200
  • Content-Range:bytes 1200-2399/5000

第3个响应

  • Content-Length:1200
  • Content-Range:bytes 2400-3599/5000

第4个响应

  • Content-Length:1400
  • Content-Range:bytes 3600-5000/5000

如果每个请求都成功了,服务端返回的response头中有一个 Content-Range 的字段域,Content-Range 用于响应头,告诉了客户端发送了多少数据,它描述了响应覆盖的范围和整个实体长度。一般格式:

Content-Range: bytes (unit first byte pos) - [last byte pos]/[entity length]Content-Range:字节 开始字节位置-结束字节位置/文件大小

浏览器支持情况

主流浏览器目前都支持这个特性。

image-20200916002624861

服务器支持

Nginx

在版本nginx版本 1.9.8 后,(加上 ngx_http_slice_module)默认自动支持,可以将 max_ranges 设置为 0的来取消这个设置。

Node

Node 默认不提供 对 Range 方法的处理,需要自己写代码进行处理。

router.get('/api/rangeFile', async(ctx) => {
    const { filename } = ctx.query;
    const { size } = fs.statSync(path.join(__dirname, './static/', filename));
    const range = ctx.headers['range'];
    if (!range) {
        ctx.set('Accept-Ranges', 'bytes');
        ctx.body = fs.readFileSync(path.join(__dirname, './static/', filename));
        return;
    }
    const { start, end } = getRange(range);
    if (start >= size || end >= size) {
        ctx.response.status = 416;
        ctx.body = '';
        return;
    }
    ctx.response.status = 206;
    ctx.set('Accept-Ranges', 'bytes');
    ctx.set('Content-Range', `bytes ${start}-${end ? end : size - 1}/${size}`);
    ctx.body = fs.createReadStream(path.join(__dirname, './static/', filename), { start, end });
}) 

或者你可以使用 koa-send 这个库。

https://github.com/pillarjs/send/blob/0.17.1/index.js#L680

Range实践

架构总览

我们先来看下流程架构图总览。单线程很简单,正常下载就可以了,不懂的可以参看我上一篇文章。多线程的话,会比较麻烦一些,需要按片去下载,下载好后,需要进行合并再进行下载。(关于blob等下载方式依旧可以参看上一篇

1600705973008

服务端代码

很简单,就是对Range做了兼容。

router.get('/api/rangeFile', async(ctx) => {
    const { filename } = ctx.query;
    const { size } = fs.statSync(path.join(__dirname, './static/', filename));
    const range = ctx.headers['range'];
    if (!range) {
        ctx.set('Accept-Ranges', 'bytes');
        ctx.body = fs.readFileSync(path.join(__dirname, './static/', filename));
        return;
    }
    const { start, end } = getRange(range);
    if (start >= size || end >= size) {
        ctx.response.status = 416;
        ctx.body = '';
        return;
    }
    ctx.response.status = 206;
    ctx.set('Accept-Ranges', 'bytes');
    ctx.set('Content-Range', `bytes ${start}-${end ? end : size - 1}/${size}`);
    ctx.body = fs.createReadStream(path.join(__dirname, './static/', filename), { start, end });
}) 

html

然后来编写 html ,这没有什么好说的,写两个按钮来展示。

<!-- html -->
<button id="download1">串行下载</button>
<button id="download2">多线程下载</button>
<script data-original="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script> 

js公共参数

const m = 1024 * 520;  // 分片的大小
const url = 'http://localhost:8888/api/rangeFile?filename=360_0388.jpg'; // 要下载的地址 

单线程部分

单线程下载代码,直接去请求以blob方式获取,然后用blobURL 的方式下载。

download1.onclick = () => {
    console.time("直接下载");
    function download(url) {
        const req = new XMLHttpRequest();
        req.open("GET", url, true);
        req.responseType = "blob";
        req.onload = function (oEvent) {
            const content = req.response;
            const aTag = document.createElement('a');
            aTag.download = '360_0388.jpg';
            const blob = new Blob([content])
            const blobUrl = URL.createObjectURL(blob);
            aTag.href = blobUrl;
            aTag.click();
            URL.revokeObjectURL(blob);
            console.timeEnd("直接下载");
        };
        req.send();
    }
    download(url);
} 

多线程部分

首先发送一个 head 请求,来获取文件的大小,然后根据 length 以及设置的分片大小,来计算每个分片是滑动距离。通过Promise.all的回调中,用concatenate函数对分片 buffer 进行一个合并成一个 blob,然后用blobURL 的方式下载。

// script
function downloadRange(url, start, end, i) {
    return new Promise((resolve, reject) => {
        const req = new XMLHttpRequest();
        req.open("GET", url, true);
        req.setRequestHeader('range', `bytes=${start}-${end}`)
        req.responseType = "blob";
        req.onload = function (oEvent) {
            req.response.arrayBuffer().then(res => {
                resolve({
                    i,
                    buffer: res
                });
            })
        };
        req.send();
    })
}
// 合并buffer
function concatenate(resultConstructor, arrays) {
    let totalLength = 0;
    for (let arr of arrays) {
        totalLength += arr.length;
    }
    let result = new resultConstructor(totalLength);
    let offset = 0;
    for (let arr of arrays) {
        result.set(arr, offset);
        offset += arr.length;
    }
    return result;
}
download2.onclick = () => {
    axios({
        url,
        method: 'head',
    }).then((res) => {
        // 获取长度来进行分割块
        console.time("并发下载");
        const size = Number(res.headers['content-length']);
        const length = parseInt(size / m);
        const arr = []
        for (let i = 0; i < length; i++) {
            let start = i * m;
            let end = (i == length - 1) ?  size - 1  : (i + 1) * m - 1;
            arr.push(downloadRange(url, start, end, i))
        }
        Promise.all(arr).then(res => {
            const arrBufferList = res.sort(item => item.i - item.i).map(item => new Uint8Array(item.buffer));
            const allBuffer = concatenate(Uint8Array, arrBufferList);
            const blob = new Blob([allBuffer], {type: 'image/jpeg'});
            const blobUrl = URL.createObjectURL(blob);
            const aTag = document.createElement('a');
            aTag.download = '360_0388.jpg';
            aTag.href = blobUrl;
            aTag.click();
            URL.revokeObjectURL(blob);
            console.timeEnd("并发下载");
        })
    })
} 

完整示例

https://github.com/hua1995116/node-demo
`// 进入目录
cd file-download
// 启动
node server.js
// 打开 
http://localhost:8888/example/download-multiple/index.html` 

由于谷歌浏览器在 HTTP/1.1 对于单个域名有所限制,单个域名最大的并发量是 6.

这一点可以在源码以及官方人员的讨论中体现。

讨论地址

https://bugs.chromium.org/p/chromium/issues/detail?id=12066

Chromium 源码

// https://source.chromium.org/chromium/chromium/src/+/refs/tags/87.0.4268.1:net/socket/client_socket_pool_manager.cc;l=47
// Default to allow up to 6 connections per host. Experiment and tuning may
// try other values (greater than 0).  Too large may cause many problems, such
// as home routers blocking the connections!?!?  See http://crbug.com/12066.
//
// WebSocket connections are long-lived, and should be treated differently
// than normal other connections. Use a limit of 255, so the limit for wss will
// be the same as the limit for ws. Also note that Firefox uses a limit of 200.
// See http://crbug.com/486800
int g_max_sockets_per_group[] = {
    6,   // NORMAL_SOCKET_POOL
    255  // WEBSOCKET_SOCKET_POOL
}; 

因此为了配合这个特性我将文件分成6个片段,每个片段为520kb (没错,写个代码都要搞个爱你的数字),即开启6个线程进行下载。

我用单个线程和多个线程进行分别下载了6次,看上去速度是差不多的。那么为什么和我们预期的不一样呢?

image-20200919165242745

探索失败的原因

我开始仔细对比两个请求,观察这两个请求的速度。

6个线程并发

image-20200919170313455

单个线程

image-20200919170512650

我们按照3.7M 82ms 的速度来算的话,大约为 1ms 下载 46kb,而实际情况可以看到,533kb ,平均就要下载 20ms 左右(已经刨去了连接时间,纯 content 下载时间)。

我就去查找了一些资料,明白了有个叫做下行速度和上行速度的东西。

网络的实际传输速度要分上行速度和下行速度,上行速率就是发送出去数据的速度,下行就是收到数据的速度。ADSL是根据我们平时上网,发出数据的要求相对下载数据的较小这种习惯来实现的一种传输方式。我们说对于4M的宽带,那么我们的l理论最高下载速度就是512K/S,这就是所说的下行速度。 --百度百科

那我们现在的情况是怎么样的呢?

把服务器比作一根大水管,我来用图模拟一下我们单个线程和多个线程下载的情况。左侧为服务器端,右侧为客户端。(以下所有情况都是考虑理想情况下,只是为了模拟过程,不考虑其他一些程序的竞态影响。)

单线程

IMG_01

多线程

IMG_02

没错,由于我们的服务器是一根大水管,流速是一定的,并且我们客户端没有限制。如果是单线程跑的话,那么会跑满用户的最大的速度。如果是多线程呢,以3个线程为例子的话,相当于每个线程都跑了原先线程三分之一的速度。合起来的速度和单个线程是没有差别的。

下面我就分几种情况来讲解一下,什么样的情况才我们的多线程才会生效呢?

服务器带宽大于用户带宽,不做任何限制

这种情况其实我们遇到的情况差不多的。

服务器带宽远大于用户带宽,限制单连接网速

IMG_03

如果服务器限制了单个宽带的下载速度,大部分也是这种情况,例如百度云就是这样,例如明明你是 10M 的宽带,但是实际下载速度只有 100kb/s ,这种情况下,我们就可以开启多线程去下载,因为它往往限制的是单个TCP的下载,当然在线上环境不是说可以让用户开启无限多个线程,还是会有限制的,会限制你当前IP的最大TCP。这种情况下下载的上限往往是你的用户最大速度。按照上面的例子,如果你开10个线程已经达到了最大速度,因为再大,你的入口已经被限制死了,那么各个线程之间就会抢占速度,再多开线程也没有用了。

改进方案

由于 Node 我暂时没有找到比较简单地控制下载速度的方法,因此我就引入了 Nginx。

我们将每个TCP连接的速度控制在 1M/s。

加入配置 limit_rate 1M;

准备工作

1.nginx_conf

server {
    listen 80;
    server_name limit.qiufeng.com;
    access_log  /opt/logs/wwwlogs/limitqiufeng.access.log;
    error_log  /opt/logs/wwwlogs/limitqiufeng.error.log;

    add_header Cache-Control max-age=60;
    add_header Access-Control-Allow-Origin *;
    add_header Access-Control-Allow-Methods 'GET, OPTIONS';
    add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,range,If-Range';
    if ($request_method = 'OPTIONS') {
        return 204;
    }
    limit_rate 1M;
    location / {
        root 你的静态目录;
        index index.html;
    }
} 

2.配置本地 host

`127.0.0.1 limit.qiufeng.com` 

查看效果,这下基本上速度已经是正常了,多线程下载比单线程快了速度。基本是 5-6 : 1 的速度,但是发现如果下载过程中快速点击数次后,使用Range下载会越来越快(此处怀疑是 Nginx 做了什么缓存,暂时没有深入研究)。

修改代码中的下载地址
const url = 'http://localhost:8888/api/rangeFile?filename=360_0388.jpg';
变成
const url = 'http://limit.qiufeng.com/360_0388.jpg'; 

测试下载速度

image-20200919201613507

还记得上面说的吗,关于 HTTP/1.1 同一站点只能并发 6 个请求,多余的请求会放到下一个批次。但是 HTTP/2.0 不受这个限制,多路复用代替了 HTTP/1.x序列和阻塞机制。让我们来升级 HTTP/2.0 来测试一下。

需要本地生成一个证书。(生成证书方法: https://juejin.im/post/6844903556722475021)

server {
    listen 443 ssl http2;
    ssl on;
    ssl_certificate /usr/local/openresty/nginx/conf/ssl/server.crt;
    ssl_certificate_key /usr/local/openresty/nginx/conf/ssl/server.key;
    ssl_session_cache shared:le_nginx_SSL:1m;
    ssl_session_timeout 1440m;

    ssl_protocols SSLv3 TLSv1 TLSv1.1 TLSv1.2;
    ssl_ciphers RC4:HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;
    server_name limit.qiufeng.com;
 
    access_log  /opt/logs/wwwlogs/limitqiufeng2.access.log;
    error_log  /opt/logs/wwwlogs/limitqiufeng2.error.log;

    add_header Cache-Control max-age=60;
    add_header Access-Control-Allow-Origin *;
    add_header Access-Control-Allow-Methods 'GET, OPTIONS';
    add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,range,If-Range';
    if ($request_method = 'OPTIONS') {
        return 204;
    }
    limit_rate 1M;
    location / {
        root 你存放项目的前缀路径/node-demo/file-download/;
        index index.html;
    }
} 

10个线程

`将单个下载大小进行修改
const m = 1024 * 400;` 

image-20200919200203877

12个线程

image-20200919202302096

24个线程

image-20200919202138838

当然线程不是越多越好,经过测试,发现线程达到一定数量的时候,反而速度会更加缓慢。以下是 36个并发请求的效果图。

image-20200919202427985

实际应用探索

那么多进程下载到底有啥用呢?没错,开头也说了,这个分片机制是迅雷等下载软件的核心机制。

网易云课堂

https://study.163.com/course/courseLearn.htm?courseId=1004500008#/learn/video?lessonId=1048954063&courseId=1004500008

我们打开控制台,很容易地发现这个下载 url,直接一个裸奔的 mp4 下载地址。

image-20200920222053726

把我们的测试脚本从控制台输入进行。

// 测试脚本,由于太长了,而且如果仔细看了上面的文章也应该能写出代码。实在写不出可以看以下代码。
https://github.com/hua1995116/node-demo/blob/master/file-download/example/download-multiple/script.js 

直接下载

image-20200920221657541

多线程下载

image-20200920221853959

可以看到由于网易云课堂对单个TCP的下载速度并没有什么限制没有那么严格,提升的速度不是那么明显。

百度云

我们就来测试一下网页版的百度云。

image-20200919210106839

以一个 16.6M的文件为例。

打开网页版百度云盘的界面,点击下载

image-20200920222309345

这个时候点击暂停, 打开 chrome -> 更多 -> 下载内容 -> 右键复制下载链接

image-20200922004619680

依旧用上述的网易云课程下载课程的脚本。只不过你需要改一下参数。

`url 改成对应百度云下载链接
m 改成 1024 * 1024 * 2 合适的分片大小~` 

直接下载

百度云多单个TCP连接的限速,真的是惨无人道,足足花了217秒!!!就一个17M的文件,平时我们饱受了它多少的折磨。(除了VIP玩家)

image-20200919211105023

多线程下载

image-20200919210516632

由于是HTTP/1.1 因此我们只要开启6个以及以上的线程下载就好了。以下是多线程下载的速度,约用时 46 秒。

image-20200919210550840

我们通过这个图再来切身感受一下速度差异。

image-20200922010911389

真香,免费且只靠我们前端自己实现了这个功能,太tm香了,你还不赶紧来试试??

方案缺陷

1.对于大文件的上限有一定的限制

由于 blob 在 各大浏览器有上限大小的限制,因此该方法还是存在一定的缺陷。

image.png

2. 服务器对单个TCP速度有所限制

一般情况下都会有限制,那么这个时候就看用户的宽度速度了。

结尾

文章写的比较仓促,表达可能不是特别精准,如有错误之处,欢迎各位大佬指出。

回头调研下,有没有网页版百度云加速的插件,如果没有就造一个网页版百度云下载的插件~。

系列文章

参考文献

Nginx带宽控制 : https://blog.huoding.com/2015/03/20/423

openresty 部署 https 并开启 http2 支持 : https://www.gryen.com/articles/show/5.html

聊一聊HTTP的Range : https://dabing1022.github.io/2016/12/24/聊一聊HTTP的Range, Content-Range/

最后

如果我的文章有帮助到你,希望你也能帮助我,欢迎关注我的微信公众号 秋风的笔记,回复好友 二次,可加微信并且加入交流群,秋风的笔记 将一直陪伴你的左右。

image

查看原文

赞 87 收藏 60 评论 7

认证与成就

  • 获得 595 次点赞
  • 获得 11 枚徽章 获得 0 枚金徽章, 获得 2 枚银徽章, 获得 9 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • word-file-transform

    Word 文档解析工具,轻松将 Word 文档导入富文本编辑器,支持自定义文档图片上传😄

  • webpack-quickly-starter

    快速创建一个本地 Webpack 开发环境,已内置多种常用 Webpack 优化方式😄(使用建议:用于学习 Webpack 时快速创建本地环境)

  • Cute-JavaScript

    一本很简单的JavaScript入门手册,目前已包括:ECMAScript规范(ES6,ES7,ES8,ES9)内容,还有JavaSCript基础知识的总结,接下来继续维护,后续还会添加面试题等。

  • Cute-FrontEnd

    王平安前端知识库整理,公众号【前端自习课】,欢迎关注!

注册于 2016-11-08
个人主页被 3.5k 人浏览