浅析无虚拟DOM的Svelte如何实现响应式

671 阅读3分钟

学不动了

前段时间听闻Vue后续也会考虑无虚拟DOM(Virtual DOM)的形式,让我直呼学不动了。但是为了不让自己早早被淘汰,最终还是努力爬起来看一下相关的思路。

话说回来,其实第一眼看到无虚拟DOM的时候,内心是在想这不是在开历史倒车么?毕竟Web前端这块,一开始也没啥虚拟DOM,是后来才提出这个概念,现在又要废弃了???虚拟DOM可以很方便地做跨端相关,另外性能也还不错,尤其是现在的设备越来越好了,那为啥还走回到老路呢。

想得再多也没用,还是得看看无虚拟DOM相关的框架,这边有代表性的主要是SvelteSolid.js话说,之前并没有注意还有这样的框架,孤陋寡闻了)。这边就先选Sveltesvelte.dev/)来研究下,其官网上提供的教程挺不错,以纠错形式让你明白语法和坑点。

再说响应式

这里的响应式是指当状态改变时,怎么样自动更新UI视图。无论是Vue还是React,其大体思路都是,将源代码编译出render方法,运行时,当状态变更,会自动调用render方法生成新的虚拟DOM树,再与老的虚拟DOM树做比较找出需要更新的虚拟DOM,最后根据结果操作真实DOM修改更新。当然,VueReact在实现思路上还是有很大的区别的。

那如果没有上面这些框架,怎么样实现响应式呢?其实就是在状态变化的时候,主动操作真实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.pinstance

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
  • 编译阶段,会自动分析模板中依赖的数据,并让数据变更自动触发脏检查
  • 运行时,数据变更情况以二进制形式标记,并在微任务中一起触发重新渲染

另外,这个也是本人粗浅的研究,如有错误,请指正。