最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • React源码解析之手写实现JSX、虚拟DOM、组件、生命周期、diff、setState

    正文概述 掘金(前端开发小马哥)   2020-11-27   866

    React是前端最受欢迎的框架之一,解读其源码的文章非常多,但是我想从另一个角度去解读React:从零开始实现一个React,从API层面实现React的大部分功能,在这个过程中去探索为什么有虚拟DOM、diff、为什么setState这样设计等问题。

    提起React,总是免不了和Vue做一番对比

    Vue的API设计非常简洁,但是其实现方式却让人感觉是“魔法”,开发者虽然能马上上手,但其原理却很难说清楚。

    相比之下React的设计哲学非常简单,虽然有很多需要自己处理的细节问题,但它没有引入任何新的概念,相对更加的干净和简单。

    JSX和虚拟DOM

    准备工作

    为了集中精力编写逻辑,在代码打包工具上选择了火热的零配置打包工具parcel

    全局安装parcel
    npm i parcel-bundler -g
    

    新建项目react_simple,新建index.html和index.js,将index.js引入到index.html中

    React源码解析之手写实现JSX、虚拟DOM、组件、生命周期、diff、setState

    执行

    parcel index.html
    

    React源码解析之手写实现JSX、虚拟DOM、组件、生命周期、diff、setState

    项目依赖配置

    安装parcel-bundler

    npm i parcel-bundler --save-dev 
    

    配置package.json

    {
      "name": "react_simple",
      "version": "1.0.0",
      "description": "",
      "main": "index.js",
      "scripts": {
        "start": "parcel index.html"
      },
      "keywords": [],
      "author": "",
      "license": "ISC",
      "devDependencies": {
        "babel-core": "^6.26.3",
        "babel-plugin-transform-react-jsx": "^6.24.1",
        "babel-preset-env": "^1.7.0",
        "parcel-bundler": "^1.12.3"
      }
    }
    
    

    安装babel插件,将jsx的语法转化成js对象(这个js对象即所谓的虚拟DOM)

    多说一下,我们都知道vue和react都有所谓的虚拟DOM,那么为什么要有虚拟DOM呢?虚拟DOM到底提升性能是因为什么呢?

    大家可以参考一下这篇文章,主要是底层浏览器渲染DOM的一下事,把这些原理给面试官讲清楚了。基本上就没问题了。

    回到正题,我们开始操作

    npm i babel-core babel-preset-env babel-plugin-transform-react-jsx --save-dev
    

    同时.babelrc配置如下

    {
        "presets": ["env"],
        "plugins": [
            ["transform-react-jsx", {
                "pragma": "React.createElement"
            }]
        ]
    }
    
    
    先看一个react的最简单,也是最熟悉的例子
    import React from 'react';
    import ReactDOM from 'react-dom';
    
    // 这并不是合法的js代码,它是一种被称为jsx的语法扩展,通过它我们可以很方便的在js代码中书写html片段
    // 本质上,jsx是语法糖
    const title = <h2 className = 'title'> <span> hello React </span></h2> ;
    
    // 上面代码会被babel转义成下面对象 而这个对象就是所谓的虚拟DOM
    /* 
    var title = React.createElement("h2", {
        className: "title"
    }, " hello React ");
    */
    ReactDOM.render(title,document.querySelector('#root'))
    
    /* 
    第一个参数是DOM节点的标签名,它的值可能是div,h1,span等等
    第二个参数是一个对象,里面包含了所有的属性,可能包含了className,id等等
    从第三个参数开始,就是它的子节点
    */
    // 从jsx转译结果来看 createElement(tag,attrs,child1,child2,child3,child4...)
    

    因此,我们得出结论:JSX 语法糖经过 Babel 编译后转换成一种对象,该对象即所谓的虚拟 DOM,使用虚拟 DOM 能让页面进行更为高效的渲染。

    函数构造

    index.js

    const React = {
        createElement
    }
    function createElement(tag,attrs,...childrens){
        return {
            tag,
            attrs,
            childrens
        }
    }
    const element = (
        <div className='title'>
            hello <span>react</span>
        </div>
    )
    console.log(element);
    

    打印结果如下:

    React源码解析之手写实现JSX、虚拟DOM、组件、生命周期、diff、setState

    createElement方法返回的对象记录了这个DOM节点所有的信息,换句话说,通过它我们可以生成真正的DOM,这个记录信息的对象的我们称之为虚拟DOM

    接下来,我们一一来实现想要的功能

    ReactDOM.render

    接下来是ReactDOM.render方法,

    ReactDOM.render(
        <h1>Hello,react</h1>,
        document.getElementById( 'root' )
    );
    

    经过转换

    ReactDOM.render(
        React.createElement( 'h1', null, 'Hello, react' ),
        document.getElementById('root')
    );
    

    所以render的第一个参数实际上接受的是createElement返回的对象,也就是虚拟DOM .而第二个参数则是挂载的目标DOM

    总而言之,render方法的作用就是将虚拟DOM渲染成真实的DOM,下面是它的实现:

    const ReactDOM = {
         render
    }
    function render(vnode, container) {
        // 如果vnode为定义,则什么都不做
        if (vnode == undefined) return;
        // 如果vnode是字符串是,渲染结果是一段文本
        if (typeof vnode === 'string') {
            const textNode = document.createTextNode(vnode);
            return container.appendChild(textNode);
        }
        // 否则是一个虚拟DOM对象
        const {
            tag
        } = vnode;
        // 创建节点对象
        const dom = document.createElement(tag);
        console.log(dom);
    
        if (vnode.attrs) {
            // 有属性
            Object.keys(vnode.attrs).forEach(key => {
                // 取值
                const val = vnode.attrs[key];
                // 为上面的节点对象设置属性
                setAttribute(dom, key, val);
            })
        }
        // 递归渲染子节点
        vnode.childrens.forEach(child => render(child, dom));
    
        // 将渲染结果挂载到真正的DOM上
        return container.appendChild(dom);
    
    }
    

    设置属性需要考虑一些特殊情况(className,onClick,其它属性等),我们单独将其封装一个方法setAttribute

    function setAttribute(dom, key, value) {
        // 如果属性名是className,则改为class
        if (key === 'className') {
            key = 'class';
            // return key
        }
    
        // 如果属性名是onClick onBlur onChange.... 则是事件监听的方法
        if (/on\w+/.test(key)) {
            // 转小写
            key = key.toLowerCase();
            dom[key] = value || '';
    
        } else if (key === 'style') { //如果属性名是style,则更新style对象
            if (!value || typeof value === 'string') {
                dom.style.cssText = value || '';
            } else if (value && typeof value === 'object') {
                // 更新style对象
                for (let k in value) {
                    // 可以通过style={width:20}这种形式来设置样式,可以省略掉单位px
                    if (typeof value[k] === 'number') {
                        dom.style[k] = value[k] + 'px';
                    } else {
                        dom.style[k] = value[k]
                    }
                    //  dom.style[ k ] = typeof value[ k ] === 'number' ? value[ k ] + 'px' : value[ k ];
    
                }
            }
        } else { //普通属性则直接更新属性
            console.log(key, dom);
    
            if (key in dom) {
    
                console.log(1);
    
                dom[key] = value || '';
            }
            if (value) { //有值则更新值
                dom.setAttribute(key, value);
            } else { //没有值 移除此属性
                dom.removeAttribute(key);
            }
        }
    }
    

    注意:多次地调用render函数时,不会清除原来的内容 先清除一下挂载目标DOM的内容

    修改代码如下:

    const ReactDOM = {
        render:(vnode,container)=>{
            container.innerHTML = '';
            return render(vnode,container);
        }
    }
    

    测试1

    const ele = <div className='title' title='123'>
        <h3 className='3'>hello <span><a href='http://www.baidu.com'>react</a> </span></h3>
    </div>
    ReactDOM.render(ele, document.getElementById('root'));
    

    React源码解析之手写实现JSX、虚拟DOM、组件、生命周期、diff、setState

    测试2

    function tick() {
        const element = (
            <div>
                <h1>Hello, world!</h1>
                <h2>It is {new Date().toLocaleTimeString()}.</h2>
            </div>
          );
        ReactDOM.render(
            element,
            document.getElementById( 'root' )
        );
    }
    
    setInterval( tick, 1000 );
    

    问题

    在定义React组件或者书写React相关代码,不管代码中有没有用到React这个对象,我们都必须将其import进来,这是为什么?

    import React from 'react';    // 下面的代码没有用到React对象,为什么也要将其import进来
    import ReactDOM from 'react-dom';
    
    ReactDOM.render( <App />, document.getElementById( 'editor' ) );
    

    其实上面就是答案了!!!!

    组件和生命周期

    在react中,组件有两种:函数组件和类组件,函数组件可以看作是类定义的一种简单形式

    上面咱们说道:React.createElement的实现

    const React = {
        createElement
    }
    
    function createElement(tag, attrs, ...childrens) {
        return {
            tag,
            attrs,
            childrens
        }
    }
    export default React;
    
    

    这种实现不得不说我们只是来渲染原生DOM元素,而对于组件来说,createElement得到的参数略有不同:如果JSX片段中的每个元素是组件,那么createElement的第一个参数tag将会是一个方法,而不是字符串

    例如在处理<Home name='react' />时,createElement方法的第一个参数tag,实际上就是我们定义的Home方法:

    function Home(props){
        return <h1>hello,{props.name}</h1>
    }
    

    我们不需要对createElement做修改,只需要知道如果渲染的是组件,tag的值将是一个函数

    组件基类React.Component

    通过类的方式定义组件,我们需要继承React.Component

    class Home extends React.Component {
        render() {
            return (
                <h1>hello,{this.props.name}</h1>
            );
        }
    }
    ReactDOM.render(<Home name='react' />, document.getElementById('root')); 
    
    Component

    React.Component包含了一些预先定义好的变量和方法啊,我们一一实现它:

    react文件夹下,新建component.js,定义一个Component类:

    import {
        renderComponent
    } from '../react-dom';
    // React.Component包含了一些预先定义好的变量和方法
    class Component{
        // 通过继承React.Component定义的组件有自己的私有状态state,可以通过this.state获取到。同时也能通过this.props来获取传入的数据。
        // 所以在构造函数中, 我们需要初始化state和props
        constructor(props = {}) {
            this.state = {};
            this.props = props;
        }
        setState(statChange){
            // 将修改合并到state
            Object.assign(this.state,stateChange);
            // 重新渲染组件
            renderComponent(this);
        }
    }
    export default Component;
    

    组件内部的state和渲染结果无关,当state改变时通常会触发渲染,为了让React知道我们改变了state,我们只能通过setState方法来修改数据,我们可以通过Object.assign来做一个简单的实现,在每次更新state后,我们需要调用renderComponent方法来重新渲染组件,renderComponent方法的实现

    export function renderComponent(comp) {
        // 声明一个初始化变量,用来保存当前js节点对象
        let base;
        const renderer = comp.render(); //调用render方法之后,返回的是jsx对象
        // 返回js节点对象
        base = _render(renderer);
    	
        // 如果调用了setState,节点替换
        if (comp.base && comp.base.parentNode) {
            comp.base.parentNode.replaceChild(base, comp.base);
        }
        // 为组件挂载js节点对象
        comp.base = base;
    }
    
    render方法
    const ReactDOM = {
        render
    }
    
    function render(vnode,container) {
    
        return container.appendChild(_render(vnode));
    }
    

    实现的render方法只支持渲染原生的DOM元素,不支持渲染组件,我们需要修改ReactDOM.render方法,让它支持渲染组件

    思路:如果是函数组件或者类组件,则vnode.tag的类型为函数

    在render方法中添加代码如下:

    // 如果是一个函数,则渲染组件
    if (typeof vnode.tag === 'function') {
        // 1.创建组件
        const comp = createComponent(vnode.tag, vnode.attrs)
        // 2.设置组件的属性
        setComponentProp(com,vnoe.attrs);
        // 3.返回当前组件的jsx对象
        return comp.base;
    }
    
    组件渲染

    createComponent方法用来创建组件实例,并且我想将函数定义组件扩展成为类定义的组件,为了后面方便处理

    /* 
        @comp: 为当前函数组件还是类定义的组件  (都是函数)
        @props: 组件属性
        @return: 返回当前组件
    */
    function createComponent(comp,props) {
        let inst;
        // 如果是类定义的组件,则直接返回实例
        if (comp.prototype && comp.prototype.render){  //有原型,原型上有render函数,则是类
           inst = new comp(props);
        }else{
            /* 
                function Home(){
                    return <div>hello</div>
                }
            */
            // 是函数组件,则将函数组件扩展成类定义的组件 方便后面的统一处理
            inst = new Component(props);
            // 改变构造函数指向
            inst.constructor = comp;
            // 定义render函数
            inst.render = function() {
                return this.constructor(props);
            }
        }
        return inst;
    }
    
    

    setComponentPorps方法用来更新props

    function setComponentProp(comp,props) {
        //更新props
        comp.props = props;
        //重新渲染组件
        renderComponent(comp)
    }
    
    生命周期方法实现

    setComponentProps方法中可以实现componentWillMountcomponentWillReceiveProps两个生命周期方法

    function setComponentProp(comp,props) {
        // 实现componentWillMount,componentWillReceiveProps两个生命周期方法
        if(!comp.base){
            if (comp.componentWillMount) comp.componentWillMount();
        }else if(comp.componentWillReceiveProps){
            comp.componentWillReceiveProps(props);
        }
        comp.props = props;
        renderComponent(comp)
    }
    

    基本的组件渲染和声明周期方法已经实现

    测试1:

    import React from './react';
    import ReactDOM from './react-dom/index.js';
    class Counter extends React.Component {
        constructor(props) {
            super(props);
            this.state = {
                num: 0
            }
        }
        handlerClick(){
            this.setState({
                num: this.state.num + 1
            })
        }
        componentWillMount(){
            console.log('组件将要挂载');
            
        }
        componentWillReceiveProps(props){
            console.log('将要接收属性',props);
            
        }
         componentWillUpdate() {
             console.log('组件将要更新');
         }
         componentDidMount(){
             console.log('组件已经挂载完成');
             
         }
    
        render() {
            console.log(this.state.num);
            
            return (
                <div>
                    <h3>number:{this.state.num}</h3>
                    <button onClick={this.handlerClick.bind(this)}>add</button>
                </div>
            );
        }
    }
    ReactDOM.render(<Counter name='react'/>, document.getElementById('root')); 
    

    效果:

    React源码解析之手写实现JSX、虚拟DOM、组件、生命周期、diff、setState

    组件挂载输出一次,后面每次更新会输出update

    React的核心功能基本已经实现,但是我们目前的做法是每次更新都重新渲染整个组件甚至整个应用,这样的做法在页面复杂时会暴露出性能的问题,为了减少DOM操作,React又使用diff算法对DOM进行操作

    diff算法

    diff算法?what?什么玩意

    如何减少DOM更新:我们需要找出渲染前后真正变化的部分,只更新这一部分.而对比变化,找出需要更新部分的算法称之为diff算法

    对比策略

    在前面我们实现了render方法,它能讲虚拟DOM转换成真正的DOM

    但是我们需要改进它,不要让它傻乎乎地重新渲染整个DOM数,而是找出真正变化的部分进行替换

    这部分很多类似React框架实现方式都不太一样,有的框架会选择保存上次渲染的虚拟DOM,然后对比虚拟DOM前后的变化,得到一系列更新的数据,然后再将这些更新应用到真正的DOM上。

    我们会选择直接对比虚拟DOM和真实DOM,这样就不需要额外保存上一次渲染的虚拟DOM,并且能够一边对比一边更新,这也是我们选择的方式。

    不管是DOM还是虚拟DOM,它们的结构都是一棵树,完全对比两棵树变化的算法时间复杂度是O(n^3),但是考虑到我们很少会跨层级移动DOM,所以我们只需要对比同一层级的变化。

    React源码解析之手写实现JSX、虚拟DOM、组件、生命周期、diff、setState

    总而言之,我们的diff算法有两个原则

    • 对比当前真实的DOM和虚拟DOM,在对比过程中直接更新真实DOM
    • 只对比同一层级的变化
    起步

    先修改render函数,将_render方法渲染的方式改为我们即将写的diff算法方式

    /react-dom/index.js

    import diff from './diff';
    const ReactDOM = {
        render
    }
    
    function render(vnode, container) {
        // return container.appendChild(_render(vnode));
        return diff(dom,vnode,container);
    }
    /* 
    

    由上面的diff()可以看出,传入了 真实DOM对象,虚拟DOM对象,根元素

    实现

    实现一个diff算法,它的作用是对比真实的DOM和虚拟DOM,最后返回更新后的DOM

    /react-dom/diff.js

    /*
    dom:真实DOM
    vnode:虚拟DOM
    container: 容器
    return : 更新后的DOM
    */
    export function diff(dom,vnode,container) {
        // 返回更新后的节点
        let ret =  diffNode(dom,vnode);
    
        return ret;
    }
    function diffNode(dom,vnode){
    	//......
    }
    

    接下来实现这个方法

    在这之前先来回忆一下我们虚拟DOM的结构:

    虚拟DOM的结构可以分为三种,分别表示文本,原生DOM节点以及组件

    // 原生DOM节点的vnode
    {
        tag: 'div',
        attrs: {
            className: 'container'
        },
        children: []
    }
    
    // 文本节点的vnode
    "hello,world"
    
    // 组件的vnode
    {
        tag: ComponentConstrucotr,
        attrs: {
            className: 'container'
        },
        children: []
    }
    
    对比文本节点

    首先考虑最简单的文本节点,如果当前的DOM就是文本节点,则直接更新内容,否则就新建一个文本节点,并移除原来的DOM

    function diffNode(dom,vnode) {
        let out = dom;
        if(typeof vnode === 'string'){
          // 如果当前的dom是文本节点,则直接更新内容
          if(dom && dom.nodeType === 3){
              if(dom.textContent !== vnode) dom.textContent = vnode;
          } else{
            //  如果dom不是文本节点,则新建一个文本节点dom,并移除原来的dom
            out = document.createTextNode(vnode);
            if(dom && dom.parentNode){
                dom.parentNode.replaceChild(out,dom);
            }
          }
            return out;
        }
        return out;
    }
    

    文本节点十分简单,它没有属性,也没有子元素

    对比组件

    之前也说过,react组件分为函数组件和类组件,我们定制一个方法diffComponent

    function diffNode(dom,vnode){
        //...
        //如果是一个组件
        if (typeof vnode.tag === 'function') {
            return diffComponent(dom, vnode);
        }
        //....
    }
    function diffComponent(dom, vnode) {
        let comp = dom;
        // 如果组件没有变化,则重新设置 props;   执行
        if (comp && comp.constructor === vnode.tag) {
            // 重新设置 props
            setComponentProp(comp, vnode.attrs);
            // 赋值
            dom = comp.base;
        } else {
            // 如果组件类型变化,则移除掉原来组件,并渲染新组件
            // 移除
            if (comp) {
                unmountComponent(comp);
                comp = null;
            }
            //核心代码
            // 1.创建新组件
            comp = createComponent(vnode.tag, vnode.attrs);
            // 2.设置组件属性
            setComponentProp(comp, vnode.attrs);
            dom = comp.base;
        }
        return dom;
    }
    
    对比非文本DOM节点

    如果vnode表示的是一个非文本DOM节点,分两种情况分析:

    情况一: 如果真实DOM不存在,表示此节点是新增的

    if(!dom){
        updateDOM = document.createElement(vnode.tag);
    }
    

    情况二:如果真实DOM存在,需要对比属性对比子节点

    对比属性

    找出来节点的属性以及事件监听的变化 单独起一个diffAttributes方法

    //设置属性和移除属性的时候 使用到了此方法.注意:移除属性 只需要设置属性值为undefined就可以
    import {
        setAttribute
    } from './index';
    function diffNode(dom,vnode){
        let out = dom;
    	//  ....
        // 如果是非文本DOM节点
        // 真实DOM不存在
        if(!dom){
          out = document.createElement(vnode.tag);
    	}
        // 如果真实DOM存在,需要对比属性和对比子节点
        diffAttributes(out,vnode);
        //...
        return out;
    }
    function diffAttributes(dom, vnode) {
        // 保存之前的真实DOM的所有属性
        const oldAttrs = {};
        const newAttrs = vnode.attrs; //虚拟DOM的属性 (也是最新的属性)
        // 获取真实DOM属性
        const domAttrs = dom.attributes;
        // const domAttrs =  document.querySelector('#root').attributes;
        [...domAttrs].forEach(item => {
            oldAttrs[item.name] = item.value;
        })
        // 比较
        // 如果原来的属性不在新的属性当中,则将其移除掉  (属性值直接设置undefined)
        for (let key in oldAttrs) {
            if (!(key in newAttrs)) {
                // 移除
                setAttribute(dom, key, undefined);
            }
        }
        // 更新新的属性值
        for (let key in newAttrs) {
            if (oldAttrs[key] !== newAttrs[key]) {
                console.log(dom, newAttrs[key], key);
                // 只更值不相等的属性
                setAttribute(dom, key, newAttrs[key]);
            }
        }
    }
    
    对比子节点

    节点对比完成之后,接下来对比它的子节点

    这个时候会有一个问题,前面我们实现的不同的diff算法,都是明确知道哪一个是真实DOM和虚拟DOM对比,但是子节点childrens是一个数组,他们可能改变顺序,或者数量有所变化,我们很难确定是和虚拟DOM对比的是哪一个?

    对比子节点的方法有点复杂,在这里理解一下原理

    function diffNode(dom,vnode){
         if (vnode.childrens && vnode.childrens.length > 0 || (out.childNodes && out.childNodes.length > 0)) {
            diffChildren(out, vnode.childrens);
        }
    }
    
    
    // 对比子节点
    function diffChildren(dom, vchildren) {
        const domChildren = dom.childNodes;
        const children = [];
        const keyed = {};
        // 将有key的节点(用对象保存)和没有key的节点(用数组保存)分开
        if (domChildren.length > 0) {
            [...domChildren].forEach(item => {
                // 获取key
                const key = item.key;
                if (key) {
                    // 如果key存在,保存到对象中
                    keyed[key] = item;
                } else {
                    // 如果key不存在,保存到数组中
                    children.push(item)
                }
    
            })
        }
        if (vchildren && vchildren.length > 0) {
            let min = 0;
            let childrenLen = children.length; //2
            [...vchildren].forEach((vchild, i) => {
                // 获取虚拟DOM中所有的key
                const key = vchild.key;
                let child;
                if (key) {
                    // 如果有key,找到对应key值的节点
                    if (keyed[key]) {
                        child = keyed[key];
                        keyed[key] = undefined;
                    }
                } else if (childrenLen > min) {
                    // alert(1);
                    // 如果没有key,则优先找类型相同的节点
                    for (let j = min; j < childrenLen; j++) {
                        let c = children[j];
                        if (c) {
                            child = c;
                            children[j] = undefined;
                            if (j === childrenLen - 1) childrenLen--;
                            if (j === min) min++;
                            break;
                        }
                    }
                }
                // 对比
                child = diffNode(child, vchild);
                // 更新DOM
                const f = domChildren[i];
                if (child && child !== dom && child !== f) {
                    // 如果更新前的对应位置为空,说明此节点是新增的
                    if (!f) {
                        dom.appendChild(child);
                        // 如果更新后的节点和更新前对应位置的下一个节点一样,说明当前位置的节点被移除了
                    } else if (child === f.nextSibling) {
                        removeNode(f);
                        // 将更新后的节点移动到正确的位置
                    } else {
                        // 注意insertBefore的用法,第一个参数是要插入的节点,第二个参数是已存在的节点
                        dom.insertBefore(child, f);
                    }
                }
            })
        }
    }
    

    最后再修改renderComponent方法的两个地方

    export function renderComponent(comp) {
        let base;
        // 调用render之后 返回jsx对象
        const renderer = comp.render();
        // 实现componentWillUpdate componentDidUpdate componentDidMount生命周期方法
        if (comp.base && comp.componentWillUpdate) {
            comp.componentWillUpdate();
        }
    
        // 调用_render方法,返回当前js节点对象
        // base = _render(renderer);
        //修改
        base = diffNode(comp.base,renderer);
        comp.base = base;
        
        
        if (comp.base) {
            if (comp.componentDidUpdate) comp.componentDidUpdate();
        } else if (comp.componentDidMount) {
            comp.componentDidMount();
        }
    
        // 节点替换 删掉这部分
        // if (comp.base && comp.base.parentNode) {
        //     comp.base.parentNode.replaceChild(base, comp.base);
        // }
        // 为当前组件挂载当前jsx节点对象(貌似也可以不加)
        // comp.base = base;
        // console.log(comp);
        // base._component = comp;
    }
    

    看一下有无diff算法之后的网页效果:

    diff算法网页效果

    React源码解析之手写实现JSX、虚拟DOM、组件、生命周期、diff、setState

    无diff算法网页效果

    React源码解析之手写实现JSX、虚拟DOM、组件、生命周期、diff、setState

    ​ 我们实现了diff算法,通过它做到了每次只更新需要更新的部分,极大地减少了DOM操作。React实现远比这个要复杂,特别是在React 16之后还引入了Fiber架构,但是主要的思想是一致的。

    ​ 实现diff算法可以说性能有了很大的提升,但是在别的地方仍然后很多改进的空间:每次调用setState后会立即调用renderComponent重新渲染组件,但现实情况是,我们可能会在极短的时间内多次调用setState。 假设我们在上文的Counter组件中写出了这种代码

    handlerClick() {
        for ( let i = 0; i < 100; i++ ) {
            this.setState( { num: this.state.num + 1 } );
        }
    }
    

    那以目前的实现,每次点击都会渲染100次组件,对性能肯定有很大的影响。

    下节课改进setState方法

    异步的setState

    上节,虽然我们实现了diff算法,性能有非常大的改进.同时我们也指出了问题:按照目前的实现,每次调用setState都会触发更新,如果组件内执行这样一段代码:

    for ( let i = 0; i < 100; i++ ) {
        this.setState( { num: this.state.num + 1 } );
    }
    

    那么执行这段代码会导致这个组件被重新渲染100次,这对性能是一个非常大的负担

    真正的React是怎么做的
    import React,{Component}from 'react';
    import ReactDOM from 'react-dom';
    class App extends Component {
        constructor(props) {
            super(props);
            this.state = {
                num: 0
            }
        }
        componentDidMount(){
            for (let i = 0; i < 100; i++) {
                this.setState({
                    num: this.state.num + 1
                });
                console.log(this.state.num);
                
            }
        }
        
        render() {
            return (
                <div>
                    <h3>{this.state.num}</h3>
                </div>
            );
        }
    }
    ReactDOM.render(<App name='react'/>,document.querySelector('#root'))
    

    效果显示: 你会发现..... What?为什么输出了100个0,我不是想让它每次循环+1么?而且你还发现页面还渲染了1

    这说明每次循环中,拿到的state仍然是更新之前的

    React源码解析之手写实现JSX、虚拟DOM、组件、生命周期、diff、setState

    看到这种效果,不要惊讶,这是React的优化手段,但是显然它会导致一些不符合直觉的问题,所以针对这种情况,React给出了一种解决方法:setState接收的参数还可以是一个函数,在这个函数中可以获取先前的状态,并通过这个函数的返回值得到下一个状态

    修改组件:

      componentDidMount(){
            for (let i = 0; i < 100; i++) {
                this.setState((prevState)=>{
                    console.log(prevState.num);
                    
                    return {
                        num : prevState.num + 1
                    }
                })  
            }
        }
    

    React源码解析之手写实现JSX、虚拟DOM、组件、生命周期、diff、setState

    通过以上的演示,我们要做两个事情

    1. 异步更新state,将短时间内的多个setState合并成一个
    2. 为了解决异步更新导致的问题,增加另一种形式的setSatet:接受一个函数作为参数,在函数中可以得到前一个状态并返回下一个状态
    合并setState
    setState(stateChange) {
        // 1.外部调用setState之后,更新state
        Object.assign(this.state, stateChange);
        // 2.渲染组件 参数为当前组件(函数),容器
        renderComponent(this);
    }
    

    这种实现,每次调用setState都会更新state并马上渲染一次

    setState队列

    为了合并setState,我们需要一个队列来保存每次setState的数据,然后在一段时间后,清空这个队列来渲染组件

    const setStateQueue = [];
    // 创建队列  
    // 队列: 先进先出
    export function enqueueSetState(stateChange, component) {
        // 入队 让所有设置的状态 都放入到队列中
        setStateQueue.push({
            stateChange,
            component
        })
    }
    

    然后修改组件的setState方法,不再直接更新state和渲染组件,而是添加进队列中

    import {
        enqueueSetState
    } from './set_state_queue';
    class Component {
        constructor(props = {}) {
            this.state = {};
            this.props = props;
        }
        setState(stateChange) {
            // 1.外部调用setState之后,更新state
            // Object.assign(this.state, stateChange);
            // 2.渲染组件 参数为当前组件(函数),容器
            // renderComponent(this);
            enqueueSetState(stateChange,this);
        }
    }
    

    现在队列是有了,怎么清空队列并渲染组件呢?

    清空队列

    定义一个flush方法,它的作用就是清空队列

    function flush() {
        let item;
        // 遍历state
        while (item = setStateQueue.shift()) {
            const {stateChange,component} = item;
            // 如果没有prevState,则将当前的state作为初始的prevState
            if (!component.prevState) {
                component.prevState = Object.assign({}, component.state);
            }
            // 如果stateChange是一个方法,也就是setState的第一种形式
            if (typeof stateChange === 'function') {
                Object.assign(component.state, stateChange(component.prevState, component.props))
            } else {
                // 如果stateChange是一个对象,则直接合并到setState中
                Object.assign(component.state, stateChange);
            }
            component.prevState = component.state;
        }
    
        // 遍历组件
        while (component = renderQueue.shift()) {
            renderComponent(component);
        }
    }
    

    这只是实现state的更新,接下来渲染组件,渲染组件不能遍历时进行,因为同一个组件可能会多次添加到队列中,我们需要另一个队列来保存所有组件,不同在于,这个队列内不会有重复的组件

    我们在enqueueSetState方法中,可以做这件事

    const setStateQueue = [];
    const renderQueue = [];
    function enquequSetState(stateChange,component){
        setStateQueue.push({
            stateChange,
            component
        })
        // 如果renderQueue里没有当前组件,则添加到队列中
        let r = renderQueue.some(item => {
            return item === component;
        })
        if (!r) {
            renderQueue.push(component);
        } 
    }
    

    flush方法中,我们还需要遍历renderQueue,来渲染每一个组件

    function flush(){
    	let item,component;
        while(item = quequ.shift()){
            //....
        }
        // 遍历组件
        while (component = renderQueue.shift()) {
            renderComponent(component);
        }
    }
    

    以上操作,添加状态队列和组件队列,以及更新状态和更新组件的任务都已完成,接下来还剩下最重要的一件事情:什么时候执行flush方法

    我们需要合并一段时间内所有的setState,也就是在一段时间后才执行flush方法来清空队列,关键是这一段时间怎么决定

    答案毫无疑问就是利用js的事件队列机制

    setTimeout( () => {
        console.log( 2 );
    }, 0 );
    Promise.resolve().then( () => console.log( 1 ) );
    console.log( 3 );
    

    你可以打开浏览器的调试工具运行一下,它们打印的结果是:

    3
    1
    2
    

    我们可以利用事件队列,让flush的所有同步任务后执行

    function defer( fn ) {
        return Promise.resolve().then( fn );
    }
    export function enqueueSetState(stateChange, component) {
        // 如果setStateQueue的长度是0,也就是在上次flush执行之后第一次往队列里添加
        if (setStateQueue.length === 0) {
            defer(flush);
        }
        // 入队
        setStateQueue.push({
            stateChange,
            component
        })
        // 如果renderQueue里没有当前组件,则添加到队列中
        let r = renderQueue.some(item => {
            return item === component;
        })
        if (!r) {
            renderQueue.push(component);
        }    
    }
    

    这样在一次“事件循环“中,最多只会执行一次flush了,在这个“事件循环”中,所有的setState都会被合并,并只渲染一次组件。

    我们又实现了一个很重要的优化:合并短时间内的多次setState,异步更新state。 到这里我们已经实现了React的大部分核心功能和优化手段了。

    好了,读到这里,基本的React内部的原理可以跟面试官聊起来了,其实React源码内部还做了很多的事情,大家可以自行去阅读相关的源码。

    觉得不做的倔友。留下你的小心心和赞 谢谢~~~~~


    起源地下载网 » React源码解析之手写实现JSX、虚拟DOM、组件、生命周期、diff、setState

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元