动手实现简单版本React
实现一个简单的 React
通过动手实现一个简单的 React 来进行深入学习
准备
为了简单起见这里使用 create-react-app 创建一个 js 项目, 之后将代码写在一个 index.js 文件中。
1 | import React from "react"; |
上面就是 React 最简单的例子, 有一个名为 App 的 React Element, 有一个 id 为 root 的 DOM 节点, 最终由 react-dom 的 render 方法渲染出来,这里先不考虑新的渲染 API。
1 | // babel 会将 |
第一版
createElement
babel 会将 jsx 转化为实际的函数,所以首先需要实现一个 createElement 函数来做同样的方法, createElement 由三个参数, 第一个参数是组件, 或者 HTML 的 tag 名称, 第二个参数是 props, 第三个参数是 children
1 | function createElement(type, props, children) { |
先实现一个简单的 createElement 函数
之后尝试渲染一下, 首先在项目根目录新建一个.env
文件
并输入
1 | DISABLE_NEW_JSX_TRANSFORM = true |
然后重新启动项目, 这一个环境变量会阻止 babel 自动引入 react, 并允许使用注释来决定 jsx 渲染
1 | import ReactDOM from "react-dom"; |
启动项目,不出意外的话会得到一个报错, 因为 React 内部会检查返回的 jsx 对象, 并判断类型, 如果是 object 类型会判断是否存在 $$typeof 属性, 如果不存在会报错。
那么需要我们自己实现一个 render 函数
render
已经知道 createElement 只是返回一个对象, 那么我们的 render 就需要根据这个对象来生成 DOM,并显示在页面之上。
1 | function createElement(type, props, children) { |
这样就可以简单的在页面上显示一段文字。 但是仅仅显示一行文字是不够的,在实际中 jsx 都是多层嵌套的, 例如
1 | const App = ( |
第二版
createElement
1 | const App = ( |
这里可以修改一下 createElement
1 | function createElement(type, props, ...children) { |
通过使用 … 剩余参数 , 将所有参数收集成为一个数组。这样无论有多少子元素,都可以会在一个数组里。为了方便 render 函数处理, 这里需要特殊识别一下文本元素。
1 | function createElement(type, props, ...children) { |
这里判断如果 child 类型是 字符串 那么我们将调用 createTextElement 创建要给对象
1 | function createTextElement(text) { |
通过这个对象,我们就可以通过同一种方法来识别元素每个元素都是将是一个对象
1 | const App = ( |
接下来我们的 render 函数只需要遍历这个结构即可
render
1 | function render(jsxElement, container) { |
render 函数还要支持更多的 dom 属性
1 | const App = ( |
支持 className style 和 一些事件
1 | function render(jsxElement, container) { |
至此我们已经可以将 jsx 渲染为实际 dom 的两个函数, 可以看到这里 render 是使用了递归进行操作的, 递归函数开始之后就不能停止,在完全渲染完成之前,会一直占用 JavaScript 线程,如果节点足够多,用户就会感觉到 卡顿 因为 js 线程被用来递归, 从而不会相应用户的任何操作。
下面是全部代码
1 | function createTextElement(text) { |
第三版
为了防止递归长时间占用线程,需要将整个渲染工作拆分成若干小的工作,每次浏览器空闲的时候都进行我们的工作。
React 解决这个问题的办法是创建了一种 fiber 数据结构,这种结构可以方便的链接父节点子节点兄弟节点,这样每个工作单元只处理个节点,然后下次在处理子节点, 如果没有子节点那么看看是否有兄弟节点,有的话就处理兄弟节点。
首先将创建 DOM 的部分独立出来, 将原来 render 函数内的部分转移到 createDOM 函数内
1 | function createDOM(fiber) { |
这个函数将根据 fiber 节点来创建 dom。
1 | // 这个变量用来储存将要处理的节点 |
1 | function workLoop(deadline) { |
1 | function performUnitOfWork(fiber) { |
1 | function commitWork(fiber) { |
这里已经可以通过 fiber 对象来渲染节点,并且使用递归。 下面是完整代码
1 | function createTextElement(text) { |
第四版
目前为止已经可以正常显示, 现在处理新增或者删除。
1 | let nextUnitOfWork = null; |
这里新增一个变量用来储存上一次渲染的 fiber 对象, 并且为为 fiber 对象添加一个 alternate 属性,这个属性是上一次渲染 dom 的 fiber 对象
新增一个 reconcileChildren 方法来处理旧的 fiber 和新的元素
1 | function performUnitOfWork(fiber) { |
然后实现 reconcileChildren 函数
1 | function reconcileChildren(fiber, elements) { |
可以看出我们通过对比前后 props 来给 fiber 新增了一个 effectTag 属性,来标记这个 element 应该要做什么。 然后使用 alternate 来储存当前渲染的 fiber 对象
1 | function commitWork(fiber) { |
这样我们就可以根据不同的状态来做不同的事情。
接下来实现 updateDOM 函数
1 | // 帮助函数 |
1 | function updateDOM(dom, prevProps, nextProps) { |
完整代码如下
1 | function createTextElement(text) { |
下一步需要支持函数组件
第五版
首先我们将
1 | const App = ( |
改为
1 | const App = () => { |
然后代码就报错了。 所以需要一个措施来识别是一个函数还是一个 jsx 对象
1 | /** @jsx toyReact.createElement */ |
1 | function performUnitOfWork(fiber) { |
1 | // 如果是函数那么吧函数的返回值拿出来当作children |
1 | function commitDeletion(fiber, domParent) { |
最后是全体代码
1 | function createTextElement(text) { |
第六版
目前已经支持函数组建了,那么最后一步就是支持 hooks
1 | /** @jsx toyReact.createElement */ |
这里需要实现一个 useState 函数
1 | let hookIndex = null; |
可以看出, hooks 也是存在 fiber 对象内,因为使用了 index 来确定下标所以 hooks 不允许在 if 内部生命, 因为 hooks 依赖于其出现的顺序
最后是所有代码
1 | function createTextElement(text) { |
那么看下来和真正的 react 区别在哪里, 首先 react 会通过 key 之类的来跳过未发生变化的节点,而 toyReact 会遍历整棵树, react 使用了事件代理和事件合成, 而 toyreact 使用的是浏览器的原生事件, react 使用了自己写的调度器等