记一次safari白屏问题排查

背景

在项目中遇到 safari 浏览器中无法页面白屏,但是控制台无任何异常信息,部分输出日志正常的现象

问题猜测

  • 编译配置问题,导致不支持此版本的浏览器
  • 由于某些原因导致代码没有执行下去,没有执行到组件渲染

问题验证

编译问题

通过配置一个最小化的 demo ,发现能够正常在 safari 中执行,估排除编译配置问题

代码问题

通过注释掉代码和输出日志的方式逐步缩小问题代码范围,然后定位具体问题,但是由于

  • 代码引用混乱,存在循环引用的情况
  • 代码过于复杂,存在顶级的 await 导致代码执行存在异步情况

考虑通过研究 js 执行时序情况,结合上述手段定位代码的最后执行位置,帮助定位问题范围

问题确定

通过排查发现问题位于一个顶层的 await 代码初始化 IndexedDB

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { openDB } from 'idb';

// 问题代码
const db = await openDB(name, version, {
upgrade(db, oldVersion, newVersion, transaction, event) {
// …
},
blocked(currentVersion, blockedVersion, event) {
// …
},
blocking(currentVersion, blockedVersion, event) {
// …
},
terminated() {
// …
},
});

通过研究 idb 源代码发现只是简单的将 IndexedDB 的操作转化为 promise 的方式,通过 debug 的方式发现初始化的时候未能够正确回调,通过查阅相关资料发现 macOS Big Sur 11.4 和 iOS 14.6 上的 Safari 存在一个严重错误,导致 IndexedDB 请求丢失且无法解决。此问题已在 Safari 14.7 中修复。

问题修复

可以通过 safari-14-idb-fix 修复问题,通过定时检测的方式当 IndexedDB 初始化完毕之后再初始化 IndexedDB 的相关操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* Work around Safari 14 IndexedDB open bug.
*
* Safari has a horrible bug where IDB requests can hang while the browser is starting up. https://bugs.webkit.org/show_bug.cgi?id=226547
* The only solution is to keep nudging it until it's awake.
*/
export default function idbReady(): Promise<void> {
const isSafari =
!navigator.userAgentData &&
/Safari\//.test(navigator.userAgent) &&
!/Chrom(e|ium)\//.test(navigator.userAgent);

// No point putting other browsers or older versions of Safari through this mess.
if (!isSafari || !indexedDB.databases) return Promise.resolve();

let intervalId: number;

return new Promise<void>((resolve) => {
const tryIdb = () => indexedDB.databases().finally(resolve);
intervalId = setInterval(tryIdb, 100);
tryIdb();
}).finally(() => clearInterval(intervalId));
}

最终代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import idbReady from 'safari-14-idb-fix';
await idbReady().then(async () => {
// Safari has definitely figured out where IndexedDB is.// You can use IndexedDB as usual.
const db = await openDB(name, version, {
upgrade(db, oldVersion, newVersion, transaction, event) {
// …
},
blocked(currentVersion, blockedVersion, event) {
// …
},
blocking(currentVersion, blockedVersion, event) {
// …
},
terminated() {
// …
},
});
return db;
});

参考

正则

有限状态机

定义

有限状态机是一个五元组 (Q, Σ ,δ, q0, F):

  • Q 是一个有穷集合,叫 状态集
  • Σ 是一个有穷集合,叫 字母表
  • δ : Q × Σ → Q 是 转移函数
  • q0 ∈ Q 是 起始状态.
  • F ⊆ Q 是 接受状态

执行流程

从开始状态,根据不同的输入,自动进行状态转移的过程,直到进入接受状态为止

确定有限状态自动机(DFA)

DFA 的每一个状态对于字母表中的每一个符号总是恰好有一个转移箭头射出。其确定性在于,在一个状态下,输入一个符号,一定是转移到确定的状态,没有其他的可能性

非确定有限状态自动机(NFA)

NFA 中每一个状态对于字母表的每一个符号(如:0/1)可能有多个箭头射出。其不确定性在于,在一个状态下,输入一个符号,可能转移到 n 个状态,出现了多种状态转移。另外 NFA 中箭头的标号可以是 ε(空转移,即:未输入任何符号也可转移到另一个状态)

组成部分

  • 解析器:负责解析正则表达式字符串,将其转换为内部表示形式,如状态机、有向图等。
  • 状态机:根据解析器生成的内部表示形式,构建一个有限状态自动机,用于在文本中搜索匹配项。
  • 匹配器:利用状态机在输入文本中进行匹配,根据正则表达式的模式和规则,找到符合条件的子串。
  • 捕获器:用于捕获匹配过程中产生的结果,如分组捕获、非捕获组等。
  • 替换器:用于执行替换操作,根据正则表达式中的替换规则,将匹配的子串替换为新的内容。

执行流程

执行流程

代码实现思路

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
class Value {
constructor(value) {
this.value = value
}

equal = (char) => {
return char === this.value
}
}

class Token {
constructor(value, meta) {
this.value = new Value(value)
this.meta = meta
}

matchMeta = (c) => {
return this.meta && this.value.equal(c)
}

matchChar = (c) => {
return !this.meta && this.value.equal(c)
}

match = (c) => {
if (this.matchMeta('.')) {
return typeof c !== 'undefined'
} else {
return this.matchChar(c)
}
}
}

class MyPattern {
constructor(tokens, matchString) {
this.tokens = tokens
this.matchString = matchString
}

matchStar = (i, j) => {
const token = this.tokens[i]
const ch = this.matchString[j]
if (token && token.match(ch) && this.matchStar(i, j + 1)) {
return true
} else {
return this.matchStr(i + 2, j)
}
}

matchStr = (i, j) => {
const token = this.tokens[i]
const nextToken = this.tokens[i + 1]
if (token && token.matchMeta('$')) {
return j >= this.matchString.length
} else if (token && nextToken && nextToken.matchMeta('*')) {
return this.matchStar(i, j)
} else if (token && token.match(this.matchString[j])) {
return this.matchStr(i + 1, j + 1)
}

return false
}

match() {
if (this.matchStr(1, 0)) {
return true
}

return false
}
}

class MyRegexp {
constructor(regexp) {
this.regexp = regexp
}

compile = () => {
this.tokens = []
for (let i = 0; i < this.regexp.length; i++) {
switch (this.regexp[i]) {
case '*': {
this.tokens.push(new Token('*', true))
break
}
case '^': {
this.tokens.push(new Token('^', true))
break
}
case '$': {
this.tokens.push(new Token('$', true))
break
}
case '.': {
this.tokens.push(new Token('.', true))
break
}
default: {
this.tokens.push(new Token(this.regexp[i], false))
break
}
}
}
}

match = (str) => {
if (!this.tokens) {
this.compile()
}
return new MyPattern(this.tokens, str).match()
}
}

参考

zustand

一个精简、高效且可扩展的状态管理方案,它巧妙地运用了简化的 Flux 原则。该方案提供了一套基于 Hooks 的直观 API,既简洁易用,又避免了冗余代码和强制性的框架偏好,让开发者能够更专注于业务逻辑的实现。

Flux 原则

Flux 原则主要围绕“单向数据流”这一核心概念展开,旨在通过清晰的数据流和组件间的解耦来提高应用的可维护性和可扩展性。

单向数据流

  • 定义:在 Flux 应用中,数据的变化严格遵循单一方向流动的原则,即数据只能从一个源头流出,经过一系列的处理后,最终更新到视图层进行展示。
  • 作用:单向数据流有助于减少数据流向的复杂性,使得数据的变化更加可预测和易于追踪。

核心组成部分

Flux 架构通常包括四个核心组成部分:View(视图)、Action(动作)、Dispatcher(分发器)和 Store(存储器)。

  • View(视图):负责渲染 UI 界面,并通过用户交互产生 Action。
  • Action(动作):是一个包含了类型(type)和数据的对象,用于描述发生了什么。Action 是数据变化的唯一来源。
  • Dispatcher(分发器):接收 Action,并将其分发给所有已注册的回调函数。Dispatcher 本身不存储任何数据或状态。
  • Store(存储器):负责存储应用的状态,并根据接收到的 Action 来更新状态。Store 是应用状态的唯一来源。

工作流程

工作流程

  • 当用户在视图层进行交互时,会产生一个 Action。
  • Action 被发送到 Dispatcher。
  • Dispatcher 将 Action 广播给所有已注册的 Store。
  • Store 根据 Action 的类型和内容来更新自身的状态。
  • Store 更新状态后,会通知视图层进行相应的渲染。

优点

  • 预测性:由于数据变化遵循严格的单向流动,因此可以更容易地预测应用的状态变化。
  • 解耦:View、Action、Dispatcher 和 Store 之间相对独立,降低了组件间的耦合度。
  • 可维护性:清晰的数据流和组件间的解耦使得应用更加容易维护和扩展。

Hooks

内部通过 useSyncExternalStore 实现,useSyncExternalStore 一个让你订阅外部 storeReact Hook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// todoStore.js
// 这是一个第三方 store 的例子,
// 你可能需要把它与 React 集成。

// 如果你的应用完全由 React 构建,
// 我们推荐使用 React state 替代。

let nextId = 0
let todos = [{ id: nextId++, text: 'Todo #1' }]
let listeners = []

export const todosStore = {
addTodo() {
todos = [...todos, { id: nextId++, text: 'Todo #' + nextId }]
emitChange()
},
subscribe(listener) {
listeners = [...listeners, listener]
return () => {
listeners = listeners.filter((l) => l !== listener)
}
},
getSnapshot() {
return todos
},
}

function emitChange() {
for (let listener of listeners) {
listener()
}
}

// 使用
import { useSyncExternalStore } from 'react'
import { todosStore } from './todoStore.js'

export default function TodosApp() {
const todos = useSyncExternalStore(
todosStore.subscribe,
todosStore.getSnapshot
)
return (
<>
<button onClick={() => todosStore.addTodo()}>Add todo</button>
<hr />
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</>
)
}

使用

支持能力

  • 异步请求
  • 组件外订阅更新
  • 框架无关
  • Immer 支持
  • 持久化
  • Reducer
  • devtools

函数式组件

1
2
3
4
5
6
7
8
9
10
11
12
import { create } from 'zustand'

const useBearStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
}))

function BearCounter() {
const bears = useBearStore((state) => state.bears)
return <h1>{bears} around here ...</h1>
}

类组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { create } from 'zustand'

const useBearStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
}))

class BearCounter extends React.Component {
componentDidMount() {
this.unSub = useBearStore.subscribe(this.handleSubscribe)
}

componentWillUnmount() {
this.unSub()
}

handleSubscribe = (store) => {
this.setState({ bears: store.bears })
}

render() {
const { bears } = this.state
return <h1>{bears} around here ...</h1>
}
}

实现

创建 store

利用闭包构建一个 store ,通过发布订阅的方式实现更新的监听

工作流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const createStoreImpl: CreateStoreImpl = (createState) => {
type TState = ReturnType<typeof createState>
type Listener = (state: TState, prevState: TState) => void
// 闭包记录 state
let state: TState
// 记录订阅的数组
const listeners: Set<Listener> = new Set()
// 更新 state ,触发事件监听,state 支持函数更新
const setState: StoreApi<TState>['setState'] = (partial, replace) => {
const nextState =
typeof partial === 'function'
? (partial as (state: TState) => TState)(state)
: partial
if (!Object.is(nextState, state)) {
const previousState = state
state =
replace ?? (typeof nextState !== 'object' || nextState === null)
? (nextState as TState)
: Object.assign({}, state, nextState)
listeners.forEach((listener) => listener(state, previousState))
}
}

// 获取 state 状态
const getState: StoreApi<TState>['getState'] = () => state

// 获取初始化状态
const getInitialState: StoreApi<TState>['getInitialState'] = () =>
initialState

// 订阅状态更新,返回移除事件监听
const subscribe: StoreApi<TState>['subscribe'] = (listener) => {
listeners.add(listener)
// Unsubscribe
return () => listeners.delete(listener)
}

const api = { setState, getState, getInitialState, subscribe }
// 初始化 state
const initialState = (state = createState(setState, getState, api))
return api as any
}

React 订阅更新

利用 useSyncExternalStore 订阅更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export function useStore<TState, StateSlice>(
api: ReadonlyStoreApi<TState>,
selector: (state: TState) => StateSlice = identity as any
) {
// 订阅更新,通过 selector 获取对应的数据
const slice = React.useSyncExternalStore(
api.subscribe,
() => selector(api.getState()),
() => selector(api.getInitialState())
)
// debug 时使用,输出 store 数据
React.useDebugValue(slice)
return slice
}

中间件

通过高阶函数的方式实现

1
2
3
4
5
6
7
8
9
10
11
const reduxImpl = (reducer, initial) => (set, _get, api) => {
// 实现 dispatch
api.dispatch = (action) => {
set((state) => reducer(state, action), false, action)
return action
}
// 标识来源,目前主要用于 devtools 中
api.dispatchFromDevtools = true

return { dispatch: (...a) => api.dispatch(...a), ...initial }
}

参考

typescript自定义实现

介绍

TypeScript 是微软开发推出的语言,是 JavaScript 的超集,补充了静态类型检查等功能
大多数情况下,TypeScript 需要编译为 JavaScript 再运行

类型

模块

用 import 和 export 来导入和创建模块

1
2
3
4
// test.ts
export function test() {
console.log('hello world')
}

命名空间

用 namespace 将具有相似功能或属性的类、接口等进行分组,避免命名冲突的方式

1
2
3
4
5
6
7
8
9
10
// test.ts
namespace Test {
export function test() {
console.log('hello world')
}
}

// 使用时
/// <reference path="test.ts" />
Test.test()

类型守卫

运行时的类型断言

1
2
3
4
const test: any = 'sss'
if (typeof test === 'string') {
// string 类型时
}

Interface

接口定义,可以用来定义接口、函数和数组

1
2
3
4
5
6
7
8
9
// 函数
interface Fun {
(a: number): void
}

// 数组
interface Arr {
[index: number]: string
}

装饰器

装饰器是一种特殊类型的声明,可以附加到类、方法、访问符、属性或参数上,以修改其行为。在 TypeScript 中,装饰器提供了一种在声明时定义如何处理类的方法、属性或参数的机制。

  • 类装饰器
1
2
3
4
5
6
7
8
9
10
function classDecorater<T extends { new (...args: any): {} }>(constructor: T) {
return class extends constructor {
public test = 'xxxx'
}
}

@classDecorater
class Test {
public aaa = 'aaa'
}
  • 方法或属性装饰器
1
2
3
4
5
6
7
8
9
10
11
function propDecorater() {
return function (obj: any, key: string, config: PropertyDescriptor) {
// 装饰器逻辑
console.log('obj', obj, 'key', key, 'config', config)
}
}

class Test {
@propDecorater()
aaa: string
}

内置类型实现

  • Exclude
1
type MyExclude<T, K> = T extends K ? never : T
  • Include
1
type MyInclude<T, K> = T extends K ? T : never
  • Omit
1
type MyOmit<T extends Object, K> = Pick<T, Exclude<keyof T, K>>
  • Partial
1
type MyPartial<T extends Object> = { [K in keyof T]?: T[K] }
  • Pick
1
2
3
type MyPick<T extends Object, K> = {
[M in MyInclude<keyof T, K>]: T[M]
}
  • Required
1
type MyRequired<T> = { [K in keyof T]-?: T[K] }
  • Parameters
1
2
3
type MyParameters<T extends (arg: any) => any> = T extends (arg: infer K) => any
? K
: never
  • ConstructorParameters
1
type MyConstructorParameters<T extends abstract new(arg: any): any > = T extends abstract new(arg: infer K): any ? K : never
  • ReturnType
1
2
3
type MyReturnType<T extends (arg: any) => any> = T extends (arg: any) => infer K
? K
: never

场景

  • 提取对象中的值类型
1
2
3
function getPropType<T, K extends keyof T>(obj: T, key: K) {
return obj[key]
}
  • 非空校验
1
type NotNull<T> = T extends null | undefined ? never : T
  • 模版字符串
1
2
3
4
5
function call<T extends `aaa_${string}`>(a: T) {
console.log('aa', a)
}

call('xxx') // Argument of type '"xxx"' is not assignable to parameter of type '`aaa_${string}`'.
  • Infer
1
2
3
4
5
6
7
type test<T extends number> = `${T}` extends `-${infer M}` ? never : T

function testCall<T extends number>(a: test<T>) {
console.log('testCall', a)
}

testCall(-1) // Argument of type 'number' is not assignable to parameter of type 'never'.

参考

业务-大牌试用

商品

生命周期

商品生命周期

小样

小样商品

频道定位

试用频道是一个商城内派样的主阵地,通过派发小样商品引导商家投入更多资源,促进正装商品的销售

价值

用户

能够在频道内买到正品好价的大牌小样商品

平台

  • 更多的新客
  • 更多的订单
  • 更多的复访复购

商家

  • 在平台上增加更多的派样订单
  • 通过派样商品带来更多的会员,沉淀更多的私域用户
  • 通过派样撬动更多的复访复购

指标

  • 派样量(订单量)
  • 入会用户数
  • 复购规模(复购 GMV)

频道内容

商品

会员商品

通过购买试用装引导用户入会,为商家带来更多的私域流量,后续通过发送会员券的方式来引导用户购买正装,促进用户的转化和复访复购

无门槛商品

  • 通过无门槛派样来建立品牌或商品在用户心里的口碑,促进正装商品的销售和用户的复访复购
  • 通过控制各个环节的成本,把商品拆分成小样的方式进行销售以达到获利目的

流量来源

  • 商城工具区等固定入口的流量
  • 提单页、支付成功页、物流详情页等渠道的引流流量
  • 抖音内的视频广告流等渠道的投流流量

商品来源

  • 通过平台圈选的商品,频道商品的主要来源,但商品的质量和价格优势不明显
  • 通过招商平台招商的商品,竞争力比较好,价格和质量都有保证

平台玩法

  • 普通的商品 feed 流
  • 优质招商商品组成的限量购玩法
  • 分人群发券引导用户购买试用装,通过发券的方式来提高商品的价格力和用户的转化
  • 正在规划建设中的 N 元 M 件玩法,通过让商家把商品入库,试用频道组合出库的方式提高派样量,降低派样成本
  • 大品牌新品上新活动

菜单设计

背景

站点菜单存在需要修改的情况,每次修改之后都需要发布应用代码,严重影响了开发迭代的效率

目标

整体站点是一个经典的顶部-侧边栏,实现一个菜单配置的功能,需要具有如下能力

  • 用户能够自主申请接入
  • 需要管理员进行审批才能生效
  • 生效前只能管理员和菜单所有者才能看到对应配置
  • 能够支持多级菜单
  • 能够支持菜单的自由排序
  • 菜单匹配需要支持精确匹配、前缀匹配和自定义匹配的方式

设计

整体流程

整体流程

数据库

选型

  • 菜单数据量少
  • 存在半结构化的数据类型(owners 可能支持多选和搜索)
  • 无复杂查询场景
  • 并发量不高

表设计

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE `Menu` (
`_id` number NOT NULL COMMENT '菜单 id,默认生成的唯一 id',
`order` number NOT NULL COMMENT '菜单顺序',
`pid` string NOT NULL COMMENT '父菜单 id,一级菜单的父 id 为 root',
`name` char(64) NOT NULL COMMENT '菜单名称',
`path` char(64) NOT NULL COMMENT '菜单路由',
`matchPrefix` boolean NOT NULL COMMENT '是否前缀匹配',
`hiddenLeftMenu` boolean NOT NULL COMMENT '是否隐藏左侧导航栏',
`owners` string[] NOT NULL COMMENT '菜单所有者',
`status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '状态 1: 待上线 10: 已上线',
`createTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '添加时间',
`updateTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='菜单管理'
  • order: 一级菜单 100-999,二级菜单为一级菜单顺序 * 1000 + (1-1000)的范围
  • matchPrefix: 是否前缀匹配,为了支持动态路由和其路径下的菜单匹配
  • hiddenLeftMenu: 是否隐藏左侧导航栏,部分场景无需左侧的菜单

接口设计

  • 菜单查询(分权限展示,未上线的菜单需要在菜单所有者和管理员的场景下能够展示出来)
  • 菜单新增
  • 菜单修改
  • 菜单上线(管理员)
  • 菜单删除(管理员)
  • 菜单排序

前端界面设计

需要支持以下功能

  • 支持菜单的叶子节点和非叶子节点
  • 支持排序

采用 antdtree 进行菜单的编辑功能

备注

菜单设计时需要考虑以下情况

  • 菜单设计时需要考虑排序
  • 权限设计
  • 路由匹配