阅读视图

发现新文章,点击刷新页面。

基于P站(Poloniex)的广度优先搜索算法来获得任意两种币的兑换价格


币圈的P站是Poloniex,前几年被孙宇晨收购了,它是一个交易所。我很久之前用过Poloniex,当时对其印象并不是很好。

不过,现在我对其好感增加,因为币安买下的coinmarketcap免费的接口就很多限制。

官方文档),这个接口的频率限制是一秒200次,很慷慨了。

https://api.poloniex.com/markets/price

能返回所有交易配对,比如这样的:

[
    {
        "symbol": "BTS_BTC",
        "price": "0.0000000186",
        "time": 1731852313035,
        "dailyChange": "-0.0462",
        "ts": 1731852313054
    },
    {
        "symbol": "DASH_BTC",
        "price": "0.000317",
        "time": 1731848096481,
        "dailyChange": "0.0063",
        "ts": 1731848096489
    },
    ... ...
]

这个JSON返回的结构是一个数组,每个元素是个结构体,也就是一个币价的具体配对信息,我们可以看成是一条边Edge两个顶点Vertice,这样就是一个图结构(带权图 Weighted Graph,权值就是兑换价格),虽然给的是单边,但其实是个双向的,比如USD_BTC得值可以反过来推得BTC到USD的价格。我们可以设计一个算法,从币价A到币价B,可以通过BFS广度优先搜索算法来获取价格。比如有配对A_B、B_C、C_D我们就可以获得A_D的值。

深度优先搜索算法DFS也可以,不过这个算法会返回找到的第一条路径,并不能保证是最短的,最短的确实是最准确的,因为链也长,转换精度就会下降。

当然,可能存在多条路径,最理想的状态是把这些路径都求出来,取个平均啥的,不过这样就得暴力搜索所有的路径了,算法时间复杂度就会比较高。

以下是BFS广度优先算法的代码,Javascript的,可以用于网页前端或者NodeJs后端,甚至是CloudFlare Serverless Worker或者是其它无服务框架:Azure Function、AWS Lambda等。

const fetch = require('node-fetch');

async function getTicker(a, b) {
  try {
    const response = await fetch('https://api.poloniex.com/markets/price');
    const data = await response.json();

    // 创建一个哈希表来存储代币对及其价格
    const pairMap = new Map();

    // 使用直接对及其反向对填充哈希表
    for (const { symbol, price } of data) {
      const [base, quote] = symbol.split('_').map(token => token.toLowerCase());
      if (!pairMap.has(base)) pairMap.set(base, new Map());
      if (!pairMap.has(quote)) pairMap.set(quote, new Map());
      
      pairMap.get(base).set(quote, parseFloat(price));
      pairMap.get(quote).set(base, 1 / parseFloat(price)); // 添加反向边
    }

    // 将 token 转换为小写
    a = a.toLowerCase();
    b = b.toLowerCase();

    // BFS 查找从 a 到 b 的转换率
    const queue = [[a, 1]]; // 从初始代币和兑换率 1 开始
    const visited = new Set([a]);

    while (queue.length > 0) {
      const [currentToken, currentRate] = queue.shift();

      if (currentToken === b) return currentRate;

      // Check connected tokens
      for (const [nextToken, rate] of (pairMap.get(currentToken) || new Map())) {
        if (!visited.has(nextToken)) {
          visited.add(nextToken);
          queue.push([nextToken, currentRate * rate]);
        }
      }
    }

    // 如果未找到路径,则返回 null
    return null;
  } catch (error) {
    console.error("获取或处理数据时出错:", error);
    return null;
  }
}

// Example usage:
(async () => {
  const rate = await getTicker('btc', 'trx');
  console.log('BTC 到 TRX 的兑换率:', rate);
})();

代码的一些简单说明:

  • API 数据提取:从 P站 API 提取数据并将响应解析为 JSON。
  • 映射对:以每个代币作为键创建一个映射,其中值是它可以直接转换为的另一个代币映射,以及兑换率。
  • 双向映射:通过反转反向转换的价格来存储直接对和反向对。
  • 广度优先搜索:使用队列遍历从 a 到 b 的路径。对于每个代币,都会检查其邻居(可转换代币)。如果找到 b,该函数将返回累积率;如果没有,则继续,直到所有选项都用尽。
  • 处理无路径:如果未找到转换路径,则函数返回 null。

如果有多组兑换,我们可以改成传入一个数组,这样就不用多次调用P站的API了。

const fetch = require('node-fetch');

async function getToken(pairs) {
  try {
    const response = await fetch('https://api.poloniex.com/markets/price');
    const data = await response.json();

    // 创建一个哈希表来存储代币对及其价格
    const pairMap = new Map();

    // 使用直接对及其反向对填充哈希表
    for (const { symbol, price } of data) {
      const [base, quote] = symbol.split('_').map(token => token.toLowerCase());
      if (!pairMap.has(base)) pairMap.set(base, new Map());
      if (!pairMap.has(quote)) pairMap.set(quote, new Map());
      
      pairMap.get(base).set(quote, parseFloat(price));
      pairMap.get(quote).set(base, 1 / parseFloat(price)); // 添加一条反向边
    }

    // 使用 BFS 查找单个对的转换率的辅助函数
    const findConversionRate = (a, b) => {
      a = a.toLowerCase();
      b = b.toLowerCase();
      
      if (a === b) return 1; // 直接转换

      const queue = [[a, 1]];
      const visited = new Set([a]);

      while (queue.length > 0) {
        const [currentToken, currentRate] = queue.shift(); // 出队列

        if (currentToken === b) return currentRate;

        for (const [nextToken, rate] of (pairMap.get(currentToken) || new Map())) {
          if (!visited.has(nextToken)) {
            visited.add(nextToken);
            queue.push([nextToken, currentRate * rate]);
          }
        }
      }

      return null; // 路径没找到
    };

    // 迭代列表并查找转换率
    const results = pairs.map(([a, b]) => findConversionRate(a, b));
    return results;
  } catch (error) {
    console.error("Error fetching or processing data:", error);
    return pairs.map(() => null); // 如果有错误,则返回每对的 null
  }
}

// Example usage:
(async () => {
  const conversionRates = await getToken([['btc', 'trx'], ['usd', 'steem']]);
  console.log('兑换结果:', conversionRates);
})();

简单的代码说明:

  • 参数更新:getToken 现在接受成对的元组数组,其中每个元组代表一对 [a, b]。
  • 辅助函数:findConversionRate 处理每对的转换,实现与以前相同的 BFS 方法。
  • 映射每对:函数迭代数组里的每个配对币,应用 findConversionRate 计算转换率,并将结果存储在数组中。
  • 错误处理:如果出现 API 或处理错误,则返回一个空值数组,与输入数组的长度匹配。

这个修改后的函数现在可以接受一个数组,并在一次Poloniex API调用中返回数组里每个配对的兑换率。

英文:Crypto Token Exchange Rate Computation Based on Breadth First Search Algorithm on Poloniex API

区块链技术

本文一共 1127 个汉字, 你数一下对不对.
基于P站(Poloniex)的广度优先搜索算法来获得任意两种币的兑换价格. (AMP 移动加速版本)

扫描二维码,分享本文到微信朋友圈
75a5a60b9cac61e5c8c71a96e17f2d9c 基于P站(Poloniex)的广度优先搜索算法来获得任意两种币的兑换价格 Javascript Poloniex P站 交易所 Crypto Exchanges 加密货币 区块链 比特币 BTC 程序设计 算法 编程 计算机 计算机 软件工程
The post 基于P站(Poloniex)的广度优先搜索算法来获得任意两种币的兑换价格 first appeared on 小赖子的英国生活和资讯.

相关文章:

  1. HPZ800服务器主板太老不支持超过2TB的大硬盘 我家里一直用的是HPZ800服务器, 很吵, 很老, 虽然这台服务器已经有十年之久(我在EBAY上买来用了五年多了), 但是即使放到今天, 这服务器速度依旧很快, 很稳定. 由于服务器用的是ECC较验内存, 所以基本上不重启关机. HPZ800主机有两个硬核CPU – 因特志强 X5650 – 每个CPU是12核....
  2. 给孩子零花钱培养孩子正确的金钱观价值观 两个娃已经不知不觉7岁8岁了. 媳妇和我商量一下决定给孩子每人每周5英镑的零花钱(Pocket Money). 这样他们慢慢的就有自己的小积蓄备将来不时之需: 比如朋友聚会生日啥的需要准备礼物. 同时, 我们决定不再给孩子买零食(薯片啥的). 孩子一天好几餐, 晚上睡觉前还得吃零食, 我们就多买了很多水果面包, 健康的食物多吃一些总不是啥坏事. 孩子可以用这些零钱买自己想要的东西, 我们也不再过问. 孩子有自己的决定权. 第一周的时候,...
  3. 测测你的幸运 – Linux Fortune-Teller LINUX 下有很好很好玩的命令,之前已经介绍过: figlet, rig, curl. 现在推荐另一个 命令 fortune 是用来随机显示一段(句)话的.fortune 在英文里就是幸运的意思. 这个命令可以不需要 参数 如果没有 可以通过 apt-get...
  4. 负电价活久见: 安装Octopus智能电表省电费甚至赚钱 前几周我的电气公司 Octopus 终于来装智能电表了(Smart Meter),虽然是免费安装的,但是排队排了有两三年了吧。因为之前一直写邮件催的时候就老是说 Not Ready。 收到邮件说可以安装智能电表我还是相当开心和期待的,因为已经听说这玩意好,但是还是得亲身体验一下。工程师来安装大概不到2小时,其中需要停电闸一会儿,重新接下线。装好后,给了个小册子,自动切换到了 Agile 的电价,也就是每半小时的电价都不一样,提前一天可以在手机App和网站上查得。 正好在原来的电价计费合同快要结束前2天换到了智能电表计价 Octopus Agile方式,但是系统还是扣了我75英镑 Exit Fee (提前合同结束得交违约费),不过我一个电话打过去,公司很爽快就给我退了。...
  5. ChatGPT-4 使用 Math Wolfram 插件解决数学脑筋急转弯问题 这篇文章, 我们看一个简单的数学问题(脑筋急转弯), 并用 Python 解决它. 我们看一下LLM(大型语言模型): ChatGPT3.5和ChatGPT4. 通过 ChatGPT-Plus 订阅(目前每月 20 美元 + VAT增值税), 我们可以启用...
  6. 微软面试题: 三角形的面积是多少? 据说是一个印度人杀入微软最后的面试, 面试官给了这么一道小学数学几何题: 这哥门也有疑问 可是最后还是坚持 答案 30 (底 X 高 / 2) 不存在 这是个陷井: 这个直角三角形是不存在的. 两个小直角三角形的勾股定理:...
  7. 给STEEM中文微信群加了个机器人 之前说到我的公众号 justyyuk 可以查询几种虚拟货币的实时价钱, 但是有点不方便, 因为很多朋友在群里聊天得切换到公众号, 这下好了, 今天往STEEM中文微信群(还有编程群)加了个机器人, 在聊天的时候想了解价钱就直接输入货币代号即可, 如: 既方便自己, 又能方便别人(省事, 价格信息会同时显示给其它成员). 注: 这机器人不是我做的, 只是我拉进来的,...
  8. Javascript 中 sleep 函数实现 Javascript 中并没有 built-in 的 sleep 函数支持, 在 async/await/Promise 的支持之前, 我们可以用 busy-waiting 的方式来模拟: 1 2 3...

后面试时代与 AI 刷题

一、引言

七月的时候,我写了一篇文章《AI 编程助手测评:GitHub Copilot vs 豆包 MarsCode》,推荐了后者。

豆包 MarsCode 团队最近就邀请我,试用他们的新功能"代码练习",它专门用来准备面试和刷题。

试用以后,我有很多想法。

我觉得,AI 会改变程序员的面试,以后是 AI 刷题的后面试时代

二、面试的 LeetCode 刷题

现在的程序员面试,一般都有算法解题。

为了准备面试,大家都会去刷算法题,最常见的就是使用 LeetCode 题库。

LeetCode 是世界最流行的算法题库。它源自美国,专门收集各种面试算法题,目前已经超过了3000道,不少是互联网大厂的真题。

很多同学坚持不懈,每天投入几个小时,做里面的题目,训练自己的算法能力。一般来说,至少要刷200题,面试才较有把握。

(图片说明:上图是某个老外的刷题统计面板,他一年365天,每天都在刷题,一共刷了1500题。)

三、AI 颠覆 LeetCode

生成式 AI 出现后,越来越多的人发现,有了 AI,人肉刷题似乎不那么必要了。

今年(2024)8月份,一位美国网友在 Reddit 论坛发了这样一个帖子。

他说,自己写了一个脚本,让 AI 自动求解 LeetCode 算法题,一天之内做了633道题目,正确率达到了86%,成本只要9美元。

这个帖子引起了巨大反响。

既然 AI 可以提示正确答案,为什么不参考 AI 来准备面试呢? 何必坚持苦思冥想、徒手 LeetCode 刷题。

四、AI 助手的解题能力

我们完全可以跟着 AI 刷题,做到事半功倍。

我选一道 LeetCode 题目,展示一下这是多么简单的事情。


你是一个 JavaScript 程序员,请补充完成下面的函数。

要求是输入一个代表罗马数字的字符串(比如"IX"),该函数会返回对应的阿拉伯数字(比如9)。

 /**
  * @param {string} s 
  * @return {number} 
  */ 

var romanToInt = function(s) {


};

选择上面这道题,主要是因为它很短,便于展示。而且,这道题的难度不大,但需要考虑多种情况,很适合用在初级面试。

我选择的 AI 工具,就是 VS Code 编辑器的豆包 MarsCode 插件,它是免费的。

在 VS Code 代码编辑器里,从左边栏打开该插件(如果未安装,可以在插件市场搜索"MarsCode")。然后,在对话框输入题目。

稍等片刻,AI 就会给出答案以及代码解释。

哪个地方看不懂,就向 AI 询问。

这节省多少时间啊,大大加快了刷题速度。

五、后面试时代

可以看到,AI 能够快速解决面试算法问题,那么,基于算法解题的面试模式,是不是应该有所改变了?

现在的互联网大厂,都允许工作中使用 AI 模型,面试再要求"徒手解题",已经有点过时了。

AI 将越来越普及,如果未来的面试不使用 AI,就好像不使用 IDE 编辑器一样不合理,应该允许程序员在面试中使用日常工具。

我的想法是,面试的考察重点可能会改变,从让面试者解题,变成让面试者解释和审查 AI 生成的代码。

面试时,给你一段 AI 生成的解题代码,让你解释这段代码,并且修正里面的错误和没有考虑到的地方。

这样做有很多好处:(1)更贴近程序员的实际工作模式;(2)能看出程序员的代码阅读和理解能力;(3)难以事前准备或作弊;(4)更能反映一个人的协作能力;(5)也适用于偏向阅读代码而不是编写代码的岗位,比如项目经理、架构师等等。

这可以称为"基于 AI 的后面试时代",即面试从考察编码解题能力,演变到考察代码的审查理解能力。

六、AI 陪练功能

现在已经有很多工具,为面试提供 AI 辅助训练。

豆包 MarsCode 的"代码练习"就是一个最新的尝试。我用了以后,觉得对准备面试、有效刷题真的有帮助,可以训练自己与 AI 协同编程。

它是一个基于云 IDE 的专门页面,内置了上百道字节跳动的面试真题

所有相关操作都集中在这个页面上,只要有浏览器就可以使用,刷题全过程不必切换页面,大大方便了使用:选题、解题、调试、AI 对话......

而且,它是免费的,大家现在就可以试试看:进入豆包 MarsCode 官网,点击顶部菜单的"代码练习",就可以进入。

这个页面是云 IDE,不需要任何安装,打开后直接在浏览器使用。

默认状态下,页面分成四栏。左侧是面试真题的目录,目前有100道,以后还会不断增加。选中一道题目,点击后可以看到具体内容。

页面右侧是代码框和 AI 框。

在代码框,需要首先选择你的编程语言,共有8种可选(Python、JavaScript、Go、C++、C、Java、Rust、TypeScript),上图是 JavaScript。然后,输入解题代码,再点击顶部的运行按钮,就可以看到运行结果。

在 AI 框,则是三个预设选项,点击后就可以让 AI 提示思路、代码、检查代码。下方还有对话框,用来跟 AI 对话。

一般来说,拿到题目后,(1)先点击"需要一点思路提示",让 AI 帮助你理解题目。(2)再点击"给我一些代码提示吧",让 AI 给出初步代码。(3)你修改和完善代码,再点击"帮我检查一下代码",看看会不会报错。

此外,这个页面还集成了 IDE 功能,比如语法检查和代码调试。随着代码键入,会自动给出用法提示(下图)。

这样的"代码练习",堪称面试刷题的利器,我觉得,面试指导书籍和辅导班可能都不需要了,以后都会被淘汰。

七、总结

AI 改变软件行业,也必然改变程序员的面试求职。

AI 刷题是未来的趋势,善用者将更容易通过面试,拿到心仪的 offer。

但是,需要提醒的是,AI 只是工具,生成的代码不一定正确和安全,而且项目之中也不应该有黑箱。

正确的做法是,使用 AI 但不依赖,思路比答案更重要,只有理解了每一行代码,才能接受它的结果。我们使用 AI 的首要目的,永远是提升自己的能力,而不是让 AI 替代我们,代码的最后责任人的不是 AI,而是你。

(完)

文档信息

  • 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证
  • 发表日期: 2024年10月10日

买不到就自己做!「困ります」ボタンDIY记录

最近刷小红书的时候老是看到这个。正好雪先生@yuki 去日本现充,就拜托他去DAISO看看有没有机会碰到。然而雪先生去的DAISO都没有… 然后一天晚上(11点半)雪先生给我发这个照片: 给你图 你看看来做个适配手机的 非常环保减塑 那既然都这么说了,为了地球母亲!开干! 技术选型很简单。既然要节能减排,那么就将它贯彻到底,在保障开发体验的前提下怎么小怎么来。自然动辄几百K包体的React是不必考虑了,

来源

使用 PNPM 的情况下,Jest 解决 ESM 依赖库的报错问题

环境

  • NX
  • PNPM
  • lodash-es
  • Jest

从 karma 转移到 Jest 遇到了如下报错

主要原因是 “node_modules” 文件夹中 ESM(ECMAScript Modules) 库不被 Jest 支持。

鉴于 Jest ESM 支持还在几乎不可用的试验阶段,而目前我主要是在公司项目上迁移到 Jest。所以本文主要采用 transformIgnorePatternsmoduleNameMapper 两种配置来解决这个问题。

11c629a593c4c8484b6cb8ca44d6aa5f.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Test suite failed to runJest encountered an unexpected tokenJest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to support such syntax.Out of the box Jest supports Babel, which will be used to transform your files into valid JS based on your Babel configuration.By default "node_modules" folder is ignored by transformers.Here's what you can do:    • If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/ecmascript-modules for how to enable it.    • If you are trying to use TypeScript, see https://jestjs.io/docs/getting-started#using-typescript    • To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config.    • If you need a custom transformation specify a "transform" option in your config.    • If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option.You'll find more details and examples of these config options in the docs:https://jestjs.io/docs/configurationFor information about custom transformations, see:https://jestjs.io/docs/code-transformation

以下配置主要以 lodash-es 作为参考。

transformIgnorePatterns

官方文档的解释是:正则表达式模式字符串的数组,在转换之前与所有源文件路径匹配。如果文件路径与任何模式匹配,则不会对其进行转换。
transformIgnorePatterns 用于指定在进行代码转换时应该忽略的文件或文件夹。

而在 NX 默认的 Jest 配置中,配置为 node_modules/(?!.*\\.mjs$)
这个正则表达式的含义是,匹配以 node_modules/ 开头的文件夹路径,但排除那些以 .mjs 为扩展名的文件夹路径。?! 是一个否定预查,表示不匹配这样的文件夹路径。

1
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],

以上配置意思就是将会把以 .mjs 为扩展名的文件从 ESM 转换为 CommonJS,以支持 Jest。

添加转换 lodash-es

顺便支持一下 PNPM

1
2
3
4
5
6
7
const esModules = ['.*\\.mjs$', 'lodash-es'].join('|');export default {    ...    transformIgnorePatterns: [`node_modules/(?!.pnpm|${esModules})`],    ...}

转换后 failed 数量从 15 减少到 11,但是这么做会有一个转换的过程会有额外的支出,需要 51s。不过第一次转换完后貌似就会缓存然后就不用转换了。

ef4e6aeef369b021b707664f9c03549a.png

支出更少的方法 moduleNameMapper

这种方法需要库本身有对应的 CommonJS,就不需要转换了。可以跑到 12s

1
2
3
4
5
6
7
export default {    ...    moduleNameMapper: {        '^lodash-es$': 'lodash',    },  ...}

e87d8ad99b64c8f836a8c1777ec217bf.png

最终配置参考如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/* eslint-disable */const esModules = ['.*\\.mjs$'].join('|');export default {  displayName: 'pc',  preset: '../../jest.preset.js',  setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],  coverageDirectory: '../../coverage/apps/pc',  moduleNameMapper: {    '^lodash-es$': 'lodash',  },  transform: {    '^.+\\.(ts|mjs|js|html)$': [      'jest-preset-angular',      {        tsconfig: '<rootDir>/tsconfig.spec.json',        stringifyContentPathRegex: '\\.(html|svg)$',      },    ],  },  transformIgnorePatterns: [`node_modules/(?!.pnpm|${esModules})`],  snapshotSerializers: [    'jest-preset-angular/build/serializers/no-ng-attributes',    'jest-preset-angular/build/serializers/ng-snapshot',    'jest-preset-angular/build/serializers/html-comment',  ],};

参考

  1. Jest setup "SyntaxError: Unexpected token export"
  2. Configuring Jest · Jest
  3. ECMAScript Modules · Jest
  4. Configuring Jest · Jest

Promise 与异步编程

Promise 是 JavaScript 中的一个重要概念,与前端的工作更是息息相关。因此本文将整理一下 Promise 在日常工作中的应用。

目录

概念

MDN | 使用 Promise 中我们能学习到 Promise 的基础使用与错误处理、组合等概念,可以将 Promise 的特点概括为:

  • Promise 对象有三种状态,且状态一旦改变就不会再变。其值记录在内部属性 [[PromiseState]] 中:
    • pending: 进行中
    • fulfilled: 已成功
    • rejected: 已失败
  • 主要用于异步计算,并且可以将异步操作队列化 (链式调用),按照期望的顺序执行,返回符合预期的结果。
  • 可以在对象之间传递和操作 Promise,帮助我们处理队列
  • 链式调用的写法更简洁,可以避免回调地狱

在现实工作中,当我们使用 Promise 时更多是对请求的管理,由于不同请求或任务的异步性。因此我们会根据不同的使用场景处理 Promise 的调度。

async/await

async/await 是基于 Promise 的一种语法糖,使得异步代码的编写和理解更加像同步代码。

当一个函数前通过 async 关键字声明,那这个函数的返回值一定会返回一个 Promise,即便函数返回的值与 Promise 无关,也会隐式包装为 Promise 对象:

1
2
3
4
5
6
async function getAge() {
return 18;
}

getAge().then(age => console.log(`age: ${age}`))
// age: 18

await

await 操作符通常和 async 是配套使用的,它会等待 Promise 并拆开 Promise 包装直接取到里面的值。当它处于 await 状态时,Promise 还处于 ``,后续的代码将不会被执行,因此看起来像是 “同步” 的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function delayResolve(x, timeout = 2000) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(x);
}, timeout);
});
}

async function main() {
const x = await delayResolve(2, 2000);
console.log(x);

const y = await delayResolve(1, 1000);
console.log(y);
}

main();
// 2
// 1

错误处理

async/await 的错误处理通过是通过 try..catch 来捕获错误。当然,我们也会根据实际业务的需求来 catch 我们真正需要处理的问题。

1
2
3
4
5
6
7
8
9
try {
const response = await axios.get('https://example.com/user');

// 处理响应数据
console.log('User data fetched:', response.data);
} catch (error) {
console.error('Error response:', error.response);
// 做其他错误处理
}

学习了前文的基础概念后,我们可以更近一步的探讨 Promise 的使用。

Promise 串联

Promise 串联一般是指多个 Promise 操作按顺序执行,其中每个操作的开始通常依赖于前一个操作的完成。这种串行执行的一个典型场景是在第一个异步操作完成后,其结果被用作第二个异步操作的输入,依此类推。

考虑以下场景:加载系统时,需要优先读取用户数据,同时需要用户的数据去读取用户的订单的信息,再需要两者信息生成用户报告。因此这是一个存在前后依赖的场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function fetchUserInfo(userId) {
return axios.get(`/api/users/${userId}`);
}

function fetchUserOrders(userId) {
return axios.get(`/api/orders/${userId}`);
}

function generateReport(userInfo, orders) {
// 根据用户信息和订单生成报告
return {
userName: userInfo.name,
totalOrders: orders.length,
// ...其他报告数据
};
}

常规处理方法

处理串联请求无非有两种方法:

方法 1: 链式调用 .then()

在这种方法中,我们利用 .then() 的链式调用来处理每个异步任务。这种方式的优点是每个步骤都明确且连贯,但可能导致所谓的“回调地狱”,尤其是在处理多个串联的异步操作时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const userId = '12345'; // 假设已知的用户ID

fetchUserInfo(userId)
.then(response => {
const userInfo = response.data;
return fetchUserOrders(userInfo.id); // 使用用户ID获取订单
})
.then(response => {
const orders = response.data;
return generateReport(userInfo, orders); // 生成报告
})
.then(report => {
console.log('用户报告:', report);
})
.catch(error => {
console.error('在处理请求时发生错误:', error);
});

方法 2: 使用 async/await

async/await 提供了一种更加直观、类似同步的方式来处理异步操作。它使代码更易于阅读和维护,特别是在处理复杂的异步逻辑时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async function getUserReport(userId) {
try {
const userInfoResponse = await fetchUserInfo(userId);
const userInfo = userInfoResponse.data;

const userOrdersResponse = await fetchUserOrders(userInfo.id);
const orders = userOrdersResponse.data;

const report = generateReport(userInfo, orders);
console.log('用户报告:', report);
} catch (error) {
console.error('在处理请求时发生错误:', error);
}
}

const userId = '12345'; // 假设已知的用户ID
getUserReport(userId);

在这个示例中,使用 async/await 使得代码的逻辑更加清晰和直观,减少了代码的嵌套深度,使错误处理变得简单。

串联自动化

以上是日常工作中最常见的需求.但这里我们还可以发散一下思维,考虑更复杂的情况:

现在有一个数组,数组内有 10 个或更多的异步函数,每个函数都依赖前一个异步函数的返回值需要做处理。在这种请求多了的特殊情况下我们手动维护会显得很冗余,因此可以通过循环来简化逻辑:

方法 1: 通过数组方法 reduce 组合

1
2
3
4
5
6
7
8
9
10
11
const processFunctions = [processStep1, processStep2, processStep3, ...];

processFunctions.reduce((previousPromise, currentFunction) => {
return previousPromise.then(result => currentFunction(result));
}, Promise.resolve(initialValue))
.then(finalResult => {
console.log('最终结果:', finalResult);
})
.catch(error => {
console.error('处理过程中发生错误:', error);
});

方法 2: 循环体和 async/await 的结合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async function handleSequentialTasks(tasks, initialResult) {
let result = initialResult;

try {
for (const task of tasks) {
result = await task(result);
}
console.log('最终结果:', result);
} catch (error) {
console.error('处理过程中发生错误:', error);
}
}

const tasks = [task1, task2, task3, ...];
handleSequentialTasks(tasks, initialValue);

Promise 并发

并发(Concurrency)在编程中是指多个任务在同一时间段内启动、执行,但不一定同时完成。在 JavaScript 的 Promise 中,并发通常涉及同时开始多个异步操作,并根据它们何时解决(fulfilled)或被拒绝(rejected)来进行相应的处理。

Promise 的并发会比串联的场景更复杂。Promise 对象提供了几个静态方法来处理并发情况,让开发者可以根据不同的使用场景选择合适的方法:

Promise.all(iterable)

Promise.all 静态方法接受一个 Promise 可迭代对象作为输入,当传入的数组中每个都被 resolve 后返回一个 Promise。若任意一个 Promise 被 reject 后就 reject。

1
2
3
4
5
6
7
8
9
10
11
const promise1 = fetch('https://example.com/api/data1');
const promise2 = fetch('https://example.com/api/data2');

Promise.all([promise1, promise2])
.then(([data1, data2]) => {
console.log('所有数据已加载:', data1, data2);
})
.catch(error => {
console.error('加载数据时发生错误:', error);
});

Promise.allSettled(iterable)

Promise.allSettled 方法同样接受一个 Promise 的可迭代对象。不同于 Promise.all,这个方法等待所有传入的 Promise 都被解决(无论是 fulfilled 或 rejected),然后返回一个 Promise,它解决为一个数组,每个数组元素代表对应的 Promise 的结果。这使得无论成功还是失败,你都可以得到每个 Promise 的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
Promise.allSettled([
Promise.resolve(33),
new Promise((resolve) => setTimeout(() => resolve(66), 0)),
99,
Promise.reject(new Error("an error")),
]).then((values) => console.log(values));

// [
// { status: 'fulfilled', value: 33 },
// { status: 'fulfilled', value: 66 },
// { status: 'fulfilled', value: 99 },
// { status: 'rejected', reason: Error: an error }
// ]

Promise.race(iterable)

Promise.race 方法接受一个 Promise 的可迭代对象,但与 Promise.allPromise.allSettled 不同,它不等待所有的 Promise 都被解决。相反,Promise.race 返回一个 Promise,它解决或被拒绝取决于传入的迭代对象中哪个 Promise 最先解决或被拒绝。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
function sleep(time, value, state) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (state === "fulfill") {
return resolve(value);
} else {
return reject(new Error(value));
}
}, time);
});
}

const p1 = sleep(500, "one", "fulfill");
const p2 = sleep(100, "two", "fulfill");

Promise.race([p1, p2]).then((value) => {
console.log(value); // "two"
// Both fulfill, but p2 is faster
});

const p3 = sleep(100, "three", "fulfill");
const p4 = sleep(500, "four", "reject");

Promise.race([p3, p4]).then(
(value) => {
console.log(value); // "three"
// p3 is faster, so it fulfills
},
(error) => {
// Not called
},
);

const p5 = sleep(500, "five", "fulfill");
const p6 = sleep(100, "six", "reject");

Promise.race([p5, p6]).then(
(value) => {
// Not called
},
(error) => {
console.error(error.message); // "six"
// p6 is faster, so it rejects
},
);

Promise.any(iterable)

Promise.any 接受一个 Promise 的可迭代对象,并返回一个 Promise。它解决为迭代对象中第一个被解决的 Promise 的结果。如果所有的 Promise 都被拒绝,Promise.any 会返回一个 AggregateError 实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const promise1 = new Promise((resolve, reject) => {
setTimeout(resolve, 500, "one");
});

const promise2 = new Promise((resolve, reject) => {
setTimeout(reject, 100, "two");
});

Promise.race([promise1, promise2])
.then((value) => {
console.log("succeeded with value:", value);
})
.catch((reason) => {
// Only promise1 is fulfilled, but promise2 is faster
console.error("failed with reason:", reason);
});
// failed with reason: two

控制批次

JavaScript 默认提供的并发处理函数很方便我们根据业务场景的不同来处理请求,但显然我们工作中所遇到的需求得考虑更复杂的情况,还需要进一步的封装和扩展我们的 API。

在服务器端编程,我们经常遇到需要批量处理数据的场景。例如,批量修改数据库中的用户数据。在这种情况下,由于数据库操作的性能限制或者 API 调用限制,我们不能直接一口气修改全部,因为短时间内发出太多的请求数据库也会处理不来导致应用性能下降。因此,我们需要一种方法来限制同时进行的操作任务数,以保证程序的效率和稳定性。

我们代入实际业务场景:假设有一个社区组织了一次大型户外活动,活动吸引了大量参与者进行在线报名和付费。由于突发情况(比如恶劣天气或其他不可抗力因素),活动不得不取消。这时,组织者需要对所有已付费的参与者进行退款。

活动组织者发起「解散活动」后,服务端接收到请求后当然也不能一次性全部执行退款的操作啦,毕竟一场活动说不定有上千人。因此我们需要分批次去处理。

在上述社区活动退款的例子中,服务器端处理退款请求的一个有效方法是实施分批次并发控制。这种方法不仅保护了后端服务免受过载,还确保了整个退款过程的可管理性和可靠性。

分批次处理时有以下关键问题需要考虑:

  1. 批次大小:确定每个批次中处理的退款请求数量。这个数字应基于服务器的处理能力和支付网关的限制来确定。
  2. 批次间隔:设置每个批次之间的时间间隔。这有助于避免短时间内发出过多请求,从而减轻对数据库和支付网关的压力。
  3. 错误处理:在处理退款请求时,应妥善处理可能发生的错误,并确保能够重新尝试失败的退款操作。

简易版并发控制

将所有待处理的异步任务(如退款请求)存放在一个 tasks 数组中,在调用并发请求前将 tasks 数组分割成多个小批次,每个批次包含固定数量的请求。每当前一个批次处理完后,才处理下一个批次的请求,直到所有批次的请求都被处理完毕:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 假设这些是返回 Promise 的函数
const tasks = [task1, task2, task3, ...];

// 分割任务数组为批次
function splitIntoBatches(tasks, batchSize) {
let batches = [];
for (let i = 0; i < tasks.length; i += batchSize) {
batches.push(tasks.slice(i, i + batchSize));
}
return batches;
}

// 处理单个批次的函数
function processBatch(batch) {
return Promise.all(batch.map(task => task()));
}

// 控制并发的主函数
async function processTasksInBatches(tasks, batchSize) {
const batches = splitIntoBatches(tasks, batchSize);

for (const batch of batches) {
await processBatch(batch);
// 可以在这里加入日志记录或其他处理
console.log('一个批次处理完毕');
}

console.log('所有批次处理完毕');
}

// 调用主函数,假设每批次处理 10 个任务
processTasksInBatches(tasks, 10);

这种写法实现的并发简单易懂,也易于维护,在一些并发压力不大,比较简单的业务场景来看是足够了。

但如果我们将这种处理方式放在时序图上进行分析,就能发现服务器可能有能力处理更多的并发任务,而这种方法可能没有充分利用可用资源。每个批次开始前会依赖于上一个批次中请求响应时间最慢的那一个,因此我们还可以进一步考虑优化并发实现方案。

动态任务队列

在之前的 “控制批次” 方法中,我们发现固定处理批次的局限性,尤其是在并发任务数量较大时可能导致的资源利用不足。为了解决这个问题,我们可以考虑采用一种更灵活的方法:维护一个动态的任务队列来处理异步请求:

  • 任务队列:创建一个任务队列,其中包含所有待处理的异步任务。
  • 动态出队和入队:当队列中的任务完成时,它会被移出队列,同时根据当前的系统负载和任务处理能力,从待处理任务列表中拉取新的任务进入队列。
  • 并发数控制:设置一个最大并发数,确保任何时候处理中的任务数量不会超过这个限制。

我们封装一个函数,提供 concurrency 参数作为并发限制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
function parallelLimit(tasks, {concurrency = 10}) {
const results = [];
const executing = new Set();

let currentlyRunning = 0;
let currentIndex = 0;

return new Promise((resolve) => {
const next = () => {
if (currentIndex < tasks.length) {
// 取出记录数,准备执行
const index = currentIndex;
const task = tasks[index];

currentIndex += 1
currentlyRunning += 1;

const resultPromise = task().then((result) => {
// 任务执行完毕,更新运行数、保存结果
currentlyRunning -= 1;
results[index] = result;
executing.delete(resultPromise);

// 开启下一个任务
next();
});

executing.add(resultPromise);

// 当前运行的任务数小于限制并且还有任务未开始时,继续添加任务
if (currentlyRunning < concurrency && currentIndex < tasks.length) {
next();
}
} else if (currentlyRunning === 0) {
// 所有任务都已完成
resolve(results);
}
};

// 初始化
for (let i = 0; i < Math.min(concurrency, tasks.length); i += 1) {
next();
}
});
}

该函数会在初始阶段会按照并发数先同步执行指定任务数,若某个任务执行完毕后,在执行完毕的回调中会唤醒下一个任务,直至任务队列执行完毕。

以下添加一些测试数据用于测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
function asyncTask(id) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`任务 ${id} 完成`);
resolve(`结果 ${id}`);
}, Math.random() * 2000);
});
}
const taskArray = Array.from({ length: 10 }, (_, i) => () => asyncTask(i + 1));

parallelLimit(taskArray, {concurrency: 3}).then((results) => {
console.log('所有任务完成:', results);
});

第三方库

在实际的项目开发中,特别是面临复杂的并发处理需求时,我们更多会考虑使用成熟的第三库来处理业务问题,它们具备更完善的测试用例来检验边界情况。

处理并发的热门库有 RxJSp-mapasync.js

  • RxJS 是一个以响应式编程为核心的库,竟然搭配 Angular 在网页端搭配使用,提供了丰富的操作符和方法来处理异步事件和数据流。
  • p-mapasync.js 包的体积更小,更适合在服务端中使用。p-map 专注于提供并发控制功能,而 async.js 提供包括并发控制、队列管理等广泛的异步处理模式,功能会更全。

笔者在 Node.js 环境下只需要处理并发问题,故用的 p-map 会更多一些。下面简要介绍 p-map 的使用:

1
2
3
4
5
6
7
8
9
import pMap from 'p-map';

const list = Array.from({ length: 10 }, (_, i) => i)

pMap(list, asyncTask, { concurrency: 3 })
.then((results) => {
console.log('所有任务完成:', results);
});

p-map源码实现很精简,建议想深入复习并发的同学去阅读其底层代码的实现作为参考思路。

总结

在本文中,我们首先回顾了 Promise 的基本概念及其在 JavaScript 异步编程中的常用方法。通过这个基础,我们能够更好地理解如何有效地处理和组织异步代码。

随后,我们深入到并发处理的实际应用场景,探讨了如何根据具体需求选择合适的并发实现策略。我们讨论了从简单的批次控制到更复杂的动态任务队列的不同方法,展示了在不同场景下优化异步任务处理的多种可能性。

但值得注意的是,我们自行实现的并发控制工具在没有做足测试用例测试时,可能不适合直接应用于生产环境。在实际的项目开发中,选择成熟且持续维护的第三方库往往是更安全和高效的选择。比如笔者选择的 p-map 稳定性和可靠性相比上文简单实现的版本将会更好。


参考资料

组件通信: EventBus 的原理解析与应用

在开发复杂的单页面应用时,我们经常会遇到一个问题:如何高效地在组件或模块之间进行通信?这里,EventBus(事件总线)就派上了用场。简单来说,EventBus 是一种设计模式,它允许不同组件或模块之间通过事件来通信,而无需直接引用彼此。

EventBus 是传统的组件通信解决方案,下面我们将讲解 EventBus 跨组件通信的原理、实现方式以及该如何使用。

原理解析

EventBus 的核心在于提供一个中央机制,允许不同的组件或模块相互通信,而不必直接引用对方。它是一种典型的发布-订阅(pub-sub)模式,这是一种广泛使用的设计模式,用于解耦发送者和接收者。

在这个模式中,EventBus 充当了一个中介的角色:它允许组件订阅那些它们感兴趣的事件,并在这些事件发生时接收通知。同样,当某个事件发生时,比如用户的一个动作或者数据的变化,EventBus 负责将这一消息广播给所有订阅了该事件的组件。

它基于三个核心操作:注册事件(on(event, callback))、触发事件(emit(event, ...args))、以及移除事件(off(event, callback))。因此,EventBus 的基本代码可以看到:

1
2
3
4
5
6
7
8
9
10
11
12
13
class EventBus {
on(event, callback) {
// 注册事件监听器
}

emit(event, ...args) {
// 触发事件
}

off(event, callback) {
// 移除事件监听器
}
}

显然,我们需要有一个私有变量来储存用户的函数,此时为类添加 events 属性。events 属性是一个对象映射,其中每个属性表示一个事件名称,对应的值是一个回调函数的数组,这个数组存储了所有订阅了该事件的回调函数。

1
2
3
4
class EventBus {
private events: Record<string, Function[]> = {};
// ...
}

当用户执行订阅事件 on 时,回调函数会被添加到相应事件名称的数组中。这样,同一个事件可以被不同组件或模块订阅,而每个订阅者的回调函数都会被正确地保存在事件队列中。最后,当触发事件 emit 时,事件队列中的每个回调函数都会被执行,实现了事件的触发和通知功能。若已经没有订阅需求,则可以通过 off 移除已经订阅的事件。

代码实现

接下来我们按照前文所述完善我们的代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
class EventBus {
// 事件存储对象,用于保存不同事件的回调函数
private events: Record<string, Function[]> = {};

/**
* 注册事件监听器
* @param eventName - 事件名称
* @param callback - 回调函数,当事件触发时执行
* @returns this - 返回 EventBus 实例,支持链式调用
*/
public on(eventName: string, callback: Function): this {
// 检查回调函数是否为函数类型
if (typeof callback !== "function") {
throw new Error("EventBus 'on' method expects a callback function.");
}

// 如果事件不存在,创建一个空数组用于存储回调函数
if (!this.events[eventName]) {
this.events[eventName] = [];
}

// 将回调函数添加到事件的回调函数列表中
this.events[eventName].push(callback);

// 支持链式调用
return this;
}

/**
* 触发事件
* @param eventName - 要触发的事件名称
* @param args - 传递给回调函数的参数
* @returns this - 返回 EventBus 实例,支持链式调用
*/
public emit(eventName: string, ...args: any[]): this {
// 获取事件对应的回调函数列表
const callbacks = this.events[eventName];
if (callbacks) {
// 遍历执行每个回调函数,并传递参数
callbacks.forEach((callback) => callback(...args));
}

// 支持链式调用
return this;
}

/**
* 移除事件监听器
* @param event - 要移除的事件名称或事件名称数组
* @param callback - 要移除的回调函数(可选)
* @returns this - 返回 EventBus 实例,支持链式调用
*/
public off(event?: string | string[], callback?: Function): this {
// 清空所有事件监听器
if (!event || (Array.isArray(event) && !event.length)) {
this.events = {};
return this;
}

// 处理事件数组
if (Array.isArray(event)) {
event.forEach((e) => this.off(e, callback));
return this;
}

// 如果没有提供回调函数,则删除该事件的所有监听器
if (!callback) {
delete this.events[event];
return this;
}

// 移除特定的回调函数
const callbacks = this.events[event];
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}

// 支持链式调用
return this;
}
}

当涉及到一次性的事件监听需求时,我们可以进一步扩展 EventBus,以支持一次性事件监听。允许用户在某个事件触发后,自动移除事件监听器,以确保回调函数只执行一次:

1
2
3
4
5
6
7
8
9
10
11
12
13
class EventBus {
// other code ...
public once(eventName: string, callback: Function): this {
const onceWrapper = (...args: any[]) => {
this.off(eventName, onceWrapper);
callback(...args);
};

this.on(eventName, onceWrapper);

return this;
}
}

使用方式

我们将类的封装到 event-bus.ts 中,通过模块的来管理:

1
2
3
export class EventBus {
// ...
}

我们现在已经封装好了一个类,若我们像使用则需要实例化。此处再文件内直接实例化一个类:

1
2
// 创建 EventBus 实例并导出
export const eventBus = new EventBus();

这样使用时可以提供两种方式:

  1. 引入已经实例化的 eventBus

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    import { eventBus } from './event-bus';

    // 订阅事件
    eventBus.on('eventName', callback);

    // 触发事件
    eventBus.emit('eventName', data);

    // 移除事件
    eventBus.off('eventName', callback);
  2. 需要多个独立的事件总线实例时,或者希望在不同模块或组件之间使用不同的事件总线时,可以选择额外实例化 eventBus。这样做的目的可能是为了隔离命名的冲突、组件与模块逻辑隔离等原因。

    1
    2
    3
    4
    5
    6
    // events.ts
    import { EventBus } from './event-bus';

    // 创建独立的事件总线实例
    export const eventBusA = new EventBus();
    export const eventBusB = new EventBus();
    1
    2
    3
    4
    5
    6
    7
    8
    9
    import {eventBusA, eventBusB} from './events'

    // 在不同模块或组件中使用不同的事件总线
    eventBusA.on('eventA', callbackA);
    eventBusB.on('eventB', callbackB);

    // 触发不同事件总线上的事件
    eventBusA.emit('eventA', dataA);
    eventBusB.emit('eventB', dataB);

以下是 CodeSandbox 的演示代码:

总结

在本文中,我们深入探讨了 EventBus 的原理,了解了它是如何工作的。我们学习了它的核心操作。除了本文所提及的实现方式,有时候在生产项目中,为了确保代码的可靠性,我们可以考虑使用成熟的第三方库,例如 mitttiny-emitter

这些库已经经过广泛的测试和使用,可以提供稳定和可靠的 EventBus 功能。

Redux 食用指南

Redux 是一个强大的状态管理框架,被广泛用于管理应用程序的状态。它的设计理念是让状态的更新可预测和透明。本文将简要探讨 Redux 的核心机制和实际应用。

在 Redux 中,有一个状态对象负责应用程序的整个状态.Redux store 是应用程序状态的唯一真实来源

如果应用程序想要更新状态,只能通过 Redux store 执行,单向数据流可以更轻松地对应用程序中的状态进行监测管理。

Redux store 是一个保存和管理应用程序状态的 state,使用 Redux 对象中的 createStore() 来创建一个 redux store,此方法将 reducer 函数作为必需参数.

1
2
3
const reducer = (state = 5) => state;

const store = Redux.createStore(reducer);

获取数据

Redux store 对象提供了几种允许你与之交互的方法,可以使用 getState() 方法检索 Redux store 对象中保存的当前的 state

1
2
3
4
5
6
const store = Redux.createStore(
(state = 5) => state
);

// 更改此行下方的代码
const currentState = store.getState();

更新状态

由于 Redux 是一个状态管理框架,因此更新状态是其核心任务之一。在 Redux 中,所有状态更新都由 dispatch action 触发,action 只是一个 JavaScript 对象,其中包含有关已发生的 action 事件的信息。

Redux store 接收这些 action 对象,然后更新相应的状态。action 对象中必须要带有 type 属性,reducer 才能根据 type 进行区分处理。
action 除了 type 属性外,还可以附带数据给 reducer 做相应的处理,这个数据是可选的。

我们可以将 Redux action 视为信使,将有关应用程序中发生的事件信息提供给 Redux store,然后 store 根据发生的 action 进行状态的更新。

reducer

reducer 将 state 和 action 作为参数,并且它总是返回一个新的 state。这是 reducer 的唯一的作用,它不应有任何其他的作用。比如它不应调用 API 接口,也不应存在任何潜在的副作用。reducer 只是一个接受状态和动作,然后返回新状态的纯函数

在 reducer 中一般通过 switch 进行判断 action 的类型,做不同的处理。

订阅事件

store.subscribe() 可以订阅 store 的数据变化,它接收一个回调函数作为参数。当 store 数据更新时会调用该回调函数。

模块划分

当应用程序的状态开始变得越来越复杂时,将状态划分为多个部分可能是个更好的选择。我们可以考虑将不同的模块进行划分,Login 作为一个模块,Account 作为另一个模块。

但对 state 进行模块划分也不能破坏 redux 中将数据存入简单 state 的原则。因此可以生成多个 reducer, 再将它们合并到 root reducer 中。

redux 提供了 combineReducers() 函数对 reducer 进行合并。它接收一个对象作为参数,对象中的 key/value 别分对应着 module name 和相对应的 reducer 函数。

1
2
3
4
5
6
7

const rootReducer = Redux.combineReducers({
counter: counterReducer,
auth: authReducer
})

const store = Redux.createStore(rootReducer);

异步

redux 本身是不能直接处理异步操作,因此需要引入中间件来处理这些问题。在 createStore 时,还可以传入第二个可选参数,这个参数就是传递给 redux 的中间件函数。

Redux 提供了 applyMiddleware() 来创建一个中间件,一般处理 redux 异步的中间件有 redux-thunkredux-saga 等。

redux-thunk

redux-thunk 允许 action 创建函数返回一个函数而不是一个 action 对象。这个返回的函数接收 dispatchgetState 作为参数,允许直接进行异步操作和状态的分发。

例如,一个异步获取数据的 thunk 可能如下所示:

1
2
3
4
5
6
7
8
9
function fetchData() {
return (dispatch, getState) => {
// 异步操作
fetch('some-api-url')
.then(response => response.json())
.then(data => dispatch({ type: 'FETCH_DATA_SUCCESS', payload: data }))
.catch(error => dispatch({ type: 'FETCH_DATA_ERROR', error }));
};
}

redux-saga

redux-saga 是一个更高级的中间件,它使用 ES6 的 Generator 函数来让你以同步的方式写异步代码。saga 监听发起的 action,并决定基于这些 action 执行哪些副作用(如异步获取数据、访问浏览器缓存等)。

一个简单的 saga 可能如下所示:

1
2
3
4
5
6
7
8
function* fetchDataSaga(action) {
try {
const data = yield call(fetch, 'some-api-url');
yield put({ type: 'FETCH_DATA_SUCCESS', payload: data });
} catch (error) {
yield put({ type: 'FETCH_DATA_ERROR', error });
}
}

React 与 Redux

在 React 应用中,Redux 被用来跨组件共享状态。使用 react-redux 库可以方便地将 Redux 集成到 React 应用中。

Provider 组件

Providerreact-redux 提供的一个组件,它使 Redux store 对 React 应用中的所有组件可用。通常,我们在应用的最顶层包裹 Provider 并传入 store:

1
2
3
4
5
6
7
8
import { Provider } from 'react-redux';
import { store } from './store';

const App = () => (
<Provider store={store}>
<MyRootComponent />
</Provider>
);

connect 函数

connect 是一个高阶函数,用于将 React 组件连接到 Redux store。它接受两个参数:mapStateToPropsmapDispatchToProps,分别用于从 store 中读取状态和向 store 发起 actions。

1
2
3
4
5
6
7
8
9
10
11
import { connect } from 'react-redux';

const mapStateToProps = state => ({
items: state.items
});

const mapDispatchToProps = dispatch => ({
fetchData: () => dispatch(fetchData())
});

export default connect(mapStateToProps, mapDispatchToProps)(MyComponent);

总结

Redux 提供了一种统一、可预测的方式来管理应用程序的状态。通过使用 actions, reducers 和 store,开发者可以以一种高度解耦的方式来管理状态和 UI。

当结合异步处理和 React 集成时,Redux 成为了一个强大的工具,能够提升大型应用程序的开发和维护效率。

JavaScript 实现二叉树

二叉树数据结构的学习与笔记。

目录

二叉树的储存结构

二叉树有两种储存方式,一种是顺序储存结构,一种是链式储存结构。

顺序储存结构就是二叉树从上至下,每层从左到右给树中节点进行编号:

1
[0,1,2,3,4,5,6]

0 是根节点,1 是根的左节点,2 是根的右节点,3 是根的左节点的左节点,4 是根的左节点的右节点…… 依照这个顺序排列下去。设 i 为顺序表中节点的索引, Qi 代表顺序表上储存的节点, n 为顺序表的长度,则可知:

  1. i = 0Qi 节点是根节点
  2. 2i+1 < n, 则索引 2i+1 上储存的是 Qi 的左节点。反之,则没有节点。
  3. 2i+2 < n, 则索引 2i+2 上储存的是 Qi 的右节点。反之,则没有节点。
  4. **Qi 的双亲节点的索引为 (i-1)/2**。比如 i=4, (i-1)/2 向下取整等于 1, 索引为 4 的双亲节点为 1

链式储存的结构大致如下:

1
2
3
4
5
6
7
8
9
10
class TreeNode {
val: number
left: TreeNode | null
right: TreeNode | null
constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
this.val = (val===undefined ? 0 : val)
this.left = (left===undefined ? null : left)
this.right = (right===undefined ? null : right)
}
}

顺序结构转链式结构

利用二叉树的性质,可以将顺序储存方式转换为对应的链式结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class TreeNode {
constructor(val, left, right) {
this.val = (val === undefined ? 0 : val)
this.left = (left === undefined ? null : left)
this.right = (right === undefined ? null : right)
}
}

function toLinkedListBinaryTree(list) {
// 临时用于储存被转换为链表的节点
const nodelist = [];

for (let i = 0; i < list.length; i++) {
const node = new TreeNode(list[i]);
nodelist.push(node);

// 根节点没有双亲节点
if (i > 0) {
// 由结论 4 可得双亲节点的索引
const parentIdx = Math.floor((i - 1) / 2);
const parent = nodelist[parentIdx];

// 当前层从左向右赋值,若左节点被赋值,则剩下右节点没有被赋值
if (parent.left) {
parent.right = node;
} else {
parent.left = node;
}
}

}

return nodelist.shift()
}

// 在 console 进行测试
cnsole.log(toLinkedListBinaryTree([0,1,2,3,4,5,6,7,8,9]));

二叉树的遍历

遍历二叉树是指沿着某条搜索路径周游二叉树,依次对树中的每个节点访问且仅访问一次。

二叉树的遍历方式可以分为递归非递归方式。遍历算法也可以分为**深度优先搜索 (Depth-First-Search,DFS)广度优先搜索 (Breadth-First Search)**。

根据二叉树的递归定义,遍历一颗非空二叉树的问题可分为三个子问题: 访问根节点 (D),遍历左子树 (L),遍历右子树 (R)。遍历的顺序可分为: DLR (前序)、LDR (中序)、LRD (后序) 和 DRL (前序)、RDL (中序)、RLD (后序)。前三种是先左后右,后三种是先右后左。一般没有提别指明的话,我们谈论二叉树的遍历,都是在讲前三种。

二叉树的前序遍历、中序遍历、后序遍历都可以通过递归方式非递归方式实现。

前序序遍历

递归形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
function preorderTraversal(root: TreeNode | null): number[] {
return postorder(root, [])
};

function postorder(root?: TreeNode, result = []): number[] {
if (!root) return result;

result.push(root.val);
postorder(root.left, result);
postorder(root.right, result);

return result;
}

中序遍历

递归形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
function inorderTraversal(root: TreeNode | null): number[] {
return inorder(root, [])
};

function inorder(root?: TreeNode, result = []): number[] {
if (!root) return result;

inorder(root.left, result);
result.push(root.val);
inorder(root.right, result);

return result;
}

后序遍历

递归形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
function postorderTraversal(root: TreeNode | null): number[] {
return postorder(root, [])
};

function postorder(root?: TreeNode, result = []): number[] {
if (!root) return result;

postorder(root.left, result);
postorder(root.right, result);
result.push(root.val);

return result;
}

层序遍历

层序遍历就是把二叉树分层,然后每一层从左到右遍历:

层序遍历二叉树很自然就能想到使用 BFS(广度优先搜索) 来遍历每层。

该算法采用一个队列来缓存二叉树的节点,若树不为空,先将二叉树根节点输出,先将根节点入队,再到循环体内出队。若根节点还有左孩子,则将左孩子也添加到队列中。若有右孩子,也将右孩子也添加到队列中。如此下去,直到队列为空:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 按层输出二叉树的值
function levelOrder(root: TreeNode | null) {
if (!root) return;

// 队列,先进先出
const queue = [root];

while (queue.length) {
// 取队首的元素
const node = queue.shift();
console.log('node --> ', node.val)

// 若有左右节点,则添加至队列
if (node.left) queue.push(node.left);
if (node.right) queue.push(node.right);
}
};

若想将每一层的值都存入数组中,则可以采用二维数组进行储存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function levelOrder(root: TreeNode | null): number[][] {
if (!root) return [];

// 最终会返回的结果
const result = [];

// 队列,先进先出
const queue = [root];

while (queue.length) {
// 当前层级
const level = [];

// 当前队列的长度
const n = queue.length;

for (let i = 0; i < n; i += 1) {
const node = queue.shift();
level.push(node.val);

// 若有左右节点,则添加至队列
// 由于已经储存上一轮的节点数,因此这里不会影响 n 的值
if (node.left) queue.push(node.left);
if (node.right) queue.push(node.right);
}

result.push(level);
}

return result;
};

合并二叉树

不考虑副作用的话,可以直接将 root1 作为结果,修改 root1 的值即可。

1
2
3
4
5
6
7
8
9
function mergeTrees(root1?: TreeNode, root2?: TreeNode): TreeNode | null {
if (!root1 || !root2) return root1 || root2;

root1.val += root2.val;
root1.left = mergeTrees(root1.left, root2.left);
root1.right = mergeTrees(root1.right, root2.right);

return root1;
};

二叉排序树 (BST)

二叉排序树(Binary Sort Tree)又称二叉查找树,它是一种特殊的二叉树,它或为空树,或具有以下性质的二叉树:

  1. 它的右子树非空,则右子树上所有节点的值都大于根节点的值。
  2. 它的左子树非空,则左子树上所有节点的值都小于根节点的值。
  3. 左右子树各是一颗二叉排序树。

以下为创建二叉排序树的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function sortedArrayToBST(nums: number[]): TreeNode | null {
let tree = null, node;

while(nums.length) {
node = new TreeNode(nums.shift())
tree = insertBST(tree, node)
}

return tree;
};

function insertBST(tree: TreeNode, node: TreeNode) {
let parent, p = tree;

while(p) {
// parent 指向 p 的双亲
parent = p;

// 要插入的节点的值小于 p 的值,赋值为左节点
// 要插入的节点的值大于 p 的值,赋值为右节点
p = node.val < p.val ? p.left : p.right;
}

if (tree == null) return node;

// console.log('p',parent.val, node.val)
if(node.val < parent.val) {
parent.left = node;
} else {
parent.right = node;
}

return tree;
}

高度平衡二叉搜索树

高度平衡二叉树是一棵满足「每个节点的左右两个子树的高度差的绝对值不超过 1」的二叉树。

Q: 给定已按升序排序的整数数组,将其构建为二叉树。

A: 因为数组已经排过序了,因此可以直接采用二分法进行构建。先去中间的元素,再向两侧递归构建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function sortedArrayToBST(nums: number[]): TreeNode | null {
return dfs(nums, 0, nums.length - 1)
};

function dfs(nums: number[], min: number, max: number): TreeNode | null {
if (min > max) return null;

// 取中间的索引,先减后加的方式可以避免索引值溢出
const mid = min + Math.floor((max - min) / 2);

// 由于是采用二分法,因此左右子树的高度差不会超过 1
const root = new TreeNode(
nums[mid],
dfs(nums, min, mid - 1),
dfs(nums, mid + 1, max)
);

return root;
}

判断指定树是否是平衡树

可以采用自底向上进行遍历,该遍历方法类似于后序遍历:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function isBalanced(root: TreeNode | null): boolean {
return height(root) !== -1;
};

function height(root?: TreeNode) {
if (!root) return 0;

const left = height(root.left);
if (left == -1) return -1;

const right = height(root.right);
if (right == -1) return -1;

// 高度差超过 1
if (Math.abs(left - right) > 1) return -1;

// 当前层 + 1
return Math.max(left, right) + 1;
}
  • 时间复杂度:O(n)O(n),其中 nn 是二叉树中的节点个数。使用自底向上的递归,每个节点的计算高度和判断是否平衡都只需要处理一次,最坏情况下需要遍历二叉树中的所有节点,因此时间复杂度是 O(n)O(n)。
  • 空间复杂度:O(n)O(n),其中 nn 是二叉树中的节点个数。空间复杂度主要取决于递归调用的层数,递归调用的层数不会超过 nn。

闭包与链式设计的使用示例

最近遇到了个按需请求数据的需求,非常适合用于讲解闭包与链式设计的例子,故来分享一下思路。

大致需求如下: 目前有个 list, list 中每项 item 都是可展开的折叠项。当展开某个折叠项时,需要根据 item 的 code 另外去取 name 的映射。考虑到列表的数据量非常大,且一次性查询过多 code 时,接口的查询效率会明显降低,故采用按需请求映射的方案。

屏蔽与本例无关的属性,瘦身后的 list 数据结构大致如下:

1
2
3
4
5
6
interface DataType {
code: string;
paymentTransaction: string[];
}

type ListType = DataType[];

我们知道大型企业中的数据会比较复杂,比较常见的一种情况是数据中有一个 id 或 code 是用于跟另一个数据项相关联的。学习过数据库的同学很容易就联想到了外键这个概念。

现在我们就要取出这些 code 发送给服务端去查询。考虑到 code 可能会有重复,因此可以将 codes 存入 Set 中,利用 Set 的特性去重。除此之外,为了使 name 映射可以被复用,每次从接口返回的 name 映射将会被缓存起来。若下次再触发事件时有对应的 key,便不再查询。

我们可以将这段逻辑抽离出来作为一个依赖收集的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const mapping = new Map();

function collectionCodes(initCodes?: string[] | Set<string>) {
const codes = new Set<string>(initCodes)

return {
append(code: string) {
if (!mapping.has(code)) {
codes.add(code);
}

return this;
},
empty() {
return !codes.size;
},
value() {
return codes;
},
}
}

collectionCodes 函数是用于收集 codes。它内部利用了闭包的特性将 codes 缓存了起来,并且在添加新的 code 之前会判断 code 在 local 的映射中是否已经存在。append 返回的 this 是经典的链式调用设计,允许多次链式添加。当本次依赖收集结束后,调用 value 方法获取最终的 codes。

可以写一些简单的 mock 数据进行尝试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
function handleNameMapping(data: DataType) {
const codes = collectionCodes()
.append(data.code)
.append('code-append-1')
.append('code-append-1')
.append('code-append-2');

data.paymentTransaction.forEach(code => codes.append(code));

if (codes.empty()) {
console.log('can get values from existing mapping.')
return;
}

// 如果请求的数据需要转为数组,可以 Array.from 进行转换
const list = Array.from(codes.value());
console.log('fetch data before, codes --> ', list);

// mock 获取数据后拿到 name mapping 后,存入 mapping 中的行为.
// 注意,Set 类型也可以用 forEach 方法,不一定得转为数组才可以操作
list.forEach(code => mapping.set(code, `random-name-${Math.random()}`))
}

const mockItemData = {
code: 'code-main',
paymentTransaction: [
'code-payment-4',
'code-payment-1',
'code-payment-2',
'code-payment-1',
'code-payment-3',
]
}

handleNameMapping(mockItemData);
// fetch data before, codes --> (7) ["code-main", "code-append-1", "code-append-2", "code-payment-4", "code-payment-1", "code-payment-2", "code-payment-3"]

handleNameMapping(mockItemData);
// can get values from existing mapping.

handleNameMapping 在发起请求前会做 code 收集,若本次收集中没有需要 fetch 的 code,那就避免发送无用的 HTTP 请求,从而达到了优化的目的。

最终示例的 TS 代码如下。若想直接在控制台尝试效果的话,可以通过 ts 官网中的 Playground 编译为可直接运行的 js 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
interface DataType {
code: string;
paymentTransaction: string[];
}

const mapping = new Map();

function collectionCodes(initCodes?: string[] | Set<string>) {
const codes = new Set<string>(initCodes);

return {
append(code: string) {
if (!mapping.has(code)) {
codes.add(code);
}

return this;
},
empty() {
return !codes.size;
},
value() {
return codes;
},
};
}

function handleNameMapping(data: DataType) {
const codes = collectionCodes()
.append(data.code)
.append('code-append-1')
.append('code-append-1')
.append('code-append-2');

data.paymentTransaction.forEach((code) => codes.append(code));

if (codes.empty()) {
console.log('can get values from existing mapping.');
return;
}

// 如果请求的数据需要转为数组,可以 Array.from 进行转换
const list = Array.from(codes.value());
console.log('fetch data before, codes --> ', list);

// mock 获取数据后拿到 name mapping 后,存入 mapping 中的行为.
// 注意,Set 类型也可以用 forEach 方法,不一定得转为数组才可以操作
list.forEach(code => mapping.set(code, `random-name-${Math.random()}`))
}

const mockItemData = {
code: 'code-main',
paymentTransaction: [
'code-payment-4',
'code-payment-1',
'code-payment-2',
'code-payment-1',
'code-payment-3',
],
};

handleNameMapping(mockItemData);
// fetch data before, codes --> (7) ["code-main", "code-append-1", "code-append-2", "code-payment-4", "code-payment-1", "code-payment-2", "code-payment-3"]

handleNameMapping(mockItemData);
// can get values from existing mapping.

本例的分析就到此结束了,虽然在本例中链式调用没有充分展示出自己的优势,但也可以作为一个设计思路用于参考。

React 知识回顾 (优化篇)

接下来对 React 性能相关的问题进行知识回顾。

完整目录概览

React 代码复用

  • Render Props
  • 高阶组件 (HOC)
  • 自定义 Hooks
  • Mixins (已被 React 废弃)

Render props

Render props 是一种在 React 组件之间共享代码的简单技术。具体的行为是:

  1. 子组件接收一个用于渲染指定视图的 prop 属性,该属性的类型是函数。
  2. 父组件在组件内部定义该函数后,将函数的引入传给子组件
  3. 子组件将组件内部 state 作为实参传给从外面传来的函数,并将函数的返回结果渲染在指定的视图区域。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 组件使用
<Mouse render={(x, y) => <span>x: {x}, y: {y}</span>} />

// 组件内部大致实现
class Mouse extends React.Component {
state = { x: 0, y: 0 };

render() {
return (
<section>
<header>头部信息</header>
<main>{this.props.render(this.state)}</main>
<footer>底部信息</footer>
</section>
);
}
}

准确来说 Render props 是一个用于告知组件需要渲染什么内容的函数属性。props 的命名可以由自己定义,比如用于在内容区域渲染的 prop 名可以叫 render,同时还可以再接收一个 renderHead 的 prop 用于渲染头部的信息。

高阶函数、高阶组件分别是什么?

高阶函数就是接收其它函数作为参数的函数就称之为高阶函数,像数组的 mapsortfilter 都是高阶函数。

高阶组件(Higher-order component, HOC) 是 React 用于复用组件逻辑的一种高级技巧。它具体的行为是:

函数接收一个组件作为参数,在函数体内定义一个新组件,新组件内编写可复用的逻辑并应用到参数组件中。最后再将新组件作为函数的返回值 return 出去。
redux 中的 connect 函数就是一个高阶组件。

React 性能优化

  1. 对比 props/state 新旧值的变化来决定是否渲染组件,参见:父组件在执行 render 时会不会触发子组件的 render 事件?如果会该怎么避免?
  2. 列表渲染时每项添加唯一的 key。参见:渲染列表为啥要用 key?
  3. 定时器、DOM 事件等在组件销毁时一同销毁,从而避免内存泄露。
  4. 代码分割,使用异步组件。
  5. Hooks 使用 useMemo 缓存上一次计算的结果,避免重复计算值。

父组件在执行 render 时会不会触发子组件的 render 事件?如果会该怎么避免?

如果父组件渲染后,子组件接收的 props 也跟着发生了改变,那么默认情况下会触发子组件的渲染。

若子组件接受的 props 没有发生改变,那就得判断子组件的状况。

如果子组件是继承于 Component 声明的组件,并且没有使用 shouldComponentUpdate 做避免重复渲染的处理,那么子组件会触发 render 事件。

为了避免重复渲染,类组件可以使用 shouldComponentUpdate 来决定是否进行渲染。也可以将继承于 Component 组件改为继承 PureComponment,该组件会浅对比 Props 是否进行改变,从而决定是否渲染组件。

如果是函数组件,可以通过 React.memo 来对函数组件进行缓存。

渲染列表为啥要用 key?

渲染列表时,如果不给列表子项传 key 的话,React 将默认使用 index 作为 key,同时会在控制台发出警告。

key 在兄弟节点之间必须唯一,要避免使用数组下标 index 作为 key。因为使用数组下标作为 `key 时,若数组的顺序发生了改变,将会影响 Diffing 算法的效率。

若列表的节点是组件的话,还可能会影响组件的 state 数据。因为组件实例是基于 key 来决定是否更新与复用。当顺序发生了变化,则 key 也会相应得被修改,从而导致子组件间的数据错乱。

React 使用的 Diffing 算法是通过 tagkey 判断是否是同一个元素(sameNode)。使用唯一的 key 有助于 React 识别哪些元素发生改变,如节点添加或删除。这样有助于减少渲染次数,从而优化性能。

如果数组中的数据没有唯一的 key,可以引入 shortid 预先给数组中每项数据生成唯一的 id

1
2
3
4
5
6
7
8
9
10
const shortid = require('shortid');

function addId(data) {
return {
...data,
id: shortid.generate(),
}
}

const newList = list.map(addId);

若确定没有列表的顺序不会发生变化同时没有其他唯一的 key 来标识列表项时才能使用数组的下标。

虚拟 dom 是如何提升性能的

当组件触发更新时,虚拟 DOM 通过 Diffing 算法比对新旧节点的变化以决定是否渲染 DOM 节点,从而减少渲染提升性能。因为修改真实 DOM 所耗费的性能远比操作 JavaScript 多几倍,因此使用虚拟 DOM 在渲染性能上会高效的多。

简述 React Diffing 算法

Diffing 算法(Diffing Algorithm) 会先比较两个根元素的变化:

  1. 节点类型变化时,将会卸载原有的树而建立新树。如父节点 <div> 标签被修改为 <section> 标签,则它们自身及 children 下的节点都会被重新渲染。
  2. DOM 节点类型相同时,保留相同的 DOM 节点,仅更新发生改变的属性。
  3. 组件类型相同时,组件更新时组件实例保持不变,React 将更新组件实例的 props, 并调用生命周期 componentWillReceiveProps()componentwillupdate(),最后再调用 render。若 render 中还有子组件,将递归触发 Diff。
  4. 列表节点发生变化,列表项没有设置 key 时, 那么 Diffing 算法会逐个对比节点的变化。如果是尾部新增节点,那 Diff 算法会 Diff 到列表末尾,仅新增元素即可,不会有其他的性能损耗。若新增的数据不在数组的尾部而是在中间,那么 Diffing 算法比较到中间时判断出节点发生变化,将会丢弃后面所有节点并重新渲染。
  5. 列表节点发生变化,列表项有设置 key 时, React 可以通过 key 来匹配新旧节点间的对应关系,可以很快完成 Diff 并避免重复渲染的问题。

异步组件怎么使用?

  1. 通过动态 import() 语法对组件代码进行分割。

  2. 使用 React.lazy 函数,结合 import() 语法引入动态组件。在组件首次渲染时,会自动导入包含 MyComponent 的包。

    1
    const MyComponent = React.lazy(() => import('./MyComponent'));
  3. React.Suspense 组件中渲染 lazy 组件,同时可以使用 fallback 做优雅降级(添加 loading 效果):

    1
    2
    3
    <React.Suspense fallback={<div>Loading...</div>}>
    <MyComponent />
    </React.Suspense>
  4. 封装一个错误捕获组件(比如组件命名为 MyErrorBoundary),组件内通过生命周期 getDerivedStateFromError 捕获错误信息。当异步组件加载失败时,将捕获到错误信息处理后给用户做错误提示功能。

    1
    2
    3
    4
    5
    <MyErrorBoundary>
    <React.Suspense fallback={<div>Loading...</div>}>
    <MyComponent />
    </React.Suspense>
    </MyErrorBoundary>

JSX 是如何编译为 js 代码的?

在 React v17 之前,JSX 会被编译为 React.createElement(component, props, ...children) 函数,执行会返回 vnodevnode 通过 patch 之类的方法渲染到页面。

React v17 之后更新了 JSX 转换规则。新的 JSX 转换不会将 JSX 转换为 React.createElement,而是自动从 React 的 package 中引入新的入口函数(react/jsx-runtime)并调用。这意味着我们不用在每个组件文件中显式引入 React

怎么对组件的参数做类型约束呢?

要对组件的参数做类型约束的话,可以引入 prop-types 来配置对应的 propTypes 属性。
FlowTypesScript 则可以对整个应用做类型检查。

React 知识回顾 (使用篇)

使用 React 进行项目开发也有好几个项目了,趁着最近有空来对 React 的知识做一个简单的复盘。

完整目录概览

React 是单向数据流还是双向数据流?它还有其他特点吗?

React 是单向数据流,数据是从上向下流。它的其他主要特点时:

  • 数据驱动视图
  • 声明式编写 UI
  • 组件化开发

setState

React 通过什么方式来更新数据

React 是通过 setState 来更新数据的。调用多个 setState 不会立即更新数据,而会批量延迟更新后再将数据合并。

除了 setState 外还可以使用 forceUpdate 跳过当前组件的 shouldComponentUpdate diff,强制触发组件渲染(避免使用该方式)。

React 不能直接修改 State 吗?

  1. 直接修改 state 不会触发组件的渲染。
  2. 若直接修改 state 引用的值,在实际使用时会导致错误的值出现
  3. 修改后的 state 可能会被后续调用的 setState 覆盖

setState 是同步还是异步的?

出于性能的考虑,React 可能会把多个 setState 合并成一个调用。

React 内有个 batchUpdate(批量更新) 的机制,在 React 可以控制的区域 (如组件生命周期、React 封装的事件处理器) 设置标识位 isBatchingUpdate 来决定是否触发更新。

比如在 React 中注册的 onClick 事件或是 componentDidMount 中直接使用 setState 都是异步的。若想拿到触发更新后的值,可以给 setState 第二个参数传递一个函数,该函数在数据更新后会触发的回调函数,函数的参数就是更新后最新的值。

不受 React 控制的代码快中使用 setState 是同步的,比如在 setTimeout 或是原生的事件监听器中使用。

setState 小测

输出以下结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
componentDidMount() {
this.setState({ count: this.state.count + 1 });
console.log("1 -->", this.state.count);

this.setState({ count: this.state.count + 1 });
console.log("2 -->", this.state.count);

setTimeout(() => {
this.setState({ count: this.state.count + 1 });
console.log("3 -->", this.state.count);
}, 0);

setTimeout(() => {
this.setState({ count: this.state.count + 1 });
console.log("4 -->", this.state.count);
}, 0);
}

输出结果为:

1
2
3
4
1 --> 0
2 --> 0
3 --> 2
4 --> 3

解答: 调用 setState 后不会立即更新 state,开头两次调用会被异步合并调用,因此只有一次调用。一轮事件循环结束后,调用第 3、4 次 setState。由于在 setTimeout 中调用是同步更新的,因此都能正常的叠加数据。

React 生命周期

React 的生命周期主要是指组件在特定阶段会执行的函数。以下是 class 组件的部分生命周期图谱:

从上图可以看出:React 的生命周期按照类型划分,可分为 挂载时(Mounting)、更新时(Updating)、卸载时(Unmounting) 。图中的生命周期函数效果如下:

constructor (构造函数)

  • 触发条件: 组件初始化时
  • 是否可以使用 setState: X
  • 使用场景: 初始化 state 或者对方法绑定 this。在构造函数中便于自动化测试。

static getDerivedStateFromProps

Tips: 不常用方法

  • 触发条件: 调用 render 函数之前
  • 是否可以使用 setState: X
  • 函数行为: 函数可以返回一个对象用于更新组件内部的 state 数据,若返回 null 则什么都不更新。
  • 使用场景: 用于 state 依赖 props 的情况,也就是状态派生。值得注意的是派生 state 会导致代码冗余,并使组件难以维护。

shouldComponentUpdate

Tips: 不常用方法

  • 触发条件: 当 props/state 发生变化
  • 是否可以使用 setState: X
  • 函数行为: 函数的返回值决定组件是否触发 render,返回值为 true 则触发渲染,反之则阻止渲染。(组件内不写该函数的话,则调用默认函数。默认函数只会返回 true,即只要 props/state 发生变化,就更新组件)
  • 使用场景: 组件的性能优化,仅仅是浅比较 props 和 state 的变化的话,可以使用内置的 PureComponent 来代替 Component 组件。

render

  • 触发条件: 渲染组件时
  • 是否可以使用 setState: X
  • 函数行为: 函数的返回值决定视图的渲染效果
  • 使用场景: class 组件中唯一必须要实现的生命周期函数。

getSnapshotBeforeUpdate

Tips: 不常用方法

  • 触发条件: 在最近一次渲染输出(提交到 DOM 节点)之前调用
  • 是否可以使用 setState: X
  • 函数行为: 函数的返回值将传入给 componentDidUpdate 第三个参数中。若只实现了该函数,但没有使用 componentDidUpdate 的话,React 将会在控制台抛出警告
  • 使用场景: 可以在组件发生更改之前从 DOM 中捕获一些信息(例如,列表的滚动位置)

componentDidMount

  • 触发条件: 组件挂载后(插入 DOM 树中)立即调用,该函数只会被触发一次
  • 是否可以使用 setState: Y (可以直接调用,但会触发额外渲染)
  • 使用场景: 从网络请求中获取数据、订阅事件等

componentDidUpdate

  • 触发条件: 组件更新完毕后(首次渲染不会触发)
  • 是否可以使用 setState: Y (更新语句须放在条件语句中,不然可能会造成死循环)
  • 使用场景: 对比新旧值的变化,进而判断是否需要发送网络请求。比如监听路由的变化

componentWillUnmount

  • 触发条件: 组件卸载及销毁之前直接调用
  • 是否可以使用 setState: X
  • 使用场景: 清除 timer,取消网络请求或清除在 componentDidMount 中创建的订阅等

生命周期阶段

针对 React 生命周期中函数的调用顺序,笔者写了一个简易的 Demo 用于演示: React 生命周期示例

React 组件挂载阶段先后会触发 constuctorstatic getDerivedStateFromPropsrendercomponentDidMount 函数。若 render 函数内还有子组件存在的话,则会进一步递归:

1
2
3
4
5
6
7
8
9
10
[Parent]: constuctor
[Parent]: static getDerivedStateFromProps
[Parent]: render
[Children]: constuctor
[Children]: static getDerivedStateFromProps
[Children]: render
[Children]: componentDidMount
[Children]: 挂载阶段结束!
[Parent]: componentDidMount
[Parent]: 挂载阶段结束!

React 组件更新阶段主要是组件的 props 或 state 发生变化时触发。若组件内还有子组件,则子组件会判断是否也需要触发更新。默认情况下 component 组件是只要父组件发生了变化,子组件也会跟着变化。以下是更新父组件 state 数据时所触发的生命周期函数:

1
2
3
4
5
6
7
8
9
10
11
12
[Parent]: static getDerivedStateFromProps
[Parent]: shouldComponentUpdate
[Parent]: render
[Children]: static getDerivedStateFromProps
[Children]: shouldComponentUpdate
[Children]: render
[Children]: getSnapshotBeforeUpdate
[Parent]: getSnapshotBeforeUpdate
[Children]: componentDidUpdate
[Children]: 更新阶段结束!
[Parent]: componentDidUpdate
[Parent]: 更新阶段结束!

值得注意的是: 在本例 Demo 中没有给子组件传参,但子组件也触发了渲染。但从应用的角度上考虑,既然你子组件没有需要更新的东西,那就没有必要触发渲染吧?

因此 Component 组件上可以使用 shouldComponentUpdate 或者将 Component 组件替换为 PureComponment 组件来做优化。在生命周期图中也可以看到: shouldComponentUpdate 返回 false 时,将不再继续触发下面的函数。

有时你可能在某些情况下想主动触发渲染而又不被 shouldComponentUpdate 阻止渲染该怎么办呢?可以使用 force­Update() 跳过 shouldComponentUpdate 的 diff,进而渲染视图。(需要使用强制渲染的场景较少,一般不推荐这种方式进行开发)

React 组件销毁阶段也没啥好说的了。父组件先触发销毁前的函数,再逐层向下触发:

1
2
3
4
[Parent]: componentWillUnmount
[Parent]: 卸载阶段结束!
[Children]: componentWillUnmount
[Children]: 卸载阶段结束!

其他生命周期

除了上图比较常见的生命周期外,还有一些过时的 API 就没有额外介绍了。因为它们可能在未来的版本会被移除:

上图没有给出错误处理的情况,以下信息作为补充: 当渲染过程,生命周期,或子组件的构造函数中抛出错误时,会调用如下方法:

React 组件通信

  1. 父组件通过 props 给子组件传递数据。子组件通过触发父组件提供的回调函数来给父组件传递消息或数据
  2. React.Context 可以跨层级组件共享数据
  3. 自定义事件
  4. 引入 Redux/Mobx 之类的状态管理器

React.Context 怎么使用

Context 可以共享对于组件树而言是全局的数据,比如全局主题、首选语言等。使用方式如下:

  1. React.createContext 函数用于生成 Context 对象。可以在创建时给 Context 设置默认值:

    1
    const ThemeContext = React.createContext('light');
  2. Context 对象中有一个 Provider(提供者) 组件,Provider 组件接受一个 value 属性用以将数据传递给消费组件。

    1
    2
    3
    <ThemeContext.Provider value="dark">
    <page />
    </ThemeContext.Provider>
  3. 获取 Context 提供的值可以通过 contextType 或者 Consumer(消费者) 组件中获取。contextType 只能用于类组件,并且只能挂载一个 Context

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class MyClass extends React.Component {
    componentDidMount() {
    let value = this.context;
    /* 在组件挂载完成后,使用 MyContext 的值执行一些有副作用的操作 */
    }
    render() {
    let value = this.context;
    /* 基于 MyContext 的值进行渲染 */
    }
    }
    MyClass.contextType = MyContext;

    若想给组件挂载多个 Context, 或者在函数组件内使用 Context 可以使用 Consumer 组件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <ThemeContext.Consumer>
    {theme => (
    <UserContext.Consumer>
    {user => (
    <ProfilePage user={user} theme={theme} />
    )}
    </UserContext.Consumer>
    )}
    </ThemeContext.Consumer>

Context 通常适用于传递较为简单的数据信息,若数据太过复杂,还是需要引入状态管理(Redux/Mbox)。

函数组件是什么?与类组件有什么区别?

函数组件本质上是一个纯函数,它接受 props 属性,最后返回 JSX。

与类组件的差别在于: 它没有实例、不能通过 extends 继承于其他方法、也没有生命周期和 state。以前函数组件常作为无状态组件,React 16.8+ 可以引入 Hooks 为函数组件支持状态和副作用操作。

Hooks

Hook vs class

类组件的不足:

  • 状态逻辑复用难,缺少复用机制。渲染属性和高阶组件导致层级冗余。
  • 组件趋向复杂难以维护。生命周期函数混杂不相干逻辑,相干逻辑分散在不同生命周期中。
  • this 指向令人困扰。内联函数过度创建新句柄,类成员函数不能保证 this。

Hooks 的优点:

  • 自定义 Hook 方便复用状态逻辑
  • 副作用的关注点分离
  • 函数组件没有 this 问题

Hooks 现有的不足:

  • 不能完全取代 class 组件的生命周期,部分不常用的生命周期暂时没有实现。
  • Hooks 的运作方式带来了一定的学习成本,需要转换现有的编程思维,增加了心智负担。

Hooks 的使用

描述 Hooks 有哪些常用的方法和大致用途

  1. useState: 使函数组件支持设置 state 数据,可用于代替类组件的 constructor 函数。

  2. useEffect: 使函数组件支持操作副作用 (effect) 的能力,Hook 第二个参数是 effect 的依赖项。当依赖项是空时,effect 函数仅会在组件挂载后执行一遍。若有一个或多个依赖项时,只要任意一个依赖项发生变化,就会触发 effect 函数的执行。effect 函数里可以做一些如获取页面数据、订阅事件等操作。

    除此之外,useEffect 还可以返回一个函数用于做清除操作,这个清除操作时可选的。常用于清理订阅事件、DOM 事件等。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 绑定 DOM 事件
    useEffect(() => {
    document.addEventListener('click', handleClick);

    // useEffect 回调函数的返回值是函数的话,当组件卸载时会执行该函数
    // 若没有需要清除的东西,则可以忽略这一步骤
    return () => {
    document.removeEventListener('click', handleClick);
    };
    }, [handleClick]);
  3. useLayoutEffect: useEffect 的 effect 执行的时机是在浏览器完成布局和绘制之后会延迟调用。若想要 DOM 变更的同时同步执行 effect 的话可以使用 useLayoutEffect。它们之间只是执行的时机不同,其他都一样。

  4. useContext: 接收一个 Context 对象,并返回 Context 的当前值。相当于类组件的 static contextType = MyContext

  5. useReduceruseState 的代替方案,它的工作方式有点类似于 Redux,通过函数来操作 state。适合 state 逻辑较为复杂且包含多个子值,或是新的 state 依赖于旧的 state 的场景。

  6. useMemo 主要用于性能优化,它可以缓存变量的值,避免每次组件更新后都需要重复计算值。

  7. useCallbck 用于缓存函数,避免函数被重复创建,它是 useMemo 的语法糖。useCallback(fn, deps) 的效果相当于是 useMemo(() => fn, deps)

Hook 之间的一些差异

  1. React.memo 与 React.useMemo

    memo 针对一个组件的渲染是否重复执行,useMemo 定义一段函数逻辑是否重复执行。

  2. React.useMemo 与 React.useCallback

    useMemo(() => fn) 返回的是一个函数,将等同于 useCallback(fn)

  3. React.useStatus 与 React.useRef

    React.useStatus 相当于类的 stateReact.useRef 相当于类的内部属性。前者参与渲染,后者的修改不会触发渲染。

自定义 Hook 的使用

自定义 Hook 的命名规则是以 use 开头的函数,比如 useLocalStorage 就符合自定义 Hook 的命名规范。
使用自定义 Hook 的场景有很多,如表单处理、动画、订阅声明、定时器等等可复用的逻辑都能通过自定义 Hook 来抽象实现。

在自定义 Hook 中,可以使用 Hooks 函数将可复用的逻辑和功能提取出来,并将内部的 state 或操作的方法从自定义 Hook 函数中返回出来。函数组件使用时就可以像调用普通函数一祥调用自定义 Hook 函数, 并将自定义 Hook 返回的 state 和操作方法通过解构保存到变量中。

下面是 useLocalStorage 的实现,它将 state 同步到本地存储,以使其在页面刷新后保持不变。 用法与 useState 相似,不同之处在于我们传入了本地存储键,以便我们可以在页面加载时默认为该值,而不是指定的初始值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import { useState } from 'react';

// Usage
function App() {
// Similar to useState but first arg is key to the value in local storage.
const [name, setName] = useLocalStorage('name', 'Bob');

return (
<div>
<input
type="text"
placeholder="Enter your name"
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
);
}

// Hook
function useLocalStorage(key, initialValue) {
// State to store our value
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState(() => {
try {
// Get from local storage by key
const item = window.localStorage.getItem(key);
// Parse stored json or if none return initialValue
return item ? JSON.parse(item) : initialValue;
} catch (error) {
// If error also return initialValue
console.log(error);
return initialValue;
}
});

// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue = value => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore =
value instanceof Function ? value(storedValue) : value;
// Save state
setStoredValue(valueToStore);
// Save to local storage
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
// A more advanced implementation would handle the error case
console.log(error);
}
};

return [storedValue, setValue];
}

注意: 自定义 Hook 函数在定义时,也可以使用另一个自定义 Hook 函数。

Hook 使用约束

  1. 只能在函数组件最顶层调用 Hook,不能在循环、条件判断或子函数中调用。
  2. 只能在函数组件或者是自定义 Hook 函数中调用,普通的 js 函数不能使用。

class 组件与 Hook 之间的映射与转换

函数组件相比 class 组件会缺少很多功能,但大多可以通过 Hook 的方式来实现。

生命周期

  • constructor:class 组件的构造函数一般是用于初始化 state 数据或是给事件绑定 this 指向的。函数组件内没有 this 指向的问题,因此可以忽略。而 state 可以通过 useState/useReducer 来实现。

  • getDerivedStateFromPropsgetDerivedStateFromProps 一般用于在组件 props 发生变化时派生 state。Hooks 实现同等效果如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    function ScrollView({row}) {
    const [isScrollingDown, setIsScrollingDown] = useState(false);
    const [prevRow, setPrevRow] = useState(null);

    if (row !== prevRow) {
    // Row 自上次渲染以来发生过改变。更新 isScrollingDown。
    setIsScrollingDown(prevRow !== null && row > prevRow);
    setPrevRow(row);
    }

    return `Scrolling down: ${isScrollingDown}`;
    }
  • shouldComponentUpdate: 使用 React.memo 应用到函数组件中后,当 props 发生变化时,会对 props 的新旧值进行前对比,相当于是 PureComponent 的功能。如果你还想自己定义比较函数的话,可以给 React.memo 的第二个参数传一个函数,若函数返回 true 则跳过更新。

    1
    2
    3
    const Button = React.memo((props) => {
    return <button>{props.text}</button>
    });
  • render: 函数组件本身就是一个 render 函数。

  • componentDidMount / componentDidUpdate / componentWillUnmount:

    useEffect 第二个参数的依赖项为空时,相当于 componentDidMount,组件挂载后只会执行一次。每个 useEffect 返回的函数相当于是 componentWillUnmount 同等效果的操作。若有依赖,则 effect 函数相当于是 componentDidUpdate

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 没有依赖项,仅执行一次
    useEffect(() => {
    const subscription = props.source.subscribe();

    // 相当于 componentWillUnmount
    return () => {
    subscription.unsubscribe();
    };
    }, []);

    // 若有依赖项,相当于 componentDidUpdate
    // 当 page 发生变化时会触发 effect 函数
    useEffect(() => {
    fetchList({ page });
    }, [page]);

Hooks 没有实现的生命周期钩子

  • getSnapshotBeforeUpdate
  • getDerivedStateFromError
  • componentDidCatch

转换实例变量

使用 useRef 设置可变数据。

强制更新 Hook 组件

设置一个没有实际作用state,然后强制更新 state 的值触发渲染。

1
2
3
4
5
6
7
8
9
10
const Todo = () => {
// 使用 useState,用随机数据更新也行
const [ignored, forceUpdate] = useReducer(x => x + 1, 0);

function handleClick() {
forceUpdate();
}

return <button click={handleClick}>强制更新组件</button>
}

获取旧的 props 和 state

可以通过 useRef 来保存数据,因为渲染时不会覆盖掉可变数据。

1
2
3
4
5
6
7
8
9
10
11
12
function Counter() {
const [count, setCount] = useState(0);

const prevCountRef = useRef();
useEffect(() => {
prevCountRef.current = count;
}, []);

const prevCount = prevCountRef.current;

return <h1>Now: {count}, before: {prevCount}</h1>;
}

受控组件与非受控组件的区别

受控组件主要是指表单的值受到 state 的控制,它需要自行监听 onChange 事件来更新 state

由于受控组件每次都要编写事件处理器才能更新 state 数据、可能会有点麻烦,React 提供另一种代替方案是非受控组件

非受控组件将真实数据储存在 DOM 节点中,它可以为表单项设置默认值,不需要手动更新数据。当需要用到表单数据时再通过 ref 从 DOM 节点中取出数据即可。

注意: 多数情况下React 推荐编写受控组件。

扩展资料: 受控和非受控制使用场景的选择

Portals 是什么?

Portals 就像个传送门,它可以将子节点渲染到存在于父组件以外的 DOM 节点的方案。

比如 Dialog 是一个全局组件,按照传统渲染组件的方式,Dialog 可能会受到其容器 css 的影响。因此可以使用 Portals 让组件在视觉上渲染到 <body> 中,使其样式不受 overflow: hiddenz-index 的影响。

React vs Vue

在项目架构时选择合适的前端框架是至关重要的。React 和 Vue 都是流行的选择,但它们在灵活性、易用性和性能方面各有特点。本文旨在深入比较这两个框架,让我们在开发前选择技术架构有个参考。

React

React 在处理复杂业务时显示出较高的灵活性。它提供多样的技术方案选择,适用于需要高度自定义的场景。React 的特点包括:

  • 组件名称需要以大写字母开头。
  • 使用 JSX 语法,组件内需要包裹一个元素,可以使用Fragment作为占位符。
  • 响应式设计,主要关注数据。
  • 事件绑定采用驼峰命名方式。
  • 不允许直接修改 state,以保持性能。
  • 构造函数中接受参数。
  • 单向数据流,专注于视图层和数据渲染。
  • 有助于自动化测试。
  • state 或 props 改变时,render 函数会重新执行。
  • 使用虚拟 DOM 来减少真实 DOM 操作,提升性能。
  • 跨端应用实现,例如 React Native。

但它的缺点也很明显:

  • 学习曲线较陡: JSX 和组件生命周期等概念对新手而言可能较难掌握。
  • 只关注视图层: 需要与其他库结合使用以构建完整的解决方案。但 react 的生态非常丰富,甚至会有多种不同的变成风格,社区中没有一个统一认可的解决方案,这会让不熟悉 react 生态的新用户看的眼花缭乱。

Vue

Vue 提供了丰富的 API,使功能实现变得简单。它适合于快速开发和较少复杂度的项目。Vue 的特点包括:

  • 易学性,提供了详尽的文档和指导。尤其作者是国人,也提供了友好的中文文档支持。
  • 更简洁的模板语法糖,如 v-bind 和 v-model。
  • 详细的错误提示和开发工具,使调试更加方便。
  • 数据双向绑定,简化了表单输入和数据展示。
  • 更轻量级,适合小型到中型项目。
  • 提供了过渡效果和动画的集成支持。
  • 可以更方便地集成到现有的项目中。
  • 提供了类似于 React 的虚拟 DOM 和组件系统。
  • 相比 react 生态的复杂, vue 官方提供了整套最基础的 web 开发架构所需的生态。当官方的提供的库无法满足需求后可以允许你去用其他第三方库,相当于起步阶段减少了选择的烦恼。对新手会比较友好。

然而,Vue 也有它的局限性:

  • 规模限制: 对于非常大型和复杂的应用,Vue 可能不如 React 灵活。
  • 过度依赖单文件组件: 可能导致项目结构和组织方式较为单一

总结

React 和 Vue 各有所长,选择哪一个取决于特定项目的需求、开发团队的技能和偏好。React 更适合需要高度灵活和可扩展性的大型应用,而 Vue 在快速开发和简单性方面表现更佳,适合新手和中小型项目。理解每个框架的优缺点有助于做出更合适的选择。

从零构建 webpack 脚手架(基础篇)

webpack 是一个现代 JavaScript 应用程序的静态模块打包工具,它对于前端工程师来说可谓是如雷贯耳,基本上现在的大型应用都是通过 webpack 进行构建的。

webpack 具有高度可配置性,它拥有非常丰富的配置。在过去一段时间内曾有人将熟练配置 webpack 的人称呼为 “webapck 工程师”。当然,这称呼只是个玩笑话,但也能从侧面了解到 webpack 配置的灵活与复杂。

为了能够熟练掌握 webpack 的使用,接下来通过几个例子循序渐进的学习如何使用 webpack。

以下 Demo 都可以在 Github 的 webpack-example 中找到对应的示例,欢迎 star~

起步

webpack@v4.0.0 开始,就可以不用再引入配置文件来打包项目。若没有提供配置的话,webpack 将按照默认规则进行打包。默认情况下 src/index 是项目的源代码入口,打包后的代码会输出到 dist/main.js 上。

首先来初始化一个项目,项目名为 getting-started

1
2
3
4
5
6
7
8
# 创建项目文件夹
mkdir getting-started

# 进入项目目录
cd getting-started

# npm 项目
npm init -y

初始化项目后,项目目录会新增一个 package.json,该文件记录了项目依赖的相关信息。若想要使用 webpack 的话需要安装它的依赖: webpack (本体)和 webpack-cli (可以在命令行操作 webpack 的工具):

1
2
3
4
5
6
# -D 和 --save-dev 选项都可以用于安装开发依赖
# npm i --save-dev webpack webpack-cli
npm i -D webpack webpack-cli

# 或者使用 yarn 安装开发依赖
yarn add -D webpack webpack-cli

接着创建 webpack 所需的默认入口文件 src/index.js 以及测试模块所用的 src/log.js 文件。此时的项目结构大致如下:

1
2
3
4
5
6
  .
├── package.json
+ ├── src
+ │ ├── index.js
+ │ └── log.js
└── node_modules
1
2
3
4
5
6
7
// src/log.js
export const log = (name) => console.log(`Hello ${name}!`);

// src/index.js
import { log } from './log'

log('anran758');

src/log.js 导出了一个工具函数,它负责向控制台发送消息。src/index.js 是默认的入口文件,它引入 log 函数并调用了它。

上面的代码很简单,像这种模块化的代码按照传统 <script src> 引入的话,浏览器是不能正确执行的。可以在根目录上创建一个 index.html 引入 js 脚本来测试一下:

/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test</title>
</head>
<body>
<!-- 引入脚本 -->
<script src="./src/index.js"></script>
</body>
</html>

创建文件后,将上例代码复制到 index.html 中。保存并打开该文件,看看浏览器能否正确处理模块逻辑。不出意外的话,文件在浏览器打开后,浏览器开发者工具会抛出错误信息:

1
Uncaught SyntaxError: Cannot use import statement outside a module

言下之意就是说浏览器不能正确的解析 ES module 语句,此时 webpack 就可以派上用场啦~ 在 package.json 中的 scripts 字段中添加如下命令:

/package.json
1
2
3
4
  "scripts": {
+ "build": "webpack"
- "test": "echo \"Error: no test specified\" && exit 1"
},

在命令行输入 npm run build 调用 webpack 对当前项目进行编译,编译后的结果会输出到 dist/main.js 文件中(即便本地没有 dist 目录,它都会自动创建该目录)。输出文件后,修改 index.html 对 js 的引用:

/index.html
1
2
3
4
  <body>
+ <script src="./dist/main.js"></script>
- <script src="./src/index.js"></script>
</body>

重新刷新页面后就能看到 log 正确的输出了 Hello anran758!。点击 log 右侧的链接,可以跳转至 Source 面板,将代码格式化后可以清晰地看到编译后 js 的变化:

使用配置

当然,上例代码只不过是小试牛刀。对于正式的项目会有更复杂的需求,因此需要自定义配置。webpack 主要有两种方式接收配置:

第一种: 通过 Node.js API引入 webpack 包,在调用 webpack 函数时传入配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
const webpack = require("webpack");

const webpackConfig = {
// webpack 配置对象
}

webpack(webpackConfig, (err, stats) => {
if (err || stats.hasErrors()) {
// 在这里处理错误
}

// 处理完成
});

第二种: 通过 webpack-cli 在终端使使用 webpack 时指定配置。

1
webpack [--config webpack.config.js]

两种方法内配置都是相似的,只是调用的形式不同。本篇先使用 webpack-cli 来做示例。

webpack 接受一个特定的配置文件,配置文件要求导出一个对象、函数、Promise 或多个配置对象组成的数组。

现在将上一章的 Demo 复制一份出来,并重命名为 **getting-started-config**,在该目录下新建 webpack.config.js 文件,文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const path = require('path');

module.exports = {
// 起点或是应用程序的起点入口
entry: "./src/index",
output: {
// 编译后的输出路径
// 注意此处必须是绝对路径,不然 webpack 将会抛错(使用 Node.js 的 path 模块)
path: path.resolve(__dirname, "dist"),

// 输出 bundle 的名称
filename: "bundle.js",
}
}

上面的配置主要是定义了程序入口、编译后的文件输出目录。然后在 src/index.js 中修改一些内容用来打包后测试文件是否被正确被编译:

src/index.js
1
2
3
4
  import { log } from './log'

+ log('本节在测试配置噢');
- log('anran758');

随后在终端输入 num run build 进行编译,可以看到 dist 目录下多了个 bundle.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ npm run build
> webpack --config ./webpack.config.js

Hash: 3cd5f3bbfaf23f01de37
Version: webpack 4.43.0
Time: 117ms
Built at: 05/06/2020 1:01:37 PM
Asset Size Chunks Chunk Names
bundle.js 1010 bytes 0 [emitted] main
Entrypoint main = bundle.js
[0] ./src/index.js + 1 modules 123 bytes {0} [built]
| ./src/index.js 62 bytes [built]
| ./src/log.js 61 bytes [built]

WARNING in configuration
The 'mode' option has not been set, webpack will fallback to 'production' for this value. Set 'mode' option to 'development' or 'production' to enable defaults for each environment.
You can also set it to 'none' to disable any default behavior. Learn more: https://webpack.js.org/configuration/mode/

由于我们输出的文件名被修改了,此时还得修改 html 的引入路径。但每改一次输出目录,HTML 中的引入路径也得跟着改,这样替换的话就比较容易出纰漏。那能不能让 webpack 自动帮我们插入资源呢?答案是可以的。

Plugin

webpack 提供**插件(plugin)**的功能,它可以用于各种方式自定义 webpack 构建过程。

html-webpack-plugin 可以在运行 webpack 时自动生成一个 HTML 文件,并将打包后的 js 代码自动插入到文档中。下面来安装它:

1
npm i --D html-webpack-plugin

安装后在 webpack.config.js 中使用该插件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  const path = require('path');
+ const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
// 起点或是应用程序的起点入口
entry: "./src/index",

// 输出配置
output: {
// 编译后的输出路径
// 注意此处必须是绝对路径,不然 webpack 将会抛错(使用 Node.js 的 path 模块)
path: path.resolve(__dirname, "dist"),

// 输出 bundle 的名称
filename: "bundle.js",
},
+ plugins: [
+ new HtmlWebpackPlugin({
+ title: 'Test Configuration'
+ })
+ ],
}

重新编译后 HTML 也被输出到 dist 目录下。查看 dist/index.html 的源码可以发现:不仅源码被压缩了,同时 <script> 标签也正确的引入了 bundle.js

此时目录结构如下:

后续目录展示会将 node_modulespackage-lock.jsonyarn.lock 这种对项目架构讲解影响不大的目录省略掉..

1
2
3
4
5
6
7
8
9
10
11
.
├── dist
│ ├── bundle.js
│ ├── index.html
│ └── main.js
├── index.html
├── package.json
├── src
│ ├── index.js
│ └── log.js
└── webpack.config.js

处理完资源自动插入的问题后,还有一个问题需要我们处理:虽然 webpack 现在能自动生成 HTML 并插入脚本,但我们还得在 HTML 中写其他代码逻辑呀,总不能去改 /dist/index.html 文件吧?

这个问题也很好解决。html-webpack-plugin 在初始化实例时,传入的配置中可以加上 template 属性来指定模板。配置后直接在指定模板上进行编码就可以解决这个问题了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
  const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
// 起点或是应用程序的起点入口
entry: "./src/index",

// 输出配置
output: {
// 编译后的输出路径
// 注意此处必须是绝对路径,不然 webpack 将会抛错(使用 Node.js 的 path 模块)
path: path.resolve(__dirname, "dist"),

// 输出 bundle 的名称
filename: "bundle.js",
},
plugins: [
// html-webpack-plugin
// https://github.com/jantimon/html-webpack-plugin#configuration
new HtmlWebpackPlugin({
title: 'Test Configuration',
+ template: path.resolve(__dirname, "./index.html"),
})
],
}

使用模板后 html-webpack-plugin 也会自动将脚本插入到模板中。因此可以将模板中的 <script> 给去掉了。为了测试输出的文件是否使用了模板,在 <body> 内随便插入一句话,重新打包后预览输出的文件是否包含这句话:

/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Test Config</title>
- <title>Test</title>
</head>
<body>
+ <p>Test Config</p>
- <script src="./dist/main.js"></script>
</body>
</html>

修改文件后,重新打包就能看到模板也被压缩输出至 /dist/index.html 了,script 标签也正常的插入了。

清理目录

现在来看编译后的目录,我们发现 dist/mian.js 这文件是使用配置之前编译出来的文件,现在我们的项目已经不再需要它了。这种历史遗留的旧文件就应该在每次编译之前就被扔进垃圾桶,只输出最新的结果。

clean-webpack-pluginrimraf 可以完成清理功能。前者是比较流行的 webpack 清除插件,后者是通用的 unix 删除命令(安装该依赖包后 windows 平台也能用)。如果仅是清理 /dist 目录下文件的话,个人是比较倾向使用 rimraf的,因为它更小更灵活。而 clean-webpack-plugin 是针对 webpack 输出做的一系列操作。

在终端安装依赖:

1
npm i -D rimraf

rimraf 的命令行的语法是: rimraf <path> [<path> ...],我们在 package.jsonscirpts 中修改 build 的命令:

/package.json
1
2
3
4
"scripts": {
+ "build": "rimraf ./dist && webpack --config ./webpack.config.js"
- "build": "webpack --config ./webpack.config.js"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ npm run build

> rimraf ./dist && webpack --config ./webpack.config.js

Hash: 763fe4b004e1c33c6876
Version: webpack 4.43.0
Time: 342ms
Built at: 05/06/2020 2:35:49 PM
Asset Size Chunks Chunk Names
bundle.js 1010 bytes 0 [emitted] main
index.html 209 bytes [emitted]
Entrypoint main = bundle.js
[0] ./src/index.js + 1 modules 123 bytes {0} [built]
| ./src/index.js 62 bytes [built]
| ./src/log.js 61 bytes [built]

WARNING in configuration
The 'mode' option has not been set, webpack will fallback to 'production' for this value. Set 'mode' option to 'development' or 'production' to enable defaults for each environment.
You can also set it to 'none' to disable any default behavior. Learn more: https://webpack.js.org/configuration/mode/
Child HtmlWebpackCompiler:
1 asset
Entrypoint HtmlWebpackPlugin_0 = __child-HtmlWebpackPlugin_0
1 module

这样 webpack 输出的 /dist 目录始终是最新的东西。

loader

在正常的页面中,引入 css 样式表会让页面变得更美观。引入图片可以让页面内容更丰富。

然而 webpack 本体只能处理原生的 JavaScript 模块,你让它处理 css 或图片资源,它是无法直接处理的。为了处理这种问题,webpack 提供了 loader 的机制,用于对模块外的源码进行转换。

loader 一般是单独的包,我们可以在社区找到对应 loader 来处理特定的资源。在使用前通过 npm 安装到项目的开发依赖中即可。loader 可以通过配置内联Cli 这三种方式来使用。下文主要以 配置 的方式来使用。

css

往常引入 css 样式表无非就是在 html 中通过 <link> 标签引入。现在想通过 webpack 来管理依赖得需要安装对应的 loader 来处理这些事。

css-loader 可以让 webpack 可以引入 css 资源。光有让 webpack 识别 css 的能还不够。为了能将 css 资源进行导出,还要安装 mini-css-extract-plugin 插件:

现在将上一节的 Demo 复制并重名为 getting-started-loader-css。进入新的项目目录后安装依赖:

1
npm install -D css-loader mini-css-extract-plugin

在更改配置之前,为了使项目结构更清晰,咱们按照文件类型重新调整源码目录结构。将 src 下的 js 文件都放进 js 文件夹中。同时创建 /src/css/style.css 样式表。调整后的目录结构如下:

1
2
3
4
5
6
7
8
9
10
.
├── package.json
├── src
│ ├── index.html
│ ├── css
│ │ └── style.css
│ └── js
│ ├── index.js
│ └── log.js
└── webpack.config.js

现在将 Flexbox 布局用例 中结尾的 Demo 迁移到项目中,测试一下效果:

HTML 源码
/src/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test</title>
</head>
<body>
<div class="panels">
<div class="panel panel1">
<p class="item name">Alice</p>
<p class="item index">I</p>
<p class="item desc">Pixiv Content ID: 65843704</p>
</div>
<div class="panel panel2">
<p class="item name">Birthday</p>
<p class="item index">II</p>
<p class="item desc">Pixiv Content ID: 70487844</p>
</div>
<div class="panel panel3">
<p class="item name">Dream</p>
<p class="item index">III</p>
<p class="item desc">Pixiv Content ID: 65040104</p>
</div>
<div class="panel panel4">
<p class="item name">Daliy</p>
<p class="item index">IV</p>
<p class="item desc">Pixiv Content ID: 64702860</p>
</div>
<div class="panel panel5">
<p class="item name">Schoolyard</p>
<p class="item index">V</p>
<p class="item desc">Pixiv Content ID: 67270728</p>
</div>
</div>
</body>
</html>
CSS 源码
/src/css/style.css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
html {
font-family: 'helvetica neue';
font-size: 20px;
font-weight: 200;
background: #f7f7f7;
}

body,
p {
margin: 0;
}

.panels {
display: flex;
min-height: 100vh;
overflow: hidden;
}

.panel {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
color: white;
background: #ececec;
text-align: center;
box-shadow: inset 0 0 0 5px rgba(255, 255, 255, 0.1);
transition: font-size 0.7s cubic-bezier(0.61, -0.19, 0.7, -0.11),
flex 0.7s cubic-bezier(0.61, -0.19, 0.7, -0.11), background 0.2s;
font-size: 20px;
background-size: cover;
background-position: center;
cursor: pointer;
}

.panel1 {
background-color: #f4f8ea;
}

.panel2 {
background-color: #fffcdd;
}

.panel3 {
background-color: #beddcf;
}

.panel4 {
background-color: ​#c3cbd8;
}

.panel5 {
background-color: #dfe0e4;
}

.item {
flex: 1 0 auto;
display: flex;
justify-content: center;
align-items: center;
transition: transform 0.5s;
font-size: 1.6em;
font-family: 'Amatic SC', cursive;
text-shadow: 0 0 4px rgba(0, 0, 0, 0.72), 0 0 14px rgba(0, 0, 0, 0.45);
}

.name {
transform: translateY(-100%);
}

.panel .index {
font-size: 4em !important;
width: 100%;
}

.desc {
transform: translateY(100%);
}

.open-active .name,
.open-active .desc {
transform: translateY(0);
width: 100%;
}

.panel.open {
flex: 3;
font-size: 40px;
}
JavaScript 源码
/src/js/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { log } from './log'
import '../css/style.css';

function installEvent() {
const panels = document.querySelectorAll('.panel')

function toggleOpen() {
panels.forEach(item => {
if (item === this) return;
item.classList.remove('open')
});

this.classList.toggle('open');
}

function toggleActicon(e) {
if (e.propertyName.includes('flex-grow')) {
this.classList.toggle('open-active')
}
}

// 给每个元素注册事件
panels.forEach(panel => {
panel.addEventListener('click', toggleOpen)
panel.addEventListener('transitionend', toggleActicon)
})
}

installEvent();
log('本节在测试配置噢');

修改 webpack 配置,引入 css-loadermini-css-extract-plugin。既然已经对源码目录进行分类了,那顺便也给输出目录的文件也进行分类整理吧:

/webpack.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
  const path = require('path');
+ const HtmlWebpackPlugin = require('html-webpack-plugin');
+ const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
// 起点或是应用程序的起点入口
entry: "./src/js/index",

// 输出配置
output: {
// 编译后的输出路径
// 注意此处必须是绝对路径,不然 webpack 将会抛错(使用 Node.js 的 path 模块)
path: path.resolve(__dirname, "dist"),

// 输出 bundle 的名称
- filename: "bundle.js",
+ filename: "js/bundle.js",
+ },
+ module: {
+ rules: [
+ {
+ test: /\.css$/i,
+ use: [MiniCssExtractPlugin.loader, 'css-loader'],
+ },
+ ],
+ },
plugins: [
// html-webpack-plugin
// https://github.com/jantimon/html-webpack-plugin#configuration
new HtmlWebpackPlugin({
title: 'Test Configuration',
- template: path.resolve(__dirname, "./index.html"),
+ template: path.resolve(__dirname, "./src/index.html"),
+ }),
+
+ // 提取 css 到单独的文件
+ // https://github.com/webpack-contrib/mini-css-extract-plugin
+ new MiniCssExtractPlugin({
+ // 选项类似于 webpackOptions.output 中的相同选项,该选项是可选的
+ filename: 'css/index.css',
+ })
],
}

现在我们根据上面的配置来解读 loader 的使用:

在上面的配置中,**module** 规定了如何处理项目中的不同类型的模块。**rules** 是创建模块时,匹配请求的 rule (规则)数组。rule 是一个对象,其中最常见的属性就是 testuseloader

rule.test 是匹配条件,通常会给它提供一个正则表达式或是由正则表达式组成的数组。如果配置了 test 属性,那这个 rule 将匹配指定条件。比如匹配条件写为 test: /\.css$/i,这意味着给后缀为 .css 的文件使用 loader

rule.use 顾名思义就是使用,给符合匹配条件的文件使用 loader。它可以接收一个字符串,这个字符串会通过 webpack 的 resolveLoader 选项进行解析。该选项可以不配置,它内置有解析规则。比如下例中默认会从 node_modules 中查找依赖:

1
use: 'css-loader'

rule.use 还可以是应用于模块的 UseEntry 对象。UseEntry 对象内主要有 loaderoptions 两个属性:

1
2
3
4
5
6
7
8
9
// use 传入 UseEntry 类型的对象
use: {
// 必选项,要告诉 webpack 使用什么 loader
loader: 'css-loader',
// 可选项,传递给 loader 选项
options: {
modules: true
}
},

如果 UseEntry 对象内只设置 loader 属性,那它与单传的字符串的效果是一样的。而 options 是传递给 loader 的配置项,不同 loader 会提供有不同的 options。值得注意的是,如果 use 是以对象形式传入,**loader 属性是必填的,而 options 是可选的**。

rule.use 还可以是一个函数,函数形参是正在加载的模块对象参数,最终该函数要返回 UseEntry 对象或数组:

1
2
3
4
5
6
7
8
9
10
11
use: (info) => {
console.log(info);
return {
loader: 'svgo-loader',
options: {
plugins: [{
cleanupIDs: { prefix: basename(info.resource) }
}]
}
}
}

打印出函数的形参 info 可以看到该对象拥有如下属性:

  • compiler: 当前的 webpack 编译器(可以未定义)
  • issuer: 导入正在加载的模块的模块的路径
  • realResource: 始终是要加载的模块的路径
  • resource: 要加载的模块的路径,通常等于 realResource。除非在请求字符串中通过 !=! 覆盖资源名。

由此可见,使用函数方式可用于按模块更改 loader 选项。

rule.use 最常见的使用形式还是提供一个数组,数组中每项可以是字符串、UseEntry 对象、UseEntry 函数。这也是一个套娃的过程:

1
use: [{ loader: MiniCssExtractPlugin.loader }, 'css-loader'],

这里需要注意的是,**rule 中使用多个 loader 要注意其顺序。使用数组 loader 将会从右至左进行应用**。

比如上例中最先通过 css-loader 来处理 .css 文件的引入问题,再通过 MiniCssExtractPlugin.loader (Tips: 该值是 loader 的绝对路径)来提取出文件。如果反过来应用就会出问题了,webpack 都不知道如何引用 css 文件,自然提取不出东西啦。

rule.loaderrule.use 的缩写,等价于 rule.use: [{ loader }]。webpack 像这样简写的配置属性还有很多,这样做有利也有弊。对于熟手来说,提供这种简便选项可以减少配置的嵌套关系,但对新手来说,这配置有种错综复杂的感觉。

1
2
3
4
5
6
{
// 匹配文件规则
test: /\.css$/i,
// rule.use 简写形式
loader: 'css-loader'
}

接下来回归正题。重新编译 webpack,编译后的目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.
├── dist
│ ├── css
│ │ └── index.css
│ ├── index.html
│ └── js
│ └── bundle.js
├── package.json
├── src
│ ├── css
│ │ └── style.css
│ ├── index.html
│ └── js
│ ├── index.js
│ └── log.js
└── webpack.config.js

image

图片资源也是项目中的常见资源,引入图片资源同样需要安装 loader。处理图片资源的 loader 主要有两种,分别是 url-loaderfile-loader

file-loader

file-loader 是将 import/require() 引入的文件解析为 url,并把文件输出到输出目录中。

复制一份新 Demo 并重命名为 **getting-started-loader-images**。在安装 loader 之前先做一个小优化:

如果我们会频繁修改源码文件,修改完后又要重新编译,这个步骤实际是有点繁琐的。webpack 有个 watch 选项可以监听文件变化,若文件有修改 webpack 将自动编译(若修改的是配置文件的话,还是需要重新运行命令)。

package.jsonscript 中给 webpack 添加 -w 选项:

1
2
3
"scripts": {
"build:watch": "rimraf ./dist && webpack --config ./webpack.config.js -w"
},

接下来就可以安装依赖了:

1
npm i -D file-loader

新建一个 /src/images 文件夹,往里面添加一些图片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  .
├── package.json
├── src
│ ├── css
│ │ └── style.css
+ │ ├── images
+ │ │ ├── 01.jpg
+ │ │ ├── 02.png
+ │ │ ├── 03.jpg
+ │ │ ├── 04.png
+ │ │ ├── 05.png
+ │ │ ├── 06.jpg
+ │ │ ├── webpack.jpg
+ │ │ └── webpack.svg
│ ├── index.html
│ └── js
│ ├── index.js
│ └── log.js
└── webpack.config.js

webpack.config.js 中配置 loader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  rules: [
{
test: /\.html$/i,
loader: 'html-loader',
},
{
// 匹配文件规则
test: /\.css$/i,
// use 从右至左进行应用
use: [MiniCssExtractPlugin.loader, 'css-loader'],
},
+ {
+ test: /\.(png|jpe?g|gif|webp|svg)(\?.*)?$/,
+ use: {
+ loader: 'file-loader',
+ options: {
+ name: 'img/[name].[hash:8].[ext]'
+ },
+ },
+ },
],

默认情况下图片会被输出到 dist 目录中,文件名也会被更改为一长串的哈希值。为了保持目录整洁,将要被输出的图片资源都归类到 img 目录中。

可以通过设定 namepublicPath 来指定目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 直接设置 name
use: {
loader: 'file-loader',
options: {
name: 'img/[name].[hash:8].[ext]',
},
},

// 或者使用 publicPath,效果与上例等价
use: {
loader: 'file-loader',
options: {
publicPath: 'img',
name: '[name].[hash:8].[ext]',
},
},

name 属性的值可以用 / 分层。除去最末尾一层的是文件名,前面每层 / 分隔都是嵌套的文件夹。比如值为 static/img/[name].[hash:8].[ext] 最后输出的结果是:根目录创建一个 static 目录,static 内又会创建一个 img 目录,img 内输出被引用的图片资源。

由于匹配的图片资源有很多,咱们不能写死输出的文件名,不然会引发重名问题,操作系统不准这样干。这时 **占位符(placeholder)**就能排上用场了。name 中方括号包裹起来的是占位符,不同占位符会被替换成不同的信息。

比如上例中使用了三个占位符: name 是文件的名称、hash 是指定用于对文件内容进行 hash (哈希)处理的 hash 方法,后面冒号加数值代表截取 hash 的长度为 8、ext 是文件的扩展名。在文件名加入 hash 的用意是针对浏览器缓存而特意加入的。现在可以不用在意这种优化问题,未来会专门另起一篇文章讲优化的问题。

现在修改完 webapck 配置,接着再来完善上一节的 Demo。在 /src/css/styles.css 中使用 backgournd-image 引入图片:

css 引入图片资源
/src/css/style.css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* 省略其他代码... */
.panel1 {
background-color: #f4f8ea;
background-image: url('../images/01.jpg');
}

.panel2 {
background-color: #fffcdd;
background-image: url('../images/02.png');
}

.panel3 {
background-color: #beddcf;
background-image: url('../images/03.jpg');
}

.panel4 {
background-color: ​#c3cbd8;
background-image: url('../images/04.png');
}

.panel5 {
background-color: #dfe0e4;
background-image: url('../images/05.png');
}

重新编译后的结果如下:

编译结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
> rimraf ./dist && webpack --config ./webpack.config.js -w


webpack is watching the files…

Hash: 398663f1f4d417d17c94
Version: webpack 4.43.0
Time: 1086ms
Built at: 05/29/2020 2:19:03 PM
Asset Size Chunks Chunk Names
css/index.css 1.72 KiB 0 [emitted] main
img/01.a8e7ddb2.jpg 170 KiB [emitted]
img/02.46713ed3.png 744 KiB [emitted] [big]
img/03.70b4bb75.jpg 529 KiB [emitted] [big]
img/04.b7d3aa38.png 368 KiB [emitted] [big]
img/05.875a8bc2.png 499 KiB [emitted] [big]
index.html 990 bytes [emitted]
js/bundle.js 1.33 KiB 0 [emitted] main
Entrypoint main = css/index.css js/bundle.js
[0] ./src/css/style.css 39 bytes {0} [built]
[1] ./src/js/index.js + 1 modules 938 bytes {0} [built]
| ./src/js/index.js 873 bytes [built]
| ./src/js/log.js 60 bytes [built]
+ 1 hidden module

WARNING in configuration
The 'mode' option has not been set, webpack will fallback to 'production' for this value. Set 'mode' option to 'development' or 'production' to enable defaults for each environment.
You can also set it to 'none' to disable any default behavior. Learn more: https://webpack.js.org/configuration/mode/

WARNING in asset size limit: The following asset(s) exceed the recommended size limit (244 KiB).
This can impact web performance.
Assets:
img/04.b7d3aa38.png (368 KiB)
img/05.875a8bc2.png (499 KiB)
img/02.46713ed3.png (744 KiB)
img/03.70b4bb75.jpg (529 KiB)

WARNING in webpack performance recommendations:
You can limit the size of your bundles by using import() or require.ensure to lazy load some parts of your application.
For more info visit https://webpack.js.org/guides/code-splitting/
Child HtmlWebpackCompiler:
1 asset
Entrypoint HtmlWebpackPlugin_0 = __child-HtmlWebpackPlugin_0
[0] ./node_modules/html-webpack-plugin/lib/loader.js!./src/index.html 1.01 KiB {0} [built]
Child mini-css-extract-plugin node_modules/css-loader/dist/cjs.js!src/css/style.css:
Entrypoint mini-css-extract-plugin = *
[0] ./node_modules/css-loader/dist/cjs.js!./src/css/style.css 3.09 KiB {0} [built]
[3] ./src/images/01.jpg 63 bytes {0} [built]
[4] ./src/images/02.png 63 bytes {0} [built]
[5] ./src/images/03.jpg 63 bytes {0} [built]
[6] ./src/images/04.png 63 bytes {0} [built]
[7] ./src/images/05.png 63 bytes {0} [built]
+ 2 hidden modules

当我们重新打开 /dist/index.html 时会发现图片并没有加载出来?查看 css 源码后发现原来是路径有问题,编译后的路径是 img/01.a8e7ddb2.jpg 这种相对路径。

由于 css 本身有一个文件夹,通过相对路径引入,那就会从 css 目录下进行查找。实际找到的是 dist/css/img/01.a8e7ddb2.jpg 这条路径。

遇到这种情况怎么办呢?我们可以给 MiniCssExtractPlugin.loader 添加 publicPath 选项用以修正路径,重新编译后就可以看到图片正确被加载了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
// 匹配文件规则
test: /\.css$/i,
// use 从右至左进行应用
use: [
{
loader: MiniCssExtractPlugin.loader,
options: {
publicPath: '../',
}
},
'css-loader'
],
},

在 js 中也可以引用文件,打开 /src/js/index.js, 在原先的基础上添加如下代码:

1
2
3
4
5
6
7
8
9
10
import img1 from '../images/06.jpg';
import img2 from '../images/webpack.jpg';
import img3 from '../images/webpack.svg';

// 省略其他代码...

log('测试图片引入~');
console.log('img1 --> ', img1);
console.log('img2 --> ', img2);
console.log('img3 --> ', img3);

重新编译后可以在 Console 面板可以看到 js 输出了文件资源的路径:

url-loader

url-loader 功能也类似于 file-loader,不同的是当文件大小(单位 byte)小于 limit 时,可以返回一个 DataURL

为什么要用 DataURL 呢?我们知道页面上每加载一个图片资源,都会发起一个 HTTP 请求。而建立 HTTP 请求的过程是需要花时间的。因此可以将文件转为 DataURL 嵌入 html/css/js 文件中,这样可以有效减少 HTTP 建立连接时所带来额外的时间开销了。同时 html/css/js 文件也可以被浏览器缓存,DataURL 被引入后也能一同被缓存。

图片转 DataURL 也有缺点,那就是编码后文本储存所占的空间比图片会更大。这其实就是传输体积与 HTTP 连接数的权衡。所以最佳做法是将小图片转为 DataURL,转换后并不会有过多体积溢出,而大尺寸图片照常引入即可。

安装 url-loader:

1
npm install url-loader -D

修改 webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
rules: [
{
// 匹配文件规则
test: /\.css$/i,
// use 从右至左进行应用
use: [
{
loader: MiniCssExtractPlugin.loader,
options: { publicPath: '../' }
},
'css-loader'
],
},
{
test: /\.(png|jpe?g|gif|webp)(\?.*)?$/,
use: {
loader: 'url-loader',
options: {
limit: 10000,
name: 'img/[name].[hash:8].[ext]'
},
},
},
{
test: /\.(svg)(\?.*)?$/,
use: {
loader: 'file-loader',
options: {
name: 'img/[name].[hash:8].[ext]'
},
},
},
],

在上例中将 pngjpgjpeggifwebp 文件交给 url-loader 处理,而 svg 仍由 file-loader 处理。这样做的理由是: DataURL 内联 svg 会破坏 sprite 系统 (将多个 svg 合为一张使用的技术) 中使用的Fragment Identifiers,因此不将 svg 转为 DataURL

url-loader 设定匹配规则后,配置 namelimit 选项。url-loadername 选项与 file-loadername 作用是相同的,就不再累述。

limit 是指定以字节(byte) 为单位的文件最大尺寸。当文件尺寸小于等于 limit 所设的值,那文件将会被转为 DataURL。相反,若文件尺寸大于 limit 时,则使用备用 loader。默认备用 loaderfile-loader。可以设定 fallback 选项来修改备用 loader

1
2
3
4
5
6
7
8
{
loader: 'url-loader',
options: {
limit: 10000,
name: 'img/[name].[hash:8].[ext]'
fallback: 'file-loader'
}
}

limit 的选值不易过大,可以设为 10240 (10KB)或 10000,也可以根据项目实际情况进行调整。

现在来测试 limit 的效果。unix 系统可以在终端使用 ls -l 命令来查看文件信息:

1
2
3
4
5
6
7
8
9
10
11
➜  getting-started-loader-images git:(master) ✗ cd ./src/images
➜ images git:(master) ✗ ls -l
total 6144
-rwxr-xr-x 1 anran staff 173596 May 28 17:41 01.jpg
-rwxr-xr-x 1 anran staff 761560 May 28 17:41 02.png
-rwxr-xr-x 1 anran staff 542065 May 28 17:41 03.jpg
-rwxr-xr-x 1 anran staff 376562 May 28 17:41 04.png
-rwxr-xr-x 1 anran staff 510812 May 28 17:41 05.png
-rw-r--r-- 1 anran staff 760117 May 28 17:41 06.jpg
-rw-r--r--@ 1 anran staff 6943 May 30 13:54 webpack.jpg
-rw------- 1 anran staff 647 May 28 21:33 webpack.svg

从输出的信息可以看到 webpack.svg (647B) 和 webpack.jpg (6943B) 的文件尺寸都低于设定的 limit: 10000。由于 svg 文件不通过 url-loader 处理,那按照预想它将会被输出到 /dist/img 中。webpack.jpg 可以被 url-loader,那编译后应该被嵌入到 js 代码中。

重新编译测试一下:

编译结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
➜  getting-started-loader-images git:(master) ✗ npm run build

> getting-started-loader@1.0.0 build /Users/anran/project_my/webpack-example/getting-started-loader-images
> rimraf ./dist && webpack --config ./webpack.config.js

Hash: 8d2e8c8220e86d46e388
Version: webpack 4.43.0
Time: 692ms
Built at: 05/30/2020 2:08:46 PM
Asset Size Chunks Chunk Names
css/index.css 1.63 KiB 0 [emitted] main
img/01.a8e7ddb2.jpg 170 KiB [emitted]
img/02.46713ed3.png 744 KiB [emitted] [big]
img/03.70b4bb75.jpg 529 KiB [emitted] [big]
img/04.b7d3aa38.png 368 KiB [emitted] [big]
img/05.875a8bc2.png 499 KiB [emitted] [big]
img/06.5b8e9d1e.jpg 742 KiB [emitted] [big]
img/webpack.258a5471.svg 647 bytes [emitted]
index.html 990 bytes [emitted]
js/bundle.js 10.5 KiB 0 [emitted] main
Entrypoint main = css/index.css js/bundle.js
[0] ./src/css/style.css 39 bytes {0} [built]
[1] ./src/js/index.js + 4 modules 10.1 KiB {0} [built]
| ./src/js/index.js 881 bytes [built]
| ./src/js/log.js 60 bytes [built]
| ./src/images/06.jpg 63 bytes [built]
| ./src/images/webpack.jpg 9.08 KiB [built]
| ./src/images/webpack.svg 68 bytes [built]
+ 1 hidden module

WARNING in configuration
The 'mode' option has not been set, webpack will fallback to 'production' for this value. Set 'mode' option to 'development' or 'production' to enable defaults for each environment.
You can also set it to 'none' to disable any default behavior. Learn more: https://webpack.js.org/configuration/mode/

WARNING in asset size limit: The following asset(s) exceed the recommended size limit (244 KiB).
This can impact web performance.
Assets:
img/04.b7d3aa38.png (368 KiB)
img/03.70b4bb75.jpg (529 KiB)
img/05.875a8bc2.png (499 KiB)
img/02.46713ed3.png (744 KiB)
img/06.5b8e9d1e.jpg (742 KiB)

WARNING in webpack performance recommendations:
You can limit the size of your bundles by using import() or require.ensure to lazy load some parts of your application.
For more info visit https://webpack.js.org/guides/code-splitting/
Child HtmlWebpackCompiler:
1 asset
Entrypoint HtmlWebpackPlugin_0 = __child-HtmlWebpackPlugin_0
[0] ./node_modules/html-webpack-plugin/lib/loader.js!./src/index.html 1.37 KiB {0} [built]
Child mini-css-extract-plugin node_modules/css-loader/dist/cjs.js!src/css/style.css:
Entrypoint mini-css-extract-plugin = *
[0] ./node_modules/css-loader/dist/cjs.js!./src/css/style.css 2.98 KiB {0} [built]
[3] ./src/images/01.jpg 63 bytes {0} [built]
[4] ./src/images/02.png 63 bytes {0} [built]
[5] ./src/images/03.jpg 63 bytes {0} [built]
[6] ./src/images/04.png 63 bytes {0} [built]
[7] ./src/images/05.png 63 bytes {0} [built]
+ 2 hidden modules
编译后的目录
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
   .
├── dist
│ ├── css
│ │ └── index.css
│ ├── img
│ │ ├── 01.a8e7ddb2.jpg
│ │ ├── 02.46713ed3.png
│ │ ├── 03.70b4bb75.jpg
│ │ ├── 04.b7d3aa38.png
│ │ ├── 05.875a8bc2.png
│ │ ├── 06.5b8e9d1e.jpg
│ │ └── webpack.258a5471.svg
│ ├── index.html
│ └── js
│ └── bundle.js
├── package-lock.json
├── package.json
├── src
│ ├── css
│ │ └── style.css
│ ├── images
│ │ ├── 01.jpg
│ │ ├── 02.png
│ │ ├── 03.jpg
│ │ ├── 04.png
│ │ ├── 05.png
│ │ ├── 06.jpg
│ │ ├── webpack.jpg
│ │ └── webpack.svg
│ ├── index.html
│ └── js
│ ├── index.js
│ └── log.js
└── webpack.config.js

重新打开 /dist/index.html 后可以在浏览器控制台看到如下输出的信息:

HTML 资源引入

HTML 中有一种常见的情况是:在模板中通过相对路径引入图片、脚本等资源时,发现引入的资源都没有被打包进去。

为什么会发生这种情况呢?原来是 webpack 默认不会处理 html 中的资源引入。为了能使 HTML 能通过相对路径引入资源,主要有 3 种解决的方案:

lodash template

现在项目中 /src/index.html 是作为 html-webpack-plugin 的模板,在模板中可以使用 lodash template 语法(以下简称模板语法)来插入内容。语法格式为: <%= value %>

比如在 src/index.html 的模板中插入图片:

/src/index.html
1
2
3
4
5
6
<div class="panels">
<!-- 其他代码略... -->
<div class="panel panel6">
<img class="img" src="<%= require('./images/06.jpg').default %>" alt="">
</div>
</div>
/src/css/style.css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/* 为了使页面美观,再添加一些样式 */
.panel6 {
position: relative;
overflow: hidden;
background-color: #061927;
}

.panel6 .item {
position: relative;
}

.panel6 .img {
position: absolute;
height: 100%;
transform: scale(1);
transition: transform 0.4s 0.6s;
}

.panel6.open {
flex: 2;
}

.panel6.open .img {
transform: scale(1.2);
}

上例将通过 require() 函数引入图片。webpack 引入图片时默认是通过 ESModule 来引入的,因此解析的结果大致为 {default: module} 这种形式。因此后面还需要再加一个 default。这样就能正确的引入资源啦。

静态目录

第二种就是新增一个静态目录 static(或者叫 public)。

HTML 默认不是引用不了源码目录上的资源吗?那我就直接将资源输出到 dist 目录上。模板引用资源时直接引入输出后的文件不就行啦?

copy-webpack-plugin 可以完成这种迁移的功能。它将从 form 处复制文件/文件夹,复制到 to (默认是 webpack 的输出目录)中。现在来安装它:

1
npm i -D copy-webpack-plugin

新增 static 目录,并添加一些测试文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  .
├── package.json
├── src
│ ├── css
│ │ └── style.css
│ ├── images
│ │ ├── 01.jpg
│ │ ├── 02.png
│ │ ├── 03.jpg
│ │ ├── 04.png
│ │ ├── 05.png
│ │ ├── 06.jpg
│ │ ├── webpack.jpg
│ │ └── webpack.svg
│ ├── index.html
│ ├── js
│ │ ├── index.js
│ │ └── log.js
+ │ └── static
+ │ └── images
+ │ ├── 06.jpg
+ │ ├── webpack.jpg
+ │ └── webpack.svg
└── webpack.config.js

现在将 src/static/images 的所有文件(不管代码里有没有引入这些文件)都复制到 dist/img 中。

/webpack.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
// webpack.config.js
{
plugins: [
new CopyPlugin({
patterns: [
{
from: path.resolve(__dirname, 'src/static/images'),
to: path.resolve(__dirname, 'dist/img')
},
],
}),
],
}

如果你不仅想要复制图片还想要复制其他诸如 css 样式表、js 脚本甚至是 excel 文件到输出目录的话。那可以考虑将 static 目录与 dist 目录进行合并,将 staticdist 下的目录名保持一致。

比如将 static 的下 images 文件夹更名为图片输出目录 img,这样打包后会输出到同一个目录中:

/webpack.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// webpack.config.js
{
plugins: [
new CopyPlugin({
patterns: [
// 如果只传 string 的话,那这个 string 相当于 from
// path.resolve(__dirname, 'src', 'static'),

// to 默认是 `compiler.options.output`, 也就是 dist 目录
// {
// from: path.resolve(__dirname, 'src/static'),
// to: ''
// },

// 当前配置中与上面两例等价
{
from: path.resolve(__dirname, 'src/static'),
to: path.resolve(__dirname, 'dist')
},
],
}),
],
}

若指定文件/文件夹不想复制到 dist 中,还可以使用 globOptions.ignore 来忽略:

/webpack.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// webpack.config.js
{
plugins: [
new CopyPlugin({
patterns: [
{
from: path.resolve(__dirname, 'src/static'),
to: path.resolve(__dirname, 'dist')
globOptions: {
ignore: ['/**/webpack.jpg', '/**/img/webpack.svg'],
}
},
],
}),
],
}

重新修改模板中的图片的引入的路径,使其指向输出目录的 img:

1
2
3
4
<div class="panel panel6">
<img class="img" src="./img/06.jpg" alt="">
<p class="item index">VI</p>
</div>

编译后就能看到图片正确被引用了。

html-loader

最后一种是安装 html-loader,让 webapck 可以处理 html 资源的引入。

1
npm install -D html-loader
1
2
3
4
5
6
7
rules: [
{
test: /\.html$/i,
loader: 'html-loader',
},
// 省略其他 rule...
]

配置 html-loader 后,HTML 访问相对路径的资源就由 html-loader 来进行引入。将模板中的路径改为源码相对路径:

1
2
3
4
<div class="panel panel6">
<img class="img" src="./images/06.jpg" alt="">
<p class="item index">VI</p>
</div>

在实际编译时,<img class="img" src="./images/06.jpg" alt="">src 的值会被转为 require('./images/06.jpg'),通过 webpack 引入后再将编译后的结果传入图片的 src 属性中。

此时重新编译后就可以正确引入了。但配置 html-loader 的方法会与方法二访问静态目录资源有点冲突。配置 html-loader 后就不能通过 ./../ 这种相对路径来访问资输出目录的资源了。

如果我们配置了 html-loader 的同时又还想访问静态资源怎么办呢?这时可以通过根路径 / 逐层来访问,这样 html-loader 就不会处理这种路径:

1
2
3
4
 <div class="panel panel6">
<img class="img" src="/img/06.jpg" alt="">
<p class="item index">VI</p>
</div>

现在问题又来了,若我们通过根路径来访问资源的话,那就不能单纯地打开文件来在浏览器查看效果了。因为直接打开文件到浏览器上,是通过 file:// 协议打开的。浏览器实际上访问的路径是文件的绝对地址。

比如笔者打开文件后,浏览器地址栏展示的 url 是: file:///Users/anran/project_my/webpack-example/getting-started-static-assets/dist/index.html。现在通过根路径访问资源,需要浏览器补全为完整的 URL,经过浏览器补全后绝对路径是 file:///img/06.jpg。这样路径都是错误的自然就访问不到想要的资源啦。

如果有写过 SPA(单页面应用) 项目的朋友应该很熟悉。将 SPA 项目打包后直接访问 index.html 页面是空白的,这种情况多半就是从根路径引入资源失败而引起的。

这个问题解决的办法也很简单,就是将编译后的项目部署到服务器上,直接通过服务器进行访问,问题就迎刃而解了。为什么这样就可以解决了呢?

比如笔者的网站域名是 anran758.github.io,现在将页面部署到服务器后,直接在浏览器访问 https://anran758.github.io/,实际上访问的是 /dist/index.html 文件。html 通过相对路径访问/img/06.jpg,那补全后图片的路径就是 https://anran758.github.io/img/06.jpg。这样自然就能访问资源啦。

我们不妨通过 Node.js 起一个本地服务器测试一下。在 /dist 同级目录上新建一个 server.js 脚本,添加如下代码:

/server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const express = require('express');
const config = require('./webpack.config');

const app = express();
const PORT = 8001;

// 设置静态资源入口
app.use(express.static(config.output.path));

// 监听端口
app.listen(PORT, (err) => {
if (err) {
console.log(err);
return;
}

console.log('Listening at http://localhost:' + PORT + '\n');
})

上例脚本代码是通过 express 快速搭建一个本地服务器,将服务器静态资源入口设为 webpack.config.js 的输出目录(也就是 /dist),随后启动服务器。

express 是基于 Node.js 的 web 框架,要使用它之前需要安装依赖:

1
npm install -D express

package.json 中添加个快捷入口,并在终端运行该脚本:

1
2
3
4
5
6
{
"scripts": {
// 其他脚本..
"test:prod": "node server.js"
},
}
1
2
3
4
5
6
➜  getting-started-static-assets git:(master) ✗ npm run test:prod 

> getting-started-loader@1.0.0 test:prod /Users/anran/project_my/webpack-example/getting-started-static-assets
> node server.js

Server is running at http://localhost:8001 . Press Ctrl+C to stop.

打开 http://localhost:8001 后就能看到图片资源正确被引用了。

总结

好啦,现在 webpack 基础篇也到了尾声。我们对上述知识做一个简单的小结:

webpack 是一个静态模块打包工具,它本体虽然只支持处理 javascript 的模块,但可以通过 loader 让 webpack 完成原本它不能处理的功能。

webpack 的提供插件的功能,插件可以针对某种需求做特定处理,比如自动给 html 插入资源。

除了静态目录的文件外,我们发现 webpack 输出的文件都是有依赖关系的。为什么会这么说呢?仔细看看 webpack 处理的逻辑就能想清楚了:

webpack 从程序的入口 /src/js/index.js 开始处理,入口文件引入了 style.css,而 style.css 内又引用了图片资源。然后 HTML 再通过 webpack 插件引入模板,再将这些资源插入模板中。这就是文件的依赖关系,这些依赖关系最终会生成一个**依赖图(Dependency Graph)**。

想必看到这里的各位对 webpack 都有了个比较清晰的概念了吧?当然这只是一个开始,后面还有一些高级的概念在本文中由于篇幅的限制无法一并理清。若对笔者 webpack 的笔记感兴趣的话可以继续关注此系列的更新,下一篇将围绕开发环境进行梳理。

参考资料:

将 JSON 数据格式输出至页面上

JSON 是一种轻量级的数据交换格式,它有键值对集合(js 中的对象)和数组两种结构。JSON是一个通用的格式,在前后端语言中都能跟该 JSON 打交道。

有时候我们需要将 JSON 格式输入至页面展示的需求,其中还需要保持一定的索引,那么该如何实现呢?

使用

我们将对象转为 JSON 字符串时会经常使用 JSON.stringify 这个 API,其实该方法就内置有格式化的参数:

1
2
3
4
5
var userInfo = {name: 'anran758',github: 'https://github.com/anran758'};
var info = JSON.stringify(userInfo, null, 2);

console.log(info);
// "{↵ "name": "anran758",↵ "github": "https://github.com/anran758"↵}"

在上面的代码中,我们第一个参数(value)传入了一个需要序列化的对象。第二个参数是replacer,用以对属性转换和处理,由于我们不需要额外的处理,因此传入一个null;第三个参数则是空格索引的个数,封顶是100或不传则没有空格。

在控制台打印出信息后,我们可以看的出来格式化的数据是带换行符,并且有缩进的格式。接下来我们就要考虑如何输出到页面中。

输出

只要学过HTML的朋友都知道,我们直接将数据输入至HTML中,空格缩进会被浏览器给忽略掉的。因此不能输入到 <div> 中。这时候又想到,JSON格式实际上也算是代码的一种,那能不能输入至雷士代码块的标签中呢?答案是可以的。

HTML 中有两个标签可以展示源代码: <pre><code> 。它们之间不同之处在于:

  • <pre> 表示预定义格式文本,按照原文件中的编排,以等宽字体的形式展现出来,文本中的空白符(比如空格和换行符)都会显示出来
  • <code> 则是呈现一段计算机代码,它以浏览器的默认等宽字体显示,但并不一定会完整呈现原来的格式

这些标签知识实际上算是比较冷门的知识,或许远古的面试题会考这种知识点,平时很少会遇到。但是如果你经常使用markdown的话,那么这些标签在markdown中有不同的别名:

1
2
3
比如 markdown 语法中的 ``,实际上等同于 <code> 标签。实际作用是短代码块标签

而 markdown 语法中的长代码块就等同于 `<pre>` 标签,不同的博客或者网站的应用中还可以对 `<pre>` 加类名,用以展示不同的语言的语法高亮。

通过三者之间的对比可以看出,只有 <pre> 才是符合我们需求的。

确定好展示的方式后,就可以考虑进一步扩展格式化的功能。比如对象中还有属性是 JSON 字符串的话,咱也进一步的解析,直至最底层。想实现这种功能需要编写一个递归函数,考虑如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
const isPlainObject = (v) => Object.prototype.toString.call(v) === "[object Object]"
const isString = (v) => Object.prototype.toString.call(v) === "[object String]"

/**
* 格式 JSON 字符串为对象
*
* @author anran758
* @param { any }
*/
function formatJsonStrAsObj(sample) {
let temp = sample;

if (isString(temp)) {
// 因为有解析失败的可能,使用 try catch 做相应处理
try {
temp = JSON.parse(temp);
} catch (ex) {
// parse error,return this sample
return sample;
}
}

if (isPlainObject(temp)) {
temp = { ...temp };

Object.keys(temp).forEach(key => {
const item = temp[key];

// 字符串或者对象进行递归确认
if (isString(item) || isPlainObject(item)) {
temp[key] = formatJsonStrAsObj(item);
}
});
}

return temp;
}

/**
* 将 JSON 字符串转换为带缩进的字符串
*
* @param {*} sample JSON 字符串
* @param {number} [indnt=2] 缩进数
* @returns
*/
function formatJSONIndnt(sample, indnt = 2) {
const newSample = formatJsonStrAsObj(sample);

if (isString(newSample)) return newSample;

try {
return JSON.stringify(newSample, null, indnt);
} catch (ex) {
return newSample.toString();
}
}

const info = JSON.stringify({
name: 'anran758',
avatar: 'https://xxx',
detail: JSON.stringify({
desc: 'some description',
level: 2,
})
})
const data = formatJSONIndnt(info);
console.log(data);

// 可以直接将 data 输出至 dom 中

输入

上文讲了如何将数据输出至页面,以及扩展格式化功能的示例。接下来讲解输入方面的应用。

当用户从别的地方复制数据想粘贴至输入框时,可以在输入框上设置监控事件,触发事件后尝试帮用户格式化数据,示例代码如下:

1
2
3
4
<div class="container">
<pre class="preview pre"></pre>
<textarea class="textarea"></textarea>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const info = JSON.stringify({
name: 'anran758',
avatar: 'https://xxx',
detail: JSON.stringify({
desc: 'some description',
level: 2,
})
})
const data = formatJSONIndnt(info);

const textarea = document.querySelector('.textarea');
const preview = document.querySelector('.pre');

preview.innerHTML = data;
textarea.addEventListener('paste', (e) => {
// 阻止默认事件
e.preventDefault();
const value = (e.clipboardData || window.clipboardData).getData('text');

// 这里使用了上面定义的函数,进行格式化数据
e.target.value = formatJSONIndnt(value, 2);
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
body {
display: flex;
margin: 0;
justify-content: center;
align-items: center;
padding: 0 10px;
box-sizing: border-box;
min-height: 100vh;
}

.container {
display: flex;
width: 100%;
}

.preview {
flex: 1;
margin-bottom: 20px;
padding: 20px;
background: #f5fcff;
border: 1px solid #d3eeff;
border-radius: 3px;
margin: 0;
}

.textarea {
flex: 1;
margin-left: 20px;
padding: 10px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier,
monospace;
}

.preview + .preview {
margin-left: 10px;
}

参考资料

【前端十万个为什么】V0

为什么用 Composition API

c2f54277b2fb37d52596daa96202d926.png

  1. 使逻辑分离更容易:使用可组合函数可以将组件的逻辑分解为多个较小的部分,不再限制于 Options API 中组织代码,更容易重用有状态逻辑。
  2. 灵活性和代码重用性:可以提取和重用共享逻辑,分离关注点,使代码更加模块化和易于维护。
  3. 实现组件的复用和组合:将逻辑拆分为可重用的部分,使用可组合函数组合组件,避免重复编写代码,提高代码的重用性,减少重复和不一致性的风险。
  4. 更好的可读性和可理解性:每个可组合函数封装了特定方面的行为、方便推理和测试、有助于团队合作,使代码结构化和有组织。
  5. 更好的类型推断:使用变量和标准 JavaScript 函数处理组件逻辑。更容易在使用静态类型系统(如 TypeScript)构建大型 Vue 应用程序时进行类型推断

为什么解构 Proxy 会失去效应性

等待补坑

为什么 Vue 中解构 props 会失去响应性

在 Vue 3 中,当你解构 props 时,可能会丧失响应性。这意味着对 props 的更改不会触发组件的更新。

原因是 Vue 的响应性系统依赖 Proxy 来跟踪对象属性的更改。当组件接收到一个对象作为 props 时,Vue 会为该对象的每个属性设置响应性的 getter 和 setter。这使得 Vue 能够检测属性何时更改并相应地更新组件。

当你解构对象 props 时,实际上是创建了一个不再具有响应性的新对象。Vue 为原始对象创建的响应性 getter 和 setter 不会转移到新对象上。

参考

  1. Vue3 如果解构 props 会失去起响应性导致 setup 里一堆 pros.xxx 怎么办? - 这似谁的小鹿的回答 - 知乎
  2. Vue Tip: Destructure Props in Composition API Without Losing …
  3. How To Destructure Props In Vue 3 Without Losing Reactivity | by Nicky Christensen | Medium

为什么 Vue 项目很少用 RxJS

一句话解释

这是因为 Vue 希望成为一个轻量且灵活的框架,允许开发者选择他们喜欢的工具和库。虽然 RxJS 是一个强大的响应式编程库,但 Vue 采用了不同的方法,提供了自己的响应式系统。

详情点

  • 设计理念不同:Vue 注重简单和直观,便于响应式编程;而 RxJS 功能更强、更复杂,适合异步和事件驱动编程。
  • 库的大小与复杂性:RxJS 库大且学习曲线陡,若作为 Vue 的默认依赖,会增加框架大小和开发复杂性,与 Vue 的轻量和灵活理念不符。
  • 灵活性:Vue 设计为灵活且适应不同的项目需求。不将自己绑定到特定的响应式库(如 RxJS),允许开发人员选择最适合他们需求的工具和库。这种灵活性使开发人员能够无缝地将 RxJS 或其他任何库集成到 Vue 项目中。
  • 学习曲线:Vue 拥有平缓的学习曲线,特别是对于初级开发。通过提供自己的响应式系统,Vue 可以提供更简单、更渐进的学习体验。

参考

  1. Introduction to VueJS and RxJS - This Dot Labs
  2. Integrating RxJS with Vue.js | DigitalOcean
  3. Reactive Programming: The Good and the Bad | goodguydaniel.com
  4. A better practice to implement HTTP client in Vue with RxJS for enterprise Apps | by Pawel Woltschkow | Medium
  5. You might not want Rxjs

JavaScript 总结、比较 V2

Promise 与 RxJS Observables 的区别

Promise

  • Promise 是 JavaScript 中内置的,不需要任何额外的库。
  • Promise 表示可能现在或将来可用的单个值。
  • Promise 是急切的,也就是说一旦 Promise 被解析,.then()回调会立即执行。
  • Promise 只能发出单个值。
  • Promise 非常适合处理产生单个结果的简单异步操作。

RxJS Observables

  • Observables 是 RxJS 库的一部分,需要额外安装依赖。
  • Observable 表示可以随时间发出的值流。
  • Observable 是惰性的,也就是说在订阅之前不会执行任何操作。
  • Observable 可以发出多个值,包括零个或多个值。
  • 可以使用各种 RxJS 操作符对 Observable 进行转换和组合,以创建新的定制流。
  • Observable 非常适合处理复杂的异步操作,例如实时数据流或事件驱动编程。

参考

  1. JavaScript Theory: Promise vs Observable - Medium
  2. angular - What is the difference between Promises and Observables? - Stack Overflow
  3. JavaScript Promises vs. RxJS Observables

模版语法的简单实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const name = 'Nexmoe';const message = 'My name is {{name}} and I\'m {{getAge(20)}} years old.';function getAge(age) {  return age;}const replacedMessage = message.replace(/\{\{(.*?)\}\}/g, (match, variableOrFunction) => {  const trimmedValue = variableOrFunction.trim();  if (trimmedValue.includes('(')) {  // 如果占位符包含括号,则表示为带参数的函数替换    const [functionName, ...args] = trimmedValue.split(/\(|\)/).filter(Boolean);    const func = eval(functionName);    return func(...args);  } else {  // 否则为变量替换    return eval(trimmedValue);  }});onsole.log(replacedMessage);

先检查占位符中是否包含括号,如果包含括号,则表示是一个带参数的函数调用。使用split方法和正则表达式来解析函数名和参数,并将其存储在functionNameargs变量中。然后,使用eval函数将函数名转换为实际的函数对象,并使用扩展运算符 (...) 将参数作为参数列表传递给函数。函数执行后,将返回值作为替换后的字符串返回。

如果占位符不包含括号,则表示是一个变量。直接使用eval函数将变量名转换为实际的变量值,并返回其值作为替换后的字符串。

⚠️ 注意:使用eval函数执行代码具有一定的安全风险,因为它可以执行任意的 JavaScript 代码。有相当多的建议建议不使用eval。准备过段时间研究研究不用eval的方法。

MVVM 是什么

MVVM 代表 Model-View-ViewModel,在 MVVM 中,Model 表示应用程序的数据和业务逻辑,View 表示用户界面,ViewModel 充当 Model 和 View 之间的中介。

模型(Model)

  • 模型代表应用程序中的数据和业务逻辑。
  • 它可以是从服务器获取的数据、本地存储的数据或通过其他方式获取的数据。
  • 模型通常实现了一些方法来操作、存储和管理数据。
  • 对应的是组件中的 data、props 属性。

视图(View)

  • 视图是用户界面的可见部分。
  • 它负责展示数据给用户,并接收用户的交互操作。
  • 在 Vue.js 中,视图通常由 Vue 组件表示,可以包含 HTML 模板和样式。

视图模型(ViewModel)

  • 视图模型是连接模型和视图的中间层。
  • 视图模型通常包含了与视图相关的数据、计算属性和方法,以及与模型交互的逻辑。
  • 通过双向绑定(data-binding)将视图和模型连接起来。当模型中的数据发生变化时,视图会自动更新。通过 DOM 事件监听,当用户在视图中输入数据或进行其他交互操作时,视图模型会自动更新模型中的数据。

优势

  • 分离关注点:将数据逻辑与视图逻辑分离,使代码更易于维护和测试。
  • 提高开发效率:通过双向数据绑定和声明式编程风格,减少了手动操作 DOM 的代码量。
  • 可重用性:通过组件化的方式,视图和视图模型可以在不同的应用程序中进行复用。
  • 响应式更新:当模型中的数据发生变化时,视图自动更新,提供了更好的用户体验。

参考

  1. 为什么尤雨溪尤大说 VUE 没有完全遵循 MVVM? - 知乎
  2. Vue 的 MVVM 思想(包含三个常见面试题) - 掘金
  3. MVC,MVP 和 MVVM 的图示 - 阮一峰的网络日志
  4. Getting Started - vue.js
  5. Comparing Vue.js to new JavaScript frameworks - LogRocket Blog

MVC 是什么

MVC 这个概念已经存在很久了,用了这么多年,今天了解一下概念做个总结。

MVC(Model-View-Controller)设计模式将应用程序中的对象分为三个角色:模型(Model)、视图(View)和控制器(Controller)。该模式不仅定义了对象在应用程序中的角色,还定义了对象之间的通信方式。每种类型的对象都通过抽象边界与其他类型的对象分离,并在这些边界上与其他类型的对象进行通信。应用程序中某种 MVC 类型的对象的集合有时被称为层,例如模型层。

848723f97c7a1b862e10abe0445da348.png

模型(Model)

  • 封装应用程序特定的数据,并定义操作和处理数据的逻辑。
  • 可以表示应用程序中的实体,如游戏中的角色或地址簿中的联系人。
  • 可以与其他模型对象建立关联,形成对象图。
  • 应该存储应用程序的持久状态数据。
  • 不应与呈现数据和用户界面相关的视图对象直接连接。

视图(View)

  • 用户可见的对象,负责显示数据和响应用户操作。
  • 知道如何绘制自身,并可以与用户进行交互。
  • 通常通过控制器对象从模型对象中获取数据进行展示和编辑。
  • 在 MVC 应用程序中与模型对象解耦,提供一致性和重用性。

控制器(Controller)

  • 充当视图对象和模型对象之间的中介。
  • 负责处理用户操作,并将其传递给模型层进行数据处理和更新。
  • 可以执行应用程序的设置和协调任务,管理其他对象的生命周期。
  • 在模型对象发生变化时,将新的模型数据传递给视图对象进行显示。

优势

  • 提供良好的应用程序设计,使对象更具可重用性和接口定义明确性。
  • 支持应用程序的可扩展性,易于添加新功能和模块。
  • 分离关注点,使代码更易于维护和测试。
  • 应用程序的模型层、视图层和控制层之间保持了清晰的分离,实现了代码的结构化和职责的明确划分,从而提高了应用程序的可维护性和可扩展性。

参考

  1. https://developer.apple.com/library/archive/documentation/General/Conceptual/DevPedia-CocoaCore/MVC.html

为什么 Bun 这么快

JavaScriptCore 引擎

Bun 使用 JavaScriptCore 引擎,这是 Safari 浏览器使用的引擎,而不是基于 Chromium 的浏览器和 Node.js 使用的 V8 引擎。JavaScriptCore 引擎经过了针对更快启动时间的优化,这有助于 Bun 的速度。

性能分析和优化

大量的性能优化。Bun 的初衷就是要快。[2]

Zig 语言

Bun 利用 Zig 语言进行低级内存控制和消除隐藏控制流。Zig 的设计原则注重性能,通过利用 Zig,Bun 可以实现更好的内存管理和控制,从而提高速度 [2]

参考

  1. Bun 1.0 | Bun Blog
  2. A first look at Bun: is it really 3x faster than Node.js and Deno? - DEV Community

动画 PNG(APNG) 转 GIF 并无限循环

今天在网上找了些 PNG 格式的动态表情包我是不会告诉你是我是在 LINE 偷的表情包的,于是了解到是 APNG 这种格式。由于微信和 QQ 不支持 APNG,所以就把 APNG 转为 GIF 了,在使用 APNG 转换成 GIF 后,发现在微信上只能播放一次,就产生了如何批量修改 GIF 的循环次数的问题。

所以准备简单介绍一下 APNG。并提供了一个在线工具,可以将 APNG 批量转换为 GIF,但是该工具不能实现无限循环。所以分享了一个批量修改 GIF 循环次数的方法,使用了 Node.js 和批处理脚本两种不同的实现方式。方便 Node 开发者和使用 Windows 的普通用户直接批量处理。

APNG 是什么?

APNG(Animated Portable Network Graphics)是 PNG 的位图动画扩展,可以实现 PNG 格式的动态图片效果。APNG 相比于 GIF 在图片质量和细节表现方面更有优势,而且随着越来越多的浏览器对 APNG 的支持,它有望成为下一代动态图的标准之一。主要有以下区别:

  1. 图片质量:GIF 最多支持 256 种颜色,并且不支持 Alpha 透明通道,这导致 GIF 在色彩丰富的图片和含有半透明效果的图片上质量较差。而 APNG 可以支持更高质量的图片,包括更多的颜色和 Alpha 透明通道,使得动画效果更加细腻。

  2. 构成原理:APNG 和 GIF 都是由多帧构成的动画,但是 APNG 的构成原理允许超自然向下兼容。APNG 的第一帧是标准的 PNG 图片,即使浏览器不认识 APNG 后面的动画数据,也可以无障碍显示第一帧。而如果浏览器支持 APNG,就可以播放后面的帧,实现动画效果。

  3. 浏览器支持:从 Chrome 59 开始,Chrome 浏览器开始支持 APNG,使得大部分浏览器都能显示 APNG 动画。唯独 IE 浏览器不支持 APNG。

更多内容请参考:https://xtaolink.cn/268.html

APNG 批量转 GIF

该工具可以批量将 APNG 转为 GIF,不过不能无限循环。

https://cdkm.com/cn/png-to-gif

批量修改 GIF 为无限循环

bat(普通用户请使用该方法)

下面是使用批处理脚本(.bat)来实现相同的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@echo offsetlocal enabledelayedexpansionset "directoryPath=C:\path\to\directory"for /r "%directoryPath%" %%f in (*.gif) do (    echo Modifying %%~nxf    call :modifyGif "%%f")exit /b:modifyGifset "filePath=%~1"set /p data=<"%filePath%"set "index=!data:~0,16!"set "modifiedData=!data:~0,16!!data:~16,1!!data:~17,1!!data:~19!"echo.!modifiedData!>"%filePath%"exit /b

请将C:\path\to\directory替换为实际的目录路径。将上述代码保存为.bat文件,双击运行即可。脚本将遍历指定目录下的所有.gif文件,并对其进行修改。

请注意,批处理脚本的功能相对有限,无法直接读取二进制文件。上述脚本通过读取文件的第一行来模拟读取文件内容。在修改文件时,它直接将修改后的数据写入文件,而不进行二进制操作。这种方法可能不适用于所有情况,尤其是处理大型文件时可能会有性能问题。如果需要更复杂的二进制文件处理,请考虑使用其他编程语言或工具来实现。

Node(Nexmoe 使用的该方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
const fs = require('fs');const path = require('path');function unlimitedGifRepetitions(path) {  const data = fs.readFileSync(path);  const index = data.indexOf(Buffer.from([0x21, 0xFF, 0x0B]));  if (index < 0) {    throw new Error(`Cannot find Gif Application Extension in ${path}`);  }  data[index + 16] = 255;  data[index + 17] = 255;  return data;}function batchModifyGifFilesInDirectory(directoryPath) {  fs.readdir(directoryPath, (err, files) => {    if (err) {      console.error('Error reading directory:', err);      return;    }    files.forEach(file => {      const filePath = path.join(directoryPath, file);      const fileExtension = path.extname(file);      if (fileExtension === '.gif') {        try {          const modifiedData = unlimitedGifRepetitions(filePath);          fs.writeFileSync(filePath, modifiedData);          console.log(`Modified ${file}`);        } catch (error) {          console.error(`Error modifying ${file}:`, error);        }      }    });  });}const directoryPath = './path/to/directory';batchModifyGifFilesInDirectory(directoryPath);

请注意,上述代码使用了 Node.js 的文件系统模块(fs)来读取和写入文件。此外,需要将./path/to/directory替换为实际的目录路径。在执行该脚本之前,请确保已经安装了 Node.js。

该脚本将批量遍历指定目录下的所有文件,并对后缀名为.gif的文件调用unlimitedGifRepetitions函数进行修改。修改后的数据将写回原始文件。在控制台输出中,你可以看到每个已修改的文件的信息或任何出现的错误信息。

更多内容参考:https://www.b612.me/golang/232.html

更好的工具

这个批处理工具可以将多个 APNG 文件批量转换为 GIF 文件,并且可以对转换后的 GIF 文件批量设置为无限循环。

https://github.com/nexmoe/batch-apng2gif

累积布局偏移优化 CLS 完全指南

什么是布局偏移

一个十几秒的短视频解释清楚。

更详细的解释是:布局偏移指的是在网页上发生突然变化时,页面中的内容位置发生意外移动的现象。这种情况常常让人感到困扰,因为它会导致阅读中断或误操作。布局偏移通常是由于资源异步加载或动态添加到页面上的 DOM 元素导致的。可能的原因包括具有未知尺寸的图像或视频、字体与其备用字体渲染大小不同,或者第三方广告或小部件动态调整大小。

难受的是,网站在开发过程中的功能通常与用户体验有很大不同。个性化或第三方内容在开发中的行为通常与生产环境中不同,测试图像通常已经存在于开发者的浏览器缓存中,本地运行的 API 调用通常非常快,延迟几乎不可察觉。

什么是 CLS

累积布局偏移 CLS(Cumulative Layout Shift)是一个指标。

是对页面整个生命周期中发生的每个意外布局变化的最大布局变化分数的度量。

CLS 通过测量实际用户遇到布局偏移的频率来帮助解决布局偏移问题。它可以帮助开发者了解布局偏移在真实用户中发生的情况,从而采取相应的措施进行修复。

为什么要优化 CLS

布局偏移是一个非常影响用户体验的问题,通过上面那一个简短的视频也能理解。

布局偏移通常会导致意外点击、页面方向的迷失,最终导致用户受挫。用户往往不会逗留太久。有时也会使用户不按照预计的产品流程走。

通常优化好布局偏移能够很好的提高用户粘性、用户停留时间等指标。

Yahoo! JAPAN News 通过降低 CLS 0.2 分,得到如下成果。

如何降低 CLS

图片等媒体元素占位

在图像、视频等媒体资源元素中始终包含宽度和高度大小属性。或用 CSS 中的 min-heightaspect-ratio 或类似的方式保留所需的空间。

aspect-ratio

可以用来直接指定当前元素的比例。

https://developer.mozilla.org/zh-CN/docs/Web/CSS/aspect-ratio

对浏览器的支持:

padding-bottom

如果考虑浏览器支持问题仍然可以考虑使用目前一个被广泛接受的基解决方案 “Padding-Top Hack”。这个解决方案需要一个父元素和一个绝对型的子元素。然后计算出长宽比的百分比来设置为 padding-top。例如:

1
2
3
<div class="container">  <img class="media" src="..." alt="..."></div>
1
2
3
4
5
6
7
8
9
10
.container {  position: relative;  width: 100%;  padding-top: 56.25%; /* 16:9 Aspect Ratio */}.media {  position: absolute;  top: 0;}

使用不易产生偏移的 CSS

其中 transfrom 表现很好,以下举几个例子。
用例可以在这里找到:https://play.tailwindcss.com/26PxFA6UVI

zoom VS transform: scale

zoom 会撑大页面并向右偏移时,transform: scale 只是在原地放大。

margin VS transform: translate

margin 造成父元素变大,transform: translate 只是让当前元素移动。

border VS box-shadow

border 会撑起父元素,而 box-shadow 并不会。

小心你的懒加载

懒加载会引起布局的偏移,如果你在有懒加载长列表的里进行跳转,请小心!
无动画进行跳转,能够一定程度上避免该问题。

小心使用 transition: all

在页面首次加载或者跳转页面时,transition: all 可能会导致元素的 padding 等从参数为 0 开始渲染,照成页面的抖动。

这都是痛:
Commit:表格以及友情链接图标抖动
Commit:修复导航栏抖动问题

标签顺序导致的偏移问题

由于在移动端上优先展示主要内容,因此侧边栏的 markup 位于主要内容的后面;而在更大的屏幕上,则通过设置 CSS order 的方式进行排序,将主要内容移到中间(即第二列),伪代码如下:

1
2
3
4
5
6
7
8
9
export default function MainLayout(props) {  return (    <Container>      <Main className={css`@media screen and (min-width: breakpoint) { order: 0 }`} />      <Left className={css`@media screen and (min-width: breakpoint) { order: -1 }`} />      <Right className={css`@media screen and (min-width: breakpoint) { order: 1 }`} />    </Container>  )}

浏览器在首次绘制时并没有完整解析 DOM、只知道 <Main /> 的存在、但不知道 <Left /> 或者 <Right /> 的存在,才因此将 <Main /> 渲染进第一列而不是第二列;直到第二次绘制时,浏览器才将 <Main /> 渲染进第二列、将 <Left /> 渲染进第一列。

Chrome 并不是一次完整解析 HTML 的,在以下两种情况下,Chrome 会暂停解析、开始渲染和绘制:

  1. Chrome 解析器在读取了 65535 字节的 HTML 后暂停
  2. Chrome 在遇到 <script> 标签后,会继续读取约 50 个「Token」之后暂停

详细了解请看:优化博客的累计布局偏移(CLS)问题

网页跳转与前进后退缓存

默认情况下,所有浏览器都使用 bfcache,但由于各种原因,有些站点不适合使用 bfcache。有关如何测试和识别阻止 bfcache 使用的任何问题的更多详细信息,请阅读 bfcache 文章

在你离开后,bfcache 将页面保存在浏览器内存中很短的一段时间,所以如果你返回它们,那么它们将完全恢复为你离开时的样子。这意味着完全加载的页面立即可用,而不会出现任何变化。

现在的 SPA 应用也能很轻易的保证路由跳转页面布局的一致性。记住始终保持你的目录和导航栏在页面的固定位置。

字体

在下载和渲染网络字体之前,通常有两种处理方式:

  1. 使用网络字体替代备用字体(FOUT——未样式化文本的闪烁)。
  2. 使用备用字体显示“不可见”文本,直到网络字体可用并且文本可见(FOIT——不可见文本的闪烁)。

着两种方式都可能导致布局变化。即使文本是不可见的,它仍然使用备用字体进行布局。这意味着使用该字体的文本块以及周围的内容在网络字体加载时会发生布局变化,与 FOUT 的可见字体完全相同。

以下方法可以帮助你最小化这种问题:

  1. 使用 font-display: optional 可以避免重新布局,因为只有在初始布局时网络字体可用时才会使用它。
  2. 使用匹配度高的备用字体。例如,使用 font-family: "Google Sans", sans-serif; 将确保在加载"Google Sans"字体时使用浏览器的无衬线备用字体。如果只使用 font-family: "Google Sans" 而不指定备用字体,将使用默认字体,而在 Chrome 上默认字体是"Times",它是比默认无衬线字体的匹配度更差。
  3. 使用新的 size-adjustascent-overridedescent-overrideline-gap-override API 来尽量减小备用字体和网络字体之间的大小差异,详细信息请参阅“Improved font fallbacks”文章。
  4. 使用 Font Loading API 可以减少获取所需字体的时间。
  5. 使用 <link rel=preload> 尽早加载关键的网络字体。预加载的字体有更高的机会达到首次绘制,这样就不会发生布局变化。
  6. 阅读有关字体最佳实践的“Best practices for fonts”文章。

使用真正的骨架屏

骨架屏好坏示例

测量 CLS 分数

生产阶段

实验阶段

Lighthouse in DevTools

能够针对移动设备和桌面设备生成网页的实际性能报告,并能够提供关于如何改进相应网页的建议。

在本地开发期间从 DevTools 运行 Lighthouse 非常方便。

PageSpeed Insights

应该就是在线版的 Lighthouse。

Performance in DevTools

性能选项卡在 Chrome 的 DevTools 配置文件的所有页面行为在一段时间内记录。时间轴上会出现一个标记为“Experience”的图层,突出显示布局的变化和发生变化的元素。

Web Vitals extension

最好将 Web vital 扩展视为查找性能问题的抽查工具,而不是全面的调试工具——这是 Chrome 的 DevTools 中的性能选项卡的工作。

结语

作为一个对自己项目有较高要求的人,平常几乎都会接触到布局偏移优化或者 Lighthouse,只不过之前自己瞎折腾的时候还没有 CLS 这个概念,现在算是对 CLS 有了较为清晰的概念了。
CLS 作为一个非常基础的优化指标,在用户体验上非常重要,任何项目都应该针对 CLS 做优化。

如有勘误,请及时指出,感谢!

参考

  1. https://web.dev/cls/
  2. https://web.dev/optimize-cls
  3. https://developers.google.com/publisher-tag/guides/minimize-layout-shift
  4. https://web.dev/yahoo-japan-news/
  5. https://addyosmani.com/blog/infinite-scroll-without-layout-shifts/
  6. https://blog.skk.moe/post/fix-blog-cls/
  7. https://developer.mozilla.org/en-US/docs/Web/CSS/aspect-ratio

记peerDependency引发的多副本问题

你现在有一个包含许多软件包的单体仓库。你的公用组件包引入了一个第三方组件包(假设是antd),你的两个客户端包和也引入了antd,当然也引入了公用组件包。你在中使用了第三方组件包中的一个Context的Provider(假设你在做一个多端的React应用),你把代码在包下的这个Context的Consumer嵌套在这个Provider的下边。正常来讲,这个Consumer应当能够读到Provider提供的Context值。bug出现了,这个Provider并没能读到Context的值,就好像它的父级根本没有Provider一样。然而同样的代码在中就跑的通,这是为什么呢? 今天这篇文章将会讲述一件由pnpm的依赖解析机制引发的问题。pnpm解决了安装npm包的许多痛点,然而其一些安装策略有可能会导致一些奇怪的行为,并且一时间很难发现。如果你还不知道pnpm是啥,

来源

❌