JSX是什么
弄清JSX对理解虚拟DOM有很重要的作用JSX只是看起来像是HTML,但它却是JavaScript,在React代码执行之前,Babel会将JSX编译为React API。
// 编译前
<div className="content">
<h3>Hello React</h3>
<p>React is great</p>
</div>
// 编译后
React.createElement(
'div',
{
className: 'content'
},
React.createElement('h3', null, 'Hello World'),
React.createElement('p', null, 'React is greate')
)
React.createElement代表一个节点元素,第一个参数是节点的名称,第二个是节点的属性,后面的参数都是子节点。我们可以自己在babeljs.is网站试验。React.createElement就是用来创建虚拟DOM的,返回的就是一个虚拟DOM对象。React再将虚拟DOM转换为真实DOM显示到页面中。
jsx在运行时会被Babel转换为React.createElement对象,React.createElement会被React转换成虚拟DOM对象,虚拟DOM对象会被React转换成真实DOM对象。
JSX语法的出现就是为了让React开发人员编写用户界面代码更加轻松。
什么是虚拟DOM
在React中,每个DOM对象都有一个对应的虚拟DOM对象,他是DOM对象的JavaScript表现形式,其实就是使用JavaScript对象来描述DOM对象信息,比如DOM对象的类型是什么,它身上有哪些属性,它拥有哪些子元素。
可以把虚拟DOM对象理解为DOM对象的一个副本,不过虚拟DOM不能直接显示在屏幕上。虚拟DOM就是为了解决React操作DOM的性能问题。
// 编译前
<div className="content">
<h3>Hello React</h3>
<p>React is great</p>
</div>
// 编译后
{
type: "div",
props: { className: "content"},
children: [
{
type: "h3",
props: null,
children: [
{
type: "text",
props: {
textContent: "Hello React"
}
}
]
},
{
type: "p",
props: null,
children: [
{
type: "text",
props: {
textContent: "React is greate"
}
}
]
}
]
}
React采用最小化的DOM操作来提升DOM操作的优势,只更新需要更新的,在React第一次创建DOM对象的时候会为每一个DOM对象创建虚拟的DOM对象,在DOM对象发生更新之前React会更新所有的虚拟DOM对象, 然后将更新前的虚拟DOM和更新后的虚拟DOM进行对比,找到变更的DOM对象,只将发生变化的DOM更新到页面中从而提升了js操作DOM的性能。
虽然在操作真实DOM之前进行的虚拟DOM更新和对比的操作,但是由于JS操作自有对象效率是很高的,成本几乎可以忽略不计的。
在React代码执行前,JSX会被Babel转换为React.createElement方法的调用,在调用createElement方法时会传入元素的类型,元素的属性,以及元素的子元素,createElement方法的返回值为构建好的虚拟DOM对象。这里我们自己来实现一个createElement方法。
createElement方法接收type, props, childrens三个参数。分别表示标签类型,标签属性和标签子元素。在这个方法中要返回一个虚拟DOM对象,在这个对象中有个type属性其实就是参数传入的值,接着是props和children。
function createElement(type, props, ...children) {
return {
type,
props,
children
}
}
我们这里使用TinyReact来分析React代码。首先要配置babel将jsx编译为Tiny的createElement方法,这样方便我们调试
.babelrc
{
"presets": [
"@babel/preset-env",
[
"@babel/preset-react",
{
"pragma": "TinyReact.createElement"
}
]
]
}
脚手架仓库自取地址 链接
src/index.js
import TinyReact from "./TinyReact"
const virtualDOM = (
<div className="container">
<h1>你好 我是虚拟DOM</h1>
</div>
)
console.log(virtualDOM);
控制台打印结果。
{
"type": "div",
"props": {
"className": "container"
},
"children": [
{
"type":"h1",
"props":null,
"children": [
"你好 我是虚拟DOM"
]
}
]
}
这里我们就打印出来一个简单的虚拟DOM,不过也有一个问题,这里的文本节点"你好 我是虚拟DOM"直接以字符串添加到了children数组中,这是不对的,正确的做法应该是文本节点也应该是一个虚拟DOM对象。
我们只需要循环children数组,判断如果不是一个对象就认为他是一个文本节点,我们将它替换成一个对象,
function createElement(type, props, ...children) {
// 遍历children对象
const childElements = [].concat(...children).map(child => {
if(child instanceof Object) {
return child; // 是对象直接返回
} else {
// 不是对象 调用createElement方法生成一个对象
return createElement('text', { textContent: child });
}
})
return {
type,
props,
children: childElements
}
}
文本节点变成了一个对象。
{
"type": "div",
"props": {
"className": "container"
},
"children": [
{
"type":"h1",
"props":null,
"children": [
{
"type":"text",
"props": {
"textContent": "你好 我是虚拟DOM"
},
"children": []
}
]
}
]
}
我们都知道在组件模板中如果是布尔值或者null值,节点是不显示的。我们这里需要处理一下。
<div className="container">
<h1>你好 我是虚拟DOM</h1>
{
1 === 2 && <h1>布尔值节点</h1>
}
</div>
function createElement(type, props, ...children) {
// 遍历children对象
const childElements = [].concat(...children).reduce((result, child) => {
// 判断child不能是布尔也不能是null
// 因为使用reduce,所以result是前一次循环的返回值,最终返回result就可以
if (child !== false && child !== true && child !== null) {
if (child instanceof Object) {
result.push(child); // 是对象直接返回
} else {
// 不是对象 调用createElement方法生成一个对象
result.push(createElement('text', {
textContent: child
}));
}
}
return result;
}, [])
return {
type,
props,
children: childElements
}
}
我们还需要将children放入到props中,只需要使用Object.assign将props和children合并返回就可以了。
return {
type,
props: Object.assign({ children: childElements}, props),
children: childElements
}
将虚拟DOM转换为真实DOM
我们要定义一个render方法,。
src/tinyReact/render.js
这个方法要接收三个参数,第一个参数是虚拟DOM,第二个参数是要渲染到的页面元素,第三个参数是旧的虚拟DOM用于进行对比。render方法的主要作用就是将虚拟DOM转换为真实DOM并且渲染到页面中。
import diff from './diff'
function render(virtualDOM, container, oldDOM) {
diff(virtualDOM, container, oldDOM);
}
需要在diff方法中进行一次处理,如果旧的虚拟DOM存在就进行对比,如果不存在就直接将当前的虚拟DOM放置在container中。
src/tinyReact/diff.js
import mountElement from './mountElement';
function diff (virtualDOM, container, oldDOM) {
// 判断oldDOM是否在巡
if (!oldDOM) {
return mountElement(virtualDOM, container);
}
}
要判断需要转换的虚拟DOM是组件还是普通的标签。需要分别进行处理, 这里我们先默认只有原生jsx标签,写死调用mountNativeElement方法。
src/tinyReact/mountElement.js
import mountNativeElement from './mountNativeElement';
function mountElement(virtualDOM, container) {
// 处理原生的jsx和组件的jsx
mountNativeElement(virtualDOM, container);
}
mountNativeElement文件用于将原生的虚拟DOM转换成真实的DOM,这里调用createDOMElement方法来实现。
src/tinyReact/mountNativeElement.js
import createDOMElement from './createDOMElement';
function mountNativeElement(virtualDOM, container) {
// 将虚拟dom转换成真实的对象
let newElement = createDOMElement(virtualDOM);
// 将转换之后的DOM对象放在页面中
container.appendChild(newElement);
}
创建真实DOM的方法单独定义文件,方便复用。需要判断如果是元素节点就创建相应的元素,如果是文本节点就创建对应的文本。然后通过递归的方式创建子节点。最后将我们创建的这个节点放在指定的容器container中就可以了。
src/tinyReact/createDOMElement.js
import mountElement from "./mountElement";
function createDOMElement(virtualDOM) {
let newElement = null;
if (virtualDOM.type === 'text') {
// 文本节点 使用createTextNode创建
newElement = document.createTextNode(virtualDOM.props.textContent);
} else {
// 元素节点 使用 createElement 创建
newElement = document.createElement(virtualDOM.type);
}
// 递归创建子节点
virtualDOM.children.forEach(child => {
mountElement(child, newElement);
})
return newElement;
}
为真实的DOM对象添加属性
我们知道属性是存储在虚拟DOM的props中的,我们只需要在创建元素的时候循环这个属性,将这些属性放在真实的元素中就可以了。
在添加属性的时候需要考虑不同的情况,比如说事件和静态属性都是不同的,而且添加属性的方法也是不同的,布尔属性和值属性的设置方式有所不同。还需要判断属性是不是children,因为children并不是属性,是我们自己定义的子元素,属性如果是className还需要转换成class进行添加。
src/tinyReact/createDOMElement.js
我们单独定一个方法来为元素添加属性,在创建元素之后调用这个方法,这里叫做updateNodeElement
import mountElement from "./mountElement";
import updateNodeElement from "./updateNodeElement";
function createDOMElement(virtualDOM) {
let newElement = null;
if (virtualDOM.type === 'text') {
// 文本节点 使用createTextNode创建
newElement = document.createTextNode(virtualDOM.props.textContent);
} else {
// 元素节点 使用 createElement 创建
newElement = document.createElement(virtualDOM.type);
// 调用添加属性的方法
updateNodeElement(newElement, virtualDOM)
}
// 递归创建子节点
virtualDOM.children.forEach(child => {
mountElement(child, newElement);
})
return newElement;
}
首先需要获取节点对象的属性列表,使用Object.keys来获得属性名,然后使用forEach来遍历。
src/tinyReact/updateNodeElement.js
如果属性名以on开头我们就认为他是一个事件, 然后我们截取出事件名称也就是去掉首部的on并且将字符串小写,使用addEventListener来绑定事件。
如果属性名是value或者checked是不能使用setAttribute来设置的,直接属性名等于属性值即可。
最后判断属性名如果是className就转换成class,如果不为children则其它属性全部可以使用setAttribute来设置。
function updateNodeElement(newElement, virtualDOM) {
// 获取节点对应的属性对象
const newProps = virtualDOM.props;
Object.keys(newProps).forEach(propName => {
const newPropsValue = newProps[propName];
// 判断是否是事件属性
if (propName.startsWith('on')) {
// 截取出事件名称
const eventName = propName.toLowerCase().slice(2);
// 为元素添加事件
newElement.addEventListener(eventName, newPropsValue);
} else if (propName === 'value' || propName === 'checked') {
// 如果属性名是value或者checked不能使用setAttribute来设置,直接以属性方式设置即可
newElement[propName] = newPropsValue;
} else if (propName !== 'children') {
// 排除children
if (propName === 'className') {
newElement.setAttribute('class', newPropsValue)
} else {
newElement.setAttribute(propName, newPropsValue)
}
}
})
}
组件渲染 - 区分函数组件还是类组件
在渲染组件之前首先我们要明确地是,组件的虚拟DOM类型值为函数,函数组件和类组件都是如此。
const Head = () => <span>head</span>
组件的虚拟DOM
{
type: function(){},
props: {},
children: []
}
在渲染组件时,要先将Component与Native Element区分开,如果是Native Element可以直接进行渲染,这个我们之前已经处理过了,如果是组件需要特别处理。
我们可以在入口文件src/index.js中渲染一个组件。
import TinyReact from "./TinyReact"
const root = document.getElementById('root');
function Demo () {
return <div>hello</div>
}
function Head () {
return <div><Demo /></div>
}
TinyReact.render(<Head />, root);
然后就需要在mountElement方法中区分原生标签和组件。
src/tinyReact/isFunction.js
function isFunction(virtualDOM) {
return virtualDOM && typeof virtualDOM.type === 'function';
}
我们在mountComponent方法中处理组件。首先我们要考虑这个组件是类组件还是函数组件,因为他们的处理方式是不同的,可以使用原型上是否存在render函数。我们可以借助isFunctionComponent函数来判断
src/tinyReact/mountComponent.js
如果type存在,并且对象是一个函数,并且对象上不存在render方法,那就是一个函数组件 src/tinyReact/isFunctionComponent.js
import isFunctionComponent from './isFunctionComponent';
function mountComponent(virtualDOM, container) {
// 判断组件是类组件还是函数组件
if (isFunctionComponent(virtualDOM)) {
}
}
src/tinyReact/isFunctionComponent.js
import isFunction from "./isFunction";
function isFunctionComponent(virtualDOM) {
const type = virtualDOM.type;
return type && isFunction(virtualDOM) && !(type.prototype && type.prototype.render)
}
处理函数组件
我们先来处理函数组件, 函数组件其实很简单,只需要调用type函数就可以了,就可以获取返回的虚拟dom。获取之后我们需要判断新获取的虚拟DOM是否是一个组件,如果是继续调用mountComponent,如果不是则为原生DOM元素直接调用mountNativeElement方法将虚拟DOM渲染到页面中。
src/tinyReact/mountComponent.js
import isFunction from './isFunction';
import isFunctionComponent from './isFunctionComponent';
import mountNativeElement from './mountNativeElement';
function mountComponent(virtualDOM, container) {
//存储得到的虚拟DOM
let nextVirtualDOM = null;
// 判断组件是类组件还是函数组件
if (isFunctionComponent(virtualDOM)) {
// 处理函数组件
nextVirtualDOM = buildFunctionComponent(virtualDOM);
}
// 判断是否仍是一个函数组件
if (isFunction(nextVirtualDOM)) {
mountComponent(nextVirtualDOM, container);
}
// 渲染nextVirtualDOM
mountNativeElement(nextVirtualDOM, container);
}
function buildFunctionComponent (virtualDOM) {
return virtualDOM.type();
}