面包黄 發表於 2023-4-15 13:08:00

Angular 20+ 高阶教程 – 信号 (Signals)

<h2>前言</h2>
<p>Signals (Reactive Programming) 是在 Angular v16 (2023年5月) 被引入的,并在 v20 达到稳定 (stable) 阶段。</p>
<p>因此,从 v20 开始,Signals 就是主流了,这也是为什么我会把它放到教程的最前面几篇。</p>
<p>&nbsp;</p>
<h2>Signals 的前世 の KO.js<span style="text-decoration: line-through"><br></span></h2>
<p>Signals 不是 Angular 专属概念,许多前端框架/库都有 Signals,甚至未来&nbsp;TC39&nbsp;也可能会内置&nbsp;Signals&nbsp;(目前在 state 1)。</p>
<p>要想深入理解 Signals,我觉得最好的方式就是去"考古" -- 为什么 Signals 会诞生?它解决了什么问题?它如何演化至今?</p>
<h3>Knockout.js</h3>
<p>Signals 最早出现在 2010 年微软的 MVVM 框架&nbsp;Knockout.js&nbsp;(简称 KO)。</p>
<p>下面是一段 KO 的代码</p>
<p>HTML</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">body</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
<span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">h1 </span><span style="color: rgba(255, 0, 0, 1)">data-bind</span><span style="color: rgba(0, 0, 255, 1)">="text: firstName"</span><span style="color: rgba(0, 0, 255, 1)">&gt;&lt;/</span><span style="color: rgba(128, 0, 0, 1)">h1</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
<span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">body</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span></pre>
</div>
<p>Scripts</p>
<pre class="language-javascript highlighter-hljs"><code>import ko from 'knockout';

const viewModel = {
firstName: 'Derrick',
};

ko.applyBindings(viewModel);</code></pre>
<p>效果</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202505/641294-20250529192914925-2006653960.png"></p>
<p>MVVM 框架的中心思想是:Application Level 只负责定义 view,view model,以及它们之间的 binding 关系,而框架则负责实际的 DOM API 操作,完成渲染。</p>
<p>下面这句是 view model</p>
<pre class="language-javascript highlighter-hljs"><code>const viewModel = {
firstName: 'Derrick',
};</code></pre>
<p>这句是 view 和 binding</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">h1 </span><span style="color: rgba(255, 0, 0, 1)">data-bind</span><span style="color: rgba(0, 0, 255, 1)">="text: firstName"</span><span style="color: rgba(0, 0, 255, 1)">&gt;&lt;/</span><span style="color: rgba(128, 0, 0, 1)">h1</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span></pre>
</div>
<p>KO 则负责操作 DOM API,大概是这样</p>
<pre class="language-javascript highlighter-hljs"><code>h1.textContent = viewModel.firstName;</code></pre>
<h4>MVVM 框架的难题 -- 监听 view model 变更</h4>
<p>上面的代码已经可以成功渲染出 firstName 了,让我们加入一个难题 -- 三秒钟后修改 firstName</p>
<pre class="language-javascript highlighter-hljs"><code>const viewModel = {
firstName: 'Derrick',
};

ko.applyBindings(viewModel);

// 三秒后
window.setTimeout(() =&gt; {
viewModel.firstName = 'Richard'; // 把 firstName 从 'Derrick' 改成 'Richard'
}, 3000);</code></pre>
<p>效果</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202505/641294-20250529193843444-20199059.png"></p>
<p>等了三秒后,&lt;h1&gt; 仍然是 Derrick,Why🤔?</p>
<p>因为我们上面这个写法会让 KO 无法监听到 firstName 变更。</p>
<p>KO 不知道 firstName 变更了,自然不会去重新渲染,结果就是 &lt;h1&gt; 始终保持不变。</p>
<p>你可能会想,view model 是对象,KO 可以做一个 Proxy setter 去拦截 firstName 写入,这样不就能监听到 firstName 变更了吗?</p>
<p>没错,今时今日确实可以用 Proxy 实现,但 KO 是 2010 - 2012 年的框架,当时 JavaScript 还没有 Proxy 概念 (Proxy 是 ES6 于 2015 年才引入的)。</p>
<p>为了应对 "监听 view model 变更" 的难题,KO 引入了一个叫&nbsp;observable variable 的概念。</p>
<h3>ko.observable</h3>
<p>顾名思义,observable variable 就是 "可观测变量",这里的 "观测"&nbsp;指的就是监听变量的变更。</p>
<p>接下来,我们透过代码去了解它</p>
<pre class="language-javascript highlighter-hljs"><code>// non-observable variable
let firstName: string = 'Derrick';         

// try to observe variable change
firstName.onChange(newFirstName =&gt; console.log(newFirstName));

// change variable
firstName = 'Richard'; </code></pre>
<p>上面是一个普通的变量,由于 JavaScript 语言不支持监听 assignment operation,也没有 onChange 方法,所以上述代码完全无法实现预期效果。</p>
<p>下面是 KO observable variable 的写法 (对应上面的例子)</p>
<pre class="language-javascript highlighter-hljs"><code>// observable variable
const firstName: KnockoutObservable&lt;string&gt; = ko.observable('Derrick');

// observe variable change
firstName.subscribe(newFirstName =&gt; console.log(newFirstName));

// change variable
firstName('Richard'); </code></pre>
<p>ko.observable 返回的不是 string,而是一个混合体 (object + setter 函数)。</p>
<p>object 的部分:它有一个 subscribe 方法,可以用来监听变量的变更,像这样</p>
<pre class="language-javascript highlighter-hljs"><code>firstName.subscribe(newFirstName =&gt; console.log(newFirstName)); // 每当 firstName 变更,console.log 就会执行</code></pre>
<p>setter 的部分:我们不使用 assign operator 赋值 (因为 assign operator 无法监听和拦截),而是透过调用这个 setter,并传入要 assign 的 value,像这样</p>
<pre class="language-javascript"><code>firstName('Richard'); // 把 firstName 从 'Derrick' 改成 'Richard'</code></pre>
<p>好,我们把 view model 里的 firstName 改成 observable variable,再试试</p>
<pre class="language-javascript highlighter-hljs"><code>const viewModel = {
firstName: ko.observable('Derrick'), // 使用 observable variable
};

ko.applyBindings(viewModel);

// 三秒后
window.setTimeout(() =&gt; {
viewModel.firstName('Richard'); // 把 firstName 从 'Derrick' 改成 'Richard'
}, 3000);</code></pre>
<p>效果</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202505/641294-20250529232929196-1203856292.gif"></p>
<p>三秒后,&lt;h1&gt; 成功从 Derrick 变成了 Richard。</p>
<p>KO 操作 DOM API 大概是这样</p>
<pre class="language-javascript highlighter-hljs"><code>// 监听 firstName 变更
viewModel.firstName.subscribe(
// 每当 firstName 变更,更新 DOM
newFirstName =&gt; (h1.textContent = newFirstName)
);</code></pre>
<h4>ko.observable 与 RxJS 的渊源</h4>
<p>熟悉 RxJS 的朋友,第一眼看到 KO 可能会感到似曾相识。</p>
<p>下面是 KO 和 RxJS 的对比代码</p>
<pre class="language-javascript highlighter-hljs"><code>// 这是 KO
const firstName = ko.observable('Derrick');
firstName.subscribe(newFirstName =&gt; console.log(newFirstName));
firstName('Richard');

// 这是 RxJS
const lastName = new BehaviorSubject('Derrick');
lastName.subscribe(newLastName =&gt; console.log(newLastName));
lastName.next('Richard');</code></pre>
<p>是不是如出一辙?</p>
<p>RxJS 出自微软的 Rx (Reactive Extensions) 体系,而&nbsp;KO 的灵感也正巧来自 Rx。</p>
<p>可谓师出同门,难怪如此相识。</p>
<p>虽然如此,我们可千万别把 KO 和 RxJS 划上等号哦,因为它们只是部分 (而且是少部分) 相似而已。下面我们会看到它们的显著不同。</p>
<p>这里先提两个小区别:</p>
<ol>
<li>
<p>RxJS subscribe 后会立即出发第一次</p>
<p>调用 RxJS 的 lastName.subscribe 会立即触发第一次 (这是 BehaviorSubject&nbsp;的特性),而 KO 的&nbsp;firstName.subscribe 则会等到变量变更后才触发。</p>
<p>如果我们希望 RxJS 像 KO 那样,可以加一个 skip(1) operator,过滤掉第一次触发。</p>
<pre class="language-javascript highlighter-hljs"><code>lastName.pipe(skip(1)).subscribe(newLastName =&gt; console.log(newLastName));</code></pre>
</li>
<li>
<p>RxJS 每一次 next value 都会触发&nbsp;subscription callback</p>
<p>每一次调用 RxJS 的 lastName.next('Derrick') 都会触发 subscription callback,即便我们传入相同的值。</p>
而 KO 有一个判断,只有当 old value 和 new value 不相等的时候,subscription callback 才会触发。
<p>如果我们希望 RxJS 像 KO 那样,可以加一个 distinctUntilChanged operator,过滤掉相同值触发。</p>
<pre class="language-javascript highlighter-hljs"><code>lastName.pipe(skip(1), distinctUntilChanged()).subscribe(newLastName =&gt; console.log(newLastName));</code></pre>
</li>
</ol>
<h3>ko.computed</h3>
<p>KO 的目标是让所有变量都成为 observable variable,上一 part 我们看了 ko.observable 的例子,它把一个普通变量变成了 observable variable。</p>
<p>这一 part,我们来看一个 "不那么普通" 的变量 -- computed variable。</p>
<p>computed variable 指的是一个变量,它的值不储存在它自身,而是透过计算其依赖的变量得来。</p>
<p>一个经典的例子就是&nbsp;fullName =&nbsp; firstName + lastName。</p>
<p>我们通常用 getter 来实现,像这样:</p>
<pre class="language-javascript highlighter-hljs"><code>const viewModel = {
firstName: 'Derrick',
lastName: 'Yam',

get fullName() {
    return this.firstName + ' ' + this.lastName;
},
};</code></pre>
<p>但,getter 不是 observable 啊,怎么办呢?</p>
<h4>用 RxJS 实现 computed variable</h4>
<p>既然 KO 和 RxJS 师出同门,那我们先试试用 RxJS 来实现</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = new BehaviorSubject('Derrick');
const lastName = new BehaviorSubject('Yam');

const fullName = combineLatest().pipe(map(() =&gt; firstName + ' ' + lastName));

fullName.subscribe(fullName =&gt; console.log(fullName)); // observe fullName change</code></pre>
<p>用 combineLatest 监听依赖,map 作为 computation,这样 fullName 就变成 observable 了。</p>
<p>不过,它有一个缺失 -- 不能直接读取 value。</p>
<pre class="language-javascript highlighter-hljs"><code>console.log(firstName.value); // direct read firstName value
console.log(fullName.value);// Error: Property 'value' does not exist on type 'Observable&lt;string&gt;'</code></pre>
<p>因为只有 BehaviorSubject 才能直接读取 value,combineLatest 返回的是 Observable 只能 subscribe 而已。</p>
<p>我们可以用一些粗糙的手法来实现,比如</p>
<pre class="language-javascript highlighter-hljs"><code>function getObservableValue&lt;T&gt;(obs: Observable&lt;T&gt;): T {
let value: T = undefined!;
obs.pipe(take(1)).subscribe(v =&gt; (value = v));
return value;
}

const fullName = combineLatest().pipe(
map(() =&gt; firstName + ' ' + lastName),
shareReplay({ bufferSize: 1, refCount: false }),
);

console.log(getObservableValue(fullName)); // direct read fullName value</code></pre>
<p>或者</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = new BehaviorSubject('Derrick');
const lastName = new BehaviorSubject('Yam');

const fullName = new BehaviorSubject&lt;string&gt;(undefined!);
combineLatest().subscribe(() =&gt; fullName.next(firstName + ' ' + lastName));

console.log(fullName.value); // direct read fullName value</code></pre>
<p>老实说,这两个方式都不太优雅。</p>
<p>第一个就是乱。</p>
<p>第二个比较直观,但&nbsp;BehaviorSubject 不是 readonly,作为 computed variable 有点反直觉,毕竟&nbsp;database 的 computed column 和 Excel 的 formula column 这些都是 readonly。</p>
<p>而且,这两个实现方式的代码都非常繁琐。</p>
<p>无论选哪一个,我们都需要做上层封装。</p>
<p>好,我们试试封装它,假设我们选第二个方式来做封装</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202505/641294-20250530214618260-1249932893.png"></p>
<p>这三句,红线是动态的部分,需要用参数传进来,其余的部分封装进函数里。</p>
<pre class="language-javascript highlighter-hljs"><code>type UnwrapObservable&lt;T&gt; = T extends Observable&lt;infer U&gt; ? U : never;
type UnwrapObservables&lt;T extends readonly Observable&lt;unknown&gt;[]&gt; = {
: UnwrapObservable&lt;T&gt;;
};
interface ObservableComputedVariable&lt;TValue&gt; {
readonly value: TValue;
subscribe: Observable&lt;TValue&gt;['subscribe'];
}

function computed&lt;TValue, TDependentVariables extends readonly Observable&lt;unknown&gt;[]&gt;(
dependentVariables: TDependentVariables,
computation: (...args: UnwrapObservables&lt;TDependentVariables&gt;) =&gt; TValue,
): ObservableComputedVariable&lt;TValue&gt; {
const computedVariable = new BehaviorSubject&lt;TValue&gt;(undefined!);

combineLatest(dependentVariables).subscribe(values =&gt; {
    computedVariable.next(computation(...(values as UnwrapObservables&lt;TDependentVariables&gt;)));
});

return computedVariable;
}</code></pre>
<p>调用方式</p>
<pre class="language-javascript highlighter-hljs"><code>const fullName = computed(, (firstName, lastName) =&gt; firstName + ' ' + lastName);
console.log(fullName.value); // direct read fullName value
fullName.subscribe(newFullName =&gt; console.log(newFullName)); // observe fullName change</code></pre>
<p>撇开性能和调用便捷性不谈,我们算是勉强实现了一个 observable computed variable。</p>
<h4>用 KO 实现 computed variable</h4>
<p>直接上代码</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = ko.observable('Derrick');
const lastName = ko.observable('Yam');
const fullName = ko.computed(() =&gt; firstName() + ' ' + lastName());

console.log(fullName()); // read fullName value
fullName.subscribe(newFullName =&gt; console.log(newFullName)); // observe fullName change</code></pre>
<p>KO 和 RxJS 在 computed variable 的实现上有着很大的区别,我们一个一个来看。</p>
<pre class="language-javascript highlighter-hljs"><code>// 这是 RxJS
const fullName = computed(, (firstName, lastName) =&gt; firstName + ' ' + lastName);

// 这是 KO
const fullName = ko.computed(() =&gt; firstName() + ' ' + lastName());</code></pre>
<p>有两个地方很不一样的:</p>
<ol>
<li>KO 的 firstName 和 lastName 是 getter 函数<br>
<p>上一 part 我们有提到过,ko.observable 返回的是一个混合体,它是 object + setter。</p>
<p>其实不仅如此,它也是一个 getter。</p>
<p>当我们调用它时,如果有传入参数,它就作为 setter;如果没有传参数,它就作为 getter。</p>
<p>我们知道,KO 把 variable 做成 setter 是为了拦截写入,从而触发 subscription callback;那做成 getter 又是为了什么呢?RxJS 可没有 getter 啊 🤔</p>




</li>
<li>RxJS 必须显式声明出 computation 里所有的依赖 (如 firstName 和 lastName),而 KO 则不需要。<br>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250601134511930-822844837.png"></p>
<p>这是因为 KO 实现了一套自动依赖收集机制。</p>
<p>当调用 ko.computed 时,computation 会立即执行一次。</p>
<p>由于 firstName 和 lastName 是 getter,KO 可以进行拦截,并将它们收集为 fullName 的依赖。</p>
<p>每当这些依赖变更,computation 就会重新执行。(解答上题:这就是为什么 KO 要把 variable 做成 getter 的原因,它要拦截 getter 做依赖收集)</p>
<p>这套机制有两个好处:</p>
<p>第一个是提升调用便捷性。</p>
<pre class="language-javascript highlighter-hljs"><code>// RxJS 的写法不仅冗长,而且需要声明依赖,超麻烦
const fullName = computed(, (firstName, lastName) =&gt; firstName + ' ' + lastName);

// 反观 KO 简洁干净
const fullName = ko.computed(() =&gt; firstName() + ' ' + lastName());

// ko.computed 几乎等价于我们写 getter 了
get fullName() {
return this.firstName + ' ' + this.lastName;
}</code></pre>
<p>第二个是提升性能</p>
<p>computed 会监听它的所有依赖,只要其中任一发生变更,就会重新执行 computation。</p>
<p>RxJS 的依赖是在声明时一次性写死的,所有可能在 computation 中用到的依赖都必须预先列出。</p>
<p>KO 的依赖则是在执行 computation 时动态收集的,例如:</p>
<pre class="language-javascript highlighter-hljs"><code>const fullName = ko.computed(() =&gt; (status() === 'completed' ? firstName() : lastName()));</code></pre>
<p>当 status 是 'completed' 时,fullName 的依赖只有 firstName;反之,依赖变成只有 lastName。</p>
<p>固定依赖 (RxJS) 必须监听所有依赖,而动态依赖 (KO) 则只需监听当前真正用到的依赖。</p>
<p>监听所有依赖可能会引发不必要的 computation,而动态依赖则可以避免这种不必要的 computation,因此 KO 的实现方式在性能上通常优于 RxJS。</p>
</li>
</ol>
<h3>ko.computed&nbsp;の 特性详解</h3>
<p>为什么要讲得这么细?因为这涉及到 Signals 概念在后 KO 时期的演化。</p>
<h4>ko.computed 都做了些什么?</h4>
<pre class="language-javascript highlighter-hljs"><code>const fullName = ko.computed(() =&gt; firstName() + ' ' + lastName());</code></pre>
<p>在调用 ko.computed 后,传入的 computation 会立即执行一次。</p>
<p>执行过程中,会自动收集依赖,监听它们的变更。</p>
<p>computation 的返回值会被缓存起来,供 getter 使用。</p>
<p>当依赖变更,computation 会重新执行,依赖也会重新收集,缓存的值也会更新,同时还会触发 subscription callback (如果这个 computed variable 有被 subscribe 的话)。</p>
<p>除了自动依赖收集以外,整体的逻辑和 RxJS 实现的 computed variable 大同小异。</p>
<h4>用 ko.computed 实现 multiple subscribe for side effect</h4>
<p>ko.observable 只能 subscribe 一个 variable</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = ko.observable('Derrick');
firstName.subscribe(newFirstName =&gt; console.log(newFirstName));</code></pre>
<p>如果我们想同时 subscribe multiple variables 做点 side effect,该怎么办?</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = ko.observable('Derrick');
const lastName = ko.observable('Yam');
// 想同时监听 firstName 和 lastName
.subscribe(() =&gt; {}) // Error!! array 没有 subscribe 方法</code></pre>
<p>我们看看 RxJS 是怎么做的</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = new BehaviorSubject('Derrick');
const lastName = new BehaviorSubject('Yam');

combineLatest().subscribe(() =&gt; console.log()); // ['Derrick', 'Yam']</code></pre>
<p>把要监听的 variables 一股脑传给 combineLatest 函数,然后再 subscribe 就行了。</p>
<p>KO 虽然没有 combineLatest,但 ko.computed 的行为和 combineLatest 非常相近,所以我们可以借助 ko.computed 来实现。</p>
<pre class="language-javascript highlighter-hljs"><code>ko.computed(() =&gt; console.log()); // ['Derrick', 'Yam']</code></pre>
<p>依据上一 part 我们对 ko.computed 的行为理解</p>
<ol>
<li>
<p>ko.computed 会立即执行 computation,此时 console.log 会被调用</p>
</li>
<li>
<p>与此同时,firstName 和 lastName 会被 subscribe (因为自动依赖收集)</p>
</li>
<li>
<p>每当 firstName 或 lastName 变更,computation (也就是 console.log) 会重新执行。</p>
</li>
</ol>
<p>注意,computation 不需要有返回值,ko.computed 的返回值也不需要存入 variable,因为我们的目的是 multiple subscribe for side effect,而不是为了要得到一个 observable computed variable。</p>
<h4>ko.computed 用作&nbsp;observable computed variable 的不足</h4>
<p>看到这里,我相信大家开始有点混乱了:ko.computed 既能创建 observable computed variable,又能用来实现 multiple subscribe side effect,那它到底是一箭双雕,还是两头不到岸呢?</p>
<p>ko.computed 用作 observable computed variable,若与 JavaScript 对象的 getter 相比较,有几个特点:</p>
<ol>
<li>
<p>push-based vs pull-based</p>
<p>JS 的 getter 是 pull-based,意思是,只有在读取 computed variable 的时候,computation 才会被执行。</p>
<p>而 KO 的 computed variable 则是 push-based。</p>
<p>即使我们没有读取 computed variable,它的 computation 也会被执行 -- 第一次的立即执行,以及之后每一次依赖变更时也都会执行。</p>
</li>
<li>
<p>cacheable</p>
<p>JS 的 getter 没有缓存能力,每次读取 computed variable,都会执行 computation。</p>
<p>而 KO 的 computed variable 是带缓存的。每次读取都是返回缓存值,而缓存会在依赖变更时被更新。</p>
</li>
</ol>
<p>理想中的 computed variable 应同时具备 pull-based、cacheable、observable 以及自动依赖收集 -- 唯有聚合这些要素,才能做到最高效,且最便捷。</p>
<p>我们看看它们是否达标:</p>
<ol>
<li>
<p>JS 的 getter 是 pull-based,但不是 cacheable 和 observable,因此不达标。</p>
</li>
<li>
<p>RxJS 具备 cacheable 和 observable,但属于 push-based,且不支持自动依赖收集,因此也不达标。</p>
</li>
<li>
<p>KO 具备 cacheable、observable 和自动依赖收集,但依然是 push-based,因此仍不达标。</p>
</li>
</ol>
<p>KO 是三者中表现最好的,但可惜仍然不达标,这也为后 KO 时代 Signals 的演化埋下了伏笔。</p>
<h4>题外话:ko.pureComputed</h4>
<p>KO 在 v3.2 (Aug 2014) 推出了 ko.pureComputed,它是 pull-based,所以达标了。</p>
<p>但 2014 年已经是后 KO 时期了,而且这个灵感好像是借鉴自 Vue,所以我不把它看作是 Signals 的前世。</p>
<p>至于 pull-based 和 push-based 的 computed 有什么区别,我们留到下一 part -- Signals 的今生,再深入探究。</p>
<h4>ko.computed 用作&nbsp;multiple subscribe side effect 的别扭</h4>
<p>ko.computed 用作 multiple subscribe side effect 与 RxJS 的 combineLatest 大同小异。</p>
<p>它们最大的区别在于:RxJS 是指定要监听的依赖,而 KO 是自动监听依赖。</p>
<p>自动是一把双刃剑,虽然方便,但有时也可能不够灵活。</p>
<p>来看一个 RxJS 灵活的例子</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = new BehaviorSubject('Derrick');
const lastName = new BehaviorSubject('Yam');
const status = new BehaviorSubject('completed');

// 指定监听 firstName 和 lastName 而已
combineLatest().subscribe(() =&gt; {
// 但 side effect 里也使用到了没被监听的 status
console.log();
});</code></pre>
<p>我们只监听 firstName 和 lastName,但在 side effect 里却也使用到了没被监听的 status。</p>
<p>再来看看 ko.computed 的相同例子</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = ko.observable('Derrick');
const lastName = ko.observable('Yam');
const status = ko.observable('completed');

ko.computed(() =&gt; console.log());</code></pre>
<p>KO 会自动监听依赖,像上面的 firstName,lastName,status 都会被监听,但这并不是我们想要的。</p>
<p>解决方法是使用 peek&nbsp;</p>
<pre class="language-javascript highlighter-hljs"><code>ko.computed(() =&gt; console.log());</code></pre>
<p>status() 会导致依赖被收集,而 status.peek() 同样是读取 value,但它不会被收集为依赖。</p>
<p>还有一种写法是这样</p>
<pre class="language-javascript highlighter-hljs"><code>ko.computed(() =&gt; {
// 把要监听的依赖声明在顶部
firstName();
lastName();

// 把 side effect wrap 一层 ignoreDependencies
ko.ignoreDependencies(() =&gt; console.log(firstName(), lastName(), status()));
});</code></pre>
<p>把要监听的依赖放到最顶部,具体的 side effect 则用 ignoreDependencies wrap 起来。</p>
<p>顾名思义,ignoreDependencies 内的代码不会被自动收集为依赖。</p>
<p>这种写法等同于 RxJS 的 combineLatest,只不过这种表达方式太不直观了,谁能理解在顶部调用 firstName() 是为了让它被收集为依赖,具体的 side effect 代码又要多包一层,总之就是非常别扭的写法。</p>
<h4>ko.computed 总结</h4>
<p>显然,ko.computed 并不是一箭双雕,而是两头不到岸。</p>
<p>无论是用作&nbsp;observable computed variable,还是用作 multiple subscribe side effect,都存在一些不足的地方。</p>
<p>这也正是后 KO 时代,Signals 要改进的方向。</p>
<h3>KO 总结</h3>
<p>KO 作为 MVVM 框架,面对的难题是:如何能监听到 view model 的变更?</p>
<p>KO 的想法是实现一套 observable variable 机制,让所有变量都能被监听。</p>
<p>透过 ko.observable、ko.computed、getter、setter、自动依赖收集等机制,KO 成功让所有的 variable 都变成 observable variable。</p>
<p>虽然 KO 的设计思想非常前沿,但放到今天来看,仍能发现不少不足和混入之处。</p>
<p>比如,ko.computed 用作 observable computed variable 时,是 push-based,性能并非最优。</p>
<p>另外,ko.computed 用作 multiple subscribe side effect 时,由于自动依赖收集的特性,它并不适合所有场景,有时还不如 RxJS 来得直观。</p>
<p>不过,无论如何,作为 2010 到 2012 年的框架,KO 拥有如此高的先见之明已经非常难得了。它的不足之处,就留待后 KO 时代的框架去完善吧。</p>
<p>&nbsp;</p>
<h2>Signals 的今生 の&nbsp;SolidJS</h2>
<p data-start="52" data-end="113">2012 年以后,KO 逐渐淡出前端视野,但 observable、computed、自动依赖收集等核心概念并未随之消失。</p>
<p data-start="115" data-end="162">这些思想被 RxJS、Vue、MobX、SolidJS 等框架继承,并在实践中不断演化和改进。</p>
<p data-start="164" data-end="215">其中又以 SolidJS 最为突出。这里我将以它为例,带大家一起看看今时今日的 Signals 及其演化。</p>
<h3 data-start="164" data-end="215">Observable variable --&nbsp;createSignal</h3>
<p>这是 KO 的 declare, read, write observable variable</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = ko.observable('Derrick'); // declare variable
console.log(firstName()); // read variable
firstName('Richard');   // write variable</code></pre>
<p>这是 SolidJS 的 declare, read, write observable variable</p>
<pre class="language-javascript highlighter-hljs"><code>const = createSignal('Derrick'); // declare variable
console.log(getFirstName()); // read variable
setFirstName('Richard');   // write variable</code></pre>
<p data-start="59" data-end="97">KO 返回的是一个混合体:object + getter + setter。</p>
<p data-start="99" data-end="135">SolidJS 返回的是 Tuple:。</p>
<p>SolidJS 的 getter、setter 和 KO 的 getter、setter 用法大同小异。</p>
<p>至于是返回一个混合体好,还是拆分成两个函数好,我觉得各有所长。比如说:</p>
<p>getter、setter 是函数,用&nbsp;getFirstName、setFirstName 来命名会比较规范(函数使用动词),而混合体就无法使用动词命名。</p>
<p>另外,getter、setter 拆开后,在传递时可以只传其中一个,比如只允许 getter,那我就只传 getter;混合体则只能整体传递。</p>
<p>当然,如果你想要一次性传递 getter 和 setter,那混合体就更方便,只需传一个变量,而拆分的形式就需要多传一个。</p>
<p>SolidJS 少了 KO 的 object,这意味着它没有&nbsp;firstName.peek()、firstName.subscribe()&nbsp;等功能。</p>
<p>不过这并不要紧,因为这些功能可以通过其他方式实现。</p>
<ul>
<li>firstName.subscribe 可以用 ko.computed 替代 (ko.computed 可以 subscribe multipl 自然也可以用作 subscribe single)。</li>
<li>
<p>firstName.peek 可以用 ko.ignoreDependencies 替代。</p>
</li>
</ul>
<p>所以,只要 SolidJS 有实现 ko.computed 和 ko.ignoreDependencies,那就没问题了。</p>
<h3>Observable computed variable -- createMemo</h3>
<p>这是 KO 的 observable computed variable</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = ko.observable('Derrick');
const lastName = ko.observable('Yam');

const fullName = ko.computed(() =&gt; firstName() + ' ' + lastName());

console.log(fullName()); // 'Derrick Yam'</code></pre>
<p>这是 SolidJS 的 observable computed variable</p>
<pre class="language-javascript highlighter-hljs"><code>const = createSignal('Derrick');
const = createSignal('Yam');

const getFullName = createMemo(() =&gt; getFirstName() + ' ' + getLastName());

console.log(getFullName()); // 'Derrick Yam'</code></pre>
<p>上一 part 我提到过 -- ko.computed 在用作 observable computed variable 时存在一些不足。</p>
<p>observable&nbsp;computed variable 应该具备 4 个要件:pull-based、cacheable、observable 以及自动依赖收集。</p>
<p>而 ko.computed 只满足了其中三个,因为它是 push-based,而不是 pull-based。</p>
<p>在这一点上,SolidJS 做了补强,它同时具备这 4 个要件:pull-based、cacheable、observable,以及自动依赖收集。</p>
<p>push-based:调用 computed 后,computation 会立即执行,每一次依赖变更,computation 都会执行。</p>
<p>pull-based:调用 computed 后,computation 不会立刻执行,只有在 computed variable 被读取时,computation 才会执行。</p>
<p>pull-based 的优势在于,它能最大程度地减少不必要的 computation 执行。</p>
<p>注:SolidJS 采用 pull-based 意味着它无法像 ko.computed 那样用作 multiple subscribe for side effect,但这并不要紧,SolidJS 有替代方案。</p>
<h3>Multiple subscribe for side effect -- createEffect &amp; createComputed</h3>
<p>ko.computed 除了可以用作 observable computed variable 还可以用作 multiple subscribe for side effect。</p>
<p>没错,两个目的混在一起实现,最终就是两头不到岸。</p>
<p>因此 SolidJS 把这两个目的拆分实现:</p>
<ul>
<li>
<p>observable computed variable 使用 createMemo 实现。</p>
</li>
<li>
<p>multiple subscribe for side effect 则使用&nbsp;createEffect 实现。</p>
</li>
</ul>
<p>这是 KO multiple subscribe for side effect</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = ko.observable('Derrick');
const lastName = ko.observable('Yam');

ko.computed(() =&gt; console.log()); // ['Derrick', 'Yam']</code></pre>
<p>这是 SolidJS multiple subscribe for side effect</p>
<pre class="language-javascript highlighter-hljs"><code>const = createSignal('Derrick');
const = createSignal('Yam');

createEffect(() =&gt; console.log());</code></pre>
<p>SolidJS 也支持 observable.peek 和 ko.ignoreDependencies</p>
<pre class="language-javascript highlighter-hljs"><code>// observable.peek
createEffect(() =&gt; {
// 用 untrack 把 status wrap 起来,这样读取 status 时就不会被依赖收集
console.log(getFirstName(), getLastName(), untrack(() =&gt; getStatus()))
});

// ko.ignoreDependencies
createEffect(() =&gt; {
// 把要监听的依赖声明在顶部
getFirstName();
getLastName();

// 把 side effect wrap 一层 untrack, 里面都不会被依赖收集
untrack(() =&gt; console.log(getFirstName(), getLastName(), getStatus()));
});</code></pre>
<p>SolidJS 还有一个叫&nbsp;createRenderEffect 的函数,它和 createEffect 的区别是:</p>
<ul>
<li>
<p>createRenderEffect 用于那些涉及 DOM 操作的 side effect</p>
</li>
<li>
<p>createEffect 用于不涉及 DOM 操作的 side effect</p>
</li>
</ul>
<p>SolidJS 是框架,它对渲染有精细的 timings 控制,因此它的 side effect 分的很细。</p>
<p>除此之外,SolidJS 其实还有一个叫 createComputed 的函数。</p>
<p>它的作用是让我们同步 Signals 之间的逻辑值。</p>
<pre class="language-javascript highlighter-hljs"><code>createComputed(() =&gt; setFullName(getFirstName() + ' ' + getLastName()));</code></pre>
<p>我们可以把它当成 push-based 版的 computed variable。</p>
<p>通常 createMemo 可以替代 createComputed,但有时候遇到复杂的情况,用 createComputed 在表达上会更直观。</p>
<p>好,来理一理:</p>
<ul>
<li>
<p>createMemo 是 for computed 而不是 side effect,它是 pull-based。</p>
</li>
<li>
<p>createEffect 是 for side effect (非 DOM 操作),它是 push-based。</p>
</li>
<li>
<p>createRenderEffect 是 for side effect (DOM 操作),它是 push-based。</p>
</li>
<li>
<p>createComputed 是 for 半 computed 半 side effect (同步 Signals 逻辑值),它是 push-based。</p>
</li>
</ul>
<h3>SolidJS 总结</h3>
<p>SolidJS 继承了 KO 的 getter setter、computed、side effect、自动依赖收集等核心概念,并改进了它们,比如:</p>
<ul>
<li>
<p>把 ko.computed 拆分成 createMemo (还改成了 pull-based 优化了性能) 和 createEffect。</p>
</li>
<li>
<p>在 side effect 的部分又细分成 createEffect、createComputed、reateRenderEffect 不同的执行时机。</p>
</li>
</ul>
<p>这些演化奠定了现代 Signals 的最终样态,Angular Signals 也大量借鉴了 SolidJS。</p>
<p>&nbsp;</p>
<h2>Angular 与 Signals 的关系</h2>
<p>Angular 团队一直到 v16 (2023年5月) 才引入 Signals (Reactive Programming),这比其它框架晚了数年。</p>
<p>为什么号称 "在三年后等你" 的 Angular,反而在 Signals 上落后如此之久?</p>
<p>追溯历史,AngularJS&nbsp;(Angular 前身) 和 KO 作为第一代 MVVM 框架,都面临着相同的难题 -- 如何监听 view model 的变更,但它们却采用了截然不同的解决方案。</p>
<p>KO 选择直面问题,设计出 observable variable 概念,使所有变量 (view model) 都具备可监听能力。</p>
<p>AngularJS 则回避问题,选择去监听导致 view model 变更的事件 (click, ajax call, setTimeout 等等),再透过 dirty checking 的方式去推测 view model 是否发生变更。</p>
<p>到了 Angular 时期,团队甚至进一步发明了 Zone.js,以&nbsp;monkey-patching 的方式更执着的去监听导致 view model 变更的事件,继续沿用 "监听事件 + 全面检测" 这一套思路。</p>
<p>那为什么 Angular 要绕这么大一圈?为什么不像 KO 那样,直接采用 observable variable 呢?</p>
<h3>Observable variable 的代价</h3>
<p>受 JavaScript 语法限制,要实现&nbsp;observable variable 就必须使用 getter setter,或者像 RxJS、Vue3 那样对变量进行一层 object wrapping。</p>
<p>无论是 getter setter 还是 object wrapping 都会对代码造成一定程度的侵入性。</p>
<p>比如</p>
<pre class="language-javascript highlighter-hljs"><code>// before
const value = 0;      // declare variable
console.log(value);   // read variable
value = 1;            // assign value to variable
value++               // other assign operator

// after
const value = declare(0);
console.log(value());
value(1);
value(value()++);</code></pre>
<p>它有以下几个问题:</p>
<ol>
<li>
<p>读取 value 时容易忘了放括弧。</p>
</li>
<li>
<p>无法使用 operator,比如 ++</p>
</li>
<li>代码可读性变差 (这一点尤其重要)</li>
</ol>
<p>这也是当初 Angular 执意不走 observable variable 这条路的原因。</p>
<h3>悬崖勒马</h3>
<p>然而 Angular 团队没料到的是,前端开发人员其实并不怎么排斥这些写法,尤其是在 React 推出 useState hook 之后。</p>
<p>随后,Vue 3 和 SolidJS 对 observable variable 进行了完善,Signals 逐渐成为主流趋势。</p>
<p>而此时 Angular 却在另一条路上越走越窄,最终团队不得不悬崖勒马,回头拥抱 Signals。</p>
<p>至此,几乎所有主流前端框架都拥抱了 Signals。而在不久的将来,TC39 也极有可能将 Signals 纳入 JavaScript 标准,这无疑是 MVVM 框架发展史上的重要里程碑。</p>
<h3>题外话 の&nbsp;与众不同的 Svelte 5</h3>
<p>Svelte 5 的 Signals 应该是所有框架/库里最符合直觉的。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202402/641294-20240204102347599-148849645.png"></p>
<p>只需要在最源头做 declaration 就可以了,count 不会变成恶心的 getter setter,它依然以 variable 的方式使用,但背后其实是 getter setter 的功能。</p>
<p>显然,Svelte 又在 compile 阶段加了很多黑魔法让其工作,不过我认为,让代码符合直觉是非常重要的,getter setter 本质上就是一种妥协。</p>
<p>那同样爱搞黑魔法、也有 compiler 的 Angular,它的 Signals 实现方式会和 Svelte 一样吗?</p>
<p>不!Sub-RFC 2: Signal APIs</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202402/641294-20240204191326012-428139936.png"></p>
<p>这段指出,Svelte 的黑魔法无法实现统一语法,在跨组件共享 Signals 的时候写法需要不一致。</p>
<p>而 Angular 认为代码一致性很重要,所以最后没有选择 Svelte 的实现方式。</p>
<h3>题外话 の 各家 Signals 的性能</h3>
<p>benchmark performance 看这里。</p>
<p>目前性能最快的是&nbsp;Alien Signals&nbsp;(应该是 Vue 派系的),性能远超 Angular Signals。</p>
<p>Angular Signals 的性能表现一直很差 (不意外,Angular 向来以肿和慢闻名),之前甚至还有个专门的&nbsp;Issue&nbsp;–&nbsp;Improve angular signals benchmark performance。</p>
<p>虽然官方声称在 v20 中做了大幅优化,但与其他框架/库相比,依然偏慢🙄。</p>
<p>&nbsp;</p>
<h2>Angular Signals 介绍</h2>
<p>Angular Signals 大量借鉴了 SolidJS。</p>
<p>getter setter,computed,effect,自动依赖收集,这些概念通通都有。</p>
<p>Angular Signals 不依赖 compiler,且基本上是一个独立的库,可单独使用。</p>
<p>它和上一篇的&nbsp;Dependency Injection&nbsp;(DI)&nbsp;有几分相似。</p>
<p>DI 不是 Angular 独有的概念,Angular 只是借鉴了它、扩展了它,并将其融入到 Angular 的方方面面。</p>
<p>我们在学习 DI 时,可以分两个阶段,先掌握 pure DI (把 DI 单独拎出来使用,脱离 Angular 整体框架),接着才是 Angular DI (在 Angular 框架内各个方面使用 DI)。</p>
<p>Signals 也是一样,它不是 Angular 独有概念,Angular 只是借鉴了它、扩展了它,并将其融入到 Angular 的方方面面。</p>
<p>我们在学习 Signals 时,同样分两个阶段,先掌握 pure Signals (把 Signals 单独拎出来使用,脱离 Angular 整体框架),接着才是 Angular Signals (在 Angular 框架内各个方面使用 Signals)。</p>
<p>本篇主要是教 pure Signals 的部分,而 Angular Signals 的部分会在后续章节中,随着不同主题逐步讲解。</p>
<p>&nbsp;</p>
<h2>Angular signal &amp; computed</h2>
<p>掌握了 Signals 的前世今生 (从 KO 演化到 SolidJS),再来看 Angular Signals 就简单多了。</p>
<p>我们直接上代码吧🚀</p>
<h3>signal 函数&nbsp;の declare, get, set, update</h3>
<p>main.ts</p>
<p>Signals 不依赖 Angular 整体框架,它可以单独拎出来使用,所以我们可以把 startup 相关的代码都注释掉。</p>
<pre class="language-javascript highlighter-hljs"><code>// import { bootstrapApplication } from '@angular/platform-browser';
// import { appConfig } from './app/app.config';
// import { App } from './app/app';

// bootstrapApplication(App, appConfig).catch((err) =&gt; console.error(err));</code></pre>
<p>接着</p>
<pre class="language-javascript highlighter-hljs"><code>// 1. import signal 函数
import { signal } from '@angular/core';

const value = signal(0); // 2. declare a Signal variable</code></pre>
<p>透过调用 signal&nbsp;函数来 declare 一个 variable,0 是初始值。</p>
<p>返回的是一个混合体&nbsp;(object + getter 函数)。</p>
<p>有点像 ko.observable</p>
<pre class="language-javascript highlighter-hljs"><code>const value = ko.observable(0); // 返回混合体 (object + getter + setter)</code></pre>
<p>读取 value 的方式是调用这个 getter 函数。</p>
<pre class="language-javascript highlighter-hljs"><code>const value = signal(0);
console.log(value()); // read value </code></pre>
<p>赋值是通过 set 方法。</p>
<pre class="language-javascript highlighter-hljs"><code>const value = signal(0);
value.set(5);</code></pre>
<p>这点和 ko.observable 不同,反而有点像 RxJS</p>
<pre class="language-javascript highlighter-hljs"><code>value.set(5); // 这是 Angular
value(5);   // 这是 KO
value.next(5) // 这是 RxJS</code></pre>
<p>累加是这样</p>
<pre class="language-javascript highlighter-hljs"><code>value.set(value() + 5);</code></pre>
<p>还有一个方式是用 update 方法</p>
<pre class="language-javascript highlighter-hljs"><code>value.update(currentValue =&gt; currentValue + 5);</code></pre>
<p>update 和 set 都可以用来修改 value, 它们的区别是 update 带有一个 current value 参数,方便我们做累加之类的操作。</p>
<p>update 的底层其实也是调用 set 来完成的。</p>
<p>相关源码在 signal.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202505/641294-20250526165958553-1821105862.png"></p>
<p>signalUpdateFn 函数内部也是调用 signalSetFn 函数</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202505/641294-20250526170228722-1112819296.png"></p>
<p>所以,update 算是一个语法糖吧。</p>
<h3>createSignalTuple 函数</h3>
<p>Angular 还有一个比较 internal 的 signal 函数 --&nbsp;createSignalTuple。</p>
<p>它的用法类似 SolidJS 的 createSignal</p>
<pre class="language-javascript highlighter-hljs"><code>import { createSignalTuple } from '@angular/core/primitives/signals';
const = createSignalTuple(0); // 这是 Angular
const = createSignal(0);                   // 这是 SolidJS</code></pre>
<p>它的好处是可以把 getter、setter 拆分传递。</p>
<p>不过 createSignalTuple 比较冷门,不推荐大家用。</p>
<h4>asReadonly 方法</h4>
<p>拆分 getter、setter 通常是为了传递 getter,不使用 createSignalTuple 我们还可以使用 asReadonly 方法。</p>
<pre class="language-javascript highlighter-hljs"><code>const person = signal({ id : 1, name: 'Derrick' });
const readonlyPerson = person.asReadonly();
readonlyPerson.set() // Error: Property 'set' does not exist</code></pre>
<p>asReadonly 会返回同一个对象,但在 TypeScript 类型上会隐藏 set, update 方法,这样就变成只读 (readonly) 了。</p>
<h3>computed 函数</h3>
<p>computed 函数用来创建 observable computed variable。</p>
<p>它和 SolidJS 的 createMemo 一样,满足 4 大要件:pull-based、cacheable、observable 以及自动依赖收集。</p>
<pre class="language-javascript highlighter-hljs"><code>import { computed, signal } from '@angular/core';

const firstName = signal('Derrick');
const lastName = signal('Yam');

// 1. 调用 computed 函数,传入 computation (注:computation 不会立即执行,因为是 pull-based)
const fullName = computed(() =&gt; firstName() + ' ' + lastName());

// 2. 调用 fullName getter
//    它会执行 computation 返回 'Derrick Yam' 并且把这个值缓存起来
console.log(fullName());

// 3. 再次调用 fullName getter
//    这一次不会执行 computation,而是直接返回缓存 'Derrick Yam'
console.log(fullName());

// 4. 修改 fullName 的依赖 -- firstName
//    这时不会执行 fullName 的 computation (因为是 pull-based)
firstName.set('Richard');

// 5. 再次调用 fullName getter
//    fullName 能判断出依赖 (firstName 和 lastName) 是否已经变更了(具体如何判断,下面逛源码的时候会讲解)
//    由于已经变更了,所以这一次会执行 computation 返回 'Richard Yam' 并且把这个值缓存起来
console.log(fullName());

// 6. 再次调用 fullName getter
//    这一次不会执行 computation,因为依赖都没有变更,所以直接返回缓存 'Richard Yam'
console.log(fullName());</code></pre>
<p>1. 因为是自动依赖收集,&nbsp;所以不需要像 RxJS 那样明确指定 computation 的依赖。</p>
<p>2. 因为是 pull-based,所以调用 computed 后不会立即执行 computation。</p>
<p>3. 因为是 pull-based,所以当依赖变更时也不会立即执行 computation。</p>
<p>4. 因为是 cacheable,所以连续调用 fullName getter,不会每一次都需要执行 computation。</p>
<p>总之,尽可能少的去执行 computation 就对了。</p>
<h4>computed 不支持异步</h4>
<pre class="language-javascript highlighter-hljs"><code>const url = signal('https://jsonplaceholder.typicode.com/users/1');

const name = computed(async () =&gt; {
const response = await fetch(url());
const { name } = await response.json() as { name: string };
return name;
});

console.log(name());</code></pre>
<p>效果&nbsp;</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250609225854702-504815472.png"></p>
<p>name() 返回的是 Promise...😂</p>
<p>这是因为 computed 不支持异步 -- computation 只能是同步代码。</p>
<p>KO 和 SolidJS 也是如此,不过 SolidJS 有一个 createResource 函数可以支持异步,而 Angular 也有对应的 resource 函数,这个下面会讲解。</p>
<p>&nbsp;</p>
<h2>逛一逛 Angular signal 和 computed 源码</h2>
<p>想要深入理解 Angular signal 和 computed,最好的方式就是逛源码。</p>
<h3>WritableSignal and Signal</h3>
<p>上面我们有提到,signal 函数返回的是一个混合体 (object + getter 函数)</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = signal('Derrick');
console.log(firstName());   // firstName 是一个 getter 函数
firstName.set('Richar');      // firstName 也是一个对象,它有 set, update 等方法</code></pre>
<p>它的类型是 WritableSignal interface,源码在 signal.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250604153148280-1837799926.png"></p>
<p>WritableSignal 继承自 type Signal (顾名思义,WritableSignal 是支持写入的 Signal,而 Signal 则只是 readonly)</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250604221437296-662099823.png"></p>
<p>type Signal 是一个 getter 函数,同时也是一个对象,也就是上面我们一直提到的混合体 (object + getter)</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250604155240906-1964009628.png"></p>
<p>另外,computed 函数返回的类型是 Signal 而不是 WritableSignal,因为 computed 是透过 computation 计算得出来的,它自然是 readonly 不能被写入。</p>
<p>源码在&nbsp;computed.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250604155506887-1309060866.png"></p>
<p>结论:</p>
<p>signal 返回 WritableSignal</p>
<p>computed 返回 Signal (readonly)</p>
<p>WritableSignal 继续自 Signal</p>
<p>所以,抽象来讲 signal 和 computed 都返回 Signal。</p>
<h3>SignalNode, ComputedNode and ReactiveNode</h3>
<p>signal 创建的 Signal 对象内部有一个隐藏对象叫 SignalNode。</p>
<p>我们可以用 SIGNAL symbol 从 Signal 对象里取出这个 SignalNode 对象。</p>
<pre class="language-javascript highlighter-hljs"><code>import { signal } from '@angular/core';
import { SIGNAL, type SignalNode } from '@angular/core/primitives/signals';

const firstName = signal('Derrick');

// 1. 用 SIGNAL symbol 获取隐藏的 SignalNode 对象
const firstNameSignalNode = firstName as SignalNode&lt;string&gt;;

console.log('firstNameSignalNode', firstNameSignalNode);</code></pre>
<p>这个 SignalNode 下面会深入讲解,它是 Angular Signals 的核心。</p>
<p>另外,computed 创建的 Signal 对象内部也有这个隐藏对象,不过它是 ComputedNode。</p>
<pre class="language-javascript highlighter-hljs"><code>const fullName = computed(() =&gt; firstName() + ' ' + lastName());
// 1. 一样用 SIGNAL symbol 获取隐藏的 ComputedNode 对象
const fullNameComputedNode = fullName as ComputedNode&lt;string&gt;;</code></pre>
<p>SignalNode 和 ComputedNode 有一点区别,但它们都继承自 ReactiveNode。</p>
<p>源码在 signal.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250604233112363-2129634071.png"></p>
<p>源码在 computed.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250604233116077-96683598.png"></p>
<p>结论:</p>
<p>signal 返回的 Signal 对象里有 SignalNode</p>
<p>computed 返回的 Signal 对象里有 ComputedNode</p>
<p>SignalNode 和 ComputedNode 都继承自 ReactiveNode</p>
<p>所以,抽象来讲 Signal 对象里有 ReactiveNode。</p>
<h3>Create a ReactiveNode</h3>
<p>SignalNode 和 ComputedNode 是 Angular 封装的上层接口,那我们能不能自己创建一个底层的 ReactiveNode?</p>
<p>当然可以!</p>
<p>我们参考一下 signal 函数,看看它是如何创建出 WritableSignal 和 SignalNode 的。</p>
<p>signal 函数的源码在&nbsp;signal.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250604192826747-421281132.png"></p>
<p>createSignal 函数的源码在&nbsp;signal.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250604192944943-2089527574.png"></p>
<p>SIGNAL_NODE 长这样</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250604193108357-833241889.png"></p>
<div>REACTIVE_NODE 长这样</div>
<div>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250604193136615-1216744321.png"></p>
<p>类型在&nbsp;graph.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250604193229319-688896681.png"></p>
<p>SignalNode 是透过 Object.create 创建出来的,也就是说&nbsp;SIGNAL_NODE 是&nbsp;SignalNode 的 prototype</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202404/641294-20240428233223076-1386005259.png"></p>
<p>非常古老的 new instance 手法。</p>
<p>ComputedNode 也是大同小异。</p>
<p>computed 函数的源码在 computed.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250604230450252-342242032.png"></p>
<p>createComputed 的源码在 computed.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250604230454609-480552716.png"></p>
<p>COMPUTED_NODE 长这样</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250604230614577-337365565.png"></p>
<p>好,那我们也像 signal / computed 函数那样创建一个 ReactiveNode 来看看</p>
<pre class="language-javascript highlighter-hljs"><code>import { REACTIVE_NODE, type ReactiveNode } from '@angular/core/primitives/signals';

const myReactiveNode = Object.create(REACTIVE_NODE) as ReactiveNode;</code></pre>
</div>
<div>
<p>ReactiveNode 是 Signals 的核心,许多底层功能都封装在里面 (下面会介绍)。</p>
<p>我们可以创建它,意味着可以扩展出类似 signal 和 computed 的功能,这对 Angular 重度使用者来说是很有帮助的👍。</p>
<h3>computed 的实现原理 の 依赖收集</h3>
</div>
<p>我们来试试推敲 computed 的实现原理 (留意 pull-based 和 cacheable 这两个特性对 computation 何时会被执行所产生的影响)</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = signal('Derrick');
const lastName = signal('Yam');

// 1. create fullName computed 不会立即执行 computation,因为是 pull-based
const fullName = computed(() =&gt; firstName() + ' ' + lastName());

fullName(); // 2. 调用 fullName getter 会执行 computation,因为这是第一次调用,完全没有缓存

fullName(); // 3. 再次调用 fullName getter 不会执行 computation 因为有缓存了

firstName.set('Richard'); // 4. 修改 firstName 不会立即执行 computation,因为是 pull-based

fullName(); // 5. 再次调用 fullName getter 会执行 computation,因为缓存失效了</code></pre>
<ol>
<li>
<p>创建 fullName computed,不会立即执行 computation。</p>
<p>这没问题,把 computation 存起来不跑就行了。</p>
</li>
<li>
<p>调用 fullName getter,由于这是第一次调用,没有任何缓存,所以会执行 computation。</p>
<p>这也没问题,执行 computation 并返回值即可。</p>
</li>
<li>
<p>再次调用 fullName getter,此时已经有缓存了,所以不会执行 computation。</p>
<p>这也没问题,第一次执行 computation 后把值缓存起来即可。</p>
</li>
<li>
<p>修改 firstName&nbsp;不会立即执行 computation。</p>
<p>这也没问题,不跑即可。</p>
</li>
<li>
<p>再次调用 fullName getter,判断缓存是否失效,若失效则重新执行 computation。</p>
</li>
</ol>
<p>这就有问题了 -- 我们要如何判断缓存是否失效?</p>
<p>computation 的逻辑本身是不会改变的,唯一可能改变的是它的依赖 (这个例子中是 firstName 和 lastName)。</p>
<p>也就是说,只要依赖没有变更,缓存就是有效的;反之,如果依赖变更了,缓存就该失效。</p>
<p>那我们有两件事要做</p>
<ol>
<li>
<p>收集出 computation 的依赖</p>
</li>
<li>
<p>判断这些依赖是否变更了</p>
</li>
</ol>
<p>好我们一步一步来,先看看如何收集依赖。</p>
<p>回顾这张图</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250605140847495-1741357393.png"></p>
<p>RxJS 需要明确表明依赖,而 KO、SolidJS、Angular 则是把依赖混在 computation 里。</p>
<p>也就是说,在执行 fullName computation&nbsp;</p>
<pre class="language-javascript highlighter-hljs"><code>() =&gt; firstName() + ' ' + lastName()</code></pre>
<p>的同时,我们需要收集到它的依赖 -- firstName 和 lastName。</p>
<h4>ReactiveNode &amp; Producer</h4>
<p>我们上面说过,ReactiveNode 是 Signals 的核心,许多底层功能都是由它来实现的,这里就来看看它是如何工作的</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = signal('Derrick');
const firstNameNode = firstName as SignalNode&lt;string&gt;; // firstName 的 ReactiveNode

const lastName = signal('Yam');
const lastNameNode = lastName as SignalNode&lt;string&gt;;   // lastName 的 ReactiveNode

// computed 是 pull-based,所以这里 computation 还不会执行
const fullName = computed(() =&gt; firstName() + ' ' + lastName());
const fullNameNode = fullName as ComputedNode&lt;string&gt;; // fullName 的 ReactiveNode

// 此时 fullNameNode.producerNode 还是 undefined (producerNode 是什么下面会讲解)
console.log(fullNameNode.producerNode === undefined);

// 调用 fullName() 会执行 computation
console.log(fullName());

// 在执行 computation 以后,fullNameNode.producerNode 就有东西了
console.log(
// 第一个 "东西" 是 firstName 的 ReactiveNode
fullNameNode.producerNode! === firstNameNode, // true
);
console.log(
// 第二个 "东西" 是 lastName 的 ReactiveNode
fullNameNode.producerNode! === lastNameNode, // true
);</code></pre>
<p>关键就在 fullName 的 ReactiveNode.producerNode。</p>
<p>producer 中文叫制作人,fullName 是由 firstName 和 lastName 联合创作出来的,所以 fullName 的制作人是 firstName 和 lastName (也就是上面我们一直在讲的 "依赖")。</p>
<p>producer (a.k.a 依赖) 并不是一开始就存在于 fullName 的 ReactiveNode.producerNode。</p>
<p>它是在执行 fullName 的 computation 以后才被记录进去的。(执行 computation = 开始收集依赖)</p>
<pre class="language-javascript highlighter-hljs"><code>// 此时 fullNameNode.producerNode 还是 undefined
console.log(fullNameNode.producerNode === undefined);

// 调用 fullName() 会执行 computation
console.log(fullName());

// 在执行 computation 以后,fullNameNode.producerNode 就收集到 producers 了</code></pre>
<p>也就是说,在执行下面这句代码后</p>
<pre class="language-javascript highlighter-hljs"><code>firstName() + ' ' + lastName()</code></pre>
<p>fullName 的 ReactiveNode.producerNode 就有了 firstNameReactiveNode 和 lastNameReactiveNode 两个 producers。</p>
<p>producerNode 的类型是 ReactiveNode Array</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250605010909915-1018015687.png"></p>
<p>显然,依赖收集的秘诀就藏在 firstName 和 lastName getter 函数里,</p>
<p>不然怎么会出现 producerNode 从 undefined &gt; 调用 computation &gt; 调用 firstName, lastName &gt; 变成 producerNode = 的过程。</p>
<p>在深入 getter 函数之前,我们先尝试自己创建 ReactiveNode,并完成一次依赖收集的过程,这样能更清楚 computed 底层到底做了些什么。</p>
<div>
<h4>替 ReactiveNode 收集 producer</h4>
<p>我们来模拟一下 computed 的依赖收集过程</p>
<pre class="language-javascript"><code>const firstName = signal('Derrick');
const firstNameNode = firstName as SignalNode&lt;string&gt;; // firstName 的 ReactiveNode

const lastName = signal('Yam');
const lastNameNode = lastName as SignalNode&lt;string&gt;; // lastName 的 ReactiveNode

// 创建 ReactiveNode 模拟 fullName ReactiveNode
const fullNameNode = Object.create(REACTIVE_NODE) as ReactiveNode;

// 把 ReactiveNode 设置成全局 Consumer (什么是 Consumer 下面会讲解)
setActiveConsumer(fullNameNode);

// 此时 fullNameNode.producerNode 还是 undefined
console.log(fullNameNode.producerNode);

// 模拟执行 computation
firstName(); // 调用 firstName getter
lastName();// 调用 lastName getter

// 在调用完 firstName lastName getter 之后,fullNameNode.producerNode 就有 producers 了
console.log(fullNameNode.producerNode! === firstNameNode); // 第一个 producer 是 firstName ReactiveNode
console.log(fullNameNode.producerNode! === lastNameNode);// 第二个 producer 是 lastName ReactiveNode</code></pre>
<p>上面最关键的是&nbsp;setActiveConsumer 函数和调用 firstName,lastName getter。</p>
<h4 style="font-size: 14px">Consumer</h4>
<p>在深入 setActiveConsumer 函数之前,我们先了解一下,什么是 consumer。</p>
<p>consumer 中文叫消费者,它和 producer 有点反过来的意思。</p>
<p>我们可以这样理解,fullName 是依赖 firstName 和 lastName 创建出来的,所以 fullName 的 producer (制作它出来的人) 是 firstName 和 lastName。</p>
<p>与此同时,fullName 本身也作为一个 consumer (消费者),因为它消费 (使用) 了 firstName 和 lastName。</p>
<p>好,有点绕,大概就是观察者模式中 Subject (producer) 和 Subscriber (consumer) 的关系啦。</p>
<h4>setActiveConsumer 函数</h4>
<p>好,我们继续深入 setActiveConsumer 函数,它的源码在 graph.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250604194808564-552280874.png"></p>
<p>没什么特别的,它只是把传入的 consumer (fullName ReactiveNode) 设置成全局变量。</p>
<p>为什么要设置成全局变量?当然是为了让其它人可以在天涯海角之外链接上使用它。谁呢?</p>
<h4>Signal getter 函数</h4>
<p>调用 firstName() lastName() 后 fullName ReactiveNode.producerNode 就收集到了 producers。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250605024002839-979752041.png"></p>
<p>所有秘密就在 Signal getter 函数里。</p>
<p>源码在&nbsp;signal.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250604195305155-422415755.png"></p>
<p>signalGetFn 函数</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250604195407280-778671605.png"></p>
<p>producerAccessed 函数源码在&nbsp;graph.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250605151350714-1673716024.png"></p>
<p>整个依赖收集的过程如下:</p>
<ol>
<li>
<p>setActiveConsumer(fullNameNode)</p>
<p>把 fullName ReactiveNode 设置成全局 consumer</p>
</li>
<li>
<p>调用 firstName getter</p>
</li>
<li>
<p>firstName getter 里面会调用 producerAccessed</p>
</li>
<li>
<p>producerAccessed 里面会把 firstName ReactiveNode push 到当前全局 consumer (也就是 fullName ReactiveNode) 的 producerNode array 里</p>
</li>
</ol>
<p>这样 producers (a.k.a 依赖) 就收集好了。</p>
<p>我们看看 computed 源码,是不是和我们上面模拟的一样。</p>
<p>每当调用 fullName getter,如果是第一次没缓存,或是判断缓存已失效,就会调用 COMPUTED_NODE.producerRecomputeValue</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250608174007263-234339708.png"></p>
<p>在执行 computation 前,会做两件事</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250608174226914-2029913746.png"></p>
<p>setActiveConsumer 函数会 set 全局 consumer,同时返回当前的全局 consumer,因为依赖收集完后,要把全局 consumer 还原回去。</p>
<p>接着执行 computation</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250608174432191-469635165.png"></p>
<p>执行完 computation 依赖就收集完了。</p>
<p>最后会做一些清理</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250608174535598-541508944.png"></p>
<p>consumerAfterComputation 函数的源码在 graph.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250608174717285-419659082.png"></p>
<p>清除多余的 producers 概念是这样:</p>
<p>每一次执行 computation,收集到的依赖数量都有可能不一样。</p>
<pre class="language-javascript highlighter-hljs"><code>const fullName = linkedSignal(() =&gt; {
if(showOnlyFirstName()) {
    return firstName();
}
else {
    return firstName() + ' ' + lastName();
}
});</code></pre>
<p>第一次执行 computation,如果 showOnlyFirstName 是 false,那最终收集到的依赖是 。</p>
<p>第二次执行 computation,假如&nbsp;showOnlyFirstName 变成了 true,整个依赖收集的过程如下:</p>
<p>0. 此时 producerNode 有 3 个 ,这是第一次执行 computation 收集到的依赖。</p>
<p>1. nextProducerIndex = 0 ——— (consumerBeforeComputation 函数做的)</p>
<p>2. producerNode =&nbsp;showOnlyFirstName ——— (producerAccessed 函数做的)</p>
<p>4.&nbsp;producerNode =&nbsp;firstName</p>
<p>5.&nbsp;此时,第二次执行的 computation 就结束了,但 producerNode 仍是 ,其中 lastName 是多余的,因为 showOnlyFirstName 为 true,所以 lastName 并不是依赖。</p>
<p>6. 此时,nextProducerIndex 是 2,producerNode.length 是 3,所以会 producerNode.pop() 一次 ——— (consumerAfterComputation 函数做的)</p>
<p>7. 最后 producerNode 是 ,依赖收集正式完毕。</p>
<h4>依赖收集不支持异步</h4>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250614051134128-1554033128.png"></p>
<p>从开始依赖收集 &gt; 到执行 computation (without await) &gt; 到结束依赖收集,整个过程都是同步的。</p>
<p>结论:computed 不支持异步。</p>
<h3>computed 的实现原理 の 依赖变更检测</h3>
<p>上面有提到,要实现 computed,需要完成两件事:</p>
<p>第一件是收集依赖,这个完成了。</p>
<p>第二件是判断依赖是否变更了,这一点我们继续深入了解。</p>
<h4>How to know if a Signal value has changed?</h4>
<div class="flex max-w-full flex-col grow">
<div class="min-h-8 text-message relative flex w-full flex-col items-end gap-2 text-start break-words whitespace-normal [.text-message+&amp;]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="2fcff7ec-d753-4e5d-8390-65ee4a98242d" data-message-model-slug="gpt-4o">
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-">
<div class="markdown prose dark:prose-invert w-full break-words light">
<p data-start="0" data-end="77">Angular 的 Signal 对象有 set、update 方法,但没有 ko.observable 或 RxJS 的 subscribe 方法。</p>
<p data-start="79" data-end="131">Signal 一定是 observable(可被监听的),但就目前为止,我们还没有讲到如何监听它的变更。</p>
<p data-start="133" data-end="165" data-is-last-node="" data-is-only-node="">但即便如此,我们依然有办法可以判断一个 Signal 是否发生了变更。</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = signal('Derrick');
const firstNameNode = firstName as SignalNode&lt;string&gt;;
console.log(firstNameNode.version); // 0
firstName.set('Alex');
console.log(firstNameNode.version); // 1
firstName.set('David');
firstName.set('Richard');
console.log(firstNameNode.version); // 3</code></pre>
</div>
</div>
</div>
</div>
<p data-start="0" data-end="59">每次调用 WritableSignal.set 修改值,ReactiveNode 的 version 就会累加 1。</p>
<p data-start="61" data-end="115" data-is-last-node="" data-is-only-node="">只要我们把某个时刻的 version 记录下来,之后再拿来和当前的 verision 做对比,就能判断这段期间是否发生了变更 (version 不同就表示这段期间有 set 新的值)。</p>
<p data-start="61" data-end="115" data-is-last-node="" data-is-only-node="">注:虽然这种判断方式不是 100% 精准。</p>
<p data-start="61" data-end="115" data-is-last-node="" data-is-only-node="">比如说:初始值是 'Derrick',接着我们 set('Alex') 然后马上又 set('Derrick') 把值改回去,严格来说这不算变更,但 version 仍累加了两次。若以 version 判断,会认为发生了变更。</p>
<p data-start="61" data-end="115" data-is-last-node="" data-is-only-node="">不过这种判断方式既简单又低成本,因此也是一个可取的方案。</p>
<h4>顺便介绍 equal options</h4>
<p>假如连续 set 相同的值</p>
<pre class="language-javascript highlighter-hljs"><code>firstName.set('Alex');
firstName.set('Alex');
firstName.set('Alex');</code></pre>
<p>ReactiveNode 的 version 并不会每次都累加,WritableSignal 内部会先判断 set 进来的新值是否与旧值相同,若相同则会直接 skip 掉后续操作,因此 version 不会累加。</p>
<p>它比较新旧值的方式是使用&nbsp;Object.is,也就是说对于对象来说,比的是引用 (reference) 而非值 (value)。</p>
<pre class="language-javascript highlighter-hljs"><code>const person = signal({ firstName: 'Derrick' });
const personNode = person as SignalNode&lt;string&gt;;
// 换了对象引用,但是值是相同的
person.set({ firstName: 'Derrick' });
console.log(personNode.version); // version 累加变成 1,因为 compare 方式是 Object.is,对象的 reference 已经不同了</code></pre>
<p>如果我们想改变它的 compare 方式,可以透过 equal options</p>
<pre class="language-javascript highlighter-hljs"><code>const person = signal(
{ firstName: 'Derrick' },
{
    // 把 compare 的方式换成 compare firstName
    equal: (prev, curr) =&gt; prev.firstName === curr.firstName,
},
);
const personNode = person as SignalNode&lt;string&gt;;
// 换了对象引用,但是值是相同的
person.set({ firstName: 'Derrick' });
console.log(personNode.version); // version 依然是 0</code></pre>
<p>提醒:当新旧值相同时,它是 skip 掉后续所有操作哦,所以不只是 version 不变,连旧值都不会变。</p>
<pre class="language-javascript highlighter-hljs"><code>const person = signal({ id : 1, name: 'Derrick' }, { equal: (a, b) =&gt; a.id === b.id }); // 对比的方式是看 id
person.set({ id: 1, name: 'Richard' }); // id 一样但 name 不一样
console.log(person().name); // name 依然是 'Derrick',因为 version 和 value 都没有完全没有改变,整个过程被 skip 掉了</code></pre>
<p>Best practice:建议 Signal 的 value 使用 immutable,这样变更会比较简单直观,debug 也会更容易。</p>
<h4>WritableSignal.set and ReactiveNode.version</h4>
<p>WritableSignal.set 的源码在&nbsp;signal.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250604165221880-674349898.png"></p>
<p>signalSetFn 函数源码在&nbsp;signal.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250618102233504-323751667.png"></p>
<div>signalValueChanged 函数</div>
<div>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250608184640324-526040135.png"></p>
<p>以上就是调用 WritableSignal.set 后,update value 和累加 ReactiveNode.version 的相关源码。</p>
</div>
<h4>computed 依赖收集 version + 变更检测</h4>
<p>回顾一下 computed 的流程:</p>
<ol>
<li>
<p>调用 fullName getter</p>
</li>
<li>判断缓存是否失效 (这一 part 源码我们还没有看,下面会解说)</li>
<li>
<p>执行 COMPUTED_NODE.producerRecomputeValue</p>
</li>
<li>
<p>setActiveConsumer(fullNameReactiveNode) 把 fullName ReactiveNode 设置成全局 consumer</p>
</li>
<li>
<p>执行 fullName computation</p>
</li>
<li>
<p>调用 firstName getter</p>
</li>
<li>
<p>执行&nbsp;producerAccessed</p>
</li>
<li>把 firstName ReactiveNode push 进 fullNameReactiveNode.producerNode array 里 (依赖收集完成)</li>
<li>
<p>把 firstNameReactiveNode.version push 进&nbsp;fullNameReactiveNode.producerLastReadVersion array 里 (这一 part 我们上面没讲到)</p>
</li>
</ol>
<p>step 2 和 9 是新加的。</p>
<p>step 9 记入 version 的目的就是为了让 Step 2 能判断出缓存是否失效。</p>
<p>step 9 的源码在&nbsp;graph.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250605184337272-974799324.png"></p>
<p>step 2 判断缓存是否失效,具体的做法是这样:</p>
<p>第一次调用 fullName getter,肯定没有缓存,所以不需要判断,直接执行 computation。</p>
<p>执行 computation 的同时会收集依赖 (a.k.a producer) 以及它们当前的 version。</p>
<p>computation 返回的值会被缓存起来。</p>
<p>下一次调用 fullName getter 时,会将之前收集到的 producer version 与当前的 producer version 做对比。</p>
<p>如果所有 producer version 都和之前一样,就表示缓存可以使用;如果有任何一个 version 不同,就表示缓存失效,需要重新执行 computation。</p>
<p>相关源码在 computed.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250605195351554-2079331071.png"></p>
<p>createComputed 函数的源码在&nbsp;computed.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250620134319493-899059377.png"></p>
<p>producerUpdateValueVersion 函数源码在&nbsp;graph.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250608185029399-1567427498.png"></p>
<p>有一些小而快的判断,我们就不细讲了,像 epoch 它是一个全局 version,如果全世界的 Signal 都没有变更,那 producer 自然也不可能变更,所以可以直接 return。</p>
<p>step 3 是检测 producers version</p>
<p>step 4 是确认缓存失效后,执行 computation &gt; 赋值给 fullNameComputedNode.value &nbsp;&gt; 累加 fullNameReactiveNode.version (注:因为 computed 不是 WritableSignal,它没有 setter,所以它的 version 是在 getter 时累加的)</p>
<p>COMPUTED_NODE.producerRecomputeValue 上面我们逛过了,这里补上更新缓存值和累加 version 的部分。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250608185312192-871441473.png"></p>
<p>step 3 的 consumerPollProducersForChange 函数</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250605213021278-1418355199.png"></p>
<p>以上就是 computed 背后的机制。</p>
<h3>总结</h3>
<p>深入理解 Angualr Signals 源码有什么好处?</p>
<p>当你遇到 Angular bug 的时候,你可以平和的面对和解决。</p>
<p>比如:Issue –&nbsp;Signal Queries are populated before the input have been set on the components</p>
<p>大家可以去看这个 Issue,即便是 Angular 团队也经常会搞不清状况,因为很多代码都不是这批人写的 (是前朝遗留下来的)。</p>
<p>所以如果你是 Angular 重度使用者,遇到这群虾兵蟹将,你的项目绝对会被他们拖累到。</p>
<p>掌握源码就可以理解来龙去脉,虽然你改变不了他们的无能,但至少你不会被他们胡乱带着走。</p>
<p>&nbsp;</p>
<h2>Angular linkedSignal</h2>
<p>linkedSignal 是 Angular 自创的,KO、RxJS、SolidJS 都没有这个概念。</p>
<p>它算是 signal + computed 的一个变种,有点像悟吉塔的感觉。</p>
<p>我们先来看一看它的各种特性,最后再看它适合用在哪些场合。</p>
<h3>linkedSignal as&nbsp;computed</h3>
<p>computed 能做的,linkedSignal 都能做,而且行为一模一样 -- pull-based、cacheable、observable、自动依赖收集。</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = signal('Derrick');
const lastName = signal('Yam');
// 不执行 computation, 因为是 pull-based
const fullName = linkedSignal(() =&gt; firstName() + ' ' + lastName());

// 执行 computation,因为第一次没有缓存
console.log(fullName());

// 不执行 computation,因为有缓存
console.log(fullName());

// 不执行 computation,因为是 pull-based
firstName.set('Richard');

// 不执行 computation,因为缓存失效了
console.log(fullName());</code></pre>
<h3>linkedSignal as WritableSignal</h3>
<p>signal 能做的,linkedSignal 也都能做到,虽然 declare 的方式有点诡异。</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = linkedSignal(() =&gt; 'Derrick'); // 用 linkedSignal 替代 signal
const lastName = linkedSignal(() =&gt; 'Yam');
const fullName = computed(() =&gt; firstName() + ' ' + lastName());

console.log(fullName()); // 'Derrick Yam'

firstName.set('Richard'); // 可以 set value

console.log(fullName()); // 'Richard Yam'

lastName.update(oldLastName =&gt; oldLastName + 'a'); // 可以 update value

console.log(fullName()); // 'Richard Yama'</code></pre>
<p>用法和 signal 一模一样,唯一的区别是,初始化值写法不同</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = linkedSignal(() =&gt; 'Derrick'); // linkedSignal 是提供一个函数,函数返回值作为 Signal 初始值

const firstName = signal('Derrick'); // signal 是直接提供初始值</code></pre>
<h3>linkedSignal as writable computed</h3>
<p>上面的例子只是为了说明 linkedSignal 是 signal + computed 的变种,兼具两者的能力。</p>
<p>但在真实项目中,如果 signal 和 computed 已经够用,我们自然不会刻意用 linkedSignal 去替代它们。</p>
<p>linkedSignal 既可以作为 computed (readonly Signal) 又可以作为 signal (WritableSignal) ,这不会冲突吗?</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = signal(() =&gt; 'Derrick');
const lastName = signal(() =&gt; 'Yam');

const fullName = linkedSignal(() =&gt; firstName() + ' ' + lastName()); // 作为 computed (readonly Signal)
fullName.set('Richard Lee'); // 作为 signal (WritableSignal)

console.log(fullName()); // 'Derrick Yam' or 'Richard Lee' ? </code></pre>
<p>fullName 的值应该是 computation 的结果 'Derrick Yam' 还是 set value 的 'Richard Lee' ?</p>
<p>答案是 'Richard Lee'</p>
<p>linkedSignal 的机制是这样:</p>
<p>after set,它就用 set 的值。</p>
<p>after 依赖变更,它就用 computation 的值。</p>
<pre class="language-javascript highlighter-hljs"><code>const fullName = linkedSignal(() =&gt; firstName() + ' ' + lastName());

console.log(fullName());   // 此时 value 来自 computation -- 'Derrick Yam'

fullName.set('Richard Lee'); // 修改 fullName

console.log(fullName());   // 此时 value 来自 set -- 'Richard Lee'

firstName.set('Alex');       // 修改 fullName computation 的依赖 -- firstName

console.log(fullName());   // 此时 value 来自 computation -- 'Alex Yam'</code></pre>
<h3>linkedSignal as pairwise"able" computed</h3>
<p>pairwise 是 RxJS 的概念,意思是 previous &amp; current value。</p>
<p>signal 有一个 update 方法,它的特点是能在更新值的时候可以依赖旧值。</p>
<pre class="language-javascript highlighter-hljs"><code>const count = signal(0);
count.update(oldCount =&gt; oldCount + 1); // 可以拿旧值 (previous value) 来做累加</code></pre>
<p>computed 则做不到这个</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = signal('Derrick');
const lastName = signal('Yam');
const fullName = computed(() =&gt; {

// 1. 希望能拿到 fullName 旧值,办不到!
// 2. 希望能拿到 firstName 和 lastName 旧值,办不到!

return firstName() + ' ' + lastName();
});</code></pre>
<p>我们无法拿到 firstName, lastName, fullName 的旧值。</p>
<p>但 linkedSignal 可以做到这个。</p>
<p>linkedSignal 函数有两个重载:</p>
<p>第一个是</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250608012013805-1067363810.png"></p>
<p>参数一是 computation 函数,这个和 computed 一样,上面例子用得都是这个。</p>
<p>第二个是</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250608012107726-1842607077.png"></p>
<p>比较复杂,我们先忽略掉所有的 source 的部分。</p>
<p>computation 依旧,但它多了一个 previous 参数</p>
<p>previous.value 可以获取到当前 linkedSignal 的值 (旧值)。</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = signal('Derrick');
const lastName = signal('Yam');

const fullName = linkedSignal&lt;undefined, string&gt;({
source: () =&gt; {},
computation: (_, previous) =&gt; {

    // 获取当前 fullName 的值
    // 第一次会是 undefined
    // 第二次是 'Derrick Yam'
    const oldFullName = previous?.value;
    console.log('oldFullName', oldFullName);

    return firstName() + ' ' + lastName();
}
});

fullName(); // run computation return 'Derrick Yam'
firstName.set('Alex');   
fullName(); // run computation return 'Alex Yam'</code></pre>
<p>好,那如果我们连 firstName, lastName 的旧值也想获得,该怎么做?-- 使用 source</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = signal('Derrick');
const lastName = signal('Yam');

const fullName = linkedSignal&lt;, string&gt;({
source: () =&gt; ,
computation: (source, previous) =&gt; {

    // 第一次是
    // 第二次是 ['Derrick', 'Yam']
    const = previous?.source ?? [];

    // 第一次是 ['Derrick', 'Yam']
    // 第二次是 ['Alex', 'Yam']
    const = source;
   
    return newFirstName + ' ' + newLastName;
}
});

fullName(); // run computation return 'Derrick Yam'
firstName.set('Alex');   
fullName(); // run computation return 'Alex Yam'</code></pre>
<p>我们需要把 firstName 和 lastName 明确定义到 source 里 (有点像 RxJS 的 combineLatest)。</p>
<p>这样 computation 的参数二 previous.source 就会有 firstName 和 fullName 的旧值。</p>
<p>另外,newFirstName 和 firstName() 其实是等价的,所以这样写也可以</p>
<pre class="language-javascript highlighter-hljs"><code>computation: (_, previous) =&gt; {
const = previous?.source ?? [];
return firstName() + ' ' + lastName();
}</code></pre>
<p>再看一次 interface</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250608023022568-649529064.png"></p>
<h3>linkedSignal 的真实使用场景</h3>
<p>单看 linkedSignal 的特性很难联想到它的真实使用场景。</p>
<p>比如说&nbsp;writable computed 就很反直觉,computed 怎么可能是 writable 呢?</p>
<div class="text-base my-auto mx-auto py-5 [--thread-content-margin:--spacing(4)] @:[--thread-content-margin:--spacing(6)] @:[--thread-content-margin:--spacing(16)] px-(--thread-content-margin)">
<div class="[--thread-content-max-width:32rem] @:[--thread-content-max-width:40rem] @:[--thread-content-max-width:48rem] mx-auto flex max-w-(--thread-content-max-width) flex-1 text-base gap-4 md:gap-5 lg:gap-6 group/turn-messages focus-visible:outline-hidden">
<div class="group/conversation-turn relative flex w-full min-w-0 flex-col agent-turn">
<div class="relative flex-col gap-1 md:gap-3">
<div class="flex max-w-full flex-col grow">
<div class="min-h-8 text-message relative flex w-full flex-col items-end gap-2 text-start break-words whitespace-normal [.text-message+&amp;]:mt-5" dir="auto" data-message-author-role="assistant" data-message-id="0434af0d-50ec-4aaf-9174-ea93281d2710" data-message-model-slug="gpt-4o">
<div class="flex w-full flex-col gap-1 empty:hidden first:pt-">
<div class="markdown prose dark:prose-invert w-full break-words light">
<p data-start="0" data-end="98" data-is-last-node="" data-is-only-node="">其实 linkedSignal&nbsp;的精髓是在 'linked' 这个字上。</p>
<p data-start="0" data-end="98" data-is-last-node="" data-is-only-node="">首先我们一定是想要一个 WritableSignal,然后这个 signal 和其它 signals 有一些逻辑关系,这时我们就可以用 linkedSignal 把这一个 signal 'link' with 其它 signals。</p>
<p data-start="0" data-end="98" data-is-last-node="" data-is-only-node="">来看一个 Angular 官网的例子:</p>
<p data-start="0" data-end="98" data-is-last-node="" data-is-only-node="">有一个 country options</p>
<pre class="language-javascript highlighter-hljs"><code>const countries = signal(['Malaysia', 'Singapore', 'China', 'India']);</code></pre>
<p>有一个 selected country</p>
<pre class="language-javascript highlighter-hljs"><code>const selectedCountry = signal(countries()); // select 'Malaysia'</code></pre>
<p>我们可以任选 country</p>
<pre class="language-javascript highlighter-hljs"><code>selectedCountry.set(countries()); // select 'China'</code></pre>
<p>但有一个规则,selectedCountry 不能超出 country options 范围。</p>
<p>那假如此时 country options 变更了会怎样?</p>
<pre class="language-javascript highlighter-hljs"><code>countries.set([...countries().slice(0, 2)]); // set to ['Malaysia', 'Singapore']</code></pre>
<p>selected country 是 'China',但此时 country options 内已经没有 'China' 了,逻辑断链。</p>
<p>依据传统思路,我们可能会这么解:</p>
<p>监听 country options,当它变更时去检查 selectedCountry 是否依然在新的 country options 范围内,如果在就 ok,如果不在就重置。</p>
<p>用 RxJS 表达是这样</p>
<pre class="language-javascript highlighter-hljs"><code>const countries = new BehaviorSubject(['Malaysia', 'Singapore', 'China', 'India']);
const selectedCountry = new BehaviorSubject(countries.value);// select 'Malaysia'
// 定义 selectedCountry 与 countries 的逻辑关系
countries.subscribe(newCountries =&gt; {
if(!newCountries.includes(selectedCountry.value)) {
    selectedCountry.next(newCountries); // select 'Malaysia'
}
});

selectedCountry.next(countries.value);// select 'China'
countries.next([...countries.value.slice(0, 2)]); // set to ['Malaysia', 'Singapore']

console.log(selectedCountry.value); // 'Malaysia'</code></pre>
<p>这种情况就适合用 linkedSignal 来解决。</p>
<pre class="language-javascript highlighter-hljs"><code>const countries = signal(['Malaysia', 'Singapore', 'China', 'India']);

const selectedCountry = linkedSignal&lt;string[], string&gt;({
source: countries, // link to countries
computation : (countries, previous) =&gt; {

    // 第一次直接返回第一个 country
    if (previous === undefined) return countries;

    const prevSelectedCountry = previous.value;

    // 如果 selected country 有在 country options 内
    if (countries.includes(prevSelectedCountry)) {
      // 那就继续保持这个 selected country
      return prevSelectedCountry;
    }

    // 如果 selectedCountry 不存在于 country options 了,那就重置返回第一个 country
    return countries;
}
});</code></pre>
<h3>Why SolidJS no need linkedSignal?</h3>
<p>为什么 Angular 需要独创 linkedSignal?</p>
<p>难道 SolidJS 没有这个需求?</p>
<p>难道 SolidJS 有其他解决方案?</p>
<p>SolidJS 要实现类似的功能,应该是用 createComputed 来实现,做法类似上面 RxJS 的版本。</p>
<p>SolidJS 和 RxJS 的实现方式与 linkedSignal 最大的区别是,SolidJS 和 RxJS 是 push-based,而 linkedSignal 是 pull-based。</p>
<p>我猜,正是因为这个原因,Angular 才独创了 linkedSignal。</p>
<p>下一 part 我们就来逛一下 linkedSignal 相关源码,看一看它是如何实现 pull-based 的。</p>
<p>&nbsp;</p>
<h2>逛一逛 Angular linkedSignal 源码</h2>
<p>linkedSignal 是 signal + computed 的变种,所以它们的源码大同小异。</p>
<p>上面我们已经逛过 signal 和 computed 了,这里只看 linkedSignal 的两个特色部分就好。</p>
<p>第一个是&nbsp;LINKED_SIGNAL_NODE.producerRecomputeValue,源码在 linked_signal.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250608051040213-780195129.png"></p>
<p>它和&nbsp;COMPUTED_NODE.producerRecomputeValue 大同小异,主要是多了 source 和 previous 的处理。</p>
<p>第二个部分是在&nbsp;linkedSignalSetFn 函数,源码在 linked_signal.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250608051428692-600632995.png"></p>
<p>producerUpdateValueVersion 和 signalSetFn 函数的源码上面都逛过了,这里不再赘述。</p>
<p>值得我们思考的是,为什么执行 signalSetFn 之前要先调用 producerUpdateValueVersion 呢?</p>
<p>我们透过这段代码来理解</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = signal('Derrick');
const lastName = signal('Yam');
const fullName = linkedSignal(() =&gt; firstName() + ' ' + lastName());

fullName(); // 'Derrick Yam'
firstName.set('Alex');
fullName.set('Richard Yam');
fullName(); // 'Richard Yam'</code></pre>
<p>请问 computation 会被执行多少次?</p>
<p>你可能会认为是一次,因为只有 'Derrick Yam' 是来自 computation 的结果,而 'Richard Yam' 是来自 set 的结果。</p>
<p>但其实是两次,第二次执行发生在 fullName.set('Richard Yam') 的时候。</p>
<p>也就是上面提到的 -- 执行 signalSetFn 之前会先调用 producerUpdateValueVersion。</p>
<p>为什么它要这样呢?</p>
<p>因为 linkedSignal 是 pull-based。</p>
<p>试想想,如果在&nbsp;fullName.set('Richard Yam') 时不调用&nbsp;producerUpdateValueVersion 会发生什么?</p>
<p>答案是,最后一行调用 fullName() 会返回 'Alex Yam',而不是 'Richard Yam'。</p>
<p>它的机制是这样 (和 computed 的机制一模一样):每一次调用 fullName getter 都会执行 producerUpdateValueVersion,但&nbsp;producerUpdateValueVersion 并不一定会执行 computation 修改值。</p>
<p>只有当 computation 的依赖 (也包括 source) 的 version 变更了,producerUpdateValueVersion 才会执行 computation 修改值。</p>
<pre class="language-javascript highlighter-hljs"><code>fullName(); // 会执行 computation
fullName.set('Richard Yam');

// 不会执行 computation 修改值,因为距离上一次调用 fullName() 到现在,依赖(firstName, lastName)都没有变更
// 由于没有修改值,那当前的值就是上一次 set 的 'Richard Yam'
fullName(); </code></pre>
<p>linkedSignal 是靠着 "computation 依赖没有变更,所以不需要执行 computation 修改值" 来保住透过 fullName.set 进来的值。</p>
<p>再看这个</p>
<pre class="language-javascript highlighter-hljs"><code>fullName(); // 会执行 computation
firstName.set('Alex'); // 修改了依赖
// fullName.set('Richard Yam');
fullName(); // 会执行 computation 修改值成 'Alex Yam',因为依赖变更了</code></pre>
<p>我刻意把第三句注释掉。</p>
<p>假设解开注释,我们期望的结果是最后一行 fullName() 不要执行 computation 修改值,因为我们想拿到的是 fullName.set 的 'Richard Yam'。</p>
<p>但它一定会执行 computation,因为距离上一次 (第一行) 调用 fullName() 间中出现了 firstName.set,依赖变更了所以它自然会执行 computation。</p>
<p>那唯一的解法就是在 fullName.set 之前,先让它执行一遍 computation,类似这样的效果</p>
<pre class="language-javascript highlighter-hljs"><code>fullName(); // 会执行 computation
firstName.set('Alex');
fullName(); // 距离上一次 fullName() 期间有依赖变更,所以会执行 computation 修改值成 'Alex Yam'
fullName.set('Richard Yam'); // 修改值成 'Richard Yam'
fullName(); // 距离上一次 fullName() 期间没有依赖变更,所以不会执行 computation 修改值, 由于没有修改值,那当前的值就是上一次 set 的 'Richard Yam'</code></pre>
<p>这就是为什么 linkedSignal.set 会先执行&nbsp;producerUpdateValueVersion 然后才调用 signalSetFn,目的就是要 update computation 依赖的 version,以保住透过 fullName.set 进来的值。</p>
<p>提醒:所以呢,linkedSignal.set 是有可能导致 computation 执行的哦,这有点反直觉,我们要注意了。</p>
<p>&nbsp;</p>
<h2>Angular effect</h2>
<p>监听 observable variable 是 Signals 的核心功能。</p>
<p>RxJS 有&nbsp;observable.subscribe (监听单个 variable) 和 combineLatest (监听多个 variables)</p>
<p>KO 有 observable.subscribe (监听单个 variable) 和 ko.computed (监听多个 variables)</p>
<p>SolidJS 有 createEffect,createComputed,createRenderEffect (都是监听多个 variables)</p>
<p>Angular 借鉴的对象是 SolidJS,它有 effect (又可细分为 root effect 和 view effect) 和&nbsp;afterRenderEffect (都是监听多个 variables)。</p>
<p>为什么 SolidJS 和 Angular 都需要那么多种不同的 effect 呢?</p>
<p>因为 effect 和框架的渲染过程有着千丝万缕的关系。effect 的触发时机 (timing) 以及副作用的范围都很讲究,因此它们才会将 effect 细分成多个版本。</p>
<p>不过,本篇不会讲得那么细,毕竟我们对 Angular 的渲染机制还一窍不通。这些细节会在后面的章节再补上。</p>
<p>本篇会把 effect 独立出来讲解,timing 的部分不会使用 Angular built-in 的机制,而是我们自己模拟一个简简单单的实现。</p>
<p>我们把焦点放在 "multiple subscribe for side effect" 这一点上就好,不要去想关于 timing 的事。</p>
<h3>effect 函数</h3>
<p>effect 长这样</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = signal('Derrick');

effect(() =&gt; console.log(firstName())); // 'Derrick' ...一秒后... 'Richard'

window.setTimeout(() =&gt; firstName.set('Richard'), 1000); // 一秒后修改 firstName</code></pre>
<p>调用 effect 函数,传入一个 callback,它会执行第一次。</p>
<p>在执行的过程中,effect 会自动收集依赖 (例子中的 firstName)。</p>
<p>每当依赖变更 (firstName.set 的时候),callback 会再次被执行。</p>
<p>以上就是 effect 的大致用法和流程。</p>
<h3>effect 函数依赖 Injector, ChangeDetectionScheduler, EffectScheduler</h3>
<p>如果我们拿上述代码来运行,会直接报错!</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250609015134528-2110711254.png"></p>
<p>因为 effect 函数依赖 Injector,还有&nbsp;ChangeDetectionScheduler 和 EffectScheduler 两个 class provider。</p>
<p>Angular 框架有 built-in 这些,但由于我们是脱离 Angular 框架独立使用 effect,所以用不了 built-in 的,只能模拟给它。</p>
<p>完整代码 -- main.ts</p>
查看代码
<pre class="language-javascript highlighter-hljs"><code>import { effect, inject, Injectable, Injector, signal, ɵChangeDetectionScheduler, ɵEffectScheduler, ɵNotificationSource } from "@angular/core";

const firstName = signal('Derrick');

// 定义 ChangeDetectionScheduler
// ɵChangeDetectionScheduler 是 abstract class,我们需要实现 notify 方法和 runningTick 属性
// 题外话:starts with ɵ symbol 代表这是 Angular 半公开的 interface,最好不要乱用,因为它可能随时 breaking change
@Injectable()
class ChangeDetectionScheduler extends ɵChangeDetectionScheduler {
// inject EffectScheduler
private readonly effectScheduler = inject(ɵEffectScheduler);

override notify(_source: ɵNotificationSource) {
    console.log('effect notify');
    // flush effect (一定要 delay,不然会报错, Angular 的限制)
    queueMicrotask(() =&gt; this.effectScheduler.flush());
}

override runningTick = false;
}

// 由于 Angular 没有公开 SchedulableEffect interface 所以这里需要补一个 type
type SchedulableEffect = Parameters&lt;ɵEffectScheduler ['add']&gt;;

// 定义 EffectScheduler
// ɵEffectScheduler 是 abstract class,我们需要实现 add, remove, schedule, flush 四个方法
@Injectable()
class EffectScheduler extends ɵEffectScheduler {

// 定义一个 SchedulableEffect array,用来保存 SchedulableEffect
private readonly schedulableEffect: SchedulableEffect[] = [];

override add(schedulableEffect: SchedulableEffect) {
    console.log('effect add');
    // 每当调用 effect,EffectScheduler.add 就会被调用
    // 并且会得到一个 SchedulableEffect
    // 它里面有我们定义的 effect callback 函数
    // 所以要将它保存起来
    this.schedulableEffect.push(schedulableEffect);
}

override flush() {
    console.log('effect flush');
    // flush 就是执行 effect callback
    this.schedulableEffect.forEach(effect =&gt; effect.dirty &amp;&amp; effect.run()); // 有 dirty 的才跑
}

override remove(schedulableEffect: SchedulableEffect) {
    console.log('effect remove');
    const index = this.schedulableEffect.indexOf(schedulableEffect);
    // 当 effect 被 destroy,EffectScheduler.remove 就会被调用
    // 我们把报错的 schedulableEffect 删除掉
    this.schedulableEffect.splice(index, 1);
}

override schedule(_schedulableEffect: SchedulableEffect) {
    console.log('effect schedule');
}
}

const injector = Injector.create({
providers: [
    { provide: ɵChangeDetectionScheduler, useClass: ChangeDetectionScheduler }, // provide ChangeDetectionScheduler
    { provide: ɵEffectScheduler, useClass: EffectScheduler } // provider EffectScheduler
]
});

effect(() =&gt; console.log(firstName()), { injector });

window.setTimeout(() =&gt; {
console.log('一秒后')
firstName.set('Richard');
}, 1000);</code></pre>
<p>这部分的细节我们就不探究了,能跑起来就好。</p>
<p>效果</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250609192510533-273217770.png"></p>
<h3>effectRef, autoCleanup, manualCleanup</h3>
<p>调用 effect 会返回一个 EffectRef 对象。</p>
<pre class="language-javascript highlighter-hljs"><code>const effectRef = effect(() =&gt; console.log(firstName()), { injector });</code></pre>
<p>这个对象有一个 destroy 方法,可以让我们终止 effect 监听。</p>
<p>一旦 destroy 后,effect 的 callback 就不会再被触发了。</p>
<pre class="language-javascript highlighter-hljs"><code>const effectRef = effect(() =&gt; console.log(firstName()), { injector });
effectRef.destroy();

window.setTimeout(() =&gt; firstName.set('Richard'), 1000); // callback 不会再触发,因为 effect 已经 destroy 了</code></pre>
<p>这个机制类似于 RxJS 的 Subscription.unsubscribe。</p>
<p>除了 EffectRef.destroy 还有一个方法可以终止 effect 监听 -- destroy injector</p>
<pre class="language-javascript highlighter-hljs"><code>effect(() =&gt; console.log(firstName()), { injector });
injector.destroy();</code></pre>
<p>effect 内部监听了 injector.onDestroy,当 injector destroy 的同时它会去调用 EffectRef.destroy。</p>
<p>如果我们不希望它这样做,可以修改 effect 的默认配置 manualCleanup</p>
<pre class="language-javascript highlighter-hljs"><code>effect(() =&gt; console.log(firstName()), { injector, manualCleanup: true }); // 设置 manualCleanup: true
injector.destroy(); // injector destroy 不会导致 effect 跟着 destroy</code></pre>
<p>这样&nbsp;injector destroy 就不会导致 effect 跟着 destroy 了。</p>
<h3>onCleanup</h3>
<p>effect callback 有一个参数叫 onCleanup,它是一个函数。</p>
<p>它可以用来注册一些清理函数,当 effect destroy 时做一些内部清理,释放资源之类的。</p>
<pre class="language-javascript highlighter-hljs"><code>const effectRef = effect(onCleanup =&gt; {
console.log(firstName());
onCleanup(() =&gt; console.log('effect destroyed, do something clearnup'));
}, { injector });

effectRef.destroy();</code></pre>
<p>这个机制类似于&nbsp;RxJS new Observable callback 返回的 displose 函数。</p>
<h3>untracked</h3>
<p>effect callback 内调用的所有 Signal getter 都会被收集为依赖。如果我们想跳过某些依赖收集,可以使用 untracked 函数 (它和 SolidJS 的 untrack 是相同功能,也等同于 ko.ignoreDependencies 和 observable.peek)。</p>
<pre class="language-javascript highlighter-hljs"><code>effect(() =&gt; {
// 用 untracked 把 status wrap 起来,这样读取 status 时就不会被依赖收集
console.log(firstName(), lastName(), untracked(() =&gt; status()));
// 或者 untracked(status) 也可以,因为 status 是 getter,它也是函数
}, { injector });

effect(() =&gt; {
// 把要监听的依赖声明在顶部
firstName();
lastName();

// 把 side effect wrap 一层 untracked, 里面都不会被依赖收集
untracked(() =&gt; console.log(firstName(), lastName(), status()));
}, { injector });</code></pre>
<p>untracked 内调用 Signal getter 不会被收集为依赖,所以 status 变更不会导致 callback 被执行。</p>
<h3>effect 不支持嵌套</h3>
<pre class="language-javascript highlighter-hljs"><code>effect(() =&gt; {
effect(() =&gt; firstName(), { injector });
}, { injector });</code></pre>
<p>直接报错</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250613231426691-1893408847.png"></p>
<p>相关源码在 effect.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250613231455848-2012967598.png"></p>
<p>assertNotInReactiveContext 函数源码在&nbsp;asserts.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250613231637585-1412018691.png"></p>
<p>只要是在依赖收集的阶段,全局就会有 consumer,而此时调用 effect 函数就会直接报错。</p>
<p>执行 effect callback 会开启依赖收集,因此在 effect callback 里调用 effect 函数就会报错。</p>
<p>为什么 Angular 要禁止嵌套 effect 呢?</p>
<p>我猜是因为 timing 问题,这个我们以后章节再讲解,本篇不涉及 timing 问题。</p>
<h3>effect 不支持异步</h3>
<p>也不是说完全不支持异步,只是要谨慎使用而已。</p>
<pre class="language-javascript highlighter-hljs"><code>function delay() {
return new Promise&lt;void&gt;(resolve =&gt; resolve());
}

const firstName = signal('Derrick');
const lastName = signal('Yam');

effect(async () =&gt; {
const fullName = firstName() + ' ' + lastName();

await delay();

console.log(fullName); // 'Derrick Yam' ... 'Richard Yam'
}, { injector });

window.setTimeout(() =&gt; firstName.set('Richard'), 1000);</code></pre>
<p>像上面这段异步是支持的,console.log 会执行两次,正确。</p>
<p>但像下面这样就不行</p>
<pre class="language-javascript highlighter-hljs"><code>effect(async () =&gt; {
await delay();

console.log(firstName() + ' ' + lastName()); // 'Derrick Yam' ... 没有第二次的 console.log
}, { injector });</code></pre>
<p>原因是:依赖收集必须是同步的。</p>
<p>在 await delay() 之前,调用 firstName(),lastName() 才会被依赖收集。(这是同步)</p>
<p>在 await delay() 以后,调用 firstName(),lastName() 就不会被依赖收集了。(这是异步)</p>
<p>没有依赖收集到 firstName 和 lastName,那往后它们的变更就不会触发 effect callback 了。</p>
<p>总之,effect callback 要 async 不是不行,只是要记得,它的依赖收集只能是同步的。</p>
<p>&nbsp;</p>
<h2>逛一逛 Angular effect 源码</h2>
<p>逛 effect 源码前,请确保你已经阅读了 signal &amp; computed 源码,因为重叠的部分我不会再重复讲。</p>
<h3>EffectRef &amp;&nbsp;EffectNode</h3>
<p>effect 函数返回的是 EffectRef,源码在 effect.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250609214301818-2103722341.png"></p>
<p>EffectRef 是一个对象,表面上看它只有一个 destroy 方法</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250609214434941-1728585089.png"></p>
<p>但仔细看 EffectRef 其实只是一个 interface,真正实现这个 interface 的是 class EffectRefImpl (注:Angular 团队喜欢这种命名规范 -- 以 Impl 作为结尾,那是 implement 的缩写)</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250609214714014-1358746437.png"></p>
<p>EffectRefImpl 多了一个属性 </p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250609214907903-1221016196.png"></p>
<p>SIGNAL 是 symbol 来的</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250609215804285-206438021.png"></p>
<p>哎哟,是不是有点似曾相识?</p>
<p>没错!</p>
<p>signal 返回的是 WritableSignal 对象</p>
<p>computed 返回的是 Signal 对象</p>
<p>effect 返回的是 EffectRefImpl (以下简称 EffectRef) 对象</p>
<p>这三个对象的共同点是:它们都有一个属性叫 。</p>
<p>signal 的 是 SignalNode</p>
<p>computed 的 是 ComputedNode</p>
<p>而 effectRef 的 则是&nbsp;EffectNode</p>
<p>SignalNode 和 ComputedNode 都继承自 ReactiveNode,想当然,EffectNode 自然也继承自 ReactiveNode。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250609221715757-1842897468.png"></p>
<p>从这里我们大概可以看出一些端倪了。</p>
<p>computed 的依赖收集是由 ReactiveNode 完成的,想当然,effect 也是。</p>
<p>带着这一层关系,我们继续深入探索。</p>
<h3>create effect</h3>
<p>源码在 effect.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250612134835021-2070260772.png"></p>
<p>做了三件事:</p>
<ol>
<li>
<p>创建 EffectNode</p>
</li>
<li>
<p>监听 injector destroy,当 injector destroy 时一并 destroy 掉 effect</p>
</li>
<li>
<p>创建 EffectRef</p>
</li>
</ol>
<p>两个知识点:</p>
<ol>
<li>
<p>effect 依赖 Injector,还有 ChangeDetectionScheduler 和 EffectScheduler class provider</p>
</li>
<li>
<p>effect 可细分为 root effect 和 view effect</p>
<p>如果 injector 可以 inject 到 ViewContext,那表示是 view effect,反之就是 root effect。</p>
<p>view effect 是针对组件的,我们还没有学组件,所以这里先忽略 view effect。</p>
<p>我们只看 root effect 就好。</p>
</li>
</ol>
<h4>createRootEffect</h4>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250610014441850-160913297.png"></p>
<p>没啥特别的,就是把一堆东西装进 EffectNode 里。</p>
<p>EffectNode 就是 ReactiveNode 的一种</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250610014730917-91333448.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250610014740082-1449885388.png"></p>
<p>EffectNode 的细节我们先不看,先看看别的。</p>
<p>我们知道调用 effect 后,callback 会先执行一次,但目前为止,我们好像还没在源码中看到这一点。</p>
<p>不,其实是有的,下面这两句就是了</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250610021301763-1677906777.png"></p>
<h4>EffectScheduler.add</h4>
<p>EffectScheduler.add 长这样</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250610021451105-467860523.png"></p>
<p>注:Angular 有 built-in 的 EffectScheduler class provider,但这里我用模拟的来讲解,因为 Angular 的比较复杂,会涉及一些我们还没有掌握的知识,以后会再找机会讲解 Angular built-in 的&nbsp;EffectScheduler。</p>
<p>参数 schedulableEffect 就是 EffectNode</p>
<p>每次调用 effect,都会创建一个 EffectNode,这个 EffectNode 会被 add 进 EffectScheduler 里。</p>
<h4>EffectScheduler.remove</h4>
<p>有 add 就有 remove。</p>
<p>当 effect.destroy 时,EffectNode 会被 remove from&nbsp;EffectScheduler。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250610021847190-1468115737.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250610021914608-777439310.png"></p>
<h4>ChangeDetectionScheduler.notify</h4>
<p>add to&nbsp;EffectScheduler 还不会执行 callback。</p>
<p>这一句才会</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250610022326530-122706334.png"></p>
<p>ChangeDetectionScheduler.notify 长这样</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250620173356169-2119387483.png"></p>
<p>注:Angular 有 built-in 的 ChangeDetectionScheduler class provider,但这里我用模拟的来讲解,因为 Angular 的比较复杂,会涉及一些我们还没有掌握的知识,以后会再找机会讲解 Angular built-in 的 ChangeDetectionScheduler。</p>
<h4>EffectScheduler.flush &amp; EffectNode.run</h4>
<p>notify 之后就 flush (必须 delay 执行,不能同步,否则会报错,这是 Angular 的限制,原因不详)。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250610024455549-1520764418.png"></p>
<p>flush 就是把所有 EffectNode 拿出来 run 一遍。(注:只有 dirty 的才需要 run)</p>
<p>EffectNode 在创建之初默认是 dirty 的,所以肯定会 run 第一次。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250610031401497-418815013.png"></p>
<p>EffectNode.run 方法</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250610030306299-2046520701.png"></p>
<p>做了三件事:</p>
<ol>
<li>
<p>EffectNode.dirty 设置成 false</p>
</li>
<li>
<p>收集 effect 的依赖,这里和 computed 使用的函数是一样的 -- consumerBeforeComputation。</p>
</li>
<li>
<p>执行 callback</p>
</li>
</ol>
<p>好,到这里 create effect 的过程就完了。</p>
<p>effect &gt; create EffectNode &gt; EffectScheduler.add &gt; ChangeDetectionScheduler.notify &gt; create EffectRef &gt;&nbsp;EffectScheduler.flush &gt;&nbsp;EffectNode.run &gt; callback &gt; 依赖收集</p>
<h3>effect 的依赖收集</h3>
<p>effect 和 computed 都有依赖收集,但它们有一个关键区别:computed 是 pull-based,而 effect 是 push-based。</p>
<p>我们看看下面这个例子,注意 computation / callback 何时会执行。</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = signal('Derrick');
const lastName = signal('Yam');

// 不会执行 computation (因为是 pull-based)
const fullName = computed(() =&gt; firstName() + ' ' + lastName());

// 会执行 callback (因为是 push-based), 同时开始收集依赖
effect(() =&gt; console.log(firstName() + ' ' + lastName()), { injector });      

// 会执行 computation,开始收集依赖
console.log(fullName());

// 会执行 callback (因为是 push-based),不会执行 computation (因为是 pull-based)
firstName.set('Alex');

// 会执行 computation
console.log(fullName());</code></pre>
<h4>computed 是单向关系</h4>
<p>computation 执行时会收集依赖,firstName 和 lastName 的 ReactiveNode 会被 push 进 fullNameReactiveNode.producerNode。</p>
<p>除了记录依赖,还会记录依赖的 version -- firstName 和 lastName 的 ReactiveNode version 会被 push 进 fullNameReactiveNode.producerLastReadVersion。</p>
<p>依赖变更时,computation 不会执行 (因为是 pull-based)。</p>
<p>只有在调用 fullName getter 时,computation 才有可能会执行。它会先对比依赖的前后 version,判断是否有变更:如果有,就重新执行 computation;如果没有,就使用缓存值。</p>
<p>整个过程,fullName 知道 firstName 和 lastName (因为 fullNameReactive 里存有 firstName 和 lastName) 的存在,但 firstName 和 lastName 却不知道 fullName 的存在,我管这个叫 "单向关系"。</p>
<p>pull-based 只需要单向关系就可以了,因为当依赖 (firstName) 变更时它不需要去通知任何人 (emit to fullName),只需要等着被 (fullName) pull 就行了。</p>
<h4>effect 是双向关系</h4>
<p>effect 是 push-based,当依赖 (firstName) 变更时,它需要去执行 callback。</p>
<p>所以,firstName 需要知道 effect 的存在 (firstName ReactiveNode 要存有 EffectNode),它不能够像 computed 那样等着被 pull,因为 effect 不会 pull。</p>
<p>结论:effect 是双向关系</p>
<p>我们来看看在依赖收集过程中,双向关系是如何建立的。</p>
<p>effect callback 执行时,firstName getter 被调用,getter 里面又会调用&nbsp;producerAccessed 函数 (这里和 computation 一模一样,producerAccessed 函数上面我们也讲解过,只是没讲全)。</p>
<p>producerAccessed 函数源码在 graph.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250612012207074-832042741.png"></p>
<p>consumerIsLive 函数用来判断是双向 (e.g. effect) 还是单向 (e.g. computed),返回 true 就是双向。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250612012134513-866354147.png"></p>
<p>首先是看&nbsp;consumerIsAlwaysLive 属性 (还有一个是看 liveConsumerNode,这个我们下面再讲)</p>
<p>如果是 computed 的 fullNameReactiveNode,那它的 consumerIsAlwaysLive 会是 false。</p>
<p>因为 ReactiveNode 默认 consumerIsAlwaysLive 就是 false,而 ComputedNode 并没有 override 这个属性。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250612012437066-1237174951.png"></p>
<p>effect 的 EffectNode 就不同,它有 override ReactiveNode.consumerIsAlwaysLive 默认值</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250612023338271-334578009.png"></p>
<p>所以 EffectNode 会被&nbsp;consumerIsLive 函数判定为是双向关系,于是会执行 producerAddLiveConsumer 函数</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250612023735710-59157651.png"></p>
<p>producerAddLiveConsumer 函数</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250612024203136-1580008824.png"></p>
<p>到这里,我们就搞清楚了。</p>
<p>for computed</p>
<p>fullNameReactiveNode.producerNode 存有&nbsp;firstNameReactiveNode (fullName 知道 firstName)</p>
<p>这叫单向关系</p>
<p>for effect</p>
<p>EffectNode.producerNode 存有 firstNameReactiveNode (effect 知道 firstName)</p>
<p>同时 firstNameReactiveNode.liveConsumerNode 存有 EffectNode (firstName 也知道 effect)</p>
<p>这叫双向关系</p>
<p>也因为&nbsp;firstNameReactiveNode.liveConsumerNode 存有&nbsp;EffectNode,所以当 firstName 变更时,它才有能力去通知 EffectNode。</p>
<h4>effect 也不支持异步</h4>
<p>和 computed 一样,effect 的依赖收集也是同步的,不支持异步。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250614050811782-1196252695.png"></p>
<h4>untracked</h4>
<p>untracked 用来 skip 依赖收集</p>
<pre class="language-javascript highlighter-hljs"><code>effect(() =&gt; {
// 只有 firstName 会被依赖收集,lastName 不会
console.log(); // ['Derrick', 'Yam']
}, { injector });</code></pre>
<p>它的原理很简单</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250614010825370-2051045585.png"></p>
<p>就是把全局 consumer 暂时设置成 null,这样依赖收集就停止了。</p>
<h3>从依赖变更到 effect callback</h3>
<p>当依赖 (firstName) 变更时,effect callback 会被调用。</p>
<p>firstName.set &gt;&nbsp;signalSetFn &gt;&nbsp;signalValueChanged</p>
<p>signalValueChanged 函数的源码在 signal.ts&nbsp;(上面有讲解过,但没讲全)</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250612033442691-201440849.png"></p>
<p>producerNotifyConsumers 函数的源码在&nbsp;graph.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250612143351729-1352173780.png"></p>
<p>consumerMarkDirty 函数</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250612143657924-514163673.png"></p>
<p>EffectNode.consumerMarkedDirty</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250612034523185-185822892.png"></p>
<p>我模拟的 EffectScheduler 没有实现 schedule 方法,但 Angular built-in 是有的,以后有机会再讲解。</p>
<p>notity 上面讲解过了,它后续的流程是:notity &gt; delay &gt; EffectScheduler.flush &gt; EffectNode.run &gt; effect callback。</p>
<p>好,以上就是从依赖变更后,到执行 effect callback 的相关源码。</p>
<h3>细节补充 の 当 effect 依赖 computed</h3>
<p>上一 part 给的例子是 effect 依赖 firstName。</p>
<p>它只有一层,比较容易理解。</p>
<p>这里我们看一个双层的例子,把之前跳过的源码给补齐。</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = signal('Derrick');
const lastName = signal('Yam');

const fullName = computed(() =&gt; firstName() + ' ' + lastName());

effect(() =&gt; console.log(fullName()), { injector });      </code></pre>
<p>effect 的 callback 依赖 fullName,而 fullName 是 computed 它又依赖 firstName,所以是两层依赖关系。</p>
<p>按照逻辑来讲,当 firstName 变更,它会导致 fullName 也变更,最后导致 effect callback 被执行。</p>
<p>好,我们来看看相关源码</p>
<p>首先是 effect callback 第一次执行:调用 fullName getter &gt;&nbsp;执行 fullName computation &gt; 收集依赖 firstName 和 lastName</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250613011859663-964110895.png"></p>
<p>同时也正在执行 effect 的依赖收集</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250613012322932-1622449688.png"></p>
<p>producerAddLiveConsumer 函数</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250613013443319-1286148228.png"></p>
<p>producerAddLiveConsumer 会被调用两次</p>
<p>第一次 node 是 fullNameReactiveNode,consumer 是 EffectNode,结果是 fullNameReactiveNode.liveConsumerNode = </p>
<p>第二次 node 是 firstNameReactiveNode,consumer 是 fullNameReactiveNode,结果是 firstNameReactiveNode.liveConsumerNode = </p>
<p>到这边,第一轮的 effect callback 依赖收集就完成了。</p>
<p>第二轮的 effect callback 有一点不同</p>
<p>effect callback &gt; 调用 fullName getter &gt; 执行 fullName computation &gt; 开启依赖收集 &gt; 调用 firstName getter</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250613014547937-2022566995.png"></p>
<p>consumerIsLive 函数</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250613014632344-1876251200.png"></p>
<p>fullNameReactiveNode 不是 consumerIsAlwaysLive,但此时它的 liveConsumerNode 是 (第一次执行 effect callback 时 push 进去的),所以 length &gt; 0 依然会继续执行 producerAddLiveConsumer。</p>
<p>于是&nbsp;firstNameReactiveNode.liveConsumerNode = 。</p>
<p>同时也正在执行 effect 的依赖收集</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250613015221473-1447227572.png"></p>
<p>第一次 effect callback,fullNameReactiveNode.liveConsumerNode 是空的,会进入 if condition;</p>
<p>第二次 effect callback,fullNameReactiveNode.liveConsumerNode 变成了 ,所以不会进入 if condition。</p>
<p>总的来说,第二次执行 effect callback 做的事情和第一次是一样的,只是&nbsp;firstNameReactiveNode.liveConsumerNode = 这部分的逻辑换了地方实现而已。</p>
<h3>Cleanup ReactiveNode.liveConsumerNode</h3>
<p>effect 和依赖是双向关系,你中有我,我中也有你。</p>
<p>比如说</p>
<p>EffectNode.producerNode = </p>
<p>firstNameReactiveNode.liveConsumerNode = </p>
<h4>cleanup&nbsp;liveConsumerNode on effect destroyed</h4>
<p>当 effect 被 destory 时,依赖 (firstNameReactiveNode.liveConsumerNode) 必须清除遗留的 EffectNode。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250613025736264-222834406.png"></p>
<p>consumerDestroy 函数</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250613031515234-1860037221.png"></p>
<p>producerRemoveLiveConsumerAtIndex 函数</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250613031638176-1651675007.png"></p>
<p>这个移除方式还挺奇葩的,它会把最后一个 ReactiveNode 覆盖掉想删除的,然后再把最后一个 ReactiveNode remove from array。</p>
<p>比如说</p>
<p>原本 firstNameReactiveNode.liveConsumerNode 是:</p>
<p>effect1 被 destroy</p>
<p>会变成:</p>
<p>再变成:</p>
<p>为什么它要用这种打乱顺序的方式做移除,我也不清楚。</p>
<p>不过即便顺序被打乱了也不会影响 effect callback 执行的顺序,因为 callback 执行顺序是依据 EffectScheduler.add 时的顺序决定的。</p>
<h4>cleanup&nbsp;liveConsumerNode&nbsp;on 依赖关系改变</h4>
<p>依赖关系可以是动态变化的,比如说:第一次执行 effect callback,firstName 是依赖;但第二次执行,firstName 可能就不是依赖了。</p>
<p>看例子</p>
<pre class="language-javascript highlighter-hljs"><code>const firstName = signal('Derrick');
const lastName = signal('Yam');
const status = signal('completed');

effect(() =&gt; {
if(status() === 'completed') {
    console.log(firstName());
}
else {
    console.log(lastName());
}
}, { injector });      

window.setTimeout(() =&gt; status.set('canceled'), 1000);</code></pre>
<p>这种情况下也需要 cleanup liveConsumerNode</p>
<p>第二次执行 effect callback &gt; 调用 lastName getter &gt; 执行&nbsp;producerAccessed&nbsp;函数</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250613034528307-1874062075.png"></p>
<p>还有 effect callback 结束后的&nbsp;consumerAfterComputation&nbsp;函数,它会把多余的依赖 (第一次有,第二次没有) 删除掉,同时也需要做 clean&nbsp;liveConsumerNode。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250612204154402-1588740225.png"></p>
<p>好,effect 的相关源码就逛到这儿。</p>
<p>&nbsp;</p>
<h2>Angular resource</h2>
<p>resource 是用来处理 asynchronous variable 的。</p>
<h3>Asynchronous variable</h3>
<p>什么是 async variable?</p>
<p>async variable 指的是:一个变量,它需要经过一个异步过程才能得出值。</p>
<p>看例子</p>
<pre class="language-javascript highlighter-hljs"><code>let products: Product[] = [];

fetch('https://dummyjson.com/products').then(async response =&gt; {
const responseData = await response.json() as { products: Product[] };
products = responseData.products;
});</code></pre>
<p>products (variable) 的初始值是 empty array。</p>
<p>接着 ajax (异步过程),等&nbsp;ajax 回来后,它的 response 成了 products 值。</p>
<p>这里 products 就是一个 async variable。</p>
<h3>Async variable 的特性</h3>
<p>为什么 async variable 需要 resource 来处理呢?</p>
<p>具体又是处理什么?</p>
<p>我们看一看 async variable 有哪些特性,就能明白了。</p>
<h4>异步过程后的变更</h4>
<p>async variable 有一些特色,比如:异步过程之后,它一定会变更。</p>
<p>注:当然,异步过程之后的值也有可能碰巧和初始值一样,但这里我们就不去扯这种状况了。绝大部分情况下,异步过程之后会变更,就是了。</p>
<p>既然它一定会变更,那使用 Signals 就很贴切。</p>
<pre class="language-javascript highlighter-hljs"><code>const products = signal&lt;Product[]&gt;([]); // products 是 Signals

fetch('https://dummyjson.com/products').then(async response =&gt; {
const responseData = await response.json() as { products: Product[] };
products.set(responseData.products);
});</code></pre>
<h4>loading &amp; resolved 阶段</h4>
<p>除了一定会变更以外,"async" 还会衍生出很多事情。</p>
<p>比如:loading &amp; resolved 阶段</p>
<p>我们不只关心 async variable 的值,我们也关心它当前处在什么阶段 -- 是在异步过程中 (loading),还是已经过了异步过程 (resolved)。</p>
<p>由于初始值和异步过程后的新值有可能是相同的,所以我们无法仅凭新旧值来判定它当前是在 loading 还是 resolved 阶段。</p>
<p>我们需要增加一个 status 变量来表示</p>
<pre class="language-javascript highlighter-hljs"><code>const products = signal&lt;Product[]&gt;([]);
const status = signal&lt;'loading' | 'resolved'&gt;('loading'); // 增加一个 status 变量,表示当前是 loading 或 resolved 阶段

fetch('https://dummyjson.com/products').then(async response =&gt; {
const responseData = await response.json() as { products: Product[] };
status.set('resolved'); // 更新 status 阶段
products.set(responseData.products);
});</code></pre>
<p>异步过程中是 loading,异步过程之后是 resolved。(注:这个 status 也会变更,因此也得使用 Signals)</p>
<h4>error 状态</h4>
<p>除了 loading,resolved,还有一种情况是出错。</p>
<p>比如 ajax response status 401 代表无访问权限,需要登入。</p>
<p>此时 status 应该要是 'error',同时还需要增加一个变量来表示具体出了什么问题。</p>
<pre class="language-javascript highlighter-hljs"><code>const products = signal&lt;Product[]&gt;([]);
const status = signal&lt;'loading' | 'resolved' | 'error'&gt;('loading');
const error = signal&lt;Error | null&gt;(null); // 增加一个 error detail 变量

fetch('https://dummyjson.com/products').then(async response =&gt; {

if (response.status === 401) {
    status.set('error'); // 更新 status
    error.set(new Error('Authentication Error')); // 写入 error detail
    return;
}

const responseData = await response.json() as { products: Product[] };
status.set('resolved');
products.set(responseData.products);
});</code></pre>
<h4>async computed variable</h4>
<p>试想想,如果 ajax 依赖 query params 会怎样?</p>
<pre class="language-javascript highlighter-hljs"><code>// 没有 query params
fetch('https://dummyjson.com/products');

// 有 query params
fetch('https://dummyjson.com/products?select=title,price&amp;sortBy=price&amp;order=asc&amp;limit=10&amp;skip=10');</code></pre>
<p>而且,随着 query params 变更,products 还需要再次 ajax 去拿新值。</p>
<p>这是不是有点像 async computed variable 的感觉?</p>
<p>像,但有一些不同。</p>
<p>computed 是 pull-based,只有在读取 computed variable 值时,computation 才会执行。</p>
<p>如果 async computed 也采用 pull-based 的话,由于 computation 是异步的,因此每次读取 computed variable 时就必须要 await,这似乎不太妥当啊。</p>
<p>所以,采用 push-based 会更适合 async computed。(push-based computed 概念可以参考上面的 -- 用 RxJS 实现 computed variable)</p>
<pre class="language-javascript highlighter-hljs"><code>const products = signal&lt;Product[]&gt;([]);

// 一些让 user 可以改变的 query params
const sortBy = signal&lt;keyof Product&gt;('price');
const order = signal&lt;'asc' | 'desc'&gt;('asc');
const limit = signal(5);
const skip = signal(0);

// 用 computed 把 query params convert to query string
const queryString = computed(() =&gt; {
const queryParams = new URLSearchParams({
    select: 'title,price',
    sortBy: sortBy(),
    order: order(),
    limit: limit().toString(),
    skip: skip().toString()
});
return queryParams.toString();
});

// 用 effect 去监听 query string
// 每当 query string 变更就 ajax 更新 products
effect(async () =&gt; {
const response = await fetch(`https://dummyjson.com/products?${ queryString() }`);
const responseData = await response.json() as { products: Product[] };
products.set(responseData.products);
console.log('new products', products());
}, { injector });

// 测试一秒后翻页
window.setTimeout(() =&gt; {
console.log('一秒后,去 page 2');
skip.set(5);
}, 1000);</code></pre>
<p>效果</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250615202221441-596930172.png">&nbsp;</p>
<h4>stream &amp; reload</h4>
<p>除了 query params 变更会导致 products 变更以外,后端数据 (e.g. 数据库) 变更也会导致 products 变更。</p>
<p>有两种解法:</p>
<p>第一种是用 WebSocket&nbsp;保持链接,后端持续返回 (push) 最新的 products,这个叫 stream。</p>
<p>第二种是前端 reload (pull) 最新的 products,比如当用户点击 reload button 时就去 reload。</p>
<p>这部分实现代码我就不写了,大家知道有这样的情况就好。</p>
<h4>destroy &amp;&nbsp;abort</h4>
<p>试想想,如果 async variable 正在 loading (e.g. 发送 ajax),可前端已经不再需要这个 variable 了 (可能是因为用户做了其它操作)。</p>
<p>那这个 ajax 是不是应该要被 abort 掉?</p>
<p>上面用来监听 query params 的 effect 是不是应该要被 destroy 掉?</p>
<p>当然是啊。</p>
<p>这部分实现代码我就不写了,大家知道有这样的情况就好。</p>
<h4>switchMap &amp;&nbsp;exhaustMap</h4>
<p>试想想,当 query params 变更后,ajax 再次发送,此时如果 query params 又变更了该怎么处理?</p>
<p>再来,当 reload 后,ajax 再次发送,此时又 reload 了该怎么处理?</p>
<p>有两种常见的处理手法:</p>
<p>第一种是&nbsp;switchMap&nbsp;(RxJS 术语),abort 掉之前的 ajax,重新再发 ajax。</p>
<p>第二种是&nbsp;exhaustMap&nbsp;(RxJS 术语),保留之前的 ajax,无视这一次的 reload 或 query params 变更。</p>
<p>这部分实现代码我就不写了,大家知道有这样的情况就好。</p>
<h4>总结</h4>
<p>async variable 多了很多状况需要处理。</p>
<p>虽然这些都可以透过 signal, computed, linkedSignal, effect 加以解决,但代码实在太多,太不优雅。</p>
<p>所以 Angular 搞了一个上层封装 -- resource。</p>
<p>是的,resource 只是 signal, computed, linkedSignal, effect 的上层封装而已,并没有引入新的概念。</p>
<p>接下来我们一同看看,如何用 Angular resource 来处理 async variable 🚀。</p>
<h3>resource 函数 &amp; ResourceRef 对象</h3>
<p>在使用 resource 函数之前,我们需要做一些 setup。</p>
<h4>Injector &amp; Provider</h4>
<pre class="language-javascript highlighter-hljs"><code>import { ɵINTERNAL_APPLICATION_ERROR_HANDLER, ɵPendingTasksInternal, PendingTasks, Injector, ɵChangeDetectionScheduler, ɵEffectScheduler } from "@angular/core";

const injector = Injector.create({
providers: [
    { provide: ɵINTERNAL_APPLICATION_ERROR_HANDLER, useValue: () =&gt; {} },
    ɵPendingTasksInternal,
    PendingTasks,
   
    { provide: ɵChangeDetectionScheduler, useClass: ChangeDetectionScheduler },
    { provide: ɵEffectScheduler, useClass: EffectScheduler }
]
});</code></pre>
<p>resource 底层使用 effect,而 effect 需要 Injector, ChangeDetectionScheduler 和 EffectScheduler,所以 resource 也需要。</p>
<p>此外,resource 还需要一些额外的 providers -- INTERNAL_APPLICATION_ERROR_HANDLER, PendingTasksInternal 和 PendingTasks。</p>
<p>提醒:是因为我把 effect / resource 脱离 Angular 框架使用,才需要搞 Injector 和 Provider;如果是在 Angular 框架内使用,就不必这么麻烦。</p>
<h4>异步过程后的变更</h4>
<p>直接上代码</p>
<pre class="language-javascript highlighter-hljs"><code>const products = resource({
injector,
defaultValue: [], // 初始值
// 异步获取新值的方法
loader: async () =&gt; {
    const response = await fetch('https://dummyjson.com/products'); // ajax
    const responseData = await response.json() as { products: Product[] };
    return responseData.products // 返回新值
}
});

// 测试效果
effect(() =&gt; {
// 第一次 (ajax 前) 是 empty array []
// 第二次 (ajax 后) 就有 products 了
console.log(products.value());
}, { injector });</code></pre>
<p>调用 resource 函数,它会返回一个对象,对象里装了很多东西,其中一个是 value,也就是 async variable 的值。</p>
<p>透过 effect,我们可以监听这个值的变化。</p>
<h4>loading &amp; resolved 阶段</h4>
<pre class="language-javascript highlighter-hljs"><code>effect(() =&gt; {
// 第一次 (ajax 前) 是 'loading'
// 第二次 (ajax 后) 是 'resolved'
console.log(products.status());

// 第一次 (ajax 前) 是 true
// 第二次 (ajax 后) 是 false
console.log(products.isLoading());
}, { injector });</code></pre>
<p>想知道当前是不是正在异步,可以查看 status 或 isLoading 属性。</p>
<h4>error 状态</h4>
<pre class="language-javascript highlighter-hljs"><code>const products = resource({
injector,
defaultValue: [],
loader: async () =&gt; {
    throw new Error('Authentication Error'); // 异步过程中 throw error
    const response = await fetch('https://dummyjson.com/products');
    const responseData = await response.json() as { products: Product[] };
    return responseData.products
}
});

// 测试效果
effect(() =&gt; {
// 第一次是 undefined,第二次是 'Authentication Error'
console.log(products.error()?.message);

// 第一从是 'loading',第二次是 'error'
console.log(products.status());

// 第一次是 empty array [],第二次会报错
console.log(products.value());
}, { injector });</code></pre>
<p>注:error 的状态下,读取 value 会报错哦。</p>
<p>Angular resource 是一个上层封装,因此它会处理各种繁琐的小细节。</p>
<p>但这些细节的处理方式未必符合每个人的预期。比如说,发生 error 时读取 value,到底该返回 undefined, null, current value, 还是直接报错?其实每个人的想法可能都不太一样。</p>
<p>但没办法,Angular 目前没有提供任何自定义的方式,我们只能接受它的处理方式,或者想办法去 hacking 它。</p>
<p>类似的小细节处理还有很多,大家自己注意咯。</p>
<h4>async computed variable</h4>
<pre class="language-javascript highlighter-hljs"><code>// 一些让 user 可以改变的 query params
const sortBy = signal&lt;keyof Product&gt;('price');
const order = signal&lt;'asc' | 'desc'&gt;('asc');
const limit = signal(5);
const skip = signal(0);

const products = resource({
injector,
defaultValue: [],
params: () =&gt; {
    const queryParams = new URLSearchParams({
      select: 'title,price',
      sortBy: sortBy(),
      order: order(),
      limit: limit().toString(),
      skip: skip().toString()
    });
    return queryParams.toString();
},
loader: async ({ params: queryString }) =&gt; {
    const response = await fetch(`https://dummyjson.com/products?${ queryString }`);
    const responseData = await response.json() as { products: Product[] };
    return responseData.products
}
});

window.setTimeout(() =&gt; limit.set(10), 1000);

// 测试效果
effect(() =&gt; {
// 第一次是 0
// 第二次是 5
// 第三次是 0
// 第四次是 10
console.log(products.value().length);
}, { injector });</code></pre>
<p>几个知识点:</p>
<ol>
<li>
<p>params 和 linkedSignal 的 source 是一样的概念</p>
<p>params 函数执行的时候会收集依赖,依赖变更会导致 loader 重跑</p>
</li>
<li>
<p>loader 透过参数可以拿 params 来使用</p>
</li>
<li>
<p>loader 函数执行时,不会依赖收集</p>
<p>computed 的 computation、effect 的 callback、linkedSignal 的 source &amp; computation、resource 的 params,这些函数在执行时都会收集依赖。</p>
<p>但是 loader 不会,why?</p>
<p>我也不晓得,猜测是因为 loader 是异步,而依赖收集是同步,可能 Angular 团队怕造成混乱所以才允许吧。</p>
</li>
<li>
<p>当 params 变更时,除了会重跑 loader 以外,value 也会被 reset to defaultValue or undefined。</p>
也因为这样,上面才会 console.log 4 次,第三次 length === 0 就是因为 value 被 reset 成 empty array []。
<p>这也是一个小细节处理,未必符合大家预期,但这是 Angular 的选择</p>
</li>
<li>params 变更导致的 loader 重跑,也会让 resource status 变成 'loading'</li>
</ol>
<h4>stream &amp; reload</h4>
<pre class="language-javascript highlighter-hljs"><code>const products = resource({
injector,
defaultValue: [],
// loader 改成 stream
stream: async () =&gt; {
    const products = signal&lt;ResourceStreamItem&lt;Product[]&gt;&gt;({ value: [] });
    const response = await fetch('https://dummyjson.com/products');
    const responseData = await response.json() as { products: Product[] };
    products.set({ value: responseData.products });

    // 模拟 WebSocket,10 秒后,数据库变更了, 剩下 5 个 products
    window.setTimeout(() =&gt; {
      products.set({ value: [...responseData.products.slice(0, 5)] });

      // 模拟报错 Error
      // products.set({ error: new Error('Error') });
    }, 10000);
   
    // stream 要返回 Signal
    return products;
},
});

// 测试效果
effect(() =&gt; {
// 第一次是 0
// 第二次是 30 (数据库有 30 个 products)
// 第三次是 5(数据库剩 5 个 products)
console.log(products.value().length);
}, { injector });</code></pre>
<p>loader 返回 Promise&lt;Value&gt;</p>
<p>stream 则返回 Promise&lt;Signal&lt;ResourceStreamItem&lt;Value&gt;&gt;&gt;</p>
<p>ResourceStreamItem 是一个对象,它可以表达 value 也可以表达 error</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250616193846712-387735650.png"></p>
<p>reload 是这样</p>
<pre class="language-javascript highlighter-hljs"><code>const products = resource({
injector,
defaultValue: [],
loader: async () =&gt; {
    const response = await fetch('https://dummyjson.com/products');
    const responseData = await response.json() as { products: Product[] };
    return responseData.products;
},
});

window.setTimeout(() =&gt; {
products.reload(); // reload resource
}, 1000);

// 测试效果
effect(() =&gt; {
// 第一次是 0
// 第二次是 30
// 第三次是 30 (如果数据库改变了,那这次的 length 也会改变)
console.log(products.value().length);
}, { injector });</code></pre>
<p>reload 和 params 变更导致的 load 是不一样的哦。</p>
<p>reload 阶段,status 是 'reloading' 而不是 'loading'。</p>
<p>reload 不会 reset value to defaultValue or undefined,它会保持当前的值。(注:这也是一个小细节处理,未必符合大家预期,但这是 Angular 的选择)</p>
<p>另外,resource.isLoading() 则不管是 'loading' 还是 'reloading' 都返回 true。</p>
<h4>destroy &amp; abort</h4>
<pre class="language-javascript highlighter-hljs"><code>const products = resource({
injector,
defaultValue: [],
loader: async ({ abortSignal }) =&gt; {

    // 在 ajax 后立刻 destroy resource
    queueMicrotask(() =&gt; products.destroy());

    try {
      // 通过参数拿到 abortSignal,传递给 fetch,当 resource destroy 时,fetch 就会被 abort 掉
      const response = await fetch('https://dummyjson.com/products', { signal: abortSignal })
      const responseData = await response.json() as { products: Product[] };
      return responseData.products;
    } catch (error) {
      if (error instanceof DOMException &amp;&amp; error.name === 'AbortError') {
      console.log(error.message); // 'signal is aborted without reason'
      }
      return;
    }
},
});</code></pre>
<p>resource 内部会使用 effect 监听 params 变更,当 resource destroy 时会一并 destroy 内部的 effect。</p>
<p>loader / stream 可以透过参数获取到&nbsp;AbortSignal&nbsp;来做 cleanup 或资源释放。</p>
<p>注1:AbortSignal 不是 Angular Signals 哦,它是 DOM API,常用来 abort fetch 请求。</p>
<p>注2:resource destroy 了就无法再激活了哦。</p>
<h4>switchMap &amp; exhaustMap</h4>
<p>当 params 变更后,异步过程 (e.g. ajax) 再次执行,此时如果 params 又变更了该怎么处理?</p>
<p>Angular 的做法是&nbsp;switchMap&nbsp;(RxJS 术语),abort 掉之前的异步过程 (e.g. ajax),重新执行新的异步过程。</p>
<p>当 reload 后,异步过程再次执行,此时又 reload 了该怎么处理?</p>
<p>Angular 的做法是&nbsp;exhaustMap (RxJS 术语),保留之前的异步过程,让它继续执行到完,并 skip 掉这一次的 reload。</p>
<pre class="language-javascript highlighter-hljs"><code>const succeeded: boolean = products.reload();</code></pre>
<p>reload 返回 true 代表有 reload 到,返回 false 代表被 skip 掉了。</p>
<p>switchMap or exhaustMap 这两个小细节处理,未必符合大家预期,但这是 Angular 的选择。</p>
<h4>resource is writable</h4>
<p>resource 可以用来实现 async computed variable,但不代表它是 readonly 哦。</p>
<p>就如同 linkedSignal 那样,只要维持好秩序,computation 和 writable 是可以兼容的。</p>
<p>resource 可以 set 也可以 update,和 linkedSignal 一样。</p>
<p>每当 reload,params 变更 或者 stream push 都会再覆盖掉 manual set 的 value。</p>
<pre class="language-javascript highlighter-hljs"><code>products.set([]);
products.update(oldProducts =&gt; oldProducts.slice(0, 2));

// 透过 value 做 set 和 update 也可以,效果一模一样
products.value.set([]);
products.value.update(oldProducts =&gt; oldProducts.slice(0, 2));</code></pre>
<h4>resource status</h4>
<p>resource 一共有 6 种 status</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250616180451954-495826611.png"></p>
<ol>
<li>
<p>idle</p>
<p>当 resource 被 destroy 以后,status 将会是 'idle'</p>
<p>或者是当 params 返回 undefined 时,status 也会是 'idle'</p>
</li>
<li>
<p>error</p>
<p>当异步过程出错时,status 将会是 'error'</p>
</li>
<li>
<p>loading</p>
<p>resource 的初始 status 是 'loading'</p>
<p>在异步过程中 (由 params 变更引发),status 将会是 'loading'</p>
</li>
<li>
<p>reloading</p>
<p>在异步过程中 (由调用 reload 方法所引发),status 将会是 'reloading'</p>
</li>
<li>
<p>resolved</p>
<p>当完成异步过程 (并且没有出错),status 将会是 'resolved'</p>
</li>
<li>
<p>local</p>
<p>当 resource manual set / update value 后,status 将会是 'local'。</p>
</li>
</ol>
<p>常见的 status 过程有这些:</p>
<p>resource() &gt; loading &gt; resolved / error</p>
<p>resource() &gt; loading &gt; resolved &gt; <strong>destroy</strong> &gt; idle</p>
<p>resource() &gt; loading &gt; resolved &gt; <strong>params 变更</strong> &gt; loading &gt;&nbsp;resolved</p>
<p>resource() &gt; loading &gt; resolved &gt; reload &gt; reloading &gt;&nbsp;resolved</p>
<p>resource() &gt; loading &gt; resolved &gt; <strong>set value&nbsp;</strong>&gt; local &gt;&nbsp;<strong>params 变更</strong> &gt; loading &gt; resolved</p>
<h4>resource value &amp; hasValue</h4>
<p>有一些操作会导致 value 自动变化,这可能不符合我们的预期,但这是 Angular 的选择,我们只能记下。</p>
<ol>
<li>
<p>当 loading 时,value 会变成 defaultValue (我们透过 options define 的) or undefined</p>
<p>注:只有 loading 哦,reloading 不会</p>
</li>
<li>
<p>当 idle 时,value 会变成 defaultValue or undefined</p>
</li>
<li>
<p>当 error 时,读取 value 会报错</p>
</li>
</ol>
<p>上面是比较奇葩的状况,下面是相对正常的情况:</p>
<ol>
<li>
<p>当 reloading 时,value 不会发生任何变化</p>
</li>
<li>
<p>当 resolved 时,value 会是异步过程返回的值</p>
</li>
<li>
<p>当 local 时,value 会是 resource.set 进去的值。</p>
</li>
</ol>
<p>另外 resource 有一个 hasValue 方法</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250616184758631-1491806197.png"></p>
<p>当 error 时,代表没有 value。</p>
<p>当 value === undefined 时代表没有 value。</p>
<p>其它情况一律代表有 value。(包括 null 也算是有 value 哦)</p>
<h4>resource 的潜规则</h4>
<p>上面一路下来提到了好几个 "潜规则",这里我们统一记入一下:</p>
<ol>
<li>
<p>params 变更,value 会变成 default value or undefined;reload 则 value 会保持不变。</p>
</li>
<li>params 变更是 swtichMap 行为,reload 是&nbsp;exhaustMap 行为。</li>
<li>
<p>当 error 时,读取 Resource.value 会报错</p>
</li>
<li>当 idle 时 (destroyed 以后),Resource.value 会是 default value or undefined。</li>
</ol>
<h3 style="font-size: 14px">resource 和 linkedSignal 的相似与不同</h3>
<p>resource 有点像是 async 版的 linkedSignal,但又有一些明显的不同,我们来比对一下</p>
<pre class="language-javascript highlighter-hljs"><code>const limit = signal(0);

const product1 = resource({
injector,
params: limit,
loader: async ({ params, abortSignal, previous }) =&gt; {
    console.log(params);
    console.log(previous.status);
    return limit;
},
});

const product2 = linkedSignal({
source: limit,
computation: (source, prev) =&gt; {
    console.log(source);
    console.log(prev?.source);
    console.log(prev?.value);
    return limit;
}
});</code></pre>
<p>相同:</p>
<ol>
<li>
<p>resource params 和 linkedSignal source 用法一样</p>
</li>
<li>linkedSignal 和 resource 都可以 set value 同时又有 computed 概念 (虽然 computed 有许多区别)</li>
</ol>
<p>不同:</p>
<ol>
<li>
<p>computation 是 sync;loader 是 async</p>
</li>
<li>
<p>computation 会依赖收集;loader 不会依赖收集</p>
</li>
<li>
<p>computation 的 prev 可以拿到 prev.source 和 prev.value;loader 的 prev 只能拿到 status 而已</p>
<p>个人觉得,如果 loader 可以拿到 prev params 和 value 会更灵活,希望未来 Angular 会加上吧。</p>
</li>
<li>
<p>linkedSignal 是 pull-based;resource 是 push-based</p>
<p>computation 只有当 linkedSignal 被读取或写入时才会执行;而 loader 会立即执行第一次,还有每当 params 变更或 reload 时也都会执行。</p>
</li>
</ol>
<p>我觉得它两的不同大于相同,所以不建议把 resource 当作 async linkedSignal 看待。</p>
<h3>总结</h3>
<p>resource&nbsp;是 signal, computed, linkedSignal, effect 的上层封装,专门用来处理 asynchronous variable。</p>
<p>在真实项目中,但凡遇到 async variable,我们都可以优先考虑使用 resource。</p>
<p>虽然它封装得比较上层,又没有开放 override / extends,因此未必能满足所有需求,但总体来说,还是很好用的。</p>
<p>&nbsp;</p>
<h2>逛一逛 Angular resource 源码</h2>
<p>resource 是 signal, computed, linkedSignal, effect 的上层封装。</p>
<p>我们逛 resource 的源码,除了能深入理解 resource 本身,也能借此看看 Angular 团队是如何具体使用 signal、computed、linkedSignal、effect 的。</p>
<h3>resource 函数</h3>
<p>resource 函数的源码在 resource.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250618165230656-945310861.png"></p>
<p>4 个知识点:</p>
<ol>
<li>
<p>ResourceRef &amp;&nbsp;ResourceImpl</p>
<p>调用 resource 函数会返回&nbsp;ResourceRef interface</p>
具体实现这个 interface 的是 class&nbsp;ResourceImpl
<p>结论:调用 resouce 函数会返回&nbsp;ResourceImpl 对象</p>
</li>
<li>
<p>default params</p>
<p>在调用 resource 时,如果我们没有提供 options.params</p>
<pre class="language-javascript highlighter-hljs"><code>const products = resource({
// 没有提供 params
loader: async () =&gt; ''
});</code></pre>
<p>resource 内部会替我们补上,等价于</p>
<pre class="language-javascript highlighter-hljs"><code>const products = resource({
// 自动补上 params
params: () =&gt; null,
loader: async () =&gt; ''
});</code></pre>
</li>
<li>
<p>loader to stream</p>
<p>在调用 resource 时,我们可以选择提供 loader 或 stream</p>
<pre class="language-javascript highlighter-hljs"><code>const products = resource({
// 提供 loader
loader: async () =&gt; {
    if (Math.random() &gt; 0.1) {
      return '';
    }
    else {
      throw new Error('Error');
    }
}
});</code></pre>
<p>resource 内部会替我们把 loader 强转成 stream</p>
<pre class="language-javascript highlighter-hljs"><code>const products = resource({
// 强转成 stream
stream: async () =&gt; {
    if (Math.random() &gt; 0.1) {
      return signal({ value: '' });
    }
    else {
      return signal({ error: new Error('Error') })
    }
}
});</code></pre>
<p>相关源码在 getLoader 函数</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250618163453188-374812118.png"></p>
</li>
<li>
<p>Injector</p>
<p>resource 内部会使用到 effect,effect 需要 Injector 所以 resource 也需要。</p>
</li>
</ol>
<h3>ResourceImpl&nbsp;constructor</h3>
<p>resource 函数返回的 ResourceImpl 对象是 Angular resource 概念的核心。</p>
<p>我们从它的 constructor 看起</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250621033501564-634843965.png"></p>
<p>参数一 request 其实就是 options.params 来的,以前叫 request,v20 改名成 params,但源码似乎还没有改。</p>
<p>参数二是 loaderFn,它是 optons.stream 或则 options.loader 被强转后的 stream。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250618174726153-1619498043.png"></p>
<p>参数还有几个,但不太重要,我就不一一介绍了。我们继续看 constructor 内容</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250620232754729-1367208311.png"></p>
<p>BaseWritableResource 是 ResourceImpl 的父类</p>
<p>run 父类 constructor 时传入了一个用 computed 创建的 Signal 对象。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250621040135639-871888316.png"></p>
<p>这个 Signal 被用作 BaseWritableResource.value,并且扩张了 set、update 方法。</p>
<p>注:下面我会把 BaseWritableResource 和 ResourceImpl 的属性/方法简称为 Resource,因为我们不需要分那么细,比如&nbsp;BaseWritableResource.value 会被简称为 Resource.value。</p>
<p>也就是说</p>
<pre class="language-javascript highlighter-hljs"><code>const products = resource({
loader: async () =&gt; ''
});

// 这个 value 其实时 computed 来的
products.value();

// 但它被扩展了 set 和 update 方法
products.set('a');</code></pre>
<p>我们调用的 Resource.value 其实是 computed 来的,但是它又被强行添加了 set 和 update 方法,所以才可以写入值。</p>
<p>呃...writable computed 怎么不是使用 linkedSignal 呢?</p>
<p>因为 Resource.value 本身并不负责 set 和 update 的具体实现</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250618180414223-855870980.png"></p>
<p>它只是借用了 Resource.set 和 update 方法而已。</p>
<p>也就是说</p>
<pre class="language-javascript highlighter-hljs"><code>products.value.set('');
products.value.update(() =&gt; '');</code></pre>
<p>等价于</p>
<pre class="language-javascript highlighter-hljs"><code>products.set('');
products.update(() =&gt; '');</code></pre>
<p>我们暂且不管 BaseWritableResource 的属性和方法,也不去深究 Resource.value 的 computation 是如何实现的,先继续看 ResourceImpl 的构造函数。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250618203019955-1650643865.png"></p>
<p>extRequest (request 是旧名字,v20 改成 params 了) 是一个 linkedSignal,link to params,自身又扩展了 reload count 属性。</p>
<p>我们暂且不管它 reload 的细节,先继续看 ResourceImpl 的构造函数。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250621135438163-1667670872.png"></p>
<p>state 也是 linkedSignal,link to extRequest (也就是 params 和 reload)。</p>
<p>computation 的返回值是一个对象,里面有 params, status, previousStatus, stream,这个 stream 就是 loaderFn 的返回值。</p>
<p>state 主要是负责 Resource.status 和 Resource.value。</p>
<p>我们暂且不管&nbsp;state 的 computation 实现细节,先继续看 ResourceImpl 的构造函数。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250621135556319-57495671.png"></p>
<p>最后做了两件事:</p>
<ol>
<li>
<p>设置了一个 effect, 具体 callback 我们先不管</p>
</li>
<li>
<p>监听 injector destroy,当 destroy 时一并 destroy ResourceImpl</p>
</li>
</ol>
<p>自此,resource 函数就执行完了,我们会得到一个 ResourceRef (具体由 ResourceImpl 实现) 对象。</p>
<h3>ResourceImpl effect callback</h3>
<p>接下来就是等待 EffectScheduler 安排执行 effect callback。</p>
<p>先理一理:</p>
<ol>
<li>
<p>request 是旧名字,v20 后改名叫 params 了,但源码还没有跟上</p>
</li>
<li>
<p>loaderFn 就是 options.stream</p>
</li>
<li>Resource value 是 computed。(注:computation 细节我们还没看)</li>
<li>
<p>extRequest 是 linkedSignal,跟 params 和 reload 有关。(注:computation 细节我们还没看)</p>
</li>
<li>
<p>state 是 linkedSignal,跟 params, reload, status, stream (oaderFn return) 有关。(注:computation 细节我们还没看)</p>
</li>
</ol>
<p>上述这些,都是接下来 effect callback 中会用到的角色,我们顺着执行流程逐一了解它们。</p>
<p>effect 的 callback 是 loadEffect 方法</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250625000933644-492448327.png"></p>
<p>第一步是读取 extRequest。</p>
<h4>ResourceImpl&nbsp;extRequest</h4>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250625001032550-1658230617.png"></p>
<p>extRequest 是 linkedSignal,因此会被 effect 依赖收集 (监听变更)。</p>
<p>extRequest 负责 params 和 reload count,也就是说,每当 params 变更或 reload 时,effect callback 就会执行。</p>
<p>此时是第一次读取,extRequest getter 会执行 computation 返回一个对象。</p>
<p>这个对象有两个属性:</p>
<ol>
<li>
<p>request 属性的值是 options.params() 的返回值</p>
</li>
<li>
<p>reload 属性值是 0</p>
</li>
</ol>
<p>继续往下看</p>
<h4>ResourceImpl.state</h4>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250625001114476-104458229.png"></p>
<p>接着读取 state 的 status 和 prev status</p>
<p>此时是第一次读取,会执行 computation</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250625001239845-405108476.png"></p>
<p>得到的结果是 status: 'loading', previousStatus: 'idle'。</p>
<p>特别讲一下,status 和 prev status 的类型是不同的</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250621142221358-1251996450.png"></p>
<p>prev status 是我们熟悉的 Resource.status</p>
<p>而 status 是简化版本的:</p>
<p>loading 和 reloading 统一是 loading</p>
<p>resolved 和 error 统一是 resolved</p>
<p>所以,status 不会区别 reloading 和 error。</p>
<p>好继续往下看</p>
<h4>loaderFn (options.stream)</h4>
<p>接下来就是最关键的 loaderFn</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250621184041993-1560929939.png"></p>
<p>loaderFn 就是我们传入的 options.stream</p>
<p>执行的时候有 untracked,所以不会收集依赖。</p>
<p>其实整个 effect callback 只有一开始的 extRequest (params 和 reload) 是依赖,其它 Signal getter 都有 wrap 一层 untracked。</p>
<p>await loaderFn() 会返回一个 Signal</p>
<p>最后会把这个 Signal update to state</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250621213351480-176041582.png"></p>
<h3>小总结</h3>
<p>resource 函数做三件事:</p>
<ol>
<li>
<p>default params</p>
</li>
<li>
<p>convert loader to stream (a.k.a loaderFn)</p>
</li>
<li>
<p>实例化 &amp; return ResourceImpl 对象</p>
</li>
</ol>
<p>ResourceImpl constructor 做 4 件事:</p>
<ol>
<li>
<p>创建 computed for Resource.value</p>
</li>
<li>
<p>创建 linkedSignal for extRequest</p>
<p>它是 params &amp; reload</p>
</li>
<li>
<p>创建 linkedSignal for state</p>
<p>它是 params, reload, status, prev status, stream (loaderFn 返回的 Signal 对象)</p>
</li>
<li>
<p>创建 effect</p>
</li>
</ol>
<p>effect callback 做 5 件事:</p>
<ol>
<li>
<p>读取和监听 extRequest (也就是 params 和 reload)</p>
</li>
<li>
<p>读取 state 的 status 和 prev status</p>
</li>
<li>
<p>创建 AbortController</p>
</li>
<li>
<p>执行 loaderFn (options.stream),并传入 params, AbortSignal 还有 prev status</p>
</li>
<li>
<p>update state 的 status 和 stream (这个 stream 就是 loaderFn 返回的 Signal 对象)</p>
</li>
</ol>
<h3>其它碎碎的代码的部分</h3>
<p>碎碎的代码挺多的,这里一点一点补</p>
<h4>Resource.destroy</h4>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250621223251365-683214520.png"></p>
<p>它会 destroy 掉 effect,会 abort,还有 update state。</p>
<p>两种情况下会 destroy:</p>
<ol>
<li>
<p>当 injector destroy 时,resource 会一并 destroy</p>
</li>
<li>
<p>manual call destroy method</p>
<pre class="language-javascript highlighter-hljs"><code>const products = resource({
injector,
loader: async () =&gt; '',
});

products.destroy(); // manual destroy </code></pre>
</li>
</ol>
<h4>Resource.reload</h4>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250621224142646-954367484.png"></p>
<p>两个点:</p>
<ol>
<li>
<p>如果正在 loaderFn,那就 skip 掉这次的 reload,返回 false。(exhaustMap 效果)</p>
</li>
<li>
<p>累加 extRequest.reload,这会触发 effect callback,开启 loaderFn。</p>
</li>
</ol>
<h4>Resource.status</h4>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250621210827353-951822375.png"></p>
<p>Resource.status 是一个 computed,它的值来自 Resource.state。</p>
<p>state.status 是简化版的 status,没有 'reloading' 和 'error',所以需要 projectStatusOfState 还原它。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250621210911919-90157736.png"></p>
<p>注意看它识别 'reloading' 的手法。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250621230031977-1273426399.png"></p>
<p>如果是 params 变更,那 reload 会被 reset to 0,这时 status 会被判定为 'loading'。</p>
<p>如果是调用 reload 方法,那 extRequest.reload 会被累加变成大过 0&nbsp;</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250621230244293-548097631.png"></p>
<p>这时 status 会被判定为 'reloading'。</p>
<p>另外,error 的判定方式是看 state.stream (也就是 loaderFn 返回的 Signal getter 的值)</p>
<p>这个值的类型是</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250621230602771-1423395999.png"></p>
<p>isResolved 函数</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250621230644926-1699665134.png"></p>
<p>如果有 error 属性,那就表示 status 是 'error',没有的话就是 'resolved'。</p>
<h4>Resource.value</h4>
<p>Resource.value 是一个 computed</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250622021958312-1574733758.png"></p>
<p>它的值来自 state.stream</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250622022040442-636800437.png"></p>
<p>state.stream 初始化时是 undefined</p>
<p>然后到 loaderFn 执行完后就有值了</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250622022203965-1725238167.png"></p>
<p>在 params 变更或 reload 之后调用 state getter,它会执行 computation (第 n 次)</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250623142021060-1190761588.png"></p>
<p>如果 extRequest.request 和上一次一样 (reload 会一样, params 变更就会不一样),那 stream 保持不变;否则 stream 变成 undefined。</p>
<p>简单说就是 reload 时 stream 保留,params 变更时 stream 变成 undefined。</p>
<p>回到 Resource.value computed</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250622023224545-1670434557.png"></p>
<p>几个点:</p>
<ol>
<li>
<p>stream 是 undefined 就返回 default value</p>
</li>
<li>
<p>如果上一次 stream 是 error,此时又在 'loading' 期间,那也返回 default value。</p>
</li>
<li>
<p>最后是返回 loaderFn 最终值,可能是正常值,也可能是 Error 对象。</p>
</li>
</ol>
<h4>Resource.isError, isLoading, hasValue</h4>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250617153449617-1072730923.png"></p>
<p>三个点:</p>
<ol>
<li>
<p>isError -- 表示当前 status 是 'error'</p>
</li>
<li>
<p>isLoading -- 表示当前 status 是 'loading' 或者 'reloading'</p>
</li>
<li>
<p>hasValue -- 两种情况代表没有 value</p>
<p>a. 有 error 就没 value</p>
<p>b. value 是 undefined 也代表没 value</p>
<p>注:null 算有 value 哦,只有 undefined 才是没 value</p>
</li>
</ol>
<h4>Resource.set</h4>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250625002154380-1859240685.png"></p>
<h4>loadEffect の 中断执行</h4>
<p>loadEffect 的源码我们上面已经逛过一遍了,不过有几个 "中断执行" 的细节当时跳过了,这里补上。</p>
<p>我们先回顾一下 loadEffect 的主要职责:</p>
<ol>
<li>
<p>监听 Resource.extRequest,每当 extRequest 变更 (也就是 params 变更或 reload) 时,执行 effect callback (即 loadEffect 方法)。</p>
</li>
<li>
<p>在 loadEffect 方法中,会调用 loaderFn (也就是我们传入的 options.stream)。</p>
</li>
<li>
<p>最后将 loaderFn 的结果用于 update Resource.state (也等同于 update Resource.status 和 Resource.value)</p>
</li>
</ol>
<p>在 loadEffect 执行的过程中,有几种情况会导致它中途被中断。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250623152129404-1547142263.png"></p>
<p>第一个是当 params 返回 undefined 时 (注:extRequest.request 就是 params 的返回值)</p>
<pre class="language-javascript highlighter-hljs"><code>const product = resource({
injector,
params: () =&gt; undefined, // 返回 undefined
loader: async () =&gt; {
    console.log('won't run'); // 不会进来
    return '';
},
}); </code></pre>
<p>params 返回 undefined 有点类似 "pause" resource 的感觉。</p>
<p>此时 status 会变成 'idle',而 loaderFn 则不会被执行。</p>
<p>它的效果和 destory 差不多,但比 destroy 更灵活 -- 因为 destroy 之后无法恢复,但 "pause" 之后只要让 params 不再返回 undefined 就可以重新启动。</p>
<p>第二个是 status !== 'loading'</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250623154836742-725329555.png"></p>
<p>按理说,每当 extRequest 变更 (params 变更或 reload) 时,status 一定是 loading。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250623180648620-1088544782.png"></p>
<p>那为什么还要判断 !== 'loading' 呢?</p>
<p>因为从 extRequest 变更到 loadEffect 实际执行,中间其实存在一个细微的 delay。</p>
<p>params 变更 or reload &gt; mark EffectNode to dirty &gt; run&nbsp;ChangeDetectionScheduler.notify &gt; <strong>delay (at least microtask)</strong> &gt; flush &gt; run effect callback (即 loadEffect)。</p>
<p>这个是 effect 的机制,最少都会 delay 一个 microtask。</p>
<p>也因为这个 delay,所以 loadEffect 执行时,status 有可能已经变了。</p>
<pre class="language-javascript highlighter-hljs"><code>const limit = signal(0);
const product = resource({
injector,
params: limit,
loader: async ({ params: limit }) =&gt; limit,
});

window.setTimeout(() =&gt; {
// params 变更 &gt; schedule effect callback run
limit.set(1);

// 此时 status 是 'loading'
console.log(product.status());

// manual set resource
product.set(100);

// 此时 status 是 'local'
console.log(product.status());

// 到目前为止都是同步阶段,loadEffect 还没有执行,因为有 delay
}, 1000);</code></pre>
<p>status 在同步阶段从 'loading' 变成 'local',这种情况下,loadEffect 就没有必要执行了,所以会被中断。</p>
<p>除了一开始判断的两种情况会外,在执行完 loaderFn 以后也需要判断一种情况需不需中断执行</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250623185232165-866312719.png"></p>
<p>loaderFn 是异步,返回时可能已经时过境迁。</p>
<p>首先检查看 AbortSignal,如果已经 abort 了,那就直接 return 中断。</p>
<p>比如 resource destroy 就会导致 abort。</p>
<p>另外一个是看 extRequest 是否变更了。</p>
<p>比如在此期间 params 又变更了,那就表示会有新一轮的 effect callback,那这一轮的就不需要了,可以中断。</p>
<h3>总结</h3>
<p>个人觉得 resource 的源码写的挺乱的,比如说:</p>
<ol>
<li>
<p>extRequest 应该要改名字为 extParams</p>
</li>
<li>
<p>extRequest.request 应该要改名为 extRequest.params&nbsp;</p>
</li>
<li>
<p>Resource.value 是 computed,但又有 set, update,可又不是 linkedSignal。</p>
<p>value 的 set, update 其实是 Resource.set 和 update 来的。</p>
</li>
<li>一堆的 untracked 太乱了,应该要放一个 untracked 把全部 wrap 起来就好</li>
<li>
<p>params 变更和 reload 要触发 effect 执行 loaderFn</p>
<p>它搞了一个 extRequest linkedSignal 把 params 和 reload (用 reload count) 关联起来</p>
<p>然后用利用 count === 0 表示 loading,count &gt; 0 表示 reloading</p>
<p>这部分我觉得挺乱的</p>
</li>
<li>
<p>effect 最少都要有一个 microtask delay,这个 delay 会影响到 effectLoad 要不要中断。</p>
<p>这部分我也觉得挺乱的。</p>
</li>
<li>
<p>equal 的实现也有点乱,连续 Resource.set,equal 的职责由 set 方法负责。</p>
<p>如果是 params 变更,然后 Resource.set,那 equal 的部分则由 Resource.value 这个 computed 负责。</p>
<p>一下它负责,一下又另一个它负责,乱。</p>
</li>
</ol>
<p>为什么总感觉实现的很勉强,很不优雅呢?</p>
<p>我觉得是受限于 signal, computed, linkedSignal, effect 的能力。</p>
<p>我甚至认为 Angular 团队是在实现 resource 上遇到困难才发明 linkedSignal 的。</p>
<p>总之,我个人感觉如果用 RxJS 来实现 resource,很可能会比 Signals 来的更干净,更直观。</p>
<p>&nbsp;</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<h2>&nbsp;</h2>
<p>&nbsp;</p>
</div>
<h2>Angular Signals &amp; RxJS</h2>
<p>上文有提到,RxJS 与 KO (Knockout.js) 算师出同门,皆源自微软的 Rx (Reactive Extensions)。</p>
<p>后来,SolidJS 借鉴了 KO,而 Angular Signals 又借鉴了 SolidJS。</p>
<p>所以大家的根都是 Reactive Programming,但是!Signals 和 RxJS 在后续的发展中走上截然不同的道路。</p>
<p>接下来,我们来梳理一下它们之间的相似之处与差异 (毕竟不少人至今仍傻傻分不清楚,何时该用 Signals,何时该用 RxJS)。</p>
<h3>相似 の observable variable</h3>
<p>其实它俩相似的地方微乎其微,我能想到的只有一个 -- observable variable (而且只是相似,并不是完全一样)。</p>
<pre class="language-javascript highlighter-hljs"><code>// Signals
const firstName = signal('Derrick');
effect(() =&gt; console.log(firstName()), { injector }); // 监听 firstName 变更,然后 console 新值

// RxJS
const firstNameBS = new BehaviorSubject('Derrick');
firstNameBS.subscribe(() =&gt; console.log(firstNameBS.value)); // 监听 firstName 变更,然后 console 新值</code></pre>
<p>Signal 和 BehaviorSubject 都可以被监听。</p>
<p>但即便是这样一个基础功能,仍然有很多不同的地方:</p>
<ol>
<li>
<p>effect callback 会延迟 (at least microtask) 触发,subscribe 是同步触发</p>
</li>
<li>
<p>signal 变更有 equal 概念,类似 RxJS 的&nbsp;distinctuntilchanged</p>
</li>
</ol>
<p>如果我们想把 BehaviorSubject 模拟成 signal + effect 会是这样</p>
<pre class="language-javascript highlighter-hljs"><code>const firstNameBS = new BehaviorSubject('Derrick');

firstNameBS.pipe(
distinctUntilChanged(), // 模拟 equal
audit(v =&gt; new Observable(subscriber =&gt; queueMicrotask(() =&gt; subscriber.next(v)))) // 模拟 effect delay
).subscribe(() =&gt; console.log(firstNameBS.value));</code></pre>
<p>即便我们做了模拟,它们仍然不是 100% 一样:</p>
<ol>
<li>
<p>Signals 的 equal 默认的 compare 方式是 Object.is</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250627114037335-876468034.png"></p>
<p>而&nbsp;distinctUntilChanged 默认的 compare 方式是 ===</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250627114101084-1904672076.png"></p>
</li>
<li>
<p>Signals 的 equal 过滤发生在 set value 之前</p>
<pre class="language-javascript highlighter-hljs"><code>const product1 = { id: 1, name: 'iPhone1' };
const product2 = { id: 1, name: 'iPhone2' }; // id 一样但 name 不一样

const product = signal(product1, { equal: (p1, p2) =&gt; p1.id === p2.id }); // compare id

effect(() =&gt; console.log(product()), { injector }); // 只会触发一次, value 是 product1

window.setTimeout(() =&gt; {
product.set(product2); // set product2
console.log(product() === product1); // true 但仍然是 product1
}, 2000);</code></pre>
<p>虽然 set 了 product2,但由于有 equal 所以仍然是 product1,effect 也不会触发第二次。</p>
<p>RxJS 的&nbsp;distinctUntilChanged 发生在 next value 之后</p>
<pre class="language-javascript highlighter-hljs"><code>const product1 = { id: 1, name: 'iPhone1' };
const product2 = { id: 1, name: 'iPhone2' }; // id 一样但 name 不一样

const product = new BehaviorSubject(product1);

product
.pipe(distinctUntilChanged((p1, p2) =&gt; p1.id === p2.id)) // compare id
.subscribe(() =&gt; console.log(product.value)); // 只会触发一次, value 是 product1

window.setTimeout(() =&gt; {
product.next(product2); // set product 2
console.log(product.value === product1); // false 已经变成 product2 了
}, 2000);</code></pre>
<p>虽然 subscribe 不会触发,但 next product2 之后,value 就变成 product2 了。</p>
</li>
</ol>
<p>总之,Signals 和 RxJS 有很大的区别,哪怕是它们比较相似的地方 (e.g. BehaviorSubject) 仍然有细微的区别,所以大家在使用的时候一定要分清楚,不要混为一谈。</p>
<h3>Angular Signals 的特色</h3>
<p>Signals 和 RxJS 的相似非常少,相对的,区别自然就多了。</p>
<p>这里讲一些 Signals 独有的特色:</p>
<ol>
<li>
<p>自动依赖收集</p>
<p>computed, effect 都有自动依赖收集的机制。</p>
<p>这个是 RxJS 完全没有的。</p>
<p>BehaviorSubject.value 不是 getter,也没有 Proxy。</p>
因此,它连实现依赖收集的基础都没有 (Signals 的依赖收集是靠 getter 实现的)。</li>
<li>
<p>computed variable</p>
<p>RxJS 虽然可以勉强实现 computed variable (上文有提到)。</p>
<p>但对比 Signals 它缺少了两个重要的概念:</p>
<p>a. 自动依赖收集 (提升便捷性,性能)</p>
<p>b. pull-based (提升性能)</p>
<p>因此,用 RxJS 来实现 computed variable 极为不妥,性能也不好,代码也繁琐。</p>
</li>
<li>
<p>effect scheduler</p>
<p>Angular effect 会被 EffectScheduler 管理,不同的 effect (root effect, view effect, afterRenderEffect) 会有不同的触发 timings。</p>
<p>比如 root effect 会 delay 一个 microtask 才触发。</p>
<p>这个 EffectScheduler 是 Angular built-in 的 (虽然本篇我们采用模拟的),我们必须遵从它规定的触发 timings。</p>
<p>RxJS 虽然也有 Scheduler 概念,但 by default 它是同步触发的。</p>
</li>
</ol>
<h3>RxJS 的特色</h3>
<p>RxJS 也有许多独有的特色:</p>
<ol>
<li>Observable
<p>Signal 和 BehaviorSubject 有点像,但 RxJS 中的 Observable,在 Signals 里是没有对应实现的。</p>
Observable 的特性自然也都没有,比如:Lazy Execution&nbsp;(有 subscribe 才开始),Cold Observable&nbsp;(multiple subscribe 会分流)。
<p>BehaviorSubject 适合用于描述 variable (state 状态变更)。</p>
<p>Observable 适合用于描述 event (事件发布)</p>
</li>
<li>
<p>异步与同步的处理能力</p>
<p>RxJS by default 是同步,但它可以很容易切换到异步,比如透过 switchMap&nbsp;operator。</p>
Signals 很死板,computed 一定是同步,resource 专门异步,effect 的触发一定是异步,effect callback 可以异步,但依赖收集却一定是同步。
<p>规则很多,又不灵活。</p>
</li>
<li>
<p>operator</p>
<p>RxJS 有一堆 built-in 的 operators,可以对 event or state 的后续做各做处理,Signals 完全没有这些配套。</p>
<p>比如,RxJS 有 built-in 的&nbsp;retry&nbsp;operator 可以用来做 fetch retry,Signals 的 resource 则完全没有支持 retry,甚至想要扩展都没办法。</p>
</li>
</ol>
<h3>何时该用 Signals,何时该用 RxJS?</h3>
<p>RxJS 和 Signals 的差异远远大于相似,按理说,什么时候该用哪一个,其实应该很好判断。</p>
<p>但现实却不是这样。在实际的 Angular 项目中,我们往往会倾向用 Signals 去做所有的事,哪怕有些情况其实更适合用 RxJS。</p>
<h4>why Angular chose signals?</h4>
<p>为什么会出现这种情况呢?我们先看看 Angular 团队的选择</p>
<ol>
<li>
<p>RxJS 是第三方库</p>
<p>Angular 团队不希望 Angular 框架依赖 RxJS。</p>
<p>RxJS 毕竟是第三方库,如果 Angular 依赖它,就意味着需要担心它是否会持续维护,它的 breaking changes 等等。</p>
<p>这对 Angular 团队来说,会有些许的不受控,还可能会提高维护成本和风险。</p>
</li>
<li>RxJS 是一个 big concept
<p>如果 Angular 依赖 RxJS 那就会逼着用户去学习 RxJS。</p>
<p>这大大提高了 Angular 的学习成本,不利于 Angular 的普及。</p>
</li>
<li>
<p>Signals 是为 MVVM 框架量身打造的解决方案</p>
<p>Signals 最早是因为 KO 要解决 MVVM 难题 -- "如何监听 view model 变更" 而量身打造的解决方案。</p>
后来 Vue、SolidJS 对它进行了完善,这两个框架也都是 MVVM based。
<p>Angular 当然也是 MVVM based 框架,所以它选择 Signals 是绝对合适的。</p>
</li>
</ol>
<p>由于 Angular 选择了 Signals,作为 Angular 的用户,我们自然而然会倾向跟随框架,沿用 Signals。</p>
<h4>Should we only use Signals?</h4>
<p>那我们是不是应该只用 Signals,而完全不用 RxJS 呢?</p>
<p>当然不是!</p>
<p>Signals 是为 MVVM 框架量身打造的解决方案。所谓 “量身打造”,意思是它特别适合用来解决特定类型的问题,但面对其它问题,反而可能更加不合身。</p>
<p>因此,我们应该自行判断,在适合使用 RxJS 的场景下就用 RxJS,而不是盲目跟随 Angular 框架,只用 Signals。</p>
<p>比如说,在实现 UI 组件时,常常需要处理大量复杂的事件监听,这时候 Signals 几乎完全派不上用场,硬要用它反而会事倍功半。</p>
<h3>Signals to RxJS の toObservable</h3>
<p>使用 Angular 框架,我们一定会用到 Signals。</p>
<p>面对复杂的问题,我们可能会用到 RxJS。</p>
<p>因此,Signals 和 RxJS 并存在 Angular 项目里是完全合理的。</p>
<p>并存就难免会遇到一些有趣的现象,比如说:</p>
<p>我们从 Angular 对外的某个接口得到了一个 Signal 对象,然后我们想监听它的变更,并进行一连串复杂的处理。</p>
<p>这时,我们可能就会想借助 RxJS 的 operators 来完成。</p>
<p>于是,如果能把 Signal 转成 RxJS 的 Observable,自然就是最理想的做法。</p>
<p>为此,Angular 贴心的为我们准备了相应的转换功能 -- toObservable 函数</p>
<pre class="language-javascript highlighter-hljs"><code>import { toObservable } from "@angular/core/rxjs-interop";

const firstName = signal('Derrick'); // Signal 对象
const firstName$ = toObservable(firstName, { injector }); // 转换成 Observable 对象

firstName$.subscribe(firstName =&gt; console.log(firstName)); // 订阅 Observable</code></pre>
<p>它可以把 Signal 对象转换成 RxJS 的 Observable 对象,这样我们就可以使用 RxJS 的 operator 做后续处理了。</p>
<p>它是如何实现的呢?我们直接逛源码吧🚀。</p>
<p>toObservable 函数的源码在&nbsp;to_observable.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250628011021405-1704763530.png"></p>
<p>原理很简单,effect + ReplaySubject。</p>
<p>利用 effect 监听 Signal 变更,然后转发给 ReplaySubject。</p>
<p>虽然简单,但有几个特性需要注意:</p>
<ol>
<li>
<p>由于内部使用 effect,所以 toObservable 也依赖 Injector 还有 effect 需要的 class provider。</p>
</li>
<li>
<p>由于内部使用 effect,所以这个 Observable.subscribe 也会 delay 触发 (因为 effect 最少会 delay 一个 microtask 才执行 callback)</p>
</li>
<li>
<p>这个 Observable 没有&nbsp;Lazy Execution&nbsp;概念,不管有没有 subscribe,effect 一定会开启监听,unsubscribe 也不会 destroy effect。</p>
<p>只有在 injector destroy 时才会 destroy effect。</p>
</li>
</ol>
<p>以上这三个特性,尤其是第二和第三,都有点反 RxJS 直觉。</p>
<p>如果我们想让它更贴近 RxJS 一点,比如:lazy execution, unsubscribe destroy, first emit sync。</p>
<p>那可以这样实现:</p>
查看代码
<pre class="language-javascript highlighter-hljs"><code>// note 解释:
// 和 Angular 的 ToObservable 有 3 个不同
// 1. 有 subscribe 才有 effect
// 2. unsubscribe 和 error 都会 destroy effect
// 3. subscribe 的第一次 effect 是同步的,第二次才 based on effect scheduler
function myToObservable&lt;T&gt;(source: Signal&lt;T&gt;, options?: { injector: Injector }): Observable&lt;T&gt; {
const injector = options?.injector ?? inject(Injector);
const destroyRef = injector.get(DestroyRef);

// 1. 不要一开始就执行 effect,把它放到 Observable callback 里执行,这样才能 deferred execution
return new Observable&lt;T&gt;(subscriber =&gt; {
    const tryGetValue = (): | =&gt; {
      try {
      return ;
      } catch (error) {
      return ;
      }
    };

    // 2. subscribe 后立刻同步 emit signal value,不等 effect scheduler
    const = tryGetValue();
    succeeded &amp;&amp; subscriber.next(valueOrError);
    if (!succeeded) {
      subscriber.error(valueOrError);
      // 3. 假如一开始就 error,那就不用执行 effect 了。
      return;
    }

    let firstTime = true;
    const firstTimeValue = valueOrError;
    const watcher = effect(
      () =&gt; {
      const = tryGetValue();
      if (firstTime) {
          // 4. 由于上面我们已经同步 emit 了第一次的 signal value
          //    这里 effect 的第一次有可能是多余的
          //    之所以是 "有可能",而不是一定,是因为 signal 也有可能会在这短短的期间变更,所以我们最好 compare 一下它们的值。
          firstTime = false;
          const signalNode = source as SignalNode&lt;T&gt;;
          if (succeeded &amp;&amp; signalNode.equal(valueOrError, firstTimeValue)) {
            return; // skip
          }
      }

      untracked(() =&gt; {
          succeeded &amp;&amp; subscriber.next(valueOrError);

          if (!succeeded) {
            watcher.destroy();
            subscriber.error(valueOrError);
          }
      });
      },
      { injector, manualCleanup: true },
    );

    destroyRef.onDestroy(() =&gt; {
      watcher.destroy();
      subscriber.complete();
    });
    return () =&gt; watcher.destroy(); // 5. unsubscribe destroy
}).pipe(shareReplay({ bufferSize: 1, refCount: true }));
}</code></pre>
<h3>RxJS to Signals の toSignal</h3>
<p>既然能从 Signals 转换到 RxJS,那反转自然也可以 (from RxJS to Signals)。</p>
<pre class="language-javascript highlighter-hljs"><code>const firstNameBS = new BehaviorSubject('Derrick');
const firstName = toSignal(firstNameBS, { injector });
console.log(firstName()); // 'Derrick'</code></pre>
<p>它的原理也很简单。</p>
<p>toSignal 内部会创建并返回一个 Signal 对象。</p>
<p>除此之外,它还会 subscribe 传入的 Observable,每当 Observable 接收到新值,就会把这个值 set to Signal。</p>
<p>再逛 toSignal&nbsp;源码之前,我们先了解一下&nbsp;ToSignalOptions</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250628035038747-705272828.png"></p>
<p>ToSignalOptions 是&nbsp;toSignal 函数的第二个参数:</p>
<ol>
<li>
<p>equal</p>
<p>equal 就是给 signal 的 equal</p>
</li>
<li>
<p>manualCleanup 和 injector</p>
<p>manualCleanup 和 injector 是一个套件。</p>
<p>上面有说到,toSignal 会&nbsp;subscribe 传入的 Observable,那什么时候要 unsubscribe?</p>
<p>如果&nbsp;manualCleanup 为 true,那就表示 Observable 会负责 complete,不需要&nbsp;unsubscribe。</p>
<p>如果&nbsp;manualCleanup 为 false (默认),那就表示 toSignal 需要一个 injector 来注入 DestroyRef,当 injector destroy 时一并 unsubscribe Observable。</p>
<p>总之,toSignal 会 subscribe Observable,为了防止内存泄漏,要嘛我们 complete Observable (manualCleanup) 或者透过 destroy injector 来&nbsp;unsubscribe Observable。</p>
</li>
<li>
<p>requireSync 和 initialValue</p>
<img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250628104036856-864957076.png">
<p>为什么 Signal 的类型会有 undefined?</p>
<p>因为 toSignal 支持的参数是 Observable 而不只是具体的 BehaviourSubject。</p>
<p>这两者的区别是,BehaviourSubject 一定会有 value,而&nbsp;Observable 则不一定会有 value。</p>
<p>比如说:interval(1000) 这个 Observable 需要在一秒后才会开始有 value,那在一秒前它的 value 就是 undefined。</p>
<p>RxJS 的 Observable 没有办法从类型上反应出它一开始有没有 value,所以 Angular 只好保守的设定为有可能是 undefined。</p>
<p>但 Angular 也提供了 options 让我们来指定</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250628104800974-923215418.png"></p>
<p>配置 requireSync: true 之后,undefined 就没了,因为这表示 Observable 有同步 value (意思是立马可以获取到 value,像 BehaviorSubject 就可以)。</p>
<p>除了&nbsp;requireSync 还有另一个类似的 options</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250628105754463-1819118024.png"></p>
<p>配置 initialValue (初始值) 就肯定会有 value,也就不会 undefined 了。</p>
</li>
</ol>
<p>toSignal 函数的源码在&nbsp;to_signal.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250628133031088-1286601707.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250628133058046-1366840462.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250628133114864-1505112751.png"></p>
<p>还有</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250628133952063-1011355605.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250628134009899-23355382.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250628134039951-1735684515.png"></p>
<h3>rxResource</h3>
<p>这是 resource with stream 的写法</p>
<pre class="language-javascript highlighter-hljs"><code>const value = resource({
injector,
defaultValue: 'default value',
stream: async () =&gt; signal({ value: 'new value' }),
});</code></pre>
<p>stream 方法要返回&nbsp;Promise&lt;Signal&lt;ResourceStreamItem&lt;TValue&gt;&gt;&gt;。</p>
<p>rxResource 和 resource with stream 一模一样,唯一的区别是:</p>
<pre class="language-javascript highlighter-hljs"><code>const value = rxResource({
injector,
defaultValue: 'default value',
stream: () =&gt; of('new value'),
});</code></pre>
<p>stream 方法要返回 Observable&lt;TValue&gt;。</p>
<p>简单说就是为了方面 RxJS 的使用者,性质就如同 convert RxJS to Signals 一般。</p>
<p>rxResource 的源码在&nbsp;rx_resource.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250628152946999-1084451203.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250628153027166-77665235.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250628153105983-1669182597.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250628153138217-2146654047.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202506/641294-20250628153206323-303601839.png"></p>
<p>原理很简单,就是 wrap 了一层做接口处理而已。</p>
<p>&nbsp;</p>
<h2>Signal,&nbsp;immutable, immer</h2>
<p>上文有提到,Signal 的 value 最好是使用 immutable,为什么呢?</p>
<pre class="language-javascript highlighter-hljs"><code>const person = signal({
firstName: 'Derrick',
lastName: 'Yam'
});

const personFullName = computed(() =&gt; person().firstName + ' ' + person().lastName);</code></pre>
<p>一个 person signal 和一个 personFullName computed</p>
<pre class="language-javascript highlighter-hljs"><code>console.log(personFullName()); // 'Derrick Yam'

person().firstName = 'Alex';   // 变更 firstName

console.log(personFullName()); // 依然是 'Derrick Yam'</code></pre>
<p>虽然 person.firstName 变更了,但 personFullName 却仍然是旧值 'Derrick Yam',why?</p>
<p>这是因为 personFullName 依赖的是 person 而不是 person.firstName。</p>
<p>person.firstName 变更了,但 person 却没有变更,它还是同一个 reference。</p>
<p>personFullName getter 时会对比 personReactiveNode 之前和之后的 version,发现 version 是相同的,于是返回缓存值 'Derrick Yam'。</p>
<p>如果不想发生这种情况,最好的方式是使用 immutable:</p>
<pre class="language-javascript highlighter-hljs"><code>console.log(personFullName()); // 'Derrick Yam'

// 采用 immutable 方式 set value
person.set({
...person(),
firstName: 'Alex'
});

console.log(personFullName()); // 'Alex Yam'</code></pre>
<p>immutable 会连同 person 对象 reference 也变更,所以&nbsp;personReactiveNode.version 就累加了。</p>
<h3>immutable 常见写法</h3>
<p>immutable 对识别变更友好,但写起来却很繁琐,这里记入一些常见的写法:</p>
<p>改属性值</p>
<pre class="language-javascript highlighter-hljs"><code>const person = { name: 'Derrick', age: 11 };
const newPerson = {
...person,
age: 12
};// { name: 'Derrick', age: 12 } // person 的 reference 换了</code></pre>
<p>remove 属性</p>
<pre class="language-javascript highlighter-hljs"><code>const person = { firstName: 'Derrick', age: 11 };
const { firstName, ...newPerson } = person; // 利用解构
console.log(newPerson); // { "age": 11 }</code></pre>
<p>remove 属性 by string</p>
<pre class="language-javascript highlighter-hljs"><code>const person = { firstName: 'Derrick', age: 11 };
const keyToRemove = 'firstName';
const { : _, ...newPerson } = person; // 利用解构
console.log(newPerson); // { "age": 11 }</code></pre>
<p>push to array</p>
<pre class="language-javascript highlighter-hljs"><code>const people = [{ name: 'Derrick', age: 11 }];
const newPeople = [
...people,
{ name: 'Alex', age: 13 }
]; // [{ name: 'Derrick', age: 11 }] // people array 和 person 对象的 reference 都换了</code></pre>
<p>insert to array</p>
<pre class="language-javascript highlighter-hljs"><code>const people = [{ name: 'Derrick', age: 11 }];

const newPerson = { name: 'Alex', age: 13 };
const index = 0;

const newPeople = [...people.slice(0, index), newPerson, ...people.slice(index)];
console.log(newPeople); // [{ name: 'Alex', age: 13 }, { name: 'Derrick', age: 11 }]</code></pre>
<p>index negative 也支持哦,行为和 splice 一致。</p>
<p>remove from array</p>
<pre class="language-javascript highlighter-hljs"><code>const people = [{ name: 'Derrick', age: 11 }];
const newPeople = people.filter(person =&gt; person.age === 11); // [] // people array 的 reference 换了

// 再一个 index 的例子
const values = ['a', 'b', 'c', 'd', 'e'];
const index = values.indexOf('c');

const newValues = index === -1 ? values : [...values.slice(0, index), ...values.slice(index + 1)]; // ['a', 'b', 'd', 'e']</code></pre>
<p>上面这几个简单的还能接受,如果遇到嵌套的,那就会变得非常的乱。</p>
<p>remove at index</p>
<pre class="language-javascript highlighter-hljs"><code>const people = [{ name: 'Alex', age: 13 }, { name: 'Derrick', age: 11 }, { name: 'David', age: 18 }];
const index = 1;

const newPeople = [...people.slice(0, index), ...people.slice(index + 1)];
console.log(newPeople); // [{ name: 'Alex', age: 13 }, { name: 'David', age: 18 }]</code></pre>
<p>上面这段不支持 negative index,如果要支持 negative 像 splice 那样,需要加入一些 formula,我的建议是用 clone array + splice 会更简单。</p>
<pre class="language-javascript highlighter-hljs"><code>const people = [{ name: 'Alex', age: 13 }, { name: 'Derrick', age: 11 }, { name: 'David', age: 18 }];
const index = -1;

const newPeople = [...people]; // clone
newPeople.splice(index, 1);    // mutate

console.log(newPeople); // [{ name: 'Alex', age: 13 }, { name: 'Derrick', age: 11 }]</code></pre>
<h3>immer</h3>
<p>为了享受&nbsp;immutable 的好处,又不想写的那么累,可以考虑使用&nbsp;immer library。</p>
<div class="cnblogs_code">
<pre>yarn add immer</pre>
</div>
<p>它的使用方法非常简单</p>
<pre class="language-javascript highlighter-hljs"><code>const newPerson = produce(person(), draftPerson =&gt; {
draftPerson.firstName = 'Alex';
});
person.set(newPerson); </code></pre>
<p>调用 produce 函数,把 oldPerson 传进去,然后修改 draftPerson,最后它会返回一个 newPerson。</p>
<p>这个 draftPerson 是一个&nbsp;Proxy&nbsp;对象,我们修改它不需要使用&nbsp;immutable 的手法,把它当作 mutable 对象来修改就可以了 (嵌套也没有问题),</p>
<p>immer 会负责监听 Proxy 然后在背地里制作出 newPerson。</p>
<p>另外,immer 修改的时候是很细腻的</p>
<pre class="language-javascript highlighter-hljs"><code>const oldPerson = {
childA : { age: 11 },
childB: { age : 12 }
}

const newPerson = produce(oldPerson, draftPerson =&gt; {
draftPerson.childB.age = 13
});

console.log(newPerson === oldPerson); // false
console.log(newPerson.childA === oldPerson.childA); // true
console.log(newPerson.childB === oldPerson.childB); // false</code></pre>
<p>上面只改了 childB,所以只有 childB 和 person 对象变更了,而 childA 依然是同一个 reference。</p>
<p>还有</p>
<pre class="language-javascript highlighter-hljs"><code>draftPerson.childB.age = 12; // assign 回同样的值</code></pre>
<p>虽然有 assign 的动作,但是值没有换,最终也不会有变更</p>
<pre class="language-javascript highlighter-hljs"><code>console.log(newPerson === oldPerson); // true
console.log(newPerson.childA === oldPerson.childA); // true
console.log(newPerson.childB === oldPerson.childB); // true</code></pre>
<h3>immer 的局限</h3>
<p>像 immer 这种背地里搞东搞西的技术,通常都会有一些 limitation,这里记入一些我遇到过的。</p>
<h4>use immer for class instance</h4>
<p>上面的例子都是用 pure object,这里我们试试 class instance</p>
<pre class="language-javascript highlighter-hljs"><code>class Person {
constructor(firstName: string) {
    console.log('person constructor');
    this.firstName = firstName;
}

firstName: string;
}

const oldPerson = new Person('Derrick');
const newPerson = produce(oldPerson, draftPerson =&gt; {
draftPerson.firstName = 'Alex';
});

console.log('newPerson', newPerson);</code></pre>
<p>效果</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202408/641294-20240819193726961-1406082245.png"></p>
<p>报错了,信息上说要加上 </p>
<pre class="language-javascript highlighter-hljs"><code>class Person {
= true;
}</code></pre>
<p>效果</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202408/641294-20240819193832438-527453095.png"></p>
<p>可以了,但有一点要注意,person constructor 只触发了一次</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202408/641294-20240819193934442-1968032607.png"></p>
<p>由 produce 创建出来的 newPerson 是不会执行 constructor 函数的。</p>
<h4>lost array properties</h4>
<pre class="language-javascript highlighter-hljs"><code>const oldValues: string[] &amp; { hiddenValue?: string } = [];
oldValues['hiddenValue'] = 'value';

const newValues = produce(oldValues, draftValues =&gt; {
draftValues.push('value');
});

console.log(newValues['hiddenValue']); // undefined</code></pre>
<p>假如 array 有特别的属性 (虽然很罕见),produce 生成的 newValues 会丢失原本 array 里的属性。</p>
<h4>only proxy object and array</h4>
<pre class="language-javascript highlighter-hljs"><code>const oldPerson = { dateOfBirth : new Date(2021, 0, 1) }
const newPerson = produce(oldPerson, draftPerson =&gt; {
draftPerson.dateOfBirth.setFullYear(2022);
});

console.log(newPerson === oldPerson); // true</code></pre>
<p>只有 object 和 array 会被 proxy,像 Date 是不会被 Proxy 的,我们要修改 Date 就必须用 immutable 的手法。</p>
<h3>总结</h3>
<p>虽然&nbsp;immutable 写起来有点繁琐,但 Signal 配&nbsp;immutable 会避开不少坑,还是强烈建议大家使用。</p>
<p>&nbsp;</p>
<h2>Signals 的小烦恼😌</h2>
<p>记入一些日常中我遇到的小烦恼:</p>
<h3>无法 JSON.stringify</h3>
<p>signal 是 function,在 to json 时会自动被过滤掉。</p>
<pre class="language-javascript highlighter-hljs"><code>const person = {
firstName: signal('Derrick'),
lastName: signal('Yam'),
fullName: computed((): string =&gt; person.firstName() + ' ' + person.lastName()),
child: signal({ age: 11 }),
};

console.log(JSON.stringify(person)); // {} emtpty object</code></pre>
<p>如果我们希望它输出正确的值,可以提供一个 replacer。</p>
<pre class="language-javascript highlighter-hljs"><code>console.log(JSON.stringify(person, (_key, value: unknown) =&gt; (isSignal(value) ? value() : value), ' '));
// 效果
// {
//   "firstName": "Derrick",
//   "lastName": "Yam",
//   "fullName": "Derrick Yam",
//   "child": {
//   "age": 11
//   }
// }</code></pre>
<p>参数二是 replacer,判断 value 是否是 Signal,如果是就调用它获取值,这样就可以了。</p>
<p>注:isSignal 是 Angular built-in 函数。</p>
<p>&nbsp;</p>
<h2>总结</h2>
<p>本篇讲解了 Signals 的前世(KO)今生(SolidJS),以及 Angular Signals 的核心功能与原理。</p>
<p>虽然内容已经不少,但这还远远不是 Angular Signals 的全貌。</p>
<p>像是 Angular built-in 的 EffectScheduler 和 ChangeDetectionScheduler,本篇刻意用模拟的跳过了 (因为要了解这部分,需要其它 Angular 知识作为基础,还没教呢)。</p>
<p>实际上,Angular 框架在各个方面都会牵涉到 Signals (不管是我们输入给它,还是它返回给我们),这些内容我会在后续章节,按主题逐一补上。</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<h2>目录</h2>
<p>上一篇&nbsp;Angular 20+ 高级教程 –&nbsp;Dependency Injection 依赖注入</p>
<p>下一篇&nbsp;Angular 20+ 高级教程 –&nbsp;Component 组件 の Angular Component vs Web Component</p>
<p>想查看目录,请移步&nbsp;Angular 20+ 高级教程 – 目录</p>
<p>喜欢请点推荐👍,若发现教程内容以新版脱节请评论通知我。happy coding&nbsp;😊💻</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<h2>在组件里使用 effect</h2>
<p>上一 part 我刻意避开了在组件内使用 effect (躲到了&nbsp;APP_INITIALIZER 里头用😅),因为我说组件内用 effect 会有化学反应。</p>
<p>这里就讲讲这些化学反应。</p>
<h3>DestroyRef 不同</h3>
<p>effect 会用 Injector inject DestroyRef 做 autoCleanup,Root Injector inject 的&nbsp;DestroyRef 是&nbsp;Root Injector 本身。</p>
<p>而换到组件里就不同了,组件的 Injector 是 NodeInjector,inject 的&nbsp;DestroyRef 是依据组件的生命周期,当组件 destroy 时 effect 也同时被 destroy。</p>
<h3>第一次执行 effect callback 的时机不同</h3>
<p><img src="https://img2024.cnblogs.com/blog/641294/202404/641294-20240429165232267-1359841674.png"></p>
<p>组件内调用 effect,callback 不会立刻被 schedule to queue,而是先把 notify 方法寄存在 LView 里。</p>
<p>一直等到当前 LView 被 refresh</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202404/641294-20240429165304368-2143196034.png"></p>
<p>AfterViewInit 后,afterNextRender 前,notify 方法被执行,effect callback 被 schedule to queue。</p>
<p>注意,只是 schedule to queue 而已,effect callback 正真被调用是在&nbsp;afterNextRender 之后。</p>
<p>另外,假如我们在 afterNextRender 里面调用 effect 它会立刻 schedule to queue。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202405/641294-20240530132425951-435886862.png"></p>
<p>因为这个阶段 LView 已经完成了第一次的 create 和 update 满足 FirstLViewPass 条件。</p>
<p>好,以上就是在组件内使用 effect 和在组件外使用 effect 的两个区别,好像区别也没有很大...😂</p>
<p>&nbsp;</p>
<h2>Signal as ViewModel</h2>
<p>上面的一些例子已经有在组件内使用 Signal 了,但它们都没有用于 Template Binding Syntax。</p>
<p>接下来我们看看 Signal 如何作为 ViewModel。</p>
<p>app.component.ts</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 0, 1)">export class AppComponent {
firstName </span>= signal('Derrick'<span style="color: rgba(0, 0, 0, 1)">);
lastName </span>= signal('Yam'<span style="color: rgba(0, 0, 0, 1)">);
fullName </span>= computed(() =&gt; `${<span style="color: rgba(0, 0, 255, 1)">this</span>.firstName()} ${<span style="color: rgba(0, 0, 255, 1)">this</span><span style="color: rgba(0, 0, 0, 1)">.lastName()}`);
}</span></pre>
</div>
<p>app.component.html</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">p</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>{{ fullName() }}<span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">p</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
<span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">button </span><span style="color: rgba(255, 0, 0, 1)">(click)</span><span style="color: rgba(0, 0, 255, 1)">="firstName.set('Alex')"</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>set first name<span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">button</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
<span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">button </span><span style="color: rgba(255, 0, 0, 1)">(click)</span><span style="color: rgba(0, 0, 255, 1)">="lastName.set('Lee')"</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>set last name<span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">button</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span></pre>
</div>
<p>效果</p>
<p><img src="https://img2023.cnblogs.com/blog/641294/202311/641294-20231124140408716-1391095378.gif"></p>
<h3>Signal and refreshView</h3>
<p>Angular 文档有提到,Signal 是可以搭配&nbsp;ChangeDetectionStrategy.OnPush 使用的。</p>
<p><img src="https://img2023.cnblogs.com/blog/641294/202311/641294-20231124140440420-1219838808.png"></p>
<p>但是有一点我要 highlight,当 Signal 变更,当前的 LView 并不会被 markForCheck。</p>
<p>Angular 用了另一套机制来处理 Signal 和 refresh LView 的关系。</p>
<h3>逛一逛 Signal 和 refresh LView 的源码</h3>
<p>如果你对 Angular TView,LView,bootstrapApplication 过程不熟悉的话,请先看&nbsp;Change Detection&nbsp;文章。</p>
<p>场景:</p>
<p>有一个组件,ChangeDetectionStrategy.OnPush,它有一个 Signal 属性,binding 到 Template。</p>
<p>组件内跑一个 setTimeout 后修改 Signal 的值,但不做 markForCheck,结果 DOM 依然被更新了。</p>
<p>提问:</p>
<p>1. Signal 变更,Angular 怎么感知?</p>
<p>2. Angular 是怎样更新 DOM 的?使用 tick、detechChanges 还是 refreshView?</p>
<p>回答:</p>
<p>首先,不要误会,Angular 并没有暗地里替我们&nbsp;markForCheck,它采用了另一套机制。</p>
<p><span style="text-decoration: line-through">这套机制依然需要 NgZone,当 Zone.js 监听事件后,依然是跑 tick。</span></p>
<p>v17.1.0 后,markForCheck 和这套机制都会触发 tick 功能,不需要再依赖 Zonje.js 触发 tick 了。</p>
<p>tick 会从 Root LView 开始往下遍历。到这里,按理说我们没有 markForCheck 任何 LView,遍历根本跑不下去。</p>
<p>所以 Angular 新加了一个往下遍历的条件。</p>
<p>change_detection.ts&nbsp;源码</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202404/641294-20240429181411007-381521434.png"></p>
<p>detectChangesInViewWhileDirty 是判断要不要往下遍历。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202404/641294-20240429181642375-1808060676.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202404/641294-20240429181651451-760456169.png"></p>
<p>HasChildViewsToRefresh 意思是当前 LView 或许不需要 refresh,但是其子孙 LView 需要,所以得继续往下遍历。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202404/641294-20240429181819276-1196051681.png"></p>
<p>那这个 HasChildViewsToRefresh 是谁去做设定的呢?自然是 Signal 咯。</p>
<p>当 Angular 在 refreshView 时</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202404/641294-20240429182033374-1077370232.png"></p>
<p>consumerBeforeComputation 函数的源码在&nbsp;graph.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202404/641294-20240429182306081-1557781808.png"></p>
<p>里面调用了 setActiveConsumer 把 node 设置成全局 consumer。</p>
<p>这个 node 是一个 ReactiveNode,具体类型是&nbsp;ReactiveLViewConsumer。(源码在 reactive_lview_consumer.ts)</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202404/641294-20240429182406297-819037243.png"></p>
<p>我想你也已经看出来了,它在搞什么鬼。</p>
<p>每一个 LView 都有一个&nbsp;ReactiveLViewConsumer,它用来收集依赖 (a.k.a producer) 的。</p>
<p>在 LView refresh 之前,它会把 LView 的 ReactiveLViewConsumer (ReactiveNode 来的) 设置成全局 consumer,</p>
<p>refreshView 执行的时候,LView 的 Template Binding Syntax (compile 后是一堆函数调用) 会被执行,这些函数中就包含了 Signal getter。</p>
<p>全局 consumer + Signal getter = 收集 producer 和 consumer (这就是 effect 的机制嘛)</p>
<p>接下来就等 Signal 变更后执行 markAncestorsForTraversal</p>
<p><img src="https://img2023.cnblogs.com/blog/641294/202311/641294-20231124145636307-1612073421.png"></p>
<p>顾名思义,就是把祖先 mark as HasChildViewsToRefresh,源码在&nbsp;view_utils.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202404/641294-20240429184007295-1263843979.png"></p>
<p>总结:</p>
<p>LView 用了和 effect 类似的手法收集 producer 和 consumer,当 producer 变更它 markAncestorsForTraversal (新招数),markAncestorsForTraversal 会触发 tick,然后 refreshView,这样就更新 DOM 了。</p>
<p>另外一点,markAncestorsForTraversal&nbsp;比 markForCheck 好,因为 markForCheck 会造成祖先一定会 refreshView,而&nbsp;markAncestorsForTraversal 只是把祖先 mark 成&nbsp;HasChildViewsToRefresh,</p>
<p>意思是只有子孙要需要 refreshView,自己是不需要 refreshView 的。希望未来 Angular 会公开这个 markAncestorsForTraversal 功能。</p>
<h3>AfterNextRender + effect + signal view model 面试题</h3>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 0, 1)">export class SimpleTestComponent {
</span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 1. 这是一个 Signal view model</span>
name = signal('derrick'<span style="color: rgba(0, 0, 0, 1)">);

constructor() {
    const injector </span>=<span style="color: rgba(0, 0, 0, 1)"> inject(Injector);

    </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 2. 这里注册一个 after render callback</span>
    afterNextRender(() =&gt;<span style="color: rgba(0, 0, 0, 1)"> {
      </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 3. 里面执行 effect</span>
<span style="color: rgba(0, 0, 0, 1)">      effect(
      () </span>=&gt;<span style="color: rgba(0, 0, 0, 1)"> {
          </span><span style="color: rgba(0, 0, 255, 1)">if</span> (<span style="color: rgba(0, 0, 255, 1)">this</span>.name() === 'derrick'<span style="color: rgba(0, 0, 0, 1)">) {
            </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 4. effect 里面修改 Signal view model</span>
            <span style="color: rgba(0, 0, 255, 1)">this</span>.name.set('new name'<span style="color: rgba(0, 0, 0, 1)">);
          }
      },
      { allowSignalWrites: </span><span style="color: rgba(0, 0, 255, 1)">true</span><span style="color: rgba(0, 0, 0, 1)">, injector },
      );
    });
}
}</span></pre>
</div>
<p>面试官:依据上面的理解,讲解一下你了解的 Angular 执行过程。</p>
<p>你:Angular bootstrapApplication&nbsp;会执行 renderView 和 tick &gt; refreshView。</p>
<p>renderView 会执行&nbsp;SimpleTest 组件的 constructor,然后会注册 after render callback。</p>
<p>等到 refreshView 结束后会执行 after render callback。</p>
<p>这时会执行 effect。由于已经过了 LView 第一轮的 render 和 refresh 所以 effect callback 会直接&nbsp;schedule to queue。</p>
<p>此时第一轮的 tick 就结束了,但是还没有到 browser 渲染哦,因为 effect schedule 是&nbsp;microtask level 而已,所以 tick 结束后就会接着执行 effect callback。</p>
<p>callback 里面会修改 signal view model,LView (ReactiveLViewConsumer) 监听了这个 view model 的变更,一旦变更就会执行 markAncestorsForTraversal,然后会触发一个 tick。</p>
<p>于是又一轮 refreshView,修改 DOM,tick 结束,browser 渲染。</p>
<p>&nbsp;</p>
<h2>Signal 新手常掉的坑</h2>
<p>刚开始使用 Signal 可能会不适应它的一些机制,一不小心就会掉坑了,这里给大家提个醒。</p>
<h3>effect 没有执行</h3>
<div class="cnblogs_code">
<pre>afterNextRender(() =&gt;<span style="color: rgba(0, 0, 0, 1)"> {
const classSelector </span>= signal('item'<span style="color: rgba(0, 0, 0, 1)">);
effect(() </span>=&gt;<span style="color: rgba(0, 0, 0, 1)"> {
    const elements </span>= Array.from(document.querySelectorAll('.container')).filter(el =&gt;<span style="color: rgba(0, 0, 0, 1)"> el.matches(classSelector()));
    console.log(</span>'elements'<span style="color: rgba(0, 0, 0, 1)">, elements);
});
});</span></pre>
</div>
<p>假如第一次执行 effect callback 的时候,querySelectorAll('.container') 返回的是 empty array,那后面的 filter 就不会跑,classSelector getter 也不会被调用。</p>
<p>这样依赖就没有被收集到,从此这个 effect callback 就不会再触发了。</p>
<p>下面这样写就完全不同了</p>
<div class="cnblogs_code">
<pre>effect(() =&gt;<span style="color: rgba(0, 0, 0, 1)"> {
const selector </span>=<span style="color: rgba(0, 0, 0, 1)"> classSelector();
const elements </span>= Array.from(document.querySelectorAll('.container')).filter(el =&gt;<span style="color: rgba(0, 0, 0, 1)"> el.matches(selector));
console.log(</span>'elements'<span style="color: rgba(0, 0, 0, 1)">, elements);
});</span></pre>
</div>
<p>classSelector 会被依赖收集,每当它变更,querySelectorAll 和后续的逻辑都会执行。</p>
<p>具体你是要哪一种效果,我不知道,我只是告诉你它们的区别。</p>
<p>&nbsp;</p>
<h2>Signal-based Input (a.k.a Signal Inputs)</h2>
<p>Angular v17.1.0 版本 release 了 Signal-based Input。</p>
<p>Input Signal 的作用就是自动把 @Input 转换成 Signal,这样既可以利用 Signal Change Detection 机制,也可以用来做 Signal Computed 等等,非常方便。</p>
<p>下面是一个 Input Signal</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 0, 1)">export class SayHiComponent implements OnInit {
inputWithDefaultValue </span>= input('default value'<span style="color: rgba(0, 0, 0, 1)">);

computedValue </span>= computed(() =&gt; <span style="color: rgba(0, 0, 255, 1)">this</span>.inputWithDefaultValue() + ' extra value'<span style="color: rgba(0, 0, 0, 1)">);

ngOnInit(): </span><span style="color: rgba(0, 0, 255, 1)">void</span><span style="color: rgba(0, 0, 0, 1)"> {
    console.log(</span><span style="color: rgba(0, 0, 255, 1)">this</span>.inputWithDefaultValue()); <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 'default value'</span>
    console.log(<span style="color: rgba(0, 0, 255, 1)">this</span>.computedValue());         <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 'default value extra value'</span>
<span style="color: rgba(0, 0, 0, 1)">}
}</span></pre>
</div>
<p>除了变成 Signal 以外,其它机制和传统的 @Input 没有太多区别,比如一样是在 OnInit Hook 时才可用。</p>
<p>还有一点要注意,这个 Input Signal 是 readonly 的,不是&nbsp;WritableSignal,这其实是合理的,以前 @Input 可以被修改反而很危险。</p>
<p>required 的写法</p>
<div class="cnblogs_code">
<pre>inputRequired = input.required&lt;string&gt;();</pre>
</div>
<p>为了更好的支持 TypeScript 类型提示,Angular 把 requried 做成了另一个方法调用,而不是通过 options。</p>
<p>如果它是 required 那就不需要 default value,相反如果它不是 required 那就一定要放 default value。</p>
<p>也因为 required 没有 default value 所以需要通过泛型声明类型。</p>
<p>alias 和 transform 的写法</p>
<div class="cnblogs_code">
<pre>inputRequiredWithAlias = input.required&lt;string&gt;({ alias: 'inputRequiredAlias'<span style="color: rgba(0, 0, 0, 1)"> });
inputRequiredWithTransform </span>= input.required<span style="color: rgba(0, 0, 0, 1)">({
transform: booleanAttribute,
});</span></pre>
</div>
<p>transform 之所以不需要提供类型是因为它从 boolAttribute 中推断出来了。</p>
<p>我们要声明也是可以的</p>
<div class="cnblogs_code">
<pre>inputWithTransform = input.required&lt;unknown, <span style="color: rgba(0, 0, 255, 1)">boolean</span>&gt;<span style="color: rgba(0, 0, 0, 1)">({
transform: booleanAttribute,
});</span></pre>
</div>
<p>optional alias 和 transform 的写法</p>
<div class="cnblogs_code">
<pre>inputOptionalWithAlias = input('defualt', { alias: 'inputOptionalAlias'<span style="color: rgba(0, 0, 0, 1)"> });
inputOptionalWithTransform </span>= input(undefined, { transform: booleanAttribute });</pre>
</div>
<p>第一个参数是 initial value,一定要放,哪怕是放 undefined 也行,因为它只有三种重载。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202403/641294-20240308173602403-1757148035.png"></p>
<h3>set readonly&nbsp;Input Signal</h3>
<p>Input Signal 对内是 readonly 合理,但是对外是 readonly 就不合理了。</p>
<p>Message 组件</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 0, 1)">@Component({
selector: </span>'app-message'<span style="color: rgba(0, 0, 0, 1)">,
standalone: </span><span style="color: rgba(0, 0, 255, 1)">true</span><span style="color: rgba(0, 0, 0, 1)">,
template: `</span>&lt;h1&gt;{{ message() }}&lt;/h1&gt;`,
<span style="color: rgba(0, 0, 0, 1)">changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MessageComponent {
readonly message </span>= input.required&lt;string&gt;<span style="color: rgba(0, 0, 0, 1)">();
}</span></pre>
</div>
<p>App 组件</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 0, 1)">@Component({
selector: </span>'app-root'<span style="color: rgba(0, 0, 0, 1)">,
standalone: </span><span style="color: rgba(0, 0, 255, 1)">true</span><span style="color: rgba(0, 0, 0, 1)">,
template: `</span>&lt;app-message message="hello world" /&gt;`,
<span style="color: rgba(0, 0, 0, 1)">changeDetection: ChangeDetectionStrategy.OnPush,
imports: ,
})
export class AppComponent {}</span></pre>
</div>
<p>如果我们想在 App 组件 query Message 组件,然后直接 set message 进去可以吗?</p>
<p>答案是不可以,因为&nbsp;InputSignal 没有 set 或 update 方法</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202406/641294-20240601192046609-524796559.png"></p>
<p>这就非常不方便,而且也和之前的 @Input 不兼容。</p>
<p>那有没有黑科技,或者 workaround?还真有😏</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 0, 1)">constructor() {
window.setTimeout(() </span>=&gt;<span style="color: rgba(0, 0, 0, 1)"> {
    const messageSignal </span>= <span style="color: rgba(0, 0, 255, 1)">this</span><span style="color: rgba(0, 0, 0, 1)">.messageComponent().message;
    messageSignal.applyValueToInputSignal(messageSignal, </span>'new message'<span style="color: rgba(0, 0, 0, 1)">);
}, </span>2000<span style="color: rgba(0, 0, 0, 1)">);
}</span></pre>
</div>
<p>直接拿&nbsp;InputSignalNode 出来操作就可以了。</p>
<p>如果 input 有配置 transform 可以先调用 transformFn 获取 transform 后的值再调用&nbsp;applyValueToInputSignal</p>
<div class="cnblogs_code">
<pre>const numberValue = messageSignal.transformFn!.('100')<span style="color: rgba(0, 0, 0, 1)">;
messageSignal.applyValueToInputSignal(messageSignal, numberValue);</span></pre>
</div>
<p>&nbsp;</p>
<h2>Signal-based Two-way Binding (a.k.a Signal Models)</h2>
<p>Angular v17.2.0 版本 release 了 Signal-based Two-way Binding,请看这篇&nbsp;Component 组件 の Template Binding Syntax&nbsp;#&nbsp;Signal-based Two-way Binding</p>
<p>&nbsp;</p>
<h2>Signal-based Query (a.k.a Signal Queries)</h2>
<p>温馨提醒:忘记了 Query Elements 的朋友,可以先回去复习。</p>
<p>Signal-based Query 是 Angular v17.2.0 推出的新 Query View 和 Query Content 写法。</p>
<p>大家先别慌,它只是上层写法换了,底层逻辑还是&nbsp;Query Elements 文章教的那一套。</p>
<h3>viewChild</h3>
<p>before Signal</p>
<div class="cnblogs_code">
<pre>@ViewChild('title'<span style="color: rgba(0, 0, 0, 1)">, { read: ElementRef })
titleElementRef</span>!: ElementRef&lt;HTMLHeadingElement&gt;;</pre>
</div>
<p>after Signal</p>
<div class="cnblogs_code">
<pre>titleElementRef2 = viewChild.required('title'<span style="color: rgba(0, 0, 0, 1)">, {
read: ElementRef</span>&lt;HTMLHeadingElement&gt;<span style="color: rgba(0, 0, 0, 1)">,
});</span></pre>
</div>
<p>有 3 个变化:</p>
<ol>
<li>
<p>Decorator 没了,改成了函数调用。从 v14 的 inject 函数取代 @Inject Decorator 开始,大家都预料到了,有朝一日 Angular Team 一定会把 Decorator 赶尽杀绝的😱。</p>
</li>
<li>
<p>titleElementRef 类型从 ElementRef&lt;HTMLHeadingElement&gt; 变成了 Signal 对象 -- Signal&lt;ElementRef&lt;HTMLHeadingElement&gt;&gt;。</p>
<p>不过目前 TypeScript 类型推导好像有点问题,titleElementRef2 的类型是&nbsp;Signal&lt;ElementRef&lt;any&gt;&gt;,它没有办法推导出泛型,所以 read&nbsp;ElementRef 时不够完美。</p>
<p>我们只能自己声明类型来解决</p>
<div class="cnblogs_code">
<pre>titleElementRef2 = viewChild.required&lt;string, ElementRef&lt;HTMLHeadingElement&gt;&gt;('title'<span style="color: rgba(0, 0, 0, 1)">, {
read: ElementRef,
});</span></pre>
</div>
<p>泛型第一个参数是 'title' 的类型,第二个是 read 的类型。</p>
</li>
<li>
<p>titleElementRef! 结尾的 ! 惊叹号变成了 viewChild.required。没有惊叹号就不需要 .required。</p>
<p>惊叹号或 required 表示一定能 Query 出 Result,不会出现 undefined。</p>
</li>
</ol>
<h3>viewChildren</h3>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> before Signal</span>
@ViewChildren('title'<span style="color: rgba(0, 0, 0, 1)">, { read: ElementRef })
titleQueryList</span>!: QueryList&lt;ElementRef&lt;HTMLHeadingElement&gt;&gt;<span style="color: rgba(0, 0, 0, 1)">;

</span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> after Signal</span>
titleArray = viewChildren&lt;string, ElementRef&lt;HTMLHeadingElement&gt;&gt;('title'<span style="color: rgba(0, 0, 0, 1)">, {
read: ElementRef,
});</span></pre>
</div>
<p>两个知识点:</p>
<ol>
<li>
<p>before Signal 返回的类型是&nbsp;QueryList 对象,after Signal 类型变成了 Signal Array -- Signal&lt;readonly ElementRef&lt;HTMLHeadingElement&gt;[]&gt;。</p>
</li>
<li>
<p>! 惊叹号不需要&nbsp;viewChildren.required,因为 @ViewChild 和 viewChildren 即便 Query 不出 Result,也会返回 QueryList 对象或 Signal Empty Array。</p>
</li>
</ol>
<h3>contentChild 和 contentChildren</h3>
<p>content 的写法和 view 是一样的。把 view 改成 content 就可以了。这里就不给例子了。</p>
<h3>Replacement for QueryList and Lifecycle Hook</h3>
<p>我们先理一下 QueryList 的特性:</p>
<ol>
<li>
<p>QueryList 是在 renderView 阶段创建的,理论上来说,组件在 constructor 阶段肯定还拿不到 QueryList,但从 OnInit Lifecycle Hook 开始就应该可以拿到 QueryList 了。</p>
<p>但是</p>
<p><img src="https://img2023.cnblogs.com/blog/641294/202312/641294-20231227130646838-1522201262.png"></p>
<p>这是因为 Angular 是在 refreshView 阶段才将 QueryList 赋值到组件属性的,所以 OnInit 和 AfterContentInit 时组件属性依然是 undefined。</p>
</li>
<li>
<p>QueryList Result Index 是在 renderView 结束时收集完毕的。理论上来说,只要在这个时候调用&nbsp;ɵɵqueryRefresh 函数,QueryList 就可以拿到 Result 了。</p>
<p>但是 Angular 一直等到 refreshView 结束后才执行 ɵɵqueryRefresh 函数。</p>
</li>
<li>
<p>综上 2 个原因,我们只能在 AfterViewInit 阶段获取到 QueryList 和 Query Result。</p>
</li>
<li>
<p>Angular 这样设计的主要原因是不希望让我们拿到不完整的 Result,尽管 renderView 结束后已经可以拿到 Result,但是这些 Result 都是还没有经过 refreshView 的,</p>
<p>组件没有经过 refreshView 那显然是不完整的,所以 Angular 将时间推迟到了最后,在 AfterViewInit 阶段所有 Query 到的组件都是已经 refreshView 了的。</p>
</li>
<li>
<p>QueryList.changes 只会在后续的改动中发布,第一次是不发布的。</p>
</li>
</ol>
<h4>Replacement for QueryList</h4>
<p>Signal-based Query 不再曝露 QueryList 对象了 (这个对象依然还在,只是不在公开而已),取而代之的是 Signal 对象,那我们要怎样监听从前的 QueryList.changes 呢?</p>
<p>QueryList 没了,不要紧,我们多了个 Signal 嘛,Signal 也可以监听丫,要监听 Signal 可以使用 effect 函数。</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 0, 1)">export class AppComponent {
titles </span>= viewChildren&lt;string, ElementRef&lt;HTMLHeadingElement&gt;&gt;('title'<span style="color: rgba(0, 0, 0, 1)">, {
    read: ElementRef,
});

constructor() {
    effect(() </span>=&gt;<span style="color: rgba(0, 0, 0, 1)"> {
      console.log(</span><span style="color: rgba(0, 0, 255, 1)">this</span><span style="color: rgba(0, 0, 0, 1)">.titles());
    });
}
}</span></pre>
</div>
<p>每当内部的 QueryList 发生变化 (包括第一次哦,这点和 QueryList.changes 不同),Signal 就会发布新值,监听 Signal 值的 effect 就会触发。</p>
<h4>Replacement for&nbsp;Lifecycle Hook</h4>
<p>除了隐藏 QueryList 之外,Signal-based Query 也修改了执行顺序。</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 0, 1)">export class AppComponent implements OnInit, AfterContentInit, AfterViewInit {
titles </span>= viewChildren&lt;string, ElementRef&lt;HTMLHeadingElement&gt;&gt;('title'<span style="color: rgba(0, 0, 0, 1)">, {
    read: ElementRef,
});

constructor() {
    console.log(</span><span style="color: rgba(0, 0, 255, 1)">this</span>.titles().length);   <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 0</span>
    effect(() =&gt;<span style="color: rgba(0, 0, 0, 1)"> {
      console.log(</span><span style="color: rgba(0, 0, 255, 1)">this</span>.titles().length); <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 1</span>
<span style="color: rgba(0, 0, 0, 1)">    });
}
ngOnInit(): </span><span style="color: rgba(0, 0, 255, 1)">void</span><span style="color: rgba(0, 0, 0, 1)"> {
    console.log(</span><span style="color: rgba(0, 0, 255, 1)">this</span>.titles().length);    <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 1</span>
<span style="color: rgba(0, 0, 0, 1)">}
ngAfterContentInit(): </span><span style="color: rgba(0, 0, 255, 1)">void</span><span style="color: rgba(0, 0, 0, 1)"> {
    console.log(</span><span style="color: rgba(0, 0, 255, 1)">this</span>.titles().length);    <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 1</span>
<span style="color: rgba(0, 0, 0, 1)">}
ngAfterViewInit(): </span><span style="color: rgba(0, 0, 255, 1)">void</span><span style="color: rgba(0, 0, 0, 1)"> {
    console.log(</span><span style="color: rgba(0, 0, 255, 1)">this</span>.titles().length);    <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 1</span>
<span style="color: rgba(0, 0, 0, 1)">}
}</span></pre>
</div>
<p>在 renderView 结束后,Angular 就执行了&nbsp;ɵɵqueryRefresh,所以从 OnInit 开始就可以获取到 Query Result 了。(注:此时的 Query Result 依然属于不完整状态,组件还没有 refreshView 的)</p>
<p>Angular 修改这个顺序主要是因为它想把职责交还给我们,它提早给,我们可以选择要不要用,它不给,我们连选择的机会都没有。</p>
<h3>Signal-based Query 源码逛一逛</h3>
<p>App 组件</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 0, 1)">export class AppComponent {
titles </span>= viewChildren&lt;string, ElementRef&lt;HTMLHeadingElement&gt;&gt;('title'<span style="color: rgba(0, 0, 0, 1)">, {
    read: ElementRef,
});

@ViewChildren(</span>'title'<span style="color: rgba(0, 0, 0, 1)">, { read: ElementRef })
titleQueryList</span>!: ElementRef&lt;HTMLHeadingElement&gt;<span style="color: rgba(0, 0, 0, 1)">;
}</span></pre>
</div>
<p>一个 Signal-based,一个 Decorator-based,我们做对比。</p>
<div class="cnblogs_code">
<pre>yarn run ngc -p tsconfig.json</pre>
</div>
<p>app.component.js</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202402/641294-20240211172041754-221060505.png"></p>
<p>2 个区别:</p>
<ol>
<li>
<p>Decorator-based 在 refreshView 阶段做了 2 件事,Signal-based 一件也没有。</p>
<p>第一件事是赋值给组件属性,Signal-based 改成了在 constructor 阶段完成。</p>
<p>所以在 constructor 阶段 Decorator-based 的 QueryList 属性是 undefined,而 Signal-based 的 Signal 属性是有 Signal 对象的。</p>
<p>第二件事是刷新 Query Result,Signal-based 改成了去监听 Dyanmic Component 的 append 和 removeChild,当插入和移除时就会刷新 Query Result。</p>
</li>
<li>
<p>在 renderView 阶段,Decorator-based 会创建 QueryList,然后收集 Query Result Index,这些 Signal-based 也都会做,做法也都一模一样。</p>
<p>Signal-based 唯一多做了的事是关联 QueryList 和 Signal。具体流程大致上是这样:</p>
<p>当 Dynamic Component append 和 removeChild 时,它会 set QueryList to Dirty,Signal 会监听 QueryList Dirty,当 QueryList Dirty 后 Signal 会刷新 Query Result。</p>
</li>
</ol>
<p>viewChildren 函数的源码在&nbsp;queries.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202402/641294-20240211181002584-980588325.png"></p>
<p>createMultiResultQuerySignalFn 函数的源码在&nbsp;query_reactive.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202402/641294-20240211181128726-1134134563.png"></p>
<p>createQuerySignalFn 函数的源码在&nbsp;query_reactive.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202402/641294-20240211183841844-2033255558.png"></p>
<p>createQuerySignalFn 函数有点绕,一行一行很难讲解,我分几个段落讲解吧。</p>
<p>createComputed 函数是我们常用的 Signal computed 函数的 internal 版本</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202402/641294-20240211184045429-201638726.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202402/641294-20240211185018921-498339709.png"></p>
<p>Computed Signal 的特色是它内部会依赖其它 Signal。</p>
<p>Computed Signal 内部</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202402/641294-20240211185935621-1829006756.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202402/641294-20240211190954008-284772480.png"></p>
<p>回到 app.component.js,ɵɵviewQuerySignal 函数的源码在&nbsp;queries_signals.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202402/641294-20240211191251693-1053502643.png"></p>
<p>createViewQuery 函数负责创建 TQuery、LQuery、QueryList。</p>
<p>Signal-based 和 Decorator-based 调用的是同一个 createViewQuery 函数,所以 Signal-based 的区别是在 bindQueryToSignal 函数。</p>
<p>bindQueryToSignal 函数的源码在&nbsp;query_reactive.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202402/641294-20240211195326007-2035617975.png"></p>
<h4>总结</h4>
<ol>
<li>
<p>有 2 个主要阶段</p>
<p>第一个是 constructor&nbsp;</p>
<p>第二个是 renderView</p>
</li>
<li>
<p>有 2 个主要对象</p>
<p>第一个是 QueryList</p>
<p>第二是 Computed Signal</p>
</li>
<li>
<p>constructor 阶段创建了 Computed Signal</p>
<p>renderView 阶段创建了 QueryList</p>
</li>
<li>
<p>Computed Signal 负责刷新 Query Result,但刷新 Query Result 需要 QueryList (当然还有其它的,比如 LView 我就不一一写出来的,用 QueryList 做代表)。</p>
<p>所以在 renderView 创建 QueryList 后,Computed Signal 和 QueryList 需要关联起来。</p>
</li>
<li>
<p>_dirtyCounter Signal 是一个小配角,因为 QueryList on Dirty 的时候要刷新 Query Result,</p>
<p>而刷新 Query Result 是&nbsp;Computed Signal 负责的,要触发一个 Signal 只能通过给它一个依赖的 Signal,所以就有了&nbsp;_dirtyCounter Signal。</p>
</li>
<li>
<p>最后:QueryList on Dirty 时 -&gt; 通知&nbsp;_dirtyCounter Signal -&gt;&nbsp;Computed Signal 依赖&nbsp;_dirtyCounter Signal -&gt;&nbsp;Computed Signal 刷新 Query Result。</p>
</li>
<li>
<p>QueryList on Dirty 是什么时候触发的呢?</p>
LQueries 负责 set QueryList to Dirty&nbsp;<br>
<p><img src="https://img2024.cnblogs.com/blog/641294/202402/641294-20240211210918444-750377482.png"></p>




LQueries 的 insertView、detachView 方法是在 Dynamic Component 插入/移除时被调用的。
<p>finishViewCreation 会在 LView renderView 后,Child LView renderView 之前被调用。</p>




</li>




</ol>
<h3>Might be a bug</h3>
<div class="cnblogs_code">
<pre>export class AppComponent {
constructor() {
    const titles = viewChildren&lt;string, ElementRef&lt;HTMLHeadingElement&gt;&gt;('title', {
      read: ElementRef,
    });

    effect(() =&gt; {
      console.log(titles());
    })
}
}</pre>
</div>
<p>如果我们把 viewChildren 返回的 Signal assign to 一个 variable 而不是一个属性的话,compilation 出来的 App Definition 不会有 viewQuery 方法。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202402/641294-20240211214311142-64493270.png"></p>
<p>也不只是 assign to variable 才出问题,写法不一样它也 compile 不了。</p>
<div class="cnblogs_code">
<pre>export class AppComponent {
titles: Signal&lt;readonly ElementRef&lt;HTMLHeadingElement&gt;[]&gt;;

constructor() {
    this.titles = viewChildren&lt;string, ElementRef&lt;HTMLHeadingElement&gt;&gt;(
      'title', { read: ElementRef, }
    );

    effect(() =&gt; {
      console.log(this.titles());
    });
}
}</pre>
</div>
<p>像上面这样分开写也是不可以的。提交了&nbsp;Github Issue,我猜 Angular Team 会说:是的,必须按照官方的 code style 去写,不然 compiler 解析不到。</p>
<p>这也是 compiler 黑魔法常见的问题,因为语法设计本来就是很复杂的,框架如果要支持各做逻辑会很耗精力。</p>
<div>&nbsp;</div>
<h2>Signal-based Output (a.k.a Signal Outputs)</h2>
<p>Angular v17.3.0 版本 release 了&nbsp;Signal-based Output。</p>
<p>Signal-based Output 其实和 Signal 没有太多关系,因为它不根本就没有使用到 Signal 对象,它和 Signal-based Input 完全不可相提并论。</p>
<p>它们唯一的共同点是调用的手法非常相识,仅此而已。</p>
<p>Signal-based Output 长这样</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 0, 1)">export class HelloWorldComponent {

newClick </span>= output&lt;string&gt;<span style="color: rgba(0, 0, 0, 1)">();

@Output()
oldClick </span>= <span style="color: rgba(0, 0, 255, 1)">new</span> EventEmitter&lt;string&gt;<span style="color: rgba(0, 0, 0, 1)">();

handleClick() {
    </span><span style="color: rgba(0, 0, 255, 1)">this</span>.newClick.emit('value'<span style="color: rgba(0, 0, 0, 1)">);
    </span><span style="color: rgba(0, 0, 255, 1)">this</span>.oldClick.emit('value'<span style="color: rgba(0, 0, 0, 1)">);
}
}</span></pre>
</div>
<p>和 Decorator-based Output 相比,主要是它不使用 Decorator 了,改成使用全局函数 output。</p>
<p>这一点和 inject, input, viewChild, contentChild 概念是一样的,通通都是从 Decorator 改成了全局函数。</p>
<p>监听的方式和以前一样,没有任何改变。</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">app-hello-world </span><span style="color: rgba(255, 0, 0, 1)">(newClick)</span><span style="color: rgba(0, 0, 255, 1)">="log($event)"</span><span style="color: rgba(255, 0, 0, 1)"> (oldClick)</span><span style="color: rgba(0, 0, 255, 1)">="log($event)"</span> <span style="color: rgba(0, 0, 255, 1)">/&gt;</span> </pre>
</div>
<h3>OutputEmitterRef vs&nbsp;EventEmitter</h3>
<p>output 函数返回的是&nbsp;OutputEmitterRef 对象。对于我们使用者来说,OutputEmitterRef&nbsp;和&nbsp;EventEmitter 没有什么区别。</p>
<p>但是往里面看,它俩的区别可就大了,这甚至会引出 Angular 团队的下一个大方向 -- Optional RxJS🤔。</p>
<p>EventEmitter 继承自 RxJS 的 Subject</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202403/641294-20240309222651337-165227310.png"></p>
<p>而 OutputEmitterRef 不依赖 RxJS</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202403/641294-20240309222807013-1249358381.png"></p>
<p>OutputEmitterRef 的源码在&nbsp;output_emitter_ref.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202403/641294-20240309224939888-1445669327.png"></p>
<p>它只有 2 个公开接口 -- subscribe 和 emit。</p>
<h3>Signal-based Output 源码逛一逛</h3>
<p>如果你没有跟我一起逛过源码,最好是顺着本教程学,因为我讲解过的就不会再重复讲解了。</p>
<p>首先 run compilation</p>
<div class="cnblogs_code">
<pre>yarn run ngc -p tsconfig.json</pre>
</div>
<p>app.component.js</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202403/641294-20240309225538541-1375151573.png"></p>
<p>监听 output 和监听普通 DOM event 是一样的,都是通过&nbsp;ɵɵlistener 函数。</p>
<p>hello-world.js</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202403/641294-20240309225705941-252667183.png"></p>
<p>Decorator-based Output 和 Signal-based Output compile 出来的 Definition 是一样的。</p>
<p>关于 output 的信息都记录在 outputs 属性中。</p>
<p>在&nbsp;Angular bootstrapApplication 源码逛一逛 文章中,我们提到过&nbsp;initializeDirectives 函数。</p>
<p>它的源码在&nbsp;shared.ts</p>
<p>在&nbsp;initializeDirectives 函数的结尾调用了&nbsp;initializeInputAndOutputAliases 函数</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202403/641294-20240309230250726-1534033122.png"></p>
<p>initializeInputAndOutputAliases 函数最终把 output 信息存放到了 TNode 上。</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202403/641294-20240309230352389-1805739466.png"></p>
<p>HelloWorld 组件 TNode</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202403/641294-20240309230638505-1269457544.png"></p>
<p>上面有提到 app.component.js 会调用&nbsp;ɵɵlistener 函数监听 output。</p>
<p>相关源码在&nbsp;listener.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202403/641294-20240309231104390-1126948037.png"></p>
<p>总结</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">app-hello-world </span><span style="color: rgba(255, 0, 0, 1)">(newClick)</span><span style="color: rgba(0, 0, 255, 1)">="log($event)"</span><span style="color: rgba(255, 0, 0, 1)"> (oldClick)</span><span style="color: rgba(0, 0, 255, 1)">="log($event)"</span> <span style="color: rgba(0, 0, 255, 1)">/&gt;</span> </pre>
</div>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 0, 1)">export class HelloWorldComponent {
newClick: OutputEmitterRef</span>&lt;string&gt; = output&lt;string&gt;<span style="color: rgba(0, 0, 0, 1)">();<br>
@Output()
oldClick </span>= <span style="color: rgba(0, 0, 255, 1)">new</span> EventEmitter&lt;string&gt;<span style="color: rgba(0, 0, 0, 1)">();
}</span></pre>
</div>
<p>上面这些 Template Binding Syntax 最终变成了</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 0, 1)">helloWorldInstance.newClick.subscribe($event =&gt; appInstandce.log($event))
helloWorldInstance.oldClick.subscribe($event =&gt; appInstandce.log($event))</span></pre>
</div>
<h3>Related with Signal</h3>
<p>我唯一看到和 Signal 有关的</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202403/641294-20240309231752420-1999285859.png"></p>
<p>在 OutputEmitterRef.emit 执行 callback function 之前,它会先把 Signal 的依赖收集器 set 成 null,执行完 callback function 后再还原&nbsp;Signal 依赖收集器。</p>
<p>我不清楚为什么它这么做,也懒得去研究,以后遇到 bug 再回头看呗。</p>
<h3>outputFromObservable 和&nbsp;outputToObservable</h3>
<p>Signal 对象可以 convert to RxJS Observable,OutputEmitterRef 也行。</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 0, 1)">export class HelloWorldComponent {
myClickSubject </span>= <span style="color: rgba(0, 0, 255, 1)">new</span> Subject&lt;string&gt;<span style="color: rgba(0, 0, 0, 1)">();
myClick </span>= outputFromObservable(<span style="color: rgba(0, 0, 255, 1)">this</span>.myClickSubject) satisfies OutputRef&lt;string&gt;<span style="color: rgba(0, 0, 0, 1)">;

myHover </span>= output&lt;string&gt;<span style="color: rgba(0, 0, 0, 1)">();
myHover$ </span>= outputToObservable(<span style="color: rgba(0, 0, 255, 1)">this</span>.myHover) satisfies Observable&lt;string&gt;<span style="color: rgba(0, 0, 0, 1)">;
}</span></pre>
</div>
<p>没什么特别的,就只是一个 convert 而已。</p>
<p>值得留意的是,outputFromObservable 返回的是 OutputRef 而不是 OutputEmitterRef,它俩的区别是&nbsp;OutputRef 只能 subscribe 不能 emit,类似 readonly 的概念。</p>
<p>&nbsp;</p>
<h2>Signal-based OnInit? (the hacking way...🤪)</h2>
<p>Angular 团队说了</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202406/641294-20240619035245421-759041689.png"></p>
<p>Signal-based 会保留 ngOnInit 和 ngOnDestroy。其它 lifecycle 用 effect 和&nbsp;AfterRenderHooks&nbsp;替代。</p>
<p>其实 ngOnDestroy 早就被 DestroyRef 取代了,目前无可替代的只剩下 ngOnInit 而已。</p>
<p>这我就纳闷了,留一个另类在那边,这不是摆明来乱的吗?😡</p>
<p>好,今天心血来潮,我们就来试试看有没有一些 hacking way 可以实现 Signal-based 风格的 ngOnInit。</p>
<h3>ngOnInit 的原理</h3>
<p>首先,我们需要知道 ngOnInit 的原理。</p>
<p>这个好办,以前我们逛过它源码的,如果你忘记了,可以看这篇。</p>
<p>我们先搭个环境</p>
<p>App Template</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">app-hello-world </span><span style="color: rgba(255, 0, 0, 1)"></span><span style="color: rgba(0, 0, 255, 1)">="value()"</span><span style="color: rgba(255, 0, 0, 1)"> appDir1 </span><span style="color: rgba(0, 0, 255, 1)">/&gt;</span><span style="color: rgba(0, 0, 255, 1)"><br></span></pre>
</div>
<p>App Template 里有 HelloWorld 组件,组件上有 Dir1 指令和 @Input value。</p>
<p>HelloWorld 组件</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 0, 1)">export class HelloWorldComponent implements OnInit {
readonly value </span>= input.required&lt;string&gt;<span style="color: rgba(0, 0, 0, 1)">();
ngOnInit() {
    console.log(</span>'HelloWorld 组件', <span style="color: rgba(0, 0, 255, 1)">this</span><span style="color: rgba(0, 0, 0, 1)">.value());
}
}</span></pre>
</div>
<p>Dir1 指令</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 0, 1)">export class Dir1Directive implements OnInit {
ngOnInit() {
    console.log(</span>'Dir1 指令'<span style="color: rgba(0, 0, 0, 1)">)
}
}</span></pre>
</div>
<p>HelloWorld 组件和 Dir1 指令都有 ngOnInit。</p>
<p>我们知道 ngOnInit 会被保存到 LView 的 parent TView 里,也就是说,HelloWorld 组件和 Dir1 指令的 ngOnInit 会被保存到 App TView 的&nbsp;preOrderHooks array 里。</p>
<p>下图是 App TView</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202406/641294-20240619162721783-425060083.png"></p>
<p>preOrderHooks 的记录是有顺序规则的。</p>
<p>index 0: 25 是 &lt;app-hello-wrold&gt; TNode 在 App TView.data 的 index</p>
<p>index 1: -36 是 HelloWorld 实例在 App LView 的 index</p>
<p>index 2: ngOnInit 是 HelloWorld 组件的 ngOnInit 方法</p>
<p>index 3: -37 是 Dir1 实例在 App LView 的 index</p>
<p>index 4:&nbsp;ngOnInit 是 Dir1 指令的 ngOnInit 方法</p>
<p>一个 TNode 可能会包含多个指令,所以会有多个指令实例和 ngOnInit 方法。</p>
<p>index 5: 下一个 TNode。我们目前的例子没有下一个了,像下面这样就会有</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">app-hello-world </span><span style="color: rgba(255, 0, 0, 1)"></span><span style="color: rgba(0, 0, 255, 1)">="value()"</span><span style="color: rgba(255, 0, 0, 1)"> appDir1 </span><span style="color: rgba(0, 0, 255, 1)">/&gt;</span>
<span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">app-say-hi </span><span style="color: rgba(255, 0, 0, 1)"></span><span style="color: rgba(0, 0, 255, 1)">="value()"</span><span style="color: rgba(255, 0, 0, 1)"> appDir1 </span><span style="color: rgba(0, 0, 255, 1)">/&gt;</span></pre>
</div>
<p>此时 index 5 就是 SayHi TNode 的 index,index 6 就是 SayHi 实例 index,index 7 就是 SayHI ngOnInit 方法,以此类推。</p>
<p>好,搞清楚 ngOnInit 被存放到哪里还不够,我们还要知道它存放的时机。</p>
<p>在 renderView 阶段,App template 方法被调用。</p>
<p>此时会实例化 HelloWorld 组件,并且 register pre order hooks。</p>
<p>但在实例化 HelloWorld 组件之前,有这么一 part</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202406/641294-20240619165121321-1464152792.png"></p>
<p>initializeDirectives 函数我们之前逛过,这里就不再赘述了。</p>
<p>我们看它的两个重点:</p>
<ol>
<li>
<p>它会从 class HelloWorld 的 prototype 里取出 ngOnInit 方法</p>
<p>HelloWorld.prototype['ngOnInit']</p>
<p>熟悉 JavaScript class prototype 概念的朋友应该能理解</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202406/641294-20240619040931304-143985592.png"></p>
</li>
<li>
<p>&nbsp;如果这个 prototype 中有 ngOnInit,ngDoCheck,ngOnChanges (这三个都是 pre order hooks),</p>
<p>那它会把 TNode index 存入 preOrderHooks array 里,也就是上面的 index 0:25。</p>
</li>
</ol>
<p>提醒:此时 HelloWorld 组件是还没有被实例化的哦,constructor 还没有被执行。</p>
<p>好,接着就是实例化 HelloWorld 组件,然后 register pre order hooks。</p>
<p>相关源码在 di.ts 里的 getNodeInjectable 函数</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202406/641294-20240619164753676-302432754.png"></p>
<p>实例化组件后才会 register pre order hooks。</p>
<p>registerPreOrderHooks 函数源码在&nbsp;hooks.ts</p>
<p><img src="https://img2024.cnblogs.com/blog/641294/202406/641294-20240619165855302-1127067483.png"></p>
<p>往 preOrderHooks array 里 push 了 2 个值。也就是上面提到的 index 1: HelloWorld 组件实例的 index,index 2: HelloWorld 组件的 ngOnInit 方法 (方法是从 class HelloWorld prototype 里拿的)</p>
<p>然后就是 refreshView 阶段了,当 @Input 被赋值后,它就会调用 TView.preOrderHooks 中的 ngOnInit 方法。</p>
<h3>The hacking way</h3>
<p>好,理一理思路:</p>
<ol>
<li>
<p>在 HelloWorld 组件实例化之前,class HelloWorld 的 prototype 中最好能有 ngOnInit 方法。</p>
<p>因为这样它才会要把 TNode index push 到&nbsp;TView.preOrderHooks array 里。</p>
</li>
<li>
<p>在 HelloWorld 组件实例化以后,class HelloWorld 的 prototype 一定要有 ngOnInit 方法。</p>
<p>因为它要 register pre order hooks,把组件实例和 ngOnInit 方法 push 到&nbsp;TView.preOrderHooks array 里。</p>
</li>
</ol>
<p>我们的目标是不要定义 ngOnInit 方法,取而代之的是像调用 afterNextRender 函数那样在 constructor 里调用 onInit 函数注册 on init callback。</p>
<p>按照我们的目标,上面第一条是无法达成了,所以我们需要手动把 TNode index 添加到&nbsp;TView.preOrderHooks array 里。</p>
<p>至于第二条,我们可以办到。只要在 constructor 里添加 ngOnInit 方法到 HelloWorld.prototype 就可以了。</p>
<p>好,思路清晰,开干。</p>
<p>首先,我们定义一个全局函数&nbsp;onInit&nbsp;</p>
<div class="cnblogs_code">
<pre>type CallBack = () =&gt; <span style="color: rgba(0, 0, 255, 1)">void</span><span style="color: rgba(0, 0, 0, 1)">;
</span><span style="color: rgba(0, 0, 255, 1)">function</span> onInit(componentInstance: Record&lt;PropertyKey, any&gt;<span style="color: rgba(0, 0, 0, 1)">, callback: CallBack) {

}</span></pre>
</div>
<p>参数一是组件实例,我们需要从组件实例中获取它的 prototype,然后添加 ngOnInit 方法进去。</p>
<p>参数二就是 on init callback 函数。</p>
<p>它的使用方式是这样的</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 0, 1)">export class HelloWorldComponent {
readonly value </span>= input.required&lt;string&gt;<span style="color: rgba(0, 0, 0, 1)">();

constructor() {
    onInit(</span><span style="color: rgba(0, 0, 255, 1)">this</span>, () =&gt;<span style="color: rgba(0, 0, 0, 1)"> {
      console.log(</span>'init1', <span style="color: rgba(0, 0, 255, 1)">this</span><span style="color: rgba(0, 0, 0, 1)">.value());
      console.log(inject(ElementRef)); </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> callback 最好是 injection context, 可以直接调用 inject 函数会比较方便</span>
<span style="color: rgba(0, 0, 0, 1)">    });

    onInit(</span><span style="color: rgba(0, 0, 255, 1)">this</span>, () =&gt;<span style="color: rgba(0, 0, 0, 1)"> {
      console.log(</span>'init2', <span style="color: rgba(0, 0, 255, 1)">this</span><span style="color: rgba(0, 0, 0, 1)">.value());
    });
}
}</span></pre>
</div>
<p>好,我们来具体实现 onInit 函数</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">function</span><span style="color: rgba(0, 0, 0, 1)"> onInit(
componentInstance: Record</span>&lt;PropertyKey, any&gt;<span style="color: rgba(0, 0, 0, 1)">,
callback: CallBack
) {
setupTViewPreOrderHooks();
setupPrototype();
saveCallback();
}</span></pre>
</div>
<p>有三大步骤:</p>
<p>第一步是把 TNode.index push 到 TView.preOrderHooks 里。</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">function</span><span style="color: rgba(0, 0, 0, 1)"> setupTViewPreOrderHooks() {
</span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 1. 这里是 LView 中的 index 引用</span>
const TVIEW = 1<span style="color: rgba(0, 0, 0, 1)">;
const PARENT </span>= 3<span style="color: rgba(0, 0, 0, 1)">;

</span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 2. 首先,我们要拿到 TNode index</span>
<span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    以这个例子来说的话</span>
<span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    app.component.html</span>
<span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    &lt;app-hello-world ="value()" appDir1 /&gt;</span>
<span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    HelloWorld 组件和 dir1 指令的 TNode 是同一个 &lt;app-hello-world&gt;</span>
<span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    TNode index 指的是这个 &lt;app-hello-world&gt; 在 App LView 里的 index</span>
<span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    它是第一个 element,所以 index 是 25 咯。</span>
<span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    我们透过 ViewContainerRef 拿到当前的 TNode,然后再拿它的 index</span>
<span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    提醒:</span>
<span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    不要使用 ChangeDetectorRef['_lView'].index 去拿</span>
<span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    因为指令和组件拿的 ChangeDetectorRef['_lView'] 是不同逻辑,很混乱的。</span>
<span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    用 ViewContainerRef 就对了</span>
<span style="color: rgba(0, 0, 0, 1)">
const viewContainerRef </span>=<span style="color: rgba(0, 0, 0, 1)"> inject(ViewContainerRef) as any;
const hostTNode </span>= viewContainerRef['_hostTNode'<span style="color: rgba(0, 0, 0, 1)">];
const tNodeIndex </span>=<span style="color: rgba(0, 0, 0, 1)"> hostTNode.index;

</span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 3. 接下来要拿到 TView.preOrderHooks</span>
<span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    同样的,不要试图用 ChangeDetectorRef['_lView'] 去拿,不准的</span>
<span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    用 ViewContainerRef 就对了</span>
const lContainer = viewContainerRef['_lContainer'<span style="color: rgba(0, 0, 0, 1)">];
const targetLView </span>=<span style="color: rgba(0, 0, 0, 1)"> lContainer;
const targetTView </span>=<span style="color: rgba(0, 0, 0, 1)"> targetLView;

</span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 4. 如果 preOrderHooks 是 null 创建一个 array 把 TNode index 传进去给它</span>
<span style="color: rgba(0, 0, 255, 1)">if</span> (!targetTView.preOrderHooks<span style="color: rgba(0, 0, 0, 1)">) {
    targetTView.preOrderHooks </span>=<span style="color: rgba(0, 0, 0, 1)"> ;
    </span><span style="color: rgba(0, 0, 255, 1)">return</span><span style="color: rgba(0, 0, 0, 1)">;
}

</span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 5. 如果 preOrderHooks 里还没有这个 TNode index 就 push 进去,有了就 skip</span>
<span style="color: rgba(0, 0, 255, 1)">if</span>(!<span style="color: rgba(0, 0, 0, 1)">targetTView.preOrderHooks.includes(tNodeIndex)) {
    targetTView.preOrderHooks.push(tNodeIndex);
}
}</span></pre>
</div>
<p>主要是依赖 ViewContainerRef 获取到准确的 TNode index 和 TView,至于它是不是真的拿的那么准,我也不好说,</p>
<p>但基本的组件,指令,@if,@for,ng-template,&nbsp;ngTemplateOutlet 我都测试过,拿的还挺准的。</p>
<p>第二步是添加 ngOnInit 方法到 HelloWorld.prototype</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">function</span><span style="color: rgba(0, 0, 0, 1)"> setupPrototype() {
const prototype </span>=<span style="color: rgba(0, 0, 0, 1)"> Object.getPrototypeOf(componentInstance);
</span><span style="color: rgba(0, 0, 255, 1)">if</span> (prototype['ngOnInit'] ===<span style="color: rgba(0, 0, 0, 1)"> undefined) {
    prototype[</span>'ngOnInit'] =<span style="color: rgba(0, 0, 0, 1)"> StgOnInit;
}
}</span></pre>
</div>
<p>Stg 是我的 library 名字缩写。StgOnInit 是一个通用的 ngOnInit 方法,下面我会展开。</p>
<p>HelloWorld.prototype 有了 ngOnInit 方法,Angular 就会 register pre order hooks 了。</p>
<p>第三步是把 on init callback 保存起来,还有 injector 也保存起来 (调用 callback 时,需要用 injector 来创建 injection context)。</p>
<div class="cnblogs_code">
<pre>const ON_INIT_CALLBACKS_PROPERTY_NAME = '__stgOnInitCallbacks__'<span style="color: rgba(0, 0, 0, 1)">;
const INJECTOR_PROPERTY_NAME </span>= '__stgInjector__'<span style="color: rgba(0, 0, 0, 1)">;

</span><span style="color: rgba(0, 0, 255, 1)">function</span><span style="color: rgba(0, 0, 0, 1)"> saveCallback() {
const callbacks </span>= componentInstance ??<span style="color: rgba(0, 0, 0, 1)"> [];
Object.defineProperty(componentInstance, ON_INIT_CALLBACKS_PROPERTY_NAME, {
    configurable: </span><span style="color: rgba(0, 0, 255, 1)">true</span><span style="color: rgba(0, 0, 0, 1)">,
    value: [...callbacks, callback],
});

</span><span style="color: rgba(0, 0, 255, 1)">if</span> (componentInstance ===<span style="color: rgba(0, 0, 0, 1)"> undefined) {
    const injector </span>=<span style="color: rgba(0, 0, 0, 1)"> inject(Injector);
    Object.defineProperty(componentInstance, INJECTOR_PROPERTY_NAME, {
      value: injector,
    });
}
}</span></pre>
</div>
<p>把它们保存在组件实例里就可以了。但要记得 enumerable: false 哦。</p>
<p>最后是通用的 ngOnInit 函数</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">function</span> StgOnInit(<span style="color: rgba(0, 0, 255, 1)">this</span><span style="color: rgba(0, 0, 0, 1)">: {
: CallBack[];
: Injector;
}) {
const callbacks </span>= <span style="color: rgba(0, 0, 255, 1)">this</span><span style="color: rgba(0, 0, 0, 1)">;
const injector </span>= <span style="color: rgba(0, 0, 255, 1)">this</span><span style="color: rgba(0, 0, 0, 1)">;
runInInjectionContext(injector, () </span>=&gt;<span style="color: rgba(0, 0, 0, 1)"> {
    </span><span style="color: rgba(0, 0, 255, 1)">for</span><span style="color: rgba(0, 0, 0, 1)"> (const callback of callbacks) {
      callback();
    }
});
}</span></pre>
</div>
<p>这个函数被赋值到 HelloWorld.prototype['ngOnInit'],lifecycle 时会被 Angular 调用。</p>
<p>this 指向组件实例。</p>
<p>我们只要从组件实例拿出 callback 和 injector 然后 for loop 执行就可以了。</p>
<h4>步骤 1,2的替代方案</h4>
<p>步骤 1,2 用到了黑科技,风险比较大,如果我们担心随时翻车,那可以用另一个比较笨拙的方法 -- 手动添加 prototype。</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 0, 1)">export class Dir1Directive {
constructor() {
    onInit(</span><span style="color: rgba(0, 0, 255, 1)">this</span>, () =&gt; console.log('dir1 init'<span style="color: rgba(0, 0, 0, 1)">));
}
}
(Dir1Directive.prototype as any).ngOnInit </span>= StgOnInit;</pre>
</div>
<p>步骤 1,2 主要就是搞&nbsp;prototype,如果我们改成手动添加,就可以避开黑科技了。当然代价就是代码超级丑。</p>
<h3>总结</h3>
<p>完整代码</p>
<div class="cnblogs_code"><img class="code_img_closed lazyload" data-src="http://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif"><img class="code_img_opened lazyload" style="display: none" data-src="http://images.cnblogs.com/OutliningIndicators/ExpandedBlockStart.gif">
<div id="cnblogs_code_open_441c96c0-9cf1-4db9-8c45-c261f140921f" class="cnblogs_code_hide">
<pre>type CallBack = () =&gt; <span style="color: rgba(0, 0, 255, 1)">void</span><span style="color: rgba(0, 0, 0, 1)">;

</span><span style="color: rgba(0, 0, 255, 1)">function</span> StgOnInit(<span style="color: rgba(0, 0, 255, 1)">this</span><span style="color: rgba(0, 0, 0, 1)">: {
: CallBack[];
: Injector;
}) {
const callbacks </span>= <span style="color: rgba(0, 0, 255, 1)">this</span><span style="color: rgba(0, 0, 0, 1)">;
const injector </span>= <span style="color: rgba(0, 0, 255, 1)">this</span><span style="color: rgba(0, 0, 0, 1)">;
runInInjectionContext(injector, () </span>=&gt;<span style="color: rgba(0, 0, 0, 1)"> {
    </span><span style="color: rgba(0, 0, 255, 1)">for</span><span style="color: rgba(0, 0, 0, 1)"> (const callback of callbacks) {
      callback();
    }
});
}

const ON_INIT_CALLBACKS_PROPERTY_NAME </span>= '__stgOnInitCallbacks__'<span style="color: rgba(0, 0, 0, 1)">;
const INJECTOR_PROPERTY_NAME </span>= '__stgInjector__'<span style="color: rgba(0, 0, 0, 1)">;

</span><span style="color: rgba(0, 0, 255, 1)">function</span><span style="color: rgba(0, 0, 0, 1)"> onInit(
componentInstance: Record</span>&lt;PropertyKey, any&gt;<span style="color: rgba(0, 0, 0, 1)">,
callback: CallBack
) {
setupTViewPreOrderHooks();
setupPrototype();
saveCallback();

</span><span style="color: rgba(0, 0, 255, 1)">function</span><span style="color: rgba(0, 0, 0, 1)"> setupTViewPreOrderHooks() {
    </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 1. 这里是 LView 中的 index 引用</span>
    const TVIEW = 1<span style="color: rgba(0, 0, 0, 1)">;
    const PARENT </span>= 3<span style="color: rgba(0, 0, 0, 1)">;

    </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 2. 首先,我们要拿到 TNode index</span>
    <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    以这个例子来说的话</span>
    <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    app.component.html</span>
    <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    &lt;app-hello-world ="value()" appDir1 /&gt;</span>
    <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    HelloWorld 组件和 dir1 指令的 TNode 是同一个 &lt;app-hello-world&gt;</span>
    <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    TNode index 指的是这个 &lt;app-hello-world&gt; 在 App LView 里的 index</span>
    <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    它是第一个 element,所以 index 是 25 咯。</span>
    <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    我们透过 ViewContainerRef 拿到当前的 TNode,然后再拿它的 index</span>
    <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    提醒:</span>
    <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    不要使用 ChangeDetectorRef['_lView'].index 去拿</span>
    <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    因为指令和组件拿的 ChangeDetectorRef['_lView'] 是不同逻辑,很混乱的。</span>
    <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    用 ViewContainerRef 就对了</span>
<span style="color: rgba(0, 0, 0, 1)">
    const viewContainerRef </span>=<span style="color: rgba(0, 0, 0, 1)"> inject(ViewContainerRef) as any;
    const hostTNode </span>= viewContainerRef['_hostTNode'<span style="color: rgba(0, 0, 0, 1)">];
    const tNodeIndex </span>=<span style="color: rgba(0, 0, 0, 1)"> hostTNode.index;

    </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 3. 接下来要拿到 TView.preOrderHooks</span>
    <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    同样的,不要试图用 ChangeDetectorRef['_lView'] 去拿,不准的</span>
    <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">    用 ViewContainerRef 就对了</span>
    const lContainer = viewContainerRef['_lContainer'<span style="color: rgba(0, 0, 0, 1)">];
    const targetLView </span>=<span style="color: rgba(0, 0, 0, 1)"> lContainer;
    const targetTView </span>=<span style="color: rgba(0, 0, 0, 1)"> targetLView;

    </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 4. 如果 preOrderHooks 是 null 创建一个 array 把 TNode index 传进去给它</span>
    <span style="color: rgba(0, 0, 255, 1)">if</span> (!<span style="color: rgba(0, 0, 0, 1)">targetTView.preOrderHooks) {
      targetTView.preOrderHooks </span>=<span style="color: rgba(0, 0, 0, 1)"> ;
      </span><span style="color: rgba(0, 0, 255, 1)">return</span><span style="color: rgba(0, 0, 0, 1)">;
    }

    </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 5. 如果 preOrderHooks 里还没有这个 TNode index 就 push 进去,有了就 skip</span>
    <span style="color: rgba(0, 0, 255, 1)">if</span>(!<span style="color: rgba(0, 0, 0, 1)">targetTView.preOrderHooks.includes(tNodeIndex)) {
      targetTView.preOrderHooks.push(tNodeIndex);
    }
}

</span><span style="color: rgba(0, 0, 255, 1)">function</span><span style="color: rgba(0, 0, 0, 1)"> setupPrototype() {
    const prototype </span>=<span style="color: rgba(0, 0, 0, 1)"> Object.getPrototypeOf(componentInstance);
    </span><span style="color: rgba(0, 0, 255, 1)">if</span> (prototype['ngOnInit'] ===<span style="color: rgba(0, 0, 0, 1)"> undefined) {
      prototype[</span>'ngOnInit'] =<span style="color: rgba(0, 0, 0, 1)"> StgOnInit;
    }
}

</span><span style="color: rgba(0, 0, 255, 1)">function</span><span style="color: rgba(0, 0, 0, 1)"> saveCallback() {
    const callbacks </span>= componentInstance ??<span style="color: rgba(0, 0, 0, 1)"> [];
    Object.defineProperty(componentInstance, ON_INIT_CALLBACKS_PROPERTY_NAME, {
      configurable: </span><span style="color: rgba(0, 0, 255, 1)">true</span><span style="color: rgba(0, 0, 0, 1)">,
      value: [...callbacks, callback],
    });

    </span><span style="color: rgba(0, 0, 255, 1)">if</span> (componentInstance ===<span style="color: rgba(0, 0, 0, 1)"> undefined) {
      const injector </span>=<span style="color: rgba(0, 0, 0, 1)"> inject(Injector);
      Object.defineProperty(componentInstance, INJECTOR_PROPERTY_NAME, {
      value: injector,
      });
    }
}
}

@Component({
selector: </span>'app-hello-world'<span style="color: rgba(0, 0, 0, 1)">,
standalone: </span><span style="color: rgba(0, 0, 255, 1)">true</span><span style="color: rgba(0, 0, 0, 1)">,
imports: [],
templateUrl: </span>'./hello-world.component.html'<span style="color: rgba(0, 0, 0, 1)">,
styleUrl: </span>'./hello-world.component.scss'<span style="color: rgba(0, 0, 0, 1)">,
})
export class HelloWorldComponent {
readonly value </span>= input.required&lt;string&gt;<span style="color: rgba(0, 0, 0, 1)">();

constructor() {
    onInit(</span><span style="color: rgba(0, 0, 255, 1)">this</span>, () =&gt; console.log('HelloWorld 组件', <span style="color: rgba(0, 0, 255, 1)">this</span><span style="color: rgba(0, 0, 0, 1)">.value()))
}
}</span></pre>
</div>
<span class="cnblogs_code_collapse">View Code</span></div>
<p>以上就是 Signal-based 风格的 ngOnInit。</p>
<p>一时心血来潮写的,没有经过严格测试,我们拿来研究学习就好,不要乱用哦。</p>
<p>最后,还是希望 Angular 团队能提供一个 Signal-based 的 ngOnInit,就目前这个状态,我真的觉得 ngOnInit 和大伙儿 (effect, DetroyedRef, AfterNextRender) 格格不入🙄</p>
<h3>One more thing&nbsp;の Signal-based ngAfterContentInit</h3>
<p>Angular v19 以后,effect 已经可以完全取代&nbsp;ngAfterContentInit 了 (v18 还不行)。</p>
<p>之所以 v19 可以,是因为 v19 换 (breaking changes) 了 effect 的 execution timing,详细的区别可以看这篇 --&nbsp;Angular 19 正式发布 の 新功能介绍。</p>
<p>简而言之,effect callback 会在 AfterContentInit 前一脚触发,所以我们可以完全把它俩的执行时机看成是一样的。</p>
<p>所有&nbsp;ngAfterContentInit 时机可以拿到的数据 (e.g. contentChildren),effect callback 里同样可以拿到。</p>
<p>ngAfterContentInit 和 effect 唯一的区别就是,ngAfterContentInit 只会触发一次,而 effect 可能会触发多次。</p>
<p>假如我们透过 effect +&nbsp;untracked 或者 manualCleanup 让 effect 只跑一次,那它俩就真的一模一样了。</p>
<p>那&nbsp;ngAfterContentInit 还有意义吗?</p>
<p>我自己的经验是这样,variable 有分 const 和 let,property 有 readonly 概念。</p>
<p>这些都是为了让我们分得清楚,什么是会变更的,什么是不会变更的。</p>
<p>Angular 把 input, contentChild 全部设定为 Signal,代表这些都是有可能变更的。</p>
<p>但真实项目中并不是这样,有些 input,contentChild 它就是不会变更的。</p>
<p>会不会变更对 user 来说是有影响的,会变更,我们就需要兼顾变更后的状况,不会变更我们就只需要考量当下。</p>
<p>把一个明明不会变更的 variable 硬看作是会变更的,然后写一堆兼顾它变更后的状态,这就属于过度设计了。</p>
<p>所以,当我遇到一些不需要变更的情况时,我会更倾向于&nbsp;ngAfterContentInit,因为它的语义比较好。</p>
<p>为此,我也写了一个 Siganl-based 的 ngAfterContentInit</p>
<div class="cnblogs_code"><img class="code_img_closed lazyload" data-src="http://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif"><img class="code_img_opened lazyload" style="display: none" data-src="http://images.cnblogs.com/OutliningIndicators/ExpandedBlockStart.gif">
<div id="cnblogs_code_open_25ea93be-87a4-4e68-9c3d-f7ed01524a4f" class="cnblogs_code_hide">
<pre>import { Injector, runInInjectionContext, inject, ViewContainerRef } from "@angular/core"<span style="color: rgba(0, 0, 0, 1)">;

type Any </span>=<span style="color: rgba(0, 0, 0, 1)"> any;
type AnyObject </span>= Record&lt;PropertyKey, Any&gt;<span style="color: rgba(0, 0, 0, 1)">;
type Callback </span>= () =&gt; <span style="color: rgba(0, 0, 255, 1)">void</span><span style="color: rgba(0, 0, 0, 1)">;

</span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">#region Signal-based ngOnInit, ngAfterContentInit</span>
const ON_INIT_CALLBACKS_PROPERTY_NAME = '__stgOnInitCallbacks__'<span style="color: rgba(0, 0, 0, 1)">;
const AFTER_CONTENT_INIT_CALLBACKS_PROPERTY_NAME </span>= '__stgAfterContentInitCallbacks__'<span style="color: rgba(0, 0, 0, 1)">;
const INJECTOR_PROPERTY_NAME </span>= '__stgInjector__'<span style="color: rgba(0, 0, 0, 1)">;

</span><span style="color: rgba(0, 0, 255, 1)">function</span><span style="color: rgba(0, 0, 0, 1)"> createLifecycleHook(callbackPropertyName: string) {
</span><span style="color: rgba(0, 0, 255, 1)">return</span> <span style="color: rgba(0, 0, 255, 1)">function</span> lifecycleHook(<span style="color: rgba(0, 0, 255, 1)">this</span>: Record&lt;string, Any&gt;<span style="color: rgba(0, 0, 0, 1)">) {
    const callbacks </span>= <span style="color: rgba(0, 0, 255, 1)">this</span><span style="color: rgba(0, 0, 0, 1)"> as Callback[];
    const injector </span>= <span style="color: rgba(0, 0, 255, 1)">this</span><span style="color: rgba(0, 0, 0, 1)"> as Injector;
    runInInjectionContext(injector, () </span>=&gt;<span style="color: rgba(0, 0, 0, 1)"> {
      </span><span style="color: rgba(0, 0, 255, 1)">for</span><span style="color: rgba(0, 0, 0, 1)"> (const callback of callbacks) {
      callback();
      }
    });
}
}

</span><span style="color: rgba(0, 0, 255, 1)">function</span><span style="color: rgba(0, 0, 0, 1)"> setupPrototype(componentInstance: AnyObject, ngHookPropertyName: string, callbackPropertyName: string) {
const prototype </span>=<span style="color: rgba(0, 0, 0, 1)"> Object.getPrototypeOf(componentInstance);
</span><span style="color: rgba(0, 0, 255, 1)">if</span> (prototype ===<span style="color: rgba(0, 0, 0, 1)"> undefined) {
    prototype </span>=<span style="color: rgba(0, 0, 0, 1)"> createLifecycleHook(callbackPropertyName);
}
}

</span><span style="color: rgba(0, 0, 255, 1)">function</span><span style="color: rgba(0, 0, 0, 1)"> saveCallback(componentInstance: AnyObject, callback: Callback, callbackPropertyName: string) {
const callbacks </span>= componentInstance as Callback[] | undefined ??<span style="color: rgba(0, 0, 0, 1)"> [];
Object.defineProperty(componentInstance, callbackPropertyName, {
    configurable: </span><span style="color: rgba(0, 0, 255, 1)">true</span><span style="color: rgba(0, 0, 0, 1)">,
    value: [...callbacks, callback],
});

</span><span style="color: rgba(0, 0, 255, 1)">if</span> (componentInstance ===<span style="color: rgba(0, 0, 0, 1)"> undefined) {
    const injector </span>=<span style="color: rgba(0, 0, 0, 1)"> inject(Injector);
    Object.defineProperty(componentInstance, INJECTOR_PROPERTY_NAME, {
      value: injector,
    });
}
}

export </span><span style="color: rgba(0, 0, 255, 1)">function</span> onInit(componentInstance: AnyObject, callback: () =&gt; <span style="color: rgba(0, 0, 255, 1)">void</span><span style="color: rgba(0, 0, 0, 1)">) {
setupTViewPreOrderHooks();
setupPrototype(componentInstance, </span>'ngOnInit'<span style="color: rgba(0, 0, 0, 1)">, ON_INIT_CALLBACKS_PROPERTY_NAME);
saveCallback(componentInstance, callback, ON_INIT_CALLBACKS_PROPERTY_NAME);

</span><span style="color: rgba(0, 0, 255, 1)">function</span><span style="color: rgba(0, 0, 0, 1)"> setupTViewPreOrderHooks() {
    const TVIEW </span>= 1<span style="color: rgba(0, 0, 0, 1)">;
    const PARENT </span>= 3<span style="color: rgba(0, 0, 0, 1)">;

    const viewContainerRef </span>=<span style="color: rgba(0, 0, 0, 1)"> inject(ViewContainerRef) as Any;
    const hostTNode </span>= viewContainerRef['_hostTNode'<span style="color: rgba(0, 0, 0, 1)">];
    const tNodeIndex </span>=<span style="color: rgba(0, 0, 0, 1)"> hostTNode.index;

    const lContainer </span>= viewContainerRef['_lContainer'<span style="color: rgba(0, 0, 0, 1)">];
    const targetLView </span>=<span style="color: rgba(0, 0, 0, 1)"> lContainer;
    const targetTView </span>=<span style="color: rgba(0, 0, 0, 1)"> targetLView;

    </span><span style="color: rgba(0, 0, 255, 1)">if</span> (!<span style="color: rgba(0, 0, 0, 1)">targetTView.preOrderHooks) {
      targetTView.preOrderHooks </span>=<span style="color: rgba(0, 0, 0, 1)"> ;
      </span><span style="color: rgba(0, 0, 255, 1)">return</span><span style="color: rgba(0, 0, 0, 1)">;
    }

    </span><span style="color: rgba(0, 0, 255, 1)">if</span> (!<span style="color: rgba(0, 0, 0, 1)">targetTView.preOrderHooks.includes(tNodeIndex)) {
      targetTView.preOrderHooks.push(tNodeIndex);
    }
}
}

export </span><span style="color: rgba(0, 0, 255, 1)">function</span> afterContentInit(componentInstance: AnyObject, callback: () =&gt; <span style="color: rgba(0, 0, 255, 1)">void</span><span style="color: rgba(0, 0, 0, 1)">) {
setupPrototype(componentInstance, </span>'ngAfterContentInit'<span style="color: rgba(0, 0, 0, 1)">, AFTER_CONTENT_INIT_CALLBACKS_PROPERTY_NAME);
saveCallback(componentInstance, callback, AFTER_CONTENT_INIT_CALLBACKS_PROPERTY_NAME);
}
</span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">#endregion</span></pre>
</div>
<span class="cnblogs_code_collapse">View Code</span></div>
<p>原理和 onInit 一样,而且更简单,因为它只需要 onInit 的后两个 step 就足够了。</p>
<p>&nbsp;</p>
<h2>&nbsp;</h2>
<p>&nbsp;</p>
<p>&nbsp;</p>
<h2>&nbsp;</h2>
<p>&nbsp;</p><br><br>
来源:https://www.cnblogs.com/keatkeat/p/17320930.html
頁: [1]
查看完整版本: Angular 20+ 高阶教程 – 信号 (Signals)