logo
ریکت خودت رو بساز - قسمت سوم
ریکت

ریکت

11 مهر 1402

سلام دوستان امیدوارم خوب باشید و همیشه درحال یادگیری🧑‍💻. توی قسمت سوم و آخر از مجموعه ریکت خودت رو بساز میخوایم سه ویژگی مهم و اساسی رو به ریکت خودمون اضافه کنیم:

  1. Reconciliation
  2. Function Components
  3. 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)

فانکشن کامپوننت ها از دو جهت تفاوت دارند:

  1. fiber ایی که از فانکشن کامپوننت ها ایجاد میشه، DOM node ندارند.
  2. و بجای اینکه فرزندان از طریق 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];
}

اینم هم ورژن ریکت خودمون که باهمدیگه ساختیمش:




امیدوارم این مجموعه کمک کرده باشه بهتون که به این درک برسید که ریکت چطوری کار می‌کنه. این رو هم در نظر بگیرید که داخل این نسخه از تمام بهینه سازی های ریکت صرف نظر کردیم و فقط اصل موضوع رو پیش رفتیم تا بتونیم به درک عمیق برسیم.

خوشحال میشم اگه انتقادی یا پیشنهادی دارید برام بنویسید تا کمک کنه برای بهتر شدن محتوا ❤️

منبع:

https://pomb.us/build-your-own-react/

این پست برات مفید بود؟

0

heart

0

like

0

happy

0

sad