万字长文,我们来好好实践一下前端工程化(二)

910 阅读22分钟

这里有第二部分的视频解说

我们接着上一篇文章继续

组件化与模块化

组件化与模块化思想

首先我们要明确一点,是什么问题导致了我们需要模块化和组件化。

  • 代码都是很多很多写在一起
  • 没有好的功能划分
  • 不能实现组件复用
  • 页面 UI 和数据没有实现分离
  • 页面逻辑条理不清晰
  • 可读性差
  • 可维护性差

所以针对以上问题前端出现了组件化和模块化的思想,以前的Jquery到现在的Angular,React,Vue在基于 MVC/MVVM的设计思想对于组件化模块化的思想越来越规范。

再看看React是怎么做的。

React的主要思想就是数据和DOM操作分离,而且都利用JS去实现所有功能(UI 渲染、数据处理),分离之后我们只需要关注数据层面的增删改查,而不需要关心怎样把数据渲染到DOM元素上,关于DOM挂载这些事情React都帮我们做了。

但其实在实际开发中,项目在架构之时如果没有一定的规范,其实代码依旧会很乱,并不能有效解决上述的问题。所以作为一个多人长期开发维护的项目,在建项的时候就要考虑到如何从根源上解决这些问题。

下面我们将实现React思想中的数据和DOM操作分离、利用JS去实现所有功能、只关注数据层面的增删改查。

函数式编程

函数是编程是一种设计思想,就像面向对象编程也是一种设计思想。函数式编程总的来说就是用尽量用函数组合来进行编程,先声明函数,然后调用函数的每一步都有返回值,将具体的每一步逻辑运算抽象,封装在函数中。再将函数组合来编写程序。

React中子组件受父组件传入的props控制,但是子组件不能直接修改props,也就是单项数据流,这样就有点类似于纯函数的特性,不改变外部状态。所以我们在开发组件是,应当尽量将具有副作用的操作交给外部控制,这样的组件才是独立的,也具有高度的适应性。

为什么要使用函数式编程?

  1. 开发速度快,高度使用函数可以不断复用逻辑,函数对外为黑盒可直接无副作用的使用。
  2. 通过函数名就可以直接理解用处,不需要了解内部实现,接近自然语言,相当于我们使用吹风机,不需要一步步制作吹风机。
  3. 代码更清晰,真正调用的代码很简单,所有细节逻辑都封装在函数内。
  4. 方便并发处理,因为方法为纯函数不影响外部变量,可以随意排放处理顺序。

编写路由

开始具体说组件化和模块化之前我们需要一个默认路由指向我们的首页容器。

import React from 'react';

import GlobalStyle from '../../global-styles';
import { Switch, Route } from 'react-router-dom';
import Home from 'app/containers/Home';

export default function App() {
  return (
    <div>
      <Switch>
        <Route exact path="/" component={Home} />
      </Switch>
      <GlobalStyle />
    </div>
  );
}

编写容器

一个容器本质上也是一种组件,它是可以进行异步操作的,所以容器是需要用到redux+Saga来处理异步操作的。但是组件是容器中抽离出来的一块块功能,它不需要redux+Saga的支持,它的逻辑应该都是内部实现的,它只接收一些需要的参数就能完成渲染,所以组件有时候也不一定需要复用性很强,或者是我觉得某一块就是一块整体,那么就可以把它抽离出来当作一个组件。

试想一下,我们的每个页面容器里面都是一个个组件拼装而成,那么这个页面的布局是不是一下就能看清楚,如同前面函数式编程说的,我不需要关心每个组件内部是怎么实现的,因为容器我只需要看清它的结构就好了。

那么我们先看一下一个页面容器模块化需要用到的文件有哪些:

  • constants.js action常量
  • actions.js action定义
  • reducer.js reducer函数
  • selectors.js reselect包装的state取值方法
  • Loadable.js 利用react的延迟加载方法,延迟加载组件
  • saga.js saga模块
  • mseeages.js 国际化信息
  • node.js 样式
  • index.js 组件

乍一看,文件很多,好像很乱的样子,我们一个一个来捋:

constants.js

给其他文件提供常量,常量值的定义一定要对应到容器名,因为我们的store是全局的,它需要知道这个action是哪个容器的。

export const DEFAULT_ACTION = 'app/Home/DEFAULT_ACTION';

actions.js

action定义的文件,组件和saga会执行对它们的调用来修改state中的值。

import { DEFAULT_ACTION } from './constants';

export function defaultAction() {
  return {
    type: DEFAULT_ACTION,
  };
}

reducer.js

简单来说 redux 就是一组 state ,想要修改它里面的值就要发起一个 action , action 就是一个普通 JavaScript 对象。强制使用 action 来描述所有变化带来的好处是可以清晰地知道应用中到底发生了什么。如果一些东西改变了,就可以知道为什么变。action 就像是描述发生了什么的指示器。最终,为了把 action 和 state 串起来,开发一些函数,这就是 reducer。最后存放 state 的地方就是 store ,能够触发 action 方法的就是 dispatch 。

那么之前我们在入口文件用了<Provider>组件实现了 store 的全局共享,但是回头看一下/app/reducers.js的代码,我们预留了一个injectedReducers的入参,就是让每个容器的redux能够在组件初始化的时候合并到store中。所以我们现在需要一个方法,能够在每次容器初始化的时候自动帮我们把当前容器的redux合并到store中。

那么我们先在/app/utils里面创建一些文件来实现合并redux的方法。

injectReducer.js

import React from 'react';
import { ReactReduxContext } from 'react-redux';

import getInjectors from './reducerInjectors';

/**
 * 动态注入一个reducer
 *
 * @param {string} key reducer的模块名称
 * @param {function} reducer 一个将被注入的reducer函数
 *
 */
const useInjectReducer = ({ key, reducer }) => {
  // 获取顶级父组件的context
  const context = React.useContext(ReactReduxContext);
  // 当前组件初始化的时候执行一次Reducer注入
  React.useEffect(() => {
    getInjectors(context.store).injectReducer(key, reducer);
  }, []);
};

export { useInjectReducer };

reducerInjectors.js

import invariant from 'invariant';
import { isEmpty, isFunction, isString } from 'lodash';

import checkStore from './checkStore';
import createReducer from '../reducers';

export function injectReducerFactory(store, isValid) {
  return function injectReducer(key, reducer) {
    if (!isValid) checkStore(store);
		// 验证参数合法性
    invariant(
      isString(key) && !isEmpty(key) && isFunction(reducer),
      '(app/utils...) injectReducer: 模块名称key应为非空字符串且reducer应该是一个reducer function',
    );

    // 开发过程中我们可能会修改Reducer
    // 判断 `store.injectedReducers[key] === reducer` 以便在模块key相同但是reducer不同时执行热加载
    if (Reflect.has(store.injectedReducers, key) && store.injectedReducers[key] === reducer) return;

    // 替换新的Reducer
    store.injectedReducers[key] = reducer; // eslint-disable-line no-param-reassign
    store.replaceReducer(createReducer(store.injectedReducers));
  };
}

export default function getInjectors(store) {
  // 检查store的有效性
  checkStore(store);

  return {
    injectReducer: injectReducerFactory(store, true),
  };
}

checkStore.js

import { conformsTo, isFunction, isObject } from 'lodash';
import invariant from 'invariant';

/**
 * 验证 redux store 的有效性
 */
export default function checkStore(store) {
  const shape = {
    dispatch: isFunction,
    subscribe: isFunction,
    getState: isFunction,
    replaceReducer: isFunction,
    runSaga: isFunction,
    injectedReducers: isObject,
    injectedSagas: isObject,
  };
  invariant(conformsTo(store, shape), '(app/utils...) injectors: 需要有效的 redux store');
}

好了,到这里我们把合并redux的通用方法写完了,后面我们只需要在index.js里面调用一次就可以了。

再说回我们当前容器的reducer.js

前面引入react插件的时候我们提到了immer,前面给出的解释如果有点难理解的话,这篇文章很好的作出了解释。我这里总结一下就是:

利用 ES6 的 proxy,几乎以最小的成本实现了 js 的不可变数据结构。当数据需要变动时,也不会对整个结构树进行重新的深拷贝,而是只改变需要改变的数据。看个例子,体会一下:

const currentState = {
  a: [],
  p: {
    x: 1
  }
}

let nextState = produce(currentState, (draft) => {
  draft.a.push(2);
})

currentState.a === nextState.a; // false
currentState.p === nextState.p; // true

reducer.js

import produce from 'immer';
import { DEFAULT_ACTION } from './constants';

export const initialState = {
  title: 'Home'
  msg: 'rainbow in paper',
};

/* eslint-disable default-case, no-param-reassign */
const homeReducer = (state = initialState, action) =>
  // eslint-disable-next-line no-unused-vars
  produce(state, draft => {
    switch (action.type) {
      case DEFAULT_ACTION:
        break;
    }
  });

export default homeReducer;

selectors.js

这里面用到了reselect对state进行取值,我们在saga和index中使用state的值都从这些选择器中获取,可以提高性能。

import { createSelector } from 'reselect';
import { initialState } from './reducer';

// Home状态域的直接选择器
// 返回当前模块的state
const selectHomeDomain = state => state.home || initialState;

/**
 * reselect可以对传入的依赖做一个缓存,如果传入的函数的结果不变,那返回的结果也不会变
 * 在依赖函数中只直接取值,不针对值进行计算,将计算放到createSelector中最后一个参数的函数中
 */

const makeSelectHome = () => createSelector(selectHomeDomain, subState => subState);
const makeSelectHomeMsg = () => createSelector(selectHomeDomain, subState => subState.msg);

export default makeSelectHome;
export { makeSelectHome, makeSelectHomeMsg };

Loadable.js

编写路由的时候我们都使用延迟加载来加载容器来提高性能,所以这个文件就是当前容器或者是组件的入口,它会返回一个包装好的延迟加载组件。想要了解延迟加载的实现的可以看下这篇文章

因为所有的延迟加载包装都是一样的操作,所以我们先在/app/utils/loadable.js里面写个包装方法。包装方法是固定的 这里不作过多解释。

import React, { lazy, Suspense } from 'react';

const loadable = (importFunc, { fallback = null } = { fallback: null }) => {
  const LazyComponent = lazy(importFunc);

  return props => (
    <Suspense fallback={fallback}>
      <LazyComponent {...props} />
    </Suspense>
  );
};

export default loadable;

Loadable.js

因为已经有包装方法了,所以实现很简单了。

import loadable from '../../utils/loadable';

export default loadable(() => import('./index'));

saga.js

saga是用来处理异常和异步等副作用操作的,而且saga的运行是依赖于redux的,所以,saga也是需要动态注册的,那么我们需要像处理redux那样,写一个注册saga的公共方法,在每个容器初始化的时候调用,组件卸载的时候自动注销。

那么我们接着在/app/utils下面创建我们的注册方法:

injectSaga.js

import React from 'react';
import hoistNonReactStatics from 'hoist-non-react-statics';
import { ReactReduxContext } from 'react-redux';

import getInjectors from './sagaInjectors';

/**
 * 动态注入Saga,将组件的props作为Saga参数传递
 * @param {string} Saga的模块
 * @param {function} 将被注入的Saga
 * @param {string} [mode] 注册模式
 */

const useInjectSaga = ({ key, saga, mode }) => {
  // 获取顶级父组件的context
  const context = React.useContext(ReactReduxContext);
  // 当前组件初始化的时候执行一次Saga注册
  React.useEffect(() => {
    const injectors = getInjectors(context.store);
    // 注册saga
    injectors.injectSaga(key, { saga, mode });

    return () => {
      // 组件卸载时执行注销saga
      injectors.ejectSaga(key);
    };
  }, []);
};

export { useInjectSaga };

sagaInjectors.js

import invariant from 'invariant';
import { isEmpty, isFunction, isString, conformsTo } from 'lodash';

import checkStore from './checkStore';
import { DAEMON, ONCE_TILL_UNMOUNT, RESTART_ON_REMOUNT } from './constants';

const allowedModes = [RESTART_ON_REMOUNT, DAEMON, ONCE_TILL_UNMOUNT];

const checkKey = key =>
  invariant(isString(key) && !isEmpty(key), '(app/utils...) injectSaga: 模块名称key应为非空字符串');

const checkDescriptor = descriptor => {
  const shape = {
    saga: isFunction,
    mode: mode => isString(mode) && allowedModes.includes(mode),
  };
  invariant(
    conformsTo(descriptor, shape),
    '(app/utils...) injectSaga: 应该传入一个有效的 saga descriptor',
  );
};

export function injectSagaFactory(store, isValid) {
  return function injectSaga(key, descriptor = {}, args) {
    if (!isValid) checkStore(store);

    const newDescriptor = {
      ...descriptor,
      mode: descriptor.mode || DAEMON,
    };
    const { saga, mode } = newDescriptor;
    // 验证参数合法性
    checkKey(key);
    checkDescriptor(newDescriptor);
    // 是否已经有saga注册过
    let hasSaga = Reflect.has(store.injectedSagas, key);
    // 如果不是生产环境,则执行热更新判断,因为在开发环境下saga是不会改变的
    if (process.env.NODE_ENV !== 'production') {
      const oldDescriptor = store.injectedSagas[key];
      // 如果新的saga与旧的不同则注销旧的
      if (hasSaga && oldDescriptor.saga !== saga) {
        oldDescriptor.task.cancel();
        hasSaga = false;
      }
    }
    // 如果没有注册过saga或者注册模式为重新装载时重新启动则注册当前saga
    if (!hasSaga || (hasSaga && mode !== DAEMON && mode !== ONCE_TILL_UNMOUNT)) {
      /* eslint-disable no-param-reassign */
      store.injectedSagas[key] = {
        ...newDescriptor,
        task: store.runSaga(saga, args),
      };
      /* eslint-enable no-param-reassign */
    }
  };
}

export function ejectSagaFactory(store, isValid) {
  return function ejectSaga(key) {
    if (!isValid) checkStore(store);
    // 验证参数合法性
    checkKey(key);
    // 判断是否存在当前模块的saga
    if (Reflect.has(store.injectedSagas, key)) {
      const descriptor = store.injectedSagas[key];
      // 如果不是默认注册模式,则每次都要注销当前saga
      if (descriptor.mode && descriptor.mode !== DAEMON) {
        descriptor.task.cancel();
        // 生产过程中的清理;在开发过程中,我们需要“descriptor.saga”进行热重新加载
        if (process.env.NODE_ENV === 'production') {
          // 需要一些值才能检测“ONCE_TILL_UNMOUNT”saga中的`injectSaga`
          store.injectedSagas[key] = 'done'; // eslint-disable-line no-param-reassign
        }
      }
    }
  };
}

export default function getInjectors(store) {
  // 检查store的有效性
  checkStore(store);

  return {
    // 注册sage
    injectSaga: injectSagaFactory(store, true),
    // 注销saga
    ejectSaga: ejectSagaFactory(store, true),
  };
}

constants.js

// RESTART_ON_REMOUNT saga将在安装组件时启动,并在卸载组件时使用'task.cancel()'取消,以提高性能。
export const RESTART_ON_REMOUNT = '@@saga-injector/restart-on-remount';
// 默认情况下(DAEMON)在组件装载时将启动saga,从未取消或重新启动。
export const DAEMON = '@@saga-injector/daemon';
// ONCE_TILL_UNMOUNT 行为类似于“重新装入时重新启动”,重新装入前不会再次运行它。
export const ONCE_TILL_UNMOUNT = '@@saga-injector/once-till-unmount';

好了,到这里我们把注册注销saga的通用方法写完了,后面我们只需要在index.js里面调用一次就可以了。

再说回我们当前容器的saga.js

saga是Middleware的一种,工作于action和reducer之间。如果按照原始的redux工作流程,当组件中产生一个

action后会直接触发reducer修改state;而往往实际中,组件中发生的action后,在进入reducer之前需要完成一

个异步任务,显然原生的redux是不支持这种操作的。

不过,实现异步操作的整体步骤是很清晰的:action被触发后,首先执行异步任务,待完成后再将这个action交给

reducer。

saga需要一个全局监听器(watcher saga),用于监听组件发出的action,将监听到的action转发给对应的接收器

(worker saga),再由接收器执行具体任务,任务执行完后,再发出另一个action交由reducer修改state,所以

这里必须注意:watcher saga监听的action和对应worker saga中发出的action不能是同一个,否则造成死循环

在saga中,全局监听器和接收器都使用Generator函数和saga自身的一些辅助函数实现对整个流程的管控

整个流程可以简单描述为

Component —> Action1 —> Watcher Saga —> Worker Saga —> Action2 —> Reducer —> Component

saga.js

import { takeLatest, delay } from 'redux-saga/effects';

import { DEFAULT_ACTION } from './constants';

// 页面初始化
export function* defaultSaga() {
  try {
    yield delay(3000);
    console.log('delay 3s log');
  } catch (error) {
    console.log(error);
  }
}

export default function* homeSaga() {
  // takeLatest 不允许多个 saga 任务并行地执行。
  // 一旦接收到新的发起的 action,它就会取消前面所有 fork 过的任务(如果这些任务还在执行的话)。
  yield takeLatest(DEFAULT_ACTION, defaultSaga);
}

mseeages.js

为了避免多人协同开发一个zh.json文件导致频繁冲突,翻译先放分别置于每个模块下message.js文件,开发完成统一用node工具自动生成到zh.jsonen.json文件。

import { defineMessages } from 'react-intl';

export const scope = 'app.containers.Home';

export default defineMessages({
  changeLang: {
    id: `${scope}.changeLang`,
    defaultMessage: '切换语言', // 默认中文
    // 语言描述,这里我们就写英文信息
		// 使用npm run extract-intl时会把description赋值到对应英文信息里面
    description: 'Change Language', 
  },
  webTitle: {
    id: `${scope}.webTitle`,
    defaultMessage: '纸上的彩虹',
    description: 'Rainbow In Paper',
  },
});

nodes.js

nodes是styled-components包装的当前页面的所有节点样式,供index页面引用,那么页面布局就像是一个个语义化的标签构成的,可以是页面的结构清晰明了。

import styled, { css } from 'styled-components';

const container = css`
  text-align: center;
  margin: 50px;
`;

/* eslint-disable prettier/prettier */
const Container = styled.div`${container}`;

export default { Container };

index.js

/**
 React.memo(...)是React v16.6引进来的新属性。
 它的作用和React.PureComponent类似,是用来控制函数组件的重新渲染的。
 React.memo(...) 其实就是函数组件的React.PureComponent。
 用memo包装组件就是尽可能优化组件的性能,避免不必要的无用或者重复的渲染
 */
import React, { memo, useEffect } from 'react';
import { connect } from 'react-redux';
import { Helmet } from 'react-helmet';
import PropTypes from 'prop-types';
import { createStructuredSelector } from 'reselect';
import { compose } from 'redux';
import { FormattedMessage } from 'react-intl';
import { useInjectReducer } from '../../utils/injectReducer';
import { useInjectSaga } from '../../utils/injectSaga';
import reducer from './reducer';
import saga from './saga';
import { makeSelectHome } from './selectors';
import { makeSelectLocale } from '../LanguageProvider/selectors';
import { defaultAction } from './actions';
import messages from './messages';
import Nodes from './nodes';
import { Button } from 'antd';
import { changeLocale } from '../LanguageProvider/actions';

export function Home(props) {
  // 注册reducer
  useInjectReducer({ key: 'home', reducer });
  // 注册saga
  useInjectSaga({ key: 'home', saga });
  const { msg } = props.home;
  // useEffect相当于函数组件的生命周期
  useEffect(() => {
    props.defaultAction();
    console.log('组件加载');
    return () => {
      console.log('组件卸载');
    }
  }, []);
  return (
    <div>
      <FormattedMessage {...messages.webTitle}>
        {title => (
          <Helmet>
            <title>{title}</title>
            <meta name="description" content="Description of Home" />
          </Helmet>
        )}
      </FormattedMessage>
      <Nodes.Container>
        <Nodes.Title>
          <FormattedMessage {...messages.webTitle} />
        </Nodes.Title>
        <Button
          type="primary"
          onClick={() => {
            props.changeLang(props.locale === 'zh' ? 'en' : 'zh');
          }}
        >
          <FormattedMessage {...messages.changeLang} />
        </Button>
      </Nodes.Container>
    </div>
  );
}

/**
 使用属性类型来记录传递给组件的属性的预期类型。
 运行时对props进行类型检查。
 */
// eslint-disable-next-line react/no-typos
Home.PropTypes = {
  dispatch: PropTypes.func.isRequired,
};

// 为当前组件的props注入需要的state
const mapStateToProps = createStructuredSelector({
  locale: makeSelectLocale(),
  home: makeSelectHome(),
});
// 为当前组件的props导入需要的action
function mapDispatchToProps(dispatch) {
  return {
    dispatch,
    defaultAction: () => {
      dispatch(defaultAction());
    },
    changeLang: lang => {
      dispatch(changeLocale(lang));
    },
  };
}

/**
 连接 React 组件与 Redux store。
 连接操作不会改变原来的组件类。
 反而返回一个新的已与 Redux store 连接的组件类。

 mapStateToProps: 如果定义该参数,组件将会监听 Redux store 的变化。
 任何时候,只要 Redux store 发生改变,mapStateToProps 函数就会被调用。
 该回调函数必须返回一个纯对象,这个对象会与组件的 props 合并。
 如果你省略了这个参数,你的组件将不会监听 Redux store。

 mapDispatchToProps: 如果传递的是一个对象,
 那么每个定义在该对象的函数都将被当作 Redux action creator,对象所定义的方法名将作为属性名;
 每个方法将返回一个新的函数,函数中dispatch方法会将action creator的返回值作为参数执行。
 这些属性会被合并到组件的 props 中。

 根据配置信息,返回一个注入了 state 和 action creator 的 React 组件。
 */
const withConnect = connect(mapStateToProps, mapDispatchToProps);

/**
 从右到左来组合多个函数。

 这是函数式编程中的方法,为了方便,被放到了 Redux 里。
 当需要把多个 store 增强器 依次执行的时候,需要用到它。

 返回值: 从右到左把接收到的函数合成后的最终函数。
 */
export default compose(withConnect, memo)(Home);

编写组件

前面说到组件我们是不需要处理异步的,同样也不需要state,它只需要接收外部传入的数据然后在内部进行消化就好了,那么相对于容器来说组件需要的模块化文件就少很多了。

  • Loadable.js 利用react的延迟加载方法,延迟加载组件,如果组件很大则需要延迟加载,否则直接引入index即可
  • mseeages.js 国际化信息
  • node.js 样式
  • index.js 组件

我这里就写一个动态修改主题色的组件:

这里我们用到一个颜色选择的插件:

$ npm install react-color

Loadable.js

import loadable from '../../utils/loadable';

export default loadable(() => import('./index'));

mseeages.js

import { defineMessages } from 'react-intl';

export const scope = 'app.components.ThemeSelect';

export default defineMessages({
  changeTheme: {
    id: `${scope}.changeTheme`,
    defaultMessage: '切换主题',
    description: 'Change Theme',
  },
});

node.js

import { createGlobalStyle } from 'styled-components';

const ContainerStyle = createGlobalStyle`
  .ant-popover-inner{
    background-color: transparent !important;
    box-shadow: none !important;
  }
`;

export default {
  ContainerStyle,
};

index.js

import React, { memo, useState } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import messages from './messages';
import { Popover, Button, message } from 'antd';
import { BlockPicker, CirclePicker } from 'react-color';
import Nodes from './nodes';

function ThemeSelect(props) {
  const [visible, setVisible] = useState(false);
  const type = props.type === 'circle' ? 'circle' : 'default';

  const handleColorChange = color => {
    window.less
      .modifyVars({ '@primary-color': color.hex, '@btn-primary-bg': color.hex })
      .then(() => {
        setVisible(false);
        message.success('主题变更成功');
      })
      .catch(error => {
        message.error(error);
      });
  };

  const handleVisibleChange = v => {
    setVisible(v);
  };

  return (
    <>
      <Popover
        content={
          type === 'circle' ? (
            <CirclePicker onChange={handleColorChange} />
          ) : (
            <BlockPicker onChange={handleColorChange} />
          )
        }
        trigger="click"
        visible={visible}
        onVisibleChange={handleVisibleChange}
      >
        <Button type="primary">
          <FormattedMessage {...messages.changeTheme} />
        </Button>
      </Popover>
      <Nodes.ContainerStyle />
    </>
  );
}
// 默认参数
ThemeSelect.defaultProps = {
  type: 'default',
};
// 传入参数
ThemeSelect.propTypes = {
  type: PropTypes.string,
};

export default memo(ThemeSelect);

然后我们在之前的Home里面引用一下:

...
import ThemeSelect from '../../components/ThemeSelect';
...
export function Home(props) {
  ...
  return (
  	...
    <ThemeSelect type="circle" />
    ...
  )
}

大功告成,目前为止我们已经走完了工程化的一整套流程。可是还有一个问题,虽然我们每个容器的模块化做的很好,但是这创建的文件也太多了,而且很多代码都是一样的,所以我们需要一个模版构建的工具,让我们能容器和组件能够自动初始化,我们只要直接写业务就行。还有既然有国际化那么我们现在默认的是中英文,怎么动态再添加一门语言呢?那么接下来我们进行最后一步:模版构建。

模版构建

模版构建这里用到的脚手架工具是plop,它是一个微型的脚手架工具,它的特点是可以根据一个模板文件批量的生成文本或者代码,不再需要手动复制粘贴,省事省力。

首先我们安装一下:

$ npm install plop -D

然后我们在/internals下面创建新文件夹generators,再创建index.js

const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// 组件生成器
const componentGenerator = require('./component/index.js');
// 容器生成器
const containerGenerator = require('./container/index.js');
// 语言生成器
const languageGenerator = require('./language/index.js');

// 备份文件后缀名
const BACKUPFILE_EXTENSION = 'rbgen';

module.exports = plop => {
  // 创建生成器
  plop.setGenerator('component', componentGenerator);
  plop.setGenerator('container', containerGenerator);
  plop.setGenerator('language', languageGenerator);
	// 自定义plop的Action Type
  // 格式化代码格式
  plop.setActionType('prettify', (answers, config) => {
    const folderPath = `${path.join(
      __dirname,
      '/../../app/',
      config.path,
      plop.getHelper('properCase')(answers.name),
      '**',
      '**.js',
    )}`;

    // eslint-disable-next-line no-useless-catch
    try {
      execSync(`npm run prettify -- "${folderPath}"`);
      return folderPath;
    } catch (err) {
      throw err;
    }
  });
  // 备份文件
  plop.setActionType('backup', (answers, config) => {
    // eslint-disable-next-line no-useless-catch
    try {
      fs.copyFileSync(
        path.join(__dirname, config.path, config.file),
        path.join(
          __dirname,
          config.path,
          `${config.file}.${BACKUPFILE_EXTENSION}`,
        ),
        'utf8',
      );
      return path.join(
        __dirname,
        config.path,
        `${config.file}.${BACKUPFILE_EXTENSION}`,
      );
    } catch (err) {
      throw err;
    }
  });
};

module.exports.BACKUPFILE_EXTENSION = BACKUPFILE_EXTENSION;

我们还需要一个方法,就是每次创建新组件的时候要去检查我们是否已经创建过同名的组件或容器。这里我们在generators里面创建一个utils文件夹,然后再创建一个componentExists.js

const fs = require('fs');
const path = require('path');
const pageComponents = fs.readdirSync(path.join(__dirname, '../../../app/components'));
const pageContainers = fs.readdirSync(path.join(__dirname, '../../../app/containers'));
const components = pageComponents.concat(pageContainers);

function componentExists(comp) {
  return components.indexOf(comp) >= 0;
}

module.exports = componentExists;

容器构建

我们在generators文件下创建文件夹container,再创建index.js

const componentExists = require('../utils/componentExists');

module.exports = {
  description: '添加一个容器',
  prompts: [
    {
      type: 'input',
      name: 'name',
      message: '容器名是什么?',
      default: 'Form',
      validate: value => {
        if (/.+/.test(value)) {
          return componentExists(value) ? '已经存在相同的容器名或者组件名' : true;
        }

        return '容器名为必填';
      },
    },
    {
      type: 'confirm',
      name: 'memo',
      default: true,
      message: '是否要将容器包装在React.memo中?',
    },
    {
      type: 'confirm',
      name: 'wantHeaders',
      default: true,
      message: '是否需要页面头部信息?',
    },
    {
      type: 'confirm',
      name: 'wantActionsAndReducer',
      default: true,
      message:
        '容器是否需要 actions/constants/selectors/reducer ?',
    },
    {
      type: 'confirm',
      name: 'wantSaga',
      default: true,
      message: '容器是否需要Saga?',
    },
    {
      type: 'confirm',
      name: 'wantMessages',
      default: true,
      message: '容器是否需要国际化组件?',
    },
    {
      type: 'confirm',
      name: 'wantLoadable',
      default: true,
      message: '容器是否要异步加载?',
    },
  ],
  actions: data => {
    // 生成页面
    const actions = [
      {
        type: 'add',
        path: '../../app/containers/{{properCase name}}/index.js',
        templateFile: './container/index.js.hbs',
        abortOnFail: true, // 如果此操作因任何原因失败,中止所有后续操作
      },
      {
        type: 'add',
        path: '../../app/containers/{{properCase name}}/nodes.js',
        templateFile: './container/nodes.js.hbs',
        abortOnFail: true,
      },
    ];

    if (data.wantMessages) {
      actions.push({
        type: 'add',
        path: '../../app/containers/{{properCase name}}/messages.js',
        templateFile: './container/messages.js.hbs',
        abortOnFail: true,
      });
    }

    if (data.wantActionsAndReducer) {
      // Actions
      actions.push({
        type: 'add',
        path: '../../app/containers/{{properCase name}}/actions.js',
        templateFile: './container/actions.js.hbs',
        abortOnFail: true,
      });

      // Constants
      actions.push({
        type: 'add',
        path: '../../app/containers/{{properCase name}}/constants.js',
        templateFile: './container/constants.js.hbs',
        abortOnFail: true,
      });

      // Selectors
      actions.push({
        type: 'add',
        path: '../../app/containers/{{properCase name}}/selectors.js',
        templateFile: './container/selectors.js.hbs',
        abortOnFail: true,
      });

      // Reducer
      actions.push({
        type: 'add',
        path: '../../app/containers/{{properCase name}}/reducer.js',
        templateFile: './container/reducer.js.hbs',
        abortOnFail: true,
      });
    }

    // Sagas
    if (data.wantSaga) {
      actions.push({
        type: 'add',
        path: '../../app/containers/{{properCase name}}/saga.js',
        templateFile: './container/saga.js.hbs',
        abortOnFail: true,
      });
    }

    if (data.wantLoadable) {
      actions.push({
        type: 'add',
        path: '../../app/containers/{{properCase name}}/Loadable.js',
        templateFile: './container/loadable.js.hbs',
        abortOnFail: true,
      });
    }
		
    // 格式化
    actions.push({
      type: 'prettify',
      path: '/containers/',
    });

    return actions;
  },
};

如上面的代码所示,每个容器有九个文件需要创建,同样就对应9个模版:

  • constants.js.hbs
  • actions.js.hbs
  • reducer.js.hbs
  • selectors.js.hbs
  • loadable.js.hbs
  • saga.js.hbs
  • mseeages.js.hbs
  • node.js.hbs
  • index.js.hbs

模版文件都是.hbs,全称Handlebars,一个单纯的模板引擎。hbs模板模板引擎语法:{{ ... }},两个大括号。模版文件内容很好理解,不过多解释。

Handlebars 是 JavaScript 一个语义模板库,通过对view和data的分离来快速构建Web模板。它用"Logic-less template"(无逻辑模版)的思路,在加载时被预编译,而不是到了客户端执行到代码时再去编译, 这样可以保证模板加载和运行的速度。

constants.js.hbs

// 每个常量需要备注,说明作用 

// 容器初始化
export const COMPONENT_DID_MOUNT = 'app/{{ properCase name }}/COMPONENT_DID_MOUNT';

actions.js.hbs

import { COMPONENT_DID_MOUNT } from './constants';

export function componentDidMountAction() {
  return {
    type: COMPONENT_DID_MOUNT,
  };
}

reducer.js.hbs

import produce from 'immer';
import { COMPONENT_DID_MOUNT } from './constants';

export const initialState = {};

/* eslint-disable default-case, no-param-reassign */
const {{ camelCase name }}Reducer = (state = initialState, action) =>
	// eslint-disable-next-line no-unused-vars
  produce(state, draft => {
    switch (action.type) {
      case COMPONENT_DID_MOUNT:
        break;
    }
  });

export default {{ camelCase name }}Reducer;

selectors.js.hbs

import { createSelector } from 'reselect';
import { initialState } from './reducer';

const select{{ properCase name }}Domain = state => state.{{ camelCase name }} || initialState;

const makeSelect{{ properCase name }} = () => createSelector(select{{ properCase name }}Domain, subState => subState);

export default makeSelect{{ properCase name }};
export { select{{ properCase name }}Domain, makeSelect{{ properCase name }} };

loadable.js.hbs

import loadable from '../../utils/loadable';

export default loadable(() => import('./index'));

saga.js.hbs

import { take, takeLatest, put, select } from 'redux-saga/effects';
import { COMPONENT_DID_MOUNT } from './constants';

// 页面初始化
export function* componentDidMountSaga() {
  try {
    console.log('componentDidMountSaga');
  } catch (error) {
    console.log(error);
  }
}

export default function* {{ camelCase name }}Saga() {
  yield takeLatest(COMPONENT_DID_MOUNT, componentDidMountSaga)
}

mseeages.js.hbs

import { defineMessages } from 'react-intl';

export const scope = 'app.containers.{{ properCase name }}';

export default defineMessages({
  header: {
    id: `${scope}.header`,
    defaultMessage: 'This is the {{ properCase name }} container!',
  },
});

node.js.hbs

/*

import styled, { css } from 'styled-components'

const containerStyle = css`
  width: 100%;
`

const container = styled.div`${containerStyle}`

export default {

  container

}

*/

index.js.hbs

{{#if memo}}
import React, { memo, useEffect } from 'react';
{{else}}
import React from 'react';
{{/if}}
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
{{#if wantHeaders}}
import { Helmet } from 'react-helmet';
{{/if}}
{{#if wantMessages}}
import { FormattedMessage } from 'react-intl';
{{/if}}
{{#if wantActionsAndReducer}}
import { createStructuredSelector } from 'reselect';
{{/if}}
import { compose } from 'redux';

{{#if wantSaga}}
import { useInjectSaga } from 'utils/injectSaga';
{{/if}}
{{#if wantActionsAndReducer}}
import { useInjectReducer } from 'utils/injectReducer';
import makeSelect{{properCase name}} from './selectors';
import reducer from './reducer';
import { componentDidMountAction } from './actions';
{{/if}}
{{#if wantSaga}}
import saga from './saga';
{{/if}}
{{#if wantMessages}}
import messages from './messages';
{{/if}}

// import Nodes from './nodes';

export function {{ properCase name }}(props) {
  {{#if wantActionsAndReducer}}
  useInjectReducer({ key: '{{ camelCase name }}', reducer });
  {{/if}}
  {{#if wantSaga}}
  useInjectSaga({ key: '{{ camelCase name }}', saga });
  useEffect(() => {
    props.componentDidMountAction();
  }, []);
  {{/if}}

  return (
    <div>
    {{#if wantHeaders}}
      <Helmet>
        <title>{{properCase name}}</title>
        <meta name="description" content="Description of {{properCase name}}" />
      </Helmet>
    {{/if}}
    {{#if wantMessages}}
      <FormattedMessage {...messages.header} />
    {{/if}}
    </div>
  );
}

{{ properCase name }}.propTypes = {
  dispatch: PropTypes.func.isRequired,
};

{{#if wantActionsAndReducer}}
const mapStateToProps = createStructuredSelector({
  {{ camelCase name }}: makeSelect{{properCase name}}(),
});
{{/if}}

function mapDispatchToProps(dispatch) {
  return {
    dispatch,
    componentDidMountAction: () => dispatch(componentDidMountAction()),
  };
}

{{#if wantActionsAndReducer}}
const withConnect = connect(mapStateToProps, mapDispatchToProps);
{{else}}
const withConnect = connect(null, mapDispatchToProps);
{{/if}}

export default compose(
  withConnect,
{{#if memo}}
  memo,
{{/if}}
)({{ properCase name }});

组件构建

我们在generators文件下创建文件夹component,再创建index.js。构建方法与容器的相同这里不作解释。

const componentExists = require('../utils/componentExists');

module.exports = {
  description: '添加一个组件',
  prompts: [
    {
      type: 'input',
      name: 'name',
      message: '组件名是什么?',
      default: 'Button',
      validate: value => {
        if (/.+/.test(value)) {
          return componentExists(value) ? '已经存在相同的容器名或者组件名' : true;
        }

        return '组件名为必填';
      },
    },
    {
      type: 'confirm',
      name: 'memo',
      default: true,
      message: '是否要将组件包装在React.memo中?',
    },
    {
      type: 'confirm',
      name: 'wantMessages',
      default: true,
      message: '组件是否需要国际化组件?',
    },
    {
      type: 'confirm',
      name: 'wantLoadable',
      default: true,
      message: '组件是否要异步加载?',
    },
  ],
  actions: data => {
    const actions = [
      {
        type: 'add',
        path: '../../app/components/{{properCase name}}/index.js',
        templateFile: './component/index.js.hbs',
        abortOnFail: true,
      },
      {
        type: 'add',
        path: '../../app/components/{{properCase name}}/nodes.js',
        templateFile: './container/nodes.js.hbs',
        abortOnFail: true,
      },
    ];

    if (data.wantMessages) {
      actions.push({
        type: 'add',
        path: '../../app/components/{{properCase name}}/messages.js',
        templateFile: './component/messages.js.hbs',
        abortOnFail: true,
      });
    }

    if (data.wantLoadable) {
      actions.push({
        type: 'add',
        path: '../../app/components/{{properCase name}}/Loadable.js',
        templateFile: './container/loadable.js.hbs',
        abortOnFail: true,
      });
    }

    actions.push({
      type: 'prettify',
      path: '/components/',
    });

    return actions;
  },
};

如上面的代码所示,每个组件有四个文件需要创建,同样就对应4个模版:

  • loadable.js.hbs 这个文件与容器的一样所以不需要再写模版。
  • mseeages.js.hbs
  • node.js.hbs 这个文件与容器的一样所以不需要再写模版。
  • index.js.hbs

mseeages.js.hbs

import { defineMessages } from 'react-intl';

export const scope = 'app.components.{{ properCase name }}';

export default defineMessages({
  header: {
    id: `${scope}.header`,
    defaultMessage: 'This is the {{ properCase name }} component!',
  },
});

index.js.hbs

{{#if memo}}
import React, { memo } from 'react';
{{else}}
import React from 'react';
{{/if}}
// import PropTypes from 'prop-types';

{{#if wantMessages}}
import { FormattedMessage } from 'react-intl';
import messages from './messages';
{{/if}}
import Nodes from './nodes';

function {{ properCase name }}() {
  return (
    <div>
    {{#if wantMessages}}
      <FormattedMessage {...messages.header} />
    {{/if}}
    </div>
  );
}

{{ properCase name }}.defaultProps = {};

{{ properCase name }}.propTypes = {};

{{#if memo}}
export default memo({{ properCase name }});
{{else}}
export default {{ properCase name }};
{{/if}}

添加语言

在之前的/app/i18n.js文件我们对项目的国际化做了配置,那么要添加一个语言也需要在这个文件内进行操作。因为是对已有文件进行修改,所以我们需要利用正则匹配文件中的位置,然后把模版中的语句添加进去。所以根据之前i18n.js文件,确定有几个地方需要添加的,然后确定需要以下文件模版:

  • intl-locale-data.hbs

    $&const {{language}}LocaleData = require('react-intl/locale-data/{{language}}');
    
  • translation-messages.hbs

    $1const {{language}}TranslationMessages = require('./translations/{{language}}.json');
    
  • add-locale-data.hbs

    $1addLocaleData({{language}}LocaleData);
    
  • format-translation-messages.hbs

    $1  {{language}}: formatTranslationMessages('{{language}}', {{language}}TranslationMessages),
    
  • translations-json.hbs

    {}
    
  • polyfill-intl-locale.hbs

    $1        import('intl/locale-data/jsonp/{{language}}.js'),
    

首先我们在generators文件下创建文件夹language,再创建index.js

const fs = require('fs');
const { exec } = require('child_process');
// 查看语言是否已经添加
function languageIsSupported(language) {
  try {
    fs.accessSync(`app/translations/${language}.json`, fs.F_OK);
    return true;
  } catch (e) {
    return false;
  }
}

module.exports = {
  description: '添加一种语言',
  prompts: [
    {
      type: 'input',
      name: 'language',
      message: '您希望添加i18n支持的语言是什么(例如 "fr", "de")?',
      default: 'en',
      validate: value => {
        if (/.+/.test(value) && value.length === 2) {
          return languageIsSupported(value) ? `已经支持语言 "${value}" 了` : true;
        }

        return '需要2个字符的语言说明符';
      },
    },
  ],

  actions: ({ test }) => {
    const actions = [];

    if (test) {
      // 备份将被修改的文件以便我们可以还原它们
      actions.push({
        type: 'backup',
        path: '../../app',
        file: 'i18n.js',
      });

      actions.push({
        type: 'backup',
        path: '../../app',
        file: 'app.js',
      });
    }

    actions.push({
      type: 'modify', // 在指定文件中执行修改操作
      path: '../../app/i18n.js',
      // 用于匹配应替换文本的正则表达式,会在最后一个匹配的后一行写入模版内容
      pattern: /(const ..LocaleData = require\('react-intl\/locale-data\/..'\);\n)+/g,
      templateFile: './language/intl-locale-data.hbs',
    });
    actions.push({
      type: 'modify',
      path: '../../app/i18n.js',
      pattern: /(\s+'[a-z]+',\n)(?!.*\s+'[a-z]+',)/g,
      templateFile: './language/app-locale.hbs',
    });
    actions.push({
      type: 'modify',
      path: '../../app/i18n.js',
      pattern:
        /(const ..TranslationMessages = require\('\.\/translations\/..\.json'\);\n)(?!const ..TranslationMessages = require\('\.\/translations\/..\.json'\);\n)/g,
      templateFile: './language/translation-messages.hbs',
    });
    actions.push({
      type: 'modify',
      path: '../../app/i18n.js',
      pattern: /(addLocaleData\([a-z]+LocaleData\);\n)(?!.*addLocaleData\([a-z]+LocaleData\);)/g,
      templateFile: './language/add-locale-data.hbs',
    });
    actions.push({
      type: 'modify',
      path: '../../app/i18n.js',
      pattern:
        /([a-z]+:\sformatTranslationMessages\('[a-z]+',\s[a-z]+TranslationMessages\),\n)(?!.*[a-z]+:\sformatTranslationMessages\('[a-z]+',\s[a-z]+TranslationMessages\),)/g,
      templateFile: './language/format-translation-messages.hbs',
    });
    actions.push({
      type: 'add',
      path: '../../app/translations/{{language}}.json',
      templateFile: './language/translations-json.hbs',
      abortOnFail: true,
    });
    actions.push({
      type: 'modify',
      path: '../../app/app.js',
      pattern:
        /(import\('intl\/locale-data\/jsonp\/[a-z]+\.js'\),\n)(?!.*import\('intl\/locale-data\/jsonp\/[a-z]+\.js'\),)/g,
      templateFile: './language/polyfill-intl-locale.hbs',
    });

    if (!test) {
      // 代码都添加好之后执行一次国际化信息的整合
      actions.push(() => {
        const cmd = 'npm run extract-intl';
        exec(cmd, (err, result) => {
          if (err) throw err;
          process.stdout.write(result);
        });
        return 'modify translation messages';
      });
    }

    return actions;
  },
};

添加模版构建指令

"scripts": {
  "generate": "plop --plopfile internals/generators/index.js",
  "prettify": "prettier --write"
}

好了,到这里我们已经完成了模版构建,可以尝试npm run generate试试效果。

工程化总结

到这里为止,我们回顾一下工程化做了哪些工作:

  • 初始化package.json

  • 构建webpack脚手架

    • html-webpack-plugin

      生成一个 HTML5 文件, 在 body 中使用 script 标签引入你所有 webpack 生成的 bundle。

    • loader

      将匹配到的文件进行转换

    • circular-dependency-plugin

      在 webpack 打包时,检测循环依赖的模块。

    • react-app-polyfill

      包括各种浏览器的兼容。它包括Create React App项目使用的最低要求和常用语言特性。

    • webpack-dev-middleware webpack-hot-middleware

      编写自己的后端服务然后使用它,开发更灵活。

    • offline-plugin

      offline-plugin应用PWA技术,帮我们生成service-worker.js,sw的资源列表会记录我们项目资源文件。每次更新代码,通过更新sw文件版本号来通知客户端对所缓存的资源进行更新,否则就使用缓存文件。

    • terser-webpack-plugin

      使用 terser 来压缩 JavaScript。

    • compression-webpack-plugin

      对文件进行Gzip压缩,提升网络传输速率,优化web页面加载时间。

  • React全家桶

    • react-helmet

      React Helmet是一个HTML文档head管理工具,管理对文档头的所有更改。

    • react-intl

      国际化。

    • redux-saga

      使用saga来模块化处理异步业务。

    • connected-react-router

      在action中实现对路由的操作。

    • history

      在组件外跳转,用到react路由的history。

    • prop-types

      使用属性类型来记录传递给组件的属性的预期类型。运行时对props进行类型检查。

    • resclect

      Redux 的选择器库,可以提高使用redux数据的效率。

    • immer

      reducer进行包装,让state中的数据不可更改,只能通过特定方法替换。

  • antd组件库

    一个是在babel里面设置按需引入style文件,一个是antd-theme-webpack-plugin插件动态设置主题色。

  • 其他插件

    主要一个styled-components用来分离页面中的样式,实现样式的JS化。其他的插件都主要用在辅助Node.js中。

  • ESLint配置

    利用prettiereslint的配合,引入一些常用配置插件,然后配置一些ESLint的规则。然后我们又通过ESLint的Node API来自定义执行ESLint时的输出。

  • stylelint配置

    样式校验,引入styled-components的校验规则,配合当前项目。

  • babel配置

    引入了很多转换的插件,用来兼容JS语法。

  • 项目启动配置

    这里我们配置了两种环境热启动的方式,自定义了命令行传参,启动输出美化等等。

  • 编写入口文件

    • Reducer

      项目的 Reducer 初始化模块。

    • i18n国际化

      从配置到国际化组件包裹项目,最后是国际化信息的整合方法。

    • 全局样式

    • App组件即项目根组件

    • 入口文件

  • 编写npm指令

    生产环境和开发环境的几种启动方式,国际化以及ESLint校验等等。

  • 项目的组件化与模块化

    • 组件化、模块化
    • 函数式编程
    • 路由
    • 容器
    • 组件
  • 模版构建

    • 容器构建
    • 组件构建
    • 添加语言
    • 模版构建指令

说在后面

到这里,我要说的工程化已经全部说完了,不知道能看到这里的你是否对工程化有了自己的理解。其实不难看出,工程化就是把很多前端思想与理念实践于项目之中,还有很多方面的优化,让我们在开发和生产两种环境下都能够快速高效地开发。当然工程化的东西还有很多,比如:Jest测试、git操作的预检查等等。

摩天大楼不是一天盖成的,我文章中提到的所有也都是在更厉害的项目中提炼出来的,或许把里面的每一个点提出来可能都是很简单的东西,你可能会觉得这东西看一眼文档就能运用自如,但是我们缺的就是没有把它们都整合起来的能力。所以这篇文章就是给大家传递一种思想,让我们以后在自己建项的时候,能考虑到组件化、模块化等等以及多人开发的规范。

其实这次的工程化里面还少一个重要的东西,就是API请求的规范,那么留个伏笔。

最后,咱们下篇文章见!