记(实现一个mini-react)

准备工作
需要用到的模板文件的仓库地址
1. JSX
先看看jsx语法,做了什么事情 babel.js
记(实现一个mini-react)
文章图片

可以看到,这些jsx语法,经过babel转译后,最终调用了React.createElement,其需要三个参数type, props, children。其返回值就是virtual DOM对象。也就是说,我们可以使用babel将我们的jsx代码,转换成虚拟DOM, 但是我们需要实现一个自己的createElement方法
2. 项目配置
查看仓库地址,可以直接获取到模板文件。这里主要介绍一下我们的.babelrc中如何配置,帮助我们解析jsx代码,并自动的调用我们自己写的createElement方法
可以看看babel官网 是如何配置react的。
记(实现一个mini-react)
文章图片

presets中配置@babel/prset-react,我们将使用他来转换我们代码中的jsx代码。想想上面的代码,我们写的函数式组件,或者jsx代码,都被转换成了React.createElement代码,所以我们借助babel就可以实现我们自定义的createElement功能

{ "presets": [ "@babel/preset-env", [ "@babel/preset-react", { "pragma": "MyReact.createElement" //默认的pragma就是 React.createElement,也就是说,我们要实现一个我们的MyReact.createElement, 那么就需要在这里写成MyReact.createElement (only in classic runtime) } ] ]}

3. Virtual DOM
3.1 什么是Virtual DOM 使用javascript对象来描述真实的dom对象,其结构一般就是这样
vdom = { type: '', props: {} // 属性对象 children: [] // 子元素,子组件 }

3.2 创建Virtual DOM 3.2.1 实现一个createElement方法 在我们的模板文件中, 已经使用webpack配置好了代码的入口文件,安装好依赖,然后将项目运行起来,这时候浏览器啥都没有发生。解析jsx的工作也都有babel帮我们完成了
如果你出现了这种情况,那么请自行更改webpack中devserver的端口号
记(实现一个mini-react)
文章图片

这是我们的项目目录结构:
记(实现一个mini-react)
文章图片

同时,也可以看看我们项目的目录结构, 这里我已经添加了一个createElement.js的文件,我们将在这个文件中,实现将jsx代码,转换为virtual DOM对象。
上面我们提到过,React.createElement会接收三个参数type, props, children,然后会自动的将jsx代码转换成下面这个类型,因此我们需要做的就是提供这么一个方法,接收这三个参数,然后在将其组装成我们想要的对象。
vdom = { type: '', props: {} // 属性对象 children: [] // 子元素,子组件 }

  1. 首先在MyReact文件夹下创建createDOMElement.js,他的结构我们上面提到过,接收三个参数,并且返回一个vdom的对象
    export default function createElement(type, props, ...children) { return { type, props, children } }

  2. 创建好了createElement方法,那么我们需要往外暴露,因此在MyReact/index.js中,我们将其暴露出来
    // MyReact/index.js import createElement from './createElement' export default { createElement, }

  3. 然后我们在入口文件, 引入我们的MyReact,同时写一段jsx的代码,看看能不能符合我们的预期
    // index.jsimport MyReact from "./MyReact" // 按照react的使用方法,这里我们先引入我们自定义的MyReact 此处的MyReact会将jsx语法 通过调用MyReact.createElement(),然后返回我们所需要的VDOM // 这个是在.babelrc配置的 const virtualDOM = ( 你好 Tiny React (我是文本) 嵌套1 嵌套 1.1 (观察: 这个将会被改变)
    {2 == 1 && 如果2和1相等渲染当前内容} {2 == 2 && 2} 这是一段内容 这个将会被删除
    2, 3 ) console.log(virtualDOM)

    看看打印结果,是不是我们的预期
    记(实现一个mini-react)
    文章图片

    bingo,确实是我们想要的。这里大家可以看到
  • children中,有些节点是一个boolean,还有就是我们可能节点就是个null, 不需要转换
  • children中,有些节点直接就是文本,需要转换文文本节点
  • props中,需要可以访问children节点
    上述两个特殊情况都没有被正确的转换成vDOM,因此我们接下来需要做的就是,对children节点,在进行一次createElement的操作。
    3.2.2 改进createElement方法上面我们说到,我们需要递归的调用createElement方法去生成vDOM。根据上面三个问题,我们可以作如下改进
    export default function createElement(type, props, ...children) { // 1.循环children对象进行对象转换,如果是对象,那么在调用一次createElement方法,将其转换为虚拟dom,否则直接返回,因为他就是普通节点. const childrenElements = [].concat(...children).reduce((result, child) => { // 2.节点中还有表达式, 不能渲染 null节点 boolean类型的节点, 因此我们这里用的reduce,而不是用map if (child !== true && child !== false && child !== null) { // child已经是对象了,那么直接放进result中 if (child instanceof Object) { result.push(child) } else { // 如果他是文本节点,则直接转换为为本节点 result.push(createElement("text", { textContent: child })) } } return result }, []) // 3. props 可以访问children节点, return { type, props: Object.assign({ children: childrenElements }, props), // hebing Props children: childrenElements } }

现在再看看我们的输出, 可以看到,之前我们children中有false的,以及纯文本的节点,都被正确的处理了,到这里,我们的createElement就结束了
记(实现一个mini-react)
文章图片

3.3 实现render方法 3.3.1 render方法 首先在MyReact文件夹下创建render.js
在render中,我们还要一个diff方法,diff算法就是保证视图只更新变动的部分,需要将新旧dom进行对比(vDOM, oldDOM),然后更新更改部分的dom(container)。我们先写一个diff方法,实际的算法,我们留在后面来补充。
// MyReact/render.jsimport diff from "./diff" export default function render(vDOM, container, oldDOM) { // diff算法 diff(vDOM, container, oldDOM) }

【记(实现一个mini-react)】然后,我们在MyReact/index.js将render方法进行导出。
import createElement from './createElement' import render from './render' export default { createElement, render }

3.3.2 diff方法 刚刚的分析,我们可以知晓,这个diff算法是需要三个参数的,newDom, container, oldDom, 在这里,我们需要做的是就是对比新旧dom,这里,我们需要一个方法,用来创建元素,于是我们现在又需要一个 mountElement方法,于是创建文件mountElement.js,用于创建元素。
// MyReact/diff.js import mountElement from "./mountElement"export default function diff(vDOM, container, oldDOM) { // 判断oldDOM是否存在 if (!oldDOM) { // 创建元素 mountElement(vDOM, container) } }

3.3.3 mountElement方法 我们的元素,需要区分原生dom元素还是组件。组件分为class组件,以及函数组件。在这我们先把原生dom进行渲染。
  • mountElement
    • mountNativeElement
    • mountComponentElement
      • class组件
      • 函数组件
// MyReact/mountElement.jsexport default function mountElement(vDOM, container) { // 此处需要区分原生dom元素还是组件,如何区分? 这个逻辑我们后面再补充 mountNativeElement(vDOM, container) }

3.3.4 mountNativeElement方法 在这个方法中,我们需要将virtual DOM转成真正的DOM节点,在这里,我们借助一个方法,来创建真实DOM元素,然后再将其append到容器中。
// MyReact/mountNativeElement.js import createDOMElement from "./createDOMElement" /** * 渲染vdom到指定节点 * @param {*} vDOM * @param {*} container */ export default function mountNativeElement(vDOM, container) { let newElement= createDOMElement(vDOM) container.appendChild(newElement) }

下面我们来实现这个createDOMElement方法,因为后续我们也会用到,所以把它作为一个公共的函数,方便其他地方使用。
这个方法,我们需要做下面的几件事情。
  1. 将传进来的vDOM创建成html元素
  2. 创建html元素 又分为两种情况, 纯文本节点,还是元素节点
  3. 递归创建子节点的html元素
    // MyReact/createDOMElement.js import mountElement from "./mountElement" /** * 创建虚拟dom * @param {*} vDOM * @returns */ export default function createDOMElement(vDOM) { let newElement = null// 1. 渲染文本节点, 根据我们之前处理的,纯文本节点,通过text去标记, 值就是props中的textContent if (vDOM.type === 'text') { newElement = document.createTextNode(vDOM.props.textContent) } else { // 2.渲染元素节点 newElement = document.createElement(vDOM.type) // type 就是html元素类型 div input p这些标签等等 // 注意,这里我们只渲染了节点,并没有将props的属性,放在html标签上,这个我们后面在进行 } // 以上步骤仅仅只是创建了根节点,还需要递归创建子节点 vDOM.children.forEach(child => { // 将其放置在父节点上, 由于不确定当前子节点是组件还是普通vDOM,因此我们再次调用mountElement方法,当前的节点容器,就是newElement mountElement(child, newElement) }) return newElement }

    代码已经就绪,我们去浏览器看看有没有什么变化, 这个时候,你的浏览器应该长这样了。然后我们再来分析下,我们还缺什么?
    记(实现一个mini-react)
    文章图片

3.3.5 更新节点属性的方法(updateNodeElement) 我们现在已经实现了将jsx的代码,渲染到了页面上。但是现在看看我们的虚拟DOM的结构。跟我们的预期还缺少了下面的东西
  1. className没有被渲染为 class
  2. data-test type value等这些原生属性没有被添加到对应的标签上
  3. button的响应事件
接下来,我们就去实现这个updateNodeElement方法。
还是先创建MyReact/updateNodeElement.js这个文件。思考一个问题,我们什么时候调用这个方法来更新node的属性呢?
在上面 3.3.4中,我们在进行更新节点的步骤,因此更新node节点的属性,也需要在那里进行
然后可以肯定的是,我们需要两个参数,一个是容器container,一个是我们的虚拟DOM,这样才能确定一个完整的element.
接下来的工作就是要把props属性,依次的赋值给html。回忆一下,如何设置html的属性?我们使用 element.setAttribute('prop', value)来实现.
明确了如何更新html上面的属性,接下来来分析下,我们要处理哪些属性,和事件
首先我们需要遍历当前vDOM的props属性,根据键值确定使用何种设置属性的方式
  1. 事件绑定:我们绑定事件都是以on开头,类似这样 onClick;
  2. value checked这样的属性值,就不能使用setAttribute方法了,想想我们使用原生dom的时候,对于输入框这样的value值,我们是直接用的input.value设置输入框的值;
  3. children属性,这是我们之前手动添加的节点属性,因此,我们要把children给他剔除;
  4. className属性,这个需要我们将其改为 class,剩下的属性,就可以直接使用键来设置了
    代码实现如下:
    // MyReact/updateNodeElement.js export default function updateNodeElement (newElement, vDOM) { const { props } = vDOM // 遍历vdom上的key,获取每个prop的值, Object.keys(props).forEach(key => { const currentProp = props[key] // 如果是以on开头的,那么就认为是事件属性,因此我们需要给他注册一个事件 onClick -> click if (key.startsWith('on')) { // 由于事件都是驼峰命名的,因此,我们需要将其转换为小写,然后取最后事件名称 const eventName = key.toLowerCase().slice(2) // 为当前元素添加事件处理函数 newElement.addEventListener(eventName, currentProp) } else if (key === 'value' || key === 'checked') { // input 中的属性值 newElement[key] = currentProp } else if (key !== 'children') { // 抛开children属性, 因为这个是他的子节点, 这里需要区分className和其他属性 newElement.setAttribute(key === 'className' ? 'class' : key, currentProp) } }) }

    接下来,我们找到createDOMElement.js文件,我们需要在渲染元素节点后,更新他的属性值
    // MyReact/createDOMElement.js import mountElement from "./mountElement" /** * 创建虚拟dom * @param {*} vDOM * @returns */ export default function createDOMElement(vDOM) { let newElement = null// 1. 渲染文本节点, 根据我们之前处理的,纯文本节点,通过text去标记, 值就是props中的textContent if (vDOM.type === 'text') { newElement = document.createTextNode(vDOM.props.textContent) } else { // 2.渲染元素节点 newElement = document.createElement(vDOM.type) // type 就是html元素类型 div input p这些标签等等 // 更新dom元素的属性,事件等等 updateNodeElement(newElement, vDOM) } // 以上步骤仅仅只是创建了根节点,还需要递归创建子节点 vDOM.children.forEach(child => { // 将其放置在父节点上, 由于不确定当前子节点是组件还是普通vDOM,因此我们再次调用mountElement方法,当前的节点容器,就是newElement mountElement(child, newElement) }) return newElement }

    至此,我们已经完成了属性设置,现在回到浏览器看看,我们的结果,class属性被正确的加载了元素上面,其他属性,以及事件响应,也都做好了。
    记(实现一个mini-react)
    文章图片

    记(实现一个mini-react)
    文章图片

阶段一完成!
阶段一小结
  1. jsx语法在babel加持下,可以转换成vDOM,我们使用 babel-preset-react,并配置.babelrc,可以让我们实现自定义的createElement方法。然后将jsx转换成虚拟DOM.
  2. 我们通过createElement方法生成的虚拟DOM对象,通过diff算法(本文未实现),然后进行dom的更新
  3. 虚拟dom对象需要我们对所有的节点进行真实dom的转换。
  4. 创建节点我们需要使用 element.createElement(type)创建文本节点element.createElement(type), 设置属性,我们需要用到 element.setAttribute(key, value)这个方法。
  5. 节点更新需要区分html节点,组件节点。组件节点又需要区分 class组件以及函数组件
未完待续
可以看到,我们仅仅实现了jsx代码能够被正确的渲染到页面中,我们还有很多工作未做,比如下面的这些,后续的代码更新都放在这里了。源码
  • 组件渲染函数组件、类组件
  • 组件渲染中 props的处理
  • dom元素更新时的vDOM对比,删除节点
  • setState方法
  • 实现ref属性获取dom对象数组
  • key属性的节点标记与对比

    推荐阅读