相信使用react
的大家对于jsx
已经游刃有余了,可是你真的了解jsx
的原理吗?
让我们由浅入深,来一层一层揭开jsx
的真实面目。
React.createElement
在react
官方中讲到,关于jsx
语法最终会被babel
编译成为React.createElement()
方法。
我们来看看这段jsx
<div className="wang.haoyu">hello</div>
复制代码
经过babel
编译后它变成这样的代码:
React.createElement("div", {
className:'wang.haoyu'
}, "hello");
复制代码
当jsx
中存在多个节点元素时,比如:
<div>hello<span>world</span></div>
复制代码
它会将多个节点的jsx
中children
属性变成多个参数进行传递下去:
React.createElement("div", null, "hello", React.createElement("span", null, "world"));
复制代码
可以看到,外层的div
元素包裹的children
元素依次在React.createElement
中铺平排列进去,并不是树型结构排列。
需要注意的是,旧的
react
版本中,只要我们使用jsx
就需要引入react这个包。而且引入的变量必须大写React
,因为上边我们看到babel
编译完jsx
之后会寻找React
变量。
新版本中,不再需要引入
React
这个变量了。有兴趣的同学可以去看看打包后的react
代码,内部会处理成为Object(s.jsx)("div",{ children: "Hello" })
,而老的版本是React.createElement('div',null,'Hello')
。
这两种方式效果和原理是一模一样的,只是新版额外引入包去处理了引入。所以不需要单独进行引入
React
。
React
元素
React
之中元素是构建React
的最小单位,其实也就是虚拟Dom对象。
本质上jsx
执行时就是在执行函数调用,是一种工厂模式通过React.createElement
返回一个元素。
const element = <div>Hello</div>
console.log(element,'element')
复制代码
先忽略掉一些ref/key
之类的属性,这个时候来看我们发现它其实就是一个js
对象,记录了type
表示元素类型。props
表示元素的接受的prop
,注意这里会将jsx
内部标签内容插入到props
的children
属性中。
需要注意的是这里的
children
属性,如果内部标签元素存在多个子元素时候。children
会是一个数组。因为这里仅仅只有文本节点,所以只有一个Hello
。
在我们平常使用react
项目的时候,index.tsx
中总是会存在这样一段代码:
ReactDOM.render(<App />, document.getElementById('root'));
复制代码
结合上边我们所讲的React.createElement
,我们不难猜出ReactDOM.render
这个方法它的作用其实就是按照React.createElement
生成的虚拟DOM节点对象,生成真实DOM插入到对应节点中去,这就是简单的渲染过程。
元素的更新
react
中元素本身是不可变的。
比如:
const element = <h1 title="hello" >Hello</h1>
console.log(JSON.stringify(element,null,2))
复制代码
当我们想将它的内容改成world
时,如果直接通过
element.props.children = 'world'
复制代码
这样是不可以的,react
会提示:
Uncaught TypeError: Cannot assign to read only property 'children' of object '#<Object>'
复制代码
无法给一个只读属性children
进行赋值,修改其他属性比如type
之类同理也是不可以的。
当我们通过这种方式给react
元素增加属性时,也是增加的。
Cannot add property xxx, object is not extensible
复制代码
not extensible
是react
17之后才进行增加的。通过Object.freeze()
将对象进行处理元素。
需要注意
Object.freeze()
是一层浅冻结,在react
内部进行了递归Object.freeze()
。
所以在react
中元素本身是不可变的,当元素被创建后是无法修改的。只能通过重新创建一个新的元素来更新旧的元素。
你可以这样理解,在react
中每一个元素类似于动画中的每一帧,都是不可以变得。
当然在
react
更新中仅仅会更新需要更新的内容,内部会和Vue相同的方式去进行diff算法,高效更新变化的元素而不是更新重新渲染所有元素。
jsx
原理分析
需要注意我们这里使用旧的
React.createElement
方法,如果是^17
版本下,需要在环境变量中添加DISABLE_NEW_JSX_TRANSFORM=true
。
上边我们已经分析过React.createElement
这个方法的返回值,接下来我们就尝试自己来实现jsx
的渲染。
先来看看原本React中createElement
方法的返回值:
import React from 'react';
import ReactDOM from 'react-dom';
const element = (
<div className="header" style={{ color: 'red' }}>
<span>hello</span>world
</div>
);
console.log(JSON.stringify(element, null, 2), 'element');
复制代码
接下来我们就根据结果来推写法,实现一个简单的createElement
方法
实现React.crateElement
方法-原生DOM
元素的渲染
- 实现
utils/react.js
// 这里之所以额外书写一个 wrapToDom元素 是为了方便对比 react源码中没有这段方法是特殊处理的
// 我们为了方便 将普通类型 也统一处理成为Object
const React = {
createElement: function (type, config, children) {
const props = {
...config,
};
// 上边讲到babel编译jsx后
// 如果参数大于3个 那么就有多个children props.children是一个数组
if (arguments.length > 3) {
props.children = Array.prototype.slice.call(arguments, 2);
} else {
props.children = children;
}
return {
type,
props,
};
},
};
export default React;
复制代码
这一步我们已经实现了基础的React.createElement
方法。
index.tsx
import React from './utils/react';
import ReactDOM from 'react-dom';
// babel编译后的代码会引入 React.createElement
// 此时的React指向的是我们自己写的React
const element = (
<div className="header" style={{ color: 'red' }}>
<span>hello</span>world
</div>
);
ReactDOM.render(element, document.getElementById('root'));
复制代码
实现ReactDOM.render
方法-将react
中源生DOM元素变成真实元素插入页面
- 接着咱们先来实现一个对于
children
类型的判断方法
// utils.js
// 常亮 判断文本类型
const REACT_TEXT = Symbol('REACT_TEXT')
// 无论以前是什么元素,都转成VDOM的对象形式
function transformVom(element) {
// 额外处理文本节点 将文本节点输出和其他节点一样的Object类型
if(typeof element === 'string' || typeof element === 'number') {
return { type: REACT_TEXT, props: { content: element } }
}
return element
}
复制代码
- 接下来我们改造一下我们之前写好的
React.createElement
方法
import { transformVNode } from './utils';
const React = {
createElement: function (type, config, children) {
const props = {
...config,
};
if (arguments.length > 3) {
props.children = Array.prototype.slice
.call(arguments, 2)
.map(transformVNode);
} else {
props.children = transformVNode(children);
}
return {
type,
props,
};
},
};
export default React;
复制代码
- 接下来我们已经拥有了对应的
VDom
对象,就可以开始实现React.render
方法。
React.render
核心思想就是将我们的Vdom
对象编程浏览器可以识别的标签节点挂载在对应元素上
。
/**
* 把虚拟DOM变成真实DOM插入
* @param {Object} vDom 虚拟DOM
* @param {HTMLElement} el 元素
*/
import { REACT_TEXT } from './constant';
// 真正渲染方法
function render(vDom, el) {
const newDom = createDom(vDom);
el.appendChild(newDom);
}
// 先不考虑自定义组件
function createDom(vDom) {
const { type, props } = vDom;
let dom;
// 文本节点
if (type === REACT_TEXT) {
dom = document.createTextNode(props.content);
} else {
dom = document.createElement(type);
}
// 更新属性
if (props) {
// 更新跟节点Dom属性
updateProps(dom, {}, props);
// 处理children 考虑undefined/null 不做任何处理
// 考虑 children是一个数组 那么就表示他拥有多个儿子
// 考虑children是一个Object 那么他就只有一个儿子节点
if (typeof props.children === 'object' && props.children.type) {
// 单个元素
render(props.children, dom);
} else if (Array.isArray(props.children)) {
// 多个元素
reconcileRender(props.children, dom);
}
}
// 记录挂载节点
vDom.__dom = dom;
return dom;
}
// 挂载多个dom元素 React.createElement先不考虑递归
function reconcileRender(vLists, parentDom) {
for (let node of vLists) {
render(node, parentDom);
}
}
/**
* 把虚拟DOM变成真实DOM插入
* @param {HTMLElement} dom 元素
* @param {Object} oldProps 元素本身的props 用于更新这里暂时用不到
* @param {Object} newProps 元素新的props
*/
function updateProps(dom, oldProps, newProps) {
// 合并props 暂时没有老的 仅处理新的
Object.keys(newProps).forEach((key) => {
if (key === 'children') {
// 单独处理children
return;
}
if (key === 'style') {
addStyleToElement(dom, newProps[key]);
} else if (key === 'content') {
// 文本不做任何操作
} else {
dom[key] = newProps[key];
}
});
}
function addStyleToElement(dom, styleObject) {
Object.keys(styleObject).forEach((key) => {
const value = styleObject[key];
dom.style[key] = value;
});
}
const ReactDOM = {
render,
};
export default ReactDOM;
复制代码
其实这里的的核心思想就是通过render
方法将虚拟DOM根据对应的属性转化成为真实DOM节点进行递归挂载,最终通过appendChild
渲染到页面上。