跳到主要内容

29 篇博文 含有标签「javascript」

查看所有标签

· 阅读需 2 分钟

MapSet 都是 ES6 中引入的表示集合的数据结构

  • Map 表示「键值对」的集合

    • 有序,插入的顺序
    • 支持 set get has size delete clear
    • 键为任意类型的值
    • 可以遍历
  • Set 表示「不重复值」的集合

    • 元素唯一无序
    • 支持 add has delete clear
    • 可以遍历

WeakMapWeakSet 类似与 Map 和 Set

  • WeakMap
    • 必须为对象
    • 键名都是弱引用
    • 不能遍历
  • WeakSet
    • 元素必须为对象
    • 成员都是弱引用
    • 不能遍历

「弱」主要体现在垃圾回收机制面前的地位

如果一个对象有引用,那么垃圾回收器就不会被回收

而 WeakMap 中的键(对象)以及 WeakSet 中的元素(对象)这两种引用方式,都不在垃圾回收机制考虑范围内,该回收照样会被回收

这也是为什么 WeakMap 和 WeakSet 不支持遍历的原因(键或元素可能会被垃圾回收掉)

· 阅读需 1 分钟

完成这个表达式:add[1][2][3] + 4 => 10

  1. 链式读取 add 对象的属性,并且累加 key

  2. 在隐式类型转换时,要将内部的累加结果作返回

const add = new Proxy(
{ value: 0 },
{
get(target, key, receiver) {
// 对象 => 原始,会先调用对象的 Symbol.toPrimitive 方法
if (key === Symbol.toPrimitive) {
return () => Reflect.get(target, 'value')
}
target.value += Number(key)
// 支持链式读取,所以返回这个 proxy 本身
return receiver
}
}
)

console.log(add[1][2][3] + 4) // 10

· 阅读需 3 分钟

JavaScript的Math对象提供了多种执行数学任务的方法,以下是一些常用的Math方法及其代码示例:

  • Math.round() - 对一个数进行四舍五入到最接近的整数。
console.log(Math.round(1.7))// 2
console.log(Math.round(2.5)) // 3
console.log(Math.round(-1.7)) // -2
console.log(Math.round(-2.5)) // -2
  • Math.floor() - 向下取整(数轴负方向),返回小于或等于一个给定数字的最大整数。
console.log(Math.floor(-1.7)) // -2
console.log(Math.floor(1.7)) // 1
  • Math.ceil() - 向上取整(数轴正方向),返回大于或等于一个给定数字的最小整数。
console.log(Math.ceil(-1.7)) // -1
console.log(Math.ceil(1.7)) // 2
  • Math.random() - 返回一个介于[0, 1)之间的随机数。
console.log(Math.random()); // 输出: 随机数
  • Math.max() - 返回一组数中的最大值。
console.log(Math.max(10, 20, 30)); // 输出: 30
  • Math.min() - 返回一组数中的最小值。
console.log(Math.min(10, 20, 30)); // 输出: 10
  • Math.sqrt() - 返回一个数的平方根。
console.log(Math.sqrt(64)); // 输出: 8
  • Math.abs() - 返回一个数的绝对值。
console.log(Math.abs(-4.7)); // 输出: 4.7
  • Math.pow() - 返回基数(第一个给定的数)的指数(第二个给定的数)次幂。
console.log(Math.pow(8, 2)); // 输出: 64
  • parseInt

Math.floor 是向取整,而 parseInt 是向取整

因此二者在对正数处理时,返回的结果是一致的

但是在处理负数时,二者就出现了差别,此时 parseIntMath.ceil 的结果是一致的

  • 生成随机数为什么使用 Math.floor 而不是 parseInt 或者 Math.round ?
    • 处理负数时的行为差别,导致随机数概率不均等
function getRandom(min, max) {
return Math.floor(Math.random() * (max + 1 - min) + min)
}

· 阅读需 4 分钟

参数默认值的由来

在开发过程中,经常会使用或封装一些接受动态参数的函数。为了满足实际需求,需要在函数体中书写大量参数处理逻辑:

function substring(start, end) {
end = end || 默认值
}

传统的方式书写麻烦,动态参数越多,代码复杂度也就越大;如果参数值为 0 ,还需额外判断逻辑

ES6 为了解决这个问题,提出了默认参数的语法,表示如果该参数没有传递(undefined),则使用默认值

function foo(a, b = 2, c = 3) {
console.log(a, b, c)
}
foo() // undefined 2 3
foo(1, null, undefined) // 1 null 3
// 传递的顺序是一一对应的,不会跳跃传递

其中有几个细节可以作为面试题的考点:

arguments

arguments 是一个类数组对象,表示函数调用时传入的实参集合

function mixArgs(a, b = 2) {
console.log(arguments)
console.log(arguments.length)
console.log(a === arguments[0])
console.log(b === arguments[1])
a = 'alpha'
b = 'beta'
console.log(arguments.length)
console.log(a === arguments[0])
console.log(b === arguments[1])e
}
mixArgs(1)
  1. argument 与实参是绑定的,是动态的,会随着参数的变化而变化
  2. 在严格模式(ES5)下,arguments 与实参的绑定就消失了
  3. 当使用参数默认值时,arguments 的行为与严格模式一致

length

length 表示函数声明的形参数量

function foo(a, b = 2, c) { }
console.log(foo.length) // 1

在计算形参 length 时,会只计算默认参数之前的形参个数

比如上面的例子,编译器会觉得这个函数可以只传一个参数 a,所以 foo.length 为 1

默认值表达式

默认参数不一定是字面量,也有可能是一个表达式。这个表达式只在需要用到默认值时,才会去调用

let n = 1
function getValue() {
return n++
}
function foo(a, b = getValue()) {
console.log(a, b)
console.log('n:', n)
}
foo(1, 10) // 1 10 n:1 不需要默认值,不会去调用默认值表达式
foo(1) // 1 1 n:2
foo(1, 10) // 1 10 n还是2
foo(1) // 1 2 n:3
foo(1) // 1 3 n:4

参数默认值的 TDZ

任何变量都会发生变量提升,只不过 let const 会有 TDZ 的现象,因为在语法层面上,不希望开发者在变量声明前去使用

function getValue(v) {
return v * 2
}
function foo(a, b = getValue(a)) {
console.log(a, b)
}
foo(1) // 1 2
foo(2) // 2 4

function bar(a = getValue(b), b) {
console.log(a, b)
}
bar(undefined, 1) // error
bar(undefined, 2) // error

参数默认值与 let const 一样,都会产生 TDZ,即位于前面的默认参数表达式无法访问位于后面的参数

· 阅读需 6 分钟

需求

实现 box 由左至右移动的动画

实现思路

CSS

使用 animation 配合 @keyframe 属性

  • 提供简单的声明式语法自动运行,但对动画的控制相对限制,如暂停、重启等功能依赖于CSS的animation-play-state属性。
  • 良好的浏览器支持,特别是在现代浏览器中,性能通常非常好,尤其是对于简单的动画效果。CSS动画可以由浏览器的合成器直接处理,不需要回流和重绘
  • 适合实现简单的动画效果,如过渡、2D平移、旋转、缩放等。
  • 能够在没有 JavaScript 的情况下工作,可以更容易地由设计师创建和维护。
#box1 {
background: red;
/* 方案1 使用 animation */
animation: moveRight 1s forwards;
}

@keyframes moveRight {
to {
transform: translateX(900px);
}

JS

setInterval

使用定时器实现动画

const box = document.querySelectorAll(".box")[0];
let left = 0;
function boxMoving() {
if (left > 200) {
clearInterval(timer);
} else {
left++;
box.style.left = left + "px";
}
}
let timer = setInterval(boxMoving, 1000 / 120); // 120Hz
  • CSS3 动画出来以前,我们通常使用 JS 来实现动画效果,即使有了 CSS 动画,我们很多动画效果还是得依赖 JS。而 JS 实现动画得两大利器便是 setTimeoutsetInterval因为我们动画的原理就是不停地刷新图像,而定时器可以帮我们做这一操作
  • 代码很简单,就是让 div 元素向右移动 1px。然后我们开启定时器不断重复该函数,需要注意的是,我们将函数执行的频率(1000/120ms)调为了差不多和屏幕刷新率一样,即 120Hz 的屏幕刷新率。
存在问题
  • setInterval异步的,也就是意味着 JS 代码执行的时候会将它放入异步队列中,所以它的执行时间并不确定
  • 屏幕刷新率是不定的,现在各大厂家的屏幕刷新率有 30Hz60Hz90Hz120Hz 等等,以后还会更多,但是我们传入 setInterval 的时间间隔是固定的,这就有可能造成动画的执行与屏幕的刷新率不匹配,从而导致在不同设备上的动画流畅度不同,产生跳帧或卡帧的现象
  • setInterval一直在后台执行,即使我们访问其它页面时。
解决
  • 前面我们介绍的 setInterval 是我们使用 JS 实现动画常用的手段,不可否认,在以前确实可以这样使用,也是最好的办法。
  • 但是我们也需要正面这些问题,特别是随着电子设备的不断更新换代,屏幕的刷新率也出现很多种的情况下,如果继续使用定时器来实现动画,很可能会造成动画的卡顿以及性能的消耗
  • requestAnimationFrame API 就非常完美的解决了定时器实现动画的各种问题。
requestAnimationFrame
  • 是一个原生API,接收一个回调函数作为参数,返回一个ID,用于cancelAnimationFrame(ID)
  • 回调函数会在屏幕刷新的时候调用,由系统来决定回调函数的执行时机
  • 不会在后台一直执行,它会在页面出现的时候才会执行,比如 document.hidetrue 的时候,而我们的定时器是一直会执行的。可以节省 CPUGPU 等等。
const box = document.querySelectorAll(".box")[0];
let left = 0;
function boxMoving() {
if (left > 200) {
cancelAnimationFrame(timer);
} else {
left++;
box.style.left = left + "px";
// 请求动画帧,即屏幕刷新的时候再次执行
requestAnimationFrame(boxMoving);
}
}
let timer = requestAnimationFrame(boxMoving);
问题

requestAnimationFrame(简称rAF)在不同屏幕刷新率下可能会导致动画的体验有所不同。这主要是因为 rAF 是与浏览器的重绘过程(也就是屏幕的刷新率)同步的。因此,动画的更新频率会与显示器的刷新频率一致。

如果一个显示器的刷新率是 60Hz,那么requestAnimationFrame大约每 16.67 毫秒被调用一次(1000ms/60),这意味着你的动画每秒钟会更新 60 次。如果显示器的刷新率更高,比如 120Hz,那么rAF将大约每 8.33 毫秒被调用一次,动画每秒更新 120 次,这会使动画更加平滑

· 阅读需 3 分钟

解释

在 JavaScript 中,callbindapply都是 Function.prototype 上的方法,它们可以用来设置函数调用时的 this,即函数执行时的上下文。

call() call 方法可以让你明确地指定函数的this值,还可以在调用函数时传递给函数参数列表

func.call(thisArg, arg1, arg2, ...)

apply() apply 方法的功能与 call 相似,但是它接受一个数组(或类数组对象)作为调用函数时的参数列表

func.apply(thisArg, [argsArray])

bind() bind 方法创建一个新函数,你可以将一个值设置为在新函数中调用原始函数时的 this 值。不同于callapplybind 不会立即执行函数,而是返回一个新的函数

const newFunc = func.bind(thisArg[, arg1[, arg2[, ...]]])

手写 bind

Function.prototype.myBind = function (ctx, ...args) {
const func = this
return function (...restArgs) {
if (new.target) {
return new func(...args, ...restArgs)
} else {
return func.apply(ctx, [...args, ...restArgs])
}
}
}

function originFunc(a, b, c, d) {
console.log('fun 执行了')
console.log('args', a, b, c, d)
console.log('this', this)
}

const boundFunc = originFunc.myBind('boundThis', 1, 2)
console.log(new boundFunc(3, 4))
  • 有一个小细节:boundFunc 如果是通过 new 关键字调用的(new.target),我们也要使用原函数作为构造函数,并返回这个实例;否则返回使用 call 或 apply 修改其 this 后的函数

手写 call

  • 处理新的 this 为对象
    • 使用 Object()globalThis 关键字
Function.prototype.myCall = function (ctx, ...args) {
const func = this
// 处理新的 this 为对象
// Object 会将基本类型的数据转为对应的包装类实例
ctx = ctx === null || ctx === undefined ? globalThis : Object(ctx)

// 将原函数作为 ctx 的一个属性
// 通过对象方法调用,从而改变原函数的 this 指向(为新 ctx)
ctx._func = func
ctx._func(...args)
}

  • 但是会导致新的 this 对象额外多了一个可枚举属性 _func,并且可能会出现属性重名的情况
    • 使用 Object.defineProperty 定义不可枚举的 Symbol 属性
Function.prototype.myCall = function (ctx, ...args) {
const func = this
ctx = ctx === null || ctx === undefined ? globalThis : Object(ctx)

const key = Symbol()
Object.defineProperty(ctx, key, {
value: func,
enumerable: false
})
ctx[key](...args)
}

function originFunc(a, b) {
console.log('args', a, b)
console.log('this', this)
}

originFunc.myCall('boundedThis', 1, 2)
console.log('==========')
originFunc.call('boundedThis', 1, 2)

手写 apply 思路与 call 一致,只需修改入参类型为数组即可

· 阅读需 1 分钟

使用 async 关键字声明的函数将返回一个 Promise 对象,而在这个异步函数中,你可以使用 await 关键字等待一个异步操作的结果,就像编写同步代码一样。

async/await 实际上可以看作是 Generator 函数及 Promise 的语法糖

async/await 出现之前,开发者可以通过 Generator 函数加上 yield 关键字以及适当的执行器(例如使用库如co)来处理异步操作,实现类似 async/await 的功能

· 阅读需 4 分钟

题目

const t = new TaskPro()
t.addTask(async (next) => {
console.log(1, 'start')
await next()
console.log(1, 'end')
})
t.addTask(() => {
console.log(2)
})
t.addTask(() => {
console.log(3)
})
t.run() // 1 start, 2, 3, 1 end

分析

t 是 TaskPro 的实例,有 run 和 addTask 两个实例方法

其中 addTask 方法接受一个函数(同步或异步),函数具有唯一参数 next,是一个异步函数

调用 next 可以跳过当前位置之后的代码,直接执行下一个任务

调用 run 方法正式开始执行任务队列中的任务

与 koa 中间件的洋葱模型同理

思路

  1. 准备实例方法和私有属性
class TaskPro {
#taskList = [] // 任务队列
#isRunning = false // 标识是否正在执行

// 添加一个任务到任务队列
addTask(task) {
this.#taskList.push(task)
}

// 执行任务队列
async run() {
// 防止多次重复执行 run
if (this.#isRunning) {
return
}
this.#isRunning = true
// ...
}
}
  1. 执行任务就是在任务队列取出一个任务来执行,这里抽离为一个私有方法 runTask
class TaskPro {
// ...
#currentTaskIndex = 0 // 记录当前执行任务的下标

async run() {
// ...
this.#runTask()
}

async #runTask() {
// 边界情况,收尾工作
if (this.#currentTaskIndex >= this.#taskList.length) {
this.#isRunning = false
this.#currentTaskIndex = 0
this.#taskList = []
return
}
// 根据当前执行任务的下标取出任务
const task = this.#taskList[this.#currentTaskIndex]
// 执行任务
await task()
}
}
  1. 处理 next 参数,next 方法实际上就是将 currentTaskIndex 加1,重复执行 runTask方法
class TaskPro {
// ...
async #runTask() {
// ...
// 由于我们在使用中是直接调用的 next,导致 next 函数中用到的 this 为 {}
// 所以使用 bind 绑定好 this 再传到 task 中
await task(this.#next.bind(this))
}

// 下标加1,继续执行任务
async #next() {
this.#currentTaskIndex++
await this.#runTask()
}
}
  1. 默认调用 next
class TaskPro {
// ...
async #runTask() {
// ...
// 记录执行任务之前的 index
const i = this.#currentTaskIndex
await task(this.#next.bind(this))
// 与 await task 之后的下标对比,如果没改变,说明用户没有手动调用 next
if (this.#currentTaskIndex === i) {
// 默认执行 next
await this.#next()
}
}
}

源码及测试用例

class TaskPro {
#taskList = []
#isRunning = false
#currentTaskIndex = 0
addTask(task) {
this.#taskList.push(task)
}
async run() {
if (this.#isRunning) {
return
}
this.#isRunning = true
await this.#runTask()
}
async #runTask() {
if (this.#currentTaskIndex >= this.#taskList.length) {
this.#isRunning = false
this.#currentTaskIndex = 0
this.#taskList = []
return
}
const task = this.#taskList[this.#currentTaskIndex]
const i = this.#currentTaskIndex
await task(this.#next.bind(this))
if (this.#currentTaskIndex === i) {
await this.#next()
}
}
async #next() {
this.#currentTaskIndex++
await this.#runTask()
}
}

const t = new TaskPro()

t.addTask(async (next) => {
console.log(1, 'start')
await next()
console.log(1, 'end')
})
t.addTask(async (next) => {
console.log(2, 'start')
await next()
console.log(2, 'end')
})
t.addTask(() => {
console.log(3)
})
t.run()

· 阅读需 5 分钟

深拷贝

b是a的一份拷贝,且b中没有对a中属性的引用

序列化

const b = JSON.parse(JSON.stringify(a))

缺点:JSON 不支持的类型(函数、Date、undefined、正则等)以及不支持自引用(a.self = a)

基本数据类型

首先根据数据类型区别基本数据类型对象instanceof Object),基本数据类型则直接返回

if(v instanceof Object) {
// ...
} else {
return v
}

对象类型

对象类型使用 for in 递归深拷贝其内部自有属性

if(v instanceof Object) {
let result = undefined
// ...
// 对内部自有属性进行递归拷贝
for(let key in v){
if(v.hasOwnProperty(key)){
result[key] = deepClone(v[key])
}
}
return result
} else {
return v
}

原型

根据 value 的原型,初始化深拷贝结果 result,相当于提前复制原型链上的属性

主要有 ArrayFunctionDateRegExpPlain Object

let result = undefined

// Array
if(v instanceof Array) {
result = new Array()
// Function
} else if(v instanceof Function){
// 普通函数
if(v.prototype) {
result = function(){ return v.apply(this, arguments)}
// 箭头函数
} else {
result = (...args) => { return v.call(undefined, ...args)}
}
// Date
} else if(v instanceof Date) {
// Date的数据减0会转为时间戳,再利用这个时间戳构造新的Date
result = new Date(v - 0)
// RegExp
} else if(v instanceof RegExp) {
result = new RegExp(v.source, v.flags)
// Plain Object
} else {
result = new Object()
}

其中,函数要区别普通函数和箭头函数(v.prototype);时间类型的数据减 0 会隐式转换为时间戳,利用这个时间戳构造新的 Date;尽可能使用 new 关键字来初始化 result

自引用

对于 a.self = a 的自引用情况,可以使用一个 Map 来记录拷贝的属性(key和value均为拷贝的值),如果发现自引用了,就直接返回a

// 利用Map对每一次拷贝做记录
const cache = new Map()
const deepClone = (v) => {
if(v instanceof Object) {
// 如果发现自引用(key 对象存在),直接返回 v
if(cache.get(v)){ return cache.get(v)}
// ...
// 将拷贝的值与结果存入map
cache.set(v, result)
// ...
} else {
return v
}
}

完整代码(附测试用例)

// 利用Map对每一次拷贝做记录
const cache = new Map()
const deepClone = (v) => {
if(v instanceof Object) {// object
// 如果发现自引用(key对象存在),直接返回v
if(cache.get(v)){ return cache.get(v)}
let result = undefined
if(v instanceof Array) {// object-Array
result = new Array()
} else if(v instanceof Function){// object-Function
if(v.prototype) {// 普通函数(都有prototype属性)
result = function(){ return v.apply(this, arguments)}
} else {// 箭头函数
result = (...args) => { return v.call(undefined, ...args)}
}
} else if(v instanceof Date) {// object-Date
// Date的数据减0会转为时间戳,再利用这个时间戳构造新的Date
result = new Date(v - 0)
} else if(v instanceof RegExp) {// object-RegExp
result = new RegExp(v.source, v.flags)
} else { // object-Object
result = new Object()
}
// 将拷贝的值与结果存入map
cache.set(v, result)
// 对内部自有属性进行递归拷贝
for(let key in v){
if(v.hasOwnProperty(key)){
result[key] = deepClone(v[key])
}
}
return result
} else {// 基本数据类型
return v
}
}
// 测试用例
const a = {
number:1, bool:false, str: 'hi', empty1: undefined, empty2: null,
array: [
{name: 'frank', age: 18},
{name: 'jacky', age: 19}
],
date: new Date(2000,0,1,20,30,0),
regex: /\.(j|t)sx/i,
obj: { name:'frank', age: 18},
f1: (a, b) => a + b,
f2: function(a, b) { return a + b }
}
// 自引用
a.self = a

const b = deepClone(a)

b.self === b // true
b.self = 'hi'
a.self !== 'hi' //true

· 阅读需 6 分钟

Source code:https://github.com/GSemir0418/file-slice-upload

针对大文件上传的业务场景,前后端采用切片上传的方案,即前端将大文件分割为固定大小的 chunk,并循环请求给后端;后端每获取一部分,就写入到服务器指定文件中,最终实现大文件上传。

1 客户端

1.1 初始化

npm init -y
yarn add vite -D

1.2 项目结构

.
├── index.html 项目首页
├── node_modules
├── package.json
├── src
│ ├── app.js 项目入口文件,主要方法都写到这里
│ └── config.js 字段映射等配置
└── yarn.lock

1.3 思路

  1. 获取上传的文件数据
// 在 oUploader 中获取到 files 数组,并取出第一项,命名为 file
const { files: [file] } = oUploader
// 结构取出上传文件的信息
const { name, size, type } = file
  1. 校验(size,type)
  2. 记录当前上传大小 uploadedSize,用于控制切片及计算进度
  3. while 循环中,使用 slice 对 file 数据进行切片
while (uploadedSize < size) {
const fileChunk = file.slice(uploadedSize, uploadedSize + CHUNK_SIZE);
}
  1. 构造 formData
function createFormData({ name, fileName, type, size, uploadedSize, chunk }) {
const postData = new FormData();
postData.append("name", name);
postData.append("fileName", fileName);
postData.append("type", type);
postData.append("size", size);
postData.append("uploadedSize", uploadedSize);
postData.append("chunk", chunk);
return postData;
}

const formData = createFormData({
name,
fileName,
type,
size,
uploadedSize,
chunk: fileChunk,
});
  1. axios 请求
// axios发送请求
try {
response = await axios.post(API, formData);
} catch (error) {
oInfo.innerHTML = INFO["UPLOAD_FAILED"] + error.message;
return;
}
  1. 每份 chunk 上传结束后,更新 uploadedSize,并同步进度条

1.4 优化思路

  1. 如果某一片上传失败了,怎么处理?
  • 重试机制:实现自动重试机制,在文件块上传失败时(catch error),可以自动尝试重新上传
  • 断点续传:记录已成功上传文件块的信息,如果上传失败,可以从中断的地方重新开始上传。也可以让用户自己决定是否重传失败的文件块。
  • 后台验证完整性:在所有文件块上传完成后,后台进行校验,确保所有文件块均正确无误地上传。如果校验失败,可以提示前端做相应处理。
  • 取消上传任务:提供一个取消机制,当用户决定取消或者在上传过程中发现某个文件块连续重试失败达到限制次数时,可以触发取消操作。取消时应清理已上传的文件块并释放资源。
  1. 如果网络波动,如何保证上传顺序?
  • 使用文件块元信息:在上传每个文件块时,除了文件块的数据外,还应该发送一个包含文件块序号的元信息,这样即使文件块的上传请求不是按序到达服务器,服务器也能根据元信息中的序号将文件块放置到正确的位置。
  • 服务器端排序:在所有文件块上传完毕后,服务器可以根据每个文件块的序号进行排序,以确保文件块在最终组装时的顺序正确。
  • 并发请求控制,详见《并发请求》一文

2 Express 服务端

2.1 初始化

npm init
yarn add express express-fileupload
yarn global add nodemon

2.2 项目结构

.
├── app.js
├── node_modules
├── package.json
├── upload_tem
│ └── 1656466982424_1.mp4.mp4
└── yarn.lock

2.3 思路

  1. 在请求体解构取出 chunk 及其他数据
const { name, fileName, uploadedSize } = req.body;
const { chunk } = req.files;
  1. 处理文件名、后缀及保存路径
  2. 根据 uploadedSize 判断新建或追加数据文件
if (uploadedSize !== "0") {
// 注意是字符串0
if (!existsSync(filePath)) {
res.send({
code: 1002,
msg: "No file exists",
});
return;
}
// append数据到文件,结束本次上传
appendFileSync(filePath, chunk.data);
res.send({
code: 200,
msg: "chunk appended",
// 将服务器静态数据文件路径发送给前端
video_url: "http://localhost:8000/" + filename,
});
return;
}
  1. 如果 uploadedSize 为 0,表示没有正在上传的数据,此时创建并写入这个文件
writeFileSync(filePath, chunk.data);
res.send({ code: 200, msg: "file created" });

附:使用到的中间件等方法

// 请求体数据处理中间件
const bodyParser = require("body-parser");
const uploader = require("express-fileupload");
// extname是获取文件后缀名的
const { extname, resolve } = require("path");
// existsSync检查文件是否存在; appendFileSync同步添加数据
const { existsSync, appendFileSync, writeFileSync } = require("fs");
// 解析并返回的请求体对象配置为任意类型
app.use(bodyParser.urlencoded({ extended: true }));
// 解析json格式的请求体
app.use(bodyParser.json());
// 请求体中上传数据的处理,返回的数据在req.files中
app.use(uploader());
// 指定静态文件url
app.use("/", express.static("upload_tem"));
// 跨域处理
app.all("*", (_, res, next) => {
res.header("Access-Control-Allow-origin", "*");
res.header("Access-Control-Allow-methods", "POST,GET");
next();
});

· 阅读需 3 分钟

现象

在浮点数运算时会出现下面的情况:

0.1 + 0.2 === 0.3 // false
0.5 - 0.4 === 0.1 // false
0.5 - 0.25 === 0.25 // true

原因

首先这不是 JS 的设计缺陷,浮点数精度问题在几乎所有采用 IEEE 754 标准的编程语言(C#、Ruby、Go、Python)中都存在。

浮点数是使用 IEEE 754 标准来表示和存储的。这个标准在表示一些分数时会产生精度问题,因为它使用的是二进制浮点格式,而并非所有的十进制小数都能精确地转换为二进制小数

由于二进制浮点数转换规则,导致了精度缺失的问题

转换规则

参考十进制浮点数的一种拆分思路: $$ 314 = 3 10^2 + 1 10^1 + 4 * 10^0 $$

$$ 3.14 = 3 10^0 + 1 10^{-1} + 4 * 10^{-2} $$

推导出二进制浮点数转十进制的规则:每一位数字分别乘以 2 的若干次幂(小数点前从 0 开始,小数点后从 -1 开始)的积再累加 $$ 101 = 1 2^2 + 02^1 +1*2^0 = 5 $$

$$ 1.101 = 1 2^0 + 1 2^{-1} + 0 2^{-2} + 1 2^{-3} = 1.625 $$

根据以上转换规则,我们会发现任何二进制的浮点数转为十进制后,最后一位一定是 5

也就是说只有最后一位是 5 的十进制浮点数,才有可能转换出有限位数的二进制浮点数

0.1 和 0.3 转为二进制都是无限的位数,相加导致精确丢失,从而影响计算结果

解决

将浮点数转为字符串,写一个十进制的规则来解决浮点数的运算,当然也可以使用第三方库

  • JS 中的十进制与二进制转换方法
// 十转二
const num = 314
num.toString(2)
// 二转十
parseInt('100111010', 2)

· 阅读需 2 分钟

参考文章:阮一峰:undefined与null的区别

undefinednull 都表示「无」,但 null 无的更具体undefined 无的不具体

null 表示无对象,转为数值时为 0undefined 表示无(原始)值,转为数值时为 NaN

undefined 表示「缺少值」,就是此处应该有一个值,但是还没有定义

(1)变量被声明了,但没有赋值时,就等于 undefined。

(2) 调用函数时,应该提供的参数没有提供,该参数等于 undefined。

(3)对象没有赋值的属性,该属性的值为 undefined。

(4)函数没有返回值时,默认返回 undefined。

null 表示「没有对象」,即该处不应该有值。

(1) 作为函数的参数,表示该函数的参数不是对象。

(2) 作为对象原型链的终点。

· 阅读需 3 分钟

概念

Reflect 提供了调用对象基本操作(内部方法)的接口

  • ECMA-262 官方文档对于对象的 Internal Methods 有详细描述,比如 [[Get]][[Set]][[OwnPropertyKeys]][[GetPrototypeOf]]

  • MDN 文档对于内部方法的映射,在 Proxy 文档中也有详细说明

也就是说,对象语法层面的操作(对象声明,属性定义与读取等)最终都是通过调用内部方法来完成的

而 Reflect 允许我们直接调用这些内部方法

const obj = {}
obj.a = 1
// 相当于
Reflect.set(obj, 'a', 1)

Why Reflect

当我们使用某个语法或某个 api 来「间接」操作对象时,与直接调用内部方法还是有区别的

也就是说,除了直接调用内部方法以外,这个语法或 api 还会进行一些额外的操作

const obj = {
a: 1,
b: 2,
get c(){
return this.a + this.b
}
}

当我们要用 obj.c 读取属性时,返回了 3,符合我们的预期

但实际上,内部方法 [[Get]] 是需要额外接收 resolver 用来确定 this 指向的

这就说明 obj.c 在读取属性的过程中,一定不只是直接调用内部方法 [[Get]],而是先把 obj 定义为 this,再调用内部方法 [[Get]](obj, 'c', obj),从而获取到 c 属性的值

应用

  • 配合 Proxy 使用

封装代理对象,需要读取某个属性时,this 应该指向代理对象而不是原始对象

const proxy = new Proxy(obj, {
get(target, key) {
console.log('read', key)
// return target[key] // read c
return Reflect.get(target, key, proxy) // read c read a read b
}
})

proxy.c
  • 读取对象的全部属性名

使用 Object.keys(obj)

虽然一开始调用了内部方法 [[OwnPropertyKeys]] 获取到了对象全部的 keys,但紧接着对于不可枚举或 Symbol 类型的属性进行了排除

因此更严谨的获取对象全部自有属性名包括 Symbol 的方法就是直接调用内部方法 Reflect.ownKeys(obj)

· 阅读需 2 分钟

面试题:

var a = { n: 1 }
var b = a
a.x = a = { n: 2 }
console.log(a.x)
console.log(b.x)

前两行代码很容易理解,下面是其内存示意图:

image-20240318105644305

赋值运算,我们常规的分析思路是先处理右边的,在把计算结果给左边即可

但实际上,是先要根据左边定位其内存空间,再把右边的返回值赋值给左边

第三行代码相当于,将表达式 a = { n: 2 } 的返回值,赋值给 a.x

定位:先确定 a.x 的内存空间,在堆上开辟一个属性 x,但属性值未知

image-20240318105541223

然后处理右边表达式 a = { n: 2 }

先定位 a 的内存空间,在堆上开辟一块内存空间 { n: 2 },然后修改 a 的指向为新对象

image-20240318105131547

该表达式的返回值就是 a 变量此时指向的新地址,因此 x 属性的值也被确定了

image-20240318105443005

显而易见,此时 a.xundefinedb.x{ n: 2 }

· 阅读需 2 分钟

1. 全局污染

var 声明的变量(全局作用域)会挂载到 window 对象上

let 则不会

二者都可以跨 <script> 标签使用

2. 块级作用域

使用 var 定义的变量只有两种作用域:全局和函数

而使用 let 定义的变量多了一种作用域:块级作用域

3. 暂时死区(TDZ)

var 声明变量之前使用该变量,会返回 undefined 而不是引用错误 ReferenceError

而在 letconstclass 声明变量之前,当前作用域开始到变量声明之间会出现暂时性死区

在这个区域内使用该变量,会抛出引用错误

详见 mdn 文档对于 变量提升 行为的详细说明

4. 重复声明

var 声明的变量可以被重复声明

let constclass 不可以

· 阅读需 4 分钟

判断属性是否存在

获取/读取/遍历对象的属性主要有以下几种方式:

  1. Object.keys(obj)

获取对象上全部自有的可枚举的字符串属性

自有:相对与原型属性,可以通过 obj.hasOwnProperty('a') 方法检查

可枚举:可枚举的属性,其enumerable 描述符为 true,可以通过 Object.getOwnPropertyDescriptor(obj, 'a') 查看某个属性的描述符

  1. for in

获取对象上全部可枚举的字符串属性(包括原型链上的可枚举属性),与 Object.keys 的区别就在于原型链上的可枚举属性也会被遍历出来

  1. in 关键字

判断字符串属性是否在对象及对象的原型链上,即不会受到自有以及可枚举的限制

  1. Object.getOwnPropertySymbols(obj)

获取对象上全部的 Symbol 属性

  1. Reflect.ownKeys(obj)

返回对象的所有自有属性


判断对象是否为空

常用方法:

  • JSON 序列化:JSON.stringify(obj) === '{}'
    • But: 只能序列化字符串类型且可枚举的键,忽略 Symbol 等其他类型或不可枚举的键
  • 遍历:for in | Object.keys(obj).length === 0
    • But: 同样,无法遍历不可枚举且非字符串的属性

更严谨的方法:

Reflect.ownKeys(obj).length === 0

Reflect 是 ES6(ECMAScript 2015)引入的一个内置的对象,它提供了拦截 JavaScript 操作的方法。

Reflect.ownKeys 方法用来返回一个由目标对象自身的所有键(包括字符串键、Symbol 键)组成的数组。这个方法实际上是在底层提供了一个更完整的方式来获取对象自身的所有属性名称(即使是不可枚举的属性)和 Symbol 键值。

因为它设计用来执行默认操作,而无论是字符串还是 Symbol 类型的键,都是对象自身属性的一部分。在语言规范中,对象属性的集合被视为属性的名字和它们对应的属性描述符的集合。Reflect.ownKeys 正是直接提供了这个集合的视图。

详见 Reflect 一文

  • 过分一点的需求:确定对象的原型链上也没有属性
  1. 使用Object.getPrototypeOf
function readPrototypeProperties(object) {
let proto = Object.getPrototypeOf(object);
while (proto) {
console.log(Object.getOwnPropertyNames(proto)); // 输出当前原型上的属性
proto = Object.getPrototypeOf(proto);
}
}

readPrototypeProperties(obj);
  1. 使用__proto__属性
let proto = obj.__proto__;
while (proto) {
console.log(Object.getOwnPropertyNames(proto)); // 输出当前原型上的属性
proto = proto.__proto__;
}

· 阅读需 2 分钟

JS 数据类型

8种:数字(number)、字符串(string)、布尔(boolean)、空(null)、未定义(undefined)、对象(object)、bigintsymbol

除 object 外,都属于原始数据类型

Typeof

优点:能够快速区分基本数据类型 (以及 function)

缺点:不能将 Object、Array、Null、Map 等引用类型区分,统一返回 object

typeof 1                        // number
typeof true // boolean
typeof 'str' // string
typeof Symbol('') // symbol
typeof undefined // undefined
typeof 1n // bigint
typeof function(){} // function

typeof [] // object
typeof {} // object
typeof null // object

Instanceof

优点:能够区分 Array、Object 和 Function 等引用数据类型,适合用于判断自定义类的实例对象

缺点:不能很好判断原始数据类型(number string boolean bigInt 等)

当使用字面量创建原始类型的值时,它们不是对象,也就不是构造函数的实例。原始类型的值在需要的时候会被自动包装成对应的对象类型(String, Number, Boolean),但这种自动包装并不影响 instanceof 运算符的行为

[] instanceof Array                     // true
function(){} instanceof Function // true
{} instanceof Object // true

1 instanceof Number // false
true instanceof Boolean // false
'str' instanceof String // false

Object.prototype.toString.call()

优点:精准判断数据类型

缺点:写法繁琐不容易记,推荐进行封装后使用

var toString = Object.prototype.toString
toString.call(1) //[object Number]
toString.call(true) //[object Boolean]
toString.call('str') //[object String]
toString.call([]) //[object Array]
toString.call({}) //[object Object]
toString.call(function(){}) //[object Function]
toString.call(undefined) //[object Undefined]
toString.call(null) //[object Null]
toString.call(2n) //[object BigInt]

· 阅读需 2 分钟

节流(throttle)

  • 理解:
    • 技能cd
  • 概念:
    • 当持续触发事件时,保证一定时间段内只调用一次事件处理函数。
  • 应用场景:
    • 鼠标不断点击触发,mousedown(单位时间内只触发一次)
    • 监听滚动事件,比如是否滑到底部自动加载更多,用throttle来判断
  • 代码:
const throttle = (func, time) => {
let timer = null
return function () {
if (timer) return
func.apply(this, arguments)
timer = setTimeout(() => {
timer = null
}, time)
}
}
throttleBtn.addEventListener('click', throttle(onThrottleBtnClick, 3000))

防抖(debounce)

  • 理解:
    • 回城被打断,只要被打断,就重新回城
    • 计算机睡眠事件
  • 概念:
    • 延时执行。指触发事件后在规定时间内回调函数只能执行一次,如果在规定时间内又触发了该事件,则会重新开始算规定时间。
  • 应用场景:
    • search输入框搜索联想,用户在不断输入值时,用防抖来节约请求资源。
    • 按钮点击:收藏,点赞,心标等
    • window触发resize的时候,不断的调整浏览器窗口大小会不断的触发这个事件,用防抖来让其只触发一次
  • 代码:
const debounce = (func, time) => {
let timer = null
return function () {
clearTimeout(timer)
timer = setTimeout(() => {
func.apply(this, arguments)
}, time)
}
}

debounceBtn.addEventListener('click', debounce(onDebounceBtnClick, 3000))

· 阅读需 8 分钟

在网上看到一道很有意思的题目: [] + {} = '[object Object]',这道题的核心在于JavaScript 中的隐式类型转换

隐式类型转换的场景

在 JavaScript 中,隐式类型转换是指在某些操作中自动将值从一种类型转换成另一种类型的过程。以下列举一些常见的隐式转换场景

1 字符串连接

使用加号 (+) 运算符连接非字符串时,涉及到的值会被转换成字符串。

let a = "The answer is " + 42; // "The answer is 42"
let b = 42 + " is the answer"; // "42 is the answer"

2 执行数学运算

当使用除了加号 (+) 之外的算数运算符时,涉及到的字符串值将转换为数字。

let a = '5' * '4'; // 20 (字符串被转换为数值)
let b = '5' - 2; // 3
let c = '10' / '2';// 5

3 条件判断

在像 ifwhile等语句的条件判断中,值会被自动转换成布尔值。

if ('hello') { // 'hello' 被隐式转换为 true
console.log('This string is considered true');
}

4 == 比较

使用 == 来比较对象和数字时,对象先被转换为一个原始值,通常是数字。

let a = { valueOf: () => 42 };
let b = a == 42; // true

比较对象和字符串时,一样会发生隐式类型转换。

let a = { toString: () => "3" };
let b = a == 3; // true

5 逻辑操作

使用逻辑或 (||) 和逻辑与 (&&) 操作符时,操作数会转换为布尔值进行判断。

let a = 'cat' && 'dog'; // 'dog' - 'cat' 转换为 true,返回最后一个真值
let b = 'cat' || 'dog'; // 'cat' - 'cat' 转换为 true,返回第一个真值

逻辑非运算符 (!) 会将操作数转换为布尔值的相反值。

let truthyValue = "hello";
let falsyResult = !truthyValue; // !'hello' 转换为 false

隐式类型转换规则

JavaScript中的隐式类型转换通常遵循一些基本规则,这些规则确定了当一个操作涉及不同类型的值时,如何对它们进行转换。这里是一些核心规则和优先级:

1 字符串连接优先级规则:

JavaScript 的加号 (+) 是一个多态的操作符,它同时表示字符串连接和数字加法。其行为取决于操作数类型:

  • 如果两个操作数都是数值或者能够转换为数值(或者通过调用 valueOf() 方法得到数字),加号执行数学加法。

  • 如果操作中有任何一个操作数是字符串(或者通过调用 toString() 方法能够得到字符串),那么其他操作数都会转换为字符串并进行连接。

  • 字符串连接优先

2 数值运算优先级规则

  • 如果操作数不是字符串,对于减法 (-)、乘法 (*)、除法 (/) 和取余 (%) 运算,非数值会转换为数值(通过调用 valueOf(),如果不行再调用 toString(),然后转换为数值)。
  • 对于一元加号 (+) 和一元减号 (-) 运算符(例如 +something-something),操作数会转换为数值。

3 逻辑运算优先级规则:

  • 对于逻辑运算如 &&||,操作数会转换为布尔值来评估逻辑表达式。
  • || 返回第一个“真值”(truthy value),如果没有则返回最后一个操作数。
  • && 返回第一个“假值”(falsy value),如果所有都是真值,则返回最后一个操作数。

4 比较运算优先级规则:

  • 在使用 ==!= 进行相等性比较时,如果一个操作数是数字,则另一个操作数会尝试转换为数字。
  • 如果一个操作数是布尔值,在比较之前,布尔值会先被转换为数字(true 转换为 1false 转换为 0)。
  • 如果一个操作数是对象,且另一个是字符串、数字或符号时,对象会通过 valueOftoString 方法转换为原始值进行比较。

5 Boolean Context规则:

  • 在期望布尔值的上下文(如 if 语句)中,值会被转换为布尔值。这包括规则如:nullundefined+0-0NaN 和空字符串 ('') 被转换为 false;其他所有值(包括 "0"'false')被转换为 true

在这些规则之外,还有一个概念叫做“假值”(falsy)和“真值”(truthy):

  • 假值 是在布尔上下文中会被转换为 false 的值,包括:+0-0nullfalseNaNundefined 和空字符串 ''
  • 真值 是在布尔上下文中会被转换为 true 的值,即除了假值之外的所有值。

现在我们来分析这道题

[] + {} = '[object Object]'
// 当 + 运算符用于数组和对象时,JavaScript 会尝试将数组和对象转换为它们的原始值,
// 如果操作中有任何一个操作数是字符串(或者通过调用 `toString()` 方法能够得到字符串),那么就会进行优先进行字符串的拼接
// [] 在转换为原始值时会先调用 Array.prototype.toString 方法,该方法会隐式调用 Array.prototype.join 方法,将数组元素连接成字符串。对于空数组,结果就是空字符串。
// 在将 {} 转为原始类型的过程中,JavaScript 内部会调用 Object.prototype.toString 方法作为转换机制的一部分,返回了对象的字符串形式。
// 因此,最终 [] 被转换为 ''(空字符串),{} 被转换为 '[object Object]',它们通过 + 运算符连接在一起就变成了 '' + '[object Object]',结果是 '[object Object]'。

Object.is()===== 的区别

  • == 会触发隐式类型转换,其他两个不会

  • 其他两个以下两个特殊场景也存在区别:

    • NaN
    Object.is(NaN, NaN) // true
    NaN === NaN // false
    • +0 -0
    Object.is(+0, -0) // false
    +0 === -0 // true
    // Object.is 能够区别正负

    总结:除了特殊情况,就用 === 即可。求稳的话就用 is

· 阅读需 8 分钟
function require(modulePath) {
// 根据传递的模块路径,得到模块完整的绝对路径作为id
var moduleId = getModuleIds(modulePath)

// 辅助函数
function _require(exports, require, module, __filename, __dirname) {
// 我们的模块代码都是在一个函数环境中执行的
// 这也就解释了为什么 commonjs 能够隔离变量,不会造成全局污染

// 我们在模块中可以直接使用 exports module __dirname __filename require 等属性或方法
// 原因就在于他们都是这个函数的参数
}

// 初始化 module 对象和 exports 对象
var module = {
exports: {}
}
var exports = module.exports
// 得到文件模块的绝对路径
var __filename = moduleId
// 得到模块所在目录的绝对路径
var __dirname = getDirname(__filename)
// 使用 call 执行函数,绑定上下文为 exports
// 这就意味着在模块中,this === exports === module.exports
__require.call(exports, exports, require, module, __filename, __dirname)

// 最后返回 module.exports
return module.exports
}

整个 commonjs 的运行都依托于 require 函数的执行机制,所以研究 commonjs 的关键就在于理解 require 函数的本质

require 函数的实现

require 函数接收模块路径作为参数

根据模块路径,得到模块完整的绝对路径,将其标识为该模块的id

根据 id 判断缓存中是否存在,如果存在直接返回缓存的模块数据

模块内部代码实际是在一个辅助函数中执行的,辅助函数接收 exportsrequire函数自身、module__filename 以及 __dirname 作为参数

这也就解释了为什么模块能够隔离变量,不会造成全局污染;并且在模块中可以直接使用 exports module __dirname __filename require 等属性或方法

准备这个辅助函数所需的参数后,使用 call 执行辅助函数,并将 module 对象的 exports 属性作为上下文传入辅助函数

这就意味着在模块代码中,this === exports === module.exports,三者默认指向同一个对象

最后缓存并返回 module.exports 即可

  • 伪代码
function require(modulePath) {
// 1. 根据传递的模块路径,得到模块完整的绝对路径作为id
var moduleId = getModuleIds(modulePath)
// 2. 判断缓存
if (cache[moduleId]) {
return cache[moduleId]
}
// 3. 真正运行模块代码的辅助函数
function _require(exports, require, module, __filename, __dirname) {
// 目标模块的代码在这里
// 也就是说我们的模块代码都是在一个函数环境中执行的
// 这也就解释了为什么 commonjs 能够隔离变量,不会造成全局污染

// 我们在模块中可以直接使用 exports module __dirname __filename require 等属性或方法
// 原因就在于他们都是这个函数的参数
}
// 4. 准备并运行辅助函数
var module = {
exports: {}
}
var exports = module.exports
// 得到文件模块的绝对路径
var __filename = moduleId
// 得到模块所在目录的绝对路径
var __dirname = getDirname(__filename)
// 使用 call 执行函数,绑定上下文为 exports
// 这就意味着在模块中,this === exports === module.exports
__require.call(exports, exports, require, module, __filename, __dirname)

// 5. 缓存 module.exports
cache[moduleId] = module.exports
// 6. 返回 module.exports
return module.exports
}

问题:2.js 中 m 的值为?

  • 牢记模块代码中的 this、exports 和 module.exports 默认指向同一个对象
// 1.js
this.a = 1
exports.b = 2
exports = {
c: 3
}
module.exports = {
d: 4
}
exports.e = 5
this.f = 6

// 2.js
const m = require('./1.js')
Codethismodule.exportsexports
this.a = 1{ a: 1 }{ a: 1 }{ a: 1 }
exports.b = 2{ a: 1, b: 2 }{ a: 1, b: 2 }{ a: 1, b: 2 }
exports = { c: 3 }{ a: 1, b: 2 }{ a: 1, b: 2 }{ c: 3 }
module.exports = { d: 4 }{ a: 1, b: 2 }{ d: 4 }{ c: 3 }
exports.e = 5{ a: 1, b: 2 }{ d: 4 }{ c: 3, e: 5 }
this.f = 6{ a: 1, b: 2, f: 6 }{ d: 4 }{ c: 3, e: 5 }

require 函数最终返回的是 module.exports 对象,因此变量 m 的值为 { d: 4 }

require 与 import 区别

  • import
  1. import 是 ES6 (ECMAScript 2015) 中引入的,用于静态导入模块

  2. 它允许使用解构赋值的方式来引入模块中的特定部分。

  3. import具有提升效果,无论在文件的哪个部分使用,都会被提升到文件顶部

  4. import语句只能在模块的顶层作用域中使用,不能在条件语句中。

  5. 它与ES6模块系统配合使用,支持模块的静态分析树摇操作(tree-shaking),有助于实现按需加载

  • require
  1. require是 CommonJS 规范中引入的,用于在 Node.js 中同步导入模块

  2. 使用require时,你直接获取到一个模块的导出对象

  3. require 在运行时调用,意味着可以根据条件的不同动态地加载不同的模块

  4. 可以在代码的任何地方使用require,包括在函数或条件语句中。

  5. require 通常不支持无用代码移除按需加载,因为它是动态加载的。

  • 区别
  1. import 是静态的,支持编译时优化;require是动态的,适用于条件加载和运行时的计算路径。

  2. import需要在模块顶部,且不能在代码块中使用;require则可以在模块的任何地方使用。

  3. import语句可以让现代的JS打包工具和引擎优化导入的模块;require则不支持这些优化功能。

  4. import语句主要用于前端JavaScript模块化,而require则常用于Node.js的模块系统。

在实际开发中,如果使用的是Node.js,并且没有使用工具如Babel或Webpack对代码进行转换和打包,通常会使用require。如果开发的是现代的前端应用程序,并且项目配置了相应的JS打包工具,则推荐使用import语法来引入模块。随着Node.js对ES6模块的支持日益完善,import语句的使用变得越来越广泛。

· 阅读需 2 分钟

给一个数字添加上千位分隔符

千位分隔符就是数字中的逗号。依西方的习惯,人们在数字中加进一个符号,以免因数字位数太多而难以看出它的值。所以人们在数字中,每隔三位数加进一个逗号,也就是千位分隔符,以便更加容易认出数值。

  • 1000 ---> 1,000
  • 1000000 ---> 1,000,000
  • 100000000 ---> 100,000,000

常规思路

  1. 将数字转为字符串,将小数部分与整数部分分离
  2. 整数部分分隔为数组,并且反转
  3. 反转后的数组每隔3位添加 ,
  4. 添加完成后,再反转回来,拼接小数部分,完成格式化
function formatNumber(number) {
const [integer, decimal] = number.toString().split('.')
const integerArr = integer.split('')
integerArr.reverse()
const result = []
for (let i = 0; i < integerArr.length; i++){
if (i % 3 === 0 && i !== 0) {
result.push(',')
}
result.push(integerArr[i])
}
result.reverse()
if (decimal) {
return `${result.join('')}.${decimal}`
}
return result.join('')
}

Number.prototype.toLocaleString()

返回数字在特定语言环境下表示的字符串

Number(1000000).toLocaleString() // '1,000,000'

Intl.NumberFormat()

new Intl.NumberFormat().format(10000000) // '10,000,000'

正则前瞻运算符

const r = str.replace(/\B(?=(\d{3})+$)/g, ',')

· 阅读需 5 分钟

完成一个并发请求的函数 concurRequest,它允许同时发送多个请求,但限制最大并发请求数

当全部请求全部收到响应结果后,返回一个数组,记录响应数据,同时保证响应顺序与参数的顺序一致

返回的 Promise 没有 reject 状态,只有 resolve,如果失败了只需提供失败原因即可

具体的实现思路如下:

  1. concurRequest 函数接受两个参数 urlsmaxNum,分别是待请求的 URL 数组和最大并发请求数。如果传入的 URL 数组为空,函数会直接返回一个解决(resolved)状态的 Promise 对象。
  2. concurRequest 函数中,定义异步函数 _request,用于发起单个请求,并将结果存储在 result 数组中。
  3. 初始化一些变量:index 表示下一次要请求的 URL 下标,result 数组用于存储所有请求的结果,doneCount 表示已经完成的请求数量。
  4. _request 函数中,我们先缓存下标 i 和当前请求的 URL url,然后通过 await 关键字发送请求并将结果存储在 result 数组中对应的位置。
  5. 无论请求是成功还是失败,我们需要增加 doneCount 的值,并在 finally 代码块中进行判断。如果所有请求都已完成,则使用 resolve 方法解析 Promise,并将 result 数组作为解决值。如果还有未完成的请求,继续调用 _request 函数以发送下一个请求。
  6. 最后,在 concurRequest 函数中,我们使用一个 for 循环来调用 _request 函数,最多同时执行 maxNum 个请求。

通过维护一个请求队列和计数器的方式,实现了并发请求的控制,保证最多同时发送指定数量的请求,并在所有请求完成后返回结果。这种实现方式可以在需要同时发起多个请求但又需要控制最大并发数的场景下使用。

const getUrls = (number = 10) => {
const result = []
for (let i = 1; i <= number; i++) {
result.push(`https://jsonplaceholder.typicode.com/todos/${i}`)
}
return result
}

/**
* 并发请求
* @param {string[]} urls 待请求的 url 数组
* @param {number} maxNum 最大并发数
*/
function concurRequest(urls, maxNum) {
if (urls.length === 0) {
return Promise.resolve([])
}
return new Promise((resolve) => {
let index = 0
const result = []
let doneCount = 0
async function _request() {
const i = index // 将 index 缓存到当前作用域下
const url = urls[index]
index++
try {
const res = await fetch(url)
result[i] = res
} catch (error) {
result[i] = error
} finally {
doneCount++
if (doneCount === urls.length) {
resolve(result)
}
if (index < urls.length)
_request()
}
}
for (let i = 0; i < Math.min(maxNum, urls.length); i++){
_request()
}
})
}

concurRequest(getUrls(10), 3).then(res => {
console.log(res)
})

Promise.all 以及 Promise.allSettled 区别

  1. 最大并发数的限制:concurRequest 函数一开始就限制了最大的并行请求数量 maxNum 。而 Promise.allPromise.allSettled 并不具有这样的限制,它们会立即启动所有传入的 Promise 。
  2. 错误处理:Promise.all 对 Promise 的错误处理是有敏感性的;如果 Promise 数组中任意一个 Promise 失败(rejected),整个 Promise.all 就会立刻失败,并且失败的原因是第一个失败的 Promise 的结果。而 Promise.allSettledconcurRequest 不会因为单个 Promise 失败而导致整体失败,它们会等待所有传入的 Promise 都结束,无论其结果是解决(resolved)还是失败(rejected)。
  3. 结果的返回:concurRequest 函数返回的是一个和输入一致的数组,结果的顺序保证和输入的 Promise 的顺序一致,它们的索引一一对应。而 Promise.allPromise.allSettled 则按照 Promise 完成的顺序确定结果的顺序。

· 阅读需 4 分钟

并发任务控制题

  • 实现 SuperTask 类
// 辅助函数,指定时间后 Promise 完成
function timeout(time) {
return new Promise((resolve) => {
setTimeout(() => {
resolve()
}, time)
})
}

const superTask = new SuperTask()

function addTask(time, name) {
superTask
.add(() => timeout(time))
.then(() => {
console.log(`任务 ${name} 完成`)
})
}
  • 要求
addTask(10000, 1) // 10000ms 后输出:任务1完成
addTask(5000, 2) // 5000ms 后输出:任务2完成
addTask(3000, 3) // 8000ms 后输出:任务3完成
addTask(4000, 4) // 12000ms 后输出:任务4完成
addTask(5000, 5) // 15000ms 后输出:任务5完成
  • 分析

SuperTask 实例具有 add 方法,接收一个任务(同步/异步),返回一个 Promise,当 Promise 完成后打印完成日志

任务1和任务2都是在指定时间后输出的,但任务3是在任务2后面输出的,由此推断 SuperTask 默认最大并发数为 2

类比于银行柜台排队办事,一共有两个柜台,最多同时处理两位客户的任务。后续的客户需要排队等待,哪个柜台完成了就去补位执行

  • 实现

需要维护三个属性,分别是最大并发任务数(默认2)、正在执行的任务数以及任务队列

add 方法接受一个新任务,返回一个 Promise,在这个 Promise 中,将新任务加入到任务队列

类比于我们去银行排队办事的「叫号」行为,在每次添加任务后,就需要去触发叫号过程,尝试执行任务

将尝试执行任务的逻辑抽离为私有方法 _run,当当前执行任务数量小于最大并发数量、任务队列存在任务的情况下,循环执行任务

  • 为啥用 while

因为我们不仅要检查当前正在执行的任务数是否小于并行任务数,如果是的话就启动一个新的任务,而且我们还要在每次任务完成时再次进行这个检查。如果还有等待执行的任务并且当前正在执行的任务数仍小于并行任务数,那么我们还需要启动更多的任务。while 循环在当前 context 中直到满足该条件才会停止,这正是我们使用 while 的原因。

  • 要使用 Promise.resolve 包裹 task 返回值

使用 Promise.resolve() 可以确保不论那个 task 的返回值是否为 Promise 对象 ,我们总是在处理一个 Promise 对象。这就确保了我们可以在任务完成或失败后调用 .then.catch 方法。

执行完毕后,无论成功失败,都会(finally)再次调用 _run 方法检查是否还有任务可以执行

class SuperTask {
constructor(parallelCount = 2) {
this.parallelCount = parallelCount
this.tasks = []
this.runningCount = 0
}

add(task) {
return new Promise((resolve, reject) => {
this.tasks.push({
task,
resolve,
reject
})
this._run()
})
}

_run() {
while (
this.runningCount < this.parallelCount &&
this.tasks.length
) {
this.runningCount++
const { task, resolve, reject } = this.tasks.shift()
Promise.resolve(task())
.then(resolve, reject)
.finally(() => {
this.runningCount--
this._run()
})
}
}
}

· 阅读需 2 分钟

为什么引入箭头函数

一句话总结:消除函数二义性

函数的二义性

在支持面向对象的编程语言中,函数往往有两个层面的含义

  1. 指令序列
  2. 创建实例

通常情况下,编程语言应该在语法层面针对函数的两种含义进行区分

但在 js 设计之初并没有进行区分,这也是 js 的其中一个设计缺陷,导致了JS 中的函数具有二义性

虽然社区提出了「构造函数首字母大写」等解决方案,但治标不治本

拿到一个函数却不知道如何使用,这对于开发造成了一定的心智负担

消除函数二义性

ES6 通过引入箭头函数(指令序列)和 class(创建实例)来解决这个问题

class 不仅方便开发人员进行面向对象编程,更重要的是消除了函数的二义性

class 声明的方法,直接调用会报错;同样的,使用 new 关键字来调用箭头函数,同样会报错

这也就回答了为什么箭头函数没有 this / prototype

  • 因为 this 是面向对象中的概念,prototype 是实现面向对象的手段。而箭头函数代表的是指令序列与面向对象无关,不需要创建实例

· 阅读需 4 分钟

提前拿到了原数组的 len,使用 while(k < len) 循环遍历,下标 k 不存在(in)的话就不会做处理,继续遍历

现象

在日常开发使用 forEach 遍历对象的过程中,会出现一些奇怪的现象:

  • 比如一边遍历,一边 push 新元素,结果并没有陷入死循环,可以正常输出结果
const arr = [1, 2, 3]

arr.forEach((item, index) => {
arr.push(6)
console.log('item', item) // item 1 item 2 item 3
})

console.log('arr', arr) // arr [1, 2, 3, 6, 6, 6]
  • 比如一边遍历,一边 splice 元素,结果只循环了两次,结果还只剩一项
const arr = [1, 2, 3]

arr.forEach((item, index) => {
arr.splice(index, 1)
console.log('item', item) // item 1 item 3
})

console.log('arr', arr) // arr [2]
  • 再比如 arr 是一个稀疏数组 [, , 3],则只会遍历一次
const arr = [, , 3]

arr.forEach((item) => {
console.log('item', item) // item 3
})

console.log('arr', arr) // arr [ <2 empty items>, 3 ]

源码

要想准确解释以上的现象,需要查阅 ECMA 关于 forEach 的介绍

This method performs the following steps when called:

  1. Let O be ? ToObject(this value).

  2. Let len be ? LengthOfArrayLike(O).

  3. If IsCallable(callbackfn) is false, throw a TypeError exception.

  4. Let k be 0.

  5. Repeat, while k < len,

    ​ a. Let Pk be ! ToString(𝔽(k)).

    ​ b. Let kPresent be ? HasProperty(O, Pk).

    ​ c. If kPresent is true, then

    ​ i. Let kValue be ? Get(O, Pk).

    ​ ii. Perform ? Call(callbackfn, thisArg, « kValue, 𝔽(k), O »).

    ​ d. d. Set k to k + 1.

  6. Return undefined.

根据上面行文逻辑,试着还原 Array.prototype.forEach 的源码

Array.prototype.myForEach = function (callback) {
// 处理 this 为当前数组
let o = this
// 拿到 this 的长度
let len = o.length
if (typeof callback !== 'function') {
throw new TypeError(callback + 'is not a function')
}
// 当前元素下标
let k = 0
// while 循环
while (k < len) {
const pk = String(k)
// 如果下标存在于当前数组,则执行 callback
if (pk in o) {
const kValue = o[pk]
callback.call(o, kValue, k, o)
}
k++
}
}

解释

现在我们来分别解释上面的三个现象

  1. forEach 的同时 push 新元素,没有陷入死循环

    • 因为遍历的次数在一开始就确定为了数组的初始长度
  2. forEach 的同时 splice 元素,遍历次数与结果与预期不符

    • splice 删除元素,相当于一直在改变 this 的值,而循环的次数 len 与下标 k 是一定的,这就导致了最终遍历次数与预期不符
  3. 稀疏数组,会跳过空元素的遍历

    • 稀疏数组的稀疏项既不是 undefined 也不是 null,我们通过 Object.keys(arr) 也读取不到稀疏项对应的 index

    • 而在循环逻辑中只有 pk in otrue 的情况下才会执行 callback,所以遇到稀疏项会自动跳过

· 阅读需 2 分钟

data-* 属性允许我们在标准、语义化的 HTML 元素中存储额外的信息,而不需要使用类似于非标准属性或者 DOM 额外属性之类的技巧。

所有在元素上以data-开头的属性为数据属性。比如说你有一篇文章,而你又想要存储一些不需要显示在浏览器上的额外信息。

你可以使用getAttribute()配合它们完整的 HTML 名称去读取它们,但标准定义了一个更简单的方法:DOMStringMap你可以使用dataset读取到数据。

为了使用dataset对象去获取到数据属性,需要获取属性名中data-之后的部分 (要注意的是破折号连接的名称需要改写为骆驼拼写法 (如"index-number"转换为"indexNumber"))。

var article = document.querySelector("#electriccars");

article.dataset.columns; // "3"
article.dataset.indexNumber; // "12314"
article.dataset.parent; // "cars"

比如你可以通过generated content使用函数attr()来显示 data-parent 的内容: article::before { content: attr(data-parent); }

你也同样可以在 CSS 中使用属性选择器根据 data 来改变样式: article[data-columns="3"] { width: 400px; } article[data-columns="4"] { width: 600px; }

· 阅读需 2 分钟

被拖拽元素

默认情况下,图片、链接和文本是可拖动的。HTML5 在所有 HTML 元素上规定了一个 draggable 属性, 表示元素是否可以拖动。图片和链接的 draggable 属性自动被设置为 true,而其他所有元素此属性的默认值为 false。

某个元素被拖动时,会依次触发以下事件:

  • ondragstart:拖动开始,当鼠标按下并且开始移动鼠标时,触发此事件;整个周期只触发一次;
  • ondrag:只要元素仍被拖拽,就会持续触发此事件;
  • ondragend:拖拽结束,当鼠标松开后,会触发此事件;整个周期只触发一次。

可释放目标

当把拖拽元素移动到一个有效的放置目标时,目标对象会触发以下事件:

  • ondragenter:只要一把拖拽元素移动到目标时,就会触发此事件;
  • ondragover:拖拽元素在目标中拖动时,会持续触发此事件;
  • ondragleaveondrop:拖拽元素离开目标时(没有在目标上放下),会触发ondragleave;当拖拽元素在目标放下(松开鼠标),则触发ondrop事件。

目标元素默认是不能够被拖放的,即不会触发 ondrop 事件,可以通过在目标元素的 ondragover 事件中取消默认事件来解决此问题。

· 阅读需 9 分钟

源 origin = 协议 scheme + 主机名/域名 host + 端口 port

BroadcastChannel:用于同源不同浏览上下文之间进行一对多的定向消息广播。new 一个 bc 实例,设置频道,通过 bc.postMessage 广播消息,其他标签通过监听同频道实例的 message 事件来获取

LocalStorage:通过监听 storage 事件来实现,其他页签改动了 ls 会触发

postMessage:用于不同浏览上下文之间进行一对一的定向消息传递,可以是同源或跨域

MessageChannel:MessageChannel API 可以看作是 postMessage API 的一种更灵活的实现方式,它允许创建独立的双向通信通道,而不需要依赖于 window 或 worker 对象。new 一个 mc 实例,实例具有两个 port,可以通过 port.postMessage 一对一发消息,通过 port.onmessage 监听其他 port 发送的消息。React 调度器就是使用这个 API 使内部的更新与渲染任务参与到浏览器的事件循环的

Shared Worker:传入worker 脚本的 url 以及 name 标识,相同的标识会共享同一个 SharedWorker 后台线程。通过 onconect 事件参数 e.ports[0] 获取页面连接的 port,与 MessageChannel 类似,通过 sw.port.postMessage 广播消息,页面通过 sw.port.onmessage 事件监听消息

1 BroadcastChannel

Broadcast Channel 是一个较新的 Web API,用于在不同的浏览器窗口、标签页或框架之间实现跨窗口通信。它基于发布-订阅模式,允许一个窗口发送消息,并由其他窗口接收。

前提:同源,频道名字一致

  • tab1 使用实例的 postMessage 发送消息
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document1</title>
</head>
<body>
<button>点我发送消息</button>
</body>
<script>
const channel1 = new BroadcastChannel('111')
const btn = document.querySelector('button')
btn.onclick = () => {
channel1.postMessage({data: 'message from tab1'})
console.log('发送成功')
}
</script>
</html>
  • tab2 监听实例的 message 事件,接收消息
const c1 = new BroadcastChannel('111')
c1.addEventListener('message', (e) => {
console.log(e.data)
})
  • 注意在合适的时间调用 channel.close() 断开频道连接

2 LocalStorage

前提:同源

  • tab1 修改 LocalStorage
const btn = document.querySelector('button')
btn.onclick = () => {
localStorage.setItem('text', 'message from tab1')
console.log('修改成功')
}
  • tab2 监听 storage 事件,即监听 LocalStorage 变动
window.addEventListener('storage', (event) => {
let { key, oldValue, newValue } = event
console.log('receive message from ls', key, oldValue, newValue)
});

相似的还有 SessionStorage 与 IndexDB

3 postMessage

前提:主窗口和弹出的新窗口之间(同源)

也可以用于不同页签

  • tab1 打开新标签页后,通过调用新标签页实例的 postMessage 方法向新标签页发送消息
const btn1 = document.querySelector('#open')
const btn2 = document.querySelector('#message')
let newTab = null
btn1.onclick = () => {
// window.open(url, target上下文名称, windowFeatures窗口特性列表)
newTab = window.open('2.html', "123", "height=600,width=600,top=20")
}
btn2.onclick = () => {
const data = { value: 'message from tab1 ' }
newTab?.postMessage(data, "*")
}
  • tab2 监听 message 事件即可
window.addEventListener('message', (e) => {
// 注意屏蔽插件或其他程序的 message 信息
// 屏蔽 react-devtools-content-script 的消息
if(e?.data?.source === 'react-devtools-content-script') {
return
}
console.log(e.data)
})

4 Shared Worker

SharedWorker API 是 HTML5 中提供的一种多线程解决方案,它可以在多个浏览器 TAB 页面之间共享一个后台线程,从而实现跨页面通信。

初始化 shared worker

实例化 Worker ,传入worker 脚本的 url 以及 name 标识,相同的标识会共享同一个 SharedWorker

// html
const sw = new SharedWorker('./sharedWorker.js', 'testWorker')

Shared Worker 实例存在一个 port 属性,相当于当前连接的 tab 页面,用于与共享线程通信

页面监听消息

页面中使用 port.onmessage 监听共享线程传递过来的消息

// html
sw.port.onmessage = (e) => alert(e.data)

页面发送消息

页面中使用 port.postMessage() 向共享线程发送消息

// html
sw.port.postMessage({ tag: 'close' })

worker 处理与分发消息

使用 onconnectself.onconnect 监听页面的连接,通过事件参数 e.port[0] 获取与连接事件关联的第一个 MessagePort 对象,即当前连接的页面

使用 port.onmessage 接收并处理页面传递过来的消息;使用 port.postMessage 向页面发消息

// sharedWorker.js
onconnect = e => {
const port = e.ports[0]
// 可以将 port 缓存起来,用于后续通信
!portsPool.includes(port) && portsPool.push(port)
port.onmessage = (e) => {
console.log('message from tab', e.data)
}
port.postMessage(xxx)
}

调试 shared worker

因为 SharedWorker 的作用域中没有 window 对象,所以 consolealert 等方法都是无法使用的

如果我们需要调试 SharedWorker,可以在浏览器地址栏中输入 chrome://inspect/#workers,这样就可以看到当前页面中的SharedWorker

关闭 shared worker

页面断开链接,通知 worker 关闭;页面关闭时,中断连接

// html
sw.port.postMessage({ tag: 'close' });
sw.port.close();
// 或者
window.onbeforeunload = () => {
sw.port.postMessage({ tag: 'close' });
sw.port.close();
};

worker 删除内部缓存即可

// sharedWorker.js
const index = portsPool.findIndex(item => item === port);
if (~index) {
portsPool.splice(index, 1);
}

当所有创建 SharedWorker 的页面关闭之后,那么 SharedWorker 的生命就走到了尽头,否则它就会一直常驻。

实战:广播与指定页面发送消息

由于 port 没有标识,各标签页之间也无法直接实现精准通信

所以只能通过广播的方式,各标签页通过的 message 的某个字段来区分是否是发给自己的消息

<!-- 1.html -->
<body>
<div>广播消息</div>
<hr />
<div id="broadcast_info"></div>
<button id="broadcast_btn">广播消息</button><br />
<button id="send">向tab2发送消息</button>
<script>
const broadcastBtn = document.querySelector('#broadcast_btn')
const sendBtn = document.querySelector('#send')
const broadcastInfo = document.querySelector('#broadcast_info')
// 初始化
const sw = new SharedWorker('./sharedWorker.js', 'test worker')

sw.port.onmessage = (e) => {
if (e.data.tag === 'broadcast') {
broadcastInfo.innerHTML = e.data.info
}
}

broadcastBtn.addEventListener('click', e => {
sw.port.postMessage({ tag: 'broadcast', info: '来自 tab1 的广播' })
})
sendBtn.addEventListener('click', e => {
sw.port.postMessage({ tag: 'tab2', info: 'tab1 发送给 tab2 的消息' })
})

window.onbeforeunload = () => {
// 取消该port在共享线程中的存储[广播用的]
sw.port.postMessage({ tag: 'close' });
// 关闭与共享线程的连接
sw.port.close();
};
</script>
</body>
<!-- 2.html -->
<body>
<div>广播消息</div>
<hr />
<div id="broadcast_info"></div>
<div>来自其他 tab 的消息</div>
<div id="message"></div>
<button id="broadcast_btn">广播消息</button><br />
<script>
const broadcastBtn = document.querySelector('#broadcast_btn')
const broadcastInfo = document.querySelector('#broadcast_info')
const messageInfo = document.querySelector('#message')
// 初始化
const sw = new SharedWorker('./sharedWorker.js', 'test worker')

sw.port.onmessage = (e) => {
if (e.data.tag === 'broadcast') {
broadcastInfo.innerHTML = e.data.info
}
if(e.data.tag === 'tab2') {
messageInfo.innerHTML = e.data.info
}
}

broadcastBtn.addEventListener('click', e => {
sw.port.postMessage({ tag: 'broadcast', info: '来自 tab2 的广播' })
})

window.onbeforeunload = () => {
// 取消该port在共享线程中的存储[广播用的]
sw.port.postMessage({ tag: 'close' });
// 关闭与共享线程的连接
sw.port.close();
};
</script>
</body>
// sharedWorker.js
const portsPool = []

// { tag: 'tab1' | 'tab2' | 'broadcast' | 'close', info: 'xxx' }
onconnect = e => {
const port = e.ports[0]
!portsPool.includes(port) && portsPool.push(port)
port.onmessage = (e) => {
if (e.data.tag === 'close') {
const index = portsPool.findIndex(item => item === port);
if (~index) {
portsPool.splice(index, 1);
}
} else {
portsPool.forEach(port => {
port.postMessage(e.data)
})
}
}
}

5 MessageChannel

const channel = new MessageChannel();
const output = document.querySelector(".output");
const iframe = document.querySelector("iframe");

iframe.addEventListener("load", onLoad);

function onLoad() {
// 监听 port1 的 message 事件
channel.port1.onmessage = onMessage;

// 通过 postMessage 向 port2 发送消息
iframe.contentWindow.postMessage("Hello from the main page!", "*", [
channel.port2,
]);
}

// Handle messages received on port1
function onMessage(e) {
output.innerHTML = e.data;
}

6 非同源

借助服务,例如 WebSocket