首页 > 技术文章 > [ 前端框架/React ] React 练习之商品展示小Demo(麻雀虽小五脏俱全)

ExileRiven 2021-12-13 10:47 原文

React Demo实战

运用到的知识点:

React类、React函数、state变量、父子组件传值、兄弟组件传值、组件划分思想等

实战内容:制作一个可筛选、可用复选框控制的产品列表

练习原型图

实战步骤 Step1:划分原型图中的组成部分

可以看做五部分

  1. 最小单元:产品项如 Football $10.99
  2. 第二单元:产品类如 Sporting Goods
  3. 第三单元:产品展示表 Name Price 包含最小单元和第二单元
  4. 第三单元:搜索栏 search bar 和复选框 checkbox
  5. 第四单元:整个展示页 product list table

注:这里的产品展示表 与 搜索栏是分在同一层级的,故都属于第三单元

实战步骤 Step2:对整体思路做规划(很重要)

我们需要理解这个东西到底是什么?
没错,就是一个产品展示页面!
我们输入关键字能进行筛选,勾选复选框能够将红色的售罄物品进行过滤。

详细阐述:

1. 我们首先需要编写最小单元:

最小单元分为两块 产品名称 productName 和 产品价格 productPrice
我们可以模拟一下数据

// productItem
{
    name: 'Football',
    price: 10.99,
    stocked: true,
    id: 'football-1099'
}

因此在它的父组件中我们应该这样写:

<>
	<ProductItem productItem={productItem} />
</>
// 注:这里的 <ProductItem /> 就是我们的第一单元了
// 其中 productItem={productItem} 是指
   将(等号右边的)父组件中的名叫 productItem 的数据 以 props 的方式
   传递给我们的(等号左边的)子组件,在子组件中使用 props.productItem 访问 

而它本身需要接收父组件传来的 productItem 数据

因此我们可以初步写出 ProductItem 组件

function ProductItem(props) {
    return (
    	<> // 此处从简不新增样式
            <span class={`product-name ${props.productItem.stocked ? 'stoked' : ''}`}>
            {props.productItem.name}
            </span>
        	<span class="product-price">
            ${props.productItem.price}
            </span>
        </>
    )
}
// 注:我们可以看到在 <span></span>标签中我们使用 props.productItem 访问到了父组件传过来的数据

2. 由最小单元我们可以写出第二单元:

第二单元也分为两部分:产品类型 productType 和 产品项 productItem 也即是最小单元。
模拟一下数据:

// productGroup
{
    productType: 'Sporting Goods',
    productItems: [
        // productItem...
    ]
}

因此我们可以再推出它的父级元素写法:

<>
    <ProductGroup productGroup={productGroup} />
</>

同样,初步写出第二单元:

function ProductGroup(props) {
    return (
        <>
            <header></header>
            <ProductItem />
            <ProductItem />
        </>
    )
}

在这里我们初步写出了第二单元,我们注意到两个问题:
一个是类型需要单独拿出来;
二是我们无法预知每一组的产品到底有多少个。

所以我们需要用到循环来创建 ProductItem 组件:

// 遍历 props 中的 productGroup.productItems 数组来创建 ProductItem 组件
const rProductItems = props.productItems.map((productItem) => {
    // 我们需要获取到每一项产品的信息 所以需要遍历 productItems 这个数组,
    // 里面的每一项就是一个 productItem
    return <ProductItem productItem={productItem} />
})

这样我们就给每一组商品创建了对应的产品个体组件,得到的 rProductItems 就是这个组件组

稍微修改整合一下,得到如下代码:

function ProductGroup(props) {
    const rProductItems = props.productGroup.productItems.map((productItem) => {
        return <ProductItem productItem={productItem} />
    })
    return (
        <>
            <h3>{props.productGroup.productType}</h3>
            {rProductItems}
        </>
    )
}

看起来没什么问题了,下一个。

3. 同上所述,我们也把第三单元分为两部分 展示表头 productListTitle 和列表的内容 productList

不难看出我们的 productList 其实就是若干个 商品组 productGroup 构成的。
模拟数据:

// productList
[
    // productGroup ...
]
// 这里稍微整合一下,结合上面两个数据
// productList 是一个无名数组
[
    // productGroup // 是一个无名对象
    {
        productType: 'Sporting Goods',
        // productItems // 是一个有名数组
        productItems: [
            // productItem 是一个无名对象
            {
                name: 'Football',
                price: 10.99,
                stocked: true,
                id: football-1099
            },
            {
                name: 'Baseball',
                price: 2.99,
                stocked: true,
                id: baseball-299
            }
        ]
    }
]

看起来也相当有规模了,太激动忘了要干什么了,接下来是什么来着?
没错,依然是用父组件对这个组件进行规划:

<>
    <ProductList productList={productList} />
</>

初步拟写一下 ProductList 组件:

function ProductList(props) {
    return (
        <>
            <header></header>
            <ProductGroup />
        </>
    )
}

咦?是不是似曾相识,而且我们很容易想到 ProductGroup 也是数量不定的,所以我们同样要来遍历...
额,遍历什么?
对了,遍历 props.productList 数组,有人会问了:

productList 数组里的数据是怎么来的呢?

问得好,不过到现在才问是不是有一点点迟了呢?

大家请看我们之前编写第一单元第二单元的时候,我们考虑过这个问题吗?

没有,为什么我们没有考虑,因为这个数据我们默认它是从父组件传过来的,本组件位于第三单元,尚且不是最外层组件(不过不要因为这句话被误导了哟),所以我们可以一直追溯这个数据到最上层去,至于我们是不是要追溯到外太空呢,哈哈,我们留一个问题,稍后来解答。

好了,我们用循环来创建 ProductGroup 组件:

const rProductGruop = props.productList.map((item) => {
    // item 是 productList 里的每一项 也即是我们的 productGroup
    return (
    	<ProductGroup productGroup={item} />
    )
})

同样进行一个整理:

function ProductList(props) {
    const rProductGruop = props.productList.map((item) => {
        return (
            <ProductGroup productGroup={item} />
        )
    })
    return (
        <>
            <span class="product-list-name">Name</span>
        	<span class="product-list-price">Price</span>
            {rProductGruop}
        </>
    )
}

看起来也没有什么问题!至此我们的 Demo 已经按照原型图上写出来了下半部分的初步代码。

但是有人肯定会有疑问:

为什么咱们不先写上面的搜索框呢?

这个嘛,我也不知道,我就喜欢从下面开始做,品尝最精髓的...咳咳,再说下去我要被抓起来了。

这个问题需要大家自己去探索,去摸寻关键,我们可以选择从最大的单元开始写,也可以从上往下写,即使你用脚写,都是可行的。或喜欢、或思路畅通、或代码风格,不同的写法才有不同的思想,这是世界缤纷的原因。

扯远了,咱们接着写。

4. 接下来是搜索框和复选框:

——也是第三单元,为什么不是第一单元呢?

——同学,你的问题太多了!

它分为两个部分,搜索框 seachInput 和 复选框 stockCheckbox
其实我们可以不用分得这么细,就是一个搜索框一个复选框,但是这么分是有根据的:

搜索框:我们用来搜索相关商品的输入框,我们输入关键字,下面的 ProductList 就会根据关键字做出相应的更新;

复选框:勾选之后我们就只能看到尚有库存的商品,红色的商品(售罄)就不再显示在页面上了。

通过对比,我们发现这两个框都是对另一个第三单元组件产生影响的,而我们再看看这个 Demo 原型图,它们之间有个共同点,都是 第四单元的 子组件,要实现两个同一层级的组件进行数据交互,这时候我们首先想到的肯定是将父组件作为媒介(本例也是如此)。

我们也可以模拟一下子组件在父组件中的写法:

<>
    <SearchBar />
	<ProductList productList={productList} />
<>
// 下面这个乌漆嘛黑的家伙是谁?
// 别激动,它只是位于与 SearchBar 组件同一层级的 ProductList 组件

所以我们把搜索框和复选框的值分别作为一个 state

import React from 'react'

// 这里为什么要用 class 呢?
class SearchBar extends React.Component{
    constructor(props) {
        super(props)
        this.state = {
            searchValue: '', // 搜索关键字初始化为空
            onlyShowStocked: false  // 复选框初始化为不勾选
        }
    }
    
    render () {
        return (
            <div class="search-bar">
                <input
                    type="text"
                    class="search-bar-input"
                    placeholder="Search..."
                />
                <input
                    type="checkbox"
                    class="search-bar-checkbox"
                />
            </div>
        )
    }
}

初步写完之后,我们看到我们在名为 constructor 的函数里增加了两个变量:searchValueonlyShowStocked

——我们写这些是为了做什么?

——搜索,筛选

SearchBar 组件本身是不参与数据修改的,所以它只有把自己的要求传达给其父组件,让父组件来修改数据。

——爸爸,ProductList 组件很坏,仗着你把 数据 给他,我现在想碰一下都不行!

——好了乖乖,等会爸爸就教训他,你尽管放心,你想把 数据 改成什么样子,我抽屉里有几台 函数电脑,你把你的要求输入到函数电脑里,我等会吃完饭就去拿,保证 ProductList 这小子给你改!

——嘻嘻,谢谢爸爸!

哎,这段对话真中二啊。

这类有一个概念,叫做 函数电脑 ,其实就是父组件中的函数,子组件如果想给父组件传值,只能使用父组件中的函数,所以我们需要借用父组件的函数来传值,但是我们父组件还没有写,怎么办?

我们可以假装父组件已经有某个函数,并且已经能够实现相关的功能(当然等会还是要实现的)。

render 函数做一下完善:

render () {
    return (
        <div class="search-bar">
            <input
                type="text"
                class="search-bar-input"
                placeholder="Search..."
                value={this.state.searchValue}
                onChange={this.handleValueChange}
            />
            <input
                type="checkbox"
                class="search-bar-checkbox"
                value={this.state.onlyShowStocked}
                onChange={this.handleCheckChange}
            />
            <label>Only Show Stocked</label>
        </div>
    )
}

我们新增了数据绑定,把 SearchBar 组件里的 state 分别绑定到输入框与复选框上,并且我们给这两个框分别添加了两个函数:handleValueChangehandleCheckChange

——为什么要这四行代码呢?

——其实这得从盘古开天辟地开始说...

——停,不想说就别说!

只举一例,当搜索框里的数据发生变化的时候,我们需要及时监听响应给父组件,因此我们需要添加
onChange={this.handleValueChange} 这行代码来监听数据变化,并且在这个函数中调用父组件的函数来告知父组件我(也即是当前的子组件)已经发生了变化,数据绑定是为了让双方的数据一致。

这时候我们需要对这两个监听函数进行完善:

class SearchBar extends React.Component {
    constructor(props) {
        // ...
        // 绑定 this 有兴趣可以了解一下原理,篇幅限制不多赘述
        this.handleValueChange = this.handleValueChange.bind(this)
        this.handleCheckChange = this.handleValueChange.bind(this)
    }
    
    // 搜索框
    handleValueChange(event) {
        // 改变当前的 state
        this.setState({
            searchValue: event.target.value
        })
        // 给父组件传值,使用父组件的函数,我们假装已经写好了
        this.props.getSearchValue(event.target.value)
    }
    
    // 复选框
    handleCheckChange(event) {
        this.setState({
            onlyShowStocked: event.target.checked
        })
        this.props.getCheckedValue(event.target.checked)
    }
    
    render () {
        // ...
    }
}

至此,我们的 SearchBar 组件就已经写好了!

但是,由于我们从 props 中使用了父组件的两个函数,所以我们再来拟写一下父组件,看看有什么不同!

<>
    <SearchBar
        getSearchValue={getSearchValue}
        getCheckedValue={getCheckedValue}
    />
	<ProductList productList={productList} />
</>

很显然,与 ProductList 组件的传值不同,这一次我们在 SearchBar 组件中传入的是两个函数,而 ProductList 是一个数组,还记得吗?

5. 好了,终于到了最激动人心的时刻了,那就是我们的父组件!

我们通过 1、2、3、4 步能够总结到下面几个点:

a. 数据已经追溯到了父节点;

b. SearchBar 组件需要改变 ProductList 组件里的值;

c. 父组件需要拥有至少两个函数来获取 SearchBar 传过来的值;

d. 父组件拥有两个子组件。

话不多说,直接上代码!

// import React from 'react' // 之前已经在写其他组件时引入过
class ProductShowPage extends React.Component {
    constructor(props) {
        super(props)
        this.getSearchValue = this.getSearchValue.bind(this)
        this.getCheckedValue = this.getCheckedValue.bind(this)
        this.state = {
            searchValue: '',
            onlyShowStocked: false
        }
    }
    
    // 获取子组件中的数据
    getSearchValue = (searchValue) {
        this.setState({
            searchValue: searchValue
        })
    }
    
    getCheckedValue = (checkedValue) {
        this.setState({
            checkedValue: checkedValue
        })
    }
    
    render() {
        return (
            <>
                <SearchBar
                    getSearchValue={this.getSearchValue}
                    getCheckedValue={this.getCheckedValue}
                />
                <ProductList productList={productList} />
            </>
        )
    }
}

至此,我们上述的 c、d 就已经解决了,还有 a、b 没有得到解决。

首先是 a ...

——为什么不是b?

——我跟你说,我打字打到这里手都快断了,能不能少问点问题!

我们假设从 b 先开始,我们发现如果 SearchBar 要想改变 ProductList 的数据还是得回到 a 上面来,毕竟要改变数据必须得现拥有数据。

这里的数据就随便编造一点,写一个函数作为返回值:

apiRequest(searchValue, onlyShowStocked) {
    // 模拟前后端交互,这里给函数传入两个参数,耳熟能详
    const sourceData = [
        {
            productType: 'Sporting Goods',
            productItems: [
                {
                    name: 'Football',
                    price: 10.99,
                    stocked: false,
                    id: football-10-99
                },
                {
                    name: 'Baseball',
                    price: 2.99,
                    stocked: true,
                    id: baseball-2-99
                },
                {
                    name: 'Basketball',
                    price: 6.99,
                    stocked: true,
                    id: basketball-6-99
                }
            ]
        },
        {
            productType: 'Electronics',
            productItems: [
                {
                    name: 'ipod Touch',
                    price: 219.99,
                    stocked: true,
                    id: ipod-touch-219-99
                },
                {
                    name: 'iPhone 5',
                    price: 249.99,
                    stocked: false,
                    id: iphone-249-99
                },
                {
                    name: 'Nexus 7',
                    price: 199.99,
                    stocked: true,
                    id: nexus-7-199-99
                }
            ]
        }
    ]
    let res = []
    if (onlyShowStocked) {
        sourceData.forEach((productGroup) => {
            let productType = productGroup.productType
            let productItems = []
            productGroup.productItems.forEach((productItem) => {
                if ((productItem.name.toLowerCase().indexOf(searchValue.trim().toLowerCase()) !== -1) && productItem.stocked) {
                    productItems.push(productItem)
                }
            })
            let rProductGroup = {
                productType: productType,
                productItems: productItems
            }
            res.push(rProductGroup)
        })
    } else {
        sourceData.forEach((productGroup) => {
            let productType = productGroup.productType
            let productItems = []
            productGroup.productItems.forEach((productItem) => {
                if (productItem.name.toLowerCase().indexOf(searchValue.trim().toLowerCase()) !== -1) {
                    productItems.push(productItem)
                }
            })
            let rProductGroup = {
                productType: productType,
                productItems: productItems
            }
            res.push(rProductGroup)
        })
    }
    return res
}

详细的就不赘述了,有兴趣的可以看看。

数据就有了,我们可以添加一个 componentDidMount 函数,在组件挂载完成之后加载数据:

class ProductShowPage extends React.Component {
    constructor(props) {
        // ...
        this.state({
            productList: []
        })
    }
    
    componentDidMount() {
        this.apiRequest(this.state.searchValue, this.state.onlyShowStocked)
    }
    
    apiRequest(searchValue, onlyShowStocked) {
        // ...
    }
    
    render () {
        // ...
        <ProductList productList={this.state.productList} />
    }
}

至此,到了最后一步,在获取子组件的值函数里我们需要重新获取带有条件的数据:

//获取子组件中的数据
getSearchValue = (searchValue) => {
    this.setState({
        searchValue: searchValue
    })
    let newProductList = this.apiRequest(searchValue, this.state.onlyShowStocked)
    this.setState({
        productList: newProductList
    })
}

getCheckedValue = (checkedValue) => {
    this.setState({
        onlyShowStocked: checkedValue
    })
    let newProductList = this.apiRequest(this.state.searchValue, checkedValue)
    this.setState({
        productList: newProductList
    })
}

写至终章,祝你成功!

附上最终代码:

// js 本笔记以最终代码为准,拆解过程可能有笔误或叙述问题
import React from 'react' // 引入React
import './index.scss'     // 引入样式

// 第一单元
function ProductItem(props) {
    return (
        <>
            <span className={`product-name ${props.productItem.stocked ? '' : 'no-stocked'}`}>
                {props.productItem.name}
            </span>
            <span className="product-price">
                ${props.productItem.price}
            </span>
        </>
    )
}

// 第二单元
function ProductGroup(props) {
    const rProductItems = props.productGroup.productItems.map((productItem) => {
        return <ProductItem productItem={productItem} />
    })
    return (
        <>
            <h3>{props.productGroup.productType}</h3>
            {rProductItems}
        </>
    )
}

// 第三单元
function ProductList(props) {
    const rProductGruop = props.productList.map((item) => {
        return (
            <ProductGroup productGroup={item} />
        )
    })
    return (
        <div className="product-list">
            <span className="product-list-name">Name</span>
            <span className="product-list-price">Price</span>
            {rProductGruop}
        </div>
    )
}

// 这里为什么要用 class 呢? 第三单元
class SearchBar extends React.Component {
    constructor(props) {
        super(props)
        // 绑定 this
        this.handleValueChange = this.handleValueChange.bind(this)
        this.handleCheckChange = this.handleCheckChange.bind(this)
        this.state = {
            searchValue: '', // 搜索关键字初始化为空
            onlyShowStocked: false  // 复选框初始化为不勾选
        }
    }

    // 搜索框
    handleValueChange(event) {
        // 改变当前的 state
        this.setState({
            searchValue: event.target.value
        })
        // 给父组件传值,使用父组件的函数,我们假装已经写好了
        this.props.getSearchValue(event.target.value)
    }
    
    // 复选框
    handleCheckChange(event) {
        this.setState({
            onlyShowStocked: event.target.checked
        })
        this.props.getCheckedValue(event.target.checked)
    }

    render() {
        return (
            <div className="search-bar">
                <input
                    type="text"
                    class="search-bar-input"
                    placeholder="Search..."
                    value={this.state.searchValue}
                    onChange={this.handleValueChange}
                />
                <input
                    type="checkbox"
                    class="search-bar-checkbox"
                    value={this.state.onlyShowStocked}
                    onChange={this.handleCheckChange}
                />
                <label>Only Show Stocked</label>
            </div>
        )
    }
}

// 第四单元
// import React from 'react' // 之前已经在写其他组件时引入过
class ProductShowPage extends React.Component {
    constructor(props) {
        super(props)
        this.getSearchValue = this.getSearchValue.bind(this)
        this.getCheckedValue = this.getCheckedValue.bind(this)
        this.state = {
            searchValue: '',
            onlyShowStocked: false,
            productList: []
        }
    }

    // 挂载时就获取数据
    componentDidMount() {
        this.setState({
            productList: this.apiRequest(this.state.searchValue, this.state.onlyShowStocked)
        })
    }

    // 获取子组件中的数据
    getSearchValue = (searchValue) => {
        this.setState({
            searchValue: searchValue
        })
        let newProductList = this.apiRequest(searchValue, this.state.onlyShowStocked)
        this.setState({
            productList: newProductList
        })
    }
    
    getCheckedValue = (checkedValue) => {
        this.setState({
            onlyShowStocked: checkedValue
        })
        let newProductList = this.apiRequest(this.state.searchValue, checkedValue)
        this.setState({
            productList: newProductList
        })
    }


    // 模拟前后端交互,这里给函数传入两个参数,耳熟能详
    apiRequest(searchValue, onlyShowStocked) {
        // 模拟数据库中的数据
        const sourceData = [
            {
                productType: 'Sporting Goods',
                productItems: [
                    {
                        name: 'Football',
                        price: 10.99,
                        stocked: false,
                        id: 'football-10-99'
                    },
                    {
                        name: 'Baseball',
                        price: 2.99,
                        stocked: true,
                        id: 'baseball-2-99'
                    },
                    {
                        name: 'Basketball',
                        price: 6.99,
                        stocked: true,
                        id: 'basketball-6-99'
                    }
                ]
            },
            {
                productType: 'Electronics',
                productItems: [
                    {
                        name: 'ipod Touch',
                        price: 219.99,
                        stocked: true,
                        id: 'ipod-touch-219-99'
                    },
                    {
                        name: 'iPhone 5',
                        price: 249.99,
                        stocked: false,
                        id: 'iphone-249-99'
                    },
                    {
                        name: 'Nexus 7',
                        price: 199.99,
                        stocked: true,
                        id: 'nexus-7-199-99'
                    }
                ]
            }
        ]
        let res = []
        // 讨论复选框被选上的情况
        if (onlyShowStocked) {
            // 分别讨论搜索值是否为空(原则上我们可以纳入同一情况)
            sourceData.forEach((productGroup) => {
                let productType = productGroup.productType
                let productItems = []
                productGroup.productItems.forEach((productItem) => {
                    if ((productItem.name.toLowerCase().indexOf(searchValue.trim().toLowerCase()) !== -1) && productItem.stocked) {
                        productItems.push(productItem)
                    }
                })
                let rProductGroup = {
                    productType: productType,
                    productItems: productItems
                }
                res.push(rProductGroup)
            })
        } else {
            sourceData.forEach((productGroup) => {
                let productType = productGroup.productType
                let productItems = []
                productGroup.productItems.forEach((productItem) => {
                    if (productItem.name.toLowerCase().indexOf(searchValue.trim().toLowerCase()) !== -1) {
                        productItems.push(productItem)
                    }
                })
                let rProductGroup = {
                    productType: productType,
                    productItems: productItems
                }
                res.push(rProductGroup)
            })
        }
        return res
    }

    render() {
        return (
            <fieldset className="product-show-page">
                <legend>Product Price List</legend>
                <SearchBar
                    getSearchValue={this.getSearchValue}
                    getCheckedValue={this.getCheckedValue}
                />
                <ProductList productList={this.state.productList} />
            </fieldset>
        )
    }
}

export default ProductShowPage
// scss 样式文件
.product-show-page {
  margin: auto;
  width: 220px;
  .search-bar {
    width: 100%;
    .search-bar-input {
      width: 95%;
    }
  }

  .product-list {
    h3 {
      margin: 0 auto;
    }
  }
  .product-name, .product-price {
    display: inline-block;
    width: 50%;
  }

  .product-name {
    &.no-stocked {
      color: #F00;
    }
  }

  .product-list-name, .product-list-price {
    padding: 30px 0 10px 0;
    display: inline-block;
    width: 50%;
    font-weight: 700;
  }
}

推荐阅读