将Elmish移植到Typescript:与ChatGPT的对话
2024 年 5 月 6 日

最近,我的自定义软件团队在我目前的项目上实现了一个“导出”功能。该项目大致处于内容管理系统空间 (opens new window),用户可以上传真实文档作为输入。然后,系统随着时间聚合这些文档并处理变更跟踪和内容发布。为了加快某些工作流程,我们创建了一个“导出”功能,以导出不同类型的文档供后续编辑。这些导出通常涉及处理许多文件,因此我们希望仅在用户需求时创建导出文件。但一旦创建完成,我们希望后续的下载速度很快。

后端的概念上是一个识别“世界状态”以及缓存具有该世界状态作为缓存键的导出工件的问题。该功能非常适合像Websockets/服务器发送事件这样的内容,以通知请求客户端导出已准备就绪。然而,目前,我们采用了基本的HTTP轮询方法。当然,不同的导出具有不同的用户体验(UX)要求。有些是模态窗口,我们希望在生成并下载文件时模态保持打开状态。其他的是链接,需要在导出进行中显示旋转器,但不会阻止任何其他交互。

问题 #

这种异步状态管理在前端代码库中很快会变得混乱不堪。当我们努力触发轮询逻辑并保持正确的UI元素同步时,我们发现自己正在玩一个打地鼠的游戏,同时还在填满一盘意大利面(这种情况几乎和那个比喻一样混乱)。

在这段时间里,我一直在尝试Feliz和Elmish,以了解使用F#进行前端开发可能是什么样子。我之前在Atomic公司这里做过一个F#项目,发现F#语言和生态系统中有很多可爱之处。事实证明,Feliz和Elmish (opens new window)是创建类似Elm风格的类型安全前端项目的绝佳选择,同时还融入了一点F#的实用性。特别是,我喜欢使用Elmish的withSubscription机制。我如此喜欢它,以至于我认为类似的机制可能有助于简化我们的轮询导出功能。

因此,为了找出这个直觉是否正确,我求助于ChatGPT。我希望能够快速将我喜欢的Elmish功能移植到Typescript中。以下是我们的对话过程。

提示1 #

在Typescript中实现类似于Elmish中store的useStore<Msg, Model>。

这是朝着正确方向迈出的基本步骤。我们在这里有一个极其基本的Redux风格结构,并且与一个Msg / reducer相关联。让我们继续完善。

提示2 #

添加一个行为类似于Elmish的subscribe函数。

这增加了订阅的概念,但缺少了使Elmish如此强大的自动启动/停止行为。该代码启动订阅,但在组件卸载之前从未更改它们。让我们继续。

提示 3 #

使useStore接收reducer和类似于Elmish的subscriptions函数,并在store内部管理创建和取消,就像Elmish一样。

这有点好了。这是第一次useStore以其消息和模型类型为泛型,这确实是我们最终想要的。现在,当传入的订阅数组发生变化时,至少旧订阅将被取消。尽管如此,仍然没有提及类似于Elmish的withSubscription的内容。

提示 4 #

为timerSubscription返回的订阅数组添加ID,并使存储与以前的订阅进行对比以确定要启动和停止的订阅。

这是一个进步。现在,订阅应该有一个ID和返回清理函数的功能。让我们继续完善。 el: Model, dispatch: Dispatch) => Subscription[] ) { const [model, dispatch] = useReducer(update, initialModel); const subscriptionRef = useRef<{ [key: string]: () => void }>({});

// 处理具有差异的订阅 useEffect(() => { if (subscriptions) { const currentSubscriptions = subscriptions(model, dispatch); const newSubscriptions: typeof subscriptionRef.current = {};

  // 启动新订阅并与现有订阅进行差异比较
  currentSubscriptions.forEach((subscription) => {
    const { id, create } = subscription;

    if (!subscriptionRef.current[id]) {
      // 具有此ID的订阅不存在,因此启动它
      newSubscriptions[id] = create(dispatch);
    } else {
      // 具有此ID的订阅存在,无需重新创建,只需转移
      newSubscriptions[id] = subscriptionRef.current[id];
    }
  });

  // 停止不再存在的订阅
  Object.keys(subscriptionRef.current).forEach((id) => {
    if (!newSubscriptions[id]) {
      subscriptionRef.current[id]();
    }
  });

  subscriptionRef.current = newSubscriptions;
}

// 清理函数
return () => {
  Object.values(subscriptionRef.current).forEach(unsubscribe => unsubscribe());
};

}, [model, dispatch, subscriptions]); // 如果模型、dispatch 或订阅函数发生更改,则重新订阅

return { model, dispatch }; }

// 订阅函数示例 const timerSubscription = (_model: Model, dispatch: Dispatch): Subscription[] => [ { id: 'timer', // 订阅的唯一ID create: (dispatch: Dispatch) => { const intervalId = setInterval(() => { dispatch({ type: 'Tick' }); }, 1000);

  // 返回清理函数
  return () => clearInterval(intervalId);
}

} ];


好了!现在我们的订阅具有逻辑标识符,而仍然正确的 `useStore` 钩子会在订阅从数组中消失时停止订阅。我们现在接近了,但这并不是我们可以直接放入 Typescript 或 React 应用中并使用的内容。

## Prompt 5

> 创建一个工厂函数,根据上述的更新和订阅函数,返回一个上下文提供程序和相应的 `useStore` 钩子。

// 订阅和更新的定义 type Subscription<Model, Msg> = { id: string; create: (model: Model, dispatch: Dispatch) => () => void; };

type Update<Model, Msg> = Reducer<Model, Msg>;

// 工厂函数 function createStore<Model, Msg>(initialModel: Model, update: Update<Model, Msg>, subscriptions: (model: Model, dispatch: Dispatch) => Subscription<Model, Msg>[]) { // 创建特定于此存储的带有 Model 和 Msg 类型的 React 上下文 const StoreContext = createContext<{ model: Model; dispatch: Dispatch; }>({ model: initialModel, dispatch: () => { /* */ }, });

// 创建上下文提供程序组件 const StoreProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [model, dispatch] = useReducer(update, initialModel); const subscriptionRef = useRef<{ [key: string]: () => void }>({});

// 用于处理具有差异的订阅的效果
useEffect(() => {
  const currentSubscriptions = subscriptions(model, dispatch);
  const newSubscriptions: { [key: string]: () => void } = {};

  currentSubscriptions.forEach(subscription => {
    const { id, create } = subscription;
    if (!subscriptionRef.current[id]) newSubscriptions[id] = create(model, dispatch);
    else newSubscriptions[id] = subscriptionRef.current[id];
  });

  Object.keys(subscriptionRef.current).forEach(id => {
    if (!newSubscriptions[id]) subscriptionRef.current[id]();
  });

  subscriptionRef.current = newSubscriptions;

  return () => {
    Object.values(subscriptionRef.current).forEach(unsubscribe => unsubscribe());
  };
}, [model]);

return (
  
    {children}
  
);

};

// 创建 useStore 钩子 const useStore = () => { const context = useContext(StoreContext); if (context === undefined) { throw new Error('useStore must be used within a StoreProvider'); } return context; };

return { StoreProvider, useStore }; }


现在我们有一个函数,只需使用一个 reducer 和一个订阅函数调用它。此外,它将返回一个提供程序用作父组件以及一个 `useStore` 钩子,可连接到该上下文中。

## ChatGPT 的强大用途

这是 ChatGPT 可以成为开发工具箱中强大工具之一的绝佳例子。您会注意到,初始提示的输出与第五个输出截然不同,以至于第一个输出几乎是无用的。但是,只需明确了解想要的结果在哪里,只需稍微调整即可获得完全符合预期的输出。