响应式
svelte的响应式并非Vue型(数据读取时收集依赖、数据更新时劫持操作),也非React的用于数据更新API(调用后引起视图更新),而是将赋值语句编译后生成一个函数$$invalidate(flag, assignment);
,通常该函数的调用是在事件处理函数之类的交互更新函数内,通过$$invalidate
的调用,触发视图更新流程。例如下面的代码:
组件编译前代码
xml
<script>
let name;
let num = 1;
const toggleName = () => {
name = 'Svelte';
}
const addNum = () => {
num += 1;
}
const updateNameAndNum = () => {
name = 'solid.js';
num = 10;
}
</script>
<main>
<h1 class="hello">Hello {name}!</h1>
<p class="num">num is {num}</p>
<button on:click={updateNameAndNum}>update</button>
</main>
<style>
main {
text-align: center;
padding: 1em;
max-width: 240px;
margin: 0 auto;
}
h1 {
color: #ff3e00;
text-transform: uppercase;
font-size: 4em;
font-weight: 100;
}
</style>
组件编译后,赋值语句被替换后的代码
ini
const updateNameAndNum = () => {
$$invalidate(0, name = 'solid.js');
$$invalidate(1, num = 10);
};
$$invalidate(flag, assignment)
flag:每个变量都有唯一的flag来追踪,这样哪个变量有变化,就去更新哪个变量对应的视图,这种颗粒度比Vue的组件颗粒度细很多,从而排除了虚拟DOM层的diff运算。
对于flag机制,svelte的每个组件实例都有一个dirty属性来记录哪些变量有更新(即所谓脏数据),dirty属性是一个数组,直观的设计就是每个变量按顺序记录,存入dirty数组。例如上面的例子,name -> 0,num -> 1,那么name -> dirty[0]
,num -> diryt[1]
。组件在更新视图时,如果dirty[0] = 1
,说明name有更新,更新name对应的视图,以此类推。但是这种方案占用的内存空间比较大,每个变量要占用一个数组元素,svelte采用了位来跟踪变量,每一位可以跟踪一个变量,那么一个字节的8位可以跟踪8个变量,内存占用远低于直接使用数组的索引。
位掩码方案跟踪变量
变量仍然按照从0递增1的方式跟踪,但是在dirty数组中需要经过一定的位运算来保存。
这里JavaScript在处理整数的位运算时,总是将操作数转换为32位整数,也就是说任何整数参与位运算时都会先转换为32位整数,所以用位来标记变量时,dirty[0]
的值最大就是32位,一个索引最多能保存32个变量。
因此,svelte在保存flag时,用了下列的位运算
css
component.$$.dirty[(i / 31) | 0] |= (1 << (i % 31));
(i / 31) | 0
:本质上就是取i / 31
的整数部分,每32个变量占据一个索引
1 << (i % 31)
:将1左移(i % 31)
位,也就是0 -> 0000 0001, 1 -> 0000 0010, 2 -> 0000 0100, 3 -> 0000 1000等等,由于是31个位,所以这里使用有符号左移即可。
在保存位运算之前,先执行了component.$$.dirty.fill(0)
,让所有索引的值都为0,0与任何数做位或运算,都是任何数。这样就保存了该变量的flag。
assignment:变量的赋值语句,值为变量最新的值
Prop与单向数据流
通常,prop是单向数据流,即父组件给子组件传递prop,prop的值只有父组件可以更改,子组件不能更改,否则无法分辨该prop的变化到底是父组件还是子组件导致的更新,不利于排查数据更新的问题,尤其是大型项目里数据复杂多变的情况。
但是,在svelte中,传统的prop思维模式下,prop的值(这里的说法不严谨,客观来将,父组件传给prop的值没有被修改,而是子组件修改了自己内部的值,因为子组件的变量名和prop名是一样的)可以被子组件所修改。看下面的例子:
xml
// 子组件,使用export声明了currentProp变量用来接收父组件传入的值
// 如果不声明则无法接收父组件传递的值
<script>
// `currentProp` is updated whenever the prop value changes...
export let currentProp;
// ...but `initial` is fixed upon initialisation
// of the component because it uses `const` instead of `$:`
const initial = currentProp;
const updateCurrent = () => {
let i = 0;
const colors = ['red', 'yellow', 'green'];
return () => {
console.log('thing component current changed', i, currentProp);
currentProp = colors[i];
i ++;
if (i > 2) {
i = 0;
}
}
}
</script>
<div>
<span style="background-color: {initial}">initial</span>
<span style="background-color: {currentProp}">current</span>
<button on:click={updateCurrent()}>thing current change</button>
<p class="thing">Thing Component</p>
</div>
<style>
// 省略
<style>
<script>
import Thing from "./Thing.svelte";
let bgColor = 'lightblue';
const updatebgColor = () => {
let i = 0;
const colors = ['darkred', 'orange', 'darkgreen'];
return () => {
console.log('app component bgColor changed', i, bgColor);
bgColor = colors[i];
i ++;
if (i > 2) {
i = 0;
}
}
}
</script>
<main>
<Thing currentProp={bgColor} --color="red" />
<button on:click={updatebgColor()}>update bgColor</button>
<p>bgColor value: {bgColor}</p>
</main>
<style>
// 省略
</style>
子组件可以通过updateCurrent
方法来更新currentProp的值,从而更新视图,但此时父组件的bgColor值未更新。
子组件使用内部变量来接收父组件的值,后面子组件更新内部的变量,只要有赋值语句就会触发视图更新,与父组件无关。
请看编译后的运行时(在instance之类的方法中)
ini
// 从父组件传递的props对象中解构出currentProp变量
let { currentProp } = $$props;
const updateCurrent = () => {
let i = 0;
const colors = ['red', 'yellow', 'green'];
return () => {
// 给函数内部变量赋值,触发更新,都与父组件无关
$$invalidate(0, currentProp = colors[i]);
i++;
if (i > 2) {
i = 0;
}
};
};
父组件可以通过updatebgColor
方法来更新bgColor的值,从而更新视图,效果是一样的。
ini
let bgColor = 'lightblue';
const updatebgColor = () => {
let i = 0;
const colors = ['darkred', 'orange', 'darkgreen'];
return () => {
console.log('app component bgColor changed', i, bgColor);
$$invalidate(1, bgColor = colors[i]);
i++;
if (i > 2) {
i = 0;
}
};
};
// 父组件的p函数用来更新视图,这里的thing_changes和thing.$set(thing_changes)用来更新子组件
// 父组件内部保存有子组件实例,调用$set更新子组件,从而触发this.$$set($$props);
// this.$$set()又会调用invalidate(0, currentProp = $$props.currentProp)
p: function update(ctx, [dirty]) {
if (!current || dirty & /*name*/ 1) set_data_dev(t1, /*name*/ ctx[0]);
if (!current || dirty & /*num*/ 4) set_data_dev(t5, /*num*/ ctx[2]);
const thing_changes = {};
if (dirty & /*bgColor*/ 2) thing_changes.currentProp = /*bgColor*/ ctx[1];
thing.$set(thing_changes);
if (!current || dirty & /*bgColor*/ 2) set_data_dev(t17, /*bgColor*/ ctx[1]);
},
组件编译结果
每个组件都继承在svelteComponent,在构造函数中会调用init初始化方法来初始化组件实例,给实例挂载核心的$$
属性。
开发者的组件代码编译后分为两部分
create_fragment函数
负责组件与DOM相关的部分,包括创建DOM节点、挂载DOM、卸载DOM、更新DOM等,可以理解为.svelte单文件中的模板部分编译结果。
instance函数
负责组件的脚本部分,即script标签的编译结果,包含了组件内变量,方法,这些变量和方法都是开发者通过script脚本的代码赋予给组件实例的扩展。
运行时执行流程
svelte打包后,会将svelte框架运行时和组件代码打成一个bundle包,组件相关的代码包含了SvelteComponent的代码以及开发者定义的组件编译后代码,其他的为框架运行时任务调度和一系列工具函数;
运行时代码由一系列DOM操作函数和任务调度函数组成。
SvelteComponent和组件运行时相关代码
-
提供组件的基类
-
提供组件的工具函数,如init函数,用于组件实例的初始化
工具函数
DOM更新的工具函数
创建DOM节点、移除DOM节点、DOM属性更新、style对象更新、事件监听等等一系列工具函数;
挂载组件、卸载组件函数
任务调度
任务调度是视图更新的核心功能,通过flush函数,遍历dirtyComponents(含有脏数据的组件,即需要更新视图的组件),调用组件各自的更新方法来更新视图。在此期间,调用生命周期方法注册的回调函数。在after_update注册的回调函数中如果更新数据,就会引发循环更新问题,在flush函数中也通过缓存方案解决了。
挂载时的执行流程
从根组件的实例初始化开始(即init函数调用),到instance函数调用(instance函数处理开发者写的组件代码,返回变量和更新变量的函数),然后调用create_fragment函数(函数内包含子组件的初始化,保留了子组件实例的引用),返回的对象包含了组件的创建、挂载、更新等方法,根组件实例$$.fragment
保存了该对象,用于之后的创建、挂载、更新等操作。最后调用mount_component函数,传入根组件实例、挂载点等参数,开始挂载流程。
mount_component流程
根组件实例调用m方法实现挂载操作,父组件的m方法中,调用了mount_component函数来挂载子组件,这样逐步递归调用,实现整个挂载流程。在挂载完毕后,将onMount生命周期函数注册的回调函数和after_update注册的回调函数都增加到render_callback中。最后,在mount_component递归调用完毕后,调用flush函数,将render_callback中的回调函数一一执行完毕。
更新流程
更新是由$$invalidate(flag, assignment)
触发,其中assignment赋值语句的值是变量最新值,调用make_dirty函数,将包含脏数据的组件和组件内的脏数据都保存,脏数据组件保存到dirty_components数组中。同时开启了异步的flush流程。
scss
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));
}
flush流程
遍历脏组件,调用update函数对脏组件进行更新。update函数内先执行beforeUpdate回调函数,将组件的dirty数组保留副本后还原dirty数组为[-1]
,然后执行各组件的p方法(传入组件的ctx和dirty副本,ctx包含所有组件内的变量),根据dirty副本去执行对应变量的DOM更新方法。接着把afterUpdate注册的回调都放到render_callback数组,执行binding_callbacks的回调函数,然后执行render_callbacks里的回调,最后执行flush_callbacks的回调函数。
-
回调函数的执行顺序
-
beforeUpdate的回调函数,父组件先于子组件
-
bind:this
的回调函数,子组件先于父组件 -
afterUpdate的回调函数,父组件先于子组件,除非在挂载阶段是子组件先于父组件(因为子组件先挂载完成)
-
-
回调函数可能引起新的更新
-
beforeUpdate的回调函数中,如果引起新的更新,会将脏组件置于dirty_components中,但不会引发新的flush调用,因为update_scheduled此时为true,只有update_scheduled为false才会引发flush调用。然后继续遍历dirty_components,这样就可以把新引起的更新也加入到此次更新流程中。
-
bind:this
的回调函数无法触发flush调用 -
afterUpdate的回调函数不会被调用两次
-