TypeScript 用腻了?来换换口味吧——使用 ReScript + React 写一个简单的Todo List

温镜
2023-12-01

前言

前端技术如今蓬勃发展,时时刻刻都有新的技术栈诞生,这使得各位前端们常常大呼学不动了。既然如此笔者为何还要去学一门和 TypeScript 类似的新语言呢,难道 TypeScript 不够用嘛。

TypeScript 生态现在已经足够强大了,那么学习一个新的 ReScript 又有什么优势呢?ReScript 是对函数式友好的编程语言,函数默认自带柯里化,原生支持 pipe 功能,友好的类型推导,以及没有 null 和 undefined 的概念来消除了因其而产生的 Bug 。还有一点就是官方的 compiler 是不依赖于 node 的原生二进制程序,所以编译速度是 TypeScript 所不能及的,虽然目前已经有诸如 esbuild 之类的 TypeScript 编译器,速度是够快,但不支持类型判断。

项目地址 在线 demo

下面笔者就从头开始写一个 Todo List 来简单的认识一下 ReScript

新建 Reducer.res

  • 定义数据类型
type action =
  | AddTodo(string)
  | RemoveTodo(int)
  | ToggleTodo(int)
// reducer action 直接用 ReScript 自带的 Variant 结构来定义,可以利用其强大的模式匹配能力减少样板代码
type todo = {
  id: int,
  completed: bool,
  text: string,
}
type state = {
  todos: array<todo>,
  nextId: int,
} 
  • 添加 reducer 函数
let reducer = ({todos, nextId}, action) => {
  switch action {
  | AddTodo(text) => {
      todos: todos->Belt.Array.concat([
        {
          id: nextId,
          text: text,
          completed: false,
        },
      ]),
      nextId: nextId + 1,
    }
  | RemoveTodo(id) => {todos: todos->Belt.Array.keep(todo => todo.id != id), nextId: nextId}
  | ToggleTodo(id) => {
      todos: todos->Belt.Array.map(todo => {
        ...todo,
        completed: todo.id == id ? !todo.completed : todo.completed,
      }),
      nextId: nextId,
    }
  }
} 

不用手动指定 reducer 参数的类型,编译器会根据上下位自动推断出 {todos, nextId}: state, action: action

因为有强大的匹配模式能力,代码结构清晰来许都,也不用写得像 TypeScript 那么啰唆;

TypeScript 版本:

type Action = {
  type: "ADD_TODO" | "REMOVE_TODO" | "TOGGLE_TODO"
  id?: number
  text?: string
}
type Todo = {
  id: number
  text: string
  completed: boolean
}
type State = {
  todos: Todo[]
  nextId: number
}

let reducer = ({ todos, nextId }: State, action: Action) => {
  switch (action.type) {
    caTodoItemse "ADD_TODO":
      return {
        todos: [
          ...todos,
          {
            id: nextId,
            text: action.text,
            completed: false,
          },
        ],
        nextId: nextId + 1,
      }
    case "REMOVE_TODO":
      return { todos: todos.filter((todo) => todo.id != action.id), nextId: nextId }
    case "TOGGLE_TODO":
      return {
        todos: todos.map((todo) => ({
          ...todo,
          completed: todo.id == action.id ? !todo.completed : todo.completed,
        })),
        nextId: nextId,
      }
  }
} 

添加组件

  • 添加 AddTodo 组件
@react.component
let make = (~onAdd: option<string> => unit) => {
  let inputValueRef: React.ref<option<string>> = React.useRef(None)

  let onSubmit = (e: ReactEvent.Form.t) => {
    e->ReactEvent.Form.preventDefault
    onAdd(inputValueRef.current)
  }

  <form className="rounded-lg w-full grid px-6 grid-cols-12" onSubmit>
    <input
      className="border rounded-l-lg bg-slate-50 h-8 text-xl py-6 px-4 ring-sky-200 col-span-10  focus:(outline-none border-sky-400 ring-2 z-10) "
      onChange={e => e->ReactEvent.Form.target->(target => inputValueRef.current = target["value"])}
      required={true}
    />
    <button
      type_="submit"
      className="bg-white border rounded-r-lg cursor-pointer flex border-l-0 text-lg ring-sky-400 col-span-2 items-center justify-center hover:(bg-slate-100) focus:(ring-2 bg-slate-50) ">
      <Icon.Plus size={36} stroke={1} />
    </button>
  </form>
} 

使用 tailwindcss 来简化样式的书写

  • 然后引入到 App
open Reducer

@react.component
let default = () => {
  let (state, dispatch) = reducer->React.useReducer({
    todos: [],
    nextId: 1,
  })

  let onAdd = value => {
    switch value {
    | Some(str) =>
      if Js.String.trim(str) != "" {
        str->AddTodo->dispatch
      }
    | None => ()
    }
  }

  <div className="bg-white rounded-md mx-auto border-1 shadow-md mt-20 pt-4 pb-8 w-[480px]">
    <h1 className="text-center"> {React.string("Todo List")} </h1>
    <Addtodo onAdd />
  </div>
} 

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-o9T6vTOa-1655351428688)(https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/61f23e4dd9db4215bf6745d55d066ff7~tplv-k3u1fbpfcp-zoom-in-crop-mark:1956:0:0:0.image?)]

ReScript 的模块导入不用写特定的语法,只要用文件名就可以了,比如我有个模块文件名为 Foo.res ,在另外一个文件理要引入模块 Foo 里面的 name 值,因为 ReScript 默认导出所有变量,只需这么写就可以直接引入

// Foo.res
let name = "ReScript" 
// Test.res
let lastName = Foo.name 
  • 添加 TodoItem 组件
type todo = Reducer.todo
@react.component
let make = (~todo: todo, ~onToggle: int => unit, ~onRemove: int => unit) => {
  let {id, text, completed} = todo
  <li className="border-b flex py-4 px-6 text-3xl items-center">
    <input
      type_="checkbox"
      className="h-6 m-0 mr-4 w-6 accent-sky-600"
      checked={completed}
      onChange={_ => onToggle(id)}
    />
    <span
      className={`-mt-1 break-words max-w-[350px] ${completed
          ? "text-gray-500 line-through"
          : "text-black"}`}>
      {React.string(text)}
    </span>
    <button
      className="bg-transparent cursor-pointer ml-auto h-6 p-0 w-6 hover:text-rose-600"
      onClick={_ => onRemove(id)}>
      <Icon.Trash size={24} />
    </button>
  </li>
} 

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-t5jkEybG-1655351428689)(https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/de56db53e7c0409c8af5faafb3df48e1~tplv-k3u1fbpfcp-zoom-in-crop-mark:1956:0:0:0.image?)]

  • 添加 TodoList 组件
open Reducer

@react.component
let make = (~todos: todos, ~onDispatch: action => unit) => {
  <>
    <ul className="border-t list-none py-0 px-0">
      {todos
      ->Belt.Array.keep(({completed}) => {
        switch filter {
        | #showAll(_) => true
        | #showActive(_) => !completed
        | #showCompleted(_) => completed
        }
      })
      ->Belt.Array.map(todo =>
        <TodoItem
          key={Js.Int.toString(todo.id)}
          todo
          onToggle={id => id->ToggleTodo->onDispatch}
          onRemove={id => id->RemoveTodo->onDispatch}
        />
      )
      ->React.array}
    </ul>
  </>
} 

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4NJG0ZAR-1655351428689)(https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c6b049241d0147c1883ec906e34b0c13~tplv-k3u1fbpfcp-zoom-in-crop-mark:1956:0:0:0.image?)]

TodoList 加上标签切换功能

open Reducer

let filterTitles = [#showAll("All"), #showActive("Active"), #showCompleted("Completed")]

@react.component
let make = (~todos: todos, ~onDispatch: action => unit) => {
  let (filter, setFilter) = React.useState(_ => #showActive("Active"))
  let allNumber = todos->Belt.Array.length
  let activeNumber = todos->Belt.Array.keep(({completed}) => !completed)->Belt.Array.length
  let completedNumber = allNumber - activeNumber

  <>
    <div className="border-t flex mt-6 px-6 justify-end">
      <ul className="flex list-none my-0 px-0">
        {filterTitles
        ->Belt.Array.map(item => {
          let #showAll(title) | #showActive(title) | #showCompleted(title) = item
          let count = switch item {
          | #showAll(_) => allNumber
          | #showActive(_) => activeNumber
          | #showCompleted(_) => completedNumber
          }
          let active = item == filter

          <li
            key={title}
            className={`${active
                ? "bg-gray-50 border-t-sky-400"
                : "bg-gray-200"} rounded-b-lg border border-t-2 border-gray-200 cursor-pointer flex ml-2 py-2 px-2 items-center`}
            onClick={_ => setFilter(_ => item)}>
            {React.string(title)}
            <span className="rounded-md bg-sky-600 text-sm text-white ml-1 px-1 pb-[0.8px]">
              {count->Js.Int.toString->React.string}
            </span>
          </li>
        })
        ->React.array}
      </ul>
    </div>
    <ul className="border-t list-none py-0 px-0">
      {todos
      ->Belt.Array.keep(({completed}) => {
        switch filter {
        | #showAll(_) => true
        | #showActive(_) => !completed
        | #showCompleted(_) => completed
        }
      })
      ->Belt.Array.map(todo =>
        <TodoItem
          key={Js.Int.toString(todo.id)}
          todo
          onToggle={id => id->ToggleTodo->onDispatch}
          onRemove={id => id->RemoveTodo->onDispatch}
        />
      )
      ->React.array}
    </ul>
  </>
} 

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WsaIpcbt-1655351428690)(https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e7bf504fc6e54e63b42815a126a0a805~tplv-k3u1fbpfcp-zoom-in-crop-mark:1956:0:0:0.image?)]

  • 然后把 TodoList 引入到 App 中就完成了。
<div className="bg-white rounded-md mx-auto border-1 shadow-md mt-20 pt-4 pb-8 w-[480px]">
    <h1 className="text-center"> {React.string("Todo List")} </h1>
    <Addtodo onAdd />
    <TodoList todos={state.todos} onDispatch={dispatch} />
</div> 

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nrtR2m2w-1655351428690)(https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/71f937e00a4f450bb1480f18f75b75ea~tplv-k3u1fbpfcp-zoom-in-crop-mark:1956:0:0:0.image?)]

总结

ReScript 是对函数式友好的编程语言,有其独特的类型系统,不像 TypeScript 的类型是对 JavaScript 原有类型的补全。类型自动推导十分便利,不用再手动指定类型。Variant 数据结构和模式匹配极大方便了开发,减少样板代码的存在。 虽然类型推导很便利,但没有 TypeScript 那样的类型体操,遇到比较复杂的数据结构需要手动定义。

 类似资料: