提升开发体验:基于 JSDoc 的 React 项目自动代码提示方案详解

需求背景

主管和其他同事基于公司的业务特点,开发了一套自研前端框架。技术选型是 React + JavaScript 的组合,上线后表现还不错。现在他们想把这个组件库推广到其他团队使用,所以让我琢磨一下:怎么能让使用者用得更顺手一点?尤其是能不能在写代码的时候有自动提示?

我调研了一下市面上常见的几种方案,大致有以下几类:

  • 把整个项目从 JavaScript 重构为 TypeScript,这样就能通过 .ts 或 .tsx 文件自动生成 .d.ts 类型声明文件;
  • 不动源码,在外面单独为每个导出的组件手动写 .d.ts 文件;
  • 使用 TypeScript 编译器解析 JavaScript 文件,直接生成 .d.ts 文件;对于那些识别不全的部分,再通过 JSDoc 注释来辅助生成更准确的类型信息。

主管的意思是,希望尽可能少投入人力,因为框架已经稳定运行了,不想为了一个"非刚需"的功能去大动干戈。所以最终我们选择了第三种方案------它的最大优点就是:对源码几乎无侵入,改动小,成本低,见效快!

不过它也不是十全十美。比如 TypeScript 自动生成的 .d.ts 文件中,很多函数参数或对象属性都会被推断成 any,即使配合 JSDoc 使用,也并不是所有组件都能有完整的类型提示。有些时候你还是得手动点进 .d.ts 文件里看定义。

但总的来说,瑕不掩瑜。毕竟现在的目标是高效产出,不是追求完美主义。

项目结构说明

我们的框架是一个典型的多包项目,主要由两个核心目录组成:packagescomponents

packages 目录下包含了四个子包:

  • cli:负责创建项目的命令行工具;
  • compatible:提供运行环境适配能力;
  • multipage:支持多页面应用架构;
  • store:实现全局状态管理。

components 目录则主要是 UI 组件和交互能力的集合。

整个项目的目录结构如下所示:

javascript 复制代码
my-project/
├── components/
│   ├── src/
│   ├── build.config.mts
│   └── package.json
├── packages/
│   ├── cli/
│   ├── compatible/
│   │   ├── src/
│   │   ├── build.config.mts
│   │   └── package.json
│   ├── multipage/
│   └── store/
├── scripts/  
└── package.json

除了 cli 外,其余子包都需要生成 .d.ts 文件。那我们的思路也很简单:在根目录安装 TypeScript,然后给每个子包加上 tsconfig.json,最后写个脚本批量处理这些子包,自动生成类型声明文件。

安装依赖

因为这些依赖项是多个子包共用的,所以我们统一安装在根目录下:

javascript 复制代码
npm install --save-dev typescript jsdoc @types/react @types/react-dom

tsconfig.json 配置

每个子包都是独立发布的,所以每个子包都要有自己的 tsconfig.json 文件。

下面是通用配置,利用了 TypeScript 的 emitDeclarationOnly 功能,只用来生成 .d.ts 文件:

javascript 复制代码
{
  "compilerOptions": {
    "module": "ESNext",
    "target": "ES5",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "jsx": "preserve",

    "allowJs": true,
    "declaration": true,
    "emitDeclarationOnly": true,
    "outDir": "./types",

    "lib": ["es2017", "dom"]
  },
  "include": ["src/**/*.js", "src/**/*.jsx"],
  "exclude": ["node_modules"]
}

自动化脚本编写

为了让这个流程自动化,我们还需要一个脚本,遍历 packagescomponents 文件夹,找到带有 tsconfig.json 的子包,然后执行 TypeScript 命令生成 .d.ts 文件,并放在对应层级下的 types 文件夹中。

我们在 scripts 目录下新建了一个 build-dts.js 脚本,内容如下:

javascript 复制代码
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");

const rootDir = __dirname + "/../";
const packagesDir = path.join(rootDir, "packages");
const componentsDir = path.join(rootDir, "components");

function buildDtsForPackage(pkgPath) {
  const tsconfigPath = path.join(pkgPath, "tsconfig.json");
  const typesOutDir = path.join(pkgPath, "types");

  if (!fs.existsSync(tsconfigPath)) {
    console.warn(
      `⚠️ No tsconfig.json found in ${pkgPath}, skipping .d.ts generation`
    );
    return;
  }

  // ---- 清空 types 文件夹 ----
  if (fs.existsSync(typesOutDir)) {
    console.log(`🧹 Clearing old types folder: ${typesOutDir}`);
    fs.rmSync(typesOutDir, { recursive: true, force: true });
  }

  try {
    // 执行 tsc 命令只生成类型声明文件
    execSync(`tsc`, {
      cwd: pkgPath,
      stdio: "inherit",
    });

    console.log(`✅ .d.ts generated for ${pkgPath}`);
  } catch (e) {
    console.error(`❌ Failed to generate .d.ts for ${pkgPath}`);
  }
}

function processDirectory(targetDir) {
  if (!fs.existsSync(targetDir)) {
    console.warn(`⚠️ Directory not found: ${targetDir}, skipping.`);
    return;
  }

  const dirs = fs.readdirSync(targetDir);

  for (const dir of dirs) {
    const fullPath = path.join(targetDir, dir);
    const stat = fs.statSync(fullPath);
    if (stat.isDirectory()) {
      buildDtsForPackage(fullPath);
    }
  }
}

function processComponentsFlat(targetDir) {
  const tsconfigPath = path.join(targetDir, "tsconfig.json");

  if (!fs.existsSync(tsconfigPath)) {
    console.warn(`⚠️ components/tsconfig.json not found, skipping.`);
    return;
  }

  console.log(`✅ Building dts for flat components directory: ${targetDir}`);
  buildDtsForPackage(targetDir);
}

function main() {
  console.log("📦 Processing packages directory...");
  processDirectory(packagesDir);

  console.log("🧩 Processing components directory...");
  processComponentsFlat(componentsDir); // 处理扁平的 components 目录
}

main();

接着在根目录的 package.json 中加一条脚本指令:

javascript 复制代码
{
  "scripts": {
     "build:dts": "node scripts/build-dts.js"
  }  
}

现在只需要在终端输入:

javascript 复制代码
npm run build:dts

就能一键为所有子包生成 .d.ts 文件啦!

实际效果展示

在没有改一行源码的情况下,TypeScript 自动生成的 .d.ts 文件长这样:

虽然类型定义可能还不够精准,但已经能帮开发者理解 API 的基本用法了。

但有些组件就比较复杂,TypeScript 推不出来详细的结构,比如下面这个组件生成的 .d.ts 就显得有点鸡肋,根本看不出组件该如何使用:

这时候就可以借助 JSDoc 来补充说明了。

用 JSDoc 补充类型信息

Page 组件为例,我们在源码顶部加上 JSDoc 注释,定义 props 结构和生命周期参数:

javascript 复制代码
/**
 * @typedef {Object} PageProps
 * @property {React.ReactElement} children - 子元素
 * @property {(info: PageLifecycleInfo) => void} [onPageBeforeIn] - 页面进入前触发(路由切换时)
 * @property {(info: PageLifecycleInfo) => void} [onPageBeforeOut] - 页面离开前触发(路由切换时)
 * @property {(info: PageLifecycleInfo) => void} [onPageAfterIn] - 页面进入后触发(DOM挂载完成)
 * @property {(info: PageLifecycleInfo) => void} [onPageAfterOut] - 页面离开后触发(DOM卸载前)
 * @property {(info: PageLifecycleInfo) => boolean} [onPageBeforeUnmount] - 页面卸载前触发(可阻止卸载)
 * @property {(info: PageLifecycleInfo) => void} [onPageAfterUnmount] - 页面卸载后触发
 * @private
 */

/**
 * 页面生命周期信息对象,提供页面相关的上下文数据。
 *
 * @typedef {Object} PageLifecycleInfo
 * @property {string} path - 当前路由路径
 * @property {Record<string, any>} params - 路由参数对象
 * @property {string} title - 页面标题
 * @property {"PUSH" | "REPLACE" | "GO" | "BACK" | "FORWARD" | "LISTEN"} openMode - 路由打开方式
 * @property {boolean} hideNav - 是否隐藏导航栏
 * @property {boolean} micro - 是否作为微前端子页面
 * @property {React.ReactElement} component - 页面组件实例
 * @property {React.RefObject<HTMLElement>} pageRef - 页面根元素的 Ref 对象
 * @property {PopStateEvent | HashChangeEvent} [event] - 原始路由事件对象
 */

/**
 * Page 是一个页面级别的容器组件,用于管理页面生命周期和渲染内容。
 * @type {React.FC<PageProps>}
 */

再执行

javascript 复制代码
npm run build:dts

这会生成的 .d.ts 文件就能清楚地告诉开发者:这个组件到底接受哪些 props。

不过也要注意,并不是所有的 JSDoc 注释生成类型声明之后都能在编译软件上有代码提示。有时候还是会遇到一些限制。

发布到 npm

生成完 .d.ts 文件之后,还要确保它们能随着组件一起发布到 npm 上。这就需要在每个子包的 package.json 中添加如下配置:

javascript 复制代码
{
   "files": [
    "cjs/**",
    "esm/**",
    "types/**"
  ],
  "types": "types/index.d.ts",
}

这样用户在使用组件时,就能看到清晰的类型提示和跳转定义了。

效果对比图

没有代码提示时:

有了代码提示之后:

是不是瞬间感觉开发起来轻松多了?通过这种"低成本+高收益"的方式,我们不仅提升了组件库的易用性,也让团队内外的开发者们写起代码来更顺手、更安心。

如果你对前端工程化有兴趣,或者想了解更多前端相关的内容,欢迎查看我的其他文章,这些内容将持续更新,希望能给你带来更多的灵感和技术分享~