啃先生

啃先生 查看完整档案

深圳编辑暨南大学  |  信息管理与信息系统 编辑国内券商  |  前端工程师 编辑填写个人主网站
编辑

前腾讯前端开发工程师,后来有一年时间经历参与创业,目前在券商,人在深圳。个人作品:

  • 小程序:天天随手记账
  • 公众号:听啃先生说H5

个人动态

啃先生 收藏了文章 · 2019-06-13

JavaScript:十大排序的算法思路和代码实现

本文内容包括:(双向)冒泡排序、选择排序、插入排序、快速排序(填坑和交换)、归并排序、桶排序、基数排序、计数排序(优化)、堆排序、希尔排序。大家可以在这里测试代码。更多 leetcodeJavaScript 解法也可以在我的算法仓库中找到,欢迎查看~

另外附上十大排序的 C++版本,因为写惯了JavaScript,所以这个 C++版本写得有些丑,请不要介意呀。

如果你觉得有帮助的话,就点个 star 鼓励鼓励我吧,蟹蟹😊

先推荐一个数据结构和算法动态可视化工具,可以查看各种算法的动画演示。下面开始正文。

冒泡排序

通过相邻元素的比较和交换,使得每一趟循环都能找到未有序数组的最大值或最小值。

最好:O(n),只需要冒泡一次数组就有序了。
最坏:O(n²)
平均:O(n²)

单向冒泡

function bubbleSort(nums) {
  for(let i=0, len=nums.length; i<len-1; i++) {
    // 如果一轮比较中没有需要交换的数据,则说明数组已经有序。主要是对[5,1,2,3,4]之类的数组进行优化
    let mark = true;
    for(let j=0; j<len-i-1; j++) {
      if(nums[j] > nums[j+1]) {
        [nums[j], nums[j+1]] = [nums[j+1], nums[j]];
        mark = false;
      }
    }
    if(mark)  return;
  }
}

双向冒泡

普通的冒泡排序在一趟循环中只能找出一个最大值或最小值,双向冒泡则是多一轮循环既找出最大值也找出最小值。

function bubbleSort_twoWays(nums) {
  let low = 0;
  let high = nums.length - 1;
  while(low < high) {
    let mark = true;
    // 找到最大值放到右边
    for(let i=low; i<high; i++) {
      if(nums[i] > nums[i+1]) {
        [nums[i], nums[i+1]] = [nums[i+1], nums[i]];
        mark = false;
      }
    }
    high--;
    // 找到最小值放到左边
    for(let j=high; j>low; j--) {
      if(nums[j] < nums[j-1]) {
        [nums[j], nums[j-1]] = [nums[j-1], nums[j]];
        mark = false;
      }
    }
    low++;
    if(mark)  return;
  }
}

选择排序

和冒泡排序相似,区别在于选择排序是将每一个元素和它后面的元素进行比较和交换。

最好:O(n²)
最坏:O(n²)
平均:O(n²)

function selectSort(nums) {
  for(let i=0, len=nums.length; i<len; i++) {
    for(let j=i+1; j<len; j++) {
      if(nums[i] > nums[j]) {
        [nums[i], nums[j]] = [nums[j], nums[i]];
      }
    }
  }
}

插入排序

以第一个元素作为有序数组,其后的元素通过在这个已有序的数组中找到合适的位置并插入。

最好:O(n),原数组已经是升序的。
最坏:O(n²)
平均:O(n²)

function insertSort(nums) {
  for(let i=1, len=nums.length; i<len; i++) {
    let temp = nums[i];
    let j = i;
    while(j >= 0 && temp < nums[j-1]) {
      nums[j] = nums[j-1];
      j--;
    }
    nums[j] = temp;
  }
}

快速排序

选择一个元素作为基数(通常是第一个元素),把比基数小的元素放到它左边,比基数大的元素放到它右边(相当于二分),再不断递归基数左右两边的序列。

最好:O(n * logn),所有数均匀分布在基数的两边,此时的递归就是不断地二分左右序列。
最坏:O(n²),所有数都分布在基数的一边,此时划分左右序列就相当于是插入排序。
平均:O(n * logn)

参考学习链接:
算法 3:最常用的排序——快速排序
三种快速排序以及快速排序的优化

快速排序之填坑

从右边向中间推进的时候,遇到小于基数的数就赋给左边(一开始是基数的位置),右边保留原先的值等之后被左边的值填上。

function quickSort(nums) {
  // 递归排序基数左右两边的序列
  function recursive(arr, left, right) {
    if(left >= right)  return;
    let index = partition(arr, left, right);
    recursive(arr, left, index - 1);
    recursive(arr, index + 1, right);
    return arr;
  }
  // 将小于基数的数放到基数左边,大于基数的数放到基数右边,并返回基数的位置
  function partition(arr, left, right) {
    // 取第一个数为基数
    let temp = arr[left];
    while(left < right) {
      while(left < right && arr[right] >= temp)  right--;
      arr[left] = arr[right];
      while(left < right && arr[left] < temp)  left++;
      arr[right] = arr[left];
    }
    // 修改基数的位置
    arr[left] = temp;
    return left;
  }
  recursive(nums, 0, nums.length-1);
}

快速排序之交换

从左右两边向中间推进的时候,遇到不符合的数就两边交换值。

function quickSort1(nums) {
  function recursive(arr, left, right) {
    if(left >= right)  return;
    let index = partition(arr, left, right);
    recursive(arr, left, index - 1);
    recursive(arr, index + 1, right);
    return arr;
  }
  function partition(arr, left, right) {
    let temp = arr[left];
    let p = left + 1;
    let q = right;
    while(p <= q) {
      while(p <= q && arr[p] < temp)  p++;
      while(p <= q && arr[q] > temp)  q--;
      if(p <= q) {
        [arr[p], arr[q]] = [arr[q], arr[p]];
        // 交换值后两边各向中间推进一位
        p++;
        q--;
      }
    }
    // 修改基数的位置
    [arr[left], arr[q]] = [arr[q], arr[left]];
    return q;
  }
  recursive(nums, 0, nums.length-1);
}

归并排序

递归将数组分为两个序列,有序合并这两个序列。

最好:O(n * logn)
最坏:O(n * logn)
平均:O(n * logn)

参考学习链接:
图解排序算法(四)之归并排序

function mergeSort(nums) {
  // 有序合并两个数组
  function merge(l1, r1, l2, r2) {
    let arr = [];
    let index = 0;
    let i = l1, j = l2;
    while(i <= r1 && j <= r2) {
      arr[index++] = nums[i] < nums[j] ? nums[i++] : nums[j++];
    }
    while(i <= r1)  arr[index++] = nums[i++];
    while(j <= r2)  arr[index++] = nums[j++];
    // 将有序合并后的数组修改回原数组
    for(let t=0; t<index; t++) {
      nums[l1 + t] = arr[t];
    }
  }
  // 递归将数组分为两个序列
  function recursive(left, right) {
    if(left >= right)  return;
    // 比起(left+right)/2,更推荐下面这种写法,可以避免数溢出
    let mid = parseInt((right - left) / 2) + left;
    recursive(left, mid);
    recursive(mid+1, right);
    merge(left, mid, mid+1, right);
    return nums;
  }
  recursive(0, nums.length-1);
}

桶排序

取 n 个桶,根据数组的最大值和最小值确认每个桶存放的数的区间,将数组元素插入到相应的桶里,最后再合并各个桶。

最好:O(n),每个数都在分布在一个桶里,这样就不用将数插入排序到桶里了(类似于计数排序以空间换时间)。
最坏:O(n²),所有的数都分布在一个桶里。
平均:O(n + k),k表示桶的个数。

参考学习链接:
拜托,面试别再问我桶排序了!!!

function bucketSort(nums) {
  // 桶的个数,只要是正数即可
  let num = 5;
  let max = Math.max(...nums);
  let min = Math.min(...nums);
  // 计算每个桶存放的数值范围,至少为1,
  let range = Math.ceil((max - min) / num) || 1;
  // 创建二维数组,第一维表示第几个桶,第二维表示该桶里存放的数
  let arr = Array.from(Array(num)).map(() => Array().fill(0));
  nums.forEach(val => {
    // 计算元素应该分布在哪个桶
    let index = parseInt((val - min) / range);
    // 防止index越界,例如当[5,1,1,2,0,0]时index会出现5
    index = index >= num ? num - 1 : index;
    let temp = arr[index];
    // 插入排序,将元素有序插入到桶中
    let j = temp.length - 1;
    while(j >= 0 && val < temp[j]) {
      temp[j+1] = temp[j];
      j--;
    }
    temp[j+1] = val;
  })
  // 修改回原数组
  let res = [].concat.apply([], arr);
  nums.forEach((val, i) => {
    nums[i] = res[i];
  })
}

基数排序

使用十个桶 0-9,把每个数从低位到高位根据位数放到相应的桶里,以此循环最大值的位数次。但只能排列正整数,因为遇到负号和小数点无法进行比较

最好:O(n * k),k表示最大值的位数。
最坏:O(n * k)
平均:O(n * k)

参考学习链接:
算法总结系列之五: 基数排序(Radix Sort)

function radixSort(nums) {
  // 计算位数
  function getDigits(n) {
    let sum = 0;
    while(n) {
      sum++;
      n = parseInt(n / 10);
    }
    return sum;
  }
  // 第一维表示位数即0-9,第二维表示里面存放的值
  let arr = Array.from(Array(10)).map(() => Array());
  let max = Math.max(...nums);
  let maxDigits = getDigits(max);
  for(let i=0, len=nums.length; i<len; i++) {
    // 用0把每一个数都填充成相同的位数
    nums[i] = (nums[i] + '').padStart(maxDigits, 0);
    // 先根据个位数把每一个数放到相应的桶里
    let temp = nums[i][nums[i].length-1];
    arr[temp].push(nums[i]);
  }
  // 循环判断每个位数
  for(let i=maxDigits-2; i>=0; i--) {
    // 循环每一个桶
    for(let j=0; j<=9; j++) {
      let temp = arr[j]
      let len = temp.length;
      // 根据当前的位数i把桶里的数放到相应的桶里
      while(len--) {
        let str = temp[0];
        temp.shift();
        arr[str[i]].push(str);
      }
    }
  }
  // 修改回原数组
  let res = [].concat.apply([], arr);
  nums.forEach((val, index) => {
    nums[index] = +res[index];
  }) 
}

计数排序

以数组元素值为键,出现次数为值存进一个临时数组,最后再遍历这个临时数组还原回原数组。因为 JavaScript 的数组下标是以字符串形式存储的,所以计数排序可以用来排列负数,但不可以排列小数

最好:O(n + k),k是最大值和最小值的差。
最坏:O(n + k)
平均:O(n + k)

function countingSort(nums) {
  let arr = [];
  let max = Math.max(...nums);
  let min = Math.min(...nums);
  // 装桶
  for(let i=0, len=nums.length; i<len; i++) {
    let temp = nums[i];
    arr[temp] = arr[temp] + 1 || 1;
  }
  let index = 0;
  // 还原原数组
  for(let i=min; i<=max; i++) {
    while(arr[i] > 0) {
      nums[index++] = i;
      arr[i]--;
    }
  }
}

计数排序优化

把每一个数组元素都加上 min 的相反数,来避免特殊情况下的空间浪费,通过这种优化可以把所开的空间大小从 max+1 降低为 max-min+1maxmin 分别为数组中的最大值和最小值。

比如数组 [103, 102, 101, 100],普通的计数排序需要开一个长度为 104 的数组,而且前面 100 个值都是 undefined,使用该优化方法后可以只开一个长度为 4 的数组。

function countingSort(nums) {
  let arr = [];
  let max = Math.max(...nums);
  let min = Math.min(...nums);
  // 加上最小值的相反数来缩小数组范围
  let add = -min;
  for(let i=0, len=nums.length; i<len; i++) {
    let temp = nums[i];
    temp += add;
    arr[temp] = arr[temp] + 1 || 1;
  }
  let index = 0;
  for(let i=min; i<=max; i++) {
    let temp = arr[i+add];
    while(temp > 0) {
      nums[index++] = i;
      temp--;
    }
  }
}

堆排序

根据数组建立一个堆(类似完全二叉树),每个结点的值都大于左右结点(最大堆,通常用于升序),或小于左右结点(最小堆,通常用于降序)。对于升序排序,先构建最大堆后,交换堆顶元素(表示最大值)和堆底元素,每一次交换都能得到未有序序列的最大值。重新调整最大堆,再交换堆顶元素和堆底元素,重复 n-1 次后就能得到一个升序的数组。

最好:O(n * logn),logn是调整最大堆所花的时间。
最坏:O(n * logn)
平均:O(n * logn)

参考学习链接:
常见排序算法 - 堆排序 (Heap Sort)
图解排序算法(三)之堆排序

function heapSort(nums) {
  // 调整最大堆,使index的值大于左右节点
  function adjustHeap(nums, index, size) {
    // 交换后可能会破坏堆结构,需要循环使得每一个父节点都大于左右结点
    while(true) {
      let max = index;
      let left = index * 2 + 1;   // 左节点
      let right = index * 2 + 2;  // 右节点
      if(left < size && nums[max] < nums[left])  max = left;
      if(right < size && nums[max] < nums[right])  max = right;
      // 如果左右结点大于当前的结点则交换,并再循环一遍判断交换后的左右结点位置是否破坏了堆结构(比左右结点小了)
      if(index !== max) {
        [nums[index], nums[max]] = [nums[max], nums[index]];
        index = max;
      }
      else {
        break;
      }
    }
  }
  // 建立最大堆
  function buildHeap(nums) {
    // 注意这里的头节点是从0开始的,所以最后一个非叶子结点是 parseInt(nums.length/2)-1
    let start = parseInt(nums.length / 2) - 1;
    let size = nums.length;
    // 从最后一个非叶子结点开始调整,直至堆顶。
    for(let i=start; i>=0; i--) {
      adjustHeap(nums, i, size);
    }
  }

  buildHeap(nums);
  // 循环n-1次,每次循环后交换堆顶元素和堆底元素并重新调整堆结构
  for(let i=nums.length-1; i>0; i--) {
    [nums[i], nums[0]] = [nums[0], nums[i]];
    adjustHeap(nums, 0, i);
  }
}

希尔排序

通过某个增量 gap,将整个序列分给若干组,从后往前进行组内成员的比较和交换,随后逐步缩小增量至 1。希尔排序类似于插入排序,只是一开始向前移动的步数从 1 变成了 gap。

最好:O(n * logn),步长不断二分。
最坏:O(n * logn)
平均:O(n * logn)

参考学习链接:
图解排序算法(二)之希尔排序

function shellSort(nums) {
  let len = nums.length;
  // 初始步数
  let gap = parseInt(len / 2);
  // 逐渐缩小步数
  while(gap) {
    // 从第gap个元素开始遍历
    for(let i=gap; i<len; i++) {
      // 逐步其和前面其他的组成员进行比较和交换
      for(let j=i-gap; j>=0; j-=gap) {
        if(nums[j] > nums[j+gap]) {
          [nums[j], nums[j+gap]] = [nums[j+gap], nums[j]];
        }
        else {
          break;
        }
      }
    }
    gap = parseInt(gap / 2);
  }
}

看完后如果大家有什么疑问或发现一些错误,可以在下方留言呀,或者在我的仓库里 提issues,我们一起讨论讨论😊

查看原文

啃先生 赞了文章 · 2019-06-13

JavaScript:十大排序的算法思路和代码实现

本文内容包括:(双向)冒泡排序、选择排序、插入排序、快速排序(填坑和交换)、归并排序、桶排序、基数排序、计数排序(优化)、堆排序、希尔排序。大家可以在这里测试代码。更多 leetcodeJavaScript 解法也可以在我的算法仓库中找到,欢迎查看~

另外附上十大排序的 C++版本,因为写惯了JavaScript,所以这个 C++版本写得有些丑,请不要介意呀。

如果你觉得有帮助的话,就点个 star 鼓励鼓励我吧,蟹蟹😊

先推荐一个数据结构和算法动态可视化工具,可以查看各种算法的动画演示。下面开始正文。

冒泡排序

通过相邻元素的比较和交换,使得每一趟循环都能找到未有序数组的最大值或最小值。

最好:O(n),只需要冒泡一次数组就有序了。
最坏:O(n²)
平均:O(n²)

单向冒泡

function bubbleSort(nums) {
  for(let i=0, len=nums.length; i<len-1; i++) {
    // 如果一轮比较中没有需要交换的数据,则说明数组已经有序。主要是对[5,1,2,3,4]之类的数组进行优化
    let mark = true;
    for(let j=0; j<len-i-1; j++) {
      if(nums[j] > nums[j+1]) {
        [nums[j], nums[j+1]] = [nums[j+1], nums[j]];
        mark = false;
      }
    }
    if(mark)  return;
  }
}

双向冒泡

普通的冒泡排序在一趟循环中只能找出一个最大值或最小值,双向冒泡则是多一轮循环既找出最大值也找出最小值。

function bubbleSort_twoWays(nums) {
  let low = 0;
  let high = nums.length - 1;
  while(low < high) {
    let mark = true;
    // 找到最大值放到右边
    for(let i=low; i<high; i++) {
      if(nums[i] > nums[i+1]) {
        [nums[i], nums[i+1]] = [nums[i+1], nums[i]];
        mark = false;
      }
    }
    high--;
    // 找到最小值放到左边
    for(let j=high; j>low; j--) {
      if(nums[j] < nums[j-1]) {
        [nums[j], nums[j-1]] = [nums[j-1], nums[j]];
        mark = false;
      }
    }
    low++;
    if(mark)  return;
  }
}

选择排序

和冒泡排序相似,区别在于选择排序是将每一个元素和它后面的元素进行比较和交换。

最好:O(n²)
最坏:O(n²)
平均:O(n²)

function selectSort(nums) {
  for(let i=0, len=nums.length; i<len; i++) {
    for(let j=i+1; j<len; j++) {
      if(nums[i] > nums[j]) {
        [nums[i], nums[j]] = [nums[j], nums[i]];
      }
    }
  }
}

插入排序

以第一个元素作为有序数组,其后的元素通过在这个已有序的数组中找到合适的位置并插入。

最好:O(n),原数组已经是升序的。
最坏:O(n²)
平均:O(n²)

function insertSort(nums) {
  for(let i=1, len=nums.length; i<len; i++) {
    let temp = nums[i];
    let j = i;
    while(j >= 0 && temp < nums[j-1]) {
      nums[j] = nums[j-1];
      j--;
    }
    nums[j] = temp;
  }
}

快速排序

选择一个元素作为基数(通常是第一个元素),把比基数小的元素放到它左边,比基数大的元素放到它右边(相当于二分),再不断递归基数左右两边的序列。

最好:O(n * logn),所有数均匀分布在基数的两边,此时的递归就是不断地二分左右序列。
最坏:O(n²),所有数都分布在基数的一边,此时划分左右序列就相当于是插入排序。
平均:O(n * logn)

参考学习链接:
算法 3:最常用的排序——快速排序
三种快速排序以及快速排序的优化

快速排序之填坑

从右边向中间推进的时候,遇到小于基数的数就赋给左边(一开始是基数的位置),右边保留原先的值等之后被左边的值填上。

function quickSort(nums) {
  // 递归排序基数左右两边的序列
  function recursive(arr, left, right) {
    if(left >= right)  return;
    let index = partition(arr, left, right);
    recursive(arr, left, index - 1);
    recursive(arr, index + 1, right);
    return arr;
  }
  // 将小于基数的数放到基数左边,大于基数的数放到基数右边,并返回基数的位置
  function partition(arr, left, right) {
    // 取第一个数为基数
    let temp = arr[left];
    while(left < right) {
      while(left < right && arr[right] >= temp)  right--;
      arr[left] = arr[right];
      while(left < right && arr[left] < temp)  left++;
      arr[right] = arr[left];
    }
    // 修改基数的位置
    arr[left] = temp;
    return left;
  }
  recursive(nums, 0, nums.length-1);
}

快速排序之交换

从左右两边向中间推进的时候,遇到不符合的数就两边交换值。

function quickSort1(nums) {
  function recursive(arr, left, right) {
    if(left >= right)  return;
    let index = partition(arr, left, right);
    recursive(arr, left, index - 1);
    recursive(arr, index + 1, right);
    return arr;
  }
  function partition(arr, left, right) {
    let temp = arr[left];
    let p = left + 1;
    let q = right;
    while(p <= q) {
      while(p <= q && arr[p] < temp)  p++;
      while(p <= q && arr[q] > temp)  q--;
      if(p <= q) {
        [arr[p], arr[q]] = [arr[q], arr[p]];
        // 交换值后两边各向中间推进一位
        p++;
        q--;
      }
    }
    // 修改基数的位置
    [arr[left], arr[q]] = [arr[q], arr[left]];
    return q;
  }
  recursive(nums, 0, nums.length-1);
}

归并排序

递归将数组分为两个序列,有序合并这两个序列。

最好:O(n * logn)
最坏:O(n * logn)
平均:O(n * logn)

参考学习链接:
图解排序算法(四)之归并排序

function mergeSort(nums) {
  // 有序合并两个数组
  function merge(l1, r1, l2, r2) {
    let arr = [];
    let index = 0;
    let i = l1, j = l2;
    while(i <= r1 && j <= r2) {
      arr[index++] = nums[i] < nums[j] ? nums[i++] : nums[j++];
    }
    while(i <= r1)  arr[index++] = nums[i++];
    while(j <= r2)  arr[index++] = nums[j++];
    // 将有序合并后的数组修改回原数组
    for(let t=0; t<index; t++) {
      nums[l1 + t] = arr[t];
    }
  }
  // 递归将数组分为两个序列
  function recursive(left, right) {
    if(left >= right)  return;
    // 比起(left+right)/2,更推荐下面这种写法,可以避免数溢出
    let mid = parseInt((right - left) / 2) + left;
    recursive(left, mid);
    recursive(mid+1, right);
    merge(left, mid, mid+1, right);
    return nums;
  }
  recursive(0, nums.length-1);
}

桶排序

取 n 个桶,根据数组的最大值和最小值确认每个桶存放的数的区间,将数组元素插入到相应的桶里,最后再合并各个桶。

最好:O(n),每个数都在分布在一个桶里,这样就不用将数插入排序到桶里了(类似于计数排序以空间换时间)。
最坏:O(n²),所有的数都分布在一个桶里。
平均:O(n + k),k表示桶的个数。

参考学习链接:
拜托,面试别再问我桶排序了!!!

function bucketSort(nums) {
  // 桶的个数,只要是正数即可
  let num = 5;
  let max = Math.max(...nums);
  let min = Math.min(...nums);
  // 计算每个桶存放的数值范围,至少为1,
  let range = Math.ceil((max - min) / num) || 1;
  // 创建二维数组,第一维表示第几个桶,第二维表示该桶里存放的数
  let arr = Array.from(Array(num)).map(() => Array().fill(0));
  nums.forEach(val => {
    // 计算元素应该分布在哪个桶
    let index = parseInt((val - min) / range);
    // 防止index越界,例如当[5,1,1,2,0,0]时index会出现5
    index = index >= num ? num - 1 : index;
    let temp = arr[index];
    // 插入排序,将元素有序插入到桶中
    let j = temp.length - 1;
    while(j >= 0 && val < temp[j]) {
      temp[j+1] = temp[j];
      j--;
    }
    temp[j+1] = val;
  })
  // 修改回原数组
  let res = [].concat.apply([], arr);
  nums.forEach((val, i) => {
    nums[i] = res[i];
  })
}

基数排序

使用十个桶 0-9,把每个数从低位到高位根据位数放到相应的桶里,以此循环最大值的位数次。但只能排列正整数,因为遇到负号和小数点无法进行比较

最好:O(n * k),k表示最大值的位数。
最坏:O(n * k)
平均:O(n * k)

参考学习链接:
算法总结系列之五: 基数排序(Radix Sort)

function radixSort(nums) {
  // 计算位数
  function getDigits(n) {
    let sum = 0;
    while(n) {
      sum++;
      n = parseInt(n / 10);
    }
    return sum;
  }
  // 第一维表示位数即0-9,第二维表示里面存放的值
  let arr = Array.from(Array(10)).map(() => Array());
  let max = Math.max(...nums);
  let maxDigits = getDigits(max);
  for(let i=0, len=nums.length; i<len; i++) {
    // 用0把每一个数都填充成相同的位数
    nums[i] = (nums[i] + '').padStart(maxDigits, 0);
    // 先根据个位数把每一个数放到相应的桶里
    let temp = nums[i][nums[i].length-1];
    arr[temp].push(nums[i]);
  }
  // 循环判断每个位数
  for(let i=maxDigits-2; i>=0; i--) {
    // 循环每一个桶
    for(let j=0; j<=9; j++) {
      let temp = arr[j]
      let len = temp.length;
      // 根据当前的位数i把桶里的数放到相应的桶里
      while(len--) {
        let str = temp[0];
        temp.shift();
        arr[str[i]].push(str);
      }
    }
  }
  // 修改回原数组
  let res = [].concat.apply([], arr);
  nums.forEach((val, index) => {
    nums[index] = +res[index];
  }) 
}

计数排序

以数组元素值为键,出现次数为值存进一个临时数组,最后再遍历这个临时数组还原回原数组。因为 JavaScript 的数组下标是以字符串形式存储的,所以计数排序可以用来排列负数,但不可以排列小数

最好:O(n + k),k是最大值和最小值的差。
最坏:O(n + k)
平均:O(n + k)

function countingSort(nums) {
  let arr = [];
  let max = Math.max(...nums);
  let min = Math.min(...nums);
  // 装桶
  for(let i=0, len=nums.length; i<len; i++) {
    let temp = nums[i];
    arr[temp] = arr[temp] + 1 || 1;
  }
  let index = 0;
  // 还原原数组
  for(let i=min; i<=max; i++) {
    while(arr[i] > 0) {
      nums[index++] = i;
      arr[i]--;
    }
  }
}

计数排序优化

把每一个数组元素都加上 min 的相反数,来避免特殊情况下的空间浪费,通过这种优化可以把所开的空间大小从 max+1 降低为 max-min+1maxmin 分别为数组中的最大值和最小值。

比如数组 [103, 102, 101, 100],普通的计数排序需要开一个长度为 104 的数组,而且前面 100 个值都是 undefined,使用该优化方法后可以只开一个长度为 4 的数组。

function countingSort(nums) {
  let arr = [];
  let max = Math.max(...nums);
  let min = Math.min(...nums);
  // 加上最小值的相反数来缩小数组范围
  let add = -min;
  for(let i=0, len=nums.length; i<len; i++) {
    let temp = nums[i];
    temp += add;
    arr[temp] = arr[temp] + 1 || 1;
  }
  let index = 0;
  for(let i=min; i<=max; i++) {
    let temp = arr[i+add];
    while(temp > 0) {
      nums[index++] = i;
      temp--;
    }
  }
}

堆排序

根据数组建立一个堆(类似完全二叉树),每个结点的值都大于左右结点(最大堆,通常用于升序),或小于左右结点(最小堆,通常用于降序)。对于升序排序,先构建最大堆后,交换堆顶元素(表示最大值)和堆底元素,每一次交换都能得到未有序序列的最大值。重新调整最大堆,再交换堆顶元素和堆底元素,重复 n-1 次后就能得到一个升序的数组。

最好:O(n * logn),logn是调整最大堆所花的时间。
最坏:O(n * logn)
平均:O(n * logn)

参考学习链接:
常见排序算法 - 堆排序 (Heap Sort)
图解排序算法(三)之堆排序

function heapSort(nums) {
  // 调整最大堆,使index的值大于左右节点
  function adjustHeap(nums, index, size) {
    // 交换后可能会破坏堆结构,需要循环使得每一个父节点都大于左右结点
    while(true) {
      let max = index;
      let left = index * 2 + 1;   // 左节点
      let right = index * 2 + 2;  // 右节点
      if(left < size && nums[max] < nums[left])  max = left;
      if(right < size && nums[max] < nums[right])  max = right;
      // 如果左右结点大于当前的结点则交换,并再循环一遍判断交换后的左右结点位置是否破坏了堆结构(比左右结点小了)
      if(index !== max) {
        [nums[index], nums[max]] = [nums[max], nums[index]];
        index = max;
      }
      else {
        break;
      }
    }
  }
  // 建立最大堆
  function buildHeap(nums) {
    // 注意这里的头节点是从0开始的,所以最后一个非叶子结点是 parseInt(nums.length/2)-1
    let start = parseInt(nums.length / 2) - 1;
    let size = nums.length;
    // 从最后一个非叶子结点开始调整,直至堆顶。
    for(let i=start; i>=0; i--) {
      adjustHeap(nums, i, size);
    }
  }

  buildHeap(nums);
  // 循环n-1次,每次循环后交换堆顶元素和堆底元素并重新调整堆结构
  for(let i=nums.length-1; i>0; i--) {
    [nums[i], nums[0]] = [nums[0], nums[i]];
    adjustHeap(nums, 0, i);
  }
}

希尔排序

通过某个增量 gap,将整个序列分给若干组,从后往前进行组内成员的比较和交换,随后逐步缩小增量至 1。希尔排序类似于插入排序,只是一开始向前移动的步数从 1 变成了 gap。

最好:O(n * logn),步长不断二分。
最坏:O(n * logn)
平均:O(n * logn)

参考学习链接:
图解排序算法(二)之希尔排序

function shellSort(nums) {
  let len = nums.length;
  // 初始步数
  let gap = parseInt(len / 2);
  // 逐渐缩小步数
  while(gap) {
    // 从第gap个元素开始遍历
    for(let i=gap; i<len; i++) {
      // 逐步其和前面其他的组成员进行比较和交换
      for(let j=i-gap; j>=0; j-=gap) {
        if(nums[j] > nums[j+gap]) {
          [nums[j], nums[j+gap]] = [nums[j+gap], nums[j]];
        }
        else {
          break;
        }
      }
    }
    gap = parseInt(gap / 2);
  }
}

看完后如果大家有什么疑问或发现一些错误,可以在下方留言呀,或者在我的仓库里 提issues,我们一起讨论讨论😊

查看原文

赞 135 收藏 113 评论 2

啃先生 收藏了文章 · 2019-04-09

推荐一款 Flutter Push 推送功能插件

又到了推荐好插件的时候了。开发 APP 避免不了使用「推送」功能。比如,新上架一个商品,或者最新的一条体育新闻,实时推送给用户。

比较了几家推送平台,貌似「极光」出了 Flutter 插件,所以就拿它试试手,顺便记录下整个推送功能开发流程。

说到「推送」,自然有推送端和接收端,接收端自然包括 Android 端和 iOS 端。

demo

引入插件:

flutter_jpush: ^0.0.4

main.dart 加入初始化代码:

void _initJPush() async {
  await FlutterJPush.startup();
  print("初始化jpush成功");

  // 获取 registrationID
  var registrationID =await FlutterJPush.getRegistrationID();
  print(registrationID);
  
  // 注册接收和打开 Notification()
  _initNotification();
}

void _initNotification() async {
  FlutterJPush.addReceiveNotificationListener(
    (JPushNotification notification) {
      print("收到推送提醒: $notification");
    }
  );

  FlutterJPush.addReceiveOpenNotificationListener(
    (JPushNotification notification) {
      print("打开了推送提醒: $notification");
    }
  );
}

Android 配置

在极光后台创建应用,生成 appkey 等信息,Android 配置好说,添加包名即可。

在项目 Android 工程 build.gradle 代码中,增加配置信息:

defaultConfig {
        // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
        applicationId "com.*.*"
        minSdkVersion 16
        targetSdkVersion 27
        versionCode flutterVersionCode.toInteger()
        versionName flutterVersionName
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
        manifestPlaceholders = [
            JPUSH_PKGNAME : applicationId,
            JPUSH_APPKEY : "****", // 极光上注册的包名对应的appkey.
            JPUSH_CHANNEL : "developer-default",
        ]
    }

好了,我们先在极光后台编写一条消息通知,看看效果。

当 APP 处于打开状态,通过命令好 log,我们能看到「收到推送提醒」:

同时,我们在通知栏上也能收到这条消息推送通知:

打开这条通知后,执行的是「addReceiveOpenNotificationListener」

就是这么简单。

iOS 配置

如何申请证书和签权,具体看极光的说明:https://docs.jiguang.cn/jpush/client/iOS/ios_cer_guide/

需要注意的是,先在 Xcode 那打开「Push Notifications」

在「iOS」工程下,添加极光配置信息:

增加
#include "FlutterJPushPlugin.h"

增加
[self startupJPush:launchOptions appKey:@"你的key" channel:@"你的渠道" isProduction:是否生产版本];

好了,配置之后,dart 端还是上面的同样代码,还是利用极光的后台,推送一条测试通知,看看效果:

打开该通知后,也执行 print 了:

服务器编程推送

只要消息能到达客户端,那具体怎么使用,或者打开客户端跳转到具体页面,这些工作就好说了,此处就没必要展开说了。

剩下的就是后台接口推送通知了,总不能每次都要在「极光」后台做推送吧!

所以我们需要借助「极光」提供的接口了。

极光提供了多语言服务端 SDK,基本可以满足我们的集成需要了。

我还是以 Laravel 为案例,简要说一说集成。

1.composer.json 文件中添加 jpush 依赖.

"jpush/jpush": "^3.5"

2. 写一个 demo 命令行推送服务:

Artisan::command('jpush', function () {
    $client = new \JPush\Client($app_key, $master_secret);
})->describe('jpush');

3. 发送一个通知试试:

$client->push()
    ->setPlatform('all')
    ->addAllAudience()
    ->setNotificationAlert('你好, 极光推送')
    ->send();

执行命令:php artisan jpush 看看:

okey,到目前为止,通过简单的例子,就可以把从服务端到客户端走通 Push 流程。

注:服务端 SDK 参考

https://github.com/jpush/jpush-api-php-client/blob/master/doc/api.md#push-api

总结

如果知道怎么结合原生 Android 和 iOS 插件集成到 Flutter 上,那使用极光推送,也可以不需要官方提供的 Flutter 插件,相信你也能写。

相反地,使用官方提供的 Flutter 插件和集成文档,可以让我们快速的完成 push 通知功能,可以让我们更聚焦于我们的产品逻辑和功能上。

查看原文

啃先生 收藏了文章 · 2018-09-11

怎么在网页中打开你的app

先声明一下关于ios中9.0打开方式的文章来自博客:IOS9通用链接(universal link)

前言

对于一个完备的互联网产品而言需要有app端与web端两个不同前端,对于产品而言很多都希望能够将wap页上的用户引向native app上这就要求前端工程师们为网页提供各种入口去打开app,今天我们就聊一聊app的打开方式(有错误的地方还请高手指正)。

常规打开

对于app打开而言最常规的打开就是通过url scheme的方式去打开你的app,如下的

myapp://
myapp://open
myapp://type=1&id=2sdeo223lwe

这些抛出都是以url的方式进行抛出,app捕捉到这些抛出去做相应的处理,本文对app的处理不做详细描述,app开发请自行谷歌百度。对于前端而言抛出的方式也有很多,而最理想的方式是通过iframe的src对其进行链抛出,来!说的在多都没有代码来的清晰,请看下面。

首先我们需要有一个iframe:

//实际上就是新建一个iframe的生成器
var  createIframe=(function(){
  var iframe;
    return function(){
        if(iframe){
            return iframe;
        }else{
            iframe = document.createElement('iframe');
            iframe.style.display = 'none';
            document.body.appendChild(iframe);
            return iframe;      
        }
    }
})()

之后我们还需要一个url scheme:

//生成一个url scheme,假设我们约定的scheme是myApp://type=1&id=iewo212j32这种形式的

var baseScheme = "myApp://"
var createScheme=function(options){
    var urlScheme=baseScheme;
    for(var item in options){
        urlScheme=urlScheme+item + '=' + encodeURIComponent(options[item]) + "&";
    }
    urlScheme = urlScheme.substring(0, urlScheme.length - 1);
    return encodeURIComponent(urlScheme);
}

这种scheme形式的其实不是最好的,根据我们踩过的坑,觉得约定为与http协议相近可能更好一些,具体的协议需要前端人员自己去和app端人员约定。

ok万事具备,iframe有了,urlScheme也有了,该去打开app了

var openApp=function(){
    var localUrl=createScheme();
    var openIframe=createIframe();
    if(isIos()){
        //判断是否是ios,具体的判断函数自行百度
        window.location.href = localUrl;
        var loadDateTime = Date.now();
        setTimeout(function () {
            var timeOutDateTime = Date.now();
            if (timeOutDateTime - loadDateTime < 1000) {
                window.location.href = "你的下载页面";
            }
        }, 25);
    }else if(isAndroid()){
        //判断是否是android,具体的判断函数自行百度
        if (isChrome()) {
            //chrome浏览器用iframe打不开得直接去打开,算一个坑
            window.location.href = localUrl;
        } else {
            //抛出你的scheme
            openIframe.src = localUrl;
        }
        setTimeout(function () {
            window.location.href = "你的下载页面";
        }, 500);
    }else{
        //主要是给winphone的用户准备的,实际都没测过,现在winphone不好找啊
        openIframe.src = localUrl;
        setTimeout(function () {
            window.location.href = "你的下载页面";
        }, 500);
    }
}

以上就是你要打开scheme的主要代码了,好吧,实际上不只是打开app,还要实现未打开的时候跳到下载页去。其中安卓实际上无论有没有打开都会跳到下载页去,而ios........好吧!按照网上的说法是浏览器失焦后会挂起脚本,呵呵,这是多老的ios版本的表现了,实际上现在的ios已经没有这么做,有些版本会跟安卓的表现一样,而有些则是直接跳转根本不会去打开,还有打开的时候那个恶心的系统弹窗是什么鬼。好吧,实际上至此你会发现,ios9.0以上的有些打不开直接跳,有些打得开还会有允许弹窗,而微信则是无论如何都打不开,实际上微信会在他的浏览器里拦截掉所有未经其允许的scheme包括app store,那么接下来我们要解决这些问题。

通用链接

针对ios9及以上的打不开问题,实际上ios9提供了更好的解决方案————通用链接。

什么是Universal Links(通用链接)?

这是iOS9推出的一项功能,如果你的应用支持Universal Links(通用链接),那么就能够方便的通过传统的HTTP链接来启动APP(如果iOS设备上已经安装了你的app,不需要额外做任何判断等),或者打开网页(iOS设备上没有安装你的app)。或许可以更简单点来说明,在iOS9之前,对于从各种从浏览器,Safari、UIWebView或者 WKWebView中唤醒APP的需求,我们通常只能使用scheme。

以上来自网上关于通用链接的介绍,对于前端简单点讲就是你访问一个http的url,如果这个url带有你提交给开发平台的配置文件中匹配规则的内容,ios系统会去尝试打开你的app,如果打不开,系统就会在浏览器中转向你要访问的链接。很好的一个属性,因为通过这个属性在ios9上我们能够绕过微信的拦截从而打开app。

以下是ios开发人员要做的百度搜索结果第一条ios中实现通用链接

而我们要做的真的很简单,实际上我们只需要抛出链接就好了(实际上博主只是来骗经验的)。在此之前请准备好与主站不同的域名,比如主站www.xxxx.com,你们可以准备好open.xxxx.com的域名作为重定向用。好吧!实际上通用链接有一个很坑的属性,必须是异域打开,而且如果你提交的是你主站的链接,你打开你的主站你会发现网站上方会挂着一个难看的灰条转向appstore中你们的app,没错,就是ios系统干的这个事,具体的其他注意事项可以参考这篇文章IOS9通用链接(universal link)

那么接下来我们的代码得做好更改

//增加通用链接的生成,
var baseScheme = "myApp://",
    baseLink="http://m.xxxx.com?";
var createScheme=function(options,isLink){
    var urlScheme=isLink?baseLink:baseScheme;
    for(var item in options){
        urlScheme=urlScheme+item + '=' + encodeURIComponent(options[item]) + "&";
    }
    urlScheme = urlScheme.substring(0, urlScheme.length - 1);
    return isLink?urlScheme:encodeURIComponent(urlScheme);
}

然后对抛出做

var openApp=function(){
    //生成你的scheme你也可以选择外部传入
    var localUrl=createScheme({type:1,id:"sdsdewe2122"});
    var openIframe=createIframe();
    if(isIos()){
        //判断是否是ios,具体的判断函数自行百度
        if(isGreaterThan9()){
            //判断是否为ios9以上的版本,跟其他判断一样navigator.userAgent判断,ios会有带版本号
            localUrl=createScheme({type:1,id:"sdsdewe2122"},true);//代码还可以优化一下
            location.href = localUrl;//实际上不少产品会选择一开始将链接写入到用户需要点击的a标签里
            return;
        }
        window.location.href = localUrl;
        var loadDateTime = Date.now();
        setTimeout(function () {
            var timeOutDateTime = Date.now();
            if (timeOutDateTime - loadDateTime < 1000) {
                window.location.href = "你的下载页面";
            }
        }, 25);
    }else if(isAndroid()){
        //判断是否是android,具体的判断函数自行百度
        if (isChrome()) {
            //chrome浏览器用iframe打不开得直接去打开,算一个坑
            window.location.href = localUrl;
        } else {
            //抛出你的scheme
            openIframe.src = localUrl;
        }
        setTimeout(function () {
            window.location.href = "你的下载页面";
        }, 500);
    }else{
        //主要是给winphone的用户准备的,实际都没测过,现在winphone不好找啊
        openIframe.src = localUrl;
        setTimeout(function () {
            window.location.href = "你的下载页面";
        }, 500);
    }
}

实际上就只需要更改这么点,最重要的是app端接入通用链接,注意抛出的链接不要跟主站同域即可,之后就是不断的调试,自己去踩坑吧,记得绑定域名,这个对域名具有一定的依赖性。

微信中打开

至此只有微信是打不开的,实际上腾讯系的产品都是打不开的,包括qq浏览器。

对于微信中有两种方式:

  • 一种简单的方式就是弹窗告诉用户让他去浏览器中打开——在技术之外的办法

  • 还有一种方式就是应用宝

是的如果是微信就去打开你的app对应的应用宝,应用宝会去检测你的app是否存在有则去打开,但只是去打开。实际上腾讯的应用宝对于开发者在功能上做的比想象中的要强大,你在应用宝的微下载中配置申请你的applink与app store的链接,之后你只要在你的链接参数中带上android_schema="myApp://"就在应用宝中打开app中的特定功能,如果忽略应用宝的页面跟自己scheme打开没有太大区别,具体的操作可以查看应用宝的说明。简而言之,腾讯的产品中都去借助应用宝这个平台去执行你需要的操作。在此就不贴代码了,只要判断浏览器如果是微信或者是qq就去跳你的应用宝链接就行。

总结

实际上单纯打开app非常简单,目前无论安卓还是ios都能够很好的支持scheme,当然腾讯系产品除外,实际上百度浏览器也会拦截scheme(我觉得真是奇了葩!!!微信这种尚能理解,一个浏览器居然擅自去拦截scheme)目前对百度浏览器还没有什么很好的办法,可以尝试是否能够通过百度应用市场去解决。如果是希望打开app同时又要打开下载页,那么ios9及以上就得用通用链接去解决,重点就是这个通用链接。

查看原文

啃先生 收藏了文章 · 2018-07-13

前端做模糊搜索

我们先看一下效果图:
image

这是搜索关键字cfg时,会自动匹配到config方法

同样,我们再看另一个例子

image
通过关键字bi会匹配到好几个结果

这个和一些编辑器的搜索功能很像,比如sublime text,不需要知道关键字的完整拼写,只需要知道其中的几个字母即可。

那么这个功能在前端我们如何去实现呢?

不考虑性能的话,我们可以用正则简单实现如下:

把关键字拆分,加入(.?),如cfg最终为 (.?)(c)(.?)(f)(.?)(g)(.*?),然后拿这个正则去测试要搜索的列表,把符合要求的选项给拿出来即可

考虑到要高亮结果,我们还要生成对应的替换表达式,最后的函数如下

var escapeRegExp = /[\-#$\^*()+\[\]{}|\\,.?\s]/g;
var KeyReg = (key) => {
    var src = ['(.*?)('];
    var ks = key.split('');
    if (ks.length) {
        while (ks.length) {
            src.push(ks.shift().replace(escapeRegExp, '\\$&'), ')(.*?)(');
        }
        src.pop();
    }
    src.push(')(.*?)');
    src = src.join('');
    var reg = new RegExp(src, 'i');
    var replacer = [];
    var start = key.length;
    var begin = 1;
    while (start > 0) {
        start--;
        replacer.push('$', begin, '($', begin + 1, ')');
        begin += 2;
    }
    replacer.push('$', begin);

    info = {
        regexp: reg,
        replacement: replacer.join('')
    };
    return info;
};

调用KeyReg把关键字传入,拿返回值的regexp去检测搜索的列表,把符合的保存下来即可。

到目前为止我们只实现了搜索功能,按更优的体验来讲,在搜索结果中,要优先把相连匹配的放在首位,如bi关键字,要把bind结果放到beginUpdate前面。第二个截图是有优化的地方的。

要完成这个功能,我们使用KeyReg返回值中的replacement,用它进行检测,把结果中长度最长的放前面即可,这块代码以后有时间再补充

2018.5.31
今天重构了下,增加了结果排序,完整的代码及使用示例如下

let Searcher = (() => {
    let escapeRegExp = /[\-#$\^*()+\[\]{}|\\,.?\s]/g;
    let escapeReg = reg => reg.replace(escapeRegExp, '\\$&');
    //groupLeft 与 groupRight是对结果进一步处理所使用的分割符,可以修改
    let groupLeft = '(',
        groupRight = ')';
    let groupReg = new RegExp(escapeReg(groupRight + groupLeft), 'g');
    let groupExtractReg = new RegExp('(' + escapeReg(groupLeft) + '[\\s\\S]+?' + escapeReg(groupRight) + ')', 'g');
    //从str中找到最大的匹配长度
    let findMax = (str, keyword) => {
        let max = 0;
        keyword = groupLeft + keyword + groupRight;
        str.replace(groupExtractReg, m => {
            //keyword完整的出现在str中,则优秀级最高,排前面
            if (keyword == m) {
                max = Number.MAX_SAFE_INTEGER;
            } else if (m.length > max) {//找最大长度
                max = m.length;
            }
        });
        return max;
    };
    let keyReg = key => {
        let src = ['(.*?)('];
        let ks = key.split('');
        if (ks.length) {
            while (ks.length) {
                src.push(escapeReg(ks.shift()), ')(.*?)(');
            }
            src.pop();
        }
        src.push(')(.*?)');
        src = src.join('');
        let reg = new RegExp(src, 'i');
        let replacer = [];
        let start = key.length;
        let begin = 1;
        while (start > 0) {
            start--;
            replacer.push('$', begin, groupLeft + '$', begin + 1, groupRight);
            begin += 2;
        }
        replacer.push('$', begin);

        info = {
            regexp: reg,
            replacement: replacer.join('')
        };
        return info;
    };

    return {
        search(list, keyword) {
            //生成搜索正则
            let kr = keyReg(keyword);
            let result = [];
            for (let e of list) {
                //如果匹配
                if (kr.regexp.test(e)) {
                    //把结果放入result数组中
                    result.push(e.replace(kr.regexp, kr.replacement)
                        .replace(groupReg, ''));
                }
            }
            //对搜索结果进行排序
            //1. 匹配关键字大小写一致的优先级最高,比如搜索up, 结果中的[user-page,beginUpdate,update,endUpdate],update要排在最前面,因为大小写匹配
            //2. 匹配关键字长的排在前面
            result = result.sort((a, b) => findMax(b, keyword) - findMax(a, keyword));
            return result;
        }
    };
})();

//假设list是待搜索的列表
let list = ['config', 'user-page', 'bind', 'render', 'beginUpdate', 'update', 'endUpdate'];
//假设userInput是用户输入的关键字
let userInput = 'up';

//获取搜索的结果
console.log(Searcher.search(list, userInput));
// ["(up)date", "begin(Up)date", "end(Up)date", "(u)ser-(p)age"]

对搜索结果中的内容做进一步处理渲染出来即可,比如把 ( 替换成 <span style="color:red">) 替换成</span>显示到页面上就完成了高亮显示

查看原文

啃先生 赞了文章 · 2018-06-08

webpack原理

webpack原理

查看所有文档页面:前端开发文档,获取更多信息。
原文链接:webpack原理,原文广告模态框遮挡,阅读体验不好,所以整理成本文,方便查找。

工作原理概括

基本概念

在了解 Webpack 原理前,需要掌握以下几个核心概念,以方便后面的理解:

  • Entry:入口,Webpack 执行构建的第一步将从 Entry 开始,可抽象成输入。
  • Module:模块,在 Webpack 里一切皆模块,一个模块对应着一个文件。Webpack 会从配置的 Entry 开始递归找出所有依赖的模块。
  • Chunk:代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割。
  • Loader:模块转换器,用于把模块原内容按照需求转换成新内容。
  • Plugin:扩展插件,在 Webpack 构建流程中的特定时机会广播出对应的事件,插件可以监听这些事件的发生,在特定时机做对应的事情。

流程概括

Webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:

  1. 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数;
  2. 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译;
  3. 确定入口:根据配置中的 entry 找出所有的入口文件;
  4. 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;
  5. 完成模块编译:在经过第4步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系;
  6. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
  7. 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

在以上过程中,Webpack 会在特定的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。

流程细节

Webpack 的构建流程可以分为以下三大阶段:

  1. 初始化:启动构建,读取与合并配置参数,加载 Plugin,实例化 Compiler。
  2. 编译:从 Entry 发出,针对每个 Module 串行调用对应的 Loader 去翻译文件内容,再找到该 Module 依赖的 Module,递归地进行编译处理。
  3. 输出:对编译后的 Module 组合成 Chunk,把 Chunk 转换成文件,输出到文件系统。

如果只执行一次构建,以上阶段将会按照顺序各执行一次。但在开启监听模式下,流程将变为如下:

在每个大阶段中又会发生很多事件,Webpack 会把这些事件广播出来供给 Plugin 使用,下面来一一介绍。

初始化阶段

事件名解释
初始化参数从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。 这个过程中还会执行配置文件中的插件实例化语句 new Plugin()
实例化 Compiler用上一步得到的参数初始化 Compiler 实例,Compiler 负责文件监听和启动编译。Compiler 实例中包含了完整的 Webpack 配置,全局只有一个 Compiler 实例。
加载插件依次调用插件的 apply 方法,让插件可以监听后续的所有事件节点。同时给插件传入 compiler 实例的引用,以方便插件通过 compiler 调用 Webpack 提供的 API。
environment开始应用 Node.js 风格的文件系统到 compiler 对象,以方便后续的文件寻找和读取。
entry-option读取配置的 Entrys,为每个 Entry 实例化一个对应的 EntryPlugin,为后面该 Entry 的递归解析工作做准备。
after-plugins调用完所有内置的和配置的插件的 apply 方法。
after-resolvers根据配置初始化完 resolverresolver 负责在文件系统中寻找指定路径的文件。
空格空格
空格空格
空格空格

编译阶段

事件名解释
run启动一次新的编译。
watch-runrun 类似,区别在于它是在监听模式下启动的编译,在这个事件中可以获取到是哪些文件发生了变化导致重新启动一次新的编译。
compile该事件是为了告诉插件一次新的编译将要启动,同时会给插件带上 compiler 对象。
compilationWebpack 以开发模式运行时,每当检测到文件变化,一次新的 Compilation 将被创建。一个 Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。Compilation 对象也提供了很多事件回调供插件做扩展。
make一个新的 Compilation 创建完毕,即将从 Entry 开始读取文件,根据文件类型和配置的 Loader 对文件进行编译,编译完后再找出该文件依赖的文件,递归的编译和解析。
after-compile一次 Compilation 执行完成。
invalid当遇到文件不存在、文件编译错误等异常时会触发该事件,该事件不会导致 Webpack 退出。
空格空格
空格空格

在编译阶段中,最重要的要数 compilation 事件了,因为在 compilation 阶段调用了 Loader 完成了每个模块的转换操作,在 compilation 阶段又包括很多小的事件,它们分别是:

事件名解释
build-module使用对应的 Loader 去转换一个模块。
normal-module-loader在用 Loader 对一个模块转换完后,使用 acorn 解析转换后的内容,输出对应的抽象语法树(AST),以方便 Webpack 后面对代码的分析。
program从配置的入口模块开始,分析其 AST,当遇到 require 等导入其它模块语句时,便将其加入到依赖的模块列表,同时对新找出的依赖模块递归分析,最终搞清所有模块的依赖关系。
seal所有模块及其依赖的模块都通过 Loader 转换完成后,根据依赖关系开始生成 Chunk。

输出阶段

事件名解释
should-emit所有需要输出的文件已经生成好,询问插件哪些文件需要输出,哪些不需要。
emit确定好要输出哪些文件后,执行文件输出,可以在这里获取和修改输出内容。
after-emit文件输出完毕。
done成功完成一次完成的编译和输出流程。
failed如果在编译和输出流程中遇到异常导致 Webpack 退出时,就会直接跳转到本步骤,插件可以在本事件中获取到具体的错误原因。

在输出阶段已经得到了各个模块经过转换后的结果和其依赖关系,并且把相关模块组合在一起形成一个个 Chunk。 在输出阶段会根据 Chunk 的类型,使用对应的模版生成最终要要输出的文件内容。

输出文件分析

虽然在前面的章节中你学会了如何使用 Webpack ,也大致知道其工作原理,可是你想过 Webpack 输出的 bundle.js 是什么样子的吗? 为什么原来一个个的模块文件被合并成了一个单独的文件?为什么 bundle.js 能直接运行在浏览器中? 本节将解释清楚以上问题。

先来看看由 安装与使用 中最简单的项目构建出的 bundle.js 文件内容,代码如下:

<p data-height="565" data-theme-id="0" data-slug-hash="NMQzxz" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="bundle.js" class="codepen">See the Pen bundle.js by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

以上看上去复杂的代码其实是一个立即执行函数,可以简写为如下:

(function(modules) {

  // 模拟 require 语句
  function __webpack_require__() {
  }

  // 执行存放所有模块数组中的第0个模块
  __webpack_require__(0);

})([/*存放所有模块的数组*/])

bundle.js 能直接运行在浏览器中的原因在于输出的文件中通过 __webpack_require__ 函数定义了一个可以在浏览器中执行的加载函数来模拟 Node.js 中的 require 语句。

原来一个个独立的模块文件被合并到了一个单独的 bundle.js 的原因在于浏览器不能像 Node.js 那样快速地去本地加载一个个模块文件,而必须通过网络请求去加载还未得到的文件。 如果模块数量很多,加载时间会很长,因此把所有模块都存放在了数组中,执行一次网络加载。

如果仔细分析 __webpack_require__ 函数的实现,你还有发现 Webpack 做了缓存优化: 执行加载过的模块不会再执行第二次,执行结果会缓存在内存中,当某个模块第二次被访问时会直接去内存中读取被缓存的返回值。

分割代码时的输出

例如把源码中的 main.js 修改为如下:

// 异步加载 show.js
import('./show').then((show) => {
  // 执行 show 函数
  show('Webpack');
});

重新构建后会输出两个文件,分别是执行入口文件 bundle.js 和 异步加载文件 0.bundle.js

其中 0.bundle.js 内容如下:

// 加载在本文件(0.bundle.js)中包含的模块
webpackJsonp(
  // 在其它文件中存放着的模块的 ID
  [0],
  // 本文件所包含的模块
  [
    // show.js 所对应的模块
    (function (module, exports) {
      function show(content) {
        window.document.getElementById('app').innerText = 'Hello,' + content;
      }

      module.exports = show;
    })
  ]
);

bundle.js 内容如下:

<p data-height="565" data-theme-id="0" data-slug-hash="yjmRyG" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="bundle.js" class="codepen">See the Pen bundle.js by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

这里的 bundle.js 和上面所讲的 bundle.js 非常相似,区别在于:

  • 多了一个 __webpack_require__.e 用于加载被分割出去的,需要异步加载的 Chunk 对应的文件;
  • 多了一个 webpackJsonp 函数用于从异步加载的文件中安装模块。

在使用了 CommonsChunkPlugin 去提取公共代码时输出的文件和使用了异步加载时输出的文件是一样的,都会有 __webpack_require__.ewebpackJsonp。 原因在于提取公共代码和异步加载本质上都是代码分割。

编写 Loader

Loader 就像是一个翻译员,能把源文件经过转化后输出新的结果,并且一个文件还可以链式的经过多个翻译员翻译。

以处理 SCSS 文件为例:

  • SCSS 源代码会先交给 sass-loader 把 SCSS 转换成 CSS;
  • sass-loader 输出的 CSS 交给 css-loader 处理,找出 CSS 中依赖的资源、压缩 CSS 等;
  • css-loader 输出的 CSS 交给 style-loader 处理,转换成通过脚本加载的 JavaScript 代码;

可以看出以上的处理过程需要有顺序的链式执行,先 sass-loadercss-loaderstyle-loader。 以上处理的 Webpack 相关配置如下:

<p data-height="365" data-theme-id="0" data-slug-hash="YLmbeQ" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="编写 Loader" class="codepen">See the Pen 编写 Loader by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

Loader 的职责

由上面的例子可以看出:一个 Loader 的职责是单一的,只需要完成一种转换。 如果一个源文件需要经历多步转换才能正常使用,就通过多个 Loader 去转换。 在调用多个 Loader 去转换一个文件时,每个 Loader 会链式的顺序执行, 第一个 Loader 将会拿到需处理的原内容,上一个 Loader 处理后的结果会传给下一个接着处理,最后的 Loader 将处理后的最终结果返回给 Webpack。

所以,在你开发一个 Loader 时,请保持其职责的单一性,你只需关心输入和输出。

Loader 基础

由于 Webpack 是运行在 Node.js 之上的,一个 Loader 其实就是一个 Node.js 模块,这个模块需要导出一个函数。 这个导出的函数的工作就是获得处理前的原内容,对原内容执行处理后,返回处理后的内容。

一个最简单的 Loader 的源码如下:

module.exports = function(source) {
  // source 为 compiler 传递给 Loader 的一个文件的原内容
  // 该函数需要返回处理后的内容,这里简单起见,直接把原内容返回了,相当于该 Loader 没有做任何转换
  return source;
};

由于 Loader 运行在 Node.js 中,你可以调用任何 Node.js 自带的 API,或者安装第三方模块进行调用:

const sass = require('node-sass');
module.exports = function(source) {
  return sass(source);
};

Loader 进阶

以上只是个最简单的 Loader,Webpack 还提供一些 API 供 Loader 调用,下面来一一介绍。

获得 Loader 的 options

在最上面处理 SCSS 文件的 Webpack 配置中,给 css-loader 传了 options 参数,以控制 css-loader。 如何在自己编写的 Loader 中获取到用户传入的 options 呢?需要这样做:

const loaderUtils = require('loader-utils');
module.exports = function(source) {
  // 获取到用户给当前 Loader 传入的 options
  const options = loaderUtils.getOptions(this);
  return source;
};

返回其它结果

上面的 Loader 都只是返回了原内容转换后的内容,但有些场景下还需要返回除了内容之外的东西。

例如以用 babel-loader 转换 ES6 代码为例,它还需要输出转换后的 ES5 代码对应的 Source Map,以方便调试源码。 为了把 Source Map 也一起随着 ES5 代码返回给 Webpack,可以这样写:

module.exports = function(source) {
  // 通过 this.callback 告诉 Webpack 返回的结果
  this.callback(null, source, sourceMaps);
  // 当你使用 this.callback 返回内容时,该 Loader 必须返回 undefined,
  // 以让 Webpack 知道该 Loader 返回的结果在 this.callback 中,而不是 return 中 
  return;
};

其中的 this.callback 是 Webpack 给 Loader 注入的 API,以方便 Loader 和 Webpack 之间通信。 this.callback 的详细使用方法如下:

this.callback(
    // 当无法转换原内容时,给 Webpack 返回一个 Error
    err: Error | null,
    // 原内容转换后的内容
    content: string | Buffer,
    // 用于把转换后的内容得出原内容的 Source Map,方便调试
    sourceMap?: SourceMap,
    // 如果本次转换为原内容生成了 AST 语法树,可以把这个 AST 返回,
    // 以方便之后需要 AST 的 Loader 复用该 AST,以避免重复生成 AST,提升性能
    abstractSyntaxTree?: AST
);
Source Map 的生成很耗时,通常在开发环境下才会生成 Source Map,其它环境下不用生成,以加速构建。 为此 Webpack 为 Loader 提供了 this.sourceMap API 去告诉 Loader 当前构建环境下用户是否需要 Source Map。 如果你编写的 Loader 会生成 Source Map,请考虑到这点。

同步与异步

Loader 有同步和异步之分,上面介绍的 Loader 都是同步的 Loader,因为它们的转换流程都是同步的,转换完成后再返回结果。 但在有些场景下转换的步骤只能是异步完成的,例如你需要通过网络请求才能得出结果,如果采用同步的方式网络请求就会阻塞整个构建,导致构建非常缓慢。

在转换步骤是异步时,你可以这样:

module.exports = function(source) {
    // 告诉 Webpack 本次转换是异步的,Loader 会在 callback 中回调结果
    var callback = this.async();
    someAsyncOperation(source, function(err, result, sourceMaps, ast) {
        // 通过 callback 返回异步执行后的结果
        callback(err, result, sourceMaps, ast);
    });
};

处理二进制数据

在默认的情况下,Webpack 传给 Loader 的原内容都是 UTF-8 格式编码的字符串。 但有些场景下 Loader 不是处理文本文件,而是处理二进制文件,例如 file-loader,就需要 Webpack 给 Loader 传入二进制格式的数据。 为此,你需要这样编写 Loader:

module.exports = function(source) {
    // 在 exports.raw === true 时,Webpack 传给 Loader 的 source 是 Buffer 类型的
    source instanceof Buffer === true;
    // Loader 返回的类型也可以是 Buffer 类型的
    // 在 exports.raw !== true 时,Loader 也可以返回 Buffer 类型的结果
    return source;
};
// 通过 exports.raw 属性告诉 Webpack 该 Loader 是否需要二进制数据 
module.exports.raw = true;

以上代码中最关键的代码是最后一行 module.exports.raw = true;,没有该行 Loader 只能拿到字符串。

缓存加速

在有些情况下,有些转换操作需要大量计算非常耗时,如果每次构建都重新执行重复的转换操作,构建将会变得非常缓慢。 为此,Webpack 会默认缓存所有 Loader 的处理结果,也就是说在需要被处理的文件或者其依赖的文件没有发生变化时, 是不会重新调用对应的 Loader 去执行转换操作的。

如果你想让 Webpack 不缓存该 Loader 的处理结果,可以这样:

module.exports = function(source) {
  // 关闭该 Loader 的缓存功能
  this.cacheable(false);
  return source;
};

其它 Loader API

除了以上提到的在 Loader 中能调用的 Webpack API 外,还存在以下常用 API:

  • this.context:当前处理文件的所在目录,假如当前 Loader 处理的文件是 /src/main.js,则 this.context 就等于 /src
  • this.resource:当前处理文件的完整请求路径,包括 querystring,例如 /src/main.js?name=1
  • this.resourcePath:当前处理文件的路径,例如 /src/main.js
  • this.resourceQuery:当前处理文件的 querystring
  • this.target:等于 Webpack 配置中的 Target。
  • this.loadModule:但 Loader 在处理一个文件时,如果依赖其它文件的处理结果才能得出当前文件的结果时, 就可以通过 this.loadModule(request: string, callback: function(err, source, sourceMap, module)) 去获得 request 对应文件的处理结果。
  • this.resolve:像 require 语句一样获得指定文件的完整路径,使用方法为 resolve(context: string, request: string, callback: function(err, result: string))
  • this.addDependency:给当前处理文件添加其依赖的文件,以便再其依赖的文件发生变化时,会重新调用 Loader 处理该文件。使用方法为 addDependency(file: string)
  • this.addContextDependency:和 addDependency 类似,但 addContextDependency 是把整个目录加入到当前正在处理文件的依赖中。使用方法为 addContextDependency(directory: string)
  • this.clearDependencies:清除当前正在处理文件的所有依赖,使用方法为 clearDependencies()
  • this.emitFile:输出一个文件,使用方法为 emitFile(name: string, content: Buffer|string, sourceMap: {...})

加载本地 Loader

在开发 Loader 的过程中,为了测试编写的 Loader 是否能正常工作,需要把它配置到 Webpack 中后,才可能会调用该 Loader。 在前面的章节中,使用的 Loader 都是通过 Npm 安装的,要使用 Loader 时会直接使用 Loader 的名称,代码如下:

module.exports = {
  module: {
    rules: [
      {
        test: /\.css/,
        use: ['style-loader'],
      },
    ]
  },
};

如果还采取以上的方法去使用本地开发的 Loader 将会很麻烦,因为你需要确保编写的 Loader 的源码是在 node_modules 目录下。 为此你需要先把编写的 Loader 发布到 Npm 仓库后再安装到本地项目使用。

解决以上问题的便捷方法有两种,分别如下:

Npm link

Npm link 专门用于开发和调试本地 Npm 模块,能做到在不发布模块的情况下,把本地的一个正在开发的模块的源码链接到项目的 node_modules 目录下,让项目可以直接使用本地的 Npm 模块。 由于是通过软链接的方式实现的,编辑了本地的 Npm 模块代码,在项目中也能使用到编辑后的代码。

完成 Npm link 的步骤如下:

  • 确保正在开发的本地 Npm 模块(也就是正在开发的 Loader)的 package.json 已经正确配置好;
  • 在本地 Npm 模块根目录下执行 npm link,把本地模块注册到全局;
  • 在项目根目录下执行 npm link loader-name,把第2步注册到全局的本地 Npm 模块链接到项目的 node_moduels 下,其中的 loader-name 是指在第1步中的 package.json 文件中配置的模块名称。

链接好 Loader 到项目后你就可以像使用一个真正的 Npm 模块一样使用本地的 Loader 了。

ResolveLoader

ResolveLoader 用于配置 Webpack 如何寻找 Loader。 默认情况下只会去 node_modules 目录下寻找,为了让 Webpack 加载放在本地项目中的 Loader 需要修改 resolveLoader.modules

假如本地的 Loader 在项目目录中的 ./loaders/loader-name 中,则需要如下配置:


module.exports = {
  resolveLoader:{
    // 去哪些目录下寻找 Loader,有先后顺序之分
    modules: ['node_modules','./loaders/'],
  }
}

加上以上配置后, Webpack 会先去 node_modules 项目下寻找 Loader,如果找不到,会再去 ./loaders/ 目录下寻找。

实战

上面讲了许多理论,接下来从实际出发,来编写一个解决实际问题的 Loader。

该 Loader 名叫 comment-require-loader,作用是把 JavaScript 代码中的注释语法:

// @require '../style/index.css'

转换成:

require('../style/index.css');

该 Loader 的使用场景是去正确加载针对 Fis3 编写的 JavaScript,这些 JavaScript 中存在通过注释的方式加载依赖的 CSS 文件。

该 Loader 的使用方法如下:

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['comment-require-loader'],
        // 针对采用了 fis3 CSS 导入语法的 JavaScript 文件通过 comment-require-loader 去转换 
        include: [path.resolve(__dirname, 'node_modules/imui')]
      }
    ]
  }
};

该 Loader 的实现非常简单,完整代码如下:

function replace(source) {
    // 使用正则把 // @require '../style/index.css' 转换成 require('../style/index.css');  
    return source.replace(/(\/\/ *@require) +(('|").+('|")).*/, 'require($2);');
}

module.exports = function (content) {
    return replace(content);
};

编写 Plugin

Webpack 通过 Plugin 机制让其更加灵活,以适应各种应用场景。 在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。

一个最基础的 Plugin 的代码是这样的:

class BasicPlugin{
  // 在构造函数中获取用户给该插件传入的配置
  constructor(options){
  }

  // Webpack 会调用 BasicPlugin 实例的 apply 方法给插件实例传入 compiler 对象
  apply(compiler){
    compiler.plugin('compilation',function(compilation) {
    })
  }
}

// 导出 Plugin
module.exports = BasicPlugin;

在使用这个 Plugin 时,相关配置代码如下:

const BasicPlugin = require('./BasicPlugin.js');
module.export = {
  plugins:[
    new BasicPlugin(options),
  ]
}

Webpack 启动后,在读取配置的过程中会先执行 new BasicPlugin(options) 初始化一个 BasicPlugin 获得其实例。 在初始化 compiler 对象后,再调用 basicPlugin.apply(compiler) 给插件实例传入 compiler 对象。 插件实例在获取到 compiler 对象后,就可以通过 compiler.plugin(事件名称, 回调函数) 监听到 Webpack 广播出来的事件。 并且可以通过 compiler 对象去操作 Webpack。

通过以上最简单的 Plugin 相信你大概明白了 Plugin 的工作原理,但实际开发中还有很多细节需要注意,下面来详细介绍。

CompilerCompilation

在开发 Plugin 时最常用的两个对象就是 Compiler 和 Compilation,它们是 Plugin 和 Webpack 之间的桥梁。 Compiler 和 Compilation 的含义如下:

  • Compiler 对象包含了 Webpack 环境所有的的配置信息,包含 optionsloadersplugins 这些信息,这个对象在 Webpack 启动时候被实例化,它是全局唯一的,可以简单地把它理解为 Webpack 实例;
  • Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。当 Webpack 以开发模式运行时,每当检测到一个文件变化,一次新的 Compilation 将被创建。Compilation 对象也提供了很多事件回调供插件做扩展。通过 Compilation 也能读取到 Compiler 对象。

Compiler 和 Compilation 的区别在于:Compiler 代表了整个 Webpack 从启动到关闭的生命周期,而 Compilation 只是代表了一次新的编译。

事件流

Webpack 就像一条生产线,要经过一系列处理流程后才能将源文件转换成输出结果。 这条生产线上的每个处理流程的职责都是单一的,多个流程之间有存在依赖关系,只有完成当前处理后才能交给下一个流程去处理。 插件就像是一个插入到生产线中的一个功能,在特定的时机对生产线上的资源做处理。

Webpack 通过 Tapable 来组织这条复杂的生产线。 Webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条生产线中,去改变生产线的运作。 Webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。

Webpack 的事件流机制应用了观察者模式,和 Node.js 中的 EventEmitter 非常相似。Compiler 和 Compilation 都继承自 Tapable,可以直接在 Compiler 和 Compilation 对象上广播和监听事件,方法如下:

/**
* 广播出事件
* event-name 为事件名称,注意不要和现有的事件重名
* params 为附带的参数
*/
compiler.apply('event-name',params);

/**
* 监听名称为 event-name 的事件,当 event-name 事件发生时,函数就会被执行。
* 同时函数中的 params 参数为广播事件时附带的参数。
*/
compiler.plugin('event-name',function(params) {

});

同理,compilation.applycompilation.plugin 使用方法和上面一致。

在开发插件时,你可能会不知道该如何下手,因为你不知道该监听哪个事件才能完成任务。

在开发插件时,还需要注意以下两点:

  • 只要能拿到 Compiler 或 Compilation 对象,就能广播出新的事件,所以在新开发的插件中也能广播出事件,给其它插件监听使用。
  • 传给每个插件的 Compiler 和 Compilation 对象都是同一个引用。也就是说在一个插件中修改了 Compiler 或 Compilation 对象上的属性,会影响到后面的插件。
  • 有些事件是异步的,这些异步的事件会附带两个参数,第二个参数为回调函数,在插件处理完任务时需要调用回调函数通知 Webpack,才会进入下一处理流程。例如:
 compiler.plugin('emit',function(compilation, callback) {
    // 支持处理逻辑

    // 处理完毕后执行 callback 以通知 Webpack 
    // 如果不执行 callback,运行流程将会一直卡在这不往下执行 
    callback();
  });

常用 API

插件可以用来修改输出文件、增加输出文件、甚至可以提升 Webpack 性能、等等,总之插件通过调用 Webpack 提供的 API 能完成很多事情。 由于 Webpack 提供的 API 非常多,有很多 API 很少用的上,又加上篇幅有限,下面来介绍一些常用的 API。

读取输出资源、代码块、模块及其依赖

有些插件可能需要读取 Webpack 的处理结果,例如输出资源、代码块、模块及其依赖,以便做下一步处理。

emit 事件发生时,代表源文件的转换和组装已经完成,在这里可以读取到最终将输出的资源、代码块、模块及其依赖,并且可以修改输出资源的内容。 插件代码如下:

<p data-height="585" data-theme-id="0" data-slug-hash="RJwjPj" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="emit" class="codepen">See the Pen emit by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

监听文件变化

Webpack 会从配置的入口模块出发,依次找出所有的依赖模块,当入口模块或者其依赖的模块发生变化时, 就会触发一次新的 Compilation。

在开发插件时经常需要知道是哪个文件发生变化导致了新的 Compilation,为此可以使用如下代码:

<p data-height="255" data-theme-id="0" data-slug-hash="jKOabJ" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="Compilation" class="codepen">See the Pen Compilation by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

默认情况下 Webpack 只会监视入口和其依赖的模块是否发生变化,在有些情况下项目可能需要引入新的文件,例如引入一个 HTML 文件。 由于 JavaScript 文件不会去导入 HTML 文件,Webpack 就不会监听 HTML 文件的变化,编辑 HTML 文件时就不会重新触发新的 Compilation。 为了监听 HTML 文件的变化,我们需要把 HTML 文件加入到依赖列表中,为此可以使用如下代码:

compiler.plugin('after-compile', (compilation, callback) => {
  // 把 HTML 文件添加到文件依赖列表,好让 Webpack 去监听 HTML 模块文件,在 HTML 模版文件发生变化时重新启动一次编译
    compilation.fileDependencies.push(filePath);
    callback();
});

修改输出资源

有些场景下插件需要修改、增加、删除输出的资源,要做到这点需要监听 emit 事件,因为发生 emit 事件时所有模块的转换和代码块对应的文件已经生成好, 需要输出的资源即将输出,因此 emit 事件是修改 Webpack 输出资源的最后时机。

所有需要输出的资源会存放在 compilation.assets 中,compilation.assets 是一个键值对,键为需要输出的文件名称,值为文件对应的内容。

设置 compilation.assets 的代码如下:

compiler.plugin('emit', (compilation, callback) => {
  // 设置名称为 fileName 的输出资源
  compilation.assets[fileName] = {
    // 返回文件内容
    source: () => {
      // fileContent 既可以是代表文本文件的字符串,也可以是代表二进制文件的 Buffer
      return fileContent;
      },
    // 返回文件大小
      size: () => {
      return Buffer.byteLength(fileContent, 'utf8');
    }
  };
  callback();
});

读取 compilation.assets 的代码如下:


compiler.plugin('emit', (compilation, callback) => {
  // 读取名称为 fileName 的输出资源
  const asset = compilation.assets[fileName];
  // 获取输出资源的内容
  asset.source();
  // 获取输出资源的文件大小
  asset.size();
  callback();
});

判断 Webpack 使用了哪些插件

在开发一个插件时可能需要根据当前配置是否使用了其它某个插件而做下一步决定,因此需要读取 Webpack 当前的插件配置情况。 以判断当前是否使用了 ExtractTextPlugin 为例,可以使用如下代码:

// 判断当前配置使用使用了 ExtractTextPlugin,
// compiler 参数即为 Webpack 在 apply(compiler) 中传入的参数
function hasExtractTextPlugin(compiler) {
  // 当前配置所有使用的插件列表
  const plugins = compiler.options.plugins;
  // 去 plugins 中寻找有没有 ExtractTextPlugin 的实例
  return plugins.find(plugin=>plugin.__proto__.constructor === ExtractTextPlugin) != null;
}

实战

下面我们举一个实际的例子,带你一步步去实现一个插件。

该插件的名称取名叫 EndWebpackPlugin,作用是在 Webpack 即将退出时再附加一些额外的操作,例如在 Webpack 成功编译和输出了文件后执行发布操作把输出的文件上传到服务器。 同时该插件还能区分 Webpack 构建是否执行成功。使用该插件时方法如下:

module.exports = {
  plugins:[
    // 在初始化 EndWebpackPlugin 时传入了两个参数,分别是在成功时的回调函数和失败时的回调函数;
    new EndWebpackPlugin(() => {
      // Webpack 构建成功,并且文件输出了后会执行到这里,在这里可以做发布文件操作
    }, (err) => {
      // Webpack 构建失败,err 是导致错误的原因
      console.error(err);        
    })
  ]
}

要实现该插件,需要借助两个事件:

  • done:在成功构建并且输出了文件后,Webpack 即将退出时发生;
  • failed:在构建出现异常导致构建失败,Webpack 即将退出时发生;

实现该插件非常简单,完整代码如下:

class EndWebpackPlugin {

  constructor(doneCallback, failCallback) {
    // 存下在构造函数中传入的回调函数
    this.doneCallback = doneCallback;
    this.failCallback = failCallback;
  }

  apply(compiler) {
    compiler.plugin('done', (stats) => {
        // 在 done 事件中回调 doneCallback
        this.doneCallback(stats);
    });
    compiler.plugin('failed', (err) => {
        // 在 failed 事件中回调 failCallback
        this.failCallback(err);
    });
  }
}
// 导出插件 
module.exports = EndWebpackPlugin;

从开发这个插件可以看出,找到合适的事件点去完成功能在开发插件时显得尤为重要。 在 工作原理概括 中详细介绍过 Webpack 在运行过程中广播出常用事件,你可以从中找到你需要的事件。

调试 Webpack

在编写 Webpack 的 Plugin 和 Loader 时,可能执行结果会和你预期的不一样,就和你平时写代码遇到了奇怪的 Bug 一样。 对于无法一眼看出问题的 Bug,通常需要调试程序源码才能找出问题所在。

虽然可以通过 console.log 的方式完成调试,但这种方法非常不方便也不优雅,本节将教你如何断点调试 工作原理概括 中的插件代码。 由于 Webpack 运行在 Node.js 之上,调试 Webpack 就相对于调试 Node.js 程序。

在 Webstorm 中调试

Webstorm 集成了 Node.js 的调试工具,因此使用 Webstorm 调试 Webpack 非常简单。

1. 设置断点

在你认为可能出现问题的地方设下断点,点击编辑区代码左侧出现红点表示设置了断点。

2. 配置执行入口

告诉 Webstorm 如何启动 Webpack,由于 Webpack 实际上就是一个 Node.js 应用,因此需要新建一个 Node.js 类型的执行入口。

以上配置中有三点需要注意:

  • Name 设置成了 debug webpack,就像设置了一个别名,方便记忆和区分;
  • Working directory 设置为需要调试的插件所在的项目的根目录;
  • JavaScript file 即 Node.js 的执行入口文件,设置为 Webpack 的执行入口文件 node_modules/webpack/bin/webpack.js

3. 启动调试

经过以上两步,准备工作已经完成,下面启动调试,启动时选中前面设置的 debug webpack

4. 执行到断点

启动后程序就会停在断点所在的位置,在这里你可以方便的查看变量当前的状态,找出问题。

原理总结

Webpack 是一个庞大的 Node.js 应用,如果你阅读过它的源码,你会发现实现一个完整的 Webpack 需要编写非常多的代码。 但你无需了解所有的细节,只需了解其整体架构和部分细节即可。

对 Webpack 的使用者来说,它是一个简单强大的工具; 对 Webpack 的开发者来说,它是一个扩展性的高系统。

Webpack 之所以能成功,在于它把复杂的实现隐藏了起来,给用户暴露出的只是一个简单的工具,让用户能快速达成目的。 同时整体架构设计合理,扩展性高,开发扩展难度不高,通过社区补足了大量缺失的功能,让 Webpack 几乎能胜任任何场景。

通过本章的学习,希望你不仅能学会如何编写 Webpack 扩展,也能从中领悟到如何设计好的系统架构。

查看原文

赞 286 收藏 335 评论 21

啃先生 收藏了文章 · 2018-06-06

webpack原理

webpack原理

查看所有文档页面:前端开发文档,获取更多信息。
原文链接:webpack原理,原文广告模态框遮挡,阅读体验不好,所以整理成本文,方便查找。

工作原理概括

基本概念

在了解 Webpack 原理前,需要掌握以下几个核心概念,以方便后面的理解:

  • Entry:入口,Webpack 执行构建的第一步将从 Entry 开始,可抽象成输入。
  • Module:模块,在 Webpack 里一切皆模块,一个模块对应着一个文件。Webpack 会从配置的 Entry 开始递归找出所有依赖的模块。
  • Chunk:代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割。
  • Loader:模块转换器,用于把模块原内容按照需求转换成新内容。
  • Plugin:扩展插件,在 Webpack 构建流程中的特定时机会广播出对应的事件,插件可以监听这些事件的发生,在特定时机做对应的事情。

流程概括

Webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:

  1. 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数;
  2. 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译;
  3. 确定入口:根据配置中的 entry 找出所有的入口文件;
  4. 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;
  5. 完成模块编译:在经过第4步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系;
  6. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
  7. 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

在以上过程中,Webpack 会在特定的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。

流程细节

Webpack 的构建流程可以分为以下三大阶段:

  1. 初始化:启动构建,读取与合并配置参数,加载 Plugin,实例化 Compiler。
  2. 编译:从 Entry 发出,针对每个 Module 串行调用对应的 Loader 去翻译文件内容,再找到该 Module 依赖的 Module,递归地进行编译处理。
  3. 输出:对编译后的 Module 组合成 Chunk,把 Chunk 转换成文件,输出到文件系统。

如果只执行一次构建,以上阶段将会按照顺序各执行一次。但在开启监听模式下,流程将变为如下:

在每个大阶段中又会发生很多事件,Webpack 会把这些事件广播出来供给 Plugin 使用,下面来一一介绍。

初始化阶段

事件名解释
初始化参数从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。 这个过程中还会执行配置文件中的插件实例化语句 new Plugin()
实例化 Compiler用上一步得到的参数初始化 Compiler 实例,Compiler 负责文件监听和启动编译。Compiler 实例中包含了完整的 Webpack 配置,全局只有一个 Compiler 实例。
加载插件依次调用插件的 apply 方法,让插件可以监听后续的所有事件节点。同时给插件传入 compiler 实例的引用,以方便插件通过 compiler 调用 Webpack 提供的 API。
environment开始应用 Node.js 风格的文件系统到 compiler 对象,以方便后续的文件寻找和读取。
entry-option读取配置的 Entrys,为每个 Entry 实例化一个对应的 EntryPlugin,为后面该 Entry 的递归解析工作做准备。
after-plugins调用完所有内置的和配置的插件的 apply 方法。
after-resolvers根据配置初始化完 resolverresolver 负责在文件系统中寻找指定路径的文件。
空格空格
空格空格
空格空格

编译阶段

事件名解释
run启动一次新的编译。
watch-runrun 类似,区别在于它是在监听模式下启动的编译,在这个事件中可以获取到是哪些文件发生了变化导致重新启动一次新的编译。
compile该事件是为了告诉插件一次新的编译将要启动,同时会给插件带上 compiler 对象。
compilationWebpack 以开发模式运行时,每当检测到文件变化,一次新的 Compilation 将被创建。一个 Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。Compilation 对象也提供了很多事件回调供插件做扩展。
make一个新的 Compilation 创建完毕,即将从 Entry 开始读取文件,根据文件类型和配置的 Loader 对文件进行编译,编译完后再找出该文件依赖的文件,递归的编译和解析。
after-compile一次 Compilation 执行完成。
invalid当遇到文件不存在、文件编译错误等异常时会触发该事件,该事件不会导致 Webpack 退出。
空格空格
空格空格

在编译阶段中,最重要的要数 compilation 事件了,因为在 compilation 阶段调用了 Loader 完成了每个模块的转换操作,在 compilation 阶段又包括很多小的事件,它们分别是:

事件名解释
build-module使用对应的 Loader 去转换一个模块。
normal-module-loader在用 Loader 对一个模块转换完后,使用 acorn 解析转换后的内容,输出对应的抽象语法树(AST),以方便 Webpack 后面对代码的分析。
program从配置的入口模块开始,分析其 AST,当遇到 require 等导入其它模块语句时,便将其加入到依赖的模块列表,同时对新找出的依赖模块递归分析,最终搞清所有模块的依赖关系。
seal所有模块及其依赖的模块都通过 Loader 转换完成后,根据依赖关系开始生成 Chunk。

输出阶段

事件名解释
should-emit所有需要输出的文件已经生成好,询问插件哪些文件需要输出,哪些不需要。
emit确定好要输出哪些文件后,执行文件输出,可以在这里获取和修改输出内容。
after-emit文件输出完毕。
done成功完成一次完成的编译和输出流程。
failed如果在编译和输出流程中遇到异常导致 Webpack 退出时,就会直接跳转到本步骤,插件可以在本事件中获取到具体的错误原因。

在输出阶段已经得到了各个模块经过转换后的结果和其依赖关系,并且把相关模块组合在一起形成一个个 Chunk。 在输出阶段会根据 Chunk 的类型,使用对应的模版生成最终要要输出的文件内容。

输出文件分析

虽然在前面的章节中你学会了如何使用 Webpack ,也大致知道其工作原理,可是你想过 Webpack 输出的 bundle.js 是什么样子的吗? 为什么原来一个个的模块文件被合并成了一个单独的文件?为什么 bundle.js 能直接运行在浏览器中? 本节将解释清楚以上问题。

先来看看由 安装与使用 中最简单的项目构建出的 bundle.js 文件内容,代码如下:

<p data-height="565" data-theme-id="0" data-slug-hash="NMQzxz" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="bundle.js" class="codepen">See the Pen bundle.js by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

以上看上去复杂的代码其实是一个立即执行函数,可以简写为如下:

(function(modules) {

  // 模拟 require 语句
  function __webpack_require__() {
  }

  // 执行存放所有模块数组中的第0个模块
  __webpack_require__(0);

})([/*存放所有模块的数组*/])

bundle.js 能直接运行在浏览器中的原因在于输出的文件中通过 __webpack_require__ 函数定义了一个可以在浏览器中执行的加载函数来模拟 Node.js 中的 require 语句。

原来一个个独立的模块文件被合并到了一个单独的 bundle.js 的原因在于浏览器不能像 Node.js 那样快速地去本地加载一个个模块文件,而必须通过网络请求去加载还未得到的文件。 如果模块数量很多,加载时间会很长,因此把所有模块都存放在了数组中,执行一次网络加载。

如果仔细分析 __webpack_require__ 函数的实现,你还有发现 Webpack 做了缓存优化: 执行加载过的模块不会再执行第二次,执行结果会缓存在内存中,当某个模块第二次被访问时会直接去内存中读取被缓存的返回值。

分割代码时的输出

例如把源码中的 main.js 修改为如下:

// 异步加载 show.js
import('./show').then((show) => {
  // 执行 show 函数
  show('Webpack');
});

重新构建后会输出两个文件,分别是执行入口文件 bundle.js 和 异步加载文件 0.bundle.js

其中 0.bundle.js 内容如下:

// 加载在本文件(0.bundle.js)中包含的模块
webpackJsonp(
  // 在其它文件中存放着的模块的 ID
  [0],
  // 本文件所包含的模块
  [
    // show.js 所对应的模块
    (function (module, exports) {
      function show(content) {
        window.document.getElementById('app').innerText = 'Hello,' + content;
      }

      module.exports = show;
    })
  ]
);

bundle.js 内容如下:

<p data-height="565" data-theme-id="0" data-slug-hash="yjmRyG" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="bundle.js" class="codepen">See the Pen bundle.js by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

这里的 bundle.js 和上面所讲的 bundle.js 非常相似,区别在于:

  • 多了一个 __webpack_require__.e 用于加载被分割出去的,需要异步加载的 Chunk 对应的文件;
  • 多了一个 webpackJsonp 函数用于从异步加载的文件中安装模块。

在使用了 CommonsChunkPlugin 去提取公共代码时输出的文件和使用了异步加载时输出的文件是一样的,都会有 __webpack_require__.ewebpackJsonp。 原因在于提取公共代码和异步加载本质上都是代码分割。

编写 Loader

Loader 就像是一个翻译员,能把源文件经过转化后输出新的结果,并且一个文件还可以链式的经过多个翻译员翻译。

以处理 SCSS 文件为例:

  • SCSS 源代码会先交给 sass-loader 把 SCSS 转换成 CSS;
  • sass-loader 输出的 CSS 交给 css-loader 处理,找出 CSS 中依赖的资源、压缩 CSS 等;
  • css-loader 输出的 CSS 交给 style-loader 处理,转换成通过脚本加载的 JavaScript 代码;

可以看出以上的处理过程需要有顺序的链式执行,先 sass-loadercss-loaderstyle-loader。 以上处理的 Webpack 相关配置如下:

<p data-height="365" data-theme-id="0" data-slug-hash="YLmbeQ" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="编写 Loader" class="codepen">See the Pen 编写 Loader by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

Loader 的职责

由上面的例子可以看出:一个 Loader 的职责是单一的,只需要完成一种转换。 如果一个源文件需要经历多步转换才能正常使用,就通过多个 Loader 去转换。 在调用多个 Loader 去转换一个文件时,每个 Loader 会链式的顺序执行, 第一个 Loader 将会拿到需处理的原内容,上一个 Loader 处理后的结果会传给下一个接着处理,最后的 Loader 将处理后的最终结果返回给 Webpack。

所以,在你开发一个 Loader 时,请保持其职责的单一性,你只需关心输入和输出。

Loader 基础

由于 Webpack 是运行在 Node.js 之上的,一个 Loader 其实就是一个 Node.js 模块,这个模块需要导出一个函数。 这个导出的函数的工作就是获得处理前的原内容,对原内容执行处理后,返回处理后的内容。

一个最简单的 Loader 的源码如下:

module.exports = function(source) {
  // source 为 compiler 传递给 Loader 的一个文件的原内容
  // 该函数需要返回处理后的内容,这里简单起见,直接把原内容返回了,相当于该 Loader 没有做任何转换
  return source;
};

由于 Loader 运行在 Node.js 中,你可以调用任何 Node.js 自带的 API,或者安装第三方模块进行调用:

const sass = require('node-sass');
module.exports = function(source) {
  return sass(source);
};

Loader 进阶

以上只是个最简单的 Loader,Webpack 还提供一些 API 供 Loader 调用,下面来一一介绍。

获得 Loader 的 options

在最上面处理 SCSS 文件的 Webpack 配置中,给 css-loader 传了 options 参数,以控制 css-loader。 如何在自己编写的 Loader 中获取到用户传入的 options 呢?需要这样做:

const loaderUtils = require('loader-utils');
module.exports = function(source) {
  // 获取到用户给当前 Loader 传入的 options
  const options = loaderUtils.getOptions(this);
  return source;
};

返回其它结果

上面的 Loader 都只是返回了原内容转换后的内容,但有些场景下还需要返回除了内容之外的东西。

例如以用 babel-loader 转换 ES6 代码为例,它还需要输出转换后的 ES5 代码对应的 Source Map,以方便调试源码。 为了把 Source Map 也一起随着 ES5 代码返回给 Webpack,可以这样写:

module.exports = function(source) {
  // 通过 this.callback 告诉 Webpack 返回的结果
  this.callback(null, source, sourceMaps);
  // 当你使用 this.callback 返回内容时,该 Loader 必须返回 undefined,
  // 以让 Webpack 知道该 Loader 返回的结果在 this.callback 中,而不是 return 中 
  return;
};

其中的 this.callback 是 Webpack 给 Loader 注入的 API,以方便 Loader 和 Webpack 之间通信。 this.callback 的详细使用方法如下:

this.callback(
    // 当无法转换原内容时,给 Webpack 返回一个 Error
    err: Error | null,
    // 原内容转换后的内容
    content: string | Buffer,
    // 用于把转换后的内容得出原内容的 Source Map,方便调试
    sourceMap?: SourceMap,
    // 如果本次转换为原内容生成了 AST 语法树,可以把这个 AST 返回,
    // 以方便之后需要 AST 的 Loader 复用该 AST,以避免重复生成 AST,提升性能
    abstractSyntaxTree?: AST
);
Source Map 的生成很耗时,通常在开发环境下才会生成 Source Map,其它环境下不用生成,以加速构建。 为此 Webpack 为 Loader 提供了 this.sourceMap API 去告诉 Loader 当前构建环境下用户是否需要 Source Map。 如果你编写的 Loader 会生成 Source Map,请考虑到这点。

同步与异步

Loader 有同步和异步之分,上面介绍的 Loader 都是同步的 Loader,因为它们的转换流程都是同步的,转换完成后再返回结果。 但在有些场景下转换的步骤只能是异步完成的,例如你需要通过网络请求才能得出结果,如果采用同步的方式网络请求就会阻塞整个构建,导致构建非常缓慢。

在转换步骤是异步时,你可以这样:

module.exports = function(source) {
    // 告诉 Webpack 本次转换是异步的,Loader 会在 callback 中回调结果
    var callback = this.async();
    someAsyncOperation(source, function(err, result, sourceMaps, ast) {
        // 通过 callback 返回异步执行后的结果
        callback(err, result, sourceMaps, ast);
    });
};

处理二进制数据

在默认的情况下,Webpack 传给 Loader 的原内容都是 UTF-8 格式编码的字符串。 但有些场景下 Loader 不是处理文本文件,而是处理二进制文件,例如 file-loader,就需要 Webpack 给 Loader 传入二进制格式的数据。 为此,你需要这样编写 Loader:

module.exports = function(source) {
    // 在 exports.raw === true 时,Webpack 传给 Loader 的 source 是 Buffer 类型的
    source instanceof Buffer === true;
    // Loader 返回的类型也可以是 Buffer 类型的
    // 在 exports.raw !== true 时,Loader 也可以返回 Buffer 类型的结果
    return source;
};
// 通过 exports.raw 属性告诉 Webpack 该 Loader 是否需要二进制数据 
module.exports.raw = true;

以上代码中最关键的代码是最后一行 module.exports.raw = true;,没有该行 Loader 只能拿到字符串。

缓存加速

在有些情况下,有些转换操作需要大量计算非常耗时,如果每次构建都重新执行重复的转换操作,构建将会变得非常缓慢。 为此,Webpack 会默认缓存所有 Loader 的处理结果,也就是说在需要被处理的文件或者其依赖的文件没有发生变化时, 是不会重新调用对应的 Loader 去执行转换操作的。

如果你想让 Webpack 不缓存该 Loader 的处理结果,可以这样:

module.exports = function(source) {
  // 关闭该 Loader 的缓存功能
  this.cacheable(false);
  return source;
};

其它 Loader API

除了以上提到的在 Loader 中能调用的 Webpack API 外,还存在以下常用 API:

  • this.context:当前处理文件的所在目录,假如当前 Loader 处理的文件是 /src/main.js,则 this.context 就等于 /src
  • this.resource:当前处理文件的完整请求路径,包括 querystring,例如 /src/main.js?name=1
  • this.resourcePath:当前处理文件的路径,例如 /src/main.js
  • this.resourceQuery:当前处理文件的 querystring
  • this.target:等于 Webpack 配置中的 Target。
  • this.loadModule:但 Loader 在处理一个文件时,如果依赖其它文件的处理结果才能得出当前文件的结果时, 就可以通过 this.loadModule(request: string, callback: function(err, source, sourceMap, module)) 去获得 request 对应文件的处理结果。
  • this.resolve:像 require 语句一样获得指定文件的完整路径,使用方法为 resolve(context: string, request: string, callback: function(err, result: string))
  • this.addDependency:给当前处理文件添加其依赖的文件,以便再其依赖的文件发生变化时,会重新调用 Loader 处理该文件。使用方法为 addDependency(file: string)
  • this.addContextDependency:和 addDependency 类似,但 addContextDependency 是把整个目录加入到当前正在处理文件的依赖中。使用方法为 addContextDependency(directory: string)
  • this.clearDependencies:清除当前正在处理文件的所有依赖,使用方法为 clearDependencies()
  • this.emitFile:输出一个文件,使用方法为 emitFile(name: string, content: Buffer|string, sourceMap: {...})

加载本地 Loader

在开发 Loader 的过程中,为了测试编写的 Loader 是否能正常工作,需要把它配置到 Webpack 中后,才可能会调用该 Loader。 在前面的章节中,使用的 Loader 都是通过 Npm 安装的,要使用 Loader 时会直接使用 Loader 的名称,代码如下:

module.exports = {
  module: {
    rules: [
      {
        test: /\.css/,
        use: ['style-loader'],
      },
    ]
  },
};

如果还采取以上的方法去使用本地开发的 Loader 将会很麻烦,因为你需要确保编写的 Loader 的源码是在 node_modules 目录下。 为此你需要先把编写的 Loader 发布到 Npm 仓库后再安装到本地项目使用。

解决以上问题的便捷方法有两种,分别如下:

Npm link

Npm link 专门用于开发和调试本地 Npm 模块,能做到在不发布模块的情况下,把本地的一个正在开发的模块的源码链接到项目的 node_modules 目录下,让项目可以直接使用本地的 Npm 模块。 由于是通过软链接的方式实现的,编辑了本地的 Npm 模块代码,在项目中也能使用到编辑后的代码。

完成 Npm link 的步骤如下:

  • 确保正在开发的本地 Npm 模块(也就是正在开发的 Loader)的 package.json 已经正确配置好;
  • 在本地 Npm 模块根目录下执行 npm link,把本地模块注册到全局;
  • 在项目根目录下执行 npm link loader-name,把第2步注册到全局的本地 Npm 模块链接到项目的 node_moduels 下,其中的 loader-name 是指在第1步中的 package.json 文件中配置的模块名称。

链接好 Loader 到项目后你就可以像使用一个真正的 Npm 模块一样使用本地的 Loader 了。

ResolveLoader

ResolveLoader 用于配置 Webpack 如何寻找 Loader。 默认情况下只会去 node_modules 目录下寻找,为了让 Webpack 加载放在本地项目中的 Loader 需要修改 resolveLoader.modules

假如本地的 Loader 在项目目录中的 ./loaders/loader-name 中,则需要如下配置:


module.exports = {
  resolveLoader:{
    // 去哪些目录下寻找 Loader,有先后顺序之分
    modules: ['node_modules','./loaders/'],
  }
}

加上以上配置后, Webpack 会先去 node_modules 项目下寻找 Loader,如果找不到,会再去 ./loaders/ 目录下寻找。

实战

上面讲了许多理论,接下来从实际出发,来编写一个解决实际问题的 Loader。

该 Loader 名叫 comment-require-loader,作用是把 JavaScript 代码中的注释语法:

// @require '../style/index.css'

转换成:

require('../style/index.css');

该 Loader 的使用场景是去正确加载针对 Fis3 编写的 JavaScript,这些 JavaScript 中存在通过注释的方式加载依赖的 CSS 文件。

该 Loader 的使用方法如下:

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['comment-require-loader'],
        // 针对采用了 fis3 CSS 导入语法的 JavaScript 文件通过 comment-require-loader 去转换 
        include: [path.resolve(__dirname, 'node_modules/imui')]
      }
    ]
  }
};

该 Loader 的实现非常简单,完整代码如下:

function replace(source) {
    // 使用正则把 // @require '../style/index.css' 转换成 require('../style/index.css');  
    return source.replace(/(\/\/ *@require) +(('|").+('|")).*/, 'require($2);');
}

module.exports = function (content) {
    return replace(content);
};

编写 Plugin

Webpack 通过 Plugin 机制让其更加灵活,以适应各种应用场景。 在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。

一个最基础的 Plugin 的代码是这样的:

class BasicPlugin{
  // 在构造函数中获取用户给该插件传入的配置
  constructor(options){
  }

  // Webpack 会调用 BasicPlugin 实例的 apply 方法给插件实例传入 compiler 对象
  apply(compiler){
    compiler.plugin('compilation',function(compilation) {
    })
  }
}

// 导出 Plugin
module.exports = BasicPlugin;

在使用这个 Plugin 时,相关配置代码如下:

const BasicPlugin = require('./BasicPlugin.js');
module.export = {
  plugins:[
    new BasicPlugin(options),
  ]
}

Webpack 启动后,在读取配置的过程中会先执行 new BasicPlugin(options) 初始化一个 BasicPlugin 获得其实例。 在初始化 compiler 对象后,再调用 basicPlugin.apply(compiler) 给插件实例传入 compiler 对象。 插件实例在获取到 compiler 对象后,就可以通过 compiler.plugin(事件名称, 回调函数) 监听到 Webpack 广播出来的事件。 并且可以通过 compiler 对象去操作 Webpack。

通过以上最简单的 Plugin 相信你大概明白了 Plugin 的工作原理,但实际开发中还有很多细节需要注意,下面来详细介绍。

CompilerCompilation

在开发 Plugin 时最常用的两个对象就是 Compiler 和 Compilation,它们是 Plugin 和 Webpack 之间的桥梁。 Compiler 和 Compilation 的含义如下:

  • Compiler 对象包含了 Webpack 环境所有的的配置信息,包含 optionsloadersplugins 这些信息,这个对象在 Webpack 启动时候被实例化,它是全局唯一的,可以简单地把它理解为 Webpack 实例;
  • Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。当 Webpack 以开发模式运行时,每当检测到一个文件变化,一次新的 Compilation 将被创建。Compilation 对象也提供了很多事件回调供插件做扩展。通过 Compilation 也能读取到 Compiler 对象。

Compiler 和 Compilation 的区别在于:Compiler 代表了整个 Webpack 从启动到关闭的生命周期,而 Compilation 只是代表了一次新的编译。

事件流

Webpack 就像一条生产线,要经过一系列处理流程后才能将源文件转换成输出结果。 这条生产线上的每个处理流程的职责都是单一的,多个流程之间有存在依赖关系,只有完成当前处理后才能交给下一个流程去处理。 插件就像是一个插入到生产线中的一个功能,在特定的时机对生产线上的资源做处理。

Webpack 通过 Tapable 来组织这条复杂的生产线。 Webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条生产线中,去改变生产线的运作。 Webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。

Webpack 的事件流机制应用了观察者模式,和 Node.js 中的 EventEmitter 非常相似。Compiler 和 Compilation 都继承自 Tapable,可以直接在 Compiler 和 Compilation 对象上广播和监听事件,方法如下:

/**
* 广播出事件
* event-name 为事件名称,注意不要和现有的事件重名
* params 为附带的参数
*/
compiler.apply('event-name',params);

/**
* 监听名称为 event-name 的事件,当 event-name 事件发生时,函数就会被执行。
* 同时函数中的 params 参数为广播事件时附带的参数。
*/
compiler.plugin('event-name',function(params) {

});

同理,compilation.applycompilation.plugin 使用方法和上面一致。

在开发插件时,你可能会不知道该如何下手,因为你不知道该监听哪个事件才能完成任务。

在开发插件时,还需要注意以下两点:

  • 只要能拿到 Compiler 或 Compilation 对象,就能广播出新的事件,所以在新开发的插件中也能广播出事件,给其它插件监听使用。
  • 传给每个插件的 Compiler 和 Compilation 对象都是同一个引用。也就是说在一个插件中修改了 Compiler 或 Compilation 对象上的属性,会影响到后面的插件。
  • 有些事件是异步的,这些异步的事件会附带两个参数,第二个参数为回调函数,在插件处理完任务时需要调用回调函数通知 Webpack,才会进入下一处理流程。例如:
 compiler.plugin('emit',function(compilation, callback) {
    // 支持处理逻辑

    // 处理完毕后执行 callback 以通知 Webpack 
    // 如果不执行 callback,运行流程将会一直卡在这不往下执行 
    callback();
  });

常用 API

插件可以用来修改输出文件、增加输出文件、甚至可以提升 Webpack 性能、等等,总之插件通过调用 Webpack 提供的 API 能完成很多事情。 由于 Webpack 提供的 API 非常多,有很多 API 很少用的上,又加上篇幅有限,下面来介绍一些常用的 API。

读取输出资源、代码块、模块及其依赖

有些插件可能需要读取 Webpack 的处理结果,例如输出资源、代码块、模块及其依赖,以便做下一步处理。

emit 事件发生时,代表源文件的转换和组装已经完成,在这里可以读取到最终将输出的资源、代码块、模块及其依赖,并且可以修改输出资源的内容。 插件代码如下:

<p data-height="585" data-theme-id="0" data-slug-hash="RJwjPj" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="emit" class="codepen">See the Pen emit by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

监听文件变化

Webpack 会从配置的入口模块出发,依次找出所有的依赖模块,当入口模块或者其依赖的模块发生变化时, 就会触发一次新的 Compilation。

在开发插件时经常需要知道是哪个文件发生变化导致了新的 Compilation,为此可以使用如下代码:

<p data-height="255" data-theme-id="0" data-slug-hash="jKOabJ" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="Compilation" class="codepen">See the Pen Compilation by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

默认情况下 Webpack 只会监视入口和其依赖的模块是否发生变化,在有些情况下项目可能需要引入新的文件,例如引入一个 HTML 文件。 由于 JavaScript 文件不会去导入 HTML 文件,Webpack 就不会监听 HTML 文件的变化,编辑 HTML 文件时就不会重新触发新的 Compilation。 为了监听 HTML 文件的变化,我们需要把 HTML 文件加入到依赖列表中,为此可以使用如下代码:

compiler.plugin('after-compile', (compilation, callback) => {
  // 把 HTML 文件添加到文件依赖列表,好让 Webpack 去监听 HTML 模块文件,在 HTML 模版文件发生变化时重新启动一次编译
    compilation.fileDependencies.push(filePath);
    callback();
});

修改输出资源

有些场景下插件需要修改、增加、删除输出的资源,要做到这点需要监听 emit 事件,因为发生 emit 事件时所有模块的转换和代码块对应的文件已经生成好, 需要输出的资源即将输出,因此 emit 事件是修改 Webpack 输出资源的最后时机。

所有需要输出的资源会存放在 compilation.assets 中,compilation.assets 是一个键值对,键为需要输出的文件名称,值为文件对应的内容。

设置 compilation.assets 的代码如下:

compiler.plugin('emit', (compilation, callback) => {
  // 设置名称为 fileName 的输出资源
  compilation.assets[fileName] = {
    // 返回文件内容
    source: () => {
      // fileContent 既可以是代表文本文件的字符串,也可以是代表二进制文件的 Buffer
      return fileContent;
      },
    // 返回文件大小
      size: () => {
      return Buffer.byteLength(fileContent, 'utf8');
    }
  };
  callback();
});

读取 compilation.assets 的代码如下:


compiler.plugin('emit', (compilation, callback) => {
  // 读取名称为 fileName 的输出资源
  const asset = compilation.assets[fileName];
  // 获取输出资源的内容
  asset.source();
  // 获取输出资源的文件大小
  asset.size();
  callback();
});

判断 Webpack 使用了哪些插件

在开发一个插件时可能需要根据当前配置是否使用了其它某个插件而做下一步决定,因此需要读取 Webpack 当前的插件配置情况。 以判断当前是否使用了 ExtractTextPlugin 为例,可以使用如下代码:

// 判断当前配置使用使用了 ExtractTextPlugin,
// compiler 参数即为 Webpack 在 apply(compiler) 中传入的参数
function hasExtractTextPlugin(compiler) {
  // 当前配置所有使用的插件列表
  const plugins = compiler.options.plugins;
  // 去 plugins 中寻找有没有 ExtractTextPlugin 的实例
  return plugins.find(plugin=>plugin.__proto__.constructor === ExtractTextPlugin) != null;
}

实战

下面我们举一个实际的例子,带你一步步去实现一个插件。

该插件的名称取名叫 EndWebpackPlugin,作用是在 Webpack 即将退出时再附加一些额外的操作,例如在 Webpack 成功编译和输出了文件后执行发布操作把输出的文件上传到服务器。 同时该插件还能区分 Webpack 构建是否执行成功。使用该插件时方法如下:

module.exports = {
  plugins:[
    // 在初始化 EndWebpackPlugin 时传入了两个参数,分别是在成功时的回调函数和失败时的回调函数;
    new EndWebpackPlugin(() => {
      // Webpack 构建成功,并且文件输出了后会执行到这里,在这里可以做发布文件操作
    }, (err) => {
      // Webpack 构建失败,err 是导致错误的原因
      console.error(err);        
    })
  ]
}

要实现该插件,需要借助两个事件:

  • done:在成功构建并且输出了文件后,Webpack 即将退出时发生;
  • failed:在构建出现异常导致构建失败,Webpack 即将退出时发生;

实现该插件非常简单,完整代码如下:

class EndWebpackPlugin {

  constructor(doneCallback, failCallback) {
    // 存下在构造函数中传入的回调函数
    this.doneCallback = doneCallback;
    this.failCallback = failCallback;
  }

  apply(compiler) {
    compiler.plugin('done', (stats) => {
        // 在 done 事件中回调 doneCallback
        this.doneCallback(stats);
    });
    compiler.plugin('failed', (err) => {
        // 在 failed 事件中回调 failCallback
        this.failCallback(err);
    });
  }
}
// 导出插件 
module.exports = EndWebpackPlugin;

从开发这个插件可以看出,找到合适的事件点去完成功能在开发插件时显得尤为重要。 在 工作原理概括 中详细介绍过 Webpack 在运行过程中广播出常用事件,你可以从中找到你需要的事件。

调试 Webpack

在编写 Webpack 的 Plugin 和 Loader 时,可能执行结果会和你预期的不一样,就和你平时写代码遇到了奇怪的 Bug 一样。 对于无法一眼看出问题的 Bug,通常需要调试程序源码才能找出问题所在。

虽然可以通过 console.log 的方式完成调试,但这种方法非常不方便也不优雅,本节将教你如何断点调试 工作原理概括 中的插件代码。 由于 Webpack 运行在 Node.js 之上,调试 Webpack 就相对于调试 Node.js 程序。

在 Webstorm 中调试

Webstorm 集成了 Node.js 的调试工具,因此使用 Webstorm 调试 Webpack 非常简单。

1. 设置断点

在你认为可能出现问题的地方设下断点,点击编辑区代码左侧出现红点表示设置了断点。

2. 配置执行入口

告诉 Webstorm 如何启动 Webpack,由于 Webpack 实际上就是一个 Node.js 应用,因此需要新建一个 Node.js 类型的执行入口。

以上配置中有三点需要注意:

  • Name 设置成了 debug webpack,就像设置了一个别名,方便记忆和区分;
  • Working directory 设置为需要调试的插件所在的项目的根目录;
  • JavaScript file 即 Node.js 的执行入口文件,设置为 Webpack 的执行入口文件 node_modules/webpack/bin/webpack.js

3. 启动调试

经过以上两步,准备工作已经完成,下面启动调试,启动时选中前面设置的 debug webpack

4. 执行到断点

启动后程序就会停在断点所在的位置,在这里你可以方便的查看变量当前的状态,找出问题。

原理总结

Webpack 是一个庞大的 Node.js 应用,如果你阅读过它的源码,你会发现实现一个完整的 Webpack 需要编写非常多的代码。 但你无需了解所有的细节,只需了解其整体架构和部分细节即可。

对 Webpack 的使用者来说,它是一个简单强大的工具; 对 Webpack 的开发者来说,它是一个扩展性的高系统。

Webpack 之所以能成功,在于它把复杂的实现隐藏了起来,给用户暴露出的只是一个简单的工具,让用户能快速达成目的。 同时整体架构设计合理,扩展性高,开发扩展难度不高,通过社区补足了大量缺失的功能,让 Webpack 几乎能胜任任何场景。

通过本章的学习,希望你不仅能学会如何编写 Webpack 扩展,也能从中领悟到如何设计好的系统架构。

查看原文

啃先生 回答了问题 · 2018-04-04

js实现a(3)(4)(4)=48

  • 识别考点:明显是函数柯里化
  • 概念:函数柯里化的基本形式,就是将一个多参数的函数,转为单个参数的函数多次调用。

例如

multiply(3,4,4) 转为 multiply(3)(4)(4)
  • 解题:所以最直接的方式就是
将一个普通的多参数定义,如
function multiply(a,b,c){
    return a*b*c
}

转为单参数函数多次调用
function multiply(a){
    return function(b){
        return function(c){
            return a*b*c
        }
    }
}

使用ES6的写法就是
const multiply = a=>b=>c=>a*b*c

关注 16 回答 15

啃先生 收藏了文章 · 2018-03-26

技术周刊 Vol.10 - React Native丨Learn Once, Write Anywhere

weekly-vol010

结束了前两期的入门( Vol.8 - React,“5 分钟快速入门”)和进阶(Vol.9 - 进阶吧!React),为期一个月的 React 学习快要完成了。接下来,我们进入学习的最后一阶段 - React Native。

本期周刊重点学习 React Native,从上手到项目实践,希望本期的内容,可以让你对 React 的整体结构,达到一个全局的了解。

React Native 上手

上手一种新的技术,官方的文档 自然是最详实不过了。然而,很多时候看完官方文档,我们仍旧会在自己用的时候掉进各种各样的坑里,所以,我们精选下面这几篇文章,让你在上手 React Native 的同时尽量避免进坑。

ChanceKing - React Native 初构建之我等到花儿都谢了

喜欢 React Native,因为它改变了前端给大家的传统认知,拓展了前端的维度;因为它不仅能在 H5 的范畴里搞一搞,也可以侵占到客户端里翻云覆雨,因为它提高了前端的存在感,让人有所期盼和兴奋。本文作者将自己第一次构建 React Native 项目所踩的坑记录一下,如果你也准备上手 React Native,不妨一起跟着试一下。

听海 JamiE - React Native 基础练习指北(一)React Native 基础练习指北(二)

React Native 是如何开发 iOS APP?如果你也好奇,那就赶快准备好 Mac OSX, XCode, node 以及 npm,在终端输入 npm install -g react-native-clireact-native init AwesomeProject,从展示一张海报开始,聊聊模拟数据、渲染,通过接口获取线上数据并展示等环节。

陈学家_6174 - React-Native 之布局篇

宽度单位和像素密度、flex 布局、图片布局、绝对定位和相对定位、文本元素……详细的讲解方式,简洁的特征总结,帮你轻松搞定 React-Native 布局。

陈学家_6174 - React-Native 与 React-Web 的融合

对于 React-Native 在实际中的应用,Facebook 官方的说法是 React-Native 是为多平台提供共同的开发方式,而不是说一份代码,多处使用。为此,作者也尝试通过一个实际的例子(以 SampleApp 做一个简单 demo)探究一下共享代码的可行性。

cnsnake11 - React Native 增量升级方案

当修改了代码或者图片的时候,只要 app 使用新的 bundle 文件和 assets 文件夹,就完成了一次在线升级。本文将基于以上思路,尝试讲解增量升级的解决方案。

DesGemini - 初窥基于 react-art 库的 React Native SVG

在移动端,考虑到跨平台的需求,加之 web 端的技术积累,react-art 成为了现成绘制图形的解决方案,且添加了 iOS 和 Android 平台上对 react-art 的支持,在此,作者为诸位带来了(全球首发?=_=)入门文档。

静逸秋水 - React Native 开发小 Tips

相信好多写 React Native 的都是前端出身,当遇见问题时会习惯性从前端出发,但由于 React Native 本身的限制,并不是支持足够多的属性和样式,故作者结合自己的开发实践,将一些未来开发可能会遇见的问题做了总结,并给出一些小的代码参考,希望能帮助到你。

DesGemini - React Native 蛮荒开发生存指南

React Native 的发展可谓是大红大紫,但其文档更新速度却远远跟不上开发的速度,使得 React Native 的工程化恍若蛮荒生存。作者为某一商业项目开发 React Native App 已近半年,并将自己的踩坑和爬坑经验撰写成文,希望此份指南能为大家带来帮助。

React Native for Android

本章节选自侯医生的「React Native Android 安利」系列,教程将会更多的结合原生的安卓去讲 React Native,项目从搭建 React Native Android 环境开始,层层深入,跟着教程学习,可以熟练掌握 react-native-android 的开发。

1. 搭建 React Native Android 环境

搭建 React-Native 的文章虽然很多,但大多数都是搭建 JS 层面的,没有结合原生 Android 及其开发去讲。本文将简单介绍如何使用 Android Studio 与 React Native 搭建一个 React 的基础环境,并使用其成功的制作出了一个 hello world。

2. 创建简单 RN 应用(以 JS 角度来看 RN)

从成功制作出一个 hello world 之后,我们要探索一下如何利用 React-Native 制作出比 Hello World 复杂一点的界面,顺便一起审视一些 React Native Android 工程的目录结构。

4. RN 中使用 JS 调用 Java 代码

掌握 3. 如何控制原生 Android 的 activity 间跳转,我们将其中学到的原生知识,使用 JS 去调用。这样双剑合璧,便可以更加高效的开发 React-Native 应用啦~

6. React Native 中的 React.js 基础

很多关于 React-Native 的知识,都是有关于样式,底层,环境等知识的,现在我们来学习一下 React.js 的基础,我们的代码,我们创建的组件及其他相关知识。

8. 手机百度 feed 流的实现

经过上述几篇文章的学习,你将基本掌握了 React-Native 样式的书写与布局的方式。这一节,我们将一起来做个动手实践的例子,来模仿一下手机百度的新闻流,巩固一下自己的 React-Native 能力。

项目分享

ctriptech - React Native 实践之携程 Moles 框架

本次分享将通过对 Moles 框架的分享,介绍携程在 React Native 方面的实战干货,希望给大家一些灵感和启发。内容包括三个方面:

  • Moles 框架在 React Native 和我们主 App 的集成中起到了什么作用?

  • Moles 框架是如何打通 Android、iOS、H5、SEO,让我们一套代码跑在多个平台上?

  • Moles 框架的组成以及原理是怎样的?

静逸秋水 - 使用 React Native 制作圆形加载条

进度条的常规制作方法,可以用 canvas 去绘制图,也可以用 SVG 去绘制。如何使用 React Native 写这样的进度条呢?可以跟着作者一起来尝试一下这个进度条的完成方案。

DesGemini - Node.js + React Native 毕设:农业物联网监测系统的开发手记

该物联网监测系统整体上可分为三层:数据库层,服务器层和客户端层。数据库层除了原有的 Oracle 11d 数据库以外,还额外增加了一个 Redis 数据库。服务器层采用 Node.js 的 Express 框架作为客户端的 API 后台。客户端层绝大部分是 React Native 代码,另外采用了 Redux 来统一应用的事件分发和 UI 数据管理了。一起来感受下 react native 自带 buff 吧~

王铁手 - React-Native 初体验 - 使用 JavaScript 来写 iOS app

写过一个 Hybrid App 了,自觉不够,又心血来潮,耍起了 React Native,非常简单的入门,不知初体验的你是否和作者想一块儿去了。

(本期完)


往期周刊传送门:


# SegmentFault 技术周刊 #

「技术周刊」是社区特别推出的技术内容系列,一周一主题。周刊筛选的每篇内容,是作者的独到见解,踩坑总结和经验分享。

每周二更新,欢迎「关注」或者「订阅」。大家也可以在评论处留言自己感兴趣的主题,推荐主题相关的优秀文章。

查看原文

认证与成就

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

擅长技能
编辑

开源项目 & 著作
编辑

注册于 2016-03-02
个人主页被 1.8k 人浏览