目录

  1. jsscript
  2. 与 QuickJS 的对照
    1. 启动流程
    2. 数据结构
    3. 错误处理
    4. 更多
  3. CLI
  4. API
    1. 基本使用
    2. Feature
  5. 心里话

不知道有多少前端,曾经像我一样,尝试去学习 V8 让自己可以从底层更好的了解 JS 的实现。但是由于太复杂而放弃,光下载、编译、调试足够让人望而却步了,更别说你需要有足够的 C++ 经验。

幸运的是,Bellard 大佬不久便开源用纯 C 实现的轻量且高性能的 QuickJS,这让我重新燃起了学习的热情,这个项目简单到仅仅有高中的 C 语言知识和数据结构就可以阅读。

无论如何,其还是用 C 实现的,内部会有很多 C 语言的 hack 写法(为了性能),阅读起来也不是那么的流畅。正巧我就是研究小程序的,小程序内部限制了动态脚本的执行,不如顺势把 QuickJS 迁移到 TypeScript 上,这样在小程序中也可以做到动态脚本执行了!

于是,这个项目便诞生了,我将其命名为 jsscripthttps://github.com/XGHeaven/jsscript

jsscript

目前这个项目还在开发中,部分能力缺失,只是我个人能力有限,JS 引擎又是一个很大的内容,很难短时间内完全支持,但最终的目标是不变的。

在立项之初,我了解到社区内有很多类似的项目都可以实现相同的功能,所以在一开始,我就觉得尽量不合他们的特性或者能力重叠,而是将更多的精力放在学习、易用性上:

  • 原生 ES6、ESNext 支持。截止写文时,test262 覆盖率为 25%。

  • 易维护。项目用纯 TypeScript 编写,所有的操作函数和参数都有完整的类型提示。

  • 易阅读。代码核心逻辑和 QuickJS 几乎无异,核心 API 命名大部分也保持一致,数据结构有所不同,使之更加适合于 TypeScript。

  • 易修改。项目内几乎所有能力都是可插拔的,你甚至可以把所有内置的对象(例如 Array、Function)删除,变身成普通的表达式计算器,按需定制。

  • Tree Shaking 友好。代码全部以无副作用和函数的方式提供(部分封装会用 class),只要你不用,就不会被打包进去,体积随你所控。

  • 架构简易。所有的事件循环依赖外部环境(例如 Node、Browser)、部分内置对象的原型方法直接代理到外部环境、只支持严格模式等。

有人好奇,为啥我这里没有列出来性能呢?一般这种项目不都是要做到第一的性能么?

其实,自从我决定将可读性、易用性、灵活性作为首要目标的时候,性能就不再是核心目标了。原因也很简单,这两者很难兼得。当然,性能也会是考虑的点,但不是现在的目标。

与 QuickJS 的对照

启动流程

QuickJS 的基本启动流程可以简单理解为:

  • 创建 Runtime

    • Runtime 提供了执行 JavaScript 代码所需的基础设施和功能,如内存管理、垃圾收集、事件循环等。

    • Runtime 是整个 JavaScript 执行过程的基础,简单理解就是初始化一个引擎。

  • 然后通过 Runtime 创建 Context

    • Context 指的是 JavaScript 代码执行时的执行环境或上下文。

    • 这两者之间的关系是一对多的,也就是说一个 Runtime 可以创建多个 Context,Context 之间可以相互访问。

    • 之所以这样设计,是因为浏览器中有 iframe,如果没有 Context,每次创建一个 iframe,就需要起一个引擎,这是很浪费的。而引入 Context 可以避免创建多个引擎的情况。

  • 初始化 Context 内的对象和属性。会初始化各类内建对象和其原型方法,例如 Array、Boolean、String 等。

  • 通过 Eval 方法执行代码

在 jsscript 中,大部分流程还是一致的,只不过我们将最后 Eval 的过程拆分成了先编译后运行的过程

数据结构

在 QuickJS 中,所有的 JS 的值都会用一个叫做 JSValue 的结构体表示,它不和任何 Context 或者 Value 绑定,是一个独立的存在,具体的结构体定义这里就不再详细解释。而在 jsscript 中,自然也是沿用了一样的设定,唯一的不同是采用了 TypeScript 的 Tagged Union 而非 C 中的 union,并且采用了更加适合前端的命名规则。

1
2
3
4
export type JSSaftHostValue = JSNumberValue | JSBoolValue | JSStringValue | JSUndefinedValue | JSNullValue
export type JSHostValue = JSSaftHostValue | JSSymbolValue
export type JSInstrinsicValue = JSHostValue | JSObjectValue | JSSymbolValue
export type JSValue = JSInstrinsicValue | JSExpectionValue | JSTryContextValue

这里的细节可能会有些多,比如:

  • null 和对象其实是用不同的 tag 表示,虽然 typeof 这两者都返回的是 "object",但在引擎层面是不一样的

  • 所有的 Object 都是使用 JSObjectValue 所表示,不论是普通对象还是函数。当然,在 JSObjectValue 内也会有具体对象类型的细分,但这不在 JSValue 这个层面所讨论的内容。

错误处理

在 QuickJS 中,错误本身和 JS 内的实现是不同的,引擎将错误状态和错误内容拆分成了两部分:

  • 错误状态会通过一个特殊的 JSValue 实现,在 jsscript 内叫做 JSExceptionValue。

  • 错误内容则会直接存储在 runtime 内,也就是说每次运行完想去拿错误信息的时候,需要去 runtime 上拿,而不是直接从返回的 value 中获取。

所以在引擎的实现过程中,如果你想表示某个过程发生了错误,只需要:

  • 创建错误对象,并存储到 runtime 中

  • 返回 JSExceptionValue,告知调用者,这里有错误

所以,当我们尝试调用一个函数的时候,都需要先检查返回值是否为错误,虽然这很麻烦,但这是必要的做法。(Golang 开发者是不是很眼熟,捂脸)

1
2
3
4
5
6
7
8
9
10
11
12
function JSDiv(ctx, leftVal, rightVal) {
const left = JSToNumber(ctx, leftVal)
if (isExceptionValue(left)) {
// 检查错误,如果出错了,就直接返回就好
return left
}
const right = JSToNumber(ctx, rightVal)
if (isExceptionValue(right)) {
return right
}
return createNumberValue(left.value - right.value)
}

更多

更多内容可以看源码,包括如何创建值、新增属性、函数调用等。

进入 https://github.com/XGHeaven/jsscript/tree/main/src 文件夹之后,可以按照入口(Runtime)的方式阅读,也可以按照文件命名的方式,找寻自己好奇的那部分点进去看即可。

CLI

最简单的体验的方式,就是安装 CLI

1
2
3
4
5
# 全局安装
npm install @xgheaven/jsscript -g

# 运行脚本
jsscript run file.js
1
2
3
4
5
// script.js
const fn = () => {
console.log(1 + 1)
}
fn()

不过目前还有些问题还没有处理:

  • 不支持 REPL

  • 不支持模块,只能运行单一脚本

  • 部分 API 缺失,不确保能够运行所有脚本

API

目前 API 的定义都处于 unstable 状态,预计后续会大改,目前仅限于了解和学习即可,请勿在生产环境使用。

基本使用

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
import { Runtime, Features, parseScript, parseBytecode, toHostValue } from '@xgheaven/jsscript'

const runtime = new Runtime({
features: [
// 注入所有在 ECMA262 中定义的方法
new Features.ECMA262Feature(),
// 注入和系统相关,例如 setTimeout 等
new Features.OsFeature(),
],
})

const context = runtime.newContext()

const script = `<your-script>`;

// 直接解析成函数
const fn = parseScript(context, script)
// 或者分成两步,先解析为字节码,然后再将字节码解析成函数。
// 字节码是可序列化的,你可以将其 stringify 之后存储下来,方便下次直接使用
const bc = compileToBytecode(script)
const fn = parseBytecode(context, bc)

// 运行刚才生成的函数
const ret = context.run(fn)

// 返回值是 vm 内的值,需要通过 toHostValue 转换成 JS 可识别的对象
console.log(toHostValue(ret))

Feature

Feature 是灵活性的根本,vm 内的各种行为和功能,都可以通过 Feature 的方式进行组合和定制。

目前提供以下几种:

  • ECMA262Feature 提供 ECMA262 规范中提供的对象,例如 Array/Boolean/String 等构造函数和其原型方法。如果不引用这个,VM 环境内将不会有这些构造函数和相关的原型方法,但是并不影响其字面量的使用。

  • JobSchedulerFeature 用于提供 Promise 的任务调度,如果代码不曾使用 Promise,则无需引用。当然,你也可以定义自己的任务调度方法

  • BrowserFeature 用于一定程度上模拟浏览器的环境,例如 window 对象

  • OsFeature 提供和系统交互的一些方法,例如 setTimeout 等

除了以上几种,也可以自定义 Feature:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Feature, createNumberValue } from '@xgheaven/jsscript'

class DemoFeature extends Feature {
initContext(ctx) {
// Context 初始化回调,你可以注入任何你想要的东西
// 在 VM 内的全局对象中注册一个 xxx 的全局属性
ctx.defineGlobalValue('xxx', createNumberValue(1))
}

initRuntime(rt) {
// Runtime 初始化回调
}
}

const runtime = new Runtime({ features: [new DemoFeature] })

心里话

其实吧,这个项目主要还是以个人学习目的为准,目前没有准确的实用目标。如果你看完之后觉得一般般,我完全表示理解,因为本身就没有特别出众的功能和能力。

但如果你也对 JSVM 感兴趣,又奈何很难、没时间直接学习 QuickJS/V8,可以尝试来一起来学习和实现。

注释和共享

概要

本文主要讲解了下我平时在工作开发中遇到的关于 Hooks 的一些缺点和问题,并尝试配合 Mobx 解决这些问题的经历。我觉得两者的配合可以极大的降低开发过程中有可能出现的问题以及极大的提高开发体验,而且学习成本也是非常的低。如果你对 Hooks 以及 Mobx 有兴趣,想知道更进一步的了解,那么这篇文章适合你。这篇文章会介绍如下内容,方便你决定是否要仔细阅读,节省时间:

  • 本文不会介绍太过于基础的内容,你需要对 Mobx 以及 Hooks 有基础的了解

  • 本文介绍了平时开发中的一些最佳实践,方便小伙伴们对两者有更加深入的认识

  • 如果你使用过一部分 Mobx,但是不太了解如何和 Hooks 更好的合作,可以尝试来看看

另外 Hooks 本身真的就是一个理解上非常简单的东西,所以本文也不长,我也不喜欢去写什么万字长文,又不是写教程,而且读者看着标题就失去兴趣了。

Hooks 究竟有什么问题?

首先,在这里我不再说 Hooks 的优点,因为他的优点用过的人都清楚是怎么回事,这里主要讲解一下他存在的缺点,以及如何用 Mobx 来进行改进。

  • 依赖传染性 —— 这导致了开发复杂性的提高、可维护性的降低

  • 缓存雪崩 —— 这导致运行性能的降低

  • 异步任务下无法批量更新 —— 这也会导致运行性能的降低

换句话说,造成这种原因主要是因为 Hooks 每次都会创建一个全新的闭包,而闭包内所有的变量其实都是全新的。而每次都会创建闭包数据,而从性能角度来讲,此时缓存就是必要的了。而缓存又会牵扯出一堆问题。

说到底,也就是说没有一个公共的空间来共享数据,这个在 Class 组件中,就是 this,在 Vue3 中,那就是 setup 作用域。而 Hooks 中,除非你愿意写 useRef + ref.current 否则是没有办法找到共享作用域。

而 mobx 和 Hooks 的结合,可以很方便在 Hooks 下提供一个统一的作用域来解决上面遇到的问题,所谓双剑合并,剑走天下。

Hook1 useObserver

在传统的使用 mobx 的过程中,大家应该都知道 observer 这个 api,对需要能够响应式的组件用这个包裹一下。同样,这个 api 直接在 hooks 中依旧可以正常使用。 但是 hooks 并不推荐 hoc 的方式。自然,mobx 也提供了 hookify 的使用方式,那就是 useObserver

1
2
3
4
5
6
const store = observable({})
function App() {
return useObserver(() => {
return <div>{store.count}</div>
})
}

看到这里,相信使用过 mobx 的应该可以发现,useObserver 的使用几乎和 Class 组件的 render 函数的使用方式一致。事实上也确实如此,而且他的使用规则也很简单,直接把需要返回的 Node 用该 hooks 包裹后再返回就可以了。

经过这样处理的组件,就可以成功监听数据的变化,当数据变化的时候,会触发组件的重渲染。至此,第一个 api 就了解完毕了

Hook2 useLocalStore

简单来讲,就是在 Hooks 的环境下封装的一个更加方便的 observable。就是给他一个函数,该函数返回一个需要响应式的对象。可以简单的这样理解

1
2
3
const store = useLocalStore(() => ({key: 'value'}))
// equal
const [store] = useState(() => observable({key: 'value'}))

然后就没有了,极其简单的一个 api 使用。而后面要讲的一些最佳实践更多的也是围绕这个展开,后文简化使用 local store 代指。

这两个 API 能带来什么?

简单来讲,就是在保留 Hooks 的特性的情况下,解决上面 hooks 所带来的问题。

第一点,由于 local store 的存在,作为一个不变的对象存储数据,我们就可以保证不同时刻对同一个函数的引用保持不变,不同时刻都能引用到同一个对象或者数据。不再需要手动添加相关的 deps。由此可以避免 useCallback 和 useRef 的过度使用,也避免很多 hooks 所面临的的闭包的坑(老手请自动忽略)。依赖传递性和缓存雪崩的问题都可以得到解决

直接上代码,主要关注注释部分

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
// 要实现一个方法,只有当鼠标移动超过多少像素之后,才会触发组件的更新
// props.size 控制移动多少像素才触发回调
function MouseEventListener(props) {
const [pos, setPos] = useState({x: 0, y: 0})
const posRef = useRef()
const propsRef = useRef()
// 这里需要用 Ref 存储最新的值,保证回调里面用到的一定是最新的值
posRef.current = pos
propsRef.current = propsRef

useEffect(() => {
const handler = (e) => {
const newPos = {x: e.xxx, y: e.xxx}
const oldPos = posRef.current
const size = propsRef.current.size
if (
Math.abs(newPos.x - oldPos.x) >= size
|| Math.abs(newPos.y - oldPos.y) >= size
) {
setPos(newPos)
}
}
// 当组件挂载的时候,注册这个事件
document.addEventListener('mousemove', handler)
return () => document.removeEventListener('mousemove', handler)
// 当然这里也可以监听 [pos.x, pos.y],但是性能不好
}, [])

return (
props.children(pos.x, pos.y)
)
}

// 用 mobx 改写之后,这种使用方式远比原生 hooks 更加符合直觉。
// 不会有任何 ref,任何 current 的使用,任何依赖的变化
function MouseEventListenerMobx(props) {
const state = useLocalStore(target => ({
x: 0,
y: 0,
handler(e) {
const nx = e.xxx
const ny = e.xxx
if (
Math.abs(nx - state.x) >= target.size ||
Math.abs(ny - state.y) >= target.size
) {
state.x = nx
state.y = ny
}
}
}), props)

useEffect(() => {
document.addEventListener('mousemove', state.handler)
return () => document.removeEventListener('mousemove', state.handler)
}, [])

return useObserver(() => props.children(state.x, state.y))
}

第二,就是针对异步数据的批量更新问题,mobx 的 action 可以很好的解决这个问题

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
// 组件挂载之后,拉取数据并重新渲染。不考虑报错的情况
function AppWithHooks() {
const [data, setData] = useState({})
const [loading, setLoading] = useState(true)
useEffect(async () => {
const data = await fetchData()
// 由于在异步回调中,无法触发批量更新,所以会导致 setData 更新一次,setLoading 更新一次
setData(data)
setLoading(false)
}, [])
return (/* ui */)
}

function AppWithMobx() {
const store = useLocalStore(() => ({
data: {},
loading: true,
}))
useEffect(async () => {
const data = await fetchData()
runInAction(() => {
// 这里借助 mobx 的 action,可以很好的做到批量更新,此时组件只会更新一次
store.data = data
store.loading = false
})
}, [])
return useObserver(() => (/* ui */))
}

不过也有人会说,这种情况下用 useReducer 不就好了么?确实,针对这个例子是可以的,但是往往业务中会出现很多复杂情况,比如你在异步回调中要更新本地 store 以及全局 store,那么就算是 useReducer 也要分别调用两次 dispatch ,同样会触发两次渲染。而 mobx 的 action 就不会出现这样的问题。// 如果你强行 ReactDOM.unstable_batchedUpdates 我就不说啥了,勇士受我一拜

Quick Tips

知道了上面的两个 api,就可以开始愉快的使用起来了,只不过这里给大家一下小 tips,帮助大家更好的理解、更好的使用这两个 api。(不想用而且也不敢用「最佳实践」这个词,感觉太绝对,这里面有一些我自己也没有打磨好,只能算是 tips 来帮助大家拓展思路了)

no this

对于 store 内的函数要获取 store 的数据,通常我们会使用 this 获取。比如

1
2
3
4
5
6
7
8
9
const store = useLocalStore(() => ({
count: 0,
add() {
this.count++
}
}))

const { add } = store
add() // boom

这种方式一般情况下使用完全没有问题,但是 this 依赖 caller,而且无法很好的使用解构语法,所以这里并不推荐使用 this,而是采用一种 no this 的准则。直接引用自身的变量名

1
2
3
4
5
6
7
8
9
const store = useLocalStore(() => ({
count: 0,
add() {
store.count++
}
}))

const { add } = store
add() // correct,不会导致 this 错误
  • 避免 this 指向的混乱

  • 避免在使用的时候直接解构从而导致 this 丢失

  • 避免使用箭头函数直接定义 store 的 action,一是没有必要,二是可以将职责划分的更加清晰,那些是 state 那些是 action

source

在某些情况下,我们的 local store 可能需要获取 props 上的一些数据,而通过 source 可以很方便的把 props 也转换成 observable 的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
function App(props) {
const store = useLocalStore(source => ({
doSomething() {
// source 这里是响应式的,当外界 props 发生变化的时候,target 也会发生变化
if (source.count) {}
// 如果这里直接用 props,由于闭包的特性,这里的 props 并不会发生任何变化
// 而 props 每次都是不同的对象,而 source 每次都是同一个对象引用
// if (props.count) {}
}
// 通过第二个参数,就可以完成这样的功能
}), props)
// return Node
}

当然,这里不仅仅可以用于转换 props,可以将很多非 observable 的数据转化成 observable 的,最常见的比如 Context、State 之类,比如

1
2
3
4
5
6
7
const context = useContext(SomeContext)
const [count, setCount] = useState(0)
const store = useLocalStore(source => ({
getCount() {
return source.count * source.multi
}
}), {...props, ...context, count})

自定义 observable

有的时候,默认的 observable 的策略可能会有一些性能问题,比如为了不希望针对一些大对象全部响应式。可以通过返回自定义的 observable 来实现。

1
2
3
4
5
6
7
const store = useLocalStore(() => observable({
hugeObject: {},
hugeArray: [],
}, {
hugeObject: observable.ref,
hugeArray: observable.shallow,
}))

甚至你觉得自定义程度不够的话,可以直接返回一个自定义的 store

1
const store = useLocalStore(() => new ComponentStore())

类型推导

默认的使用方式下,最方便高效的类型定义就是通过实例推导,而不是通过泛型。这种方式既能兼顾开发效率也能兼顾代码可读性和可维护性。当然了,你想用泛型也是可以的啦

1
2
3
4
5
6
7
8
9
// 使用这种方式,直接通过对象字面量推导出类型
const store = useLocalStore(() => ({
todos: [] as Todo[],
}))

// 当然你可以通过泛型定义,只要你不觉得烦就行
const store = useLocalStore<{
todos: Todo[]
}>(() => ({todos: []}))

但是这个仅仅建议用作 local store 的时候,也就是相关的数据是在本组件内使用。如果自定义 Hooks 话,建议还是使用预定义类型然后泛型的方式,可以提供更好的灵活性。

memo?

当使用 useObserver api 之后,就意味着失去了 observer 装饰器默认支持的浅比较 props 跳过渲染的能力了,而此时需要我们自己手动配合 memo 来做这部分的优化

另外,memo 的性能远比 observer 的性能要高,因为 memo 并不是一个简单的 hoc

1
2
3
4
5
6
export default memo(function App(){
const xxx = useLocalStore(() => ({}))
return useObserver(() => {
return (<div/>)
})
})

不再建议使用 useCallback/useRef/useMemo 等内置 Hooks

上面的这几个 Hooks 都可以通过 useLocalStore 代替,内置 Hooks 对 Mobx 来说是毫无必要。而且这几个内置 api 的使用也会导致缓存的问题,建议做如下迁移

  • useCallback 有两种做法

    • 如果函数不需要传递给子组件,那么完全没有缓存的必要,直接删除掉 useCallback 即可,或者放到 local store 中也可以

    • 如果函数需要传递给子组件,直接放到 local store 中即可。

  • useMemo 直接放到 local store,通过 getter 来使用

useEffect or reaction?

经常使用 useEffect 知道他有一个功能就是监听依赖变化的能力,换句话说就是可以当做 watcher 使用,而 mobx 也有自己的监听变化的能力,那就是 reaction,那么究竟使用哪种方式更好呢?

这边推荐的是,两个都用,哈哈哈,没想到吧。

1
2
3
useEffect(() =>
reaction(() => store.count, () => console.log('changed'))
, [])

说正经的,针对非响应式的数据使用 useEffect,而响应式数据优先使用 reaction。当然如果你全程抛弃原生 hooks,那么只用 reaction 也可以的。

组合?拆分?

逻辑拆分和组合,是 Hooks 很大的一个优势,在 mobx 加持的时候,这个有点依旧可以保持。甚至在还更加简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function useCustomHooks() {
// 推荐使用全局 Store 的规则来约束自定义 Hooks
const store = useLocalStore(() => ({
count: 0,
setCount(count) {
store.count = count
}
}))
return store
}

function App() {
// 此时这个 store 你可以从两个角度来思考
// 第一,他是一个 local store,也就是每一个都会初始化一个新的
// 第二,他可以作为全局 store 的 local 化,也就是你可以将它按照全局 store 的方式来使用
const store = useCustomHook()
return (
// ui
)
}

App Store

Mobx 本身就提供了作为全局 Store 的能力,这里只说一下和 Hooks 配合的使用姿势

当升级到 mobx-react@6 之后,正式开始支持 hooks,也就是你可以简单的通过这种方式来使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export function App() {
return (
<Provider sa={saStore} sb={sbStore}>
<Todo/>
</Provider>
)
}

export function Todo() {
const {sa, sb} = useContext(MobxProviderContext)
return (
<div>{sa.foo} {sb.bar}</div>
)
}

Context 永远是数据共享的方案,而不是数据托管的方案,也就是 Store

这句话怎么理解数据共享和组件通讯呢?举个例子

  • 有一些基础的配置信息需要向下传递,比如说 Theme。而子组件通常只需要读取,然后做对应的渲染。换句话说数据的控制权在上层组件,是上层组件共享数据给下层组件,数据流通常是单向的,或者说主要是单向的。这可以说是数据共享

  • 而有一些情况是组件之间需要通讯,比如 A 组件需要修改 B 组件的东西,这种情况下常见的做法就是将公共的数据向上一层存放,也就是托管给上层,但是使用控制权却在下层组件。其实这就是全局 Store,也就是 Redux 这类库做的事情。可以看出来数据流通常是双向的,这就可以算作数据托管

曾经关注过 Hooks 的发展,发现很多人在 Hooks 诞生的时候开始尝试用 Context + useReducer 来替换掉 Redux,我觉得这是对 Context 的某种曲解。

原因就是 Context 的更新问题,如果作为全局 Store,那么一定要在根组件上挂载,而 Context 检查是否发生变化是通过直接比较引用,那么就会造成任意一个组件发生了变化,都会导致从 Provider 开始的整个组件树发生重新渲染的情况。

1
2
3
4
5
6
7
8
9
10
function App() {
const [state, dispatch] = useReducer(reducer, init)
return (
// 每次当子组件调用 dispatch 之后,会导致 state 发生变化,从而导致 Provider 的 value 变化
// 进而让所有的子组件触发刷新
<GlobalContext.Provider value={{...state, dispatch}}>
{/* child node */}
</GlobalContext.Provider>
)
}

而如果你想避免这些问题,那就要再度封装一层,这和直接使用 Redux 也就没啥区别了。

主要是 Context 的更新是一个性能消耗比较大的操作,当 Provider 检测到变化的时候,会遍历整颗 Fiber 树,比较检查每一个 Consumer 是否要更新。

专业的事情交给专业的来做,使用 Redux Mobx 可以很好的避免这个问题的出现。

如何写好一个 Store

知道 Redux 的应该清楚他是如何定义一个 Store 吧,官方其实已经给出了比较好的最佳实践,但在生产环境中,使用起来依旧很多问题和麻烦的地方。于是就诞生了很多基于 Redux 二次封装的库,基本都自称简化了相关的 API 的使用和概念,但是这些库其实大大增加了复杂性,引入了什么 namespace/modal 啥的,我也记不清了,反正看到这些就自动劝退了,不喜欢在已经很麻烦的东西上为了简化而做的更加麻烦。

而 Mobx 这边,官方也有了一个很好的最佳实践。我觉得是很有道理,而且是非常易懂易理解的。

但还是那个问题,官方在有些地方还是没有进行太多的约束,而在开发中也遇到了类似的问题,所以这里在基于官方的框架下有几点意见和建议:

  • 保证所有修改 store 的操作都只能在 store 内部操作,也就是说你要通过调用 store 上的 action 方法更新 store,坚决不能在外部直接修改 store 的 property 的值。

  • 保证 store 的可序列化,方便 SSR 的使用以及一些 debug 的功能

    • 类构造函数的第一个参数永远是初始化的数据,并且类型保证和 toJSON 的返回值的类型一致

    • 如果 store 不定义 toJSON 方法,那么要保证 store 中的数据不存在不可序列化的类型,比如函数、DOM、Promise 等等类型。因为不定义默认就走 JSON.stringify 的内置逻辑了

  • store 之间的沟通通过构造函数传递实现,比如 ThemeStore 依赖 GlobalStore,那么只需要在 ThemeStore 的构造参数中传入 GlobalStore 的实例即可。不过说到这里,有的人应该会想到,这不就是手动版本的 DI 么。没错,DI 是一个很好的设计模式,但是在前端用的比较轻,就没必要引入库来管理了,手动管理下就好了。也通过这种模式,可以很方便的实现 Redux 那种 namespace 的概念以及子 store

  • 如果你使用 ts 开发,那么建议将实现和定义分开,也就是说分别定义一个 interface 和 class,class 继承 Interface,这样对外也就是组件内只需要暴露 interface 即可。这样可以很方便的隐藏一些你不想对外部暴露的方法,但内部却依旧要使用的方法。还是上面的例子,比如 GlobalStore 有一个属性是 ThemeStore 需要获取的,而不希望组件获取,那么就可以将方法定义到 class 上而非 interface 上,这样既能有良好的类型检查,又可以保证一定的隔离性。

是的,基本上这样就可以写好一个 Store 了,没有什么花里胡哨的概念,也没有什么乱七八糟的工具,约定俗成就足以。我向来推崇没有规则就是最大的规则,没有约束就是最大的约束。很多东西能约定俗成就约定俗成,落到纸面上就足够了。完全没必要做一堆 lint/tools/library 去约束,既增加了前期开发成本,又增加了后期维护成本,就问问你司内部有多少 dead 的工具和库?

俗话说的话,「秦人不暇自哀而后人哀之,后人哀之而不鉴之,亦使后人而复哀后人也」,这就是现状(一巴掌打醒)

不过以上的前提是要求你们的开发团队有足够的开发能力,否则新手很多或者同步约定成本高的话,搞个库去约束到也不是不行(滑稽脸)

缺点?

说了这么多,也不是说是万能的,有这个几个缺点

  • 针对一些就带状态的小组件,性能上还不如原生 hooks。可以根据业务情况酌情针对组件使用原生 hooks 还是 mobx hooks。而且针对小组件,代码量可能相应还是增多。因为每次都要包裹 useObserver 方法。

  • mobx 就目前来看,无法很好在未来使用异步渲染的功能,虽然我觉得这个功能意义不大。某种程度上说就是一个障眼法,不过这个思路是值得一试的。

  • 需要有一定 mobx 的使用基础,如果新手直接上来写,虽然能避免很多 hooks 的坑,但是可能会踩到不少 mobx 坑

总结

Mobx 在我司的项目中已经使用了很久了,但 Hooks 也是刚刚使用没多久,希望这个能给大家帮助。也欢迎大家把遇到的问题一起说出来,大家一起找解决办法。

我始终觉得基于 Mutable 的开发方式永远是易于理解、上手难度最低的方式,而 Immutable 的开发方式是易维护、比较稳定的方式。这两者没必要非此即彼,而 Mobx + React 可以认为很好的将两者整合在一起,在需要性能的地方可以采用 Immutable 的方式,而在不需要性能的地方,可以用 Mutable 的方式快速开发。

当然了,你就算不用 Mobx 也完全没有问题,毕竟原生的 Hooks 的坑踩多了之后,习惯了也没啥问题,一些小项目,我也会只用原生 Hooks 的(防杠声明)。

注释和共享

目录

  1. A Primer on Element Resize
  2. overflow & underflow
  3. 大大大
  4. 小一点
  5. bomb!撞在一起
  6. 你看的见我,又看不见我了,偷偷摸摸搞点事情
  7. ResizeObserver
  8. 还有别的办法么?
  9. 结语
  10. Refs

A Primer on Element Resize

监听元素尺寸的变化一直以来,都是一个很常见的需求,但是又不那么容易去实现。因为浏览器都有实现针对窗口变化的监听,而却没有针对元素变化的监听。这个常常发生在一些内部元素大小变化的情况。

比如飞书 Admin 的管理页面,当左侧侧边栏收起或者展开的时候,右侧的宽度会发生变化,而浏览器的窗口并没有变化。

如果我们从实际角度出发,监听元素的变动其实大部分是为了监听因为窗口变化而导致的大小变化,此时最简单的方案就是直接监听浏览器窗口的 resize 事件,这个就不细说了。

其次针对上文说道的情况,社区有很多的实现方案,但很多基于 JQuery 实现的,而且性能较差,因为是使用计数器去拉取元素宽度。不过会有很多优化方案,比如当点击某些可能会导致宽度发生变化的时候才启动定时器去检查,并且检查一段时间发现没有变化的时候,就停止检查。但是不管怎样,性能都是不太好的。

overflow & underflow

于是,社区中有另外一种检测方案,那就是基于事件检测,用的就是 overflowunderflow 事件。不知道这两个事件是什么的,可以去看 MDN 或者这篇博客

简单来讲,就是当内容超出外部容器的时候,会触发 overflow 事件,当内容又小于容器宽度的时候,会触发 underflow 事件。

那怎么检测呢?也很简单,先从扩大变化检测说起

假设有这样一个容器,长度宽度为 100px,内部有一个元素,长宽分别为 101px,也就是比父容器各大 1px。那么如果你将外部容器扩大的一瞬间,长宽至少会增加一个像素,也就是说至少比内部元素大,≥ 101px,此时会触发 underflow 事件。扩大检测就实现完成了,在检测完成之后重置内部元素大小依旧比外部容器大即可。

同理缩小检测也很简单,外部容器不变,内部元素宽度和父容器一直,当缩小的时候,父容器肯定会小于内部元素,于是触发 overflow。缩小检测也完成了,再重置宽度即可。

看似一切美好,但实际上,这两个事件的兼容到现在都巨差无比。但别担心,还有别的办法

大大大

我们现在考虑下扩大的时候,如果父容器有滚动条,有一样东西是会发生变化的。那就是可滚动区域的大小——这不是废话么。别急,先想想可滚动区域变化的话,什么也会跟着变化呢?

没错,就是父元素的 scrollTopscrollLeft。具体怎么讲呢,这边我们先考虑 scrollTop 的情况。

假设有这样一个容器(绿色),高宽为 100px,内部有一个元素(红色),高 200px,宽 100px,也就是比容器固定大 100px。容器滚动到最底部,此时容器的 scrollTop: 100px,如左图所示。如果此时容器扩大 10px,很明显随着滚动区域的变大,scrollTop 也会自动变化成 90px,如右图。

那么问题来了,scrollTop 怎么监听呢?

神奇就神奇在这里,因为元素尺寸的变化而导致 scrollTop 的变化竟然会触发容器的 scroll 事件,而且这个事件触发还是跨平台的,兼容性甚至可以下探到 ie7

小一点

这个时候你会发现,想要变小一点,那么问题就麻烦了。你会发现上面那个方式,你不管怎么搞,都无法让他在缩小的时候触发 scrollTop 的改变。

但是换个思路来考虑,你会发现另外一种让 scrollTop 发生改变的情况。依旧从上面那个方式来讲,如果容器缩小了 10px,那么内容至少要缩小 11px 才能导致滚动发生变化,那么我们会想到什么呢?

百分比对不对?这里假设设置内容高度为 200%,就可以做到当容器缩小 10px 的时候,内部元素可以缩小 20px,也就是 200px(100px 200%) ⇒ 180px(90px 200%)。如图所示

bomb!撞在一起

把上面两个结合起来,就可以实现监听 resize 事件了。但实际上使用需要处理一些样式问题,不过这个问题很简单,不单独说了。

如果前面你能看懂,那么到这里你心中有没有考虑一个情况,就是在重置的时候,会导致 scrollTop/Left 又发生了修改,从而又导致的 scroll 事件的触发,而这个怎么消除呢?

有这么两种办法:

  1. 通过检测元素是否发生变动,如果有变动那么就是因为元素缩放导致的,而如果没有缩放,那么就是通过重置导致的。

  2. 通过 raf,在同一帧内,先触发因为元素变动导致的 scroll,再触发因为重置导致的事件,然后将对事件的处理统一推迟到下一阵开始的时间。从而消除循环事件

你看的见我,又看不见我了,偷偷摸摸搞点事情

众所周知,当你给一个元素设置 display:none; 的时候,元素不会渲染,同理也不会监听外部容器尺寸的变化。那么这就给了被监听元素可乘之机,他可以让 DOM 先隐藏,然后再改变大小,再显示 DOM,此时尺寸就乱了,于是就无法很好的监听 resize 了。

不过,也不是没有办法。

当 DOM 显示在浏览器的时候,会执行一个操作——播放动画

而动画事件是可以被监听到的,于是骚操作来了,给 DOM 元素添加一个 1ms 的动画,然后监听动画开始的事件,然后重置并检测大小变化。

ResizeObserver

这么通用的需求,怎么能逃过 w3c 的双眼,于是就推出了 ResizeObserver,方便做 resize 的监听。

而这个 api 所能做的远比我们想象的强大很多:

  • 能够监听不同 box-sizing 的变化,比如 border-box/content-box

  • 能够监听元素挂载和卸载

  • 能够监听非容器类型的 DOM,比如 input canvas 等等

正好说到了这个,顺便讲下一个有意思的事情。自古监听,从来没逃过循环计算的问题,大家熟悉的 angular 脏检查的次数限制,而 ResizeObserver 是怎么解决这个问题的呢。

这里先说明几个概念:

  • observer 每一个 ResizeObserver 实例

  • observation 当 observer 实例调用 observe 方法之后创建的每一个监听,每个 observer 可能包含多个 observation

  • target 被监听的 DOM 元素

  • depth 深度,表示一个 DOM 元素距离根元素的距离,在网页中,也就是距离 html 标签的距离。

在每一次 Event Loop 中,会检查每一个 observationtargetdepth ,并取一个最小值。然后顺便检查有那些 observation 产生了变化,并创建对应的 entity ,最后作为参数传给 observer 的回调。当上面这一操作之后,就完成了一轮检测,然后会再重复一遍这样的操作,只不过这次有个要求,不仅仅要求 observation 有变化,还要要求对应的 depth 比上次检查的最小值还要大,才可以创建 entity。就这样一直一直循环检测跑下去,直到没有任何东西被检测到发生变化。

用一点通俗的话来说,除了第一轮的检查外,其他的每一轮检查都要求元素的高度要大于在上一轮检查元素的高度最小值,从而保证每一次检查,深度都会越来越大,直到达到最小的根节点,进而检查结束。

不过也许你会好奇,难道有些元素就会被跳过不检查么?其实不会的,对于那些深度小于上一次的最小深度的 observation 会自动到下一个 Event Loop 的时机去检查。

以上内容确实有点绕,我自己写的再看第二遍都看不懂了,原本好像补一个图仔细讲讲,但是发现上图也讲不清,算了弃疗,大家有兴趣的看看 Spec 吧。还有就是兼容性有点差,微软家的和苹果家的都不支持,摊手

还有别的办法么?

当然有了,还有我们 IntersectionObserver 呀。

怎么用?和最上面使用 scroll 的差不多,但是要略微简单一些,其实也没简单多少,因为该 api 判断的是相交的面积,也就是不能通过一个哨兵来判断,而是一个方向需要有一个哨兵。

至于兼容性么,除了 IE 不支持,其他都支持,这就很完美了么,对吧。(诡异的双眼,实际上坑很多,不建议尝试)

结语

本文没有特别深入的去讲解一些细节,只是讲了一下我觉得有意思的地方,如果你觉得哪里不清楚或者想了解更多,可以尝试去看源码。/ 我又没说包教包会 /

另外,我也是没懂为啥各大浏览器厂商这次这么一致,针对容器大小变化导致的 scrollTop 变化会触发 scroll 事件。可能是我太年轻了吧,这种上古事件的在各大浏览器之间的爱情纠葛我实在是无从考证。

另外,上面说 HC 很多,要多多推荐人,我也不喜欢专门打广告这种事情,但……杭州 Lark 部门招人,前端后端设计产品,应该(因为我也不确定,逃)是都要的。另外我觉得字节跳动是个好公司,如果你有其他部门或者其他城市的部门想要内推或者了解详情的,我也可以帮忙引荐下,只要进了字节我觉得就是对字节最大的贡献,不一定要来我们部门的,滑稽脸。

Refs

注释和共享

自从 Hooks 诞生以来,官方就有考虑到了性能的问题。添加了各种方法优化性能,比如 memo、hooks deps、lazy initilize 等。而且在官方 FAQ 中也有讲到,Function 组件每次创建闭包函数的速度是非常快的,而且随着未来引擎的优化,这个时间进一步缩短,所以我们这里根本不需要担心函数闭包的问题。

当然这一点也通过我的实验证实了,确实不慢,不仅仅是函数闭包不慢,就算是大量的 Hooks 调用,也是非常快的。简单来说,1 毫秒内大约可以运行上千次的 hooks,也就是 useState useEffect 的调用。而函数的创建,就更多了,快的话十万次。

很多人都觉得既然官方都这么说了,那我们这么用也就好了,不需要过分担心性能的问题。我一开始也是这样想的。但是直到最近有一次我尝试对公司项目里面一个比较复杂的组件用 Hooks 重写,我惊奇的发现重渲染时间竟然从 2ms 增长到了 4ms。业务逻辑没有任何变化,唯一的变的是从 Class 变成了 Hooks。这让我有点难以相信,我一直觉得就算是慢也不至于慢了一倍多吧,怎么着两者差不多吧。于是我开始无差别对比两个写法的性能区别。

懒人阅读指南

我相信肯定很多懒人不想看下面的分析,想直接看结果。没问题,满足你们,直接通过目录找到最后看「总结」就好了,如果你觉得有问题或者觉得我说的不对,可以重新仔细阅读一下文章,帮我指出哪里有问题。

为什么有这篇文章

其实我原本不是很想写一篇文章的,因为我觉得这个只是很简单的一个对比。于是我只是在掘金的沸点上随口吐槽了两句,结果……我决定写一篇文章。主要是觉得这群人好 two,就算是质疑也应该先质疑我的测量方式,而不是说我的使用方式。都用了这么多年了,还能用错)滑稽脸

不过既然要写,就写的完备一些,尽量把一些可能的情况都覆盖了,顺便问问大家是否有问题。如果大家对下面的测试方法或者内容有任何问题的话,请大家正常交流哦,千万不要有一些过激或者偏激的言论。因为性能测试这东西,一人一个方法,一人一个想法。

既然说道这里,其实有一点我要说,沸点里面说到的 50% 这个测量数据确实有些问题。主要有这么几个原因,第一,我当初只是想抱着试试的心态,于是就直接在开发模式下运行的。第二,平时写代码写习惯了,就直接用了 Date.now() 而没有使用精度更高 performance.now() 从而导致了误差略微有点大。虽然误差略大,但是大方向还是没错的

后文的测试中,我也将这些问题修复了,尽量给大家一个正确的数据。

开始之前,我们要知道……

假设现在有 HookCompClassComp 两个组件分别表示函数式组件和类组件,后文用 Hook(HC) 和 Class(CC) 代替。

功能定义

为了更加贴近实际,这里假设两个组件都要完成相同的一个功能。那就是用户登录这个流程:

  • 有用户名输入框和密码输入框

  • 有一个登录按钮,点击之后校验用户名是否为 admin 且密码为 admin

  • 如果校验成功,下方提示登录成功,否则提示用户名或者密码错误

  • 每次输入内容,都将清空内容

  • 另外为了消除误差,额外添加一个按钮,用于触发 100 次的 render,并 log 出平均的渲染时间。

具体的业务逻辑的实现,请看后面的 DEMO 地址。

另外因为 Class 组件有 setState 可以自动实现 batch 更新,但是 Hook 不行,所以这里实现的时候把所有的更新操作都放在 React 事件中同步更新,众所周知,React 的事件是自带 batch 更新的,从而保证只有一次渲染。保证两者功能效果一致。

对比常量

  • 2018 款 15 寸 MacBook Pro 入门款,i7-8750H 6 核 12 线程 + 16g + 256g

  • Chrome Stable 79.0.3945.117

  • react 16.12.0 PS: 其实我从 16.8.0 就开始测试了,懒癌发作一直没有继续搞

  • react-dom 16.12.0

    React 全家桶版本全部使用生产模式,降低开发模式的影响。

衡量标准:从函数调用到渲染到 DOM 上的时间

这个时间其实当组件量非常大的时候其实是不准的,因为大家调用的时间是不同的,但是渲染到 DOM 上的时间基本是一致的,就会导致在组件树越浅越前的组件测量出来的时间就会越长。但是这里的情况是页面只有一个对比组件,所以可以暂时用这个作为衡量标准。

针对 HC 来说

  • 在组件运行的一开始就记录为开始时间

  • 使用 useLayoutEffect 的回调作为结束时间。该 Hook 会在组件挂载或者更新 DOM 之后同步调用。而 useEffect 会在下一个 tick 调用,如果使用该 hook 就会导致最终测量出来的结果普遍慢一些。

1
2
3
4
5
function Hooks() {
const now = performance.now()
useLayoutEffect(() => console.log(performance.now() - now))
return (/* ui */)
}

针对 CC 来说

  • 当运行 render 方法的时候,记录时间

  • 当运行 componentDidUpdate 或者 componentDidMount 的时候,打印耗时。这两个钩子都是在组件挂载或者更新 DOM 之后同步调用,与 useLayoutEffect 调用时机一致。

1
2
3
4
5
6
7
8
9
class Class extends Component {
componentDidMount = () => this.log()
componentDidUpdate = () => this.log()
log = () => console.log(performance.now() - this.now)
render() {
this.now = performance.now()
return (/* ui */)
}
}

测试流程和结果计算

  • 页面刷新,此时要针对测试内容先进行 5 轮预热测试。目的是为了让 Chrome 对热区代码进行优化,达到最高的性能。

  • 每一轮包含若干次的渲染,比如 100 次或者 50 次,对于每一轮测试,都会抛弃 5% 最高和最低一共 10% 的数据,只保留中间的值,并对这些值计算平均值得到该轮测试结果

  • 然后进行 5 轮正常测试,记录每次的结果,统计平均值。

  • 将此时的值计算作为最终的数据值

DEMO 地址

PS: CodeSandBox 似乎不能以生产模式运行,不过你可以将它一键部署到 ZEIT 或者 netlify 上面,查看生产环境的效果。

开胃菜-重渲染测试结果

最为开胃菜,用一个最常见的场景来测试实在是最合适不过了,那就是组件的重渲染。话说不多,直接上测试结果

Avg. Time(ms) Hook Slow Hook(Self) Class Class(Self) Hook Self
第五次平均时间 0.171808623414 0.04126375367107627 0.1941208809532307 0.024725271102327567 0.22747252228577713 0.668889837468
第四次平均时间 0.1696739222 0.04082417709159327 0.18879122377096952 0.02120880942259516 0.22082417118516598 0.924868873031
第三次平均时间 0.160409555184 0.04109888910674132 0.1931868181410399 0.022967028748858104 0.22417582970644748 0.789473490728
第二次平均时间 0.130965058748 0.045824176785382593 0.2072527365001676 0.02346153545019391 0.23439560331158585 0.95316188416
第一次平均时间 0.216216175927 0.04549450906259673 0.20939560484263922 0.02357143663115554 0.2546703217776267 0.93006942148

简单解释下数据,Hook 和 Class 是通过上面规定的方式统计出来的数据,而 Hook(Self) Class(Self) 是计算了 HC 和 CC 函数调用的时间,最后的 Self 和 Hook Slow 则是 Hook 相比 Class 慢的百分比。这里只需要关注不带 Self 的数据即可。

让我们来细细「品味」一下,Hook 比 Class 慢了 16%。

等等??? 16%,emmm……乍一听这是一个多么惊人的数字,5 % 的性能降低都很难接受了,何况是 16%。如果你的页面中有上百个这样组件,想想都知道……咦~~~那酸爽

Wait!!! 或许有人会说了,抛开数值大小谈相对值,这根本就是耍流氓么。每个组件组件都是毫秒级别的渲染,这么小的级别作比较误差也会很大。而且你的测试的测量方式真的很对么?为啥看到很多文章说 Hooks 性能甚至比 Class 组件还高啊。而且你这个测量真的准确么?

这里先回答一下测量的问题,上面也说了,useLayoutEffect 和 CDU/CDM 基本是一致的,而且为了佐证,这里直接上 Performance 面板的数据,虽然只能在开发模式下才能看到这部分数据,但依旧具有参考意义

当然因为我这里只是截取了一个样例,没法给大家一个平均的值,但是如果大家多次尝试可以发现就算是 React 自己打的标记点,Class 也会比 Hook 快那么一点点。

而针对更多的疑问,这里我们就基于这个比较结果,引申出更多的比较内容,来逐步完善:

  • 挂载性能如何?也就是第一次渲染组件

  • 大量列表渲染性能如何?没准渲染的组件多了,性能就不会呈现线性叠加呢?

  • 当 Class 被很多 HOC 包裹的时候呢?

其他对比

挂载性能

通过快速卸载挂载 40 次计算出平均时间,另外将两者横向布局,降低每次挂载卸载的时候 Chrome Layout&Paint 上的差异。话不多说,直接上结果

Avg. Time(ms) Hook Slow(%) Hook(Self) Class(Self) Hook Class
第三次平均时间 0.100681682204 0.04797298587053209 0.024729729252489837 0.5672973001728187 0.5154054158845464
第二次平均时间 0.137816482105 0.041216209128096294 0.02486483395301007 0.6013513618224376 0.5285134916571347
第四次平均时间 0.009446076914 0.04378377736822979 0.025405410073093465 0.5343243404216057 0.5293243023491389
第五次平均时间 0.05774346214 0.041081066671255474 0.025540552529934292 0.5371621495263802 0.5078378347428264
第一次平均时间 0.036722530281 0.04027024968653112 0.025810805980015446 0.5608108209295047 0.5409459180727199

通过交替运行连续跑 5 轮 40 次测试,可以得到上面这个表格。可以发现,不管那一次运行,都是 Class 时间会少于 Hook 的时间。通过计算可得知,Hook 平均比 Class 慢了 (0.53346 - 0.49811) / 0.49811 = 7%,绝对差值为 0.03535ms。

这个的性能差距可以说是很少了,如果挂载上百个组件的时候,两者差距基本是毫秒内的差距。而且可以看出来,绝对值的差距可以说是依旧没有太多的变化,甚至略微微微微减少,可以简单的认为其实大部分的时间依旧都花费在一些常数时间上,比如 DOM。

大列表性能

通过渲染 100 个列表的数据计算出平均时间。

Avg. Time(ms) Hook(500) Hook Hook Slow(%,500) Hook Slow(%) Class(500) Class
第二次平均时间 9.59723405143682 2.6090425597701934 0.10286063613 0.104480973312 8.702127664211266 2.3622340473168073
第三次平均时间 9.64329787530005 2.5888297637488615 0.10438723417 0.104028668798 8.731808533218313 2.344893603684737
第一次平均时间 9.55063829376818 2.5251063647026077 0.085798601307 0.081415992606 8.795957447604296 2.335000020313136
第五次平均时间 9.597553207756992 2.571702087694343 0.10075770158 0.15273472846 8.719042523149797 2.230957413012994
第四次平均时间 9.604468084673615 2.567340426662184 0.095974553092 0.0995534837 8.76340427574642 2.334893631570517

我们先不计算下慢了多少,先看看这个数值,100 次渲染一共 2ms 多,平均来说一次 0.02ms,而而我们上面测试的时候发现,单独渲染一个组件,平均需要 0.2ms,这中间的差距是有点巨大的。

而如何合理解释这个问题呢?只能说明在组件数小的时候,React 本身所用的时间与组件的时间相比来说比例就会比较大,而当组件多了起来之后,这部分就变少了。

换句话说,React Core 在这中间占用了多少时间,我们不得而知,但是我们知道肯定是不少的。

HOC

Hook 的诞生其实就是为了降低逻辑的复用,简单来讲就是简化 HOC 这种方式,所以和 Hook 对线的其实是 HOC。最简单的例子,Mobx 的注入,就需要 inject 高阶组件包裹才可以,但是对于 Hook 来讲,这一点完全不需要。

这里测试一下 Class 组件被包裹了 10 层高阶组件的情况下的性能,每一层包裹的组件做的事情非常简单,那就是透传 props。

啥?你说根本不可能套 10 层?其实也是很容易的,你要注意这里我们所说的 10 层其实是指有 10 层组件包裹了最终使用的组件。比如说大家都知道 mobx inject 方法或者 redux 的 connect 方法,看似只被包裹了一层,其实是两层,因为还有一层 Context.Consumer。同理你再算上 History 的 HOC,也是一样要来两层的。再加上一些其他的东西,再加一点夸张不就够了,手动滑稽)

Avg. Time(ms) Class With 10 HOC
第五轮 0.25384614182697546
第四轮 0.27269232207602195
第二轮 0.2821977993289193
第三轮 0.278846147951189
第一轮 0.2710439444898249

这结果也就是很清楚了吧,在嵌套较多 HOC 的时候,Class 的性能其实并不好,从 0.19855ms 增加到 0.27173ms,时间接近有 26% 的增加。而这个性能不好并不是因为 Class,而是因为渲染的组件过多导致的。从另一个角度,hook 就没有这种烦恼,即使是大量 Hook 调用性能依旧在可接受范围内。

量化娱乐一下?

有了上面的数据,来做一个有意思的事情,将数据进行量化。

假设有如下常数,r 表示 React 内核以及其他和组件数无关的常数,h 表示 hook 组件的常数,而 c 表示 Class 组件的常数,T 表示最终所消耗的时间。可以得知这四个参数肯定不为负数。

通过简单的假设,可以得到如下等式:

1
2
3
T(n,m) = hn + cm + r
// n 表示 hook 组件的数量
// m 表示 class 组件的数量

想要计算得到 r h c 参数也很–简单–,简单个鬼,因为数据不是准确的,不能直接通过求解三元一次方程组的方式,而是要通过多元一次拟合的方式求得,而我又不想装 matlab,于是千辛万苦找到一个支持在线计算多元一次方程的网站算了下,结果如下:

1
2
3
4
5
h = 0.0184907294
c = 0.01674766395
r = 0.4146159332
RSS = 0.249625719
R^2 = 0.9971412136

这个拟合的结果有那么一点点差强人意,因为如果你把单个 Class 或者 Hook 的结果代入的话,会发现偏差了有一倍多。所以我上面也说道只是娱乐娱乐,时间不够也没法细究原因了。不过从拟合的结果上来看,也能发现一个现象,那就是 h 比 c 要大。

另外观察最后的拟合度,看起来 0.99 很大了,但实际上并没有什么意义。而且这里数据选取的也不是很好,做拟合最好还是等距取样,这样做出来的数据会更加准确。这里只是突然奇想想要玩玩看,所以就随便做了下。

总结

不管你是空降过来的还是一点点阅读到这里的,我这边先直接说基于上面的结论:

  • 当使用 Hook 的时候,整体性能相比 Class 组件会有 10 - 20% 的性能降低。

  • 当仅仅使用函数式组件,而不使用 Hook 的时候,性能不会有降低。也就是说可以放心使用纯函数式组件

  • Hook 的性能降低不仅仅体现在渲染过程,就算是在第一次挂载过程中,也相比 Class 有一定程度的降低

  • Hook 的性能降低有三部分

    • 第一部分是 Hook 的调用,比如说 useState 这些。但是这里有一点需要注意的是,这里的调用指的是有无,而不是数量。简单来说就是从 0 到 1,性能降低的程度远远高于 从 1 到 n。

    • 第二部分是因为引入 Hook 而不得不在每次渲染的时候创建大量的函数闭包,临时对象等等

    • 第三部分是 React 对 Hook 处理所带来的额外消耗,比如对 Hook 链表的管理、对依赖的处理等等。随着 Hook 的增加,这些边际内容所占用的时间也会变得越来越大。

  • 但 Hook 有一点很强,在逻辑的复用上,是远高于 HOC 方式,算是扳回一局。

所以 Hook 确实慢,慢的有理有据。但究竟用不用 Hooks 就全看,我不做定夺。凡事都有两面,Hooks 解决了 Class 一些短板,但是也引入了一些不足。如果一定要我推荐的话,我推荐 Hooks+Mobx。

Refs

One More

以上内容是我花了快一个月一点点整理出来的,甚至还跨了个与众不同的「年」。性能测试本身就是一个很有争议的东西,不同的写法不同的测试方式都会带来不同的结果。我也是在这期间一点点修改我的测试内容,从最开始只有单组件测试,到后来添加了组件列表的测试,以及挂载的测试。另外对数据收集也修改了很多,比如多次取平均值,代码预热等等。每一次修改都意味着所有测试数据要重新测试,但我只是想做到一个公平的对比。

就在现在,我依旧会觉得测试里面有很多内容依旧值得去改进,但是我觉得拖的时间太长了,而且我认为把时间花在从源码角度分析为什么 Hook 比 Class 慢上远比用数据证明要有意义的多。

注释和共享

当你看到这个标题的时候,一定很好奇,React 不是很快么?为啥会变慢呢?在写这篇文章之前,我也是这么认为的,但是当我去看了一下 React 有关 Array 的 Diff 之后,我才认识到其实 React 如果你用的不正确,那么是会变慢的。

React Diff 算法

React Diff 算法相信大家不是很陌生吧,这里就不具体展开讲了。不过有一点要补充下,Diff 算法针对的是整个 React 组件树,而不仅仅是 DOM 树,虽然这样性能会比较低一些,但是实现起来却很方便。

而在 Diff 算法中,针对数组的 diff 其实是比较有意思的一个地方。在开始讲解方面,我希望你能对 React 有一定的了解和使用。

试一试有什么区别?

首先我们创建 3 个组件,分别渲染 10000 个 DOM 元素,从 [1...10000] ,渲染成如下。

1
2
const e10000 = new Array(10000).fill(0).map((_, i) => i + 1)
element10000.map(i => <div key={`${i}`}>{i}</div>)

每个组件有两个状态,会切换数据的顺序

  • 组件 A 在 [1...10000][2,1,3...10000] 之间切换。

  • 组件 B 在 [1...10000][10000,1...9999] 之间切换

  • 组件 C 在 [1...10000][10000...1] 之间切换,也就是正序和倒序之间切换。

我们简单命名下,默认的初始状态为 S1 而切换之后的状态为 S2 。大家可以思考一下,同一个组件状态切换的时候,所耗费的时间是不是都是一样的?可以直接使用这个 DEMO

可以直接点击上方的 toggle 来切换两者之间的状态,并在控制台中查看渲染的时间。因为每次时间都不是绝对准确的,所以取了多次平均值,直接揭晓答案:

组件 S2 ⇒ S1 S1 ⇒ S2
A 102ms 103ms
B 129ms 546ms
C 556ms 585ms

有么有觉得很奇怪,为什么同样是 S1 ⇒ S2 ,同样是只改变了一个元素的位置,为什么 A 和 B 的时间差距有这么多的差距。这个具体原理就要从 Diff 算法开始讲起了。

Array Diff 的原理

在讲 React 的实现之前,我们先来抛开 React 的实现独立思考一下。但是如果直接从 React 的组件角度下手会比较麻烦,首先简化一下问题。

存在两个数组 A 和 B,数组中每一个值必须要保证在对应数组内是唯一的,类型可以是字符串或者数字。那么这个问题就转变成了如何从数组 A 通过最少的变换步骤到数组 B。

其实每个元素的值对应的就是 React 当中的 key。如果一个元素没有 key 的话,index 就是那个元素默认的 key。为什么要强调最少?因为我们希望的是能够用最少的步数完成,但是实际上这会造成计算量的加大,而 React 的实现并没有计算出最优解,而是一个较快解。

顺便定义一下操作的类型有:删除元素插入元素移动元素

这里又要引申一个特殊点,React 充分利用了 DOM 的特性,在 DOM 操作中,你是可以不使用 index 来索引数据的。简单来讲,如果用数组表示,删除需要指定删除元素的索引,插入需要指定插入的位置,而移动元素需要指定从哪个索引移动到另一个索引。而利用 DOM,我们就可以简化这些操作,可以直接删除某个元素的实例,在某个元素前插入或者移动到这里(利用 insertBefore API,如果是要在添加或者移动到最后,可以利用 append )。这样最大的好处是我们不需要记录下移动到的位置,只需要记录下那些元素移动了即可,而且这部分操作正好可以由 Fiber 来承担。

举个例子说,从 A=[1,2,3] 变化到 B=[2,3,4,1],那么只需要记录如下操作即可:

有人好奇,不需要记录移动插入到那个元素前面么?其实不需要的,这是因为你有了操作列表和 B 数组之后,就可以知道目标元素在哪里了。而且采用这种方式就根本不需要关心每次操作之后索引的变化。

回到上面的简化后的问题,首先通过对比 A、B 数组,可以得到哪些元素是删除的,哪些元素是添加的,而不管采用什么样子的策略,添加删除元素的操作次数是无法减少的。因为你不能凭空产生或者消失一个元素。那么我们问题就可以再简化一下,把所有的添加删除的元素剔除后分别得到数组 A’ 和 B’,也就是 A’ 中不包含被删除的元素,B’ 中不包含被添加的元素,此时 A’ 和 B’ 的长度一定是一样长的。也就是求解出最少移动次数使得数组 A’ 能够转化成数组 B’。

如果只是简单的求解一下最少移动步数的话,答案很简单,就是最长上升子序列(LIS,Longest Increasing Subsequence)。关于如何证明为什么是最长不下降子序列这个算法,可以通过简单的反证法得到。关于这个算法的内容我就不具体讲解了,有兴趣的可以自行 Google。在这里我们只需要知道这个算法的时间复杂度是 O(n^2)

但是现在我们还无法直接应用这个算法,因为每个元素的类型可能是字符串或者数字,无法比较大小。定义数组 T 为 B’ 内元素在 A’ 的位置。举个例子,如果 A' = ['a', 'b', 'c'] B' = ['b', 'c', 'a'],那么 T = [2, 3, 1]。本文约定位置是从 1 开始,索引从 0 开始。

此时便可以对 T 求解 LIS,可以得到 [2, 3],我们将剩下不在 LIS 中的元素标记为移动元素,在这里就是 1,最后补上被剔除的删除和插入的元素的操作动作。这样 Diff 算法就可以结束了。

上面讲解的是一个个人认为完整的 Array Diff 算法,但是还是可以在保证正确性上继续优化。但是不管优化,这个复杂度对于 React 来讲还是偏高的,而如何平衡效率和最优解成为了最头疼的问题,好在 React 采用了一个混合算法,在牺牲掉一定正确性的前提下,将复杂度降低为 O(n)。下面我们来讲解下。

React 简化之后的 Array Diff

大家有过 React 开发经验的人很清楚,大部分情况下,我们通常是这样使用的:

  • 情形1:一个标签的的直接子子标签数量类型顺序不变,通常用于静态内容或者对子组件的更新

    1
    2
    3
    4
    5
    6
    7
    // 比如每次渲染都是这样的,里面的直接子元素的类型和数量是不变的,在这种情况下,其实是可以省略 key
    <div>
    <div key="header">header</div>
    <div key="content">content</div>
    <div key="footer">footer</div>
    <SubCmp time={Date.now()}/>
    </div>
  • 情形2:一个标签有多个子标签,但是一般只改变其中的少数几个子标签。最常见的场景就是规则编辑器,每次只在最后添加新规则,或者删除其中某个规则。当然了,滚动加载也算是这种。

  • 情形3:交换某几个子标签之间的顺序

  • 情形4:翻页操作,几乎重置了整个子元素

上面只是简单举了几个常见的例子,大家可以发现,大部分情况下子标签变动的其实并不多,React 利用了这个,所以将 LIS 简化成以第一个元素开始,找到最近上升子序列。简单来来讲就是从头开始遍历,只要这个元素不小于前的元素,那么就加入队列。

1
2
3
4
5
Q = [4, 1, 5, 2, 3]
// 标准算法
LIS = [1, 2, 3]
// 简化后的算法,从第一个开始,找到最近的不下降子序列即可。
LIS_React = [4, 5]

我们乍一看,这个算法不对呀,随便就能举出一个例子让这个算法错成狗,但是我们要结合实际情况来看。如果我们套回前面说的几种情况,可以看到对于情况 1,2,3 来讲,几乎和简化前效果是一样。而这样做之后,时间复杂度降低为 O(n) ,空间复杂度降低为 O(1)。我们给简化后的算法叫做 LIS' 方便后面区分。

我们将 LIS 算法简化后,配合上之前一样的流程就可以得出 React 的 Array Diff 算法的核心流程了。(为什么叫核心流程,因为还有很多优化的地方没有讲)

变慢的原因?

当我们在了解了 React 的实现之后,我们再回过来头来看看前面给出的三个例子为啥会有这么大的时间差距?

  • 组件 A 从 [1...10000] 变化到 [2,1,3...10000] 。此时我们先求解一下 LIS' 可以得到 [2,3,4...10000],那么我们只需要移动 1 这个元素就可以了,将移动到元素 3 前面。同理反过来也是如此,也就是说 S1 ⇒ S2 和 S2 ⇒ S1 的所需要移动的次数是一致的,理论上时间上也就是相同的。

  • 组件 B 从 [1...10000] 变化到 [10000,1,2...9999] 。同理,先计算 LIS' 可以得到 [10000],没错,你没看错,就是只有一次元素,那么我需要将剩下的所有元素全都移动到 10000 的后面去,换句话要进行 9999 次移动。这也就是为啥 S1 => S2 的时间会这么慢。但是反过来却不需要这个样子,将状态反过来,并重新计算索引,那么也就是从 [1...10000][2,3....10000,1],在计算一次 LIS' 得到 [2,3...10000] ,此时只需要移动一次即可,S2 ⇒ S1 的时间也就自然恢复的和组件 A 一致。

  • 组件 C 是完全倒序操作,所以只分析其中一个过程即可。首先计算 LIS' 可以得到,[10000] ,也就是说要移动 9999 次,反过来也是要 9999 次,所以时间状态是一致的。

经过这样的分析大家是不是就明白为啥会变慢了吧?

优化细节

降低 Map 的生成操作次数

上面有一点没有讲到,不知道大家有没有思考到,我怎么知道某个元素是该添加函数删除呢?大家第一反应就是构建一个 Set,将数组元素全放进去,然后进行判断就可以了。但是在 React 中,其实用的是 Map,因为要存储对应的 Fiber,具体细节大家可以不用关注,只需要知道这里用 Map 实现了这个功能。

不管怎么样,根据算法,一开始肯定要构建一遍 Map,但是我们来看下上面的 情形1。发现内容是根本不会发生变化的,而且对于 情形2 来讲,有很大的概率前面的大部分是相同的。

于是 React 一开始不构建 Map,而是假设前面的内容都是一致的,对这些元素直接执行普通的更新 Fiber 操作,直到碰到第一个 key 不相同的元素才开始构建 Map 走正常的 Diff 流程。按照这个方式,情形1根本不会创建 Map,而且对于情形2、3来讲也会减少很多 Map 元素的操作(set、get、has)。

降低循环次数

按照上面的算法,我们需要至少 3 遍循环:第一遍构建 Map,第二遍剔除添加删除的元素生成 A’ 和 B’,第三遍计算 LIS 并得到哪些元素需要移动或者删除。而我们发现第二遍和第三遍是可以合并在一起的。也即是说我们在有了 Map 的情况下,不需要剔除元素,当遍历发现这个元素是新增的时候,直接记录下来。

总结

关于 Diff 算法其实还有很多的细节,我这边没有过多讲解,因为比较简单,比较符合直觉。大家有兴趣的可以自己去看下。另外有人应该会注意到,上面的例子中,为什么切换同样的次数,有的时间长,有的时间短了。日后有时间再分析下补充了。

注释和共享

目录

  1. 什么是可控和不可控?
  2. 为啥要区分呢?
  3. value? defaultValue? onChange?
  4. propName in this.props?
  5. Independence
  6. 如何使用?
  7. 总结

前言:本人入职之后算是第一次真正去写 React,发现了 React 的组件系统和其他框架的组件系统有些许的不同,这也触发了我对其中组件的可控性的一些思考和总结。

自从前端有了组件系统之后,有一个很常见但是却又被大家忽视的概念,就是可控组件(Controlled Component)和不可控组件(Uncontrolled Component)。

什么是可控和不可控?

官方详细讲解了什么事可控和不可控组件,虽然只是针对 input 组件的 value 属性来讲的。但是对于很多第三方组件库来讲,一个组件不止有一个数据属于可控。比如 Ant Design 的 Select 组件,valueopen 都属于可控的数据,如果你让 value 可控 open 不可控,那这到底是可控组件还是不可控组件呢?

所以从广义来讲使用可控/不可控组件其实不是很恰当,这里使用可控数据不可控数据更加合理一点。一个组件可能可能同时有可控的数据和不可控的数据。

可控数据是指组件的数据被使用者所控制。不可控数据是指组件的数据不由使用者来控制而是由组件内部控制。

之所以会有可控和不可控,主要是跟人奇怪的心理有关。如果把框架比作一个公司,组件比作人,组件之间的关系比作上下级。那么上级对下级的期望就是你既能自己做好分内的事情,也可以随时听从我的命令。这本身就是一件矛盾的事情,一边撒手不管,一边又想全权掌控。遇到这样的上级,下级肯定会疯了吧。

为啥要区分呢?

在 Vue 中,其实都忽视了这两者的区别,我们来看下面这个例子。

1
<input/>

上面是一个最简单 Input 组件,我们来思考一下如下几种使用场景:

  • 如果我只关心最后的结果,也就是输入的值,中间的过程不关心,最简单的方式是用 v-model 或者自己在 change 事件里面获取值并保存下来。
    这种场景是非常普遍,Vue 可以很好的完成,结果也符合人们的预期。

    1
    2
    3
    <input v-model="value"/>
    <!-- OR -->
    <input @change="change"/>
  • 如果我也只是关心结果,但是想要一个初始值。 也很简单,通过 value 传入一个静态字符串不就好了,或者传入一个变量,因为 Vue 的 props 是单向的。
    其中第三个方案并不是非常正确的方式,如果 initValue 在用户输入期间发生了更新,那么他将覆盖用户的数据,且不会触发 change 事件。

    1
    2
    3
    <input v-model="value"/> <!-- value 有初始值 -->
    <input value="init string" @change="change"/>
    <input :value="initValue" @change="change"/>
  • 我不仅仅关心结果,还关心过程,我需要对过程进行控制。比如说把输入的字符串全部大小写,或者锁定某些字符串。 熟练的工程师肯定可以写出下面的代码。
    但是这会有问题:

    1
    2
    <input v-model="value"/> <!-- watch "value",做修改 -->
    <input :value="value" @change="change"/> <!-- 在 change 中修改数据 -->
    1. 数据的修改都是在渲染 dom 之后,也就是说你不管怎么处理,都会出现输入的抖动。

    2. 如果通过第二种方法,恰巧你做的工作是限制字符串长度,那么你这样写 change(e) {this.value = e.target.slice(0, 10)} 函数会发现没有效果。这是因为当超过 10 字符之后,value 的值长度一直是 10,vue 没有检测到 value 的变化,从而不会更新 input.value。

出现这个问题最关键的是因为没有很好的区分可控组件和不可控组件,我们来回顾一下上面的某一段代码:

1
<input :value="value" @change="change"/>

你能从这块代码能看出来使用这个组件的用户的意图是什么呢?他是想可控的使用组件还是说只是想设置一个初始值?你无法得知。我们人类都无法得知,那么代码层面就不可能得知的了。所以 vue 对这一块的处理其实是睁一只眼闭一只眼。用户用起来方便,

用一个例子来简单描述一下:上级让你去做一项任务,你询问了上级关于这些任务的信息(props),然后你就开始(初始化组件)工作了,并且你隔一段时间就会向上级汇报你的工作进度(onChange),上级根据你反馈的进度,合理安排其他的事情。看起来一切都很完美。但是有的上级会有比较强的控制欲,当你提交了你的工作进度之后,他还会瞎改你的工作,然后告诉你,按照我的继续做。然后下级就懵逼,当初没说好我要接受你的修改的呀(value props),我这里也有一份工作进度呀(component state),我应该用我自己的还是你的?

对于人来说,如何处理上级的要求(props)和自身工作(state)是一个人情商的表现,这个逻辑很符合普通人的想法,但是对于计算机来说,它没有情商也无法判断究竟应该听谁的。为了克服这个问题,你需要多很多的判断和处理才可以,而且对于一些不变的值,你需要先清空再 nextTick 之后赋值才可以出发组件内部的更新。

最近入职之后,公司用到了 React,我才真正的对这个有所理解。

value? defaultValue? onChange?

如果对 React 可控组件和不可控组件有了解了可以跳过这块内容了。

让我们来看一下 React 如何处理这个的?我们还是拿上面的那三种情况来说:

  • 如果我只关心最后的结果,也就是输入的值,中间的过程不关心

    1
    <input onChange={onChange}/>
  • 如果我也只是关心结果,但是想要一个初始值

    1
    2
    <input defaultValue="init value" onChange={onChange}/>
    <input defaultValue={initValue} onChange={onChange}/>
  • 我不仅仅关心结果,还关心过程,我需要对过程进行控制

    1
    <input value={value} onChange={onChange}/>

当看完了这段你会很清楚的知道什么样的结构是可控,什么结构是不可控:

  • 如果有 value 那么就属于可控数据,永远使用 value 的值

  • 否则属于不可控数据,由组件使用内部 value 的值,并且通过 defaultValue 设置默认值

不论什么情况修改都会触发 onChange 事件。

React 对可控和不可控的区分其实对于计算机来说是非常合理的,而且也会让整个流程变的非常清晰。当然,不仅仅只有这一种设置的方式,你可以按照一定的规则也同样可以区分,但是保证可控和不可控之间清晰的界限是一个好的设计所必须要满足的

propName in this.props?

了解上面的概念之后,我们进入到实战环节,我们怎么从代码的层面来判断当前组件是可控还是不可控呢?

根据上面的判断逻辑来讲:

1
2
3
const isControlled1 = 'value' in this.props // approval 1
const isControlled2 = !!this.props.value // approval 2
const isControlled3 = 'value' in this.props && this.props.value !== null && this.props.value !== undefined // approval 3

我们来观察上面几个判断的方式,分别对应一下下面几个模板(针对第三方组件):

1
2
3
4
<Input value={inputValue} /> // element 1,期望可控
<Input value="" /> // element 2,期望可控
<Input /> // element 3,期望不可控
<Input value={null} /> // element 4,期望???

可以得到如下表格

是否可控 approval 1 approval 2 approval 3
element1 true true true
element2 true false true
element3 false false false
element4 true false false

大家第一眼就应该能看出来方法二其实是不正确的,他无法很好的区分这两种状态,所以直接 pass 掉。

眼尖的同学也会发现为什么 element 4 的期望没有填写呢?这是因为有一条官方的规则没有讲,这条规则是这样的:当设置了 **value** 属性之后,组件就变成了可控组件,会阻止用户修改 input 的内容。但是如果你想在设置了 **value** prop 的同时还想让用户可以编辑的话,只可以通过设置 **value****undefined****null**

在官方的这种规则下面,element 4 期望是不可控组件,也就是说 approval 3 是完全符合官方的定义的。但是这样会导致可控和不可控之间的界限有些模糊。

1
2
<Input value={inputValue} />
// 如果 inputValue 是 string,组件是什么状态?如果是 null 又是什么状态?

所以这里其实我推荐使用 approval 1 的方式,这也是 antd 所采用的。虽然不符合官方的定义,但是我觉得符合人们使用组件的一种直觉。第六感,=逃=

Independence

有了判断的方法,那么我们可以画出一个简单的流程图(Input 组件为例):

图片有点复杂,简单来讲就是每一次需要获取可控数据或者更新可控数据的时候,都需要检测一下当前组件的状态,并根据状态选择是从 props 中获取数据还是从 state 中获取数据已经更新的时候调用的是那个函数等等。图中有一些箭头的方向不是很正确,而且部分细节未画出,大家见谅。

如果只是添加这一个可控的属性 value ,这样写未尝不可,但是如果我们要同时考虑很多属性呢?比如说 Antd Select 组件就同时有 valueopen 两个可控属性,那么整个代码量是以线性方式增长的。这很明显是无法接受的。

于是这里我引入了 Independence 装饰器来做这件事情。架构如下:

我们可以这么理解,一个支持可控和不可控的组件本质上可以拆分成内部一个展示型的无状态受控的组件和外面的包装组件,通过包装(也就是高阶组件的方式)让内部受控组件支持不可控。

这样写其实有如下几个好处:

  1. 组件逻辑复杂度降低,只需要将组件的受控情况

  2. 可以将任意受控组件包装成不受控组件,尤其是对第三方组件的封装上

  3. 组件复杂度降低,代码冗余变少

  4. 非常方便的添加和删除受控属性,只需要修改装饰器即可

如何使用?

目前我简单实现了 Independence 装饰器,代码在网易猛犸开源的组件库 bdms-ui(建设中,组件不全、文档不全、时间不够,敬请期待)中,代码在此

他遵循这样的规范:假如属性名称为 **value**,那么默认值为 **defaultValue**,change 事件为 **onValueChange**。支持通过 onChangeName 修改 change 事件名称,通过 defaultName 修改默认值名称。

另外最简单的使用方式就是通过装饰器了,拿 Select 组件举例。

1
2
3
4
5
6
7
8
9
@Independence({
value: {
onChangeName: 'onChange'
},
open: {} // 使用默认值
})
export default class Select extends Component {
// blahblah,你就可以当受控组件来编写了
}

从此编写可控和不可控的数据从未如此简单。另外 Independence 还实现了 forward ref 的功能。

不过现在功能还比较薄弱,需要经过时间的检验,等完备之后可以封装成一个库。

总结

本文简单讲解了一下什么是可控和不可控,以及提出了一个 React 的解决方案。

这些只是基于我的经验的总结,欢迎大家积极交流。

注释和共享

目录

  1. 环境搭建
  2. useState 第一个接触的 Hook
  3. useEffect 监听开始和结束事件
  4. 其他 Hook
  5. 我们来用 Emotion 加点样式
  6. 收尾
  7. 总结复盘 —— 性能问题?
  8. 总结

阅读推荐:本人需要您有一定的 React 基础,并且想简单了解一下 Hook 的工作方式和注意点。但是并不详细介绍 React Hook,如果想有进一步的了解,可以查看官方文档。因为项目比较简单,所以我会比较详细的写出大部分代码。建议阅读文章之前请先阅读目录找到您关注的章节。

几天前,我女票和我说他们新人培训需要一个《真心话大冒险》的界面,想让我帮她写一个。我说好呀,正好想到最近的 React Hook 还没有玩过,赶紧来试试,于是花了一个晚上的时间,其实是俩小时,一个小时搭建项目,一个小时写。

Demo: http://souche-truth-or-dare.surge.sh (因为女票是大搜车的)

环境搭建

首先我们创建一个文件夹,做好初始化操作。

1
mkdir truth-or-darecd truth-or-darenpm init -y

安装好依赖,react@next react-dom@next parcel-bundler emotion@9 react-emotion@9 babel-plugin-emotion@9

React Hook 截止发稿前(2018-12-26)还处于测试阶段,需要使用 next 版本。

1
npm i react@next react-dom@next emotion@9 react-emotion@9npm i parcel-bundler babel-plugin-emotion@9 -D

创建 .babelrc 文件或者在 package.json 中写入 Babel 配置:

1
2
3
4
5
{
"plugin": [
["emotion", {"sourceMap": true}]
]
}

创建 src 文件夹,并创建 index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>真心话大冒险</title>
</head>
<body>
<div id="app"></div>
<script src="./index.jsx"></script>
</body>
</html>

index.jsx 文件

1
2
3
4
import * as React from 'react'
import { render } from 'react-dom'

render(<div>First Render</div>, document.getElementById('app'))

最后添加如下 scriptspackage.json

1
2
3
4
{
"start": "parcel serve src/index.html",
"build": "rm -rf ./dist && parcel build src/index.html"
}

最后我们就可以 npm start 就可以成功启动开发服务器了。在浏览器中打开 localhost:1234 即可。

parcel 已经内建了 Hot Reload,所以不需要进行额外的配置,开箱即用。是不是觉得非常简单,有了它,手动搭建项目不再困难。当然了,TS 也是开箱即用的,不过这次我这个项目真的很小,就不用 TS 了。

useState 第一个接触的 Hook

我们创建一个 App.jsx 开始我们真正的编码。先简单来看一下

1
2
3
4
5
6
7
8
9
10
11
export default function App() {
const [selected, setSelected] = useState('*')
const [started, setStarted] = useState(false)

return (
<div>
<div>{selected}</div>
<button>{started ? '结束' : '开始'}</button>
</div>
)
}

我们就完成了对 Hook 最简单的使用,当然了现在还没有任何交互效果,也许你并不明白这段代码有任何用处。

简单讲解一下 useState,这个函数接受一个参数,为初始值,可以是任意类型。它会返回一个 [any, (v: any) => void] 的元组。其中第一个 State 的值,另一个是一个 Setter,用于对 State 设置值。

这个 Setter 我们如何使用呢?只需要在需要的地方调用他就可以了。

1
<button onClick={() => setStarted(!started)}>{started ? '结束' : '开始'}</button>

保存,去页面点击一下这个按钮看看,是不是发现他会在 结束开始 之间切换?Setter 就是这么用,非常简单,如果用传统的 Class Component 来理解的话,就是调用了 this.setState({started: !this.state.started}) 。不过和 setState 不同的是,Hook 里面的所有数据比较都是 ===(严格等于)。

useState 还有很多用法,比如说 Setter 支持接收一个函数,用于传入之前的值以及返回更新之后的值。

useEffect 监听开始和结束事件

接下来,我们想要点击开始之后,屏幕上一直滚动,直到我点击结束。

如果这个需求使用 Class Component 来实现的话,是这样的:

  1. 监听按钮点击事件

  2. 判断是开始还是结束

    • 如果是开始,那么就创建一个定时器,定时从数据当中随机获取一条真心话或大冒险并更新 selected

    • 如果是结束,那么就删除之前设置的定时器

非常直接,简单粗暴。

用了 Hook 之后,当然也可以这样做了,不过你还需要额外引入一个 State 来存储 timer,因为函数组件无法持有变量。但是如果我们换一种思路:

  1. 监听 started 变化

    • 如果是开始,那么创建一个定时器,做更新操作

    • 如果是结束,那么删除定时器

好像突然变简单了,让我们想象这个用 Class Component 怎么实现呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export default class App extends React.Component {
componentDidUpdate(_, preState) {
if (this.state.started !== preState.started) {
if (this.state.started) {
this.timer = setInterval(/* blahblah*/)
} else {
clearInterval(this.timer)
}
}
}

render() {
// blahblah
}
}

好麻烦,而且逻辑比较绕,而且如果 componentDidUpdate 与 render 之间有非常多的代码的时候,就更难对代码进行分析和阅读了,如果你后面维护这样的代码,你会哭的。可是用 useEffect Hook 就不一样了。画风如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export default function App() {
// 之前的代码

// 当 started 变化的时候,调用传进去的回调
useEffect(() => {
if (started) {
const timer = setInterval(() => {
setSelected(chooseOne())
}, 60)

return () => clearInterval(timer)
}
}, [started])

return (
// 返回的 View
)
}

当用了 React Hook 之后,所有的逻辑都在一起了,代码清晰且便于阅读。

useEffect 从字面意义上来讲,就是可能会产生影响的一部分代码,有些地方也说做成副作用,其实都是没有问题的。但是副作用会个人一种感觉就是这段代码是主动执行的而不是被动执行的,不太好理解。我觉得更好的解释就是受到环境(State)变化影响而执行的代码。

为什么这么理解呢?你可以看到 useEffect 还有第二个参数,是一个数组,React 会检查这个数组这次渲染调用和上次渲染调用(因为一个组件内可能会有多次 useEffect 调用,所以这里加入了渲染限定词)里面的每一项和之前的是否变化,如果有一项发生了变化,那么就调用回调。

当理解了这个流程之后,或许你就能理解为什么我这么说。

当然了,第二个参数是可以省略的,省略之后就相当于默认监听了全部的 State。(现在你可以这么理解,但是当你进一步深入之后,你会发现不仅仅有 State,还有 Context 以及一些其他可能触发状态变化的 Hook,本文不再深入探究)

到现在,我们再来回顾一下关于定时器的流程,先看一下代码:

1
2
3
4
5
6
7
if (started) {
const timer = setInterval(() => {
setSelected(chooseOne())
}, 60)

return () => clearInterval(timer)
}

理想的流程是这样的:

  • 如果开始,那么注册定时器。——Done!

  • 如果是结束,那么取消定时器。——Where?

咦,else 的分支去哪里了?为啥在第一个分支返回了取消定时器的函数?

这就牵扯到 useEffect 的第二个特性了,他不仅仅支持做正向处理,也支持做反向清除工作。你可以返回一个函数作为清理函数,当 effect 被调用的时候,他会先调用上次 effect 返回的清除函数(可以理解成析构),然后再调用这次的 effect 函数。

于是我们轻松利用这个特性,可以在只有一条分支的情况下实现原先需要两条分支的功能。

其他 Hook

在 Hook 中,上面两个是使用非常频繁的,当然还有其他的比如说 useContext/useReducer/useCallback/useMemo/useRef/useImperativeMethods/useLayoutEffect

你可以创建自己的 Hook,在这里 React 遵循了一个约定,就是所有的 Hook 都要以 use 开头。为了 ESLint 可以更好对代码进行 lint。

这些都属于高级使用,感兴趣的可以去研究一下,本片文章只是入门,不再过多讲解。

我们来用 Emotion 加点样式

css-in-js 大法好,来一顿 Duang, Duang, Duang 的特技就好了,代码略过。

收尾

重新修改 src/index.jsx 文件,将 <div/> 修改为 <App/> 即可。

最后的 src/App.jsx 文件如下:

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
import React, { useState, useEffect } from 'react'
import styled from 'react-emotion'

const lists = [
'说出自己的5个缺点',
'绕场两周',
'拍一张自拍放实习生群里',
'成功3个你说我猜',
'记住10个在场小伙伴的名字',
'大声说出自己的名字“我是xxx”3遍',
'拍两张自拍放实习生群里',
'选择另一位小伙伴继续游戏',
'直接通过',
'介绍左右两个小伙伴',
]

function chooseOne(selected) {
let n = ''
do {
n = lists[Math.floor(Math.random() * lists.length)]
} while( n === selected)
return n
}

const Root = styled.div`
background: #FF4C19;
height: 100vh;
width: 100vw;
text-align: center;
`

const Title = styled.div`
height: 50%;
font-size: 18vh;
text-align: center;
color: white;
padding: 0 10vw;
font-family:"Microsoft YaHei",Arial,Helvetica,sans-serif,"宋体";
`

const Button = styled.button`
outline: none;
border: 2px solid white;
border-radius: 100px;
min-width: 120px;
width: 30%;
text-align: center;
font-size: 12vh;
line-height: 20vh;
margin-top: 15vh;
color: #FF4C19;
cursor: pointer;
`

export default function App() {
const [selected, setSelected] = useState('-')
const [started, setStarted] = useState(false)

function onClick() {
setStarted(!started)
}

useEffect(() => {
if (started) {
const timer = setInterval(() => {
setSelected(chooseOne(selected))
}, 60)

return () => clearInterval(timer)
}
}, [started])

return (
<Root>
<Title>{selected}</Title>
<Button onClick={onClick}>{started ? '结束' : '开始'}</Button>
</Root>
)
}

总结复盘 —— 性能问题?

最近刚刚转正答辩,突然发现复盘这个词还挺好用的,哈哈哈。

虽然这么短时间的使用,还是有一些自己的思考,说出来供大家参考一下。

如果你仔细思考一下会发现,当使用 useEffect 的时候,其实每次都是创建了一个新的函数,但并不是说每次都会调用这个函数。如果你代码里面 useEffect 使用的很多,而且代码还比较长,每次渲染都会带来比较大的性能问题。

所以解决这个问题有两个思路:

  1. 不要在 Hook 中做太多的逻辑,比如说可以让 Hook 编写一些简单的展示组件,比如 Tag/Button/Loading 等,逻辑不复杂,代码量小,通过 Hook 写在一起可以降低整个组件的复杂度。

  2. 将 Effect 拆分出去,并通过参数传入。类似于这个样子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function someEffect(var1, var2) {
    // doSomething
    }

    export function App() {
    // useState...
    useEffect(() => someEffect(var1, var2), [someVar])
    // return ....
    }

    虽然这也是创建了一个函数,但是这个函数创建的速度和创建一个几十行几百行的逻辑的函数相比,确实快了不少。其次不建议使用 .bind 方法,他的执行效率并没有这种函数字面量快。

    这种方式不建议手动来做,可以交给 babel 插件做这部分的优化工作。

其实作为一个开发者来说,不应该太多的关注这部分,但是性能就是程序员的 XX 点,我还是会下意识从性能的角度来思考。这里只是提出了一点小小的优化方向,希望以后 React 官方也可以进一步做这部分的优化工作。

已经有的优化方案,可以查看官方 FAQ

总结

经过这个简短的使用,感觉用了 Hook 你可以将更多的精力放在逻辑的编写上,而不是数据流的流动上。对于一些轻组件来说简直是再合适不过了,希望早点能够正式发布正式使用上吧。

另外 parcel 提供了强大的内置功能,让我们有着堪比 webpack 的灵活度却有着比 webpack 高效的开发速度。

好的,一篇 1 小时写代码,1 天写文章的水文写完了。以后如果有机会再深入尝试。

注释和共享

Reflect-Metadata 详解

发布在 Frontend

目录

  1. 引言
  2. Metadata
  3. Reflect Metadata
    1. 概念
    2. 安装/使用
    3. 类/属性/方法 装饰器
    4. 原型链查找
    5. 用途
    6. API
  4. 深入 Reflect Metadata
    1. 实现原理
  5. End
  6. 题外话

引言

在 ES6 的规范当中,就已经存在 Reflect API 了。简单来说这个 API 的作用就是可以实现对变量操作的函数化,也就是反射。具体的关于这个 API 的内容,可以查看这个教程

然而我们在这里讲到的,却是 Reflect 里面还没有的一个规范,那么就是 Reflect Metadata

Metadata

想必对于其他语言的 Coder 来说,比如说 Java 或者 C#,Metadata 是很熟悉的。最简单的莫过于通过反射来获取类属性上面的批注(在 JS 当中,也就是所谓的装饰器)。从而可以更加优雅的对代码进行控制。

而 JS 现在有装饰器,虽然现在还在 Stage2 阶段。但是 JS 的装饰器更多的是存在于对函数或者属性进行一些操作,比如修改他们的值,代理变量,自动绑定 this 等等功能。

所以,后文当中我就使用 TypeScript 来进行讲解,因为 TypeScript 已经完整的实现了装饰器。
虽然 Babel 也可以,但是需要各种配置,人懒,不想配置那么多。

但是却无法实现通过反射来获取究竟有哪些装饰器添加到这个类/方法上。

于是 Reflect Metadata 应运而生。

Reflect Metadata

Relfect Metadata,简单来说,你可以通过装饰器来给类添加一些自定义的信息。然后通过反射将这些信息提取出来。当然你也可以通过反射来添加这些信息。
就像是下面这个例子所示。

1
2
3
4
5
6
7
8
9
10
@Reflect.metadata('name', 'A')
class A {
@Reflect.metadata('hello', 'world')
public hello(): string {
return 'hello world'
}
}
Reflect.getMetadata('name', A) // 'A'
Reflect.getMetadata('hello', new A()) // 'world'
// 这里为什么要用 new A(),用 A 不行么?后文会讲到

是不是很简单,那么我简单来介绍一下~

概念

首先,在这里有四个概念要区分一下:

  1. Metadata Key {Any} 后文简写 k。元数据的 Key,对于一个对象来说,他可以有很多元数据,每一个元数据都对应有一个 Key。一个很简单的例子就是说,你可以在一个对象上面设置一个叫做 'name' 的 Key 用来设置他的名字,用一个 'created time' 的 Key 来表示他创建的时间。这个 Key 可以是任意类型。在后面会讲到内部本质就是一个 Map 对象。

  2. Metadata Value {Any} 后文简写 v。元数据的类型,任意类型都行。

  3. Target {Object} 后文简写 o。表示要在这个对象上面添加元数据

  4. Property {String|Symbol} 后文简写 p。用于设置在那个属性上面添加元数据。大家可能会想,这个是干什么用的,不是可以在对象上面添加元数据了么?其实不仅仅可以在对象上面添加元数据,甚至还可以在对象的属性上面添加元数据。其实大家可以这样理解,当你给一个对象定义元数据的时候,相当于你是默认指定了 undefined 作为 Property。下面有一个例子大家可以看一下。

大家明白了上面的概念之后,我之前给的那个例子就很简单了~不用我多说了。

安装/使用

下面不如正题,我们怎么开始使用 Reflect Metadata 呢?
首先,你需要安装 reflect-metadata polyfill,然后引入之后就可以看到在 Reflect 对象下面有很多关于 Metadata 的函数了。因为这个还没有进入正式的协议,所以需要安装垫片使用。

啥,Reflect 是啥,一个全局变量而已。

你不需要担心这个垫片的质量,因为连 Angular 都在使用呢,你怕啥。

之后你就可以安装我上面写的示例,在 TypeScript 当中去跑了。

类/属性/方法 装饰器

看这个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Reflect.metadata('name', 'A')
class A {
@Reflect.metadata('name', 'hello')
hello() {}
}
const objs = [A, new A, A.prototype]
const res = objs.map(obj => [
Reflect.getMetadata('name', obj),
Reflect.getMetadata('name', obj, 'hello'),
Reflect.getOwnMetadata('name', obj),
Reflect.getOwnMetadata('name', obj ,'hello')
])
// 大家猜测一下 res 的值会是多少?

想好了么?再给你 10 秒钟

10
9
8
7
6
5
4
3
2
1

res

1
2
3
4
5
[
['A', undefined, 'A', undefined],
[undefined, 'hello', undefined, undefined],
[undefined, 'hello', undefined, 'hello'],
]

那么我来解释一下为什么回是这样的结果。

首先所有的对类的修饰,都是定义在类这个对象上面的,而所有的对类的属性或者方法的修饰,都是定义在类的原型上面的,并且以属性或者方法的 key 作为 property,这也就是为什么 getMetadata 会产生这样的效果了。

那么带 Own 的又是什么情况呢?

这就要从元数据的查找规则开始讲起了

原型链查找

类似于类的继承,查找元数据的方式也是通过原型链进行的。

就像是上面那个例子,我实例化了一个 new A(),但是我依旧可以找到他原型链上的元数据。

举个例子

1
2
3
4
5
6
7
8
9
10
11
12
class A {
@Reflect.metadata('name', 'hello')
hello() {}
}
const t1 = new A()
const t2 = new A()
Reflect.defineMetadata('otherName', 'world', t2, 'hello')
Reflect.getMetadata('name', t1, 'hello') // 'hello'
Reflect.getMetadata('name', t2, 'hello') // 'hello'
Reflect.getMetadata('otherName', t2, 'hello') // 'world'
Reflect.getOwnMetadata('name', t2, 'hello') // undefined
Reflect.getOwnMetadata('otherName', t2, 'hello') // 'world'

用途

其实所有的用途都是一个目的,给对象添加额外的信息,但是不影响对象的结构。这一点很重要,当你给对象添加了一个原信息的时候,对象是不会有任何的变化的,不会多 property,也不会有的 property 被修改了。
但是可以衍生出很多其他的用途。

  • Anuglar 中对特殊字段进行修饰 (Input),从而提升代码的可读性。

  • 可以让装饰器拥有真正装饰对象而不改变对象的能力。让对象拥有更多语义上的功能。

API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace Reflect {
// 用于装饰器
metadata(k, v): (target, property?) => void
// 在对象上面定义元数据
defineMetadata(k, v, o, p?): void
// 是否存在元数据
hasMetadata(k, o, p?): boolean
hasOwnMetadata(k, o, p?): boolean
// 获取元数据
getMetadata(k, o, p?): any
getOwnMetadata(k, o, p?): any
// 获取所有元数据的 Key
getMetadataKeys(o, p?): any[]
getOwnMetadataKeys(o, p?): any[]
// 删除元数据
deleteMetadata(k, o, p?): boolean
}

大家可能注意到,针对某些操作,会有 Own 的函数。这是因为有的操作是可以通过原型链进行操作的。这个后文讲解。

深入 Reflect Metadata

实现原理

如果你去翻看官网的文档,他会和你说,所有的元数据都是存在于对象下面的 [[Metadata]] 属性下面。一开始我也是这样认为的,新建一个 Symbol('Metadata'),然后将元数据放到这个 Symbol 对应的 Property 当中。直到我看了源码才发现并不是这样。请看例子

1
2
3
@Reflect.metadata('name', 'A')
class A {}
Object.getOwnPropertySymbols(A) // []

哈哈,并没有所谓的 Symbol,那么这些元数据都存在在哪里呢?

其实是内部的一个 WeakMap 中。他正是利用了 WeakMap 不增加引用计数的特点,将对象作为 Key,元数据集合作为 Value,存到 WeakMap 中去。

如果你认真探寻的话,你会发现其内部的数据结构其实是这样的

1
WeakMap<any, Map<any, Map<any, any>>>

是不是超级绕,但是我们从调用的角度来思考,这就一点都不绕了。

1
weakMap.get(o).get(p).get(k)

先根据对象获取,然后在根据属性,最后根据元数据的 Key 获取最终要的数据。

End

因为 Reflect Metadata 实在是比较简单,这里就不多讲解了。更多内容请查看 Spec

题外话

其实看了源码之后还是挺惊讶的,按照一般的套路,很多 polyfill 会让你提供一些前置的 polyfill 之后,当前的 polyfill 才能使用。但是 reflect-metadata 竟然内部自己实现了很多的 polyfill 和算法。比如 Map, Set, WeakMap, UUID。最惊讶的莫过于 WeakMap 了。不是很仔细的阅读了一下,好像还是会增加引用计数。

注释和共享

目录

vue-lever 是一个使用装饰器模式的插件来帮你管理 true/false 状态

最近一直在写 vue 相关的东西,因为毕竟是前端,所以经常会遇到一种情况就是说,我需要维护一个 true/false 状态,比如说:当按钮点击的时候,显示加载字样,然后等加载完毕之后完成显示。

首先我们考虑出现这种情况的时候,如果我们一行一行的书写,那将是非常麻烦的。

1
2
3
4
5
6
7
8
9
10
11
12
13
new Vue({
data() {
return { loading: false }
}
methods: {
asyncAction() {
this.loading = true
this.$http.doing() // return a promise
.then(() => this.loading = false)
.catch(() => this.loading = false))
}
}
})

我们需要在每一个退出的情况下将 loading 状态设置为 false,这至少意味着你要写 3 遍,非常冗余。而且很容易忘记。

我们可以抽象出来,可以发现每当这种函数运行的时候,首先将相关的变量设置成 true,然后等待异步操作完成或者失败之后,再将变量设置回之前的值。

完成这个操作最简单的方式就是代理,在用户的函数调用之前设置相应的变量,在用户的函数调用完成之后,或者如果函数是异步操作,那么通过返回一个 promise 来表示异步操作。

既然讲到了代理模式,那么在 JS 中有很多,不过我们在这里通过一个 ES6 的新语法,装饰器。

话不多说,上代码

1
2
3
4
5
6
7
8
9
10
import Lever from 'vue-lever'
// import others
new Vue({
methods: {
@Lever.Lever('loading')
asyncAction() {
return this.$http.doing() // return a promise
}
}
})

这个功能和上面的那段代码是一模一样的,是不是感觉用了装饰器之后就变得特别简单了呢?

不过这里有一点需要注意的是,为了方便和隔离,我将所有的变量全都放在 levers 这个变量下面,也就是说你需要通过 levers.loading 来使用,而不是 loading

不过现在暂时没有支持回调函数的方式,也就是你必须要返回一个 promise,否则是不行的。

其次我们这里还有手动模式,也就是如果你不想用装饰器的话,你可以通过 this.$lever(name, value),来更改状态,其中 name 为变量名,value 为 true/false 值。当然这里也提供两个 alias,this.$lever.t(name)this.$lever.f(name)。方便设置变量为 true/false。

还有一些其他的参数,大家可以去 Github 网站查看。这篇文章就写到这里。

注释和共享

  • 第 1 页 共 1 页

XGHeaven

一个弱弱的码农


杭州电子科技大学学生一枚


Weifang Shandong, China