相关文章:
上一篇的最后,我们列举了两个简单的逐个串行与并发执行的例子。不过日常实际开发中,我们还会遇到更复杂的场景——比如下载 300 张图片,上一篇中简单的写法就无法应对了。这次我们来说说如何更恰当地处理这类批量异步任务。
2023/12/28 - 更新,修复启动列表为空时
Promise.race()
无法 resolve 导致卡住的问题。
如果我们需要下载 300 张图片,该怎么处理呢?
下载 300 张图片之前,我们应该会有它们的 URL,存在一个数组内。
按照传统的同步编程思路,我们首先想到的大概是这个样子:
1 | const urlList = [/* 这是300张图片的 URL... */]; |
而实际上,由于浏览器内 JavaScript 执行与 UI 是阻塞的单线程模式,下载图片这种耗时的任务必须作为异步任务来处理,以免下载完成前阻塞 UI 线程导致页面完全卡死,让用户无法继续交互操作。
那么调用 downloadImage()
后,浏览器将会启动一个异步的下载任务,而下载完成状态将在回调函数中异步触发(而非启动下载的下一句)。
所以在我们上面的循环中,执行 downloadImage()
启动下载后将会立刻执行下一次循环,马上启动下一张图片的下载——也就是说,上面的代码将会瞬间发出了 300 个下载图片的网络请求。
呃,理论上说 300 张图片的下载任务之间没有前后依赖的逻辑关系,确实可以并发执行。
但是真的直接一把梭同时发起 300 个请求,先不论你的客户端性能是否可以 hold 住这样的瞬间高负载任务,就说服务器一下收到你这么多请求,也要把你当作恶意攻击服务器的黑客,直接给拉黑屏蔽了。不然每个客户端都这么来一出,服务器性能和带宽也要遭不住。
这么搞显然是不行的。
通过 Promise + async/await,我们可以将上面的写法稍作改动,用类似同步编程的思路,实现 300 张图片的逐个下载:
1 | const urlList = [/* 这是300张图片的 URL... */]; |
这里我们使用的是 for 循环而不是 Array.forEach()
,因为后者需要传入一个新的闭包函数来处理每个链接的异步任务,那这个闭包函数就需要使用 async 函数,那上面的函数就会变成:
1 | async function batchDownload() { |
而上一篇我们就已经知道,async 函数其实就是 Promise 的语法糖,所以这段代码的实际效果其实相当于:
1 | function batchDownload() { |
有些同学可能应该已经看出问题了,简单来说就是:每个下载任务内都是类似同步顺序执行(先打印“开始下载”,下载完成后打印“下载完成”),但所有 Promise 都在瞬间同时被创建,所以整个下载任务仍然是瞬间发出了300个请求。
而上一个方案里,使用 for 的写法看起来比较简单便捷,虽然取数组长度、递增和获取成员的代码有点啰嗦,但也可以使用 for-of 语法来简化达到类似 Array.forEach()
的便捷程度(不过会导致更难获取当前处理的下标序号 index):
1 | async function batchDownload() { |
但这样的写法也是有问题的,常见的 eslint 规则集(比如 eslint-config-airbnb)一般都会开启一条规则:no-await-in-loop
。
个人认为设置这个限制的大致原因可能有两个:
for-of
循环过程中,对原数组的成员增减操作将会影响循环的执行。项目规模较大时,某些意外流程可能因此使循环无法如预期结束而导致失控。因此,我们的理想处理方案应该是:
Array.forEach()
的便捷语法;为了让异步任务的效果更直观可见,我们先创建一个页面模拟异步加载的效果:
以下是页面代码:
1 | <!-- index.html --> |
1 | // index.js |
1 | // task-list.js |
如果不考虑并发的话,其实上面 Array.forEach()
方案的问题改一改就能解决问题了,突破口就是 Array.reduce()
:
既然之前每个数组成员的迭代结果都会变成 new Promise()
,而 Array.reduce()
可以将前一次迭代的结果传给下一次迭代。
那我们如果将它们结合一下,在每次迭代开始时先 await 前一次迭代的 Promise 完成,以此类推不是就能完成每个任务之间逐个等待完成,直到最终任务完成了?
1 | /** |
因为返回值是 await Promise 的 async 函数,可以省略最终的 await,所以还可以稍作简化:
1 | // index.js |
可以看到多个异步任务逐个进行,效果符合预期:
我们还可以对这个 iteratePromise()
做个小改动,实现类似 Array.reduce()
基于前一次迭代结果继续计算的效果:
1 | /** |
这个版本可以用于实现 对多个异步任务进行合计运算、基于前一个任务的结果调整后一个任务的运算策略 等效果。
上面的方案达到了异步任务批量串行执行的基本诉求,接下来我们就要考虑如何控制同一时间内允许指定数量的异步任务并行执行。
最简单粗暴的思路就是直接对上面的任务数组进行均匀切分,假如我们允许同时3个任务并发执行,那么:
Promise.all()
并行执行;iteratePromise()
串行执行。这个版本实际效果并不理想,这边就不实现了。
问题在于每个任务组内部分任务完成时,并不能马上开始下一组任务,下一组任务仍然需要等待前一组任务的所有任务完成后才能开始,策略过于僵硬。
所以,实际上每组任务都会存在一段部分任务完成后等待组内最慢任务的“偷懒”时间,而不是我们理想状态下每时每刻都有3个任务在跑的效果。
为了确保每一时刻尽量跑满我们所预期的并发数量,就需要视情况随时调整进行中的任务。这个动态调控的运行任务列表,我们暂且称之为 任务池。
在每个任务完成时,我们从任务池里剔除已完成的任务,加入等待中的任务,已维持全程并发数量都达到我们的预设数量(除非剩余任务数已经不足)。
这里我们使用 Promise.race()
来处理任务池,就可以在其中任一任务结束时进行响应处理,基本思路如下:
1 | /** 并发数量限制 */ |
顺带一提,关于 Promise.all()
、Promise.race()
和 Promise.any()
三者的异同:
Promise.all()
返回的新 Promise 将在传入的所有成员全部被 resolve 时才会被 resolve,在任一成员被 reject 时立刻 reject(浏览器兼容性 Chrome >= 32
);Promise.any()
则是在所有成员全部被 reject 时才会被 reject,在任一成员被 resolve 时立刻 resolve(浏览器兼容性 chrome >= 85
);Promise.race()
在任一成员被 resolve 或 reject 时,立刻返回相同结果(浏览器兼容性 chrome >= 32
);all()
和 any()
的策略类似于 成员结果间 &&
和 ||
运算作为最终结果,并且带短路运算的特性。而 race()
则是 竞赛机制,只看第一个结束的成员结果。为了方便搜寻和增减任务,我们把任务池 pool
的类型由数组改为 Record 键值对,然后再每个任务完成时返回自己的 key。
完成上面的 TODO 之后,我们的算法如下:
1 | /** |
使用上面的 eachPromise()
修改我们之前的测试页:
1 | // index.js |
看看效果:
OK,至此我们的目标终于基本达成。
上面的 eachPromise()
暂时只是最基本的核心算法,对于各种特殊情况都还没有做考虑:
run()
怎么办。……等等各种情况,都是我们需要考虑并处理的。
毕竟异步任务提高效率的代价,就是让编码更复杂,你需要考虑各种情况做好处理。
正如我们调侃多线程那句老话:“你有2个问题需要处理,通过使用多线程后,你在现个问6题了有。”
不过上面的两个小问题还不足为虑,前者只要在每个任务启动时的 .then()
中,增加 onRejected 情况的处理,也返回任务 key 就行了:
1 | // ... |
后者的话,可以在每次 race()
时保留当前 Promise 作为依据,判断任务已经执行时,就不再启动:
1 | export const eachPromise = (list, callback) => { |
经过上一步我们的修改后,虽然有成员任务失败后不再影响整个任务池的运作,但是在所有任务结束后,我们没法直到哪些任务被成功完成、哪一些失败了。
所以我们还可以再对于每次任务的执行结果进行记录,最后在结束所有任务后,像 Promise.all()
一样将执行结果以数组的形式返回。
最终我们相对完善的代码版本:
1 | /** |
调整之前的测试代码,模拟一个任务出错概率,并输出最后的任务执行结果:
1 | // index.js |
检查效果:
OK。
本文上面的代码对需要提示的数据类型进行了 JsDoc 标注,日常使用时,只要开启项目 tsconfig/jsconfig 的 checkJs: true
配置,就已经可以得到比较完善的 IDE 智能提示和纠错了。
不过如果想直接把上面的函数加入到 TypeScript 项目,那还是再稍微改写一下,提供 TypeScript 的版本吧:
1 | export const iteratePromise = async <T>( |
以上就是我关于使用 Promise 进行批量异步任务的并发控制处理的一点小思考和心得,希望能对大家有帮助或启发~
]]>欢迎关注博客地址: https://blog.krimeshu.com/
不知道大家是否还记得,在第二篇《PixiJS 修炼指南 - 02. 项目重构》中,我们创建第一个场景时曾经声明了一个名为 IScene
的场景接口,今天让我们开始实现场景管理器把它给用起来。
和 资源管理模块 (AssetsManager) 一样,我们同样以静态类的形式创建一个 场景管理器 (SceneManager) 出来:
1 | // src/services/scene-manager.ts |
然后,添加 app
、root
和 currentScene
三个基本成员,控制可访问性使它们对外只读:
1 | export class SceneManager { |
接着提供一个 initialize()
方法用于初始化配置上面的属性,并且在初始化时为 app.ticker
绑定回调,在这里调用当前场景的 tick 动画帧更新方法:
1 | interface ISceneManagerInitializeOptions { |
这里的 onUpdate
作用我们在之后的场景动画篇章里再细说,目前可以先当作占位处理。
最后,我们增加一个用于切换场景的方法 changeScene()
:
1 | export class SceneManager { |
在 changeScene()
方法中,我们主要做的事情就是确认场景管理器的可用状态后,将旧场景 oldScene
从场景根节点移除并销毁。
再将跳转到的新场景 newScene
挂载到根节点上,并且触发新场景的 onEnter()
生命周期钩子,对其传入新场景的进场参数。
目前我们 DEMO 项目的 src/ 目录结构不出意外的话,应该大致是这样的:
1 | src |
我们在 src/scenes/ 目录内创建一个启动场景 boot-loader.ts,并将 app.ts 内的 startGame()
改为切换到此场景,并约定一个 onAssetsLoaded
回调钩子,在这个回调里切换到 FirstScene 起始场景:
1 | // src/app.ts |
启动场景 boot-loader.ts 结构比较简单,目前就是一个基本的进度文案提示。
创建启动场景后,场景内将调用 AssetsManager 进行资源加载。加载过程中定期更新加载进度展示,完成后触发 onAssetsLoaded
回调:
1 | // src/scenes/boot-loader.ts |
这样,我们就完成了一个用于资源加载时展示的 启动等待场景 (BootLoader) ,并实现了加载完毕后通过 场景管理器 (SceneManager) 切换到 起始场景 (FirstScene) 的转场流程。
上面的 BootLoader
启动等待场景内,我们只使用到一个 Text
成员用于文本展示,实际项目中的场景肯定远非这么一两个小虾米就能搞定的,场景内用到的成员可能会达到几十甚至上百的数量。
这个情况下,直接将场景成员作为场景类的一级字段进行存放,结构难免将会越来越凌乱,甚至可能出现成员命名和场景的方法或者基类成员名字撞车的情况。
因此,我们推荐将场景的成员统一放入一个 members
字段,并约定其成员类型:
1 | // 【增加】场景成员类型 |
这样,如果成员类型发生增减并不会影响场景的一级字段和 constructor
构造函数内的代码复杂度,并且在创建和使用到场景成员的地方都能得到可用成员的类型提示辅助,便于开发时快速获取可用的场景成员。
如果我们创建的成员还有自己的事件回调,相关绑定处理的代码也可以提取出来,这里建议收拢书写为一个 events
字段处理。
比如,我们在启动等待场景内添加一个退出按键,设定对应的事件模式后,通过 on()
方法绑定对应的点击回调函数:
1 | interface IBootLoaderMembers { |
在 bindEvents()
方法内,我们再对场景内的成员进行事件绑定,这样创建成员时的定位、样式等调整代码,与回调事件的处理代码就不会混杂在一起;事件回调处理与场景的操作方法、生命周期钩子也不会混杂,互相之间的界线清晰明了。
相关资料:
- 关于 PixiJS 的交互事件(
mouse
/touch
/pointer
):https://pixijs.io/guides/basics/interaction.html- 关于事件模式
eventMode
的取值说明:https://pixijs.download/release/docs/PIXI.DisplayObject.html#eventMode
要控制场景的结构复杂度,除了上面的代码整理之外,对于较庞大的结构还需要抽取成为独立的场景子组件,再创建添加到当前场景内:
1 | // src/scenes/boot-loader.ts |
对于背景子组件,也可以使用上面的 members
和 events
代码组织模式来进行书写:
1 | // src/scenes/boot-loader/fancy-background.ts |
这样将复杂的场景模块作为独立的子组件拆分出来独立自治,单独维护自身的子成员和事件处理,就不会影响所在场景的一阶逻辑复杂度了。
当我们的子组件需要向它所属的场景或者其他父组件传递信息时,就需要用到事件通信了。
比如我们刚才为退出按键绑定的 pointerdown 事件回调函数,其实就是 PixiJS 的 DisplayObject 内部提供了一套基本的交互事件中的其中之一。
在组件内部通过 emit()
方法来发送事件,在组件外就能像上面一样通过 on()
方法进行回调监听了:
如果我们需要发送上面的事件名单之外的事件,比如我们创建了一个虚拟键盘组件,用户点击某个按键之后,我们向上级组件送出一个 virtual-key-down
事件,直接调用组件的 emit()
方法就会遇到 TypeScript 的报错——我们新增的事件并不在默认的 DisplayObject
通用事件列表内:
这个问题比较简单的解决方案,就是声明虚拟键盘的自定义事件接口 IVirtualKeyboardEvents
,然后用它为我们的虚拟键盘类创建一个自定义事件对象成员 customEvents
,之后只需要将自定义事件的发送和监听都交给这个成员来处理。
这个成员在组件的内部和外部都不会——也不应该——被修改,所以可以直接设置为 readonly
完全只读访问:
1 | import { EventEmitter } from '@pixi/utils'; |
这样就能正常发送我们自定义的新事件了。
而在上级组件内对这个自定义事件进行监听,绑定回调时也可以直接获得对应的类型检查和智能提示:
这次我们只实现了场景管理器的 转场控制 能力,没有什么复杂内容,只是完成了一个通用流程的提取,所以后面补充了一点场景写法上的建议。
之后我们将在此基础上,说说场景动画的控制以及画面尺寸适配的问题。
欢迎大家点赞收藏关注,期待下篇文章再见~
]]>上一篇中,我们实现的项目资源管理模块 AssetsManager 功能基本还只是雏形,这次我们来对它进行一些改进和加强,完善诸如对精灵表的支持、总进度回调这样的能力。
其实相比普通的 Sprite
精灵对象,PixiJS 官方表示更推荐使用 Spritesheet
“精灵表”。
它使用起来就像 Web 开发中的 CSS “雪碧图”,将许多的小图合并到一张大图内,再根据需求来控制展示大图的部分区域。
(顺带一提,其实“雪碧图”的原文 Sprites
精灵图,应该就是指早期电视游戏开发中就已有之的精灵图概念。只是 Web 开发的同学可能很多都是先接触到 CSS Sprites,再看到游戏开发的精灵图时反而有前者像后者的感觉。这波可以说“这爸爸长得真像儿子”了属于是。)
按照 PixiJS 文档中所说,SpriteSheets 至少有以下两个明显优点:
Spritesheet
内的素材更有可能在同义词调用内批量渲染,提高渲染效率。所以我们来改进一下之前的 AssetsManager,让它也支持快速配置 Spritesheet
资源的加载吧。
我们先用 TexturePacker 创建一个包含多个小图的精灵表素材,再将导出的 Json 和图片文件加入项目的 public/ 目录,随后就可以通过 Assets.load()
读取 Json 文件获得 Spritesheet
对象了:
1 | import { Spritesheet } from 'pixi.js'; |
其中,精灵表对象内的纹理素材都在 sheet.textures
字段下,以文件名和对应素材 Texture
的键值对形式存在:
1 | // 假如 test.json 的 frames 内有名为 minion.png 素材 |
并且 TexturePacker 还支持自动排列帧动画素材。只需要在制作时,将加入表内的动画帧文件名按照动画帧的顺序命名,工具即可自动识别。
比如,我使用 TexturePacker 制作了一个精灵表 Json 文件: https://hk.krimeshu.com/public/sheets/cat.json。
打开可以看到其中的 frames
下有 cat-01.png~cat-14.png 这些图片素材,同时后面的 animations
内出现了一个名为 cat
的成员,正是这些图片按顺序排列后的动画序列帧。
通过上面的方式读取它后,在 sheet.animations
里就会出现上面定义的动画帧序列 cat
,我们再通过它创建一个 AnimatedSprite
动画精灵:
1 | import { AnimatedSprite, Spritesheet } from 'pixi.js'; |
将其加入到场景内后,就可以轻松地看到动画效果了:
我们还可以做点小改动,为它加上加速和减速的效果:
1 | let acceleration = 0.05; |
就得到了一只会加速奔跑,并且间歇减速休息的小猫:
Gif 录屏的帧数不是很稳定,建议大家自行搭建项目后进行测试体验,或者访问我的 Demo 页面 https://hk.krimeshu.com/public/demos/running-cat/ 来查看效果。
上面这个例子我们使用 Assets.load()
加载了精灵表的 JSON 配置文件,再将加载结果通过 as Spritesheet
断言为精灵表类型。看起来这样已经为 sheet
对象明确了类型,实际还是过于笼统。
因为其中 animations
和 textures
成员的类型分别为 Dict<Texture[]>
和 Dict<Texture>
,IDE 只能知道它们是 Dict<>
却并不能推断出其中具体有哪些动画、纹理的 key 是真正可用的。
这样实际开发工作中将无法得到相关智能提示和代码检查,对于每个 JSON 配置提供了什么可用的动画和纹理都需要打开文件逐个确认,效率低下。而且还容易出现有人手滑写错键名的情况。随着项目规模越来越大,显然将会变得无比繁琐和混乱。
因此,我们将之前的 AssetsManager 继续改造,让它支持 Spritesheet
精灵表类型资源的加载和管理。
首先,对于 animations
和 textures
两类可用成员,我们分别定义好可用的键枚举:
1 | /** 素材表分包:奔跑的猫 */ |
定义对应的加载函数 SheetLoader
类型:
1 | /** 精灵表加载器 */ |
然后在 AssetsPacks
内添加精灵表类型的子包成员 SHEET_RUNNING_CAT
,并在 loadAllPacks
内调用加载函数进行加载操作:
1 | /** 资源总包 */ |
完成上面这些 config/assets-config.ts 内的类型和总包加载流程的修改后,我们还需要打开之前的 assets-manager.ts,真正实现 loadSheet()
的加载操作。
大家有没有注意到,上面对于精灵表的加载函数 loadSheet()
的参数表中,我们将其第二个参数 jsonList
的类型设定为 string[]
而不是 string
。
这是因为打包后的总纹理图其实有大小限制,分配置较低的设备上可能无法正常渲染单张尺寸过大的纹理图,所以像 TexturePacker 就推荐打包合并后的总纹理图样大小不要超过 2048x2048。这样就导致有些逻辑上属于同一分包的成员,可能最终会被拆分打散在几个不同的 JSON 配置内。
所以,我们在这里通过传入一个地址的数组,再将它们逐一加载后,再进行汇总合并处理。
这个过程相比 loadBundle()
会稍为繁琐一些,我们将其提取到一个单独的静态方法内,再在 loadSheet()
内调用它:
1 | // 管理器: src/service/assets-manager.ts |
如此一来,资源加载时就会自动将我们需要的精灵表分包和之前的普通分包一起加载完毕。
日常开发中,我们只需要在 IDE 内敲出分包的名字,就可以得到可用精灵表成员字段的智能提示了:
通常在各种游戏启动时,我们都能看到一个加载进度的提示,它能给用户一个完成预期,缓解等待交流。还可以用于推测自己的网络状况,判断是否遇到加载失败等异常情况。
PixiJS 基础的 Assets.load()
和 Assets.loadBundle()
方法其实提供了这样的进度回调,但它们都是基于这一次分包的加载进度进行计算的,对于我们这些分包组合加载的情况并无从知晓。
所以我们在它的基础上封装一个总进度回调函数,除了当前加载的分包进度之外,对于所有分包的数量、已加载分包的个数、正在加载的分包名字等信息进行汇总,再提供给最外层的回调所知晓。
如何实现呢?我们先在 service/assets-manager.ts 内将刚才提到的总进度定义出来:
1 | /** 总进度 */ |
然后,在 AssetsManager.init()
做个改动,创建上面的总进度对象,在每个分包的加载过程中更新这个总进度,通知其回调函数:
1 | // 管理器: src/service/assets-manager.ts |
其中 Assets.loadBundle(bundleName, onProgress)
已经支持分包进度的回调,但是对于 AssetsManager.loadSheet()
的进度回调,显然就需要我们自己来实现了。
不过其实也不复杂,因为精灵表分包的每个 JSON 文件的加载速度都很快,通过 Assets.load()
获得的进度回调基本只会有 0
和 1
两个状态。我们不如将精灵表分包的已加载 JSON 数量作为基准来计算精灵表分包的整体加载进度:
1 | // 管理器: src/service/assets-manager.ts |
这样,在之前的应用入口位置,我们就能获取到总加载进度了:
1 | // src/app.ts |
在 DEMO 项目内的运行效果:
目前,我们是在入口 startGame()
后静默等待直到加载完成才进入场景,这个加载过程如果较长的话,用户将会看到很久的白屏,显然体验相当糟糕。
既然已经能拿到所有资源的总加载进度了,那我们就可以开始动手创建一个启动加载场景,把资源加载进度输出展示给用户了。
这个启动加载场景只要注意以下几点就好:
<img/>
渲染出来。这两篇文章里,我们创建了自己的 AssetsManager
进行项目资源的加载和管理,除了方便我们日常开发时代码提示、运行时把控总加载进度之外,还有个意义就是提供了一个稳定的项目资源引入模式。
刚刚开始尝试的同学,或许会觉得每次引入资源文件时都要手动修改 configs/assets-config.ts 内的配置代码,未免有些太麻烦了,哪怕有上面提到的代码提示和加载进度把控的效果,好像也有些得不偿失。
但仔细观察 assets-config.ts 内的代码,就会发现其规律十分明显,基本与资源文件名和类型一一对应。因此——除了手动书写之外,你完全可以考虑通过项目构建流程自动生成资源配置表,避免所有机械的工作,又能兼顾代码检查和进度控制。
不过篇幅有限,而且不同项目的资源文件规范可能各有不同,这里就不细说了,大家按照自己的想法去实现就好。
期待下一篇文章再见~
]]>不知道有没有同学注意到,第一篇中我们创建精灵时使用的是 Sprite.from(textureUrl)
方法,但是第二篇重构后却改用了 Assets.load(textureUrl)
加载纹理,然后再设置到 this.texture
属性内来完成精灵纹理素材加载的。
这里的 Assets
是 PixiJS 提供的资源管理器,由它负责处理下载、缓存、转换等工作,将项目资源变成你需要的形式。
和其他 PixiJS 模块一样,虽然它的功能很强大,但使用起来还是有些骨感。对于日常开发还需要将其做一些封装和完善,改造为更方便的开发模式来使用。
Pixi.Assets
模块的前身是 PixiJS 6.x 之前的版本中的 Pixi.Loader
,经过改进完善后,它提供了更现代化的 Promise 风格 API。
Assets
模块工作时,在后台自动进行并发加载的控制调度,缩短加载时间,加快启动速度;缓存机制避免重复加载相同的资源,提高效率;可扩展的转换器系统,允许我们轻松扩展和定制更多需要的资源格式。
在没有添加第三方转换器的情况下,PixiJS.Assets
内部默认提供了以下几类资源的支持:
这些已经基本覆盖了我们日常开发的常见资源类型,更多资源类型的支持可以再针对需求找寻、实现相关的加载转换器,再加入项目中即可。
之前的例子中,为了更快看到 demo 的效果,通过直接访问一张我放在服务器上的图片,来作为精灵纹理的素材。
日常开发工作中,自然需要把用到的资源加入项目内,再进行打包整理和部署等处理。
这里我们直接将素材放到 Vite 默认的项目静态资源目录 public/ 内就好,先在其中创建大致的分类目录:
1 | ./public |
放在其中的资源,不需要经过打包处理,可以直接通过相对页面位置的路径来进行访问,方便我们到时候做配置统一化管理。
当我们向项目添加上面的三类资源后,希望可以实现快速地找到配置文件、方便地创建加载配置、开发时自动提示可用资源的效果。
为了实现这个效果,我们首先需要定义一下对应这些资源的 TypeScript 类型。
比如我们先定义一个资源总包 AssetsPacks,然后把项目中用到的资源粗略分为 GAME_AUDIO
游戏音频和 SPRITE_TEXTURE
精灵纹理两个子包:
1 | import { Texture } from 'pixi.js'; |
它们都是以 string
为键类型的 Record
对象,其值类型分别为 Sound
和 Texture
。
其中 Sound
目前并不包含在 PixiJS 的默认包内,记得手动额外安装一下 @pixi/sound
模块:
1 | npm i -S @pixi/sound |
这个情况下,我们使用 AssetsPacks
类的实例时,能得到第一级子包名字的智能提示,然而无法获得子包内部资源名的智能提示,使用起来还是有些不便。
不过没关系,因为我们定义子包的加载参数时,正好有个键参数可以用作提示。我们先用 enum
的形式准备好 GAME_AUDIO
和 SPRITE_TEXTURE
包的加载参数:
1 | /** 包参数:游戏音频 */ |
请参考上面的格式,在自己项目中准备几个对应的资源文件。
然后借助它们的键来完善子包 Record
键类型的约束:
1 | /** 资源总包 */ |
这样,我们在日常编码过程中使用 AssetsPacks
实例时,就能轻松地得到所有可用资源子包和子包内容的智能提示了:
类型定义得差不多了,我们来看看怎么加载上面的资源吧。
Pixi.Assets
提供的加载方法,除了之前 demo 里出现过的 Assets.load()
之外,还有一个就是用于批量加载的 Assets.loadBundle()
,以及两者对应的参数准备和后台预加载方法。它们的使用方式大致如下:
对于 Assets.load()
,可以直接根据指定 url 加载图片资源作为纹理素材:
1 | const url = 'https://hk.krimeshu.com/public/images/sprite-minion.png'; |
也可以通过 Asset.add()
添加别名,然后再根据别名进行加载:
1 | Assets.add('SPRITE_MINION', 'https://hk.krimeshu.com/public/images/sprite-minion.png'; |
还可以提前通过 Assets.backgroundLoad()
启动后台加载,再在需要素材的时候通过 Assets.load()
立刻获得预加载好的素材资源。
比如,我们首先在后台启动两种按键状态的纹理素材的预加载:
1 | Assets.add('BTN_DEFAULT', './images/ui/btn-default{png,webp}'); |
然后,在加载的过程中,我们就可以继续创建场景的准备工作,比如创建使用到上面素材资源的按键对象:
1 | // ... |
如果在这个准备过程中,后台预加载已经完成,此时我们就可以立刻得到预加载好的素材,提高加载效率。
同样,按键交互后的动态改变状态(比如按下),也可以享受到与加载方法带来的效率提升:
1 | // ... |
不过相比之下,我们还是推荐使用 Assets.loadBundle()
以发挥出 Pixi.Assets
的优势。
类似 Assets.add()
和 Assets.load()
之间的关系,Assets.loadBundle()
的基本使用方法如下:
1 | Assets.addBundle('UI_SPRITES', { |
同样,Assets.loadBundle()
也有对应的后台预加载方法 Assets.backgroundLoadBundle()
,使用思路与 Assets.backgroundLoad()
基本没什么区别。
通过这些方法组合,我们可以实现按需分批加载的效果,减少首屏加载的等待时长——通常减少首屏等待时长都可以减少首屏退出率,提高留存。
例如,在开发时将资源根据场景划分子包。这样就可以在启动应用时优先加载首屏需要的资源包,比加载整个应用的资源包体积更小、更快;等到用户进入首屏后,再在后台启动后续场景的资源预加载;最后,等用户通过交互跳去下一场景时,下一个场景的资源也就基本都加载完成了。
当然,上面这些都是理想效果,想真正使用在实际项目中,还需要对一些意外情况做好预测和兜底处理,比如:
这里我们在自己项目中来实现一个和 Pixi.Assets
一样的静态类对象:使用前不需要实例化,项目内共享同一个静态实例。
以往在 JavaScript 模式的开发中,可能会用一个全局的字面量对象来实现这样的效果。但这个传统的方式无法使用私有字段特性来封装自身内部使用的属性和方法,不方便做类型约束,且所有字段都公开暴露出来,使用时也容易受干扰。
所以我们通过 class 的语法来创建我们的资源管理模块(AssetsManager),将其所有方法和属性都设为 static
静态成员,这样无需实例化就可以使用它们了;并且记得将构造函数设为 private
避免被误操作实例化出其它实例,导致内部基于静态类设计的流程出现意外。
1 | // src/services/assets-manager.ts |
我们从最基本的流程开始,先不考虑分包优化,为 AssetsManager 增加一个 init()
方法,用于加载项目的所有分包资源。
在之前我们定义的子包参数,就可以在这里用于加载了,基本思路大概就是这样:
1 | /** 包参数:游戏音频 */ |
很显然,今后我们向总包增加分包的时候,并不想上下翻找 AssetsPacks 和 AssetsManager,在两个地方同时修改对应代码,这样太反直觉了,无法确保之后加入项目的同学也能正确掌握这里的修改方式。
所以我们做个小调整,将总包内部结构的定义和加载流程收拢在 AssetsPacks 类的静态方法中,但是通过借助 AssetsManager 提供的加载器来完成各个子包的加载。
1 | // 配置表: src/configs/assets-config.ts |
上面是资源包的配置,下面是使用这些配置来加载的具体流程逻辑:
1 | // 管理器: src/service/assets-manager.ts |
这样拆分后,两者分工更明了。放在项目的不同位置后,大部分时候我们只需要调整其中之一即可:
虽然从之前的代码提示看来,我们已经能拿到 AssetsPacks 内的成员字段,但那只是我们通过 as
关键字断言设定的成员类型。
相当于我们对 TypeScript 的编译器和 VSCode 的代码提示插件“打包票”:AssetsPacks 类里的这些成员一定是这样的类型。
实际上,如果我们不执行 AssetsManager.init()
做初始化的话,这些成员并没有真正加载完毕,只能拿到我们在 as
左侧设置的空对象。
所以我们还需要稍微调整一下之前 App.startGame()
,在里面调用 AssetsManager 的初始化,等待资源加载完毕后再进入后续场景:
1 | // src/app.ts |
项目资源管理模块 AssetsManager 的基本雏形就出来了。
篇幅所限,下一篇我们再继续完善它,为它添加 Spritesheet 精灵表资源的支持,并且实现加载进度的回调,敬请期待~
]]>在上一篇最后的例子中,我们写了一段代码实现一个简单的静态场景 demo,它跑起来了而且目前看起来也不复杂——从上到下写过来的代码,可以说是相当地“平铺直叙”了。
但是设想一下,随着项目开发的正式启动,其中的场景成员越来越多可能达到几十甚至上百以计的规模。而且通常游戏都不会只有一个场景,每个场景、成员之间的控制和回调代码相互交织,结果显然将会变成一团混乱的面条代码,彻底走向失控。
所以我们通常推荐在项目跑起来后,通过面向对象的方式将代码进行抽象归类,再通过启动入口、场景管理器等核心部分进行统一调度管理。以合理的代码组织方式进行项目重构,来达到各部分之间的界限清晰、分工明确的效果,确保项目的可维护性。
一般而言,我们的项目会有一个固定入口,我们会在其中进行项目启动时的初始化设定,这里的代码相对固定,而且不需要在具体业务逻辑中复用,我们将它们划分为 启动代码 (Bootstrap)。
我们通常会在这里做以下事情:
所有场景代码存放在各自的文件或目录内,将会存在很多份。但是场景之间的切换调度、缩放适配等逻辑只需要存在一份,而且这些逻辑内部关系较为紧密,所以我们将其提取出来,作为一个核心模块—— 场景管理器 (SceneManager)。
这个模块我们因为只需要存在一份实例,所以我们之后会将其作为静态类来实现,达到 不需要实例化、跟随应用全局的生命周期存在、业务代码内引入即可用 的效果,让之后的业务代码编写更加方便快捷。
对于每个不同的场景,我们将它们和内部的场景成员放在单独的文件或目录内进行开发。
每个场景自身的代码逻辑内部聚合,开发时按照推荐模式进行代码组织,这样在团队合作中就能更快速的理清场景代码结构,提高合作效率和之后的项目可维护性。
上面说的几个部分间,大致可以简单理解成这样的引用关系:
在我们的游戏过程中,各个场景和它们内部成员,都会按照具体情况反复创建和销毁,而且像是场景成员还有可能同时有多个实例存在。
所以我们通常不会一个个 new 出成员后再逐个动态调整它们的属性和方法。而是采用面向对象的开发模式,先根据我们的需求创建出具有定制的属性、方法的类,之后就能随时地将这些类进行实例化 new 出需要的数量,随时将它们 加入场景、监听回调、操作控制 或是 销毁回收。
比如上一篇中,我们在 demo 里直接通过 Sprite.from()
这样类似 new Sprite()
的“创建后再动态调整”的方式可以完成简单的需求开发,看起来似乎没什么问题:
1 | // 创建精灵成员 |
但如果我们需要给它增加左右移动的方法时,就需要这样来实现了:
1 | // 方法:向左移动 |
这时候,如果我们需要继续创建多个相同的精灵成员实例,就需要给每个成员都进行 moveLeft
和 moveRight
的“方法动态补完”处理,效率低下,而且代码零散。
而且事实上因为我们使用 TypeScript 开发,这样的代码将会直接报错:
1 | - 类型“Sprite”上不存在属性“moveLeft”。ts(2339) |
因为 TypeScript 作为强类型语言,并不允许在运行过程中动态地直接进行类型修改——毕竟静态类型检查无法预测这样的修改情况。
只能通过函数的形式来操作:
1 | // 外部操作函数:向左移动 |
但这样通过外部函数访问,只能操作到对象的公开属性,无法访问私有属性,影响封装效果。而且这种写法,无法直接通过对象成员的形式进行智能提示的辅助开发,显然不是个好办法。
这里推荐的写法是,将“可以移动的精灵成员”写成一个由 Sprite
派生的类 MovableSprite
:
1 | // movable-sprite.ts |
这样一来,需要创建更多成员的时候只要直接 new MovableSprite()
就行了。
而且每个 MovableSprite
示例都会自动加载纹理素材、设置锚点,并自动拥有定义好的 moveLeft()
/ moveRight()
方法可供直接调用。
在入口脚本使用它时的例子:
1 | // main.ts |
对于需要在销毁时回收资源的类,还可以重写 destroy()
方法,实现整个场景销毁时自动释放成员内对应资源的引用,确保不会再使用到的资源能被JS引擎垃圾回收,释放出占用的内存。
我们只需要在类里写下 destroy
的前面部分,VSCode 就会给出重载 destroy()
的智能提示:
这时候只需要光标切换到需要重载的方法位置上,按下回车键即可自动生成需要重载的方法格式。然后我们只需要在这个基础上再做调整,加上基类同名方法调用后,继续补充我们需要的销毁前资源释放处理就行了:
1 | export default class MovableSprite extends Sprite { |
刚刚说完了场景成员,现在该来看看场景了——所谓场景,其实就是用来容纳场景成员的容器。
所以我们通过继承 PixiJS 的 Container
类来创建场景即可。
不过除了容器本身的性质之外,场景一般还会有一些需要实现的特性:
这里我们通过全局的 type 定义文件内创建一个接口的方式来做约束。
我们先在项目的 src/ 目录下新增一个 types/ 目录,然后在里面新建一个文件,名字改为 scene.d.ts,内容为:
1 | // src/types/scene.d.ts |
这样我们就完成了一个名为 IScene
的场景约定接口,它要求实现该接口的类需要继承于 Container,然后还提供了 onEnter()
, update()
, onResize()
三个可选回调方法。
和之前的 destroy()
一样,我们需要重载这三个可选回调时,也可以通过智能提示来快速创建基本代码:
这三个方法的具体作用我们之后结合具体情况再细说,目前可以说只是先占个位。
接下来,我们再创建一个 src/scenes/ 目录,之后我们的所有场景都放在这个目录下。
比如现在我们创建一个 first-scene.ts,将之前入口脚本的简单场景内容转移到这里:
1 | // src/scenes/first-scene.ts |
这样就完成了我们第一个场景的定义,之后需要创建它时,只要随时 new FirstScene()
就行了。
同样的,我们的应用对象也使用这个方式从 PixiJS 默认的 Application
中派生出来,这里取名就直接取名为“我的应用” (MyApp
) 吧:
1 | // app.ts |
最后终于回到入口位置的脚本,我们只需要这样创建和启动刚才的应用:
1 | // main.ts |
至此,我们的项目重构工作算是暂时告一段落了。
完成这一切后,重新跑起来的项目效果看起来与之前相比,其实并不会有什么明显区别。
但是只要打开项目内部的文件查看,就会发现之前全部堆积在一起的代码已经井井有条:
import
只剩下引入基类 Application
和初始场景 FirstScene
,清晰明了;FirstScene
内使用 MovableSprite
时,就不需要关注内部的材质加载、锚点设定、移动方法实现等细节,只要使用即可;IScene
接口的约定,为之后实现场景管理器做好准备。如此一来,内部代码的可读性、可拓展性和可维护性都得到了质的提升,为之后的开发工作算是打了个相对稳固的基础。
之后我们将会再结合场景成员类型与事件管理、资源预加载、画面适配、场景动画和过渡动画等更多例子,继续完善这个项目结构,敬请期待~
]]>PixiJS 是一个使用便捷且高效的2D渲染引擎——没错,它不是大而全的游戏引擎,而是更轻量的渲染引擎。
这也使得它更专注于做好高效的2D渲染工作,给予WebGL高效渲染,实现上万对象渲染的粒子效果;同时也提供了更高的自由度,可用于做任何游戏类型的渲染层,甚至仅仅用于宣传页面的2D动画绘制。
同时,作为渲染引擎,它又比纯粹的 Canvas 使用起来更为便捷,可以直接通过操作 Sprite
、Container
、Graphics
等对象的属性完成画面中渲染效果的更新。
这样轻量易上手而又高效的渲染引擎,对于快速搭建轻量级的H5小游戏或者游戏 demo 来说可谓再合适不过。
而且,从2014年10月的第一个版本发布至今已过去近十年,仍然在不断更新迭代。2022年的 PixiJS v6 开始更是提供了 TypeScript 的支持,提供了内部对象更加方便的智能提示支持,也让大型项目使用 TS 开发后更加规范和可维护。最新的 v7 更是抛弃了各种历史包袱,更新到了现代化的前端项目生态,并且改进了一些历史 API(比如 interactive
),提供新的更深入优化项目性能的能力。
对于诸如骨骼动画、游戏滤镜、物理引擎、跨平台框架等需求,PixiJS 也有各种第三方工具、插件的支持,可扩展性也十分优秀。
这样优秀的工具,却可能因为官方团队人力有限无暇顾及文档维护,或是觉得都是基本的开发概念不需要再重新写文档赘述,官方的文档较为简陋,基本只是罗列 API 的参考手册。
对于之前没太多了解的新同学来说,上手可能要走不少弯路。
于是就想在个人学习的笔记基础上,梳理一个从基础概念开始的学习流程供大家参考,希望能对有需要的同学有所帮助。
首先,我们来搭建一个使用 PixiJS 渲染的游戏项目。
如果只是想快速体验,可以参考官方文档指南,在页面内通过 <script>
标签引入 PixiJS 的 dist 文件后,直接在静态项目内体验使用 PixiJS:
1 |
|
这一方式的优点是快速可用。
但缺点也很明显,没有构建环境的支持无法使用 TypeScript 等相关能力,也不具备 Tree Shaking 优化项目产物大小等前端构建项目的常用特性。
这一途径则是在现有的前端构建项目中,通过 npm/pnpm 安装 PixiJS,再 import 需要的模块到页面内进行开发。
优点是可以完整地使用所有 PixiJS 应有的能力,以及前端构建项目所具有的所有便捷特性。缺点是搭建最初的项目结构稍微需要花一点时间。
推荐使用 Vite 创建一个基本的 Vanilla + TypeScript 项目,再安装 pixi.js
和几个常用的 PixiJS 基本子包:
1 | $ npm create vite@latest my-pixi-demo |
然后清空项目的入口脚本(一般为 src/main.ts),修改为:
1 | import { Application } from 'pixi.js'; |
启动开发构建服务:
1 | $ npm run dev |
点击打开出现的开发预览页面链接,不出意外的话,就能看到游戏的画布出现在浏览器内了。
刚才我们搭建完项目后,创建了一个 PixiJS 提供的 Application
对象,它就是我们开发的 游戏应用 了。
只不过目前它里面空空如也,只是绘制了一个指定背景色和宽高尺寸的空画布。
接下来我们就要往里面加入各种成员,让它热闹起来。
1 | import { |
效果大致如下:
上面的例子中,除了之前提到的 Application
之外,主要有以下几个新面孔:
Text
Graphics
Sprite
以及 Application
的几个成员:
显然,Text
、Graphics
和 Sprite
将会是我们之后开发游戏常用的成员类型。其中的 Text
和 Graphics
顾名思义很好理解,就是 文本 和 图形。而 Sprite
其实也是它的字面意思“精灵”,它是具有图形材质和一系列属性、操作方法的成员对象,是我们在游戏中直接操作的基础单元之一。
如果查看他们的 type 声明就会发现,它们具有这样的继承派生关系:
符号
->
表示继承。
1 | Graphics -> Container |
可见它们都属于一个共同的祖先类别 Container
,而 Container
又继承于更原始的 DisplayObject
。
可推测 DisplayObject
是 PixiJS 中可用于绘制的 可显示对象,应该是渲染底层操作的基础单位。
而 Container
是在 DisplayObject
的基础上具有类似 Web 节点性质的树形结构对象。整个游戏需要绘制的成员,都以嵌套的树形结构最终挂载于 app.stage
这个顶级 Container
之下。
实际上因为 PixiJS 没有 CSS 的层级概念,绘制时其实就是按照遍历整个 app.stage
的树形结构,从上到下、从前到后 进行绘制,后绘制对象覆盖先绘制的对象 的优先级来决定层级覆盖关系。
Graphics
、Sprite
和 Text
则是在 Container
基础上,拥有更多特化后的绘制能力和操作方法的可显示对象具体子类。将它们的实例通过 addChild
加入到游戏的 app.stage
中,就会被 PixiJS 绘制出来,最终出现在我们眼前了。
1 | const text1 = new Text('...'); |
除了 app.stage
之外,上面还用到了 app.screen
和 app.view
这两个 Application
的属性。
通过查看类型定义,我们发现前者的类型是 Rectangle
,即矩形,对其的官方定义为:
Rectangle
对象是一个由它左上角的 原点(x, y)
和自身 宽度width
+高度height
定义的区域。
而 app.screen
就是我们整个游戏应用的矩形渲染区域,平时游戏中只有位于这个区域内的可显示对象才能被用户在页面上看到。
最后的 app.view
则是 PixiJS 应用的渲染器所持有的 Canvas 画布对象引用。
在我们的例子中,因为创建 Application
时没有传入画布对象,所以 PixiJS 内部会帮我们创建符合指定属性的画布,并挂载在 app
实例的 view
属性上。在这一切完成之后,我们最后将创建的 app.view
画布通过 appendChild()
加入到页面的 DOM 树内。
同样的,我们也可以不使用自动创建的画布,而是使用页面上已有的 Canvas 画布对象来创建 Application
应用对象:
1 |
|
1 | const canvas = document.querySelector('#cvsMyGame'); |
这个例子里,如果我们不将 canvas 的宽高传给 Application
的构造参数,PixiJS 将会用一个默认的尺寸创建游戏,并修改为 canvas 的新宽高。所以还是需要获取后赋值传入,稍显啰嗦。
如果我们的游戏是面向移动端设备开发的话,还需要增加一个分辨率参数,以适配高分辨率设备的像素密度:
1 | const app = new Application({ |
不过如果我们的游戏应用与网页视口的尺寸始终保持一致(即所谓的“全屏游戏”)的话,其实可以也不用传入这么多参数,只需要这样配置:
1 | const app = new Application({ |
通过 resizeTo
属性指定应用画布跟随网页窗口尺寸,还可以在用户屏幕旋转、调整窗口尺寸后由 PixiJS 自动调整画布尺寸,以适配用户设备的最新画面状态。
——不过页面内的成员坐标和尺寸并不会按新旧尺寸的比例进行调整更新,毕竟实际游戏场景的成员数可能相当多,而且不同成员的定位适配策略通常并不相同,还是需要在检测到对应 resize
事件后自行调整。
这次我们创建了一个基本的 PixiJS 游戏应用,并对一些基础概念进行了说明。
但这个基本 demo 中还是有不少东西没有说清楚,并且这个应用的代码也没有合理组织,之后我们将在这个基础上继续补充和完善。
如果有什么纰漏与谬误欢迎指出~
]]>\u00ff
这样转义处理的字符,而不是它们的明文原文。这是为什么呢?1 |
|
1 | import json |
以及,这个问题和今天要说的 MD5 转换编码的踩坑又有什么关系呢?
最近在小程序项目中,对于一些请求数据的防篡改处理,与后台约定了在表单内增加一个校验字段,为原始数据进行 MD5 换算后的结果。最初调试走通后没发现什么问题,就发布上线了。
之后陆续有部分用户反馈,偶尔发现部分页面无法正常显示,与后台同学定位发现这些用户请求时无法通过防篡改校验,导致被拦截策略误伤。只好对这些情况作了一些白名单的临时处理,待有时间时再彻底定位处理。
后来逐渐发现,出现 MD5 处理结果异常的用户,往往名字里有 emoji 或者生僻汉字出现,莫非问题和这些字符的编码方式有关——
通过与后台对比 emoji 的编码结果,发现两端确实出现了不一致的情况,看来这些字符确实是问题的充分条件了。
于是,在某次版本之后得以稍微喘口气的某个周末,开始阅读之前同事从网上找到的纯 JavaScript 实现的 MD5 模块源码——发现并看不懂,还得先找找 MD5 算法的原理,结合着参考对照阅读,终于大致明白了各个函数的作用。
经过阅读对比,发现目前项目里使用的这套算法中分组、线性函数 F(x,y,z)/G(x,y,z)/H(x,y,z)/I(x,y,z)
和16个分组的4轮运算过程看起来都没什么问题。
而在开始这些处理之前,有个对于输入字符串的处理函数,目前是这样写的:
1 | function encodeUTF8(string) { |
很显然,这个函数的作用自然就是“编码为UTF8”,顾名思义嘛——
不过这时候有同学可能就要问了:等等,我们平时在项目中写代码时,用的已经是 UTF-8 编码了啊?为什么还要再编码成 UTF-8?
这里就涉及到文件编码与 JS 引擎内部编码的区别了,有兴趣的同学可以阅读一下相关文章:
《Unicode 编码及 UTF-32, UTF-16 和 UTF-8》
没有时间自己详细阅读前因后果的同学,也可以参考一下我的理解(不保证绝对准确无误):
所以,这个模块在 JavaScript 的字符串进行 MD5 计算前,“尝试”将 JS 引擎内的 UTF-16/UCS-2 格式的字符串先转换成了基于 UTF-8 格式表示的 Unicode 字符,再将其对应编码值进行 MD5 计算处理。
这里举几个例子:
作为基础拉丁字母的 A,它的 Unicode 码点为 65(转换成16进制为 0x41
)。
其各种编码的16进制书写和在内存、硬盘等介质中2进制表现形式为:
编码 | 16进制 | 2进制 |
---|---|---|
UTF-8 | 41 | 01000001 |
UTF-16BE | 00 41 | 00000000 01000001 |
UTF-16LE | 41 00 | 01000001 00000000 |
UTF-32BE | 00 00 00 41 | 00000000 00000000 00000000 01000001 |
UTF-32LE | 41 00 00 00 | 01000001 00000000 00000000 00000000 |
可以看出,对于码点较小的字符,位于几种编码的第一段区域时,表现格式其实差别不大:
作为常用汉字的“我”字,码点 25105(0x6211
):
0x6211
没有超过两个字节,所以使用 UTF-16 相当于直接对 Unicode 码点做16进制编码转换,仍只占用2字节;0x800
~ 0xffff
),已经需要3个字节来进行表示了。编码 | 16进制 | 2进制 |
---|---|---|
UTF-8 | E6 88 91 | 11100110 10001000 10010001 |
UTF-16BE | 62 11 | 01100010 00010001 |
UTF-16LE | 11 62 | 00010001 01100010 |
UTF-32BE | 00 00 62 11 | 00000000 00000000 01100010 00010001 |
UTF-32LE | 11 62 00 00 | 00010001 01100010 00000000 00000000 |
而对于音乐符号,码点 119136(0x1D160
):
0x10000
~ 0x10ffff
),就算是使用 UTF-8 也需要4个字节来编码表示了;0xffff
的它需要借助 低位代理&高位代理 进行辅助表示,也使用了4个字节。编码 | 16进制 | 2进制 |
---|---|---|
UTF-8 | F0 9D 85 A0 | 11110000 10011101 10000101 10100000 |
UTF-16BE | D8 34 DD 60 | 11011000 00110100 11011101 01100000 |
UTF-16LE | 34 D8 60 DD | 00110100 11011000 01100000 11011101 |
UTF-32BE | 00 01 D1 60 | 00000000 00000001 11010001 01100000 |
UTF-32LE | 60 D1 01 00 | 01100000 11010001 00000001 00000000 |
汉字“𠀾”(这个字怎么读?我也不会读……查了一下,似乎是吴越方言,读“pēi”),码点 131134(0x2003E
):
编码 | 16进制 | 2进制 |
---|---|---|
UTF-8 | F0 A0 80 BE | 11110000 10100000 10000000 10111110 |
UTF-16BE | D8 40 DC 3E | 11011000 01000000 11011100 00111110 |
UTF-16LE | 40 D8 3E DC | 01000000 11011000 00111110 11011100 |
UTF-32BE | 00 02 00 3E | 00000000 00000010 00000000 00111110 |
UTF-32LE | 3E 00 02 00 | 00111110 00000000 00000010 00000000 |
中日韩常用的统一汉字文字区——基本多文种平面 内的
0x4E00
~0x9FFF
段落,其中基本含括了日常生活需要用到的常用汉字。在2000年、2001年、2003年、2010年、2015年、2017年、2020年扩充了 A、B、C、D、E、F、G 这7个扩充区,包括各种古籍、生僻字、日语汉字、喃字、急用字等更多汉字。顺便一提,“biang biang 面”的“biang”于2020年被收录到了扩充区 G 中(码点
0x30EDD
/0x30EDE
),不过目前大部分字体和客户端都还无法支持渲染显示。
Emoji(绘文字)“🐟”,码点 128031(0x1F41F
):
编码 | 16进制 | 2进制 |
---|---|---|
UTF-8 | F0 9F 90 9F | 11110000 10011111 10010000 10011111 |
UTF-16BE | D8 3D DC 1F | 11011000 00111101 11011100 00011111 |
UTF-16LE | 3D D8 1F DC | 00111101 11011000 00011111 11011100 |
UTF-32BE | 00 01 F4 1F | 00000000 00000001 11110100 00011111 |
UTF-32LE | 1F F4 01 00 | 00011111 11110100 00000001 00000000 |
现在,我们回过头来看最初那个 UTF-8 编码转换函数:
1 | function encodeUTF8(string) { |
先不论其中的位运算是否有问题,已经可以看出,这个算法的转换结果只有1~3字节这三种情况,不会输出4字节的结果——
所以,问题很明显了:它能处理常见的英文、汉字字符,但是无法处理结果为4字节的 0x10000
~ 0x10ffff
范围内的字符,其中就包括上面的音符特殊字符、汉字扩展B区、emoji 的几种情况。
找到问题原因后,就可以调整搜寻方向,比如找找其它的 UTF-8 转码的 JavaScript 实现方案。
其中就找到一个很神奇的方法,看起来真是意外的简单,甚至完全没有需要用到任何位运算处理: unescape(encodeURIComponent(string))
。
它真的有用吗,我们来试一试:
1 | const encoded = unescape(encodeURIComponent('🐟')); |
通过它处理“🐟”之后,得到的正是这个 emoji 的 UTF-8 表示形式:0xF09F909F
。
为什么会这样呢?其实原因不复杂,因为 encodeURIComponent
的转换是基于 UTF-8 进行计算的(估计是为了网络传输效率和常见服务器支持格式考虑而设计实现的),再将结果直接按字节地一一转换回 JavaScript 字符串,其中每个字符的 charCode 就变成对应 UTF-8 位置的结果了。
这个方案的问题在于 unescape
作为非标准函数,因为各种问题已经在很早时被宣布弃用(deprecated),我们最好还是找更为标准的解决方案,以免以后浏览器不再支持而无法正常工作。
原本的 encodeUTF8
函数中通过 String.prototype.charCodeAt()
操作原始字符串,得到的是根据 UCS-2 计算的字长、相当于 UTF-16 的编码,基于这个结果再转换成 UTF-8 最好是先还原成 Unicode 码点再操作。
而好在 ES6 中增加了新的基于 Unicode 码点的处理方式 String.prototype.codePointAt()
;而对于字长的处理则可以使用 Array.from()
,它将会正确按照每个字符进行拆分:
1 | '钓🐟'.length; |
所以转换处理的循环部分可以改写一下,这里主要是要注意:需要基于 Array.from()
的结果进行循环,以及通过 codePointAt
进行码点获取时,只需要传入每个字符的下标0位置即可:
1 | function encodeUTF8(string) { |
Unicode 码点段落 | UTF-8 编码 |
---|---|
0x0 ~ 0x7f | 0xxxxxxx |
0x80 ~ 0x7ff | 110xxxxx 10xxxxxx |
0x800 ~ 0xffff | 1110xxxx 10xxxxxx 10xxxxxx |
0x10000 ~ ... | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
通过上面的格式可以看出,Unicode 码点大于 0xffff
的字符转换成 4字节的 UTF-8 时,处理方式大致分以下几步:
xxx
和之后三组6位 yyyyyy
这样的4组二进制位;xxx
与 11110000
(0xF0
) 进行按位或运算,得到 11110xxx
的结果;yyyyyy
与 10000000
(0x80
) 进行按位或运算,得到 10yyyyyy
的结果。顺便把相关位运算的 <<
/>>
位移运算符改为 <<<
/>>>
无符号位移运算符。
最终算法如下:
1 | function encodeUTF8(string) { |
返回结果从字符串变成了更方便运算的类 byte[] 的数组,调用的位置也记得需要做相应调整。
在常见编程语言里,我们经常通过 x
的前缀来书写16进制编码,JavaScript 看起来也支持这个写法:
1 | console.log('\x41'); |
可以看出,\x
后面固定为两位16进制格式,即只支持表示单个字节。那么对于汉字这样多字节字符,比如“谢”字(UTF-8: 0xE8B0A2
, UTF-16BE: 0x8C22
),该怎样书写呢?
在 PHP 里,我们可以直接按字节顺序写出,最终打印出来的就是完整的汉字:
1 | echo "\xE8\xB0\xA2\xE8\xB0\xA2"; |
但是在 JavaScript 中,如果你按照这样的方式书写,就会发现打印出来的并不是你预想中的“谢谢”:
1 | console.log('\x8C\x22\x8C\x22'); |
为什么会这样呢?
通过输出结果我们可以看出,其中 \x8C
和 \x22
都被当成单个的字符进行处理了,而 JavaScript 中 UCS-2/UTF-16 的实现下,一个字符是以2或4个字节进行存储的。
所以,这里最终得到的字符串其实是: 0x008C
0x0022
,而不是 0x8C22
。
这种多字节字符的情况,就需要使用 JavaScript 提供的 \u
进行转义书写了:
1 | console.log('\u8C22\u8C22'); |
不过 \u
有着和 \x
类似的毛病,\x
后面固定为两位16进制,\u
后面默认则是固定4位16进制。
对于 “🐟”(UTF-16: 0xD83DDC1F
, Code Point: 0x1F41F
)就需要写成 \uD83D\uDC1F
,或者使用 ES6 提供的码点写法整个写入 \u{1F41F}
:
1 | console.log('\uD83D\uDC1F'); |
这种格式下,一个码点就是一个字符,各个字符的边界更直观,更便于阅读和调整。
最后,我们回到最开始的问题:服务器为什么以 \uXXXX
的形式返回汉字和emoji?
服务器对于诸如汉字和 emoji 这些多字节字符,返回 JSON 字符串的时候如果直接返回明文,其实返回的是自己运行环境下的编码实现。比如 PHP 返回 谢谢
时,发出的将会是 0xE8 0xB0 0xA2 0xE8 0xB0 0xA2
,对于 UCS-2/UTF-16 的 JavaScript 来说就变成乱码了。
而服务端实现 JSON 序列化时可能考虑到了这点,提前将其变为基于 UTF-16 的转义书写字符串字面量,这样浏览器内的 JavaScript 反序列化后就可以正常得到预期的 谢谢
了。
我的博客即将同步至腾讯云+社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=ortdhu8zxomu
]]>.epub
所需要的基本文件内容,并且梳理出可以通过工具自动完成的流程,以及需要补充信息来完成的流程。这次我们正式开始动手,编码实现我们的电子书生成小工具了。
创建一个目录 kepub
执行 npm init -y
,然后修改 package.json 文件,设置 "type": "module"
启用 ES Module 模式。
安装 eslint,以及我们选择的 airbnb 标准和相关依赖:
1 | npm i -D eslint eslint-config-airbnb eslint-plugin-jsx-a11y eslint-plugin-react eslint-plugin-import |
然后根据自己的需要,微调一下 ESLint 配置:
1 | # .eslintrc.yml |
我们选用 marked 进行 Markdown 文章的渲染,再通过 cheerio 对文章中使用到的图片等资源进行解析和收集整理。
最后的 zip 打包的话用 adm-zip 来处理,它基于纯 node.js 实现,不依赖原生程序,确保我们的项目即可直接运行,不需要对 win/mac/linux 做专门的适配。
1 | npm i -S marked cheerio adm-zip |
在项目的入口文件 index.js
中,我们约定传入的第一个参数为需要处理的电子书目录,其中存在对应 book.json
配置:
1 | // index.js |
上面是一些处理工作的基础参数检查,根据实际需要,还可以进一步补充详细的 book.json
格式校验,这里就不再赘述。
对于电子书的基础文件和 meta
信息部分,我们直接基于模板字符串配合传参就可以实现对应渲染函数。
比如渲染 package.opf 文件内容:
1 | const renderPackageOpf = ({ meta }) = ` |
如果有兴趣,还可以把其中的
id
,date
,modified
字段也改为自动生成机制,进一步减少创建电子书时的手动工作。
在处理流程中,只要调用上面的渲染函数,传入 book.json
的配置,即可得到电子书 package.opf
文件基本结构。
其中的 manifest
和 spine
部分还需要整个电子书渲染完成后的相关资源配置参数,这里暂时留空。
虽然上面的渲染函数已经可以工作了,但可以看出一个明显问题:
渲染函数内的字符串内容格式是 xml,但是在我们的代码里编写时,只会被 IDE 当成普通的字符串,没有任何代码高亮和校验处理。对其中的内容做修改调正时,如果发生误删字符之类的格式问题,没法在编码阶段快速发现。
所以我们在这里做个小优化,把上面字符串模板的内容提取到 templates/EPUB/package.opf.xml 文件内,然后再重新实现一个 render
函数:
templateName
,找到 templates 目录下对应的模板文件,读取为模板字符串。args
,将其中的字段解析后作为渲染参数注入到模板渲染函数 fn
内。fn
,返回最终文件内容。1 | // scripts/render.js |
这样,我们就实现了一个通用的模板渲染函数。
除了 package.opf 之外,之前的 mimetype 和 META-INF/container.xml 文件也可以提取为模板目录 templates 内的文件,在整个流程中传入对应名字就能完成它们的渲染了。
Markdown 的渲染需要提前做个转换处理,在传入要渲染的文件路径 filePath
,读取其内容后调用 marked
进行转换即可得到页面 html 内容。
我们创建一个书籍页面通用的 templates/EPUB/book-page.xhtml 模板,调用上一步中实现的 render()
即可渲染成 EPUB 内的标准页面文件:
1 | import fs from 'fs/promises'; |
模板文件 templates/EPUB/book-page.xhtml 内容:
1 |
|
在对电子书的处理过程中,我们需要根据 book.json 内的 pages
字段处理多个 Markdown 页面文件,并且保留它们的目录层级结构。而与此同时,每个页面文件内可能会引用多个图片资源。只有将页面和页面内引用到的资源信息进行汇总,最终才能生成全书的 资源清单、书脊 和 导航目录。
这就需要我们在过程中一边渲染和生成文件,一边整理相关信息。
所以我们在项目里创建一个 Task
任务类,每次任务就创建一个它的实例负责处理。在任务过程中,它会有一个属于自己的临时目录保存过程中的中间文件,可以在自己的实例变量中缓存的资源信息。最后由它统筹生成上面提到的基础信息,打包成书,随后清理临时目录。
1 | import fs from 'fs/promises'; |
之前我们在 render.js 模块内对于 Markdown 页面的渲染函数中,有个收集标题和图片的 TODO
,现在就到了把这个坑填上的时候了。
我们在 book.json 的 pages
节点内定义 title
字段,但实际书籍标题时往往还是和内容一起更新的。所以我们尝试读取文件内第一个 <h1>
标题的文本作为默认标题。这里使用 Cheerio 进行处理:
1 | export const renderMdPage = async (filePath, args = {}) => { |
对于页面内的图片,我们也可以这样通过 Cheerio 进行收集。
最后在返回值内告知外部任务实例,最终渲染的标题和用到的图片资源:
1 | export const renderMdPage = async (filePath, args = {}) => { |
这样,单个页面的底层渲染函数基本就完成了,接下来我们就要在 Task 内通过调用它实现整本书所有页面的渲染。
之前我们在 book.config 内定义的 pages
字段是一个树形结构,便于我们日常灵活调整和更新,但最终需要生成的资源清单和书脊却是一维线性的(与真实书籍的纸张排列一样)。
所以我们开始任务前,先将这个结构扁平化处理一下,这也会方便我们在后续过程中使用 async-pool
一类的库实现并发控制。并且我们对 list
内节点的引用的方式,保留原目录数据的基本树形结构,便于之后生成树形的导航目录。
1 | const flattenPages = (pages) => { |
接着,我们在 Task.run()
内调用上面的 flattenPages()
处理页面结构,然后为每条页面记录 href
页面链接字段:
1 | class Task { |
接着实现 Task.convertPages()
函数,处理上面的页面列表。
由于过程中有不少可以异步处理的 IO 操作,这里通过 tiny-async-pool 进行并发控制,节约整个任务的处理时间:
1 | import asyncPool from 'tiny-async-pool'; |
这样我们就实现了全书页面的转换生成处理,并且返回了全书用到的所有图片资源。
但这里其实还是有问题的:
我们先对图片资源相对目录路径做个转换,处理成相对 EPUB/package.opf 的项目路径,并且做去重处理。
找到刚才的 TODO: 修复相对路径
位置,将其改成:
1 | const isAbsolute = (src) => /^([^:\\/]+:\/)?\//.test(src); |
这样,我们就得到了图片基于项目目录的图片路径,或者绝对路径/网络路径。
这次我们创建 Task.copyImage()
和 Task.convertImages()
处理刚才的图片列表。
在前者中,我们通过传入的图片路径类型找到真实位置,做相应处理后返回 package.opf 文件中 <manifest>
内的 href
路径:
href
返回。1 | const COPY_CONCUR_RESTRICTION = 5; |
有兴趣的同学,页可以考虑尝试通过符号链接节省图片拷贝的成本;或者加入图片压缩处理,优化电子书体积。
最后,回到我们的 Task.run()
,在其中执行完 Task.convertPages()
和 Task.transportImages()
即可得到页面相关的资源清单 manifestList
基本内容了:
1 | class Task { |
实现了页面和图片的处理流程后,我们再来自动创建两个特殊资源:目录 和 封面。
前者我们可以根据之前的 pageTree
递归拼出目录部分的 html 结构,再通过通用的 render()
函数渲染生成,并加入到 manifestList
内:
1 | const parseToc = (toc) => { |
不要忘了加上目录页的特殊 attribute:
[properties="nav"]
。
对应模板 templates/EPUB/toc.xhtml 内容:
1 |
|
封面由图片资源和图片页面两部分组成,前者直接转移图片后加入 manifestList
即可,后者也通过模板渲染处理:
1 | class Task { |
对应模板 templates/EPUB/cover.xhtml 内容:
1 |
|
经过前面的所有处理后,manifestList
内已经集齐了书籍内需要的所有资源基础信息。
我们再对其稍加处理,通过 media-types
查询各个资源的媒体类型 (MIME):
1 | import mimeTypes from 'mime-types'; |
现在,我们可以完成最开始的 EPUB/package.opf 模板文件,实现资源清单 <manifest>
和书脊 <spine>
的渲染了:
更新模板 templates/EPUB/package.opf.xml 内容:
1 |
|
最后在 Task.run()
中,将任务目录打包为 .epub
文件并在完成后清理任务目录:
1 | import AdmZip from 'adm-zip'; |
至此,我们便完成了一个可以将已有 Markdown 文集、经过简单的配置后转换成 .epub
电子书的工具。
本文完整 DEMO 地址:https://github.com/krimeshu/kepub/tree/v1.0.0-beta.2
有兴趣的同学可以 clone 下来后,
npm test
查看效果。
我们的工具目前处于“能用”的阶段,日后可能还要根据更多更复杂的实际情况,做相应调整和完善。
再或者优化现有的流程,如:
其中,由于 EPUB3 中增加的对于 HTML5 的支持,我们可以通过加入触发器和脚本,实现类似互动电子书、AVG 文字冒险游戏的效果,极大地增强互动性。
虽然对于 EPUB3 标准完整支持的电子书阅读器,除了苹果家的 图书 外暂时还没有几个,但可能随着以后设备性能、软件支持的普及,通过电子书实现这样效果的日子或许终会来临。
有兴趣的同学也欢迎参与到本项目的开发中来~
]]>
不知道大家平时有没有阅读电子书的习惯,这里指的并不是 .txt
的文本文档,而是通常带有精美封面、便捷目录、图文并茂的 .epub
电子书。它是怎样实现这些效果的呢?我们能不能把自己平时用 Markdown 写的技术笔记、博客文章做成一本属于自己的电子书呢?
EPUB 格式是什么
其实做 Web 开发的同学,如果把 .epub
文件通过 zip 打开后就会发现,其实它并不神秘,反而相当开放直观和熟悉──其内在就是一堆 xhtml 页面、css 样式、图片,以及描述这些资源关系的 xml 配置信息,把它们一起打个 zip 包就是 .epub
电子书了。
在我们解压出来的文件,往往会有一个 .opf
文件,内容开头一般是:
1 |
|
我们只要访问命名空间属性中的这个 http://www.idpf.org/2007/opf 链接,就可以查询到关于这个 OPF
电子书的所有规范描述了。
简单来说,这就是一个由国际数字出版论坛和 W3C 组织一起完成的开放电子书标准,在 2007 年 9 月取代了之前的 Open eBook,被国际数字出版论坛选为新的正式标准。
既然 epub 内部就是 html 页面,我们的 Markdown 文章也能编译成 html,那我们写个工具将以往的文档处理成符合 epub 标准的文件包,不就可以做一本自己的电子书了?
我们先创建一个 example 目录,其中包含 META-INF 和 EPUB 两个子目录。然后在现有目录结构中创建 mimetype, META-INF/container.xml 和 EPUB/package.opf 文件:
1 | example |
文件 mimetype 内容:
1 | application/epub+zip |
文件 META-INF/container.xml 内容:
1 |
|
文件 EPUB/package.opf 内容:
1 |
|
以上就是我们的 .epub 文件中最基础的三个文件。
其中 package.opf 中,我们在 package > metadata
内定义了一些 .epub 必备的元信息。以后我们向电子书添加内容时,还需要根据实际情况继续更新其中 package > manifest
资源清单 和 package > spine
书脊 的相关信息。
接下来就是向其中添加内容了。
在之前的基础上,我们再创建一个 EPUB/book 目录,在其中添加一个 EPUB/book/page-1.xhtml 文件:
1 | example |
文件 EPUB/book/page-1.xhtml 内容:
1 |
|
然后修改 package.opf 中的资源清单和书脊:
1 | <manifest> |
对于刚才的页面,我们创建了 package > manifest > item
条目,标记了它的 [media-type]
类型,并且设定了一个 [id="page-1"]
属性,将其以 itemref[idref="page-1"]
的形式在书脊内进行了引用。
此时,如果将 example 目录的内容进行 zip 打包,生成文件名称改为 example.epub,就已经可以在一些 epub 阅读器中正常打开进行阅读了。但部分基于导航目录进行内容索引的阅读器(比如 微信读书)还无法正常浏览,需要再做一点小小的改动。
我们再创建一个 EPUB/toc.xhtml,内容:
1 |
|
注意其中的
nav[epub:type="toc"]
,这是 epub3 与 epub2 的区别之一,可以将目录页面的部分作为书籍的导航目录,不再需要单独提供.ncx
文件。
同样的,继续修改 package.opf 的资源清单和书脊:
1 | <manifest> |
其中 item#htmldoc
添加了 [properties="nav"]
表示这个页面是导航目录。
这次,再将 example 目录内容打包为 example.epub 后,就能在大部分阅读器内都正常打开了。
我们还可以给自己的电子书添加一个好看的封面,比如:
将其保存为 EPUB/images/cover.jpg,然后创建 EPUB/cover.xhtml,内容为:
1 |
|
老规矩,继续更新 package.opf 的资源清单和书脊:
1 | <manifest> |
这次,增加的图片文件也需要登记在 package > manifest
资源清单内,并且添加了 properties="cover-image"
属性,将其标记为书籍的封面图片。
而创建的 cover.xhtml
文件,是为了让我们在打开书籍后,也能在内容内看到封面的效果。
如果我们需要在电子书内,添加更多页面、引用更多图片、添加装饰样式、改用自定义字体,也是相同的操作:
manifest
资源清单。[id]
引用到 spine
书脊内,并且更新 toc.xhtml 内的 nav
记录。nav
导航目录,支持 ol > li > ol > li ...
嵌套实现多级目录。nav
导航目录使用 ul
无须列表。ol
/ li
设置 [hidden=""]
属性,进行隐藏处理。基于上面的原理,我们已经能够开始手动编写我们的电子书了。
不过这个过程中还有很多手动操作并不便捷的步骤,比如 每篇文章进行 Markdown to Html 转化、文章中所有图片添加到资源清单、更新文章目录结构 ,如果文章页面、引用资源稍微多一些,就基本没法手动处理过来了。
所以我们需要更高效的自动处理方案。
刚才提到的资源清单内容,大致可以分为两类:
其中,后者可以直接在 Markdown 文档渲染成 html 文件后,进行 html 解析再对所有 img
标签进行汇总即可得出配置列表。
前者可以基于 .md
文件本身的目录结构进行资源列表的整合,但是 对于页面在书脊和导航目录内的顺序 无法进行很好的控制。
如果基于文件名进行排序,相当于引入了一套不可控的潜规则,对于书籍迁移、页面删减维护都不太方便。而且如果需要处理导航目录内隐藏、重新引用的场景,还要引入更复杂的潜规则。
不如增加一个简化的 .json
配置文件,统一管理页面在导航目录内的顺序和层级关系。
我们重新创建一个 new-book 目录,并在其中创建一些子目录和文件:
1 | new-book |
文件 book.json 内容:
1 | { |
文件 chapter-1/index.md 内容:
1 | # Chapter.01 |
文件 chapter-1/ep-1.md 内容:
1 | # Episode.01 |
这样,我们日常创建一本电子书时,真正需要自己定制的内容基本就已收录其中。而且能方便地在其中定义和调整页面的顺序和层级关系,控制对应条目是否在导航内隐藏了。
接下来,我们只需要再编写一些脚本,将上面的结构自动转化成电子书需要的 mimetype
, container.xml
, package.opf
和各种页面文件,并且汇总对应的资源清单、书脊和导航目录。
过去通常是通过传递回调函数的形式使用,如今我们通常使用 Promise,配合 async/await,让日常这些异步处理方便了很多。
不过对于刚接触 Promise 的新同学来说,日常可能只接触和使用过其中比较基础的使用形式,又没有花时间去了解其中的实现原理,这就可能会导致一些错误理解和反模式实践。
这里将平时遇见过的问题列举出来,结合自己的理解,希望能帮新同学们绕开一些可以避免的坑。
个人认为,Promise 是一种 可链式触发的单向异步任务单元。
进行中
、已完成
、已拒绝
。进行中
,可 单向流转:进行中
→ 已完成
/已拒绝
;不可以逆向流转。进行中
状态下,可以通过它的 then(onResolved?, onRejected?)
函数指定 完成/拒绝状态回调函数。已完成
状态;已拒绝
状态。上面是 Promise 基本概念,看起来似乎“平平无奇”。然而它又通过以下机制实现了链式触发的效果:
then
函数中,将自动创建另一个临时 Promise 实例:已完成
状态。而其中 then
函数的状态回调函数还存在特殊情况:
then
的两个回调函数参数中,不存在对应当前 Promise 状态的回调函数时:这样,我们就可以在日常开发中通过 then
不断地链式创建临时 Promise,让我们的多个异步任务按照预期地逐个触发了。
async/await 被我们日常作为 Promise 状态回调函数函数的语法糖使用。
1 | function createTask(factor) { |
上面的 work
可以使用 async/await 改写为:
1 | const work = async () => { |
只需要对 Promise 实例使用 await
操作符,就可以将异步任务的后续处理方式从嵌套的回调函数,彻底改变成仿佛是顺序执行的相同层级语句。甚至还可以使用 try/catch 同时捕获异步任务前后的异常。
尤其是对于多个异步任务逐个执行的情况,代码会简单和清晰很多,减轻业务开发中不必要的思维负担。
而对于暂时不支持 async/await 的浏览器环境,可以通过 babel+regeneratorRuntime 对项目代码进行转换,从而在日常开发中放心的使用这项新语法糖。
对于初次使用 Promise 的新手,可能会因为不知道可以在 then
回调内直接传递新的 Promise 作为 结果值,从而把 Promise 当作过去的回调函数使用,重新陷入回调地狱:
1 | // Bad: |
得益于 Promise 递归等待的机制,我们可以直接在最外层的 then
后面链式追加后续任务,并不需要反复嵌套:
1 | new Promise((rs) => { |
当然,还可以使用 async/await 处理:
1 | (async () => { |
新同学使用日常使用 Promise 时,可能并不会留心给每次 Promise 调用的最后加上 catch()
进行异常捕获。
或者直接使用 try/catch
尝试捕获 Promise 异步任务和状态回调内的异常,发现没能如预期地捕获到。
这是由于 Promise 的异步函数执行时,已经脱离创建时的调用栈,其内部发生的错误没法直接被调用时的 try/catch
捕捉到。
可以通过以下例子模拟类似的情形:
1 | function doItLater(fn, delay) { |
将
doItLater()
中的setTimeout(fn, delay)
改为fn()
同步调用,就能在外层捕获到异常。而 Promise 的状态回调并非同步执行,所以无法在外层直接捕获异常。
对于异步任务,我们需要通过 catch()
进行异常捕获,以便在外层做好任务被拒绝或者其它意外的处理:
1 | new Promise((rs) => { |
不过 catch()
只能捕获到 Promise 内部的异常,如果需要同时捕获异步任务之前的某些同步处理异常,还得把相同的异常处理再用 try/catch
写一遍:
1 | try { |
相同的异常处理写了三遍,有些可怕……不过上面的例子有点刻意了,doSomePreprocessing()
其实可以放在 Task start 相同的 try/catch 里。
但有时候也不一定能这样重新组织代码,不如直接使用 async/await
避免这样的冗余情况:
1 | (async () => { |
日常开发中,如果涉及到多个异步任务的情况,新同学可能没有多想就直接使用 await
让它们逐个执行了:
1 | (async () => { |
然而稍微观察就会发现,上面的请求的数据中可能存在前后依赖关系的情况,但也有不少可以并行处理的数据。
而让所有请求一股脑排队串行处理,既浪费现在日新月异的终端性能,又浪费用户宝贵的等待时间,未免有些暴殄天物。
对于并行处理的任务,我们可以使用 Promise.all()
方法:
让我们用它重新组织上面的异步任务,提高一下页面效率吧:
1 | (async () => { |
除了 Promise.all()
,还有两个类似的 Promise.race()
和 Promise.any()
方法。
Promise.race():
Promise.any():
注意! Promise.any() 方法依然是实验特性,尚未被浏览器完全支持。
对于类似 IO 任务的情况,可能需要反复确认完成进度的情况。
直接封装为只有开始结束态的 Promise 的话,会让用户长时间等待中无法获得任何感知,用户体验较差。
需要配合传统回调函数,结合具体的业务需求和页面交互进行实现。
推荐仔细阅读:Jiasm 的 《微任务、宏任务与Event-Loop》 - https://juejin.cn/post/6844903657264136200
在 Promise/A+ 的规范中,Promise 的实现可以是微任务,也可以是宏任务。不过普遍的共识一般将 Promise.then
的状态回调作为微任务实现。
相比之下,setTimeout
的宏任务将会在同一批创建的 Promise.then
微任务之后执行。
之前我们学习了 useState
和 useEffect
两个基础 React Hook。
通过它们,可以实现以前的类组件的大部分功能:属性值传入、自身状态维持、状态更新触发、生命周期回调。
并且让你可以:
一切看起来都很美好,虽然我们基本还不知道这两个 Hook 内部是怎么样神奇的实现了维持状态和生命周期回调,但通过简单的项目 Demo 就能看到它们确实按照我们预期的效果跑起来了。
去深挖黑盒的内部构造也是很有意思的,不过现在还为时尚早。
为什么?不只是因为还有其它 Hook 没有讲到,而且现有的两个 Hook 我们也没有彻底理解。
只需要对之前的 Demo 稍微做一点小修改,出乎你预料的麻烦事就要发生了……
我们将之前 useState
的例子做个小改动,将点击计数 count
改为渲染次数计数 renderCount
。
然后设置一个副作用,不传入依赖数组,使之在每次渲染完成后都执行,执行时将 renderCount
加一来实现计数功能:
1 | function App() { |
将例子跑起来后,你就会看到——页面上的 renderCount
计数在不停地疯狂飙升,控制台里也出现了来自 React 的警告:
1 | Warning: Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render. |
为什么会这样?我们看看刚才的副作用:
1 | useEffect(() => setRenderCount(renderCount + 1)); |
组件渲染完毕后,副作用中的 setRenderCount
会导致 renderCount
这个 state 的变化,从而触发组件重渲染。而重渲染又会再次触发 setRenderCount
……从而无限循环触发,导致运行的情况与我们想要的效果不太一样。
也就是说,我们避免 renderCount
这个 state 触发渲染就能解决问题了。
添加一个依赖数组,对于组件内除了 renderCount
之外的其它 state 发生改变,再执行副作用就能达到这个效果。
不过目前除了 renderCount
之外,不存在其它 state,所以我们的依赖数组现在是空的。
假如增加一个名为 title
的 state:
1 | const [renderCount, setRenderCount] = useState(0); |
这里其实还有个隐患,某些情况下直接使用 renderCount
取到的可能不是最新值,最好还是通过回调的方式取到最新值再处理:
1 | useEffect(() => setRenderCount(renderCount => renderCount + 1), [title]); |
但这样终究有些繁琐,每次增加 state 后找到这里添加依赖只是一项潜规则,参与项目的人越多、修改次数越多,出错的概率就越大。
之所以 renderCount
能触发渲染,是因为它是个 state,所以如果它不是 state 不触发渲染就能解决问题了?
1 | const renderCount = 0; |
这样写的话,renderCount
的改变确实不会触发渲染了,但同样它也没法按照我们的意愿改变了——
函数式组件本身相当于 render
,每次组件重新渲染都会被执行,而 renderCount 作为其中一个普通的局部变量,每次都会被赋值为 0 而非上一次修改的值。导致不管重新渲染几次,页面上的计数始终为0。
正确的方法是使用另一个 Hook —— useRef
:
1 | function App() { |
这样,就算增加别的 state,也不需要修改现有代码即可保持逻辑的正常执行。
此外,我们还可以直接使用
useState
保持一个对象状态,再通过其中的子字段实现计数,原理与useRef
一样。但是需要注意setState
时必须使用原对象而非新对象(比如使用解构赋值创建新对象),否则会导致此对象的 state 依赖对比不通过,触发重渲染从而又导致无限更新。
问题的根本在于副作用内更新 state 时,state 的变化直接或间接地影响了副作用自身的触发条件,从而导致副作用被无限触发。
想要尽量避免这样的情况,需要遵循以下原则:
eslint-plugin-react-hooks
插件,辅助开发。正好最近有个项目改用了 React 的,于是趁机体验了一下 React Hooks,看看是否真是如此。
说来惭愧,上次使用 React,还是几年前想在 React 项目里想要实现组件样式作用域,对比和选择 css-modules 和 styled-components 方案来着,最终实现体验还是不怎么样,后来大部分项目都是小程序、Vue 和 node.js 印象就还停留在那个年代。
那什么是 React Hook 呢?官方的介绍如下:
Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。
其中的 class 指的应该是 ES Class 也就是类语法,而 state 应该就是指平时通过 setState
更新状态来触发重渲染的组件 state
属性了。
并且官方保证它 没有破坏性改动:
React Hook 是:
- 完全可选的,可以轻松引入。如果你不喜欢,也可以不去学习和使用。
- 100% 向后兼容,React Hook 不会包含任何破坏性改动。
- 现在可用,Hook 已发布于 v16.8.0。
第一条说明官方并不强制要求使用 React Hook。第二条则是说明,使用它不会影响旧版代码,确保存量项目代码的正常工作。
至于支持 Hook 的 React 版本,大约发布于2018年底。到本文的2021年初算来,差不多已经过去两年时间了。
不过需要注意 React Hook 的使用规则:
- 只能在 函数最外层 调用 Hook。
- 只能在 React 的函数组件 中调用 Hook。
第二条很好理解,毕竟是为函数组件所设计的,第一条究竟为何,没有实际体验也很难说清楚,我们容后再叙。
既然已经出来两年之久,这个 React Hook 实际使用起来究竟效果如何呢?
比如一个简单的点击计数示例,其中使用到一个计数 state,在每次点击后将其 +1 后更新视图:
1 | import React, { Component } from 'react'; |
如果使用 React Hooks 实现,就只需要这样:
1 | import React, { useState } from 'react'; |
这个例子中可以看出,React Hook 相比组件类:
useState
的 Hook 来实现。其它生命周期函数我们稍后再叙,先来看看一些上面的例子没有提到的情况:
对于组件 props 的获取很简单,函数组件的第一个传入参数就是了:
1 | function Child({ name }) { |
对于简单的值类型 state,直接使用 useState
返回的更新函数就可以轻松完成更新了。
对于数组和键值对(对象)类型的数据,又该怎么更新呢?
难道直接把整个新的数组/对象传入更新函数?
——没错。
不过这样操作可能会稍显繁琐,因为必须传入一个新的数组/对象才能触发更新。直接修改原对象后直接传入更新函数的话,并不会触发重渲染。
所以我们需要创建一个数组/对象的拷贝,再传给更新函数,通常可以使用ES6数组方法和解构赋值对操作稍作简化:
1 | function Example() { |
但对于更复杂些的情况(比如对象数组),这样还是不太方便,不过也没关系后面会有处理这类情况的其它 Hook。
使用 Hook 实现的函数组件(function component),其函数本身执行时机相当于 render
函数,执行较早。
对于日常开发中常用的其它生命周期,通常使用 useEffect
Hook 实现。这里的 effect,官方称呼为“副作用”:
数据获取,设置订阅以及手动更改 React 组件中的 DOM 都属于副作用。不管你知不知道这些操作,或是“副作用”这个名字,应该都在组件中使用过它们。
它的第一个参数是个回调函数,称之为 副作用函数:
1 | function Example() { |
这样写的时候,副作用函数会在函数组件的每次 DOM 更新完毕后被调用,相当于类组件生命周期的 componentDidMount + componentDidUpdate。
如果需要在其它时机执行副作用函数,就要靠第二个依赖数组字段了。
如果存在依赖数组,React 就会在每次副作用函数执行前,检查依赖数组中的内容。当依赖数组与上次触发时完全没有变化,就会掉过此次执行。
1 | function Example({ name }) { |
依赖数组传空数组或者固定值的时候,每次触发的值都不会变化,所以这个副作用就只会在组件生命周期中执行一次。
1 | function Example() { |
相当于类组件的 componentDidMount 生命周期函数。
对于副作用函数,我们还可以在其中返回一个对应的 清理函数:
1 | function Example() { |
清理函数将在当前副作用函数失效、下一个副作用函数设定之前被执行。
上面的例子中,清理函数的执行时机相当于 componentWillUnmount。
比如在组件挂载后添加一个对页面滚动做监听处理,并在卸载时清理监听器:
1 | function Example() { |
为什么要这样设计呢?官方给出了一个例子,就是根据 props 参数订阅数据源时,如果 props 参数发生变化,都需要清理旧订阅注册新订阅。
在类组件的实现中,这需要把对应处理分散在多个生命周期函数中:
1 | class Example extends Component { |
可以看到,对于同一个数据源的处理被分散得七零八落,其中 componentDidUpdate 的处理还经常被遗忘,导致一些本不应该产生的 bug。
如果依赖于多个数据源的组件,或者还有其他相同生命周期的处理(如上面页面滚动事件的监听例子),还会让同一类数据源/事件的处理不能收拢到一起,反而因为发生时机而被混在其它不同数据源/事件的处理当中。导致组件编写过程中需要上下跳跃,而且后期维护中代码的阅读难度上升、可重构性下降。
而通过 useEffect
实现,只需要放在同一个副作用处理内,再把相关参数放进依赖数组就行了:
1 | function Example({ dependId }) { |
只需要这样,不用再做 componentWillUpdate 的额外处理。而且最终同一类逻辑处理被收在同一个 effect 函数中,开发过程中聚焦单一问题,产出代码清晰可读,十分方便代码维护和重构。
可以说是非常方便了。
基础的 React Hook 就是上面的 useState
和 useEffect
两个了,使用它们已经可以替代大部分以前使用类组件完成的功能,并且产出代码和执行效率都挺不错的。
简单概括一下对于 React Hook 的第一印象:
不过 React Hook 的设计也不是十全十美,有些问题通过简单例子可能无法体现出来,还需要通过更多使用场景的实践将其暴露出来。其它 Hooks 也将在新的例子中继续说明。
敬请期待~
]]>To be continued.
于是稍微梳理了一下,2020年的个人开发环境选择。
那就万能的苹果吧。
我首先想到的自然是 MBP,自己平时开发环境就是一台 iMac 和一台 Win10 机器。而从屏幕效果、开发软件源和终端体验来看,苹果家都胜出一筹。
不过随后那朋友补充了自己的预算——3000,嗯,人民币。
好吧,那基本告别苹果了。
那 Linux 怎么样?
不过除了苹果和 Windows 之外,其实还可以使用 Ubuntu 之类的 Linux 桌面环境。预想中,类似 macOS 的高清字体渲染、Unix-like 的命令环境、无缝对接服务端开发……然而理想很美好,现实还是有点骨感。
在外企开发的同学估计没问题,但在国内肯定离不开 QQ、微信、企业微信等各种国内软件。而国情所限,这些国内软件往往只支持 Win 环境、偶尔支持 macOS,基本无视更通用的 Web 端等平台的开发。
如果选择了 Linux,基本只能靠 wine 来跑它们了,而经过实际体验的感觉并不怎么愉快。
看来还是得 Windows 了。
至于 Windows 的命令行环境,有点一言难尽,不过还是有人尝试着概括成了一句话:Linux 是在命令行上做了个图形界面,Windows 是在图形界面里顺便带了个命令行。
嗯,差不多就是这个感觉,Windows 环境的命令行简直就是附带的,功能凑合,勉强能用。
虽然经常被用到,但 cmd 基本是无奈之选,软件生态贫瘠,可定制项目少,提示符展示 git 分支名称都没法做到。命令补全功能也只能做到路径补全,不支持参数补全、引号区分混乱……
PowerShell 似乎有改进,但启动更慢了,软件生态问题也没什么变化,反而干掉 &&
/||
搞了一套与 Linux/Mac 都不兼容的流程控制符,让跨平台的项目配置变得颇为蛋疼。就算后期 Win10 默认推荐使用它,也还是让人没有使用的兴趣。
而且,对于日常使用 git* 工作和做个人笔记同步的我来说,默认也不提供可用的 ssh,就算手动安装软件支持,git bash/openssh/putty 默认使用的密钥还不太一样,就算花时间去配置整合也不一定能完全通用,费时费力也不讨好。
总之,windows 端的终端环境,不做一番改造是没法用的。
一直以来,两者都是 Windows 端命令环境的不错选择。
不过,前者基于 mintty,官方已经声明了,它并不能完全替代命令行环境。比如不能直接用 Windows 下的 Python、MySQL 等环境,甚至不支持 tree 命令:
后者基于 ConEmu,还搭配了 clink 和 git 环境等便捷配置。如果想直接使用 ConEmu 达到类似的效果,还是需要做不少手动优化的:
可见 cmder 已经提前做好了多少优化配置,让人省心。
cmder 官方提供了 精简版 和 完整版 两个包,区别在于后者内置了一份 git。由于 git 肯定会手动安装最新版,顺便自动配置 PATH 以方便 VSCode 等软件的集成和调用,所以可以考虑直接使用精简版。
对于经常在多台办公电脑、个人电脑、平板之间来回切换的我来说,打包一份做好个人配置的 cmder,就可以轻松在多端获得同样的命令行体验,简直不要太轻松。
打开 cmder 的 github 仓库或者官网下载即可:
修改 %CMDER_ROOT%\config\cmder_prompt_config.lua 中的 prompt_lambSymbol
改为 $
,以免切换历史命令时出现光标位置和字符显示错乱的问题。
切换到 cmder 的主目录 右键以管理员权限打开 Cmder.exe,在命令栏输入 Cmder.exe /REGISTER ALL
,回车执行即可添加右键菜单。
为了方便之后重装系统后重新设置,或者移动使用。
也可以在 cmder.exe 所在目录创建一个 register.bat,内容如下:
1 | %~dp0cmder.exe /REGISTER ALL |
保存关闭,右键点击它选择“管理员身份运行”,执行完毕后,就能在右键菜单中看到 “Cmder Here” 了。
在系统环境变量中,增加一个 CMDER_ROOT
,内容为 cmder 的主目录路径。
然后,将以下内容保存为 ide_shell_entry.bat,放在 cmder 目录下:
1 | @echo off |
最后配置 IDE 启动的终端为 cmd.exe
,启动参数 /k %CMDER_ROOT%/ide_shell_entry.bat
。
这样,就能在 VSCode、IDEA 等 IDE 中进行项目开发的时候,随时在集成终端中使用与 cmder 一致的环境。
在 2016 年,Win10 系统十周年之际,微软推出了 “Bash on Ubuntu on Windows”,后来又改名成了 “Windows Subsystem for Linux - WSL”。在 2019 年,又改造升级成了 WSL2。
有兴趣的同学可以继续阅读:《WSL1 与 WSL2 简单对比》
并且还推出了新的终端模拟器 Windows Terminal,界面美观、使用方便、CJK 字体渲染完美、启动快速,搭配 WSL 使用香到不行。
打开 Win10 的应用商店,搜索 terminal 即可找到 Windows Terminal,点击安装即可。
WSL 的话,则是直接搜索自己想要安装的 Linux 发行版本,比如 Ubuntu 20,在搜索结果中找到它,点击安装即可。
两者都安装完毕后,打开 Terminal 修改配置文件,将默认启动配置 defaultProfile
改为下面 profiles
中 WSL 对应条目的 guid
。
打开 WSL 官方页面 (http://aka.ms/wsl),点击 INSTALL WSL
后,按照指示一步步操作。
如果在本机使用了 Proxifier 可能会遇到 WSL 启动报错无法使用的情况:
1 | 参考的对象类型不支持尝试的操作。 |
可以执行 netsh winsock reset
修复,但这样又会导致 Proxifier 的代理控制失效,只是拆了东墙补西墙。
Proxifier 官方提供了一个工具修复这个问题,下载 www.proxifier.com/tmp/Test20200228/NoLsp.exe 后,使用管理员权限打开 cmd/PowerShell 切换到 NoLsp.exe 所在目录,执行 NoLsp.exe c:\windows\system32\wsl.exe
即可解决问题。
参考:https://github.com/microsoft/WSL/issues/4177#issuecomment-597736482
更新:2021/02
较新版本的 Terminal 已经会自动创建上下文菜单项,无需再手动添加。
先创建个放置小图标的目录:
1 | mkdir "%USERPROFILE%\AppData\Local\terminal" |
然后,在 easyicon.net 搜索 terminal 后,找个自己顺眼的图标,下载放到刚才的目录中(不知道 AppData 目录在哪的同学,直接在资源管理器地址栏里粘贴上面的路径就可以打开了),改名为 terminal.ico。
接着,创建一个 wt.reg
文件,输入以下内容:
1 | Windows Registry Editor Version 5.00 |
记得将上面的{username}
改成自己的用户名。
双击执行后,就可以在右键菜单中看到 Open Terminal Here 的选项了。
不过,点击选项后你会发现打开的 Terminal 是固定目录,如果要设置为当前目录,需要修改 Terminal 的配置文件。
在 profiles.defaults
段落中,在添加 "startingDirectory" : ".",
就可以了。
相比 Cmder 还需要创建脚本配置启动参数,WSL 就比较简单了,直接将 IDE 默认的继承终端启动程序,由 cmd.exe
改为 wsl.exe
就行。
Terminal 默认的效果还是挺素的,喜欢自定义的同学可以调调配色、换换背景图。
我个人则是倾向于给 Terminal 配置个亚克力背景效果。在配置文件对应的 profiles
段落内,添加 defaults
配置:
1 | { |
日常项目开发,推荐使用 WSL。
毕竟微软自家做的环境,底层与系统的对接较完善,启动速度快。
在 IDE 中启动 cmder 的时候,往往需要六七秒的时间。如果碰上 VSCode 打开了多个项目。重启机器后,VSCode 会瞬间还原上次的多个窗口,并同时开始打开多个集成终端,速度极其缓慢,经常还有部分窗口的终端启动失败,需要手动重启,体验较差。
配置成 WSL 的话,不管几个都是秒开,简直和 Linux 环境的体验差不多。
但是遇到需要使用 Windows 系统真实环境的情况(比如 electron
打包、ffmpeg
视频转换),还是得使用 cmder 或者 cmd。不过除非是专门做 Windows 平台应用开发的同学,否则一般较少遇到这类情况。
前文:
Vue3 中从父级向子级传值与 Vue2 一样,就是在模板里创建子组件的标签时,通过 v-bind:
/:
指令传值。
而父组件通过 v-on:
/@
绑定的事件监听器,需要在子组件触发事件时,需要通过 props
之后的第二个参数 context
调用:
1 | // child.vue |
同时,context
中还提供了我们操作子组件时经常需要用到的插槽 slots
:
1 | // parent.vue |
顺带一提,由于 context
不是响应式的,所以我们可以直接在参数表中,使用解构赋值取出 emit
和 slots
:
1 | // parent.vue |
不过需要注意,slots
的属性值也可能随时发生变化,但它本身并非响应式数据。为了确保你的组件随时获得最新的插槽状态,建议在 onUpdated
中操作其属性值。
我们有时会遇到类似这样的需求(比如:Tab 与 TabPane),所有子组件需要根据同一个父组件/祖先组件的状态调整自身的状态,做到跨级数据联动。
在 Vue2 中,是被打散在不同构造参数中的 provide
和 inject
属性实现的:
1 | // tab.vue |
可以看到,又是被打散到不同 data
/methods
/computed
段落的零散数据,靠 this
强行绑定到一起。
在 Vue3 中,则是通过 provide
和 inject
函数,更直观地组装出来:
1 | // tab.vue |
传入响应式数据后,在子孙组件都可以方便地取到,以至于甚至可以替代很多 Vuex 的使用场景。
]]>这一系列至此告一段落,Vue3 的组装 API 使用起来还是相对简单的,大部分问题都能查阅官方文档解决。
如果还有其他问题也欢迎留言联系,或者加入QQ群: 121757667 ,一起讨论和学习!
前文:
通过数据和事件处理的几个例子,大家或许发现了 Vue3 的两个基本变化思路:
而这两者都避免了再将各种不同层级的属性、方法绑定到 $vm 上,从而避免了混乱的 this
指向问题。
以至于我们在 Vue3 的例子里基本没再用到过 this
。
对于父级组件传入的属性值,以前都是通过 this.<属性名>
访问的,在 Vue3 的 setup()
中怎么获取组件属性呢?
很简单,setup()
函数收到的第一个参数就是传入的属性值了:
1 | export default { |
不过由于传入的 props
是一个响应式数据,为了确保传入的非引用型数据发生变化时,页面内状态能动态更新,我们还得用 toRefs
从里面解构取值:
1 | import { toRefs } from 'vue'; |
对于间接通过其它数据再计算出来的计算属性,通过 Vue3 的组装 API 实现也很简单:
1 | // sells.js |
除了实际的业务逻辑之外,computed() 通常还可以用于多个 className 的计算合成,比如:
1 | export default { |
当某个响应数据发生变化时,执行相关处理逻辑,我们就会用到 watch() 了:
1 | const { disabledIds } = toRefs(props); |
我们还可以使用 watch() 返回的停止器来结束监听:
1 | const selectedId = ref(null); |
与 watch 不同,watchEffect() 不需要指明监听目标,在它接收一个 effect 函数后,将会立刻执行并分析其中依赖的响应数据,在它们发生变化时再次执行这个 effect 函数。:
1 | const sellsCount = ref(0); |
如果需要在监听停止的同时,做一些额外的回收处理(比如解除 DOM 事件监听器、清理其它数据等),可以用到 onInvalidate 函数:
1 | const sellsCount = ref(0); |
]]>下一篇:《初探 Vue 3.0 的组装式 API(四)》
今天继续看看其它日常使用方式的变化与对比吧。
前文:
Vue2 中,模板使用到的事件处理函数,通常都被放在 vm 构造参数的 methods
属性中,然后才能通过 v-on:<event>
/@<event>
标记到对应 DOM 上:
1 | <template> |
上面的 increase()
方法中,this
看似指向 methods
属性的对象,实际上和之前 data
返回对象一样,指向的其实是最终创建的 vm 对象,日常指代混乱。
data
中返回了数据模板,告知 vm 会有一个名为 count 的响应式数据;methods
对象作为方法模板,告知 vm 需要创建一个名为 increase 的方法,供模板事件处理; export default
对象中不处在同一层级,实际上 this
都指向了 vm 对象。1 | // Vue 3.0 |
在 Vue3 的 setup
中,对数据的改动,直接使用普通函数或箭头表达式对数据进行操作就行了,非常直观。
return
返回给模板使用;(1) Vue2 的 mixin 实现
对于不同组件可复用的数据和事件处理函数关系,在 Vue2 中我们通常都是用 mixin 来完成的。
比如,不同页面都经常使用到一个 ajax
的网络请求方法,和一个请求状态数据 isRequestSending
(可用于在模板内判断和调整界面展示和按键交互),过去的 Vue2 中通常这样实现:
1 | // mixin-net.js |
1 | <!-- page-a.vue --> |
可以看出,因为之前 vm 构造参数导致 this 指代混乱的问题,Vue2 中组件的可复用逻辑只好使用 mixin 的方式,将一个与构造参数结构一致的对象混合到一起来实现。
以至于这个 mixin 的结构,同样继承了组件构造参数的毛病。
而且引入 mixin 之前,无法通过标准 es 模块结构分析可用的数据、方法和钩子函数。必须解读参数中字段,甚至函数返回值,才能得知复用逻辑的大致结构。
(2) Vue3 的方案
Vue3 中,你可以使用类似构造函数的结构,在组件中取到返回值后,直接解构使用:
1 | // net.js |
1 | <!-- page-a.vue --> |
也可以根据个人喜好和业务实际情况,考虑做进一步拆分,以便简化代码结构或者实现某些属性的单例控制等效果:
1 | // net.js |
相比 Vue2 的 mixin,更加自由可控、清晰明了。
]]>下一篇:《初探 Vue 3.0 的组装式 API(三)》
最近收拾东西,把搬家后一直放着压箱底的它翻了出来,发现还能开机,突然想把它折腾一番,发挥余热。
当初把它当作下载机和漫画阅读器,挂在床头支架上,常年插电挂机之后又放置了两三年,电池已经濒临报废。
上班前开机更新 Win10 系统,下班回来后就黑屏发热再也开不了机了……
目测是电池彻底报废,于是在万能的淘宝找到同型号的电池,下单到货后拆开更换上,终于重新开机。然而由于之前更新过程中的断电,似乎已经导致系统损坏,无论输入什么账号密码也无法登录。
使用带供电的 OTG Hub 外接键盘,开机长按 Shift 进入特殊启动菜单,选择恢复系统。缓慢的恢复过程不提,还总在快要结束时卡住,最后突然重启又回到无法登录任何账号的状态。
被折腾到没脾气,转念一想,就算重置了 Win10 系统估计也是卡得没法用,要不装个 Linux 试试?
毕竟就算它的配置不高,比树莓派还是要强不少的。
经过几次尝试,最终选择了 Ubuntu 20.04,其他几个系统安装和使用中遇到但问题:
Linux Mint 没法正常完成安装。
Debian 倒是安装顺利,但安装完毕后缺少各种驱动,无法调整屏幕亮度、没有声音、甚至看不到电池电量。
Ubuntu 的话,首先尝试了 Ubuntu 18.04,大部分驱动相对正常,重力感应异常(不过可以手动锁定屏幕方向);换成 16.04 后 Unity 桌面比 18 的 Gnome 流畅许多,但是缺少很多驱动。
最后尝试了 20.04,安装后驱动几乎都正常,手动再装个 Unity 桌面后就可以使用了。
这里推荐使用 Rufus,将 Ubuntu 镜像写入闲置的 U 盘,就做成系统安装启动盘了。
不过需要注意,Z3735F 芯片虽然本身支持 64 位,但可能是为了节省系统空间,加上系统配置本身也都不高,厂商都采用了 32 位的系统,对应的 EFI 也是 32 位的。
而 Ubuntu 并没有提供 32 位 EFI 的引导文件,所以制作完安装启动盘后,需要网上找一个 bootia32.efi,放到 U 盘的 /EFI/BOOT/ 路径下。
将 U 盘和键鼠接上 OTG Hub,开机时按住 Esc 进入系统启动选单,选择从 U 盘启动。
如果启动失败,可能进入 grub 命令行(在 grub 菜单按 C 键),手动配置参数启动:
1 | ls # 查看所有可用的设备 |
经过系统自检之后就将进入 Ubuntu 的安装环境,点击安装后一步步操作:选择语言、时区、安装分区、创建用户,然后等待文件复制完毕并且安装完毕即可。
安装到最后一步,不出意外将看见一个报错弹窗:
然后,提示安装程序崩溃了:
不用惊慌,此时系统其实基本已经安装完毕,但和引导进入 U 盘安装环境一样,也需要修复一下安装后但系统启动引导。
不过我们要先手动引导启动一次,才能开始之后的修复工作。
依旧是通过之前的操作进入 U 盘系统,打开 disks
查看内部存储里的磁盘信息。
找到安装完成后的系统分区(注意不是引导分区),记住它的设备分区号,如:/dev/mmcblk1p2
。
重启后再次进入 grub 命令行,使用之前的办法,通过 ls
命令找到内部存储中带有 /boot/vmlinuz*
的磁盘分区:
1 | set root=(hdX,gptY) # 刚才找到的分区 |
这样,应该就能启动刚才安装在本地的系统了。
为了不用每次都这样手动输入命令启动,我们还是得修复一下启动引导。
连接 WiFi,打开终端,输入以下命令:
1 | sudo apt-get update |
没看到什么报错的话,启动引导应该就修复完毕了,之后就算拔掉 U 盘,也可以直接自动启动到 Ubuntu 系统了。
之后就是更换 Unity/KDE 桌面,安装 vim、git、VSCode、Chrome,想怎么折腾就怎么折腾了。
Linux 的终端环境比 Windows 的强太多,配置 swap 内存后,可以通过 Chrome 打开不少网页,开启 VSCode 敲敲代码之类的更是不在话下。
老设备成功复活~
]]>如果启动栏有个
install RELEASE
的 Ubuntu 安装入口,觉得碍眼,可以执行sudo apt remove ubiquity
移除掉。
从最简单的数据绑定开始,在 Vue 2.0 中,我们这样将一个数据绑定到模板的指定位置:
在组件创建参数的 data
构造函数中返回一个用来绑定的数据对象,其中有个 now
字段,会被渲染到模板内的 .app > p
内。
1 | <template> |
用 Vue3 的组装 API 实现的话,则是这样:
1 | // Vue 3.0 |
奇怪,看起来好像没啥区别,只是把 data
改成了 setup
吗?
并不是,假如我们现在对这个 DEMO 做个小改动,让它每秒钟刷新一次时间,用 Vue2 大概是这样实现:
1 | // Vue 2.0 |
而 Vue3 的等效实现则为:
1 | // Vue 3.0 |
写了太多 Vue 的我们可能已经忘了,Vue2 的代码从标准 JS 模块的角度来看有多奇怪:
mounted
中修改的 this.now
数据是在哪创建的?我们在模块 default
对象的成员里并没有找到对应字段,倒是在 data
内返回的另一个对象中有这个字段;data
中返回的 now
也不是真正的 this.now
,而是 this.now
的初始值,在 data
中 setInterval
修改 now
并不能更新渲染出来的时间;mixin
函数混入到当前组件的构造参数内。这一切,是因为整个模块 default
对象其实是 vm
对象的构造参数。其背后隐藏了对象的创建逻辑,在构造对象时构造参数中的一些不同层级的字段被绑定到了 vm
对象上。
不少新手可能都犯过一个错误,在 data
中返回的数据字段和 props
、methods
或者 computed
中的字段命名撞车(尤其是使用名为 data
的字段),在编码阶段并不能被 IDE 直接发现。就是因为上面的原因,这些字段创建时隶属于不同的位置,在之后构造时才被绑在了同一个对象上,导致了运行时才能发现的冲突。
Vue3 中,改成提供 ref
、reactive
、toRef
、onMounted
等函数的形式实现,例子中:
setup
中看到的 now
即是用于绑定的 this.now
;now.value
即可看到页面状态的更新;now
和 onMounted
处理提取到同一个函数内,再将 now
返回即可,不再需要黑盒的 mixin
处理。可以说 Vue3 是直接将响应数据的创建决定权、生命周期的通知回调,都通过 API 的形式交给了开发者,更直观明了和可控。
下面详细说说常用的几个响应式数据相关 API:ref
, reactive
和 toRefs
。
(1) ref
上面例子中使用到的 ref
,可以将一个数据包装成响应式数据代理对象。
1 | const count = ref(0); |
当你修改代理对象的 count.value
属性时,模板中使用到 count
的位置将响应数据的变化,更新视图中的数据状态。
(2) reactive
对于对象的响应式封装,使用 ref
稍显麻烦:
1 | const state = ref({ |
这时可以改为使用 reactive
,像操作普通对象的字段一样修改 count
即可更新视图:
1 | const state = reactive({ |
对代理对象 state
添加新的字段也可触发视图更新。
(3) toRefs
有时候,对象的名字过长,我们想直接在模板内使用对象内部字段,直接使用解构是不行的:
1 | import { reactive } from 'vue'; |
这个情况下,使用 toRefs
处理后再解构赋值即可:
1 | import { reactive, toRefs } from 'vue'; |
但需要注意,toRefs
只处理调用时 position
的现有字段,如果在之后对 position
增加新字段,将无法触发视图更新。
]]>下一篇:《初探 Vue 3.0 的组装式 API(二)》
比如 UI 框架提供了一个菜单组件 <iv-menu>
,但是其中标题文本的效果不符合我们的预期。我们在 Chrome Inspector 中找到对应 DOM,发现 className 为 .title
,于是就添加了这样的样式:
1 | <!-- page.vue --> |
结果——添加的样式并没有生效。
因为 page.vue 这里我们使用了 scoped
样式作用域,Vue 会为当前模板内所有元素会被增加一个特殊属性(如:[data-v-5ef48958]
),并且为所有样式选择器最后一级添加这个属性的选择器。
生成的样式和 DOM 大致是这样的:
1 | <style> |
可以看到 .page
选择器自动变成了 .page[data-v-5ef48958]
,从而达到这个组件的 .page
样式不污染其它同名样式的效果。
而这个处理,也就是导致我们无法修改子组件内样式的原因。毕竟,不污染子组件样式其实就是样式作用域本身预期的效果。
上面例子中修改 <iv-menu>
组件内标题的例子,生成代码大致如下:
1 | <div class="page" data-v-5ef48958> |
对应样式为:
1 | .page[data-v-5ef48958] { |
其中 .page[data-v-5ef48958]
和 .iv-menu[data-v-5ef48958]
的样式对应的 DOM 选择器都是正确的。
但是对于 .iv-menu
内部的 .title
,Vue 的样式作用域处理逻辑认为它属于当前组件,所以生成的选择器是 .iv-menu .title[data-v-5ef48958]
。
而实际需要的选择器其实是 .iv-menu[data-v-5ef48958] .title
。
也就是说,只需要告诉 Vue 的样式作用域处理逻辑:“我们这个组件只管到 .iv-menu
,后面的 .title
是属于更深的子组件样式,不要加作用域处理”,就行了。
而 Vue 已经提供了这样的告知方法,就是深度选择器 /deep/
。只需要在组件样式内加入它就行了:
1 | <!-- page.vue --> |
如果遇到 SASS 处理器不识别 /deep/
而报错,可以改成 >>>
或者 ::v-deep
操作符,三者是一样的。
]]>
1 | ├── common |
此时,如果在 top-bar.vue 里引用 net.js,路径将会是这样:
1 | import * as net from '../../common/net.js' |
某些情况还需要写好几个 ../
,而且一旦某些逻辑分离成单独组件时,放在了不同层级深度的目录里,这些路径代码就得一一修复引用。
所以我们经常在 Webpack 里做类似这样的配置:
1 | // webpack.config.js |
然后不管需要用到公共组件的文件在哪个层级,都可以直接写成:
1 | import * as net from '@/common/net.js' |
虽然这么写是能通过编译了,但是编码过程中又会发现一些体验上的不便。
在 IDE 中通过准确的路径引用的文件,可以提供便捷的定义跳转、函数提示、自动完成等功能。
而通过别名引用的文件,IDE 似乎就爱莫能助了,按住 ctrl/cmd 看不见跳转链接、写出函数名的前几个字母也不会出现智能提示、对于公用组件的函数 Js Doc 也无法直接看到。
这都 2020 年了,难道没有 IDE 支持常用前端项目结构的 alias 路径解析吗?
答案是有的,WebStorm 里就提供了 Webpack 配置文件的 alias 路径解析。
但是有人可能和我一样,虽然写了 alias,而且确实是官方的语法。但是 WebStorm 并没有对应的提示,那么是哪里出了问题呢?
为了定位问题,我先创建一个最基础的 Webpack 项目,然后通过 WebStorm 打开,发现 alias 里的路径全都能正常解析。
并没有什么特殊字符或者目录层级的问题,使用 @
、@@
、{SRC}
等命名都是可以正常识别和提示的。
但是完全相同的配置,在我的另一个旧项目里就无法识别了。
这个现有项目相比基础的项目,多了构建环境区分、多页面入口检测、各类资源 loader、后置服务器环境配置任务等很多内容,一一排除的话工作量有点大。
这时,突然发现每次修改 webpack.conf.js 后 WebStorm 的输出窗口里是有相应提示的。只不过对于解析失败的情况,给出的错误信息非常模糊,只说是一个 default
关键字不存在的异常。
看到 default
首先想到的是 ES6 模块的默认输出对象,但是项目配置是用 CommonJS 写的,并没有使用 export default
。倒是根据启动时设定的环境变量,在入口 webpack.config.js 内通过 switch
引入了不同的任务配置(development/production),而这个 switch
里没有编写 default
的处理。
补充了 default
的情况后,WebStorm 的输出窗口里不再提示 default
异常,但还是提示了另一个错误。不过从错误信息的变化看来,WebStorm 对于 Webpack 配置文件的解析不像是静态解析,更可能是后台执行了一遍 webpack.confi.js,然后取了返回结果。
于是在 webpack.config.js 内,拼装配置的过程中,添加了一段代码,向当前项目目录内输出了一个临时文件:
1 | require('fs').writeFileSync(__dirname + '/detect.log', 'Created:' + new Date()); |
如果 WebStorm 偷偷执行了配置脚本,这边也能通过是否出现 detect.log 发现它的踪迹。
果然,保存配置文件刚过了一会儿,并没有启动 Webpack 任务,项目目录中却出现了一个 detect.log。
既然摸到了 WebStorm 检测的踪迹,接下来就可以开始顺着踪迹逐步定位问题了。
通过在配置文件不同位置打点输出到刚才的临时日志文件,就能定位到项目配置里到底是哪里影响了 WebStorm 的检测了。
这边主要是两个情况:一是项目中的附加参数为空时取不到对应配置;二是某些情况下通过 realine
让用户输入相关配置参数,在 WebStorm 检测时是超时无效的。
将 WebStorm 检测时的 process.env
打印到文件内,对比正常启动任务和 WebStorm 检测的不同环境变量,针对后台检测时做好跳过处理后,终于项目里也能正常检测到定义的 alias 了,问题解决。
如果大家在使用 WebStorm 的过程中,也遇到类似的问题,可以参考这个方案进行定位和解决问题。
]]>