Coder Social home page Coder Social logo

blog's Introduction

Hi I'm vortesnail 👋

vortesnail's github stats

⌨️ A passionate front-end developer who enjoys coding.
🎮 A dedicated single-player gamer.
📷 Becoming a skilled photographer.

Working for life, coding for love!

  • Read my blog to learn more about me.
  • Contact with me by email or wechat: vortesnail.
  • Follow me on juejin or bilibili.

typeroom 欢迎大家使用 typeroom.cn ,一个让你简单方便地进行 TypeScript 类型体操的小站 ❤️❤️,题目基于 type-challenges,在原题基础上翻译润色,提供对新手友好的题解,云端存储的在线 IDE 环境等~

blog's People

Contributors

vortesnail avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

blog's Issues

从头到尾给你讲清楚如何实现一个new

从头到尾给你讲清楚如何实现一个new

前言

“诶,你讲讲如何实现一个new吧,有必要也可以写一下。”,面试官微笑(或严肃)着脸说道,并递给了你纸和笔。
“emmmm,那个,新建一个对象,emm,然后....emmm..不好意思,有点记不得了”。你也尴尬而不失礼貌地笑笑😊。

那我们今天就来聊聊这个东西怎么写呗~

对于原型链继承关系可参考我的一篇博客:
原型链继承详解

原生的 new 写法:

我们平常用new的时候,是这样用的:

function People(name) {
  this.name = name;
  this.job = '前端工程师';
}

People.prototype.showName = function() {
  console.log(this.name);
}

const coder = new People('vortesnail');
coder.showName();	// vortesnail
console.log(coder.job);	// '前端工程师'

我们可以看到:
1.实例可以访问到构造函数的属性和方法(此处访问到的是 this.name )
2.实例可以访问到构造函数原型中的属性和方法(此处访问到的是原型中的 showName  这个方法)

重写 new

我们依然先来构建我们的构造函数,与上面一样:

function People(name) {
  this.name = name;
  this.job = '前端工程师';
}

People.prototype.showName = function() {
  console.log(this.name);
}

这个时候,内存分布是这样的:
Untitled1.png

分析原生的 new 我们可以知道他都做了哪些工作:
1.毫无疑问,首先我们有一个新的实例对象产生了。

let newObj = new Object();	// 或 let newObj = [];

2.它可以访问到构造函数原型中属性和方法,那一定是这个新的实例对象与构造函数的原型构成了原型链继承关系。

newObj.__proto__ = People.prototype;

这个时候,内存分配是这样的:
Untitled1 (2).png

3.它还访问到了构造函数中的属性和方法,那就要改变 People 中的 this 指向了。

People.apply(obj, arg);	// 或 People.call(obj, ...arg);
  1. return 出这个新对象 newObj

因为原生 new 是一个关键字,我们无法用 new Foo 这中写法,我们可将我们自己写的 _new 作为一个函数。

初步的实现如下:

function People(name) {
  this.name = name;
  this.job = '前端工程师';
}

People.prototype.showName = function() {
  console.log(this.name);
}

function _new(constructer, ...arg) {
  let newObj = new Object();
  newObj.__proto__ = constructer.prototype;
  constructer.apply(newObj, arg);
  return newObj;
}

let coder = _new(People, 'vortesnail');
coder.showName();	// vortesnail
console.log(coder.job);	// '前端工程师'

至此,我们写的 _new 与原生 new 似乎实现了相同的功能,此时内存分布如下:
Untitled1 (3).png

当然了,以上的代码是可以进行优化的,如果构造函数有返回且返回的是个指定对象呢,比如:

function People(name) {
  this.name = name;
  this.job = '前端工程师';
  this.result = 'People返回了一个对象';
  return {
  	result: this.result
  }
}

const coder = new People('vortesnail');	// 原生 new
console.log(coder);		                         // { result: 'People返回了一个对象' }
console.log(coder.name, coder.job);		// undefined undefined

如果还是老样子的写法,依然可以访问到构造函数内部的属性,这显然不是程序设计者的初衷,那怎么改进呢?

function _new(constructer, ...arg) {
  let newObj = new Object();
  newObj.__proto__ = constructer.prototype;
  const ret = constructer.apply(newObj, arg);
  return ret instanceof Object ? ret : newObj;
}

分析上面代码,可以看到 constructer.apply(newObj, arg); 之后有一个返回值,根据官方文档:

js中的call(), apply()和bind()是Function.prototype下的方法,都是用于改变函数运行时上下文,最终的返回值是你调用的方法的返回值,若该方法没有返回值,则返回undefined

上面这句话是啥意思?假如我们原先 People 这个构造函数没有返回值,就是后来写的那个碍眼的 return { result: this.result} ,那返回值就是 undefined ,现在有了,那就是构造函数中返回的这个对象。

所以, return ret instanceof Object ? ret : newObj; 就是说:如果你有返回对象,那就将这个对象 return 出去;没有返回对象,那就像原来一样返回我们一开始建的那个新对象。

至此,我们已经实现了完整的功能,但是还可以这样写:

function _new(constructer, ...arg) {
  let newObj = Object.create(constructer.prototype);	// 这句话顶了之前写的两句话
  const ret = constructer.apply(newObj, arg);
  return ret instanceof Object ? ret : newObj;
}

关于 Object.create() 的用法,大家就自行Google吧~~

完整实现:

function People(name) {
  this.name = name;
  this.job = '前端工程师';
  this.result = 'People这个构造函数返回了一个对象';
  // return {
  //   result: this.result
  // }
}

People.prototype.showName = function() {
  console.log(this.name);
}

function _new(constructer, ...arg) {
  let newObj = Object.create(constructer.prototype);
  const ret = constructer.apply(newObj, arg);
  return ret instanceof Object ? ret : newObj;
}

// let coder = _new(People, 'vortesnail');
let coder = _new(People, 'vortesnail');
coder.showName();
console.log(coder.job);

强调:返回的若是对象,属性和方法都是调用不了的,我想这个大家应该都清楚吧?

图文并茂总结7个工作中常用的css3案例,带你了解冷门却实用的特性!

前言

最近看了《css 揭秘》这本神书,学到了很多技巧,工作中遇到的一些问题在这本书中得到了很好的解决。这篇文章也不是把书中的内容随便抄一下就拿来给大家说,我会在此基础上向外扩展一些,请求大家理性评论!另外,有几个案例是我工作中遇到过的比较棘手的问题的解决方案,总结出来让大家有个印象,万一哪天你也要实现同样的需求呢?😁

如果对大家有帮助,请各位老爷务必留下你宝贵的 star🌟,这是我的 github/blog

我会从以下 4 个纬度介绍各个案例:

  • 需求描述:客户(产品)就是上帝;
  • 尝试方案:每个人都有第一次;
  • 改进方案:我不敢称其为最佳,万一有更牛的人有更好的实现呢?
  • 在线演示:shut up and show me the code!

DJ, drop the beat! 🎤

使用变量 currentColor 减少重复代码

需求描述

  • 移到按钮上时,改变该元素的 border-colorcolor ,还有一个具有透明度的同色背景。
  • 点击按钮之后,颜色更改为移入按钮时的同种颜色。

1

尝试方案

我相信任何一个前端开发者都能很快实现这个需求,不知道大家怎么样的,我在之前一直都是以下代码快速实现:

index.html 文件 :

<div class='good'>请给我点赞</div>

index.scss 文件 :

.good {
  padding: 3px 6px;
  color: #333;
  background: rgba(#333, 0.1);
  border: 1px solid #333;
  border-radius: 3px;
  cursor: pointer;

  &:hover {
    color: #0069ff;
    background: rgba(#0069ff, 0.1);
    border: 1px solid #0069ff;
  }

  &.good-click {
    color: #0069ff;
    background: rgba(#0069ff, 0.1);
    border: 1px solid #0069ff;
  }
}

index.js 文件 :

const goodBtn = document.querySelector('.good')

goodBtn.addEventListener('click', () => {
  if (goodBtn.classList.contains('good-click')) {
    goodBtn.classList.remove(['good-click'])
    return
  }
  goodBtn.classList.add(['good-click'])
})

是的,就是那么朴实无华,缺点也暴露无遗:

  • 相同的颜色我们使用了多次,比如 #333 和 #0069ff 。如果有一天产品说把这个那个颜色改一下,细心点的你多动动手指也没啥问题,改就改了,但是这种方式很不“程序员”。

针对这个问题我们直接使用预处理器(SASS/LESS)的变量就完事了:

index.scss 文件 :

$color: #333;
$hoverColor: #0069ff;

.good {
  padding: 3px 6px;
  color: $color;
  background: rgba($color, 0.1);
  border: 1px solid $color;
  border-radius: 3px;
  cursor: pointer;

  &:hover {
    color: $hoverColor;
    background: rgba($hoverColor, 0.1);
    border: 1px solid $hoverColor;
  }

  &.good-click {
    color: $hoverColor;
    background: rgba($hoverColor, 0.1);
    border-color: $hoverColor;
  }
}

咋一看已经是很好的实现方式了,但是也有缺点:

  • 有时候(比如我大多数时候)都不想为了某一个特殊的类下的 color 单独设置一个变量,仅仅只有它使用,我还要专门为其定义一个变量就显得代码很臃肿;
  • 在我添加了 good-click 这个类名后,我要把 color 、 border-color 、 background 全都重新设置一遍;

这个时候,css 原生变量 currentColor 即可大显身手了。

改进方案

变量 currentColor 能拿到本元素的 color 属性的值,如果没有显示设置,拿的将会是父元素的 color 属性的值,由此类推。借助这个特性,我们即可优化上述代码:

index.scss 文件 :

.good {
  position: relative;
  padding: 3px 6px;
  color: #333;
  border: 1px solid currentColor;
  border-radius: 3px;
  cursor: pointer;

  &::before {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: currentColor;
    opacity: 0.1;
    content: '';
  }

  &:hover {
    color: #0069ff;
  }

  &.good-click {
    color: #0069ff;
  }
}

现在看起来是不是好多了,我每次要更改颜色,只需要将此元素的 color 属性更改即可,不需要再重新写一堆重复的属性,当然,原生的 css 以及功能强大的 sass/less 都还是无法支持 rgba(currentColor, 0.1) 这种写法,我还去官方提了个 issue ,官方也给了很好的回复,有兴趣的同学可以看看。

所以现在我只能添加一个 ::before 来模拟背景色块,真正做到只改 color 属性,即可改全部颜色。

现在大家就可以在 React 或 Vue 中通过状态来控制改变颜色的类名添加与否并设置 color 属性,以此来完美地进行颜色的快速变换了~

多说一句,如果我们直接使用 ele.style.color = '#fff' 这种操作 dom 的形式来改变字体颜色,在未使用 currentColor 的情况下,我们是没法操作伪元素的,也就改变不了伪元素的 background 、 border-color 等其他与字体颜色一致的属性,所以这时候 currentColor 的优势就更明显了~

在线演示

使用变量 currentColor 减少重复代码 - codepen

完美的带小箭头的聊天框

需求描述

  • 主体功能聊天气泡,需有有边框 border 、背景色 background 、阴影、带边框的小三角箭头。
  • 小三角的边框颜色和阴影颜色与主体框的颜色要一致,小三角的边框有 border-radius 。

4

尝试方案

给该元素加个伪元素,背景色与聊天框背景色一致,再给该伪元素添加上、左同色边框,绝对定位调整位置,再来个 border-top-left-radius: 3px ,最后 transform: rotate(-45deg) 旋转一下,代码如下:

index.html 文件 :

<div class="chat-box">大家好,我是 vortesnail,如果大家喜欢我的文章,对大家有所帮助,麻烦给个小小的赞支持一下,谢谢😊</div>

index.scss 文件 :

.chat-box {
  position: relative;
  max-width: 200px;
  padding: 10px;
  color: #faae43;
  background: #fff9ed;
  border: 1px solid #ffc16b;
  border-radius: 4px;
  box-shadow: 0 2px 6px rgba(250, 174, 67, 0.8);
}

.chat-box::before {
  position: absolute;
  top: 20px;
  left: -6px;
  width: 10px;
  height: 10px;
  background: #fff9ed;
  border-color: #ffc16b;
  border-style: solid;
  border-width: 1px 0 0 1px;
  transform: rotate(-45deg);
  content: '';
  /* box-shadow: 0 2px 6px rgba(250, 174, 67, 0.8); */
}

可以达到现在下面的效果:
5

细心的你一定发现了,这个小三角指示箭头是没有阴影的,如果我给其加上与主体元素一致的 box-shadow ,又因为这个属性不能像 border-color 一样分别给各边设置为透明,结果就会像下面这样:

6

这已经是无法满足具有相同阴影的要求了,而且大家如过想一下就知道,在我主体元素不设 padding 或设的很小的情况下,小三角的背景色会将我们的文字挡住,这种方案直接宣布失败!

改进方案

针对以上的问题,我们进行一步步改造。

首先,我们考虑到主体元素不设置 padding 的情况,为了防止内容被我们的小三角背景色覆盖,我们可通过加一个伪元素 ::before ,利用 border 来画成一个三角形,代码如下:

.chat-box {
  // 其他样式
  
  &::before {
    position: absolute;
    top: 20px;
    left: -8px; // 注意,这里做了略微调整
    width: 0;
    height: 0;
    border-color: transparent #fff9ed transparent transparent;
    border-style: solid;
    border-width: 8px 8px 8px 0;
    content: '';
  }
}

现在是这个样子:
7

注意,这里的小三角已经是没有右边部分的了,解决了我们不设置 padding 时导致内容被遮挡的问题。但是这样就没有办法实现边框,毕竟你已经是使用边框做出来的三角形了。

那我们就再使用一个伪元素呗, ::after 安排上了。接下来为大家提供一个思路:采用尝试方案中的方式再画一个正方形做旋转,但是不为其设置背景色,只设置其 border ,调整下位置即可。

.chat-box {
  // 其他样式
  
  &::before {}
  
  &::after {
    position: absolute;
    top: 22px;
    left: -7px;
    width: 10px;
    height: 10px;
    /* border-color: inherit transparent transparent inherit; */
    border-color: transparent;
    border-style: solid;
    border-width: 1px;
    border-top-color: inherit;
    border-left-color: inherit;
    border-top-left-radius: 3px;
    transform: rotate(-45deg);
    content: '';
  }
}

可以看到,代码中我设置上和左的 border-color 为 inherit ,表示继承父级元素的 border-color ,因我注释那部分的写法不被识别,所以我们新增了几行代码实现,利用 inherit 可以在颜色更改时少写颜色值的重复代码,与 currentColor 想要达到的目的是一致的。

现在,越来越接近我们的目标:
8

这里小三角还是没有阴影,因为 box-shadow 并不会作用于伪元素,解决方案就是使用 filter 属性, drop-shadow 接受的参数和 box-shadow 基本一致,我们替代它即可:

// box-shadow: 0 2px 6px rgba(250, 174, 67, 0.8);
filter: drop-shadow(0 2px 6px rgba(250, 174, 67, 0.8));

现在已经完美实现~

在线演示

实现一个完美的带小箭头的聊天框 - codepen

利用 grid 实现完美的水平铺满、间隔一致的自适应布局

需求描述

  • 在一个容器元素下,有不确定数量的子元素,要求他们水平铺满,并且在当前行的最左边和最右边的子元素距离父元素左边缘和右边缘都是无缝贴合的。
  • 每个子元素之间的间隔必须一致。
  • 当浏览器窗口大小变动自适应。

2

尝试方案

这个问题从我入职第一份工作之后困扰了我接近半年,我基本还是惯性思维,一眼看过去就是 flex弹性盒子 一把梭,于是我有了以下这种方案:

index.html 文件 :

<body>
  <div class="father">
    <div class="child">Child1</div>
    <div class="child">Child2</div>
    <div class="child">Child3</div>
    <div class="child">Child4</div>
    <div class="child">Child5</div>
    <div class="child">Child6</div>
    <div class="child">Child7</div>
    <div class="child">Child8</div>
    <div class="child">Child9</div>
    <div class="child">Child10</div>
  </div>
</body>

index.scss 文件 :

.father {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: flex-start;
  width: 100%;
  padding: 10px 0 10px 20px;

  .child {
    margin-right: 14px;
    margin-bottom: 14px;
    // 其他卡片样式
  }
}

可以看到,我会为每个子元素都设置 margin-top 以及 margin-right 来固定他们之间的间距,但是因为每一行最右边的子元素也有 margin-right ,为了补偿这个,我就将父元素的 padding-right 去掉了,这样做的坏处太多了,需要自己去计算,做补偿,而且右边有时候容纳不下一个完整的子元素,就会导致换行而留下一大片白。。
3

为了能用弹性盒子做到想要的效果,我已经把阮一峰老师的Flex 布局教程:语法篇看烂了。。根本没法实现最佳最想要的效果,以上只是我多次尝试之后唯一能接受的方案,我就这么个方案用了好多次。

直到有一天,我又遇到了这种布局需求,我辛辛苦苦用 js 去硬算他们之间的间距,算是实现了想要的效果,但是真的非常繁琐,我就受不了了。这个时候我又偶遇了阮一峰老师的CSS Grid 网格布局教程,谢天谢地,采用 Grid 可完美实现以上需求!

改进方案

Flex 布局是轴线布局,只能指定"项目"针对轴线的位置,可以看作是一维布局。Grid 布局则是将容器划分成"行"和"列",产生单元格,然后指定"项目所在"的单元格,可以看作是二维布局。Grid 布局远比 Flex 布局强大

首先我们需要给容器指定为 grid 网格布局,就像 flex 一样:

.father {
  display: grid;
}

接着要为其划分列数, grid-template-columns 属性可定义每一列的列宽,假如代码如下,我们将容器划分成 3 列,每列宽度为容器的 100px :

.father {
  grid-template-columns: 100px 100px 100px;
}

但是这个时候我们看到的效果会是下面这样:
3 0

子元素并没有把父元素占满,这显然不是我们想要的效果,幸亏有 repeat() 函数帮助我们简化重复值, 它接受两个参数,第一个参数是重复的次数,第二个参数是所要重复的值 。上面的代码完全可用以下代码代替:

.father {
  grid-template-columns: repeat(3, 100px);
}

当然,这只是第一步,我们还需要借助 auto-fill 关键字,在我们需要容器能尽可能容纳子元素时,就需要用到它,表示自动填充,我的理解是 repeat() 接受了这个 auto-fill 的参数时,会去自动计算容纳的数量,就好像你事先算出来这个容器能容纳多少子元素,然后把这个“多少”传给该函数一样。这时候代码如下:

.father {
  display: grid;
  grid-template-columns: repeat(auto-fill, 100px);
}

现在图形如下,已经越来越接近我们的目标了:
3 1

但是很显然,右边有一个空隙, justify-content 属性拯救我们,它整个内容区域在容器里面的水平位置,当我设置其为 space-between 时,意味着子元素之间的间隔相等,而子元素与容器边框之间没有间隔

不过子元素之间还是没有间隔,简单设置一下属性 gap 即可,它是 column-gap 和 row-gap 的合并简写,分别表示列与列行与行之间的间距,现在代码如下:

.father {
  display: grid;
  grid-template-columns: repeat(auto-fill, 100px);
  justify-content: space-between;
  gap: 14px 4px;
}

由此简单的几行代码就已经完美实现了我们想要的效果:
3 2

不过 grid 网格布局的兼容性不是很好,点此查看支持的浏览器列表~

在线演示

利用 grid 实现完美的水平铺满、间隔一致的自适应布局 - codepen

间距可调整的虚线框

需求描述

  • 实现一个按钮,该按钮边框为虚线,且虚线的每个笔触之间的空隙和长度都是可调的。
  • 能支持有圆角,即可设置 border-radius 。
    9

尝试方案

其实我一直很迷惑为什么 css3 不提供一些能调整虚线框的必要属性,默认的 dash-border 经常会和 ui 所需要的虚线框要求会不一致,既然官方不支持,我们只能自己寻找一些解决方案。

这个解决方案看似很多,其实每一种解决方案都会有一定的局限性,选择最合适的就是最好的,具体我列出了以下几条:

  • 利用 border-image 和自定义的图片来进行虚线框的生成,该方案在 stackoverflow 查到的,我个人也尝试了下,但是修改起来特别麻烦,我第一感觉就是不会采用这种方案,有兴趣的可以看下:Brew your own border with border-image
  • 在所需要虚线框的元素的宽高是固定的情况下,可以让 UI 画好这个虚线框就行,弊端很明显,长度若一旦发生变化,虚线比例和原来就不会一致,特别丑。
  • 利用 4 个绝对定位的“伪元素”来模拟,代码示例如下:

index.html 文件 :

<body>
  <div id="box">
    <div class="border-horizontal top"></div>
    <div class="border-vertical right"></div>
    <div class="border-horizontal  bottom"></div>
    <div class="border-vertical left"></div>
    I am vortesnail, now i try to make a custom dashed border!
  </div>
</body>

index.scss 文件 :

$border-color: #ccc;
$border-dashed-unit-width: 8px;
$border-dashed-unit-height: 1px;
$stroke-rate: 50%;

body {
  #box {
    width: 400px;
    background: #fff;
    padding: 10px;
    box-sizing: border-box;
    position: relative;

    .border-horizontal {
      position: absolute;
      width: 100%;
      height: $border-dashed-unit-height;
      left: 0;
      background-image: linear-gradient(
        to right,
        $border-color 0%,
        $border-color $stroke-rate,
        transparent $stroke-rate
      );
      background-size: $border-dashed-unit-width $border-dashed-unit-height;
      background-repeat: repeat-x;
    }

    .border-vertical {
      position: absolute;
      width: $border-dashed-unit-height;
      height: 100%;
      top: 0;
      background-image: linear-gradient(
        to bottom,
        $border-color $stroke-rate,
        $border-color $stroke-rate,
        transparent $stroke-rate
      );
      background-size: $border-dashed-unit-height $border-dashed-unit-width;
      background-repeat: repeat-y;
    }

    .top {
      top: 0;
    }

    .right {
      right: 0;
    }

    .bottom {
      bottom: 0;
    }

    .left {
      left: 0;
    }
  }
}

其实其**很简单,就是 4 个矩形,每个矩形加上渐变背景,并 repeat 即可模拟虚线效果,其间距、比例可根据我们设定的变量去调整。

但是它的弊端非常大,就是无法调整 border-radius ,即没有圆角!这里向大家展示只是为了抛砖引玉,万一你有更好的想法,或者你不需要圆角,那就可以用这个方案。效果如下:
10

改进方案

其实我们借助 svg 就能比较不错的实现自定义虚线框,如果不想自己写 svg 的朋友可以直接在这个网站进行调整和生成:Customize your CSS Border ,但如果你稍微了解一些 svg 的用法,也完全可以自己实现,如果你想了解一下,可阅读这篇文章:SVG入门—如何手写SVG

代码如下:
index.html 文件

<body>
  <div id="box">I am vortesnail, now i try to draw a dashed border box.</div>
</body>

index.scss 文件 :

body {
  #box {
    width: 400px;
    border-radius: 4px;
    padding: 10px;
    background-image: url('data:image/svg+xml,\
    <svg xmlns="http://www.w3.org/2000/svg">\
      <rect width="100%" height="100%" rx="4" ry="4" style="stroke: black; stroke-width: 2px; fill: none; stroke-dasharray: 8px 5px; stroke-dashoffset: 10px;"/>\
    </svg>');
  }
}

可通过 stroke-width 调整虚线框宽度, stroke-dasharray 调整比例及长度、间距, stroke-dashoffset 调整偏移值。

tips:svg 方案在一些比较老的安卓机上会不兼容,即使在新的机型上,也会出现一些表现差异,虽然在 web 端支持 svg 的浏览器上表现是正常的,但若考虑到移动端用户群体时,使用请慎重。

在线演示

实现一个间距可调的虚线框 - codepen

自定义复选框

需求描述

  • 能够完全自定义复选框样式,就像定义一个普通的 div 元素一样。
  • disabled 状态复选框样式也可以自定义。
    11

尝试方案

很显然,当我们使用默认的 input.checkbox 方案时,是没有办法改变其样式的,而且在不同浏览器之间,其表现也不一致,代码如下:

index.html 文件 :

<body>
  <div class="checkbox-container">
    <input type="checkbox" id="apple">  
    <label for="apple">苹果</label>
  </div>
  <div class="checkbox-container">
    <input type="checkbox" id="banana" disabled>  
    <label for="banana">香蕉</label>
  </div>
  <div class="checkbox-container">
    <input type="checkbox" id="watermelon">  
    <label for="watermelon">西瓜</label>
  </div>
</body>

index.scss 文件 :

.checkbox-container {
  display: flex;
  align-items: center;
  
  input[type='checkbox'] {
    & + label {
      color: #333;
    }
  }

  input[type='checkbox']:disabled {
    &+ label {
      color: #c6c6c6;
    }
  }
}

在 chrome 下表现为:
12

在 firefox 下表现为:
13

在 safari 下与在 firefox 下一致。

如果任由这种情况的发生,你们 ui 可能会找产品经理和你打一架~🐶

改进方案

我们可以在不改变上面尝试方案中的 html 结构,只需 css 即可做到!给每一个 label 标签添加一个伪元素 ::before 作为复选框!

首先,我们给这个伪元素添加必要样式,使其符合 ui 的设计:

input[type="checkbox"] {
  & + label {
    display: flex;
    align-items: center;
  }

  & + label::before {
    box-sizing: border-box;
    content: "\a0"; /* 不换行空格 */
    width: 13px;
    height: 13px;
    margin-right: 4px;
    border-radius: 2px;
    border: 1px solid #333;
  }
}

现在的拙劣效果如下:
14

我们发现,默认的复选框还是存在的,我们怎么做到将其隐藏而不破坏其可访问性呢(即不能使用 display: none )?

input[type="checkbox"] {
  position: absolute;
  clip: rect(0, 0, 0, 0);
  
  & + label {...}

  & + label::before {...}
}

以上隐藏的方案引用至 css揭秘151页

现在点击我们自定义的复选框是没有任何效果的,接下来借助 css 的相邻兄弟选择器对 checked 状态、disabled 状态分别设置样式即可:

input[type="checkbox"]:checked {
  & + label::before {
    background-color: #1890ff;
    background-image: url("https://s1.ax1x.com/2020/10/11/0cUbi4.png");
    background-repeat: no-repeat;
    background-size: 100% 100%;
    border: none;
  }
}

input[type="checkbox"]:disabled {
  & + label {
    color: #868686;
    cursor: not-allowed;
  }

  & + label::before {
    border-color: #868686;
  }
}

可以看到,经过此番处理后,我们可以完全自主地去设计复选框样式,比如我在上面选中时,呈现了一张对勾的图片: background-image: url("https://s1.ax1x.com/2020/10/11/0cUbi4.png"); ,这极大地方便了我们对其呈现形式的掌控。

除此之外,你还可以设置 input[type="checkbox"]:focus 时的样式哦,赶快试试吧!

在线演示

自定义复选框 - codepen

交互式图片对比效果

需求描述

  • before 和 after 图片对比效果,可拖过拖拽中间的竖形条状进行两张图片的宽度变化。
    15 0

尝试方案

css3 中引入了 resize 属性,该属性可以不通过 js 就可以改变设置该属性的元素的宽度 width ,大家一定使用过 textarea 标签把?那个右下角的可拖拽更改长宽的东西就是该属性的功劳。实际上,所有标签都可以设置该属性!

于是,简单的几段代码就可以达到交互式图片对比效果,虽然和我们想要实现的效果有点差异,但如果要求不高的话,就采用它吧:

index.html 文件 :

<div class="image-slider">
  <div class="before-container">
    <img src="https://img3.doubanio.com/view/photo/l/public/p2622600072.webp" alt="before">
  </div>
  <img src="https://img9.doubanio.com/view/photo/l/public/p2380745925.webp" alt="after">
</div>

index.scss 文件 :

.image-slider {
  position: relative;
  
  img {
    display: block;
    width: 720px;
    user-select: none;
  }
  
  .before-container {
    position: absolute;
    top: 0;
    left: 0;
    width: 50%;
    max-width: 100%; /* 防止容器宽度拉长至比图片还宽 */
    overflow: hidden; /* 必须不可见 */
    resize: horizontal; /* 赋予水平宽度可拉伸功能 */
    
    &::before {
      position: absolute;
      right: 0;
      bottom: 0;
      width: 12px;
      height: 12px;
      background: linear-gradient(-45deg, #000 50%, transparent 0);
      background-clip: content-box;
      cursor: ew-resize;
      content: '';
    }
  }
}

我们利用一个伪元素 ::before 来对右下角的拉伸图标进行覆盖,以便于自定义样式,现在效果如下:
15

这个方案弊端就是右下角的可拖拽图标无法更改位置和大小,即使我们利用伪元素去覆盖,但是和我们需求中所需要的效果也相差甚远,于是我们不得不借助 js 了!

改进方案

在上述方案中增加一个 span 标签用于画我们的拖拽竖条,紧接着按照上述方案先将两张照片的位置和大小调好:

index.html 文件 :

<body>
  <div class="image-slider">
    <div class="before-container">
      <img src="https://img3.doubanio.com/view/photo/l/public/p2622600072.webp" alt="before">
    </div>
    <img src="https://img9.doubanio.com/view/photo/l/public/p2380745925.webp" alt="after">
    <span class="handler"></span>
  </div>
</body>

index.scss 文件 :

body {
  .image-slider {
    position: relative;
    
    img {
      display: block;
      width: 520px;
      user-select: none;
      pointer-events: none;
    }
    
    .before-container {
      position: absolute;
      left: 0;
      top: 0;
      width: 50%;
      overflow: hidden;
    }
  }
}

现在效果如下:
16

初步的效果出来了,接下来增加可拖拽改变水平宽度的功能。首先需要先在两张图片交际出添加一个竖形的条状,用于拖拽位置,更改 class='handler' 样式:

.handler {
  position: absolute;
  top: 0;
  left: 50%;
  display: block;
  width: 4px;
  height: 100%;
  background: rgba(0, 0, 0, 0.4);
  transform: translateX(-50%);
  cursor: ew-resize;
}

注意中间的透明竖形条状即是我们可拖拽的位置:
17

接下来写我们的 js 脚本,首先通过原生 js 方法找到三个 dom 节点:

index.js 文件 :

const imageSlider = document.querySelector(".image-slider");
const beforeContainer = document.querySelector(".before-container");
const handler = document.querySelector(".handler");

然后我们还需要获得 image-slider 这个最外层元素相对页面左边的距离,我们定义为变量 leftX ,并在鼠标于 handler 元素上按下时计算该值:

let leftX;

handler.onmousedown = (e) => {
  leftX = e.pageX - handler.offsetLeft;
};

用一张图来解释说明下:
18

然后在给 window 对象添加一个 mousemove 的监听事件,该回调用于改变 handler 位置和 before-image 的宽度:

handler.onmousedown = (e) => {
  leftX = e.pageX - handler.offsetLeft;
  window.addEventListener('mousemove', moveHandler)
};

const moveHandler = e => {
  const beforeWidth = e.pageX - leftX;
  const imageSliderWidth = imageSlider.offsetWidth;

  if (beforeWidth >= 0 && beforeWidth <= imageSliderWidth) {
    handler.style.left = beforeWidth + 'px';
    beforeContainer.style.width = beforeWidth + 'px';
  }
}

目前为止,我们拖拽 handler 已经能实现所需的效果,但是无法停止,我们需要添加一个鼠标按键抬起的监听事件,需要注意的是不能在 handler 上添加,比如: handler.onmouseup ,必须在 window 对象上添加,具体为什么,大家可以动手试试就知道了:

window.onmouseup = (e) => {
  window.removeEventListener('mousemove', moveHandler)
};

到此为止,就已经完美实现了该效果,大家还可以在 handler 上做更多工作,使其用户体验达到更好~

在线演示

交互式图片对比效果 - codepen

透明度渐变层代替滚动条提示

需求描述

  • 一个可滚动列表,在未滚动到顶部之前,需有有一个渐变层代替滚动条作为剩余内容提示,底部同理;
  • 滚到到顶部时,渐变层消失,底部同理;
  • 渐变层未矩形渐变。

19

尝试方案

首先很明确的是,先将内容滚动条搞出来:

index.html 文件 :

<body>
  <div class="content-wrapper">
    <header>目录</header>
    <div class="list-wrapper">
      <ul>
        <li>如何长高</li>
        ...省略
      </ul>
    </div>
  </div>
</body>

index.scss 文件 :

body {
  .content-wrapper {
    width: 248px;
    padding: 20px 0;
    background: #fafafa;

    header {
      padding: 0 20px 0 24px;
      font-weight: 500;
      font-size: 16px;
    }
    
    .list-wrapper {
      ul {
        height: 400px;
        padding: 0 20px 0 24px;
        overflow-y: auto;
        color: #595959;

        li {
          padding: 6px 0;
          font-size: 14px;
          list-style: none;
        }
      }
    }
  }
}

现在效果如下:
20

接下来为滚动范围的顶部和底部都先加上我们所需要的渐变层,通过父容器的 ::before 和 ::after 伪元素来实现,同时动态为 list-wrapper 这个元素增加两个类名,用于控制渐变层的显隐:

.list-wrapper {
  position: relative;
  
  &::before {
    position: absolute;
    right: 0;
    left: 0;
    z-index: 1;
    height: 60px;
    content: '';
    pointer-events: none;
  }
  
  &.top-gradient::before {
    top: 0;
    background: linear-gradient(to bottom,#fafafa,hsla(0,0%,98%,.5) 84%,hsla(0,0%,98%,.13));
  }
  
  &::after {
    position: absolute;
    right: 0;
    left: 0;
    z-index: 1;
    height: 60px;
    content: '';
    pointer-events: none;
  }
  
  &.bottom-gradient::after {
    bottom: 0;    
    background: linear-gradient(to top,#fafafa,hsla(0,0%,98%,.5) 84%,hsla(0,0%,98%,.13));
  }
}

但是我们想要达到的效果是:一旦滚动条不是最顶部,顶部就要有渐变层;一旦滚动条不是最底部,底部就要有渐变层。现在完全是写死在两头,需要通过简单的 js 脚本来判断 ul 元素的滚动条的位置:

index.js 文件 :

const listWrapper = document.querySelector(".list-wrapper");
const ul = document.querySelector("ul");

const onScroll = (e) => {
  // 滚动条是否在顶部
  if (e.target.scrollTop > 0) {
    listWrapper.classList.add("top-gradient");
  } else {
    listWrapper.classList.remove("top-gradient");
  }
  // 滚动条是否在底部
  if (e.target.scrollHeight - e.target.scrollTop !== ul.offsetHeight) {
    listWrapper.classList.add("bottom-gradient");
  } else {
    listWrapper.classList.remove("bottom-gradient");
  }
};

ul.addEventListener("scroll", onScroll);

最后再将原生滚动条隐藏,OK!

ul {
  ...
  scrollbar-width: none; /* Firefox */
  -ms-overflow-style: none; /* IE10+ */
  
  &::-webkit-scrollbar { 
    display: none; /* Chrome */
  }
}

顺带说一句,在 codepen 中滚动条隐藏不了,本地调试时可以,我也不晓得啥问题~

改进方案

实际上上述方案是我看《css揭秘》之后想到的,在这本书中,讲到了利用两层 background 以及 background-attachment 属性来进行渐变层的实现,但是我按书中实现之后,发现效果并不完美,甚至可以说有很大缺陷!我想了好久还是觉得用 js 方便,css 看起来是无法实现我想要的效果的!

所以上述方案就是最终改进方案,《css揭秘》中的方法我实在不敢认同,不过关于 background-attachment 属性的介绍倒是给我学到了~

在线演示

透明度渐变层代替滚动条提示 - codepen

结语

虽然标题写了是 css3,但是还是难免涉及到了 js,我的目的是希望有同类需求的小伙伴能通过本篇文章得到帮助。欢迎各位理性讨论~如果有更好的方法,请大佬们务必不吝赐教!如果你已经看到此处,干脆点个赞再走吧~

如果对大家有帮助,请各位老爷务必留下你宝贵的 star🌟,这是我的 github/blog

我是怎么从 React 过渡到 Vue2 的?

前言

之前学习或工作经历中都是 React 技术栈相关的,现来到新公司后需要使用 Vue2 相关技术栈维护项目,开发需求。大概花了一周时间左右刷了刷 Vue2 的官方文档,现在为了加强自己在 Vue2 使用上的熟练度,也为了防止因为以后 React 不太常用但是特定时刻又要切换回去的时候能快速记忆起用法,于是就有了这篇讲解 Vue2 与 React 在基础使用上的对应关系的文章。

强调一下,这篇文章不会在两个框架的原理上有过多深入(我还没读过源码 😂),仅仅是从我们常规开发中需要用到的实现上做了比对,简单来说就是,我在 React 中的实现如何用 Vue2 去实现

设计理念

一个庞大而复杂的项目拥有分工明确的代码结构是很重要的,这对于项目维护具有非常重要的意义,所以 React 和 Vue 都推崇组件化的方式去组织我们的项目,就像一台完整的计算机一样,打散开来各个模块都可以独立设计、开发、互不耦合,最后按照大家统一的协议去设计好接口,最终才能组装成一台强大、完整的计算机。

但是在整体的写法上,两个框架的设计理念是不太一样的:

JSX

React 中只有一个语法糖,那就是 JSX,将结构与执行逻辑以及表现都融入到 JacaScript 中,这也就是为什么说 React 相比起来较为灵活的原因。这种 all in js 的方式有一定的弊端,会让 html 与 js 强耦合,导致组件内代码混乱,不利于维护。但是另一方面,这样的形式能在类型提示、自动检查,以及调试时候能精确跳转到定义,这种开发体验在可维护性上又弥补了许多。

template 模板

Vue 拥抱了比较传统且经典的**,将 html、css、js 分离开来,这就意味着开发者在编写代码时会将结构、执行逻辑和表现分开进行,这对于项目的可维护性上有很大的提升。但是在 Vue 中我们使用 template 模板,并借助提供的 v-ifv-showv-for 等语法糖去编写代码时,在类型提示、定义跳转等等方面又是非常不友好的,这对于项目维护又是一个减分项。

一个例子 🌰

现在我们有一个简单的场景,根据某个状态来决定渲不渲染某个“小”组件,这个状态可随按钮点击进行布尔值切换。
11

在 React 中这样写:

import React, { useState } from "react";

function App() {
  const [show, setShow] = useState(true);

  const renderContent = () => {
    return (
      <div>
        Contents are all here...
        <span>Could be more complex</span>
      </div>
    );
  };

  return (
    <div className="App">
      <h1>Do you want to show the content below?</h1>
      <button onClick={() => setShow(!show)}>Click</button>
      {show && renderContent()}
    </div>
  );
}

export default App;

我们使用 JSX 语法 { show && ...} 去判断后面的渲染逻辑是否执行, 并且将渲染逻辑单独抽离了出来(即 renderContent ,没有直接在后面写渲染,这种抽离在我以前的开发经验中是很常见的,一是为了结构复用,比如当前文件内其他地方也用到了这种渲染结构,但是该“小组件”的体量又不足以让我去单独创建一个 jsx 文件来写成一个独立的组件;二是为了保持 return 中的代码简洁。

但是随着需求的持续迭代,当前这个 App 组件会变得无比臃肿,比如充斥大量类似 renderContent 这种渲染结构散落在组件内。假如你现在是一个接手该项目的人,你会发现,你根据已展现的页面结构来对应代码中的渲染结构,会非常累!!!以前维护过一个页面内写了几千行代码的 jsx 文件,各种渲染逻辑、执行逻辑大量穿插在这个页面的各处,我当时直接 emo 了。

在 Vue 中使用 template 模板我们可以这样写:

<template>
  <div id="app">
    <h1>Do you want to show the content below?</h1>
    <button @click="handleBtnClick">Click</button>
    <div v-show="show">
      Contents are all here...
      <span>Could be more complex</span>
    </div>
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      show: true,
    };
  },
  methods: {
    handleBtnClick() {
      this.show = !this.show;
    },
  },
};
</script>

可以看到使用模板去写组件时,你的渲染结构全部都在 template 模板内,页面与代码结构相一致,这对于初步接手的开发者来说是很友好的。另外,如果想要在该组件内复用带 v-show 这部分的渲染逻辑,将会被被强迫封装为另一个组件。在 React 中不这么做是因为实在是太自由了,大多数时候大多数人不想这么麻烦。

使用 template 模板写法的弊端就是,写在指令后的内容都是以字符串形式去书写的,定义跳转这种实用的功能被掐的死死的,似乎可以和 TypeScript 配合达到,但是听说 Vue2 和 Ts 配合蛮困难的。

组件结构

React 使用 .jsx 文件来定义组件,一般样式是单独引入的文件,在该组件内强耦合了 HTML 和 JS,使用 {} 来解析表达式,写法如下:

// OneComponent.jsx
import React, { useState } from "react";
// import './style.css';

function OneComponent() {
  const [content, setContent] = useState("I am one of useless component.");

  return <div>{content}</div>;
}

export default OneComponent;

使用组件:

// App.jsx
import OneComponent from "./OneComponent";

function App() {
  return (
    <div>
      One component below:
      <OneComponent />
    </div>
  );
}

Vue 使用 .vue 文件来定义组件,在此文件中同时编写 HTML、CSS、JS,template 内使用 {{}} 来解析表达式或值,写法如下:

// OneComponent.vue // 结构(html)
<template>
  <div>{{ content }}</div>
</template>

// 执行逻辑(js)
<script>
export default {
  name: "OneComponent",
  data() {
    return {
      content: "I am one of useless component.",
    };
  },
};
</script>

// 表现(css)
<style scoped></style>

使用组件:

// App.vue
<template>
  <div>
    One component below:
    <one-component />
  </div>
</template>

<script>
import OneComponent from "./OneComponent";

export default {
  name: "App",
  components: {
    OneComponent,
  },
};
</script>

数据管理

React 和 Vue 都是单向数据流,父组件的数据可向下流入子组件,反过来则不行。组件的数据来源一般包括两个部分,一个是通过 props 传入的,另一个是自身的数据。

react

在 React 中支持向下传递静态动态prop,静态 prop 一般直接传字符串。

props

函数组件获取 props 的方式如下:

function Student(props) {
  return <h1>My name is {props.name}</h1>;
}

const element = <Student name="vortesnail" />;

动态 prop 可以这也写:

<Student name={name} age={1} isMarried={false} isHandsom />

state

React 16.8 以前的 class component 使用 state 来管理组件内的数据状态,16.8 后的 hooks 使函数式组件也有了管理 state 的能力。

useState 返回一个 state,以及更新 state 的函数。如果新的 state 需要使用到上一次的 state ,可以传递一个函数给 setState 。该函数第一个参数接收的即为上一次的 state ,处理并返回一个更新后的值。

import React, { useState } from "react";

function Counter({ initialCount }) {
  const [count, setCount] = useState(initialCount);

  return (
    <>
      Count: {count}
      <button onClick={() => setCount(initialCount)}>Reset</button>
      <button onClick={() => setCount((prevCount) => prevCount - 1)}>-</button>
      <button onClick={() => setCount((prevCount) => prevCount + 1)}>+</button>
    </>
  );
}

vue

在 Vue 中同样支持静态和动态的 prop 传递,不过在动态传递的情况下,要用指令 v-bind ,简写为 :

props

静态 prop 一般传递字符串,获取 props 方式如下:

<template>
  <h1>My name is {{ name }}, I'm {{ age }} years old.</h1>
</template>

<script>
export default {
  name: "Student",
  props: ["name", "age"],
};
</script>

传递动态 prop 就不太一样,需要用到 v-bind

<student :name="name" :age="1" />
;

在 Vue 中对 prop 中可以做类型约束,比如:

props: { name: String, age: { type: Number, default: 18 } }

但是在 React 中要借助 prop-types 这个库才行(使用 TypeScript 只是编译时检查)。

data

Vue 中组件内部的数据状态由 data 来管理,当一个组件被定义,data 必须声明为返回一个初始数据对象的函数。

<script>
export default {
  name: "OneComponent",
  data() {
    return {
      name: "vortesnail",
      age: 12,
    };
  },
};
</script>

可直接通过 vue 实例来对状态进行修改:

methods: { changeName() { this.name = 'vortesnail2'; } }

class 和 style

classstyle 的写法上,React 和 Vue 之间有比较大的差异。

react

React 中使用 className 关键字来代替真实 dom 中的 class 属性。

className

React 中 className 一般传字符串常量或者字符串变量,不支持传递数组或者对象。

import React, { useState } from "react";

function App() {
  const [show, setShow] = useState(true);
  const [active, setActive] = useState(true);

  return (
    <div
      className={`app ${show ? "show" : ""} ${active ? "active" : ""}`}
    ></div>
  );
}

export default App;

React 里面直接采用 JS 的模板字符串语法,样式太多的情况下可以采用 classnames 这个包,优雅传递各种状态,使用非常简单:

classNames("foo", "bar"); // => 'foo bar'
classNames("foo", { bar: true }); // => 'foo bar'
classNames({ "foo-bar": true }); // => 'foo-bar'
classNames({ "foo-bar": false }); // => ''
classNames({ foo: true }, { bar: true }); // => 'foo bar'
classNames({ foo: true, bar: true }); // => 'foo bar'

// lots of arguments of various types
classNames("foo", { bar: true, duck: false }, "baz", { quux: true }); // => 'foo bar baz quux'

// other falsy values are just ignored
classNames(null, false, "bar", undefined, 0, 1, { baz: null }, ""); // => 'bar 1'

style

React 中 style 接收一个对象

const someStyles = {
  color: lightyellow,
  background: lightblue,
  fontSize: "12px",
};

function App() {
  return <div style={someStyles}>Colorful world!</div>;
}

vue

与 React 不同的是,Vue 中对 classstyle 做了功能上的增强,可以传字符串数组对象
另外,v-bind:class 还会与 class 进行合并,v-bind:style 还会与 style 进行合并。

class

  1. 绑定字符串:
<div class="app">hello world!</div>
  1. 绑定对象:
<template>
  <div class="app" :class="{ show: isShow, active: isActive }">
    hello world!
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      isShow: true,
      isActive: false,
    };
  },
};
</script>

<style>
.app {
  background-color: #fff;
}
.show {
  display: block;
}
.active {
  color: blue;
}
</style>

真实的 dom 结构渲染出来如下:

<div class="app show">hello world!</div>
  1. 绑定数组:
<template>
  <div class="app" :class="[showClass, activeClass]">hello world!</div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      showClass: "show",
      activeClass: "active",
    };
  },
};
</script>

<style>
.app {
  background-color: #fff;
}
.show {
  display: block;
}
.active {
  color: blue;
}
</style>

真实的 dom 结构渲染出来如下:

<div class="app show active">hello world!</div>
  1. class 能直接作为组件的属性传递给组件内部最外层元素:
Vue.component('my-component', { template: '
<p class="origin">hello world!</p>
' })

使用时额外添加 class

<my-component class="extra"></my-component>

真实的 dom 结构渲染出来如下:

<p class="origin extra">hello world!</p>

style

  1. 传对象:
<template>
  <div :style="{ color: activeColor, fontSize: fontSize + 'px' }">
    hello world!
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      activeColor: "green",
      fontSize: 16,
    };
  },
};
</script>

真实的 dom 结构渲染出来如下:

<div style="color: green; font-size: 16px;">hello world!</div>
  1. 传数组:
<template>
  <div :style="[baseStyles, overridingStyles]">hello world!</div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      baseStyles: {
        fontSize: "20px",
        color: "green",
      },
      overridingStyles: {
        fontSize: "16px",
      },
    };
  },
};
</script>

真实的 dom 结构渲染出来如下:

<div style="font-size: 16px; color: green; height: 80px;">hello world!</div>

条件渲染

条件渲染就是根据某一条件去判断是否渲染某个内容。

react 实现

在 React 中常使用与运算符 && 、三目运算符 ? : 、判断语句 if...else 来实现条件渲染。

1. 与(&&)运算符

与运算符 && ,左边值为真时,就会渲染右边的内容。

return <div>{show && <div>Content here.</div>}</div>;

2. 三目运算符(? :

和 js 中语法一样,条件满足就渲染 : 前面的内容,反之渲染后面。

return (
  <div>
    {renderFirst ? <div>First content.</div> : <div>Second content.</div>}
  </div>
);

3. 多重判断语句

return 语句中不要写太多的条件嵌套判断,比如用三目运算符尽量不要使用超过一层,不然代码会变得非常难读,所以一般我们会把这种多重判断渲染内容的放到外部函数去做,函数内通过 if...elseswitch case 去做筛选。

import React, { useState, Fragment } from "react";

function App() {
  const [order, setOrder] = useState("first");

  const renderContent = (order) => {
    if (order === "first") {
      // Frament 不会有实际 DOM 生成,可以多块内容分组
      return (
        <Fragment>
          <div>First content.</div>
          <div>Extra content of first part.</div>
        </Fragment>
      );
    } else if (order === "second") {
      return <div>Second content.</div>;
    } else if (order === "third") {
      return <div>Third content.</div>;
    } else {
      return null;
    }
  };

  return <div>{renderContent(order)}</div>;
}

export default App;

vue 实现

在 vue 中实现条件渲染只需要使用指令 v-ifv-else-ifv-else 即可。

1. v-if、v-else-if、v-else

和 js 的语法一致。

<template>
  <div id="app">
    <div v-if="type === 'first'">First content.</div>
    <div v-else-if="type === 'second'">Second content.</div>
    <div v-else>Third content.</div>
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      type: "first",
    };
  },
};
</script>

2. template 上使用 v-if

<template> 上使用 v-if 可以决定是否渲染已被分组的整块内容,与 React 中使用 <Fragment> 类似。

<template>
  <div id="app">
    <template v-if="type === 'first'">
      <div>First content.</div>
      <div>Extra content of first part.</div>
    </template>
    <div v-else-if="type === 'second'">Second content.</div>
    <div v-else>Third content.</div>
  </div>
</template>

元素显示隐藏

与条件渲染不同的是,我们还可以通过样式对元素的显隐进行控制,这也可以降低因 dom 节点的频繁增删对性能的影响。

react 实现

在 React 中我们通过修改内联样式(style)或增删选择器(class)的方式来实现,主要是修改 display 属性。

修改内联样式 style 方式:

<div style={{ display: showName ? "block" : "none" }}>vortesnail</div>

增删类名方式:

import React, { useState } from "react";

function App() {
  const [show, setShow] = useState(true);

  return <div classNames={`app ${show ? "show" : "hide"}`}></div>;
}

export default App;

vue 实现

Vue 中提供了 v-show 指令用于快捷操作元素是否显示,本质上也只是修改内联样式 display 属性。

<div v-show="showName">vortensnail</div>

showNamefalse 时,styledisplaynone
image.png

showNametrue 时,styledisplay 属性被删除,使用该元素默认值:
image.png

列表渲染

React 中使用原生 js 数组语法 map 来渲染列表,而 Vue 中使用指令 v-for 来渲染列表。这一块儿 React 灵活一些,比如可以进行链式调用 lists.filter(...).map(...) 进行过滤。

每个列表项都要添加唯一 key 值,用来减少没必要的 diff 算法对比。

react 实现

渲染数组:

import React, { useState } from "react";

function App() {
  const [lists, setLists] = useState([
    { name: "vortesnail" },
    { name: "sean" },
  ]);

  return (
    <ul id="app">
      {lists.map((item, index) => (
        <li key={item.name + index}>{item.name}</li>
      ))}
    </ul>
  );
}

export default App;

渲染对象:

import React, { useState } from "react";

function App() {
  const [obj, setObj] = useState({
    name: "vortesnail",
    age: 26,
    sex: "male",
    height: 171,
  });

  const renderObj = () => {
    const keys = Object.keys(obj);
    return keys.map((key, index) => <li key={key + index}>{obj[key]}</li>);
  };

  return <ul id="obj-rendering">{renderObj()}</ul>;
}

export default App;

其实就是 js 的语法。

vue 实现

渲染数组:

<template>
  <ul id="app">
    <li v-for="(item, index) in lists" :key="item.name + index">
      {{ item.name }}
    </li>
  </ul>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      lists: [{ name: "vortesnail" }, { name: "sean" }],
    };
  },
};
</script>

渲染对象:

<template>
  <ul id="app">
    <li v-for="(value, key, index) in obj" :key="key + index">
      {{ value }}
    </li>
  </ul>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      obj: {
        name: "vortesnail",
        age: 26,
        sex: "male",
        height: 171,
      },
    };
  },
};
</script>

事件处理

无论是 React 还是 Vue 都对原生 dom 事件做了封装,但在使用上有挺大差异。

react

React 元素的事件处理使用方式和原生 dom 使用比较类似,但是在语法上有一定的不同:

  • 事件的命名采用小驼峰式(比如 onClick ),而不是纯小写。
  • 使用 JSX 语法时你需要传入一个函数作为事件处理函数。
  1. 事件处理函数不传参数

不传参数时,会隐式传递一个事件 event 对象作为处理函数的第一个参数,一般我们会这样写:

import React from "react";

function App() {
  const handleClick = (e) => {
    console.log(e.target);
  };

  return <div onClick={handleClick}>Click me</div>;
}

export default App;
  1. 事件处理函数传递参数

大多数时候我们是需要往处理函数中传递其它参数的,我们可以这也写:

import React from "react";

function App() {
  const handleClick = (e, str) => {
    console.log(e.target);
    console.log(str);
  };

  return <div onClick={(e) => handleClick(e, "vortesnail")}>Click me</div>;
}

export default App;

vue

Vue 中处理事件需要用到一个指令 v-on ,简写为 @ ,接受一个方法名,并以字符串形式传入。

  1. 事件处理函数不传参数
<template>
  <div @click="handleClick">Click me!</div>
</template>

<script>
export default {
  name: "App",
  methods: {
    handleClick(e) {
      console.log(e.target);
    },
  },
};
</script>
  1. 事件处理函数传递参数(可以使用 $event 占位访问事件 event 对象)
<template>
  <div @click="handleClick($event, 'vortesnail')">Click me</div>
</template>

<script>
export default {
  name: "App",
  methods: {
    handleClick(e, str) {
      console.log(e.target);
      console.log(str);
    },
  },
};
</script>

Vue 中还提供了常用的事件修饰符按键修饰符,可以让我们更好地专注处理数据逻辑,而不是处理 DOM 的事件细节。

组件通信

在开发组件时不可避免会遇到父子组件、跨多层级组件之间的通信问题,无论在 React 还是 Vue 中,它们都有对应适合的解决方案。

父子组件通信

有一种很常见的场景:父组件维护了一组数据,并且某个数据的变动也是由父组件定义的函数来执行进行变更的,这个函数可以在父组件及其子组件中去调用,由子组件调用时还可以拿到子组件的数据。

举个例子:点击“改变随机数”按钮产生一个新的随机数,并更新页面值。

22

react 实现

React 中通过 props + 回调函数实现。

父组件 App.jsx

import React, { useState } from "react";
import RandomNum from "./RandomNum";

function App() {
  const [randomNum, setRandomNum] = useState(0);

  const handleUpdateNum = (num) => {
    setRandomNum(num);
  };

  return (
    <div id="app">
      <p>当前随机数为:</p>
      <RandomNum num={randomNum} changeNum={handleUpdateNum} />
    </div>
  );
}

export default App;

子组件 RandomNum.jsx

import React from "react";

function RandomNum(props) {
  const { num, changeNum } = props;

  const handleChangeTitle = () => {
    changeNum(~~(Math.random() * 100));
  };

  return (
    <div>
      <h4>{num}</h4>
      <button onClick={handleChangeTitle}>改变随机数</button>
    </div>
  );
}

export default RandomNum;

一方面是父组件的 randomNum 数据通过 num 传给了子组件,另一方面子组件又通过 changeNum 传递的父组件回调函数接收子组件的数据,从而达到父子组件通信的效果。

Tips:父组件调子组件方法可以通过 forwardRefuseImperativeHandle 实现。

vue 实现

第一种方式与上面 react 实现类似,同样的思路也可以在 vue 中实现,也就是通过 props + 回调函数。

父组件 App.vue

<template>
  <div id="app">
    <p>当前随机数为:</p>
    <random-num :num="randomNum" :changeNum="handleUpdateNum" />
  </div>
</template>

<script>
import RandomNum from "./RandomNum.vue";

export default {
  name: "App",
  components: { RandomNum },
  data() {
    return {
      randomNum: 0,
    };
  },
  methods: {
    handleUpdateNum(num) {
      this.randomNum = num;
    },
  },
};
</script>

子组件 RandomNum.vue

<template>
  <div>
    <h4>{{ num }}</h4>
    <button @click="handleChangeTitle">改变随机数</button>
  </div>
</template>

<script>
export default {
  name: "RandomNum",
  props: ["num", "changeNum"],
  methods: {
    handleChangeTitle() {
      this.changeNum(~~(Math.random() * 100));
    },
  },
};
</script>

第二种方式通过 props + 自定义事件方式。

父组件通过 props 传递数据给子组件,子组件使用 $emit 触发自定义事件,父组件中监听子组件的自定义事件从而获取子组件传递来的数据。其本质也是通过回调函数实现子组件给父组件传数据。

父组件 App.vue

<template>
  <div id="app">
    <p>当前随机数为:</p>
    <random-num :num="randomNum" @changeNum="handleUpdateNum" />
  </div>
</template>

<script>
import RandomNum from "./RandomNum.vue";

export default {
  name: "App",
  components: { RandomNum },
  data() {
    return {
      randomNum: 0,
    };
  },
  methods: {
    handleUpdateNum(num) {
      this.randomNum = num;
    },
  },
};
</script>

子组件 RandomNum.vue

<template>
  <div>
    <h4>{{ num }}</h4>
    <button @click="handleChangeTitle">改变随机数</button>
  </div>
</template>

<script>
export default {
  name: "RandomNum",
  props: ["num"],
  methods: {
    handleChangeTitle() {
      this.$emit("changeNum", ~~(Math.random() * 100));
    },
  },
};
</script>

Tips:父组件调子组件方法可以通过 this.$refs 来实现。

跨多层级组件通信

理论上我们可以通过共同的父组件实现兄弟组件通信,多层 props 传递实现祖孙级组件通信,但是这样会非常麻烦,写到后面代码也别维护了,因为没人维护的来~(我开始学 React 时候做的项目就没有用任何状态管理手段,做到后面我自己都维护不下去了)

所以我们需要更高效且更可具维护性的方案,React 还是 Vue 都提供了这种能力。

react 实现

React 中实现主要借助 React.createContextuseContext 这两个 API 来实现。

根组件 App.jsx

import React, { useState } from "react";
import Title from "./Title";

// 创建Context对象
export const AppContext = React.createContext();

function App() {
  const [randomNum, setRandomNum] = useState(0);

  const handleUpdateNum = (num) => {
    setRandomNum(num);
  };

  return (
    <AppContext.Provider value={{ num: randomNum, changeNum: handleUpdateNum }}>
      <div id="app">
        <Title />
      </div>
    </AppContext.Provider>
  );
}

export default App;

根组件下一层的组件 Title.jsx

import React from "react";
import RandomNum from "./RandomNum";

function Title() {
  return (
    <div>
      <p>当前随机数为:</p>
      <RandomNum />
    </div>
  );
}

export default Title;

孙子组件 RandomNum.jsx

import React, { useContext } from "react";
import { AppContext } from "./App";

function RandomNum() {
  const { num, changeNum } = useContext(AppContext);

  const handleChangeTitle = () => {
    changeNum(~~(Math.random() * 100));
  };

  return (
    <div>
      <h4>{num}</h4>
      <button onClick={handleChangeTitle}>改变随机数</button>
    </div>
  );
}

export default RandomNum;

其实这里的组件通信方式中还可以使用 useReducer 来模拟 react-redux 的使用,不作深究,一般这个在开发复杂组件时会用到。如果是对于我们项目本身,直接用 react-redux、mobx 就完事了。

vue 实现

Vue 中实现跨级组件间通信的方式实在是有点多(除了 vuex 这种工具),主要分析下以下两种怎么用:

  • $attrs$listeners
  • provideinject

第一种方式通过 provideinject

这对选项需要一起使用,以允许一个祖先组件向其所有子孙后代注入一个依赖,不论组件层次有多深,并在其上下游关系成立的时间里始终生效。如果你熟悉 React,这与 React 的上下文特性很相似。

根组件 App.vue

<template>
  <div id="app">
    <title-component />
  </div>
</template>

<script>
import Title from "./Title.vue";

export default {
  name: "App",
  components: { "title-component": Title },
  provide() {
    return {
      num: this.randomNum,
      changeNum: this.handleUpdateNum,
    };
  },
  data() {
    return {
      randomNum: {
        value: 0,
      },
    };
  },
  methods: {
    handleUpdateNum(num) {
      this.randomNum.value = num;
    },
  },
};
</script>

这里一定要注意,provide 要使用函数返回对象形式,不然拿不到 this.randomNumthis.handleUpdateNum ,这时候 thisundefined

而且在 data 中定义的 randomNum 必须是对象,原因在于将注入的数据变成可响应式的,看官网这段话:
image.png

根组件下一层的组件 Title.vue

<template>
  <div>
    <p>当前随机数为:</p>
    <random-num />
  </div>
</template>

<script>
import RandomNum from "./RandomNum";

export default {
  name: "Title",
  components: {
    RandomNum,
  },
};
</script>

孙子组件 RandomNum.vue

<template>
  <div>
    <h4>{{ num.value }}</h4>
    <button @click="handleChangeTitle">改变随机数</button>
  </div>
</template>

<script>
export default {
  name: "RandomNum",
  inject: ["num", "changeNum"],
  methods: {
    handleChangeTitle() {
      this.changeNum(~~(Math.random() * 100));
    },
  },
};
</script>

第二种方式通过 $attrs$listeners
其实我觉得这种方式有点类似于 props 的逐层传递,$attrs 可以使子组件通过 props 拿到根组件通过 v-bind 传递的数据,$listeners 可以使子组件通过 this.$emit 触发根组件通过 v-on 绑定的自定义事件回调。

根组件 App.vue

<template>
  <div id="app">
    <title-component :num="randomNum" @changeNum="handleUpdateNum" />
  </div>
</template>

<script>
import Title from "./Title.vue";

export default {
  name: "App",
  components: { "title-component": Title },
  data() {
    return {
      randomNum: 0,
    };
  },
  methods: {
    handleUpdateNum(num) {
      this.randomNum = num;
    },
  },
};
</script>

根组件下一层的组件 Title.vue

<template>
  <div>
    <p>当前随机数为:</p>
    <random-num v-bind="$attrs" v-on="$listeners" />
  </div>
</template>

<script>
import RandomNum from "./RandomNum";

export default {
  name: "Title",
  components: {
    RandomNum,
  },
};
</script>

孙子组件 RandomNum.vue

<template>
  <div>
    <h4>{{ num }}</h4>
    <button @click="handleChangeTitle">改变随机数</button>
  </div>
</template>

<script>
export default {
  name: "RandomNum",
  props: ["num"],
  methods: {
    handleChangeTitle() {
      this.$emit("changeNum", ~~(Math.random() * 100));
    },
  },
};
</script>

Vue 中有一个能实现所有组件间通信的方式,叫做全局事件总线(EventBus),感兴趣的可以瞅瞅:Vue 中全局事件总线(GlobalEventBus)原理及探究过程

缓存优化

在组件内,某个数据值的获取要经过大量复杂的计算,耗时较多时,React 和 Vue 都提供了优化的方法,对于相同输入,必定是同一输出的函数来说,这种结果是可缓存的,不必每次重新渲染时都重新计算一次。

react 的 useMemo 和 useCallback

在 React 中主要提供了两个钩子 useMemouseCallback
使用 useMemo 来缓存值,使用 useCallback 来缓存函数。

当子组件使用了 React.memo 时,就可以考虑使用 useMemouseCallback 封装提供给子组件的 props,这样就能够充分利用 memo 带来的浅比较能力,从而减少不必要的重复但无意义的渲染。

假如现在有以下场景:点击“产生随机数”按钮,randomNum 会被更改,组件重新渲染。点击“改变小西瓜数量”按钮,列表长度会递增。

App.jsx

import React, { useState } from "react";
import List from "./List";

function App() {
  const [randomNum, setRandomNum] = useState(0);
  const [listLen, setListLen] = useState(100);

  const handleGenerateRandomNum = () => {
    setRandomNum(~~(Math.random() * 100));
  };

  const handleChangeListLength = () => {
    setListLen((pre) => pre + 1);
  };

  const list = new Array(listLen).fill(1).map((item, index) => {
    return {
      id: index,
      text: `${index}个小西瓜`,
    };
  });

  return (
    <div id="app">
      <List data={list} />
      <span>随机数是:{randomNum}</span>
      <button onClick={handleGenerateRandomNum}>产生随机数</button>
      <button onClick={handleChangeListLength}>改变小西瓜数量</button>
    </div>
  );
}

export default App;

List.jsx

import React, { memo } from "react";

function List(props) {
  const { data = [] } = props;
  console.log(`我是 List 组件,我被渲染了,我的长度是 ${data.length}`);

  return (
    <ul>
      {data.map((item) => (
        <li key={item.id}>{item.text}</li>
      ))}
    </ul>
  );
}

export default memo(List);

如果这时候你打开控制台,疯狂点击“产生随机数”按钮,你会发现这种情况:
33

理论上来说我们是不希望 List 组件在点击“产生随机数”时被重新渲染的,因为 randomNum 这个状态与 List 组件是无关的。可以看到即使我们在子组件中使用了 React.memo 也是没用的,因为每次生成的都是一个在内存中全新的数组。

这时候只需要将依赖 listLen 计算列表的地方使用 useMemo 包裹起来就行了,这样只有在 listLen 变化时,才会去重新计算 list

const list = useMemo(() => {
  return new Array(listLen).fill(1).map((item, index) => {
    return {
      id: index,
      text: `${index}个小西瓜`,
    };
  });
}, [listLen]);

效果如下:
44

useCallback 的使用也是类似,比如我们父组件要向子组件传递方法时,只要依赖没变化,就没必要生成新的方法,也没必要让子组件重新渲染,这时候就可以使用 useCallback 了。

vue 的 computed

Vue 中用 computed 来表示计算属性,计算属性是基于它们的响应式依赖进行缓存的。只在相关响应式依赖发生改变时它们才会重新求值。

App.vue

<template>
  <div id="app">
    <list :data="list" />
    <span>随机数是:{{ randomNum }}</span>
    <button @click="handleGenerateRandomNum">产生随机数</button>
    <button @click="handleChangeListLength">改变小西瓜数量</button>
  </div>
</template>

<script>
import List from "./List.vue";

export default {
  name: "App",
  components: { List },
  data() {
    return {
      randomNum: 0,
      listLen: 100,
    };
  },
  computed: {
    list() {
      return new Array(this.listLen).fill(1).map((item, index) => {
        return {
          id: index,
          text: `${index}个小西瓜`,
        };
      });
    },
  },
  methods: {
    handleGenerateRandomNum() {
      this.randomNum = ~~(Math.random() * 100);
    },
    handleChangeListLength() {
      this.listLen += 1;
    },
  },
};
</script>

List.vue

<template>
  <ul>
    <li v-for="item in data" :key="item.id">{{ item.text }}</li>
  </ul>
</template>

<script>
export default {
  name: "List",
  props: ["data"],
  updated() {
    console.log(`我是 List 组件,我被渲染了,我的长度是 ${this.data.length}`);
  },
};
</script>

另外,如果将 list 的求值放到 methods 中的话,子组件也会重新渲染。

<template>
  <div id="app">
    <list :data="list()" />
  </div>
</template>

<script>
import List from "./List.vue";

export default {
  methods: {
    //...
    list() {
      return new Array(this.listLen).fill(1).map((item, index) => {
        return {
          id: index,
          text: `${index}个小西瓜`,
        };
      });
    },
  },
};
</script>

计算属性不仅有 getter 还有 setter ,具体可查看官方文档

侦听器

watch 的概念其实只有 Vue 才有,它的作用是监听 propsdatacomputed 的变化,执行异步或开销较大的操作。在 React 中通过自定义 hook 也能实现类似 Vue watch 的功能,具体的实现都可以另写一篇了,有机会研究下再做分享。

在 Vue 中用法如下:

watch: { message: { handler(newMsg, oldMsg) { this.msg = newMsg; }, // 代表在
wacth 里声明了 message 这个方法之后立即先去执行 handler 方法 immediate: true, //
深度监听 deep: true, } }

ref

React 和 Vue 都提供了访问原生 DOM 的特性,使用 ref 实现。

react 实现

在当前组件中使用 ref 获得实际 DOM。

import React, { useEffect, useRef } from "react";

function App() {
  const ref = useRef(null);

  useEffect(() => {
    ref.current?.focus();
  }, []);

  return (
    <div id="app">
      <input ref={ref} />
    </div>
  );
}

export default App;

但是有时候我们想要在父组件中获得子组件中的某个实际 DOM 元素就需要用到 React.forwardRef 了。

父组件 App.jsx

import React, { useEffect, useRef } from "react";
import InputComponent from "./InputComponent";

function App() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return (
    <div id="app">
      <InputComponent ref={inputRef} />
    </div>
  );
}

export default App;

子组件 InputComponent.jsx

import React from "react";

const InputComponent = React.forwardRef((props, ref) => {
  return (
    <div id="input-component">
      <input ref={ref} />
    </div>
  );
});

export default InputComponent;

vue 实现

在当前组件中使用 ref 获得实际 DOM。

<template>
  <div id="app">
    <input ref="input" />
  </div>
</template>

<script>
export default {
  name: "App",
  mounted() {
    this.$refs.input.focus();
  },
};
</script>

在 vue 中要获取子组件的实际 DOM 需要先获取子组件实例,再通过子组件实例的 $refs 拿到 DOM。

父组件 App.vue

<template>
  <div id="app">
    <input-component ref="inputComponent" />
  </div>
</template>

<script>
import InputComponent from "./InputComponent.vue";

export default {
  name: "App",
  components: {
    InputComponent,
  },
  mounted() {
    this.$refs.inputComponent.$refs.inputRef.focus();
  },
};
</script>

子组件 InputComponent.vue

<template>
  <div id="input-component">
    <input ref="inputRef" />
  </div>
</template>

<script>
export default {
  name: "InputComponent",
};
</script>

受控与 v-model

React 中 inputtextarea 等非受控组件通过 onChange 事件获取当前输入内容,将当前输入内容作为 value 传入,此时它们就成为受控组件,这样做的目的是可以通过 onChange 事件控制用户输入,比如使用正则表达式过滤不合理输入。

Vue 中使用 v-model 实现数据双向绑定。

react 实现

import React, { useState } from "react";

function App() {
  const [name, setName] = useState("vortesnail");

  const handleChange = (e) => {
    setName(e.target.value);
  };

  return (
    <div id="app">
      <input value={name} onChange={handleChange}></input>
    </div>
  );
}

export default App;

vue 实现

v-model 用于表单数据的双向绑定,其实它就是一个语法糖,这个背后就做了两个操作:

  • v-bind 绑定一个 value 属性。
  • v-on 指令给当前元素绑定 input 事件。
<template>
  <div id="app">
    <input v-model="name" />
    // 本质上相当于
    <!-- <input :value="name" @input="(e) => (name = e.target.value)" /> -->
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      name: "vortensial",
    };
  },
};
</script>

对于自定义组件也可以使用 v-model ,子组件应该有如下操作:

  • 接收一个 value 作为 prop
  • 触发 input 事件,并传入新值。

父组件 App.vue

<template>
  <div id="app">
    <input-component v-model="name" />
  </div>
</template>

<script>
import InputComponent from "./InputComponent.vue";

export default {
  name: "App",
  components: { InputComponent },
  data() {
    return {
      name: "vortensial",
    };
  },
};
</script>

子组件 InputComponent.vue

<template>
  <div id="input-component">
    <input :value="value" @input="handleInput" />
  </div>
</template>

<script>
export default {
  name: "InputComponent",
  props: ["value"],
  methods: {
    handleInput(e) {
      this.$emit("input", e.target.value);
    },
  },
};
</script>

插槽

这部分得先从 Vue 中的插槽讲起,个人觉得 Vue 中默认插槽、具名插槽、作用域插槽的这种划分已经覆盖了至少我所接触到的所有场景了,而 React 并没有这种划分,万物皆 props

vue

vue 中通过 <slot> 实现插槽功能,包含默认插槽、具名插槽、作用域插槽。

默认插槽

默认插槽使用 <slot></slot> 在组件中占了一个预留位置,使用该组件的起始标签和结束标签内包含的所有内容都会被渲染到这个占位的地方。

父组件 App.vue

<template>
  <div id="app">
    <title-component>
      <div>我是内容</div>
    </title-component>
  </div>
</template>

<script>
import Title from "./Title.vue";

export default {
  name: "App",
  components: { "title-component": Title },
};
</script>

<style></style>

子组件 Title.vue

<template>
  <div>
    <h1>我是标题</h1>
    <slot></slot>
  </div>
</template>

<script>
export default {
  name: "Title",
};
</script>

渲染出来的真实 DOM 结构验证了我们所说的“占位”:
image.png

具名插槽

默认插槽只能插入一个插槽,当插入多个插槽时需要使用具名插槽。使用 <slot name="xxx"> 形式来定义具名插槽。

默认插槽的 namedefaultv-slot 可简写为 #

子组件 Page.vue

<template>
  <div>
    <header>
      <slot name="header"> Header content. </slot>
    </header>
    <main>
      <slot> Main content. </slot>
    </main>
    <footer>
      <slot name="footer"> Footer content. </slot>
    </footer>
  </div>
</template>

<script>
export default {
  name: "Page",
};
</script>

父组件 App.vue

<template>
  <div id="app">
    <page>
      <template #header>
        <div>This is header content.</div>
      </template>
      <template>
        <div>This is main content.</div>
      </template>
      <template #footer>
        <div>This is footer content.</div>
      </template>
    </page>
  </div>
</template>

<script>
import Page from "./Page.vue";

export default {
  name: "App",
  components: { Page },
};
</script>

作用域插槽

有时让插槽内容能够访问子组件中才有的数据是很有用的。

子组件 Page.vue

<template>
  <div>
    <header>
      <slot name="header"> Header content. </slot>
    </header>
    <main>
      <slot :main="main"> Main content. </slot>
    </main>
  </div>
</template>

<script>
export default {
  name: "Page",
  data() {
    return {
      main: {
        title: "我是文章标题",
        content: "我是文章内容",
      },
    };
  },
};
</script>

父组件 App.vue

<template>
  <div id="app">
    <page>
      <template v-slot:header>
        <div>This is header content.</div>
      </template>
      <template v-slot:default="{ main }">
        <div>{{ main.title }}</div>
        <div>{{ main.content }}</div>
      </template>
    </page>
  </div>
</template>

<script>
import Page from "./Page.vue";

export default {
  name: "App",
  components: { Page },
};
</script>

注意,这里 v-slot:default 没有简写。只有明确定义了 name 的才能使用,比如 #header="xxx"

react

React 中可以通过 props.childrenRender Props 实现 Vue 中的插槽功能。

props.children

其实 props.children 就是子组件起始和结束标签包裹的任何元素,和默认插槽没区别。

父组件 App.jsx

import React from "react";
import Title from "./Title";

function App() {
  return (
    <div id="app">
      <Title>
        <div>我是内容</div>
      </Title>
    </div>
  );
}

export default App;

子组件 Title.jsx

import React from "react";

function Title(props) {
  return (
    <div>
      <h1>我是标题</h1>
      {props.children}
    </div>
  );
}

export default Title;

render props

记住在 React 中万物皆 props 就行了,我们来模拟实现作用域插槽。

子组件 Page.jsx

import React, { useState } from "react";

function Page(props) {
  const [main, setMain] = useState({
    title: "我是文章标题",
    content: "我是文章内容",
  });

  return (
    <div>
      <header>{props.header || "Header content."}</header>
      <main>{props.renderMain ? props.renderMain(main) : "Main content."}</main>
    </div>
  );
}

export default Page;

父组件 App.jsx

import React from "react";
import Page from "./Page";

function App() {
  return (
    <div id="app">
      <Page
        header={<div>This is header content.</div>}
        renderMain={(main) => (
          <>
            <div>{main.title}</div>
            <div>{main.content}</div>
          </>
        )}
      />
    </div>
  );
}

export default App;

我个人在 React 开发中迄今为止没用到类似作用域插槽的这种功能,不知道为什么在 Vue 中那么推崇。😂

逻辑复用

无论是什么框架,逻辑代码的复用这件事都是必须考虑的,在 React 中可以使用自定义 hook 抽离出经常使用到的逻辑达到复用效果,在 Vue2 中可以使用 mixins 来实现。

react

在 React 16.8 以前,我们复用代码逻辑的常用方式是 HOC,现在我们常用自定义 Hook 来实现代码复用。

假设现在有以下场景,A 组件和 B 组件中都要在初次渲染时请求数据,但是又不想分别在两个组件中都去写请求的代码逻辑。

HOC

首先我们定义一个高阶函数 withData ,它接收组件并返回一个接收 props 的匿名函数,在该函数内写我们要复用的代码逻辑,然后将 props 和复用代码的“结果”同样作为 prop 传给被高阶函数包裹的组件,这样在该组件中就能通过 props 拿到复用代码的“结果”了。

import React, { useState, useEffect } from "react";

const withData = (Component) => (props) => {
  const [results, setResults] = useState([]);

  const fetchData = async () => {
    const response = await fetch("https://pokeapi.co/api/v2/pokemon");
    const data = await response.json();
    setResults(data.results);
  };

  useEffect(() => {
    fetchData();
  }, []);

  return <Component {...props} results={results} />;
};

const A = (props) => {
  const { title, results } = props;
  return (
    <div>
      <h1>{title}</h1>
      <ul>
        {results.map((pokemon) => (
          <li key={pokemon.name}>{pokemon.name}</li>
        ))}
      </ul>
    </div>
  );
};

const B = (props) => {
  return <div>List length: {props.results.length}</div>;
};

const WrappedA = withData(A);
const WrappedB = withData(B);

function App() {
  return (
    <div id="app">
      <WrappedA title="列表数据如下:" />
      <WrappedB />
    </div>
  );
}

export default App;

HOC 容易造成深层次的嵌套、可读性差、调试困难,并且重名 props 可能会被覆盖。

自定义 hook

自定义 hook 看起来就简单很多了,本质上就是把原本要在组件中写的代码包装到另一个 hook 中,要用的时候在组件里面调一下就行了。

import React, { useState, useEffect } from "react";

const useData = () => {
  const [results, setResults] = useState([]);

  const fetchData = async () => {
    const response = await fetch("https://pokeapi.co/api/v2/pokemon");
    const data = await response.json();
    setResults(data.results);
  };

  useEffect(() => {
    fetchData();
  }, []);

  return results;
};

const A = (props) => {
  const results = useData();
  const { title } = props;

  return (
    <div>
      <h1>{title}</h1>
      <ul>
        {results.map((pokemon) => (
          <li key={pokemon.name}>{pokemon.name}</li>
        ))}
      </ul>
    </div>
  );
};

const B = (props) => {
  const results = useData();

  return <div>List length: {results.length}</div>;
};

function App() {
  return (
    <div id="app">
      <A title="列表数据如下:" />
      <B />
    </div>
  );
}

export default App;

vue

mixins

一个混入对象可以包含任意组件选项。当组件使用混入对象时,所有混入对象的选项将被“混合”进入该组件本身的选项。

根组件 App.vue

<template>
  <div id="app">
    <a-component title="列表数据如下:" />
    <b-component />
  </div>
</template>

<script>
import A from "./A.vue";
import B from "./B.vue";

export default {
  name: "App",
  components: { "a-component": A, "b-component": B },
};
</script>

子组件 A.vue

<template>
  <div>
    <h1>{{ title }}</h1>
    <ul>
      <li v-for="pokemon in results" :key="pokemon.name">
        {{ pokemon.name }}
      </li>
    </ul>
  </div>
</template>

<script>
import { dataMixin } from "./mixins";

export default {
  name: "A",
  mixins: [dataMixin],
  props: ["title"],
};
</script>

子组件 B.vue

<template>
  <div>List length: {{ results.length }}</div>
</template>

<script>
import { dataMixin } from "./mixins";

export default {
  name: "A",
  mixins: [dataMixin],
};
</script>

混入 mixins.js

export const dataMixin = {
  data() {
    return {
      results: [],
    };
  },
  created() {
    this.fetchData();
  },
  methods: {
    async fetchData() {
      const response = await fetch("https://pokeapi.co/api/v2/pokemon");
      const data = await response.json();
      this.results = data.results;
    },
  },
};

通过观察我们会发现这和 React 中自定义 hook 很像,就是把原本要写在组件本身里的逻辑换了个地方写而已。

  • mixins 容易冲突:因为每个特性的属性都被合并到同一个组件中,组件内同名的属性或方法会把 mixins 里的覆盖掉。
  • 可重用性有限:我们不能向 mixins 传递任何参数来改变它的逻辑,这降低了它们在抽象逻辑方面的灵活性。
  • 数据来源不清晰:组件里所使用的 mixins 里的数据或方法在当前组件代码里搜索不到,易造成错误的解读,比如被当成错误代码或冗余代码而误删。

结语

通篇读下来会发现内容还是比较简单的,本文目的就像开头说的,通过对比的方式让大家能在两个框架之间建立起一个认知桥梁,这样子切换技术栈时会更容易接受点。我个人也认为这样的学习方式对于工作来说会更有效率些,首先让自己能干活,再去思考深入的问题。

参考

Vue2 官方文档
React 官方文档
为什么我们放弃了 Vue?Vue 和 React 深度对比

web 和 node 项目部署阿里云服务器并域名访问教程

当你想把开发的前端项目和 Node 服务端项目部署至云服务器上,以便于别人能够公网访问,相信大多数开发者都要经历一个查来查去的过程,还容易踩坑。这篇文章会一步一步地教你如何做,可以让你少走些弯路。
在开始之前,我先介绍下我的项目技术栈:

  • 前端:vite4 + vue3 + vue-router
  • 服务端:koa2 + sequelize + mysql2

很常规,其实这篇教程和技术栈是没有任何关系的,毕竟我们要部署的前端项目只是打包后的静态资源文件而已,服务端进程管理器目前最好的选择也就 pm2 。

购买云服务器

现在的云服务器选择很多,比如腾讯云、阿里云等其它云,这里讲下阿里云服务器 ECS 的购买,后续的服务器配置等操作都是基于我们购买的云服务器进行的,所以如果你购买的不是阿里云的,可能本教程只能作为一个参考。

访问 阿里云服务器 ECS 主页 往下翻到产品规格,有许多种类型的服务器供我们选择,我是购买了共享型下的 2 核 4G 实例,即下图:

1

你看到的价格可能不一样,会因为活动(比如双 11、618)、新客专享和学生优惠,有更低的价格,总之看你用途是什么吧,个人的学习项目或博客啥的,买便宜点的就行了。
购买配置如下:

  • 实例规格:2 核 4G
  • 地域:我选的是 华东 1(杭州)
  • 操作系统:比较习惯 Linux CentOS 7.9 64 位,也可以尝试下 Alibaba Cloud Linux,官网介绍说他完全兼容 CentOS ,且长期维护;
  • 带宽:学习的话建议 1M 就可以了,我购买的是 5M 的,这玩意儿真的贵。

连接服务器

有了服务器之后自然是要登录上去进行操作,下面介绍密码登录和本地远程面密登录。

密码登录服务器

我们先尝试下通过 Workbench 远程连接服务器,如下图操作:

2

在这里你可能会遇到无法使用密码登录的问题,首先你要保重重置了实例密码,然后通过 VNC 远程连接,创建 6 位的密码后,进入页面登录实例:

Login: root
Password: 输入你创建的实例密码,不是 VNC 密码

然后按照这个文档 使用密码无法登录 Linux 云服务器 ECS 该如何处理?操作就行了。

重启退出后我们再点远程连接,就可以正常通过密码登录了,然后就可以操作我们的服务器咯。

本机免密登录服务器

如果我们想在自己电脑上就快捷登录到云服务器系统上,可以打开终端,因为我是 Windows 系统,所以使用的是 PowerShell ,然后输入以下命令:

上面 root 是登录用户名,后面的 111.11.1.1 是服务器实例的公网 IP,记得替换成你自己的公网 IP。回车之后会让你输入登录密码,即服务器实例密码。

3

但是我们每次打开终端都要执行一遍登录,还要输入密码,特别麻烦,而且不利于后面要讲到的利用 Github Actions 自动化部署的工作进行。

幸运的是可以让本机与云服务器建立信任,实现免密登录,接下来介绍实现建立信任步骤:

本机生成 ssh key

在本机的终端输入以下命令:

ssh-keygen -t rsa -C "你的 github 邮箱"

云服务器添加本机公钥

执行上面命令以后,要找到 id_rsa.pub 文件,我的电脑上路径是 C:\Users\10913\.ssh ,你可以做个参考,然后随便找个编辑器将其打开后,复制该文件内的所有内容,复制到云服务器上的 ~/.ssh/authorized_keys 文件中(如没有该文件,就创建一个)。

上述过程用到步骤命令如下:

# 登录云服务器
ssh [email protected]

# 来到 ~/.ssh 目录
cd ~/.ssh

# 查看下有没有 authorized_keys 文件
ls

# 没有的话创建一个
touch authorized_keys

# 编辑该文件
vi authorized_keys

# 粘贴后退出,先按 ESC,然后
:wq

然后你输入 exit 命令退出云服务器系统,再重新执行上述登录,就不用输入密码了。

如果还是需要你输入密码,估计是权限不够,我们使用密码再次登录后,依次执行以下命令:

cd .ssh
chmod 700 ../
chmod 700 .
chmod 600 authorized_keys

安装必要软件

云服务器的初始系统是比较干净的,有些必要的软件需要我们自己安装。比如,开发的服务端没有 Node 怎么那可不行。

在开始安装之前,要确认我们的云服务器能访问外网,简单的方法就是 ping 一下随便一个域名,比如:

ping www.baidu.com

如果是像下面一样,就代表网是通的。

4

安装 git

因为我购买云服务器时选择的系统是 CentOS,自带 yum ,所以我可以使用它来安装 git :

sudo yum install git

安装完成之后,查看 git 版本:

git --version

安装 nginx

继续使用 yum 安装:

sudo yum install nginx

安装完成之后,查看 nginx 版本:

nginx -v

安装 wget

wget 可以在控台访问一个 url 地址,并得到返回结果,用于下载软件,也可用于测试 web server 是否正常运行。

sudo yum install wget

安装 nvm

nvm 是一个 node 包版本管理工具,非常好用,使用 wget 来安装:

wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash

下载的版本最好保持最新,可在 nvm 官方文档 随时查看。

注意,你大概率会遇到无法下载的问题,也就是连接不到资源,建议多尝试下,或者联系阿里云技术支持。

如果你安装成功了之后,需要重新连接服务器,查看 nvm 版本:

nvm -v

安装 node

有了 nvm,可以很方便地安装 node 的不同版本,因为我本地开发项目时 node 版本是 16.19.0 ,所以为了保持一致,我准备在云服务器上也安装一个相同的版本。
执行以下命令以查看 node 版本有哪些:

nvm ls-remote

然后选一个版本开始安装:

nvm install 16.19.0

安装成功之后确认下:

nvm list

查看当前使用的 node 版本:

node -v

安装 yarn

因为我使用的时 yarn 包管理器,需要安装下:

npm install --global yarn

查看当前使用的 yarn 版本:

yarn -v

安装 pm2

使用 npm 直接安装 pm2 :

npm install pm2 -g

查看当前使用的 pm2 版本:

pm2 -v

测试 web 和 node 服务

必须的环境准备好之后,我们需要确认公网是否能访问我们的服务器资源,所以需要先写个测试的 demo 。

新建静态页面

在根目录下,新建一个测试目录,进入该目录:

mkdir test-demo
cd test-demo

然后在该目录下新建一个 html ,并编辑:

touch test.html
vi test.html

编辑内容如下:

<h1>Hello World</h1>

找到 nginx 配置文件进行配置

执行以下命令查看 nginx.conf 的所在位置:

nginx -t

5

直接开始编辑:

vi /etc/nginx/nginx.conf

添加一个 server

server {
    listen 8001;
    server_name test-demo;
    root /root/test-demo;
    include /etc/nginx/default.d/*.conf;
}

:wq 保存退出之后,再执行下 nginx -t 看是否正常。如果正常,重启下 nginx :

nginx -s reload

然后使用 wget 测试下是否能正常访问该目录下的资源:

wget http://localhost:8001/test.html

结果报了 403 的错误,表示没有相关权限,我们去修改 nginx.conf 文件,把 user nginx 改成 user root 就可以了。

创建 node 服务

我们再试一下使用 pm2 启动一个 node 服务。首先来到 test-demo 下新建一个 server.js

cd /root/test-demo
touch server.js

编辑该 js 文件并输入以下内容:

const http = require("http");

const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-type": "application/json" });
  res.end(
    JSON.stringify({
      errno: 0,
      msg: "Hello node server!",
    })
  );
});

server.listen(8002);

保存退出后启动 node 服务:

pm2 start server.js

使用 wget 测试服务是否启动成功:

wget http://localhost:8002

执行之后会默认下载一个 index.html 文件,我们查看下它的里面是什么内容:

cat index.html

如果输出了以下内容就代表我们的服务启动了:

{"errno":0,"msg":"Hello node server!"}

公网访问

云服务器是有一个公网 IP 的,这意味着我们可以在公网访问其服务器资源,但是需要配置防火墙。
我们可以先尝试访问下公网 IP 获取上面创建的 test.html ,在浏览器打开以下地址(记得替换你的公网 IP):

http://111.111.11.1:8001/test.html

或者上面创建的 node 服务:

http://111.111.11.1:8002

不出意外的话,是完全不可访问的。需要回到阿里云平台配置安全组开放我们的端口,如下图:

6

现在再访问应该就可以了。

7

Github Actions 自动部署

每次发布新的代码都要登录到服务器,手动部署最新的代码,这是重复且容易犯错的一个过程,如果我们能让机器自己执行这个过程,大大降低了风险和解放了劳动力。

Github Actions 是 Github 免费提供的一个持续集成服务,接下来介绍如何做。

新建 secrets

之前我们说过本机与远程的云服务器建立了信任得以免密登录,现在我们使用 Github Actions 提供的临时的虚拟机也就相当于我们的本机,也要建立信任,才能方便进行后续文件拷贝的工作。

还记得之前已经我们的公钥(即 id_rsa.pub)添加到云服务器,如果我们把本机的私钥(即 id_rsa)搬到虚拟机上,不就可以模拟本机免密登录了吗!

来到我们的 github 项目下,找到以下新建 secret 的地方,新建一个私钥的 secret

9

切记,私钥一定不能泄露,不然别人能随便就登进你的服务器了。

成功之后如下图,另外我还建了其它的 secret ,服务器的公网 IP 和项目目录地址。

8

编写 workflow

现在我先部署我的 type-room-web 前端项目。

在项目的根目录下新建 .github 目录,再新建 workflows 目录,最后在新建 deploy.yml 文件,编辑这个文件:

name: deploy type-room-web

on:
  push:
    branches:
      - "main" # 针对 main 分支
    paths:
      - ".github/workflows/*"
      - "src/**"
      - "public/*"
      - "package.json"
      - "vite.config.ts"
      - "index.html"

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: 拉取项目代码
        uses: actions/checkout@v3

      - name: 设置 node 环境
        uses: actions/setup-node@v3
        with:
          node-version: "16.19.0"

      - name: 安装依赖
        run: yarn

      - name: 编译打包
        run: yarn build

      - name: 设置 id_rsa
        run: |
          mkdir -p ~/.ssh/
          echo "${{secrets.VORTESNAIL_ID_RSA}}" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
          ssh-keyscan ${{secrets.REMOTE_HOST}} >> ~/.ssh/known_hosts
          cat ~/.ssh/known_hosts

      - name: 将远程服务器的对应目录下所有文件及文件夹删除
        run: | # type-room/web
          ssh root@${{secrets.REMOTE_HOST}} "
            cd /root/${{secrets.REMOTE_WEB_DIR}};
            rm -rf ./*;
          "

      - name: 将编译后的包复制到远程服务器对应目录
        run: scp -r ./dist root@${{secrets.REMOTE_HOST}}:/root/${{secrets.REMOTE_WEB_DIR}}

      - name: 删除 id_rsa
        run: rm -rf ~/.ssh/id_rsa

总结下上面文件做的事情:

  1. 监听到 main 分支的提交后,且 src/** 等文件内容改变时,开始执行这次 ci 流程;
  2. 拉取项目的代码到虚拟机中;
  3. 下载 node 16.19.0,因为后面的编译打包需要用到 node,尽量保持和本机开发时的版本一致;
  4. 安装依赖;
  5. 编译打包前端项目;
  6. secrets 中的建立的私钥写入到虚拟机的 .ssh/id_rsa 中,并赋予读的权限,再将云服务器的公网 IP 追加写入到 .ssh/known_hosts 中;
  7. 删除云服务器的对应目录下的所有文件及文件夹;
  8. 将编译好的 dist 目录复制到云服务器对应目录;
  9. 清除私钥。

⚠️ 这里请务必注意,你要事先在云服务器上建好你要操作的目录,比如我的 /root/type-room/web
万事俱备,现在让我们提交一波前端的项目看看:

git add -A
git commit -m "ci: 测试 GitHub Actions 持续集成"
git push origin main

修改 nginx.conf

因为现在用到的是我们实际的项目,所以需要修改之前的 nginx 配置文件:

# gzip 配置
gzip on;
gzip_static on;
gzip_min_length  5k;
gzip_buffers     4 16k;
gzip_http_version 1.0;
gzip_comp_level 7;
gzip_types       text/plain application/javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png;
gzip_vary on;

server {
    listen 8088;
    server_name type-room-web;
    root /root/type-room/web/dist;
    include /etc/nginx/default.d/*.conf;

    # 单页应用 try file
    location / {
        try_files $uri $uri/ /index.html;
    }

    # api 重定向到我们自己的服务端地址
    location /api/ {
        rewrite ^/api/(.*)$ /$1 break;
        proxy_pass http://localhost:7077;
    }
}

如果顺利的话,你会看到每一步都是 OK 的,如果遇到了问题,不要慌,根据错误提示去解决,放心,不难的。

10

购买数据库

我购买的是下面这个,新人专享还是蛮便宜的:

10 5

数据库连接

连接数据库

首先要来到我们购买的数据库控制台,点击实例进入后来到账号管理,创建一个主账号:

11

然后就可以用这个账号登录数据库了:

本机连接数据库

目前只能通过阿里云自研的 DMS 进行数据库管理,如果你想在自己常用的电脑上连接远程数据库,需要开放外网访问,且要给自己的本机 IP 加白名单。

12

等待一会儿后,点击外网地址旁边的设置白名单,添加一个白名单组,把你的本机出口 IP 地址添加上去:

13

⚠️ 也可以再新建一个安全组,专门放你的云服务器内网和外网地址,这样后续可以通过内网连接数据库。

这一步做完,就可以在本机连接我们的远程是数据库了,比如我使用 MySQL Workbench 来进行连接:

14

node 项目修改数据库连接地址

来到我们的 node 服务端项目,你必然是会有一个数据库连接地址的,将其修改为云数据库的内网地址:

// 开发配置
let MYSQL_CONF = {
  host: "localhost",
  port: "3306",
  user: "root",
  password: "xxx",
  database: "type_room_db",
};

// 线上配置
if (isProd) {
  MYSQL_CONF = {
    host: "你的云数据库内网地址",
    port: "3306",
    user: "root",
    password: "xxx",
    database: "type_room_db",
  };
}

部署 node 服务

在项目根目录下新建 ecosystem.config.js 文件,写入 pm2 所需的配置:

module.exports = {
  apps: [
    {
      name: "type-room-server",
      script: "./bin/www",
      instances: "2",
      watch: true,
      ignore_watch: ["logs", "node_modules"],
      error_file: "./logs/err.log",
      out_file: "./logs/out.log",
      log_date_format: "YYYY-MM-DD HH:mm:ss",
    },
  ],
};

增加 package.json 中的生产环境启动服务命令 prod

"scripts": {
  "dev": "cross-env NODE_ENV=dev ./node_modules/.bin/nodemon bin/www",
  "prod": "cross-env NODE_ENV=production pm2 start ecosystem.config.js",
},

和前端项目一样,也要创建我们的 .github/workflows/deploy.yml ,写入以下内容:

name: deploy type-room-server

on:
  push:
    branches:
      - "main" # 针对 main 分支
    paths:
      - ".github/workflows/*"
      - "src/**"
      - "bin/*"
      - "package.json"
      - "ecosystem.config.js"
      - ".env"

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: 拉取项目代码
        uses: actions/checkout@v3
        with:
          path: "clone-files"

      - name: 设置 id_rsa
        run: |
          mkdir -p ~/.ssh/
          echo "${{secrets.VORTESNAIL_ID_RSA}}" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
          ssh-keyscan ${{secrets.REMOTE_HOST}} >> ~/.ssh/known_hosts
          cat ~/.ssh/known_hosts

      - name: 将远程服务器的对应目录下所有文件及文件夹删除
        run: | # type-room/server
          ssh root@${{secrets.REMOTE_HOST}} "
            cd /root/${{secrets.REMOTE_SERVER_DIR}};
            pm2 kill;
            rm -rf ./*;
          "

      - name: 将项目复制到远程服务器对应目录
        run: |
          rsync -avz --exclude=".git" --exclude="node_modules" clone-files/ root@${{secrets.REMOTE_HOST}}:/root/${{secrets.REMOTE_SERVER_DIR}}
          ls -a

      - name: 启动 pm2
        run: |
          ssh root@${{secrets.REMOTE_HOST}} "
            cd /root/${{secrets.REMOTE_SERVER_DIR}};
            ls -a;
            yarn;
            yarn prod;
          "

      - name: 删除 id_rsa
        run: rm -rf ~/.ssh/id_rsa

和 web 项目不一样的是,复制时使用的是 scp ,现在我们用的是 rsync ,两者区别大家可以自行查查。

然后我们提交代码到 github 远程仓库,在 actions 里面看下我们的 ci 流程是否正常。

创建数据库

现在 node 服务是部署好了,我们可以登录云数据库,创建数据库,我的创建格式如下:

15

创建好数据库,我需要去创建和我开发环境保持一直的数据库表,因为我用的是 sequelize ,我在云服务器的 node 项目根目录下执行下写有同步逻辑的 js 文件就可以了。比如我的同步操作:

./node_modules/.bin/cross-env NODE_ENV=production node ./src/db/sync.js

同步成功之后,就可以开始读写数据库了,截止目前,你的 web 和 node 项目都已经完成了线上自动化部署、公网访问的所有流程了。

域名解析

现在访问我们的页面只能通过服务器的公网 IP 去访问,我们希望通过自己购买的域名去进行访问,该怎么做呢?
首先,找到域名解析,你会看到你购买的域名:

16

在列表的最右边有解析设置按钮,点击跳转之后,再点击新手引导,填入以下信息:

17

记住要把对应设置"@"主机记录对应设置"www"主机记录都勾选上,前者可以让你不输入 www 时也能正常访问。

设置之后我们打开终端,测试下域名的连通性,我们 ping 一下域名:

ping www.typeroom.cn
#
ping typeroom.cn

如果能正确显示出云服务器的 IP 地址表示成功了:

18

这个时候,已经可以使用域名代替之前的公网 IP 访问了,就像我的这样:

http://www.typeroom.cn:8088

默认 80 端口

细心的同学会发现,我现在访问页面还需要加端口 8088 才行,这是因为我云服务器的 nginx 配置中将前端资源的 server 端口设置成了 8088 ,将它改成 80 端口:

server {
    listen 80;
    server_name type-room-web;
    root /root/type-room/web/dist;
    include /etc/nginx/default.d/*.conf;

    ....
}

回到阿里云服务器实例的控制台,将安全组规则中原来的 8088 改成 80

19

改完之后再访问我们的页面,即不加端口的地址:

http://www.typeroom.cn

结果出现这个提示:

20

原因时我们的网站没有进行备案,那接下来就去备案呗~

网站备案

访问阿里云网站备案,按照流程填写资料,提交申请后只能等待了。

如果你的居住地和户籍地不一致,要事先去办理流动人口居住证,阿里云那边审核的时候肯定会给你打电话的,所以事先准备好吧。

后续如果一切顺利的话,你的域名就可以正常访问了。

支持 https 访问

https 想必 http 的优势是什么就不说了,老生常谈了,你的网站如果不是 https,在如今,对你的访问量几乎是打击性的。

但是付费的 SSL 证书是真的贵!接下来为大家演示下如何申请免费的证书,并让你的网站支持 https 访问。这一切得益于 Let's Encrypt 免费证书

安装 certbot

Certbot 是 Let's Encrypt 推出的获取证书的客户端,可以让我们免费快速地获取 Let's Encrypt 证书。

先安装必要的软件:

yum install epel-release -y
yum install certbot -y

生成证书

接着生成泛域名证书(别忘了写的是你自己的域名哦):

certbot certonly --preferred-challenges dns --manual -d *.typeroom.cn --server https://acme-v02.api.letsencrypt.org/directory

执行上面命令后会连续回答几个问题,该填邮箱就填,该同意的就同意,直到出现这个提示:

21

回到阿里云域名解析,添加一条 TXT 的解析:

22

添加完解析后稍等几秒钟,即可回车继续,这时候就会校验记录是否有效。

23

出现这个 Congratulations 就算是成功了!!生成的证书在 /etc/letsencrypt/live 目录下。

修改 nginx 配置

原来 http 访问的是 80 端口,我们需要修改为 443 端口,并增加证书的配置:

server {
    listen 443 ssl;
    server_name *.typeroom.cn;
    # 证书位置
    ssl_certificate /etc/letsencrypt/live/typeroom.cn/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/typeroom.cn/privkey.pem;
    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE;
    ssl_prefer_server_ciphers on;
    # 静态页面目录
    root /root/type-room/web/dist;
    include /etc/nginx/default.d/*.conf;

    error_page 404 /404.html;
        location = /404.html {
    }

    error_page 500 502 503 504 /50x.html;
        location = /50x.html {
    }

    location / {
        try_files $uri $uri/ /index.html;
    }

    location /api/ {
        rewrite ^/api/(.*)$ /$1 break;
        proxy_pass http://localhost:7077;
    }
}

# http 访问转至 https 访问
server {
    listen 80;
    server_name  *.typeroom.cn;
    root /usr/share/nginx/html;
    # 下面这行不加会导致无法重定向!
    include /etc/nginx/default.d/*.conf;

    rewrite ^(.*)$ https://${host}$1 permanent;
}

修改配置之后要重启下 nginx :

systemctl restart nginx

开放 443 端口

之前云服务器的安全组配置是开放了 80 端口,现在需要新增一个 443 端口。

24

不出意外的话,现在你可以使用 https 访问你的网站了!但是意外总会出现,比如访问 https 还是不通。但是我们不慌,一步一步解决。

可先通过以下命令查看 nginx 进程的 pid 和监听的端口:

ps aux | grep nginx
# 比如 master process 的 pid 为 9690
netstat -anp | grep 9690

发现已经监听 80443 了:

25

我们先看下 nginx 服务的状态:

systemctl status nginx

结果报红了:

26

重启 nginx 也是失败:

systemctl restart nginx

27

百般不得其解,直到看到这个问题:

nginx.service failed because the control process exited

最高赞的回答解决了这个问题,首先找到当前占用了 80443 端口的进程,发现就是 nginx 的:

netstat -tulpn

然后根据 pid (比如 9680)杀掉这个进程:

sudo kill -2 9680

这个时候再重启 nginx 服务就可以了:

systemctl restart nginx

自动续期

免费证书有效期 3 个月,到期之后我们可以再次续期,达到永久免费的效果。

https://www.frankfeekr.cn/2021/03/28/let-is-encrypt-cerbot-for-https/index.html
https://juejin.cn/post/7205839782381928508#comment

github host

阿里云的国内服务器,访问 github 时经常访问不到,可以尝试修改下本地的 hosts,但是 github 的访问 ip 经常变动,每次都要手动去更新 ip。

好在有 GitHub520 这个项目,让我们写个定时任务,实时拿最新的 ip 写入到系统的 hosts 配置文件中,比如 CentOS 下是 /etc/hosts

首先写入一个定时任务:

crontab -e

另起一行后,写入以下内容:

0 */1 * * * /usr/bin/sed -i "/# GitHub520 Host Start/Q" /etc/hosts && curl https://raw.hellogithub.com/hosts >> /etc/hosts && /usr/bin/sed -i "/<html>/, /<\/html>/d" /etc/hosts

每个 1 天就会去获取最新的数据进行写入。

:wq 保存之后重启下 crontab 服务:

systemctl restart crond.service

大功告成!

[译]使用 React Hooks 构建电影搜索应用程序

[译]使用 React Hooks 构建电影搜索应用程序

前言:

在这篇文章中,我们将使用 React Hooks 构建一个非常简单的应用程序。因此,我们不会在此应用程序中使用任何class 组件。 我将解释一些API的工作原理,以便于使你能在构建其它应用程序时能更得心应手地使用 React Hooks。

以下是完成这个应用程序之后的页面截图:

image.png
我知道,这名字看起来很有创造性...


基本上,该程序可通过 OMDB API 来搜索电影并将结果返回给我们。构建此应用程序的目的在于使我们更加理解 React Hooks 并且助你在自己开发的项目中更好地使用它,那么,我们开始吧!在此之前,你需要做一些事情:

  • Node (>=6)
  • 有一个超酷的代码编辑器 (我用的是 vscode)
  • OMDB的API key (你可以在此处获取或使用我的)

开始构建

创建 React app

这个教程将会使用 react 脚手架工具 create-react-app 来构建我们的应用,如果你还没有安装这个脚手架工具,在终端执行以下命令:

npm install -g create-react-app

接下来,创建我们的 React app,在终端输入以下命令:

create-react-app hooked

"hooked" 是我们创建的 app 的名字

完成后,我们应该有一个名为 “Hooked” 的文件夹,其目录结构如下所示:

image.png
初始化的项目结构

创建所需组件

此应用程序中包含4个组件,我来概述下每个组件及其功能:

  • App.js  — 它将是其他3个组件的父组件。它还将包含处理 API 请求的函数,并且具有在组件的初始渲染期间调用API的函数。
  • Header.js  — 一个简单的组件,可呈现应用程序标题并接收标题 prop
  • Movie.js  — 它渲染每个 movie 。 movie 对象作为 props 传递给它。
  • Search.js  — 包含具有输入元素和搜索按钮的表单,包含处理输入元素并重置字段的函数,还包含调用作为 props 传递给它的搜索函数的函数。

让我们开始创建它们吧,在 src 目录下,创建一个新文件夹命名为 components ,这个文件夹存放我们所有的组件,将 App.js 文件拖进去。接着,我们创建一个新的文件命名为 Header.js ,并输入以下代码:

import React from 'react';

const Header = (props) => {
  return (
    <header className="App-header">
      <h2>{props.text}</h2>
    </header>
  )
}

export default Header;

这个组件不需要太多的解释,就是一个很基本的组件,接受 props ,并将 props.text 渲染为页面标题。

别忘记更新我们的 index.js 文件:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './components/App';    // 嘿,看这里,这里变化了
import * as serviceWorker from './serviceWorker';

ReactDOM.render(<App />, document.getElementById('root'));

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

现在你执行 npm run start 必然是成功不了的,一是我们 App.js 路径变了,引入 App.css 的路径也没改,而且许多默认构建的元素如 logo.svg 路径都没变,我们现在先不急,等我们把组件都写好之后,在集中修改 App.js 。

接下来在 components 下继续创建一个新的组件 Movie.js ,添加以下代码:

import React from "react";

const DEFAULT_PLACEHOLDER_IMAGE =
  "https://m.media-amazon.com/images/M/MV5BMTczNTI2ODUwOF5BMl5BanBnXkFtZTcwMTU0NTIzMw@@._V1_SX300.jpg";

const Movie = ({ movie }) => {
  const poster =
    movie.Poster === "N/A" ? DEFAULT_PLACEHOLDER_IMAGE : movie.Poster;
  return (
    <div className="movie">
      <h2>{movie.Title}</h2>
      <div>
        <img
          width="200"
          alt={`The movie titled: ${movie.Title}`}
          src={poster}
        />
      </div>
      <p>({movie.Year})</p>
    </div>
  );
}

export default Movie;

这就需要解释一下啦~ 但它也只是一个无状态组件(没有任何内部状态),用于呈现电影的标题,图像和年份。之所以使用 DEFAULT_PLACEHOLDER_IMAGE ,是因为从 API 检索的某些电影没有图片,因此我们以一个自己预设好的图片作为替换,而不是一个断开的链接,这对用户很不友好。

现在我们来创建组件 Search.js ,这部分很令人激动,因为在过去,为了处理内部状态,我们不得不创建一个 class 组件... 但是现在不用了!因为有了 hooks ,我们现在可以创建一个普通的函数就能处理内部状态,就问你厉不厉害。在文件夹 components 下创建文件 Search.js ,添加以下代码:

import React, { useState } from "react";

const Search = (props) => {
  const [searchValue, setSearchValue] = useState('');

  const handleSearchInputChanges = (e) => {
    setSearchValue(e.target.value);
  }

  const resetInputField = () => {
    setSearchValue("")
  }

  const callSearchFunction = (e) => {
    e.preventDefault();
    props.search(searchValue);
    resetInputField();
  }

  return (
    <form className="search">
      <input
        value={searchValue}
        onChange={handleSearchInputChanges}
        type="text"
      />
      <input onClick={callSearchFunction} type="submit" value="SEARCH" />
    </form>
  );
}

export default Search;

这真的太酷了,你不用像以前一样在 class 组件中的 constructor 中创建状态,利用 setState 更新状态,以及繁琐的 .bind(this) 。我相信你已经看过了我们使用的 useState ,顾名思义,它使我们可以将 React 状态添加到普通函数组件中。 useState 接受一个参数,该参数是初始状态,然后返回一个包含当前状态(等同于类组件的 this.state )和更新它的函数(等同于 this.setState )的数组。

在本例中,我们将当前状态作为搜索输入字段的值。 因为注册了 onChange 事件,在输入改变时,将调用 handleSearchInputChanges 函数,该函数使用新的输入值去更新当前状态。 resetInputField 函数就是重置输入框的值为空字符串。 点我了解更多 useState API 信息。

最后,我们来解决我们之前留下的坑,更新 App.js :

import React, { useState, useEffect } from "react";
import "../App.css";
import Header from "./Header";
import Movie from "./Movie";
import Search from "./Search";

// 下面这个地址你需要替换为你自己的
// 你用浏览器打开这个网址试试,看看是什么?
const MOVIE_API_URL = "https://www.omdbapi.com/?s=man&apikey=4a3b711b";

const App = () => {
  const [loading, setLoading] = useState(true);
  const [movies, setMovies] = useState([]);
  const [errorMessage, setErrorMessage] = useState(null);

  useEffect(() => {
    fetch(MOVIE_API_URL)
      .then(response => response.json())
      .then(jsonResponse => {
        setMovies(jsonResponse.Search);
        setLoading(false);
      });
  }, []);

  const search = searchValue => {
    setLoading(true);
    setErrorMessage(null);

    fetch(`https://www.omdbapi.com/?s=${searchValue}&apikey=4a3b711b`)
      .then(response => response.json())
      .then(jsonResponse => {
        if (jsonResponse.Response === "True") {
          setMovies(jsonResponse.Search);
          setLoading(false);
        } else {
          setErrorMessage(jsonResponse.Error);
          setLoading(false);
        }
      });
  };

  return (
    <div className="App">
      <Header text="HOOKED" />
      <Search search={search} />
      <p className="App-intro">分享一些为喜欢的电影</p>
      <div className="movies">
        {
          loading && !errorMessage ? (
            <span>loading...</span>
          ) : errorMessage ? (
            <div className="errorMessage">{errorMessage}</div>
          ) : (
                movies.map((movie, index) => (
                  <Movie key={`${index}-${movie.Title}`} movie={movie} />
                ))
              )
        }
      </div>
    </div>
  );
}

export default App;

让我仔细研究下上面的代码:我们使用了3个 useState 函数,是的,我们可以在一个组件中写多个 useState 函数,第一个用于处理加载状态(将loading设置为true时,它会呈现“ loading…”文本)。第二个用于处理从服务器获取的电影数组。 第三个用于处理发出API请求时可能发生的任何错误。

之后,我们遇到了应用程序中使用的第二个钩子 API: useEffect 钩子。 该钩子可以在功能组件中执行副作用。 所谓副作用,是指诸如数据获取,订阅和手动 DOM 操作之类的事情。 关于这个钩子的最好的部分是 React 官方文档中的这句话:

useEffect 就是一个 Effect Hook,给函数组件增加了操作副作用的能力。它跟 class 组件中的 componentDidMountcomponentDidUpdate 和 componentWillUnmount 具有相同的用途,只不过被合并成了一个 API。

其实就是说, useEffect 在首次渲染(componentDidMount)以及之后每次更新(componentDidUpdate)都被调用。

我知道你可能想知道如果每次更新后都调用它,那与 componentDidMount 有何相似之处呢? Emmm..,这是因为 useEffect 函数接受两个参数,一个是你要运行的函数,另一个是数组,你仔细看看官方文档的代码或上面我们自己写的代码。 在该数组中,我们只传入一个值,该值告诉 React 如果传入的值没有被更改,则跳过此次调用。

根据文档,这类似于我们在 componentDidUpdate 中添加条件语句时的情况:

// class 组件
componentDidUpdate(prevProps, prevState) {
  if (prevState.count !== this.state.count) {
    document.title = `You clicked ${this.state.count} times`;
  }
}


// 使用 hooks 的函数组件
useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]); // 只有 count 值变了,才会重新执行

在我们的例子中,我们没有任何变化的值,因此我们可以传入一个空数组,该数组告诉 React 这个效果应该被调用一次。

如你所见,我们有3个 useState 函数,它们看起来有相关性,应该有可能将它们组合在一起。 为了做到这点,React 团队已经为我们想到了,于是他们制作了一个有助于此操作的钩子 - 该钩子称为 useReducer 。 让我们将App组件转换为使用 useReducer 的新组件,这样我们的 App.js 现在将如下所示:

import React, { useEffect, useReducer } from "react";
import "../App.css";
import Header from "./Header";
import Movie from "./Movie";
import Search from "./Search";

// 下面这个地址你需要替换为你自己的
const MOVIE_API_URL = "https://www.omdbapi.com/?s=man&apikey=4a3b711b";

const initialState = {
  loading: true,
  movies: [],
  errorMessage: null
}

const reducer = (state, action) => {
  switch (action.type) {
    case "SEARCH_MOVIES_REQUEST":
      return {
        ...state,
        loading: true,
        errorMessage: null
      };
    case "SEARCH_MOVIES_SUCCESS":
      return {
        ...state,
        loading: false,
        movies: action.payload
      };
    case "SEARCH_MOVIES_FAILURE":
      return {
        ...state,
        loading: false,
        errorMessage: action.error
      };
    default:
      return state;
  }
}

const App = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  useEffect(() => {
    fetch(MOVIE_API_URL)
      .then(response => response.json())
      .then(jsonResponse => {
        dispatch({
          type: "SEARCH_MOVIES_SUCCESS",
          payload: jsonResponse.Search
        });
      });
  }, []);
  
  const search = searchValue => {
    dispatch({
      type: "SEARCH_MOVIES_REQUEST"
    });
    fetch(`https://www.omdbapi.com/?s=${searchValue}&apikey=4a3b711b`)
      .then(response => response.json())
      .then(jsonResponse => {
        if (jsonResponse.Response === "True") {
          dispatch({
            type: "SEARCH_MOVIES_SUCCESS",
            payload: jsonResponse.Search
          });
        } else {
          dispatch({
            type: "SEARCH_MOVIES_FAILURE",
            error: jsonResponse.Error
          });
        }
      });
  };

  const { movies, errorMessage, loading } = state;

  return (
    <div className="App">
      <Header text="HOOKED" />
      <Search search={search} />
      <p className="App-intro">分享一些为喜欢的电影</p>
      <div className="movies">
        {
          loading && !errorMessage ? (
            <span>loading...</span>
          ) : errorMessage ? (
            <div className="errorMessage">{errorMessage}</div>
          ) : (
                movies.map((movie, index) => (
                  <Movie key={`${index}-${movie.Title}`} movie={movie} />
                ))
              )
        }
      </div>
    </div>
  );
}

export default App;

如果一切顺利,那么我们应该不会看到应用程序与之前相比有任何变化。 现在让我们来看一下 useReducer 挂钩的工作方式。

该 hook 接受3个参数,但在我们的用例中,我们将仅使用2个。典型的 useReducer 钩子如下所示:

const [state, dispatch] = useReducer(
  reducer,
  initialState
);

reducer 参数类似于我们在 Redux 中使用的参数,看起来像这样:

const reducer = (state, action) => {
  switch (action.type) {
    case "SEARCH_MOVIES_REQUEST":
      return {
        ...state,
        loading: true,
        errorMessage: null
      };
    case "SEARCH_MOVIES_SUCCESS":
      return {
        ...state,
        loading: false,
        movies: action.payload
      };
    case "SEARCH_MOVIES_FAILURE":
      return {
        ...state,
        loading: false,
        errorMessage: action.error
      };
    default:
      return state;
  }
}

reducer 接收 initialState 和 action ,因此 reducer 根据 action.type 返回一个新的状态对象。 例如,如果调度的操作类型为 SEARCH_MOVIES_REQUEST ,则状态将使用新对象更新,其中 loading 的值为 true ,而 errorMessage 为 null。

值得一提的是,在搜索功能中,我们实际上是在分派三个不同的动作:

  • 一个动作是 SEARCH_MOVIES_REQUEST 动作,它更新我们的状态对象,使 loading = true 且 errorMessage = null 。
  • 如果请求成功,那么我们将分派另一个类型为 SEARCH_MOVIES_SUCCESS 的动作,该动作将更新我们的状态对象,从而使 loading = false 和 movie = action.payload ,其中 payload 是从OMDB获得的movie 数组。
  • 如果出现错误,我们将分派类型为 SEARCH_MOVIES_FAILURE 的其他操作,该操作更新状态对象,使 loading = false 和 errorMessage = action.error ,其中 action.error 是从服务器获取的错误消息。

要了解有关 useReducer 钩子的更多信息,请查看官方文档

最后修改我们的 App.css (这部分不是重点,直接把现在所需的样式全给你们了,作为参考):

.App {
  text-align: center;
}

.App-header {
  background-color: #282c34;
  height: 70px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
  padding: 20px;
  cursor: pointer;
}

.spinner {
  height: 80px;
  margin: auto;
}

.App-intro {
  font-size: large;
}

/* new css for movie component */

* {
  box-sizing: border-box;
}

.movies {
  display: flex;
  flex-wrap: wrap;
  flex-direction: row;
}

.App-header h2 {
  margin: 0;
}

.add-movies {
  text-align: center;
}

.add-movies button {
  font-size: 16px;
  padding: 8px;
  margin: 0 10px 30px 10px;
}

.movie {
  padding: 5px 25px 10px 25px;
  max-width: 25%;
}

.errorMessage {
  margin: auto;
  font-weight: bold;
  color: rgb(161, 15, 15);
}


.search {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  justify-content: center;
  margin-top: 10px;
}


input[type="submit"] {
  padding: 5px;
  background-color: transparent;
  color: black;
  border: 1px solid black;
  width: 80px;
  margin-left: 5px;
  cursor: pointer;
}


input[type="submit"]:hover {
  background-color: #282c34;
  color: antiquewhite;
}


.search > input[type="text"]{
  width: 40%;
  min-width: 170px;
}

@media screen and (min-width: 694px) and (max-width: 915px) {
  .movie {
    max-width: 33%;
  }
}

@media screen and (min-width: 652px) and (max-width: 693px) {
  .movie {
    max-width: 50%;
  }
}


@media screen and (max-width: 651px) {
  .movie {
    max-width: 100%;
    margin: auto;
  }
}

你做到了!

哇!!! 我们已经走了很长一段路,我相信你对 hooks 的可能性感到兴奋。 就我个人而言,将初学者介绍给 React 非常容易,因为我不再需要解释 class 的工作方式或 this 的工作方式,或者在JS中 bind 的工作方式。

在本教程中,我们仅涉及了一些钩子,甚至没有介绍创建自己的自定义钩子等功能。 如果您还有其他用钩的用例,或者已经实现了自己的自定义钩,请添加评论并加入其中。

这篇文章的代码就不提供了,我希望任何能安心看下来的小伙伴能手动敲一边,收获还是有的!~

后记:

因为笔者也是刚刚学 React hooks,在掘金上另一篇文章中看到了推荐这个项目,自己读了一遍,做了一遍,发现作为入门还是不错的,故想翻译一下让更多学习 React hooks 的小伙伴能学习到~若是翻译有误,还请指正,谢谢啦🙏。

这篇文章收录于我自己的Github/blog,若对你有所帮助,欢迎 star,之后会陆续推出更多基础优质文章~

逐步解析 koa2 核心实现原理及代码实践

引言

作为一个前端,工作大部分时间都是在和页面打交道,但如果有一天你有一个很好的产品 idea,前后端都需要,就尴尬了,一般这个时候有 3 条路走:

  • 放弃,心里想着这产品哪怕做出来可能也没人用,还要消耗自己大部分时间和人力,白费力气。
  • 找一个认识的后端,说动他(她)并一起实践这个伟大的想法。
  • 前后端都自己做,不过要花大量时间去涉猎服务端相关知识,并且一个人做两个人的活儿,累死累活最终实现这个伟大的想法。

我的建议还是首先尝试一下第三条路(如果你觉得这个想法再不快点实现就亏了一个亿,你也可以首先选第二条路),先别想着放弃,一方面哪怕最后不成功没人用,但是从技术角度讲,这不是刚好扩展了自己的知识面吗?而且万一咱的产品🔥了呢?

想搞服务端,前端最好的选择无非就是 NodeJS 了,语法和我们写前端时的 JavaScript 是一样的,只需要了解相关的 node 模块即可上手编写服务端代码。而我们最先应该了解的就是 http 模块,它能让我们快速启动一个服务。但是因为历史包袱以及考虑到广泛的适用性,原生的模块多少是需要二次封装一下才能很好地服务于我们开发者。

Koa2 原理实现

Koa2 就是这么个封装了原始 http 模块,拥有更好的心智模型的框架,接下来我会从原理实现入手,讲清楚如何实现一个基本的 Koa2 ,再到后面介绍如何基于 Koa2 开始我们的服务端开发!一个快乐的 SQL Boy!🎉

koa2 与 http 简单对比

我们先使用 node 原生模块 http 起一个本地服务:

// server-http.js
const http = require('http')

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/html' })
  res.end('<h1>Hello World</h1>')
})

server.listen(3000, () => {
  console.log('server is running on http://localhost:3000')
})

执行一下 node server-http.js ,在浏览器打开 http://localhost:3000 ,即可看到效果。

现在使用对 http 进行了封装的 Koa2 起一个类似的本地服务,当然了,这需要我们先安装一下这个包,控制台执行下 npm i koa ,然后在新建的文件中写入以下代码:

// server-koa.js
const Koa = require('koa')

const app = new Koa()

app.use(async ctx => {
  ctx.response.res.writeHead(200, { 'Content-Type': 'text/html' })
  ctx.body = '<h1>Hello World</h1>'
})

app.listen(3001, () => {
  console.log('server is running on http://localhost:3001')
})

执行一下 node server-koa.js ,在浏览器打开 http://localhost:3001 ,即可看到一样的效果。

我们对比下两者书写上的区别,可以很直观地发现:

  • koa 中导出的是一个类 class ,没有暴露创建服务的方法 http.createServer ,可以猜想到是在 app.listen 中执行了此方法。
  • app.use 的第一个参数是一个回调函数,与 http.createServer 类似,不过,回调参数 reqres 被封装到了一个参数 ctx 中。
  • 在原生 http 中将内容响应到客户端是使用 res.end ,在 koa2 中却是 ctx.body ,咋回事呢?

现在我们对两者先有直观上的区别感受,接下来大家逐步跟着我的思路阅读,会对这种区别产生的原因理解地透透的,大家记住一句话,本质上 koa2 就是对 http 的扩展,使其有更多的常用功能和更好用而已,我们学习的就是 koa2 的封装思路。

koa2 源码文件的结构

koa2 的源码文件就只有 4 个,很简洁明了。

|- lib
    |-- application.js
    |-- context.js
    |-- request.js
    |-- response.js

各个文件的名字很直接展示了其主要作用:

  • application.js 为导出 Koa 类的主入口文件,内部实现将其它模块串联起来的逻辑。
  • context.js 主要作用是代理 request.jsresponse.js 中的方法。
  • request.js 封装了 http 的请求,扩展了一些功能。
  • response.js 封装了 http 的响应,扩展了一些功能。

根据 koa2 的简单 demo 和上面的文件结构,我们就可以顺着思路一步步实现基本的 koa2 了,这里的实现不是完全照搬 koa2 的源码,而是利用其实现思路,写一个相对来说更容易理解的版本。

封装 http 服务

新建 application.js ,直接创建 Application 类,并实现对 node 中 http 的封装:

const http = require('http')

class Application {
  constructor() {
    this.fn = null
  }

  use(fn) {
    this.fn = fn
  }

  handleRequestCallback() {
    return (req, res) => {
      this.fn(req, res)
    }
  }

  listen(...args) {
    const server = http.createServer(this.handleRequestCallback())
    server.listen(...args)
  }
}

module.exports = Application

上面的代码就是对 http 的一个简单封装,利用 app.use 注册回调函数,通过 app.listen 监听 server 并传入参数。

值得注意的是 handleRequestCallback 返回的是一个箭头函数,这里是为了让 this 指向的是实例,毕竟 fn 就是挂在实例上的。如果这里不这样写,而是直接执行 this.fn(req, res) ,其中 this 将会指向我们创建的 server ,显然是不正确的。

此时在同目录新建一个 test.js ,写入以下代码:

const Koa = require('./koa')

const app = new Koa()

app.use((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/html' })
  res.end('<h1>Hello World</h1>')
})

app.listen(8888, () => {
  console.log('server is running on http://localhost:8888')
})

控制台使用 node 命令执行该文件,随后打开 http://localhost:8888 ,会发现 Hello World 被正确地返回。

但是我们使用 koa2 时,app.use 中回调函数的第一个参数是 ctx ,而不是现在的 node 原生的 requestresponse 对象,所以我们要将其封装成如下这样:

app.use(ctx => {
  ctx.response.res.writeHead(200, { 'Content-Type': 'text/html' })
  ctx.body = '<h1>Hello World</h1>'
})

如果你将代码替换成上面这种写法,数据是不会被正确返回的,我们后续会再继续完善!

接下来就需要我们编写 context.jsrequest.jsresponse.js 中的内容了,并将它们在 application.js 中串联起来。

创建上下文 context

使用 koa 时,在上下文中能访问到封装的请求对象 ctx.request ,原生模块的请求对象 ctx.req ,封装的响应对象 ctx.response ,原生模块的响应对象 ctx.res ,以及原生的请求、响应对象都被附加到了封装的请求、响应对象中,即 ctx.request.reqctx.response.res

先在各个文件写入最简单的代码,先实现这些对象存储结构。

context.js

const context = {}

module.exports = context

request.js

const request = {}

module.exports = request

response.js

const response = {}

module.exports = response

然后在 application.js 中导入这些模块:

const context = require('./context')
const request = require('./request')
const response = require('./response')

接下来我们思考以下几个问题:

  • 如何做到避免用户直接操作我们的 contextrequestresponse 对象?
  • 每新建一个应用,即 new Koa ,如何保持各个应用中对于这 3 个模块的独立性?
  • 每次通过 app.use 注册回调函数时,这些回调函数内的上下文都是独立的?

koa 通过在构造函数内分别创建三个对应的对象,并将原型分别指向 contextrequestresponse ,解决前两个问题:

class Application {
  constructor() {
    this.context = Object.create(context)
    this.request = Object.create(request)
    this.response = Object.create(response)

    this.fn = null
  }
}

因为 http 请求无状态,使用 app.use 注册回调函数时,其上下文也要保持独立,所以需要再进行一次类似上面的原型操作:

class Application {
  createContext(req, res) {
    const context = Object.create(this.context)
    const request = Object.create(this.request)
    const response = Object.create(this.response)

    return context
  }

  handleRequestCallback() {
    return (req, res) => {
      const ctx = this.createContext(req, res)
      this.fn(ctx)
    }
  }

再然后就是将各个原生对象挂在我们自己封装的对象,以下是这一步骤 application.js 完整代码:

const http = require('http')
const context = require('./context')
const request = require('./request')
const response = require('./response')

class Application {
  constructor() {
    this.context = Object.create(context)
    this.request = Object.create(request)
    this.response = Object.create(response)

    this.fn = null
  }

  use(fn) {
    this.fn = fn
  }

  createContext(req, res) {
    const context = Object.create(this.context)
    const request = Object.create(this.request)
    const response = Object.create(this.response)

    context.req = req // 原生的
    context.request = request // 自己封装的
    context.request.req = req // 原生的

    context.res = res // 原生的
    context.response = response // 自己封装的
    context.response.res = res // 原生的

    return context
  }

  handleRequestCallback() {
    return (req, res) => {
      const ctx = this.createContext(req, res)
      this.fn(ctx)
    }
  }

  listen(...args) {
    const server = http.createServer(this.handleRequestCallback())
    server.listen(...args)
  }
}

module.exports = Application

自定义 request 和 response 扩展

我们上面说到过,ctx 上挂载的 requestresponse 是自定义的请求和响应对象的扩展,接下来举个例子说明这两个自定义对象的目的。

第一种情况,代理原生请求或响应对象本来就有的能力:

const request = {
  get url() {
    return this.req.url
  },
  set url(val) {
    this.req.url = val
  },
}

module.exports = request

通过 getter/setter 函数对原生的 url 进行代理,可方便进行赋值和取值操作。

我们在 test.js 中通过以下写法来获取 url ,这样写:

app.use(ctx => {
  ctx.response.res.writeHead(200, { 'Content-Type': 'text/html' })
  console.log(ctx.request.url)
  ctx.body = '<h1>Hello World</h1>'
})

控制台使用 node 命令重新执行该文件,随后打开 http://localhost:8888/a/b ,在控制台会发现打印了 /a/b ,说明我们自定义的 request 扩展对象代理 url 成功。

现在考虑下,为什么在执行 this.req.url 时能够正确访问原生 req 对象?因为在 context 中我们将原生 req 对象挂载到了自定义扩展的 request 对象上了,即以下这行代码:

context.request.req = req

于是访问 this.req.url 时,实际上这里的 this 就是 ctx.request

第二种情况,新增原生对象上没有的能力:

const response = {
  _body: undefined,
  get body() {
    return this._body
  },
  set body(val) {
    this._body = val
    this.res.statusCode = 200
  }
}

module.exports = response

到目前为止,我们的服务并没有返回任何东西,如果你开着浏览器访问,会一直转圈圈,因为我们实际上并没有返回任何东西,原生的 http 服务通过 res.end('xxx') 来返回数据并关闭连接。而现在我们是通过ctx.body 来模拟这个操作。

有了 response 模块这部分代码,当我们执行 ctx.body = '<h1>Hello World</h1>' 时,相当于给 _body 赋值存了起来。

诶,不对,给 ctx.body 赋值,关我 response 什么事?还记得一开始我们说过,context.js 主要作用是代理 request.jsresponse.js 中的方法。

比如 ctx.body 其实就相当于 ctx.response.body ,回到 context 模块,以下代码就是实现这种代理的方式:

const context = {}

function defineGetter(target, key) {
  context.__defineGetter__(key, function() {
    return this[target][key]
  })
}

function defineSetter(target, key) {
  context.__defineSetter__(key, function(value) {
    return this[target][key] = value
  })
}

defineGetter('request', 'url')
defineSetter('request', 'url')

defineGetter('response', 'body')
defineSetter('response', 'body')

module.exports = context

实际上 __defineGetter____defineSetter 一直都是非标准方法,但是其兼容性却特别好。koa2 中使用的 delegates 模块也一直是用的这两个非标准方法。或许大家可以考虑下用 Object.defineProperty 来实现。

回到 application.js ,在 handleRequestCallback 函数中添加以下代码:

class Application {
  handleRequestCallback() {
    return (req, res) => {
      const ctx = this.createContext(req, res)
      res.statusCode = 404
      this.fn(ctx)

      const content = ctx.body
      if (content) {
        res.end(content)
      } else {
        res.end('Not Found')
      }
    }
  }
}

module.exports = Application

重启服务,再看看浏览器,即可看到正确返回了 Hello World

中间件机制 - 洋葱模型

目前在我们的测试文件 test.js 中只使用了一次 app.use ,注册了一个回调函数,然而在实际应用时,我们必然会使用多次的。

现在我们使用 koa2 做个测试,新建一个 test-koa.js 写入以下代码:

// test-koa.js
const Koa = require('koa')

const app = new Koa()

app.use((ctx, next) => {
  console.log(1)
  next()
  console.log(2)
})

app.use((ctx, next) => {
  console.log(3)
  next()
  console.log(4)
})

app.use((ctx, next) => {
  console.log(5)
  next()
  console.log(6)
})

app.listen(7777, () => {
  console.log('server is running on http://localhost:7777')
})

控制台执行 node test-koa.js 后,打开 http://localhost:7777 �,再回到控制台会看到打印的顺序如下:

1 3 5 6 4 2

koa2 中注册函数的第一个参数 ctx 大家很熟悉了,第二个参数 next 代表下一个要被执行的注册函数。其实上面的代码可以这样来理解:

app.use((ctx, next) => {
  console.log(1)
  (ctx, next) => {
    console.log(3)
    (ctx, next) => {
      console.log(5)
      // empty
      console.log(6)
    }()
    console.log(4)
  }()
  console.log(2)
})

每一次执行 next() 函数就相当于将下一个注册函数执行,也就是说执行下一个中间件函数,这就是所谓的洋葱模型,用一张图来解释:

上面的代码中全是同步逻辑,如果我们的中间件函数里有异步逻辑,也就是我们使用 koa2 时经常用到的 async/await ,我们考虑下面一段代码会在浏览器显示什么,以及控制台的打印顺序:

const Koa = require('koa')

const app = new Koa()

const sleep = (time) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log('sleeping')
      resolve()
    }, time)
  })
}

app.use((ctx, next) => {
  console.log(1)
  ctx.body = '1'
  next()
  console.log(2)
  ctx.body = '2'
})

app.use(async (ctx, next) => {
  console.log(3)
  ctx.body = '3'
  await sleep(2000)
  next()
  console.log(4)
  ctx.body = '4'
})

app.use((ctx, next) => {
  console.log(5)
  ctx.body = '5'
  next()
  console.log(6)
  ctx.body = '6'
})

app.listen(7777, () => {
  console.log('server is running on http://localhost:7777')
})

结果是浏览器访问 http://localhost:7777 显示的是 2 ,控制台打印的是顺序是:

1 3 2 (延迟 2000 ms 后) sleeping 5 6 4

koa2 中代表所有中间件函数执行完毕的标志是最外圈的“洋葱皮”被“刀”都切到了,也就是说最外层的代码都被执行完毕了,知道这个我们再来分析上面代码的输出结果。

在第一个(也就是最外层)中间件函数中,执行到的 next 中有异步逻辑 但我们没有等待 next 执行完毕,只是进入了第二个中间件函数中开始执行代码,所以直接打印出了 1 3 2 ,至此其实已经判定为中间件函数执行完毕了,开始响应逻辑,所以在浏览器页面上看到的是 2

但后续的代码还在执行,在第二个中间件函数中使用了 await ,于是 2000 ms 后继续走后续逻辑,所以在控制台的打印顺序如上。

所以咱们在 koa2 的使用中,一定要在 next() 前加上 await ,不然大概率结果会不如预期,大多数中间件都是有异步逻辑的。

实现中间件机制

koa2 中是如何实现上述的中间件机制的呢?通过上述代码和结果演示,能够看出每一个中间件函数的执行顺序是和 app.use 的使用顺序一致的,这不难想到也许 koa2 中使用了一个数组来保存每一个中间件函数,并依次执行。

回到我们的 application.js 模块,接下来就是重头戏了,也是 koa2 中最核心最精华的部分,我将演示如何一步步实现中间件机制。

初始化一个 middlewares 数组

在构造函数 constructor 中把我们原来定义的 this.fn 删掉,定义一个数组 this.middlewares = [] ,其目的是保存所有 app.use 注册的中间件函数。

class Application {
  constructor() {
    this.context = Object.create(context)
    this.request = Object.create(request)
    this.response = Object.create(response)

    this.middlewares = []
  }
}

添加中间件函数

use 函数内部,删除 this.fn = fn ,而是将 fn 添加至 this.middlewares 数组中。

class Application {
  use(fn) {
    this.middlewares.push(fn)
  }
}

新建组合函数 compose

之前只注册一个函数时,我们直接执行 this.fn(ctx) ,但现在我们需要一个新的组合函数 compose 来执行所有注册的有异步逻辑的中间件函数,并且返回一个 Promise

class Application {
  compose(ctx) {
    // 执行所有中间件函数并返回一个 Promise
  }

  handleRequestCallback() {
    return (req, res) => {
      const ctx = this.createContext(req, res)
      res.statusCode = 404

      this.compose(ctx)
        .then(() => {
          const content = ctx.body
          if (content) {
            res.end(content)
          } else {
            res.end('Not Found')
          }
        })
    }
  }
}

⚠️koa2 源码中使用了 koa-compose 模块,该模块导出的 compose 为一个函数,该函数执行后返回一个新的内联函数,我们上述的实现相当于直接把这个内联函数抽出来执行了,相信大家看源码的时候会理解的。

实现 compose 逻辑

我们在 compose 内部的代码主要实现以下逻辑:

  • 没有注册中间件函数时,直接返回 Promise.resolve()
  • 从第一个中间件函数执行开始,遇到执行 next 就意味着要执行第二个中间件函数,相当于递归调用。
  • 所有返回结果都要包装成 Promise
  • 一个中间件函数不能被调用两次,否则抛错。

于是我们根据上面思路,就可以写出以下代码:

class Application {
  compose(ctx) {
    let index = -1

    const dispatch = (i) => {
      // 一个中间件函数不能被调用两次,否则抛错
      if (i <= index) {
        return Promise.reject('[Error] next() called multiples times')
      }

      index = i

      // 没有注册中间件函数时,直接返回 Promise.resolve()
      if (this.middlewares.length === i) {
        return Promise.resolve()
      }

      const fn = this.middlewares[i]
      try {
        // 遇到执行 next 就意味着要执行第二个中间件函数,相当于递归调用
        // 这里 () => dispatch(i + 1) 就是 next
        return Promise.resolve(fn(ctx, () => dispatch(i + 1)))
      } catch (err) {
        return Promise.reject(err)
      }
    }

    // 从第一个中间件函数执行开始
    return dispatch(0)
  }
}

大家思考一下,为什么我在一个中间件函数里执行两次或以上 next ,就会导致报错?原因在于我们执行无论多少次,i + 1i 都是同一个值,但是第一次执行 dispatch(i + 1) 时,index 已经赋值为 i + 1 了,这样第二次执行时,i + 1 <= index 就会成立,反之就代表只执行了一次。

就是那么简单的几行代码就实现了 koa2 的核心功能,不过我们只是阅读别人的代码时候觉得简单,真的要自己想出来估计也是需要不少脑细胞的。

接下来你可以拿刚才写好的 test-koa 来测试这段逻辑了,别忘了引入的是我们自己的 koa 哦~

错误捕获与处理

一个优秀的框架或 SDK,良好的错误或异常捕获是很有必要的,不至于因为代码执行错误导致后续逻辑中断,这可以给开发者更多的信息,也能有更多选择,比如降级逻辑。

koa2 中某个中间件函数发生错误时,可以通过 app.on('error', () => {}) 拿到错误信息,这需要 node 的原生模块 events 默认导出的 EventEmitter 支持,如果有用过 Vue 的同学,对这个一定很熟悉了。不熟悉的同学可以搜索下发布订阅模式~

回到 application.js ,我们引入并继承这个类,构造函数中要加上 super()

const EventEmitter = require('events')

class Application extends EventEmitter {
  constructor() {
    super()

    this.context = Object.create(context)
    this.request = Object.create(request)
    this.response = Object.create(response)

    this.middlewares = []
  }
}

在执行 compose 方法的地方,我们已经写了 then ,把 catch 也补上:

class Application {
  handleRequestCallback() {
    return (req, res) => {
      // ...
      this.on('error', this.onerror)
      const onerror = err => ctx.onerror(err)

      this.compose(ctx)
        .then(() => {
          // ...
        })
        .catch(onerror)
    }
  }

  onerror(err) {
    const msg = err.stack || err.toString()
    console.error('[Inner Error]', `\n${msg.replace(/^/gm, '  ')}\n`)
  }
}

在上面代码我们总共做了两件事:

  • 添加 this.on('error', this.onerror) 并创建了一个方法 onerror ,该方法用于处理捕获到的错误,并处理后在控制台输出(我这里为了演示简便,只是很简单处理)。
  • 添加 const onerror = err => ctx.onerror(err) ,并在 catch 中将 err 传递给 onerror

上面的两个 onerror 作用是完全不一样的,第一个是用于 koa2 内部错误打印,如果用了社区的 koa-logger 还能用于收集错误日志。第二个是用于返回给用户的原始错误。

但是给用户的错误是通过 ctx.onerror 去做的,所以我们要来到 context 模块,为其添加一个 onerror 方法:

const context = {
  onerror(err) {
    if (null == err) return
    this.app.emit('error', err, this)
  },
}

同样地,我为了演示,在该方法只是将错误抛出去,可以看到是通过 this.app.emit 进行抛出的,那么问题来了,context 上面怎么会有一个 app 属性,并且它上面还有继承了 EventEmitter 才有的方法 emit ,不难想到,其实我们只需要在 application 模块中 createContext 时,将 this 赋值给 ctx.app 就可以了:

class Application {
  createContext(req, res) {
    const context = Object.create(this.context)
    const request = Object.create(this.request)
    const response = Object.create(this.response)

    context.app = this
    // ...

    return context
  } 
}

接下来新建一个 test-koa-error.js 文件,输入以下故意有错误的代码,执行后看看控制台是不是正确打印了错误:

const Koa = require('./koa')

const app = new Koa()

app.use((ctx) => {
  ctx.response.res.writeHead(200, { 'Content-Type': 'text/html' })
  str += '<h1>Hello World</h1>' // 变量未声明,应该报错
  ctx.body = str
});

app.on('error', (err, ctx) => {
  console.error('[Outer Error]', err)
});

app.listen(8888, () => {
  console.log('server is running on http://localhost:8888')
})

启动服务后,打开 http://localhost:8888 再回到控制台,出现[Outer Error][Inner Error] ,说明我们的错误捕获成功了!

2

当然,这两个提示只是我为了区分才故意这样写的哦~

参考代码

以上就是 koa2 框架实现的基本原理,因为是文章的展现形式,可能做不到每行代码都解释清清楚楚,也不可能每一行代码都演示如何去写,我已经尽量将整段代码切割成一块一块,如果大家还是有不理解的地方,可以参考下本文的所有演示代码:

koa2 实现思路源码

结语

写这篇文章的目的一方面在于强制驱动自己去学习了解 koa2 的源码,以便于在使用 koa2 进行开发时遇到问题能快速定位问题,也能学习到其封装思路,之后自己写代码时能借鉴其**;另一方面希望能帮助和我有一样想法的同学建立起源码阅读思路。总而言之,我认为源码的阅读不是为了读而读,而是阅读理解它之后或许能让我们在实践时有指导思路。

另外,如果对大家有所帮助,给我的 blog 赏个 star🌟 哦~

不一样的“悬停几秒后执行函数”?

大家好,我是 vortesnail。

前言:

最近这几个星期,一直都在维护自己的基于 React 的开源播放器组件,以为功能基本都差不多了,却忽视了播放器一个很重要的功能:鼠标悬停在视频播放界面时,在一定时间后鼠标会消失,视频下方的控制栏也会隐藏,呈现视频的最大可视化。但是鼠标稍微一动,一切恢复如初。用一张简单的 gif 图来说明的话,是下面这样子的:
屏幕录制-2019-10-31-11.18.41 (1).gif
有点犯难,它不是简单地移到元素(如视频)上,2秒后让它执行鼠标消失和控制栏消失的操作,因为一旦鼠标一动一点,都要恢复原样,不过好在结合 防抖 的**以及自己的一些思考,实现了这个功能,并将其封装为一个工具函数,可供大家使用,当然,其中也有不足,也请各位能给予意见,我会结合给的意见更新这篇博客。

开始:

如果你现在需要使用这个功能,你希望用起来尽量简单,并且能达到你的使用要求,思来想去,给你暴露 4 个参数最为妥当:

  • element:你所希望操作的元素(比如上面 gif 中 “我是视频”这个 div 元素)
  • secondsLaterDoFn:你设定的时间之后,想做什么操作(比如上面 gif 中“鼠标消失,控制栏消失”)
  • seconds:你希望的时间,单位:ms(比如上面 gif 中我设定的时间为 2000ms)
  • reNormalFn:回归原样的操作(比如上面 gif 中控制栏和鼠标都回来)

那么,我们现在写一个函数,把这四个参数传进去,并对传入的 element 写两个监听事件,以及我们的清除定时器函数:

function HoverSD(element, secondsLaterDoFn, seconds, reNormalFn) {
  var timeout;
  var ele = element, secondsLDF = secondsLaterDoFn, secs = seconds, reNFn = reNormalFn;
  
  var clearTimer = function() {
    timeout && clearTimeout(timeout);
  }

  this.secondsHoverEX = function() {
    ele.addEventListener('mousemove', rebuildTimer);
    ele.addEventListener('mouseleave', clearTimer);
  }
}

window.HoverSD = HoverSD;

你也发现了, rebuildTimer 和 clearTimer 是个啥玩意儿?别急。
我们整理一下思路:在鼠标移到这个元素上面之后,我们要有一个定时器,在设定的时间过后,执行操作,但是,我们在设定的时间之内,又移动了鼠标,这时候需要把之前设定的定时器清除,重新开一个定时器,重新计时,这里的**和 防抖 一模一样,于是我们初步的 rebuildTimer 如下:

var rebuildTimer = function() {
  var context = this;
  var args = arguments;
  clearTimer();
  timeout = setTimeout(() => {
    secondsLDF.apply(context, args);
  }, secs);
}

意思就是,当我们 mousemove 的时候,就会执行 rebuildTimer 函数,在这个函数内部,清除定时器,即每次移动之后,重新计时,执行 secondsLDF 。

可是我们传入的参数 reNormalFn (即 reNFn )并没有用到啊,它是用来恢复原状态的操作,我们直接插入在清除定时器之前就可以了。

var rebuildTimer = function() {
  var context = this;
  var args = arguments;
  
  reNFn.apply(context, args);
  
  clearTimer();
  timeout = setTimeout(() => {
    secondsLDF.apply(context, args);
  }, secs);
}

那我们在特定的时候想要移出这个元素的监听事件怎么办呢?比如在 React 中我们在 componentDidMount 中用了,需要在 componentWillUnmount 中去除监听事件,防止内存占用,那我们就需要再写一个移除事件的函数:

this.removeElemEventListener = function() {
  ele.removeEventListener('mousemove', rebuildTimer);
  ele.removeEventListener('mouseleave', clearTimer);
}

注意, removeEventListener 的参数,必须与监听时候的执行函数完全相同,且 不能有参数,不能有参数,不能有参数!!! ,我一开始就是写的有参数形式,怎么搞都搞不对。。。

现在把它封装成一个工具函数,放到 npm 上,直接安装使用,为了兼顾在不配置 webpack 情况下以及低版本浏览器情况下,我们可以这样来做:

// all code
function HoverSD(element, secondsLaterDoFn, seconds, reNormalFn) {
  var timeout;
  var ele = element, secondsLDF = secondsLaterDoFn, secs = seconds, reNFn = reNormalFn;

  var rebuildTimer = function() {
    var context = this;
    var args = arguments;
    reNFn.apply(context, args);
    clearTimer();
    timeout = setTimeout(() => {
      secondsLDF.apply(context, args);
    }, secs);
  }
  
  var clearTimer = function() {
    timeout && clearTimeout(timeout);
  }

  this.secondsHoverEX = function() {
    ele.addEventListener('mousemove', rebuildTimer);
    ele.addEventListener('mouseleave', clearTimer);
  }
  
  this.removeElemEventListener = function() {
    ele.removeEventListener('mousemove', rebuildTimer);
    ele.removeEventListener('mouseleave', clearTimer);
  }
}

window.HoverSD = HoverSD;

将主要的核心函数 secondsHoverEX 和 removeElemEventListener 通过 this. 暴露出来,再将这个函数暴露到 window 全局,安装之后可以直接通过

let hoversd = new window.HoverSD(elem, fn1, 2000, fn2);
hoversd.secondsHoverEX();
// ...
// other code here
// ...
hoversd.removeElemEventListener();

即可完成使用。

本篇文章收录于我的 个人blog,后续会致力于推出越来越多文章以及开源工具,如有帮助,赏个 star,谢谢各位老爷了!

后记:

非常感谢大家能看到最后,如果有朋友想要提出意见,欢迎来 github/issue 给我提建议,也可在下方评论直接提出,我个人觉得这个工具函数适用的场景还是蛮多的,如果能帮助到你,欢迎来此项目下赏个 star 。我会在项目主页关于此工具的使用做更详细的介绍,欢迎访问:
github项目地址:https://github.com/vortesnail/hover-seconds-do

当然,厚着脸皮也希望大家能支持下我的基于 React 的开源播放器组件:https://github.com/vortesnail/qier-player
68747470733a2f2f69302e6864736c622e636f6d2f6266732f616c62756d2f646334363438326563343235656266373866383530316662343466303566386230316362646134622e706e67.png
下次再见,拜拜啦~

这一次,彻底理解https原理

我的github/blog,若star,无比感谢

建议电脑观看,图有点挤,手机屏幕太小可能看不清楚。

放轻松

作为一个前端er,你总会在学习或工作中,听到这样的声音:什么是https?你对https理解多少?说一说https吧?等此类问题,这也是在前端面试中比较容易被问到的问题,目的在于考究被面试者的知识广度,我相信你也不想在被问到的时候是以下表情:

此篇文章旨在帮助对于https完全没有了解的小小前端er建立起一个宏观的理解,此处的“宏观”并非草草了事,而是涉及到一些加密算法不予解析以及技术细节不予解读,总之这篇文章不需要多少的思考与理解能力,只需要认真地阅读,我相信你一定会理解https原理的,请放轻松。好了,让我们先来进入以下一个场景。

Michael和琪儿长期合作的伙伴关系,他们之间经常有密切的交易联系,经常会涉及一些金额的绝密信息需要通过互联网发送至对方,然而拥有技术的黑客Jack早已凯觎已久,总想着盗取点什么信息。

一天,Michael和琪儿仍然按着原来的方式来进行通信,所谓原来的方式,就是不对想要发送的信息做任何处理,直接在网络上传输:

这下,黑客Jack可乐坏了,他截获了Michael的消息,并把其中的卡号改成了自己的,给琪儿发了过去:

此时琪儿并不知道Michael发过来的信息以及被篡改过了,于是将资金打入了黑客自己的银行账号(上图中的8888),并且出于礼貌给了一个简单的回复,这条回复事实上也能被黑客Jack截获,但是他并不在意了,收到钱后的他扬长而去,直到Michael再次发消息给琪儿催款的时候,琪儿才知道自己被黑客攻击了。

我们将以上的各种关系与我们与服务端获取发送消息的关系对应一下:

Michael 琪儿 黑客Jack 原来的方式
客服端 服务端 中间人 http

解决办法之一:对称加密

这下坏了,Michael和琪儿已经不敢在如此的进行信息交换了,不然鬼知道还要送多少钱给黑客Jack,但是生意仍然要做啊,交易也不能停止,Michael不可能为了叫琪儿打个钱还得特意坐飞机去找琪儿,并当面告诉他卡号吧?(在此请不要问为什么不打电话,问就是为了场景需要,没有电话😊)

聪明的Michael想了一会儿说:“不如你设定一个规则,我给你发消息的时候,我用这个规则去对数据进行变换,让它不会被直接认出来我们说了什么,然后你收到之后你再用这个规则将变换后的信息转变回真正的信息。你现在先给我讲一下这个规则吧”,琪儿说:“那我们将每个中文字的拼音中的每个字母用1-26的数字代替,每个英语字母.....balablabala”(此处便是一个加密及解密的“算法”,也就是一个key,具体的算法有很多,不展开),Michael听闻后说:“这个规则(加密算法)非常nice,就这样做吧”。

以上演示的就是对称加密算法,加密和解密用的都是同一个key,且双方都互有。

不久后,琪儿发现这个方式有严重的漏洞:

  • 我的客户遍布全球,如果某黑客(比如Jack)冒充我的客户,获得了我的key,也就是我的加密算法,那他岂不是又可以像之前一样截获解密其他客户发给我的消息啦?
  • 就算我对每一个客户生成一个不同的key,且不说我的记忆存储成本有多高,那黑客要是在我发送key给客户的时候就截获了这个key,那他又可以肆无忌惮地解密了。

这该死的黑客把Michael和琪儿搞的很头大,不过Michael刚好认识一个搞密码学的朋友,一次闲聊中Michael从他那里得知了一种加密算法,称为非对称加密算法(RSA),这下Michael可乐坏了,立马告诉了琪儿。

解决办法之二:非对称加密

所谓非对称加密,其实原理很简单,之前琪儿只有一个公共的key,所有客户(其中也可以有黑客)都可以获取到,或者截获到,从而能解密其他人的消息。但是现在不一样了,琪儿有两个key,一个公钥(public key),所有人都可以获取得到;另一个是私钥(private key),只有琪儿一个人知道。

现在Michael发正式消息之前,先问琪儿索要公钥:

得到公钥之后,使用公钥去对要发送的正式消息进行加密,琪儿接收到之后,再用自己的私钥去解密(私钥只有琪儿有,只有她才能解开这个公钥加密的内容)

现在就算黑客Jack从中间截获了Michael的消息,由于他没有琪儿的私钥,他看到的也是一阵乱七八糟的字符,根本无从解密,黑客Jack存在感下降了许多。

反过来也是同样地,如果琪儿想要给Michael发消息,也得先索要Michael的公钥,琪儿得到后用其加密发送,Michael再用自己的私钥解密。为了便于理解,我们现在只讨论单向通信,默认双方都完成了同样建立通信的动作。

在双方用非对称加密通信了一段时间后,他们又发现了这个方式通信效率特别低(这是非对称加密算法的问题,不做深究),比之前的对称加密慢了100多倍,实在让人难以忍受,于是聪明的Michael将对称加密和非对称加密结合在一起,分两步走:“(1)琪儿先生成一个临时的对称加密的算法,也就是一个临时key,然后将该key以非对称加密(RSA)的方式发给Michael;(2)Michael安全拿到对称加密算法key之后,之后他们的通信就以对称加密方式进行”。

现在看起来解决了安全地传递对称密钥的问题,又解决了速度问题,简直妙!

在Michael为自己的聪明沾沾自喜时,黑客Jack表示这就是雕虫小计,Jack发起了恶名昭彰的中间人攻击

中间人攻击

根据以上的对称 + 非对称加密算法可知,Michael需要一开始问琪儿拿到公钥,因为黑客Jack也有自己事先设好的公钥和私钥,当他截获了Michael问琪儿索取公钥的消息,Jack就把自己的公钥发给了Michael,并假惺惺地把这条消息通过自己继续传给琪儿。

琪儿收到索取公钥的请求,将公钥发送回并附带了一条信息,黑客Jack截获了这个公钥之后,只将信息发给Michael,琪儿的公钥自己保留。

之后Michael就用Jack的公钥去加密自己想要发送给琪儿的数据信息,并被黑客Jack截获,Jack再用自己的私钥去解密,便能获取Michael的信息,而且可以随意篡改消息内容,用之前保留的琪儿的公钥去加密发送给琪儿,这一切可以用以下一张图解释:

是的,Michael和琪儿再一次被恶心到了,但是问题还是得解决啊,日子还得过,生意还得做啊。

图片 4.png

解决办法之三:第三方公证机构涉入

上面的核心问题是Michael无法确保自己拿到的公钥是琪儿的公钥,那可不可以建立一个公证处(CA),只要琪儿拿着自己的公钥去公证处开个证明,把一些琪儿的个人信息、开具的证明和琪儿的公钥包装成一个证书,凡是收到这个证书的客户,就能确认这是琪儿的公钥,从而再进行安全地传输。这就好像一个人去jc局开身份证一样,去开一个能证明自己身份的东西。

公证处(CA)有自己的私钥和公钥

可是一个无解的问题又冒出来了,Michael又怎么知道这个证书在传输过程中没有被黑客篡改呢??不用担心,数字签名帮助我们~

数字签名

琪儿现在准备去公证处(CA)开具证明,但是会出现上面我们想到的无解的问题,于是琪儿先把自己的公钥和个人信息以及一些其他必要的信息用一个 hash算法 生成一个 数字摘要 ,这个 hash算法 有很好的特性,只要信息内容有一点的更改,重新使用该算法生成的 数字摘要 内容将会产生巨变。

之后,公证处(CA)使用自己的私钥对该 数字摘要 进行加密,生成一个叫做 数字签名 的东东。

数字证书

再之后,把琪儿的原始信息和 数字签名 合并,形成一个全新的东西,叫做 数字证书 

这时候,Michael问琪儿索要公钥的时候,琪儿就把该颁发的证书发送给Michael

Michael收到该证书后,先用从证书内拿到的 hash算法 对琪儿的原始信息生成新的 数字摘要 ,再用公证处(CA)的公钥对 数字签名 进行解密,也生成一个 数字摘要 ,还记得我们说过的该hash算法的特性马?只要有一点内容变动,摘要就会产生巨大变动,所以如果琪儿的一些信息被篡改了,hash生成的摘要将会与CA公钥解密得到的摘要有巨大的不同,由此可判定传过来的公钥等其他信息是否被中途篡改过。

(我知道你想问CA的公钥是如何获取的,大家先暂时理解为存在了你的操作系统里,或者自定义添加的)

如果现在Michael终于确认了是琪儿的公钥后,他就可以高枕无忧的使用非对称加密算法先获取琪儿临时生成的对称加密算法key,进行安全通信了。

结语

非常感谢大家的认真阅读,文中大量图片都是本人为了帮助大家理解绘制,希望大家通过本篇文章的阅读,真的理解了https的原理,这也是我写此文的目的,若文笔有所不适之处,请尽可能见谅。另外,本文参考了许多文章,奈何写该篇文章的时候已经不记得参考过的文章了,也没有去重新找过,故没有放出参考文章,至此抱歉。

从零配置webpack 4+react脚手架(一)

从零配置webpack 4+react脚手架(一)

前言:

如果你和我一样学习了webpack相关的教程,并跟着webpack官方指南进行了一些简单的配置,但是不知道如何去使用它,那么这个系列的文章将通过搭建webpack+react脚手架给予你一定的配置经验,写这个系列的文章一是为了方便以后自己有配置需求时可以及时回顾,二是加强自己对于webpack的理解。我会尽可能详细地一步一步讲解这个脚手架配置步骤,也会对一些需要注意的点进行提醒,希望能帮助到大家~

前提:

  • 务必安装Node.js的最新版本,在控制台中输入 node -v 查看当前版本,若没有安装或不是最新版本,这里提供Node.js官网以便下载
  • 已经了解过webpck的相关配置,不然你跟着这篇文章操作并不能增加多少熟练度,当然了,你可以先关注一下,之后再来看,题外话,如果你是webpack老手了,请务必关闭这篇文章,你的时间非常值钱。
  • 当前文章的写作基于mac os环境下完成,若是windows,与我相同操作却得不到结果的,请Google
  • 保持耐心,我会将这篇文章分为许多小节,防止太多时间专注造成的疲劳,你可以休息一会儿看一节。

开始吧!

建一个空文件夹

让我们在桌面建一个空文件夹,名为 webpck-react-scaffold ,并使用你的编辑器打开它。
将此文件夹拖到终端,执行:

npm init -y

上面命令会在你的根目录生成 package.json 文件,该文件定义了这个项目所需要的各种模块,以及项目的配置信息(比如名称、版本、许可证等元数据)。npm install命令根据这个配置文件,自动下载所需的模块,也就是配置项目所需的运行和开发环境。

安装webpack

因为我们使用的是 webpack 4+ 版本,还需要安装 webpack-cli ,执行以下命令:

npm install --save-dev webpack webpack-cli

安装完成,你会发现在 package.josn 中多了一个 devDependencies 属性,这是因为我们安装依赖包时 --save-dev 的结果,这代表了开发时的依赖。之后我们会只用 --save 安装依赖包,这代表了运行时依赖。

我们确认一下,现在根目录下的文件结构如下:

  webpack-react-scaffold
  |- node_modules
  |- package.json

接下来,我们在根目录下新建一个文件夹名为 config 用于存放配置文件,在此文件夹下创建一个 .js 文件名为 webpack.common.config.js ,敲入以下代码:

const path = require('path');

module.exports = {
  entry: {
    app: './src/app.js',
  },
  output: {
    filename: 'js/bundle.js',
    path: path.resolve(__dirname, '../dist')
  }
}

webpack 配置是标准的 Node.js的CommonJS 模块,它通过require来引入其他模块,通过module.exports导出模块,由 webpack 根据对象定义的属性进行解析。

entry 属性定义了入口文件路径, output 定义了编译打包之后的文件名以及所在路径。
这段代码的意思是告诉webpack,入口文件是 src 目录下的 app.js 文件。打包输出的文件名字为 bundle.js ,保存在上一级目录下的 dist 文件夹中。 

我们创建一个文件夹名为 src ,在其中新建一个js文件名为 app.js ,现在我们的目录结构如下:

  webpack-react-scaffold
+ |- config
+     |- webpack.common.config.js
  |- node_modules
+ |- src
+     |- app.js
  |- package.json

那我们怎么打包呢?在 package.json 中配置如下属性:

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
+  "start": "webpack --config ./config/webpack.common.config.js"
  },

好了,我们试试怎么打包吧,虽然你的 app.js 中什么代码也没有。
在控制台中输入以下代码:

npm run start

想必你也看出来了,为什么是 “start”,--config选项来指定配置文件。
执行之后,你会发现根目录多出了一个文件夹: dist/js ,其中有一个js文件: bundle.js ,那么至此,我们已经成功编译打包了一个js文件,即入口文件: app.js 。

使用webpack-merge

我们将使用一个名为 webpack-merge 的工具。通过“通用”配置,我们不必在环境特定(environment-specific)的配置中重复代码。简单来说就是生产环境不同,我们要给的配置也有所不同,但是可以共用一个共有的配置。

我们先从安装 webpack-merge 开始:

npm install --save-dev webpack-merge

安装结束之后,我们在 config 这个文件夹下新建两个文件,分别为 webpack.prod.config.js 和 webpack.dev.config.js ,这两个文件分别对应生产和开发两个环境的配置。

现在的目录结构:

  webpack-react-scaffold
  |- config
     |- webpack.common.config.js
+    |- webpack.prod.config.js
+    |- webpack.dev.config.js
  |- node_modules
  |- src
     |- app.js
  |- package.json

webpack.prod.config.js 中输入以下代码:

const merge = require('webpack-merge');
const common = require('./webpack.common.config.js');

module.exports = merge(common, {
  mode: 'production',
});

回到我们之前创建的 app.js 文件,输入代码:

var root =document.getElementById('root');
root.innerHTML = 'hello, webpack!';

在根目录下创建一个文件夹名为: public ,再新建一个html文件,名为: index.html ,以下内容:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>从零配置webpack4+react脚手架</title>
</head>
<body>
  <div id="root"></div>
  <script src="../dist/js/bundle.js"></script>
</body>
</html>

现在的目录结构是这样子(只要不编译打包,要引入的 bundle.js 就没有):

  webpack-react-scaffold
   |- config
      |- webpack.common.config.js
      |- webpack.prod.config.js
      |- webpack.dev.config.js
   |- node_modules
+  |- public
+     |- index.html
   |- src
      |- app.js
   |- package.json

打包之前,我们修改 package.json :

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
-   "start": "webpack --config ./config/webpack.common.config.js",
+   "build": "webpack --config ./config/webpack.prod.config.js"
  },

好了,接下来我们编译打包试试吧!控制台执行以下代码:

npm run build

我们可以看到,webpack重新进行了编译,这和执行
webpack --config config/webpack.prod.conf.js 是一样的效果。
现在你可以打开用浏览器 public/index.html ,看看是不是有东西了~~

安装React

在控制台输入以下代码:

npm install --save react react-dom

安装完成之后,我们就可以写react的JSX语法了。

这里为了和react官方脚手架 create-react-app 的目录结构相类似,我们在 src 文件夹下新建一个js文件, index.js ,用于渲染根组件。

index.js 输入以下代码:

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

ReactDOM.render(<App />, document.getElementById('root'));

并用jsx语法重写 app.js :

import React from 'react';

function App() {
  return (
    <div className="App">Hello World</div>
  );
}

export default App;

webpack.common.config.js 文件中的入口进行修改,因为我们现在要编译打包的应该 index.js :

  const path = require('path');

  module.exports = {
    entry: {
-     app: './src/app.js',
+     index: './src/index.js',
    },
    output: {
      filename: 'js/bundle.js',
      path: path.resolve(__dirname, '../dist')
    }
  }

现在尝试一下重新运行 npm run build ,会发现打包失败了,为什么呢?接着看.....

使用babel 7

为什么我们上面写jsx会打包不了呢,因为webpack根本识别不了jsx语法,那怎么办?使用loader对文件进行预处理。
其中,babel-loader,就是这样一个预处理插件,它加载 ES2015+ 代码,然后使用 Babel 转译为 ES5。那开始配置它吧!

首先安装babel相关的模块:

npm install --save-dev babel-loader @babel/preset-react @babel/preset-env @babel/core
  • **babel-loader:**使用Babel和webpack来转译JavaScript文件。
  • **@babel/preset-react:**转译react的JSX
  • **@babel/preset-env:**转译ES2015+的语法
  • **@babel/core:**babel的核心模块

理论上我们可以直接在 webpack.common.config.js 中配置"options",但最好在当前根目录,注意,一定要是根目录!!! 新建一个配置文件 .babelrc 配置相关的"presets":

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          // 大于相关浏览器版本无需用到 preset-env
          "edge": 17,
          "firefox": 60,
          "chrome": 67,
          "safari": 11.1
        },
        // 根据代码逻辑中用到的 ES6+语法进行方法的导入,而不是全部导入
        "useBuiltIns": "usage"
      }
    ],
    "@babel/preset-react"
  ]
}

这里有关bebel的配置可上官网查询文档。

再修改 webpack.common.config.js ,添加如下代码:

const path = require('path');

module.exports = {
  entry: {
    index: './src/index.js',
  },
  output: {
    filename: 'js/bundle.js',
    path: path.resolve(__dirname, '../dist')
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        use: 'babel-loader',
        exclude: /node_modules/,
      }
    ]
  }
}

test 规定了作用于以规则中匹配到的后缀结尾的文件, use 即是使用 babel-loader 必须的属性, exclude 告诉我们不需要去转译"node_modules"这里面的文件。

接下来:

npm run build

是不是能打包成功了呢?回到你的html页面,看一下是否打印出了“hello webpack”吧!

我们再最后确认一下我们的目录:

  webpack-react-scaffold
   |- config
      |- webpack.common.config.js
      |- webpack.prod.config.js
      |- webpack.dev.config.js
   |- node_modules
   |- public
      |- index.html
   |- src
+     |- index.js
      |- app.js
+  |- .babelrc
   |- package.json

这一小节就到这里,你会发现有很多功能还是没有得以实现,比如自动生成html文件,实时刷新页面等,下一节开始我们会逐步优化我们的配置!

好想用Typescript+React hooks开发啊!(嘴对嘴解释)

本文直接灵感:终于搞懂 React Hooks了!!!!!

这里是我的 github/blog 地址,如有帮助,赏个 star~

看人家 Typescript 和 React hooks 耍的溜的飞起,好羡慕啊~🥺

那来吧,这篇爽文从脑壳到jio干地教你如何使用这两大利器开始闪亮开发!✨

image.png

课前预知

🌸我觉得比较好的学习方式就是跟着所讲的内容自行实现一遍,所以先启个项目呗~

npx create-react-app hook-ts-demo --template typescript

src/App.tsx 内引用我们的案例组件,在 src/example.tsx 写我们的案例组件。

🌸函数式组件的使用~
我们可以通过以下方式使用有类型约束的函数式组件:

import React from 'react'

type UserInfo = {
  name: string,
  age: number,
}

export const User = ({ name, age }: UserInfo) => {
  return (
    <div className="App">
      <p>{ name }</p>
      <p>{ age }</p>
    </div>
  )
}

const user = <User name='vortesnail' age={25} />

也可以通过以下方式使用有类型约束的函数式组件:

import React from 'react'

type UserInfo = {
  name: string,
  age: number,
}

export const User:React.FC<UserInfo> = ({ name, age }) => {
  return (
    <div className="User">
      <p>{ name }</p>
      <p>{ age }</p>
    </div>
  )
}

const user = <User name='vortesnail' age={25} />

上述代码中不同之处在于:

export const User = ({ name, age }: UserInfo)  => {}
export const User:React.FC<UserInfo> = ({ name, age }) => {}

使用函数式组件时需要将组件申明为React.FC类型,也就是 Functional Component 的意思,另外props需要申明各个参数的类型,然后通过泛型传递给React.FC

虽然两种方式都差不多,但我个人更喜欢使用 React.FC 的方式来创建我的有类型约束的函数式组件,它还支持 children 的传入,即使在我们的类型中并没有定义它:

export const User:React.FC<UserInfo> = ({ name, age, children }) => {
  return (
    <div className="User">
      <p>{ name }</p>
      <p>{ age }</p>
      <div>
        { children }
      </div>
    </div>
  )
}

const user = <User name='vortesnail' age={25}>I am children text!</User>

我们也并不需要把所有参数都显示地解构:

export const User:React.FC<UserInfo> = (props) => {
  return (
    <div className="User">
      <p>{ props.name }</p>
      <p>{ props.age }</p>
      <div>
        { /* 仍可以拿到 children */ }
        { props.children }
      </div>
    </div>
  )
}

const user = <User name='vortesnail' age={25}>I am children text!</User>

好了,我们暂时知道上面这么多,就可以开始使用我们的 hooks 了~

我将从三个点阐述如何结合 typescript 使用我们的 hooks :

  • 为啥使用❓
  • 怎么使用🛠
  • 场景例举📖

useState

为啥使用useState?

可以让函数式组件拥有状态管理特性,类似 class 组件中的 this.state 和 this.setState ,但是更加简洁,不用频繁的使用 this 。

怎么使用useState?

const [count, setCount] = useState<number>(0)

场景举例

1.参数为基本类型时的常规使用:
import React, { useState } from 'react'

const Counter:React.FC<{ initial: number }> = ({ initial = 0 }) => {
  const [count, setCount] = useState<number>(initial)

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count+1)}></button>
      <button onClick={() => setCount(count-1)}></button>
    </div>
  )
}

export default Counter
2.参数为对象类型时的使用:
import React, { useState } from 'react'

type ArticleInfo = {
  title: string,
  content: string
}

const Article:React.FC<ArticleInfo> = ({ title, content }) => {
  const [article, setArticle] = useState<ArticleInfo>({ title, content })

  return (
    <div>
      <p>Title: { article.title }</p>
      <section>{ article.content }</section>
      <button onClick={() => setArticle({
        title: '下一篇',
        content: '下一篇的内容',
      })}>
        下一篇
      </button>
    </div>
  )
}

export default Article

在我们的参数为对象类型时,需要特别注意的是, setXxx 并不会像 this.setState 合并旧的状态,它是完全替代了旧的状态,所以我们要实现合并,可以这样写(虽然我们以上例子不需要):

setArticle({
  title: '下一篇',
  content: '下一篇的内容',
  ...article
})

useEffect

为啥使用useEffect?

你可以把 useEffect 看做 componentDidMount , componentDidUpdate 和 componentWillUnmount 这三个函数的组合。

怎么使用useEffect?

useEffect(() => {
  ...
  return () => {...}
},[...])

场景举例

1.每当状态改变时,都要重新执行 useEffect 的逻辑:
import React, { useState, useEffect } from 'react'

let switchCount: number = 0

const User = () => {
  const [name, setName] = useState<string>('')
  useEffect(() => {
    switchCount += 1
  })

  return (
    <div>
      <p>Current Name: { name }</p>
      <p>switchCount: { switchCount }</p>
      <button onClick={() => setName('Jack')}>Jack</button>
      <button onClick={() => setName('Marry')}>Marry</button>
    </div>
  )
}

export default User
2.即使每次状态都改变,也只执行第一次 useEffect 的逻辑:
useEffect(() => {
  switchCount += 1
}, [])
3.根据某个状态是否变化来决定要不要重新执行:
const [value, setValue] = useState<string>('I never change')
useEffect(() => {
  switchCount += 1
}, [value])

因为 value 我们不会去任何地方改变它的值,所以在末尾加了 [value] 后, useEffect 内的逻辑也只会执行第一次,相当于在 class 组件中执行了 componentDidMount ,后续的 shouldComponentUpdate 返回全部是 false 。

4.组件卸载时处理一些内存问题,比如清除定时器、清除事件监听:
useEffect(() => {
  const handler = () => {
    document.title = Math.random().toString()
  }

  window.addEventListener('resize', handler)

  return () => {
    window.removeEventListener('resize', handler)
  }
}, [])

useRef

为啥使用useRef?

它不仅仅是用来管理 DOM ref 的,它还相当于 this , 可以存放任何变量,很好的解决闭包带来的不方便性。

怎么使用useRef?

const [count, setCount] = useState<number>(0)
const countRef = useRef<number>(count)

场景举例

1.闭包问题:

想想看,我们先点击  按钮 3 次,再点 弹框显示 1次,再点  按钮 2 次,最终 alert 会是什么结果?

import React, { useState, useEffect, useRef } from 'react'

const Counter = () => {
  const [count, setCount] = useState<number>(0)

  const handleCount = () => {
    setTimeout(() => {
      alert('current count: ' + count)
    }, 3000);
  }

  return (
    <div>
      <p>current count: { count }</p>
      <button onClick={() => setCount(count + 1)}></button>
      <button onClick={() => handleCount()}>弹框显示</button>
    </div>
  )
}

export default Counter

结果是弹框内容为 current count: 3 ,为什么?

当我们更新状态的时候, React 会重新渲染组件, 每一次渲染都会拿到独立的 count 状态,  并重新渲染一个  handleCount 函数.  每一个 handleCount 里面都有它自己的 count 。

**
那如何显示最新的当前 count 呢?

const Counter = () => {
  const [count, setCount] = useState<number>(0)
  const countRef = useRef<number>(count)

  useEffect(() => {
    countRef.current = count
  })

  const handleCount = () => {
    setTimeout(() => {
      alert('current count: ' + countRef.current)
    }, 3000);
  }

  //...
}

export default Counter
2.因为变更 .current 属性不会引发组件重新渲染,根据这个特性可以获取状态的前一个值:
const Counter = () => {
  const [count, setCount] = useState<number>(0)
  const preCountRef = useRef<number>(count)

  useEffect(() => {
    preCountRef.current = count
  })

  return (
    <div>
      <p>pre count: { preCountRef.current }</p>
      <p>current count: { count }</p>
      <button onClick={() => setCount(count + 1)}></button>
    </div>
  )
}

我们可以看到,显示的总是状态的前一个值:

image.png

3.操作 Dom 节点,类似 createRef():
import React, { useRef } from 'react'

const TextInput = () => {
  const inputEl = useRef<HTMLInputElement>(null)

  const onFocusClick = () => {
    if(inputEl && inputEl.current) {
      inputEl.current.focus()
    } 
  }

  return (
    <div>
      <input type="text" ref={inputEl}/>
      <button onClick={onFocusClick}>Focus the input</button>
    </div>
  )
}

export default TextInput

useMemo

为啥使用useMemo?

useEffect 可以知道,可以通过向其传递一些参数来影响某些函数的执行。 React 检查这些参数是否已更改,并且只有在存在差异的情况下才会执行此。

useMemo 做类似的事情,假设有大量方法,并且只想在其参数更改时运行它们,而不是每次组件更新时都运行它们,那就可以使用 useMemo 来进行性能优化。

记住,传入 useMemo 的函数会在渲染期间执行。请不要在这个函数内部执行与渲染无关的操作,诸如副作用这类的操作属于 useEffect 的适用范畴,而不是 useMemo 。

怎么使用useMemo?

function changeName(name) {
  return name + '给name做点操作返回新name'
}

const newName = useMemo(() => {
	return changeName(name)
}, [name])

场景举例

1.常规使用,避免重复执行没必要的方法:

我们先来看一个很简单的例子,以下是还未使用 useMemo 的代码:

import React, { useState, useMemo } from 'react'

// 父组件
const Example = () => {
  const [time, setTime] = useState<number>(0)
  const [random, setRandom] = useState<number>(0)

  return (
    <div>
      <button onClick={() => setTime(new Date().getTime())}>获取当前时间</button>
      <button onClick={() => setRandom(Math.random())}>获取当前随机数</button>
      <Show time={time}>{random}</Show>
    </div>
  )
}

type Data = {
  time: number
}

// 子组件
const Show:React.FC<Data> = ({ time, children }) => {
  function changeTime(time: number): string {
    console.log('changeTime excuted...')
    return new Date(time).toISOString()
  }

  return (
    <div>
      <p>Time is: { changeTime(time) }</p>
      <p>Random is: { children }</p>
    </div>
  )
}

export default Example

在这个例子中,无论你点击的是 获取当前时间 按钮还是 获取当前随机数 按钮, <Show /> 这个组件中的方法 changeTime 都会执行。

但事实上,点击 获取当前随机数 按钮改变的只会是 children 这个参数,但我们的 changeTime 也会因为子组件的重新渲染而重新执行,这个操作是很没必要的,消耗了无关的性能。

使用 useMemo 改造我们的 <Show /> 子组件:

const Show:React.FC<Data> = ({ time, children }) => {
  function changeTime(time: number): string {
    console.log('changeTime excuted...')
    return new Date(time).toISOString()
  }

  const newTime: string = useMemo(() => {
    return changeTime(time)
  }, [time])

  return (
    <div>
      <p>Time is: { newTime }</p>
      <p>Random is: { children }</p>
    </div>
  )
}

这个时候只有点击 获取当前时间 才会执行 changeTime 这个函数,而点击 获取当前随机数 已经不会触发该函数执行了。

2.你可能会好奇, useMemo 能做的难道不能用 useEffect 来做吗?

答案是否定的!如果你在子组件中加入以下代码:

const Show:React.FC<Data> = ({ time, children }) => {
	//...
  
  useEffect(() => {
    console.log('effect function here...')
  }, [time])

  const newTime: string = useMemo(() => {
    return changeTime(time)
  }, [time])
  
	//...
}

你会发现,控制台会打印如下信息:

> changeTime excuted...
> effect function here...

正如我们一开始说的:传入 useMemo 的函数会在渲染期间执行
在此不得不提 React.memo ,它的作用是实现整个组件的 Pure 功能:

const Show:React.FC<Data> = React.memo(({ time, children }) => {...}

所以简单用一句话来概括 useMemo 和 React.memo 的区别就是:前者在某些情况下不希望组件对所有 props 做浅比较,只想实现局部 Pure 功能,即只想对特定的 props 做比较,并决定是否局部更新。

useCallback

为啥使用useCallback?

useMemo 和 useCallback 接收的参数都是一样,都是在其依赖项发生变化后才执行,都是返回缓存的值,区别在于 useMemo 返回的是函数运行的结果, useCallback 返回的是函数。

useCallback(fn, deps) 相当于 useMemo(() => fn, deps)

怎么使用useCallback?

function changeName(name) {
  return name + '给name做点操作返回新name'
}

const getNewName = useMemo(() => {
  return changeName(name)
}, [name])

场景举例

将之前 useMemo 的例子,改一下子组件以下地方就OK了:

const Show:React.FC<Data> = ({ time, children }) => {
  //...
  const getNewTime = useCallback(() => {
    return changeTime(time)
  }, [time])

  return (
    <div>
      <p>Time is: { getNewTime() }</p>
      <p>Random is: { children }</p>
    </div>
  )
}

useReducer

为什么使用useReducer?

有没有想过你在某个组件里写了很多很多的 useState 是什么观感?比如以下:

const [name, setName] = useState<string>('')
const [islogin, setIsLogin] = useState<boolean>(false)
const [avatar, setAvatar] = useState<string>('')
const [age, setAge] = useState<number>(0)
//...

怎么使用useReducer?

import React, { useState, useReducer } from 'react'

type StateType = {
  count: number
}

type ActionType = {
  type: 'reset' | 'decrement' | 'increment'
}

const initialState = { count: 0 }

function reducer(state: StateType, action: ActionType) {
  switch (action.type) {
    case 'reset':
      return initialState
    case 'increment':
      return { count: state.count + 1 }
    case 'decrement':
      return { count: state.count - 1 }
    default:
      return state
  }
}

function Counter({ initialCount = 0}) {
  const [state, dispatch] = useReducer(reducer, { count: initialCount })

  return (
    <div>
      Count: {state.count}
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
    </div>
  )
}

export default Counter

场景举例:

useContext 结合代替 Redux 方案,往下阅读。

useContext

为啥使用useContext?

简单来说 Context 的作用就是对它所包含的组件树提供全局共享数据的一种技术。

怎么使用useContext?

export const ColorContext = React.createContext({ color: '#1890ff' })
const { color } = useContext(ColorContext)
// 或
export const ColorContext = React.createContext(null)
<ColorContext.Provider value='#1890ff'>
  <App />
</ColorContext.Provider>
// App 或以下的所有子组件都可拿到 value
const color = useContext(ColorContext) // '#1890ff'

场景举例

1.根组件注册,所有子组件都可拿到注册的值:
import React, { useContext } from 'react'

const ColorContext = React.createContext<string>('')

const App = () => {
  return (
    <ColorContext.Provider value='#1890ff'>
      <Father />
    </ColorContext.Provider>
  )
}

const Father = () => {
  return (
    <Child />
  )
}

const Child = () => {
  const color = useContext(ColorContext)
  return (
    <div style={{ backgroundColor: color }}>Background color is: { color }</div>
  )
}

export default App
2.配合 useReducer 实现 Redux 的代替方案:
import React, { useReducer, useContext } from 'react'

const UPDATE_COLOR = 'UPDATE_COLOR'

type StateType = {
  color: string
}

type ActionType = {
  type: string,
  color: string
}

type MixStateAndDispatch = {
  state: StateType,
  dispatch?: React.Dispatch<ActionType>
}

const reducer = (state: StateType, action: ActionType) => {
  switch(action.type) {
    case UPDATE_COLOR:
      return { color: action.color }
    default:
      return state  
  }
}

const ColorContext = React.createContext<MixStateAndDispatch>({
  state: { color: 'black' },
})

const Show = () => {
  const { state, dispatch } = useContext(ColorContext)
  return (
    <div style={{ color: state.color }}>
      当前字体颜色为: {state.color}
      <button onClick={() => dispatch && dispatch({type: UPDATE_COLOR, color: 'red'})}>红色</button>
      <button onClick={() => dispatch && dispatch({type: UPDATE_COLOR, color: 'green'})}>绿色</button>
    </div>
  )
}

const Example = ({ initialColor = '#000000' }) => {
  const [state, dispatch] = useReducer(reducer, { color: initialColor })
  return (
    <ColorContext.Provider value={{state, dispatch}}>
      <div>
        <Show />
        <button onClick={() => dispatch && dispatch({type: UPDATE_COLOR, color: 'blue'})}>蓝色</button>
        <button onClick={() => dispatch && dispatch({type: UPDATE_COLOR, color: 'lightblue'})}>轻绿色</button>
      </div>
    </ColorContext.Provider>
  )
}

export default Example

以上此方案是值得好好思索的,特别是因为 TypeScript 而导致的类型约束!
当然,如果有更好的解决方案,希望有大佬提出来,我也可以多学习学习~

结语

最近也是看了许多好文章,多谢各位掘金的大佬的无私奉献,本篇文章的灵感来源也是最近蛮火的一篇文章:

终于搞懂 React Hooks了!!!!!

这篇文章写的通俗易懂,但是没有涉及到在 Typescript 中的使用,且我在掘金上也搜不到类似的带入门的文章,故决定自己写一篇,希望能帮助到一些朋友,也能补足下自己的知识点。

参考文章:

TypeScript and React

终于搞懂 React Hooks了!!!!!

用 useContext + useReducer 替代 redux

React Hooks Tutorial on pure useReducer...

好东西不能独享,我在此强烈推荐一篇从零搭建 React + Typescript 开发环境的系列文章给大家,这是我看到过写的最清楚且优质的环境搭建文章,大家可以去看看,绝对收获满满:

从零开始配置 react + typescript(一):dotfiles

从零开始配置 react + typescript(二):linters 和 formatter

从零开始配置 react + typescript(三):webpack

Nest.js 入门与核心原理解析,从 IoC、DI、装饰器到元数据

Nest.js 入门与核心原理解析,从 IoC、DI、装饰器到元编程

使用 Node.js 开发服务端应用有很多方式,比如最简陋的使用内置 http 模块的 createServer api 即可快速创建一个服务。

如果是偏简单点的服务端应用还可以使用 Express 或 Koa 这种轻量封装的库。想要开发大型的、企业级别的服务端应用,那就得选择比如 Egg、Nest 或 Midway 这类企业级别的框架。

目前企业级框架中 Nest 是 Github 上 star 数量最多的,相当于把其它的按在地上打的差距,实际上手的开发体验也很棒。

上图统计时间截止到 2023.10.29

可以看到,Nest 的维护还是很稳定的,接下来先起个基本的应用,再去探索其写法的背后设计和实现原理。

快速创建一个 Nest 应用

Nest 提供了非常好用的 cli 工具,先安装一下:

npm i -g @nestjs/cli

然后使用 nest 命令生成一个本地项目:

nest new nest-demo-1

此命令作用很多,开发过程中经常要创建的 controllermoduleservice 等等都可以用该命令快速生成。

打开创建好的项目,可以看到以下目录结构:

基本的项目结构特别简单,对 src/ 目录下文件做一个简单的介绍:

  • app.controller.spec.ts :测试文件,写单测的地方;
  • app.controller.ts :控制器文件,负责处理传入的请求和向客户端返回响应;
  • app.module.ts :根模块文件,所有的子模块都要通过它来引入;
  • app.service.ts :提供功能服务的文件,负责数据的处理;
  • main.ts :入口文件。

打开 main.ts 看下入口文件的代码:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

代码很简单,就是引入根模块 AppModule 然后通过 NestFactory 这个对象创建一个应用程序,启动一个端口为 3000 的服务。

接着打开 app.module.ts 文件,该模块通过装饰器语法以及 controllersprovides 关键字段与 AppControllerAppService 建立了联系:

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

再探 app.controller.ts 文件:

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}

该文件定义了一个被 Controller 装饰器装饰的类并在构造函数中接收一个 AppService 服务,以及一个被 Get 装饰器装饰的 getHello 方法,该方法中调用了服务中的 getHello 方法,并将结果返回。

打开 app.service.ts 文件,可以看到就是一个通过 Injectable 装饰器装饰的类:

import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}

我们先不用具体去理解各个装饰器的作用和代码的组织方式,光从这几个文件来看,他的功能大致是这样的:定义了一个路由,该路由调用了服务里面的 getHello 方法,然后返回结果值 Hello World!

启动服务验证下:

yarn start:dev

启动成功后在 Apifox 中创建一个快捷请求,返回的结果与我们预想的一样:

实际开发中,我们只需要保留根模块文件 app.module.ts 文件,控制器文件 app.controller.ts 和服务文件 app.service.ts 都可以删除,因为我们一般会把其作为一个聚合各个子模块的模块,而不会去写业务功能。

提供增删改查接口

接下来我们写下简单的增删改查接口,让大家快速掌握 Nest 的基本写法,新建一个项目:

nest new nest-demo-2

假如我们现在要写一个用户模块,要求提供创建用户、删除用户、修改用户资料和查询所有用户的接口,这个用户模块就是我上面所说的子模块。

删掉我上面提到的控制器文件 app.controller.ts 和服务文件 app.service.ts 后,我们使用 nest 命令先创建一个子模块,将其放在 modules/ 目录下:

nest generate module modules/user

创建成功后,点开 app.module.ts 你会发现自动引入了一个 UserModule ,并且在 user.module.ts 中已经写入了基本的代码。这就是使用 nest 命令的好处之一,可以少写重复的代码,提高开发的效率和舒适度。

接着创建控制器,同样使用 nest 命令:

nest g controller modules/user --no-spec

generate 可以使用缩写 g,而 --no-spec 表示不生成测试文件。

控制器文件一般是用来处理请求和向客户端返回响应的,所以它不应该充斥大量的业务逻辑,一般我们会将业务逻辑写在 service 文件下,继续使用 nest 命令生成:

nest g service modules/user --no-spec

来到 user.module.ts 你会发现自动把我们生成的控制器和服务都自动引入了:

import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';

@Module({
  controllers: [UserController],
  providers: [UserService],
})
export class UserModule {}

OK,接下来我们在 main.ts 中定义一个全局变量 UsersStorage ,用来模拟存储工具(非持久化):

globalThis.UsersStorage = [];

后续我们的数据操作就基于该变量。先使用命令启动服务:

yarn start:dev

创建

接下来我们实现创建一个 User 的接口,在 user.controller.ts 文件中输入以下代码:

import { Controller, Body, Post } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post('/create')
  async createUser(@Body() userInfo: any) {
    const res = await this.userService.create(userInfo);
    return {
      code: 0,
      msg: '创建成功',
      data: res,
    };
  }
}

可以看到又出现了新的装饰器, @Post 代表请求方式是 post@Body 代表是从请求 body 中解析传入的参数,其背后的实现原理我们先不用管,先知道是这么用的就行。

上面代码还在构造函数 contructor 中定义了一个私有变量 userService ,但是我们没有初始化它, Nest 就自动给我们注入了 UserService ,并能使用该类中的方法,比如上面的 create 方法,这是怎么做到的呢?后面我们再细说。

user.service.ts 文件中代码如下:

import { Injectable } from '@nestjs/common';

@Injectable()
export class UserService {
  async create(userInfo: any) {
    globalThis.UsersStorage.push(userInfo);
    return userInfo;
  }
}

可以看到逻辑也是非常简单,往全局变量 UsersStorage 数组中往后添加了一个用户。

现在打开 Apifox,创建一个快速请求来验证我们上述的创建接口是否好用:

请求正常响应,且返回正确,那么一个简单的创建接口就完成了。

查询

查询接口也很简单,继续在 controller 中增加我们的方法:

import { Controller, Body, Get, Post } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  // ...

  @Get('/queryList')
  async getAllUsers() {
    const res = await this.userService.findAll();
    return {
      code: 0,
      msg: '请求成功',
      data: res,
    };
  }
}

service 中代码如下:

import { Injectable } from '@nestjs/common';

@Injectable()
export class UserService {
  // ...

  async findAll() {
    return globalThis.UsersStorage;
  }
}

删除

删除操作一般使用 delete 方式请求,需要用到装饰器 @Delete ,还要传入一个参数 name 字段,使用装饰器 @Param 解析,用于找到该名字后将其对应的用户信息对象删除,继续在 controller 中增加我们的方法:

import { Controller, Body, Param, Get, Post, Delete } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}
  
  // ...

  @Delete('/delete/:name')
  async removeGoods(@Param('name') name: string) {
    const res = await this.userService.delete(name);
    return {
      code: 0,
      msg: '删除成功',
      data: res,
    };
  }
}

service 中代码如下:

import { Injectable } from '@nestjs/common';

@Injectable()
export class UserService {
  // ...

  async delete(name: string) {
    const idx = globalThis.UsersStorage.findIndex((item) => item.name === name);
    globalThis.UsersStorage.splice(idx, 1);
    return globalThis.UsersStorage;
  }
}

修改

假设我们现在要修改某个用户的年龄大小,部分修改一般使用装饰器 @Patch ,对应请求方式为 patch ,传入参数 name 字段,以及需要修改为什么年龄的 age 字段,使用装饰器 @Body 解析请求参数,继续在 controller 中增加我们的方法:

import { Controller, Body, Param, Get, Post, Delete, Patch } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  // ...

  @Patch()
  async updateGoods(@Body() userInfo: any) {
    const res = await this.userService.update(userInfo);
    return {
      code: 0,
      msg: '修改成功',
      data: res,
    };
  }
}

service 中代码如下:

import { Injectable } from '@nestjs/common';

@Injectable()
export class UserService {
  // ...

  async update(userInfo: any) {
    const idx = globalThis.UsersStorage.findIndex(
      (item) => item.name === userInfo.name,
    );
    globalThis.UsersStorage[idx].age = userInfo.age;
    return globalThis.UsersStorage;
  }
}

OK,就这么多,上手 Nest 我们就先写到这么多,更多的内容我们可以慢慢探索,但是,如果你是一个初学者,其实你在书写上面的代码时,你一定是困惑的,你不明白各个装饰器的背后都做了什么,不明白为什么要使用 Module 来组织各个模块,不明白为什么能直接访问未被实例初始化的 service 等,接下来我们就来探索下其背后的实现原理。

IoC(控制反转)和 DI(依赖注入)

控制反转和依赖注入是 Nest 的核心设计**,我们先不讲概念,从一个例子逐步理解。

一个公司的例子

假如现在有一家软件公司,简化后的公司架构可以用如下代码表示:

// 产品经理
class ProductManager {
  generateRequirement() {
    console.log("A requirement has been generated.");
  }
}

// 程序员
class Programmer {
  completeRequirement() {
    console.log("A requirement has been completed.");
  }
}

// 主管
class Director {
  private productManager: ProductManager;
  private programmer: Programmer;

  constructor() {
    this.productManager = new ProductManager();
    this.programmer = new Programmer();
  }

  task() {
    this.productManager.generateRequirement();
    this.programmer.completeRequirement();
  }
}

// 公司
class Company {
  run() {
    const screwWorkshop = new Director();
    screwWorkshop.task();
  }
}

在这个简化的公司架构中,定义了两个负责实际生产的产品经理类和程序员类,以及依赖了这两个类的主管类,最后是经营的公司类。

可以看到,想要公司开始正常经营,公司层面让主管负责管理,主管要向程序员和产品经理下达命令,安排需求和完成需求,这样产品逐渐得到迭代,客户也越来越多。

依赖耦合

随着市场的逐渐饱和和竞争者赶上,公司经营出现困难,需要降本增效,这时公司决定开除工资高的程序员,重新招便宜的程序员。此时主管就必须找程序员谈话、开除、重新招人,非常琐碎又消耗心力,代码只体现重新招到的程序员代码变动,如下:

// 便宜程序员
class CheapProgrammer {
  completeRequirement() {
    console.log("A requirement has been completed.");
  }
}

// 主管
class Director {
  private productManager: ProductManager;
  private programmer: CheapProgrammer;

  constructor() {
    this.productManager = new ProductManager();
    this.programmer = new CheapProgrammer();
  }

  task() {
    this.productManager.generateRequirement();
    this.programmer.completeRequirement();
  }
}

虽然新招的便宜程序员也能完成需求,但是因为压力太大很快离职了,主管又要消耗大精力招人,在代码中体现为重新实例化一个例如 OtherProgrammer 类。

产品经理类 ProductManager 和程序员类 Programmer 都是产品迭代的实际执行者,属于底层类,它们都归属于高层类主管类 Director ,在这个公司中,高层类依赖了底层类,这样明显违反了“依赖倒置”原则,最直接的问题就是导致明明主管的工作是安排任务,让产生需求和完成需求顺利进行,但还需要去考虑员工的问题。

解耦

为了让主管类专注做他该做的事(主管类并不关心来的是什么程序员,只要是能完成需求的程序员就行,产品经理也同理),所以我们需要解耦这种关系,可以改造为:

// 定义一个产品经理接口
interface ProductManager {
  generateRequirement: () => void
}

// 定义一个程序员接口
interface Programmer {
  completeRequirement: () => void
}

// 贵产品经理
class ExpProductManager implements ProductManager {
  generateRequirement() {
    console.log("A requirement has been generated.");
  }
}

// 贵程序员
class ExpProgrammer implements Programmer {
  completeRequirement() {
    console.log("A requirement has been completed.");
  }
}

// 主管
class Director {
  private productManager: ProductManager;
  private programmer: Programmer;

  constructor(pm: ProductManager, programmer: Programmer) {
    this.productManager = pm;
    this.programmer = programmer;
  }

  task() {
    this.productManager.generateRequirement();
    this.programmer.completeRequirement();
  }
}

// 公司
class Company {
  run() {
    // 控制反转
    const expProductManager: ExpProductManager = new ExpProductManager()
    // const cheapProductManager: CheapProductManager = new CheapProductManager()
    const expProgrammer: ExpProgrammer = new ExpProgrammer()
    // const cheapProgrammer: CheapProgrammer = new CheapProgrammer()

    const screwWorkshop = new Director(expProductManager, expProgrammer);
    screwWorkshop.task();
  }
}

现在,我们对产品经理类和程序员类这种底层类分别定义了一个接口,每个产品经理类和程序员类都要遵从接口实现,也就是产品经理类必须实现了 generateRequirement 方法,程序员类必须实现了 completeRequirement 方法。

然后对产品经理类和程序员类的实例化操作我们不再在主管类中去做,而是放到了该类的外部(公司层面)去做,构造函数直接接收实例化后的类,这些类都实现了对应的方法。

这样就彻底解放了主管类,他不需要再关心来的是什么人,只要能完成任务,保证需求正常迭代即可。

概念对照

在上面的改造过程中,其实我们已经在接触 IoC 和 DI 了,但可能还不是很清楚怎么将代码与概念结合起来理解。

首先看下 IoC 和 DI 的概念:

  • 控制反转:是面向对象编程中的一种设计原则,可以用来减低计算机代码之间的耦合度。控制是指在一个类中完成了其依赖的对象的创建和绑定,反转是指将这种控制权移交给了 IoC 容器。
  • 依赖注入:控制反转只是一种**,而没有具体告诉我们该怎么做。依赖注入就是实现控制反转的一种方式,它允许在类之外创建依赖的对象,并通某些方式将这些对象提供给类。

总结就是,使用“依赖注入”的手段,我们能够将类所依赖对象的创建和绑定移动到类自身的实现之外,实现了“控制反转”的效果,以便符合“依赖倒置”原则。

用一张图表现下上面的改造:

那么改造的代码与概念对比起来理解话,大概如下:

  • 控制反转:在 Director 类中需要使用到的 productManager/programmer 实例,被放到了 Company 类中实例化后传入,Director 对依赖项 ExpProductManager/ExpProgrammer 的控制被反转了;
  • 依赖注入:在 Director 类中不关注具体 productManager/programmer 实例的创建,而是通过构造函数 constructor 注入;

最后,我们将 Nest 中的各个模块与我们的公司进行一个类比:

  • Provider 👉 ExpProductManager/ExpProgrammer:实现具体功能的底层类。
  • Controller 👉 Director:调用底层类的高层类。
  • NestFactory 👉 Company:IoC 容器,控制相关类实例的新建与注入,解藕各个类相互间的依赖。

Decorator(装饰器)

在本文一开始例举的增删改查示例中,我们看到了 ControllerInjectableBodyGet 等装饰器,事实上在 Nest 的应用中,大量用到了各种各样的装饰器,所以我们有必要了解下 TypeScript 中装饰器的语法。

由于经过自己测试,当前最新的 Nest 10.x 版本还是使用 TypeScript 装饰器的旧语法,所以我们下面讲解也是基于旧语法,推荐学习阮一峰的 TypeScript 教程 - 装饰器(旧语法)

装饰器是一种语法结构,写法为 @Decorator,这个结构中有两个部分:

  • @:标识后面的函数作为装饰器使用;
  • Decorator:编译阶段执行的一个函数。

比如下面的简单示例:

function logName(target: any) {
  console.log(`This is class ${target.name}`);
  return target;
}

@logName
class Programmer {} // "This is class Programmer"

我们定义了一个函数 logName,接收一个参数,打印一条信息,将原参数返回。上面作为装饰器使用,本质上类似以下调用过程:

function logName(target: any) {
  console.log(`This is class ${target.name}`);
  return target;
}

class Programmer {} 

logName(Programmer) // "This is class Programmer"

上面我们只是打印了一条信息,但是我们还能做更多,比如给类的每个实例添加一个属性并赋值,另外,我们可以补足下 target 类型,它本质上是类的构造函数:

type Constructor = new (...args: any[]) => any;

function addFate<T extends Constructor>(target: T) {
  return class extends target {
    constructor(...args: any[]) {
      super(...args);
      this.fate = 'chives';
    }
  }
}

@addFate
class Programmer {
  [x: string]: any

  print() {
    console.log(`Most of programmers are ${this.fate}`)
  }
}

const p = new Programmer()
p.print() // Most of programmers are chives

除了类装饰器,还有方法装饰器、属性装饰器、存取器装饰器、和参数装饰器。受篇幅限制,我写了一个例子,用以介绍它们的大概作用。

有一个程序员类 Programmer ,我们要从这个类 new 一些程序员来干活儿,对他的要求有以下这些:

  • 这个人的背景是有房贷;
  • 年龄小于 35 岁;
  • 每天早上工作最低 3 小时,中午后工作最低 7 个小时;
  • 上报每天的工作时长。

首先用一个装饰器来给这个类的原型对象添加背景:

type Constructor = new (...args: any[]) => any;

// 类装饰器
function addBackground<T extends Constructor>(target: T) {
  target.prototype.background = "mortgage slave";
  return target;
}

再用一个装饰器来判断类的属性(这里我们指年龄 age)值是否大于特定的值,如果大于就抛错:

// 属性装饰器
function validateAge(max: number) {
  return (target: Object, propertyKey: string) => {
    Object.defineProperty(target, propertyKey, {
      set: function (v: number) {
        if (v > max) {
          throw new Error(`${v} is too old!`);
        }
      },
    });
  };
}

接下来的装饰器用于打印总工时:

// 方法装饰器
function logWorkTime(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;

  descriptor.value = function (...args: any[]) {
    const result = original.call(this, ...args);
    console.log(`Already worked ${result} hours`);
    return result;
  };
}

最后我们的类长这样:

@addBackground
class Programmer {
  @validateAge(35)
  age!: number;

  @logWorkTime
  work(morningTime: number, afternoonTime: number) {
    return morningTime + afternoonTime;
  }
}

const programmer = new Programmer();
const a = programmer.work(3, 7); // Already worked 10 hours
programmer.age = 36; // throw error

上面给执行实例方法 programmer.work(3, 7) 时会打印已经工作的时长,给属性赋值 programmer.age = 36 时会抛出错误,因为被 validateAge 装饰后,对该属性的赋值有所限制。

但是我们对照下要实现功能,少了一个校验 “每天早上工作最低 3 小时,中午后工作最低 7 个小时”,你可能会想通过参数装饰器去实现,但是很遗憾,参数装饰器功能很有限,其函数定义如下:

type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;
  • target:(对于静态方法)类的构造函数,或者(对于类的实例方法)类的原型对象。
  • propertyKey:所装饰的方法的名字,类型为 string | symbol
  • parameterIndex:当前参数在方法的参数序列的位置(从 0 开始)。

从上面的定义来看,我们只能拿到方法名和当前装饰的参数的位置,而对参数本身做不了任何事,更别提校验参数了。

这就要引出我们下一个章节讲讲元编程了,学习完就可以实现我们的参数校验功能!

Metaprogramming (元编程)

按照维基百科的定义

Metaprogramming is a programming technique in which computer programs have the ability to treat other programs as their data. It means that a program can be designed to read, generate, analyze or transform other programs, and even modify itself while running.

元编程是一种编程技术,允许计算机程序将其它程序作为其数据。这意味着一个程序可以读取、生成、分析、转换其它的程序,甚至是在运行时修改自身。

概念是比较抽象的,简短总结:将程序作为数据,然后对其维护。

上面的程序维护在不同上下文中,有不同的含义。比如我们写的 TypeScript 程序通过编译器转为了 JavaScript,这里的程序就是指 TypeScript,维护就是指编译过程,这是在编译阶段的元编程。

在运行阶段,程序就指正在执行的代码逻辑行为,比如创建对象、函数、类等实体,而这些实体可能非常复杂,维护就是我们对这些实体的读取、分析或修改等行为。而 ES6 的 ProxyReflect 让元编程变得更加方便。

举个简单的例子:

const programmer = {
  name: "vortesnail",
  age: 28,
};

const proxy = new Proxy(programmer, {
  set: function (target, propKey, value, receiver) {
    if (propKey === "age" && value > 28) {
      throw new Error("property name must be less than or equal to 28.");
    }
    return Reflect.set(target, propKey, value, receiver);
  },
});

proxy.age = 30;

这段代码中,程序就是指 programmer 这个实体本身以及其任何行为,而维护就是我们代理了这个实体,并将它的 name 属性赋值规定为小于等于 28,不然就会抛错。换句话说,我们扩展了这个实体的能力,对赋值做了校验。

在我看来,我们在上一节举的例子中,实现的那些装饰器都在元编程的范围。

现在再看我们未实现的“每天早上工作最低 3 小时,中午后工作最低 7 个小时”参数校验功能,我们可以按照以下代码进行实现:

// 其它代码不变

// 方法装饰器
function logWorkTime(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;

  descriptor.value = function (...args: any[]) {
    // 在方法装饰器中对参数做校验 - START
    const proptype = target as any;
    proptype.paramsCollector = Reflect.get(proptype, "paramsCollector") || {};
    for (let parameterIndex = 0; parameterIndex < args.length; parameterIndex++) {
      let minNum: number[] = Reflect.get(proptype.paramsCollector, `${propertyKey}_${parameterIndex}`);
      if (args[parameterIndex] < minNum) {
        throw new Error(`Working hours are less than ${minNum} hours`);
      }
    }
    // 在方法装饰器中对参数做校验 - END

    const result = original.call(this, ...args);
    console.log(`Already worked ${result} hours`);
    return result;
  };
}

// 参数装饰器
function minimum(num: number) {
  return (target: Object, propertyKey: string, parameterIndex: number) => {
    const proptype = target as any;
    proptype.paramsCollector = Reflect.get(proptype, "paramsCollector") || {};
    Reflect.set(proptype.paramsCollector, `${propertyKey}_${parameterIndex}`, num);
  };
}

@addBackground
class Programmer {
  // ...

  @logWorkTime
  work(@minimum(3) morningTime: number, @minimum(7) afternoonTime: number) {
    return morningTime + afternoonTime;
  }
}

const programmer = new Programmer();

上面代码中,我们定义了一个参数装饰器 minimum 用于在原型对象上新增一个属性 paramsCollector,它的作用为存储传入的值,也就是我们要求的最低工时,结构如下:

{
  work_0: 3,
  work_1: 7
}

接着还改造了方法装饰器 logWorkTime,它主要是从原型对象上把 paramsCollector 对象下的参数校验最小值取出来,接着遍历我们的参数,就可以对当前传入的参数值进行比较了,不符合要求的直接抛错。

现在如果我们执行 work 方法时,传入的参数不符合要求就会报错:

programmer.work(2, 6); // throw error

上面代码虽然实现了我们想要的参数校验功能,但是有一个问题就是,我们“污染”了原型对象,因为我们给它新增了一个属性 paramsCollector,这是不利于维护的,也不优雅,那么我们该怎么办呢?那就要讲讲接下来的元数据了。

关于元编程,推荐再仔细阅读下这两篇文章,帮助理解:
JavaScript 元编程简介
JavaScript 元编程

Metadata(元数据)

元数据表示的含义为数据的额外数据,比如下面的一个简单对象:

const p = {
  name: 'vortesnail'
};

这个对象本身是一个数据,对象中 name 字段的值是否可写、是否可遍历都是该字段的元数据,本质就是对数据的一种描述,而这种描述能够被读写的。我们要理解这句话,可以将上述对象的 name 字段设置为不可修改:

const p = {
  name: "vortesnail",
};

let descriptor1 = Object.getOwnPropertyDescriptor(p, "name");

console.log(descriptor1); // { value: 'vortesnail', writable: true, enumerable: true, configurable: true }

Object.defineProperty(p, "name", {
  writable: false,
});

let descriptor2 = Object.getOwnPropertyDescriptor(p, "name");

console.log(descriptor2); // { value: 'vortesnail', writable: false, enumerable: true, configurable: true }

descriptor1 会打印出 writable: true,表示我们可写;接着通过 Object.definePropertyname 字段修改为不可写,descriptor2 打印出 writable: false,这时候 p.name = "other" 将会报错。

于是,我们就可以将属性描述符作为这个对象数据的元数据来理解。

但是现在有个问题,我们目前依靠 JavsScript 的语法无法给数据添加自定义的元数据,幸运的是,现在有一个提案,通过扩展 Reflect 的功能,允许向对象和对象属性添加自定义元数据。

reflect-metadata

这是一个 npm 包,相当于一个 Reflect 的附加 polyfill 方案,扩展了 Reflect 的能力,要使用的话需要先安装下:

yarn add reflect-metadata

还需要把 tsconfig.json 中的以下配置项打开:

{
  "compilerOptions": {
    "emitDecoratorMetadata": true,
  },
}

以下是扩展后的读写元数据方法的使用方式:

// 给对象定义一个元数据
Reflect.defineMetadata(metadataKey, metadataValue, target);

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

// 获取对象上的元数据
const result = Reflect.getMetadata(metadataKey, target);
// 获取对象属性上的元数据
const result = Reflect.getMetadata(metadataKey, target, propertyKey);

可以看到,和使用 JavaScript 对象一样,元数据也就是一个键值对而已。以下是个简单例子:

import "reflect-metadata";

const p = {
  name: "vortesnail",
};

Reflect.defineMetadata("age", 18, p);
Reflect.defineMetadata("is", "string", p, "name");

// 对象 p 打印结果
console.log("p ->", p); // p -> { name: 'vortesnail' }

// 对象 p 上的元数据
console.log("p(age) ->", Reflect.getMetadata("age", p)); // p(age) -> 18
// 对象 p 的 name 属性上的元数据
console.log("p.name(is) ->", Reflect.getMetadata("is", p, "name")); // p.name(is) -> string

从打印日志可以看出来,向对象 p 或其属性注册元数据并不会改变这个对象的原始结构。

事实上,元数据应该被保存在 [[Metadata]] 内部插槽。

现在我们可以重新优化上面参数校验的功能了,只需要将 Reflect.getReflect.set 的用法替换为 Reflect.getMetadataReflect.defineMetadata 用法即可:

// 其它代码不变

// 方法装饰器
function logWorkTime(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;

  descriptor.value = function (...args: any[]) {
    const paramsCollector = Reflect.getOwnMetadata("paramsCollector", target) || {};
    for (let parameterIndex = 0; parameterIndex < args.length; parameterIndex++) {
      let minNum: number[] = paramsCollector[`${propertyKey}_${parameterIndex}`];
      if (args[parameterIndex] < minNum) {
        throw new Error(`Working hours are less than ${minNum} hours`);
      }
    }

    const result = original.call(this, ...args);
    console.log(`Already worked ${result} hours`);
    return result;
  };
}

// 参数装饰器
function minimum(num: number) {
  return (target: Object, propertyKey: string, parameterIndex: number) => {
    let paramsCollector = Reflect.getOwnMetadata("paramsCollector", target) || {};
    paramsCollector = { ...paramsCollector, [`${propertyKey}_${parameterIndex}`]: num };
    Reflect.defineMetadata("paramsCollector", paramsCollector, target);
  };
}

// 其它代码不变

上述代码使用的是 Reflect.getOwnMetadata 而不是 Reflect.getMetadata,他可以避免从原型链上去查找元数据。

现在我们就真正做到了对方法参数的校验,同时还不污染类的原型。

写一个 Toy Nest

经过前面设计**和相关语法的学习后,是时候写一个 Toy Nest 来练练手了。

Nest 初始项目

在本文一开始,我们通过官方脚手架 @nestjs/cli 创建了一个名字为 nest-demo-1 的初始化项目,大家可以翻到前面这一小节再看看,初始化的项目特别简单。

为了更便于大家阅读,也为了更好理解 Nest 的实现,我对初始化项目做些改造,第一是将所有模块都放到 main.ts 文件中;第二是给 ControllerGet 装饰器都传入了参数。

使用以下命令创建项目:

nest new nest-demo-3

改造后文件结构如下:

main.ts 代码如下:

import { NestFactory } from '@nestjs/core';
import { Module, Controller, Get, Injectable } from '@nestjs/common';

// 原来的 .service.ts
@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}

// 原来的 .controller.ts
@Controller('/app')
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('/hello')
  getHello(): string {
    return this.appService.getHello();
  }
}

// 原来的 .module.ts
@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

// main
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

在控制台执行命令启动服务:

yarn start:dev

接着使用 Apifox 测试下该路由,正常返回了结果。

我们实现的 Toy Nest 起码要先保证和这个初始化项目能做到的事情一致,所以我们的思路是,先将这个初始化项目中使用到的装饰器和方法一一实现,最后将 Nest 相关的实现替换为我们自己的实现,正常情况下,会得到一致的运行结果。

代码功能分析

在上面的初始项目中,我们看看都用到了 Nest 中什么功能:

  1. 底层服务类 AppService 要在 AppController 的构造函数中被注入,使用装饰器 @Injectable() 标记为一个 Provider
  2. 调用底层服务的 AppController 被装饰器 @Controller('/app') 标记为一个 Controller,并且路由访问的根路径为 /app,装饰器 @Get('/hello') 标记 getHello 方法的访问方式是 get,并且相对路径为 /hello
  3. 装饰器 @Module 标记 AppModule 是一个 Module,作用为将上述 ControllerProvider 进行关联。
  4. 调用 NestFactory.create 方法,参数传入 AppModule 构造一个服务实例,启动并监听 3000 端口。

接下来,我们围绕上面用到的所有装饰器和方法,一步步实现它们。

实现各个装饰器

前面我们提到过,IoC(控制反转)和 DI(依赖注入)是 Nest 设计的核心**,而实现的主要手段就是通过装饰器元数据,大家在接下来的实现过程中,跟着我逐步理解到底其中的奥秘。

篇幅限制,我不会把所有代码都放到本文中,若需要参考,可点击该项目地址进行查看:toy-nest

Injectable

新建 src/common/injectable.ts 文件,写入以下代码:

import { INJECTABLE_WATERMARK } from '../const';
import type { ClassDecorator } from '../types';

function Injectable(): ClassDecorator {
  return (target) => {
    Reflect.defineMetadata(INJECTABLE_WATERMARK, true, target);
  };
}

export default Injectable;

Injectable 方法的代码特别简单,它的作用是返回一个装饰器,将装饰的类标记为可注入的,回顾示例项目中,appService 没有在构造函数中初始化,但是我们却可以直接使用,就是因为我们标记了该类,后续会在 IoC 容器中进行注入。

类装饰器可以没有返回值,如果有返回值,就会替代所装饰的类的构造函数。

Controller

新建 src/common/controller.ts 文件,写入以下代码:

import { PATH_METADATA } from '../const';
import type { ClassDecorator } from '../types';

function Controller(path?: string): ClassDecorator {
  const defaultPath = '/';

  return (target) => {
    Reflect.defineMetadata(PATH_METADATA, path || defaultPath, target);
  };
}

export default Controller;

Controller 方法接收参数 path,并将其值存入元数据,该值的含义是请求的根路径,在示例项目中,该值指 /app

Get

新建 src/common/request.ts 文件,写入以下代码:

import { PATH_METADATA, METHOD_METADATA } from '../const';
import type { MethodDecorator } from '../types';

function RequestMapping(method?: string) {
  return (path?: string): MethodDecorator => {
    const reqPath = path || '/';
    const reqMethod = method || 'Get';

    return (target, propertyKey, descriptor) => {
      Reflect.defineMetadata(METHOD_METADATA, reqMethod, descriptor.value);
      Reflect.defineMetadata(PATH_METADATA, reqPath, descriptor.value);
    };
  };
}

export const Get = RequestMapping('Get');
export const Post = RequestMapping('Post');

Get 还是 PostPatchDelete 等 http 方法都是由 RequestMapping 这个高阶方法执行得来的,返回后的方法接收一个 path 参数,表示请求的相对路径。

最内层返回的方法即是我们的装饰器方法,该装饰器主要是给当前装饰的方法添加了两个元数据,分别表示请求方式和相对路径。在示例项目中,请求方式表示 Get,相对路径表示 /hello

Module

新建 src/common/module.ts 文件,写入以下代码:

import type { ClassDecorator } from '../types';

export function Module(metadata: Record<string, any[]>): ClassDecorator {
  return (target) => {
    for (const property in metadata) {
      if (metadata.hasOwnProperty(property)) {
        Reflect.defineMetadata(property, metadata[property], target);
      }
    }
  };
}

Module 方法接收参数 metadata,该参数是一个对象,方法执行后返回一个类装饰器方法,将对象中的键值对作为元数据存到了所装饰的类上。

在示例项目中即把 controllers: [AppController]providers: [AppService] 存起来,其中 controllers 后续启动应用时会用到。

实现 ToyNestFactory

上面实现的所有装饰器从代码上来看都只做了一件事情,就是保存元数据,既然保存了,肯定就有要用到的地方。在上面我们一直在说 IoC 容器注入依赖,这个容器就是我们的 ToyNestFactory 类(在 Nest 中名字叫 NestFactory)。

另外,在示例项目中,是这样启动服务的:

const app = await NestFactory.create(AppModule);
await app.listen(3000);

这意味着 NestFactory.create 方法返回了一个能够监听端口的 http 服务,而 Nest 官方底层使用的提供 http 服务的框架是 express,我们有必要先了解下怎么利用 express 启动一个最基本的 http 服务。

首先安装 express:

yarn add express

随便建个测试文件,比如我的 src/test-express.ts,写入以下代码:

import express from 'express';

const app = express();

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

app.listen(3000);

使用 ts-node 执行该文件后,服务被启动,使用 Apifox 请求 http://localhost:3000/hello 会看到返回了 Hello World!,这就是最基本的用法。

知道了上面的用法,我们可以先初步写个 ToyNestFactoryStatic 类的雏形,新建 src/core/index.ts 文件,写入以下代码:

import 'reflect-metadata';
import express from 'express';
import type { Express } from 'express';

class ToyNestFactoryStatic {
  private readonly app: Express;

  constructor() {
    this.app = express();
  }

  create(): Express {
    return this.app;
  }
}

export const ToyNestFactory = new ToyNestFactoryStatic();

导出的 ToyNestFactory 是经过 ToyNestFactoryStatic 实例化后得到的一个实例,所以我们可以直接调用 ToyNestFactory.create 方法。

create

之前我们存的元数据信息里,有标记为需要被注入的类的信息(布尔值)、请求的路径信息和请求方法,我们在 create 方法中要做的事由一张图说明就是:

根据上图思路,完善我们的 create 方法:

class ToyNestFactoryStatic {
  // 其他代码...

  create(module: any): Express {
    const Controllers = Reflect.getMetadata('controllers', module);
    this.initialize(Controllers);

    return this.app;
  }

  initialize(Controllers: any[]) {}
}

因为 @Module 装饰器已经往 AppModule 类上注入了控制器的元数据,所以现在我们可以直接从它上面取出来所有 Controller,示例中其实就只有一个 AppController

然后新建了一个 initialize 方法,用于遍历 Controllers 并做上图的后续处理。

initialize

我们前面分析到,需要拿到每个 controller 的构造函数中参数,即 service 类,进行实例化后再进行注入。

比如示例项目的 AppController 依赖了 AppService,而这部分并没有存储到元数据中。但是 TypeScript 有一个优势,可以在编译时自动添加一些元数据。举个例子:

import 'reflect-metadata';

function addBackground(target: any) {
  target.prototype.background = 'mortgage slave';
  return target;
}

class Computer {}

@addBackground
class Programmer {
  constructor(private readonly computer: Computer) {
    console.log(this.computer);
  }
}

上面这段代码,在 tsconfig.json 把编译选项 emitDecoratorMetadata 设置为 true 后,使用 tsc 编译后的代码,可以看到将构造函数中的参数类以 design:paramtypes 为键的元数据注入到了 Programmer 类上。

接下来不就好办了,直接取出来参数组成的数组,遍历分别实例化:

initialize(Controllers: any[]) {
  Controllers.forEach((Controller) => {
    // guide 1
    const Services: any[] = Reflect.getMetadata('design:paramtypes', Controller);

    const services = Services.map((Service) => {
      // guide 2
      if (!Reflect.getMetadata(INJECTABLE_WATERMARK, Service)) {
        throw new Error(
          `${Service.name} is not injectable, check if it is decorated with @Injectable.`
        );
      }

      // guide 3
      const instance = new Service();
      return instance;
    });

    // guide 4
    const controller = new Controller(...services);

    // guide 5
    const rootPath = Reflect.getMetadata(PATH_METADATA, Controller);

    // guide 6
    this.createRoute(controller, rootPath);
  });
}

对上面代码解释下:

  1. 得益于 TypeScript 编译时会自动给类添加构造函数中的参数类型元数据,我们可以轻易取到服务类,示例中是 [AppService]
  2. 如果存在未被装饰器 @Injectable 装饰的服务类被控制器使用,直接抛错;
  3. 实例化服务类;
  4. 将服务类的实例注入给我们的控制器,从而实现了依赖注入
  5. 获取装饰器 @Controller 传入的根路径,示例项目中即 /app
  6. 注册路由信息。

这个方法的最后,我们拿到了每个控制器的实例,以及根路由信息,接下来就要在 createRoute 方法中注册路由信息,包括请求方式(比如 Get),完整路由(比如 /app/hello)和回调方法(比如 getHello)。

createRoute

createRoute(controller: any, rootPath: string) {
  // guide 1
  const prototype = Reflect.getPrototypeOf(controller) as any;
  const allMethodNames = Reflect.ownKeys(prototype).filter((name) => name !== 'constructor');

  allMethodNames.forEach((methodName) => {
    const fn = prototype[methodName];

    // guide 2
    const method = Reflect.getMetadata(METHOD_METADATA, fn);
    const path = Reflect.getMetadata(PATH_METADATA, fn);

    // guide 3
    if (!method || !path) {
      return;
    }

    const completePath = rootPath + path;
    const lowerMethod = method.toLowerCase() as 'get' | 'post';
    const bindFn = fn.bind(controller);

    // guide 4
    this.app[lowerMethod](completePath, (req: any, res: any) => {
      res.send(bindFn(req));
    });
  });
}

对上面代码解释下:

  1. 在 JavaScript 中类中定义的方法可在实例的原型上找到,通过 Reflect 的 APIs 即可获取到所有方法名;
  2. 我们通过 @Get 装饰器已经往被装饰的方法上添加了请求方法和相对路径元数据,现在可以从它上面取出;
  3. 如果没被装饰器装饰的方法忽略;
  4. 往 express 上注册路由及执行回调方法,发送我们在控制器中实际执行的方法。

测试 Toy Nest

测试我们写完的代码很简单,把 Nest 相关方法替换掉就行,其他代码不用变:

原来的代码:

import { NestFactory } from '@nestjs/core';
import { Module, Controller, Get, Injectable } from '@nestjs/common';

替换后的代码:

import { ToyNestFactory } from './core';
import { Module, Controller,  Get, Injectable } from './common';

// 其他代码不变

启动服务后,通过 ApiFox 访问 http://localhost:3000/app/hello 返回了和使用 Nest 一样的结果。

如何启动一个 TypeScript 的 node 项目,大家可以参考我的源码看看:toy-nest

结语

希望读者在阅读完本文后,对 Nest 背后的设计原理和大致的实现方式有所认识,这对于我们开发 Nest 项目过程中是非常有利的,避免出现做了但是又不知道为什么这么做的尴尬。

如果本文对你有所帮助,不要吝啬你的 star🌟 哦,这是我的博客地址:vortesnail/blog

拜~

React Router 5 完整指南

最近在搭建自己的网站时,以前一直被自己认为写起来很简单的路由狠狠地给了我一巴掌,我既然怎么也想不到该怎么去合理地设计路由,痛定思痛,阅读了很多文章及官方文档,过程中也读到了这一篇很好的文章,想翻译下来向大家分享下!同时,文中一些没讲到点上的,我都会进行补充,欢迎大家阅读与留言!

另外,Twitter 已经私信给原作者,得到了翻译许可!

React Router 是 React 社区最受欢迎的路由库,当你需要在一个有多个页面的 React 应用程序中根据 URL 来导航到对应的页面时,就可以使用 React Router 来处理这个问题,它会使你的应用的 UI 和 URL 保持同步。

本教程将会向你介绍 React Router 5 以及你可以利用它而做到的一大堆事情。

介绍

我们都知道 React 是一个用于创建在客户端进行渲染单页应用(SPA)的流行库,在一个 SPA 中可能有多个视图(也可以叫页面),但是与传统的多页应用程序不同的是,浏览这些页面时不会导致整个页面被重新加载。我们希望的是这些页面能够在当前页面中进行内联渲染,当然了,如果我们习惯了多页应用程序,那么希望 SPA 中也要具有以下的功能:

  • 每个页面都应该有一个唯一指定该页面的 URL,这是为了能让用户可以将 URL 加入书签或直接输入浏览器而访问,比如 www.example.com/products
  • 点击浏览器的后腿和前进按钮都应该如其如期工作。
  • 动态生成的嵌套页面最好也有一个自己的 URL,比如 www.example.com/products/shoes/101 ,其中 101 是产品 ID。

路由是使浏览器的 URL 与页面上正在展示的页面保持同步的过程。React Router 让你以声明的方式处理路由,声明式路由方法允许你控制应用程序中的数据流,基本的使用方式就像下面一样简单:

<Route path="/about">
  <About />
</Route>

这里简单提一下声明式路由函数式路由分别长啥样:

  • 声明式:<NavLink to='/products' />
  • 函数式:histor.push('/products')

你可以把 <Route> 组件放在任何你想渲染路由的地方,因为 <Route><Link> 以及其它 React Router 的 APIs 都只是组件而已,所以你可以很容易地在 React 中启动和运行路由。

⚠️ 注意:有一个普遍的误解,认为 React Router 是由 Facebook 开发的官方路由解决方案。实际上,它只是一个第三方库,但因其设计和简单性而广受欢迎。

概览

本教程将会分为几个小节,首先我们会使用 npm 来安装 React 和 React Router,接着就直接介绍 React Router 的基础知识。你会看到根据不同知识点而写的不同的代码演示,本教程中涉及的例子有:

  • 基本的导航路由
  • 嵌套路由
  • 带路径参数的嵌套路由
  • 权限路由

所有与构建这些路由有关的概念都将在此过程中讨论。另外,该项目的全部代码可在 GitHub repo 上找到。

现在就让我们搞起来吧!

安装 React Router

请保证你电脑上安装了 nodenpm ,然后利用 create-react-app 来创建一个新的 React 项目,我们直接使用 npx 来进行项目的新建:

npx create-react-app react-router-demo

npx 可以使你不需要全局安装 create-react-app 就能创建 cra 项目。

接下来切换到该项目目录下:

cd react-router-demo

React Router 库包含三个包:react-routerreact-router-domreact-router-native 。路由操作相关的核心包是 react-router,而其他两个是特定环境下使用的。如果你正在开发一个 web 应用,你应该使用 react-router-dom,如果你在使用 React Native 开发移动应用,则应该使用 react-router-native

使用 npm 来安装 react-router-dom

npm install react-router-dom

然后执行以下命令来启动本地服务:

npm run start

好了,你现在已经有了一个安装了 React Router 的 React 应用,你可以在 http://localhost:3000/ 查看该应用的运行情况了。

React Router 基础知识

现在让我们熟悉一下 React Router 的基础知识,为了做到这一点,我们将制作一个有三个独立页面的应用程序:Home,Category 和 Products。

Router 组件

我们需要做的第一件事是将我们的 <App> 组件包裹在一个 <Router> 组件中(由 React Router 提供)。由于我们正在建立的是一个基于浏览器的 web 应用程序,我们可以使用 React Router API 中的两种类型的路由:

两者主要区别在于他们创建的 URL 上:

// <BrowserRouter>
http://example.com/about 
// <HashRouter> 
http://example.com/#/about

<BrowserRouter> 在两者中会更受欢迎些,因为它使用的是 HTML5 History API 来保持应用的页面与 URL 同步,而 <HashRouter> 则使用的是 URL 的哈希部分(window.location.hash)。如果你的代码运行在不支持 History API 的传统浏览器上,你应该使用 <HashRouter> ,否则 <BrowserRouter> 对于大多数情况来说是更好的选择。

导入 BrowserRouter 组件并用其包裹 <App> 组件:

// src/index.js

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import { BrowserRouter as Router } from "react-router-dom";

ReactDOM.render(
  <Router>
    <App />
  </Router>,
  document.getElementById("root")
);

在上面代码中,我们为整个 <App> 组件创建了一个 history 实例,等会向大家解释这意味着什么。

⚠️ 为了能让大家更加明白这两者有啥区别,我会在下面做一个简短的说明。

<BrowserRouter><HashRouter> 区别

BrowserRouter:

BrowserRouter 要求服务端对发送的不同的 URL 都要返回对应的 HTML,比如说现在有如下两个 URL 发送 GET 请求到服务端:

http://example.com/home http://example.com/about

那么这个时候服务端拿到的是完整的 URL,这时候服务端就必须分别对 /home/about 做处理并返回相应的 HTML 来给到客户端渲染。这个带来的影响就是,如果你切换到某个服务端没有做相应处理的页面路由,比如:

http://example.com/article

如果你在 SPA 中写了这部分路由要渲染的页面,在页面无刷新情况下跳转是没啥问题的。但是如果你直接在此路由下进行页面的刷新,就会得到一个 404。

HashRouter

HashRouter 在 URL 中使用哈希符号(#)来使服务端忽略 # 后面所有的 URL 内容,比如你在浏览器地址栏中直接输入以下 URL:

http://example.com/#/home http://example.com/#/about

服务端拿到的只会是 http://example.com/ ,这样服务端只需要对这个路由做处理并返回 HTML,然后后面的路由 /home/about 将全部交给客户端(也就是我们的 SPA 应用)来处理并渲染对应的页面。所以你在任意的路由进行页面的刷新都不会是 404。

History 的小知识

history 这个库可以让你在 JavaScript 运行的任何地方都能轻松地管理回话历史,history 对象抽象化了各个环境中的差异,并提供了最简单易用的的 API 来给你管理历史堆栈、导航,并保持会话之间的持久化状态。 — React Training 文档

每个 <Router> 组件都会创建一个 history 对象,它记录了当前的位置(history.location),还记录了堆栈中以前的位置。在当前位置发生变化时,页面会被重新渲染,于是你就有一种导航跳转的感觉。

那么如何改变当前的位置呢?也就是说如何做到导航跳转呢?这时候 history 的作用就来了,这个对象暴露了一些方法,比如 history.pushhistory.replace ,它们就可以拿来处理上面的问题。

当你点击一个 <Link> 组件时,history.push 就会被调用,而当你使用一个 <Redirect> 组件时,history.replace 就会被调用。其它的方法比如 history.goBackhistory.goForward 可以用来在历史堆栈中回溯或前进。

LinkRoute 组件

可以说 <Route> 组件是 React Router 中最重要的组件了,如果当前的位置与路由的路径匹配,就会渲染对应的 UI。理想情况下,<Route> 组件应该有一个名为 path 的属性,如果路径名称与当前位置匹配,它就会被渲染。

<Link> 组件被用来在页面之间进行导航,它其实就是 HTML 中的 <a> 标签的上层封装,不过在其源码中使用 event.preventDefault 禁止了其默认行为,然后使用 history API 自己实现了跳转。我们都知道,如果使用 <a> 标签去进行导航的话,整个页面都会被刷新,这是我们不希望看到的,当然,跳转到首页这种行为我倒是蛮喜欢用 <a> 标签的~

所以我们使用 <Link> 组件来导航到一个目标 URL,可以在不刷新页面的情况下重新渲染页面。

现在我们已经知道了所有要完成我们的 APP 所需要的知识,接着更新 src/App.js ,如下所示:

import React from "react";
import { Link, Route, Switch } from "react-router-dom";

const Home = () => (
  <div>
    <h2>Home</h2>
  </div>
);

const Category = () => (
  <div>
    <h2>Category</h2>
  </div>
);

const Products = () => (
  <div>
    <h2>Products</h2>
  </div>
);

export default function App() {
  return (
    <div>
      <nav className="navbar navbar-light">
        <ul className="nav navbar-nav">
          <li>
            <Link to="/">Home</Link>
          </li>
          <li>
            <Link to="/category">Category</Link>
          </li>
          <li>
            <Link to="/products">Products</Link>
          </li>
        </ul>
      </nav>
      {/* 如果当前路径与 path 匹配就会渲染对应的组件 */}
      <Route path="/">
        <Home />
      </Route>
      <Route path="/category">
        <Category />
      </Route>
      <Route path="/products">
        <Products />
      </Route>
    </div>
  );
}

在上面的 App.js 中我们定义了三个组件分别为 HomeCategoryProducts 。虽然现在这样做还算说得过去,但是当一个组件内的代码变得很多时,最好的方式是为每一个组件建立一个独立的文件。就我的经验来说,如果一个组件占用的代码超过 10 行,我就会为它创建一个新的文件。所以从第二个演示开始,我将会为那些代码过多而放在 App.js 中会显得特别臃肿的组件单独创建一个文件来存放。

App 组件中我们已经写好了路由的逻辑,<Route>path 如果与当前位置相匹配的话,对应的组件也会被渲染。在以前,要被渲染的组件应该作为 <Route> 组件的属性传入的,但是现在的版本只要作为 <Route> 的子组件就可以被正确渲染。

在上面的路由设计中,/ 将会匹配 //category 以及 /products ,这带来的结果是会同时在页面上渲染三个组件,即 HomeCategoryProducts ,这不是我们所希望看到的。因此,我们可以通过传入 exact 属性给 <Route> 组件来避免这个问题出现:

<Route exact path="/">
  <Home />
</Route>

所以如果你期望的是根据一个安全匹配的 path 去渲染对应的组件,你就应该考虑使用属性 exact 了。

嵌套路由

如果想要使用嵌套路由,我们要更加深入地理解 <Route> 组件的工作方式,接下来我们一探究竟。

通过 React Router 官方文档 可知,使用 <Route> 渲染一个页面(或组件)的最佳方式是使用子元素方式,就像我们上面的演示一样。然而,还是有一些其它的方式,这些方式是为了兼容在没有引进 hooks 之前的早期版本的 React Router 构建的 APP:

  • component :当 URL 匹配时,React Router 会使用 React.createElement 从给定的组件创建一个 React 元素。
  • render :能使你便捷的渲染内联组件或是嵌套组件,你可以给这个属性传入一个函数,当路由的路径匹配时调用,返回一个元素。
  • children :与 render 属性有些类似,它也是接收一个函数,不同的是,无论现在 path 是否与当前位置匹配,这个函数都会被执行。

路径和匹配

属性 path 是用于识别路由应该被匹配到的 URL 部分,它使用 path-to-regexp 库将字符串形式的 path 转换为一个正则表达式,然后将它与当前的位置进行匹配。

如果路由的 path 与当前位置完全匹配时,一个 match 对象 就会被创建,这个对象中有关于 URL 和路径的更多信息,这些信息可以通过这个对象的属性来进行访问,下面为大家列出有哪些属性:

  • match.url :一个字符串(string),返回 URL 匹配的部分,这对于构建嵌套的 <Link> 组件特别有用。
  • match.path :一个字符串(string),返回路由的 path ,即 <Route path=""> ,我们将使用它来构建嵌套的 <Route> 组件。
  • match.isExact :一个布尔值(boolean),如果匹配时精确的,即没有任何尾部字符,则返回 true
  • match.params :一个对象(object),返回的是从 URL 中解析出来键值对。

属性的隐式传递

请注意,当使用 component 属性来渲染路由时,matchlocationhistory 这些路由属性是隐式地传给被渲染的组件的。但当使用比较新的路由渲染模式时,情况有所不同。

比如,以下面这个组件为例:

const Home = (props) => {
  console.log(props);

  return (
    <div>
      <h2>Home</h2>
    </div>
  );
};

以这种方式渲染路由:

<Route exact path="/" component={Home} />

控制台打印的日志:

{
  history: { ... }
  location: { ... }
  match: { ... }
}

但是现在如果以这种方式渲染路由:

<Route exact path="/">
  <Home />
</Route>

控制台打印的日志将会是这样:

{}

可能你会觉得以这种方式来使用不太好,因为我们在渲染的组件中拿不到路由属性了。但是不用担心,React v5.1 引入了几个 hooks,通过在组件内部使用这些 hooks 可以助你访问到上面隐式传递的任何路由属性,这是一种新的管理路由状态的方法,并在一定程度上使我们的组件更加整洁。

我将在本教程中使用其中的一些 hooks,但是如果你想要更深入地了解,可以查看 React Router v5.1 的发布公告。请注意,hooks 是在 React 的 16.8 版本中引入的,所以你至少需要在这个版本以上才能使用它们。

Switch 组件

在开始代码演示之前,我想先向大家介绍一下 Switch 组件。当多个 <Route> 被一起使用时,所有匹配到的路由都会被渲染,大家看下下面的代码,我会向大家解释为什么 <Switch> 是有用的:

<Route exact path="/"><Home /></Route>
<Route path="/category"><Category /></Route>
<Route path="/products"><Products /></Route>
<Route path="/:id">
  <p>This text will render for any route other than '/'</p>
</Route>

如果 URL 是 /products ,那么 path/products/:id 的路由会一起在页面渲染出来,这就是这样设计的。然而,这种行为基本不可能是我们所期待的,所以才要用到 <Switch> ,有了 <Switch> ,只有第一个与当前 URL 匹配到的子 <Route> 才会被渲染:

<Switch>
  <Route exact path="/">
    <Home />
  </Route>
  <Route path="/category">
    <Category />
  </Route>
  <Route path="/products">
    <Products />
  </Route>
  <Route path="/:id">
    <p>This text will render for any route other than those defined above</p>
  </Route>
</Switch>

path:id 部分用于动态路由,它将匹配斜杠后面的任何东西,并且这个匹配到的值在被渲染的组件中是可以拿到的,我们会在下一节演示如何取这个值。

现在我们知道了关于 <Route><Switch> 组件的一切,让我们看看本节的主题嵌套路由的示例吧。

动态嵌套路由

在上面的示例中我们创建了 //category/products 路由,但是如果我们想要匹配一个 /category/shoes 的路由咋办呢?让我们更新一波 src/App.js 的代码:

import React from "react";
import { Link, Route, Switch } from "react-router-dom";
import Category from "./Category";

const Home = () => (
  <div>
    <h2>Home</h2>
  </div>
);

const Products = () => (
  <div>
    <h2>Products</h2>
  </div>
);

export default function App() {
  return (
    <div>
      <nav className="navbar navbar-light">
        <ul className="nav navbar-nav">
          <li>
            <Link to="/">Home</Link>
          </li>
          <li>
            <Link to="/category">Category</Link>
          </li>
          <li>
            <Link to="/products">Products</Link>
          </li>
        </ul>
      </nav>

      <Switch>
        <Route path="/">
          <Home />
        </Route>
        <Route path="/category">
          <Category />
        </Route>
        <Route path="/products">
          <Products />
        </Route>
      </Switch>
    </div>
  );
}

你应该注意到了,我已经把 Category 组件独立出来了,而我们的嵌套路由就在这个组件中去定义,那么现在就来创建 Category.js 吧!

// src/Category.js

import React from "react";
import { Link, Route, useParams, useRouteMatch } from "react-router-dom";

const Item = () => {
  const { name } = useParams();

  return (
    <div>
      <h3>{name}</h3>
    </div>
  );
};

const Category = () => {
  const { url, path } = useRouteMatch();

  return (
    <div>
      <ul>
        <li>
          <Link to={`${url}/shoes`}>Shoes</Link>
        </li>
        <li>
          <Link to={`${url}/boots`}>Boots</Link>
        </li>
        <li>
          <Link to={`${url}/footwear`}>Footwear</Link>
        </li>
      </ul>
      <Route path={`${path}/:name`}>
        <Item />
      </Route>
    </div>
  );
};

export default Category;

在这里我们使用 useRouteMatch hook 来获取上面我们说过的 match 对象。如前所述,match.url 为 URL 匹配的部分,用于构建嵌套链接。match.path 为路由的 path ,用于构建嵌套路由。

如果你觉得在 match 对象中的属性有理解上的困难,没关系,console.log(useRouteMatch()) 打印在控制台仔细看看它的属性的值是什么,你就大概能知道啥意思了。

<Route path={`${path}/:name`}>
  <Item />
</Route>

这就是我们对动态路由的第一次尝试,因为我们没有将路由写死,而是在属性 path 中使用了一个变量,:name 是一个路径参数,可以捕捉到 category/ 之后的所有内容,直到遇到另外一个正斜杠(/)。因此,像 category/running-shoes 这样的路径名称将会创建一个 params 对象,如下所示:

{
  name: "running-shoes";
}

为了在 <Item> 组件中访问到这个值,我们使用 useParams hook ,它返回一个 URL 参数的键值对的对象。

你可以在控制台中打印下看看返回的到底是什么,那么现在 Category 应该就会有三个子路由了。

带路径参数的嵌套路由

我们把这个例子在复杂化一点,以便我们更好地去理解。在实际开发中,我们的路由必须具有处理数据并动态展示它们的功能。假设有一些 API 返回的产品数据,其格式如下:

const productData = [
  {
    id: 1,
    name: "NIKE Liteforce Blue Sneakers",
    description:
      "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin molestie.",
    status: "Available",
  },
  {
    id: 2,
    name: "Stylised Flip Flops and Slippers",
    description:
      "Mauris finibus, massa eu tempor volutpat, magna dolor euismod dolor.",
    status: "Out of Stock",
  },
  {
    id: 3,
    name: "ADIDAS Adispree Running Shoes",
    description:
      "Maecenas condimentum porttitor auctor. Maecenas viverra fringilla felis, eu pretium.",
    status: "Available",
  },
  {
    id: 4,
    name: "ADIDAS Mid Sneakers",
    description:
      "Ut hendrerit venenatis lacus, vel lacinia ipsum fermentum vel. Cras.",
    status: "Out of Stock",
  },
];

假设我们还需要为以下的路径创建路由:

  • /products :这应该显示一个产品列表。
  • /products/:productId :如果匹配到 :productId 那么就应该显示这个产品的数据,如果没有就显示一个错误信息。

创建一个新文件 src/Products.js 文件,并添加以下代码:

import React from "react";
import { Link, Route, useRouteMatch } from "react-router-dom";
import Product from "./Product";

const Products = ({ match }) => {
  const productData = [ ... ];
  const { url } = useRouteMatch();

  /* Create an array of `<li>` items for each product */
  const linkList = productData.map((product) => {
    return (
      <li key={product.id}>
        <Link to={`${url}/${product.id}`}>{product.name}</Link>
      </li>
    );
  });

  return (
    <div>
      <div>
        <div>
          <h3>Products</h3>
          <ul>{linkList}</ul>
        </div>
      </div>

      <Route path={`${url}/:productId`}>
        <Product data={productData} />
      </Route>
      <Route exact path={url}>
        <p>Please select a product.</p>
      </Route>
    </div>
  );
};

export default Products;

首先我们使用了 useRouteMatch 钩子,并从 match 对象中拿到 URL ,然欧根据每个产品的 id 属性来建立一个 <Link> 组件的列表,并将其返回存储到一个 linkList 变量中。

第一个路由使用 path 中的一个变量,它与产品 id 对应,当匹配成功时,我们就会渲染 <Product> 组件(我们马上进行定义),将我们的产品数据传递给它:

<Route path={`${url}/:productId`}>
  <Product data={productData} />
</Route>

注意到第二个路由中有一个 exact 属性,只有当 URL 是 /products 且其后面没有任何路径参数时才会渲染。

OK,下面是 <Product> 组件的代码,你只需要在 src/Product.js 创建这个文件:

import React from "react";
import { useParams } from "react-router-dom";

const Product = ({ data }) => {
  const { productId } = useParams();
  const product = data.find((p) => p.id === Number(productId));
  let productData;

  if (product) {
    productData = (
      <div>
        <h3> {product.name} </h3>
        <p>{product.description}</p>
        <hr />
        <h4>{product.status}</h4>
      </div>
    );
  } else {
    productData = <h2> Sorry. Product doesn't exist </h2>;
  }

  return (
    <div>
      <div>{productData}</div>
    </div>
  );
};

export default Product;

find 方法用于在产品数组中搜索一个 id 属性与 match.params.productId 相同的对象。如果该产品存在,就会渲染对应的数据。如果不存在,就会显示 “产品不存在”的信息。

最后,更新你的 <App> 组件,如下所示:

import React from "react";
import { Link, Route, Switch } from "react-router-dom";
import Category from "./Category";
import Products from "./Products";

const Home = () => (
  <div>
    <h2>Home</h2>
  </div>
);

export default function App() {
  return (
    <div>
      <nav className="navbar navbar-light">
        <ul className="nav navbar-nav">
          <li>
            <Link to="/">Home</Link>
          </li>
          <li>
            <Link to="/category">Category</Link>
          </li>
          <li>
            <Link to="/products">Products</Link>
          </li>
        </ul>
      </nav>

      <Switch>
        <Route exact path="/">
          <Home />
        </Route>
        <Route path="/category">
          <Category />
        </Route>
        <Route path="/products">
          <Products />
        </Route>
      </Switch>
    </div>
  );
}

现在你就可以在浏览器中访问你写的这些路由了,如果你选择“Products”,你会看到一个子菜单,并且显示了产品的数据。

尝试着好好理解下这个演示中的代码,确保你要掌握这部分内容。

权限路由

在如今大多数网站应用中,只有登录了的用户才能访问网站的某些部分,比如掘金登录之后才会有进入到个人主页的入口。接下来这一节,我会告诉大家如何去实现一个权限路由,也就是说如果有人试图访问 /admin ,他将会首先被要求登录。

然而,我们需要先了解 React Router 的几个方面。

<Redirect> 组件

与服务端的重定向类似,React Router 的 Redirect component 将会用一个新的位置替换历史栈中的当前位置,新的位置是由 to 属性来指向的。那么接下来我就会向大家介绍如何使用 <Redirect>

<Redirect to={{ pathname: '/login', state: { from: location }}}

如果有人试图在未登录状态下访问 /admin 路由,他就会被重定向到 /login 路由,关于当前位置的信息是由 state 属性进行传递的,这样做是为了在用户登录成功之后,用户又可以被重定向到他试图访问的路由页面。

自定义路由

如果我们需要决定一个路由是否应该被渲染,那么编写一个自定义路由是个好办法,接下来在 src 目录下创建一个新文件 PrivateRoute.js ,并写入以下代码:

import React from "react";
import { Redirect, Route, useLocation } from "react-router-dom";
import { fakeAuth } from "./Login";

const PrivateRoute = ({ component: Component, ...rest }) => {
  const location = useLocation();

  return (
    <Route {...rest}>
      {fakeAuth.isAuthenticated === true ? (
        <Component />
      ) : (
        <Redirect to={{ pathname: "/login", state: { from: location } }} />
      )}
    </Route>
  );
};

export default PrivateRoute;

如你所见,在函数定义中,我们将接收到的 props 中拿到一个 Component 还有一个剩余属性 restComponent 将包含我们的 <PrivateRoute> 所保护的任何组件(在该例中为 Admin 组件),其余的属性将会通过 rest 传递给 <Route>

我们返回的是一个 <Route> 组件,该组件会根据用户是否登录来决定是否渲染受到保护的组件,如果没有登录将会重定向到 /login 路由。这是由 fakeAuth.isAuthenticated 属性决定的,这个属性从 <Login> 组件中导入。

这种封装的方法好处在于是声明式的,而且 <PrivateRoute> 可被重复使用。

实践权限路由

现在我们可以修改 src/App.js

import React from "react";
import { Link, Route, Switch } from "react-router-dom";
import Category from "./Category";
import Products from "./Products";
import Login from "./Login";
import PrivateRoute from "./PrivateRoute";

const Home = () => (
  <div>
    <h2>Home</h2>
  </div>
);

const Admin = () => (
  <div>
    <h2>Welcome admin!</h2>
  </div>
);

export default function App() {
  return (
    <div>
      <nav className="navbar navbar-light">
        <ul className="nav navbar-nav">
          <li>
            <Link to="/">Home</Link>
          </li>
          <li>
            <Link to="/category">Category</Link>
          </li>
          <li>
            <Link to="/products">Products</Link>
          </li>
          <li>
            <Link to="/admin">Admin area</Link>
          </li>
        </ul>
      </nav>

      <Switch>
        <Route exact path="/">
          <Home />
        </Route>
        <Route path="/category">
          <Category />
        </Route>
        <Route path="/products">
          <Products />
        </Route>
        <Route path="/login">
          <Login />
        </Route>
        <PrivateRoute path="/admin" component={Admin} />
      </Switch>
    </div>
  );
}

正如你所见,我们在文件的顶部添加了一个 <Admin> 组件,并在 <Switch> 组件下添加了一个 <PrivateRoute> 组件。正如前面所说,如果用户已经登录的话,这个自定义路由将会渲染的是 <Admin> 组件,否则,用户会被重定向到 /login

最后,这里是 Login 组件代码:

import React, { useState } from "react";
import { Redirect, useLocation } from "react-router-dom";

export default function Login() {
  const { state } = useLocation();
  const { from } = state || { from: { pathname: "/" } };
  const [redirectToReferrer, setRedirectToReferrer] = useState(false);

  const login = () => {
    fakeAuth.authenticate(() => {
      setRedirectToReferrer(true);
    });
  };

  if (redirectToReferrer) {
    return <Redirect to={from} />;
  }

  return (
    <div>
      <p>You must log in to view the page at {from.pathname}</p>
      <button onClick={login}>Log in</button>
    </div>
  );
}

/* A fake authentication function */
export const fakeAuth = {
  isAuthenticated: false,
  authenticate(cb) {
    this.isAuthenticated = true;
    setTimeout(cb, 100);
  },
};

我们使用 useLocation hook 来访问路由的 location 属性,也就是从 state 属性带过来的。然后我们使用对象的解构来获取用户在被要求登录之前试图访问的 URL,这个这个值不存在,我们就设为 { pathname: "/" }

然后我们使用 React 的 useState 钩子来初始化一个 redirectToReferrer 状态为 false ,根据这个值来决定用户是被重定向到他们想要访问的路径(也就是说用户已经登录了),还是向用户展示一个按钮让他们登录。

一旦按钮被点击,fakeAuth.authenticate 这个方法就会被执行,它将 fakeAuth.isAuthenticated 设为 true ,并(在一个回调函数中)将 redirectToReferrer 状态更新为 true ,这将导致组件重新渲染,用户将被重定向。

完整示例

以下就是我们使用学到的东西做出来的最终 demo:

Edit React Router Demo

如何使用React Testing Library和Jest测试React应用

原文链接:How to Start Testing Your React Apps Using the React Testing Library and Jest

写测试通常都会被认作一个乏味的过程,但是这是你必须掌握的一个技能,虽然在某些时候,测试并不是必要的。然后对于大多数有追求的公司而言,单元测试是必须的,开发者对于代码的自信会大幅提高,侧面来说也能提高公司对其产品的信心,也能让用户使用得更安心。

在 React 世界中,我们使用 react-testing-libraryjest 配合使用来测试我们的 React Apps。

在本文中,我将向你介绍如何使用 8 种简单的方式来来测试你的 React App。

先备条件

本教程假定你对 React 有一定程度的了解,本教程只会专注于单元测试。

接下来,在终端中运行以下命令来克隆已经集成了必要插件的项目:

git clone https://github.com/ibrahima92/prep-react-testing-library-guide


安装依赖:

npm install


或者使用 Yarn :

yarn


好了,就这些,现在让我们了解一些基础知识!

基础知识

本文将大量使用一些关键内容,了解它们的作用可以帮助你快速理解。

it 或 test :用于描述测试本身,其包含两个参数,第一个是该测试的描述,第二个是执行测试的函数。

expect :表示测试需要通过的条件,它将接收到的参数与 matcher 进行比较。

matcher :一个希望到达预期条件的函数,称其为匹配器。

render :用于渲染给定组件的方法。

import React from 'react'
import { render } from '@testing-library/react'
import App from './App'
 
it('should take a snapshot', () => {
  const { asFragment } = render(<App />)

  expect(asFragment(<App />)).toMatchSnapshot()
})

如上所示,我们使用 it 来描述一个测试,然后使用 render 方法来显示 App 这个组件,同时还期待的是 asFragment(<App />) 的结果与 toMatchSnapshot() 这个 matcher 匹配(由 jest 提供的匹配器)。

顺便说一句, render 方法返回了几种我们可以用来测试功能的方法,我们还使用了对象解构来获取到某个方法。

那么,让我们继续并在下一节中进一步了解 React Testing Library 吧~ 。

什么是 React Testing Library ?

React Testing Library 是用于测试 React 组件的非常便捷的解决方案。 它在 react-dom 和 react-dom/test-utils 之上提供了轻量且实用的 API,如果你打开 React 官网中的测试工具推荐,你会发现 Note 中写了:

注意:
我们推荐使用 React Testing Library,它使得针对组件编写测试用例就像终端用户在使用它一样方便。

React Testing Library 是一个 DOM 测试库,这意味着它并不会直接处理渲染的 React 组件实例,而是处理 DOM 元素以及它们在实际用户面前的行为。

这是一个很棒的库,(相对)易于使用,并且鼓励良好的测试实践。 当然,你也可以在没有 Jest 的情况下使用它。

“你的测试与软件的使用方式越接近,就能越给你信心。”

那么,让我们在下一部分中就开始使用它吧。顺便说一下,你不需要安装任何依赖了,刚才克隆的项目本身是用 create-react-app 创建的,已经集成了编写单元测试所需要的插件了,只需保证你已经安装了依赖即可。

8个示例

1.如何创建测试快照

顾名思义,快照使我们可以保存给定组件的快照。 当你对组件进行一些更新或重构,希望获取或比较更改时,它会很有帮助。

现在,让我们对 App.js 文件进行快照测试。

  • App.test.js 
import React from 'react'
import { render, cleanup } from '@testing-library/react'
import App from './App'

afterEach(cleanup)

it('should take a snapshot', () => {
  const { asFragment } = render(<App />)

  expect(asFragment(<App />)).toMatchSnapshot()
})

要获得快照,我们首先需要导入 render 和 cleanup 方法。 在本文中,我们将经常使用这两种方法。

你大概也猜到了, render 方法用于渲染 React 组件, cleanup 方法将作为参数传递给 afterEach ,目的是在每个测试完成后清除所有内容,以避免内存泄漏。

接下来,我们可以使用 render 渲染 App 组件,并从该方法返回 asFragment 。 最后,确保 App 组件的片段与快照匹配。

现在,要运行测试,请打开终端并导航到项目的根目录,然后运行以下命令:

yarn test


如果你使用 NPM:

npm run test

结果,它将在 src 中创建一个新文件夹 __snapshots__ 和及其目录下新建一个 App.test.js.snap 文件,如下所示:

  • App.test.js.snap :
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should take a snapshot 1`] = `
<DocumentFragment>
  <div class="App">
    <h1>
      Testing Updated
    </h1>
  </div>
</DocumentFragment>
`;

如果现在你对 App.js 进行更改,则测试将失败,因为快照将不再符合条件。要使其通过,只需按键盘上的 u 健即可对其进行更新。 并且你将在 App.test.js.snap 中拥有更新后的快照。

现在,让我们继续并开始测试我们的元素。

2.测试 DOM 元素

为了测试我们的 DOM 元素,我们先大概看下 components/TestElements.js 文件。

  • TestElements.js :
import React from 'react'

const TestElements = () => {
  const [counter, setCounter] = React.useState(0)

  return (
    <>
      <h1 data-testid="counter">{counter}</h1>
      <button data-testid="button-up" onClick={() => setCounter(counter + 1)}> Up</button>
      <button disabled data-testid="button-down" onClick={() => setCounter(counter - 1)}>Down</button>
    </>
  )
}

export default TestElements

你唯一需要留意的就是 data-testid 。 它将用于从测试文件中获取到这些 dom 元素。 现在,让我们编写单元测试:

测试计数器(counter)是否等于0

  • TestElements.test.js :
import React from 'react'
import { render, cleanup } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import TestElements from './TestElements'

afterEach(cleanup)

it('should equal to 0', () => {
  const { getByTestId } = render(<TestElements />)
  expect(getByTestId('counter')).toHaveTextContent(0)
})

如你所见,语法其实和先前的快照测试非常相似。唯一的区别是,我们现在使用 getByTestId 进行 dom 元素的获取,然后检查该元素的文本内容是否为 0 。

测试 button 按钮是禁用还是启用

  • TestElements.test.js (将以下代码追加到该文件中):
it('should be enabled', () => {
  const { getByTestId } = render(<TestElements />)
  expect(getByTestId('button-up')).not.toHaveAttribute('disabled')
})

it('should be disabled', () => {
  const { getByTestId } = render(<TestElements />)
  expect(getByTestId('button-down')).toBeDisabled()
})

同样地,我们使用 getByTestId 来获取 dom 元素,第一个测试是测试 button 元素上没有属性 disabled ;第二个测试是测试 button 元素处于禁用状态。

保存之后再运行测试命令,你会发现测试全部通过了!

恭喜你成功通过了自己的第一个测试!


现在,让我们在下一部分中学习如何测试事件。

3.测试事件

在写单元测试之前,我们先来看看 components/TestEvents.js 文件是啥样:

  • TestEvents.js :
import React from 'react'

const TestEvents = () => {
  const [counter, setCounter] = React.useState(0)

  return (
    <>
      <h1 data-testid="counter">{counter}</h1>
      <button data-testid="button-up" onClick={() => setCounter(counter + 1)}>Up</button>
      <button data-testid="button-down" onClick={() => setCounter(counter - 1)}>Down</button>
    </>
  )
}

export default TestEvents

现在,让我们为这个组件写单元测试。

单击按钮时,测试计数器是否正确递增和递减

  • TestEvents.test.js :
import React from 'react'
import { render, cleanup, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import TestEvents from './TestEvents'

afterEach(cleanup)

it('increments counter', () => {
  const { getByTestId } = render(<TestEvents />)

  fireEvent.click(getByTestId('button-up'))

  expect(getByTestId('counter')).toHaveTextContent('1')
})

it('decrements counter', () => {
  const { getByTestId } = render(<TestEvents />)

  fireEvent.click(getByTestId('button-down'))

  expect(getByTestId('counter')).toHaveTextContent('-1')
})

如你所见,除了预期的文本内容不同之外,这两个测试非常相似。

第一个测试使用 fireEvent.click() 触发 click 事件,以检查单击按钮时计数器是否增加为 1 。

第二个测试检查单击按钮时计数器是否递减到 -1 。

fireEvent 有几种可用于测试事件的方法,因此请随时阅读文档以了解更多信息。

现在我们知道了如何测试事件,让我们继续学习下一节如何处理异步操作。

4.测试异步操作

异步操作需要花费一些时间才能完成。它可以是HTTP请求,计时器等。

同样地,让我们检查一下 components/TestAsync.js 文件。

  • TestAsync.js :
import React from 'react'

const TestAsync = () => {
  const [counter, setCounter] = React.useState(0)

  const delayCount = () => (
    setTimeout(() => {
      setCounter(counter + 1)
    }, 500)
  )

  return (
    <>
      <h1 data-testid="counter">{counter}</h1>
      <button data-testid="button-up" onClick={delayCount}>Up</button>
      <button data-testid="button-down" onClick={() => setCounter(counter - 1)}>Down</button>
    </>
  )
}

export default TestAsync

在这里,我们使用 setTimeout() 模拟异步。

测试计数器是否在0.5s后递增

  • TestAsync.test.js :
import React from 'react'
import { render, cleanup, fireEvent, waitForElement } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import TestAsync from './TestAsync'

afterEach(cleanup)

it('increments counter after 0.5s', async () => {
  const { getByTestId, getByText } = render(<TestAsync />)

  fireEvent.click(getByTestId('button-up'))

  const counter = await waitForElement(() => getByText('1'))

  expect(counter).toHaveTextContent('1')
})

为了测试递增事件,我们首先必须使用 async/await 来处理该动作,因为正如我之前所说的,它需要一段时间之后才能完成。

随着我们使用了一个新的辅助方法 getByText() ,这与 getByTestId() 相似,只是现在我们通过 dom 元素的文本内容去获取该元素而已,而不是之前使用的 test-id 。

现在,单击按钮后,我们等待使用 waitForElement(() => getByText('1')) 递增计数器。 计数器增加到 1 后,我们现在可以移至条件并检查计数器是否有效等于 1 。

是不是理解起来很简单?话虽如此,让我们现在转到更复杂的测试用例。

你准备好了吗?


5.测试 React Redux

如果您不熟悉 React Redux,本文可能会为你提供些许帮助。先让我们看一下 components/TestRedux.js 的内容。

  • TestRedux.js :
import React from 'react'
import { connect } from 'react-redux'

const TestRedux = ({ counter, dispatch }) => {
  const increment = () => dispatch({ type: 'INCREMENT' })
  const decrement = () => dispatch({ type: 'DECREMENT' })

  return (
    <>
      <h1 data-testid="counter">{counter}</h1>
      <button data-testid="button-up" onClick={increment}>Up</button>
      <button data-testid="button-down" onClick={decrement}>Down</button>
    </>
  )
}

export default connect(state => ({ counter: state.count }))(TestRedux)

再看看 store/reducer.js :

export const initialState = {
  count: 0,
}

export function reducer(state = initialState, action) {
  switch (action.type) {
    case 'INCREMENT':
      return {
        count: state.count + 1,
      }
    case 'DECREMENT':
      return {
        count: state.count - 1,
      }
    default:
      return state
  }
}

如你所见,没有什么花哨的东西 - 它只是由 React Redux 处理的基本计数器组件。

现在,让我们编写单元测试。

测试初始状态是否等于0

  • TestRedux.test.js :
import React from 'react'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
import { render, cleanup, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import { initialState, reducer } from '../store/reducer'
import TestRedux from './TestRedux'

const renderWithRedux = (
  component,
  { initialState, store = createStore(reducer, initialState) } = {}
) => {
  return {
    ...render(<Provider store={store}>{component}</Provider>),
    store,
  }
}

afterEach(cleanup)

it('checks initial state is equal to 0', () => {
  const { getByTestId } = renderWithRedux(<TestRedux />)
  expect(getByTestId('counter')).toHaveTextContent('0')
})

我们需要导入一些内容来测试 React Redux。在这里,我们创建了自己的辅助函数 renderWithRedux() 来渲染组件,因为它将多次被使用到。

renderWithRedux() 接收要渲染的组件, initialState 和 store 作为参数。如果没有 store ,它将创建一个新 store ,如果没有收到 initialState 或 store ,则将返回一个空对象。

接下来,我们使用 render() 渲染组件并将 store 传递给 Provider 。

意味着,我们现在可以将组件 TestRedux 传递给 renderWithRedux() 来测试计数器是否等于 0 。

测试计数器是否正确递增和递减

  • TestRedux.test.js (将以下代码追加到该文件中):
it('increments the counter through redux', () => {
  const { getByTestId } = renderWithRedux(
    <TestRedux />,
    { initialState: { count: 5 } }
  )
  fireEvent.click(getByTestId('button-up'))
  expect(getByTestId('counter')).toHaveTextContent('6')
})

it('decrements the counter through redux', () => {
  const { getByTestId } = renderWithRedux(
    <TestRedux />,
    { initialState: { count: 100 } }
  )
  fireEvent.click(getByTestId('button-down'))
  expect(getByTestId('counter')).toHaveTextContent('99')
})

为了测试递增和递减事件,我们将initialState 作为第二个参数传递给 renderWithRedux() 。 现在,我们可以单击按钮并测试预期结果是否符合条件。

现在,让我们进入下一部分并介绍 React Context。

再接下来是 React Router 和 Axios,你还会看下去吗?


6.测试 React Context

如果您不熟悉React Context,请先阅读本文。另外,让我们看下 components/TextContext.js 文件。

  • TextContext.js :
import React, { createContext, useContext, useState } from "react"

export const CounterContext = createContext()

const CounterProvider = () => {
  const [counter, setCounter] = useState(0)
  const increment = () => setCounter(counter + 1)
  const decrement = () => setCounter(counter - 1)

  return (
    <CounterContext.Provider value={{ counter, increment, decrement }}>
      <Counter />
    </CounterContext.Provider>
  )
}

export const Counter = () => {
  const { counter, increment, decrement } = useContext(CounterContext)
  return (
    <>
      <h1 data-testid="counter">{counter}</h1>
      <button data-testid="button-up" onClick={increment}>Up</button>
      <button data-testid="button-down" onClick={decrement}>Down</button>
    </>
  )
}

export default CounterProvider

现在计数器状态通过 React Context 进行管理,让我们编写单元测试以检查其行为是否符合预期。

测试初始状态是否等于0

  • TestContext.test.js :
import React from 'react'
import { render, cleanup, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import CounterProvider, { CounterContext, Counter } from './TestContext'

const renderWithContext = (component) => {
  return {
    ...render(
      <CounterProvider value={CounterContext}>
        {component}
      </CounterProvider>
    )
  }
}

afterEach(cleanup)

it('checks if initial state is equal to 0', () => {
  const { getByTestId } = renderWithContext(<Counter />)
  expect(getByTestId('counter')).toHaveTextContent('0')
})

与上一节关于 React Redux 的部分一样,这里我们通过创建一个辅助函数 renderWithContext() 来渲染组件。但是这次,它仅接收组件作为参数。 为了创建一个新的上下文,我们将 CounterContext 传递给 Provider。

现在,我们就可以测试计数器初始状态是否等于 0 。

测试计数器是否正确递增和递减

  • TestContext.test.js (将以下代码追加到该文件中):
it('increments the counter', () => {
  const { getByTestId } = renderWithContext(<Counter />)

  fireEvent.click(getByTestId('button-up'))
  expect(getByTestId('counter')).toHaveTextContent('1')
})
 
it('decrements the counter', () => {
  const { getByTestId } = renderWithContext(<Counter />)

  fireEvent.click(getByTestId('button-down'))
  expect(getByTestId('counter')).toHaveTextContent('-1')
})

如你所见,这里我们触发一个 click 事件,测试计数器是否正确地增加到 1 或减少到 -1 。

我们现在可以进入下一节并介绍 React Router。

7.测试 React Router

如果您想深入研究 React Router,这篇文章可能会对你有所帮助。现在,让我们先 components/TestRouter.js 文件。

  • TestRouter.js :
import React from 'react'
import { Link, Route, Switch, useParams } from 'react-router-dom'

const About = () => <h1>About page</h1>
const Home = () => <h1>Home page</h1>
const Contact = () => {
  const { name } = useParams()

  return <h1 data-testid="contact-name">{name}</h1>
}

const TestRouter = () => {
  const name = 'John Doe'
  
  return (
    <>
      <nav data-testid="navbar">
        <Link data-testid="home-link" to="/">Home</Link>
        <Link data-testid="about-link" to="/about">About</Link>
        <Link data-testid="contact-link" to={`/contact/${name}`}>Contact</Link>
      </nav>
      <Switch>
        <Route exact path="/" component={Home} />
        <Route path="/about" component={About} />
        <Route path="/about:name" component={Contact} />
      </Switch>
    </>
  )
}

export default TestRouter

在这里,我们有一些导航主页时想要渲染的组件。

测试导航切换时是否正确渲染

  • TestRouter.test.js :
import React from 'react'
import { Router } from 'react-router-dom'
import { render, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import { createMemoryHistory } from 'history'
import TestRouter from './TestRouter'

const renderWithRouter = (component) => {
  const history = createMemoryHistory()
  return {
    ...render(
      <Router history={history}>
        {component}
      </Router>
    )
  }
}

it('should render the home page', () => {
  const { container, getByTestId } = renderWithRouter(<TestRouter />)
  const navbar = getByTestId('navbar')
  const link = getByTestId('home-link')

  expect(container.innerHTML).toMatch('Home page')
  expect(navbar).toContainElement(link)
})

要测试 React Router,我们首先必须有一个导航 history。因此,我们使用 createMemoryHistory() 来创建导航 history 。

接下来,我们使用辅助函数 renderWithRouter() 渲染组件并将 history 传递给 Router 组件。 这样,我们现在可以测试在开始时加载的页面是否是主页,并在导航栏中渲染预期中的 Link 组件。

单击链接时,测试它是否导航到其他页面

  • TestRouter.test.js (将以下代码追加到该文件中):
it('should navigate to the about page', () => {
  const { container, getByTestId } = renderWithRouter(<TestRouter />)

  fireEvent.click(getByTestId('about-link'))
  expect(container.innerHTML).toMatch('About page')
})

it('should navigate to the contact page with the params', () => {
  const { container, getByTestId } = renderWithRouter(<TestRouter />)

  fireEvent.click(getByTestId('contact-link'))
  expect(container.innerHTML).toMatch('John Doe')
})

要检查导航是否有效,我们必须在导航链接上触发 click 事件。

对于第一个测试,我们检查内容是否与“About Page”中的文本相等,对于第二个测试,我们测试路由参数并检查其是否正确传递。

现在,我们可以转到最后一节,学习如何测试 Axios 请求。

我们快完成了!加油啊!

8.测试 HTTP Request

像往常一样,让我们首先看一下 components/TextAxios.js 文件内容。

  • TestAxios.js :
import React from 'react'
import axios from 'axios'

const TestAxios = ({ url }) => {
  const [data, setData] = React.useState()

  const fetchData = async () => {
    const response = await axios.get(url)
    setData(response.data.greeting)
  }

  return (
    <>
      <button onClick={fetchData} data-testid="fetch-data">Load Data</button>
      {
        data ?
          <div data-testid="show-data">{data}</div> :
          <h1 data-testid="loading">Loading...</h1>
      }
    </>
  )
}

export default TestAxios

如你所见,我们有一个简单的组件,该组件带有一个用于发出请求的按钮。并且如果数据不可用,它将显示一条加载中的消息(Loading...)。

现在,让我们编写测试。

测试是否已正确提取和显示数据

  • TestAxios.test.js :
import React from 'react'
import { render, waitForElement, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import axiosMock from 'axios'
import TestAxios from './TestAxios'

jest.mock('axios')

it('should display a loading text', () => {
  const { getByTestId } = render(<TestAxios />)

  expect(getByTestId('loading')).toHaveTextContent('Loading...')
})

it('should load and display the data', async () => {
  const url = '/greeting'
  const { getByTestId } = render(<TestAxios url={url} />)

  axiosMock.get.mockResolvedValueOnce({
    data: { greeting: 'hello there' },
  })

  fireEvent.click(getByTestId('fetch-data'))

  const greetingData = await waitForElement(() => getByTestId('show-data'))

  expect(axiosMock.get).toHaveBeenCalledTimes(1)
  expect(axiosMock.get).toHaveBeenCalledWith(url)
  expect(greetingData).toHaveTextContent('hello there')
})

这个测试用例有些不同,因为我们必须处理一个 HTTP 请求。为此,我们必须借助 jest.mock('axios') 模拟axios 请求。

现在,我们可以使用 axiosMock 并对其应用 get() 方法。最后,我们将使用 Jest 的内置函数 mockResolvedValueOnce() 将模拟数据作为参数传递。

对于第二个测试,我们可以单击按钮来获取数据,所以需要使用 async/await 来处理异步请求。现在我们必须保证以下 3 个测试通过:

  • HTTP 请求执行了正确的次数?
  • HTTP请求是否已通过 url 完成?
  • 获取的数据是否符合期望?


对于第一个测试,我们只检查没有数据要显示时是否显示加载消息(loading...)。

到现在为止,我们现在已经完成了 8 个简单步骤来开始测试 React Apps了。


## 推荐阅读 现在的你是否已经感觉入门了呢?请查阅更多文档信息进阶吧,以下是一些推荐阅读:

官方文档

React Testing Library docs
React Testing Library Cheatsheet
Jest DOM matchers cheatsheet
Jest Docs

基础入门

Testing with react-testing-library and Jest


前端自动化测试jest教程1-配置安装
前端自动化测试jest教程2-匹配器matchers
前端自动化测试jest教程3-命令行工具
前端自动化测试jest教程4-异步代码测试
前端自动化测试jest教程5-钩子函数
前端自动化测试jest教程6-mock函数
前端自动化测试jest教程7-定时器测试
前端自动化测试jest教程8-snapshot快照测试

写在最后

React Testing Library 是用于测试 React 组件的出色插件包。它使我们能够访问 jest-dom 的 matcher,我们可以使用它们来更有效地并通过良好实践来测试我们的组件,希望本文对你有所帮助。

感谢您阅读!

这是我的 github/blog,若对你有所帮助,赏个小小的 star 🌟咯~

这样入门 js 抽象语法树(AST),从此我来到了一个新世界

契机

最近在搭建一个开源的项目环境时,我需要打一个 ES 模块的包,以便开发者可以直接通过 npm  就能安装并使用,但是这个项目注定了会有样式,而且我希望打出的包的文件目录和我开发目录是一致的,似乎 Rollup  是一个不错的选择,但是我(自虐般地)选择了 Typescript  自带的编译器 tsc ,然后我就开始我的填坑之旅~

tsc 遇到的坑

在使用 tsc  编译我的代码时,对我目前来说,有三个基本的坑,下面我会对它们进行简单的阐述,在此之前看下即将被编译的目录结构。

|-- src
  |-- assets
    |-- test.png
  |-- util
    |-- classnames.ts
  |-- index.tsx
  |-- index.scss

简化引用路径问题

首先我是在 tsconfig.json  中写了简化引用路径配置的,比如针对以上目录,我是这样:

{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "@Src/*": ["src/*"],
      "@Utils/*": ["src/utils/*"],
      "@Assets/*": ["src/assets/*"]
    }
  }
}

那么无论我层级多深时,我要是想引用 util  或 assets  里面的文件模块、资源就会特别方便,比如我在 index.tsx  文件中这样引入:

编译前:

import classNames from "@Utils/classnames";
import testPNG from "@Assets/test.png";

编译后(预期 😢):

import classNames from "./util/classnames";
import testPNG from "./assets/test.png";

然而实际编译后的结果令我大失所望, tsc  既然连这个都不支持转译!!它编译之后的代码还是老样子,于是我就去找官网查,发现也没有这个相关的配置项,于是跑到外网查了下发现有人是和我遇到了相同的问题的,它提供了一个解决方案就是,使用这个插件 tscpaths 并在编译后多加一段 npm  命令即可:

"scripts": {
  "build": "tsc -p tsconfig.json && tscpaths -p tsconfig.json -s src -o dist,
},

当执行到这个命令时:

tscpaths -p tsconfig.json -s src -o dist

这个插件会去遍历每一个我们已经由 tsc  编译之后的 .js  文件,将我们简化的引用路径转为相对路径,大功告成~

静态资源未打包问题

如上所示,如果我在 index.tsx  文件中引入一个放在 assets  的图片资源:

import testPNG from "@Assets/test.png";

在经过 tsc  编译之后,而且在使用我们的命令行工具之后,我们的引用路径是对了,但是一看打包出来的目录中,是不会出现 assets  这个资源文件夹的,其实这也正常,毕竟 tsc  也仅仅是个 Typescript 的编译器,要实现其它的打包功能,要靠自己动手!

解决问题的办法就是使用 copyfiles 命令行工具,它和上面我们介绍的插件一样,都是在 tsc  编译之后,做一些额外操作达到我们想要的目的。

就像它的名字一样,它就是拿来复制文件的~我们在 npm scripts 下的 build 命令后面再加上这个:

copyfiles -f src/assets/* dist/assets

这样就能把资源文件夹复制到打包之后的文件目录下了。

引入样式文件后缀名问题

我们做一个项目时在所难免会用到 sass  或 less ,本项目就选择了 sass ,我在 index.tsx  中引入样式文件方式如下:

import "./index.scss";

但是在 tsc  编译为 .js  文件之后,打开 index.js  发现引入的样式后缀还是 .scss 。作为给别的开发者使用的包,一定是要引入 .css  文件的格式的,你不可能确定别人用的都是 sass ,所以我又去网上找解决方案,发现很少有人提这个问题,而且也没有找到可以用的插件什么的。

就在一筹莫展之时,我突然想到,卧槽,这不就是类似于上面提到的 tscpaths  这个工具吗,也是在文件内做字符串替换,太像了!于是我赶紧下载了它的源码,看了下大概是使用 node 读取了 tsconfig.json  中 bathUrl  和 paths  配置,以及用户自定义的入口、出口路径来找到 .js  文件,分析成相对路径之后再正则匹配到对应的引用路径去替换掉!

立马有了思路准备实践,突然想到全局正则匹配做替换的局限性,比如在开发者代码中也写了与引用一样的代码(这种情况基本不可能发生,但是仍要考虑),那不是把人家的逻辑代码都改了吗?比如以下代码:

import React from "react";
import "./index.scss";

const Tool = () => {
  return (
    <div>
      <p>You should import style file like this:</p>
      <p>import './index.scss'</p>
    </div>
  );
};

怎么办,你做全局替换,是会替换掉别人逻辑源代码的。。当然,可以写更好的查找算法(或正则)来精确替换,但是无形中考虑的情况就非常多了;我们有没有更好的实现方式呢?这时候我想到了抽象语法树(AST)

注意 ⚠️:另外要说一下, tsc  也不会编译 .scss  文件的,它需要 node-sass  来将每个 .scss  文件编译到对应打包目录,在 tsc  编译之后,再执行以下命令即可:

"build-css": "node-sass -r src -o dist",

AST 是什么?

如果你了解或者使用过 ESLint 、Babel  及 Webpack  这类工具,那么恭喜你,你已经对 AST 的强大之处有了最直观的了解了,比如 ESLint  是怎么修复你的代码的呢?看下面不太严谨的图:

1

不严谨的语言描述就是,eslint 将当前的 js 代码解析成了一个抽象语法树,在这棵树上做了一些修整,比如剪掉一条树枝,就是去除代码中多出的空格 space ;比如修整了一条树枝,就是 var  转换为 const  等。修整完之后再转换为我们的 js 代码!

这个树中的每条“枝”都代表了 js 代码中的某个字段的描述对象,比如以下简单的代码:

const a = 1;

我们先自己定制一套简单的转换为 AST 语法规则,可以这样表示上面这行代码:

{
  "type": "VariableDeclaration",
  "kind": "const",
  "declarations": [
    {
      "type": "VariableDeclarator",
      "id": {
        "type": "Identifier",
        "name": "a"
      },
      "init": {
        "type": "Literal",
        "value": 1,
        "raw": "1"
      }
    }
  ]
}

是的,这就是一颗简易的抽象语法树了,就这么简单,它只是一种特殊的对象结构来表示我们的 js 代码而已,如果我们有一个手段,能拿到表示 1  这个值的节点,并将 init.value  改为 2 ,再将该语法树转换为 js 源码,那就能得到:

const a = 2;

那么上面说的“转换”规则是不用我们自己去写的,随着 JavaScript 语言的发展,由一些大佬创建的项目 ESTree 用于更新 AST 规则,目前已成为社区标准。然后社区中一些其它项目比如 ESlint 和 Babel 就会使用 ESTree 或在此基础上做一些修改,然后衍生出自己的一套规则,并制作相应的转换工具,暴露出一些 API 给开发者使用。

搭配工具

因为生成的 AST 结构上看起来是特别繁杂的,如果没有好用工具或文档,学习时或写代码时会很困扰,那么接下来就给大家介绍三个利器。

在线调试工具 AST Explorer

这是一个非常棒的网站,只需要将你现在的 js 代码输入进去,即可查看转换后的 AST 结构。

有了这个网站你就能实时地去查看解析之后的 AST 是什么样子的,以及它们的类型是什么,这在之后写代码去对 AST 做修改特别有用!因为你可以明确自己想要修改的地方是哪里。

2

比如上图中,我们想要修改 12 ,我们通过某个工具去找到这个 AST 中的 type  为 Literal  这个节点,将其 value  设为 2 ,再转换为 js 代码就实现了这个需求。

类似的工具是很多的,我们就选用 Facebook 官方的开源工具:jscodeshift

AST 转换工具 jscodeshift

jscodeshift 是基于 recast 封装的一个库,相比于 recast 不友好的 api 设计,jscodeshift 将其封装并暴露出对 js 开发者来说更为友好的 api,让我们在操作修改 AST 的时候更加方便。

我建议大家先知道这个工具就行,具体的 api 使用我下面会跟大家挑几个典型的说一说,有个具体的印象就行,说实话,这个库的文档写的并不好,也不适合初学者阅读,特别是英语还不好的人。当你使用过它的一些 api 后有了直观的感觉,再去阅读也不迟~

AST 类型大全 @babel/types

这是一本 AST 类型词典,如果我们想要生成一些新的代码,也就是要生成一些新的节点,按照语法规则,你必须将你要添加的节点类型按照规范传入,比如 const  的类型就为 type: VariableDeclaration ,当然了, type  只是一个节点的一个属性而已,还有其他的,你都可以在这里面查阅到。

下面是常用的节点类型含义对照表,更多的类型大家可以细看 @babel/types

类型名称 中文译名 描述
Program 程序主体 整段代码的主体
VariableDeclaration 变量声明 声明变量,比如 let const var
FunctionDeclaration 函数声明 声明函数,比如 function
ExpressionStatement 表达式语句 通常为调用一个函数,比如 console.log(1)
BlockStatement 块语句 包裹在 {} 内的语句,比如 if (true) { console.log(1) }
BreakStatement 中断语句 通常指 break
ContinueStatement 持续语句 通常指 continue
ReturnStatement 返回语句 通常指 return
SwitchStatement Switch 语句 通常指 switch
IfStatement If 控制流语句 通常指 if (true) {} else {}
Identifier 标识符 标识,比如声明变量语句中 const a = 1 中的 a
ArrayExpression 数组表达式 通常指一个数组,比如 [1, 2, 3]
StringLiteral 字符型字面量 通常指字符串类型的字面量,比如 const a = '1' 中的 '1'
NumericLiteral 数字型字面量 通常指数字类型的字面量,比如 const a = 1 中的 1
ImportDeclaration 引入声明 声明引入,比如 import

AST 节点的增删改查

上面说到了 jscodeshift 的 api 设计的是比较友好的,那么我们就以一个树的增删改查来简单地带大家了解一下,不过在这之前需要先搭建一个简单的开发环境。

开发环境

第一步:创建一个项目文件夹

mkdir ast-demo
cd ast-demo

第二步:项目初始化

npm init -y

第三步:安装 jscodeshift

npm install jscodeshift --save

第四步:新建 4  个 js 文件,分别对应增删该查。

touch create.js delete.js update.js find.js

第五步:在做以下事例时,请大家打开 AST Explorer ,把要转换的 value  都复制进来看看它的树结构,以便更好地理解。

查找节点

find.js :

const jf = require("jscodeshift");

const value = `
import React from 'react';
import { Button } from 'antd';
`;

const root = jf(value);
root
  .find(jf.ImportDeclaration, { source: { value: "antd" } })
  .forEach((path) => {
    console.log(path.node.source.value);
  });

在控制台执行以下命令:

node find.js

然后你就能看到控制台打印了 antd 。

在此说明一下,上面代码中定义的 value  字符串就是我们要操作的文本内容,实际应用中我们一般都是读取文件,然后做处理。

在上面的 .find  函数中,第一个参数为要查找的类型,第二个参数为查询条件,如果你将上面的 value  复制到 AST Explorer 上看看,你就知道这个查询条件为什么是这种结构了。

修改节点

update.js :

const jf = require("jscodeshift");

const value = `
import React from 'react';
import { Button, Input } from 'antd';
`;

const root = jf(value);
root
  .find(jf.ImportDeclaration, { source: { value: "antd" } })
  .forEach((path) => {
    const { specifiers } = path.node;
    specifiers.forEach((spec) => {
      if (spec.imported.name === "Button") {
        spec.imported.name = "Select";
      }
    });
  });

console.log(root.toSource());

上面的代码目的是将从 antd  引入的 Button  改为 Input ,为了很精确地定位在这一行,我们先通过 ImportDeclaration  和条件参数去找到,在向内找到 Button  这个节点,简单的判断之后就可以做修改了。

你能看到最后一行我们执行了 toSource() ,该方法就是将 AST  转回为我们的源码,控制台打印如下:

import React from "react";
import { Select, Input } from "antd"; // 可以看到 Button 已被精确地替换为了 Select

增加节点

create.js :

const jf = require("jscodeshift");

const value = `
import React from 'react';
import { Button, Input } from 'antd';
`;

const root = jf(value);
root
  .find(jf.ImportDeclaration, { source: { value: "antd" } })
  .forEach((path) => {
    const { specifiers } = path.node;
    specifiers.push(jf.importSpecifier(jf.identifier("Select")));
  });

console.log(root.toSource());

上面代码首先仍然是找到 antd  那行,然后在 specifiers  这个数组的最后一位添加一个新的节点,表现在转换后的 js 代码上就是,新增了一个 Select  的引入:

import React from "react";
import { Button, Input, Select } from "antd"; // 可以看到引入了 Select

删除节点

delete.js :

const jf = require("jscodeshift");

const value = `
import React from 'react';
import { Button, Input } from 'antd';
`;

const root = jf(value);
root
  .find(jf.ImportDeclaration, { source: { value: "antd" } })
  .forEach((path) => {
    jf(path).replaceWith("");
  });

console.log(root.toSource());

删除引入 antd  一整行,就是这么简单。

更多 API

上面所实现的增删改查其实都是多种实现方式中的一种而已,只要你对 API 很熟练,或者脑洞够大,那可就谁也拦不住了~这里我只想说,去官方的 collectionextensions 看看你就知道有哪些 API 了,然后多尝试、多动手,总会实现你想要的效果的。

实战解析

技术为需求服务。

明确需求

在对 jscodeshift 有了初步了解之后,我们接下来做一个命令行工具来解决我在上面提出的“引入样式文件后缀名问题”,接下来会简单使用到 commander ,它使 nodejs 命令行接口变得更简单~

我再次明确下我目前的需求:tsc  编译之后的目录,比如 dist ,我要将里面生成的所有 js 文件中关于样式文件的引入,比如 import './style.scss' ,全部转换成以 .css  为后缀的方式。

该命令行工具我给它命名为:tsccss

3 (1)

搭建环境

就像上面一样,我们先初始化项目,因为演示为主,所以我们就不使用 Typescript 了,就写原生 nodejs 原生模块写法,如果对项目要求较高的,也可以加上 ESLint 、 Prettier  等规范代码的工具,如果大家有兴趣,可以前往我在 github 上已经写好了的这个命令行工具 tsccss ,可以做个参考。

好的,现在我们一气呵成,按下面步骤来:

# 创建项目目录
mkdir tsccss
cd tsccss

# 初始化
npm init -y

# 安装依赖包
npm i commander globby jscodeshift --save

# 创建入口文件
mkdir src
cd src
touch index.js

现在目录如下:

|-- node_modules
|-- src
  |-- index.js
|-- package.json

接下来在 package.json  中找个位置加入以下代码:

{
  "main": "src/index.js",
  "bin": {
    "tsccss": "src/index.js"
  },
  "files": ["src"]
}

其中 bin  字段很重要,在其他开发者下载了你这个包之后,人家在 tsccss xxxxxx  时就会以 node 执行后面配置的文件,即 src/index.js ,当然,我们的 index.js  还要在最顶部加上这行代码:

#! /usr/bin/env node

这句代码解决了不同的用户 node 路径不同的问题,可以让系统动态的去查找 node 来执行你的脚本文件。

使用 commander

直接在 index.js  中加入以下代码:

const { program } = require("commander");

program.version("0.0.1").option("-o, --out <path>", "output root path");

program.on("--help", () => {
  console.log(`
  You can add the following commands to npm scripts:
 ------------------------------------------------------
  "compile": "tsccss -o dist"
 ------------------------------------------------------
`);
});

program.parse(process.argv);

const { out } = program.opts();
console.log(out);

if (!out) {
  throw new Error("--out must be specified");
}

接下来在项目根目录下,执行以下控制台命令:

node src/index.js -o dist

你会发现控制台打印了 dist ,是的,就是 -o dist  的作用,简单介绍下 version  和 option 。

  • version

作用:定义命令程序的版本号;
用法示例:.version('0.0.1', '-v, --version') ;
参数解析

  1. 第一个参数,版本号 <必须>;
  2. 第二个参数,自定义标志 <可省略>,默认为 -V 和 --version。
  • option

作用:用于定义命令选项;
用法示例:.option('-n, --name  ', 'edit your name', 'vortesnail');
参数解析

  1. 第一个参数,自定义标志 <必须>,分为长短标识,中间用逗号、竖线或者空格分割;
    (标志后面可跟参数,可以用 <> 或者 [] 修饰,前者意为必须参数,后者意为可选参数)
  2. 第二个参数,选项描述 <省略不报错>,在使用 --help 命令时显示标志描述;
  3. 第三个参数,选项参数默认值,可选。

所以大家还可以试试这两个命令:

node src/index.js --version
node src/index.js --help

读取 dist 下 js 文件

dist  目录是假定我们要去做样式文件后缀名替换的文件根目录,现在需要使用 globby  工具自动读取该目录下的所有 js 文件路径,在顶部需要引入两个函数:

const { resolve } = require("path");
const { sync } = require("globby");

然后在下面继续追加代码:

const outRoot = resolve(process.cwd(), out);

console.log(`tsccss --out ${outRoot}`);

// Read output files
const files = sync(`${outRoot}/**/!(*.d).{ts,tsx,js,jsx}`, {
  dot: true,
}).map((x) => resolve(x));
console.log(files);

files  即 dist  目录下所有 js 文件路径,我们故意在该目录下新建几个任意的 js 文件,再执行下 node src/index.js -o dist ,看看控制台是不是正确打印出了这些文件的绝对路径。

编写替换方法

因为有了前面的增删改查的铺垫,其实现在这一步已经很简单了,思路就是:

  • 找到所有类型为 ImportDeclaration  的节点;
  • 运用正则判断该节点的 source.value  是否以 .scss  或 .less  结尾;
  • 若正则匹配到了,我们就运用正则的一些用法将其后缀替换为 .css 。

就这么简单,我们直接引入 jscodeshift :

const jscodeshift = require("jscodeshift");

然后追加以下代码:

function transToCSS(str) {
  const jf = jscodeshift;
  const root = jf(str);
  root.find(jf.ImportDeclaration).forEach((path) => {
    let value = "";
    if (path && path.node && path.node.source) {
      value = path.node.source.value;
    }
    const regex = /(scss|less)('|"|`)?$/i;
    if (value && regex.test(value.toString())) {
      path.node.source.value = value
        .toString()
        .replace(regex, (_res, _$1, $2) => ($2 ? `css${$2}` : "css"));
    }
  });

  return root.toSource();
}

可以看到,该方法直接返回了转换后的 js 代码,是可以直接写入源文件的内容。

读写文件

拿到文件路径 files  后,需要 node 原生模块 fs  来帮助我们读写文件,这部分代码很简单,思路就是:读 js 文件,将文件内容转换为 AST 做节点值替换,再转为 js 代码,最后写回该文件,就 OK 了。

const { readFileSync, writeFileSync } = require("fs");

// ...

const filesLen = files.length;
for (let i = 0; i < filesLen; i += 1) {
  const file = files[i];
  const content = readFileSync(file, "utf-8");
  const resContent = transToCSS(content);
  writeFileSync(file, resContent, "utf8");
}

现在你到 dist  目录下的 index1.js 、 index2.js  文件中,随便输入以下内容,以便查看效果:

import "style.scss";
import "style.less";
import "style.css";

然后最后一次执行我们的命令:

node src/index.js -o dist

再看刚才的 index1.js  或 index2.js ,是不是全部正确替换了:

import "style.css";
import "style.css";
import "style.css";

舒服了~ 😊

上面的代码还是可以优化很多地方的,比如大家还可以写一些额外的代码来统计替换的位置、数量、文件修改数量等,这些都可以在控制台打印出来,在别人使用时也能得到较好的反馈~甚至替换的正则方法也可以再做改进,看大家的了!

最后想说的

虽然上面的实战是非常简单的一种 AST 用法,但是这篇文章的主要作用就是能带大家入门,利用这种思维去解决工作或学习中遇到的一些问题,在我看来,有了对某方法的事物认知之后,你的解决问题的方式就会无形之中多了一种。其实技术在某种程度来说并不是最重要的,重要的是对技术的认知

毕竟,你不知道某个东西,利用它的想法都不会产生,但是你知道了,无论技术实现再难,也总是可以攻克的!

最后感谢大家能认真读到这里,文章中有错误的地方,欢迎探讨。

本文产出工具:github/tsccss ,欢迎使用,star🌟。
本人博客地址:github/blog ,若此文对你有帮助,赏个 star🌟,谢谢老爷了!

参考文章:
commander
像玩 jQuery 一样玩 AST
jscodeshift 简易教程

从零配置webpack 4+react脚手架(三)

前言:

这一节我们将在脚手架中引入CSS,SASS,LESS,并且实现代码压缩,以及PostCSS的使用。

先让CSS跑起来

新建CSS文件

在我们的 /src 目录下,新建一个文件名为 app.css ,并输入以下代码:

.App {
  height: 200px;
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: lightcoral;
}

h1 {
  font-size: 16px;
  color: #fff;
}

在app.js中引入css

打开 /src/app.js ,添加以下代码:

import './app.css';

配置loader

wbpack只能编译js文件,css文件是无法被识别并编译的,我们需要loader加载器来进行预处理。
首先安装 style-loader 和 css-loader :

npm install --save-dev style-loader css-loader  

注:

  • 遇到后缀为.css的文件,webpack先用css-loader加载器去解析这个文件,遇到“@import”等语句就将相应样式文件引入(所以如果没有css-loader,就没法解析这类语句),最后计算完的css,将会使用style-loader生成一个内容为最终解析完的css代码的style标签,放到head标签里。
  • loader是有顺序的,webpack肯定是先将所有css模块依赖解析完得到计算结果再创建style标签。因此应该把style-loader放在css-loader的前面(webpack loader的执行顺序是从右到左)。

配置module.export.rules

在webpack.prod.config.js中配置:

const merge = require('webpack-merge');
const common = require('./webpack.common.config.js');

const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');

module.exports = merge(common, {
  //...
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [ 
          'style-loader', 
          'css-loader' 
        ]
      }
    ]
  },
  //...
});

现在,执行一下 npm run build ,打开页面,发现样式生效。你再打开控制台看Elements,发现style样式已经插入到了****内。

打包出CSS独立文件

我们可以看到上面,style样式是通过style-loader预处理,插入到了head标签内,但是我们平常写样式的时候,一定是通过引入外部css文件进行样式引入的,那我们怎么做呢?使用插件 mini-css-extract-plugin :

安装 mini-css-extract-plugin

npm install --save-dev mini-css-extract-plugin

引入 mini-css-extract-plugin

webpack.prod.config.js 中引入:

const MiniCssExtractPlugin = require('mini-css-extract-plugin');

配置plugin

module.exports = merge(common, {
  plugins: [
    //...
    new MiniCssExtractPlugin({
      filename: 'css/[name].[hash].css',
      chunkFilename: 'css/[id].[hash].css',
    }),
  ]
});

修改loader

module: {
    rules: [
      {
        test: /\.css$/,
        use: [ 
          MiniCssExtractPlugin.loader,
          'css-loader' 
        ]
      }
    ]
  },

现在你的webpack.prod.config.js应该是下面这样:

const merge = require('webpack-merge');
const common = require('./webpack.common.config.js');

const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = merge(common, {
  mode: 'production',
  output: {
    filename: 'js/[name].[chunkhash:8].bundle.js',
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [ 
          MiniCssExtractPlugin.loader,
          'css-loader' 
        ]
      }
    ]
  },
  optimization: {
    minimizer: [new UglifyJsPlugin()],
    splitChunks: {
      chunks: 'all',
      minSize: 30000,
      maxSize: 0,
      minChunks: 1,
      cacheGroups: {
        framework: {
          priority: 100,
          test: "framework",
          name: "framework",
          enforce: true
        },
        vendors: {
          priority: -10,
          test: /node_modules/,
          name: "vendor",
          enforce: true,
        },
      }
    }
  },
  plugins: [
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: 'public/index.html',
      inject: 'body',
      minify: {
        removeComments: true,
        collapseWhitespace: true,
      },
    }),
    new CleanWebpackPlugin(),
    new MiniCssExtractPlugin({
      filename: 'css/[name].[hash].css',
      chunkFilename: 'css/[id].[hash].css',
    }),
  ]
});

我们再执行一下 npm run build ,查看dist目录,你会发现css文件已经被单独打包出来了。

压缩打包出的CSS文件

我们打开dist目录下打包生成的css文件,你会发现他和我们写的是一模一样的,这意味着该代码没有被压缩,使用插件optimize-css-assets-webpack-plugin来做这项工作。

安装optimize-css-assets-webpack-plugin

npm install --save-dev optimize-css-assets-webpack-plugin

引入optimize-css-assets-webpack-plugin

webpack.prod.config.js 中引入:

const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');

配置minimizer参数

optimization: {
  minimizer: [
    new UglifyJsPlugin(),
    new OptimizeCssAssetsPlugin({
      assetNameRegExp:/\.css$/g,
      cssProcessor:require("cssnano"),
      cssProcessorPluginOptions:{
        preset:['default', { discardComments: { removeAll:true } }]
      },
      canPrint:true
    })
  ],
  //...
}
参数 意义
assetNameRegExp 正则表达式,用于匹配需要优化或者压缩的资源名。默认值是
/.css$/g
cssProcessor 用于压缩和优化CSS 的处理器,默认是 cssnano.
cssProcessorPluginOptions 传递给cssProcessor的插件选项,默认为{}
canPrint 表示插件能够在console中打印信息,默认值是true
discardComments 去除注释

另外,这段配置也是可以放到 plugins 这个属性下进行配置的。
配置完成,执行 npm run build ,查看dist目录下打包出的css文件是不是代码被压缩了!

接着让Less或Sass飞起来

我们写项目的时候没几个人会去写css吧?sass或less对于工作效率的提高是肉眼可见的,但是我们webpack也同样无法理解这种编写方式,那就需要配置loader做预处理,将其转换为css。

安装less-loader,sass-loader

npm install --save-dev less less-loader node-sass sass-loader

配置loader

webpack.prod.config.js 中的 module.rules 内增加两个对象:

module: {
    rules: [
      //...
      {
        test: /\.less$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
          'less-loader'
        ]
      },
      {
        test: /\.(sass|scss)$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
          'sass-loader'
        ]
      },
    ]
  },

我们将 src 目录下的 app.css 改为 app.less 或 app.sass ,在里面改为以下代码:

.App {
  height: 200px;
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: lightcoral;
  h1 {
    font-size: 16px;
    color: #fff;
  }
}

然后修改 app.js 中的引入 import 'app.less' 或 import 'app.sass' 

执行 npm run build 看看是否把less文件或sass文件打包成了dist目录下的css文件

使用PostCSS

postcss 一种对css编译的工具,类似babel对js的处理,常见的功能如:
1 . 使用下一代css语法
2 . 自动补全浏览器前缀
3 . 自动把px代为转换成rem
4 . css 代码压缩等等
postcss 只是一个工具,本身不会对css一顿操作,它通过插件实现功能,autoprefixer 就是其一。

安装postcss

npm install postcss postcss-loader --save-dev

安装postcss某个插件,以Autoprefixer举例

npm install autoprefixer --save-dev

配置postcss.config.js

根目录新建postcss.config.js

module.exports = {
  plugins: [
    require('autoprefixer')({ browsers: ['last 5 version', '>1%', 'ie >=8'] })
  ]
};

设置loader

module.exports = merge(common, {
  //...
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [ 
          MiniCssExtractPlugin.loader,
          'css-loader',
          'postcss-loader'
        ]
      },
      {
        test: /\.less$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
          'postcss-loader',
          'less-loader'
        ]
      },
      {
        test: /\.(scss|sass)$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
          'postcss-loader',
          'sass-loader'
        ]
      },
    ]
  },
  //...
});

执行 npm run build 可以,浏览器打开dist目录下的index.html,控制台看css样式,加上了浏览器前缀:
image.png

我们可以复制一份webpack.prod.config.js中关于rules的配置到webpck.dev.config.js,修改第一个为style-loader,因为我们在开发环境下没必要打包单独文件。

以下是webpck.dev.config.js部分配置

module: {
    rules: [
      {
        test: /\.css$/,
        use: [ 
          'style-loader',
          'css-loader',
          'postcss-loader'
        ]
      },
      {
        test: /\.less$/,
        use: [
          'style-loader',
          'css-loader',
          'postcss-loader',
          'less-loader'
        ]
      },
      {
        test: /\.(scss|sass)$/,
        use: [
          'style-loader',
          'css-loader',
          'postcss-loader',
          'sass-loader'
        ]
      },
    ]
  },

现在执行 npm run start 来启动开发环境。

到此为止,我们已经基本搭起了一个简单的react脚手架,下一节,我们还需要再进行相关配置的优化!很重要!

原型链继承图解

原型链继承详解

前言

在大多数面向对象编程语言中,继承是基于类来实现的,但是在JavaScript中是基于原型链来实现的,何为原型链,今天我们一步一步来解析它。

两个函数对象,父类型和子类型

我们首先来创建两个函数对象,并且希望子类型能继承父类型的某些方法,代码如下,很简单:

<script>
  //父类型
function Supper() {
  this.supProp = "Supper Property";
}

Supper.prototype.showSupperProperty = function() {
  console.log(this.supProp);
}

//子类型
function Sub() {
  this.subProp = "Sub Property";
}

Sub.prototype.showSubProperty = function() {
  console.log(this.subProp);
}

</script>

这段代码会在内存空间中有如图所示的内存分配:
Untitled1.png

想要实现继承,我们根据 实例的隐式原型对象指向构造函数的显示原型对象 可知,要想要Sub的实例能访问Supper原型对象中的showSupperProperty函数对象,必须有一条原型链能指向上图中地址为0x234Object实例对象,我们先假如有一个Supper的实例对象已经创建,在图中体现如下:
Untitled1 (1).png

好了,我们先不急,先创建一个Sub的实例看看,他在内存中如何指向的,在上述代码之后添加

var sub = new Sub();

有如下内存分配:
Untitled1 (2).png

根据上图来说,我们的Sub的实例sub并没有与Object构成原型链,无法访问到Supper原型中的showSupperProperty方法,那我们到底该怎么呢? 

首先我们要清楚一点,sub.__proto__的值是怎么来的,Sub函数对象在定义时就已经有了Sub.prototype,而sub.__proto__是由Sub.prototype传递地址值给它而得到的值。我们要让sub._ ``_proto__`` === (Supper的某一个实例对象)不可以构成原型链了吗,需要下面关键的一句话:

Sub.prototype = new Supper();

这句话需要在子类型创建之后立马写上,为什么?你看之后画的图就知道了。
现在内存图如下:
Untitled1 (3).png

由图已经可得到,有一条完整的原型链,故现在子类型的实例对象可以访问父类型的原型中的方法了。
若我们的

Sub.prototype.showSubProperty = function() {
  console.log(this.subProp);
}

不在Sub.prototype = new Supper();之后,那就等于白白丢掉了。。因为原来所指向的Object对象已经成为了垃圾对象。所以,完整的代码如下:

  <script>
    //父类型
    function Supper() {
      this.supProp = "Supper Property";
    }

    Supper.prototype.showSupperProperty = function() {
      console.log(this.supProp);
    }

    //子类型
    function Sub() {
      this.subProp = "Sub Property";
    }

    //子类型的原型为父类型的一个实例对象
    Sub.prototype = new Supper();
		//让子类型的原型的constructor指向子类型
		Sub.prototype.constructor = Sub;

    Sub.prototype.showSubProperty = function() {
      console.log(this.subProp);
    }

    var sub = new Sub();
    sub.showSupperProperty();
    sub.showSubProperty();

  </script>

得到完整图如下:
Untitled1 (4).png

控制台输出验证了其正确性:

屏幕快照 2019-06-18 21.36.52.png

结语:这就是原型链继承了,图还是要多画多看几遍的,好好理解一下很有帮助。

下面简单给一个组合继承的代码,算是模板代码了,本身是借用构造函数继承(应该不能叫真正意义上的继承)和原型链继承的组合,重点是原型链继承,以上已经详细讲述:

  <script>
    function Person(name, age) {
      this.name = name;
      this.age = age;
    }

    Person.prototype.setName = function(name) {
      this.name = name;
    }

    function Student(name, age, price) {
      Person.call(this, name, age); //为了得到属性
      this.price = price;
    }

    Student.prototype = new Person();//为了能看到父类型的方法
    Student.prototype.constructor = Student;//修正constructor属性

    Student.prototype.setPrice = function(price) {
      this.price = price;
    }

    var stu1 = new Student('Bob', 18, 16000);
    stu1.setName('Jack');
    stu1.setPrice(20000);
    console.log(stu1.name, stu1.age, stu1.price);

  </script>

从零配置webpack 4+react脚手架(四)

前言

经过前三节的学习,我们已经大概能自己配出一个react脚手架了,但是仍有许多配置未完成,比如图片,字体图标的配置,Source Map的配置等,通过前面的学习,我相信你已经能够做到这些简单的配置了,实在还不是很清楚,那我们就往下看吧!

添加图片的loader

file-loader 可以对图片文件进行打包,但是 url-loader 可以实现 file-loader 的所有功能,且能在图片大小限制范围内打包成base64图片插入到js文件中,这样做的好处是什么呢?先一步一步走着!

安装url-loader

这里需要注意,url-loader依赖于file-loader,所有我们两个loder都要安装

npm install file-loader url-loader --save-dev

 

引入url-loader

webpack.common.config.js 中的rules中添加一个新的对象,并输入以下代码:

module: {
  rules: [
    //...
    {
      test: /\.(jpg|png|gif)$/,
      use: {
        loader: 'url-loader',
        options: {
          name: '[name].[ext]',
          outputPath: 'images/',
          limit: 8192,
        },
      }
    }
  ]
}
  • 遇到以jpg,png,gif为后缀的文件,使用url-loader进行预处理;
  • options中的[name].[ext]表示,输出的文件名为 原来的文件名.后缀 ;
  • outputPath是输出到dist目录下的路径,即dist/images/...  ;
  • limit表示,如果你这个图片文件大于8192b,即8kb,那我url-loader就不用,转而去使用file-loader,把图片正常打包成一个单独的图片文件到设置的目录下,若是小于了8kb,那好,我就将图片打包成base64的图片格式插入到bundle.js文件中,这样做的好处是,减少了http请求,但是如果文件过大,js文件也会过大,得不偿失,这是为什么有limit的原因!

接下来就是测试下可以不可以用了,在 src 目录下新建一个文件夹: images ,并导入一个图片文件,名为 background.png ,图片文件点我下载

然后在 app.js 中写如下代码:

import React from 'react';
import './app.less';
import background from './images/background.png';

function App() {
  return (
    <div className="app">
      <h1 className="text">Hello Webpack</h1>
      <img className="background" src={background} alt=""/>
    </div>
  );
}

export default App;

app.less 中写如下代码:

.app {
  height: 200px;
  display: flex;
  justify-content: center;
  align-items: center;
  position: relative;
  overflow: hidden;
  .text {
    font-size: 20px;
    color: lightseagreen;
  }
  .background {
    position: absolute;
    width: 100%;
    left: 0;
    top: -124px;
    z-index: -1;
  }
}

执行 npm run build ,你去dist目录下看看是不是多了一个images/background.png,这是因为我们的文件有300多kb,远远超出了我们设定的8kb,如果你在limit设置为:819200,你再重新编译一次,你会发现这个图片文件没有被打包出来,因为它以base64格式图片导入到了bundle.js中。

你可以看看index.html是啥样子的!~~

添加字体图标loader

字体图标需要我们之前已经安装过的 file-loader ,配置非常简单,但是具体操作还是得给你讲明白一点

安装file-loader

如果你不确定自己是否安装,在package.json中看看有没有依赖项

引入file-loader

webpack.common.config.js 中添加以下代码:

module: {
    rules: [
      //...
      {
        test: /\.(eot|ttf|svg|woff|woff2)$/,
        use: {
          loader: 'file-loader',
          options: {
            name: '[name]_[hash].[ext]',
            outputPath: 'font/'
          }
        }
      }
    ]
  }

将iconfon图标导入项目

我们先在 src 目录下新建一个文件夹: font 。
然后我们去iconfont官网找几个图标,(若没有注册,先注册再添加至新项目)比如我添加了一个 爱心 图标至我的webpack-demo项目,点击下载至本地:
image.png
找到该文件夹,把 eot\svg\ttf\woff\woff2 为后缀的文件全部剪切进我们新建的 font 文件夹中,把 iconfont.css 文件中的代码复制到我们的 app.less 中,但因为我们的几个字体文件放到了font文件夹,我们需要更改url:

@font-face {font-family: "iconfont";
  src: url('./font/iconfont.eot?t=1571041571943'); /* IE9 */
  src: url('./font/iconfont.eot?t=1571041571943#iefix') format('embedded-opentype'), /* IE6-IE8 */
  url('data:application/x-font-woff2;charset=utf-8;base64,d09GMgABAAAAAALIAAsAAAAABnQAAAJ6AAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHEIGVgCCcAp8gREBNgIkAwgLBgAEIAWEbQcyG7kFyJ6aPIEICihKRGQ5DgwQD//t9799ZubdP980uSRtnk1XN2B1CJEEsVGaheKZ/P5vrf3FtfHmPeqdN9STytzuzIrPDe7JpBEKIRGKNdNGIpKpWPGkkqoy4HL8XwEFMg8ot7U2/aImBRhYGuhYgyIrk9Qbxi54gccJdJnkIZw0tHVCksIeF4h3qowhqZBUFFapQtOwtLUpfBavpo/pFsCn6PvxD0JRSVqZzTp/qpeg6ue5MyIvthcN5hVLy/nBVpGxDhTiurF4KlYwYayuYpxZcCx68PO8/R+4xVH/PNGwqT1gHCqfSeWpH5WaQEl654C9SVeShrJnCh57/Hb/PLnaKZWf3mwfCRdfG4Ktlo5THx5ePGjOhWEZp8W/G9KHwMwXLClCjqgd7WX1FB3skhz7Rz3ryKmpyQp/ov+sVgvdPUJ14pkqgPZC+csrBAQfHgfeRzLL/mpqAT+uK6bqYWFQdx4KflJNMwhA+c81djEVdFADydyksQldutAGFB3yOdU6ekfVUI3eV7jDRIasmieKYB0t3bbQqPbRZU3j6m7DmFhROrFqDSAMuETS5wuyAXdEEXygZcwvGgOR0eU2crfsthivKbWMYE4kFJlCqmhb1PXwlJjeTuQxA7O8JhJ1ExZVMyjoC5QrjcQibIkZ0XE5xDlFlNkmagCnEcOwkcNsjYjcp3DulPj9tOlNPtE2IcUxhsA4QoJETEFUIpuFBt25lMrn2xGyMQaMdTTV5bsRTJTaPxLkExhANBqtQU2P8krUOFkIx1EIxdhMSAMYRBgMNsRpHqQhRJyPMiHvKOF3OtGhRt/2ZvMHKlgbti2Fw8nqe9XCqu8AAAAAAA==') format('woff2'),
  url('./font/iconfont.woff?t=1571041571943') format('woff'),
  url('./font/iconfont.ttf?t=1571041571943') format('truetype'), /* chrome, firefox, opera, Safari, Android, iOS 4.2+ */
  url('./font/iconfont.svg?t=1571041571943#iconfont') format('svg'); /* iOS 4.1- */
}

.iconfont {
  font-family: "iconfont" !important;
  font-size: 16px;
  font-style: normal;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

.icon-xinaixin:before {
  content: "\e69b";
}


.app {
  height: 200px;
  display: flex;
  justify-content: center;
  align-items: center;
  position: relative;
  overflow: hidden;
  .text {
    font-size: 20px;
    color: lightseagreen;
  }
  .background {
    position: absolute;
    width: 100%;
    left: 0;
    top: -124px;
    z-index: -1;
  }
}

然后在 app.js 中使用:

import React from 'react';
import './app.less';
import background from './images/background.png';

function App() {
  return (
    <div className="app">
      <h1 className="text">Hello Webpack<i className="iconfont">&#xe69b;</i></h1>
      <img className="background" src={background} alt=""/>
    </div>
  );
}

export default App;

这时候你再打包,回到页面看看是不是我们的图标正确显示了。
我的html文件内容如下:
image.png

配置source-map

source-map是干嘛用的?我们先来修改以下 app.js 中的代码,故意给它制造一个错误:

import React from 'react';
import './app.less';
import background from './images/background.png';

function App() {
  return (
    <div className="app">
      <h1 className="text">Hello Webpack<i className="iconfont">&#xe69b;</i></h1>
      <img className="background" src={background} alt=""/>
      {
        consele.log("I cannot print to console!")
      }
    </div>
  );
}

export default App;

我们增加了一个控制台打印输出语句,如果正常的话我们会在控制台中看到打印输出:I cannot print to console!
但是我们把console.log故意制造了语法错误,写成consele.log。这个时候我们去控制台查看:
image.png
它的错误提示是我们的打包文件bundle.js,这是打包之后的文件,我们想知道的是我们源码文件的错误地方,不然你还要通过查看打包文件的错误,回溯到我们源码的错误地方,特别麻烦,那有没有一个方法能让我们控制台直接提示的是源码错误出处呢?答案就是 source-map 

它的配置非常简单,只需要在 webpack.common.config.js 中增加一个 devtool 属性即可!

module.exports = {
  devtool: 'cheap-module-eval-source-map',
	//...
}

这里为什么是 cheap-module-eval-source-map ,你可查阅这个文档:devtool

然后我们再打包一次,这次去控制台看看,它的错误提示是不是我们源码位置了:
image.png

ps:右边的错误提示不是再app.js是因为我们定义了两个入口文件,可能会有相互依赖的关系,这里我也不是很清楚,知道的同学可以交流下。

这一节结束,我们这系列文章就算是结束了,其实还有很多可以优化的手段,比如建立单独的配置文件,让我们不用手动去找webpack配置进行修改啦;比如懒加载(lazy-loading)啦。。。这些大家有兴趣可需求可自行了解,这个系列文章主要和大家一起进行一些简单的配置,快速上手。

结语:花了大概四天完成这个系列的文章,作为自己的一个记录,也希望能帮到像我一样的新手,webpack的学习还任重道远,与大家共勉!

如何画一个只能在已显示区域进行事件绑定的三角形

如何画一个只能在已显示区域进行事件绑定的三角形

前言: 这个问题是去一家公司面试的时候,技术经理问到我的,我第一想法是用CSS中的border去画,但是我们现在的要求是在那个三角形区域,才能进行事件的绑定,比如:click事件,那border所用到的transparent仅仅是透明度为0,那个区域还是在的,这就无法完成特定区域的事件绑定。

1.先来试试border方法到底行不行吧!

Html:

<div className="triangle"></div>

Css:

.triangle {
  width: 0px;
  height: 0px;
  border-left: 50px solid yellow;
  border-right: 50px solid blue;
  border-top: 50px solid red;
  border-bottom: 50px solid black;
}

现在我们可以看到如下:
image.png
我们就选一个指向右边的箭头吧,修改Css样式:
Css:

.triangle {
  width: 0px;
  height: 0px;
  border-left: 50px solid yellow;
  border-right: 50px solid transparent;
  border-top: 50px solid transparent;
  border-bottom: 50px solid transparent;
}

现在我们得到了想要的三角形(具体形状自行调整border-size就行):
image.png

接下来,我们给这个 div 添加一个 onmouseover 事件:

const triangle = document.querySelector('.triangle');
triangle.addEventListener('mouseover', () => {
  console.log('你碰到我了!');
})

结果我们去测试的时候,不出所料地,透明的部分也会触发事件,如下所示:

1.gif

那可咋办呢??

2.换个思路,用遮罩!

这个思路很简单,但是面试官坐在面前的时候,却怎么也想不出来。。

step1: 我们先画三个矩形:

Html:

<div class="rectangle-1"></div>
<div class="rectangle-2"></div>
<div class="rectangle-3"></div>

Css:

.reactangle-container {
  position: relative;
}

.rectangle-mask1,
.rectangle-mask2 {
  width: 200px;
  height: 80px;
  position: absolute;
}

.rectangle-mask1 {
  top: 11px;
  left: -5px;
  transform: rotate(50deg);
  background-color: lightblue;
  z-index: 2;
}

.rectangle-mask2 {
  bottom: 11px;
  left: -5px;
  transform: rotate(-50deg);
  background-color: lightgreen;
  z-index: 2;
}

.rectangle-tri {
  width: 100px;
  height: 100px;
  background-color: lightcoral;
  z-index: 1;
}

得到如下图:
image.png
其中粉红的区域就是我们要的三角形区域,理论上你可以通过两个或许多个矩形构建出你想要的任何三角形!

step2: 进行绑定

那这个时候,我们怎么绑定事件到这个三角形上呢?
思路:rectangle-tri 这个矩形绑定事件,也给 rectangle-mask1 和 rectangle-mask2 绑定事件,但是不做操作,这样的话,因为rectangle-tri 在最下面,移动非三角形区域的时候,触发的事件监听是另外两个遮罩矩形的事件。

const reactangleContainer = document.querySelector('.reactangle-container');
const rectangleMask1 = reactangleContainer.querySelector('.rectangle-mask1');
const rectangleMask2 = reactangleContainer.querySelector('.rectangle-mask2');
const rectangleTri = reactangleContainer.querySelector('.rectangle-tri');

rectangleTri.addEventListener('mouseover', () => {
  console.log('你碰到我了!');
})

rectangleMask1.addEventListener('mouseover', () => {
	// 不做任何行为
})

rectangleMask2.addEventListener('mouseover', () => {
	// 不做任何行为
})

此时,特定的区域才有事件绑定了:
2.gif

现在,我们怎么把这两个遮罩矩形去掉呢????设置背景色为白色?那就是自欺欺人

step3: 只显示三角形

这里我真的还找不到方法啊,有哪位大佬能来说说吗???

3.用CSS3中的clip-path试试吧!

clip-path CSS 属性可以创建一个只有元素的部分区域可以显示的剪切区域。区域内的部分显示,区域外的隐藏。剪切区域是被引用内嵌的URL定义的路径或者外部svg的路径,或者作为一个形状例如circle().。clip-path属性代替了现在已经弃用的剪切 clip属性。

上看的引用出自MDN,我们只需要知道,这个属性可以让我们裁剪出想要的形状

基本形状裁剪:

.clip-me {
  clip-path: inset();
  clip-path: circle();
  clip-path: ellipse();
  clip-path: polygon();
}

其中inset是矩形的剪切,circle是圆形的剪切,ellipse是椭圆的剪切,polygon是多边形的剪切。对于我们想要把矩形剪切成三角形,应该使用polygon这个语法。
Html:

<div class="triangle"></div>

Css:

.triangle {
  width: 100px;
  height: 100px;
  background-color: lightcoral;
  clip-path: polygon(0px 0px, 0px 100px, 100px 50px);
}

clip-path: polygon(0px 0px, 0px 100px, 100px 50px); 这句话意思是,根据坐标轴上确定的三个点来进行裁剪:(0px, 0px),(0px 100px),(100px, 50px) ,这三个点构成了一个我们如下三角形:
image.png

现在我们可以来试试绑定事件好不好用来:

const tri = document.querySelector('.triangle');
tri.addEventListener('mouseover', () => {
  console.log("你碰到我了!");
})

以下为测试结果:
3.gif

我只能说,这个真是太好用了,不过这个属性兼容性会有些不好,酌情使用把!

部署阿里云云函数舒畅无阻的请求 Github OpenApi 实现 OAuth2.0 登录

在自己建站的过程中,想要实现登录功能其实并不是一个简单的活儿,它的主要难点不在于技术实现,而是难在独立开发者往往没有太多资金投入。

举个例子,如果我现在要实现一个手机号码短信登录,先不考虑用户意愿问题,我们需要购买的第一个第三方服务就是短信发送的服务,每发送一条短信验证码,都是真金白银的钱,为了防止被人恶意刷量,还要购买行为验证码(或风控相关的)服务,这背后要多少钱大家可以自行了解下。

换个思路,我们支持微信登录好了,结果一查要注册公司盖章提交审核,才能使用微信的第三方登录功能,还要每年交个 300 元。所以可选择的不多,Github OAuth 登录是我的首选项,因为大多数开发者都会有自己的 Github 账号,而 Github OAuth 登录又没有类似微信的各种限制。

不过问题是,Github 登录过程中要调用它的 OpenApi,如果你和我一样买的是大陆的云服务器,会经常超时,这非常令人头疼,修改 host 什么的根本卵用没有,买国外的服务器又死贵。不过好在阿里云的 FC(云函数)产品还是挺便宜的,接下来就介绍下如何利用云函数调用 OpenApi,而国内服务器又能调该云函数获取返回结果。

前提

因为我购买的是阿里云的 FC 产品,所以你最好也是和我一样,有一个阿里云账号,如果你还没购买,新用户可以试用三个月。

打开控制台

找到产品函数计算 FC,点击【管理控制台】。

进入之后,你会看到账号下的基础信息。

创建服务

找到左边菜单的【服务及函数】按钮,点击跳转后,将注意力移到最上方,地理位置选择美区(只要不是大陆地区就行),非常重要!

接着我们点击【创建服务】,名称随便填写,最好和我们要实现的功能相近。打开【高级选项】,我们要选择一个角色,没有的话,点下面创建一个就行。

创建函数

服务创建完成后,我们就可以开始创建云函数,点击【创建函数】。

选择【使用自定义运行时创建】,函数名称写一个比较贴近函数功能的名字,请求处理程序类型选择【处理 HTTP 请求】,运行环境推荐选比较稳定的 Node.js 16,代码上传方式我选择的是【使用示例代码】,这样我们可以知道如何写一个 Hello World 级别的云函数,并正确调用后慢慢实现我们的功能函数。

其它的选项我们都可以先使用默认的,点击底部【创建】按钮完成创建。

调用云函数

创建完成后,来到这一个选项卡【触发器管理(URL)】,就可以看到我们的访问地址了。

在选项卡【函数代码】中可以直接编写我们的接口,【测试函数】能够进行接口调用测试,都是很实用的,这里我们先打开【函数代码】看看写了啥。

可以看到我们的接口代码,使用 Apifox 调用下这个接口测试下。

返回正确,证明我们的云函数部署成功了。接下来就可以大施拳脚改造我们的请求接口,实现 Github Openapi 的调用了。

比如我主要是实现 OAuth2.0 登录,代码如下:

const express = require('express');
const bodyParser = require('body-parser');
const axios = require('axios')

const app = express();

app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.use(bodyParser.raw());

const port = 9000

app.get('/oauth/access_token', async (req, res) => {
  let errorMsg
  let tokenResponse = {}

  try {
    const { client_id, client_secret, code } = req.query
    tokenResponse = await axios({
      method: 'post',
      url:
        'https://github.com/login/oauth/access_token?' +
        `client_id=${client_id}&` +
        `client_secret=${client_secret}&` +
        `code=${code}`,
      timeout: 6000,
      headers: {
        accept: 'application/json',
      },
    })
  } catch (error) {
    errorMsg = error
  }

  res.send(errorMsg ? {
    errorMsg
  } : {
    ...tokenResponse.data
  })
})

app.get('/oauth/user_info', async (req, res) => {
  let errorMsg
  let githubUserInfo = {}

  try {
    const { access_token } = req.query
    githubUserInfo = await axios({
      method: 'get',
      url: 'https://api.github.com/user',
      timeout: 6000,
      headers: {
        accept: 'application/json',
        Authorization: `token ${access_token}`,
      },
    })
  } catch (error) {
    errorMsg = error
  }

  res.send(errorMsg ? {
    errorMsg
  } : {
    ...githubUserInfo.data
  })
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})

上面我写了两个接口,没在同一个接口实现两个接口的请求是因为会 401,但是分开就可以,这里非常奇怪,我也不知道是哪里问题,非常奇怪。

最后

现在你就可以在自己服务端去调这些你写好的接口了,比如我的调用如下:

async oauthGithubLogin(oauthGithubLoginDto: OAuthGithubLoginDto) {
  const { code } = oauthGithubLoginDto

  let tokenResponse: any = {}
  let githubUserInfo: any = {}

  try {
    tokenResponse = await axios({
      method: 'get',
      url:
        'https://github-auth-api-github-openapi-phktxmgeeb.us-east-1.fcapp.run/oauth/access_token?' +
        `client_id=${OAUTH_GITHUB_CLIENT_ID}&` +
        `client_secret=${OAUTH_GITHUB_CLIENT_SECRET}&` +
        `code=${code}`,
      timeout: 6000,
    })
  } catch (error) {
    throw new RequestTimeoutException('Request github openapi timed out.')
  }

  try {
    const { access_token: accessToken } = tokenResponse.data || {}
    githubUserInfo = await axios({
      method: 'get',
      url: 'https://github-auth-api-github-openapi-phktxmgeeb.us-east-1.fcapp.run/oauth/user_info?' + `access_token=${accessToken}`,
      timeout: 6000,
    })
  } catch (error) {
    throw new RequestTimeoutException('Request github openapi timed out.')
  }

  console.log(githubUserInfo)
  // 后续逻辑
}

好了,终于可以愉快调用 Github 的 OpenApi 了,整就一个舒畅。

弹幕的常规设计与实现

引言

在 2022 年的今天,弹幕在国内的各大视频网站已经成为了一个最基本的评论交互形式,它为视频社交增添了很大的活力,人是渴望交流且拥有共情能力的物种,对于同一个视频某一个时间节点,不同的人可以在弹幕中看到与自己有相同看法或有趣的评论,这无形中增加了视频观看者的共同参与感。

回想一下没有弹幕的过去,我们对某个情节有讨论或看法时,只能暂停视频,来到评论区,敲下几个字再回去播放视频。这种视频与用户的割裂感问题在弹幕出现后得到了很好的解决。

本文会介绍下弹幕是什么以及相比传统的聊天室(滚动评论)模式有哪些优点,之后会以一个较为简单的思路给大家说说如何在 web 端开发我们自己的弹幕系统,包括滚动弹幕顶部固定弹幕底部固定弹幕

弹幕简介

弹幕的读音为 dàn mù,因为大量评论在视频上方滚动时很像飞行射击游戏里的“弹幕”,所以国人命名如此,而在日语中被称为 danmaku ,注意了,不是 danmuku

弹幕的发明者具体到个人不是很清楚,这种评论形式最初是在日本的线上影片分享网站 Niconico 动画 出现,后来被 AcFun 引进,再后来大家都知道了,Bilibili 出现后将这种形式发扬光大,可以说弹幕成就了B站,B站也发扬了弹幕这种形式。

现如今国内各大视频平台,发弹幕已经成为了最基本的功能了,不过就我目前体验来看,还是B站的弹幕花样最多,但实际上对于腾讯视频、爱奇艺这种偏影视的视频网站来说,也不需要多少花样,不然适得其反。

弹幕得以发展的原因

在弹幕这种评论形式出现之前,对于在线视频的用户,他们之间的实时交流方式主要是聊天室模式,用户输入文本内容后,文本列表将整体从下向上滚动,如下图:

1

而在弹幕出现之后,用户输入文本内容后,文本将出现在视频右侧,在独立的轨道中从右向左移动,如下图:

2

两者之间各有各的优势,不过就现在用户的观看习惯来说,弹幕带来的用户体验是比聊天室模式要好的,接下来介绍下弹幕都有哪些优势吧~

评论同屏密度

与聊天室模式相比,弹幕模式有更宽的展示区域,毕竟用户观看的信息主体在视频内容,没有哪个网站会把聊天框的宽度设置的比视频宽度还大的。同一条评论,因为弹幕是横向轨道内移动,所以不会像聊天室模式下由于句子过长而导致这行而占据更多高度。所以同屏下,对于人眼来说,弹幕模式比聊天室模式接收到的信息更多。

3

评论更新频率

在聊天室模式中,所有评论都是以相同的频率向上滚动,一条评论的出现会将所有评论向上顶,而弹幕模式下每条评论都在独立的轨道中移动,并不受其他评论的出现所影响,可以通过算法保障每条评论在屏幕内的展示时长。

4

视线移动适应性

在聊天室模式中,用户如果关注视频内容则无法阅读评论。而弹幕模式通过把文字覆盖于视频画面之上让用户可以同时阅读评论与观看视频,无需视线在两个区域间往返移动,有更好的沉浸体验。

以下是聊天室模式下,我们的视线移动方向示意图:

5

以下是弹幕模式下,我们的视线聚焦范围示意图:

6

阅读习惯

我们大多数人(除了像阿拉伯这种国家的人民)阅读习惯是从左到右从上到下,因此人们养成了横向阅读单行信息的习惯。

在弹幕模式下文字从右向左移动,人从左向右阅读,形成从左向右的合力,在这种模式下我们可以用较短时间就能理解文字的含义。

以下是弹幕模式下,视觉的合力方向示意图:

7

而在聊天室模式下人从左到右阅读,而阅读中的文字则在不停的向上移动,形成一个倾斜向上的合力,这会对快速阅读产生障碍。

以下是聊天室模式下,视觉的合力方向示意图:

8

心理因素

弹幕的出现能使多个用户对同一视频时间点于不同的时空发表看法,有一种跨越时空交流的感觉,极大的增强了用户的参与感。

在观看视频的某一时间节点,在弹幕上能看到很多与自己相同的观点,会觉得这个世界上有很多和你一样想法的人,会有一种共鸣感。另外,有时候还会看到很有意思的弹幕,惹的人会心一笑,还会看到一些很有哲理、对自己的知识扩展也很有帮助的弹幕。

弹幕的实现方式

在现在市面上,弹幕的实现可以分为两种方式,一个是 HTML+CSS 方式,另一个是 Canvas 方式。

前者实现能很方便地给每条弹幕添加事件,比如我们常用的移到弹幕上悬停并弹框跳出选项,这得益于原生的 DOM 事件很容易做到。而后者实现需要自己去写一套事件机制,对于像我这种对 Canvas 不太熟的前端,就比较麻烦了,不过愿意花个几天时间去搞一搞倒也不是什么问题。

9

两者在性能上还是有区别的,结论是 HTML+CSS 的性能是没有 Canvas 实现好的,前者会在页面下创建非常多的 DOM 节点,当同屏弹幕过多导致出现大量 DOM 节点时,对于一些“老机器”说不准都能卡死。所以大家会看到,在直播时,对性能要求很高,比如很多视频网站直播就会采用 Canvas 的实现方式去创建弹幕。

接下来我们的代码实操采用 HTML+CSS 方式实现,对 Canvas 感兴趣的,按照相同的设计思路也是能写出来的。

弹幕的设计

我们开始对弹幕的实现做一个功能上的设计,在后面代码实操之前让大家有一个初步的设计思路,更容易理解代码的意思。

首先我们看下一个视频中常规的弹幕画面:

10

其中我使用“红线”标记出来的就是每轮弹幕所滚动的区域,我们称这个区域为轨道,图中又画了我们常见的滚动弹幕底部弹幕,当然,为了图简洁点,顶部弹幕我就不画了。

目前我们能确定的信息是,弹幕系统需要一个轨道的承载逻辑,同时背后还需要一个指挥官来决定每一条新加入的弹幕是去往哪个轨道,什么时候开始渲染弹幕,以及动画逻辑。

轨道

从弹幕的呈现效果可以很明显得知,一个轨道内有若干的弹幕,而且弹幕的出现是依次进行的,这意味着需要一个容器来把这些弹幕装进去,适合的时机再一条条放出来。为了让指挥官能计算出这个时机,我们还需要一个变量 offset 来表示轨道已占据的宽度

根据上面的简单分析,我们可以先定义好表示这个轨道的类和它所需要的属性:

class Track<T extends Danmu> {
  danmus: T[] = [];         // 弹幕数组
  offset = 0;               // 轨道已占据的宽度
}

容器我们有了 danmus 这个数组,但是如何添加、删除弹幕呢?那就要定义方法了:

class Track<T extends Danmu> {
  danmus: T[] = [];         // 弹幕数组
  offset = 0;               // 轨道已占据的宽度

  push(...items: T[]) {};   // 添加弹幕

  remove(index: number) {}; // 删除弹幕

  reset() {};               // 重置弹幕数组及轨道已占据的宽度
}

最后还有一个特别重要的方法是用于更新轨道已占据的宽度,在后面会说到渲染弹幕动画的时候,弹幕进入轨道的时机是由这个所占据轨道的宽度来计算的,所以弹幕的每次移动,我们都需要去对轨道的所占据宽度进行更新。

class Track<T extends Danmu> {
  // ...
  updateOffset() {};        // 更新弹幕已占据的宽度
}

这就是整个轨道的设计,非常简洁明了,它的职责非常清晰:管理轨道内弹幕的增加、删除及已占据宽度的更新,但是不负责渲染不负责渲染不负责渲染

⚠️ 看到这里如果有心情仔细了解逻辑代码是怎么样的,可以点击这个仓库查看源码:qier-player-danmaku

指挥官

由前文可知,我们一个视频画布内是有多条轨道的,那么如何管理这些轨道呢?那就是背后的指挥官在“负重前行”了。

首先是一种指挥官管理了当前弹幕类型下的 2N 个轨道:

11

而我们这次的弹幕设计中包括了滚动弹幕顶部固定弹幕底部固定弹幕,所以对于指挥官我们又有了以下层级关系:

12

然而这些指挥官是有共同的属性及方法的,我们可以抽象成一个指挥官基类(BaseCommander),简单的代码结构如下:

export default abstract class BaseCommander<T extends Danmu> {
  protected tracks: Track<T>[] = [];  // 轨道数组
  waitingQueue: T[] = [];             // 等待队列

  constructor(config: Commander) {
    super();        
    for (let i = 0; i < config.trackCnt; ++i) {
      this.tracks[i] = new Track();
    }
  }

  abstract add(danmu: T): boolean;    // 创建弹幕实例并添加弹幕到等待队列
  abstract findTrack(): number;       // 寻找合适的轨道
  abstract extractDanmu(): void;      // 从等待队列中抽取弹幕并放入弹幕
  abstract render(): void;            // 渲染函数
  abstract reset(): void;             // 重置清空
}

有了指挥官基类,我们就可以去分别实现各种类型的指挥官了,在这里我不想把指挥官的实现代码贴出来,放一张思路图,如果有兴趣的,可以再去阅读源码:qier-player-danmaku

13

render

上面的图除了“用户发送弹幕”这一步,其它所有步骤是我们的核心实现 render ,所以我们来对此方法做一个剖析。它的工作职责为下图:

14

从等待队列中抽取合适的弹幕放入轨道

每一个指挥官(Commander)都有自己的等待队列 waitingQueue,里面存放的是所有还未被渲染到画布上的弹幕,每次在 render 方法执行时,要把等待队列中的弹幕添加到合适的轨道上去,这个过程由 extractDanmu 方法实现:

extractDanmu(): void {
  let isAdded: boolean;
  for (let i = 0; i < this.waitingQueue.length; ) {
    isAdded = this.add(this.waitingQueue[i]);
    // 若有一次无法添加成功,说明无轨道可用,终止剩余弹幕的 add 尝试
    if (!isAdded) {
      break;
    }
    this.waitingQueue.shift();
  }
}

这里的**是,遍历等待队列中的弹幕,尝试将其通过 add 方法添加到轨道上,如果添加成功,将该弹幕从等待队列中删掉,进行后面的弹幕的添加。

但是遍历过程中若出现了一次添加失败,证明所有轨道都没有办法再添加新的弹幕了,我们就要停止遍历。

add 方法的作用即为将弹幕添加到合适的轨道上,而实现这一目的还需要以下过程:

  1. 找到合适的轨道;

  2. 创建真实的弹幕 DOM 并计算速度;

  3. 将其推入要被加入的轨道的 danmus 数组中,计算弹幕的 offset

另外,add 是继承指挥官基类的抽象方法的具体实现,这里我们以最复杂的滚动弹幕作为实现举例:

add(danmu: RollingDanmu): boolean {
  const trackId = this.findTrack();
  if (trackId === -1) {
    return false;
  }

  const { text, color, size, offset } = danmu;
  const danmuDom = createDanmu(text, color, size, this.trackHeight, offset);
  this.el.appendChild(danmuDom);
  const width = danmuDom.offsetWidth;

  // 根据追及问题,计算弹幕的速度
  const track = this.tracks[trackId];
  const trackOffset = track.offset;
  const trackWidth = this.trackWidth;
  let speed: number;
  if (isEmptyArray(track.danmus)) {
    speed = this.defaultSpeed * this.randomSpeed;
  } else {
    const { speed: preSpeed } = getArrayLast<RollingDanmu>(track.danmus);
    speed = (trackWidth * preSpeed) / trackOffset;
  }
  // 防止速度过快一闪而过,最大值只能为平均速度的 2 倍
  speed = Math.min(speed, this.defaultSpeed * 2);
  const normalizedDanmu = { ...danmu, offset: trackWidth, speed, width };
  track.push(normalizedDanmu);
  track.offset = trackWidth + normalizedDanmu.width * 1.2;

  return true;
}

上面代码中第一行就是 findTrack 方法,目的就是找到合适的轨道,如何找到合适的轨道呢?

findTrack(): number {
  const failCode = -1;
  let idx = failCode;
  let max = -Infinity;
  this.each((track, index) => {
    const trackOffset = track.offset;
    if (trackOffset > this.trackWidth) {
      return failCode;
    }
    const t = this.trackWidth - trackOffset;
    // 策略为找到剩余空间最大的轨道进行插入
    if (t > max) {
      idx = index;
      max = t;
    }
  });
  return idx;
}

这里采取的实现策略还是比较直观简单的,从上往下遍历轨道,找到第一个能插入的轨道,

15

找到合适的轨道就返回其下标,继续执行后面逻辑,没有就返回 false

接下来我们探索下怎么计算弹幕的速度,这里弹幕的速度看产品喜好,下面介绍两种:

  1. 每个弹幕的速度都是相同的,所以也就不存在碰撞问题,但是效果非常死板。

  2. 每个弹幕的速度都是不一样的,但是需要解决碰撞问题。。

为了实现不同的速度,最简单有效的方式其实就是通过追及问题求出弹幕的最大速度。

16

假设现在轨道长度为 L ,轨道上已存在的最后一个弹幕A已经飞过了距离 S ,其速度已知是 vA ,那么如何计算弹幕B的速度呢?

首先我们假设弹幕B弹幕A要在同一时间达到轨道的终点,就会得到以下的等式:

17

于是见很简单的推理出了弹幕B的速度为 vB ,转换为我们代码里面的变量名就是后面红色字的等式。

但是这样会有一个问题就是,假入弹幕A已经快到了轨道终点了,这样就会造成计算出的弹幕B的速度过大,具体表现即为弹幕飞的很快一闪而过,这种体验是很差的,所以我们需要有一个最大速度的限制,在上面 add 方法中有这么一行代码就是这个作用:

speed = Math.min(speed, this.defaultSpeed * 2);

这里 defaultSpped 大小为平均速度,所以这里的限制即为平均速度的 2 倍。

后面就是将创建好的弹幕 DOM 放到指定的轨道中的 danmus 数组等待渲染即可。

遍历轨道数组,依次渲染轨道中的弹幕

放出 render 方法的实现,我们主要经历以下四个过程:

  1. 遍历每个轨道中的每个弹幕;

  2. 获取弹幕 DOM 并计算 translateX

  3. 更新轨道的偏移量 offset

  4. 移除超出画布的弹幕。

render(): void {
  this.extractDanmu();
  const objToElm = this.objToElm;
  const trackHeight = this.trackHeight;
  this.each((track, trackIdx) => {
    let shouldRemove = false;
    let removeIndex = -1;
    track.each((danmu, danmuIdx) => {
      if (!objToElm.has(danmu)) {
        return;
      }
      if (danmu.static) {
        return;
      }
      const danmuDom = objToElm.get(danmu)!;
      const offset = danmu.offset;
      danmuDom.style.transform = `translate(${offset}px, ${trackIdx * trackHeight}px)`;
      // 每一帧后弹幕的偏移量都会减少 speed 大小的距离
      danmu.offset -= danmu.speed;
      if (danmu.offset < 0 && Math.abs(danmu.offset) > danmu.width) {
        shouldRemove = true;
        removeIndex = danmuIdx;
      }
    });
    track.updateOffset();
    if (shouldRemove) {
      this.removeElementFromTrack(track, removeIndex);
      track.remove(removeIndex);
    }
  });
}

以上代码应该是很容易看出对应的过程,这里就不细述了。

总结

这篇文章简单的讨论了弹幕的常规设计与实现思路,里面还有可以优化的点,比如弹幕的速度问题,还有没有提到的事件监听,具体实现大家如果有兴趣可以阅读下源码:

qier-player-danmaku

另外,如果对大家有所帮助,给我的 vortesnail/blog 赏个 star🌟 哦~

参考:

弹幕用例规范

a-barrage

node 写一个自动监听文件并读写配置的脚本

前言

我的 github/blog,给个小星星咯~

最近因为工作,需要写一个脚本来自动读取文件夹下的某个文件,把其中的内容写到另一个新生成的文件中。因为这种场景还是挺常见的,网络上也搜不到好的(手把手教学的)解决方案,这对于还没学过 node.js  的前端小白来说,很不友好啊~

于是这篇文章就手把手教你写一个这样的脚本,都会尽量解释清楚,保证你看了就会!

场景举例

假如有这么一个项目,其文件目录如下:

|-- app1
    |-- config.json
    |-- index.js
|-- app2
    |-- config.json
    |-- index.js
|-- app3
    |-- config.json
    |-- index.js
|-- app4
    |-- config.json
    |-- index.js
|-- config.all.js
|-- package.json

index.js  中的内容是啥与本文无关,只是做个样子,但是在每个 app  文件夹中都有一个 config.json  文件,这就是我们需要读的配置文件,现在我们要做的就是写一个 node  脚本去监听当前这个目录的文件变动,并且实时地去读各个 app  下的配置文件,并写入 config.all.js  文件中。

现在假设配置文件 config.json  内容大概如下:

{
  "name": "vortesnail",
  "github": "github.com/vortesnail",
  "age": "24",
  "address": "earth",
  "hobby": ["sing", "dance", "rap", "code"]
}

各个 app  文件下的 config.json  内容可不一致,比较符合我们的实际项目场景。

脚本编写

安装  chokidar

因为用原生的 fs.watch  会有很多问题,并且有很大的局限,我们采用第三方模块 chokidar  来进行文件的监听。

npm install chokidar

创建脚本文件

现在在根目录下创建我们的脚本文件: auto-config.js ,当然,名字随你。
先引用我们的第三方模块 chokidar ,以及 node 核心模块 fs 、 path  以及 process 。

const chokidar = require('chokidar')
const fs = require('fs')
const path = require('path')
const process = require('process')
const PROJECT_PATH = process.cwd()

PROJECT_PATH  表示当前目录路径。

使用 chokidar.watch

这里需要注意我们是 chokidar.watch('.', {}).on() , .  代表当前跟路径,使用 PROJECT_PATH  会有问题,有知道的大佬可以评论交流一下!

const chokidar = require('chokidar')
const fs = require('fs')
const path = require('path')
const process = require('process')
const PROJECT_PATH = process.cwd()

chokidar.watch('.', {
  persistent: true,
  ignored: /(^|[\/\\])\..|auto-config.js|config.all.js|node_modules/,
  depth: 1
}).on('all', (event, pathname) => {
  console.log(event, pathname)
  // do something later...
})
  • persistent:  与原生 fs.watch  一样,表示是否保护进程不退出持久监听,默认值为 true。
  • ignored: 所要忽略监听的文件或文件夹。
  • depth: 只监听当前目录以及下一级子目录。

使用 fs.readdirSync

使用 fs.readdirSync(PROJECT_PATH)  可读取当前目录的文件列表,是数组形式,数组内容为每一个文件或文件夹的名字。更新我们的代码:

chokidar.watch('.', {
  persistent: true,
  ignored: /(^|[\/\\])\..|auto-config.js|config.all.js|node_modules/,
  depth: 0
}).on('all', (event, pathname) => {
  console.log(event, pathname)
- // do something later...
+ const rootFilenames = fs.readdirSync(PROJECT_PATH)
+ console.log(rootFilenames)
})

现在已经可以在当前目录执行 node auto-config.js  来查看当前控制台的打印了,会发现循环打印了当前目录下的文件名字数组:

[
  'app1',
  'app2',
  'app3',
  'app4',
  'auto-config.js',
  'config.all.js',
  'node_modules',
  'package-lock.json',
  'package.json'
]

循环的原因是 chokidar  第一次会监听当前目录所有文件的 add  事件,都有哪些事件详情可看这: event

循环遍历每个文件夹并获取子目录文件列表

获得了当前目录的文件,我们需要先筛选出文件夹,再对该文件夹(比如我们的 app1 、 app2  文件夹)使用上面使用过的 fs.readdirSync([路径])  来获取配置文件所在目录的文件列表, [路径]  可通过字符串拼接得到。

chokidar.watch('.', {
  // ...
}).on('all', (event, pathname) => {
  console.log(event, pathname)
  const rootFilenames = fs.readdirSync(PROJECT_PATH)
- console.log(rootFilenames)
+ rootFilenames.forEach(function(file) {
+   const newPath = path.join(PROJECT_PATH, `/${file}/`)
+   const subFilenanme = fs.readdirSync(newPath)
+   console.log(subFilenanme)
+ })
})

但是现在会报错,因为对于 fs.readdirSync  来说,若读取的当前路径为一个文件而不是一个文件夹,就会发生错误并终止程序的运行。故我们需要对其做一个判断。

读取文件状态  fs.stat

使用 fs.stat(path,callback) ,而不是 fs.statSync ,我们可以处理错误发生后的一些操作。

  • callback  有两个参数: (err,stats),stats 是一个 fs.Stats 对象。
  • stats.isDirectory()  可判断是否是文件夹。

更新代码如下:

chokidar.watch('.', {
  // ...
}).on('all', (event, pathname) => {
  console.log(event, pathname)
  const rootFilenames = fs.readdirSync(PROJECT_PATH)
  rootFilenames.forEach(function(file) {
    const newPath = path.join(PROJECT_PATH, `/${file}/`)
    fs.stat(newPath, function(err, stats) {
      if(err){
        console.log(file + 'is not a directory...')
      } else {
        const isDir = stats.isDirectory() //是文件夹
        if (isDir) {
          const subFilenanmes = fs.readdirSync(newPath)
          console.log(subFilenanmes)
        }
      }
    })
  })
})

现在已经可以获取到子目录的文件列表了,接下来可以判断是否找到我们需要读取的文件,并且读文件了。

使用 fs.readFileSync 与 fs.writeFileSync

我们需要一个变量来存储读取到的值,这里我们使用

let content = ''

这里我只是简单的读取 .json  文件,并将其内容后添加一个 ,  并全部写入到新生成的 config.all.js  文件中。

添加代码如下:

chokidar.watch('.', {
  persistent: true,
  ignored: /(^|[\/\\])\..|auto-config.js|config.all.js|node_modules/,
  depth: 0
}).on('all', (event, pathname) => {
  console.log(event, pathname)
+ let content = ''
  const rootFilenames = fs.readdirSync(PROJECT_PATH)
  rootFilenames.forEach(function(file) {
    const newPath = path.join(PROJECT_PATH, `/${file}/`)
    fs.stat(newPath, function(err, stats) {
      if(err){
        console.log(file + 'is not a directory...')
      } else {
        const isDir = stats.isDirectory() //是文件夹
        if (isDir) {
          const subFilenanmes = fs.readdirSync(newPath)
-         console.log(subFilenanmes)
+         subFilenanmes.forEach(function(file) {
+           if (file === 'config.json') {
+             const data = fs.readFileSync(path.join(newPath, file), 'utf-8') //读取文件内容
+             content += data + ',' + '\n'
+           }
+           fs.writeFileSync(path.join(PROJECT_PATH, 'config.all.js'), `module.exports={data: [${content}]}`)
+         })
        }
      }
    })
  })
+ console.log(`配置表 config.all.js 已自动生成...`)
})

到目前为止,这个读写脚本就算完成了,你不信你执行 node auto-config.js ,再打开根目录下 config.all.js  文件看看,是不是把所有 app  目录下的 config.json  中的文件写入到里面了,而且你任意修改一下当前目录以及子目录的任一文件内容,都会重新生成配置表。

处理瑕疵

最后的的打印因为第一次监听会生成很多很多。。。这看起来太丑了,可以加一个防抖,只让它输出一次。
另外,还可以在适合的地方加一些提示,现放出完整代码:

const chokidar = require('chokidar')
const fs = require('fs')
const path = require('path')
const process = require('process')
const PROJECT_PATH = process.cwd()

chokidar.watch('.', {
  persistent: true,
  ignored: /(^|[\/\\])\..|auto-config.js|config.all.js|node_modules/,
  depth: 0
}).on('all', (event, pathname) => {
  console.log(event, pathname)
  let content = ''
  const rootFilenames = fs.readdirSync(PROJECT_PATH)
  rootFilenames.forEach(function(file) {
    const newPath = path.join(PROJECT_PATH, `/${file}/`)
    fs.stat(newPath, function(err, stats) {
      if(err){
        console.log(file + 'is not a directory...')
      } else {
        const isDir = stats.isDirectory() //是文件夹
        if (isDir) {
          const subFilenanmes = fs.readdirSync(newPath)
          subFilenanmes.forEach(function(file) {
            if (file === 'config.json') {
              const data = fs.readFileSync(path.join(newPath, file), 'utf-8') //读取文件内容
              content += data + ',' + '\n'
            }
            fs.writeFileSync(path.join(PROJECT_PATH, 'config.all.js'), `module.exports={data: [${content}]}`)
          })
        }
      }
    })
  })

  success()
})

function debounce(func, wait) {
  var timeout;
  return function () {
    var context = this;
    var args = arguments;
    clearTimeout(timeout)
    timeout = setTimeout(function(){
      func.apply(context, args)
    }, wait);
  }
}

const success = debounce(() => {
  console.log('配置表 config.all.js 已自动生成...')
}, 500)

现在你再试试 node auto-config.js ,看看效果吧~

webpack 打包配置

有的时候,我们不仅仅是只在项目中使用而已,我们需要打包出一个脚本文件,丢到 nginx  环境中去,在那个根目录打开我们的脚本,自动时时刻刻监听文件的变动,生成配置表,彻底解放双手!

打包配置很简单,不要慌!

安装必要插件

无非就是一些 webpack 打包需要的依赖而已,比较重要的是 node-loader  这个包。

npm install -D webpack webpack-cli node-loader

webpack 配置

根目录创建 webpack.auto.js

const path = require('path');

module.exports = {
  target: "node",
  entry: {
    'auto-config': path.resolve(__dirname, "auto-config.js"),
  },
  output: {
    publicPath: '',
    filename: '[name].js',
    path: path.resolve(__dirname, "build"),
  },
  module: {
    rules: [
      {
        test: /\.node$/,
        use: 'node-loader'
      }
    ],
  },
  node: {
    fs: 'empty',
    child_process: 'empty',
    tls: 'empty',
    net: 'empty'
  },
};

比较重要的地方就是 target: node ,以及入口文件要写对,因为 fsevents 中有 .node 文件,我们需要对其处理,需要一个 node-loader  做识别转译。

修改 package.json 

"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1",
+ "build:auto": "webpack -p --progress --config webpack.auto.js"
},

打包

现在,你在控制台执行 npm run build:auto ,一个可以监听并读写的小脚本就这样完成了!尽情去使用吧。把打出来的包丢到任意目录,执行:

node auto-config.js
// 没权限的话需要要加 sudo
sudo node auto-config.js

完美!

结语

自我认为该写法还有很大改进地方,奈何自己水平有限,如果有大佬有更好的意见,非常希望您能在评论区说出来,让更多像我这样“求知若渴”的同学得到成长,感谢!🙏

推荐阅读:
这一次,彻底理解 https 原理

如何部署create-react-app项目到Github pages步骤

如何部署create-react-app项目到Github pages步骤

此文目的:

提供两种将 create-react-app 创建的项目部署到 Github Pages 的方法,因为其中有坑,此文或许能帮到一些朋友。

前提须知:

  • 你已经通过 create-react-app 创建了一个 react 项目, 并通过 npm run start 能在线下环境正确运行。
  • 当然是在 github 上已经创建了一个与你本地代码同步的仓库啦~
  • 我的项目名为 qier-player-demo ,以下操作基于这个项目。(打个广告,我自己写了一个轻量且精致、基于 React 的播放器组件,名为 qier-player ,感兴趣的朋友点以下链接了解一下,给个 star 什么的最感谢啦)

一款轻量且精致的、基于React的H5播放器

方法一:官方方案

抱怨:这里不得不说,官方的方案是真的有坑,按照步骤来,发现根本无法成功, npm run build 之后页面空白, npm run deploy 之后一直卡在终端,几个小时也不动。由于设备原因,我也无法排除是不是自身电脑或网络的问题,我暂且把官方的方法给大家理一下,可以一试。

1.安装 gh-pages 

在我的项目之下打开终端,输入以下命令安装

npm install gh-pages --save-dev

2.修改 package.json 

修改"homepage" 和 "scripts"

{
  // ...
  "homepage": "./",
  "dependencies": {
    // ...
  },
  "scripts": {
    // ...
    "deploy": "gh-pages -d build"
  },
}

这里请注意了,官方介绍是"homepage"的值要设置为 http://{username}.github.io/{repo-name} ,比如我当前的就是 http://vortesnail.github.io/qier-player-demo ,但是这样操作会在 build 打的包会在所有文件路径前加上 **qier-player-demo **,比如 index.html 文件中对同等目录下的文件引用应该是 src='./index.css' ,结果会变成 src='./qier-player-demo /index.css' ,这样部署后肯定无法访问,所有资源都找不到。

3.开始部署

Github Pages 无法识别 React 代码,只能识别 html,css,js,故你需要先打包编译你的项目:

npm run build

你会发现你的项目目录多了一个 build 文件夹,这就是要部署的文件夹,终端执行以下代码:

npm run deploy

这时 github 上项目就多出了一个gh-pagesbranch,在设置中Github Pages处选择gh-pages分支保存,部署完成。过几分钟你再点击生成的链接即可访问你的页面,与线下环境下的页面相同即成功。详情如下:

image.png

image.png
image.png

方法二:利用 git subtree 

上面介绍的官方方法对我或者有些小伙伴并不管用,不是空白就是卡住,那我们换个思路也可以做到。
我们不需要安装 gh-pages ,也只需要修改 package.json 中 "homepage": "./", 即可。

1.打包编译当前项目

与之前一样,我们需要先打包:

npm run build

2.提交代码到 github 远程仓库

常用三连~

git add .
git commit -m "test gh-pages"
git push origin master

3.生成 gh-pages 分支

这时候我们的远程仓库的 master 分支下有了 build 这个文件夹,里面就是打包编译之后的文件。我们接着在终端输入以下命令:

git subtree push --prefix=build origin gh-pages

上面这个命令的意思是将master分支下某个文件(如:build)复制一份到 gh-pages 这个新分支下。
这时候通过 setting 与方法一截图操作一样就可以了,最终效果都是一样的,不过你的代码每次迭代之后,都需要手动在部署一下,才能达到线上线下一致。

总结:

其实两个方法都是一样的,都是生成新的分支 gh-pages ,并在这个分支下的文件为我们打包编译之后的文件,便于 github pages 识别。

参考文章:https://segmentfault.com/a/1190000019290048

从零配置webpack 4+react脚手架(二)

前言:

你可能也注意到了,html文件中的关于js的引用是我们手动写的,那假如我们改了输出路径或打包编译之后的文件名,那我们岂不是还要手动去修改html文件中的引用?我们怎么做到,像create-react-app中那样一旦你修改了某个文件内容,页面会自己刷新?我们来一步一步实现它们,当然,这一小节不仅仅只是为了完成这两点。

自动编译html并引入js文件

public的index.html应该自动编译到dist目录,并且所有的js引用是自动添加的。你可以使用html-webpack-plugin插件来处理这个优化。

安装HtmlWebpackPlugin

在控制台执行以下代码:

npm install --save-dev html-webpack-plugin

在webpack.prod.config.js中配置plugins属性

const merge = require('webpack-merge');
const common = require('./webpack.common.config.js');

const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = merge(common, {
  mode: 'production',
  plugins: [
    new HtmlWebpackPlugin({
      filename: 'index.html',
      // 这里有小伙伴可能会疑惑为什么不是 '../public/index.html'
      // 我的理解是无论与要用的template是不是在一个目录,都是从根路径开始查找
      template: 'public/index.html',
      inject: 'body',
      minify: {
        removeComments: true,
        collapseWhitespace: true,
      },
    })
  ]
});
  • filename:打包之后的html文件名字
  • template:以我们自己定义的html为模板生成,不然我们还要到打包之后的html文件中写
  • inject:在body最底部引入js文件,如果是head,就是在head中引入js
  • minify:压缩html文件,更多配置点我
    •  removeComments:去除注释
    • collapseWhitespace:去除空格

更多配置请点击官方README

删除index.html中手动引入的script标签

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>从零配置webpack4+react脚手架</title>
</head>
<body>
  <div id="root"></div>
</body>
</html>

现在我们再来打包试试,看看dist中是不是多出了html文件,并且自动引入了script,用浏览器打开它试试看是不是能正确输出内容了!

给打包出的js文件换个不确定名字

这个操作是为了防止因为浏览器缓存带来的业务代码更新,而页面却没变化的问题,你想想看,假如客户端请求js文件的时候发现名字是一样的,那么它很有可能不发新的数据包,而直接用之前缓存的文件,当然,这和缓存策略有关。

那我们怎么给导出文件的安排一个不确定的名字呢?很简单,[hash]或[chunkhash]
修改webpck.prod.config.js

const merge = require('webpack-merge');
const common = require('./webpack.common.config.js');

const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = merge(common, {
  mode: 'production',
  output: {
    filename: 'js/[name].[chunkhash:8].bundle.js',
  },
  plugins: [
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: 'public/index.html',
      inject: 'body',
      minify: {
        removeComments: true,
        collapseWhitespace: true,
      },
    })
  ]
});

其中,name就是模块名称,我们在entry中进行过配置,在这里重新设置会代替之前common中的设置,chunkhash是文件内容的hash,webpack默认采用md5的方式对文件进行hash。8是hash的长度,如果不设置,webpack会设置默认值为20。

现在你重新打包,去看看生成的js文件的名字~

打包编译前清理dist目录

在上面的修改后,因为js文件名字不同,你之后再打包,会把之前打包之后的js文件也留下,我们只想要最新打包编译的文件,就需要先清除dist目录,再重新生成。

安装clean-webpack-plugin

npm install --save-dev clean-webpack-plugin

这个插件不被官方文档所收录,可以去github查看它的配置文档

使用clean-webpack-plugin

修改webpck.prod.config.js

const merge = require('webpack-merge');
const common = require('./webpack.common.config.js');

const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = merge(common, {
  mode: 'production',
  output: {
    filename: 'js/[name].[chunkhash:8].bundle.js',
  },
  plugins: [
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: 'public/index.html',
      inject: 'body',
      minify: {
        removeComments: true,
        collapseWhitespace: true,
      },
    }),
    new CleanWebpackPlugin()
  ]
});

这里需要注意:之前引入CleanWebpackPlugin的写法是
const CleanWebpackPlugin = require('clean-webpack-plugin'); 
而且在下面new的时候需要传入参数,dist文件路径。但是现在必须这样引入:
const {CleanWebpackPlugin} = require('clean-webpack-plugin');
而且,不用再写路径参数

现在再来执行看看,是不是只有一个js文件了!~

代码分割

我们先看下,我们之前打包编译的时候,控制台的信息:
image.png
我们看到,这个打包之后的bundle.js文件大小为129kb,随着业务代码越来越多,这个包会变得越来越大,你每次修改了代码并发布,用户都需要重新下载这个包,但是想想看,我们修改的代码只是整个代码的一小部分,还有许多其他不变的代码,例如 react 和 react-dom ,那我们把这部分不变的代码单独打包。

修改 webpack.common.config.js ,增加一个入口:

  entry: {
    index: './src/index.js',
    framework: ['react','react-dom'],
  },

重新打包,发现react和react-dom 被编译成framework.js,但是我们的index.bundle.js还是129kb,没有变过。
这是因为我们还没有抽离index.js中的公共代码。

webpack3版本是通过配置CommonsChunkPlugin插件来抽离公共的模块。webpack4版本,官方废弃了CommonsChunkPlugin,而是改用配置optimization.splitChunks的方式,更加方便。

添加代码至 webpack.prod.config.js :

module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 30000,
      maxSize: 0,
      minChunks: 1,
      cacheGroups: {
        framework: {
          test: "framework",
          name: "framework",
          enforce: true
        },
        vendors: {
          priority: -10,
          test: /node_modules/,
          name: "vendor",
          enforce: true,
        },
      }
    }
  },
  //...
};

cacheGroups对象,定义了需要被抽离的模块,其中test属性是比较关键的一个值,他可以是一个字符串,也可以是正则表达式,还可以是函数。如果定义的是字符串,会匹配入口模块名称,会从其他模块中把包含这个模块的抽离出来。name是抽离后生成的名字,和入口文件模块名称相同,这样抽离出来的新生成的framework模块会覆盖被抽离的framework模块,虽然他们都叫framework。
vendors这个缓存组,它的test设置为 /node_modules/ 表示只筛选从node_modules文件夹下引入的模块,所以所有第三方模块才会被拆分出来。

重新打包,我们发现index.bundle.js文件大小只有:1.69kb
image.png
我们随意修改一下app.js中的内容,比如

import React from 'react';

function App() {
  return (
    <div className="App">
      <h1>I am changed</h1>
    </div>
  );
}

export default App;

再打包一次,你会发现index.bundle.js(不被缓存)的hash值变了,但是freamework.bundle.js(能被缓存)的hash值没变,成了成了!!

压缩JS文件

我们需要把打包生成的js文件尽可能压缩,以便减少文件体积,更快地被用户加载。
我们需要一个插件: uglifyjs-webpack-plugin 来做这份工作

安装uglifyjs-webpack-plugin

在控制台执行以下代码:

npm install uglifyjs-webpack-plugin --save-dev

引入uglifyjs-webpack-plugin

增加如下代码至 webpack.prod.config.js :

const UglifyJsPlugin = require('uglifyjs-webpack-plugin');

optimization内配置minimizer参数

minimizer: [
  new UglifyJsPlugin(),
	//...
],

现在optimization参数应该是现在这样:

  optimization: {
    minimizer: [new UglifyJsPlugin()],
    splitChunks: {
      chunks: 'all',
      minSize: 30000,
      maxSize: 0,
      minChunks: 1,
      cacheGroups: {
        framework: {
          priority: 100,
          test: "framework",
          name: "framework",
          enforce: true
        },
        vendors: {
          priority: -10,
          test: /node_modules/,
          name: "vendor",
          enforce: true,
        },
      }
    }
  },

重新打包编译看看~我们的index.bundle.js减少了0.1kb,当然,随着业务代码越来越多,这部分差距会渐渐变大。

自动编译打包

我们每次修改代码,查看结果都要经历以此 npm run build ,大大降低了开发效率,这难以忍受!
webpack给我们提供了devServer开发环境,支持热更新,相当舒服。

安装webpack-dev-server

在控制台执行以下代码:

npm install webpack-dev-server --save-dev

增加代码至 webpack.dev.config.js :

是不是都快忘记这个之前创建的配置文件了?没关系,反正也没代码,它是专门用来配置我们开发环境的

const path = require('path');
const merge = require('webpack-merge');
const common = require('./webpack.common.config.js');

const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = merge(common, {
  mode: 'development',
  output: {
    filename: 'js/[name].[hash:8].bundle.js',
  },
  devServer: {
    contentBase: path.resolve(__dirname, '../dist'),
    open: true,
    port: 9000,
    compress: true,
    hot: true
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: 'public/index.html',
      inject: 'body',
      hash: false
    }),
    new webpack.HotModuleReplacementPlugin()
  ]
});

HotModuleReplacementPlugin是webpack热更新的插件,设置devServer.hot为true,并且在plugins中引入HotModuleReplacementPlugin插件即可。
还需要注意的是我们开启了hot,那么导出不能使用chunkhash,需要替换为hash。

修改我们的package.json

像之前build的时候,我们是通过配置package.json做到的,现在我们同样加入以下代码来模拟:

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack --config ./config/webpack.prod.config.js",
+   "start": "webpack-dev-server --inline --config ./config/webpack.dev.config.js"
  },

接下来,在控制台执行

npm run start

是不是自动开了一个端口为9000的网页,上面是我们写的页面内容,这和我们的配置都是一一对应的。
现在你随意修改app.js中的代码,再回到页面看下是不是也跟着变了,那我们就整合webpack-dev-server成功!

下面一小节我们会配置css相关的属性,加油!

我是这样搭建Typescript+React项目环境的!(2.7w字详解)

前言

现在我们开发一个 React 项目最快的方式便是使用 Facebook 官方开源的脚手架 create-react-app ,但是随着业务场景的复杂度提升,难免会需要我们再去添加或修改一些配置,这个时候如果对 webpack 不够熟练的话,会比较艰难,那种无力的感觉,就好像是女朋友在旁边干扰你打游戏一样,让人焦灼且无可奈何。

这篇文章的主要目的是让大家(新手)对webpack 构建 react + typescript 项目开发环境有一个很感性的认知,以及 会配合使用 rollup 打包组件并发布至 npm 全流程,坦白说,相关的文章真的很多了,但是我仍然想再写一篇属于我自己风格的文章,什么风格呢?

1.从零开始搭建至完整的项目开发环境流程!
2.尽量做到每一步操作、每一行代码都能尽量解释给读者!
3.若完全跟着做下来,一定能实现同样的功能!

你能学到什么?

希望在你阅读本篇文章之后,不会觉得浪费了时间。如果你跟着读下来,你将会学到:

  • 🍋 项目中常用配置文件的作用及配置方式
  • 🍊 eslint、stylelint 及 prettier 的配置
  • 🍉 代码提交规范的第三方工具强制约束方式实现
  • 🍓 webpack 配置 react + typescript 开发与生产环境及优化
  • 🍑 rollup 构建组件打包环境并发布至 npm 的全流程
  • 🍏 利用 react-testing-library 对 react 组件进行测试
  • 🥝 持续集成(CI)、Github Actions

项目初始化及配置

大家对 github 一定很熟悉了,各式各样的开源工具一定也是经常被大家用到,用久了自己也想对开源社区做一些贡献,奈何各种配置太过繁琐,劝退了一大部分热心的开发者,我当初就是有很多想法,但是只会写代码,看别人的开源项目一堆配置文件,看的头皮发麻,再想想自己全都看不懂,想想就算开发出来了,别人也会觉得不专业,就抱着这种心态直接放弃了~

image.png

别慌,看完这篇文章,该会的都会了!
那我们现在就从 github 新建一个开发脚手架项目开始吧~

这一步只需要在 github 主页右上角点击“+”然后 New repository 之后进行项目名字及项目描述的填写,选择一个开源协议即可确定创建完成(比如我新建的一个项目便为 react-ts-quick-starter ,欢迎大家 pr 以及 star🌟。),进入到项目主页之后,点击绿油油的 Code 大按键,复制 SSH 链接,回到我们的桌面,打开终端(控制台),切换到你想要的目录下,执行命令:

# 注意以下的 ssh 连接要是自己项目下复制的
git clone [email protected]:vortesnail/react-ts-quick-starter.git

当 clone 完成之后,使用编辑器打开项目文件夹,我们的 vscode 该上场了!
我个人比较习惯于使用 vscode 自带的终端,打开默认的终端快捷键为 ctrl + 反引号 ,当前目录默认就为项目目录。

1. package.json

每一个项目都需要一个 package.json 文件,它的作用是记录项目的配置信息,比如我们的项目名称、包的入口文件、项目版本等,也会记录所需的各种依赖,还有很重要的 script 字段,它指定了运行脚本命令的 npm 命令行缩写。

通过以下命令就能快速生成该文件:

npm init -y

你也可以使用 yarn 来进行生成,但是我个人还是对 npm 更习惯些,所以我之后都会用 npm 来进行依赖包的安装。

通过修改生成的默认配置,现在的内容如下:

{
  "name": "react-ts-quick-starter",
  "version": "1.0.0",
  "description": "Quickly create react + typescript project development environment and scaffold for developing npm package components",
  "main": "index.js",
  "scripts": {},
  "repository": {
    "type": "git",
    "url": "git+https://github.com/vortesnail/react-ts-quick-starter.git"
  },
  "keywords": ["react-project", "typescript-project", "react-typescript", "react-ts-quick-starter"],
  "author": {
    "name": "vortesnail",
    "url": "https://github.com/vortesnail",
    "email": "[email protected]"
  },
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/vortesnail/react-ts-quick-starter/issues"
  },
  "homepage": "https://github.com/vortesnail/react-ts-quick-starter#readme"
}

暂时修改了以下配置:

  • description :增加了对该项目的描述,github 进行 repo 搜索时,关键字匹配会使你的项目更容易被搜索到。
  • scripts :把默认生成的删了,没啥用。
  • keywords :增加了项目关键字,其他开发者在 npm 上搜索的时候,适合的关键字能你的包更容易被搜索到。
  • author :添加了更具体的作者信息。
  • license :修改为MIT协议。

2. LICENSE

我们在建仓库的时候会有选项让我们选择开源协议,我当时就选了MIT协议,如果没选的也不要紧,去网站 choosealicense 选择合适的 license(一般会选宽松的 MIT 协议),复制到项目根目录下的 LICENSE 文件内即可,然后修改作者名和年份,如下:

MIT License

Copyright (c) 2020 chen xin

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights...

3. .gitignore

该文件决定了项目进行 git 提交时所需要忽略掉的文件或文件夹,编辑器如 vscode 也会监听 .gitignore 之外的所有文件,如果没有进行忽略的文件有所变动时,在进行 git 提交时就会被识别为需要提交的文件。

node_modules 是我们安装第三方依赖的文件夹,这个肯定要添加至 .gitignore 中,且不说这个文件夹里面成千上万的文件会给编辑器带来性能压力,也会给提交至远端的服务器造成不小损失,另外就是这个文件夹中的东西,完全可以通过简单的 npm install 就能得到~

所以不需要上传至 git 仓库的都要添加进来,比如我们常见的 build 、 dist 等,还有操作系统默认生成的,比如 MacOs 会生成存储项目文件夹显示属性的 DS_Store 文件。

image.png

那么这些系统或编辑器自动生成的文件,但是又不被我们很容易查知的该怎么办呢?使用 vscode 的 gitignore 插件,下载安装该插件之后, ctrl+shift+p 召唤命令面板,输入 Add gitignore 命令,即可在输入框输入系统或编辑器名字,来自动添加需要忽略的文件或文件夹至 .gitignore 中。

123.gif

我添加了以下: Node 、 Windows 、 MacOS 、 SublimeText 、 Vim 、 Vscode ,大家酌情添加吧。如果默认中没有的,可自行手动输入至 .gitignore 中,比如我自己加了 dist/ 和 build/ ,用于忽略之后webpack 打包生成的文件。

4. .npmrc

大家一开始使用 npm 安装依赖包时,肯定感受过那挤牙膏般的下载速度,上网一查只需要将 npm 源设置为淘宝镜像源就行,在控制台执行一下以下命令:

npm config set registry https://registry.npm.taobao.org

从此过上了速度七十迈,心情是自由自在的生活。

但是大家想想,万一某个同学克隆了你的项目之后,准备在他本地开发的时候,并没有设置淘宝镜像源,又要人家去手动设置一遍,我们作为项目的发起者,就先给别人省下这份时间吧,只需要在根目录添加一个 .npmrc 并做简单的配置即可:

# 创建 .npmrc 文件
touch .npmrc
# 在该文件内输入配置
registry=https://registry.npm.taobao.org/

5. README.md

你只要上 github 找任何一个项目,点进去之后往下拉一点,看到的对项目的直接说明就是 README.md 所呈现的,这个文件无比重要,一个好的开源项目必须!必须!必须!有一个简明且美观的 README.md ,不过文章写到现在为止,我们的这个脚手架并没有任何实质性的内容,之后完全配置完之后,会再好好书写一下。

后续我还会再对这部分内容做补充,现在大家先 touch README.md 创建文件,然后随意写点东西先看着~

规范代码与提交

多人共同开发一个项目的很大问题就是每个开发者代码风格都有所差异,随着版本不断迭代,维护人员不断更换,这个项目将会变得越来越难维护,因为后人基本不可能再看懂了。比如小迈、小克、小尔三个开发者的风格如下:

// 小迈 紧凑型
const add=(a,b)=>{
  return a+b;
}

// 小克 规范型
const add = (a, b) => {
    return a + b
}

// 小尔 松紧皆可型
var add = (a,b) => {
  return a+b
}

请问如果你刚加入一个团队,所要参与的项目中有这几种代码风格,你会不会觉得“人间不值得”

image.png

如果我们一开始就有手段能够约束大家的代码风格,使其趋于统一,将会极大地增强代码的可维护性,很重要的一点是能提高我们开发的幸福度。

当然了,作为开源项目,代码的提交规范也是很有必要遵守的,这个我们也可以通过第三方工具来强制约束,不要太美滋滋啊,既能使项目的提交更加规范,还能不断地锻炼自己的**规范性思维,**这对于无论是开源项目还是团队项目,都是大有裨益的。

1. EditorConfig

.editorconfig 是跨编辑器维护一致编码风格的配置文件,有的编辑器会默认集成读取该配置文件的功能,但是 vscode 需要安装相应的扩展 EditorConfig For vs Code 。

image.png

安装完此扩展后,在 vscode 中使用快捷键 ctrl+shift+p 打开命令台,输入 Generate .editorcofig 即可快速生成 .editorconfig 文件,当然,有时候 vscode 抽风找不到命令也是可能的,比如我就经常遇到输入该命令没用,需要重启才会重新出现,那么就手动创建该文件也是没问题的。

该文件的配置特别简单,就少许的几个配置,比如我的配置如下:

root = true

[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
end_of_line = lf

[*.md]
trim_trailing_whitespace = false

扩展装完,配置配完,编辑器就会去首先读取这个配置文件,对缩进风格、缩进大小在换行时直接按照配置的来,在你 ctrl+s 保存时,就会按照里面的规则进行代码格式化。以下是上述配置的简单介绍:

  • indent_style :缩进风格,可选配置有 tab 和 space 。
  • indent_size :缩进大小,可设定为 1-8 的数字,比如设定为 2 ,那就是缩进 2 个空格。
  • charset :编码格式,通常都是选 utf-8 。
  • trim_trailing_whitespace :去除多余的空格,比如你不小心在尾巴多打了个空格,它会给你自动去掉。
  • insert_final_newline :在尾部插入一行,个人很喜欢这个风格,当最后一行代码很长的时候,你又想对该行代码比较靠后的位置编辑时,不要太好用哦,建议大家也开上。
  • end_of_line :换行符,可选配置有 lf ,cr ,crlf ,会有三种的原因是因为各个操作系统之间的换行符不一致,这里有历史原因,有兴趣的同学自行了解吧,许多有名的开源库都是使用 lf ,我们姑且也跟跟风吧。

因为 markdown 语法中,我想要换行需要在上一行多打 2 个以上的空格,为了不影响该语法,故 .md 文件中把去除多余空格关掉了。

2. Prettier

如果说 EditorConfig 帮你统一编辑器风格,那 Prettier 就是帮你统一项目风格的。 Prettier 拥有更多配置项(实际上也不多,数了下二十个),且能在发布流程中执行命令自动格式化,能够有效的使项目代码风格趋于统一。

在我们的项目中执行以下命令安装我们的第一个依赖包:

npm install prettier -D

安装成功之后在根目录新建文件 .prettierrc ,输入以下配置:

{
  "trailingComma": "all",
  "tabWidth": 2,
  "semi": false,
  "singleQuote": true,
  "endOfLine": "lf",
  "printWidth": 120,
  "bracketSpacing": true,
  "arrowParens": "always"
}

其实 Prettier 的配置项很少,大家可以去 Prettier Playground 大概把玩一会儿,下面我简单介绍下上述的配置:

  • trailingComma :对象的最后一个属性末尾也会添加 , ,比如 { a: 1, b: 2 } 会格式为 { a: 1, b: 2, } 。
  • tabWidth :缩进大小。
  • semi :分号是否添加,我以前从C++转前端的,有一段时间非常不能忍受不加分号的行为,现在香的一匹。
  • singleQuote :是否单引号,绝壁选择单引号啊,不会真有人还用双引号吧?不会吧!😏
  • jsxSingleQuote :jsx 语法下是否单引号,同上。
  • endOfLine :与 .editorconfig 保持一致设置。
  • printWidth :单行代码最长字符长度,超过之后会自动格式化换行。
  • bracketSpacing :在对象中的括号之间打印空格, {a: 5} 格式化为 { a: 5 } 。
  • arrowParens :箭头函数的参数无论有几个,都要括号包裹。比如 (a) => {} ,如果设为 avoid ,会自动格式化为 a => {} 。

那我们现在也配置好了,但是咋用的呢?

  • 一个是我们可以通过命令的形式去格式化某个文件下的代码,但是我们基本不会去使用,最终都是通过 ESlint 去检测代码是否符合规范。
  • 二是当我们编辑完代码之后,按下 ctrl+s 保存就给我们自动把当前文件代码格式化了,既能实时查看格式化后的代码风格,又省去了命令执行代码格式化的多余工作。

你所需要做的是先安装扩展 Prettier - Code formatter

image.png

当安装结束后, 在项目根目录新建一个文件夹 .vscode ,在此文件下再建一个 settings.json 文件:

image.png

该文件的配置优先于 vscode 全局的 settings.json ,这样别人下载了你的项目进行开发,也不会因为全局 setting.json 的配置不同而导致 Prettier 或之后会说到的 ESLint 、 StyleLint 失效,接下来在该文件内输入以下代码:

{ 
  // 指定哪些文件不参与搜索
  "search.exclude": {
    "**/node_modules": true,
    "dist": true,
    "yarn.lock": true
  },
  "editor.formatOnSave": true,
  "[javascript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[javascriptreact]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescriptreact]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[json]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[html]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[markdown]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[css]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[less]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[scss]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  }
}

"editor.formatOnSave" 的作用是在我们保存时,会自动执行一次代码格式化,而我们该使用什么格式化器?接下来的代码便是设置默认的格式化器,看名字大家也能看得出来了吧!

在遇到 .js 、 .jsx 、.ts 、.tsx 、.json 、.html 、.md 、 .css 、 .less 、 .scss 为后缀的文件时,都会去使用 Prettier 去格式化代码,而格式化的规则就是我们配置的 .prettierrc 决定的!

12.gif

.editorconfig 配置文件中某些配置项是会和 Prettier 重合的,例如 指定缩进大小 两者都可以配置。

那么两者有什么区别呢?

我们可以看到 EditorConfig 的配置项都是一些不涉及具体语法的,比如 缩进大小、文移除多余空格等。

Prettier 是一个格式化工具,要根据具体语法格式化,对于不同的语法用单引号还是双引号,加不加分号,哪里换行等,当然,肯定也有缩进大小。

即使缩进大小这些共同都有的设置,两者也是不冲突的,设置 EditorConfig 的 indent_size  为 4 , Prettier 的 tabWidth 为 2 。

12.gif

可以看到,在我们新起一行时,根据 .editorconfig 中的配置,缩进大小为 4 ,所以光标直接跳到了此处,但是保存时,因为我们默认的格式化工具已经在 .vscode/settings.json 中设置为了 Prettier ,所以这时候读取缩进大小为 2 的配置,并正确格式化了代码。

当然,我还是建议大家两个都配置文件重合的地方都保持一致比较好~

3. ESLint

在上面我们配置了 EditorConfig 和 Prettier 都是为了解决代码风格问题,而 ESLint 是主要为了解决代码质量问题,它能在我们编写代码时就检测出程序可能出现的隐性BUG,通过 eslint --fix 还能自动修复一些代码写法问题,比如你定义了 var a = 3 ,自动修复后为 const a = 3 。还有许多类似的强制扭转代码最佳写法的规则,在无法自动修复时,会给出红线提示,强迫开发人员为其寻求更好的解决方案。

prettier 代码风格统一支持的语言更多,而且差异化小,eslint 一大堆的配置能弄出一堆风格,prettier 能对 ts js html css json md做风格统一,这方面 eslint 比不过。 --来自“三元小迷妹”

我们先把它用起来,直观感受一下其带来的好处!

首先在项目中安装 eslint :

 npm install eslint -D

安装成功后,执行以下命令:

npx eslint --init

上述命令的功能为初始化 ESLint 的配置文件,采取的是问答的形式,特别人性化。不过在我们介绍各个问答之前先来看看这句命令中 npx 是什么。

实际上,要达到以上命令的效果还有两种方式。

一是直接找到我们项目中安装的 eslint 的可执行文件,如下图:

image.png

然后根据该路径来执行命令:

./node_modules/.bin/eslint --init

二是先全局安装 eslint ,直接执行以下命令即可:

# 全局安装 eslint
npm install eslint -g

# eslint 配置文件初始化
eslint --init

现在让我们来说下这两种方式的缺点:

  • 针对第一种,其实本质上来说和我们所推荐的 npx 形式没有区别,缺点是该命令太过于繁琐。
  • 针对第二种,我们需要先全局进行 eslint 的安装,这会占据我们电脑的硬盘空间,且会将安装文件放到挺隐蔽的地方,个人有心里洁癖,非常接受不了这种全局安装的方式,特别是越来越多全局包的时候。再有一个比较大的问题是,因为我们执行 eslint --init 是使用全局安装的版本去初始化的,这有可能会和你现在项目中的 eslint 版本不一致。这个问题我就出现了,记得很久以前装的全局 eslint ,版本好低。

那么 npx 的作用就是抹掉了上述两个缺点,其是 npm v5.2.0 引入的一条命令,它在上述命令执行时:

  • 会先去本地 node_modules 中找 eslint 的执行文件,如果找到了,就直接执行,相当于上面所说的第一种方式;
  • 如果没有找到,就去全局找,找到了,就相当于上述第二种方式;
  • 如果都没有找到,就下载一个临时的 eslint ,用完之后就删除这个临时的包,对本机完全无污染。

image.png

已经执行 npx eslint --init 的小伙伴现在会依次遇到下面问题,请跟我慢慢看来:

  • How would you like to use ESLint?

    果断选择第三条 To check syntax, find problems, and enforce code style ,检查语法、检测问题并强制代码风格。

  • What type of modules does your project use?

    项目非配置代码都是采用的 ES6 模块系统导入导出,选择 JavaScript modules (import/export) 。

  • Which framework does your project use?

    显而易见,选择 React 。

  • Does your project use TypeScript?

    果断用上 Typescript 啊,还记得我们文章的标题吗?选择 Yes 后生成的 eslint 配置文件会给我们默认配上支持 Typescript 的 parse 以及插件 plugins 等。

  • Where does your code run?

Browser 和 Node 环境都选上,之后可能会编写一些 node 代码。

  • How would you like to define a style for your project?

    选择 Use a popular style guide ,即使用社区已经制定好的代码风格,我们去遵守就行。

  • Which style guide do you want to follow?

    选择 Airbnb 风格,都是社区总结出来的最佳实践。

  • What format do you want your config file to be in?

    选择 JavaScript ,即生成的配置文件是 js 文件,配置更加灵活。

  • Would you like to install them now with npm?

    当然 Yes 了~

在漫长的安装结束后,项目根目录下多出了新的文件 .eslintrc.js ,这便是我们的 eslint 配置文件了。其默认内容如下:

module.exports = {
  env: {
    browser: true,
    es2020: true,
    node: true,
  },
  extends: ['plugin:react/recommended', 'airbnb'],
  parser: '@typescript-eslint/parser',
  parserOptions: {
    ecmaFeatures: {
      jsx: true,
    },
    ecmaVersion: 11,
    sourceType: 'module',
  },
  plugins: ['react', '@typescript-eslint'],
  rules: {},
}

各个属性字段的作用可在 Configuring ESLint 仔细了解,可能会比较迷惑的地方是 extends 和 plugins 的关系,其实 plugins 就是插件的意思,都是需要 npm 包的安装才可以使用,只不过默认支持简写,官网都有说;至于 extneds 其实就是使用我们已经下载的插件的某些预设规则。

现在我们对该配置文件作以下修改:

  • 根据 eslint-config-airbnb 官方说明,如果要开启 React Hooks 的检查,需要在 extends 中添加一项 'airbnb/hooks' 。
  • 根据 @typescript-eslint/eslint-plugin 官方说明,在 extends 中添加 'plugin:@typescript-eslint/recommended' 可开启针对 ts 语法推荐的规则定义。
  • 需要添加一条很重要的 rule ,不然在 .ts 和 .tsx 文件中引入另一个文件模块会报错,比如:

image.png

添加以下规则到 rules 即可:

rules: {
  'import/extensions': [
    ERROR,
    'ignorePackages',
    {
      ts: 'never',
      tsx: 'never',
      json: 'never',
      js: 'never',
    },
  ],
}

在之后我们安装 typescript 之后,会出现以下的怪异错误:

image.png

大家先添加以下配置,毕竟之后一定要安装 typscript 的,把最常用的扩展名排在最前面,这里寻找文件时最快能找到:

  settings: {
    'import/resolver': {
      node: {
        extensions: ['.tsx', '.ts', '.js', '.json'],
      },
    },
  },

接下来安装 2 个社区中比较火的 eslint 插件:

  • eslint-plugin-promise :让你把 Promise 语法写成最佳实践。
  • eslint-plugin-unicorn :提供了更多有用的配置项,比如我会用来规范关于文件命名的方式。

执行以下命令进行安装:

npm install eslint-plugin-promise eslint-plugin-unicorn -D

在添加了部分规则 rules 后,我的配置文件修改之后如下:

const OFF = 0
const WARN = 1
const ERROR = 2

module.exports = {
  env: {
    browser: true,
    es2020: true,
    node: true,
  },
  extends: [
    'airbnb',
    'airbnb/hooks',
    'plugin:react/recommended',
    'plugin:unicorn/recommended',
    'plugin:promise/recommended',
    'plugin:@typescript-eslint/recommended',
  ],
  parser: '@typescript-eslint/parser',
  parserOptions: {
    ecmaFeatures: {
      jsx: true,
    },
    ecmaVersion: 11,
    sourceType: 'module',
  },
  settings: {
    'import/resolver': {
      node: {
        extensions: ['.tsx', '.ts', '.js', '.json'],
      },
    },
  },
  plugins: ['react', 'unicorn', 'promise', '@typescript-eslint'],
  rules: {
    // 具体添加的其他规则大家可查看我的 github 查看
    // https://github.com/vortesnail/react-ts-quick-starter/blob/master/.eslintrc.js
  },
}

在之后的配置过程中,我们可能还会需要对该文件进行更改😛,比如添加解决 eslint 和 prettier 的规则冲突处理插件,请大家期待一下下。

大家新建一个 hello.ts 文件,在里面打上以下代码:

var add = (a, b) => {
  console.log(a + b)
  return a + b
}

export default add

你会发现没有任何的错误提示,很明显上面的代码违反了不能使用 var 定义变量的规则,理论上来说一定会报一堆红线的~

这时候按下图看我们的 ESLint 输出:

image.png

原来是 @typescript-eslint/eslint-plugin 这个插件需要安装 typescript ,虽然我们这部分内容应该在之后再讲的,但是现在为了让大家写点代码测试看下 eslint 是否好用,我们就先安装一下吧:

npm install typescript -D

安装完之后,你再回头看看刚才那个 hello.ts 文件内的代码,是不是一堆爆红了!

image.png

我们知道 eslint 由编辑器支持是有自动修复功能的,首先我们需要安装扩展:

image.png

再到之前创建的 .vscode/settings.json 中添加以下代码:

{
  "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
  "typescript.tsdk": "./node_modules/typescript/lib", // 代替 vscode 的 ts 语法智能提示
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true,
  },
}

这时候我们保存时,就会开启 eslint 的自动修复,完美!

12.gif

不过有时候我们并不希望 ESLint 或 Prettier 去对某些文件做任何修改,比如某个特定的情况下我想去看看打包之后的文件内容,里面的内容一定是非常不符合各种 lint 规则的,但我不希望按保存时对其进行格式化,此时就需要我们添加 .eslintignore 和 .prettierignore ,我一般会使这两个文件的内容都保持一致:

/node_modules
/build
/dist

先添加以上三个需要忽略的文件目录好了,之后大家视情况而添加就行。

4. StyleLint

经过上面的一顿操作,我们的 js 或 ts 代码已经能有良好的代码风格了,但可别忘了还有样式代码的风格也需要统一啊!这个真的很有必要啊,有时候去调试其他人的样式代码,这里一坨那里一坨,看着属实难受。

image.png

根据 stylelint 官网介绍,我们先安装两个基本的包:

npm install stylelint stylelint-config-standard -D

然后在项目根目录新建 .stylelintrc.js 文件,输入以下内容:

module.exports = {
  extends: ['stylelint-config-standard'],
  rules: {
    'comment-empty-line-before': null,
    'declaration-empty-line-before': null,
    'function-name-case': 'lower',
    'no-descending-specificity': null,
    'no-invalid-double-slash-comments': null,
    'rule-empty-line-before': 'always',
  },
  ignoreFiles: ['node_modules/**/*', 'build/**/*'],
}

同样,简单介绍下配置上的三个属性:

  • extends :其实和 eslint 的类似,都是扩展,使用 stylelint 已经预设好的一些规则。
  • rules :就是具体的规则,如果默认的你不满意,可以自己决定某个规则的具体形式。
  • ignoreFiles :不像 eslint 需要新建 ignore 文件, stylelint 配置就支持忽略配置字段,我们先添加 node_modules 和 build ,之后有需要大家可自行添加。

其中关于 xxx/**/* 这种写法的意思有不理解的,大家可在 google (或百度)glob模式

eslint 一样,想要在编辑代码时有错误提示以及自动修复功能,我们需要 vscode 安装一个扩展:

image.png

并且在 .vscode/settings.json 中增加以下代码:

{
	// 使用 stylelint 自身的校验即可
  "css.validate": false,
  "less.validate": false,
  "scss.validate": false,
  
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true,
    "source.fixAll.stylelint": true
  },
}

这时候随便建一个 .less 文件测试下,已经有错误提示和保存时自动修复功能了。

123.gif

我们可以在社区下载一些优秀的 stylelint extends 和 stylelint plugins :

1.Positioning   2.Box Model    3.Typography    4.Visual    5.Animation    6.Misc
{ display: inline; width: 100px; }

我们来一波安装:

npm install stylelint-order stylelint-config-rational-order stylelint-declaration-block-no-ignored-properties -D

现在更改以下我们的配置文件:

module.exports = {
  extends: ['stylelint-config-standard', 'stylelint-config-rational-order'],
  plugins: ['stylelint-order', 'stylelint-declaration-block-no-ignored-properties'],
  rules: {
    'plugin/declaration-block-no-ignored-properties': true,
    'comment-empty-line-before': null,
    'declaration-empty-line-before': null,
    'function-name-case': 'lower',
    'no-descending-specificity': null,
    'no-invalid-double-slash-comments': null,
    'rule-empty-line-before': 'always',
  },
  ignoreFiles: ['node_modules/**/*', 'build/**/*'],
}

至此, stylelint 就配置完成了,具体的规则以及插件大家都可以在其官网进行浏览或查找,然后添加一些自己希望的规则定义。

5. lint命令

我们在 package.json 的 scripts 中增加以下三个配置:

{
	scripts: {
  	"lint": "npm run lint-eslint && npm run lint-stylelint",
    "lint-eslint": "eslint -c .eslintrc.js --ext .ts,.tsx,.js src",
    "lint-stylelint": "stylelint --config .stylelintrc.js src/**/*.{less,css,scss}"
  }
}

在控制台执行 npm run lint-eslint 时,会去对 src/ 下的指定后缀文件进行 eslint 规则检测, lint-stylelint 也是同理, npm run lint 会两者都按顺序执行。

其实我个人来说,这几个命令我是都不想写进 scripts 中的,因为我们写代码的时候,不规范的地方就已经自动修复了,只要保持良好的习惯,看到有爆红线的时候想办法去解决它,而不是视而不见,那么根本不需要对所有包含的文件再进行一次命令式的规则校验。

但是对于新提交缓存区的代码还是有必要执行一次校验的,这个后面会说到。

6. ESLint、Stylelint 和 Prettier 的冲突

有时候 eslint 和 stylelint 的自定义规则和 prettier 定义的规则冲突了,比如在 .eslintrc.js 中某个 extends 的配置设置了缩进大小为 4 ,但是我 .prettierrc 中我设置的缩进为 2 ,那就会出现我们保存时,先是 eslint 的自动修复缩进大小为 4 ,这个时候 prettier 不开心了,又强制把缩进改为了 2 ,好了, eslint 不开心,代码直接爆红!
12.gif
那么我们如何解决这部分冲突呢?

其实官方提供了很好的解决方案,查阅 Integrating with Linters 可知,针对 eslint 和 stylelint 都有很好的插件支持,其原理都是禁用与 prettier 发生冲突的规则。

安装插件 eslint-config-prettier ,这个插件会禁用所有和 prettier 起冲突的规则:

npm install eslint-config-prettier -D

添加以下配置到 .eslintrc.js 的 extends 中:

{
  extends: [
    // other configs ...
   	'prettier',
    'prettier/@typescript-eslint',
    'prettier/react',
    'prettier/unicorn',
  ]
}

这里需要注意, 'prettier' 及之后的配置要放到原来添加的配置的后面,这样才能让 prettier 禁用之后与其冲突的规则。

stylelint 的冲突解决也是一样的,先安装插件 stylelint-config-prettier

npm install stylelint-config-prettier -D

添加以下配置到 .stylelintrc.js 的 extends 中:

{  
	extends: [
  	// other configs ...
    'stylelint-config-prettier'
  ]
}

7. lint-staged

在项目开发过程中,每次提交前我们都要对代码进行格式化以及 eslint 和 stylelint 的规则校验,以此来强制规范我们的代码风格,以及防止隐性 BUG 的产生。

那么有什么办法只对我们 git 缓存区最新改动过的文件进行以上的格式化和 lint 规则校验呢?答案就是 lint-staged

我们还需要另一个工具 husky ,它会提供一些钩子,比如执行 git commit 之前的钩子 pre-commit ,借助这个钩子我们就能执行 lint-staged 所提供的代码文件格式化及 lint 规则校验!

图片名称

赶紧安装一下这两个插件吧:

npm install husky lint-staged -D

随后在 package.json 中添加以下代码(位置随意,我比较习惯放在 repository 上面):

{
	"husky": {
    "hooks": {
      "pre-commit": "lint-staged",
    }
  },
  "lint-staged": {
    "*.{ts,tsx,js}": [
      "eslint --config .eslintrc.js"
    ],
    "*.{css,less,scss}": [
      "stylelint --config .stylelintrc.js"
    ],
    "*.{ts,tsx,js,json,html,yml,css,less,scss,md}": [
      "prettier --write"
    ]
  },
}

首先,我们会对暂存区后缀为 .ts .tsx .js 的文件进行 eslint 校验, --config 的作用是指定配置文件。之后同理对暂存区后缀为 .css .less .scss 的文件进行 stylelint 校验,注意⚠️,我们没有添加 --fix 来自动修复不符合规则的代码,因为自动修复的内容对我们不透明,你不知道哪些代码被更改,这对我来说是无法接受的。

但是在使用 prettier 进行代码格式化时,完全可以添加 --write 来使我们的代码自动格式化,它不会更改语法层面上的东西,所以无需担心。

可能大家搜索一些文章的时候,会发现在 lint-staged 中还配置了一个 git add ,实际上在 v10 版本之后任何被修改了的原 staged 区的文件都会被自动 git add,所以无需再添加。

8. commitlint + changelog

在多人协作的项目中,如果 git 的提交说明精准,在后期协作以及 bug 处理时会变得有据可查,项目的开发可以根据规范的提交说明快速生成开发日志,从而方便开发者或用户追踪项目的开发信息和功能特性。

建议阅读 Commit message 和 Change log 编写指南(阮一峰)

commitlint 可以帮助我们进行 git commit 时的 message 格式是否符合规范,conventional-changelog 可以帮助我们快速生成 changelog ,至于在命令行中进行可视化的 git commit 插件 commitizen 我们就不配了,有兴趣的同学可以自行了解~

首先安装 commitlint 相关依赖:

npm install @commitlint/cli @commitlint/config-conventional -D

@commitlint/config-conventional 类似 eslint 配置文件中的 extends ,它是官方推荐的 angular 风格的 commitlint 配置,提供了少量的 lint 规则,默认包括了以下除了我自己新增的 type 。

随后在根目录新建文件 .commitlintrc.js ,这就是我们的 commitlint 配置文件,写入以下代码:

module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [
      2,
      'always',
      ['build', 'ci', 'chore', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test', 'anno'],
    ],
  },
}

我自己增加了一种 anno ,目的是表示一些注释的增删改的提交。

/**
 * build : 改变了build工具 如 webpack
 * ci : 持续集成新增
 * chore : 构建过程或辅助工具的变动
 * feat : 新功能
 * docs : 文档改变
 * fix : 修复bug
 * perf : 性能优化
 * refactor : 某个已有功能重构
 * revert : 撤销上一次的 commit
 * style : 代码格式改变
 * test : 增加测试
 * anno: 增加注释
 */

随后回到 package.json 的 husky 配置,增加一个钩子:

{
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged",
      "commit-msg": "commitlint --config .commitlintrc.js -E HUSKY_GIT_PARAMS"
    }
  },
}

-E HUSKY_GIT_PARAMS 简单理解就是会拿到我们的 message ,然后 commitlint 再去进行 lint 校验。

接着配置生成我们的 changelog ,首先安装依赖:

npm install conventional-changelog-cli -D

package.json 的 scripts 下增加一个命令:

{
  "scripts": {
    "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s"
  },
}

之后就可以通过 npm run changelog 生成 angular 风格的 changelog ,需要注意的是,上面这条命令产生的 changelog 是基于上次 tag 版本之后的变更(feat、fix 等等)所产生的。

现在就来测试一下我们上面的工作有没有正常运行吧!执行以下提交信息不规范(chore 写成 chora)的命令:

# 提交所有变化到缓存区
git add -A
# 把暂存区的所有修改提交到分支 
git commit -m "chora: add commitlint to force commit style"

像预期中的一致,出现了以下报错:

image.png

那我们现在进行我们的提交,把故意写错的改回来:

git commit -m "chore: add commitlint to force commit style"

这时候我们成功 commit ,再执行以下命令提交到远端:

git push origin master

经历了漫长的配置,我们“初步”形成了一个完善的项目开发环境,接下来就开始进入 Webpack 的世界吧!

image.png

Webpack 基本配置

我们最终的配置要支持 React 和 Typescript 的开发与生产,现在的我们的思路是将对这两个部分的支持放到最后去配置,一开始先把必要的都配好,这样大家能有一个很直观的印象,什么时候该做什么?怎么做?

对于 Webpack 的配置,我会尽量地去解释清楚每一个新增的配置都有什么用,希望大家耐心阅读~

⚠️ 目前讲解的 webpack 版本为 4+

1. 开始

想要使用 webpack,这两个包你不得不装:

npm install webpack webpack-cli -D
  • webpack :这不必多说,其用于编译 JavaScript 模块。
  • webpack-cli :此工具用于在命令行中运行 webpack。

紧接着我们在根目录下新建文件夹 scripts ,在之下再建一个文件夹 config ,在 config 中再建一个 .js 文件 webpack.common.js ,此结构如下:

scripts/
    config/
    webpack.common.js

为什么会是这样的目录结构,主要考虑到之后讲了 webpack-merge 之后,会把 webpack 的核心配置文件放到 config 下,其余的例如导出文件路径的文件模块放到 config 同级。总之大家先这样搞着,之后咱慢慢解释。

2. input、output

**入口(input)出口(output)**是 webpack 的核心概念之二,从名字就能大概感知他们是干什么的:指定一个(或多个)入口文件,经过一系列的操作之后转换成另一个(或多个)文件

接下来在 webpack.common.js 中输入以下代码:

const path = require('path')

module.exports = {
  entry: {
    app: path.resolve(__dirname, '../../src/app.js'),
  },
  output: {
    filename: 'js/[name].[hash:8].js',
    path: path.resolve(__dirname, '../../dist'),
  },
}

webpack 配置是标准的 Node.js 的 CommonJS 模块,它通过 require 来引入其他模块,通过 module.exports 导出模块,由 webpack 根据对象定义的属性进行解析。

  • entry :定义了入口文件路径,其属性名 app 表示引入文件的名字。
  • output :定义了编译打包之后的文件名以及所在路径。

这段代码的意思就是告诉 webpack,入口文件是根目录下的 src 下的 app.js 文件,打包输出的文件位置为根目录下的 dist 中,注意到 filename 为 js/[name].[hash:8].js ,那么就会在 dist 目录下再建一个 js 文件夹,其中放了命名与入口文件命名一致,并带有 hash 值的打包之后的 js 文件。

接下来在根目录创建 src 文件夹,新建 app.js 文件,输入以下代码:

const root = document.querySelector('#root')
root.innerHTML = 'hello, webpack!'

现在我们尝试使用刚才的 webpack 配置对其进行打包,那如何操作呢?
打开 package.json ,为其添加一条 npm 命令:

{
  "scripts": {
+   "build": "webpack --config ./scripts/config/webpack.common.js",
    "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
    "lint": "npm run lint-eslint && npm run lint-stylelint",
    "lint-eslint": "eslint -c .eslintrc.js --ext .ts,.tsx,.js src",
    "lint-stylelint": "stylelint --config .stylelintrc.js src/**/*.{less,css,scss}"
  },
}

--config 选项来指定配置文件

然后在控制台输入:

npm run build

等待一两秒后,你会发现根目录下真的多出了一个 dist 文件夹,里面的内容和我们 webpack 配置所想要达到的效果是一样的:一个 js 文件夹以及下面的(比如) app.e406fb9b.js 的文件。

至此,我们已经初步使用 webpack 打了一个包,接下来我们逐步开始扩展其他的配置以及相应优化吧!~

3. 公用变量文件

在上面简单的 webpack 配置中,我们发现有两个表示路径的语句:

path.resolve(__dirname, '../../src/app.js')
path.resolve(__dirname, '../../dist')
  • path.resolve :node 的官方 api,可以将路径或者路径片段解析成绝对路径。
  • __dirname :其总是指向被执行 js 文件的绝对路径,比如在我们 webpack 文件中访问了 __dirname ,那么它的值就是在电脑系统上的绝对路径,比如在我电脑上就是:
/Users/RMBP/Desktop/react-ts-quick-starter/scripts/config

所以我们上面的写法,大家可以简单理解为, path.resolve 把根据当前文件的执行路径下而找到的想要访问到的文件相对路径转换成了:该文件在系统中的绝对路径!

比如我的就是:

/Users/RMBP/Desktop/react-ts-quick-starter/src/app.js

但是大家也看出来了,这种写法需要不断的 ../../ ,这个在文件层级较深时,很容易出错且很不优雅。那我们就换个思路,都从根目录开始找所需的文件路径不久很简单了吗,相当于省略了 ../../ 这一步。

scripts 下新建一个 constant.js 文件,专门用于存放我们的公用变量(之后还会有其他的):

scripts/
	config/
  	webpack.common.js
+ constant.js

在里面定义我们的变量:

const path = require('path')

const PROJECT_PATH = path.resolve(__dirname, '../')
const PROJECT_NAME = path.parse(PROJECT_PATH).name

module.exports = { 
  PROJECT_PATH,
  PROJECT_NAME
}
  • PROJECT_PATH :表示项目的根目录。
  • PROJECT_NAME :表示项目名,目前不用,但之后的配置会用到,我们就先定义好吧~

上面两个简单的 node api 大家可以自己简单了解一下,不想了解也可以,只要明白其有啥作用就行。

然后在 webpack.common.js 中引入,修改代码:

const { resolve } = require('path')
const { PROJECT_PATH } = require('../constants')

module.exports = {
  entry: {
    app: resolve(PROJECT_PATH, './src/app.js'),
  },
  output: {
    filename: 'js/[name].[hash:8].js',
    path: resolve(PROJECT_PATH, './dist'),
  },
}

好了,现在是不是看起来清爽多了,大家可以 npm run build 验证下自己代码是不是有写错或遗漏啥的~🐶

4. 区分开发/生产环境

在 webpack 中针对开发环境与生产环境我们要分别配置,以适应不同的环境需求,比如在开发环境中,报错要能定位到源代码的具体位置,而这又需要打出额外的 .map 文件,所以在生产环境中为了不牺牲页面性能,不需要添加此功能,毕竟,没人会在生产上调试代码吧?

虽然都要分别配置,但是又有挺多基础配置是开发和生产都需要且相同的,那我们不可能写两份文件,写两次基础配置吧?这也太冗余了,不过不用担心,webpack-merge 为我们都想好了。

安装它:

npm install webpack-merge -D

scripts/config 下新建文件 webpack.dev.js 作为开发环境配置,并输入以下代码:

const { merge } = require('webpack-merge')
const common = require('./webpack.common.js')

module.exports = merge(common, {
  mode: 'development',
})

同样地,在 scripts/config 下新建文件 webpack.prod.js 作为生产环境配置,并输入以下代码:

const { merge } = require('webpack-merge')
const common = require('./webpack.common.js')

module.exports = merge(common, {
  mode: 'production',
})

在我使用 require('webpack-merge') 时,给我报了以下 eslint 的报错:
'webpack-merge' should be listed in the project's dependencies, not devDependencies.
只需要在 .eslintrc.js 中添加以下规则即可解决:
'import/no-extraneous-dependencies': [ERROR, { devDependencies: true }] 

虽然都分开了配置,但是在公共配置中,还是可能会出现某个配置的某个选项在开发环境和生产环境中采用不同的配置,这个时候我们有两种选择:

  • 一是分别在 dev 和 prod 配置文件中写一遍,common 中就不写了。
  • 二是设置某个环境变量,根据这个环境变量来判别不同环境。

显而易见,为了使代码最大的优雅,采用第二种。

cross-env 可跨平台设置和使用环境变量,不同操作系统设置环境变量的方式不一定相同,比如 Mac 电脑上使用 export NODE_ENV=development ,而 Windows 电脑上使用的是 set NODE_ENV=development ,有了这个利器,我们无需在考虑操作系统带来的差异性。

安装它:

npm install cross-env -D

然后在 package.json 中添加修改以下代码:

{
  "scripts": {
+   "start": "cross-env NODE_ENV=development webpack --config ./scripts/config/webpack.dev.js",
+   "build": "cross-env NODE_ENV=production webpack --config ./scripts/config/webpack.prod.js",
-   "build": "webpack --config ./scripts/config/webpack.common.js",
  },
}

修改 srcipt/constants.js 文件,增加一个公用布尔变量 isDev :

const isDev = process.env.NODE_ENV !== 'production'

module.exports = {
  isDev,
	// other
}

我们现在就使用这个环境变量做点事吧!记得之前配的公共配置中,我们给出口文件的名字配了 hash:8 ,原因是在生产环境中,即用户已经在访问我们的页面了,他第一次访问时,请求了比如 app.js 文件,根据浏览器的缓存策略会将这个文件缓存起来。然后我们开发代码完成了一版功能迭代,涉及到打包后的 app.js 发生了大变化,但是该用户继续访问我们的页面时,如果缓存时间没有超出或者没有人为清除缓存,那么他将继续得到的是已缓存的 app.js ,这就糟糕了。

于是,当我们文件加了 hash 后,根据入口文件内容的不同,这个 hash 值就会发生非常夸张的变化,当更新到线上,用户再次请求,因为缓存文件中找不到同名文件,就会向服务器拿最新的文件数据,这下就能保证用户使用到最新的功能。

不过,这个 hash 值在开发环境中并不需要,于是我们修改 webpack.common.js 文件:

- const { PROJECT_PATH } = require('../constants')
+ const { isDev, PROJECT_PATH } = require('../constants')

module.exports = {
	// other...
  output: {
-   filename: 'js/[name].[hash:8].js',
+   filename: `js/[name]${isDev ? '' : '.[hash:8]'}.js`,
    path: resolve(PROJECT_PATH, './dist'),
  },
}

5. mode

在我们没有设置 mode 时,webpack 默认为我们设为了 mode: 'prodution' ,所以之前打包后的 js 文件代码都没法看,因为在 production 模式下,webpack 默认会丑化、压缩代码,还有其他一些默认开启的配置。

我们只要知道,不同模式下 webpack 为为其默认开启不同的配置,有不同的优化,详细可见 webpack.mode

然后接下来大家可以分别执行以下命令,看看分别打的包有啥区别,主要感知下我们上面所说的:

# 开发环境打包
npm run start

# 生产环境打包
npm run build

6. 本地服务实时查看页面

说了这么多,我们到现在甚至连个页面都看不到,使用过各种脚手架的朋友一定很熟悉 npm run start ,它直接起一个本地服务,然后页面就出来了。而我们现在执行这个命令却只能简单的打个包,别急,我们借助 webpack-dev-serverhtml-webpack-plugin 就能实现,现在先把它们安装下来:

npm install webpack-dev-server html-webpack-plugin -D

简单介绍一下两个工具的作用:

  • html-webpack-plugin :每一个页面是一定要有 html 文件的,而这个插件能帮助我们将打包后的 js 文件自动引进 html 文件中,毕竟你不可能每次更改代码后都手动去引入 js 文件。
  • webpack-dev-server :可以在本地起一个 http 服务,通过简单的配置还可指定其端口、热更新的开启等。

现在,我们先在项目根目录下新建一个 public 文件夹,里面存放一些公用的静态资源,现在我们先在其中新建一个 index.html ,写入以下内容:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React+Typescript 快速开发脚手架</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

注意⚠️:里面有一个 div 标签,id 值为 root

因为 html-webpack-plugin 在开发和生产环境我们都需要配置,于是我们打开 webpck.common.js 增加以下内容:

const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  entry: {...},
  output: {...},
  plugins: [
  	new HtmlWebpackPlugin({
      template: resolve(PROJECT_PATH, './public/index.html'),
      filename: 'index.html',
      cache: fale, // 特别重要:防止之后使用v6版本 copy-webpack-plugin 时代码修改一刷新页面为空问题。
      minify: isDev ? false : {
        removeAttributeQuotes: true,
        collapseWhitespace: true,
        removeComments: true,
        collapseBooleanAttributes: true,
        collapseInlineTagWhitespace: true,
        removeRedundantAttributes: true,
        removeScriptTypeAttributes: true,
        removeStyleLinkTypeAttributes: true,
        minifyCSS: true,
        minifyJS: true,
        minifyURLs: true,
        useShortDoctype: true,
      },
    }),
  ]
}

可以看到,我们以 public/index.html 文件为模板,并且在生产环境中对生成的 html 文件进行了代码压缩,比如去除注释、去除空格等。

plugin 是 webpack 的核心功能,它丰富了 webpack 本身,针对是 loader 结束后,webpack打包的整个过程,它并不直接操作文件,而是基于事件机制工作,会监听 webpack 打包过程中的某些节点,执行广泛的任务。

随后在 webpack.dev.js 下增加本地服务的配置:

const { SERVER_HOST, SERVER_PORT } = require('../constants')

module.exports = merge(common, {
  mode: 'development',
  devServer: {
    host: SERVER_HOST, // 指定 host,不设置的话默认是 localhost
    port: SERVER_PORT, // 指定端口,默认是8080
    stats: 'errors-only', // 终端仅打印 error
    clientLogLevel: 'silent', // 日志等级
    compress: true, // 是否启用 gzip 压缩
    open: true, // 打开默认浏览器
    hot: true, // 热更新
  },
})

我们定义了两个新的变量 SERVER_HOST 和 SERVER_PORT ,在 constants.js 中定义它们:

const SERVER_HOST = '127.0.0.1'
const SERVER_PORT = 9000

module.exports = {
  SERVER_HOST,
  SERVER_PORT,
	// ...
}

其中提高开发幸福度的配置项:

  • stats :当设为 error-only 时,终端中只会打印错误日志,这个配置个人觉得很有用,现在开发中经常会被一堆的 warn 日志占满,比如一些 eslint 的提醒规则,编译信息等,头疼的很。
  • clientLogLevel :设为 slient 之后,原来的三条信息会变为只有一条。

image.png

  • hot :这个配置开启后,之后在配合其他配置,可以开启热更新,我们之后再说。

现在配置好了本地服务的相关配置,我们还需要回到 package.json 中修改 start 命令:

{
  "scripts": {
+   "start": "cross-env NODE_ENV=development webpack-dev-server --config ./scripts/config/webpack.dev.js",
-   "start": "cross-env NODE_ENV=development webpack --config ./scripts/config/webpack.dev.js",
  },
}

然后确认一下, src/app.js 中的代码如下:

const root = document.querySelector('#root')
root.innerHTML = 'hello, webpack!'

很简单,就是往之前在 html 文件中定义的 id 为 root 的 div 标签下加了一个字符串。
现在,执行以下命令:

npm run start

你会发现浏览器默认打开了一个页面,屏幕上出现了期待中的 hello, webpack! 。查看控制台,发现 html 文件真的就自动引入了我们打包后的文件~

image.png

至此,我们已经能利用本地服务实时进行页面更新了!当然,这远远是不够的,我们会一步一步继续,尽可能的去完善。

7. devtool

devtool 中的一些设置,可以帮助我们将编译后的代码映射回原始源代码,即大家经常听到的 source-map ,这对于调试代码错误的时候特别重要,而不同的设置会明显影响到构建和重新构建的速度。所以选择一个适合自己的很重要。

它都有哪些值可以设置,官方 devtool 说明中说的很详细,我就不具体展开了,**在这里我非常非常无敌强烈建议大家故意写一些有错误的代码,然后使用每个设置都试试看!**在开发环境中,我个人比较能接受的是 eval-source-map ,所以我会在 webpack.dev.js 中添加以下代码:

module.exports = merge(common, {
  mode: 'development',
+ devtool: 'eval-source-map',
})

在生产环境中我直接设为 none ,不需要 source-map 功能,在 webpack.prod.js 中添加以下代码:

module.exports = merge(common, {
  mode: 'production',
+ devtool: 'none',
})

通过上面配置,我们本地进行开发时,代码出现了错误,控制台的错误日志就会精确地告诉你错误的代码文件、位置等信息。比如我们在 src/app.js 中第 5 行故意写个错误代码:

const root = document.querySelector('#root')
root.innerHTML = 'hello, webpack!'

const a = 5
a = 6

其错误日志提示我们:你的 app.js 文件中第 5 行出错了,具体错误原因为 balabala.... ,赶紧看看吧~

image.png

完美!完美了吗?

image.png

如果你已经执行过多次 npm run build ,你会发现事情不简单:

image.png

妈蛋,多出了那么多 app.xxxxxxxx.js ,为了我们最终打包后没有前一次打包出来的多余文件,得想个办法处理这个问题。

8. 打包编译前清理 dist 目录

我们发现每次打出来的文件都会继续残留在 dist 目录中,当然如果你足够勤快,可以每次打包前手动清理一下,但是这种勤劳是毫无意义的。

借助 clean-webpack-plugin 可以实现每次打包前先处理掉之前的 dist 目录,以保证每次打出的都是当前最新的,我们先安装它:

npm install clean-webpack-plugin -D

打开 webpack.prod.js 文件,增加以下代码:

const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
	// other...
  plugins: [
    new CleanWebpackPlugin(),
  ],
}

它不需要你去指定要删除的目录的位置,会自动找到 output 中的 path 然后进行清除。
现在再执行一下 npm run build ,看看打出来的 dist 目录是不是干净清爽了许多?

9. 样式文件处理

如果你现在在 src/ 目录下新建一个 app.css 文件,给 #root 随便添加一个样式, app.js 中通过 import './app.css' ,再进行打包或本地服务启动,webpack 直接就会报错,因为 webpack 只会编译 .js 文件,它是不支持直接处理 .css 、 .less 或 .scss 文件的,我们需要借助 webpack 中另一个很核心的东西:**loader **。

loader 用于对模块的源代码进行转换。loader 可以使你在 import 或"加载"模块时预处理文件。因此,loader 类似于其他构建工具中“任务(task)”,并提供了处理前端构建步骤的强大方法。loader 可以将文件从不同的语言(如 TypeScript)转换为 JavaScript,或将内联图像转换为 data URL。loader 甚至允许你直接在 JavaScript 模块中 import CSS文件!

CSS 样式文件处理

处理 .css 文件我们需要安装 style-loadercss-loader

npm install style-loader css-loader -D
  • 遇到后缀为 .css 的文件,webpack 先用 css-loader 加载器去解析这个文件,遇到 @import 等语句就将相应样式文件引入(所以如果没有 css-loader ,就没法解析这类语句),计算后生成css字符串,接下来 style-loader 处理此字符串生成一个内容为最终解析完的 css 代码的 style 标签,放到 head 标签里。

  • loader 是有顺序的,webpack 肯定是先将所有 css 模块依赖解析完得到计算结果再创建 style 标签。因此应该把 style-loader 放在 css-loader 的前面(webpack loader 的执行顺序是从右到左,即从后往前)。

于是,打开我们的 webpack.common.js ,写入以下代码:

module.exports = {
	// other...
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              modules: false, // 默认就是 false, 若要开启,可在官网具体查看可配置项
              sourceMap: isDev, // 开启后与 devtool 设置一致, 开发环境开启,生产环境关闭
              importLoaders: 0, // 指定在 CSS loader 处理前使用的 laoder 数量
            },
          },
        ],
      },
    ]
  },
}

test 字段是匹配规则,针对符合规则的文件进行处理。

use 字段有几种写法:

  • 可以是一个字符串,假如我们只使用 style-loader ,只需要 use: 'style-loader' 。
  • 可以是一个数组,假如我们不对 css-loader 做额外配置,只需要 use: ['style-loader', 'css-loader']
  • 数组的每一项既可以是字符串也可以是一个对象,当我们需要在webpack 的配置文件中对 loader 进行配置,就需要将其编写为一个对象,并且在此对象的 options 字段中进行配置。比如我们上面要对 css-loader 做配置的写法。

LESS 样式文件处理

处理 .less 文件我们需要安装 lessless-loader

npm install less less-loader -D
  • 遇到后缀为 .less 文件, less-loader 会将你写的 less 语法转换为 css 语法,并转为 .css 文件。
  • less-loader 依赖于 less ,所以必须都安装。

继续在 webpack.common.js 中写入代码:

module.exports = {
	// other...
  module: {
    rules: [
      { /* ... */ },
      {
        test: /\.less$/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              modules: false,
              sourceMap: isDev,
              importLoaders: 1, // 需要先被 less-loader 处理,所以这里设置为 1
            },
          },
          {
            loader: 'less-loader',
            options: {
              sourceMap: isDev,
            },
          },
        ],
      },
    ]
  },
}

SASS 样式文件处理

处理 .scss 文件我们需要安装 node-sasssass-loader

npm install node-sass sass-loader -D
  • 遇到 .scss 后缀的文件, sass-loader 会将你写的 sass 语法转为 css 语法,并转为 .css 文件。
  • 同样地, sass-loader 依赖于 node-sass ,所以两个都需要安装。( node-sass 我不用代理就没有正常安装上过,还好我们一开始就在配置文件里设了淘宝镜像源)

继续在 webpack.common.js 中写入代码:

module.exports = {
	// other...
  module: {
    rules: [
      { /* ... */ },
      {
        test: /\.scss$/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              modules: false,
              sourceMap: isDev,
              importLoaders: 1, // 需要先被 sass-loader 处理,所以这里设置为 1
            },
          },
          {
            loader: 'sass-loader',
            options: {
              sourceMap: isDev,
            },
          },
        ],
      },
    ]
  },
}

现在,通过以上配置之后,你再把 src/app.css 改为 app.less 或 app.scss ,执行 npm run start ,你会发现咱们的样式正常加载了出来,开心噢~

PostCSS 处理浏览器兼容问题

postcss 一种对 css 编译的工具,类似 babel 对 js 一样通过各种插件对 css 进行处理,在这里我们主要使用以下插件:

  • postcss-flexbugs-fixes :用于修复一些和 flex 布局相关的 bug。
  • postcss-preset-env :将最新的 CSS 语法转换为目标环境的浏览器能够理解的 CSS 语法,目的是使开发者不用考虑浏览器兼容问题。我们使用 autoprefixer 来自动添加浏览器头。
  • postcss-normalize :从 browserslist 中自动导入所需要的 normalize.css 内容。

安装上面提到的所需的包:

npm install postcss-loader postcss-flexbugs-fixes postcss-preset-env autoprefixer postcss-normalize -D

postcss-loader 放到 css-loader 后面,配置如下:

{
  loader: 'postcss-loader',
  options: {
    ident: 'postcss',
    plugins: [
      require('postcss-flexbugs-fixes'),
      require('postcss-preset-env')({
        autoprefixer: {
          grid: true,
          flexbox: 'no-2009'
        },
        stage: 3,
      }),
      require('postcss-normalize'),
    ],
    sourceMap: isDev,
  },
},

但是我们要为每一个之前配置的样式 loader 中都要加一段这个,这代码会显得非常冗余,于是我们把公共逻辑抽离成一个函数,与 cra 一致,命名为 getCssLoaders ,因为新增了 postcss-loader ,所以我们要修改 importLoaders ,于是我们现在的 webpack.common.js 修改为以下这样:

const getCssLoaders = (importLoaders) => [
  'style-loader',
  {
    loader: 'css-loader',
    options: {
      modules: false,
      sourceMap: isDev,
      importLoaders,
    },
  },
  {
    loader: 'postcss-loader',
    options: {
      ident: 'postcss',
      plugins: [
        // 修复一些和 flex 布局相关的 bug
        require('postcss-flexbugs-fixes'),
        require('postcss-preset-env')({
          autoprefixer: {
            grid: true,
            flexbox: 'no-2009'
          },
          stage: 3,
        }),
        require('postcss-normalize'),
      ],
      sourceMap: isDev,
    },
  },
]

module.exports = {
	// other...
  module: {
    rules: [
      {
        test: /\.css$/,
        use: getCssLoaders(1),
      },
      {
        test: /\.less$/,
        use: [
          ...getCssLoaders(2),
          {
            loader: 'less-loader',
            options: {
              sourceMap: isDev,
            },
          },
        ],
      },
      {
        test: /\.scss$/,
        use: [
          ...getCssLoaders(2),
          {
            loader: 'sass-loader',
            options: {
              sourceMap: isDev,
            },
          },
        ],
      },
    ]
  },
  plugins: [//...],
}

最后,我们还得在 package.json 中添加 browserslist (指定了项目的目标浏览器的范围):

{
  "browserslist": [
    ">0.2%",
    "not dead", 
    "ie >= 9",
    "not op_mini all"
  ],
}

现在,在如果你在入口文件(比如我之前一直用的 app.js )随便引一个写了 display: flex 语法的样式文件, npm run start 看看是不是自动加了浏览器前缀了呢?快试试吧!

10. 图片和字体文件处理

我们可以使用 file-loader 或者 url-loader 来处理本地资源文件,比如图片、字体文件,而 url-loader 具有 file-loader 所有的功能,还能在图片大小限制范围内打包成 base64 图片插入到 js 文件中,这样做的好处是什么呢?别急,我们先安装所需要的包(后者依赖前者,所以都要安装):

npm install file-loader url-loader -D

然后在 webpack.common.js 中继续在 modules.rules 中添加以下代码:

module.exports = {
  // other...
  module: {
    rules: [
      // other...
      {
        test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 10 * 1024,
              name: '[name].[contenthash:8].[ext]',
              outputPath: 'assets/images',
            },
          },
        ],
      },
      {
        test: /\.(ttf|woff|woff2|eot|otf)$/,
        use: [
          {
            loader: 'url-loader',
            options: {
              name: '[name].[contenthash:8].[ext]',
              outputPath: 'assets/fonts',
            },
          },
        ],
      },
    ]
  },
  plugins: [//...],
}
  • [name].[contenthash:8].[ext] 表示输出的文件名为 原来的文件名.哈希值.后缀 ,有了这个 hash 值,可防止图片更换后导致的缓存问题。
  • outputPath 是输出到 dist 目录下的路径,即图片目录 dist/assets/images 以及字体相关目录 dist/assets/fonts 下。
  • limit 表示如果你这个图片文件大于 10240b ,即 10kb ,那我 url-loader 就不用,转而去使用 file-loader ,把图片正常打包成一个单独的图片文件到设置的目录下,若是小于了 10kb ,就将图片打包成 base64 的图片格式插入到打包之后的文件中,这样做的好处是,减少了 http 请求,但是如果文件过大,js文件也会过大,得不偿失,这是为什么有 limit 的原因!

接下来大家引一下本地的图片并放到 img 标签中,或者去 iconfont 下个字体图标试试吧~

不幸的是,当你尝试引入一张图片的时候,会有以下 ts 的报错(如果你安装了 ts 的话):

image.png

这个时候在 src/ 下新建以下文件 typings/file.d.ts ,输入以下内容即可:

declare module '*.svg' {
  const path: string
  export default path
}

declare module '*.bmp' {
  const path: string
  export default path
}

declare module '*.gif' {
  const path: string
  export default path
}

declare module '*.jpg' {
  const path: string
  export default path
}

declare module '*.jpeg' {
  const path: string
  export default path
}

declare module '*.png' {
  const path: string
  export default path
}

其实看到现在已经很不容易了,不过我相信大家仔细跟到现在的话,也会收获不少的,上面的 webpack 基本配置只是配置了最基本的功能,接下来我们要达到支持 React,TypeScript 以及一堆的开发环境和生产环境的优化,大家加油噢~

image.png

支持 React

终于来到我们 React 的支持环节了,美好的开始就是安装 react 和 react-dom :

npm install react react-dom -S

-S 相当于 --save , -D 相当于 --save-dev 。

其实安装了这两个包就已经能使用 jsx 语法了,我们在 src/index.js 中输入以下代码:

import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'

ReactDOM.render(<App />, document.querySelector('#root'))

src/app.js 中输入以下示例代码:

import React from 'react'

function App() {
  return <div className='App'>Hello World</div>
}

export default App

然后修改 webpack.common.js 中 entry 字段,修改入口文件为 index.js :

module.exports = {
  entry: {
+   app: resolve(PROJECT_PATH, './src/index.js'),
-   app: resolve(PROJECT_PATH, './src/app.js'),
  },
}

如果这时候,你无论尝试 npm run start 还是 npm run build ,结果都会报错:

image.png

诶!为啥啊,我不是都安装了 react 了吗,咋还不行啊?
因为 webpack 根本识别不了 jsx 语法,那怎么办?使用 babel-loader 对文件进行预处理。

在此,强烈建议大家先阅读一篇关于 babel 写的很好的文章:不容错过的 Babel7 知识,绝对的收获满满,我知道在自己文章中插入一个链接,让读者去阅读再回来接着读这种行为挺让人反感的,我看别人文章时也有这种感觉,但是在这里我真的不得不推荐,一定要读!一定要读!一定要读!

好了,安装该有的包:

npm install babel-loader @babel/core @babel/preset-react -D

babel-loader 使用 babel 解析文件;@babel/core 是 babel 的核心模块;@babel/preset-react 转译 jsx 语法。

在根目录下新建 .babelrc 文件,输入以下代码:

{
  "presets": ["@babel/preset-react"]
}

presets 是一些列插件集合。比如 @babel/preset-react 一般情况下会包含 @babel/plugin-syntax-jsx 、 @babel/plugin-transform-react-jsx 、 @babel/plugin-transform-react-display-name 这几个 babel 插件。

接下来打开我们的 webpack.common.js 文件,增加以下代码:

module.exports = {
	// other...
  module: {
    rules: [
      {
        test: /\.(tsx?|js)$/,
        loader: 'babel-loader',
        options: { cacheDirectory: true },
        exclude: /node_modules/,
      },
      // other...
    ]
  },
  plugins: [ //... ],
}

注意,我们匹配的文件后缀只有 .tsx 、.ts 、 .js ,我把 .jsx 的格式排除在外了,因为我不可能在 ts 环境下建 .jsx 文件,实在要用 jsx 语法的时候,用 .js 不香吗?

babel-loader 在执行的时候,可能会产生一些运行期间重复的公共文件,造成代码体积大冗余,同时也会减慢编译效率,所以我们开启 cacheDirectory 将这些公共文件缓存起来,下次编译就会加快很多。

建议给 loader 指定 include 或是 exclude,指定其中一个即可,因为 node_modules 目录不需要我们去编译,排除后,有效提升编译效率。

现在,我们可以 npm run start 看看效果了!其实 babel 还有一些其他重要的配置,我们先把 TS 支持了再一起搞!

支持 TypeScript

webpack 模块系统只能识别 js 文件及其语法,遇到 jsx 语法、tsx 语法、文件、图片、字体等就需要相应的 loader 对其进行预处理,像图片、字体这种我们上面已经配置了,为了支持 React,我们使用了 babel-loader 以及对应的插件,现在如果要支持 TypeScript 我们也需要对应的插件。

1. 安装对应 babel 插件

@babel/preset-typescript 是 babel 的一个 preset,它编译 ts 的过程很粗暴,它直接去掉 ts 的类型声明,然后再用其他 babel 插件进行编译,所以它很快。

废话补多少,先来安装它:

npm install @babel/preset-typescript -D

注意:我们之前因为 Eslint 的配置地方需要先安装 Typescript,所以之前安装过的就不用再安装一次了。

然后修改 .babelrc :

{
  "presets": ["@babel/preset-react", "@babel/preset-typescript"]
}

presets 的执行顺序是从后到前的。根据以上代码的 babel 配置,会先执行 @babel/preset-typescript ,然后再执行 @babel/preset-react 。

2. tsx 语法测试

src/ 有以下两个 .tsx 文件,代码分别如下:

index.tsx :

import React from 'react'
import ReactDOM from 'react-dom'
import App from './app'

ReactDOM.render(
  <App name='vortesnail' age={25} />,
  document.querySelector('#root')
)

app.tsx :

import React from 'react'

interface IProps {
  name: string
  age: number
}

function App(props: IProps) {
  const { name, age } = props
  return (
    <div className='app'>
      <span>{`Hello! I'm ${name}, ${age} years old.`}</span>
    </div>
  )
}

export default App

很简单的代码,在 <App /> 中输入属性时因为 ts 有了良好的智能提示,比如你不输入 name 和 age ,那么就会报错,因为在 <App /> 组件中,这两个属性时必须值!

但是这个时候如果你 npm run start ,发现是编译有错误的,我们修改 webpack.common.js 文件:

module.exports = {
  entry: {
    app: resolve(PROJECT_PATH, './src/index.tsx'),
  },
  output: {//...},
  resolve: {
    extensions: ['.tsx', '.ts', '.js', '.json'],
  },
}

一来修改了 entry 中的入口文件后缀,变为 .tsx 。

二来新增了 resolve 属性,在 extensions 中定义好文件后缀名后,在 import 某个文件的时候,比如上面代码:

import App from './app'

就可以不加文件后缀名了。webpack 会按照定义的后缀名的顺序依次处理文件,比如上文配置 ['.tsx', '.ts', '.js', '.json'] ,webpack 会先尝试加上 .tsx 后缀,看找得到文件不,如果找不到就依次尝试进行查找,所以我们在配置时尽量把最常用到的后缀放到最前面,可以缩短查找时间。

这个时候再进行 npm run start ,页面就能正确输出了。

既然都用上了 Typescript,那 React 的类型声明自然不能少,安装它们:

npm install @types/react @types/react-dom -D

3. tsconfig.json 详解

每个 Typescript 都会有一个 tsconfig.json 文件,其作用简单来说就是:

  • 编译指定的文件
  • 定义了编译选项

一般都会把 tsconfig.json 文件放在项目根目录下。在控制台输入以下代码来生成此文件:

npx tsc --init

打开生成的 tsconfig.json ,有很多注释和几个配置,有点点乱,我们就将这个文件的内容删掉吧,重新输入我们自己的配置。

此文件中现在的代码为:

{
  "compilerOptions": {
    // 基本配置
    "target": "ES5",                          // 编译成哪个版本的 es
    "module": "ESNext",                       // 指定生成哪个模块系统代码
    "lib": ["dom", "dom.iterable", "esnext"], // 编译过程中需要引入的库文件的列表
    "allowJs": true,                          // 允许编译 js 文件
    "jsx": "react",                           // 在 .tsx 文件里支持 JSX
    "isolatedModules": true,
    "strict": true,                           // 启用所有严格类型检查选项

    // 模块解析选项
    "moduleResolution": "node",               // 指定模块解析策略
    "esModuleInterop": true,                  // 支持 CommonJS 和 ES 模块之间的互操作性
    "resolveJsonModule": true,                // 支持导入 json 模块
    "baseUrl": "./",                          // 根路径
    "paths": {																// 路径映射,与 baseUrl 关联
      "Src/*": ["src/*"],
      "Components/*": ["src/components/*"],
      "Utils/*": ["src/utils/*"]
    },

    // 实验性选项
    "experimentalDecorators": true,           // 启用实验性的ES装饰器
    "emitDecoratorMetadata": true,            // 给源码里的装饰器声明加上设计类型元数据

    // 其他选项
    "forceConsistentCasingInFileNames": true, // 禁止对同一个文件的不一致的引用
    "skipLibCheck": true,                     // 忽略所有的声明文件( *.d.ts)的类型检查
    "allowSyntheticDefaultImports": true,     // 允许从没有设置默认导出的模块中默认导入
    "noEmit": true														// 只想使用tsc的类型检查作为函数时(当其他工具(例如Babel实际编译)时)使用它
  },
  "exclude": ["node_modules"]
}

compilerOptions 用来配置编译选项,其完整的可配置的字段从这里可查询到; exclude 指定了不需要编译的文件,我们这里是只要是 node_modules 下面的我们都不进行编译,当然,你也可以使用 include 去指定需要编译的文件,两个用一个就行。

接下来对 compilerOptions 重要配置做一下简单的解释:

  • target 和 module :这两个参数实际上没有用,它是通过 tsc 命令执行才能生成对应的 es5 版本的 js 语法,但是实际上我们已经使用 babel 去编译我们的 ts 语法了,根本不会使用 tsc 命令,所以它们在此的作用就是让编辑器提供错误提示。

  • isolatedModules :可以提供额外的一些语法检查。

比如不能重复 export :

import { add } from './utils'
add()

export { add } // 会报错

比如每个文件必须是作为独立的模块:

const print = (str: string) => { console.log(str) } // 会报错,没有模块导出

// 必须有 export
export print = (str: string) => { 
  console.log(str) 
}
  • esModuleInterop :允许我们导入符合 es6 模块规范的 CommonJS 模块,下面做简单说明。

比如某个包为 test.js :

// node_modules/test/index.js
exports = test

使用此包:

// 我们项目中的 app.tsx
import * as test from 'test'
test()

开启 esModuleInterop 后,直接可如下使用:

import test from 'test'
test()

接下来我们着重讲下 baseUrl 和 paths ,这两个配置真的是提升开发效率的利器啊!它的作用就是快速定位某个文件,防止多层 ../../../ 这种写法找某个模块!比如我现在的 src/ 下有这么几个文件:

image.png

我在 app.js 中要引入 src/components 下的 Header 组件,以往的方式是:

import Header from './components/Header'

大家可能觉得,蛮好的啊,没毛病。但是我这里是因为 app.tsx 和 components 是同级的,试想一下如果你在某个层级很深的文件里要用 components ,那就是疯狂 ../../../.. 了,所以我们要学会使用它,并结合 webpack 的 resolve.alias 使用更香。

但是想用它麻烦还蛮多的,咱一步步拆解它。

首先 baseUrl 一定要设置正确,我们的 tsconfig.json 是放在项目根目录的,那么 baseUrl 设为 ./ 就代表了项目根路径。于是, paths 中的每一项路径映射,比如 ["src/*"] 其实就是相对根路径。

如果大家像上面一样配置了,并自己尝试用以下方式开始进行模块的引入:

import Header from 'Components/Header'

因为 eslint 的原因,是会报错的:

image.png

这个时候需要改 .eslintrc.js 文件的配置了,首先得安装 eslint-import-resolver-typescript

npm install eslint-import-resolver-typescript -D

然后在 .eslintrc.js 文件的 setting 字段修改为以下代码:

settings: {
  'import/resolver': {
    node: {
      extensions: ['.tsx', '.ts', '.js', '.json'],
    },
    typescript: {},
  },
},

是的,只需要添加 typescript: {} 即可,这时候再去看已经没有报错了。
但是上面我们完成的工作仅仅是对于编辑器来说可识别这个路径映射,我们需要在 webpack.common.js 中的 resolve.alias 添加相同的映射规则配置:

module.exports = {
  // other...
  resolve: {
    extensions: ['.tsx', '.ts', '.js', '.json'],
    alias: {
      'Src': resolve(PROJECT_PATH, './src'),
      'Components': resolve(PROJECT_PATH, './src/components'),
      'Utils': resolve(PROJECT_PATH, './src/utils'),
    }
  },
  module: {//...},
  plugins: [//...],
}

现在,两者一致就可以正常开发和打包了!可能有的小伙伴会疑惑,我只配置 webpack 中的 alias 不就行了吗?虽然开发时会有报红,但并不会影响到代码的正确,毕竟打包或开发时 webpack 都会进行路径映射替换。是的,的确是这样,但是在 tsconfig.json 中配置,会给我们增加智能提示,比如我打字打到 Com ,编辑器就会给我们提示正确的 Components ,而且其下面的文件还会继续提示。

如果你参与过比较庞大、文件层级会很深的项目你就能明白智能提示真的很香。

image.png

更多 babel 配置

之前我们已经使用 babel 去解析 react 语法和 typescript 语法了,但是目前我们所做的也仅仅如此,你在代码中用到的 ES6+ 语法编译之后依然全部保留,然而不是所有浏览器都能支持 ES6+ 语法的,这时候就需要@babel/preset-env 来做这个苦力活了,它会根据设置的目标浏览器环境(browserslist)找出所需的插件去转译 ES6+ 语法。比如 const 或 let 转译为 var 。

但是遇到 Promise 或 .includes 这种新的 es 特性,是没办法转译到 es5 的,除非我们把这中新的语言特性的实现注入到打包后的文件中,不就行了吗?我们借助 @babel/plugin-transform-runtime 这个插件,它和 @babel/preset-env 一样都能提供 ES 新API 的垫片,都可实现按需加载,但前者不会污染原型链。

另外,babel 在编译每一个模块的时候在需要的时候会插入一些辅助函数例如 _extend ,每一个需要的模块都会生成这个辅助函数,显而易见这会增加代码的冗余,@babel/plugin-transform-runtime 这个插件会将所有的辅助函数都从 @babel/runtime-corejs3 导入(我们下面使用 corejs3),从而减少冗余性。

安装它们:

npm install @babel/preset-env @babel/plugin-transform-runtime -D
npm install @babel/runtime-corejs3 -S

注意: @babel/runtime-corejs3 的安装为生产依赖。

修改 .babelre 如下:

{
  "presets": [
    [
      "@babel/preset-env",
      {
        // 防止babel将任何模块类型都转译成CommonJS类型,导致tree-shaking失效问题
        "modules": false
      }
    ],
    "@babel/preset-react",
    "@babel/preset-typescript"
  ],
  "plungins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "corejs": {
          "version": 3,
          "proposals": true
        },
        "useESModules": true
      }
    ]
  ]
}

ok,搞定!

到此为止,我们的 react+typescript 项目开发环境已经可行了,就是说现在已经可以正常进行开发了,但是针对开发环境和生产环境,我们能做的优化还有很多,大家继续加油!

公共(common)环境优化

这部分主要针对无论开发环境还是生产环境都需要的公共配置优化。

1. 拷贝公共静态资源

大家有没有注意到,到目前为止,我们的开发页面还是没有 icon 的,就下面这个东西:
image.png
create-react-app 一样,我们将 .ico 文件放到 public/ 目录下,比如我就复制了一个 cra 的 favicon.ico 文件,然后在我们的 index.html 文件中加入以下标签:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
+   <link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React+Typescript 快速开发脚手架</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

这时候你 npm run build 打个包,我们看到 dist 目录下是没有 favicon.ico 文件的,那么 html 文件中的引入肯定就无法起效了。于是我们希望有一个手段,在打包时能把 public/ 文件夹下的静态资源复制到我们打包后生成的 dist 目录中,除非你想每次打包完手动复制,不然就借助 copy-webpack-plugin 吧!

安装它:

npm install copy-webpack-plugin -D

修改 webpack.common.js 文件,增加以下代码:

const CopyPlugin = require('copy-webpack-plugin')

module.exports = {
	plugins: [
    // 其它 plugin...
  	new CopyPlugin({
      patterns: [
        {
          context: resolve(PROJECT_PATH, './public'),
          from: '*',
          to: resolve(PROJECT_PATH, './dist'),
          toType: 'dir',
        },
      ],
    }),
  ]
}

然后你重新 npm run start ,再清下页面缓存,你会看到我们的小图标就出来了,现在你可以替换成你自己喜欢的图标了。

image.png

同样地,其它的静态资源文件,大家只要往 public/ 目录下丢,打包之后都会自动复制到 dist/ 目录下。

特别注意⚠️:在讲基础配置配置 html-webpack-plugin 时,注释中特别强调过要配置 cache: false ,如果不加的话,你代码修改之后刷新页面,html 文件不会引入任何打包出来的 js 文件,自然也没有执行任何 js 代码,特别可怕,我搞了好久,查了 copy-webpack-plugin 官方 issue 才找到的解决方案。

2. 显示编译进度

我们现在执行 npm run start 或 npm run build 之后,控制台没有任何信息能告诉我们现在编译的进度怎么样,在大型项目中,编译打包的速度往往需要很久,如果不是熟悉此项目尿性的人,基本都会认为是不是卡住了,从而极大地增强了焦虑感。。。所以,显示打包的进度是非常重要的,这是对开发者积极的正向反馈。

在我看来,人活着,心中希望真的很重要。

我们可以借助 webpackbar 来完成此项任务,安装它:

npm install webpackbar -D

webpack.common.js 增加以下代码:

const WebpackBar = require('webpackbar')

module.exports = {
	plugins: [
    // 其它 plugin...
  	new WebpackBar({
      name: isDev ? '正在启动' : '正在打包',
      color: '#fa8c16',
    }),
  ]
}

现在我们本地起服务还是打包都有进度展示了,是不是特别舒心呢?我真的很喜欢这个插件。

3. 编译时的 Typescirpt 类型检查

我们之前配置 babel 的时候说过,为了编译速度,babel 编译 ts 时直接将类型去除,并不会对 ts 的类型做检查,来看一个例子,大家看我之前创建的 src/app.tsx 文件下,我故意解构出一个事先没有声明的类型:
image.png
如上所示,我尝试解构的 wrong 是没有在我们的 IProps 中声明的,在编辑器中肯定会报错的,但是重点是,在某一刻某一个人某种情况下就是犯了这样的错误,而它没有去处理这个问题,我们接手这个项目之后,并不知道有这么个问题,然后本地开发或打包时,依然可以正常进行,这完全丧失了 typescript 类型声明所带来的优势以及带来了重大的隐性 bug!

所以,我们需要借助 fork-ts-checker-webpack-plugin ,在我们打包或启动本地服务时给予错误提示,那就安装它吧:

npm install fork-ts-checker-webpack-plugin -D

webpack.common.js 中增加以下代码:

const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin')

module.exports = {
	plugins: [
    // 其它 plugin...
  	new ForkTsCheckerWebpackPlugin({
      typescript: {
        configFile: resolve(PROJECT_PATH, './tsconfig.json'),
      },
    }),
  ]
}

现在,我们执行 npm run build 看看,会有以下错误提示:

image.png

发现问题之后我们就可以去解决它了,而不是啥都不知道任由其隐性 bug 存在。

4. 加快二次编译速度

这里所说的“二次”意思为首次构建之后的每一次构建。

有一个神器能大大提高二次编译速度,它为程序中的模块(如 lodash)提供了一个中间缓存,放到本项目 node_modules/.cache/hard-source 下,就是 hard-source-webpack-plugin ,首次编译时会耗费稍微比原来多一点的时间,因为它要进行一个缓存工作,但是再之后的每一次构建都会变得快很多!我们先来安装它:

npm install hard-source-webpack-plugin -D

webpack.common.js 中增加以下代码:

const HardSourceWebpackPlugin = require('hard-source-webpack-plugin')

module.exports = {
	plugins: [
    // 其它 plugin...
  	new HardSourceWebpackPlugin(),
  ]
}

这时候我们执行两次 npm run start 或 npm run build ,看看花费时间对比图:

image.png

随着项目变大,这个速度差距会更明显。

5. external 减少打包体积

到目前为止,我们无论是开发还是生产,都要先经过 webpack 将 react、react-dom 的代码打进我们最终生成的代码中,试想一下,当这种第三方包变得越来也多的时候,最后打出的文件将会很大,用户每次进入页面需要下载一个那么大的文件,带来的就是白屏时间变长,将会严重影响用户体验,所以我们将这种第三方包剥离出去或者采用 CDN 链接形式。

修改 webpack.common.js ,增加以下代码:

module.exports = {
	plugins: [
    // 其它 plugin...
  ],
  externals: {
    react: 'React',
    'react-dom': 'ReactDOM',
  },
}

在开发时,我们是这样使用 react 和 react-dom 的:

import React from 'react'
import ReactDOM from 'react-dom'

那么,我们最终打完的包已经不注入这两个包的代码了,肯定得有另外的方式将其引入,不然程序都无法正确运行了,于是我们打开 public/index.html ,增加以下 CDN 链接:

<!DOCTYPE html>
<html lang="en">
  <body>
    <div id="root"></div>
+   <script crossorigin src="https://unpkg.com/[email protected]/umd/react.production.min.js"></script>
+   <script crossorigin src="https://unpkg.com/[email protected]/umd/react-dom.production.min.js"></script>
  </body>
</html>

它们各自的版本可在 package.json 去确定!

然后我们对比一下添加 externals 前后的打包体积会发现相差很多。

这个时候大家就疑惑了,我无论添不添加 externals,最终需要下载的文件大小其实并没有变啊,只不过一个是一次性下载一个文件,另一个是一次性下载三个文件,大小都不变,时间应该也不变啊?其实它有以下优势:

  • http 缓存:当用户第一次下载后,之后每次进入页面,根据浏览器的缓存策略,都不需要再重新下载 react 和 react-dom。
  • webpack 编译时间减少:因为少了一步打包编译 react 和 react-dom 的工作,因此速度会提升。

跟大家说明下,关于 externals 的配置,如果是用在自己的项目里,这样配完全没问题,但是如果用该脚手架开发 react 组件,并需要发布到 npm 上的,那如果你把 react 这种依赖没有打进最终输出的包里,那么别人下载了你这个包就需要 npm install [email protected] -S ,这其实是有问题的,你无法保证别人的 react 版本和你一致,这个问题我们之后会再说,现在先提个醒~

6. 抽离公共代码

我们先来讲一下ES6中的懒加载

懒加载是优化网页首屏速度的利器,下面演示一个简单的例子,让大家明白有什么好处。

一般情况下,我们引入某个工具函数是这样的:

import { add } from './math.js';

如果这样引入,在打包之后, math.js 这个文件中的代码就会打进最终的包里,**即使这个 ****add** **方法不一定在首屏就会使用!**那么带来的坏处显而易见,我都不需要在首屏使用它,却要承担下载这个目前的多余代码的响应速度变慢的后果!

但是,如果现在我们以下面的方式进行引入:

import("./math").then(math => {
  console.log(math.add(16, 26))
})

webpack 就会自动解析这个语法,进行代码分割,打包出来之后, math.js 中的代码会被自动打成一个独立的 chunk 文件,只有我们在页面交互时调用了这个方法,页面才会下载这个文件,并执行调用的方法。

同理,我们也可以对 React 组件进行这样的懒加载,只需借助 React.lazy 和 React.Suspense 即可,下面做个简单的演示:

src/app.tsx :

import React, { Suspense, useState } from 'react'

const ComputedOne = React.lazy(() => import('Components/ComputedOne'))
const ComputedTwo = React.lazy(() => import('Components/ComputedTwo'))

function App() {
  const [showTwo, setShowTwo] = useState<boolean>(false)

  return (
    <div className='app'>
      <Suspense fallback={<div>Loading...</div>}>
        <ComputedOne a={1} b={2} />
        {showTwo && <ComputedTwo a={3} b={4} />}
        <button type='button' onClick={() => setShowTwo(true)}>
          显示Two
        </button>
      </Suspense>
    </div>
  )
}

export default App

src/components/ComputedOne/index.tsx :

import React from 'react'
import './index.scss'
import { add } from 'Utils/math'

interface IProps {
  a: number
  b: number
}

function ComputedOne(props: IProps) {
  const { a, b } = props
  const sum = add(a, b)

  return <p className='computed-one'>{`Hi, I'm computed one, my sum is ${sum}.`}</p>
}

export default ComputedOne

ComputedTwo 组件代码与 ComputedOne 组件代码相似, math.ts 是简单的求和函数,就不贴代码了。

接下来,我们 npm run start ,并打开控制台的 Network,会发现以下动态加载 chunk 文件:
12.gif
以上演示便是实现了组件的懒加载方式。接下来,执行一下 npm run build 看看打包出来了以下文件:
image.png
红线框住的文件就是两个组件( ComputedOne 和 ComputedTwo )的代码,这样带来的好处很明显:

  • 若通过懒加载引入的组件,若该组件代码不变,打出的包名也不会变,部署到生产环境后,因为浏览器缓存原因,用户不需要再次下载该文件,缩短了网页交互时间。
  • 防止把所有组件打进一个包,降低了页面首屏时间。

懒加载带来的优势不可小觑,我们沿着这个思维模式向外延伸思考,如果我们能把一些引用的第三方包也打成单独的 chunk,是否也会具有同样的优势呢?

image.png

答案是肯定的,因为第三方依赖包只要版本锁定,代码是不会有变化的,那么每一次项目代码的迭代,都不会影响到依赖包 chunk 文件的文件名,那么就会同样具有以上优势!

其实 webpack4 默认就开启该功能,所以以上演示的懒加载才会打出独立 chunk 文件,但是要将第三方依赖也打出来独立 chunk,我们需要在 webpack.common.js 中增加以下代码:

module.exports = {
	// other...
  externals: {//...},
  optimization: {
    splitChunks: {
      chunks: 'all',
      name: true,
    },
  },
}

这个时候我们 npm run build ,就会发现多了这么一个包:

image.png

这个 chunk 里放了一些我们没有通过 externals 剔除的第三方包的代码,若大家不想通过 cdn 形式引入 react 和 react-dom ,这里也可以进行相应的配置将它们单独抽离出来;另一方面,若是多页应用,还需要配置把公共模块也抽离出来,这里因为我们是搭建单页应用开发环境,就不演示了。

给大家推荐两个学习 splitChunks 配置的地方:1. webpack官方介绍;2. 理解webpack4.splitChunks

开发(dev)环境优化

这部分主要针对无论开发环境还是开发环境都需要的公共配置优化。

1. 热更新

如果你开发时忍受过稍微改一下代码,页面就会重新刷新的痛苦,那么热更新一定得学会了!可能小项目你觉得没什么,都一样快,但是项目大了每一次编译都是直击内心的痛!

image.png

所谓的热更新其实就是,页面只会对你改动的地方进行“局部刷新”,这个说法可能不严谨,但是想必大家能理解什么意思。打开 webpack.dev.js ,执行以下三个步骤即可使用:

第一步:将 devServer 下的 hot 属性设为 true 。
image.png

第二步:新增 webpack.HotModuleReplacementPlugin 插件:

const webpack = require('webpack')

module.exports = merge(common, {
  devServer: {//...},
  plugins: [
    new webpack.HotModuleReplacementPlugin(),
  ]
})

这个时候,你 npm run start 并尝试改变局部的代码,保存后发现整个页面还是会进行刷新,如果你希望得到上面所说的“局部刷新”,需要在项目入口文件加以下代码。

第三步:修改入口文件,比如我就选择 src/index.js 作为我的入口文件:

import React from 'react'
import ReactDOM from 'react-dom'
import App from './app'

if (module && module.hot) {
  module.hot.accept()
}

ReactDOM.render(<App />, document.querySelector('#root'))

这时候因为 ts 的原因会报错:
image.png
我们只需要安装 @types/webpack-env 即可:

npm install @types/webpack-env -D

现在,我们在重新 npm run start ,在页面上随便修改个代码看看,是不是不会整体刷新了?舒服~

2. 跨域请求

一般来说,利用 devServer 本来就有的 proxy 字段就能配置接口代理进行跨域请求,但是为了使构建环境的代码与业务代码分离,我们需要将配置文件独立出来,可以这样做:

第一步:在 src/ 下新建一个 setProxy.js 文件,并写入以下代码:

const proxySettings = {
  // 接口代理1
  '/api/': {
    target: 'http://198.168.111.111:3001',
    changeOrigin: true,
  },
  // 接口代理2
  '/api-2/': {
    target: 'http://198.168.111.111:3002',
    changeOrigin: true,
    pathRewrite: {
      '^/api-2': '',
    },
  },
  // .....
}

module.exports = proxySettings

配置完成,我们要在 webpack.dev.js 中要引入,并正确放大 devServer 的 proxy 字段。

第二步:简单的引入及解构下就行:

const proxySetting = require('../../src/setProxy.js')

module.exports = merge(common, {
  devServer: {
    //...
    proxy: { ...proxySetting }
  },
})

可以了!就这么简单!接下来安装我们最常用的请求发送库 axios :

npm install axios -S

src/app.tsx 中简单发个请求,就可以自己测试了,这里大家要找测试接口的话可以找下 github 的公用 api,这里我就直接蹭公司的了~

生产(prod)环境优化

这部分主要针对无论开发环境还是生产环境都需要的公共配置优化。

1. 抽离出 css 样式

抽离出单独的 chunk 文件的优势在上面“抽离公共代码”一节已经简单描述过,现在我们写的所有样式打包后都打进了 js 文件中,如果这样放任下去,该文件会变得越来越大,抽离出样式文件势在必行!

借助 mini-css-extract-plugin 进行 css 样式拆分,先安装它:

npm install mini-css-extract-plugin -D

webpack.common.js 文件(注意⚠️,是 common 文件)中增加和修改以下代码:

const MiniCssExtractPlugin = require('mini-css-extract-plugin')

const getCssLoaders = (importLoaders) => [
  isDev ? 'style-loader' : MiniCssExtractPlugin.loader,
  // ....
]

module.exports = {
	plugins: [
    // 其它 plugin...
    !isDev && new MiniCssExtractPlugin({
      filename: 'css/[name].[contenthash:8].css',
      chunkFilename: 'css/[name].[contenthash:8].css',
      ignoreOrder: false,
    }),
  ]
}

我们修改了 getCssLoaders 这个方法,原来无论在什么环境我们使用的都是 style-loader ,因为在开发环境我们不需要抽离,于是做了个判断,在生产环境下使用 MiniCssExtractPlugin.loader

我们随便写点样式,然后执行以下 npm run build ,再到 dist 目录下看看:

image.png
可以看到成功拆出来了样式 chunk 文件,享用了至尊级待遇!

2. 去除无用样式

我在样式文件中故意为某个不会用到的类名加了个样式:
image.png
结果我执行打包,找到这个分离出的样式文件点进去一看:
image.png
它默认还是保留这个样式了,这显然是无意义的代码,所以我们要想办法去除它,所幸有 purgecss-webpack-plugin 这个利器,让我们先安装它及路径查找利器 node-glob

npm install purgecss-webpack-plugin glob -D

然后在 webpack.prod.js 中增加以下代码:

const { resolve } = require('path')
const glob = require('glob')
const PurgeCSSPlugin = require('purgecss-webpack-plugin')
const { PROJECT_PATH } = require('../constants')

module.exports = merge(common, {
	// ...
  plugins: [
    new PurgeCSSPlugin({
      paths: glob.sync(`${resolve(PROJECT_PATH, './src')}/**/*.{tsx,scss,less,css}`, { nodir: true }),
    }),
  ],
})

简单解释下上面的配置:
glob 是用来查找文件路径的,我们同步找到 src 下面的后缀为 .tsx 、 .(sc|c|le)ss 的文件路径并以数组形式返给 paths ,然后该插件就会去解析每一个路径对应的文件,将无用样式去除; nodir 即去除文件夹的路径,加快处理速度。为了直观给大家看下路径数组,打印出来是这个样子:

[
  '/Users/RMBP/Desktop/react-ts-quick-starter/src/app.scss',
  '/Users/RMBP/Desktop/react-ts-quick-starter/src/app.tsx',
  '/Users/RMBP/Desktop/react-ts-quick-starter/src/components/ComputedOne/index.scss',
  '/Users/RMBP/Desktop/react-ts-quick-starter/src/components/ComputedOne/index.tsx',
  '/Users/RMBP/Desktop/react-ts-quick-starter/src/components/ComputedTwo/index.scss',
  '/Users/RMBP/Desktop/react-ts-quick-starter/src/components/ComputedTwo/index.tsx',
  '/Users/RMBP/Desktop/react-ts-quick-starter/src/index.tsx'
]

大家要注意⚠️:一定也要把引入样式的 tsx 文件的路径也给到,不然无法解析你没有哪个样式类名,自然也无法正确剔除无用样式了。

现在再看看我们打包出来的样式文件,已经没有了那个多余的代码,简直舒服!

3. 压缩 js 和 css 代码

在生产环境,压缩代码是必须要做的工作,其打包出的文件体积能减少一大半呢!

js 代码压缩

webpack4 中 js 代码压缩神器 terser-webpack-plugin 可谓是无人不知了吧?它支持对 ES6 语法的压缩,且在 mode 为 production 时默认开启,是的,webpack4 完全内置,不过我们为了能对它进行一些额外的配置,还是需要先安装它的:

npm install terser-webpack-plugin -D

webpack.common.js 文件中的 optimization 增加以下配置:

module.exports = {
	// other...
  externals: {//...},
  optimization: {
    minimize: !isDev,
    minimizer: [
      !isDev && new TerserPlugin({
        extractComments: false,
        terserOptions: {
          compress: { pure_funcs: ['console.log'] },
        }
      })
    ].filter(Boolean),
    splitChunks: {//...},
  },
}

首先增加了 minimize ,它可以指定压缩器,如果我们设为 true ,就默认使用 terser-webpack-plugin ,设为 false 即不压缩代码。接下来在 minimize 中判断如果是生产环境,就开启压缩。

  • extractComments 设为 false 意味着去除所有注释,除了有特殊标记的注释,比如 @preserve 标记,后面我们会利另一个插件来生成我们的自定义注释。
  • pure_funcs 可以设置我们想要去除的函数,比如我就将代码中所有 console.log 去除。

css 代码压缩

同样也是耳熟能详的 css 压缩插件 optimize-css-assets-webpack-plugin ,直接安装它:

npm install optimize-css-assets-webpack-plugin -D

在我们上面配置过的 minimizer 新增一段代码即可:

module.exports = {
  optimization: {
    minimizer: [
      // terser
      !isDev && new OptimizeCssAssetsPlugin()
    ].filter(Boolean),
  },
}

4. 添加包注释

上面我们配置 terser 时说过,打包时会把代码中所有注释去除,除了一些有特殊标记的比如 @preserve 这种就会保留。我们希望别人在使用我们开发的包时,可以看到我们自己写好的声明注释(比如 react 就有),就可以使用 webpack 内置的 BannerPlugin ,无需安装!

webpack.prod.js 文件中增加以下代码,并写入自己想要的声明注释即可:

const webpack = require('webpack')

module.exports = merge(common, {
  plugins: [
    // ...
    new webpack.BannerPlugin({
      raw: true,
      banner: '/** @preserve Powered by react-ts-quick-starter (https://github.com/vortesnail/react-ts-quick-starter) */',
    }),
  ],
})

这时候打个包去 dist 目录下看看出口文件:

image.png

5. tree-shaking

tree-shaking 是 webpack 内置的打包代码优化神器,在生产环境下,即 mode 设置为 production 时,打包后会将通过 ES6 语法 import 引入的未使用的代码去除。下面我们简单举个例子:

src/utils/math.ts 中写入以下代码:

export function add(a: number, b: number) {
  console.info('I am add func')
  return a + b
}

export function minus(a: number, b: number) {
  console.info('I am minus func')
  return a - b
}

回到我们的 src/app.tsx 中,清除以前的内容,写入以下代码:

import React from 'react'
import { add, minus } from 'Utils/math'

function App() {
  return <div className='app'>{add(5, 6)}</div>
}

export default App

可以看到,我们同时引入来 add 和 minus 方法,但是实际使用时只使用了 add 方法,这时候我们 build 一下,打开打包后的文件搜索 console.info('I am minus func') 是搜不到的,但却搜到了 console.info('I am add func') 意味着这个方法因为没有被使用导致被删除,这就是 tree-shaking 的作用!

在我开发的项目时,我不会去 package.json 中配置 sideEffects: false ,因为我写的模块我能保证没有副作用。

这里大家有必要回忆一下,在 .babelrc 中我们在 @babel/preset-env 下配置了 module: false ,目的在于不要将 import 和 export 关键字处理成 commonJS 的模块导出引入方式,比如 require ,这样的话才能支持 tree-shaking,因为我们上面说了,在 ES6 模块导入方式下才会有效。

6. 打包分析

有时候我们想知道打出的包都有哪些,具体多大,只需借助 webpack-bundle-analyzer 即可,我们安装它:

npm install webpack-bundle-analyzer -D

打开 webpack.prod.js 增加以下 plugin 即可:

const webpack = require('webpack')

module.exports = merge(common, {
  plugins: [
    // ...
    new BundleAnalyzerPlugin({
      analyzerMode: 'server',					// 开一个本地服务查看报告
      analyzerHost: '127.0.0.1',			// host 设置
      analyzerPort: 8888,							// 端口号设置
    }),
  ],
})

这时候我们 npm run build 完成后,就会打开默认浏览器,出现一下 bundle 分析页面:

image.png
尽情想用吧!~

前半部分结语

大家跟着读到这里,或者跟着做到这里,相信大家感觉一定不虚此行了吧?现在完成的配置已经是可以进行正常的开发了,至于项目中经常用到的 react-router-dom 、 react-redux 、 mobx 等更多的库大家就按照正常开发时安装使用就可以。

接下来后半部分我想以两个案例讲解使用现用我们搭出来的架子开发 React 组件和常规工具并发布至 npm 的全流程,内容分别如下:

  • 利用 rollup 和 tsc 打包工具包并发布至 npm 全流程
  • 利用 rollup 和 tsc 打包开发的 react 组件并发布至 npm 全流程

这一部分能讲的东西也是满多的,我会新起另一篇文章讲解,大家敬请期待吧!这篇文章我前后花了大概一个月时间,都是利用工作之余时间写的,希望大家能给予一点小小的鼓励,只需要给我的github/blog一个小小的 star✨ 即可让我元气满满!球球了🙏!!!

image.png

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.