•
阅读时长 3 分钟

前端重构,小步快跑

目录

组件放 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 风格指南。