在前面的文章中,我们学习了 Solid 的响应式原理,深入了了解其实现方式。
这篇文章将主要深入解析组件内部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,可以进行尝试:
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
对象,重点关注一下它创建对象的这种方式,说一下这种方式的好处:
- 这种方式直接将数据封装在对象内部,而不是直接暴露,同时,也无法去修改属性值。
- 和 Solid 结合,这样,每次调用
name()
的时候,都能拿到最新的值。 - 延迟计算,只有在访问的时候才会执行。
原因
根据之前文章(响应式原理)那一篇,我们能得知,保持响应式的关键就是这个 name()
、age()
这两个 Signal
,它们内部执行会进行依赖收集操作。
那如果把它进行结构,就会直接获取到 name()
的值,后续时候就会丢失 readSignal
的执行,只是单纯的一个值。
const a = props.name
props.name -> name()
a -> 'JinSo' // 导致响应式丢失
这也是,为什么不建议拆分 props
进行使用的原因。
官方对此也有说明:
如果你想解构下来使用,可以通过以下方式:
const a = () => props.name
当然,这种方式,实际还是调用的 Signal
,这在 Solid 里面有一个术语,叫 Derived Signal
(派生 Signal)。
特殊例子
再来看几个特殊的方式,如果我把传递 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.name
为 undefined
,所以会走默认值。
这里注意,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;
}
根据 keys
对 props
进行拆分,划分到不同的 object
返回即可。
回到原文,我们已经知道 props
能保持响应式的原理是什么了,以及为什么会导致其响应式丢失的问题;实际上本质还是 Signal
去实现的。