吐槽向
从我实习开始,虽然主职是 iOS 开发,但是还是要处理 React Native 项目相关任务,于是就接触到 React 相关的周边 Libs,其中最出名的就是 Redux。使用它时,要配合 Action、Reducer 及 Dispatch 来完成一个任务,当时就在想做一桩事要配合字符串是真的让人难绷,后来接触语言多了之后才发觉,很可能是由于 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 文件,下面是我们声明了一个 action 的 variant,这个类型一眼就看出能做啥,做 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
虽然之前已经处理了 Action 跟 Reducer,但是我们没解决 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 跳过检查的冲动。