Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

简单聊一聊 JavaScript 中的引用 #27

Open
hacker0limbo opened this issue Jun 3, 2022 · 0 comments
Open

简单聊一聊 JavaScript 中的引用 #27

hacker0limbo opened this issue Jun 3, 2022 · 0 comments
Labels
javascript 原生 JavaScript 笔记整理

Comments

@hacker0limbo
Copy link
Owner

hacker0limbo commented Jun 3, 2022

老生常谈的问题了, 之前看到一篇还挺有趣的文章, 记录一下

什么是引用

考虑如下代码:

let word = 'hello';

我们可以说变量 word 指向(pints to) 一个盒子, 该盒子里装着 "hello". 注意 word 变量不是盒子, 而是指向盒子.

现在给 word 重新赋值

word = 'world';

这段代码的解释可以为, 现在有一个新的盒子被创建了, 盒子里装着 "world", word 变量现在指向这个新的装有 "world" 盒子, 而之前装有 "hello" 的盒子被 garbage collector 清理掉了, 因为没有地方用到他.

用图表示的话如下:

reassign-a-variable

引申一下, 如果尝试过给函数参数重新进行赋值, 你会发现他并不能改变函数外的任何值.

简单来讲, 即如果给函数形式参数重新赋值, 那对外部传递的实际参数没有任何影响. 比如以下代码:

function reassignFail(word) {
  // this assignment does not leak out
  word = 'world';
}

let test = 'hello';
reassignFail(test);
console.log(test); // prints "hello"

上面代码的解释为:

  • 一开始只有 test 变量指向 "hello"
  • 当执行 reassignFail 函数开始传递实参的时候, testword 均指向相同的盒子, 即 "hello"
  • 函数执行, 进行变量的重新赋值, 此时形式参数 word 指向了新的盒子 "world", 但该操作对外面的 test 变量没有任何影响, test 变量仍旧指向 "hello" 盒子

在 JavaScript 中变量的赋值可以看做是这么解释的. 当重新给一个变量赋值的时候, 他不会改变别的也指向之前同样"盒子"的变量, 他只改变自己对"盒子"的指向, 且不管这个盒子里装的是什么, boolean, number, object, array, function, 该操作均是这样进行的

两种类型

JavaScript 有两种广泛的类型分类, 他们对于赋值(assignment) 和 referential equality(引用) 有不同的规则

Primitive Type 原始类型

原始类型包括 string, number, boolean, symbol, undefined, null, 这些类型的数据是 immutable 的, 即只可读, 不可被改变(read-only, can’t be changed)

当一个变量持有原始类型的数据时, 你不能改变数据本身, 换句话说, 如果盒子里装的是原始类型, 你不能改变盒子里的东西, 你能做的只能创建一个新的盒子, 然后将变量指向这个新的盒子, 就如同之前的图所展示的:

reassign-a-variable

而不是以下所示图这样:

Flawed_Mental_Model_Of_Reassignment

同样的, 举个例子, 所有 string 的方法总是返回一个新的 string 而不是改变 string 本身, 如果你想要获取新的 string, 你就必须用一个变量指向他把他保存起来, 因为旧的值永远不会发生改变, 例如以下代码片段:

let name = "Dave"
name.toLowerCase();
console.log(name) // still capital-D "Dave"

name = name.toLowerCase()
console.log(name) // now it's "dave"

Object Type

第二种类型是对象类型, 包括常见的 Object, Array, Function, MapSet. 和原始类型不同的是这些类型是 mutable 的, 形象点说你可以改变盒子里的东西

Immutable is Predictable

Immutable 是可控的. 就像之前所说, 如果将一个原始类型作为参数传递给一个函数, 那么可以保证指向这个原始类型的变量是"安全的". 因为调用这个函数永远不会改变到这个变量所对应的值. 即盒子还是原来的盒子, 盒子里的东西永远没变.

但是对于对象类型, 这就存在隐患. 如果将一个对象传递给一个函数, 那么调用这个函数后该对象的值可能会发生改变. 比如如果这个对象是一个数组, 函数内部可以对这个数组进行增加元素或者删除元素操作. 虽然说对于外部一开始指向对象的变量引用没变, 但是由于对象是 mutable 的, 盒子里的值还是变了. 即盒子还是原来的盒子, 但是盒子里的东西最后有可能发生变化...

一个不会改变参数, 或者任何外部的东西的函数可以认为他是一个纯函数. 如果需要改变参数某个值, 那么会选择返回一个新的变量存着新的值

简单回顾: 变量指向盒子, 原始类型是 immutable 的

不管是原始类型还是对象类型, 在进行赋值操作的时候, 我们都认为创建了一个新的盒子, 然后将变量指向那个盒子, 这种操作永远都是成立的, 不管是发生在第一次赋值(assignment)还是后面的重新赋值(reassignment)

let num = 42
let name = "Dave"
let yes = true
let no = false
let person = {
  firstName: "Dave",
  lastName: "Ceddia"
}
let numbers = [4, 8, 12, 37]

原始类型是不可变的, 你没有办法改变盒子里的东西, 你只能重新创建一个盒子有着新的值, 然后让变量指向这个新的盒子.

对象类型: 改变盒子里的内容

假设有这样一个对象 book:

let book = {
  title: "Tiny Habits",
  author: "BJ Fogg",
  isCheckedOut: false
}

如果对这个对象的某个属性进行修改操作:

book.isCheckedOut = true

虽然 book 这个变量指向没变, 一直指向的盒子是同一个盒子(同一个对象). 但是盒子里的内容还是变了, 因为对象的属性变了.

如图所示:

change-object

注意这种赋值和之前的规则是一样的, 不同于我们直接使用变量 isCheckedOut, 这次使用 book.isCheckedOut 来代表一个变量, 重新赋值的时候指向新的盒子

需要注意的是, book 这个对象的引用一直没变, 即盒子还是原来的盒子. 如果我们尝试用另一个变量指向这个盒子, 会发现当 book 内的属性改变时, 另一个变量也会跟随变化, 因为都是指向同一个对象. 例如:

let book = {
  title: "Tiny Habits",
  author: "BJ Fogg",
  isCheckedOut: false
}

let backup = book

book.isCheckedOut = true

console.log(backup === book)  // true!

这种操作不是拷贝, 因为没有制造一个新的对象, 只是单纯的让两个变量指向同一个对象地址, 所以最后 console.log 返回的是 true

需要注意的是, 变量永远只会指向盒子, 不存在变量指向变量的情况. 比如这个例子里, 当进行 backup = book 操作的时候, JS 会查看 book 指向的盒子, 然后让 backup 也指向相同的盒子, 不存在 backup 指向 book 这种说法. 这就很赞, 因为就不会被谁指向谁再指向谁一层一层绕晕了...

在函数中 Mutate(修改) 一个对象

之前提到在函数中直接完全修改形式参数是不会影响到实际参数的(换盒子地址). 但是如果改变的是盒子内部的属性, 那么会影响到函数外边对该盒子的变量. 因为形式参数和实际参数都指向同一个盒子. 比如这个例子:

function checkoutBook(book) {
  // this change will leak out!
  book.isCheckedOut = true
}

let book = {
  title: "Tiny Habits",
  author: "BJ Fogg",
  isCheckedOut: false
}

checkoutBook(book); // isCheckedOut: true

用图来表示和之前的一样:

change-object

如果想要阻止这种情况发生, 比较好的做法是对传来的对象做一份浅拷贝, 创造一个新盒子, 新盒子里的属性和旧盒子完全一样. 这样改变新盒子的内容就不会影响到旧盒子了

function pureCheckoutBook(book) {
  let copy = { ...book }

  // this change will only affect the copy
  copy.isCheckedOut = true

  // gotta return it, otherwise the change will be lost
  return copy
}

let book = {
  title: "Tiny Habits",
  author: "BJ Fogg",
  isCheckedOut: false
}

// This function returns a new book,
// instead of modifying the existing one,
// so replace `book` with the new checked-out one
book = pureCheckoutBook(book);

关于引用的一些小例子

第一个例子:

document.addEventListener('click', () => console.log('clicked'));
document.removeEventListener('click', () => console.log('clicked'));

// won't work

addEventListenerremoveEventListener 第二个参数必须是一样的引用才能保证该回调函数被成功添加/移除到 click 事件上, 上面的例子相当于创建了两个匿名的盒子, 引用当然完全不一样

比如可以试一下:

let a = () => {}
let b = () => {}
console.log(a === b) // false

最后结果一定是 false. 所有的对象(array, function, set, map, etc.) 创建的时候都存在于自己的盒子里

第二个例子:

function minimum(array) {
  array.sort();
  return array[0]
}

const items = [7, 1, 9, 4];
const min = minimum(items);
console.log(min) // 1
console.log(items) // [1, 4, 7, 9]

sort() 会改变 array 本身. 不注意的话有时候就会造成意想不到的结果...

Strict Equal 和 Shallow Equal

两个概念其实经常出现在 React 以及一些衍生库里, 比如一些 APi 会提到默认使用 Strict Equal 进行比较, 如果想要自定义比较过程需要手动写 equal function.

Strict Equal

先说 strict equal. 中文翻译过来就是严格比较. 官方也有相关文档. 简单来说如果是原始类型, 那直接比较值就行, 比如 true === true, 1 === 1, null === null. 如果比喻成盒子的话, 比较的时候会具体到盒子里存的内容, 比如这样:

let a = 1
let b = 1
let c = a

console.log(a === b) // true
console.log(a === c) // true
console.log(b === c) // true

如果是两个对象进行严格比较, 那么比较的是他们两个地址一不一样, 即两个对象是不是同一个对象, 或者说他们指向的是不是同一个盒子.

比如最简单的:

let a = {}
let b = {}
console.log(a === b) // false

Shallow Equal

然后是 shallow equal, 浅比较. React 官方有关于这个概念简单的定义:

shallowCompare performs a shallow equality check on the current props and nextProps objects as well as the current state and nextState objects.
It does this by iterating on the keys of the objects being compared and returning true when the values of a key in each object are not strictly equal.

简单来讲, 就是对一个对象的所有属性进行严格比较, 注意是只有第一层的属性. 所以如果第一层属性对应的值是一个对象的话, 比较结果可能是 false, 因为两个对象的引用根本不一样. 举一些例子:

shallowEqual([1, 2, 3], [1, 2, 3]); // => true
shallowEqual([{ a: 5 }], [{ a: 5 }]); // => false

shallowEqual({ a: 5, b: "abc" }, { a: 5, b: "abc" }); // => true
shallowEqual({ a: 5, b: {} }, { a: 5, b: {} }); // => false

一些应用

React

React 就提供一些 API 基于 shallowEuqal 的特性进行性能优化, 比如比较常见的一个 React.memo. 官网的介绍也是非常清晰了, 这里直接贴一下:

If your component renders the same result given the same props, you can wrap it in a call to React.memo for a performance boost in some cases by memoizing the result. This means that React will skip rendering the component, and reuse the last rendered result.

React.memo only checks for prop changes. If your function component wrapped in React.memo has a useState, useReducer or useContext Hook in its implementation, it will still rerender when state or context change.

By default it will only shallowly compare complex objects in the props object. If you want control over the comparison, you can also provide a custom comparison function as the second argument.

翻译过来就这么几个点:

  • 如果组件接受相同的 props 且 render 的结果也一样, 那么可以用 React.memo 进行优化, 优化方式是这种情况下会跳过重复渲染, 直接采用上次渲染的结果(毕竟 render 结果都是一样的)
  • React.memo 只考虑 props 变化, 如果 state 有变化即使 props 一样组件依旧会被渲染.
  • 使用 shallowEqual 对 props 比较, 也就是对 props 这个对象进行浅比较. 如果 props 这个对象是一个很复杂的对象, 比如包含很多深层次的属性, 那可以自定义比较函数

说了这么多, 直接看一个例子: https://stackblitz.com/edit/react-wvppng-tfoayr

先看效果:

react-memo

代码如下:

import React from 'react';

function Child({ count }) {
  console.log('child render');
  return (
    <div>
      Child: <span>{count}</span>
    </div>
  );
}

const MemorizedChild = React.memo(({ count }) => {
  console.log('memorized child render');
  return (
    <div>
      MemorizedChild: <span>{count}</span>
    </div>
  );
});

export default function App() {
  const [value, setValue] = React.useState('');

  return (
    <div>
      <h1>React memo 使用</h1>
      <input
        type="text"
        value={value}
        onChange={(e) => {
          setValue(e.target.value);
        }}
      />
      <Child count={0} />
      <MemorizedChild count={0} />
    </div>
  );
}

App 组件里由于有一个输入框, 输入框有内容输入的时候会不断触发 setState 继而 App 会不断被渲染. 而由于 React 的默认行为是当父组件被渲染时, 子组件也会被渲染(不考虑 bail out 情况). 所以 ChildMemorizedChild 也理应要被渲染. 这里可以注意, 两个子组件的 props 一直都是一样的都是 { count: 0 }. 为了避免这种无必要重复渲染, 使用 React.memo 包裹后的 MemorizedChild 就不会被重复渲染了.

Redux

Redux 提供的 useSelector 默认的比较行为是严格比较(===), 如果想要通过返回一个对象或者数组拿多个状态的话很有可能造成没必要的重复渲染, 因为可能只是某个子状态发生了改变, 但由于进行的是严格比较, 整个对象的引用都变了, 会导致组件也会被渲染. 一般没有特殊需求的话使用 shallowEqual 就行. 如下:

import { shallowEqual, useSelector } from 'react-redux'

// later
const selectedData = useSelector(selectorReturningObject, shallowEqual)

或者直接自定义为一个 hook:

import { useSelector, shallowEqual } from 'react-redux'

export function useShallowEqualSelector(selector) {
  return useSelector(selector, shallowEqual)
}

另一个类似的库 zustand 也是提倡类似的概念, 官方文档也更加简洁清晰, 如下:

// If you want to construct a single object with multiple state-picks inside, similar to redux's mapStateToProps, you can tell zustand that you want the object to be diffed shallowly by passing the shallow equality function.

import shallow from 'zustand/shallow'

// Object pick, re-renders the component when either state.nuts or state.honey change
const { nuts, honey } = useStore(state => ({ nuts: state.nuts, honey: state.honey }), shallow)

// Array pick, re-renders the component when either state.nuts or state.honey change
const [nuts, honey] = useStore(state => [state.nuts, state.honey], shallow)

// Mapped picks, re-renders the component when state.treats changes in order, count or keys
const treats = useStore(state => Object.keys(state.treats), shallow)

// For more control over re-rendering, you may provide any custom equality function.
const treats = useStore(
  state => state.treats,
  (oldTreats, newTreats) => compare(oldTreats, newTreats)
)

用这类库的时候有时候多写一行也不是什么坏事, 保证每次获取的状态都是独立, 这样也不用去考虑如何写 compareFn, 理论上也不会导致不必要的渲染

参考

@hacker0limbo hacker0limbo added the javascript 原生 JavaScript 笔记整理 label Jun 3, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
javascript 原生 JavaScript 笔记整理
Projects
None yet
Development

No branches or pull requests

1 participant