【开源】一个 React + TS 项目模板

21,245 阅读7分钟

前言

在小公司待了三年多,前端团队很小很小,没有前端大佬坐镇,完全处于自我摸索的状态。两年前开始独立负责前端项目,热衷于自己手搭项目。对于那个时候的我来说,一切都处于朦胧的状态,虽然有心想要把项目设计的更好,但是没有什么好的方向/思路(就比如刚开始写项目,调用后端接口都是分散在每个模块中的,没有统一放在一个目录下去维护,如果后端接口变了,就需要全局搜索一个个的去修改接口...)。后来阅读了大量的书籍、文章、别人开源的项目以及惨痛的项目重构血泪史,渐渐地积累了一些项目经验,有了自己的积累(配置项目模板、写脚手架、搭建组件库...),渐渐的往前端工程化这个方向靠。

写这篇文章的目的:给那些和我相同处境、喜欢自己手搭项目的小伙伴们一个参考,让初学者少走点弯路。如果有更好的建议还请告知,不胜感激。

项目文件树结构

tree.png

项目特点

Normalize.css

CSS reset 相对“暴力”,不管你有没有用,统统重置成一样的效果,且影响的范围很大,讲求跨浏览器的一致性。Normalize.css 不讲求样式一致,而讲求通用性和可维护性,是一种 CSS reset 的替代方案。它在默认的 HTML 元素样式上提供了跨浏览器的高度一致性。相比于传统的 CSS reset,Normalize.css 是一种现代的、为 HTML5 准备的优质替代方案。

默认支持 CSS 模块化

  • 使用 css-loader 的参数配置实现 CSS 模块化
module: {
   rules: [
     {
       test: /\.css$/,
       use: ['style-loader','css-loader']
     },
     {
       test: /\.less$/,
       use: [
         'style-loader',
         {
           loader: 'css-loader',
           options: {
             importLoaders: 2,
             localsConvention: 'camelCase',
             modules: {
               localIdentName: '[name]__[local]--[hash:base64:5]'
             },
           }
         },
         'less-loader'
       ]
     }
   ],
}

postcss-loader + autoprefixer

  • 自动兼容处理不同浏览器的样式问题
module: {
  rules: [
    {
      test: /\.css$/,
      use: ['style-loader','css-loader', 'postcss-loader']
    },
    {
      test: /\.less$/,
      use: [
        'style-loader',
        {
          loader: 'css-loader',
          options: {
            importLoaders: 2,
            localsConvention: 'camelCase',
            modules: {
              localIdentName: '[name]__[local]--[hash:base64:5]'
            },
          }
        },
        'postcss-loader',
        'less-loader'
      ]
    }
  ],
}

postcss.config.js


// postcss-loader 会自动查找并调用这个文件
const autoprefixer = require('autoprefixer');
module.exports = {
    plugins: [autoprefixer()],
};

自定义配置 html

  • 配置更加灵活,尤其是多页面应用
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge, chrome=1"/>
    <meta name="renderer" content="webkit"/>
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>首页</title>
    <%htmlWebpackPlugin.options.dependencies.css.forEach(css=>{%>
    <link rel="stylesheet" href="<%= css %>">
    <%})%>
</head>
<body>
<div id="root"></div>
<%htmlWebpackPlugin.options.dependencies.js.forEach(js=>{%>
<script src="<%=js%>"></script>
<%})%>
</body>
</html>

webpack.html.config.js

const HtmlWebpackPlugin = require('html-webpack-plugin');

/**
 * 生产环境资源包路径配置
 */
const libs = {
    // iconfont: {
    //     js: ['./fonts/iconfont.js'],
    //     css: ['./fonts/iconfont.css']
    // },
};

/**
 * 开发环境资源包路径配置
 */
if (process.env.NODE_ENV === 'development') {
    Object.assign(libs, {
        // iconfont:{
        //     js: ['//at.alicdn.com/t/font_xxx.js'],
        //     css: ['//at.alicdn.com/t/font_xxx.css']
        // },
        dll: {
            js: ['/public/dll/dllLibs.dll.js']
        }
    });
}

function createHtmlWebpackPluginConfig(chunkName, path, modules, chunks) {
    const config = {
        favicon: './src/entry/favicon.ico',
        filename: `${chunkName}.html`,
        template: `${path || './src/entry/index.html'}`,
        inject: true,
        chunks: ['vendors', chunkName].concat(chunks),
        chunksSortMode: 'dependency',
        minify: {
            removeComments: true,
            collapseWhitespace: false
        },
        dependencies: {
            css: [],
            js: []
        },
    };
    modules && modules.forEach(m => {
        if (m && m.css && m.css.length > 0) {
            config.dependencies.css = config.dependencies.css.concat(m.css);
        }
        if (m && m.js && m.js.length > 0) {
            config.dependencies.js = config.dependencies.js.concat(m.js);
        }
    });
    return new HtmlWebpackPlugin(config);
}

module.exports = [
    createHtmlWebpackPluginConfig('index', '', [libs.dll]),
];

DllPlugin

  • 因为是将以前配置的模板进行了一次大升级,所以继续沿用了这个依赖缓存插件,但是我习惯用在开发环境中,生产环境是不配置的,所以在新版本的 Webpack 开发环境中测试时,提升的速度不是很明显,对于未来的 Webpack 5 来说,这个插件就更没有使用的意义了。
const path = require('path');
const DllPlugin = require('webpack/lib/DllPlugin');

module.exports = {
    mode: 'development',
    context: path.resolve(__dirname, "../"),
    entry: {
        dllLibs: ['react', 'react-dom', 'lodash', 'antd', 'react-redux', 'redux','history', 'react-router-dom', 'connected-react-router','axios','events','moment','react-beautiful-dnd']
    },
    output: {
        path: path.resolve('public'),
        // 输出的动态链接库的文件名称,[name] 代表当前动态链接库的名称
        filename: 'dll/[name].dll.js',
        // 默认是 var 这个全局变量,如果以这种方式导出的话,只能用脚本的方式进行全局访问
        libraryTarget: 'var',
        // 存放动态链接库的全局变量名称,例如对应 libs 来说就是 _dll_libs
        library: '_dll_[name]',
    },
    plugins: [
        new DllPlugin({
            // 动态链接库的全局变量名称,需要和 output.library 中保持一致
            // 该字段的值也就是输出的 manifest.json 文件 中 name 字段的值
            // 例如 libs.manifest.json 中就有 "name": "_dll_libs"
            name: '_dll_[name]',
            // 描述动态链接库的 manifest.json 文件输出时的文件名称
            path: path.join('public', 'dll/[name].manifest.json'),
        }),
    ]
};

@babel/preset-typescript

  • 使用 @babel/preset-typescript 转译 TS,如果想要校验 TS 文件,只需执行 npm run type-check

package.json

"scripts": {
     "type-check": "tsc --watch",
}

tsconfig.json

{
    "compilerOptions": {
       // 不生成文件,只做类型检查
        "noEmit": true,
    }
}

tsconfig-paths-webpack-plugin

tsconfig.json

{
    "compilerOptions": {
        // 在解析非绝对路径模块名的时候的基准路径
        "baseUrl": "./",
        "paths": {
            /*路径映射的集合*/
            "@public/*": ["public/*"],
            "@src/*": ["src/*"],
            "@assets/*": ["src/assets/*"],
            "@styles/*": ["src/assets/styles/*"],
            "@common/*": ["src/common/*"],
            "@components/*": ["src/components/*"],
            "@library/*": ["src/library/*"],
            "@routes/*": ["src/routes/*"],
            "@store/*": ["src/store/*"],
            "@server/*": ["src/server/*"],
            "@api/*": ["src/server/api/*"],
            "@utils/*": ["src/utils/*"]
        }
    }
}

webpack.base.config.js

resolve: {
    plugins: [
      // 将 tsconfig.json 中的路径配置映射到 webpack 中
      new TsconfigPathsPlugin({
        configFile: './tsconfig.json'
      })
    ],
      // 因为使用了 TsconfigPathsPlugin 插件,所以这里就不需要再映射路径了
      // alias: {
      //     "@src": path.resolve('src'),
      //     "@public": path.resolve('public'),
      //     "@assets": path.resolve('src/assets'),
      // },
}

支持配置式路由+路由懒加载

export interface RouteConfigDeclaration {
    /**
     * 当前路由路径
     */
    path: string;
    /**
     * 当前路由名称
     */
    name?: string;
    /**
     * 是否严格匹配路由
     */
    exact?: boolean;
    /**
     * 是否需要路由鉴权
     */
    isProtected?: boolean;
    /**
     * 是否需要路由重定向
     */
    isRedirect?: boolean;
    /**
     * 是否需要动态加载路由
     */
    isDynamic?: boolean;
    /**
     * 动态加载路由时的提示文案
     */
    loadingFallback?: string;
    /**
     * 路由组件
     */
    component: any;
    /**
     * 子路由
     */
    routes?: RouteConfigDeclaration[];
}
export const routesConfig: RouteConfigDeclaration[] = [
    {
        path: '/',
        name: 'root-route',
        component: App,
        routes: [
            {
                path: '/home',
                // exact: true,
                isDynamic: true,
                // loadingFallback: '不一样的 loading 内容...',
                // component: Home,
                // component: React.lazy(
                //     () =>
                //         new Promise(resolve =>
                //             setTimeout(
                //                 () =>
                //                     resolve(
                //                       import(/* webpackChunkName: "home"*/ '@src/views/home/Home'),
                //                     ),
                //                 2000,
                //             ),
                //         ),
                // ),
                component: React.lazy(() =>
                    import(/* webpackChunkName: "home"*/ '@src/views/home/Home'),
                ),
                routes: [
                    {
                        path: '/home/child-one',
                        isDynamic: true,
                        component: React.lazy(() =>
                            import(/* webpackChunkName: "child-one" */ '@src/views/home/ChildOne'),
                        ),
                    },
                    {
                        path: '/home/child-two',
                        isRedirect: true,
                        isDynamic: true,
                        component: React.lazy(() =>
                            import(/* webpackChunkName: "child-two" */ '@src/views/home/ChildTwo'),
                        ),
                    },
                ],
            },
            {
                path: '/login',
                isDynamic: true,
                isRedirect: true,
                component: React.lazy(() =>
                    import(
                        /* webpackChunkName: "login" */
                        '@src/views/login/Login'
                    ),
                ),
            },
            {
                path: '/register',
                isDynamic: true,
                component: React.lazy(() =>
                    import(/* webpackChunkName: "register"*/ '@src/views/register/Register'),
                ),
            },
        ],
    },
];

ESLint+Prettier

husky + lint-staged

  • 在提交代码前,进行代码风格校验并修复:每次提交时,只检查本次提交所修改的文件(相比 git 暂存区),节省了大量的时间。
"husky": {
  "hooks": {
    "pre-commit": "lint-staged"
  }
},
"lint-staged": {
  "src/**/*.{js,jsx,ts,tsx}": [
    "eslint --fix",
    "prettier --write"
  ]
},

rematch

  • rematch 参考了 DvaMirror,在 redux 的基础上进行了二次封装,在 rematch 没有多余的 action types、action creators、switch 语句、thunks、saga 以及繁琐的 store 配置,极大的简化了 redux 的使用成本。

image.png

models/register/index.ts

// 添加状态
const INCREMENT = 'INCREMENT';

import { RootDispatch, RootState } from '@src/store';

export interface RegisterStateDeclaration {
    pageName?: string;
    count: number;
}

const state: RegisterStateDeclaration = {
    pageName: 'register',
    count: 0,
};

export default {
    name: 'register',
    state,
    reducers: {
        [INCREMENT]: (state: RegisterStateDeclaration, payload): RegisterStateDeclaration => {
            // 打印输出的是一个 proxy 代理实例对象
            // console.log(state);
            state.count += 1;
            // 最终要返回整棵 state 树(当前 model 的 state 树——login)
            return state;
        },
    },
    // 两种写法:一种用常量作为 key ,一种直接定义方法
    effects: (dispatch: RootDispatch) => ({
        // async incrementAsync(payload, rootState: RootState) {
        async incrementAsync() {
            await new Promise(resolve =>
                setTimeout(() => {
                    resolve();
                }, 1000),
            );
            // 派发 login 里面的 action
            // dispatch.login.INCREMENT();
            this.INCREMENT();
        },
    }),
    // effects: {
    //     async incrementAsync(payload, rootState: RootState) {
    //         await new Promise(resolve =>
    //             setTimeout(() => {
    //                 resolve();
    //             }, 1000),
    //         );
    //         this.INCREMENT();
    //     },
    // },
};

events

  • 使用 events 创建一个全局的事件中心(发布订阅),虽然项目中已经用 redux 作为全局通信的工具,但在某些情况下,还是得依赖事件订阅和通知。

utils

  • 内置了一些好用的工具函数,如下:
/**
 * 检测变量类型
 * @param type
 */
function isType(type) {
    return function(value): boolean {
        return Object.prototype.toString.call(value) === `[object ${type}]`;
    };
}

export const variableTypeDetection = {
    isNumber: isType('Number'),
    isString: isType('String'),
    isBoolean: isType('Boolean'),
    isNull: isType('Null'),
    isUndefined: isType('Undefined'),
    isSymbol: isType('Symbol'),
    isFunction: isType('Function'),
    isObject: isType('Object'),
    isArray: isType('Array'),
};

项目地址

react-ts-project-template

参考

使用 ESLint + Prettier 来统一前端代码风格

用 husky 和 lint-staged 构建超溜的代码检查工作流

推荐阅读

TS 常见问题整理(60多个,持续更新ing)

你真的了解 React 生命周期吗

React Hooks 详解 【近 1W 字】+ 项目实战

React SSR 详解【近 1W 字】+ 2个项目实战

从 0 到 1 实现一款简易版 Webpack

Webpack 转译 Typescript 现有方案

傻傻分不清之 Cookie、Session、Token、JWT