前端森林

前端森林 查看完整档案

上海编辑  |  填写毕业院校  |  填写所在公司/组织 github.com/Cosen95 编辑
编辑

个人动态

前端森林 关注了用户 · 10月14日

阿宝哥 @angular4

http://www.semlinker.com/
聚焦全栈,专注分享 Angular、TypeScript、Node.js/Java 、Spring 技术栈等全栈干货

欢迎各位小伙伴关注本人公众号全栈修仙之路

关注 2193

前端森林 发布了文章 · 10月14日

「面试必问」leetcode高频题精选

image

引言(文末有福利)🏂

算法一直是大厂前端面试常问的一块,而大家往往准备这方面的面试都是通过leetcode刷题。

我特地整理了几道leetcode中「很有意思」而且非常「高频」的算法题目,分别给出了思路分析(带图解)和代码实现。

认真仔细的阅读完本文,相信对于你在算法方面的面试一定会有不小的帮助!🤠

两数之和 🦊

题目难度easy,涉及到的算法知识有数组、哈希表

题目描述

给定一个整数数组 nums  和一个目标值 target,请你在该数组中找出和为目标值的那两个整数,并返回他们的数组下标。

你可以假设每种输入只会对应一个答案。但是,数组中同一个元素不能使用两遍。

示例:

给定 nums = [2, 7, 11, 15], target = 9

因为 nums[0] + nums[1] = 2 + 7 = 9
所以返回 [0, 1]

思路分析

大多数同学看到这道题目,心中肯定会想:这道题目太简单了,不就两层遍历嘛:两层循环来遍历同一个数组;第一层循环遍历的值记为a,第二层循环时遍历的值记为b;若a+b = 目标值,那么ab对应的数组下标就是我们想要的答案。

这种解法没毛病,但有没有优化的方案呢?🤔

要知道两层循环很多情况下都意味着O(n^2) 的复杂度,这个复杂度非常容易导致你的算法超时。即便没有超时,在明明有一层遍历解法的情况下,你写了两层遍历,面试官也会对你的印象分大打折扣。🤒

其实我们可以在遍历数组的过程中,增加一个Map结构来存储已经遍历过的数字及其对应的索引值。然后每遍历到一个新数字的时候,都回到Map里去查询targetNum与该数的差值是否已经在前面的数字中出现过了。若出现过,那么答案已然显现,我们就不必再往下走了。

我们就以本题中的例子结合图片来说明一下上面提到的这种思路:

  • 这里用对象diffs来模拟map结构:

    首先遍历数组第一个元素,此时key为 2,value为索引 0

  • 往下遍历,遇到了 7:

    计算targetNum和 7 的差值为 2,去diffs中检索 2 这个key,发现是之前出现过的值。那么本题的答案就出来了!

代码实现

/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number[]}
 */
const twoSum = function (nums, target) {
  const diffs = {};
  // 缓存数组长度
  const len = nums.length;
  // 遍历数组
  for (let i = 0; i < len; i++) {
    // 判断当前值对应的 target 差值是否存在
    if (diffs[target - nums[i]] !== undefined) {
      // 若有对应差值,那么得到答案
      return [diffs[target - nums[i]], i];
    }
    // 若没有对应差值,则记录当前值
    diffs[nums[i]] = i;
  }
};

三数之和 🦁

题目难度medium,涉及到的算法知识有数组、双指针

题目描述

给你一个包含n个整数的数组nums,判断nums中是否存在三个元素abc ,使得a + b + c = 0。请你找出所有满足条件且不重复的三元组。

注意:答案中不可以包含重复的三元组。

示例:

给定数组 nums = [-1, 0, 1, 2, -1, -4],

满足要求的三元组集合为:
[
  [-1, 0, 1],
  [-1, -1, 2]
]

思路分析

和上面的两数之和一样,如果不认真思考,最快的方式可能就是多层遍历了。但有了前车之鉴,我们同样可以把求和问题变为求差问题:固定其中一个数,在剩下的数中寻找是否有两个数的和这个固定数相加是等于 0 的。

这里我们采用双指针法来解决问题,相比三层循环,效率会大大提升。

双指针法的适用范围比较广,一般像求和、比大小的都可以用它来解决。但是有一个前提:数组必须有序

因此我们的第一步就是先将数组进行排序:

// 给 nums 排序
nums = nums.sort((a, b) => {
  return a - b;
});

然后对数组进行遍历,每遍历到哪个数字,就固定当前的数字。同时左指针指向该数字后面的紧邻的那个数字,右指针指向数组末尾。然后左右指针分别向中间靠拢:

每次指针移动一次位置,就计算一下两个指针指向数字之和加上固定的那个数之后,是否等于 0。如果是,那么我们就得到了一个目标组合;否则,分两种情况来看:

  • 相加之和大于 0,说明右侧的数偏大了,右指针左移
  • 相加之和小于 0,说明左侧的数偏小了,左指针右移

代码实现

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
const threeSum = function (nums) {
  // 用于存放结果数组
  let res = [];
  // 目标值为0
  let sum = 0;
  // 给 nums 排序
  nums = nums.sort((a, b) => {
    return a - b;
  });
  // 缓存数组长度
  const len = nums.length;
  for (let i = 0; i < len - 2; i++) {
    // 左指针 j
    let j = i + 1;
    // 右指针k
    let k = len - 1;
    // 如果遇到重复的数字,则跳过
    if (i > 0 && nums[i] === nums[i - 1]) {
      continue;
    }
    while (j < k) {
      // 三数之和小于0,左指针前进
      if (nums[i] + nums[j] + nums[k] < 0) {
        j++;
        // 处理左指针元素重复的情况
        while (j < k && nums[j] === nums[j - 1]) {
          j++;
        }
      } else if (nums[i] + nums[j] + nums[k] > 0) {
        // 三数之和大于0,右指针后退
        k--;

        // 处理右指针元素重复的情况
        while (j < k && nums[k] === nums[k + 1]) {
          k--;
        }
      } else {
        // 得到目标数字组合,推入结果数组
        res.push([nums[i], nums[j], nums[k]]);

        // 左右指针一起前进
        j++;
        k--;

        // 若左指针元素重复,跳过
        while (j < k && nums[j] === nums[j - 1]) {
          j++;
        }

        // 若右指针元素重复,跳过
        while (j < k && nums[k] === nums[k + 1]) {
          k--;
        }
      }
    }
  }

  // 返回结果数组
  return res;
};

盛最多水的容器 🥃

题目难度medium,涉及到的算法知识有数组、双指针

题目描述

给你 n 个非负整数 a1,a2,...,an,每个数代表坐标中的一个点  (i, ai) 。在坐标内画 n 条垂直线,垂直线 i  的两个端点分别为  (i, ai) 和 (i, 0)。找出其中的两条线,使得它们与  x  轴共同构成的容器可以容纳最多的水。

说明:你不能倾斜容器,且  n  的值至少为 2。

图中垂直线代表输入数组[1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。

示例:

输入:[1,8,6,2,5,4,8,3,7]
输出:49

思路分析

首先,我们能快速想到的一种方法:两两进行求解,计算可以承载的水量。 然后不断更新最大值,最后返回最大值即可。

这种解法,需要两层循环,时间复杂度是O(n^2)。这种相对来说比较暴力,对应就是暴力法

暴力法

/**
 * @param {number[]} height
 * @return {number}
 */
var maxArea = function (height) {
  let max = 0;
  for (let i = 0; i < height.length - 1; i++) {
    for (let j = i + 1; j < height.length; j++) {
      let area = (j - i) * Math.min(height[i], height[j]);
      max = Math.max(max, area);
    }
  }

  return max;
};

那么有没有更好的办法呢?答案是肯定有。

其实有点类似双指针的概念,左指针指向下标 0,右指针指向length-1。然后分别从左右两侧向中间移动,每次取小的那个值(因为水的高度肯定是以小的那个为准)。

如果左侧小于右侧,则i++,否则j--(这一步其实就是取所有高度中比较高的,我们知道面积等于长*宽)。对应就是双指针 动态滑窗

双指针 动态滑窗

/**
 * @param {number[]} height
 * @return {number}
 */
var maxArea = function (height) {
  let max = 0;
  let i = 0;
  let j = height.length - 1;
  while (i < j) {
    let minHeight = Math.min(height[i], height[j]);
    let area = (j - i) * minHeight;
    max = Math.max(max, area);
    if (height[i] < height[j]) {
      i++;
    } else {
      j--;
    }
  }
  return max;
};

爬楼梯 🎢

题目难度easy,涉及到的算法知识有斐波那契数列、动态规划。

题目描述

假设你正在爬楼梯。需要 n  阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

注意:给定 n 是一个正整数。

示例 1:

输入: 2
输出: 2
解释: 有两种方法可以爬到楼顶。
1.  1 阶 + 1 阶
2.  2 阶

示例 2:

输入: 3
输出: 3
解释: 有三种方法可以爬到楼顶。
1.  1 阶 + 1 阶 + 1 阶
2.  1 阶 + 2 阶
3.  2 阶 + 1 阶

思路分析

这道题目是一道非常高频的面试题目,也是一道非常经典的斐波那契数列类型的题目。

解决本道题目我们会用到动态规划的算法思想-可以分成多个子问题,爬第 n 阶楼梯的方法数量,等于 2 部分之和:

  • 爬上n−1阶楼梯的方法数量。因为再爬 1 阶就能到第 n 阶
  • 爬上n−2阶楼梯的方法数量,因为再爬 2 阶就能到第 n 阶

可以得到公式:

climbs[n] = climbs[n - 1] + climbs[n - 2];

同时需要做如下初始化:

climbs[0] = 1;
climbs[1] = 1;

代码实现

/**
 * @param {number} n
 * @return {number}
 */
var climbStairs = function (n) {
  let climbs = [];
  climbs[0] = 1;
  climbs[1] = 1;
  for (let i = 2; i <= n; i++) {
    climbs[i] = climbs[i - 1] + climbs[i - 2];
  }
  return climbs[n];
};

环形链表 🍩

题目难度easy,涉及到的算法知识有链表、快慢指针。

题目描述

给定一个链表,判断链表中是否有环。

为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。

示例 1:

输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。

示例 2:

输入:head = [1,2], pos = 0
输出:true
解释:链表中有一个环,其尾部连接到第一个节点。

示例 3:

输入:head = [1], pos = -1
输出:false
解释:链表中没有环。

思路分析

链表成环问题也是非常经典的算法问题,在面试中也经常会遇到。

解决这种问题一般有常见的两种方法:标志法快慢指针法

标志法

给每个已遍历过的节点加标志位,遍历链表,当出现下一个节点已被标志时,则证明单链表有环。

/**
 * Definition for singly-linked list.
 * function ListNode(val) {
 *     this.val = val;
 *     this.next = null;
 * }
 */

/**
 * @param {ListNode} head
 * @return {boolean}
 */
var hasCycle = function (head) {
  while (head) {
    if (head.flag) return true;
    head.flag = true;
    head = head.next;
  }
  return false;
};

快慢指针(双指针法)

设置快慢两个指针,遍历单链表,快指针一次走两步,慢指针一次走一步,如果单链表中存在环,则快慢指针终会指向同一个节点,否则直到快指针指向null时,快慢指针都不可能相遇。

/**
 * Definition for singly-linked list.
 * function ListNode(val) {
 *     this.val = val;
 *     this.next = null;
 * }
 */

/**
 * @param {ListNode} head
 * @return {boolean}
 */
var hasCycle = function (head) {
  if (!head || !head.next) {
    return false;
  }
  let slow = head,
    fast = head.next;
  while (slow !== fast) {
    if (!fast || !fast.next) return false;
    fast = fast.next.next;
    slow = slow.next;
  }
  return true;
};

有效的括号 🍉

题目难度easy,涉及到的算法知识有栈、哈希表。

题目描述

给定一个只包括'('')''{''}''['']'  的字符串,判断字符串是否有效。

有效字符串需满足:

1、左括号必须用相同类型的右括号闭合。
2、左括号必须以正确的顺序闭合。

注意空字符串可被认为是有效字符串。

示例 1:

输入: "()";
输出: true;

示例  2:

输入: "()[]{}";
输出: true;

示例  3:

输入: "(]";
输出: false;

示例  4:

输入: "([)]";
输出: false;

示例  5:

输入: "{[]}";
输出: true;

思路分析

这道题可以利用结构。

思路大概是:遇到左括号,一律推入栈中,遇到右括号,将栈顶部元素拿出,如果不匹配则返回 false,如果匹配则继续循环。

第一种解法是利用switch case

switch case

/**
 * @param {string} s
 * @return {boolean}
 */
var isValid = function (s) {
  let arr = [];
  let len = s.length;
  if (len % 2 !== 0) return false;
  for (let i = 0; i < len; i++) {
    let letter = s[i];
    switch (letter) {
      case "(": {
        arr.push(letter);
        break;
      }
      case "{": {
        arr.push(letter);
        break;
      }
      case "[": {
        arr.push(letter);
        break;
      }
      case ")": {
        if (arr.pop() !== "(") return false;
        break;
      }
      case "}": {
        if (arr.pop() !== "{") return false;
        break;
      }
      case "]": {
        if (arr.pop() !== "[") return false;
        break;
      }
    }
  }
  return !arr.length;
};

第二种是维护一个map对象:

哈希表map

/**
 * @param {string} s
 * @return {boolean}
 */
var isValid = function (s) {
  let map = {
    "(": ")",
    "{": "}",
    "[": "]",
  };
  let stack = [];
  let len = s.length;
  if (len % 2 !== 0) return false;
  for (let i of s) {
    if (i in map) {
      stack.push(i);
    } else {
      if (i !== map[stack.pop()]) return false;
    }
  }
  return !stack.length;
};

滑动窗口最大值 ⛵

题目难度hard,涉及到的算法知识有双端队列。

题目描述

给定一个数组 nums,有一个大小为  k  的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k  个数字。滑动窗口每次只向右移动一位。

返回滑动窗口中的最大值。

进阶:你能在线性时间复杂度内解决此题吗?

示例:

输入: nums = [1,3,-1,-3,5,3,6,7], 和 k = 3
输出: [3,3,5,5,6,7]
解释:

  滑动窗口的位置                最大值
---------------               -----
[1  3  -1] -3  5  3  6  7       3
 1 [3  -1  -3] 5  3  6  7       3
 1  3 [-1  -3  5] 3  6  7       5
 1  3  -1 [-3  5  3] 6  7       5
 1  3  -1  -3 [5  3  6] 7       6
 1  3  -1  -3  5 [3  6  7]      7

提示:

  • 1 <= nums.length <= 10^5
  • -10^4 <= nums[i] <= 10^4
  • 1 <= k <= nums.length

思路分析

暴力求解

第一种方法,比较简单。也是大多数同学很快就能想到的方法。

  • 遍历数组
  • 依次遍历每个区间内的最大值,放入数组中
/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number[]}
 */
var maxSlidingWindow = function (nums, k) {
  let len = nums.length;
  if (len === 0) return [];
  if (k === 1) return nums;
  let resArr = [];
  for (let i = 0; i <= len - k; i++) {
    let max = Number.MIN_SAFE_INTEGER;
    for (let j = i; j < i + k; j++) {
      max = Math.max(max, nums[j]);
    }
    resArr.push(max);
  }
  return resArr;
};

双端队列

这道题还可以用双端队列去解决,核心在于在窗口发生移动时,只根据发生变化的元素对最大值进行更新。

结合上面动图(图片来源)我们梳理下思路:

  • 检查队尾元素,看是不是都满足大于等于当前元素的条件。如果是的话,直接将当前元素入队。否则,将队尾元素逐个出队、直到队尾元素大于等于当前元素为止。(这一步是为了维持队列的递减性:确保队头元素是当前滑动窗口的最大值。这样我们每次取最大值时,直接取队头元素即可。)
  • 将当前元素入队
  • 检查队头元素,看队头元素是否已经被排除在滑动窗口的范围之外了。如果是,则将队头元素出队。(这一步是维持队列的有效性:确保队列里所有的元素都在滑动窗口圈定的范围以内。)
  • 排除掉滑动窗口还没有初始化完成、第一个最大值还没有出现的特殊情况。
/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number[]}
 */
var maxSlidingWindow = function (nums, k) {
  // 缓存数组的长度
  const len = nums.length;
  const res = [];
  const deque = [];
  for (let i = 0; i < len; i++) {
    // 队尾元素小于当前元素
    while (deque.length && nums[deque[deque.length - 1]] < nums[i]) {
      deque.pop();
    }
    deque.push(i);

    // 当队头元素的索引已经被排除在滑动窗口之外时
    while (deque.length && deque[0] <= i - k) {
      // 队头元素出对
      deque.shift();
    }
    if (i >= k - 1) {
      res.push(nums[deque[0]]);
    }
  }
  return res;
};

每日温度 🌡

题目难度medium,涉及到的算法知识有栈。

题目描述

根据每日气温列表,请重新生成一个列表,对应位置的输出是需要再等待多久温度才会升高超过该日的天数。如果之后都不会升高,请在该位置用  0 来代替。

例如,给定一个列表  temperatures = [73, 74, 75, 71, 69, 72, 76, 73],你的输出应该是  [1, 1, 4, 2, 1, 1, 0, 0]。

提示:气温列表长度的范围是  [1, 30000]。每个气温的值的均为华氏度,都是在  [30, 100]  范围内的整数。

思路分析

看到这道题,大家很容易就会想到暴力遍历法:直接两层遍历,第一层定位一个温度,第二层定位离这个温度最近的一次升温是哪天,然后求出两个温度对应索引的差值即可。

然而这种解法需要两层遍历,时间复杂度是O(n^2),显然不是最优解法。

本道题目可以采用栈去做一个优化。

大概思路就是:维护一个递减栈。当遍历过的温度,维持的是一个单调递减的态势时,我们就对这些温度的索引下标执行入栈操作;只要出现了一个数字,它打破了这种单调递减的趋势,也就是说它比前一个温度值高,这时我们就对前后两个温度的索引下标求差,得出前一个温度距离第一次升温的目标差值。

代码实现

/**
 * @param {number[]} T
 * @return {number[]}
 */
var dailyTemperatures = function (T) {
  const len = T.length;
  const stack = [];
  const res = new Array(len).fill(0);
  for (let i = 0; i < len; i++) {
    while (stack.length && T[i] > T[stack[stack.length - 1]]) {
      const top = stack.pop();
      res[top] = i - top;
    }
    stack.push(i);
  }
  return res;
};

括号生成 🎯

题目难度medium,涉及到的算法知识有递归、回溯。

题目描述

数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。

示例:

输入:n = 3
输出:[
       "((()))",
       "(()())",
       "(())()",
       "()(())",
       "()()()"
     ]

思路分析

这道题目通过递归去实现。

因为左右括号需要匹配、闭合。所以对应“(”和“)”的数量都是n,当满足这个条件时,一次递归就结束,将对应值放入结果数组中。

这里有一个潜在的限制条件:有效的括号组合。对应逻辑就是在往每个位置去放入“(”或“)”前:

  • 需要判断“(”的数量是否小于 n
  • “)”的数量是否小于“(”

代码实现

/**
 * @param {number} n
 * @return {string[]}
 */
var generateParenthesis = function (n) {
  let res = [];
  const generate = (cur, left, right) => {
    if (left === n && right === n) {
      res.push(cur);
      return;
    }
    if (left < n) {
      generate(cur + "(", left + 1, right);
    }
    if (right < left) {
      generate(cur + ")", left, right + 1);
    }
  };
  generate("", 0, 0);
  return res;
};

电话号码的字母组合 🎨

题目难度medium,涉及到的算法知识有递归、回溯。

题目描述

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

示例:

输入:"23"
输出:["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"].

思路分析

首先用一个对象map存储数字与字母的映射关系,接下来遍历对应的字符串,第一次将字符串存在结果数组result中,第二次及以后的就双层遍历生成新的字符串数组。

代码实现

哈希映射 逐层遍历

/**
 * @param {string} digits
 * @return {string[]}
 */
var letterCombinations = function (digits) {
  let res = [];
  if (digits.length === 0) return [];
  let map = {
    2: "abc",
    3: "def",
    4: "ghi",
    5: "jkl",
    6: "mno",
    7: "pqrs",
    8: "tuv",
    9: "wxyz",
  };
  for (let num of digits) {
    let chars = map[num];
    if (res.length > 0) {
      let temp = [];
      for (let char of chars) {
        for (let oldStr of res) {
          temp.push(oldStr + char);
        }
      }
      res = temp;
    } else {
      res.push(...chars);
    }
  }
  return res;
};

递归

/**
 * @param {string} digits
 * @return {string[]}
 */
var letterCombinations = function (digits) {
  let res = [];
  if (!digits) return [];
  let map = {
    2: "abc",
    3: "def",
    4: "ghi",
    5: "jkl",
    6: "mno",
    7: "pqrs",
    8: "tuv",
    9: "wxyz",
  };
  function generate(i, str) {
    let len = digits.length;
    if (i === len) {
      res.push(str);
      return;
    }
    let chars = map[digits[i]];
    for (let j = 0; j < chars.length; j++) {
      generate(i + 1, str + chars[j]);
    }
  }
  generate(0, "");
  return res;
};

岛屿数量 🏝

题目难度medium,涉及到的算法知识有 DFS(深度优先搜索)。

题目描述

给你一个由  '1'(陆地)和 '0'(水)组成的的二维网格,请你计算网格中岛屿的数量。

岛屿总是被水包围,并且每座岛屿只能由水平方向或竖直方向上相邻的陆地连接形成。

此外,你可以假设该网格的四条边均被水包围。

示例 1:

输入: 11110;
11010;
11000;
00000;
输出: 1;

示例  2:

输入:
11000
11000
00100
00011
输出: 3
解释: 每座岛屿只能由水平和/或竖直方向上相邻的陆地连接而成。

思路分析

如上图,我们需要计算的就是图中相连(只能是水平和/或竖直方向上相邻)的绿色岛屿的数量。

这道题目一个经典的做法是沉岛,大致思路是:采用DFS(深度优先搜索),遇到 1 的就将当前的 1 变为 0,并将当前坐标的上下左右都执行 dfs,并计数。

终止条件是:超出二维数组的边界或者是遇到 0 ,直接返回。

代码实现

/**
 * @param {character[][]} grid
 * @return {number}
 */
var numIslands = function (grid) {
  const rows = grid.length;
  if (rows === 0) return 0;
  const cols = grid[0].length;
  let res = 0;
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      if (grid[i][j] === "1") {
        helper(grid, i, j, rows, cols);
        res++;
      }
    }
  }
  return res;
};
function helper(grid, i, j, rows, cols) {
  if (i < 0 || j < 0 || i > rows - 1 || j > cols - 1 || grid[i][j] === "0")
    return;

  grid[i][j] = "0";

  helper(grid, i + 1, j, rows, cols);
  helper(grid, i, j + 1, rows, cols);
  helper(grid, i - 1, j, rows, cols);
  helper(grid, i, j - 1, rows, cols);
}

分发饼干 🍪

题目难度easy,涉及到的算法知识有贪心算法。

题目描述

假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。对每个孩子 i ,都有一个胃口值  gi ,这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j ,都有一个尺寸 sj 。如果 sj >= gi ,我们可以将这个饼干 j 分配给孩子 i ,这个孩子会得到满足。你的目标是尽可能满足越多数量的孩子,并输出这个最大数值。

注意:

你可以假设胃口值为正。
一个小朋友最多只能拥有一块饼干。

示例  1:

输入: [1,2,3], [1,1]

输出: 1

解释:
你有三个孩子和两块小饼干,3个孩子的胃口值分别是:1,2,3。
虽然你有两块小饼干,由于他们的尺寸都是1,你只能让胃口值是1的孩子满足。
所以你应该输出1。

示例  2:

输入: [1,2], [1,2,3]

输出: 2

解释:
你有两个孩子和三块小饼干,2个孩子的胃口值分别是1,2。
你拥有的饼干数量和尺寸都足以让所有孩子满足。
所以你应该输出2.

思路分析

这道题目是一道典型的贪心算法类。解题思路大概如下:

  • 优先满足胃口小的小朋友的需求
  • 设最大可满足的孩子数量为maxNum = 0
  • 胃口小的拿小的,胃口大的拿大的
  • 两边升序,然后一一对比

    • 饼干j >= 胃口i 时,i++j++maxNum++
    • 饼干j < 胃口i时,说明饼干不够吃,换更大的,j++
  • 到边界后停止

代码实现

/**
 * @param {number[]} g
 * @param {number[]} s
 * @return {number}
 */
var findContentChildren = function (g, s) {
  g = g.sort((a, b) => a - b);
  s = s.sort((a, b) => a - b);
  let gLen = g.length,
    sLen = s.length,
    i = 0,
    j = 0,
    maxNum = 0;
  while (i < gLen && j < sLen) {
    if (s[j] >= g[i]) {
      i++;
      maxNum++;
    }
    j++;
  }
  return maxNum;
};

买卖股票的最佳时机 II 🚁

题目难度easy,涉及到的算法知识有动态规划、贪心算法。

题目描述

给定一个数组,它的第  i 个元素是一支给定股票第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。

注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:

输入: [7,1,5,3,6,4]
输出: 7
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
     随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6-3 = 3 。

示例 2:

输入: [1,2,3,4,5]
输出: 4
解释: 在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
     注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。
     因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。

示例  3:

输入: [7,6,4,3,1]
输出: 0
解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。

提示:

  • 1 <= prices.length <= 3 * 10 ^ 4
  • 0 <= prices[i] <= 10 ^ 4

思路分析

其实这道题目思路也比较简单:

  • 维护一个变量profit用来存储利润
  • 因为可以多次买卖,那么就要后面的价格比前面的大,那么就可以进行买卖
  • 因此,只要prices[i+1] > prices[i],那么就去叠加profit
  • 遍历完成得到的profit就是获取的最大利润

代码实现

/**
 * @param {number[]} prices
 * @return {number}
 */
var maxProfit = function (prices) {
  let profit = 0;
  for (let i = 0; i < prices.length - 1; i++) {
    if (prices[i + 1] > prices[i]) profit += prices[i + 1] - prices[i];
  }
  return profit;
};

不同路径 🛣

题目难度medium,涉及到的算法知识有动态规划。

题目描述

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。

问总共有多少条不同的路径?

例如,上图是一个 7 x 3 的网格。有多少可能的路径?

示例  1:

输入: m = 3, n = 2
输出: 3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。
1. 向右 -> 向右 -> 向下
2. 向右 -> 向下 -> 向右
3. 向下 -> 向右 -> 向右

示例  2:

输入: (m = 7), (n = 3);
输出: 28;

思路分析

由题可知:机器人只能向右或向下移动一步,那么从左上角到右下角的走法 = 从右边开始走的路径总数+从下边开始走的路径总数。

所以可推出动态方程为:dp[i][j] = dp[i-1][j]+dp[i][j-1]

代码实现

这里采用Array(m).fill(Array(n).fill(1))进行了初始化,因为每一格至少有一种走法。
/**
 * @param {number} m
 * @param {number} n
 * @return {number}
 */
var uniquePaths = function (m, n) {
  let dp = Array(m).fill(Array(n).fill(1));
  for (let i = 1; i < m; i++) {
    for (let j = 1; j < n; j++) {
      dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
    }
  }
  return dp[m - 1][n - 1];
};

零钱兑换 💰

题目难度medium,涉及到的算法知识有动态规划。

题目描述

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回  -1。

示例  1:

输入: (coins = [1, 2, 5]), (amount = 11);
输出: 3;
解释: 11 = 5 + 5 + 1;

示例 2:

输入: (coins = [2]), (amount = 3);
输出: -1;

说明:
你可以认为每种硬币的数量是无限的。

思路分析

这道题目我们同样采用动态规划来解决。

假设给出的不同面额的硬币是[1, 2, 5],目标是 60,问最少需要的硬币个数?

我们需要先分解子问题,分层级找最优子结构。

dp[i]: 表示总金额为 i 的时候最优解法的硬币数

我们想一下:求总金额 60 有几种方法?一共有 3 种方式,因为我们有 3 种不同面值的硬币。

  • 拿一枚面值为 1 的硬币 + 总金额为 59 的最优解法的硬币数量。即:dp[59] + 1
  • 拿一枚面值为 2 的硬币 + 总金额为 58 的最优解法的硬币数。即:dp[58] + 1
  • 拿一枚面值为 5 的硬币 + 总金额为 55 的最优解法的硬币数。即:dp[55] + 1

所以,总金额为 60 的最优解法就是上面这三种解法中最优的一种,也就是硬币数最少的一种,我们下面用代码来表示一下:

dp[60] = Math.min(dp[59] + 1, dp[58] + 1, dp[55] + 1);

推导出状态转移方程

dp[i] = Math.min(dp[i - coin] + 1, dp[i - coin] + 1, ...)
其中 coin 有多少种可能,我们就需要比较多少次,遍历 coins 数组,分别去对比即可

代码实现

/**
 * @param {number[]} coins
 * @param {number} amount
 * @return {number}
 */
var coinChange = function (coins, amount) {
  let dp = new Array(amount + 1).fill(Infinity);
  dp[0] = 0;
  for (let i = 0; i <= amount; i++) {
    for (let coin of coins) {
      if (i - coin >= 0) {
        dp[i] = Math.min(dp[i], dp[i - coin] + 1);
      }
    }
  }
  return dp[amount] === Infinity ? -1 : dp[amount];
};

福利

大多数前端同学对于算法的系统学习,其实是比较茫然的,这里我整理了一张思维导图,算是比较全面的概括了前端算法体系。

另外我还维护了一个github仓库:https://github.com/Cosen95/js_algorithm,里面包含了大量的leetcode题解,并且还在不断更新中,感觉不错的给个star哈!🤗

❤️ 爱心三连击

1.如果觉得这篇文章还不错,来个分享、点赞、在看三连吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。

查看原文

赞 16 收藏 12 评论 0

前端森林 发布了文章 · 10月12日

JavaScript中的这些骚操作,你都知道吗?

image

引言 🏂

写这篇文章的缘由是上周在公司前端团队的code review时,看了一个实习小哥哥的代码后,感觉一些刚入行不久的同学,对于真实项目中的一些js处理不是很熟练,缺乏一些技巧。

因此整理了自己开发中常用的一些js技巧,灵活的运用,会增强你解决问题的能力,也会对你的代码简洁性有很大的改观。

数组去重 🐻

正常我们实现数组去重大多都是通过双层遍历或者indexOf的方式。

双层for循环去重

function unique(arr) {
  for (var i = 0; i < arr.length; i++) {
    for (var j = i + 1; j < arr.length; j++) {
      if (arr[i] == arr[j]) {
        arr.splice(j, 1);
        j--;
      }
    }
  }
  return arr;
}

利用indexOf去重

function unique(arr) {
  if (!Array.isArray(arr)) {
    console.log("type error!");
    return;
  }
  var array = [];
  for (var i = 0; i < arr.length; i++) {
    if (array.indexOf(arr[i]) === -1) {
      array.push(arr[i]);
    }
  }
  return array;
}

但其实有一种更简单的方式:利用Array.fromset去重

function unique(arr) {
  if (!Array.isArray(arr)) {
    console.log("type error!");
    return;
  }
  return Array.from(new Set(arr));
}

这种代码的实现是不是很简洁 😉

数组转化为对象(Array to Object)🦑

数组转化为对象,大多数同学首先想到的就是这种方法:

var obj = {};
var arr = ["1","2","3"];
for (var key in arr) {
    obj[key] = arr[key];
}
console.log(obj)

Output:
{0: 1, 1: 2, 2: 3}

但是有一种比较简单快速的方法:

const arr = [1,2,3]
const obj = {...arr}
console.log(obj)

Output:
{0: 1, 1: 2, 2: 3}

一行代码就能搞定的事情为什么还要用遍历呢?😛

合理利用三元表达式 👩‍👦‍👦

有些场景我们需要针对不同的条件,给变量赋予不同的值,我们往往会采用下面这种方式:

const isGood = true;
let feeling;
if (isGood) {
  feeling = 'good'
} else {
  feeling = 'bad'
}
console.log(`I feel ${feeling}`)

Output:
I feel good

但是为什么不采用三元表达式呢?

const isGood = true;
const feeling = isGood ? 'good' : 'bad'
console.log(`I feel ${feeling}`)

Output:
I feel good

这种也就是所谓的Single line(单行)思想,其实就是代码趋向于简洁性

转换为数字类型(Convert to Number)🔢

这种是很常见的,大家用的比较多的可能是parseInt()Number()这种:

const age = "69";
const ageConvert = parseInt(age);
console.log(typeof ageConvert);

Output: number;

其实也可以通过+来实现转换:

const age = "69";
const ageConvert = +age;
console.log(typeof ageConvert);

Output: number;

转换为字符串类型(Convert to String)🔡

转换为字符串一般会用toString()String()实现:

let a = 123;

a.toString(); // '123'

但也可以通过value + ""这种来实现:

let a = 123;

a + ""; // '123'

性能追踪 🥇

如果你想测试一段js代码的执行耗时,那么你可以尝试下performance

let start = performance.now();
let sum = 0;
for (let i = 0; i < 100000; i++) {
  sum += 1;
}
let end = performance.now();
console.log(start);
console.log(end);

合并对象(Combining Objects)🌊

两个对象合并大家用的比较多的可能就是Object.assign了:

const obj1 = { a: 1 }
const obj2 = { b: 2 }
console.log(Object.assign(obj1, obj2))

Output:
{ a: 1, b: 2 }

其实有一种更简洁的方式:

const obj1 = { a: 1 }
const obj2 = { b: 2 }
const combinObj = { ...obj1, ...obj2 }
console.log(combinObj)

Output:
{ a: 1, b: 2 }

也就是通过展开操作符(spread operator)来实现。

短路运算(Short-circuit evaluation) 🥅

我们可以通过&&||来简化我们的代码,比如:

if (isOnline) {
  postMessage();
}
// 使用&&
isOnline && postMessage();

// 使用||
let name = null || "森林";

数组扁平化(Flattening an array)🍓

数组的扁平化,我们一般会用递归reduce去实现

递归

var arr = [1, [2, [3, 4]]];

function flatten(arr) {
  var result = [];
  for (var i = 0, len = arr.length; i < len; i++) {
    if (Array.isArray(arr[i])) {
      result = result.concat(flatten(arr[i]));
    } else {
      result.push(arr[i]);
    }
  }
  return result;
}

console.log(flatten(arr));

reduce

var arr = [1, [2, [3, 4]]];

function flatten(arr) {
  return arr.reduce(function (prev, next) {
    return prev.concat(Array.isArray(next) ? flatten(next) : next);
  }, []);
}

console.log(flatten(arr));

但是es6提供了一个新方法 flat(depth),参数depth,代表展开嵌套数组的深度,默认是1

let arr = [1, [2, 3, [4, [5]]]];
arr.flat(3); // [1,2,3,4,5]

求幂运算 🍜

平时我们实现指数运算,用的比较多的应该是Math.pow(),比如求2^10

console.log(Math.pow(2, 10));

ES7中引入了指数运算符****具有与Math.pow()一样的计算结果。

console.log(2 ** 10); // 输出1024

浮点数转为整数(Float to Integer)🦊

我们一般将浮点数转化为整数会用到Math.floor()Math.ceil()Math.round()。但其实有一个更快的方式:

console.log(~~6.95); // 6
console.log(6.95 >> 0); // 6
console.log(6.95 << 0); // 6
console.log(6.95 | 0); // 6
// >>>不可对负数取整
console.log(6.95 >>> 0); // 6

也就是使用~, >>, <<, >>>, |这些位运算符来实现取整

截断数组

如果你有修改数组长度为某固定值的需求,那么你可以试试这个
let array = [0, 1, 2, 3, 4, 5];
array.length = 3;
console.log(array);

Output: [0, 1, 2];

获取数组中的最后一项 🦁

通常,获取数组最后一项,我们用的比较多的是:

let arr = [0, 1, 2, 3, 4, 5];
const last = arr[arr.length - 1];
console.log(last);

Output: 5;

但我们也可以通过slice操作来实现:

let arr = [0, 1, 2, 3, 4, 5];
const last = arr.slice(-1)[0];
console.log(last);

Output: 5;

美化你的JSON 💄

日常开发中,我们会经常用到JSON.stringify,但大家可能并不大清楚他具体有哪些参数。

他有三个参数:

  • json: 必须,可以是数组或Object
  • replacer: 可选值,可以是数组,也可以是方法
  • space: 用什么来进行分隔

而我们恰恰可以指定第三个参数space的值去美化我们的JSON

Object.create(null) 🐶

VueVuex的源码中,作者都使用了Object.create(null)来初始化一个新对象。为什么不用更简洁的{}呢?
我们来看下Object.create()的定义:
Object.create(proto, [propertiesObject]);
  • proto:新创建对象的原型对象
  • propertiesObject:可选。要添加到新对象的可枚举(新添加的属性是其自身的属性,而不是其原型链上的属性)的属性。

我们对比分别通过Object.create(null){}创建对象的不同:

从上图可以看到,通过{}创建的对象继承了Object自身的方法,如hasOwnPropertytoString等,在新对象上可以直接使用。

而使用Object.create(null)创建的对象,除了自身属性a之外,原型链上没有任何属性。

也就是我们可以通过Object.create(null)这种方式创建一个纯净的对象,我们可以自己定义hasOwnPropertytoString等方法,完全不必担心会将原型链上的同名方法覆盖掉。

拷贝数组 🐿

日常开发中,数组的拷贝是一个会经常遇到的场景。其实实现数组的拷贝有很多骚技巧。

Array.slice

const arr = [1, 2, 3, 4, 5];
const copyArr = arr.slice();

展开操作符

const arr = [1, 2, 3, 4, 5];
const copyArr = [...arr];

使用 Array 构造函数和展开操作符

const arr = [1, 2, 3, 4, 5];
const copyArr = new Array(...arr);

Array.concat

const arr = [1, 2, 3, 4, 5];
const copyArr = arr.concat();

避免多条件并列 🦀

开发中有时会遇到多个条件,执行相同的语句,也就是多个||这种:

if (status === "process" || status === "wait" || status === "fail") {
  doSomething();
}

这种写法语义性、可读性都不太好。可以通过switch caseincludes这种进行改造。

switch case

switch (status) {
  case "process":
  case "wait":
  case "fail":
    doSomething();
}

includes

const enum = ["process", "wait", "fail"];
if (enum.includes(status)) {
  doSomething();
}

Object.freeze() 🃏

Vue 的文档中介绍数据绑定和响应时,特意标注了对于经过 Object.freeze() 方法的对象无法进行更新响应。
Object.freeze() 方法用于冻结对象,禁止对于该对象的属性进行修改。

正是由于这种特性,所以在实际项目中,他有很多的适用场景。

像一些纯展示类的页面,可能存在巨大的数组或对象,如果这些数据不会发生更改,那么你就可以使用Object.freeze()将他们冻结,这样Vue就不会对这些对象做settergetter的转换,可以大大的提升性能。

❤️ 爱心三连击

1.如果觉得这篇文章还不错,来个分享、点赞、在看三连吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。

查看原文

赞 46 收藏 34 评论 5

前端森林 发布了文章 · 10月9日

Chrome DevTools中的这些骚操作,你都知道吗?

image

引言 🏂

作为开发人员,平时用的最多的就是Chrome devtools了,但是可能很多同学都像我一样平时用的最多也就只是ConsoleElements面板了。

我整理了一些我平时用的比较多的一些调试小技巧,相信对提高你的工作效率能起到不小的帮助!

命令(Command) 菜单 🏈

“命令”菜单是最最常用的,本文也会多次用到,所以这里先说一下打开方式:

Cmd + Shift + P(如果使用Windows,则按Ctrl + Shift + P)打开“命令”菜单。

截图DOM元素 🏉

当你只想对一个特别的 DOM 节点进行截图时,你可能需要使用其他工具弄半天,但现在你直接选中那个节点,打开 命令(Command) 菜单并且使用 节点截图 就可以了。

截取特定节点对应上图命令是Screenshot Capture node screenshot

截取特定DOM元素示例:

不只是这样,你同样可以用这种方式 实现全屏截图 :通过 Screenshot Capture full size screenshot 命令。

请注意,这里说的是全屏,并不只是页面可视区域,而是包含滚动条在内的所有页面内容。

对应截取全屏示例:

在控制台中使用上次操作的值 🎃

我是最近才发现这个技巧。使用$_可以引用在控制台执行的前一步操作的返回值。如果您正在控制台调试一些JavaScript代码,并且需要引用先前的返回值,那么这可能非常方便。

重新发起xhr请求 🚀

在平时和后端联调时,我们用的最多的可能就是Network面板了。但是每次想重新查看一个请求,我们往往都是通过刷新页面、点击按钮等方式去触发xhr请求,这种方式有时显得会比较麻烦,我们可以通过google提供的Replay XHR的方式去发起一条新的请求,这样对于我们开发效率的提升是有所帮助的。
chrome-调试-重新发起xhr请求

编辑页面上的任何文本 ✍

在控制台输入document.body.contentEditable="true"或者document.designMode = 'on'就可以实现对网页的编辑了。
chrome-调试-网页编辑

其实这个还是比较实用的,比如你要测试一个DOM节点文字太长时,样式是否会混乱,或者要去直接修改页面元素去满足一些业务需求时。(我之前是在Elements面板一个一个去修改的,,,)

网络面板(Network)的幻灯片模式 🌇

启动Network 面板下的Capture screenshots就可以在页面加载时捕捉屏幕截图。有点幻灯片的感觉。

单击每一帧截图,显示的就是对应时刻发生的网络请求。这种可视化的展现形式会让你更加清楚每一时刻发生的网络请求情况。

动画检查 🎏

DevTools 中有一个动画面板,默认情况下它是关闭的,很多人可能不太清楚这个功能。它可以让你控制和操纵 CSS 动画,并且可视化这些动画是如何工作的。

要打开该面板,可以在 DevTools 右上角菜单 → More tools 中打开 Animations

默认情况下,DevTools 会“监听”动画。一旦触发,它们将被添加到列表中。你能看到这些动画块如何显示。在动画本身上,DevTools 会向我们展示哪些属性正在更改,例如 background-colortransform

然后,我们可以通过使用鼠标拖动或调整时间轴来修改该动画。

递增/递减 CSS 属性值 🃏

作为前端开发,平时少不了通过Elements面板去查找元素以及它的css样式。有时调整像素px会比较麻烦一点,这时就可以使用快捷键去帮你完成:

* 增量0.1
  * Mac: Option +向上和Option +向下
  * Windows: Alt +向上和Alt +向下
* 增量1
  * Mac:向上+向下
  * Windows:向上+向下
* 增量10
  * Mac:⇧+向上和⇧+向下
  * Windows:⇧+向上和⇧+向下
* 递增100
  * Mac: ⌘+向上和⌘+向下
  * Windows: Ctrl +向上和Ctrl +向下

在低端设备和弱网情况下进行测试 📱

我们平时开发一般都是在办公室(wifi 网速加快),而且设备一般都是市面上较新的。但是产品的研发和推广,一定要考虑低设备人群和弱网的情况。

Chrome DevTools中可以轻松调节CPU功能和网络速度。这样,我们就可以测试 Web 应用程序性能并进行相应优化。

具体打开方式是:在Chrome DevTools中通过CMD/Ctrl + Shift + p打开命令菜单。然后输入Show Performance打开性能面板。

copying & saving 📜

在调试的过程中,我们总会有对 Dev Tools 里面的数据进行 复制 或者 保存 的操作,其实他们也是有一些小技巧的!

copy()

可以通过全局的方法 copy()consolecopy 任何你能拿到的资源
chrome-调试-copy

Store as global variable

如果在console中打印了一堆数据,想对这堆数据做额外的操作,可以将它存储为一个全局变量。只需要右击它,并选择 “Store as global variable”选项。

第一次使用的话,它会创建一个名为 temp1 的变量,第二次创建 temp2,第三次 ... 。通过使用这些变量来操作对应的数据,不用再担心影响到他们原来的值。

自定义 devtools 🌈

大家平时用的最多的Chrome 主题可能就是白色/黑色这两种了,但用的久了,难免想尝试像IDE一样切换主题。

打开方式

  • 首先需要启用实验模式中的Allow custom UI themes

    • 地址栏输入如下url
    chrome://flags/#enable-devtools-experiments # 启用实验功能
    • 启用实验功能,并重启浏览器

  • 控制台中使用快捷键F1打开设置,切换到Experiments 选项
  • 启用Allow custom UI themes

  • Chrome商店安装Material DevTools Theme Collection扩展程序

  • 选择你喜欢的主题即可

CSS/JS 覆盖率 ✅

Chrome DevTools 中的Coverage功能可以帮助我们查看代码的覆盖率。

打开方式

  • 打开调试面板,用快捷键 shift+command+P (mac)输入 Show Coverage调出相应面板

  • 点击reload 按钮开始检测

  • 点击相应文件即可查看具体的覆盖情况(绿色的为用到的代码,红色表示没有用到的代码)

自定义代码片段 Snippets 🌰

在平常开发过程中,我们经常有些 JavaScript 的代码想在 Chrome Devtools中调试,直接在 console 下 写比较麻烦,或者我们经常有些代码片段(防抖、节流、获取地址栏参数等)想保存起来,每次打开 Devtools 都能获取到这些代码片段,而不用再去google,正好Chrome Devtool 就提供了这种功能。

如图所示,在 Sources 这个tab栏下,有个 Snippets 标签,在里面可以添加一些常用的代码片段。

将图片复制为数据 URI 🦊

打开方式

  • 选择Network面板
  • 在资源面板中选择Img
  • 右键单击将其复制为数据URI(已编码为base 64

媒体查询 🔭

媒体查询是自适应网页设计的基本部分。在Chrome Devtools中的设备模式下,在三圆点菜单中点击 Show Media queries即可启用:

Devtools会在样式表中检测媒体查询,并在顶端标尺中将它们显示为彩色条形:

那怎么使用呢?其实也很简单:

  • 点击媒体查询条形,调整视口大小和预览适合目标屏幕大小的样式
  • 右键点击某个条形,查看媒体查询在 CSS 中何处定义并跳到源代码中的定义

keys/values 🎯

这个是Devtools提供的快速查看一个对象的keyvaluesAPI。用起来也很简单:

你可能会说Object.keys()Object.values()也可以实现啊,但这个不是更简单点吗 🤠

table 🦐

Devtools提供的用于将对象数组记录为表格的API:

❤️ 爱心三连击

1.如果觉得这篇文章还不错,来个分享、点赞、在看三连吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。

4.添加微信fs1263215592,拉你进技术交流群一起学习 🍻
前端森林公众号二维码2

查看原文

赞 39 收藏 25 评论 2

前端森林 发布了文章 · 2月24日

关于koa2,你不知道的事

引言

什么是 koa

koa 是一个基于 node 实现的一个新的 web 框架,它是由 express 框架的原班人马打造。特点是优雅、简洁、表达力强、自由度高。和 express 相比,它是一个更轻量的 node 框架,因为它所有的功能都通过插件来实现,这种插拔式的架构设计模式,很符合 unix 哲学。

本文从零开始,循序渐进的展示和详解上手 koa2 框架的几个最重要的概念,最后会串联讲解一下 koa2 的处理流程以及源码结构。看完本文以后,相信无论对于上手 koa2 还是深入了解 koa2 都会有不小的帮助。

快速开始

安装并启动(hello world)

按照正常逻辑,安装使用这种一般都会去官网看一下类似guide的入门指引,殊不知 koa 官网和 koa 本身一样简洁(手动狗头)。

如果一步步搭建环境的话可能会比较麻烦,还好有项目生成器koa-generator(出自狼叔-桑世龙)。

// 安装koa项目生成器koa-generator
$ npm i koa-generator -g

// 使用koa-generator生成koa2项目
$ koa2 hello_koa2

// 切到指定项目目录,并安装依赖
$ cd hello_koa2
$ npm install

// 启动项目
$ npm start

项目启动后,默认端口号是3000,在浏览器中运行可以得到下图的效果说明运行成功。

koa2 简析结构

项目已经启动起来了,下面让我们来简单看一下源码文件目录结构吧:

这个就是 koa2 源码的源文件结构,核心代码就是 lib 目录下的四个文件:

application.js

application.js是 koa 的入口文件,它向外导出了创建 class 实例的构造函数,继承自 node 自带的events,这样就会赋予框架事件监听和事件触发的能力。application 还暴露了一些常用的 api,比如listenuse等等。

listen的实现原理其实就是对http.createServer进行了一个封装,这个函数中传入的callback是核心,它里面包含了中间件的合并,上下文的处理,对 res 的特殊处理。

use 的作用主要是收集中间件,将多个中间件放入一个缓存队列中,然后通过koa-compose这个插件进行递归组合调用这一系列的中间件。

context.js

这部分就是 koa 的应用上下文 ctx,其实就一个简单的对象暴露,里面的重点在 delegate,这个就是代理,这个就是为了开发者方便而设计的,比如我们要访问 ctx.repsponse.status 但是我们通过 delegate,可以直接访问 ctx.status 访问到它。

request.js、response.js

这两部分就是对原生的resreq的一些操作了,大量使用 es6 的getset的一些语法,去取headers或者设置headers、还有设置body等等

路由(URL 处理)

原生路由实现

koa 是个极简的 web 框架,简单到连路由模块都没有配备,我们先来可以根据ctx.request.url或者ctx.request.path获取用户请求的路径,来实现简单的路由。

const Koa = require('koa')
const app = new Koa()

app.use( async ( ctx ) => {
  let url = ctx.request.url
  ctx.body = url
})
app.listen(3000)

访问 http://localhost:3000/hello/forest 页面会输出 /hello/forest,也就是说上下文的请求request对象中url就是当前访问的路径名称,可以根据ctx.request.url 通过一定的判断或者正则匹配就可以定制出所需要的路由。

koa-router 中间件

如果依靠ctx.request.url去手动处理路由,将会写很多处理代码,这时候就需要对应的路由的中间件对路由进行控制,这里介绍一个比较好用的路由中间件koa-router

安装 koa-router 中间件

// koa2 对应的版本是 7.x
$ npm install --save koa-router@7

使用

const Koa = require('koa');
const Router = require('koa-router');

const app = new Koa();
const router = new Router();

router.get('/', async (ctx) => {
  let html = `
      <ul>
        <li><a href="/hello">helloworld</a></li>
        <li><a href="/about">about</a></li>
      </ul>
    `
  ctx.body = html
}).get('/hello', async (ctx) => {
  ctx.body = 'hello forest'
}).get('/about', async (ctx) => {
  ctx.body = '前端森林'
})

app.use(router.routes(), router.allowedMethods())

app.listen(3000);

中间件

在上面说到路由时,我们用到了中间件(koa-router)。那中间件究竟是什么呢?

Koa 的最大特色,也是最重要的一个设计,就是中间件(middleware)。Koa 应用程序是一个包含一组中间件函数的对象,它是按照类似堆栈的方式组织和执行的。

Koa 中使用app.use()来加载中间件,基本上 Koa 所有的功能都是通过中间件实现的。

每个中间件默认接受两个参数,第一个参数是 Context 对象,第二个参数是next函数。只要调用 next 函数,就可以把执行权转交给下一个中间件。

下图为经典的 Koa 洋葱模型:

我们来看一下 koa 官网的这个例子:

const Koa = require('koa');
const app = new Koa();

// logger

app.use(async (ctx, next) => {
  await next();
  const rt = ctx.response.get('X-Response-Time');
  console.log(`${ctx.method} ${ctx.url} - ${rt}`);
});

// x-response-time

app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  ctx.set('X-Response-Time', `${ms}ms`);
});

// response

app.use(async ctx => {
  ctx.body = 'Hello World';
});

app.listen(3000);

上面的执行顺序就是:请求 -> logger 中间件 -> x-response-time 中间件 -> 响应中间件 -> x-response-time 中间件 -> logger 中间件 -> 响应。

通过这个顺序我们可以发现这是个栈结构以"先进后出"(first-in-last-out)的顺序执行。

Koa 已经有了很多好用的中间件(https://github.com/koajs/koa/wiki#middleware)你需要的常用功能基本上上面都有。

请求数据获取

get

获取方法

在 koa 中,获取GET请求数据源使用 koa 中 request 对象中的query方法或querystring方法。

query返回是格式化好的参数对象,querystring返回的是请求字符串,由于 ctx 对 request 的 API 有直接引用的方式,所以获取 GET 请求数据有两个途径。

  • 1、从上下文中直接获取

    • 请求对象ctx.query,返回如 { name:'森林', age:23 }
    • 请求字符串 ctx.querystring,返回如 name=森林&age=23
  • 2、从上下文的 request 对象中获取

    • 请求对象ctx.request.query,返回如 { a:1, b:2 }
    • 请求字符串 ctx.request.querystring,返回如 a=1&b=2

示例

const Koa = require('koa')
const app = new Koa()

app.use( async ( ctx ) => {
  let url = ctx.url
  // 从上下文的request对象中获取
  let request = ctx.request
  let req_query = request.query
  let req_querystring = request.querystring

  // 从上下文中直接获取
  let ctx_query = ctx.query
  let ctx_querystring = ctx.querystring

  ctx.body = {
    url,
    req_query,
    req_querystring,
    ctx_query,
    ctx_querystring
  }
})

app.listen(3000, () => {
  console.log('[demo] request get is starting at port 3000')
})

post

对于POST请求的处理,koa2 没有封装获取参数的方法,需要通过自己解析上下文 context 中的原生 node.js 请求对象req,将 POST 表单数据解析成 querystring(例如:a=1&b=2&c=3),再将 querystring 解析成 JSON 格式(例如:{"a":"1", "b":"2", "c":"3"})。

我们来直接使用koa-bodyparser 中间件从 POST 请求的数据体里面提取键值对。

对于POST请求的处理,koa-bodyparser中间件可以把 koa2 上下文的formData数据解析到ctx.request.body中。

示例

首先安装koa-bodyparser

$ npm install --save koa-bodyparser@3

看一个简单的示例:

const Koa = require('koa')
const app = new Koa()
const bodyParser = require('koa-bodyparser')

// 使用koa-bodyparser中间件
app.use(bodyParser())

app.use(async (ctx) => {

  if (ctx.url === '/' && ctx.method === 'GET') {
    // 当GET请求时候返回表单页面
    let html = `
      <h1>koa2 request post demo</h1>
      <form method="POST" action="/">
        用户名:<input name="name" /><br/>
        年龄:<input name="age" /><br/>
        邮箱: <input name="email" /><br/>
        <button type="submit">submit</button>
      </form>
    `
    ctx.body = html
  } else if (ctx.url === '/' && ctx.method === 'POST') {
    // 当POST请求的时候,中间件koa-bodyparser解析POST表单里的数据,并展示到页面
    ctx.body = ctx.request.body
  } else {
    // 404
    ctx.body = '<h1>404 Not Found</h1>'
  }
})

app.listen(3000, () => {
  console.log('[demo] request post is starting at port 3000')
})

模版引擎

在实际项目开发中,返回给用户的网页往往都会被写成模板文件。 Koa 先读取模板文件,然后将这个模板返回给用户,这里我们就需要使用模板引擎了。

关于 Koa 的模版引擎,我们只需要安装 koa 模板使用中间件koa-views, 然后再下载你喜欢的模板引擎(支持列表)便可以愉快的使用了。

这里以使用ejs模版为例展开说明。

安装模版

// 安装koa模板使用中间件
$ npm install --save koa-views

// 安装ejs模板引擎
$ npm install --save ejs

使用模版引擎

文件目录

├── package.json
├── index.js
└── view
    └── index.ejs

./index.js 文件

const Koa = require('koa')
const views = require('koa-views')
const path = require('path')
const app = new Koa()

// 加载模板引擎
app.use(views(path.join(__dirname, './view'), {
  extension: 'ejs'
}))

app.use( async ( ctx ) => {
  let title = '森林带你学koa2'
  await ctx.render('index', {
    title,
  })
})

app.listen(3000)

./view/index.ejs 模板

<!DOCTYPE html>
<html>
<head>
    <title><%= title %></title>
</head>
<body>
    <h1><%= title %></h1>
    <p>EJS Welcome to <%= title %></p>
</body>
</html>

静态资源服务器

网站一般都提供静态资源(图片、字体、样式表、脚本……),我们可以自己实现一个静态资源服务器,但这没必要,koa-static模块封装了这部分功能。

安装

$ npm i --save koa-static

示例

const Koa = require('koa')
const path = require('path')
const static = require('koa-static')

const app = new Koa()

// 静态资源目录对于相对入口文件index.js的路径
const staticPath = './static'

app.use(static(
  path.join( __dirname,  staticPath)
))


app.use( async ( ctx ) => {
  ctx.body = 'hello world'
})

app.listen(3000, () => {
  console.log('[demo] static-use-middleware is starting at port 3000')
})

cookie/session

koa2 中使用 cookie

使用方法

koa 提供了从上下文直接读取、写入 cookie 的方法:

  • ctx.cookies.get(name, [options]) 读取上下文请求中的 cookie
  • ctx.cookies.set(name, value, [options]) 在上下文中写入 cookie
    koa2 中操作的 cookies 是使用了 npm 的cookies模块,源码在这里,所以在读写 cookie 时的使用参数与该模块的使用一致。

示例

const Koa = require('koa')
const app = new Koa()

app.use( async ( ctx ) => {

  if ( ctx.url === '/index' ) {
    ctx.cookies.set(
      'cid',
      'hello world',
      {
        domain: 'localhost',  // 写cookie所在的域名
        path: '/index',       // 写cookie所在的路径
        maxAge: 10 * 60 * 1000, // cookie有效时长
        expires: new Date('2017-02-15'),  // cookie失效时间
        httpOnly: false,  // 是否只用于http请求中获取
        overwrite: false  // 是否允许重写
      }
    )
    ctx.body = 'cookie is ok'
  } else {
    ctx.body = 'hello world'
  }

})

app.listen(3000, () => {
  console.log('[demo] cookie is starting at port 3000')
})

koa2 中实现 session

koa2 原生功能只提供了 cookie 的操作,但是没有提供session操作。session 就只能自己实现或者通过第三方中间件实现。

我这里给大家演示一下通过中间件koa-generic-session来在 koa2 中实现 session。

const Koa = require("koa");
const redisStore = require("koa-redis");
const session = require("koa-generic-session");

app.use(
  session({
    key: "forum.sid", // cookie name
    prefix: "forum:sess:", // redis key的前缀
    cookie: {
      path: "/",
      httpOnly: true,
      maxAge: 24 * 60 * 60 * 1000 // ms
    },
    ttl: 24 * 60 * 60 * 1000, // ms
    store: redisStore({
      all: `${REDIS_CONF.host}:${REDIS_CONF.port}`
    })
  })
);

koa2 处理流程

上面提到了很多 koa2 涉及到的一些概念,下面让我们梳理一下 koa2 完整的处理流程吧!

完整大致可以分为以下四部分:

  • 初始化应用
  • 请求到来-创建上下文
  • 请求到来-中间件执行
  • 返回 res-特殊处理
这里参考大佬的一张关于 koa2 的完整流程图,

初始化应用

在我们的app.js中,初始化的时候创建了 koa 实例(new koa()),然后是很多的use,最后是app.listen(3000)

use主要是把所有的函数(使用的中间件)收集到一个middleware数组中。

listen主要是对http.createServer进行了一个封装,这个函数中传入的callback是核心,它里面包含了中间件的合并,上下文的处理等。也就是http.createServer(app.callback()).listen(...)

创建上下文

一个请求过来时,可以拿到对应的 req、res,koa 拿到后就通过createContext来创建应用上下文,并进行属性代理delegate

中间件执行

请求过来时,通过use操作已经将多个中间件放入一个缓存队列中。使用koa-compose将传入的middleware组合起来,然后返回了一个 promise。

http.createServer((req, res) => {
 // ... 通过req,res创建上下文
 // fn是`koa-compose`返回的promise
 return fn(ctx).then(handleResponse).catch(onerror);
})

res 返回并进行特殊处理

在上面一部分,我们看到有一个handleResponse,它是什么呢?(其实到这里我们还没有res.end())。

const handleResponse = () => respond(ctx);

respond 到底做了什么呢,其实它就是判断你之前中间件写的 body 的类型,做一些处理,然后才使用res.end(body)

到这里就结束了,返回了页面。

参考

福利

到这里关于 koa2 的一些相关概念就分享结束了,不知道你有没有收获呢?

我这里有两个关于koa2的完整(何为完整,数据库、日志、模型等等等等,你想要的都有)的项目,可以供大家参考,当然感觉不错的话可以给个 star 支持一下!!

  • https://github.com/Jack-cool/rest_node_api
  • https://github.com/Jack-cool/forum_code

最后

同时你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。

查看原文

赞 6 收藏 4 评论 1

前端森林 发布了文章 · 2月9日

好玩儿的css

interstingCss1.jpeg

引言

其实很早就想写一篇关于 css 的文章了。(拖延症,一直没写。。。)

css 发展到今天已经越来越强大了。其语法的日新月异,让很多以前完成不了的事情,现在可以非常轻松的做到。

今天带大家看几个用css(部分会用到canvasjs)实现的好玩儿的效果(不好好琢磨下,还真写不出来)

本篇文章有参考一些css大佬的杰作,具体参考链接在文末有提及

超能陆战队-大白

超能陆战队中的大白,相信你一定不陌生吧。影片中的大白又萌又可爱,十分惹人喜欢。

下面让我们打造属于自己的大白吧!

效果

思路

大白主要是由大大小小的圆和椭圆组成,主要会用到border-radius属性。

整体bigwhite头部head(包含 eye、eye2 和 mouth)、躯干torso(heart)、躯干连接处belly(cover)、左臂left-arm(包含 l-bigfinger、l-smallfinger)、右臂right-arm(包含 r-bigfinger、r-smallfinger)、左腿left-leg右腿right-leg组成。

相对还是比较简单的,具体实现如下:

代码实现

html

<body>
  <div id="bigwhite">
    <!--头部-->
    <div id="head">
      <div id="eye"></div>
      <div id="eye2"></div>
      <div id="mouth"></div>
    </div>

    <!--躯干-->
    <div id="torso">
      <div id="heart"></div>
    </div>
    <div id="belly">
      <div id="cover"></div>
      <!--和躯干连接处-->
    </div>

    <!--左臂-->
    <div id="left-arm">
      <div id="l-bigfinger"></div>
      <div id="l-smallfinger"></div>
    </div>

    <!--右臂-->
    <div id="right-arm">
      <div id="r-bigfinger"></div>
      <div id="r-smallfinger"></div>
    </div>

    <!--左腿-->
    <div id="left-leg"></div>

    <!--右腿-->
    <div id="right-leg"></div>
</body>

css

body {
  background: #ff3300;
}
#bigwhite {
  margin: 0 auto;
  height: 600px;
  /*隐藏溢出*/
  overflow: hidden;
}
#head {
  height: 64px;
  width: 100px;
  /*画圆*/
  border-radius: 50%;
  background: #fff;
  margin: 0 auto;
  margin-bottom: -20px;

  border-bottom: 5px solid #e0e0e0;

  /*元素的堆叠顺序*/
  z-index: 100;

  position: relative;
}

#eye,
#eye2 {
  width: 11px;
  height: 13px;
  background: #282828;
  border-radius: 50%;
  position: relative;
  top: 30px;
  left: 27px;

  /*旋转元素*/
  transform: rotate(8deg);
}
#eye2 {
  /*对称旋转*/
  transform: rotate(-8deg);
  left: 69px;
  top: 17px;
}
#mouth {
  width: 38px;
  height: 1.7px;
  background: #282828;
  position: relative;
  top: 10px;
  left: 34px;
}

#torso,
#belly {
  margin: 0 auto;
  height: 200px;
  width: 180px;
  background: #fff;
  border-radius: 47%;

  border: 5px solid #e0e0e0;
  border-top: none;
  z-index: 1;
}
#belly {
  height: 300px;
  width: 245px;
  margin-top: -140px;
  z-index: 5;
}
#heart {
  width: 25px;
  height: 25px;
  border-radius: 50px;
  position: relative;
  /*添加阴影*/
  box-shadow: 2px 5px 2px #ccc inset;

  right: -115px;
  top: 40px;
  z-index: 111;
  border: 1px solid #ccc;
}

#left-arm,
#right-arm {
  height: 270px;
  width: 120px;
  border-radius: 50%;
  background: #fff;
  margin: 0 auto;
  position: relative;
  top: -350px;
  left: -100px;
  transform: rotate(200deg);
  z-index: -1;
}
#right-arm {
  transform: rotate(-200deg);
  left: 100px;
  top: -620px;
}

#l-bigfinger,
#r-bigfinger {
  height: 50px;
  width: 20px;
  border-radius: 50%;
  background: #fff;
  position: relative;
  top: -35px;
  left: 39px;
  transform: rotate(-50deg);
}
#r-bigfinger {
  left: 63px;
  transform: rotate(50deg);
}
#l-smallfinger,
#r-smallfinger {
  height: 35px;
  width: 15px;
  border-radius: 50%;
  background: #fff;
  position: relative;
  top: -70px;
  left: 25px;
  transform: rotate(-40deg);
}
#r-smallfinger {
  background: #fff;
  transform: rotate(40deg);
  top: -70px;
  left: 80px;
}

#left-leg,
#right-leg {
  height: 170px;
  width: 90px;
  border-radius: 40% 30% 10px 45%;
  background: #fff;
  position: relative;
  top: -640px;
  left: -45px;
  transform: rotate(-1deg);
  margin: 0 auto;
  z-index: -2;
}
#right-leg {
  border-radius: 40% 30% 45% 10px;
  position: relative;
  margin: 0 auto;
  top: -810px;
  left: 50px;
  transform: rotate(1deg);
}

具体可查看https://codepen.io/jack-cool-the-lessful/pen/vYOYoPp

飘逸灵动的彩带(借鉴尤雨溪博客首页)

很早之前见过这种效果(当时还不知道这是尤大大的作品)。

第一次看到这个主页的时候,就觉得很惊艳。主页图案的组成元素只有一种:富有魅力的三角网格。整个页面简单却不单调,华丽而不喧闹。(简单来说就是有逼格)。

效果

思路

这里最关键的两个点,绘制三角形的算法和颜色的取值算法。具体参考https://zhuanlan.zhihu.com/p/28257724,这里面介绍的比较详细。

下面看一下代码实现(有注释):

代码

html

<body>
  <div id="wrapper">
    <h1>之晨</h1>
    <h2>公众号-「前端森林」</h2>
    <p>
      <a href="https://github.com/Jack-cool" target="_blank">Github</a>
    </p>
    <p>
      <a href="https://juejin.im/user/5a767928f265da4e78327344/activities" target="_blank">掘金</a>
    </p>
    <p>
  </div>
  <canvas width="1920" height="917"></canvas>
</body>

css

html,
body {
  overflow: hidden;
  margin: 0;
}

body {
  font-family: "Open Sans", "Helvetica Neue", "Hiragino Sans GB", "LiHei Pro",
    Arial, sans-serif;
  color: #333;
}

#wrapper {
  position: absolute;
  left: 0;
  width: 320px;
  text-align: center;
  top: 50%;
  left: 50%;
  margin-left: -160px;
  margin-top: -160px;
  -webkit-user-select: none;
  -moz-user-select: none;
  user-select: none;
}

h1 {
  font-family: "Montserrat", "Helvetica Neue", Arial, sans-serif;
  font-weight: 700;
  font-size: 30px;
  letter-spacing: 9px;
  text-transform: uppercase;
  margin: 12px 0;
  left: 4px;
}

h2 {
  color: #999;
  font-weight: normal;
  font-size: 15px;
  letter-spacing: 0.12em;
  margin-bottom: 30px;
  left: 3px;
}

h1,
h2 {
  position: relative;
}

p {
  font-size: 14px;
  line-height: 2em;
  margin: 0;
  letter-spacing: 2px;
}

canvas {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

a {
  color: #999;
  text-decoration: none;
  transition: color 0.2s ease;
}

a:hover {
  color: #f33;
}

js

document.addEventListener("touchmove", function(e) {
  e.preventDefault();
});
var canvasRibbon = document.getElementsByTagName("canvas")[0],
  ctx = canvasRibbon.getContext("2d"), // 获取canvas 2d上下文
  dpr = window.devicePixelRatio || 1, // the size of one CSS pixel to the size of one physical pixel.
  width = window.innerWidth, // 返回窗口的文档显示区的宽高
  height = window.innerHeight,
  RIBBON_WIDE = 90,
  path,
  math = Math,
  r = 0,
  PI_2 = math.PI * 2, // 圆周率*2
  cos = math.cos, // cos函数返回一个数值的余弦值(-1~1)
  random = math.random; // 返回0-1随机数
canvasRibbon.width = width * dpr; // 返回实际宽高
canvasRibbon.height = height * dpr;
ctx.scale(dpr, dpr); // 水平、竖直方向缩放
ctx.globalAlpha = 0.6; // 图形透明度
function init() {
  ctx.clearRect(0, 0, width, height); // 擦除之前绘制内容
  path = [
    { x: 0, y: height * 0.7 + RIBBON_WIDE },
    { x: 0, y: height * 0.7 - RIBBON_WIDE }
  ];
  // 路径没有填满屏幕宽度时,绘制路径
  while (path[1].x < width + RIBBON_WIDE) {
    draw(path[0], path[1]); // 调用绘制方法
  }
}
// 绘制彩带每一段路径
function draw(start, end) {
  ctx.beginPath(); // 创建一个新的路径
  ctx.moveTo(start.x, start.y); // path起点
  ctx.lineTo(end.x, end.y); // path终点
  var nextX = end.x + (random() * 2 - 0.25) * RIBBON_WIDE,
    nextY = geneY(end.y);
  ctx.lineTo(nextX, nextY);
  ctx.closePath();
  r -= PI_2 / -50;
  // 随机生成并设置canvas路径16进制颜色
  ctx.fillStyle =
    "#" +
    (
      ((cos(r) * 127 + 128) << 16) |
      ((cos(r + PI_2 / 3) * 127 + 128) << 8) |
      (cos(r + (PI_2 / 3) * 2) * 127 + 128)
    ).toString(16);
  ctx.fill(); // 根据当前样式填充路径
  path[0] = path[1]; // 起点更新为当前终点
  path[1] = { x: nextX, y: nextY }; // 更新终点
}
// 获取下一路径终点的y坐标值
function geneY(y) {
  var temp = y + (random() * 2 - 1.1) * RIBBON_WIDE;
  return temp > height || temp < 0 ? geneY(y) : temp;
}
document.onclick = init;
document.ontouchstart = init;
init();

具体可查看https://codepen.io/jack-cool-the-lessful/pen/rNVaBVL

知乎(老版本)首页动态粒子效果背景

效果

思路

涉及到的知识点主要是:canvasES6requestAnimationFrame

大致思路就是:

  • 定义一个类,创建圆和线的实例
  • 设置单个粒子的随机 x,y 坐标和圆圈的半径。使用window.innerWidthwindow.innerHeight获取屏幕宽高,圆的大小设置在一定范围内随机
  • 使用 canvas 的 api 进行绘制粒子(圆圈)和粒子之间连线,设置一个范围,在此范围内的粒子圆心到圆心通过直线连接
  • 让粒子在屏幕范围内移动
  • 置鼠标的交互事件,相当于以鼠标位置的 x,y 坐标为圆心,固定或随机值为半径重新创建了一个粒子,并且也在一定范围内也设置和其他粒子的连线(同第二步)
  • 定义一个变量用来存储生成的圆,遍历它,创建实例;
  • 使用requestAnimationFrame让所有圆动起来

代码实现

html

<canvas id="canvas"></canvas>

css

html {
  height: 100%;
}
body {
  margin: 0;
  height: 100%;
  background: #fff;
}
canvas {
  display: block;
  width: 100%;
  height: 100%;
}

js

class Circle {
  //创建对象
  //以一个圆为对象
  //设置随机的 x,y坐标,r半径,_mx,_my移动的距离
  //this.r是创建圆的半径,参数越大半径越大
  //this._mx,this._my是移动的距离,参数越大移动
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.r = Math.random() * 10;
    this._mx = Math.random();
    this._my = Math.random();
  }

  //canvas 画圆和画直线
  //画圆就是正常的用canvas画一个圆
  //画直线是两个圆连线,为了避免直线过多,给圆圈距离设置了一个值,距离很远的圆圈,就不做连线处理
  drawCircle(ctx) {
    ctx.beginPath();
    //arc() 方法使用一个中心点和半径,为一个画布的当前子路径添加一条弧。
    ctx.arc(this.x, this.y, this.r, 0, 360);
    ctx.closePath();
    ctx.fillStyle = "rgba(204, 204, 204, 0.3)";
    ctx.fill();
  }

  drawLine(ctx, _circle) {
    let dx = this.x - _circle.x;
    let dy = this.y - _circle.y;
    let d = Math.sqrt(dx * dx + dy * dy);
    if (d < 150) {
      ctx.beginPath();
      //开始一条路径,移动到位置 this.x,this.y。创建到达位置 _circle.x,_circle.y 的一条线:
      ctx.moveTo(this.x, this.y); //起始点
      ctx.lineTo(_circle.x, _circle.y); //终点
      ctx.closePath();
      ctx.strokeStyle = "rgba(204, 204, 204, 0.3)";
      ctx.stroke();
    }
  }

  // 圆圈移动
  // 圆圈移动的距离必须在屏幕范围内
  move(w, h) {
    this._mx = this.x < w && this.x > 0 ? this._mx : -this._mx;
    this._my = this.y < h && this.y > 0 ? this._my : -this._my;
    this.x += this._mx / 2;
    this.y += this._my / 2;
  }
}
//鼠标点画圆闪烁变动
class currentCirle extends Circle {
  constructor(x, y) {
    super(x, y);
  }

  drawCircle(ctx) {
    ctx.beginPath();
    //注释内容为鼠标焦点的地方圆圈半径变化
    //this.r = (this.r < 14 && this.r > 1) ? this.r + (Math.random() * 2 - 1) : 2;
    this.r = 8;
    ctx.arc(this.x, this.y, this.r, 0, 360);
    ctx.closePath();
    //ctx.fillStyle = 'rgba(0,0,0,' + (parseInt(Math.random() * 100) / 100) + ')'
    ctx.fillStyle = "rgba(255, 77, 54, 0.6)";
    ctx.fill();
  }
}
//更新页面用requestAnimationFrame替代setTimeout
window.requestAnimationFrame =
  window.requestAnimationFrame ||
  window.mozRequestAnimationFrame ||
  window.webkitRequestAnimationFrame ||
  window.msRequestAnimationFrame;

let canvas = document.getElementById("canvas");
let ctx = canvas.getContext("2d");
let w = (canvas.width = canvas.offsetWidth);
let h = (canvas.height = canvas.offsetHeight);
let circles = [];
let current_circle = new currentCirle(0, 0);

let draw = function() {
  ctx.clearRect(0, 0, w, h);
  for (let i = 0; i < circles.length; i++) {
    circles[i].move(w, h);
    circles[i].drawCircle(ctx);
    for (j = i + 1; j < circles.length; j++) {
      circles[i].drawLine(ctx, circles[j]);
    }
  }
  if (current_circle.x) {
    current_circle.drawCircle(ctx);
    for (var k = 1; k < circles.length; k++) {
      current_circle.drawLine(ctx, circles[k]);
    }
  }
  requestAnimationFrame(draw);
};

let init = function(num) {
  for (var i = 0; i < num; i++) {
    circles.push(new Circle(Math.random() * w, Math.random() * h));
  }
  draw();
};
window.addEventListener("load", init(60));
window.onmousemove = function(e) {
  e = e || window.event;
  current_circle.x = e.clientX;
  current_circle.y = e.clientY;
};
window.onmouseout = function() {
  current_circle.x = null;
  current_circle.y = null;
};

具体可查看https://codepen.io/jack-cool-the-lessful/pen/YzXPzRy

canvas 生成验证码

我们在做一些后台系统登录功能的时候,一般都会用到验证码,现在用的比较多的一种是前端直接使用canvas生成验证码。

效果

由于该功能相对比较简单,这里就不过多做解释了。(代码中有对应相关注解)

代码实现

html

<canvas width="120" height="40" id="c1"></canvas>

css

body {
  text-align: center;
}
canvas {
  border: 1px solid skyBlue;
}

js

// 随机数
function rn(min, max) {
  return parseInt(Math.random() * (max - min) + min);
}
// 随机颜色
function rc(min, max) {
  var r = rn(min, max);
  var g = rn(min, max);
  var b = rn(min, max);
  return `rgb(${r},${g},${b})`;
}
// 背景颜色,颜色要浅一点
var w = 120;
var h = 40;
var ctx = c1.getContext("2d");
ctx.fillStyle = rc(180, 230);
ctx.fillRect(0, 0, w, h);
// 随机字符串
var pool = "ABCDEFGHIJKLIMNOPQRSTUVWSYZ1234567890";
for (var i = 0; i < 4; i++) {
  var c = pool[rn(0, pool.length)]; //随机的字
  var fs = rn(18, 40); //字体的大小
  var deg = rn(-30, 30); //字体的旋转角度
  ctx.font = fs + "px Simhei";
  ctx.textBaseline = "top";
  ctx.fillStyle = rc(80, 150);
  ctx.save();
  ctx.translate(30 * i + 15, 15);
  ctx.rotate((deg * Math.PI) / 180);
  ctx.fillText(c, -15 + 5, -15);
  ctx.restore();
}
// 随机5条干扰线,干扰线的颜色要浅一点
for (var i = 0; i < 5; i++) {
  ctx.beginPath();
  ctx.moveTo(rn(0, w), rn(0, h));
  ctx.lineTo(rn(0, w), rn(0, h));
  ctx.strokeStyle = rc(180, 230);
  ctx.closePath();
  ctx.stroke();
}
// 随机产生40个干扰的小点
for (var i = 0; i < 40; i++) {
  ctx.beginPath();
  ctx.arc(rn(0, w), rn(0, h), 1, 0, 2 * Math.PI);
  ctx.closePath();
  ctx.fillStyle = rc(150, 200);
  ctx.fill();
}

具体可查看https://codepen.io/jack-cool-the-lessful/pen/VwLYYbP

抖音 LOGO

抖音我们每天都在刷,抖音的 logo 大家也再熟悉不过。

效果

思路

抖音 logo 是两个音符 ♪ 叠加、混合而成的。这个音符可以拆分为三个部分:


我们可以看到,它由三部分组成:

1、中间的竖线(矩形)

2、右上角的四分之一圆环(利用 border-radiustransform 旋转来实现)

3、左下角的四分之三圆环(利用 border-radiustransform 旋转来实现)

从上面的 logo,我们可以清晰的看到两个音符 ♪ 之间是有重叠部分的。这一块是通过mix-blend-mode属性实现的。

CSS3 新增了一个很有意思的属性 -- mix-blend-mode ,其中 mix 和 blend 的中文意译均为混合,那么这个属性的作用直译过来就是混合混合模式,当然,我们我们通常称之为混合模式。

由此可以知道实现该 logo 的关键点在于:

  • 主要借助伪元素实现了整体 J 结构,借助了 mix-blend-mode 实现融合效果
  • 利用 mix-blend-mode: lighten 混合模式实现两个 J 形结构重叠部分为白色

代码实现

html

<div class="g-container">
  <div class="j"></div>
  <div class="j"></div>
</div>

css(scss)

body {
  background: #000;
  overflow: hidden;
}

.g-container {
  position: relative;
  width: 200px;
  margin: 100px auto;
  filter: contrast(150%) brightness(110%);
}

.j {
  position: absolute;
  top: 0;
  left: 0;
  width: 47px;
  height: 218px;
  z-index: 1;
  background: #24f6f0;

  &::before {
    content: "";
    position: absolute;
    width: 100px;
    height: 100px;
    border: 47px solid #24f6f0;
    border-top: 47px solid transparent;
    border-radius: 50%;
    top: 121px;
    left: -147px;
    transform: rotate(45deg);
  }

  &::after {
    content: "";
    position: absolute;
    width: 140px;
    height: 140px;
    border: 40px solid #24f6f0;
    border-right: 40px solid transparent;
    border-top: 40px solid transparent;
    border-left: 40px solid transparent;
    top: -110px;
    right: -183px;
    border-radius: 100%;
    transform: rotate(45deg);
    z-index: -10;
  }
}

.j:last-child {
  left: 10px;
  top: 10px;
  background: #fe2d52;
  z-index: 100;
  mix-blend-mode: lighten;
  animation: moveLeft 10s infinite;

  &::before {
    border: 47px solid #fe2d52;
    border-top: 47px solid transparent;
  }
  &::after {
    border: 40px solid #fe2d52;
    border-right: 40px solid transparent;
    border-top: 40px solid transparent;
    border-left: 40px solid transparent;
  }
}

@keyframes moveLeft {
  0% {
    transform: translate(200px);
  }
  50% {
    transform: translate(0px);
  }
  100% {
    transform: translate(0px);
  }
}

具体可查看https://codepen.io/jack-cool-the-lessful/pen/poJvvVB

掘金登录特效

效果

思路

这里用到了平时不大可能会用到的:focus-within

:focus-within 伪类选择器,它表示一个元素获得焦点,或,该元素的后代元素获得焦点。

这也就意味着,它或它的后代获得焦点,都可以触发 :focus-within

深入了解可查看https://github.com/chokcoco/iCSS/issues/36

代码实现

html

<div class="g-container">
  <h2>登录</h2>
  <div class="g-username">
    <input name="loginPhoneOrEmail" maxlength="64" placeholder="请输入手机号或邮箱" class="input">
    <img data-original="https://b-gold-cdn.xitu.io/v3/static/img/greeting.1415c1c.png" class="g-username">
  </div>

  <div class="g-password">
    <input name="loginPassword" type="password" maxlength="64" placeholder="请输入密码" class="input">
    <img data-original="https://b-gold-cdn.xitu.io/v3/static/img/blindfold.58ce423.png" class="g-password">
  </div>

  <img data-original="https://b-gold-cdn.xitu.io/v3/static/img/normal.0447fe9.png" class="g-normal">
</div>

css(scss)

$bg-normal: "https://b-gold-cdn.xitu.io/v3/static/img/normal.0447fe9.png";
$bg-username: "https://b-gold-cdn.xitu.io/v3/static/img/greeting.1415c1c.png";
$bg-password: "https://b-gold-cdn.xitu.io/v3/static/img/blindfold.58ce423.png";

.g-container {
  position: relative;
  width: 318px;
  margin: 100px auto;
  height: 370px;
  padding: 20px;
  box-sizing: border-box;
  background: #fff;
  z-index: 10;

  h2 {
    font-size: 20px;
    font-weight: bold;
    margin-bottom: 30px;
  }

  input {
    outline: none;
    padding: 10px;
    width: 100%;
    border: 1px solid #e9e9e9;
    border-radius: 2px;
    outline: none;
    box-sizing: border-box;
    font-size: 16px;
  }
}

img {
  position: absolute;
  top: -20%;
  left: 50%;
  width: 120px;
  height: 95px;
  transform: translate(-50%, 0);
}

.g-username {
  margin-bottom: 10px;

  img {
    display: none;
    width: 120px;
    height: 113px;
  }
}

.g-username:focus-within ~ img {
  display: none;
}

.g-username:focus-within {
  input {
    border-color: #007fff;
  }
  img {
    display: block;
  }
}

.g-password {
  margin-bottom: 10px;

  img {
    display: none;
    width: 103px;
    height: 84px;
    top: -15%;
  }
}

.g-password:focus-within ~ img {
  display: none;
}

.g-password:focus-within {
  input {
    border-color: #007fff;
  }
  img {
    display: block;
  }
}

具体可查看https://codepen.io/jack-cool-the-lessful/pen/VwLYYqz

波浪百分比

第一次见到这种效果好像还是在手机营业厅里面,展示剩余流量时。

不得不感叹,css 有时真的很强大。当然感觉这也属于 css 的奇技淫巧了。

效果

思路

这里我简单说明一下关键点:

  • 利用 border-radius 生成椭圆
  • 让椭圆旋转起来
  • 并不是利用旋转的椭圆本身生成波浪效果,而是利用它去切割背景,产生波浪的效果。

具体可参考https://zhuanlan.zhihu.com/p/28508128,里面分别提到了用svgcanvas纯css来实现波浪效果。

我们这里是用纯css来实现的。

代码实现

html

<div class="container">
  <div class="wave"></div>
</div>

css(scss)

.container {
  position: absolute;
  width: 200px;
  height: 200px;
  padding: 5px;
  border: 5px solid rgb(0, 102, 204);
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  overflow: hidden;
}
.wave {
  position: relative;
  width: 200px;
  height: 200px;
  background-color: rgb(51, 102, 204);
  border-radius: 50%;

  &::before,
  &::after {
    content: "";
    position: absolute;
    width: 400px;
    height: 400px;
    top: 0;
    left: 50%;
    background-color: rgba(255, 255, 255, 0.4);
    border-radius: 45%;
    transform: translate(-50%, -70%) rotate(0);
    animation: rotate 6s linear infinite;
    z-index: 10;
  }

  &::after {
    border-radius: 47%;
    background-color: rgba(255, 255, 255, 0.9);
    transform: translate(-50%, -70%) rotate(0);
    animation: rotate 10s linear -5s infinite;
    z-index: 20;
  }
}

@keyframes rotate {
  50% {
    transform: translate(-50%, -73%) rotate(180deg);
  }
  100% {
    transform: translate(-50%, -70%) rotate(360deg);
  }
}

具体可查看https://codepen.io/jack-cool-the-lessful/pen/XWbJJLd

酷炫的充电动画

看完上面的波浪百分比动画,在此基础上我们来实现一个更为复杂的动画效果,也就是常见的充电动画

效果

思路

  • 画一个电池
  • 增加阴影及颜色的变化(使用 filter: hue-rotate() 对渐变色彩进行色彩过渡变换动画)
  • 添加波浪,这里用一张动图说明(结合上个波浪百分比,相信你很快就明白了)

代码实现

html

<div class="container">
  <div class="header"></div>
  <div class="battery">
  </div>
  <div class="battery-copy">
    <div class="g-wave"></div>
    <div class="g-wave"></div>
    <div class="g-wave"></div>
  </div>
</div>

css(scss)

html,
body {
    width: 100%;
    height: 100%;
    display: flex;
    background: #e4e4e4;
}
.container {
  position: relative;
  width: 140px;
  margin: auto;
}

.header {
  position: absolute;
  width: 26px;
  height: 10px;
  left: 50%;
  top: 0;
  transform: translate(-50%, -10px);
  border-radius: 5px 5px 0 0;
  background: rgba(255, 255, 255, 0.88);
}

.battery-copy {
  position: absolute;
  top: 0;
  left: 0;
  height: 220px;
  width: 140px;
  border-radius: 15px 15px 5px 5px;
  overflow: hidden;
}

.battery {
  position: relative;
  height: 220px;
  box-sizing: border-box;
  border-radius: 15px 15px 5px 5px;
  box-shadow: 0 0 5px 2px rgba(255, 255, 255, 0.22);
  background: #fff;
  z-index: 1;

  &::after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    top: 80%;
    background: linear-gradient(
      to bottom,
      #7abcff 0%,
      #00bcd4 44%,
      #2196f3 100%
    );
    border-radius: 0px 0px 5px 5px;
    box-shadow: 0 14px 28px rgba(33, 150, 243, 0),
      0 10px 10px rgba(9, 188, 215, 0.08);
    animation: charging 10s linear infinite;
    filter: hue-rotate(90deg);
  }
}

.g-wave {
  position: absolute;
  width: 300px;
  height: 300px;
  background: rgba(255, 255, 255, 0.8);
  border-radius: 45% 47% 44% 42%;
  bottom: 25px;
  left: 50%;
  transform: translate(-50%, 0);
  z-index: 1;
  animation: move 10s linear infinite;
}

.g-wave:nth-child(2) {
  border-radius: 38% 46% 43% 47%;
  transform: translate(-50%, 0) rotate(-135deg);
}

.g-wave:nth-child(3) {
  border-radius: 42% 46% 37% 40%;
  transform: translate(-50%, 0) rotate(135deg);
}

@keyframes charging {
  50% {
    box-shadow: 0 14px 28px rgba(0, 150, 136, 0.83),
      0px 4px 10px rgba(9, 188, 215, 0.4);
  }

  95% {
    top: 5%;
    filter: hue-rotate(0deg);
    border-radius: 0 0 5px 5px;
    box-shadow: 0 14px 28px rgba(4, 188, 213, 0.2),
      0 10px 10px rgba(9, 188, 215, 0.08);
  }
  100% {
    top: 0%;
    filter: hue-rotate(0deg);
    border-radius: 15px 15px 5px 5px;
    box-shadow: 0 14px 28px rgba(4, 188, 213, 0),
      0 10px 10px rgba(9, 188, 215, 0.4);
  }
}

@keyframes move {
  100% {
    transform: translate(-50%, -160px) rotate(720deg);
  }
}

具体可查看https://codepen.io/jack-cool-the-lessful/pen/gOpbpaB

参考链接:

  • https://github.com/chokcoco/iCSS
  • https://github.com/chokcoco/CSS-Inspiration

最后

到这里本篇文章也就结束了,这里主要是结合我平时的所见所得对好玩儿的css做的简单的总结。希望能给你带来帮助!

同时你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。

查看原文

赞 1 收藏 0 评论 0

前端森林 发布了文章 · 1月30日

你真的了解mongoose吗?

引言

继上篇文章「Koa2+MongoDB+JWT实战--Restful API最佳实践」后,收到许多小伙伴的反馈,表示自己对于mongoose不怎么了解,上手感觉有些难度,看官方文档又基本都是英文(宝宝心里苦,但宝宝不说)。

为了让各位小伙伴快速上手,加深对于 mongoose 的了解,我特地结合之前的项目整理了一下关于 mongoose 的一些基础知识,这些对于实战都是很有用的。相信看了这篇文章,一定会对你快速上手,了解使用 mongoose 有不小的帮助。

mongoose 涉及到的概念和模块还是很多的,大体有下面这些:

本篇文章并不会逐个去展开详细讲解,主要是讲述在实战中比较重要的几个模块:模式(schemas)模式类型(SchemaTypes)连接(Connections)模型(Models)联表(Populate)

模式(schemas)

定义你的 schema

Mongoose的一切都始于一个Schema。每个 schema 映射到 MongoDB 的集合(collection)和定义该集合(collection)中的文档的形式。

const mongoose = require("mongoose");

const { Schema, model } = mongoose;

const userSchema = new Schema(
  {
    __v: { type: Number, select: false },
    name: { type: String, required: true },
    password: { type: String, required: true, select: false },
    avatar_url: { type: String },
    gender: {
      type: String,
      enum: ["male", "female"],
      default: "male",
      required: true
    },
    headline: { type: String },
  },
  { timestamps: true }
);

module.exports = model("User", userSchema);
这里的__vversionKey。该 versionKey 是每个文档首次创建时,由 mongoose 创建的一个属性。包含了文档的内部修订版。此文档属性是可配置的。默认值为__v。如果不需要该版本号,在 schema 中添加{ versionKey: false}即可。

创建模型

使用我们的 schema 定义,我们需要将我们的userSchema转成我们可以用的模型。也就是mongoose.model(modelName, schema) 。也就是上面代码中的:

module.exports = model("User", userSchema);

选项(options)

Schemas 有几个可配置的选项,可以直接传递给构造函数或设置:

new Schema({..}, options);

// or

var schema = new Schema({..});
schema.set(option, value);

可用选项:

  • autoIndex
  • bufferCommands
  • capped
  • collection
  • id
  • _id
  • minimize
  • read
  • shardKey
  • strict
  • toJSON
  • toObject
  • typeKey
  • validateBeforeSave
  • versionKey
  • skipVersioning
  • timestamps

这里我只是列举了常用的配置项,完整的配置项可查看官方文档https://mongoosejs.com/docs/guide.html#options

这里我主要说一下versionKeytimestamps:

  • versionKey(上文有提到) 是 Mongoose 在文件创建时自动设定的。 这个值包含文件的内部修订号。 versionKey 是一个字符串,代表版本号的属性名, 默认值为 __v
  • 如果设置了 timestamps 选项, mongoose 会在你的 schema 自动添加 createdAtupdatedAt 字段, 其类型为 Date

到这里,已经基本介绍完了Schema,接下来看一下SchemaTypes

模式类型(SchemaTypes)

SchemaTypes为查询和其他处理路径默认值,验证,getter,setter,字段选择默认值,以及字符串和数字的特殊字符。 在 mongoose 中有效的 SchemaTypes 有:

  • String
  • Number
  • Date
  • Buffer
  • Boolean
  • Mixed
  • ObjectId
  • Array
  • Decimal128
  • Map

看一个简单的示例:

const answerSchema = new Schema(
  {
    __v: { type: Number, select: false },
    content: { type: String, required: true },
    answerer: {
      type: Schema.Types.ObjectId,
      ref: "User",
      required: true,
      select: false
    },
    questionId: { type: String, required: true },
    voteCount: { type: Number, required: true, default: 0 }
  },
  { timestamps: true }
);

所有的 Schema 类型

  • required: 布尔值或函数,如果为 true,则为此属性添加必须的验证。
  • default: 任意类型或函数,为路径设置一个默认的值。如果值是一个函数,则函数的返回值用作默认值。
  • select: 布尔值 指定 query 的默认 projections
  • validate: 函数,对属性添加验证函数。
  • get: 函数,使用 Object.defineProperty() 定义自定义 getter
  • set: 函数,使用 Object.defineProperty() 定义自定义 setter
  • alias: 字符串,只对mongoose>=4.10.0有效。定义一个具有给定名称的虚拟属性,该名称可以获取/设置这个路径

索引

你可以用 schema 类型选项声明 MongoDB 的索引。

  • index: 布尔值,是否在属性中定义一个索引。
  • unique: 布尔值,是否在属性中定义一个唯一索引。
  • sparse: 布尔值,是否在属性中定义一个稀疏索引。
var schema2 = new Schema({
  test: {
    type: String,
    index: true,
    unique: true // 如果指定`unique`为true,则为唯一索引
  }
});

字符串

  • lowercase: 布尔值,是否在保存前对此值调用toLowerCase()
  • uppercase: 布尔值,是否在保存前对此值调用toUpperCase()
  • trim: 布尔值,是否在保存前对此值调用trim()
  • match: 正则,创建一个验证器,验证值是否匹配给定的正则表达式
  • enum: 数组,创建一个验证器,验证值是否是给定数组中的元素

数字

  • min: 数字,创建一个验证器,验证值是否大于等于给定的最小值
  • max: 数字,创建一个验证器,验证值是否小于等于给定的最大的值

日期

  • min: Date
  • max: Date

现在已经介绍完Schematype,接下来让我们看一下Connections

连接(Connections)

我们可以通过利用mongoose.connect()方法连接 MongoDB 。

mongoose.connect('mongodb://localhost:27017/myapp');

这是连接运行在本地myapp数据库最小的值(27017)。如果连接失败,尝试用127.0.0.1代替localhost

当然,你可在 uri 中指定更多的参数:

mongoose.connect('mongodb://username:password@host:port/database?options...');

操作缓存

意思就是我们不必等待连接建立成功就可以使用 models,mongoose 会先缓存 model 操作

let TestModel = mongoose.model('Test', new Schema({ name: String }));
// 连接成功前操作会被挂起
TestModel.findOne(function(error, result) { /* ... */ });

setTimeout(function() {
  mongoose.connect('mongodb://localhost/myapp');
}, 60000);

如果要禁用缓存,可修改bufferCommands配置,也可以全局禁用 bufferCommands

mongoose.set('bufferCommands', false);

选项

connect 方法也接收一个 options 对象:

mongoose.connect(uri, options);

这里我列举几个在日常使用中比较重要的选项,完整的连接选项看这里

  • bufferCommands:这是 mongoose 中一个特殊的选项(不传递给 MongoDB 驱动),它可以禁用 mongoose 的缓冲机制
  • user/pass:身份验证的用户名和密码。这是 mongoose 中特殊的选项,它们可以等同于 MongoDB 驱动中的auth.userauth.password选项。
  • dbName:指定连接哪个数据库,并覆盖连接字符串中任意的数据库。
  • useNewUrlParser:底层 MongoDB 已经废弃当前连接字符串解析器。因为这是一个重大的改变,添加了 useNewUrlParser 标记如果在用户遇到 bug 时,允许用户在新的解析器中返回旧的解析器。
  • poolSize:MongoDB 驱动将为这个连接保持的最大 socket 数量。默认情况下,poolSize 是 5。
  • useUnifiedTopology:默认情况下为false。设置为 true 表示选择使用 MongoDB 驱动程序的新连接管理引擎。您应该将此选项设置为 true,除非极少数情况会阻止您保持稳定的连接。

示例:

const options = {
  useNewUrlParser: true,
  useUnifiedTopology: true,
  autoIndex: false, // 不创建索引
  reconnectTries: Number.MAX_VALUE, // 总是尝试重新连接
  reconnectInterval: 500, // 每500ms重新连接一次
  poolSize: 10, // 维护最多10个socket连接
  // 如果没有连接立即返回错误,而不是等待重新连接
  bufferMaxEntries: 0,
  connectTimeoutMS: 10000, // 10s后放弃重新连接
  socketTimeoutMS: 45000, // 在45s不活跃后关闭sockets
  family: 4 // 用IPv4, 跳过IPv6
};
mongoose.connect(uri, options);

回调

connect()函数也接收一个回调参数,其返回一个 promise。

mongoose.connect(uri, options, function(error) {
  // 检查错误,初始化连接。回调没有第二个参数。
});

// 或者用promise
mongoose.connect(uri, options).then(
  () => { /** ready to use. The `mongoose.connect()` promise resolves to undefined. */ },
  err => { /** handle initial connection error */ }
);

说完Connections,下面让我们来看一个重点Models

模型(Models)

Models 是从 Schema 编译来的构造函数。 它们的实例就代表着可以从数据库保存和读取的 documents。 从数据库创建和读取 document 的所有操作都是通过 model 进行的。

const mongoose = require("mongoose");

const { Schema, model } = mongoose;

const answerSchema = new Schema(
  {
    __v: { type: Number, select: false },
    content: { type: String, required: true },
  },
  { timestamps: true }
);

module.exports = model("Answer", answerSchema);

定义好 model 之后,就可以进行一些增删改查操作了

创建

如果是Entity,使用save方法;如果是Model,使用create方法或insertMany方法。

// save([options], [options.safe], [options.validateBeforeSave], [fn])
let Person = mongoose.model("User", userSchema);
let person1 = new Person({ name: '森林' });
person1.save()

// 使用save()方法,需要先实例化为文档,再使用save()方法保存文档。而create()方法,则直接在模型Model上操作,并且可以同时新增多个文档
// Model.create(doc(s), [callback])
Person.create({ name: '森林' }, callback)

// Model.insertMany(doc(s), [options], [callback])
Person.insertMany([{ name: '森林' }, { name: '之晨' }], function(err, docs) {

})

说到这里,我们先要补充说明一下 mongoose 里面的三个概念:schemamodelentity:

  • schema: 一种以文件形式存储的数据库模型骨架,不具备数据库的操作能力
  • model: 由 schema 发布生成的模型,具有抽象属性和行为的数据库操作对
  • entity: 由 Model 创建的实体,他的操作也会影响数据库
Schema、Model、Entity 的关系请牢记: Schema生成Model,Model创造Entity,Model 和 Entity 都可对数据库操作造成影响,但 Model 比 Entity 更具操作性。

查询

对于 Mongoosecha 的查找文档很容易,它支持丰富的查询 MongoDB 语法。包括findfindByIdfindOne等。

find()

第一个参数表示查询条件,第二个参数用于控制返回的字段,第三个参数用于配置查询参数,第四个参数是回调函数,回调函数的形式为function(err,docs){}

Model.find(conditions, [projection], [options], [callback])

下面让我们依次看下 find()的各个参数在实际场景中的应用:

  • conditions

    • 查找全部
    Model.find({})
    • 精确查找
    Model.find({name:'森林'})
    • 使用操作符

对比相关操作符

compareOp.png

Model.find({ age: { $in: [18, 24]} })

返回 age 字段等于 18 或者 24 的所有 document。

逻辑相关操作符
logicOp.png

// 返回 age 字段大于 24 或者 age 字段不存在的文档
Model.find( { age: { $not: { $lte: 24 }}})

字段相关操作符
fieldOp.png

数组字段的查找
arrFieldOp.png

// 使用 $all 查找同时存在 18 和 20 的 document
Model.find({ age: { $all: [ 18, 20 ] } });
  • projection

    指定要包含或排除哪些 document 字段(也称为查询“投影”),必须同时指定包含或同时指定排除,不能混合指定,_id除外。

    在 mongoose 中有两种指定方式,字符串指定对象形式指定

    字符串指定时在排除的字段前加 - 号,只写字段名的是包含。

    Model.find({},'age');
    Model.find({},'-name');

    对象形式指定时,1 是包含,0 是排除。

    Model.find({}, { age: 1 });
    Model.find({}, { name: 0 });
  • options

    // 三种方式实现
    Model.find(filter,null,options)
    Model.find(filter).setOptions(options)
    Model.find(filter).<option>(xxx)

    options 选项见官方文档 Query.prototype.setOptions()

    这里我们只列举常用的:

    • sort: 按照排序规则根据所给的字段进行排序,值可以是 asc, desc, ascending, descending, 1, 和 -1。
    • limit: 指定返回结果的最大数量
    • skip: 指定要跳过的文档数量
    • lean: 返回普通的 js 对象,而不是 Mongoose Documents。建议不需要 mongoose 特殊处理就返给前端的数据都最好使用该方法转成普通 js 对象。
    // sort 两种方式指定排序
    Model.find().sort('age -name'); // 字符串有 - 代表 descending 降序
    Model.find().sort({age:'asc', name:-1});

sortlimit 同时使用时,调用的顺序并不重要,返回的数据都是先排序后限制数量。

// 效果一样
Model.find().limit(2).sort('age');
Model.find().sort('age').limit(2);
  • callback

    Mongoose 中所有传入 callback 的查询,其格式都是 callback(error, result) 这种形式。如果出错,则 error 是出错信息,result 是 null;如果查询成功,则 error 是 null, result 是查询结果,查询结果的结构形式是根据查询方法的不同而有不同形式的。

    find() 方法的查询结果是数组,即使没查询到内容,也会返回 [] 空数组。

findById

Model.findById(id,[projection],[options],[callback])

Model.findById(id) 相当于 Model.findOne({ _id: id })

看一下官方对于findOnefindById的对比:

不同之处在于处理 id 为 undefined 时的情况。findOne({ _id: undefined }) 相当于 findOne({}),返回任意一条数据。而 findById(undefined) 相当于 findOne({ _id: null }),返回 null

查询结果:

  • 返回数据的格式是 {} 对象形式。
  • id 为 undefinednull,result 返回 null
  • 没符合查询条件的数据,result 返回 null

findOne

该方法返回查找到的所有实例的第一个

Model.findOne(conditions, [projection], [options], [callback])

如果查询条件是 _id,建议使用 findById()

查询结果:

  • 返回数据的格式是 {} 对象形式。
  • 有多个数据满足查询条件的,只返回第一条。
  • 查询条件 conditions 为 {}、 null 或 undefined,将任意返回一条数据。
  • 没有符合查询条件的数据,result 返回 null。

更新

每个模型都有自己的更新方法,用于修改数据库中的文档,不将它们返回到您的应用程序。常用的有findOneAndUpdate()findByIdAndUpdate()update()updateMany()等。

findOneAndUpdate()

Model.findOneAndUpdate(filter, update, [options], [callback])
  • filter

    查询语句,和find()一样。

    filter 为{},则只更新第一条数据。

  • update

    {operator: { field: value, ... }, ... }
    必须使用 update 操作符。如果没有操作符或操作符不是 update 操作符,统一被视为 $set 操作(mongoose 特有)

    字段相关操作符

    符号描述
    $set设置字段值
    $currentDate设置字段值为当前时间,可以是 Date 或时间戳格式。
    $min只有当指定值小于当前字段值时更新
    $max只有当指定值大于当前字段值时更新
    $inc将字段值增加指定数量指定数量可以是负数,代表减少。
    $mul将字段值乘以指定数量
    &dollar;unset删除指定字段,数组中的值删后改为 null。

    数组字段相关操作符

    符号描述
    &dollar;充当占位符,用来表示匹配查询条件的数组字段中的第一个元素 {operator:{ "arrayField.$" : value }}
    &dollar;addToSet向数组字段中添加之前不存在的元素 { $addToSet: {arrayField: value, ... }},value 是数组时可与 $each 组合使用。
    &dollar;push向数组字段的末尾添加元素 { $push: { arrayField: value, ... } },value 是数组时可与 $each 等修饰符组合使用
    &dollar;pop移除数组字段中的第一个或最后一个元素 { $pop: {arrayField: -1(first) / 1(last), ... } }
    &dollar;pull移除数组字段中与查询条件匹配的所有元素 { $pull: {arrayField: value / condition, ... } }
    &dollar;pullAll从数组中删除所有匹配的值 { $pullAll: { arrayField: [value1, value2 ... ], ... } }

    修饰符

    符号描述
    &dollar;each修饰 $push$addToSet 操作符,以便为数组字段添加多个元素。
    &dollar;position修饰 $push 操作符以指定要添加的元素在数组中的位置。
    &dollar;slice修饰 $push 操作符以限制更新后的数组的大小。
    &dollar;sort修饰 $push 操作符来重新排序数组字段中的元素。

    修饰符执行的顺序(与定义的顺序无关):

    • 在指定的位置添加元素以更新数组字段
    • 按照指定的规则排序
    • 限制数组大小
    • 存储数组
  • options

    • lean: true 返回普通的 js 对象,而不是 Mongoose Documents
    • new: 布尔值,true 返回更新后的数据,false (默认)返回更新前的数据。
    • fields/select:指定返回的字段。
    • sort:如果查询条件找到多个文档,则设置排序顺序以选择要更新哪个文档。
    • maxTimeMS:为查询设置时间限制。
    • upsert:布尔值,如果对象不存在,则创建它。默认值为 false
    • omitUndefined:布尔值,如果为 true,则在更新之前删除值为 undefined 的属性。
    • rawResult:如果为 true,则返回来自 MongoDB 的原生结果。
  • callback

    • 没找到数据返回 null
    • 更新成功返回更新前的该条数据( {} 形式)
    • options{new:true},更新成功返回更新后的该条数据( {} 形式)
    • 没有查询条件,即 filter 为空,则更新第一条数据

findByIdAndUpdate()

Model.findByIdAndUpdate(id, update, options, callback)

Model.findByIdAndUpdate(id, update) 相当于 Model.findOneAndUpdate({ _id: id }, update)

result 查询结果:

  • 返回数据的格式是 {} 对象形式。
  • id 为 undefinednull,result 返回 null
  • 没符合查询条件的数据,result 返回 null

update()

Model.update(filter, update, options, callback)
  • options

    • multi: 默认 false,只更新第一条数据;为 true 时,符合查询条件的多条文档都会更新。
    • overwrite:默认为 false,即 update 参数如果没有操作符或操作符不是 update 操作符,将会默认添加 $set;如果为 true,则不添加 $set,视为覆盖原有文档。

updateMany()

Model.updateMany(filter, update, options, callback)

更新符合查询条件的所有文档,相当于 Model.update(filter, update, { multi: true }, callback)

删除

删除常用的有findOneAndDelete()findByIdAndDelete()deleteMany()findByIdAndRemove()等。

findOneAndDelete()

Model.findOneAndDelete(filter, options, callback)
  • filter
    查询语句和 find() 一样
  • options

    • sort:如果查询条件找到多个文档,则设置排序顺序以选择要删除哪个文档。
    • select/projection:指定返回的字段。
    • rawResult:如果为 true,则返回来自 MongoDB 的原生结果。
  • callback

    • 没有符合 filter 的数据时,返回 null
    • filter 为空或 {} 时,删除第一条数据。
    • 删除成功返回 {} 形式的原数据。

findByIdAndDelete()

Model.findByIdAndDelete(id, options, callback)

Model.findByIdAndDelete(id) 相当于 Model.findOneAndDelete({ _id: id })

  • callback

    • 没有符合 id 的数据时,返回 null
    • id 为空或 undefined 时,返回 null
    • 删除成功返回 {} 形式的原数据。

deleteMany()

Model.deleteMany(filter, options, callback)
  • filter
    删除所有符合 filter 条件的文档。

deleteOne()

Model.deleteOne(filter, options, callback)
  • filter
    删除符合 filter 条件的第一条文档。

findOneAndRemove()

Model.findOneAndRemove(filter, options, callback)

用法与 findOneAndDelete() 一样,一个小小的区别是 findOneAndRemove() 会调用 MongoDB 原生的 findAndModify() 命令,而不是 findOneAndDelete() 命令。

建议使用 findOneAndDelete() 方法。

findByIdAndRemove()

Model.findByIdAndRemove(id, options, callback)

Model.findByIdAndRemove(id) 相当于 Model.findOneAndRemove({ _id: id })

remove()

Model.remove(filter, options, callback)

从集合中删除所有匹配 filter 条件的文档。要删除第一个匹配条件的文档,可将 single 选项设置为 true

看完Models,最后让我们来看下在实战中比较有用的Populate

联表(Populate)

Mongoose 的 populate() 可以连表查询,即在另外的集合中引用其文档。

Populate() 可以自动替换 document 中的指定字段,替换内容从其他 collection 中获取。

refs

创建 Model 的时候,可给该 Model 中关联存储其它集合 _id 的字段设置 ref 选项。ref 选项告诉 Mongoose 在使用 populate() 填充的时候使用哪个 Model

const mongoose = require("mongoose");

const { Schema, model } = mongoose;

const answerSchema = new Schema(
  {
    __v: { type: Number, select: false },
    content: { type: String, required: true },
    answerer: {
      type: Schema.Types.ObjectId,
      ref: "User",
      required: true,
      select: false
    },
    questionId: { type: String, required: true },
    voteCount: { type: Number, required: true, default: 0 }
  },
  { timestamps: true }
);

module.exports = model("Answer", answerSchema);

上例中 Answer model 的 answerer 字段设为 ObjectId 数组。 ref 选项告诉 Mongoose 在填充的时候使用 User model。所有储存在 answerer 中的 _id 都必须是 User model 中 document_id

ObjectIdNumberString 以及 Buffer 都可以作为 refs 使用。 但是最好还是使用 ObjectId

在创建文档时,保存 refs 字段与保存普通属性一样,把 _id 的值赋给它就好了。

const Answer = require("../models/answers");

async create(ctx) {
  ctx.verifyParams({
    content: { type: "string", required: true }
  });
  const answerer = ctx.state.user._id;
  const { questionId } = ctx.params;
  const answer = await new Answer({
    ...ctx.request.body,
    answerer,
    questionId
  }).save();
  ctx.body = answer;
}

populate(path,select)

填充document

const Answer = require("../models/answers");

const answer = await Answer.findById(ctx.params.id)
      .select(selectFields)
      .populate("answerer");

被填充的 answerer 字段已经不是原来的 _id,而是被指定的 document 代替。这个 document 由另一条 query 从数据库返回。

返回字段选择

如果只需要填充 document 中一部分字段,可给 populate() 传入第二个参数,参数形式即 返回字段字符串,同 Query.prototype.select()

const answer = await Answer.findById(ctx.params.id)
      .select(selectFields)
      .populate("answerer", "name -_id");

populate 多个字段

const populateStr =
      fields &&
      fields
        .split(";")
        .filter(f => f)
        .map(f => {
          if (f === "employments") {
            return "employments.company employments.job";
          }
          if (f === "educations") {
            return "educations.school educations.major";
          }
          return f;
        })
        .join(" ");
const user = await User.findById(ctx.params.id)
      .select(selectFields)
      .populate(populateStr);

最后

到这里本篇文章也就结束了,这里主要是结合我平时的项目(https://github.com/Jack-cool/rest_node_api)中对于mongoose的使用做的简单的总结。希望能给你带来帮助!

同时你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。

查看原文

赞 1 收藏 0 评论 0

前端森林 发布了文章 · 1月21日

Koa2+MongoDB+JWT实战--Restful API最佳实践

restful_banner.jpeg

引言

Web API 已经在最近几年变成重要的话题,一个干净的 API 设计对于后端系统是非常重要的。

通常我们为 Web API 使用 RESTful 设计,REST 概念分离了 API 结构逻辑资源,通过 Http 方法GET, DELETE, POSTPUT等 来操作资源。

本篇文章是结合我最近的一个项目,基于koa+mongodb+jwt来给大家讲述一下 RESTful API 的最佳实践。

RESTful API 是什么?

具体了解RESTful API前,让我们先来看一下什么是REST

REST的全称是Representational state transfer。具体如下:

  • Representational: 数据的表现形式(JSON、XML...)
  • state: 当前状态或者数据
  • transfer: 数据传输

它描述了一个系统如何与另一个交流。比如一个产品的状态(名字,详情)表现为 XML,JSON 或者普通文本。

REST 有六个约束:

  • 客户-服务器(Client-Server)

    关注点分离。服务端专注数据存储,提升了简单性,前端专注用户界面,提升了可移植性。

  • 无状态(Stateless)

    所有用户会话信息都保存在客户端。每次请求必须包括所有信息,不能依赖上下文信息。服务端不用保存会话信息,提升了简单性、可靠性、可见性。

  • 缓存(Cache)

    所有服务端响应都要被标为可缓存或不可缓存,减少前后端交互,提升了性能。

  • 统一接口(Uniform Interface)

    接口设计尽可能统一通用,提升了简单性、可见性。接口与实现解耦,使前后端可以独立开发迭代。

  • 分层系统(Layered System)
  • 按需代码(Code-On-Demand)

看完了 REST 的六个约束,下面让我们来看一下行业内对于RESTful API设计最佳实践的总结。

最佳实践

请求设计规范

  • URI 使用名词,尽量使用复数,如/users
  • URI 使用嵌套表示关联关系,如/users/123/repos/234
  • 使用正确的 HTTP 方法,如 GET/POST/PUT/DELETE

响应设计规范

  • 查询
  • 分页
  • 字段过滤

如果记录数量很多,服务器不可能都将它们返回给用户。API 应该提供参数,过滤返回结果。下面是一些常见的参数(包括上面的查询、分页以及字段过滤):

?limit=10:指定返回记录的数量
?offset=10:指定返回记录的开始位置。
?page=2&per_page=100:指定第几页,以及每页的记录数。
?sortby=name&order=asc:指定返回结果按照哪个属性排序,以及排序顺序。
?animal_type_id=1:指定筛选条件
  • 状态码
  • 错误处理

就像 HTML 的出错页面向访问者展示了有用的错误消息一样,API 也应该用之前清晰易读的格式来提供有用的错误消息。

比如对于常见的提交表单,当遇到如下错误信息时:

{
    "error": "Invalid payoad.",
    "detail": {
        "surname": "This field is required."
    }
}

接口调用者很快就能定位到错误原因。

安全

  • HTTPS
  • 鉴权

RESTful API 应该是无状态。这意味着对请求的认证不应该基于cookie或者session。相反,每个请求应该带有一些认证凭证。

  • 限流

为了避免请求泛滥,给 API 设置速度限制很重要。为此 RFC 6585 引入了 HTTP 状态码429(too many requests)。加入速度设置之后,应该给予用户提示。

上面说了这么多,下面让我们看一下如何在 Koa 中践行RESTful API最佳实践吧。

Koa 中实现 RESTful API

先来看一下完成后的项目目录结构:

|-- rest_node_api
    |-- .gitignore
    |-- README.md
    |-- package-lock.json
    |-- package.json      # 项目依赖
    |-- app
        |-- config.js     # 数据库(mongodb)配置信息
        |-- index.js      # 入口
        |-- controllers   # 控制器:用于解析用户输入,处理后返回相应的结果
        |-- models        # 模型(schema): 用于定义数据模型
        |-- public        # 静态资源
        |-- routes        # 路由

项目的目录呈现了清晰的分层、分模块结构,也便于后期的维护和扩展。下面我们会对项目中需要注意的几点一一说明。

Controller(控制器)

什么是控制器?

  • 拿到路由分配的任务并执行
  • 在 koa 中是一个中间件

为什么要用控制器

  • 获取 HTTP 请求参数

    • Query String,如?q=keyword
    • Router Params,如/users/:id
    • Body,如{name: 'jack'}
    • Header,如 Accept、Cookie
  • 处理业务逻辑
  • 发送 HTTP 响应

    • 发送 Status,如 200/400
    • 发送 Body,如{name: 'jack'}
    • 发送 Header,如 Allow、Content-Type

编写控制器的最佳实践

  • 每个资源的控制器放在不同的文件里
  • 尽量使用类+类方法的形式编写控制器
  • 严谨的错误处理

示例

app/controllers/users.js

const User = require("../models/users");
class UserController {
  async create(ctx) {
    ctx.verifyParams({
      name: { type: "string", required: true },
      password: { type: "string", required: true }
    });
    const { name } = ctx.request.body;
    const repeatedUser = await User.findOne({ name });
    if (repeatedUser) {
      ctx.throw(409, "用户名已存在");
    }
    const user = await new User(ctx.request.body).save();
    ctx.body = user;
  }
}

module.exports = new UserController();

错误处理机制

koa自带错误处理

要执行自定义错误处理逻辑,如集中式日志记录,您可以添加一个 “error” 事件侦听器:
app.on('error', err => {
  log.error('server error', err)
});

中间件

本项目中采用koa-json-error来处理错误,关于该中间件的详细介绍会在下文展开。

用户认证与授权

目前常用的用于用户信息认证与授权的有两种方式-JWTSession。下面我们分别对比一下两种鉴权方式的优劣点。

Session

  • 相关的概念介绍

    • session::主要存放在服务器,相对安全
    • cookie:主要存放在客户端,并且不是很安全
    • sessionStorage:仅在当前会话下有效,关闭页面或浏览器后被清除
    • localstorage:除非被清除,否则永久保存
  • 工作原理

    • 客户端带着用户名和密码去访问/login 接口,服务器端收到后校验用户名和密码,校验正确就会在服务器端存储一个 sessionId 和 session 的映射关系。
    • 服务器端返回 response,并且将 sessionId 以 set-cookie 的方式种在客户端,这样,sessionId 就存在了客户端。
    • 客户端发起非登录请求时,假如服务器给了 set-cookie,浏览器会自动在请求头中添加 cookie。
    • 服务器接收请求,分解 cookie,验证信息,核对成功后返回 response 给客户端。
  • 优势

    • 相比 JWT,最大的优势就在于可以主动清楚 session 了
    • session 保存在服务器端,相对较为安全
    • 结合 cookie 使用,较为灵活,兼容性较好(客户端服务端都可以清除,也可以加密)
  • 劣势

    • cookie+session 在跨域场景表现并不好(不可跨域,domain 变量,需要复杂处理跨域)
    • 如果是分布式部署,需要做多机共享 Session 机制(成本增加)
    • 基于 cookie 的机制很容易被 CSRF
    • 查询 Session 信息可能会有数据库查询操作

JWT

  • 相关的概念介绍

    由于详细的介绍 JWT 会占用大量文章篇幅,也不是本文的重点。所以这里只是简单介绍一下。主要是和 Session 方式做一个对比。关于 JWT 详细的介绍可以参考https://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html

JWT 的原理是,服务器认证以后,生成一个 JSON 对象,发回给用户,就像下面这样:

{
  "姓名": "森林",
  "角色": "搬砖工",
  "到期时间": "2020年1月198日16点32分"
}

以后,用户与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认证用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名。

服务器就不保存任何 session 数据了,也就是说,服务器变成无状态了,从而比较容易实现扩展。

JWT 的格式大致如下:

它是一个很长的字符串,中间用点(.)分隔成三个部分。

JWT 的三个部分依次如下:

Header(头部)
Payload(负载)
Signature(签名)
  • JWT相比Session

    • 安全性(两者均有缺陷)
    • RESTful API,JWT 优胜,因为 RESTful API 提倡无状态,JWT 符合要求
    • 性能(各有利弊,因为 JWT 信息较强,所以体积也较大。不过 Session 每次都需要服务器查找,JWT 信息都保存好了,不需要再去查询数据库)
    • 时效性,Session 能直接从服务端销毁,JWT 只能等到时效性到了才会销毁(修改密码也无法阻止篡夺者的使用)

jsonwebtoken

由于 RESTful API 提倡无状态,而 JWT 又恰巧符合这一要求,因此我们采用JWT来实现用户信息的授权与认证。

项目中采用的是比较流行的jsonwebtoken。具体使用方式可以参考https://www.npmjs.com/package/jsonwebtoken

实战

初始化项目

mkdir rest_node_api  # 创建文件目录
cd rest_node_api  # 定位到当前文件目录
npm init  # 初始化,得到`package.json`文件
npm i koa -S  # 安装koa
npm i koa-router -S  # 安装koa-router

基础依赖安装好后可以先搞一个hello-world

app/index.js

const Koa = require("koa");
const Router = require("koa-router");

const app = new Koa();
const router = new Router();

router.get("/", async function (ctx) {
    ctx.body = {message: "Hello World!"}
});

app.use(router.routes()).use(router.allowedMethods());

app.listen(3000);

相关中间件和插件依赖

koa-body

之前使用 koa2 的时候,处理 post 请求使用的是 koa-bodyparser,同时如果是图片上传使用的是 koa-multer。这两者的组合没什么问题,不过 koa-multer 和 koa-route(注意不是 koa-router) 存在不兼容的问题。

koa-body结合了二者,所以 koa-body 可以对其进行代替。

依赖安装

npm i koa-body -S

app/index.js

const koaBody = require('koa-body');
const app = new koa();
app.use(koaBody({
  multipart:true, // 支持文件上传
  encoding:'gzip',
  formidable:{
    uploadDir:path.join(__dirname,'public/uploads'), // 设置文件上传目录
    keepExtensions: true,    // 保持文件的后缀
    maxFieldsSize:2 * 1024 * 1024, // 文件上传大小
    onFileBegin:(name,file) => { // 文件上传前的设置
      // console.log(`name: ${name}`);
      // console.log(file);
    },
  }
}));

参数配置:

  • 基本参数

    参数名描述类型默认值
    patchNode将请求体打到原生 node.js 的ctx.reqBooleanfalse
    patchKoa将请求体打到 koa 的 ctx.requestBooleantrue
    jsonLimitJSON 数据体的大小限制String / Integer1mb
    formLimit限制表单请求体的大小String / Integer24kb
    textLimit限制 text body 的大小String / Integer23kb
    encoding表单的默认编码Stringutf-8
    multipart是否支持 multipart-formdate 的表单Booleanfalse
    urlencoded是否支持 urlencoded 的表单Booleantrue
    formidable配置更多的关于 multipart 的选项Object{}
    onError错误处理Functionfunction(){}
    stict严格模式,启用后不会解析 GET, HEAD, DELETE 请求Booleantrue
  • formidable 的相关配置参数

    参数名描述类型默认值
    maxFields限制字段的数量Integer500
    maxFieldsSize限制字段的最大大小Integer1 * 1024 * 1024
    uploadDir文件上传的文件夹Stringos.tmpDir()
    keepExtensions保留原来的文件后缀Booleanfalse
    hash如果要计算文件的 hash,则可以选择 md5/sha1Stringfalse
    multipart是否支持多文件上传Booleantrue
    onFileBegin文件上传前的一些设置操作Functionfunction(name,file){}

koa-json-error

在写接口时,返回json格式且易读的错误提示是有必要的,koa-json-error中间件帮我们做到了这一点。

依赖安装

npm i koa-json-error -S

app/index.js

const error = require("koa-json-error");
const app = new Koa();
app.use(
  error({
    postFormat: (e, { stack, ...rest }) =>
      process.env.NODE_ENV === "production" ? rest : { stack, ...rest }
  })
);

错误会默认抛出堆栈信息stack,在生产环境中,没必要返回给用户,在开发环境显示即可。

koa-parameter

采用koa-parameter用于参数校验,它是基于参数验证框架parameter, 给 koa 框架做的适配。

依赖安装

npm i koa-parameter -S

使用

// app/index.js
const parameter = require("koa-parameter");
app.use(parameter(app));

// app/controllers/users.js
 async create(ctx) {
    ctx.verifyParams({
      name: { type: "string", required: true },
      password: { type: "string", required: true }
    });
    ...
  }

因为koa-parameter是基于parameter的,只是做了一层封装而已,底层逻辑还是按照 parameter 来的,自定义规则完全可以参照 parameter 官方说明和示例来编写。

let TYPE_MAP = Parameter.TYPE_MAP = {
  number: checkNumber,
  int: checkInt,
  integer: checkInt,
  string: checkString,
  id: checkId,
  date: checkDate,
  dateTime: checkDateTime,
  datetime: checkDateTime,
  boolean: checkBoolean,
  bool: checkBoolean,
  array: checkArray,
  object: checkObject,
  enum: checkEnum,
  email: checkEmail,
  password: checkPassword,
  url: checkUrl,
};

koa-static

如果网站提供静态资源(图片、字体、样式、脚本......),为它们一个个写路由就很麻烦,也没必要。koa-static模块封装了这部分的请求。

app/index.js

const Koa = require("koa");
const koaStatic = require("koa-static");
const app = new Koa();
app.use(koaStatic(path.join(__dirname, "public")));

连接数据库

数据库我们采用的是mongodb,连接数据库前,我们要先来看一下mongoose

mongoosenodeJS提供连接 mongodb的一个库,类似于jqueryjs的关系,对mongodb一些原生方法进行了封装以及优化。简单的说,Mongoose就是对node环境中MongoDB数据库操作的封装,一个对象模型(ODM)工具,将数据库中的数据转换为JavaScript对象以供我们在应用中使用。

安装 mongoose

npm install mongoose -S

连接及配置

const mongoose = require("mongoose");
mongoose.connect(
  connectionStr,  // 数据库地址
  { useUnifiedTopology: true, useNewUrlParser: true },
  () => console.log("mongodb 连接成功了!")
);
mongoose.connection.on("error", console.error);

用户的 CRUD

项目中的模块是比较多的,我不会一一去演示,因为各个模块实质性的内容是大同小异的。在这里主要是以用户模块的crud为例来展示下如何在 koa 中践行RESTful API最佳实践

app/index.js(koa 入口)

入口文件主要用于创建 koa 服务、装载 middleware(中间件)、路由注册(交由 routes 模块处理)、连接数据库等。

const Koa = require("koa");
const path = require("path");
const koaBody = require("koa-body");
const koaStatic = require("koa-static");
const parameter = require("koa-parameter");
const error = require("koa-json-error");
const mongoose = require("mongoose");
const routing = require("./routes");
const app = new Koa();
const { connectionStr } = require("./config");
mongoose.connect(  // 连接mongodb
  connectionStr,
  { useUnifiedTopology: true, useNewUrlParser: true },
  () => console.log("mongodb 连接成功了!")
);
mongoose.connection.on("error", console.error);

app.use(koaStatic(path.join(__dirname, "public")));  // 静态资源
app.use(  // 错误处理
  error({
    postFormat: (e, { stack, ...rest }) =>
      process.env.NODE_ENV === "production" ? rest : { stack, ...rest }
  })
);
app.use(  // 处理post请求和图片上传
  koaBody({
    multipart: true,
    formidable: {
      uploadDir: path.join(__dirname, "/public/uploads"),
      keepExtensions: true
    }
  })
);
app.use(parameter(app));  // 参数校验
routing(app);  // 路由处理

app.listen(3000, () => console.log("程序启动在3000端口了"));

app/routes/index.js

由于项目模块较多,对应的路由也很多。如果一个个的去注册,有点太麻烦了。这里用 node 的 fs 模块去遍历读取 routes 下的所有路由文件,统一注册。

const fs = require("fs");

module.exports = app => {
  fs.readdirSync(__dirname).forEach(file => {
    if (file === "index.js") {
      return;
    }
    const route = require(`./${file}`);
    app.use(route.routes()).use(route.allowedMethods());
  });
};

app/routes/users.js

用户模块路由,里面主要涉及到了用户的登录以及增删改查。

const jsonwebtoken = require("jsonwebtoken");
const jwt = require("koa-jwt");
const { secret } = require("../config");
const Router = require("koa-router");
const router = new Router({ prefix: "/users" });  // 路由前缀
const {
  find,
  findById,
  create,
  checkOwner,
  update,
  delete: del,
  login,
} = require("../controllers/users");  // 控制器方法

const auth = jwt({ secret });  // jwt鉴权

router.get("/", find);  // 获取用户列表

router.post("/", auth, create);  // 创建用户(需要jwt认证)

router.get("/:id", findById);  // 获取特定用户

router.patch("/:id", auth, checkOwner, update);  // 更新用户信息(需要jwt认证和验证操作用户身份)

router.delete("/:id", auth, checkOwner, del);  // 删除用户(需要jwt认证和验证操作用户身份)

router.post("/login", login);  // 用户登录

module.exports = router;

app/models/users.js

用户数据模型(schema)

const mongoose = require("mongoose");

const { Schema, model } = mongoose;

const userSchema = new Schema(
  {
    __v: { type: Number, select: false },
    name: { type: String, required: true },  // 用户名
    password: { type: String, required: true, select: false },  // 密码
    avatar_url: { type: String },  // 头像
    gender: {  //   性别
      type: String,
      enum: ["male", "female"],
      default: "male",
      required: true
    },
    headline: { type: String },  // 座右铭
    locations: {  // 居住地
      type: [{ type: Schema.Types.ObjectId, ref: "Topic" }],
      select: false
    },
    business: { type: Schema.Types.ObjectId, ref: "Topic", select: false },  // 职业
  },
  { timestamps: true }
);

module.exports = model("User", userSchema);

app/controllers/users.js

用户模块控制器,用于处理业务逻辑

const User = require("../models/users");
const jsonwebtoken = require("jsonwebtoken");
const { secret } = require("../config");
class UserController {
  async find(ctx) {  // 查询用户列表(分页)
    const { per_page = 10 } = ctx.query;
    const page = Math.max(ctx.query.page * 1, 1) - 1;
    const perPage = Math.max(per_page * 1, 1);
    ctx.body = await User.find({ name: new RegExp(ctx.query.q) })
      .limit(perPage)
      .skip(page * perPage);
  }
  async findById(ctx) {  // 根据id查询特定用户
    const { fields } = ctx.query;
    const selectFields =  // 查询条件
      fields &&
      fields
        .split(";")
        .filter(f => f)
        .map(f => " +" + f)
        .join("");
    const populateStr =  // 展示字段
      fields &&
      fields
        .split(";")
        .filter(f => f)
        .map(f => {
          if (f === "employments") {
            return "employments.company employments.job";
          }
          if (f === "educations") {
            return "educations.school educations.major";
          }
          return f;
        })
        .join(" ");
    const user = await User.findById(ctx.params.id)
      .select(selectFields)
      .populate(populateStr);
    if (!user) {
      ctx.throw(404, "用户不存在");
    }
    ctx.body = user;
  }
  async create(ctx) {  // 创建用户
    ctx.verifyParams({  // 入参格式校验
      name: { type: "string", required: true },
      password: { type: "string", required: true }
    });
    const { name } = ctx.request.body;
    const repeatedUser = await User.findOne({ name });
    if (repeatedUser) {  // 校验用户名是否已存在
      ctx.throw(409, "用户名已存在");
    }
    const user = await new User(ctx.request.body).save();
    ctx.body = user;
  }
  async checkOwner(ctx, next) {  // 判断用户身份合法性
    if (ctx.params.id !== ctx.state.user._id) {
      ctx.throw(403, "没有权限");
    }
    await next();
  }
  async update(ctx) {  // 更新用户信息
    ctx.verifyParams({
      name: { type: "string", required: false },
      password: { type: "string", required: false },
      avatar_url: { type: "string", required: false },
      gender: { type: "string", required: false },
      headline: { type: "string", required: false },
      locations: { type: "array", itemType: "string", required: false },
      business: { type: "string", required: false },
    });
    const user = await User.findByIdAndUpdate(ctx.params.id, ctx.request.body);
    if (!user) {
      ctx.throw(404, "用户不存在");
    }
    ctx.body = user;
  }
  async delete(ctx) {  // 删除用户
    const user = await User.findByIdAndRemove(ctx.params.id);
    if (!user) {
      ctx.throw(404, "用户不存在");
    }
    ctx.status = 204;
  }
  async login(ctx) {  // 登录
    ctx.verifyParams({
      name: { type: "string", required: true },
      password: { type: "string", required: true }
    });
    const user = await User.findOne(ctx.request.body);
    if (!user) {
      ctx.throw(401, "用户名或密码不正确");
    }
    const { _id, name } = user;
    const token = jsonwebtoken.sign({ _id, name }, secret, { expiresIn: "1d" });  // 登录成功返回jwt加密后的token信息
    ctx.body = { token };
  }
  async checkUserExist(ctx, next) {  // 查询用户是否存在
    const user = await User.findById(ctx.params.id);
    if (!user) {
      ctx.throw(404, "用户不存在");
    }
    await next();
  }

}

module.exports = new UserController();

postman演示

登录

获取用户列表

获取特定用户

创建用户

更新用户信息

删除用户

最后

到这里本篇文章内容也就结束了,这里主要是结合用户模块来给大家讲述一下RESTful API最佳实践在 koa 项目中的运用。项目的源码已经开源,地址是https://github.com/Jack-cool/rest_node_api。需要的自取,感觉不错的话麻烦给个 star!!

同时你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。

查看原文

赞 4 收藏 3 评论 2

前端森林 发布了文章 · 1月21日

你不知道的npm

npm.jpeg

引言

作为 node 自带的包管理器工具,在 nodejs 社区和 web 前端工程化领域发展日益庞大的背景下,npm已经成为每位前端开发同学必备的工具。

每天,无数的开发人员使用npm来构建项目,npm initnpm install等方式几乎成为了构建项目的首选方式,但是大多数同学对于 npm 的使用却只停留在了npm install这里。(相信删除 node_modules 文件夹,重新执行 npm install 这种事情你应该做过吧)

本篇文章主要是结合我以往的经验,带大家更深层次的了解一下 npm

npm 中的依赖包

依赖包类型

npm 目前支持一下几种类型的依赖包管理

  • dependencies
  • devDependencies
  • peerDependencies
  • optionalDependencies
  • bundledDependencies / bundleDependencies
  "dependencies": {
    "koa": "^2.7.0",
    "koa-bodyparser": "^4.2.1",
    "koa-redis": "^4.0.0",
  },
  "devDependencies": {
    "babel-eslint": "^10.0.3",
    "cross-env": "^6.0.3",
    "lint-staged": "^9.5.0",
    "mysql2": "^2.1.0",
    "nodemon": "^1.19.1",
    "precommit": "^1.2.2",
    "redis": "^2.8.0",
    "sequelize": "^5.21.3",
  },
  "peerDependencies": {},
  "optionalDependencies": {},
  "bundledDependencies": []

dependencies

应用依赖,或者叫做业务依赖,是我们最常用的一种。这种依赖是应用发布后上线所需要的,也就是说其中的依赖项属于线上代码的一部分。比如框架react,第三方的组件库ant-design等。可通过下面的命令来安装:

npm i ${packageName} -S

devDependencies

开发环境依赖。这种依赖只在项目开发时所需要,比如构建工具webpackgulp,单元测试工具jestmocha等。可通过下面的命令来安装:

npm i ${packageName} -D

peerDependencies

同行依赖。这种依赖的作用是提示宿主环境去安装插件在peerDependencies中所指定依赖的包,用于解决插件与所依赖包不一致的问题。

听起来可能没有那么好理解,举个例子来说明下。antd@3.19.5只是提供了一套基于reactui组件库,但它要求宿主环境需要安装指定的react版本,所以你可以看到 node_modules 中 antd 的package.json中有这么一项配置:

"peerDependencies": {
    "react": ">=16.0.0",
    "react-dom": ">=16.0.0"
  },

它要求宿主环境安装大于等于16.0.0版本的react,也就是antd的运行依赖宿主环境提供的该范围的react安装包。

在安装插件的时候,peerDependencies 在npm 2.xnpm 3.x中表现不一样。npm2.x 会自动安装同等依赖,npm3.x 不再自动安装,会产生警告!手动在package.json文件中添加依赖项可以解决。

optionalDependencies

可选依赖。这种依赖中的依赖包即使安装失败了,也不影响整个安装的过程。需要注意的是,optionalDependencies会覆盖dependencies中的同名依赖包,所以不要在两个地方都写。

在实际项目中,如果某个包已经失效,我们通常会寻找它的替代方案。不确定的依赖会增加代码判断和测试难度,所以这个依赖项还是尽量不要使用。

bundledDependencies / bundleDependencies

打包依赖。如果在打包发布时希望一些依赖包也出现在最终的包里,那么可以将包的名字放在bundledDependencies中,bundledDependencies 的值是一个字符串数组,如:

{
  "name": "sequelize-test",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "mysql2": "^2.1.0"
  },
  "devDependencies": {
    "sequelize": "^5.21.3"
  },
   "bundledDependencies": [
    "mysql2",
    "sequelize"
  ]
}

执行打包命令npm pack,在生成的sequelize-test-1.0.0.tgz包中,将包含mysql2sequelize

需要注意的是,在bundledDependencies中指定的依赖包,必须先在 dependencies 和 devDependencies 声明过,否则打包会报错。

语义化版本控制

为了在软件版本号中包含更多意义,反映代码所做的修改,产生了语义化版本,软件的使用者能从版本号中推测软件做的修改。npm 包使用语义化版控制,我们可安装一定版本范围的 npm 包,npm 会选择和你指定的版本相匹配 的 (latest)最新版本安装。

npm 采用了semver规范作为依赖版本管理方案。版本号由三部分组成:主版本号次版本号补丁版本号。变更不同的版本号,代表不同的意义:

  • 主版本号(major):软件做了不兼容的变更(breaking change 重大变更)
  • 次版本号(minor):添加功能或者废弃功能,向下兼容
  • 补丁版本号(patch):bug 修复,向下兼容

下面让我们来看下常用的几个版本格式:

  • "compression": "1.7.4"

表示精确版本号。任何其他版本号都不匹配。在一些比较重要的线上项目中,建议使用这种方式锁定版本。

  • "typescript": "^3.4.3"

表示兼容补丁和小版本更新的版本号。官方的定义是能够兼容除了最左侧的非 0 版本号之外的其他变化。这句话不是很好理解,举几个例子大家就明白了:

"^3.4.3" 等价于 `">= 3.4.3 < 4.0.0"。即只要最左侧的 "3" 不变,其他都可以改变。所以 "3.4.5", "3.6.2" 都可以兼容。

"^0.4.3" 等价于 ">= 0.4.3 < 0.5.0"。因为最左侧的是 "0",那么只要第二位 "4" 不变,其他的都兼容,比如 "0.4.5" 和 "0.4.99"。

"^0.0.3" 等价于 ">= 0.0.3 < 0.0.4"。大版本号和小版本号都为 "0" ,所以也就等价于精确的 "0.0.3"。
  • "mime-types": "~2.1.24"

表示只兼容补丁更新的版本号。关于 ~ 的定义分为两部分:如果列出了小版本号(第二位),则只兼容补丁(第三位)的修改;如果没有列出小版本号,则兼容第二和第三位的修改。我们分两种情况理解一下这个定义:

"~2.1.24" 列出了小版本号 "1",因此只兼容第三位的修改,等价于 ">= 2.1.24 < 2.2.0"。

"~2.1" 也列出了小版本号 "2",因此和上面一样兼容第三位的修改,等价于 ">= 2.1.0 < 2.2.0"。

"~2" 没有列出小版本号,可以兼容第二第三位的修改,因此等价于 ">= 2.0.0 < 3.0.0"
  • "underscore-plus": "1.x""uglify-js": "3.4.x"

除了上面的xX还有*和(),这些都表示使用通配符的版本号,可以匹配任何内容。具体来说:

"*" 、"x" 或者 (空) 表示可以匹配任何版本。
"1.x", "1.*" 和 "1" 表示匹配主版本号为 "1" 的所有版本,因此等价于 ">= 1.0.0 < 2.0.0"。

"1.2.x", "1.2.*" 和 "1.2" 表示匹配版本号以 "1.2" 开头的所有版本,因此等价于 ">= 1.2.0 < 1.3.0"。
  • "css-tree": "1.0.0-alpha.33""@vue/test-utils": "1.0.0-beta.29"

有时候为了表达更加确切的版本,还会在版本号后面添加标签或者扩展,来说明是预发布版本或者测试版本等。常见的标签有:

标签含义补充
demodemo 版本可能用于验证问题的版本
dev开发版开发阶段用的,bug 多,体积较大等特点,功能不完善
alphaα 版本预览版,或者叫内部测试版;一般不向外发布,会有很多 bug;一般只有测试人员使用。
beta测试版(β 版本)测试版,或者叫公开测试版;这个阶段的版本会一直加入新的功能;在 alpha 版之后推出。
gamma(γ)伽马版本较 α 和 β 版本有很大的改进,与稳定版相差无几,用户可使用
trial试用版本本软件通常都有时间限制,过期之后用户如果希望继续使用,一般得交纳一定的费用进行注册或购买。有些试用版软件还在功能上做了一定的限制。
csp内容安全版本js 库常用
rc最终测试版本可能成为最终产品的候选版本,如果未出现问题则可发布成为正式版本
latest最新版本不指定版本和标签,npm 默认安最新版
stable稳定版

npm install 原理分析

我们都知道,执行npm install后,依赖包被安装到了node_modules中。虽然在实际开发中我们无需十分关注里面具体的细节,但了解node_modules中的内容可以帮助我们更好的理解npm安装依赖包的具体机制。

嵌套结构

在 npm 的早期版本中,npm 处理依赖的方式简单粗暴,以递归的方式,严格按照 package.json 结构以及子依赖包的 package.json 结构将依赖安装到他们各自的 node_modules 中。

举个例子,我们的项目ts-axios现在依赖了两个模块: axiosbody-parser:

{
  "name": "ts-axios",
  "dependencies": {
    "axios": "^0.19.0",
    "body-parser": "^1.19.0",
  }
}

axios依赖了follow-redirectsis-buffer模块:

{
  "name": "axios",
  "dependencies": {
      "follow-redirects": "1.5.10",
      "is-buffer": "^2.0.2"
    },
}

body-parser依赖了bytescontent-type等模块:

{
  "name": "body-parser",
  "dependencies": {
    "bytes": "3.1.0",
    "content-type": "~1.0.4",
     ...
  }
}

那么,执行 npm install 后,得到的 node_modules 中模块目录结构就是下面这样的:

这样的方式优点很明显, node_modules 的结构和 package.json 结构一一对应,层级结构明显,并且保证了每次安装目录结构都是相同的。

但是,试想一下,如果你依赖的模块非常之多,你的 node_modules 将非常庞大,嵌套层级非常之深:

从上图这种情况,我们不难得出嵌套结构拥有以下缺点:

  • 在不同层级的依赖中,可能引用了同一个模块,导致大量冗余
  • 嵌套层级过深可能导致不可预知的问题

扁平结构

为了解决以上问题,npm 在 3.x 版本做了一次较大更新。其将早期的嵌套结构改为扁平结构。

安装模块时,不管其是直接依赖还是子依赖的依赖,优先将其安装在 node_modules 根目录。

还是上面的依赖结构,我们在执行 npm install 后将得到下面的目录结构:

此时我们若在模块中又依赖了 is-buffer@2.0.1 版本:

{
  "name": "ts-axios",
  "dependencies": {
    "axios": "^0.19.0",
    "body-parser": "^1.19.0",
    "is-buffer": "^2.0.1"
  }
}

当安装到相同模块时,判断已安装的模块版本是否符合新模块的版本范围,如果符合则跳过,不符合则在当前模块的 node_modules 下安装该模块。

此时,我们在执行 npm install 后将得到下面的目录结构:

对应的,如果我们在项目代码中引用了一个模块,模块查找流程如下:

  • 在当前模块路径下搜索
  • 在当前模块 node_modules 路径下搜索
  • 在上级模块的 node_modules 路径下搜索
  • ...
  • 直到搜索到全局路径中的 node_modules

假设我们又依赖了一个包 axios2@^0.19.0,而它依赖了包 is-buffer@^2.0.3,则此时的安装结构是下面这样的:

所以 npm 3.x 版本并未完全解决老版本的模块冗余问题,甚至还会带来新的问题。

我们在 package.json 通常只会锁定大版本,这意味着在某些依赖包小版本更新后,同样可能造成依赖结构的改动,依赖结构的不确定性可能会给程序带来不可预知的问题。

package-lock.json

为了解决 npm install 的不确定性问题,在 npm 5.x 版本新增了 package-lock.json 文件,而安装方式还沿用了 npm 3.x 的扁平化的方式。

package-lock.json 的作用是锁定依赖结构,即只要你目录下有 package-lock.json 文件,那么你每次执行 npm install 后生成的 node_modules 目录结构一定是完全相同的。

例如,我们有如下的依赖结构:

{
  "name": "ts-axios",
  "dependencies": {
    "axios": "^0.19.0",
  }
}

在执行 npm install 后生成的 package-lock.json 如下:

{
  "name": "ts-axios",
  "version": "0.1.0",
  "dependencies": {
      "axios": {
        "version": "0.19.0",
        "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.0.tgz",
        "integrity": "sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ==",
        "requires": {
          "follow-redirects": "1.5.10",
          "is-buffer": "^2.0.2"
        },
        "dependencies": {
          "debug": {
            "version": "3.1.0",
            "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
            "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
            "requires": {
              "ms": "2.0.0"
            }
          },
          "follow-redirects": {
            "version": "1.5.10",
            "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz",
            "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==",
            "requires": {
              "debug": "=3.1.0"
            }
          },
          "is-buffer": {
            "version": "2.0.3",
            "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz",
            "integrity": "sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw=="
          },
          "ms": {
            "version": "2.0.0",
            "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
            "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
          }
      }
    },
  }
}

最外面的两个属性 nameversionpackage.json 中的 nameversion ,用于描述当前包名称和版本。

dependencies 是一个对象,对象和 node_modules 中的包结构一一对应,对象的 key 为包名称,值为包的一些描述信息:

  • version: 包唯一的版本号
  • resolved: 安装来源
  • integrity: 表明包完整性的 hash 值(验证包是否已失效)
  • requires: 依赖包所需要的所有依赖项,与子依赖的 package.jsondependencies的依赖项相同。
  • dependencies: 依赖包node_modules中依赖的包,与顶层的dependencies一样的结构

这里注意,并不是所有的子依赖都有 dependencies 属性,只有子依赖的依赖和当前已安装在根目录的 node_modules 中的依赖冲突之后,才会有这个属性。

通过以上几个步骤,说明package-lock.json文件和node_modules目录结构是一一对应的,即项目目录下存在package-lock.json可以让每次安装生成的依赖目录结构保持相同。

在开发一个应用时,建议把package-lock.json文件提交到代码版本仓库,从而让你的团队成员、运维部署人员或CI系统可以在执行npm install时安装的依赖版本都是一致的。

npm scripts 脚本

脚本功能是 npm 最强大、最常用的功能之一。

npm 允许在package.json文件中使用scripts字段来定义脚本命令。以vue-cli3为例:

"scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "test:unit": "vue-cli-service test:unit"
  },

这样就可以通过npm run serve脚本替代vue-cli-service serve脚本来启动项目,而无需每次都敲一遍冗长的脚本。

原理

这里我们参考一下阮老师的文章:

npm 脚本的原理非常简单。每当执行 npm run,就会自动新建一个 Shell,在这个 Shell 里面执行指定的脚本命令。因此,只要是 Shell(一般是 Bash)可以运行的命令,就可以写在 npm 脚本里面。
比较特别的是,npm run 新建的这个 Shell,会将当前目录的node_modules/.bin子目录加入 PATH 变量,执行结束后,再将 PATH 变量恢复原样。

传入参数

在原有脚本后面加上 -- 分隔符, 后面再加上参数,就可以将参数传递给 script 命令了,比如 eslint 内置了代码风格自动修复模式,只需给它传入 -–fix 参数即可,我们可以这样写:

"scripts": {
    "lint": "vue-cli-service lint --fix",
  },

除了第一个可执行的命令,以空格分割的任何字符串(除了一些 shell 的语法)都是参数,并且都能通过process.argv属性访问。

process.argv 属性返回一个数组,其中包含当启动 Node.js 进程时传入的命令行参数。 第一个元素是 process.execPath,表示启动 node 进程的可执行文件的绝对路径名。第二个元素为当前执行的 JavaScript 文件路径。剩余的元素为其他命令行参数。

执行顺序

如果 npm 脚本里面需要执行多个任务,那么需要明确它们的执行顺序。

如果是串行执行,即要求前一个任务执行成功之后才能执行下一个任务。使用&&符号连接。

npm run script1 && npm run script2
串行命令执行过程中,只要一个命令执行失败,则整个脚本将立刻终止。

如果是并行执行,即多个任务可以同时执行。使用&符号来连接。

npm run script1 & npm run script2

钩子

这里的钩子和vuereact里面的生命周期有点相似。

npm 脚本有prepost两个钩子。在执行 npm scripts 命令(无论是自定义还是内置)时,都经历了 pre 和 post 两个钩子,在这两个钩子中可以定义某个命令执行前后的命令。

比如,在用户执行npm run build的时候,会自动按照下面的顺序执行。

npm run prebuild && npm run build && npm run postbuild

当然,如果没有指定prebuildpostbuild,会默默的跳过。如果想要指定钩子,必须严格按照 pre 和 post 前缀来添加。

环境变量

npm 脚本有一个非常强大的功能,就是可以使用 npm 的内部变量。

在执行npm run脚本时,npm 会设置一些特殊的env环境变量。其中 package.json 中的所有字段,都会被设置为以npm_package_ 开头的环境变量。比如 package.json 中有如下字段内容:

{
  "name": "sequelize-test",
  "version": "1.0.0",
  "description": "sequelize测试",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "mysql2": "^2.1.0",
    "sequelize": "^5.21.3"
  }
}

那么,变量npm_package_name返回sequelize-test,变量npm_package_description返回sequelize测试。也就是:

console.log(process.env.npm_package_name)  // sequelize-test

console.log(process.env.npm_package_description)  // sequelize测试

npm 配置

优先级

npm 从以下来源获取配置信息(优先级由高到低):

命令行

npm run dev --foo=bar

执行上述命令,会将配置项foo的值设为bar,通过process.env.npm_config_foo可以访问其配置值。这个时候的 foo 配置值将覆盖所有其他来源存在的 foo 配置值。

环境变量

如果 env 环境变量中存在以npm_config_为前缀的环境变量,则会被识别为 npm 的配置属性。比如,环境变量中的npm_config_foo=bar 将会设置配置参数 foo 的值为 "bar"

如果只指定了参数名却没有指定任何值的配置参数,其值将会被设置为 true

npmrc文件

通过修改 npmrc 文件可以直接修改配置。系统中存在多个 npmrc 文件,这些 npmrc 文件被访问的优先级从高到低的顺序为:

  • 项目配置文件

只作用在本项目下。在其他项目中,这些配置不生效。通过创建这个.npmrc 文件可以统一团队的 npm 配置规范。路径为/path/to/my/project/.npmrc

  • 用户配置文件

默认为~/.npmrc/,可通过npm config get userconfig查看存放的路径。

  • 全局配置文件

    通过npm config get globalconfig可以查看具体存放的路径。

  • npm 内置的配置文件

这是一个不可更改的内置配置文件,为了维护者以标准和一致的方式覆盖默认配置。mac下的路径为/path/to/npm/npmrc

默认配置

通过npm config ls -l查看 npm 内部的默认配置参数。如果命令行、环境变量、所有配置文件都没有配置参数,则使用默认参数值。

npm config 指令

set

npm config set <key> <value> [-g|--global]
npm config set registry <url>  # 指定下载 npm 包的来源,默认为 https://registry.npmjs.org/ ,可以指定私有源

设置配置参数 key 的值为 value,如果省略 value,key 会被设置为 true

get

npm config get <key>

查看配置参数 key 的值。

delete

npm config delete <key>

删除配置参数 key。

list

npm config list [-l] [--json]

查看所有设置过的配置参数。使用 -l 查看所有设置过的以及默认的配置参数。使用 --json 以 json 格式查看。

edit

npm config edit

在编辑器中打开 npmrc 文件,使用 --global 参数打开全局 npmrc 文件。

总结

以上就是我关于 npm 的一些深度挖掘,当然有很多方面没有总结到位,后续我会在实战的过程中,不断总结,随时更新。也欢迎大佬随时来吐槽

最后

你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。当然,我也是开源社区的积极贡献者,github地址https://github.com/Jack-cool,欢迎star!!!

查看原文

赞 2 收藏 2 评论 0

前端森林 发布了文章 · 1月21日

浅谈Hybrid

引言

随着 Web 技术和移动设备的飞速发展,各种 APP 层出不穷,极速的业务扩展提高了团队对开发效率的要求,这个时候使用 IOS/Andriod 开发一个 APP 似乎成本有点过高了,而 H5 的低成本、高效率、跨平台等特性马上被利用起来形成了一种新的开发模式:Hybrid APP

Hybrid 技术已经成为一种最主流最常见的方案。一套好的 Hybrid 架构解决方案能让 App 既能拥有极致的体验和性能,同时也能拥有 Web 技术 灵活的开发模式、跨平台能力以及热更新机制。本文主要是结合我最近开发的一个 Hybrid 项目(https://github.com/Jack-cool/hybrid_jd),带大家全面了解一下 Hybrid。

现有混合方案

深入了解 Hybrid 前,让我们先来看一下目前市面上比较成熟的混合解决方案。

基于 WebView UI 的基础方案

这种是市面上大多数 app 采取的方案,也是混合开发最基础的方案。在 webview 的基础上,与原生客户端建立js bridge桥接,以达到 js 调用Native API和 Native 执行js方法的目的。

目前国内绝大部分的大厂都有一套自己的基于 webview ui 的 hybrid 解决方案,例如微信的JS-SDK,支付宝的JSAPI等,通过JSBridge完成 h5 与 Native 的双向通讯,从而赋予 H5 一定程度的原生能力。

基于 Native UI 的方案

可以简单理解为“跨平台”,现在比较通用的有React NativeWeexFlutter等。在赋予 H5 原生 API 能力的基础上,进一步通过 JSBridge 将 JS 解析成的虚拟节点数(Virtual DOM)传递到 Native 并使用原生渲染。我们这里来看下上面提到的这三种:

React Native

“Learn once, write anywhere”,React Native采用了 React 的设计模式,但 UI 渲染、动画效果、网络请求等均由原生端实现(由于 JS 是单线程,不大可能处理太多耗时的操作)。开发者编写的 JS 代码,通过 React Native 的中间层转化为原生控件和操作,极大的提高了用户体验。

React Native所有的标签都不是真实控件,JS 代码中所写控件的作用,类似 Map 中的 key 值。JS 端通过这个 key 组合的 Dom ,最后 Native 端会解析这个 Dom ,得到对应的 Native 控件渲染,如 Android 中 标签对应 ViewGroup 控件。

总结下来,就是:React Native 是利用 JS 来调用 Native 端的组件,从而实现相应的功能。

Weex

“Write once, run everywhere”,基于 Vue 设计模式,支持 web、android、ios 三端,原生端同样通过中间层转化,将控件和操作转化为原生逻辑来提升用户体验。

在 weex 中,主要包括三大部分:JS BridgeRenderDom,JS Bridge 主要用来和 JS 端实现进行双向通信,比如把 JS 端的 dom 结构传递给 Dom 线程。Dom 主要是用于负责 dom 的解析、映射、添加等等的操作,最后通知 UI 线程更新。而 Render 负责在 UI 线程中对 dom 实现渲染。

和 react native 一样,weex 所有的标签也都不是真实控件,JS 代码中所生成的 dom,最终都是由 Native 端解析,再得到对应的 Native 控件渲染,如 Android 中 标签对应 WXTextView 控件。

Flutter

Flutter 是谷歌 2018 年发布的跨平台移动 UI 框架。与 react native 和 weex 的通过 Javascript 开发不同,Flutter 的编程语言是Dart,所以执行时并不需要 Javascript 引擎,但实际效果最终也通过原生渲染。

看完这三种方案的简介,下面让我们简单来做个对比吧:

React NativeWeexFlutter
平台实现JavaScriptJavaScript原生编码
引擎JS V8JSCoreFlutter engine
核心语言ReactVueDart
框架程度较重较轻
特点适合开发整体 App适合单页面适合开发整体 App
支持Android、IOSAndroid、IOS、WebAndroid、IOS(可能还不止)
Apk 大小(Release)7.6M10.6M8.1M

小程序

小程序开发本质上还是前端 HTML + CSS + JS 那一套逻辑,它基于 WebView 和微信(当然支付宝、百度、字节等现在都有自己的小程序,这里只是拿微信小程序做个说明)自己定义的一套 JS/WXML/WXSS/JSON 来开发和渲染页面。微信官方文档里提到,小程序运行在三端:iOS、Android 和用于调试的开发者工具,三端的脚本执行环境以及用于渲染非原生组件的环境是各不相同的。

通过更加定制化的 JSBridge,并使用双 WebView 双线程的模式隔离了 JS 逻辑与 UI 渲染,形成了特殊的开发模式,加强了 H5 与 Native 混合程度,提高了页面性能及开发体验。

PWA

Progressive Web App, 简称 PWA,是提升 Web App 体验的一种新方法,能给用户带来原生应用的体验。

PWA 能做到原生应用的体验不是靠某一项特定的技术,而是经过应用一系列新技术进行改进,在安全、性能和体验三个方面都有了很大的提升,PWA 本质上还是 Web App,并兼具了 Native App 的一些特性和优点,主要包括下面三点:

  • 可靠 - 即使在不稳定的网络环境下,也能快速加载并展现
  • 体验 - 快速响应,并且有平滑的动画响应用户的操作
  • 粘性 - 设备上的原生应用,具有沉浸式的用户体验,用户可以添加到桌面

Android 和主流的浏览器都早已支持了 PWA 标准,在 iOS 11.3 和 macOS 10.13.4 上,苹果的 Safari 上也支持了 PWA。相信在不久的将来势必会迎来 PWA 的大爆发...

看完目前主流的混合解决方案,我们回归本篇主题,讲解一下成熟解决方案背后的 Hybrid底层基础,要知道决定上层建筑的永远都是底层基础,新的技术层出不穷,只有原理是不变的~~

Hybrid 是什么,为什么要用 Hybrid?

Hybrid,字面意思“混合”。可以简单理解为是前端和客户端的混合开发。

让我们先来看一下目前主流的移动应用开发方式:

Native APP

Native App 是一种基于智能手机本地操作系统如 iOS、Android、WP 并使用原生程式编写运行的第三方应用程序,也叫本地 app。一般使用的开发语言为 Java、C++、Objective-C。。分别来看一下 Native 开发的优缺点:

  • 优点

    • 用户体验近乎完美
    • 性能稳定
    • 访问本地资源(通讯录、相册)
    • 操作流畅
    • 设计出色的动效、转场
    • 系统级的贴心通知或提醒
    • 用户留存率高
  • 缺点

    • 门槛高,原生开发人才稀缺,至少比前端和后端少,开发环境昂贵
    • 发布成本高,需要通过 store 或 market 的审核,导致更新缓慢
    • 维持多个版本、多个系统的成本比较高,而且必须做兼容
    • 无法跨平台,开发的成本比较大,各个系统独立开发

Web APP

Web App,顾名思义是指基于 Web 的应用,基本采用 Html5 语言写出,不需要下载安装。类似于现在所说的轻应用。基于浏览器运行的应用,基本上可以说是触屏版的网页应用。分别来看一下 Web 开发的优缺点:

  • 优点

    • 开发成本低
    • 临时入口,可以随意嵌入
    • 无需安装,不会占用手机内存,而且更新速度最快
    • 能够跨多个平台和终端
    • 不存在多版本问题,维护成本低
  • 缺点

    • 无法获取系统级别的通知,提醒,动效等等
    • 设计受限制较多
    • 体验较差
    • 受限于手机和浏览器性能,用户体验相较于其他模式最差
    • 用户留存率低

究其原因就是性能要求的问题。Web app 之所以能够占领开发市场,主要是因为它的开发速度快,使用简单,应用范围广,但是在性能方面因为无法调用全部硬件底层功能,就现在讲,还是比不过原生 App 的性能。

Hybrid APP

混合开发,也就是半原生半 Web 的开发模式,由原生提供统一的 API 给 JS 调用,实际的主要逻辑有 Html 和 JS 来完成,最终是放在 webview 中显示的,所以只需要写一套代码即可达到跨平台效果。

Hybrid App 兼具了 Native APP 用户体验佳、系统功能强大和 Web APP 跨平台、更新速度快的优势。本质其实是在原生的 App 中,使用 WebView 作为容器直接承载 Web 页面。因此,最核心的点就是 Native 端 与 H5 端 之间的双向通讯层,也就是我们常说的 JSBridge

下面让我们来看下 JS 与 Native(客户端)通信的方式吧。

JS 与客户端通信

JS 通知客户端(Native)

JS上下文注入

原理其实就是 Native 获取 JavaScript 环境上下文,并直接在上面挂载对象或者方法,使 JS 可以直接调用。

Android 与 IOS 分别拥有对应的挂载方式。分别对应是:苹果UIWebview JavaScriptCore注入安卓addJavascriptInterface注入苹果WKWebView scriptMessageHandler注入

上面这三种方式都可以被称为是JS上下文注入,他们都有一个共同的特点就是,不通过任何拦截的办法,而是直接将一个 native 对象(or 函数)注入到 JS 里面,可以由 Web 的 JS 代码直接调用,直接操作。

弹窗拦截

这种方式主要是通过修改浏览器 Window 对象的某些方法,然后拦截固定规则的参数,之后分发给客户端对应的处理方法,从而实现通信。

常用的四个方法:

  • alert: 可以被 webview 的 onJsAlert 监听
  • confirm: 可以被 webview 的 onJsConfirm 监听
  • prompt: 可以被 webview 的 onJsPrompt 监听

简单拿 prompt 来举例说明,Web 页面通过调用 prompt()方法,安卓客户端通过监听onJsPrompt事件,拦截传入的参数,如果参数符合一定协议规范,那么就解析参数,扔给后续的 Java 去处理。这种协议规范,最好是跟 iOS 的协议规范一样,这样跨端调起协议是一致的,但具体实现不一样而已。比如:jack://utils/${action}?a=a 这样的协议,而其他格式的 prompt 参数,是不会监听的,即除了 jack://utils/${action}?a=a 这样的规范协议,prompt 还是原来的 prompt。

但这几种方法在实际的使用中有利有弊,但由于prompt是几个里面唯一可以自定义返回值,可以做同步交互的,所以在目前的使用中,prompt是使用的最多的。

URL Schema

schema 是 URI 的一种格式,上文提到的jack://utils/${action}?a=a 就是一个 scheme 协议,这里说的 scheme(或者 schema)泛指安卓和 iOS 的 schema 协议,因为它比较通用。

安卓和 iOS 都可以通过拦截跳转页 URL 请求,然后解析这个 scheme 协议,符合约定规则的就给到对应的 Native 方法去处理。

安卓和 iOS 分别用于拦截 URL 请求的方法是:

  • android:shouldOverrideUrlLoading方法
  • iOS:UIWebView 的delegate函数

这里简单看一个之前项目中对于 schema 封装:

// 调用
window.fsInvoke.share({title: 'xxx', content: 'xxx'}, result => {
    if (result.errno === 0) {
        alert('分享成功')
    } else {
        // 分享失败
        alert(result.message)
    }
)

---------------------------下方为对fsInvoke的封装

(function(window, undefined) {
    // 分享
    invokeShare = (data, callback) => {
        _invoke('share', data, callback)
    }

    // 登录
    invokeLogin = (data, callback) => {
        _invoke('login', data, callback)
    }

    // 打开扫一扫
    invokeScan = (data, callback) => {
        _invoke('scan', data, callback)
    }

    _invoke = (action, data, callback) => {
        // 拼接schema协议
        let schema = `jack://utils/${action}?a=a`;
        Object.keys(data).forEach(key => {
            schema += `&${key}=${data[key]}`
        })

        // 处理callback
        let callbackName = '';
        if(typeof callback === 'string) {
            callbackName = callback
        } else {
            callbackName = action + Date.now();
            window[callbackName] = callback;
        }

        schema += `&callback=${callbackName}`

        // 触发
        let iframe = document.createElement('iframe');
        iframe.style.display = 'none';
        iframe.src = schema;
        let body = document.body;
        body.appendChild(iframe);
        setTimeout(function() {
            body.removeChild(iframe);
            iframe = null;
        })
    }

    // 暴露给全局
    window.fsInvoke = {
        share: invokeShare,
        login: invokeLogin,
        scan: invokeScan
    }
})(window)

说完了 JS 主动通知客户端(Native)的方式,下面让我们来看下客户端(Native)主动通知调用 JS。

客户端(Native)通知 JS

loadUrl

在安卓 4.4 以前是没有 evaluatingJavaScript API 的,只能通过 loadUrl 来调用 JS 方法,只能让某个 JS 方法执行,但是无法获取该方法的返回值。这时我们需要使用前面提到的 prompt 方法进行兼容,让 H5 端 通过 prompt 进行数据的发送,客户端进行拦截并获取数据。

// mWebView = new WebView(this); //即当前webview对象
mWebView.loadUrl("javascript: 方法名('参数,需要转为字符串')");

//ui线程中运行
 runOnUiThread(new Runnable() {
        @Override
        public void run() {
            mWebView.loadUrl("javascript: 方法名('参数,需要转为字符串')");
            Toast.makeText(Activity名.this, "调用方法...", Toast.LENGTH_SHORT).show();
        }
});

evaluatingJavaScript

在安卓 4.4 之后,evaluatingJavaScript 是一个非常普遍的调用方式。通过 evaluateJavascript 异步调用 JS 方法,并且能在 onReceiveValue 中拿到返回值。

//异步执行JS代码,并获取返回值
mWebView.evaluateJavascript("javascript: 方法名('参数,需要转为字符串')", new ValueCallback() {
        @Override
        public void onReceiveValue(String value) {
            //这里的value即为对应JS方法的返回值
        }
});

stringByEvaluatingJavaScriptFromString

在 iOS 中 Native 通过stringByEvaluatingJavaScriptFromString调用 Html 绑定在 window 上的函数。

// Swift
webview.stringByEvaluatingJavaScriptFromString("方法名('参数')")
// oc
[webView stringByEvaluatingJavaScriptFromString:@"方法名(参数);"];

总结

看完本篇文章,相信你对 Hybrid 有了一个初步的了解。虽然本篇比较基础,但是只有了解了最本质的底层原理后,才能对现有的解决方案有一个很好的理解,你也可以去打造适合你和团队的Hybrid方案。当然了,后面会有对于 Hybrid 更深入的探讨,敬请期待哦!!

最后

你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。当然,我也是开源社区的积极贡献者,github地址https://github.com/Jack-cool,欢迎star!!!

查看原文

赞 2 收藏 2 评论 0

前端森林 发布了文章 · 1月21日

你可能已经忽略的git commit规范

gitcommit.jpeg

引言

在日常的开发工作中,我们通常使用 git 来管理代码,当我们对代码进行某项改动后,都可以通过 git commit 来对代码进行提交。

git 规定提交时必须要写提交信息,作为改动说明,保存在 commit 历史中,方便回溯。规范的 log 不仅有助于他人 review, 还可以有效的输出 CHANGELOG,甚至对于项目的研发质量都有很大的提升。

但是在日常工作中,大多数同学对于 log 信息都是简单写写,没有很好的重视,这对于项目的管理和维护来说,无疑是不友好的。本篇文章主要是结合我自己的使用经验来和大家分享一下 git commit 的一些规范,让你的 log 不仅“好看”还“实用”。

为什么要规范 git commit

一直在说要规范 commit 格式,那为什么要这样做呢?

让我们先来看一个不太规范的 commit 记录:

看完什么感觉,写的是啥啊(内心 OS),这种 commit 信息对于想要从中获取有效信息的人来说无疑是一种致命的打击。

那我们来看一个社区里面比较流行的Angular规范的 commit 记录:

看完是不是一目了然呢?

上图中这种规范的 commit 信息首先提供了更多的历史信息,方便快速浏览。其次,可以过滤某些 commit(比如文档改动),便于快速查找信息。

既然说到了 Angular 团队的规范是目前社区比较流行的 commit 规范,那它具体是什么呢?下面让我们来具体深入了解下吧。

Angular 团队的 commit 规范

它的 message 格式如下:

<type>(<scope>): <subject>
// 空一行
<body>
// 空一行
<footer>

分别对应 Commit message 的三个部分:HeaderBodyFooter

Header

Header 部分只有一行,包括三个字段:type(必需)、scope(可选)和subject(必需)。

  • type: 用于说明 commit 的类型。一般有以下几种:

    feat: 新增feature
    fix: 修复bug
    docs: 仅仅修改了文档,如readme.md
    style: 仅仅是对格式进行修改,如逗号、缩进、空格等。不改变代码逻辑。
    refactor: 代码重构,没有新增功能或修复bug
    perf: 优化相关,如提升性能、用户体验等。
    test: 测试用例,包括单元测试、集成测试。
    chore: 改变构建流程、或者增加依赖库、工具等。
    revert: 版本回滚
  • scope: 用于说明 commit 影响的范围,比如: views, component, utils, test...
  • subject: commit 目的的简短描述

Body

对本次 commit 修改内容的具体描述, 可以分为多行。如下所示:

# body: 72-character wrapped. This should answer:
# * Why was this change necessary?
# * How does it address the problem?
# * Are there any side effects?
# initial commit

Footer

一些备注, 通常是 BREAKING CHANGE(当前代码与上一个版本不兼容) 或修复的 bug(关闭 Issue) 的链接。

简单介绍完上面的规范,我们下面来说一下commit.template,也就是 git 提交信息模板。

git 提交信息模板

如果你的团队对提交信息有格式要求,可以在系统上创建一个文件,并配置 git 把它作为默认的模板,这样可以更加容易地使提交信息遵循格式。

通过以下命令来配置提交信息模板:

git config commit.template   [模板文件名]    //这个命令只能设置当前分支的提交模板
git config  — —global commit.template   [模板文件名]    //这个命令能设置全局的提交模板,注意global前面是两杠

新建 .gitmessage.txt(模板文件) 内容可以如下:

# headr: <type>(<scope>): <subject>
# - type: feat, fix, docs, style, refactor, test, chore
# - scope: can be empty
# - subject: start with verb (such as 'change'), 50-character line
#
# body: 72-character wrapped. This should answer:
# * Why was this change necessary?
# * How does it address the problem?
# * Are there any side effects?
#
# footer:
# - Include a link to the issue.
# - BREAKING CHANGE
#

看完上面这些,你会不会像我一样感觉配置下来挺麻烦的,配置一个适合自己和团队使用的近乎完美的 commit 规范看来也不是一件容易的事情。不过社区也为我们提供了一些辅助工具来帮助进行提交,下面来简单介绍一下这些工具。

commitizen(cz-cli)

commitizen是一款可以交互式建立提交信息的工具。它帮助我们从 type 开始一步步建立提交信息,具体效果如图所示:

  • 首先通过上下键控制指向你想要的 type 类型,分别对应有上面提到的featfixdocsperf等:

  • 然后会让你选择本次提交影响到的文件:

  • 后面会让你分别写一个简短的和详细的提交描述:

  • 最后会让你去判断本次提交是否是BREAKING CHANGE或者有关联已开启的issue:

看完上面的 commitizen 的整个流程,下面让我们来看下如何来安装。

  • 全局环境下安装:

    commitizen 根据不同的adapter配置 commit message。例如,要使用 Angular 的 commit message 格式,可以安装cz-conventional-changelog
    # 需要同时安装commitizen和cz-conventional-changelog,后者是adapter
    $ npm install -g commitizen cz-conventional-changelog
    # 配置安装的adapter
    $ echo '{ "path": "cz-conventional-changelog" }' > ~/.czrc
    # 使用
    $ git cz
    
  • 本地项目安装:

    # 安装commitizen
    $ npm install --save-dev commitizen
    # 接下来安装适配器
    # for npm >= 5.2
    $ npx commitizen init cz-conventional-changelog --save-dev --save-exact
    # for npm < 5.2
    $ ./node_modules/.bin/commitizen init cz-conventional-changelog --save-dev --save-exact
    
    // package.json script字段中添加commit命令
    "scripts": {
       "commit": "git-cz"
    }
    // use
    $ npm run commit

commitlint

commitlint是一个提交验证工具。原理是可以在实际的 git commit 提交到远程仓库之前使用 git 钩子来验证信息。提交不符合规则的信息将会被阻止提交到远程仓库。

先来看一下演示:

对于 Conventional Commits 规范,社区已经整理好了 @commitlint/config-conventional 包,我们只需要安装并启用它就可以了。

首先安装 commitlint 以及 conventional 规范:

npm install --save-dev @commitlint/cli @commitlint/config-conventional

接着在 package.json 中配置 commitlint 脚本:

"commitlint": {
    "extends": [
      "@commitlint/config-conventional"
    ]
  },
当然如果你想单独对 commitlint 进行配置的话,需要建立校验文件 commitlint.config.js,不然会校验失败

为了可以在每次 commit 时执行 commitlint 来 检查我们输入的 message,我们还需要用到一个工具 —— husky

husky 是一个增强的 git hook 工具。可以在 git hook 的各个阶段执行我们在 package.json 中配置好的 npm script。

首先安装 husky:

npm install --save-dev husky

接着在 package.json 中配置 commitmsg 脚本:

"husky": {
    "hooks": {
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
 },

到这里,commitlint就配置完成了~

gitmoji-cli

平时与朋友聊天时,我们一定会用到表情包,比如。表情包的出现让我们与朋友之间的沟通变得更加有趣。如果能在 git 提交 commit 时用到表情包,岂不是使每次的 commit 能够更加直观,维护起来也更加方便。

gitmoji就是可以实现这种功能的插件,先让我们来感受一下

有没有感觉很 cool~~

其实gitmoji的使用是很简单的:

# 安装
npm i -g gitmoji-cli
# 使用
git commit -m ':bug: 问题fix'

我们来看一下官方的示例吧:

是不是跃跃欲试了呢?

gitmoji项目地址

gitmoji使用示例

看完本文,是不是感觉对于git commit message又有了新的认识呢?去在你的项目中运用这些吧,让你的commit更加规范的同时,也不要忘了给你的log加上emoji哦!

最后附上一个之前项目针对git commit配置的package.json,作为参考:

{
  "name": "ts-axios",
  "version": "0.0.0",
  "description": "",
  "keywords": [],
  "main": "dist/ts-axios.umd.js",
  "module": "dist/ts-axios.es5.js",
  "typings": "dist/types/ts-axios.d.ts",
  "files": [
    "dist"
  ],
  "author": "fengshuan <1263215592@qq.com>",
  "repository": {
    "type": "git",
    "url": ""
  },
  "license": "MIT",
  "engines": {
    "node": ">=6.0.0"
  },
  "scripts": {
    "dev": "node examples/server.js",
    "lint": "tslint  --project tsconfig.json -t codeFrame 'src/**/*.ts' 'test/**/*.ts'",
    "prebuild": "rimraf dist",
    "build": "tsc --module commonjs && rollup -c rollup.config.ts && typedoc --out docs --target es6 --theme minimal --mode file src",
    "start": "rollup -c rollup.config.ts -w",
    "test": "jest --coverage",
    "test:watch": "jest --coverage --watch",
    "test:prod": "npm run lint && npm run test -- --no-cache",
    "deploy-docs": "ts-node tools/gh-pages-publish",
    "report-coverage": "cat ./coverage/lcov.info | coveralls",
    "commit": "git-cz",
    "semantic-release": "semantic-release",
    "semantic-release-prepare": "ts-node tools/semantic-release-prepare",
    "precommit": "lint-staged",
    "travis-deploy-once": "travis-deploy-once"
  },
  "husky": {
    "hooks": {
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  },
  "lint-staged": {
    "{src,test}/**/*.ts": [
      "prettier --write",
      "git add"
    ]
  },
  "config": {
    "commitizen": {
      "path": "node_modules/cz-conventional-changelog"
    }
  },
  "jest": {
    "transform": {
      ".(ts|tsx)": "ts-jest"
    },
    "testEnvironment": "node",
    "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$",
    "moduleFileExtensions": [
      "ts",
      "tsx",
      "js"
    ],
    "coveragePathIgnorePatterns": [
      "/node_modules/",
      "/test/"
    ],
    "coverageThreshold": {
      "global": {
        "branches": 90,
        "functions": 95,
        "lines": 95,
        "statements": 95
      }
    },
    "collectCoverageFrom": [
      "src/*.{js,ts}"
    ]
  },
  "prettier": {
    "semi": false,
    "singleQuote": true
  },
  "commitlint": {
    "extends": [
      "@commitlint/config-conventional"
    ]
  },
  "devDependencies": {
    "@commitlint/cli": "^7.1.2",
    "@commitlint/config-conventional": "^7.1.2",
    "@types/jest": "^23.3.2",
    "@types/node": "^10.11.0",
    "body-parser": "^1.19.0",
    "colors": "^1.3.2",
    "commitizen": "^3.0.0",
    "coveralls": "^3.0.2",
    "cross-env": "^5.2.0",
    "cz-conventional-changelog": "^2.1.0",
    "express": "^4.17.1",
    "husky": "^1.0.1",
    "jest": "^23.6.0",
    "jest-config": "^23.6.0",
    "lint-staged": "^8.0.0",
    "lodash.camelcase": "^4.3.0",
    "prettier": "^1.14.3",
    "prompt": "^1.0.0",
    "replace-in-file": "^3.4.2",
    "rimraf": "^2.6.2",
    "rollup": "^0.67.0",
    "rollup-plugin-commonjs": "^9.1.8",
    "rollup-plugin-json": "^3.1.0",
    "rollup-plugin-node-resolve": "^3.4.0",
    "rollup-plugin-sourcemaps": "^0.4.2",
    "rollup-plugin-typescript2": "^0.18.0",
    "semantic-release": "^15.9.16",
    "shelljs": "^0.8.3",
    "travis-deploy-once": "^5.0.9",
    "ts-jest": "^23.10.2",
    "ts-loader": "^6.1.1",
    "ts-node": "^7.0.1",
    "tslint": "^5.11.0",
    "tslint-config-prettier": "^1.15.0",
    "tslint-config-standard": "^8.0.1",
    "tslint-loader": "^3.5.4",
    "typedoc": "^0.12.0",
    "typescript": "^3.0.3",
    "webpack": "^4.40.2",
    "webpack-dev-middleware": "^3.7.1",
    "webpack-hot-middleware": "^2.25.0"
  }
}

最后

你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。当然,我也是开源社区的积极贡献者,github地址https://github.com/Cosen95,欢迎star!!!

image

查看原文

赞 24 收藏 17 评论 1

前端森林 发布了文章 · 1月21日

滚动视差让你不相信“眼见为实”

parallex.jpeg

引言

视差滚动(Parallax Scrolling)是指让多层背景以不同的速度移动,形成立体的运动效果。

其实,这项技术早在 2013 年就已经开始在一些国外的网站中得到了大量的应用。由于它给网站带来了非常出色的视觉体验,现在已经有数不胜数的网站应用了这项技术。

我是在最近的项目中用到了这块,觉得有必要整理一下。本文主要是简单的介绍一下什么是视差滚动,实现方式以及如何在现有框架(vue/react)中使用视差滚动。

什么是视差滚动?

视差效果, 最初是一个天文术语。当我们看着繁星点点的天空时,较远的恒星运动较慢,而较近的恒星运动较快。当我们坐在车里看着窗外时,我们会有相同的感觉。远处的山脉似乎没有动,附近的稻田很快过去了。许多游戏使用视差效果来增加场景的三维度。说的简单点就是,滚动屏幕时,网页中元素的位置会发生变化。但是不同的元素位置变化的速度不同,导致网页中产生分层元素的错觉。

看完上面这段,相信你对视差滚动的概念已经有了一个初步的了解。下面让我们先来看一下如何用 css 来实现视差滚动。

css 实现

css 中主要有两种实现方式:分别是通过background-attachment: fixedtransform: translate3d来实现,下面让我们看一下具体的实现方式:

background-attachment: fixed

平时业务开发中可能不太会用到background-attachment,让我们先来认识一下它。

background-attachment CSS 属性决定背景图像的位置是在视口内固定,还是随着包含它的区块滚动。

它一共有三个属性:

  • fixed: 键字表示背景相对于视口固定。即使一个元素拥有滚动机制,背景也不会随着元素的内容滚动。
  • local: 此关键字表示背景相对于元素的内容固定。如果一个元素拥有滚动机制,背景将会随着元素的内容滚动。
  • scroll: 此关键字表示背景相对于元素本身固定, 而不是随着它的内容滚动。
    我们使用 background-attachment: fixed 来实现视差滚动,看一下示例:
// html
<div class="a-text">1</div>
<div class="a-img1">2</div>
<div class="a-text">3</div>
<div class="a-img2">4</div>
<div class="a-text">5</div>
<div class="a-img3">6</div>
<div class="a-text">7</div>
// css
$img1: 'https://images.pexels.com/photos/1097491/pexels-photo-1097491.jpeg';

$img2: 'https://images.pexels.com/photos/2437299/pexels-photo-2437299.jpeg';

$img3: 'https://images.pexels.com/photos/1005417/pexels-photo-1005417.jpeg';

div {
    height: 100vh;
    background: rgba(0, 0, 0, .7);
    color: #fff;
    line-height: 100vh;
    text-align: center;
    font-size: 20vh;
}

.a-img1 {
    background-image: url($img1);
    background-attachment: fixed;
    background-size: cover;
    background-position: center center;
}

.a-img2 {
    background-image: url($img2);
    background-attachment: fixed;
    background-size: cover;
    background-position: center center;
}

.a-img3 {
    background-image: url($img3);
    background-attachment: fixed;
    background-size: cover;
    background-position: center center;
}

效果如下:

当然,你可以直接去这里查看:https://codepen.io/jack-cool/pen/MWYogYQ

transform: translate3d

同样,让我们先来看一下两个概念transformperspective

  • transform: css3 属性,可以对元素进行变换(2d/3d),包括平移 translate,旋转 rotate,缩放 scale,等等
  • perspective: css3 属性,当元素涉及 3d 变换时,perspective 可以定义我们眼睛看到的 3d 立体效果,即空间感。

先来看一下示例:

// html
<div id="app">
   <div class="one">one</div>
   <div class="two">two</div>
   <div class="three">three</div>
 </div>
// css
html {
   overflow: hidden;
   height: 100%
}

 body {
   perspective: 1px;
   transform-style: preserve-3d;
   height: 100%;
   overflow-y: scroll;
   overflow-x: hidden;
 }
 #app{
   width: 100vw;
   height:200vh;
   background:skyblue;
   padding-top:100px;
 }
.one{
  width:500px;
  height:200px;
  background:#409eff;
  transform: translateZ(0px);
  margin-bottom: 50px;
}
.two{
  width:500px;
  height:200px;
  background:#67c23a;
  transform: translateZ(-1px);
  margin-bottom: 150px;
}
.three{
  width:500px;
  height:200px;
  background:#e6a23c;
  transform: translateZ(-2px);
  margin-bottom: 150px;
}

效果如下:

当然,你可以直接去这里查看:https://codepen.io/jack-cool/pen/zYxzOpb

这里解释下使用transform: translate3d来实现视差滚动的原理:

1、给容器设置上transform-style: preserve-3dperspective: xpx,那么处于这个容器下的子元素就会处于 3D 空间中;

2、给子元素分别设置不同的transform: translateZ(),这时不同子元素在 3D Z 轴方向距离屏幕的距离也就不一样;

3、滚动滚动条,由于子元素设置了不同的transform: translateZ(),那么他们滚动的上下距离translateY相对屏幕(我们的眼睛),也是不一样的,这就达到了滚动视差的效果。

总结下来就是: 父容器设置transform-style: preserve-3dperspective: xpx,子元素设置不同的transform: translateZ()

看完了用 css 实现滚动视差的两种方式,下面让我们看下如何在现有框架(vue/react)中来应用滚动视差。

vue 或 react 中使用

react 中使用

在 react 中使用可以采用react-parallax,代码示例:

import React from "react";
import { render } from "react-dom";
import { Parallax } from "react-parallax";
import Introduction from "./Introduction";

const styles = {
  fontFamily: "sans-serif",
  textAlign: "center"
};
const insideStyles = {
  background: "white",
  padding: 20,
  position: "absolute",
  top: "50%",
  left: "50%",
  transform: "translate(-50%,-50%)"
};
const image1 =
  "https://images.pexels.com/photos/830891/pexels-photo-830891.jpeg";
const image2 =
  "https://images.pexels.com/photos/1236701/pexels-photo-1236701.jpeg";
const image3 =
  "https://images.pexels.com/photos/3210189/pexels-photo-3210189.jpeg";
const image4 =
  "https://images.pexels.com/photos/2437299/pexels-photo-2437299.jpeg";

const App = () => (
  <div style={styles}>
    <Introduction name="React Parallax" />
    <Parallax bgImage={image1} strength={500}>
      <div style={{ height: 500 }}>
        <div style={insideStyles}>HTML inside the parallax</div>
      </div>
    </Parallax>
    <h1>| | |</h1>
    <Parallax bgImage={image3} blur={{ min: -1, max: 3 }}>
      <div style={{ height: 500 }}>
        <div style={insideStyles}>Dynamic Blur</div>
      </div>
    </Parallax>
    <h1>| | |</h1>
    <Parallax bgImage={image2} strength={-100}>
      <div style={{ height: 500 }}>
        <div style={insideStyles}>Reverse direction</div>
      </div>
    </Parallax>
    <h1>| | |</h1>
    <Parallax
      bgImage={image4}
      strength={200}
      renderLayer={percentage => (
        <div>
          <div
            style={{
              position: "absolute",
              background: `rgba(255, 125, 0, ${percentage * 1})`,
              left: "50%",
              top: "50%",
              borderRadius: "50%",
              transform: "translate(-50%,-50%)",
              width: percentage * 500,
              height: percentage * 500
            }}
          />
        </div>
      )}
    >
      <div style={{ height: 500 }}>
        <div style={insideStyles}>renderProp</div>
      </div>
    </Parallax>
    <div style={{ height: 500 }} />
    <h2>{"\u2728"}</h2>
  </div>
);

render(<App />, document.getElementById("root"));

效果如下:

当然,更多细节可以查看:https://codesandbox.io/s/react-parallax-zw5go

vue 中使用

在 vue 中使用可以采用vue-parallaxy,代码示例:

<template>
  <div id="app">
    <div style="background-color: #fff; height: 100vh;">
      <h1 style="margin-top: 0; padding-top: 20px;">Scroll down ⬇</h1>
    </div>
    <div style="position: relative; z-index: 9999; background-color: #fff;">
      <h1 style="margin:0;">Parallax Effect</h1>
      <parallax>
        <img data-original="https://images.pexels.com/photos/830891/pexels-photo-830891.jpeg">
      </parallax>
    </div>
    <div style="background-color: #fff; height: 100vh;"></div>
    <h1>Parallax fixed position</h1>

    <div style="position: relative;">
      <parallax :fixed="true">
        <img data-original="https://images.pexels.com/photos/3210189/pexels-photo-3210189.jpeg">
      </parallax>
    </div>

    <div style="background-color: #fff; height: 100vh;"></div>
  </div>
</template>

<script>
import Parallax from "vue-parallaxy";

export default {
  name: "App",
  components: {
    Parallax
  }
};
</script>

<style>
body {
  margin: 0;
}
#app {
  font-family: "Avenir", Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
  position: relative;
}
</style>

效果如下:

当然,更多细节可以查看: https://codesandbox.io/s/vue-parallaxjs-ljh9g

最后

你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。当然,我也是开源社区的积极贡献者,github地址https://github.com/Cosen95,欢迎star!!!
image

查看原文

赞 4 收藏 2 评论 0

前端森林 发布了文章 · 1月21日

优雅的在vue中使用TypeScript

引言

近几年前端对 TypeScript 的呼声越来越高,Typescript 也成为了前端必备的技能。TypeScript 是 JS 类型的超集,并支持了泛型、类型、命名空间、枚举等特性,弥补了 JS 在大型应用开发中的不足。

在单独学习 TypeScript 时,你会感觉很多概念还是比较好理解的,但是和一些框架结合使用的话坑还是比较多的,例如使用 React、Vue 这些框架的时候与 TypeScript 的结合会成为一大障碍,需要去查看框架提供的.d.ts 的声明文件中一些复杂类型的定义、组件的书写方式等都要做出不小的调整。

本篇文章主要是结合我的经验和大家聊一下如何在Vue中平滑的从js过渡到ts,阅读本文建议对 TypeScript 有一定了解,因为文中对于一些 TypeScript 的基础的知识不会有太过于详细的讲解。(具体可以参考官方文档https://www.w3cschool.cn/typescript/typescript-tutorial.html,官方文档就是最好的入门手册)

构建

通过官方脚手架构建安装

# 1. 如果没有安装 Vue CLI 就先安装
npm install --global @vue/cli

最新的Vue CLI工具允许开发者 使用 TypeScript 集成环境 创建新项目。

只需运行vue create my-app

然后,命令行会要求选择预设。使用箭头键选择 Manually select features。

接下来,只需确保选择了 TypeScript 和 Babel 选项,如下图:

然后配置其余设置,如下图:


设置完成 vue cli 就会开始安装依赖并设置项目。

目录解析

安装完成打开项目,你会发现集成 ts 后的项目目录结构是这样子的:

|-- ts-vue
    |-- .browserslistrc     # browserslistrc 配置文件 (用于支持 Autoprefixer)
    |-- .eslintrc.js        # eslint 配置
    |-- .gitignore
    |-- babel.config.js     # babel-loader 配置
    |-- package-lock.json
    |-- package.json        # package.json 依赖
    |-- postcss.config.js   # postcss 配置
    |-- README.md
    |-- tsconfig.json       # typescript 配置
    |-- vue.config.js       # vue-cli 配置
    |-- public              # 静态资源 (会被直接复制)
    |   |-- favicon.ico     # favicon图标
    |   |-- index.html      # html模板
    |-- src
    |   |-- App.vue         # 入口页面
    |   |-- main.ts         # 入口文件 加载组件 初始化等
    |   |-- shims-tsx.d.ts
    |   |-- shims-vue.d.ts
    |   |-- assets          # 主题 字体等静态资源 (由 webpack 处理加载)
    |   |-- components      # 全局组件
    |   |-- router          # 路由
    |   |-- store           # 全局 vuex store
    |   |-- styles          # 全局样式
    |   |-- views           # 所有页面
    |-- tests               # 测试

其实大致看下来,与之前用js构建的项目目录没有什么太大的不同,区别主要是之前 js 后缀的现在改为了 ts 后缀,还多了tsconfig.jsonshims-tsx.d.tsshims-vue.d.ts这几个文件,那这几个文件是干嘛的呢:

  • tsconfig.json: typescript 配置文件,主要用于指定待编译的文件和定义编译选项
  • shims-tsx.d.ts: 允许.tsx 结尾的文件,在 Vue 项目中编写 jsx 代码
  • shims-vue.d.ts: 主要用于 TypeScript 识别.vue 文件,Ts 默认并不支持导入 vue 文件

使用

开始前我们先来了解一下在 vue 中使用 typescript 非常好用的几个库

  • vue-class-component: vue-class-component是一个 Class Decorator,也就是类的装饰器
  • vue-property-decorator: vue-property-decorator是基于 vue 组织里 vue-class-component 所做的拓展

    import { Vue, Component, Inject, Provide, Prop, Model, Watch, Emit, Mixins } from 'vue-property-decorator'
  • vuex-module-decorators: 用 typescript 写 vuex 很好用的一个库

    import { Module, VuexModule, Mutation, Action, MutationAction, getModule } from 'vuex-module-decorators'

组件声明

创建组件的方式变成如下

import { Component, Prop, Vue, Watch } from "vue-property-decorator";

@Component
export default class Test extends Vue {}

data 对象

import { Component, Prop, Vue, Watch } from 'vue-property-decorator';

@Component
export default class Test extends Vue {
  private name: string;
}

Prop 声明

@Prop({ default: false }) private isCollapse!: boolean;
@Prop({ default: true }) private isFirstLevel!: boolean;
@Prop({ default: "" }) private basePath!: string;
  • !: 表示一定存在,?: 表示可能不存在。这两种在语法上叫赋值断言
  • @Prop(options: (PropOptions | Constructor[] | Constructor) = {})

    • PropOptions,可以使用以下选项:type,default,required,validator
    • Constructor[],指定 prop 的可选类型
    • Constructor,例如 String,Number,Boolean 等,指定 prop 的类型

method

js 下是需要在 method 对象中声明方法,现变成如下

public clickFunc(): void {
  console.log(this.name)
  console.log(this.msg)
}

Watch 监听属性

@Watch("$route", { immediate: true })
private onRouteChange(route: Route) {
  const query = route.query as Dictionary<string>;
  if (query) {
  this.redirect = query.redirect;
  this.otherQuery = this.getOtherQuery(query);
  }
}
  • @Watch(path: string, options: WatchOptions = {})

    • options 包含两个属性 immediate?:boolean 侦听开始之后是否立即调用该回调函数 / deep?:boolean 被侦听的对象的属性被改变时,是否调用该回调函数
  • @Watch('arr', { immediate: true, deep: true })
    onArrChanged(newValue: number[], oldValue: number[]) {}

computed 计算属性

public get allname() {
  return 'computed ' + this.name;
}

allname 是计算后的值,name 是被监听的值

生命周期函数

public created(): void {
  console.log('created');
}

public mounted():void{
  console.log('mounted')
}

emit 事件

import { Vue, Component, Emit } from "vue-property-decorator";
@Component
export default class MyComponent extends Vue {
  count = 0;
  @Emit()
  addToCount(n: number) {
    this.count += n;
  }
  @Emit("reset")
  resetCount() {
    this.count = 0;
  }
  @Emit()
  returnValue() {
    return 10;
  }
  @Emit()
  onInputChange(e) {
    return e.target.value;
  }
  @Emit()
  promise() {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve(20);
      }, 0);
    });
  }
}

使用 js 写法

export default {
  data() {
    return {
      count: 0,
    };
  },
  methods: {
    addToCount(n) {
      this.count += n;
      this.$emit("add-to-count", n);
    },
    resetCount() {
      this.count = 0;
      this.$emit("reset");
    },
    returnValue() {
      this.$emit("return-value", 10);
    },
    onInputChange(e) {
      this.$emit("on-input-change", e.target.value, e);
    },
    promise() {
      const promise = new Promise((resolve) => {
        setTimeout(() => {
          resolve(20);
        }, 0);
      });
      promise.then((value) => {
        this.$emit("promise", value);
      });
    },
  },
};
  • @Emit(event?: string)
  • @Emit 装饰器接收一个可选参数,该参数是&dollar;Emit 的第一个参数,充当事件名。如果没有提供这个参数,&dollar;Emit 会将回调函数名的 camelCase 转为 kebab-case,并将其作为事件名
  • @Emit 会将回调函数的返回值作为第二个参数,如果返回值是一个 Promise 对象,&dollar;emit 会在 Promise 对象被标记为 resolved 之后触发
  • @Emit 的回调函数的参数,会放在其返回值之后,一起被&dollar;emit 当做参数使用

vuex

在使用 store 装饰器之前,先过一下传统的 store 用法吧

export default {
  namespaced: true,
  state: {
    foo: "",
  },
  getters: {
    getFoo(state) {
      return state.foo;
    },
  },
  mutations: {
    setFooSync(state, payload) {
      state.foo = payload;
    },
  },
  actions: {
    setFoo({ commit }, payload) {
      commot("getFoo", payload);
    },
  },
};

然后开始使用vuex-module-decorators

import {
  VuexModule,
  Mutation,
  Action,
  getModule,
  Module,
} from "vuex-module-decorators";
  • VuexModule 用于基本属性

    export default class TestModule extends VuexModule {}

    VuexModule 提供了一些基本属性,包括 namespaced,state,getters,modules,mutations,actions,context

  • @Module 标记当前为 module

    @Module({ dynamic: true, store, name: "settings" })
    class Settings extends VuexModule implements ISettingsState {}

    module 本身有几种可以配置的属性:

    • namespaced:boolean 启/停用 分模块
    • stateFactory:boolean 状态工厂
    • dynamic:boolean 在 store 创建之后,再添加到 store 中。开启 dynamic 之后必须提供下面的属性
    • name:string 指定模块名称
    • store:Vuex.Store 实体 提供初始的 store
  • @Mutation 标注为 mutation

    @Mutation
    private SET_NAME(name: string) {
    // 设置用户名
    this.name = name;
    }
  • @Action 标注为 action

    @Action
    public async Login(userInfo: { username: string; password: string }) {
      // 登录接口,拿到token
      let { username, password } = userInfo;
      username = username.trim();
      const { data } = await login({ username, password });
      setToken(data.accessToken);
      this.SET_TOKEN(data.accessToken);
    }
  • getModule 得到一个类型安全的 store,module 必须提供 name 属性

    export const UserModule = getModule(User);

示例

我之前基于 ts+vue+element 构建了一个简单的中后台通用模板。


涵盖的功能如下:

- 登录 / 注销

- 权限验证
  - 页面权限
  - 权限配置

- 多环境发布
  - Dev / Stage / Prod

- 全局功能
  - 动态换肤
  - 动态侧边栏(支持多级路由嵌套)
  - Svg 图标
  - 全屏
  - 设置
  - Mock 数据 / Mock 服务器

- 组件
  - ECharts 图表

- 表格
  - 复杂表格

- 控制台
- 引导页
- 错误页面
  - 404

里面对于在 vue 中使用 typescript 的各种场景都有很好的实践,大家感兴趣的可以参考一下,https://github.com/easy-wheel/ts-vue,当然不要吝惜你的 star!!!

最后

你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。当然,我也是开源社区的积极贡献者,github 地址https://github.com/Cosen95,欢迎 star!!!

image

查看原文

赞 10 收藏 8 评论 0

前端森林 发布了文章 · 1月21日

useTypescript-React Hooks和TypeScript完全指南

引言

React v16.8 引入了 Hooks,它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。这些功能可以在应用程序中的各个组件之间使用,从而易于共享逻辑。Hook 令人兴奋并迅速被采用,React 团队甚至想象它们最终将替换类组件。

以前在 React 中,共享逻辑的方法是通过高阶组件和 props 渲染。Hooks 提供了一种更简单方便的方法来重用代码并使组件可塑形更强。

本文将展示 TypeScript 与 React 集成后的一些变化,以及如何将类型添加到 Hooks 以及你的自定义 Hooks 上。

引入 Typescript 后的变化

有状态组件(ClassComponent)

API 对应为:

React.Component<P, S>

class MyComponent extends React.Component<Props, State> { ...

以下是官网的一个例子,创建 Props 和 State 接口,Props 接口接受 name 和 enthusiasmLevel 参数,State 接口接受 currentEnthusiasm 参数:

import * as React from "react";

export interface Props {
  name: string;
  enthusiasmLevel?: number;
}

interface State {
  currentEnthusiasm: number;
}

class Hello extends React.Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { currentEnthusiasm: props.enthusiasmLevel || 1 };
  }

  onIncrement = () => this.updateEnthusiasm(this.state.currentEnthusiasm + 1);
  onDecrement = () => this.updateEnthusiasm(this.state.currentEnthusiasm - 1);

  render() {
    const { name } = this.props;

    if (this.state.currentEnthusiasm <= 0) {
      throw new Error('You could be a little more enthusiastic. :D');
    }

    return (
      <div className="hello">
        <div className="greeting">
          Hello {name + getExclamationMarks(this.state.currentEnthusiasm)}
        </div>
        <button onClick={this.onDecrement}>-</button>
        <button onClick={this.onIncrement}>+</button>
      </div>
    );
  }

  updateEnthusiasm(currentEnthusiasm: number) {
    this.setState({ currentEnthusiasm });
  }
}

export default Hello;

function getExclamationMarks(numChars: number) {
  return Array(numChars + 1).join('!');
}

TypeScript 可以对 JSX 进行解析,充分利用其本身的静态检查功能,使用泛型进行 Props、 State 的类型定义。定义后在使用 this.state 和 this.props 时可以在编辑器中获得更好的智能提示,并且会对类型进行检查。

react 规定不能通过 this.props.xxx 和 this.state.xxx 直接进行修改,所以可以通过 readonly 将 State 和 Props 标记为不可变数据:

interface Props {
  readonly number: number;
}

interface State {
  readonly color: string;
}

export class Hello extends React.Component<Props, State> {
  someMethod() {
    this.props.number = 123; // Error: props 是不可变的
    this.state.color = 'red'; // Error: 你应该使用 this.setState()
  }
}

无状态组件(StatelessComponent)

API 对应为:

// SFC: stateless function components
const List: React.SFC<IProps> = props => null
// v16.8起,由于hooks的加入,函数式组件也可以使用state,所以这个命名不准确。新的react声明文件里,也定义了React.FC类型^_^
React.FunctionComponent<P> or React.FC<P>。

const MyComponent: React.FC<Props> = ...

无状态组件也称为傻瓜组件,如果一个组件内部没有自身的 state,那么组件就可以称为无状态组件。在@types/react已经定义了一个类型type SFC<P = {}> = StatelessComponent

先看一下之前无状态组件的写法:

import React from 'react'

const Button = ({ onClick: handleClick, children }) => (
  <button onClick={handleClick}>{children}</button>
)

如果采用 ts 来编写出来的无状态组件是这样的:

import React, { MouseEvent, SFC } from 'react';

type Props = { onClick(e: MouseEvent<HTMLElement>): void };

const Button: SFC<Props> = ({ onClick: handleClick, children }) => (
  <button onClick={handleClick}>{children}</button>
);

事件处理

我们在进行事件注册时经常会在事件处理函数中使用 event 事件对象,例如当使用鼠标事件时我们会通过 clientX、clientY 去获取指针的坐标。

大家可以想到直接把 event 设置为 any 类型,但是这样就失去了我们对代码进行静态检查的意义。

function handleMouseChange (event: any) {
  console.log(event.clientY)
}

试想下当我们注册一个 Touch 事件,然后错误的通过事件处理函数中的 event 对象去获取其 clientY 属性的值,在这里我们已经将 event 设置为 any 类型,导致 TypeScript 在编译时并不会提示我们错误, 当我们通过 event.clientY 访问时就有问题了,因为 Touch 事件的 event 对象并没有 clientY 这个属性。

通过 interface 对 event 对象进行类型声明编写的话又十分浪费时间,幸运的是 React 的声明文件提供了 Event 对象的类型声明。

  • 通用的 React Event Handler

API 对应为:

React.ReactEventHandler<HTMLElement>

简单的示例:

const handleChange: React.ReactEventHandler<HTMLInputElement> = (ev) => { ... }

<input onChange={handleChange} ... />
  • 特殊的 React Event Handler

常用 Event 事件对象类型:

ClipboardEvent<T = Element> 剪贴板事件对象


DragEvent<T = Element> 拖拽事件对象


ChangeEvent<T = Element>  Change 事件对象


KeyboardEvent<T = Element> 键盘事件对象


MouseEvent<T = Element> 鼠标事件对象


TouchEvent<T = Element>  触摸事件对象


WheelEvent<T = Element> 滚轮事件对象


AnimationEvent<T = Element> 动画事件对象


TransitionEvent<T = Element> 过渡事件对象

简单的示例:

const handleChange = (ev: React.MouseEvent<HTMLDivElement>) => { ... }

<div onMouseMove={handleChange} ... />

React 元素

API 对应为:

React.ReactElement<P> or JSX.Element

简单的示例:

// 表示React元素概念的类型: DOM元素组件或用户定义的复合组件
const elementOnly: React.ReactElement = <div /> || <MyComponent />;

React Node

API 对应为:

React.ReactNode

表示任何类型的 React 节点(基本上是 ReactElement + 原始 JS 类型的合集)

简单的示例:

const elementOrComponent: React.ReactNode = 'string' || 0 || false || null || undefined || <div /> || <MyComponent />;

React CSS 属性

API 对应为:

React.CSSProperties

用于标识 jsx 文件中的 style 对象(通常用于 css-in-js

简单的示例:

const styles: React.CSSProperties = { display: 'flex', ...
const element = <div style={styles} ...

Hooks 登场

首先,什么是 Hooks 呢?

React 一直都提倡使用函数组件,但是有时候需要使用 state 或者其他一些功能时,只能使用类组件,因为函数组件没有实例,没有生命周期函数,只有类组件才有。

Hooks 是 React 16.8 新增的特性,它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。

默认情况下,React 包含 10 个钩子。其中 3 个挂钩被视为是最常使用的“基本”或核心挂钩。还有 7 个额外的“高级”挂钩,这些挂钩最常用于边缘情况。10 个钩子如下:

  • 基础

    • useState
    • useEffect
    • useContext
  • 高级

    • useReducer
    • useCallback
    • useMemo
    • useRef
    • useImperativeHandle
    • useLayoutEffect
    • useDebugValue

useState with TypeScript

API 对应为:

// 传入唯一的参数: initialState,可以是数字,字符串等,也可以是对象或者数组。
// 返回的是包含两个元素的数组:第一个元素,state 变量,setState 修改 state值的方法。
const [state, setState] = useState(initialState);

useState是一个允许我们替换类组件中的 this.state 的挂钩。我们执行该挂钩,该挂钩返回一个包含当前状态值和一个用于更新状态的函数的数组。状态更新时,它会导致组件的重新 render。下面的代码显示了一个简单的 useState 钩子:

import * as React from 'react';

const MyComponent: React.FC = () => {
  const [count, setCount] = React.useState(0);
  return (
    <div onClick={() => setCount(count + 1)}>
      {count}
    </div>
  );
};

useEffect with TypeScript

API 对应为:

// 两个参数
// 第一个是一个函数,是在第一次渲染(componentDidMount)以及之后更新渲染之后会进行的副作用。这个函数可能会有返回值,倘若有返回值,返回值也必须是一个函数,会在组件被销毁(componentWillUnmount)时执行。
// 第二个参数是可选的,是一个数组,数组中存放的是第一个函数中使用的某些副作用属性。用来优化 useEffect
useEffect(() => { // 需要在componentDidMount执行的内容 return function cleanup() { // 需要在componentWillUnmount执行的内容 } }, [])

useEffect是用于我们管理副作用(例如 API 调用)并在组件中使用 React 生命周期的。useEffect 将回调函数作为其参数,并且回调函数可以返回一个清除函数(cleanup)。回调将在第一次渲染(componentDidMount) 和组件更新时(componentDidUpate)内执行,清理函数将组件被销毁(componentWillUnmount)内执行。

useEffect(() => {
  // 给 window 绑定点击事件
  window.addEventListener('click', handleClick);

  return () => {
      // 给 window 移除点击事件
      window.addEventListener('click', handleClick);
  }
});

默认情况下,useEffect 将在每个渲染时被调用,但是你还可以传递一个可选的第二个参数,该参数仅允许您在 useEffect 依赖的值更改时或仅在初始渲染时执行。第二个可选参数是一个数组,仅当其中一个值更改时才会 reRender(重新渲染)。如果数组为空,useEffect 将仅在 initial render(初始渲染)时调用。

useEffect(() => {
  // 使用浏览器API更新文档标题
  document.title = `You clicked ${count} times`;
}, [count]);    // 只有当数组中 count 值发生变化时,才会执行这个useEffect。

useContext with TypeScript

useContext允许您利用React context这样一种管理应用程序状态的全局方法,可以在任何组件内部进行访问而无需将值传递为 props。

useContext 函数接受一个 Context 对象并返回当前上下文值。当提供程序更新时,此挂钩将触发使用最新上下文值的重新渲染。

import { createContext, useContext } from 'react';

props ITheme {
  backgroundColor: string;
  color: string;
}

const ThemeContext = createContext<ITheme>({
  backgroundColor: 'black',
  color: 'white',
})

const themeContext = useContext<ITheme>(ThemeContext);

useReducer with TypeScript

对于更复杂的状态,您可以选择将该 useReducer 函数用作的替代 useState。

const [state,dispatch] =  useReducer(reducer,initialState,init);

如果您以前使用过Redux,则应该很熟悉。useReducer接受 3 个参数(reducer,initialState,init)并返回当前的 state 以及与其配套的 dispatch 方法。reducer 是如下形式的函数(state, action) => newState;initialState 是一个 JavaScript 对象;而 init 参数是一个惰性初始化函数,可以让你延迟加载初始状态。

这听起来可能有点抽象,让我们看一个实际的例子:

const initialState = 0;
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {number: state.number + 1};
    case 'decrement':
      return {number: state.number - 1};
    default:
      throw new Error();
  }
}
function init(initialState){
    return {number:initialState};
}
function Counter(){
    const [state, dispatch] = useReducer(reducer, initialState,init);
    return (
        <>
          Count: {state.number}
          <button onClick={() => dispatch({type: 'increment'})}>+</button>
          <button onClick={() => dispatch({type: 'decrement'})}>-</button>
        </>
    )
}

看完例子再结合上面 useReducer 的 api 是不是立马就明白了呢?

useCallback with TypeScript

useCallback 钩子返回一个 memoized 回调。这个钩子函数有两个参数:第一个参数是一个内联回调函数,第二个参数是一个数组。数组将在回调函数中引用,并按它们在数组中的存在顺序进行访问。

const memoizedCallback =  useCallback(()=> {
    doSomething(a,b);
  },[ a,b ],);

useCallback 将返回一个记忆化的回调版本,它仅会在某个依赖项改变时才重新计算 memoized 值。当您将回调函数传递给子组件时,将使用此钩子。这将防止不必要的渲染,因为仅在值更改时才执行回调,从而可以优化组件。可以将这个挂钩视为与shouldComponentUpdate生命周期方法类似的概念。

useMemo with TypeScript

useMemo返回一个 memoized 值。 传递“创建”函数和依赖项数组。useMemo 只会在其中一个依赖项发生更改时重新计算 memoized 值。此优化有助于避免在每个渲染上进行昂贵的计算。

const memoizedValue =  useMemo(() =>  computeExpensiveValue( a, b),[ a, b ]);
useMemo 在渲染过程中传递的函数会运行。不要做那些在渲染时通常不会做的事情。例如,副作用属于 useEffect,而不是 useMemo。

看到这,你可能会觉得,useMemouseCallback的作用有点像啊,那它们之间有什么区别呢?

  • useCallback 和 useMemo 都可缓存函数的引用或值。
  • 从更细的使用角度来说 useCallback 缓存函数的引用,useMemo 缓存计算数据的值。

useRef with TypeScript

useRef挂钩允许你创建一个 ref 并且允许你访问基础 DOM 节点的属性。当你需要从元素中提取值或获取与 DOM 相关的元素信息(例如其滚动位置)时,可以使用此方法。

const refContainer  =  useRef(initialValue);

useRef 返回一个可变的 ref 对象,其.current属性被初始化为传递的参数(initialValue)。返回的对象将存留在整个组件的生命周期中。

function TextInputWithFocusButton() {
  const inputEl = useRef<HTMLInputElement>(null);
  const onButtonClick = () => {
    inputEl.current.focus();
  };

  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

useImperativeHandle with TypeScript

useImperativeHandle可以让你在使用 ref 时,自定义暴露给父组件的实例值。

useImperativeHandle(ref, createHandle, [inputs])

useImperativeHandle 钩子函数接受 3 个参数: 一个 React ref、一个 createHandle 函数和一个用于暴露给父组件参数的可选数组。

function FancyInput(props, ref) {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    }
  }));
  return <input ref={inputRef} ... />;
}
FancyInput = React.forwardRef(FancyInput);

const fancyInputRef = React.createRef();
<FancyInput ref={fancyInputRef}>Click me!</FancyInput>;

useLayoutEffect with TypeScript

与 useEffect Hooks 类似,都是执行副作用操作。但是它是在所有 DOM 更新完成后触发。可以用来执行一些与布局相关的副作用,比如获取 DOM 元素宽高,窗口滚动距离等等。

useLayoutEffect(() => { doSomething });
进行副作用操作时尽量优先选择 useEffect,以免阻止视图更新。与 DOM 无关的副作用操作请使用 useEffect。
import React, { useRef, useState, useLayoutEffect } from 'react';

export default () => {

    const divRef = useRef(null);

    const [height, setHeight] = useState(50);

    useLayoutEffect(() => {
        // DOM 更新完成后打印出 div 的高度
        console.log('useLayoutEffect: ', divRef.current.clientHeight);
    })

    return <>
        <div ref={ divRef } style={{ background: 'red', height: height }}>Hello</div>
        <button onClick={ () => setHeight(height + 50) }>改变 div 高度</button>
    </>

}

useDebugValue with TypeScript

useDebugValue是用于调试自定义挂钩(自定义挂钩请参考https://reactjs.org/docs/hooks-custom.html)的工具。它允许您在 React Dev Tools 中显示自定义钩子函数的标签。

示例

我之前基于 umi+react+typescript+ant-design 构建了一个简单的中后台通用模板。

涵盖的功能如下:

- 组件
  - 基础表格
  - ECharts 图表
  - 表单
    - 基础表单
    - 分步表单
  - 编辑器

- 控制台
- 错误页面
  - 404

里面对于在 react 中结合Hooks使用 typescript 的各种场景都有很好的实践,大家感兴趣的可以参考一下,https://github.com/FSFED/Umi-hooks/tree/feature_hook,当然不要吝惜你的 star!!!

最后

你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。当然,我也是开源社区的积极贡献者,github地址https://github.com/Jack-cool,欢迎star!!!

查看原文

赞 4 收藏 3 评论 0

前端森林 发布了文章 · 1月21日

深入理解浏览器的缓存机制

引言

浏览器缓存,一个经久不衰的话题。

先来看一下百度百科对它的定义:

浏览器缓存(Browser Caching)是为了节约网络的资源加速浏览,浏览器在用户磁盘上对最近请求过的文档进行存储,当访问者再次请求这个页面时,浏览器就可以从本地磁盘显示文档,这样就可以加速页面的阅览。

缓存可以说是性能优化中简单高效的一种优化方式了。一个优秀的缓存策略可以缩短网页请求资源的距离,减少延迟,并且由于缓存文件可以重复利用,还可以减少带宽,降低网络负荷。

本篇文章会从缓存位置、缓存过程分析、缓存类型、缓存机制、缓存策略以及用户行为对浏览器缓存的影响几方面带你一步步深入了解浏览器缓存。

缓存位置

从缓存位置上来说分为四种,并且各自有优先级,当依次查找缓存且都没有命中的时候,才会去请求网络。

  • Service Worker
  • Memory Cache
  • Disk Cache
  • Push Cache

Service Worker

Service Worker 是运行在浏览器背后的独立线程,一般可以用来实现缓存功能。使用 Service Worker 的话,传输协议必须为 HTTPS。因为 Service Worker 中涉及到请求拦截,所以必须使用 HTTPS 协议来保障安全。

Memory Cache

Memory Cache 也就是内存中的缓存,主要包含的是当前页面中已经抓取到的资源,例如页面上已经下载的样式、脚本、图片等。读取内存中的数据肯定比磁盘快,内存缓存虽然读取高效,可是缓存持续性很短,会随着进程的释放而释放(一旦我们关闭 Tab 页面,内存中的缓存也就被释放了)。

内存缓存中有一块重要的缓存资源是 preloader 相关指令(例如<link rel="prefetch">)众所周知 preloader 的相关指令已经是页面优化的常见手段之一,它可以一边解析 js/css 文件,一边网络请求下一个资源。

Disk Cache

Disk Cache 也就是存储在硬盘中的缓存,读取速度虽然慢点,但是什么都能存储到磁盘中,与 Memory Cache 相比,优势是容量和存储时效性。

在所有浏览器缓存中,Disk Cache 覆盖面基本上是最大的。它会根据 HTTP Header 中的字段判断哪些资源缓存(不用慌,关于 HTTP 的协议头中的缓存字段,会在下面详细介绍的),哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据。绝大部分的缓存都来自 Disk Cache。

浏览器会把哪些文件丢进内存中?哪些丢进硬盘中?

关于这点,网上说法不一,不过以下两点比较靠得住:

  • 对于大文件来说,大概率是不存储在内存中的
  • 当前系统内存使用率高的话,文件优先存进硬盘

Push Cache

Push Cache(推送缓存)是 HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。它只在会话(Session)中存在,一旦会话结束就被释放,并且缓存时间也很短暂。

如果以上四种缓存都没有命中的话,那么只能发起请求来获取资源了。

为了性能上的考虑,大部分的接口都应该选择好缓存策略,通常浏览器缓存策略分为两种:强缓存和协商缓存,并且缓存策略都是通过设置 HTTP Header 来实现的。

缓存过程分析

浏览器与服务器通信的方式为应答模式,即: 浏览器发起 HTTP 请求 >> 服务器响应该请求,那么浏览器怎么确定一个资源该不该缓存,如何去缓存呢?浏览器第一次向服务器发起该请求后拿到请求结果后,将请求结果和缓存标识存入浏览器缓存,浏览器对于缓存的处理是根据第一次请求资源时返回的响应头来确定的。具体过程如下图:

由上图我们可以知道:

  • 浏览器每次发起请求,都会先在浏览器缓存中查找该请求的结果以及缓存标识。
  • 浏览器每次拿到返回的请求结果都会将该结果和缓存标识存入浏览器缓存中。

以上两点是浏览器缓存机制的关键,它确保了每个请求的缓存存入与读取。下面说一下浏览器缓存的使用规则。根据是否需要向服务器重新发起 HTTP 请求将缓存过程分为两个部分,分别是强缓存和协商缓存。

强缓存

强缓存: 不会向服务器发起请求,直接从缓存中读取资源,在 chrome 控制台的 Network 选项中可以看到该请求返回 200 的状态码,并且size显示from disk cachefrom memory cache。强缓存可以通过设置两种 HTTP Header 实现: Expires 和 Cache-Control

1、 Expires

缓存过期时间,用来指定资源到期的时间,是服务端的具体时间点。也就是说,Expires=max-age + 请求时间,需要和 Last-modified 结合使用。Expires 是 Web 服务器响应消息头字段,在响应 http 请求时告诉浏览器在过期时间前浏览器可以直接从浏览器缓存取数据,而无需再次请求。

Expires 是 HTTP/1 的产物,受限于本地时间,如果修改了本地时间,可能会造成缓存失效。

2、 Cache-Control

在 HTTP/1.1 中,Cache-Control 是最重要的规则,主要用于控制网页缓存。

Cache-Control 可以在请求头或者响应头中设置,并且可以组合使用多种指令:

  • public: 表明响应可以被任何对象(包括:发送请求的客户端,代理服务器,等等)缓存,即使是通常不可缓存的内容(例如,该响应没有max-age指令或Expires消息头)。
  • private: 表明响应只能被单个用户缓存,不能作为共享缓存(即代理服务器不能缓存它)。私有缓存可以缓存响应内容。
  • no-cache: 在发布缓存副本之前,强制要求缓存把请求提交给原始服务器进行验证。
  • no-store: 缓存不应存储有关客户端请求或服务器响应的任何内容。
  • max-age: 设置缓存存储的最大周期,超过这个时间缓存被认为过期(单位秒)。与Expires相反,时间是相对于请求的时间。
  • s-maxage: 覆盖max-age或者Expires头,但是仅适用于共享缓存(比如各个代理),私有缓存会忽略它。
  • max-stale: 表明客户端愿意接收一个已经过期的资源。可以设置一个可选的秒数,表示响应不能已经过时超过该给定的时间。
  • min-fresh: 表示客户端希望获取一个能在指定的秒数内保持其最新状态的响应。

3、 Expires 和 Cache-Control 两者对比

其实这两者差别不大,区别就在于 Expires 是 http1.0 的产物,Cache-Control 是 http1.1 的产物,两者同时存在的话,Cache-Control 优先级高于 Expires;在某些不支持 HTTP1.1 的环境下,Expires 就会发挥用处。所以 Expires 其实是过时的产物,现阶段它的存在只是一种兼容性的写法。

强缓存判断是否缓存的依据来自于是否超出某个时间或者某个时间段,而不关心服务器端文件是否已经更新,这可能会导致加载文件不是服务器端最新的内容,那我们如何获知服务器端内容是否已经发生了更新呢?此时我们需要用到协商缓存策略。

协商缓存

协商缓存就是强制缓存失效后,浏览器携带缓存标识向服务器发起请求,由服务器根据缓存标识决定是否使用缓存的过程,主要有以下两种情况:

  • 协商缓存生效,返回 304 和 Not Modified

  • 协商缓存成功,返回 200 和请求结果

协商缓存可以通过设置两种 HTTP Header 实现: Last-Modified 和 ETag

缓存机制

强制缓存优先于协商缓存进行,若强制缓存 (Expires 和 Cache-Control) 生效则直接使用缓存,若不生效则进行协商缓存 (Last-Modified / If-Modified-Since 和 Etag / If-None-Match),协商缓存由服务器决定是否使用缓存,若协商缓存失效,那么代表该请求的缓存失效,返回 200,重新返回资源和缓存标识,再存入浏览器缓存中;生效则返回 304,继续使用缓存。具体流程图如下:

实际场景应用缓存策略

  • 频繁变动的资源

对于频繁变动的资源,首先需要使用 Cache-Control: no-cache 使浏览器每次都请求服务器,然后配合 ETag 或者 Last-Modified 来验证资源是否有效。这样的做法虽然不能节省请求数量,但是能显著减少响应数据大小。

  • 不常变化的资源

通常在处理这类资源时,给它们的 Cache-Control 配置一个很大的 max-age=31536000 (一年),这样浏览器之后请求相同的 URL 会命中强制缓存。而为了解决更新的问题,就需要在文件名 (或者路径) 中添加 hash, 版本号等动态字符,之后更改动态字符,从而达到更改引用 URL 的目的,让之前的强制缓存失效 (其实并未立即失效,只是不再使用了而已)。

用户行为对浏览器缓存的影响

所谓用户行为对浏览器缓存的影响,指的就是用户在浏览器如何操作时,会触发怎样的缓存策略。主要有 3 种:

  • 打开网页,地址栏输入地址: 查找 disk cache 中是否有匹配。如有则使用;如没有则发送网络请求;
  • 普通刷新 (F5):因为 TAB 并没有关闭,因此 memory cache 是可用的,会被优先使用 (如果匹配的话)。其次才是 disk cache;
  • 强制刷新 (Ctrl + F5):浏览器不使用缓存,因此发送的请求头部均带有 Cache-control: no-cache(为了兼容,还带了 Pragma: no-cache), 服务器直接返回 200 和最新内容。

最后

你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。当然,我也是开源社区的积极贡献者,github地址https://github.com/Jack-cool,欢迎star!!!

前端森林公众号二维码.png

查看原文

赞 3 收藏 1 评论 0

前端森林 发布了文章 · 1月21日

webpack5快发布了,你还没用过4吗?

webpack.jpeg

引言

webpack5 预计会在 2020 年年初发布,之前从 alpha 版本就有关注,本次重点更新在长期缓存,tree shakking 和 es6 打包这块。具体变更可以参考https://github.com/webpack/ch...

webpack 是现代前端开发中最火的模块打包工具,只需要通过简单的配置,便可以完成模块的加载和打包。那它是怎么做到通过对一些插件的配置,便可以轻松实现对代码的构建呢?

本篇文章不会去探讨 webpack5 中所要更新的内容,我相信大多数前端同学对于 webpack 只是会简单的配置,而且现在像 vue-cli、umi 等对于 webpack 都有很好的封装,但其实这样对于我们自己是不太好的。尤其是想针对业务场景去做一些个性化的定制时。只有对 webpack 中的细节足够了解,我们才能游刃有余,本文将从 webpack 现有的大版本 webpack4,带你一步步打造极致的前端开发环境。

安装 webpack 的几种方式

  • global(全局):通过 webpack index.js 运行
  • local(项目维度安装):通过 npx webpack index.js 运行

避免全局安装 webpack(针对多个项目采用不同的 webpack 版本进行打包的场景),可采用npx

entry(入口)

单一入口

// webpack.config.js

const config = {
  entry: {
    main: "./src/index.js"
  }
};

多入口

// webpack.config.js

const config = {
  entry: {
    main: "./src/index.js",
    sub: "./src/sub.js"
  }
};

output(输出)

默认配置

// webpack.config.js
const path = require('path');
...

const config = {
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  }
};

module.exports = config;

多个入口起点

如果配置创建了多个单独的 "chunk"(例如,使用多个入口起点或使用像 CommonsChunkPlugin 这样的插件),则应该使用占位符(substitutions)来确保每个文件具有唯一的名称。
// webpack.config.js
const path = require('path');
{
  entry: {
    main: './src/index.js',
    sub: './src/sub.js'
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist')
  }
}

// 写入到硬盘:./dist/main.js, ./dist/sub.js

高级进阶

使用 cdn
// webpack.config.js
const path = require('path');
{
  entry: {
    main: './src/index.js',
    sub: './src/sub.js'
  },
  output: {
    publicPath: 'http://cdn.example.com'
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist')
  }
}

// 写入到http://cdn.example.com/main.js, http://cdn.example.com/sub.js

loaders

webpack 可以使用 loader 来预处理文件。这允许你打包除 JavaScript 之外的任何静态资源。

file-loader

  • file-loader 可以解析项目中的 url 引入(不仅限于 css),根据我们的配置,将图片拷贝到相应的路径,再根据我们的配置,修改打包后文件引用路径,使之指向正确的文件。
  • 默认情况下,生成的文件的文件名就是文件内容的 MD5 哈希值并会保留所引用资源的原始扩展名。
rules: [
  {
    test: /\.(jpg|png|gif)$/,
    use: {
      loader: "file-loader",
      options: {
        name: "[name]_[hash].[ext]",
        outputPath: "images/"
      }
    }
  }
];

url-loader

  • url-loader 功能类似于 file-loader,但是在文件大小(单位 byte)低于指定的限制时,可以返回一个 DataURL。
  • url-loader 把资源文件转换为 URL,file-loader 也是一样的功能。不同之处在于 url-loader 更加灵活,它可以把小文件转换为 base64 格式的 URL,从而减少网络请求次数。url-loader 依赖 file-loader。
rules: [
  {
    test: /\.(jpg|png|gif)$/,
    use: {
      loader: "url-loader",
      options: {
        name: "[name]_[hash].[ext]",
        outputPath: "images/",
        limit: 204800
      }
    }
  }
];

css-loader

  • 只负责加载 css 模块,不会将加载的 css 样式应用到 html
  • importLoaders 用于指定在 css-loader 前应用的 loader 的数量
  • 查询参数 modules 会启用 CSS 模块规范
module: {
  rules: [
    {
      test: /\.css$/,
      use: ["style-loader", "css-loader"]
    }
  ];
}

style-loader

  • 负责将 css-loader 加载到的 css 样式动态的添加到 html-head-style 标签中
  • 一般建议将 style-loader 与 css-loader 结合使用

sass-loader

安装

yarn add sass-loader node-sass webpack --dev

  • node-sass 和 webpack 是 sass-loader 的 peerDependency,因此能够精确控制它们的版本。
  • loader 执行顺序:从下至上,从右至左
  • 通过将 style-loader 和 css-loader 与 sass-loader 链式调用,可以立刻将样式作用在 DOM 元素。
// webpack.config.js
module.exports = {
...
module: {
  rules: [{
    test: /\.scss$/,
    use: [{
        loader: "style-loader" // 将 JS 字符串生成为 style 节点
    }, {
        loader: "css-loader" // 将 CSS 转化成 CommonJS 模块
    }, {
        loader: "sass-loader" // 将 Sass 编译成 CSS
    }]
  }]
}
};

postcss-loader

  • webpack4 中使用 postcss-loader 代替 autoprefixer,给 css3 样式加浏览器前缀。具体可参考https://blog.csdn.net/u014628388/article/details/82593185
// webpack.config.js
 {
  test: /\.scss$/,
  use: [
    'style-loader',
      'css-loader',
      'sass-loader',
      'postcss-loader'
    ],
}

//postcss.config.js
module.exports = {
    plugins: [
        require('autoprefixer')({ browsers: ['last 2 versions'] }),
    ],
};

plugins

plugin 可以在 webpack 运行到某个时刻的时候,帮你做一些事情

HtmlWebpackPlugin

  • HtmlWebpackPlugin 会在打包结束后,自动生成一个 html 文件,并把打包生成的 js 自动引入到这个 html 文件中
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
...
plugins: [
    new HtmlWebpackPlugin({
      template: 'src/index.html'
    }),
  ],
};

clean-webpack-plugin

  • clean-webpack-plugin 插件用来清除残留打包文件,特别是文件末尾添加了 hash 之后,会导致改变文件内容后重新打包时,文件名不同而内容越来越多。
  • 新版本中的 clean-webpack-plugin 仅接受一个对象,默认不需要传任何参数。具体可参考https://blog.csdn.net/qq_23521659/article/details/88353708
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
...
plugins: [
    new CleanWebpackPlugin()
  ],
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  }

SplitChunksPlugin

  • 具体概念可参考https://juejin.im/post/5af15e895188256715479a9a
splitChunks: {
    chunks: "async",
    minSize: 30000,
    minChunks: 1,
    maxAsyncRequests: 5,
    maxInitialRequests: 3,
    automaticNameDelimiter: '~',
    name: true,
    cacheGroups: {
        vendors: {
            test: /[\\/]node_modules[\\/]/,
            priority: -10
        },
    default: {
            minChunks: 2,
            priority: -20,
            reuseExistingChunk: true
        }
    }
}

MiniCssExtractPlugin

将 CSS 提取为独立的文件的插件,对每个包含 css 的 js 文件都会创建一个 CSS 文件,支持按需加载 css 和 sourceMap
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
  plugins: [
    new MiniCssExtractPlugin({
      // Options similar to the same options in webpackOptions.output
      // both options are optional
      filename: "[name].css",
      chunkFilename: "[id].css"
    })
  ],
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader
          },
          {
            loader: "css-loader",
            options: {
              importLoaders: 2 // 用于指定在 css-loader 前应用的 loader 的数量
              // modules: true   // 查询参数 modules 会启用 CSS 模块规范
            }
          },
          "sass-loader",
          "postcss-loader"
        ]
      },
      {
        test: /\.css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader
          },
          "css-loader",
          "postcss-loader"
        ]
      }
    ]
  }
};

OptimizeCSSAssetsPlugin

webpack5 可能会内置 CSS 压缩器,webpack4 需要自己使用压缩器,可以使用 optimize-css-assets-webpack-plugin 插件。 设置 optimization.minimizer 覆盖 webpack 默认提供的,确保也指定一个 JS 压缩器
const UglifyJsPlugin = require("uglifyjs-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin");

module.exports = {
  optimization: {
    minimizer: [
      new UglifyJsPlugin({
        cache: true,
        parallel: true,
        sourcMap: true
      }),
      new OptimizeCSSAssetsPlugin({})
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: "[name].css",
      chunkFilename: "[id].css"
    })
  ],
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, "css-loader"]
      }
    ]
  }
};

devtool

source map

source map 就是对打包生成的代码与源代码的一种映射,主要是为了方便定位问题和排查问题。devtool 关键有 eval、cheap、module、inline 和 source-map 这几块,具体可参考文档:https://www.webpackjs.com/configuration/devtool/
  • development 环境参考配置: 'cheap-module-eval-source-map'
  • production 环境参考配置: 'cheap-module-source-map'

webpack-dev-server

webpack-dev-server 提供了一个简单的 web 服务器,并且能够实时重新加载(live reloading)。具体可参考https://www.webpackjs.com/guides/development/#%E4%BD%BF%E7%94%A8-webpack-dev-server

接口代理(请求转发)

如果你有单独的后端开发服务器 API,并且希望在同域名下发送 API 请求 ,那么代理某些 URL 会很有用。dev-server 使用了非常强大的 http-proxy-middleware 包。常用于接口请求转发。具体参考https://www.webpackjs.com/configuration/dev-server/#devserver-proxy
devServer: {
    contentBase: "./dist",
    open: true,
    hot: true,
    hotOnly: true,
    proxy: {
      "/api": {
        target: "https://other-server.example.com",
        pathRewrite: {"^/api" : ""},
        secure: false,
        bypass: function(req, res, proxyOptions) {
          if (req.headers.accept.indexOf("html") !== -1) {
            console.log("Skipping proxy for browser request.");
            return "/index.html";
          }
        }
      }
    }
  },

解决单页面路由问题

当使用 HTML5 History API 时,任意的 404 响应都可能需要被替代为 index.html
通过传入以下启用:
historyApiFallback: true;

通过传入一个对象,比如使用 rewrites 这个选项,此行为可进一步地控制:

historyApiFallback: {
  rewrites: [
    { from: /^\/$/, to: "/views/landing.html" },
    { from: /^\/subpage/, to: "/views/subpage.html" },
    { from: /./, to: "/views/404.html" }
  ];
}

webpack-dev-middleware

webpack-dev-middleware 是一个容器(wrapper),它可以把 webpack 处理后的文件传递给一个服务器(server)。 webpack-dev-server 在内部使用了它,同时,它也可以作为一个单独的包来使用,以便进行更多自定义设置来实现更多的需求
// server.js
// 使用webpack-dev-middleware
// https://www.webpackjs.com/guides/development/#%E4%BD%BF%E7%94%A8-webpack-dev-middleware
const express = require("express");
const webpack = require("webpack");
const webpackDevMiddleware = require("webpack-dev-middleware");
const config = require("./webpack.config.js");
const complier = webpack(config);

const app = express();

app.use(
  webpackDevMiddleware(complier, {
    publicPath: config.output.publicPath
  })
);

app.listen(3000, () => {
  console.log("server is running");
});

Hot Module Replacement

模块热替换(Hot Module Replacement 或 HMR)是 webpack 提供的最有用的功能之一。它允许在运行时更新各种模块,而无需进行完全刷新。
// webpack.config.js
...
const webpack = require('webpack');
...
devServer: {
  contentBase: './dist',
  open: true,
  hot: true,
  hotOnly: true
},
plugins: [
  ...
  new webpack.HotModuleReplacementPlugin()
],

如果已经通过 HotModuleReplacementPlugin 启用了模块热替换(Hot Module Replacement),则它的接口将被暴露在 module.hot 属性下面。通常,用户先要检查这个接口是否可访问,然后再开始使用它。

// index.js
if (module.hot) {
  module.hot.accept("./library.js", function() {
    // 使用更新过的 library 模块执行某些操作...
  });
}

bundle 分析

借助一些官方推荐的可视化分析工具,可对打包后的模块进行分析以及优化
  • webpack-chart: webpack 数据交互饼图
  • webpack-visualizer: 可视化并分析你的 bundle,检查哪些模块占用空间,哪些可能是重复使用的
  • webpack-bundle-analyzer: 一款分析 bundle 内容的插件及 CLI 工具,以便捷的、交互式、可缩放的树状图形式展现给用户

Preloading、Prefetching

prefetch:会等待核心代码加载完成后,页面带宽空闲后再去加载 prefectch 对应的文件;preload:和主文件一起去加载
  • 可以使用谷歌浏览器 Coverage 工具查看代码覆盖率(ctrl+shift+p > show coverage)
  • 使用异步引入 js 的方式可以提高 js 的使用率,所以 webpack 建议我们多使用异步引入的方式,这也是 splitChunks.chunks 的默认值是"async"的原因
  • 使用魔法注释 /_ webpackPrefetch: true _/ ,这样在主要 js 加载完,带宽有空闲时,会自动下载需要引入的 js
  • 使用魔法注释 /_ webpackPreload: true _/,区别是 webpackPrefetch 会等到主业务文件加载完,带宽有空闲时再去下载 js,而 preload 是和主业务文件一起加载的

babel

babel 编译 es6、jsx 等

  • @babel/core babel 核心模块
  • @babel-preset-env 编译 es6 等
  • @babel/preset-react 转换 jsx
  • @babel/plugin-transform-runtime 避免 polyfill 污染全局变量,减少打包体积
  • @babel/polyfill es6 内置方法和函数转化垫片
  • @babel/runtime
module: {
  rules: [
    {
      test: /\.js$/,
      exclude: /node_modules/,
      use: {
        loader: "babel-loader"
      }
    }
  ];
}

新建.babelrc 文件

{
  "presets": ["@babel/preset-env", "@babel/preset-react"],
  "plugins": ["@babel/plugin-transform-runtime"]
}

按需引入 polyfill

在 src 下的 index.js 中全局引入@babel/polyfill 并写入 es6 语法,但是这样有一个缺点:
全局引入@babel/polyfill 的这种方式可能会导入代码中不需要的 polyfill,从而使打包体积更大,修改.babelrc 配置


`yarn add core-js@2 @babel/runtime-corejs2 --dev`

{
  "presets": [
    [
      "@babel/preset-env", {
      "useBuiltIns": "usage"
      }
    ],
    "@babel/preset-react"
  ],
  "plugins": ["@babel/plugin-transform-runtime"]
}

这就配置好了按需引入。配置了按需引入 polyfill 后,用到 es6 以上的函数,babel 会自动导入相关的 polyfill,这样能大大减少打包编译后的体积。

babel-runtime 和 babel-polyfill 的区别

参考https://www.jianshu.com/p/73ba084795ce
  • babel-polyfill 会”加载整个 polyfill 库”,针对编译的代码中新的 API 进行处理,并且在代码中插入一些帮助函数
  • babel-polyfill 解决了 Babel 不转换新 API 的问题,但是直接在代码中插入帮助函数,会导致污染了全局环境,并且不同的代码文件中包含重复的代码,导致编译后的代码体积变大。 Babel 为了解决这个问题,提供了单独的包 babel-runtime 用以提供编译模块的工具函数, 启用插件 babel-plugin-transform-runtime 后,Babel 就会使用 babel-runtime 下的工具函数
  • babel-runtime 适合在组件,类库项目中使用,而 babel-polyfill 适合在业务项目中使用。

高级概念

tree shaking(js)

tree shaking 可清除代码中无用的 js 代码,只支持 import 方式引入,不支持 commonjs 的方式引入
mode 是 production 的无需配置,下面的配置是针对 development 的
// webpack.config.js
optimization: {
  usedExports: true
}


// package.json
"sideEffects": false,

Code Spliting

代码分割,和 webpack 无关
  • 同步代码(需在 webpack.config.js 中配置 optimization)
// index.js
import _ from 'lodash';

console.log(_.join(['a','b','c'], '****'))

// 在webpack.base.js里做相关配置
optimization: {
    splitChunks: {
      chunks: 'all'
    }
  },
  • 异步代码(无需任何配置,但需安装@babel/plugin-syntax-dynamic-import包)
// index.js
function getComponent() {
  return import("lodash").then(({ default: _ }) => {
    const element = document.createElement("div");
    element.innerHTML = _.join(["Jack", "Cool"], "-");
    return element;
  });
}

getComponent().then(el => {
  document.body.appendChild(el);
});

Caching(缓存)

通过使用 output.filename 进行文件名替换,可以确保浏览器获取到修改后的文件。[hash] 替换可以用于在文件名中包含一个构建相关(build-specific)的 hash,但是更好的方式是使用 [contenthash] 替换,当文件内容发生变化时,[contenthash]也会发生变化
output: {
  filename: "[name].[contenthash].js",
  chunkFilename: '[name].[contenthash].chunk.js'
}

Shimming

webpack 编译器(compiler)能够识别遵循 ES2015 模块语法、CommonJS 或 AMD 规范编写的模块。然而,一些第三方的库(library)可能会引用一些全局依赖(例如 jQuery 中的 &dollar;)。这些库也可能创建一些需要被导出的全局变量。这些“不符合规范的模块”就是 shimming 发挥作用的地方
  • shimming 全局变量(第三方库)(ProvidePlugin 相当于一个垫片)
 const path = require('path');
+ const webpack = require('webpack');

  module.exports = {
    entry: './src/index.js',
    output: {
      filename: 'bundle.js',
      path: path.resolve(__dirname, 'dist')
-   }
+   },
+   plugins: [
+     new webpack.ProvidePlugin({
+       _: 'lodash'
+     })
+   ]
  };
  • 细粒度 shimming(this 指向 window)(需要安装 imports-loader 依赖)
 const path = require('path');
  const webpack = require('webpack');

  module.exports = {
    entry: './src/index.js',
    output: {
      filename: 'bundle.js',
      path: path.resolve(__dirname, 'dist')
    },
+   module: {
+     rules: [
+       {
+         test: require.resolve('index.js'),
+         use: 'imports-loader?this=>window'
+       }
+     ]
+   },
    plugins: [
      new webpack.ProvidePlugin({
        join: ['lodash', 'join']
      })
    ]
  };

环境变量

webpack 命令行环境选项 --env 允许您传入任意数量的环境变量。您的环境变量将可访问 webpack.config.js。例如,--env.production 或--env.NODE_ENV=local
webpack --env.NODE_ENV=local --env.production --progress

使用环境变量必须对 webpack 配置进行一项更改。通常,module.exports 指向配置对象。要使用该 env 变量,必须转换 module.exports 为函数:

// webpack.config.js
const path = require("path");

module.exports = env => {
  // Use env.<YOUR VARIABLE> here:
  console.log("NODE_ENV: ", env.NODE_ENV); // 'local'
  console.log("Production: ", env.production); // true

  return {
    entry: "./src/index.js",
    output: {
      filename: "bundle.js",
      path: path.resolve(__dirname, "dist")
    }
  };
};

library 打包配置

除了打包应用程序代码,webpack 还可以用于打包 JavaScript library
用户应该能够通过以下方式访问 library:
  • ES2015 模块。例如 import library from 'library'
  • CommonJS 模块。例如 require('library')
  • 全局变量,当通过 script 脚本引入时

我们打包的 library 中可能会用到一些第三方库,诸如 lodash。现在,如果执行 webpack,你会发现创建了一个非常巨大的文件。如果你查看这个文件,会看到 lodash 也被打包到代码中。在这种场景中,我们更倾向于把 lodash 当作 peerDependency。也就是说,用户应该已经将 lodash 安装好。因此,你可以放弃对外部 library 的控制,而是将控制权让给使用 library 的用户。这可以使用 externals 配置来完成:

  // webpack.config.js
  var path = require('path');

  module.exports = {
    entry: './src/index.js',
    output: {
      path: path.resolve(__dirname, 'dist'),
      filename: 'webpack-numbers.js'
-   }
+   },
+   externals: {
+     lodash: {
+       commonjs: 'lodash',
+       commonjs2: 'lodash',
+       amd: 'lodash',
+       root: '_'
+     }
+   }
  };

对于用途广泛的 library,我们希望它能够兼容不同的环境,例如 CommonJS,AMD,Node.js 或者作为一个全局变量。为了让你的 library 能够在各种用户环境(consumption)中可用,需要在 output 中添加 library 属性:

  // webpack.config.js
  var path = require('path');

  module.exports = {
    entry: './src/index.js',
    output: {
      path: path.resolve(__dirname, 'dist'),
-     filename: 'library.js'
+     filename: 'library.js',
+     library: 'library'
    },
    externals: {
      lodash: {
        commonjs: 'lodash',
        commonjs2: 'lodash',
        amd: 'lodash',
        root: '_'
      }
    }
  };

当你在 import 引入模块时,这可以将你的 library bundle 暴露为名为 webpackNumbers 的全局变量。为了让 library 和其他环境兼容,还需要在配置文件中添加 libraryTarget 属性。这是可以控制 library 如何以不同方式暴露的选项。

  var path = require('path');

  module.exports = {
    entry: './src/index.js',
    output: {
      path: path.resolve(__dirname, 'dist'),
      filename: 'library.js',
+     library: 'library',
+     libraryTarget: 'umd'
    },
    externals: {
      lodash: {
        commonjs: 'lodash',
        commonjs2: 'lodash',
        amd: 'lodash',
        root: '_'
      }
    }
  };

我们还需要通过设置 package.json 中的 main 字段,添加生成 bundle 的文件路径。

// package.json
{
  ...
  "main": "dist/library.js",
  ...
}

PWA 打包配置

渐进式网络应用程序(Progressive Web Application - PWA),是一种可以提供类似于原生应用程序(native app)体验的网络应用程序(web app)。PWA 可以用来做很多事。其中最重要的是,在离线(offline)时应用程序能够继续运行功能。这是通过使用名为 Service Workers 的网络技术来实现的
添加 workbox-webpack-plugin 插件,并调整 webpack.config.js 文件:
npm install workbox-webpack-plugin --save-dev

webpack.config.js

 const path = require('path');
  const HtmlWebpackPlugin = require('html-webpack-plugin');
  const CleanWebpackPlugin = require('clean-webpack-plugin');
+ const WorkboxPlugin = require('workbox-webpack-plugin');

  module.exports = {
    entry: {
      app: './src/index.js',
      print: './src/print.js'
    },
  plugins: [
    new CleanWebpackPlugin(['dist']),
    new HtmlWebpackPlugin({
-     title: 'Output Management'
+     title: 'Progressive Web Application'
-   })
+   }),
+   new WorkboxPlugin.GenerateSW({
+     // 这些选项帮助 ServiceWorkers 快速启用
+     // 不允许遗留任何“旧的” ServiceWorkers
+     clientsClaim: true,
+     skipWaiting: true
+   })
  ],
    output: {
      filename: '[name].bundle.js',
      path: path.resolve(__dirname, 'dist')
    }
  };

注册 Service Worker

  import _ from 'lodash';
  import printMe from './print.js';

+ if ('serviceWorker' in navigator) {
+   window.addEventListener('load', () => {
+     navigator.serviceWorker.register('/sw.js').then(registration => {
+       console.log('SW registered: ', registration);
+     }).catch(registrationError => {
+       console.log('SW registration failed: ', registrationError);
+     });
+   });
+ }

现在来进行测试。停止服务器并刷新页面。如果浏览器能够支持 Service Worker,你应该可以看到你的应用程序还在正常运行。然而,服务器已经停止了服务,此刻是 Service Worker 在提供服务。

TypeScript 打包配置

可参考https://www.webpackjs.com/guides/typescript/https://webpack.js.org/guides/typescript/
  • 安装 ts 依赖npm install --save-dev typescript ts-loader
  • 增加 tsconfig.json 配置文件
{
  "compilerOptions": {
    "outDir": "./dist/",
    "noImplicitAny": true,
    "module": "es6",
    "target": "es5",
    "jsx": "react",
    "allowJs": true
  }
}
  • webpack.config.js 添加对 ts/tsx 语法支持(ts-loader)
const path = require("path");

module.exports = {
  entry: "./src/index.ts",
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: "ts-loader",
        exclude: /node_modules/
      }
    ]
  },
  resolve: {
    extensions: [".tsx", ".ts", ".js"]
  },
  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "dist")
  }
};
  • 当从 npm 安装第三方库时,一定要牢记同时安装这个库的类型声明文件。可以从 TypeSearch 中找到并安装这些第三方库的类型声明文件。如npm install --save-dev @types/lodash

webpack 性能优化

  • 及时更新 node、yarn、webpack 等的版本
  • 在尽可能少的模块上应用 loader
  • plugin 尽可能精简并确保可靠(选用社区已验证的插件)
  • resolve 参数合理配置(具体参考https://www.webpackjs.com/configuration/resolve/)
  • 使用 DllPlugin 提高打包速度
  • 控制包文件大小(tree shaking / splitChunksPlugin)
  • thread-loader,parallel-webpack,happypack 多进程打包
  • 合理利用 sourceMap
  • 结合stats.json分析打包结果(bundle analyze)
  • 开发环境内存编译
  • 开发环境无用插件剔除

最后

你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。当然,我也是开源社区的积极贡献者,github地址https://github.com/Jack-cool,欢迎star!!!

前端森林公众号二维码.png

查看原文

赞 7 收藏 4 评论 0

前端森林 收藏了文章 · 2019-05-31

Vue3.0数据双向绑定Proxy探究

前言

2018年11月16日,关注vue的人都知道这个时间点发生了什么事儿吧。vue3.0更新内容

研究数据双向绑定的大佬们都在开始猜测这个新机制了,用原生Proxy替换Object.defineProperty

1. 为什么要替换Object.defineProperty

替换不是因为不好,是因为有更好的方法使用效率更高

Object.defineProperty的缺点:

  1. 在Vue中,Object.defineProperty无法监控到数组下标的变化,导致直接通过数组的下标给数组设置值,不能实时响应。为了解决这个问题,经过vue内部处理后可以使用以下几种方法来监听数组。有关于这个说明,可以看看这个文章 vue为什么不能检测数组变动

    push()
    pop()
    shift()
    unshift()
    splice()
    sort()
    reverse()

    目前只针对以上方法做了hack处理,所以恰数组属性是检测不到的,有局限性。

  2. Object.defineProperty只能劫持对象的属性,因此我们需要对每个对象的每个属性进行遍历。Vue里,是通过递归以及遍历data对象来实现对数据的监控的,如果属性值也是对象那么需要深度遍历,显然如果能劫持一个完整的对象,不管是对操作性还是性能都会有一个很大的提升。
    而要取代它的Proxy有以下两个优点:

    1. 可以劫持整个对象,并返回一个新对象
    2. 有13种劫持操作
    

2. 什么是Proxy

Proxy是 ES6 中新增的一个特性,翻译过来意思是"代理",用在这里表示由它来“代理”某些操作。 Proxy 让我们能够以简洁易懂的方式控制外部对对象的访问。其功能非常类似于设计模式中的代理模式。

Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。

使用 Proxy 的核心优点是可以交由它来处理一些非核心逻辑(如:读取或设置对象的某些属性前记录日志;设置对象的某些属性值前,需要验证;某些属性的访问控制等)。 从而可以让对象只需关注于核心逻辑,达到关注点分离,降低对象复杂度等目的。

基本用法:

let p = new Proxy(target, handler);

参数:

target: 是用Proxy包装的被代理对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。
handler: 是一个对象,其声明了代理target 的一些操作,其属性是当执行一个操作时定义代理的行为的函数。

p是Proxy对象,当其他操作对p进行更改的时候,会执行handler对象的方法。Proxy有13种数据劫持的操作,常用的handler处理方法:

get: 读取值,
set: 设置值,
has: 判断对象是否拥有该属性,
construct: 构造函数

给个例子:

let obj = {};
 let handler = {
   get(target, property) {
    console.log(`${property} 被读取`);
    return property in target ? target[property] : 3;
   },
   set(target, property, value) {
    console.log(`${property} 被设置为 ${value}`);
    target[property] = value;
   }
 }

 let p = new Proxy(obj, handler);
 p.name = 'tom' //name 被设置为 tom
 p.age; //age 被读取 3

更多的Proxy属性方法参考MDN Proxy

3. Proxy实现数据劫持

observe(data) {
  const that = this;
  let handler = {
   get(target, property) {
      return target[property];
    },
    set(target, key, value) {
      let res = Reflect.set(target, key, value);
      that.subscribe[key].map(item => {
        item.update();
      });
      return res;
    }
  }
  this.$data = new Proxy(data, handler);
}

这段代码里把代理器返回的对象代理到this.$data,即this.$data是代理后的对象,外部每次对this.$data进行操作时,实际上执行的是这段代码里handler对象上的方法。
注:这儿用到了reflect属性,这也是ES6里面的,不知道的去这儿看看吧。reflect属性

4. 对于怎么拼接到watcher和compile

上面说到了怎么使用Proxy做数据劫持,怎么结合订阅发布,请结合 vue2.0数据双向绑定探究 对照着Object.defineProperty
数据劫持的部分去替换看一下。其他的设计思想估计跟之前的八九不离十。

查看原文

前端森林 收藏了文章 · 2019-05-05

git代码统计

命令行

查看git上的个人代码量:

git log --author="username" --pretty=tformat: --numstat | awk '{ add += $1; subs += $2; loc += $1 - $2 } END { printf "added lines: %s, removed lines: %s, total lines: %s\n", add, subs, loc }' -

结果示例:(记得修改 username)

added lines: 120745, removed lines: 71738, total lines: 49007

统计每个人增删行数

git log --format='%aN' | sort -u | while read name; do echo -en "$name\t"; git log --author="$name" --pretty=tformat: --numstat | awk '{ add += $1; subs += $2; loc += $1 - $2 } END { printf "added lines: %s, removed lines: %s, total lines: %s\n", add, subs, loc }' -; done

结果示例

Max-laptop    added lines: 1192, removed lines: 748, total lines: 444
chengshuai    added lines: 120745, removed lines: 71738, total lines: 49007
cisen    added lines: 3248, removed lines: 1719, total lines: 1529
max-h    added lines: 1002, removed lines: 473, total lines: 529
max-l    added lines: 2440, removed lines: 617, total lines: 1823
mw    added lines: 148721, removed lines: 6709, total lines: 142012
spider    added lines: 2799, removed lines: 1053, total lines: 1746
thy    added lines: 34616, removed lines: 13368, total lines: 21248
wmao    added lines: 12, removed lines: 8, total lines: 4
xrl    added lines: 10292, removed lines: 6024, total lines: 4268
yunfei.huang    added lines: 427, removed lines: 10, total lines: 417
³Ÿö    added lines: 5, removed lines: 3, total lines: 2

查看仓库提交者排名前 5

git log --pretty='%aN' | sort | uniq -c | sort -k1 -n -r | head -n 5

贡献值统计

git log --pretty='%aN' | sort -u | wc -l

提交数统计

git log --oneline | wc -l

添加或修改的代码行数:

git log --stat|perl -ne 'END { print $c } $c += $1 if /(\d+) insertions/'

使用gitstats

GitStats项目,用Python开发的一个工具,通过封装Git命令来实现统计出来代码情况并且生成可浏览的网页。官方文档可以参考这里。

使用方法

git clone git://github.com/hoxu/gitstats.git
cd gitstats
./gitstats 你的项目的位置 生成统计的文件夹位置

可能会提示没有安装gnuplot画图程序,那么需要安装再执行:

//mac osx
brew install gnuplot
//centos linux
yum install gnuplot

生成的统计文件为HTML:
2014-8-16-git.jpg

使用cloc

npm install -g cloc

image

参考文章

git代码行统计命令集
统计本地Git仓库中不同贡献者的代码行数的一些方法
使用Git工具统计代码

查看原文

前端森林 收藏了文章 · 2019-04-11

Vue面试中,经常会被问到的面试题/Vue知识点整理

看看面试题,只是为了查漏补缺,看看自己那些方面还不懂。切记不要以为背了面试题,就万事大吉了,最好是理解背后的原理,这样面试的时候才能侃侃而谈。不然,稍微有水平的面试官一看就能看出,是否有真才实学还是刚好背中了这道面试题。
(都是一些基础的vue面试题,大神不用浪费时间往下看)

一、对于MVVM的理解?

MVVM 是 Model-View-ViewModel 的缩写。
Model代表数据模型,也可以在Model中定义数据修改和操作的业务逻辑。
View 代表UI 组件,它负责将数据模型转化成UI 展现出来。
ViewModel 监听模型数据的改变和控制视图行为、处理用户交互,简单理解就是一个同步View 和 Model的对象,连接Model和View。
在MVVM架构下,View 和 Model 之间并没有直接的联系,而是通过ViewModel进行交互,Model 和 ViewModel 之间的交互是双向的, 因此View 数据的变化会同步到Model中,而Model 数据的变化也会立即反应到View 上。
ViewModel 通过双向数据绑定把 View 层和 Model 层连接了起来,而View 和 Model 之间的同步工作完全是自动的,无需人为干涉,因此开发者只需关注业务逻辑,不需要手动操作DOM, 不需要关注数据状态的同步问题,复杂的数据状态维护完全由 MVVM 来统一管理。
bg2015020110.png

二、Vue的生命周期

beforeCreate(创建前) 在数据观测和初始化事件还未开始
created(创建后) 完成数据观测,属性和方法的运算,初始化事件,$el属性还没有显示出来
beforeMount(载入前) 在挂载开始之前被调用,相关的render函数首次被调用。实例已完成以下的配置:编译模板,把data里面的数据和模板生成html。注意此时还没有挂载html到页面上。
mounted(载入后) 在el 被新创建的 vm.$el 替换,并挂载到实例上去之后调用。实例已完成以下的配置:用上面编译好的html内容替换el属性指向的DOM对象。完成模板中的html渲染到html页面中。此过程中进行ajax交互。
beforeUpdate(更新前) 在数据更新之前调用,发生在虚拟DOM重新渲染和打补丁之前。可以在该钩子中进一步地更改状态,不会触发附加的重渲染过程。
updated(更新后) 在由于数据更改导致的虚拟DOM重新渲染和打补丁之后调用。调用时,组件DOM已经更新,所以可以执行依赖于DOM的操作。然而在大多数情况下,应该避免在此期间更改状态,因为这可能会导致更新无限循环。该钩子在服务器端渲染期间不被调用。
beforeDestroy(销毁前) 在实例销毁之前调用。实例仍然完全可用。
destroyed(销毁后) 在实例销毁之后调用。调用后,所有的事件监听器会被移除,所有的子实例也会被销毁。该钩子在服务器端渲染期间不被调用。
1.什么是vue生命周期?
答: Vue 实例从创建到销毁的过程,就是生命周期。从开始创建、初始化数据、编译模板、挂载Dom→渲染、更新→渲染、销毁等一系列过程,称之为 Vue 的生命周期。

2.vue生命周期的作用是什么?
答:它的生命周期中有多个事件钩子,让我们在控制整个Vue实例的过程时更容易形成好的逻辑。

3.vue生命周期总共有几个阶段?
答:它可以总共分为8个阶段:创建前/后, 载入前/后,更新前/后,销毁前/销毁后。

4.第一次页面加载会触发哪几个钩子?
答:会触发 下面这几个beforeCreate, created, beforeMount, mounted 。

5.DOM 渲染在 哪个周期中就已经完成?
答:DOM 渲染在 mounted 中就已经完成了。

三、 Vue实现数据双向绑定的原理:Object.defineProperty()

vue实现数据双向绑定主要是:采用数据劫持结合发布者-订阅者模式的方式,通过Object.defineProperty()来劫持各个属性的setter,getter,在数据变动时发布消息给订阅者,触发相应监听回调。当把一个普通 Javascript 对象传给 Vue 实例来作为它的 data 选项时,Vue 将遍历它的属性,用 Object.defineProperty 将它们转为 getter/setter。用户看不到 getter/setter,但是在内部它们让 Vue 追踪依赖,在属性被访问和修改时通知变化。

vue的数据双向绑定 将MVVM作为数据绑定的入口,整合Observer,Compile和Watcher三者,通过Observer来监听自己的model的数据变化,通过Compile来解析编译模板指令(vue中是用来解析 {{}}),最终利用watcher搭起observer和Compile之间的通信桥梁,达到数据变化 —>视图更新;视图交互变化(input)—>数据model变更双向绑定效果。

js实现简单的双向绑定

<body>
    <div id="app">
    <input type="text" id="txt">
    <p id="show"></p>
</div>
</body>
<script type="text/javascript">
    var obj = {}
    Object.defineProperty(obj, 'txt', {
        get: function () {
            return obj
        },
        set: function (newValue) {
            document.getElementById('txt').value = newValue
            document.getElementById('show').innerHTML = newValue
        }
    })
    document.addEventListener('keyup', function (e) {
        obj.txt = e.target.value
    })
</script>

四、Vue组件间的参数传递

1.父组件与子组件传值
父组件传给子组件:子组件通过props方法接受数据;
子组件传给父组件:$emit方法传递参数
2.非父子组件间的数据传递,兄弟组件传值
eventBus,就是创建一个事件中心,相当于中转站,可以用它来传递事件和接收事件。项目比较小时,用这个比较合适。(虽然也有不少人推荐直接用VUEX,具体来说看需求咯。技术只是手段,目的达到才是王道。)

五、Vue的路由实现:hash模式 和 history模式

hash模式:在浏览器中符号“#”,#以及#后面的字符称之为hash,用window.location.hash读取;
特点:hash虽然在URL中,但不被包括在HTTP请求中;用来指导浏览器动作,对服务端安全无用,hash不会重加载页面。
hash 模式下,仅 hash 符号之前的内容会被包含在请求中,如 http://www.xxx.com,因此对于后端来说,即使没有做到对路由的全覆盖,也不会返回 404 错误。

history模式:history采用HTML5的新特性;且提供了两个新方法:pushState(),replaceState()可以对浏览器历史记录栈进行修改,以及popState事件的监听到状态变更。
history 模式下,前端的 URL 必须和实际向后端发起请求的 URL 一致,如 http://www.xxx.com/items/id。后端如果缺少对 /items/id 的路由处理,将返回 404 错误。Vue-Router 官网里如此描述:“不过这种模式要玩好,还需要后台配置支持……所以呢,你要在服务端增加一个覆盖所有情况的候选资源:如果 URL 匹配不到任何静态资源,则应该返回同一个 index.html 页面,这个页面就是你 app 依赖的页面。”

六、Vue与Angular以及React的区别?

(版本在不断更新,以下的区别有可能不是很正确。我工作中只用到vue,对angular和react不怎么熟)
1.与AngularJS的区别
相同点:
都支持指令:内置指令和自定义指令;都支持过滤器:内置过滤器和自定义过滤器;都支持双向数据绑定;都不支持低端浏览器。

不同点:
AngularJS的学习成本高,比如增加了Dependency Injection特性,而Vue.js本身提供的API都比较简单、直观;在性能上,AngularJS依赖对数据做脏检查,所以Watcher越多越慢;Vue.js使用基于依赖追踪的观察并且使用异步队列更新,所有的数据都是独立触发的。

2.与React的区别
相同点:
React采用特殊的JSX语法,Vue.js在组件开发中也推崇编写.vue特殊文件格式,对文件内容都有一些约定,两者都需要编译后使用;中心思想相同:一切都是组件,组件实例之间可以嵌套;都提供合理的钩子函数,可以让开发者定制化地去处理需求;都不内置列数AJAX,Route等功能到核心包,而是以插件的方式加载;在组件开发中都支持mixins的特性。
不同点:
React采用的Virtual DOM会对渲染出来的结果做脏检查;Vue.js在模板中提供了指令,过滤器等,可以非常方便,快捷地操作Virtual DOM。

七、vue路由的钩子函数

首页可以控制导航跳转,beforeEach,afterEach等,一般用于页面title的修改。一些需要登录才能调整页面的重定向功能。

beforeEach主要有3个参数to,from,next:

to:route即将进入的目标路由对象,

from:route当前导航正要离开的路由

next:function一定要调用该方法resolve这个钩子。执行效果依赖next方法的调用参数。可以控制网页的跳转。

八、vuex是什么?怎么使用?哪种功能场景使用它?

只用来读取的状态集中放在store中; 改变状态的方式是提交mutations,这是个同步的事物; 异步逻辑应该封装在action中。
在main.js引入store,注入。新建了一个目录store,….. export 。
场景有:单页应用中,组件之间的状态、音乐播放、登录状态、加入购物车
图片描述

state
Vuex 使用单一状态树,即每个应用将仅仅包含一个store 实例,但单一状态树和模块化并不冲突。存放的数据状态,不可以直接修改里面的数据。
mutations
mutations定义的方法动态修改Vuex 的 store 中的状态或数据。
getters
类似vue的计算属性,主要用来过滤一些数据。
action
actions可以理解为通过将mutations里面处里数据的方法变成可异步的处理数据的方法,简单的说就是异步操作数据。view 层通过 store.dispath 来分发 action。

const store = new Vuex.Store({ //store实例
      state: {
         count: 0
             },
      mutations: {                
         increment (state) {
          state.count++
         }
          },
      actions: { 
         increment (context) {
          context.commit('increment')
   }
 }
})

modules
项目特别复杂的时候,可以让每一个模块拥有自己的state、mutation、action、getters,使得结构非常清晰,方便管理。

const moduleA = {
  state: { ... },
  mutations: { ... },
  actions: { ... },
  getters: { ... }
 }
const moduleB = {
  state: { ... },
  mutations: { ... },
  actions: { ... }
 }

const store = new Vuex.Store({
  modules: {
    a: moduleA,
    b: moduleB
})

九、vue-cli如何新增自定义指令?

1.创建局部指令

var app = new Vue({
    el: '#app',
    data: {    
    },
    // 创建指令(可以多个)
    directives: {
        // 指令名称
        dir1: {
            inserted(el) {
                // 指令中第一个参数是当前使用指令的DOM
                console.log(el);
                console.log(arguments);
                // 对DOM进行操作
                el.style.width = '200px';
                el.style.height = '200px';
                el.style.background = '#000';
            }
        }
    }
})

2.全局指令

Vue.directive('dir2', {
    inserted(el) {
        console.log(el);
    }
})

3.指令的使用

<div id="app">
    <div v-dir1></div>
    <div v-dir2></div>
</div>

十、vue如何自定义一个过滤器?

html代码:

<div id="app">
     <input type="text" v-model="msg" />
     {{msg| capitalize }}
</div>

JS代码:

var vm=new Vue({
    el:"#app",
    data:{
        msg:''
    },
    filters: {
      capitalize: function (value) {
        if (!value) return ''
        value = value.toString()
        return value.charAt(0).toUpperCase() + value.slice(1)
      }
    }
})

全局定义过滤器

Vue.filter('capitalize', function (value) {
  if (!value) return ''
  value = value.toString()
  return value.charAt(0).toUpperCase() + value.slice(1)
})

过滤器接收表达式的值 (msg) 作为第一个参数。capitalize 过滤器将会收到 msg的值作为第一个参数。

十一、对keep-alive 的了解?

keep-alive是 Vue 内置的一个组件,可以使被包含的组件保留状态,或避免重新渲染。
在vue 2.1.0 版本之后,keep-alive新加入了两个属性: include(包含的组件缓存) 与 exclude(排除的组件不缓存,优先级大于include) 。

使用方法

<keep-alive include='include_components' exclude='exclude_components'>
  <component>
    <!-- 该组件是否缓存取决于include和exclude属性 -->
  </component>
</keep-alive>

参数解释
include - 字符串或正则表达式,只有名称匹配的组件会被缓存
exclude - 字符串或正则表达式,任何名称匹配的组件都不会被缓存
include 和 exclude 的属性允许组件有条件地缓存。二者都可以用“,”分隔字符串、正则表达式、数组。当使用正则或者是数组时,要记得使用v-bind 。

使用示例

<!-- 逗号分隔字符串,只有组件a与b被缓存。 -->
<keep-alive include="a,b">
  <component></component>
</keep-alive>

<!-- 正则表达式 (需要使用 v-bind,符合匹配规则的都会被缓存) -->
<keep-alive :include="/a|b/">
  <component></component>
</keep-alive>

<!-- Array (需要使用 v-bind,被包含的都会被缓存) -->
<keep-alive :include="['a', 'b']">
  <component></component>
</keep-alive>

十二、一句话就能回答的面试题

1.css只在当前组件起作用
答:在style标签中写入scoped即可 例如:<style scoped></style>

2.v-if 和 v-show 区别
答:v-if按照条件是否渲染,v-show是display的block或none;

3.$route$router的区别
答:$route是“路由信息对象”,包括path,params,hash,query,fullPath,matched,name等路由信息参数。而$router是“路由实例”对象包括了路由的跳转方法,钩子函数等。

4.vue.js的两个核心是什么?
答:数据驱动、组件系统

5.vue几种常用的指令
答:v-for 、 v-if 、v-bind、v-on、v-show、v-else

6.vue常用的修饰符?
答:.prevent: 提交事件不再重载页面;.stop: 阻止单击事件冒泡;.self: 当事件发生在该元素本身而不是子元素的时候会触发;.capture: 事件侦听,事件发生的时候会调用

7.v-on 可以绑定多个方法吗?
答:可以

8.vue中 key 值的作用?
答:当 Vue.js 用 v-for 正在更新已渲染过的元素列表时,它默认用“就地复用”策略。如果数据项的顺序被改变,Vue 将不会移动 DOM 元素来匹配数据项的顺序, 而是简单复用此处每个元素,并且确保它在特定索引下显示已被渲染过的每个元素。key的作用主要是为了高效的更新虚拟DOM。

9.什么是vue的计算属性?
答:在模板中放入太多的逻辑会让模板过重且难以维护,在需要对数据进行复杂处理,且可能多次使用的情况下,尽量采取计算属性的方式。好处:①使得数据处理结构清晰;②依赖于数据,数据更新,处理结果自动更新;③计算属性内部this指向vm实例;④在template调用时,直接写计算属性名即可;⑤常用的是getter方法,获取数据,也可以使用set方法改变数据;⑥相较于methods,不管依赖的数据变不变,methods都会重新计算,但是依赖数据不变的时候computed从缓存中获取,不会重新计算。

10.vue等单页面应用及其优缺点
答:优点:Vue 的目标是通过尽可能简单的 API 实现响应的数据绑定和组合的视图组件,核心是一个响应的数据绑定系统。MVVM、数据驱动、组件化、轻量、简洁、高效、快速、模块友好。
缺点:不支持低版本的浏览器,最低只支持到IE9;不利于SEO的优化(如果要支持SEO,建议通过服务端来进行渲染组件);第一次加载首页耗时相对长一些;不可以使用浏览器的导航按钮需要自行实现前进、后退。

11.怎么定义 vue-router 的动态路由? 怎么获取传过来的值
答:在 router 目录下的 index.js 文件中,对 path 属性加上 /:id,使用 router 对象的 params.id 获取。

Vue面试中,经常会被问到的面试题/Vue知识点整理
532道前端真实大厂面试题
学习ES6笔记──工作中常用到的ES6语法

查看原文

前端森林 收藏了文章 · 2019-04-09

React 的未来:Time Slicing 和 Suspense

本文转自 FEPulse 公众号(微信搜索 FEPulse,每日精选一条国内外最新前端资讯,为你把握前端脉搏)。

JSConf Iceland 大会于 3.1 - 3.2 在冰岛举行,在会中,React 核心团队的 Dan Abramov 发表了名为 “Beyond React 16” 的演讲,描述了对 React 未来的展望,本文根据 Dan 的演讲整理。

React 作为一个通用的框架,需要考虑各种网络状况(网速有快有慢)以及各种设备类型(CPU 性能有好有坏),而框架开发者的一个重要使命就是帮助开发者们开发在各种情况下用户体验都很好的应用程序。

影响用户体验的因素主要可以归为两大类:计算能力(Computing Power)和网络速度(Network Speed),他们对应计算设备的 CPU 和 IO 能力。在 React 中,CPU 主要影响 DOM 元素创建和更新的效率,而 IO 则影响获取数据和懒加载的代码。下面 Dan 用了两个 Demo 来展示 React 在这两个方面的尝试。

Demo 1

图片描述

第一个例子,由一个输入框和下面的三个图表组成,当输入的内容越复杂,下面的图表也越来越复杂。

为了更直观地看到页面刷新的效率,Dan 还写了一个时钟,绿色表示页面刷新时帧与帧之间间隔的时间很短,而颜色越深则表示间隔时间越长,页面卡顿感越强,用户体验自然也越差。
图片描述

这种场景中,提升用户体验的一个经典的做法是 Debounce(Throttle 类似),即等用户暂停输入后再刷新页面,对应的 Demo 如下:
图片描述

但这么做用户体验上也是有缺陷的,如果用户的 CPU 很强劲,那也不得不等暂停输入后才看到结果。那有没有更好的解决方案呢?有的,Dan 给了一种异步刷新的方案,先看图:
图片描述

引用 Dan PPT 中的一句话,用来体现这种异步方案的精髓。

We've built a generic way to ensure that high-priority updates like user input don't get blocked by rendering low-priority updates.

Dan 称这种特性为 “Time Slicing”,主要包括以下几点:
图片描述

Demo 2
图片描述

第二个 Demo 是一个电影信息展示的应用,选择一个电影,点击进入可查看电影的详情和评论。在后面的演示中,Dan 通过一步步修改这个 Demo 的代码来让我们理解 React 在网络方面如何提升用户体验的。

首先,Dan 将数据从硬编码改成了从网络获取。代码如下:
图片描述

在上面这段代码中,当 React 渲染 MovieDetails 组件时,会先看对应电影 ID 的详情有没有被缓存,因为是第一次,电影详情信息还没被缓存,所以需要去网络拉取,下面高能!React 会阻止渲染过程,直到数据被拉取回来!而我们需要做的,仅仅告诉 React,从电影列表页到电影详情页的过程是个异步过程即可。代码如下:
图片描述

这样修改过后,下面的动图展示了这种异步过程:点击一个电影,React 去网络拉取对应电影的详情数据,等数据拉取回来后,React 再将页面渲染出来。
图片描述

同时,页面还保持良好的可交互性,点击一个电影之后紧接着用户也可以点击其他的电影。

上面的修改仅仅模拟了 1s 的网络延迟,如果延迟更长咋办,用户点击了一个电影,发现页面像卡死了一般,用户体验肯定很差。Dan 紧接着又对代码做了修改:使用一个叫 “Placeholder” 的组件包裹即将要渲染的异步组件,当组件在加载的过程中,Placehodler 会显示一个“安慰剂”。
图片描述

同样的,这个过程界面仍然保持着良好的交互性,当页面在转圈时,用户可以选择返回。
图片描述

上面的修改中,只有一个电影详情数据需要从网络获取,如果有多个数据需要从网络获取,那么 React 可以选择等所有数据都返回后显示最终的页面,也可以选择优先展示先获取到的数据。
图片描述

除了在详情页中展示安慰剂,也可以在电影列表处展示,这需要通过一个叫 Loading 的组件来实现,Loading 与 Placehodler 一样,是一个未来的特性。
图片描述

除此以外,还可以使用 Code Splitting 来优化用户体验。但点击一个电影时,再去拉取跟电影详情页相关的代码。这里还是使用未来提供的 createFetcher 接口来实现 Code Splitting。
图片描述

运行代码,点击一个电影,会发现 Network 中新拉下来一个叫 “1.chunk.js” 的文件。
图片描述

最后,Dan 还做了一个用户体验方面的优化,目前为止的 Demo 中,打开页面时,电影海报可能没加载出来(从上面一张动图中也可以看出来),所以 Dan 做了优化,需要等图片加载完成后,页面才能显示。这里的细节不再详述,当然这也是用 createFetcher 实现的。

到这里,Demo 2 就结束了。Dan 用一句话概括了这个新特性:

We've built a generic way to suspend rendering while they load asynchronous data.

Dan 称这个新特性为 “Suspense”,主要包括以下几点:
图片描述

这两项新的特性据说将在今年发布,是不是很期待呢?你对 Timing Slicing 和 Suspense 又有什么看法呢?欢迎留言。

最后,再次邀请大家关注我们的公众号 FEPulse,第一时间获取我们精心整理的最新的前端资讯。

查看原文