准备工作
需要用到的模板文件的仓库地址
1. JSX
先看看jsx语法,做了什么事情 babel.js
同时react的文档也写明了
实际上,JSX 仅仅只是 React.createElement(component, props, ...children) 函数的语法糖。
可以看到,这些jsx语法,经过babel转译后,最终调用了React.createElement
,其需要三个参数type, props, children
。其返回值就是virtual DOM对象。也就是说,我们可以使用babel
将我们的jsx
代码,转换成React.createElement
的调用,最后由该方法,将jsx转换为虚拟dom对象. 也就是说,jsx实际就是React.createElement
的语法糖。 现在我们就需要实现一个自己的createElement
方法
2. 项目配置
查看仓库地址,可以直接获取到模板文件。这里主要介绍一下我们的.babelrc
中如何配置,帮助我们解析jsx代码,并自动的调用我们自己写的createElement
方法
可以看看babel官网 是如何配置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的端口号
这是我们的项目目录结构:
同时,也可以看看我们项目的目录结构, 这里我已经添加了一个createElement.js
的文件,我们将在这个文件中,实现将jsx代码,转换为virtual DOM
对象。
上面我们提到过,React.createElement
会接收三个参数type, props, children
,然后会自动的将jsx
代码转换成下面这个类型,因此我们需要做的就是提供这么一个方法,接收这三个参数,然后在将其组装成我们想要的对象。
vdom = {
type: '',
props: {} // 属性对象
children: [] // 子元素,子组件
}
首先在MyReact文件夹下创建
createDOMElement.js
,他的结构我们上面提到过,接收三个参数,并且返回一个vdom的对象export default function createElement(type, props, ...children) { return { type, props, children } }
创建好了createElement方法,那么我们需要往外暴露,因此在
MyReact/index.js
中,我们将其暴露出来// MyReact/index.js import createElement from './createElement' export default { createElement, }
然后我们在入口文件, 引入我们的
MyReact
,同时写一段jsx
的代码,看看能不能符合我们的预期// index.js import MyReact from "./MyReact" // 按照react的使用方法,这里我们先引入我们自定义的MyReact 此处的MyReact会将jsx语法 通过调用MyReact.createElement(),然后返回我们所需要的VDOM // 这个是在.babelrc配置的 const virtualDOM = ( <div className="container"> <h1>你好 Tiny React</h1> <h2 data-test="test">(我是文本)</h2> <div> 嵌套1 <div>嵌套 1.1</div> </div> <h3>(观察: 这个将会被改变)</h3> {2 == 1 && <div>如果2和1相等渲染当前内容</div>} {2 == 2 && <div>2</div>} <span>这是一段内容</span> <button onClick={() => alert("你好")}>点击我</button> <h3>这个将会被删除</h3> 2, 3 <input type="text" value="13" /> </div> ) console.log(virtualDOM)
看看打印结果,是不是我们的预期
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
就结束了
3.3 实现render方法
3.3.1 render方法
首先在MyReact
文件夹下创建render.js
。
在render中,我们还要一个diff
方法,diff
算法就是保证视图只更新变动的部分,需要将新旧dom进行对比(vDOM, oldDOM),然后更新更改部分的dom(container)。我们先写一个diff
方法,实际的算法,我们留在后面来补充。
// MyReact/render.js
import diff from "./diff"
export default function render(vDOM, container, oldDOM) {
// diff算法
diff(vDOM, container, oldDOM)
}
然后,我们在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.js
export 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
方法,因为后续我们也会用到,所以把它作为一个公共的函数,方便其他地方使用。
这个方法,我们需要做下面的几件事情。
- 将传进来的
vDOM
创建成html
元素 - 创建html元素 又分为两种情况, 纯文本节点,还是元素节点
递归创建子节点的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 }
代码已经就绪,我们去浏览器看看有没有什么变化, 这个时候,你的浏览器应该长这样了。然后我们再来分析下,我们还缺什么?
3.3.5 更新节点属性的方法(updateNodeElement)
我们现在已经实现了将jsx
的代码,渲染到了页面上。但是现在看看我们的虚拟DOM的结构。跟我们的预期还缺少了下面的东西
className
没有被渲染为 classdata-test
type
value
等这些原生属性没有被添加到对应的标签上button
的响应事件
接下来,我们就去实现这个updateNodeElement
方法。
还是先创建MyReact/updateNodeElement.js
这个文件。思考一个问题,我们什么时候调用这个方法来更新node的属性呢?
在上面 3.3.4中,我们在进行更新节点的步骤,因此更新node节点的属性,也需要在那里进行
然后可以肯定的是,我们需要两个参数,一个是容器container
,一个是我们的虚拟DOM
,这样才能确定一个完整的element
.
接下来的工作就是要把props属性,依次的赋值给html。回忆一下,如何设置html的属性?我们使用 element.setAttribute('prop', value)
来实现.
明确了如何更新html上面的属性,接下来来分析下,我们要处理哪些属性,和事件
首先我们需要遍历当前vDOM的props属性,根据键值
确定使用何种设置属性的方式
- 事件绑定:我们绑定事件都是以on开头,类似这样 onClick;
value
checked
这样的属性值,就不能使用setAttribute
方法了,想想我们使用原生dom的时候,对于输入框这样的value值,我们是直接用的input.value
设置输入框的值;children
属性,这是我们之前手动添加的节点属性,因此,我们要把children
给他剔除;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
属性被正确的加载了元素上面,其他属性,以及事件响应,也都做好了。
阶段一完成!
阶段一小结
jsx
语法在babel
加持下,可以转换成vDOM
,我们使用babel-preset-react
,并配置.babelrc
,可以让我们实现自定义的createElement
方法。然后将jsx
转换成虚拟DOM
.- 我们通过
createElement
方法生成的虚拟DOM对象,通过diff
算法(本文未实现),然后进行dom
的更新 - 虚拟dom对象需要我们对所有的节点进行真实dom的转换。
- 创建节点我们需要使用
element.createElement(type)
创建文本节点element.createElement(type)
, 设置属性,我们需要用到element.setAttribute(key, value)
这个方法。 - 节点更新需要区分html节点,组件节点。组件节点又需要区分 class组件以及函数组件
未完待续
可以看到,我们仅仅实现了jsx
代码能够被正确的渲染到页面中,我们还有很多工作未做,比如下面的这些,后续的代码更新都放在这里了。源码
- 组件渲染函数组件、类组件
- 组件渲染中 props的处理
- dom元素更新时的vDOM对比,删除节点
- setState方法
- 实现ref属性获取dom对象数组
- key属性的节点标记与对比
2021-12-31更新
4.组件渲染
上面我们说到,我们只进行了html节点的渲染,接下来我们要做的就是渲染组件。react
中,组件分为两种,一种是函数组件,一种是类组件。那么我们如何来进行区分呢?
想想我们在使用的时候是怎么使用的
// 函数组件
function Hello = function() {
return <div>Hello </div>
}
// 类组件
class World extends React.Component {
constructor(props) {
super(props)
}
render(){
return <div>Hello react</div>
}
}
看到上面的方法,我们考虑函数组件与类组件的区别。函数组件实际上就是个函数,那么我们在生成virtual DOM的时候,那他的type应该就是function
, class组件是一个类,当然他的类型也会是函数,于是我们就可以知道,class组件可以通过prototype
找到render方法,而函数组件则不存在这个方法,因此,我们需要一个方法来判断当前组件是函数组件还是类组件。
创建MyReact/isFunction.js
以及MyReact/isFunctionComponet.js
。
// isFunction.js
export default function isFunction(vDOM) {
return typeof vDOM?.type === 'function'
}
// isFunctionComponet.js
export default function isFunctionComponent(vDOM) {
const type = vDOM.type
return type && isFunction(vDOM) && !(type.prototype && type.prototype.render)
}
我们现在需要做的就是在mountElement.js
中进行组件的区分,此时我们需要新建一个方法,叫做mountComponentElement
,在这里面去区分函数组件以及类组件
// mountElement.js
import mountNativeElement from "./mountNativeElement"
import mountComponentElement from "./mountComponentElement"
import isFunction from "./isFunction"
/**
* 挂载元素
* @param {*} vDOM
* @param {*} container
*/
export default function mountElement(vDOM, container) {
// 此处需要区分原生dom元素还是组件,如何区分?
if (isFunction(vDOM)) {
mountComponentElement(vDOM, container)
} else {
mountNativeElement(vDOM, container)
}
}
4.1 函数组件的渲染
上面分析到我们需要创建一个mountComponentElement
方法,用于渲染组件元素. 我们该如何去渲染这个函数式组件呢?我们先看看调用render方法后,传入的组件vDOM是个什么情况
我们在入口index.js中写上这么一段测试代码,然后在下面看看输出
function Hello() {
return <div>Hello 123!</div>
}
function Heart() {
return (<div>
♥
<Hello />
</div>)
}
MyReact.render(<Heart />, root)
控制台可以看到,现在传入的函数组件,他的type就是一个function
, 就是我们写的那个Heart
,那既然是函数,我们就可以直接执行,然后就能获取到他返回的virtual DOM了。
这里,我们需要引入之前创建的isFunctionComponet
函数,用于区分函数组件还是类组件.然后我们在分析下,如果这个传入的组件,他还嵌入的其他的组件呢?因此,我们在调用了这个函数组件的方法后,还需要去判断,此时生成的组件是什么类型的html
functional
class
.因此,在mountComponentElement
我们这个方法中,需要递归的去调用这个方法,同时我们还要调用渲染html组件的方法。我们看来如何实现
import isFunction from "./isFunction"; // 判断是否为function
import isFunctionComponent from "./isFunctionComponent"; // 区分函数组件 类组件
import mountNativeElement from "./mountNativeElement"; // mount Native元素
//
export default function mountComponentElement (vDOM, container) {
console.log(vDOM, 'vDOM')
let nextElementVDOM = null // 申明一个变量用于保存即将生成的vDOM
// 区分class组件, 以及函数组件,如果vDOM中 有render方法,那么就是class组件,否则就是函数组件
if (isFunctionComponent(vDOM)) {
// 这里生成的可能是一个包含组件的函数式组件
nextElementVDOM = buildFunctionComponent(vDOM) // 借用一个方法,去生成函数组件的vDOM
} else {
// TODO 生成类组件vDOM
}
// 如果这个创建的elemnt还是个函数,那么就继续处理
if (isFunction(nextElementVDOM)) {
mountComponentElement(nextElementVDOM, container)
} else {
// 如果不是函数,那么就是普通的Dom元素了,直接进行渲染
mountNativeElement(nextElementVDOM, container)
}
}
/**
* build函数式组件,函数式组件直接执行,执行过后就是生成的vdom
* @param {*} vDOM
* @returns
*/
function buildFunctionComponent(vDOM) {
return vDOM.type() // 上面的分析可知,这个vDOM的type就是个函数 执行过后,返回return中的内容
}
写到这里,我们的页面上应该已经出现结果了
4.2 类组件的渲染
接下来 我们来实现类组件的渲染,同样我们也需要借助一个方法去实现这个功能.但是在这之前,我们还需要一个关键的东西。那就是Component
。因为我们使用class组件的时候,都需要 class Com extends React.Component
,因此我们还需要实现这个Component
类, 然后我们的类组件才能去继承这个类实现其他方法
新建MyReact/Component.js
,现在这里我们什么都不做,只是将这个类暴露出去
// Component.js
export default class Component {
}
在index.js中,将这个Component类导出
// MyReact/index.js
import createElement from './createElement'
import render from './render'
import Component from './Component'
export default {
createElement,
render,
Component
}
更新下我们的测试代码,index.js
文件中我们来使用Component
声明一个类组件
// index.js
class MyClass extends MyReact.Component {
constructor(props) {
super(props)
}
render() {
return <div>我是类组件-----</div>
}
}
MyReact.render(<MyClass />, root)
然后在4.1的基础上,增加buildClassComponent方法。按照我们之前做函数组件的分析,类组件生成vDOM需要执行一次实例化,然后手动的去调用render放发
import isFunction from "./isFunction"; // 判断是否为function
import isFunctionComponent from "./isFunctionComponent"; // 区分函数组件 类组件
import mountNativeElement from "./mountNativeElement"; // mount Native元素
//
export default function mountComponentElement (vDOM, container) {
console.log(vDOM, 'vDOM')
let nextElementVDOM = null // 申明一个变量用于保存即将生成的vDOM
// 区分class组件, 以及函数组件,如果vDOM中 有render方法,那么就是class组件,否则就是函数组件
if (isFunctionComponent(vDOM)) {
// 这里生成的可能是一个包含组件的函数式组件
nextElementVDOM = buildFunctionComponent(vDOM) // 借用一个方法,去生成函数组件的vDOM
} else {
// 类组件的渲染
nextElementVDOM = buildClassComponent(vDOM)
}
// 如果这个创建的elemnt还是个函数,那么就继续处理
if (isFunction(nextElementVDOM)) {
mountComponentElement(nextElementVDOM, container)
} else {
// 如果不是函数,那么就是普通的Dom元素了,直接进行渲染
mountNativeElement(nextElementVDOM, container)
}
}
/**
* build函数式组件,函数式组件直接执行,执行过后就是生成的vdom
* @param {*} vDOM
* @returns
*/
function buildFunctionComponent(vDOM) {
return vDOM.type() // 上面的分析可知,这个vDOM的type就是个函数 执行过后,返回return中的内容
}
// 新增,build类组件方法
/**
* build类组件, 需要对其进行实例化,然后手动的调用render方法
* @param {*} vDOM
* @returns
*/
function buildClassComponent(vDOM) {
const component = new vDOM.type()
return component.render()
}
到这里,我们的类组件渲染方法已经结束了,可以到浏览器中看看执行结果
4.3 组件的props渲染
在4.1 4.2
中,我们完成了组件的渲染,但是没有把props放入进去。我们回忆一下,props在react中是如何进行传递的.
// 类组件
class MyComponent extends React.Component {
constructor(props) {
super(props)
}
render() {
return <div>
<p>{this.props.age}</p>
<p>{this.props.name}</p>
<p>{this.props.gender}</p>
</div>
}
}
// 函数组件
function Heart(props) {
return (<div>
♥
{props.title}
<Hello />
</div>)
}
- 类组件
通过继承自父类的props,然后在子类中使用,因此我们需要在Component
这个类中,实现props的保存。如何去做呢,很简单,那就是将子类传递过来的props,在构造函数中保存起来,这样子类便可以访问到父类的props了。于是我们加入下面的代码到Component
类中。
接下来的问题是,我们如何将props属性,传递到类组件的构造函数中呢?也很简单,那就是在我们buildClassComponent
这个方法中,实例化了类组件,因此我们只需要在这里将props属性在类组件被实例化的时候传递过去就可以了
// Component.js
export default class Component {
constructor(props) {
this.props = props
}
}
// mountComponentElement.js
function buildClassComponent(vDOM) {
const component = new vDOM.type(vDOM.props) // 将props作为实例化是的参数传递过去
return component.render()
}
函数组件
函数组件相对更简单,只需要在调用render方法的时候,将props传递过去就可以了,因为他就是个普通函数。// mountComponentElement.js function buildFunctionComponent(vDOM) { return vDOM.type(vDOM.props) // 上面的分析可知,这个vDOM的type就是个函数 执行过后,返回return中的内容, 我们把props传递过去,在组件内部就可以获取到props的值了。 }
至此,函数组件的渲染就已经完成了。
接下来要做的就是:
- dom元素更新时的vDOM对比,删除节点
- setState方法
- 实现ref属性获取dom对象数组
- key属性的节点标记与对比
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。