React Hooks的花样玩法

React Hooks是react 最新的编程范式,我们可以容易地写出更加简单和可扩展的代码。最近看了jsconf(https://www.youtube.com/watch?v=J-g9ZJha8FE)的会议分享后,觉得有很多代码实现思路都可以在自己的项目中借鉴,所以根据自己的理解对其主要内容做了一次总结。

useDark

对于做移动端的前端来说,换肤一般是比较常见的一个需求。在之前我们可能需要在redux中定义一个全局状态进行管理,现在利用React Hooks,就能很方便地实现这个功能了:

function App() {
  const [isDark, setIsDark] = React.useState(false);
  
  const theme = isDark ? themes.dark : themes.light;
  
  return (
    <ThemeProvider theme={theme}>
        ...
    </ThemeProvider>
  );
}

可以看到,我们利用themes对象就可以控制前端UI显示的是黑夜模式还是日常模式。在移动端,通常是根据用户定义的系统主题颜色来判断UI显示的主题。那么我们如何实现这个功能呢?

如果大家做过响应式应用开发,那么对媒体查询应该并不陌生。一般来说都会使用css来写媒体查询语句,不过在这里我们将使用matchMedia这个API来实现。它的功能主要是用来判断媒体查询语句在特定浏览器上是否生效,

如:

window.matchMedia('screen and (min-width: 800px)');

这个命令就会判断浏览器的屏幕宽度是否大于800px。如果是的话,就会返回true,否则返回false。

那么,我们就可以借助这个方法再结合prefers-color-scheme标志来判断用户设置了什么样的系统主题色。

有了上述的知识后,再结合前面的ThemeProvider组件,我们就可以写出下面的代码来:

const matchDark = '(prefers-color-scheme: dark)';

function App() {
    const [isDark, setIsDark] = React.useState(() => window.matchMedia && window.matchMedia(matchDark).matches);

    React.useEffect(() => {
        const matcher = window.matchMedia(matchDark);
        const onChange = ({ matches }) => setIsDark(matches);
    
        matcher.addListener(onChange);
    
        return () => {
            matcher.removeListener(onChange);
        }
    }, [setIsDark]);

    const theme = isDark ? themes.dark : themes.light;

    return <ThemeProvider theme={theme}>...</ThemeProvider>
}

接下来,我们简化一下代码,将设置主题相关的代码抽取成自定义Hook:

function useDarkMode() {
    const [isDark, setIsDark] = React.useState(() => window.matchMedia && window.matchMedia(matchDark).matches);

    React.useEffect(() => {
        const matcher = window.matchMedia(matchDark);
        const onChange = ({ matches }) => setIsDark(matches);
    
        matcher.addListener(onChange);
    
        return () => {
            matcher.removeListener(onChange);
        }
    }, [setIsDark]);

    return isDark;
}

function App() {
    const theme = useDarkMode() ? themes.dark : themes.light;

    return (<ThemeProvider theme={theme}>
        ...
    </ThemeProvider>)
}

useClickOutside

模态框Modal是一种十分常见的前端组件,无论你是做菜单、弹窗还是提示框,这个功能都是必备的。那么在开发中,我们通常都会实现一个叫做“点击页面其他元素,modal自动关闭”的功能。

现在利用React Hooks的useRef方法就可以实现这个功能了。useRef这个hook主要用来解决元素或组件引用的问题,我们可以通过给组件传入ref属性来获取当前组件的实例。

实现原理比较简单,在document元素上绑定一个点击事件,判断当前点击元素是否是目标元素即可。封装成useClickOutside hook后,代码如下:

function useClickOutside(elRef, callback) {
    const callbackRef = React.useRef();
    callbackRef.current = callback;

    React.useEffect(() => {
        const handleClickOutside = e => {
            if (elRef?.current?.contains(e.target) && callback) {
                callbackRef.current(e);
            }
        }

        document.addEventListener('click', handleClickOutside, true);

        retrun () => {
            document.removeEventListener('click', handleClickOutside, true)
        }
    }, [callbackRef, elRef]);
}

有了这个自定义Hook后,传入所要使用的元素实例以及对应的回调函数即可:

function Menu() {
    const menuRef = React.useRef();

    const onClickOutside = () => {
        console.log('clicked outside');
    };

    useClickOutside(menuRef, onClickOutside);

    return (<div ref={menuRef}></div>)
}

useSelector

我们都知道,之前使用redux进行状态管理的时候,都需要用connect来封装组件。而react-redux从7.1之后发布了新的Hook API useSelector。利用它我们就可以替换原来需要用connect进行封装的高阶组件了:

import { useSelector } from "react-redux";
import { createSelector } from 'reselect';

const selectHaveDoneTodos = createSelector(
    state => state.todos,
    todos => todos.filter(todo => todo.isDone)
)

function Todos() {
    const doneTodos = useSelector(selectHaveDoneTodos);
    return <div>{doneTodos}</div>
}

这样一来,就避免了代码中class组件和functional组件分散得到处都是的问题。

全局状态管理

对于全局状态的管理,我们可以结合createContextuseReducer来实现。前者会创建一个新的上下文对象,然后利用这个对象就可以保存一些特定的全局状态。而后者主要负责状态的分发和修改。

下面来实现一个StoreProvider组件:

const context = React.createContext();

export function StoreProvider({
    children,
    reducer,
    initialState = {}
}) {
    const [store, dispatch] = React.useReducer(reducer, initialState);

    const contextValue = React.useMemo(() => [store, dispatch], [store, dispatch]);

    return (<context.Provider value={contextValue}>
        {children}
    </context.Provider>)
}

可以看到该组件和react-redux提供的Provider组件类似,任何它的子组件都能够访问到对应的全局状态。如果你的应用比较简单,该组件完全就可以满足你的需要,不必再引入繁重的react-redux框架。

多个上下文

上面的组件并没有对外开放接口,所有

const storeContext = React.createContext();
const dispatchContext = React.createContext();

export const StoreProvider = ({ children, reducer, initialState = {} }) => {
    const [store, dispatch] = React.useReducer(reducer, initialState);

    return (
        <dispatchContext.Provider value={dispatch}>
            <storeContext.Provider value={store}>
                {childern}
            </storeContext.Provider>
        </dispatchContext.Provider>
    )
}

export function useStore() {
    return React.useContext(storeContext);
}

export function useDispatch() {
    return React.useContext(dispatchContext);
}

完成上面的基础工作后,我们再来看一下,要如何在组件中更新状态呢?

import { useDispatch } from "./useStore";

function Todo ({ todo }) {
    const dispatch = useDispatch();

    const handleClick = () => {
        dispatch({ type: 'toggleTodo', todoId: todo.id });
    }

    return (
        <div onClick={handleClick}>{todo.name}</div>
    )
}

可以看到,组件状态的更新主要是利用useStore暴露出来的dispatch方法来实现,核心思想和redux是类似的,都是通过单一数据流。

我们同样可以借鉴redux的思想,来实现一个工厂方法:

function makeStore(reducer, initialState) {
    // do something
    return [StoreProvider, useDispatch, useStore];
}

利用makeStore这个方法,只要传入初始状态和reducer就能实现自定义的状态管理器:

import makeStore from './makeStore'

const todosReducer = (state, action) => {...}

const [
    TodosProvider,
    useTodos,
    useTodosDispatch
] = makeStore(todosReducer, [])

export { TodosProvider, useTodos, useTodosDispatch }

从缓存中恢复状态

有时候为了提供应用的性能,你需要利用缓存技术。那么我们完全可以借助localStorage来给状态加上持久化的功能。只要在每次更新状态的时候,同时更新localStorage里的值,然后下次再创建store时就能自动获取缓存,从而加快应用的启动。

export default function makeStore(userReducer, initialState, key) {
    const dispatchContext = React.createContext();
    const storeContext = React.createContext();

    try {
        initialState = JSON.parse(localStorage.getItem(key)) || initailState
    } catch {}

    const reducer = (state, action) => {
        const newState = userReducer(state, action);
        localStorage.setItem(key, JSON.stringify(newState));
        return newState;
    }

    const StoreProvider = ({ childern }) => {
        const [store, dispatch] = React.useReducer(reducer, initialState);

        return (
            <dispatchContext.Provider value={dispatch}>
                //...
            </dispatchContext.Provider>
        )
    }
}

异步处理

用户界面通常是同步的,而业务逻辑,如状态、计算等等通常是异步的,那么如何处理这些逻辑呢?

我们可以先创建一个自定义hook:useTodos,它会返回异步请求对应的数据以及状态:

import { useTodosStore } from "./useTodosStore";

export function useTodos() {
    // do something

    return {
        todos,
        isLoading: false,
        error: null
    }
}

接着我们利用useState,useEffectaxios来扩充一下功能:

export function useTodos() {
    const [todos, setTodos] = React.useState({});
    const [isLoading, setIsLoading] = React.useState(false);
    const [error, setError] = React.useState(null)

    const fetchTodos = React.useCallback(async () => {
        setIsLoading(true)

        try {
            const {data: todos} = await axios.get('/todos');
            setTodos(todos)
        } catch (err) {
            setError(err)
        }

        setIsLoading(false)
    }, [setIsLoading, setTodos, setError]);

    React.useEffect(() => {
        fetchTodos()
    }, [fetchTodos]);

    return {
        todos,
        isLoading,
        error
    }
}

我们可以进一步简化这部分的代码,将公用的数据请求逻辑抽取出来,成为usePromise hook:

function usePromise(callback) {
    const [isLoading, setIsLoading] = React.useState(false);
    const [error, setError] = React.useState(null);
    const [data, setData] = React.useState(null);
    
    const process = async () => {
        setIsLoading(true);

        try {
            const data = await callback();
            setData(data);
        } catch (err) {
            setError(err);
        }

        setIsLoading(false)
    };

    React.useEffect(() => {
        process();
    }, [setIsLoading, setData, setError]);

    return {
        data,
        isLoading,
        error
    }
}

export function useTodos() {
    const getTodos = React.useCallback(async () => {
        const { data } = await axios.get('/todos');
        return data;
    }, []);

    const { data: todos, isLoading, error } = usePromise(getTodos);

    return {
        todos,
        isLoading,
        error
    }
}

完成后,我们就可以在组件中使用这部分代码了。

总结

自从react hooks发布以来,以前很多冗余的状态逻辑处理都能很轻松地进行抽象复用。大家也可以在github等地方找到别人实现的许多自定义hooks,利用这些自定义hooks可以让我们前端的代码更加简洁和优雅。最后,推荐一个网站,https://usehooks.com/ 这个网站上记录了很多实用的hooks,大家可以按需使用。

——--转载请注明出处--———

最后,欢迎大家关注我的公众号,一起学习交流。


微信扫描二维码,关注我的公众号.jpg

参考资料

https://www.youtube.com/watch?v=J-g9ZJha8FE

https://learning.oreilly.com/library/view/the-modern-web/9781457172489/media_queries_in_javascript.html
https://www.30secondsofcode.org/react/s/use-click-outside

https://kentcdodds.com/blog/how-to-use-react-context-effectively/

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260