实现一个 Mini React:核心功能详解 - 自己实现 lazy 懒加载组件的实现,以及Suspense, 从底层理解运行机制

159 阅读5分钟

xdm,又要到饭了,又更新代码了!

总结一下上一篇完成的内容,

  1. 完成了useRef的实现, 从底层理解运行机制。

有兴趣的可以点这里查看useRef的实现, 从底层理解运行机制

这一章节我们实现一个性能相关的api, lazy。

在 React 中,React.lazy 允许你使用代码分割(Code Splitting)来动态加载组件。它接收一个返回 Promise 的函数,这个 Promise 解析为一个组件。当组件真正需要渲染时,React 会自动加载它。

使用 React.lazy 的好处是:

  • 减少初始加载时间:只加载当前页面需要的组件。
  • 按需加载:当组件被需要时才加载,优化性能。

2. 实现思路

实现类似 React.lazy 的功能,需要:

  1. 创建一个 lazy 函数:它接受一个加载组件的函数,并返回一个特殊的对象或标识,告诉渲染器这是一个懒加载的组件。
  2. 修改渲染逻辑:在渲染阶段,识别懒加载组件。如果组件尚未加载,渲染一个占位符(例如 null 或一个自定义的加载中组件)。当组件加载完成后,触发重新渲染。
  3. 处理异步加载:当懒加载组件的 Promise 解析后,需要更新组件并重新渲染。

3. 实现 lazy 函数

function lazy(loadComponent) {
    return {
        $$typeof: Symbol.for('react.lazy'),
        _load: loadComponent, 
        _loaded: null, // 存储已加载组件
        _error: null, // 如果加载有错误,则存储错误信息
        _promise: null // 存储加载中的promise, 即懒加载使用promise进行异步懒加载
    }
}

解释:

  • $$typeof:用于标识这是一个懒加载组件。
  • _load:加载组件的函数,返回一个 Promise。
  • _loaded:存储已加载的组件。
  • _error:存储加载错误信息。
  • _promise:存储加载中的 Promise,避免重复加载。

4. 修改渲染逻辑

修改渲染代码之前,先需要更新一下之前的fiber 的数据结构

class FiberNode {
    constructor(type, props, parent = null, sibling = null, child = null) {
        this.type = type; // 组件类型
        this.props = props; // 组件属性
        this.parent = parent; // 父节点
        this.sibling = sibling; // 兄弟节点
        this.child = child; // 子节点
        this.effectTag = null; // 标记需要执行的操作(如更新)
        this.hooks = []; // 组件的 Hook 列表
        this.currentHook = 0; // 当前 Hook 的索引
        this.alternate = null; // 交替的 Fiber
        this.stateNode = null; // 真实 DOM 节点
        this.effects = []; // 存储副作用的列表
        this.contexts = new Map(); // 存储上下文值的 Map
        this.isSuspense = false; // 是否为 Suspense 组件
        this.fallback = null; // fallback UI
        this.promise = null; // 悬挂的 Promise
    }
}

懒加载的过程在渲染阶段,所以需要渲染阶段中处理懒加载组件。这个需要修改一下我们之前几篇实现的 performUnitOfWork函数,即检测到懒加载组件时,进行相应处理。

let workInProgress // current fiber
function performUnitOfWork(fiber) {
    workInProgress = fiber

    if (fiber.type === 'ROOT') {
        const children = fiber.props.children
        reconcileChildren(fiber, children)
    } else if (fiber.type === 'Suspense') {
        const { fallback, children } = fiber.props
        try {
            // 尝试渲染 children
            reconcileChildren(fiber, children)
        } catch (promise) {
            if (promise instanceof Promise) {
                // 记录挂起状态和后备 UI
                fiber.fallback = fallback
                fiber.promise = promise

                // 启动加载过程
                promise.then(() => {
                    // 组件加载完成后,重新触发渲染
                    requestHostCallback(workLoop)
                }).catch(() => {
                    // 加载失败,可以在此处理错误状态
                    requestHostCallback(workLoop)
                })

                // 渲染 fallback UI
                reconcileChildren(fiber, [fallback])
            }
        }
    } else if (typeof fiber.type === 'function') {
        // 检查是否为懒加载组件
        if (fiber.type.$$typeof === 'LAZY_COMPONENT') {
            const lazyComponent = fiber.type
            if (lazyComponent._loaded) {
                fiber.type = lazyComponent._loaded
                const component = fiber.type
                const props = fiber.props
                workInProgress.hooks = []
                workInProgress.currentHook = 0
                workInProgress.effects = []

                const children = component(props)
                reconcileChildren(fiber, children)
            } else if (lazyComponent._error) {
                // 渲染错误状态
                reconcileChildren(fiber, null) // 或者渲染一个错误组件
            } else {
                // 开始加载组件
                if (!lazyComponent._promise) {
                    lazyComponent._promise = lazyComponent._load()
                        .then(module => {
                            lazyComponent._loaded = module.default || module
                            requestHostCallback(workLoop)
                        })
                        .catch(error => {
                            lazyComponent._error = error
                            requestHostCallback(workLoop)
                        })
                }
                // 渲染占位符或后备 UI
                reconcileChildren(fiber, null) // 在 Suspense 中会渲染 fallback
            }
        } else {
            const component = fiber.type
            const props = fiber.props
            workInProgress.hooks = []
            workInProgress.currentHook = 0
            workInProgress.effects = []
            const children = component(props)
            reconcileChildren(fiber, children)
        }
    } else {
        reconcileChildren(fiber, fiber.props.children)
    }

    if (fiber.child) { 
        return fiber.child; 
    } 
    let nextFiber = fiber; 
    while (nextFiber) { 
        if (nextFiber.sibling) { 
            return nextFiber.sibling; 
        } 
        nextFiber = nextFiber.parent; 
    } 
    return null;
}


// 模拟 import() 函数,因为我们在浏览器中运行,可以使用全局变量模拟
function importComponent(url) {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = url;
    script.onload = () => {
      // 假设组件被挂载到 window 对象上
      resolve({ default: window.HeavyComponent });
    };
    script.onerror = reject;
    document.body.appendChild(script);
  });
}

// 使用 lazy 加载组件
const LazyHeavyComponent = lazy(() => importComponent('HeavyComponent.js'));

解释:

  • 检测懒加载组件:如果 fiber.type.$$typeof === 'LAZY_COMPONENT',则处理为懒加载组件。

  • 组件已加载:将 fiber.type 更新为加载的组件,继续正常渲染流程。

  • 组件加载中:如果组件尚未加载,开始加载并存储 Promise,避免重复加载。

  • 组件加载完成后:在 Promise 的 then 中,更新已加载的组件,并触发重新渲染。

  • 组件加载失败:在 Promise 的 catch 中,存储错误信息,并触发重新渲染。

  • 模拟 import() :由于在浏览器环境下,我们可以通过动态加载脚本的方式模拟 import()

  • 挂载组件到全局对象:在加载的脚本 HeavyComponent.js 中,将组件挂载到 window 对象上。

5. 提供占位符组件 (Suspense 组件)

上面的代码在懒加载组件加载期间,显示一个占位符,react也提供一个suspense 组件,接受一个fallback prop,用户可以自定义在异步加载组件加载没有完成的时机显示自定义内容。上面的代码里面已经对这样的fiber进行了标记 fiber.isSuspense = true, 那么根据这个来实现一下lazy 配合 suspense 的实现。

如果你已经阅读了之前的如何实现fiber架构的文章,那么就会知道渲染的过程基本可以分成2个步骤,即 rendering 以及 commit。

既然需要渲染fallback 内容,那么肯定需要在commit阶段完成(ui),所以我们需要改进一下前几篇完成的 commitWork 函数,让它支持渲染suspense 组件。

function hasSuspendChildre(fiber) {
    let child = fiber.child
    
    while(child) {
        if (child.isSuspense) return true
        
        child = child.sibling
    }
    return false
} 
function commitWork(fiber) {
   let currentFiber = fiber
   
   while(currentFiber !== null) {
       // 处理 Suspense 的 fallback UI 
       if (currentFiber.type === 'Suspense') { 
           // 如果 Suspense 有挂起的 Promise,确保渲染了 fallback 
           if (currentFiber.promise) { 
               // 已在渲染阶段渲染了 fallback,无需额外处理 
           } 
       }
       
       // ... 其他代码保持不变
   }
}

进一步理解

  • 懒加载的核心在于组件的加载与渲染解耦
  • 由于懒加载组件的加载是异步的,需要确保在加载完成后触发重新渲染。
  • 渲染器负责决定何时需要组件,当需要时才触发加载
  • 代码中的体现主要在渲染流程中对懒加载组件的特殊处理

这篇我们尝试实现了一个lazy 以及如何配合suspense,以及讲解了它的使用场景以及底层实现原理。

下一篇,我们继续从0 - 1完成一个性能相关的api, memo。

如果文章对你有帮助,请点个赞支持一下!

啥也不是,散会。