Waxiangyu

Waxiangyu 查看完整档案

上海编辑南京农业大学  |  企业管理 编辑赞同科技股份有限公司  |  前端开发工程师 编辑 github.com/Waxiangyu 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

Waxiangyu 发布了文章 · 4月15日

react

转自React技术揭秘

React15

React15架构可以分为2层:

  • Reconciler(协调器)————负责找出变化的组件,diff
  • Renderer(渲染器)————负责将变化的组件渲染到页面上

Reconciler(协调器)

react是通过this.setState,this.forceUpdate,ReactDOM.renderAPI触发更新的。
每当有更新发生时,Reconciler会做如下工作:

  1. 调用函数组件、或class组件的render方法,将返回的JSX转化为虚拟DOM
  2. 将虚拟DOM和上次更时的虚拟DOM对比
  3. 通过对比找出本次更新中变化的虚拟DOM
  4. 通知Renderer将变化的虚拟DOM渲染到页面上

    Renderer(渲染器)

    由于react支持跨平台,所以不同平台有不同的Renderer,浏览器的是ReactDOM
    由于用递归执行,所以没办法中断,当层级很深时,递归更新时间超过了16ms,用户交互就会卡顿

state=1;
<li>{state.count}</li>//<li>1</li>
<li>{state.count*2}</li>//<li>2</li>

当点一个state+1时更新步骤:

  1. Reconciler发现1需要变为2,通知RendererRenderer更新DOM,1变为2。
  2. Reconciler发现2需要变为4,通知RendererRenderer更新DOM,2变为4。

可以看到,ReconcilerRenderer是交替工作的,当第一个li在页面上已经变化后。第二个li才进入Reconciler。就是发现改变渲染改变,改变就渲染的模式

React16

react16的架构可以分为三层:

  • Scheduler(调度器)————调度任务的优先级,高优任务优先进入Reconciler
  • Reconciler(协调器)————负责找出变化的组件,diff,又被称为render阶段,在此阶段会调用组件的render方法。
  • Renderer(渲染器)————负责将变化的组件渲染到页面上,又被称为commit阶段,就像git commit一样把render阶段的信息提交渲染到页面上。
    rendercommit阶段统称为work

    Scheduler(调度器)

    以浏览器是否有剩余时间作为任务中断的标准,也需要当浏览器有剩余时间时来通知到我们,类似API:requistIdCallback
    就是判断浏览器有无剩余时间,如有按优先级继续执行Reconciler

    Reconciler(协调器)

    react15是用递归来处理虚拟DOMreact16的更新工作从递归变成可以中断的循环过程。

    /** @noinline */
    function workLoopConcurrent() {
      // Perform work until Scheduler asks us to yield
      while (workInProgress !== null && !shouldYield()) {
        workInProgress = performUnitOfWork(workInProgress);
      }
    }

    React16中,ReconcilerRenderer不是交替工作,而是当Scheduler将任务交给Reconciler后,Reconciler会将变化的虚拟DOM打上增/删/改的tag
    整个SchedulerReconciler的工作都在内存中进行,只有当所有组件都完成Reconciler的工作,才会统一交给Renderer渲染

    Renderer(渲染器)

    Renderer根据Reconciler为虚拟DOM打的标记,同步执行对应的DOM操作。

state=1;
<li>{state.count}</li>//<li>1</li>
<li>{state.count*2}</li>//<li>2<li>

当点一个state+1时更新步骤:

  1. Scheduler接收到更新,看下有没有其它高优先更新要执行,没有的放将state.count从1变成2,交给Reconciler
  2. Reconciler接收到更新,找出需要变化的虚拟DOM,发现在1要变成2打tag:Update,又发现了2要变成4再给第二个打上tag:Update。都完了之后将打了标识的虚拟DOMRenderer
  3. Renderer接收到通知,找到打了Update标识的2个虚拟DOM,对它们执行更新DOM的操作。
    2,3步可随时因为有其它高优先级任务先更新或没有剩余时间而中断,但由于2,3都是在内存中进行,不会更新页面上的DOM,所以就算反复中断,用记也不会看到更新一半的DOM

    Fiber

    react15及之前,Reconciler采用递归的方式创建虚拟DOM,递归不能中断,如果组件树层级很深,递归时间就多,线程释放不出来,就会造成卡顿,由于数据保存在递归栈中被称为stack Reconcilerreact16将递归的无法中断更新重构为异步的可中断更新,支持任务不同优先级,可中断与恢复,恢复后可利用之前的中间状态。每个任务更新单元为React Element对应的Fiber节点,基于Fiber节点实现叫Fiber Reconciler

    Fiber的结构

    function FiberNode(
      tag: WorkTag,
      pendingProps: mixed,
      key: null | string,
      mode: TypeOfMode,
    ) {
      // 作为静态数据结构的属性
      this.tag = tag;
      this.key = key;
      this.elementType = null;
      this.type = null;
      this.stateNode = null;
    
      // 用于连接其他Fiber节点形成Fiber树
      this.return = null;// 指向父级Fiber节点
      this.child = null;// 指向子Fiber节点
      this.sibling = null;// 指向右边第一个兄弟Fiber节点
      this.index = 0;
    
      this.ref = null;
    
      // 作为动态的工作单元的属性,保存本次更新造成的状态改变相关信息
      this.pendingProps = pendingProps;
      this.memoizedProps = null;
      this.updateQueue = null;
      this.memoizedState = null;
      this.dependencies = null;
    
      this.mode = mode;
      //保存本次更新会造成的DOM操作
      this.effectTag = NoEffect;
      this.nextEffect = null;
    
      this.firstEffect = null;
      this.lastEffect = null;
    
      // 调度优先级相关
      this.lanes = NoLanes;
      this.childLanes = NoLanes;
    
      // 指向该fiber在另一次更新时对应的fiber
      this.alternate = null;
    }

    作为架构来说,每个Fiber节点有对应的React element,多个Fiber节点是靠this.return,this.child,this.sibling3个属性连接成树的
    如组件结构对应的Fiber树,用return代指父节点

    function App() {
      return (
        <div>
          i am
          <span>KaSong</span>
        </div>
      )
    }

    image.png
    render阶段依次链式执行顺序:

  4. rootFiber beginWork
  5. App Fiber beginWork
  6. div Fiber beginWork
  7. "i am" Fiber beginWork
  8. "i am" Fiber completeWork
  9. span Fiber beginWork
  10. span Fiber completeWork
  11. div Fiber completeWork
  12. App Fiber completeWork
  13. rootFiber completeWoek

    双缓存Fiber

    在内存中构建并直接替换的技术叫双缓存
    react使用双缓存来完成Fiber树的构建与替换--对应着DOM树的创建与更新。
    react中最多会同时存在2棵Fiber树。当前屏幕上显示的Fiber树称为current Fiber,正在内存构建的称为workInProgress Fiber
    当内存的workInprogress Fiber树构建完成交给Renderer渲染在页面上后,应用的要节点的current指针指向workInProgress Fiber树,workInProgress Fiber树就变成了current Fiber树。
    每次状态更新都会产生新的workInProgress Fiber树,通过currentworkInProgress替换,完成DOM更新。在构建workInProgress Fiber树时会尝试复用current Fiber树中已有的Fiber节点内的属性,克隆current.child作为workInProgress.child,而不需要新建workInProgres.child

就是在组件mount时,Reconciler根据JSX描述的组件内容生成组件对应的Fiber节点。在组件第一次mount的时候只有rootFiber上有插入的tag,把Reconciler生成的DOM树全部放在rootFiber下。
update时,ReconcilerJSXFiber节点,保存的数据对比,生成组件对应的Fiber节点,并根据对比结果为Fiber节点打上标记。每个执行完completeWork且存在effectTagFiber节点会被保存在effectList(只包含它的子孙节点)的单向链表中。在commit阶段只要遍历effectList就能执行所有的effect了。

diff

为了降低复杂度,reactdiff预设了3个限制:

  1. 只对同级元素进行diff,如果一个DOM节点在前后2次更新中跨越了层级,那么react就不会复用它了。
  2. 2个不同类型的元素会产生出不同的树,如果元素由div变为preact会销毁div及其子孙节点,并新建p及其子孙节点。
  3. 开发者可以通过key prop来暗示哪些子元素在不同的渲染下保持稳定,如:

    // 更新前
    <div>
      <p key="ka">ka</p>
      <h3 key="song">song</h3>
    </div>
    // 更新后
    <div>
      <h3 key="song">song</h3>
      <p key="ka">ka</p>
    </div>

    如果没有keyreact会认为div的第一个子节点由p变为h3,第二个子节点由h3变为p。符合第2条的规定,会销毁它并重建。
    但是当我们用key指明了节点前后对应关系后,react知道key==='ka'p在更新后还存在,所以DOM节点可以复用,只是需要交接下顺序。

查看原文

赞 0 收藏 0 评论 0

Waxiangyu 发布了文章 · 4月12日

vue、react、angular三大框架对比

概论

angular

angular是一个大而全的MVC框架,它提供了我们所需要的各种功能,如模块管理,双向绑定等等,它涵盖了开发中的各个层面,并且层与层之前都经过了精心调试

react

all in js。很多东西都得自己去手动处理。要学jsx,还有其它一些如redux。也能麻烦。

vue

提供的vue模板,让习惯了html,js,css分离,使代码看起来更方便。封装的一些东西对使用者比较友好,是三者中速度最快,体积最小的一个框架。
Vue宣称可以更快地计算出Virtual DOM的差异,这是由于它在渲染过程中,会跟踪每一个组件的依赖关系,不需要重新渲染整个组件树。

而对于React而言,每当应用的状态被改变时,全部子组件都会重新渲染。当然,这可以通过shouldComponentUpdate这个生命周期方法来进行控制,但Vue将此视为默认的优化。

优缺点

angular

是一个大而全的框架,适用于大中型项目。

学习成本高

react

是一个小而精的库

jsx,很多东西都得自己去手动处理。只注重view层的渲染。而把其它的东西都是有社区完成。也造成了,比如react-router4就跟之前不一样。版本变更不一致的坑爹行为。但是vue和angular则不会都是由官方维护,和框架一起发布变更。

vue

封装的一些东西对使用者比较友好,是三者中速度最快,体积最小的一个框架

选型

angular

如果你需要在一个大型的框架内获取技术资源,框架内的技术通常很容易移植。或者你需要在框架中训练开发人员,并且还有一定的信心,他们会在短期内获得一定的开发能力。
如果你的web应用能够很好的转化为标准的模型——视图模式,那么你也可以忽略其它直接使用。

react

如果你很少需要手把手指导,并且正在寻找更好的库而不是全面的框架,那么react可能是正确的,这一过程,你不仅要对你的团队和组织的能力保持忠诚,还要在你的初始开发过程中,以及在整个应用程序的长期维护过程中保持忠诚。

vue

如果你有一个传统的web应用程序,并需要一个强壮稳健的应用程序层,那么vue可能是一个很好的选择,它有清晰的模式,即使没有经验的团队也能正确的使用它。

v-model的实现

react

class Input extends Component{
    valueChange=(e)=>{
        this.setState({value:e.target.value})
    }
    render(){
        <div>
            <input type="text" value={this.state.value} onChange={this.valueChange}/>
        </div>
    }
}

angular

<input [ngModel]='todo.text' (ngModelChange)='todo.text=$event'/>=
<input [(ngModel)]='todo.text'/>

@Directive({
    selector:'[ngModel]',
    host:{
        '[value]':'ngModl',
        '(input)':'ngModelChanged.next($event.target.value)'
    }
})
class NgModelDirective{
    @Input() ngModel:any;
    @Output() ngModelChanged:EventEmitter=new EventEmitter()
}

vue

<input type='text' value='' id='J_input'/>
<p id='J_p'></p>

let obj={};
Object.defineProperty(obj,'say',{
    set:function(value){
        document.getElementById('J_input').value=value;
        document.getElementById('J_p').innerHTML=value;
    }
})
document.addEventListener('keyup',function(e){
    obj.say=e.target.value;
})
查看原文

赞 0 收藏 0 评论 0

Waxiangyu 发布了文章 · 3月30日

webpack

为会么要用webpack?

  1. 首先是编译loader:针对jsx,ts,等的js通过babel编译,针对less,sass等的css编译.
  2. 文件打包:一般通过打包压缩ugliyJS
  3. 模块化:依赖,网络请示
    image.png
    webpack是一个前端资源动态加载/打包工具,会分析模块的依赖,并将模块根据指定规则生成静态资源。

模块打包运行原理

  1. 读取webpack的配置参数
  2. 启动webpack,创建compiler对象并开始解析项目
  3. 从入口文件entry开始解析,并找到对应导入的依赖模块,递归分析,形成依赖关系树
  4. 对不同文件类型的依赖模块文件使用对应的loader进行编译,最终转为js文件
  5. 整个过程中webpack会通过发布订阅模式,向外抛出一些hooks,而webpack的插件会通过监听这些关键的事件节点,执行插件任务达到干预输出结果的目的。
    其中文件解析与构建是一个比较复杂的过程,在webpack源码中主要依赖于compiler和compilation2个核心对象实现。
    compiler对象是一个全局单例,它负责把控整个webpack打包的构建流程,compilation对象是每一次构建的上下文对象,它包含了当次构建所需要的所有信息,每次热更新和重新构建compiler都会重新生成一个新的compilation对象,负责此次更新的构建过程。
    而每个模块间的依赖关系,则依赖于AST语法树,每个模块文件在loader解析完后,会通过acorn库生成模块代码的AST语法树,通过语法树就可以分析这个模块是否还有依赖的模块,进而继续循环执行下一个模块的编译解析。
    最终webpack打包出来的bundle文件是一个立即执行函数。

webpack配置,webpack.config.js

image.png

首先webpack是js代码,js是需要在运行环境下才能运行的,那我们的webpack就是在nodejs(后端服务器)中运行的。

配置开头的require,用的就是node的内置模块,require是运行webpack时调用.
为什么不用import是因为,import是编译时调用,是解构过程,它也是es6,需要转换成es5再执行,import会转码成require.
webpack的配置文件内容是不经过编译的。

loader

loader是用来编译处理源文件的,比如es6,ts,less等都要通过loader编译成浏览器识别的语言。

loader的执行顺序:从下往上,从右往左。
use:['style-loader','css-loader'];
loader可以链式调用,链中的每个Loader都会对资源进行转换,第一个loader会将结果(被转换后的资源)传递给下一个Loader的入参,最后的loader返回js;
loader的其它配置:
image.png

plugin

plugin主要是对webpack功能的扩展。
插件可以携带参数/选项,所以要在webpack配置中,向Plugin传入new实例。

const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

plugins: [ 
    new HtmlWebpackPlugin({ title: '管理输出'}),//生成新的html文件,并把打包好的js文件引入
    new CleanWebpackPlugin(),//清理dist文件夹
]
 

plugin的机制基于事件流框架tapable,这个小型library是webpack的一个核心工具,也用于以提供类似的插件接口,webpack中许多对象扩展自tapable类,这个类暴露tap,tapAsync和tapPromise方法,使用这些方法,注入自定义的构建步骤,这些步骤将在整个编译过程中不同时机触发。(可以理解成一个生命周期,通过它在编译时期来执行方法);
image.png
如compile,run,compilation等都是变异的不同周期,不同的周期内部,又提供更加细分的hooks.
image.png
compiler和compilation暴露的事件钩子总数超过30个,在特定的阶段钩入想要添加的自定义功能。

根据官方文档说明,一个自定义的plugin需要包含:

  • 一个js命名函数
  • 插件函数的prototype上要有一个apply方法
  • 指定一个绑定到webpack自身的事件钩子
  • 注册一个回调函数来处理webpack实例中的指定数据
  • 处理完后调用webpack提供的回调

SplitChunksPlugin分离包 小即是快
image.png

查看原文

赞 0 收藏 0 评论 0

Waxiangyu 发布了文章 · 2月8日

手写Promise

Promise的用法 :

let p = new Promise(function(resolve, reject){
    console.log(111)
    setTimeout(function(){
        resolve(2)
    }, 1000)
})
p.then(function(res){
    console.log('suc',res)
},function(err){
    console.log('err',err)
})

可以看到new了一个Promise,里面有一个回调函数,回调函数里有2个参数,分别又是另外2个函数。其实里面很多也都是回调函数的封装调用。

const [PENDING,RESOLVED,REJECTED]=["PENDING","RESOLVED","REJECTED"];`

class Promise1 {
    
    constructor(executor){
        state = PENDING;
        value = undefined;
        reason = undefined;
        
        resolve = (value) => {
            if(this.state === PENDING){
                this.state = RESOLVED;
                this.value = value;
            }
        }
        reject = (reason) => {
            if(this.state === PENDING){
                this.state = REJECTED;
                this.value = reason;
            }
        }
        
        try{
            executor(this.resolve,this.reject)
        }catch(e){
            reject(e)
        }
    }
 }

测试一下执行情况:

let p1 = new Promise1((resolve,reject)=>{//function:executor
    console.log(111)
    setTimeout(()=>{
        resolve(111)//this.resolve,直接传参调用
    }, 1000)
})

下面就是then

class Promise1 {
    constructor(executor){
        ...
        onResolvedCallbacks=[]; //then,pending里先存起来
        onRejectedCallbacks=[];

        ...

        resolve = (value) => {
            if(this.state === PENDING){
                ...
                //executor里如果是异步会把then里存起来,然后executor走完了resolved的话,会再调then里回调函数
                this.onResolvedCallbacks.forEach(fn => fn(value))
            }
        }
        reject = (reason) => {
            if(this.state === PENDING){
                ...
               this.onRejectedCallbacks.forEach(fn => fn(reason))
            }
        }
    }
    
    then(onResolved,onRejected){
        onResolved = typeof onResolved === 'function' ? onResolved : value => value;
        onRejected = typeof onRejected === 'function' ? onRejected : reason => {
            throw reason
        };
        
        switch(this.state){
            case RESOLVED : //状态变了,就直接把结果给出去
                onResolved(this.value);
            case REJECTED :
                onRejected(this.reason);
            case PENDING : 
            //如果是pending的话,说明executor还没走完,先存起来,走完再调用
                this.onResolvedCallbacks.push(onResolved);
                this.onRejectedCallbacks.push(onRejected)
        }
    }
}

测试一下 :

let p2 = new Promise1((resolve, reject) => {
    setTimeout(()=>{
        resolve(222)
    }, 1000)
})
p2.then(res => console.log(res, 'res')) //222

链式调用:

class Promise1 {
    ...
    
    
    then(onResolved,onRejected){
        ...
        //返回一个promise1
        let newPromise=new Promise1((resolve,reject)=>{
            switch(this.state){
                case RESOLVED : //状态变了,就直接把结果给出去
                    setTimeout(()=>{//then,放后面处理
                        try{
                        let reValue=onResolved(this.value);
 rePromise(newPromise,reValue,resolve,reject)
                        }catch(e){reject(e)}
                    },0)
                    
                case REJECTED :
                    setTimeout(()=>{
                        try{
                            let reValue=onRejected(this.reason);
                            rePromise(newPromise,reValue,resolve,reject)
                        }catch(e){reject(e)}
                    },0)
                    
                case PENDING : 
                //如果是pending的话,说明executor还没走完,先存起来,走完再调用
                    this.onResolvedCallbacks.push(()=>{
                        setTimeout(()=>{
                            try{
                                let reValue=onResolved(this.value);
 rePromise(newPromise,reValue,resolve,reject)
                            }catch(e){reject(e)}
                        },0)
                    });
                    this.onRejectedCallbacks.push(()=>{
                        setTimeout(()=>{
                            try{
                                let reValue=onRejected(this.reason);
                            rePromise(newPromise,reValue,resolve,reject)
                            }catch(e){reject(e)}
                        },0)
                    })
            }
        })
        return newPromise;
    }
}

//newPromise:新的promise1对象,reValue:上一个then的返回值,newPromise.resolve,newPromise.reject
function rePromise(newPromise,reValue,resolve,reject){
    //相当于把自己return出去了,let p2 = p.then(res => p2);
    if(newPromise === reValue){
        reject(new TypeError('循环了'))
    }
    if(reValue !== null && (typeof reValue === 'object' || typeof reValue === 'function')){
        try{
    //判断一下reValue是不是promise1,是个对象,还带有then函数
    //如果有then并是个函数,就调then; 
            let then = reValue.then;
            if(typeof then === 'function'){
                then.call(reValue, res=>{
                    resolve(res)
                },err=>{
                    reject(err)
                })
            }else{//then不是函数,直接用resolve返回值
                resolve(reValue)
            }
        }catch(e){
            reject(e)
        }
    }else{//只是个普通值
        resolve(reValue)
    }
}

测试一下:

p3.then(res => {
    console.log(res)
    return new Promise1((resolve, reject) => {
        resolve('p3')
    })
}).then(res => console.log(res, 'pp3'))//p3

但还有个问题

...

function rePromise(newPromise,reValue,resolve,reject){
    ...
    let called;//是否调用过,防止多次调用
    if(reValue !== null && (typeof reValue === 'object' || typeof reValue === 'function')){
        try{
   
            let then = reValue.then;
            if(typeof then === 'function'){
                then.call(reValue, res=>{
                    if(called) return;
                    called = true;
                   //promise1就继续下一个,直到then走完,变成普通的值 
                   rePromise(newPromise,res,resolve,reject)
                },err=>{
                    if(called) return;
                    called = true;
                    reject(err)
                })
            }else{//then不是函数,直接用resolve返回值
                resolve(reValue)
            }
        }catch(e){
            if(called) return;
            called = true;
            reject(e)
        }
    }else{//只是个普通值
        resolve(reValue)
    }
}

测试一下

    let p1=new Promise1((resolve,reject)=>{
        resolve(111)
    })
    let p2=p1.then(res=>{console.log(res,'p1')},err=>console.log(err,'p1'));
    p2.then(res=>console.log(res,'p2'))
    .then(res=>console.log(res,'p3))

还有其它属性:

class Promise1 {
    ...
    catch(onReject){
        return this.then(null,onReject)
    }
    defer(){
        let defer={};
        defer.promise=new Promise1((resolve,reject)=>{
            defer.resolve=resolve;
            defer.reject=reject;
        })
        return defer;
    }
    deffered(){
        return this.defer;
    }
    all(promises){
        return new Promise1((resolve,reject)=>{
            let done=gen(promises.length,resolve);
            for(let i=0;i<promises.length,i++){
                promises[i].then(res=>{
                    done(i,res)
                },reject)
            }
        })
    }
    race(promises){
        return new Promise1((resolve,reject)=>{
            for(let i=0;i<promises.length;i++){
                promises[i].then(resolve,reject)
            }
        })
    }
    resolve(value){
        return new Promise1((resolve,reject)=>{
            resolve(value)
        })
    }
    reject(reason){
        return new Promise1((resolve,reject)=>{
            reject(reason)
        })
    }
}

function gen(times,cb){
    const result=[];
    let count=0;
    return (i,data)=>{
        result[i]=data;
        count+=1;
        if(count===times){
            cb(result)
        }
    }
}
查看原文

赞 5 收藏 3 评论 0

Waxiangyu 收藏了文章 · 2020-08-04

React Fiber 原理介绍

欢迎关注我的公众号睿Talk,获取我最新的文章:
clipboard.png

一、前言

在 React Fiber 架构面世一年多后,最近 React 又发布了最新版 16.8.0,又一激动人心的特性:React Hooks 正式上线,让我升级 React 的意愿越来越强烈了。在升级之前,不妨回到原点,了解下人才济济的 React 团队为什么要大费周章,重写 React 架构,而 Fiber 又是个什么概念。

二、React 15 的问题

在页面元素很多,且需要频繁刷新的场景下,React 15 会出现掉帧的现象。请看以下例子:
https://claudiopro.github.io/...

clipboard.png

其根本原因,是大量的同步计算任务阻塞了浏览器的 UI 渲染。默认情况下,JS 运算、页面布局和页面绘制都是运行在浏览器的主线程当中,他们之间是互斥的关系。如果 JS 运算持续占用主线程,页面就没法得到及时的更新。当我们调用setState更新页面的时候,React 会遍历应用的所有节点,计算出差异,然后再更新 UI。整个过程是一气呵成,不能被打断的。如果页面元素很多,整个过程占用的时机就可能超过 16 毫秒,就容易出现掉帧的现象。

针对这一问题,React 团队从框架层面对 web 页面的运行机制做了优化,得到很好的效果。

clipboard.png

三、解题思路

解决主线程长时间被 JS 运算占用这一问题的基本思路,是将运算切割为多个步骤,分批完成。也就是说在完成一部分任务之后,将控制权交回给浏览器,让浏览器有时间进行页面的渲染。等浏览器忙完之后,再继续之前未完成的任务。

旧版 React 通过递归的方式进行渲染,使用的是 JS 引擎自身的函数调用栈,它会一直执行到栈空为止。而Fiber实现了自己的组件调用栈,它以链表的形式遍历组件树,可以灵活的暂停、继续和丢弃执行的任务。实现方式是使用了浏览器的requestIdleCallback这一 API。官方的解释是这样的:

window.requestIdleCallback()会在浏览器空闲时期依次调用函数,这就可以让开发者在主事件循环中执行后台或低优先级的任务,而且不会对像动画和用户交互这些延迟触发但关键的事件产生影响。函数一般会按先进先调用的顺序执行,除非函数在浏览器调用它之前就到了它的超时时间。

有了解题思路后,我们再来看看 React 具体是怎么做的。

四、React 的答卷

React 框架内部的运作可以分为 3 层:

  • Virtual DOM 层,描述页面长什么样。
  • Reconciler 层,负责调用组件生命周期方法,进行 Diff 运算等。
  • Renderer 层,根据不同的平台,渲染出相应的页面,比较常见的是 ReactDOM 和 ReactNative。

这次改动最大的当属 Reconciler 层了,React 团队也给它起了个新的名字,叫Fiber Reconciler。这就引入另一个关键词:Fiber。

Fiber 其实指的是一种数据结构,它可以用一个纯 JS 对象来表示:

const fiber = {
    stateNode,    // 节点实例
    child,        // 子节点
    sibling,      // 兄弟节点
    return,       // 父节点
}

为了加以区分,以前的 Reconciler 被命名为Stack Reconciler。Stack Reconciler 运作的过程是不能被打断的,必须一条道走到黑:

clipboard.png

而 Fiber Reconciler 每执行一段时间,都会将控制权交回给浏览器,可以分段执行:

clipboard.png

为了达到这种效果,就需要有一个调度器 (Scheduler) 来进行任务分配。任务的优先级有六种:

  • synchronous,与之前的Stack Reconciler操作一样,同步执行
  • task,在next tick之前执行
  • animation,下一帧之前执行
  • high,在不久的将来立即执行
  • low,稍微延迟执行也没关系
  • offscreen,下一次render时或scroll时才执行

优先级高的任务(如键盘输入)可以打断优先级低的任务(如Diff)的执行,从而更快的生效。

Fiber Reconciler 在执行过程中,会分为 2 个阶段。

clipboard.png

  • 阶段一,生成 Fiber 树,得出需要更新的节点信息。这一步是一个渐进的过程,可以被打断。
  • 阶段二,将需要更新的节点一次过批量更新,这个过程不能被打断。

阶段一可被打断的特性,让优先级更高的任务先执行,从框架层面大大降低了页面掉帧的概率。

五、Fiber 树

Fiber Reconciler 在阶段一进行 Diff 计算的时候,会生成一棵 Fiber 树。这棵树是在 Virtual DOM 树的基础上增加额外的信息来生成的,它本质来说是一个链表。

clipboard.png

Fiber 树在首次渲染的时候会一次过生成。在后续需要 Diff 的时候,会根据已有树和最新 Virtual DOM 的信息,生成一棵新的树。这颗新树每生成一个新的节点,都会将控制权交回给主线程,去检查有没有优先级更高的任务需要执行。如果没有,则继续构建树的过程:

clipboard.png

如果过程中有优先级更高的任务需要进行,则 Fiber Reconciler 会丢弃正在生成的树,在空闲的时候再重新执行一遍。

在构造 Fiber 树的过程中,Fiber Reconciler 会将需要更新的节点信息保存在Effect List当中,在阶段二执行的时候,会批量更新相应的节点。

六、总结

本文从 React 15 存在的问题出发,介绍 React Fiber 解决问题的思路,并介绍了 Fiber Reconciler 的工作流程。从Stack ReconcilerFiber Reconciler,源码层面其实就是干了一件递归改循环的事情,日后有机会的话,我再结合源码作进一步的介绍。

查看原文

Waxiangyu 收藏了文章 · 2020-08-02

2020最新:100道有答案的前端面试题(上)

网上的面试题一大堆,鱼龙混杂,一方面多数题目质量不高,另一方面有答案的很少,即使拿到面试题对自己的帮助也不大。

最近我花了一些时间,为大家整理了2020年各一、二线互联网公司的前端面试题,内容包括JavaScript、算法、网络&安全、Vue、React等大量的前端知识点和相关面试题。答案和解析也整理在文中了,整理不易,麻烦各位走过路过的壮士给颗star,如果可以star fork watch三连更好,感谢😄 🙏

因篇幅太长,本文收录前50道,后50道将在下篇更新,欢迎关注。

173382ede7319973.gif

1.写一个 mySetInterVal(fn, a, b),每次间隔 a,a+b,a+2b 的时间,然后写一个 myClear,停止上面的 mySetInterVal

公司:头条

分类:JavaScript

答案&解析

2.合并二维有序数组成一维有序数组,归并排序的思路

公司:头条

分类:算法

答案&解析

3.斐波那契数列

公司:腾讯、CVTE、微软

分类:算法

答案&解析

4.字符串出现的不重复最长长度

公司:腾讯

分类:算法

答案&解析

5.介绍chrome 浏览器的几个版本

公司:滴滴

分类:网络&安全

答案&解析

6.React 项目中有哪些细节可以优化?实际开发中都做过哪些性能优化

公司:滴滴、掌门一对一、网易、有赞、沪江、喜马拉雅、酷家乐、快手

分类:React

答案&解析

7.react 最新版本解决了什么问题 加了哪些东西

公司:滴滴

分类:React

答案&解析

8.说一下 Http 缓存策略,有什么区别,分别解决了什么问题

公司:滴滴、头条、网易、易车、脉脉、掌门一对一、虎扑、挖财、爱范儿

分类:网络&安全

答案&解析

9.介绍防抖节流原理、区别以及应用,并用JavaScript进行实现

公司:滴滴、虎扑、挖财、58、头条

分类:JavaScript、编程题

答案&解析

10.前端安全、中间人攻击

公司:滴滴

分类:网络&安全

答案&解析


前端刷题神器

扫码进入前端面试星球🌍,解锁刷题神器,还可以获取800+道前端面试题一线常见面试高频考点

173382ede7319973.gif


11.对闭包的看法,为什么要用闭包?说一下闭包原理以及应用场景

公司:滴滴、携程、喜马拉雅、微医、蘑菇街、酷家乐、腾讯应用宝、安居客

分类:JavaScript

答案&解析

12.css 伪类与伪元素区别

公司:滴滴

分类:Css

答案&解析

13.有一堆整数,请把他们分成三份,确保每一份和尽量相等(11,42,23,4,5,6 4 5 6 11 23 42 56 78 90)

公司:滴滴

分类:算法

答案&解析

14.实现 lodash 的_.get

公司:滴滴

分类:JavaScript

答案&解析

15.实现 add(1)(2)(3)

公司:滴滴

分类:JavaScript

答案&解析

16.实现链式调用

公司:滴滴

分类:JavaScript

答案&解析

17.React 事件绑定原理

公司:滴滴、沪江

分类:React

答案&解析

18.类数组和数组的区别,dom 的类数组如何转换成数组

公司:海康威视

分类:JavaScript

答案&解析

19.webpack 做过哪些优化,开发效率方面、打包策略方面等等

公司:滴滴、快手、掌门一对一、高思教育

分类:工程化

答案&解析

20.说一下事件循环机制(node、浏览器)

公司:滴滴、伴鱼、高德、自如、虎扑、58

分类:Node、JavaScript

答案&解析

21.如何封装 node 中间件

公司:滴滴、酷狗

分类:Node

答案&解析

22.node 中间层怎样做的请求合并转发

公司:易车

分类:Node

答案&解析

23.介绍下 promise 的特性、优缺点,内部是如何实现的,动手实现 Promise

公司:滴滴、头条、喜马拉雅、兑吧、寺库、百分点、58、安居客

分类:JavaScript、编程题

答案&解析

24.实现 Promise.all

Promise.all = function (arr) {
  // 实现代码
};

公司:滴滴、头条、有赞、微医

分类:JavaScript、编程题

答案&解析

25.React 组件通信方式

公司:滴滴、掌门一对一、喜马拉雅、蘑菇街

分类:React

答案&解析

26.redux-saga 和 mobx 的比较

公司:掌门一对一

分类:React

答案&解析

27.说一下 react-fiber

公司:头条、滴滴、菜鸟网络、挖财、喜马拉雅

分类:React

答案&解析

28.手写发布订阅

公司:滴滴、头条

分类:JavaScript

答案&解析

29.手写数组转树

公司:滴滴

分类:JavaScript

答案&解析

30.手写用 ES6proxy 如何实现 arr[-1] 的访问

公司:滴滴

分类:JavaScript

答案&解析

31.请写出下面代码执行的的结果

console.log(1);
setTimeout(() => {
  console.log(2);
  process.nextTick(() => {
    console.log(3);
  });
  new Promise((resolve) => {
    console.log(4);
    resolve();
  }).then(() => {
    console.log(5);
  });
});
new Promise((resolve) => {
  console.log(7);
  resolve();
}).then(() => {
  console.log(8);
});
process.nextTick(() => {
  console.log(6);
});
setTimeout(() => {
  console.log(9);
  process.nextTick(() => {
    console.log(10);
  });
  new Promise((resolve) => {
    console.log(11);
    resolve();
  }).then(() => {
    console.log(12);
  });
});

分类:JavaScript

答案&解析


173382ede7319973.gif

31.写出执行结果

function side(arr) {
  arr[0] = arr[2];
}
function a(a, b, c = 3) {
  c = 10;
  side(arguments);
  return a + b + c;
}
a(1, 1, 1);

分类:JavaScript

答案&解析

32.写出执行结果

var min = Math.min();
max = Math.max();
console.log(min < max);

分类:JavaScript

答案&解析

33.写出执行结果,并解释原因

var a = 1;
(function a () {
    a = 2;
    console.log(a);
})();

分类:JavaScript

答案&解析

34.写出执行结果,并解释原因

var a = [0];
if (a) {
  console.log(a == true);
} else {
  console.log(a);
}

分类:JavaScript

答案&解析

35.写出执行结果,并解释原因

(function () {
  var a = (b = 5);
})();

console.log(b);
console.log(a);

分类:JavaScript

答案&解析

36.写出执行结果,并解释原因

var fullname = 'a';
var obj = {
   fullname: 'b',
   prop: {
      fullname: 'c',
      getFullname: function() {
         return this.fullname;
      }
   }
};
 
console.log(obj.prop.getFullname()); // c
var test = obj.prop.getFullname;
console.log(test());  // a

分类:JavaScript

答案&解析

37.写出执行结果,并解释原因

var company = {
    address: 'beijing'
}
var yideng = Object.create(company);
delete yideng.address
console.log(yideng.address);

分类:JavaScript

答案&解析

38.写出执行结果,并解释原因

var foo = function bar(){ return 12; };
console.log(typeof bar());  

分类:JavaScript

答案&解析

39.写出执行结果,并解释原因

var x=1;
if(function f(){}){
    x += typeof f;
}
console.log(x)

分类:JavaScript

答案&解析

40.写出执行结果,并解释原因

function f(){
      return f;
 }
console.log(new f() instanceof f);

分类:JavaScript

答案&解析

41.写出执行结果,并解释原因

var foo = {
        bar: function(){
            return this.baz;
        },
         baz:1
    }
console.log(typeof (f=foo.bar)());

分类:JavaScript

答案&解析

42.说一下React Hooks在平时开发中需要注意的问题和原因?

答案&解析


43.Vue组件中写name选项有除了搭配keep-alive还有其他作用么?你能谈谈你对keep-alive了解么?(平时使用和源码实现方面)

分类:Vue

答案&解析

44.Vue 为什么要用 vm.$set() 解决对象新增属性不能响应的问题 ?你能说说如下代码的实现原理么?

Vue.set (object, propertyName, value) 
vm.$set (object, propertyName, value)

分类:Vue

答案&解析

45.既然 Vue 通过数据劫持可以精准探测数据在具体dom上的变化,为什么还需要虚拟 DOM diff 呢?

分类:Vue

答案&解析

分类:Vue

答案&解析

46.下面代码输出什么?

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 1);
}

分类:JavaScript

答案&解析

47.写出执行结果,并解释原因

const num = {
  a: 10,
  add() {
    return this.a + 2;
  },
  reduce: () => this.a -2;
};
console.log(num.add());
console.log(num.reduce());

分类:JavaScript

答案&解析

48.写出执行结果,并解释原因

const person = { name: "yideng" };

function sayHi(age) {
  return `${this.name} is ${age}`;
}
console.log(sayHi.call(person, 5));
console.log(sayHi.bind(person, 5));

分类:JavaScript

答案&解析

49.写出执行结果,并解释原因

["1", "2", "3"].map(parseInt);

分类:JavaScript

答案&解析

50.写出执行结果,并解释原因

[typeof null, null instanceof Object]

分类:JavaScript

答案&解析

前端刷题神器

扫码进入前端面试星球🌍,解锁刷题神器,还可以获取800+道前端面试题一线常见面试高频考点

173382ede7319973.gif


本文首发微信公众号:前端先锋

欢迎扫描二维码关注公众号,每天都给你推送新鲜的前端技术文章

欢迎扫描二维码关注公众号,每天都给你推送新鲜的前端技术文章

欢迎继续阅读本专栏其它高赞文章:


查看原文

Waxiangyu 收藏了文章 · 2020-08-02

手写React的Fiber架构,深入理解其原理

熟悉React的朋友都知道,React支持jsx语法,我们可以直接将HTML代码写到JS中间,然后渲染到页面上,我们写的HTML如果有更新的话,React还有虚拟DOM的对比,只更新变化的部分,而不重新渲染整个页面,大大提高渲染效率。到了16.x,React更是使用了一个被称为Fiber的架构,提升了用户体验,同时还引入了hooks等特性。那隐藏在React背后的原理是怎样的呢,Fiberhooks又是怎么实现的呢?本文会从jsx入手,手写一个简易版的React,从而深入理解React的原理。

本文主要实现了这些功能:

简易版Fiber架构

简易版DIFF算法

简易版函数组件

简易版Hook: useState

娱乐版Class组件

本文代码地址:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/fiber-and-hooks

本文程序跑起来效果如下:

Jun-19-2020 17-01-28

JSX和creatElement

以前我们写React要支持JSX还需要一个库叫JSXTransformer.js,后来JSX的转换工作都集成到了babel里面了,babel还提供了在线预览的功能,可以看到转换后的效果,比如下面这段简单的代码:

const App =
(
  <div>
    <h1 id="title">Title</h1>
    <a href="xxx">Jump</a>
    <section>
      <p>
        Article
      </p>
    </section>
  </div>
);

经过babel转换后就变成了这样:

image-20200608175937104

上面的截图可以看出我们写的HTML被转换成了React.createElement,我们将上面代码稍微格式化来看下:

var App = React.createElement(
  'div',
  null,
  React.createElement(
    'h1',
    {
      id: 'title',
    },
    'Title',
  ),
  React.createElement(
    'a',
    {
      href: 'xxx',
    },
    'Jump',
  ),
  React.createElement(
    'section',
    null,
    React.createElement('p', null, 'Article'),
  ),
);

从转换后的代码我们可以看出React.createElement支持多个参数:

  1. type,也就是节点类型
  2. config, 这是节点上的属性,比如idhref
  3. children, 从第三个参数开始就全部是children也就是子元素了,子元素可以有多个,类型可以是简单的文本,也可以还是React.createElement,如果是React.createElement,其实就是子节点了,子节点下面还可以有子节点。这样就用React.createElement的嵌套关系实现了HTML节点的树形结构。

让我们来完整看下这个简单的React页面代码:

image-20200608180112829

渲染在页面上是这样:

image-20200608180139663

这里面用到了React的地方其实就两个,一个是JSX,也就是React.createElement,另一个就是ReactDOM.render,所以我们手写的第一个目标就有了,就是createElementrender这两个方法。

手写createElement

对于<h1 id="title">Title</h1>这样一个简单的节点,原生DOM也会附加一大堆属性和方法在上面,所以我们在createElement的时候最好能将它转换为一种比较简单的数据结构,只包含我们需要的元素,比如这样:

{
  type: 'h1',
  props: {
    id: 'title',
    children: 'Title'
  }
}

有了这个数据结构后,我们对于DOM的操作其实可以转化为对这个数据结构的操作,新老DOM的对比其实也可以转化为这个数据结构的对比,这样我们就不需要每次操作都去渲染页面,而是等到需要渲染的时候才将这个数据结构渲染到页面上。这其实就是虚拟DOM!而我们createElement就是负责来构建这个虚拟DOM的方法,下面我们来实现下:

function createElement(type, props, ...children) {
  // 核心逻辑不复杂,将参数都塞到一个对象上返回就行
  // children也要放到props里面去,这样我们在组件里面就能通过this.props.children拿到子元素
  return {
    type,
    props: {
      ...props,
      children
    }
  }
}

上述代码是React的createElement简化版,对源码感兴趣的朋友可以看这里:https://github.com/facebook/react/blob/60016c448bb7d19fc989acd05dda5aca2e124381/packages/react/src/ReactElement.js#L348

手写render

上述代码我们用createElement将JSX代码转换成了虚拟DOM,那真正将它渲染到页面的函数是render,所以我们还需要实现下这个方法,通过我们一般的用法ReactDOM.render( <App />,document.getElementById('root'));可以知道他接收两个参数:

  1. 根组件,其实是一个JSX组件,也就是一个createElement返回的虚拟DOM
  2. 父节点,也就是我们要将这个虚拟DOM渲染的位置

有了这两个参数,我们来实现下render方法:

function render(vDom, container) {
  let dom;
  // 检查当前节点是文本还是对象
  if(typeof vDom !== 'object') {
    dom = document.createTextNode(vDom)
  } else {
    dom = document.createElement(vDom.type);
  }

  // 将vDom上除了children外的属性都挂载到真正的DOM上去
  if(vDom.props) {
    Object.keys(vDom.props)
      .filter(key => key != 'children')
      .forEach(item => {
        dom[item] = vDom.props[item];
      })
  }
  
  // 如果还有子元素,递归调用
  if(vDom.props && vDom.props.children && vDom.props.children.length) {
    vDom.props.children.forEach(child => render(child, dom));
  }

  container.appendChild(dom);
}

上述代码是简化版的render方法,对源码感兴趣的朋友可以看这里:https://github.com/facebook/react/blob/3e94bce765d355d74f6a60feb4addb6d196e3482/packages/react-dom/src/client/ReactDOMLegacy.js#L287

现在我们可以用自己写的createElementrender来替换原生的方法了:

image-20200608180301596

可以得到一样的渲染结果:

image-20200608180139663

为什么需要Fiber

上面我们简单的实现了虚拟DOM渲染到页面上的代码,这部分工作被React官方称为renderer,renderer是第三方可以自己实现的一个模块,还有个核心模块叫做reconciler,reconciler的一大功能就是大家熟知的diff,他会计算出应该更新哪些页面节点,然后将需要更新的节点虚拟DOM传递给renderer,renderer负责将这些节点渲染到页面上。但是这个流程有个问题,虽然React的diff算法是经过优化的,但是他却是同步的,renderer负责操作DOM的appendChild等API也是同步的,也就是说如果有大量节点需要更新,JS线程的运行时间可能会比较长,在这段时间浏览器是不会响应其他事件的,因为JS线程和GUI线程是互斥的,JS运行时页面就不会响应,这个时间太长了,用户就可能看到卡顿,特别是动画的卡顿会很明显。在React的官方演讲中有个例子,可以很明显的看到这种同步计算造成的卡顿:

1625d95bc100c7fe

而Fiber就是用来解决这个问题的,Fiber可以将长时间的同步任务拆分成多个小任务,从而让浏览器能够抽身去响应其他事件,等他空了再回来继续计算,这样整个计算流程就显得平滑很多。下面是使用Fiber后的效果:

1625d95bc2baf0e1

怎么来拆分

上面我们自己实现的render方法直接递归遍历了整个vDom树,如果我们在中途某一步停下来,下次再调用时其实并不知道上次在哪里停下来的,不知道从哪里开始,所以vDom的树形结构并不满足中途暂停,下次继续的需求,需要改造数据结构。另一个需要解决的问题是,拆分下来的小任务什么时候执行?我们的目的是让用户有更流畅的体验,所以我们最好不要阻塞高优先级的任务,比如用户输入,动画之类,等他们执行完了我们再计算。那我怎么知道现在有没有高优先级任务,浏览器是不是空闲呢?总结下来,Fiber要想达到目的,需要解决两个问题:

  1. 新的任务调度,有高优先级任务的时候将浏览器让出来,等浏览器空了再继续执行
  2. 新的数据结构,可以随时中断,下次进来可以接着执行

requestIdleCallback

requestIdleCallback是一个实验中的新API,这个API调用方式如下:

// 开启调用
var handle = window.requestIdleCallback(callback[, options])

// 结束调用
Window.cancelIdleCallback(handle) 

requestIdleCallback接收一个回调,这个回调会在浏览器空闲时调用,每次调用会传入一个IdleDeadline,可以拿到当前还空余多久,options可以传入参数最多等多久,等到了时间浏览器还不空就强制执行了。使用这个API可以解决任务调度的问题,让浏览器在空闲时才计算diff并渲染。更多关于requestIdleCallback的使用可以查看MDN的文档。但是这个API还在实验中,兼容性不好,所以React官方自己实现了一套。本文会继续使用requestIdleCallback来进行任务调度,我们进行任务调度的思想是将任务拆分成多个小任务,requestIdleCallback里面不断的把小任务拿出来执行,当所有任务都执行完或者超时了就结束本次执行,同时要注册下次执行,代码架子就是这样:

function workLoop(deadline) {
  while(nextUnitOfWork && deadline.timeRemaining() > 1) {
    // 这个while循环会在任务执行完或者时间到了的时候结束
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }

  // 如果任务还没完,但是时间到了,我们需要继续注册requestIdleCallback
  requestIdleCallback(workLoop);
}

// performUnitOfWork用来执行任务,参数是我们的当前fiber任务,返回值是下一个任务
function performUnitOfWork(fiber) {
  
}
requestIdleCallback(workLoop);

上述workLoop对应React源码看这里。

Fiber可中断数据结构

上面我们的performUnitOfWork并没有实现,但是从上面的结构可以看出来,他接收的参数是一个小任务,同时通过这个小任务还可以找到他的下一个小任务,Fiber构建的就是这样一个数据结构。Fiber之前的数据结构是一棵树,父节点的children指向了子节点,但是只有这一个指针是不能实现中断继续的。比如我现在有一个父节点A,A有三个子节点B,C,D,当我遍历到C的时候中断了,重新开始的时候,其实我是不知道C下面该执行哪个的,因为只知道C,并没有指针指向他的父节点,也没有指针指向他的兄弟。Fiber就是改造了这样一个结构,加上了指向父节点和兄弟节点的指针:

image-20200609173312276

上面的图片还是来自于官方的演讲,可以看到和之前父节点指向所有子节点不同,这里有三个指针:

  1. child: 父节点指向第一个子元素的指针。
  2. sibling:从第一个子元素往后,指向下一个兄弟元素。
  3. return:所有子元素都有的指向父元素的指针。

有了这几个指针后,我们可以在任意一个元素中断遍历并恢复,比如在上图List处中断了,恢复的时候可以通过child找到他的子元素,也可以通过return找到他的父元素,如果他还有兄弟节点也可以用sibling找到。Fiber这个结构外形看着还是棵树,但是没有了指向所有子元素的指针,父节点只指向第一个子节点,然后子节点有指向其他子节点的指针,这其实是个链表。

实现Fiber

现在我们可以自己来实现一下Fiber了,我们需要将之前的vDom结构转换为Fiber的数据结构,同时需要能够通过其中任意一个节点返回下一个节点,其实就是遍历这个链表。遍历的时候从根节点出发,先找子元素,如果子元素存在,直接返回,如果没有子元素了就找兄弟元素,找完所有的兄弟元素后再返回父元素,然后再找这个父元素的兄弟元素。整个遍历过程其实是个深度优先遍历,从上到下,然后最后一行开始从左到右遍历。比如下图从div1开始遍历的话,遍历的顺序就应该是div1 -> div2 -> h1 -> a -> div2 -> p -> div1。可以看到这个序列中,当我们return父节点时,这些父节点会被第二次遍历,所以我们写代码时,return的父节点不会作为下一个任务返回,只有siblingchild才会作为下一个任务返回。

image-20200610162336915

// performUnitOfWork用来执行任务,参数是我们的当前fiber任务,返回值是下一个任务
function performUnitOfWork(fiber) {
  // 根节点的dom就是container,如果没有这个属性,说明当前fiber不是根节点
  if(!fiber.dom) {
    fiber.dom = createDom(fiber);   // 创建一个DOM挂载上去
  } 

  // 如果有父节点,将当前节点挂载到父节点上
  if(fiber.return) {
    fiber.return.dom.appendChild(fiber.dom);
  }

  // 将我们前面的vDom结构转换为fiber结构
  const elements = fiber.children;
  let prevSibling = null;
  if(elements && elements.length) {
    for(let i = 0; i < elements.length; i++) {
      const element = elements[i];
      const newFiber = {
        type: element.type,
        props: element.props,
        return: fiber,
        dom: null
      }

      // 父级的child指向第一个子元素
      if(i === 0) {
        fiber.child = newFiber;
      } else {
        // 每个子元素拥有指向下一个子元素的指针
        prevSibling.sibling = newFiber;
      }

      prevSibling = newFiber;
    }
  }

  // 这个函数的返回值是下一个任务,这其实是一个深度优先遍历
  // 先找子元素,没有子元素了就找兄弟元素
  // 兄弟元素也没有了就返回父元素
  // 然后再找这个父元素的兄弟元素
  // 最后到根节点结束
  // 这个遍历的顺序其实就是从上到下,从左到右
  if(fiber.child) {
    return fiber.child;
  }

  let nextFiber = fiber;
  while(nextFiber) {
    if(nextFiber.sibling) {
      return nextFiber.sibling;
    }

    nextFiber = nextFiber.return;
  }
}

React源码中的performUnitOfWork看这里,当然比我们这个复杂很多。

统一commit DOM操作

上面我们的performUnitOfWork一边构建Fiber结构一边操作DOMappendChild,这样如果某次更新好几个节点,操作了第一个节点之后就中断了,那我们可能只看到第一个节点渲染到了页面,后续几个节点等浏览器空了才陆续渲染。为了避免这种情况,我们应该将DOM操作都搜集起来,最后统一执行,这就是commit。为了能够记录位置,我们还需要一个全局变量workInProgressRoot来记录根节点,然后在workLoop检测如果任务执行完了,就commit:

function workLoop(deadline) {
  while(nextUnitOfWork && deadline.timeRemaining() > 1) {
    // 这个while循环会在任务执行完或者时间到了的时候结束
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }

  // 任务做完后统一渲染
  if(!nextUnitOfWork && workInProgressRoot) {
    commitRoot();
  }

  // 如果任务还没完,但是时间到了,我们需要继续注册requestIdleCallback
  requestIdleCallback(workLoop);
}

因为我们是在Fiber树完全构建后再执行的commit,而且有一个变量workInProgressRoot指向了Fiber的根节点,所以我们可以直接把workInProgressRoot拿过来递归渲染就行了:

// 统一操作DOM
function commitRoot() {
  commitRootImpl(workInProgressRoot.child);    // 开启递归
  workInProgressRoot = null;     // 操作完后将workInProgressRoot重置
}

function commitRootImpl(fiber) {
  if(!fiber) {
    return;
  }

  const parentDom = fiber.return.dom;
  parentDom.appendChild(fiber.dom);

  // 递归操作子元素和兄弟元素
  commitRootImpl(fiber.child);
  commitRootImpl(fiber.sibling);
}

reconcile调和

reconcile其实就是虚拟DOM树的diff操作,需要删除不需要的节点,更新修改过的节点,添加新的节点。为了在中断后能回到工作位置,我们还需要一个变量currentRoot,然后在fiber节点里面添加一个属性alternate,这个属性指向上一次运行的根节点,也就是currentRootcurrentRoot会在第一次render后的commit阶段赋值,也就是每次计算完后都会把当次状态记录在alternate上,后面更新了就可以把alternate拿出来跟新的状态做diff。然后performUnitOfWork里面需要添加调和子元素的代码,可以新增一个函数reconcileChildren。这个函数里面不能简单的创建新节点了,而是要将老节点跟新节点拿来对比,对比逻辑如下:

  1. 如果新老节点类型一样,复用老节点DOM,更新props
  2. 如果类型不一样,而且新的节点存在,创建新节点替换老节点
  3. 如果类型不一样,没有新节点,有老节点,删除老节点

注意删除老节点的操作是直接将oldFiber加上一个删除标记就行,同时用一个全局变量deletions记录所有需要删除的节点:

      // 对比oldFiber和当前element
      const sameType = oldFiber && element && oldFiber.type === element.type;  //检测类型是不是一样
      // 先比较元素类型
      if(sameType) {
        // 如果类型一样,复用节点,更新props
        newFiber = {
          type: oldFiber.type,
          props: element.props,
          dom: oldFiber.dom,
          return: workInProgressFiber,
          alternate: oldFiber,          // 记录下上次状态
          effectTag: 'UPDATE'           // 添加一个操作标记
        }
      } else if(!sameType && element) {
        // 如果类型不一样,有新的节点,创建新节点替换老节点
        newFiber = {
          type: element.type,
          props: element.props,
          dom: null,                    // 构建fiber时没有dom,下次perform这个节点是才创建dom
          return: workInProgressFiber,
          alternate: null,              // 新增的没有老状态
          effectTag: 'REPLACEMENT'      // 添加一个操作标记
        }
      } else if(!sameType && oldFiber) {
        // 如果类型不一样,没有新节点,有老节点,删除老节点
        oldFiber.effectTag = 'DELETION';   // 添加删除标记
        deletions.push(oldFiber);          // 一个数组收集所有需要删除的节点
      }

然后就是在commit阶段处理真正的DOM操作,具体的操作是根据我们的effectTag来判断的:

function commitRootImpl(fiber) {
  if(!fiber) {
    return;
  }

  const parentDom = fiber.return.dom;
  if(fiber.effectTag === 'REPLACEMENT' && fiber.dom) {
    parentDom.appendChild(fiber.dom);
  } else if(fiber.effectTag === 'DELETION') {
    parentDom.removeChild(fiber.dom);
  } else if(fiber.effectTag === 'UPDATE' && fiber.dom) {
    // 更新DOM属性
    updateDom(fiber.dom, fiber.alternate.props, fiber.props);
  }

  // 递归操作子元素和兄弟元素
  commitRootImpl(fiber.child);
  commitRootImpl(fiber.sibling);
}

替换和删除的DOM操作都比较简单,更新属性的会稍微麻烦点,需要再写一个辅助函数updateDom来实现:

// 更新DOM的操作
function updateDom(dom, prevProps, nextProps) {
  // 1. 过滤children属性
  // 2. 老的存在,新的没了,取消
  // 3. 新的存在,老的没有,新增
  Object.keys(prevProps)
    .filter(name => name !== 'children')
    .filter(name => !(name in nextProps))
    .forEach(name => {
      if(name.indexOf('on') === 0) {
        dom.removeEventListener(name.substr(2).toLowerCase(), prevProps[name], false);
      } else {
        dom[name] = '';
      }
    });

  Object.keys(nextProps)
    .filter(name => name !== 'children')
    .forEach(name => {
      if(name.indexOf('on') === 0) {
        dom.addEventListener(name.substr(2).toLowerCase(), nextProps[name], false);
      } else {
        dom[name] = nextProps[name];
      }
    });
}

updateDom的代码写的比较简单,事件只处理了简单的on开头的,兼容性也有问题,prevPropsnextProps可能会遍历到相同的属性,有重复赋值,但是总体原理还是没错的。要想把这个处理写全,代码量还是不少的。

函数组件

函数组件是React里面很常见的一种组件,我们前面的React架构其实已经写好了,我们这里来支持下函数组件。我们之前的fiber节点上的type都是DOM节点的类型,比如h1什么的,但是函数组件的节点type其实就是一个函数了,我们需要对这种节点进行单独处理。

首先需要在更新的时候检测当前节点是不是函数组件,如果是,children的处理逻辑会稍微不一样:

// performUnitOfWork里面
// 检测函数组件
function performUnitOfWork(fiber) {
  const isFunctionComponent = fiber.type instanceof Function;
  if(isFunctionComponent) {
    updateFunctionComponent(fiber);
  } else {
    updateHostComponent(fiber);
  }
  
  // ...下面省略n行代码...
}

function updateFunctionComponent(fiber) {
  // 函数组件的type就是个函数,直接拿来执行可以获得DOM元素
  const children = [fiber.type(fiber.props)];

  reconcileChildren(fiber, children);
}

// updateHostComponent就是之前的操作,只是单独抽取了一个方法
function updateHostComponent(fiber) {
  if(!fiber.dom) {
    fiber.dom = createDom(fiber);   // 创建一个DOM挂载上去
  } 

  // 将我们前面的vDom结构转换为fiber结构
  const elements = fiber.props.children;

  // 调和子元素
  reconcileChildren(fiber, elements);
}

然后在我们提交DOM操作的时候因为函数组件没有DOM元素,所以需要注意两点:

  1. 获取父级DOM元素的时候需要递归网上找真正的DOM
  2. 删除节点的时候需要递归往下找真正的节点

我们来修改下commitRootImpl:

function commitRootImpl() {
  // const parentDom = fiber.return.dom;
  // 向上查找真正的DOM
  let parentFiber = fiber.return;
  while(!parentFiber.dom) {
    parentFiber = parentFiber.return;
  }
  const parentDom = parentFiber.dom;
  
  // ...这里省略n行代码...
  
  if{fiber.effectTag === 'DELETION'} {
    commitDeletion(fiber, parentDom);
  }
}

function commitDeletion(fiber, domParent) {
  if(fiber.dom) {
    // dom存在,是普通节点
    domParent.removeChild(fiber.dom);
  } else {
    // dom不存在,是函数组件,向下递归查找真实DOM
    commitDeletion(fiber.child, domParent);
  }
}

现在我们可以传入函数组件了:

import React from './myReact';
const ReactDOM = React;

function App(props) {
  return (
    <div>
      <h1 id="title">{props.title}</h1>
      <a href="xxx">Jump</a>
      <section>
        <p>
          Article
        </p>
      </section>
    </div>
  );
}

ReactDOM.render(
  <App title="Fiber Demo"/>,
  document.getElementById('root')
);

实现useState

useState是React Hooks里面的一个API,相当于之前Class Component里面的state,用来管理组件内部状态,现在我们已经有一个简化版的React了,我们也可以尝试下来实现这个API。

简单版

我们还是从用法入手来实现最简单的功能,我们一般使用useState是这样的:

function App(props) {
  const [count, setCount] = React.useState(1);
  const onClickHandler = () => {
    setCount(count + 1);
  }
  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={onClickHandler}>Count+1</button>
    </div>
  );
}

ReactDOM.render(
  <App title="Fiber Demo"/>,
  document.getElementById('root')
);

上述代码可以看出,我们的useState接收一个初始值,返回一个数组,里面有这个state的当前值和改变state的方法,需要注意的是App作为一个函数组件,每次render的时候都会运行,也就是说里面的局部变量每次render的时候都会重置,那我们的state就不能作为一个局部变量,而是应该作为一个全部变量存储:

let state = null;
function useState(init) {

  state = state === null ? init : state;

  // 修改state的方法
  const setState = value => {
    state = value;

    // 只要修改了state,我们就需要重新处理节点
    workInProgressRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot
    }

    // 修改nextUnitOfWork指向workInProgressRoot,这样下次就会处理这个节点了
    nextUnitOfWork = workInProgressRoot;
    deletions = [];
  }

  return [state, setState]
}

这样其实我们就可以使用了:

Jun-19-2020 15-16-04

支持多个state

上面的代码只有一个state变量,如果我们有多个useState怎么办呢?为了能支持多个useState,我们的state就不能是一个简单的值了,我们可以考虑把他改成一个数组,多个useState按照调用顺序放进这个数组里面,访问的时候通过下标来访问:

let state = [];
let hookIndex = 0;
function useState(init) {
  const currentIndex = hookIndex;
  state[currentIndex] = state[currentIndex] === undefined ? init : state[currentIndex];

  // 修改state的方法
  const setState = value => {
    state[currentIndex] = value;

    // 只要修改了state,我们就需要重新处理这个节点
    workInProgressRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot
    }

    // 修改nextUnitOfWork指向workInProgressRoot,这样下次就会处理这个节点了
    nextUnitOfWork = workInProgressRoot;
    deletions = [];
  }

  hookIndex++;

  return [state[currentIndex], setState]
}

来看看多个useState的效果:

Jun-19-2020 15-28-59

支持多个组件

上面的代码虽然我们支持了多个useState,但是仍然只有一套全局变量,如果有多个函数组件,每个组件都来操作这个全局变量,那相互之间不就是污染了数据了吗?所以我们数据还不能都存在全局变量上面,而是应该存在每个fiber节点上,处理这个节点的时候再将状态放到全局变量用来通讯:

// 申明两个全局变量,用来处理useState
// wipFiber是当前的函数组件fiber节点
// hookIndex是当前函数组件内部useState状态计数
let wipFiber = null;
let hookIndex = null;

因为useState只在函数组件里面可以用,所以我们之前的updateFunctionComponent里面需要初始化处理useState变量:

function updateFunctionComponent(fiber) {
  // 支持useState,初始化变量
  wipFiber = fiber;
  hookIndex = 0;
  wipFiber.hooks = [];        // hooks用来存储具体的state序列
  
  // ......下面代码省略......
}

因为hooks队列放到fiber节点上去了,所以我们在useState取之前的值时需要从fiber.alternate上取,完整代码如下:

function useState(init) {
  // 取出上次的Hook
  const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex];

  // hook数据结构
  const hook = {
    state: oldHook ? oldHook.state : init      // state是每个具体的值
  }

  // 将所有useState调用按照顺序存到fiber节点上
  wipFiber.hooks.push(hook);
  hookIndex++;

  // 修改state的方法
  const setState = value => {
    hook.state = value;

    // 只要修改了state,我们就需要重新处理这个节点
    workInProgressRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot
    }

    // 修改nextUnitOfWork指向workInProgressRoot,这样下次requestIdleCallback就会处理这个节点了
    nextUnitOfWork = workInProgressRoot;
    deletions = [];
  }

  return [hook.state, setState]
}

上面代码可以看出我们在将useState和存储的state进行匹配的时候是用的useState的调用顺序匹配state的下标,如果这个下标匹配不上了,state就错了,所以React里面不能出现这样的代码:

if (something) {
    const [state, setState] = useState(1);
}

上述代码不能保证每次something都满足,可能导致useState这次render执行了,下次又没执行,这样新老节点的下标就匹配不上了,对于这种代码,React会直接报错:

image-20200619161005858

用Hooks模拟Class组件

这个功能纯粹是娱乐性功能,通过前面实现的Hooks来模拟实现Class组件,这个并不是React官方的实现方式哈~我们可以写一个方法将Class组件转化为前面的函数组件:

function transfer(Component) {
  return function(props) {
    const component = new Component(props);
    let [state, setState] = useState(component.state);
    component.props = props;
    component.state = state;
    component.setState = setState;

    return component.render();
  }
}

然后就可以写Class了,这个Class长得很像我们在React里面写的Class,有state,setStaterender

import React from './myReact';

class Count4 {
  constructor(props) {
    this.props = props;
    this.state = {
      count: 1
    }
  }

  onClickHandler = () => {
    this.setState({
      count: this.state.count + 1
    })
  }

  render() {
    return (
      <div>
        <h3>Class component Count: {this.state.count}</h3>
        <button onClick={this.onClickHandler}>Count+1</button>
      </div>
    ); 
  }
}

// export的时候用transfer包装下
export default React.transfer(Count4);

然后使用的时候直接:

<div>
  <Count4></Count4>
</div>

当然你也可以在React里面建一个空的class Component,让Count4继承他,这样就更像了。

好了,到这里我们代码就写完了,完整代码可以看我GitHub。

总结

  1. 我们写的JSX代码被babel转化成了React.createElement
  2. React.createElement返回的其实就是虚拟DOM结构。
  3. ReactDOM.render方法是将虚拟DOM渲染到页面的。
  4. 虚拟DOM的调和和渲染可以简单粗暴的递归,但是这个过程是同步的,如果需要处理的节点过多,可能会阻塞用户输入和动画播放,造成卡顿。
  5. Fiber是16.x引入的新特性,用处是将同步的调和变成异步的。
  6. Fiber改造了虚拟DOM的结构,具有父 -> 第一个子子 -> 兄子 -> 父这几个指针,有了这几个指针,可以从任意一个Fiber节点找到其他节点。
  7. Fiber将整棵树的同步任务拆分成了每个节点可以单独执行的异步执行结构。
  8. Fiber可以从任意一个节点开始遍历,遍历是深度优先遍历,顺序是父 -> 子 -> 兄 -> 父,也就是从上往下,从左往右。
  9. Fiber的调和阶段可以是异步的小任务,但是提交阶段(commit)必须是同步的。因为异步的commit可能让用户看到节点一个一个接连出现,体验不好。
  10. 函数组件其实就是这个节点的type是个函数,直接将type拿来运行就可以得到虚拟DOM。
  11. useState是在Fiber节点上添加了一个数组,数组里面的每个值对应了一个useStateuseState调用顺序必须和这个数组下标匹配,不然会报错。

参考资料

A Cartoon Intro to Fiber

妙味课堂大圣老师:手写react的fiber和hooks架构

React Fiber

这可能是最通俗的 React Fiber(时间分片) 打开方式

浅析 React Fiber

React Fiber架构

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

欢迎关注我的公众号进击的大前端第一时间获取高质量原创~

“前端进阶知识”系列文章源码地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二维码_2.png

查看原文

Waxiangyu 收藏了文章 · 2020-08-02

手写React的Fiber架构,深入理解其原理

熟悉React的朋友都知道,React支持jsx语法,我们可以直接将HTML代码写到JS中间,然后渲染到页面上,我们写的HTML如果有更新的话,React还有虚拟DOM的对比,只更新变化的部分,而不重新渲染整个页面,大大提高渲染效率。到了16.x,React更是使用了一个被称为Fiber的架构,提升了用户体验,同时还引入了hooks等特性。那隐藏在React背后的原理是怎样的呢,Fiberhooks又是怎么实现的呢?本文会从jsx入手,手写一个简易版的React,从而深入理解React的原理。

本文主要实现了这些功能:

简易版Fiber架构

简易版DIFF算法

简易版函数组件

简易版Hook: useState

娱乐版Class组件

本文代码地址:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/fiber-and-hooks

本文程序跑起来效果如下:

Jun-19-2020 17-01-28

JSX和creatElement

以前我们写React要支持JSX还需要一个库叫JSXTransformer.js,后来JSX的转换工作都集成到了babel里面了,babel还提供了在线预览的功能,可以看到转换后的效果,比如下面这段简单的代码:

const App =
(
  <div>
    <h1 id="title">Title</h1>
    <a href="xxx">Jump</a>
    <section>
      <p>
        Article
      </p>
    </section>
  </div>
);

经过babel转换后就变成了这样:

image-20200608175937104

上面的截图可以看出我们写的HTML被转换成了React.createElement,我们将上面代码稍微格式化来看下:

var App = React.createElement(
  'div',
  null,
  React.createElement(
    'h1',
    {
      id: 'title',
    },
    'Title',
  ),
  React.createElement(
    'a',
    {
      href: 'xxx',
    },
    'Jump',
  ),
  React.createElement(
    'section',
    null,
    React.createElement('p', null, 'Article'),
  ),
);

从转换后的代码我们可以看出React.createElement支持多个参数:

  1. type,也就是节点类型
  2. config, 这是节点上的属性,比如idhref
  3. children, 从第三个参数开始就全部是children也就是子元素了,子元素可以有多个,类型可以是简单的文本,也可以还是React.createElement,如果是React.createElement,其实就是子节点了,子节点下面还可以有子节点。这样就用React.createElement的嵌套关系实现了HTML节点的树形结构。

让我们来完整看下这个简单的React页面代码:

image-20200608180112829

渲染在页面上是这样:

image-20200608180139663

这里面用到了React的地方其实就两个,一个是JSX,也就是React.createElement,另一个就是ReactDOM.render,所以我们手写的第一个目标就有了,就是createElementrender这两个方法。

手写createElement

对于<h1 id="title">Title</h1>这样一个简单的节点,原生DOM也会附加一大堆属性和方法在上面,所以我们在createElement的时候最好能将它转换为一种比较简单的数据结构,只包含我们需要的元素,比如这样:

{
  type: 'h1',
  props: {
    id: 'title',
    children: 'Title'
  }
}

有了这个数据结构后,我们对于DOM的操作其实可以转化为对这个数据结构的操作,新老DOM的对比其实也可以转化为这个数据结构的对比,这样我们就不需要每次操作都去渲染页面,而是等到需要渲染的时候才将这个数据结构渲染到页面上。这其实就是虚拟DOM!而我们createElement就是负责来构建这个虚拟DOM的方法,下面我们来实现下:

function createElement(type, props, ...children) {
  // 核心逻辑不复杂,将参数都塞到一个对象上返回就行
  // children也要放到props里面去,这样我们在组件里面就能通过this.props.children拿到子元素
  return {
    type,
    props: {
      ...props,
      children
    }
  }
}

上述代码是React的createElement简化版,对源码感兴趣的朋友可以看这里:https://github.com/facebook/react/blob/60016c448bb7d19fc989acd05dda5aca2e124381/packages/react/src/ReactElement.js#L348

手写render

上述代码我们用createElement将JSX代码转换成了虚拟DOM,那真正将它渲染到页面的函数是render,所以我们还需要实现下这个方法,通过我们一般的用法ReactDOM.render( <App />,document.getElementById('root'));可以知道他接收两个参数:

  1. 根组件,其实是一个JSX组件,也就是一个createElement返回的虚拟DOM
  2. 父节点,也就是我们要将这个虚拟DOM渲染的位置

有了这两个参数,我们来实现下render方法:

function render(vDom, container) {
  let dom;
  // 检查当前节点是文本还是对象
  if(typeof vDom !== 'object') {
    dom = document.createTextNode(vDom)
  } else {
    dom = document.createElement(vDom.type);
  }

  // 将vDom上除了children外的属性都挂载到真正的DOM上去
  if(vDom.props) {
    Object.keys(vDom.props)
      .filter(key => key != 'children')
      .forEach(item => {
        dom[item] = vDom.props[item];
      })
  }
  
  // 如果还有子元素,递归调用
  if(vDom.props && vDom.props.children && vDom.props.children.length) {
    vDom.props.children.forEach(child => render(child, dom));
  }

  container.appendChild(dom);
}

上述代码是简化版的render方法,对源码感兴趣的朋友可以看这里:https://github.com/facebook/react/blob/3e94bce765d355d74f6a60feb4addb6d196e3482/packages/react-dom/src/client/ReactDOMLegacy.js#L287

现在我们可以用自己写的createElementrender来替换原生的方法了:

image-20200608180301596

可以得到一样的渲染结果:

image-20200608180139663

为什么需要Fiber

上面我们简单的实现了虚拟DOM渲染到页面上的代码,这部分工作被React官方称为renderer,renderer是第三方可以自己实现的一个模块,还有个核心模块叫做reconciler,reconciler的一大功能就是大家熟知的diff,他会计算出应该更新哪些页面节点,然后将需要更新的节点虚拟DOM传递给renderer,renderer负责将这些节点渲染到页面上。但是这个流程有个问题,虽然React的diff算法是经过优化的,但是他却是同步的,renderer负责操作DOM的appendChild等API也是同步的,也就是说如果有大量节点需要更新,JS线程的运行时间可能会比较长,在这段时间浏览器是不会响应其他事件的,因为JS线程和GUI线程是互斥的,JS运行时页面就不会响应,这个时间太长了,用户就可能看到卡顿,特别是动画的卡顿会很明显。在React的官方演讲中有个例子,可以很明显的看到这种同步计算造成的卡顿:

1625d95bc100c7fe

而Fiber就是用来解决这个问题的,Fiber可以将长时间的同步任务拆分成多个小任务,从而让浏览器能够抽身去响应其他事件,等他空了再回来继续计算,这样整个计算流程就显得平滑很多。下面是使用Fiber后的效果:

1625d95bc2baf0e1

怎么来拆分

上面我们自己实现的render方法直接递归遍历了整个vDom树,如果我们在中途某一步停下来,下次再调用时其实并不知道上次在哪里停下来的,不知道从哪里开始,所以vDom的树形结构并不满足中途暂停,下次继续的需求,需要改造数据结构。另一个需要解决的问题是,拆分下来的小任务什么时候执行?我们的目的是让用户有更流畅的体验,所以我们最好不要阻塞高优先级的任务,比如用户输入,动画之类,等他们执行完了我们再计算。那我怎么知道现在有没有高优先级任务,浏览器是不是空闲呢?总结下来,Fiber要想达到目的,需要解决两个问题:

  1. 新的任务调度,有高优先级任务的时候将浏览器让出来,等浏览器空了再继续执行
  2. 新的数据结构,可以随时中断,下次进来可以接着执行

requestIdleCallback

requestIdleCallback是一个实验中的新API,这个API调用方式如下:

// 开启调用
var handle = window.requestIdleCallback(callback[, options])

// 结束调用
Window.cancelIdleCallback(handle) 

requestIdleCallback接收一个回调,这个回调会在浏览器空闲时调用,每次调用会传入一个IdleDeadline,可以拿到当前还空余多久,options可以传入参数最多等多久,等到了时间浏览器还不空就强制执行了。使用这个API可以解决任务调度的问题,让浏览器在空闲时才计算diff并渲染。更多关于requestIdleCallback的使用可以查看MDN的文档。但是这个API还在实验中,兼容性不好,所以React官方自己实现了一套。本文会继续使用requestIdleCallback来进行任务调度,我们进行任务调度的思想是将任务拆分成多个小任务,requestIdleCallback里面不断的把小任务拿出来执行,当所有任务都执行完或者超时了就结束本次执行,同时要注册下次执行,代码架子就是这样:

function workLoop(deadline) {
  while(nextUnitOfWork && deadline.timeRemaining() > 1) {
    // 这个while循环会在任务执行完或者时间到了的时候结束
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }

  // 如果任务还没完,但是时间到了,我们需要继续注册requestIdleCallback
  requestIdleCallback(workLoop);
}

// performUnitOfWork用来执行任务,参数是我们的当前fiber任务,返回值是下一个任务
function performUnitOfWork(fiber) {
  
}
requestIdleCallback(workLoop);

上述workLoop对应React源码看这里。

Fiber可中断数据结构

上面我们的performUnitOfWork并没有实现,但是从上面的结构可以看出来,他接收的参数是一个小任务,同时通过这个小任务还可以找到他的下一个小任务,Fiber构建的就是这样一个数据结构。Fiber之前的数据结构是一棵树,父节点的children指向了子节点,但是只有这一个指针是不能实现中断继续的。比如我现在有一个父节点A,A有三个子节点B,C,D,当我遍历到C的时候中断了,重新开始的时候,其实我是不知道C下面该执行哪个的,因为只知道C,并没有指针指向他的父节点,也没有指针指向他的兄弟。Fiber就是改造了这样一个结构,加上了指向父节点和兄弟节点的指针:

image-20200609173312276

上面的图片还是来自于官方的演讲,可以看到和之前父节点指向所有子节点不同,这里有三个指针:

  1. child: 父节点指向第一个子元素的指针。
  2. sibling:从第一个子元素往后,指向下一个兄弟元素。
  3. return:所有子元素都有的指向父元素的指针。

有了这几个指针后,我们可以在任意一个元素中断遍历并恢复,比如在上图List处中断了,恢复的时候可以通过child找到他的子元素,也可以通过return找到他的父元素,如果他还有兄弟节点也可以用sibling找到。Fiber这个结构外形看着还是棵树,但是没有了指向所有子元素的指针,父节点只指向第一个子节点,然后子节点有指向其他子节点的指针,这其实是个链表。

实现Fiber

现在我们可以自己来实现一下Fiber了,我们需要将之前的vDom结构转换为Fiber的数据结构,同时需要能够通过其中任意一个节点返回下一个节点,其实就是遍历这个链表。遍历的时候从根节点出发,先找子元素,如果子元素存在,直接返回,如果没有子元素了就找兄弟元素,找完所有的兄弟元素后再返回父元素,然后再找这个父元素的兄弟元素。整个遍历过程其实是个深度优先遍历,从上到下,然后最后一行开始从左到右遍历。比如下图从div1开始遍历的话,遍历的顺序就应该是div1 -> div2 -> h1 -> a -> div2 -> p -> div1。可以看到这个序列中,当我们return父节点时,这些父节点会被第二次遍历,所以我们写代码时,return的父节点不会作为下一个任务返回,只有siblingchild才会作为下一个任务返回。

image-20200610162336915

// performUnitOfWork用来执行任务,参数是我们的当前fiber任务,返回值是下一个任务
function performUnitOfWork(fiber) {
  // 根节点的dom就是container,如果没有这个属性,说明当前fiber不是根节点
  if(!fiber.dom) {
    fiber.dom = createDom(fiber);   // 创建一个DOM挂载上去
  } 

  // 如果有父节点,将当前节点挂载到父节点上
  if(fiber.return) {
    fiber.return.dom.appendChild(fiber.dom);
  }

  // 将我们前面的vDom结构转换为fiber结构
  const elements = fiber.children;
  let prevSibling = null;
  if(elements && elements.length) {
    for(let i = 0; i < elements.length; i++) {
      const element = elements[i];
      const newFiber = {
        type: element.type,
        props: element.props,
        return: fiber,
        dom: null
      }

      // 父级的child指向第一个子元素
      if(i === 0) {
        fiber.child = newFiber;
      } else {
        // 每个子元素拥有指向下一个子元素的指针
        prevSibling.sibling = newFiber;
      }

      prevSibling = newFiber;
    }
  }

  // 这个函数的返回值是下一个任务,这其实是一个深度优先遍历
  // 先找子元素,没有子元素了就找兄弟元素
  // 兄弟元素也没有了就返回父元素
  // 然后再找这个父元素的兄弟元素
  // 最后到根节点结束
  // 这个遍历的顺序其实就是从上到下,从左到右
  if(fiber.child) {
    return fiber.child;
  }

  let nextFiber = fiber;
  while(nextFiber) {
    if(nextFiber.sibling) {
      return nextFiber.sibling;
    }

    nextFiber = nextFiber.return;
  }
}

React源码中的performUnitOfWork看这里,当然比我们这个复杂很多。

统一commit DOM操作

上面我们的performUnitOfWork一边构建Fiber结构一边操作DOMappendChild,这样如果某次更新好几个节点,操作了第一个节点之后就中断了,那我们可能只看到第一个节点渲染到了页面,后续几个节点等浏览器空了才陆续渲染。为了避免这种情况,我们应该将DOM操作都搜集起来,最后统一执行,这就是commit。为了能够记录位置,我们还需要一个全局变量workInProgressRoot来记录根节点,然后在workLoop检测如果任务执行完了,就commit:

function workLoop(deadline) {
  while(nextUnitOfWork && deadline.timeRemaining() > 1) {
    // 这个while循环会在任务执行完或者时间到了的时候结束
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }

  // 任务做完后统一渲染
  if(!nextUnitOfWork && workInProgressRoot) {
    commitRoot();
  }

  // 如果任务还没完,但是时间到了,我们需要继续注册requestIdleCallback
  requestIdleCallback(workLoop);
}

因为我们是在Fiber树完全构建后再执行的commit,而且有一个变量workInProgressRoot指向了Fiber的根节点,所以我们可以直接把workInProgressRoot拿过来递归渲染就行了:

// 统一操作DOM
function commitRoot() {
  commitRootImpl(workInProgressRoot.child);    // 开启递归
  workInProgressRoot = null;     // 操作完后将workInProgressRoot重置
}

function commitRootImpl(fiber) {
  if(!fiber) {
    return;
  }

  const parentDom = fiber.return.dom;
  parentDom.appendChild(fiber.dom);

  // 递归操作子元素和兄弟元素
  commitRootImpl(fiber.child);
  commitRootImpl(fiber.sibling);
}

reconcile调和

reconcile其实就是虚拟DOM树的diff操作,需要删除不需要的节点,更新修改过的节点,添加新的节点。为了在中断后能回到工作位置,我们还需要一个变量currentRoot,然后在fiber节点里面添加一个属性alternate,这个属性指向上一次运行的根节点,也就是currentRootcurrentRoot会在第一次render后的commit阶段赋值,也就是每次计算完后都会把当次状态记录在alternate上,后面更新了就可以把alternate拿出来跟新的状态做diff。然后performUnitOfWork里面需要添加调和子元素的代码,可以新增一个函数reconcileChildren。这个函数里面不能简单的创建新节点了,而是要将老节点跟新节点拿来对比,对比逻辑如下:

  1. 如果新老节点类型一样,复用老节点DOM,更新props
  2. 如果类型不一样,而且新的节点存在,创建新节点替换老节点
  3. 如果类型不一样,没有新节点,有老节点,删除老节点

注意删除老节点的操作是直接将oldFiber加上一个删除标记就行,同时用一个全局变量deletions记录所有需要删除的节点:

      // 对比oldFiber和当前element
      const sameType = oldFiber && element && oldFiber.type === element.type;  //检测类型是不是一样
      // 先比较元素类型
      if(sameType) {
        // 如果类型一样,复用节点,更新props
        newFiber = {
          type: oldFiber.type,
          props: element.props,
          dom: oldFiber.dom,
          return: workInProgressFiber,
          alternate: oldFiber,          // 记录下上次状态
          effectTag: 'UPDATE'           // 添加一个操作标记
        }
      } else if(!sameType && element) {
        // 如果类型不一样,有新的节点,创建新节点替换老节点
        newFiber = {
          type: element.type,
          props: element.props,
          dom: null,                    // 构建fiber时没有dom,下次perform这个节点是才创建dom
          return: workInProgressFiber,
          alternate: null,              // 新增的没有老状态
          effectTag: 'REPLACEMENT'      // 添加一个操作标记
        }
      } else if(!sameType && oldFiber) {
        // 如果类型不一样,没有新节点,有老节点,删除老节点
        oldFiber.effectTag = 'DELETION';   // 添加删除标记
        deletions.push(oldFiber);          // 一个数组收集所有需要删除的节点
      }

然后就是在commit阶段处理真正的DOM操作,具体的操作是根据我们的effectTag来判断的:

function commitRootImpl(fiber) {
  if(!fiber) {
    return;
  }

  const parentDom = fiber.return.dom;
  if(fiber.effectTag === 'REPLACEMENT' && fiber.dom) {
    parentDom.appendChild(fiber.dom);
  } else if(fiber.effectTag === 'DELETION') {
    parentDom.removeChild(fiber.dom);
  } else if(fiber.effectTag === 'UPDATE' && fiber.dom) {
    // 更新DOM属性
    updateDom(fiber.dom, fiber.alternate.props, fiber.props);
  }

  // 递归操作子元素和兄弟元素
  commitRootImpl(fiber.child);
  commitRootImpl(fiber.sibling);
}

替换和删除的DOM操作都比较简单,更新属性的会稍微麻烦点,需要再写一个辅助函数updateDom来实现:

// 更新DOM的操作
function updateDom(dom, prevProps, nextProps) {
  // 1. 过滤children属性
  // 2. 老的存在,新的没了,取消
  // 3. 新的存在,老的没有,新增
  Object.keys(prevProps)
    .filter(name => name !== 'children')
    .filter(name => !(name in nextProps))
    .forEach(name => {
      if(name.indexOf('on') === 0) {
        dom.removeEventListener(name.substr(2).toLowerCase(), prevProps[name], false);
      } else {
        dom[name] = '';
      }
    });

  Object.keys(nextProps)
    .filter(name => name !== 'children')
    .forEach(name => {
      if(name.indexOf('on') === 0) {
        dom.addEventListener(name.substr(2).toLowerCase(), nextProps[name], false);
      } else {
        dom[name] = nextProps[name];
      }
    });
}

updateDom的代码写的比较简单,事件只处理了简单的on开头的,兼容性也有问题,prevPropsnextProps可能会遍历到相同的属性,有重复赋值,但是总体原理还是没错的。要想把这个处理写全,代码量还是不少的。

函数组件

函数组件是React里面很常见的一种组件,我们前面的React架构其实已经写好了,我们这里来支持下函数组件。我们之前的fiber节点上的type都是DOM节点的类型,比如h1什么的,但是函数组件的节点type其实就是一个函数了,我们需要对这种节点进行单独处理。

首先需要在更新的时候检测当前节点是不是函数组件,如果是,children的处理逻辑会稍微不一样:

// performUnitOfWork里面
// 检测函数组件
function performUnitOfWork(fiber) {
  const isFunctionComponent = fiber.type instanceof Function;
  if(isFunctionComponent) {
    updateFunctionComponent(fiber);
  } else {
    updateHostComponent(fiber);
  }
  
  // ...下面省略n行代码...
}

function updateFunctionComponent(fiber) {
  // 函数组件的type就是个函数,直接拿来执行可以获得DOM元素
  const children = [fiber.type(fiber.props)];

  reconcileChildren(fiber, children);
}

// updateHostComponent就是之前的操作,只是单独抽取了一个方法
function updateHostComponent(fiber) {
  if(!fiber.dom) {
    fiber.dom = createDom(fiber);   // 创建一个DOM挂载上去
  } 

  // 将我们前面的vDom结构转换为fiber结构
  const elements = fiber.props.children;

  // 调和子元素
  reconcileChildren(fiber, elements);
}

然后在我们提交DOM操作的时候因为函数组件没有DOM元素,所以需要注意两点:

  1. 获取父级DOM元素的时候需要递归网上找真正的DOM
  2. 删除节点的时候需要递归往下找真正的节点

我们来修改下commitRootImpl:

function commitRootImpl() {
  // const parentDom = fiber.return.dom;
  // 向上查找真正的DOM
  let parentFiber = fiber.return;
  while(!parentFiber.dom) {
    parentFiber = parentFiber.return;
  }
  const parentDom = parentFiber.dom;
  
  // ...这里省略n行代码...
  
  if{fiber.effectTag === 'DELETION'} {
    commitDeletion(fiber, parentDom);
  }
}

function commitDeletion(fiber, domParent) {
  if(fiber.dom) {
    // dom存在,是普通节点
    domParent.removeChild(fiber.dom);
  } else {
    // dom不存在,是函数组件,向下递归查找真实DOM
    commitDeletion(fiber.child, domParent);
  }
}

现在我们可以传入函数组件了:

import React from './myReact';
const ReactDOM = React;

function App(props) {
  return (
    <div>
      <h1 id="title">{props.title}</h1>
      <a href="xxx">Jump</a>
      <section>
        <p>
          Article
        </p>
      </section>
    </div>
  );
}

ReactDOM.render(
  <App title="Fiber Demo"/>,
  document.getElementById('root')
);

实现useState

useState是React Hooks里面的一个API,相当于之前Class Component里面的state,用来管理组件内部状态,现在我们已经有一个简化版的React了,我们也可以尝试下来实现这个API。

简单版

我们还是从用法入手来实现最简单的功能,我们一般使用useState是这样的:

function App(props) {
  const [count, setCount] = React.useState(1);
  const onClickHandler = () => {
    setCount(count + 1);
  }
  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={onClickHandler}>Count+1</button>
    </div>
  );
}

ReactDOM.render(
  <App title="Fiber Demo"/>,
  document.getElementById('root')
);

上述代码可以看出,我们的useState接收一个初始值,返回一个数组,里面有这个state的当前值和改变state的方法,需要注意的是App作为一个函数组件,每次render的时候都会运行,也就是说里面的局部变量每次render的时候都会重置,那我们的state就不能作为一个局部变量,而是应该作为一个全部变量存储:

let state = null;
function useState(init) {

  state = state === null ? init : state;

  // 修改state的方法
  const setState = value => {
    state = value;

    // 只要修改了state,我们就需要重新处理节点
    workInProgressRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot
    }

    // 修改nextUnitOfWork指向workInProgressRoot,这样下次就会处理这个节点了
    nextUnitOfWork = workInProgressRoot;
    deletions = [];
  }

  return [state, setState]
}

这样其实我们就可以使用了:

Jun-19-2020 15-16-04

支持多个state

上面的代码只有一个state变量,如果我们有多个useState怎么办呢?为了能支持多个useState,我们的state就不能是一个简单的值了,我们可以考虑把他改成一个数组,多个useState按照调用顺序放进这个数组里面,访问的时候通过下标来访问:

let state = [];
let hookIndex = 0;
function useState(init) {
  const currentIndex = hookIndex;
  state[currentIndex] = state[currentIndex] === undefined ? init : state[currentIndex];

  // 修改state的方法
  const setState = value => {
    state[currentIndex] = value;

    // 只要修改了state,我们就需要重新处理这个节点
    workInProgressRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot
    }

    // 修改nextUnitOfWork指向workInProgressRoot,这样下次就会处理这个节点了
    nextUnitOfWork = workInProgressRoot;
    deletions = [];
  }

  hookIndex++;

  return [state[currentIndex], setState]
}

来看看多个useState的效果:

Jun-19-2020 15-28-59

支持多个组件

上面的代码虽然我们支持了多个useState,但是仍然只有一套全局变量,如果有多个函数组件,每个组件都来操作这个全局变量,那相互之间不就是污染了数据了吗?所以我们数据还不能都存在全局变量上面,而是应该存在每个fiber节点上,处理这个节点的时候再将状态放到全局变量用来通讯:

// 申明两个全局变量,用来处理useState
// wipFiber是当前的函数组件fiber节点
// hookIndex是当前函数组件内部useState状态计数
let wipFiber = null;
let hookIndex = null;

因为useState只在函数组件里面可以用,所以我们之前的updateFunctionComponent里面需要初始化处理useState变量:

function updateFunctionComponent(fiber) {
  // 支持useState,初始化变量
  wipFiber = fiber;
  hookIndex = 0;
  wipFiber.hooks = [];        // hooks用来存储具体的state序列
  
  // ......下面代码省略......
}

因为hooks队列放到fiber节点上去了,所以我们在useState取之前的值时需要从fiber.alternate上取,完整代码如下:

function useState(init) {
  // 取出上次的Hook
  const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex];

  // hook数据结构
  const hook = {
    state: oldHook ? oldHook.state : init      // state是每个具体的值
  }

  // 将所有useState调用按照顺序存到fiber节点上
  wipFiber.hooks.push(hook);
  hookIndex++;

  // 修改state的方法
  const setState = value => {
    hook.state = value;

    // 只要修改了state,我们就需要重新处理这个节点
    workInProgressRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot
    }

    // 修改nextUnitOfWork指向workInProgressRoot,这样下次requestIdleCallback就会处理这个节点了
    nextUnitOfWork = workInProgressRoot;
    deletions = [];
  }

  return [hook.state, setState]
}

上面代码可以看出我们在将useState和存储的state进行匹配的时候是用的useState的调用顺序匹配state的下标,如果这个下标匹配不上了,state就错了,所以React里面不能出现这样的代码:

if (something) {
    const [state, setState] = useState(1);
}

上述代码不能保证每次something都满足,可能导致useState这次render执行了,下次又没执行,这样新老节点的下标就匹配不上了,对于这种代码,React会直接报错:

image-20200619161005858

用Hooks模拟Class组件

这个功能纯粹是娱乐性功能,通过前面实现的Hooks来模拟实现Class组件,这个并不是React官方的实现方式哈~我们可以写一个方法将Class组件转化为前面的函数组件:

function transfer(Component) {
  return function(props) {
    const component = new Component(props);
    let [state, setState] = useState(component.state);
    component.props = props;
    component.state = state;
    component.setState = setState;

    return component.render();
  }
}

然后就可以写Class了,这个Class长得很像我们在React里面写的Class,有state,setStaterender

import React from './myReact';

class Count4 {
  constructor(props) {
    this.props = props;
    this.state = {
      count: 1
    }
  }

  onClickHandler = () => {
    this.setState({
      count: this.state.count + 1
    })
  }

  render() {
    return (
      <div>
        <h3>Class component Count: {this.state.count}</h3>
        <button onClick={this.onClickHandler}>Count+1</button>
      </div>
    ); 
  }
}

// export的时候用transfer包装下
export default React.transfer(Count4);

然后使用的时候直接:

<div>
  <Count4></Count4>
</div>

当然你也可以在React里面建一个空的class Component,让Count4继承他,这样就更像了。

好了,到这里我们代码就写完了,完整代码可以看我GitHub。

总结

  1. 我们写的JSX代码被babel转化成了React.createElement
  2. React.createElement返回的其实就是虚拟DOM结构。
  3. ReactDOM.render方法是将虚拟DOM渲染到页面的。
  4. 虚拟DOM的调和和渲染可以简单粗暴的递归,但是这个过程是同步的,如果需要处理的节点过多,可能会阻塞用户输入和动画播放,造成卡顿。
  5. Fiber是16.x引入的新特性,用处是将同步的调和变成异步的。
  6. Fiber改造了虚拟DOM的结构,具有父 -> 第一个子子 -> 兄子 -> 父这几个指针,有了这几个指针,可以从任意一个Fiber节点找到其他节点。
  7. Fiber将整棵树的同步任务拆分成了每个节点可以单独执行的异步执行结构。
  8. Fiber可以从任意一个节点开始遍历,遍历是深度优先遍历,顺序是父 -> 子 -> 兄 -> 父,也就是从上往下,从左往右。
  9. Fiber的调和阶段可以是异步的小任务,但是提交阶段(commit)必须是同步的。因为异步的commit可能让用户看到节点一个一个接连出现,体验不好。
  10. 函数组件其实就是这个节点的type是个函数,直接将type拿来运行就可以得到虚拟DOM。
  11. useState是在Fiber节点上添加了一个数组,数组里面的每个值对应了一个useStateuseState调用顺序必须和这个数组下标匹配,不然会报错。

参考资料

A Cartoon Intro to Fiber

妙味课堂大圣老师:手写react的fiber和hooks架构

React Fiber

这可能是最通俗的 React Fiber(时间分片) 打开方式

浅析 React Fiber

React Fiber架构

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

欢迎关注我的公众号进击的大前端第一时间获取高质量原创~

“前端进阶知识”系列文章源码地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二维码_2.png

查看原文

Waxiangyu 收藏了文章 · 2020-02-14

JavaScript:彻底理解同步、异步和事件循环(Event Loop)

一. 单线程

我们常说“JavaScript是单线程的”。

所谓单线程,是指在JS引擎中负责解释和执行JavaScript代码的线程只有一个。不妨叫它主线程

但是实际上还存在其他的线程。例如:处理AJAX请求的线程、处理DOM事件的线程、定时器线程、读写文件的线程(例如在Node.js中)等等。这些线程可能存在于JS引擎之内,也可能存在于JS引擎之外,在此我们不做区分。不妨叫它们工作线程

二. 同步和异步

假设存在一个函数A:

A(args...);

同步:如果在函数A返回的时候,调用者就能够得到预期结果(即拿到了预期的返回值或者看到了预期的效果),那么这个函数就是同步的。

例如:

Math.sqrt(2);
console.log('Hi');
  • 第一个函数返回时,就拿到了预期的返回值:2的平方根。

  • 第二个函数返回时,就看到了预期的效果:在控制台打印了一个字符串。

所以这两个函数都是同步的。

异步:如果在函数A返回的时候,调用者还不能够得到预期结果,而是需要在将来通过一定的手段得到,那么这个函数就是异步的。

例如:

fs.readFile('foo.txt', 'utf8', function(err, data) {
    console.log(data);
});

在上面的代码中,我们希望通过fs.readFile函数读取文件foo.txt中的内容,并打印出来。
但是在fs.readFile函数返回时,我们期望的结果并不会发生,而是要等到文件全部读取完成之后。如果文件很大的话可能要很长时间。

下面以AJAX请求为例,来看一下同步和异步的区别:

  • 异步AJAX:

  • 主线程:“你好,AJAX线程。请你帮我发个HTTP请求吧,我把请求地址和参数都给你了。”

  • AJAX线程:“好的,主线程。我马上去发,但可能要花点儿时间呢,你可以先去忙别的。”

  • 主线程::“谢谢,你拿到响应后告诉我一声啊。”

  • (接着,主线程做其他事情去了。一顿饭的时间后,它收到了响应到达的通知。)

  • 同步AJAX:

  • 主线程:“你好,AJAX线程。请你帮我发个HTTP请求吧,我把请求地址和参数都给你了。”

  • AJAX线程:“......”

  • 主线程::“喂,AJAX线程,你怎么不说话?”

  • AJAX线程:“......”

  • 主线程::“喂!喂喂喂!”

  • AJAX线程:“......”

  • (一炷香的时间后)

  • 主线程::“喂!求你说句话吧!”

  • AJAX线程:“主线程,不好意思,我在工作的时候不能说话。你的请求已经发完了,拿到响应数据了,给你。”

正是由于JavaScript是单线程的,而异步容易实现非阻塞,所以在JavaScript中对于耗时的操作或者时间不确定的操作,使用异步就成了必然的选择。异步是这篇文章关注的重点。

三. 异步过程的构成要素

从上文可以看出,异步函数实际上很快就调用完成了。但是后面还有工作线程执行异步任务、通知主线程、主线程调用回调函数等很多步骤。我们把整个过程叫做异步过程。异步函数的调用在整个异步过程中,只是一小部分。

总结一下,一个异步过程通常是这样的:

主线程发起一个异步请求,相应的工作线程接收请求并告知主线程已收到(异步函数返回);主线程可以继续执行后面的代码,同时工作线程执行异步任务;工作线程完成工作后,通知主线程;主线程收到通知后,执行一定的动作(调用回调函数)。

异步函数通常具有以下的形式:

A(args..., callbackFn)

它可以叫做异步过程的发起函数,或者叫做异步任务注册函数。args是这个函数需要的参数。callbackFn也是这个函数的参数,但是它比较特殊所以单独列出来。

所以,从主线程的角度看,一个异步过程包括下面两个要素:

  • 发起函数(或叫注册函数)A

  • 回调函数callbackFn

它们都是在主线程上调用的,其中注册函数用来发起异步过程,回调函数用来处理结果。

举个具体的例子:

setTimeout(fn, 1000);

其中的setTimeout就是异步过程的发起函数,fn是回调函数。

注意:前面说的形式A(args..., callbackFn)只是一种抽象的表示,并不代表回调函数一定要作为发起函数的参数,例如:

var xhr = new XMLHttpRequest();
xhr.onreadystatechange = xxx; // 添加回调函数
xhr.open('GET', url);
xhr.send(); // 发起函数

发起函数和回调函数就是分离的。

四. 消息队列和事件循环

上文讲到,异步过程中,工作线程在异步操作完成后需要通知主线程。那么这个通知机制是怎样实现的呢?答案是利用消息队列和事件循环。

用一句话概括:

工作线程将消息放到消息队列,主线程通过事件循环过程去取消息。

  • 消息队列:消息队列是一个先进先出的队列,它里面存放着各种消息。

  • 事件循环:事件循环是指主线程重复从消息队列中取消息、执行的过程。

实际上,主线程只会做一件事情,就是从消息队列里面取消息、执行消息,再取消息、再执行。当消息队列为空时,就会等待直到消息队列变成非空。而且主线程只有在将当前的消息执行完成后,才会去取下一个消息。这种机制就叫做事件循环机制,取一个消息并执行的过程叫做一次循环。

事件循环用代码表示大概是这样的:

while(true) {
    var message = queue.get();
    execute(message);
}

那么,消息队列中放的消息具体是什么东西?消息的具体结构当然跟具体的实现有关,但是为了简单起见,我们可以认为:

消息就是注册异步任务时添加的回调函数。

再次以异步AJAX为例,假设存在如下的代码:

$.ajax('http://segmentfault.com', function(resp) {
    console.log('我是响应:', resp);
});

// 其他代码
...
...
...

主线程在发起AJAX请求后,会继续执行其他代码。AJAX线程负责请求segmentfault.com,拿到响应后,它会把响应封装成一个JavaScript对象,然后构造一条消息:

// 消息队列中的消息就长这个样子
var message = function () {
    callbackFn(response);
}

其中的callbackFn就是前面代码中得到成功响应时的回调函数。

主线程在执行完当前循环中的所有代码后,就会到消息队列取出这条消息(也就是message函数),并执行它。到此为止,就完成了工作线程对主线程的通知,回调函数也就得到了执行。如果一开始主线程就没有提供回调函数,AJAX线程在收到HTTP响应后,也就没必要通知主线程,从而也没必要往消息队列放消息。

用图表示这个过程就是:

图片描述

从上文中我们也可以得到这样一个明显的结论,就是:

异步过程的回调函数,一定不在当前这一轮事件循环中执行。

五. 异步与事件

上文中说的“事件循环”,为什么里面有个事件呢?那是因为:

消息队列中的每条消息实际上都对应着一个事件。

上文中一直没有提到一类很重要的异步过程:DOM事件

举例来说:

var button = document.getElement('#btn');
button.addEventListener('click', function(e) {
    console.log();
});

从事件的角度来看,上述代码表示:在按钮上添加了一个鼠标单击事件的事件监听器;当用户点击按钮时,鼠标单击事件触发,事件监听器函数被调用。

从异步过程的角度看,addEventListener函数就是异步过程的发起函数,事件监听器函数就是异步过程的回调函数。事件触发时,表示异步任务完成,会将事件监听器函数封装成一条消息放到消息队列中,等待主线程执行。

事件的概念实际上并不是必须的,事件机制实际上就是异步过程的通知机制。我觉得它的存在是为了编程接口对开发者更友好。

另一方面,所有的异步过程也都可以用事件来描述。例如:setTimeout可以看成对应一个时间到了!的事件。前文的setTimeout(fn, 1000);可以看成:

timer.addEventListener('timeout', 1000, fn);

六. 生产者与消费者

从生产者与消费者的角度看,异步过程是这样的:

工作线程是生产者,主线程是消费者(只有一个消费者)。工作线程执行异步任务,执行完成后把对应的回调函数封装成一条消息放到消息队列中;主线程不断地从消息队列中取消息并执行,当消息队列空时主线程阻塞,直到消息队列再次非空。

七. 总结一下

最后再用一个生活中的例子总结一下同步和异步:在公路上,汽车一辆接一辆,有条不紊的运行。这时,有一辆车坏掉了。假如它停在原地进行修理,那么后面的车就会被堵住没法行驶,交通就乱套了。幸好旁边有应急车道,可以把故障车辆推到应急车道修理,而正常的车流不会受到任何影响。等车修好了,再从应急车道回到正常车道即可。唯一的影响就是,应急车道用多了,原来的车辆之间的顺序会有点乱。

这就是同步和异步的区别。同步可以保证顺序一致,但是容易导致阻塞;异步可以解决阻塞问题,但是会改变顺序性。改变顺序性其实也没有什么大不了的,只不过让程序变得稍微难理解了一些 :)

PS:ECMAScript 262规范中,并没有对异步、事件队列等概念及其实现的描述。这些都是具体的JavaScript运行时环境使用的机制。本文重点是描述异步过程的原理,为了便于理解做了很多简化。所以文中的某些术语的使用可能是不准确的,具体细节也未必是正确的,例如消息队列中消息的结构。请读者注意。

查看原文

Waxiangyu 关注了专栏 · 2019-11-04

JavaScript 高级深入理解总结系列文章

本专栏系统深入整理JavaScript高级的知识点,形成系统的知识体系。包括基础深入(数据类型、数据变量与内存、对象、函数、回调函数、this等),函数高级(原型与原型链、执行上下文与执行上下文栈、作用域与作用域链、闭包),对象高级(对象创建模式、继承模式),线程机制与事件机制(进程与线程、浏览器内核、定时器、事件循环模型、Web Workers),内存溢出与内存泄漏等。

关注 10

认证与成就

  • 获得 170 次点赞
  • 获得 2 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 2 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • vf.js

    基于vue和angular数据驱动和双向绑定以及react虚拟dom思想+require.js,编写的一套适用于ME-CRM平台系统的MVP框架。公司内部使用。

注册于 2017-04-05
个人主页被 1.4k 人浏览