目标
利用我们已经写完了的mini-react写一个TODO应用。
- 支持添加,删除和完成todo item
- 以列表形式展示所有todo items
- 支持filter:all, done, active
- 点击save保存,刷新浏览器后列表能加载出来。
方法论
在写代码的时候依然可以承袭前面课程的逐步迭代方法论:
- 静态 - hardcode 写死数据和页面
- 动态 - 基于数据驱动。提取数据结构,写function处理数据
- 实现功能
- 重构优化
- 先实现功能后优化的好处在于:如果先预设了一些设计,在实现过程中发现不合用的地方得随时调整。到最后相比于原本的设计也会有很大的变形。不如直接先keep it simple stupid。等实现完成之后再优化重构。
Todos组件
- 使用useEffect 从localStorage 加载保存的列表
- 使用state存储输入框值;以及动态添加,删除和过滤的列表
- 点击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是实现样式的一种方式。另一种方式是:
- 数据中加入status属性,取值done或者active
- 给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。以及最后对它的使用和完善,通关啦~