蒋鹏飞

蒋鹏飞 查看完整档案

成都编辑四川大学  |  计算机科学与技术 编辑  |  填写所在公司/组织 github.com/dennis-jiang/Front-End-Knowledges 编辑
编辑

前端工程师,底层技术人。
思否2020年度“Top Writer”!
掘金“优秀作者”!
开源中国2020年度“优秀源创作者”!
分享各种大前端进阶知识!
关注公众号【进击的大前端】第一时间获取高质量原创。
更多文章和示例源码请看:https://github.com/dennis-jia...

个人动态

蒋鹏飞 赞了文章 · 1月22日

这次,十分钟把宏任务和微任务讲清楚

为什么写这个文章

  • 这是一道大厂、小厂面试官都喜欢问的题目
  • 很多面试官和面试者也不知道什么是标准答案
  • 网上各种文章层次不齐..误导过不少人,包括我
  • 觉得还是今天花十分钟讲清楚他吧

正式开始

  • 先上代码
    function app() {
      setTimeout(() => {
        console.log("1-1");
        Promise.resolve().then(() => {
          console.log("2-1");
        });
      });
      console.log("1-2");
      Promise.resolve().then(() => {
        console.log("1-3");
        setTimeout(() => {
          console.log("3-1");
        });
      });
    }
    app();
  • 输出结果:
1-2
1-3
1-1
2-1
3-1

开始分析

  • 面试官特别喜欢问:你讲讲什么是微任务和宏任务
大部分面试官其实自己也不懂什么是微任务和宏任务,不信下次你们反问一下

所谓微任务和宏任务

  • 宏任务:常见的定时器,用户交互事件等等.(宏任务就是特定的这些个任务,没什么特殊含义)
  • 微任务:Promise相关任务,MutationObserver等(一样,只是一种称呼而已!!!

到底先执行微任务还是宏任务

  • 先有鸡还是先有蛋? 到底是先有宏任务还是微任务啊?

第一个原则

  • 万物皆从全局上下文准备退出,全局的同步代码运行结束的这个时机开始
  • 例如我们刚才这段代码:
   function app() {
      setTimeout(() => {
        console.log("1-1");
        Promise.resolve().then(() => {
          console.log("2-1");
        });
      });
      console.log("1-2");
      Promise.resolve().then(() => {
        console.log("1-3");
        setTimeout(() => {
          console.log("3-1");
        });
      });
    }
    app();
  • 当执行完了console.log("1-2");的时候,意味着全局的上下文马上要退出了,因为此时全局的同步代码都执行完了,剩下的都是异步代码

第二个原则

  • 同一层级下(不理解层级,可以先不管,后面会讲),微任务永远比宏任务先执行
  • 即Promise.then比setTimeout先执行
  • 所以先打印1-3,再打印1-1

第三个原则

  • 每个宏任务,都单独关联了一个微任务队列
  • 我用刚买的黑板画了一张图,大家就知道什么是层级了

  • 每个层级的宏任务,都对应了他们的微任务队列,微任务队列遵循先进先出的原则,当全局同步代码执行完毕后,就开始执行第一层的任务。同层级的微任务永远先于宏任务执行,并且会在当前层级宏任务结束前全部执行完毕

怎么分辨层级?

  • 属于同一个维度的代码,例如下面的func1和func2就属于同层级任务
setTimeout(func1)...
Promise.resolve().then(func2)...
  • 下面这种fn1和fn2就不属于同一个层级的,因为fn2属于内部这个setTimeout的微任务队列,而fn1属于外部setTimeout的微任务队列
setTimeout(()=>{
Promise.resolve().then(fn1)
setTimeout(()=>{
Promise.resolve().then(fn2)  
})})
划重点:每个宏任务对应一个单独的微任务队列

遇到面试题

  • 就按照我的套路,从全局上下文退出前(全局的同步代码执行完毕后),开始收集当前层级的微任务和宏任务,然后先清空微任务队列,再执行宏任务.如果这期间遇到宏任务/微任务,就像我这样画个图,把他们塞进对应的层级里即可

写在最后

  • 简单的1000字,相信能彻底解决你的微任务和宏任务疑惑
  • 如果你想理解得更深,记得关注下公众号,后续会写一些更深入的东西,真正的“深入浅出”
查看原文

赞 15 收藏 7 评论 2

蒋鹏飞 发布了文章 · 1月19日

前端也能学算法:由浅入深讲解贪心算法

贪心算法是一种很常见的算法思想,而且很好理解,因为它符合人们一般的思维习惯。下面我们由浅入深的来讲讲贪心算法。

找零问题

我们先来看一个比较简单的问题:

假设你是一个商店老板,你需要给顾客找零n元钱,你手上有的钱的面值为:100元,50元,20元,5元,1元。请问如何找零使得所需要的钱币数量最少?

例子:你需要找零126元,则所需钱币数量最少的方案为100元1找,20元1张,5元1张,1元1张。

这个问题在生活中很常见,买东西的时候经常会遇到,那我们一般是怎么思考的呢?假设我们需要找零126元,我们先看看能找的最大面值是多少,我们发现126比100大,那肯定可以找一张100块,然后剩下26元,再看26能匹配的最大面值是多少,发现是20,那找一张20的,还剩6块,同样的思路,找一张5块的和1块的。这其实就是贪心算法的思想,每次都很贪心的去找最大的匹配那个值,然后再找次大的。这个算法代码也很好写:

const allMoney = [100, 50, 20, 5, 1];  // 表示我们手上有的面值
function changeMoney(n, allMoney) {
  const length = allMoney.length;
  const result = [];    // 存储结果的数组,每项表示对应面值的张数
  for(let i = 0; i < length; i++) {
    if(n >= allMoney[i]) {
      // 如果需要找的钱比面值大,那就可以找,除一下看看能找几张
      result[i] = parseInt(n / allMoney[i]);
      n = n - result[i] * allMoney[i];   // 更新剩下需要找的钱
    } else {
      // 否则不能找
      result[i] = 0;
    }
  }
  
  return result;
}

const result = changeMoney(126, allMoney);
console.log(result);   // [1, 0, 1, 1, 1]

贪心算法

上面的找零问题就是贪心算法,每次都去贪最大面值的,发现贪不了了,再去贪次大的。从概念上讲,贪心算法是:

image-20200220105715893

从上面的定义可以看出,并不是所有问题都可以用贪心算法来求解的,因为它每次拿到的只是局部最优解,局部最优解组合起来并不一定是全局最优解。下面我们来看一个这样的例子:

背包问题

背包问题也是一个很经典的算法问题,题目如下:

有一个小偷,他进到了一个店里要偷东西,店里有很多东西,每个东西的价值是v,每个东西的重量是w。但是小偷只有一个背包,他背包总共能承受的重量是W。请问怎么拿东西能让他拿到的价值最大?

其实背包问题细分下来又可以分成两个问题:0-1背包和分数背包。

0-1背包:指的是对于某个商品来说,你要么不拿,要么全拿走,不能只拿一半或者只拿三分之二。可以将商品理解成金砖,你要么整块拿走,要么不拿,不能拿半块。

分数背包:分数背包就是跟0-1背包相反的,你可以只拿一部分,可以拿一半,也可以拿三分之二。可以将商品理解成金砂,可以只拿一部分。

下面来看个例子:

image-20200220110835213

这个问题用我们平时的思维也很好想,要拿到总价值最大,那我们就贪呗,就拿最贵的,即价值除以重量的数最大的。但是每次都拿最贵的,是不是最后总价值最大呢?我们先假设上面的例子是0-1背包,最贵的是v1,然后是v2,v3。我们先拿v1, 背包还剩40,拿到总价值是60,然后拿v2,背包还剩20,拿到总价值是160。然后就拿不下了,因为v3的重量是30,我们背包只剩20了,装不下了。但是这个显然不是全局最优解,因为我们明显可以看出,如果我们拿v2,v3,背包刚好装满,总价值是220,这才是最优解。所以0-1背包问题不能用贪心算法。

但是分数背包可以用贪心,因为我们总是可以拿最贵的。我们先拿了v1, v2,发现v3装不下了,那就不装完了嘛,装三分之二就行了。下面我们用贪心来实现一个分数背包:

const products = [
  {id:1, v: 60, w: 10}, 
  {id:2, v: 100, w: 20}, 
  {id:3, v: 120, w: 30}
];    // 新建一个数组表示商品列表,每个商品加个id用于标识

function backpack(W, products) {
  const sortedProducts = products.sort((product1, product2) => {
    const price1 = product1.v / product1.w;
    const price2 = product2.v / product2.w;
    if(price1 > price2) {
      return -1;
    } else if(price1 < price2) {
      return 1;
    }
    
    return 0;
  });  // 先对商品按照价值从大到小排序
  
  const result = []; // 新建数组接收结果
  let allValue = 0;  // 拿到的总价值
  const length = sortedProducts.length;
  
  for(let i = 0; i < length; i++) {
    const sortedProduct = sortedProducts[i];
    if(W >= sortedProduct.w) {
      // 整个拿完
      result.push({
        id: sortedProduct.id,
        take: 1,     // 拿的数量
      });
      W = W - sortedProduct.w;
      allValue = allValue + sortedProduct.v;
    } else if(W > 0) {
      // 只能拿一部分
      result.push({
        id: sortedProduct.id,
        take: W / sortedProduct.w,     
      });
      allValue = allValue + sortedProduct.v * (W / sortedProduct.w);
      W = 0; // 装满了
    } else {
      // 不能拿了
      result.push({
        id: sortedProduct.id,
        take: 0,     
      });
    }
  }
  
  return {result: result, allValue: allValue};
}

// 测试一下
const result = backpack(50, products);
console.log(result);

运行结果:

image-20200220113537290

0-1背包

前面讲过0-1背包不能用贪心求解,我们这里还是讲讲他怎么来求解吧。要解这个问题需要用到动态规划的思想,关于动态规划的思想,可以看看我这篇文章,如果你只想看看贪心算法,可以跳过这一部分。假设我们背包放了n个商品,W是我们背包的总容量,我们这时拥有的总价值是$D(n, W)$。我们考虑最后一步,

假如我们不放最后一个商品,则总价值为$D(n-1, W)$

假设我们放了最后一个商品,则总价值为最后一个商品加上前面已经放了的价值,表示为$v_n + D(n-1, W-w_n)$,这时候需要满足的条件是$ W >= w_n$,即最后一个要放得下。

我们要求的最大解其实就是上述两个方案的最大值,表示如下:

$$ D(n, W) = max(D(n-1, W), v_n + D(n-1, W-w_n)) $$

递归解法

有了递推公式,我们就可以用递归解法了:

const products = [
  {id:1, v: 60, w: 10}, 
  {id:2, v: 100, w: 20}, 
    {id:3, v: 120, w: 30}
];    // 新建一个数组表示商品列表,每个商品加个id用于标识

function backpack01(n, W, products) {
  if(n < 0 || W <= 0) {
    return 0;
  }
  
  const noLast = backpack01(n-1, W, products);  // 不放最后一个
  
  let getLast = 0;
  if(W >= products[n].w){  // 如果最后一个放得下
    getLast = products[n].v + backpack01(n-1, W-products[n].w, products);
  }
  
  const result = Math.max(noLast, getLast);
  
  return result;
}

// 测试一下
const result = backpack01(products.length-1, 50, products);
console.log(result);   // 220

动态规划

递归的复杂度很高,我们用动态规划重写一下:

const products = [
  {id:1, v: 60, w: 10}, 
  {id:2, v: 100, w: 20}, 
    {id:3, v: 120, w: 30}
];    // 新建一个数组表示商品列表,每个商品加个id用于标识

function backpack01(W, products) {
  const d = [];      // 初始化一个数组放计算中间值,其实为二维数组,后面填充里面的数组
  const length = products.length;
  
  // i表示行,为商品个数,数字为 0 -- (length - 1)
  // j表示列,为背包容量,数字为 0 -- W
  for(let i = 0; i < length; i++){
    d.push([]);
    for(let j = 0; j <= W; j++) {
      if(j === 0) {
        // 背包容量为0
        d[i][j] = 0;
      } else if(i === 0) {
        if(j >= products[i].w) {
          // 可以放下第一个商品
          d[i][j] = products[i].v;
        } else {
          d[i][j] = 0;
        }
      } else {
        const noLast = d[i-1][j];
        
        let getLast = 0;
        if(j >= products[i].w) {
          getLast = products[i].v + d[i-1][j - products[i].w];
        }
        
        if(noLast > getLast) {
          d[i][j] = noLast;
        } else {
          d[i][j] = getLast;
        }
      }
    }
  }
  
  console.log(d);
  return d[length-1][W];
}

// 测试一下
const result = backpack01(50, products);
console.log(result);   // 220

回溯最优解

为了能够输出最优解,我们需要将每个最后放入的商品记录下来,然后从最后往前回溯,将前面的代码改造如下:

const products = [
  {id:1, v: 60, w: 10}, 
  {id:2, v: 100, w: 20}, 
    {id:3, v: 120, w: 30}
];    // 新建一个数组表示商品列表,每个商品加个id用于标识

function backpack01(W, products) {
  const d = [];      // 初始化一个数组放计算中间值,其实为二维数组,后面填充里面的数组
  const res = [];    // 记录每次放入的最后一个商品, 同样为二维数组
  const length = products.length;
  
  // i表示行,为商品个数,数字为 0 -- (length - 1)
  // j表示列,为背包容量,数字为 0 -- W
  for(let i = 0; i < length; i++){
    d.push([]);
    res.push([]);
    for(let j = 0; j <= W; j++) {
      if(j === 0) {
        // 背包容量为0
        d[i][j] = 0;
        res[i][j] = null;  
      } else if(i === 0) {
        if(j >= products[i].w) {
          // 可以放下第一个商品
          d[i][j] = products[i].v;
          res[i][j] = products[i];
        } else {
          d[i][j] = 0;
          res[i][j] = null;
        }
      } else {
        const noLast = d[i-1][j];
        
        let getLast = 0;
        if(j >= products[i].w) {
          getLast = products[i].v + d[i-1][j - products[i].w];
        }
        
        if(noLast > getLast) {
          d[i][j] = noLast;
        } else {
          d[i][j] = getLast;
          res[i][j] = products[i];   // 记录最后一个商品
        }
      }
    }
  }
  
  // 回溯res, 得到最优解
  let tempW = W;
  let tempI = length - 1;
  const bestSol = [];
  while (tempW > 0 && tempI >= 0) {
    const last = res[tempI][tempW];
    bestSol.push(last);
    tempW = tempW - last.w;
    tempI = tempI - 1;
  }
  
  console.log(d);
  console.log(bestSol);
  return {
    totalValue: d[length-1][W],
    solution: bestSol
  }
}

// 测试一下
const result = backpack01(50, products);
console.log(result);   // 220

上面代码的输出:

image-20200220144941561

数字拼接问题

再来看一个贪心算法的问题,加深下理解,这个问题如下:

image-20200220153438242

这个问题看起来也不难,我们有时候也会遇到类似的问题,我们可以很直观的想到一个解法:看哪个数字的第一个数字大,把他排前面,比如32和94,把第一位是9的94放前面,得到9432,肯定比32放前面的3294大。这其实就是按照字符串大小来排序嘛,字符大的排前面,但是这种解法正确吗?我们再来看两个数字,假如我们有728和7286,按照字符序,7286排前面,得到7286728,但是这个值没有728放前面的7287286大。说明单纯的字符序是搞不定这个的,对于两个数字a,b,如果他们的长度一样,那按照字符序就没问题,如果他们长度不一样,这个解法就不一定对了,那怎么办呢?其实也简单,我们看看a+b和b+a拼成的数字,哪个大就行了。

假设
a = 728
b = 7286
字符串: a + b = "7287286"
字符串: b + a = "7286728"
比较下这两个字符串, a + b比较大,a放前面就行了, 反之放到后面

上述算法就是一个贪心,这里贪的是什么的?贪的是a + b的值,要大的那个。在实现的时候,可以自己写个冒泡,也可以直接用数组的sort方法:

const nums = [32, 94, 128, 1286, 6, 71];

function getBigNum(nums) {
  nums.sort((a, b) => {
    const ab = `${a}${b}`;
    const ba = `${b}${a}`;
    
    if(ab > ba) {
      return -1;   // ab大,a放前面
    } else if (ab < ba) {
      return 1;  
    }
    
    return 0;
  });
  
  return nums;
}

const res = getBigNum(nums);
console.log(res);    // [94, 71, 6, 32, 1286, 128]

活动选择问题

活动选择问题稍微难一点,也可以用贪心,但是需要贪的东西没前面的题目那么直观,我们先来看看题目:

image-20200220155950342

这个问题应该这么思考:为了能尽量多的安排活动,我们在安排一个活动时,应该尽量给后面的活动多留时间,这样后面有机会可以安排更多的活动。换句话说就是,应该把结束时间最早的活动安排在第一个,再剩下的时间里面继续安排结束时间早的活动。这里的贪心其实贪的就是结束时间早的,这个结论其实可以用数学来证明的:

image-20200220161538654

下面来实现下代码:

const activities = [
  {start: 1, end: 4},
  {start: 3, end: 5},
  {start: 0, end: 6},
  {start: 5, end: 7},
  {start: 3, end: 9},
  {start: 5, end: 9},
  {start: 6, end: 10},
  {start: 8, end: 11},
  {start: 8, end: 12},
  {start: 2, end: 14},
  {start: 12, end: 16},
];

function chooseActivity(activities) {
  // 先按照结束时间从小到大排序
  activities.sort((act1, act2) => {
    if(act1.end < act2.end) {
      return -1;
    } else if(act1.end > act2.end) {
      return 1;
    }
    
    return 0;
  });
  
  const res = [];  // 接收结果的数组
  let lastEnd = 0; // 记录最后一个活动的结束时间
  
  for(let i = 0; i < activities.length; i++){
    const act = activities[i];
    if(act.start >= lastEnd) {
      res.push(act);
      lastEnd = act.end
    }
  }
  
  return res;
}

// 测试一下
const result = chooseActivity(activities);
console.log(result);

上面代码的运行结果如下:

image-20200220163750591

总结

贪心算法的重点就在一个贪字,要找到贪的对象,然后不断的贪,最后把目标贪完,输出最优解。要注意的是,每次贪的时候其实拿到的都只是局部最优解,局部最优解不一定组成全局最优解,比如0-1背包,对于这种问题是不能用贪心的,要用其他方法求解。

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

欢迎关注我的公众号进击的大前端第一时间获取高质量原创~

“前端进阶知识”系列文章源码地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二维码_2.png

查看原文

赞 14 收藏 9 评论 5

蒋鹏飞 收藏了文章 · 1月18日

可视化拖拽组件库一些技术要点原理分析(二)

本文是对《可视化拖拽组件库一些技术要点原理分析》的补充。上一篇文章主要讲解了以下几个功能点:

  1. 编辑器
  2. 自定义组件
  3. 拖拽
  4. 删除组件、调整图层层级
  5. 放大缩小
  6. 撤消、重做
  7. 组件属性设置
  8. 吸附
  9. 预览、保存代码
  10. 绑定事件
  11. 绑定动画
  12. 导入 PSD
  13. 手机模式

现在这篇文章会在此基础上再补充 4 个功能点,分别是:

  • 拖拽旋转
  • 复制粘贴剪切
  • 数据交互
  • 发布

和上篇文章一样,我已经将新功能的代码更新到了 github:

友善提醒:建议结合源码一起阅读,效果更好(这个 DEMO 使用的是 Vue 技术栈)。

14. 拖拽旋转

在写上一篇文章时,原来的 DEMO 已经可以支持旋转功能了。但是这个旋转功能还有很多不完善的地方:

  1. 不支持拖拽旋转。
  2. 旋转后的放大缩小不正确。
  3. 旋转后的自动吸附不正确。
  4. 旋转后八个可伸缩点的光标不正确。

这一小节,我们将逐一解决这四个问题。

拖拽旋转

拖拽旋转需要使用 Math.atan2() 函数。

Math.atan2() 返回从原点(0,0)到(x,y)点的线段与x轴正方向之间的平面角度(弧度值),也就是Math.atan2(y,x)。Math.atan2(y,x)中的y和x都是相对于圆点(0,0)的距离。

简单的说就是以组件中心点为原点 (centerX,centerY),用户按下鼠标时的坐标设为 (startX,startY),鼠标移动时的坐标设为 (curX,curY)。旋转角度可以通过 (startX,startY)(curX,curY) 计算得出。

那我们如何得到从点 (startX,startY) 到点 (curX,curY) 之间的旋转角度呢?

第一步,鼠标点击时的坐标设为 (startX,startY)

const startY = e.clientY
const startX = e.clientX

第二步,算出组件中心点:

// 获取组件中心点位置
const rect = this.$el.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2

第三步,按住鼠标移动时的坐标设为 (curX,curY)

const curX = moveEvent.clientX
const curY = moveEvent.clientY

第四步,分别算出 (startX,startY)(curX,curY) 对应的角度,再将它们相减得出旋转的角度。另外,还需要注意的就是 Math.atan2() 方法的返回值是一个弧度,因此还需要将弧度转化为角度。所以完整的代码为:

// 旋转前的角度
const rotateDegreeBefore = Math.atan2(startY - centerY, startX - centerX) / (Math.PI / 180)
// 旋转后的角度
const rotateDegreeAfter = Math.atan2(curY - centerY, curX - centerX) / (Math.PI / 180)
// 获取旋转的角度值, startRotate 为初始角度值
pos.rotate = startRotate + rotateDegreeAfter - rotateDegreeBefore

放大缩小

组件旋转后的放大缩小会有 BUG。

从上图可以看到,放大缩小时会发生移位。另外伸缩的方向和我们拖动的方向也不对。造成这一 BUG 的原因是:当初设计放大缩小功能没有考虑到旋转的场景。所以无论旋转多少角度,放大缩小仍然是按没旋转时计算的。

下面再看一个具体的示例:

从上图可以看出,在没有旋转时,按住顶点往上拖动,只需用 y2 - y1 就可以得出拖动距离 s。这时将组件原来的高度加上 s 就能得出新的高度,同时将组件的 topleft 属性更新。

现在旋转 180 度,如果这时拖住顶点往下拖动,我们期待的结果是组件高度增加。但这时计算的方式和原来没旋转时是一样的,所以结果和我们期待的相反,组件的高度将会变小(如果不理解这个现象,可以想像一下没有旋转的那张图,按住顶点往下拖动)。

如何解决这个问题呢?我从 github 上的一个项目 snapping-demo 找到了解决方案:将放大缩小和旋转角度关联起来。

解决方案

下面是一个已旋转一定角度的矩形,假设现在拖动它左上方的点进行拉伸。

现在我们将一步步分析如何得出拉伸后的组件的正确大小和位移。

第一步,按下鼠标时通过组件的坐标(无论旋转多少度,组件的 topleft 属性不变)和大小算出组件中心点:

const center = {
    x: style.left + style.width / 2,
    y: style.top + style.height / 2,
}

第二步,用当前点击坐标和组件中心点算出当前点击坐标的对称点坐标:

// 获取画布位移信息
const editorRectInfo = document.querySelector('#editor').getBoundingClientRect()

// 当前点击坐标
const curPoint = {
    x: e.clientX - editorRectInfo.left,
    y: e.clientY - editorRectInfo.top,
}

// 获取对称点的坐标
const symmetricPoint = {
    x: center.x - (curPoint.x - center.x),
    y: center.y - (curPoint.y - center.y),
}

第三步,摁住组件左上角进行拉伸时,通过当前鼠标实时坐标和对称点计算出新的组件中心点:

const curPositon = {
    x: moveEvent.clientX - editorRectInfo.left,
    y: moveEvent.clientY - editorRectInfo.top,
}

const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)

// 求两点之间的中点坐标
function getCenterPoint(p1, p2) {
    return {
        x: p1.x + ((p2.x - p1.x) / 2),
        y: p1.y + ((p2.y - p1.y) / 2),
    }
}

由于组件处于旋转状态,即使你知道了拉伸时移动的 xy 距离,也不能直接对组件进行计算。否则就会出现 BUG,移位或者放大缩小方向不正确。因此,我们需要在组件未旋转的情况下对其进行计算。

第四步,根据已知的旋转角度、新的组件中心点、当前鼠标实时坐标可以算出当前鼠标实时坐标currentPosition 在未旋转时的坐标 newTopLeftPoint。同时也能根据已知的旋转角度、新的组件中心点、对称点算出组件对称点sPoint 在未旋转时的坐标 newBottomRightPoint

对应的计算公式如下:

/**
 * 计算根据圆心旋转后的点的坐标
 * @param   {Object}  point  旋转前的点坐标
 * @param   {Object}  center 旋转中心
 * @param   {Number}  rotate 旋转的角度
 * @return  {Object}         旋转后的坐标
 * https://www.zhihu.com/question/67425734/answer/252724399 旋转矩阵公式
 */
export function calculateRotatedPointCoordinate(point, center, rotate) {
    /**
     * 旋转公式:
     *  点a(x, y)
     *  旋转中心c(x, y)
     *  旋转后点n(x, y)
     *  旋转角度θ                tan ??
     * nx = cosθ * (ax - cx) - sinθ * (ay - cy) + cx
     * ny = sinθ * (ax - cx) + cosθ * (ay - cy) + cy
     */

    return {
        x: (point.x - center.x) * Math.cos(angleToRadian(rotate)) - (point.y - center.y) * Math.sin(angleToRadian(rotate)) + center.x,
        y: (point.x - center.x) * Math.sin(angleToRadian(rotate)) + (point.y - center.y) * Math.cos(angleToRadian(rotate)) + center.y,
    }
}

上面的公式涉及到线性代数中旋转矩阵的知识,对于一个没上过大学的人来说,实在太难了。还好我从知乎上的一个回答中找到了这一公式的推理过程,下面是回答的原文:

通过以上几个计算值,就可以得到组件新的位移值 topleft 以及新的组件大小。对应的完整代码如下:

function calculateLeftTop(style, curPositon, pointInfo) {
    const { symmetricPoint } = pointInfo
    const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)
    const newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate)
    const newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)
  
    const newWidth = newBottomRightPoint.x - newTopLeftPoint.x
    const newHeight = newBottomRightPoint.y - newTopLeftPoint.y
    if (newWidth > 0 && newHeight > 0) {
        style.width = Math.round(newWidth)
        style.height = Math.round(newHeight)
        style.left = Math.round(newTopLeftPoint.x)
        style.top = Math.round(newTopLeftPoint.y)
    }
}

现在再来看一下旋转后的放大缩小:

自动吸附

自动吸附是根据组件的四个属性 topleftwidthheight 计算的,在将组件进行旋转后,这些属性的值是不会变的。所以无论组件旋转多少度,吸附时仍然按未旋转时计算。这样就会有一个问题,虽然实际上组件的 topleftwidthheight 属性没有变化。但在外观上却发生了变化。下面是两个同样的组件:一个没旋转,一个旋转了 45 度。

可以看出来旋转后按钮的 height 属性和我们从外观上看到的高度是不一样的,所以在这种情况下就出现了吸附不正确的 BUG。

解决方案

如何解决这个问题?我们需要拿组件旋转后的大小及位移来做吸附对比。也就是说不要拿组件实际的属性来对比,而是拿我们看到的大小和位移做对比。

从上图可以看出,旋转后的组件在 x 轴上的投射长度为两条红线长度之和。这两条红线的长度可以通过正弦和余弦算出,左边的红线用正弦计算,右边的红线用余弦计算:

const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)

同理,高度也是一样:

const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)

新的宽度和高度有了,再根据组件原有的 topleft 属性,可以得出组件旋转后新的 topleft 属性。下面附上完整代码:

translateComponentStyle(style) {
    style = { ...style }
    if (style.rotate != 0) {
        const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)
        const diffX = (style.width - newWidth) / 2
        style.left += diffX
        style.right = style.left + newWidth

        const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)
        const diffY = (newHeight - style.height) / 2
        style.top -= diffY
        style.bottom = style.top + newHeight

        style.width = newWidth
        style.height = newHeight
    } else {
        style.bottom = style.top + style.height
        style.right = style.left + style.width
    }

    return style
}

经过修复后,吸附也可以正常显示了。

光标

光标和可拖动的方向不对,是因为八个点的光标是固定设置的,没有随着角度变化而变化。

解决方案

由于 360 / 8 = 45,所以可以为每一个方向分配 45 度的范围,每个范围对应一个光标。同时为每个方向设置一个初始角度,也就是未旋转时组件每个方向对应的角度。

pointList: ['lt', 't', 'rt', 'r', 'rb', 'b', 'lb', 'l'], // 八个方向
initialAngle: { // 每个点对应的初始角度
    lt: 0,
    t: 45,
    rt: 90,
    r: 135,
    rb: 180,
    b: 225,
    lb: 270,
    l: 315,
},
angleToCursor: [ // 每个范围的角度对应的光标
    { start: 338, end: 23, cursor: 'nw' },
    { start: 23, end: 68, cursor: 'n' },
    { start: 68, end: 113, cursor: 'ne' },
    { start: 113, end: 158, cursor: 'e' },
    { start: 158, end: 203, cursor: 'se' },
    { start: 203, end: 248, cursor: 's' },
    { start: 248, end: 293, cursor: 'sw' },
    { start: 293, end: 338, cursor: 'w' },
],
cursors: {},

计算方式也很简单:

  1. 假设现在组件已旋转了一定的角度 a。
  2. 遍历八个方向,用每个方向的初始角度 + a 得出现在的角度 b。
  3. 遍历 angleToCursor 数组,看看 b 在哪一个范围中,然后将对应的光标返回。

经常上面三个步骤就可以计算出组件旋转后正确的光标方向。具体的代码如下:

getCursor() {
    const { angleToCursor, initialAngle, pointList, curComponent } = this
    const rotate = (curComponent.style.rotate + 360) % 360 // 防止角度有负数,所以 + 360
    const result = {}
    let lastMatchIndex = -1 // 从上一个命中的角度的索引开始匹配下一个,降低时间复杂度
    pointList.forEach(point => {
        const angle = (initialAngle[point] + rotate) % 360
        const len = angleToCursor.length
        while (true) {
            lastMatchIndex = (lastMatchIndex + 1) % len
            const angleLimit = angleToCursor[lastMatchIndex]
            if (angle < 23 || angle >= 338) {
                result[point] = 'nw-resize'
                return
            }

            if (angleLimit.start <= angle && angle < angleLimit.end) {
                result[point] = angleLimit.cursor + '-resize'
                return
            }
        }
    })

    return result
},

从上面的动图可以看出来,现在八个方向上的光标是可以正确显示的。

15. 复制粘贴剪切

相对于拖拽旋转功能,复制粘贴就比较简单了。

const ctrlKey = 17, vKey = 86, cKey = 67, xKey = 88
let isCtrlDown = false

window.onkeydown = (e) => {
    if (e.keyCode == ctrlKey) {
        isCtrlDown = true
    } else if (isCtrlDown && e.keyCode == cKey) {
        this.$store.commit('copy')
    } else if (isCtrlDown && e.keyCode == vKey) {
        this.$store.commit('paste')
    } else if (isCtrlDown && e.keyCode == xKey) {
        this.$store.commit('cut')
    }
}

window.onkeyup = (e) => {
    if (e.keyCode == ctrlKey) {
        isCtrlDown = false
    }
}

监听用户的按键操作,在按下特定按键时触发对应的操作。

复制操作

在 vuex 中使用 copyData 来表示复制的数据。当用户按下 ctrl + c 时,将当前组件数据深拷贝到 copyData

copy(state) {
    state.copyData = {
        data: deepCopy(state.curComponent),
        index: state.curComponentIndex,
    }
},

同时需要将当前组件在组件数据中的索引记录起来,在剪切中要用到。

粘贴操作

paste(state, isMouse) {
    if (!state.copyData) {
        toast('请选择组件')
        return
    }

    const data = state.copyData.data

    if (isMouse) {
        data.style.top = state.menuTop
        data.style.left = state.menuLeft
    } else {
        data.style.top += 10
        data.style.left += 10
    }

    data.id = generateID()
    store.commit('addComponent', { component: data })
    store.commit('recordSnapshot')
    state.copyData = null
},

粘贴时,如果是按键操作 ctrl+v。则将组件的 topleft 属性加 10,以免和原来的组件重叠在一起。如果是使用鼠标右键执行粘贴操作,则将复制的组件放到鼠标点击处。

剪切操作

cut(state) {
    if (!state.curComponent) {
        toast('请选择组件')
        return
    }

    if (state.copyData) {
        store.commit('addComponent', { component: state.copyData.data, index: state.copyData.index })
        if (state.curComponentIndex >= state.copyData.index) {
            // 如果当前组件索引大于等于插入索引,需要加一,因为当前组件往后移了一位
            state.curComponentIndex++
        }
    }

    store.commit('copy')
    store.commit('deleteComponent')
},

剪切操作本质上还是复制,只不过在执行复制后,需要将当前组件删除。为了避免用户执行剪切操作后,不执行粘贴操作,而是继续执行剪切。这时就需要将原先剪切的数据进行恢复。所以复制数据中记录的索引就起作用了,可以通过索引将原来的数据恢复到原来的位置中。

右键操作

右键操作和按键操作是一样的,一个功能两种触发途径。

<li @click="copy" v-show="curComponent">复制</li>
<li @click="paste">粘贴</li>
<li @click="cut" v-show="curComponent">剪切</li>

cut() {
    this.$store.commit('cut')
},

copy() {
    this.$store.commit('copy')
},

paste() {
    this.$store.commit('paste', true)
},

16. 数据交互

方式一

提前写好一系列 ajax 请求API,点击组件时按需选择 API,选好 API 再填参数。例如下面这个组件,就展示了如何使用 ajax 请求向后台交互:

<template>
    <div>{{ propValue.data }}</div>
</template>

<script>
export default {
    // propValue: {
    //     api: {
    //             request: a,
    //             params,
    //      },
    //     data: null
    // }
    props: {
        propValue: {
            type: Object,
            default: () => {},
        },
    },
    created() {
        this.propValue.api.request(this.propValue.api.params).then(res => {
            this.propValue.data = res.data
        })
    },
}
</script>

方式二

方式二适合纯展示的组件,例如有一个报警组件,可以根据后台传来的数据显示对应的颜色。在编辑页面的时候,可以通过 ajax 向后台请求页面能够使用的 websocket 数据:

const data = ['status', 'text'...]

然后再为不同的组件添加上不同的属性。例如有 a 组件,它绑定的属性为 status

// 组件能接收的数据
props: {
    propValue: {
        type: String,
    },
    element: {
        type: Object,
    },
    wsKey: {
        type: String,
        default: '',
    },
},

在组件中通过 wsKey 获取这个绑定的属性。等页面发布后或者预览时,通过 weboscket 向后台请求全局数据放在 vuex 上。组件就可以通过 wsKey 访问数据了。

<template>
    <div>{{ wsData[wsKey] }}</div>
</template>

<script>
import { mapState } from 'vuex'

export default {
    props: {
        propValue: {
            type: String,
        },
        element: {
            type: Object,
        },
        wsKey: {
            type: String,
            default: '',
        },
    },
    computed: mapState([
        'wsData',
    ]),
</script>

和后台交互的方式有很多种,不仅仅包括上面两种,我在这里仅提供一些思路,以供参考。

17. 发布

页面发布有两种方式:一是将组件数据渲染为一个单独的 HTML 页面;二是从本项目中抽取出一个最小运行时 runtime 作为一个单独的项目。

这里说一下第二种方式,本项目中的最小运行时其实就是预览页面加上自定义组件。将这些代码提取出来作为一个项目单独打包。发布页面时将组件数据以 JSON 的格式传给服务端,同时为每个页面生成一个唯一 ID。

假设现在有三个页面,发布页面生成的 ID 为 a、b、c。访问页面时只需要把 ID 带上,这样就可以根据 ID 获取每个页面对应的组件数据。

www.test.com/?id=a
www.test.com/?id=c
www.test.com/?id=b

按需加载

如果自定义组件过大,例如有数十个甚至上百个。这时可以将自定义组件用 import 的方式导入,做到按需加载,减少首屏渲染时间:

import Vue from 'vue'

const components = [
    'Picture',
    'VText',
    'VButton',
]

components.forEach(key => {
    Vue.component(key, () => import(`@/custom-component/${key}`))
})

按版本发布

自定义组件有可能会有更新的情况。例如原来的组件使用了大半年,现在有功能变更,为了不影响原来的页面。建议在发布时带上组件的版本号:

- v-text
  - v1.vue
  - v2.vue

例如 v-text 组件有两个版本,在左侧组件列表区使用时就可以带上版本号:

{
  component: 'v-text',
  version: 'v1'
  ...
}

这样导入组件时就可以根据组件版本号进行导入:

import Vue from 'vue'
import componentList from '@/custom-component/component-list`

componentList.forEach(component => {
    Vue.component(component.name, () => import(`@/custom-component/${component.name}/${component.version}`))
})

参考资料

查看原文

蒋鹏飞 赞了文章 · 1月18日

可视化拖拽组件库一些技术要点原理分析(二)

本文是对《可视化拖拽组件库一些技术要点原理分析》的补充。上一篇文章主要讲解了以下几个功能点:

  1. 编辑器
  2. 自定义组件
  3. 拖拽
  4. 删除组件、调整图层层级
  5. 放大缩小
  6. 撤消、重做
  7. 组件属性设置
  8. 吸附
  9. 预览、保存代码
  10. 绑定事件
  11. 绑定动画
  12. 导入 PSD
  13. 手机模式

现在这篇文章会在此基础上再补充 4 个功能点,分别是:

  • 拖拽旋转
  • 复制粘贴剪切
  • 数据交互
  • 发布

和上篇文章一样,我已经将新功能的代码更新到了 github:

友善提醒:建议结合源码一起阅读,效果更好(这个 DEMO 使用的是 Vue 技术栈)。

14. 拖拽旋转

在写上一篇文章时,原来的 DEMO 已经可以支持旋转功能了。但是这个旋转功能还有很多不完善的地方:

  1. 不支持拖拽旋转。
  2. 旋转后的放大缩小不正确。
  3. 旋转后的自动吸附不正确。
  4. 旋转后八个可伸缩点的光标不正确。

这一小节,我们将逐一解决这四个问题。

拖拽旋转

拖拽旋转需要使用 Math.atan2() 函数。

Math.atan2() 返回从原点(0,0)到(x,y)点的线段与x轴正方向之间的平面角度(弧度值),也就是Math.atan2(y,x)。Math.atan2(y,x)中的y和x都是相对于圆点(0,0)的距离。

简单的说就是以组件中心点为原点 (centerX,centerY),用户按下鼠标时的坐标设为 (startX,startY),鼠标移动时的坐标设为 (curX,curY)。旋转角度可以通过 (startX,startY)(curX,curY) 计算得出。

那我们如何得到从点 (startX,startY) 到点 (curX,curY) 之间的旋转角度呢?

第一步,鼠标点击时的坐标设为 (startX,startY)

const startY = e.clientY
const startX = e.clientX

第二步,算出组件中心点:

// 获取组件中心点位置
const rect = this.$el.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2

第三步,按住鼠标移动时的坐标设为 (curX,curY)

const curX = moveEvent.clientX
const curY = moveEvent.clientY

第四步,分别算出 (startX,startY)(curX,curY) 对应的角度,再将它们相减得出旋转的角度。另外,还需要注意的就是 Math.atan2() 方法的返回值是一个弧度,因此还需要将弧度转化为角度。所以完整的代码为:

// 旋转前的角度
const rotateDegreeBefore = Math.atan2(startY - centerY, startX - centerX) / (Math.PI / 180)
// 旋转后的角度
const rotateDegreeAfter = Math.atan2(curY - centerY, curX - centerX) / (Math.PI / 180)
// 获取旋转的角度值, startRotate 为初始角度值
pos.rotate = startRotate + rotateDegreeAfter - rotateDegreeBefore

放大缩小

组件旋转后的放大缩小会有 BUG。

从上图可以看到,放大缩小时会发生移位。另外伸缩的方向和我们拖动的方向也不对。造成这一 BUG 的原因是:当初设计放大缩小功能没有考虑到旋转的场景。所以无论旋转多少角度,放大缩小仍然是按没旋转时计算的。

下面再看一个具体的示例:

从上图可以看出,在没有旋转时,按住顶点往上拖动,只需用 y2 - y1 就可以得出拖动距离 s。这时将组件原来的高度加上 s 就能得出新的高度,同时将组件的 topleft 属性更新。

现在旋转 180 度,如果这时拖住顶点往下拖动,我们期待的结果是组件高度增加。但这时计算的方式和原来没旋转时是一样的,所以结果和我们期待的相反,组件的高度将会变小(如果不理解这个现象,可以想像一下没有旋转的那张图,按住顶点往下拖动)。

如何解决这个问题呢?我从 github 上的一个项目 snapping-demo 找到了解决方案:将放大缩小和旋转角度关联起来。

解决方案

下面是一个已旋转一定角度的矩形,假设现在拖动它左上方的点进行拉伸。

现在我们将一步步分析如何得出拉伸后的组件的正确大小和位移。

第一步,按下鼠标时通过组件的坐标(无论旋转多少度,组件的 topleft 属性不变)和大小算出组件中心点:

const center = {
    x: style.left + style.width / 2,
    y: style.top + style.height / 2,
}

第二步,用当前点击坐标和组件中心点算出当前点击坐标的对称点坐标:

// 获取画布位移信息
const editorRectInfo = document.querySelector('#editor').getBoundingClientRect()

// 当前点击坐标
const curPoint = {
    x: e.clientX - editorRectInfo.left,
    y: e.clientY - editorRectInfo.top,
}

// 获取对称点的坐标
const symmetricPoint = {
    x: center.x - (curPoint.x - center.x),
    y: center.y - (curPoint.y - center.y),
}

第三步,摁住组件左上角进行拉伸时,通过当前鼠标实时坐标和对称点计算出新的组件中心点:

const curPositon = {
    x: moveEvent.clientX - editorRectInfo.left,
    y: moveEvent.clientY - editorRectInfo.top,
}

const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)

// 求两点之间的中点坐标
function getCenterPoint(p1, p2) {
    return {
        x: p1.x + ((p2.x - p1.x) / 2),
        y: p1.y + ((p2.y - p1.y) / 2),
    }
}

由于组件处于旋转状态,即使你知道了拉伸时移动的 xy 距离,也不能直接对组件进行计算。否则就会出现 BUG,移位或者放大缩小方向不正确。因此,我们需要在组件未旋转的情况下对其进行计算。

第四步,根据已知的旋转角度、新的组件中心点、当前鼠标实时坐标可以算出当前鼠标实时坐标currentPosition 在未旋转时的坐标 newTopLeftPoint。同时也能根据已知的旋转角度、新的组件中心点、对称点算出组件对称点sPoint 在未旋转时的坐标 newBottomRightPoint

对应的计算公式如下:

/**
 * 计算根据圆心旋转后的点的坐标
 * @param   {Object}  point  旋转前的点坐标
 * @param   {Object}  center 旋转中心
 * @param   {Number}  rotate 旋转的角度
 * @return  {Object}         旋转后的坐标
 * https://www.zhihu.com/question/67425734/answer/252724399 旋转矩阵公式
 */
export function calculateRotatedPointCoordinate(point, center, rotate) {
    /**
     * 旋转公式:
     *  点a(x, y)
     *  旋转中心c(x, y)
     *  旋转后点n(x, y)
     *  旋转角度θ                tan ??
     * nx = cosθ * (ax - cx) - sinθ * (ay - cy) + cx
     * ny = sinθ * (ax - cx) + cosθ * (ay - cy) + cy
     */

    return {
        x: (point.x - center.x) * Math.cos(angleToRadian(rotate)) - (point.y - center.y) * Math.sin(angleToRadian(rotate)) + center.x,
        y: (point.x - center.x) * Math.sin(angleToRadian(rotate)) + (point.y - center.y) * Math.cos(angleToRadian(rotate)) + center.y,
    }
}

上面的公式涉及到线性代数中旋转矩阵的知识,对于一个没上过大学的人来说,实在太难了。还好我从知乎上的一个回答中找到了这一公式的推理过程,下面是回答的原文:

通过以上几个计算值,就可以得到组件新的位移值 topleft 以及新的组件大小。对应的完整代码如下:

function calculateLeftTop(style, curPositon, pointInfo) {
    const { symmetricPoint } = pointInfo
    const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)
    const newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate)
    const newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)
  
    const newWidth = newBottomRightPoint.x - newTopLeftPoint.x
    const newHeight = newBottomRightPoint.y - newTopLeftPoint.y
    if (newWidth > 0 && newHeight > 0) {
        style.width = Math.round(newWidth)
        style.height = Math.round(newHeight)
        style.left = Math.round(newTopLeftPoint.x)
        style.top = Math.round(newTopLeftPoint.y)
    }
}

现在再来看一下旋转后的放大缩小:

自动吸附

自动吸附是根据组件的四个属性 topleftwidthheight 计算的,在将组件进行旋转后,这些属性的值是不会变的。所以无论组件旋转多少度,吸附时仍然按未旋转时计算。这样就会有一个问题,虽然实际上组件的 topleftwidthheight 属性没有变化。但在外观上却发生了变化。下面是两个同样的组件:一个没旋转,一个旋转了 45 度。

可以看出来旋转后按钮的 height 属性和我们从外观上看到的高度是不一样的,所以在这种情况下就出现了吸附不正确的 BUG。

解决方案

如何解决这个问题?我们需要拿组件旋转后的大小及位移来做吸附对比。也就是说不要拿组件实际的属性来对比,而是拿我们看到的大小和位移做对比。

从上图可以看出,旋转后的组件在 x 轴上的投射长度为两条红线长度之和。这两条红线的长度可以通过正弦和余弦算出,左边的红线用正弦计算,右边的红线用余弦计算:

const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)

同理,高度也是一样:

const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)

新的宽度和高度有了,再根据组件原有的 topleft 属性,可以得出组件旋转后新的 topleft 属性。下面附上完整代码:

translateComponentStyle(style) {
    style = { ...style }
    if (style.rotate != 0) {
        const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)
        const diffX = (style.width - newWidth) / 2
        style.left += diffX
        style.right = style.left + newWidth

        const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)
        const diffY = (newHeight - style.height) / 2
        style.top -= diffY
        style.bottom = style.top + newHeight

        style.width = newWidth
        style.height = newHeight
    } else {
        style.bottom = style.top + style.height
        style.right = style.left + style.width
    }

    return style
}

经过修复后,吸附也可以正常显示了。

光标

光标和可拖动的方向不对,是因为八个点的光标是固定设置的,没有随着角度变化而变化。

解决方案

由于 360 / 8 = 45,所以可以为每一个方向分配 45 度的范围,每个范围对应一个光标。同时为每个方向设置一个初始角度,也就是未旋转时组件每个方向对应的角度。

pointList: ['lt', 't', 'rt', 'r', 'rb', 'b', 'lb', 'l'], // 八个方向
initialAngle: { // 每个点对应的初始角度
    lt: 0,
    t: 45,
    rt: 90,
    r: 135,
    rb: 180,
    b: 225,
    lb: 270,
    l: 315,
},
angleToCursor: [ // 每个范围的角度对应的光标
    { start: 338, end: 23, cursor: 'nw' },
    { start: 23, end: 68, cursor: 'n' },
    { start: 68, end: 113, cursor: 'ne' },
    { start: 113, end: 158, cursor: 'e' },
    { start: 158, end: 203, cursor: 'se' },
    { start: 203, end: 248, cursor: 's' },
    { start: 248, end: 293, cursor: 'sw' },
    { start: 293, end: 338, cursor: 'w' },
],
cursors: {},

计算方式也很简单:

  1. 假设现在组件已旋转了一定的角度 a。
  2. 遍历八个方向,用每个方向的初始角度 + a 得出现在的角度 b。
  3. 遍历 angleToCursor 数组,看看 b 在哪一个范围中,然后将对应的光标返回。

经常上面三个步骤就可以计算出组件旋转后正确的光标方向。具体的代码如下:

getCursor() {
    const { angleToCursor, initialAngle, pointList, curComponent } = this
    const rotate = (curComponent.style.rotate + 360) % 360 // 防止角度有负数,所以 + 360
    const result = {}
    let lastMatchIndex = -1 // 从上一个命中的角度的索引开始匹配下一个,降低时间复杂度
    pointList.forEach(point => {
        const angle = (initialAngle[point] + rotate) % 360
        const len = angleToCursor.length
        while (true) {
            lastMatchIndex = (lastMatchIndex + 1) % len
            const angleLimit = angleToCursor[lastMatchIndex]
            if (angle < 23 || angle >= 338) {
                result[point] = 'nw-resize'
                return
            }

            if (angleLimit.start <= angle && angle < angleLimit.end) {
                result[point] = angleLimit.cursor + '-resize'
                return
            }
        }
    })

    return result
},

从上面的动图可以看出来,现在八个方向上的光标是可以正确显示的。

15. 复制粘贴剪切

相对于拖拽旋转功能,复制粘贴就比较简单了。

const ctrlKey = 17, vKey = 86, cKey = 67, xKey = 88
let isCtrlDown = false

window.onkeydown = (e) => {
    if (e.keyCode == ctrlKey) {
        isCtrlDown = true
    } else if (isCtrlDown && e.keyCode == cKey) {
        this.$store.commit('copy')
    } else if (isCtrlDown && e.keyCode == vKey) {
        this.$store.commit('paste')
    } else if (isCtrlDown && e.keyCode == xKey) {
        this.$store.commit('cut')
    }
}

window.onkeyup = (e) => {
    if (e.keyCode == ctrlKey) {
        isCtrlDown = false
    }
}

监听用户的按键操作,在按下特定按键时触发对应的操作。

复制操作

在 vuex 中使用 copyData 来表示复制的数据。当用户按下 ctrl + c 时,将当前组件数据深拷贝到 copyData

copy(state) {
    state.copyData = {
        data: deepCopy(state.curComponent),
        index: state.curComponentIndex,
    }
},

同时需要将当前组件在组件数据中的索引记录起来,在剪切中要用到。

粘贴操作

paste(state, isMouse) {
    if (!state.copyData) {
        toast('请选择组件')
        return
    }

    const data = state.copyData.data

    if (isMouse) {
        data.style.top = state.menuTop
        data.style.left = state.menuLeft
    } else {
        data.style.top += 10
        data.style.left += 10
    }

    data.id = generateID()
    store.commit('addComponent', { component: data })
    store.commit('recordSnapshot')
    state.copyData = null
},

粘贴时,如果是按键操作 ctrl+v。则将组件的 topleft 属性加 10,以免和原来的组件重叠在一起。如果是使用鼠标右键执行粘贴操作,则将复制的组件放到鼠标点击处。

剪切操作

cut(state) {
    if (!state.curComponent) {
        toast('请选择组件')
        return
    }

    if (state.copyData) {
        store.commit('addComponent', { component: state.copyData.data, index: state.copyData.index })
        if (state.curComponentIndex >= state.copyData.index) {
            // 如果当前组件索引大于等于插入索引,需要加一,因为当前组件往后移了一位
            state.curComponentIndex++
        }
    }

    store.commit('copy')
    store.commit('deleteComponent')
},

剪切操作本质上还是复制,只不过在执行复制后,需要将当前组件删除。为了避免用户执行剪切操作后,不执行粘贴操作,而是继续执行剪切。这时就需要将原先剪切的数据进行恢复。所以复制数据中记录的索引就起作用了,可以通过索引将原来的数据恢复到原来的位置中。

右键操作

右键操作和按键操作是一样的,一个功能两种触发途径。

<li @click="copy" v-show="curComponent">复制</li>
<li @click="paste">粘贴</li>
<li @click="cut" v-show="curComponent">剪切</li>

cut() {
    this.$store.commit('cut')
},

copy() {
    this.$store.commit('copy')
},

paste() {
    this.$store.commit('paste', true)
},

16. 数据交互

方式一

提前写好一系列 ajax 请求API,点击组件时按需选择 API,选好 API 再填参数。例如下面这个组件,就展示了如何使用 ajax 请求向后台交互:

<template>
    <div>{{ propValue.data }}</div>
</template>

<script>
export default {
    // propValue: {
    //     api: {
    //             request: a,
    //             params,
    //      },
    //     data: null
    // }
    props: {
        propValue: {
            type: Object,
            default: () => {},
        },
    },
    created() {
        this.propValue.api.request(this.propValue.api.params).then(res => {
            this.propValue.data = res.data
        })
    },
}
</script>

方式二

方式二适合纯展示的组件,例如有一个报警组件,可以根据后台传来的数据显示对应的颜色。在编辑页面的时候,可以通过 ajax 向后台请求页面能够使用的 websocket 数据:

const data = ['status', 'text'...]

然后再为不同的组件添加上不同的属性。例如有 a 组件,它绑定的属性为 status

// 组件能接收的数据
props: {
    propValue: {
        type: String,
    },
    element: {
        type: Object,
    },
    wsKey: {
        type: String,
        default: '',
    },
},

在组件中通过 wsKey 获取这个绑定的属性。等页面发布后或者预览时,通过 weboscket 向后台请求全局数据放在 vuex 上。组件就可以通过 wsKey 访问数据了。

<template>
    <div>{{ wsData[wsKey] }}</div>
</template>

<script>
import { mapState } from 'vuex'

export default {
    props: {
        propValue: {
            type: String,
        },
        element: {
            type: Object,
        },
        wsKey: {
            type: String,
            default: '',
        },
    },
    computed: mapState([
        'wsData',
    ]),
</script>

和后台交互的方式有很多种,不仅仅包括上面两种,我在这里仅提供一些思路,以供参考。

17. 发布

页面发布有两种方式:一是将组件数据渲染为一个单独的 HTML 页面;二是从本项目中抽取出一个最小运行时 runtime 作为一个单独的项目。

这里说一下第二种方式,本项目中的最小运行时其实就是预览页面加上自定义组件。将这些代码提取出来作为一个项目单独打包。发布页面时将组件数据以 JSON 的格式传给服务端,同时为每个页面生成一个唯一 ID。

假设现在有三个页面,发布页面生成的 ID 为 a、b、c。访问页面时只需要把 ID 带上,这样就可以根据 ID 获取每个页面对应的组件数据。

www.test.com/?id=a
www.test.com/?id=c
www.test.com/?id=b

按需加载

如果自定义组件过大,例如有数十个甚至上百个。这时可以将自定义组件用 import 的方式导入,做到按需加载,减少首屏渲染时间:

import Vue from 'vue'

const components = [
    'Picture',
    'VText',
    'VButton',
]

components.forEach(key => {
    Vue.component(key, () => import(`@/custom-component/${key}`))
})

按版本发布

自定义组件有可能会有更新的情况。例如原来的组件使用了大半年,现在有功能变更,为了不影响原来的页面。建议在发布时带上组件的版本号:

- v-text
  - v1.vue
  - v2.vue

例如 v-text 组件有两个版本,在左侧组件列表区使用时就可以带上版本号:

{
  component: 'v-text',
  version: 'v1'
  ...
}

这样导入组件时就可以根据组件版本号进行导入:

import Vue from 'vue'
import componentList from '@/custom-component/component-list`

componentList.forEach(component => {
    Vue.component(component.name, () => import(`@/custom-component/${component.name}/${component.version}`))
})

参考资料

查看原文

赞 19 收藏 12 评论 0

蒋鹏飞 关注了用户 · 1月15日

SHERlocked93 @sherlocked93

来自南京的前端打字员,掘金优秀作者,慕课畅销专栏 <JavaScript 设计模式精讲> 作者,原创同步更新于 Github 个人博客 (求 star🤪 )

公众号 前端下午茶,欢迎关注 👏 ,分享前端相关的技术博客、精选文章,期待在这里和大家一起进步 ~

关注 386

蒋鹏飞 关注了用户 · 1月12日

joyqi @joyqi

我的生涯一片无悔,想起那天夕阳下的奔跑,那是我逝去的青春

关注 1332

蒋鹏飞 发布了文章 · 1月12日

前端也能学算法:由浅入深讲解动态规划

动态规划是一种常用的算法思想,很多朋友觉得不好理解,其实不然,如果掌握了他的核心思想,并且多多练习还是可以掌握的。下面我们由浅入深的来讲讲动态规划。

斐波拉契数列

首先我们来看看斐波拉契数列,这是一个大家都很熟悉的数列:

// f = [1, 1, 2, 3, 5, 8]
f(1) = 1;
f(2) = 1;
f(n) = f(n-1) + f(n -2); // n > 2

有了上面的公式,我们很容易写出计算f(n)的递归代码:

function fibonacci_recursion(n) {
  if(n === 1 || n === 2) {
    return 1;
  }
  
  return fibonacci_recursion(n - 1) + fibonacci_recursion(n - 2);
}

const res = fibonacci_recursion(5);
console.log(res);   // 5

现在我们考虑一下上面的计算过程,计算f(5)的时候需要f(4)与f(3)的值,计算f(4)的时候需要f(3)与f(2)的值,这里f(3)就重复算了两遍。在我们已知f(1)和f(2)的情况下,我们其实只需要计算f(3),f(4),f(5)三次计算就行了,但是从下图可知,为了计算f(5),我们总共计算了8次其他值,里面f(3), f(2), f(1)都有多次重复计算。如果n不是5,而是一个更大的数,计算次数更是指数倍增长,这个递归算法的时间复杂度是$O(2^n)$。

image-20200121174402790

非递归的斐波拉契数列

为了解决上面指数级的时间复杂度,我们不能用递归算法了,而要用一个普通的循环算法。应该怎么做呢?我们只需要加一个数组,里面记录每一项的值就行了,为了让数组与f(n)的下标相对应,我们给数组开头位置填充一个0

const res = [0, 1, 1];
f(n) = res[n];

我们需要做的就是给res数组填充值,然后返回第n项的值就行了:

function fibonacci_no_recursion(n) {
  const res = [0, 1, 1];
  for(let i = 3; i <= n; i++){
    res[i] = res[i-1] + res[i-2];
  }
  
  return res[n];
}

const num = fibonacci_no_recursion(5);
console.log(num);   // 5

上面的方法就没有重复计算的问题,因为我们把每次的结果都存到一个数组里面了,计算f(n)的时候只需要将f(n-1)和f(n-2)拿出来用就行了,因为是从小往大算,所以f(n-1)和f(n-2)的值之前就算好了。这个算法的时间复杂度是O(n),比$O(2^n)$好的多得多。这个算法其实就用到了动态规划的思想。

动态规划

动态规划主要有如下两个特点

  1. 最优子结构:一个规模为n的问题可以转化为规模比他小的子问题来求解。换言之,f(n)可以通过一个比他规模小的递推式来求解,在前面的斐波拉契数列这个递推式就是f(n) = f(n-1) + f(n -2)。一般具有这种结构的问题也可以用递归求解,但是递归的复杂度太高。
  2. 子问题的重叠性:如果用递归求解,会有很多重复的子问题,动态规划就是修剪了重复的计算来降低时间复杂度。但是因为需要存储中间状态,空间复杂度是增加了。

其实动态规划的难点是归纳出递推式,在斐波拉契数列中,递推式是已经给出的,但是更多情况递推式是需要我们自己去归纳总结的。

钢条切割问题

image-20200121181228767

先看看暴力穷举怎么做,以一个长度为5的钢条为例:

image-20200121182429181

上图红色的位置表示可以下刀切割的位置,每个位置可以有切和不切两种状态,总共是$2^4 = 16$种,对于长度为n的钢条,这个情况就是$2^{n-1}$种。穷举的方法就不写代码了,下面直接来看递归的方法:

递归方案

还是以上面那个长度为5的钢条为例,假如我们只考虑切一刀的情况,这一刀的位置可以是1,2,3,4中的任意位置,那切割之后,左右两边的长度分别是:

// [left, right]: 表示切了后左边,右边的长度
[1, 4]: 切1的位置
[2, 3]: 切2的位置
[3, 2]: 切3的位置
[4, 1]: 切4的位置

分成了左右两部分,那左右两部分又可以继续切,每部分切一刀,又变成了两部分,又可以继续切。这不就将一个长度为5的问题,分解成了4个小问题吗,那最优的方案就是这四个小问题里面最大的那个值,同时不要忘了我们也可以一刀都不切,这是第五个小问题,我们要的答案其实就是这5个小问题里面的最大值。写成公式就是,对于长度为n的钢条,最佳收益公式是:

image-20200122135927576

  • $r_n$ : 表示我们求解的目标,长度为n的钢条的最大收益
  • $p_n$: 表示钢条完全不切的情况
  • $r_1 + r_{n-1}$: 表示切在1的位置,分为了左边为1,右边为n-1长度的两端,他们的和是这种方案的最优收益
  • 我们的最大收益就是不切和切在不同情况的子方案里面找最大值

上面的公式已经可以用递归求解了:

const p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30]; // 下标表示钢条长度,值表示对应价格

function cut_rod(n) {
  if(n === 1) return 1;
  
  let max = p[n];
  for(let i = 1; i < n; i++){
    let sum = cut_rod(i) + cut_rod(n - i);
    if(sum > max) {
      max = sum;
    }
  }
  
  return max;
}

cut_rod(9);  // 返回 25

上面的公式还可以简化,假如我们长度9的最佳方案是切成2 3 2 2,用前面一种算法,第一刀将它切成2 75 4,然后两边再分别切最终都可以得到2 3 2 2,所以5 4方案最终结果和2 7方案是一样的,都会得到2 3 2 2,如果这两种方案,两边都继续切,其实还会有重复计算。那长度为9的切第一刀,左边的值肯定是1 -- 9,我们从1依次切过来,如果后面继续对左边的切割,那继续切割的那个左边值必定是我们前面算过的一个左边值。比如5 4切割成2 3 4,其实等价于第一次切成2 7,第一次如果是3 6,如果继续切左边,切为1 2 6,其实等价于1 8,都是前面切左边为1的时候算过的。所以如果我们左边依次是从1切过来的,那么就没有必要再切左边了,只需要切右边。所以我们的公式可以简化为:

$$ r_n = \max_{1<=i<=n}(pi+r_{n-i}) $$

继续用递归实现这个公式:

const p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30]; // 下标表示钢条长度,值表示对应价格

function cut_rod2(n) {
  if(n === 1) return 1;
  
  let max = p[n];
  for(let i = 1; i <= n; i++){
    let sum = p[i] + cut_rod2(n - i);
    if(sum > max) {
      max = sum;
    }
  }
  
  return max;
}

cut_rod2(9);  // 结果还是返回 25

上面的两个公式都是递归,复杂度都是指数级的,下面我们来讲讲动态规划的方案。

动态规划方案

动态规划方案的公式和前面的是一样的,我们用第二个简化了的公式:

$$ r_n = \max_{1<=i<=n}(pi+r_{n-i}) $$

动态规划就是不用递归,而是从底向上计算值,每次计算上面的值的时候,下面的值算好了,直接拿来用就行。所以我们需要一个数组来记录每个长度对应的最大收益。

const p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30]; // 下标表示钢条长度,值表示对应价格

function cut_rod3(n) {
  let r = [0, 1];   // r数组记录每个长度的最大收益
  
  for(let i = 2; i <=n; i++) {
    let max = p[i];
    for(let j = 1; j <= i; j++) {
      let sum = p[j] + r[i - j];
      
      if(sum > max) {
        max = sum;
      }
    }
    
    r[i] = max;
  }
  
  console.log(r);
  return r[n];
}

cut_rod3(9);  // 结果还是返回 25

我们还可以把r数组也打出来看下,这里面存的是每个长度对应的最大收益:

r = [0, 1, 5, 8, 10, 13, 17, 18, 22, 25]

使用动态规划将递归的指数级复杂度降到了双重循环,即$O(n^2)$的复杂度。

输出最佳方案

上面的动态规划虽然计算出来最大值,但是我们并不是知道这个最大值对应的切割方案是什么,为了知道这个方案,我们还需要一个数组来记录切割一次时左边的长度,然后在这个数组中回溯来找出切割方案。回溯的时候我们先取目标值对应的左边长度,然后右边剩下的长度又继续去这个数组找最优方案对应的左边切割长度。假设我们左边记录的数组是:

leftLength = [0, 1, 2, 3, 2, 2, 6, 1, 2, 3]

我们要求长度为9的钢条的最佳切割方案:

1. 找到leftLength[9], 发现值为3,记录下3为一次切割
2. 左边切了3之后,右边还剩6,又去找leftLength[6],发现值为6,记录下6为一次切割长度
3. 又切了6之后,发现还剩0,切完了,结束循环;如果还剩有钢条继续按照这个方式切
4. 输出最佳长度为[3, 6]

改造代码如下:

const p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30]; // 下标表示钢条长度,值表示对应价格

function cut_rod3(n) {
  let r = [0, 1];   // r数组记录每个长度的最大收益
  let leftLength = [0, 1];  // 数组leftLength记录切割一次时左边的长度
  let solution = [];
  
  for(let i = 2; i <=n; i++) {
    let max = p[i];
    leftLength[i] = i;     // 初始化左边为整块不切
    for(let j = 1; j <= i; j++) {
      let sum = p[j] + r[i - j];
      
      if(sum > max) {
        max = sum;
        leftLength[i] = j;  // 每次找到大的值,记录左边的长度
      } 
    }
    
    r[i] = max;
  }
  
  // 回溯寻找最佳方案
  let tempN = n;
  while(tempN > 0) {
    let left = leftLength[tempN];
    solution.push(left);
    tempN = tempN - left;
  }
  
  console.log(leftLength);  // [0, 1, 2, 3, 2, 2, 6, 1, 2, 3]
  console.log(solution);    // [3, 6]
  console.log(r);           // [0, 1, 5, 8, 10, 13, 17, 18, 22, 25]
  return {max: r[n], solution: solution};
}

cut_rod3(9);  // {max: 25, solution: [3, 6]}

最长公共子序列(LCS)

image-20200202214347127

上叙问题也可以用暴力穷举来求解,先列举出X字符串所有的子串,假设他的长度为m,则总共有$2^m$种情况,因为对于X字符串中的每个字符都有留着和不留两种状态,m个字符的全排列种类就是$2^m$种。那对应的Y字符串就有$2^n$种子串, n为Y的长度。然后再遍历找出最长的公共子序列,这个复杂度非常高,我这里就不写了。

我们观察两个字符串,如果他们最后一个字符相同,则他们的LCS(最长公共子序列简写)就是两个字符串都去掉最后一个字符的LCS再加一。因为最后一个字符相同,所以最后一个字符是他们的子序列,把他去掉,子序列就少了一个,所以他们的LCS是他们去掉最后一个字符的字符串的LCS再加一。如果他们最后一个字符不相同,那他们的LCS就是X去掉最后一个字符与Y的LCS,或者是X与Y去掉最后一个字符的LCS,是他们两个中较长的那一个。写成数学公式就是:

image-20200202220405084

看着这个公式,一个规模为(i, j)的问题转化为了规模为(i-1, j-1)的问题,这不就又可以用递归求解了吗?

递归方案

公式都有了,不废话,直接写代码:

function lcs(str1, str2) {
  let length1 = str1.length;
  let length2 = str2.length;
  
  if(length1 === 0 || length2 === 0) {
    return 0;
  }
  
  let shortStr1 = str1.slice(0, -1);
  let shortStr2 = str2.slice(0, -1);
  if(str1[length1 - 1] === str2[length2 -  1]){
    return lcs(shortStr1, shortStr2) + 1;
  } else {
    let lcsShort2 = lcs(str1, shortStr2);
    let lcsShort1 = lcs(shortStr1, str2);
    
    return lcsShort1 > lcsShort2 ? lcsShort1 : lcsShort2;
  }
}

let result = lcs('ABBCBDE', 'DBBCD');
console.log(result);   // 4

动态规划

递归虽然能实现我们的需求,但是复杂度是在太高,长一点的字符串需要的时间是指数级增长的。我们还是要用动态规划来求解,根据我们前面讲的动态规划原理,我们需要从小的往大的算,每算出一个值都要记下来。因为c(i, j)里面有两个变量,我们需要一个二维数组才能存下来。注意这个二维数组的行数是X的长度加一,列数是Y的长度加一,因为第一行和第一列表示X或者Y为空串的情况。代码如下:

function lcs2(str1, str2) {
  let length1 = str1.length;
  let length2 = str2.length;
  
  // 构建一个二维数组
  // i表示行号,对应length1 + 1
  // j表示列号, 对应length2 + 1
  // 第一行和第一列全部为0
  let result = [];
  for(let i = 0; i < length1 + 1; i++){
    result.push([]); //初始化每行为空数组
    for(let j = 0; j < length2 + 1; j++){
      if(i === 0) {
        result[i][j] = 0; // 第一行全部为0
      } else if(j === 0) {
        result[i][j] = 0; // 第一列全部为0
      } else if(str1[i - 1] === str2[j - 1]){
        // 最后一个字符相同
        result[i][j] = result[i - 1][j - 1] + 1;
      } else{
        // 最后一个字符不同
        result[i][j] = result[i][j - 1] > result[i - 1][j] ? result[i][j - 1] : result[i - 1][j];
      }
    }
  }
  
  console.log(result);
  return result[length1][length2]
}

let result = lcs2('ABCBDAB', 'BDCABA');
console.log(result);   // 4

上面的result就是我们构造出来的二维数组,对应的表格如下,每一格的值就是c(i, j),如果$X_i = Y_j$,则它的值就是他斜上方的值加一,如果$X_i \neq Y_i$,则它的值是上方或者左方较大的那一个。

image-20200202224206267

输出最长公共子序列

要输出LCS,思路还是跟前面切钢条的类似,把每一步操作都记录下来,然后再回溯。为了记录操作我们需要一个跟result二维数组一样大的二维数组,每个格子里面的值是当前值是从哪里来的,当然,第一行和第一列仍然是0。每个格子的值要么从斜上方来,要么上方,要么左方,所以:

1. 我们用1来表示当前值从斜上方来
2. 我们用2表示当前值从左方来
3. 我们用3表示当前值从上方来

看代码:

function lcs3(str1, str2) {
  let length1 = str1.length;
  let length2 = str2.length;
  
  // 构建一个二维数组
  // i表示行号,对应length1 + 1
  // j表示列号, 对应length2 + 1
  // 第一行和第一列全部为0
  let result = [];
  let comeFrom = [];   // 保存来历的数组
  for(let i = 0; i < length1 + 1; i++){
    result.push([]); //初始化每行为空数组
    comeFrom.push([]);
    for(let j = 0; j < length2 + 1; j++){
      if(i === 0) {
        result[i][j] = 0; // 第一行全部为0
        comeFrom[i][j] = 0;
      } else if(j === 0) {
        result[i][j] = 0; // 第一列全部为0
        comeFrom[i][j] = 0;
      } else if(str1[i - 1] === str2[j - 1]){
        // 最后一个字符相同
        result[i][j] = result[i - 1][j - 1] + 1;
        comeFrom[i][j] = 1;      // 值从斜上方来
      } else if(result[i][j - 1] > result[i - 1][j]){
        // 最后一个字符不同,值是左边的大
        result[i][j] = result[i][j - 1];
        comeFrom[i][j] = 2;
      } else {
        // 最后一个字符不同,值是上边的大
        result[i][j] = result[i - 1][j];
        comeFrom[i][j] = 3;
      }
    }
  }
  
  console.log(result);
  console.log(comeFrom);
  
  // 回溯comeFrom数组,找出LCS
  let pointerI = length1;
  let pointerJ = length2;
  let lcsArr = [];   // 一个数组保存LCS结果
  while(pointerI > 0 && pointerJ > 0) {
    console.log(pointerI, pointerJ);
    if(comeFrom[pointerI][pointerJ] === 1) {
      lcsArr.push(str1[pointerI - 1]);
      pointerI--;
      pointerJ--;
    } else if(comeFrom[pointerI][pointerJ] === 2) {
      pointerI--;
    } else if(comeFrom[pointerI][pointerJ] === 3) {
      pointerJ--;
    }
  }
  
  console.log(lcsArr);   // ["B", "A", "D", "B"]
  //现在lcsArr顺序是反的
  lcsArr = lcsArr.reverse();
  
  return {
    length: result[length1][length2], 
    lcs: lcsArr.join('')
  }
}

let result = lcs3('ABCBDAB', 'BDCABA');
console.log(result);   // {length: 4, lcs: "BDAB"}

最短编辑距离

这是leetcode上的一道题目,题目描述如下:

image-20200209114557615

这道题目的思路跟前面最长公共子序列非常像,我们同样假设第一个字符串是$X=(x_1, x_2 ... x_m)$,第二个字符串是$Y=(y_1, y_2 ... y_n)$。我们要求解的目标为$r$, $r[i][j]$为长度为$i$的$X$和长度为$j$的$Y$的解。我们同样从两个字符串的最后一个字符开始考虑:

  1. 如果他们最后一个字符是一样的,那最后一个字符就不需要编辑了,只需要知道他们前面一个字符的最短编辑距离就行了,写成公式就是:如果$Xi = Y_j$,$r[i][j] = r[i-1][j-1]$。
  2. 如果他们最后一个字符是不一样的,那最后一个字符肯定需要编辑一次才行。那最短编辑距离就是$X$去掉最后一个字符与$Y$的最短编辑距离,再加上最后一个字符的一次;或者是是$Y$去掉最后一个字符与$X$的最短编辑距离,再加上最后一个字符的一次,就看这两个数字哪个小了。这里需要注意的是$X$去掉最后一个字符或者$Y$去掉最后一个字符,相当于在$Y$上进行插入和删除,但是除了插入和删除两个操作外,还有一个操作是替换,如果是替换操作,并不会改变两个字符串的长度,替换的时候,距离为$r[i][j]=r[i-1][j-1]+1$。最终是在这三种情况里面取最小值,写成数学公式就是:如果$Xi \neq Y_j$,$r[i][j] = \min(r[i-1][j], r[i][j-1],r[i-1][j-1]) + 1$。
  3. 最后就是如果$X$或者$Y$有任意一个是空字符串,那为了让他们一样,就往空的那个插入另一个字符串就行了,最短距离就是另一个字符串的长度。数学公式就是:如果$i=0$,$r[i][j] = j$;如果$j=0$,$r[i][j] = i$。

上面几种情况总结起来就是

$$ r[i][j]= \begin{cases} j, & \text{if}\ i=0 \\ i, & \text{if}\ j=0 \\ r[i-1][j-1], & \text{if}\ X_i=Y_j \\ \min(r[i-1][j], r[i][j-1], r[i-1][j-1]) + 1, & \text{if} \ X_i\neq Y_j \end{cases} $$

递归方案

老规矩,有了递推公式,我们先来写个递归:

const minDistance = function(str1, str2) {
    const length1 = str1.length;
    const length2 = str2.length;

    if(!length1) {
        return length2;
    }

    if(!length2) {
        return length1;
    }

    const shortStr1 = str1.slice(0, -1);
    const shortStr2 = str2.slice(0, -1); 

    const isLastEqual = str1[length1-1] === str2[length2-1];

    if(isLastEqual) {
        return minDistance(shortStr1, shortStr2);
    } else {
        const shortStr1Cal = minDistance(shortStr1, str2);
        const shortStr2Cal = minDistance(str1, shortStr2);
        const updateCal = minDistance(shortStr1, shortStr2);

        const minShort = shortStr1Cal <= shortStr2Cal ? shortStr1Cal : shortStr2Cal;
        const minDis = minShort <= updateCal ? minShort : updateCal;

        return minDis + 1;
    }
}; 

//测试一下
let result = minDistance('horse', 'ros');
console.log(result);  // 3

result = minDistance('intention', 'execution');
console.log(result);  // 5

动态规划

上面的递归方案提交到leetcode会直接超时,因为复杂度太高了,指数级的。还是上我们的动态规划方案吧,跟前面类似,需要一个二维数组来存放每次执行的结果。

const minDistance = function(str1, str2) {
    const length1 = str1.length;
    const length2 = str2.length;

    if(!length1) {
        return length2;
    }

    if(!length2) {
        return length1;
    }

    // i 为行,表示str1
    // j 为列,表示str2
    const r = [];
    for(let i = 0; i < length1 + 1; i++) {
        r.push([]);
        for(let j = 0; j < length2 + 1; j++) {
            if(i === 0) {
                r[i][j] = j;
            } else if (j === 0) {
                r[i][j] = i;
            } else if(str1[i - 1] === str2[j - 1]){ // 注意下标,i,j包括空字符串,长度会大1
                r[i][j] = r[i - 1][j - 1];
            } else {
                r[i][j] = Math.min(r[i - 1][j ], r[i][j - 1], r[i - 1][j - 1]) + 1;
            }
        }
    }

    return r[length1][length2];
};

//测试一下
let result = minDistance('horse', 'ros');
console.log(result);  // 3

result = minDistance('intention', 'execution');
console.log(result);  // 5

上述代码因为是双重循环,所以时间复杂度是$O(mn)$。

总结

动态规划的关键点是要找出递推式,有了这个递推式我们可以用递归求解,也可以用动态规划。用递归时间复杂度通常是指数级增长,所以我们有了动态规划。动态规划的关键点是从小往大算,将每一个计算记过的值都记录下来,这样我们计算大的值的时候直接就取到前面计算过的值了。动态规划可以大大降低时间复杂度,但是增加了一个存计算结果的数据结构,空间复杂度会增加。这也算是一种用空间换时间的策略了。

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

欢迎关注我的公众号进击的大前端第一时间获取高质量原创~

“前端进阶知识”系列文章源码地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二维码_2.png

查看原文

赞 19 收藏 11 评论 0

蒋鹏飞 赞了文章 · 1月8日

思否有约丨@皮小蛋:深漂多年,只想早日上岸

访谈嘉宾:@皮小蛋
访谈编辑:芒果果


能不能接受 996?

回老家还是去大城市?

要去大企业还是小公司?

…...

年轻人面临的艰难选择太多了,每一个不同的决定都有可能带你走上完全不同的人生轨迹。

image

行业内卷严重,新人如何才能出头?

27 岁的皮小蛋是个“深漂”,从 2016 年毕业后,他就只身到了深圳。经历了几年“社会的毒打”后,皮小蛋现在已经可以坐在面试官的岗位考核即将入职的新员工了。

皮小蛋说,“深圳, 是一座种充满活力的城市。 竞争很大, 但机会也很多。其他方面,感觉都挺好的, 就是房价太高了。希望好好干几年,多多努力, 争取早日上岸。”

也许,皮小蛋这种状态就是很多年轻人梦寐以求的,能在大城市有一份体面的工作,打拼几年后再买一套房子,站稳脚跟。

但每个人的情况不同,职业选择也会不同。互联网行业发展的越来越快,投身到IT行业的人越来越多,个人想要做的出彩,出人头地, 确实比较难。而且,企业对人才的要求也在不断变化, 门槛越来越高。程序员行业的“内卷”愈来愈严重,比如 996、11 12 7 的工作制,和随处可见的倒挂等等。

作为有一定经验的面试官,皮小蛋也提出了自己的看法。

对研发而言,主要是两类人比较吃香:

  1. 有潜力, 爱学习,态度好的初中级工程师。
  2. 经验丰富,独当一面, 具备一定管理能力的高级工程师。

对个人而言,有几点非常重要:

  1. 保持学习,提升专业素质,形成自己的核心竞争力。
  2. 保持一个好的心态, 延迟满足感。
  3. 不要给自己设限, 你可以是专业的工程师, 也可以是魔术师, 投资小能手。

用代码行数考核绩效?工作要有自己的准则

当然了,在工作当中皮小蛋也经历过一些令他怀疑人生的瞬间。当听到老板说要通过代码行数来衡量程序员的绩效的那一刻,他的脑子里闪出了一行字“还有这种操作?”

原以为只是段子里的事情居然真的发生了,而且就发生在自己身边,这让皮小蛋和他的同事都无法接受。程序员这个职业不能单纯通过代码行数来衡量业绩,一些算法优化可能就是调调参数, 并没有多少代码, 不能作为绩效考核的依据。好在在大家强烈的反对下,用代码行数考核业绩的方式最终没有实行。

对待工作,皮小蛋有自己的一条准则:及时、紧凑、可预判、避免惊喜。具体到工作事项中,就是利用碎片化的时间用来处理消息、邮件,同步进度,整块的时间用来开会、编码等需要投入的事情。同时,每件事的进度也要有一套标准来进行管理。

寻找爱好也是一种疏解压力的方式,别让生活太紧绷

工作不是全部,奋斗也是为了生活,压力太大时也需要放松身心。皮小蛋养了一只叫“皮蛋”的小猫,这也是他笔名的来源。平时,他会和皮蛋一起玩,或者在家看书、弹钢琴,偶尔也会骑上山地车来一次短途骑行。

image.png

寻找爱好也是一种疏解压力的方式。大学毕业前夕,在所有人都忙着考研或者找工作的时候,皮小蛋却用这段时间开始学起了钢琴。那是一个普通的周末,正在路口等车的皮小蛋突然听到了一段悠扬的旋律,他瞬间就被吸引了,车也不等了,就开始顺着声音寻找。

最后,在一个角落里的二楼找到了一家琴行,皮小蛋想都没想就当场交钱开始了学琴生涯。毕业的第二年,他已经考到了中央音乐学院的 3 级钢琴证书。

到现在,他都会在每天午饭后到公司楼下去练一会儿琴。弹钢琴既是他的长期爱好,也是他舒缓解压的方式之一。


无论选择在大城市还是回老家,去大企业还是小公司,最重要的是有想法、有能力、意愿,在自己的岗位上发挥最大的能力。

但同时,也别忘了生活,可以学个乐器、养个宠物、甚至只是去公园里散散步,为生活添点生气和乐趣。

希望每一个“漂着”的年轻人都能早日“上岸”。


欢迎有兴趣参与访谈的小伙伴踊跃报名,《思否有约》将把你与编程有关的故事记录下来。报名邮箱:mango@sifou.com

segmentfault 公众号

查看原文

赞 11 收藏 1 评论 0

蒋鹏飞 关注了用户 · 1月7日

木马啊 @wintc

想把代码写成诗的未成名作家

欢迎交流技术问题。
微信:win_tc

个人网站: https://wintc.top

关注 116

蒋鹏飞 关注了用户 · 1月7日

高阳Sunny @sunny

SegmentFault 思否 CEO
C14Z.Group Founder
Forbes China 30U30

独立思考 敢于否定

曾经是个话痨... 要做一个有趣的人!

任何问题可以给我发私信或者发邮件 sunny@sifou.com

关注 2148

蒋鹏飞 赞了文章 · 1月6日

而立之年——回顾我的前端转行之路

为什么转行

因为混得不好。

在成为程序员之前,我干过很多工作。由于学历的问题(高中),我的工作基本上都是体力活。包括但不限于:工厂普工、销售(没有干销售的才能)、搬运工、摆地摊等,转行前最后一份工作是修电脑。这么多年,月薪没高过 3300...

后来偶然一个机会我发现了知乎这个网站,在上面了解到程序员的各种优点。于是,我下定决心转行(2016 年,当时 28 了),辞职在家自学编程。并且也得到了媳妇的支持,感谢我的媳妇。

转行准备

转行选择前端也是在知乎上看网友分析的,比后端好入门。

如何选择教程?

最好在网上多查查资料,找评价高的或者去豆瓣上找评分高的书。

我在网上查了很多资料,最终确定 HTML、CSS 在 w3cschool 学习。JavaScript 则选择了JavaScript 高级程序设计第三版(俗称红宝书,现在已经有第四版了)。

光看不练是学不好编程的,我非常幸运的遇到了百度前端技术学院。它从易到难设置了 52 个任务,共分为四个阶段。任务难度循序渐进,每一个任务都有清晰的讲解和学习参考资料。它还怕你不会做,允许你查看其他人上传的任务答案。

我先学习了 HTML、CSS,做完了第一阶段任务。再看完红宝书前十三章,做完了第二阶段任务。然后把红宝石剩下的全看完,做到第三阶段的任务四十五。后面的任务对于当时的我来说实在太难了,就没往下做。在 1 月的时候,又学习了 ajax,了解了前后端如何相互通信。

我从 16 年 11 月开始自学前端,一直到 17 年 2 月。历时 3 个月,平均每天学习 3-4 个小时。中间有好几次因为太难想过放弃,不过最后还是坚持下来了。

找工作的过程非常艰难,我在网上各大招聘平台投了很多简历,但由于没学历、没经验,所以一个回复都没有。最后还是我媳妇工作的公司在招前端,给了我一个内推的机会,才有了第一次面试。并且第一次面试也很顺利,居然过了,这是我没想到的。直到多年后我和面试官又在一个公司的时候,才知道原因。他的意思是:看在我这么努力自学编程的份上,愿意给我一个机会。

虽然人生很艰难,但很有可能,遇到一个愿意给你机会的人,就能改变你的命运。

正式工作

第一年

在正式的项目中写代码和在学习时写代码是不一样的。你必须得考虑这样写安不安全,会不会引起 BUG,会不会引起性能问题。在工作的第一年,写业务代码对我的提升非常大。

第一年的主要任务,就是提升前端基础能力。因此我看了很多 JavaScript 的书籍来提升自己的水平:

  1. JavaScript高级程序设计(第三版)
  2. 高性能JavaScript
  3. JavaScript语言精粹
  4. 你不知道的JavaScript(上中下三卷)
  5. ES6标准入门
  6. 深入浅出Node.js

这些书都是非常经典的书籍,有几本我还看了好几篇。

除了看书外,我还做了百度前端技术学院 2017 年的任务,它比 2016 年的任务(转行时做的是 2016 年的任务)更有难度和深度,非常适合进阶。

另外还学习了 jquery 和 nodejs。jquery 是工作中要用,nodejs 则是出于兴趣学习的,没有多深入。

第二年

到了第二年,写业务代码对于我来说,已经提升不大了,就像一个熟练工一样。而且感觉前端方面掌握的知识已经足够把工作做好了。于是我就想,为了成为一名顶尖的程序员,还需要做什么。我在网上查了很多资料,看了很多前辈的回答,最后决定自学计算机专业。

我制定了一个自学计算机专业的计划,并且减少花在前端上的时间。因为说到底,基础是地基。基础打好了,楼才能建得高。

计算机系统要素

计算机系统要素是我制订计划后开始学习的第一本书。它主要讲解了计算机原理(1-5章)、编译原理(6-11章)、操作系统相关知识(12章)。不要看内容这么多,其实这本书的内容非常通俗易懂,翻译也很给力。每一章后面都有相关的实验,需要你手写代码去完成,堪称理论与实践结合的经典。

这里引用一下书里的简介,大家可以感受一下:

本书通过展现简单但功能强大的计算机系统之构建过程,为读者呈现了一幅完整、严格的计算机应用科学大图景。本书作者认为,理解计算机工作原理的最好方法就是亲自动手,从零开始构建计算机系统。

通过12个章节和项目来引领读者从头开始,本书逐步地构建一个基本的硬件平台和现代软件阶层体系。在这个过程中,读者能够获得关于硬件体系结构、操作系统、编程语言、编译器、数据结构、算法以及软件工程的详实知识。通过这种逐步构造的方法,本书揭示了计算机科学知识中的重要成分,并展示其它课程中所介绍的理论和应用技术如何融入这幅全局大图景当中去。

全书基于“先抽象再实现”的阐述模式,每一章都介绍一个关键的硬件或软件抽象,一种实现方式以及一个实际的项目。完成这些项目所必要的计算机科学知识在本书中都有涵盖,只要求读者具备程序设计经验。本书配套的支持网站提供了书中描述的用于构建所有硬件和软件系统所必需的工具和资料,以及用于12个项目的200个测试程序。

全书内容广泛、涉猎全面,适合计算机及相关专业本科生、研究生、技术开发人员、教师以及技术爱好者参考和学习。

做完这些实验,让我有了一个质的提升。以前感觉计算机就是一个黑盒,但现在不一样了。我开始了解计算机内部是如何运作的。明白了自己写的代码是怎么经过编译变成指令,最后在 CPU 中执行的。也明白了指令、数据怎么在 CPU 和内存之间流转的。

这本书所有实验的答案我都放在了 github 上,有兴趣不妨了解一下。

Vue

这一年还学会了 Vue。除了熟读文档外,还为了研究源码而模仿 Vue1.0 版本写了一个 mini-vue。不过学习源码对于我写业务代码并没有什么帮助。如果不是出于兴趣去研究源码,最好不要去学,熟读文档就能完全应付工作了。如果是为了面试,那也不需要阅读源码。只需要在网上找一些质量高的源码分析文章看一看,作一下笔记就 OK 了。

为什么我不建议阅读源码?因为阅读源码效率太低,而且相对于你的付出,收益并不大。到后面 Vue 出了 3.0 版本时,我也是有选择地阅读部分源码。

第三年

第三年有大半年的时间浪费在王者荣耀上,那会天天只想着冲荣耀,根本没心思学习。后来终于醒悟过来了,王者荣耀是我成为顶级程序员的阻碍。于是痛定思痛,给戒掉了。

由于打王者的原因,第三年没学习多少新知识。基本上只做了三件事:

  1. 写了几个 Vue 相关的插件和项目。
  2. 将过去所学的前端知识,整理了一下放在 github 上,有空就复习一下。
  3. 学习数据结构与算法。

数据结构与算法

数据结构和算法有什么用?学了算法后,我觉得至少会懂得去分析程序的性能问题。

一个程序的性能有问题,需要你去优化。如果学过数据结构和算法,你会从时间复杂度和空间复杂度去分析代码,然后解决问题。如果没学过,你只能靠猜、碰运气来解决问题。

理论知识上,我主要看的是算法这本书,课后习题没做,改成用刷 leetcode 代替。目前已经刷了 300+ 道题,还在继续刷。不过由于数学差,稍微复杂一点的算法知识都看不懂,效果不是很好。

第四年

第四年,也就是今年(2020),是我重新奋斗的一年。今年比以往的任何一年都要努力,每天保证 3 小时以上的学习时间。如果实在太忙了,达不到要求,那就改天把时间补上。附上我今年的学习时长图(记录软件为 Now Then):

今年我做了非常多的事情:

  1. 研究前端工程化。
  2. 学习操作系统。
  3. 学习计算机网络。
  4. 学习软件工程。
  5. 学习 C++。
  6. 学英语。

前端工程化

研究前端工程化的目的,就是为了提高团队的开发效率。为此我看了很多书和资料:

...

研究了一年的时间,写了一篇质量较高的入门教程——手把手带你入门前端工程化——超详细教程。除此之外,还有其他工程化相关的一系列文章:

操作系统

操作系统是管理计算机硬件与软件资源的计算机程序。通常情况下,程序是运行在操作系统上的,而不是直接和硬件交互。一个程序如果想和硬件交互就得通过操作系统。

如果你掌握了操作系统的知识,你就知道程序是怎么和硬件交互的。

例如你知道申请内存,释放内存的内部过程是怎样的;当你按下 k 键,你也知道 k 是怎么出现在屏幕上的;知道文件是怎么读出、写入的。

对于操作系统,我主要学习了以下书籍:

  1. x86汇编语言:从实模式到保护模式
  2. xv6-chinese
  3. 操作系统导论

然后做 MIT6.828 的实验,实现了一个简单的操作系统内核。

计算机网络

计算机网络的作用主要是解决计算机之间如何通信的问题。

例如 A 地区和 B 地区的的计算机怎么通信?同一局域网的两台电脑又如何通信?学习计算机网络知识就是了解它们是怎么通信的以及怎么将它们联通起来。

对于计算机网络,我主要学习了以下书籍:

  1. 计算机网络--自顶向下
  2. 计算机网络
  3. HTTP权威指南
  4. HTTP/2基础教程

并且做了计算机网络--自顶向下的实验。

软件工程

软件工程是一门研究用工程化方法构建和维护有效的、实用的和高质量的软件的学科。它涉及程序设计语言、数据库、软件开发工具、系统平台、标准、设计模式等方面。

学习以下书籍:

  1. 代码大全(第2版)
  2. 重构(第2版)
  3. 软件工程

软件工程是一门非常庞大的学科,我只学习了一点皮毛。主要学习的是关于代码怎么写得更好、结构组织更合理的知识,这需要一边学习一边在工作中运用。

C++

学习 C++ 其实是为了研究 nodejs 源码用的,看的这本书C++ Primer 中文版(第 5 版)

英语

我从转行开始就一直在学习英语,不过今年花的时间比较多。

英语对于程序员的好处非常非常多,就我知道的有:

  1. 可以用 google 和 stackoverflow 来解决问题。
  2. 知道怎么给变量、函数起一个好的命名。
  3. 很多流行的软件都是国外程序员写的,有问题你可以直接看文档以及和别人交流。

在我转行前英语词汇量只有几百,三年多过去了,现在词汇量有 6000(都是用百词斩测的)。

写作

写作的好处是非常多的,越早写越好。我还记得第一篇文章是 2017 年 2 月发表的,是我工作后的第 13 天,发表在 CSDN 上。

个人认为写作的好处有三点:

  1. 锻炼你的写作能力。一般情况下,写得越多,写作能力越好。这个好,不是说你的文章遣词造句有多好,而是指文章条理清晰,通俗易懂,容易让人理解。
  2. 写作其实是费曼学习法的运用,帮助自己加深理解所学的知识。有没有试过,学完一个知识点后,觉得自己懂了。但让你向别人讲述这个知识点时,反而吞吞吐吐不知道怎么讲。其实这是没理解透才会这样的,要让别人明白你在表达什么,首先你得非常熟悉这个知识点。一知半解是不可能把它讲明白的,所以写作也是在帮你梳理知识。
  3. 增加自己的曝光度。在我三年多的程序员生涯中,一共写了 50 多篇文章,因此在一些平台上也收获了不少赞和粉丝。因为我写的某些文章质量还行,不少大厂的程序员找过我,给我内推。不过由于个人学历问题,基本上都没下文...

总之一句话,写作对你只有好处,没有坏处。

学习

有选择的学习

我觉得学习一定要有非常清晰的目标,知道你要学什么,怎么学。对于前端来说,我认为很多框架和库都是不用学的。例如前端三大框架,没有必要三个都学,把你工作中要用的那个掌握好就行。

比如你公司用的是 Vue,就深入学习 Vue,如果要看源码就只看重点部分的源码。例如模板编译、Diff 算法、Vue 原生组件实现、指令实现等等。

剩下的两个框架 React、Angular 做个 DEMO 熟悉一下就行,毕竟原理都是相通的。等你公司要上这两个再深入学习,不过也不建议阅读源码了,太累。看别人写的现成的源码分析文章就好。

其他的,像 easyui、Backbone.js、各种小程序... 用不到的坚决不学,浪费时间。用的时候看文档就行了,当然,如果有兴趣了解如何实现也是可以的。

学习方法

我觉得好的学习方法非常重要,对我比较有用的两个是:

  1. 费曼学习法。
  2. 学习一个知识点,最好把它吃透。

费曼学习法在《写作》一节中已经说过了,这里着重说说第二个。

你有没有过这种感觉:觉得自己会的东西很多,但其实掌握的知识很多都停留在表面上,别人要是往深一问,就懵逼了。

我以前就有过这种感觉,主要问题出在对知识的学习仅停留在浅尝即止的状态。就是学习新知识,能写个 DEMO,就觉得自己学得差不多了。这种学习方法是很有害的,首先知识存留度不高,其次是浪费时间,因为很快就会忘掉。

后来我尝试改正这种状态,在学习新的知识点时,时常问自己三个问题:

  1. 这是什么?
  2. 为什么要这样?可以不这样吗?
  3. 有没有更好的方式?

当然,不是所有问题都能适用灵魂三问,但它适用大多数情况。

举个例子:看过性能优化相关文章的同学应该知道有这么一条规则,要减少页面上的 HTTP 请求。

这是什么?

先了解一下 HTTP 请求是啥,查资料发现原来是向服务器请求资源用的。

为什么要减少 HTTP 请求?

查资料发现:HTTP 请求需要经历 DNS 查找,TCP 握手,SSL 握手(如果有的话)等一系列过程,才能真正发出这个请求。并且现代浏览器对于 TCP 并发数也是有限制的,超过 TCP 并发数的 HTTP 请求只能等前面的请求完成了才能继续发送。

我们可以打开 chrome 开发者工具看一下一个 HTTP 请求所花费的具体时间。

在这里插入图片描述

这是一个 HTTP 请求,请求的文件大小为 28.4KB。

名词解释:

  1. Queueing: 在请求队列中的时间。
  2. Stalled: 从TCP 连接建立完成,到真正可以传输数据之间的时间差,此时间包括代理协商时间。
  3. Proxy negotiation: 与代理服务器连接进行协商所花费的时间。
  4. DNS Lookup: 执行DNS查找所花费的时间,页面上的每个不同的域都需要进行DNS查找。
  5. Initial Connection / Connecting: 建立连接所花费的时间,包括TCP握手/重试和协商SSL。
  6. SSL: 完成SSL握手所花费的时间。
  7. Request sent: 发出网络请求所花费的时间,通常为一毫秒的时间。
  8. Waiting(TFFB): TFFB 是发出页面请求到接收到应答数据第一个字节的时间总和,它包含了 DNS 解析时间、 TCP 连接时间、发送 HTTP 请求时间和获得响应消息第一个字节的时间。
  9. Content Download: 接收响应数据所花费的时间。

从这个例子可以看出,真正下载数据的时间占比为 13.05 / 204.16 = 6.39%。文件越小,这个比例越小,文件越大,比例就越高。这就是为什么要建议将多个小文件合并为一个大文件,从而减少 HTTP 请求次数的原因。

有没有更好的方式?

使用 HTTP2,所有的请求都可以放在一个 TCP 连接上发送。HTTP2 还有好多东西要学,这里不深入讲解了。

经过灵魂三问后,是不是这条优化规则的来龙去脉全都理清了,并且在你查资料动手的过程中,知识会理解得更加深刻。

掌握了这种学习方法,并且时刻运用在学习中、工作中,突破瓶颈只是时间的问题

总结

下面提前回答一下可能会有的问题。

百度前端技术学院

百度前端技术学院 2017 年及往后的任务,如果没有报名,那就只能做部分任务。2016 年的任务则由于百度服务器的问题,很多题的示例图都裂了。这个其实是有解决方案的,那就是看别人的答案。把别人的源码下载下来,用浏览器打开 html 文件当示例图看。这两年的任务我都做了大部分,附上答案:

  1. 百度前端技术学院2016任务
  2. 百度前端技术学院2017任务

学历提升

我从 18 年开始,已经报考了成人高考大专,19 年报了自考本科。大专明年 1 月就能毕业,自考本科比较难,可能 2021 年或 2022 年才能考下来。

写在最后

从转行到现在,已经过去 3 年多了。不得不说转行当程序员给了我人生第二次机会,我也很喜欢这个职业。不过这几年一直都是在小公司,导致自己的技术和视野得不到很大的提升。所以现在的目标除了学习计算机专业外,就是进大厂,希望有一天能实现。

虽然今年已经 32 了,但我对未来仍然充满希望。努力地学习,努力地提升自己,为了成为一名顶尖的程序员而努力。

查看原文

赞 55 收藏 24 评论 17

蒋鹏飞 发布了文章 · 1月5日

工作都是公司的,技术才是自己的!| 底层技术人的2020年度总结

本文参与了 SegmentFault 思否征文「2020 总结」,欢迎正在阅读的你也加入。

昨日听闻某公司98年姑娘的噩耗,再结合自己的经历,深感:工作都是公司的,健康和技术这些才是自己的!拼命为资本打工,到头来不过是肥了资本家,苦了自己!愿天堂没有资本家的压榨!

最近平台在征文,写年度总结,我也心里痒痒。但是看了各位大佬的总结:

  1. 粉丝数万
  2. 阅读量上百万
  3. GitHub几千star
  4. 副业月入上万
  5. 炒股还能再赚几十万

一度吓得我不敢动笔,因为这些我都没有!!我只是一个被焦虑驱赶着的底层技术人😭

我的2020是从糟糕和愤怒中开始的,一切都源于一次绩效谈话:

领导:“Dennis,今年你的绩效非常好,涨薪是xxx”

我:“哦。。。” (没有升职,涨薪不过是公司平均数,虽然对这个结果有过预期,但是心里还是很难受)

领导:“你有什么想问或者想说的吗?”

我:“没有。。。” (说什么呢,你这只是通知我结果,又不是跟我商量,我说啥有用吗,还不如不说,难不成骂你一顿?)

领导:“嗯。。。Dennis。。。大家虽然都知道你技术很厉害,但是都是你周围的同事知道,有时候跟上面领导搞好关系也是很重要的!”

我:“哦。。。”(你是也觉得这个对我不公平吗,所以补这么一句,我没问原因,我也不想知道原因,你还补这么一句,是觉得准备了一堆PUA的话语没了用武之地吗?这是叫我接下来要注意跪舔领导,好升职加薪吗?)

这次谈话全程我没说几个字,但是心里很愤怒!我是2017年5月加入这家公司的,之前是在上海,因为结婚了,想着回成都安家就到成都找工作了。找的时候很随意,总共面试了半个月吧,拿了几个offer,包括某二线大厂,经过比较,觉得外企不加班,工作环境也还可以,薪资也能接受,就来了这里。

当时进来的时候是个新项目组,除了上面提到的领导,我是成都第一个前端,后面又加入了一些小伙伴,最多的时候,我们组前端有10个。我们这个项目组从一开始成立就是公司的开荒团队,基本都是做新项目,上面老板有了什么新想法都会扔给我们实现。使用的技术栈我们也没有决定权,基本都是美国架构团队决定的,所以第一个项目用的是Ember,估计很多小伙伴都没听说过,后面几个项目才引入了React,还用React-Native做过APP。因为东西基本是新的,我入职最早,而且技术上也有点底子,学东西也快,所以可以不要脸的说,后面加入的小伙伴或多或少我都带过,以致于坐我旁边的小伙伴说:“Dennis,我来这里两年多,感觉没学到什么东西,唯独从你身上学了不少!”

2017年这半年项目有点乱糟糟的,美国架构团队主导,还跟美国同事合作,有时差沟通不方便,效率很低,感觉没弄出什么名堂。2018年慢慢的我们有了些自主权,项目算是进入正轨了,这一年我也全身心投入工作,任务专挑难的,产出高,BUG还少。我们一个迭代三周,我经常是三周的活儿一周多就干完了,于是我又去领下个迭代的任务,最多的时候我完成的任务超前两个迭代😳还发生过其他小伙伴去领下个迭代任务的时候,发现被我领太多,他们没有了的情况。所以整个2018年谈绩效时,我非常好,从完成的任务量来看,我是组内第一,我自己估算了下,超第二名20% -- 30%,超最后一名可能有50% -- 80%,因为统计数据员工看不到,这个数字是我自己估算的。但是组内第一肯定是没跑的,领导也是确认的。

于是2019年初,谈2018绩效的时候,领导说:“Dennis,你今年的绩效非常好,明年再这样是可以升职的!”我当时听了还挺高兴,有点奔头了,嘿嘿~直到某天鼠标滑到了某同事的头像上,嗯?职位变了,升职了!就这么悄悄咪咪的升职了,别说庆祝了,连个官宣邮件都没有,等着组内小伙伴自己去寻宝?说实话心里有点难受,之前职级跟我一样的,我以为我能升职,但是却是别人,但是想到领导说的话,今年继续好好干,我明年也能升职,又有点释怀了,晚一年就晚一年吧。

结果2020年初谈2019绩效的时候就出现了开头那一幕,又没升职!虽然我绩效跟2018一样,也是非常好,但是有毛用!心里很愤怒,不解,当时也不知道PUA这词,现在看来遇到老PUA了!去年骗我继续努力卖命,今年再骗我去跪舔领导?

愤怒归愤怒,冷静后开始想出路:是我做错了吗?是的,我是有错,我太老实了,只知道埋头干活,除了直系领导,上面的领导我都不熟,甚至CEO跟CTO是谁我都不知道,只知道是个外国人,国籍我都不太清楚;是的,我是有错,不该太懦弱,上次没升职,心里不舒服没有说出来,让人觉得好欺负了,所以这次继续;是的,我是有错,太埋头于业务了,技术虽然能够满足业务需求,但是除了写点公司业务外,在其他技术方面没有进一步建树,以致于中途面某大厂,一败涂地。

我落后了

曾经将所有心思都放到公司上,总想着怎么快点,好点完成任务,技术没有深入,够用就行,但是当公司不认可你这种付出后,怎么办?我发现,我落后了!这几年我只出去面试过一次,是隔壁组领导离职后拉我去面的,是某一线大厂,一面过了,二面没过,二面问了些源码,我都不知道。光写业务去了,只会import,谁管他是怎么实现的。。。当时就感觉有点落后,但是好歹还有份工作,这个公司我绩效好,说不定很快就能混个技术经理当当,就没继续深究了。现在升职技术经理没戏了,这种落后的感觉让我焦虑了。再仔细看看,这几年都拿着公司的平均涨薪,而2017年我是降薪回成都的,结果就是我现在的薪资还没2017在上海的时候高,而当时留在上海的小伙伴已经是我两倍甚至三倍的工资了,我焦虑的头发都秃了。。。

破局

落后了就赶上来吧,怎么赶?我不知道!但是我必须得做点什么,做什么呢?跪舔领导,升职加薪?貌似也是条路,但是我不会,我也不想去学。学不来跪舔就学技术吧,毕竟业务做再多也是公司的,技术才是自己的,从头开始整理前端知识架构吧!我在网上找了些架构图谱,还买了些网课,也看了些别人写的知识架构。发现很多都是深度不够,有些前端知识架构一篇文章就写完了,我到现在已经写了几十篇,数十万字,都还没写完。。。很多网课目标客户也是一两年的新手前端,难道我看了这个出去告诉别人:“我学完了XX课程,会React,Node。。。”别闹,我2016年初开始转行做前端(之前还干了两年多测试),已经做了4年了,光这样,肯定达不到我的目标。

于是,我决定,我自己来写,将那些我曾经凌磨两可的知识深入嚼碎,自己写成文章。深度一定要够,怎么才算深度够,至少要比某些网课讲的深!有这么一件事,当时我在某网课看了节Promise课程,但是他只讲了用法,这对我没什么用,Promise我都用了几年了。那怎么比他写的深呢?自己实现一个Promise,并且要符合Promise/A+标准,我花了点时间,把这个实现了,并且发布到了思否:手写一个Promise/A+,完美通过官方872个测试用例。后来这个网课助教回访,问我对课程有没有什么建议,我说,Promise太浅了,用法我早就会了,至少也来个手写Promise。当时我并没有说我已经手写过了。。。后来他们又补了一节手写Promise的课程,内容我就没看了。还有件事,我发现很多课程讲Express.js的时候只讲用法,不讲源码,反而喜欢讲Koa.js的源码,于是我自己写了Express.js源码解析,也写了Koa.js源码解析,写完后才发现,Express.js源码比Koa.js复杂多了,怪不得大家都喜欢写Koa.js源码,而不写Express.js的。

之前面某大厂不是源码挂了吗,除了Express.js,我还去看了很多源码,我把看源码过程都写成了文章。React常用技术栈都写了:React FiberReduxReact-ReduxRedux-ThunkRedux-SagaReact-Router。当然还写了很多其他的源码解析和基础知识。我写文章的时候很多时候都是写给未来的自己看的,因为我学东西,经常有这样的感受,学的时候啥都看懂了,但是过了三五个月就忘了,还得重头再来学一次。为了让三五个月后的自己能快速看懂复习好,我写的文章尽量深入浅出,好理解,写的源码解析尽量层层递进,从简单入手,而不是一上来就是源码把人弄得晕头转向。不少朋友在评论时也提到这点了,说我的文章好理解,比喻恰当,由浅入深,层层递进,后面我会继续保持这个风格~

所以我这一年没有去抢活儿干了,完成本职工作就行了,多的时间都拿来补充我的知识架构,写文章了,我把这些文章都汇总了一个列表:前端进阶知识汇总

变数

绩效谈完,不久就发生了大家都知道的大事:新冠爆发了!刚开始时,主要在中国爆发,外国还没受太大影响,我们公司除了安排员工在家办公外也没啥变化。但是过了几个月就不一样了,大家都知道的,美国爆了!我们的主要客户都在北美,而且我们的主营业务是各种体育赛事的软件服务,那可是雪上加霜了,都这会儿了,谁还出来体育活动啊?最严重的一个季度我们营业额直接同比下降80%!裁员开始了,隔壁组项目上线第二天全部裁了。美国人太贵,美国办公室基本算关门了,之前帮我们技术选型Ember的那个架构师也走了,不知道是被裁的,还是主动离职的。但是我们组一个人没裁,估计是看我们是开荒队伍,公司对新项目还抱有一线希望,所以没动我们。

又过了几个月,公司业绩还没有起色,又有了碎言碎语,又要裁一波,于是我们组前端被裁了两个:开头找我谈绩效的领导和说从我身上学了东西的小伙伴!同时被裁的还有我领导的领导,以及我们组后端领导。这是什么操作?小兵留了一堆,领导全裁了!我们也不知道,我们也不敢问。但是打听到他们裁员补偿还不错,n+2,不到一年算一年,年终奖折算,年假也折算成钱,要知道我们年假起步就是15天,在职第三年20天,第五年25天,年假折算差不多又是个把月工资。这可把我们没被裁的馋坏了,反正升职无望,不如拿了大礼包换个地方东山再起。于是接下来的半年我们天天盼着被裁,但是这都2021年了还没实现。。。

收获

其实我开始写文章并没有期望什么收获,纯粹就是个人的知识总结。但是有不少小伙伴给我点赞,也有小伙伴开始关注我,刚刚还拿了思否2020年度“Top Writer”,这里感谢各位小伙伴的喜爱和思否平台的支持~

有些朋友看了我的文章后也会向我发出面试邀请,职位有大厂的前端工程师,也有小厂的前端负责人,但是我全部都拒绝了。不是我矫情,而是我还有件大事:我们的小美妞出生了!就是我女儿出生了,名字叫“静静”,来,静静,给大家笑一个:

image-20210102202538973

虽然对现在公司有很多怨念,但是工作时间那是100个满意。我在这里待了三年多,加班总共才一两天,有次周六加班还拿了双倍工资。平时基本是上午10点到公司,下午六点就走了,有时候有个事什么的,打个招呼四点多也可以走。而且年假还多,我2020年的年假是20天。另外家里如果有事走不开,打个招呼就可以在家办公。这么灵活的时间更好照顾家里,所以这一年我一个面试都没有参加。

所以总结下来,我的2020年主要收获就是:

  1. 小美妞一枚
  2. 原创“前端进阶知识”系列博文45篇
  3. 全网粉丝7000+
  4. 全网阅读量30W
  5. 博客项目在GitHub有近300 star,近100 fork
  6. 思否2020年度“Top Writer”
  7. 掘金4级“优秀作者”,还上了运营官的感谢列表
  8. 开源中国2020年度“优秀源创作者”

2021 flag

flag还是要有的,万一实现了呢:

  1. 完成“前端进阶知识”系列博文
  2. 再拿个"Top Writer"
  3. 认真运营公众号,争取粉丝突破5000,目前不到500
  4. 出一本书,实体的或者电子的都行
  5. 成为React Contributor
  6. 副业收入实现0的突破

最后感谢思否给的平台,祝思否越办越好~

也感谢各位伙伴的阅读,祝大家新的一年财运滚滚,心想事成~

另外随便关注下我的公众号呗,帮我实现2021的flag:进击的大前端,谢谢各位老铁~

查看原文

赞 26 收藏 7 评论 16

蒋鹏飞 关注了用户 · 1月4日

民工哥 @jishuroad

民工哥,10多年职场老司机的经验分享,坚持自学一路从技术小白成长为互联网企业信息技术部门的负责人。

我的新书:《Linux系统运维指南》

微信公众号:民工哥技术之路

民工哥:知乎专栏

欢迎关注,我们一同交流,相互学习,共同成长!!

关注 3015

蒋鹏飞 赞了文章 · 1月4日

SegmentFault 2020 年度 Top Writer发布,我看了下,我。。。

image.png

有一群活跃在 SegmentFault 思否社区的一群卓越的开发者,他们热衷于分享知识与经验,他们布道技术与未来,他们让众多开发者受益,他们叫「Top Writer」。

SegmentFault 思否根据社区用户行为大数据(如文章 & 问答发布数量、获得声望 & 点赞量等)综合分析,从「技术问答」和「专栏文章」两个维度进行了2020年度「Top Writer」的评选。

image.png

2020年就这样远去了,这么魔幻而又让我们难忘的一年,它注定会成为我们每个人人生当中不可磨灭的一年,因为,这一年的种种经历,种种困难,种种感动,种种突破,种种坚持,种种的超越自我。

没有一种坚持会被辜负!再见 2020

是的,你的每一种努力,第一种坚持,都不会被辜负,加油吧,努力的打工人!!!!

查看原文

赞 4 收藏 0 评论 10

蒋鹏飞 发布了文章 · 1月4日

使用mono-repo实现跨项目组件共享

本文会分享一个我在实际工作中遇到的案例,从最开始的需求分析到项目搭建,以及最后落地的架构的整个过程。最终实现的效果是使用mono-repo实现了跨项目的组件共享。在本文中你可以看到:

  1. 从接到需求到深入分析并构建架构的整个思考过程。
  2. mono-repo的简单介绍。
  3. mono-repo适用的场景分析。
  4. 产出一个可以跨项目共享组件的项目架构。

本文产出的架构模板已经上传到GitHub,如果你刚好需要一个mono-repo + react的模板,直接clone下来吧:https://github.com/dennis-jiang/mono-repo-demo

需求

需求概况

是这么个情况,我还是在那家外企供职,不久前我们接到一个需求:要给外国的政府部门或者他的代理机构开发一个可以缴纳水电费,顺便还能卖卖可乐的网站。主要使用场景是市政厅之类的地方,类似这个样子:

image-20201224162525774

这张图是我在网上随便找的某银行的图片,跟我们使用场景有点类似。他有个自助的ATM机,远处还有人工柜台。我们也会有自助机器,另外也会有人工柜台,这两个地方都可以交水电费,汽车罚款什么的,唯一有个区别是人工那里除了交各种账单,还可能会卖点东西,比如口渴了买个可乐,烟瘾犯了来包中华。

需求分析

上面只是个概况,要做下来还有很多东西需要细化,柜员使用的功能和客户自助使用的功能看起来差不多,细想下来区别还真不少:

  1. 无论是交账单还是卖可乐,我们都可以将它视为一个商品,既然卖商品那肯定有上架和下架的功能,也就是商品管理,这个肯定只能做在柜员端。
  2. 市政厅人员众多,也会有上下级关系,普通柜员可能没有权限上/下架,他可能只有售卖权限,上/下架可能需要经理才能操作,这意味着柜员界面还需要权限管理。
  3. 权限管理的基础肯定是用户管理,所以柜员界面需要做登陆和注册。
  4. 客户自助界面只能交账单不能卖可乐很好理解,因为是自助机,旁边无人值守,如果摆几瓶可乐,他可能会拿了可乐不付钱。
  5. 那客户自助交水电费需要登陆吗?不需要!跟国内差不多,只需要输入卡号和姓名等基本信息就可以查询到账单,然后线上信用卡就付了。所以客户界面不需要登陆和用户管理。

从上面这几点分析我们可以看出,柜员界面会多很多功能,包括商品管理,用户管理,权限管理等,而客户自助界面只能交账单,其他功能都没有。

原型设计

基于上面几点分析,我们的设计师很快设计了两个界面的原型。

这个是柜员界面的

image-20201224172006928

柜员界面看起来也很清爽,上面一个头部,左上角显示了当前机构的名称,右上角显示了当前用户的名字和设置入口。登陆/登出相关功能点击用户名可以看到,商品管理,用户管理需要点击设置按钮进行跳转。

这个是客户自助界面的

image-20201224172649189

这个是客户界面的,看起来基本是一样的,只是少了用户和设置那一块,卖的东西少了可乐,只能交账单。

技术

现在需求基本已经理清楚了,下面就该我们技术出马了,进行技术选型和架构落地。

一个站点还是两个站点?

首先我们需要考虑的一个问题就是,柜员界面和客户界面是做在一个网站里面,还是单独做两个网站?因为两个界面高度相似,所以我们完全可以做在一起,在客户自助界面隐藏掉右上角的用户和设置就行了。

但是这里面其实还隐藏着一个问题:柜员界面是需要登陆的,所以他的入口其实是登陆页;客户界面不需要登陆,他的入口应该直接就是售卖页。如果将他们做在一起,因为不知道是柜员使用还是客户使用,所以入口只能都是登录页,柜员直接登陆进入售卖页,对于客户可以单独加一个“客户自助入口”让他进入客户的售卖页面。但是这样用户体验不好,客户本来不需要登陆的,你给他看一个登录页可能会造成困惑,可能需要频繁求教工作人员才知道怎么用,会降低整体的工作效率,所以产品经理并不接受这个,要求客户一进来就需要看到客户的售卖页面。

而且从技术角度考虑,现在我们是一个if...else...隐藏用户和设置就行了,那万一以后两个界面差异变大,客户界面要求更花哨的效果,就不是简单的一个if...else...能搞定的了。所以最后我们决定部署两个站点,柜员界面和客户界面单独部署到两个域名上

组件重复

既然是两个站点,考虑到项目的可扩展性,我们创建了两个项目。但是这两个项目的UI在目前阶段是如此相似,如果我们写两套代码,势必会有很多组件是重复的,比较典型的就是上面的商品卡片,购物车组件等。其实除了上面可以看到这些会重复外,我们往深入想,交个水费,我们肯定还需要用户输入姓名,卡号之类的信息,所以点了水费的卡片后肯定会有一个输入信息的表单,而且这个表单在柜员界面和客户界面基本是一样的,除了水费表单外,还有电费表单,罚单表单等等,所以可以预见重复的组件会非常多。

作为一个有追求的工程师,这种重复组件肯定不能靠CV大法来解决,我们得想办法让这些组件可以复用。那组件怎么复用呢?提个公共组件库嘛,相信很多朋友都会这么想。我们也是这么想的,但是公共组件库有多种组织方式,我们主要考虑了这么几种:

单独NPM包

再创建一个项目,这个项目专门放这些可复用的组件,类似于我们平时用的antd之类的,创建好后发布到公司的私有NPM仓库上,使用的时候直接这样:

import { Cart } from 'common-components';

但是,我们需要复用的这些组件跟antd组件有一个本质上的区别:我们需要复用的是业务组件,而不是单纯的UI组件antdUI组件库为了保证通用性,基本不带业务属性,样式也是开放的。但是我这里的业务组件不仅仅是几个按钮,几个输入框,而是一个完整的表单,包括前端验证逻辑都需要复用,所以我需要复用的组件其实是跟业务强绑定的。因为他是跟业务强绑定的,即使我将它作为一个单独的NPM包发布出去,公司的其他项目也用不了。一个不能被其他项目共享的NPM包,始终感觉有点违和呢。

git submodule

另一个方案是git submodule,我们照样为这些共享组件创建一个新的Git项目,但是不发布到NPM仓库去骚扰别人,而是直接在我们主项目以git submodule的方式引用他。git submodule的基本使用方法网上有很多,我这里就不啰嗦了,主要说几个缺点,也是我们没采用他的原因:

  1. 本质上submodule和主项目是两个不同的git repo,所以你需要为每个项目创建一套脚手架(代码规范,发布脚本什么的)。
  2. submodule其实只是主项目保存了一个对子项目的依赖链接,说明了当前版本的主项目依赖哪个版本的子项目,你需要小心的使用git submodule update来管理这种依赖关系。如果没有正确使用git submodule update而搞乱了版本的依赖关系,那就呵呵了。。。
  3. 发布的时候需要自己小心处理依赖关系,先发子项目,子项目好了再发布主项目。

mono-repo

mono-repo是现在越来越流行的一种项目管理方式了,与之相对的叫multi-repomulti-repo就是多个仓库,上面的git submodule其实就是multi-repo的一种方式,主项目和子项目都是单独的git仓库,也就构成了多个仓库。而mono-repo就是一个大仓库,多个项目都放在一个git仓库里面。现在很多知名开源项目都是采用的mono-repo的组织方式,比如BabelReact ,Jest, create-react-app, react-router等等。mono-repo特别适合联系紧密的多个项目,比如本文面临的这种情况,下面我们就进入本文的主题,认真看下mono-repo

mono-repo

其实我之前写react-router源码解析的时候就提到过mono-repo,当时就说有机会单独写一篇mono-repo的文章,本文也算是把坑填上了。所以我们先从react-router的源码结构入手,来看下mono-repo的整体情况,下图就是react-router的源码结构:

image-20201225153108233

我们发现他有个packages文件夹,里面有四个项目:

  1. react-router:是React-Router的核心库,处理一些共用的逻辑
  2. react-router-config:是React-Router的配置处理库
  3. react-router-dom:浏览器上使用的库,会引用react-router核心库
  4. react-router-native:支持React-Native的路由库,也会引用react-router核心库

这四个项目都是为react的路由管理服务的,在业务上有很强的关联性,完成一个功能可能需要多个项目配合才能完成。比如修某个BUG需要同时改react-router-domreact-router的代码,如果他们在不同的Git仓库,需要在两个仓库里面分别修改,提交,打包,测试,然后还要修改彼此依赖的版本号才能正常工作。但是使用了mono-repo,因为他们代码都在同一个Git仓库,我们在一个commit里面就可以修改两个项目的代码,然后统一打包,测试,发布,如果我们使用了lerna管理工具,版本号的依赖也是自动更新的,实在是方便太多了。

lerna

lerna是最知名的mono-repo的管理工具,今天我们就要用它来搭建前面提到的共享业务组件的项目,我们目标的项目结构是这个样子的:

mono-repo-demo/                  --- 主项目,这是一个Git仓库
  package.json
  packages/
    common/                      --- 共享的业务组件
      package.json
    admin-site/                  --- 柜员网站项目
      package.json
    customer-site/               --- 客户网站项目
      package.json

lerna init

lerna初始化很简单,先创建一个空的文件夹,然后运行:

npx lerna init

这行命令会帮我创建一个空的packages文件夹,一个package.jsonlerna.json,整个结构长这样:

image-20201225162905950

package.json中有一点需要注意,他的private必须设置为true,因为mono-repo本身的这个Git仓库并不是一个项目,他是多个项目,所以他自己不能直接发布,发布的应该是packages/下面的各个子项目。

"private": true,

lerna.json初始化长这样:

{
  "packages": [
    "packages/*"
  ],
  "version": "0.0.0"
}

packages字段就是标记你子项目的位置,默认就是packages/文件夹,他是一个数组,所以是支持多个不同位置的。另外一个需要特别注意的是version字段,这个字段有两个类型的值,一个是像上面的0.0.0这样一个具体版本号,还可以是independent这个关键字。如果是0.0.0这种具体版本号,那lerna管理的所有子项目都会有相同的版本号----0.0.0,如果你设置为independent,那各个子项目可以有自己的版本号,比如子项目1的版本号是0.0.0,子项目2的版本号可以是0.1.0

创建子项目

现在我们的packages/目录是空的,根据我们前面的设想,我们需要创建三个项目:

  1. common:共享的业务组件,本身不需要运行,放各种组件就行了。
  2. admin-site:柜员站点,需要能够运行,使用create-react-app创建吧
  3. customer-site:客户站点,也需要运行,还是使用create-react-app创建

创建子项目可以使用lerna的命令来创建:

lerna create <name>

也可以自己手动创建文件夹,这里common子项目我就用lerna命令创建吧,lerna create common,运行后common文件夹就出现在packages下面了:

image-20201231145959966

这个是使用lerna create默认生成的目录结构,__test__文件夹下面放得是单元测试内容,lib下面放得是代码。由于我是准备用它来放共享组件的,所以我把目录结构调整了,默认生成的两个文件夹都删了,新建了一个components文件夹:

image-20201231150311253

另外两个可运行站点都用create-react-app创建了,在packages文件夹下运行:

npx create-react-app admin-site; npx create-react-app customer-site;

几个项目都创建完后,整个项目结构是这样的:

image-20201231151536018

按照mono-repo的惯例,这几个子项目的名称最好命名为@<主项目名称>/<子项目名称>,这样当别人引用你的时候,你的这几个项目都可以在node_modules的同一个目录下面,目录名字就是@<主项目名称>,所以我们手动改下三个子项目package.json里面的name为:

@mono-repo-demo/admin-site
@mono-repo-demo/common
@mono-repo-demo/customer-site

lerna bootstrap

上面的图片可以看到,packages/下面的每个子项目有自己的node_modules,如果将它打开,会发现很多重复的依赖包,这会占用我们大量的硬盘空间。lerna提供了另一个强大的功能:将子项目的依赖包都提取到最顶层,我们只需要先删除子项目的node_modules再跑下面这行命令就行了

lerna bootstrap --hoist

删除已经安装的子项目node_modules可以手动删,也可以用这个命令:

lerna clean

yarn workspace

lerna bootstrap --hoist虽然可以将子项目的依赖提升到顶层,但是他的方式比较粗暴:先在每个子项目运行npm install,等所有依赖都安装好后,将他们移动到顶层的node_modules。这会导致一个问题,如果多个子项目依赖同一个第三方库,但是需求的版本不同怎么办?比如我们三个子项目都依赖antd,但是他们的版本不完全一样:

// admin-site
"antd": "3.1.0"

// customer-site
"antd": "3.1.0"

// common
"antd": "4.9.4"

这个例子中admin-sitecustomer-site需要的antd版本都是3.1.0,但是common需要的版本却是4.9.4,如果使用lerna bootstrap --hoist来进行提升,lerna会提升用的最多的版本,也就是3.1.0到顶层,然后把子项目的node_modules里面的antd都删了。也就是说common去访问antd的话,也会拿到3.1.0的版本,这可能会导致common项目工作不正常。

这时候就需要介绍yarn workspace 了,他可以解决前面说的版本不一致的问题,lerna bootstrap --hoist会把所有子项目用的最多的版本移动到顶层,而yarn workspace 则会检查每个子项目里面依赖及其版本,如果版本不一样则会留在子项目自己的node_modules里面,只有完全一样的依赖才会提升到顶层。

还是以上面这个antd为例,使用yarn workspace的话,会把admin-sitecustomer-site3.1.0版本移动到顶层,而common项目下会保留自己4.9.4antd,这样每个子项目都可以拿到自己需要的依赖了。

yarn workspace使用也很简单,yarn 1.0以上的版本默认就是开启workspace的,所以我们只需要在顶层的package.json加一个配置就行:

// 顶层package.json
{
  "workspaces": [
    "packages/*"
  ]
}

然后在lerna.json里面指定npmClientyarn,并将useWorkspaces设置为true

// lerna.json
{
  "npmClient": "yarn",
  "useWorkspaces": true
}

使用了yarn workspace,我们就不用lerna bootstrap来安装依赖了,而是像以前一样yarn install就行了,他会自动帮我们提升依赖,这里的yarn install无论在顶层运行还是在任意一个子项目运行效果都是一样的。

启动子项目

现在我们建好了三个子项目,要启动CRA子项目,可以去那个目录下运行yarn start,但是频繁切换文件夹实在是太麻烦了。其实有了lerna的帮助我们可以直接在顶层运行,这需要用到lerna的这个功能:

lerna run [script]

比如我们在顶层运行了lerna run start,这相当于去每个子项目下面都去执行yarn run start或者npm run start,具体是yarn还是npm,取决于你在lerna.json里面的这个设置:

"npmClient": "yarn"    

如果我只想在其中一个子项目运行命令,应该怎么办呢?加上--scope就行了,比如我就在顶层的package.json里面加了这么一行命令:

// 顶层package.json
{
  "scripts": {
    "start:aSite": "lerna --scope @mono-repo-demo/admin-site run start"
  }
}

所以我们可以直接在顶层运行yarn start:aSite,这会启动前面说的管理员站点,他其实运行的命令还是lerna run start,然后加了--scope来指定在管理员子项目下运行,@mono-repo-demo/admin-site就是我们管理员子项目的名字,是定义在这个子项目的package.json里面的:

// 管理员子项目package.json
{
  "name": "@mono-repo-demo/admin-site"
}

然后我们实际运行下yarn start:aSite吧:

image-20201231155954580

看到了我们熟悉的CRA转圈圈,说明到目前为止我们的配置还算顺利,哈哈~

创建公共组件

现在项目基本结构已经有了,我们建一个公共组件试一下效果。我们就用antd创建一个交水费的表单吧,也很简单,就一个姓名输入框,一个查询按钮。

//  packages/common/components/WaterForm.js

import { Form, Input, Button } from 'antd';
const layout = {
  labelCol: {
    span: 8,
  },
  wrapperCol: {
    span: 16,
  },
};
const tailLayout = {
  wrapperCol: {
    offset: 8,
    span: 16,
  },
};

const WaterForm = () => {
  const onFinish = (values) => {
    console.log('Success:', values);
  };

  const onFinishFailed = (errorInfo) => {
    console.log('Failed:', errorInfo);
  };

  return (
    <Form
      {...layout}
      name="basic"
      initialValues={{
        remember: true,
      }}
      onFinish={onFinish}
      onFinishFailed={onFinishFailed}
    >
      <Form.Item
        label="姓名"
        name="username"
        rules={[
          {
            required: true,
            message: '请输入姓名',
          },
        ]}
      >
        <Input />
      </Form.Item>

      <Form.Item {...tailLayout}>
        <Button type="primary" htmlType="submit">
          查询
        </Button>
      </Form.Item>
    </Form>
  );
};

export default WaterForm;

引入公共组件

这个组件写好了,我们就在admin-site里面引用下他,要引用上面的组件,我们需要先在admin-sitepackage.json里面将这个依赖加上,我们可以去手动修改他,也可以使用lerna命令:

lerna add @mono-repo-demo/common --scope @mono-repo-demo/admin-site

这个命令效果跟你手动改package.json是一样的:

image-20201231161945744

然后我们去把admin-site默认的CRA圈圈改成这个水费表单吧:

image-20201231162333590

然后再运行下:

image-20201231162459416

嗯?报错了。。。如果我说这个错误是我预料之中的,你信吗😜

共享脚手架

仔细看下上面的错误,是报在WaterForm这个组件里面的,错误信息是说:jsx语法不支持,最后两行还给了个建议,叫我们引入babel来编译。这些都说明了一个同问题:babel的配置对common子项目没有生效。这其实是预料之中的,我们的admin-site之所以能跑起来是因为CRA帮我们配置好了这些脚手架,而common这个子项目并没有配置这些脚手架,自然编译不了。

我们这几个子项目都是React的,其实都可以共用一套脚手架,所以我的方案是:将CRA的脚手架全部eject出来,然后手动挪到顶层,让三个子项目共享。

首先我们到admin-site下面运行:

yarn eject

这个命令会将CRA的config文件夹和scripts文件夹弹出来,同时将他们的依赖添加到admin-sitepackage.json里面。所以我们要干的就是手动将config文件夹和scripts文件夹移动到顶层,然后将CRA添加到package.json的依赖也移到最顶层,具体CRA改了package.json里面的哪些内容可以通过git看出来的。移动过后的项目结构长这样:

image-20201231165208361

注意CRA项目的启动脚本在scripts文件夹里面,所以我们需要稍微修改下admin-site的启动命令:

// admin-site package.json

{
  "scripts": "node ../../scripts/start.js",
}

现在我们使用yarn start:aSite仍然会报错,所以我们继续修改babel的设置。

首先在config/paths里面添加上我们packages的路径并export出去:

image-20201231173801079

然后修改webpacka配置,在babel-loaderinclude路径里面添加上这个路径:

image-20201231173912873

现在再运行下我们的项目就正常了:

image-20210102142340656

最后别忘了,还有我们的customer-site哦,这个处理起来就简单了,因为前面我们已经调好了整个主项目的结构,我们可以将customer-site的其他依赖都删了,只保留@mono-repo-demo/common,然后调整下启动脚本就行了:

image-20210102142635875

这样客户站点也可以引入公共组件并启动了。

发布

最后要注意的一点是,当我们修改完成后,需要发布了,一定要使用lerna publish,他会自动帮我更新依赖的版本号。比如我现在稍微修改了一下水费表单,然后提交:

image-20210102145343033

现在我试着发布一下,运行

lerna publish

运行后,他会让你选择新的版本号:

image-20210102150019630

我这里选择一个minor,也就是版本号从0.0.0变成0.1.0,然后lerna会自动更新相关的依赖版本,包括:

  1. lerna.json自己版本号升为0.1.0

    image-20210102150535183

  2. common的版本号变为0.1.0

    image-20210102150621696

  3. admin-site的版本号也变为0.1.0,同时更新依赖的common0.1.0

    image-20210102150722538

  4. customer-site的变化跟admin-site是一样的。

independent version

上面这种发布策略,我们修改了common的版本,admin-site的版本也变成了一样的,按理来说,这个不是必须的,admin-site只是更新依赖的common版本,自己的版本不一定是升级一个minor,也许只是一个patch这种情况下,admin-site的版本要不要跟着变,取决于lerna.json里面的version配置,前面说过了,如果它是一个固定的指,那所有子项目版本会保持一致,所以admin-site版本会跟着变,我们将它改成independent就会不一样了。

// lerna.json
{
  "version": "independent"
}

然后我再改下common再发布试试:

image-20210102151332029

在运行下lerna publish,我们发现他会让你自己一个一个来选子项目的版本,我这里就可以选择将common升级为0.2.0,而admin-site只是依赖变了,就可以升级为0.1.1:

image-20210102151752370

具体采用哪种策略,是每个子项目版本都保持一致还是各自版本独立,大家可以根据自己的项目情况决定。

总结

这个mono-repo工程我已经把代码清理了一下,上传到了GitHub,如果你刚好需要一个mono-repo + react的项目模板,直接clone吧:https://github.com/dennis-jiang/mono-repo-demo

下面我们再来回顾下本文的要点:

  1. 事情的起源是我们接到了一个外国人交水电费并能卖东西的需求,有柜员端和客户自助端。
  2. 经过分析,我们决定将柜员端和客户自助端部署为两个站点。
  3. 为了这两个站点,我们新建了两个项目,这样扩展性更好。
  4. 这两个项目有很多长得一样的业务组件,我们需要复用他们。
  5. 为了复用这些业务组件,我们引入了mono-repo的架构来进行项目管理,mono-repo特别适合联系紧密的多个项目。
  6. mono-repo最出名的工具是lerna
  7. lerna可以自动管理各个项目之间的依赖以及node_modules
  8. 使用lerna bootstrap --hoist可以将子项目的node_modules提升到顶层,解决node_modules重复的问题。
  9. 但是lerna bootstrap --hoist在提升时如果遇到各个子项目引用的依赖版本不一致,会提升使用最多的版本,从而导致少数派那个找不到正确的依赖,发生错误。
  10. 为了解决提升时版本冲突的问题,我们引入了yarn workspace,他也会提升用的最多的版本,但是会为少数派保留自己的依赖在自己的node_modules下面。
  11. 我们示例中两个CRA项目都有自己的脚手架,而common没有脚手架,我们调整了脚手架,将它挪到了最顶层,从而三个项目可以共享。
  12. 发布的时候使用lerna publish,他会自动更新内部依赖,并更新各个子项目自己的版本号。
  13. 子项目的版本号规则可以在lerna.json里面配置,如果配置为固定版本号,则各个子项目保持一致的版本,如果配置为independent关键字,各个子项目可以有自己不同的版本号。

参考资料

  1. Lerna官网:https://lerna.js.org/
  2. Yarn workspace: https://classic.yarnpkg.com/en/docs/workspaces/

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

欢迎关注我的公众号进击的大前端第一时间获取高质量原创~

“前端进阶知识”系列文章源码地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二维码_2.png

查看原文

赞 31 收藏 19 评论 6

蒋鹏飞 赞了文章 · 1月4日

SegmentFault 思否 2020 年度 Top Writer

日新月异的技术革命,数字经济的新一轮爆发,背后是无数开发者夜以继日的付出。他们信奉技术力量,敢于技术创新,践行技术信仰,他们是技术先锋,探索改变世界的方向。

SegmentFault 思否作为中国领先的新一代开发者社区,在 2020 展开了第二届“中国技术先锋”年度评选,并先后发布《中国技术品牌影响力企业》、《中国开源先锋 33 人》及《最受开发者欢迎的技术活动》系列榜单。

而在这些引领着时代变革的先锋力量中,有一股力量不容忽视 —— 他们是社区的基石,也是行业发展、技术发展的源动力。他们是一群活跃在 SegmentFault 思否社区的一群卓越的开发者,他们热衷于分享知识与经验,他们布道技术与未来,他们让众多开发者受益,他们叫「Top Writer」。

SegmentFault 思否根据社区用户行为大数据(如文章 & 问答发布数量、获得声望 & 点赞量等)综合分析,从「技术问答」和「专栏文章」两个维度进行了2020年度「Top Writer」的评选。

话不多说,让我们来一同揭晓评选结果~

image

TopWriter·问答作者积累声望值高票问答
然后去远足15948git所谓的分布式体现在什么地方?
linong17915vue回车聚焦下一个input,动态绑定ref出现,refs拿到为undefined
fefe9695promise then 的回调函数是在什么时候进入微任务队列的?
GhostOfYou3748Linux crontab 没有效果
Meathill12308阅读源码重要吗?有多重要?
木马啊9087用纯css怎么实现A元素+B元素,A是绿色背景,A元素+C元素,A是红色背景?
唯一丶10723null undefined区别
zangeci3264chrome控制台 这种怎么输出的?
asseek8967怎么简写下面的赋值语句
hfhan13061element-ui 中 Cascader 级联选择器有没有什么办法判断它是否被全选
madRain3616js中多个时间,怎么取最小值
水不凉4199关于class中的函数问题
边城42037js 数组内嵌对象(json结构),知道路径怎么去修改内容?
TNT4020java 字符串去掉多余空格和空行
程序媛兔子1333vue项目如何实现导航栏中的前进和后退都要刷新页面?vue项目如何实现导航栏中的前进和后退都要刷新页面?
TopWriter·文章作者积累声望值高票文章
民工哥16954小姐姐用动画图解Git命令,一看就懂!
谭光志4666前端性能优化 24 条建议(2020)
前端小智54379能解决 80% 需求的 10个 CSS动画库
疯狂的技术宅410612020最新:100道有答案的前端面试题(上)
lzg95272036分享8个非常实用的Vue自定义指令
Jason302807-SpringBoot+MyBatis+Spring 技术整合实现商品模块的CRUD操作
杜尼卜9987听说你熟练使用Vue,那这9种Vue技术你掌握了吗?不信你全知道!
Peter谭老师13076深度:从零编写一个微前端框架
敖丙2640Redis 缓存雪崩、击穿、穿透
flydean661八张图彻底了解JDK8 GC调优秘籍-附PDF下载
阿宝哥14032「1.8W字」一份不可多得的 TS 学习指南
小傅哥243012天,这本《重学Java设计模式》PDF书籍下载量9k,新增粉丝1400人,Github上全球推荐榜!
codecraft11291聊聊golang的panic与recover
iyacontrol1236服务网格平台探索性指南
蒋鹏飞3443速度提高几百倍,记一次数据结构在实际工作中的运用

恭喜以上上榜的技术内容创作者!请入选的作者们添加下方思否小姐姐的微信,我们为每位「Top Writer」准备了定制证书和 SegmentFault 2021 限量版卫衣。

也欢迎更多开发者在 SegmentFault 思否社区分享自己的经验与技能,为更多「同路人」答疑解惑、互动交流。如果你希望!自己的内容更快被更多用户看见和关注,欢迎加入思否社区创作者群,交流技术、分享写作经验、获得更多流量。(入群请添加小姐姐微信并发送你的社区账号)

扫我↓ 添加 vivian

image

最后思否小姐姐为各位 Top Writer 和社区活跃的开发者点赞,在 SegmentFault 思否社区活跃的开发者最可爱!2021,我们继续在一起鸭!

查看原文

赞 22 收藏 4 评论 17

蒋鹏飞 关注了用户 · 2020-12-30

JasonTy @jasonty_58705e04da954

关注 5

蒋鹏飞 报名了系列讲座 · 2020-12-29

自顶向下学 React 源码

曾有大牛说过:“前端领域十八个月难度翻一倍。”作为前端工程师,如果将自己的定位局限在“熟练使用技术栈完成业务”,那么随着技术革新,慢慢会陷入“学不动了”的境地。这个问题如何解决呢?答案是:探索前端的边界。 在业务之外,前端还有很多富有挑战与机遇的领域,包括但不限于:全栈、移动端、工程化、可视化、框架开发。熟练完成业务并且深入前端某一领域,也是“资深前端工程师”必须掌握的能力。在这些领域中,与我们日常开发关系最密切的,便是“框架开发”了。 ### 源码为什么难学 从`机遇`可以看到,小到具体`算法`,大到`编程思想`,要了解源码运行流程,需要学习很多前置知识。 通常我们调试库的源码,会从库的入口函数开始`debug`。对于`React`来说,这个入口就是`ReactDOM.render`。 ![8IBgRQ5kS7nVycq](https://vip2.loli.net/2020/10/15/8IBgRQ5kS7nVycq.png) 打印从`ReactDOM.render`执行到`视图渲染`过程的调用栈会发现:这中间的调用流程非常复杂,甚至还有异步流程。 初学源码的同学很容易陷入在源码的汪洋大海中,从入门到放弃。 ### 源码该怎么学 如果将调用栈按功能划分,可以分为三块: ![ODs6ESVemgtX7yr](https://vip2.loli.net/2020/10/15/ODs6ESVemgtX7yr.png) 分别对应了源码架构中的三大模块:调度 → 协调 → 渲染 ![lJbEg2tAmDVhGj1](https://vip2.loli.net/2020/10/15/lJbEg2tAmDVhGj1.png) 所以,在学习具体代码前,更好的方式是先了解`React`的架构体系。**那么`React`为什么要这样架构呢?**我们作为开发者,在开发一个功能前会关注开发这个功能的目的——是为了更好的用户体验,还是为了商业化? 同样,`React`团队作为框架的开发者,在开发`React`前也会关注开发目的。这个目的,就是`React`的设计理念。 可以从[官网React哲学](https://zh-hans.reactjs.org/docs/thinking-in-react.html)了解到设计理念: > React 是用 JavaScript 构建快速响应的大型 Web 应用程序的首选方式。 可见,`React`的设计初衷就是为了践行`快速响应`的理念。 **Q:那么,是什么在制约页面的`快速响应`?** A:`CPU`的瓶颈与`IO`的瓶颈。 **Q:如何解决瓶颈?** A:以`并发`的模式更新视图。 Q:**至此,我们也解开了一大疑惑:为什么`React`要推出`Fiber`架构?** A:`React15`的架构无法实现`并发更新`。 可以看到,我们并没有从如 ReactDOM.render、this.setState 或 Hooks 等这些日常开发耳熟能详的 API 入手,而是从“理念”这样比较高的抽象层次开始学习,这是有意为之的。 ### 授课思路 为了帮你从本质上学懂`React`源码,本课程的讲解将从理念到架构,从架构到实现,从实现到具体代码。 这是一个自顶向下、抽象程度递减,符合大脑认知的过程。 基于此,本课程划分为三大篇章: * 理念篇:带你了解`React`的设计理念及演进史 * 架构篇:讲解“设计理念“如何具体落实到代码架构上 * 实现篇:讲解主要 API如何在“架构体系”中实现 ### 课程特色 * **基于最新源码,**本课程基于最新 React17.0.0-alpha 设计,全网稀缺。React17 发布的 Concurrent Mode 是React 前几年的努力方向,也是未来几年的发展方向。市面上目前还没有 React17 源码级别的课程。 * **辅助资料完备**,课程配备了配套电子书《React技术揭秘》、丰富的在线Demo、实战练习。 * **“自顶向下”的授课方式,**符合人类认知的过程。如果说别的源码分析课程是“高级”难度,那么本课程只有“中级”难度。原因在于课程使用“自顶向下”的授课方式,更符合人类认知的过程。 ### 课程大纲 #### **理念篇:React的设计理念及架构体系** * React的设计理念 * 为了满足设计理念,这些年React架构的演进历程 * 新的React架构是如何设计的 #### **架构篇:React架构的工作流程** * React架构render阶段的完整流程 * React架构commit阶段的完整流程 #### **实现篇:具体功能的源码实现** * Diff算法的实现 * 状态更新相关API的实现 * Hooks的实现 * Concurrent Mode的实现 ### 讲师介绍 卡颂,奇舞团前端工程师,React Contributor,《React技术揭秘》作者。在制作本课程前,已经通过电子书的形式帮助非常多小伙伴学习React源码,广受好评。并形成了上千人的源码学习社群,技术氛围浓郁。

蒋鹏飞 赞了文章 · 2020-12-22

权威发布丨2020 中国技术品牌影响力企业 30 强

2020 中国技术品牌影响力企业 30 强

#前言

充满变数的 2020 年,技术行业从业者肩上的责任超越了以往任何历史时期。

突如其来的疫情让全人类经历了一次“数字化生存”大考,政企上云、传统行业的数字化转型也在大环境中被催化。作为新基建的底层支撑,芯片、服务器、操作系统、中间件、数据库等一系列信创技术,在全国范围内被广泛关注。

日新月异的技术革命,数字经济的新一轮爆发,背后是无数开发者和科技企业夜以继日的付出。他们面对不断变化的外部环境,扎根行业,他们信奉技术力量,敢于技术创新,践行技术信仰,他们是技术先锋,探索改变世界的方向。


SegmentFault 思否作为中国领先的新一代开发者社区,依托数百万开发者用户数据分析,及各科技企业和个人在国内技术领域的行为、影响力指标,推出了第二届“中国技术先锋”年度评选。

我们将从今天起陆续发布《2020 中国技术品牌影响力企业 30 强》,《中国开源先锋 33 人之心尖上的开源人物》,《2020 最受开发者欢迎的技术活动》。并在 2021 年 1 月发布《2020 年度 SegmentFault 思否 TopWriter》。

无论是积极输出优质 UGC 内容的技术作者,还是高质量技术活动的主办方、在开发者生态上积极投入的科技企业,都是推动社会创新的 “先锋力量”,我们推荐大家在即将到来的 2021 年关注他们的发展和动态。


此次《SegmentFault 思否 2020 中国技术品牌影响力企业榜单》评选除综合榜单之外,另设五个专项奖:#最佳技术服务奖、#生态发展奖、#技术品牌营销奖、#技术向善奖、#技术文化奖。获奖详情如下:

2020 中国技术品牌影响力企业 30 强

2020 中国技术品牌影响力企业 30 强

2020 中国技术品牌影响力企业 - 专项奖

2020 中国技术品牌影响力企业 30 强
2020 中国技术品牌影响力企业 30 强
2020 中国技术品牌影响力企业 30 强
2020 中国技术品牌影响力企业 30 强
2020 中国技术品牌影响力企业 30 强

当开发者生态和技术品牌受到越来越多企业的重视,我们希望让开发者看到那些真正坚持长期价值,积极输出优质技术内容、坚持产品创新、为开发者创造价值和便利的企业或团队,帮助他们获得更多关注,也推动更多企业加大在开发者生态的投入,造福开发者。


关于 SegmentFault 思否

SegmentFault 思否作为中国领先的新一代开发者社区和专业技术媒体,是国内 DGC (Developer Generated Content) 内容最丰富、技术问答板块最活跃的开发者社区。

目前已经覆盖和服务了超过 1000 万开发者和上千家科技企业,帮助开发者解决了超过数百万个技术问题,用户原创产生的优质技术文章已累积超过 10 万篇,上千家科技企业技术团队入驻,每月开发者访问流量超千万。

同时,思否团队也是中国最大的黑客马拉松 (Hackathon) 组织者,公司曾先后获得顶级 VC 软银赛富、IDG 资本、G5 资本、华兴资本数千万投资。

segmentfault 思否

查看原文

赞 11 收藏 0 评论 3

蒋鹏飞 发布了文章 · 2020-12-21

轻松理解HTTP缓存策略

上一篇文章我写了koa-static的源码解析,其中用到了HTTP的缓存策略,给返回的静态文件设置了一些缓存的头,比如Cache-Control之类的。于是我就跟朋友讨论了一下HTTP的缓存策略:

朋友说:“HTTP里面控制缓存的头(header)太多了,啥Cache-ControlETagLast-Modified,一大堆,乱七八糟的,而且之间逻辑关系不强,要掌握基本靠背!”

我有点惊讶:“为什么要去背这个呢?所有的技术都是为了解决问题而存在的,不了解问题而去单纯的学习技术,去,背,去,死记,确实很枯燥,而且效果不好。HTTP缓存策略只是为了解决客户端和服务端信息不对称的问题而存在的,客户端为了加快速度会缓存部分资源,但是下次请求时,客户端不知道这个资源有没有更新,服务端也不知道客户端缓存的是哪个版本,不知道该不该再返回资源,其实就是一个信息同步问题,HTTP缓存策略就是来解决这个问题的。如果我们跳出这种纯粹的技术思维,我们会发现生活中这种信息同步问题也很常见。而我们解决这些问题的思路很多时候都是司空见惯了,如果从这个角度来说,这个问题就很好理解!”

于是我给他讲了一个我小时候租光碟看奥特曼的故事。

租光碟看奥特曼

事情是这样的,我小时候特别喜欢看动画片,尤其是奥特曼,但是那时候没有电脑啊,也没有网络。我只有一台DVD播放机,于是我会经常跑去租光碟的店租奥特曼。

ETag

某天,我看完了《艾斯奥特曼》第10集,我还想继续看。于是我找到了光碟店的老板:“老板,第10集我看完了哦,你还有没有新的啊?”老板说:“有有有,刚出了第11集,你拿去吧!”

上面这一个简单的交流过程其实就包含了一个HTTP的缓存技术,那就是ETag!类比于网络请求,我其实就是客户端,光碟店就是服务端,我去租光碟就相当于发起一个请求。但是我去租光碟时,老板并不知道我看到哪集了,我们的信息是不同步的。所以我告诉了他一个标记(Tag),在这里这个标记就是第10集,老板拿到这个标记,跟他自己库存的标记比较一下,发现他最新标记是第11集,于是知道有更新了,将第11集给了我。

Last-Modified & If-Modified-Since

再来,我《艾斯奥特曼》看完了,我开始看《泰罗奥特曼》了。可是老板这次比较鸡贼,《泰罗奥特曼》没买正版的,是他自己翻录的,他翻录的时候自己也不知道是第几集,但是他聪明的在光盘上写上了翻录日期。于是我正在看的这盘也没啥封面,只光秃秃的写了一个2000年12月1日。当我这盘看完了,我又去找老板了:“老板,你这个2000年12月1日的我已经看完了,你还有没有新的啊?”这里的2000年12月1日其实就是标记了我手上副本的更新日期,这也对应了HTTP的一个缓存技术,那就是Last-ModifiedIf-Modified-Since。你可以理解为,老板给日期还取了一个名字,叫Last-Modified,所以光碟上完整文字是Last-Modified:2000年12月1日,而我去问的时候就这么问:“Do you have any updates IF-Modified-Since 2000年12月1日?”。

Expires和Max-Age

继续,我《泰罗奥特曼》也看完了,开始看《雷欧奥特曼》了。这《雷欧奥特曼》跟前面两个都不一样,我去租的时候老板就说了:“你小子别天天跑来问了!《雷欧奥特曼》我每周去进一次货,你每周一来拿就行!”这句话也对应了一个HTTP缓存技术,那就是ExpiresMax-Age。我知道了下周一之前,我手上都是最新的,到了下周一就过期(Expire)了。所以“我手上的是最新的”这个说法有个生命周期,他的年龄是有限的,他的年龄等于下周一更新时间减去当前时间,这就是他的最大年龄(Max-Age)。

Immutable

再来一个,我《雷欧奥特曼》也看完了,开始看《奈克斯特奥特曼》了。这《奈克斯特奥特曼》跟前面几个都不一样,我去租的时候老板说了:“小子,你这次运气好,这《奈克斯特奥特曼》已经出完了,你全部拿去吧,也不用天天跑来问了!”这句话对应的HTTP缓存技术是啥?当然是ImmutableImmutable就跟字面意思一样,不可变的!就像《奈克斯特奥特曼》一样,已经出完了,不用再去问更新了。

言归正传

扯蛋到这里结束,咱们言归正传!之所以举这么个例子,是为了说明HTTP缓存技术要解决的问题在生活中很常见,从这些常见的场景入手,理解起来更简单。下面我们正儿八经的来说说HTTP缓存技术:

两种机制

从上面的几个小例子可以看出,有时候为了知道是不是有更新,我必须去问老板,比如第一个例子里面:“老板,第10集我看完了哦,你还有没有新的啊?”。这种为了知道有没有更新,必须跟服务端沟通过才知道的,我们称之为协商缓存。还有些场景,我不去问就知道有没有更新,比如第三个例子,因为知道是周更的,当周一来之前,我都不会去问了,到了周一再去问,这种不用跟服务器协商直接用本地副本的叫做强制缓存。换成技术的话说就是,强制缓存不用发请求直接用本地缓存,协商缓存要发请求去问服务器有没有更新。下面我们详细来讲下这两种缓存:

协商缓存

前面第一个例子和第二个例子每次都需要向服务器端询问,所以是协商缓存

ETag和If-None-Match

ETag是URL的Entity Tag,就是一个URL资源的标识符,类似于文件的md5,计算方式也类似,当服务器返回时,可以根据返回内容计算一个hash值或者就是一个数字版本号,类似于我们的第10集,具体返回什么值要看服务器的计算策略。然后将它加到responseheader里面,可能长这样:

ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

客户端拿到后会将这个ETag和返回值一起存下来,等下次请求时,使用配套的If-None-Match,将这个放到requestheader里面,可能长这样:

If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

然后服务端拿到请求里面的If-None-Match跟当前版本的ETag比较下:

  1. 如果是一样的话,直接返回304,语义为Not Modified,不返回内容(body),只返回header,告诉浏览器直接用缓存。
  2. 如果不一样的话,返回200和最新的内容

ETag配套的还有一个不太常用的request header ----If-Match,这个和前面If-None-Match的语义是相反的。前面If-None-Match的语义是如果不匹配就下载。而If-Match通常用于post或者put请求中,语义为如果匹配才提交,比如你在编辑一个商品,其他人也可能同时在编辑。当你提交编辑时,其他人可能已经先于你提交了,这时候服务端的ETag就已经变了,If-Match就不成立了,这时候服务端会给你返回412错误,也就是Precondition Failed,前提条件失败。如果If-Match成立,就正常返回200

Last-Modified & If-Modified-Since

Last-ModifiedIf-Modified-Since也是配套使用的,类似于ETagIf-None-Match的关系。只不过ETag放的是一个版本号或者hash值,Last-Modified放的是资源的最后修改时间。Last-Modified是放到responseheader里面的,可能长这样:

Last-Modified: Wed, 21 Oct 2000 07:28:00 GMT 

而客户端浏览器在使用时,应该将配套的If-Modified-Since放到requestheader里面,长这样:

If-Modified-Since: Wed, 21 Oct 2000 07:28:00 GMT 

服务端拿到这个头后,会跟当前版本的修改时间进行比较:

  1. 当前版本的修改时间比这个晚,也就是这个时间后又改过了,返回200和新的内容
  2. 当前版本的修改时间和这个一样,也就是没有更新,返回304,不返回内容,只返回头,客户端直接使用缓存

If-Modified-Since对应的还有If-Unmodified-SinceIf-Modified-Since可以理解为有更新才下载,那If-Unmodified-Since就是没有更新才下载。如果客户端传了If-Unmodified-Since,像这样:

If-Unmodified-Since: Wed, 21 Oct 2000 07:28:00 GMT 

服务端拿到这个头后,也会跟当前版本的修改时间进行比较:

  1. 如果这个时间后没有更新,服务器返回200,并返回内容。
  2. 如果这个时间后有更新,其实就是这个if不成立,会返回错误代码412,语义为Precondition Failed

ETag和Last-Modified优先级

ETagLast-Modified都是协商缓存,都需要服务器进行计算和比较,那如果这两个都存在,用哪个呢?答案是ETagETag的优先级比Last-Modified。因为Last-Modified在设计上有个问题,那就是Last-Modified的精度只能到秒,如果一个资源频繁修改,在同一秒进行多次修改,你从Last-Modified上是看不出来区别的。但是ETag每次修改都会生成新的,所以他比Last-Modified精度高,更准确。但是ETag也不是完全没问题的,你的ETag如果设计为一个hash值,每次请求都要计算这个值,需要额外耗费服务器资源。具体使用哪一个,需要根据自己的项目情况来进行取舍。

强制缓存

上面扯蛋那里的第三个例子和第四个例子就是强制缓存,就是我知道在某个时间段完全不用去问服务端,直接去用缓存就行。这两个例子里面提到的Expires是一个单独的headermax-ageimmutable同属于Cache-Control这个header

Expires

Expires比较简单,就是服务器responseheader带上这个字段:

Expires: Wed, 21 Oct 2000 07:28:00 GMT

然后在这个时间前,客户端浏览器都不会再发起请求,而是直接用缓存资源。

Cache-Control

Cache-Control相对比较复杂,可设置属性也比较多,max-age只是其中一个属性,长这样:

Cache-Control: max-age=20000

这表示当前资源在20000秒内都不用再请求了,直接使用缓存。

上面提到的immutable也是Cache-Control的一个属性,但是是个实验性质的,各个浏览器兼容并不好。设置了Cache-control: immutable表示这辈子都用缓存了,再请求是不可能的了。

其他常用属性还有:

no-cache:使用缓存前,强制要求把请求提交给服务器进行验证(协商缓存验证)。

no-store:不存储有关客户端请求或服务器响应的任何内容,即不使用任何缓存。

另外Cache-Control还有很多属性,大家可以参考MDN的文档

Expires和Cache-Control的优先级

就一句话:如果在Cache-Control响应头设置了 max-age 或者 s-maxage 指令,那么 Expires 头会被忽略。

协商缓存和强制缓存优先级

这个其实很好理解,协商缓存需要发请求跟服务器协商,强制缓存如果生效,根本就不会发请求。所以这个优先级就是:先判断强制缓存,如果强制缓存生效,直接使用缓存;如果强制缓存失效,再发请求跟服务器协商,看要不要使用缓存

总结

本文从生活中常见的场景入手,阐述了HTTP缓存机制其实是提高访问速度和解决信息不同步的一种机制。这种信息不同步在生活中很常见,很多解决思路我们已经司空见惯,带着这种思维,我们可以很好的理解HTTP缓存机制。HTTP缓存机制要点如下:

  1. HTTP缓存机制分为强制缓存协商缓存两类。
  2. 强制缓存的意思就是不要问了(不发起请求),直接用缓存吧。
  3. 强制缓存常见技术有ExpiresCache-Control
  4. Expires的值是一个时间,表示这个时间前缓存都有效,都不需要发起请求。
  5. Cache-Control有很多属性值,常用属性max-age设置了缓存有效的时间长度,单位为,这个时间没到,都不用发起请求。
  6. immutable也是Cache-Control的一个属性,表示这个资源这辈子都不用再请求了,但是他兼容性不好,Cache-Control其他属性可以参考MDN的文档
  7. Cache-Controlmax-age优先级比Expires高。
  8. 协商缓存常见技术有ETagLast-Modified
  9. ETag其实就是给资源算一个hash值或者版本号,对应的常用request headerIf-None-Match
  10. Last-Modified其实就是加上资源修改的时间,对应的常用request headerIf-Modified-Since,精度为
  11. ETag每次修改都会改变,而Last-Modified的精度只到,所以ETag更准确,优先级更高,但是需要计算,所以服务端开销更大。
  12. 强制缓存协商缓存都存在的情况下,先判断强制缓存是否生效,如果生效,不用发起请求,直接用缓存。如果强制缓存不生效再发起请求判断协商缓存

参考资料:

ETag MDN文档:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/ETag

Last-Modified MDN文档:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Last-Modified

Expires MDN文档:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Expires

Cache-Control MDN文档:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Cache-Control

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

欢迎关注我的公众号进击的大前端第一时间获取高质量原创~

“前端进阶知识”系列文章源码地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二维码_2.png

查看原文

赞 25 收藏 18 评论 1