y_ck

y_ck 查看完整档案

杭州编辑南开大学  |  计算机 编辑  |  填写所在公司/组织 github.com/KieSun 编辑
编辑

Hi there,I'm yck 👋

目前任职于酷家乐业务架构组,有需要的内推的可以发邮件至 zx597813039@gmail.com。

个人动态

y_ck 发布了文章 · 4月13日

开源项目都在用 monorepo,但是你知道居然有那么多坑么?

前言

今天文章的话题是 monorepo。在进入正文之前,笔者先来概括下什么是 monorepo 以及本文会从哪几个点来聊聊 monorepo。

monorepo 简单来说就是将多个项目整合到了一个仓库里来管理,很多开源库都采用了这种代码管理方式,比如 Vue 3.0:

1617799085854

从上图我们可以看到 packages 文件夹下存在一堆文件夹,这每个文件夹都对应一个 npm 包,我们把这一些 npm 包都管理在一个仓库下了。

了解 monorepo 的读者肯定听过 lerna,想必也看过不少 lerna 配置相关的文章。本文不会来聊 lerna 该怎么怎么配置,而是主要来聊聊当我们使用 monorepo 后会引入哪些问题?lerna 这些工具链解决了什么问题以及是如何解决的,总的来说将会从以下几点来聊聊 monorepo:

  • 对比一下几种代码管理方式的不同处
  • 这些代码管理方式各自有什么优缺点,为什么我们会选择 monorepo
  • 选择 monorepo 会给我们带来哪些挑战
  • 市面上流行的工具链,比如 lerna 是如何帮助我们解决问题的

两种代码管理的方式及优缺点

目前流行的就两种代码管理方式,分别为:

  • multi repo
  • mono repo

j55G0G

接下来聊聊它们各自的优缺点。

开发

mono repo

✅ 只需在一个仓库中开发,编码会相当方便。

✅ 代码复用高,方便进行代码重构。

❌ 项目如果变的很庞大,那么 git clone、安装依赖、构建都会是一件耗时的事情。

multi repo

✅ 仓库体积小,模块划分清晰。

❌ 多仓库来回切换(编辑器及命令行),项目一多真的得晕。如果仓库之间存在依赖,还得各种 npm link

❌ 不利于代码复用。

工程配置

mono repo

✅ 工程统一标准化

multi repo

❌ 各个团队可能各自有一套标准,新建一个仓库又得重新配置一遍工程及 CI / CD 等内容。

依赖管理

mono repo

✅ 共同依赖可以提取至 root,版本控制更加容易,依赖管理会变的方便。

multi repo

❌ 依赖重复安装,多个依赖可能在多个仓库中存在不同的版本,npm link 时不同项目的依赖可能会存在冲突问题。

代码管理

mono repo

❌ 代码全在一个仓库,项目一大,几个 G 的话,用 Git 管理会存在问题。

multi repo

✅ 各个团队可以控制代码权限,也几乎不会有项目太大的问题。

部署

这部分两者其实都存在问题。

multi repo 的话,如果各个包之间不存在依赖关系倒没事,一旦存在依赖关系的话,开发者就需要在不同的仓库按照依赖先后顺序去修改版本及进行部署。

而对于 mono repo 来说,有工具链支持的话,部署会很方便,但是没有工具链的话,存在的问题一样蛋疼,后续文章中会讲到。

看了上文中的对比,相信读者应该是能认识到 mono repo 在一些痛点上还是解决得很不错的,这也是很多开源项目采用它的原因。但是实际上当我们引入 mono repo 架构以后,又会带来一大堆新的问题,无非市面上的工具链帮我们解决了大部分问题,比如 lerna。

接下来笔者就来聊聊 monorepo 在不使用工具链的情况下会存在哪些问题,以及市面上的工具链是如何解决问题的。

monorepo 带来了什么问题

安装依赖

各个包之间都存在各自的依赖,有些依赖可能是多个包都需要的,我们肯定是希望相同的依赖能提升到 root 目录下安装,其它的依赖装哪都行。

此时我们可以通过 yarn 来解决问题(npm 7 之前不行),需要在 package.json 中加上 workspaces 字段表明多包目录,通常为 packages

之后当我们安装依赖的时候,yarn 会尽量把依赖拍平装在根目录下,存在版本不同情况的时候会把使用最多的版本安装在根目录下,其它的就装在各自目录里。

这种看似正确的做法,可能又会带来更恶心的问题。

比如说多个 package 都依赖了 React,但是它们版本并不都相同。此时 node_modules 里可能就会存在这种情况:根目录下存在这个 React 的一个版本,包的目录中又存在另一个依赖的版本。

guYtrn

因为 node 寻找包的时候都是从最近目录开始寻找的,此时在开发的过程中可能就会出现多个 React 实例的问题,熟悉 React 开发的读者肯定知道这就会报错了。

遇到这种情况的时候,我们就得用 resolutions 去解决问题,当然也可以通过阻止 yarn 提升共同依赖来解决(更麻烦了)。笔者已经不止一次遇到过这种问题,多是安装依赖的依赖造成的多版本问题。

link

在 multi repo 中各种 link 已经够头疼了,我可不想在 mono repo 中继续 link 了。

此时 yarn 又拯救了我们,在安装依赖的时候会帮助我们将各个 package 软链到根目录中,这样每个 package 就能找到另外的 package 以及依赖了。

但是实际上这样的方式还会带来一个坑。因为各个 package 都能访问到拍平在根目录中的依赖了,因此此时其实我们无需在 package.json 中声明 dependencies 就能使用别人的依赖了。这种情况很可能会造成我们最终忘了加上 dependencies,一旦部署上线项目就运行不起来了。

以上两块主要聊了依赖以及 link 层面的问题,这部分我们可以直接通过 yarn 解决,虽然又引入了别的问题。

zQRUpt

接下来聊聊 mono repo 在 CI 中会遇到的挑战,包括了构建、单测、部署环节。

构建

构建是我们会遇到的第一个问题。这时候可能有些读者就会说了,构建不就是跑个 build 么,能有个啥问题。哎,接下来我就跟你聊聊这些问题。

首先因为所有包都存在一个仓库中了,如果每次执行 CI 的时候把所有包都构建一遍,那么一旦代码量变多,每次构建可能都要花上不少的时间。

这时候肯定有读者会想到增量构建,每次只构建修改了代码的 package,这个确实能够解决问题,核心代码也很简单:

git diff --name-only {git tag / commit sha} --{package path}

上述命令的功能是寻找从上次的 git tag 或者初次的 commit 信息中查找某个包是否存在文件变更,然后我们拿到这些信息只针对变更的包做构建就行。但是注意这个命令的前提是在部署的时候打上 tag,否则就找不到上次部署的节点了。

但是单纯这样的做法是不够的,因为在 mono repo 中我们还会遇到多个 package 之间有依赖的场景

RdUElM

在这种情况下假如此时在 CI 中发现只有 A 包需要构建并且只去构建了 A 包,那么就会出现问题:在 TS 环境下肯定会报错找不到 D 包的类型。

在这种存在包于包之间有依赖的场景时,我们需要去构建一个有向无环图(DAG)来进行拓扑排序,关于这个概念有兴趣的读者可以自行查阅资料。

总之在这种场景下,我们需要寻找出各个包之间的依赖关系,然后根据这个关系去构建。比如说 A 包依赖了 D 包,当我们在构建 A 包之前得先去构建 D 包才成。

以上是没有工具链时可能会出现的问题。如果我们用上 lerna 的话,内置的一些命令就可以基本帮助我们解决问题了:

  • lerna changed 寻找代码有变动的包,接下来我们就可以自己去进行增量构建了。
  • 通过 lerna 执行命令,本身就会去进行拓扑排序,所以包之间存在依赖时的构建问题也就被解决了。

总结一下构建时我们会遇到的问题:

T95y1Q

单测

单测的问题其实和构建遇到的问题类似。每次把所有用例都跑一遍,可能耗时比构建还长,引入增量单测很有必要。

这个需求一般来说单测工具都会提供,比如 Jest 通过以下命令我们就能实现需求了:

jest --coverage --changedSince=master

但是这种单测方式会引来一个小问题:单测覆盖率是以「测试用例覆盖的代码 / 修改过的代码」来算的,很可能会出现覆盖率不达标的问题,虽然整体的单测覆盖率可能是达标的。 常写单测的读者肯定知道有时候一部分代码就是很难写单测,出现这种问题也在所难免,但是如果我们在 CI 中配置了低于覆盖率就不能通过 CI 的话就会有点蛋疼。

当然这个问题其实仁者见仁智者见智,往好了说也是在提高每次 commit 的代码质量。

部署

部署是最重要的一环了,这里会遇到的问题也是最复杂的,当然大部分问题其实之前都解决过了,问题大致可分为:

  • 如何给单个 package 部署?
  • 单个 package 部署时有依赖关系如何解决?
  • package 部署时版本如何自动计算?

首先来看前两个问题。

第一个问题的解决办法其实和增量构建那边做法一样,通过命令找到修改过代码的 package 就行。但是光找到需要部署的 package 还不够,我们还需要通过拓扑排序看看这个 package 有没有被别的 package 所依赖。如果被别的 package 所依赖的话,依赖方即使代码没有变动也是需要进行部署的,这就是第二个问题的解决方案。

第三个问题解决起来涉及的东西会有点多,笔者之前也给自动化部署系统写过一篇文章:链接 ,有兴趣的读者可以一读。

这里笔者就简短地聊聊解决方案。

首先我们需要引入 commitizen 这个工具。

这个工具可以帮助我们提交规范化的 commit 信息:

上图中最重要的就是 feat、fix 这些信息,我们需要根据这个 type 信息来计算最终的部署版本号。

接下来在 CI 中我们需要分析这个规范化的 commit 信息来得出 type。

其实原理很简单,还是用到了 git command:

git log -E --format=%H=%B

对于以上 commit,我们可以通过执行命令得出以下结果:

当然这样分析是把当前分支的所有 commit 都分析进去了,大部分发版时候我们只需要分析上次发版至今的所有变更,因此需要修正 command 为:

git log 上次的 commit id...HEAD -E --format=%H=%B

最后我们就可以通过正则来拿到 type,然后通过 semver 计算出版本号。

当然了,lerna 这些问题也能帮我们解决的差不多了:

lerna publish --conventional-commits

执行以上代码就基本解决了部署会遇到的问题,当然如果自己去实现这套内容会方便自定义一些。

总结一下部署环节中我们可能会遇到的问题:

kGUdyE

工具链带来的好处及坏处

从上文中读者们应该也可以发现这些 monorepo 的工具链帮助我们解决了很多问题,以至于把这些问题都隐藏了起来,导致了很多开发者可能都不了解使用 monorepo 到底会带来哪些问题。

另外这些工具链也并不是完美的,使用它们以后其实又会带来一些别的问题。

比如说我们用 yarn workspaces 解决了 link 以及安装依赖的问题,但是又带来了版本间的冲突以及非法访问依赖的问题,解决这些问题我们可能又得引入新的包管理器,比如 pnpm 来解决。

总的来说,在编程世界里还真的没啥银弹,看似不错的工具,在帮助我们解决了不少问题的同时必然又会引入新的问题,选择工具无非是在看当下哪个使用起来成本更低收益更大罢了。

总结

mono repo 并不是银弹,使用这个架构还是会带来很多问题,无非市面上的工具链帮助我们解决了大部分问题。文章主要聊了聊在没有这些工具链的时候我们可能会遇到哪些问题,以及使用这些工具后解决了什么又带来了什么。

查看原文

赞 1 收藏 1 评论 0

y_ck 发布了文章 · 4月7日

17K star 仓库,解决 90% 的大厂基础面试题

前言

笔者开源的前端进阶之道已有三年之久,至今也有 17k star,承蒙各位读者垂爱。在当下部分内容已经略微过时,因此决定提笔翻新内容。

翻新后的内容会全部集合在「干爆前端」中,有兴趣的读者可以前往查看。

阅读前重要提示:

本文非百科全书,只专为面试复习准备、查漏补缺、深入某知识点的引子、了解相关面试题等准备。

笔者一直都是崇尚学会面试题底下涉及到的知识点,而不是刷一大堆面试题,结果变了个题型就不会的那种。所以本文和别的面经不一样,旨在提炼面试题底下的常用知识点,而不是甩一大堆面试题给各位看官。

大家也可以在笔者的网站上阅读,体验更佳!

数据类型

JS 数据类型分为两大类,九个数据类型:

  1. 原始类型
  2. 对象类型

其中原始类型又分为七种类型,分别为:

  • boolean
  • number
  • string
  • undefined
  • null
  • symbol
  • bigint

对象类型分为两种,分别为:

  • Object
  • Function

其中 Object 中又包含了很多子类型,比如 ArrayRegExpMathMapSet 等等,也就不一一列出了。

原始类型存储在栈上,对象类型存储在堆上,但是它的引用地址还是存在栈上。

注意:以上结论前半句是不准确的,更准确的内容我会在闭包章节里说明。

常见考点

  • JS 类型有哪些?
  • 大数相加、相乘算法题,可以直接使用 bigint,当然再加上字符串的处理会更好。
  • NaN 如何判断

另外还有一类常见的题目是对于对象的修改,比如说往函数里传一个对象进去,函数内部修改参数。

function test(person) {
  person.age = 26
  person = {}

  return person
}
const p1 = {
  age: 25
}

这类题目我们只需要牢记以下几点:

  1. 对象存储的是引用地址,传来传去、赋值给别人那都是在传递值(存在栈上的那个内容),别人一旦修改对象里的属性,大家都被修改了。
  2. 但是一旦对象被重新赋值了,只要不是原对象被重新赋值,那么就永远不会修改原对象。

类型判断

类型判断有好几种方式。

typeof

原始类型中除了 null,其它类型都可以通过 typeof 来判断。

typeof null 的值为 object,这是因为一个久远的 Bug,没有细究的必要,了解即可。如果想具体判断 null 类型的话直接 xxx === null 即可。

对于对象类型来说,typeof 只能具体判断函数的类型为 function,其它均为 object

instanceof

instanceof 内部通过原型链的方式来判断是否为构建函数的实例,常用于判断具体的对象类型。

[] instanceof Array

都说 instanceof 只能判断对象类型,其实这个说法是不准确的,我们是可以通过 hake 的方式得以实现,虽然不会有人这样去玩吧。

class CheckIsNumber {
  static [Symbol.hasInstance](number) {
    return typeof number === 'number'
  }
}

// true
1 instanceof CheckIsNumber

另外其实我们还可以直接通过构建函数来判断类型:

// true
[].constructor === Array

Object.prototype.toString

前几种方式或多或少都存在一些缺陷,Object.prototype.toString 综合来看是最佳选择,能判断的类型最完整。

上图是一部分类型判断,更多的就不列举了,[object XXX] 中的 XXX 就是判断出来的类型。

isXXX API

同时还存在一些判断特定类型的 API,选了两个常见的:

常见考点

  • JS 类型如何判断,有哪几种方式可用
  • instanceof 原理
  • 手写 instanceof

类型转换

类型转换分为两种情况,分别为强制转换及隐式转换。

强制转换

强制转换就是转成特定的类型:

Number(false) // -> 0
Number('1') // -> 1
Number('zb') // -> NaN
(1).toString() // '1'

这部分是日常常用的内容,就不具体展开说了,主要记住强制转数字和布尔值的规则就行。

转布尔值规则:

  • undefined、null、false、NaN、''、0、-0 都转为 false
  • 其他所有值都转为 true,包括所有对象。

转数字规则:

  • true 为 1,false 为 0
  • null 为 0,undefinedNaNsymbol 报错
  • 字符串看内容,如果是数字或者进制值就正常转,否则就 NaN
  • 对象的规则隐式转换再讲

隐式转换

隐式转换规则是最烦的,其实笔者也记不住那么多内容。况且根据笔者目前收集到的最新面试题来说,这部分考题基本绝迹了,当然讲还是讲一下吧。

对象转基本类型:

  • 调用 Symbol.toPrimitive,转成功就结束
  • 调用 valueOf,转成功就结束
  • 调用 toString,转成功就结束
  • 报错

四则运算符:

  • 只有当加法运算时,其中一方是字符串类型,就会把另一个也转为字符串类型
  • 其他运算只要其中一方是数字,那么另一方就转为数字

== 操作符

常见考点

如果这部分规则记不住也不碍事,确实有点繁琐,而且考的也越来越少了,拿一道以前常考的题目看看吧:

[] == ![] // -> ?

this

this 是很多人会混淆的概念,但是其实他一点都不难,不要被那些长篇大论的文章吓住了(我其实也不知道为什么他们能写那么多字),你只需要记住几个规则就可以了。

普通函数

function foo() {
    console.log(this.a)
}
var a = 1
foo()

var obj = {
    a: 2,
    foo: foo
}
obj.foo()

// 以上情况就是看函数是被谁调用,那么 `this` 就是谁,没有被对象调用,`this` 就是 `window`

// 以下情况是优先级最高的,`this` 只会绑定在 `c` 上,不会被任何方式修改 `this` 指向
var c = new foo()
c.a = 3
console.log(c.a)

// 还有种就是利用 call,apply,bind 改变 this,这个优先级仅次于 new

箭头函数

因为箭头函数没有 this,所以一切妄图改变箭头函数 this 指向都是无效的。

箭头函数的 this 只取决于定义时的环境。比如如下代码中的 fn 箭头函数是在 windows 环境下定义的,无论如何调用,this 都指向 window

var a = 1
const fn = () => {
  console.log(this.a)
}
const obj = {
  fn,
  a: 2
}
obj.fn()

常见考点

这里一般都是考 this 的指向问题,牢记上述的几个规则就够用了,比如下面这道题:

const a = {
  b: 2,
  foo: function () { console.log(this.b) }
}

function b(foo) {
  // 输出什么?
  foo()
}

b(a.foo)

闭包

首先闭包正确的定义是:假如一个函数能访问外部的变量,那么这个函数它就是一个闭包,而不是一定要返回一个函数。这个定义很重要,下面的内容需要用到。

let a = 1
// fn 是闭包
function fn() {
  console.log(a);
}

function fn1() {
  let a = 1
  // 这里也是闭包
  return () => {
    console.log(a);
  }
}
const fn2 = fn1()
fn2()

大家都知道闭包其中一个作用是访问私有变量,就比如上述代码中的 fn2 访问到了 fn1 函数中的变量 a。但是此时 fn1 早已销毁,我们是如何访问到变量 a 的呢?不是都说原始类型是存放在栈上的么,为什么此时却没有被销毁掉?

接下来笔者会根据浏览器的表现来重新理解关于原始类型存放位置的说法。

先来说下数据存放的正确规则是:局部、占用空间确定的数据,一般会存放在栈中,否则就在堆中(也有例外)。 那么接下来我们可以通过 Chrome 来帮助我们验证这个说法说法。

上图中画红框的位置我们能看到一个内部的对象 [[Scopes]],其中存放着变量 a,该对象是被存放在堆上的,其中包含了闭包、全局对象等等内容,因此我们能通过闭包访问到本该销毁的变量。

另外最开始我们对于闭包的定位是:假如一个函数能访问外部的变量,那么这个函数它就是一个闭包,因此接下来我们看看在全局下的表现是怎么样的。

let a = 1
var b = 2
// fn 是闭包
function fn() {
  console.log(a, b);
}

从上图我们能发现全局下声明的变量,如果是 var 的话就直接被挂到 globe 上,如果是其他关键字声明的话就被挂到 Script 上。虽然这些内容同样还是存在 [[Scopes]],但是全局变量应该是存放在静态区域的,因为全局变量无需进行垃圾回收,等需要回收的时候整个应用都没了。

只有在下图的场景中,原始类型才可能是被存储在栈上。

这里为什么要说可能,是因为 JS 是门动态类型语言,一个变量声明时可以是原始类型,马上又可以赋值为对象类型,然后又回到原始类型。这样频繁的在堆栈上切换存储位置,内部引擎是不是也会有什么优化手段,或者干脆全部都丢堆上?只有 const 声明的原始类型才一定存在栈上?当然这只是笔者的一个推测,暂时没有深究,读者可以忽略这段瞎想。

因此笔者对于原始类型存储位置的理解为:局部变量才是被存储在栈上,全局变量存在静态区域上,其它都存储在堆上。

当然这个理解是建立的 Chrome 的表现之上的,在不同的浏览器上因为引擎的不同,可能存储的方式还是有所变化的。

常见考点

闭包能考的很多,概念和笔试题都会考。

概念题就是考考闭包是什么了。

笔试题的话基本都会结合上异步,比如最常见的:

for (var i = 0; i < 6; i++) {
  setTimeout(() => {
    console.log(i)
  })
}

这道题会问输出什么,有哪几种方式可以得到想要的答案?

new

new 操作符可以帮助我们构建出一个实例,并且绑定上 this,内部执行步骤可大概分为以下几步:

  1. 新生成了一个对象
  2. 对象连接到构造函数原型上,并绑定 this
  3. 执行构造函数代码
  4. 返回新对象

在第四步返回新对象这边有一个情况会例外:

function Test(name) {
  this.name = name
  console.log(this) // Test { name: 'yck' }
  return { age: 26 }
}
const t = new Test('yck')
console.log(t) // { age: 26 }
console.log(t.name) // 'undefined'

当在构造函数中返回一个对象时,内部创建出来的新对象就被我们返回的对象所覆盖,所以一般来说构建函数就别返回对象了(返回原始类型不影响)。

常见考点

  • new 做了那些事?
  • new 返回不同的类型时会有什么表现?
  • 手写 new 的实现过程

作用域

作用域可以理解为变量的可访问性,总共分为三种类型,分别为:

  • 全局作用域
  • 函数作用域
  • 块级作用域,ES6 中的 letconst 就可以产生该作用域

其实看完前面的闭包、this 这部分内部的话,应该基本能了解作用域的一些应用。

一旦我们将这些作用域嵌套起来,就变成了另外一个重要的知识点「作用域链」,也就是 JS 到底是如何访问需要的变量或者函数的。

首先作用域链是在定义时就被确定下来的,和箭头函数里的 this 一样,后续不会改变,JS 会一层层往上寻找需要的内容。

其实作用域链这个东西我们在闭包小结中已经看到过它的实体了:[[Scopes]]

图中的 [[Scopes]] 是个数组,作用域的一层层往上寻找就等同于遍历 [[Scopes]]

常见考点

  • 什么是作用域
  • 什么是作用域链

原型

原型在面试里只需要几句话、一张图的概念就够用了,没人会让你长篇大论讲上一堆内容的,问原型更多的是为了引出继承这个话题。

根据上图,原型总结下来的概念为:

  • 所有对象都有一个属性 __proto__ 指向一个对象,也就是原型
  • 每个对象的原型都可以通过 constructor 找到构造函数,构造函数也可以通过 prototype 找到原型
  • 所有函数都可以通过 __proto__ 找到 Function 对象
  • 所有对象都可以通过 __proto__ 找到 Object 对象
  • 对象之间通过 __proto__ 连接起来,这样称之为原型链。当前对象上不存在的属性可以通过原型链一层层往上查找,直到顶层 Object 对象,再往上就是 null

常见考点

  • 聊聊你理解的原型是什么

继承

即使是 ES6 中的 class 也不是其他语言里的类,本质就是一个函数。

class Person {}
Person instanceof Function // true

其实在当下都用 ES6 的情况下,ES5 的继承写法已经没啥学习的必要了,但是因为面试还会被问到,所以复习一下还是需要的。

首先来说下 ES5 和 6 继承的区别:

  1. ES6 继承的子类需要调用 super() 才能拿到子类,ES5 的话是通过 apply 这种绑定的方式
  2. 类声明不会提升,和 let 这些一致

接下来就是回字的几种写法的名场面了,ES5 实现继承的方式有很多种,面试了解一种已经够用:

function Super() {}
Super.prototype.getNumber = function() {
  return 1
}

function Sub() {}
Sub.prototype = Object.create(Super.prototype, {
  constructor: {
    value: Sub,
    enumerable: false,
    writable: true,
    configurable: true
  }
})
let s = new Sub()
s.getNumber()

常见考点

  • JS 中如何实现继承
  • 通过原型实现的继承和 class 有何区别
  • 手写任意一种原型继承

深浅拷贝

浅拷贝

两个对象第一层的引用不相同就是浅拷贝的含义。

我们可以通过 assign 、扩展运算符等方式来实现浅拷贝:

let a = {
    age: 1
}
let b = Object.assign({}, a)
a.age = 2
console.log(b.age) // 1
b = {...a}
a.age = 3
console.log(b.age) // 2

深拷贝

两个对象内部所有的引用都不相同就是深拷贝的含义。

最简单的深拷贝方式就是使用 JSON.parse(JSON.stringify(object)),但是该方法存在不少缺陷。

比如说只支持 JSON 支持的类型,JSON 是门通用的语言,并不支持 JS 中的所有类型。

同时还存在不能处理循环引用的问题:

如果想解决以上问题,我们可以通过递归的方式来实现代码:

// 利用 WeakMap 解决循环引用
let map = new WeakMap()
function deepClone(obj) {
  if (obj instanceof Object) {
    if (map.has(obj)) {
      return map.get(obj)
    }
    let newObj
    if (obj instanceof Array) {
      newObj = []     
    } else if (obj instanceof Function) {
      newObj = function() {
        return obj.apply(this, arguments)
      }
    } else if (obj instanceof RegExp) {
      // 拼接正则
      newobj = new RegExp(obj.source, obj.flags)
    } else if (obj instanceof Date) {
      newobj = new Date(obj)
    } else {
      newObj = {}
    }
    // 克隆一份对象出来
    let desc = Object.getOwnPropertyDescriptors(obj)
    let clone = Object.create(Object.getPrototypeOf(obj), desc)
    map.set(obj, clone)
    for (let key in obj) {
      if (obj.hasOwnProperty(key)) {
        newObj[key] = deepClone(obj[key])
      }
    }
    return newObj
  }
  return obj
}

上述代码解决了常见的类型以及循环引用的问题,当然还是一部分缺陷的,但是面试时候能写出上面的代码已经足够了,剩下的能口述思路基本这道题就能拿到高分了。

比如说递归肯定会存在爆栈的问题,因为执行栈的大小是有限制的,到一定数量栈就会爆掉。

因此遇到这种问题,我们可以通过遍历的方式来改写递归。这个就是如何写层序遍历(BFS)的问题了,通过数组来模拟执行栈就能解决爆栈问题,有兴趣的读者可以咨询查阅。

Promise

Promise 是一个高频考点了,但是更多的是在笔试题中出现,概念题反倒基本没有,多是来问 Event loop 的。

对于这块内容的复习我们需要熟悉涉及到的所有 API,因为考题里可能会问到 allrace 等等用法或者需要你用这些 API 实现一些功能。

对于 Promise 进阶点的知识可以具体阅读笔者的这篇文章,这里就不复制过来占用篇幅了:Promise 你真的用明白了么?

常见考点

另外还有一道很常见的串行题目:

页面上有三个按钮,分别为 A、B、C,点击各个按钮都会发送异步请求且互不影响,每次请求回来的数据都为按钮的名字。 请实现当用户依次点击 A、B、C、A、C、B 的时候,最终获取的数据为 ABCACB。

这道题目主要两个考点:

  1. 请求不能阻塞,但是输出可以阻塞。比如说 B 请求需要耗时 3 秒,其他请求耗时 1 秒,那么当用户点击 BAC 时,三个请求都应该发起,但是因为 B 请求回来的慢,所以得等着输出结果。
  2. 如何实现一个队列?

其实我们无需自己去构建一个队列,直接利用 promise.then 方法就能实现队列的效果了。

class Queue {
  promise = Promise.resolve();

  excute(promise) {
    this.promise = this.promise.then(() => promise);
    return this.promise;
  }
}

const queue = new Queue();

const delay = (params) => {
  const time = Math.floor(Math.random() * 5);
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(params);
    }, time * 500);
  });
};

const handleClick = async (name) => {
  const res = await queue.excute(delay(name));
  console.log(res);
};

handleClick('A');
handleClick('B');
handleClick('C');
handleClick('A');
handleClick('C');
handleClick('B');

async、await

awaitpromise 一样,更多的是考笔试题,当然偶尔也会问到和 promise 的一些区别。

await 相比直接使用 Promise 来说,优势在于处理 then 的调用链,能够更清晰准确的写出代码。缺点在于滥用 await 可能会导致性能问题,因为 await 会阻塞代码,也许之后的异步代码并不依赖于前者,但仍然需要等待前者完成,导致代码失去了并发性,此时更应该使用 Promise.all

下面来看一道很容易做错的笔试题。

var a = 0
var b = async () => {
  a = a + await 10
  console.log('2', a) // -> ?
}
b()
a++
console.log('1', a) // -> ?

这道题目大部分读者肯定会想到 await 左边是异步代码,因此会先把同步代码执行完,此时 a 已经变成 1,所以答案应该是 11。

其实 a 为 0 是因为加法运算法,先算左边再算右边,所以会把 0 固定下来。如果我们把题目改成 await 10 + a 的话,答案就是 11 了。

事件循环

在开始讲事件循环之前,我们一定要牢记一点:JS 是一门单线程语言,在执行过程中永远只能同时执行一个任务,任何异步的调用都只是在模拟这个过程,或者说可以直接认为在 JS 中的异步就是延迟执行的同步代码。另外别的什么 Web worker、浏览器提供的各种线程都不会影响这个点。

大家应该都知道执行 JS 代码就是往执行栈里 push 函数(不知道的自己搜索吧),那么当遇到异步代码的时候会发生什么情况?

其实当遇到异步的代码时,只有当遇到 Task、Microtask 的时候才会被挂起并在需要执行的时候加入到 Task(有多种 Task) 队列中。

从图上我们得出两个疑问:

  1. 什么任务会被丢到 Microtask Queue 和 Task Queue 中?它们分别代表了什么?
  2. Event loop 是如何处理这些 task 的?

首先我们来解决问题一。

Task(宏任务):同步代码、setTimeout 回调、setInteval 回调、IO、UI 交互事件、postMessageMessageChannel

MicroTask(微任务):Promise 状态改变以后的回调函数(then 函数执行,如果此时状态没变,回调只会被缓存,只有当状态改变,缓存的回调函数才会被丢到任务队列)、Mutation observer 回调函数、queueMicrotask 回调函数(新增的 API)。

宏任务会被丢到下一次事件循环,并且宏任务队列每次只会执行一个任务。

微任务会被丢到本次事件循环,并且微任务队列每次都会执行任务直到队列为空。

假如每个微任务都会产生一个微任务,那么宏任务永远都不会被执行了。

接下来我们来解决问题二。

Event Loop 执行顺序如下所示:

  1. 执行同步代码
  2. 执行完所有同步代码后且执行栈为空,判断是否有微任务需要执行
  3. 执行所有微任务且微任务队列为空
  4. 是否有必要渲染页面
  5. 执行一个宏任务

如果你觉得上面的表述不大理解的话,接下来我们通过代码示例来巩固理解上面的知识:

console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
    queueMicrotask(() => console.log('queueMicrotask'))
    console.log('promise');
});

console.log('script end');
  1. 遇到 console.log 执行并打印
  2. 遇到 setTimeout,将回调加入宏任务队列
  3. 遇到 Promise.resolve(),此时状态已经改变,因此将 then 回调加入微任务队列
  4. 遇到 console.log 执行并打印

此时同步任务全部执行完毕,分别打印了 'script start' 以及 'script end',开始判断是否有微任务需要执行。

  1. 微任务队列存在任务,开始执行 then 回调函数
  2. 遇到 queueMicrotask,将回到加入微任务队列
  3. 遇到 console.log 执行并打印
  4. 检查发现微任务队列存在任务,执行 queueMicrotask 回调
  5. 遇到 console.log 执行并打印

此时发现微任务队列已经清空,判断是否需要进行 UI 渲染。

  1. 执行宏任务,开始执行 setTimeout 回调
  2. 遇到 console.log 执行并打印

执行一个宏任务即结束,寻找是否存在微任务,开始循环判断...

其实事件循环没啥难懂的,理解 JS 是个单线程语言,明白哪些是微宏任务、循环的顺序就好了。

最后需要注意的一点:正是因为 JS 是门单线程语言,只能同时执行一个任务。因此所有的任务都可能因为之前任务的执行时间过长而被延迟执行,尤其对于一些定时器而言。

常见考点

  • 什么是事件循环?
  • JS 的执行原理?
  • 哪些是微宏任务?
  • 定时器是准时的嘛?

模块化

当下模块化主要就是 CommonJS 和 ES6 的 ESM 了,其它什么的 AMD、UMD 了解下就行了。

ESM 我想应该没啥好说的了,主要我们来聊聊 CommonJS 以及 ESM 和 CommonJS 的区别。

CommonJS

CommonJs 是 Node 独有的规范,当然 Webpack 也自己实现了这套东西,让我们能在浏览器里跑起来这个规范。

// a.js
module.exports = {
    a: 1
}
// or
exports.a = 1

// b.js
var module = require('./a.js')
module.a // -> log 1

在上述代码中,module.exportsexports 很容易混淆,让我们来看看大致内部实现

// 基本实现
var module = {
  exports: {} // exports 就是个空对象
}
// 这个是为什么 exports 和 module.exports 用法相似的原因
var exports = module.exports
var load = function (module) {
    // 导出的东西
    var a = 1
    module.exports = a
    return module.exports
};

根据上面的大致实现,我们也能看出为什么对 exports 直接赋值不会有任何效果。

对于 CommonJS 和 ESM 的两者区别是:

  • 前者支持动态导入,也就是 require(${path}/xx.js),后者使用 import()
  • 前者是同步导入,因为用于服务端,文件都在本地,同步导入即使卡住主线程影响也不大。而后者是异步导入,因为用于浏览器,需要下载文件,如果也采用同步导入会对渲染有很大影响
  • 前者在导出时都是值拷贝,就算导出的值变了,导入的值也不会改变,所以如果想更新值,必须重新导入一次。但是后者采用实时绑定的方式,导入导出的值都指向同一个内存地址,所以导入值会跟随导出值变化

垃圾回收

本小结内容建立在 V8 引擎之上。

首先聊垃圾回收之前我们需要知道堆栈到底是存储什么数据的,当然这块内容上文已经讲过,这里就不再赘述了。

接下来我们先来聊聊栈是如何垃圾回收的。其实栈的回收很简单,简单来说就是一个函数 push 进栈,执行完毕以后 pop 出来就当可以回收了。当然我们往深层了讲深层了讲就是汇编里的东西了,操作 esp 和 ebp 指针,了解下即可。

然后就是堆如何回收垃圾了,这部分的话会分为两个空间及多个算法。

两个空间分别为新生代和老生代,我们分开来讲每个空间中涉及到的算法。

新生代

新生代中的对象一般存活时间较短,空间也较小,使用 Scavenge GC 算法。

在新生代空间中,内存空间分为两部分,分别为 From 空间和 To 空间。在这两个空间中,必定有一个空间是使用的,另一个空间是空闲的。新分配的对象会被放入 From 空间中,当 From 空间被占满时,新生代 GC 就会启动了。算法会检查 From 空间中存活的对象并复制到 To 空间中,如果有失活的对象就会销毁。当复制完成后将 From 空间和 To 空间互换,这样 GC 就结束了。

老生代

老生代中的对象一般存活时间较长且数量也多,使用了两个算法,分别是标记清除和标记压缩算法。

在讲算法前,先来说下什么情况下对象会出现在老生代空间中:

  • 新生代中的对象是否已经经历过一次以上 Scavenge 算法,如果经历过的话,会将对象从新生代空间移到老生代空间中。
  • To 空间的对象占比大小超过 25 %。在这种情况下,为了不影响到内存分配,会将对象从新生代空间移到老生代空间中。

老生代中的空间很复杂,有如下几个空间

enum AllocationSpace {
  // TODO(v8:7464): Actually map this space's memory as read-only.
  RO_SPACE,    // 不变的对象空间
  NEW_SPACE,   // 新生代用于 GC 复制算法的空间
  OLD_SPACE,   // 老生代常驻对象空间
  CODE_SPACE,  // 老生代代码对象空间
  MAP_SPACE,   // 老生代 map 对象
  LO_SPACE,    // 老生代大空间对象
  NEW_LO_SPACE,  // 新生代大空间对象

  FIRST_SPACE = RO_SPACE,
  LAST_SPACE = NEW_LO_SPACE,
  FIRST_GROWABLE_PAGED_SPACE = OLD_SPACE,
  LAST_GROWABLE_PAGED_SPACE = MAP_SPACE
};

在老生代中,以下情况会先启动标记清除算法:

  • 某一个空间没有分块的时候
  • 空间中被对象超过一定限制
  • 空间不能保证新生代中的对象移动到老生代中

在这个阶段中,会遍历堆中所有的对象,然后标记活的对象,在标记完成后,销毁所有没有被标记的对象。在标记大型对内存时,可能需要几百毫秒才能完成一次标记。这就会导致一些性能上的问题。为了解决这个问题,2011 年,V8 从 stop-the-world 标记切换到增量标志。在增量标记期间,GC 将标记工作分解为更小的模块,可以让 JS 应用逻辑在模块间隙执行一会,从而不至于让应用出现停顿情况。但在 2018 年,GC 技术又有了一个重大突破,这项技术名为并发标记。该技术可以让 GC 扫描和标记对象时,同时允许 JS 运行,你可以点击 该博客 详细阅读。

清除对象后会造成堆内存出现碎片的情况,当碎片超过一定限制后会启动压缩算法。在压缩过程中,将活的对象像一端移动,直到所有对象都移动完成然后清理掉不需要的内存。

其它考点

0.1 + 0.2 !== 0.3

因为 JS 采用 IEEE 754 双精度版本(64位),并且只要采用 IEEE 754 的语言都有该问题。

不止 0.1 + 0.2 存在问题,0.7 + 0.1、0.2 + 0.4 同样也存在问题。

存在问题的原因是浮点数用二进制表示的时候是无穷的,因为精度的问题,两个浮点数相加会造成截断丢失精度,因此再转换为十进制就出了问题。

解决的办法可以通过以下代码:

export const addNum = (num1: number, num2: number) => {
  let sq1;
  let sq2;
  let m;
  try {
    sq1 = num1.toString().split('.')[1].length;
  } catch (e) {
    sq1 = 0;
  }
  try {
    sq2 = num2.toString().split('.')[1].length;
  } catch (e) {
    sq2 = 0;
  }
  m = Math.pow(10, Math.max(sq1, sq2));
  return (Math.round(num1 * m) + Math.round(num2 * m)) / m;
};

核心就是计算出两个浮点数最大的小数长度,比如说 0.1 + 0.22 的小数最大长度为 2,然后两数乘上 10 的 2次幂再相加得出数字 32,然后除以 10 的 2次幂即可得出正确答案 0.32。

手写题

防抖

你是否在日常开发中遇到一个问题,在滚动事件中需要做个复杂计算或者实现一个按钮的防二次点击操作。

这些需求都可以通过函数防抖动来实现。尤其是第一个需求,如果在频繁的事件回调中做复杂计算,很有可能导致页面卡顿,不如将多次计算合并为一次计算,只在一个精确点做操作。

PS:防抖和节流的作用都是防止函数多次调用。区别在于,假设一个用户一直触发这个函数,且每次触发函数的间隔小于阈值,防抖的情况下只会调用一次,而节流会每隔一定时间调用函数。

我们先来看一个袖珍版的防抖理解一下防抖的实现:

// func是用户传入需要防抖的函数
// wait是等待时间
const debounce = (func, wait = 50) => {
  // 缓存一个定时器id
  let timer = 0
  // 这里返回的函数是每次用户实际调用的防抖函数
  // 如果已经设定过定时器了就清空上一次的定时器
  // 开始一个新的定时器,延迟执行用户传入的方法
  return function(...args) {
    if (timer) clearTimeout(timer)
    timer = setTimeout(() => {
      func.apply(this, args)
    }, wait)
  }
}
// 不难看出如果用户调用该函数的间隔小于 wait 的情况下,上一次的时间还未到就被清除了,并不会执行函数

这是一个简单版的防抖,但是有缺陷,这个防抖只能在最后调用。一般的防抖会有immediate选项,表示是否立即调用。这两者的区别,举个栗子来说:

  • 例如在搜索引擎搜索问题的时候,我们当然是希望用户输入完最后一个字才调用查询接口,这个时候适用延迟执行的防抖函数,它总是在一连串(间隔小于wait的)函数触发之后调用。
  • 例如用户给interviewMap点star的时候,我们希望用户点第一下的时候就去调用接口,并且成功之后改变star按钮的样子,用户就可以立马得到反馈是否star成功了,这个情况适用立即执行的防抖函数,它总是在第一次调用,并且下一次调用必须与前一次调用的时间间隔大于wait才会触发。

下面我们来实现一个带有立即执行选项的防抖函数

// 这个是用来获取当前时间戳的
function now() {
  return +new Date()
}
/**
 * 防抖函数,返回函数连续调用时,空闲时间必须大于或等于 wait,func 才会执行
 *
 * @param  {function} func        回调函数
 * @param  {number}   wait        表示时间窗口的间隔
 * @param  {boolean}  immediate   设置为ture时,是否立即调用函数
 * @return {function}             返回客户调用函数
 */
function debounce (func, wait = 50, immediate = true) {
  let timer, context, args
  
  // 延迟执行函数
  const later = () => setTimeout(() => {
    // 延迟函数执行完毕,清空缓存的定时器序号
    timer = null
    // 延迟执行的情况下,函数会在延迟函数中执行
    // 使用到之前缓存的参数和上下文
    if (!immediate) {
      func.apply(context, args)
      context = args = null
    }
  }, wait)

  // 这里返回的函数是每次实际调用的函数
  return function(...params) {
    // 如果没有创建延迟执行函数(later),就创建一个
    if (!timer) {
      timer = later()
      // 如果是立即执行,调用函数
      // 否则缓存参数和调用上下文
      if (immediate) {
        func.apply(this, params)
      } else {
        context = this
        args = params
      }
    // 如果已有延迟执行函数(later),调用的时候清除原来的并重新设定一个
    // 这样做延迟函数会重新计时
    } else {
      clearTimeout(timer)
      timer = later()
    }
  }
}

整体函数实现的不难,总结一下。

  • 对于按钮防点击来说的实现:如果函数是立即执行的,就立即调用,如果函数是延迟执行的,就缓存上下文和参数,放到延迟函数中去执行。一旦我开始一个定时器,只要我定时器还在,你每次点击我都重新计时。一旦你点累了,定时器时间到,定时器重置为 null,就可以再次点击了。
  • 对于延时执行函数来说的实现:清除定时器ID,如果是延迟调用就调用函数

节流

防抖动和节流本质是不一样的。防抖动是将多次执行变为最后一次执行,节流是将多次执行变成每隔一段时间执行。

/**
 * underscore 节流函数,返回函数连续调用时,func 执行频率限定为 次 / wait
 *
 * @param  {function}   func      回调函数
 * @param  {number}     wait      表示时间窗口的间隔
 * @param  {object}     options   如果想忽略开始函数的的调用,传入{leading: false}。
 *                                如果想忽略结尾函数的调用,传入{trailing: false}
 *                                两者不能共存,否则函数不能执行
 * @return {function}             返回客户调用函数   
 */
_.throttle = function(func, wait, options) {
    var context, args, result;
    var timeout = null;
    // 之前的时间戳
    var previous = 0;
    // 如果 options 没传则设为空对象
    if (!options) options = {};
    // 定时器回调函数
    var later = function() {
      // 如果设置了 leading,就将 previous 设为 0
      // 用于下面函数的第一个 if 判断
      previous = options.leading === false ? 0 : _.now();
      // 置空一是为了防止内存泄漏,二是为了下面的定时器判断
      timeout = null;
      result = func.apply(context, args);
      if (!timeout) context = args = null;
    };
    return function() {
      // 获得当前时间戳
      var now = _.now();
      // 首次进入前者肯定为 true
      // 如果需要第一次不执行函数
      // 就将上次时间戳设为当前的
      // 这样在接下来计算 remaining 的值时会大于0
      if (!previous && options.leading === false) previous = now;
      // 计算剩余时间
      var remaining = wait - (now - previous);
      context = this;
      args = arguments;
      // 如果当前调用已经大于上次调用时间 + wait
      // 或者用户手动调了时间
       // 如果设置了 trailing,只会进入这个条件
      // 如果没有设置 leading,那么第一次会进入这个条件
      // 还有一点,你可能会觉得开启了定时器那么应该不会进入这个 if 条件了
      // 其实还是会进入的,因为定时器的延时
      // 并不是准确的时间,很可能你设置了2秒
      // 但是他需要2.2秒才触发,这时候就会进入这个条件
      if (remaining <= 0 || remaining > wait) {
        // 如果存在定时器就清理掉否则会调用二次回调
        if (timeout) {
          clearTimeout(timeout);
          timeout = null;
        }
        previous = now;
        result = func.apply(context, args);
        if (!timeout) context = args = null;
      } else if (!timeout && options.trailing !== false) {
        // 判断是否设置了定时器和 trailing
        // 没有的话就开启一个定时器
        // 并且不能不能同时设置 leading 和 trailing
        timeout = setTimeout(later, remaining);
      }
      return result;
    };
  };

Event Bus

class Events {
  constructor() {
    this.events = new Map();
  }

  addEvent(key, fn, isOnce, ...args) {
    const value = this.events.get(key) ? this.events.get(key) : this.events.set(key, new Map()).get(key)
    value.set(fn, (...args1) => {
        fn(...args, ...args1)
        isOnce && this.off(key, fn)
    })
  }

  on(key, fn, ...args) {
    if (!fn) {
      console.error(`没有传入回调函数`);
      return
    }
    this.addEvent(key, fn, false, ...args)
  }

  fire(key, ...args) {
    if (!this.events.get(key)) {
      console.warn(`没有 ${key} 事件`);
      return;
    }
    for (let [, cb] of this.events.get(key).entries()) {
      cb(...args);
    }
  }

  off(key, fn) {
    if (this.events.get(key)) {
      this.events.get(key).delete(fn);
    }
  }

  once(key, fn, ...args) {
    this.addEvent(key, fn, true, ...args)
  }
}

instanceof

instanceof 可以正确的判断对象的类型,因为内部机制是通过判断对象的原型链中是不是能找到类型的 prototype

function instanceof(left, right) {
    // 获得类型的原型
    let prototype = right.prototype
    // 获得对象的原型
    left = left.__proto__
    // 判断对象的类型是否等于类型的原型
    while (true) {
        if (left === null)
            return false
        if (prototype === left)
            return true
        left = left.__proto__
    }
}

call

Function.prototype.myCall = function(context, ...args) {
  context = context || window
  let fn = Symbol()
  context[fn] = this
  let result = context[fn](...args)
  delete context[fn]
  return result
}

apply

Function.prototype.myApply = function(context) {
  context = context || window
  let fn = Symbol()
  context[fn] = this
  let result
  if (arguments[1]) {
    result = context[fn](...arguments[1])
  } else {
    result = context[fn]()
  }
  delete context[fn]
  return result
}

bind

Function.prototype.myBind = function (context) {
  var _this = this
  var args = [...arguments].slice(1)
  // 返回一个函数
  return function F() {
    // 因为返回了一个函数,我们可以 new F(),所以需要判断
    if (this instanceof F) {
      return new _this(...args, ...arguments)
    }
    return _this.apply(context, args.concat(...arguments))
  }
}

其他

其他手写题上文已经有提及,比如模拟 new、ES5 实现继承、深拷贝。

另外大家可能经常能看到手写 Promise 的文章,其实根据笔者目前收集到的数百道面试题以及读者的反馈来看,压根就没人遇到这个考点,所以我们大可不必在这上面花时间。

最后

以上就是本篇基础的全部内容了,如果有各位读者认为重要的知识点笔者却遗漏的话,欢迎大家指出。

大家也可以在笔者的网站上阅读,体验更佳!
查看原文

赞 47 收藏 34 评论 1

y_ck 发布了文章 · 3月17日

前端搞工程化:揭秘自动化部署系统(一)

发 npm 包对于稍微大点的厂来说都是个频繁需求,因此本地执行计算版本命令以及 npm publish 都是不大可行的,大都会有一个单独的部署系统去自动帮助我们完成这个事情。

今天就来聊聊这个部署系统中核心的计算版本以及发布的逻辑以及流程。

Semver 语义化版本

聊部署系统之前,我们先得来聊聊语义化版本,因为笔者发现很多人对于这一块内容还是一知半解。

版本含义

  • 小于 1.0.0:测试版,说明该库目前 API 不稳定
  • 大于等于 1.0.0:正式版
  • 版本中携带 alpha、beta、rc 等 tag 字样,统称先行版,一般格式为 x.y.z-[tag].[次数 / meta 信息]

    • alpha:内部版本
    • beta:公测版本
    • rc:预发的正式版本

版本号格式

一般的版本号格式都为 X.Y.Z,分别的含义为:

  • X:major,主版本号,当有不兼容的 API 出现时应该修改该版本号
  • Y:minor,次版本号,当有向后兼容的新功能出现时应该修改该版本号
  • Z:patch,补丁,当需要修复向后兼容的 bug 时应该修改该版本号

但是这个语义也不是一成不变的。比如当版本号为测试版时(版本小于 1.0.0 时),我们可以将语义修改为 0.minor.patch。因为此时出现不兼容 API 是很正常的事情,不应该直接改动主版本号,而是应该改动次版本号,同时将功能新增及 bug 修复造成的版本变更体现在补丁上。

版本变更规则

X.Y.Z 必须为正整数且前面不能补零。

X.Y.Z 在每次变更版本号时,需要重置更小的版本号至 0。比如说 1.0.2 升级至 1.1.0。

同一个版本的先行版多次发布,只需变更末尾的次数或者 meta 值。比如说 1.0.0-beta.0 再次发布先行版应为 1.0.0-beta.1。

测试版一般从 0.1.0 开始计算。正式版一般在结束快速迭代以及开发者认为 API 稳定以后就可以发布。

自动计算版本的前提条件

术语解释

npm 项目分为两种包结构:

  • 单包,一个项目中只存在一个需要发布的 npm 包
  • 多包,一个项目中存在多个需要发包的 npm 包,通常使用 lerna 管理

内容

如果我们需要实现自动发版,就得让服务知道我们到底是需要发什么版本,否则无论怎样都不可能实现自动计算版本的需求。

因此我们需要引入 commitizen 这个工具。

这个工具可以帮忙我们提交规范化的 commit 信息:

格式如下:

<type>[optional scope]: <description>

[optional body]

[optional footer(s)]

一般对于日常开发来说没那么严谨,type、description 必填,breaking 在需要的时候选择,其他内容可有可无。

未免读者不明白这三者分别代表什么,笔者先来解释下

type 就是本次 commit 所做的代码变动,基本分为以下几种:

  • feat: 新特性,可以造成正式版变更次版本号
  • fix: 修改 bug,可以造成正式版变更补丁版本号
  • refactor: 代码重构,不会引起版本变更
  • docs: 文档相关,不会引起版本变更
  • style: 代码格式修改,不会引起版本变更
  • test: 测试用例,不会引起版本变更
  • chore: 工程配置相关的修改,不会引起版本变更

当然如果用户有个性化需求的话,也是可以增删这部分内容的。

description 就是字面意思了,代表本地 commit 的信息。

最后是 breaking,当有不兼容的 API 出现时我们需要提交这部分的内容,告知正式版需要变更主版本号。

PS:上文中说的都是正式版,如果当前版本为测试版的话,变更版本规则参考 版本变更规则小节

最后生成出来的内容大致长这个样子:fix: do not alter attributes

另外还有一个注意点是多包的问题。如果发版用统一版本的话事情就基本回归到单包结构上了,很简单。但是如果版本不统一的话,我们就需要收集到底有哪些包是变更过文件的,在部署时只对变更过的包进行操作。

这里大致有两种方法可以实现:

第一种方法是 @lerna/changed,这个工具可以帮助我们找到所有变更过的二方包。这里原理其实挺简单的,核心就是通过 git command 去实现:

git diff --name-only {git tag / commit sha} --{package path}

翻译过来就是寻找从上次的 git tag 或者初次的 commit 信息中查找某个包是否存在文件变更。

第二种方式可以改造 git cz 工具,新增一个功能:每次提交的时候自动带上本次提交变更了哪些包,当然底下还是用了上面的原理,但是我们可以根据需求来定制更多的功能,更自由。

规范化的 commit 信息是自动部署系统的基石,无论用工具也好还是直接手写,同时也能让开发者清晰地了解大致项目做了哪些变更。

部署系统中如何计算版本

计算版本是个挺有趣的东西,这里我们需要用到 semver 来帮助我们计算,当然多包场景下你也可以使用 lerna 计算,但是我们内部还是直接统一都 semver 算了。

另外算版本这件事情需要分场景来论,接下来我们一个个来看。

当然在开始之前,我们需要了解下版本的通用变更规则,因为几个场景都基于这个通用规则。

通用变更逻辑

首先来介绍下升版本所需要用到的几种类型:

major | minor | patch | premajor | preminor | prepatch | prerelease

前三种之前就聊过,这里不再多说。

之后三种都对应先行版,以 beta 版本举例 premajor,能将版本 1.0.0 变更为 2.0.0-beta.0,其实大体上还是和前三者相同,无非多了一个先行版本号。

最后种同样也是对应先行版。以 beta 版本举例,能将版本 1.0.0 变更为 1.0.1-beta.0,同时也能将版本 1.0.1-beta.0 变更为 1.0.1-beta.1。

知道了变更版本类型,我们就该想该如何得出它们了。一般来说用户都会提交多个 commit,我们首先需要找出其中所有的 commit type 并且取出一个最大值。

比如说用户自上次发版以来共提交了三个 commit,类型分别为 feat、doc、breakchange,那么最大类型为 breakchange。

得出最大 commit type 后,我们需要根据不同的版本来计算。比如说正式版与测试版发版规则就不同,详见 版本号格式,不再赘述。

举个例子,当前版本为 1.0.0,此时根据 commit 我们得出最大 type 为 feat,且需要发布先行版(beta),因此最终计算得出的变更规则为 preminor,可将版本升级为 1.1.0-beta.0。

截屏2021-03-16下午10.54.26

分析 commit 信息

上文有说到我们需要分析 commit 来获取 type,那么读者可能会疑问如何分析?

其实原理很简单,还是用到了 git command:

git log -E --format=%H=%B

对于以上 commit,我们可以通过执行命令得出以下结果:

当然这样分析是把当前分支的所有 commit 都分析进去了,大部分发版时候我们只需要分析上次发版至今的所有变更,因此需要修正 command 为:

git log 上次的 commit id...HEAD -E --format=%H=%B

最后就是各种正则表达式大显身手的时候了,想拿啥信息匹配就行。

单包场景

单包场景其实是最简单的,直接套用通用变更逻辑就行。

多包场景

单纯的多包环境其实也是简单的。无非比单包多了一步需要先找到哪些文件被变更了,然后需要筛选出 commit 中对应当前包的 type,最后就是套用通用变更逻辑。

多包且互有依赖场景

先解释下这个场景,比如说目前维护了 A、B、C 三个包,A 包的 dependencies 中包含了 B 和 C,这就是有依赖的意思,此时计算版本还需要多个步骤。

当通用逻辑结束以后,我们需要根据依赖关系来判断是否还需要变更包版本。

比如说本地提交需要执行 patch 变更 B 包版本,其它两包都没有代码变动。但是实际上我们还需要变动 A 的版本,否则光升 B 不升 A,用 A 包的用户就用不到 B 包的新版本变化了。

收尾

当我们将所有版本计算完毕以后,就需要写入 package.json 然后 执行 npm publish,最后还需要提交下代码打上 tag。

最后

本篇文章就是大致聊了下算版本以及发布这部分的内容,大家有问题的可以交流讨论。

另外我相信肯定有读者会说这做的太麻烦,有别的工具可以简化步骤。这个笔者当然也知道,但是他们底下自动修改版本的原理都是和本文一致的,了解下工具底下是怎么做事的也不为过。

查看原文

赞 7 收藏 5 评论 0

y_ck 发布了文章 · 1月26日

这些一行 JS 实现功能的代码,让你看起来像一个前端专家

文章译自 此处,老外也很会写标题。标题可能有 XX 党嫌疑,但是部分内容还是挺有用的。

JavaScript 可以做很多神奇的事情!

从复杂的框架到处理 API,有太多的东西需要学习。

但是,它也能让你只用一行代码就能做一些了不起的事情。

看看这 13 句 JavaScript 单行代码,会让你看起来像个专家!

1. 获取一个随机布尔值 (true/false)

这个函数使用 Math.random() 方法返回一个布尔值(true 或 false)。Math.random 将在 0 和 1 之间创建一个随机数,之后我们检查它是否高于或低于 0.5。这意味着得到真或假的几率是 50%/50%。

const randomBoolean = () => Math.random() >= 0.5;
console.log(randomBoolean());
// Result: a 50/50 change on returning true of false

2. 检查日期是否为工作日

使用这个方法,你就可以检查函数参数是工作日还是周末。

const isWeekday = (date) => date.getDay() % 6 !== 0;
console.log(isWeekday(new Date(2021, 0, 11)));
// Result: true (Monday)
console.log(isWeekday(new Date(2021, 0, 10)));
// Result: false (Sunday)

3. 反转字符串

有几种不同的方法来反转一个字符串。以下代码是最简单的方式之一。

const reverse = str => str.split('').reverse().join('');
reverse('hello world');     
// Result: 'dlrow olleh'

4. 检查当前 Tab 页是否在前台

我们可以通过使用 document.hidden 属性来检查当前标签页是否在前台中。

const isBrowserTabInView = () => document.hidden;
isBrowserTabInView();
// Result: returns true or false depending on if tab is in view / focus

5. 检查数字是否为奇数

最简单的方式是通过使用模数运算符(%)来解决。如果你对它不太熟悉,这里是 Stack Overflow 上的一个很好的图解。

const isEven = num => num % 2 === 0;
console.log(isEven(2));
// Result: true
console.log(isEven(3));
// Result: false

6. 从日期中获取时间

通过使用 toTimeString() 方法,在正确的位置对字符串进行切片,我们可以从提供的日期中获取时间或者当前时间。

const timeFromDate = date => date.toTimeString().slice(0, 8);
console.log(timeFromDate(new Date(2021, 0, 10, 17, 30, 0))); 
// Result: "17:30:00"
console.log(timeFromDate(new Date()));
// Result: will log the current time

7. 保留小数点(非四舍五入)

使用 Math.pow() 方法,我们可以将一个数字截断到某个小数点。

const toFixed = (n, fixed) => ~~(Math.pow(10, fixed) * n) / Math.pow(10, fixed);
// Examples
toFixed(25.198726354, 1);       // 25.1
toFixed(25.198726354, 2);       // 25.19
toFixed(25.198726354, 3);       // 25.198
toFixed(25.198726354, 4);       // 25.1987
toFixed(25.198726354, 5);       // 25.19872
toFixed(25.198726354, 6);       // 25.198726

8. 检查元素当前是否为聚焦状态

我们可以使用 document.activeElement 属性检查一个元素当前是否处于聚焦状态。

const elementIsInFocus = (el) => (el === document.activeElement);
elementIsInFocus(anyElement)
// Result: will return true if in focus, false if not in focus

9. 检查浏览器是否支持触摸事件

const touchSupported = () => {
  ('ontouchstart' in window || window.DocumentTouch && document instanceof window.DocumentTouch);
}
console.log(touchSupported());
// Result: will return true if touch events are supported, false if not

10. 检查当前用户是否为苹果设备

我们可以使用 navigator.platform 来检查当前用户是否为苹果设备。

const isAppleDevice = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
console.log(isAppleDevice);
// Result: will return true if user is on an Apple device

11. 滚动到页面顶部

window.scrollTo() 方法会取一个 x 和 y 坐标来进行滚动。如果我们将这些坐标设置为零,就可以滚动到页面的顶部。

注意:IE 不支持 scrollTo() 方法。

const goToTop = () => window.scrollTo(0, 0);
goToTop();
// Result: will scroll the browser to the top of the page

12. 获取所有参数平均值

我们可以使用 reduce 方法来获得函数参数的平均值。

const average = (...args) => args.reduce((a, b) => a + b) / args.length;
average(1, 2, 3, 4);
// Result: 2.5

13. 转换华氏度/摄氏度。(这个应该很少在国内用到吧)

处理温度有时会让人感到困惑。这 2 个功能将帮助你将华氏温度转换为摄氏温度,反之亦然。

const celsiusToFahrenheit = (celsius) => celsius * 9/5 + 32;
const fahrenheitToCelsius = (fahrenheit) => (fahrenheit - 32) * 5/9;
// Examples
celsiusToFahrenheit(15);    // 59
celsiusToFahrenheit(0);     // 32
celsiusToFahrenheit(-20);   // -4
fahrenheitToCelsius(59);    // 15
fahrenheitToCelsius(32);    // 0

谢谢你的阅读!希望你今天能学到一些新的东西。
image

查看原文

赞 35 收藏 25 评论 1

y_ck 发布了文章 · 1月19日

前端搞工程化:从零打造性能检测库「源码 + 视频」

工程化体系专栏永远首发自我的 Github,大家可以关注点赞,通常会早于发布各大平台一周时间以上。

本文涉及到的源码及视频地址:

  • 源码
  • 视频,因为制作肯定比文字需要的时间多,所以本周才会更新完毕,大家可以先对视频插个眼

前言

经常有读者问我什么是前端工程化?该怎么开始做前端工程化?

聊下来以后得出一些结论:这类读者普遍就职于中小型公司,前端人员个位数,平时疲于开发,团队内部几乎没有基础建设,工具很蛮荒。工程化对于这些读者来说很陌生,基本不知道这到底是什么,或者说认为 Webpack 就是前端工程化的全部了。

笔者目前就职于某厂的基础架构组,为百来号前端提供基础服务建设,对于这个领域有些许皮毛经验。因此有了一些想法,前端搞工程化会是笔者今年开坑的一个系列作品,每块内容会以文章 + 源码 + 视频的方式呈现。

这个系列的产出适用于以下群体:

  • 中小厂前端,基建蛮荒,平时疲于业务,不知道业务外怎么做东西能提高自己的竞争力、丰富简历
  • 公司暂时没有做基建计划,只能业余做一些低成本收益高的产品
  • 想了解前端工程化

需要说明的是产出只会是一个低成本下的最小可用产品,你可以拿来按需增加功能、参考思路或者纯粹当学习一点知识。

什么是前端工程化?

因为是该系列第一篇文章,就先来大致说下什么是前端工程化。

我的理解是前端工程化大体上可以理解为是做提效工程,从写代码开始的每一步都可以做工程化。比如说你用 IDE 对比记事本写代码的体验及效率肯定是不一样的;比如说 Webpack 等这类工具也是在帮助我们提升开发、构建的效率,其他的工具也就不一一列出了,大家知道意思就好。

当然了,今天要聊到的性能检测也是工程化的一部分。毕竟我们需要有个工具去协助找到应用到底在哪块地方存在性能短板,能帮助开发者更快地定位问题,而不是在生产环境中让用户抱怨产品卡顿。

为什么需要性能检测?

性能优化是很多前端都绕不开的话题,先不说项目是否需要性能优化,面试的时候这类问题是很常见的。

但是光会性能优化的手段还是不够的,我们最后还是需要做出前后数据对比才能体现出这次优化的价值到底有多少,毕竟数据的量化在职场中还是相当重要的。老板不知道你具体做的事情,很多东西都得从数据中来看,数据越好看就说明你完成工作的能力越高。

想获取性能的前后数据变化,我们肯定得用一些工具来做性能检测。

性能该怎么检测?

性能检测的方式有很多:

  • Chrome 自带的开发者工具:Performance
  • Lighthouse 开源工具
  • 原生 Performance API
  • 各种官方库、插件

这些方法各有各的好处,前两种方式简单快捷,能够可视化各类指标,但是很难拿到用户端的数据,毕竟你不大可能让用户去跑这些工具,当然除此之外还有一些很小的缺点,比如说拿不到重定向次数等等。

官方库、插件相比前两者来说会逊色很多,并且只提供一部分核心指标。

原生 Performance API 存在兼容问题,但是能覆盖到开发生产阶段,并且功能也能覆盖自带的开发者工具:Performance 工具。不仅在开发阶段能了解到项目的性能指标,还能获取用户端的数据,帮助我们更好地制定优化方案。另外能获取的指标也很齐全,因此是此次我们产品的选择。

当然了这不是多选一的选择题,我们在开发阶段还是需要将 Performance 工具及 API 结合起来使用,毕竟他们还是有着相辅相成的作用。

实战

这是此处产品的源码:地址

一些性能指标

在开始实战前,我们还是得来了解一些性能指标,随着时代发展,其实一些老的性能优化文章已经有点过时了。谷歌一直在更新性能优化这块的指标,笔者之前写过一篇文章来讲述当下的最新性能指标有哪些,有兴趣的读者可以先详细的读一下。

当然如果你嫌太长不看,可以先通过以下思维导图简单了解一下:

当然除了这个指标以外,我们还需要获取网络、文件传输、DOM等信息丰富指标内容。

Performance 使用

Performance 接口可以获取到当前页面中与性能相关的信息,并且提供高精度的时间戳,秒杀 Date.now()。首先我们来看下这个 API 的兼容性:

这个百分比其实已经算是兼容度很高了,主流浏览器的版本都能很好的支持。

对于 Performance 上 API 具体的讲解文中就不赘述了,有兴趣的可以阅读 MDN 文档,笔者在这里只讲几个后续用到的重要 API。

getEntriesByType

这个 API 可以让我们通过传入 type 获取一些相应的信息:

  • frame:事件循环中帧的时间数据。
  • resource:加载应用程序资源的详细网络计时数据
  • mark:performance.mark 调用信息
  • measure:performance.measure 调用信息
  • longtask:长任务(执行时间大于 50ms)信息。这个类型已被废弃(文档未标注,但是在 Chrome 中使用会显示已废弃),我们可以通过别的方式来拿
  • navigation:浏览器文档事件的指标的方法和属性
  • paint:获取 FP 和 FCP 指标

最后两个 type 是性能检测中获取指标的关键类型。当然你如果还想分析加载资源相关的信息的话,那可以多加上 resource 类型。

PerformanceObserver

PerformanceObserver 也是用来获取一些性能指标的 API,用法如下:

const perfObserver = new PerformanceObserver((entryList) => {
    // 信息处理
})
// 传入需要的 type
perfObserver.observe({ type: 'longtask', buffered: true })

结合 getEntriesByType 以及 PerformanceObserver,我们就能获取到所有需要的指标了。

上代码!

因为已经贴了源码地址,笔者就不贴大段代码上来了,会把主要的从零到一过程梳理一遍。

首先我们肯定要设计好用户如何调用 SDK(代指性能检测库)?需要传递哪些参数?如何获取及上报性能指标?

一般来说调用 SDK 多是构建一个实例,所以这次我们选择 class 的方式来写。参数的话暂定传入一个 tracker 函数获取各类指标以及 log 变量决定是否打印指标信息,签名如下:

export interface IPerProps {
  tracker?: (type: IPerDataType, data: any, allData: any) => void
  log?: boolean
}

export type IPerDataType =
  | 'navigationTime'
  | 'networkInfo'
  | 'paintTime'
  | 'lcp'
  | 'cls'
  | 'fid'
  | 'tbt'

接下来我们写 class 内部的代码,首先在前文中我们知道了 Performance API 是存在兼容问题的,所以我们需要在调用 Performance 之前判断一下浏览器是否支持:

export default class Per {
  constructor(args: IPerProps) {
    // 存储参数
    config.tracker = args.tracker
    if (typeof args.log === 'boolean') config.log = args.log
    // 判断是否兼容
    if (!isSupportPerformance) {
      log(`This browser doesn't support Performance API`)
      return
    }
}

export const isSupportPerformance = () => {
  const performance = window.performance
  return (
    performance &&
    !!performance.getEntriesByType &&
    !!performance.now &&
    !!performance.mark
  )
}

以上前置工作完毕以后,就可以开始写获取性能指标数据的代码了。

我们首先通过 performance.getEntriesByType('navigation') 来获取关于文档事件的指标

这个 API 还是能拿到挺多事件的时间戳的,如果你想了解这些事件具体含义,可以阅读文档,这里就不复制过来占用篇幅了。

看到那么多字段,可能有的读者就晕了,那么多东西我可怎么算指标。其实不需要担心,看完下图结合刚才的文档就行了:

我们不需要全部利用上获得的字段,重要的指标信息暴露出来即可,照着图和文档依样画葫芦就能得出代码:

export const getNavigationTime = () => {
  const navigation = window.performance.getEntriesByType('navigation')
  if (navigation.length > 0) {
    const timing = navigation[0] as PerformanceNavigationTiming
    if (timing) {
    //   解构出来的字段,太长不贴
      const {...} = timing

      return {
        redirect: {
          count: redirectCount,
          time: redirectEnd - redirectStart,
        },
        appCache: domainLookupStart - fetchStart,
        // dns lookup time
        dnsTime: domainLookupEnd - domainLookupStart,
        // handshake end - handshake start time
        TCP: connectEnd - connectStart,
        // HTTP head size
        headSize: transferSize - encodedBodySize || 0,
        responseTime: responseEnd - responseStart,
        // Time to First Byte
        TTFB: responseStart - requestStart,
        // fetch resource time
        fetchTime: responseEnd - fetchStart,
        // Service work response time
        workerTime: workerStart > 0 ? responseEnd - workerStart : 0,
        domReady: domContentLoadedEventEnd - fetchStart,
        // DOMContentLoaded time
        DCL: domContentLoadedEventEnd - domContentLoadedEventStart,
      }
    }
  }
  return {}
}

大家可以发现以上获得的指标中有不少是和网络有关系的,因此我们还需要结合网络环境来分析,获取网络环境信息很方便,以下是代码:

export const getNetworkInfo = () => {
  if ('connection' in window.navigator) {
    const connection = window.navigator['connection'] || {}
    const { effectiveType, downlink, rtt, saveData } = connection
    return {
      // 网络类型,4g 3g 这些
      effectiveType,
      // 网络下行速度
      downlink,
      // 发送数据到接受数据的往返时间
      rtt,
      // 打开/请求数据保护模式
      saveData,
    }
  }
  return {}
}

拿完以上的指标之后,我们需要用到 PerformanceObserver 来拿一些核心体验(性能)指标了。比如说 FP、FCP、FID 等等,内容就包括在我们上文中看过的思维导图中:

在这之前我们需要先了解一个注意事项:页面是有可能在处于后台的情况下加载的,因此这种情况下获取的指标是不准确的。所以我们需要忽略掉这种情况,通过以下代码来存储一个变量,在获取指标的时候比较一下时间戳来判断是否处于后台中:

document.addEventListener(
  'visibilitychange',
  (event) => {
    // @ts-ignore
    hiddenTime = Math.min(hiddenTime, event.timeStamp)
  },
  { once: true }
)

接下来是获取指标的代码,因为他们获取方式大同小异,所以先把获取方法封装一下:

// 封装一下 PerformanceObserver,方便后续调用
export const getObserver = (type: string, cb: IPerCallback) => {
  const perfObserver = new PerformanceObserver((entryList) => {
    cb(entryList.getEntries())
  })
  perfObserver.observe({ type, buffered: true })
}

我们先来获取 FP 及 FCP 指标:

export const getPaintTime = () => {
  const data: { [key: string]: number } = ({} = {})
  getObserver('paint', entries => {
    entries.forEach(entry => {
      data[entry.name] = entry.startTime
      if (entry.name === 'first-contentful-paint') {
        getLongTask(entry.startTime)
      }
    })
  })
  return data
}

拿到的数据结构长这样:

需要注意的是在拿到 FCP 指标以后需要同步开始获取 longtask 的时间,这是因为后续的 TBT 指标需要使用 longtask 来计算。

export const getLongTask = (fcp: number) => {
  getObserver('longtask', entries => {
    entries.forEach(entry => {
      // get long task time in fcp -> tti
      if (entry.name !== 'self' || entry.startTime < fcp) {
        return
      }
      // long tasks mean time over 50ms
      const blockingTime = entry.duration - 50
      if (blockingTime > 0) tbt += blockingTime
    })
  })
}

接下来我们来拿 FID 指标,以下是代码:

export const getFID = () => {
  getObserver('first-input', entries => {
    entries.forEach(entry => {
      if (entry.startTime < hiddenTime) {
        logIndicator('FID', entry.processingStart - entry.startTime)
        // TBT is in fcp -> tti
        // This data may be inaccurate, because fid >= tti
        logIndicator('TBT', tbt)
      }
    })
  })
}

FID 的指标数据长这样,需要用户交互才会触发:

在获取 FID 指标以后,我们也去拿了 TBT 指标,但是拿到的数据不一定是准确的。因为 TBT 指标的含义是在 FCP 及 TTI 指标之间的长任务阻塞时间之和,但目前好像没有一个好的方式来获取 TTI 指标数据,所以就用 FID 暂代了。

最后是 CLS 和 LCP 指标,大同小异就贴在一起了:

export const getLCP = () => {
  getObserver('largest-contentful-paint', entries => {
    entries.forEach(entry => {
      if (entry.startTime < hiddenTime) {
        const { startTime, renderTime, size } = entry
        logIndicator('LCP Update', {
          time: renderTime | startTime,
          size,
        })
      }
    })
  })
}

export const getCLS = () => {
  getObserver('layout-shift', entries => {
    let cls = 0
    entries.forEach(entry => {
      if (!entry.hadRecentInput) {
        cls += entry.value
      }
    })
    logIndicator('CLS Update', cls)
  })
}

拿到的数据结构长这样:

截屏2021-01-17下午7.37.33
截屏2021-01-17下午7.37.14

另外这两个指标还和别的不大一样,并不是一成不变的。一旦有新的数据符合指标要求,就会更新。

以上就是我们需要获取的所有性能指标了,当然光获取到指标肯定是不够,还需要暴露每个数据给用户,对于这种统一操作,我们需要封装一个工具函数出来:

// 打印数据
export const logIndicator = (type: string, data: IPerData) => {
  tracker(type, data)
  if (config.log) return
  // 让 log 好看点
  console.log(
    `%cPer%c${type}`,
    'background: #606060; color: white; padding: 1px 10px; border-top-left-radius: 3px; border-bottom-left-radius: 3px;',
    'background: #1475b2; color: white; padding: 1px 10px; border-top-right-radius: 3px;border-bottom-right-radius: 3px;',
    data
  )
}
export default (type: string, data: IPerData) => {
  const currentType = typeMap[type]
  allData[currentType] = data
  // 如果用户传了回调函数,那么每次在新获取指标以后就把相关信息暴露出去
  config.tracker && config.tracker(currentType, data, allData)
}

封装好函数以后,我们可以这样调用:

logIndicator('FID', entry.processingStart - entry.startTime)

在这里为止我们 SDK 的大体内容已经完成了,我们可以按需添加一些小功能,比如说获取指标分数。

指标分数是官方给的一些建议,你可以在官方 Blog 或者我的文章中看到定义的数据。

代码不复杂,我们就以获取 FCP 指标的分数为例演示一下代码:

export const scores: Record<string, number[]> = {
  fcp: [2000, 4000],
  lcp: [2500, 4500],
  fid: [100, 300],
  tbt: [300, 600],
  cls: [0.1, 0.25],
}

export const scoreLevel = ['good', 'needsImprovement', 'poor']

export const getScore = (type: string, data: number) => {
  const score = scores[type]
  for (let i = 0; i < score.length; i++) {
    if (data <= score[i]) return scoreLevel[i]
  }

  return scoreLevel[2]
}

首先是获取分数相关的工具函数,这块反正就是看着官方建议照抄,然后我们只需要在刚才获取指标的地方多加一句代码即可:

export const getPaintTime = () => {
  getObserver('paint', (entries) => {
    entries.forEach((entry) => {
      const time = entry.startTime
      const name = entry.name
      if (name === 'first-contentful-paint') {
        getLongTask(time)
        logIndicator('FCP', {
          time,
          score: getScore('fcp', time),
        })
      } else {
        logIndicator('FP', {
          time,
        })
      }
    })
  })
}

结束了,有兴趣的可以来这里读一下源码,反正也没几行。

最后

文章周末写的,略显仓促,如有出错请斧正,同时也欢迎大家一起探讨问题。

想看更多文章可以关注我的 Github 或者进群一起聊聊前端工程化。

查看原文

赞 11 收藏 7 评论 1

y_ck 关注了专栏 · 2020-11-25

题叶

ClojureScript 爱好者.

关注 631

y_ck 发布了文章 · 2020-09-08

Promise 你真的用明白了么?

文章首发自笔者的 Github

Promise 关于 API 这块大家应该都能熟练使用,但是和微任务相关的你可能还存在知识盲区。

前置知识

在开始正文前,我们先把本文涉及到的一些内容提前定个基调。

Promise 哪些 API 涉及了微任务?

Promise 中只有涉及到状态变更后才需要被执行的回调才算是微任务,比如说 thencatchfinally ,其他所有的代码执行都是宏任务(同步执行)。

上图中蓝色为同步执行,黄色为异步执行(丢到微任务队列中)。

这些微任务何时被加入微任务队列?

这个问题我们根据 ecma 规范来看:

  • 如果此时 Promise 状态为 pending,那么成功或失败的回调会分别被加入至 [[PromiseFulfillReactions]][[PromiseRejectReactions]] 中。如果你看过手写 Promise 的代码的话,应该能发现有两个数组存储这些回调函数。
  • 如果此时 Promise 状态为非 pending 时,回调会成为 Promise Jobs,也就是微任务。

了解完以上知识后,正片开始。

同一个 then,不同的微任务执行

初级

Promise.resolve()
  .then(() => {
    console.log("then1");
    Promise.resolve().then(() => {
      console.log("then1-1");
    });
  })
  .then(() => {
    console.log("then2");
  });

以上代码大家应该都能得出正确的答案:then1 → then1-1 → then2

虽然 then 是同步执行,并且状态也已经变更。但这并不代表每次遇到 then 时我们都需要把它的回调丢入微任务队列中,而是等待 then 的回调执行完毕后再根据情况执行对应操作。

基于此,我们可以得出第一个结论:链式调用中,只有前一个 then 的回调执行完毕后,跟着的 then 中的回调才会被加入至微任务队列。

中级

大家都知道了 Promise resolve 后,跟着的 then 中的回调会马上进入微任务队列。

那么以下代码你认为的输出会是什么?

let p = Promise.resolve();

p.then(() => {
  console.log("then1");
  Promise.resolve().then(() => {
    console.log("then1-1");
  });
}).then(() => {
  console.log("then1-2");
});

p.then(() => {
  console.log("then2");
}); 

按照一开始的认知我们不难得出 then2 会在 then1-1 后输出,但是实际情况却是相反的。

基于此我们得出第二个结论:每个链式调用的开端会首先依次进入微任务队列。

接下来我们换个写法:

let p = Promise.resolve().then(() => {
  console.log("then1");
  Promise.resolve().then(() => {
    console.log("then1-1");
  });
}).then(() => {
  console.log("then2");
});

p.then(() => {
  console.log("then3");
});

上述代码其实有个陷阱,then 每次都会返回一个新的 Promise,此时的 p 已经不是 Promise.resolve() 生成的,而是最后一个 then 生成的,因此 then3 应该是在 then2 后打印出来的。

顺便我们也可以把之前得出的结论优化为:同一个 Promise 的每个链式调用的开端会首先依次进入微任务队列。

高级

以下大家可以猜猜 then1-2 会在何时打印出来?

Promise.resolve()
  .then(() => {
    console.log("then1");
    Promise.resolve()
      .then(() => {
        console.log("then1-1");
        return 1;
      })
      .then(() => {
        console.log("then1-2");
      });
  })
  .then(() => {
    console.log("then2");
  })
  .then(() => {
    console.log("then3");
  })
  .then(() => {
    console.log("then4");
  });

这题肯定是简单的,记住第一个结论就能得出答案,以下是解析:

  • 第一次 resolve 后第一个 then 的回调进入微任务队列并执行,打印 then1
  • 第二次 resolve 后内部第一个 then 的回调进入微任务队列,此时外部第一个 then 的回调全部执行完毕,需要将外部的第二个 then 回调也插入微任务队列。
  • 执行微任务,打印 then1-1then2,然后分别再将之后 then 中的回调插入微任务队列
  • 执行微任务,打印 then1-2then3 ,之后的内容就不一一说明了

接下来我们把 return 1 修改一下,结果可就大不相同啦:

Promise.resolve()
  .then(() => {
    console.log("then1");
    Promise.resolve()
      .then(() => {
        console.log("then1-1");
        return Promise.resolve();
      })
      .then(() => {
        console.log("then1-2");
      });
  })
  .then(() => {
    console.log("then2");
  })
  .then(() => {
    console.log("then3");
  })
  .then(() => {
    console.log("then4");
  });

当我们 return Promise.resolve() 时,你猜猜 then1-2 会何时打印了?

答案是最后一个才被打印出来。

为什么在 then 中分别 return 不同的东西,微任务的执行顺序竟有如此大的变化?以下是笔者的解析。

PS:then 返回一个新的 Promise,并且会用这个 Promise 去 resolve 返回值,这个概念需要大家先了解一下。

根据 Promise A+ 规范

根据规范 2.3.2,如果 resolve 了一个 Promise,需要为其加上一个 thenresolve

if (x instanceof MyPromise) {
  if (x.currentState === PENDING) {
  } else {
    x.then(resolve, reject);
  }
  return;
}

上述代码节选自手写 Promise 实现。

那么根据 A+ 规范来说,如果我们在 then 中返回了 Promise.resolve 的话会多入队一次微任务,但是这个结论还是与实际不符的,因此我们还需要寻找其他权威的文档。

根据 ECMA - 262 规范

根据规范 25.6.1.3.2,当 Promise resolve 了一个 Promise 时,会产生一个NewPromiseResolveThenableJob,这是属于 Promise Jobs 中的一种,也就是微任务。

This Job uses the supplied thenable and its then method to resolve the given promise. This process must take place as a Job to ensure that the evaluation of the then method occurs after evaluation of any surrounding code has completed.

并且该 Jobs 还会调用一次 then 函数来 resolve Promise,这也就又生成了一次微任务。

这就是为什么会触发两次微任务的来源。

最后

文章到这里就完结了,大家有什么疑问都可以在评论区提出。

推荐关注我的微信公众号【前端真好玩】,工作日推送高质量文章。

image.png

笔者就职于酷家乐,家装设计行业独角兽。一流的可视化、前端技术团队,有兴趣的可以简历投递至 zx597813039@gmail.com
查看原文

赞 25 收藏 21 评论 0

y_ck 发布了文章 · 2020-07-14

还在看那些老掉牙的性能优化文章么?这些最新性能指标了解下

性能优化相关的文章其实网上挺多,但是大部分都是在讲如何优化性能,也就是讲方法论。但是在实际工作中,如何量化性能优化也是相当重要的一环。今天本文会介绍谷歌提倡的七个用户体验指标(也可以认为是性能指标),每个指标分别根据以下几点讲解:

  1. 指标本身的作用、测量、推荐时间区间等
  2. 如何指标进行优化,该内容会在文末统一讲解

FP & FCP

首次绘制,FP(First Paint),这个指标用于记录页面第一次绘制像素的时间。

首次内容绘制,FCP(First Contentful Paint),这个指标用于记录页面首次绘制文本、图片、非空白 Canvas 或 SVG 的时间。

这两个指标看起来大同小异,但是 FP 发生的时间一定大于等于 FCP,如下图是掘金的指标:

FP 指的是绘制像素,比如说页面的背景色是灰色的,那么在显示灰色背景时就记录下了 FP 指标。但是此时 DOM 内容还没开始绘制,可能需要文件下载、解析等过程,只有当 DOM 内容发生变化才会触发,比如说渲染出了一段文字,此时就会记录下 FCP 指标。因此说我们可以把这两个指标认为是和白屏时间相关的指标,所以肯定是最快越好。

上图是官方推荐的时间区间,也就是说如果 FP 及 FCP 两指标在 2 秒内完成的话我们的页面就算体验优秀。

LCP

最大内容绘制,LCP(Largest Contentful Paint),用于记录视窗内最大的元素绘制的时间,该时间会随着页面渲染变化而变化,因为页面中的最大元素在渲染过程中可能会发生改变,另外该指标会在用户第一次交互后停止记录。指标变化如下图:

LCP 其实能比前两个指标更能体现一个页面的性能好坏程度,因为这个指标会持续更新。举个例子:当页面出现骨架屏或者 Loading 动画时 FCP 其实已经被记录下来了,但是此时用户希望看到的内容其实并未呈现,我们更想知道的是页面主要的内容是何时呈现出来的。

此时 LCP 指标是能够帮助我们实现想要的需求的。

上图是官方推荐的时间区间,在 2.5 秒内表示体验优秀。

TTI

首次可交互时间,TTI(Time to Interactive)。这个指标计算过程略微复杂,它需要满足以下几个条件

  1. 从 FCP 指标后开始计算
  2. 持续 5 秒内无长任务(执行时间超过 50 ms)且无两个以上正在进行中的 GET 请求
  3. 往前回溯至 5 秒前的最后一个长任务结束的时间

这里你可能会疑问为什么长任务需要定义为 50ms 以外?

Google 提出了一个 RAIL 模型:

对于用户交互(比如点击事件),推荐的响应时间是 100ms 以内。那么为了达成这个目标,推荐在空闲时间里执行任务不超过 50ms(W3C 也有这样的标准规定),这样能在用户无感知的情况下响应用户的交互,否则就会造成延迟感。

长任务也会在 FID 及 TBT 指标中使用到。

因此这是一个很重要的用户体验指标,代表着页面何时真正进入可用的状态。毕竟光内容渲染的快也不够,还要能迅速响应用户的交互。想必大家应该体验过某些网站,虽然内容渲染出来了,但是响应交互很卡顿,只能过一会才能流畅交互的情况。

FID

首次输入延迟,FID(First Input Delay),记录在 FCP 和 TTI 之间用户首次与页面交互时响应的延迟。

这个指标其实挺好理解,就是看用户交互事件触发到页面响应中间耗时多少,如果其中有长任务发生的话那么势必会造成响应时间变长。

其实在上文我们就讲过 Google 推荐响应用户交互在 100ms 以内:

TBT

阻塞总时间,TBT(Total Blocking Time),记录在 FCP 到 TTI 之间所有长任务的阻塞时间总和。

假如说在 FCP 到 TTI 之间页面总共执行了以下长任务(执行时间大于 50ms)及短任务(执行时间低于 50ms)

那么每个长任务的阻塞时间就等于它所执行的总时间减去 50ms

所以对于上图的情况来说,TBT 总共等于 345ms。

这个指标的高低其实也影响了 TTI 的高低,或者说和长任务相关的几个指标都有关联性。

CLS

累计位移偏移,CLS(Cumulative Layout Shift),记录了页面上非预期的位移波动。

大家想必遇到过这类情况:页面渲染过程中突然插入一张巨大的图片或者说点击了某个按钮突然动态插入了一块内容等等相当影响用户体验的网站。这个指标就是为这种情况而生的,计算方式为:位移影响的面积 * 位移距离。

以上图为例,文本移动了 25% 的屏幕高度距离(位移距离),位移前后影响了 75% 的屏幕高度面积(位移影响的面积),那么 CLS 为 0.25 * 0.75 = 0.1875

CLS 推荐值为低于 0.1,越低说明页面跳来跳去的情况就越少,用户体验越好。毕竟很少有人喜欢阅读或者交互过程中网页突然动态插入 DOM 的情况,比如说插入广告~

介绍完了所有的指标,接下来我们来了解哪些是用户体验三大核心指标、如何获取相应的指标数据及如何优化。

三大核心指标

Google 在今年五月提出了网站用户体验的三大核心指标,分别为:

  • LCP
  • FID
  • CLS

LCP 代表了页面的速度指标,虽然还存在其他的一些体现速度的指标,但是上文也说过 LCP 能体现的东西更多一些。一是指标实时更新,数据更精确,二是代表着页面最大元素的渲染时间,通常来说页面中最大元素的快速载入能让用户感觉性能还挺好。

FID 代表了页面的交互体验指标,毕竟没有一个用户希望触发交互以后页面的反馈很迟缓,交互响应的快会让用户觉得网页挺流畅。

CLS 代表了页面的稳定指标,尤其在手机上这个指标更为重要。因为手机屏幕挺小,CLS 值一大的话会让用户觉得页面体验做的很差。

如何获取指标

Lighthouse

你可以通过安装 Lighthouse 插件来获取如下指标

web-vitals-extension

官方出品,你可以通过安装 web-vitals-extension 插件来获取三大核心指标

web-vitals 库

官方出品,你可以通过安装 web-vitals 包来获取如下指标

代码使用方式也挺简单:

import {getCLS, getFID, getLCP} from 'web-vitals';

getCLS(console.log);
getFID(console.log);
getLCP(console.log);

Chrome DevTools

这个工具就不多做介绍了,打开 Performance 即可快速获取如下指标

如何优化指标

资源优化

该项措施可以帮助我们优化 FP、FCP、LCP 指标。

  • 压缩文件、使用 Tree-shaking 删除无用代码
  • 服务端配置 Gzip 进一步再压缩文件体积
  • 资源按需加载
  • 通过 Chrome DevTools 分析首屏不需要使用的 CSS 文件,以此来精简 CSS
  • 内联关键的 CSS 代码
  • 使用 CDN 加载资源及 dns-prefetch 预解析 DNS 的 IP 地址
  • 对资源使用 preconnect,以便预先进行 IP 解析、TCP 握手、TLS 握手
  • 缓存文件,对首屏数据做离线缓存
  • 图片优化,包括:用 CSS 代替蹄片、裁剪适配屏幕的图片大小、小图使用 base64 或者 PNG 格式、支持 WebP 就尽量使用 WebP、渐进式加载图片

网络优化

该项措施可以帮助我们优化 FP、FCP、LCP 指标。

这块内容大多可以让后端或者运维帮你去配置,升级至最新的网络协议通常能让你网站加载的更快。

比如说使用 HTTP2.0 协议、TLS 1.3 协议或者直接拥抱 QUIC 协议~

优化耗时任务

该项措施可以帮助我们优化 TTI、FID、TBT 指标。

  • 使用 Web Worker 将耗时任务丢到子线程中,这样能让主线程在不卡顿的情况下处理 JS 任务
  • 调度任务 + 时间切片,这块技术在 React 16 中有使用到。简单来说就是给不同的任务分配优先级,然后将一段长任务切片,这样能尽量保证任务只在浏览器的空闲时间中执行而不卡顿主线程

不要动态插入内容

该项措施可以帮助我们优化 CLS 指标。

  • 使用骨架屏给用户一个预期的内容框架,突兀的显示内容体验不会很好
  • 图片切勿不设置长宽,而是使用占位图给用户一个图片位置的预期
  • 不要在现有的内容中间插入内容,起码给出一个预留位置

最后

以上是笔者对于用户体验指标的一些内容整理,如果有不懂的或者错误的地方欢迎指正及交流。

推荐关注我的微信公众号【前端真好玩】,工作日推送高质量文章。

image.png

笔者就职于酷家乐,家装设计行业独角兽。一流的可视化、前端技术团队,有兴趣的可以简历投递至 zx597813039@gmail.com
查看原文

赞 22 收藏 13 评论 0

y_ck 关注了用户 · 2020-07-14

敖丙 @aobing

关注 5896

y_ck 关注了专栏 · 2020-07-14

前端巅峰

注重前端性能优化和前沿技术,重型跨平台开发,即时通讯技术等。 欢迎关注微信公众号:前端巅峰

关注 21021

认证与成就

  • 获得 148 次点赞
  • 获得 0 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 0 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2020-07-14
个人主页被 4.1k 人浏览