XState-状态机 VS HOOKS

538 阅读6分钟

什么是 XState ?

XState 是一个库,用于创建、解释和执行有限状态机状态图,以及管理这些状态机与演员(Actor)调用。

🆚 状态机 VS HOOKS 示例

  1. 需求描述:根据输入的关键字进行搜索,并将搜索结果显示出来,并允许用户取消请求。

状态机需求实例.png

  1. hooks 实现流程
  • 定义 state
    • dataList: 存放请求结果
    • loading:接口响应比较慢时的loading效果;当用户在等待搜素请求的时候,结果返回前,禁止再次发送请求:
    • isError:接口出错状态
    • fetchAbort: 取消请求的 AbortController
  • 定义请求方法
    • 请求未完成之前,禁止用户再次发起请求
    • 请求前 标记 loading
    • 请求成功 之后 保存结果清除error状态清除loading状态
    • 请求失败 标记error状态清除loading状态
  • 定义取消请求方法
    • 调用前需要判断 loading状态fetchAbort是否存在
function useSearch() {
    const SEARCH_URL = "/edrawsoft/mindmap/1/PV/DESC/CN/1/";
    // 请求结果
    const [dataList, setDataList] = useState([]);
    // loading 作用:1,UI loading 样式 2,当用户在等待搜素请求的时候,结果返回前,禁止再次发送请求:
    const [loading, setLoading] = useState(false);
    // 接口出错了
    const [isError, setIsError] = useState(false);
    // 用来中止一个请求
    const fetchAbort = useRef(null);

    // 请求数据
    const fetchData = useCallback((keyword) => {
        if (!loading) { // 当用户在等待搜素请求的时候,结果返回前,禁止再次发送请求:
            setLoading(true);// 请求开始,loading 设置为true

            fetchAbort.current = new AbortController();

            fetch(SEARCH_URL + keyword, { signal: fetchAbort.current.signal })
                .then(response => response.text())
                .then(str => new window.DOMParser().parseFromString(str, "text/xml"))
                .then((data) => {
                    setIsError(false);// 请求成功了,清除错误状态

                    setDataList(Array.from(data.documentElement.children[0].children)
                        .filter(item => item.tagName === 'item'))
                })
                .catch((e) => {
                    if (e && e.name === "AbortError") { // 手动中止请求
                        // 手动取消请求,啥也不干
                    } else {
                        setIsError(true);// 标记错误状态
                    }
                })
                .finally(() => {
                    setLoading(false);// 记得把 Loading 关掉:
                });
        }

    }, [loading]);

    // 取消请求
    const cancelFetch = useCallback(() => {
        if (loading && fetchAbort.current) { // 请求时才能取消请求
            fetchAbort.current.abort();
        }
    }, [loading]);

    return { loading, dataList, isError, fetchData, cancelFetch };
}

页面使用


function AppHooks() {
    const { loading, dataList, isError, fetchData, cancelFetch } = useSearch();

    return (
        <div className="app-hook">
            <h3>app-hook</h3>
            <Search onSearch={fetchData} disabled={loading} />
            <button onClick={cancelFetch}>取消请求</button>

            <p>{loading ? "loading...." : " "}</p>
            <p>{isError ? "Error...." : " "}</p>

            <ul className="app-res-list">
                {dataList.map(item => <li key={item.children[4].innerHTML}>
                    <a href={item.children[4].innerHTML}>{item.children[0].innerHTML}</a>
                </li>)}
            </ul>
        </div>
    );
}

平常使用的都是这种事项方式,也对他存在的问题感同身受:

  • 难以阅读理解,包含各种分散使用的 flag 变量和嵌套着各种 if/else 的代码,由于功能的快速迭代,来不及留下业务流程或者代码逻辑说明,所有逻辑流程只存在开发当时的脑子里,后边其他同学或者自己开发时得从头梳理一遍逻辑,才能继续开发,如果没有注释的话可能就💣了。
  • 难以扩展,如果是很久远的代码,不好理解,不清楚为什么这么写,写了有啥作用,导致老的逻辑不敢动,新的逻辑不敢加。
  • 可能含有隐藏的 Bug;边界不清楚,容易遗漏一些条件判断。
  • 新功能增加时使逻辑进一步混乱,老的逻辑不能动,就只能在老逻辑上新增判断逻辑,结果代码逻辑就越来越长。
  1. 看下用 XState 怎么实现
  • 定义一个状态机 fetchMachine
    • 定义 readyloadingerrorsuccessuserCancel 5个状态。
    • 定义初始状态 ready
    • 定义一个上下文,也就是存请求结果的地方:dataList
    • 定义一个 fetch-data 子状体机 ,他和 fetchMachine 的关系可以理解为 父子组件的关系,他接受两个事件 FETCH 用来发起请求,CANCEL_FETCH 用来取消请求。请求成功后 触发 fetchMachineFETCH_SUCCESS 事件,失败时 触发 fetchMachineFETCH_ERROR 事件
    • readyerrorsuccessuserCancel 这几个状态 直接收 FETCH_DATA事件,就是只能发起请求,触发时转为 loading 状态。
    • loading 进入这个状态时,会触发 fetch-dataFETCH 事件,用来发起请求。可以接受以下几个事件。
      • FETCH_SUCCESS 请求成功,触发时更新dataList,状态机转换为 success 状态;
      • FETCH_ERROR 请求失败,状态机转换为 error 状态;
      • FETCH_CANCEL 用户取消请求,状态机转换为 userCancel 状态;

// 请求 fetch-data 子状体机
function invokeFetchData() {
    let fetchAbort = null; // 取消请求 AbortController
    const SEARCH_URL = "/edrawsoft/mindmap/1/PV/DESC/CN/1/";

    // 请求接口
    function fetchData(keyword) {
        fetchAbort = new AbortController();

        return fetch(SEARCH_URL + keyword, { signal: fetchAbort.signal })
            .then(response => response.text())
            .then(str => new window.DOMParser().parseFromString(str, "text/xml"))
    }

    // 取消请求
    function cancelFetch() {
        fetchAbort.abort();
    }

    return function invokeFetchDataFn(sendBack, onReceive) {
        onReceive(e => {
            if (e.type === "FETCH") {
                fetchData(e.data)
                    .then(data => {
                        sendBack({
                            type: "FETCH_SUCCESS",
                            data: Array.from(data.documentElement.children[0].children)
                                .filter(item => item.tagName === 'item')
                        })
                    })
                    .catch(err => {
                        if (err.name !== "AbortError") {
                            sendBack({ type: "FETCH_ERROR", err });
                        }
                    })
            } else if (e.type === "CANCEL_FETCH") {
                cancelFetch();
            }
        })
    }
}

const fetchMachine = createMachine(
    {
        id: 'fetchMachine',
        initial: 'ready',
        context: {
            dataList: [], // 请求结果
        },
        invoke: [{
            id: "fetch-data",
            src: "invokeFetchData"
        }],
        states: {
            ready: { // 空闲
                on: {
                    "FETCH_DATA": { target: 'loading' }
                }
            },
            loading: { // 请求中
                entry: [send((_, e) => ({ type: "FETCH", data: e.data }), { to: "fetch-data" })], // 告诉 fetch-data 调用接口
                on: {
                    "FETCH_SUCCESS": { // 接口调用成功了
                        target: "success",
                        actions: assign({ dataList: (_, e) => e.data }), // 更新dataList
                    },
                    "FETCH_ERROR": { // 接口调用失败了
                        target: "error"
                    },
                    "FETCH_CANCEL": { // 用户取消了请求
                        target: "userCancel",
                        actions: send({ type: "CANCEL_FETCH" }, { to: "fetch-data" }), // 告诉 fetch-data 取消请求
                    }
                }
            },
            error: { // 请求成功
                on: {
                    "FETCH_DATA": { target: 'loading' }
                }
            },
            success: { // 请求失败
                on: {
                    "FETCH_DATA": { target: 'loading' }
                }
            },
            userCancel: { // 用户取消了请求
                on: {
                    "FETCH_DATA": { target: 'loading' }
                }
            }
        }
    },
    {
        services: {
            invokeFetchData
        }
    }
);

如果VSCode 装了 XState VSCode 插件的话可以自动生成状体图

Pasted image 20220629154130.png

Pasted image 20220629154208.png

页面使用:

function AppMatchine() {
    const [state, send] = useMachine(fetchMachine);

    return (
        <div className="app-machine">
            <h3>app-machine</h3>
            <Search onSearch={(keyword) => send({ type: "FETCH_DATA", data: keyword })} disabled={state.matches("loading")} />
            <button onClick={() => send("FETCH_CANCEL")}>取消请求</button>

            <p>{state.matches("loading") ? "loading...." : " "}</p>
            <p>{state.matches("error") ? "Error...." : " "}</p>

            <ul className="app-res-list">
                {state.context.dataList.map(item => <li key={item.children[4].innerHTML}>
                    <a href={item.children[4].innerHTML}>{item.children[0].innerHTML}</a>
                </li>)}
            </ul>
        </div>
    );
}

可以显著的看出以下几个优点:

  • 逻辑清晰,什么状态能干什么事以及接受到事件后会转移到什么状态 可以很容易看出来。
  • 状态图,可以自动生成状态图,方便查看整体逻辑。
  • 页面使用简单,没有很多变量的判断。

🤔 思考

  1. 使用 XState 有哪些好处?
  • UI 组件逻辑 和 业务逻辑 拆分,比如按钮组件只需要知道它是可点击的还是禁止点击的,以及点击它会触发一个事件。至于他为什么可以点击,触发的事件 搜索 还是 重置 并不需要关心。
  • 状态图可以帮助我们关注应用所有可能的状态、状态之间的相互关系以及每个状态中可能发生的操作,从而消除所有可能存在的问题/错误场景,无论是代码问题还是 UI 问题。它也会迫使我们思考什么的状态机可以适用到视图或 UI 组件上。
  • 协作的问题,有了图,就好沟通了。
  • 有了图,便于记忆整个逻辑关系,例如 TCP 有限状态机
  1. 只有优点吗?
  • 新的功能,新的页面感觉可能会用一下,老的逻辑如果要使用的话,时间成本和风险有点儿大。
  • 学习和试错成本问题,相对简单的逻辑还可以,相对复杂的逻辑不知道能不能解决。

参考

  1. 深入浅出理解有限状态机
  2. XState词汇表
  3. 有限状态机之前端开发
  4. 降低前端业务复杂度新视角:状态机范式
  5. XState 状态管理