Svelte 更新流程及与React,Vue更新流程对比

732 阅读4分钟

svelte 更新流程

调试方法

从官网的 REPL 进去,可以看到右侧 JS ouput 的内容,这些就是左侧的 svelte 代码编译后的结果,如左侧内容:

<script>
	let name = 'world';
	function changeName() {
		name = 'Ya';
	}
</script>

<h1>Hello {name}!</h1>

右侧 JS output内容:

/* App.svelte generated by Svelte v3.31.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(/*name*/ 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 & /*name*/ 1) set_data(t1, /*name*/ ctx[0]);
		},
		i: noop,
		o: noop,
		d(detaching) {
			if (detaching) detach(h1);
		}
	};
}

function instance($$self, $$props, $$invalidate) {
	let name = "world";

	function changeName() {
		$$invalidate(0, name = "Ya");
	}

	return [name];
}

class App extends SvelteComponent {
	constructor(options) {
		super();
		init(this, options, instance, create_fragment, safe_not_equal, {});
	}
}

export default App;

将上面的代码下载下来,本地安装以来后,运行 yarn dev ,然后观察 public 里的 build 文件夹中的 bundle.js 文件。以下内容贴出来的代码均是 dev 环境下的 bundle.js 文件的内容。

初次渲染流程

init -> instance(初始化 ctx) -> before_update -> $$.fragment.c -> (transition_in 动画) -> mont_component -> fragment.m(挂载到 target 上去) -> after_update -> flush -> render_callbacks

更新流程

更新流程可以划分为三个阶段:

事件触发([点击事件] -> $$invalidate [instance第三个参数]) -> 标记更新(make_dirty) -> 更新(schedule_update -> flush -> update -> \.fragment.p [一些事件也会在此调用])

更新流程中关键的方法

  1. instance

    function instance($$self, $$props, $$invalidate) {
      let name = "world";
      function changeName() {
    		$$invalidate(0, name = "Ya");
    	}
      return [name, changeName];
    }
    

    结合 init 方法来看:

    function init(component, options, instance, create_fragment, not_equal, props, dirty = [-1]) {
      // 忽略其他无关代码
      $$.ctx = instance
                ? instance(component, prop_values, (i, ret, ...rest) => {
                    const value = rest.length ? rest[0] : ret;
                    if ($$.ctx && not_equal($$.ctx[i], $$.ctx[i] = value)){
                        if (!$$.skip_bound && $$.bound[i])
                            $$.bound[i](value);
                        if (ready)
                            make_dirty(component, i);
                    }
                    return ret;
                })
                : [];
    }
    

    可以看到,当调用 changeName 方法时,实质上是调用了 instance 方法中第三个参数的方法,进行了对比和赋值,然后调用 make_dirty 方法往下走。

  2. make_dirty 和 schedule_update

    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));
    }
    
    function schedule_update() {
      if (!update_scheduled) {
        update_scheduled = true;
        resolved_promise.then(flush);
      }
    }
    

    当开始更新时,component.$$.dirty[0] 的值是 -1 ,开启了 schedule_update ,然后会将 flush 放置到微任务中。

    component.$$.dirty[(i / 31) | 0] |= (1 << (i % 31)) 的作用是标记需要更新的变量在 ctx 的位置,以便在 $$.fragment.p 方法中命中更新DOM的方法。

    为什么标记方法会这样计算?

    这里是使用了位计算。首先我们要知道一个前提:svelte 利用二进制来保存需要更新的变量在ctx的位置,如:component.$$.dirty[8] 可表示为 component.$$.dirty[0000000000000000000000000001000] ,表示是 ctx 数组内第三项需要更新。

    为什么是31位二进制表示?

    www.w3school.com.cn/js/pro_js_o…

    在 ECMAScript 中,所有整数字面量默认都是有符号整数。有符号整数使用 31 位表示整数的数值,用第 32 位表示整数的符号,0 表示正数,1 表示负数。数值范围从 -2147483648 到 2147483647。

    当页面变量超出了31个,怎么办?

    component.$$.dirty[0, 8] 表示是 ctx数组内第 34 个变量发生了变化。

    现在变量标记已经结束,至于怎么在更新中命中更新 DOM 的方法将会在 $$.fragment.p 中揭晓。

  3. flush

    do {
      // first, call beforeUpdate functions
      // and update components
      for (let i = 0; i < dirty_components.length; i += 1) {
        const component = dirty_components[i];
        set_current_component(component);
        update(component.$$);
      }
      set_current_component(null);
      dirty_components.length = 0;
      while (binding_callbacks.length)
        binding_callbacks.pop()();
      // then, once components are updated, call
      // afterUpdate functions. This may cause
      // subsequent updates...
      for (let i = 0; i < render_callbacks.length; i += 1) {
        const callback = render_callbacks[i];
        if (!seen_callbacks.has(callback)) {
          // ...so guard against infinite loops
          seen_callbacks.add(callback);
          callback();
        }
      }
      render_callbacks.length = 0;
    } while (dirty_components.length);
    

    这是 flush 的核心代码,主要有两个作用:一是,如果 render_callbacks 非空,则逐一调用render_callbacks 内的事件 ;二是update对应的 dirty_component

  4. update

    function update($$) {
      if ($$.fragment !== null) {
        $$.update();
        run_all($$.before_update);
        const dirty = $$.dirty;
        $$.dirty = [-1];
        $$.fragment && $$.fragment.p($$.ctx, dirty);
        $$.after_update.forEach(add_render_callback);
      }
    }
    

    更新对应的事件和DOM。$$.fragment.p($$.ctx, dirty) 就是更新对应的DOM的方法。

  5. \.fragment.p

    function create_fragment(ctx) {
      // 忽略无关代码
      return {
        (ctx, dirty) {
          if (dirty[0] & /*name*/ 1) set_data_dev(t1, /*name*/ ctx[0]);
          if (dirty[0] & /*name1*/ 2) set_data_dev(t5, /*name1*/ ctx[1]);
          if (dirty[0] & /*name2*/ 4) set_data_dev(t9, /*name2*/ ctx[2]);
          if (dirty[0] & /*name3*/ 8) set_data_dev(t13, /*name3*/ ctx[3]);
          // 如果超过 31 个变量/更多变量,则会表示如下
          if (dirty[1] & /*name31*/ 1) set_data(t125, /*name31*/ ctx[31]);
          if (dirty[1] & /*name32*/ 2) set_data(t129, /*name32*/ ctx[32]);
          if (dirty[1] & /*name33*/ 4) set_data(t133, /*name33*/ ctx[33]);
        }
      }
    }
    

    通过 dirty[index](index 是 i / 31 的值) 和 2的n次幂 & 操作来定位更新那些DOM。如:

    // dirty[0] 是 7
    7 & 1 === 1
    7 & 2 === 2
    7 & 4 === 4
    7 & 8 === 0
    // 表示ctx数组内第 0,1,2个位置上的变量发生了变化,需要更新对应的DOM
    
    // dirty[0] 是 8
    8 & 1 === 0
    8 & 2 === 0
    8 & 4 === 0
    8 & 8 === 8
    // 表示ctx数组内第 3 个位置上的变量发生了变化,需要更新对应的DOM
    

简单总结一下:

init 方法里,执行 instance 方法将返回 component 中使用到的变量和方法,并保存在 ctx 变量中,其中变量和方法在 ctx 的次序很重要 ,后续的更新操作就是按这个次序来寻找对应的更新DOM方法去更新视图的。

component 内的事件/方法里对应的变量更新,将会改造为一个方法(该方法是 instance 方法的第三个参数),该变量在 ctx 的次序和变量的更新值将会成为这个方法的第一二变量(该变量如是引用类型,该方法的第三参数将是该变量),执行后,将会触发 make_dirty 方法,该方法将开启这次更新(schedule_update),并标记需要更新的标量的位置。

这次更新被开启后,flush 将会放在微任务中执行(resolved_promise.then(flush))以确保所有更新的变量的次序都被标记了,然后执行,将会调用 update 方法,该方法是更新的执行者,执行相关的事件和调用 $$.fragment.p 将更新绘制到视图中。

至此,一轮更新流程结束。

其中,次序是最重要,最关键的,但又是最容易被忽略的。

通过编译,变量将会在ctx数组中拥有自己的次序,方法在数组中处于变量之后。更新变量的方法中,执行的 $$invalidate 将会传递该变量的在ctx中的次序,后续的所有方法,都会跟这个次序相关,无论是对比,赋值,标记,更新。

所以,变量在 ctx 的次序,将会贯穿它短短的的一生。

简单类型更新

由上,我们知道,当执行更新的时候,最终会到达这步:

// 这是 dev 下的
 if (dirty[0] & /*name*/ 1) set_data_dev(t1, /*name*/ ctx[0]);

// set_data_dev
function set_data_dev(text, data) {
  data = '' + data;
  if (text.wholeText === data)
    return;
  dispatch_dev('SvelteDOMSetData', { node: text, data });
  text.data = data;
}

// build 后的 set_data
function set_data(text, data) {
  data = '' + data;
  if (text.wholeText !== data)
    text.data = data;
}

引用类型-数组类型更新

注意,使用 push/pop 等方法操作数组后,视图并不会更新,直到将新的数组赋值给数组变量后,才会触发更新。

<script>
let arr = [1, 2, 3];
function changeArr() {
	// arr 改变了,但视图并不会更新
  arr.push(4);
}
</script>

为什么呢?

因为编译后,这一块没有被改造为 $$invalidate 方法,而是原样输出了:

function instance($$self, $$props, $$invalidate) {
  // 忽略无关代码
  function changeArr() {
    arr.push(4);
  }
}

arr 的值的确是改变了,但是没有经过更新的流程处理,也就无法渲染到视图上。

此处有个有趣的现象,如果有一个方法(changeArrFn)是可以正常更新 arr 的,那么当 changeArr 运行后,再运行changeArrFn,那么 changeArr 改动将会渲染到视图上。

正确示例:

<script>
let arr = [1, 2, 3];
function changeArr() {
  arr.push(4);
  arr = arr;
}
</script>

编译后:

function instance($$self, $$props, $$invalidate) {
  // 忽略无关代码
  function changeArr() {
    arr.push(4);
    $$invalidate(0, arr);
  }
}

引用类型-对象类型更新

<script>
	let obj = {
		some: 123,
		any: 456
  }
	function changeObj() {
		obj.people = 789;
		obj = {...obj, some: 1230}
  }
</scripit>

编译后:

function instance($$self, $$props, $$invalidate) {
  // 忽略无关代码
  function changeObj() {
    $$invalidate(36, obj.some = 8000, obj);
    $$invalidate(36, obj.people = 789, obj);
    $$invalidate(36, obj = { ...obj, some: 1230 });
  }
}

正常更新即可,不必要 Object.assign / {...obj} 这样更新。

数组更新详细介绍

svelte:

<script>
let arr = [
	{
		id: 1,
		text: 'A'
	}, {
		id: 2,
		text: 'B'
	}, {
		id: 3,
		text: 'C'
	}, {
		id: 4,
		text: 'D'
	}
]
</script>
<ul>
	{#each arr as item}
		<span>{item.text}</span>
	{/each}
</ul>	

编译后代码(请不要在意次序,因为代码片段是从完整程序里提取出来的):

function get_each_context_1(ctx, list, i) {
  const child_ctx = ctx.slice();
  child_ctx[42] = list[i];
  return child_ctx;
}

function create_each_block_1(ctx) {
  let span;
  let t_value = /*item*/ ctx[42].text + "";
  let t;

  const block = {
    c: function create() {
      span = element("span");
      t = text(t_value);
    },
    m: function mount(target, anchor) {
      insert_dev(target, span, anchor);
      append_dev(span, t);
    },
    p: function update(ctx, dirty) {
      if (dirty[1] & /*arr*/ 16 && t_value !== (t_value = /*item*/ ctx[42].text + "")) set_data_dev(t, t_value);
    },
    d: function destroy(detaching) {
      if (detaching) detach_dev(span);
    }
  };

  return block;
}

function create_fragment(ctx) {
  // 忽略无关代码
  const block = {
    // 忽略无关代码
    p: function update() {
      // 忽略无关代码
      if (dirty[1] & /*arr*/ 16) {
        each_value_1 = /*arr*/ ctx[35];
        validate_each_argument(each_value_1);
        let i;

        for (i = 0; i < each_value_1.length; i += 1) {
          const child_ctx = get_each_context_1(ctx, each_value_1, i);

          if (each_blocks_1[i]) {
            each_blocks_1[i].p(child_ctx, dirty);
          } else {
            each_blocks_1[i] = create_each_block_1(child_ctx);
            each_blocks_1[i].c();
            each_blocks_1[i].m(ul, null);
          }
        }

        for (; i < each_blocks_1.length; i += 1) {
          each_blocks_1[i].d(1);
        }

        each_blocks_1.length = each_value_1.length;
      }
    }
  };
  
  return block;
}

着重看 create_fragmentupdate 方法,当运行这行代码时:

const child_ctx = get_each_context_1(ctx, each_value_1, i);

分析一下 get_each_context_1 方法:

function get_each_context_1(ctx, list, i) {
  // 深拷贝 ctx
  const child_ctx = ctx.slice();
  // 当前 ctx 的长度是 41,所以 child_ctx[42] 是额外增加的
  // 用于保存当前list第i项的值
  child_ctx[42] = list[i];
  return child_ctx;
}

可知,child_ctx 就是 ctx 的值和 arr 第 i 个值的集合。

看一下更新DOM的方法:

function create_each_block_1(ctx) {
  // 忽略无关代码
  const block = {
    p: function update(ctx, dirty) {
      if (dirty[1] & /*arr*/ 16 && t_value !== (t_value = /*item*/ ctx[42].text + "")) set_data_dev(t, t_value);
    },
  }
}

对比的时候,也是使用了 ctx 的第 42 项,根据

if (each_blocks_1[i]) {
  each_blocks_1[i].p(child_ctx, dirty);
}

这行代码,可知,ctx 就是 child_ctx

数组更新时对比判断用到的对比数据是深拷贝ctxchild_ctx 的最后一项(ctx.length + 1)。

根据以上分析,总结一下数组更新对应的视图表现:

  1. 当顺序地插入数组尾部时(数据表现),svelte会创建节点并插入(视图表现)

    a b c --> a b c d

    d节点是新创建的,插入到最后去

  2. 当更新其中的某一/多项时(数据表现),会顺序地更新(视图表现)

    a b c --> a d c

    b 节点会被更新为 d

  3. 当插入到某个位置时(数据表现),等同于顺序地更新和创建节点并插入到数组尾部(视图表现)

    a b c --> a d b c

    a 节点不变,b 节点会被更新为 d,c节点会被更新为 b,然后新增一个节点,保存c,插入到最后。

set_data 后,如何将更新渲染到视图上?

纵观 svelte ,它会创建两种类型的元素:

// TextNode
function text(data) {
  return document.createTextNode(data);
}

// Other
function element(name) {
  return document.createElement(name);
}

其中,变量发生变化需要需要渲染到视图上的是 TextNode ,该元素具有比较特殊的地方是,当设置它的data属性之后,浏览器将会更新它的值。

let node = document.createTextNode('hello~');
console.log(node.data);
node.data = 'happy~';

可以在 这里 ,将 myFunction 改为以下内容,看下运行的结果。

function myFunction(){
	var t=document.createTextNode("Hello World");
	document.body.appendChild(t);
	setTimeout(() => {
		t.data = '100fen'
	}, 1000);
};

其他元素的情况下,如果是属性更新,如图片属性更新:

<script>
  import png1 from './1.jpg';
  import png2 from './2.jpg';
  
	let src = png1;
	functioin changeSrc() {
		src = png2;
	}
</script>

<div on:click={changeSrc}>change img</div>

<img src={src} alt="" style="width: 100px;">

那么,它将会调用 attr 方法去更新:

function attr(node, attribute, value) {
  if (value == null)
    node.removeAttribute(attribute);
  else if (node.getAttribute(attribute) !== value)
    node.setAttribute(attribute, value);
}

该属性更新后会立即反应到浏览器上,浏览器将会自动更新应用该属性。

还有更多更新的细节,未一一说明。

与 React ,Vue 相比

更新流程

React

setState -> render(beginWork 和 completeWork [diff将会在render阶段完成])-> commit(before mutation, mutation, layout)

Vue

watcher -> vNode(diff将会在此阶段完成) -> patch -> DOM

Svelte

回调事件 -> make_dirty -> flush -> update -> \.fragment.p -> set_data

明显地看到,两者的区别就是React 和 Vue 都需要 diff 计算,而Svelte是通过 make_dirty 来标记更新的(这里也是官方所说的 No Virtual DOM)。

那么 Virtual DOM 主要的作用是:

  1. 跨平台
  2. 掩盖操作DOM操作,由命令式编程转变为声明式编程
  3. 性能提升

Virtual DOM 性能提升体现

场景:在列表中插入一行新数据,如(每个字母代表一个节点)

A B C D E F --> A B G C D E F

Svelte的做法:

A,B不变,C -> G, D -> C, E -> D, F -> E,新增一行 F

React / Vue 可能的做法:

A,B不变,新增一行 F ,插入到 B 的后面,然后 C, D, E, F 往后移动一个位置

对比可知,React / Vue 理想的情况下会尽可能地复用原有的DOM,而 Svelte 将会顺从直觉地覆盖原有的DOM元素,然后在最后新增元素。这是 Virtual DOM 性能提升的体现之处。

Svelte 性能提升体现

正如官方所言:No Virtual DOM。

Virtual DOM 的 diff 会有性能损耗(毕竟需要比对,标记),而 Svelte 的更新不需要经过 Virtual DOM 的 diff 处理,直击要害。

OSZAR »