学不动了
前段时间听闻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
- 编译阶段,会自动分析模板中依赖的数据,并让数据变更自动触发脏检查
- 运行时,数据变更情况以二进制形式标记,并在微任务中一起触发重新渲染
另外,这个也是本人粗浅的研究,如有错误,请指正。