前端项目规范

lxf2023-03-13 20:45:01

本文主要分为两部分,一部分是项目文件结构、一部分是编码规范。

项目文件结构

文件和文件夹的命名规则

  • 字母全部小写,单词间用小短线-连接

    auto-complete/
    date-picker.tsx
    
  • React UI 组件文件名后缀为tsx

    button.tsx
    menu.tsx
    
  • 如果一个文件夹下有多个同类型文件,则文件名应该是复数:

    components
    pages
    
  • index.ts 只能作为导出文件使用

    不然搜索文件时需要关注文件夹名,增加了心智负担。

    //index.ts
    export * from './my-component';
    export { default } from './my-component';
    

项目结构划分的方式

  • 按功能划分

    把一个功能相关联的文件都放在一起:

    common/
      Avatar.js
      Avatar.css
      APIUtils.js
      APIUtils.test.js
    feed/
      index.js
      Feed.js
      Feed.css
      FeedStory.js
      FeedStory.test.js
      FeedAPI.js
    profile/
      index.js
      Profile.js
      ProfileHeader.js
      ProfileHeader.css
      ProfileAPI.js
    

    按功能划分结构很清晰,一个功能的代码都在一起,要删除一个功能也不用到处去找相关的文件。但是,一个功能的定义因人而异,有时一个功能的定义非常模糊,拆分起来非常困难,比如要实现一个搜索订单的功能,是应该属于搜索还是属于订单?

  • 按文件类型划分

    将同一类型的文件放一起,比如所有的 component,所有的 api:

    apis/
      APIUtils.js
      ProfileAPI.js
      UserAPI.js
    components/
      Avatar.js
      Feed.js
      FeedStory.js
      Profile.js
      ProfileHeader.js
    

    按类型放一起的好处在于,不用再纠结该放哪的问题,组件都放 components 就好了,api 都放 apis 下好了,当然这失去了按功能组织的优点,因为相关文件都散落在各处。

这两种项目结构并没有绝对的好坏之分。通常,随着项目扩大,一般都是混用这两种类型。之所以有结构,更多的是一种约定,开发者共同遵守这一约定即可。

项目结构划分

项目结构划分个人更倾向于以按功能划分为主,类型划分为辅的混合方式。 目录大概分为以下类型:

project/
├── docs/
├── scripts/
├── .prettierrc
├── tsconfig.json
└── src/

docs

项目相关的文档文件。

docs/
├── dev.md
└── architecture.md

scripts

项目的一些工具脚本文件。

scripts/
├── compile-message.sh
└── generate-pro-env.ts

.prettierrc

prettier 配置文件,如果不配置,可能导致不同开发人员保存时格式化的结果不一致。

//.prettierrc
{
  "semi": true,
  "singleQuote": true,
  "printWidth": 100,
  "trailingComma": "all"
}

tsconfig.json

TypeScript 配置文件。

//tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "baseUrl": "." //支持通过绝对路径(如src/)引用组件
  },
  "include": ["src", "scripts"] //src、scripts两个文件夹都接受ts检查
}

src

src 文件夹为项目的主要文件夹,主要有以下文件:

src/
├── pages/
├── locales/
├── fetcher/       (可能有)
├── components/    (可能有)
├── hooks/         (可能有)
├── utils/         (可能有)
├── features/
├── store.ts       (可能有)
├── routes.tsx     (可能有)
├── app.tsx
└── index.tsx
  • pages

    页面文件,pages 下为各个页面的文件,每个页面都有一个路由,主页命名为 index.tsx。

    pages/
    ├── login.tsx
    ├── about.tsx
    └── index.tsx
    

    页面应由 feature 组合而成,而不应该包含显示的逻辑(逻辑在子组件中实现),只是一个函数式组件:

    //pages/login.tsx
    export const LoginPage = () => {
      return (
        <Container>
          <LogoContainer>
            <Logo src={logoImg} />
            <Slogan>
              <FormattedMessage defaultMessage="人工智能助力智慧医疗" />
            </Slogan>
            <LoginForm />//LoginForm包含了登录逻辑
          </LoginContainer>
          <Version />
        </Container>
      );
    };
    
  • locales

    多语言文件,一般有中英文的 JSON 文件。

    locales/
    ├── en.json
    │   zh.json
    └── index.ts //导出多语言文件
    
    /***************分割线*****************/
    // index.ts
    import zh from './zh.json';
    import en from './en.json';
    
    export const getLocale = (type: string) => ({ zh, en }[type]);
    
    
  • fetcher

    处理 api 请求权限,配置等相关的文件,如果数据请求无需统一配置,也没有权限问题,则不创建该文件夹。

    fetcher/
    ├── fetcher.ts
    └── index.tsx
    /***************分割线*****************/
    // fetcher.ts
    import A, { AxiosInstance, AxiosError } from 'axios';
    
    export const STRAPI_CMS_HOST = process.env.REACT_APP_STRAPI_CMS_URL || '';
    
    class Fetcher {
      private _axios?: AxiosInstance;
    
      initAxios = (token: string, onUnauthorizedError: () => void) => {
        this._axios = A.create({
          baseURL: STRAPI_CMS_HOST,
          headers: {
            authorization: `Bearer ${token}`,
          },
        });
        this._axios.interceptors.response.use(undefined, (error: AxiosError) => {
          if (error?.response?.status === 401) {
            onUnauthorizedError();
          } else {
            return Promise.reject(error);
          }
        });
      };
    
      get axios(): AxiosInstance {
        if (!this._axios) {
          throw new Error('It must be init axios');
        }
        return this._axios!;
      }
    }
    
    export const fetcher = new Fetcher();
    
    
  • components

    通用组件文件,与业务无关,只有在多个 feature 需要复用组件时才存在,如果没有复用的需求则 components 应该挪到对应的 feature 目录。一个文件夹为一个组件(组件内部可能有多个小组件),如果组件只需要一个文件则添加与组件同名文件(不能只用一个 index.tsx),并用 index.ts 导出。如果组件内部有 css 文件则名字与文件夹同名。

    components/
    ├── button/
    │   ├── button.tsx
    |   └── index.ts
    ├── icon/
    │   ├── icon.tsx
    │   ├── icon.css
    │   └── index.ts
    └── index.ts //导出组件
    

    组件的命名采用基于路径的组件命名方式,即根据相对于 components 文件目录的相对路径来命名,名字为大驼峰式,比如components/order/list.tsx,被命名为 OrderList。如果文件名和文件目录名相同,则不需要重复这个名字。比如 components/order/form/form.tsx 会命名为 OrderForm 而不是 OrderFormForm。

    components/
    ├── order/
    │   └── form
    │   │   ├── form.tsx
    │   │   └── form.css
    │   ├── list.tsx
    │   └── index.ts
    
  • hooks

    自定义的 React Hook 文件,只有在多个功能需要复用 hook 时才存在。一个文件为一个 hook,如果一个 hook 需要多个文件则拆分成更小的 hook,hook 文件以 use 开头。

    hooks/
    ├── use-mounted
    └── use-key
    └── index.ts //导出hook
    

    hook 组件的命名方式为 use 开头的小驼峰式。

    import { useState, useEffect } from 'react';
    
    function useFriendStatus(friendID) {}
    
  • utils

    一些可以复用的工具函数,和业务无关,可以项目间通用(比如 lodash 的函数)。

    utils/
    ├── create-random-number.ts
    └── debounce.ts
    └── index.ts //导出工具函数
    
  • features

    按功能划分的文件,通过该目录就能知道 app 的相关功能,feature 间严格禁止引用。上述的 components、hooks、utils 如果没有 feature 间复用的需求应放到 features 目录下,当 components 放到 feature 目录下时也可以放与业务相关的组件。此外 features 目录下可能还有 models、apis、helpers、assets。

    features/
    ├── todos/
    |   ├── components/
    |   ├── hooks/
    |   ├── utils/
    |   ├── apis/
    |   ├── models/
    |   ├── helpers/
    |   ├── assets/
    |   └── index.ts
    ├── users/
    └── index.ts //导出 feature
    
    • apis

      数据请求的 api 文件,如果有 fetcher 则会引用到 fetcher 文件。

      apis/
      ├── user.ts
      ├── log.ts
      └── index.ts //导出api
      
    • models

      redux 相关的文件(也可以是其他提供数据源的框架),如果用redux-toolkit,一般包括 slice 文件和 selector 文件:

      models/
      ├── todos/
      |   ├── slice.ts
      |   ├── selector.ts
      |   └── index.ts //导出 slice 和 selector
      └── index.ts //导出 model
      
    • helpers

      为完成项目内特定的功能而实现的和业务相关的函数,如果不需要则不创建。

      helpers/
      ├── sort-case.ts
      └── make-query.ts
      └── index.ts //导出帮助函数
      
    • assets

      资源文件,一般包括图片和字体。

      assets/
      ├── images
      └── fonts
      
  • store.ts

    redux store 相关的文件,如果不需要就不创建。

    //store.ts
    import { configureStore } from '@reduxjs/toolkit';
    import { combineReducers } from 'redux';
    
    const persistConfig = {
      key: 'root',
      storage,
      whitelist: ['user'],
    };
    
    const rootReducer = combineReducers({});
    
    export const store = configureStore({
      reducer: rootReducer,
    });
    
    export type RootState = ReturnType<typeof store.getState>;
    
    export type AppDispatch = typeof store.dispatch;
    
  • router.tsx

    路由文件。

    路由文件这里写得比较简单,如果项目比较复杂可以将路由文件放到对应的feature文件夹下。

    //router.tsx
    import React from 'react';
    import { Routes, Route, HashRouter } from 'react-router-dom';
    
    import { HomePage } from './pages/home';
    import { LoginPage } from './pages/login';
    
    const RouteMap = {
      ROOT: '/',
      LOGIN: '/login',
    };
    
    const Router = () => (
      <Routes>
        <Route path={RouteMap.ROOT} element={<HomePage />} />
        <Route path={RouteMap.LOGIN} element={<LoginPage />} />
      </Routes>
    );
    
    export default Router;
    
  • app.tsx

    app 入口文件。

    import React from 'react';
    import styled from 'styled-components';
    import { IntlProvider } from 'react-intl';
    import { Provider } from 'react-redux';
    import { PersistGate } from 'redux-persist/integration/react';
    import { persistStore } from 'redux-persist';
    import { ConfigProvider } from 'antd';
    
    import { getLocale } from './locales';
    import { store } from './store';
    import Router from './router';
    
    const persistor = persistStore(store);
    
    const App = () => (
      <Provider store={store}>
        <IntlProvider locale="zh" messages={getLocale('zh')}>
          <ConfigProvider locale={zhCN}>
            <PersistGate persistor={persistor}>
              <Router />
            </PersistGate>
          </ConfigProvider>
        </IntlProvider>
      </Provider>
    );
    export default App;
    
  • index.tsx

    项目入口文件。

    import React from 'react';
    import ReactDOM from 'react-dom/client';
    
    import App from './app';
    
    const root = ReactDOM.createRoot(
      document.getElementById('root') as HTMLElement
    );
    root.render(
      <React.StrictMode>
        <App />
      </React.StrictMode>
    );
    

综上,最终的一个前端项目目录结构如下:

project/
├── docs/
├── scripts/
├── .prettierrc
├── tsconfig.json
└── src/
    ├── pages/
    ├── locales/
    ├── fetcher/       (可能有)
    ├── components/    (可能有)
    ├── hooks/         (可能有)
    ├── utils/         (可能有)
    ├── features/
    |   ├── feature1/
    |   |   ├── components/
    |   |   ├── hooks/
    |   |   ├── utils/
    |   |   ├── apis/
    |   |   ├── models/
    |   |   ├── helpers/
    |   |   ├── assets/
    |   |   └── index.ts
    |   ├── feature2/
    |   ├── ...
    |   └── index.ts
    ├── store.ts       (可能有)
    ├── routes.tsx     (可能有)
    ├── app.tsx
    └── index.tsx

编码规范

  • 表示某一类别的对象命名用 Map 结尾

    对象所代表的内容用单数,结尾加 Map,比如 reducerMap 不应该是 reducers 或 reducersMap。

    const reducerMap = {
      router: connectRouter(history),
      diagram: diagramReducer,
      editor: editorReducer,
    };
    
    const stateMap = {
      success: 'success',
      failed: 'failed',
      pending: 'pending',
    };
    
  • 不是一个类别的对象命名用复数

    如果对象的值不是表示一类参数,且对象键值对不只一个,就用复数形式:

    //bad
    const option = {
      include: []
      target: 'web'
    };
    
    //good
    const options = {
      include: []
      target: 'web'
    };
    
    
  • enum 名和枚举名都采用大驼峰

    enum 名采用单数形式。

    enum Direction {
      Up,
      Down,
      Left,
      Right,
    }
    
    enum FileAccess {
      None,
      Read
      Write
      ReadWrite = Read | Write,
    }
    
  • 常量全部大写,单词间用下划线分隔

    const MAX_COUNT = 70;
    const URL = 'http://www.xinghunm.com';
    
  • 类的私有属性命名以下划线开头

    class Man {
      private _name: string;
      constructor() { }
      // 公共方法
      getName() {
        return this._name;
      }
      // 公共方法
      setName(name) {
        this._name = name;
      }
    }
    
  • 什么时候用 is

    is 表示是不是,返回一个布尔值,当这个词是形容词时通常不加 is,比如:

    enable
    disable
    

    如果这个词既是形容词又是动词时,应该加 is,比如 empty,它有清空的意思,表达是否为空就应该是 isEmpty。

  • 引入模块的顺序

    引入模块时先引入 node_modules 的模块,再引入自定义模块, node_modules 模块和自定义模块间用空行隔开,自定义模块先引入绝对路径的模块,再引入相对路径的模块:

    import React, { useMemo } from 'react';
    import { Table } from 'antd';
    import styled from 'styled-components';
    
    import { ColorTag, Row, Pagination } from 'src/components';
    import { TagList } from './tag-list';
    
  • 导出 default

    当只导出一个变量且可以预见将来不会再导出其他变量时,用 default 导出。当要导出多个变量时,如果没有明确的主体则不导出 default。

    //a.js
    const sum = () => {};
    export default sum;
    /***************分割线*****************/
    
    //b.js
    //sum、add没有明确的主体,也没有主从关系,不导出default
    export const sum = () => {};
    export const add = () => {};
    
  • 路由命名全小写,单词间用中划线-链接

    const RouteMap = {
      Login: '/login',
      UserCenter: 'user-center',
      DicomViewer: 'dicom-viewer',
    };
    
  • 缩写大写

    缩写一律大写:

    //bad
    caseId
    htmlAndCss
    
    //good
    caseID
    HTMLAndCSS
    
  • 事件函数命名区分

    组件内部函数按照 handle{Type}{Event} 命名,例如 handleStatusChange。对外暴露的方法按照 on{Type}{Event}命名,例如 onStatusChange。

  • 将属性的缩写放在对象声明的开头。

    const a = 1;
    const b = 2;
    
    // bad
    const obj = {
      c: 3,
      d: 4,
      a,
      b,
    };
    
    // good
    const obj = {
      a,
      b,
      c: 3,
      d: 4,
    };
    
  • 减少无意义的前缀

    // bad
    const user = {
      userName: 'lily',
      userAge: 17,
    };
    
    // good
    const user = {
      name: 'lily',
      age: 17,
    };
    
  • 函数参数最好不要超过三个

    // bad
    const request = (url, method = 'get', data, params) => {};
    // good
    const request = ({ url, method = 'get', data, params }) => {};
    // good
    const request = (url, data, options: { method = 'get', params }) => {};
    
  • 函数应遵循单一职责原则

    // bad
    const notify = (users) => {
      users.map((user) => {
        const record = DB.find(user);
        if (record.isActive()) {
          sendMessage(user);
        }
      });
    };
    // good
    const isActive = (user) => {
      const record = DB.find(user);
      return record.isActive();
    };
    const notify = (users) => {
      users.filter(isActive).forEach(sendMessage);
    };
    
  • 函数应提前 return

    提前 return 代码结构能更加清晰。

    // bad
    const onChange = ({ a, b, c, d, e }) => {
      if (a) {
        if (b) {
          if (c) {
            if (d) {
              if (e) {
                return true;
              }
            }
          }
        }
      }
    
      return false;
    };
    // good
    const onChange = ({ a, b, c, d, e }) => {
      if (!a) return false;
    
      if (!b) return false;
    
      if (!c) return false;
    
      if (!d) return false;
    
      if (!e) return false;
    
      return true;
    };
    
  • if 应首先处理正逻辑

    //bad
    const submit = (dirty) => {
      if (!dirty) {
      } else {
      }
    };
    
    //good
    const submit = (dirty) => {
      if (dirty) {
      } else {
      }
    };
    
  • null 和 undefined

    如果是赋空值只能用 null,只有在某个属性未定义是才是 undefined,因为这才是未定义的意思。

    let a = 3;
    //bad
    a = undefined;
    //good
    a = null;
    
  • 不要为了共享而把变量设置为类的字段

    //bad
    class A {
      let v;
      method1() {
        v = 1;
        method2();
      }
    
      method2() {
        console.log(v)
      }
    }
    
    //good
    class A {
      method1() {
        let v = 1;
        method2(v);
      }
    
      method2(v) {
        console.log(v)
      }
    }
    
  • 尽量用语义化的标签

    //bad
    <b>My page title</b>
    <div class="top-navigation">
      <div class="nav-item"><a href="#home">Home</a></div>
      <div class="nav-item"><a href="#news">News</a></div>
      <div class="nav-item"><a href="#about">About</a></div>
    </div>
    
    <div class="news-page">
      <div class="page-section news">
        <div class="title">All news articles</div>
        <div class="news-article">
          <h2>Bad article</h2>
          <div class="intro">Introduction sub-title</div>
          <div class="content">This is a very bad example for HTML semantics</div>
          <div class="article-side-notes">
            I think I'm more on the side and should not receive the main credits
          </div>
          <div class="article-foot-notes">
            This article was created by David
            <div class="time">2014-01-01 00:00</div>
          </div>
        </div>
    
        <div class="section-footer">
          Related sections: Events, Public holidays
        </div>
      </div>
    </div>
    
    <div class="page-footer">Copyright 2014</div>
    
    //good
    <!-- The page header should go into a header element -->
    <header>
      <!-- As this title belongs to the page structure it's a heading and h1 should be used -->
      <h1>My page title</h1>
    </header>
    
    <!-- All navigation should go into a nav element -->
    <nav class="top-navigation">
      <!-- A listing of elements should always go to UL (OL for ordered listings) -->
      <ul>
        <li class="nav-item"><a href="#home">Home</a></li>
        <li class="nav-item"><a href="#news">News</a></li>
        <li class="nav-item"><a href="#about">About</a></li>
      </ul>
    </nav>
    
    <!-- The main part of the page should go into a main element (also use role="main" for accessibility) -->
    <main class="news-page" role="main">
      <!-- A section of a page should go into a section element. Divide a page into sections with semantic elements. -->
      <section class="page-section news">
        <!-- A section header should go into a section element -->
        <header>
          <!-- As a page section belongs to the page structure heading elements should be used (in this case h2) -->
          <h2 class="title">All news articles</h2>
        </header>
    
        <!-- If a section / module can be seen as an article (news article, blog entry, products teaser, any other
        re-usable module / section that can occur multiple times on a page) a article element should be used -->
        <article class="news-article">
          <!-- An article can contain a header that contains the summary / introduction information of the article -->
          <header>
            <!-- As a article title does not belong to the overall page structure there should not be any heading tag! -->
            <div class="article-title">Good article</div>
            <!-- Small can optionally be used to reduce importance -->
            <small class="intro">Introduction sub-title</small>
          </header>
    
          <!-- For the main content in a section or article there is no semantic element -->
          <div class="content">
            <p>This is a good example for HTML semantics</p>
          </div>
          <!-- For content that is represented as side note or less important information in a given context use aside -->
          <aside class="article-side-notes">
            <p>
              I think I'm more on the side and should not receive the main credits
            </p>
          </aside>
          <!-- Articles can also contain footers. If you have footnotes for an article place them into a footer element -->
          <footer class="article-foot-notes">
            <!-- The time element can be used to annotate a timestamp. Use the datetime attribute to specify ISO time
            while the actual text in the time element can also be more human readable / relative -->
            <p>
              This article was created by David
              <time datetime="2014-01-01 00:00" class="time">1 month ago</time>
            </p>
          </footer>
        </article>
    
        <!-- In a section, footnotes or similar information can also go into a footer element -->
        <footer class="section-footer">
          <p>Related sections: Events, Public holidays</p>
        </footer>
      </section>
    </main>
    
    <!-- Your page footer should go into a global footer element -->
    <footer class="page-footer">Copyright 2014</footer>
    

需要说明的是,不管是项目结构还是编码规范,随着认知的变化是会不断调整的。当然,这不意味着反复无常。