前端框架发展迅速,各种轮子层出不穷,近些年来随着React,Vue,Angular三大框架的稳定,前端发展似乎缓慢下来,React 16.8已发布一年多,Vue也在去年发布了3.0。展望未来,可以挑战三大框架框架的统治地位的新兴框架,Svelte是个备选项。
什么是Svelte?
Svelte叫法是[svèlt], 本意是苗条纤瘦的,是一个新兴热门的前端框架。
相对于传统框架的主要区别在于:Svelte在编译/构建阶段将应用程序转换为理想的JavaScript应用。而不是在运行阶段解释应用程序代码,这就意味着不需要为框架消耗额外的性能,同时能保证首屏加载没有额外的消耗。
对于未来可以挑战三大框架技术,Svelte绝对是最优的选择之一。在《State of JS 2020》中Svelte的用户满意度和关注度都是第一。
- 用户满意度
- 用户关注度
轮子哥 —— Rich Harris
说到Svelte,就不得不提Svelte的作者,轮子哥Rich Harris,也就是Rollup作者(大神永远是大神)。Rich Harris在油管上有个非常有名的演讲《Rethinking reactivity》,有兴趣的同学可以去看看。同时有一篇文章《Virtual DOM is pure overhead》是对Virtual Dom的批评,在这篇文章中Rich Harris认为Virtual DOM是一种纯粹无用的开销。
- Svelte的设计思想
Svelte的设计思想在于「通过静态编译生成极少运行时代码」,而在Vue和React的框架中必须引入运行时代码,用于Virtual DOM,diff算法等,Svelte完全融入到JavaScript中,应用的所有运行时代码都包含在bundle.js中,除了引用这个组件本身,不需要引入额外的代码。
Svelte的优势
No Runtime —— 无运行时代码
和传统的框架(React,Vue,Angular)相比,Svelte最主要的特点是去掉了Runtime(不是完全没有,只是相当少,其核心的代码不会超过200行),这样不仅可以减少编译后代码的体积也能提高代码渲染的速度(对于首屏渲染尤为重要)。
下面图片是各个框架对于Realword编译后代码的体积:
没错,Svelte编译后的体积只有15K,差不多是Vue的五分之一,React+Redux的十二分之一。
Less Code —— 更少的代码
程序员的终极梦想是:写最少的的代码,实现最多的功能,Svelte无疑帮我们实现了这个梦想。代码少还有一个好处,就是代码少,意味着bug少。
对于实现相同的模块,React和Svelte使用代码行数对比:
Height Performance —— 高性能
Svelte编译后几乎和手写的原生代码相同,对于大多数纯页面展示+少量交互的场景,虚拟DOM能发挥的作用不大,使用Svelte有更好的性能。
独立开发
因为Svelte没有runtime,没有额外的依赖,所以所有的组件都可以单独使用,可以无缝的在React和Vue等框架中直接import。
Svelte 缺点
当然对于任何框架都是既有优点又有缺点的,Svelte框架有如下缺点:
- 没有成熟UI库,比如:想要写一个提示框,需要重头写。
- Svelte原生不支持处理器,比如less/scss,需要自己单独配置webpack loader。
- 在大型的项目中,对于React/Vue的runtime代码在bundle中占的比例越来越小。所以要考虑阈值。
- 没有成熟的测试脚手架。
- 不能在JS中拿到原生的DOM对象。
- 对于props是无法验证类型的。
- ....
Svelte/React/Vue 对比
Svelte使用
生命周期
- onMount:该函数在Component挂载到DOM上后会立即执行。如果该函数返回还是一个函数,则在卸载时调用此函数。
import { onMount } from 'svelte';
// 挂载时调用
onMount(() => {
const interval = setInterval(() => {
console.log('beep');
}, 1000);
// 卸载时调用
return () => clearInterval(interval);
});
- beforeUpdate:给所有state变更后,安排一个回调函数,运行在component渲染之前。
import { beforeUpdate } from 'svelte';
// state更新后,调用p函数之前,执行
beforeUpdate(() => {
console.log('the component is about to update');
});
- afterUpdate:安排一个回调函数运行在component渲染之后。
import { afterUpdate } from 'svelte';
afterUpdate(() => {
console.log('the component just updated');
});
- onDestroy:在Commpent卸载后执行。
import { onDestroy } from 'svelte';
onDestroy(() => {
console.log('the component is being destroyed');
});
响应式
响应式编程是一种面向数据流和变化传播的编程范式。这意味着可以在编程语言中很方便地表达静态或动态的数据流,而相关的计算模型会自动将变化的值通过数据流进行传播 ——百度百科
简单来说响应式就是通过一个变量的改变来控制另一个变量的改变,如:
let a = 1;
// 当a变量发生改变时,b也随之改变,就如Vue中的computed属性,在svelte中以$:开头声明的变量都具有响应式。
$: b = a + 1;
a++;
console.log(b); // 3
通过使用$:
JS label 语法作为前缀。可以让任何位于top-level的语句(即不在块或函数内部)具有响应式。每当它们依赖的值发生更改时,它们都会在 component更新之前立即运行。
export let title;
// 这将在“title”的prop属性发生更改时
// 更新“document.title”
$: document.title = title;
$: {
console.log(`multiple statements can be combined`);
console.log(`the current title is ${title}`);
}
状态管理 - Store
Svelte内置了状态管理功能,响应式的 store 在组件之间共享状态是非常方便。使用得时候,store 放在单独的JS文件里。使用writable创建一个可写的对象,当使用时在外部导入即可。
store.js文件:
// store.js
import { writable } from 'svelte/store';
export const count = writable(0);
App.svelte文件
// App.svelte
<script>
import {count} from 'store';
function decrement() {
count.updata((n) => n + 1);
}
</script>
<button on:click={decrement}></button>
Svelte原理
在说Svelte原理之前,要说明两个概念:
- Fragment
Fragment是Svelte提供一个接口对象,将我们的DOM编译成Fragment,在初始化的时候将Fragment注入到Component中。主要包含下面的生命周期。
interface Fragment { key: string|null; first: null; /* create */ c: () => void; /* claim */ l: (nodes: any) => void; /* hydrate */ h: () => void; /* mount */ m: (target: HTMLElement, anchor: any) => void; /* update */ p: (ctx: any, dirty: any) => void; /* measure */ r: () => void; /* fix */ f: () => void; /* animate */ a: () => void; /* intro */ i: (local: any) => void; /* outro */ o: (local: any) => void; /* destroy */ d: (detaching: 0|1) => void;}
- Component
SvelteComponent(SvelteComponentDev) 则是包含了svelte组件内置的属性和生命周期,它们与Fragment的属性和生命周期是息息相关,SvelteComponent是依赖于Fragment,组件的变化会触发Fragment的变化。
Svelte文件:
<script> let count = 1; // the `$:` means 're-run whenever these values change' $: doubled = count * 2; $: quadrupled = doubled * 2; function handleClick() { count += 1; }</script><button on:click={handleClick}> Count: {count}</button><p>{count} * 2 = {doubled}</p><p>{doubled} * 2 = {quadrupled}</p>
编译后JS文件:
/* App.svelte generated by Svelte v3.35.0 */import { SvelteComponent, append, detach, element, init, insert, listen, noop, safe_not_equal, set_data, space, text} from "svelte/internal";// 生成Fragment对象function create_fragment(ctx) { let button; let t0; let t1; let t2; let p0; let t3; let t4; let t5; let t6; let p1; let t7; let t8; let t9; let mounted; let dispose; return { // create c() { button = element("button"); t0 = text("Count: "); t1 = text(/*count*/ ctx[0]); t2 = space(); p0 = element("p"); t3 = text(/*count*/ ctx[0]); t4 = text(" * 2 = "); t5 = text(/*doubled*/ ctx[1]); t6 = space(); p1 = element("p"); t7 = text(/*doubled*/ ctx[1]); t8 = text(" * 2 = "); t9 = text(/*quadrupled*/ ctx[2]); }, // mount m(target, anchor) { insert(target, button, anchor); append(button, t0); append(button, t1); insert(target, t2, anchor); insert(target, p0, anchor); append(p0, t3); append(p0, t4); append(p0, t5); insert(target, t6, anchor); insert(target, p1, anchor); append(p1, t7); append(p1, t8); append(p1, t9); if (!mounted) { dispose = listen(button, "click", /*handleClick*/ ctx[3]); mounted = true; } }, // update p(ctx, [dirty]) { if (dirty & /*count*/ 1) set_data(t1, /*count*/ ctx[0]); if (dirty & /*count*/ 1) set_data(t3, /*count*/ ctx[0]); if (dirty & /*doubled*/ 2) set_data(t5, /*doubled*/ ctx[1]); if (dirty & /*doubled*/ 2) set_data(t7, /*doubled*/ ctx[1]); if (dirty & /*quadrupled*/ 4) set_data(t9, /*quadrupled*/ ctx[2]); }, i: noop, o: noop, // destroy d(detaching) { if (detaching) detach(button); if (detaching) detach(t2); if (detaching) detach(p0); if (detaching) detach(t6); if (detaching) detach(p1); mounted = false; dispose(); } };}// Javascript代码编译后生成此函数function instance($$self, $$props, $$invalidate) { let doubled; let quadrupled; let count = 1; function handleClick() { $$invalidate(0, count += 1); } $$self.$$.update = () => { if ($$self.$$.dirty & /*count*/ 1) { // the `$:` means 're-run whenever these values change' $: $$invalidate(1, doubled = count * 2); } if ($$self.$$.dirty & /*doubled*/ 2) { $: $$invalidate(2, quadrupled = doubled * 2); } }; return [count, doubled, quadrupled, handleClick];}// 创建Component,并在初始化的时候将Fragment挂载到Component的$$属性上。class App extends SvelteComponent { constructor(options) { super(); init(this, options, instance, create_fragment, safe_not_equal, {}); }}export default App;
Virtual DOM 真的快吗?
Rich Harris 在设计Svelte的时候没有使用Virtual DOM,他认为虚拟DOM是很低效,详情请见《Virtual DOM is pure overhead》文章。我们之所以认为Virtual DOM快,是因为真实DOM慢,而现代的JS引擎是很高效的。但是Virtual DOM也付出了额外开销,比如为了diff算法。
如下图所示,只有message会发生变动,但是我们需要走多次的diff,才能找具体的节点。为了解决这样的问题,React提供pureComponent,shouldComponentUpdate等API来优化diff算法。
Svelte怎么解决渲染更新的呢?
Svelte在设计之初就采用了Template语法(类似Vue),而没有选择JSX (JSX语法有很强的Javascript表达能力,能够构建出相当复杂的组件,但是灵活的语法意味着不能在编译阶段做更多的优化)。采用Template语法可以让Svelte在编译阶段对代码进行分析优化,可以准确地找到需要更新的DOM,在运行阶段在结合位掩码技术(位掩码技术相当简单)实现低成本高效的更新。
位掩码
Svelte记录脏数据的方式采用的是位掩码(bitMask)的技术,我们都知道计算机存储数据都是0,1,每一位都有两个状态,我们可以规定每一位的1表示有脏数据,0表示无脏数据。这样我们就可以跟踪数据,来达到更新的目的(计算机中的按位操作是相当快的)。
如上图,有四个变量,A B C D,如果A表示第0位,B表示第1位,一次类推,当数据A,B发生变化是,我们可以把第0位和第1位置为1,也就是十进制的3,其余位数还是0,当我们更新时调用Fragment中p函数,函数中会按位与运算,判断要更新的DOM。
p(ctx, [dirty]) { // 按位与运算,判断更新,注意,set_data和React中的setDate是不同的,此处的setDate就是更新真实DOM,是对真实DOM API的再次封装 if (dirty & /*count*/ 1) set_data(t1, /*count*/ ctx[0]); if (dirty & /*count*/ 1) set_data(t3, /*count*/ ctx[0]); if (dirty & /*doubled*/ 2) set_data(t5, /*doubled*/ ctx[1]); if (dirty & /*doubled*/ 2) set_data(t7, /*doubled*/ ctx[1]); if (dirty & /*quadrupled*/ 4) set_data(t9, /*quadrupled*/ ctx[2]);},
因为JavaScript中的二进制有32位限制,还有最高位是符号位,所以能用的位数只有31位,这就意味着每个数只能对应31个变量,这当然是无法满足要求,所以Svelte才用数组来存储(component.$$.dirty)。
渲染更新流程
现在我们可以开始探索Svelte的渲染更新流程了,当用户点击时,渲染更新过程如下图:
在渲染更新过程中好像出来了好多莫名的函数和变量,make-dirty,flush,component.$$.dirty...这都是什么鬼,不要着急,下面我们就要从源码中慢慢解密。
源码分析
Svelte的源码相当简单,整个源码可以分为两部分:complier,runtime。complier是Svelte的编译源码,不在我们本次的讨论范围,感兴趣的同学可以去看看源码。先从runtime看起,在runtime中最终的两个对象是Component和Fragment,而将他们链接起来的正是init函数,我们就先从init函数看起
-
init函数
编译生成的代码:
// Javascript代码编译后生成次函数function instance($$self, $$props, $$invalidate) { let doubled; let quadrupled; let count = 1; function handleClick() { // 在init函数中,传入$$invalidate,$$invalidate会调用make_dirty $$invalidate(0, count += 1); } $$self.$$.update = () => { if ($$self.$$.dirty & /*count*/ 1) { // the `$:` means 're-run whenever these values change' $: $$invalidate(1, doubled = count * 2); } if ($$self.$$.dirty & /*doubled*/ 2) { $: $$invalidate(2, quadrupled = doubled * 2); } }; return [count, doubled, quadrupled, handleClick];}class App extends SvelteComponent { constructor(options) { super(); init(this, options, instance, create_fragment, safe_not_equal, {}); }}
源码:
export function init(component, options, instance, create_fragment, not_equal, props, dirty = [-1]) { const parent_component = current_component; set_current_component(component); // 生成$$对象,在$$对象中保存dirty等属性,$$最终回挂载到Component上。 const $$: T$$ = component.$$ = { fragment: null, ctx: null, // state props, update: noop, not_equal, bound: blank_object(), // lifecycle on_mount: [], on_destroy: [], on_disconnect: [], before_update: [], after_update: [], context: new Map(parent_component ? parent_component.$$.context : []), // everything else callbacks: blank_object(), dirty, skip_bound: false }; let ready = false; // 调用instance函数,将$$invalidate变量传入到函数中,并将函数的返回值添加到$$.ctx中。 $$.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 (!$$.skip_bound && $$.bound[i]) $$.bound[i](value); // 调用make_dirty if (ready) make_dirty(component, i); } return ret; }) : []; // 省略后面代码}
好了,现在我们知道make_dirty是怎么产生了,我们再看一遍:我们写的Javascript代码会被编译成instance函数,instance函数接受一个invalidate参数传入到instance函数,而$$invalidate是一个回调函数,这个函数最主要的功能就是触发make_dirty。
- make_dirty函数
我们已经知道make_dirty是在什么地方触发的了,那么make_dirty中又做了什么的呢?make_dirty函数相当简单,就是启动调度。
// make_dirty 主要的任务是调起schedule_update();// 真正的更新在flush中进行的function make_dirty(component, i) { // 在更新后,将component.$$.dirty[0] 置位 -1 if (component.$$.dirty[0] === -1) { // 更新时候时候将component弹出 dirty_components.push(component); // 将flus推入到promise中,启动调度 schedule_update(); component.$$.dirty.fill(0); } // 设置脏位置 component.$$.dirty[(i / 31) | 0] |= (1 << (i % 31));}
- schedule_update函数将flush推入都promise.then中,等待微任务调用更新。
export function schedule_update() { if (!update_scheduled) { update_scheduled = true; // 将flush放到then中 resolved_promise.then(flush); }}
- flush函数
export function flush() { // 当flushing正在运行的时候,退出flush if (flushing) return; flushing = true; do { // 在update函数中首先调用befor_update函数 再调用更新代码 for (let i = 0; i < dirty_components.length; i += 1) { const component = dirty_components[i]; set_current_component(component); // 更新函数,此函数会调用beforeUpdate和afterUpdate生命周期 update(component.$$); } // 省略一部分代码}
- update 更新函数
function update($$) { if ($$.fragment !== null) { $$.update(); // 执行beforeUpdate生命周期 run_all($$.before_update); const dirty = $$.dirty; // 重置 dirty方法 $$.dirty = [-1]; // 所有要更新的,调用p方法,更新到dom上 $$.fragment && $$.fragment.p($$.ctx, dirty); // 执行afterUpdate生命周期 $$.after_update.forEach(add_render_callback); }}
到此为止,我们的页面DOM也更新完成。
总结
不管React,Vue,Angular框架,都需要对DOM进行对比更新,React采用自顶向下的更新,而Vue是通过Reflect技术进行更新,两个框架都要通过diff算法,才能确定哪些DOM更新。且两个框架都是组件的级别的颗粒度。Svelte通过编译,将真实的数据和DOM映射起来。在p函数中进行更新。
Svelte是新兴的框架,还有很多不足,但是有很多特性值得尝试。