跳到主要内容

· 阅读需 22 分钟

Zustand: https://github.com/pmndrs/zustand

Why Zustand

Zustand 是一个小型、快速且可扩展性强的,基于 hooks 的状态管理库

简单:原理、源码、思想、用法

Zustand 团队同时也开发了 JotaiValtio 状态管理库

  • Zustand 基于发布订阅模式
  • Valtio 基于 Proxy(类似 Vue3)
  • Jotai 基于原子化状态的概念

image-20240422144652948

核心原理

Zustand = 发布订阅 + useSyncExternalStore

基于发布订阅模式实现了基本数据维护与通知更新,与框架的集成则基于 React 官方提供的 useSyncExternalStore 来实现,订阅外部store,实现状态更新后,触发组件重新渲染

出于兼容性考虑以及 selector 实现,Zustand 并未直接使用 react 库提供的 useSyncExternalStore(),而是使用了 use-sync-external-store 库提供的 useSyncExternalStoreWithSelector()

基本使用

// 定义
import { create } from 'zustand'

interface CountStore {
count: number
add: () => void
}

export const useCountStore = create<Store>((set, get) => ({
count: 0,
add: () => set((state) => ({ count: state.count + 1 })), // ✅
// add: () => set({ count: get().count + 1}), // ❌
// 出于可维护性和并发安全性考虑,应该使用上面的方式获取最新的 count
// 但没有测试出来异步并发情况下的 bug,不过官方推荐了第一种方式
}))

// 使用
import { useCountStore } from 'xxx'
const { count, add } = useCountStore()

中间件

export const useCountStore = create<Store>(
redux(persist(
(set, get) => ({
count: 0,
add: () => set((state) => state.count + 1),
// add: () => set({ count: get().count + 1}),
})
)
)
)

注意事项

当我们需要在两个组件使用同一个仓库的不同状态时:

// App.tsx
function App() {
return (
<>
<Child1 />
<hr/>
<Child2 />
</>
)
}

// Child1.tsx
export const Child1 = () => {
const { count, addCount } = useConfigStore()

console.log('Child1 render')

return (
<>
<div>{count}</div>
<button onClick={addCount}>+1</button>
</>
)
}

// Child2.tsx
export const Child2 = () => {
const { name, setName } = useConfigStore()

console.log('Child2 render...')

return (
<>
<div>{name}</div>
<button onClick={() => setName(name === 'bar' ? 'foo' : 'bar')}>切换</button>
</>
)
}

// Store
interface ConfigStore {
count: number
name: string
addCount: () => void
setName: (name: string) => void
}

export const useConfigStore = create<ConfigStore>((set) => ({
count: 0,
name: 'foo',
addCount: () => set((store) => ({ count: store.count + 1 })),
setName: (name) => set({ name }),
}))

页面初始渲染时,此时两个子组件分别被渲染一次

image-20240428095155293

当我们点击「+1」按钮时,改变了 count 状态,因此 Child1 组件重新渲染了。但是我们发现此时 Child2 也跟着重新渲染了:

image-20240428095500086

Child2 作为 Child1 的兄弟组件,二者也并没有交叉的状态值,可以说两个组件是完全独立的。

解决方案

当我们调用 addCount 或者 setName 函数时,它们会通过 set 函数更新 Zustand 状态,并通知所有订阅了这个 store 的组件去重新渲染。

由于 Child1Child2 组件都从同一个 useConfigStore 钩子中获取状态,它们实际上都订阅了整个状态对象,包括 countname。这意味着无论哪一个状态更新,两个组件都会重新渲染,即使它们各自只用到了其中的一个状态。

这是因为 Zustand 的状态更新机制是基于浅比较的,更新状态时会替换整个状态对象,导致所有使用状态的组件认为状态已经变化,即使它们依赖的那部分状态实际上并没有变。

为了解决这个问题,我们可以利用 Zustand 提供的选择器功能。选择器允许组件只订阅状态对象中的一部分,从而当全局状态对象发生变化时,只有当组件所依赖的那部分状态实际发生变化时,组件才会重新渲染。

通过使用选择器,我们实际上创造了状态的快照,并使组件只关注快照中的那部分。当状态更新时,只有快照发生变化的组件会被通知更新。

为了防止不必要的渲染,你需要确保 Child1 组件仅订阅 count 状态,Child 组件仅订阅 name 状态。当各自相关的状态改变时,对应的组件才会重新渲染,而不会影响到另外一个。这种方式不仅可以提高性能,还可以避免不必要的渲染带来的问题,如界面闪烁或是计算量的不必要增加

// Child1.tsx
- const { count, addCount } = useConfigStore()
+ const count = useConfigStore((state) => state.count)
+ const addCount = useConfigStore((state) => state.addCount)

但这样在状态非常复杂的时候会造成代码冗余,下面提出解决方案

优化1

创建自定义的 Hooks 来获取特定的状态和状态更新函数,封装选择器的使用

export const useCount = () => {
const count = useConfigStore((state) => state.count)
const addCount = useConfigStore((state) => state.addCount)
return { count, addCount }
}

优化2

状态仓库拆分,每个 Store 管理的状态的功能尽可能单一

源码阅读

首先从 index 入手

// src/index.ts
// 将 vanilla.ts 和 react.ts 中定义的所有内容(除默认导出外)在当前文件中重新导出,方便其他模块统一导入。
export * from './vanilla.ts'
export * from './react.ts'
// 直接导出了 react.ts 的默认导出项作为这个模块的默认导出项
export { default } from './react.ts'

react.ts 中依赖了 vanilla.ts,所以我们先看下 vanilla.ts 文件

Vanilla

createStore

该文件导出了 createStore 高阶函数,返回值取决于是否传递了 createState 参数。该函数用于灵活地创建 store 实例,返回 store api

export const createStore = ((createState) =>
createState ? createStoreImpl(createState) : createStoreImpl) as CreateStore

在其类型声明中,涉及到了函数重载

type CreateStore = {
<T, Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>,
): Mutate<StoreApi<T>, Mos>

<T>(): <Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>,
) => Mutate<StoreApi<T>, Mos>
}

这两部分代码可能看起来有些复杂。这是因为 Zustand 支持使用 mutator 来修改 state,而 mutator 本身可以被定义为一个包含多个元素的数组,每个元素都是一个 [StoreMutatorIdentifier, unknown] 类型的数组。

为了方便理解,我们将涉及到中间件的类型暂时移除

type CreateStore = {
<T>(initializer: StateCreator<T>): StoreApi<T>
<T>(): (initializer: StateCreator<T>) => StoreApi<T>
}
  • 第一种形式表示 createStore 可以是一个函数,接收 initializer 作为参数,返回 StoreApi;

  • 第二种形式表示 createStore 是一个柯里化函数,接收一个无参的函数,返回另外一个接收 initializer 作为参数、返回 StoreApi 的函数

使用实例

const initializer = (set, get, api) => ({})
// 直接创建 store
const store = create(initializer)
// 先得到创建 store 的方法
const createStore = create()
const store = createStore(initializer)

StateCreator

我们继续看下 initializer 也就是 StateCreator 的类型定义

export type StateCreator<
T,
Mis extends [StoreMutatorIdentifier, unknown][] = [],
Mos extends [StoreMutatorIdentifier, unknown][] = [],
U = T,
> = ((
setState: Get<Mutate<StoreApi<T>, Mis>, 'setState', never>,
getState: Get<Mutate<StoreApi<T>, Mis>, 'getState', never>,
store: Mutate<StoreApi<T>, Mis>,
) => U) & { $$storeMutators?: Mos }

同样暂时将中间件相关的类型定义移除

export type StateCreator<T> = (
setState: Get<StoreApi<T>, 'setState', never>,
getState: Get<StoreApi<T>, 'getState', never>,
store: StoreApi<T>,
) => T

StateCreator 是一个函数,用于初始化 store 的数据,接收 set、get 以及 store 实例作为参数,返回 state

StateCreator 是一个用户自定义的函数,用于初始化 store 的状态。它接受三个参数:setStategetStateapi

  • setState 是一个函数,用于更新 store 的状态。
  • getState 是一个函数,用于获取当前的 store 状态。
  • api 是包含 store 所有公开方法的对象。

其中 Get 类型表示 K 如果在 T 对象的 keys 中,则返回 K 属性对应的值的类型,否则返回 F

type Get<T, K, F> = K extends keyof T ? T[K] : F

使用示例:

const store = create((set, get, api) => ({}))

StoreApi

回到 createStore 的类型定义,继续看返回值 StoreApi 类型定义

export interface StoreApi<T> {
setState: SetStateInternal<T>
getState: () => T
getInitialState: () => T
subscribe: (listener: (state: T, prevState: T) => void) => () => void
/**
* @deprecated Use `unsubscribe` returned by `subscribe`
*/
destroy: () => void
}

返回了五个 api(destory 即将废弃),其中 SetStateInternal 的定义如下

type SetStateInternal<T> = {
_(
partial: T | Partial<T> | { _(state: T): T | Partial<T> }['_'],
replace?: boolean | undefined,
): void
}['_']

为什么额外使用了一个对象字面量 _ 和查找类型的,看似多此一举的操作?

我们先来对比下二者的区别

type SetStateInternal<T> = {
_(
partial: T | Partial<T> | { _(state: T): T | Partial<T> }['_'],
replace?: boolean | undefined,
): void
}['_']

type SetStateInternal2<T> = (
partial: T | Partial<T> | ((state: T) => T | Partial<T>),
replace?: boolean | undefined
) => void

type State = { name: string; age: number }

// 此时 setState 的类型定义为 const setState: (partial: ..., replace?: ...) => void
declare const setState: SetStateInternal<State>
// 而 setState2 的类型定义为 const setState2: SetStateInternal2<State>
declare const setState2: SetStateInternal2<State>

SetStateInternal 与 SetStateInternal2 的类型是一致的,但是当作为变量的类型时,该变量的编辑器提示出现了如上的差异,即使用了这个技巧的变量,其提示更具体;

type T1 = {
name: string,
age: number
}

type T0 = {
_:{name: string, age: number}
}['_']

const t1: T1 = { name: "", age: 0 }
const t0: T0 = { name: "", age: 0 }
  • 所以我猜想,声明并读取 _ 属性的原因之一是为了使函数类型获得更清晰的类型支持,帮助开发者更容易理解和使用 API

createStoreImpl

vanilla 中的类型定义就先告一段落,下面我们学习创建 store 实例的核心逻辑实现: createStoreImpl,详见注释

const createStoreImpl: CreateStoreImpl = (createState) => {
type TState = ReturnType<typeof createState>
type Listener = (state: TState, prevState: TState) => void
// 初始状态,类型为 createState 方法的返回值类型
let state: TState
// 使用集合存储订阅者
const listeners: Set<Listener> = new Set()

// partial: 接收一个新的状态对象 | 部分状态对象 | 接收当前状态并返回更新后状态或部分对象的函数
// replace:决定是状态直接替换还是合并
const setState: StoreApi<TState>['setState'] = (partial, replace) => {
// 根据参数类型,计算 nextState
const nextState =
typeof partial === 'function'
? (partial as (state: TState) => TState)(state)
: partial
// 使用 Object.is 浅对比前后 state
if (!Object.is(nextState, state)) {
const previousState = state
// 如果替换,直接赋值 state 为 nextState
// 如果不替换,且 nextState 是对象,则合并前后状态
state =
replace ?? (typeof nextState !== 'object' || nextState === null)
? (nextState as TState)
: Object.assign({}, state, nextState)
// 通知状态更新
listeners.forEach((listener) => listener(state, previousState))
}
}

const getState: StoreApi<TState>['getState'] = () => state

const getInitialState: StoreApi<TState>['getInitialState'] = () =>
initialState

// 新增订阅者,返回取消订阅的方法
const subscribe: StoreApi<TState>['subscribe'] = (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
}

const destroy: StoreApi<TState>['destroy'] = () => {
// 废弃警告
listeners.clear()
}

const api = { setState, getState, getInitialState, subscribe, destroy }
// 赋值 initialState 与 state
const initialState = (state = createState(setState, getState, api))
return api as any
}

React

同样,我们先从 create 函数入手。create 函数与 vanilla 中逻辑一致,根据传入参数,返回 createImpl 函数或者调用该函数。

createImpl

createImpl 函数是 create 函数的具体实现。它内部检查 createState 的类型,确保其为函数,并最终调用 createStore(来自 ./vanilla.ts 文件)来创建一个 store。createImpl 返回一个 useBoundStore 方法,它是由 createStore 和 useStore 的返回值组装的,分别对应着 api 和 useBoundStore,api 这个对象存放着一系列的方法比如 setState、getState 等,useBoundStore 如果收到 selector 传参,则返回对应的 store 数据,如果没有就返回整个 store

const createImpl = <T>(createState: StateCreator<T, [], []>) => {
// 开发环境警告
if (
import.meta.env?.MODE !== 'production' &&
typeof createState !== 'function'
) {
console.warn(
"[DEPRECATED] Passing a vanilla store will be unsupported in a future version. Instead use `import { useStore } from 'zustand'`.",
)
}
// 调用 vanilla 的 createStore 函数,获取该函数返回的 api 对象
const api =
typeof createState === 'function' ? createStore(createState) : createState

const useBoundStore: any = (selector?: any, equalityFn?: any) =>
useStore(api, selector, equalityFn)


Object.assign(useBoundStore, api)

// 返回 useBoundStore 函数
return useBoundStore
}

下面对于其中的 useBoundStoreuseStore 进行分析

useStore

useStore 内部利用 use-sync-external-store/shim/with-selector 包中的 useSyncExternalStoreWithSelector 实现对状态的订阅和更新。这保证了状态变更能够即时反映到组件上

import useSyncExternalStoreExports from 'use-sync-external-store/shim/with-selector'
const { useSyncExternalStoreWithSelector } = useSyncExternalStoreExports;

const identity = arg => arg;

export function useStore<TState, StateSlice>(
api: WithReact<StoreApi<TState>>,
selector: (state: TState) => StateSlice = identity as any,
equalityFn?: (a: StateSlice, b: StateSlice) => boolean,
) {
// 开发环境警告
if (
import.meta.env?.MODE !== 'production' &&
equalityFn &&
!didWarnAboutEqualityFn
) {
console.warn(
"[DEPRECATED] Use `createWithEqualityFn` instead of `create` or use `useStoreWithEqualityFn` instead of `useStore`. They can be imported from 'zustand/traditional'. https://github.com/pmndrs/zustand/discussions/1937",
)
didWarnAboutEqualityFn = true
}

const slice = useSyncExternalStoreWithSelector(
api.subscribe,
api.getState,
api.getServerState || api.getInitialState,
selector,
equalityFn,
)
// 使用 `useDebugValue` 为 React DevTools 显示调试值。
useDebugValue(slice)
return slice
}

其中useSyncExternalStoreWithSelector就是对 useSyncExternalStore的一个包装,是在useSyncExternalStore的基础上添加了两个参数:

  • selector:一个函数,用于获取 state 中的部分数据,有了这个参数 useSyncExternalStoreWithSelector的返回值就可以根据selector的结果来返回而不是每次都返回整个 store,相对灵活方便

同时也解决了上面遇到的组件重新渲染的问题

  • equalityFn:数据比较方法,如果不希望使用 Object.is做数据对比,可以提供自己的对比函数

useBoundStore

useBoundStore是一个函数,基于上面对于 useStore 的分析,它调用之后会根据传入的 selector返回对应的 store 数据,如果没有传入selector,则会默认返回整个 store

这也是我们在使用 useXXXStore hook 时支持传入选择器的原理

const count = useCountStore(state => state.count)

createImpl 函数在最后将useBoundStore函数和api进行合并,目的就是方便用户在 React 组件之外修改和获取数据

useXXXStore.setState(state => ({ count: state.count + 1 }))
useXXXStore.getState()

useSyncExternalStore

最后重点讨论下 zustand 与 React 框架集成的核心 hook

useSyncExternalStore 是 React 18 引入的一个新 Hook,允许开发者在 React 中同步外部的数据源

背景

在React中,我们所说的状态通常分为三种:

  • 组件内部的State/Props
  • 上下文Context
  • 组件外部的独立状态Store(Redux/Zustand)

前两种状态实际上都是React内部维护的Api,自然也会跟随着React版本的迭代而进行相对应的优化。 但是组件外部的状态,对于React来说并不可控,如果需要更好的契合React本身,我们需要去写一些与本身业务逻辑无关的胶水代码。例如订阅外部状态、外部状态更新时,对组件进行重渲染。

但是在 React18 推出 Concurrent Mode 后,这种外部状态的订阅模式会存在一个问题,也就是被称为撕裂问题的 Bug。

我们来思考这样一种场景: 假设我们的现在的页面触发了更新,需要进行 re-render,而根据 Concurrent Mode 的规则,我们会把更新过程中需要执行的任务划分优先级,优先级低的有可能会被打断。假设某个任务 A 和 B,都同时依赖了外部状态中的某个 State,在 re-render 开始时,值为1,任务 A 执行完之后,React 把线程的处理权交还给了浏览器,浏览器的某些操作导致了这个 State 的值变成了 2。那么等到 B 任务重新恢复执行时,读到的值就会出现差异,导致渲染结果的不一致性。

针对上述的一些外部状态与React本身不契合的情况,React提供了一个名为useSyncExternalStore的Hook,这个hook可以让我们更加方便的去订阅外部的Store,并且避免发生撕裂问题。

使用方法

useSyncExternalStore(subscribe, getSnapshot)

参数

  • subscribe:一个函数,接收一个单独的 callback 参数并把它订阅到 store 上。当 store 发生改变,它应当调用被提供的 callback。这会导致组件重新渲染。subscribe 函数会返回清除订阅的函数。

    Store 对象中维护的 listener 包含了触发 React 重新渲染的函数以及我们自己定义的监听数据变化后做的副作用函数

  • getSnapshot:一个函数,返回组件需要的 store 中的数据快照。在 store 不变的情况下,重复调用 getSnapshot 必须返回同一个值。如果 store 改变,并且返回值也不同了(用 Object.is 比较),React 就会重新渲染组件。

返回值:该 store 的当前快照,可以在你的渲染逻辑中使用

使用示例

使用该 hook,模拟 zustand 与 react 的简单集成过程

声明 store

const createStore = () => {
let currentListeners: any[] = []
let currentState = { count: 0 }

return {
subscribe(listener: any) {
currentListeners.push(listener)
return () => {
currentListeners = currentListeners.filter(l => l !== listener)
}
},
getState() {
return currentState
},
setState(newState: any) {
currentState = newState;
currentListeners.forEach(listener => listener())
},
}
}

export const store = createStore()

封装 useStore hook

import { useSyncExternalStore } from "react"
import { store } from "./store"

export const useStore = () => {
const getSnapshot = () => store.getState()

// useSyncExternalStore 会通过 onStoreChange 参数
// 将组件重新渲染的逻辑注入 subscribe 中
const subscribe = (onStoreChange: any) => {
return store.subscribe(onStoreChange)
}

// 将 store 的 suscribe 与 getState 包装一下传入 hook 中
const state = useSyncExternalStore(subscribe, getSnapshot)
return state;
}

组件使用

import { store } from "./store"
import { useStore } from "./useStore"

export const Counter = () => {
const { count } = useStore()

return (
<div>
<span>Count: {count}</span>
<button onClick={() => store.setState({ count: count + 1 })}>Increment</button>
</div>
)
}

TODO:中间件

· 阅读需 8 分钟

封装一个 Message 组件,用于全局展示操作反馈信息。可提供成功、警告和错误等反馈信息。顶部居中显示并自动消失,是一种不打断用户操作的轻量级提示方式。

组件基本实现

支持传入类型与内容

export interface MessageProps {
type?: 'success' | 'error' | 'warning' | 'info'
content: string
}

Message 支持多条展示,所以还需准备 MessageList 组件作为 Message 组件的容器

export const MessageList: React.FC<{ messages: MessageProps[] }> = ({ messages }) => {
return (
<div className="message-list">
{messages.map((message) => (
<Message key={message.id} {...message} />
))}
</div>
)
}

其中 Message 组件类名使用模板字符串,根据 type 进行类名拼接即可

import React from 'react'
import './message.css'

export const Message: React.FC<MessageProps> = ({
type='success',
content,
}) => {
return (
<p className={`message message-${type}`}>
{content}
</p>
)
}

部分样式如下

.message-list {
position: fixed;
z-index: 1000;
left: 50%;
transform: translateX(-50%);
top: 16px;
}

.message {
padding: 12px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.message-success {
background-color: #d4edda;
border-color: #c3e6cb;
color: #155724;
}

组件渲染在哪?

Message 组件在实际使用中,通常不会直接显式声明在 JSX 中,而是通过一些 api 函数来渲染组件。例如 message.success('Success')message.error('Error'),随用随调

这样就需要 root.render 方法来动态渲染组件,准备一个额外的用于挂载 MessageList 组件的容器

const CONTAINER_ID = "message-container";

export function createContainer() {
let container = document.getElementById(CONTAINER_ID)
if (!container) {
container = document.createElement("div")
container.setAttribute("id", CONTAINER_ID)
document.body.appendChild(container)
}

return container
}

然后使用 React.createRoot(container) 创建 container 元素的 root 节点,然后调用 root.render 方法动态地将 MessageList 组件挂载到页面上

import { createRoot } from "react-dom/client";

function renderMessage(messages) {
// 获取容器
const container = createContainer()
// 创建 root 节点
const containerRoot = createRoot(container)
// root.render 渲染 Message 组件
containerRoot.render(<MessageList messages={messages} />)
}

这样一来,当我们传入 message 数据调用 renderMessage 方法后,就初步实现了 MessageList 组件的渲染

组件如何支持 api 调用?

使用面向对象的方式来组织代码,渲染 api 则由 Message 的实例方法来实现

同时我们将 createContainer 方法整合到 Message 的构造器中

renderMessage 方法作为 Message 的私有方法 render

维护 messages 数据与 containerRoot 节点

最后使用单例模式确保全局唯一实例

class Message {
static instance: Message
#containerRoot: Root
#messages: Array<InternalMessageProps> = []

constructor() {
let container = document.getElementById(CONTAINER_ID)
if (!container) {
container = document.createElement("div")
container.setAttribute("id", CONTAINER_ID)
document.body.appendChild(container)
this.#containerRoot = createRoot(container)
} else {
this.#containerRoot = createRoot(container)
}
}

#render() {
this.#containerRoot.render(<MessageList messages={this.#messages} />)
}

success(content: string) {}

error(content: string) {}

info(content: string) {}

warning(content: string) {}

static getInstance() {
if (!Message.instance) {
Message.instance = new Message();
}
return Message.instance;
}
}

export const message = Message.getInstance()

如何实现组件显隐?

显示与隐藏本质上就是对 Message 类的 messages 数据进行新增与删除操作,然后使用新的 messages 重新渲染 MessageList 组件即可

显示

当我们调用 message.success 时,首先需要在 messages 中新增这条数据,然后调用 render 方法使用新数据重新渲染 MessageList 组件即可

这里我们将逻辑抽离为 addMessage 私有方法

class Message {
// ...
#addMessage(message: MessageProps) {
this.#messages.push(message)
this.#render()
}

success(content: string) {
this.#addMessage({ type: 'success', content })
}

error(content: string) {
this.#addMessage({ type: 'error', content })
}

info(content: string) {
this.#addMessage({ type: 'info', content })
}

warning(content: string) {
this.#addMessage({ type: 'warning', content })
}
}
隐藏

Message 消失实际上就是在 messages 数据中移除这条数据,然后再重新渲染 MessageList 组件即可

移除数据需要唯一标识与回调函数,这里我们扩展 MessageProps 的 Type,并新增 InternalMessageProps 类型

export interface MessageProps {
type?: 'success' | 'error' | 'warning' | 'info'
content: string
onClose?: () => void
}

export interface InternalMessageProps extends MessageProps {
id: number
}

Message 组件接收 onClose 回调,内部使用 useEffect 设置定时器,在 2s 后调用 onClose

const Message: React.FC<MessageProps> = ({
type = 'success',
content,
onClose,
}) => {
useEffect(() => {
const timer = setTimeout(() => {
onClose?.()
}, 2000)

return () => {
clearTimeout(timer)
}
}, [onClose])
// ...
}

我们在 Message 类新增 messageCount 私有属性作为 message 数据的唯一标识,抽离移除逻辑作为私有方法 removeMessage。在新增时将 id 与 onClose 回调也一同保存

class Message {
static instance: Message
#containerRoot: Root
#messages: Array<InternalMessageProps> = []
#messageCount: number = 0

#render() {
this.#containerRoot.render(<MessageList messages={this.#messages} />)
}

#addMessage(message: MessageProps) {
const id = ++this.#messageCount
this.#messages.push({ id, onClose: () => this.#removeMessage(id), ...message })
this.#render()
}

#removeMessage(id: number) {
this.#messages = this.#messages.filter(message => message.id !== id)
this.#render()
}
// ...
}

以上,我们就实现了一个 Message 组件的基本功能

完整代码

// core.tsx
import { Root, createRoot } from "react-dom/client";
import { InternalMessageProps, MessageList, MessageProps } from "./Message";

const CONTAINER_ID = 'message-container'

class Message {
static instance: Message
#containerRoot: Root
#messages: Array<InternalMessageProps> = []
#messageCount: number = 0

constructor() {
let container = document.getElementById(CONTAINER_ID)
if (!container) {
container = document.createElement("div")
container.setAttribute("id", CONTAINER_ID)
document.body.appendChild(container)
this.#containerRoot = createRoot(container)
} else {
this.#containerRoot = createRoot(container)
}
}

#render() {
this.#containerRoot.render(<MessageList messages={this.#messages} />)
}

#addMessage(message: MessageProps) {
const id = ++this.#messageCount
this.#messages.push({ id, onClose: () => this.#removeMessage(id), ...message })
this.#render()
}

#removeMessage(id: number) {
this.#messages = this.#messages.filter(message => message.id !== id)
this.#render()
}

success(content: string) {
this.#addMessage({ type: 'success', content })
}

error(content: string) {
this.#addMessage({ type: 'error', content })
}

info(content: string) {
this.#addMessage({ type: 'info', content })
}

warning(content: string) {
this.#addMessage({ type: 'warning', content })
}

static getInstance() {
if (!Message.instance) {
Message.instance = new Message();
}
return Message.instance;
}
}

export const message = Message.getInstance()
// Message.tsx
import React, { useEffect } from 'react'
import './message.css'

export interface MessageProps {
type?: 'success' | 'error' | 'warning' | 'info'
content: string
onClose?: () => void
}

export interface InternalMessageProps extends MessageProps {
id: number
}

const Message: React.FC<MessageProps> = ({
type = 'success',
content,
onClose,
}) => {
useEffect(() => {
const timer = setTimeout(() => {
onClose?.()
}, 2000)

return () => {
clearTimeout(timer)
}
}, [onClose])

return (
<p className={`message message-${type}`}>
{content}
</p>
)
}

export const MessageList: React.FC<{ messages: InternalMessageProps[] }> = ({ messages }) => {
return (
<div className="message-list">
{messages.map((message) => (
<Message key={message.id} {...message} />
))}
</div>
)
}
/* message.css */
.message-list {
position: fixed;
z-index: 1000;
left: 50%;
transform: translateX(-50%);
top: 16px;
}

.message {
padding: 12px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.message-success {
background-color: #d4edda;
border-color: #c3e6cb;
color: #155724;
}

.message-error {
background-color: #f8d7da;
border-color: #f5c6cb;
color: #721c24;
}

.message-warning {
background-color: #fff3cd;
border-color: #ffeeba;
color: #856404;
}

.message-info {
background-color: #bee5eb;
border-color: #81c784;
color: #0c5460;
}

· 阅读需 4 分钟

手写一个实时搜索提示的输入框组件

首先模拟一个搜索提示信息数据的请求

export const fetchData = async (url: string, searchTerm: string) => {
const res = []
for (let i = 0; i < 10; i++) {
res.push({ label: searchTerm + i, value: searchTerm + i, })
}
// 时间随机为 500-1000ms
const randomTime = Math.floor(Math.random() * 500) + 500
await new Promise((resolve) => setTimeout(resolve, randomTime))
console.log(url, randomTime)
return res;
}

组件结构非常简单,一个输入框,一个提示列表。在 onInput 事件触发请求,使用响应的数据渲染提示信息列表。

import { useState } from "react"
import { fetchData } from "./fetch-data"

export const SearchBox: React.FC<{}> = () => {
const [searchList, setSearchList] = useState<{ label: string, value: string }[]>([])

const onInput = async (e: any) => {
try {
const res = await fetchData("/api/search", e.target?.value)
setSearchList(res)
} catch (error) {
console.log(error)
}
}

return (
<div className="search-wrapper">
<input type="text" onInput={onInput} />
<ul className="complete-list">
{searchList.map(item => (
<li key={item.value}>
{item.label}
</li>
))}
</ul>
</div>
)
}

由于我们在每次输入都会触发请求,而且响应的时间是不确定的,这种高频的输入变化可能会引起请求的竞态条件(race condition),导致返回的搜索结果与当前搜索框中的查询字符串不匹配

countRef 在这里充当了一个请求计数器的角色,每次触发 onInput 事件时,都会递增这个计数器的值。通过将当前的计数器值 temp 存储在一个局部变量中,然后将其与全局的 countRef.current 比较,这个代码检查了在异步操作完成之前 countRef.current 是否发生了改变。

如果 countRef.current 的值在请求过程中被修改(这说明有新的输入事件发生了),则按钮点击时的查询已不再是最新的,就会直接返回而不设置搜索结果列表。这就确保了只有最后一次输入时触发的搜索请求得到的结果会被渲染到组件上。

const countRef = useRef(0)
const [searchList, setSearchList] = useState<{ label: string, value: string }[]>([])

const onInput = async (e: any) => {
countRef.current += 1
const temp = lockRef.current
try {
const res = await fetchData("/api/search", e.target?.value)
if (countRef.current !== temp) return
setSearchList(res)
} catch (error) {
console.log(error)
}
}

这样一来

  • 确保即便是在快速连续输入的情况下也只显示最后一次输入的搜索结果。
  • 防止之前的搜索请求覆盖了后来的请求结果。
  • 处理并发请求的顺序问题,避免了潜在的竞态条件。

· 阅读需 11 分钟

需求

批量更新或新增数据,输入一个原始数据的数组,根据数据中在数据库中是否存在来判断是更新还是修改

直接想到的做法就是遍历这个数组,然后逐个查询,如果查到了,就修改,反之则新增

const db = client.db("数据库名").collection("users")
const data = [
{ code: '1', name: 'a', qty: 100 },
// ...
]

for (const d of data) {
// 先用 find 去查找数据是否存在
const result = await db.findOne({ code: d.code })
if (result) {
// 如果存在,则更新
await db.updateOne({ code: d.code }, d)
}else {
// 如果不存在,则新增
await db.insertOne(d)
}
}

虽然思路很清晰,但当数据量大的时候,频繁操作数据库可能会引起一些性能问题

相比逐条处理数据,批量更新通常会带来更高的性能

可以利用 mongoDB 提供的一些批量操作 api,一次性更新多个文档

方案1 预处理

  1. 先遍历查询每条数据
  2. 数据预处理:维护一个 insertDocsupdateDocs
  3. 最后分别调用 updateManyinsertMany 来批量更新这些数据
    • 注意 updateMany 用于同时更新多个满足特定条件的文档,但是它并不适用于每个文档有不同更新条件的情况
    • 所以这里使用 bulkWrite 来批量更新
const data = [
{ code: '1', name: 'a', qty: 100 },
// ...
]

const updateDocs = []
const insertDocs = []

for (let d of data) {
const result = await db.findOne({ code: d.code })
if (result) {
updateDocs.push({
filter: { code: d.code },
update: { $set: d }
})
} else {
insertDocs.push(d)
}
}

// 批量新增
const insertResult = await db.insertMany(insertDocs)
console.log(`Inserted ${insertResult.insertedCount} new document(s).`)

// 批量更新
const bulkUpdateOps = updateDocs.map(update => ({
updateOne: {
filter: update.filter,
update: update.update,
upsert: false
}
}));
const updateResult = await collection.bulkWrite(bulkUpdateOps)
console.log(`${updateResult.matchedCount} document(s) matched the filter`)
console.log(`${updateResult.modifiedCount} document(s) were updated`)

方案2 bulkWrite

bulkWrite API 原生支持批量新增或更新的操作,利用其 upsert 配置的特性来完成这个需求

const data = [
{ code: '1', name: 'a', qty: 100 },
// ...
]
// 创建 bulkWrite 操作数组
const bulkOperations = data.map(d => ({
updateOne: {
filter: { code: d.code },
update: { $set: d },
// upsert 表示新增或更新
upsert: true // 如果不存在,那么就新增
}
}))

// 执行 bulkWrite 操作
const result = await db.bulkWrite(bulkOperations)

console.log(`${result.matchedCount} document(s) matched the query criteria.`)
console.log(`${result.modifiedCount} document(s) was/were updated.`)
console.log(`${result.upsertedCount} document(s) was/were inserted.`)
console.log(`Upserted document _id: ${result.upsertedIds}`)

批量更新

类似于 MongoDB 中的批量更新功能,关系型数据库如 MySQL 和 PostgreSQL 也提供了处理批量更新的能力。不过,批量更新的实现方式和语法因不同的数据库而异。以下是 MySQL 和 PostgreSQL 中批量更新数据的基本示例:

MySQL 批量更新

在 MySQL 中,你可以使用 INSERT ... ON DUPLICATE KEY UPDATE 语法来达到类似批量更新的效果。这要求表中有唯一索引或主键,当插入的行与现有行在唯一索引或主键上冲突时,则执行更新操作。

INSERT INTO 表名 (1,2, ...)
VALUES
(1a,2a, ...),
(1b,2b, ...),
...
ON DUPLICATE KEY UPDATE
1 = VALUES(1),2 = VALUES(2), ...;

PostgreSQL 批量更新

在 PostgreSQL 中,可以使用 INSERT ... ON CONFLICT 语法实现类似的功能。这同样依赖于表中的唯一约束或主键。

INSERT INTO 表名 (1,2, ...)
VALUES
(1a,2a, ...),
(1b,2b, ...),
...
ON CONFLICT (列名) DO UPDATE
SET1 = EXCLUDED.1,2 = EXCLUDED.2, ...;

在这两个示例中,批量更新(或称为“批量插入或更新”)的行为取决于是否有与现有数据冲突的新数据。如果有冲突,则执行更新;如果没有冲突,则插入新数据。

这些方法非常适合于需要同步大量数据或进行批量数据处理的场景。需要注意的是,具体的语法和实现可能因数据库的版本和具体配置而有所不同,因此在实际应用中应参考相关数据库的官方文档。

关联查询

mongoDB 聚合查询

MongoDB 作为一个非关系型数据库(NoSQL),其原生的设计并没有传统意义上的“连表查询”概念。在关系型数据库(如 MySQL)中,多个表通过关联字段进行关联查询(JOIN 操作),而 MongoDB 中存储数据的基本单位是集合(Collection),它们之间不像关系型数据库中的表那样自然具有关系。

然而,MongoDB 提供了 $lookup 聚合管道操作符,它允许你在聚合查询中实现类似于 SQL 中的 JOIN 功能。使用 $lookup,你可以引用其他集合的文档并将它们与当前集合的文档汇聚在一起。

以下是一个 $lookup 的图书和作者示例,模拟关系型数据库中的连表查询:

db.books.aggregate([
{
$lookup: {
from: "authors", // 要 join 的集合名
localField: "authorId", // 本集合中用于 join 的字段
foreignField: "_id", // 外部集合中用于 join 的字段
as: "authorInfo" // 查找到的数据放在哪个字段
}
}
]);

在这个例子中,我们假设 books 集合中的每本书都有一个 authorId 字段,它引用了 authors 集合中对应作者的 _id 字段。$lookup 会将 authors 集合中匹配到的作者文档添加到 authorInfo 字段中。

在 MongoDB 3.2 以上版本中,$lookup 可以非常有效地执行这种操作,使 MongoDB 更接近于传统的关系型数据库体验。

此外,MongoDB 3.6 以上版本引入了更高级的 $lookup 语法,允许进行更复杂的连接操作,包括可以实现类似左外连接的操作。

尽管 MongoDB 有 $lookup 操作符,但它并不是为了大量连表操作而设计的。在使用 MongoDB 设计数据模型时,通常推荐的做法是尽量减少需要连接操作的场景,比如通过嵌入文档(embedding)或使用文档引用(referencing)来优化查询效率。

公司规定数据库每次关联查询不能超过三张表,怎么做

使用中间表减少连接

假设我们有三个表:users (用户信息)、orders (订单信息)、和 products (产品信息)。为了减少查询时的连接数量,我们可能会创建一个中间表 order_details,其中包含了用户信息和订单信息的部分数据。

创建中间表 order_details

CREATE TABLE order_details AS
SELECT u.user_id, u.user_name, o.order_id, o.product_id, p.product_name, o.quantity
FROM users u
JOIN orders o ON u.user_id = o.user_id
JOIN products p ON o.product_id = p.product_id;

之后的查询可以直接从 order_details 中获取数据,无需再次进行三表连接。

分解查询

假设我们需要基于用户的订单来获取产品信息。我们可以先查询用户的订单,然后使用这些订单ID来查询订单中的产品信息。

  1. 查询用户订单:
SELECT order_id
FROM orders
WHERE user_id = '123';

假设这返回了订单ID列表 [456, 789]

  1. 基于订单ID查询产品信息:
SELECT product_name, quantity
FROM order_details
WHERE order_id IN (456, 789);

这样,我们就避免了一次性连接多个表。

示例3: 使用子查询

子查询可以在单个 SQL 语句中嵌入另一个查询,从而减少同时需要连接的表的数量。

查询订单中的产品信息,其中产品价格大于某个值:

SELECT o.order_id, (SELECT product_name FROM products p WHERE p.product_id = o.product_id AND p.price > 100) as expensive_products 
FROM orders o
WHERE o.user_id = '123';

这里,我们在SELECT子句中使用了子查询来获取符合条件的产品名称。

示例4: 使用公用表表达式(CTE)

查找过去一月内活跃用户的订单:

WITH ActiveUsers AS (
SELECT user_id
FROM users
WHERE last_login_date > CURRENT_DATE - INTERVAL '1 month'
),
UserOrders AS (
SELECT user_id, order_id
FROM orders
WHERE user_id IN (SELECT user_id FROM ActiveUsers)
)
SELECT u.user_name, o.order_id
FROM UserOrders o
JOIN users u ON u.user_id = o.user_id;

这里,我们使用了两个CTE(ActiveUsers 和 UserOrders)来逐步减少所需连接的表数量。

示例5: 优化数据模型

在设计数据库时,可以通过合理的数据模型设计来避免复杂的查询,例如通过适当的反归一化来预存一些经常一起查询的数据字段,或者根据查询模式调整表结构。

例如:在 orders 表中添加 user_name 字段,避免查询时每次都要连接 users 表来获取用户名。

· 阅读需 2 分钟

Vite 插件开发

插件可以运行在构建期间的一些特殊节点,从而扩展构建工具的能力

插件本质上就是一个工厂函数,返回一个对象

Rollup 官方将钩子分为两类:构建钩子输出生成钩子

  • 构建钩子在构建阶段运行。它们主要涉及在 Rollup 处理输入文件之前定位、提供和转换输入文件。
  • 输出生成钩子可以提供有关生成的产物的信息并在构建完成后修改构建。

文档对于这些 hook 的声明与运行时机有详细说明,这里不再赘述

下面我们实现一个根据路由生成 html 文件结构的简单插件

// plugins/generateHTMLFromRoutes.ts
import fs from 'node:fs'
import path from 'node:path'

// 暂时手动维护一份路由表
const routes = [
{
path: 'basic-data',
children: [
{ path: 'material' },
{ path: 'resource' },
{ path: 'switch-rule' },
],
},
{ path: 'overview-today' },
{ path: 'overview-month' },
]
interface Options {
outDir?: string
}
/*
* options: 目前仅支持 outDir 配置
* */
export default function generateHtmlWithRoutes(options: Options = {}) {
const outputDir = options.outDir || 'dist'
return {
name: 'generateHtmlWithRoutes',
apply: 'build' as const,
writeBundle() {
const sourceFile = path.join(outputDir, 'index.html')
const makeDir = (url: string) => fs.mkdirSync(url, { recursive: true })
const cpHtml = (target: string) => fs.copyFile(
sourceFile,
target,
(err) => {
if (err)
throw err
})
// 按路由表生成文件夹并复制 html
for (const m of routes) {
if (m.children) {
for (const m2 of m.children) {
makeDir(path.join(outputDir, m.path, m2.path))
cpHtml(path.join(outputDir, m.path, m2.path, 'index.html'))
}
}
else {
makeDir(path.join(outputDir, m.path))
cpHtml(path.join(outputDir, m.path, 'index.html'))
}
}
},
}
}

使用

// vite.config.ts
export default defineConfig({
plugins: [generateHtmlWithRoutes()],
// ...
})

· 阅读需 6 分钟

题目描述

  • 依次顺序执行一系列任务

  • 所有任务全部完成后可以得到每个任务的执行结果

  • 需要返回两个方法,start 用于启动任务,pause 用于暂停任务

  • 每个任务具有原子性,即不可中断,只能在两个任务之间中断

  • 每个任务无参,异步

function processTasks(...task) {}

实现

首先确定返回值为两个方法,start 和 pause

function processTasks(...task) {
return {
start() {}
pause() {}
}
}

先实现 start 的基本功能:遍历 tasks 并取出任务,依次执行,将收集到的结果保存,当全部任务结束后返回最终结果

function processTasks(...tasks) {
// 保存结果
const result = []
return {
// 任务是异步的,所以 start 也是异步的
async start() {
// 取出任务并执行
for (let i = 0; i < tasks.length; i++) {
console.log(`任务${i} 开始...`)
const r = await tasks[i]();
console.log(`任务${i} 完成!`)
result.push(r)
}
// 任务全部完成,返回最终结果
if (result.length === tasks.length) {
return result
}
},
pause() { }
}
}

然后考虑中断的情况,需要缓存当前执行的任务索引,以及维护暂停的状态

pause 方法要做的就只是将暂停状态更改为 true

在 start 中,将暂停状态更改为 false,循环执行每个任务之后,判断当前暂停状态,如果暂停了,就使用 return 终止 start 函数的执行

function processTasks(...tasks) {
const result = []
// 缓存当前执行的位置
let currentIndex = 0
// 当前执行状态
let isPaused = false
return {
async start() {
// 开始执行
console.log('start: 开始执行...')
isPaused = false
for (; currentIndex < tasks.length; currentIndex++) {
console.log(`任务${currentIndex} 开始...`)
const r = await tasks[currentIndex]();
console.log(`任务${currentIndex} 完成!`)
result.push(r)
// 每次任务执行结束后,检查当前状态
if (isPaused) {
console.log('start: 暂停,等待恢复...')
// 如果暂停了,就终止函数执行
return
}
}
if (result.length === tasks.length) {
return result
}
},
pause() {
console.log('pause: 暂停执行!')
isPaused = true
}
}
}

目前存在一个问题,就是暂停后恢复执行时,会重复执行上次的任务

currentIndex 应该在任务完成后就自增,循环可以改为 while

// ...
while(currentIndex < tasks.length) {
console.log(`任务${currentIndex} 开始...`)
const r = await tasks[currentIndex]();
console.log(`任务${currentIndex} 完成!`)
result.push(r)
// 在每次任务执行后就更新任务索引
currentIndex++
if (isPaused) {
console.log('start: 暂停,等待恢复...')
return
}
}
// ...

目前任务执行与中断的需求就完成了

不过唯一美中不足的就是,每次暂停执行后,start 的函数就会 return,导致 start 返回的 Promise 会在每次暂停执行后默认 resolve 一个 undefined,与要求中的「所有任务全部完成后可以得到每个任务的执行结果」不符

  • 如何使 start 方法在所有任务都完成后才会返回最终结果呢?

即 start 方法返回的 Promise 只有在全部任务完成后才 resolve

为了手动显式控制 Promise 的 resolve,所以让 start 方法手动返回一个 Promise;当所有任务完成后,使用 resolve(result) 替换 return result

// start 也不用 async 了
start() {
return new Promise(async (resolve) => {
console.log('start: 开始执行...')
isPaused = false
while(currentIndex < tasks.length) {
console.log(`任务${currentIndex} 开始...`)
const r = await tasks[currentIndex]();
console.log(`任务${currentIndex} 完成!`)
result.push(r)
currentIndex++
if (isPaused) {
console.log('start: 暂停,等待恢复...')
// 这里 return 就不会导致整个 start 的 Promise 返回 undefined 了
// 而是仅仅使得当前的 Promise 进入 pending 状态
return
}
}
if (result.length === tasks.length) {
resolve(result)
}
})
}

这样一来,即使任务中断了,return 不会导致当前 start 返回的 Promise 进入 resolve 状态,而是一直维持 pending 状态

最终代码

function processTasks(...tasks) {
const result = []
let currentIndex = 0
let isPaused = false

return {
start() {
return new Promise(async (resolve) => {
isPaused = false
while(currentIndex < tasks.length) {
const r = await tasks[currentIndex]()
result.push(r)
currentIndex++
if (isPaused) {
return
}
}
if (result.length === tasks.length) {
resolve(result)
}
})
},
pause() {
isPaused = true
}
}
}

测试代码

<body>
<button id="begin">启动任务</button>
<button id="pause">暂停任务</button>
</body>
<script src="./3.js"></script>
<script>
const tasks = []
for (let i = 0; i < 5; i++) {
tasks.push(() => {
return new Promise((resolve) => {
setTimeout(() => resolve(i), 1000)
})
})
}
const processor = processTasks(...tasks)

begin.onclick = async () => {
console.log('点击开始')
const results = await processor.start()
console.log('任务执行完成', results)
}

pause.onclick = () => {
console.log('点击暂停')
processor.pause()
}
</script>

· 阅读需 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 类型的属性进行了排除

因此更严谨的获取对象全部属性名的方法就是直接调用内部方法 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))

· 阅读需 3 分钟

元素/鼠标的位置

getBoundingClientRect()

获取包含整个元素的最小矩形(包括 paddingborder-width),相对视口左上角,使用xytopleftbottomright等属性描述整个矩形的位置

image-20240315140408927

const element = document.getElementById("myElement");
const rect = element.getBoundingClientRect();
console.log("元素左上角的X坐标:" + rect.left);
console.log("元素左上角的Y坐标:" + rect.top);
console.log("元素右下角的X坐标:" + rect.right);
console.log("元素右下角的Y坐标:" + rect.bottom);

offsetTop 和 offsetLeft 属性:

相对父元素的偏移量

image-20240315142059782

const element = document.getElementById("myElement");
console.log("元素的上边缘的偏移量:" + element.offsetTop);
console.log("元素的左边缘的偏移量:" + element.offsetLeft);

pageX 和 pageY 属性:

pageX/Y获取到的是鼠标相对文档区域左上角距离,会随着页面滚动而改变

element.addEventListener("mousemove", function(event) {
console.log("鼠标的X坐标:" + event.pageX);
console.log("鼠标的Y坐标:" + event.pageY);
});

clientX 和 clientY 属性:

clientX/Y获取到的是鼠标相对浏览器可视区域左上角距离,不随页面滚动而改变

element.addEventListener("mousemove", function(event) {
console.log("鼠标在视口中的X坐标:" + event.clientX);
console.log("鼠标在视口中的Y坐标:" + event.clientY);
});

screenX 和 screenY 属性:

screenX/Y获取到的是鼠标相对显示器屏幕左上角的距离,不随页面滚动而改变。

image-20240315145513092

元素的尺寸

不可靠的方式

window.getComputedStyle(dom).width

获取 CSSOM 树(浏览器渲染第二步)的节点尺寸

不一定能反应界面上的最终实际尺寸(padding,border,flex)

dom.style.width

获取 dom 元素(DOM 树)上的 style 属性,即内联样式,同样不一定能反应界面上的实际尺寸(还可能读不到)

以下三种方式读到的是 Layout 树的信息,可以读到元素的几何信息(不考虑 transform)

dom.clientWidth

content + padding

dom.offsetWidth

content + padding + scroll + border

clientWidth + scroll + border

dom.scrollWidth

可滚动区域的全部尺寸

image-20240315160052452

getBoundingClientRect()

读取到的是在浏览器渲染管线最后一步 draw 后的尺寸数据,也就是我们能看到的元素的实际最终尺寸

draw 阶段会处理一些 transform 变换,本质上是矩阵变换,由 GPU 计算并绘制到屏幕中,效率非常高

image-20240315160714012

· 阅读需 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 能够区别正负

· 阅读需 7 分钟

整个 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()
})
}
}
}

· 阅读需 7 分钟

https://github.com/GSemir0418/video-call-demo-express

基于 SocketIO 和 PeerJS,使用 Express 完成 Web 视频通话的 demo,

二者结合既能高效传输数据,又能正确处理实时的信令事件

其中 socket.io 主要负责在服务器和客户端之间实现实时双向通信,用于在服务器和客户端之间传递与通话相关的信令数据,比如「哪一个用户加入了通话」或者「一个用户离开了通话」

PeerJS

https://peerjs.com/

PeerJS 是一个封装了 WebRTC API 的 JavaScript 库,主要负责通过浏览器直接在客户端之间进行实时通信,可以让两个浏览器直接进行实时数据的传输,无需经过服务器。

WebRTC(Web Real-Time Communication)是一项开源项目,让网页应用和网站能在不需要中间媒介的情况下直接进行浏览器间音视频通话和数据分享,大大降低了实时通信应用的复杂性,使得开发者无需为实现复杂的实时通信架构而头疼,也让用户无需安装任何额外插件就能使用浏览器进行音视频聊天和文件分享。

PeerJS 的作用主要体现在以下几点:

  1. 直接传输视频和音频流:由于 PeerJS 封装了 WebRTC,你可以使用它将视频和音频流直接从一个浏览器传输到另一个浏览器,而无需经过服务器。这不仅能提高数据传输速度,还可以显著降低服务器带宽消耗。
  2. 高效的数据传输:PeerJS 允许你使用其数据通道API进行二进制数据的直接传输,同时,数据通道API也支持流控和拥塞控制。
  3. 点对点的通信模式:通过 PeerJS 和 WebRTC 实现的点对点(Peer-to-Peer)通信模式,可以有效避免服务端负载过大的问题,并且提高了整体的数据传输性能。

使用

  • 基本使用
npm i -g peer
peerjs --port 9000 --key peerjs --path /myapp
// 初始化 peer 实例,连接 peer 服务
const peer = new Peer(undefined, {
host: '你的服务器地址',
port: '你的服务器端口',
path: '/myapp',
key: 'peerjs',
})
// 第一个参数表示该用户 id,传入 undefined 表示自动生成用户 id
  • 可以将一个 peer 对象类比于一个用户
  • open 事件

open 事件在 Peer 对象与其对应的 Peer 服务器成功建立连接时触发。在这个事件触发之后,我们就可以获得一个在 Peer 服务器上唯一的ID,它可以被用来建立 Peer 到 Peer 的连接

事件参数 id 是由 Peer 服务器为此 Peer 对象分配的唯一 ID。在此事件触发后,我们就可以使用这个 ID 与其他 Peer 对象建立连接

  • call 事件

call 事件在 Peer.call() 调用时触发,其事件参数 call 对象,表示一次 Peer 到 Peer 的连接

当与另一个 Peer 对象建立连接并共享音视频流,可以调用 peer.call(otherPeerId, yourStream)。其中 otherPeerId 是你想要连接的其他 Peer 的 ID,yourStream 是你想要共享的音视频流

当调用了 peer.call() 后,我们可以得到一个代表这次连接的 Call 对象。在这个 Call 对象上,我们可以听取各种事件,如 streamclose 等,分别在接收到其他 Peer 的流以及连接关闭时触发

  • 可以将 call 连接类比于 socket 连接,可以使用 on 监听事件,也可以触发事件

代码

服务端开启 peerjs 服务

服务端开启 socket 服务,处理 join-roomuser-connecteddisconnected 以及 user-disconnected 事件

io.on('connection', socket => {
// 注册并监听客户端触发的 join-room 事件
socket.on('join-room', (roomId, userId) => {
// 让 socket 客户端加入这个 roomId 房间
socket.join(roomId)
// 让 socket 客户端向指定房间内(除自己之外)的其他客户端广播 user-connected 事件
socket.to(roomId).emit('user-connected', userId)

// 注册断开连接事件
socket.on('disconnect', () => {
// 向指定房间内其他成员广播 该用户断开连接
socket.to(roomId).emit('user-disconnected', userId)
})
})
})

客户端源码

// 初始化 socket 连接
const socket = io('/')
const videoGrid = document.getElementById('video-grid')
// 缓存所有连接到同一房间的其他用户
const peers = {}
// 连接 peerjs 服务器
const myPeer = new Peer(undefined, {
host: '/',
port: '3001'
})

// 视频元素:来自当前用户设备的视频流
const myVideo = document.createElement('video')
myVideo.style.border = '3px solid red'
myVideo.muted = true

// 当 peer 连接打开时,通过socket向服务器发送一个'join-room'事件
myPeer.on('open', id => {
socket.emit('join-room', ROOM_ID, id)
})

// 获取当前用户视频流
navigator.mediaDevices.getUserMedia({
video: true,
audio: true
}).then(currentStream => {
// 添加当前用户的视频流数据及 video 元素
addVideoStream(myVideo, currentStream)

// 监听其他用户的连接事件
socket.on('user-connected', (userId) => {
// 发起 peer 连接
connectToNewUser(userId, currentStream)
})

// 监听'call'事件,
myPeer.on('call', call => {
// 当收到其他用户的音视频流时,将当前用户的视频流返回给其他用户
call.answer(currentStream)
const video = document.createElement('video')
call.on('stream', otherVideoStream => {
addVideoStream(video, otherVideoStream)
})
})
})

// socket 断开连接事件
socket.on('user-disconnected', userId => {
if(peers[userId])
peers[userId].close()
})


// 添加(其他用户)视频元素
function addVideoStream(video, stream) {
video.srcObject = stream
video.addEventListener('loadedmetadata', () => {
video.play()
})
videoGrid.append(video)
}

// 连接到(其他)用户
function connectToNewUser(userId, stream) {
// 向该用户建立连接
const call = myPeer.call(userId, stream)
const video = document.createElement('video')
call.on('stream', userVideoStream => {
addVideoStream(video, userVideoStream)
})
// close 事件触发后,移除 video 元素
call.on('close', () => {
video.remove()
})
// 缓存新用户连接
peers[userId] = call
}

· 阅读需 2 分钟

为什么引入箭头函数

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

函数的二义性

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

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

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

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

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

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

消除函数二义性

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

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

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

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

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

· 阅读需 4 分钟

现象

在日常开发使用 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,所以遇到稀疏项会自动跳过

· 阅读需 7 分钟

定义

JWT 全称 JSON Web Token,定义了一种在网络上安全传输以 JSON 格式包含的声明信息(数据)的标准,也指代了一种登录认证方案

由来

传统的 cookie-session 登录模式流程如下

1、客户端向服务器发送用户名和密码

2、服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等

3、服务器向客户端返回一个 session_id,写入客户端的 Cookie

4、客户端随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器

5、服务器收到 session_id,找到前期保存的数据,由此得知用户的身份

这种模式具有一些明显的弊端

  1. 扩展性不高:在分布式系统中,如果用户的请求被路由到不同的服务器,需要共享 Session,处理这个问题会相对麻烦。这是因为,会话信息通常保存在服务器的内存中,当服务需要扩展时,也需要同步这些会话信息。
  2. 存储压力:如果网站有大量的并发访问,每个用户登录都需要服务器存储用户的会话信息,这无疑会增加服务器的存储压力。
  3. 无法携带数据:客户端的 Cookie 只是一个会话 ID,要获取用户的登录信息,还需要在服务器端进行查询。
  4. CSRF攻击:与跨站点脚本 (XSS) 不同,跨站点脚本 (XSS) 利用用户对特定站点的信任,而 CSRF 利用站点在用户浏览器中的信任。不过现在可以通过 sameSite|secure|httpOnly 等属性解决一定的 cookie 安全性问题
  5. 存在被篡改和伪造的隐患:如果某个人能够拦截这个会话ID,那么他就可以通过伪造这个会话ID来假冒用户身份,这就是所谓的“会话劫持”。此外,如果服务器端的会话存储不当,比如存储在可被外部访问或者能够被SQL注入的数据库,那么这些会话数据就有可能被篡改。

为了提高安全性减轻服务端压力实现单点登录等,JWT 应运而生

组成

JWT 由三部分组成,中间由 . 连接

[HEADER].[PAYLOAD].[SIGNATURE]

Header 是一个 JSON 对象,描述 JWT 的元数据

{
"alg": "HS256", // 加密算法
"typ": "JWT" // token 类型
}

将上面的 JSON 对象使用 Base64URL 算法转成字符串 btoa 解码 atob

注意这里是 Base64URL 而不是 Base64

Base64编码使用的字符包括A-Z,a-z,0-9,+和/,并且在需要的时候使用=作为填充。然而,这些字符在URL中有特殊的含义,可能会被认为是分隔符,也可能在传输过程中被改变。

Base64URL编码是为了解决这个问题而产生的。它将Base64编码中的+和/分别替换为-和_。这样,就可以在URL和文件名中安全地使用Base64URL编码的字符串,不需要进行额外的URL转义处理。

Payload

Payload 也是一个 JSON 对象,用来存放实际需要传递的数据

{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iss": "签发人",
"exp": "过期时间",
"nbf": "生效时间",
"iat": "签发时间",
"jti": "编号"
}

注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。

这个 JSON 对象也要使用 Base64URL 算法转成字符串

Signature

Signature 部分是对前两部分的签名,防止数据篡改。

首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),产生前两部分的签名,再经过 Base64URL 编码后作为 JWT 的第三部分

HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)

流程

登录成功后,服务端使用密钥生成 jwt 返回给客户端,客户端保存在本地。再次发起请求时,服务端将 jwt 放到 Authorization 请求头中,服务端拿到 jwt 字符串,使用密钥解密,验证用户是否有效,解密成功且用户有效,则校验通过。

优缺点

  • 优点:解决了上述传统登录模式的问题,提升了数据传输的安全性、减轻服务端压力(自包含用户信息,无状态,无需保持会话状态)
  • 缺点:由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑(黑名单)。

· 阅读需 8 分钟

需求

某公司有很多子产品线,为了提升用户体验,希望用户只要在其中一个系统登录,再访问另一个网站就会自动登录

一种实现方案基于 session-cookie,将用户管理的业务单独抽离出来,成为独立的用户认证中心,用来鉴权与管理用户信息。

1. 用户向认证中心发起登录请求,登录成功后,认证中心会生成 session_id,结合用户信息,以 key value 的形式储存到服务器的数据库/内存中
2. 将 session_id 通过 Set-cookie 方法发送给客户端
3. 当用户再次向子系统发起请求时,浏览器会自动带上这个 id 作为 cookie 请求头,供服务端验证
4. 此时子系统会访问认证中心的服务器,认证中心来验证用户的登录状态
5. 如果服务端的 session 数据中有这条数据,说明用户的登录验证是有效的;反之说明用户没有登录或登录过期

image-20240228134957767

这种方式的优势在于

  • 架构清晰

  • 认证中心对于用户的管理具有很强的控制力

缺点也显而易见——烧钱

  • 对于用户量较大的应用,同时在线人数很多,无论是认证中心的请求响应业务还是数据存储的压力都非常大,所以对服务器的配置要求也非常高;

  • 而且如果认证中心服务挂了,所有系统都瘫痪了,所以还需要做容灾;

  • 还有就是只要某个子系统有扩容需求时,认证中心的服务为了满足这个子系统的需求也要跟着扩容

Token

为了降低认证中心的压力,降低成本,提出了 Token 模式

即认证中心服务器索性不保存 session 数据了,所有数据都保存在客户端,每次请求都发回服务器。JWT 就是这种方案的一个代表。

1. 用户向认证中心发起登录请求,登录成功后,认证中心会加密生成一个不会被篡改的字符串(Token),发送给客户端
2. 客户端本地保存 localStorage/cookie,接下来认证中心就什么都不管了
3. 当用户再次向子系统请求受限的资源时,将这个 token 也带过去,由子系统自行验证即可(与认证中心共享加密密钥)
4. 子系统无需频繁向认证中心验证用户登录状态,

image-20240228135309184

优点在于显著降低了成本低了,也解除了与子系统的依赖

缺点在于认证中心失去了对用户绝对的控制

双 token

为了弥补这个缺陷,在 token 方案的基础上,提出了双 token 的模式,

即在普通 token 的基础上,增加一个 refreshToken

1. 用户向认证中心发起登录请求,登录成功后,认证中心会加密生成两个 Token,发送给客户端
2. 一个是可以供子系统自行认证的 token,另一个是只有认证中心才能解密的 token
3. 但是第一个 token 过期时间通常设置的短一些(10 min),第二个 refresh Token 的过期时间要久一些(一个月)
4. 当第一个 token 过期时,用户就会使用 refreshToken 请求认证中心,认证中心校验成功后,颁发新的 token

image-20240228135720305

这样的目的就是让用户每隔一小段时间来一次认证中心,从而保持认证中心对用户绝对的控制

如何实现 Token 的无感刷新?

Token 无感刷新就是上面说的双 token 的具体实现方案,只有在单点登录的场景下才可以讨论

主体逻辑并不难,难在处理刷新时机与冗余刷新等一些细节问题

利用响应拦截器来完成主体逻辑

如果响应中含有 token 或者 refreshToken,则存储到本地

如果响应表示无权限

request.js

import axios from 'axios'
import { setToken, setRefreshToken, getToken } from './token'
import { refreshToken, isRefreshRequest } from './refreshToken'

const ins = axios.create({
baseURL: 'xxxx',
headers: {
Authorization: `Bearer ${getToken()}`
}
})

ins.interceptors.response.use(async (res) => {
// 如果有短 token,就保存/更新 ls
if (res.headers.authorization) {
const token = res.headers.authorization.replace('Bearer ', '')
setToken(token)
// 修改默认请求头为最新的 token
ins.defaults.headers.Authorization = `Bearer ${token}`
}

// 如果有 refreshtoken,就保存/更新 ls
if (res.headers.refreshtoken) {
const refreshToken = res.headers.refreshtoken.replace('Bearer ', '')
setRefreshToken(refreshToken)
}

// 如果响应无权限且不是刷新 token 的请求,就调用更新 token 的接口,并重新发起请求
if (res.data.code === 401 && !isRefreshRequest(res.config)) {
// 刷新 token
const isSuccess = await refreshToken()
if (isSuccess) {
// 如果成功换到了新 token
// 使用之前请求的配置(修改权限请求头)重新发起请求
res.config.headers.Authorization = `Bearer ${getToken()}`
const resp = await ins.request(res.config)
return resp
} else {
console.log('请重新登录')
}

}

return res.data
})

export default ins

refreshToken.js

import request from './request'
import { getRefreshToken } from './token'

// 缓存刷新 token 请求的 promise
let promise

export async function refreshToken() {
if (promise){
return promise
}

promise = new Promise((resolve) => {
const res = await request.get('/refresh-token', {
headers: {
Authorization: `Bearer ${getRefreshToken()}`,
},
// 标记是否是刷新 token 的请求
__isRefreshToken: true
})
resolve(res.code === 0)
})

promise.finally(() => promise = null)

return promise
}

export function isRefreshRequest(config) {
return !!config.__isRefreshToken
}

为了解决刷新 token 期间产生的新请求,仍然会重复请求刷新 token 的问题

我们可以将 refreshToken 的结果缓存为一个变量 promise,如果这个 promise 没结束,说明还没完成刷新 token 的请求

这样一来,在刷新 token 期间产生的请求,都等待(await)的是同一个刷新 token 的 promise,解决了冗余请求刷新 token 的问题

· 阅读需 5 分钟

TCP 三次握手

  • 第一次 客户端向服务端发送连接请求

客户端向服务端发送连接请求报文段。该报文段中包含自身的数据通讯初始序号。请求发送后,客户端便进入 SYN-SENT 状态。

  • 第二次 服务端确认接收客户端的连接请求,并向客户端发送连接请求

服务端收到连接请求报文段后,如果同意连接,则会发送一个应答,该应答中也会包含自身的数据通讯初始序号,发送完成后便进入 SYN-RECEIVED 状态。

  • 第三次 客户端确认接收服务端的连接请求,连接建立成功

当客户端收到连接同意的应答后,还要向服务端发送一个确认报文。客户端发完这个报文段后便进入 ESTABLISHED 状态,服务端收到这个应答后也进入 ESTABLISHED 状态,此时连接建立成功。

image-20231219170511166

TCP 四次挥手

  • 第一次

若客户端 A 认为数据发送完成,则它需要向服务端 B 发送连接释放请求。

  • 第二次

B 收到连接释放请求后,会告诉应用层要释放 TCP 链接。然后会发送 ACK 包,并进入 CLOSE_WAIT 状态,此时表明 A 到 B 的连接已经释放,不再接收 A 发的数据了。但是因为 TCP 连接是双向的,所以 B 仍旧可以发送数据给 A。

  • 第三次

B 如果此时还有没发完的数据会继续发送,完毕后会向 A 发送连接释放请求,然后 B 便进入 LAST-ACK 状态。

PS:通过延迟确认的技术(通常有时间限制,否则对方会误认为需要重传),可以将第二次和第三次握手合并,延迟 ACK 包的发送。

  • 第四次

A 收到释放请求后,向 B 发送确认应答,此时 A 进入 TIME-WAIT 状态。该状态会持续 2MSL(最长报文段寿命,指报文段在网络中生存的时间,超时会被抛弃) 时间,若该时间段内没有 B 的重发请求的话,就进入 CLOSED 状态。当 B 收到确认应答后,也便进入 CLOSED 状态。

image-20231219170654861

TCP 泛洪攻击

我们已经知道,TCP 只有经过三次握手才能连接,而 SYN 泛洪攻击就是针对 TCP 握手过程进行攻击:

  • 攻击者发送大量的 SYN 包给服务器(第一次握手成功)
  • 服务器回应(SYN + ACK)包(第二次握手成功)
  • 但是攻击者不回应 ACK 包(第三次握手不进行)

导致服务器存在大量的半开连接,这些半连接可以耗尽服务器资源,使被攻击服务器无法再响应正常 TCP 连接,从而达到攻击的目的

幸运的是,一种称为 SYN cookie 的有效防御现在已部署在大多数主要的操作系统中:

  • 在客户端发送 SYN 报文给服务器(第一次握手),服务端收到连接请求报文段后,服务器不会为此SYN创建半开连接,而是生成一个序列号(所谓的 cookie)一起发送给客户端(第二次握手),在这个阶段,服务器不会为该连接分配任何资源
  • 客户端返回 ACK 报文给服务器(第三次握手),服务器会验证这个 cookie 值,只有验证成功才创建 TCP 连接,分配资源
  • 如果客户端没有返回 ACK 报文给服务器,也不会对服务器造成任何的伤害,因为服务器没有分配任何资源给它

· 阅读需 1 分钟

思路

localStorage 本身是没有数据过期机制的,可以参考 cookie 过期的特性,通过其他手段来扩展,使其满足我们对数据过期的要求

  • 存入数据时,要传入过期时间

  • 获取数据时,判断是否过期,过期返回 undefined,否则正常返回数据

localStorage 和 sessionStorage 都继承自 Storage 对象,我们可以扩展 Storage 原型方法

API 设计

  • setItemWithAge(key, value, age)

  • getItemWithAge(key)

Storage.prototype.setItemWithAge = (key, value, age) => {
if (isNaN(Number(age)))
throw new Error("age must be a number");
const obj = {
data: value,
time: Date.now(),
maxAge: Number(age)
}
localStorage.setItem(key, JSON.stringify(obj))
}

Storage.prototype.getItemWithAge = (key) => {
if (localStorage.getItem(key)) {
const { data, time, maxAge } = JSON.parse(localStorage.getItem(key))
if (time + maxAge < Date.now()) {
localStorage.removeItem(key)
return undefined
}
return data
}
}

· 阅读需 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 事件中取消默认事件来解决此问题。

· 阅读需 7 分钟

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

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 非同源

借助服务,例如 WebSocket

· 阅读需 1 分钟
sudo lsof -i :<PROT>
kill -3 <PID>
-3 quit -9 kill no chance to clean up -15 softer than -9

· 阅读需 2 分钟

tar 命令是 linux 系统中用于文件归档与压缩工具

tar 是 tape archive(磁带存档)的缩写,它最初是为了在磁带上进行文件备份和恢复而设计的。随着时间的推移,它逐渐演变成了一个通用的文件处理工具,用于创建归档、压缩文件和目录等。它的特点在于,它可以将多个文件或目录打包成一个单独的文件,并且可以应用不同的压缩算法。

创建归档文件

tar -cvf archive_name.tar /path/to/source/*

  • c 表示创建新的归档文件
  • v 表示暴露详细归档过程
  • f 表示指定归档文件名

解压归档文件

tar -xvf archive_name.tar 默认当前目录

  • x 表示解压归档文件

压缩文件

tar -cvfz archive_name.tar.gz /path/to/source/*

  • z 表示使用gzip算法进行压缩,文件名也要指定为 *.gz

指定解压路径

tar -xvf archive.tar -C /path/to/target

  • C 指定解压路径

解压部分文件

tar -xvf archive.tar file1 file2

· 阅读需 2 分钟

记录一下tplink路由器设置与服务器网络环境配置

  • 问题

设备:ubuntu server 服务器,通过网线与路由器相连

将 ubuntu server 的 mac 地址与内网 ip 绑定

一开始是将这台服务器设置为路由器的 DMZ 主机,即路由器公网 ip 接收到的请求都会优先转发给这台服务器上,包括 22 端口的 ssh 连接等

之后便可以通过域名(tplink 提供的免费 dns 服务)远程连接到这台服务器了

但是服务器上 8080 端口启动的 nginx 服务,内网可以正确访问,但公网访问不了

检查了 ufw ,允许 nginx HTTP 以及 8080 端口的访问

怀疑是路由器设置问题,又在路由器8080端口创建了虚拟服务器,将公网8080端口的请求转发给服务器内网ip的8080端口

还是不行

  • 解决

后面为了安全考虑,用虚拟服务器的功能替代了 DMZ 主机功能,开放 22、21 等远程端口,目前远程连接一切正常

此时又尝试了下 8080 端口的虚拟服务,还是不行

最后改了一组端口映射(1234:8080),就可以了,可能 8080 端口是被内网某个设备或应用占用了。。

  • 总结:ufw 开启端口,路由器利用虚拟服务器功能做请求端口转发(8080:8080),如果不通,可以换一组端口映射试试(1234:8080),尽量使用虚拟服务器功能替代 DMZ 主机功能

· 阅读需 1 分钟

asdfasdfasdf

提示

Use the power of React to create interactive blog posts.

<button onClick={() => alert('button clicked!')}>Click me!</button>