结合ArkTS组件开发 再谈ES的this指向问题

499 阅读4分钟

缘起

关于this指向问题,有个开发中的小插曲:

我在组件A中创建一个@BuilderParam成员作为插槽,在页面中调用A组件,并把该页面的一个@Builder传入这个插槽。在这个@Builder中有个按钮动作,一点就导致App闪退。

下面复现一下这个问题。首先是组件代码:

@Component
struct CompWillCrash {
    @BuilderParam slot?: () => void
    build() {
        Column() {
            Text("This is `CompWillCrash`")
            if(typeof this.slot === 'function') {
                this.slot()
            }
        }
    }
}

页面调用代码:

@Entry
@Component
struct Index {
    @State message: string = 'Index'
    @Builder
    Slot() {
        Button('Let's crash').onClick(() => {
            promptAction.showToast({
                message: this.message
            })
        })
    }

    build() {
        Flex({ direction: FlexDirection.Column, alignItems: ItemAlign.Center, justifyContent: FlexAlign.Center }){
            CompWillCrash({
                slot: this.Slot, // 这里传入内部builder插槽
            })
        }
        .size({ width: '100%', height: '100%' })
    }
}

先读代码:我们在页面中定义组件Slot,组件中有个按钮,点击后toast页面状态message的值。

这个Slot组件通过slot参数传递给组件CompWillCrash,最终是由CompWillCrashSlot组件渲染到页面上。

可能有些同学到这里就能感知到问题了。看下效果:

10.gif

如果到这里还没看出问题,那么我展示一下一行代码解决app闪退的功力:

@Component
struct CompWillCrash {
    @BuilderParam slot?: () => void
    @State message: string = 'Watch out!'  // ---> 添加一行代码
    build() {
        Column() {
            Text("This is `CompWillCrash`")
            if(typeof this.slot === 'function') {
                this.slot()
            }
        }
    }
}

来看效果:

11.gif

缘由

因为Slot(注意大小写)组件是个Function,这个function是在组件中调用的,在this.slot()运行的时候,Slot的上下文会变成this.slot()里的this,也就是组件CompWillCrash

所以,Slot组件的onClick触发时,promptAction.showToast调用的this.message,实际上是CompWillCrash.prototype.message,是undefined,导致参数错误并闪退:

Error message:Parameter error. The type of message is incorrect.
Error code:401
SourceCode:
                promptAction.showToast({

复习一下this上下文

我们在web范畴讨论下面的代码:

(function() { console.log(this) })();
(() => { console.log(this) })();
const obj = {
    f1(){ console.log(this) },
    f2: function() { console.log(this) },
    f3: () => { console.log(this) }
}
class A { f4(){ console.log(this) } f5 = () => { console.log(this) } }
obj.f1()
obj.f2()
obj.f3()
new A().f4()
new A().f5()

输出:

Window
Window
{f1: ƒ, f2: ƒ, f3: ƒ}
{f1: ƒ, f2: ƒ, f3: ƒ}
Window
A
A

其中,obj.f1obj.f2都是function声明,只不过f1是简化写法。

整体来讲输出符合预期,不赘述。下面进阶一下:

const of1 = obj.f1;
const of2 = obj.f2;
const of3 = obj.f3;
const of4 = new A().f4;
const of5 = new A().f5;
of1();
of2();
of3();
of4();
of5();

输出结果:

Window
Window
Window
undefined
A

这个结果倒也不是很意外。

其中obj内部的3个函数,前两个of1 of2是因为调用方式变化,所以指向了Window对象;后一个of3是箭头函数,this总是指向定义时所在位置的上下文,这和of5箭头函数输出A同理。

关于of4输出undefined的原因,大概说一下我的理解:f4作为function声明,会被记录在原型链上(f5并不在原型链上)。我们重新赋值出来of4,由于f4调用方式发生变化,相当于打断了原型链,原本this指向的实例消失,所以输出undefined

回到ArkTS

首先页面中的Slot在定义时是function声明(也不支持箭头函数声明呢),所以一定会受到调用方式的变化。那么在组件中,this.slot()方式调用SlotSlot的上下文自然就变成组件的实例。

同时Slot内部的onClick事件绑定的是箭头函数,按箭头函数总是指向定义时所在位置的上下文来推算,那里面的this自然会随Slot变成组件自己。

缘解

解决this指向问题自然办法很多,首先来一套bind

build() {
    Flex({ direction: FlexDirection.Column, alignItems: ItemAlign.Center, justifyContent: FlexAlign.Center }){
        CompWillCrash({
            slot: this.Slot.bind(this),
        })
    }
    .size({ width: '100%', height: '100%' })
}

别说,这样真能解决:

image.png

但是官方不支持呢:

image.png

同样不支持的还有call apply

寻求官方issue支持

找官方提了issue,希望要么支持箭头函数组件,也就是@Builder需要支持箭头函数;要么支持function声明函数的bind支持。

当时挺急的,issue的言辞也比较硬,因为这种指向问题,与代码观感严重不符,掉坑几率是百分百的;另外,如果没有有效的解决方案,不但开发受影响,而且几乎可以说明ArkTS的组件模式不能自洽。

几轮沟通之后,我终于说清楚了问题,官方给了推荐做法:

build() {
    Flex({ direction: FlexDirection.Column, alignItems: ItemAlign.Center, justifyContent: FlexAlign.Center }){
        CompWillCrash({
            slot: () => {
                this.Slot()
            },
        })
    }
    .size({ width: '100%', height: '100%' })
}

看了之后不吱声了,怪自己代码还不够骚……

然后呢

这应该是卡我开发的最后一个问题,后面就ArkTS起飞了。