菜鸡是怎么手写 React 的 2.0
前言
上篇文章讲述了如何将vdom、函数组件、类组件转换为真实的dom节点,这篇文章主要讲述如何去实现数据的更新,也就是实现setState方法
页面代码
首先把下面的代码全部复制到js文件中,让我们的页面变成一个可删除、可增加的列表
class Component {
constructor(props) {
this.props = props || {};
this.state = null;
}
setState(nextState) {
}
}
function Item(props) {
return <li className="item" style={props.style}>{props.children} <a href="#" onClick={props.onRemoveItem}>X </a></li>;
}
class List extends Component {
constructor(props) {
super();
this.state = {
list: [
{
text: 'aaa',
color: 'pink'
},
{
text: 'bbb',
color: 'orange'
},
{
text: 'ccc',
color: 'yellow'
}
]
}
}
handleItemRemove(index) {
this.setState({
list: this.state.list.filter((item, i) => i !== index)
});
}
handleAdd() {
this.setState({
list: [
...this.state.list,
{
text: document.getElementById('myRef').value
}
]
});
}
render() {
return <div>
<ul className="list">
{this.state.list.map((item, index) => {
return <Item key={`item${index}`} style={{ background: item.color, color: this.state.textColor}} onRemoveItem={() => this.handleItemRemove(index)}>{item.text}</Item>
})}
</ul>
<div>
<input id='myRef'/>
<button onClick={this.handleAdd.bind(this)}>add</button>
</div>
</div>;
}
}
const isTextNode = (vdom) => { return typeof vdom === 'string' || typeof vdom === 'number' }
const isElementNode = (vdom) => { return typeof vdom === 'object' && typeof vdom.type === 'string' }
const isEventListenerAttr = (key,value) => { return typeof value === 'function' && key.startsWith('on')}
const isStyleAttr = (key,value) => { return key === 'style' && typeof value === 'object'}
const isPlainAttr = (value) => { return typeof value !== 'object' && typeof value !== 'function'}
const isComponentVdom = (vdom) => { return typeof vdom.type == 'function' }
const setAttribute = (dom,key,value) => {
if(isEventListenerAttr(key,value)){//这里要设置的是元素的响应事件
const eventType = key.slice(2).toLowerCase()
dom.addEventListener(eventType,value)
}else if(key === 'className'){
//className属性需要特殊设置,设置后变成<div class='xxx'></div>,而用dom.setAttribute会变成<div className='xxx'></div>
dom[key] = value
}else if(isStyleAttr(key,value)){//style属性是对象,需要特别设置
Object.assign(dom.style,value)
}else if(isPlainAttr(value)){//其他常规的属性在这里设置,比如id属性、title属性等
dom.setAttribute(key,value)
}
}
function renderComponent(vdom,parent){
if (Component.isPrototypeOf(vdom.type)) {
const instance = new vdom.type(vdom.props);
const componentVdom = instance.render();
instance.dom = render(componentVdom, parent);
return instance.dom
} else {
//执行type属性上的函数,并将vdom.props属性传入
const componentVdom = vdom.type(vdom.props);
//最后执行render函数
return render(componentVdom, parent);
}
}
const render = (vdom,parent) => {
const mount = parent ? (el => parent.appendChild(el)) : (el => el);
if(isTextNode(vdom)){
return mount(document.createTextNode(vdom))
}else if(isElementNode(vdom)){
const newDom = document.createElement(vdom.type)
//开始设置节点的属性
for(let props in vdom.props){
if(props !== 'children'){//需要将children过滤掉
setAttribute(newDom,props,vdom.props[props])
}
}
//对元素的子节点进行递归
if(vdom.props.children){
for (const child of vdom.props.children) {
render(child, newDom);
}
}
return mount(newDom)
}else if (isComponentVdom(vdom)) {//当我们判断到传入的vdom是由函数组件编译而来的时候
return renderComponent(vdom,parent)
}
}
render(<List textColor={'pink'}/>,document.documentElement)
现在页面是这样的
当我们在input框输入值时,会触发handleAdd方法
handleAdd() {
this.setState({
list: [
...this.state.list,
{
text: document.getElementById('myRef').value
}
]
});
}
而现在setState方法是空的,最简单粗暴的方法就是,当我们去更改了类组件中的state的值后,我们用这一份新的state,重新调用类组件的render方法,重新生成一份vdom,再用这个新的vdom,去调用全局的render方法,将旧的dom节点全部删掉,再将新的dom节点全部挂载上去
setState(nextState) {
//将state的值进行更新
this.state = Object.assign(this.state,nextState);
//当触发this.render时,会通过state的值去生成vdom,而state的值我们是已经更新过的,所以这一份vdom是新的vdom
const newDom = render(this.render());
//进行节点的替换
this.dom.replaceWith(newDom);
//需要将dom节点的引用进行更改
this.dom = newDom;
}
效果会是这样的
但这样的页面性能会很差,所以我们需要通过patch函数去找出可以复用的dom节点,也就是我们常常说的diff算法,我们先来看一看diff算法的理论知识
diff算法理论知识
-
首先我们要清楚的一点是,diff算法比较的对象是谁,diff算法是旧的dom节点和新的vdom节点之间的比较,而不是旧的vdom节点和新的vdom节点去进行对比
-
其次,我们常常会看到这样一句话,找出尽可能可以复用的dom节点,实际上,我们在对比旧的dom节点和新的vdom节点后,我们会直接在旧的dom节点上面更改,那么,没有更改到的dom节点我们就说它是可以复用的dom节点,而不是说将没有更改过的dom节点重新挂载到根节点上面去
-
我们通过一张图来走一遍diff算法
-
-
实diff算法会经过两轮比较
-
第一轮,会进行同层次、同位置之间的比较,就是小a=》大A,小b=》大B,小c=》大C,小d=》大E,这样去对比,因为是递归的关系,所以如果小b节点还有子节点,会进行一个深度遍历
-
在经过第一轮比较后,我们会发现,旧的dom节点中,a,b,c在经过比较后,都是可以复用的节点,现在旧的dom节点中只剩下d节点未被复用,但实际上,d节点也是应该被复用的,只不过是因为它后移了一位,导致它没有被识别到应该被复用,所以如果还会进行第二次比较
- 我们会将旧的dom节点中,还未被复用过的节点,全部放入一个map结构中,然后去遍历一遍新的vdom,vdom节点中,还未被打上标签的对象(如果已经找到可以复用的会打上标签),会在map结构中去寻找是否有可以复用的dom节点,找到了会给自己打上标签,同时将该dom节点从map结构中删除
-
-
-
从上面讲述的流出可以看出,diff算法对比,其实也就是一个递归的操作,那一个是dom结构,另外一个是对象,我们应该怎么进行对比操作?我在下边列举了一小部分代码,并且添加上了对应的注释,可以先把这里理解了再往下看
//如果dom节点的nodeName和vdom的type属性不一致,那么他们就不是同一个节点,可以直接进行替换 if (dom.nodeName !== vdom.type.toUpperCase() && typeof vdom === 'object') { //替换操作 return replace(render(vdom, parent)); } else{//上边的图片中,dom的nodeName是div,vdom的type也是div,所以他们是同一个节点,可以进行子节点的比较 //将dom节点的childNodes放入oldDomsArr const oldDomsArr = [].concat(...dom.childNodes) //对vdom.children进行遍历 [].concat(...vdom.children).map((child, index) => { 通过index,进行同层级,同位置之间的对比 patch(oldDoms[index], child); }); }
setState的具体实现
- 现在的setState函数是这样的
setState(nextState) { this.state = Object.assign(this.state,nextState);//生成新的state //判断state和props时候和之前存在不一样,避免不必要的渲染 if(this.dom && this.shouldComponentUpdate(this.props, nextState)) { //旧的dom节点和新的vdom节点的对比 //dom属性会在render函数中,渲染类组件时挂载到dom节点上面去 patch(this.dom, this.render()); } } shouldComponentUpdate(nextProps, nextState) { return nextProps != this.props || nextState != this.state; }
- 进入到patch函数中要注意的参数有dom节点和vdom对象,这两个对象分别都有两种情况,dom节点可能是一个文本节点也有可能是一个元素节点,同样,vdom有可能是一个对象,也有可能是一个字符串或者是数字,所以我们先将上面说的几种情况全部列举出来
function patch(dom,vdom,parent = dom.parentNode){ //后续有多处需要进行替换节点操作,所以把replaceChild函数封装起来 const replace = parent ? el => { parent.replaceChild(el, dom); return el; } : (el => el); if(dom instanceof Text){//dom节点只有两大类,文本节点和元素节点,我们先对dom节点做一个区分 if (typeof vdom === 'object') { } else { } }else{//表示都是元素节点 if(typeof vdom === 'object'){//元素节点有两种情况,传进来的vdom是一个对象,或者是一个数字或字符串 }else if(typeof vdom === 'string' || typeof vdom === 'number'){//文本节点 } } }
dom是文本节点的情况
- 如果vdom是个元素节点,那么这两个节点肯定不能复用,那么直接将vdom的转变成dom再替换上去
- 如果vdom也是一个文本节点,那么我们去对比他们的文本是否相同,如果不相同我们再将vdom转变成dom再替换上去,如果相同我们不做任何处理
if (dom instanceof Text) { if (typeof vdom === 'object') { return replace(render(vdom, parent)); } else { return dom.textContent != vdom ? replace(render(vdom, parent)) : dom; } }
dom是元素节点的情况
vdom是一个数字或是一个字符串的情况
- 先来看比较简单的情况,如果vdom是一个数字或是一个字符串,表示它是一个文本节点,但是dom现在是元素节点,所以这个dom节点就不能复用,直接替换
if(typeof vdom === 'object'){//元素节点有两种情况,传进来的vdom是一个对象,或者是一个数字或字符串 }else if(typeof vdom === 'string' || typeof vdom === 'number'){//文本节点 return replace(render(vdom, dom)); }
vdom是一个对象的情况
- 如果vdom是一个对象,那么它还会有很多种情况,因为这个vdom它有可能是由类组件转换而来,也有可能是由函数组件转换而来,也有可能就是一个普通的vdom,所以我们还需要对vdom.type属性以及该组件是类组件还是函数组件进行一个判断
if(typeof vdom === 'object'){//元素节点有两种情况,传进来的vdom是一个对象,或者是一个数字或字符串 if(typeof vdom.type === 'function'){//vdom可能是由类组件或是函数组件转换而来 if(Component.isPrototypeOf(vdom.type)){//类组件 }else{//函数组件 } }else if(typeof vdom.type === 'string'){//一个普通的vdom } }
vdom是类组件的情况
- 对于vdom是类组件转化过来的情况,它要对比的dom,有可能是同一个组件渲染的,也有可能不是同一个组件渲染的,所以我们需要知道,当前的dom,是由哪一个类组件转换而来的;
- 我们只要改一下render函数的代码,让它在渲染类组件的时候打上一个标记,让这个dom节点和我们的类函数挂上关系,我们通过判断这个关系就可以知道是否是同一个组件渲染出来的
- 对于相同的组件,因为此时我们只是判断了他们是由相同的组件渲染而来,但我们还不知道它们的子节点是否完全一致,所以我们需要把更新好的props传入this.render()方法中去,生成新的vdom,再用这个vdom和dom节点进行patch比较(重点!!!)
- 如果不是同一个组件渲染出来的,我们直接用vdom的值生成一份新的dom节点,替换掉旧节点就行
//render函数中渲染组件的代码新增_instance指针 if (Component.isPrototypeOf(vdom.type)) { instance.dom_instance = instance; }
if(Component.isPrototypeOf(vdom.type)){//类组件 //dom节点上挂载_instance属性,指向的是一个对象,这个对象是class类的实例,这个对象的constructor指向生成它的函数 if(dom.__instance && dom.__instance.constructor == vdom.type){ //我们把实例上的props属性进行更新,将新的props传递进去 dom.__instance.props = props; //dom.__instance.render()会生成一个新的vdom,因为dom.__instance的props属性已经更新了 return patch(dom, dom.__instance.render(), parent); }else{//不是同一个组件渲染出来的直接调用renderComponent生成新的dom节点 //renderComponent是render函数中处理组件的逻辑 const componentDom = renderComponent(vdom, parent); if (parent){ parent.replaceChild(componentDom, dom); return componentDom; } else { return componentDom } } }else{//函数组件 }
vdom是函数组件的情况
- 同样的,对于函数组件,我们也可以在渲染这个组件的时候,打上标签
if (Component.isPrototypeOf(vdom.type)) {//渲染类组件 } else {//渲染函数组件 //执行type属性上的函数,并将vdom.props属性传入 const componentVdom = vdom.type(vdom.props); const functionDom = render(componentVdom, parent); functionDom._fcInstance = vdom.type;//打上标签 //最后执行render函数 return functionDom }
if(Component.isPrototypeOf(vdom.type)){//类组件 }else{//函数组件 if(dom._fcInstance && dom._fcInstance === vdom.type){ //如果是同一个函数渲染出来的,我们将props传入vdom.type函数中去生成一个新的vdom return patch(dom, vdom.type(props), parent); }else{ const componentDom = renderComponent(vdom, parent); if (parent){ parent.replaceChild(componentDom, dom); return componentDom; } else { return componentDom } } }
vdom是一个普通的对象的情况
-
如果vdom是一个普通的对象,那么这时候,就和diff理论知识中举的例子一样,dom和vdom会进行真正的比较,vdom不会再去进行转化。
else if(typeof vdom.type === 'string'){//一个普通的vdom if (dom.nodeName !== vdom.type.toUpperCase()) {//如果dom节点和vdom的type不能对应起来,比如div对p,那么就直接替换整个节点 return replace(render(vdom, parent)); } else{//如果能对应起来,比如div对div,那么就进行子节点的对比 //dom.childNodes会获取到dom节点的子元素,并且我们需要考虑到key到情况,如果没有key,我们就赋予它一个跟下标有关的key值 const oldDoms = {}; [].concat(...dom.childNodes).map((child, index) => { const key = child.__key || `__index_${index}`; oldDoms[key] = child; }); vdom.props.children && [].concat(...vdom.props.children).map((child, index) => { const key = child.props && child.props.key || `__index_${index}`; //关键代码是下面这一行,实际上就是两个数组进行相同位置上的元素比较,如果相同位置上有元素,那么就将这两个元素拿去patch比较,如果没有,那么就直接生成新的dom节点 dom.appendChild(oldDoms[key] ? patch(oldDoms[key], child) : render(child, dom)); delete oldDoms[key]; }); //将不用的dom节点删除,释放内存 for (const key in oldDoms) { oldDoms[key].remove(); } //删除dom的属性 for (const attr of dom.attributes) dom.removeAttribute(attr.name); //对dom属性重新设置 for (const prop in vdom.props) { if(prop !== 'children'){ setAttribute(dom, prop, vdom.props[prop]); } } return dom } }
//这里是上面代码块所需要的函数 //设置属性的方法 const setAttribute = (dom, key, value) => { if (isEventListenerAttr(key, value)) { //把各种事件的 listener 放到 dom 的 __handlers 属性上,每次删掉之前的,换成新的。 const eventType = key.slice(2).toLowerCase(); dom.__handlers = dom.__handlers || {}; dom.removeEventListener(eventType, dom.__handlers[eventType]); dom.__handlers[eventType] = value; dom.addEventListener(eventType, dom.__handlers[eventType]); } else if (key == 'checked' || key == 'value' || key == 'className') { dom[key] = value; } else if (isStyleAttr(key, value)) { Object.assign(dom.style, value); } else if (key == 'key') { dom.__key = value; } else if (isPlainAttr(key, value)) { dom.setAttribute(key, value); } }
页面效果是这样的
总结
实现setState方法的关键在于,怎么去对比dom和vdom从而找到可以复用的dom节点,并且我们要理解diff算法的思想以及dom节点的渲染流程;
这篇文章实现的setState是递归渲染的,如果vdom树太大,计算量会非常的大,并且有可能会感到卡顿,所以现在的react是fiber架构,递归渲染更改成循环渲染,这样随时可以打断去执行优先级更高的任务。
3.0会实现react的fiber和hook功能。
页面完整代码
class Component {
constructor(props) {
this.props = props || {};
this.state = null;
}
setState(nextState) {
this.state = Object.assign(this.state,nextState);//生成新的state
//判断state和props时候和之前存在不一样,避免不必要的渲染
if(this.dom && this.shouldComponentUpdate(this.props, nextState)) {
//旧的dom节点和新的vdom节点的对比
//dom属性会在render函数中,渲染类组件时挂载到dom节点上面去
patch(this.dom, this.render());
}
}
shouldComponentUpdate(nextProps, nextState) {
return nextProps != this.props || nextState != this.state;
}
}
function patch(dom,vdom,parent = dom.parentNode){
//后续有多处需要进行替换节点操作,所以把replaceChild函数封装起来
const replace = parent ? el => {
parent.replaceChild(el, dom);
return el;
} : (el => el);
if(dom instanceof Text){//dom节点只有两大类,文本节点和元素节点,我们先对dom节点做一个区分
if (typeof vdom === 'object') {
return replace(render(vdom, parent));
} else if(typeof vdom === 'string' || typeof vdom === 'number'){
return dom.textContent != vdom ? replace(render(vdom, parent)) : dom;
}
}else{//表示都是元素节点
if(typeof vdom === 'object'){//元素节点有两种情况,传进来的vdom是一个对象,或者是一个数字或字符串
if(typeof vdom.type === 'function'){//vdom可能是由类组件或是函数组件转换而来
if(Component.isPrototypeOf(vdom.type)){//类组件
//dom节点上挂载_instance属性,指向的是一个对象,这个对象是class类的实例,这个对象的constructor指向生成它的函数
if(dom.__instance && dom.__instance.constructor == vdom.type){
//我们把实例上的props属性进行更新,将新的props传递进去
dom.__instance.props = props;
//dom.__instance.render()会生成一个新的vdom,因为dom.__instance的props属性已经更新了
return patch(dom, dom.__instance.render(), parent);
}else{//不是同一个组件渲染出来的直接调用renderComponent生成新的dom节点
//renderComponent是render函数中处理组件的逻辑
const componentDom = renderComponent(vdom, parent);
if (parent){
parent.replaceChild(componentDom, dom);
return componentDom;
} else {
return componentDom
}
}
}else{//函数组件
if(dom._fcInstance && dom._fcInstance === vdom.type){
//如果是同一个函数渲染出来的,我们将props传入vdom.type函数中去生成一个新的vdom
return patch(dom, vdom.type(props), parent);
}else{
const componentDom = renderComponent(vdom, parent);
if (parent){
parent.replaceChild(componentDom, dom);
return componentDom;
} else {
return componentDom
}
}
}
}else if(typeof vdom.type === 'string'){//一个普通的vdom
if (dom.nodeName !== vdom.type.toUpperCase()) {//如果dom节点和vdom的type不能对应起来,比如div对p,那么就直接替换整个节点
return replace(render(vdom, parent));
} else{//如果能对应起来,比如div对div,那么就进行子节点的对比
//dom.childNodes会获取到dom节点的子元素,并且我们需要考虑到key到情况,如果没有key,我们就赋予它一个跟下标有关的key值
const oldDoms = {};
[].concat(...dom.childNodes).map((child, index) => {
const key = child.__key || `__index_${index}`;
oldDoms[key] = child;
});
vdom.props.children && [].concat(...vdom.props.children).map((child, index) => {
const key = child.props && child.props.key || `__index_${index}`;
//关键代码是下面这一行,实际上就是两个数组进行相同位置上的元素比较,如果相同位置上有元素,那么就将这两个元素拿去patch比较,如果没有,那么就直接生成新的dom节点
dom.appendChild(oldDoms[key] ? patch(oldDoms[key], child) : render(child, dom));
delete oldDoms[key];
});
//将不用的dom节点删除,释放内存
for (const key in oldDoms) {
oldDoms[key].remove();
}
//删除dom的属性
for (const attr of dom.attributes) dom.removeAttribute(attr.name);
//对dom属性重新设置
for (const prop in vdom.props) {
if(prop !== 'children'){
setAttribute(dom, prop, vdom.props[prop]);
}
}
return dom
}
}
}else if(typeof vdom === 'string' || typeof vdom === 'number'){//文本节点
return replace(render(vdom, dom));
}
}
}
//设置属性的方法
const setAttribute = (dom, key, value) => {
if (isEventListenerAttr(key, value)) {
//把各种事件的 listener 放到 dom 的 __handlers 属性上,每次删掉之前的,换成新的。
const eventType = key.slice(2).toLowerCase();
dom.__handlers = dom.__handlers || {};
dom.removeEventListener(eventType, dom.__handlers[eventType]);
dom.__handlers[eventType] = value;
dom.addEventListener(eventType, dom.__handlers[eventType]);
} else if (key == 'checked' || key == 'value' || key == 'className') {
dom[key] = value;
} else if (isStyleAttr(key, value)) {
Object.assign(dom.style, value);
} else if (key == 'key') {
dom.__key = value;
} else if (isPlainAttr(key, value)) {
dom.setAttribute(key, value);
}
}
function Item(props) {
return <li className="item" style={props.style}>{props.children} <a href="#" onClick={props.onRemoveItem}>X </a></li>;
}
class List extends Component {
constructor(props) {
super();
this.state = {
list: [
{
text: 'aaa',
color: 'pink'
},
{
text: 'bbb',
color: 'orange'
},
{
text: 'ccc',
color: 'yellow'
}
]
}
}
handleItemRemove(index) {
this.setState({
list: this.state.list.filter((item, i) => i !== index)
});
}
handleAdd() {
this.setState({
list: [
...this.state.list,
{
text: document.getElementById('myRef').value
}
]
});
}
render() {
return <div>
<ul className="list">
{this.state.list.map((item, index) => {
return <Item key={`item${index}`} style={{ background: item.color, color: this.state.textColor}} onRemoveItem={() => this.handleItemRemove(index)}>{item.text}</Item>
})}
</ul>
<div>
<input id='myRef'/>
<button onClick={this.handleAdd.bind(this)}>add</button>
</div>
</div>;
}
}
const isTextNode = (vdom) => { return typeof vdom === 'string' || typeof vdom === 'number' }
const isElementNode = (vdom) => { return typeof vdom === 'object' && typeof vdom.type === 'string' }
const isEventListenerAttr = (key,value) => { return typeof value === 'function' && key.startsWith('on')}
const isStyleAttr = (key,value) => { return key === 'style' && typeof value === 'object'}
const isPlainAttr = (value) => { return typeof value !== 'object' && typeof value !== 'function'}
const isComponentVdom = (vdom) => { return typeof vdom.type == 'function' }
const isRefAttr = (key, value) => { return key === 'ref' && typeof value === 'function' }
function renderComponent(vdom,parent){
if (Component.isPrototypeOf(vdom.type)) {
const instance = new vdom.type(vdom.props);
const componentVdom = instance.render();
instance.dom = render(componentVdom, parent);
instance.dom_instance = instance;
return instance.dom
} else {
//执行type属性上的函数,并将vdom.props属性传入
const componentVdom = vdom.type(vdom.props);
//最后执行render函数
return render(componentVdom, parent);
}
}
const render = (vdom,parent) => {
const mount = parent ? (el => parent.appendChild(el)) : (el => el);
if(isTextNode(vdom)){
return mount(document.createTextNode(vdom))
}else if(isElementNode(vdom)){
const newDom = document.createElement(vdom.type)
//开始设置节点的属性
console.log('======vdom',vdom)
for(let props in vdom.props){
if(props !== 'children'){//需要将children过滤掉
console.log('======vdom',props)
setAttribute(newDom,props,vdom.props[props])
}
}
//对元素的子节点进行递归
if(vdom.props.children){
for (const child of vdom.props.children) {
render(child, newDom);
}
}
return mount(newDom)
}else if (isComponentVdom(vdom)) {//当我们判断到传入的vdom是由函数组件编译而来的时候
return renderComponent(vdom,parent)
}
}
render(<List textColor={'pink'}/>,document.documentElement)
转载自:https://juejin.cn/post/7207698564641325112