Solid 之旅 —— 为什么 props 被解构后会导致响应式丢失

532 阅读6分钟

在前面的文章中,我们学习了 Solid 的响应式原理,深入了了解其实现方式。

Solid 之旅 —— Signal 响应式原理

这篇文章将主要深入解析组件内部props的原理,为什么结构后会导致响应式丢失?

案例

我们以一个例子作为参考,由浅入深的讲解其中的奥秘。

Parent.tsx

import { createSignal } from 'solid-js'
import Child from './child'

export default function Props() {
  const [name, setName] = createSignal('JinSo')
  const [age, setAge] = createSignal(23)

  const update = () => {
    setName('Jason')
    setAge(99)
  }

  return (
    <section class='text-gray-700 p-8'>
      <Child name={name()} age={age()} />
      <button type='button' onClick={update}>
        Update
      </button>
    </section>
  )
}

Child.tsx

interface ChildProps {
  name: string
  age: number
}

export default function Child(props: ChildProps) {
  return (
    <div>
      <div>name: {props.name}</div>
      <div>age: {props.age}</div>
    </div>
  )
}

组件编译

先来看看组件编译后是什么样子的,官网也提供了 Playground,可以进行尝试:

Solid Playground

Parent.tsx 组件编译效果

import { template as _$template } from "solid-js/web";
import { delegateEvents as _$delegateEvents } from "solid-js/web";
import { insert as _$insert } from "solid-js/web";
import { createComponent as _$createComponent } from "solid-js/web";
var _tmpl$ = /*#__PURE__*/_$template(`<section class="text-gray-700 p-8"><button type=button>Update`);
import { render } from "solid-js/web";
import { createSignal } from "solid-js";

export default function Props() {
  const [name, setName] = createSignal('JinSo');
  const [age, setAge] = createSignal(23);
  const update = () => {
    setName('Jason');
    setAge(99);
  };
  return (() => {
    var _el$ = _tmpl$(),
      _el$2 = _el$.firstChild;
    _$insert(_el$, _$createComponent(Child, {
      get name() {
        return name();
      },
      get age() {
        return age();
      }
    }), _el$2);
    _el$2.$$click = update;
    return _el$;
  })();
}

render(() => _$createComponent(Props, {}), document.getElementById("app"));
_$delegateEvents(["click"]);

Child.tsx 组件编译效果

import { template as _$template } from "solid-js/web";
import { createComponent as _$createComponent } from "solid-js/web";
import { insert as _$insert } from "solid-js/web";
var _tmpl$ = /*#__PURE__*/_$template(`<div><div>name: </div><div>age: `);
import { render } from "solid-js/web";

function Child(props) {
  return (() => {
    var _el$ = _tmpl$(),
      _el$2 = _el$.firstChild,
      _el$3 = _el$2.firstChild,
      _el$4 = _el$2.nextSibling,
      _el$5 = _el$4.firstChild;
    _$insert(_el$2, () => props.name, null);
    _$insert(_el$4, () => props.age, null);
    return _el$;
  })();
}

编译内容和源码本身其实大部分是一致的,只是对最终的 DOM 实现这块进行了特殊处理;暂时剥去其他的内容先不管,看看 Child 组件被编译成了什么:

_$createComponent(Child, {
  get name() {
    return name()
  },
  get age() {
    return age()
  },
})

可以看到,使用了 createComponent 生成组件,同时将 props 转换成一个对象进行传递。

createComponent 里实际就是调用了 Comp 函数式组件:

export function createComponent<T>(Comp: Component<T>, props: T): JSX.Element {
  if ("_SOLID_DEV_") return devComponent(Comp, props || ({} as T));
  return untrack(() => Comp(props || ({} as T)));
}

先来看一下这个转换后的 props 对象,重点关注一下它创建对象的这种方式,说一下这种方式的好处:

  1. 这种方式直接将数据封装在对象内部,而不是直接暴露,同时,也无法去修改属性值。
  2. 和 Solid 结合,这样,每次调用 name() 的时候,都能拿到最新的值。
  3. 延迟计算,只有在访问的时候才会执行。

原因

根据之前文章(响应式原理)那一篇,我们能得知,保持响应式的关键就是这个 name()age() 这两个 Signal,它们内部执行会进行依赖收集操作。

那如果把它进行结构,就会直接获取到 name() 的值,后续时候就会丢失 readSignal 的执行,只是单纯的一个值。

const a = props.name

props.name -> name()
a -> 'JinSo' // 导致响应式丢失

这也是,为什么不建议拆分 props 进行使用的原因。

官方对此也有说明:

Props - SolidDocs

如果你想解构下来使用,可以通过以下方式:

const a = () => props.name

当然,这种方式,实际还是调用的 Signal,这在 Solid 里面有一个术语,叫 Derived Signal(派生 Signal)。

Derived signals - SolidDocs

特殊例子

再来看几个特殊的方式,如果我把传递 Signal 的方式改为传递函数呢?

export default function Props() {
  // ...

  return (
    <section class='text-gray-700 p-8'>
      <Child name={name} age={age} />
    </section>
  )
}

这样,你是可以在子组件中解构下来使用的,因为这时你传入的是一个函数了,不再是一个简单的 Signal 值。

再把子组件改成如下方式调用。

function Child(props: ChildProps) {
  const {name, age} = props

  return (
    <div>
      <div>name: {name()}</div>
      <div>age: {age()}</div>
    </div>
  )
}

实际上,也是生效的。

对照一开始那个案例来看,我们知道 props 实现响应式的本质还是 Signal 的处理。所以这里的 Child 并没有违背这个理念,只是换了种方式,而且是可行的。

甚至说,子组件不变都是生效的。

// 原
function Child(props: ChildProps) {
  return (
    <div>
      <div>name: {props.name}</div>
      <div>age: {props.age}</div>
    </div>
  )
}

这其实和 Vue 使用 ref 是一个道理,在 Vue 中的 template 使用 ref 并不需要手动添加 value 属性,因为 Vue 在编译的时候给你处理了。

同理,Solid 也会在编译的时候给 Signal 做处理。

可以回头看一下 Child 组件被编译的结果,会调用一个 insert 函数。

_$insert(_el$2, () => props.name, null);

来看一下这个函数内部的处理:

dom-expresssions/packages/dom-expresssions/src/client.js

export function insert(parent, accessor, marker, initial) {
  if (marker !== undefined && !initial) initial = [];
  if (typeof accessor !== "function") return insertExpression(parent, accessor, initial, marker);

	// 这里,对于 accessor是个函数的情况下,会手动做一层响应式处理
  effect(current => insertExpression(parent, accessor(), current, marker), initial);
}

对于函数的情况,Solid 内部会自动做一层响应式处理。

额外

我们知道 Solid 提供了两个方法,来管理/操作 props 的属性。

来看看这两个方法里面做了什么,能保持响应式不会丢失的。

mergeProps

以下面这个为例:

const [s, set] = createSignal(undefined)
const props = {
  get name() {
    return s();
  },
  get age() {
    return 26;
  }
}

const mergedProps = mergeProps({name: 'default'}, props)

我们来先来看一下关于属性合并相关的源码:

export function mergeProps<T extends unknown[]>(...sources: T): MergeProps<T> {
  // ...

  const sourcesMap: Record<string, any[]> = {};
  const defined: Record<string, PropertyDescriptor | undefined> = Object.create(null);
  //let someNonTargetKey = false;

  for (let i = sources.length - 1; i >= 0; i--) {
    const source = sources[i] as Record<string, any>;
    if (!source) continue;
    const sourceKeys = Object.getOwnPropertyNames(source);
    //someNonTargetKey = someNonTargetKey || (i !== 0 && !!sourceKeys.length);
    for (let i = sourceKeys.length - 1; i >= 0; i--) {
      const key = sourceKeys[i];
      if (key === "__proto__" || key === "constructor") continue;
      // props 中的 Signal 是包含 get 的属性描述符的
      const desc = Object.getOwnPropertyDescriptor(source, key)!;
      if (!defined[key]) {
        defined[key] = desc.get
          ? {
              enumerable: true,
              configurable: true,
              // 注意,这里将 desc.get 绑定到 source 上,这样在 resolveSources 的时候,就可以通过 source 访问到对应的 key 的值
              // 这里注意, sourceMap[key] 是数组,所以 resolveSources 的时候,会遍历 sourceMap[key],执行对应的 get 方法
              // 同时,因为是数组,所以如果定义了多个同名 key 的 props,会执行多次,会执行 Signal 的响应式和默认值处理。
              get: resolveSources.bind((sourcesMap[key] = [desc.get.bind(source)]))
            }
          : desc.value !== undefined
          ? desc
          : undefined;
      } else {
        // 这里正常是用来处理多个同名 key 的 props 的,比如:
        // const mergedProps = mergeProps({name: 'default'}, props)
        // 这里就会将 name 添加到 sourcesMap[name] 中
        const sources = sourcesMap[key];
        if (sources) {
          // 合并 props 走这里,因为存在不同的 desc.get
          if (desc.get) sources.push(desc.get.bind(source));
          // 合并 默认值 走这里,将 desc.value 添加到 source 上
          else if (desc.value !== undefined) sources.push(() => desc.value);
        }
      }
    }
  }
  // 最后做统一合并
  const target: Record<string, any> = {};
  const definedKeys = Object.keys(defined);
  for (let i = definedKeys.length - 1; i >= 0; i--) {
    const key = definedKeys[i],
      desc = defined[key];
    if (desc && desc.get) Object.defineProperty(target, key, desc);
    else target[key] = desc ? desc.value : undefined;
  }
  return target as any;
}

主要就是对参数内的对象进行合并,生成一个新的 target 对象。

同时,对内部特殊的属性(如props上的属性包含get),做特殊处理,同时每个 key 有一个独立的 sources,用于做多个同名 key 处理,包括合并多个 props、默认值等处理。

再看一下 resolveSources 做了什么:

function resolveSources(this: (() => any)[]) {
  for (let i = 0, length = this.length; i < length; ++i) {
	  // 注意这里的执行,对于 Signal 的话,会做响应式处理
    const v = this[i]();
    if (v !== undefined) return v;
  }
}

按上面的案例来说,这里的 this(sources) 数据为 [props.name, () ⇒ name(’default’)]

因为此时 props.nameundefined,所以会走默认值。

这里注意,props.name 也是执行的,即执行了 Signal,所以后面 name 更新的时候,这里的数据也会更新。

所以说,实际上 mergeProps 只是做了层代理,最终调用的还是 props 上的属性的 get 来实现的响应式。

splitProps

同理,来看个案例:

console.log(props) // {a: 1, b: 2, c: 3, d: 4, e: 5, foo: "bar"}
const [vowels, consonants, leftovers] = splitProps(
  props,
  ["a", "e"],
  ["b", "c", "d"]
)
console.log(vowels) // {a: 1, e: 5}
console.log(consonants) // {b: 2, c: 3, d: 4}
console.log(leftovers.foo) // bar

实现上和 mergeProps 的基本类似:

export function splitProps<
  T extends Record<any, any>,
  K extends [readonly (keyof T)[], ...(readonly (keyof T)[])[]]
>(props: T, ...keys: K): SplitProps<T, K> {
	// ...

  const otherObject: Record<string, any> = {};
  const objects: Record<string, any>[] = keys.map(() => ({}));

  for (const propName of Object.getOwnPropertyNames(props)) {
    const desc = Object.getOwnPropertyDescriptor(props, propName)!;
    const isDefaultDesc =
      !desc.get && !desc.set && desc.enumerable && desc.writable && desc.configurable;
    // 利用 keys 进行划分,将 propName 划分到对应的 objects 中
    let blocked = false;
    let objectIndex = 0;
    for (const k of keys) {
      if (k.includes(propName)) {
        blocked = true;
        isDefaultDesc
          ? (objects[objectIndex][propName] = desc.value)
          : Object.defineProperty(objects[objectIndex], propName, desc);
      }
      ++objectIndex;
    }
    if (!blocked) {
      isDefaultDesc
        ? (otherObject[propName] = desc.value)
        : Object.defineProperty(otherObject, propName, desc);
    }
  }
  return [...objects, otherObject] as any;
}

根据 keysprops 进行拆分,划分到不同的 object 返回即可。

回到原文,我们已经知道 props 能保持响应式的原理是什么了,以及为什么会导致其响应式丢失的问题;实际上本质还是 Signal 去实现的。