学不动了
前段时间听闻Vue
后续也会考虑无虚拟DOM(Virtual DOM
)的形式,让我直呼学不动了。但是为了不让自己早早被淘汰,最终还是努力爬起来看一下相关的思路。
话说回来,其实第一眼看到无虚拟DOM的时候,内心是在想这不是在开历史倒车么?毕竟Web前端这块,一开始也没啥虚拟DOM,是后来才提出这个概念,现在又要废弃了???虚拟DOM可以很方便地做跨端相关,另外性能也还不错,尤其是现在的设备越来越好了,那为啥还走回到老路呢。
想得再多也没用,还是得看看无虚拟DOM相关的框架,这边有代表性的主要是Svelte
和Solid.js
(话说,之前并没有注意还有这样的框架,孤陋寡闻了)。这边就先选Svelte
(svelte.dev/)来研究下,其官网上提供的教程挺不错,以纠错形式让你明白语法和坑点。
再说响应式
这里的响应式是指当状态改变时,怎么样自动更新UI视图。无论是Vue
还是React
,其大体思路都是,将源代码编译出render
方法,运行时,当状态变更,会自动调用render
方法生成新的虚拟DOM树,再与老的虚拟DOM树做比较找出需要更新的虚拟DOM,最后根据结果操作真实DOM修改更新。当然,Vue
和React
在实现思路上还是有很大的区别的。
那如果没有上面这些框架,怎么样实现响应式呢?其实就是在状态变化的时候,主动操作真实DOM重新渲染一下UI,类似:
function renderUI() {
// ... 根据状态操作真实DOM来渲染UI
}
// 某些操作
function click() {
// 修改状态
count += 1;
// 重新渲染
renderUI();
}
而Svelte
就是这么做的,不过它提供了一个强大的编译器,让你可以写很少的代码。比如上面这个例子,你只需要写:
count += 1;
它自动会帮你编译成类似:
count += 1;
renderUI();
Svelte
Svelte
的作者就是Rollup
的作者,NB的人总是各种NB。另外,Svelte
有非常重的编译器,而其运行时代码就非常轻了。先看看最简单的Hello World
的实现:
<script>
let name = 'world';
</script>
<h1>Hello {name}!</h1>
非常简单,基本就是普通的HTML语法了。不过得看看编译后的代码,毕竟Svelte
是重编译的:
/* App.svelte generated by Svelte v3.49.0 */
import {
SvelteComponent,
detach,
element,
init,
insert,
noop,
safe_not_equal
} from "svelte/internal";
function create_fragment(ctx) {
let h1;
return {
c() {
h1 = element("h1");
h1.textContent = `Hello ${name}!`;
},
m(target, anchor) {
insert(target, h1, anchor);
},
p: noop,
i: noop,
o: noop,
d(detaching) {
if (detaching) detach(h1);
}
};
}
let name = 'world';
class App extends SvelteComponent {
constructor(options) {
super();
init(this, options, null, create_fragment, safe_not_equal, {});
}
}
export default App;
代码行数比较多,但是其实逻辑还是比较简单直观的,其中element
(创建DOM)、insert
(插入到DOM树)、detach
(脱离DOM树)方法,都是直接操作真实DOM的:
export function element<K extends keyof HTMLElementTagNameMap>(name: K) {
return document.createElement<K>(name);
}
export function insert(target: Node, node: Node, anchor?: Node) {
target.insertBefore(node, anchor || null);
}
export function detach(node: Node) {
node.parentNode.removeChild(node);
}
而safe_not_equal
就是比较两个元素是否不相等,但是如果是对象或者函数,则直接认为不相等。这一点直接影响后面要说的响应式相关。
export function safe_not_equal(a, b) {
return a != a ? b == b : a !== b || ((a && typeof a === 'object') || typeof a === 'function');
}
PS:看
Svelte
源代码的时候,发现其代码风格与自己熟悉的不太一样,比如上面的safe_not_equal
方法,我会为... ? ... : ...
三元运算符:
后面那一长串代码加个括号来强调优先级(因为自己并没有去仔细记各种操操作符的优先级以及结合性),而它并不是必须的,所以有时候看代码的时候得花一点时间看看优先级之类的T_T
另外,SvelteComponent
是组件基类,noop
是空方法,都可以先不管。主要逻辑都在init
方法里面。为了方便说明,精简了一下init
的源码:
export function init(component, options, instance, create_fragment, not_equal, props, append_styles, dirty = [-1]) {
// ...一些代码
const $$: T$$ = component.$$ = {
fragment: null,
ctx: null,
// ...一些代码
};
// ...一些代码
// `false` as a special case of no DOM component
$$.fragment = create_fragment ? create_fragment($$.ctx) : false;
// ...一些代码
$$.fragment && $$.fragment!.c();
// ...一些代码
$$.fragment && $$.fragment.m(options.target, options.anchor);
// ...一些代码
}
这里算是给组建初始化$$
属性,然后再把组建挂载上去,里面涉及到了一个T$$
类型,这边也精简展示一下:
interface T$$ {
ctx: null | any;
fragment: null | false | Fragment;
// ...一些代码
}
export interface Fragment {
/* create */ c: () => void;
/* mount */ m: (target: HTMLElement, anchor: any) => void;
/* update */ p: (ctx: any, dirty: any) => void;
/* destroy */ d: (detaching: 0 | 1) => void;
// ...一些代码
}
综合分析,之前定义的create_fragment
方法,就是把所有DOM操作都包了一下,而后在init
的时候,调用了fragment.c
方法来创建,而后调用fragment.m
方法来插入DOM树中,这样就可以在页面上看到了。
说明完Hello World
,终于可以开始进入响应式相关的逻辑了。
Svelte的响应式
之前的例子并没有状态变化,那么整一个简单的状态变化的例子:
<script>
let count = 1;
setInterval(() => {
count++;
}, 1000);
</script>
<h1>Hello {count}!</h1>
例子也很简单,每秒count
自增1。然后看看其编译后的样子:
/* App.svelte generated by Svelte v3.49.0 */
import {
SvelteComponent,
append,
detach,
element,
init,
insert,
noop,
safe_not_equal,
set_data,
text
} from "svelte/internal";
function create_fragment(ctx) {
let h1;
let t0;
let t1;
let t2;
return {
c() {
h1 = element("h1");
t0 = text("Hello ");
t1 = text(/*count*/ ctx[0]);
t2 = text("!");
},
m(target, anchor) {
insert(target, h1, anchor);
append(h1, t0);
append(h1, t1);
append(h1, t2);
},
p(ctx, [dirty]) {
if (dirty & /*count*/ 1) set_data(t1, /*count*/ ctx[0]);
},
i: noop,
o: noop,
d(detaching) {
if (detaching) detach(h1);
}
};
}
function instance($$self, $$props, $$invalidate) {
let count = 1;
setInterval(() => {
$$invalidate(0, count++, count);
},
1000
);
return [count];
}
class App extends SvelteComponent {
constructor(options) {
super();
init(this, options, instance, create_fragment, safe_not_equal, {});
}
}
export default App;
比之前的Hello World
又多了好多行代码,主要体现在create_fragment
的逻辑变多了,将<h1>Hello {count}!</h1>
打散成了4部分来创建,并多了p
方法(update
方法),多了一个instance
方法,而我们自增逻辑也都在被放到这个里面,变成了$$invalidate(0, count++, count);
这里的instance
方法也是传入init
里面的,只不过之前Hello World
例子中是null
,所以之前写的init
方法里面,我把涉及instance
忽略掉了,现在再加回来:
export function init(component, options, instance, create_fragment, not_equal, props, append_styles, dirty = [-1]) {
// ...一些代码
let ready = false;
$$.ctx = instance
? instance(component, options.props || {}, (i, ret, ...rest) => {
const value = rest.length ? rest[0] : ret;
if ($$.ctx && not_equal($$.ctx[i], $$.ctx[i] = value)) {
// ...一些代码
if (ready) make_dirty(component, i);
}
return ret;
})
: [];
$$.update();
ready = true;
// ...一些代码
}
综合起来看,可以当作:
$$invalidate = (i, ret, ...rest) => {
const value = rest.length ? rest[0] : ret;
if ($$.ctx && not_equal($$.ctx[i], $$.ctx[i] = value)) {
// ...一些代码
if (ready) make_dirty(component, i);
}
return ret;
}
另外,这也说明,模板中依赖的变量,都会存储在$$.ctx
这个数组中,之后都是用索引来访问。这边传入的instance
方法会返回[count]
,而$$invalidate(0, count++, count);
就是访问数组第一项。
需要注意
$$invalidate(0, count++, count);
这玩意,如果当前count = 1
,那么这里就相当于$$invalidate(0, 1, 2);
。count++
会先返回count
,然后再++
。
再细分析$$invalidate
的实现,可以发现value
优先取第三个参数的值,再使用not_equal($$.ctx[i], $$.ctx[i] = value)
来判断值是否变化,如果有变化,再标记组件为脏make_dirty(component, i)
。
这里又涉及到一个有点绕的判断
not_equal($$.ctx[i], $$.ctx[i] = value)
,仔细看,这一步相当于这一步相当于not_equal($$.ctx[i], value); $$.ctx[i] = value;
这么两步的效果,既保证了$$.ctx[i]
为最新值,又能正确传入前后值。
接着看看make_dirty
的实现:
function make_dirty(component, i) {
if (component.$$.dirty[0] === -1) {
dirty_components.push(component);
schedule_update();
component.$$.dirty.fill(0);
}
component.$$.dirty[(i / 31) | 0] |= (1 << (i % 31));
}
这里挺传统的处理,就是把组建塞到脏队列里面,通过schedule_update
择机处理。比较特殊的是,其通过dirty[0] === -1
来表示组件是否需要重新渲染,另外有哪些依赖值变化,是通过二进制01来标记的(1 << (i % 31))
,一组最多记录31个值的变化情况,超过了就是加一组。
再来看看到底是什么时候更新的,查看一下schedule_update
方法:
const resolved_promise = Promise.resolve();
let update_scheduled = false;
export function schedule_update() {
if (!update_scheduled) {
update_scheduled = true;
resolved_promise.then(flush);
}
}
就是在Promise.then
产生的微任务里面去执行flush
:
let flushidx = 0;
export function flush() {
// ...一些代码
while (flushidx < dirty_components.length) {
const component = dirty_components[flushidx];
flushidx++;
// ...一些代码
const dirty = component.$$.dirty;
component.$$.dirty = [-1];
component.$$.fragment && component.$$.fragment.p($$.ctx, dirty);
}
// ...一些代码
update_scheduled = false;
// ...一些代码
}
这里可以看到核心就是会调用fragment.p
方法(update方法),这样最终回到了我们一开始create_fragment
中的逻辑:
p(ctx, [dirty]) {
if (dirty & /*count*/ 1) set_data(t1, /*count*/ ctx[0]);
}
注意这里是**&
,也就是判断dirty
对应标记为上是否为1**来看是否要执行这次更新,而set_data
就是直接操作DOM了:
export function set_data(text, data) {
data = '' + data;
if (text.wholeText !== data) text.data = data;
}
到这里,一个完整的响应式闭环逻辑梳理好了。
不过,如果你足够仔细,你会发现还有疑惑未解,核心在于之前not_equal($$.ctx[i], $$.ctx[i] = value)
这样的判断应该只适用于$$.ctx[i]
为值类型的时候,如果是引用类型,如对象,改其中内部值,地址是不会变的!
那就再写一个引用类型的示例看看:
<script>
const a = { count: 1 };
setInterval(() => {
a.count++;
}, 1000);
</script>
<h1>Hello {a.count}!</h1>
其编译后与之前的差不多,有差别的就是fragment.p
和instance
:
p(ctx, [dirty]) {
if (dirty & /*a*/ 1 && t1_value !== (t1_value = /*a*/ ctx[0].count + "")) set_data(t1, t1_value);
}
function instance($$self, $$props, $$invalidate) {
const a = { count: 1 };
setInterval(() => {
$$invalidate(0, a.count++, a);
},
1000
);
return [a];
}
那就先看看$$invalidate(0, a.count++, a);
是否会触发组件脏标记。带入之前的函数,可以得出value = a
,然后逻辑判断not_equal(a, a)
,这里看起来应该会返回false
。其实不然,因为我们在梳理Hello World
示例的时候,传入了safe_not_equal
方法来作为not_equal
判断,**而safe_not_equal
对于引用类型直接返回true
!**所以这里会触发组件脏标记。
在来看更新的时候的判断dirty & /*a*/ 1 && t1_value !== (t1_value = /*a*/ ctx[0].count + "")
:&
和!==
的优先级都比&&
高,所以不需要括号,而t1_value !== (t1_value = /*a*/ ctx[0].count + "")
又使用了类似之前not_equal
的技巧,对于值类型来说可以正常判断。所以引用类型也没有问题。
总结一下
总的来说,Svelte
非常重编译时,轻运行时,对于响应式,有以下简单结论:
- 编译阶段,会生成真实DOM操作的代码,无虚拟DOM
- 编译阶段,会自动分析模板中依赖的数据,并让数据变更自动触发脏检查
- 运行时,数据变更情况以二进制形式标记,并在微任务中一起触发重新渲染
另外,这个也是本人粗浅的研究,如有错误,请指正。