React-聯合元件

inight發表於2018-05-14

前言

本文講的如何利用context,將多個元件串聯起來,實現一個更大的聯合元件。最具有這個特性的就是表單元件,所以本文例子就是一個表單元件。本文例子參考 Ant Design 。本次不講 context 知識,需要的話等到下一次分享。

準備

或者直接使用本文 demo Gitee地址

基本程式碼

<Form onSubmit={(e, v) => {
  console.log(e, `error`);
  console.log(v, `value`);
}}>
  <Form.Item label={`手機號`}>
    <Form.Input name={`phone`} rules={[{validator: (e) => /^1[3-9]d+$/.test(e), message: `手機號格式錯誤`}]}/>
  </Form.Item>
  <Form.Item label={`年齡`}>
    <Form.Input name={`age`} rules={[{validator: (e) => /^d+$/.test(e), message: `只允許輸入數字`}]}/>
  </Form.Item>
    <Form.Button>提交</Form.Button>
  <Form.Button type={`reset`}>重置</Form.Button>
</Form>

需求

  • 自定義校驗規則
  • 表單內容元件不限組合方式
  • 點選提交按鈕就可以提交
  • 提交時候可以校驗值並且可以自動攔截,然後將錯誤資訊下發給 FormItem 元件並且顯示出來
  • 通過傳入 Form 元件的 onSubmit 引數就可以獲取到內容

實現

明白自己所需要的內容後,我們建立基本程式碼中的幾個元件,Form , FormItem ,Input , 以及 Button。
具體內容看程式碼中的註釋

Form

首先我們要知道 Form 元件在聯合元件中的負責的內容

  • 資料收集
  • 資料校驗
  • 提交、重置動作處理

程式碼如下

import React, {Component} from `React`;
import PropTypes from `prop-types`;
import {Item} from `./Item`;
import {Button} from `./Button`;
import {Input} from `./Input`;

export class Form extends Component{
  static propTypes = {
    onSubmit: PropTypes.func.isRequired, // 需要該引數因為,如果沒有該引數,整個元件就沒有意義
    defaultValues: PropTypes.object, // 如果有些需要預設引數的,就需要該引數
    children: PropTypes.any,
  };
  
  static defaultProps = {
    defaultValues: {},
  };

  static childContextTypes = {
    form: PropTypes.any, // 定義上下文引數名稱和格式,格式太麻煩,直接any了或者 object也可以。
  };

  state = {
    validates: {},
    change: 0,
  };
  
  // 為什麼不將資料全部放在 state 裡面,在本文最後會講到
  registerState = {
    form: {},
    rules: {},
    label: {},
  };

  getChildContext() {
    // 定義上下文返回內容
    const {validates} = this.state;
    const {form} = this.registerState;
    return {
      form: {
        submit: this.submit.bind(this),
        reset: this.reset.bind(this),
        register: this.register.bind(this),
        registerLabel: this.registerLabel.bind(this),
        setFieldValue: this.setFieldValue.bind(this),
        data: form,
        validates,
      },
    };
  }

  submit() {
    // 提交動作
    const {onSubmit} = this.props;
    if (onSubmit) {
      const validates = [];
      const {form, rules, label} = this.registerState;
      Object.keys(form).forEach(key => {
        const item = form[key];
        const itemRules = rules[key];

        itemRules.forEach(rule => {
          //To do something validator 簡單列出幾種基本校驗方法,可自行新增
          let res = true;
          // 如果校驗規則裡面有基本規則時候,使用基本規則
          if (rule.hasOwnProperty(`type`)) {
            switch (rule) {
              case `phone`:
                /^1[3-9]d+$/.test(item);
                res = false;
                break;
              default:
                break;
            }
          }
          // 如果校驗規則裡面有 校驗函式時候,使用它
          if (rule.hasOwnProperty(`validator`)) {
            res = rule.validator(item);
          }
          // 校驗不通過,向校驗結果陣列裡面增加,並且結束本次校驗
          if (!res) {
            validates.push({key, message: rule.message, label: label.hasOwnProperty(key) ? label[key] : ``});
            return false;
          }
        });
      });
        
      if (validates.length > 0) {
        // 在控制檯列印出來
        validates.forEach(item => {
          console.warn(`item: ${item.label ? item.label : item.key}; message: ${item.message}`);
        });
        // 將錯誤資訊返回到 state 並且由 context 向下文傳遞內容,例如 FormItem 收集到該資訊,就可以顯示出錯誤內容和樣式
        this.setState({
          validates,
        });
      }
      // 最後觸發 onSubmit 引數,將錯誤資訊和資料返回
      onSubmit(validates, this.registerState.form);
    }
  }

  reset() {
    // 重置表單內容
    const {form} = this.registerState;
    const {defaultValues} = this.props;
    this.registerState.form = Object.keys(form).reduce((t, c) => {
      t[c] = defaultValues.hasOwnProperty(c) ? defaultValues[c] : ``;
      return t;
    }, {});
    // 因為值不在 state 中,需要重新整理一下state,完成值在 context 中的更新
    this.change();
  }
  
  //更新某一個值
  setFieldValue(name, value) {
    this.registerState.form[name] = value;
    this.change();
  }

  // 值和規則都不在state中,需要藉助次方法更新內容
  change() {
    this.setState({
      change: this.state.change + 1,
    });
  }
  
  // 註冊引數,最後資料收集和規則校驗都是通過該方法向裡面新增的內容完成
  register(name, itemRules) {
    if (this.registerFields.indexOf(name) === -1) {
      this.registerFields.push(name);
      const {defaultValues} = this.props;
      this.registerState.form[name] = defaultValues.hasOwnProperty(name) ? defaultValues[name] : ``;
      this.registerState.rules[name] = itemRules;
    } else {
      // 重複的話提示錯誤
      console.warn(``${name}` has repeat`);
    }
  }
  
  // 新增 欄位名稱,優化體驗
  registerLabel(name, label) {
    this.registerState.label[name] = label;
  }

  render() {
    return (
      <div className="form">
        {this.props.children}
      </div>
    ); // 這裡使用括號因為在 webStrom 下格式化程式碼後的格式看起來更舒服。
  }
}

// 將子元件加入到 Form 中 表示關聯關係
Form.Item = Item;
Form.Button = Button;
Form.Input = Input;

FormItem

它的功能不多

  • 向 Form 中註冊 輸入框的關聯名稱
  • 從 Form 中獲取 校驗結果並且展示出來

程式碼如下

import React, {Component} from `react`;
import PropTypes from `prop-types`;

export class Item extends Component {
  // 這個值在 FormItem 元件 被包裹在 Form 元件中時,必須有
  name;

  static propTypes = {
    label: PropTypes.string,
  };

  static childContextTypes = {
    formItem: PropTypes.any,
    children: PropTypes.any,
  };

  static contextTypes = {
    form: PropTypes.object,
  };

  // 防止重複覆蓋 name 的值
  lock = false;

  // 獲取到 包裹的輸入元件的 name值,如果在存在 Form 中,則向 Form 註冊name值相對的label值
  setName(name) {
    if (!this.lock) {
      this.lock = true;
      this.name = name;

      const {form} = this.context;
      if (form) {
        form.registerLabel(name, this.props.label);
      }
    } else {
      // 同樣,一個 FormItem 只允許操作一個值
      console.warn(`Allows only once `setName``);
    }

  }

  getChildContext() {
    return {
      formItem: {
        setName: this.setName.bind(this),
      },
    };
  }

  render() {
    const {label} = this.props;
    const {form} = this.context;

    let className = `form-item`;
    let help = false;
    if (form) {
      const error = form.validates.find(err => err.key === this.name);
      // 如果有找到屬於自己錯誤,就修改狀態
      if (error) {
        className += ` form-item-warning`;
        help = error.message;
        return false;
      }
    }

    return (
        <div className={className}>
          <div className="label">
            {label}
          </div>
          <div className="input">
            {this.props.children}
          </div>
          {help ? (
              <div className="help">
                {help}
              </div>
          ) : ``}
        </div>
    );
  }
}

Input

暫時演示輸入元件為 Input ,後面可以按照該元件內容,繼續增加其他操作元件
該型別元件負責的東西很多

  • 唯一name,通知 FormItem 它所包裹的是誰
  • Form 元件裡面,收集的資料
  • 校驗規則

程式碼如下

import React, {Component} from `react`;
import PropTypes from `prop-types`;

export class Input extends Component {
  constructor(props, context) {
    super(props);
    // 如果在 Form 中,或者在 FormItem 中,name值為必填
    if ((context.form || context.formItem) && !props.name) {
      throw new Error(`You should set the `name` props`);
    }
    // 如果在 Form 中,不在 FormItem 中,提示一下,不在 FormItem 中不影響最後的值
    if (context.form && !context.formItem) {
      console.warn(`Maybe used `Input` in `FormItem` can be better`);
    }

    // 在 FormItem 中,就要通知它自己是誰
    if (context.formItem) {
      context.formItem.setName(props.name);
    }
    // 在 Form 中,就向 Form 註冊自己的 name 和 校驗規則
    if (context.form) {
      context.form.register(props.name, props.rules);
    }
  }

  shouldComponentUpdate(nextProps) {
    const {form} = this.context;
    const {name} = this.props;
    // 當 有 onChange 事件 或者外部使用元件,強行更改了 Input 值,就需要通知 Form 更新值
    if (form && this.changeLock && form.data[name] !== nextProps.value) {
      form.setFieldValue(name, nextProps.value);
      return false;
    }
    return true;
  }

  static propTypes = {
    name: PropTypes.string,
    value: PropTypes.string,
    onChange: PropTypes.func,
    rules: PropTypes.arrayOf(PropTypes.shape({
      type: PropTypes.oneOf([`phone`]),
      validator: PropTypes.func,
      message: PropTypes.string.isRequired,
    })),
    type: PropTypes.oneOf([`text`, `tel`, `number`, `color`, `date`]),
  };

  static defaultProps = {
    value: ``,
    rules: [],
  };

  static contextTypes = {
    form: PropTypes.object,
    formItem: PropTypes.object,
  };

  onChange(e) {
    const val = e.currentTarget.value;
    const {onChange, name} = this.props;
    const {form} = this.context;
    if (onChange) {
      this.changeLock = true;
      onChange(val);
    } else {
      if (form) {
        form.setFieldValue(name, val);
      }
    }
  }

  render() {
    let {value, name, type} = this.props;
    const {form} = this.context;
    if (form) {
      value = form.data[name] || ``;
    }
    return (
        <input onChange={this.onChange.bind(this)} type={type} value={value}/>
    );
  }
}

Button

負責內容很簡單

  • 提交,觸發 submit
  • 重置,觸發 reset

程式碼如下

import React, {Component} from `react`;
import PropTypes from `prop-types`;

export class Button extends Component {

  componentWillMount() {
    const {form} = this.context;
    // 該元件只能用於 Form
    if (!form) {
      throw new Error(`You should used `FormButton` in the `Form``);
    }
  }

  static propTypes = {
    children: PropTypes.any,
    type: PropTypes.oneOf([`submit`, `reset`]),
  };

  static defaultProps = {
    type: `submit`,
  };

  static contextTypes = {
    form: PropTypes.any,
  };

  onClick() {
    const {form} = this.context;
    const {type} = this.props;
    if (type === `reset`) {
      form.reset();
    } else {
      form.submit();
    }
  }

  render() {
    return (
        <button onClick={this.onClick.bind(this)} className={`form-button`}>
          {this.props.children}
        </button>
    );
  }
}

後言

首先先講明為何 不將label 和資料不放在state 裡面因為多個元件同時註冊時候,state更新來不及,會導致部分值初始化不成功,所以最後將值收集在 另外的 object 裡面,並且是直接賦值
看了上面幾個元件的程式碼,應該有所明確,這些元件組合起來使用就是一個大的元件。同時又可以單獨使用,知道該如何使用後,又可以按照規則,更新整個各個元件,而不會說,一個巨大無比的單獨元件,無法拆分,累贅又複雜。通過聯合元件,可以達成很多奇妙的組合方式。上文的例子中,如果沒有 Form 元件, 單獨的 FormInput 加 Input,這兩個組合起來,也可以是一個單獨的驗證器。

相關文章