ریکت
11 مهر 1402
ریکت خودت رو بساز - قسمت سوم
سلام دوستان امیدوارم خوب باشید و همیشه درحال یادگیری🧑💻. توی قسمت سوم و آخر از مجموعه ریکت خودت رو بساز میخوایم سه ویژگی مهم و اساسی رو به ریکت خودمون اضافه کنیم:
- Reconciliation
- Function Components
- Hooks
قدم ششم: Reconciliation
تا اینجا ما اضافه کردن element به DOM رو نوشتیم، حالا برای حذف یا آپدیت element از DOM باید چیکار کنیم؟
ما باید element های جدیدی که توی تابع رندر دریافت میکنیم رو با fiber tree قبلی که داخل DOM هست، مقایسه کنیم. پس ما به یک reference نیاز داریم تا داخل "fiber tree قبلی که داخل DOM هست" بعد از اعمال تغییرات روی DOM، ذخیره بشه. ما بهش currentRoot
میگیم.
همچنین پراپرتی alternate
رو هم برای هر fiber اضافه میکنیم. این پراپرتی به fiber ایی که داخل فاز قبلی به DOM اضافه شده لینک هست.
function commitRoot() {
commitWork(wipRoot.child)
+ currentRoot = wipRoot
wipRoot = null
}
function commitWork(fiber) {
if (!fiber) {
return
}
const domParent = fiber.parent.dom
domParent.appendChild(fiber.dom)
commitWork(fiber.child)
commitWork(fiber.sibling)
}
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
+ alternate: currentRoot,
}
nextUnitOfWork = wipRoot
}
let nextUnitOfWork = null
+ let currentRoot = null
let wipRoot = null
حالا بیایید تابع performUnitOfWork
رو که قبلا نوشتیم رو که وظیفه ایجاد fiber جدید رو داشت:
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
const elements = fiber.props.children
let index = 0
let prevSibling = null
while (index < elements.length) {
const element = elements[index]
const newFiber = {
type: element.type,
props: element.props,
parent: fiber,
dom: null,
}
if (index === 0) {
fiber.child = newFiber
} else {
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
}
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
کوچک ترش کنیم و تابع جدید reconcileChildren
رو بنویسیم:
+ function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
+ const elements = fiber.props.children
+ reconcileChildren(fiber, elements)
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
+ function reconcileChildren(wipFiber, elements) {
...
}
داخل این تابع ما fiber قدیمی رو با element های جدید مقایسه میکنیم. ما بصورت همزمان روی فرزندان fiber قدیمی (wipFiber.alternate
) و آرایه از element هایی که میخوایم تطبیق (reconcile) انجام بشه، پیمایش میکنیم.
اگه تمام چیزهای جانبی رو صرف نظر کنیم، با چیزهایی که برامون مهم هست داخل این حلقه while مواجه میشیم: oldFiber
and element
.
element
چیزی هست که میخوایم داخل DOM رندر بشه و oldFiber
چیزی هست که دفعه قبلی رندر کردیم. باید این دو رو مقایسه کنیم تا ببینیم نیاز به تغییر جدید به DOM هست یا نه:
+ function reconcileChildren(wipFiber, elements) {
+ let index = 0
+ let oldFiber =
+ wipFiber.alternate && wipFiber.alternate.child
let prevSibling = null
+ while (
+ index < elements.length ||
+ oldFiber != null
+ ) {
+ const element = elements[index]
let newFiber = null
// TODO compare oldFiber to element
if (oldFiber) {
oldFiber = oldFiber.sibling
}
برای اینکه اینا رو مقایسه کنیم از type استفاده میکنیم:
- اگه fiber قدیمی و element جدید type یکسانی داشتند، ما DOM node رو نگه میداریم و فقط با prop جدید آپدیتش میکنیم.
- اگه type متفاوت بود و element جدید داشتیم، این یعنی ما باید DOM node جدید اضافه کنیم.
- و اگر type متفاوت بود و fiber قدیمی داشتیم، باید node قبلی رو حذف کنیم.
اینجا ریکت همچنین از keys استفاده میکنه. این باعث میشه تطبیق بهتری داشته باشیم، بطور مثال تشخیص میده چه زمانی فرزند، محلش داخل آرایه element ها تغییر میکنه.
const element = elements[index]
let newFiber = null
+ const sameType =
+ oldFiber &&
+ element &&
+ element.type == oldFiber.type
+ if (sameType) {
+ // TODO update the node
+ }
+ if (element && !sameType) {
+ // TODO add this node
+ }
+ if (oldFiber && !sameType) {
+ // TODO delete the oldFiber's node
+ }
if (oldFiber) {
oldFiber = oldFiber.sibling
}
وقتی که fiber قدیمی و element جدید type یکسانی داشتند، ما fiber جدید ایجاد میکنیم و DOM node مربوط به fiber قدیمی رو نگه میداریم و props رو از element جدید دریافت میکنیم.
همچنین یک پراپرتی جدید هم به fiber اضافه میکنیم: effectTag
. از این پراپرتی بعدا توی فاز commit (اعمال تغییرات جدید به DOM) استفاده میکنیم:
const element = elements[index]
let newFiber = null
const sameType =
oldFiber &&
element &&
element.type == oldFiber.type
if (sameType) {
+ newFiber = {
+ type: oldFiber.type,
+ props: element.props,
+ dom: oldFiber.dom,
+ parent: wipFiber,
+ alternate: oldFiber,
+ effectTag: "UPDATE",
}
}
بعد توی موردی که element نیاز به تشکیل DOM node جدید دارد، ما از PLACEMENT
برای تگ fiber جدید استفاده میکنیم:
if (element && !sameType) {
+ newFiber = {
+ type: element.type,
+ props: element.props,
+ dom: null,
+ parent: wipFiber,
+ alternate: null,
+ effectTag: "PLACEMENT",
}
}
if (oldFiber && !sameType) {
// TODO delete the oldFiber's node
}
و برای موردی که نیاز بود node رو حذف کنیم، نیاز به fiber جدید نداریم پس در نتیجه فقط effect tag رو به fiber قدیمی اضافه میکنیم. برای اینکه DOM رو با این fiber آپدیت کنیم، نیاز هست wipRoot (work in progress root) هم به این fiber قدیمی دسترسی داشته باشه.
if (element && !sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: "PLACEMENT",
}
}
if (oldFiber && !sameType) {
+ oldFiber.effectTag = "DELETION"
+ deletions.push(oldFiber)
}
if (oldFiber) {
oldFiber = oldFiber.sibling
}
پس نیاز به یک آرایه ایی داریم که node هایی که قرار هست حذف بشن رو نگهداری کنه:
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
}
+ deletions = []
nextUnitOfWork = wipRoot
}
let nextUnitOfWork = null
let currentRoot = null
let wipRoot = null
+ let deletions = null
و بعدش وقتی که داریم تغییرات جدید رو به DOM اضافه میکنیم، از fiber های اون آرایه هم اضافه میکنیم:
function commitRoot() {
+ deletions.forEach(commitWork)
commitWork(wipRoot.child)
currentRoot = wipRoot
wipRoot = null
}
حالا بیایین تابع commitWork
رو تغییر بدیم تا بتونیم effectTags
جدید رو مدیریت کنیم.
اگر fiber، تگ PLACEMENT
داشت، DOM node رو به node والد fiber اضافه میکنیم:
function commitWork(fiber) {
if (!fiber) {
return
}
const domParent = fiber.parent.dom
+ if (
+ fiber.effectTag === "PLACEMENT" &&
+ fiber.dom != null
+ ) {
+ domParent.appendChild(fiber.dom)
+ }
commitWork(fiber.child)
commitWork(fiber.sibling)
}
اگر تگ DELETION
ما خلاف این عمل میکنیم و فرزند رو حذف میکنیم:
function commitWork(fiber) {
if (!fiber) {
return
}
const domParent = fiber.parent.dom
if (
fiber.effectTag === "PLACEMENT" &&
fiber.dom != null
) {
domParent.appendChild(fiber.dom)
+ } else if (fiber.effectTag === "DELETION") {
+ domParent.removeChild(fiber.dom)
+ }
commitWork(fiber.child)
commitWork(fiber.sibling)
}
و اگر UPDATE
بود، باید DOM node فعلی رو با prop های جدید آپدیت کنیم:
const domParent = fiber.parent.dom
if (
fiber.effectTag === "PLACEMENT" &&
fiber.dom != null
) {
domParent.appendChild(fiber.dom)
+ } else if (
+ fiber.effectTag === "UPDATE" &&
+ fiber.dom != null
+ ) {
+ updateDom(
+ fiber.dom,
+ fiber.alternate.props,
+ fiber.props
+ )
} else if (fiber.effectTag === "DELETION") {
domParent.removeChild(fiber.dom)
}
commitWork(fiber.child)
commitWork(fiber.sibling)
}
برای آپدیت کردن از تابع updateDom
استفاده میکنیم:
function updateDom(dom, prevProps, nextProps) {
// TODO
}
میاییم prop های fiber قدیمی رو با prop های fiber جدید مقایسه میکنیم، prop هایی که از بین رفتن رو حذف میکنیم و بجای اون prop های جدید یا تغییر کرده رو قرار میدیم:
const isProperty = key => key !== "children"
const isNew = (prev, next) => key =>
prev[key] !== next[key]
const isGone = (prev, next) => key => !(key in next)
function updateDom(dom, prevProps, nextProps) {
// Remove old properties
Object.keys(prevProps)
.filter(isProperty)
.filter(isGone(prevProps, nextProps))
.forEach(name => {
dom[name] = ""
})
// Set new or changed properties
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
dom[name] = nextProps[name]
})
}
یک prop خاص که باید آپدیت بشه event listeners هستش. ما میاییم prop هایی که با “on” شروع میشن رو بصورت جدا مدیریتش میکنیم:
+ const isEvent = key => key.startsWith("on")
+ const isProperty = key =>
+ key !== "children" && !isEvent(key)
const isNew = (prev, next) => key =>
prev[key] !== next[key]
const isGone = (prev, next) => key => !(key in next)
function updateDom(dom, prevProps, nextProps) {
...
اگر event handler تغییر کرد ما از node حذفش میکنیم:
function updateDom(dom, prevProps, nextProps) {
+ //Remove old or changed event listeners
+ Object.keys(prevProps)
+ .filter(isEvent)
+ .filter(
+ key =>
+ !(key in nextProps) ||
+ isNew(prevProps, nextProps)(key)
+ )
+ .forEach(name => {
+ const eventType = name
+ .toLowerCase()
+ .substring(2)
+ dom.removeEventListener(
+ eventType,
+ prevProps[name]
+ )
+ })
// Remove old properties
...
و بعدش event listener جدید اضافه میکنیم:
...
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
dom[name] = nextProps[name]
})
+ // Add event listeners
+ Object.keys(nextProps)
+ .filter(isEvent)
+ .filter(isNew(prevProps, nextProps))
+ .forEach(name => {
+ const eventType = name
+ .toLowerCase()
+ .substring(2)
+ dom.addEventListener(
+ eventType,
+ nextProps[name]
+ )
+ })
}
function commitRoot() {
deletions.forEach(commitWork)
commitWork(wipRoot.child)
...
و نتیجه نهایی رو میتونید ببینید:
قدم هفتم: Function Components
چیز بعدی که لازم هست اضافه کنیم، پشتیبانی از فانکشن کامپوننت هست. برای اینکار ما این مثال ساده رو در نظر میگیریم (که فقط h1
رو برمیگردونه) و ادامه میدیم:
/** @jsx Didact.createElement */
function App(props) {
return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />
const container = document.getElementById("root")
Didact.render(element, container)
فانکشن کامپوننت ها از دو جهت تفاوت دارند:
- fiber ایی که از فانکشن کامپوننت ها ایجاد میشه، DOM node ندارند.
- و بجای اینکه فرزندان از طریق
props
قابل دسترس باشند، از اجرای تابع میان.
لازم هست چک کنیم آیا تایپ fiber از نوع function هست و بر حسب اون تابع آپدیت مختلفی رو صدا میزنیم:
function performUnitOfWork(fiber) {
- if (!fiber.dom) {
- fiber.dom = createDom(fiber)
- }
- const elements = fiber.props.children
- reconcileChildren(fiber, elements)
+ const isFunctionComponent =
+ fiber.type instanceof Function
+ if (isFunctionComponent) {
+ updateFunctionComponent(fiber)
+ } else {
+ updateHostComponent(fiber)
+ }
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
+ function updateFunctionComponent(fiber) {
+ // TODO
+ }
+ function updateHostComponent(fiber) {
+ if (!fiber.dom) {
+ fiber.dom = createDom(fiber)
+ }
+ reconcileChildren(fiber, fiber.props.children)
+ }
داخل updateHostComponent
ما شبیه به قبل عمل میکنیم، و توی تابع updateFunctionComponent
، ما تابع رو اجرا میکنیم تا فرزندان رو بگیریم.
بطور مثال، اینجا fiber.type
تابع App
هست و زمانی که اجراش کنیم، تگ h1
رو برمیگردونه.
زمانی که فرزند داشته باشیم تابع reconcileChildren
مطابق قبل عمل میکنه و نیاز به تغییرات نداره.
...
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
function updateFunctionComponent(fiber) {
+ const children = [fiber.type(fiber.props)]
+ reconcileChildren(fiber, children)
}
function updateHostComponent(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
reconcileChildren(fiber, fiber.props.children)
}
تغییراتی اصلی که باید اعمال بشه توی تابع commitWork
هستش. الان fiber های ما DOM node ایی ندارند پس لازم هست دو قسمت تغییر داشته باشیم.
اول، برای پیدا کردن والد DOM node نیاز هست اونقدر از درخت fiber بالا بریم تا یک fiber دارای DOM node پیدا کنیم:
function commitWork(fiber) {
if (!fiber) {
return
}
- const domParent = fiber.parent.dom
+ let domParentFiber = fiber.parent
+ while (!domParentFiber.dom) {
+ domParentFiber = domParentFiber.parent
+ }
+ const domParent = domParentFiber.dom
if (
fiber.effectTag === "PLACEMENT" &&
fiber.dom != null
) {
domParent.appendChild(fiber.dom)
} else if (
fiber.effectTag === "UPDATE" &&
fiber.dom != null
) {
updateDom(
fiber.dom,
fiber.alternate.props,
fiber.props
)
} else if (fiber.effectTag === "DELETION") {
domParent.removeChild(fiber.dom)
}
commitWork(fiber.child)
commitWork(fiber.sibling)
}
و در زمانی که داریم یک node رو حذف میکنیم، همچنین لازم هست دنبال فرزندی باشیم که DOM node دارد:
function commitWork(fiber) {
if (!fiber) {
return
}
let domParentFiber = fiber.parent
while (!domParentFiber.dom) {
domParentFiber = domParentFiber.parent
}
const domParent = domParentFiber.dom
if (
fiber.effectTag === "PLACEMENT" &&
fiber.dom != null
) {
domParent.appendChild(fiber.dom)
} else if (
fiber.effectTag === "UPDATE" &&
fiber.dom != null
) {
updateDom(
fiber.dom,
fiber.alternate.props,
fiber.props
)
} else if (fiber.effectTag === "DELETION") {
- domParent.removeChild(fiber.dom)
+ commitDeletion(fiber, domParent)
}
commitWork(fiber.child)
commitWork(fiber.sibling)
}
+ function commitDeletion(fiber, domParent) {
+ if (fiber.dom) {
+ domParent.removeChild(fiber.dom)
+ } else {
+ commitDeletion(fiber.child, domParent)
+ }
+ }
قدم هشتم: Hooks
داخل قدم آخر میخوایم استیت هم اضافه کنیم!
بیاین بریم سراغ مثال کلاسیک counter. هر بار که کلیک انجام بشه، مقدار استیت یکی اضافه میشود. توجه داشته باشید که ازDidact.useState
برای دریافت و آپدیت counter استفاده میکنیم.
const Didact = {
createElement,
render,
useState,
}
/** @jsx Didact.createElement */
function Counter() {
const [state, setState] = Didact.useState(1)
return (
<h1 onClick={() => setState(c => c + 1)}>
Count: {state}
</h1>
)
}
const element = <Counter />
const container = document.getElementById("root")
Didact.render(element, container)
طبق مثالی که داشتیم در اینجا تابع Counter
صدا زده میشه و داخل این تابع هم ما useState
رو صدا زدیم:
function updateFunctionComponent(fiber) {
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
+ function useState(initial) {
+ // TODO
+ }
ابتدا لازم هست چند تا متغیر گلوبال تعریف کنیم تا بتونیم داخل تابع useState
استفاده کنیم. اول متغیر work in progress fiber رو اضافه میکنیم. همچنین آرایه hooks
رو به fiber اضافه میکنیم برای اینکه این قابلیت رو داشته باشیم useState
رو چندین بار توی یک کامپوننت صدا بزنیم و حواسمون به index هوک فعلی باشه.
let wipFiber = null
let hookIndex = null
function updateFunctionComponent(fiber) {
wipFiber = fiber
hookIndex = 0
wipFiber.hooks = []
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
function useState(initial) {
// TODO
}
زمانی که فانکشن کامپوننت میاد useState
رو صدا میزنه، باید چک کنیم که ببینم هوک قدیمی داریم یا نه. برای اینکار از طریق index هوک، موجود داخل فیلد alternate
درون fiber اینکار رو انجام میدیم.
اگر هوک قدیمی داشتیم، استیت رو از هوک قدیمی به هوک جدید کپی میکنیم، اگر هم هوک جدید بود استیت رو مقداردهی اولیه میکنیم. بعد یک هوک جدید به fiber اضافه میکنیم، مقدار index هوک رو هم یک واحد اضافه میکنیم و استیت رو برمیگردونیم.
function useState(initial) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex]
const hook = {
state: oldHook ? oldHook.state : initial,
}
wipFiber.hooks.push(hook)
hookIndex++
return [hook.state]
}
useState
همچنین یک تابع برمیگردونه تا مقدار استیت آپدیت بشه، پس یک تابع تعریف میکنیم که به عنوان ورودی action
قبول میکنه (برای مثال ما این action در واقع تابعی هست که مقدار استیت رو یکی یکی اضافه میکنه). ما این action
رو به queue داخل هوک پوش میکنیم و باید کاری شبیه به تابع render
انجام بدیم، یک wipRoot رو به عنوان کار بعدی (nextUnitOfWork) ست میکنیم تا تابع work loop بتونه فاز رندر جدید رو شروع کنه.
function useState(initial) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex]
const hook = {
state: oldHook ? oldHook.state : initial,
+ queue: [],
}
+ const setState = action => {
+ hook.queue.push(action)
+ wipRoot = {
+ dom: currentRoot.dom,
+ props: currentRoot.props,
+ alternate: currentRoot,
+ }
+ nextUnitOfWork = wipRoot
+ deletions = []
}
wipFiber.hooks.push(hook)
hookIndex++
return [hook.state, setState]
}
حالا بنظرتون کجا باید action
ها رو اجرا کنیم؟
توی رندر بعدی کامپوننت اینکار رو انجام میدیم، تمام action
ها رو از queue هوک قبلی میگیریم، و دونه به دونه روی استیت هوک جدید اعمال میکنیم، در نتیجه زمانی که استیت رو برمیگردونیم آپدیت شده است.
function useState(initial) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex];
const hook = {
state: oldHook ? oldHook.state : initial,
queue: []
};
+ const actions = oldHook ? oldHook.queue : [];
+ actions.forEach(action => {
+ hook.state = action(hook.state);
+ });
const setState = action => {
hook.queue.push(action);
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot
};
nextUnitOfWork = wipRoot;
deletions = [];
};
wipFiber.hooks.push(hook);
hookIndex++;
return [hook.state, setState];
}
اینم هم ورژن ریکت خودمون که باهمدیگه ساختیمش:
امیدوارم این مجموعه کمک کرده باشه بهتون که به این درک برسید که ریکت چطوری کار میکنه. این رو هم در نظر بگیرید که داخل این نسخه از تمام بهینه سازی های ریکت صرف نظر کردیم و فقط اصل موضوع رو پیش رفتیم تا بتونیم به درک عمیق برسیم.
خوشحال میشم اگه انتقادی یا پیشنهادی دارید برام بنویسید تا کمک کنه برای بهتر شدن محتوا ❤️
منبع:
این پست برات مفید بود؟
0
0
0
0
نظرات