lihan
lihan
发布于 2024-07-21 / 58 阅读
0
0

前端踩坑记录

Learning-notes


前端学习笔记 & 踩坑日记 & 冷知识,记录一些工作中遇到的问题

1. isNaN()Number.isNaN() 的区别

Number.isNaN() 方法确定传递的值是否为  NaN,并且检查其类型是否为  Number。它是  isNaN()  的更稳妥的版本。

和  isNaN()  相比,Number.isNaN() 不会自行将参数转换成数字,只有在参数是值为  NaN  的数字时,才会返回  true,否则返回 false

Number.isNaN(NaN); // true
Number.isNaN(Number.NaN); // true
Number.isNaN(0 / 0); // true

Number.isNaN({}); // false
Number.isNaN('NaN'); // false
Number.isNaN('blabla'); // false
Number.isNaN(undefined); // false

isNaN({}); // true
isNaN('NaN'); // true
isNaN('blabla'); // true
isNaN(undefined); // true

2. CSS 实现文本溢出省略

  • 单行文本:

overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
  • 多行文本:

overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;

3. 复制到粘贴板

const clipboardWriteText = copyText => {
  // 判断是否存在clipboard并且是安全的协议
  if (navigator.clipboard && window.isSecureContext) {
    return new Promise((resolve, reject) => {
      navigator.clipboard
        .writeText(copyText)
        .then(() => {
          resolve(true);
        })
        .catch(() => {
          reject(new Error('复制失败'));
        });
    });
  }
  // 否则用被废弃的execCommand
  const textArea = document.createElement('textarea');
  textArea.value = copyText;
  // 使text area不在viewport,同时设置不可见
  textArea.style.position = 'absolute';
  textArea.style.opacity = '0';
  textArea.style.left = '-999999px';
  textArea.style.top = '-999999px';
  document.body.append(textArea);
  textArea.focus();
  textArea.select();
  return new Promise((resolve, reject) => {
    // 执行复制命令并移除文本框
    if (document.execCommand('copy')) {
      document.execCommand('copy');
      resolve(true);
    } else {
      reject(new Error('复制失败'));
    }
    textArea.remove();
  });
};

使用:

clipboardWriteText('balabalabala')
  .then(() => {
    console.log('复制成功');
  })
  .catch(() => {
    console.log('复制失败');
  });

4. 什么是抽象渗漏

抽象渗漏指的是在代码中暴露了底层的实现细节,这些底层实现细节应该被屏蔽掉。

举例:在数组内查找某个值是否存在的时候,我们通常会使用到 indexOf 方法,该方法成功时返回下标,失败时返回 -1,这里用 -1 作为失败时的返回值,而这种细节应该被屏蔽掉。

所以更加推荐使用 includes 这种不会暴露代码底层实现细节的方法:

// 不推荐
[1, 2, 3].indexOf(1) !== -1; // true

// 推荐
[1, 2, 3].includes(1); // true

5. 高性能向下取整

核心是利用了位运算:

// 不推荐
const num = parseFloat(1.2);
const num = parseFloat('1.2');

// 推荐
const num = 1.2 >>> 0;
const num = '1.2' >>> 0;

6. 高性能判断奇偶

跟上条一样,也是利用位运算:

// 不推荐
if (num % 2) {
  console.log(`${num}是奇数`);
} else {
  console.log(`${num}是偶数`);
}

// 推荐
if (num & 1) {
  console.log(`${num}是奇数`);
} else {
  console.log(`${num}是偶数`);
}

7. SEO 优化

  • 最好用 ssr 框架,比如 react 的 next,或者 vue 的 nuxt(废话)

  • HTML 标签语义化,在适当的位置使用适当的标签

  • a 标签都记得设置链接,并且要加上 title 属性加以说明

  • img 标签都记得加 alt 属性

  • 谨慎使用 display: none,因为搜索引擎会过滤掉 display: none 中的内容

  • meta 信息包含 title、keywords、description,有的页面需要单独定制,有的需要通用

  • 页面在 html 标签上加 lang=“zh-CN”属性,表明文档的语言

  • 每个页面最好都要有且仅有一个 h1 标题,尤其是不需要登录的页面(若不喜欢 h1 的默认样式可通过 CSS 设置)

8. 冷知识:浏览器地址栏也能运行代码

  • 运行 js:

做法是以 javascript: 开头,然后跟要执行的语句。比如:

// 需要注意的是并不是所有浏览器都支持
javascript: alert('你好');
  • 运行 html:

做法是以 data:text/html, 开头,然后跟要执行的语句。比如:

<!-- 需要注意的是并不是所有浏览器都支持 -->
data:text/html,
<h1>hello</h1>
;

9. 冷知识:你不知道的 setTimeout

  • 冷知识:最大延迟时间 24.8 天

大多数浏览器都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647,换算一下相当于 24.8 天。那么这就意味着 setTimeout 设置的延迟值大于做个数字就会溢出。

setTimeout(() => {
  console.log('123');
}, 2147483647);
  • 冷知识:setTimeout 的第一个参数回调函数也可以是字符串类型

setTimeout(`console.log('balabala');`, 0);

10. 冷知识:Math.min 和 Math.max

执行 Math.min 而不传参数的时候,得到的结果是 Infinity,执行 Math.max 而不传参数的时候,得到的结果是-Infinity:

Math.min(); // Infinity
Math.max(); // -Infinity

11. 我们整天挂在嘴边的闭包到底是什么?

这里收集了不同文献中的原话,具体怎么理解看你自己:

  • 《JavaScript 高级程序设计》

    闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。

  • 《Node 深入浅出》

    在 JavaScript 中,实现外部作用域访问内部作用域中变量的方法叫做闭包(closure)。

  • 《JavaScript 设计模式与开发实践》

    局部变量所在的环境被外界访问,这个局部变量就有了不被销毁的理由。这时就产生了一个闭包结构,在闭包中,局部变量的生命被延续了。

  • 《你不知道的 JavaScript(上卷)》

    内部的函数持有对一个值的引用,引擎会调用这个函数,而词法作用域在这个过程中保持完整,这就是闭包。换句话说:当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域外执行,这时就产生了闭包。

12. 节流与防抖

  • 函数节流

// 方法一:定时器
const throttle = function (fn, delay) {
  let timer = null;
  return function () {
    const context = this;
    const args = arguments;
    if (!timer) {
      timer = setTimeout(() => {
        fn.apply(context, args);
        clearTimeout(timer);
      }, delay);
    }
  };
};

// 方法二:时间戳
const throttle2 = function (fn, delay) {
  let preTime = Date.now();
  return function () {
    const context = this;
    let args = arguments;
    let doTime = Date.now();
    if (doTime - preTime >= delay) {
      fn.apply(context, args);
      preTime = Date.now();
    }
  };
};
  • 函数防抖

function debounce(func, wait) {
  let timeout;
  return function () {
    let context = this;
    let args = arguments;
    if (timeout) {
      clearTimeout(timeout);
    }
    timeout = setTimeout(() => {
      func.apply(context, args);
    }, wait);
  };
}

11. 冷知识:pr(pull Request)和 mr(merge Request)有什么区别?

答:没有区别。

一般我们执行分支合并,需要执行下面两个命令:

git pull // 拉取需要合并的分支
git merge // 合并进目标分支

Github 选择了第一个命令来命名,叫 Pull Request

Gitlab 选择了最后一个命令来命名,叫 Merge Request

反正都不咋地……这起的什么狗屁名字

正确的起名应该是:

Merge Request // 请求把代码合并进去
Push Request // 请求把代码推进去

12. 判断一个对象是普通对象还是通过类创建的

const isPlainObject = (obj: any): boolean => {
  if (typeof obj !== 'object' || obj === null) {
    return false;
  }

  let proto = Object.getPrototypeOf(obj);
  if (proto === null) {
    return true;
  }

  let baseProto = proto;
  while (Object.getPrototypeOf(baseProto) !== null) {
    baseProto = Object.getPrototypeOf(baseProto);
  }

  return proto === baseProto;
};

13. 判断是否在浏览器环境

const isBrowser = () => {
  return (
    typeof window !== 'undefined' &&
    typeof window.document !== 'undefined' &&
    typeof window.document.createElement !== 'undefined'
  );
};

14. 判断是否为移动端

const userAgent = () => {
  const u = navigator.userAgent;
  return {
    trident: u.includes('Trident'),
    presto: u.includes('Presto'),
    webKit: u.includes('AppleWebKit'),
    gecko: u.includes('Gecko') && !u.includes('KHTML'),
    mobile: !!u.match(/AppleWebKit.*Mobile.*/),
    ios: !!u.match(/\\(i[^;]+;( U;)? CPU.+Mac OS X/),
    android: u.includes('Android') || u.includes('Adr'),
    iPhone: u.includes('iPhone'),
    iPad: u.includes('iPad'),
    webApp: !u.includes('Safari'),
    weixin: u.includes('MicroMessenger'),
    qq: !!u.match(/\\sQQ/i),
  };
};

const isMobile = () => {
  if (!isBrowser()) {
    return false;
  }
  const { mobile, android, ios } = userAgent();
  return mobile || android || ios || document.body.clientWidth < 750;
};

15. 判断页面是否在 iframe 框架里

const isInIframe = (): boolean => {
  try {
    return (
      self !== top ||
      self.frameElement?.tagName === 'IFRAME' ||
      window.frames.length !== parent.frames.length
    );
  } catch {
    return true;
  }
};

16. 实现一个 compose 函数

const compose = (...funcs) => {
  if (funcs.length === 0) {
    return arg => arg;
  }
  if (funcs.length === 1) {
    return funcs[0];
  }
  return funcs.reduce((a, b) => {
    return (...args) => a(b(...args));
  });
};

17. 处理数字精度问题

// 加
function add(arg1, arg2) {
  let digits1, digits2, maxDigits;
  try {
    digits1 = arg1.toString().split('.')[1].length || 0;
  } catch {
    digits1 = 0;
  }
  try {
    digits2 = arg2.toString().split('.')[1].length || 0;
  } catch {
    digits2 = 0;
  }
  maxDigits = 10 ** Math.max(digits1, digits2);
  return (mul(arg1, maxDigits) + mul(arg2, maxDigits)) / maxDigits;
}

// 减
function sub(arg1, arg2) {
  let digits1, digits2, maxDigits;
  try {
    digits1 = arg1.toString().split('.')[1].length || 0;
  } catch {
    digits1 = 0;
  }
  try {
    digits2 = arg2.toString().split('.')[1].length || 0;
  } catch {
    digits2 = 0;
  }
  maxDigits = 10 ** Math.max(digits1, digits2);
  return (mul(arg1, maxDigits) - mul(arg2, maxDigits)) / maxDigits;
}

// 乘
function mul(arg1, arg2) {
  let digits = 0;
  const s1 = arg1.toString();
  const s2 = arg2.toString();
  try {
    digits += s1.split('.')[1].length;
  } catch {}
  try {
    digits += s2.split('.')[1].length;
  } catch {}
  return (Number(s1.replace(/\\./, '')) * Number(s2.replace(/\\./, ''))) / 10 ** digits;
}

function div(arg1, arg2) {
  let int1 = 0;
  let int2 = 0;
  let digits1;
  let digits2;
  try {
    digits1 = arg1.toString().split('.')[1].length || 0;
  } catch (e) {
    digits1 = 0;
  }
  try {
    digits2 = arg2.toString().split('.')[1].length || 0;
  } catch (e) {
    digits2 = 0;
  }
  int1 = Number(arg1.toString().replace(/\\./, ''));
  int2 = Number(arg2.toString().replace(/\\./, ''));
  return ((int1 / int2) * 10) ** (digits2 - digits1 || 1);
}

顺便说一下,关于处理精度问题的解决方案,目前市面上已经有了很多较为成熟的库,比如 bignumber.jsdecimal.js、以及 big.js 等,这些库不仅解决了浮点数的运算精度问题,还支持了大数运算,并且修复了原生 toFixed 结果不准确的问题。我们可以根据自己的需求来选择对应的工具。

最后提醒一下:这玩意儿也就面试的时候写一下,强烈建议业务中还是用现成的库,出了问题我可不负责的嗷,唉,我好菜啊

18. 垂直居中 textarea

难点

根本就不能通过 css 来实现输入的垂直居中

网上的那些就会复制答案,什么 flex 都来了

只能用 js 来实现

思路

通过动态调整 paddingTop 来偏移文本内容。

需要注意的是,多行的时候,需要计算行数

可以通过 set Height 0,然后滚动高度就是输入文字的总高度,算完之后把高度复原

行数 = 文字总高度 / 行高

所以,设置行高很重要,默认是 normal,normal 是字符串,没办法计算的,所以自己手动设一个 lineheight 吧

<textarea id="text"></textarea>
textarea {
  width: 200px;
  height: 200px;
  padding: 0;
  margin: 0;
  line-height: 1.2;
  text-align: center;
  border: 1px solid black;
  box-sizing: border-box;
  word-break: break-all;
  resize: none;
}
// 获取行数,注意需要先把paddingtop置0,不然scrollHeight会把padding算进去
function getLinesCount(textEle, lineHeight) {
  textEle.style.paddingTop = 0;
  const h0 = textEle.style.height;
  textEle.style.height = 0;
  const h1 = textEle.scrollHeight;
  textEle.style.height = h0;
  return Math.floor(h1 / lineHeight);
}

function update() {
  const textArea = document.querySelector('#text');
  const lineHeight = Number(window.getComputedStyle(textArea).lineHeight.slice(0, -2));
  const h = textArea.getBoundingClientRect().height;
  const lines = getLinesCount(textArea, lineHeight);
  const top = h / 2 - (lineHeight * lines) / 2;
  textArea.style.paddingTop = `${top}px`;
}

window.onload = update;

19. interface 和 type 的区别

相同点:

  • 都可以描述对象

  • 都允许扩展(extends)

不同点:

  • type 可以为任何类型引入名称,interface 只能描述对象

  • type 不支持继承,只能通过交叉类型合并,interface 可以通过继承扩展,也可以通过重载扩展

  • type 无法被实现 implements,而接口可以被派生类实现

  • type 重名会抛出错误,interface 重名会产生合并

  • interface 性能比 type 好一点(社区有讨论过这点,争议比较大,不管对不对,我贴出来兄弟们自己判断吧)

20. gulp 和 webpack 的区别

gulp

webpack

定位

强调的是规范前端开发的流程

是一个前端模块化方案

目标

自动化和优化开发工作流,为通用 website 开发而生

通用模块打包加载器,为移动端大型 SPA 应用而生

学习难度

易于学习,易于使用,api 总共只有 5 个方法

有大量新的概念和 api,有详尽的官方文档

使用场景

基于流的作用方式合适多页面应用开发

一切皆模块的特点适合单页面应用开发

作业方式

对输入(gulp.src)的 js,ts,scss,less 等资源文件进行打包(bundle)、编译(compile)、压缩、重命名等处理后(guld.dest)到指定目录中去,为了构建而打包

对入口文件(entry)递归解析生成依赖关系图,然后将所有以来打包在一起,在打包之前将所有依赖转译成可打包的 js 模块,为了打包而构建

使用方式

常规 js 开发,编写一些列构建任务(task)

编辑各种 JSON 配置

优点

适合多页面开发,易于学习,易于使用,接口优雅

可以打包一切资源,适配各种模块系统

缺点

在大页面应用方面输出乏力,而且对流行的大页面技术有些难以处理(比如 vue 但文件组织,使用 gulp 处理就会很困难,而 webpack 一个 loader 就能轻松搞定)

不适合多页应用开发,灵活度高但同时配置很繁琐复杂,“打包一切”这个优点对于 HTTP1.1 尤其重要,因为所有资源打包在一起能明显减少浏览器访问页面时的请求数量,从而减少应用程序必须等待的时间。但这个有点可能会随着 HTTP/2 的流行而变得不那么突出,因为 HTTP/2 的多路复用可以有效解决客服端并行请求的瓶颈问题。

结论

浏览器多页应用(MPA)首选方案

浏览器单页应用(SPA)首选方案

21. 手写 getQueryString

const src = '<https://www.baidu.com/?id=123&name=aaa&phone=12345>';

const getQueryString = url => {
  if (!url.includes('?')) {
    return null;
  }
  const [, search] = url.split('?');
  const obj = {};
  search.split('&').forEach(item => {
    if (item.includes('=')) {
      const [key, val] = item.split('=');
      Reflect.set(obj, key, val);
    }
  });
  return obj;
};

getQueryString(src);
// { id: "123", name: "aaa", phone: "12345" }

22. 手写 Array.flat(Infinity)

const isArray = Array.isArray;

const flatDeep = arr => {
  return arr.reduce((acc, val) => acc.concat(isArray(val) ? flatDeep(val) : val), []);
};

flatDeep([1, 2, [3, [4, [5, 6]]]]);
// [1, 2, 3, 4, 5, 6]

23. 算法 — 有效的括号

// map解法
const isValid = (s: string): boolean => {
  if (s.length & 1) {
    return false;
  }
  const stack: string[] = [];
  const map = new Map<string, string>();
  map.set('(', ')');
  map.set('{', '}');
  map.set('[', ']');
  for (let i = 0; i < s.length; i++) {
    const c = s[i];
    if (map.has(c)) {
      stack.push(c);
    } else {
      const t = stack.at(-1);
      if (map.get(t) === c) {
        stack.pop();
      } else {
        return false;
      }
    }
  }
  return stack.length === 0;
};

// 栈解法
const isValid2 = (s: string): boolean => {
  if (s.length & 1) {
    return false;
  }
  const stack: string[] = [];
  for (let i = 0; i < s.length; i++) {
    const c = s[i];
    if (['(', '[', '{'].includes(c)) {
      stack.push(c);
    } else {
      const t = stack.at(-1);
      if ((t === '(' && c === ')') || (t === '[' && c === ']') || (t === '{' && c === '}')) {
        stack.pop();
      } else {
        return false;
      }
    }
  }
  return stack.length === 0;
};

24. 图片加载失败处理方式

图片为空很容易判断:

<img src={imgSrc || defaultSrc} />

图片加载失败,使用图片自带的 error 事件处理即可:

<img
  src={imgSrc}
  onError={event => {
    event.currentTarget.src = defaultSrc;
  }}
/>

注意有些加载 404 的图片不会走error 事件,而是走了load事件,那么我们可以通过直接添加一个占位底图来实现,这样如果能加载就会覆盖占位图,如果不能加载那就会显示底下的底图

<div>
  <img src={imgSrc} />
  <img src={defaultSrc} />
</div>

25. 判断对象中是否存在某个属性的三种方法

1. hasOwnProperty()

hasOwnProperty方法会返回一个布尔值,指示对象自身属性中是否具有指定的属性(不包含原型上的属性):

({ a: 1 }.hasOwnProperty('a')); // true
({ a: 1 }.hasOwnProperty('toString')); // false

2. in 操作符

in 操作符会返回一个布尔值,指示对象自身属性中是否具有指定的属性(包含原型上的属性):

'a' in { a: 1 }; // true
'toString' in { a: 1 }; // true

3. Reflect.has()

Reflect.has作用与in 操作符相同:

Reflect.has({ a: 1 }, 'a'); // true
Reflect.has({ a: 1 }, 'toString'); // true

26. 实现深拷贝

1. 简易版

这个方法有些缺点,懂的都懂,不再废话了

const newData = JSON.parse(JSON.stringify(data));

2. 加强版

const deepClone = obj => {
  const ans = Array.isArray(obj) ? [] : {};
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      ans[key] = obj[key] && typeof obj[key] === 'object' ? deepClone(obj[key]) : obj[key];
    }
  }
  return ans;
};

const newData = deepClone(data);

3. 非主流版

structuredClone:原生 js 的深拷贝,因为是新出的,所以兼容差的要死,不建议使用

const newData = structuredClone(data);

目前只有浏览器可以用,node 环境还不支持,并且只有最新几个版本的浏览器才能用

对了,而且这个方法不能拷贝函数,遇到函数会直接报错,嘻嘻嘻

4. 终极版

import { cloneDeep } from 'lodash';

const newData = cloneDeep(data);

27. 让指定方法最多只能被调用 1 次

/**
 * @param n 最多调用次数
 * @param func 回调函数
 */
function before(n, func) {
  if (typeof n !== 'number') {
    throw new TypeError('Expected a number');
  }
  if (typeof func !== 'function') {
    throw new TypeError('Expected a function');
  }
  let result;
  return function (...args) {
    if (--n >= 0) {
      result = func.apply(this, args);
    }
    if (n < 0) {
      func = null;
    }
    return result;
  };
}

function once(func) {
  return before(1, func);
}

// 使用:

const initialize = once(doSomething);

initialize(); // 只有第一次有效
initialize(); // 无效
initialize(); // 无效

28. 判断是否为原生函数

  • lodash 源码中是这样实现的:

const reIsNative = RegExp(
  `^${Function.prototype.toString
    .call(Object.prototype.hasOwnProperty)
    .replace(/[\\\\^$.*+?()[\\]{}|]/g, '\\\\$&')
    .replace(/hasOwnProperty|(function).*?(?=\\\\\\()| for .+?(?=\\\\\\])/g, '$1.*?')}$`
);

const isObject = value => {
  return value && ['object', 'function'].includes(typeof value);
};

const isNative = value => {
  return isObject(value) && reIsNative.test(value);
};

// 使用:
isNative([].push); // true
isNative(myFunction); // false
  • vue 源码中是这样实现的:

const reIsNative = /native code/;

const isObject = value => {
  return value && ['object', 'function'].includes(typeof value);
};

const isNative = value => {
  return isObject(value) && reIsNative.test(value.toString());
};

// 使用:
isNative([].push); // true
isNative(myFunction); // false

不知道 lodash 为啥实现的如此复杂,可能是因为 lodash 太老了吧,都多少年了……

29. 不创建新变量的前提下,交换两个变量

方法一:四则运算

注意:由于 IEEE 754 标准的存在,第一种方法并不是一定安全的,可能会出现精度问题。

let [a, b] = [1, 2];

a = a + b;
b = a - b;
a = a - b;

console.log(a, b); // 2 1

方法二:位运算

let [a, b] = [1, 2];

a = a ^ b;
b = a ^ b;
a = a ^ b;

console.log(a, b); // 2 1

方法三:解构

let [a, b] = [1, 2];

[a, b] = [b, a];

console.log(a, b); // 2 1

30. 猜打印顺序

猜一猜下面代码的打印顺序:

const object = { a2: '', 2: '', 1: '', a1: '' };

for (const key in object) {
  console.log(key);
}

先说答案:顺序是 1、2、a2、a1

解释:js 在对对象的 key 进行遍历的时候,会先判断 key 的类型,如果是 number 类型,则会放在前面,并且进行排序,如果是 string 类型,则放在后面,不进行排序(对 number 排序是为了方便内存寻址,string 不能进行四则运算,所以排序没有意义)。

31. 猜打印结果

console.log(11);

结果:11

解释:普通的十进制数字,没啥好解释的

console.log(0.11); // .11 前面本来没有0 保存的时候编辑器自动格式化了,淦

结果:0.11

解释:如果数值前面的整数部分为 0,那么 js 允许我们省略

console.log(11); // 11. 后面有个. 保存的时候编辑器自动格式化了,淦

结果:11

解释:如果小数点后面的小数部分为 0,那么 js 允许省略

console.log(011);

结果:9

解释:如果数值前面以 0 开头,那么 js 会把它当成八进制,逢八进一

console.log(080);

结果:80

解释:因为八进制的数值里面不可能出现数字 8,所以这种情况下是无效的八进制,js 会当成十进制进行处理

console.log(0o11);

结果:9

解释:0o 开头的数值也会被当成八进制处理

console.log(0o80);

结果:报错

解释:0o 开头的数值会被当成八进制处理,但是八进制的数值里面不可能出现数字 8,所以直接报错了

console.log(0b11);

结果:3

解释:0b 开头的数值会被当成二进制处理

console.log(0x11);

结果:17

解释:0x 开头的数值会被当成十六进制处理

console.log(11e2);

结果:1100

解释:科学计数法,表示 11 (10 * 2)

console.log(11.toString());

结果:报错

解释:在数字转字符串的过程中,toString 方法被当成小数点后面的小数部分了,所以报错了,正确写法如下:

// 方法一,小数点后面加空格
11. toString();

// 方法二,小数点后面再次调用toString
11..toString();

// 方法三,使用括号运算符提升优先级
(11).toString();

// 方法四,提前申明变量
const num = 11;
const string = num.toString();

32. 隐藏元素之 display、visibility、opacity

相同点:都能控制元素在视图中的可见性

不同点:直接看图

display: none

visibility: hidden

opacity: 0

是否生成盒子

是否占据空间

是否可以交互

是否参与回流

是否参与重绘

33. TCP 与 UDP 的区别

相同点

  • TCPUDP 都是运行在运输层的协议

  • TCPUDP 的通信都需要开放端口

不同点

TCP

UDP

面向连接的协议,提供全双工通信,需要建立链接之后再传输数据,数据传输负载相对较大

无连接的,即发送数据之前不需要建立连接,数据传输负载相对较小。

提供可靠交付的服务,使用流量控制和拥塞控制等服务保证可靠通信。

使用尽最大努力交付,即不保证可靠交付,同时也不使用流量控制和拥塞控制。

首部最小 20 字节,最大 60 字节,包括源端口、目的端口、序号、确认号、数据偏移、控制标志、窗口、校验和、紧急指针、选项等信息。

首部 8 字节,包括源端口、目的端口、长度、校验和信息。

只能是一对一通信。

具有单播、多播、广播的功能,支持一对一、一对多、多对多、多对一的数据传输方式。

面向字节流通信。

是面向报文通信,对应用层交下来的报文,既不合并,也不拆分,而是保留这些报文的边界,在添加首部后就向下交付 IP 层。

保证数据传输的顺序,通过给 TCP 连接中传送数据流的每个字节都编上序号来确定传输顺序。

不保证数据传输的顺序,需要应用层程序在数据段加入序号等方式控制顺序。

提供校验和、确认应答、序列号、超时重传、连接管理、流量控制、拥塞控制等功能。

只在 IP 的数据报服务之上增加了很少一点的功能,即端口的功能和差错检测的功能。

适用于要求可靠传输的应用,如文件传输等

适用于实时应用,如网络电话、视频会议、直播等

34. 关于代码质量引发的一些哲学问题

前言:之所以讨论这个话题,是因为在掘金上看到了一篇关于设计模式的文章,但是文章的作者为了封装而封装,为了职责链模式而硬套了,把原本很正常的逻辑变的更加复杂,于是引起了评论区一些大佬的讨论。

其中一个大佬的评论总结的很到位,也引发了一些思考,所以摘录在下面:

1、为了封装而封装,硬套设计模式,这就是代码越写越乱的典型(负优化)

2、大筐里有 4 种萝卜,作者觉得这样很乱,于是往大筐里又套 4 个小筐,把萝卜放到小筐里(犯了形而上学的错误,只是对代码量进行了转移,并没有减少,甚至为了转移后的联系,增加了很多额外代码)。

3、奥卡姆剃刀原理,如无必要、勿增实体,没有必要把一段简单的 switch case 或者几行 if else 判断,直接在拦截器里可以搞定的事情,拆成 n 个子模块,而且为了联系上下文还要写一堆无用代码来桥接。

4、泰斯勒定律,复杂性守恒原理,复杂度不会凭空增加或消除,只能对复杂性进行转移,这里是转移了复杂性,但是因为上下文的联系,不得不增加额外代码,这就增加了复杂性,所以转移的目的没有任何意义。

5、责任链设计模式,作者只掌握了形式,并没有掌握精髓。

6、其他评论说的对,这个场景的模式选择的不对,策略模式更加合适。

35. 老掉牙的面试题:React diff 是什么?可以省略吗?

回答:可以省略,但是强烈不推荐(废话文学,面试的时候直接说不可以就好了)

下面看满分答案:

  • key 的作用就是服务于 diff 算法,是节点是否可以复用的首要判定条件

  • 如果省略了 key,内部会默认使用 null,在列表节点有排序需求的情况下,会造成性能损耗

在 react 组件开发的过程中,key是一个常用的属性值,多用于列表开发. 这里从源码的角度,分析keyreact内部是如何使用的,key是否可以省略.

ReactElement 对象

我们在编程时直接书写的jsx代码,实际上是会被编译成 ReactElement 对象,所以keyReactElement对象的一个属性.

构造函数

在把jsx转换成ReactElement对象的语法时,有一个兼容问题. 会根据编译器的不同策略,编译成 2 种方案.

  1. 最新的转译策略: 会将jsx语法的代码,转译成jsx()函数包裹

    jsx函数: 只保留与key相关的代码(其余源码这里不讨论)

    /**
     * <https://github.com/reactjs/rfcs/pull/107>
     * @param {*} type
     * @param {object} props
     * @param {string} key
     */
    export function jsx(type, config, maybeKey) {
      let propName;
    
      // 1. key的默认值是null
      let key = null;
    
      // Currently, key can be spread in as a prop. This causes a potential
      // issue if key is also explicitly declared (ie. <div {...props} key="Hi" />
      // or <div key="Hi" {...props} /> ). We want to deprecate key spread,
      // but as an intermediary step, we will use jsxDEV for everything except
      // <div {...props} key="Hi" />, because we aren't currently able to tell if
      // key is explicitly declared to be undefined or not.
      if (maybeKey !== undefined) {
        // 2. 将key转换成字符串
        key = '' + maybeKey;
      }
    
      if (hasValidKey(config)) {
        // 2. 将key转换成字符串
        key = '' + config.key;
      }
      // 3. 将key传入构造函数
      return ReactElement(type, key, ref, undefined, undefined, ReactCurrentOwner.current, props);
    }
    
  2. 传统的转译策略: 会将jsx语法的代码,转译成React.createElement()函数包裹

    React.createElement()函数: 只保留与key相关的代码(其余源码这里不讨论)

    /**
     * Create and return a new ReactElement of the given type.
     * See <https://reactjs.org/docs/react-api.html#createelement>
     */
    export function createElement(type, config, children) {
      let propName;
    
      // Reserved names are extracted
      const props = {};
    
      let key = null;
      let ref = null;
      let self = null;
      let source = null;
    
      if (config != null) {
        if (hasValidKey(config)) {
          key = '' + config.key; // key转换成字符串
        }
      }
    
      return ReactElement(type, key, ref, self, source, ReactCurrentOwner.current, props);
    }
    

可以看到无论采取哪种编译方式,核心逻辑都是一致的:

  1. key的默认值是null

  2. 如果外界有显式指定的key,则将key转换成字符串类型.

  3. 调用ReactElement这个构造函数,并且将key传入.

// ReactElement的构造函数: 本节就先只关注其中的key属性
const ReactElement = function (type, key, ref, self, source, owner, props) {
  const element = {
    $$typeof: REACT_ELEMENT_TYPE,
    type: type,
    key: key,
    ref: ref,
    props: props,
    _owner: owner,
  };
  return element;
};

源码看到这里,虽然还只是个皮毛,但是起码知道了key的默认值是null. 所以任何一个reactElement对象,内部都是有key值的,只是一般情况下(对于单节点)很少显式去传入一个 key.

Fiber 对象

react的核心运行逻辑,是一个从输入到输出的过程(回顾reconciler 运作流程). 编程直接操作的jsxreactElement对象,我们的数据模型是jsx,而react内核的数据模型是fiber树形结构. 所以要深入认识key还需要从fiber的视角继续来看.

fiber对象是在fiber树构造循环过程中构造的,其构造函数如下:

function FiberNode(tag: WorkTag, pendingProps: mixed, key: null | string, mode: TypeOfMode) {
  this.tag = tag;
  this.key = key; // 重点: key也是`fiber`对象的一个属性
  // ...
  this.elementType = null;
  this.type = null;
  this.stateNode = null;
  // ... 省略无关代码
}

可以看到,key也是fiber对象的一个属性. 这里和reactElement的情况有所不同:

  1. reactElement中的key是由jsx编译而来,key是由开发者直接控制的(即使是动态生成,那也是直接控制)

  2. fiber对象是由react内核在运行时创建的,所以fiber.key也是react内核进行设置的,程序员没有直接控制.

注意: fiber.keyreactElement.key的拷贝,他们是完全相等的(包括null默认值)。

接下来分析fiber创建,剖析key在这个过程中的具体使用情况.

fiber对象的创建发生在fiber树构造循环阶段中,具体来讲,是在reconcileChildren调和函数中进行创建.

reconcileChildren 调和函数

reconcileChildrenreact中的一个明星函数,最热点的问题就是diff算法原理,事实上,key的作用完全就是为了diff算法服务的.

注意: 这里只分析 key 相关的逻辑,对于 diff 函数的算法原理不做详细分析

调和函数源码(只摘取了部分代码):

function ChildReconciler(shouldTrackSideEffects) {
  function reconcileChildFibers(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChild: any,
    lanes: Lanes
  ): Fiber | null {
    // Handle object types
    const isObject = typeof newChild === 'object' && newChild !== null;

    if (isObject) {
      switch (newChild.$$typeof) {
        case REACT_ELEMENT_TYPE:
          // newChild是单节点
          return placeSingleChild(
            reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes)
          );
      }
    }
    //  newChild是多节点
    if (isArray(newChild)) {
      return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
    }
    // ...
  }
  return reconcileChildFibers;
}

单节点

这里先看单节点的情况reconcileSingleElement(只保留与key有关的逻辑):

function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement,
  lanes: Lanes
): Fiber {
  const key = element.key;
  let child = currentFirstChild;
  while (child !== null) {
    // 重点1: key是单节点是否复用的第一判断条件
    if (child.key === key) {
      switch (child.tag) {
        default: {
          if (child.elementType === element.type) {
            // 第二判断条件
            deleteRemainingChildren(returnFiber, child.sibling);
            // 节点复用: 调用useFiber
            const existing = useFiber(child, element.props);
            existing.ref = coerceRef(returnFiber, child, element);
            existing.return = returnFiber;
            return existing;
          }
          break;
        }
      }
      // 不匹配,直接删除
      deleteRemainingChildren(returnFiber, child);
      break;
    }
    child = child.sibling;
  }
  // 重点2: fiber节点创建,`key`是随着`element`对象被传入`fiber`的构造函数
  const created = createFiberFromElement(element, returnFiber.mode, lanes);
  created.ref = coerceRef(returnFiber, currentFirstChild, element);
  created.return = returnFiber;
  return created;
}

可以看到,对于单节点来讲,有 2 个重点:

  1. key是单节点是否复用的第一判断条件(第二判断条件是type是否改变,比如div改变为span).

    • 如果key不同,其他条件是完全不看的

  2. 在新建节点时,key随着element对象被传入fiber的构造函数.

所以到这里才是key的最核心作用, 是调和函数中, 针对单节点是否可以复用的第一判断条件.

对于单节点来讲, key是可以省略的, react内部会设置成默认值null. 在进行diff时, 由于null === nulltrue, 前后renderkey是一致的, 可以进行复用比较.

如果单节点显式设置了key,且两次render时的key如果不一致,则无法复用.

多节点

继续查看多节点相关的逻辑:

function reconcileChildrenArray(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChildren: Array<*>,
  lanes: Lanes
): Fiber | null {
  if (__DEV__) {
    // First, validate keys.
    let knownKeys = null;
    for (let i = 0; i < newChildren.length; i++) {
      const child = newChildren[i];
      // 1. 在dev环境下, 执行warnOnInvalidKey.
      //  - 如果没有设置key, 会警告提示, 希望能显式设置key
      //  - 如果key重复, 会错误提示.
      knownKeys = warnOnInvalidKey(child, knownKeys, returnFiber);
    }
  }

  let resultingFirstChild: Fiber | null = null;
  let previousNewFiber: Fiber | null = null;

  let oldFiber = currentFirstChild;
  let lastPlacedIndex = 0;
  let newIdx = 0;
  let nextOldFiber = null;
  // 第一次循环: 只会在更新阶段发生
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    if (oldFiber.index > newIdx) {
      nextOldFiber = oldFiber;
      oldFiber = null;
    } else {
      nextOldFiber = oldFiber.sibling;
    }
    // 1. 调用updateSlot, 处理公共序列中的fiber
    const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);
    if (newFiber === null) {
      // 如果无法复用, 则退出公共序列的遍历
      if (oldFiber === null) {
        oldFiber = nextOldFiber;
      }
      break;
    }
  }

  // 第二次循环
  if (oldFiber === null) {
    for (; newIdx < newChildren.length; newIdx++) {
      // 2. 调用createChild直接创建新fiber
      const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
    }
    return resultingFirstChild;
  }

  for (; newIdx < newChildren.length; newIdx++) {
    // 3. 调用updateFromMap处理非公共序列中的fiber
    const newFiber = updateFromMap(
      existingChildren,
      returnFiber,
      newIdx,
      newChildren[newIdx],
      lanes
    );
  }

  return resultingFirstChild;
}

reconcileChildrenArray中, 有 3 处调用与fiber有关(当然也和key有关了), 它们分别是:

  1. updateSlot

    function updateSlot(
      returnFiber: Fiber,
      oldFiber: Fiber | null,
      newChild: any,
      lanes: Lanes
    ): Fiber | null {
      const key = oldFiber !== null ? oldFiber.key : null;
    
      if (typeof newChild === 'object' && newChild !== null) {
        switch (newChild.$$typeof) {
          case REACT_ELEMENT_TYPE: {
            // 重点: key用于是否复用的第一判断条件
            if (newChild.key === key) {
              return updateElement(returnFiber, oldFiber, newChild, lanes);
            } else {
              return null;
            }
          }
        }
      }
    
      return null;
    }
    
  2. createChild

    function createChild(returnFiber: Fiber, newChild: any, lanes: Lanes): Fiber | null {
      if (typeof newChild === 'object' && newChild !== null) {
        switch (newChild.$$typeof) {
          case REACT_ELEMENT_TYPE: {
            // 重点: 调用构造函数进行创建
            const created = createFiberFromElement(newChild, returnFiber.mode, lanes);
            return created;
          }
        }
      }
    
      return null;
    }
    
  3. updateFromMap

    function updateFromMap(
      existingChildren: Map<string | number, Fiber>,
      returnFiber: Fiber,
      newIdx: number,
      newChild: any,
      lanes: Lanes
    ): Fiber | null {
      if (typeof newChild === 'object' && newChild !== null) {
        switch (newChild.$$typeof) {
          case REACT_ELEMENT_TYPE: {
            //重点: key用于是否复用的第一判断条件
            const matchedFiber =
              existingChildren.get(newChild.key === null ? newIdx : newChild.key) || null;
            return updateElement(returnFiber, matchedFiber, newChild, lanes);
          }
        }
        return null;
      }
    }
    

针对多节点的diff算法可以分为三个步骤(请回顾算法章节React 算法之调和算法):

  1. 第一次循环:比较公共序列

    • 从左到右逐一遍历, 遇到一个无法复用的节点则退出循环.

  2. 第二次循环:比较非公共序列

    • 在第一次循环的基础上, 如果oldFiber队列遍历完了, 证明newChildren队列中剩余的对象全部都是新增.

    • 此时继续遍历剩余的newChildren队列即可, 没有额外的diff比较.

    • 在第一次循环的基础上, 如果oldFiber队列没有遍历完, 需要将oldFiber队列中剩余的对象都添加到一个map集合中, 以oldFiber.key作为键.

    • 此时则在遍历剩余的newChildren队列时, 需要用newChild.keymap集合中进行查找, 如果匹配上了, 就将oldFibermap中取出来, 同newChild进行diff比较.

  3. 清理工作

    • 在第二次循环结束后, 如果map集合中还有剩余的oldFiber,则可以证明这些oldFiber都是被删除的节点, 需要打上删除标记.

通过回顾diff算法的原理, 可以得到key在多节点情况下的特性:

  1. 新队列newChildren中的每一个对象(即reactElement对象)都需要同旧队列oldFiber中有相同key值的对象(即oldFiber对象)进行是否可复用的比较. key就是新旧对象能够对应起来的唯一标识.

  2. 如果省略key或者直接使用列表index作为key, 表现是一样的(key=null时, 会采用index代替key进行比较). 在新旧对象比较时, 只能按照index顺序进行比较, 复用的成功率大大降低, 大列表会出现性能问题.

    • 例如一个排序的场景: oldFiber队列有 100 个, newChildren队列有 100 个(但是打乱了顺序). 由于没有设置key, 就会导致newChildren中的第 n 个必然要和oldFiber队列中的第 n 个进行比较, 这时它们的key完全一致(都是null), 由于顺序变了导致props不同, 所以新的fiber完全要走更新逻辑(理论上比新创建一个的性能还要耗).

    • 同样是排序场景可以出现的 bug: 上面的场景只是性能差(又不是不能用), key使用不当还会造成bug

    • 还是上述排序场景, 只是列表中的每一个item内部又是一个组件, 且其中某一个item使用了局部状态(比如class组件里面的state). 当第二次render时, fiber对象不会delete只会update导致新组件的state还沿用了上一次相同位置的旧组件的state,造成了状态混乱。

总结

reactkey是服务于diff算法, 它的默认值是null, 在diff算法过程中, 新旧节点是否可以复用, 首先就会判定key是否相同, 其后才会进行其他条件的判定. 在源码中, 针对多节点(即列表组件)如果直接将key设置成index和不设置任何值的处理方案是一样的, 如果使用不当, 轻则造成性能损耗, 重则引起状态混乱造成 bug.

36. 扁平数组转 tree 结构

要求:输入 list,输出对应的 result

interface ArrayItem {
  id: number;
  name: string;
  pid: number;
}

interface TreeItem extends ArrayItem {
  children?: TreeItem[];
}

// 输入
const list: ArrayItem[] = [
  { id: 1, name: '部门1', pid: 0 },
  { id: 2, name: '部门2', pid: 1 },
  { id: 3, name: '部门3', pid: 1 },
  { id: 4, name: '部门4', pid: 3 },
  { id: 5, name: '部门5', pid: 4 },
];

// 输出
const result: TreeItem[] = [
  {
    id: 1,
    name: '部门1',
    pid: 0,
    children: [
      {
        id: 2,
        name: '部门2',
        pid: 1,
      },
      {
        id: 3,
        name: '部门3',
        pid: 1,
        children: [
          {
            id: 4,
            name: '部门4',
            pid: 3,
            children: [
              {
                id: 5,
                name: '部门5',
                pid: 4,
              },
            ],
          },
        ],
      },
    ],
  },
];

实现:

const arrToTree = (arr: ArrayItem[]): TreeItem[] => {
  const res: TreeItem[] = [];
  const map = new Map<PropertyKey, TreeItem>();
  arr.forEach(item => {
    map.set(item.id, item);
  });
  arr.forEach(item => {
    const parent = map.get(item.pid);
    if (parent) {
      if (parent?.children) {
        parent.children.push(item);
      } else {
        parent.children = [item];
      }
    } else {
      res.push(item);
    }
  });
  return res;
};

测试结果:

const ans = arrToTree(list);

console.log(JSON.stringify(ans) === JSON.stringify(result)); // true


评论