Skip to content

ref与todolist类组件实现练习(Day5 3/3) ​

本页对应 React + TypeScript + 医通项目,共 5 段视频,约 33 分钟。

学习目标 ​

  • 能够为单条待办复选框绑定受控 change 事件,通过子传父传递 id,并在父组件中使用 map 取反对应项的 isDone 后验证视图更新。
  • 能够实现全选/全不选功能:根据已完成数量与总数计算复选框 checked 状态,处理 0/0 边界,并通过 change 事件获取最新 checked 值批量更新所有待办状态。
  • 能够实现“删除已完成”功能:使用 filter 保留未完成项,并在触发删除前通过 window.confirm 完成二次确认。
  • 能够使用 localStorage 的 setItem/getItem/removeItem/clear 及 JSON.stringify/JSON.parse 持久化对象数组,并验证刷新页面后数据仍可恢复使用。
  • 能够定位并修复本地存储数据格式错误导致的 toDo.map is not a function 等数组方法报错,通过检查 Application/localStorage、清理错误键、初始化空数组恢复页面。
  • 能够在类组件 Todolist 练习中说明并使用 ref 获取所需 DOM 或组件引用,完成与待办操作相关的事件处理验证。

知识点 ​

ref 的作用:获取真实 DOM 元素 ​

为什么需要:在 React 中,我们通常通过 state 和事件对象来驱动界面,例如转写稿里复选框通过 e.target.checked 获取选中状态,并不需要直接碰 DOM。但有些操作必须拿到真实 DOM:让输入框聚焦、读取元素尺寸、控制滚动位置、播放媒体等。此时就需要 ref。笔记明确写了:ref 的作用是“在 react 项目中,获取真实 dom 元素”。

怎么想:先不要一上来就操作 DOM。先判断:这个值能不能用 state 或事件对象拿到?如果能,优先用 React 的数据流。只有“必须操作真实 DOM 节点”时,才创建 ref。创建 ref 后,它像一个“盒子”,React 在渲染并把元素挂到页面后,会把真实 DOM 放进这个盒子的 current 属性里。

怎么做:按笔记三步走。第一步创建 ref 对象:

js
this.inputRef = React.createRef();

第二步给 JSX 元素绑定 ref 属性,保留笔记写法:

jsx
<input ref={ref对象} />

实际类组件中写成:

jsx
<input ref={this.inputRef} />

第三步通过 current 获取真实 DOM:

js
ref对象.current

例如:

js
this.inputRef.current.focus();

易错点:不要以为 React.createRef() 一执行,current 里就有 DOM。创建时它只是空盒子,元素挂载后才有值;在渲染前或元素卸载后访问,可能是 null。另外,ref对象 和 ref对象.current 是两回事:前者是 ref 容器,后者才是真实 DOM。

绑定 ref 后如何验证与调试 ​

为什么需要:ref 绑定是否成功,不能靠猜。转写稿里调试复选框时,会先刷新页面、观察事件是否收到 ID、再确认状态是否变化;ref 调试也应先验证 current 到底是什么。

怎么想:先最小化验证:只放一个 input 和一个按钮,点击按钮时打印 this.inputRef.current。如果打印出真实 DOM 节点,说明绑定成功;如果打印 null,先检查 ref 是否绑到了 JSX 元素上,以及访问时机是否在挂载之后。

怎么做:可以写一个完整类组件:

jsx
class Demo extends React.Component {
  constructor(props) {
    super(props);
    this.inputRef = React.createRef();
  }

  handleFocus = () => {
    console.log(this.inputRef.current);
    this.inputRef.current.focus();
  };

  render() {
    return (
      <div>
        <input ref={this.inputRef} />
        <button onClick={this.handleFocus}>聚焦输入框</button>
      </div>
    );
  }
}

点击按钮后,控制台应能看到 input 的真实 DOM,并且输入框获得焦点。

易错点:调试时不要只打印 this.inputRef,那看到的是 ref 对象;要打印 this.inputRef.current。如果点击按钮报“Cannot read properties of null”,通常是 ref 没绑上,或元素还没挂载就访问了。

与受控组件取值的区别 ​

为什么需要:转写稿中复选框通过 onChange 和 e.target.checked 获取最新选中状态,再传给父组件修改数组。这说明很多场景不需要 ref。若把 ref 当成“万能取值工具”,会破坏 React 的数据流。

怎么想:表单值优先走受控组件:value / checked 配 onChange,从事件对象或 state 取。只有事件对象给不了、state 也不适合表达的操作,才用 ref 直接操作 DOM。

怎么做:像转写稿里的复选框,继续用:

jsx
<input
  type="checkbox"
  checked={todo.isDone}
  onChange={(e) => this.checkOne(todo.id, e.target.checked)}
/>

而“让输入框聚焦”这类行为,用:

js
this.inputRef.current.focus();

易错点:不要为了拿输入值而滥用 ref;受控组件更符合 React 思路。也不要混淆“获取真实 DOM”和“修改 React 状态”:ref 改 DOM 不会自动改 state,state 改完也要通过渲染反映到界面。

使用 ref 的关键检查清单 ​

为什么需要:ref 的三个步骤很短,但出错点集中在创建、绑定、访问时机。

怎么想:按顺序检查:ref 是否用 React.createRef() 创建?是否绑定到目标 JSX 元素?访问是否在挂载后?是否访问了 .current?

怎么做:保留本节核心代码:

js
// 1. 创建 ref 对象
this.xxxRef = React.createRef();

// 2. 绑定 ref 属性
<input ref={this.xxxRef} />

// 3. 获取真实 DOM
this.xxxRef.current;

易错点:ref 名不要和普通变量混淆;一个 ref 只绑定一个目标更清晰;元素卸载后不要再访问 current。记住:ref 是获取真实 DOM 的通道,不是替代 state 的捷径。

本节目标与组件拆分 ​

这一节的核心目标不是单纯把 TodoList 写出来,而是练习组件化开发思路:先拆页面,再定数据,最后按“描述行为—拆分步骤—匹配技术点”的方式实现交互。

待办事项组件效果图

为什么需要先拆组件?因为 TodoList 同时包含输入、列表、底部统计和操作区。若全部写在一个组件里,状态和事件会混在一起。怎么想?按页面区域拆:父组件管理数据,输入区负责收集内容,列表项负责展示与触发单条操作,底部组件负责统计、全选和清除已完成。怎么做?先搭静态页面,再渲染首屏数据,最后逐个补交互。易错点是先写交互再拆组件,导致数据不知道该放哪。

首屏数据与数据放在哪里 ​

首屏渲染前要先分析数据结构。笔记给出的结构是:

js
let todos = [
    {
        id:xxx,
        title:'吃饭',
        isDone:true | false
    }
]

为什么要有 id?因为勾选、删除都要定位具体某一条。title 负责显示内容,isDone 表示完成状态。

数据应该定义在哪个组件身上?原则是:只在一个组件使用的数据放自己身上;多个组件都要用的数据放到共同父级身上,也就是“状态提升”。本节的 todos 被列表、底部统计、全选等共同使用,所以应放在父组件中。易错点是子组件各自维护一份列表,导致统计和列表不同步。

添加待办:从描述到技术点 ​

转写稿强调交互实现要先描述结果:文本框输入内容,按下回车,把内容添加到列表中。再拆步骤:获取输入内容、监听回车、追加到列表。匹配技术点:输入框可用 ref、受控组件或 e.target.value;回车用 onKeyUp/onKeyDown;追加列表涉及子传父。

为什么需要子传父?因为输入区只负责收集内容,真正修改 todos 的方法应定义在持有数据的父组件中。怎么做?子组件把输入内容传给父组件,父组件再更新列表。易错点是输入为空也追加,或事件绑定后忘记在父组件真正 setState。

单条勾选:checkOne ​

点击复选框改变完成状态,行为可描述为:复选框状态变化,触发事件,改变该条 isDone。技术点仍是受控组件加 onChange,并通过子传父把当前条 id 传给父组件。

转写稿中调试时先确认父组件能否收到 id:点击不同条目,父组件分别收到对应 id。父组件拿到 id 后,用 map 生成新数组:找到对应 id 的那条,把 isDone 取反,其余原样返回,再 setState。为什么用 map?因为长度不变,只改其中一条。易错点是直接修改原数组,或忘记返回新对象。

全选与全不选:checkAll ​

底部复选框是否勾选,应由数据推导:已完成数量 doneNumber 等于总数 total 时勾选。转写稿中发现一个 bug:当 0 和 0 相等时也会勾选,所以条件还要加“总数大于 0”。即类似:doneNumber === total && total > 0。

点击全选框时,通过 onChange 获取 e.target.checked,再子传父给父组件。父组件用 map 把每一项的 isDone 统一改成传入的布尔值。易错点是只改底部状态,没有改 todos;或全选条件漏掉空列表判断。

删除已完成:deleteDone ​

“清除已完成”要删除 isDone 为 true 的项,保留未完成项,因此用 filter,返回 !todo.isDone。这个方法不需要传具体 id,因为“哪些已完成”从 todos 里就能判断。转写稿中还加了 window.confirm 提示,点击取消就不执行删除。易错点是把 filter 写成 map,或忘记用 window.confirm 导致找不到方法。

localStorage 持久化与常见报错 ​

刷新后数据丢失,是因为数据写死在代码里。持久化可存到 localStorage。但本地存储只能存基本类型,不能直接存对象和数组;直接存数组会变成类似 [object Object] 的字符串,读出来无法使用。所以要存前 JSON.stringify,读后 JSON.parse。

转写稿还提到:读取不存在的 key 时得到 null,JSON.parse(null) 返回 null,不会直接报错,可据此在初始化时兜底为空数组。常见错误是忘记 JSON.stringify,刷新后出现 todo.map is not a function。遇到这类报错,先看 Application 里 localStorage 的值是否正确;练习时可清掉对应存储再刷新。易错点是存储值格式错误后仍继续用数组方法操作。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>ref 获取真实 DOM</title>
</head>
<body>
  <div id="root"></div>

  <script src="https://unpkg.com/react@17/umd/react.development.js"></script>
  <script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
  <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>

  <script type="text/babel" data-presets="react">
    class RefDemo extends React.Component {
      constructor(props) {
        super(props);
        // 1. 创建 ref 对象
        this.inputRef = React.createRef();
        this.handleClick = this.handleClick.bind(this);
      }

      handleClick() {
        // 3. 通过 ref 对象的 current 属性获取真实 DOM
        alert('输入框的值:' + this.inputRef.current.value);
        this.inputRef.current.focus();
      }

      render() {
        return (
          <div>
            {/* 2. 给 jsx 元素绑定 ref 属性 */}
            <input ref={this.inputRef} placeholder="请输入内容" />
            <button onClick={this.handleClick}>获取真实 DOM 值</button>
          </div>
        );
      }
    }

    ReactDOM.render(<RefDemo />, document.getElementById('root'));
  </script>
</body>
</html>

要点解释:

  • React.createRef() 创建 ref 对象。
  • <input ref={this.inputRef} /> 把 ref 绑定到真实 DOM 元素上。
  • this.inputRef.current 才是真实 DOM,不能写成 this.inputRef.value。
  • current 在组件挂载后才有值,事件回调里使用最稳妥。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>TodoList 类组件实现</title>
</head>
<body>
  <div id="root"></div>

  <script src="https://unpkg.com/react@17/umd/react.development.js"></script>
  <script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
  <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>

  <script type="text/babel" data-presets="react">
    class TodoList extends React.Component {
      constructor(props) {
        super(props);
        this.state = {
          todos: [
            { id: 1, title: '吃饭', isDone: false },
            { id: 2, title: '睡觉', isDone: true }
          ]
        };

        this.addTodo = this.addTodo.bind(this);
        this.checkOne = this.checkOne.bind(this);
        this.checkAll = this.checkAll.bind(this);
        this.deleteDone = this.deleteDone.bind(this);
      }

      componentDidMount() {
        const raw = localStorage.getItem('todos');
        if (!raw) return;

        try {
          const parsed = JSON.parse(raw);
          // 防止本地存储数据被误存成非数组,导致 todos.map is not a function
          if (Array.isArray(parsed)) {
            this.setState({ todos: parsed });
          } else {
            this.setState({ todos: [] });
          }
        } catch (e) {
          this.setState({ todos: [] });
        }
      }

      componentDidUpdate() {
        // localStorage 只能存字符串,所以要 JSON.stringify
        localStorage.setItem('todos', JSON.stringify(this.state.todos));
      }

      addTodo(title) {
        const text = title.trim();
        if (!text) return;

        const todo = {
          id: Date.now(),
          title: text,
          isDone: false
        };

        this.setState({
          todos: [...this.state.todos, todo]
        });
      }

      checkOne(id) {
        // 用 map 返回新数组,不能直接修改 state 里的数组
        this.setState(prevState => ({
          todos: prevState.todos.map(todo =>
            todo.id === id ? { ...todo, isDone: !todo.isDone } : todo
          )
        }));
      }

      checkAll(isDone) {
        // 把传入的选中状态赋值给每一条 todo
        this.setState(prevState => ({
          todos: prevState.todos.map(todo => ({
            ...todo,
            isDone
          }))
        }));
      }

      deleteDone() {
        if (!window.confirm('确定删除已完成?')) return;

        // filter 留下未完成的,即删除已完成的
        this.setState(prevState => ({
          todos: prevState.todos.filter(todo => !todo.isDone)
        }));
      }

      render() {
        const { todos } = this.state;
        const total = todos.length;
        const done = todos.filter(todo => todo.isDone).length;

        return (
          <div style={{ padding: 20 }}>
            <h2>TodoList</h2>

            <TodoInput onAdd={this.addTodo} />

            <ul>
              {todos.map(todo => (
                <TodoItem
                  key={todo.id}
                  todo={todo}
                  checkOne={this.checkOne}
                />
              ))}
            </ul>

            <TodoFooter
              total={total}
              done={done}
              checkAll={this.checkAll}
              deleteDone={this.deleteDone}
            />
          </div>
        );
      }
    }

    class TodoInput extends React.Component {
      constructor(props) {
        super(props);
        this.inputRef = React.createRef();
        this.handleKeyDown = this.handleKeyDown.bind(this);
      }

      handleKeyDown(e) {
        if (e.key === 'Enter' || e.keyCode === 13) {
          this.props.onAdd(this.inputRef.current.value);
          this.inputRef.current.value = '';
        }
      }

      render() {
        return (
          <input
            ref={this.inputRef}
            onKeyDown={this.handleKeyDown}
            placeholder="输入待办事项,按回车添加"
          />
        );
      }
    }

    class TodoItem extends React.Component {
      render() {
        const { todo, checkOne } = this.props;

        return (
          <li>
            <input
              type="checkbox"
              checked={todo.isDone}
              onChange={() => checkOne(todo.id)}
            />
            <span
              style={{
                textDecoration: todo.isDone ? 'line-through' : 'none'
              }}
            >
              {todo.title}
            </span>
          </li>
        );
      }
    }

    class TodoFooter extends React.Component {
      render() {
        const { total, done, checkAll, deleteDone } = this.props;

        return (
          <div>
            <label>
              <input
                type="checkbox"
                checked={total > 0 && done === total}
                onChange={e => checkAll(e.target.checked)}
              />
              全选/全不选
            </label>

            <span style={{ marginLeft: 12 }}>
              已完成 {done} / 全部 {total}
            </span>

            <button onClick={deleteDone} style={{ marginLeft: 12 }}>
              清除已完成
            </button>
          </div>
        );
      }
    }

    ReactDOM.render(<TodoList />, document.getElementById('root'));
  </script>
</body>
</html>

要点解释:

  • todos 数据定义在父组件 TodoList 中,子组件通过 props 接收,属于“状态提升”。
  • checkOne(id) 用 map 找到对应 id 的 todo,并取反 isDone。
  • checkAll(isDone) 用 map 把所有 todo 的 isDone 改成传入的布尔值。
  • 全选框 checked={total > 0 && done === total} 必须加 total > 0,否则 0 === 0 时会被误选中。
  • deleteDone() 用 filter(todo => !todo.isDone) 留下未完成项,删除已完成项,并用 window.confirm 做提示。
  • 本地存储只能存字符串,写入用 JSON.stringify,读取用 JSON.parse。
  • 读取本地存储后要判断 Array.isArray(parsed),否则坏数据会导致 todos.map is not a function。
  • 复选框受控时 checked 和 onChange 要配套使用,不能只写 checked。

常见错误 ​

  • 点复选框没反应,或整行状态不跟着变 现象:勾选某一条,界面不动;控制台没有报错。 原因:onChange={this.props.checkOne(item.id)} 在渲染时就执行了,事件绑定的其实是返回值;或者方法从爷→父→子透传时中间层没接收、没 bind(this)。 修法:写成 onChange={() => this.props.checkOne(item.id)};顺着 props 链逐层检查“定义处 → 中间商 → 使用者”三处是否都对得上。

  • 复选框变成“只读”,怎么点都弹回原状 现象:点了会闪一下又恢复,控制台提示受控组件缺少 onChange。 原因:给了 checked={...} 却没绑 onChange(或把 checked 写成了 value)。 修法:checkbox 的 checked 与 onChange 必须成对出现,回调里用 e.target.checked 取最新布尔值,再向上传。

  • 列表清空后“全选”仍然显示为勾选 现象:一条数据都没有时,全选框是打勾状态。 原因:判断条件只写了 doneCount === total,0 === 0 为真。 修法:补上非空判断,如 total > 0 && doneCount === total。

  • 刷新后白屏,报 todos.map is not a function 现象:一刷新就报一堆“map / filter / reduce 不是函数”的错。 原因:写入时漏了 JSON.stringify,存进去的是 [object Object] 之类的字符串,JSON.parse 还原不回来,读出来不是数组。 修法:写入一律 JSON.stringify,读取 JSON.parse 后用 Array.isArray(x) ? x : [] 兜底;开发阶段先到 Application → Local Storage 删掉对应 key 再刷新,能立刻定位是不是脏数据。

  • “清除已完成”删错对象或点了没反应 现象:要么把未完成的删了,要么怎么点都还在。 原因:filter 条件写反(留下了 isDone === true 的);或误用 splice/push 原地改 state,引用没变导致不重渲染。 修法:filter(todo => !todo.isDone) 得到新数组再 setState;确认框用 window.confirm(直接写 confirm 在部分环境下会报未定义)。

  • 改了单条数据,页面偶发不更新或“回滚” 现象:map 里改了 item.isDone 再 return item,有时生效有时不生效。 原因:返回的是原对象,新旧 state 引用相同,且污染了旧 state,后续做对比/回退时出错。 修法:return { ...todo, isDone: 新值 },始终保持“不可变更新”。

  • this.refObj.current 拿到 null 现象:在 render 里读 current 得到 null,或报 Cannot read property 'value' of null。 原因:DOM 还没挂载就去读了;或把 ref 挂到了函数组件上。 修法:只在事件回调 / componentDidMount 之后读 current;函数组件需要转发 ref。

练习 ​

  1. 双击编辑标题:给单条待办加“双击变输入框”,用 ref 在进入编辑态时自动聚焦;回车保存、Esc 取消、失焦保存。要求改完的数据仍走“子传父”,由持有 todos 的组件统一 setState,并说明为什么不能由子组件自己改。
  2. footer 的显示策略:把已完成数量、全选、清除已完成放到 footer,实现“列表为空时整个 footer 不渲染”“全部完成时全选框自动勾上”“清空后不残留勾选状态”。写出你判断空列表、全选的两个表达式。
  3. 持久化封装:抽出 loadTodos() / saveTodos(list) 两个函数,要求:首次访问返回空数组;本地值是 null、非法 JSON、非数组时都能兜底;saveTodos 在增/删/改/勾选后都要被调用。故意把一处 JSON.stringify 删掉复现报错,再说明排查路径。
  4. 数组方法辨析:用 map / filter / splice 三种写法各实现一次“切换某条完成状态”,运行对比效果,写出 splice 版本会出现什么问题、为什么本项目统一用前两者。
  5. ref vs 受控组件:分别用 ref 取值和受控组件两种方式实现“输入框回车添加待办”,比较代码量、能否做输入校验、能否一键清空输入框,给出你的选型结论。

原视频对照 ​

#视频时长
1checkOne改变完成状态07:33
2checkAll07:45
3删除已完成03:58
4localStorage-JSON.stringify-parse复习10:58
5本地存储数据错误导致的数组方法报错及解决方案02:58

小结 ​

  • 本组件的“根”只有一个:todos 数组放在共同父级,其余组件只负责展示和“向上报告”,数据在哪,改数据的方法就定义在哪。
  • 所有交互都是同一套模板:描述结果 → 拆步骤 → 匹配技术点 → 落到“子传父 + setState”。复选框、全选、清除已完成没有本质区别。
  • 子传父传什么由父组件需要什么决定:切换状态只传 id 就够(父自己能查、能取反);全选要传最新布尔值;清除已完成什么都不用传。
  • 事件绑定的参数一定要用箭头函数包一层,否则渲染期就执行;checked 和 onChange 必须成对,这是受控组件的硬约束。
  • 更新 state 一律产出新数组/新对象(map 里展开、filter 取子集),不要原地 push/splice,也不要直接改 item 后返回原引用。
  • 判断条件要考虑边界:0 === 0 会让空列表误显示为全选,凡是“相等即真”的逻辑都要问一句“空的时候对吗”。
  • localStorage 只能存字符串,对象数组必须 JSON.stringify / JSON.parse 成对使用;脏数据是“数组方法报错”的头号嫌疑,先清 key 再刷新是最快的定位手段。
  • 写这类列表页的熟练度,最终取决于两件事:数组方法用得顺不顺手,以及 props 逐层透传时链路清不清楚。