Skip to content

前端碎片知识集

作者:Atom
字数统计:15.9k 字
阅读时长:59 分钟

我与我周旋久, 宁做我     — 殷浩

在前端的学习旅途中,我们时常会像漫游宇宙般穿梭在各种知识星球之间。有时,我们会发现一颗熠熠生辉的星辰,它可能是一种新的技术,一种优化方案,亦或是一段灵感的闪现。

于是,我将自己在前端旅途中搜集到的那些零散而珍贵的知识点,如同星辰般闪耀,融汇于这篇文章之中。这或许是一个小小的知识集合,但在我的成长过程中,它们无疑是重要的里程碑。

如何学习一门语言

  • 语言优势和使用场景
  • 基础语法(常量、变量、函数、类、流程控制)
  • 内置库及API
  • 框架及第三方库
  • 开发环境搭建及调试
  • 线上环境部署及监控

XMLHttpRequest

异步请求中, xhr对象的readyState主要以下几个状态:

  • UNSET - 0 尚未调用open方法
  • OPENED - 1 open方法已调用
  • HEAD_RECEIVED - 2 send方法已调用, header已被接收
  • LOAD - 3 responseText已有部分内容
  • DONE - 4 请求完成
js
const xhr = new XMLHttpRequest()
xhr.open('POST', 'www.baidu.com', true) // 默认True异步
setRequestHeader("Content-type", "application/json");
xhr.onreadystatechange = function(){
  if(xhr.readyState === 4){
    if(xhr.staus === 200){
      console.log('请求成功!')
    }
  }
}
xhr.send(JSON.stringify({name: 'zs'}))

xhrreadyState状态为DONE的时候, 也就等同于onload事件完成, 所以Xhr2的标准中可以使用onload

js
xhr.onload = function(){
  if(xhr.staus === 200){
    console.log('请求成功!')
  }
}

TCP三次握手

第一次握手: 客户端A将标志位SYN置为1,随机产生一个值为seq=J(J的取值范围为=1234567)的数据包到服务器,客户端A进入SYN_SENT状态,等待服务端B确认;

第二次握手: 服务端B收到数据包后由标志位SYN=1知道客户端A请求建立连接,服务端B将标志位SYN和ACK都置为1,ack=J+1,随机产生一个值seq=K,并将该数据包发送给客户端A以确认连接请求,服务端B进入SYN_RCVD状态。

第三次握手: 客户端A收到确认后,检查ack是否为J+1,ACK是否为1,如果正确则将标志位ACK置为1,ack=K+1,并将该数据包发送给服务端B,服务端B检查ack是否为K+1,ACK是否为1,如果正确则连接建立成功,客户端A和服务端B进入ESTABLISHED状态,完成三次握手,随后客户端A与服务端B之间可以开始传输数据了。

TCP 四次挥手

第一次挥手: Client发送一个FIN,用来关闭Client到Server的数据传送,Client进入FIN_WAIT_1状态。

第二次挥手: Server收到FIN后,发送一个ACK给Client,确认序号为收到序号+1(与- SYN相同,一个FIN占用一个序号),Server进入CLOSE_WAIT状态。

第三次挥手: Server发送一个FIN,用来关闭Server到Client的数据传送,Server进入LAST_ACK状态。

第四次挥手: Client收到FIN后,Client进入TIME_WAIT状态,接着发送一个ACK给Server,确认序号为收到序号+1,Server进入CLOSED状态,完成四次挥手。

屏幕响应式适配

如今移动端响应式屏幕方案主要分为rem适配vw适配

rem适配

  • 将css属性单位从px改为rem

  • 动态获取用户设备的屏幕宽度

sh
将项目的根字体大小设置为:

fontSize = width(真实设备的屏幕宽度) / width(设计稿的屏幕宽度) * fontSize(设计稿中的根字体大小)

根据这个等比例公式, 动态设置设备的根字体大小

js
document.documentElement.style.fontSize =
   Math.min(screen.width, document.documentElement.getBoundingClientRect().width)
   / 750 * 75(设计稿根字体大小)
   + 'px'

此处的设计稿中的根字体大小可以随意设置任意正数值, 但是必须与postcss-pxtorem这种插件中的配置值一致

js
// postcss.config.js
module.exports = {
  plugins: {
    'postcss-pxtorem': {
      rootValue: 75,  // 可以随意设置, 与动态值匹配即可
      propList: ['*'],
      minPixelValue: 2
    }
  }
};

vw适配

vw适配方案将不需要动态的js来计算屏幕宽度, 只需要使用postcss-px-to-viewport插件, 按照设计稿的参数来配置即可

js
module.exports = {
  plugins: {
    'postcss-px-to-viewport': {
      unitToConvert: 'px', // 要转化的单位
      viewportWidth: 750, // UI设计稿的宽度
      unitPrecision: 3, // 转换后的精度,即小数点位数
      propList: ['*'], // 指定转换的css属性的单位,*代表全部css属性的单位都进行转换
      viewportUnit: 'vw', // 指定需要转换成的视窗单位,默认vw
      fontViewportUnit: 'vw', // 指定字体需要转换成的视窗单位,默认vw
      selectorBlackList: [], // 指定不转换为视窗单位的类名,
      minPixelValue: 1, // 默认值1,小于或等于1px则不进行转换
      mediaQuery: true, // 是否在媒体查询的css代码中也进行转换,默认false
      replace: true, // 是否转换后直接更换属性值
      exclude: [/node_modules/], // 设置忽略文件,用正则做目录名匹配
      landscape: false // 是否处理横屏情况
    }
  }
}

rem + vw 适配

以下例子可以适配设计稿为750px的尺寸

html
<head>
  <style>
    /* 根字体大小设置为10vw */
    html {
      font-size: 10vw;
    }
  </style>
</head>
js
// postcss.config.js
module.exports = {
  plugins: {
    'postcss-pxtorem': {
      rootValue: 75,  // 可以随意设置, 与动态值匹配即可
      propList: ['*'],
      minPixelValue: 2
    }
  }
};

BEM命名规范

BEM是块(block)、元素(element)、修饰符(modifier)的简写

  • 中划线( - ): 仅作为连字符使用, 表示某个块或者某个子元素的多单词之间的连接记号(单词间隔)
  • 双下划线( __ ): 双下划线用来连接块和块的子元素(连接块元素)
  • 双中划线( -- ): 双中划线用来描述一个块或者块的子元素的一种状态(元素状态)

案例

html
<div class="card">
  <img class="card__img" src="./img.jpg" alt="">
  <div class="card__content">
    <ul class="card__list">
      <li class="card__item card__item--active">手机</li>
      <li class="card__item">移动市场</li>
      <li class="card__item">科技</li>
    </ul>
    <p class="card__desc">商化前端是一个很有活力的团队,能学到很多知识,你心动了吗?</p>
    <a class="card__link" href="#">详细内容</a>
  </div>
</div>

对应的sass结构如下:

scss
.card{
  &__img{}
  &__content {}
  &__list{}
  &__item {}
  &__item--active {}
  &__link{}
  &__link:hover{}
}

刚开始使用 BEM 的时候容易犯一个问题,就是把 ulli 的样式写成 card__content__listcard__content__list__item 因为这样更能体现层级的关系。

其实这有悖BEM命名规范,BEM的命名中只包含三个部分,元素名只占其中一部分,所以不能出现多个元素名的情况。这样的约定可以防止当层级很深命名过长的问题。

作用域分类

JS 总共有 9 种作用域,我们通过调试的方式来分析了下:

  • Global 作用域: 全局作用域,在浏览器环境下就是 window,在 node 环境下是 global
  • Local 作用域:本地作用域,或者叫函数作用域
  • Block 作用域:块级作用域
  • Script 作用域:let、const 声明的全局变量会保存在 Script 作用域,这些变量可以直接访问,但却不能通过 window.xx 访问
  • Module 作用域:es module 模块运行的时候会生成 Module 作用域,而 commonjs 模块运行时严格来说也是函数作用域,因为 node 执行它的时候会包一层函数,算是比较特殊的函数作用域,有 module、exports、require 等变量
  • Catch Block 作用域: catch 语句的作用域可以访问错误对象
  • With Block 作用域:with 语句会把传入的对象的值放到单独的作用域里,这样 with 语句里就可以直接访问了
  • Closure 作用域:函数返回函数的时候,会把用到的外部变量保存在 Closure 作用域里,这样再执行的时候该有的变量都有,这就是闭包。eval 的闭包比较特殊,会把所有变量都保存到 Closure 作用域
  • Eval 作用域:eval 代码声明的变量会保存在 Eval 作用域

资源提示符

html标签包含很多资源提示符, 常用async、defer、preload、prefetch

  • script标签async 不阻塞dom解析, 等script加载完成, 立即停止dom解析, 执行script

  • script标签defer不阻塞dom解析, 等script加载完成, 不会立即执行, 而是等到DomContentLoaded事件开始之前执行

  • link标签preload尽快获取并缓存, 当前页面可能会用到

  • link标签prefetch空闲时间获取并缓存, 下个页面可能会用到

经典三栏布局

经典的三栏布局常用双飞翼布局圣杯布局

双飞翼布局

html
<style>
  .middle,
  .left,
  .right {
    float: left;
    height: 300px;
  }

  .shuangfeiyi {
    /* 生成BFC, 清除浮动 */
    overflow: hidden;
  }

  .middle {
    width: 100%
  }

  .left {
    width: 200px;
    margin-left: -100%;
  }

  .right {
    width: 300px;
    margin-left: -300px;
  }

  .middle__content {
    margin: 0 300px 0 200px
  }
</style>

<div class="shuangfeiyi">
  <div class="middle">
    <div class="middle__content"></div>
  </div>
  <div class="left"></div>
  <div class="right"></div>
</div>

圣杯布局

html
<style>
  .middle,
  .left,
  .right {
    float: left;
    height: 300px;
  }

  .shengbei {
    /* 生成BFC, 清除浮动 */
    overflow: hidden;
    padding: 0 300px 0 200px;
  }

  .middle {
    width: 100%;
    background-color: antiquewhite;
  }

  .left {
    width: 200px;
    margin-left: -100%;
    position: relative;
    right: 200px;
    background-color: pink;
  }

  .right {
    width: 300px;
    margin-right: -300px;
    background-color: aquamarine;
  }
</style>

<div class="shengbei">
  <div class="middle">中间</div>
  <div class="left">左侧</div>
  <div class="right">右侧</div>
</div>

React

React Effect

当我们编写组件时,应该尽量将组件编写为纯函数。

对于组件中的副作用,首先应该明确:

是「用户行为触发的」还是「视图渲染后主动触发的」?

对于前者,将逻辑放在Event handlers中处理。

对于后者,使用useEffect处理。

React 首行引入

新版本React函数式组件, 不需要每次在引入React库, 是因为babel做了处理, Automatic Runtime会自动注入行首的React引入逻辑

只需要将babel配置为:

js
"presets": [
    ["@babel/preset-react",{
     "runtime": "automatic"
    }]
],

在代码中就会自动引入

js
// 会被自动引入
import { jsx as _jsx } from "react/jsx-runtime";

React 元素隐藏

在React中, 当一个元素为falseundefinednull时, 该元素将不显示。 因此开发时,要注意元素判断条件是否为这几类值, 否则会导致bug

js
class App {
  render() {
    const { list } = this.state
    return <div className="page-index-box">
      {
       // 该写法, 当length为0, 会显示0, 导致bug
        list.length && list.map(() =>
          <div>
            {list.id} - {list.name}
          </div>
        )
      }
    </div>
  }
}

React SetState

假如一次事件中触发一次如上 setState ,在 React 底层主要做了那些事呢?

  • 首先,setState 会产生当前更新的优先级(老版本用 expirationTime ,新版本用 lane)
  • 接下来 React 会从 fiber Root 根部 fiber 向下调和子节点,调和阶段将对比发生更新的地方,更新对比 expirationTime ,找到发生更新的组件,合并 state,然后触发 render 函数,得到新的 UI 视图层,完成 render 阶段
  • 接下来到 commit 阶段,commit 阶段,替换真实 DOM ,完成此次更新流程
  • 此时仍然在 commit 阶段,会执行 setStatecallback 函数,如上的()=>{ console.log(this.state.number) },到此为止完成了一次 setState 全过程

更新的流程图如下:

主要任务的先后顺序如下,这对于弄清渲染过程可能会有帮助

  • 首先, render 阶段 render 函数执行
  • 其次, commit 阶段真实 DOM 替换
  • 最后, setState 回调函数执行 callback

Node

Node Babel的配置

  • 安装依赖
sh
pnpm i @babel/core @babel/preset-env @babel/plugin-transform-runtime -D
# @babel/runtime-corejs3会将core-js@作为依赖安装
pnpm i @babel/runtime-corejs3 -S
  • 书写配置文件
js
module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        targets: {
          node: "current"
        },
        // 关闭 @babel/preset-env 默认的 Polyfill 注入
        useBuiltIns: "usage",
        // 使用core-js@3提供polyfill
        corejs: 3,
        // 是否转换为其他模块, 可以转换成‘commonjs’、'amd'、‘umd’、‘systemjs’
        modules: false
      }
    ]
  ],
  plugins: [
    // 添加 transform-runtime 插件
    [
      '@babel/plugin-transform-runtime',
      { corejs: 3 }
    ]
  ]
}

Node ESM模块开发

  • Node中使用ESM模块开发, 需要处理常用的内置变量, 例如__dirname或者__filename
js
import path from 'path'

const __filename = new URL(import.meta.url).pathname
const __dirname = path.dirname(__filename)

Node v20.11更新中, 通过 import.meta 添加了对以上两个变量的支持

js
const { filename: __filename, dirname: __dirname } = import.meta
  • Node ESM模块中使用json文件应该怎么做?
js
// 对于 Node 17.5+,你可以使用导入断言
import pkg from './package.json' assert { type: 'json' };
export default {
	external: Object.keys(pkg.dependencies)
};

// 如果版本低于17.5, 通用情况就使用fs读取
import { readFileSync } from 'node:fs';
const packageJson = JSON.parse(
	readFileSync(new URL('./package.json', import.meta.url))
);

Node Spawn双向通信

js
const { spawn } = require('child_process');

const svrCodeWatchProcess = spawn('npm', ['run', 'svr:watch'], { shell: process.platform === 'win32' });

// 父进程发送消息给子进程
svrCodeWatchProcess.stdin.write('Hello from parent process!');

// 子进程接收父进程消息
process.stdout.on('data', (data) => {
  console.log('Received message from child process:', data.toString());
});

// 子进程发送消息给父进程
process.stdout.write('Message from child process');

// 父进程接收子进程消息
svrCodeWatchProcess.stdout.on('data', (data) => {
  console.log('Received message from child process:', data.toString());
});

微前端

  • 增量开发及迁移
  • 独立构建发布
  • 包容不同的技术栈

HMR分类

HMR主要分为热重载和热替换, 两者的区别如下:

  • 热重载: 代码变化重新加载整个应用, 会重刷整个页面, 导致状态和数据丢失

  • 热替换: 只会替换修改的模块, 不重刷页面, 状态数据得以保留

HMR热更新原理

热更新主要是由4个部分组成

  • HMR Server 服务端
  • Compiler 编译器
  • Module 模块
  • HMR Runtime 客户端

首先, 服务端和客户端会建立Websocket连接, 当代码文件发生变动, HMR Server告知HMR Runtime需要检查更新, HMR Runtime 发起一个请求,获取变更的模块信息JSON文件, 从JSON中获取到已经发生变更的模块;

此时, HMR Runtime异步下载需要更新的模块, 一切就绪后, HMR Server通过 WebSocket 通知 HMR Runtime 可以同步应用更新, HMR Runtime 将最新的模块进行替换,将老的模块解绑,最后更新应用 Hash,并开始以更新的模块为起点向上进行冒泡

如果, 在模块中有注册了 HMR module.hot.accept接口, 那么就会调用该接口来实现元素如何替换

HMR整体流程

Webpack DevServerHMR(Hot Module Replacement)是一种在开发过程中实现模块热替换的机制,它能够在不刷新整个页面的情况下,实时更新已经修改的模块。下面是 Webpack DevServer HMR 的主要流程:

启用 HMR: 在 Webpack 配置中,你需要启用 HMR 功能。这通常通过 webpack-dev-server 提供的 hot 参数来实现。例如:

js
// webpack.config.js
module.exports = {
  // ...
  devServer: {
    hot: true,
  },
  // ...
};
  • 启动开发服务器: 运行 webpack-dev-server 命令启动开发服务器。开发服务器会将打包后的文件提供给浏览器,并监听文件变化。

  • 构建并打包: Webpack 会将项目的源代码进行构建和打包。每个模块都会生成一个唯一的 ID,并且在构建过程中会生成一些用于 HMR 的额外代码。

  • HMR runtime 注入: Webpack 会在构建过程中,将 HMR runtime(HMR 的运行时)注入到打包后的文件中。HMR runtime 负责在浏览器端与开发服务器进行通信,接收更新,并触发模块的热替换。

  • 浏览器连接到开发服务器: 当你在浏览器中访问开发服务器时,浏览器会与开发服务器建立连接。这个连接使用 WebSocket 协议,因为 WebSocket 具有实时双向通信的能力。

  • 监听文件变化: 开发服务器会监听项目源代码的变化,包括源代码中的 JavaScript 文件、CSS 文件等。

  • 文件更新推送: 当文件发生变化时,Webpack DevServer 会通过 WebSocket 将更新的模块信息推送给浏览器中的 HMR runtime

  • 模块热替换: 在接收到更新的模块信息后,HMR runtime 会根据这些信息进行模块热替换。它会移除旧的模块,加载新的模块,并应用新的变化,从而实现实时更新。这样,你就能在浏览器中看到最新的修改,而不需要刷新整个页面。

  • 应用更新: HMR 完成模块的热替换后,浏览器会将更新应用到页面中,从而实现无刷新的开发体验。

总的来说,Webpack DevServer HMR 的流程可以简化为:文件变化 -> HMR runtime 接收更新 -> 模块热替换 -> 浏览器应用更新。这样开发者就可以在保持应用状态的同时,实时地看到修改后的效果,提高开发效率。

逻辑运算规律

逻辑运算有几个常见的规律,其中包括以下几种:

  1. 交换律(Commutative Law):对于逻辑与(AND)和逻辑或(OR)运算,交换律成立:
    • A && B 等价于 B && A
    • A || B 等价于 B || A
  2. 结合律(Associative Law):对于逻辑与(AND)和逻辑或(OR)运算,结合律成立:
    • (A && B) && C 等价于 A && (B && C)
    • (A || B) || C 等价于 A || (B || C)
  3. 分配律(Distributive Law):对于逻辑与(AND)和逻辑或(OR)运算,分配律成立:
    • A && (B || C) 等价于 (A && B) || (A && C)
    • A || (B && C) 等价于 (A || B) && (A || C)
  4. 吸收律(Absorption Law):对于逻辑与(AND)和逻辑或(OR)运算,吸收律成立:
    • A && (A || B) 等价于 A
    • A || (A && B) 等价于 A
  5. 德摩根定律(De Morgan's Laws):逻辑非(NOT)的德摩根定律成立:
    • !(A && B) 等价于 !A || !B
    • !(A || B) 等价于 !A && !B
    • A && B 等价于 !(!A || !B)
    • A || B 等价于 !(!A && !B)

JS中的访问器属性

在 JavaScript 中,对象的访问器属性可以通过 Object.defineProperty 方法来定义,这是一种常见的方式。但是,还有其他方式可以定义对象的访问器属性。

除了使用 Object.defineProperty,还有以下两种方式来定义对象的访问器属性:

  • 使用 getset 关键字:在 ES6(ECMAScript 2015)及以后的版本中,可以使用 getset 关键字来定义对象的访问器属性。
js
const obj = {
  firstName: 'John',
  lastName: 'Doe',
  get fullName() {
    return this.firstName + ' ' + this.lastName;
  },
  set fullName(value) {
    const [firstName, lastName] = value.split(' ');
    this.firstName = firstName;
    this.lastName = lastName;
  }
};

console.log(obj.fullName); // 输出:John Doe

obj.fullName = 'Jane Smith';
console.log(obj.firstName); // 输出:Jane
console.log(obj.lastName); // 输出:Smith

在上述示例中,通过在对象字面量中使用 getset 关键字,定义了 fullName 属性的获取和设置逻辑。

  • 使用 class 中的 getter 和 setter 方法:在使用类(class)定义对象时,可以通过 getter 和 setter 方法来定义访问器属性。
js
class Person {
  constructor(firstName, lastName) {
    this._firstName = firstName;
    this._lastName = lastName;
  }

  get fullName() {
    return this._firstName + ' ' + this._lastName;
  }

  set fullName(value) {
    const [firstName, lastName] = value.split(' ');
    this._firstName = firstName;
    this._lastName = lastName;
  }
}

const person = new Person('John', 'Doe');

console.log(person.fullName); // 输出:John Doe

person.fullName = 'Jane Smith';
console.log(person.fullName); // 输出:Jane Smith

在上述示例中,通过在 class 中定义 getter 和 setter 方法,实现了访问器属性 fullName

总结:除了使用 Object.defineProperty 方法外,JavaScript 中的对象的访问器属性可以通过 getset 关键字以及 class 中的 getter 和 setter 方法来定义。这些方法都可以用来创建和操作对象的访问器属性, 且get或者set后面必须指定属性名。

动态执行代码

Javascript中动态执行代码的几种方式如下

  • Eval: 同步执行代码, 且作用域是局部

  • Function: 同步执行代码, 作用域是局部

  • SetTimeout: 异步执行代码, 作用域是全局

  • Script: 同步执行代码, 作用域是全局, 会创建额外的<script>标签

严格模式

类和模块的内部,默认就是严格模式,所以不需要使用use strict指定运行模式。只要你的代码写在类或模块之中,就只有严格模式可用。考虑到未来所有的代码,其实都是运行在模块之中,所以 ES6 实际上把整个语言升级到了严格模式。

Iterator 和 Generator

生成器用于创建迭代器, 调用生成器会返回迭代器, 部署了迭代器接口[Symbol.Iterator]函数的就可以通过for...of直接遍历, 在生成器内部, 为了方便使用, 可以是用yield*直接调用迭代器, 等同于多条yield

缓存图片

在浏览器中, 可以使用三种方式创建图片

  • innerHTML
  • new Image
  • document.createElement

创建好图片后, 将需要缓存的所有图片, 添加上src属性, 浏览器就会去下载对应的图片。当使用图片时, 直接引用这些图片对象即可

js
// 缓存图片
const imgList = Array.from({ length: 10 }, (_, index) => {
  const img = new Image()
  img.src = `https://picsum.photos/id/${index}/200/300`
  return img
})

Vue子组件异步调用

Vue中父组件如果需要调用子组件的异步方法, 或者父组件需要等待子组件某种Ready状态时, 再执行某些事情, 可以按照如下几种方式

子组件发送事件

vue
// a.vue
<template>
  <div>
    这是a页面
    <childB ref="childB" @onReady="toPlay"/>
  </div>
</template>
<script>
import childB from './b'
export default {
  methods: {
    toPlay(){
      const { play } = this.$refs.childB
      play()
    }
  },
  components: {
    childB,
  },
}
</script>

// b.vue
<template>
  <div>这是b页面</div>
</template>
<script>
export default {
  beforeCreate(){
    this.init()
  },
  methods: {
    init() {
      setTimeout(() => {
        this.$emit("onReady")
      }, 2000)
    },
    play() {
      console.log('ok')
    },
  },
}
</script>

子组件暴露Promise

vue
// a.vue
<template>
  <div>
    这是a页面
    <childB ref="childB" />
  </div>
</template>
<script>
import childB from './b'
export default {
  mounted() {
    const { init, play } = this.$refs.childB
    init().then(play)
  },
  components: {
    childB,
  },
}
</script>

// b.vue
<template>
  <div>这是b页面</div>
</template>
<script>
export default {
  methods: {
    init() {
      return new Promise(resolve => {
        setTimeout(() => {
          resolve()
        }, 2000)
      })
    },
    play() {
      console.log('ok')
    },
  },
}
</script>

子组件内部await

vue
// a.vue
<template>
  <div>
    这是a页面
    <childB ref="childB" />
  </div>
</template>
<script>
import childB from './b'

export default {
  mounted() {
    this.$refs.childB.play()
  },
  components: {
    childB,
  },
}
</script>


// b.vue
<template>
  <div>这是b页面</div>
</template>
<script>
const promisArr = (n = 1) =>
  Array.from({ length: n }, (_, id) => {
    let resolve, reject
    const promise = new Promise((e, j) => ((resolve = e), (reject = j)))
    return { id, resolve, reject, promise }
  })

const { id, reject, resolve, promise } = promisArr().pop()
export default {
  created() {
    this.init()
  },
  methods: {
    init() {
      setTimeout(() => {
        resolve('hello')
      }, 2000)
    },
    async play() {
      const res = await promise
      console.log('ok', res)
    }
  }
}
</script>

字符串码点和码元

字符编码系统将一个 Unicode 码位编码为一个或者多个码元

JavaScript字符串使用的编码系统是UTF-16, 即表示一个码元, 也就是两个个字节(2^16 = 65535)

并非 Unicode 定义的所有码位都适合单个 UTF-16 来编码

如果字符串包含非 ASCII字符,那么这个字符可能是由两个码元组成, 即四个字节, 这种两个码元组成的称为码点

我们常用的API, 都是使用的单个码元作为单位编码, 例如:length表示码元个数、slice以码元作为单位

为了表示完整的字符串, JS提供了一个码点相关的API

  • String.prototype.codePointAt 读取字符的码点值
  • String.prototype.fromCodePoint 从码点值转化为字符
js
const str = `🎂`
str[0]  // 输出为�, 会乱码
str.codePointAt(0)  // 127874大于了65535, 因此使用两个码元编码

// 给字符串添加码点长度的属性
Reflect.defineProperty(String.prototype, 'codePointLength', {
  get(){
    const TWO_BYTE = 65535
    let len = 0
    for(let i = 0; i < this.length; ){
      const codePonitValue = this.codePointAt(i)
      i += codePonitValue > TWO_BYTE ? 2 : 1
      len++
    }
    return len
  }
})

内存泄露

常见内存泄露的场景如下:

  • 意外的全局变量
  • 遗忘的定时器
  • 使用不当的闭包
  • 已卸载DOM的持久引用

此处第四点, 如果有变量引用一个DOM元素, 后来DOM元素被移除, 就会发生内存泄露

因此, 日常开发中, 记得使用WeakSetWeakMap来存储这种场景下的DOM元素

Visual Studio Code

重构相关

Visual Studio Code征对重构类、快速定位报错行、征对报错行进行处理、修改函数名称等等征对代码重构优化操作的快捷键收录到此

  • 重构:control + shift + R
  • 定位代码警告:F8
  • 快速修复: cmd + .
  • 重命名符号: F2
  • 跳转到符号: shift + cmd + O
  • 跳转到文件: cmd + P
  • 跳转到行: control + G
  • 显示所有符号Symbol: cmd + T
  • 显示命令板: cmd + shift + P
  • 单词切换大写: control + shift + U
  • 单词切换小写: control + shift + I

更多细节阅读Visual Studio Code文档: typescript-refactoring

常用调试配置

json
{
  // 使用 IntelliSense 了解相关属性。
  // 悬停以查看现有属性的描述。
  // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "name": "ChromeCanary调试",
      "request": "launch",
      "type": "chrome",
      "url": "http://localhost:8888",
      "webRoot": "${workspaceFolder}",
      "userDataDir": false,
      "runtimeExecutable": "canary",
      "runtimeArgs": [
        "--auto-open-devtools-for-tabs"
        // 无痕模式
        // "--incognito"
      ]
    },
    {
      "name": "ChromeStable调试",
      "request": "launch",
      "type": "chrome",
      "url": "http://localhost:8081",
      "webRoot": "${workspaceFolder}/projects/wds-html/src",
      // "preLaunchTask": "debug", // 添加的配置,在执行之前需要启动项目,这个是启动项目用的任务。
      "sourceMapPathOverrides": {
        // 把调试的文件 sourcemap 到的路径映射到本地的文件
        "webpack:///./*": "${webRoot}/*",
        "webpack:///src/*": "${webRoot}/*",
        "webpack:///./src/*": "${webRoot}/*",
        "webpack:///*": "*",
        "webpack:///./~/*": "${webRoot}/node_modules/*"
      } // 添加的配置,为了找到打包文件和源代码之间的关联,使断点生效。
    },
    {
      "name": "Static调试",
      "request": "launch",
      "type": "chrome",
      "runtimeExecutable": "canary",
      "userDataDir": false,
      "webRoot": "${workspaceFolder}",
      "file": "${workspaceFolder}/code-snippets/htmls/vue滑动卡片跟随.html",
      "pathMapping": {
        // 服务的路径映射到本地的目录
        "/assets/js/": "${workspaceFolder}/src/"
      }
    },
    {
      "type": "node",
      "request": "launch",
      "name": "Node调试",
      // 这里配置脚本位置(package.json->main)
      "program": "${file}",
      // 传给program的参数
      // "args": [
      //   "-l https://juejin.cn/book/7070324244772716556/section/7148818133343158286"
      // ],
      "cwd": "${workspaceFolder}",
      "skipFiles": [
        // "<node_internals>/**"
      ],
      // console 要设置为 integratedTerminal,这样日志会输出在 terminal,就和我们手动执行 npm run xxx 是一样的
      // 不然,日志会输出在 debug console。颜色啥的都不一样
      "console": "integratedTerminal"
    },
    {
      "name": "Pnpm调试",
      "request": "launch",
      "cwd": "${workspaceFolder}/projects/webpack-error-test",
      "env": {
        "ENV_VAR": "1123"
      },
      // "envFile":"...",
      "runtimeArgs": ["run-script", "build-dev"],
      "runtimeExecutable": "pnpm",
      "skipFiles": [],
      "type": "node",
      "resolveSourceMapLocations": ["${workspaceFolder}/**"],
      "stopOnEntry": true
    },
    {
      "name": "Typescript调试",
      "program": "${file}",
      "request": "launch",
      "sourceMaps": true,
      // 默认是 node, 从 PATH 的环境变量中查找对应名字的 runtime 启动
      "runtimeExecutable": "esno", // ts-node也可以, 执行效率没有esno快
      "skipFiles": ["<node_internals>/**", "**/node_modules/**"],
      "resolveSourceMapLocations": ["${workspaceFolder}/**"],
      "type": "node",
      // 入口处断住
      "stopOnEntry": true
    },
    {
      // require(child_process).exec('xxx.js')
      "name": "Child进程调试",
      "program": "${file}",
      "request": "launch",
      "skipFiles": ["<node_internals>/**"],
      "type": "node",
      "console": "internalConsole",
      "autoAttachChildProcesses": true
    },
    {
      "name": "Pick进程调试",
      "processId": "${command:PickProcess}",
      "request": "attach",
      "skipFiles": ["<node_internals>/**"],
      "type": "node"
    },
    {
      "name": "Vite调试",
      "type": "chrome",
      "request": "launch",
      "runtimeExecutable": "canary",
      "runtimeArgs": ["--auto-open-devtools-for-tabs"],
      "userDataDir": false,
      "url": "http://localhost:3000",
      // vite 时会有一些热更之类的文件,也会被映射到源码,导致断在某名奇妙的地方
      // 把 webRoot 配置成任意的一个不存在的目录,比如 noExistPath,这样这些文件就不会被错误的映射到源码里了。
      // 算是一种 hack 的处理方式
      "webRoot": "${workspaceFolder}/noExistPath"
    },
    {
      // 可以调试多个项目, 先自己在浏览器打开相应的页面
      "type": "chrome",
      // 连接某个已经在调试模式启动的 url 进行调试
      "request": "attach",
      "name": "Vite 模块联邦",
      "port": 9222,
      // "preLaunchTask": "launch-chrome",
      "webRoot": "${workspaceFolder}/projects/module-federation/",
      "skipFiles": [],
      "sourceMapPathOverrides": {
        "webpack:///./*": "${webRoot}/*",
        "webpack:///src/*": "${webRoot}/*",
        "webpack:///./src/*": "${webRoot}/*",
        "webpack:///*": "*",
        "webpack:///./~/*": "${webRoot}/node_modules/*"
      }
    },
    {
      // nodemon --inspect-brk=9229 x.js
      // nodemon --inspect=9229 x.js
      "name": "Server附加调试",
      "port": 9229,
      "request": "attach",
      "skipFiles": ["<node_internals>/**"],
      "type": "node"
    }
  ]
}

快捷键文档

如果需要查询Chrome的所有快捷键, 可以在vscode中按下cmd + k cmd + r组合快捷键

通过Chrome直达Visual Studio Code快捷键官方文档

单点登录三种方式

选用该Session + Cookie方案的系统, 用户登录都会到统一认证中心。 认证中心产生UIDSessionId, 认证中心存储登录信息到数据库中, 并将UID返回到Cookie

当请求子系统接口时, 会将Cookie发送到子系统, 子系统通过查询认证中心UID是否登录, 再进行下一步的处理

优缺点

很容易控制用户在线状态,缺点扩容花费高,子系统扩容会导致认证中心也必须扩容

Token

选用该Session + Cookie方案的系统, 用户登录依旧到统一认证中心, 认证中心产生Token返回给客户端, 客户端存储到本地用于后续请求

当请求子系统接口时, 会将Token发送到子系统, 子系统能直接验证Token是否有效, 因为子系统和认证中心有一套约定的密钥,因此不需要再次请求认证中心

优缺点

方便子系统扩容,缺点是不容易控制用户状态,如果想注销用户的状态,只能等待Token过期

AccessToken + RefreshToken

选用该AccessToken + RefreshToken方案的系统, 用户登录依旧到统一认证中心, 认证中心产生短效期的AccessToken(分钟)和长效期RefreshToken(天)返回给客户端, 客户端将两个Token都存储到本地用于后续请求

当请求子系统接口时, 客户端只会将短效期的AccessToken发送到子系统, 子系统能直接验证AccessToken是否有效, 由于短效AccessToken时间短, 因此会经常过期

当短效AccessToken过期时, 客户端需要使用RefreshToken向认证中心发送请求, 获取最新的AccessToken, 再次存储到本地, 供后续请求使用

优缺点

子系统容易扩容,还可以控制用户登出状态, 因为AccessToken时效短, 因此方案更佳

Jest

项目集成

在一个typescript项目中集成Jest的步骤如下

  • 安装依赖
sh
pnpm add jest @types/jest -D
  • 添加BabelTypescript支持
sh
# 如果项目中在使用`jest-cli`,推荐搭配`babel-jest`,它将使用 `Babel` 自动编译 `JS` 代码
pnpm add babel-jest @babel/core @babel/preset-env @babel/preset-typescript -D
  • 添加Babel配置
js
// 为Node配置Babel后, 就可以随意在Node中使用ESM模块
module.exports = {
  presets: [
    ['@babel/preset-env', {targets: {node: 'current'}}],
    '@babel/preset-typescript',
  ],
};

集成部分请查阅Jest官网: 使用Typescript

小技巧

  • 模拟函数 jest.fn
  • 跳过当前用例 it.skip
  • 仅测试当前用例, 后续不测试 it.only
  • 监听所有测试文件 jest --watchAll
  • 测试覆盖率 jest --coverage

断言API

sh
====================真实性Truthiness=============
toBeNull                // 是否为Null
toBeUndefined
toBeDefined
toBeFalsy               // 为假 ensure a value is false in a boolean context
toBeTruthy

====================数字Numbers====================
toBeGreaterThan         // 大于
toBeGreaterThanOrEqual  // 大于等于
toBeLessThan            // 小于
toBeLessThanOrEqual     // 小于
toBe                    //
toEqual                 //
toBeCloseTo             // 接近的数,解决js小数的浮点问题
closeTo                 // 浮点数compare float point numbers

====================数组Arrays&迭代iterables=============
toContain               // 对象是否在数组中使用 check an item is in an array
not.arrayContaining     // 不包含该元素 array is not a subset子集 of the received array

====================异常Exceptions=============
toThrow             // 调用时必须抛出异常 a function throws when it is called
not.toThrow         // 不要抛出异常

====================方法Function=============
assertions          // 必须要调用测试 verifies a certain number of assertion are called
hasAssertions       // 至少调用了一个方法 at least one assertion is called
toHaveBeenCalled    // 被调用的

====================对象Object====================
toEqual             // 相等 Object.is
toStrictEqual       // 对象的结构是否完全相等 test the object have the same types&structure
objectContaining    // 预期对象存在接收对象 received object contains properties in expected object
toBeInstanceOf      // 对象是否为类的实例 check the object is an instance of a class
toMatchObject       // 对象匹配另一个对象的子集a js Object matches a subset of the properties of an object

====================字符串String====================
stringMatching      // 匹配字符串\正 matches string\Regular Expression
toMatch             // 字符串匹配器 a string matches a regular expression

====================更多More====================
toMatchSnapshot     //匹配最近的快照 matches the most recent snapshot

更多查阅Jest官网

钩子函数

BeforeAll

所有的测试用例执行之前执行的方法

AfterAll

所有的测试用例执行完后执行的方法,如果传入的回调函数返回值是 PromiseGeneratorJest 会等待 Promise Resolve 再继续执行

BeforeEach

BeforeEach 在每个测试完成之前都运行一遍

AfterEach

AfterAll 相比,AfterEach 在每个测试完成后都运行一遍

TDD开发

TDD (Test Driven Development)是一种开发流程, 在进行开发工作之前,编写测试,预先模拟欲测试的场景

  • 书写测试用例
  • 编写源码通过用例
  • 重构源码

测试覆盖率

代码覆盖率是一项指标,可以帮助您了解测试了多少源代码。这是一个非常有用的指标,可以帮助您评估测试套件的质量, 即检测测试是否全面

  • 函数覆盖率:已定义的函数中有多少被调用
  • 语句覆盖率:程序中有多少语句已执行
  • 分支覆盖率:控制结构的分支(例如 if 语句)中有多少已执行
  • 条件覆盖率:已经测试了多少布尔子表达式的真值和假值
  • 行覆盖率:已经测试了多少行源代码

这些指标通常表示为实际测试的项目数量、代码中找到的项目以及覆盖率百分比(测试的项目/找到的项目)

jestkarama 都是基于 istanbul 做的覆盖率检测

组件按需加载

babel常见的按需加载的包有两个, babel-plugin-componentbabel-plugin-import。该插件在babel做代码转换时,通过读取AST并收集归属于特定的libraryName的有效imported,然后进行命名转换、生成组件和样式的import 代码、移除多余imported

两者大致的区别如下

  • babel-plugin-component 主要用于element-ui组件的按需加载, 与babel-plugin-import同一作者, 核心逻辑相同, 已不再维护
  • babel-plugin-import 兼容了多个组件库, 例如antdantd-mobilelodashmaterial-ui

CSS Tree Shaking

Babel依靠AST技术完成对Javascript代码的遍历分析。 而在样式的世界中, PostCSS也起到了Babel的作用。

PostCSS提供了一个解析器, 能够将CSS解析成AST, 我们可以通过PostCSS插件对CSS对应的AST进行操作, 实现

Tree Shaking。这里主要记录在Webpack中如何配置CSS Tree Shaking

  • 安装依赖
sh
npm i purgecss-webpack-plugin -D
  • 修改webpack配置文件
js
const path = require("path");
const glob = require("glob");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const { PurgeCSSPlugin } = require("purgecss-webpack-plugin");

const PATHS = {
  src: path.join(__dirname, "src"),
};

module.exports = {
  entry: "./src/index.js",
  output: {
    filename: "bundle.js",
    path: path.join(__dirname, "dist"),
  },
  optimization: {
    splitChunks: {
      cacheGroups: {
        styles: {
          name: "styles",
          test: /\.css$/,
          chunks: "all",
          enforce: true,
        },
      },
    },
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, "css-loader"],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: "[name].css",
    }),
    new PurgeCSSPlugin({
      paths: glob.sync(`${PATHS.src}/**/*`, { nodir: true }),
    }),
  ],
};

JS Tree Shaking

Webpack中, 如果代码中包含副作用, 可以利用package.jsonsideEffects属性告诉工程化工具, 哪些模块具有副作用, 哪些模块没有副作用并可以被Tree Shaking优化

副作用声明

  • 表示全部模块均没有副作用
json
{
  "name": "my-package",
  "sideEffects": false
}
  • 表示部分模块具有副作用
json
{
  "name": "my-package",
  "sideEffects": ["./src/some-side-effectful-file.js", "*.css"]
}

不利于Tree Shaking情况

以下情况都不利于进行Tree Shaking处理

  • 导出一个包含多个属性和方法的对象
js
export default {
  add(a, b){
    return a + b
  },
  subtract(a, b){
    return a - b
  }
}
  • 导出一个包含多个属性和方法的类
js
export default class {
  add(a, b){
    return a + b
  }
  subtract(a, b){
    return a - b
  }
}
  • 使用export default方法导出
js
export default xxx

鉴于上述的情况, 更推荐遵循原子化和颗粒化原则导出

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

export function subtract(a, b){
  return a - b
}

append 和 appendChild

Element.append 方法在 Element的最后一个子节点之后插入一组 Node 对象或 DOMString 对象。被插入的 DOMString 对象等价为 Text (en-US) 节点

Node.appendChild() 的差异:

  • Element.append()允许追加 DOMString 对象,而 Node.appendChild() 只接受 Node 对象

  • Element.append() 没有返回值,而 Node.appendChild() 返回追加的 Node 对象

  • Element.append() 可以追加多个节点和字符串,而 Node.appendChild() 只能追加一个节点

LocalStorage 和 SessionStorage

LocalStorageSessionStorage都是Web Storage API的一部分, 用于在浏览器中存储数据, 且存储都是关联到域名的

  • LocalStorage存储的数据没有过期时间, 除非手动清除, 否则一直存在

  • SessionStorage存储的数据在会话结束时会被清除, 会话结束指浏览器关闭或者标签页关闭

而征对于同一个域名, LocalStorageSessionStorage的区别如下

  • LocalStorage存储的数据在不同的Tab页面中是共享的, 即一个Tab页面中存储的数据, 另一个Tab页面可以访问到

  • SessionStorage存储的数据在不同的Tab页面中是不共享的, Tab页无法访问同源的其他Tab页数据

特殊情况

以下两种情况下打开的新Tab页, SessionStorage会被复制到新的Tab页面中

  • window.open打开新的同源Tab

  • <a href="samesite url" rel=”opener” target="_blank">new tab</>打开新的Tab

函数参数长度

一个 Function 对象的 length 属性表示函数期望的参数个数,即形参的个数

这个数字不包括剩余参数,只包括在第一个具有默认值的参数之前的参数

相比之下,arguments.length 是局限于函数内部的,它提供了实际传递给函数的参数个数

ParseInt 和 Math.floor

两者都能获取小数的整数部分, 但是有以下不同点

  • Math.floor 无论正负, 均向下取最接近的整数。 取随机整数[min,max]场景, 只能用该方法(Math.floor始终向下取整)

  • ParseInt对于负数, 会向上取整到最接近的整数。 对于正数, 会向下取整到最接近的整数(即ParseInt向0取整)

Switch-Case非常规写法

本段落来记录Switch...Case的非常规写法

无Break语句

该场景下, Switch...Case会从第一个匹配的Case子语句开始, 执行后续所有的Case语句, 而不需要判断是否满足Case条件, 直到遇到Break语句或者Switch结束

应该特别注意连写Case的场景, 等同于多个条件的逻辑或运算

ts
const fruittype = "Apples"
switch (fruittype) {
  case "Oranges":
    console.log("Oranges are $0.59 a pound.");
  case "Apples":
    console.log("Apples are $0.32 a pound.");
  case "Bananas":
    console.log("Bananas are $0.48 a pound.");
  case "Cherries":
    console.log("Cherries are $3.00 a pound.");
  // 逻辑上等同于if(fruittype === "Mangoes" || fruittype === "Papayas")
  case "Mangoes":
  case "Papayas":
    console.log("Mangoes and Papayas are $2.79 a pound.");
    break;
  default:
    console.log("Sorry, we are out of " + fruittype + ".");
}

// Apples are $0.32 a pound.
// Bananas are $0.48 a pound.
// Cherries are $3.00 a pound.
// Mangoes and Papayas are $2.79 a pound.

前置Default语句

该场景下, Switch...Case中, Default语句放在开头, 其效果与放最后没有区别, 在没找到匹配的Case语句时, 会执行Default语句

需要注意, Case后面的子语句可以加上块作用域的括号, 来避免变量冲突

ts
const fruittype = "Apples"
switch (fruittype) {
  default: {
    console.log("Sorry, we are out of " + fruittype + ".");
    break;
  }
  case "Oranges": {
    console.log("Oranges are $0.59 a pound.");
    break;
  }
  case "Apples": {
    console.log("Apples are $0.32 a pound.");
    break;
  }
}
// Apples are $0.32 a pound.

网页位置DOM属性

网页位置中, 需要注意五类属性, 分别是WindowScreenElementEvent元素Document

屏幕尺寸 Screen

  • screen.width 屏幕宽度
  • screen.height 屏幕高度
  • screen.availWidth 屏幕可用宽度
  • screen.availHeight 屏幕可用高度

窗口尺寸 Window

  • window.screenTop 窗口顶部距屏幕顶部的距离
  • window.screenLeft 窗口左侧距屏幕左侧的距离
  • window.innerWidth 窗口中可视区域的宽度
  • window.innerHeight 窗口中可视区域的高度(与浏览器是否显示菜单栏等因素有关)
  • window.outerWidth 浏览器窗口本身的宽度(可视区域宽度+浏览器边框宽度)
  • window.outerHeight 浏览器窗口本身的高度(可是区域高度+浏览器菜单栏等高度)

元素尺寸 Element

此处使用div来表示某一个元素

client

  • div.clientWidth 在页面上返回元素的可视宽度(内容宽度+内边距)
  • div.clientHeight 在页面上返回元素的可视高度(内容高度+内边距)
  • div.offsetWidth 返回元素的宽度包括边框和填充(内容宽度+内边距+边框宽)
  • div.offsetHeight 返回元素的高度包括边框和填充(内容高度+内边距+边框高)
  • div.scrollWidth 返回元素的整个滚动宽度(包括带滚动条不可见的部分)
  • div.scrollHeight 返回元素的整个滚动高度(包括带滚动条不可见的部分)
  • div.scrollTop 当元素包含竖向滚动条时, 向下滚动后离开视野的区域高度
  • div.scrollLeft 当元素包含横向滚动条时, 向左滚动后离开视野的区域宽度
  • div.offsetTop 元素的上边与父容器(offsetParent对象)上边的距离
  • div.offsetLeft 元素的左边与父容器(offsetParent对象)左边的距离

如何获取网页的绝对位置:

offset

js
function getElementLeft(element) {
  let actualLeft = element.offsetLeft;
  let current = element.offsetParent;

  while (current !== null) {
    actualLeft += current.offsetLeft;
    current = current.offsetParent;
  }

  return actualLeft;
}

function getElementTop(element) {
  let actualTop = element.offsetTop;
  let current = element.offsetParent;

  while (current !== null) {
    actualTop += current.offsetTop;
    current = current.offsetParent;
  }

  return actualTop;
}

// 方法一: 迭代计算
function getElementAbsoluteByIter(element) {
  return {
    absoluteTop: getElementTop(element),
    absoluteLeft: getElementLeft(element)
  }
}


// 方法二: 借助新的API-getBoundingClientRect
function getElementAbsolute(element) {
  const { top, left } = getBoundingClientRect(element)

  const scrollTop = element.scrollTop
  const scrollLeft = element.scrollLeft

  return {
    absoluteTop: top + scrollTop,
    absoluteLeft: left + scrollLeft
  };
}

如何获取网页的相对位置:

relative

可以使用getBoundingClientRect直接获取当前元素的相对位置

事件目标元素尺寸 Event

Event 代表事件的对象,包含了事件的具体信息。此处使用event代表事件对象参数

  • event.pageX 相对整个页面,以页面左上角为坐标原点到事件所在点的水平距离(IE8以上)
  • event.pageY 相对整个页面,以页面左上角为坐标原点到事件所在点的垂直距离(IE8以上)
  • event.clientX 相对可视区域,以可视区域左上角为坐标原点到事件所在点的水平距离
  • event.clientY 相对可视区域,以可视区域左上角为坐标原点到事件所在点的垂直距离
  • event.screenX 相对电脑屏幕,以屏幕左上角为坐标原点到事件所在点的水平距离
  • event.screenY 相对电脑屏幕,以屏幕左上角为坐标原点到事件所在点的垂直距离
  • event.offsetX 相对于自身,以自身的padding左上角为坐标原点到事件所在点的水平距离
  • event.offsetY 相对于自身,以自身的padding左上角为坐标原点到事件所在点的水平距离

根元素 Document

假如获取整个文档的尺寸, 究竟是使用document.documentElement还是document.body呢?

这就不得不提到doctype的概念了, doctypedocument type的缩写, 用于告诉浏览器当前文档的类型, 以便浏览器能够正确的渲染页面

在JS中可以使用document.compatMode来获取当前文档的doctype类型, 该属性有两个值

  • BackCompat 表示无doctype声明, 浏览器使用怪异模式渲染页面, 此时使用document.body获取宽高

  • CSS1Compat 表示有doctype声明, 浏览器使用标准模式渲染页面, 此时使用document.documentElement获取宽高

需要注意

  • safari 比较特别,有自己获取scrollTop的函数window.pageYOffset
  • 火狐等等相对标准些的浏览器就省心多了,直接用 document.documentElement.scrollTop

其实, document.body.scrollTopdocument.documentElement.scrollTop两者有个特点,就是同时只会有一个值生效

比如document.body.scrollTop能取到值的时候,document.documentElement.scrollTop就会始终为0;反之亦然

如果要得到网页的真正的scrollTop值

js
// 方法一
const scrollTop = document.body.scrollTop
  + document.documentElement.scrollTop;
const scrollLeft = document.body.scrollLeft
  + document.documentElement.scrollLeft;

// 方法二
const scrollLeft = Math.max(
  document.documentElement.scrollLeft,
  document.body.scrollLeft
);
const scrollTop = Math.max(
  document.documentElement.scrollTop,
  document.body.scrollTop
);

// 方法三
const scrollTop = document.documentElement.scrollTop
  || window.pageYOffset
  || document.body.scrollTop
  || 0;

判断滚动条触底

在开发实践中, 可以使用上面的属性来判断滚动条已经触底, 触底的业务逻辑中可以做加载更多数据或者一些其他业务逻辑

  • 使用scrollTop判断触底加载
ts
// 使用scrollTop判断触底加载

//滚动条在Y轴上的滚动距离
function getScrollTop() {
  return scrollTop = document.body.scrollTop + document.documentElement.scrollTop;
}
//文档的总高度
function getScrollHeight() {
  return scrollHeight = document.body.scrollHeight + document.documentElement.scrollHeight;
}
//浏览器视口的高度
function getWindowHeight() {
  return document.compatMode == "CSS1Compat"
    ? windowHeight = document.documentElement.clientHeight
    : windowHeight = document.body.clientHeight;
}
window.onscroll = function () {
  if (getScrollTop() + getWindowHeight() >= getScrollHeight()) {
    console.log("滚动条已经触底")
  }
};
  • 使用IntersectionObserver判断触底加载
ts
// 首先需要一个最底部的目标元素,用于监测是否进入视口
const sentinel = document.querySelector('.load-more-tip')

// 创建 IntersectionObserver 实例
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      // 如果目标元素出现在视口中
      if (entry.isIntersecting) {
        // 加载更多数据
        console.log("已经触底了")
      }
    })
  },
  {
    root: null, // 视口为浏览器窗口
    rootMargin: '0px', // 可以增加触发加载的提前量
    threshold: 0 // 目标元素有一部分出现在视口中就触发
  }
)

// 开始观察目标元素
observer.observe(sentinel)

判断元素可见性

  • 使用getBoundingClientRect进行判断
ts
function isInViewport(element) {
  const r = element.getBoundingClientRect()
  return (r.top <= window.innerHeight && r.bottom >= 0)
    && (r.left <= window.innerWidth && r.right >= 0)
}
  • 使用InterSectionObserver进行判断
ts
function isInViewport(element) {
  // 创建一个IntersectionObserver实例
  const options = {
    root: null, // 视口元素,null表示使用浏览器视口
    rootMargin: '0px', // 定义根的margin,扩展或收缩检测区
    threshold: 0.1 // 触发回调的阈值,0.1表示元素至少有10%可见时触发
  }

  // 回调函数,当观察到的元素进入或离开视口时执行
  const callback = (entries, observer) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        // 元素进入视口
        console.log('元素已经进入视口:', entry.target)
      } else {
        // 元素离开视口
        console.log('元素离开视口:', entry.target)
      }
    })
  }
  const observer = new IntersectionObserver(callback, options)
  // 开始观察元素
  observer.observe(element)
}

HTMLCollection、NodeList

上述两个属性都能获取元素的节点集合, 但是他们有一些需要注意的差异性

NodeList

一般而言,NodeList 是一个静态集合,也就意味着随后对文档对象模型的任何改动都不会影响集合的内容。比如 document.querySelectorAll 就会返回一个静态 NodeList

特殊情况

在一些情况下,NodeList 是一个实时集合,也就是说,如果文档中的节点树发生变化,NodeList 也会随之变化。例如,Node.childNodes 是实时的:

HTMLCollection

DOM 中的 HTMLCollection 是即时更新的(live);当其所包含的文档结构发生改变时,它会自动更新。因此,最好是创建副本(例如,使用 Array.from)后再迭代这个数组以添加、移动或删除 DOM 节点

差异性

  • NodeList 是一个静态集合,其不受 DOM 树元素变化的影响;相当于是 DOM 树快照,节点数量和类型的快照,就是对节点增删,NodeList 感觉不到。但是对节点内部内容修改,是可以感觉到的,比如修改 innerHTML

  • HTMLCollection动态绑定的,是一个的动态集合,DOM 树发生变化,HTMLCollection 也会随之变化,节点的增删是敏感的

  • 只有 NodeList 对象有包含属性节点和文本节点

  • HTMLCollection 元素可以通过 nameidindex 索引来获取。NodeList 只能通过 index 索引来获取

  • HTMLCollectionNodeList 本身无法使用数组的方法:poppushjoin 等。除非你把他转为一个数组

Monorepo

Monorepo(Monolithic Repository)意为单体代码库,是指将所有相关的代码放在一个仓库中,而不是像传统的做法那样,将不同的项目放在不同的仓库中

优缺点

优点

  • 仓库管理: 多项目使用同一代码库, 提交和管理更便捷
  • 基础配置: 工程多项目在一个仓库,工程配置一致,代码质量标准及风格也很容易一致
  • 依赖管理: 多项目代码都在一个仓库中,相同版本依赖提升到顶层只安装一次,节省磁盘内存
  • 开发调试: 依赖调试方便,依赖包迭代场景下,借助工具自动 pnpm link 或者 yalc link,直接使用最新版本依赖,简化了操作流程
  • 构建部署: 可以配置依赖项目的构建优先级,可以实现一次命令完成所有的部署

缺点

  • 项目粒度的权限管理无法精细化控制
  • 代码量比较庞大, 导致推拉代码的速度变慢

主流Monorepo工具

JSBridge 的双向通信

平时开发混合应用时, 会遇到JSBridge的双向通信, 本节主要记录JSBridge的双向通信的实现原理。

在双向通信中,两侧主要是NativeJSNative是指原生的AndroidiOSJS是指WebView中的JS代码

JS调用Native

JS 调用 Native 的常见主流方式主要有拦截 URL Scheme 、重写 promptAPI注入等几种

拦截 URL Scheme

Android

Android 应用中,Webview 提供了 shouldOverrideUrlLoading 方法来提供给 Native 拦截 H5 发送的 URL Scheme 请求

IOS

iOSWKWebview 可以根据拦截到的 URL Scheme 和对应的参数执行相关的操作

重写 prompt

使用该方式时,可以与 AndroidiOS 约定好使用传参的格式,这样 H5 可以无需识别客户端,传入不同参数直接调用 Native 即可。剩下的交给客户端自己去拦截相同的方法,识别相同的参数,进行自己的处理逻辑即可实现多端表现一致

Android

Android 中一般会通过修改浏览器的部分 Window 对象的方法来完成操作。主要是拦截 alertconfirmpromptconsole.log 四个方法,分别被 WebviewonJsAlertonJsConfirmonConsoleMessageonJsPrompt 监听

IOS

IOS 中, 由于安全机制, WKWebViewalertconfirmprompt 等方法做了拦截,因此需要实现 WKWebView 的三个 WKUIDelegate 代理方法

API 注入

基于 Webview 提供的能力,我们可以向 Window 上注入对象或方法。JS 通过这个对象或方法进行调用时,执行对应的逻辑操作,可以直接调用 Native 的方法。使用该方式时,JS 需要等到 Native 执行完对应的逻辑后才能进行回调里面的操作。

Android

AndroidWebview 提供了 addJavascriptInterface 方法,支持 Android 4.2 及以上系统。

IOS

iOSUIWebview 提供了 JavaScriptScore 方法,支持 iOS 7.0 及以上系统。WKWebview 提供了 window.webkit.messageHandlers 方法,支持 iOS 8.0 及以上系统。UIWebview 在几年前常用,目前已不常见。

Native调用JS

Native 调用 JS 比较简单,只要 H5JS 方法暴露在 Window 上给 Native 调用即可。

Android

Android 中主要有两种方式实现。在 4.4 以前,通过 loadUrl 方法,执行一段 JS 代码来实现。在 4.4 以后,可以使用 evaluateJavascript 方法实现。loadUrl 方法使用起来方便简洁,但是效率低无法获得返回结果且调用的时候会刷新 WebViewevaluateJavascript 方法效率高获取返回值方便,调用时候不刷新WebView,但是只支持 Android 4.4+

IOS

iOSWKWebview 中可以通过 evaluateJavaScript:javaScriptString 来实现,支持 iOS 8.0 及以上系统

循环引用通用解决思路

如果在开发中遇到模块的循环引用, 会造成非常严重的问题, 因此应该极力避免。 本节主要记录该场景下的通用解决思路

使用延迟加载

使用延迟导入(Lazy Import)或者在需要时动态导入模块,而不是在模块的顶层导入

下面模块a和模块b循环引用, 导致模块b中报错, 无法读取模块aobj.name的值

js
// a.js
require('./b')
module.exports = {
  obj: {
    name: 'this is a'
  }
}
js
// b.js
const moduleA = require('./a')
console.log(moduleA.obj.name)
module.exports = 'this is b'
js
var a = require('./a.js')
var b = require('./b.js')

使用延迟加载后, 能够正确加载并读取值

js
// a.js
let moduleB
setTimeout(() => {
  moduleB = require('./b')
}, 0)

module.exports = {
  obj: {
    name: 'this is a'
  }
}
js
// b.js
let moduleA
setTimeout(() => {
  moduleA = require('./a')
  console.log(moduleA.obj.name)
}, 0)

module.exports = 'this is b'
js
var a = require('./a.js')
var b = require('./b.js')

引入中间层或接口

先来看一下ESM加载循环依赖的流程

js
// a.js
import { funcB } from './b.js';

funcB();

export var funcA = () => {
  console.log('a');
}
js
// b.js
import { funcA } from './a.js';

funcA();

export var funcB = () => {
  console.log('b')
}
html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
  <script type="module" src="/a.js"></script>
</body>
</html>

ESM解析流程

在上述的示例代码中, a.jsb.js 产生了循环引用, 以下是他的解析流程

  • JS 引擎执行 a.js 时,发现引入了 b.js,于是去执行 b.js
  • 引擎执行b.js,发现里面引入了a.js(出现循环引用),认为a.js已经加载完成,继续往下执行
  • 执行到funcA()语句时发现 funcA 并没有定义,于是报错

解决思路:

使用中间层的思路,将两个互相引用的模块之间的依赖关系分离。尝试将相关的功能合并到一个模块中,避免过多的相互依赖

定义一个新的模块common,将其中相关的模块成员全部集中在一起,之后循环引用的原模块都引入common,从而解耦

例如, 原模块中的循环引用如下:

js
import { C } from "./C";

new C();
js
import { A } from "./A";
import { B } from "./B";

export class C {}
js
// 这里的import 会报错 Super expression must either be null or a function
import { C } from "./C";
export class A extends C {
  constructor() {
    super();
  }
}
js
import { C } from "./C";
export class B extends C {
  constructor() {
    super();
  }
}

通过一个中间模块common.js解决循环依赖之后的代码如下

js
import { C } from "./common";

new C();
js
// 注意这里export * from "./C";一定要写在顶部,因为C模块是被A和C依赖的模块,需要优先缓存
export * from "./C";
export * from "./A";
export * from "./B";
js
import { A } from "./common";
import { B } from "./common";

export class C {}
js
// 这里的import 会报错 Super expression must either be null or a function
import { C } from "./common";
export class A extends C {
  constructor() {
    super();
  }
}
js

import { C } from "./common";
export class B extends C {
  constructor() {
    super();
  }
}

使用缓存和优化

使用缓存来避免循环引用, Webpack打包后的代码就是采用的此方案

有两个模块ab, 相互循环引用。 首先const a = __webpack_require__("./src/commonJS/a.js")去加载模块a,在加载模块b之前已经就缓存了a,因此不会再次加载模块a,而是直接从缓存中获取模块a的导出对象,即使a的模块并未完全导出,也不会影响,因为__webpack_require__会将模块的导出对象缓存起来,等待模块完全导出后再返回导出对象

值得注意

a模块中去加载b模块的过程中, b模块当中又去加载了a模块, 此时只会得到一个缓存值。该缓存值是不完全导出, 因此在b模块中使用a模块中一些还未完全导出的对象时, 值将会是undefined

js
exports.done = false;
const b = require('./b.js');
exports.done = true;
js
exports.done = false;
const a = require('./a.js');
exports.done = true;
js
const a = require('./a.js');
const b = require('./b.js');
js
// 打包后的代码
(() => {
	var __webpack_modules__ = {
		"./src/commonJS/a.js": (
			__unused_webpack_module,
			exports,
			__webpack_require__
		) => {
			exports.done = false;
			const b = __webpack_require__("./src/commonJS/b.js");
			exports.done = true;
		},

		"./src/commonJS/b.js": (
			__unused_webpack_module,
			exports,
			__webpack_require__
		) => {
			exports.done = false;
			const a = __webpack_require__("./src/commonJS/a.js");
			exports.done = true;
		},
	};

  // 模块缓存
	var __webpack_module_cache__ = {};

	function __webpack_require__(moduleId) {
		var cachedModule = __webpack_module_cache__[moduleId];
		if (cachedModule !== undefined) {
			return cachedModule.exports;
		}
		var module = (__webpack_module_cache__[moduleId] = {
			exports: {},
		});

		__webpack_modules__[moduleId](
			module,
			module.exports,
			__webpack_require__
		);

		return module.exports;
	}

	(() => {
		const a = __webpack_require__("./src/commonJS/a.js");
		const b = __webpack_require__("./src/commonJS/b.js");
	})();
})();

使用工具检测和解决循环引用

使用工具检测循环引用,例如ESLint工具或专门用于检测模块循环引用的构建工具Webpack等等, 一些IDE(集成开发环境)也提供检测循环引用的功能

单页路由核心

  • 无刷新改变URL

通过改变hash值,或者historyrepalceStatepushState都可以实现无刷新的改变URL

  • 监听URL中的hash变化

hash模式下

通过hash改变了URL,会触发hashchange事件,只要监听hashchange事件,就能捕获到通过hash改变URL的行为

js
window.onhashchange=function(event){
  console.log(event);
}
//或者
window.addEventListener('hashchange',function(event){
   console.log(event);
})

history模式下

History.back()History.forward()History.go()事件是会触发popstate事件的,因此这三种操作只需要监听popstate事件即可

js
window.addEventListener('popstate', function(event) {
  console.log(event);
})

但是History.pushState()History.replaceState()不会触发popstate事件, 这种情况下需要做hack处理

js
const _w = function(type) {
   const orig = history[type];
   return function() {
      const rv = orig.apply(this, arguments);
      const e = new Event(type);
      e.arguments = arguments;
      window.dispatchEvent(e);
      return rv;
   };
};
// 这样就创建了2个全新的事件,事件名为pushState和replaceState,我们就可以在全局监听:
history.pushState = _w('pushState');
history.replaceState = _w('replaceState');
js
window.addEventListener('replaceState', function(e) {
  console.log('THEY DID IT AGAIN! replaceState 111111');
});
window.addEventListener('pushState', function(e) {
  console.log('THEY DID IT AGAIN! pushState 2222222');
});

Android 抓包

本段落记录使用Charles对手机应用进行抓包的过程, 重点记录高版本的Android系统如何安装系统根证书。 默认手机端已ROOT, 且安装了Magisk

高版本Android遇到的问题

安卓7 之前把Charles CA证书安装到用户证书下即可进行抓包, 但安卓7以上只有系统级证书才能被信任(APP 可以设置信任范围, 默认只信任系统范围的证书), 所以为了能正常抓包, 需要把CA证书安装到系统证书下

如果是自家公司的应用, 可以找客户端前端开发打一个测试apk包, 其中的配置改为信任用户证书即可

  • 手机WIFI网络代理到电脑的IP上, 端口填Charles的默认端口8888(这一步可以使用Brook或者Appproxy这类工具更便捷)

  • 在电脑端下载Charles的根证书, 通过Help -> SSL Proxying -> Save Charles Root Certificate->保存到本地的证书名为charles-ssl-proxying-certificate.pem

  • 电脑终端执行以下命令, 转换Android需要的证书, 可以参考MoveCertificate手动安装证书到系统证书目录

sh
# Android 系统使用 DER,  所以移动后的 PEM 证书格式需要转成 DER

## 1. 计算 hash, 得到 02e06844
openssl x509 -inform PEM -subject_hash_old -in charles-ssl-proxying-certificate.pem

## 2. 转der
openssl x509 -in charles-ssl-proxying-certificate.pem -outform der -out cacert.der

# 3. 修改文件名称为第一步得到的 hash.0
mv cacert.der 02e06844.0
  • 手机端安装面具模块MoveCertificate(推荐) 或者 CustomCACert(备用), 待安装完成就能通过MT管理器将上面的3a1074b3.0文件拷贝到/data/local/tmp/cert/

  • 02e06844.0文件权限为-rw-r--r--(权限位 644)

  • 证书推到手机后, 重启即可生效

如果手机未ROOT

在这样的情况下, 需要借助Android模拟器(例如夜游神模拟器)来实现, 将Charles的证书安装到模拟器的系统根证书里面, 然后通过模拟器来运行程序进行抓包

此处附录几篇相关资料:

许可证类型

在开源软件许可证中,有多种不同的许可证类型,每种许可证都有其特定的使用条件和限制。

以下是一些常见的开源许可证:

  1. MIT License - 一种非常宽松的许可证,允许人们做几乎任何事情,只要保留版权声明和许可声明。

  2. BSD License - 包括原始的BSD许可证(BSD Old或BSD 4-Clause)和修改的BSD许可证(BSD New或BSD 3-Clause)。这些许可证也很宽松,只要满足最小的条件就可以使用。

  3. Apache License 2.0 - 允许用户使用、修改和分发您的代码,只要他们遵守许可证的条款,包括保留版权和许可声明。

  4. GNU General Public License (GPL) - 要求任何分发的软件或衍生作品都必须开源,并在相同的许可证下发布。

  5. GNU Lesser General Public License (LGPL) - 类似于GPL,但它允许与非自由软件的链接,适用于库。

  6. Mozilla Public License 2.0 - 允许混合自由和非自由代码,但要求修改的文件必须在相同的许可证下发布。

  7. Creative Commons Licenses - 通常用于创意作品(如文本、音乐、图片),而不是软件。

  8. Eclipse Public License - 用于Eclipse社区的项目,允许用户使用、修改和分发代码。

  9. Artistic License - 主要用于Perl语言社区,允许在某些条件下使用和分发。

这些只是众多开源许可证中的一部分。每种许可证都有其特定的用途和限制,选择哪种许可证取决于您希望他人如何使用您的项目。对于非开源项目,通常会使用更加严格的许可证来限制使用、复制和分发。

小程序跳转的生命周期

微信小程序中的页面导航API有不同的生命周期行为, 各导航API的生命周期影响下列行为:

navigateTo

  • 保留当前页面,跳转到应用内的某个页面
  • 原页面会被压入页面栈,不会触发原页面的销毁生命周期
  • 新页面会触发 onLoad, onShow 等生命周期函数

navigateBack

  • 关闭当前页面,返回上一页面或多级页面
  • 当前页面会被销毁,触发 onUnload 生命周期
  • 返回的页面会触发 onShow 生命周期

redirectTo

  • 关闭当前页面,跳转到应用内的某个页面
  • 当前页面会被销毁,触发 onUnload 生命周期
  • 新页面会触发 onLoad, onShow 等生命周期函数
  • 不会在页面栈中留下记录,无法通过 navigateBack 返回

switchTab

  • 跳转到 tabBar 页面,并关闭其他所有非 tabBar 页面
  • 如果当前页面不是 tabBar 页面,则会被销毁,触发 onUnload 生命周期
  • 如果目标页面是首次打开,会触发 onLoad 和 onShow 生命周期
  • 如果目标页面已经打开过,只会触发 onShow 生命周期

reLaunch

  • 关闭所有页面,打开到应用内的某个页面
  • 会触发当前页面的 onUnload 生命周期
  • 新页面会触发 onLoad, onShow 等生命周期函数
  • 清空整个页面栈