ReScript 真好玩-ReScript 简化版 Redux 实现

328 阅读4分钟

吐槽向

从我实习开始,虽然主职是 iOS 开发,但是还是要处理 React Native 项目相关任务,于是就接触到 React 相关的周边 Libs,其中最出名的就是 Redux。使用它时,要配合 ActionReducerDispatch 来完成一个任务,当时就在想做一桩事要配合字符串是真的让人难绷,后来接触语言多了之后才发觉,很可能是由于 JavaScript 有些方面太弱,所以很多其他语言很容易实现的东西,在它这边要靠字符串来处理,哪怕我刚毕业就换到 TypeScript 写相关业务代码,还是由于 TypeScript 完美继承 JavaScript 的缺点导致我根本体会不到 Redux 的优秀。

创建一个简单的 ReScript + React + Vite 项目

在开始之前,有些基础语法概念及写法请参考我之前的一篇水文 ReScript 真好玩-写个 CSV 转 JSON 的 CLI,尤其是涉及到 ReScript Module 语法相关的内容。

我们最终要生成的文件目录结构长这样

.
├── README.md
├── bun.lockb
├── index.html
├── package.json
├── public
│   └── vite.svg
├── rescript.json
├── src
│   ├── App.res
│   ├── Main.res
│   ├── Redux.res
│   ├── assets
│   │   ├── css
│   │   │   ├── App.css
│   │   │   └── index.css
│   │   └── react.svg
│   └── main.js
└── vite.config.js

首先使用 Vite 根据提示创建一个 React 项目,最好是选择 JS + SWC,因为我们根本用不上 TypeScript

bun create vite

然后把通过 vite 创建的 React 项目的 package.json 文件修改一下,主要是引用了一几个 ReScript 相关的包,还把几个 ESLint 相关的包去掉

{
  "name": "rescript-redux-demo",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "start": "run-p start:**",
    "start:dev": "vite",
    "start:re": "rescript build -w",
    "build": "run-s build:**",
    "build:vi": "vite build",
    "build:re": "rescript build -with-deps",
    "clean": "rescript clean -with-deps",
    "preview": "vite preview",
    "postinstall": "rescript build -with-deps",
    "format": "rescript format"
  },
  "dependencies": {
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "@rescript/core": "^1.6.1",
    "rescript": "^11.1.4"
  },
  "devDependencies": {
    "@rescript/react": "^0.13.0",
    "npm-run-all": "^4.1.5",
    "@vitejs/plugin-react-swc": "^3.5.0",
    "vite": "^5.4.8"
  }
}

挨下来修改 index.html 文件内容,主要是 <script type="module" src="/src/main.js"></script> 这段修改

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + ReScript</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

然后,我们在 src 目录下把 main.jsx 改名成 main.js,里面内容修改成

import "./Main.bs.js"

那很自然地我们就要创建一个 Main.res 的文件,这里涉及到一个知识点 %%raw("import './assets/css/index.css'"),这一句是内嵌 javascript 语法,主要是用来引入 index.css

%%raw("import './assets/css/index.css'")

switch ReactDOM.querySelector("#root") {
| Some(rootElement) =>
  ReactDOM.Client.createRoot(rootElement)->ReactDOM.Client.Root.render(
    <React.StrictMode>
      <App />
    </React.StrictMode>,
  )
| None => ()
}

然后是把 App.jsx 改成了 App.res,这里涉及到几个知识点,一个是 ReScript 是强类型语言,所以它的字符串要转成 React.string 的字符串(这里只是语义级别的转换,实际生成的代码就是 JS 的字符串,所以没有性能开销),还有 count 这个 int 类型要显示在 HTML 上就要用 Int.toString 函数转换一下,这个语言一致性做得很好,这点非常棒

@module("./assets/react.svg") external reactLogo: string = "default"
@module("/vite.svg") external viteLogo: string = "default"

%%raw("import './assets/css/App.css'")

@react.component
let make = () => {
  let (count, setCount) = React.useState(_ => 0)

  <>
    <div>
      <a href="https://vitejs.dev" target="_blank">
        <img src={viteLogo} className="logo" alt="Vite logo" />
      </a>
      <a href="https://react.dev" target="_blank">
        <img src={reactLogo} className="logo react" alt="React logo" />
      </a>
    </div>
    <h1> {"Vite + React"->React.string} </h1>
    <div className="card">
      <button onClick={_ => setCount(count => count + 1)}>
        {`count is ${count->Int.toString}`->React.string}
      </button>
      <p>
        {"Edit "->React.string}
        <code> {"src/App.jsx"->React.string} </code>
        {" and save to test HMR"->React.string}
      </p>
    </div>
    <p className="read-the-docs">
      {"Click on the Vite and React logos to learn more"->React.string}
    </p>
  </>
}

用 ReScript 实现简化版 Redux

Action

我们知道 Redux 有一个 Action 的东西概念,其实就是表达一个操作,这个操作可以携带相关数据,那这玩意在 ReScript 里表达就相当简单啦。打开 Redux.res 文件,下面是我们声明了一个 actionvariant,这个类型一眼就看出能做啥,做 Add 动作同时可以携带 string 类型的数据,做 Increment 动作可以携带 int 类型的数据。

type action = Add(string) | Increment(int)

Reducer

然后我们讨论一下 Reducer,其实这玩意就是根据你的 state 及相关 action 来进行一个事件监听,假设我们的初始 state 是

// ... type action
type state = {str: string, number: int}
let state = {str: "xxx", number: 0}

配合 ReScript 的模式匹配,总结一个字爽

// ... type action
// ... type state
// ... let state
let reducer = (state: state, action: action) => {
    switch action {
    | Add(s) => {...state, str: s}
    | Increment(number) => {...state, number}
    }
}

Store

虽然之前已经处理了 ActionReducer,但是我们没解决 Store 的声明,现在来处理下。首先当然是声明一下类型

type store<'state, 'action> = {
  mutable state: 'state,
  reducer: ('state, 'action) => 'state
}

Dispatch

现在来写一个函数,名叫 dispatch,它的作用是在 store 上作用 action,通过 reducer 来产生新的 state 然后更新 store 的 state

// ... type store
let dispatch = (store, action) => {
  store.state = store.reducer(store.state, action)
}

初始化状态

根据 ReScript 的惯例,我们应该写一个 make 函数来做一些初始化工作

// ... type store
// ... let dispatch
let make = (initialState, reducer) => {
  {state: initialState, reducer}
}

Provider 组件

现在来写一个简单的 Provider 组件,同时我们还要考虑一个问题,那就是类型不确定,我们既然要用一个强类型语言,那自然要利用它的优势根据之前写得那篇文章的方式,我们先可以声明一下 Module 类型

module type R = {
  type state
  type action
}

同样的 Make 套路,这里又涉及一个知识点,那就是要把一个函数转成一个 React 组件,要加 module 跟 @react.component 组件的形式,里面就简单地调相关的逻辑,这里就是用 React.createElement 的 React 传统套路来写组件

module Make = (T: R) => {
  module Provider = {
    @react.component
    let make = (~store: store<T.state, T.action>, ~children: React.element) => {
      React.createElement(provider, {value: Some(store), children})
    }
  }
}

module StoreT = {
  type action = Add(string) | Increment(int)

  type state = {str: string, number: int}
  let state = {str: "xxx", number: 0}

  let reducer = (state: state, action: action) => {
    switch action {
    | Add(s) => {...state, str: s}
    | Increment(number) => {...state, number}
    }
  }
}

include Make(StoreT)

然后我们在 Main.res 文件中用一下

%%raw("import './assets/css/index.css'")

open Redux

switch ReactDOM.querySelector("#root") {
| Some(rootElement) =>
  ReactDOM.Client.createRoot(rootElement)->ReactDOM.Client.Root.render(
    <React.StrictMode>
      <Provider store>
        <App />
      </Provider>
    </React.StrictMode>,
  )
| None => ()
}

React Context

虽然我们在 Main.res 文件中使用了 Provider 组件,但是又发现一个新问题,我们在 App 组件中不知道怎么取 store 里的数据,也就是讲我们要实现一个 useSelector 来解决这个问题,但是 useSelector 又涉及到 React Context 相关的使用。
那继续在 module Make 里加点料,用上 React 的 Context,这里不处理取不到 store 的情况,我直接 assert(false)

module Make = (T: R) => {
  // ... Provider
  let context = React.createContext(None)
  let provider = React.Context.provider(context)
  
  let useSelector = selector => {
    switch React.useContext(context) {
    | Some(store) => selector(store.state)
    | None => assert(false)
    }
  }
}

useDispatch

既然有了 useSelector,那我们该实现 useDispatch 了,比较简单,调用 useDispatch 函数后,会从 React Context 取到 store 再返回一个新函数,后续要使用只要传对应的 action 就行

module Make = (T: R) => {
  module Provider = {
    @react.component
    let make = (~store: store<T.state, T.action>, ~children: React.element) => {
      React.createElement(provider, {value: Some(store), children})
    }
  }
  
  let context = React.createContext(None)
  let provider = React.Context.provider(context)

  let useDispatch = () => {
    switch React.useContext(context) {
    | Some(store) => action => dispatch(store, action)
    | None => assert(false)
    }
  }

  let useSelector = selector => {
    // ...
  }
}

有了 useSelector 跟 useDispatch 的组合,现在来试验一下

@module("./assets/react.svg") external reactLogo: string = "default"
@module("/vite.svg") external viteLogo: string = "default"
%%raw("import './assets/css/App.css'")

open Redux

@react.component
let make = () => {
  let str = useSelector(state => state.str)
  let dispatch = useDispatch()

  <>
    <div>
      <a href="https://vitejs.dev" target="_blank">
        <img src={viteLogo} className="logo" alt="Vite logo" />
      </a>
      <a href="https://react.dev" target="_blank">
        <img src={reactLogo} className="logo react" alt="React logo" />
      </a>
    </div>
    <h1> {`Vite + React + ${str}`->React.string} </h1>
    <div className="card">
      <button onClick={_ => dispatch(Add("ReScript"))}> {`ReScript`->React.string} </button>
      <p>
        {"Edit "->React.string}
        <code> {"src/App.jsx"->React.string} </code>
        {" and save to test HMR"->React.string}
      </p>
    </div>
    <p className="read-the-docs">
      {"Click on the Vite and React logos to learn more"->React.string}
    </p>
  </>
}

然后我们发觉,useSelector 能取到初始化时的数据,但是在调用 dispatch 并不会更新 UI,其实我们一直少做了一步,那就是每次进行 dispatch 应该执行更新 UI 操作,现在回到最初声明 type store 的地方,添加一个 subscribers 属性,并且要修改 dispatch 函数,目的是为了每次更新状态后都要执行里面的订阅器

type store<'state, 'action> = {
  mutable state: 'state,
  reducer: ('state, 'action) => 'state,
  subscribers: array<unit => unit>,
}

let dispatch = (store, action) => {
  store.state = store.reducer(store.state, action)
  store.subscribers->Array.forEach(f => f())
}

现在还要配套上添加跟删除订阅器的操作,目前还没用上

let subscribe = (store, f) => {
  store.subscribers->Array.push(f)
}

let unsubscribe = (store, f) => {
  let index = Array.indexOf(store.subscribers, f)
  Array.splice(store.subscribers, ~start=index, ~remove=1, ~insert=[])
}

然后修改 useSelector 的代码,主要是配合 useState 让 UI 更新,这时候就让 subscribe/unsubscribe 派上用场了。完成下面的步骤后,我们可以在 App.res 那块试验一下

module Make = (T: R) => {
  // ...

  let useSelector = selector => {
    switch React.useContext(context) {
    | Some(store) =>
      let (state, setState) = React.useState(() => selector(store.state))
      React.useLayoutEffect0(() => {
        let update = () => {
          setState(_ => selector(store.state))
        }
        subscribe(store, update)
        Some(() => unsubscribe(store, update))
      })
      state
    | None => assert(false)
    }
  }
}

最后补上 Redux.res 文件内的完整实现

type store<'state, 'action> = {
  mutable state: 'state,
  reducer: ('state, 'action) => 'state,
  subscribers: array<unit => unit>,
}

let dispatch = (store, action) => {
  store.state = store.reducer(store.state, action)
  store.subscribers->Array.forEach(f => f())
}

let subscribe = (store, f) => {
  store.subscribers->Array.push(f)
}

let unsubscribe = (store, f) => {
  let index = Array.indexOf(store.subscribers, f)
  Array.splice(store.subscribers, ~start=index, ~remove=1, ~insert=[])
}

let make = (initialState, reducer) => {
  {state: initialState, reducer, subscribers: []}
}

module type R = {
  type state
  type action
}

module Make = (T: R) => {
  let context = React.createContext(None)
  let provider = React.Context.provider(context)

  module Provider = {
    @react.component
    let make = (~store: store<T.state, T.action>, ~children: React.element) => {
      React.createElement(provider, {value: Some(store), children})
    }
  }

  let useDispatch = () => {
    switch React.useContext(context) {
    | Some(store) => action => dispatch(store, action)
    | None => assert(false)
    }
  }

  let useSelector = selector => {
    switch React.useContext(context) {
    | Some(store) =>
      let (state, setState) = React.useState(() => selector(store.state))
      React.useLayoutEffect0(() => {
        let update = () => {
          setState(_ => selector(store.state))
        }
        subscribe(store, update)
        Some(() => unsubscribe(store, update))
      })
      state
    | None => assert(false)
    }
  }
}

module StoreT = {
  type action = Add(string) | Increment(int)

  type state = {str: string, number: int}
  let state = {str: "xxx", number: 0}

  let reducer = (state: state, action: action) => {
    switch action {
    | Add(s) => {...state, str: s}
    | Increment(number) => {...state, number}
    }
  }
}

include Make(StoreT)
let store = make(StoreT.state, StoreT.reducer)

实现得有点粗糙,不过基本能满足一般的场景使用,而且这玩意是强类型的,编辑器/IDE 能提供类型提示,而且有个优点,写代码过程中你就会考虑到类型,不会像 TypeScript 会让你一直有种想用 any 跳过检查的冲动。