ocleo1

ocleo1 查看完整档案

填写现居城市  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 该用户太懒什么也没留下

个人动态

ocleo1 收藏了文章 · 4月15日

前端十大经典算法

个人博客

算法概述

算法分类

十种常见排序算法可以分为两大类:

非线性时间比较类排序:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破O(nlogn),因此称为非线性时间比较类排序。

线性时间非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此称为线性时间非比较类排序。

849589-20180402132530342-980121409.png

算法复杂度

849589-20180402133438219-1946132192.png

相关概念

稳定:如果a原本在b前面,而a=b,排序之后a仍然在b的前面。

不稳定:如果a原本在b的前面,而a=b,排序之后 a 可能会出现在 b 的后面。

时间复杂度:对排序数据的总的操作次数。反映当n变化时,操作次数呈现什么规律。

空间复杂度:是指算法在计算机内执行时所需存储空间的度量,它也是数据规模n的函数。

冒泡排序(Bubble Sort)

冒泡排序是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。

1.1 算法描述

  • 比较相邻的元素。如果第一个比第二个大,就交换它们两个;
  • 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
  • 针对所有的元素重复以上的步骤,除了最后一个;
  • 重复步骤1~3,直到排序完成。

1.2 动图演示

849589-20171015223238449-2146169197.gif

1.3 代码实现

function bubbleSort(arr) {

    var len = arr.length;

    for (var i = 0; i < len - 1; i++) {

        for (var j = 0; j < len - 1 - i; j++) {

            if (arr[j] > arr[j+1]) {       // 相邻元素两两对比

                var temp = arr[j+1];       // 元素交换

                arr[j+1] = arr[j];

                arr[j] = temp;

            }

        }

    }

    return arr;

}

选择排序(Selection Sort)

选择排序(Selection-sort)是一种简单直观的排序算法。它的工作原理:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。

2.1 算法描述

n个记录的直接选择排序可经过n-1趟直接选择排序得到有序结果。具体算法描述如下:

  • 初始状态:无序区为R[1..n],有序区为空;
  • 第i趟排序(i=1,2,3…n-1)开始时,当前有序区和无序区分别为R[1..i-1]和R(i..n)。该趟排序从当前无序区中-选出关键字最小的记录 R[k],将它与无序区的第1个记录R交换,使R[1..i]和R[i+1..n)分别变为记录个数增加1个的新有序区和记录个数减少1个的新无序区;
  • n-1趟结束,数组有序化了。

2.2 动图演示

849589-20171015224719590-1433219824.gif  

2.3 代码实现

function selectionSort(arr) {

    var len = arr.length;

    var minIndex, temp;

    for (var i = 0; i < len - 1; i++) {

        minIndex = i;

        for (var j = i + 1; j < len; j++) {

            if (arr[j] < arr[minIndex]) {    // 寻找最小的数

                minIndex = j;                // 将最小数的索引保存

            }

        }

        temp = arr[i];

        arr[i] = arr[minIndex];

        arr[minIndex] = temp;

    }

    return arr;

} 

2.4 算法分析

表现最稳定的排序算法之一,因为无论什么数据进去都是O(n2)的时间复杂度,所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。理论上讲,选择排序可能也是平时排序一般人想到的最多的排序方法了吧。

插入排序(Insertion Sort)

插入排序(Insertion-Sort)的算法描述是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

3.1 算法描述

一般来说,插入排序都采用in-place在数组上实现。具体算法描述如下:

  • 从第一个元素开始,该元素可以认为已经被排序;
  • 取出下一个元素,在已经排序的元素序列中从后向前扫描;
  • 如果该元素(已排序)大于新元素,将该元素移到下一位置;
  • 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;
  • 将新元素插入到该位置后;
  • 重复步骤2~5。

3.2 动图演示

849589-20171015225645277-1151100000.gif

3.2 代码实现

function insertionSort(arr) {

    var len = arr.length;

    var preIndex, current;

    for (var i = 1; i < len; i++) {

        preIndex = i - 1;

        current = arr[i];

        while (preIndex >= 0 && arr[preIndex] > current) {

            arr[preIndex + 1] = arr[preIndex];

            preIndex--;

        }

        arr[preIndex + 1] = current;

    }

    return arr;

}

3.4 算法分析

插入排序在实现上,通常采用in-place排序(即只需用到O(1)的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。

希尔排序(Shell Sort)

1959年Shell发明,第一个突破O(n2)的排序算法,是简单插入排序的改进版。它与插入排序的不同之处在于,它会优先比较距离较远的元素。希尔排序又叫缩小增量排序

4.1 算法描述

先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,具体算法描述:

  • 选择一个增量序列t1,t2,…,tk,其中ti>tj,tk=1;
  • 按增量序列个数k,对序列进行k 趟排序;
  • 每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m 的子序列,分别对各子表进行直接插入排序。仅增量因子为1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。

4.2 动图演示

849589-20180331170017421-364506073.gif

4.3 代码实现

function shellSort(arr) {

    var len = arr.length,

        temp,

        gap = 1;

    while (gap < len / 3) {         // 动态定义间隔序列

        gap = gap * 3 + 1;

    }

    for (gap; gap > 0; gap = Math.floor(gap / 3)) {

        for (var i = gap; i < len; i++) {

            temp = arr[i];

            for (var j = i-gap; j > 0 && arr[j]> temp; j-=gap) {

                arr[j + gap] = arr[j];

            }

            arr[j + gap] = temp;

        }

    }

    return arr;

}

4.4 算法分析

希尔排序的核心在于间隔序列的设定。既可以提前设定好间隔序列,也可以动态的定义间隔序列。动态定义间隔序列的算法是《算法(第4版)》的合著者Robert Sedgewick提出的。 

归并排序(Merge Sort)

归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。

5.1 算法描述

  • 把长度为n的输入序列分成两个长度为n/2的子序列;
  • 对这两个子序列分别采用归并排序;
  • 将两个排序好的子序列合并成一个最终的排序序列。

5.2 动图演示

849589-20171015230557043-37375010.gif

5.3 代码实现

function mergeSort(arr) { // 采用自上而下的递归方法
    var len = arr.length;
    if (len < 2) {
        return arr;
    }
    var middle = Math.floor(len / 2),
        left = arr.slice(0, middle),
        right = arr.slice(middle);
    return merge(mergeSort(left), mergeSort(right));
}
 
function merge(left, right) {
    var result = [];
 
    while (left.length>0 && right.length>0) {
        if (left[0] <= right[0]) {
            result.push(left.shift());
        }else {
            result.push(right.shift());
        }
    }
 
    while (left.length)
        result.push(left.shift());
 
    while (right.length)
        result.push(right.shift());
 
    return result;
}

5.4 算法分析

归并排序是一种稳定的排序方法。和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是O(nlogn)的时间复杂度。代价是需要额外的内存空间。

快速排序(Quick Sort)

快速排序的基本思想:通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。

6.1 算法描述

快速排序使用分治法来把一个串(list)分为两个子串(sub-lists)。具体算法描述如下:

  • 从数列中挑出一个元素,称为 “基准”(pivot);
  • 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
  • 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。

6.2 动图演示

849589-20171015230936371-1413523412.gif

6.3 代码实现

function quickSort(arr, left, right) {
    var len = arr.length,
        partitionIndex,
        left =typeof left !='number' ? 0 : left,
        right =typeof right !='number' ? len - 1 : right;
 
    if (left < right) {
        partitionIndex = partition(arr, left, right);
        quickSort(arr, left, partitionIndex-1);
        quickSort(arr, partitionIndex+1, right);
    }
    return arr;
}
 
function partition(arr, left ,right) {    // 分区操作
    var pivot = left,                     // 设定基准值(pivot)
        index = pivot + 1;
    for (var i = index; i <= right; i++) {
        if (arr[i] < arr[pivot]) {
            swap(arr, i, index);
            index++;
        }       
    }
    swap(arr, pivot, index - 1);
    return index-1;
}
 
function swap(arr, i, j) {
    var temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

堆排序(Heap Sort)

堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。

7.1 算法描述

  • 将初始待排序关键字序列(R1,R2….Rn)构建成大顶堆,此堆为初始的无序区;
  • 将堆顶元素R[1]与最后一个元素R[n]交换,此时得到新的无序区(R1,R2,……Rn-1)和新的有序区(Rn),且满足R[1,2…n-1]<=R[n];
  • 由于交换后新的堆顶R[1]可能违反堆的性质,因此需要对当前无序区(R1,R2,……Rn-1)调整为新堆,然后再次将R[1]与无序区最后一个元素交换,得到新的无序区(R1,R2….Rn-2)和新的有序区(Rn-1,Rn)。不断重复此过程直到有序区的元素个数为n-1,则整个排序过程完成。

7.2 动图演示

849589-20171015231308699-356134237.gif

7.3 代码实现

var len;   // 因为声明的多个函数都需要数据长度,所以把len设置成为全局变量
 
function buildMaxHeap(arr) {  // 建立大顶堆
    len = arr.length;
    for (var i = Math.floor(len/2); i >= 0; i--) {
        heapify(arr, i);
    }
}
 
function heapify(arr, i) {    // 堆调整
    var left = 2 * i + 1,
        right = 2 * i + 2,
        largest = i;
 
    if (left < len && arr[left] > arr[largest]) {
        largest = left;
    }
 
    if (right < len && arr[right] > arr[largest]) {
        largest = right;
    }
 
    if (largest != i) {
        swap(arr, i, largest);
        heapify(arr, largest);
    }
}
 
function swap(arr, i, j) {
    var temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}
 
function heapSort(arr) {
    buildMaxHeap(arr);
 
    for (var i = arr.length - 1; i > 0; i--) {
        swap(arr, 0, i);
        len--;
        heapify(arr, 0);
    }
    return arr;
}

计数排序(Counting Sort)

计数排序不是基于比较的排序算法,其核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。 作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。

8.1 算法描述

  • 找出待排序的数组中最大和最小的元素;
  • 统计数组中每个值为i的元素出现的次数,存入数组C的第i项;
  • 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加);
  • 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1。

8.2 动图演示

849589-20171015231740840-6968181.gif

8.3 代码实现

function countingSort(arr, maxValue) {
    var bucket =new Array(maxValue + 1),
        sortedIndex = 0;
        arrLen = arr.length,
        bucketLen = maxValue + 1;
 
    for (var i = 0; i < arrLen; i++) {
        if (!bucket[arr[i]]) {
            bucket[arr[i]] = 0;
        }
        bucket[arr[i]]++;
    }
 
    for (var j = 0; j < bucketLen; j++) {
        while(bucket[j] > 0) {
            arr[sortedIndex++] = j;
            bucket[j]--;
        }
    }
 
    return arr;
}

8.4 算法分析

计数排序是一个稳定的排序算法。当输入的元素是 n 个 0到 k 之间的整数时,时间复杂度是O(n+k),空间复杂度也是O(n+k),其排序速度快于任何比较排序算法。当k不是很大并且序列比较集中时,计数排序是一个很有效的排序算法。

桶排序(Bucket Sort)

桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。桶排序 (Bucket sort)的工作的原理:假设输入数据服从均匀分布,将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排)。

9.1 算法描述

  • 设置一个定量的数组当作空桶;
  • 遍历输入数据,并且把数据一个一个放到对应的桶里去;
  • 对每个不是空的桶进行排序;
  • 从不是空的桶里把排好序的数据拼接起来。

9.2 图片演示

849589-20171015232107090-1920702011.png

9.3 代码实现

unction bucketSort(arr, bucketSize) {
    if (arr.length === 0) {
      return arr;
    }
 
    var i;
    var minValue = arr[0];
    var maxValue = arr[0];
    for (i = 1; i < arr.length; i++) {
      if (arr[i] < minValue) {
          minValue = arr[i];               // 输入数据的最小值
      }else if (arr[i] > maxValue) {
          maxValue = arr[i];               // 输入数据的最大值
      }
    }
 
    // 桶的初始化
    var DEFAULT_BUCKET_SIZE = 5;           // 设置桶的默认数量为5
    bucketSize = bucketSize || DEFAULT_BUCKET_SIZE;
    var bucketCount = Math.floor((maxValue - minValue) / bucketSize) + 1;  
    var buckets =new Array(bucketCount);
    for (i = 0; i < buckets.length; i++) {
        buckets[i] = [];
    }
 
    // 利用映射函数将数据分配到各个桶中
    for (i = 0; i < arr.length; i++) {
        buckets[Math.floor((arr[i] - minValue) / bucketSize)].push(arr[i]);
    }
 
    arr.length = 0;
    for (i = 0; i < buckets.length; i++) {
        insertionSort(buckets[i]);                     // 对每个桶进行排序,这里使用了插入排序
        for (var j = 0; j < buckets[i].length; j++) {
            arr.push(buckets[i][j]);                     
        }
    }
 
    return arr;
}

9.4 算法分析

桶排序最好情况下使用线性时间O(n),桶排序的时间复杂度,取决与对各个桶之间数据进行排序的时间复杂度,因为其它部分的时间复杂度都为O(n)。很显然,桶划分的越小,各个桶之间的数据越少,排序所用的时间也会越少。但相应的空间消耗就会增大。

基数排序(Radix Sort)

基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。

10.1 算法描述

  • 取得数组中的最大数,并取得位数;
  • arr为原始数组,从最低位开始取每个位组成radix数组;
  • 对radix进行计数排序(利用计数排序适用于小范围数的特点);

10.2 动图演示

849589-20171015232453668-1397662527.gif

10.3 代码实现

/ LSD Radix Sort
var counter = [];
function radixSort(arr, maxDigit) {
    var mod = 10;
    var dev = 1;
    for (var i = 0; i < maxDigit; i++, dev *= 10, mod *= 10) {
        for(var j = 0; j < arr.length; j++) {
            var bucket = parseInt((arr[j] % mod) / dev);
            if(counter[bucket]==null) {
                counter[bucket] = [];
            }
            counter[bucket].push(arr[j]);
        }
        var pos = 0;
        for(var j = 0; j < counter.length; j++) {
            var value =null;
            if(counter[j]!=null) {
                while ((value = counter[j].shift()) !=null) {
                      arr[pos++] = value;
                }
          }
        }
    }
    return arr;
}

10.4 算法分析

基数排序基于分别排序,分别收集,所以是稳定的。但基数排序的性能比桶排序要略差,每一次关键字的桶分配都需要O(n)的时间复杂度,而且分配之后得到新的关键字序列又需要O(n)的时间复杂度。假如待排数据可以分为d个关键字,则基数排序的时间复杂度将是O(d*2n) ,当然d要远远小于n,因此基本上还是线性级别的。

基数排序的空间复杂度为O(n+k),其中k为桶的数量。一般来说n>>k,因此额外空间需要大概n个左右。

查看原文

ocleo1 收藏了问题 · 2019-04-12

使用translateZ(0)提升性能的原理是什么?

很多文章中都写到使用tanslateZ可以利用gpu加速,提高浏览器渲染的性能;一些文章中说原理是因为使用translateZ的元素会单独占据一个渲染层;这样减小了元素变动时,浏览器的重新绘制的区域。

但是实际上使用translate和opacity的元素就会单独占据一个渲染层,所以使用translateZ做硬件加速的真实原理是什么?

ocleo1 赞了问题 · 2019-04-12

解决使用translateZ(0)提升性能的原理是什么?

很多文章中都写到使用tanslateZ可以利用gpu加速,提高浏览器渲染的性能;一些文章中说原理是因为使用translateZ的元素会单独占据一个渲染层;这样减小了元素变动时,浏览器的重新绘制的区域。

但是实际上使用translate和opacity的元素就会单独占据一个渲染层,所以使用translateZ做硬件加速的真实原理是什么?

关注 7 回答 5

ocleo1 收藏了文章 · 2019-02-26

常见六大Web 安全攻防解析

前言

在互联网时代,数据安全与个人隐私受到了前所未有的挑战,各种新奇的攻击技术层出不穷。如何才能更好地保护我们的数据?本文主要侧重于分析几种常见的攻击的类型以及防御的方法。

想阅读更多优质原创文章请猛戳GitHub博客

一、XSS

XSS (Cross-Site Scripting),跨站脚本攻击,因为缩写和 CSS重叠,所以只能叫 XSS。跨站脚本攻击是指通过存在安全漏洞的Web网站注册用户的浏览器内运行非法的HTML标签或JavaScript进行的一种攻击。

跨站脚本攻击有可能造成以下影响:

  • 利用虚假输入表单骗取用户个人信息。
  • 利用脚本窃取用户的Cookie值,被害者在不知情的情况下,帮助攻击者发送恶意请求。
  • 显示伪造的文章或图片。

XSS 的原理是恶意攻击者往 Web 页面里插入恶意可执行网页脚本代码,当用户浏览该页之时,嵌入其中 Web 里面的脚本代码会被执行,从而可以达到攻击者盗取用户信息或其他侵犯用户安全隐私的目的

XSS 的攻击方式千变万化,但还是可以大致细分为几种类型。

1.非持久型 XSS(反射型 XSS )

非持久型 XSS 漏洞,一般是通过给别人发送带有恶意脚本代码参数的 URL,当 URL 地址被打开时,特有的恶意代码参数被 HTML 解析、执行。


举一个例子,比如页面中包含有以下代码:

<select>
    <script>
        document.write(''
            + '<option value=1>'
            +     location.href.substring(location.href.indexOf('default=') + 8)
            + '</option>'
        );
        document.write('<option value=2>English</option>');
    </script>
</select>

攻击者可以直接通过 URL (类似:https://xxx.com/xxx?default=<script>alert(document.cookie)</script>) 注入可执行的脚本代码。不过一些浏览器如Chrome其内置了一些XSS过滤器,可以防止大部分反射型XSS攻击。

非持久型 XSS 漏洞攻击有以下几点特征:

  • 即时性,不经过服务器存储,直接通过 HTTP 的 GET 和 POST 请求就能完成一次攻击,拿到用户隐私数据。
  • 攻击者需要诱骗点击,必须要通过用户点击链接才能发起
  • 反馈率低,所以较难发现和响应修复
  • 盗取用户敏感保密信息

为了防止出现非持久型 XSS 漏洞,需要确保这么几件事情:

  • Web 页面渲染的所有内容或者渲染的数据都必须来自于服务端。
  • 尽量不要从 URLdocument.referrerdocument.forms 等这种 DOM API 中获取数据直接渲染。
  • 尽量不要使用 eval, new Function()document.write()document.writeln()window.setInterval()window.setTimeout()innerHTMLdocument.createElement() 等可执行字符串的方法。
  • 如果做不到以上几点,也必须对涉及 DOM 渲染的方法传入的字符串参数做 escape 转义。
  • 前端渲染的时候对任何的字段都需要做 escape 转义编码。

2.持久型 XSS(存储型 XSS)

持久型 XSS 漏洞,一般存在于 Form 表单提交等交互功能,如文章留言,提交文本信息等,黑客利用的 XSS 漏洞,将内容经正常功能提交进入数据库持久保存,当前端页面获得后端从数据库中读出的注入代码时,恰好将其渲染执行。

举个例子,对于评论功能来说,就得防范持久型 XSS 攻击,因为我可以在评论中输入以下内容

主要注入页面方式和非持久型 XSS 漏洞类似,只不过持久型的不是来源于 URL,referer,forms 等,而是来源于后端从数据库中读出来的数据 。持久型 XSS 攻击不需要诱骗点击,黑客只需要在提交表单的地方完成注入即可,但是这种 XSS 攻击的成本相对还是很高。

攻击成功需要同时满足以下几个条件:

  • POST 请求提交表单后端没做转义直接入库。
  • 后端从数据库中取出数据没做转义直接输出给前端。
  • 前端拿到后端数据没做转义直接渲染成 DOM。

持久型 XSS 有以下几个特点:

  • 持久性,植入在数据库中
  • 盗取用户敏感私密信息
  • 危害面广

3.如何防御

对于 XSS 攻击来说,通常有两种方式可以用来防御。

1) CSP

CSP 本质上就是建立白名单,开发者明确告诉浏览器哪些外部资源可以加载和执行。我们只需要配置规则,如何拦截是由浏览器自己实现的。我们可以通过这种方式来尽量减少 XSS 攻击。

通常可以通过两种方式来开启 CSP:

  • 设置 HTTP Header 中的 Content-Security-Policy
  • 设置 meta 标签的方式 <meta http-equiv="Content-Security-Policy">

这里以设置 HTTP Header 来举例:

  • 只允许加载本站资源
Content-Security-Policy: default-src 'self'
  • 只允许加载 HTTPS 协议图片
Content-Security-Policy: img-src https://*
  • 允许加载任何来源框架
Content-Security-Policy: child-src 'none'

如需了解更多属性,请查看Content-Security-Policy文档

对于这种方式来说,只要开发者配置了正确的规则,那么即使网站存在漏洞,攻击者也不能执行它的攻击代码,并且 CSP 的兼容性也不错。

2) 转义字符

用户的输入永远不可信任的,最普遍的做法就是转义输入输出的内容,对于引号、尖括号、斜杠进行转义

function escape(str) {
  str = str.replace(/&/g, '&amp;')
  str = str.replace(/</g, '&lt;')
  str = str.replace(/>/g, '&gt;')
  str = str.replace(/"/g, '&quto;')
  str = str.replace(/'/g, '&#39;')
  str = str.replace(/`/g, '&#96;')
  str = str.replace(/\//g, '&#x2F;')
  return str
}

但是对于显示富文本来说,显然不能通过上面的办法来转义所有字符,因为这样会把需要的格式也过滤掉。对于这种情况,通常采用白名单过滤的办法,当然也可以通过黑名单过滤,但是考虑到需要过滤的标签和标签属性实在太多,更加推荐使用白名单的方式。

const xss = require('xss')
let html = xss('<h1 id="title">XSS Demo</h1><script>alert("xss");</script>')
// -> <h1>XSS Demo</h1>&lt;script&gt;alert("xss");&lt;/script&gt;
console.log(html)

以上示例使用了 js-xss 来实现,可以看到在输出中保留了 h1 标签且过滤了 script 标签。

3) HttpOnly Cookie。

这是预防XSS攻击窃取用户cookie最有效的防御手段。Web应用程序在设置cookie时,将其属性设为HttpOnly,就可以避免该网页的cookie被客户端恶意JavaScript窃取,保护用户cookie信息。

二、CSRF

CSRF(Cross Site Request Forgery),即跨站请求伪造,是一种常见的Web攻击,它利用用户已登录的身份,在用户毫不知情的情况下,以用户的名义完成非法操作。

1.CSRF攻击的原理

下面先介绍一下CSRF攻击的原理:

完成 CSRF 攻击必须要有三个条件:

  • 用户已经登录了站点 A,并在本地记录了 cookie
  • 在用户没有登出站点 A 的情况下(也就是 cookie 生效的情况下),访问了恶意攻击者提供的引诱危险站点 B (B 站点要求访问站点A)。
  • 站点 A 没有做任何 CSRF 防御

我们来看一个例子: 当我们登入转账页面后,突然眼前一亮惊现"XXX隐私照片,不看后悔一辈子"的链接,耐不住内心躁动,立马点击了该危险的网站(页面代码如下图所示),但当这页面一加载,便会执行submitForm这个方法来提交转账请求,从而将10块转给黑客。

2.如何防御

防范 CSRF 攻击可以遵循以下几种规则:

  • Get 请求不对数据进行修改
  • 不让第三方网站访问到用户 Cookie
  • 阻止第三方网站请求接口
  • 请求时附带验证信息,比如验证码或者 Token

1) SameSite

可以对 Cookie 设置 SameSite 属性。该属性表示 Cookie 不随着跨域请求发送,可以很大程度减少 CSRF 的攻击,但是该属性目前并不是所有浏览器都兼容。

2) Referer Check

HTTP Referer是header的一部分,当浏览器向web服务器发送请求时,一般会带上Referer信息告诉服务器是从哪个页面链接过来的,服务器籍此可以获得一些信息用于处理。可以通过检查请求的来源来防御CSRF攻击。正常请求的referer具有一定规律,如在提交表单的referer必定是在该页面发起的请求。所以通过检查http包头referer的值是不是这个页面,来判断是不是CSRF攻击

但在某些情况下如从https跳转到http,浏览器处于安全考虑,不会发送referer,服务器就无法进行check了。若与该网站同域的其他网站有XSS漏洞,那么攻击者可以在其他网站注入恶意脚本,受害者进入了此类同域的网址,也会遭受攻击。出于以上原因,无法完全依赖Referer Check作为防御CSRF的主要手段。但是可以通过Referer Check来监控CSRF攻击的发生。

3) Anti CSRF Token

目前比较完善的解决方案是加入Anti-CSRF-Token。即发送请求时在HTTP 请求中以参数的形式加入一个随机产生的token,并在服务器建立一个拦截器来验证这个token。服务器读取浏览器当前域cookie中这个token值,会进行校验该请求当中的token和cookie当中的token值是否都存在且相等,才认为这是合法的请求。否则认为这次请求是违法的,拒绝该次服务。

这种方法相比Referer检查要安全很多,token可以在用户登陆后产生并放于session或cookie中,然后在每次请求时服务器把token从session或cookie中拿出,与本次请求中的token 进行比对。由于token的存在,攻击者无法再构造出一个完整的URL实施CSRF攻击。但在处理多个页面共存问题时,当某个页面消耗掉token后,其他页面的表单保存的还是被消耗掉的那个token,其他页面的表单提交时会出现token错误。

4) 验证码

应用程序和用户进行交互过程中,特别是账户交易这种核心步骤,强制用户输入验证码,才能完成最终请求。在通常情况下,验证码够很好地遏制CSRF攻击。但增加验证码降低了用户的体验,网站不能给所有的操作都加上验证码。所以只能将验证码作为一种辅助手段,在关键业务点设置验证码。

三、点击劫持

点击劫持是一种视觉欺骗的攻击手段。攻击者将需要攻击的网站通过 iframe 嵌套的方式嵌入自己的网页中,并将 iframe 设置为透明,在页面中透出一个按钮诱导用户点击。

1. 特点

  • 隐蔽性较高,骗取用户操作
  • "UI-覆盖攻击"
  • 利用iframe或者其它标签的属性

2. 点击劫持的原理

用户在登陆 A 网站的系统后,被攻击者诱惑打开第三方网站,而第三方网站通过 iframe 引入了 A 网站的页面内容,用户在第三方网站中点击某个按钮(被装饰的按钮),实际上是点击了 A 网站的按钮。
接下来我们举个例子:我在优酷发布了很多视频,想让更多的人关注它,就可以通过点击劫持来实现

iframe {
width: 1440px;
height: 900px;
position: absolute;
top: -0px;
left: -0px;
z-index: 2;
-moz-opacity: 0;
opacity: 0;
filter: alpha(opacity=0);
}
button {
position: absolute;
top: 270px;
left: 1150px;
z-index: 1;
width: 90px;
height:40px;
}
</style>
......
<button>点击脱衣</button>
<img data-original="http://pic1.win4000.com/wallpaper/2018-03-19/5aaf2bf0122d2.jpg">
<iframe data-original="http://i.youku.com/u/UMjA0NTg4Njcy" scrolling="no"></iframe>


从上图可知,攻击者通过图片作为页面背景,隐藏了用户操作的真实界面,当你按耐不住好奇点击按钮以后,真正的点击的其实是隐藏的那个页面的订阅按钮,然后就会在你不知情的情况下订阅了。

3. 如何防御

1)X-FRAME-OPTIONS

X-FRAME-OPTIONS是一个 HTTP 响应头,在现代浏览器有一个很好的支持。这个 HTTP 响应头 就是为了防御用 iframe 嵌套的点击劫持攻击。

该响应头有三个值可选,分别是

  • DENY,表示页面不允许通过 iframe 的方式展示
  • SAMEORIGIN,表示页面可以在相同域名下通过 iframe 的方式展示
  • ALLOW-FROM,表示页面可以在指定来源的 iframe 中展示

2)JavaScript 防御

对于某些远古浏览器来说,并不能支持上面的这种方式,那我们只有通过 JS 的方式来防御点击劫持了。

<head>
  <style id="click-jack">
    html {
      display: none !important;
    }
  </style>
</head>
<body>
  <script>
    if (self == top) {
      var style = document.getElementById('click-jack')
      document.body.removeChild(style)
    } else {
      top.location = self.location
    }
  </script>
</body>

以上代码的作用就是当通过 iframe 的方式加载页面时,攻击者的网页直接不显示所有内容了。

四、URL跳转漏洞

定义:借助未验证的URL跳转,将应用程序引导到不安全的第三方区域,从而导致的安全问题。

1.URL跳转漏洞原理

黑客利用URL跳转漏洞来诱导安全意识低的用户点击,导致用户信息泄露或者资金的流失。其原理是黑客构建恶意链接(链接需要进行伪装,尽可能迷惑),发在QQ群或者是浏览量多的贴吧/论坛中。
安全意识低的用户点击后,经过服务器或者浏览器解析后,跳到恶意的网站中。

恶意链接需要进行伪装,经常的做法是熟悉的链接后面加上一个恶意的网址,这样才迷惑用户。

诸如伪装成像如下的网址,你是否能够识别出来是恶意网址呢?

http://gate.baidu.com/index?act=go&url=http://t.cn/RVTatrd
http://qt.qq.com/safecheck.html?flag=1&url=http://t.cn/RVTatrd
http://tieba.baidu.com/f/user/passport?jumpUrl=http://t.cn/RVTatrd

2.实现方式:

  • Header头跳转
  • Javascript跳转
  • META标签跳转

这里我们举个Header头跳转实现方式:

<?php
$url=$_GET['jumpto'];
header("Location: $url");
?>
http://www.wooyun.org/login.php?jumpto=http://www.evil.com

这里用户会认为www.wooyun.org都是可信的,但是点击上述链接将导致用户最终访问www.evil.com这个恶意网址。

3.如何防御

1)referer的限制

如果确定传递URL参数进入的来源,我们可以通过该方式实现安全限制,保证该URL的有效性,避免恶意用户自己生成跳转链接

2)加入有效性验证Token

我们保证所有生成的链接都是来自于我们可信域的,通过在生成的链接里加入用户不可控的Token对生成的链接进行校验,可以避免用户生成自己的恶意链接从而被利用,但是如果功能本身要求比较开放,可能导致有一定的限制。

五、SQL注入

SQL注入是一种常见的Web安全漏洞,攻击者利用这个漏洞,可以访问或修改数据,或者利用潜在的数据库漏洞进行攻击。

1.SQL注入的原理

我们先举一个万能钥匙的例子来说明其原理:

<form action="/login" method="POST">
    <p>Username: <input type="text" name="username" /></p>
    <p>Password: <input type="password" name="password" /></p>
    <p><input type="submit" value="登陆" /></p>
</form>

后端的 SQL 语句可能是如下这样的:

let querySQL = `
    SELECT *
    FROM user
    WHERE username='${username}'
    AND psw='${password}'
`;
// 接下来就是执行 sql 语句...

这是我们经常见到的登录页面,但如果有一个恶意攻击者输入的用户名是 admin' --,密码随意输入,就可以直接登入系统了。why! ----这就是SQL注入

我们之前预想的SQL 语句是:

SELECT * FROM user WHERE username='admin' AND psw='password'

但是恶意攻击者用奇怪用户名将你的 SQL 语句变成了如下形式:

SELECT * FROM user WHERE username='admin' --' AND psw='xxxx'

在 SQL 中,' --是闭合和注释的意思,-- 是注释后面的内容的意思,所以查询语句就变成了:

SELECT * FROM user WHERE username='admin'

所谓的万能密码,本质上就是SQL注入的一种利用方式。

一次SQL注入的过程包括以下几个过程:

  • 获取用户请求参数
  • 拼接到代码当中
  • SQL语句按照我们构造参数的语义执行成功

**SQL注入的必备条件:
1.可以控制输入的数据
2.服务器要执行的代码拼接了控制的数据**。

我们会发现SQL注入流程中与正常请求服务器类似,只是黑客控制了数据,构造了SQL查询,而正常的请求不会SQL查询这一步,SQL注入的本质:数据和代码未分离,即数据当做了代码来执行。

2.危害

  • 获取数据库信息

    • 管理员后台用户名和密码
    • 获取其他数据库敏感信息:用户名、密码、手机号码、身份证、银行卡信息……
    • 整个数据库:脱裤
  • 获取服务器权限
  • 植入Webshell,获取服务器后门
  • 读取服务器敏感文件

3.如何防御

  • 严格限制Web应用的数据库的操作权限,给此用户提供仅仅能够满足其工作的最低权限,从而最大限度的减少注入攻击对数据库的危害
  • 后端代码检查输入的数据是否符合预期,严格限制变量的类型,例如使用正则表达式进行一些匹配处理。
  • 对进入数据库的特殊字符(',",,<,>,&,*,; 等)进行转义处理,或编码转换。基本上所有的后端语言都有对字符串进行转义处理的方法,比如 lodash 的 lodash._escapehtmlchar 库。
  • 所有的查询语句建议使用数据库提供的参数化查询接口,参数化的语句使用参数而不是将用户输入变量嵌入到 SQL 语句中,即不要直接拼接 SQL 语句。例如 Node.js 中的 mysqljs 库的 query 方法中的 ? 占位参数。

六、OS命令注入攻击

OS命令注入和SQL注入差不多,只不过SQL注入是针对数据库的,而OS命令注入是针对操作系统的。OS命令注入攻击指通过Web应用,执行非法的操作系统命令达到攻击的目的。只要在能调用Shell函数的地方就有存在被攻击的风险。倘若调用Shell时存在疏漏,就可以执行插入的非法命令。

命令注入攻击可以向Shell发送命令,让Windows或Linux操作系统的命令行启动程序。也就是说,通过命令注入攻击可执行操作系统上安装着的各种程序。

1.原理


黑客构造命令提交给web应用程序,web应用程序提取黑客构造的命令,拼接到被执行的命令中,因黑客注入的命令打破了原有命令结构,导致web应用执行了额外的命令,最后web应用程序将执行的结果输出到响应页面中。

我们通过一个例子来说明其原理,假如需要实现一个需求:用户提交一些内容到服务器,然后在服务器执行一些系统命令去返回一个结果给用户

// 以 Node.js 为例,假如在接口中需要从 github 下载用户指定的 repo
const exec = require('mz/child_process').exec;
let params = {/* 用户输入的参数 */};
exec(`git clone ${params.repo} /some/path`);

如果 params.repo 传入的是 https://github.com/admin/admin.github.io.git 确实能从指定的 git repo 上下载到想要的代码。
但是如果 params.repo 传入的是 https://github.com/xx/xx.git && rm -rf /* && 恰好你的服务是用 root 权限起的就糟糕了。

2.如何防御

  • 后端对前端提交内容进行规则限制(比如正则表达式)。
  • 在调用系统命令前对所有传入参数进行命令行参数转义过滤。
  • 不要直接拼接命令语句,借助一些工具做拼接、转义预处理,例如 Node.js 的 shell-escape npm

给大家推荐一个好用的BUG监控工具Fundebug,欢迎免费试用!

参考资料

查看原文

ocleo1 收藏了文章 · 2019-01-03

Vim 利剑常磨,见血封喉

年底了,故事总是会有很多。

Vim 利剑常磨,见血封喉

刚了一波通宵加班,趁着有时间,过了一遍Vim教程,顺便汇总下常用命令。

对于以 OSX / Linux为开发环境的伙伴们,应该并不陌生。因其轻便,扩展性,可定制化,一直很受开发者追捧。高阶大神,袖中必有深藏。

Vim编辑器简洁易用,在终端上可用来快速查看修改文档。虽然与其他编辑器如WebStrom, VScode相比,功能扩展相对欠缺,对新手朋友不太友好。但胜在多数Linux系统已附带,开箱即用,终端直接使用,无需再多开一个应用窗口。

作为一名前端开发工程师,如果你想秀操作用Vim来写HTML/CSS/JavaScript,我只想说一句,“小伙子,你很6啊!”。

Vim学习的过程不是两三天即可入境,懒惰如笔者,几天不敲,命令忘了七八。时不时还得翻出来看看,至今仍以新手自居。

前言 vi/vim的区别

vim 是 vi 的升级版本,它不仅兼容vi的所有指令,还有新增了一些新的特性。

vi filename
vim filename
// 两者同样的功能,能少敲一个字母,所以我们就用vi吧

“工具总须多磨,方才用的趁手。”

Mac 终端键入 vimtutor 即可进入Vim入门教程,30分钟足以让你应对Vim日常操作。

常规操作

命令说明
x删除光标下的字符
X删除光标左边的字符
s修改一个字符(与r的区别,修改后直接进入Insert模式)
i光标前插入字符
a光标后插入字符
A行尾插入字符
o光标下方新建一行并进入Insert模式
O (字母)光标上方新建一行并进入Insert模式
r替换字符(单个)
R替换字符(多个)
cw替换文本直到单词末尾(ce也可)
c$替换文本直到行尾
dw删除单词 (de, d2w)
d$删除到行尾
D删除到行尾
S删除到行为,并进入Insert模式
dd删除整行
y复制
yy复制整行 (yw, ye)
p粘贴到光标之后
P粘贴到光标之前
u撤销
.重复上一次操作
:!执行外部名 (:!dir/:ls)
%查找配对的括号 ), }, ]
v motion :w选择性保存
Ctrl + D查看可能的补全

光标移动

命令说明
h
l
k
j
0 (数字)行首
$行尾
w下一个单词起始处
nw (2w)n可为数字,表示移动两个单词
e单词末尾(与w的区别)
gg文件起始处
G文件末尾处

退出

命令说明
:wq保存并退出
ZZ保存并退出
:q!丢弃修改并退出 (q退出,有修改的话q不能退出)
:e!丢弃修改并打开原文件

查找并替换

命令说明
:s/old/new/g查找old并替换为new,g代表整行匹配
:#, #s/old/new#, #代表首尾行号
:%s/old/new/g全文匹配替换
:%s/old/new/gc全文匹配替换,并每次替换会弹出确认提示

翻页

命令说明
Ctrl + e向下滚动一行
Ctrl + y向上滚动一行
Ctrl + f向下翻一页
Ctrl + b向上翻一页
Ctrl + u向上翻半页
Ctrl + d向下翻半页

文件操作

命令说明
:e + filename打开新文件
:r + filename读取文件并插入到当前文件

选项设置

命令说明
:set ic忽略大小写
:set is部分匹配(like)
:set hls高亮匹配
:set nu显示行号

分屏启动vim

命令说明
vim -on file1 file2...水平分屏
vim -On file1 file2...垂直分屏

关闭分屏

命令说明
Ctrl + w  c关闭当前窗口(如果是最后一个,无法关闭)
Ctrl + w q关闭当前窗口(如果是最后一个,则退出vim)

分屏

命令说明
Ctrl + w s上下分屏
Ctrl + w v左右分屏
:sp filename左右分屏,且打开一个新的文件
:vsp filename上下分屏,且打开一个新的文件

切换分屏(光标切换)

命令说明
Ctrl + w l右边
Ctrl + w h左边
Ctrl + w j上边
Ctrl + w k下边

分屏尺寸调整

TIPS: (上下分屏生效)

命令说明
Ctrl + w =所有屏一样高度
Ctrl + w +增加高度
Ctrl + w - 减少高度

写在结尾

这么多命令要记,想对Vim说喜欢真不容易,按耐不住就新开VScode。
少年莫慌,很多事情,难在开头,当你学会了,才会感谢当初的付出。

本文仅对常用的一些命令做了汇总,Vim的玩法还有很多。在此推荐易水博客的《Vim使用进阶》系列,干货满满。

寒梅飘香,这个寒冬,与诸君互勉。


参考文档:

作者:以乐之名
本文原创,有不当的地方欢迎指出。转载请指明出处。
查看原文

ocleo1 收藏了文章 · 2018-12-20

从入门到上线一个天气小程序

前言

学习了一段时间小程序,大致过了两遍开发文档,抽空做个自己的天气预报小程序,全当是练手,在这记录下。小程序开发的安装、注册和接入等流程就不罗列了,在小程序接入指南已经写得很清楚了,以下只对开发过程常用到得一些概念进行简单梳理,类比 Vue 加强记忆,最后选取个人项目天气小程序中要注意的几点来说明。

minWeather

欢迎扫码体验

minWeather

源码请戳这里,欢迎start~

初始化项目目录结构

安装好开发者工具,填好申请到的AppID,选好项目目录,初始化一个普通小程序目录结构,得到:

--|-- pages
    |-- index
      |-- index.js // 首页js文件
      |-- index.json // 首页json文件
      |-- index.wxml // 首页wxml文件
      |-- index.wxss // 首页wxss文件
    |-- logs
      |-- logs.js // 日志页js文件
      |-- logs.json // 日志页json文件
      |-- logs.wxml // 日志页wxml文件
      |-- logs.wxss // 日志页wxss文件
  |-- utils
    |-- util.js // 小程序公用方法
  |-- app.js // 小程序逻辑
  |-- app.json // 小程序公共配置
  |-- app.wxss // 小程序公共样式表
  |-- project.config.json // 小程序项目配置

可以看到,项目文件主要分为.json.wxml.wxss.js类型,每一个页面由四个文件组成,为了方便开发者减少配置,描述页面的四个文件必须具有相同的路径与文件名。

JSON配置

小程序配置 app.json

app.json配置是当前小程序的全局配置,包括小程序的所有页面路径、界面表现、网络超时时间、底部 tab 等。

工具配置 project.config.json

工具配置在小程序的根目录,对工具做的任何配置都会写入这个文件,使得只要载入同一个项目代码包,开发则工具会自动恢复当时你开发项目时的个性设置。

页面配置 page.json

页面配置 是小程序页面相关的配置,让开发者可以独立定义每个页面的一些属性,比如顶部颜色,是否下拉等。

WXML 模板

WXML 充当类似 HTML 的角色,有标签,有属性,但是还是有些区别:

  1. 标签名不一样。
    HTML 常用标签 <div><p><span>等,而小程序中标签更像是封装好的组件,比如<scroll-view>, <swiper>, <map>,提供相应的基础能力给开发者使用。
  2. 提供 wx:if,{{}}等模板语法。
    小程序将渲染和逻辑分离,类似于ReactVueMVVM开发模式,而不是让 JS 操作 DOM

下面针对小程序的数据绑定、列表渲染、条件渲染、模板、事件和应用跟 Vue 类比加深记忆。

数据绑定

WXML 中的动态数据均来自对应 Page(或 Component) 的 data,而在 Vue中来自当前组件。

小程序和Vue的数据绑定都使用 Mustache 语法,双括号将变量包起来。区别是 Vue 中使用Mustache 语法不能作用在 HTML 特性上

<div v-bind:id="'list-' + id">{{msg}}</div>

而小程序作用在标签属性上

<view id="item-{{id}}">{{msg}}</view>

列表渲染

Vue 中使用 v-for 指令根据一组数组的选项列表,也可以通过一个对象的属性迭代进行渲染,使用 (item, index) in items(item, index) of items 形式特殊语法。

<ul>
  <li v-for="(item, index) in items">
    {{ index }} - {{ item.message }}
  </li>
</ul>

渲染包含多个元素,利用 <template>元素

<ul>
  <template v-for="(item, index) in items">
    <li>{{ index }} - {{ item.message }}</li>
    <li class="divider" role="presentation"></li>
  </template>
</ul>

而在小程序中使用 wx:for 控制属性绑定一个数组(其实对象也可以),默认数组的当前项的下标变量为 index ,当前项变量为 item

<view wx:for="{{items}}"> {{index}} - {{item.message}} </view>

也可以用 wx:for-item 指定数组当前元素的变量名,用 wx:for-index 指定数组当前下标的变量名。

<view wx:for="{{items}}" wx:for-index="idx" wx:for-item="itemName">
  {{idx}}: {{itemName.message}}
</view>

渲染一个包含多节点的结构块,利用 <block> 标签

<block wx:for="{{items}}">
  <view> {{index}} - {{item.message}} </view>
  <view class="divider" role="presentation"></view>
</block>

条件渲染

Vue 中使用v-ifv-else-ifv-else指令条件渲染,多个元素使用<template>包裹,而小程序中使用wx:ifwx:elseifwx:else来条件渲染,多个组件标签使用<block>包裹。

模板

Vue 中定义模板一种方式是在 <script> 元素中,带上 text/x-template 的类型,然后通过一个id将模板引用过去。

定义模板:

<script type="text/x-template" id="hello-world-template">
  <p>Hello hello hello</p>
  <p>{{msg}}</p>
</script>

使用模板:

Vue.component('hello-world', {
  template: '#hello-world-template',
  data () {
    return {
      msg: 'this is a template'
    }
  }
})

而在小程序中,在 <template> 中使用 name 属性作为模板名称,使用 is 属性声明需要使用的模板,然后将模板所需的 data 传入。

定义模板:

<template name="hello-world-template">
  <view>Hello hello hello</view>
  <view>{{msg}}</view>
</template>

使用模板:

<template is="hello-world-template" data="{{...item}}"></template>
Page({
  data: {
    item: {
      msg: 'this is a template'
    }
  }
})

事件

Vue 中,用 v-on 指令监听 DOM 事件,并在触发时运行一些 JavaScript 代码,对于阻止事件冒泡、事件捕获分别提供事件修饰符.stop.capture的形式

<!-- 阻止单击事件继续传播 -->
<a v-on:click.stop="doThis"></a>
<!-- 添加事件监听器时使用事件捕获模式 -->
<!-- 即元素自身触发的事件先在此处理,然后才交由内部元素进行处理 -->
<div v-on:click.capture="doThis">...</div>

而在小程序中,绑定事件以 keyvalue 的形式,keybindcatch 开头,然后跟上事件的类型,如 bindtapcatchtouchstart,也可紧跟一个冒号形式,如 bind:tapcatch:touchstartbind 事件绑定不会阻止冒泡事件向上冒泡,catch 事件绑定可以阻止冒泡事件向上冒泡。

<!-- 单击事件冒泡继续传播 -->
<view bindtap="doThis">bindtap</view>
<!-- 阻止单击事件冒泡继续传播 -->
<view catchtap="doThis">bindtap</view>

采用 capture-bindcapture-catch 分别捕获事件和中断捕获并取消冒泡。

<!-- 捕获单击事件继续传播 -->
<view capture-bind:tap="doThis">bindtap</view>
<!-- 捕获单击事件阻止继续传播,并且阻止冒泡 -->
<view capture-catch="doThis">bindtap</view>

引用

Vue 中引用用于组件的服用引入

import ComponentA from './ComponentA'
import ComponentC from './ComponentC'

在小程序中,WXML 提供两种引用方式 importinclude

在 item.wxml 中定义了一个叫item的template:

<!-- item.wxml -->
<template name="item">
  <text>{{text}}</text>
</template>

在 index.wxml 中引用了 item.wxml,就可以使用item模板:

<import data-original="item.wxml" /> <template is="item" data="{{text: 'forbar'}}" />

include 可以将目标文件除了 <template><wxs> 外整个代码引入:

<!-- index.wxml -->
<include data-original="header.wxml" /> <view> body </view> <include data-original="footer.wxml" />
<!-- header.wxml -->
<view> header </view>
<!-- footer.wxml -->
<view> footer </view>

WXSS 样式

WXSS(WeiXin Style Sheets) 具有 CSS 大部分的特性,也做了一些扩充和修改。

尺寸单位rpx

支持新的尺寸单位 rpx,根据屏幕宽度自适应,规定屏幕宽为750rpx,免去开发换算的烦恼(采用浮点计算,和预期结果会有点偏差)。

设备rpx换算px(屏宽/750)px换算rpx(750/屏宽)
iPhone51rpx = 0.42px1px = 2.34rpx
iPhone61rpx = 0.5px1px = 2rpx
iPhone6 Plus1rpx = 0.552px1px = 1.81rpx

iPhone6上,换算相对最简单,1rpx = 0.5px = 1物理像素,建议设计师以 iPhone6 为设计稿。

样式导入

使用 @import 语句导入外联样式表,注意路径为相对路径。

全局样式与局部样式

app.wxss中的样式为全局样式,在 Page (或 Component) 的 wxss文件中定义的样式为局部样式,自作用在对应页面,并会覆盖 app.wxss 中相同选择器。

页面注册

小程序是以 Page(Object) 构造页面独立环境,app加载后,初始化某个页面,类似于 Vue 的实例化过程,有自己的初始数据、生命周期和事件处理回调函数。

初始化数据

Vue 一样,在构造实例属性上都有一个 data 对象,作为初始数据。

Vue 中修改 data 中某个属性值直接赋值即可,而在小程序中需要使用 Page 的实例方法 setData(Object data, Function callback) 才起作用,不需要在 this.data 中预先定义,单次设置数据大小不得超过1024kb。

支持以数据路径的形式改变数组某项或对象某项属性:

// 对于对象或数组字段,可以直接修改一个其下的子字段,这样做通常比修改整个对象或数组更好
  this.setData({
    'array[0].text': 'changed data'
  })

生命周期回调函数

每个 Vue 实例在被创建时都要经过一系列的初始化过程,每一个阶段都有相应钩子函数被调用,createdmountedupdateddestroyed

vueLifecycle

对于小程序生命周期,分为 Page 的生命周期和 Component 的生命周期。

Page 的生命周期回调函数有:

  • onLoad 生命周期回调-监听页面加载
  • onShow 生命周期回调-监听页面显示
  • onReady 生命周期回调-监听页面初次渲染完成
  • onHide 生命周期回调-监听页面隐藏
  • onUnload 生命周期回调-监听页面卸载
  • onPullDownRefresh监听用户下拉动作
  • onReachBotton 页面上拉触底事件的处理函数
  • onShareAppMessage 用户点击右上角转发
  • onPageScroll 页面滚动触发事件的处理函数
  • onTabItemTap 当前是 tab 页时,点击 tab 触发

Component 的生命周期有:

  • created 在组件实例刚刚被创建时执行
  • attached 在组件实例进入页面节点树时执行
  • ready 在组件在视图层布局完成后执行
  • moved 在组件实例被移动到节点树另一个位置时执行
  • detached 在组件实例被从页面节点树移除时执行
  • error 每当组件方法抛出错误时执行
  • show 组件所在的页面被展示时执行
  • hide 组件所在的页面被隐藏时执行
  • resize 组件所在的页面尺寸变化时执行

vueLifecycle

wxs

WXS(WeiXin Script)是小程序的一套脚本语言,结合 WXML,可以构建出页面的结构。wxs 的运行环境和其他 JavaScript 代码是隔离的,wxs 中不能调用其他 JavaScript 文件中定义的函数,也不能调用小程序提供的API。从语法上看,大部分和 JavaScript是一样的,以下列出一些注意点和差别:

  • <wxs> 模块只能在定义模块的 WXML 文件中被访问。使用 <include><import> 时, <wxs> 模块不会被引用到对应的 WXML 文件中;
  • <template> 标签中,只能使用定义该<template>WXML 文件中定义的 <wxs> 模块;
  • Date对象,需要使用 getDate 函数,返回一个当前时间的对象;
  • RegExp对象,使用 getRegExp 函数;
  • 使用 constructor 属性判断数据类型。

组件间通信

小程序组件间通信和Vue 组件间通信很相似

父组件传值到子组件

Vue 中,父组件定义一些自定义特性,子组件通过 props 实例属性获取,也可通过 wm.$refs 可以获取子组件获取子组件所有属性和方法。

<!-- 父组件 -->
<blog-post title="A title"></blog-post>
<!-- 子组件 -->
<h3>{{ postTitle }}</h3>
export default {
  props: ['postTitle']
}

同样的,在小程序中,父组件定义一些特性,子组件通过 properties 实例属性获取,不同的是,提供了 observer 回调函数,可以监听传递值的变化。父组件还可以通过 this.selectComponent 方法获取子组件实例对象,这样就可以直接访问组件的任意数据和方法。

Component({
  properties: {
    myProperty: { // 属性名
      type: String, // 类型(必填),目前接受的类型包括:String, Number, Boolean, Object, Array, null(表示任意类型)
      value: '', // 属性初始值(可选),如果未指定则会根据类型选择一个
      observer(newVal, oldVal, changedPath) {
        // 属性被改变时执行的函数(可选),也可以写成在methods段中定义的方法名字符串, 如:'_propertyChange'
        // 通常 newVal 就是新设置的数据, oldVal 是旧数据
      }
    },
    myProperty2: String // 简化的定义方式
  }
})

子组件传值到父组件

在Vue 中通过自定义事件系统触发 vm.$emit( eventName, […args] ) 回调传参实现。

<!-- 子组件 -->
<button v-on:click="$emit('enlarge-text')">
  Enlarge text
</button>
<!-- 父组件 -->
<blog-post
  ...
  v-on:enlarge-text="postFontSize += 0.1"
></blog-post>

同样的,在小程序中也是通过触发自定义事件 triggerEvent 回调传参形式实现子组件向父组件传递数据。

<!-- page.wxml -->
<my-component bindcustomevent="pageEventListener2"></my-component>
// my-component.js
Component({
  methods: {
    onTap () {
      this.triggerEvent('customevent', {})
    }
  }
})

天气预报小程序

说了很多小程序开发的基础准备,下面就结合个人实际练手项目——天气预报小程序简单说明。

物料准备

从需求结果导向,天气程序首先要能获取到当前所在地天气状况,再次可以自由选择某地,知道其天气状况。这样就需要有获取天气的API和搜索地址API。

开发前物料(服务能力)准备好了,接下来就是撸小程序了!

首页获取用户信息、布局相关

布局

微信小程序的样式已支持大部分 CSS 特性,不用再去考虑太多传统浏览器兼容性问题了,布局方便直接选用 flex 布局。
比如:

/**app.wxss**/
page {
  background: #f6f6f6;
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
}

获取用户信息

首页首次加载获取用户,通常会弹窗提示是否允许获取用户信息,用户点击允许获取授权,才能成功获取用户信息,展示用户名和用户头像等,小程序为了优化用户体验,使用 wx.getUserInfo 接口直接弹出授权框的开发方式将逐步不再支持。目前开发环境不弹窗了,正式版暂不受影响。提倡使用 button 组件,指定 open-typegetUserInfo类型,用户主动点击后才弹窗。
天气小程序获取用户头像和用户名采用的是另一种方式,使用open-data 可以直接获取用户基础信息,不用弹窗提示。

  <!-- 用户信息 -->
  <view class="userinfo">
    <open-data type="userAvatarUrl" class="userinfo-avatar"/>
    <text class="userinfo-nickname">{{greetings}},</text>
    <open-data type="userNickName"/>
</view>

城市拼音首字母锚点

上下滑动城市列表,当滑过当前可视区的城市拼音首字母,右侧字母索引栏对应的字母也会切换到高亮显示。

要满足当前的这个场景需求,首先要为城市列表的拼音首字母标题添加标志(id),当<scroll-view>滚动触发时获取各个标志位距离视窗顶部的位置,此处用到小程序 WXML 节点API NodesRef.boundingClientRect(function callback) 获取布局位置,类似于 DOMgetBoundingClientRect。距离大小为最小负数的标志位是当前刚滑过的,右侧索引栏对应字母应当高亮。

<!-- searchGeo.wxml -->
<scroll-view bindscroll="scroll" scroll-y="{{true}}">
  <!-- 城市列表... -->
</scroll-view>
Page({
  // ...
  // 城市列表滚动
  scroll () {
    wx.createSelectorQuery().selectAll('.city-list-title')
      .boundingClientRect((rects) => {
        let index = rects.findIndex((item) => {
          return item.top >= 0
        })
        if (index === -1) {
          index = rects.length
        }
        this.setIndex(index - 1)
      }).exec()
  },
  // ...
点击右侧字母索引栏的字母,城市列表自动滑动使得对应字母标题可视

满足这个需求场景,可以利用 <scroll-view> 组件的 scroll-into-view 属性,由于已有拼音首字母标题添加标志(id),只需将当前点击的字母对应的元素id滚动到可视即可。需要注意:

  • 频繁 setData 造成性能问题,在这里过滤重复赋值;
  • 由于设置了 <scroll-view> 为动画滚动效果,滚动到标志元素位置需要时间,途中可能会经过其它标志元素,不能立即设置索引焦点,要有一定延时(还没找到其它好解决方案,暂时这样)
// 点击索引条
  tapIndexItem (event) {
    let id = event.currentTarget.dataset.item
    this.setData({
      scrollIntoViewId: `title_${id === '#' ? 0 : id}`
    })

    // 延时设置索引条焦点
    setTimeout(() => {
      this.setData({
        barIndex: this.data.indexList.findIndex((item) => item === id)
      })
    }, 500)
  },

minWeather

频繁触发节流处理

频繁输入,或者频繁滚动,回调触发会造成性能问题,而其接口也有限定调用频率,这样就需要做节流处理。节流是再频繁触发的情况下,在大于一定时间间隔才允许触发。

// 节流
const throttle = function(fn, delay) {
  let lastTime = 0
  return function () {
    let nowTime = Date.now()
    if (nowTime - lastTime > delay || !lastTime) {
      fn.apply(this, arguments)
      lastTime = nowTime
    }
  }
}

具体对一些场景,比如腾讯位置服务提供的关键字搜索地址,就限定5次/key/秒,很容易就超了,可以做节流处理

Page({
  // ...
  // 输入搜索关键字
  input: util.throttle(function () {
    let val = arguments[0].detail.value
    if (val === '') {
      this.setData({
        suggList: []
      })
      this.changeSearchCls()
      return false
    }

    api.getSuggestion({
      keyword: val
    })
      .then((res) => {
        this.setData({
          suggList: res
        })
        this.changeSearchCls()
      })
      .catch((err) => {
        console.error(err)
      })
  }, 500),
  // ...
})

minWeather

对于上面城市列表滚动,获取标志元素位置也应用节流处理。

总结

小程序的基本入门学习门槛不高,小程序的设计应该借鉴了很多现在流行的框架,如果有 ReactVue 的基础会有很多似曾相识的感觉,当然,在深入的探索过程还有很多“坑”要跨越,本文只是简单的梳理,具体问题还能多看文档小程序社区,还有什么错误欢迎指正哈,完~

查看原文

ocleo1 收藏了文章 · 2018-12-16

DOM操作成本到底高在哪儿?

从我接触前端到现在,一直听到的一句话:操作DOM的成本很高,不要轻易去操作DOM。尤其是React、vue等MV*框架的出现,数据驱动视图的模式越发深入人心,jQuery时代提供的强大便利地操作DOM的API在前端工程里用的越来越少。刨根问底,这里说的成本,到底高在哪儿呢?

什么是DOM

Document Object Model 文档对象模型

什么是DOM?可能很多人第一反应就是div、p、span等html标签(至少我是),但要知道,DOM是Model,是Object Model,对象模型,是为HTML(and XML)提供的API。HTML(Hyper Text Markup Language)是一种标记语言,HTML在DOM的模型标准中被视为对象,DOM只提供编程接口,却无法实际操作HTML里面的内容。但在浏览器端,前端们可以用脚本语言(JavaScript)通过DOM去操作HTML内容。

那么问题来了,只有JavaScript才能调用DOM这个API吗?

答案是NO

Python也可以访问DOM。所以DOM不是提供给Javascript的API,也不是Javascript里的API。

PS: 实质上还存在CSSOM:CSS Object Model,浏览器将CSS代码解析成树形的数据结构,与DOM是两个独立的数据结构

浏览器渲染过程

讨论DOM操作成本,肯定要先了解该成本的来源,那么就离不开浏览器渲染。

这里暂只讨论浏览器拿到HTML之后开始解析、渲染。(怎么拿到HTML资源的可能后续另开篇总结吧,什么握握握手啊挥挥挥挥手啊,万恶的flag...)

  1. 解析HTML,构建DOM树(这里遇到外链,此时会发起请求)
  2. 解析CSS,生成CSS规则树
  3. 合并DOM树和CSS规则,生成render树
  4. 布局render树(Layout/reflow),负责各元素尺寸、位置的计算
  5. 绘制render树(paint),绘制页面像素信息
  6. 浏览器会将各层的信息发送给GPU,GPU将各层合成(composite),显示在屏幕上

1.构建DOM树

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
    <title>Critical Path</title>
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img data-original="awesome-photo.jpg"></div>
  </body>
</html>
无论是DOM还是CSSOM,都是要经过Bytes → characters → tokens → nodes → object model这个过程。

DOM树构建过程:当前节点的所有子节点都构建好后才会去构建当前节点的下一个兄弟节点。

2.构建CSSOM树

上述也提到了CSSOM的构建过程,也是树的结构,在最终计算各个节点的样式时,浏览器都会先从该节点的普遍属性(比如body里设置的全局样式)开始,再去应用该节点的具体属性。还有要注意的是,每个浏览器都有自己默认的样式表,因此很多时候这棵CSSOM树只是对这张默认样式表的部分替换。

3.生成render树

DOM树和CSSOM树合并生成render树

简单描述这个过程:

DOM树从根节点开始遍历可见节点,这里之所以强调了“可见”,是因为如果遇到设置了类似display: none;的不可见节点,在render过程中是会被跳过的(但visibility: hidden; opacity: 0这种仍旧占据空间的节点不会被跳过render),保存各个节点的样式信息及其余节点的从属关系。

4.Layout 布局

有了各个节点的样式信息和属性,但不知道各个节点的确切位置和大小,所以要通过布局将样式信息和属性转换为实际可视窗口的相对大小和位置。

5.Paint 绘制

万事俱备,最后只要将确定好位置大小的各节点,通过GPU渲染到屏幕的实际像素。

Tips

  • 在上述渲染过程中,前3点可能要多次执行,比如js脚本去操作dom、更改css样式时,浏览器又要重新构建DOM、CSSOM树,重新render,重新layout、paint;
  • Layout在Paint之前,因此每次Layout重新布局(reflow 回流)后都要重新出发Paint渲染,这时又要去消耗GPU;
  • Paint不一定会触发Layout,比如改个颜色改个背景;(repaint 重绘)
  • 图片下载完也会重新出发Layout和Paint;

何时触发reflow和repaint

reflow(回流): 根据Render Tree布局(几何属性),意味着元素的内容、结构、位置或尺寸发生了变化,需要重新计算样式和渲染树;
repaint(重绘): 意味着元素发生的改变只影响了节点的一些样式(背景色,边框颜色,文字颜色等),只需要应用新样式绘制这个元素就可以了;
reflow回流的成本开销要高于repaint重绘,一个节点的回流往往回导致子节点以及同级节点的回流;

GoogleChromeLabs 里面有一个csstriggers,列出了各个CSS属性对浏览器执行Layout、Paint、Composite的影响。

引起reflow回流

现代浏览器会对回流做优化,它会等到足够数量的变化发生,再做一次批处理回流。
  1. 页面第一次渲染(初始化)
  2. DOM树变化(如:增删节点)
  3. Render树变化(如:padding改变)
  4. 浏览器窗口resize
  5. 获取元素的某些属性:
    浏览器为了获得正确的值也会提前触发回流,这样就使得浏览器的优化失效了,这些属性包括offsetLeft、offsetTop、offsetWidth、offsetHeight、 scrollTop/Left/Width/Height、clientTop/Left/Width/Height、调用了getComputedStyle()或者IE的currentStyle

引起repaint重绘

  1. reflow回流必定引起repaint重绘,重绘可以单独触发
  2. 背景色、颜色、字体改变(注意:字体大小发生变化时,会触发回流)

优化reflow、repaint触发次数

  • 避免逐个修改节点样式,尽量一次性修改
  • 使用DocumentFragment将需要多次修改的DOM元素缓存,最后一次性append到真实DOM中渲染
  • 可以将需要多次修改的DOM元素设置display: none,操作完再显示。(因为隐藏元素不在render树内,因此修改隐藏元素不会触发回流重绘)
  • 避免多次读取某些属性(见上)
  • 将复杂的节点元素脱离文档流,降低回流成本

为什么一再强调将css放在头部,将js文件放在尾部

DOMContentLoaded 和 load

  • DOMContentLoaded 事件触发时,仅当DOM加载完成,不包括样式表,图片...
  • load 事件触发时,页面上所有的DOM,样式表,脚本,图片都已加载完成

CSS 资源阻塞渲染

构建Render树需要DOM和CSSOM,所以HTML和CSS都会阻塞渲染。所以需要让CSS尽早加载(如:放在头部),以缩短首次渲染的时间。

JS 资源

  • 阻塞浏览器的解析,也就是说发现一个外链脚本时,需等待脚本下载完成并执行后才会继续解析HTML

  • 普通的脚本会阻塞浏览器解析,加上defer或async属性,脚本就变成异步,可等到解析完毕再执行

    • async异步执行,异步下载完毕后就会执行,不确保执行顺序,一定在onload前,但不确定在DOMContentLoaded事件的前后
    • defer延迟执行,相对于放在body最后(理论上在DOMContentLoaded事件前)

举个栗子

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img data-original="awesome-photo.jpg"></div>
    <script data-original="app.js"></script>
  </body>
</html>

  • 浏览器拿到HTML后,从上到下顺序解析文档
  • 此时遇到css、js外链,则同时发起请求
  • 开始构建DOM树
  • 这里要特别注意,由于有CSS资源,CSSOM还未构建前,会阻塞js(如果有的话)
  • 无论JavaScript是内联还是外链,只要浏览器遇到 script 标记,唤醒JavaScript解析器,就会进行暂停 blocked 浏览器解析HTML,并等到 CSSOM 构建完毕,才执行js脚本
  • 渲染首屏(DOMContentLoaded 触发,其实不一定是首屏,可能在js脚本执行前DOM树和CSSOM已经构建完render树,已经paint)

首屏优化Tips

说了这么多,其实可以总结几点浏览器首屏渲染优化的方向
  • 减少资源请求数量(内联亦或是延迟动态加载)
  • 使CSS样式表尽早加载,减少@import的使用,因为需要解析完样式表中所有import的资源才会算CSS资源下载完
  • 异步js:阻塞解析器的 JavaScript 会强制浏览器等待 CSSOM 并暂停 DOM 的构建,导致首次渲染的时间延迟
  • so on...

知道操作DOM成本多高了吗?

其实写了这么多,感觉偏题了,大量的资料参考的是chrome开发者文档。感觉js脚本资源那块还是有点乱,包括和DOMContentLoaded的关系,希望大家能多多指点,多多批评,谢谢大佬们。

操作DOM具体的成本,说到底是造成浏览器回流reflow和重绘reflow,从而消耗GPU资源。

参考文献:

https://developers.google.com/web/fundamentals/performance/critical-rendering-path/

已同步至个人博客-软硬皆施
Github 欢迎star :)
查看原文

ocleo1 收藏了文章 · 2018-12-14

你真的了解回流和重绘吗

回流和重绘可以说是每一个web开发者都经常听到的两个词语,我也不例外,可是我之前一直不是很清楚这两步具体做了什么事情。最近由于部门内部要做分享,所以对其进行了一些研究,看了一些博客和书籍,整理了一些内容并且结合一些例子,写了这篇文章,希望可以帮助到大家。

浏览器的渲染过程

本文先从浏览器的渲染过程来从头到尾的讲解一下回流重绘,如果大家想直接看如何减少回流和重绘,可以跳到后面。(这个渲染过程来自MDN

webkit渲染过程

从上面这个图上,我们可以看到,浏览器渲染过程如下:

  1. 解析HTML,生成DOM树,解析CSS,生成CSSOM树
  2. 将DOM树和CSSOM树结合,生成渲染树(Render Tree)
  3. Layout(回流):根据生成的渲染树,进行回流(Layout),得到节点的几何信息(位置,大小)
  4. Painting(重绘):根据渲染树以及回流得到的几何信息,得到节点的绝对像素
  5. Display:将像素发送给GPU,展示在页面上。(这一步其实还有很多内容,比如会在GPU将多个合成层合并为同一个层,并展示在页面中。而css3硬件加速的原理则是新建合成层,这里我们不展开,之后有机会会写一篇博客)

渲染过程看起来很简单,让我们来具体了解下每一步具体做了什么。

生成渲染树

生成渲染树

为了构建渲染树,浏览器主要完成了以下工作:

  1. 从DOM树的根节点开始遍历每个可见节点。
  2. 对于每个可见的节点,找到CSSOM树中对应的规则,并应用它们。
  3. 根据每个可见节点以及其对应的样式,组合生成渲染树。

第一步中,既然说到了要遍历可见的节点,那么我们得先知道,什么节点是不可见的。不可见的节点包括:

  • 一些不会渲染输出的节点,比如script、meta、link等。
  • 一些通过css进行隐藏的节点。比如display:none。注意,利用visibility和opacity隐藏的节点,还是会显示在渲染树上的。只有display:none的节点才不会显示在渲染树上。

注意:渲染树只包含可见的节点

回流

前面我们通过构造渲染树,我们将可见DOM节点以及它对应的样式结合起来,可是我们还需要计算它们在设备视口(viewport)内的确切位置和大小,这个计算的阶段就是回流。

为了弄清每个对象在网站上的确切大小和位置,浏览器从渲染树的根节点开始遍历,我们可以以下面这个实例来表示:

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>Critial Path: Hello world!</title>
  </head>
  <body>
    <div style="width: 50%">
      <div style="width: 50%">Hello world!</div>
    </div>
  </body>
</html>

我们可以看到,第一个div将节点的显示尺寸设置为视口宽度的50%,第二个div将其尺寸设置为父节点的50%。而在回流这个阶段,我们就需要根据视口具体的宽度,将其转为实际的像素值。(如下图)

重绘

最终,我们通过构造渲染树和回流阶段,我们知道了哪些节点是可见的,以及可见节点的样式和具体的几何信息(位置、大小),那么我们就可以将渲染树的每个节点都转换为屏幕上的实际像素,这个阶段就叫做重绘节点。

既然知道了浏览器的渲染过程后,我们就来探讨下,何时会发生回流重绘。

何时发生回流重绘

我们前面知道了,回流这一阶段主要是计算节点的位置和几何信息,那么当页面布局和几何信息发生变化的时候,就需要回流。比如以下情况:

  • 添加或删除可见的DOM元素
  • 元素的位置发生变化
  • 元素的尺寸发生变化(包括外边距、内边框、边框大小、高度和宽度等)
  • 内容发生变化,比如文本变化或图片被另一个不同尺寸的图片所替代。
  • 页面一开始渲染的时候(这肯定避免不了)
  • 浏览器的窗口尺寸变化(因为回流是根据视口的大小来计算元素的位置和大小的)

注意:回流一定会触发重绘,而重绘不一定会回流

根据改变的范围和程度,渲染树中或大或小的部分需要重新计算,有些改变会触发整个页面的重排,比如,滚动条出现的时候或者修改了根节点。

浏览器的优化机制

现代的浏览器都是很聪明的,由于每次重排都会造成额外的计算消耗,因此大多数浏览器都会通过队列化修改并批量执行来优化重排过程。浏览器会将修改操作放入到队列里,直到过了一段时间或者操作达到了一个阈值,才清空队列。但是!当你获取布局信息的操作的时候,会强制队列刷新,比如当你访问以下属性或者使用以下方法:

  • offsetTop、offsetLeft、offsetWidth、offsetHeight
  • scrollTop、scrollLeft、scrollWidth、scrollHeight
  • clientTop、clientLeft、clientWidth、clientHeight
  • getComputedStyle()
  • getBoundingClientRect
  • 具体可以访问这个网站:https://gist.github.com/pauli...

以上属性和方法都需要返回最新的布局信息,因此浏览器不得不清空队列,触发回流重绘来返回正确的值。因此,我们在修改样式的时候,最好避免使用上面列出的属性,他们都会刷新渲染队列。如果要使用它们,最好将值缓存起来。

减少回流和重绘

好了,到了我们今天的重头戏,前面说了这么多背景和理论知识,接下来让我们谈谈如何减少回流和重绘。

最小化重绘和重排

由于重绘和重排可能代价比较昂贵,因此最好就是可以减少它的发生次数。为了减少发生次数,我们可以合并多次对DOM和样式的修改,然后一次处理掉。考虑这个例子

const el = document.getElementById('test');
el.style.padding = '5px';
el.style.borderLeft = '1px';
el.style.borderRight = '2px';

例子中,有三个样式属性被修改了,每一个都会影响元素的几何结构,引起回流。当然,大部分现代浏览器都对其做了优化,因此,只会触发一次重排。但是如果在旧版的浏览器或者在上面代码执行的时候,有其他代码访问了布局信息(上文中的会触发回流的布局信息),那么就会导致三次重排。

因此,我们可以合并所有的改变然后依次处理,比如我们可以采取以下的方式:

  • 使用cssText

    const el = document.getElementById('test');
    el.style.cssText += 'border-left: 1px; border-right: 2px; padding: 5px;';
  • 修改CSS的class

    const el = document.getElementById('test');
    el.className += ' active';

批量修改DOM

当我们需要对DOM对一系列修改的时候,可以通过以下步骤减少回流重绘次数:

  1. 使元素脱离文档流
  2. 对其进行多次修改
  3. 将元素带回到文档中。

该过程的第一步和第三步可能会引起回流,但是经过第一步之后,对DOM的所有修改都不会引起回流,因为它已经不在渲染树了。

有三种方式可以让DOM脱离文档流:

  • 隐藏元素,应用修改,重新显示
  • 使用文档片段(document fragment)在当前DOM之外构建一个子树,再把它拷贝回文档。
  • 将原始元素拷贝到一个脱离文档的节点中,修改节点后,再替换原始的元素。

考虑我们要执行一段批量插入节点的代码:

function appendDataToElement(appendToElement, data) {
    let li;
    for (let i = 0; i < data.length; i++) {
        li = document.createElement('li');
        li.textContent = 'text';
        appendToElement.appendChild(li);
    }
}

const ul = document.getElementById('list');
appendDataToElement(ul, data);

如果我们直接这样执行的话,由于每次循环都会插入一个新的节点,会导致浏览器回流一次。

我们可以使用这三种方式进行优化:

隐藏元素,应用修改,重新显示

这个会在展示和隐藏节点的时候,产生两次重绘

function appendDataToElement(appendToElement, data) {
    let li;
    for (let i = 0; i < data.length; i++) {
        li = document.createElement('li');
        li.textContent = 'text';
        appendToElement.appendChild(li);
    }
}
const ul = document.getElementById('list');
ul.style.display = 'none';
appendDataToElement(ul, data);
ul.style.display = 'block';

使用文档片段(document fragment)在当前DOM之外构建一个子树,再把它拷贝回文档

const ul = document.getElementById('list');
const fragment = document.createDocumentFragment();
appendDataToElement(fragment, data);
ul.appendChild(fragment);

将原始元素拷贝到一个脱离文档的节点中,修改节点后,再替换原始的元素。

const ul = document.getElementById('list');
const clone = ul.cloneNode(true);
appendDataToElement(clone, data);
ul.parentNode.replaceChild(clone, ul);

对于上述那种情况,我写了一个demo来测试修改前和修改后的性能。然而实验结果不是很理想。

原因:原因其实上面也说过了,浏览器会使用队列来储存多次修改,进行优化,所以对这个优化方案,我们其实不用优先考虑。

避免触发同步布局事件

上文我们说过,当我们访问元素的一些属性的时候,会导致浏览器强制清空队列,进行强制同步布局。举个例子,比如说我们想将一个p标签数组的宽度赋值为一个元素的宽度,我们可能写出这样的代码:

function initP() {
    for (let i = 0; i < paragraphs.length; i++) {
        paragraphs[i].style.width = box.offsetWidth + 'px';
    }
}

这段代码看上去是没有什么问题,可是其实会造成很大的性能问题。在每次循环的时候,都读取了box的一个offsetWidth属性值,然后利用它来更新p标签的width属性。这就导致了每一次循环的时候,浏览器都必须先使上一次循环中的样式更新操作生效,才能响应本次循环的样式读取操作。每一次循环都会强制浏览器刷新队列。我们可以优化为:

const width = box.offsetWidth;
function initP() {
    for (let i = 0; i < paragraphs.length; i++) {
        paragraphs[i].style.width = width + 'px';
    }
}

同样,我也写了个demo来比较两者的性能差异。你可以自己点开这个demo体验下。这个对比差距就比较明显。

对于复杂动画效果,使用绝对定位让其脱离文档流

对于复杂动画效果,由于会经常的引起回流重绘,因此,我们可以使用绝对定位,让它脱离文档流。否则会引起父元素以及后续元素频繁的回流。这个我们就直接上个例子

打开这个例子后,我们可以打开控制台,控制台上会输出当前的帧数(虽然不准)。

image-20181210223750055

从上图中,我们可以看到,帧数一直都没到60。这个时候,只要我们点击一下那个按钮,把这个元素设置为绝对定位,帧数就可以稳定60。

css3硬件加速(GPU加速)

比起考虑如何减少回流重绘,我们更期望的是,根本不要回流重绘。这个时候,css3硬件加速就闪亮登场啦!!

划重点:使用css3硬件加速,可以让transform、opacity、filters这些动画不会引起回流重绘 。但是对于动画的其它属性,比如background-color这些,还是会引起回流重绘的,不过它还是可以提升这些动画的性能。

本篇文章只讨论如何使用,暂不考虑其原理,之后有空会另外开篇文章说明。

如何使用

常见的触发硬件加速的css属性:

  • transform
  • opacity
  • filters
  • Will-change

效果

我们可以先看个例子。我通过使用chrome的Performance捕获了一段时间的回流重绘情况,实际结果如下图:

image-20181210225609533

从图中我们可以看出,在动画进行的时候,没有发生任何的回流重绘。如果感兴趣你也可以自己做下实验。

重点

  • 使用css3硬件加速,可以让transform、opacity、filters这些动画不会引起回流重绘
  • 对于动画的其它属性,比如background-color这些,还是会引起回流重绘的,不过它还是可以提升这些动画的性能。

css3硬件加速的坑

  • 如果你为太多元素使用css3硬件加速,会导致内存占用较大,会有性能问题。
  • 在GPU渲染字体会导致抗锯齿无效。这是因为GPU和CPU的算法不同。因此如果你不在动画结束的时候关闭硬件加速,会产生字体模糊。

总结

本文主要讲了浏览器的渲染过程、浏览器的优化机制以及如何减少甚至避免回流和重绘,希望可以帮助大家更好的理解回流重绘。

参考文献

本文地址在->本人博客地址, 欢迎给个 start 或 follow

查看原文

ocleo1 收藏了文章 · 2018-12-11

8张图帮你一步步看清 async/await 和 promise 的执行顺序

8张图让你一步步看清 async/await 和 promise 的执行顺序

  • 为什么写这篇文章?
  • 测试一下自己有没有必要看
  • 需要具备的前置基础知识
  • 主要内容

    • 对于async await的理解
    • 画图一步步看清宏任务、微任务的执行过程

为什么写这篇文章?

说实话,关于js的异步执行顺序,宏任务、微任务这些,或者async/await这些慨念已经有非常多的文章写了。

但是怎么说呢,简单来说,业务中很少用async,不太懂async呢,

研究了一天,感觉懂了,所手痒想写一篇 ,哈哈

毕竟自己学会的知识,如果连表达清楚都做不到,怎么能指望自己用好它呢?

测试一下自己有没有必要看

所以我写这个的文章,主要还是交流学习,如果您已经清楚了eventloop/async/await/promise这些东西呢,可以 break 啦

有说的不对的地方,欢迎留言讨论,

那么还是先通过一道题自我检测一下,是否有必要继续看下去把。

其实呢,这是去年一道烂大街的「今日头条」的面试题

我觉得这道题的关键,不仅是说出正确的打印顺序,更重要的能否说清楚每一个步骤,为什么这样执行。


    async function async1() {
        console.log( 'async1 start' )
        await async2()
        console.log( 'async1 end' )
    }
    
    async function async2() {
        console.log( 'async2' )
    }
    
    console.log( 'script start' )
    
    setTimeout( function () {
        console.log( 'setTimeout' )
    }, 0 )
    
    async1();
    
    new Promise( function ( resolve ) {
        console.log( 'promise1' )
        resolve();
    } ).then( function () {
        console.log( 'promise2' )
    } )
    
    console.log( 'script end' )
注:因为是一道前端面试题,所以答案是以浏览器的eventloop机制为准的,在node平台上运行会有差异。
     script start
     async1 start
     async2
     promise1
     script end
     promise2
     async1 end
     setTimeout

如果你发现运行结果跟自己想的一样,可以选择跳过这篇文章啦,

或者如果你有兴趣看看俺俩的理解有没有区别,可以跳到后面的 「画图讲解的部分」

需要具备的前置知识

  • promise的使用经验
  • 浏览器端的eventloop

不过如果是对 ES7 的 async 不太熟悉,是没关系的哈,因为这篇文章会详解 async。

那么如果不具备这些知识呢,推荐几篇我觉得讲得比较清楚的文章

主要内容

第1部分:对于async await的理解

我推荐的那篇文章,对 async/await 讲得更详细。不过我希望自己能更加精炼的帮你理解它们

这部分,主要会讲解 3 点内容

  • 1.async 做一件什么事情?
  • 2.await 在等什么?
  • 3.await 等到之后,做了一件什么事情?
  • 4.补充: async/await 比 promise有哪些优势?(回头补充)
1.async 做一件什么事情?

一句话概括: 带 async 关键字的函数,它使得你的函数的返回值必定是 promise 对象

也就是

如果async关键字函数返回的不是promise,会自动用Promise.resolve()包装

如果async关键字函数显式地返回promise,那就以你返回的promise为准

这是一个简单的例子,可以看到 async 关键字函数和普通函数的返回值的区别

async function fn1(){
    return 123
}

function fn2(){
    return 123
}

console.log(fn1())
console.log(fn2())
Promise {<resolved>: 123}

123

所以你看,async 函数也没啥了不起的,以后看到带有 async 关键字的函数也不用慌张,你就想它无非就是把return值包装了一下,其他就跟普通函数一样。

关于async关键字还有那些要注意的?

  • 在语义上要理解,async表示函数内部有异步操作
  • 另外注意,一般 await 关键字要在 async 关键字函数的内部,await 写在外面会报错。
2.await 在等什么?

一句话概括: await等的是右侧「表达式」的结果

也就是说,

右侧如果是函数,那么函数的return值就是「表达式的结果」

右侧如果是一个 'hello' 或者什么值,那表达式的结果就是 'hello'

async function async1() {
    console.log( 'async1 start' )
    await async2()
    console.log( 'async1 end' )
}
async function async2() {
    console.log( 'async2' )
}
async1()
console.log( 'script start' )

这里注意一点,可能大家都知道await会让出线程,阻塞后面的代码,那么上面例子中, 'async2' 和 'script start' 谁先打印呢?

是从左向右执行,一旦碰到await直接跳出, 阻塞async2()的执行?

还是从右向左,先执行async2后,发现有await关键字,于是让出线程,阻塞代码呢?

实践的结论是,从右向左的。先打印async2,后打印的script start

之所以提一嘴,是因为我经常看到这样的说法,「一旦遇到await就立刻让出线程,阻塞后面的代码」

这样的说法,会让我误以为,await后面那个函数, async2()也直接被阻塞呢。

3.await 等到之后,做了一件什么事情?

那么右侧表达式的结果,就是await要等的东西。

等到之后,对于await来说,分2个情况

  • 不是promise对象
  • 是promise对象

如果不是 promise , await会阻塞后面的代码,先执行async外面的同步代码,同步代码执行完,再回到async内部,把这个非promise的东西,作为 await表达式的结果

如果它等到的是一个 promise 对象,await 也会暂停async后面的代码,先执行async外面的同步代码,等着 Promise 对象 fulfilled,然后把 resolve 的参数作为 await 表达式的运算结果。

第2部分:画图一步步看清宏任务、微任务的执行过程

我们以开篇的经典面试题为例,分析这个例子中的宏任务和微任务。

        async function async1() {
            console.log( 'async1 start' )
            await async2()
            console.log( 'async1 end' )
        }
        async function async2() {
            console.log( 'async2' )
        }
        console.log( 'script start' )
        setTimeout( function () {
            console.log( 'setTimeout' )
        }, 0 )
        async1();
        new Promise( function ( resolve ) {
            console.log( 'promise1' )
            resolve();
        } ).then( function () {
            console.log( 'promise2' )
        } )
        console.log( 'script end' )

先分享一个我个人理解的宏任务和微任务的慨念,在我脑海中宏任务和为微任务如图所示

clipboard.png

也就是「宏任务」、「微任务」都是队列。

一段代码执行时,会先执行宏任务中的同步代码,

  • 如果执行中遇到setTimeout之类宏任务,那么就把这个setTimeout内部的函数推入「宏任务的队列」中,下一轮宏任务执行时调用。
  • 如果执行中遇到promise.then()之类的微任务,就会推入到「当前宏任务的微任务队列」中,在本轮宏任务的同步代码执行都完成后,依次执行所有的微任务1、2、3

下面就以面试题为例子,分析这段代码的执行顺序.

每次宏任务和微任务发生变化,我都会画一个图来表示他们的变化。

直接打印同步代码 console.log('script start')
首先是2个函数声明,虽然有async关键字,但不是调用我们就不看。然后首先是打印同步代码 console.log('script start')

clipboard.png

将setTimeout放入宏任务队列
默认<script></script>所包裹的代码,其实可以理解为是第一个宏任务,所以这里是宏任务2

clipboard.png

调用async1,打印 同步代码 console.log( 'async1 start' )
我们说过看到带有async关键字的函数,不用害怕,它的仅仅是把return值包装成了promise,其他并没有什么不同的地方。所以就很普通的打印 console.log( 'async1 start' )

clipboard.png

分析一下 await async2()
前文提过await,1.它先计算出右侧的结果,2.然后看到await后,中断async函数

- 先得到await右侧表达式的结果。执行async2(),打印同步代码console.log('async2'), 并且return Promise.resolve(undefined)
- await后,中断async函数,先执行async外的同步代码

目前就直接打印 console.log('async2')

clipboard.png

被阻塞后,要执行async之外的代码

执行new Promise(),Promise构造函数是直接调用的同步代码,所以 console.log( 'promise1' )

clipboard.png

代码运行到promise.then()
代码运行到promise.then(),发现这个是微任务,所以暂时不打印,只是推入当前宏任务的微任务队列中。

注意:这里只是把promise2推入微任务队列,并没有执行。微任务会在当前宏任务的同步代码执行完毕,才会依次执行

clipboard.png

打印同步代码 console.log( 'script end' )
没什么好说的。执行完这个同步代码后,「async外的代码」终于走了一遍

下面该回到 await 表达式那里,执行await Promise.resolve(undefined)了

clipboard.png

回到async内部,执行await Promise.resolve(undefined)

这部分可能不太好理解,我尽量表达我的想法。

对于 await Promise.resolve(undefined) 如何理解呢?

https://developer.mozilla.org...

根据 MDN 原话我们知道

如果一个 Promise 被传递给一个 await 操作符,await 将等待 Promise 正常处理完成并返回其处理结果。

在我们这个例子中,就是Promise.resolve(undefined)正常处理完成,并返回其处理结果。那么await async2()就算是执行结束了。

目前这个promise的状态是fulfilled,等其处理结果返回就可以执行await下面的代码了。

那何时能拿到处理结果呢?

回忆平时我们用promise,调用resolve后,何时能拿到处理结果?是不是需要在then的第一个参数里,才能拿到结果。

(调用resolve时,会把then的参数推入微任务队列,等主线程空闲时,再调用它)

所以这里的 await Promise.resolve() 就类似于

Promise.resolve(undefined).then((undefined) => {

})

把then的第一个回调参数 (undefined) => {} 推入微任务队列。

then执行完,才是await async2()执行结束。

await async2()执行结束,才能继续执行后面的代码

如图

clipboard.png

此时当前宏任务1都执行完了,要处理微任务队列里的代码。

微任务队列,先进先出的原则,

  • 执行微任务1,打印promise2
  • 执行微任务2,没什么内容..

但是微任务2执行后,await async2()语句结束,后面的代码不再被阻塞,所以打印

console.log( 'async1 end' )

宏任务1执行完成后,执行宏任务2

宏任务2的执行比较简单,就是打印

console.log('setTimeout')

补充在不同浏览器上的测试结果

谷歌浏览器,目前是版本是「版本 71.0.3578.80(正式版本) (64 位)」 Mac操作系统

clipboard.png

Safari浏览器的测试结果

clipboard.png

火狐浏览器的测试结果

clipboard.png

如果不理解可以留言,有错误的话也欢迎指正。

关于执行顺序

评论区有指出

  • Chrome72 dev版本的执行顺序是Promise2后打印,
  • 或者是babel编译过后的代码是promise2后打印。

我自己也实践了一下babel编译后的代码执行顺序的确是promise2后打印的..

原因是ESMA最新规范的有修改,然后这一点的详情,说实话我目前也不是很清楚,评论区有给出资料,可供参考讨论。

https://github.com/rhinel/blo...

查看原文

ocleo1 收藏了文章 · 2018-12-09

AST抽象语法树——最基础的javascript重点知识,99%的人根本不了解

抽象语法树(AST),是一个非常基础而重要的知识点,但国内的文档却几乎一片空白。

本文将带大家从底层了解AST,并且通过发布一个小型前端工具,来带大家了解AST的强大功能

Javascript就像一台精妙运作的机器,我们可以用它来完成一切天马行空的构思。

我们对javascript生态了如指掌,却常忽视javascript本身。这台机器,究竟是哪些零部件在支持着它运行?

AST在日常业务中也许很难涉及到,但当你不止于想做一个工程师,而想做工程师的工程师,写出vue、react之类的大型框架,或类似webpack、vue-cli前端自动化的工具,或者有批量修改源码的工程需求,那你必须懂得AST。AST的能力十分强大,且能帮你真正吃透javascript的语言精髓。

事实上,在javascript世界中,你可以认为抽象语法树(AST)是最底层。 再往下,就是关于转换和编译的“黑魔法”领域了。

人生第一次拆解Javascript

小时候,当我们拿到一个螺丝刀和一台机器,人生中最令人怀念的梦幻时刻便开始了:

我们把机器,拆成一个一个小零件,一个个齿轮与螺钉,用巧妙的机械原理衔接在一起...

当我们把它重新照不同的方式组装起来,这时,机器重新又跑动了起来——世界在你眼中如获新生。

image

通过抽象语法树解析,我们可以像童年时拆解玩具一样,透视Javascript这台机器的运转,并且重新按着你的意愿来组装。

现在,我们拆解一个简单的add函数

function add(a, b) {
    return a + b
}

首先,我们拿到的这个语法块,是一个FunctionDeclaration(函数定义)对象。

用力拆开,它成了三块:

  • 一个id,就是它的名字,即add
  • 两个params,就是它的参数,即[a, b]
  • 一块body,也就是大括号内的一堆东西

add没办法继续拆下去了,它是一个最基础Identifier(标志)对象,用来作为函数的唯一标志,就像人的姓名一样。

{
    name: 'add'
    type: 'identifier'
    ...
}

params继续拆下去,其实是两个Identifier组成的数组。之后也没办法拆下去了。

[
    {
        name: 'a'
        type: 'identifier'
        ...
    },
    {
        name: 'b'
        type: 'identifier'
        ...
    }
]

接下来,我们继续拆开body
我们发现,body其实是一个BlockStatement(块状域)对象,用来表示是{return a + b}

打开Blockstatement,里面藏着一个ReturnStatement(Return域)对象,用来表示return a + b

继续打开ReturnStatement,里面是一个BinaryExpression(二项式)对象,用来表示a + b

继续打开BinaryExpression,它成了三部分,leftoperatorright

  • operator+
  • left 里面装的,是Identifier对象 a
  • right 里面装的,是Identifer对象 b

就这样,我们把一个简单的add函数拆解完毕,用图表示就是

image

看!抽象语法树(Abstract Syntax Tree),的确是一种标准的树结构。

那么,上面我们提到的Identifier、Blockstatement、ReturnStatement、BinaryExpression, 这一个个小部件的说明书去哪查?

请查看 AST对象文档

送给你的AST螺丝刀:recast

输入命令:

npm i recast -S

你即可获得一把操纵语法树的螺丝刀

接下来,你可以在任意js文件下操纵这把螺丝刀,我们新建一个parse.js示意:

parse.js

// 给你一把"螺丝刀"——recast
const recast = require("recast");

// 你的"机器"——一段代码
// 我们使用了很奇怪格式的代码,想测试是否能维持代码结构
const code =
  `
  function add(a, b) {
    return a +
      // 有什么奇怪的东西混进来了
      b
  }
  `
// 用螺丝刀解析机器
const ast = recast.parse(code);

// ast可以处理很巨大的代码文件
// 但我们现在只需要代码块的第一个body,即add函数
const add  = ast.program.body[0]

console.log(add)

输入node parse.js你可以查看到add函数的结构,与之前所述一致,通过AST对象文档可查到它的具体属性:

FunctionDeclaration{
    type: 'FunctionDeclaration',
    id: ...
    params: ...
    body: ...
}

你也可以继续使用console.log透视它的更内层,如:

console.log(add.params[0])
console.log(add.body.body[0].argument.left)

recast.types.builders 制作模具

一个机器,你只会拆开重装,不算本事。

拆开了,还能改装,才算上得了台面。

recast.types.builders里面提供了不少“模具”,让你可以轻松地拼接成新的机器。

最简单的例子,我们想把之前的function add(a, b){...}声明,改成匿名函数式声明const add = function(a ,b){...}

如何改装?

第一步,我们创建一个VariableDeclaration变量声明对象,声明头为const, 内容为一个即将创建的VariableDeclarator对象。

第二步,创建一个VariableDeclarator,放置add.id在左边, 右边是将创建的FunctionDeclaration对象

第三步,我们创建一个FunctionDeclaration,如前所述的三个组件,id params body中,因为是匿名函数id设为空,params使用add.params,body使用add.body。

这样,就创建好了const add = function(){}的AST对象。

在之前的parse.js代码之后,加入以下代码

// 引入变量声明,变量符号,函数声明三种“模具”
const {variableDeclaration, variableDeclarator, functionExpression} = recast.types.builders

// 将准备好的组件置入模具,并组装回原来的ast对象。
ast.program.body[0] = variableDeclaration("const", [
  variableDeclarator(add.id, functionExpression(
    null, // Anonymize the function expression.
    add.params,
    add.body
  ))
]);

//将AST对象重新转回可以阅读的代码
const output = recast.print(ast).code;

console.log(output)

可以看到,我们打印出了

const add = function(a, b) {
  return a +
    // 有什么奇怪的东西混进来了
    b
};

最后一行

const output = recast.print(ast).code;

其实是recast.parse的逆向过程,具体公式为

recast.print(recast.parse(source)).code === source

打印出来还保留着“原装”的函数内容,连注释都没有变。

我们其实也可以打印出美化格式的代码段:

const output = recast.prettyPrint(ast, { tabWidth: 2 }).code

输出为

const add = function(a, b) {
  return a + b;
};
现在,你是不是已经产生了“我可以通过AST树生成任何js代码”的幻觉?

我郑重告诉你,这不是幻觉。

实战进阶:命令行修改js文件

除了parse/print/builder以外,Recast的三项主要功能:

  • run: 通过命令行读取js文件,并转化成ast以供处理。
  • tnt: 通过assert()和check(),可以验证ast对象的类型。
  • visit: 遍历ast树,获取有效的AST对象并进行更改。

我们通过一个系列小务来学习全部的recast工具库:

创建一个用来示例文件,假设是demo.js

demo.js

function add(a, b) {
  return a + b
}

function sub(a, b) {
  return a - b
}

function commonDivision(a, b) {
  while (b !== 0) {
    if (a > b) {
      a = sub(a, b)
    } else {
      b = sub(b, a)
    }
  }
  return a
}

recast.run —— 命令行文件读取

新建一个名为read.js的文件,写入
read.js

recast.run( function(ast, printSource){
    printSource(ast)
})

命令行输入

node read demo.js

我们查以看到js文件内容打印在了控制台上。

我们可以知道,node read可以读取demo.js文件,并将demo.js内容转化为ast对象。

同时它还提供了一个printSource函数,随时可以将ast的内容转换回源码,以方便调试。

recast.visit —— AST节点遍历

read.js

#!/usr/bin/env node
const recast  = require('recast')

recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function({node}) {
        console.log(node)
        return false
      }
    });
});

recast.visit将AST对象内的节点进行逐个遍历。

注意

  • 你想操作函数声明,就使用visitFunctionDelaration遍历,想操作赋值表达式,就使用visitExpressionStatement。 只要在 AST对象文档中定义的对象,在前面加visit,即可遍历。
  • 通过node可以取到AST对象
  • 每个遍历函数后必须加上return false,或者选择以下写法,否则报错:
#!/usr/bin/env node
const recast  = require('recast')

recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function(path) {
        const node = path.node
        printSource(node)
        this.traverse(path)
      }
    })
});

调试时,如果你想输出AST对象,可以console.log(node)

如果你想输出AST对象对应的源码,可以printSource(node)

命令行输入`
node read demo.js`进行测试。

#!/usr/bin/env node 在所有使用recast.run()的文件顶部都需要加入这一行,它的意义我们最后再讨论。

TNT —— 判断AST对象类型

TNT,即recast.types.namedTypes,就像它的名字一样火爆,它用来判断AST对象是否为指定的类型。

TNT.Node.assert(),就像在机器里埋好的炸药,当机器不能完好运转时(类型不匹配),就炸毁机器(报错退出)

TNT.Node.check(),则可以判断类型是否一致,并输出False和True

上述Node可以替换成任意AST对象,例如TNT.ExpressionStatement.check(),TNT.FunctionDeclaration.assert()

read.js

#!/usr/bin/env node
const recast = require("recast");
const TNT = recast.types.namedTypes

recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function(path) {
        const node = path.value
        // 判断是否为ExpressionStatement,正确则输出一行字。
        if(TNT.ExpressionStatement.check(node)){
          console.log('这是一个ExpressionStatement')
        }
        this.traverse(path);
      }
    });
});

read.js

#!/usr/bin/env node
const recast = require("recast");
const TNT = recast.types.namedTypes

recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function(path) {
        const node = path.node
        // 判断是否为ExpressionStatement,正确不输出,错误则全局报错
        TNT.ExpressionStatement.assert(node)
        this.traverse(path);
      }
    });
});

命令行输入`
node read demo.js`进行测试。

实战:用AST修改源码,导出全部方法

exportific.js

现在,我们想让这个文件中的函数改写成能够全部导出的形式,例如

function add (a, b) {
    return a + b
}

想改变为

exports.add = (a, b) => {
  return a + b
}

除了使用fs.read读取文件、正则匹配替换文本、fs.write写入文件这种笨拙的方式外,我们可以用AST优雅地解决问题

查询AST对象文档

首先,我们先用builders凭空实现一个键头函数

exportific.js

#!/usr/bin/env node
const recast = require("recast");
const {
  identifier:id,
  expressionStatement,
  memberExpression,
  assignmentExpression,
  arrowFunctionExpression,
  blockStatement
} = recast.types.builders

recast.run(function(ast, printSource) {
  // 一个块级域 {}
  console.log('\n\nstep1:')
  printSource(blockStatement([]))

  // 一个键头函数 ()=>{}
  console.log('\n\nstep2:')
  printSource(arrowFunctionExpression([],blockStatement([])))

  // add赋值为键头函数  add = ()=>{}
  console.log('\n\nstep3:')
  printSource(assignmentExpression('=',id('add'),arrowFunctionExpression([],blockStatement([]))))

  // exports.add赋值为键头函数  exports.add = ()=>{}
  console.log('\n\nstep4:')
  printSource(expressionStatement(assignmentExpression('=',memberExpression(id('exports'),id('add')),
    arrowFunctionExpression([],blockStatement([])))))
});

上面写了我们一步一步推断出exports.add = ()=>{}的过程,从而得到具体的AST结构体。

使用node exportific demo.js运行可查看结果。

接下来,只需要在获得的最终的表达式中,把id('add')替换成遍历得到的函数名,把参数替换成遍历得到的函数参数,把blockStatement([])替换为遍历得到的函数块级作用域,就成功地改写了所有函数!

另外,我们需要注意,在commonDivision函数内,引用了sub函数,应改写成exports.sub

exportific.js

#!/usr/bin/env node
const recast = require("recast");
const {
  identifier: id,
  expressionStatement,
  memberExpression,
  assignmentExpression,
  arrowFunctionExpression
} = recast.types.builders

recast.run(function (ast, printSource) {
  // 用来保存遍历到的全部函数名
  let funcIds = []
  recast.types.visit(ast, {
    // 遍历所有的函数定义
    visitFunctionDeclaration(path) {
      //获取遍历到的函数名、参数、块级域
      const node = path.node
      const funcName = node.id
      const params = node.params
      const body = node.body

      // 保存函数名
      funcIds.push(funcName.name)
      // 这是上一步推导出来的ast结构体
      const rep = expressionStatement(assignmentExpression('=', memberExpression(id('exports'), funcName),
        arrowFunctionExpression(params, body)))
      // 将原来函数的ast结构体,替换成推导ast结构体
      path.replace(rep)
      // 停止遍历
      return false
    }
  })


  recast.types.visit(ast, {
    // 遍历所有的函数调用
    visitCallExpression(path){
      const node = path.node;
      // 如果函数调用出现在函数定义中,则修改ast结构
      if (funcIds.includes(node.callee.name)) {
        node.callee = memberExpression(id('exports'), node.callee)
      }
      // 停止遍历
      return false
    }
  })
  // 打印修改后的ast源码
  printSource(ast)
})

一步到位,发一个最简单的exportific前端工具

上面讲了那么多,仍然只体现在理论阶段。

但通过简单的改写,就能通过recast制作成一个名为exportific的源码编辑工具。

以下代码添加作了两个小改动

  1. 添加说明书--help,以及添加了--rewrite模式,可以直接覆盖文件或默认为导出*.export.js文件。
  2. 将之前代码最后的 printSource(ast)替换成 writeASTFile(ast,filename,rewriteMode)

exportific.js

#!/usr/bin/env node
const recast = require("recast");
const {
  identifier: id,
  expressionStatement,
  memberExpression,
  assignmentExpression,
  arrowFunctionExpression
} = recast.types.builders

const fs = require('fs')
const path = require('path')
// 截取参数
const options = process.argv.slice(2)

//如果没有参数,或提供了-h 或--help选项,则打印帮助
if(options.length===0 || options.includes('-h') || options.includes('--help')){
  console.log(`
    采用commonjs规则,将.js文件内所有函数修改为导出形式。

    选项: -r  或 --rewrite 可直接覆盖原有文件
    `)
  process.exit(0)
}

// 只要有-r 或--rewrite参数,则rewriteMode为true
let rewriteMode = options.includes('-r') || options.includes('--rewrite')

// 获取文件名
const clearFileArg = options.filter((item)=>{
  return !['-r','--rewrite','-h','--help'].includes(item)
})

// 只处理一个文件
let filename = clearFileArg[0]

const writeASTFile = function(ast, filename, rewriteMode){
  const newCode = recast.print(ast).code
  if(!rewriteMode){
    // 非覆盖模式下,将新文件写入*.export.js下
    filename = filename.split('.').slice(0,-1).concat(['export','js']).join('.')
  }
  // 将新代码写入文件
  fs.writeFileSync(path.join(process.cwd(),filename),newCode)
}


recast.run(function (ast, printSource) {
  let funcIds = []
  recast.types.visit(ast, {
    visitFunctionDeclaration(path) {
      //获取遍历到的函数名、参数、块级域
      const node = path.node
      const funcName = node.id
      const params = node.params
      const body = node.body

      funcIds.push(funcName.name)
      const rep = expressionStatement(assignmentExpression('=', memberExpression(id('exports'), funcName),
        arrowFunctionExpression(params, body)))
      path.replace(rep)
      return false
    }
  })


  recast.types.visit(ast, {
    visitCallExpression(path){
      const node = path.node;
      if (funcIds.includes(node.callee.name)) {
        node.callee = memberExpression(id('exports'), node.callee)
      }
      return false
    }
  })

  writeASTFile(ast,filename,rewriteMode)
})

现在尝试一下

node exportific demo.js

已经可以在当前目录下找到源码变更后的demo.export.js文件了。

npm发包

编辑一下package.json文件

{
  "name": "exportific",
  "version": "0.0.1",
  "description": "改写源码中的函数为可exports.XXX形式",
  "main": "exportific.js",
  "bin": {
    "exportific": "./exportific.js"
  },
  "keywords": [],
  "author": "wanthering",
  "license": "ISC",
  "dependencies": {
    "recast": "^0.15.3"
  }
}

注意bin选项,它的意思是将全局命令exportific指向当前目录下的exportific.js

这时,输入npm link 就在本地生成了一个exportific命令。

之后,只要哪个js文件想导出来使用,就exportific XXX.js一下。

这是在本地的玩法,想和大家一起分享这个前端小工具,只需要发布npm包就行了。

同时,一定要注意exportific.js文件头有

#!/usr/bin/env node

否则在使用时将报错。

接下来,正式发布npm包!

如果你已经有了npm 帐号,请使用npm login登录

如果你还没有npm帐号 https://www.npmjs.com/signup 非常简单就可以注册npm

然后,输入
npm publish

没有任何繁琐步骤,丝毫审核都没有,你就发布了一个实用的前端小工具exportific 。任何人都可以通过

npm i exportific -g

全局安装这一个插件。

提示:==在试验教程时,请不要和我的包重名,修改一下发包名称。==

结语

我们对javascript再熟悉不过,但透过AST的视角,最普通的js语句,却焕发出精心动魄的美感。你可以通过它批量构建任何javascript代码!

童年时,这个世界充满了新奇的玩具,再普通的东西在你眼中都如同至宝。如今,计算机语言就是你手中的大玩具,一段段AST对象的拆分组装,构建出我们所生活的网络世界。

所以不得不说软件工程师是一个幸福的工作,你心中住的仍然是那个午后的少年,永远有无数新奇等你发现,永远有无数梦想等你构建。

github地址:https://github.com/wanthering...

image

查看原文

认证与成就

  • 获得 0 次点赞
  • 获得 1 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 1 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-01-01
个人主页被 123 人浏览