React+Typescript项目踩踩坑坑

8,975 阅读7分钟

前言

  • 项目里使用react 16.8.x, typescript 3.5.3
  • 然后也用 koa2+typescript 搭了一个简单的 api 后台服务,只是用来验证 Axios 封装Api 的使用,还有个人 node.js 玩耍需要🙃,并不涉及数据库操作等。。。代码可以戳 这里
  • 顺便升级了一下 webpack4
  • 然后,这只是一个空模版,用来验证一些东西,只有少数简单 demo 页面,其他页面都已删除。。。
  • 本文项目源码可以看 这里
  • 更新:[2019-09-05]: electron,可以看 这里
  • 更新:[2019-09-09]: 第三方资源使用 CDN (看 13、构建)
  • 更新:[2019-11-08]: 状态管理 由 redux+rematch 换为 mobx,资源预加载 prefetch 等

1、创建项目

这里没有使用antd官方的demo,而是在普通 react+typescript 项目增加 antd 然后改造的

为什么不用antd官方的demo? 因为我试过了之后可以用,但是webpack设置别名搞不定,老是有问题,就不用那个了。。。

create-react-app project --typescript

src结构:

.
├── api
├── assets
├── components
├── lang
├── routes
├── store
├── utils
├── views
├── App.scss
├── App.test.tsx
├── App.tsx
├── index.scss
├── index.tsx
├── router.tsx
└── setupProxy.js

2、typescript

tsconfig.json:

{
  "compilerOptions": {
    "baseUrl": "src",
    "outDir": "build/dist",
    "module": "esnext",
    "target": "es5",
    "lib": ["es6", "dom"],
    "sourceMap": true,
    "allowJs": true,
    "jsx": "preserve",
    "moduleResolution": "node",
    "rootDir": ".",
    "forceConsistentCasingInFileNames": true,
    "noImplicitReturns": true,
    "noImplicitThis": true,
    "noImplicitAny": true,
    "importHelpers": true,
    "strictNullChecks": true,
    "suppressImplicitAnyIndexErrors": true,
    "noUnusedLocals": true,
    "allowSyntheticDefaultImports": true,
    "experimentalDecorators": true,
    "paths": {
      "@/*": ["./*"]
    }
  },
  "awesomeTypescriptLoaderOptions": {
    "useBabel": true,
    "useCache": false,
    "emitRequireType": false
  },
  "includes": [
    "src"
  ],
  "exclude": [
    "node_modules",
    "build",
    "scripts",
    "acceptance-tests",
    "webpack",
    "jest",
    "src/setupTests.ts",
    "public/"
  ]
}

.babelrc

{
  "presets": [
    "react-app"
  ],
  "plugins": [
    "transform-decorators-legacy", 
    [
      "import",
      {
        "libraryName": "antd-mobile",
        "style": "css"
      }
    ]
  ]
}

3、升级webpack4.x

webpack.config.dev.js中添加mode字段:mode: 'development'
webpack.config.prod.js中添加mode字段:mode: 'production'

需要升级的相关模块:

yarn upgrade ** 升级或者直接 yarn add ** -D 也可以

  • file-loader
  • fork-ts-checker-webpack-plugin
  • html-webpack-plugin@next
  • react-dev-utils
  • url-loader
  • webpack
  • webpack-cli
  • webpack-dev-server
  • webpack-manifest-plugin

部分QA

  1. 编译报错:webpack is not a function

    把上面相应插件升级一下,然后 script/start.js:
    const compiler = createCompiler(webpack, config, appName, urls, useYarn);改为:
    const compiler = createCompiler({webpack, config, appName, urls, useYarn});

  2. 编译报错:this.htmlWebpackPlugin.getHooks is not a function

    注意html-webpack-plugin@next这个插件要添加@next才行
    config/webpack.comfig.dev.js,config/webpack.config.prod.js:
    new InterpolateHtmlPlugin(env.raw) 改为:
    new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw)

  3. 打包后报错 Chunk Loading failed

    config/paths.js:

    function getServedPath(appPackageJson) {
      const publicUrl = getPublicUrl(appPackageJson);
      const servedUrl = envPublicUrl ||
        (publicUrl ? url.parse(publicUrl).pathname : '/');
      return ensureSlash(servedUrl, true);
    }
    

    将其中的'/'改为'./'即可

  4. 报@types/tapable @types/html-minifier @types/webpack不存在

    yarn add @types/tapable @types/html-minifier @types/webpack
    

4、antd

yarn add antd

按需加载

  • ts/tsx 使用 awesome-typescript-loader 这个loader解析
  • antd 组件的css按需加载使用 babel-plugin-import 这个插件
yarn add awesome-typescript-loader babel-plugin-import
// webpack.config.dev.js, webpack.config.prod.js
{
    test: /\.(ts|tsx)$/,
    include: paths.appSrc,
    loader: 'awesome-typescript-loader',
    exclude: /node_modules/,
    options: {
      babelOptions: {
        "presets": ["react"],
        "plugins": [
          [
            "import", 
            { 
              "libraryName": "antd", 
              "style": "css" 
            }
          ]
        ]
      }
    }
  },

5、路由/权限控制

路由按需加载使用 @loadable/component
如果报 @types/xxx 的错误,按提示安装就行,没有的话就手动在 common.d.ts 添加一个 declare module '@loadable/component';

yarn add @loadable/component

路由

  • App之下的路由

通过以下,实现类似Vue中将路由嵌套在 App 内部的写法,App 中的 props.children 相当于 Vue 中的 router-view ,然后 Header 等全局组件只会挂载一次

// src/router.tsx
...
<AuthRoute 
  path='/' 
  render={() => (
    <App>
      <Switch>
        {routes.map(route => route)}
      </Switch>
    </App>
  )}
/>
...
  • 独立在App之外的路由

aloneComp

// src/router.tsx
<Switch>
  {
    aloneComp.map(route => route)
  }
  <AuthRoute 
    path='/' 
    render={() => (
      <App>
        <Switch>
          {routes.map(route => route)}
        </Switch>
      </App>
    )}
  />
</Switch>
// src/App.tsx
...
  public render() {
    return (
      <div className={style.app}>
        <Header />
        { this.props.children }
      </div>
    );
  }

路由管理

  • 路由统一管理
// src/routes/index.tsx
import login from './login-register';
import home from './home';

/**
 * 使用这个组件 '@/routes/auth-route',代替官方 Route,控制需要登录权限的路由
 */
export default [
  ...login,
  ...home
]
  • 路由模块
// src/routes/home.tsx
import AuthRoute from '@/routes/auth-route';
import * as React from 'react';
import Loadable from '@loadable/component';

// home
export default [
  <AuthRoute 
    key="home" 
    exact={true} 
    path="/" 
    component={Loadable(() => import('@/views/home'))} 
  />,
  <AuthRoute 
    key="home" 
    exact={true} 
    path="/home" 
    component={Loadable(() => import('@/views/home'))} 
  />
]
  • 路由入口 router.tsx

分为App之下的路由,和独立在App之外的路由;视情况而定,如果所有页面都有一个一样的 App 外壳,就不需要这么分开

// src/router.tsx
import * as React from 'react';
import { HashRouter, Switch } from 'react-router-dom';
import AuthRoute from '@/routes/auth-route';
import Loadable from '@loadable/component';
import PageRoutes from './routes';
import login from '@/routes/login-register';

// 使用 import { lazy } from '@loadable/component';
// lazy()会有警告,跟React.lazy()一样的警告
const App = Loadable(() => import('./App'));
const ErrComp = Loadable(() => import(/* webpackPrefetch: true */ './views/err-comp'));

const AppComp = () => {
  // 独立在 app 之外的路由
  const aloneComp = [
    ...login
  ];
  const ErrRoute = 
    <AuthRoute 
      key='err404' 
      exact={true} 
      path='/err404' 
      component={ErrComp} 
    />;
  const NoMatchRoute = 
    <AuthRoute 
      key='no-match' 
      component={ErrComp} 
    />;

  const routes = [...PageRoutes, ErrRoute, NoMatchRoute];

  return (
    <Switch>
      {
        aloneComp.map(route => route)
      }
      <AuthRoute 
        path='/' 
        render={() => (
          <App>
            <Switch>
              {routes.map(route => route)}
            </Switch>
          </App>
        )}
      />
    </Switch>
  );
}

export default function Router() {
  return (
    <HashRouter>
      <AppComp />
    </HashRouter>
  );
}
  • 项目入口 src/index.tsx
// src/index.tsx
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Provider } from 'mobx-react';
import store from '@/store';
import AxiosConfig from './api';
import Router from './router';
import './index.scss';
// import registerServiceWorker from './registerServiceWorker'; 

const Loading = () => (<div>loading...</div>);

AxiosConfig(); // 初始化 axios

ReactDOM.render(
  <React.Suspense fallback={<Loading />}>
    <Provider {...store}>
      <Router />
    </Provider>
  </React.Suspense>,
  document.getElementById('root') as HTMLElement
);

// registerServiceWorker();

登录权限控制

使用js-cookie包,将登录后后端接口返回的token(sessionId?)存在cookie中的'auth'字段

// src/routes/auth-route.tsx:
import * as React from 'react';
import { ComponentProps } from 'react';
import { Route, Redirect, RouteProps } from 'react-router';
import * as Cookies from 'js-cookie';

export interface AuthRouteProps extends RouteProps {
  key?: string|number,
  path?: string,
  auth?: boolean, // 是否需要权限
  redirectPath?: string, // 重定向后的路由
  render?: any,
  component?: ComponentProps<any>
}

const initialProps = {
  key: 1,
  path: '/login',
  auth: true,
  component: () => <div />
};

/**
 * 权限控制处理路由
 */
const AuthRoute = (props: AuthRouteProps = initialProps) => {
  const { auth, path, component, render, key, redirectPath } = props;
  if (auth && !Cookies.get('auth')) {
    // console.log('path: ', path);
    return (
      <Route 
        key={key}
        path={path}
        render={() => 
          <Redirect to={{
            pathname: redirectPath || '/login',
            search: '?fromUrl='+path
          }} />
        } 
      />
    )
  }
  return (
    <Route 
      key={key}
      path={path}
      component={component}
      render={render}
    />
  )
}

export default AuthRoute;

6、api管理

axios

yarn add axios

axios配置、请求/响应拦截

// src/api/index.ts
import axios, { AxiosRequestConfig, AxiosError, AxiosResponse } from 'axios';
import { message } from 'antd';
import * as Cookies from 'js-cookie';
import * as NProgress from 'nprogress';

axios.defaults.timeout = 10000;
axios.defaults.baseURL = process.env.NODE_ENV === 'production'
  ? 'http://192.168.0.5:2333' // 这里设置实际项目的生产环境地址
  : '';

let startFlag = false; // loadingStart的标志

// 拦截器
export default function AxiosConfig() {
  // 请求拦截
  axios.interceptors.request.use((config: AxiosRequestConfig) => {
    if (config.data && config.data.showLoading) {
      // 需要显示loading的请求
      startFlag = true;
      NProgress.start();
    }
    // 请求 access_token,登录后每个请求都带上
    if (Cookies.get('auth')) {
      config.headers.Authorization = Cookies.get('auth');
    }
    if (config.params) config.params._t = Date.now();

    return config;

  }, (err: AxiosError) => {
    if (startFlag) {
      startFlag = false;
      NProgress.done();
    }
    return Promise.reject(err);
  });

  // 响应拦截
  axios.interceptors.response.use((res: AxiosResponse) => {
    if (startFlag) {
      startFlag = false;
      NProgress.done();
    }
    return res.data;
    
  }, (err: AxiosError) => {
    // 服务器错误
    if (err.response && (err.response.status+'').startsWith('5')) {
      message.error('请求出错!')
    }
    if (startFlag) {
      startFlag = false;
      NProgress.done();
    }
    return Promise.reject(err);
  })
}

api 模块

// src/api/test-api.ts
import axios from 'axios';

// 获取文件
const api = {
  // 示例:
  // get只有params才会作为请求参数
  // 其他请求方式如:POST,PUT,PATCH,data作为请求参数
  testApi: (params: any = {}) => {
    // post
    // return axios.post('/api/file/uploadFile', params);

    // get
    return axios.get('/api/file/getFile', { 
      params, 
      data: { showLoading: true }
    });
  }
};

export default api;

api使用

import Api from '@/api/test-api';
...

Api.testApi(params).then((res: any) => {...});

-7、状态管理使用rematch (rematch 废弃,已换为 mobx )

由于 redux v7.1.0 新增了 useSelector, useDispatch 等Hooks,更新 react-redux 版本即可使用,下面将增加使用 useSelector, useDispatch 的版本

yarn add @rematch/core react-redux

store管理

// src/store-rematch/index.ts
import { init, RematchRootState } from '@rematch/core';
import * as models from './models/index';

// 缓存列表
const cacheList = ['common'];
const stateCache = sessionStorage.getItem('store-rematch');
// 初始化 state
const initialState = (stateCache && JSON.parse(stateCache)) || {};

const store = init({
  models,
  redux: {
    initialState
  }
});

// 监听每次 state 的变化
store.subscribe(() => {
  const state = store.getState();
  let stateData = {};
  
  Object.keys(state).forEach(item => {
    if (cacheList.includes(item)) {
      stateData[item] = state[item];
    }
  });

  sessionStorage.setItem('store-rematch', JSON.stringify(stateData));
});

export type Store = typeof store;
export type Dispatch = typeof store.dispatch;
export type iRootState = RematchRootState<typeof models>;
export default store;

models

// src/store-rematch/models/indes.ts
import { createModel } from '@rematch/core';
// import detail from './detial';

export interface ICommonState {
  appName: string,
  isMobile: boolean,
  count: number,
  countAsync: number
}
const initialState: ICommonState = {
  appName: 'react-ts-mdnote',
  isMobile: false,
  count: 0,
  countAsync: 0
};
const common = createModel({
  state: initialState,
  reducers: {
    setIsMobile(state: ICommonState, payload: boolean) {
      return {
        ...state,
        isMobile: payload
      }
    },
    addCount(state: ICommonState) {
      return {
        ...state,
        count: state.count + 1
      }
    },
    setCount(state: ICommonState, payload: number) {
      return {
        ...state,
        countAsync: payload
      }
    }
  },
  effects: (dispatch) => ({
    async setCountAsync(payload, rootState) {
      await new Promise(resolve => setTimeout(resolve, 1000))
      dispatch.common.setCount(payload)
    }
  })
});

export {
  common,
  // detail
}

组件中使用

  • 普通的 connect + mapState + mapDispatch 写法
// src/views/home/index.tsx
import * as React from 'react';
import { connect } from 'react-redux';
import { iRootState, Dispatch } from '@/store-rematch';
import { Button } from 'antd';
import styles from './home.scss';

interface IProps {
  [prop: string]: any
}

function Home(props: IProps) {
  return (
    <div className={styles.home}>
      <div className={styles.content}>
        <p>react-ts-antd-template</p>
        <p className={styles.count}>
          count: { props.count } &emsp;
          <Button onClick={props.addCount}>count++</Button>
        </p>
        <p className={styles.count}>
          countAsync: { props.countAsync } &emsp;
          <Button onClick={props.setCountAsync}>countAsync</Button>
        </p>
      </div>
    </div>
  )
}

const mapState = (state: iRootState) => {
  return {
    count: state.common.count,
    countAsync: state.common.countAsync
  }
}
const mapDispatch = (dispatch: Dispatch) => {
  return {
    addCount: () => dispatch({ type: 'common/addCount' }),
    setCountAsync: () => dispatch({ type: 'common/setCountAsync', payload: new Date().getSeconds() }),
  }
}

export default connect(mapState, mapDispatch)(Home);
  • react-redux 新增Hooks: useSelector, useDispatch 写法
import * as React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { iRootState, Dispatch } from '@/store-rematch';
import { Button } from 'antd';
import styles from './home.scss';

interface IProps {
  [prop: string]: any
}

function Home(props: IProps) {
  const dispatch: Dispatch = useDispatch();
  const { count, countAsync } = useSelector((state: iRootState) => state.common);
  
  return (
    <div className={styles.home}>
      <div className={styles.content}>
        <p>react-ts-antd-template</p>
        <p className={styles.count}>
          count: { count } &emsp;
          <Button onClick={() => dispatch({ type: 'common/addCount' })}>count++</Button>
        </p>
        <p className={styles.count}>
          countAsync: { countAsync } &emsp;
          <Button 
            onClick={() => dispatch({ type: 'common/setCountAsync', payload: new Date().getSeconds() })}
          >countAsync</Button>
        </p>
      </div>
    </div>
  )
}

export default Home;

+7、状态管理 mobx

yarn add mobx mobx-react

相对 redux 来说,mobx 概念少,写法简单使用也简单;类组件使用装饰器,函数组件使用同名函数

  • @observable: 声明数据 state
  • @computed: 计算属性,可以从对象或数组中取出需要的数据
  • @action: 动作函数,可以直接写异步函数
  • runInAction: 注意没有 @,不是装饰器;在 @action 装饰的函数内部修改 state,如下面 setTimeout 内修改数据
  • flow: 返回一个生成器 generator 函数,用 function */yield 代替 async/await(这两个其实是他们的语法糖),不需要使用 @action/runInAction
  • @inject('homeStore'): 将 homeStore 注入到组件
  • @observer: 函数/装饰器可以用来将 React 组件转变成响应式组件。 它用 mobx.autorun 包装了组件的 render 函数以确保任何组件渲染中使用的数据变化时都可以强制刷新组件。observer 是由单独的 mobx-react 包提供的。

其他的配置:

  • 下载插件
    yarn add babel-plugin-transform-decorators-legacy -D
    
  • 然后在 .babelrc: 使用装饰器
    "plugins": ["transform-decorators-legacy"]
    
  • tsconfig.json: 使用装饰器
    "compilerOptions": {
      "experimentalDecorators": true,
    }
    

7.1 项目入口

使用 Provider 包括项目

import { Provider } from 'mobx-react';
import * as React from 'react';
import * as ReactDOM from 'react-dom';

import { Provider } from 'mobx-react';
import store from './store';
import AxiosConfig from './api';
import Router from './router';
import './index.scss';
import registerServiceWorker from './registerServiceWorker'; 

const Loading = () => (<div>loading...</div>);

AxiosConfig(); // 初始化 axios

ReactDOM.render(
  <React.Suspense fallback={<Loading />}>
    <Provider {...store}>
      <Router />
    </Provider>
  </React.Suspense>,
  document.getElementById('root') as HTMLElement
);

registerServiceWorker();

7.2 模块

// src/store/home.ts
import * as mobx from 'mobx';

// 禁止在 action 外直接修改 state 
mobx.configure({ enforceActions: "observed"});
const { observable, action, computed, runInAction, autorun } = mobx;

let cache = sessionStorage.getItem('homeStore');

// 初始化数据
let initialState = {
  count: 0,
  data: {
    time: '2019-11-08'
  },
};

// 缓存数据
if (cache) {
  initialState = {
    ...initialState,
    ...JSON.parse(cache)
  }
}

class Home {
  @observable
  public count = initialState.count;

  @observable
  public data = initialState.data;

  @computed
  public get getTime() {
    return this.data.time;
  }

  @action
  public setCount = (_count: number) => {
    this.count = _count;
  }

  @action
  public setCountAsync = (_count: number) => {
    setTimeout(() => {
      runInAction(() => {
        this.count = _count;
      })
    }, 1000);
  }

  // public setCountFlow = flow(function *(_count: number) {
  //   yield setTimeout(() => {}, 1000);
  //   this.count = _count;
  // })
}

const homeStore = new Home();

autorun(() => {
  // 数据变化后触发,数据缓存
  const obj = mobx.toJS(homeStore);
  sessionStorage.setItem('homeStore', JSON.stringify(obj));
})

export type homeStoreType = typeof homeStore;
export default homeStore;

7.3 缓存

这里使用 sessionStorage,改为其他随意

数据缓存的时候,可以根据需要,匹配某些 key 去缓存,而不是所有数据;

  • 初始化数据

    数据初始化时,如果缓存中有数据,则使用缓存的数据覆盖默认数据

    let cache = sessionStorage.getItem('homeStore');
    
    // 初始化数据
    let initialState = {
      count: 0,
      data: {
        time: '2019-11-08'
      },
    };
    
    // 缓存数据
    if (cache) {
      initialState = {
        ...initialState,
        ...JSON.parse(cache)
      }
    }
    
  • 监听数据变化

    autorun 会在每次数据变化执行,然后将 homeStore 转化为 js 对象(只包含 state ),存到缓存中

    const homeStore = new Home();
    
    autorun(() => {
      // 数据变化后触发,数据缓存
      const obj = mobx.toJS(homeStore);
      sessionStorage.setItem('homeStore', JSON.stringify(obj));
    })
    

7.4 模块管理输出

// src/store/index.ts
import homeStore from './home';

/**
 * 使用 mobx 状态管理
 */
export default {
  homeStore
}

7.5 组件使用

使用装饰器在 class 上就可以了, inject 注入对应模块,可以多次 inject

注意 @inject('homeStore') @observer 这两个的顺序,不然会有警告

// src/views/home/index.tsx
import { observer, inject } from 'mobx-react';
import { homeStoreType } from '@/store/home';
...

interface IProps extends RouteComponentProps {
  history: History,
  homeStore: homeStoreType
}

@inject('homeStore')
@observer
class Home extends React.Component<IProps> {
  ...
  
  public componentDidMount() {

    this.props.homeStore.setCount(2);
    console.log(this.props.homeStore.count); // 2
    
  }

  ...
}

8、跨域代理

使用 http-proxy-middleware 插件

yarn add http-proxy-middleware

新建 scr/setupProxy.js

const proxy = require("http-proxy-middleware");

module.exports = function(app) {
  app.use(
    proxy('/', {
      target: 'http://192.168.0.5:2333',
      changeOrigin: true
    })
  );
};

在script/start.js中使用:

const devServer = new WebpackDevServer(compiler, serverConfig);

之后,添加以下代码(如果可以代理下面就不用加了)

require('../src/setupProxy')(devServer);

9、css-module、全局scss变量

class 输出配置: [local]__[hash:base64:6],输出形如:content__1f1Aqs,详细可看 这里

sass全局变量使用这个 loader sass-resources-loader
配置一下 loader,然后在这个文件里面 src/utils/variable.scss 写变量,然后就可以愉快的使用了

yarn add sass-resources-loader
// webpack.config.dev.js, webpack.config.prod.js
  {
    test: /\.(scss|less)$/,
    exclude: [/node_modules/],
    use: [
      {
        loader: require.resolve('style-loader'),
      },
      {
        loader: require.resolve('css-loader'),
        options: {
          importLoaders: 1,
          modules: true,
          localIdentName: '[local]__[hash:base64:6]'
        }
      },
      {
        loader: require.resolve('postcss-loader'),
        options: {
          // Necessary for external CSS imports to work
          // https://github.com/facebookincubator/create-react-app/issues/2677
          ident: 'postcss',
          plugins: () => [
            require('postcss-flexbugs-fixes'),
            autoprefixer({
              browsers: [
                '>1%',
                'last 4 versions',
                'Firefox ESR',
                'not ie < 9', // React doesn't support IE8 anyway
              ],
              flexbox: 'no-2009',
            }),
          ],
        },
      },
      {
        loader: require.resolve('sass-loader'),
      },
      {
        loader: 'sass-resources-loader',
        options: {
          resources: [
            path.resolve(__dirname, './../src/utils/variable.scss'),
          ],
        }
      }
    ]
  },

10、列表keep-alive

可以看 这里

11、高阶组件与withRouter

主要是多个高阶组件使用时候 props 类型的传递 需要注意

Context.Provider

// src/App.tsx
import * as React from 'react';
import Header from '@/components/header';
import Sidebar from '@/components/sidebar';
import Footer from '@/components/footer';
import styles from './App.scss';
import { RouteComponentProps, withRouter } from 'react-router';

interface IProps extends RouteComponentProps {
  [prop: string]: any
}
export interface IState {
  timer?: any
}
export type State = Readonly<IState>;

export interface IAppContext {
  appname: string
}
const defaultContext: IAppContext = { appname: 'react-antd-ts' };
export const AppContext = React.createContext(defaultContext);

class App extends React.Component<IProps, State> {
  public readonly state: State = {};
  
  constructor(props: IProps) {
    super(props);
  }

  public render() {
    return (
      <div className={styles.app}>
        <AppContext.Provider value={defaultContext}>
          <Header text="tteexxtt" />
          <Sidebar />
          { this.props.children }
          <Footer />
        </AppContext.Provider>
      </div>
    );
  }
}

export default withRouter(App);

Context.Consumer 包装

也可以使用 useContext 替代,就不需要下面Consumer包装了

// src/components/withAppContext/index.tsx
import * as React from 'react';
import { AppContext, IAppContext } from '@/App';

// 高阶组件:AppContext Consumer包装
// 使用时包在最外层,如 withAppContext<IProps>(withRouter(Header));
function withAppContext<T>(Component: React.ElementType) {
  // T: 泛型,传递 Component 的 props 类型,被包装的组件在父组件使用时智能提示
  // 但是需要和 withRouter 的类型分开, 
  // 因为 withRouter 不会传递除 history/location/match 之外的 props
  return (props: T) => {
    return (
      <AppContext.Consumer>
        {
          (appcontext: IAppContext) =>  <Component {...props} {...appcontext} />
        }
      </AppContext.Consumer>
    );
  }
}

export default withAppContext;

组件使用

注意:

1、withRouter 不会传递除 history/location/match 之外的 props, 所以这里与组件本身的 props 类型分开;

2、使用 withAppContext 传递的泛型是组件本身的 props:即 IProps

// src/components/header/index.tsx
import * as React from 'react';
import withAppContext from '@/components/withAppContext';
import { withRouter, RouteComponentProps } from 'react-router';
import styles from './header.scss';

const { useEffect } = React;

interface IProps {
  text: string,
  [prop: string]: any
}
// withRouter不会传递除 history/location/match 之外的 props,
// 所以这里与组件本身的 props 类型分开
type IPropsWithRoute = IProps & RouteComponentProps;

function Header(props: IPropsWithRoute) {
  useEffect(() => {
    console.log(props);
  }, []);
  
  return (
    <section className={styles.header}>
      <div className="center-content">
        <div>LOGO</div>
        <div>HEADER, { props.appname }, {props.text}</div>
      </div>
    </section>
  );
}

// withRouter不会传递除 history/location/match 之外的 props,
// 所以这里使用组件本身的 props:即 IProps
export default withAppContext<IProps>(withRouter(Header));

12、国际化

使用 react-intl

yarn add react-intl @types/react-intl

在 App 中使用 IntlProvider

// src/App.tsx
import { IntlProvider } from 'react-intl';
import messages from '@/lang';

...

class App extends React.Component<Props, State> {
  public readonly state: State = {
    lang: Cookies.get('lang') || 'zh'
  };
  
  constructor(props: Props) {
    super(props);
  }
  
  public onLangChange(locale: string) {
    Cookies.set('lang', locale);
    this.setState({ lang: locale });
  }

  public render() {
    // console.log(this.props);
    const { lang } = this.state;

    return (
      <div className={styles.app}>
        <IntlProvider key="intl" locale={lang} messages={messages[lang]}>
          <AppContext.Provider value={defaultContext}>
            <Header text="tteexxtt" onLangChange={(locale: string) => this.onLangChange(locale)} />
            <Sidebar />
            { this.props.children }
            <Footer />
          </AppContext.Provider>
        </IntlProvider>
      </div>
    );
  }
}
...

语言文件

lang 入口

// src/lang/index.ts
import en from './en_US';
import zh from './zh_CN';

export default {
  en,
  zh
};

messages 具体语言

原本是想像 Vue 里面用的 i18n 那样,语言模块多一层,但是插件结构貌似不允许(可能需要设置),所以只能扁平展开 模块,然后在下面 messages 模块 里面的键名做处理了

// src/lang/zh_CN/index.ts
import home from './home';
// import detail from './detail';

export default {
  ...home,
  // ...detail
};

messages 模块

注意键名,暂使用这种方式实现按模块的多语言

// src/lang/zh_CN/home.ts
const home = {
  'home.home': '首页',
  'home.list': '列表',
  'home.login': '登录'
};

export default home;

组件使用

react-intl 这个多语言包除了 FormattedMessage之外,还有其他的组件用来实现金额、货币、日期等差异显示,这里就不写了,有需要看文档照做就是了

// src/components/sidebar/index.tsx
import { FormattedMessage } from 'react-intl';
...
<FormattedMessage id="home.home" />

切换语言

// src/components/header/index.tsx
...
import Cookies from 'js-cookie';

const { useEffect, useMemo } = React;

interface IProps {
  text: string,
  onLangChange: (locale: string) => void,
  [prop: string]: any
}
// withRouter不会传递除 history/location/match 之外的 props,
// 所以这里与组件本身的 props 类型分开
type IPropsWithRoute = IProps & RouteComponentProps;

function Header(props: IPropsWithRoute) {
  const lang = useMemo(() => {
    return Cookies.get('lang') || 'zh';
  }, [Cookies.get('lang')]);

  return (
    <section className={styles.header}>
      ...
          <div className={styles.langsection}>
            <span 
              className={`${styles.lang} ${lang === 'zh' ? styles.active : ''}`} 
              onClick={() => props.onLangChange('zh')}
            >中文</span>
            <span 
              className={`${styles.lang} ${lang === 'en' ? styles.active : ''}`} 
              onClick={() => props.onLangChange('en')}
            >English</span>
          </div>
      ...
    </section>
  );
}
...

13、构建

输出

使用 chunkhash 的话每次构建都会生成一个hash,导致内容不变但是还是文件名却变了;所以修改为 contenthash 根据内容生成 hash ,则 hash 值与内容相关,更好的缓存,但是不可避免的会导致构建时间增加,不过还是值得的

  • 文件名:修改 output 中文件名 chunkhash -> contenthash,如:
filename: 'static/js/[name].[contenthash:8].js',
chunkFilename: 'static/js/[name].[contenthash:8].chunk.js',
  • 代码分割
  optimization: {
    splitChunks: {
      chunks: 'all'
    }
  },

tree-shaking

webpack 文档有说明要设置 mode: 'production',但是我这里 build 之后的文件,打开 webpack module 会报错;但是设置 mode: 'development' 之后就可以正常访问,只是文件比用 production 要大一点,,,这样就没意义了,所以这部分 暂时不搞 了。。。

TypeError: Cannot read property 'call' of undefined

package.json 中

添加 "sideEffects": false,

webpack.prod.js 中

  optimization: {
    ...
    // tree shaking,与 package.json 中 "sideEffects": false 配合使用
    usedExports: true
  }

第三方资源 CDN

目前只有构建使用资源CDN引入,开发阶段并无区别

react-router-dom 有问题会报错,暂时无法使用

暂时手动处理,也可以使用 HtmlWebpackPlugin 自动处理

格式: 包名: 导出变量名

  • webpack 使用 externals:
  externals: {
    'axios': 'axios',
    'lodash' : {
      commonjs: 'lodash',
      amd: 'lodash',
      root: '_' // 指向全局变量
    },
    'react': 'React',
    'react-dom': 'ReactDOM',
    'react-router': 'ReactRouter',
    // 'react-router-dom': 'ReactRouterDOM',
    'mobx': 'mobx',
    'react-mobx': 'ReactMobx',
  },
  • public/index.html 中添加 第三方资源的 CDN 链接
<script src="https://cdn.bootcss.com/axios/0.19.0/axios.min.js"></script>
<script src="https://cdn.bootcss.com/react/16.8.6/umd/react.production.min.js"></script>
<script src="https://cdn.bootcss.com/react-dom/16.8.6/umd/react-dom.production.min.js"></script>
<script src="https://cdn.bootcss.com/react-router/5.0.1/react-router.min.js"></script>
<!-- <script src="https://cdn.bootcss.com/react-router-dom/5.0.1/react-router-dom.min.js"></script> -->
<script src="https://cdn.bootcss.com/mobx/4.14.0/mobx.umd.min.js"></script>
<script src="https://cdn.bootcss.com/mobx-react/5.2.0/custom.min.js"></script>
<script src="https://cdn.bootcss.com/lodash.js/4.17.15/lodash.core.min.js"></script>

最后

  • 项目里用到的东西,基本上都在上面了,后续有其他的东西再更新加上吧;
  • 前面有些代码是早期写的,后续加新的东西,所以跟后面有些功能是不一样的,不过按之前的写法一般不会有问题;就是新加功能需要改写原来的部分代码
  • 另外,webpack 开发/生产配置可以只用一个,然后使用 webpack merge 进去就可以了,本文的 webpack 都是在旧的文件基础上改的,可能有些东西是多余的。。。
  • React Hooks 已经很好用,差不多可以不用写 class 组件了
  • 多个高阶组件组合的 props 传递需要注意一下
  • React 用了几个月,能想到的就这些了,其他的高级货暂时没有。。。