ReactiveScript 的探索
本文并非要教你前端开发的最新趋势,也不会深入探讨如何最大限度地提升网站性能。相反,我想写一些我过去一年一直在脑子里琢磨却一直没时间实现的东西:响应式作为通用语言。
如果你想找人怪罪,那就怪 Jay Phelps 吧(我开玩笑的)。在我做了一个演示,展示了细粒度响应式的强大功能后,他让我意识到我们应该把它看作一种更通用的语言。我当时沉浸在自己的 DSL 世界中,思考着如何让框架构建更容易,但他挑战我去更广泛地思考这个问题。
我一直想接受他的提议,但与此同时,我能做的就是写点东西。因为去年我做了很多研究,思考该如何处理这个问题。而且,最近围绕 Svelte、Vue Ref Sugar 以及我在 Marko 上的工作等话题,我觉得现在正是分享我所学知识的好时机。
命运操纵者
我读过的关于反应式编程的最好的介绍之一,其实是《什么是反应式编程?》。我不能保证它对初学者来说是最好的介绍。但它以一种非常简单的方式介绍了反应式。反应式是指一个等式在其值发生变化后仍然成立。如果,并且如果在或更新后仍然反映这个和,a = b + c
那么它就是反应式的。a
b
c
本文建议使用“命运运算符”<=
来表示这种关系:
var a = 10;
var b <= a + 1;
a = 20;
Assert.AreEqual(21, b);
语言的简单补充,却能实现如此多的功能。最重要的是,它突出了响应式声明和赋值之间的区别。b
重新赋值毫无意义,因为这样一来,它总是大于 1 的关系a
就不成立了。而如果a
需要重新赋值,否则这个系统实际上就没什么用了。
这仅仅是个开始。在很多方面,这都被视为理想状态。现实情况远比这复杂得多。我们稍后再讨论“命运操纵者”。
标识符
如果你曾经使用过 JavaScript 中的细粒度响应式库,你就会了解使用函数 getter/setter 的常见模式。它们可能隐藏在代理之后,但其核心是一个访问器,用于跟踪值并进行订阅。
const [value, setValue] = createSignal(0);
// log the value now and whenever it changes
createEffect(() => console.log(value()));
setValue(10); // set a new value
事实上,我认为大多数前端 JavaScript 框架都属于这三部分反应式 API/语言:
- 反应状态(信号、可观察、参考)
- 派生值(备忘录、计算)
- 副作用(效果、观察、反应、自动运行)
上面的例子使用了 Solid,但你应该能够很容易地在 React、Mobx、Vue、Svelte 等中想象出来。它们看起来都非常相似。
有关更详细的介绍,请查看《细粒度反应性实践介绍》
问题是,无论我们在运行时如何处理细粒度的响应式,都会有额外的语法。运行时不可能只让value
be 一个值并进行响应式处理。它将会是value()
orsomething.value
或value.something
。这是一个很小的人体工程学细节,但我们渴望解决它。
最简单的编译器辅助方法是修饰变量标识符,让它知道应该编译为函数调用。我第一次在Fidan框架中看到这种方法,后来又在社区为Solid创建的一些 Babel 插件中看到。
let value$ = createSignal(0);
// log the value now and whenever it changes
createEffect(() => console.log(value$));
value$ = 10; // set a new value
这样做的好处是,无论来源是什么,我们都可以使用这个语法糖:
let value$ = createCustomReactiveThing();
然而,现在我们的信号总是被当作一个值。我们如何将它传递到模块上下文之外并保持响应性?也许我们可以不用 ``` 来引用它$
?我们用 ```thunk``` 传递它吗() => value$
?我们是否为此发明了一种语法?我们能控制响应值是否为只读吗?如上所示,派生的响应值应该可以。我实际上看到过一个版本,其中 ```single``$
表示可变,` $$
`` 表示只读。
但关键在于,这种语法并没有简化思维模型。你需要清楚地知道传递了什么,接收了什么。这样可以节省一些字符输入,可能只需要 1 个字符,因为在不使用编译器技巧的情况下,表达响应式的最短方式是 2 个字符(()
或_.v
)。我很难相信添加所有这些是否值得。
关键字、装饰器、标签
那么如何做得更好呢?如果响应性是一个关键字、装饰器或标签呢?MobX 已经通过在类上使用装饰器来实现这一点很久了,但Svelte将其提升到了一个全新的水平。
基本思想是:
signal: value = 0;
// log the value now and whenever it changes
effect: console.log(value);
value = 10; // set a new value
Svelte 意识到,如果将每个变量都视为一个信号,就可以将其简化为:
let value = 0;
// log the value now and whenever it changes
$: console.log(value);
value = 10; // set a new value
如果这与“命运运算符”有相似之处,那就应该如此。Svelte 的$:
标签确实很接近它。他们意识到“命运运算符”的不足,因为不仅有响应式派生,还有像这样的副作用console.log
。因此,你$:
既可以使用像“命运运算符”这样的响应式声明来定义变量,也可以使用响应式有效表达式。
所以我们做对了。其实不然。这种方法存在很大的局限性。响应性是如何离开这个模块的?没有办法获取响应性信号本身的引用;只能获取它的值。
注意:Svelte 确实具有双向绑定语法,并且
export let
可以将响应式传递给父级到子级。但通常情况下,如果不使用像 Svelte Stores 这样的辅助响应式系统,您无法仅导出或导入函数就使其具有响应式。
我们如何知道该如何处理:
import createCustomReactiveThing from "somewhere-else";
let value = createCustomReactiveThing();
它是响应式的吗?它可以被赋值吗?对于这种情况,我们可以在标识符上引入一个符号,但这又回到了上一个解决方案的原点。如果你想提取一个派生类,doubleValue
模板该如何处理它呢?
let value = 0;
// can this
$: doubleValue = value * 2;
// become
const doubleValue = doubler(value);
不直观。我们有一个关键字(标签),它不会转置。
功能装饰
嗯,组合为王。这或许是React成功最重要的因素,对我们很多人来说,任何组合都是可行的。Svelte 通过其 store 实现了组合和可扩展性,但今天的重点是它作为响应式语言的不足之处。
还有另一种方法,我第一次接触是在大约两年前与Marko团队交流时。Marko 是一种有趣的语言,因为它非常重视标记语法,而且维护人员基本上已经决定将他们的响应式功能融入到他们的标签中。
<let/value = 0 />
<!-- log the value now and whenever it changes -->
<effect() { console.log(value); }/>
value = 10; // set a new value
乍一看确实很陌生,但通过使用标签,他们基本上解决了 Svelte 的问题。你知道这些是响应式的。它类似于 React 约定的use____
钩子语法版本。
有趣的是,大约一年后,尤雨溪在为Vue 3编写的 Ref Sugar API 第 2 版中也得出了同样的结论。第 1 版是像上面那样的标签,但他意识到这种方法的缺点,最终得到了:
let value = $ref(0)
// log the value now and whenever it changes
watchEffect(() => console.log(value));
value = 10; // set a new value
这和 Marko 的例子几乎一模一样。这种方法实际上满足了我们大部分的需求。我们重新获得了构图。
然而,在将引用传递到当前作用域之外时,仍然需要考虑一个问题。由于 Vue 将其用作语法糖,就像之前的标识符示例一样,它仍然需要告诉编译器何时需要通过引用而不是值传递,并且有一个$$()
函数可以做到这一点。例如,如果我们想要传递显式依赖项:
let value = $ref(0)
// log the value now and whenever it changes
watch($$(value), v => console.log(v));
注意watch
这里只是一个普通函数。它不知道该如何value
以不同的方式处理。如果置之不理,它会被编译为watch(value.value, v => ... )
,这会导致在跟踪范围之外过早地进行被动访问。
提案中有一些评论要求用 来$watch
处理这个问题,但我怀疑它们不会通过,因为这是 Vue$(function)
所没有的特定行为。Vue 的目标是可组合性,所以$watch
be 的特殊性是不可接受的。这基本上使它成为一个关键字,$mywatch
除非我们添加其他语法或对行为进行更通用的更改,否则它不会被赋予相同的行为。
事实上,除了 Marko 的标签之外,没有任何解决方案能够在不使用额外语法的情况下处理这种情况。Marko 可以利用标签的知识,做出一些普通函数无法做出的假设。而作为标签,我们无意中发现了我认为可能是真正解决方案的东西。
重新思考反应式语言
所有方法都面临着同样的挑战。如何保持响应性?我们总是担心失去它,所以我们不得不讨论“按引用传递”和“按值传递”。但那是因为我们生活在一个命令式的世界,我们是一个声明式的
女孩范例。
我来详细解释一下。Marko 使用<const>
标签来声明反应式派生。可以说是我们的“命运操作符”。这有时会让人感到困惑,因为派生值可能会改变,那么它怎么会是“const”呢?好吧,它永远不会被重新赋值,表达式永远有效。
当我试图向新人解释这一点时,Michael Rawlings(也是 Marko 团队的成员)澄清说,信号let
(Signal)才是特殊的,而不是派生const
(Derivation)。我们模板中的每个表达式都像派生一样,每个属性绑定、组件 prop 也都像派生一样。我们的<const value=(x * 2)>
和……没什么不同<div title=(name + description)>
。
这让我想到,如果我们反过来看会怎么样?如果表达式默认是响应式的,而我们需要表示命令式的逃生舱口,会怎么样?我们不需要“命运操作符”,而需要一个副作用操作符。
这听起来有点不可思议,因为改变 JavaScript 的语义却保持相同的语法,这难道不直观吗?我当时认为不会,但我的意思是,我们已经看到过这种做法取得了巨大的成功。Svelte 的脚本与“纯 JavaScript”完全不同,但人们似乎接受了它们,有些人甚至还以此作为宣传。
不久前我确实做过民意调查,虽然没有定论,但结果表明许多开发人员对语法比对语义更敏感。
所以问题是,我们能否在保留所有工具优势(甚至 TypeScript)的情况下,使用现有的 JavaScript 语法来做一些事情?我的意思是,像 Svelte、React Hooks 或 Solid 的 JSX 那样,完全颠覆 JavaScript 的执行方式,用纯 JavaScript 语法,以人们能够理解的方式实现。好吧,我们可以试试。
设计 ReactiveScript
尽管我对上述各种方法中做出的决策可能有些批评,但其中有很多优秀的前期工作值得借鉴。我认为如今的 Svelte 是一个很好的起点,因为它语法简单,并且已经扭曲了预期的语义。以上图为例,我们希望将 提升console.log
到另一个函数中(也许是从另一个模块导入的)。这不是 Svelte 目前能做到的,但也许是这样的:
function log(arg) {
$: console.log(arg);
}
let value = 0;
// log the value now and whenever it changes
log(value);
value = 10; // set a new value
为了直观地展示实际行为,我将把它们“编译”成 Solid 的显式运行时语法。虽然基于运行时并非必需。
function log(arg) {
createEffect(() => console.log(arg());
}
const [value, setValue] = createSignal(0);
// log the value now and whenever it changes
log(value); // or log(() => value())
setValue(10); // set a new value
所有函数参数都被包装在函数中(或直接传递函数)。所有局部作用域变量都被作为函数调用。
如果我们想创建一个派生值怎么办?在我们新的响应式世界中,它可能看起来像这样:
let value = 0;
const doubleValue = value * 2;
// log double the value now and whenever it value changes
log(doubleValue);
value = 10; // set a new value
或者我们甚至可以将其吊出:
function doubler(v) {
return v * 2;
}
let value = 0;
const doubleValue = doubler(value);
可以编译为:
function doubler(v) {
return () => v() * 2;
}
const [value, setValue] = createSignal(0);
const doubleValue = doubler(value);
你可能会对这个例子感到困惑,因为真的有什么东西会运行吗?嗯,除非需要,否则它不会运行。就像它被用于表示为 的副作用中一样$:
。我们有一种惰性求值语言,它只在绝对需要时运行代码。
我们的派生值仍然赋值给 a,const
因此它保持一致。无需新的语法来确切了解其行为。从某种意义上说,响应式值从变异的角度来看不会像 Svelte 那样脱离其局部作用域,但从跟踪的角度来看会。这在提供局部变异便利的同时,保留了清晰的控制。
这种“每个表达式都是响应式的”原则也可以扩展到语言原语。类似于 Solid 在 JSX 中转换三元运算符的方式,我们可以查看if
andfor
语句之类的内容并进行相应的编译。
let value = 0;
if (value < 5) {
log("Small number");
} else log("Large number");
// logs "Small number"
value = 10;
// logs "Large number"
一旦条件发生变化,这段代码最终会同时运行两个分支if
。而那些副作用根本不需要console.logs
,可以是任何类似 JSX 的东西。
如果您可以编写这样的组件并使其以最少的执行细粒度反应性工作,那会怎样?
function Component({ visible }) {
let firstName, lastName = "";
if (!visible) return <p>Hidden</p>;
// only do this calculation when visible
const fullName = `${firstName} ${lastName}`
return <>
<input onInput={e => firstName = e.target.value} />
<input onInput={e => firstName = e.target.value} />
<p>{fullName}</p>
</>
}
尝尝味道
说实话,有很多细节需要处理。比如循环。在这个范式中,我们自然想要一个.map
运算符而不是一个for
,那么我们该如何协调呢?不过,这样做的好处是,它是可分析的,并且所应用的模式是一致的。
此类系统的性能可能需要更多考量。我认为,通过额外的分析和编译时方法,这实际上更有潜力。查看哪些代码let
/const
哪些代码实际上是有状态的,可以决定哪些代码需要包装或不需要包装。一旦走上这条路,它就会发挥巨大的作用。它可以用作诸如部分水合之类的工具,以准确了解哪些代码实际上可以更新并发送到浏览器。
说实话,目前这只是个想法。关于如何实现,我还有很多想法。不过,看了最近的讨论,我觉得有人可能会有兴趣探索一下,我鼓励他们联系我,一起讨论!
文章来源:https://dev.to/this-is-learning/the-quest-for-reactivescript-3ka3