1. 程式人生 > >深入理解React中的上下文this

深入理解React中的上下文this

寫在前面

JavaScript中的作用域scope 和上下文 context 是這門語言的獨到之處,每個函式有不同的變數上下文和作用域。這些概念是JavaScript中一些強大的設計模式的後盾。在ES5規範裡,我們可以遵循一個原則——每個function內的上下文this指向該function的呼叫方。比如:

var Module = {
    name: 'Jafeney',
    first: function() {
        console.log(this);   // this物件指向呼叫該方法的Module物件
        var second = (function
() {
console.log(this) // 由於變數提升,this物件指向Window物件 })() }, init: function() { this.first() } } Module.init()

這裡寫圖片描述

但是,在ES6規範中,出現了一個逆天的箭頭操作符 => ,它可以替代原先ES5裡function的作用,快速宣告函式。那麼,在沒有了function關鍵字,箭頭函式內部的上下文this是怎樣一種情況呢?

ES6中的箭頭函式

箭頭函式的基本介紹

ES6允許使用“箭頭”=> 定義函式。

var f = v => v;
//上面的箭頭函式等同於:
var f = function(v) {
  return v;
};
  • 如果箭頭函式不需要引數或需要多個引數,就使用一個圓括號代表引數部分。
var f = () => 5;
// 等同於
var f = function () { return 5 };
var sum = (num1, num2) => num1 + num2;
// 等同於
var sum = function(num1, num2) {
  return num1 + num2;
};
  • 如果箭頭函式的程式碼塊部分多於一條語句,就要使用大括號將它們括起來,並且使用return
    語句返回(重要)
var sum = (num1, num2) => { return num1 + num2; }
  • 由於大括號被解釋為程式碼塊,所以如果箭頭函式直接返回一個物件,必須在物件外面加上括號(重要)。
var getTempItem = id => ({ id: id, name: "Temp" });
  • 箭頭函式可以與變數解構結合使用。
const full = ({ first, last }) => first + ' ' + last;
// 等同於
function full(person) {
  return person.first + ' ' + person.last;
}
  • 箭頭函式使得表達更加簡潔。
const isEven = n => n % 2 == 0;
const square = n => n * n;

上面程式碼只用了兩行,就定義了兩個簡單的工具函式。如果不用箭頭函式,可能就要佔用多行,而且還不如現在這樣寫醒目。

  • 箭頭函式的一個用處是簡化回撥函式。
// 正常函式寫法
[1,2,3].map(function (x) {
  return x * x;
});

// 箭頭函式寫法
[1,2,3].map(x => x * x);

箭頭函式使用注意點

(1)函式體內的this物件,就是定義時所在的物件,而不是使用時所在的物件。

(2)不可以當作建構函式,也就是說,不可以使用new命令,否則會丟擲一個錯誤。

(3)不可以使用arguments物件,該物件在函式體內不存在。如果要用,可以用Rest引數代替。

(4)不可以使用yield命令,因此箭頭函式不能用作Generator函式。

this指向固定化

ES5規範中,this物件的指向是可變的,但是在ES6的箭頭函式中,它卻是固定的。

function foo() {
  setTimeout(() => {
    console.log('id:', this.id);
  }, 100);
}

var id = 21;

foo.call({ id: 42 });   // 輸出 id: 42

注意:上面程式碼中,setTimeout的引數是一個箭頭函式,這個箭頭函式的定義生效是在foo函式生成時,而它的真正執行要等到100毫秒後。如果是普通函式,執行時this應該指向全域性物件window,這時應該輸出21。但是,箭頭函式導致this總是指向函式定義生效時所在的物件(本例是{id: 42}),所以輸出的是42。

箭頭函式的原理

this指向的固定化,並不是因為箭頭函式內部有繫結this的機制,實際原因是箭頭函式根本沒有自己的this,導致內部的this就是外層程式碼塊的this。正是因為它沒有this,所以也就不能用作建構函式。所以,箭頭函式轉成ES5的程式碼如下:

// ES6
function foo() {
  setTimeout(() => {
    console.log('id:', this.id);
  }, 100);
}

// ES5
function foo() {
  var _this = this;

  setTimeout(function () {
    console.log('id:', _this.id);
  }, 100);
}

上面程式碼中,轉換後的ES5版本清楚地說明了,箭頭函式裡面根本沒有自己的this,而是引用外層的this

兩道經典的面試題

// 請問下面有幾個this 

function foo() {
  return () => {
    return () => {
      return () => {
        console.log('id:', this.id);
      };
    };
  };
}

var f = foo.call({id: 1});

var t1 = f.call({id: 2})()(); // 輸出 id: 1
var t2 = f().call({id: 3})(); // 輸出 id: 1
var t3 = f()().call({id: 4}); // 輸出 id: 1

上面程式碼之中,其實只有一個this,就是函式foo的this,所以t1、t2、t3都輸出同樣的結果。因為所有的內層函式都是箭頭函式,都沒有自己的this,它們的this其實都是最外層foo函式的this。另外,由於箭頭函式沒有自己的this,所以也不能用call()apply()bind()這些方法去改變this的指向

// 請問下面程式碼執行輸出什麼

(function() {
  return [
    (() => this.x).bind({ x: 'inner' })()
  ];
}).call({ x: 'outer' });

上面程式碼中,箭頭函式沒有自己的this,所以bind方法無效,內部的this指向外部的this。所以上面的程式碼最終輸出 ['outer']

函式繫結 ::

箭頭函式可以繫結this物件,大大減少了顯式繫結this物件的寫法(callapplybind)。但是,箭頭函式並不適用於所有場合,所以ES7提出了“函式繫結”(function bind)運算子,用來取代callapplybind呼叫。雖然該語法還是ES7的一個提案,但是Babel轉碼器已經支援。

函式繫結運算子是並排的兩個雙冒號(::),雙冒號左邊是一個物件,右邊是一個函式。該運算子會自動將左邊的物件,作為上下文環境(即this物件),繫結到右邊的函式上面。

foo::bar;
// 等同於
bar.bind(foo);

foo::bar(...arguments);
// 等同於
bar.apply(foo, arguments);

const hasOwnProperty = Object.prototype.hasOwnProperty;
function hasOwn(obj, key) {
  return obj::hasOwnProperty(key);
}

如果雙冒號左邊為空,右邊是一個物件的方法,則等於將該方法繫結在該物件上面。

var method = obj::obj.foo;
// 等同於
var method = ::obj.foo;

let log = ::console.log;
// 等同於
var log = console.log.bind(console);

由於雙冒號運算子返回的還是原物件,因此可以採用鏈式寫法。

// 例一
import { map, takeWhile, forEach } from "iterlib";

getPlayers()
::map(x => x.character())
::takeWhile(x => x.strength > 100)
::forEach(x => console.log(x));

// 例二
let { find, html } = jake;

document.querySelectorAll("div.myClass")
::find("p")
::html("hahaha");

React中的各種this

目前React的編寫風格已經全面地啟用了ES6和部分ES7規範,所以很多ES6的坑在React裡一個個浮現了。本篇重點介紹 this,也是近期跌得最疼的一個。

Component方法內部的this

還是用具體的例子來解釋吧,下面是我 Royal 專案裡一個Table元件(Royal正在開發中,歡迎fork貢獻程式碼 ^_^)

import React, { Component } from 'react'
import Checkbox from '../../FormControls/Checkbox/' 
import './style.less'

class Table extends Component {
    constructor(props) {
        super(props)
        this.state = {
            dataSource: props.dataSource || [],
            columns: props.columns || [],
            wrapClass: props.wrapClass || null,
            wrapStyle: props.wrapStyle || null,
            style: props.style || null,
            className: props.className || null,
        }
        this.renderRow = props.renderRow || null
    }

    onSelectAll() {
        for (let ref in this.refs) {
            if (ref!=='selectAll') {
                this.refs[ref].setState({checked:true})
            }
        }
    }

    offSelectAll() {
        for (let ref in this.refs) {
            if (ref!=='selectAll') {
                this.refs[ref].setState({checked:false})
            }
        }
    }

    _renderHead() {
        return this.state.columns.map((item,i) => {
            return [<th>{i===0?<Checkbox ref="selectAll" onConfirm={()=>this.onSelectAll()} onCancel={()=>this.offSelectAll()} />:''}{item.title}</th>]
        })
    }

    _renderBody() {
        let _renderRow = this.renderRow;
        return this.state.dataSource.map((item) => {
            return _renderRow && _renderRow(item)
        })
    }

    render() {
        let state = this.state;
        return (
            <div className={state.wrapClass} style={state.wrapStyle}>
                <table
                    border="0"
                    style={state.style}
                    className={"ry-table " + (state.className && state.className : "")}>
                    <thead>
                        <tr>{this._renderHead()}</tr>
                    </thead>
                    <tbody>
                        {this._renderBody()}
                    </tbody>
                </table>
            </div>
        )
    }
}

export default Table

ComponentReact內的一個基類,用於繼承和建立React自定義元件。ES6規範下的面向物件實現起來非常精簡,class關鍵字 可以快速建立一個類,而Component類內的所有屬性和方法均可以通過this訪問。換而言之,在Component內的任意方法內,可以通過this.xxx的方式呼叫該Component的其他屬性和方法。

接著分析上面的程式碼,寥寥幾行實現的是對一個Table元件的封裝,借鑑了ReactNative元件的設計思路,通過外部傳遞dataSource(資料來源)、columns(表格的表頭項)、renderRow(當行渲染的模板函式)來完成一個Table的構建,支援全選和取消全選的功能、允許外部傳遞classNamestyle物件來修改樣式。

從這個例子我們可以發現:只要不採用function定義函式,Component所有方法內部的this物件始終指向該類自身。

container呼叫component時傳遞的this

還是繼續上面的例子,下面在一個做為Demo的container裡呼叫之前 的Table

import Table from '../../components/Views/Table/' 

接著編寫renderRow函式並傳遞給Table元件

    _renderRow(row) {
        // ------------ 注意:這裡對callback函式的寫法 -----------
        let onEdit = (x)=> {
            console.log(x+x)
        }, onDelete = (x)=> {
            console.log(x*x)
        }
        // ---------------------------------------------------
        return (
            <tr>
                <td><Checkbox ref={"item_" + row.key} />{row.key}</td>
                <td>{row.name}</td>
                <td>{row.age}</td>
                <td>{row.birthday}</td>
                <td>{row.job}</td>
                <td>{row.address}</td>
                <td>
                    <Button type="primary" callback={()=>onEdit(row.key)} text="編輯" />
                    <Button type="secondary" callback={()=>onDelete(row.key)} text="刪除" />
                </td>
            </tr>
        )
    }

    //... 省略一大堆程式碼

    render() {
        let dataSource = [{
            key: '1',
            name: '胡彥斌',
            age: 32,
            birthday: '2016-12-29',
            job: '前端工程師',
            address: '西湖區湖底公園1號'
            }, {
            key: '2',
            name: '胡彥祖',
            age: 42,
            birthday: '2016-12-29',
            job: '前端工程師',
            address: '西湖區湖底公園1號'
        }],columns = [{
            title: '編號',
            dataIndex: 'key',
            key: 'key',
            },{
            title: '姓名',
            dataIndex: 'name',
            key: 'name',
            }, {
            title: '年齡',
            dataIndex: 'age',
            key: 'age',
            }, {
            title: '生日',
            dataIndex: 'birthday',
            key: 'birthday',
            }, {
            title: '職務',
            dataIndex: 'job',
            key: 'job',
            },{
            title: '住址',
            dataIndex: 'address',
            key: 'address',
            }, {
            title: '操作',
            dataIndex: 'operate',
            key: 'operate',
        }];
        return (
            <div>
                <Table dataSource={dataSource} columns={columns} renderRow={this._renderRow}/>
            </div>
        );
    }

顯示效果如下:

這裡寫圖片描述

分析上面的程式碼,有幾處容易出錯的地方:

(1)_renderRow 作為component的方法來定義,然後在對應的render函式內通過this來呼叫。很重要的一點,這裡this._renderRow作為的是函式名方式傳遞。

(2)_renderRow 內部Button元件的callback是按鈕點選後觸發的回撥,也是一個函式,但是這個函式沒有像上面一樣放在component的方法裡定義,而是作為一個變數定義並通過匿名函式的方式傳遞給子元件:

let onEdit = (x)=> {
    console.log(x+x)
}

// .....
callback={()=>onEdit(row.key)}

這樣就避開了使用this時上下文變化的問題。這一點是很講究的,如果沿用上面的寫法很容易這樣寫:


onEdit(x) {
   console.log(x+x)
}

// ... 
callback={()=>this.onEdit(row.key)}

但是很遺憾,這樣寫this傳遞到子元件後會變成undefined,從而報錯。

(3)父元件如要呼叫子元件的方法,有兩種方式:

  • 第一種 通過匿名函式的方式
callback = {()=>this.modalShow()}
  • 第二種 使用 bind
callback = {this.modalShow.bind(this)}

注意:如果要繫結的函式需要傳引數,可以這麼寫: xxx.bind(this,arg1,arg2...)

參考