最近这段时间新型冠状病毒肆虐,上海确诊人数每天都在增加,人人提心吊胆,街上都没人了。为了响应国家号召,近期呆在家里撸码,着手将项目迁移到React中,项目比较朴素,是一张线索提交页面,包含表单、图片滚动等功能。
一、目录结构
项目基于Create React App构建而成,简单的做了下二次封装,src目录的结构如下所示。
├── src │ ├── __tests__ ---------------------- 测试文件 │ ├── common ------------------------- 通用功能 │ ├── component ---------------------- 组件 │ ├── img ---------------------------- 图片 │ ├── page --------------------------- 页面 │ ├── router ------------------------- 路由 │ ├── store -------------------------- 状态容器 │ ├── index.scss --------------------- 公共样式 │ ├── index.js ----------------------- 入口文件
在index.js中会引入公共样式、路由、统计脚本、通用功能等。index.scss集合的公共样式包括重置、布局、字体、间距等。common中的通用功能包括通信、加载第三方脚本、微信配置等。
二、组件
在本项目中组件都以函数的形式出现,并且其最小的粒度是控件,也就是文本框(Input)、选择框(Select)、复选框(Checkbox)和单选框(Radio),然后是表单(Form)和三级联动(Chain),其目录如下所示,一定会包含index两个文件。
├── component │ ├── Input -------------------------- 控件 │ │ ├── index.scss ----------------- 样式 │ │ ├── index.js ------------------- 脚本
在Form组件中,会包含其它组件,并且会传递相关数据给它们。
1)文本框
一般会将文本框的type、placeholder和name传进来,当没有属性时,就直接返回一个只包含基本样式的文本框。在Input组件中,通过useState()钩子初始化状态,如下所示。
export function Input(props) { if (!props) return ; //空的文本框 const [value, setValue] = useState(props.value || ""); const inputProps = { type: props.type, placeholder: props.placeholder, name: props.name }; return ; }
在Form组件中可传递的数据如下所示。
const txt = { type: "text", placeholder: "姓名", name: "name" };
2)单选框和复选框
单选框和复选框接收的props是一个数组,而不是对象,在Form组件中可传递的数据如下所示,其中defaultChecked属性用于设置默认的选中项。
const radios = [ {type:"radio", name:"gender", value:1, text:"man"}, {type:"radio", name:"gender", value:2, text:"woman", defaultChecked:true} ]; const checkboxs = [ {type:"checkbox", name:"color", value:1, text:"红"}, {type:"checkbox", name:"color", value:2, text:"绿", defaultChecked:true}, {type:"checkbox", name:"color", value:3, text:"蓝", defaultChecked:true} ];
在Radio组件中,选中项保存在checked变量中,通过ES6新增的find()方法获取,如下所示。组件为每个单选框注册了Change事件,事件处理程序中调用的callback()方法将在后文讲解。
export function Radio(props) { if (!props) return null; const radios = props.items || [], checked = radios.find(item => item.defaultChecked) || {}, [value, setValue] = useState(checked.value || ""); function handle(e) { props.callback(e.target.value); } return radios.map(item => ( )); }
在Checkbox组件中,选中项可以是多个,保存在checkeds数组中,通过filter()和map()获取。同样为每个复选框都注册了一个Change事件,在事件处理程序中会过滤掉当前值,当选中时,再添加到选中数组中,如下所示。
export function Checkbox(props = []) { if (props.length == 0) return null; const checkboxs = props.items || [], checkeds = checkboxs .filter(item => item.defaultChecked) .map(item => item.value), [values, setValues] = useState(checkeds); function handle(e) { const { checked, value } = e.target; const current = values.filter(item => item != value); checked && current.push(value); props.callback(current); } return checkboxs.map(item => ( )); }
3)快照测试
在做快照测试时,需要传递事件对象,为简便起见,直接用一个普通对象模拟它,如下所示。
it("Radio-Snapshot", () => { const radios = [ { type: "radio", name: "gender", value: 1, text: "man" }, { type: "radio", name: "gender", value: 2, text: "woman", defaultChecked: true } ]; const component = renderer.create(); let tree = component.toJSON(); expect(tree).toMatchSnapshot(); //模拟Change事件 const event = { target: { value: 1 } }; act(() => { tree[0].children[0].props.onChange(event); }); tree = component.toJSON(); });
注意,为了测试时更接近React在浏览器中的工作方式,需要在act()方法中运行组件。
三、表单
1)通信
在表单中需要搜集控件的值,此时会涉及父子之间的通信。如果要在父组件中读取子组件的状态,那么有两种方式实现。
第一种是通过事件对象,也就是在表单的Submit事件中读取事件对象的target属性,如下所示。
function submit(e) { console.log(e.target.name.value); //读取控件值 e.preventDefault(); } return ();
第二种是通过回调函数,也就是在表单中传递一个函数到组件内。如果一个个传递,维护成本会巨大,而以高阶组件的方式,会简洁许多,如下所示。
//高阶组件 function HOC(Wrapped, data) { //子组件向父组件传递信息 function callback(value) { data.currentValue = value; } function Enhanced(props) { return; } return Enhanced; } const InputHOC = HOC(Input, txt), RadioHOC = HOC(Radio, radios), CheckboxHOC = HOC(Checkbox, checkboxs);
2)验证
在控件中读取的值都得经过验证后,才能提交到服务器中。目前的做法比较粗暴,封装性和扩展性都不友好,将验证逻辑直接放置在Form组件中,如下所示,包含三组验证规则。
const methods = { required: function(value) { //必填 return value.length > 0 || false; }, name: function(value) { //姓名 return /^[\u4e00-\u9fa5]+$/.test(value); }, mobile: function(value) { //手机号码验证 return /^1[0-9]{10}$/.test(value); }, checked: function(value = "", data) { if (!data.rules || !data.messages) { return true; } const rules = data.rules.split("|"), messages = data.messages.split("|"), length = rules.length; let i = 0; while (i < length) { if (this[rules[i]](value)) { i++; continue; } alert(messages[i]); return false; } return true; } };
需要验证的渲染数据会多两个属性:rules和messages,以竖线分隔,如下所示。
const txt = { type: "text", placeholder: "姓名", name: "name", rules: "required|name", messages: "请输入姓名|请输入正确的姓名" };
3)Redux
本来还尝试使用Redux来处理组件的状态,但还没有完全理解其精髓,在实际操作时有点力不从心。例如在Store.js容器文件中只是想处理状态,但是得引入要关联的Input组件(如下所示),这与我的初衷有偏差。
import React from "react"; import { createStore } from "redux"; import { connect, Provider } from "react-redux"; import { Input } from "../component/Input/index"; //Actions function addName(name) { return { type: "ADD_NAME", name }; } //Reducers function caculate(previousState, action) { let state = Object.assign({}, previousState); switch (action.type) { case "ADD_NAME": state.name = action.name; break; } return state; } const store = createStore(caculate); function mapStateToProps(state) { return state; } const SmartInput = connect(mapStateToProps, { addName })(Input); const Store = (); export default Store;
Input组件也需要修改,在Change事件中回调传递过来的addName()方法,触发Redux更新状态。
export function Input(props) { function handle(e) { props.addName(e.target.value); } return ; }
保存的状态会以Context方式传递,在接收时需要通过ReactReduxContext.Consumer读取。
import { ReactReduxContext } from 'react-redux'; render() { return ({({ store }) => { // do something with the store here }} ) }
在Form组件中可以像下面这样引用容器,但其实我只是想在当前读取到子组件的状态,目前的逻辑并没有解决该问题,有待修改。
import Store from '../../store/index'; function Form() { return (); }
四、Swiper
Swiper是流行的触摸滑动插件,但没有找到官方的React版本。如果使用其它相关的React插件,需要增加集成的时间成本,并且还有未知BUG,可能影响上线时间,因此还是决定使用Swiper,进行二次封装,组件目录如下。
├── Swiper │ ├── img ---------------------------- 图片 │ ├── index.js ----------------------- 脚本 │ ├── index.scss --------------------- 样式 │ ├── swiper.js ---------------------- 插件脚本 │ ├── swiper.scss -------------------- 插件样式
在组件中引入了useEffect()钩子,并且将图像作为资源引入,如下代码所示。useEffect()会在componentDidMount()和componentDidUpdate()触发,此时DOM结构中已包含Swiper容器,可以将其初始化。
import React, { useEffect } from "react"; import "./index.scss"; import Swiper from "./swiper"; import img1 from "./img/1.png"; import img2 from "./img/2.png"; import img3 from "./img/3.png"; export default function ReactSwiper(props) { useEffect(() => { new Swiper("#slide", { loop: true }); }); return (); }
五、三级联动
省市经销商三级联动是本项目的一个特殊业务,类似于常规的省市区三级联动。三级联动的数据来源于一个静态文件,数据格式如下所示,保存在shops.js中。
export let poi = [ { name: "北京", citys: [ { name: "北京", dealer: [ { dealerName: "北京汽车销售有限公司" }, { dealerName: "北京商贸有限公司" } ] }] }];
Chain组件中的状态是一个对象,与之前不同,在调用更新函数时,不能直接传初始化的变量,得改用对象解构复制一个对象,再传这个副本,否则无法触发组件的渲染,如下所示,省略了部分逻辑代码和两个选择框。
import React, {useState} from 'react'; import './index.scss'; import {poi} from './shops'; export default function Chain(props) { let initData = { provinces: [], province: "", cities: [], shops: [], poiHash: {} }; initData.provinces = setOption(poi); poi.forEach(value => { initData.poiHash[value.proName] = value.citys; }); const [data, setData] = useState(initData); function clearOptions(name) { data[name] = []; setData({ ...data }); } function options(name, list) { clearOptions(name); data[name] = setOption(list); setData({ ...data }); //对象解构 //setData(data); //错误 无法触发渲染 } function provinceChange(e) { var val = e.target.value; data.province = val; options("cities", data.poiHash[val]); clearOptions("shops"); } return ( <> ...... > ); }