第十章 React中的闭包 上

208 阅读8分钟

文章出处:www.advanced-react.com/

专栏地址:juejin.cn/column/7443…

在上一章节,我们学习了Refs的相关知识:什么是Refs,为什么需要Refs,何时用Refs,何时不用Refs。然而,在讨论到重新渲染前后的Refs时,还有一个话题我们要讨论:函数。更准确的说,是闭包以及它们如何影响其他代码。

让我们探讨一下一些有趣的事情和典型的bug,在这个过程中,学习到:

  • 什么是闭包,它们如何出现,以及为什么需要它们。
  • 什么是过时闭包,以及它们为什么回出现。
  • 导致React出现过时闭包的普遍场景,以及如何处理它们。

注意:也许你永远也解决不了React的闭包问题,这个章节可能会使你的头脑爆炸。请确保你有足够的巧克力来支撑你燃烧大脑。

问题

想象一下,你正在实现一个表单,而这个表单有若干输入框。其中一个字段引用了繁重的外部库。你无法访问这个外部库,所以你没法修复这个库的性能问题。但是你又不得不使用这个库,所以你决定使用React.memo来最大程度上减少它的重新渲染:

const HeavyComponentMemo = React.memo(HeavyComponent);
const Form = () => {
    const [value, setValue] = useState();
    return (
        <>
            <input
                type="text"
                value={value}
                onChange={(e) => setValue(e.target.value)}
            />
            <HeavyComponentMemo />
        </>
    ); 
 };

现在,这个HeavyComponent有一个字符串属性,和一个onClick回调。这个onClick回调,在用户点击HeavyComponent中的“done”按钮时触发。如果想把你输入的内传递给这个HeavyComponent组件,通过这个onClick回调即可:

const HeavyComponentMemo = React.memo(HeavyComponent); const Form = () => {
    const [value, setValue] = useState();
    const onClick = () => {
      // submit our form data here console.log(value);
    };
    return (
    <>
        <input
            type="text"
            value={value}
            onChange={(e) => setValue(e.target.value)}
        />
        <HeavyComponentMemo
            title="Welcome to the form"
            onClick={onClick}
        /> 
    </>
    );
};

现在,你进入了一个两难困境。我们从第五章知道,包裹在React.memo内的组件的属性,需要是原始值,或者是地址没有发生变化的引用类型。否则的话,这个缓存是无效的。所以,我们需要把onClick缓存在useCallback里:

const onClick = useCallback(() => {
    // submit data here
}, [])

但是,在使用useCallback时,我需要通过声明依赖数组来确保函数更新:

const onClick = useCallback(() => {
    // submit data here
    console.log(value)
    
    // adding value to the dependency
}, [value])

然后,又遇到了这个两难困境:即使我们的onClick被缓存了,它依然会在人们输入内容时发生更新。所以,这一性能优化是失败的。

好吧,我们只能使用另一种方法了。React.memo提供了一个比较函数。它允许我们对组件属性的比较进行更颗粒化的操作。通常情况下,React是自动比较前值和后值。如果我们使用了这个函数,React会依照这个函数的返回结果进行比较。如果返回为true,React会知道属性的前后值是一样的,所以不会重新渲染这个组件。感觉上这正是我们需要的。

我们认为只有一个属性的更新,需要重新渲染,就是title,而这逻辑也是很简单的:

const HeavyComponentMemo = React.memo(
    HeavyComponent,
    (before, afeter) => {
        return before.title === after.title
    }
)

如此一来,完整的代码是这样的:

const HeavyComponentMemo = React.memo(
    HeavyComponent,
    (before, after) => {
            return before.title === after.title;
        },
    );
    const Form = () => {
        const [value, setValue] = useState();
        const onClick = () => {
            // submit our form data here
            console.log(value);
        };
    return (
        <>
            <input
                type="text"
                value={value}
                onChange={(e) => setValue(e.target.value)}
            />
            <HeavyComponentMemo
                title="Welcome to the form"
                onClick={onClick}
            />
        </>
    );
}

这段代码跑起来了!当我们在输入框输入内容时,这个HeavyComponent并没有重新渲染,而且性能也没有被影响。

但是,这个onClick却不可用。如果去控制台看value的值,打印的是undefined

// thos one logs it correctly
console.log(value);

const onClick = () => {
    // this is always undefiend
    console.log(value);
}

代码示例: advanced-react.com/examples/10…

这背后发生了什么?

这其实是“过时闭包”问题。为了解决这个问题,我首先要研究JavaScript里最令人头疼的问题:

闭包及其工作原理。

JavaScript, 作用域 与 闭包

让我们先从函数和变量开始。我们在JavaScript里声明了一个函数时,到底发生了什么,无论我们是通过函数声明还是箭头函数。

function something() {
    //
}

const something = () => {};

通过声明函数,我们生成了一个本地作用域:这个作用域里的代码对内可见,但对外不可见。

const something = () => {
    const value = 'text';
};

console.log(value) // not going to work, "value" is local to "something" function

每当我们创建一个函数时,函数都会生成其本地作用域。如果我们在函数内部再生成一个函数,这个函数内的函数也有自己的作用域,其作用域对外层的函数也是不可见的。

const something = () => {
    const inside = () => {
        const value = 'text';
    }
    
    console.log(value) // not going to work, "value" is local to "inside" function
};

但如果我们从相反的视角看作用域的访问,就是一条康庄大道。在最里层的函数,可以访问所有外层声明的变量!

const something = () => {
    const value = 'text';
    
    const inside = () => {
        // perfectly fine, value is avaliable here
        console.log(value);
    }
   
};

这是通过创建所谓的 “闭包” 来实现的。内部的函数会 “闭合” 外部的所有数据。从本质上来说,它就像是对所有 “外部” 数据在某一时刻的快照,这些数据被冻结在那个时刻,并单独存储在内存中。

除了在inside函数内创建value外,我们还可以把value当作参数进行传递,并返回一个inside函数:

const something = (value) => {
    const inside = () => {
        // perfectly fine, value is avaliable here
        console.log(value);
    }
    
    return inside;
}

我们可以这样使用:

const first = something('first');
const second = something('second');

first();   // logs "first"
second();  // logs "second"

我们以“first”为参数调用something函数,并将函数运行结果赋值给了一个变量。而这个函数的运行结果是对已经声明的inside函数的引用。如此一来,一个闭包就被生成了。从现在开始,只要first变量一直保有对inside函数的引用,我们传递的“first”就被冻结了,inside函数就一直可以访问“first”。

同样的事情,也在第二次调用时发生了:我们传了另一个值,形成了另一个闭包,而inside返回函数可以永远访问第二次调用时传入的参数:

const something = (value) => {
  const r = Math.random();
  const inside = () => {
    // ...
  };
  return inside;
};
const first = something('first');
const second = something('second');

first(); // logs random number
second(); // logs another random number

这个过程,好比为一个动态物体拍照:只要你按下快门,当前图景被当作照片记录下来了。而后面拍下的照片,并不会影响原来拍的照片。

image.png

在React中,我一直在无意识得创造闭包。每个函数组件内的回调函数,都是一个闭包:

const Component = () => {
    const onClick = () => {
        // closure!
    }
    
    return <buton onClick={onClick}>
}

useEffect或者useCallback中的内容,也是一个闭包:

const Component = () => {
    const onClick = useCallback(() => {
        // closure!
    });

    useEffect(() => {
        // closure!
    });
};

而闭包里的成员,可以访问状态、属性和组件的本地变量了:

const Component = () => {
    const [state, setState] = useState();
    
    const onClick = useCallback(() => {
        // perfectly fine
        console.log(state);
    });
    
    useEffect(() => {
        // perfectly fine
        console.log(state);
    });
};

组件里的每一个函数都是一个闭包,因为组件本身就是一个函数。

过时闭包问题

为什么闭包会令那么多开发者头疼?

这是因为只要对创建闭包的那个函数存在引用,闭包就会一直存在。而一个函数的索引是一个任意赋值的值。让我们头脑风暴一下。下面是一个返回了闭包的代码:

const something = (value) => {
    const inside = () => {
        console.log(value);
    }
    
    return inside;
}

但这个inside函数会在每次something调用时,而重新创建。如果我想与之斗争,并缓存它,该怎么办?下面是代码:

const something = (value) => {
    if (!cache.current) {
        cache.current = () => {
            console.log(value);
        ]
    }
    
    return cache.current;
}

表面上看,这段代码没啥问题。我们不过是生成了一个名为cache的外部变量,然后把我们的inside函数赋值给了cache.current属性。现在,这个函数不会每次调用时都重新生成,我们只返回已经保存的值。

然而,如果我们调用几次这个函数,就会看到一些怪异的事情:

const first = something('first');
const second = something('second');
const third = something('third');

first(); // logs "first"
second(); // logs "first"
third(); // logs "first"

我们用不同的参数调用了something函数,但是打印的值都是第一次的值.

我们刚刚,其实生成了一个“过时闭包”。每一个闭包在其被创建时,就被“冻结”了。当我们第一次调用something函数时,我们生成了一个value值为“first”的闭包。之后,我们把这个闭包存在了一个位于something函数外的对象里。

当我们再次调用something函数时,就没有再次生成一个新的闭包了,只是返回了原来已经生成的闭包。而第一次调用时所生成的闭包就被永远“冻结”在那里了。

image.png

为了修复这个bug,我们需要实现每次value变化时,重新生成这个函数和与之对应的闭包。这是代码:

const cache = {};
let preValue;

const something = (value) => {
    // check whether the value has chagned
    if (!cache.current !! value !== preValue) {
        cache.current = () => {
            console.log(value);
        }
    };
    
    // refresh it
    preValue = value;
    return cache.current;
}

把value缓存起来以进行前后比较。之后,如果value前后不一样,就更新cache.current

如此一来,这段代码的就可以正常地打日志了。如果我们比较两个传入一样参数的函数,其引用也是一样的:

const first = something('first');
const anotherFirst = something('second');
const second = something('third');

first(); // logs "first"
second(); // logs "second"
console.log(first === anotherFirst); // wii be true

代码示例: advanced-react.com/examples/10…