远子 💖 Vina

表单组件的几种写法

远子 â€¢  2021å¹´03月18日 â€¢ è¯„论

为什么要自定义表单组件

将复杂的业务场景组件化是前端的趋势,我日常工作中大部分时间都在编写和调用组件。

前端代码离不开表单,表单组件分为以下三类:

  1. 浏览器原生组件,如 input、textarea、select、checkbox 、radio 等;
  2. 组件库组件,如 Ant Design 提供的 tree-select 、slider等;
  3. 业务组件,这类和业务强绑定,比如:员工选择器、部门选择器、审批按钮等。

除了组件化的优点外,自定义表单组件最大的特点是使用简单,就像一个简单 input 标签一样。

下面来实现一个名为 TagPicker 的双向绑定的表单组件,用来给用户打标签,先来看一下实现效果:

TagPicker 接收一个数组 string[],数组中的每个元素都是一个标签。

  1. 点击 New Tag 时显示输入框,用户输入内容后按回车(或者失去焦点时)后创建标签,如果新建的标签已存在则跳过;
  2. 点击标签后的 x 删除标签;
  3. 支持禁用,禁用时不可删除、不可新建;

TagPicker 的使用方式非常简单,

在 Vue 中:

<tag-picker v-model="tags"></tag-picker>

在 React 中:

<tag-picker value={tags} onChange={onChange}></tag-picker>

在 Angular 中:

<tag-picker [(ngModel)]="tags"></tag-picker>

在 Svelte 中:

用 Vue2 实现

TagPicker 的完整代码如下:

<template>
  <div>
    <a-tag
      v-for="(tag, index) in innerValue"
      :key="index"
      :closable="!disabled"
      @close="remove(tag)"
    >
      {{ tag }}
    </a-tag>

    <a-input
      v-if="inputVisible"
      v-model="inputValue"
      ref="input"
      type="text"
      size="small"
      :style="{ width: '78px' }"
      @blur="confirm"
      @keyup.enter="confirm"
    />
    <a-tag v-else @click="showInput">+ New Tag</a-tag>
  </div>
</template>

<script>
const deepClone = (obj) => JSON.parse(JSON.stringify(obj));

export default {
  name: "TagPicker",
  props: {
    value: {
      type: Array,
      default: () => [],
    },
    disabled: {
      type: Boolean,
      default: false,
    },
  },
  data() {
    return {
      innerValue: [],
      inputVisible: false,
      inputValue: "",
    };
  },
  watch: {
    value: {
      immediate: true,
      handler(newVal) {
        this.innerValue = deepClone(newVal);
      },
    },
  },
  methods: {
    remove(tag) {
      this.innerValue = this.innerValue.filter((item) => item !== tag);
      this.$emit("input", this.innerValue);
    },

    showInput() {
      this.inputVisible = true;
      this.$nextTick(() => {
        this.$refs.input.focus();
      });
    },

    confirm() {
      if (this.inputValue) {
        const idx = this.innerValue.indexOf(this.inputValue);
        if (idx === -1) {
          this.innerValue.push(this.inputValue);
        }
      }
      this.inputValue = "";
      this.inputVisible = false;
      this.$emit("input", this.innerValue);
    },
  },
};
</script>

接下来在表单中使用 TagPicker:

<template>
  <div class="demo">
    <a-card title="Custom v-model Component">
      <a-form-model
        :model="formData"
        :rules="formRules"
        :label-col="{ span: 4 }"
        :wrapper-col="{ span: 14 }"
      >
        <a-form-model-item label="User Name" prop="username">
          <a-input v-model="formData.username" />
        </a-form-model-item>
        <a-form-model-item label="User Tags" prop="username">
          <tag-picker v-model="formData.tags" />
        </a-form-model-item>
        <a-row>
          <a-col :span="14" :offset="4">
            <a-button @click="reset">Reset</a-button>
            <a-button type="primary" @click="submit">Submit</a-button>
          </a-col>
        </a-row>
      </a-form-model>

      <div class="code" v-if="formDataString">
        <pre><code>{{ formDataString }}</code></pre>
      </div>
    </a-card>
  </div>
</template>

<script>
import TagPicker from "./components/TagPicker";

export default {
  name: "App",
  components: { TagPicker },
  data() {
    return {
      formData: {
        username: "rmlzy",
        tags: ["90后", "程序员"],
      },
      formRules: {
        username: [{ required: true, message: "Required!", trigger: "blur" }],
      },
      formDataString: "",
    };
  },
  methods: {
    reset() {
      this.formData = { username: "", tags: [] };
    },

    submit() {
      this.formDataString = JSON.stringify(this.formData, null, "\t");
    },
  },
};
</script>

最终预览效果如下:

用 React Hook 实现

TagPicker 的完整代码如下:

import React, { useState, useRef } from "react";
import { Tag, Input } from "antd";

export const TagPicker = ({ value = [], onChange }) => {
  const inputRef = useRef(null);
  const [inputVisible, setInputVisible] = useState(false);
  const [inputValue, setInputValue] = useState("");
  const remove = (tag) => {
    const temp = value.filter((item) => item !== tag);
    onChange(temp);
  };
  const confirm = () => {
    if (inputValue) {
      const idx = value.indexOf(inputValue);
      if (idx === -1) {
        onChange([...value, inputValue]);
      }
    }
    setInputVisible(false);
    setInputValue("");
  };
  React.useEffect(() => {
    if (inputVisible) {
      inputRef.current.focus();
    }
  });
  return (
    <React.Fragment>
      {value.map((tag) => (
        <Tag key={tag} closable onClose={() => remove(tag)}>
          {tag}
        </Tag>
      ))}
      {inputVisible ? (
        <Input
          ref={inputRef}
          type="text"
          size="small"
          style={{ width: 78 }}
          value={inputValue}
          onChange={(evt) => setInputValue(evt.target.value)}
          onBlur={confirm}
          onPressEnter={confirm}
        />
      ) : (
        <Tag onClick={() => setInputVisible(true)}>+New Tag</Tag>
      )}
    </React.Fragment>
  );
};

接下来在表单中使用 TagPicker:

import { useState } from "react";
import { Card, Form, Input, Button } from "antd";
import { TagPicker } from "./components/TagPicker";

export default function App() {
  const [form] = Form.useForm();
  const [code, setCode] = useState("");
  const initialValues = {
    name: "rmlzy",
    tags: ["90后", "程序员"]
  };
  const submit = () => {
    form
      .validateFields()
      .then((values) => {
        const _code = JSON.stringify(values, null, "\t");
        setCode(_code);
      })
      .catch((error) => {
        // pass
      });
  };
  const reset = () => {
    form.setFieldsValue({
      name: "",
      tags: []
    });
    setCode("");
  };
  return (
    <div className="demo">
      <Card title="TagPicker Demo - React Hook Version">
        <Form
          form={form}
          labelCol={{ span: 4 }}
          wrapperCol={{ span: 14 }}
          initialValues={initialValues}
        >
          <Form.Item
            label="User Name"
            name="name"
            rules={[{ required: true, message: "Required!", trigger: "blur" }]}
          >
            <Input />
          </Form.Item>
          <Form.Item label="User Tags" name="tags">
            <TagPicker></TagPicker>
          </Form.Item>
          <Form.Item wrapperCol={{ span: 14, offset: 4 }}>
            <Button onClick={reset}>Reset</Button>
            <Button type="primary" onClick={submit}>
              Submit
            </Button>
          </Form.Item>

          {code && (
            <div className="code">
              <pre>
                <code>{code}</code>
              </pre>
            </div>
          )}
        </Form>
      </Card>
    </div>
  );
}

最终预览效果如下:

用 React Class 实现

TagPicker 的完整代码如下:

import React from "react";
import { Tag, Input } from "antd";

export default class TagPicker extends React.Component {
  inputRef = React.createRef();

  constructor(props) {
    super(props);
    this.state = {
      inputVisible: false,
      inputValue: ""
    };
  }

  remove = (tag) => {
    const { value, onChange } = this.props;
    const temp = value.filter((item) => item !== tag);
    onChange(temp);
  };

  confirm = () => {
    const { value, onChange } = this.props;
    const { inputValue } = this.state;
    if (inputValue) {
      const idx = value.indexOf(inputValue);
      if (idx === -1) {
        onChange([...value, inputValue]);
      }
    }
    this.setState({
      inputVisible: false,
      inputValue: ""
    });
  };

  showInput = () => {
    this.setState({ inputVisible: true }, () => {
      this.inputRef.current.focus();
    });
  };

  render() {
    const { value } = this.props;
    const { inputVisible, inputValue } = this.state;
    return (
      <React.Fragment>
        {value.map((tag) => (
          <Tag key={tag} closable onClose={() => this.remove(tag)}>
            {tag}
          </Tag>
        ))}
        {inputVisible ? (
          <Input
            ref={this.inputRef}
            type="text"
            size="small"
            style={{ width: 78 }}
            value={inputValue}
            onChange={(evt) => this.setState({ inputValue: evt.target.value })}
            onBlur={this.confirm}
            onPressEnter={this.confirm}
          />
        ) : (
          <Tag onClick={this.showInput}>+New Tag</Tag>
        )}
      </React.Fragment>
    );
  }
}

接下来在表单中使用 TagPicker:

import React from "react";
import { Card, Form, Input, Button } from "antd";
import TagPicker from "./components/TagPicker";

export default class App extends React.Component {
  formRef = React.createRef();

  constructor(props) {
    super(props);
    this.state = {
      formData: {
        name: "rmlzy",
        tags: ["90后", "程序员"]
      },
      code: ""
    };
  }

  reset = () => {
    this.formRef.current.resetFields();
    this.setState({
      code: "",
      formData: {
        name: "",
        tags: []
      }
    });
  };

  submit = () => {
    this.formRef.current
      .validateFields()
      .then((values) => {
        const code = JSON.stringify(values, null, "\t");
        this.setState({ code });
      })
      .catch((error) => {
        // pass
      });
  };

  render() {
    const { formData, code } = this.state;
    return (
      <div className="demo">
        <Card title="TagPicker Demo - React Hook Version">
          <Form
            ref={this.formRef}
            labelCol={{ span: 4 }}
            wrapperCol={{ span: 14 }}
            initialValues={formData}
          >
            <Form.Item
              label="User Name"
              name="name"
              rules={[
                { required: true, message: "Required!", trigger: "blur" }
              ]}
            >
              <Input />
            </Form.Item>
            <Form.Item label="User Tags" name="tags">
              <TagPicker></TagPicker>
            </Form.Item>
            <Form.Item wrapperCol={{ span: 14, offset: 4 }}>
              <Button onClick={this.reset}>Reset</Button>
              <Button type="primary" onClick={this.submit}>
                Submit
              </Button>
            </Form.Item>

            {code && (
              <div className="code">
                <pre>
                  <code>{code}</code>
                </pre>
              </div>
            )}
          </Form>
        </Card>
      </div>
    );
  }
}

最终预览效果如下:

用 Angular 实现

TagPicker 的完整代码如下:

tag-picker.component.html:

<nz-tag
  *ngFor="let tag of innerValue"
  [nzMode]="disabled ? 'default' : 'closeable'"
  (nzOnClose)="remove(tag)"
>
  {{ tag }}
</nz-tag>

<nz-tag *ngIf="!disabled && !inputVisible" nzNoAnimation (click)="showInput()">
  +New Tag
</nz-tag>

<input
  #inputElement
  nz-input
  nzSize="small"
  type="text"
  style="width: 78px;"
  *ngIf="inputVisible"
  [(ngModel)]="inputValue"
  (blur)="confirm()"
  (keydown.enter)="confirm()"
/>

tag-picker.component.ts:

import {
  Component,
  ElementRef,
  ViewChild,
  forwardRef,
  Input
} from "@angular/core";
import { NG_VALUE_ACCESSOR, ControlValueAccessor } from "@angular/forms";

type ITags = string[];

@Component({
  // tslint:disable-next-line:component-selector
  selector: "tag-picker",
  templateUrl: "./tag-picker.component.html",
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => TagPickerComponent),
      multi: true
    }
  ]
})
export class TagPickerComponent implements ControlValueAccessor {
  innerValue: ITags = [];
  inputVisible = false;
  inputValue = "";
  @Input() disabled = false;
  @ViewChild("inputElement", { static: false }) inputElement?: ElementRef;

  public get value(): ITags {
    return this.innerValue;
  }

  public set value(v: ITags) {
    this.innerValue = v;
    this.onChange(v);
  }

  onChange: any = () => {};

  onTouched: any = () => {};

  writeValue(v: any) {
    this.innerValue = v;
  }

  registerOnChange(fn: any) {
    this.onChange = fn;
  }

  registerOnTouched(fn: any) {
    this.onTouched = fn;
  }

  showInput() {
    this.inputVisible = true;
    setTimeout(() => {
      this.inputElement?.nativeElement.focus();
    }, 10);
  }

  remove(tag: string) {
    this.value = this.innerValue.filter((item) => item !== tag);
  }

  confirm() {
    if (this.inputValue) {
      const idx = this.innerValue.indexOf(this.inputValue);
      if (idx === -1) {
        this.value = [...this.innerValue, this.inputValue];
      }
    }
    this.inputValue = "";
    this.inputVisible = false;
  }
}

接下来在表单中使用 TagPicker:

app.component.html:

<div class="demo">
  <nz-card nzTitle="TagPicker Demo - Angular Version" [nzLoading]="loading">
    <form nz-form>
      <nz-form-item>
        <nz-form-label [nzSpan]="4" nzRequired>User Name</nz-form-label>
        <nz-form-control [nzSpan]="14" nzErrorTip="Required!">
          <input nz-input [(ngModel)]="formData.name" name="name" required />
        </nz-form-control>
      </nz-form-item>
      <nz-form-item>
        <nz-form-label [nzSpan]="4">User Tags</nz-form-label>
        <nz-form-control [nzSpan]="14">
          <tag-picker
            [disabled]="false"
            [(ngModel)]="formData.tags"
            name="tags"
          ></tag-picker>
        </nz-form-control>
      </nz-form-item>
      <nz-form-item>
        <nz-form-control nzCol="14" nzOffset="4">
          <button nz-button (click)="reset()">Reset</button>
          <button
            nz-button
            nzType="primary"
            [nzLoading]="submitting"
            (click)="submit()"
          >
            Submit
          </button>
        </nz-form-control>
      </nz-form-item>
    </form>
    <div class="code" *ngIf="code">
      <pre><code>{{ code }}</code></pre>
    </div>
  </nz-card>
</div>
import { Component } from "@angular/core";

interface IFormData {
  name: string;
  tags?: string[];
}

@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"]
})
export class AppComponent {
  formData: IFormData = {
    name: "rmlzy",
    tags: ["90后", "程序员"]
  };
  code = "";

  reset() {
    this.formData = {
      name: "",
      tags: []
    };
  }

  submit() {
    this.code = JSON.stringify(this.formData, null, "\t");
  }
}

最终预览效果如下:

用 Svelte 实现

TODO:CodeSandbox 不给力,有时间再补。

总结

  1. Vue 的实现最简单,Vue2 中接收 value 参数,数据变更时 $emit('input', newValue) 即可;
  2. React 的实现最容易理解,接收 value,数据变更时触发 onChange;
  3. Angular 的实现最严谨,同时代码量也是最多的。

我要发表看法

«-必填
«-必填,不公开