组件放 components,action 放 actions,reducer 放 reducers,项目小的时候很好找。一个功能需要改很多地方以后,相关代码按功能放在一起可能更方便。
改目录时,如果同时重写状态结构、升级状态库、调整接口,很难判断出错到底是哪一步造成的。下面只搬一个“添加笔记”功能,保持 action 和 state 的形状不变。
先保留原来的行为作对照
创建 legacy/reducer.mjs:
export const legacyReducer = (state = [], action) =>
action.type === "notes/add"
? [...state, { id: action.payload.id, title: action.payload.title }]
: state;
新建 features/notes/model.mjs:
export const notesReducer = (state = [], action) =>
action.type === "notes/add"
? [...state, { id: action.payload.id, title: action.payload.title }]
: state;
export const addNote = (id, title) => ({ type: "notes/add", payload: { id, title } });
目前代码几乎一样,这是有意保留对照。移动实现不是顺便改行为,未知 action 仍然返回原 state,添加 action 仍然生成 id、title。
在旧入口 legacy/compat.mjs 里转导出:
export { notesReducer as legacyReducer } from "../features/notes/model.mjs";
调用方暂时使用旧名字和旧入口,内部已经转到新实现。真实项目可以在原文件路径上保留这个兼容导出,逐步迁移调用方;不用为了搬目录创建两个 store 同步状态。
组件从功能入口导出
创建 features/notes/index.js:
import React, { useReducer } from "react";
import { notesReducer, addNote } from "./model.mjs";
export function Notes() {
const [notes, dispatch] = useReducer(notesReducer, []);
return React.createElement(
"div",
null,
React.createElement(
"button",
{
id: "feature-add",
onClick: () => dispatch(addNote(String(notes.length), "新笔记")),
},
"新模块添加",
),
React.createElement("p", { id: "feature-count" }, notes.length),
);
}
创建一个空目录,安装 React 和打包工具。示例不用 JSX,省去 Babel 配置,组件逻辑和 JSX 的写法是一样的。
npm init -y
npm install --save-exact react@18.3.1 react-dom@18.3.1
npm install --save-dev --save-exact webpack@5.99.9
创建 build.cjs:
const webpack = require("webpack");
const path = require("path");
webpack(
{
mode: "development",
entry: path.resolve("main.js"),
output: { path: path.resolve("dist"), filename: "main.js" },
devtool: "source-map",
},
(error, stats) => {
if (error || stats.hasErrors()) {
console.error(error || stats.toString("errors-only"));
process.exitCode = 1;
}
},
);
创建 index.html:
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8" /><meta name="viewport" content="width=device-width" />
<title>React 实验</title>
<div id="root"></div>
<script src="./dist/main.js"></script>
</html>
把下面的代码存为 main.js,运行 node build.cjs,然后用浏览器打开 index.html。这里用开发构建和根节点 StrictMode,观察次数时要把这两个条件算进去。
import React from "react";
import { createRoot } from "react-dom/client";
import { Notes } from "./features/notes/index.js";
createRoot(document.querySelector("#root")).render(React.createElement(Notes));
点击添加,数量增加。示例使用 useReducer,按功能组织代码不要求先引入某一个状态库。
把迁移前后放进同一组检查
创建 check.mjs:
import assert from "node:assert/strict";
import { legacyReducer } from "./legacy/reducer.mjs";
import { legacyReducer as compatibleReducer } from "./legacy/compat.mjs";
import { notesReducer, addNote } from "./features/notes/model.mjs";
const actions = [addNote("1", "A"), { type: "unknown" }, addNote("2", "B")];
const before = actions.reduce(legacyReducer, []);
assert.deepEqual(actions.reduce(notesReducer, []), before);
assert.deepEqual(actions.reduce(compatibleReducer, []), before);
const state = notesReducer([], addNote("1", "A"));
assert.equal(notesReducer(state, { type: "unknown" }), state);
console.log(before);
node check.mjs 运行通过,能确认这组 action 的结果相同,以及未知 action 不创建新数组。它没有覆盖持久化、权限和异步错误,真实模块还需要沿用原有行为测试。
功能内部文件可以继续拆分,但不需要为了三行代码提前创建 api、types、services 等空目录。跨功能调用走公开入口,通用代码确实稳定且共享时再提取。
迁移结束、旧调用清完以后,再移除兼容入口和对照实现。目录变好看只是过程,外部行为没变、以后修改能少找几处,才是这次改动的意义。组织建议参考 Redux feature folder 风格指南。