React中列表渲染

发布于 大漠

在上一节中,我们学习了如何在React中实现条件渲染。今天我们一起来学习如何在React中实现列表渲染(在Vue中我们可以使用v-for指令)。因为在实际开发中我们时常要处理一些列表的渲染,比如在《列表渲染和Vue的v-for指令》一文中示例:

上图中我们有多个列表的渲染,比如Tweets列表,关注用户列表等。

在React中,处理列表渲染和处理条件渲染类似,需要借助JavaScript的一些原生能力来辅助我们实现列表渲染。如果你对这方面知识感兴趣的话,欢迎继续往下阅读。

回顾JavaScript中的循环

在开始学习React列表渲染之前,我们很有必要先简单的回顾JavaScript中有关于循环相关的知识。原因很简单:

React中的列表渲染用的就是JavaScript原生的循环知识

简单地说,循环就是重复做一件事件。在MDN中有一张图形象的阐述了这个概念

上图是位农夫为他的家庭准备一周的食物计划。为了完成这个计划,他或许需要执行一个循环。一个循环通常会需要一些特定的条件:

  • 一个开始条件:这是循环的起点(比如上图中的“没有食材了”)。用到代码中,它被初始化为一个特定的值(常称初始条件
  • 一个结束条件:这是循环的结束点(比如上图中的“有足够的食材吗?”)。用到代码中,它就是循环的停止标准(常称结束条件),通常计数器达到一定的值(假设,该农夫整个家庭一周有十种食物就可以满足)
  • 一个迭代器:这通常在每个连续循环上递增(或递减)一个计数器,直到达到结束条件。比如,农夫能够每小时收集到两份食物,每小时后,他收集的食物增量就增加了两倍,他检查他是否有足够的食物。如果达到了十份食物(退出条件),该农夫就可以停止收集食物

如果我们用伪代码来描述上图中农夫准备食物这样的场景,可能会像下面这样:

loop(food=0; foodNeeded=10){
    // 农夫目前的食物为0 ~> 初始条件 food=0
    // 农夫一周需要的食物定量是10 ~> 结束条件 fooNeeded=10

    // 如果农夫当前食物和所需食物数量相等 ~> food = foodNeeded
    if (food = foodNeeded) {
        exit loop; // ~> 退出循环
        // 农夫有足够的食物了,停止收集食物
    } else {
        food +=2; // 每一个小时,农夫可以收集到两件食物 ~> 循环迭代计数器 (递增或递减)
        // 循环将继续执行
    }
}

如果我们用JavaScript来实现上述这样的场景,就可以使用循环语句来处理。关键是JavaScript中循环有很多种类型,但它们的本质是做同样的一件事情:

把一个动作重复了很多次(也有可能重复的次数是0

JavaScript中各种循环机制提供了不同的方法去确定循环的开始和结束。不同情况下,某一种类型的循环会比其他的循环用起来更简单。

在JavaScript中,最基础的、最原始的循环语句是for语句do...while语句和while语句。如果用图来描述的话,大致如下:

while语句

while语句只要指定**条件求值为真(true)**就会一直执行它的语句块。比如下面这样的示例:

var n = 0;
var x = 0;
while (n < 3) {
    n++;
    x += n;
    console.log(x)
}

在每次循环里,n会增加1,并被加到x上。所以,xn的变化是:

  • 第一次完成后:n=1x=1
  • 第二次完成后:n=2x=3
  • 第三次完成后:n=3x=6

在三次完成后,条件n<3的结果不再为真,所以循环终止了。

for语句

for循环会一直重复执行,直到指定的循环条件为false。当一个for循环执行的时候,会发生以下过程:

  • 如果有初始化表达式initialExpression,它将被执行。这个表达式通常会初始化一个或多个循环计数器,但语法上是允许一个任意复杂度的表达式的。这个表达式可以声明变量
  • 计算condition表达式的值。如果condition的值是true,循环中的语句会被执行。如果condition的值是falsefor循环终止。如果condition表达式整个都被省略掉了,condition的值会被认为是true
  • 循环中的statement被执行。如果需要执行多条语句,可以使用块({...})来包裹这些语句
  • 如果有更新表达式incrementExpression,执行更新表达式
  • 回到第2步,重新执行

比如下面这样的小示例:

for (i = 0, len = 3; i < len; i++) {
    console.log(i)
}

整个for循环执行过程是这样:

  • 第一次完成后,i=0
  • 第二次完成后,i=1
  • 第三次完成后,i=2

i<len(即可i<3)时,条件不再为真,循环会终止。

do...while语句

do...while语句一直重复直到指定的条件求值得到假值(false)。代码声明块(statement)在检查条件之前会执行一次(至少会执行一次)。要执行多条件语句(语句块),要使用块语句({...})包裹起来。如果条件(condition)为值(true),statement将再次执行。在每个执行的结束会进行条件的检查。当condition为假(false),执行会停止并且把控制权交回给do...while后面的语句。

比如下面的小示例:

var i = 0;
do {
    i += 1;
    console.log(i);
} while (i < 3);

这个do...while循环将至少重复一次,并且一直重复直到i不再小于3,循环停止。

这些是JavaScript中最基础的知识,如果你对这方面感兴趣的话,建议花点时间阅读下面几篇文章:

其他迭代和遍历的方法

而我们在处理数据(根据数据渲染列表)一般都是对数组或对象这样的JSON数据做遍历处理。比如我们要渲染下图中“你可能会喜欢”的列表区块:

服务端可能提供的JSON数据会是像下面这样:

const followData = [
    {
        id: '940202913393082373',
        avatar: 'https://pbs.twimg.com/profile_images/940202913393082373/23X8DJQc_x96.jpg',
        nick: 'Bloomberg TicToc',
        account: '@tictoc',
        isFollowed: true,
        certification: true,
        recommended: true
    },
    {
        id: '1021778918842531840',
        avatar: 'https://pbs.twimg.com/profile_images/1021778918842531840/FBVP_uDf_x96.jpg',
        nick: 'Netlify',
        account: '@Netlify',
        isFollowed: false,
        certification: false,
        recommended: false
    },
    {
        id: '1070861268246978560',
        avatar: 'https://pbs.twimg.com/profile_images/1070861268246978560/ObS2CG3t_x96.jpg',
        nick: 'npm,Inc.',
        account: '@npmjs',
        isFollowed: true,
        certification: true,
        recommended: false
    }
]

也就是说,我们需要对上面的数组(当然,有的时候也有可能是一个对象)进行遍历。但是在JavaScript中,针对数组和对象进行遍历采用的方法也有所不同,有关于这方面的介绍可以阅读:

如果你了解JavaScript中数组或对象遍历方法或者阅读了上面相关的文章,你会发现在现代的JavaScript中有更多的了遍历方法。比如:forEach()map()every()some()filter()find()findIndex()keys()values()entries()reduce()reduceRight()等。其中有些是ES5中的,也有一些是ES6中的方法;有些是用于数组的遍历,也有一些是用于对象的遍历。

事实上,不管是处理一个数组还是一个对象,我们想要的是拿到相应的字段。很多时候我们可以将ObjectArray的一些方法结合起来使用达到自己所需要的目的。比如下面这样的小示例:

const profile = {
    id: '1021778918842531840',
    avatar: 'https://pbs.twimg.com/profile_images/1021778918842531840/FBVP_uDf_x96.jpg',
    nick: 'Netlify',
    account: '@Netlify',
    isFollowed: false,
    certification: false,
    recommended: false
}

通过Object.keys().values()可以拿到对象的keyvalue,并且输出的是相应的数组:

如果配合Array相应的遍历方法,就拿forEach()来说,可以将值一个一个输出,比如:

虽然在JavaScript中用于遍历数据集的各种技术和方法有很多,但比较流行的方法之一是.map()方法。而且.map()方法是一个非变异的方法,因为它创建了一个新的数组,而不是只改变调用数组的方法。在React的列表渲染中也经常会用到.map()方法。这里简单的来看看.map()方法的使用。

JavaScript的.map()方法接受一个回调函数作为其参数之一,该函数的一个重要参数是该函数正在处理的项的当前值,这也是一个必要的参数。比如上面的所列的followData数组,如果我们想取出该数组中每一项(每一项是一个Object)的account字段,我们可以这样写:

const getAccount = followData.map((item) => {
    console.log(item.account)
})

如果把.map()取到的每项值丢到DOM标签内,就可以生成我们想要的列表,稍后会用到。

.map()方法除了可以处理数组之外,还可以用于迭代数组中的对象。同样拿followData数组来举例:

const followInfo = followData.map(item =>{
    const nickObj = {}

    nickObj[item.account] = item.nick

    console.log(nickObj)
})

有关于.map()更多的介绍,还可以阅读下面相关文章:

React列表渲染

前面提到过,React中的列表渲染和其他的一些流行框架(比如Vue)做法有些不同。比如在Vue框架中有自已的指令v-for可以在HTML中直接迭代,但在React中,只能使用原生JavaScript方法来进行迭代。接下来我们从不同的角度来了解和学习React的列表渲染相关的知识点。

创建一个简单地静态列表

我们先从一个简单地列表开始,比如像下面这样的一个菜单列表:

如果你和我一样是位React的初学者的话,要在React中构建这样的菜单列表,可能想到的就是一个静态列表。可能会把一坨HTML的代码和JSX结合起来:

const Menu = () => {
    return (
        <ul className="nav">
            <li><a href="#">主页</a></li>
            <li><a href="#">探索</a></li>
            <li><a href="#">通知</a></li>
            <li><a href="#">私信</a></li>
            <li><a href="#">书签</a></li>
            <li><a href="#">列表</a></li>
            <li><a href="#">个人资料</a></li>
            <li><a href="#">更多</a></li>
        </ul>
    )
}

添加一些样式的话,看到的效果大致如下:

动态创建列表

上面的方式虽然能渲染出列表,但这样除了没有用好React框架的优势之外还让代码难于维护。前面我们也提到过,在React中我们可以使用JavaScript的循环迭代能力,轻松的实现列表的渲染。另外我们拿到的数据一般都会是一个对象或数组。比如上面的菜单列表,如果我们把所有的菜单项放到一起就是可以是一个数组,比如:

const menuData = ['主页', '探索', '通知', '私信', '书签', '列表', '个人资料', '更多']

这样我们就可以通过.map()方法来简化上面的静态列表的代码:

const Menu = () => {
    return (
        <ul className="nav">
            {menuData.map(menu => (
                <li><a href="#">{menu}</a></li>
            ))}
        </ul>
    )
}

得到的效果是一样的:

事实上,上面的示例仅仅是在静态列表上显得更优雅一点,但如果Menu组件要运用到另一个组件中(Menu组件中的menuData有可能会随着组件的运用场景而变化),其局限性就显现出来了。不过不用着急,我们可以借用props来对上面的组件做一个改造:

const menuData = ['主页', '探索', '通知', '私信', '书签', '列表', '个人资料', '更多']

const Menu = ({lists}) => {
    const listsItem = lists.map(item => <li><a href="#">{item}</a></li>)
    return (
        <ul className="nav">{listsItem}</ul>
    )
}

const rootElement = document.getElementById("app");
ReactDOM.render(<Menu  lists={menuData} />, rootElement);

最终效果将会是一样的

值得注意的是,在React中渲染列表时如果未显式的给每个列表项设置key属性时,在浏览器上会有一个警告信息:

有关于React列表渲染时为什么要显式给列表项设置key属性更详细的介绍,我们稍后会做相关的讨论。

如何在React中渲染对象列表

前面的示例我们看到的数据源都是一个数组,但很多时候我们这个数组是一个对象的集合。比如前面提到的followData,它就是一个含有对象的数组集合。在React中对于JavaScript数组中的复杂对象,和前面我们看到的示例没有什么差异,同样可以使用.map()方法来渲染列表。比如下面这个示例:

const Follow = ({lists}) => {
    const listsItem = lists.map(item => (
        <div className="media" key={item.id}>
            <div className="media__object">
                <img src={item.avatar} alt={item.nick} />
            </div>
            <div className="media__body">
                <h5>{item.nick} {item.certification && <span className="certification">认证</span>}</h5>
                <div className="account">{item.account}</div>
                {item.recommended && <div className="recommended">推荐</div>}
            </div>
            {!item.isFollowed && <button className="button">关注</button>}
        </div>
    ))

    return <div className="card">{listsItem}</div>
}

const rootElement = document.getElementById("app");
ReactDOM.render(<Follow  lists={followData} />, rootElement);

效果如下:

该示例实现的迭代遍历方法和前面的没有太大的差异,使用的还是.map()方法。其实JavaScript中其他迭代遍历方式方法都有效,只不过.map()在React中列表渲染使用的是最多的方法。另外上面的示例中,如果你足够仔细的话,我们在做一些条件判断的时候,运用到了上篇文章中介绍到的相关方案。这样做主要目的之一是让代码足够简洁,这也符合了我们前面有关于JSX方面中学习到的知识,即编写出更为优雅的JSX代码

React中嵌套列表渲染

上面示例看到的效果都是单维的列表渲染,有的时候我们会需要处理一些嵌套列表的渲染(比如二维列表)。在React中渲染嵌套列表仍然可以使用以前的实现技术。比如我们实现一个下拉菜单的,该菜单的数据可能是这样:

const listsData = [
    {
        title: 'Dashboard',
        items: ['Tools', 'Reports', 'Analytics', 'Code Blocks']
    },
    {
        title: 'Sales',
        items: ['New Sales', 'Expired Sales', 'Sales Reports', 'Deliveries']
    },
    {
        title: ' Messages',
        items: ['Inbox', 'Outbox', 'Sent', 'Archived']
    }
]

同样使用.map()方法来遍历,只不过为了实现二维列表的渲染,可能会多次采用.map()方法,比如:

const Menu = ({lists}) => {
    return (
        <ul className="nav">
            {
                lists.map((list, index) => (
                    <li key={index}>
                        <input id={list.title} name="menu" type="radio" />
                        <label htmlFor={list.title}>
                            <span className="nav__title">{list.title}</span>
                            <ul className="nav__sub">
                                {
                                    list.items.map((item, index) => (
                                        <li key={index}>{item}</li>
                                    ))
                                }
                            </ul>
                        </label>
                    </li>
                ))
            }
        </ul>
    )
}

const rootElement = document.getElementById("app");
ReactDOM.render(<Menu lists={listsData} />, rootElement);

效果如下:

只不过这样做,会让应用程序变得更复杂,可维护性的负担也会变大。这就是为什么在《如何编写出优雅的JSX》建议我们尽可能的把逻辑抽得简单化原因之一。

如何在React中处理重复元素中的children

在React中构建组件时,同样也会涉及到父子组件的关系。在渲染重复元素时我们有什么方式能更好的处理子组件呢?接下来我们用一个时钟组件Clock来举例,和大家一起探讨和学习这方面的知识。

假设我们想要构建的Clock组件,可以灵活的设置时钟的样式(24小时时钟或12小时时钟),不同的分隔符,任意自己想要的时钟参数(小时,分钟,秒)。为了有一个更具灵活性的Clock组件,我们可以将其分解成多个组件,比如Hour(小时)、Minute(分钟)、Second(秒)、Separator(分隔符)和Ampm等。

在React中创建组件有多种不同的方式,在这个示例中,我们采用的是无状态函数组件(Function Stateless)

这些组件都只是函数,写起来不会很复杂,大致可能像下面这样:

const Hour = (props) => {
    let {hours} = props

    if (props.twelveHours) {
        if(hours <= 12){
            hours = hours
        }else if(hours > 12 && hours < 24){
            hours -= 12
        }else if(hours == 24){
            hours = '00'
        }
    }

    return <span>{hours}</span>
}

const Minute = (props) => {
    const {minutes} = props

    return <span>{minutes < 10 && '0'}{minutes}</span>
}

const Second = (props) => {
    const {seconds} = props

    return <span>{seconds < 10 && '0'}{seconds}</span>
}

const Separator = (props) => {
    const {separator} = props

    return <span>{separator || ':'}</span>
}

const Ampm = (props) => {
    const {hours} = props

    return <span>{ hours >= 12 ? 'pm' : 'am'}</span>
}

在些基础上,还需要另一个组件,让Clock组件可以接受一个format字符串并分解这个字符串。这样做的目的是让用户能更灵活的配置自己想要的时钟方式。比如这里,我们新创建一个叫Formatter的组件,其代码可能会像下面这样:

const Formatter = (props) => {
    const {format} = props
    let children = format.split('').map((e, idx) => {
        switch(e) {
            case 'h':
                return <Hour key={idx} {...props} /> 
            case 'm':
                return <Minute key={idx} {...props} />
            case 's':
                return <Second key={idx} {...props} />
            case 'p':
                return <Ampm key={idx} {...props} />
            case ' ':
                return <span key={idx}> </span>
            default:
                return <Separator key={idx} {...props} />
        }
    })

    return <div className="timer">{children}</div>
}

const hour = new Date().getHours();
const minute = new Date().getMinutes();
const second = new Date().getSeconds();

const rootElement = document.getElementById("app");
ReactDOM.render(<Formatter format="h:m:s"  hours={hour} minutes={minute} seconds={second} />, rootElement);

这个时候你可以看到时间的输出。

上图看到是按照用户所需要的格式format="h:m:s"方式输出的格式化好的时间。虽然达到我们所需要的效果,但上面示例中的keyprops的使用的不够优雅。其实我们可以借助React的一些其他特性,使用React.Children对象映射 到React对象的列表上。这样一来,我们可以将Formatter的代码进行优化:

const Formatter = (props) => {
    const {format} = props
    let children = format.split('').map(e => {
        switch(e) {
            case 'h':
                return <Hour /> 
            case 'm':
                return <Minute />
            case 's':
                return <Second />
            case 'p':
                return <Ampm />
            case ' ':
                return <span> </span>
            default:
                return <Separator />
        }
    })
    
    return <div className="timer">{React.Children.map(children, c => React.cloneElement(c, props))}</div>
}

const currentTime = new Date();
const hour = currentTime.getHours();
const minute = currentTime.getMinutes();
const second = currentTime.getSeconds();

const rootElement = document.getElementById("app");
ReactDOM.render(<Formatter format="h:m:s"  hours={hour} minutes={minute} seconds={second} />, rootElement);

最终得到的效果是一样的:

这里用到了React.cloneElementReact.Children.map相关的知识,这里不做过多阐述,感兴趣的同学可以阅读下面相关的文章:

上面示例看到的只是如何格式化时间格式,但时钟并不会动,接下来构建Clock组件,让其动起来:

const Clock = () => {
    const [currentTime, setCurrentTime] = React.useState(new Date())

    React.useEffect(()=>{
        const timer = setTimeout(()=>{
            setCurrentTime(new Date())
        }, 1000)

        retrun () => {
            clearTimeout(timer)
        }

    }, [currentTime])

    const hour = currentTime.getHours()
    const minute = currentTime.getMinutes()
    const second = currentTime.getSeconds()

    return <Formatter format="h:m:s"  hours={hour} minutes={minute} seconds={second} />
}

效果如下:

有关于React中children更深入的探讨和学习,我们在接下来的学习中会和大家一起讨论,感兴趣的同学,欢迎持续关注相关的更新。

React中列表渲染中的key

前面提到过,React中列表渲染的时候,如果没有给列表项显式设置key属性是会报错的。在React中key可以帮助React识别哪些元素改变了,比如被添加或删除,另外使用key属性还能用来决定哪些元素可以在下一个渲染中重用。特别是在动态列表渲染中其显得尤其重要。

React将新元素的key值和之前的key值进行比较:

  • 新增的元素有一个新的key
  • 删除的元素的key值不再使用

很多React开发人员在使用.map()来渲染列表时,习惯性将index值赋值给key。那么这样做是对还是错呢?如果是错误使用,我们又应该怎么来做才是较佳的方式。为了更好的理解,我们从简单的示例开始。

const Lists = ({initialList}) => {
    const [lists, setLists] = React.useState(initialList)
    
    const handleClick = (e) => {
        setLists(lists.slice().reverse())
    }
    
    return (
        <>
            <ul className="list">
                {lists.map((item, index) => <li className="list__item" key={index}>{item}<span>key={index}</span></li>)}
            </ul> 
            <div className="action">
                <button onClick={handleClick}><span>列表反转排列</span></button>
            </div>  
        </>
    )
}

const listsData = ['CSS', 'React', 'Vue', 'HTML5', 'JavaScript']

const rootElement = document.getElementById("app");
ReactDOM.render(<Lists  initialList={listsData}/>, rootElement);

这个示例很简单,列表有五个列表项,其key的值是数组listsData的索引值index,依次是04。当你点击按钮时,整个列表会反转排列。如下图所示:

虽然列表只是位置反转排列了(只需要交互一下DOM位置就行),但是React并不知道只改变了元素的位置,所以它会重新渲染整个列表(再次执行了虚拟DOM策略),这样一来就会大大增加DOM操作。大家都知道,React的高效是依赖于虚拟DOM策略。也就是说,能复用的话就应该尽量复用,没有必要的话绝对不碰DOM。

总而言之,对于列表元素来说,处理列表元素复用性会有一个问题:元素可能会在一个列表中改变位置。正如上例所示。

如果我们要修复这个问题,就需要给每个元素加上唯一的标识,这样React才能知道元素只是交换了位置。即,给每个列表项唯一的key值,通过唯一的key值为判断,列表只是更换位置(倒序排列),可以尽量复用元素内部的结构。因此,上面的示例,我们稍作调整:

<ul className="list">
    {lists.map((item, index) => <li className="list__item" key={item.toString().toLocaleLowerCase()}>{item}<span>key={item.toString().toLocaleLowerCase()}</span></li>)}
</ul> 

如果你坚持将key的值设置为数组的index索引值时,将会:

  • 导致不必要的重新渲染(前面示例已演示过了)
  • 当列表是不受控制的组件且仍然要使用props时会报错

为了提高列表渲染的性能,key值强制为列表项的唯一值,一般可以通过后台给数据提供一个id值。这样可以用于强制组件的完整的重新加载,对性能也非常有益。

如果你想对React中key的使用进一步了解的话,可以阅读下面几篇文章:

小结

今天我们主要学习和探讨了在React中如何实现列表渲染。React中的列表渲染和条件渲染类似,都是React的基础部分,对于初学者而言,掌握这方面的知识是非常有必要的。文章开头简单地回顾了JavaScript中有关于循环迭代的一些基础知识,以及我们怎么使用JavaScript对数组、对象循环迭代,因为这部分是React中实现列表渲染最基础的部分。在React中我们拿到的数据一般都是数组或对象,所以要较好的实现列表渲染,就需要掌握这些JavaScript的知识,正如文章中的示例,我们使用的都是.map()来实现列表渲染。

当然,在列表渲染中还有很多知识,文章中的都是最基础的部分,如果你在这方面有较深的经验,欢迎在下面的评论中与我们一起共享。