mini-react 第八天:做一个TODO 应用

目标


利用我们已经写完了的mini-react写一个TODO应用。

  • 支持添加,删除和完成todo item
  • 以列表形式展示所有todo items
  • 支持filter:all, done, active
  • 点击save保存,刷新浏览器后列表能加载出来。

方法论

在写代码的时候依然可以承袭前面课程的逐步迭代方法论:

  1. 静态 - hardcode 写死数据和页面
  2. 动态 - 基于数据驱动。提取数据结构,写function处理数据
  3. 实现功能
  4. 重构优化
    • 先实现功能后优化的好处在于:如果先预设了一些设计,在实现过程中发现不合用的地方得随时调整。到最后相比于原本的设计也会有很大的变形。不如直接先keep it simple stupid。等实现完成之后再优化重构。

Todos组件

  1. 使用useEffect 从localStorage 加载保存的列表
  2. 使用state存储输入框值;以及动态添加,删除和过滤的列表
  3. 点击save将列表存入localStorage
js 复制代码
import React from "./core/React.js";

function loadItems() {
  const items = localStorage.getItem("items")
  if (items) {
    return(JSON.parse(items))
  }
  return []
}

function Todos() {
  const [inputValue, setInputValue] = React.useState("");
  const [items, setItems] = React.useState(loadItems());

  function handleInput(e) {
    setInputValue(e.target.value)
  }

  function handleAdd() {
    setItems([...items, {text: inputValue}])
    setInputValue("")
  }

  function handleRemove(index) {
    return function () {
      const newItems = [...items]
      newItems.splice(index, 1)
      setItems(newItems)
    }
  }

  function handleDone(index) {
    return function () {
      const newItems = [...items]
      newItems[index].done = true
      setItems(newItems)
    }
  }

  function handleSave() {
    localStorage.setItem("items", JSON.stringify(items))
  }

  React.useEffect(() => {
    console.log("update items", items)
  }, [items])

  return (
    <div>
      <input type="text" onChange={handleInput} value={inputValue}></input>
      <button onClick={handleAdd}>add</button>
      <div>
        <button onClick={handleSave}>save</button>
      </div>
      <ul>
        {items.length}
        {/* {items.map((item, index) => {
          return (
            <li key={index}>
              <span style={{textDecoration: item.done ? "line-through" : "none"}}>{item.text}</span>
              <button onClick={handleRemove(index)}>remove</button>
              <button onClick={handleDone(index)}>done</button>
            </li>
          )
        })} */}
      </ul>
    </div>
  )
}

function App() {
  return (
    <div>
      <h1>TODOs</h1>
      <Todos></Todos>
    </div>
  )
}

export default App

支持数组作为vdom节点

通过打印发现,items.map产生的vdom节点是个数组。需要把数组元素取出来放到parent的children数组中。

js 复制代码
// React.js
function createChildNode (child) {
  const isTextNode = typeof child === "string" || typeof child === "number"
  console.log(child, isTextNode)
  return isTextNode ? createTextNode(child) : child
}

 function createElement(type, props, ...children) {
   return {
     type,
     props: {
       ...props,
       children: children.reduce((result, child) => {
         const isArray = Array.isArray(child) && child.length > 0
         if (isArray) {
           child.forEach((c) => result.push(createChildNode(c)))
         } else {
           result.push(createChildNode(child))
         }
         return result;
       }, []),
     },
   };
 }
js 复制代码
function Foo () {
  ...
      <ul>
        {items.map((item, index) => {
          return (
            <li key={index}>
              <span>{item.text}</span>
              <button onClick={handleRemove(index)}>remove</button>
              <button onClick={handleDone(index)}>done</button>
            </li>
          )
        })}
      </ul>
  ...

添加 filter

js 复制代码
function Todos() {
  const [inputValue, setInputValue] = React.useState("");
  const [items, setItems] = React.useState(loadItems());

  const [filter, setFilter] = React.useState("all");

  // 用const记录被filter之后的items. 由于没有useMemo,每次渲染都会计算
  const filteredItems = items.filter(item => {
    if (filter === "done") {
      return item.done
    }
    if (filter === "active") {
      return !item.done
    }

    return true
  })

  function handleInput(e) {
    setInputValue(e.target.value)
  }

  function handleAdd() {
    setItems([...items, {text: inputValue}])
    setInputValue("")
  }

  function handleRemove(index) {
    return function () {
      const newItems = [...items]
      newItems.splice(index, 1)
      setItems(newItems)
    }
  }

  function handleDone(index) {
    return function () {
      const newItems = [...items]
      newItems[index].done = true
      setItems(newItems)
    }
  }

  function handleActive(index) {
    return function () {
      const newItems = [...items]
      newItems[index].done = false
      setItems(newItems)
    }
  }

  function handleSave() {
    localStorage.setItem("items", JSON.stringify(items))
  }

  // 处理radio button点击
  function handleFilter(e) {
    console.log("checked", e.target.value)
    setFilter(e.target.value)
  }

  // 添加radio group,改用filteredItems展示列表
  return (
    <div>
      <input type="text" onChange={handleInput} value={inputValue}></input>
      <button onClick={handleAdd}>add</button>
      <div>
        <button onClick={handleSave}>save</button>
      </div>
      <div>
        <input type="radio" id="all" name="filter" value="all" onChange={handleFilter} checked/>
        <label for="all">all</label>
        <input type="radio" id="done" name="filter" value="done" onChange={handleFilter}/>
        <label for="done">done</label>
        <input type="radio" id="active" name="filter" value="active" onChange={handleFilter}/>
        <label for="active">active</label>
      </div>
      <ul>
        {filteredItems.map((item, index) => {
          return (
            <li key={index}>
              <span>{item.text}</span>
              <button onClick={handleRemove(index)}>remove</button>
              {
                item.done ? 
                  <button onClick={handleActive(index)}>active</button>:
                  <button onClick={handleDone(index)}>done</button>
              }
            </li>
          )
        })}
      </ul>
    </div>
  )
}

支持style属性

对于标记为done的item,显示为划线的效果

js 复制代码
<span style={{textDecoration: item.done ? "line-through" : "none"}}>{item.text}</span>

处理props,理所当然是在updateProps函数中进行

js 复制代码
function updateProps(dom, nextProps, prevProps) {
  Object.keys(prevProps).forEach((key) => {
    if (key !== "children") {
      if (!(key in nextProps)) {
        dom.removeAttribute(key);
      }
    }
  });
  Object.keys(nextProps).forEach((key) => {
    if (key !== "children") {
      if (prevProps[key] !== nextProps[key]) {
        if (key.startsWith("on")) {
          const eventType = key.slice(2).toLowerCase();
          dom.removeEventListener(eventType, prevProps[key]);
          dom.addEventListener(eventType, nextProps[key]);
        } else if (key==="style"){
          // 单独处理style属性
          dom[key] = objectToCssString(nextProps[key]);
        } else {
          dom[key] = nextProps[key];
        }
      }
    }
  });
}

// 将camel case命名key的css object 转换成分号分割的字符串
// style={{ textDecoration: "line-through" }} -> 
//   style="text-decoration: line-through;"
function objectToCssString(obj) {
  return Object.keys(obj).map(key => {
    return `${key.replace(/([A-Z])/g, "-$1").toLowerCase()}: ${obj[key]}`;
  }).join(";")
}

支持className

上面写死的style是实现样式的一种方式。另一种方式是:

  1. 数据中加入status属性,取值done或者active
  2. 给li元素一个className={item.status},然后引入css
arduino 复制代码
// style.css
.done {
  text-decoration: "line-through"
}

// main.jsx
import "./style.css";
...

添加useMemo

为了更贴近正常react的行为,我们可以把filteredItems用useMemo记忆一下

js 复制代码
  const filteredItems = React.useMemo(() => {
    return items.filter(item => {
      if (filter === "done") {
        return item.done
      }
      if (filter === "active") {
        return !item.done
      }

      return true
    })
  }, [items, filter])

支持单个useMemo

以类似于useEffect的写法,将callback,deps和cachedValue 存入wipFiber. 当deps发生变化时调用callback计算;不变时,直接返回cachedValue.

js 复制代码
function useMemo(callback, deps) {
  const memoHook = {
    callback,
    cachedValue: undefined,
    deps
  }

  let cachedValue;
  const oldHook = wipFiber.alternate?.memoHook;
  const hasChanged = memoHook.deps === undefined || 
    memoHook.deps.some((dep, i) => dep !== oldHook?.deps[i])
  if (!hasChanged) {
    console.log("useMemo: 未变化");
    cachedValue = oldHook.cachedValue;
  } else {
    console.log("useMemo: 变化");
    cachedValue = memoHook.callback();
  }
  memoHook.cachedValue = cachedValue;

  wipFiber.memoHook = memoHook;
  return cachedValue;
}

测试一下,如果依赖数组不填filter的话,即使filter变化,filteredItems也不会变化

js 复制代码
  const filteredItems = React.useMemo(() => {
    return items.filter(item => {
      if (filter === "done") {
        return item.done
      }
      if (filter === "active") {
        return !item.done
      }

      return true
    })
  }, [items])
  //}, [items, filter])

支持一个函数组件内多个useMemo

类似于useState的做法:引入memoHooks数组,在updateFunctionComponent中为每一个fiber节点初始化。并按照调用顺序标记index。

js 复制代码
let memoHooks;
let memoHookIndex;
function useMemo(callback, deps) {
  const memoHook = {
    callback,
    cachedValue: undefined,
    deps
  }

  let cachedValue;
  const oldHook = wipFiber.alternate?.memoHooks[memoHookIndex];
  const hasChanged = memoHook.deps === undefined
    || memoHook.deps.some((dep, i) => dep !== oldHook?.deps[i])
  if (!hasChanged) {
    console.log("useMemo: 未变化");
    cachedValue = oldHook.cachedValue;
  } else {
    console.log("useMemo: 变化");
    cachedValue = memoHook.callback();
  }
  console.log("cachedValue", cachedValue);
  memoHook.cachedValue = cachedValue;
  memoHooks.push(memoHook);
  memoHookIndex++;

  wipFiber.memoHooks = memoHooks;
  return cachedValue;
}

function updateFunctionComponent(fiber) {
  stateHooks = [];
  stateHookIndex = 0;
  effectHooks = [];

  memoHooks = [];
  memoHookIndex = 0;
  
  wipFiber = fiber;
  // 如果是函数组件,不直接为其append dom
  const children = [fiber.type(fiber.props)]
  reconcileChildren(fiber, children)
}

测试一下

js 复制代码
  const filteredItems = React.useMemo(() => {
    return items.filter(item => {
      if (filter === "done") {
        return item.done
      }
      if (filter === "active") {
        return !item.done
      }

      return true
    })
  }, [items, filter])

  const doneItems = React.useMemo(() => {
    return items.filter(item => item.done)
  }, [items]);

【思考】useState和useMemo的调用顺序

js 复制代码
function Todos() {
  const [inputValue, setInputValue] = React.useState("");
  const [items, setItems] = React.useState(loadItems());
  
  const filteredItems = React.useMemo(() => {
    return items.filter(item => {
      if (filter === "done") {
        return item.done
      }
      if (filter === "active") {
        return !item.done
      }

      return true
    })
  }, [items, filter])
  
  // 这样把filter声明后置写会导致错误
  const [filter, setFilter] = React.useState("all");

  const doneItems = React.useMemo(() => {
    return items.filter(item => item.done)
  }, [items]);
  ...

不用担心顺序问题,如果state在memo之后,系统会提示错误

用useEffect 加载数据

js 复制代码
  const [items, setItems] = React.useState([]);
  React.useEffect(() => {
    setItems(loadItems());
  }, [])

但是发现数据没有加载到页面上。debug发现,虽然setItem被成功执行了,但是在commitEffectHook之后,setState并没有再次渲染dom,所以没显示。究其原因是因为wipRoot被设为null,不符合if (!nextWorkOfUnit && wipRoot)这个条件,所以不会commitRoot。我们只需再添加一个条件判断,然后将刚刚渲染完成后保存的currentRoot赋值给wipRoot 即可。

js 复制代码
function workLoop(deadline) {
  let shouldYield = false;
  while (!shouldYield && nextWorkOfUnit) {
    nextWorkOfUnit = performWorkOfUnit(nextWorkOfUnit);
    if (wipRoot?.sibling?.type === nextWorkOfUnit?.type) {
      nextWorkOfUnit = undefined;
    }
    shouldYield = deadline.timeRemaining() < 1;
  }
  if (!nextWorkOfUnit && wipRoot) {
    commitRoot()
  }

  // 用于在commitEffectHooks之后,将更新了的state渲染一下。
  if (nextWorkOfUnit && !wipRoot) {  
    wipRoot = currentRoot;
  }

  requestIdleCallback(workLoop);
}

let deletions = [] // 需要删除的节点集合
let currentRoot;
function commitRoot() {
  deletions.forEach(commitDeletion)
  commitWork(wipRoot.child);
  commitEffectHooks();
  currentRoot = wipRoot;
  wipRoot = null;
  deletions = [];
}

组件抽取

js 复制代码
function Todos () {
  ...
      <TodoItems
        filteredItems={filteredItems}
        handleRemove={handleRemove}
        handleDone={handleDone}
        handleActive={handleActive}>
      </TodoItems>
  ...
}

function TodoItems({ filteredItems, handleRemove, handleDone, handleActive }) {
  return (
    <ul>
      {filteredItems.map((item, index) => {
        return (
          <li key={index}>
            <span style={{ textDecoration: item.done ? "line-through" : "none" }}>{item.text}</span>
            <button onClick={handleRemove(index)}>remove</button>
            {
              item.done ?
                <button onClick={handleActive(index)}>active</button> :
                <button onClick={handleDone(index)}>done</button>
            }
          </li>
        )
      })}
    </ul>
  )
}

报错这里通过call stack 看到对应的fiber正是函数组件 TodoItems. 它没有dom,所以不应该为它updateProps

js 复制代码
function commitWork(fiber) {
  if (!fiber) return
  let fiberParent = fiber.parent;
  while (!fiberParent.dom) {
    fiberParent = fiberParent.parent
  }

  // if (fiber.effectTag === "update") {
  if (fiber.effectTag === "update" && fiber.dom) {
    updateProps(fiber.dom, fiber.props, fiber.alternate?.props)
  } else if (fiber.effectTag === "placement" && fiber.dom) {
    fiberParent.dom.append(fiber.dom)
  }

  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

总结

通过逐步迭代书写mini-react。以及最后对它的使用和完善,通关啦~

相关推荐
Q_0046 小时前
umi自带的tailwindcss修改为手动安装
react.js·postcss
努力的搬砖人.12 小时前
React相关面试题
react native·react.js·面试·reactjs·reactnative
乐闻x12 小时前
在 React 中使用 Web Components 的实践操作
前端·react.js·前端框架·web-component
祈澈菇凉14 小时前
解释什么是受控组件和非受控组件
前端·javascript·react.js
情非得已小猿猿18 小时前
‌React Hooks主要解决什么
javascript·react.js·ecmascript
zhyoobo19 小时前
现代前端开发框架对比:React、Vue 和 Svelte 的选择指南
前端·vue.js·react.js
指尖的记忆21 小时前
React Hooks 深度解析:核心用法、最佳实践与实战场景
react.js
柯小慕21 小时前
React自定义Hooks入门指南:让函数组件更强大
前端·react.js
Shawn5901 天前
为什么React 函数组件会产生闭包陷阱?
前端·react.js