Get hooked

React.js的hooks已经发布几年了,最近刚好“高强度”地使用React.js,回顾一下这套API的设计,说一下我的一些想法。

toc

hooks解决了什么问题

工具的作用就是解决问题,工具的改进就是为了解决在解决问题过程中发现的新问题。过去的几年里Vue飞速发展,也经常被用来和React进行对比。我的上一个工作就主要是使用Vue或者类型微信小程序的Vue变种,我刚开始工作的时候只会一点JS,学了半天JS的语法就找工作了。Vue的神奇之处在于,哪怕你只学了半天JS,你也可以照猫画虎,照着文档和别人的代码写组件。这就是Vue所谓的“渐进式”,因为你看到看不懂的语法自然会去查文档,实在搞不懂就会放弃,不影响你先写出一个能用的组件。

而渐进式API的问题在于,约束性太强了,你必须按照某个固定的API去写代码。强约束本身不是缺点,强约束对于代码功能的稳定性也有极正面的作用。问题在于,写代码会很无趣,一样是画画,用画笔和数位板,用数位板和鼠标切图,用鼠标切图和直接拿现成的图形复制粘贴组合,都是不一样的体验。而更实际一点的代价就是,一定程度上牺牲了可读性,复用能力也打折扣。

所以,Vue用这些代价,换来的是,你只要按照一系列奇怪的规则去写代码,你就可以写出组件。你不需要考虑为什么this.xxx啥都有,为什么一定要绑在本来就是个谜一样的this上,你只要知道你可以这样做什么,你只能这样做就可以了;你不需要考虑为什么模板里一大堆奇奇怪怪的v-修饰符,你只需要知道你也这样写就能领工资就可以了。一个团队维护一个开发了很久的项目,代码的可读性非常重要,这直接决定了码农的心情,进而决定了他们的效率。Vue能凭这些毫无品味的API流行,可以说本身就是写代码门槛降低的缩影和无需维护的code base一次性化的趋势,而这正是工具的意义,所以Vue是成功的。

我总不至于要抨击实用主义。但是,如果有让工作更有乐趣的可能,不仅仅是欣赏工作成果的成就感,能有享受创作本身的乐趣的可能,那又何乐而不为呢?说句题外话,如果不能活每一天的生活本身,人生一定会有个成果在等着吗?是的,我绕了这么远说半天Vue,其实就是为了说React的hooks解决了工作过于乏味的这个问题。你会发现React的文档在教你怎么用React的方式思考问题,它会介绍React带来的新概念以及它为什么会被设计成现在这个样子;Vue的文档就是在教你怎么用Vue,它有哪些API和语法。我觉得Vue的性能更好,Vue更易学,但是Vue不make sense,这始终让我很难接受。

16.8之前的React对比Vue没有任何优势,我那时候和当时的同事聊天说过,React的SFC(stateless functional component)真的太好用了,写静态网站简直就是享受,而class component就是个蹩脚的Vue。Algebra Effects让React向后兼容的纯函数组件成为可能,hooks的出现从根本上改变了形势。从此props => view的UI逻辑真正变成了props => (state, effect) => view,实现了从面向对象到ReactiveX都没能解决的问题。React的设计一直都很稳定,与其说是终于实现了纯函数组件,倒不如说终于可以不再使用class component这个临时的解决方案了,从fiber到reconciliation,React一直在把渲染逻辑抽象出来分层,这也是如此巨大的变动依然能向后兼容的原因,甚至React原本的代码并没有进行大量修改。这一点从Vue3.0的更新就看得出来,Vue往另一条路上越走越远了,我可以很明确地说我讨厌config这个API。

hooks让state和effect相关的逻辑能抽象出来,更易复用,同时让代码的可读性得到极大提升。声明式代码的优势,expression相对statement的优势,我只能说“懂的都懂”。你不需要去填充框架作者预设的slot来实现自己的业务逻辑,而是利用框架的运行机制设计自己的业务逻辑。无论是类继承还是Vue的mixin,我相信没人会真的认为好用,认为是合理的UI逻辑抽象复用范式。更可贵的是,你不需要学习艰涩的reactive programming或者函数式编程的知识,一样可以写纯函数组件,因为这些东西学起来真的很挠心,不在了解过Monad的前提下写个一两年代码你永远也体会不到函数式抽象的真正意义和难点在哪里。

hooks带来了什么问题

打开了,但是没有完全打开。

虽然是想解决问题,hooks也只是在一定程度上缓解了问题,无法从根本上解决问题。任何降低开发难度的手段都还是需要开发,任何降低学习成本的方式还是需要学习。如果不换语境,问题就永远不会消失,除非不开发了,不然不管怎么朝着让开发变简单的方向努力,我们也还是要开发,还是需要熟悉工具。即使更符合直觉了,但是概念的学习还是少不了的,你需要知道propsstate的区别,需要学习hooks的使用方式,需要了解hooks工作的特点,最好还要了解hooks的工作原理。而hooks和Vue的问题是一样的,它们容易上手,但是真的用来写复杂的逻辑会需要更多学习成本。不过React with hooks代码比Vue易读,设计稳定,后续优化的空间也更大,所以我还是更喜欢React。

世间难得双全法,不负如来不负卿。

仓央嘉措也认为没有银弹。hooks解决了一部分问题就一定会带来新的问题。虽然hooks的使用方式很符合直觉,但是hooks的工作机制是不符合直觉的。写一些复杂的逻辑时,hooks的写法会更复杂,当然这也没办法,你不可能又把这部分逻辑独立出来,让UI的逻辑更清晰,还没有任何的overhead。最核心的在于你需要通过经常使用hooks,逐渐了解render函数会被反复调用,hooks只是用来声明哪些东西在函数被反复调用的时候是稳定的。对hooksAPI设计的理解深度限制了你能写出的hooks。下面我就拿最近写过的一个hooks举例,在实际应用场景中,新手可能会遇到很难用hooks解决的问题。

hooks不好处理的问题

最常见的就是基于副作用的副作用,听不懂直接看实例就行了,最典型的就是setTimeout/setInterval相关的异步回调。比如我最近写一个玩具项目的时候遇到的用monaco编辑器通过Broadcast Channel控制另一个页面的样式,很自然的我要加一个防抖的逻辑进去。那么直接用类似这样的函数装饰回调即可:

function debounce<T extends (...args: any[]) => any> (f: T, time: number) {
    let debounced: null | ReturnType<typeof setTimeout> = null
    return function (...args: any[]) {
        if (debounced) clearTimeout(debounced)
        debounced = setTimeout(() => f(...args), time)
    } as T
}
function debounce<T extends (...args: any[]) => any> (f: T, time: number) {
    let debounced: null | ReturnType<typeof setTimeout> = null
    return function (...args: any[]) {
        if (debounced) clearTimeout(debounced)
        debounced = setTimeout(() => f(...args), time)
    } as T
}

本来很顺畅,在monaco同时编辑多文件的时候问题就来了,当前编辑文件用一个state表示:

const [currentFile, setCurrentFile] = useState(DEFAULT_THEME_FILE)
const [currentFile, setCurrentFile] = useState(DEFAULT_THEME_FILE)

用过hooks的都知道,这时候被修饰的函数里面是无法读到currentFile的最新值的,因为绑定到monaco事件的响应函数是确定的,那么不在useEffect反复绑定函数就不可能读到最新的state。而反复绑定新的事件回调显然也是不可取的。直觉告诉你,可能需要一个useDebouncehook了。而既然是写玩具项目做思维体操,去Google搜现成的就没意思了,而且它们大概率也是把上面那个debounce函数改一改,玩不出什么花,很可能还应付不了苛刻的场景。那不妨自己慢慢改一个能用的hook出来。

function useDebounce<T extends (...args: any[]) => any> (f: T, time: number): T {
    return useMemo<T>(() => {
        let debounced: null | ReturnType<typeof setTimeout> = null
        return ((...args: any[]) => {
            if (debounced) clearTimeout(debounced)
            debounced = setTimeout(() => f(...args), time)
        }) as T
    }, [f])
}
function useDebounce<T extends (...args: any[]) => any> (f: T, time: number): T {
    return useMemo<T>(() => {
        let debounced: null | ReturnType<typeof setTimeout> = null
        return ((...args: any[]) => {
            if (debounced) clearTimeout(debounced)
            debounced = setTimeout(() => f(...args), time)
        }) as T
    }, [f])
}

第一个可以沾沾自喜的版本很简单就完成了,通过useMemo的构造函数,可以实现给事件绑定一个动态函数的目的。但是这个东西的问题很大,我们是打算这样用这个hook的

const updateFile = useDebounce(useCallback((value, e) => setState(prev => ({
    ...prev,
    theme: currentFile === DEFAULT_THEME_FILE ? value : prev.theme,
    files: {
        ...prev.files,
        [currentFile]: {
            ...prev.files[currentFile],
            content: value,
        },
    },
}), [currentFile])), BLOCK_INTERVAL)
const updateFile = useDebounce(useCallback((value, e) => setState(prev => ({
    ...prev,
    theme: currentFile === DEFAULT_THEME_FILE ? value : prev.theme,
    files: {
        ...prev.files,
        [currentFile]: {
            ...prev.files[currentFile],
            content: value,
        },
    },
}), [currentFile])), BLOCK_INTERVAL)

问题在哪呢,问题在于,如果一个回调被延迟执行了,这样它确实会拿到最新的currentFile这个state了,但这是有问题的,如果我们在这期间内切换文件,那么到了函数执行的时候就会以最新的文件为目标了,这显然不对。我们真正要的不是currentFile的最新值,而是这个函数被声明调用的那个时刻的最新值。于是问题又变成了,我们如何在未来拿到一个过去的值。这个听上去很玄乎的问题,其实也就是如何合理地在回调里面展开一个闭包,很显然我们需要一个稳定的可变对象来保存这个能拿到当时环境的闭包,那么最简单的就是useRef了。

 function useDebounce<T extends (...args: any[]) => any> (f: T, time: number): T {
+    const savedCallback = useRef(f)
+    useEffect(() => {
+        savedCallback.current = f
+    }, [f])

     return useMemo<T>(() => {
         let debounced: null | ReturnType<typeof setTimeout> = null
         return ((...args: any[]) => {
             if (debounced) clearTimeout(debounced)
-            debounced = setTimeout(() => f(...args), time)
+            const fc = savedCallback.current
+            debounced = setTimeout(() => fc(...args), time)
         }) as T
     }, [f])
 }
 function useDebounce<T extends (...args: any[]) => any> (f: T, time: number): T {
+    const savedCallback = useRef(f)
+    useEffect(() => {
+        savedCallback.current = f
+    }, [f])

     return useMemo<T>(() => {
         let debounced: null | ReturnType<typeof setTimeout> = null
         return ((...args: any[]) => {
             if (debounced) clearTimeout(debounced)
-            debounced = setTimeout(() => f(...args), time)
+            const fc = savedCallback.current
+            debounced = setTimeout(() => fc(...args), time)
         }) as T
     }, [f])
 }

这样当setTimeout真正触发的时候,调用的就是我们在调用setTimeout那一时刻ref到的回调函数。这样一来,其实简单的useDebounce已经可以正常工作了,虽然只有几行代码,但是要写对这个hook,对react hooks熟练度的要求不低,或者你很熟悉HaskellElm,那么也能很自然地想到如何解决问题。而实际工作中,我认为要求所有人都有时间写这样的hook不太现实,理想的是半年经验就能独立负责项目,而半年经验能否看明白这样的hook为什么这么写都成问题。但这也很难说是hooks的问题,不管怎么抽象,逻辑总是需要实现,问题总需要解决,不会凭空消失,所有解决办法都是权宜之计。这样暴露出来的useDebounce确实是很干净的API,能让组件的代码更整洁。

当然,如果使用hooks没有那么熟练,也有更直接的思路,把currentFile当作参数去调用一个被debounce的函数。

const debouncedUpdateFile = debounce((value, e, currentFile) => setState(prev => ({
    ...prev,
    theme: currentFile === DEFAULT_THEME_FILE ? value : prev.theme,
    files: {
        ...prev.files,
        [currentFile]: {
            ...prev.files[currentFile],
            content: value,
        },
    },
})), BLOCK_INTERVAL)

const updateFile = useCallback((value, e) => debouncedUpdateFile(value, e, currentFile), [currentFile])
const debouncedUpdateFile = debounce((value, e, currentFile) => setState(prev => ({
    ...prev,
    theme: currentFile === DEFAULT_THEME_FILE ? value : prev.theme,
    files: {
        ...prev.files,
        [currentFile]: {
            ...prev.files[currentFile],
            content: value,
        },
    },
})), BLOCK_INTERVAL)

const updateFile = useCallback((value, e) => debouncedUpdateFile(value, e, currentFile), [currentFile])

但是这个使用的逻辑似乎并没有useDebounce简洁,也没那么好用。而且涉及到更复杂到逻辑的时候,这种写法反而会更力不从心,因为Uncurrying永远是懒人最直接的堕落途径,Currying永远是最朴素的抽象戏法。比如我又加了需求,需要在currentFile变化的时候直接撤销延时并且触发被debounce的函数要怎么做呢?这个写法就很难改了,而我们之前更简洁,看上去更艰涩的写法就能直接通过修改useDebounce的逻辑实现。由于永远至多一个函数等待执行,这大大降低了逻辑的实现难度

 function useDebounce<T extends (...args: any[]) => any> (f: T, time: number): T {
     const savedCallback = useRef(f)
+    const tomb = useRef<ReturnType<typeof setTimeout> | null>(null)
+    const [action, setAction] = useState<() => void>(() => {})
+
+    const killDebounced = (release = false) => {
+        if (!tomb.current) return
+        clearTimeout(tomb.current)
+
+        if (release) action()
+        else {
+            tomb.current = null
+        }
+    }
     useEffect(() => {
+        killDebounced(true)
         savedCallback.current = f
     }, [f])

     return useMemo<T>(() => {
-        let debounced: null | ReturnType<typeof setTimeout> = null
         return ((...args: any[]) => {
-            if (debounced) clearTimeout(debounced)
             const fc = savedCallback.current
-            debounced = setTimeout(() => fc(...args), time)
+            killDebounced()
+            setAction(() => {
+                const nextAction = () => {
+                    setDebouncing(false)
+                    killDebounced()
+                    fc(...args)
+                }
+                tomb.current = setTimeout(nextAction, time)
+                return nextAction
+            })
         }) as T
     }, [f])
 }
 function useDebounce<T extends (...args: any[]) => any> (f: T, time: number): T {
     const savedCallback = useRef(f)
+    const tomb = useRef<ReturnType<typeof setTimeout> | null>(null)
+    const [action, setAction] = useState<() => void>(() => {})
+
+    const killDebounced = (release = false) => {
+        if (!tomb.current) return
+        clearTimeout(tomb.current)
+
+        if (release) action()
+        else {
+            tomb.current = null
+        }
+    }
     useEffect(() => {
+        killDebounced(true)
         savedCallback.current = f
     }, [f])

     return useMemo<T>(() => {
-        let debounced: null | ReturnType<typeof setTimeout> = null
         return ((...args: any[]) => {
-            if (debounced) clearTimeout(debounced)
             const fc = savedCallback.current
-            debounced = setTimeout(() => fc(...args), time)
+            killDebounced()
+            setAction(() => {
+                const nextAction = () => {
+                    setDebouncing(false)
+                    killDebounced()
+                    fc(...args)
+                }
+                tomb.current = setTimeout(nextAction, time)
+                return nextAction
+            })
         }) as T
     }, [f])
 }

这差不多就是我最后用的版本了。我觉得这个例子很好地说明了hooks的优势和劣势,它实现了逻辑的隔离,让UI的代码稳定下来,但同时它也引入了更多的逻辑,某些场景下对技巧的要求可以说更高了。

hooks教会了我们什么

我们回过头来考虑,不在hooks语境下,这个防抖的逻辑应该怎么实现呢。最直接的就是增加一层或者修改现有的对接monaco事件响应的API,很难说这会不会造成冗余,而且这些会让对接的逻辑变复杂,写得不够干净的话,出了问题更难隔离和定位,这是实践中典型的把代码写得越来越难维护的方式。任何时候任何地方,越复杂出错的机会越大,因此越靠后的API应该尽量简单且稳定,而为了让最靠前的代码更直观,那么它必然是被抽象了的,把复杂的逻辑隔离在次靠前那一层,我觉得这是hooks最大的意义。或许我们经常体会到这一点可能是最佳实践,可能为了应付压力或者无法控制同事写出什么样的代码,但react是我们能接触到的实例,只要写react,你就能体会到这种实践的优势。这是我们思考API抽象程度的时候可以借鉴的地方。

如果沉湎于工作和业务本身,我们往往会忽略实现抽象的乐趣,以及浪费了无数在实践中锻炼思维的机会。我时不时看一看Preact的源码,每次都会体会到完善代码本身的乐趣,比如有兴趣的可以去看一下Preact里面hooks相关的源码,对函数调用的技巧每次都让我感觉写出这样的代码的人真是充满了想象力。

React整个项目就充满了想象力,它就是个最佳实践的集合,它第一个真正改变了前端写代码的方式,以及思考的方式,也是很难得的改变程序员写代码习惯还能流行的类库。hooks除了我上面说过的那些表面上的利好编写代码体验的优势,其实由于实现了更纯粹的函数式组件,对组件的AOT优化也带来了极大的便利和想象空间。所以有些时候你不知道是为了获得什么效果而做对的事情还是因为做了对的事情获得了意想不到的好效果。

这不是什么技术博客,只是个人由hooks说开,谈一谈自己的想法,那么我也更啰嗦一点最近换工作以后的感受。

之前换工作的时候,对前同事说过但没有对上司说,我很怕过长的工作时间消耗我对编程的兴趣。burn out是个身心同步的过程。自从换了个工作以后,我感觉整个世界都明亮了起来。每天上班,娱乐的项目和工作内容占的时间对半分,剩下的时间看书或者博客。我换工作之前为了排解压力,不管发不发,总之会疯狂写东西,现在时间多了反而写得更少了。

Published

Tags

JavascriptReact.jsCoding