slot思想、用法及原理详解

slot的意思是插槽,它主要是用来做内容分发的工具,那么什么是内容分发呢?slot又有几种用法?slot的每种用法主要是用于解决什么问题?

可能在学习Vue的文档时,不少人都会有这样的疑问。

今天我就来总结一下slot思想及用法。

slot

首先我们需要明确的是Vue的组件化思想。

组件的意义在于复用,所以我们在写出一个组件后总是希望父组件用某种方式来调用它,for instance:

<child-component :para="xxx" @evt="yyy"></child-component>

这样的做法是Vue推荐的,我们可以在使用时像子组件prop参数,也可以监听子组件emit的事件。

但是存在一个问题,子组件的内部对父组件是不透明的。

这就意味着父组件没有办法自由控制子组件的部分行为,无法将它的内容直接分发到子组件,无法在调用组件时实现定制化。

于是slot就可以发挥作用了。

举一个例子,假设我们有一个btn组件,它的意义在于被各个组件复用,不同的父组件button可能有不同的文字,我们可能会想到这样写:

<template id="btn-template">
    <button>{{ text }}</button>
</template>
Vue.component('btn', {
    template: '#btn-template',
    props: ['text']
})

然后父组件在调用时:

<btn text="删除"></btn>

<btn text="添加"></btn>

这样做不是不可以,但存在问题,一是如何处理prop数据是由子组件控制的,父组件无法操控,不直观也不透明;二是父组件需要分发较为复杂的模板内容时,子组件会存在许多复杂的DOM操作逻辑。

我们可以用slot来解决这个问题。

<template id="btn-template">
    <button>
        <slot>
            默认的内容
        </slot>
    </button>
</template>
Vue.component('btn', {
    template: '#btn-template'
})

在调用时:

<btn>删除</btn>

<btn>添加</btn>

子组件中默认的内容只有在父组件没有分发内容时才会渲染,如果父组件有分发的内容,则会被覆盖。

上述的调用会渲染成:

<button>删除</button>

<button>添加</button>

命名slot

可能讲的有点啰嗦了,不过基本的思想阐述清楚了。

下面尽量简洁一点。

命名slot,顾名思义,这个插槽是有名字的。

之所以要有名字,是为了方便父组件分发多个内容,并在子组件中正确渲染。

这个例子官方文档举的很好,假设子组件的模板是这样:

<div class="container">
  <header>
    <slot name="header"></slot>
  </header>
  <main>
    <slot></slot>
  </main>
  <footer>
    <slot name="footer"></slot>
  </footer>
</div>

看到这个模板应该已经清楚了。

我们需要的就是父组件在调用时指明要分发的内容给分别名为header和footer的插槽,指明的过程也很简单,给分发内容加上一个相应值的slot特性即可。

父组件中没有具名的分发内容,会被渲染到不具名的slot中。

比如我们这样调用子组件:

<app-layout>
  <h1 slot="header">这里可能是一个页面标题</h1>
  <p>主要内容的一个段落。</p>
  <p>另一个主要段落。</p>
  <p slot="footer">这里有一些联系信息</p>
</app-layout>

最终渲染的结果是这样的:

<div class="container">
  <header>
    <h1>这里可能是一个页面标题</h1>
  </header>
  <main>
    <p>主要内容的一个段落。</p>
    <p>另一个主要段落。</p>
  </main>
  <footer>
    <p>这里有一些联系信息</p>
  </footer>
</div>

作用域slot

作用域slot可能就没有那么见名知义了。

我的想法是,作用域slot的意思就是,将分发的内容限制在插入插槽的作用域内,这个作用域中的数据既可以是自己定义的变量,也可以是prop得到数据。

这在分发内容需要依靠复杂数据时特别有用(如渲染表格、列表等),举一个例子,假设我们子组件中有一个插槽列表:

<ul>
  <slot 
    name="item"
    v-for="item in items"
    :text="item.text"></slot>
</ul>

从这一段模板我们可以看出,子组件中有多个同名的slot。

那么如何把父组件分发的内容限制在其对应插槽的作用域内呢?

也很简单,在父组件的分发内容中加入一个slot-scope特性就可以了。

这个slot-scope特性的值指向这个插槽的作用域对象(也可以使用解构~)

比如在调用这个子组件时,分发内容可以这样写:

<my-awesome-list :items="items">
  <li
    slot="item"
    slot-scope="props"
    class="my-fancy-item">
    {{ props.text }}
  </li>
</my-awesome-list>

假设items是一个数组:['hello', 'world']

渲染结果会是这样的:

<ul>
  <li>hello</li>
  <li>world</li>
</ul>

需要注意的一点是,在Vue 2.5以前,slot-scope特性必须在template标签上使用,Vue 2.5以后,slot已经可以用于任意的元素和组件中了。

在render函数中渲染slot

讲完了在template中的slot用法,我们来讲一讲在render函数中如何使用slot接收分发的内容。

事实上template是被编译成render函数以生成VNode的,所以本文接下来也可以看做是对slot工作原理的阐述。

在render函数中使用slot接收分发的内容,我们需要首先熟悉一些实例属性API。

我们要知道的是,访问分发给插槽的内容,需要调用vm.$slots这个API。

比如,slot="foo"中的内容可以在vm.$slots.foo中找到,default属性包括了所有没有被包含在具名插槽中的分发内容。

用官方文档举一个例子:

Vue.component('blog-post', {
    render(createElement) {
        const header = this.$slots.header
        const body = this.$slots.default
        const footer = this.$slots.footer
        return createElement('div', [
            createElement('header', header),
            createElement('main', body),
            createElement('footer', footer)
        ])
    }
})

在调用子组件时我们就可以正常的分发内容了:

<blog-post>
  <h1 slot="header">
    About Me
  </h1>
  <p>Here's some page content, which will be included in vm.$slots.default, because it's not inside a named slot.
  </p>
  <p slot="footer">
    Copyright 2016 Evan You
  </p>
  <p>If I have some content down here, it will also be included in vm.$slots.default.</p>.
</blog-post>

分发内容会被渲染到它应在的位置。

在render函数中分发内容

render函数可以用来渲染子组件,当然也可以在父组件中调用子组件,而在调用子组件时,自然可以向其分发内容,举个例子:

render(createElement) {
    return createElement('div', [
        createElement('child', [
            createElement('p', '这是一个不具名slot'),
            createElement('p', {
                slot: 'main'
            },
            '这是一个名为main的slot')
        ])
    ])
}

如此分发内容给插槽,可以使内容分发有别于prop,更加清晰(I guess…)

这个render函数返回的VNodes如下:

<div>
  <child>
    <p>这是一个不具名slot</p>
    <p slot="main">这是一个名为main的slot</p>
  </child>
</div>

在render函数中渲染作用域插槽

在render函数中使用作用域插槽同样需要我们对实例属性vm.$scopedSlots有一定的理解。

vm.$scopedSlots用来访问作用域插槽接收到的分发内容,包括默认插槽在内的每一个插槽接收到的分发内容,都可用该对象访问,该对象会包含一个返回相应VNode的函数(类似于一个createElement函数)。

比如我们这样写一个render函数:

return createElement('div', [
    this.$scopedSlots.default({
        text: this.defaultMsg
    }),
    this.$scopedSlots.foo({
        property: this.fooMsg
    })
])

上面这一段render函数相当于这样一个模板:

// child
<div>
  <slot :text="defaultMsg"></slot>
  <slot :property="fooMsg" name="foo"></slot>
</div>

于是我们可以这样给它分发内容:

<child>
  <p slot-scope="props">{{ props.text }}</p>
  <!-- 下面使用了解构语法 -->
  <p slot-scope="{ property }" slot="foo">{{ property }}</p>
</child>

在render函数中分发内容给作用域插槽

在render函数中分发内容给作用域插槽,可以使用createElement函数的data参数对象的scopedSlots属性。

还是以一个例子来说明:

render(createElement) {
    return createElement('div', [
        createElment('child', {
            scopedSlots: {
                default: props => createElement('span', props.text)
            }
        })
    ])
}

如此分发内容给作用域插槽,可以使内容分发有别于props,更加清晰,同时也是由于Vue 2.5之前slot-scope必须写在template标签上(I guess…)

事实上,上面这一段渲染函数渲染出的VNodes如下:

<div>
  <child>
    <span slot-scope="props">{{ props.text }}</span>
  </child>
</div>

结语

这篇文章从内容分发/slot的思想讲到用法,最后讲到在render函数中的实现原理,我感觉写的还是比较清晰的。

也希望读者可以有一定收获。

理解Kadane算法

最近刷leetcode时遇到一道题:

Find the contiguous subarray within an array (containing at least one number) which has the largest sum.

For example, given the array [-2,1,-3,4,-1,2,1,-5,4], the contiguous subarray [4,-1,2,1] has the largest sum = 6.

做题时想了想,除了暴搜之外没有想到其他的办法,然后看Discuss区,发现一个很有意思的算法:

    public int maxSubArray(int[] nums) {
        int maxForNow = nums[0];
        int maxPostfix = nums[0];
        for(int i = 1; i < nums.length; i++) {
            maxPostfix = Math.max(maxPostfix + nums[i], nums[i]);
            maxForNow = Math.max(maxForNow, maxPostfix);
        }
        return maxForNow;
    }

看了很久都没有看明白,去了解了一下历史,发现这是一个著名的求解最大子数组的线性O(n)算法–Kadane算法。

算法的篇幅虽然比较短,但是理解起来比较困难,所以我记录一下我理解的思路,以期帮助有困惑的学习者,也便于复习。

为了更方便的说明这个算法,我先用测试用例进行一次遍历:

测试用例遍历

可以看到的是maxForNow总是存储目前最大的子数组,maxPostfix总是存储最大后缀的子数组。

于是就可以很清晰的理解Kadane算法了:

该算法的本质就是利用动态规划的思想,通过寻找局部最优解 + 状态转移来寻找全局最优解。

用直白的语言说就是:如果我已经知道nums[0]-nums[i - 1]的最大子数组,那么我如何寻找nums[0]-nums[i]的最大子数组。

显然的,nums[0]-nums[i]的最大子数组可能取以下两个值:

1、之前nums[0]-nums[i - 1]时的最大子数组,即之前的maxForNow
2、nums[k]-nums[i](0 <= k <= i),加上nums[i]之后的,由nums[i]及其之前的多个元素构成的最大子数组,也就是最大后缀,我们称之为maxPostfix

于是就很容易理解:

maxForNow = Math.max(maxForNow, maxPostfix);

那么maxPostfix怎么求呢?

同样很显然的,maxPostfix只有可能取以下两个值:

1、previous maxPostfix + nums[i]
2、nums[i]

于是这一语句也容易理解了:

maxPostfix = Math.max(maxPostfix + nums[i], nums[i]);

从nums[0]开始,遍历数组,不断更新maxForNow和maxPostfix,遍历整个数组后,得到maxForNow,就是我们需要的最大子数组。

How JS Works学习笔记(2)

今天记录How JavaScript Works第三部分的学习笔记,主要是以下内容:

内存管理 + 如何处理4个常见的内存泄漏

下面开始讲解。

内存管理 + 如何处理4个常见的内存泄漏

概念

JS不像C这样的语言拥有原生内存管理原语(如malloc和free)。

JS在创建变量时自动分配内存,在变量不被需要时自动释放内存(垃圾回收,GC),这让一些开发者认为在使用JS的过程中我们不必关心内存管理,但这个概念实际上是错误的。

因为自动的内存管理可能存在一些问题,比如GC的实现可能存在缺陷和不足,因此我们必须对内存管理有一定的理解。

内存生命周期

内存的声明周期在不同的语言中几乎都是一样的:

1、 分配内存,并允许程序使用它,在JS中自动进行。
2、 使用内存,实际使用之前分配的内存,通过代码对内存进行读写。
3、 释放内存,在内存不再被需要的时候释放它,以便于重新分配,在JS中也是自动进行的。

什么是内存及怎样分配内存

我们可以想象,内存中包含有大量的触发电路,每一个触发电路都包含一些可以存储1位数据的晶体管。

触发器可以通过唯一标识符来寻址,然后进行读取和覆盖。

因此我们可以认为内存是一个巨大的可读写阵列。

在内存中存储的位信息被人们以更加容易被理解的方式组织。

如,8位是一个字节,2或4个字节是一个字等。

所有的变量和程序中用到的数据以及程序和操作系统的代码都被存放在内存中

而在分配内存时有两种方式:

1、 静态分配
2、 动态分配
1. 静态分配

假设有下面一段代码:

int n;
int x[4];
double m;

编译器在编译过程中马上就知道内存需要4 + 4 * 4 + 8 = 28字节,于是在编译时进行内存分配如下:

静态内存分配图

注意,静态分配的内存是被分配在栈空间上的。因为函数在调用时被添加到栈上成为一个栈帧,这个栈帧保存函数的内部变量。

2. 动态分配

事情总是这么简单吗?未必。

请看:

int n = readInput();

// 创建一个有n个元素的数组

编译器在编译这段代码时并不知道这个数组需要多少内存,因为数组大小取决于用户提供的值。

因此,程序必须在运行时想操作系统请求足够的空间,此时的内存从堆空间被分配。

3. 静态分配和动态分配的区别

这张图总结的很好:

静态分配和动态分配的区别

JS中的内存管理

JS中内存分配、内存使用和内存释放都是由引擎自动完成的。

在声明变量时引擎自动分配内存,在操作变量时引擎自动读/写内存,在内存不被需要时自动释放内存。

难点就在于,如何知道这些被分配的内存何时不再被需要。

JS中,通过垃圾回收算法来跟踪内存的分配和使用,以便于发现一些内存不再被需要的情况,并且自动的释放内存。

目前的垃圾回收算法主要有两个:

1、 内存引用计数法
2、 标记清除法

垃圾回收算法-内存引用计数

内存引用计数算法是通过查询内存引用来确定内存是否可以被释放的算法。

让我们先来明确一个概念,什么是引用?

简单的说,一个对象访问(指向)了另一个对象,就说另一个对象被引用了。

引用又分为隐式引用或者显式引用。

比如说一个对象,隐式的指向它的原型[[prototype]],则它的原型被隐式引用。

function Person() {}
var person1 = new Person() // Person.prototype被隐式引用

比如说一个对象,显式的访问了它原型[[prototype]]的一个方法,那么它的原型被显式引用。

function Person() {}
Person.prototype.sayHi = function () {}
var person1 = new Person()
person1.sayHi() // Person.prototype被显式引用

内存引用计数算法的思想很简单:当一个对象不再被引用,则回收它。

但这个算法有一个致命的缺陷在于,它无法解决循环引用的问题,请看:

function f() {
  var o1 = {}
  var o2 = {}
  o1.p = o2 // o1 引用 o2
  o2.p = o1 // o2 引用 o1. 形成循环引用
}

f()

这个例子中两个对象被创建并相互引用,这样就创建了一个循环引用,在函数执行结束后,按照道理来讲,应该o1和o2都可以被释放,但考虑到两个对象都至少被引用了一次,所以不可以被回收。

垃圾回收算法-标记清除算法

标记清除算法原文讲的很详细,也比较容易理解,所以引用原文的讲法:

1、垃圾回收器生成一个根列表。根通常是其引用被保存在代码中的全局变量。在JavaScript中,window对象是一个可以作为根的全局变量。
2、所有的根都被检查和标记成活跃的(不是垃圾),所有的子变量也被递归检查。所有可能从根元素到达的都不被认为是垃圾。
3、所有没有被标记成活跃的内存都被认为是垃圾。垃圾回收器就可以释放内存并且把内存还给操作系统

标记清除算法可以解决循环引用的问题,因为在函数执行完后,这两个对象没有被任何的全局对象可达的引用,因此被标记为不可达。

注,大多数GC实现会在分配内存时启动回收例程,在其他时间保持空闲。

内存泄漏

内存泄漏指的是不再被应用需要的内存,由于某种原因,没有被归还给系统或进入可用内存池。

在JS中,常见的内存泄漏主要有以下几种形式:

1. 全局变量

我们有时候会意外的创建一些全局变量:

function foo(arg) {
    bar = 'some text'
    this.par = 'whaaaat'
}

foo()

在函数foo()执行完毕后,bar和par可不会被回收!

除了意外的创建全局变量,明确创建全部变量也是不可以被回收的,如果只是用来暂存大量数据的全局变量,应该确保在使用后将它赋值为null。

2. 被遗忘的定时器或回调
var serverData = loadData();
setInterval(function() {
    var renderer = document.getElementById('renderer');
    if(renderer) {
        renderer.innerHTML = JSON.stringify(serverData);
    }
}, 5000); //每5秒执行一次

即使DOM中的renderer被移除了,让interval处理器内部的整个块都变得没有用。但由于interval仍然起作用,处理程序并不能被回收(除非interval停止)。如果interval不能被回收,它的依赖也不可能被回收。这就意味着serverData(大概保存了大量的数据)也不可能被回收。

事件侦听器也会造成这种类型的内存泄漏,要注意明确的移除不需要的事件侦听。

3. 闭包

无需多说…

4. DOM外引用

我们有时候会在数据结构中存储DOM节点,这时候一个DOM节点有两个引用,一个在DOM树中,另一个在JS代码结构中,我们需要保证两个引用都不可达。

How JS Works学习笔记(1)

最近学习了sessionstack上的系列文章How JS Works,对JavaScript的内部机制有了一些了解,因此写一个学习笔记来记录一下学到的知识。

先给出原文的链接:How JS Works

主要学习了三块知识:

1. JavaScript内部机制的综述,包括:引擎、调用栈、运行时等相关知识
2. V8引擎的工作原理 + 一些良好的代码优化习惯

学习JavaScript内部运行机制的目的在于我们希望通过更加深入的了解JS的内部机制,更好的利用语言和生态系统提供的所有技术,编写出更好的非阻塞应用程序。

下面就开始吧。

JavaScript内部机制:综述

JavaScript引擎

JavaScript引擎是一个执行JavaScript代码的程序或解释器,它有两种实现形式:

1.标准解释器,即直接执行高级语言
2.以某种形式将JavaScript代码编译成字节码的编译器

目前最流行的JavaScript引擎是Google的V8引擎,它被Chrome和Node.js使用。

V8引擎主要包括两个部分:

1.内存堆(Memory Heap): 内存分配发生的地方
2.调用栈(Call Stack): 代码执行所在的栈帧

内存堆由于比较复杂,所以放在下一部分阐述,这里主要阐述运行时和JS引擎中的调用栈。

运行时(Runtime)

Runtime这个词是和程序语言对应起来的,Runtime是指除去语言的标准库,运行一段语言程序时书写代码的机制。

JavaScript的运行时包含了浏览器提供的Web APIS(如:DOM、AJAX(HttpRequest)、Timeout等),事件循环相关的API以及回调函数的API等。

调用栈

JavaScript是单线程的,这意味着它只有一个调用栈。

调用栈记录了我们位于程序中的位置,调用栈中的一个条目被称为一个栈帧。

如果程序执行一个函数,就把该函数放在栈顶,如果从函数返回,就把它从栈顶弹出。

这是一个例子:

function multiply(x, y) {
    return x * y;
}

function printSquare(x) {
    var s = multiply(x, x);
    console.log(s);
}

printSquare(5);

执行这段代码时,调用栈的变化状况是这样的:

image

这也就意味着调用栈可以帮助我们追踪异常所在。

当达到最大调用栈大小时,会产生爆栈,也就是说,如果调用栈中的函数调用量超过了调用栈的实际大小,浏览器会抛出一个错误。

由于JavaScript是单线程的只有一个调用栈,并且,当调用栈里有函数在执行时,浏览器是被阻塞的,这意味着浏览器无法渲染,这会带来不好的用户体验,所以我们必须解决这个问题。

解决这个问题的方案是异步回调,这方面的内容会在第二篇学习笔记中阐述。

V8引擎的工作原理 + 一些良好的优化代码习惯

Google的V8引擎是开源的,用C++编写。

V8引擎为了提高JS的执行性能,没有采用上文阐述的两种方式来执行JS代码,而是通过JIT(即时)编译器,直接将JavaScript代码编译成机器码。

这里主要的区别是V8不会产生字节码或任何中间代码。

V8引擎的工作原理

V8引擎的内部是使用多线程的,它的多线程执行的任务主要是这样的:

1.主线程执行我们想让它干的活:获取代码,编译然后执行它
2.一个单独的编译线程(我们称其为优化线程),用于在主线程执行的同时优化代码
3.一个Profiler线程,用于让Runtime知道哪些方法花了大量时间,从而可以对其进行优化
4.几个线程用于GC

V8引擎的工作原理是这样的:

第一次执行JS代码时,主线程直接将解析的JS编译成机器码,快速开始执行。

代码运行一段时间后,Profiler线程已经收集了足够的数据来判断应该优化哪个方法。

优化线程对Profiler中分析的方法进行优化。

V8引擎的优化方法

1)内联

内联是用被调用的函数的函数体替换调用位置(调用函数所在的代码行)的过程。

2)隐藏类

大多数JS解释器都是用类似字典的结构(基于Hash函数)来存储对象的属性值。

由于JS的对象是动态的,所以也不可能像Java中一样固定对象布局,然而,用字典来查找内存中对象属性的位置是非常低效的。

因此V8使用了不同的方法的替代,这就是隐藏类。

隐藏类的工作机制类似于像Java这样的语言中使用的固定对象布局,但它是在运行时创建的。

原文中对隐藏类的构建的过程讲的很详细,所以我就不赘述了。

如果两个对象以相同的顺序初始化相同的动态属性,那么这两个对象可以共享一个隐藏类,这样就可以重用隐藏类而不需要重复构造了。

隐藏类使得JS中的对象属性也可以使用offset来进行访问,对属性查找的性能提升是显著的。

3)内联缓存

内联缓存的思想来自于一个观察的结果,即:对同一方法的重复调用往往发生在同一类型的对象上。

V8维护最近调用某一方法的对象类型的缓存,并根据该信息对将来可能调用该方法的对象类型做出假设,如果假设正确,就可以直接使用先前查找到的对象的隐藏类所存储的信息。

也就是说,连续两次对同一个隐藏类的同一方法进行成功调用后,V8就可以省掉查找对象隐藏类的过程,直接将属性的偏移地址加到对象指针本身上。

内联缓存也是为什么同一类型的对象共享隐藏类非常重要的原因。

4)当前栈替换(OSR)

在我们开始编译和优化一个明显要长期运行的方法之前,我们可能会运行它。

V8可以记忆刚刚执行的代码,所以它不会再用优化方法又执行一遍,而是将转换所有已有的上下文(栈、寄存器),以便我们直接执行程序的优化版本。

5)垃圾回收

V8使用的是标记、清扫这种传统的方法记性GC,不过为了控制成本,V8使用增量式标记:不是遍历整个堆,尝试标记每一个可能对象,而是遍历一部分堆,记录下停止位置,然后恢复正常执行,在下一次GC会从之前记录的位置开始。

良好的代码优化习惯

  1. 注意实例化对象属性的顺序

  2. 尽量不使用动态属性,防止隐藏类被修改

  3. 重复执行相同的方法执行效率更高

  4. 避免稀疏数组,稀疏数组是哈希表;避免预分配大数组,增量分配;避免删除数组中的元素

  5. 尽量使用在31位有符号数字的范围内的数字,避免JS对象装箱操作。