Frankie's Blog https://github.com/tofrankie/blog 种一棵树,最好的时间是十年前。其次,是现在。 Fri, 16 Jan 2026 09:05:56 GMT https://validator.w3.org/feed/docs/rss2.html https://github.com/jpmonette/feed zh-CN All rights reserved 2025 <![CDATA[Cursor: Model not available]]> https://github.com/tofrankie/blog/issues/384 https://github.com/tofrankie/blog/issues/384 Mon, 12 Jan 2026 09:05:18 GMT 配图源自 Freepik

背景

今天使用 Claude Sonnet 4.5 模型的时]]> 配图源自 Freepik

背景

今天使用 Claude Sonnet 4.5 模型的时候,提示:

Model not available
This model provider doesn't serve your region.Visit https://docs.cursor.com/account/regions for more information

根据 Cursor 官网说明:部分模型提供商有基于位置的限制,这意味着某些模型可能在你所在地区不可用。 更多

兜底选择:可以继续使用 Auto 模式,Cursor 会为每次请求自动选择可用模型。

解决方法

参考 cursor #3372

  1. 梯子开启 TUN 模式(参考
  2. 在 Cursor Settings - Network 处,将 HTTP Compatibility Mode 选项改为 HTTP/1.0
  3. 重启 Cursor

]]>
<![CDATA[将剪贴板的图片保存至本地]]> https://github.com/tofrankie/blog/issues/383 https://github.com/tofrankie/blog/issues/383 Tue, 06 Jan 2026 07:14:59 GMT 打开 macOS 内置的预览 App,使用快捷键 ⌘ + N(从剪贴板新建)打开,然后 ⌘ + S 保存便可。

打开 macOS 内置的预览 App,使用快捷键 ⌘ + N(从剪贴板新建)打开,然后 ⌘ + S 保存便可。

看起来可以用 Raycast Script Command 写一个 AppleScript 处理,有空再搞一下...

]]>
<![CDATA[Google Antigravity 登录成功无法跳转的解决方法]]> https://github.com/tofrankie/blog/issues/382 https://github.com/tofrankie/blog/issues/382 Thu, 11 Dec 2025 12:47:35 GMT 配图源自 Freepik

由于

由于 Antigravity 仅限部分国家地区可用,因此登录过程可能会出现类似的提示:

Your current account is not eligible for Antigravity.

第一步,前往这里修改你的 Google 帐号关联地区,建议中国台湾、美国。提交后,通常约 30 分钟后会有修改结果的邮件,留意邮箱。

第二步,科学上网:

  1. 选择与 Google 帐号关联地区相同的节点,比如台湾。
  2. 开启 TUN 模式(虚拟网卡模式)

我发现 ClashX 似乎没有开启 TUN 模式的选项,后来装了 Clash Verge,通过上面开启。

第三步,安装 Antigravity,按引导操作便可:

如果可以正常跳转,控制台会有类似提示:

Launched external handler for 'antigravity://oauth-success'.

突然有个想法,macOS 内置的 open 命令是可以直接打开 URL Scheme 的。当网页端登录成功后,前往终端执行 open antigravity://oauth-success 命令是不是让 Antigravity 完成最后一步操作呢?

]]>
<![CDATA[看,有架飞机 ✈️]]> https://github.com/tofrankie/blog/issues/381 https://github.com/tofrankie/blog/issues/381 Fri, 28 Nov 2025 14:51:08 GMT
  • 📍 广州 TIT 创意园(天台)
  • 🌅 天气晴
  • ⏳ 时间 2021 ~ 2025
  • ▲ 飞机在这 😄

    ]]>
    <![CDATA[使用 Raycast 搜索 Confluence 或 Jira]]> https://github.com/tofrankie/blog/issues/380 https://github.com/tofrankie/blog/issues/380 Fri, 28 Nov 2025 10:09:16 GMT 配图源自 Freepik

    前言

    如果你用过 Alfred,还没有用过 配图源自 Freepik

    前言

    如果你用过 Alfred,还没有用过 Raycast,不妨试试,其免费订阅也足够好用(Raycast vs Alfred)。

    免费订阅用户无法享用 Raycast 内置的数据同步功能,可借助 Raycast 的 Export/Import Settings & Data + iCloud Drive 进行同步。

    背景

    我们公司使用 Atlassian 旗下的 Confluence、Jira 做知识库、项目管理。

    我们要经常打开 wiki 和 jira 页面查看相关内容,找到一篇目标文章或 Issue,往往需要几个动作才能完成:打开网页、聚焦输入框搜索、筛选...。稍快一点可能是将常用链接添加到书签或者创建 Raycast Quicklink + Alias 方便快速访问。

    其中 Quicklink 有些场景很好用,比如检索文档:https://developer.mozilla.org/zh-CN/search?q={Query},其中 {Query} 表示动态参数,也就是你要搜索的内容。只要是支持链接带参搜索的网站,都可以用这种方式,比如爱范儿的 https://www.ifanr.com/search?query={Query}

    但这种搜索方式在公司内部的 wiki、jira 中行不通。另外,通过创建 Quicklink 的方式其实“不太适合”公司内的文档检索。试想,随着时间积累,要访问、收藏的链接只会越来越多,无论是浏览器书签或 Quicklink 只会越来越多。

    于是产生了开发一个 Raycast 扩展的想法,以支持 Confluence 和 Jira 的快速搜索以及部分简单操作。恰好公司私有部署版本(Data Center)也支持以 Personal Access Token 的方式进行身份认证,这是一种安全的认证方式以便于与外部程序集成。了解更多

    开始之前

    安装 Raycast 后,前往 Raycast Store 安装 Atlassian Data Center (Self-Hosted) 扩展。

    使用该扩展的命令时,会引导完成一些初始配置,准备好以下信息:

    • Confluence Base URL:如 https://confluence.example.com
    • Confluence PAT:前往公司 Confluence 网站,在 Profile(头像处) → Personal Access Tokens → Create token(请妥善保管访问令牌,如有泄露,及时撤销
    • Jira Base URL:https://jira.example.com
    • Jira PAT:创建方式跟 Confluence 同理

    PS:由于以上配置做成必填的,如果只用到其一,另一个可以随便填个值,非空便可。

    使用指南

    PS:还可以根据个人习惯为命令设置 Alias 更方便直达命令

    目前 Atlassian Data Center 提供的命令有:

    • Confluence
      • Search Contents - 搜索页面(文章)、博文、附件、评论,支持收藏功能
      • Search Spaces - 搜索空间
      • Search Users - 搜索用户
    • Jira
      • Search Issues - 搜索 Issue,并支持一些简单的操作,比如扭转 Issue 状态、创建 Worklog
      • Board View - 看板,比如当前 Active Sprint
      • Worklog View - 你的工作记录
      • Notification View - 通知列表(若有“未读”消息,在 Search Issues 列表也会提示)
      • Manage Fields - 该命令的作用是辅助 Search Issues 以在搜索结果展示更多内容

    每个搜索面板都内置了很多常用的筛选项(日常应该管够了):

    • Search Contents
      • All Contents
      • Full Text Search - 搜索范围从 title ~ "xxx" 放宽到 text ~ "xxx",而不仅仅搜索标题
      • Viewed Recently
      • Updated Recently
      • Created by Me
      • Contributed by Me
      • Mention Me
      • My Favourites
      • Watched by Me
    • Search Issues
      • All Issues
      • Full Text Search - 搜索范围从 summary ~ "xxx" 放宽到 text ~ "xxx"
      • Open Issues
      • My Open Issues
      • Assigned to Me
      • Reported by Me
      • Created Recently
      • Updated Recently
      • Resolved Recently
      • Viewed Recently
      • Watched by Me

    前面提到时间越长 Quicklink 越多的情况,我认为将文章进行收藏是一个不错的方案(在 Action Panel 提供了快速收藏和取消收藏),检索时切换至 My Favourites 选项便可。

    除此之外,还支持键入完整的 CQL 或 JQL 进行定制化的高级查询:

    再提一句:

    由于 Jira 允许自定义字段,字段类型繁多,加之各家 Jira 实例大不相同,因此添加了 Manage Fields 命令来辅助 Search Issue 以显示更多信息。

    比如在 Manage Fields 界面下搜索 Test Engineer 并通过「Add to Search」添加成功,那么 Search Issues 列表下就可以显示该字段信息。

    截图

    由于 Raycast List 显示的内容有限,很多附加信息只能隐藏在 tooltip 里,可以通过移动光标至图标、标题、副标题、时间、头像等处显示。

    ▼ Confluence

    ▼ Jira

    更多截图

    其他

    个人常用的一些 Raycast 扩展,但不一定适合你,按需食用:

    ]]>
    <![CDATA[浅谈 CSS 3D Transform 与 Perspective]]> https://github.com/tofrankie/blog/issues/379 https://github.com/tofrankie/blog/issues/379 Wed, 26 Nov 2025 04:11:33 GMT 前言

    前不久,在一个项目(打开,出发)中应用了一种新的交互方式「视差滚动」。

    恰好设计同学也说可以总结下经验,以便后续的运用,那浅谈一下。

    ]]> 前言

    前不久,在一个项目(打开,出发)中应用了一种新的交互方式「视差滚动」。

    恰好设计同学也说可以总结下经验,以便后续的运用,那浅谈一下。

    若有错处,欢迎指正。

    从数学开始

    坐标系

    中学时期,我们学习过笛卡尔坐标系,常见的有「平面直角坐标系」和「空间直角坐标系」两种。

    同样地,在 CSS 世界里也有一个「坐标系」,但其 X、Y、Z 三轴及其正负方向,与书本中的坐标系稍有不同。我们将屏幕想象成一个「空间直角坐标系」:

    • 原点:即屏幕的左上角。
    • X 轴:处于水平方向,原点右侧为 X 轴正方向、原点左侧为 X 轴负方向。
    • Y 轴:处于垂直方向,原点以下为 Y 轴正方向、原点以上为 Y 轴负方向。
    • Z 轴:与屏幕平面垂直,屏幕正前方为 Z 轴正方向,屏幕正后方为 Z 轴负方向。

    如果页面元素是以 2D 形态渲染,就相当于没有 Z 轴,此时是一个「平面直角坐标系」。

    投影

    我们也学习过「投影」知识。物体在灯光或日光的照射下,就会在墙壁或地面上产生影子,这是一种自然现象。投影就是由这类自然现象抽象出来的。阳光在地面上留下各种影子,便是最直白的例子。

    一般地,用光线照射物体,在某个平面上形成的影子,叫做物体的「投影」。发出照射的光源处叫做「投影中心」,照射的光线叫做「投影线」,投影所在的平面叫做「投影面」。

    投影可以分为「平行投影」和「中心投影」两类,它们还能细分。

    • 如果投射线是一组互相平行的射线(如手电筒的光线),此时形成的投影叫做「平行投影」。若投射线与投射平面垂直,则称为「正投影」,否则称为「斜投影」。
    • 如果由同一点光源发出的光线(即投射线相交于一点)形成的投影叫做「中心投影」。所有投射线相交的点,称为「消失点/灭点」。

    若投影中心与投影面之间的距离足够远,趋向于无穷大,此时的中心投影可认为就是一个平行投影。比如太阳光。

    当我们从某一个方向观察物体时,所看到的平面图形叫做物体的一个视图。

    CSS 中 2D 与 3D 的区别

    示例:https://codepen.io/tofrankie/pen/jOxxrzX

    perspective 与 translateZ

    • perspective 的取值范围是正数,如果小于等于 0 则没有 3D 视觉效果。
    • translateZ 可正可负。

    图中 d 表示视点与屏幕之间的距离,在 CSS 中通过 perspective 属性来设置。 图中 Z 表示元素在 Z 轴的位置,在 CSS 中通过 translateZ 属性来设置。

    场景可以理解为:人坐在屏幕前,人的眼睛即为视点,d 就是人与屏幕的距离。(注意,perspective 不设置时,不代表人与屏幕之间的距离为 0,CSS 中 px 等单位取了一个人类坐在屏幕前观看屏幕较为舒服的距离而设定的长度单位)

    从图片可以看到,当 d 相同时,当 Z 越靠近视点时,投射在屏幕上的点越大(蓝色部分)。简单来说就是「近大远小」。

    未完待续...

    ]]>
    <![CDATA[TS Check 与 JSDoc]]> https://github.com/tofrankie/blog/issues/378 https://github.com/tofrankie/blog/issues/378 Wed, 26 Nov 2025 03:02:14 GMT 参考链接

    VS Code 中 JavaScript 文件的代码提示是基于 TypeScript 实现的。

    TypeScript 不完全支持所有的 JSDoc 标签,具体请看:JSDoc Reference

    JavaScript 中的类型检查可使用 JSDoc 注释来增强。

    JS 类型检查

    在项目根目录下添加 tsconfig.json 或 jsconfig.json 配置文件,以启动对项目中 JavaScript 文件的类型检查。

    如果只是想要 TS 静态类型检查的能力,可能还需要指定 onEmit 等编译选项,更多请看:TSConfig Reference

    这样的话,安装好 typescript 依赖,执行 tsc 便可对项目进行检查。

    tsconfig.json

    {
      "compilerOptions": {
        "allowJs": true,
        "checkJs": true
      },
      "include": ["xxx"],
      "exclude": ["xxx"]
    }
    

    JSDoc

    目前 TS 可识别以下这些 JSDoc 注释。对应示例可看 JSDoc Reference。

    Types

    Classes

    Documentation

    Other

    示例

    断言

    形式:/** @type {!MyType} */ (valueExpression)

    必须要有圆括号,否则断言无效!

    常量断言

    const STATUS = /** @type {const} */ ({
      PENDING: 'pending'
    })
    

    导入

    定义可复用的类型:

    /**
     * @typedef {import("react").CSSProperties} CSSProperties
     * @typedef {import("react").HTMLAttributes} HTMLAttributes
     */
    
    /**
     * @param {CSSProperties} styles
     */
    function getStyle(styles) {}
    
    /**
     * @param {HTMLAttributes} attrs
     */
    function getAttrs(attrs) {}
    

    从 TypeScript 5.5 起,可以直接在 JSDoc 中导入一组类型,不再需要使用 @typedef 定义一系列的类型:

    /** @import { CSSProperties, HTMLAttributes } from "react" */
    
    /**
     * @param {CSSProperties} styles
     */
    function getStyle(styles) {}
    
    /**
     * @param {HTMLAttributes} attrs
     */
    function getAttrs(attrs) {}
    

    更多:The JSDoc @import Tag

    函数

    函数表达式

    /**
     * @type {function (number, number): number} 
     */
    const sum = (x, y) => x + y
    

    函数声明

    /** 
     * @name fn 
     * @param {string} str 
     * @param {boolean} flag 
     * @returns {*[]} 
     */
    function fn(str, flag) {
      return []
    }
    

    未完待续...

    ]]>
    <![CDATA[GIF 图片优化]]> https://github.com/tofrankie/blog/issues/377 https://github.com/tofrankie/blog/issues/377 Mon, 24 Nov 2025 10:32:41 GMT 配图源自 Freepik

    背景

    在微信交互图文中,除了 JPG、PNG 图片之外,更]]> 配图源自 Freepik

    背景

    在微信交互图文中,除了 JPG、PNG 图片之外,更多的是 GIF 动图,且所占比例比较高。本文就 GIF 图片优化作相关调研。

    GIF 文件

    GIF(Graphics Interchange Format)是 1987 年创建的一种位图图形交互文件格式,目前有 87a 和 89a 两种版本。其颜色深度只有 8bit,可最多支持 256 种颜色。

    发音:

    • [dʒɪf] - 发音同 jif,创建者认证的发音。
    • [ɡɪf] - 发音同 gift,被更广泛使用的发音。

    深入了解:

    文件格式:

    相关工具

    一些工具:

    $ brew install ffmpeg gifsicle imagemagick graphicsmagick
    

    网上大多数 GIF 压缩应用都是基于开源的 Gifsicle 封装的,本文也将使用到它。

    一些命令:

    # 查看所有帧信息
    $ gifsicle --info source.gif
    
    # 查看某帧信息
    $ gifsicle --info "#0" "#3" < source.gif
    
    # 查看详细颜色表
    $ gifsicle --cinfo source.gif
    
    # 逐帧导出(ImageMagick)
    $ convert source.gif target.png
    

    优化方向

    [!TIP] 本文提供了一些 GIF 优化方向,但是否适合你的,应按实际情况仔细斟酌。

    1. 降低分辨率

    提到图片优化,降低分辨率自然是首先想到的方向之一。

    $ gifsicle --resize-width=width source.gif > target.gif
    

    对于其他项目,这或许是一个不错的选择。但在微信交互图文,设计师交付的 GIF 图片基本上是 @1.5x 倍图,常见为 514 × 940 像素。如果进一步降低分辨率的话,在 DPR 为 3 及更高的高清屏下显示效果不好。

    2. 移除无用的扩展内容

    在 GIF 文件中,有一部分扩展内容(比如注释扩展)是对数据流渲染没有影响,因此是可以移除的。

    # 移除应用程序扩展(亲测发现不会影响 Loop Count)
    $ gifsicle --no-app-extensions source.gif > target.gif
    
    # 移除注释扩展
    $ gifsicle --no-comments source.gif > target.gif
    
    # 移除扩展(文档中未注明哪一种扩展)
    $ gifsicle --no-extensions source.gif > target.gif
    
    # 移除帧文本名称(可理解为帧的别名)
    $ gifsicle --no-names source.gif > target.gif
    

    平常对帧的操作,命令通常是 gifsicle "#0" ...,使用别名(如果有的话)是 gifsicle "#first" ...。其作用仅此而已,因此也是可以移除的。

    由于扩展内容的不会占用太多字节,因此移除前后文件大小不会有太大变化。

    3. 减色

    通常,一个连续的 GIF 动图,帧与帧之间的颜色差异不会很大,意味着大量相同的颜色被重复使用,因此这里面是有优化空间的。

    关于 GIF 文件的颜色表:

    • 一个 GIF 文件允许最多有一个全局颜色表,可供所有帧使用。
    • 每一帧允许最多允许有一个本地颜色表,仅供当前帧使用。
    • 一般情况下,至少会存在一种颜色表。若无本地颜色表,则从全局颜色表中寻找。
    • 根据规范,颜色表支持 2 的 N 次方种颜色(N 取值 1 ~ 8),即支持 2 ~ 256 种颜色。

    3.1 移除本地颜色表

    根据上文,如果本地颜色表不存在,就会往全局颜色表中寻找。这样的话,移除掉所有帧的本地颜色表就能节省一部分空间。

    $ gifsicle --colors=256 source.gif > target.gif
    

    其中 --colors 指定为 256 可确保颜色不会被丢失。

    请注意 --colors=num 会做两件事:

    • 一是,移除本地颜色表。
    • 二是,减少颜色表数量至指定值,若指定值大于原本数量将会被忽略。

    以下两张图片,前者每一帧含本地颜色表,后者仅含全局颜色表。优化前后对比:

    /Users/frankie/Desktop/gif-test/input/B2-静帧-1.67s.gif
      原图:416.74 KB
      处理后:373.26 KB
      减少了:43.48 KB
      压缩比例:10.43%
    
    /Users/frankie/Desktop/gif-test/input/F3-按钮触发后-2.13s.gif
      原图:957.43 KB
      处理后:956.25 KB
      减少了:1.18 KB
      压缩比例:0.12%
    

    此方法对于存在本地颜色表的 GIF 图片,优化效果还是挺明显的,否则几乎无影响

    3.2 减少颜色表数量

    $ gifsicle --colors=num source.gif > target.gif
    

    其中 num 表示颜色表数量,取值范围 2 ~ 256。可同时使用 --color-method 参数以指定减少颜色表的算法(详看 Gifsicle manual)。

    请注意,颜色表数量仅支持 2 的 N 次方数量( N 为 1 ~ 8)。

    当使用 Gifsicle 处理图像时,假设指定为 --colors=120,将取可容纳的最小的数量 128,其中第 120 ~ 127 索引位被指定为 #000000(不排除有其他颜色值,但不重要,毕竟不会再有索引值指向它们)。

    如果图像中含有透明色,实际处理过程中 num 可能会 +1。

    对于普通 GIF 动图优化还是很明显的,但在微信交互图文应该是不能接受的,因此不推荐使用这种方式进行优化,原因有二:

    • 一是,处理前后质量下滑肉眼可见,反而色彩差异倒不是太明显(比如减少至 128 种颜色时)。
    • 二是,结果不可控。其中 Gifsicle 减少颜色表的默认算法为「diversity」。

    4. 透明度存储

    我们都知道,GIF 动图是由一帧一帧的位图组成的。但通常一个连续的 GIF 动态,帧与帧之间的差异不会很大,意味着重复部分会很多。

    如果消除掉重复部分,那么整个文件占用空间将会大幅降低,这种方式称为「透明度存储」。

    借助 Gifsicle 工具可实现透明度存储,命令如下:

    $ gifsicle -O2 source.gif > target.gif
    

    也可直接使用 -O3 参数,请注意这里是大写字母「O」,而不是数字「0」。

    • -O2:Store only the changed portion of each image, and use transparency.
    • -O3:Try several optimization methods (usually slower, sometimes better results).

    一般情况下,这种优化方式在体积上会有「质」的变化,而且质量上几乎看不出有损失。

    但是,在你亲测之后或许会发现,效果并不明显,可能体积上只降低几个 KB 大小,甚至会多几个 KB(这方面官方文档有提到,但具体原因未说明)。

    原因很简单,设计师交付的图片已做了透明度处理,因此我们再使用 gifsicle -O2 处理时,结果是几乎没有变化的。

    利用 ImageMagick 将 GIF 逐帧导出:

    $ brew install imagemagick
    $ convert B2-静帧-1.67s.gif ./output/b.png
    

    第一帧完整性最高,后续帧只保留了变化的部分,未变化部分表现为透明背景。

    接着,利用 gifsicle --unoptimize 命令将 GIF 图片还原,再逐帧导出:

    $ gifsicle --unoptimize B2-静帧-1.67s.gif > B2-origin.gif
    $ convert B2-origin.gif ./output/b-origin.png
    

    是的,这个才是 GIF 图的最初模样。原图大小为 2.1MB,透明度处理之后大小为 250KB,因此透明度存储这种方式的优化是巨大的。

    请注意,采用透明度存储的方式,显示效果或许会受到 Disposal Method(定义帧之间的叠加效果)的影响,但一般情况下无需特别设置。若有需要 Gifsicle 也提供了 --disposal 参数供处理。

    5. 有损压缩

    GIF 的图像数据使用了 LZW 无损压缩算法。

    也可以尝试 Gifsicle 提供的 --lossy 有损压缩选项,具体细节其文档中未体现(有兴趣可看 kohler/gifsicle #16),因此实际效果不可预测。

    $ gifsicle --lossy=num source.gif > target.gif
    

    其中 num 取值范围是 20 ~ 200,默认为 20。贡献 --lossy 的作者 Kornel Lesiński 在 Loosy GIF compressor 指出:80 应该是一个比较合适的值。

    GIF's LZW compression is based on a "dictionary" of strings of pixels seen. Normal encoder searches the dictionary for the longest string of pixels that exactly matches pixels in the image. Lossy encoder picks longest string of pixels that's "similar enough" to pixels in the image (plus some magic to hide the distortions with dithering).

    6. 抽帧

    经过以上优化处理,或有了一定的效果,但可能还是不太满意。那你或许应该试一下「抽帧」处理。

    其实,我们可以预知抽帧带来的影响:

    • 帧数减少,时长变短,速率加快。
    • 帧与帧之间的衔接不流畅,有噪点。

    如何优化?

    • 将 GIF 还原成未使用透明存储的状态,接着再抽帧,然后重新进行透明度优化,以减少噪点。
    • 由于抽帧导致时长变短,造成速率加快的「现象」,因此可以尝试将所有帧的 Delay Time 取出相加,然后再重新分配。
    • 抽帧不应抽取连续的帧。同时,切记不能抽去第一帧。

    直接抽帧 vs 先还原透明度再抽帧

    以下测试图片,开始前已做透明度处理优化。

    接着我们抽掉其中三帧,对比两种方式的优劣:

    # 第一组:不还原透明度,直接抽帧
    $ gifsicle source.gif --delete "#3" "#12" "#20" > target.gif
    
    # 第二组:先将透明度还原,接着抽帧,最终作透明度优化
    $ gifsicle source.gif --unoptimize --delete "#3" "#12" "#20" > temp.gif
    $ gifsicle temp.gif -O2 > target.gif
    

    结果如下,在体积上两组相差无几 👇 :

    /Users/frankie/Desktop/gif-test/input/F3-按钮触发后-2.13s.gif(第一组)
      原图:956.25 KB
      处理后:867.45 KB
      减少了:88.80 KB
      压缩比例:9.29%
    
    /Users/frankie/Desktop/gif-test/input/F3-按钮触发后-2.13s.gif(第二组)
      原图:956.25 KB
      处理后:873.74 KB
      减少了:82.52 KB
      压缩比例:8.63%
    

    在效果上,两组差异则非常明显(由于录制 GIF 图片有点麻烦,截图如下 👇 ):

    当然,并不是所有图片如第一组般处理时,效果都那么差。但批量处理图片的话,应先还原透明度,再抽帧,再优化透明度存储。

    重新分配 Delay Time(废弃)

    前面抽掉三帧之后,GIF 时长会发生变化,由原来的 2.13s 变为 1.92s。时长可使用 FFmpeg 工具查看:

    $ ffprobe F3-按钮触发后-2.13s.gif
    Input #0, gif, from 'F3-按钮触发后-2.13s.gif':
      Duration: 00:00:01.92, start: 0.000000, bitrate: 3701 kb/s
      Stream #0:0: Video: gif, bgra, 514x1101, 15.08 fps, 15.17 tbr, 100 tbn
    

    这种方式对于每一帧 Delay Time 相同或相近的情况比较合适,因此局限性较大。

    小结

    基于以上提供的几个优化方向,大致可以划分为两类:

    • 无损压缩:移除扩展内容、移除本地颜色表、透明度存储。
    • 有损压缩:降低图片分辨率、减少颜色表数量、GifSicle Lossy、抽帧。

    其中无损压缩适用于绝大多数项目。以微信交互图文项目为例,除了无损压缩,较为合适的只有适当地抽帧了。

    快速抽帧技巧

    利用 macOS 内置的「预览 App」可快速进行抽帧或添加帧等操作。

    用预览打开图片,在「选取边栏显示」处切换至「缩略图」或「缩略清单」模式(如图 👇)。

    • 删除帧:选中帧后,按下「⌘ + delete」组合键,可删除该帧。
    • 添加帧:打开两组图片,选择上述模式之一,然后两组图片之间进行拖拽即可。比如,从 A.gif 中拖拽一帧到 B.gif,A.gif 帧数不会发生变化,B.gif 则会增加一帧。

    进行以上操作之前,建议先将原图备份。

    优化脚本

    初步想法,大概会支持一张或多张图片批量处理。

    大致流程如下:

    1. 若需要执行抽帧(通过参数区分),则先进行 gifsicle -U 操作以还原透明度,否则跳过。
    2. 若需要执行抽帧,根据抽帧方式对图片进行抽帧操作,否则跳过。
    3. 移除扩展内容、本地颜色表。
    4. 对图片进行透明度存储。

    抽帧形式,想法如下:

    1. 随机抽帧:按比例抽取对应帧数。随机抽取的帧数,不能是第一帧,也不应该抽取连续帧。
    2. 抽取指定帧:传入指定帧数,形如 #3 #9。但这种方式不适用于批量处理。

    以上操作,将会使用 Shell 脚本实现。

    由于绝大多数场景可能不适合抽帧,暂不实现抽帧处理。

    目前已编写了一个 Shell 脚本去「批量、无损」处理 GIF 图片,主要优化点是「透明度存储」、「移除 Local Color Table」、「移除对渲染无用的扩展内容」。

    可通过 NPM 的方式安装此脚本。

    # 全局安装
    $ yarn global add https://github.com/tofrankie/gif-optimize.git
    
    # 使用
    $ gg <input-dir> -o <output-dir>
    

    更多请移步 gif-optimize 仓库。

    References

    ]]>
    <![CDATA[React Query 小记]]> https://github.com/tofrankie/blog/issues/376 https://github.com/tofrankie/blog/issues/376 Mon, 17 Nov 2025 09:20:11 GMT 配图源自 Freepik

    本文以 V5 版本为例

    配图源自 Freepik

    本文以 V5 版本为例

    Query Key

    它是由字符串、(可嵌套)对象、或两者兼具组成的数组。

    顺序

    数组的顺序很重要

    // 以下 Query Key 不相同
    useQuery({ queryKey: ['todos', status, page] })
    useQuery({ queryKey: ['todos', page, status] })
    useQuery({ queryKey: ['todos', undefined, page, status] })
    

    对象的顺序不重要

    // 以下 Query Key 相同
    useQuery({ queryKey: ['todos', { status, page }] })
    useQuery({ queryKey: ['todos', { page, status }] })
    useQuery({ queryKey: ['todos', { page, status, other: undefined }] })
    

    对象总是以确定性的 sort 排序:👇

    /**
     * Default query & mutation keys hash function.
     * Hashes the value into a stable hash.
     */
    export function hashKey(queryKey: QueryKey | MutationKey): string {
      return JSON.stringify(queryKey, (_, val) =>
        isPlainObject(val)
          ? Object.keys(val)
              .sort()
              .reduce((result, key) => {
                result[key] = val[key]
                return result
              }, {} as any)
          : val,
      )
    }
    

    结构化

    尽管 Query Key 在整个 Query 中唯一便可使用,但我们可以按一定的颗粒度进行划分。比如:

    useQuery({ queryKey: ['todos', 'list', { filters: 'all' }] })
    useQuery({ queryKey: ['todos', 'list', { filters: 'done' }] })
    useQuery({ queryKey: ['todos', 'detail', 1] })
    useQuery({ queryKey: ['todos', 'detail', 2] })
    

    这样做的好处是,在处理数据更新时更加灵活,或者批量使对应缓存失效:

    function useUpdateTitle() {
      return useMutation({
        mutationFn: updateTitle,
        onSuccess: newTodo => {
          // 更新单个数据
          queryClient.setQueryData(['todos', 'detail', newTodo.id], newTodo)
    
          // 更新列表数据
          queryClient.setQueriesData(['todos', 'list'], previous =>
            previous.map(todo => (todo.id === newTodo.id ? newTodo : todo))
          )
    
          // 使查询列表失效
          queryClient.invalidateQueries({queryKey: ['todos', 'list']})
        },
      })
    }
    

    Related link: https://tkdodo.eu/blog/effective-react-query-keys#structure

    有时,使用“纯对象”的 Query Key 更好:

    function useTodosQuery(filter: string) {
      return useQuery({
        // queryKey: ["todos", "list", filter],
        queryKey: [{scope: 'todos', entity: 'list', filter}],
        queryFn: ({queryKey}) => {
          // 保不准下次会新增什么参数,此时对象要优于数组
          // const [, , filter] = queryKey;
          const {filter} = queryKey
          return fetchTodos({filter})
        },
      })
    }
    

    Related Link: https://tkdodo.eu/blog/leveraging-the-query-function-context#object-query-keys

    Select

    在 useQuery 或 useInfinityQuery 中的 select 会在每次组件更新时执行,而不是查询结果变化时才执行,所以按需使用 useCallback(但如果处理不会很复杂,也不需要缓存处理)。

    References

    ]]>
    <![CDATA[在 Parallels Desktop 中使用 Node.js 遇到的问题]]> https://github.com/tofrankie/blog/issues/375 https://github.com/tofrankie/blog/issues/375 Fri, 14 Nov 2025 02:13:57 GMT 作个记录。

    访问 macOS 文件

    参考 从 Windows 应用程序访问 macOS 文件夹或文件

    corepack enable 没有操作权限

    在安装 Node.js 后,在 PowerShell 执行 corepack enable 报错:

    $ corepack enable
    Internal Error: EPERM: operation not permitted, open 'C:\Program Files\nodejs\pnpx'
    Error: EPERM: operation not permitted, open 'C:\Program Files\nodejs\pnpx'
    

    corepack enable 会尝试在 C:\Program Files\nodejs\ 目录下创建 pnpm、yarn、yarnpkg 可执行文件。但该目录属于系统目录,普通 PowerShell 没有写入权限,所以报错了。

    以管理员身份启动 PowerShell 运行执行便可解决。

    问题二

    $ npm install
    npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。有关详细信息,请参阅 https:/go.microsof
    t.com/fwlink/?LinkID=135170 中的 about_Execution_Policies。
    所在位置 行:1 字符: 1
    + npm install
    + ~~~
        + CategoryInfo          : SecurityError: (:) [],PSSecurityException
        + FullyQualifiedErrorId : UnauthorizedAccess
    

    前往 Node.js 安装目录 C:\Program Files\nodejs,右键「属性 - 安全 - 高级 - 权限」,选择 Users 并更改权限设为「完全控制」,并确认退出。

    问题三

    $ npm install
    npm warn cleanup Failed to remove some directories [
    npm warn cleanup   [
    npm warn cleanup     '\\\\?\\UNC\\psf\\Home\\Web\\Git\\raycast-wechat-devtool\\node_modules\\@typescript-eslint',
    npm warn cleanup     [Error: EPERM: operation not permitted, rmdir '\\psf\Home\Web\Git\raycast-wechat-devtool\node_modules\@typescript-eslint\eslint-plugin\dist'] {
    npm warn cleanup       errno: -4048,
    npm warn cleanup       code: 'EPERM',
    npm warn cleanup       syscall: 'rmdir',
    npm warn cleanup       path: '\\\\psf\\Home\\Web\\Git\\raycast-wechat-devtool\\node_modules\\@typescript-eslint\\eslint-plugin\\dist'
    npm warn cleanup     }
    npm warn cleanup   ]
    npm warn cleanup ]
    npm error code 1
    npm error path \\psf\Home\Web\Git\raycast-wechat-devtool\node_modules\esbuild
    npm error command failed
    npm error command C:\WINDOWS\system32\cmd.exe /d /s /c node install.js
    npm error '\\psf\Home\Web\Git\raycast-wechat-devtool\node_modules\esbuild'
    npm error ����Ϊ��ǰĿ¼������·�������� CMD.EXE��
    npm error UNC ·������֧�֡�Ĭ��ֵ��Ϊ Windows Ŀ¼��
    npm error node:internal/modules/cjs/loader:1424
    npm error   throw err;
    npm error   ^
    npm error
    npm error Error: Cannot find module 'C:\Windows\install.js'
    npm error     at Module._resolveFilename (node:internal/modules/cjs/loader:1421:15)
    npm error     at defaultResolveImpl (node:internal/modules/cjs/loader:1059:19)
    npm error     at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1064:22)
    npm error     at Module._load (node:internal/modules/cjs/loader:1227:37)
    npm error     at TracingChannel.traceSync (node:diagnostics_channel:328:14)
    npm error     at wrapModuleLoad (node:internal/modules/cjs/loader:245:24)
    npm error     at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
    npm error     at node:internal/main/run_main_module:33:47 {
    npm error   code: 'MODULE_NOT_FOUND',
    npm error   requireStack: []
    npm error }
    npm error
    npm error Node.js v24.11.1
    npm error A complete log of this run can be found in: C:\Users\frankie\AppData\Local\npm-cache\_logs\2025-11-14T01_57_43_855Z-debug-0.log
    
    1. 由于 macOS 的目录是只读的,需要手动对具体目录移除「只读」属性。
    2. 项目位于 Parallels Desktop 的共享目录(UNC 路径),Parallels 的 psf 文件系统不完全兼容 Windows 的权限系统。使用 PS Z:\Web\Git\raycast-wechat-devtool> 替代 \psf\Home\Web\Git\raycast-wechat-devtool。

    问题四

    在 VS Code 中使用源代码管理无法查看 Changes 内容,提示:The editor could not be opened due to an unexpected error. Please consult the log for more details.

    Error: Unable to read file '\\Mac\Home\Web\Git\raycast-wechat-devtool\src\utils\command.ts' (Unknown (FileSystemError): UNC host 'mac' access is not allowed. Please update the 'security.allowedUNCHosts' setting if you want to allow this host.)
    

    参考 Working with UNC paths,在 VS Code 配置:

    {
      "security.allowedUNCHosts": ["mac"]
    }
    
    
    ]]>
    <![CDATA[在 Parallels Desktop 中通过 ISO 镜像文件升级 Windows]]> https://github.com/tofrankie/blog/issues/374 https://github.com/tofrankie/blog/issues/374 Thu, 06 Nov 2025 04:17:33 GMT 配图源自 Freepik

    背景

    此前在设置中进行 Windows 系统更新,总是失败]]> 配图源自 Freepik

    背景

    此前在设置中进行 Windows 系统更新,总是失败,提示安装错误 - 0xc1900101。💢

    方法

    现通过本地镜像文件方式进行升级:

    1. 前往 https://www.microsoft.com/en-us/software-download/ 下载 Windows 镜像(按需下载)
    2. 在 Parallels Desktop 控制中心,打开镜像配置窗口
    3. 切换至硬件选项卡,在「CD/DVD - 源」处选择你的 ISO 镜像文件
    4. 启动 Windows 虚拟机,会自动挂载一个虚拟光驱(例如 D:)
    5. 在文件管理器打开该光驱,点击 setup.exe 进行升级
    ]]>
    <![CDATA[解决 Windows 启动错误代码 0x0000605]]> https://github.com/tofrankie/blog/issues/373 https://github.com/tofrankie/blog/issues/373 Thu, 06 Nov 2025 02:38:19 GMT 配图源自 Freepik

    前情提要

    • 系统:macOS (Apple Si]]> 配图源自 Freepik

      前情提要

      • 系统:macOS (Apple Silicon)
      • 虚拟机:Parallels Desktop (Windows 11 ARM)

      Windows 的版本还是 2023 年安装的 22H2 Beta 版本(2324.1000 ni_prerelease),很久没有在电脑上启动过虚拟机了。

      近期打开发现启动不了,报错如下:

      搜了一下,错误代码 0x0000605 表示系统的安全验证失败(Secure Boot 验证无法通过),最常见原因是系统时间错误或 Secure Boot 数据损坏。在 Windows ARM 或 EFI 环境中,如果系统时间落在证书签发时间之外(例如时间太早),启动加载的签名校验会失败,于是报 0x0000605。

      失败的尝试

      根据 ChatGPT,可以在这样处理:

      1. 按下 Win 键,进入 UEFI 主界面
      2. 进入 Boot Maintenance Manager
      3. 选择 Advanced Boot Options 或 Change System Date and Time(不同版本略有不同)
      4. 设定为当前正确的日期和时间
      5. 返回主界面,选择 Continue 启动 Windows

      遗憾的是,我这里并没有时间选项,所以此方案失败告终!

      它还提到在 Boot Maintenance Manager 界面,选择 Boot From File,找到 \EFI\BOOT\BOOTAA64.EFI 文件(标准的 EFI Shell 文件),按 Enter 启动它。

      先后输入以下命令,看看是不是很早的日期,如果是说明时间错误。

      $ date
      $ time
      

      接着,移除执行以下命令设置当前时间,设置后再次使用 date、time 命令确认是否设置成功。

      $ date 11/06/2025
      $ time 09:00:00
      

      格式:

      • 日期:MM/DD/YYYY
      • 时间:HH:MM:SS(24 小时制)

      退出:

      $ exit
      

      返回主界面,选择 Continue 启动 Windows

      但我选择 \EFI\BOOT\BOOTAA64.EFI 回车,并没有进入命令行界面,而是回到文章开头的蓝屏状态,所以这个方案对我也不适用。

      成功解决

      继续请教 ChatGPT,它提到「关闭 Secure Boot 绕过签名检查」。

      1. 按下 Win 键,进入 UEFI 主界面
      2. 进入 Device Manager
      3. 选择 Secure Boot Configuration
      4. 将 Attempt Secure Boot 或 Enable Secure Boot 改为 Disabled(界面上 [X] 表示 Enable,[] 表示 Disabled)
      5. 保存退出,回到主界面,选择 Continue 启动 Windows

      接着,我出现了新的报错:

      说明 Secure Boot 的确已经被你成功关闭了。

      但新的蓝屏(BitLocker 恢复界面)说明——Windows 系统磁盘启用了 BitLocker 加密,而 Secure Boot 的关闭让 TPM(可信平台模块)状态变化,Windows 因此认为“系统被篡改”,所以要求恢复密钥。

      1. 前往 https://account.microsoft.com/devices/recoverykey
      2. 登录与虚拟机中 Windows 相同的 Microsoft 帐号。
      3. 找到这台设备对应的恢复密钥。
      4. 在蓝屏界面输入该密钥后,即可继续启动。

      在前面的蓝屏界面,按回车(还是 Win 键来着)进入 Windows 恢复环境界面:

      选择「Troubleshoot - Advanced Options - Command Prompt」进入 cmd.exe 命令行窗口。

      1. 查看加密卷:
      $ manage-bde -status
      

      这会列出系统上所有磁盘的加密状态(C:、D:、E: 等)。

      1. 解锁目标盘
      $ manage-bde -unlock C: -RecoveryPassword <your-key>
      

      我要解锁的是 C 盘,key 是前面提到的恢复密钥(一串 48 位数字)

      1. 关闭 BitLocker
      $ manage-bde -off C:
      
      1. 确认界面是否完成
      $ manage-bde -status
      

      如果输出结果包含以下两行,表示解密已完成。

      Percentage Encrypted: 0.0%
      Conversion Status: Fully Decrypted
      

      如果显示如下内容,说明仍在后台解密中,不要强制中断或关机,耐心等待完成。

      Conversion Status: Decrypting
      Percentage Encrypted: XX%
      
      1. 退出命令行
      $ exit
      

      回到主界面,选择 Continue,启动系统。

      至此,我的 Windows 恢复了。

      ]]>
      <![CDATA[记十月:珠海 - 澳门三日]]> https://github.com/tofrankie/blog/issues/372 https://github.com/tofrankie/blog/issues/372 Sat, 01 Nov 2025 17:57:01 GMT 简要
      • 时间:10.17 ~ 10.19
      • 地点:珠海、澳门 🇲🇴
      • 去返:中旅大巴(广州 - 珠海)

      出发之前

      准备:

      • 证件类:身份证、港澳通行证(含澳门有效签注)
      • ]]> 简要
        • 时间:10.17 ~ 10.19
        • 地点:珠海、澳门 🇲🇴
        • 去返:中旅大巴(广州 - 珠海)

        出发之前

        准备:

        • 证件类:身份证、港澳通行证(含澳门有效签注)
        • 药品类:一些必要的基础药品
        • 其他:少量现金 💵

        关于要不要兑换葡币(澳门元)?

        1. 当前汇率:「人民币 : 葡币 : 港币」是「1 : 1.13 : 1.09」
        2. 通关口岸很多找换店,但收点手续费(约 100 ~ 5),金额较大建议提前去银行换
        3. 澳门很多店都支持微信、支付宝、人民币结算,但: a. 公交、轻轨等基础设施,或者一些大店,使用国内移动支付会自动折算汇率 b. 一些如便利店、出租车等小作坊,也可能支持人民币,大概率是按「1 : 1 : 1」进行支付

        未完待续...

        ]]>
        <![CDATA[浅谈 TypeScript 泛型]]> https://github.com/tofrankie/blog/issues/371 https://github.com/tofrankie/blog/issues/371 Tue, 09 Sep 2025 10:28:15 GMT 配图源自 Freepik

        假设现在需要给 id 函数标注类型,它接收任意类型的]]> 配图源自 Freepik

        假设现在需要给 id 函数标注类型,它接收任意类型的参数并原样返回。

        function id(arg) {
          return arg
        }
        

        如何描述参数与返回值的类型关系呢?

        较笨的做法是使用函数重载把可能的类型都列出来,但这样写下去,没写完就下班了...

        function id(arg: number): number
        function id(arg: string): string
        function id(arg: boolean): boolean
        // ...
        

        类型可以有无数种(包括复合类型、联合类型等),根本穷举不完。

        如果希望类型足够灵活,这也不是一种合理的做法。

        我们知道,参数 arg 的具体值只有在函数调用时才能确认,参数类型也是同样的。

        TS 的类型也是支持函数编程的,伪代码如下:

        function Id(Arg) {
          return Arg
        }
        

        把类型函数化,也就是说 Arg 类型取决于函数调用时入参的类型。比如,当输入为 string 类型,输出也是 string 类型。

        但这样写跟 JS 的函数语法有冲突,实际会被当作一个名为 Id 的 JS 函数。

        TS 需要使用特有的语法来表达类型函数,用的是 <>,类似于 JS 函数的 (),在其内部声明类型的形参、函数的形参,伪代码如下:

        // 伪代码
        function Id<Arg> {
          return Arg
        }
        

        历史故事:由于 TS 比 React 早出生,刚开始 TS 跟 JSX 的 <> 语法是有冲突的,直到 TS 1.6 才开始支持 JSX。

        前面都是伪代码,最后 TS 是这么干的,它把类型函数和函数本身融合在一起,语法如下:

        ⚠️ 注意,「类型函数」只是本文为了让读者更好地理解泛型而使用的一种表述,并非官方定术语。

        function id<Arg>(arg: Arg): Arg {
          return arg
        }
        

        我们可以这样去解读:

        1. 它是由函数本身和类型函数两部分组成。
        2. 函数本身和类型函数都可以定义形参,分别在 ()<> 内定义。
        3. 类型函数的形参在 <> 内定义,其作用范围(即函数体)是函数本身的形参类型和返回值类型。
        4. 因为形参本身也算是一种变量,所以形参的 Arg 可以命名为其他合法的变量名。习惯上,T(Type)及其后面的字母 UV 用得较多,类似于循环遍历常用的 i、j、k 差不多,算是一种约定俗成的写法吧。

        使用更广泛的 T 替换 Arg,是不是很眼熟:

        function id<T>(arg: T): T {
          return arg
        }
        

        得益于 TS 强大的自动类型推断,这个例子中不显式指定返回值类型也是 OK 的,它可以自动识别返回类型就是 T

        function id<T>(arg: T) {
          return arg
        }
        

        函数调用,这样传递类型参数:

        id<string>('abc')
        id<number>(1)
        

        同样地,利用 TS 的类型推断,可以省略调用时 <> 类型:

        id('abc')
        id(1)
        

        以上就是泛型在函数中的应用。

        基础语法

        前面我们将泛型“函数化”,实际上它也是支持定义多个类型参数、以及类型参数默认值的。

        多个泛型参数:

        function fn<T, U>(x: T, y: U) {
          // do something...
        }
        

        泛型参数默认值:

        function fn<T, U = string>(x: T, y: U) {
          // do something...
        }
        

        有默认值意味着它是可选参数,它必须放在必选参数之后声明,因此 <T = string, U> 是错误写法。

        泛型参数约束:

        function getLength<T extends {length: number}>(arg: T) {
          return arg.length
        }
        
        getLength('abc')
        getLength([])
        

        未完待续...

        体操演练场

        ]]>
        <![CDATA[为什么函数返回布尔类型不会触发类型收窄?]]> https://github.com/tofrankie/blog/issues/370 https://github.com/tofrankie/blog/issues/370 Sun, 07 Sep 2025 17:36:11 GMT 配图源自 Freepik

        假设有一个判断字符串的函数:

        假设有一个判断字符串的函数:

        function isStr(value: unknown) {
          return typeof value === 'string'
        }
        

        TS 会自动推断返回类型为 boolean

        let value: number | string = Math.random() > 0.5 ? 'hello' : 1
        
        if (isStr(value)) {
          // ❌ Property 'length' does not exist on type 'string | number'.
          value.length
        }
        

        但使用时会有问题,可见 TS 并没有做类型收窄。

        解决方法也简单,使用类型谓词(Type Predicates)即可:

        function isStr(value: unknown): value is string {
          return typeof value === 'string'
        }
        

        [!NOTE] 注意,自 TypeScript 5.5 起支持推断类型谓词,无需显式指定。

        类型谓词用于描述函数的返回值类型。这里 value is string 并不是返回类型,而是告诉 TS 如果函数返回 true,那么参数 value 一定是 string 类型。换句话说,当函数返回 true 时,告诉 TS 编译器参数 value 的类型应该被收窄为 string

        为什么函数返回布尔类型不会触发类型收窄?

        因为 TS 的类型系统是静态的,编译时它不会去分析函数的实现,只是根据签名(返回类型)来决定能不能做收窄。只告诉 TS 返回真假,不会带来额外的类型信息,所以不会收窄。

        类型收窄必须依赖签名(类型谓词)信息,而不是依赖函数实现。

        附上类型收窄的方式:

        • typeof
        • instanceof
        • in 操作符
        • 判别联合(字面量字段)
        • 真值收窄(if 语句)
        • 等式收窄(===!== 等)
        • 类型谓词
        • 控制流分析(如赋值收窄等)
        • Exhaustive check + never
        ]]> <![CDATA[TypeScript 中 Object、object、{} 之间的区别]]> https://github.com/tofrankie/blog/issues/369 https://github.com/tofrankie/blog/issues/369 Thu, 04 Sep 2025 10:12:25 GMT 配图源自 Freepik

        写在前面

        在 JS 中,构造函数 Object<]]> 配图源自 Freepik

        写在前面

        在 JS 中,构造函数 Object、空对象字面量 {} 可以作为值使用,因此它俩在 TS 中也可以作为值使用。

        在 TS 中,Objectobject{} 还可以作为类型使用。注意 Objectobject 是两种不同的类型。还有 object 是 TS 特有的类型,只能用于类型,不能作为值。

        迷之写法 🤯

        请问这几种写法有什么不同?它们可以接收哪些值?

        // 1️⃣
        const obj = {}
        
        // 2️⃣
        const obj: {} = {}
        
        // 3️⃣
        const obj: {}
        
        // 4️⃣
        const obj: Object
        
        // 5️⃣
        const obj: object
        

        object(小 o)

        回顾一下,在 JS 中有「基本数据类型」和「引用数据类型」两种数据类型:

        • 基本数据类型
          • Number
          • String
          • Boolean
          • Symbol
          • BigInt
          • Undefined
          • Null
        • 引用数据类型
          • Object(包括对象、数组、函数、Map、Set、Date 等)

        注意,以上用大写字母开头的表述形式与 TS 无关,把它换成中文(如字符串类型)也行,别混淆了。

        这些数据类型对应的 TS 类型如下:

        • 基本数据类型:
          • number
          • string
          • boolean
          • symbol
          • bigint
          • undefined
          • null
        • 引用数据类型
          • object

        到这里应该理解了,object 表示一个引用值的类型。

        // 所有基本数据类型的类型
        type Primitive = number | string | boolean | symbol | bigint | null | undefined
        
        // 所有引用数据类型的类型
        type NonPrimitive = object // 相当于 Exclude<unknown, Primitive>
        

        示例:只要值是引用值便可以赋值给 object 类型的变量。

        let obj: object
        
        obj = 1 // ❌ Type 'number' is not assignable to type 'object'.
        obj = 'abc' // ❌ Type 'string' is not assignable to type 'object'.
        obj = true // ❌ Type 'boolean' is not assignable to type 'object'.
        obj = Symbol('abc') // ❌ Type 'symbol' is not assignable to type 'object'.
        obj = 1n // ❌ Type 'bigint' is not assignable to type 'object'.
        obj = null // ❌ Type 'null' is not assignable to type 'object'.
        obj = undefined // ❌ Type 'undefined' is not assignable to type 'object'.
        
        obj = {} // ✅
        obj = Object // ✅
        obj = { foo: 123 } // ✅
        obj = [1, 2] // ✅
        obj = (a: number, b: number) => a + b // ✅
        

        虽说可以把任意的引用值赋值给 obj 变量,但是其访问属性时,TS 为了确保安全访问,只能访问其包含的类型中都存在的属性,否则要做类型收窄。

        let a: object
        
        a = [] // ✅
        a = {} // ✅
        
        a.join(',') // ❌ 像普通对象就没有 join 属性,因此抛出错误 Property 'join' does not exist on type 'object'.
        a.toString() // ✅ 共有属性
        
        if (Array.isArray(a)) {
          a.join(',') // ✅ 数组独有的方法
        }
        

        Object(大 O)

        先从 JS 讲起。我们知道 JS 的原始值是没有任何属性和方法的。

        const foo = 'abc'
        foo.length // 3
        

        那为什么 foo.length 不报错呢?原因是内部相当于这样,首先将其转为引用类型,实际读取的是 String 实例对象的 length 属性。

        const foo = 'abc'
        Object(3).length // 3
        

        undefinednull 这两个家伙无法转为引用类型,因此类似 undefined.length 这种会抛出 TypeError。

        前面铺垫完之后,再看看示例:

        let obj: Object
        
        obj = 1 // ✅
        obj = 1n // ✅
        obj = 'abc' // ✅
        obj = true // ✅
        obj = null // ❌ Type 'null' is not assignable to type 'Object'.
        obj = undefined // ❌ Type 'undefined' is not assignable to type 'Object'.
        obj = Symbol('abc') // ✅
        
        obj = {} // ✅
        obj = Object // ✅
        obj = { foo: 123 } // ✅
        obj = [1, 2] // ✅
        obj = (a: number, b: number) => a + b // ✅
        

        任意非 undefinednull 的值,都可以赋值给 Object 类型(指 TS 类型)的变量。

        Object 表示的类型范围太宽了,实际中应该少用。

        空对象 {}

        在 TS 中,空对象 {} 既是一个值,也是一个特殊的类型。

        以下两种写法是等价的:

        const obj = {}
        const obj: {} = {}
        

        TS 自动推导出 obj 的类型是 {},即 Object

        以下两种写法是等价的:

        const obj: {}
        const obj: Object
        

        所以使用 {} 作为类型时,它实际上是 Object 类型的简写形式。

        要注意下,虽然两者形式上有点像,第一个本质上是 Object 类型,所以它不会触发对象的严格字面量检查。

        let obj: {} = { a: 1 } // ✅
        
        let obj2: { x: number } = { x: 1, y: 2 } // ❌ Object literal may only specify known properties, and 'y' does not exist in type '{ x: number; }'.
        

        总结

        • object 类型的变量仅接受任意引用值。
        • Object 类型的变量接受除 undefinednull 之外的任意值。
        • {} 作为类型时,它实际上是 Object 类型的简写。即 const obj: {} 等价于 const obj: Object

        回到文章开头令人抓狂的示例:

        // 1️⃣
        const obj = {}
        
        // 2️⃣
        const obj: {}
        
        // 3️⃣
        const obj: {} = {}
        
        // 4️⃣
        const obj: Object
        
        // 5️⃣
        const obj: object
        

        1️⃣、2️⃣、3️⃣、4️⃣ 为同一类,都是 Object 类型,5️⃣ 则是 object 类型。

        ]]>
        <![CDATA[TypeScript 类型关系图]]> https://github.com/tofrankie/blog/issues/368 https://github.com/tofrankie/blog/issues/368 Wed, 03 Sep 2025 16:32:11 GMT 类型分类
        flowchart TD
            TypeScript[TypeScript 类型系统]
            
            TypeScript --> PrimitiveTypes[基本类型]
            TypeScript --]]>
                    类型分类
        
        flowchart TD
            TypeScript[TypeScript 类型系统]
            
            TypeScript --> PrimitiveTypes[基本类型]
            TypeScript --> ObjectTypes[对象类型]
            TypeScript --> SpecialTypes[特殊类型]
            TypeScript --> CompoundTypes[复合类型]
        

        基本类型

        flowchart TD
            PrimitiveTypes[基本类型] --> String[string]
            PrimitiveTypes --> Number[number]
            PrimitiveTypes --> Boolean[boolean]
            PrimitiveTypes --> BigInt[bigint]
            PrimitiveTypes --> Symbol[symbol]
            PrimitiveTypes --> Null[null]
            PrimitiveTypes --> Undefined[undefined]
        

        对象类型

        flowchart TD
            ObjectTypes[对象类型] --> Object[Object]
            ObjectTypes --> Function[Function]
            ObjectTypes --> Array[Array]
            ObjectTypes --> Promise[Promise]
            ObjectTypes --> Date[Date]
            ObjectTypes --> RegExp[RegExp]
            ObjectTypes --> Error[Error]
            ObjectTypes --> Map[Map]
            ObjectTypes --> Set[Set]
            ObjectTypes --> WeakMap[WeakMap]
            ObjectTypes --> WeakSet[WeakSet]
            ObjectTypes --> Iterator[Iterator]
            ObjectTypes --> Generator[Generator]
            
            Function --> Object
            Array --> Object
            Promise --> Object
            Date --> Object
            RegExp --> Object
            Error --> Object
            Map --> Object
            Set --> Object
            WeakMap --> Object
            WeakSet --> Object
            Iterator --> Object
            Generator --> Object
        

        特殊类型

        flowchart TD
            SpecialTypes[特殊类型] --> Never[never]
            SpecialTypes --> Unknown[unknown]
            SpecialTypes --> Any[any]
            SpecialTypes --> Void[void]
        

        复合类型

        flowchart TD
            CompoundTypes[复合类型] --> UnionType[联合类型]
            CompoundTypes --> IntersectionType[交叉类型]
            CompoundTypes --> TupleType[元组类型]
            CompoundTypes --> LiteralType[字面量类型]
        

        所有类型

        flowchart TD
            TypeScript[TypeScript 类型系统]
            
            TypeScript --> PrimitiveTypes[基本类型]
            TypeScript --> ObjectTypes[对象类型]
            TypeScript --> SpecialTypes[特殊类型]
            TypeScript --> CompoundTypes[复合类型]
            
            PrimitiveTypes --> String[string]
            PrimitiveTypes --> Number[number]
            PrimitiveTypes --> Boolean[boolean]
            PrimitiveTypes --> BigInt[bigint]
            PrimitiveTypes --> Symbol[symbol]
            PrimitiveTypes --> Null[null]
            PrimitiveTypes --> Undefined[undefined]
            
            ObjectTypes --> Object[Object]
            ObjectTypes --> Function[Function]
            ObjectTypes --> Array[Array]
            ObjectTypes --> Promise[Promise]
            ObjectTypes --> Date[Date]
            ObjectTypes --> RegExp[RegExp]
            ObjectTypes --> Error[Error]
            ObjectTypes --> Map[Map]
            ObjectTypes --> Set[Set]
            ObjectTypes --> WeakMap[WeakMap]
            ObjectTypes --> WeakSet[WeakSet]
            ObjectTypes --> Iterator[Iterator]
            ObjectTypes --> Generator[Generator]
            
            SpecialTypes --> Never[never]
            SpecialTypes --> Unknown[unknown]
            SpecialTypes --> Any[any]
            SpecialTypes --> Void[void]
            
            CompoundTypes --> UnionType[联合类型]
            CompoundTypes --> IntersectionType[交叉类型]
            CompoundTypes --> TupleType[元组类型]
            CompoundTypes --> LiteralType[字面量类型]
            
            Function --> Object
            Array --> Object
            Promise --> Object
            Date --> Object
            RegExp --> Object
            Error --> Object
            Map --> Object
            Set --> Object
            WeakMap --> Object
            WeakSet --> Object
            Iterator --> Object
            Generator --> Object
        

        类型赋值关系

        严格模式

        flowchart TD
            %% 底层类型
            Never[never - 底层类型]
            
            %% 第四层 - 特殊值类型
            Null[null]
            Undefined[undefined]
            Void[void]
            
            %% 第三层 - 基本类型
            String[string]
            Number[number]
            Boolean[boolean]
            BigInt[bigint]
            Symbol[symbol]
            
            %% 第二层 - 对象类型
            Object[Object - 对象基类]
            
            %% 顶层类型
            Any[any - 顶层类型]
            Unknown[unknown - 类型安全顶层]
            
            %% 正确的赋值关系
            Never --> Any
            Never --> Unknown
            Never --> Object
            Never --> String
            Never --> Number
            Never --> Boolean
            Never --> BigInt
            Never --> Symbol
            Never --> Null
            Never --> Undefined
            Never --> Void
            
            %% 基本类型和对象类型可以赋值给 unknown 和 any
            String --> Any
            String --> Unknown
            Number --> Any
            Number --> Unknown
            Boolean --> Any
            Boolean --> Unknown
            BigInt --> Any
            BigInt --> Unknown
            Symbol --> Any
            Symbol --> Unknown
            
            Object --> Any
            Object --> Unknown
            
            %% 特殊值类型只能赋值给 unknown 和 any
            Null --> Any
            Null --> Unknown
            Undefined --> Any
            Undefined --> Unknown
            Void --> Any
            Void --> Unknown
            
            %% unknown 可以赋值给 any
            Unknown --> Any
            
            %% 分组
            subgraph BottomLevel[底层类型层]
                Never
            end
            
            subgraph SpecialLevel[特殊值类型层]
                Null
                Undefined
                Void
            end
            
            subgraph PrimitiveLevel[基本类型层]
                String
                Number
                Boolean
                BigInt
                Symbol
            end
            
            subgraph ObjectLevel[对象类型层]
                Object
            end
            
            subgraph TopLevel[顶层类型层]
                Any
                Unknown
            end
        

        非严格模式

        flowchart TD
            %% 底层类型
            Never[never - 底层类型]
            
            %% 第四层 - 特殊值类型
            Null[null]
            Undefined[undefined]
            Void[void]
            
            %% 第三层 - 基本类型
            String[string]
            Number[number]
            Boolean[boolean]
            BigInt[bigint]
            Symbol[symbol]
            
            %% 第二层 - 对象类型
            Object[Object - 对象基类]
            
            %% 顶层类型
            Any[any - 顶层类型]
            Unknown[unknown - 类型安全顶层]
            
            %% 正确的赋值关系
            Never --> Any
            Never --> Unknown
            Never --> Object
            Never --> String
            Never --> Number
            Never --> Boolean
            Never --> BigInt
            Never --> Symbol
            Never --> Null
            Never --> Undefined
            Never --> Void
            
            %% 基本类型和对象类型可以赋值给 unknown 和 any
            String --> Any
            String --> Unknown
            Number --> Any
            Number --> Unknown
            Boolean --> Any
            Boolean --> Unknown
            BigInt --> Any
            BigInt --> Unknown
            Symbol --> Any
            Symbol --> Unknown
            
            Object --> Any
            Object --> Unknown
            
            %% 非严格模式下的额外赋值关系 - 只有 null 和 undefined
            Null --> Any
            Null --> Unknown
            Null --> String
            Null --> Number
            Null --> Boolean
            Null --> BigInt
            Null --> Symbol
            
            Undefined --> Any
            Undefined --> Unknown
            Undefined --> String
            Undefined --> Number
            Undefined --> Boolean
            Undefined --> BigInt
            Undefined --> Symbol
            
            %% void 类型始终只能赋值给 unknown 和 any
            Void --> Any
            Void --> Unknown
            
            %% unknown 可以赋值给 any
            Unknown --> Any
            
            %% 分组
            subgraph BottomLevel[底层类型层]
                Never
            end
            
            subgraph SpecialLevel[特殊值类型层]
                Null
                Undefined
                Void
            end
            
            subgraph PrimitiveLevel[基本类型层]
                String
                Number
                Boolean
                BigInt
                Symbol
            end
            
            subgraph ObjectLevel[对象类型层]
                Object
            end
            
            subgraph TopLevel[顶层类型层]
                Any
                Unknown
            end
        
        ]]>
        <![CDATA[东风 5C 打击范围覆盖全球]]> https://github.com/tofrankie/blog/issues/367 https://github.com/tofrankie/blog/issues/367 Wed, 03 Sep 2025 15:32:49 GMT 中华人民共和国万岁

        人民有信仰,国家有力量,民族有希望

        新华社出图 中华人民共和国万岁

        人民有信仰,国家有力量,民族有希望

        新华社出图

        ]]> <![CDATA[联合类型与交叉类型的反直觉]]> https://github.com/tofrankie/blog/issues/366 https://github.com/tofrankie/blog/issues/366 Fri, 29 Aug 2025 04:01:27 GMT 前言
        • 联合类型:要么 A,要么 B(只要满足任意一个分支)
        • 交叉类型:必须同时满足 A 和 B(必须满足所有分支)
        type Union = A | B
        
        type Intersec]]>
                    前言
        
        • 联合类型:要么 A,要么 B(只要满足任意一个分支)
        • 交叉类型:必须同时满足 A 和 B(必须满足所有分支)
        type Union = A | B
        
        type Intersection = A & B
        

        看起来,联合类型像数学上的“并集”,交叉类型像“交集”。

        符合直觉

        ▼ 要么是字符串,要么是数字,这是符合直觉的。

        type DataUnion = string | number
        
        let data: DataUnion
        
        data = 'abc' // ✅
        data = 123 // ✅
        

        ▼ 由于不可能存在同时满足是字符串,也是数字的值,因此 DataIntersection 类型是 never。这也是符合直觉的。

        type DataIntersection = string & number
        
        let data: DataIntersection
        
        data = 'abc' // ❌ Type '"abc"' is not assignable to type 'never'.
        data = 123 // ❌ Type '123' is not assignable to type 'never'.
        

        ▼ 以下为对象类型,Shape 应该保持一致,是符合直觉的。

        type Point = { x: number }
        
        let p: Point
        
        p = { x: 1 } // ✅
        p = { y: 2 } // ❌ Object literal may only specify known properties, and 'y' does not exist in type 'Point'.
        p = { x: 1, y: 2 } // ❌ Object literal may only specify known properties, and 'y' does not exist in type 'Point'.
        

        ▼ 以下为对象的交叉类型,要符合「必须同时满足 A 和 B」结论,其结果必须是一个包含 xy 属性新对象类型,是符合直觉的。

        type PointIntersection = { x: number } & { y: number }
        
        let p: PointIntersection
        
        p = { x: 1 } // ❌ Property 'y' is missing in type '{ x: number; }' but required in type '{ y: number; }'.
        p = { y: 2 } // ❌ Property 'x' is missing in type '{ y: number; }' but required in type '{ x: number; }'.
        p = { x: 1, y: 2 } // ✅
        p = { x: 1, y: 2, z: 3 } // ❌ Object literal may only specify known properties, and 'z' does not exist in type 'PointIntersection'.
        

        ▼ 以下示例访问属性 point.xpoint.y 报错是符合直觉的,一个没有 x、一个没有 y。TypeScript 会要求所有成员类型里都保证存在的属性,才允许安全访问。该方法的修正方式是做类型收窄。

        type PointUnion = { x: number } | { y: number }
        
        function showPoint(point: PointUnion) {
          const x = point.x ?? 0 // ❌ Property 'x' does not exist on type '{ y: number; }'.
          const y = point.y ?? 0 // ❌ Property 'y' does not exist on type '{ x: number; }'.
          return `(${x}, ${y})`
        }
        

        反直觉

        ▼ 以下示例,按理 p = obj 报错才是符合直觉的。

        type Point = { x: number }
        
        let p: Point
        
        let obj = { x: 1, y: 2 }
        
        p = { x: 1, y: 2 } // ❌ Object literal may only specify known properties, and 'y' does not exist in type 'Point'.
        p = obj // ✅
        

        原因如下:

        1. 如果赋值语句等号右边是一个字面量,会触发对象的严格字面量检查(strict object literal checking)。因此多了未定义的属性 y 会报错。
        2. 如果赋值语句等号右边是一个变量,则不会触发对象的严格字面量检查,所以多了属性没报错(但不能少)。尽管赋值的 obj 是包含 y 属性的,但通过 p.y 访问属性仍然会报错,因为 p 的类型 Point 并不包含 y 属性。

        对于 p = obj 这类不触发严格字面量检查,是有实际意义的。比如接口的返回数据有 N 多字段,但业务上实际用到可能只有其中一部分,这种情况下只需声明用到的字段即可。

        ▼ 以下示例,为什么 p = { x: 1, y: 2 } 又不报错?

        type PointUnion = { x: number } | { y: number }
        
        let p: PointUnion
        
        p = { x: 1 } // ✅
        p = { y: 2 } // ✅
        p = { x: 1, y: 2 } // ✅ 明明是字面量,又不报错了???
        

        原因是触发对象的严格字面量检查,有两个要求:

        1. 赋值语句等号右边是一个字面量
        2. 被赋值对象的类型是单一的对象类型(非联合、非索引签名)

        ▼ 再看示例,原因跟前面一样,就是看起来有点奇怪。

        type PointUnion = { x: number } | { y: number }
        type PointIntersection = { x: number } & { y: number }
        
        let point1: PointUnion
        let point2: PointIntersection
        
        point1 = { x: 1, y: 2 } // ✅
        point2 = { x: 1, y: 2 } // ✅
        
        ]]>
        <![CDATA[微信公众号是如何加载、处理图片的?]]> https://github.com/tofrankie/blog/issues/365 https://github.com/tofrankie/blog/issues/365 Fri, 11 Jul 2025 09:55:19 GMT 作个记录。

        下载网页

        $ wget -r -np -k -L -p https://mp.weixin.qq.com/s/Ks8LG7bjmmCpfKp-jkANrg
        
        <]]> 作个记录。

        下载网页

        $ wget -r -np -k -L -p https://mp.weixin.qq.com/s/Ks8LG7bjmmCpfKp-jkANrg
        

        似乎不太行的样子

        开始时

        1. 查找所有包含背景的元素 dom.querySelectorAll('[style*="background-image"]')
        2. 将背景图替换为本地占位图 bgPlaceholder(1 像素的 base64 图片)。
        3. 将原来的背景图添加到 data-lazy-bgimg 属性上。
        if (!window.__second_open__ && !isCareMode && !isCartoonCopyright) {
          containers.forEach(function (dom) {
            var containsBackground = dom.querySelectorAll('[style*="background-image"]')
            _toConsumableArray(containsBackground).forEach(function (node) {
              if (
                node &&
                node.style &&
                typeof node.getAttribute === 'function' &&
                !node.getAttribute('data-lazy-bgimg') &&
                !window.__lazyload_detected
              ) {
                var bgImg = node.style.backgroundImage
                var bgImgUrl = bgImg && bgImg.match(/url\(['"]?(.*?)['"]?\)/)
                if (bgImgUrl && bgImgUrl[1]) {
                  node.style.backgroundImage = bgImg.replace(/url\(['"]?.*?['"]?\)/, bgPlaceholder)
                  node.setAttribute('data-lazy-bgimg', bgImgUrl[1])
                  node.classList.add('wx_imgbc_placeholder')
                }
              }
            })
          })
        }
        

        未完待续...

        ]]>
        <![CDATA[如何开发一个 Raycast 扩展?]]> https://github.com/tofrankie/blog/issues/364 https://github.com/tofrankie/blog/issues/364 Mon, 07 Jul 2025 13:29:52 GMT 配图源自 Freepik

        前言

        是的,我已经将

        前言

        是的,我已经将 Alfred 换为 Raycast 了,后者的免费功能足够好用。

        但也没有完全丢掉 Alfred,还在用它的剪贴板历史功能。

        此前看到一个帖子说:Alfred 用户一定要试试 Raycast。当时我不以为意。

        用了七、八年的 Alfred 几乎没怎么“变过”,有种不思进取的感觉。特别是近几年 AI 的发展,它好像还没反应过来一样。

        对比

        在使用 Alfred 时,用得最多的功能是:

        • Web Search - 快速跳转,通常用于搜索文档等。
        • Clipboard History - 剪贴板历史,没什么好说的,用过都说好用。
        • Workflow - 工作流,比如翻译、打开项目等,写过一两个插件,开发体验不好,能做的有限。

        这些在 Raycast 都有替代方案,甚至更好。除此之外,Raycast 的界面更现代一些,跟最新的 macOS 风格更加契合。并且可以完全通过键盘完成一系列操作。

        Raycast 官方博文:Raycast vs Alfred

        最重要的是,Raycast Extension 的开发体验比 Alfred 好太多了。官方有提供很多了 API,统一的 UI 风格,有统一的分发 Store,不用网上到处搜索。活跃度很高,目前扩展数量 2200+

        比较麻烦的是,在 Raycast 发布扩展,需要经过官方审核,通过后才会上传到 Store,审核周期较慢!

        开始之前

        技术栈

        Node.js + TypeScript + React

        虽然使用 React 构建,但只能使用官方提供的内置组件,不能过多地自定义(可能是 React to macOS Native 的缘故吧),好处是 UI 风格统一。

        安装扩展

        • 键入 Store 搜索安装
        • 键入 Import Extension 在本地导入

        键入 Manage Extensions 可以管理本地扩展。

        卸载扩展

        • 键入关键词,选中要卸载的扩展,在 Action Panel 选择 Uninstall Extension。
        • 键入 Manage Extension 可管理本地扩展。

        在 Raycast 中卸载本地扩展(即非通过 Store 安装的扩展)时,本地扩展文件不会被删除。

        扩展路径 ~/Library/Application Support/com.raycast.macos/extensions

        创建扩展

        键入 Create Extension 回车输入必要信息,便创建好了。

        官方提供了多个模板,可以选择简单的 Show Detail 模板。

        接着:

        $ cd <your-extension-path>
        $ npm install
        $ npm run dev
        

        调试扩展

        当我们执行 npm run dev(即 ray develop)时,它会自动安装扩展,并以开发模式启动 Raycast 应用。

        为了方便调试,在 Preferences - Advanced - Developer Tools 下勾选几个选项:

        • Auto-reload on Save
        • Open Raycast in development mode
        • Keep window always visible during development

        这对开发体验尤为重要。一是开发过程中文件修改可以自动重载,二是使 Raycast 始终显示,以避免切换其他应用时窗口关闭。

        在开发模式下,使用 console.log() 可以在终端上显示打印信息。

        如果没有正确使用 API、组件或快捷键冲突,此处也会有 Warning 显示。

        发布扩展

        如果想要将你的扩展发布到 Store 上,是需要官方审核的。

        为避免审核不通过,你的扩展应满足 Prepare an Extension for Store 要求。

        二选一:

        • Fork 官方仓库,将你的扩展放在仓库 extensions 目录下,提交 PR。
        • 创建独立仓库,然后通过 ray publish 提交。

        提交 PR 时,建议附上视频,或许可以更快地审核。我之前写了一个微信开发工具的扩展,由于他们没有小程序项目,流程上它们跑不通,可能会要求附上视频辅助审核,例如这个

        If you add a new extension or command, include a screencast (or screenshot for straightforward changes). A good screencast will make the review much faster - especially if your extension requires registration in other services.

        提交 PR 之后,机器人 greptile-apps 可能会提一些 Review 意见,按需调整即可。

        想要吐槽的是,审核很慢,看 commit 记录一天只处理几个。

        开发

        Raycast Extension 官方开发文档

        命名规范

        遵循 Apple Style Guide,详见这里

        示例:

        • 扩展标题:尽量使用名词,而不是动词,比如 WeChat DevTool。
        • 扩展描述:一句话准确、简洁地描述扩展程序的功能。
        • 命令标题:动名词组合,尽可能具体地描述命令的作用,比如 Open Project。
        • 命令副标题:为标题提供上下文信息。如果副标题几乎与命令标题重复,那么你可能不需要它。不指定时,该位置会显示扩展标题。
          • 示例:命令标题 Search Package,命令副标题 NPM,
          • 示例:命令标题 Search NPM Package,无命名副标题。
          • 以上示例,对于使用者来说,都是非常清晰明确的。
        • 命令描述:一句话准确、简洁地描述命令的功能。

        本地化

        很遗憾,目前 Raycast 扩展仅支持美式英文,不支持本地化语言,更多请看这里

        不上传到 Store 可忽略。

        扩展信息

        基本上都在 package.json 声明,列举一些基本字段:

        {
          "name": "wechat-devtool", // 包名
          "title": "WeChat DevTool", // 扩展标题
          "description": "Quickly open WeChat Mini Program projects via official CLI.", // 扩展描述
          "icon": "icon.png", // 扩展图标 512 × 512 的 PNG 图片
          "author": "tofrankie", // Raycast 账户用户名
          "contributors": ["someone"], // 贡献者 Raycast 用户名
          "license": "MIT", // 协议
          "platforms": ["macOS", "Windows"], // 平台
          "categories": ["Developer Tools"], // 扩展分类
          "commands": [ // 命令列表
            {
              "name": "open-project", // 名称,跟入口文件同名
              "title": "Open Project", // 标题
              "description": "Open Mini Program projects via WeChat DevTool.", // 描述
              "mode": "view" // 模式
            },
            {
              "name": "configure-projects",
              "title": "Configure Projects",
              "description": "Configure Mini Program projects.",
              "mode": "view"
            }
          ],
          "preferences": [ // 扩展偏好设置
            {
              "name": "wechat-devtool-path", // 名称
              "type": "textfield", // 类型
              "title": "WeChat DevTool Path", // 标题
              "description": "The path to the WeChat DevTool executable.", // 描述
              "required": false // 是否必需
            }
          ],
          "keywords": [ // 关键词,可供 Store 搜索使用
            "Developer Tools",
            "WeChat",
            "WeChat DevTool",
            "WeChat Mini Program"
          ],
          // ...
        }
        

        目录结构:

        .
        ├── assets                      # 静态资源目录
        │   └── icon.png
        ├── CHANGELOG.md                # 变更日志
        ├── metadata                    # 扩展截图
        │   ├── wechat-devtool-1.png
        │   └── wechat-devtool-2.png
        ├── package.json                # 扩展信息等
        ├── README.md                   # About This Extension 会显示这个
        ├── src
        │   ├── configure-projects.tsx  # 入口文件(跟 commands name 同名)
        │   └── open-project.tsx        # 入口文件
        └── tsconfig.json
        

        一些注意点:

        • name:不能与 Store 已有扩展重复,可以在这里搜索一下。将用于发布后的扩展链接,比如: https://www.raycast.com/tofrankie/wechat-devtool
        • icon:图标放在 /assets 目录。其他需要跟扩展一同打包的静态资源都要放在此目录下。
        • author:是 Raycast 用户名,不是 GitHub 用户名。
        • license:若要发布到 Store,只能是 MIT 协议。
        • categories:至少选择一个,可选分类看这里
        • commands
          • mode:可选值 viewno-viewmenu-bar。比如,打开链接等不需要界面的命令操作,可以选择 no-view
        • preferences:偏好设置时可以同步的。
          • required:当设为 true 时,用户设置后才能使用其他命令。

        更多请看 Extension PropertiesExtension Schemas

        命令入口文件

        入口文件放在 /src 目录下,文件名要与 commands[].name 保持一致。

        命令为 view 模式,可以用官方提供的组件来构建界面,详见 User Interface

        • List 列表类型
        • Grid 网格类型
        • Detail 渲染 Markdown 内容、展示图片
        • Form 表单类型

        虽然是用 React 编写的界面,但不能自由使用类似 div 或第三方组件库来构建复杂界面。

        Action Panel

        当我们使用 List、Detail、Form 等构建命令界面时,通常需要声明 Action Panel 来提供更多选项。

        <List.Item
          // others...
          actions={
            <ActionPanel>
              <Action
                title="Open Project"
                icon={Icon.Terminal}
                onAction={() => {
                  // do something...
                }}
              />
              <Action.Push
                title="Go to Configuration"
                icon={Icon.Gear}
                target={<ConfigureProjects />}
              />
              <Action.CopyToClipboard
                title="Copy Project Path"
                content="project path"
                shortcut={{ modifiers: ["cmd", "shift"], key: "," }}
              />
              <Action
                title="Delete Project"
                icon={Icon.Trash}
                style={Action.Style.Destructive}
                onAction={() => {
                  // do something...
                }}
              />
            </ActionPanel>
          }
        />
        

        Raycast 提供了 Action.OpenAction.PushAction.CopyToClipboardAction.OpenInBrowser 等一系列内置命令以轻松完成常用操作,更多请看这里

        对于危险操作,可以声明为 Action.Style.Destructive 以高亮显示,可以配合 confirmAlert 二次确认。

        Action 快捷键

        在 Action Panel 中,将第一、第二个操作称作 Primary Action 和 Secondary Action,它们会自动分配快捷键。

        • 在 List、Grid、Detail 页面分别 Enter、⌘ + Enter
        • 在 Form 页面分别是 ⌘ + Enter、⌘ + ⇧ + Enter

        设定 Action 快捷键时,可以参考 Raycast 官方推荐的常用快捷键,以便使用各插件有一致的用户体验,更多请看这里

        Name macOS Windows
        Copy ⌘ + ⇧ + C ctrl + shift + C
        CopyDeeplink ⌘ + ⇧ + C ctrl + shift + C
        CopyName ⌘ + ⇧ + . ctrl + alt + C
        CopyPath ⌘ + ⇧ + , alt + shift + C
        Save ⌘ + S ctrl + S
        Duplicate ⌘ + D ctrl + shift + S
        Edit ⌘ + E ctrl + E
        MoveDown ⌘ + ⇧ + ↓ ctrl + shift + ↓
        MoveUp ⌘ + ⇧ + ↑ ctrl + shift + ↑
        New ⌘ + N ctrl + N
        Open ⌘ + O ctrl + O
        OpenWith ⌘ + ⇧ + O ctrl + shift + O
        Pin ⌘ + ⇧ + P ctrl + .
        Refresh ⌘ + R ctrl + R
        Remove ⌃ + X ctrl + D
        RemoveAll ⌃ + ⇧ + X ctrl + shift + D
        ToggleQuickLook ⌘ + Y ctrl + Y

        图标、图片

        开发扩展免不了使用图片,Raycast 已经内置了很多图标可直接使用,详见这里

        还可以指定远程图片、本地文件等。

        type ImageLike = URL | Asset | Icon | FileIcon | Image
        
        type ImageSource = URL | Asset | Icon | { light: URL | Asset; dark: URL | Asset }
        
        • URL:如 HTTP 链接
        • Assetassets 目录的图片文件
        • Icon:Raycast 内置的图标
        • FileIcon:该文件/目录在 Finder 所显示的图标
        • Image:类型如 ImageSource,还可以指定浅色、深色主题的图片,命名形式 icon.pngicon@dark.png

        图片着色:

        import { Color, Icon, List } from "@raycast/api"
        
        const tintedIcon = { source: Icon.RaycastLogoPos, tintColor: Color.Blue }
        
        export default function Example() {
          return (
            <List>
              <List.Item title="Blue" icon={tintedIcon} />
            </List>
          )
        }
        

        本地 svg 图片同样也是可以着色的。

        导航

        • 在 Action Panel 可以用 Action.Push 跳转目标页
        • 在页面可以使用 const { push, pop } = useNavigation() 跳转下一页或返回上一页
        • 使用 popToRoot() 可以返回 Raycast 根界面
        • 使用 closeMainWindow() 可以主动关闭 Raycast 窗口

        搜索

        使用 List 构建的页面带有一个搜索栏用于筛选,来源是 List.Item 的 titlekeywords 字段。

        我发现,它没有根页面输入框那么智能,对筛选中文或拼音不太灵光的样子。

        这种情况下,有两种解决方法:

        • 自定义搜索规则:searchText + onSearchTextChange
        • 丰富 keywords 内容

        List Props

        以后者为例,可以将项目名称、项目路径、项目名称拼音(若有中文)添加到 keywords 字段,参考 pinyin.ts

        图片展示

        若要展示一张图片,似乎只有 Detail + Markdown 的方式了。

        export default function ImageView({ url }: { url: string }) {
          // 可通过 example.png?raycast-width=250&raycast-height=250 指定宽高
          const markdown = `![](${url})`;
          return <Detail markdown={markdown} />;
        }
        

        表单

        没什么好说的,文档很详尽了,请看这里

        Toast、Loading、Alert

        提供的 API 有:

        • showToast:有 Success、Failure、Animated 三种,后者为 Loading 圈圈。如果 Raycast 窗口关闭了,会回退到 showHUD
        • showFailureToast:显示错误信息优先选择这个
        • showHUD:Raycast 窗口关闭时在屏幕下方显示一条信息
        • confirmAlert:用于危险操作的二次确认弹窗

        环境信息

        可以在 environment 获取:

        import { environment } from "@raycast/api";
        
        environment.raycastVersion // 1.102.5
        environment.ownerOrAuthorName // tofrankie
        environment.extensionName // wechat-devtool
        environment.commandName // open-project
        environment.commandMode // view
        environment.assetsPath // /Users/frankie/.config/raycast/extensions/wechat-devtool/assets
        environment.supportPath // /Users/frankie/Library/Application Support/com.raycast.macos/extensions/wechat-devtool
        environment.isDevelopment // true
        environment.appearance // light
        environment.textSize // medium
        environment.launchType // userInitiated
        

        ▼ Raycast for Windows 如下

        import { environment } from "@raycast/api";
        
        environment.raycastVersion // 0.34.0.0
        environment.ownerOrAuthorName // tofrankie
        environment.extensionName // wechat-devtool
        environment.commandName // open-project
        environment.commandMode // view
        environment.assetsPath // C:\Users\frankie\.config\raycast-x\extensions\wechat-devtool\assets
        environment.supportPath // C:\Users\frankie\AppData\Local\Raycast\extensions\wechat-devtool
        environment.isDevelopment // true
        environment.appearance // light
        environment.textSize // medium
        environment.launchType // userInitiated
        

        扩展相关文件

        • assetsPath 扩展安装到 Raycast 的产物路径
        • supportPath 扩展相关文件,比如扩展记录一个配置文件,可以放在该目录下

        需要注意的是,Raycast 不会同步 supportPath 目录的文件。

        之前我想着把一些配置写入 supportPath 的文件,使其在 Raycast 中进行同步。但这是不行的,对扩展来说,只同步 preferences 的配置,而 preferences 又没办法动态更新。

        Shell 环境

        有时我们要在 Raycast 扩展中执行一些 Shell 命令。

        下面是默认情况下的一些变量值。

        $ echo $SHELL
        /bin/zsh
        
        $ echo $PATH
        /usr/gnu/bin:/usr/local/bin:/bin:/usr/bin:.
        
        $ printenv
        SUPPORT_PATH=/Users/frankie/Library/Application Support/com.raycast.macos/extensions/wechat-devtool
        TMPDIR=/var/folders/z4/nxcp1z415jgff4rwrrf8v3y00000gn/T
        ASSETS_PATH=/Users/frankie/.config/raycast/extensions/wechat-devtool/assets
        LC_ALL=en_CN-u-hc-h23-u-ca-gregory-u-nu-latn
        __CF_USER_TEXT_ENCODING=0x1F5:0x19:0x34
        EXTENSION_NAME=wechat-devtool
        RAYCAST_VERSION=1.102.5
        PWD=/
        FAVICON_PROVIDER=legacy
        NODE_PATH=/Applications/Raycast.app/Contents/Resources/RaycastNodeExtensions_RaycastNodeExtensions.bundle/Contents/Resources/api/node_modules
        NODE_ENV=development
        SHLVL=1
        HOME=/Users/frankie
        COMMAND_NAME=open-project
        RAYCAST_BUNDLE_ID=com.raycast.macos
        _=/usr/bin/printenv
        

        通常我们会在 ~/.zshrc 中配置 export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:$PATH" 以使用 Homebrew 安装的命令,但 Raycast 扩展执行 Shell 不会加载 ~/.zshrc 因此无法直接使用里面的命令。

        可以这样处理(仅供参考):

        import { exec } from "child_process";
        import { promisify } from "util";
        
        const execAsync = promisify(exec);
        
        function execCommand(command: string, cwd?: sting) {
          const env = getEnv();
          await execAsync(command, { cwd, env });
          // do something...
        }
        
        function getEnv() {
          return { ...process.env, PATH: joinHomebrewPath() };
        }
        
        function joinHomebrewPath() {
          return [process.env.PATH, "/usr/local/bin", "/opt/homebrew/bin", "/opt/homebrew/sbin"].filter(Boolean).join(":");
        }
        

        Change Log

        开发完成后,需要在 CHANGELOG.md 补充一条变更记录。

        遵循 Keep a Changelog 写法,要求如下:

        • 格式必须是 ## [xxx] - {PR_MERGE_DATE}
        • [] 内的为本次变更的标题
        • {PR_MERGE_DATE} 为合并日期。因为从提交 PR 到审核通过可能要好几天,被合并时会自动替换

        更多请看这里

        # WeChat DevTool Changelog
        
        ## [New Version] - {PR_MERGE_DATE}
        
        - Add something
        - Improve something
        - Fix something
        
        ## [First Release] - 2025-07-11
        
        - **Open Project** - Open configured mini program project via WeChat DevTool CLI.
        - **Graphical Configuration** - Complete graphical interface for dynamic project management.
        

        截图

        存放在 metadata 目录下,可提供 1 ~ 6 张截图。

        Raycast 内置了截屏功能,在 Raycast Settings - Advanced - Window Capture 中设置快捷键(如 ⌥ + ⇧ + ⌘ + M)。

        npm run dev 模式下启动扩展,按下快捷键,便可调出截屏窗口,并勾选上「Save to Metadata」,就会自动保存到扩展的 metadata 目录,更多请看这里

        Raycast 提供了一些高清壁纸,请看这里

        贡献

        如果想对已发布的扩展作出贡献:

        1. fork raycast/extensions
        2. 功能调整...
        3. 将你的 Raycast 用户名添加到 package.json 的 contributors 字段
        4. 提交 PR,等待审核发布

        由于审核较慢,如果同时多个扩展做出贡献,可以创建不同的分支去处理,如 ext/extension-a、ext/extension-b。

        ]]>
        <![CDATA[毕业快乐 🎓]]> https://github.com/tofrankie/blog/issues/363 https://github.com/tofrankie/blog/issues/363 Sun, 22 Jun 2025 15:54:53 GMT 未完待续...

        ]]>
        未完待续...

        ]]>
        <![CDATA[CSS 中 display、opacity 、visibility 的区别]]> https://github.com/tofrankie/blog/issues/362 https://github.com/tofrankie/blog/issues/362 Sat, 14 Jun 2025 06:12:51 GMT 配图源自 Freepik

        用 CSS 使元素在视觉上不可见,我们通常会想到以下几种属性:

        配图源自 Freepik

        用 CSS 使元素在视觉上不可见,我们通常会想到以下几种属性:

        • display: none
        • opacity: 0
        • visibility: hidden

        尽管这些属性都能使元素不可见,但它们之间仍存在明显差异!

        基本概念

        • display: none:元素完全从文档流中移除,不占据任何空间,不可见且不响应事件。
        • opacity: 0:元素在视觉上不可见,但仍占据原有空间,可以响应事件。
        • visibility: hidden:元素在视觉上不可见,占据原有空间,但不响应事件。

        直观对比

        是否占据布局空间

        属性 占据空间 说明
        display: none 元素从文档流中完全移除
        opacity: 0 元素仍占据原有位置和尺寸
        visibility: hidden 元素仍占据原有位置和尺寸

        是否响应事件

        属性 元素本身 子元素 说明
        display: none 元素及其后代均不响应事件
        opacity: 0 无论是否可见,均可响应事件
        visibility: hidden 取决于子元素设置 子元素可设置为 visible 来响应事件

        CodePen 示例(元素本身)

        CodePen 示例(子元素)

        是否支持过渡动画

        属性 支持过渡动画 说明
        display 无法在 noneblock 之间平滑过渡
        opacity 可以在 0 ~ 1 之间平滑过渡
        visibility 无法在 visiblehidden 之间平滑过渡

        CodePen 示例

        外设访问性

        属性 屏幕阅读器 键盘导航 说明
        display: none 元素完全从可访问性树中移除
        opacity: 0 元素仍存在于可访问性树中
        visibility: hidden 元素仍存在于可访问性树中

        原因分析

        为了理解为什么会有这些差异,我们需要了解浏览器的渲染流程。

        页面生成过程

        1. 解析 HTML 构建 DOM Tree
        2. 解析 CSS 构建 CSSOM Tree
        3. 将 DOM 和 CSSOM 合并生成渲染树(Render Tree)
        4. Layout 阶段,根据渲染树计算每个节点的位置和尺寸
        5. Painting 阶段,将各个节点绘制到屏幕上

        渲染树生成过程

        1. 从根节点开始,遍历每个可见节点。
          1. 某些不可见节点(如 head、script、link、meta 等标签),它们会被忽略。
          2. 一些使用 CSS 属性隐藏(display: none)的节点,也会被忽略。
        2. 对于每个可见节点,找到其匹配的 CSSOM 规则并应用。
        3. 最终形成带有内容及计算样式的可见节点。

        注意,虽然 display: none 的元素不会进入渲染树,但仍然可以使用 JS 操作该元素。原因是 JS 操作的是 DOM 树,而不是渲染树。

        关键差异:

        • display: none 的元素不会进入渲染树,因此不参与后续的布局和绘制
        • opacity: 0visibility: hidden 的元素会进入渲染树,参与布局计算

        Layout 和 Painting 阶段

        • Layout 阶段:计算元素的确切位置和大小,回流发生在此阶段。
        • Painting 阶段:将元素转换为屏幕像素,重绘发生在此阶段。

        回流与重绘的关系

        • 有回流一定伴随着重绘
        • 发生重绘不一定伴随着回流

        为什么会产生不同表现?

        基于渲染流程:

        1. display: none 产生回流的原因: 元素不进入渲染树,导致其他元素重新计算位置和尺寸
        2. opacity: 0visibility: hidden 不产生回流的原因: 元素仍占据原有空间,不影响其他元素的布局

        继承性与子元素表现

        CSS 继承性

        属性 是否为继承属性 初始值 说明
        display inlineblock 取决于元素类型
        opacity 1 子元素默认不透明
        visibility visible 子元素默认继承父元素的值

        子元素可见性分析

        当父元素设置不同属性时,子元素的表现:

        情况一:子元素使用默认值

        父元素设置 子元素默认值 子元素最终表现 结果
        display: none display: block display: block ❌ 父元素不在渲染树中,均不可见
        opacity: 0 opacity: 1 opacity: 1 ❌ 受父元素影响,均不可见
        visibility: hidden visibility: hidden visibility: hidden ❌ 受父元素影响,均不可见

        情况二:子元素主动设置

        父元素设置 子元素设置 结果
        display: none display: block ❌ 父元素不在渲染树中,均不可见
        opacity: 0 opacity: 1 ❌ 受父元素影响,均不可见
        visibility: hidden visibility: visible ✅ 不受父元素影响,父元素不可见,子元素可见

        visibility 的特殊性:子元素可以设置为 visibility: visible 来摆脱父元素的影响。

        子元素事件响应分析

        父元素设置 子元素设置 子元素可见性 子元素事件响应 说明
        display: none 任何值 父元素不在渲染树中
        opacity: 0 任何值 仅视觉上不可见
        visibility: hidden visibility: visible 子元素摆脱父元素影响

        实际应用建议

        何时使用 display: none

        适用场景:

        • 需要完全移除元素,不占用任何空间
        • 元素及其后代都不需要响应事件
        • 不需要考虑可访问性

        注意事项:

        • 会产生回流,影响性能
        • 无法通过过渡动画实现平滑效果

        何时使用 opacity: 0

        适用场景:

        • 需要平滑的淡入淡出效果
        • 元素需要响应事件(如悬停效果)
        • 需要保持布局稳定

        注意事项:

        • 元素仍占据空间
        • 子元素也会不可见

        何时使用 visibility: hidden

        适用场景:

        • 需要保持布局稳定
        • 子元素需要可见或响应事件
        • 需要良好的可访问性支持

        注意事项:

        • 元素仍占据空间
        • 不支持过渡动画
        • 子元素可以通过设置 visibility: visible 来摆脱影响

        选择指南总结

        需求 推荐属性 原因
        完全隐藏元素,不占空间 display: none 元素不进入渲染树,不参与布局计算
        平滑的淡入淡出效果 opacity: 0 支持过渡动画
        保持布局,子元素可见 visibility: hidden 子元素可设置为 visible
        隐藏但保持事件响应 opacity: 0 元素仍存在于 DOM 中
        性能优先,避免回流 opacity: 0visibility: hidden 不产生回流

        总结

        对比之下:

        1. display: none 适合完全移除元素的场景,但会产生回流
        2. opacity: 0 适合需要平滑动画和事件响应的场景
        3. visibility: hidden 适合需要保持布局和灵活控制子元素的场景
        ]]>
        <![CDATA[生日快乐]]> https://github.com/tofrankie/blog/issues/361 https://github.com/tofrankie/blog/issues/361 Fri, 13 Jun 2025 15:58:30 GMT 听说,小媛同学今年有三个生日。

        今天是第一个,生日快乐呀~ 🎂

        另外,发现了一个款式非常非常好看的蛋糕(橙路蛋糕)👇

        【冰岛小屋】世人皆孤岛,凭爱意丰饶

        07.06

        今天是跟小媛同学过的第二个生日,还有弟弟、妹妹一起,逛逛吃吃...

        还有凯琪小朋友很帅哦

        ]]>
        <![CDATA[Folo Verification]]> https://github.com/tofrankie/blog/issues/360 https://github.com/tofrankie/blog/issues/360 Fri, 06 Jun 2025 16:57:49 GMT 配图源自 Freepik

        使用

        使用 RSSHub 可以为 GitHub Issues 快速创建订阅源。

        /github/issue/:user/:repo/:state?/:labels?
        

        你可以创建一个包含以下认证信息的 Issue(右键认证处可复制),用于证明你是此订阅源的所有者。

        This message is used to verify that this feed (feedId:154574662305076224) belongs to me (userId:150343881723676672). Join me in enjoying the next generation information browser https://follow.is.

        ]]>
        <![CDATA[微信小程序开发记录]]> https://github.com/tofrankie/blog/issues/359 https://github.com/tofrankie/blog/issues/359 Wed, 14 May 2025 03:45:14 GMT 配图源自 Freepik

        [!NOTE] 众所周知,微信的文档写得很烂...<]]> 配图源自 Freepik

        [!NOTE] 众所周知,微信的文档写得很烂...

        关于跳转路径

        老是忘记路径是否需要前导 /,作个记录吧。😑

        • page/index/index
        • /page/index/index

        场景:

        • 小程序内的页面跳转:wx.navigateTo 这类 API 的 url 参数「必须」要有前导 /
        • 小程序之间跳转:wx.navigateToMiniProgram 的 path 参数前导 / 是「可选」的。
        • 分享/转发:onShareAppMessage 的 path 参数「必须」要有前导 /
        • 订阅消息:订阅消息的 page 参数前导 / 是「可选」的(亲测均可正常跳转)。

        结论:建议都带上前导 /(可以少用点脑子)。

        相关链接:

        页面底部 margin-bottom 失效

        这个现象开发过小程序的同学应该都遇到过,它仅发生在 iOS 下,Android 和开发工具是符合预期的。

        page {
          margin-bottom: 30rpx;
        }
        

        在 iOS 下,实际表现是底部下外边距“相当于”未设置,会被吞掉。

        解决方法很多,也很简单。比如,页面底部整体设置一个下内边距,然后再内层通过 view 等设置下外边距撑开。

        page {
          padding-bottom: 1px;
        }
        
        原因分析(⚠️ 源自 ChatGPT,不一定准确)

        根本原因是 iOS WebView (WKWebView) 在计算可滚动内容高度时,对“文档末尾由 margin 产生的空白”进行了裁剪。

        1. WebView 的滚动本质
        WKWebView
         └─ UIScrollView
             └─ Web content layer
        

        最终能不能滚到某个位置,取决于 UIScrollView.contentSize.height

        1. contentSize 是怎么来的?

        WKWebView 在内部会:

        1. WebKit 计算文档布局
        
        2. 将“内容高度”同步给 UIScrollView
        
        3. UIKit 再决定是否裁剪末尾空白
        

        问题就出在第 3 步

        1. iOS WebView 的特殊策略(核心)

        iOS WebView 对“文档末尾空白”有一个长期存在的策略:

        如果页面底部是“非内容性的空白”,尤其是由 margin-bottom 产生的,UIKit 可能会将其视为“可裁剪区域”,不计入 scroll height。

        而以下情况不会被裁剪:

        • padding-bottom
        • 实际存在的元素高度
        • 占位 div
        • 可交互内容
        方式 是否生效
        margin-bottom ❌ 易被裁剪
        padding-bottom ✅ 稳定
        空 div 高度 ✅ 稳定
        body 末尾内容
        ]]> <![CDATA[记五月:汕头 - 潮州两日]]> https://github.com/tofrankie/blog/issues/358 https://github.com/tofrankie/blog/issues/358 Wed, 07 May 2025 01:19:40 GMT 配图源自 Freepik

        提前攻略

        假期不可避免的,一是人从众,二是车票难抢 😶 配图源自 Freepik

        提前攻略

        假期不可避免的,一是人从众,二是车票难抢 😶

        由于平时没有长假期,于是决定体验一番人挤人吧。

        清明过后,就开始打算五一计划了,奈何时间过得太快太快了...

        来到假期前两周,好像广东或周边都显示五一要下雨 🌧

        粗略扫了一遍贵州、福建、两湖,由于高铁 🚄 买不着,飞机 ✈️ 偏贵,且要往返,加之天气也没特别好,就放弃它们了。

        又看了一周,一番纠结下基本锁定了:

        • 珠海:伶仃岛、情人路、日月贝、海滩、长隆。
        • 潮汕:一个字“吃”,还有南澳岛。

        它们离广州不算太远,玩 2 ~ 3 天足矣。这样便可以在广州休息一天,同时避开假期第一天高峰。恰好 5 月 2 日高铁也还有很多选择,兜底还有大巴、打车等选择,回程也是。

        出发之前

        假期第一天,去吃了顿乳鸽(百利鸽王聚龙店),这家店是小媛同学强烈推荐。吃完,我的评价是:

        没有想象好吃,不如大鸽饭(体育西店)。盐焗乳鸽略咸~

        吃完离开还落下了黄皮冰茶(好喝),可惜,浪费了 😭

        一顿收拾,准备了一些东西:

        • 证件类:身份证、学生证
        • 洗护类:毛巾、牙刷、防晒、洁面、护发、护肤等化妆物品 💅
        • 衣物类:那些好看衣服,换洗袜子 🧦 等
        • 药品类:感冒、发烧、肠胃、创可贴 🩹 等基础药品,以防万一
        • 其他:伞、充电宝、少量现金 💵 等

        对了,我不是学生了,如果你出行,可以带上哦~

        行程安排:

        • Day1 汕头
        • Day2 潮州
        • Day3 回广

        出发了...

        Day1

        次日,早早起来赶高铁:新塘站 → 汕头站,时长 3h 17min,略长~

        出发前的攻略路线是:汕头小公园 → 汕头旅社 → 西堤公园 → 广场轮渡。然而,我们到达汕头已经是下午 1 点了,我们并没有按这个路线去逛,来汕头主要任务是“吃”,也就没有太大关系了,也深知这些景点都是千篇一律。

        我们提前订了靠近汕头站的一个公寓,环境很一般...

        办理入住,放下行李,马上赶往汕头小公园,肚子也有点小饿了...

        接下来,就是吃吃吃...

        我是极其挑吃的,小媛同学则相反,除了茼蒿都可以爱吃。在这方面总是你迁就我,太委屈你了。

        没有特别说非要吃哪家网红店、必打卡之类的,就沿途遇到爱吃、想试的就尝尝。

        • 黄皮柠檬茶(名字忘了):没有广州茶救星球的黄皮冰茶好喝,基本上都是柠檬味...
        • 碗翅:小媛爱吃,我嘛喝了一口汤就吃不下去了...
        • 绿豆饼:中规中矩
        • 冰糖草莓:🍓 颗粒大,划算~
        • 腐乳鸡翅:小媛同学强烈推荐,就是可能吃多了容易上火
        • 反沙芋头:我只能说“反沙 xxx”都不建议买...
        • 绿豆莲子:噢,这糖水好喝
        • 三姐妹肠粉:排长龙,份量足,价格便宜(相比广州来说,不清楚当地物价)。我的评价是:不太好吃。
        • 甘草水果:满街都是,水果刺客,不好吃
        • 發發牛牛肉火锅:价格实惠、好吃,推荐~(主要跟潮州阿彬牛肉火锅对比得出的结论)

        Day2

        第三天,高铁:汕头站 → 潮汕站,时长 23min。

        到达潮州,高铁站就有古城专线大巴到达古城,在南桥市场站下车。

        下车点到住宿点,有几百米距离,因为有点热,加上行李箱提手断了,感觉好远好远。

        在美团上订了「意达雅居」,就在潮州二字打卡点对面,距离广济桥很近,约 3 ~ 5min 的距离。

        由于五一假期的原因,各大平台,住宿基本翻倍,但参考汕头站的住宿条件,这个真的很好很好了,房间很干净,隔音不错很安静。古城商铺会打烊,所以不用太担心会吵。

        我们到达时,由于房间还没准备好,寄存行李后,我们出去逛了逛,在成川茶店点了杯春江水暖(没啥特别,也不算好喝),买了一顶帽子。就近在「阿彬火锅店」吃了一顿牛肉火锅,饭后吃感是贵且没那么好吃(对比汕头的發發牛牛肉火锅)。

        办理入住...

        潮州的景点较为集中:

        ▲ 图片源自小红书

        广济桥

        准备前往广济桥,确实很近,但古城到处都是人从众...

        在古城城楼一顿拍,然后过马路去广济桥,没想到要排队上桥(人也很多),在湖边看过去,好像没有很大的欲望上桥,就草草离开了。

        主要是天气不太好。

        潮州西湖

        后来,叫了个摩托车前往湖州西湖(可以讲个价,司机很多,选择很多,最后找了个 18R 的成交)。

        司机大叔人挺好,路上还不忘给我们做介绍,把五一假期一天拉了多少客都和盘托出,哈哈。

        到达后,在湖里划船 🚣🏻,划 30min,手动挡(脚踏版本,哈哈),船不太好控制方向,有点老旧吧。

        未完待续...

        ]]>
        <![CDATA[Jira 使用记录]]> https://github.com/tofrankie/blog/issues/357 https://github.com/tofrankie/blog/issues/357 Wed, 30 Apr 2025 08:58:47 GMT 配图源自 Freepik

        推荐一个用于搜索 Jira issues 的 Ray]]> 配图源自 Freepik

        推荐一个用于搜索 Jira issues 的 Raycast 扩展:Atlassian Data Center

        过滤器(筛选器)是 Jira 核心功能之一,利用它可以实现很多的需求。

        开始之前

        作一些简单的了解。

        JQL:Jira Query Language

        JQL Functions

        一些基本函数,更多请看这里

        函数 说明
        currentUser() 当前登录用户
        startOfWeek() 本周开始
        endOfWeek() 本周结束
        startOfMonth() 本月开始
        endOfMonth() 本月结束
        ...

        JQL Keywords

        利用 AND、OR、NOT 等将多个条件连接在一起,实现更多复杂的查询,更多请看这里

        查询

        利用函数可以快速做一些筛选。

        本周工作:

        worklogAuthor = currentUser() AND worklogDate >= startOfWeek() AND worklogDate < endOfWeek()
        

        本月工作:

        worklogAuthor = currentUser() AND worklogDate >= startOfMonth() AND worklogDate < endOfMonth()
        

        指定时间范围:

        worklogAuthor = currentUser() AND worklogDate >= "2025/04/21" AND worklogDate <= "2025/04/25"
        

        周报/月报

        请注意,函数是动态的

        如果将上述查询条件直接应用在汇报里面是有问题的。

        1. worklogAuthor = currentUser() 谁登录了就是谁的。
        2. startOfWeek() 是动态的,如果这周访问了上周的周报,会显示这周的内容。

        你想一下,如果领导访问你的周报,但他看到了自己本周的任务,惊呆了!😲

        我是这样做的(仅供参考):

        1. 指定时间范围
        2. 跟我有关的
          • 有记录工时的任务
          • 指派给我,但未记录工时。比如像 WON'T FIX 的任务

        组合查询如下:

        (worklogAuthor = "frankie" AND worklogDate >= "2025/04/21" AND worklogDate <= "2025/04/25")
        OR
        (assignee = "frankie" AND worklogAuthor is EMPTY AND created >= "2025/04/21" AND created <= "2025/04/25")
        

        也可以在 .zshrc 中放入这个小脚本自动生成。

        function genReportJQL() {
          local username="your_jira_username" # TODO: 你的用户名
          local mode=${1:-weekly} # 默认 weekly
        
          local start end
        
          if [[ "$mode" == "monthly" ]]; then
            start=$(date '+%Y/%m/01')
            end=$(date -v+1m -v1d -v-1d '+%Y/%m/%d')
          else
            local dow=$(date +%u)
            start=$(date -v -"$(($dow - 1))"d '+%Y/%m/%d')
            end=$(date -v +"$((5 - $dow))"d '+%Y/%m/%d')
          fi
        
          local jql="(worklogAuthor = \"$username\" AND worklogDate >= \"$start\" AND worklogDate <= \"$end\") OR (assignee = \"$username\" AND worklogAuthor is EMPTY AND created >= \"$start\" AND created <= \"$end\")"
        
          printf "%s" "$jql" | pbcopy
          echo "$jql"
          echo "✅ Copied to clipboard"
        }
        

        参考链接

        ]]> <![CDATA[与食物的有限交集]]> https://github.com/tofrankie/blog/issues/356 https://github.com/tofrankie/blog/issues/356 Tue, 08 Apr 2025 15:47:08 GMT 配图源自 Freepik

        📍 坐标广州

        记录一些吃过的]]> 配图源自 Freepik

        📍 坐标广州

        记录一些吃过的店~

        对吃的没有特别感兴趣,属于「很挑也不挑」的那种类型 🤷‍♀️

        纯粹以个人口味做个备忘 📝

        踩过的坑、留恋的味道、新奇的组合,一起记下来 ✅

        有时候同一个品牌,不同分店的口味也能天差地别,真的无语了 🙃

        🌇 天河区

        🛍 美林天地

        💡 新奇榜

        • 豆浆汤圆 🫘🍡
          没想到豆浆跟甜汤圆这么配!意外好喝,推荐尝试 🎉

        • 黄皮冰茶 🍋🧊
          茶救星球家的黄皮系列,味道很惊艳,记得选「咸」的版本!

        🥢 好吃榜

        • 鱼粉王 🐟🍜
          已歇业了,真的太可惜 😢
          当每天纠结吃什么的时候,它是个不错的选择。

        • 探鱼 🔥🐠
          偶尔想吃烤鱼的时候,探鱼是个稳定选项,味道在线!

        • 九毛九 🥩🍚
          西北菜里比较喜欢的一家,酱骨架做得不错,米饭也好吃。

        • 龙井湖 🥢🏞
          家常味道蛮对我口味,环境也挺舒服的,适合安静吃饭。

        🙅‍♀️ 不好吃榜

        • 兰州 xxx 馆 🍜🚫
          忘了名字,总之吃完没有任何回头欲望。

        • 克茗冰室 🧊🥪
          吃的是一种「为啥会开在这儿」的困惑感 😬

        • 师烤 🍢💸
          味道普通、价格跟探鱼差不多。

        📦 外卖榜

        • 金牌筒骨粉 🦴🍲
          肉给得实在,汤浓味正,冬天吃特别舒服 ❄️

        • 金牌烧鹅(东圃雅怡店) 🍗🍚
          包装讲究,鹅肉不柴,配饭一绝,回购过几次 ✅

        • 益禾堂(珠村店) 🍹🧋
          茶味够,甜度好调,属于茶饮里不会出错的牌子。

        🏙 越秀区

        🧭 北京路区域

        • 达扬原味炖品 🍲🐔
          点了炖竹丝鸡(招牌),汤味浓郁,天冷来一碗很疗愈 🧣

        • 百花甜品店 🍧🌸
          名气挺大,但实话说感觉一般,可能是我口味不太对 🧐

        ]]>
        <![CDATA[那些纠结过的英文词汇]]> https://github.com/tofrankie/blog/issues/355 https://github.com/tofrankie/blog/issues/355 Tue, 18 Feb 2025 09:43:35 GMT 配图源自 Freepik

        实时

        参赛选手:

        • realtime]]> 配图源自 Freepik

          实时

          参赛选手:

          • realtime
          • real-time
          • real time

          更新/升级

          参赛选手:

          • update
          • upgrade

          清空

          参赛选手:

          • clean
          • clear

          性别

          参赛选手:

          • gender - 指社会建构的、由女孩、女人、男孩、男人以及性别多元化人群的角色、行为、表达和身份。它影响着人们如何看待自己和他人,如何行动和互动,以及社会中权力和资源的分配。性别认同并非局限于二元对立(女孩/女人,男孩/男人),也并非一成不变;它是一个连续体,会随着时间而变化。个人和群体对性别的理解、体验和表达方式存在着相当大的多样性,这体现在他们所承担的角色、对他们的期望、与他人的关系以及性别在社会中制度化的复杂方式上。
          • sex - 指人类和动物的一组生物学属性。它主要与生理特征相关,包括染色体、基因表达、激素水平和功能以及生殖/性解剖结构。性别通常分为女性和男性,但构成性别的生物学属性及其表达方式存在差异。

          What is gender? What is sex?

          黑/白名单

          参赛选手:

          • blacklist vs blocklist
          • whitelist vs allowlist

          因为众所周知的原因,Git 的 master/main 也是同理。

          状态

          参赛选手:

          • state
          • status

          分类/策略

          • category
          • strategy
          ]]>
          <![CDATA[关于进制]]> https://github.com/tofrankie/blog/issues/354 https://github.com/tofrankie/blog/issues/354 Sun, 22 Dec 2024 13:54:48 GMT 简介

          进制(进位制)是一种记数方式,可以用有限的“数字符号”表示所有的数值。

          进位表示在一个位值的数字达到基数后,将其重置为零并使高一位的数字加一。

          比如,十进制只有 0 ~ 9 这十个数字符号,表示]]> 简介

          进制(进位制)是一种记数方式,可以用有限的“数字符号”表示所有的数值。

          进位表示在一个位值的数字达到基数后,将其重置为零并使高一位的数字加一。

          比如,十进制只有 0 ~ 9 这十个数字符号,表示第十一个数(从 0 起),就要进一位,变成 10。其他进制同理。

          进制数转换

          以 35 为例:

          • 二进制:100011
          • 八进制:43
          • 十进制:35
          • 十六进制:23

          十进制 → 二进制、八进制、十六进制

          整数部分和小数部分的转换规则不太一样。

          简单来说:整数部分取余逆序,小数部分乘法取整正序。

          以 35.125 为例。

          整数部分转二进制:

          • 35 / 2:商 17,余数 1
          • 17 / 2:商 8,余数 1
          • 8 / 2:商 4,余数 0
          • 4 / 2:商 2,余数 0
          • 2 / 2:商 1,余数 0
          • 1 / 2:商 0,余数 1

          不断除以 2,直至商为 0。从最后一个余数读起(逆序),便是其二进制数 100011。其他进制同理,除以 8、16。

          小数部分转二进制:

          • 0.125 * 2:积 0.25,取整数部分 0,剩 0.25
          • 0.25 * 2:积 0.5,取整数部分 0,剩 0.5
          • 0.5 * 2:积 1,取整数部分 1,剩 0

          不断乘以 2,取出结果的整数部分,再余下的小数部分重复计算,直到积的小数部分为 0。从第一个积读起(正序)取其整数部分,便是其二进制数 001。其他进制同理,乘以 8、16。

          最后,将整数和小数合起来,十进制数 35.125 对应的二进制数为 100011.001

          小数部分的误差:

          有时,不断乘以 2 得到的积的小数部分永远不为 0,但精度要求,因此也会有舍入的操作。

          以 0.8 为例:

          • 0.8 * 2:积 1.6,取整数部分 1,剩 0.6
          • 0.6 * 2:积 1.2,取整数部分 1,剩 0.2
          • 0.2 * 2:积 0.4,取整数部分 0,剩 0.4
          • 0.4 * 2:积 0.8,取整数部分 0,剩 0.8
          • 0.8 * 2:积 1.6,取整数部分 1,剩 0.6
          • 无限循环...

          对应的二进制为 1100 1100 1100 1100...

          二进制、八进制、十六进制 → 十进制

          35 用十进制的表示法如下:

          35 = 3 * 10^1 + 5 * 10^0
          

          以从左到右的书写习惯为例,左侧为高位,右侧为低位。

          其中 3、5 为位值,10^1、10^0 为权值。

          从低位起(从右往左),其权值分别为 10^0、10^1、...、10^n-1(10 表示为对应进制,n 表示位数)。

          将每位的位值乘以权值,再求和就是对应的十进制值。

          二进制 100011

          35 = 1 * 2^5 + 0 * 2^4 + 0 * 2^3 + 0 * 2^2 + 1 * 2^1 + 1 * 2^0
          

          八进制 43

          35 = 4 * 8^1 + 3 * 8^0
          

          十六进制 23

          35 = 2 * 16^1 + 3 * 16^0
          

          二进制、八进制、十六进制互转

          一位八进制用三位二进制表示,一位十六进制用四位二进制表示。位数不够,高位补零。

          • 二进制 100011
          • 八进制 100 01143
          • 十六进制 0010 001123

          十六进制转八进制,可以先转为二进制,再转为八进制。

          位运算

          未完待续...

          ]]>
          <![CDATA[uni-app 开发记录]]> https://github.com/tofrankie/blog/issues/353 https://github.com/tofrankie/blog/issues/353 Tue, 10 Dec 2024 01:52:31 GMT 配图源自 Freepik

          有些东西隔一段时间不用就容易忘记,在此作下记录。

          自定义组件]]> 配图源自 Freepik

          有些东西隔一段时间不用就容易忘记,在此作下记录。

          自定义组件选项配置

          诸如 styleIsolation、virtualHost 等自定义组件配置,声明方式如下:

          ▼ Composition API

          若是 Vue 3.3+,可使用 defineOptions()

          <script setup>
            defineOptions({
              options: {
                virtualHost: false,
                styleIsolation: 'shared',
              },
            })
          
            // your code...
          </script>
          

          若是 Vue 3.3 以下,需要独立的 <script> 块。

          <script setup>
            // your code...
          </script>
          
          <script>
            export default {
              options: {
                virtualHost: false,
                styleIsolation: 'shared',
              },
            }
          </script>
          

          ▼ Options API

          <script>
            export default {
              options: {
                virtualHost: false,
                styleIsolation: 'shared',
              },
            }
          </script>
          

          判断是否安装微信

          利用 HTML5 Plus API:

          function checkWechatInstalled() {
            return plus.runtime.isApplicationExist({pname: 'com.tencent.mm', action: 'weixin://'})
          }
          
          • Android 平台通过 pname 属性(包名)进行查询。
          • iOS 平台通过 action 属性(Scheme)进行查询。iOS9 以后需要添加白名单才可查询,在 manifest.json 配置 urlschemewhitelist: ["weixin"]参考)。其他 App 同理。

          相关链接:

          App 热更新

          我们要知道,应用平台对 App 提供热更新服务其实是持排斥态度的,送审期间最好不要弹出热更新弹窗。

          前端部分:

          1. 通过 plus.runtime.getProperty() 获取当前 wgt 包的版本名称 versionName 和版本号 versionCode
          2. 请求服务端检查是有更新的 wgt 包
          3. 通过 uni.downloadFile() 下载 wgt 包
          4. 通过 plus.runtime.install() 安装 wgt 包
          5. 通过 plus.runtime.restart() 重启

          完整流程参考示例

          打包部分:

          1. 打包新版 wgt 时要更新 versionNameversionCode,且 versionCode 要比上一次大,否则安装完新包后退出,冷启动应用可能会重新加载旧包。
            • versionName 应用版本名称,如 1.2.3
            • versionCode 应用版本号,必须是整数,取值范围 1~2147483647,升级时必须高于上一次设置的值。

          相关链接:

          获取 getApp().globalData

          在原生小程序 + uni-app 小程序混合的场景下,当二者需要通信的时候,需要借助 getApp() 获取全局唯一的应用实例,在实例上注册相关属性或方法(比如 globalData)。

          在 uni-app 中获取 globalData 的方式:

          <script setup>
          import { getCurrentInstance } from 'vue'
          import { onLaunch } from '@dcloudio/uni-app'
          
          const instance = getCurrentInstance()
          
          onLaunch(() => {
            const globalData = instance.ctx.globalData
            // do something...
          })
          </script>
          

          相关链接:

          各端差异

          主要讨论微信小程序端与 App、H5 等非小程序端的差异。

          后代选择器

          比如,在父组件查询子组件内的某个元素。

          在微信小程序端有两种做法:

          1. 拿到子组件实例,然后再父组件内使用 uni.createSelectorQuery.in(subComponentInstance).select('.descendant') 获取元素。
          2. 使用 >>>(跨自定义组件的后代选择器),在父元素内使用 uni.createSelectorQuery().select('.ancestor >>> .descendant') 获取其子(子子...)组件内的元素。

          在 App 端,编译后的 HTML 结构跟小程序不一样,小程序带有一个 ShadowRoot 节点隔离,而 App 端则没有,因此 App 端使用 uni.createSelectorQuery().select('.ancestor .descendant') 可以直接查询到后代的元素。注意 App 端不支持 >>> 语法,它是小程序特有的。

          布尔 dataset

          在 HTML 中有一种布尔类型的属性

          <!-- attr is true -->
          <div attr></div>
          <div attr=""></div>
          <div attr="false"></div>
          <div attr="any value"></div>
          
          <!-- attr is false -->
          <div></div>
          

          如果要将某个属性变为 false,只能 removeAttribute('attr')。对于 setAttribute('attr', undefined) 等均是无效的,内部会有 toString() 的处理,因为 HTML 的属性值只能是字符串。data-* 同理。

          但使用 uniapp 时,不同端表现不一样。

          <view data-attr></div>
          

          对于 dataset.attr 微信小程序是布尔值 true,而 App 端则是字符串 '',后者表现是符合 HTML 规范的。猜测是微信小程序内部做了 dataset.attr !== undefined 的处理。如果要用于条件判断,要注意各端值是不一样的。

          未完待续...

          ]]>
          <![CDATA[常用 CSS Media Query 记录]]> https://github.com/tofrankie/blog/issues/352 https://github.com/tofrankie/blog/issues/352 Sun, 24 Nov 2024 16:56:17 GMT 配图源自 Freepik

          • @media 用于基于一个或多个媒体查]]> 配图源自 Freepik

            • @media 用于基于一个或多个媒体查询的结果来应用样式表中的一部分。
            • @supports 指定依赖于浏览器中的一个或多个特定的 CSS 功能的支持声明。

            深色模式

            @media (prefers-color-scheme: dark) {
              /* some rules */
            }
            

            prefers-color-scheme

            Safari

            Safari for Mac 和 Safari for Mobile(包括 iOS 端的 Chrome 等使用 Webkit 内核的浏览器)。

            @supports (-webkit-hyphens: none) {
              /* some rules */
            }
            

            -webkit-hyphens

            Safari for Mobile

            移动端 Safari 浏览器(包括 iOS 端的 Chrome 等使用 Webkit 内核的浏览器),但不包括 Safari for Mac。

            @supports (-webkit-touch-callout: none) {
              /* some rules */
            }
            

            -webkit-touch-callout

            ]]>
            <![CDATA[小程序图标变色]]> https://github.com/tofrankie/blog/issues/351 https://github.com/tofrankie/blog/issues/351 Tue, 19 Nov 2024 09:11:27 GMT 配图源自 Freepik

            图标变色是一个常见需求,比如根据用户心情自动切换皮肤。🐶

            本]]> 配图源自 Freepik

            图标变色是一个常见需求,比如根据用户心情自动切换皮肤。🐶

            本文以微信小程序为例。

            使用 svg 标签

            第一反应是用 svg + currentColor。比如:

            <div class="box">
              <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 15.7715 20.4004" width="15.7715" height="20.4004" >
                <path
                  d="M11.3965 4.66797C9.94141 4.66797 8.75 5.55664 7.98828 5.55664C7.17773 5.55664 6.12305 4.66797 4.85352 4.66797C2.44141 4.66797 0 6.71875 0 10.4688C0 12.8125 0.898438 15.2832 2.02148 16.875C2.97852 18.2227 3.81836 19.3066 5.0293 19.3066C6.2207 19.3066 6.74805 18.5352 8.23242 18.5352C9.73633 18.5352 10.0781 19.3066 11.3965 19.3066C12.7051 19.3066 13.5742 18.1055 14.4043 16.9238C15.3223 15.5664 15.7129 14.248 15.7227 14.1797C15.6445 14.1602 13.1445 13.1348 13.1445 10.2734C13.1445 7.79297 15.1074 6.67969 15.2246 6.5918C13.9258 4.72656 11.9434 4.66797 11.3965 4.66797ZM10.7129 3.08594C11.3086 2.36328 11.7285 1.37695 11.7285 0.380859C11.7285 0.244141 11.7188 0.107422 11.6992 0C10.7227 0.0390625 9.55078 0.644531 8.85742 1.46484C8.30078 2.08984 7.79297 3.08594 7.79297 4.08203C7.79297 4.23828 7.82227 4.38477 7.83203 4.43359C7.89062 4.44336 7.98828 4.46289 8.0957 4.46289C8.96484 4.46289 10.0586 3.87695 10.7129 3.08594Z"
                />
              </svg>
            </div>
            
            .box {
              color: #0066cc;
            }
            
            .box svg {
              fill: currentColor;
            }
            

            CodePen

            动态修改父元素的颜色就能变色。

            然,微信小程序不支持 <svg /> 标签。

            使用 image 标签 + base64

            小程序 image 标签是支持 svg 格式图片的(一些限制)。

            注意,将 fill="currentColor" 的 svg 图片放到 image 标签,该图片是无法读取其本身或父级颜色的,因此呈现会默认的黑色。

            要做一些转换处理:

            1. 下载 svg 图片
            2. 读取 svg 图片文件源码
            3. 替换 svg fill 属性为指定颜色
            4. 将替换后的 svg 文件转化为 base64
            function genColorfulSvg(url, color) {
              const localFilePath = await downloadSvgFile(url)
              const svgSourceStr = await readSvgFile(localFilePath)
              const colorfulSvgSourceStr = replaceSvgFillColor(svgSourceStr, color)
              const colorfulSvgBase64 = genSvgBase64(colorfulSvgSourceStr)
              return colorfulSvgBase64
            }
            
            // 下载图片
            async function downloadSvgFile(url) {
              return new Promise((resolve, reject) => {
                wx.downloadFile({
                  url,
                  success: res => {
                    if (res.statusCode === 200) return resolve(res.tempFilePath)
                    reject(new Error('download file fail'))
                  },
                  fail: err => reject(err),
                })
              })
            }
            
            // 读取 svg 文件
            async function readSvgFile(localFilePath) {
              return new Promise((resolve, reject) => {
                const fs = wx.getFileSystemManager()
                fs.readFile({
                  filePath: localFilePath,
                  encoding: 'utf-8',
                  position: 0,
                  success: res => {
                    const sourceFile = res.data
                    resolve(sourceFile)
                  },
                  fail: err => reject(err),
                })
              })
            }
            
            // 替换 fill 属性
            function replaceSvgFillColor(svgSourceStr, newFillColor) {
              // 匹配 fill="..." 中的非 none 值(按需调整)
              return svgSourceStr.replace(/fill="(?!none)[^"]*"/g, `fill="${newFillColor}"`)
            }
            
            // 生成 base64
            function genSvgBase64(svgSourceStr) {
              return `data:image/svg+xml;base64,${base64Encode(svgSourceStr)}`
            }
            

            示例中 base64Encode() 来自掘金社区(原文),找其他也行。

            将以上封装成一个自定义组件,比如:

            <colorful-image color="xxx" src="xxx" />
            

            可以在 Component 的 lifetimes.attached() 时机执行,为了避免重复下载转换,可以用 Map 将 base64 存起来。

            (具体实现就不写了)

            这个方案的缺点是要额外的读取、替换、转换,性能不太好。

            使用 image 标签 + CSS Filter

            这种方案使用 CSS Filter 函数 drop-shadow,它通常用于生成阴影效果。

            常规阴影是位于图片下方的,借助 transform 进行一些位移操作,思路如下:

            1. 隐藏原图:
              1. 将图片水平方向位移足够大的距离
              2. 将图片父级设置为 overflow: hidden
            2. 让阴影显示在原图本来的位置
              1. 将 filter: drop-shadow 水平相反方向位移同样的距离

            过程中遇到了一些坑,先以 Web 为例:

            <img class="icon" src="../../images/star.svg" />
            
            .icon {
              width: 100rpx;
              height: 100rpx;
              filter: drop-shadow(200rpx 0 0 #1f883d);
              transform: translateX(-200rpx);
            }
            

            ▲ 黄色为原图,绿色为投影

            接着,添加一个父级元素,并设为 overflow: hidden

            但是 drop-shadow 有一个表现特性:

            在 Chrome 浏览器下,如果一个元素的主体部分,无论以何种方式,只要在页面中不可见,其 drop-shadow 是不可见的;实体部分哪怕有 1px 可见,则 drop-shadow 完全可见。(源自张鑫旭大佬的博客)

            亲测 Safari 18.1 也有相同表现,不显示阴影,而 Firefox 132 是符合预期的。

            文章里提到一个解决方案是用透明边框,使元素总在屏幕可视区域内。

            调整下代码:

            + <view class="icon-box">
                <img class="icon" src="../../images/star.svg" />
            +  </view>
            
            + .icon-box {
            +   width: 100rpx;
            +   height: 100rpx;
            +   overflow: hidden;
            + }
            
              .icon {
                width: 100rpx;
                height: 100rpx;
                filter: drop-shadow(200rpx 0 0 #1f883d);
                transform: translateX(-200rpx);
            +   border-right: 1000px solid transparent;
              }
            

            这样的话,在 Chrome 下是符合预期了(黄色隐藏,绿色显示),然而 Safari 下阴影并没有显示出来。

            看起来有点类似这个《深究 Safari 下 border-radius & overflow 不生效的问题》,给 icon 添加 transform: translateZ(0)will-change: transform 等解决,但 Chrome 又不能加,可用 CSS Media Query 区分(小程序亲测有用)。

            如果使用 wx.getSystemInfo().platform 来区分设备,要注意开发工具、PC 端、Android 端与 Chrome 表现一致,不能加 will-change: transform 等处理,否则不显示。

            比如:

            @supports (-webkit-hyphens: none) {
              /* Safari for macOS or iOS browsers */
              .icon {
                will-change: transform;
              }
            }
            

            测试结果,Chrome、Safari、Firefox 和 iOS 浏览器都符合预期了。

            ⚠️ 注意,本打算给 border-right 给一个足够大的边框宽度,以兼容各种尺寸的图标,但发现过大时在 Safari 下不会呈现阴影,暂未发现有啥规律。但通常图标不会很大,如小程序端可以设置一屏大小 750rpx 的边框宽度。

            小程序组件:

            <colorful-image image-class="xxx" color="xxx" src="xxx" />
            
            • image-class 用于指定图片宽高等
            • color 颜色
            • src 图片链接(注意,相对路径是相对于 colorful-image 组件所在的目录)
            Component({
              externalClasses: ['image-class'],
            
              properties: {
                // Required
                src: {
                  type: String,
                  value: '',
                },
            
                color: {
                  type: String,
                  value: 'transparent',
                },
              },
            })
            
            {
              "component": true,
              "usingComponents": {}
            }
            
            <view class="image-class inner-image-box">
              <image class="inner-image" style="filter: drop-shadow(760rpx 0 0 {{ color }})" src="{{ src }}" />
            </view>
            
            .inner-image-box {
              overflow: hidden;
            }
            
            .inner-image {
              box-sizing: content-box;
              display: block;
              width: 100%;
              height: 100%;
              border-right: 100000px solid transparent;
              transform: translateX(-760rpx);
            }
            
            @supports (-webkit-hyphens: none) {
              .inner-image {
                will-change: transform;
              }
            }
            

            小程序代码片段

            注意:Skyline 渲染模式暂不支持。

            ]]>
            <![CDATA[小程序迷惑行为大赏]]> https://github.com/tofrankie/blog/issues/350 https://github.com/tofrankie/blog/issues/350 Tue, 19 Nov 2024 07:15:12 GMT 你开发小程序有遇到过哪些奇葩表现?

            自定义组件默认是块级元素还是行内元素?

            2024.11.19

            起因,给两个相邻的自定义组件设置 margin 值,发现没有 margin collapsing 效果。

            <]]>
            你开发小程序有遇到过哪些奇葩表现?

            自定义组件默认是块级元素还是行内元素?

            2024.11.19

            起因,给两个相邻的自定义组件设置 margin 值,发现没有 margin collapsing 效果。

            由于自定义组件一直表现为「换行」特性,让我以为默认是块级元素,然而开发工具 Computed 显示为 display: inline。😮

            有网友发现,它可能是“随机”的,有 block、有 inline,也有 flex 的。🙄

            Related link:

            ]]>
            <![CDATA[GitHub Blogger + GitHub Action]]> https://github.com/tofrankie/blog/issues/349 https://github.com/tofrankie/blog/issues/349 Mon, 12 Aug 2024 15:47:51 GMT 配图源自 Freepik

            前言

            去年二月,我将简书所有文章搬到 GitHub 上,终于]]> 配图源自 Freepik

            前言

            去年二月,我将简书所有文章搬到 GitHub 上,终于不用被审核了!

            • README 为主页
            • Issues 为文章列表

            📢 这是我的博客,你也可以试试用 Github Blogger 写一个属于自己的。

            入门速看

            备份

            数据在手才最让人安心的。

            如果你是用 Github Blogger 来管理文章(Issue),文章、配图会自动存档到 archivesimages 目录。

            如果你未使用 GitHub Blogger 或在此之前有一些历史 Issue,可以在本地使用脚本调用 GitHub API 拉取所有 issue,然后重新推到远程仓库。

            可参考 fetch-all-issues.ts

            此前试过用 hub-mirror-action 进行同步到 Gitee,但遇到些问题没解决。后来发现官方就有提供,参考《Gitee 仓库镜像管理》。

            访问统计

            在仓库 Insights - Traffic 处可以看到近两周的数据。由于 GitHub 只提供近 14 天的数据,可以创建定时任务 Workflow 将数据保存到仓库,用于统计总量。

            可参考 update-traffic-data.ts

            如果想将访问量展示到 README,可以用这个数据生成一个 SVG 图片,并引用该图片即可。

            可参考 update-traffic-views.ts

            我还看过另一个方案 Hints,当在 README 引入其图片后,每访问 README 一次(也就是 Get 一次图片),访问量 +1。缺点也明显,只有访问 README 才会被统计,访问 issue 等流量无法统计,也无法去重。

            RSS

            使用 GitHub Issue 作为博客,可以使用以下两种方案来创建 RSS:

            • RSSHub
            • 使用 GitHub Action 生成 RSS

            如果使用 Folo 订阅,都可以参考这篇文章认领你的 RSS。

            如果是第二种方式,可参考 generate-rss.ts,并在 README 中引入 Badge,比如:

            [![RSS](https://img.shields.io/badge/rss-subscribe-orange?logo=rss&style=flat)](https://raw.githubusercontent.com/tofrankie/blog/refs/heads/main/rss.xml)
            

            文章统计

            可以做些什么呢?

            比如:

            • 字数统计
            • 每年文章数
            • 近期热门文章
            • 近期创建/更新的文章

            字数统计我暂时没做,思路很简单,获取所有 issues,统计 issue.body 渲染成 Markdown 格式后的字数,打算参考 ByteMd

            每年文章数,遍历所有 issues,根据 issue.created_at 判断年份,可得出数据。我个人的话,更喜欢添加一个年份 Label,并利用 GitHub Issue 筛选功能,以便于快速查看每年所写文章,而且我有一些往年在简书写的文章,也是从去年从迁到 GitHub 上,所以创建时间也对不上。

            近期热门文章,其实跟上一章节 Traffic 是相关的,它可以获取近两周排名前 10 的内容,但不单指 issue 内容,还有其他的。只能根据返回的 path 过滤得到对应的 issue。

            近期创建/更新的文章,可以设置一个 Workflow,每当 issue 被创建或编辑时触发,然后也是通过 List repository issues 接口获取近 10 篇,加个排序 sort = updated 即可。可参考我的

            配置 issue 模板

            对于博客型仓库,每个 Issue 表示一篇文章。

            通常不希望他人创建 Issue,可以对此作出一些限制,创建 .github/ISSUE_TEMPLATE/config.yml 文件,比如:

            blank_issues_enabled: true
            contact_links:
              - name: ❗️ 请勿新开 Issue
                url: https://github.com/tofrankie/blog/discussions/342
                about: 阅读文章时,如有任何想法、建议,欢迎底下评论(或在 Discussions 发起讨论),而不是新开 Issue。
            

            当他人 New Issue 时,会提示:

            如果想要彻底关掉,可将 blank_issues_enabled 设为 false,更多请看配置模板选择器

            ]]>
            <![CDATA[通过两个例子再探 Event Loop]]> https://github.com/tofrankie/blog/issues/348 https://github.com/tofrankie/blog/issues/348 Sun, 11 Aug 2024 08:42:16 GMT 配图源自 Freepik

            提问

            ▼ 请问点击哪个按钮会导致页面卡死?

            配图源自 Freepik

            提问

            ▼ 请问点击哪个按钮会导致页面卡死?

            <article>
              <h1>蒹葭</h1>
              <p>蒹葭苍苍,白露为霜。所谓伊人,在水一方。溯洄从之,道阻且长。溯游从之,宛在水中央。</p>
              <p>蒹葭萋萋,白露未晞。所谓伊人,在水之湄。溯洄从之,道阻且跻。溯游从之,宛在水中坻。</p>
              <p>蒹葭采采,白露未已。所谓伊人,在水之涘。溯洄从之,道阻且右。溯游从之,宛在水中沚。</p>
              <p></p>
            </article>
            
            <button onclick="whileLoop()">点我</button>
            <button onclick="timerLoop()">点我</button>
            <button onclick="promiseLoop()">点我</button>
            
            <script>
              function whileLoop() {
                while (true) {}
              }
            
              function timerLoop() {
                setTimeout(timerLoop, 0)
              }
              
              function promiseLoop() {
                Promise.resolve().then(promiseLoop)
              }
            </script>
            

            ▼ 请问点击按钮红色 div 会闪吗?

            <div id="box" style="width: 100px; height: 100px; background: red"></div>
            <button onclick="clickme">点我</button>
            
            <script>
              const box = document.getElementById('box')
            
              function clickme() {
                box.style.display = 'none'
                box.style.display = 'block'
                box.style.display = 'none'
                box.style.display = 'block'
                box.style.display = 'none'
                box.style.display = 'block'
              })
            </script>
            

            题目比较简单,相信大家都有答案了。

            我们继续往下。

            开始之前

            对于 Event Loop,相信大家都有这样一张图:

            接下来,将会更深入地了解 Event Loop,真的如上图所示吗?

            没错,我也是从以下链接受益,并结合自己的理解,将其写下来而已。

            为什么 JavaScript 设计成单线程?

            最初 JavaScript 是为浏览器而设计的,旨在增强可交互性。

            单线程,意味着同一时间只能做一件事情。

            设想一下,有两个线程同时作用于某个元素,一个是修改样式,另一个是删除元素,如何响应呢?引入锁机制?

            当时网页不如现在复杂,选择单线程是明智、合理、够用的,操作变得有序可控,且大大降低复杂度。

            随着时代的发展,计算越来越复杂,单线程有点捉襟见肘,后来 HTML5 提供了 Web Worker 等 API 可主动创建新的线程运行一些复杂的运算。

            什么是 Event Loop?

            规范是这样定义的:

            To coordinate events, user interaction, scripts, rendering, networking, and so forth. 协调事件、用户交互、脚本、渲染、网络等。

            个人理解:它是让各种任务执行变得有序可控的一种机制。

            用伪代码表示:

            while (true) {
              task = taskQueue.pop()
              execute(task)
            }
            

            当然,实际没有这么简单,只是从简单说起,请继续往下。

            它是无限循环的,7 × 24h 随时待命(比 996 还惨 😭),直至浏览器 Tab 被关闭。

            只要有任务,它就会不停地从队列中取出任务,执行任务。

            在浏览器中,Event Loop 有 Window Event Loop、Worker Event Loop、Worklet Event Loop 三种,第一种是本文主要讨论的对象。当然 Node.js 也有 Event Loop 机制,但不太一样。

            什么是 Task?

            规范是这样定义的:

            Formally, a task is a struct which has: steps, a source, a document, a script evaluation environment settings object set. 形式上,任务是一种 struct 结构体,包含 Steps、Source、Document、Script evaluation environment settings object set。

            简单来说,任务就是一个包含 steps 等属性的对象,里面记录了任务的来源、所属 Document 对象、上下文等,以供后续调度。

            常见的任务有:

            • 与用户发生交互而产生的所有事件回调(比如单击、文本选择、页面滚动、键盘输入等)
            • setTimeout、setInterval
            • 执行 script 块
            • I/O 操作

            什么是 Task Queue?

            常规意义的队列

            队列(Queue)是一种基本的数据结构,遵循先进先出(FIFO, First In First Out)的原则。在队列中,最先插入的元素最先被移除,类似于排队等候的场景。

            • 入队(Enqueue):将一个元素添加到队列的尾部。
            • 出队(Dequeue):从队列的头部移除一个元素,并返回该元素。

            Event Loop 中的任务队列

            规范中提到:

            An event loop has one or more task queues. 事件循环有一个或多个任务队列。

            Task queues are sets, not queues, because the event loop processing model grabs the first runnable task from the chosen queue, instead of dequeuing the first task.' 任务队列是集合,而不是队列,因为事件循环处理模型从所选队列中获取第一个可运行的任务,而不是使第一个任务出队。

            The microtask queue is not a task queue. 微任务队列不是任务队列。

            前面提到,task 是有 source 的,比如来自鼠标点击等。排队时,同 source 的 task 会被放入与该 source 相关的 task queue 里。假设鼠标事件的任务要优于其他任务,Event Loop 就可以在对应 source 的 task queue 中取出任务优先执行。规范里 Event Loop 执行步骤并没有明确定义“出队”的规则,它取决于浏览器的实现。

            Let taskQueue be one such task queue, chosen in an implementation-defined manner.

            现在 Event Loop 用伪代码表示是这样的:

            while (true) {
              queue = getNextQueue()
              task = queue.getFirstRunnableTask()
              execute(task)
            }
            

            😲 在此之前,我的认知是:一个 Event Loop 里有且只有一个任务队列,且它是一个常规意义的队列。虽说如此,如果只想了解 Event Loop 主要执行顺序,不深入浏览器究竟维护了多少个任务队列、浏览器如何决定下一任务,按原来的理解也问题不大。

            什么时候重绘页面?

            总不能只执行任务,不更新 DOM 吧。

            本质上,网页就是给人看的,与人交互的,所以用户体验非常重要。假设任务队列有源源不断的任务产生,如果 Event Loop 只会一直循环执行队列里的任务,而不去更新页面,用户体验是非常糟糕的。

            请问浏览器什么时候会更新页面?

            浏览器是非常聪明的,没必要的工作它不会做。以 60Hz 屏幕为例,每秒刷新 60 次,约 16.7ms 刷新一次。只要满足该刷新频率的,显示就算是流畅的,因为再快的刷新频率对肉眼来说也不会有明显的感知。也就是说每 16.7ms 可获得一次渲染机会(rendering opportunity),这样浏览器就知道要更新 DOM 了。

            假设一个任务耗时 3 ~ 5ms,远没到 16.7ms,对于浏览器来说,此时更新 DOM 是没有必要的,因此也不会获得一个渲染机会。相反地,如果一个任务执行超过 16.7ms,呈现出来的效果有可能是卡顿的。

            注意,规范中不强制要求使用任何特定模型来选择渲染机会。但例如,如果浏览器尝试实现 60Hz 刷新率,则渲染机会最多每 60 秒出现一次(约 16.7ms)。如果浏览器发现 navigable 无法维持此速率,则该 navigable 可能会下降到更可持续的每秒 30 个渲染机会,而不是偶尔丢帧。类似地,如果 navigable 不可见,浏览器可能会决定将该页面降低到每秒 4 个渲染机会,甚至更少。

            React 16 可中断的调度机制,就是为了可以执行优先级更高的任务(比如更新 DOM),以解决某些场景下页面卡顿的问题。

            因此,一个任务执行完,如果有渲染机会先更新 DOM,接着才执行下一个任务。

            现在 Event Loop 用伪代码表示是这样的:

            while (true) {
              queue = getNextQueue()
              task = queue.getFirstRunnableTask()
              execute(task)
              
              if (hasRendringOpportunity()) repaint()
            }
            

            什么是 Microtask?

            还没完,还没完...

            规范中提到:

            Each event loop has a microtask queue, which is a queue of microtasks, initially empty.

            A microtask is a colloquial way of referring to a task that was created via the queue a microtask algorithm.

            The microtask queue is not a task queue.

            好,我们重新捋一下:

            • 一个 Event Loop 有一个或多个 task queue。
            • 一个 Event Loop 有且仅有一个 microtask queue。
            • task 是一个由特定属性的对象(规范中称为 struct)。
            • microtask 只是一种通俗的说法,它是通过特定算法创建的 task。
            • task queue 是一组 task 的集合,并不是队列
            • microtask 是常规意义的队列,遵循先进先出。
            • microtask queue 不是 task queue,前者是队列,后者是集合。

            为便于区分理解,本文暂且将以下规范术语口语化(但注意,这种说法不一定准确)。

            • task:(宏)任务
            • task queue:(宏)任务队列
            • microtask:微任务
            • microtask queue:微任务队列

            有哪些微任务?

            在 JavaScript 里会产生微任务的大概有:

            • queueMicrotask(Window 或 Web Worker)
            • Promise 回调
            • MutationObserver 回调
            • Object.observe(Deprecated)

            什么时候执行微任务?

            从规范(Processing model)可知,只要 Event Loop 存在,就必须不断执行以下步骤:

            1. 从(宏)任务队列取出一个 task
            2. 执行该 task
            3. 执行微任务检查点(microtask checkpoint
              1. 如果检查点标志为真(初始值为 false),则返回(跳出微任务执行)。
              2. 将检查点标志设为 true
              3. 如果当前 Event Loop 里的微任务队列不为空,将一直循环直至微队列为空:
                1. 在微任务队列里取出的第一个微任务
                2. 执行微任务
              4. 将检查点标志设为 false
            4. 重复上述步骤

            以上为简化后的步骤。

            至此,文章开头的提问之一就有答案。由于它在执行微任务的过程中不停地产生新的微任务,因此将会在 3.iii 陷入死循环,自然页面就“卡死”了。

            跟 task 的一些区别

            请注意,无论是(宏)任务,还是微任务,执行过程中都可能产生“新”的(宏)任务或微任务。它们的执行顺序是有区别的:

            • (宏)任务执行时产生的新的(宏)任务,在下一轮或以后执行。
            • (宏)任务执行时产生的新的微任务,在当前(宏)任务执行完之后、更新 DOM 或下一轮(宏)任务之前执行。
            • 微任务执行时产生的新的(宏)任务,在下一轮或以后执行。
            • 微任务执行时产生的新的微任务,马上放入微任务队列,直到所有微任务队列执行完,才到更新 DOM 或执行下一轮(宏)任务。

            现在 Event Loop 用伪代码表示是这样的:

            while (true) {
              queue = getNextQueue()
              task = queue.getFirstRunnableTask()
              execute(task)
              
              while (microtaskQueue.hasTask()) {
                microtask = microtaskQueue.pop()
                excute(microtask)
              }
              
              if (hasRendringOpportunity()) repaint()
            }
            

            什么是 requestAnimationFrame?

            噢,还没完,还有一个 requestAnimationFrame,其回调函数会在页面重绘之前调用。

            当浏览器检测到有渲染机会,会更新 DOM,具体执行顺序如下:

            1. 执行 requestAnimationFrame 回调
            2. 合成:计算样式,将 DOM Tree 和 CSSOM Tree 合成一个 Render Tree(Attachment)
            3. 重排:以确定每个节点所占空间、所在位置等(Layout)
            4. 重绘:以设置颜色等(Paint)

            比较坑的是,Edge 和 Safari 将 requestAnimationFrame 回调放到 Paint 后面执行,这是非标准做法。也就是说,如果回调中涉及样式,用户要在下一帧才能看到变化。

            Safari 是否已修复,待验证。

            除了有 task queue(集合)、microtask queue(队列),还有一个 animation frame callbacks,它是一个 ordered map(映射)。

            将 animation frame callbacks 简单理解为“队列”也不是不行,因为根据 run the animation frame callbacks 可以看到,也是从第一个开始遍历执行。

            同样地,执行 callbacks 的过程中产生新的 callback,它们会放到下一次 Loop 执行,这点跟微任务是不一样的。

            现在 Event Loop 用伪代码表示是这样的:

            while (true) {
              queue = getNextQueue()
              task = queue.getFirstRunnableTask()
              execute(task)
              
              while (microtaskQueue.hasTask()) {
                microtask = microtaskQueue.pop()
                excute(microtask)
              }
              
              if (hasRendringOpportunity()) {
                callbacks = animationFrameCallbacks.spliceAll()
                for (callback in callbacks) {
                  execute(callback)
                }
                
                repaint()
              }
            }
            

            Node.js Event Loop 是怎样的呢?

            相比之下,Node.js 里没有以下这些:

            • 没有 <script> 解析
            • 没有用户交互
            • 没有 DOM
            • 没有 requestAnimationFrame

            Node.js 特有的是:

            • setImmediate
            • process.nextTick

            Node.js 的 Event Loop 由 libuv 实现,包含以下阶段:

               ┌───────────────────────────┐
            ┌─>│           timers          │
            │  └─────────────┬─────────────┘
            │  ┌─────────────┴─────────────┐
            │  │     pending callbacks     │
            │  └─────────────┬─────────────┘
            │  ┌─────────────┴─────────────┐
            │  │       idle, prepare       │
            │  └─────────────┬─────────────┘      ┌───────────────┐
            │  ┌─────────────┴─────────────┐      │   incoming:   │
            │  │           poll            │<─────┤  connections, │
            │  └─────────────┬─────────────┘      │   data, etc.  │
            │  ┌─────────────┴─────────────┐      └───────────────┘
            │  │           check           │
            │  └─────────────┬─────────────┘
            │  ┌─────────────┴─────────────┐
            └──┤      close callbacks      │
               └───────────────────────────┘
            

            The Node.js Event Loop

            • timers:执行 setTimeout、setInterval 的回调。
            • pending callbacks:执行一些系统操作的回调,比如 TCP 错误等。
            • idle, prepare:仅在内部使用。
            • poll:几乎所有异步回调都在这个阶段执行,除 setTimeout、setInterval 和 setImmediate 之外。
            • check:执行 setImmediate 的回调。
            • close callbacks:执行关闭事件,比如 socket 或 handle 突然关闭,会发出 close 事件。

            在 Node.js 中,还有一个特殊的 process.nextTick() 方法。技术上,它不属于事件循环的一部分。当你在某个阶段调用时,传递给它的所有回调将在当前阶段执行完之后,下一个阶段执行之前执行。如果递归调用它,是会造成死循环的。

            用伪代码表示是这样的:

            while (tasksAreWaiting()) {
              queue = getNextQueue()
              
              while (queue.hasTask()) {
                task = queue.pop()
                execute(task)
                
                while (nextTickQueue.hasTask()) {
                  callback = nextTickQueue.pop()
                  excute(callback)
                }
                
                while (promiseQueue.hasTask()) {
                  promise = promiseQueue.pop()
                  excute(promise)
                }
              }
            }
            

            Worker Event Loop 又是怎样的呢?

            它更简单:

            • 没有 <script> 解析
            • 没有用户交互
            • 没有 DOM(Worker 不能直接操作 DOM)
            • 没有 requestAnimationFrame
            • 没有 process.nextTick
            • 没有 setImmediate

            且线程之间相互独立,每个线程都有自己的 Event Loop,互不干扰。

            现在 Event Loop 用伪代码表示是这样的:

            while (true) {
              task = taskQueue.pop()
              execute(task)
              
              while (microtaskQueue.hasTask()) {
                microtask = microtaskQueue.pop()
                excute(microtask)
              }
            }
            

            但注意,如果在 Web Worker 的线程向主线程传递消息,这个消息对于 Window Event Loop 来说属于一个 task,它仍受主线程的 Event Loop 控制,该排队还得排队。

            思考题

            先回到文章开头的题目。

            点击哪个按钮会导致页面卡死?

            <article>
              <h1>蒹葭</h1>
              <p>蒹葭苍苍,白露为霜。所谓伊人,在水一方。溯洄从之,道阻且长。溯游从之,宛在水中央。</p>
              <p>蒹葭萋萋,白露未晞。所谓伊人,在水之湄。溯洄从之,道阻且跻。溯游从之,宛在水中坻。</p>
              <p>蒹葭采采,白露未已。所谓伊人,在水之涘。溯洄从之,道阻且右。溯游从之,宛在水中沚。</p>
              <p></p>
            </article>
            
            <button onclick="whileLoop()">点我</button>
            <button onclick="timerLoop()">点我</button>
            <button onclick="promiseLoop()">点我</button>
            
            <script>
              function whileLoop() {
                while (true) {}
              }
            
              function timerLoop() {
                setTimeout(timerLoop, 0)
              }
              
              function promiseLoop() {
                Promise.resolve().then(promiseLoop)
              }
            </script>
            

            答案:whileLoop、promiseLoop 会导致页面卡死,timerLoop 则不会。

            whileLoop 分析:点击按钮,产生一个 task,进入 task queue 排队。轮到它的时候,执行 whileLoop() 方法,里面是一个无线循环的 while 语句,因此这个 task 会一直执行下去,且致使后面的 task、更新 DOM 等永远无法执行。页面就卡死了。

            timerLoop 分析:点击按钮,产生一个 task,进入 task queue 排队。轮到它的时候,执行 timerLoop() 方法,又产生一个 task 并放入 task queue。执行完之后,如果有 rendering opportunity 会先更新 DOM,完了执行进行下一轮。尽管 timerLoop 里不停地产生新的 task,但用户仍然通过文本选择、页面滚动等产生其他 task 进入到 task queue 进行排队。因此页面是不会呈现卡死状态的。

            promiseLoop 分析:点击按钮,产生一个 task,进入 task queue 排队。轮到它的时候,执行 promiseLoop() 方法,其中 Promise.resolve() 产生一个 microtask 并放入 microtask queue。当 task 执行完,接着从 microtask queue 里取出 microtask 执行,即执行 then(promiseLoop),它有又产生新的 microtask,所以 microtask queue 就一直有任务存在,因此会陷入死循环,致使后面的 task、更新 DOM 等永远无法执行。

            它们会闪烁吗?

            你有没有担心过这些代码会“闪”一下?

            document.body.appendChild(element)
            element.style.display = 'none'
            

            当然,实际中更多是先设置样式再 appendChild,但效果是一样的。

            请问点击按钮红色块会闪烁吗?

            <div id="box" style="width: 100px; height: 100px; background: red"></div>
            <button id="btn">Click me</button>
            
            <script>
              const btn = document.getElementById('btn')
              const box = document.getElementById('box')
            
              btn.addEventListener('click', () => {
                box.style.display = 'none'
                box.style.display = 'block'
                box.style.display = 'none'
                box.style.display = 'block'
                box.style.display = 'none'
                box.style.display = 'block'
                // ...
              })
            </script>
            

            CodePen

            答案:都不会。

            分析:上述点击事件产生一个 task(事件回调),只有执行完 task 里面的代码,才会执行后面的微任务或更新 DOM。也就是说渲染之前,实际只有最后一行的样式设置是起作用的,不管中间设了多少遍,浏览器只关心最后的样式如何。

            它们的执行顺序是?

            以下示例,一个按钮绑定了两个 click 事件:

            <button id="btn">Click me</button>
            
            <script>
              const btn = document.getElementById('btn')
            
              btn.addEventListener('click', () => {
                Promise.resolve().then(() => console.log('microtask 1'))
                console.log('listener 1')
              })
            
              btn.addEventListener('click', () => {
                Promise.resolve().then(() => console.log('microtask 2'))
                console.log('listener 2')
              })
              
              // btn.click()
            </script>
            

            现触发 click 事件的方式有两种:一个是通过鼠标点击触发,另一个是通过 btn.click() 触发。这两种方式的执行顺序一样吗?

            通过鼠标点击的结果是:

            listener 1
            microtask 1
            listener 2
            microtask 2
            

            通过 btn.click() 的结果是:

            listener 1
            listener 2
            microtask 1
            microtask 2
            

            原因分析:通过与用户交互而触发的事件,其监听器是异步调用的,而通过 btn.click() 触发,会同步派发事件,并以合适的顺序同步地调用监听器。

            对于“鼠标”点击:由于 btn 注册了两个 click 监听器,鼠标点击一次,产生两个 task 进入 task queue,先后执行,因此得到前面的结果。

            对于 btn.click() 模拟点击:当执行到 btn.click() 时,按顺序同步执行两个监听器。

            The dispatchEvent() method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. The normal event processing rules (including the capturing and optional bubbling phase) also apply to events dispatched manually with dispatchEvent().

            1. 执行到第一个监听器 Promise.resolve() 时产生一个 microtask 入队到 microtask queue。
            2. 接着打印 listener 1。
            3. 注意,此时调用栈里还没执行完,所以接着并不是立马执行 microtask queue 里的任务。而是接着执行第二个监听器。同样地,它又产生一个 microtask 入队到 microtask queue。
            4. 接着打印 listener 2。
            5. 此时调用栈空了,接着从 microtask queue 取出任务,逐个执行,因此先后打印 microtask 1、microtask 2。

            可以通过 event.isTrusted 来区分两种触发方式,用户与浏览器交互而产生的事件 isTrustedtrue,使用 JavaScript 来模拟点击等事件触发的 isTrustedfalse

            参考链接

            ]]>
            <![CDATA[Photoshop 操作记录]]> https://github.com/tofrankie/blog/issues/347 https://github.com/tofrankie/blog/issues/347 Sat, 10 Aug 2024 04:43:33 GMT 配图源自 Freepik

            好长时间没用 Photoshop 了,有些操作忘得很快,在此记录一下。

            ]]>
            配图源自 Freepik

            好长时间没用 Photoshop 了,有些操作忘得很快,在此记录一下。

            将 PNG 非透明部分填充为前景色

            步骤:

            1. 打开 PNG 图片
            2. 新建图层
            3. 将新建的图层填充为前景色(⌥ + ⌫)
            4. 选择新健的图层创建剪贴蒙版(⌥ + ⌘ + G)
            5. 选择所有图层,合并可见图层(⇧ + ⌘ + E)
            6. 保存。

            快捷键:填充前景色为 ⌥ + ⌫,填充背景色为 ⌘ + ⌫。

            批处理:

            1. 打开「窗口 - 动作 - 创建新动作」,将上述步骤录一遍。
            2. 打开「文件 - 自动 - 批处理」,选择上一步的动作和要处理的图片目录,点击确定即可。
            ]]>
            <![CDATA[老是记不住 isEmpty]]> https://github.com/tofrankie/blog/issues/346 https://github.com/tofrankie/blog/issues/346 Wed, 07 Aug 2024 08:48:22 GMT 配图源自 Freepik

            背景

            经常需要判断一个值是否为“空”,包括空对象、空数组、空]]> 配图源自 Freepik

            背景

            经常需要判断一个值是否为“空”,包括空对象、空数组、空字符串、null、undefined 等。

            用到较多的库是 licia 或 lodash,它们都提供了一个 isEmpty() 方法,但有一点区别。

            纠结 isEmpty 的主要原因是:没完全区分好什么值算作“空”?

            有时候,不确定某个值算不算空,为避免项目出错可能要跑 Playground 确认。

            比如 isEmpty(1) 结果是 true 还是 false?

            起初我以为 isEmpty(0) 结果为 false, isEmpty(1) 结果为 true。出现这种直觉性的错误,是因为在 JavaScript 中 1 属于真值(Truth Value),0 属于虚值(Falsy Value),所以理所当然了...

            然,实际测试结果:对于 Number 类型的值,isEmpty 均返回 true。

            于是就有了这篇文章,彻底扫清纠结。

            哪些值算作“空”?

            在 licia 和 lodash 中,以下值都视为空:

            • 空对象:本身可枚举的 key length 为 0 的对象(不含原型上的)
            • 空数组:长度为 0 的数组(类数组同理)
            • 字符串:长度为 0 的字符串
            • 数值:均视为空
            • 布尔值:均视为空
            • undefined
            • null

            对于 Map/Set 类型,licia 和 lodash 会有差异:

            • 在 licia 中不支持这两种类型,无论 size 是多少,均视为空。
            • 在 lodash 中,当 size 为 0 的 Map/Set 对象视为空。

            其实像数组、字符串这些类型,对“空”的定义是毫无疑问的,长度为 0 就是空。主要是数值、布尔值这类,刚开始不清楚。

            示例

            虚值:

            // licia.isEmpty() 与 lodash.isEmpty() 结果一致
            
            isEmpty(null) // true
            isEmpty(undefined) // true
            isEmpty(false) // true
            isEmpty('') // true
            isEmpty(0) // true
            isEmpty(0n) // true
            isEmpty(NaN) // true
            

            部分真值:

            // licia.isEmpty() 与 lodash.isEmpty() 结果一致
            
            isEmpty(1) // true
            isEmpty(1n) // true
            isEmpty(true) // true
            

            对于 Number、Boolean、BigInt 类型的原始值,isEmpty() 均返回 true。

            Map/Set:

            const licia = require('licia')
            const lodash = require('lodash')
            
            const map = new Map([[1, 'one']])
            const set = new Set([1])
            
            licia.isEmpty(map) // true
            licia.isEmpty(set) // true
            
            lodash.isEmpty(map) // false
            lodash.isEmpty(set) // false
            

            源码

            // https://github.com/liriliri/licia/blob/master/src/isEmpty.js
            
            _('isArrLike isArr isStr isArgs keys');
            
            exports = function(val) {
                if (val == null) return true;
            
                if (isArrLike(val) && (isArr(val) || isStr(val) || isArgs(val))) {
                    return val.length === 0;
                }
            
                return keys(val).length === 0;
            };
            
            // https://github.com/liriliri/licia/blob/master/src/keys.js
            
            _('has');
            
            if (Object.keys && !LICIA_TEST) {
                exports = Object.keys;
            } else {
                exports = function(obj) {
                    const ret = [];
            
                    for (const key in obj) {
                        if (has(obj, key)) ret.push(key);
                    }
            
                    return ret;
                };
            }
            
            // https://github.com/liriliri/licia/blob/master/src/has.js
            
            const hasOwnProp = Object.prototype.hasOwnProperty;
            
            exports = function(obj, key) {
                return hasOwnProp.call(obj, key);
            };
            

            非常简单:

            1. 判断 null、undefined
            2. 判断数组、类数组
            3. 使用 in 操作符,获取其自身可枚举的属性,再判断 key length

            对于原始值,in 操作会隐式转换为包装对象 Object(val),由于它本身没有可枚举属性,所以 Number、Boolean、BigInt 等类型的值 isEmpty() 会返回 true。

            lodash 差不多,其支持的类型更丰富一下,比如 ArrayBuffer、TypedArray,但项目一般很少判断这类的,不多说了。https://github.com/lodash/lodash/blob/4.17.15/lodash.js#L11479

            ]]>
            <![CDATA[iCloud 云盘同步卡住的解决方法]]> https://github.com/tofrankie/blog/issues/345 https://github.com/tofrankie/blog/issues/345 Sat, 03 Aug 2024 08:56:44 GMT 配图源自 Freepik

            在一定程度上来说,Apple 生态确实做得很不错。

            但是 iCl]]> 配图源自 Freepik

            在一定程度上来说,Apple 生态确实做得很不错。

            但是 iCloud Drive 就是一坨屎,经常性同步卡死...

            比如这样,它可以卡到天荒地老... :anger:

            一搜,全是这类问题:

            先到 Apple 官网(戳我),确认 iCloud 云盘服务有没崩。

            然后官方客服可能会让你关掉 iCloud 云盘同步,重新打开,但如果是 iCloud 云盘重度用户,文件容量特别多的话,你得想想重新下载的痛苦,而且 iCloud 下载的速度还...

            [!NOTE] 以下提供一些「可能」有效的方法,实际全凭天意。

            杀掉进程

            终端下先后执行以下命令,然后重新打开 Finder。

            $ killall bird
            $ killall cloudd
            

            bird 是什么?

            $ man bird
            BIRD(8)                                       System Manager's Manual                                       BIRD(8)
            
            NAME
                 bird – Documents in the Cloud
            
            SYNOPSIS
                 bird
            
            DESCRIPTION
                 bird is one of the system daemons backing the Documents in the Cloud feature.
            
                 There are no configuration options to bird, and users should not run bird manually.
            
            SEE ALSO
                 brctl(1)
            
            Mac OS X                                              22/04/14
            

            调整进程优先级

            1. 执行 ps aux | grep bird,会看到类似的输出。其中 4137 为进程 ID(PID)。
            $ ps aux | grep bird
            frankie           4137   0.0  0.1 33705140  10160   ??  S     4:01下午   0:06.68 /System/Library/PrivateFrameworks/CloudDocsDaemon.framework/Versions/A/Support/bird
            
            1. 执行 ps -fl -C <PID> 查看某进程优先级。其中 NI 是指 nice 值,表示一个进程的优先级。
            $ ps -fl -C 4137
              UID   PID  PPID   C STIME   TTY           TIME CMD                     F PRI NI       SZ    RSS WCHAN     S             ADDR
              501  4137     1   0  4:01下午 ??         0:06.68 /System/Library/  1004004  31  0 33704616  10244 -      S                   0
            
            1. 执行 sudo renice -n -10 -p <PID> 调整某个进程优先级。
            $ sudo renice -n -10 -p 4137
            

            负数优先级更高,据说 -20 最高,但不建议,这里使用 -10(参考这里)。

            根据以上自定义两个 alias,可添加到 .bash_profile.zshrc 里。

            alias renice_bird_process='BIRDPID=$(ps aux | grep -i bird | awk '\''{print $2}'\'' | head -1); sudo renice -n -10 -p $BIRDPID'
            
            alias show_bird_process_priority='BIRDPID=$(ps aux | grep -i bird | grep -v grep | awk '\''{print $2}'\'' | head -1); ps -o pid,ni -p $BIRDPID'
            

            删除 CloudDocs(慎重)

            这个我未亲测,仅供参考。

            $ killall bird
            
            $ rm -rf ~/Library/Application\ Support/CloudDocs
            

            删掉 CloudDocs 目录下所有文件,相当于把 iCloud 云盘的数据库清空,然后它会自动重启 iCloud 任务,重新上传或下载。

            ]]>
            <![CDATA[记七月:福州平潭 & 林奕匡]]> https://github.com/tofrankie/blog/issues/344 https://github.com/tofrankie/blog/issues/344 Sat, 27 Jul 2024 05:27:16 GMT 时间过得飞快,马上七月就要结束了。

            上个月想着,没那么忙了就出去玩几天,无奈六月都在下雨,就推到了七月。

            恰好家里那几位少爷也放暑假(好吧,家里只有我没寒暑假了,实名羡慕)。

            接着,简单做下攻略,订好民宿、往返车票,就出发了...

            平潭

            �]]> 时间过得飞快,马上七月就要结束了。

            上个月想着,没那么忙了就出去玩几天,无奈六月都在下雨,就推到了七月。

            恰好家里那几位少爷也放暑假(好吧,家里只有我没寒暑假了,实名羡慕)。

            接着,简单做下攻略,订好民宿、往返车票,就出发了...

            平潭

            🗺️ 福州平潭

            ▲ 北港村附近的海边小路

            ▲ 将军山

            ▲ 龙王头沙滩(我哥和我的小弟们)

            出发前的准备

            • 防晒:物理防晒 + 化学防晒,比如防晒衣、防晒手套、防晒霜、防晒喷雾、冰袖、帽子...
            • 学生证:平潭收费景区不多,如果有还是带上。
            • 身份证:这个不用说了。
            • 天气:平潭一定是晴天才好看。

            千万千万千万不要小看平潭的紫外线,可以去小红书搜一下,看到那些晒出个围脖、手套、袜子的,还有一年还没完全白回来的帖子,笑死。

            骑小电驴的不要忽略手,特意提到「防晒手套」。

            不怕晒伤、晒到蜕皮、晒黑的,可忽略。

            行程

            • 福州一天
            • 平潭两天

            13 日,高铁广州南 → 福州南。

            14 日,下午高铁福州南 → 平潭。

            15 日,早晨日出 + 平潭北线。

            16 日,平潭南线。

            17 日,自然醒,下午的高铁回广州。

            13 日到福州南已是 15:30,在福州呆了一晚。逛了下三坊七巷,那啥鱼燕真不喜欢吃,几位少爷们走一下就喊累,一行几人对吃的不大感冒,下次一定不带你们去什么步行街、美食街了。

            福州 → 平潭,除了高铁,也可以在闽运易行小程序上面提前订票(找约巴),听说挺实惠的。

            夏天日出时间 05:00 左右。15 日早上天气不好,被一朵云挡住了,看了个寂寞。

            住宿

            • 福州:福州南站附近,说实话很差。
            • 平潭:距离龙王头约 2km,三室一厅,可住 6 人,约 260 一晚,住宿条件很好。

            平潭民宿是玫瑰小区扣子客栈(步梯七楼),给老板打个广告,虽然可能没什么用,哈哈。

            如果要走南北两条线的,推荐住平潭县城,龙王头附近。

            租车

            • 电动车:优点随停随拍,不用担心停车问题,缺点晒。
            • 小车:跟电动车相反,特别是周末、节假日堵车停车问题尤为明显。

            根据行程安排选择,比如一天走完南北线,那肯定选小车。

            如果时间充足,个人更推荐电动车。比如,北线很多海边小路,风景远比大路好,这是小车不能带来的快乐。

            租车提前订,美团、小红书、抖音都可以,找一个下榻附近的,开车去哪都方便。

            电动车,以 24h 为例,价格 30 ~ 60 不等。挑个车况好的,有没电免费救援服务的,我们租了两天,跑满南北线完全没问题,还嘎嘎快,但要注意安全,全岛都是电动车。

            [!NOTE] 关于傍晚骑小电驴返回县城,导航请选择「驾车线路」,避免走偏僻、漆黑的道路。

            [!IMPORTANT] 开车一定要遵守交通规则,安全第一,生命至上。无论什么时候,在哪,开什么车,宁可等一分钟,也不抢一秒

            [!CAUTION] 返程那天刷到一个电动车事故的视频,一辆电动车撞上了环卫车,好像其中一个女生不幸......所以一定一定一定要注意安全。还听说最近平潭严抓电动车,我猜跟这起事故多少有点关系。

            美食

            个人非常非常非常挑食,无论去到哪,对(当地特色)美食基本上提不起兴趣,就不说了。

            景点

            ▲ 源自小红书

            大致有这些,我没做什么攻略,了解路线之后,骑上小电驴就出发,也没有严格按景点走。

            北线:

            • 仙人井,非常一般吧,门票 42 一人,学生票半票。
            • 镜沙没去,长江澳全是人,感觉一点也不好,不知道日落会怎样。
            • 北部湾,那天下午快 6 点才赶到,天气不好,完全感受不到最美风景,有点失望。
            • 北线,反而是海边小路风景才好看,特别是北港村 ~ 最美环岛路这一带的。

            南线:

            • 猴研岛:门票 38 一人,赶上大中午了特别晒,没啥感觉。
            • 海坛古城:没去,听说就一个纯商业街。
            • 坛南湾:有三个沙滩:崎沙澳、田美澳、远当澳,我们只去了一个,非常非常非常普通。沙滩上的懒人沙发都是收费的。
            • 将军山:门票 20 一人,反而觉得南线最值得去的地方,风景看起来最美,但景区部分未开放,在维修?
            • 象鼻湾:去到才发现没开,那个游客大厅都长草,荒废一段时间了,我的天。

            总的来说,还算满意。

            就是少爷们骑车太快了,就像为了赶路,像镜沙其实是错过的,虽然我网上看了并不觉得好看。那「懒惰三人组」更是...差不多是换个地方玩手机的感觉,一逮到机会就看手机,服了。

            放个「暑假带娃有多辛苦」的视频链接,虽然少爷们不是娃了,但那感觉是一样的。

            跟扫兴的人出去玩,真的很扫兴。

            林奕匡

            平潭之行结束之后,少爷们回家过暑假,社畜的我继续上班...

            7 月 20 日周六,去了 #林奕匡高山低谷十周年演唱会(广州站),这是我为数不多想去看的演唱会之一,好正(粤语音),下次还去...

            • 高山低谷
            • 难得一遇
            • 有人共鸣
            • 查无此字
            • 一世同学
            • ...

            或许只得你共鸣,可歌可泣的感性...

            可惜,没有唱「献丑」,等了一晚,下次一定 @林奕匡

            The end.

            ]]>
            <![CDATA[解决 macOS 照片不同步问题]]> https://github.com/tofrankie/blog/issues/343 https://github.com/tofrankie/blog/issues/343 Sat, 20 Jul 2024 05:12:48 GMT 配图源自 Freepik

            有时,macOS 下「照片 App」不会同步最新的图片,原因不详。试过重启 A]]> 配图源自 Freepik

            有时,macOS 下「照片 App」不会同步最新的图片,原因不详。试过重启 App、电脑没用,又不想登出 iCloud 帐号。

            现提供另外一个方法:

            按下 ⌘ + ⌥ 键,然后点击打开照片 App,会出现如下修复提示,点击修复,等待完成即可。

            ]]>
            <![CDATA[试图丢掉鼠标]]> https://github.com/tofrankie/blog/issues/341 https://github.com/tofrankie/blog/issues/341 Thu, 20 Jun 2024 15:15:11 GMT 指法练习:TypingClud Vim 游戏:VIM Adventures

            基础

            三种模式

              ]]> 指法练习:TypingClud Vim 游戏:VIM Adventures

              基础

              三种模式

              • 普通模式:执行命令
              • 插入模式:编辑文本
              • 视图模式:高亮文本等
              i    # 进入插入模式
              v    # 进入视图模式
              esc  # 从其他模式返回普通模式
              

              光标移动

              基础:

              h    # 左
              j    # 下
              k    # 上
              l    # 右
              

              未完待续...

              References

              ]]>
              <![CDATA[开发一个简单的 Chrome Extension]]> https://github.com/tofrankie/blog/issues/340 https://github.com/tofrankie/blog/issues/340 Sun, 16 Jun 2024 06:32:09 GMT 配图源自 Freepik

              在此作一个记录,第一次开发 Chrome Exten]]> 配图源自 Freepik

              在此作一个记录,第一次开发 Chrome Extension 的同学也可以看看。

              背景

              我现在使用 GitHub Blogger 作为个人博客工具,在翻阅文章时,有个体验痛点:无法快速定位到某个章节。

              比如,这篇文章涉及的二级、三级标题多达 25+,篇幅也很长。

              目前 GitHub 只有仓库 Markdown 文件支持目录能力,但是 Issue 还不支持,所以开发一个 Chrome 扩展程序来解决。

              github-issue-toc

              • 支持 h1 ~ h6 标题。
              • 支持滚动高亮。
              • 支持粘性布局,滚动时始终在可视区域内。
              • 样式风格与 GitHub 契合,支持深色模式。

              开始之前

              项目没必要一步一步去搭,选择社区流行方案即可,我选 Plasmo,开发体验还不错。目录样式参考了 GithubByteMD

              可粗略看看,了解一些基础概念:

              第一篇作者总结得挺好,但里面一些东西在 Manifest V3 发生了变化,可以看看第二篇文章。

              基础概念

              清单文件是每个扩展程序必须的,它会列出扩展程序的结构和行为等信息。

              Actions 是点击扩展程序图标时发生的动作,可以是打开一个弹出式窗口、打开侧边栏面板、右键菜单等。

              内容脚本主要用于修改网页内容。

              Extension Service Worker 是运行在浏览器里的后台脚本。

              它们之间可以相互传递消息,详见消息传递

              开发

              创建项目:

              $ pnpm create plasmo --with-src --entry=contents/inline,popup,background
              

              似乎 --entry 指定入口目录有点问题,我只用到弹出式窗口、内容脚本以及 Service Worker,但生成的模板包括了 newtab,需手动删掉。

              Content Scripts

              前面提到,内容脚本是用来修改网页内容的,但它跟网页的 JavaScript 环境是隔离的。

              分类

              在 Plasmo 里,内容脚本分为两类:

              • content.ts
              • content.tsx

              .ts 表示没有 UI 界面的纯脚本,后者则是带 UI 的组件,所以它要默认导出 Component。

              此处 .tsx 是以 React 为例,其他框架则是 .vue.svelte 扩展名。

              由于 Plasmo 的 TypeScript 配置将所有文件视为模块,如果你的纯内容脚本没有任何导出,则必须要加上 export {}

              如果有多个内容脚本,则用 contents 目录,比如 contents/foo.tsxcontents/bar.tsx

              导出配置

              主要用于定义脚本作用的网页地址、执行时机等。

              // tos.tsx
              import type { PlasmoCSConfig } from 'plasmo'
              
              export const config: PlasmoCSConfig = {
                matches: ['https://github.com/*'],
                run_at: 'document_end'
              }
              

              其中配置项详见 Inject with static declarations

              如果你的脚本要修改网页的 window 对象,要指定 world 配置项为 'MAIN'

              指定插入锚点

              也就是我们的 UI 脚本要挂载到网页的哪个地方。

              // tos.tsx
              import type { PlasmoCSConfig, PlasmoGetInlineAnchor } from 'plasmo'
              
              export const config: PlasmoCSConfig = {
                matches: ['https://github.com/*'],
                run_at: 'document_end'
              }
              
              // 🆕
              export const getInlineAnchor: PlasmoGetInlineAnchor = async () => ({
                element: document.querySelector('#partial-discussion-sidebar'),
                insertPosition: 'afterend'
              })
              
              export default function Toc() {
                return <div className="toc">Toc 组件</div>
              }
              

              如果需要挂载多个,导出 getInlineAnchorList,详见 Inline Anchor

              引入样式文件

              假设引入 toc.tsx 同级目录下的 toc.css 文件。

              /* toc.css */
              .toc {
                color: #0969da;
              }
              
              // tos.tsx
              import type { PlasmoCSConfig, PlasmoGetStyle } from 'plasmo'
              import styleText from 'data-text:./toc.css'
              
              // 🆕
              export const config: PlasmoCSConfig = {
                matches: ['https://github.com/*'],
                css: ['./toc.css'],
                run_at: 'document_end'
              }
              
              export const getStyle: PlasmoGetStyle = () => {
                const style = document.createElement('style')
                style.textContent = styleText
                return style
              }
              
              export default function Toc() {
                return <div className="toc">Toc 组件</div>
              }
              

              导出一个 getStyle 方法,读取文件的内容,然后往网页插入一个 <style> 标签。

              对于 data-text:./toc.css 的写法,详见 Import Resolution

              自定义 Root Container

              默认情况下,Plasmo 会创建 Shadow DOM,再挂载到页面,这样做的好处是与外部隔离,如上图所示。

              有时,我们希望可以用原网页的样式,比如 CSS 变量等。

              这样的话,需要导出一个 getRootContainer 方法。

              // tos.tsx
              import type { PlasmoCSConfig, PlasmoGetStyle, PlasmoGetInlineAnchor } from 'plasmo'
              import styleText from 'data-text:./toc.css'
              
              export const config: PlasmoCSConfig = {
                matches: ['https://github.com/*'],
                css: ['./toc.css'],
                run_at: 'document_end'
              }
              
              export const getStyle: PlasmoGetStyle = () => {
                const style = document.createElement('style')
                style.textContent = styleText
                return style
              }
              
              // 🆕 移除掉
              // export const getInlineAnchor: PlasmoGetInlineAnchor = async () => ({
              //   element: document.querySelector('#partial-discussion-sidebar'),
              //   insertPosition: 'afterend'
              // })
              
              // 🆕
              export const getRootContainer = () => {
                return new Promise(resolve => {
                  const timer = setInterval(() => {
                    const rootContainer = document.querySelector('#plasmo-toc')
                    if (rootContainer) {
                      clearInterval(timer)
                      resolve(rootContainer)
                      return
                    }
              
                    const rootContainerParent = document.querySelector('.Layout-sidebar')
                    if (rootContainerParent) {
                      clearInterval(timer)
              
                      const rootContainer = document.createElement('div')
                      rootContainer.id = 'plasmo-toc'
                      rootContainerParent.appendChild(rootContainer)
              
                      resolve(rootContainer)
                    }
                  }, 200)
                })
              }
              
              export default function Toc() {
                return <div className="toc">Toc 组件</div>
              }
              

              这里用到 setInterval() 是为了确保挂载点的父级已加载完毕。

              以上示例,我在 .Layout-sidebar 下添加了 #plasmo-toc 元素,并将 Toc 组件挂载到上面,以实现上述 getInlineAnchorinsertPosition: 'afterend' 的效果。原因是 ReactDOM 的 createRoot() 会覆盖挂载元素的内容,它会吞掉 .Layout-sidebar 的所有内容,这不是我想要的。

              自定义 render

              前面 run_at 指定为 document_end,还有其他值:

              • document_start:在 css 中的任何文件之后、构建任何其他 DOM 或运行任何其他脚本之前注入脚本。

              • document_end:在 DOM 完成之后,在图片和框架等子资源加载之前立即注入脚本。

              • document_idle:浏览器会选择一个时间,在 document_end 之间以及 window.onload 事件触发后立即注入脚本。注入的确切时刻取决于文档的复杂程度和加载用时,并针对网页加载速度进行了优化。在 document_idle 运行的内容脚本不需要监听 window.onload 事件;它们一定会在 DOM 完成后运行。如果脚本确实需要在 window.onload 之后运行,该扩展程序可以使用 document.readyState 属性检查 onload 是否已触发。这是默认值。

              以 TOC 组件为例,当进入页面后,页面加载完毕,然后生成了目录。如果后续 Markdown 内容通过 Ajax 方式更新了,那目录有可能跟最新内容对不上了。这里有两种解决方法:

              • 一是,在 TOC 组件内监听内容变化,进而触发组件更新。
              • 二是,当内容更新时,先卸载旧的 TOC 组件,再挂载新的 TOC 组件。

              按需选择。在 GitHub Issue TOC 的场景,要用第二种方式。原因是,在 GitHub 的非 Issue 页面跳转到 Issue 页面时,应该是使用了 history.pushState() 方式,它不会重新加载页面,导致目录就不会生成了。这种情况靠 document_end 是无法解决的。

              // tos.tsx
              import type { PlasmoCSConfig, PlasmoGetStyle, PlasmoCSUIJSXContainer, PlasmoRender } from 'plasmo'
              import styleText from 'data-text:./toc.css'
              
              export const config: PlasmoCSConfig = {
                matches: ['https://github.com/*'],
                css: ['./toc.css'],
                run_at: 'document_end'
              }
              
              export const getStyle: PlasmoGetStyle = () => {
                const style = document.createElement('style')
                style.textContent = styleText
                return style
              }
              
              export const getRootContainer = () => {
                return new Promise(resolve => {
                  const timer = setInterval(() => {
                    const rootContainer = document.querySelector('#plasmo-toc')
                    if (rootContainer) {
                      clearInterval(timer)
                      resolve(rootContainer)
                      return
                    }
              
                    const rootContainerParent = document.querySelector('.Layout-sidebar')
                    if (rootContainerParent) {
                      clearInterval(timer)
              
                      const rootContainer = document.createElement('div')
                      rootContainer.id = 'plasmo-toc'
                      rootContainerParent.appendChild(rootContainer)
              
                      resolve(rootContainer)
                    }
                  }, 200)
                })
              }
              
              // 🆕
              export const render: PlasmoRender<PlasmoCSUIJSXContainer> = async ({ createRootContainer }) => {
                const url = document.location.href
                if (!isGitHubIssuePage(url)) return
              
                const rootContainer = await createRootContainer()
                const root = createRoot(rootContainer)
                window.__plasmoTocRoot = root
                root.render(<Toc />)
              }
              
              export default function Toc() {
                return <div className="toc">Toc 组件</div>
              }
              

              具体逻辑,按需调整。由于重新挂载页面时,要先将旧的 React App 卸载,所以这里记录了window.__plasmoTocRoot = root 供下次挂载用。比如:

              async function recreateRoot() {
                const rootContainer = await getRootContainer()
              
                if (window.__plasmoTocRoot) {
                  window.__plasmoTocRoot.unmount()
                }
              
                const root = createRoot(rootContainer as Element)
                window.__plasmoTocRoot = root
                root.render(<Toc />)
              
                onIssueUpdate()
              }
              

              Service Worker

              这是可选的,如果你用不到 Service Worker 可以在删掉 background.ts 文件,或者导出一个 export {},原因同 Content Scripts。

              前面提到,从 GitHub Repo 跳转到 GitHub Issue 页面的场景,无法生成目录。所以这里我要借助 Service Worker 来解决这个问题。大致思路是,通过 chrome.webNavigation 来监听网页 History 变化,当跳转到 Issue 页面时,向 Content Scripts 传递消息,告诉它该挂载 TOC 组件了。

              事件顺序:onBeforeNavigate → onCommitted → [onDOMContentLoaded] → onCompleted

              // background.ts
              import { MESSAGE_TYPE } from '@/constants'
              import { isGitHubIssuePage } from '@/utils'
              
              chrome.webNavigation.onCompleted.addListener(() => {
                chrome.webNavigation.onHistoryStateUpdated.addListener(details => {
                  const { url, tabId } = details
                  if (!isGitHubIssuePage(url)) return
                  sendMessageToContentScript(tabId, { type: MESSAGE_TYPE.PLASMO_TOC_MOUNT, payload: details })
                })
              })
              
              async function sendMessageToContentScript(tabId: number, message: any) {
                try {
                  chrome.tabs.sendMessage(tabId, message)
                } catch (error) {
                  console.error(`Failed to send message: ${error}`)
                }
              }
              

              在 Service Worker 内无法获取、操作 DOM。

              由于 Service Worker 用到 webNavigation,需要在清单中声明权限。在 Plasmo 框架是在 package.json 里指定即可,构建时会自动生成到 manifest.json 的。

              {
                "manifest": {
                  "permissions": [
                    "webNavigation"
                  ]
                }
              }
              

              同时,要在 Content Script 里接收信息:

              // toc.tsx
              import { MESSAGE_TYPE } from '@/constants'
              
              chrome.runtime.onMessage.addListener(throttle(onBackgroundMessage, 500))
              
              let plasmoTocMounting = false
              
              function onBackgroundMessage(message: { type: string; payload: any }) {
                try {
                  if (message.type !== MESSAGE_TYPE.PLASMO_TOC_MOUNT) return
              
                  if (plasmoTocMounting) return
                  plasmoTocMounting = true
              
                  const rootContainer = document.querySelector('#plasmo-toc')
                  if (rootContainer) return
              
                  recreateRoot()
                } finally {
                  plasmoTocMounting = false
                }
              }
              

              Popup

              我这个扩展,其实 Popup 窗口,其实没什么内容,就放了一个 Homepage 和 Report issue 的链接。

              // popup/index.tsx
              import './index.css'
              
              export default function Popup() {
                return (
                  <div className="popup">
                    <div className="greeting"> Enjoy it. ❤️</div>
                    <div className="links">
                      <a className="link" href="https://github.com/tofrankie/github-issue-toc" target="_blank">
                        Homepage
                      </a>
                      <span className="separator">•</span>
                      <a
                        className="link"
                        href="https://github.com/tofrankie/github-issue-toc/issues"
                        target="_blank">
                        Report issue
                      </a>
                    </div>
                  </div>
                )
              }
              

              提一下,导入样式不用像 Content Script 那样用 data-text:./index.css 来导入,也不用导出 getStyle 方法。

              调试

              使用 pnpm create plasmo 创建的项目,包含了:

              {
                "scripts": {
                  "dev": "plasmo dev",
                  "build": "plasmo build",
                  "package": "plasmo package"
                }
              }
              

              就字面意思,不多说了。

              • pnpm dev 产出 build/chrome-mv3-dev
              • pnpm build 产出 build/chrome-mv3-prod
              • pnpm package 产出 build/chrome-mv3-prod.zip

              注意,dev 和 build 的产物是两个不同的扩展,前者在扩展名称加了 DEV | 前缀。

              导入本地扩展程序

              1. 浏览器打开 chrome://extensions
              2. 开启「开发者模式」
              3. 在「加载已解压的扩展程序」选择对应的产物目录,并启用该扩展。

              调试 Content Scripts

              在开发时,源代码变更会自动更新扩展,甚至会重新加载页面。有时可能会看到以下错误信息:

              Error: Extension context invalidated.
              

              或页面上出现 Plamso 提示:

              Context Invalidated, Press to Reload
              

              原因是 Plasmo 重新加载扩展,会使得旧的扩展上下文失效,故而报错,解决方法是刷新页面。

              在 DevTool 的 Source - Content Scripts 面板,可以查看所有扩展程序的脚本。

              调试 Service Worker

              在 chrome://extensions 对应扩展里,可以看到 「检查视图 Service Worker」或「检查视图 背景页」的入口,点击进入可调试。

              调试 Popup

              开发时,可以将扩展固定在 Chrome 工具栏,以便于调试。点击扩展图标,打开 Popup 弹窗,然后像网页那样右键检查元素即可。

              提交扩展

              准备好以下东西,执行 pnpm package 打包,前往开发者信息中心上传,填好相关信息提交审核即可。

              基础信息

              使用 Plasmo 的话,package.json 部分字段会在构建时传递到 manifest.json 里,按实际情况填写就好。

              • packageJson.versionmanifest.version
              • packageJson.displayNamemanifest.name
              • packageJson.descriptionmanifest.description
              • packageJson.authormanifest.author
              • packageJson.homepagemanifest.homepage_url

              其余从 packageJson.manifest 读取。

              图标

              建议格式为 .png,不支持 .webp.svg

              提供 16×16、32×32、48×48、128×128 四种尺寸的图片,以 icon<size>.png 形式命名,置于 assets 目录内。

              请注意,Plasmo 项目的 assets 目录位于项目根目录,不是 src 目录内,即便是通过 --with-src 方式创建的模板项目。

              图标视觉设计,参考官方指南

              Chrome Web Store 图片资源

              • 商店图标:128×128 的图标
              • 屏幕截图:1280×800 或 640×400,1 ~ 5 张
              • 小型宣传图:440×280 画布(可选)
              • 顶部宣传图:1400×560 画布(可选)

              隐私相关说明

              一是,要准备扩展程序的用途说明。 二是,要准备请求权限的理由,比如我用到了 host_permissionswebNavigation 权限,都要在上传时说明清楚。

              所以,没用到的权限就不要加上去了。

              ]]> <![CDATA[如何实现一个准确的倒计时功能]]> https://github.com/tofrankie/blog/issues/339 https://github.com/tofrankie/blog/issues/339 Sun, 26 May 2024 05:08:30 GMT 配图源自 Freepik

              前言

              倒计时、计时器是一个很常见的业务场景。要求很简单,但做]]> 配图源自 Freepik

              前言

              倒计时、计时器是一个很常见的业务场景。要求很简单,但做起来也不太简单:

              • 准确性高
              • 性能好

              如果你将要实现的计时要体现在 DOM 上,它永远不可能百分百准确

              JavaScript 是单线程的(指主线程),注定了无法一边执行 JS 代码、一边更新 DOM。即便是 HTML5 提出的 Web Worker,它是可以在主动创建一些后台执行的线程,可它不能直接操作 DOM,它传递信息给主线程,也会受到 Event Loop 的影响,该排队还是得排队。

              但就人眼来说,几毫秒、几十毫米的误差基本是无感的,这就可以算是一个准确、合格的倒计时。

              通常要考虑的问题有:

              • setTimeout、setInterval 不准
              • requestAnimationFrame 执行太频繁
              • Date 受本机系统时钟影响

              setTimeout 和 setInterval

              我认为还是要聊一聊 setTimeout 和 setInterval。

              看个例子:

              setTimeout(() => {
                console.log('Hi~')
              }, 1000)
              

              众所周知的原因,它至少 1s 之后才能打印 Hi~

              setTimeout(fn, delay)delay 是最小开始执行时间,而且只会多不会少。

              再看:

              setInterval(() => {
                console.log('Hi~')
              }, 1000)
              

              它跟 setTimeout 一样受 Event Loop 影响,自然不可能完美地每秒打印一次 Hi~

              用 setTimeout 模拟:

              setTimeout(function tick() {
                console.log('Hi~')
                setTimeout(tick, 1000)
              }, 1000)
              

              🙋 提问:它跟 setInterval 版本功能上等效的吗?

              答案是不一样的,setInterval 会产生一种“漂移”(drift)现象。

              在 Google 上搜索「setInterval drift」关键词,可以看到很多相关的讨论帖子,比如:

              怎么理解漂移呢?

              <!DOCTYPE html>
              <html lang="en">
                <body>
                  <div id="time"></div>
                  <script>
                    window.onload = function () {
                      const element = document.getElementById('time')
                      const startTime = performance.now()
                      let count = 0
              
                      setInterval(() => {
                        count++
                        const currentTime = performance.now()
                        const time = (currentTime - startTime) / 1000
                        const rate = count / time
                        element.innerHTML = `${count} call in ${time.toFixed(3)}s, or ${rate.toFixed(6)} calls per second.`
                      }, 1000)
                    }
                  </script>
                </body>
              </html>
              

              CodePen Demo

              它在 Chrome 126 表现很好,几乎是每一秒更新一次。

              34 call in 34.001s, or 0.999965 calls per second.
              

              在 Firefox 127 上,当执行了大概 300 次之后,约漂移了 1s 左右。Safari 漂移也较为明显。

              317 call in 318.242s, or 0.996097 calls per second.
              

              据查,Chrome 有做“自动修正”的处理(源码),即便是执行了 300 多次,甚至更多时,其漂移也很低,几乎可以忽略。尽管这种修正并不是规范所要求,但应该是开发者想要的结果。

              除此之外,当页面挂起后台,为了省电和减少 CPU 占用,不同浏览器会采用一些策略,暂停或延长定时器的 Delay Time。

              挂起后台的情况包含但不限于:有其他处于活跃状态的标签、窗口最小化、网页内容完全不可见、屏幕锁定、移动设备回到桌面等。

              小结:

              • setTimeout(fn, delay) 的 delay 是最小开始执行时间,而且只会多不会少。
              • setInterval(fn, delay) 的 delay 会左右“漂移”,累计执行次数越多,漂移越明显。在 Chrome 浏览器下有“修正”处理,每次实际执行的 delay 与传入的值很接近,可以当作没有误差。
              • 在后台时,setTimeout 和 setInterval 回调会降低执行频率,甚至暂停,返回前台再恢复。

              关于 Event Loop 推荐两个不错的视频:

              不靠谱版本

              尽管 setTimeout 和 setInterval 很多问题,但还是要用到它,我们要做的是尽可能减少误差。

              假设有示例如下:

              <div id="countdown">0 days, 0 hours, 0 minutes, 0 seconds</div>
              
              window.onload = function () {
                // 倒计时时长(秒)
                const seconds = 90
                countdown(seconds)
              }
              
              function countdown(seconds) {
                // TODO: 待实现...
              }
              
              // 倒计时展示形式
              function renderCounter(timeLeft) {
                const secondInMillisecond = 1000
                const minuteInMillisecond = secondInMillisecond * 60
                const hourInMillisecond = minuteInMillisecond * 60
                const dayInMillisecond = hourInMillisecond * 24
              
                const dayLeft = Math.floor(timeLeft / dayInMillisecond)
                const hourLeft = Math.floor((timeLeft % dayInMillisecond) / hourInMillisecond)
                const minuteLeft = Math.floor((timeLeft % hourInMillisecond) / minuteInMillisecond)
                const secondLeft = Math.floor((timeLeft % minuteInMillisecond) / secondInMillisecond)
              
                const html = `${dayLeft} days, ${hourLeft} hours, ${minuteLeft} minutes, ${secondLeft} seconds`
                document.getElementById('countdown').innerHTML = html
              }
              

              其中 countdown() 方法接收一个剩余的秒数 seconds

              我不关心是用本地时间,还是服务器时间算出来的,你只需告诉我剩余多少秒就行。

              简陋版本:

              function countdown(seconds) {
                const startTime = Date.now()
                const endTime = startTime + seconds * 1000
              
                let timeLeft = endTime - startTime
              
                const timer = setInterval(() => {
                  timeLeft -= 1000
              
                  if (timeLeft <= 0) {
                    clearInterval(timer)
                    renderCounter(0)
                    return
                  }
              
                  renderCounter(timeLeft)
                }, 1000)
              
                renderCounter(timeLeft)
                // 🙋
              }
              

              假设在 🙋 处有一个耗时的同步任务,比如:

              function longRunningTask() {
                for (let i = 0; i < 1000000000; i++) {
                  // do something...
                }
              }
              

              实际中,耗时任务不应放在主线程中执行,这里只用于表达上述例子的缺点。

              那么 setInterval 第一次回调的执行就可能发生在 N 秒之后,这样页面上的倒计时就更不准了。会出现过了 5s 之后,倒计时可能只减去 1s 的情况,显然这不是我们想要的。

              即便没有耗时任务,如果被挂起后台,执行频率会变低,甚至暂停,重新回到前台剩余时间就不准了。

              因此,timeLeft(剩余时间)要在 setInterval 回调函数内重新计算,修改如下:

              function countdown(seconds) {
                const startTime = Date.now()
                const endTime = startTime + seconds * 1000
                
                const timer = setInterval(() => {
                  const now = Date.now()
                  const timeLeft = endTime - now
              
                  if (timeLeft <= 0) {
                    clearInterval(timer)
                    renderCounter(0)
                    return
                  }
              
                  renderCounter(timeLeft)
                }, 1000)
                
                renderCounter(endTime - startTime)
              }
              

              这样,至少可以确保下一次更新的时候,剩余的时间是“准确”的。

              假设在相对理想的环境中,页面上只剩下这个倒计时了,也没有阻塞主线程的(同步)任务,它几乎可以每秒执行一次 renderCounter,最起码人眼感知不到其中的误差。

              但现实是,在不同浏览下,随着 setInterval 不停地执行,其 Delay Time 会产生偏差。比如 Safari 和 Firefox 可能会增加几毫秒,而 Chrome 甚至会“自动修复”这种时间偏差(这应该是开发者所期待的),也就是说 Delay Time 甚至会减少。

              所以,页面看到的效果有可能是:

              0 days, 0 hours, 1 minutes, 30 seconds
              ↓
              0 days, 0 hours, 1 minutes, 28 seconds
              ↓
              ...
              

              原因是:假设刚好在剩余 1m 30s 的时候 renderCounter(),由于 Delay Time 的偏差(假设多了 10ms),导致下一次执行时得到 1m 28s < timeLeft < 1m 29s 的结果,导致页面跳过 29s 显示了 28s(前面使用了 Math.floor() 来换算)的问题。

              如果页面有其他耗时任务或者挂起后台时,这种偏差只会更明显。

              综上,这个方案缺点如下:

              1. 挂起后台时,setInterval() 仍在执行,占用 CPU 资源。
              2. 可能会出现跳秒的情况,也就是说,倒计时不是一直 -1,偶尔会 -2
              3. Date.now() 受系统时钟影响。

              改进版本

              当页面挂起时,如果不想让定时器一直在后台执行,可以借助 visibilitychange 事件来处理。

              • 页面不可见时,清除定时器
              • 页面可见时,创建新的定时器。
              function countdown(seconds) {
                const startTime = Date.now()
                const endTime = startTime + seconds * 1000
              
                const paint = () => {
                  const now = Date.now()
                  const timeLeft = endTime - now
              
                  if (timeLeft <= 0) {
                    clearInterval(timer)
                    renderCounter(0)
                    return
                  }
              
                  renderCounter(timeLeft)
                }
              
                let timer = setInterval(paint, 1000)
                
                handleVisibilityChange({
                  hiddenFn: () => {
                    clearInterval(timer)
                  },
                  visibleFn: () => {
                    if (timer) clearInterval(timer)
                    timer = setInterval(paint, 1000)
                  },
                })
                
                renderCounter(endTime - startTime)
              }
              
              function handleVisibilityChange({ hiddenFn = () => {}, visibleFn = () => {} }) {
                document.addEventListener('visibilitychange', event => {
                  if (document.visibilityState === 'hidden') {
                    hiddenFn(event)
                    return
                  }
                  visibleFn(event)
                })
              }
              

              该方案的缺点:

              1. 处理后台执行的方式过于复杂。
              2. 未解决跳秒问题。
              3. 未解决 Date.now() 受系统时钟影响的问题。

              进阶版本

              可以考虑 requestAnimationFrame,它会在页面重绘之前执行指定的回调函数。

              出于省电和性能考虑,当页面挂起时,该 API 会暂停执行。

              它执行频率跟屏幕刷新率有关。比如屏幕刷新率为 60Hz,表示每秒刷新 60 次,即每 16.67ms 刷新一次以确保画面不卡顿。其他常见的 90Hz、120Hz、144Hz 的刷新率同理。

              比如:

              function countdown(seconds) {
                const startTime = Date.now()
                const endTime = startTime + seconds * 1000
                renderCounter(endTime - startTime)
              
                let rafId = requestAnimationFrame(function paint() {
                  const now = Date.now()
                  const timeLeft = endTime - now
              
                  if (timeLeft <= 0) {
                    renderCounter(0)
                    cancelAnimationFrame(rafId)
                    return
                  }
              
                  renderCounter(timeLeft)
                  rafId = requestAnimationFrame(paint)
                })
              }
              

              在刷新率为 60Hz 的显示器下,每秒执行 60 次,倒计时是足够准确了。但执行太频繁了,也不是我们想要的,还不如 setInterval(() => {}, 333) 呢。

              可以结合 setTimeout 解决频繁执行的问题,然后要解决的是:如何获取下一次更新的时间?

              引入一个 Document Timeline,此时间轴对于每个文档(document)来说都是唯一的,并在文档的生命周期中持续存在。其时间原点(Time Origin)可通过 performance.timeOrigin 获取。

              要获取当前文档自创建以来(即相对于时间原点)所经过的时间,有两种方式:

              它们都返回一个相对高精度的毫秒数,但又有点区别。

              举个例子:以 60Hz 的屏幕为例,页面每 16.67ms 更新一次。假设第三次更新完(当前时间记为 50ms),接着马上执行下一次 Tick,若时间过了 5ms,此时 document.timeline.currentTimeperformance.now() 分别为 50ms、55ms。等这次 Tick 执行完那一刻它俩的值又将同步,以此类推。

              简单来说,document.timeline.currentTime 是当前帧起始那一刻相对于时间原点经过的毫秒数。而 performance.now() 是“真正”当前时间相当于时间原点经过的毫秒数。所以,实际表现后者总是比前者大一点。

              接着,我们尝试修改下:

              function countdown(seconds) {
                const startTime = document.timeline ? document.timeline.currentTime : performance.now()
                const endTime = startTime + seconds * 1000
                
                const paint = () => {
                  const now = document.timeline ? document.timeline.currentTime : performance.now()
                  const timeLeft = endTime - now
              
                  if (timeLeft <= 0) {
                    renderCounter(0)
                    return
                  }
              
                  const roundedTimeLeft = Math.round(timeLeft / 1000) * 1000
                  renderCounter(roundedTimeLeft)
              
                  const nextTime = startTime + (seconds * 1000 - roundedTimeLeft) + 1000
                  const nextDelay = nextTime - performance.now()
              
                  setTimeout(() => requestAnimationFrame(paint), nextDelay)
                }
              
                paint()
              }
              

              考虑 Document APIHigh Resolution Time API 兼容性。

              以下这行处理,目的是避免跳秒现象。举个例子,假设当前 timeLeft 为 2988ms,由于 renderCounter() 里秒数转换是使用了 Math.floor(),它会被转为 2s,但实际上它更接近 3s,因此应该用 Math.round() 作取整操作。

              注意,这里是秒数取整,而不是毫秒数取整。

              const roundedTimeLeft = Math.round(timeLeft / 1000) * 1000
              

              renderCounter() 之前处理,也便于准确计算出下一秒的时间轴时间。

              const nextTime = startTime + (seconds * 1000 - roundedTimeLeft) + 1000
              

              最后,通过下一秒的时间点减去当前时间点,得出延迟时间。

              const nextDelay = nextTime - performance.now()
              

              这种方案的优点:

              • 时间更准确,可以解决用户修改系统时钟导致计时可能不准确的问题。
                • 使用 Date 会受到时钟偏差和系统时钟调整的影响。
                • 使用 High Resolution Time 不受系统时钟影响。
              • 当浏览器挂起时自动暂停,恢复前台时继续,且准确性不受影响。

              由于这种方案还用到了 setTimeout(),跳秒问题还存在。假设主线程存在耗时任务,没办法及时执行其回调函数,因此可能会出现类似 4s 直接跳到 6s、7s 的情况。

              有些文章使用 Web Worker 来实现倒计时,因为它是独立于主线程,可以一直在后台线程进行计时,这样计时倒是准确。如果计时要体现在页面上,得每隔 1s 通知主线程更新 UI(Worker 无法直接操作 DOM)。但是,如果主线程被耗时任务占着,即便主线程接到通知了,但你还是要排队等主线程空闲下来。

              因此,根本的解决办法应该是将耗时任务放在 Worker 执行,或者使用时间分片(Time Slicing)方案将耗时任务分成若干小任务,以让出空隙给主线程更新 UI,避免造成页面假死现象。

              微信小程序版本

              小程序框架的逻辑层并非运行在浏览器中,因此 JavaScript 在 web 中一些能力都无法使用,如 window,document 等。

              在小程序里,它们都不能用:

              • document.timeline.currentTime
              • window.performance.now()
              • window.requestAnimationFrame()

              小程序有个 wx.getPerformance().now() 方法(文档未提到),它返回的是自 1970 年 1 月 1 日 0 点开始以来的毫秒数,调试发现其内部返回的就是 Date.now(),所以这玩意在这里压根没用。🙄

              • window.performance.now() 返回自 performance.timeOrigin 开始以来的毫秒数,不受系统时钟影响。
              • wx.getPerformance().now() 返回自 1970 年 1 月 1 日 0 点开始以来的毫秒数,受系统时钟影响。

              既然小程序里面获取不到不受系统时钟影响的当前时间,唯有使用 Date.now() 了,并在 onShow() 时重新校验。

              示例如下(小程序代码片段):

              import { getServerTime } from '../../utils/index'
              
              // 截止时间:2024/07/28 23:59:59
              const DEADLINE_TIME = new Date(2024, 6, 28, 23, 59, 59).getTime()
              
              Page({
                data: {
                  formattedCountdown: '',
                },
              
                async onShow() {
                  // TIPS: 小程序需配置请求域名,获取服务器时间根据实际调整,比如发起 HEAD 请求获取 header.date 等方式。
                  const now = await getServerTime()
                  const secondsLeft = Math.floor((DEADLINE_TIME - now) / 1000)
                  
                  this.countdown(secondsLeft)
                },
              
                onUnload() {
                  clearTimeout(this._countdown_timer)
                },
              
                countdown(seconds) {
                  const startTime = Date.now()
                  const endTime = startTime + seconds * 1000
              
                  // 避免 onShow 后有多个定时器在跑
                  clearTimeout(this._countdown_timer)
              
                  const paint = () => {
                    const now = Date.now()
                    const timeLeft = endTime - now
              
                    if (timeLeft <= 0) {
                      this.renderCounter(0)
                      return
                    }
              
                    const roundedTimeLeft = Math.round(timeLeft / 1000) * 1000
                    this.renderCounter(roundedTimeLeft)
              
                    const nextTime = startTime + (seconds * 1000 - roundedTimeLeft) + 1000
                    const nextDelay = nextTime - Date.now()
              
                    this._countdown_timer = setTimeout(() => paint(), nextDelay)
                  }
              
                  paint()
                },
              
                renderCounter(timeLeft) {
                  const secondInMillisecond = 1000
                  const minuteInMillisecond = secondInMillisecond * 60
                  const hourInMillisecond = minuteInMillisecond * 60
                  const dayInMillisecond = hourInMillisecond * 24
              
                  const dayLeft = Math.floor(timeLeft / dayInMillisecond)
                  const hourLeft = Math.floor((timeLeft % dayInMillisecond) / hourInMillisecond)
                  const minuteLeft = Math.floor((timeLeft % hourInMillisecond) / minuteInMillisecond)
                  const secondLeft = Math.floor((timeLeft % minuteInMillisecond) / secondInMillisecond)
              
                  const formattedStr = `${dayLeft} days, ${hourLeft} hours, ${minuteLeft} minutes, ${secondLeft} seconds`
              
                  this.setData({ formattedCountdown: formattedStr })
                },
              })
              

              参考链接

              ]]>
              <![CDATA[Node.js 环境变量]]> https://github.com/tofrankie/blog/issues/338 https://github.com/tofrankie/blog/issues/338 Wed, 01 May 2024 18:00:16 GMT 配图源自 Freepik

              前言

              在 Node.js 中通常会使用 proc]]> 配图源自 Freepik

              前言

              在 Node.js 中通常会使用 process.env 来获取环境变量。

              process.env

              它返回一个包含用户环境的对象。这里的用户环境是 Shell 进程,这个对象包含了当前进程的变量。注意,process.env 对象可以被修改,但其修改不会影响到此进程之外。

              Shell 变量

              分类:

              • 环境变量:通常是指 Shell 内置变量或者 Shell 配置文件中声明的变量。
              • 自定义变量:通常是在 Shell 脚本或命令行中声明的变量。

              作用域:

              • 环境变量:创建 Shell 进程时会自动加载,这些变量可以被当前进程以及子进程访问。
              • 自定义变量:
                • 函数体内:在函数体内使用 local 显式声明的变量,其作用域仅在函数内。
                • 当前进程:当前 Shell 进程内可访问,但子进程不能访问。默认作用域。
                • 当前进程及其子进程:使用 export 显式声明的变量,其作用域是当前进程及子进程。

              Shell 环境是天然隔离的,在当前进程内设置或修改变量,都不会影响到其他非关联进程的环境变量。

              function fn() {
                foo=1         # 作用域为当前进程
                local bar=2   # 作用域为当前函数
                export baz=3  # 作用域为当前进程及子进程
              }
              
              fn
              
              echo $foo    # 1
              echo $bar    # 空字符串
              echo $baz    # 3
              

              在 Shell 中,如果引用的变量不存在,它不会报错,而是输出空字符。

              NPM 环境变量

              假设有以下包:

              {
                "name": "node-env",
                "version": "1.0.0",
                "scripts": {
                  "start": "node -e 'console.log(process.env)'"
                }
              }
              

              执行 npm run start 时,会得到这些变量:

              {
                // Shell 内置变量
                SHELL: '/bin/zsh',
                USER: 'frankie',
                HOME: '/Users/frankie',
                // ...
                
                // zsh 自定义环境变量
                NVM_DIR: '/Users/frankie/.nvm',
                // ...
                
                // npm config 相关变量
                npm_config_sass_binary_site: 'https://npmmirror.com/mirrors/node-sass',
                npm_config_prefix: '/Users/frankie/.nvm/versions/node/v18.16.0',
                // ...
                
                // npm package 相关变量
                npm_package_json: '/Users/frankie/Web/Git/html-demo/src/demo/node-env/package.json',
                npm_package_name: 'node-env',
                npm_package_version: '1.0.0',
                // ...
              }
              

              process.env 的值都是字符串。如果赋值时不是字符串,会被隐式转换为字符串。

              可以看到两类与 npm 相关的环境变量,在执行 npm run 命令时自动载入。

              • npm_config_
              • npm_package_

              其中 npm_config_ 开头的环境变量源自 .npmrc 配置文件,优先级从上到下:

              • 项目级别 .npmrc
              • 用户级别 $HOME/.npmrc
              • 全局级别 $PREFIX/etc/npmrc(其中 $PREFIXnpm config get prefix 的路径)
              • npm 内置配置文件 /path/to/npm/npmrc

              其中 key 大小写不敏感,它们都会被转换为小写形式,- 也会被转为 _

              其中 npm_package_ 则源自 package.json。比如使用 process.env.npm_package_version 获取包版本号。

              NPM Script 自定义环境变量

              以上是 npm run 内部执行逻辑带入的环境变量,也可以自定义。

              比如:

              {
                "name": "node-env",
                "version": "1.0.0",
                "scripts": {
                  "start": "NODE_ENV=development node -e 'console.log(process.env.NODE_ENV)'"
                }
              }
              

              这样就能在 Node 脚本里获取到这个 NODE_ENV 变量值了。

              在命令前加上变量声明,它会传递给子进程。类似 export 的效果,但不完全相同,这种方式不会影响当前进程的同名变量。Simple Command Expansion

              但它仅支持 Unix-like 操作系统,到 Windows 就不行了。后者需要使用 set 命令:

              {
                "name": "node-env",
                "version": "1.0.0",
                "scripts": {
                  "start": "set NODE_ENV=development node -e 'console.log(process.env.NODE_ENV)'"
                }
              }
              

              注意,Windows 操作系统的环境变量不区分大小写。

              后来,出现了一些跨平台方案,比如 cross-env。用法变成了这样:

              $ npm i cross-env -D
              
              {
                "name": "node-env",
                "version": "1.0.0",
                "scripts": {
                  "start": "cross-env NODE_ENV=development node -e 'console.log(process.env.NODE_ENV)'"
                }
              }
              

              cross-env is "finished" (now in maintenance mode)

              如果项目的环境变量很多,script 就会很长很长,不好看也不好维护,后来又使用 dotenv 方案。

              比如,项目根目录有 .env.env.development 文件:

              由于 .env 文件可能会包含像密钥这类敏感信息,它不在版本控制范围内,应该添加到 .gitignore 里。如果是多人协作的项目,可以考虑添加类似 .env.example 模板到仓库里,以便其他成员清楚了解用到哪些环境变量。

              # .env
              API_URL=https://example.com/api/
              
              # .env.development
              API_URL=https://dev.example.com/api/
              
              {
                "name": "node-env",
                "version": "1.0.0",
                "scripts": {
                  "start": "cross-env NODE_ENV=development node -e 'console.log(process.env.API_URL)'",
                  "build": "cross-env NODE_ENV=production node -e 'console.log(process.env.API_URL)'"
                }
              }
              

              这样本地开发和打包的时候,就能根据 NODE_ENV 的值从对应的 .env 文件中读取配置。

              当然,以上环境变量仅可在编译时有效。要在业务代码中使用,还得借助类似 webpack.DefinePluginwebpack.EnvironmentPlugin 等插件处理,它们将会在编译时被替换为相应的字符串。

              $ npm i dotenv
              
              const webpack = require('webpack')
              require('dotenv').config()
              
              module.exports = {
                plugins: [
                  new webpack.DefinePlugin({
                    'process.env.API_URL': JSON.stringify(process.env.API_URL),
                  }),
                ],
              }
              

              如果已有同名环境变量,dotenv 解析时将会忽略它。比如开发环境中先后加载 .env.development.env,其中解析前者时已设置 API_URL 变量,当解析到后者时就会忽略 API_URL

              以上仅为示例,如果你是使用 webpack 的话,可以用 dotenv-webpack

              Node.js 20.6.0 原生支持 .env 文件,处于实验性阶段,当前还有很多功能上的缺失,不能完全替代 dotenv。更多请看 Node.js 20.6.0 includes built-in support for .env files

              注意,很多构建工具只有「特定前缀开头」以及像 NODE_ENV 这种很通用的环境变量才能在运行时(即业务代码)可用。

              • vite:VITE_
              • vue-cli:VUE_APP_
              • create-react-app:REACT_APP_
              • Taro:TARO_APP_
              • ...

              任何不能对外公开的信息,都不要嵌入构建当中,因为它们都可以在构建产物中查到。

              其他

              除此之外,还有其他一些方式可以提供。

              webpack

              可以通过 webpack-cli 的 --env 参数传递。比如:

              $ npm i webpack webpack-cli -D
              
              {
                "name": "node-env",
                "version": "1.0.0",
                "scripts": {
                  "start": "webpack -w --env test",
                  "build": "webpack --env prod",
                  "build:pre": "webpack --env pre",
                }
              }
              

              执行 npm run build:pre 时,可以这样获取到值:

              // webpack.config.js
              module.exports = function (env, argv) {
                console.log(env.pre) // true
                // 可以结合 webpack.DefinePlugin 使用
                // ...
              }
              

              若使用 --env,webpack 配置需导出为函数。

              更多请看 Environment Options

              还想多说一下。

              以 webpack 为例,其模式有 developmentproductionnone 三种。当「显式」声明 mode 为前两者时,它会自动设置 process.env.NODE_ENV 为对应值(更多)。从这个角度看,process.env.NODE_ENV 通常用来区分开发模式、打包模式。比如,开发模式下启用 sourcemap、HMR 等以便于开发调试。打包模式下启用 minimizer、splitChunks 等以减少产物体积。

              但好像有些同学会将 process.env.NODE_ENV 用于区分「项目」的测试、生成环境,其实“不对”的。假设项目有测试环境、预生产环境和生产环境呢,那它就不够用了。而且,即使是部署到非正式环境,在打包时也应该使用 production 模式。

              可以像上面那样不同项目环境传入不同的 --env 参数,然后结合 webpack.DefinePlugin 来定义特定变量,比如:

              const webpack = require('webpack')
              
              module.exports = function (env, argv) {
                return {
                  // ...
                  plugins: [
                    new webpack.DefinePlugin({
                      'process.env.TEST': env.test,
                      'process.env.PRE': env.pre,
                      'process.env.PROD': env.prod,
                    }),
                  ],
                }
              }
              
              // 业务
              export const IS_TEST = process.env.TEST
              export const IS_PRE = process.env.PRE
              export const IS_PROD = process.env.PROD
              
              export const API_URL = IS_TEST
                ? 'http://test.example.com/api/'
                : IS_PRE
                ? 'http://pre.example.com/api/'
                : 'http://example.com/api/'
              

              说那么多,是为了不要混淆 --env--modeprocess.env.NODE_ENV 的关系。process.env.NODE_ENV 在各大构建工具频繁出现,算是一个约定俗成的变量了,它与项目环境是不同的概念。

              未完待续...

              ]]>
              <![CDATA[不透明度与十六进制值]]> https://github.com/tofrankie/blog/issues/337 https://github.com/tofrankie/blog/issues/337 Wed, 01 May 2024 05:22:15 GMT 配图源自 Freepik

              转换方法:

              转换方法:

              function opacity2hex(opacity) {
                const hexValue = Math.round((opacity / 100) * 255)
                const hexString = hexValue.toString(16).padStart(2, '0')
                return hexString
              }
              
              
              function hex2opacity(hex) {
                const decimalValue = parseInt(hex, 16)
                const opacity = Math.round((decimalValue / 255) * 100)
                return opacity
              }
              

              0% ~ 100%

              百分比(不透明度) 十六进制值
              0% 00
              10% 1A
              20% 33
              30% 4D
              40% 66
              50% 80
              60% 99
              70% B3
              80% CC
              90% E6
              100% FF

              0% ~ 10%

              展开
              百分比(不透明度) 十六进制值
              0% 00
              1% 03
              2% 05
              3% 08
              4% 0A
              5% 0D
              6% 0F
              7% 12
              8% 14
              9% 17
              10% 1A

              11% ~ 20%

              展开
              百分比(不透明度) 十六进制值
              11% 1C
              12% 1F
              13% 21
              14% 24
              15% 26
              16% 29
              17% 2B
              18% 2E
              19% 30
              20% 33

              21% ~ 30%

              展开
              百分比(不透明度) 十六进制值
              21% 36
              22% 38
              23% 3B
              24% 3D
              25% 40
              26% 42
              27% 45
              28% 47
              29% 4A
              30% 4D

              31% ~ 40%

              展开
              百分比(不透明度) 十六进制值
              31% 4F
              32% 52
              33% 54
              34% 57
              35% 59
              36% 5C
              37% 5E
              38% 61
              39% 63
              40% 66

              41% ~ 50%

              展开
              百分比(不透明度) 十六进制值
              41% 69
              42% 6B
              43% 6E
              44% 70
              45% 73
              46% 75
              47% 78
              48% 7A
              49% 7D
              50% 80

              51% ~ 60%

              展开
              百分比(不透明度) 十六进制值
              51% 82
              52% 85
              53% 87
              54% 8A
              55% 8C
              56% 8F
              57% 91
              58% 94
              59% 96
              60% 99

              61% ~ 70%

              展开
              百分比(不透明度) 十六进制值
              61% 9C
              62% 9E
              63% A1
              64% A3
              65% A6
              66% A8
              67% AB
              68% AD
              69% B0
              70% B3

              71% ~ 80%

              展开
              百分比(不透明度) 十六进制值
              71% B5
              72% B8
              73% BA
              74% BD
              75% BF
              76% C2
              77% C4
              78% C7
              79% C9
              80% CC

              81% ~ 90%

              展开
              百分比(不透明度) 十六进制值
              81% CF
              82% D1
              83% D4
              84% D6
              85% D9
              86% DB
              87% DE
              88% E0
              89% E3
              90% E6

              91% ~ 100%

              展开
              百分比(不透明度) 十六进制值
              91% E8
              92% EB
              93% ED
              94% F0
              95% F2
              96% F5
              97% F7
              98% FA
              99% FC
              100% FF
              ]]> <![CDATA[下赛季见]]> https://github.com/tofrankie/blog/issues/336 https://github.com/tofrankie/blog/issues/336 Sat, 20 Apr 2024 07:22:21 GMT 北京时间 4 月 18 日 2023-2024 赛季欧冠 1/4 决赛次回合,曼城坐镇伊蒂哈德主场迎战皇马。

              本场比赛德布劳内,沃克悉数回归,而且本场发挥出色,全场数据表现占优,但很遗憾没能在 90 分钟内终结比赛,而且在点球大战中 3:4 不敌皇马,两回合总比分 7:8 遭淘汰。

              其]]> 北京时间 4 月 18 日 2023-2024 赛季欧冠 1/4 决赛次回合,曼城坐镇伊蒂哈德主场迎战皇马。

              本场比赛德布劳内,沃克悉数回归,而且本场发挥出色,全场数据表现占优,但很遗憾没能在 90 分钟内终结比赛,而且在点球大战中 3:4 不敌皇马,两回合总比分 7:8 遭淘汰。

              其实这场比赛真的真的真的踢得很好了,但在点球大战中折戟,真的很无奈。

              这两天已经有意少刷比赛视频回放了,后劲太大了,比 2021-2022 赛季还意难平,现在还没缓过来...

              ]]>
              <![CDATA[如何查看自己在 Github 中提及或回复过的 Issue 记录?]]> https://github.com/tofrankie/blog/issues/335 https://github.com/tofrankie/blog/issues/335 Sun, 14 Apr 2024 14:12:24 GMT 配图源自 Freepik

              我们知道,在 Github 主页左侧有个 Recent Activity 的栏]]> 配图源自 Freepik

              我们知道,在 Github 主页左侧有个 Recent Activity 的栏目,但它最多显示过去两周内 4 次最近的更新,但要求状态是 open 的。

              有时候,我们需要查找自己创建的、评论过的 Issue 或 PR 记录,然而它可能已经 closed 了。

              https://github.com/issues

              个人主页的 Issues 入口默认的限定条件是:is:open is:issue archived:false author:xxx

              翻查 Github 搜索文档可知:

              按议题或拉取请求中涉及的用户搜索

              可根据 involves 限定符查找以某种方式涉及特定用户的问题,该限定符是 authorassigneementionscommenter 的逻辑或关系。限定条件为 involves@meinvolves:<your-username>(你的 Github 用户名)。

              也可组合更多的限定条件达到更精细的查询,比如

              • is:issue involves:tofrankie alfred:与我相关的、标题或正文包含 alfred 的 Issues。
              • is:issue in:title involves:tofrankie alfred:与我相关的、标题包含 alfred 的 Issues。

              为了方便,我会用 Alfred 定义一个 Web Search 指令。使用时只要「⌥ + Space」唤起 Alfred 并输入关键词就能快速搜索。比如:

              https://github.com/issues?q=is%3Aissue+involves%3AtoFrankie {query}

              References

              ]]>
              <![CDATA[Proxy 与 Reflect]]> https://github.com/tofrankie/blog/issues/334 https://github.com/tofrankie/blog/issues/334 Fri, 05 Apr 2024 11:07:10 GMT 元编程

          元编程(meta-programming)一般分为两类,一是在编译时生成代码,二是在运行时修改代码行为。

          Just like metadata is data about data, metaprogramming is writing progr]]> 元编程

          元编程(meta-programming)一般分为两类,一是在编译时生成代码,二是在运行时修改代码行为。

          Just like metadata is data about data, metaprogramming is writing programs that manipulate programs. It's a common perception that metaprograms are the programs that generate other programs. But the paradigm is even broader. All of the programs designed to read, analyze, transform, or modify themselves are examples of metaprogramming. Metaprogramming in Python

          怎么理解元编程?

          Proxy

          在 JavaScript 中,Proxy 属于元编程的一种。

          简介

          如果你问我多大,通过 person.age 访问得到 20

          const person = {
           name: 'Frankie',
           age: 20,
          }
          

          但这届年轻人,总是说「别问,问就是 18」,那么我会创建一个替身:

          const substitute = new Proxy(person, {
            get(target, property) {
              if (property === 'age') {
                return 18
              }
              return target[property]
            },
          })
          

          这样,再问我年龄时,你问的其实是 substitute,此时 substitute.age 是 18。尽管我真实年龄 person.age 是 20。

          Proxy 通常用于修改某些操作的默认行为。比如,别人访问我的年龄,讲道理应该返回真实年龄(默认行为),但由于某些原因(心情不爽),就告诉你我 18。

          Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

          基础语法

          const proxy = new Proxy(target, handler)
          
          • target - 被代理的对象(下文称为源对象)。可以是任意类型的对象,比如数组、函数、另一个代理对象等。
          • handler - 一个含有特定方法的对象。

          其中 handler 有以下方法:

          • handler.get()
          • handler.set()
          • handler.has()
          • handler.apply()
          • handler.construct()
          • handler.defineProperty()
          • handler.deleteProperty()
          • handler.getOwnPropertyDescriptor()
          • handler.getPrototypeOf()
          • handler.setPrototypeOf()
          • handler.ownKeys()
          • handler.isExtensible()
          • handler.preventExtensions()

          所有方法都是可选的。如果某个方法未定义,将会保留源对象的默认行为。

          一个无操作转发代理:

          const person = {}
          const proxy = new Proxy(person, {})
          
          proxy.name = 'Frankie'
          
          console.log(person.name) // 'Frankie'
          

          get/set 方法

          用于拦截对象的读取、赋值。

          const person = {
            name: 'Frankie',
            age: 20,
          }
          
          const handler = {
            get(target, property, receiver) {
              console.log(`Getting ${property}`)
              return Reflect.get(target, property, receiver)
            },
            
            set(target, property, value, receiver) {
              console.log(`Setting ${property}`)
              return Reflect.set(target, property, value, receiver)
            },
          }
          
          const proxy = new Proxy(person, handler)
          

          当读取 proxy.name 或赋值 proxy.name = 'foo' 就会对应触发 getset 方法。

          参数:

          • target - 源对象。
          • property - 被读取/赋值的属性名。
          • value - 将被赋值的值(仅 set 方法有)。
          • receiver - 最初接收赋值的对象。通常是代理实例本身。

          返回值:

          • get() 方法可返回任意值。
          • set() 方法返回布尔值,true 表示属性设置成功。

          约束:

          receiver 不是代理实例本身的反例:

          const empty = {}
          
          const proxy = new Proxy(
            {},
            {
              get(target, property, receiver) {
                console.log(receiver === proxy) // ?
                console.log(receiver === empty) // ?
                return Reflect.get(target, property, receiver)
              },
            }
          )
          
          Object.setPrototypeOf(empty, proxy)
          
          empty.foo
          

          当读取 empty.foo 时,因本身没有 foo 属性,则从原型链 proxy 上找,触发 get 方法,此时打印结果分别是 falsetrue。也就是说,此时的 receiverempty 对象,而非 proxy 实例。

          其他方法

          除了最常用的拦截属性读写操作之外,还可以拦截以下操作:

          handler 方法 拦截操作
          get() 针对属性读取的拦截。
          set() 针对属性赋值的拦截。
          has() 针对 in 操作符的拦截。
          apply() 针对函数调用的拦截。
          construct() 针对 new 操作符的构造函数调用的拦截。
          defineProperty() 针对 Object.defineProperty() 操作的拦截。
          deleteProperty() 针对 delete 操作符删除属性的拦截。
          getOwnPropertyDescriptor() 针对 Object.getOwnPropertyDescriptor() 操作的拦截。
          getPrototypeOf() 针对 Object.getPrototypeOf()Object.prototype.__proto__Object.prototype.isPrototypeOf()instanceof 操作的拦截。
          setPrototypeOf() 针对 Object.setPrototypeOf() 操作的拦截。
          ownKeys() 针对 Reflect.ownKeys() 操作的拦截。
          isExtensible() 针对 Object.isExtensible() 操作的拦截。
          preventExtensions() 针对 Object.preventExtensions() 操作的拦截。

          以上 handler 所有方法,都会对应拦截 Reflect 的同名方法。

          应用场景

          防止访问私有属性。

          const person = {
            name: 'Frankie',
            _phone: '12345678910',
          }
          
          const user = new Proxy(person, {
            get(target, property, receiver) {
              if (property.startsWith('_')) return undefined
              return Reflect.get(target, property, receiver)
            },
          })
          
          console.log(user._phone) // 'Frankie'
          console.log(user._phone) // undefined
          

          我们知道,在 JavaScript 中访问一些不存在的属性会返回 undefined,那么借助 Proxy 可以在访问未知/不存在的属性时添加一些 Warning。

          数组负值索引:

          Negative Array Index in Javascript

          为什么 Vue 使用 Proxy 代替 Object.defineProperty?

          关于 Object.defineProperty() 缺点:

          • 对于属性众多、嵌套更深的对象,需要遍历、深层监听,可能会带来性能问题。
          • 无法监听到对象属性的新增/删除,需要额外添加新的 API 实现,比如 setdelete
          • 无法监听数组 API,加之数组长度可能很大,如果使用对象那种遍历、深层监听的方式,性能更加糟糕了,所以重写了 pushpop 等方式。

          这些问题在 Proxy 上都有较好且完整的支持。

          但 Proxy 兼容性没那么好。它无法 polyfill。

          Due to the limitations of ES5, Proxies cannot be transpiled or polyfilled.

          源码:

          Proxy 性能

          Reflect

          Reflect 是一个内置「对象」。它不是函数,自然也不能当作普通函数或使用 new 关键字调用。

          Object.prototype.toString.call(Reflect) // '[object Reflect]'
          

          它跟 Proxy 的 handler 有着同名的方法:

          • Reflect.get()
          • Reflect.set()
          • Reflect.has()
          • Reflect.apply()
          • Reflect.construct()
          • Reflect.defineProperty()
          • Reflect.deleteProperty()
          • Reflect.getOwnPropertyDescriptor()
          • Reflect.getPrototypeOf()
          • Reflect.setPrototypeOf()
          • Reflect.ownKeys()
          • Reflect.isExtensible()
          • Reflect.preventExtensions()

          Proxy 可以与 Reflect 搭配使用,前者负责拦截对象的操作,后者负责原有的默认行为。

          设计 Reflect 的目的:

          • 未来 JS 新语法可能只部署到 Reflect 上。类似于可迭代的 Map 一样,假设未来要新增一种数据结构,也会基于可迭代的趋势去设计。
          • 修改某些 Object 方法的返回结果,变得更合理。比如 Reflect.defineProperty() 如果无法定义属性,就会返回 false,而不像 Object.defineProperty() 在无法定义属性时抛出错误。
          • 统一为函数行为,比如原来的 delete obj[key] 删除属性,现在则是 Reflect.deleteProperty(obj, key)
          • 设计与 Proxy handler 一致,都有对应的方法。

          References

          ]]> <![CDATA[CSS 透明网格背景]]> https://github.com/tofrankie/blog/issues/333 https://github.com/tofrankie/blog/issues/333 Sat, 23 Mar 2024 12:23:06 GMT 配图源自 Freepik

          写在前面

          我们知道,无论是 Adobe 还是其他图形编辑器,]]> 配图源自 Freepik

          写在前面

          我们知道,无论是 Adobe 还是其他图形编辑器,都会使用灰白相间的网格背景来表达透明。比如这样:

          用 CSS 如何实现呢?

          • 使用 background-image + background-size + background-repeat 组合,如果想要图片不失真,可使用四个网格 SVG 图片重复。
          • 如果不想用图片的话,可以使用 background-image + linear-gradient,设置多个 linear-gradient 然后进行一定的错位,使其形成格子状。

          第一种方式缺点是不便调整大小、颜色。下文就第二种方式展开介绍。

          实现

          提前知道:

          • background-image 可以设置一张或多张图片,前面的图片会挡住后面的图片。
          • background-position 可以指定一张或多张图片的位置,background-size 等同理。

          先定义一个渐变:

          .grid {
            background-image: linear-gradient(45deg, red 25%, green 25%, green 75%, red 75%);
            background-repeat: no-repeat;
            background-size: 50px 50px;
          }
          

          为什么是 45°、25%、75%?

          为了让绿色部分占格子宽度一半。就是一个格子分成了三部分红色、绿色、红色,它们所占比例分别是 1:2:1。当旋转 45° 之后,在对角线看也是这个比例,也是为了后面的堆叠。

          接着,再定义多一个相同的渐变,并移动第二个渐变的位置,移动距离为背景图片大小的一半。

          .grid {
            background-image: linear-gradient(
                45deg,
                rgba(255, 0, 0, 0.5) 25%,
                rgba(0, 255, 0, 0.5) 25%,
                rgba(0, 255, 0, 0.5) 75%,
                rgba(255, 0, 0, 0.5) 75%
              ),
              linear-gradient(
                45deg,
                rgba(255, 0, 0, 0.5) 25%,
                rgba(0, 255, 0, 0.5) 25%,
                rgba(0, 255, 0, 0.5) 75%,
                rgba(255, 0, 0, 0.5) 75%
              );
            background-repeat: no-repeat;
            background-position: 0 0, 25px 25px;
            background-size: 50px 50px;
          }
          

          为方便查看堆叠效果,添加了透明度。

          到这里,是不是可以想象得到正方形格子的形状了,接着我们改成 background-repeat: repeat 再看看 👇

          观察上图,只要把绿色部分改成透明,红色部分改成灰色,就是灰白相间的网格效果。但改成透明的话,如果本身底部是含有背景的,显示就有问题了,因此背景颜色要改成白色。

          .grid {
            background-color: #fff;
            background-image: linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%),
              linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%);
            background-position: 0 0, 25px 25px;
            background-size: 50px 50px;
          }
          

          如格子大小、颜色可按需调整。其中白色格子调整 background-color、灰色格子调整 background-image 中的 #eee 颜色值。目前每个格子大小是 25px,如调整大小,需同时修改 background-position,它始终是 background-size 的一半。

          另外,MDN 提供了另外一种做法(Checkerboard),可是兼容性一般。

          The end.

          ]]>
          <![CDATA[记一次 HTML 富文本特殊字符转义]]> https://github.com/tofrankie/blog/issues/332 https://github.com/tofrankie/blog/issues/332 Sat, 16 Mar 2024 14:10:17 GMT 配图源自 Freepik

          背景

          此前有个项目里面一个功能是:运营后台进行富文本配置,然]]> 配图源自 Freepik

          背景

          此前有个项目里面一个功能是:运营后台进行富文本配置,然后在浏览器、微信小程序各端做展示。

          富文本编辑器本身是可以设置字体的,当预设的字体名称包含空格(比如 Microsoft Yahei)时,出现问题了,在小程序端渲染时字体不生效。

          当时临时方案是将字体名称用连字符替代空格,因为第二天要上线。

          这不是一个好的解决方案,比如针对 Microsoft YaheiPingFang SCHelvetica Neue 这类系统内置的字体,不应写成 Microsoft-Yahei,这是不合理的。

          下面开始找找根本原因。

          开始之前

          通常来说,我们在 CSS 中设置 font-family 时,字体名称是可以包含空格的,但有空格时,应该要用引号括起来。

          MDN

          The name of a font family. For example, "Times" and "Helvetica" are font families. Font family names containing whitespace should be quoted. For example: "Comic Sans MS".

          比如:

          .app {
            font-family: "Helvetica Neue", "Segoe UI", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
          }
          

          鉴于浏览器的包容性很强,其实不写引号,也能正常识别。

          寻找原因

          经排查发现,前面遇到的问题,其实是就是因为对 HTML 实体转义后,导致 HTML 结构不正确导致的。

          后台的富文本编辑器用的是 Braft Editor,它设置字体的方式如下:

          const fontFamilies = [
            {
              name: 'ST Song',
              family: '"ST Song"', // "'ST Song'" 在 BraftEditor 内不生效
            },
          ]
          
          <BraftEditor fontFamilies={fontFamilies} />
          

          是的,在 Braft Editor 里字体名称只能用「双引号」包裹,单引号不生效。

          假设富文本编辑器输出的 HTML 如下(Hello World):

          <p><span style="font-family:&quot;ST Song&quot;">&quot;Hello World&quot;</span></p>
          

          在浏览器中,这段富文本内容直接通过 Element.innerHTML 去修改 DOM,是可以得到预期效果的。但在小程序里,需要对类似 &quot;(双引号)等 HTML Entity 进行转换,才能正常显示,否则它会将 &quot; 当作五个普通字符,而不是一个双引号。

          之前是通过 entities 来做转义的。

          import { decodeHTML } from 'entities'
          
          const html = `<p><span style="font-family:&quot;ST Song&quot;">&quot;Hello World&quot;</span></p>`
          
          const transformedHtml = decodeHTML(html)
          
          console.log(transformedHtml)
          

          得到的转义结果是:

          <p><span style="font-family:"ST Song"">"Hello World"</span></p>
          

          其实这就看出问题了,style 属性包裹了两个双引号,自然解析不到预期结果。

          解决问题

          我们知道,CSS 字符串类型的属性值,可以用单引号或双引号。我们先把 style 里可能出现的引号,全部转为单引号,这样的话就能正确解析了。

          CSS 属性值使用到引号的(只想起了这几个):

          • font-family
          • content
          • url()
          • ...

          Related Link: https://www.w3.org/TR/2011/REC-CSS2-20110607/syndata.html#values

          这样的话,用表达式做匹配出 style 的属性值,然后将里面的引号替换掉,方法如下:

          function transformHtmlInlineStyle(html) {
            return html.replace(/(\s+style="[^"]*")/gi, match => {
              return match.replace(/&quot;|&apos;|&#34;|&#39;|&#x22;|&#x27;/gi, "'")
            })
          }
          

          因此,上面的流程只要加多一步就行:

            import { decodeHTML } from 'entities'
          
            const html = `<p><span style="font-family:&quot;ST Song&quot;">&quot;Hello World&quot;</span></p>`
          
          + let transformedHtml = transformHtmlInlineStyle(html)
          
            transformedHtml = decodeHTML(html)
          
            console.log(transformedHtml)
          

          得到的结果为:

          <p><span style="font-family:'ST Song'">"Hello World"</span></p>
          

          示例:CodeSandbox

          The end.

          ]]>
          <![CDATA[Taro 自定义 TabBar for H5 示例]]> https://github.com/tofrankie/blog/issues/331 https://github.com/tofrankie/blog/issues/331 Fri, 15 Mar 2024 17:14:07 GMT 配图源自 Freepik

          背景

          最近在做 Taro 项目,有微信小程序和 H5 两端,]]> 配图源自 Freepik

          背景

          最近在做 Taro 项目,有微信小程序和 H5 两端,因导航栏样式无法满足要求,需实现自定义 TabBar。

          目前 Taro for H5 未默认支持自定义 TabBar,详见 NervJS/taro #10049

          开始之前

          相关链接:

          注意事项:

          • 文件名为 custom-tab-bar,且放在 src 目录下。
          • 完整配置 tabBar 字段,一是为了向下兼容,二是不配置的情况下 H5 端切换页面的体验很差。后者不确定是否因为 Taro 不支持所以没做兼容。
          • 由于 H5 端未得到官方支持,因此配置 custom: true 编译为 H5 时是无效的。

          实现思路:

          对于微信小程序来说,Taro 已经支持了,这个不多说,按要求写就能正常显示。而 H5 则在 TabBar 页面中引入 custom-tab-bar 即可。有两个问题,一是内置的 TabBar 仍然存在,通过样式将其屏蔽掉。二是,在 TabBar 页面切换时,由于组件不会重新挂载,可能不会触发重新渲染,为避免 Tab 的高亮状态不正确,需在 onShow 时机进行设置。

          引入 custom-tab-bar 页面的方式有两种,一是在每个 TabBar 页面中手动引入(一般不会很多),二是通过插件(比如 taro-inject-component-loader)自动插入到每个页面。后者,还需要在组件内进一步判断,若是 TabBar 的路由则显示自定义 TabBar 的内容,否则不显示。

          下文以 React 为例,并选择第二种方式导入自定义 TabBar。

          实现

          小程序配置 app.config.js,记得完整配置 tabBar

          export default defineAppConfig({
            tabBar: {
          +   custom: true,
              // ...
            },
          })
          

          安装 taro-inject-component-loader,如果手动引入 custom-tab-bar 可以忽略。

          $ pnpm add taro-inject-component-loader -D
          

          编译配置 config/index.js 如下:

          import path from 'path'
          
          export default {
            // ...
            alias: {
          +   '@': path.resolve('./src'),
            },
            h5: {
              // ...
              webpackChain(chain) {
                chain.merge({
                  module: {
                    rule: {
                      injectBaseComponentLoader: {
                        test: /\.jsx$/,
                        use: [
                          {
          +                 loader: 'taro-inject-component-loader',
          +                 options: {
          +                   importPath: '@/custom-tab-bar',
          +                 },
                          },
                        ],
                      },
                    },
                  },
                })
              },
            },
          }
          

          可关注下 taro-inject-component-loaderisPage 的默认配置是否满足要求,特别是有分包时,它只会插入到页面组件里,详见

          编写 custom-tab-bar 组件,几个注意点:

          • 非 TabBar 页面,不显示 custom-tab-bar 组件内容。这个可以在 injectBaseComponentLoader.use[].options.isPage 通过自定义正则限制只在 TabBar 页注入组件。
          • 为避免非 TabBar 页面返回 TabBar 页面时,在切换的间隙,可能会出现 custom-tab-bar 隐藏 → 显示 → 隐藏的过程,影响用户体验,要使用一定的缓存处理。
          • 由于 TabBar 页面之间切换不一定能触发 rerender,因此要在 onShow 生命周期设置 Tab 的高亮状态,以确保 Tab 正确显示。
          • 由于只有在页面级别的组件内才会触发 onShow 生命周期(详见),因此这里使用 Taro.eventCenter 来监听页面组件的 onShow 生命周期。
          • 关于图片资源引用问题,小程序端可以不用 import 导入的方式,Taro 会自动根据 TabBar 配置处理(详见)。而 H5 由于官方未支持,则需要手动 import 导入。
          • 官方提供了一个 React 的示例,使用的是 Class Component,它可以通过 Taro.getTabBar() 获取组件实例,进而更新组件状态。但在 H5 端并未提供 Taro.getTabBar() 方法,因此它无法兼容小程序和 H5 两端。下面我用 Functional Component 并统一用 Taro 的消息机制来更新组件的状态。

          src/constants/index.js 👇

          export const IS_H5 = process.env.TARO_ENV === 'h5'
          
          export const ROUTE = {
            INDEX: '/pages/index/index',
            MINE: '/pages/mine/index',
          }
          
          export const TAB_BAR_ROUTES = [ROUTE.INDEX, ROUTE.MINE]
          
          export const EVENT_NAME = {
            TAB_BAR_PAGE_VISIBLE: 'tab_bar_page_visible',
          }
          

          src/custom-tab-bar/index.jsx 👇

          展开
          import { useMemo, useState } from 'react'
          import { View, Image } from '@tarojs/components'
          import Taro, { eventCenter } from '@tarojs/taro'
          
          import { IS_H5, EVENT_NAME, TAB_BAR_ROUTES } from '@/constants'
          
          import indexIcon from '@/images/icon-index.png'
          import indexIconActive from '@/images/icon-index-active.png'
          import mineIcon from '@/images/icon-mine.png'
          import mineIconActive from '@/images/icon-mine-active.png'
          
          // 样式文件碍于篇幅原因,就不贴出来了,请看文末完整示例
          import './index.scss'
          
          const tabBarConfig = {
            color: '#7A7E83',
            selectedColor: '#3CC51F',
            backgroundColor: '#F7F7F7',
            borderStyle: 'black',
            list: [
              {
                iconPath: IS_H5 ? indexIcon : '../images/icon-index.png',
                selectedIconPath: IS_H5 ? indexIconActive : '../images/icon-index-active.png',
                pagePath: '/pages/index/index',
                text: '首页',
              },
              {
                iconPath: IS_H5 ? mineIcon : '../images/icon-mine.png',
                selectedIconPath: IS_H5 ? mineIconActive : '../images/icon-mine-active.png',
                pagePath: '/pages/mine/index',
                text: '我的',
              },
            ],
          }
          
          export default function CustomTabBar() {
            const [selected, setSelected] = useState(-1)
          
            const onChange = (index, url) => {
              setSelected(index)
              Taro.switchTab({ url })
            }
          
            const currentRoute = useMemo(() => {
              const pages = Taro.getCurrentPages()
              const currentPage = pages[pages.length - 1]
              const route = currentPage.route?.split('?')[0]
              return IS_H5 ? route : `/${route}`
            }, [])
          
            const isTabBarPage = useMemo(() => {
              return tabBarConfig.list.some(item => {
                // 如有做路由映射,此处可能要调整判断条件
                const matched = TAB_BAR_ROUTES.find(route => route === currentRoute)
                return matched && item.pagePath === matched
              })
            }, [currentRoute])
          
            // 以避免多余的监听,特别是 rerender 时
            useState(() => {
              if (!isTabBarPage) return
              eventCenter.on(EVENT_NAME.TAB_BAR_PAGE_VISIBLE, index => setSelected(index))
            })
          
            const element = useMemo(() => {
              if (IS_H5 && !isTabBarPage) return null
          
              return (
                <View className="tab-bar">
                  {tabBarConfig.list.map((item, index) => (
                    <View
                      key={item.pagePath}
                      className="tab-bar-item"
                      onClick={() => onChange(index, item.pagePath)}
                    >
                      <Image
                        className="tab-bar-icon"
                        src={selected === index ? item.selectedIconPath : item.iconPath}
                      />
                      <View
                        className="tab-bar-text"
                        style={{color: selected === index ? tabBarConfig.selectedColor : tabBarConfig.color}}
                      >
                        {item.text}
                      </View>
                    </View>
                  ))}
                </View>
              )
            }, [selected, isTabBarPage])
          
            return element
          }
          

          抽成 Hook src/hooks/use-tab-bar.js 👇

          import Taro, { useDidShow } from '@tarojs/taro'
          
          import { EVENT_NAME } from '@/constants'
          
          export default function useTabBar(selectedIndex) {
            useDidShow(() => {
              Taro.eventCenter.trigger(EVENT_NAME.TAB_BAR_PAGE_VISIBLE, selectedIndex)
            })
          }
          

          页面使用 src/pages/index/index.jsx 👇

          import { View, Text } from '@tarojs/components'
          import useTabBar from '@/hooks/use-tab-bar'
          import './index.scss'
          
          export default function Index() {
            useTabBar(0)
          
            return (
              <View className="index">
                <Text>首页</Text>
              </View>
            )
          }
          

          H5 端内置的 TabBar 组件还是会渲染的,要通过样式来隐藏。src/app.scss 👇

          .taro-tabbar__tabbar {
            display: none !important;
          }
          

          最后

          完整示例:tofrankie/taro-custom-tab-bar

          有个体验上的问题,自定义 TabBar 在各个 TabBar 页面初始化时都会创建一个新的组件实例导致,导致切换 Tab 时图片闪烁,可以关注 NervJS/taro #7302

          The end.

          ]]>
          <![CDATA[一次弄懂汽车内循环、外循环、A/C、除雾]]> https://github.com/tofrankie/blog/issues/330 https://github.com/tofrankie/blog/issues/330 Sat, 10 Feb 2024 07:27:14 GMT 小米 SU7

          前言

          首先,本人不是老司机,如有不对,请指正。]]> 小米 SU7

          前言

          首先,本人不是老司机,如有不对,请指正。

          由于有些东西老是记不住,过后就忘,于是借着整理下,就以下几个问题展开介绍:

          • 什么是内循环、外循环?
          • 如何打开内循环、外循环?
          • 如何选择内循环、外循环?
          • 冬天开暖风时,要不要按下 A/C 键?
          • 如何除湿、除雾?

          什么是内循环、外循环?

          简单来说,「循环」就是使得空气流动起来,而「外循环」和「内循环」的区别在于空气的来源。

          • 外循环 - 空气来自车外。
          • 内循环 - 空气来自车内。

          外循环

          外循环的作用是将车外的空气送到车内。当开启外循环后,意味着打开了车内外的气流通道,可以有效保持车内空气清新。因此,如果车外空气条件不佳(比如异味、雾霾、扬尘、道路拥堵车辆尾气严重),应该关闭外循环,切换至内循环。

          内循环

          内循环则相反。当开启内循环,意味着关闭了车内外的气流通道,不开风机就没有气流循环,开风机时吸入的气流也仅来自车内,形成车辆内部的气流循环。作用是及时有效地阻止外部的灰尘和有害气体进入车内。

          如果用人类的呼吸来比喻,就是:

          • 外循环 - 正常吸气、呼气。
          • 内循环 - 憋气时的内呼吸。

          外循环就是呼吸外面的新鲜空气。当我们遇到异味时,自然地就会屏住呼吸,但憋久了会不舒服,因为氧气不足了,我们需要进行外循环补充空气。坐车也是同理的,内循环开久之后,乘车人吸收氧气,呼出二氧化碳,导致车内氧气不足,这时需要开启外循环,将车外的空气往车内输送,以避免司机、乘车人感到不适。

          也可以把车内环境当做一个房间,打开窗口相当于外循环,关闭窗户就是内循环。

          如何打开内循环、外循环?

          内循环、外循环模式只能打开其一,不可同时打开。

          如何判断打开了哪种模式:

          • 有些车,内循环和外循环都有独立的按钮,哪个灯亮表示打开哪个模式。(下图一)
          • 有些车,只有一个内循环按钮,灯亮表示使用内循环模式,灯灭则表示使用外循环模式。(下图二)

          每辆车外循环、内循环的按钮可能都长得不太一样(但形状应该是类似的)。

          • 外循序 - 箭头从车外贯穿到车内
          • 内循环 - 箭头始终在车内 🔁

          如何选择内循环、外循环?

          一般情况下,首选外循环。若要用内循环(比如夏天制冷),不能长时间单一使用内循环,应与外循环交替使用,避免车内氧气不足,导致一氧化碳中毒等情况。

          使用外循环的场景:

          • 在车内「长时间休息」或「睡觉」,一定要使用外循环,避免造成窒息等情况。
          • 长时间使用内循环,导致车内太闷或氧气不足,应内外循环交替使用。
          • 新车有异味,可以缓解车内异味。
          • 跑高速换气,代替开窗方式,后者风阻大耗油。

          使用内循环的场景(注意与外循环交替使用):

          • 车外空气质量不佳,比如道路拥堵汽车尾气严重、雾霾、异味、有害气体等。
          • 夏天制冷(休息或睡觉应使用外循环)。外循环会带入车外高温空气,制冷效果可能会差一些,同时会带来更多能耗。

          冬天开暖风时,要不要按下 A/C 键?

          在此之前,先大致了解下汽车空调系统。

          汽车空调系统,由制冷系统、供暖系统、通风系统、空气净化系统以及控制系统组成,可以调节温度、湿度、气流等。它的作用不仅仅是制冷、供暖。

          以传统燃油汽车为例。

          制冷系统

          制冷系统,由空调压缩机、冷凝器、储液干燥器、膨胀阀、蒸发器、空调管路以及制冷剂等组成。

          制冷过程:

          1. 加压过程:压缩机将低压端的气态冷媒加压使之成为高压气态冷媒。
          2. 液化(放热):经过冷凝器对加压后的气态冷媒通过散热风扇进行降温(降温过程)使之成为中温高压液体冷媒。
          3. 过滤节流:再经管道流到储液干燥器对中温高压液态冷媒进行过滤,滤出其中的水分与杂质,顺带对中温高压液态冷媒进行节流,使之成为干净稳定的液态冷媒。
          4. 汽化(吸热):中温高压液态冷媒再经管道流向膨胀阀,膨胀阀就相当于一个喷嘴,把中温高压液态冷媒喷成非常细小的雾珠(低温低压气态冷媒)(类似于汽车喷油嘴)喷入蒸发器。
          5. 蒸发器内的低压气态冷媒又经低压管道吸入压缩机,进行一个个循环。

          制冷过程需要压缩机一直工作,大部分汽车空调压缩机是由发动机驱动的,因此制冷是会增加耗油的。其中 A/C 键用于控制压缩机是否工作,因此制冷时会打开它。

          供暖系统

          供暖系统,由热交换器、冷却液管路及鼓风机、导风管、下风道及控制机构等组成。

          以水暖式供暖为例。发动机工作时,被发动机气缸燃烧高温加热的冷却液在发动机冷却系统水泵的作用下,经进水管进入热交换器,通过鼓风机吹出的空气将冷却液散发出的热量送到车厢内或风窗玻璃,用以提高车厢内温度和除霜。在热交换器中进行了散热过程的冷却液经回水管被水泵抽回,如此循环,实现暖风供热。

          简单来说,供暖就是利用了发动机的工作时的产生的热量,不耗油(或者说其油耗可以忽略不计)。

          供暖过程不需要压缩机工作,因此冬天制暖无需打开 A/C 键。

          A/C 键

          A/C 是 Air Conditioning 缩写,是空气调节的意思,也就是常说的空调。A/C 键的作用是控制压缩机工作,在制冷时打开。

          空调有制冷和供暖里两种方式,由于供暖是利用发动机产生的热量,不需要压缩机工作,所以 A/C 键只有一项功能,用于制冷。

          按下 A/C 键灯亮,压缩机开始工作,会导致油耗增大、噪音变大。当 A/C 灯灭,只是送风,只能利用外界空气和发动机热量调解温度,也就是只能升温不能降温。温度较高需要降温时,只有打开 A/C 才能起作用。

          除此之外,它还能降低空气湿度达到除雾的效果。

          如何除雾?

          起雾原因

          空气中的水蒸气(环境空气湿度到达一定饱和状态)遇到冰冷的挡风玻璃液化形成小水珠附着在玻璃上,进而影响视野。

          两个主要因素:

          • 空气湿度大
          • 空气与玻璃温差大

          空气湿度跟地理位置也有关系,南方湿度较大,北方则比较干燥。在北方的冬天,就算加上乘车人呼吸带来的湿度增加也不一定能达到临界饱和状态,所以无法形成水珠。

          在南方夏天行车时,我们通常会开冷风,即便车内挡风玻璃与车内温差大,可不一定形成雾。其原因是按下 A/C 键制冷(它有一项功能是去湿),也就是会降低车内空气湿度。

          以下这些都会影响空气湿度:

          • 夏天下雨前(南方更明显)
          • 呼气
          • 在车上喝热水、热饮等
          • ...

          起雾位置

          根据起雾位置分为两种:

          • 车外挡风玻璃起雾
          • 车内挡风玻璃起雾

          外部挡风玻璃起雾的话,使用雨刮器即可。通常是夏天下雨之前会发生,下雨之前,车外空气湿度大,车内空调开得低,导致车内外温差大,空气接触玻璃液化形成雾。

          车内挡风玻璃除雾

          车内起雾主要发生在冬天,一是环境空气湿度本身就很大,二是乘车人呼吸使得车内湿度增加,这是主要原因。除雾方式有很多种。

          手动擦拭(不推荐)

          最简单的方法。但只能在行车前擦拭,行车过程中无法进行,而且影响驾驶,很危险!

          喷防雾剂

          网上很多。原理很简单,防雾剂的成分多是活性剂,可破坏水的表面张力,当空气遇到冰冷的挡风玻璃,会变成一层薄薄的水附着在玻璃上,由于水分布均匀,不会对视野产生太大影响。

          在温度低的环境,这层薄薄的水容易结冰。

          听说用纯洗洁精(不兑水)也能达到类似效果,没尝试过。

          开暖风 + 外循环

          这种情况适合环境空气湿度很低,很干燥的天气使用,比如北方寒冷而干燥的冬天。

          开暖风的目的:

          • 增加空气流动速度,让水分快速蒸发。
          • 提高温度加速水分蒸发。

          开外循环的目的:

          • 引入车外湿度较低的空气,加快水分蒸发。

          总之,空气湿度越小、越干燥,除雾效果越好。

          开制冷 + 外循环

          这种情况适合夏末秋初的下雨天,这种天气空气湿度非常大,如果开暖风除雾,湿热的空气被暖风加入后就像对着挡风玻璃哈气,不但不能除雾,反而让挡风玻璃更模糊。

          正确的做法是开空调,它可以降低空气湿度,当干燥的空气吹向挡风玻璃,可以加速水分蒸发。

          快速除雾

          很多车都带有除雾功能,扇形 + 三条波浪线的是前挡风玻璃除雾标志。长方形 + 三条波浪线的是后挡风玻璃除雾标志。

          ]]> <![CDATA[perspective 滚动距离计算]]> https://github.com/tofrankie/blog/issues/329 https://github.com/tofrankie/blog/issues/329 Wed, 07 Feb 2024 09:41:01 GMT 假设有以下示例:

          <section style="width: 100%; max-width: 100%; perspective: 100px; perspective-origin: top center; overf]]>
                      假设有以下示例:

          <section style="width: 100%; max-width: 100%; perspective: 100px; perspective-origin: top center; overflow-x: scroll; overflow-y: hidden; -webkit-overflow-scrolling: touch; background: #eee">
            <section style="height: 0; transform: scale(1) translate3d(0, 0, 0)">
              <section style="display: flex; align-items: center; width: 400%; max-width: none !important">
                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 300" preserveAspectRatio="xMidYMin meet" style="flex: 1">
                  <rect x="0" y="0" width="100%" height="100%" fill="transparent" stroke="#f00" stroke-width="2"></rect>
                </svg>
                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 300" preserveAspectRatio="xMidYMin meet" style="flex: 1">
                  <rect x="0" y="0" width="100%" height="100%" fill="transparent" stroke="#0f0" stroke-width="2"></rect>
                </svg>
                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 300" preserveAspectRatio="xMidYMin meet" style="flex: 1">
                  <rect x="0" y="0" width="100%" height="100%" fill="transparent" stroke="#00f" stroke-width="2"></rect>
                </svg>
                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 300" preserveAspectRatio="xMidYMin meet" style="flex: 1">
                  <rect x="0" y="0" width="100%" height="100%" fill="transparent" stroke="#0ff" stroke-width="2"></rect>
                </svg>
              </section>
            </section>
          
            <section style="height: 0; transform: scale(0.8) translate3d(0, 0, 20px)">
              <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 300" preserveAspectRatio="xMidYMin meet" style="width: 400%; max-width: none !important">
                <rect x="0" y="0" width="100%" height="100%" fill="#f00" style="opacity: 0.2"></rect>
              </svg>
            </section>
          
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 300" preserveAspectRatio="xMidYMin meet" style="width: 400%; max-width: none !important"></svg>
          </section>
          

          Performant Parallaxing 可知:

          • 缩放比例 = (perspective - translateZ) / perspective
          • 滚动速率 = perspective / (perspective - translateZ)

          它们都相对的,相对于 translateZ 为 0 的情况,也就是没有视差效果。

          举个例子,假设一屏的横向宽度为 350px,父级元素设置 perspective 为 100,其下有两个子元素 A 和 B,其中 A 设为 transform: translateZ(0),其中 B 设为 transform: translateZ(20px),此时 A 属于正常的元素,而 B 由于更靠近视点位置(即 perspective 位置),它表现为变大,而滚动速率更快。

          假设 A 的速率为 1(正常滚动),而 B 的速率计算方式为「A 的速率 × (100 / (100 - 20))」,等于 1.25。也就是说,若 A 滚动 350px 时,B 会滚动 437.5px。

          ]]>
          <![CDATA[批量图片旋转脚本]]> https://github.com/tofrankie/blog/issues/328 https://github.com/tofrankie/blog/issues/328 Wed, 07 Feb 2024 07:33:51 GMT 之前有一个批量旋转图片的场景,写了个脚本处理。

          使用

          .
          ├── source/  # 源文件目录
          ├── output/  # 处理后产出目录
          └── rotate   # 可执行脚本文件
          

          先安装 ImageMagick,接着将要处理的文件放置于 source 目录,双击 rotate 文件执行旋转操作完成后文件存放于 output 目录。

          $ brew install imagemagick
          

          实现

          rotate 添加执行权限:

          $ chmod u+x rotate
          

          扩展名不重要,但为了避免在 Finder 双击打开时,被其他软件默认打开,这里删除扩展名。

          获取 rotate 脚本的绝对路径:

          $(dirname "$(readlink -f "$0")")
          

          判断是否安装了 ImageMagick,这里用到了它的 convert 命令:

          if ! command -v convert &>/dev/null; then
            echo "Error: 'convert' command not found. Please install ImageMagick."
            exit 1
          fi
          

          遍历 source 目录,并获取图片文件,并执行旋转操作,比如这里顺时针旋转 90°:convert <source-file> -rotate 90 <output-file>

          完整脚本如下:

          #!/bin/bash
          
          # 获取脚本或可执行文件的路径
          script_path="$(dirname "$(readlink -f "$0")")"
          
          # 检查 convert 工具是否已安装
          if ! command -v convert &>/dev/null; then
            echo "Error: 'convert' command not found. Please install ImageMagick."
            exit 1
          fi
          
          # 设置源目录和输出目录
          source_dir="$script_path/source"
          output_dir="$script_path/output"
          
          # 清空输出目录
          if [ -d "$output_dir" ]; then
            rm -rf "$output_dir"
          fi
          
          # 创建输出目录,如果不存在
          mkdir -p "$output_dir"
          
          # 遍历源目录中的图像文件
          for file in "$source_dir"/*; do
            # if [ -e "$file" ]; then
            # 提取文件名和扩展名
            file_name=$(basename "$file")
            base_name="${file_name%.*}"
            extension="${file_name##*.}"
          
            if [ -f "$file" ] && [ -n "$extension" ] && { [ "$extension" = "png" ] || [ "$extension" = "jpg" ] || [ "$extension" = "jpeg" ] || [ "$extension" = "gif" ]; }; then
          
              # 使用 convert 工具旋转图像并导出到输出目录
              convert "$file" -rotate 90 "$output_dir/$file_name"
          
              echo "已处理文件: $file_name"
            fi
          done
          
          echo "处理完成!"
          

          The end.

          ]]>
          <![CDATA[使用 VS Code + GitHub 搭建个人博客]]> https://github.com/tofrankie/blog/issues/327 https://github.com/tofrankie/blog/issues/327 Sat, 03 Feb 2024 08:26:03 GMT 配图源自 Freepik

          相关话题

          • 相关话题

            候选方案

            选择很多,门槛很低。

            现有平台:

            • 掘金
            • 语雀
            • 知乎
            • 简书
            • 博客园
            • 微信公众号
            • SegmentFault
            • Medium
            • ...

            自行搭建:

            • WordPress
            • Hexo
            • GitBook
            • VuePress
            • dumi
            • ...

            如何选择?

            我们写个人博客的初衷,大致会有这些吧:

            • 记录踩过的坑、解决过的难题、生活等
            • 建立知识库、影响力
            • 总结归纳,提升写作表达能力
            • 内容输出、观点分享、讨论及改进
            • ...

            我想要的:

            • 随时随地编辑发布
            • 良好的 Markdown 语法支持,有图床
            • 无内容审核(国内尤为明显,比如文中有竞品字眼或 URL 容易限流/封禁)
            • 良好的 SEO(还是希望写的东西可以让更多人看见)
            • 可以专注于内容输出
            • ...

            在国内的话,可能是掘金、语雀、博客园会好一些,用户群体基本都是程序员,可以带来更多讨论。知乎 Markdown 支持度太差了。简书内容审核不友好,在简书写了几年,后来由于其审核机制太机器人了,改个文字动不动封禁的程度,实在忍无可忍,就溜了。

            自行建站要考虑 SEO、图床、域名备案、运维费用等。选择国内平台,无法避免的是内容审核,改个字都得审核一下。🙄

            总之,各有利弊,选择一个合适自己的就行。

            我的选择

            我的博客:种一棵树,最好的时间是十年前。其次,是现在。

            GitHub Blogger

            Inspired by Aaronphy/Blogger.

            此前离开简书,考虑过到掘金上续写,但现在掘金的整体质量不如以前,而且充斥着各种标题党,贩卖焦虑的味太浓了。后来遇到了 Aaronphy/Blogger 可以静静地写了。

            用了一段时间,发现有些地方不太顺手、有一些 Bug,作者也很久没更新了。然后就二次开发了,修了一些 Bug,加了一些新功能,于是 GitHub Blogger 诞生了。

            原作者的设计思路可看《在 VSCODE 中写博客吧

            我在原有基础上作了一些调整:

            • 调整 UI 主题
            • 调整 Markdown 主题表现,保持与 GitHub 一致
            • 支持 Markdown 更多格式,比如数学公式、图表等
            • 支持标题、多标签搜索
            • 支持搜索面板
            • 支持在 GitHub 中打开文章
            • 支持文章备份,每次编辑保存都会记录到你的博客仓库中
            • 修复按标签搜索无法翻页的问题
            • 修复新建文章选择标签无法创建的问题
            • 修复 Labels 只能显示前 20 个的问题

            2025 年中,完全重构了,交互体验跟原生 GitHub 一致。现在长这样 👇

            如果刚好你也喜欢,欢迎来试试~ 👋

            如何使用 GitHub Blogger?

            由于 GitHub Blogger 内部使用 jsDelivr 的图床方案,后者不支持私有仓库(更多),因此你的博客仓库必须是公开的,否则图片无法显示。

            很简单:

            1. 你需要注册一个 GitHub 账号(已有跳过)
            2. 你需要安装 VS Code、Cursor 等编辑器(理论上基于 VSCodium 的编辑器都支持)
            3. VS Code MarketplaceOpen VSX 安装扩展,也可在编辑器内搜索 GitHub Blogger 扩展
            4. 准备好你的 GitHub Personal Access Token (classic)(调用 GitHub API 需要用到)
            5. 使用 Command + Shift + P 或 Ctrl + Shift + P 快捷键唤起命令面板:
              1. 键入 Configure GitHub Blogger 完成初始配置(仓库可以选择已有的公开仓库)
              2. 键入 Open GitHub Blogger 打开编辑界面,就可以开始了。

            扩展配置:

            {
              "github-blogger.token": "xxx", // Your GitHub Personal Access Token
              "github-blogger.user": "xxx", // Your GitHub Username
              "github-blogger.repo": "xxx", // Your GitHub Repository Name
              "github-blogger.branch": "main" // Your GitHub Repository Branch Name
            }
            

            其中 branch 默认是 main 分支,它决定图片、文章存档提交到哪个分支。通常博客仓库不像实际项目有多个分支,一般无需特别设置,是仓库主分支就行。

            可配合 github-issue-toc 食用,它可以在 GitHub Issue 页面右侧生成目录。

            GitHub Flavored Markdown(GFM)除了支持标准的 CommonMarkdown 语法之外,还更多特有格式,详见这里

            ]]> <![CDATA[在 CSS 中尝试超椭圆圆角]]> https://github.com/tofrankie/blog/issues/326 https://github.com/tofrankie/blog/issues/326 Tue, 26 Dec 2023 13:31:30 GMT 开始

            说到「超椭圆 Superellipse」可能很多人不了解,但小米于 2021 年 3 月 30 日发布的全新 LOGO 很多人不陌生。没错,它就是超椭圆的一个例子,长这样 👇

            当时雷总刚开完发布会,小米旗下的网站还没来得及更换全新的 LOGO,然后开发同学直接拉了个 border-radius19px 的圆角(没记错的话)还被各大网友调侃,哈哈。现在已经换上了一个 SVG 图形。

            <svg xmlns="http://www.w3.org/2000/svg" width="50" viewBox="0 0 808 808">
              <g>
                <path fill="#ff6900" d="M723.79,84.42C647.55,8.48,537.94,0,404,0,269.89,0,160.12,8.58,83.92,84.72S0,270.43,0,404.39,7.74,648,84,724.14,269.9,808,404,808s243.85-7.71,320-83.86,84-185.78,84-319.75C808,270.25,800.16,160.54,723.79,84.42Z"></path>
                <path fill="#fff" d="M374.26,553.72a5,5,0,0,1-5.06,5H300.3a5.05,5.05,0,0,1-5.12-5V373.53a5.05,5.05,0,0,1,5.12-5h68.9a5,5,0,0,1,5.06,5Z"></path>
                <path fill="#fff" d="M509.18,553.72a5.05,5.05,0,0,1-5.09,5H438.5a5,5,0,0,1-5.1-5V398.26c-.07-27.15-1.62-55-15.64-69.06-12-12.09-34.51-14.86-57.88-15.44H241a5,5,0,0,0-5.07,5v235a5.07,5.07,0,0,1-5.12,5H165.16a5,5,0,0,1-5.06-5V254.31a5,5,0,0,1,5.06-5H354.52c49.49,0,101.22,2.26,126.74,27.81s27.92,77.3,27.92,126.85Z"></path>
                <path fill="#fff" d="M644.29,553.72a5.06,5.06,0,0,1-5.09,5H573.57a5,5,0,0,1-5.08-5V254.31a5,5,0,0,1,5.08-5H639.2a5.06,5.06,0,0,1,5.09,5Z"></path>
              </g>
            </svg>
            

            CodePen

            在大学时期,那会做小米主题接触到超椭圆,然后就迷上了。在以前,iOS 的应用图标总是要比 Android 好看一些,原因就在它圆角上。当然,现在看国内几家系统都已经用上了,爽!

            简单来说,它比普通圆角,看起来更舒服、更自然。

            超椭圆

            超椭圆(Superellipse)也称为拉梅曲线(Lamé curve),是在笛卡儿坐标系下满足以下方程式的点的集合:

            $|\frac{x}a|^{n} + |\frac{y}b|^{n} = 1$

            未完待续...

            ]]>
            <![CDATA[久违的刷机体验]]> https://github.com/tofrankie/blog/issues/325 https://github.com/tofrankie/blog/issues/325 Sat, 23 Dec 2023 16:56:40 GMT

            趁着周末,把手上的小米 6X 刷了个移植版的 MIUI 14,可能是因为加了 2GB 的扩展内存,丝]]>

            趁着周末,把手上的小米 6X 刷了个移植版的 MIUI 14,可能是因为加了 2GB 的扩展内存,丝滑了许多...

            • 2011 ~ 2012 年开始关注 MIUI
            • 2013 年买了小米 2
            • 2015 年买了小米 Note Pro
            • 2018 年买了小米 6X

            还记得,高一买的第一台安卓机是联想 A500,它没法刷 MIUI,当时很后悔没买贼能刷的中兴 V880。

            2016 下半年主力转向 iPhone。细节控的 iOS 真的治愈了我的强迫症(做小米主题养成的)。

            大学毕业后,因工作的关系,也没空做小米主题了,用小米自然就少了,基本上都在用苹果的生态。这些年像小米电视等生态产品买了不少,明年收楼考虑米家的全屋智能方案,继续一如既往地支持小米。

            近几年,「刷机」这个词已经很少人提起了,原因是各家的定制系统已经做得足够好了,已经不需要刷别家系统来弥补自家体验的不足。你还记得魔趣、乐蛙等第三方 ROM 包吗?今年 10 月雷总宣布小米启用全新的操作系统「小米澎湃 OS」,MIUI 将成为历史。

            不知后续小米 6X 有无 HyperOS 版本的包,坐等...(已更新)

            小米 6X 刷入 HyperOS

            最近酷安的大佬放出了 HyperOS for 小米 6X 的包(链接),可以开刷了。

            之前系统是 MIUI 14,这次要清除 data 分区才能刷入 HyperOS。

            刷入 TWRP

            1. 下载第三方 Recovery 和 android-platform-tools
              • 按机型从 TWRP 下载到本地
              • 使用 brew install android-platform-tools 安装,包含了 adbfastboot
            2. 将手机连接到电脑,用 adb devices 测试是否连接成功。
            $ adb devices
            * daemon not running; starting now at tcp:5037
            * daemon started successfully
            List of devices attached
            de5edad9	device
            
            1. 连接成功后,使用 fastboot 命令刷入 TWRP:
            # TWRP 路径根据实际存放路径调整
            $ fastboot flash recovery /path/to/your-twrp.img
            <waiting for any device>
            
            1. 重启,按住「音量减」和「电源键」进入 FASTBOOT 模式。
            fastboot flash recovery /path/to/your-twrp.img
            < waiting for any device >
            Sending 'recovery' (41104 KB)                      OKAY [  0.884s]
            Writing 'recovery'                                 OKAY [  0.286s]
            Finished. Total time: 1.214s
            
            1. 刷完之后,执行 fastboot reboot 重启。为防止重启手机 MIUI 自动替换回官方 Recovery,按住「音量加」,直至进入 TWRP 界面。

            格式化 Data 分区

            进入 TWRP 后,会询问「是否保持系统分区为只读」。为了禁止 MIUI 替换回官方 Recovery,滑动按钮以允许修改即可。

            选择「清除 → 格式化 Data 分区 → 输入 yes 确认 → 重启系统」

            卡在 TWRP 界面

            刷完 TWRP 以及格式化分区后,可能会出现一直卡在 TWRP 界面,无法进入系统的情况。

            实际在「安装」选择卡刷包刷入系统就可以了。但格式化分区后,里面就不存在卡刷包了(即使之前已下载)。有几种解决方式:

            • 使用内存卡(提前将卡刷包拷进去),但前提是手机支持...

            • 使用 OTG 方式,但前提是得有 OTG 线...

            上面这两种方式都可能一时间解决不了,很多手机不支持内存卡了,手上有 OTG 线的少之又少。

            我的手机不支持内存卡,TWRP 上又无法挂载 USB-OTG 的内容,加上手机重置了,也无法连接上电脑,因为 USB 调试没开...

            最终借助了 ADB Sideload 来解决,一种无需进入正常进入系统也能安装软件的方式(大概是这个意思)。

            在手机端 TWRP 界面的「高级 → ADB Sideload → 滑动按钮开始 Sideload」,这时候手机终端控制台应该能看到如下信息:

            starting adb sideload feature...
            

            接着,在电脑端执行 adb devices 应该能看到类似这样,表示连接成功了。

            $ adb devices
            List of devices attached
            de5edad9	sideload
            

            再接着,电脑端执行 adb sideload 命令将卡刷包刷入,耐心等待完成(如下),过程中不要重启手机。

            $ adb sideload /path/to/your-卡刷包.zip
            Total xfer: 1.00x
            

            我遇到了 Total xfer: 0.00x 的情况,属于未刷成功,具体原因我也不清楚,我后面换了上一个版本的卡刷包就好了,然后再通过卡刷最新的包来解决的。

            上面完成之后,在手机端会提示重启系统,重启就能体验新鲜的 HyperOS 了。

            汇总链接

            以上工具基本上要借助 Windows 环境,比如解 BL 锁的工具就只有 Windows 版本。

            ]]>
            <![CDATA[Chrome 2023 全新外观丑?想换回经典外观?]]> https://github.com/tofrankie/blog/issues/324 https://github.com/tofrankie/blog/issues/324 Sat, 16 Dec 2023 06:31:32 GMT 配图源自 Freepik

            最近 Chrome 120 推送更新,默认启用了 Chrome 2023 的]]> 配图源自 Freepik

            最近 Chrome 120 推送更新,默认启用了 Chrome 2023 的全新外观。

            What’s New in Chrome

            一直使用 Google Chrome 的原因有二,一是本身从事 Web 前端开发,二是它的「颜值」。但作为强迫症末期患者,实在对新外观提不起兴趣。比如:

            • 主页按钮(丑)
            • 书签图标与字体不协调(前天那个版本更丑)
            • 书签字体颜色过重,有点失去主次了
            • ...

            总的来说,是我不太喜欢 Material You 设计语言。以前 Material Design 的(黑白灰)图标还挺喜欢的,但配色是一直没有命中我的喜好,过于鲜艳。当然,个人喜好而已,那你觉得好看吗?

            像反悔的话,可以通过以下方式返回经典外观。

            地址栏输入打开:

            chrome://flags/
            

            搜索 Chrome Refresh 2023,将相关 flag 按需 Disable 掉就行。比如,简单将 Chrome Refresh 2023 设为 Disable 重启就能切换为经典外观。

            如果要更精细地控制,可以调整以下选项:

            • Chrome Refresh 2023
            • Chrome WebUI Refresh 2023
            • Chrome Refresh 2023 New Tab Button
            • Chrome Refresh 2023 Top Chrome Font Style
            • Realbox Chrome Refresh 2023
            • Customize Chrome Side Panel
            • Omnibox CR 2023 Action Chips
            • Omnibox CR 2023 Action Chips Icons

            但,这些只是过渡用,未来的某一个版本肯定会被移除的,大家都得用新外观...

            2024.03.22 更新

            前两天发布的 Chrome 123 (arm64) 以上设置已失效。

            ]]>
            <![CDATA[GitHub GraphQL API 分页查询]]> https://github.com/tofrankie/blog/issues/323 https://github.com/tofrankie/blog/issues/323 Sun, 03 Dec 2023 13:42:13 GMT 配图源自 Freepik

            背景

            前不久做

            背景

            前不久做 Github Blogger 插件开发时,遇到分页查询的场景,是用于查询 Issue 的。

            使用 Github REST API 的话,可以很方便地分页查询,比如:

            const {Octokit} = require('@octokit/core')
            
            const octokit = new Octokit({
              auth: 'TOKEN',
            })
            
            await octokit.request('GET /repos/{owner}/{repo}/issues', {
              owner: 'OWNER',
              repo: 'REPO',
              per_page: 20,
              page: 1,
            })
            

            Related Link: List repository issues

            由于它并没有提供 in:title 等更加复杂的搜索方式,只能用 GitHub GraphQL API 实现该需求。

            简介

            我的需求是根据 titlelabel 来筛选 Issues,并支持分页查询。使用到的 API 是下面这个(Queries search):

            借助 afterfirstquery 就能实现,比如:

            query Issues {
              search(
                type: ISSUE
                query: "user:tofrankie repo:blog state:open label:2023"
                first: 5
                after: null
              ) {
                issueCount
                pageInfo {
                  startCursor
                  endCursor
                  hasNextPage
                  hasPreviousPage
                }
                edges {
                  node {
                    ... on Issue {
                      title
                    }
                  }
                }
              }
            }
            

            如果对 Github Graph API 不熟的话,可以借助 Github 提供的 Explorer 进行验证,不懂可看 Using the Explorer

            其实从这里就看得出大概了,结果中的 pageInfo 结构如下

            • startCursor 当前分页的起始锚点
            • endCursor 当前分页结果的结尾锚点
            • hasPreviousPage 向前翻页,是否有更多项目
            • hasNextPage 向后翻页,是否有更多项目

            也就是说,如果已知 startCursorendCursor 就能代入 Search 的 afterbefore 参数实现翻页的需求。

            如果是按顺序从第一页、第二页......进行翻页的话,从本页的 pageInfo 中取出 endCursor 代入下一个查询的 after 中就行。如果我要从第 1 页调到第 5 页呢?

            我试图去寻找 Cursor 的规律,从 Introduction to GraphQL 可知,Github 采用 GraphQL 的 cursor-based pagination 方式以实现翻页。

            原来 Cursor 是用 Base64 编码后的字符串。

            Y3Vyc29yOjU=
            
            cursor:5
            

            解码后可知是 cursor:offset 的形式,所以,实现跳页只要将其编码为 Base64 再传入 after 即可。如果是首页,可以不传入 after 参数或设为 null

            示例

            其中 TOKENUSERREPOLABEL 以及 query 等按需调整。

            const {Octokit} = require('@octokit/core')
            const {encode} = require('js-base64')
            
            const octokit = new Octokit({
              auth: 'TOKEN',
            })
            
            const page = 2
            const perPage = 5
            const offset = (page - 1) * perPage
            const pageCursor = offset > 0 ? encode(`cursor:${offset}`) : null
            
            await octokit.graphql(`
              {
                search(
                  type: ISSUE
                  query: "user:USER repo:REPO state:open label:LABEL"
                  first: ${perPage}
                  after: ${pageCursor ? `"${pageCursor}"` : null}
                ) {
                  issueCount
                  pageInfo {
                    startCursor
                    endCursor
                    hasNextPage
                    hasPreviousPage
                  }
                  edges {
                    node {
                      ... on Issue {
                        title
                      }
                    }
                  }
                }
              }
            `)
            

            RunKit Demo

            参考链接

            ]]>
            <![CDATA[图片操作记录]]> https://github.com/tofrankie/blog/issues/322 https://github.com/tofrankie/blog/issues/322 Fri, 10 Nov 2023 08:24:41 GMT 配图源自 Freepik

            常用工具

            • 常用工具

              它们的命令行工具均可通过 Homebrew 安装,部分有桌面客户端应用。

              查看 GIF 信息

              Gifsicle

              # 查看所有帧信息
              $ gifsicle --info source.gif
               
              # 查看某帧信息
              $ gifsicle --info "#0" "#3" < source.gif
               
              # 查看详细颜色表
              $ gifsicle --cinfo source.gif
              

              支持网络图片

              详见 get_info.sh,它内部依赖 ImageMagick。

              $ gifinfo [-n number_of_frames] <filename>
              

              其中 filename 支持网络图片。

              逐帧信息读取

              https://github.com/tofrankie/gif

              GIF 转换为 PNG

              使用 ImageMagick

              # 逐帧导出
              $ convert source.gif target.png
              

              WARNING: The convert command is deprecated in IMv7, use "magick" instead of "convert" or "magick convert"

              如果 GIF 已经过透明度处理(即第一帧信息完整保留,后续帧仅保留变化的部分,未变的空间均为透明),可通过 Gifsicle 还原,再使用 ImageMagick 处理。

              # 还原
              $ gifsicle --unoptimize source.gif > tmp.gif
              
              # 逐帧导出
              $ convert tmp.gif target.png
              

              导出某帧

              $ convert 'source.gif[0]' target.png
              

              导出多帧,可以这样指定:source.gif[0-3]source.gif[3,2,4](非连续帧)。

              GIF 帧操作

              简单抽帧

              使用 macOS 内置的「预览」应用,可以快速插入/删除帧。打开 GIF 并在「菜单栏 - 显示」切换为「缩略清单」。

              • 删除帧:选中帧后,使用「⌘ + delete」快捷键删除。
              • 添加帧:打开两组 GIF(必需都是 GIF),选中某帧,拖拽至另一个 GIF 对应帧的位置。比如,从 A 中拖拽一帧到 B,A 帧数不会发生变化,B 则会增加一帧。

              删除指定帧

              $ gifsicle source.gif --delete '#1-5' > output.gif
              

              帧索引从 0 开始。

              帧数支持负数,比如 #-1 表示倒数第一帧,#0--2 表示前 N - 2 帧。

              修改帧与帧之间的 Delay Time

              $ gifsicle source.gif -d100 '#0-5' > output.gif
              

              TODO: 目前修改似乎有问题,只导出了修改后的帧。

              注意,单位是 1/100 秒,-d100 表示延迟时间为 1s。

              在线工具 Ezgif GIF Maker

              Adding a delay to the end of an animated gif

              MP4 转 GIF

              使用 FFmpeg

              $ ffmpeg -vf "fps=10" -loop 0 target.gif -i source.mp4
              

              HEIC 转 PNG

              使用 ImageMagick

              $ magick source.heic target.png
              

              图片旋转

              使用 ImageMagick

              $ convert source.png -rotate 90 target.png
              

              想起再继续补充...

              ]]> <![CDATA[微信公众号图片上传压缩规则]]> https://github.com/tofrankie/blog/issues/321 https://github.com/tofrankie/blog/issues/321 Wed, 01 Nov 2023 09:31:53 GMT 配图源自 Freepik

              [!NOTE] 由于微信手握“生杀大权”,一旦规则]]> 配图源自 Freepik

              [!NOTE] 由于微信手握“生杀大权”,一旦规则有变,本文内容也可能随之失效,仅供参考。

              背景

              公众号图片上传后,图片会有不同程度的压缩,本文讨论的就是通过一定的技巧来减少损耗,内容收集自网络或实践总结所得。

              格式

              • PNG:一种有损压缩的图片格式,优点是体积小,可压缩空间大(质量损耗大)。但质量不如 PNG,且不支持透明背景。
              • JPG:一种有损压缩的图片格式,图片质量高,且支持透明背景。但可压缩空间少。
              • GIF:一种最多可支持 256 种颜色的图片格式,色彩丰富度远不如 JPG、PNG。它在微信公众号 SVG 交互排版中扮演着很重要的角色。

              前提

              超过以下上限将无法在公众号后台保存。

              • ~图片不能超过 5M~(2023 年某次更新后,提升至 10M 了)
              • 图片不能超过 10M
              • GIF 不能超过 300 帧

              注意,尽管控制在上限范围内,上传后可能会有不同程度的压缩。

              结论

              • 图片宽度不要超过 1080px,超过会被压缩为 1080px。
              • 对于静帧图片,要追求质量的话,优先选 PNG,其压缩空间小。
              待验证
              • GIF 帧数大于 60 帧,不被压缩
              • GIF 帧数小于 60 帧:
                • 宽度小于等于 640px,不被压缩
                • 宽度大于 640px,被压缩为 640px
              • 图片像素总数(宽 × 高)不超过 600w

              Q&A

              为什么是 1080px?

              众所周知 1280 宽是公众号后台素材库静态图片宽度的上限,那为啥输出设置的是 1080px,而不是 1280px?——第一是因为好记,第二是因为 1280 宽度的图片在测试过程中也会偶尔被玄学压缩,因此再缩小一点确保不会被压缩。使用这个格式的图片质量已经是量级飞跃了,200 像素差距不大,对设计源文件输出的电脑要求也较低,可以导出更长的 10M 内文件。(源自

              压缩工具

              References

              ]]> <![CDATA[解决 Mac App Store 不显示可用更新的问题]]> https://github.com/tofrankie/blog/issues/320 https://github.com/tofrankie/blog/issues/320 Sat, 23 Sep 2023 04:49:44 GMT 配图源自 Freepik

              相信不少人遇到过 Mac App Store 有更新提示,但进入应用商店的「更]]> 配图源自 Freepik

              相信不少人遇到过 Mac App Store 有更新提示,但进入应用商店的「更新」一栏不会显示可用更新的软件列表。只有进入对应软件详情页才会显示可更新。

              解决方法是使用快捷键「⌘ + R」来刷新页面。

              Related Link: Troubleshooting App Store Issues

              ]]>
              <![CDATA[细读 ES6 | Map、Set]]> https://github.com/tofrankie/blog/issues/319 https://github.com/tofrankie/blog/issues/319 Sun, 23 Jul 2023 13:35:55 GMT 在日常开发中,使用 MapSet 的场景有哪些?

              Map

              简介

              Map 是 ES6 中新增的一种引用数据类型。

              O]]>
                          在日常开发中,使用 MapSet 的场景有哪些?

              Map

              简介

              Map 是 ES6 中新增的一种引用数据类型。

              Object.prototype.toString.call(new Map()) // "[object Map]"
              

              常与 Object 作比较,主要区别如下:

              Map Object
              默认键 无。 Object.create(null) 创建的对象之外,一般都有默认键,比如 __proto__ 等与原型相关的键。
              键的类型 可以是任意数据类型的值。 只能是 StringSymbol 类型的值。
              键的顺序 有序(按插入顺序)。 无序。
              是否可迭代 支持,其实例对象是一个可迭代对象。 不支持。

              既然它是一个可迭代对象,自然就很方便地被解构、扩展运算符、for...ofArray.from() 等消费。

              new Map()[Symbol.iterator] // ƒ entries() { [native code] }
              

              构造函数

              其构造函数可接受一个可迭代对象作为参数,可选。

              new Map([iterable])
              

              通常是 [[key, value], ...] 形式。比如:

              const map = new Map([
                [1, 'one'],
                [2, 'two'],
                [3, 'three'],
              ])
              

              也可使用链式形式插入键值。

              const map = new Map()
                .set(1, 'one')
                .set(2, 'two')
                .set(3, 'three')
              

              实例属性、方法

              它只提供了两个实例属性。

              Map.prototype.size
              

              只读,返回实例对象的键值对数量。对比 ObjectObject.keys(obj).length,它方便太多了。

              另一个 @@species 属性太太太少见了,有兴趣自行翻阅。

              它提供了一系列的实例方法,便于对其进行增删改查等操作。

              Map.prototype.get()
              Map.prototype.set()
              Map.prototype.has()
              Map.prototype.delete()
              Map.prototype.clear()
              
              • clear() 之外,以上其他方法接受一个参数 key,可以是任意类型的值(原始值或引用值)。
              • get(key) 用于获取指定健的值,若 key 不存在于实例对象,则返回 undefined
              • set(key) 用于添加或更新指定健,并返回实例对象,因而可以链式调用。
              • has(key) 用于判断指定健是否存在,返回布尔值。
              • delete(key) 用于删除指定健,若健存在且已被删除,则返回 true,否则返回 false
              • clear() 用于清空实例对象中所有元素。
              Map.prototype.keys()
              Map.prototype.values()
              Map.prototype.entries()
              
              • 作用与 Object.keys()Object.values()Object.entries() 类似,但不同。
              • 它们三个都返回一个新的迭代器对象(不是 Map 对象,也不是数组),其顺序同原始 Map 对象元素插入的顺序。
              Map.prototype.forEach()
              

              有且仅有这一个用于遍历的实例方法。别一上来就联想到 Array.prototype.forEach(),虽然都是遍历,但不同,它没有 map()filter() 等方法。

              未完待续...

              ]]>
              <![CDATA[解构赋值被滥用?]]> https://github.com/tofrankie/blog/issues/318 https://github.com/tofrankie/blog/issues/318 Sat, 22 Jul 2023 05:45:38 GMT 配图源自 Freepik

              开始

              某天看到这样一个

              开始

              某天看到这样一个老帖子调侃道:

              // let inputClass = manager.options.inputClass
              let { options: { inputClass } } = manager
              

              不可否认,ES6 的解构赋值(Destructuring Assignment)语法在日常开发中带来了极大的便利。但是...

              看起来不错

              有一个对象,

              var store = {
                name: 'Apple Store Parc Central',
                national_code: '86',
                phone: '4006139742'
              }
              

              若要将其属性值赋予新变量,在 ES5 及以前,

              var name = store.name
              var phone = store.phone
              

              在 ES6 及以后,

              const { name, phone } = store
              const { national_code: nationalCode } = store
              

              到目前为止,看起来很不错。

              看起来还行

              若是一个嵌套类型的对象,

              const store = {
                name: 'Apple Store Parc Central',
                national_code: '86',
                phone: '4006139742',
                address: {
                  province: 'Guangdong',
                  city: 'Guangzhou',
                  county: 'Tianhe',
                  street: 'No. 218, Tianhe Road',
                }
              }
              

              一部分同学可能会这样用,

              const { address: { province, city } } = store
              

              而我个人更偏向于,

              const { province, city } = store.address
              

              为什么呢?

              假设数据源 store 来自于别处,其中 store.address 可能是一个不存在的属性,为避免解构出错,我们可能会为其设置默认值。比如:

              const { address: { province, city } = {} } = store
              
              const { province, city } = store.address || {}
              

              个人认为后者更直观一些,这也是我选择它的原因之一。

              前者还有个陷进,如果 store.addressnull,默认值就不生效,自然解构也会出错。

              但如果要获取不同层级的属性时,也会像前面那样用,

              const { name, address: { province, city } } = store
              

              看起来不好!

              若对象时一个嵌套多层的对象,

              const store = {
                name: 'Apple Store Parc Central',
                national_code: '86',
                phone: '4006139742',
                address: {
                  province: 'Guangdong',
                  city: 'Guangzhou',
                  county: 'Tianhe',
                  street: 'No. 218, Tianhe Road',
                },
                activity: {
                  today: {
                    name: '光影技巧:用 iPhone 拍摄照片',
                    description: '用 iPhone 拍出更棒的照片和视频。',
                    start_at: '1690090200',
                    end_at: '1690093800',
                  },
                },
              }
              

              就可能会出现这种令人抓狂的写法,

              const {
                activity: {
                  today: { start_at: startAt },
                },
              } = store
              
              // or
              const { activity: { today: { start_at: startAt } = {} } = {} } = store
              

              给人的感觉是「为了用而用」。

              为什么不用最原始的方式?忘本了?

              const startAt = store.activity.today.start_at
              

              即使这样,可读性也比前面的好吧,

              const { start_at: startAt } = store.activity.today
              

              新语法固然是为了解决某种场景而生的,但其并非适用于所有场景的啊。有句话怎么说来着:「因地制宜」。

              那应该怎么写?

              解构赋值语法允许指定默认值。但当属性值为 null 时,并不会赋予默认值。假设以下 store.activitynull 时,解构会出错,

              const { activity: { today: { start_at: startAt } = {} } = {} } = store
              

              我们可能还是要这样写,

              const startAt = store && store.activity && store.activity.today ? store.activity.today.start_at : undefined
              

              在这篇文章有一种写法,

              const getPropValue = (obj, key) => {
                return key.split('.').reduce((o, x) => (o == undefined ? o : o[x]), obj)
              }
              
              const startAt = getPropValue(store, 'activity.today.start_at') // "1690090200"
              

              对于获取深层属性的情况,很多工具库都有提供现成的方法,感兴趣可看下实现。比如:

              后来,ES2020 增加了可选链(Optional Chains)语法 ?.,就可以这样去写了:

              const { province, city } = store?.address || {}
              
              const startAt = store?.activity?.today?.start_at
              

              当某个属性值为空值(nullishundefined or null),则短路返回 undefined

              最后

              解构赋值虽好,但不要贪杯哦!

              ]]>
              <![CDATA[用好 VS Code 快捷键]]> https://github.com/tofrankie/blog/issues/317 https://github.com/tofrankie/blog/issues/317 Wed, 12 Jul 2023 06:37:01 GMT 配图源自 Freepik

              工欲善其事必先利其器,用好快捷键一定程度上可提高编码效率。

              配图源自 Freepik

              工欲善其事必先利其器,用好快捷键一定程度上可提高编码效率。

              本文以 macOS 为例,部分快捷键组合方式与 Windows、Linux 平台可能略有差异

              快捷键有很多很多,但每个人常用的也就那些,无需强迫自己一次性记住,多用就会形成肌肉记忆。

              展开/收起目录:
              • 开始之前
              • 管理窗口
              • 切换面板
              • 切换侧栏
              • 搜索与替换
                • 搜索内容
                • 替换内容
                • 搜索文件
              • 编辑区
                • 移动光标
                • 选择文本
                • 移动行
                • 插入行
                • 操作行
                • 删除
                • 注释
                • 格式化
                • 折叠与展开代码
                • 保存
                • 撤销与恢复
              • 命令面板
                • 常用命令
                • 配置命令
                • 查看插件命令
              • 高阶用法
                • 场景一
                • 场景二
              • References

              开始之前

              在此之前,先弄清楚快捷键的增删改查,从左下角的「管理 - 键盘快捷方式」打开面板。

              将鼠标移至对应命令,然后右键可对增加、修改、删除、重置对应的键绑定。点击左侧 ICON 也可以增加或修改。查询则有以下方式任君选择:

              • 按关键词:输入关键词可以搜索命令、键绑定、当的内容,比如 查找findcmd f 等(中文查找需安装中文语言包)。
              • 按源分类:分为用户 @source:user、系统 @source:system、插件 @source:extension、默认 @source:default 四类。
              • 按录制键盘:先点击输入框右侧类似键盘的 ICON,再按下快捷键,可快速检索对应绑定键。

              除了修改键绑定之外,还可以修改 When 条件。

              部分控制键与符号映射如下:

              Keys Symbols
              Commad
              Control
              Shift
              Option

              一些会采用两组键组合的方式:先用「⌘ + K」记录第一组组合键,然后再按下其他键或组合键以记录第二组。这种习惯似乎源自 Emacs。比如「⌘ + K」和「⌘ + S」两组组合键来打开快捷键面板。

              管理窗口

              绑定键 描述 使用场景/说明
              ⌘ + , 打开设置 在 macOS 里几乎所有软件都是使用该快捷键打开偏好设置的。
              ⌘ + R 打开最近的文件 用于快速切换项目。
              ⌘ + ⇧ + N 新建窗口 使用 ⌘ + R 会覆盖当前窗口,如果不希望覆盖可用这个。
              ⌘ + ⇧ + P 显示命令面板 所有的命令在这里都能找到。
              ⌘ + K 和 ⌘ + W 关闭所有编辑器

              对于在新窗口打开项目,也可以用 code /path/to/project 来替代「⌘ + ⇧ + N」和「⌘ + R」的组合操作。但还是有点麻烦,为此特地做了一个 Alfred 插件(alfred-open-with-vscode),支持在指定目录范围内检索并打开项目,还能展示 VS Code 最近打开过的项目。

              对于重新加载窗口,可以先「⌘ + ⇧ + P」唤起命令面板,然后输入关键词「Reload Window」,再「Return」确认即可。类似插件更新、Lint 规则调整后没生效等场景,可通过重新加载解决。

              切换面板

              主要用于控制侧边面板、底部面板的显示与隐藏。

              绑定键 描述 使用场景/说明
              ⌘ + B 显示/隐藏侧栏
              ⌘ + J 显示/隐藏底部面板 可快速打开内置终端、调试控制台等。
              ⌃ + ` 显示/隐藏内置终端 与「⌘+ J」类似,它会定位至终端选项卡。

              如果是 108 键键盘,个人更多用「⌃ + `」。而 87 键的还要额外组合「fn」键才能唤起内置终端,此时更多用「⌘ + J」。

              切换侧栏

              默认情况下,侧栏有资源管理器、搜索、源代码管理、运行和调试、扩展。

              绑定键 描述 使用场景/说明
              ⌘ + ⇧ + F 切换至搜索 用于全局搜索。
              ⌘ + ⇧ + X 切换至插件 查看插件。
              ⌘ + ⇧ + E 切换至资源管理器
              ⌘ + ⇧ + D 切换至运行和调试
              ⌃ + ⇧ + G 切换至源代码管理

              有一些个人用得不算多。比如,切换至资源管理器查找文件场景,我更多用的是「⌘ + P」更快地切换文件。

              关于 「⌘ + ⇧ + E」有可能会与搜狗输入法冲突,后者优先级更高,可在「搜狗输入法 - 偏好设置 - 按键 - 快捷功能 - 英文输入法」处禁用。

              搜索与替换

              包括搜索文件(内容)、替换内容等。

              搜索内容

              绑定键 描述 使用场景/说明
              ⌘ + ⇧ + F 切换至搜索 全局搜索。
              ⌘ + F 文件内搜索

              替换内容

              绑定键 描述 使用场景/说明
              ⌘ + ⇧ + H 切换至搜索栏并展开替换 全局替换。相比「⌘ + ⇧ + F」,它会自动展示替换输入框。
              ⌘ + ⇧ + L 选择所有匹配项 当前文件范围。是「⌘ + F」加上区分大小写和全字匹配,然后替换的一种更快的方案。
              F2 重命名符合 可以对定义处、引用处的属性/方法进行同步修改,贼方便。

              假设当前文件有 setStoragesetStorageSync两个方法,但不小心把 setStorage 错写为 setStroage 了,此时可以将光标移至 setStroage 中间,按下「⌘ + ⇧ + L」快速选中当前文件所有匹配的文本,可以进行批量修正。

              假设上述文件 setStroage 是引用自其他文件,更好的操作可能是右键选择「重命名符号 F2」的方式进行修正,它可以对定义处的方法名称进行同步修改。

              请注意,重命名符合与作用域有关,这点与「⌘ + ⇧ + L」是有区别的。假设函数内外各定义了一个 setStroage 方法,如果对函数内的进行重命名,它只会修改函数内的,而函数外的则不会修改。

              搜索文件

              对于文件数多、目录层级深的项目,如果一个一个地在资源管理器面板中展开查找,显得有点慢。

              绑定键 描述 使用场景/说明
              ⌘ + P 转到文件 用于快速打开某文件。

              编辑区

              平常开发中,主要都聚焦在文件编辑区中,掌握一些常用的快捷键很有必要。

              移动光标

              列举一些常见的移动光标场景:

              • 以单词为单位进行左移、右移
              • 移动至行首、行尾
              • 移动至文件开头、末尾
              • 移动至代码块起始、结束
              • 移动至指定行、列
              • 返回至上一个位置、前进至下一个位置
              • ......

              常规

              如果用方向键一个一个地移动,对于一些较长的变量名、属性、方法等,显然很不友好。如果用鼠标,要经历离开键盘 → 滑动鼠标 → 回到键盘的过程,也烦。仅用「左/右方向键」来移动光标,是以「单个字符」为颗粒度进行移动的。如果以「Option + 左/右方向键」组合的话,其颗粒度变为「单词」,以实现快速移动的目的。

              绑定键 描述 使用场景/说明
              ⌥ + ← 以「单词」为颗粒度向左移动 对于变量、属性、方法名称过长的情况,特方便。
              ⌥ + → 以「单词」为颗粒度向右移动 同上。
              ⌘ + ← 移动至行首
              ⌘ + → 移动至行尾
              ⌘ + ↑ 移动至文件开头
              ⌘ + ↓ 移动至文件末尾
              PageUp 向上翻页 一次移动约 25 行。
              PageDown 向下翻页 同上。

              如果跳到行首/行尾,然后 Return 以在上方/下方插入空行,那么用「⌘ + Return」和「⌘ + ⇧ + Return」或许更快哦!

              对于移动至行首/行尾,还可以使用「Home」或「End」键。

              如果有些键盘没有 PageUp/PageDown,可以尝试用「fn + ↑」和「fn + ↓」进行翻页。

              代码块相关

              有时,我们需要快速切换至代码块的起始、结束位置。

              绑定键 描述 使用场景/说明
              ⌘ + ⇧ + \ 转到括号 支持 {}() 两类括号,可快速切换至就近括号的开头/结尾位置。

              支持多种语言,比如,JavaScript 的函数、条件语句,CSS 的选择器等。

              跳转相关

              绑定键 描述 使用场景/说明
              ⌃ + G 转到行、列 用于调试排错等场景,可以根据 Error 堆栈信息中的行列快速锁定源文件位置。
              ⌃ + - 返回 比如,使用「⌘ + Click」或 「F12」跳转至某个函数定义的地方,再返回时就特别方便。
              ⌃ + ⇧ + - 前进

              跳转至指定行列,其形式如 137:15,表示跳转至第 137 行、第 15 列。若不需要指定列,可省略列。

              返回/前进支持跨文件之间切换。比如,JavaScript 中 A.js 使用了 B.js 文件中的 updateUserInfo() 方法,假设要看该函数具体实现过程,我们可以按住 ⌘ 键点击跳转至函数定义的位置,看完后要原路返回的话,只需要「⌃ + -」就能返回 A.js 引用的位置。

              多光标相关

              创建多光标的方式有以下两种:

              • 按住「⌥」键,然后鼠标在对应位置点击。
              • 使用快捷键。
              绑定键 描述 使用场景/说明
              ⌘ + ⌥ + ↑ 在上方添加光标
              ⌘ + ⌥ + ↓ 在下方添加光标
              ⌥ + ⇧ + I 在行尾添加光标 前提是要选中多行。

              撤销光标,可以使用「⌘ + U」,也可按住「⌥」在对应位置点击以取消。

              其实多光标最终目的是批量操作,应优先考虑批量选择文本的快捷方式。若不满足,再使用多光标。

              我是觉得「⌥ + ⇧ + I」稍微有点鸡肋的。就是选中多行时,最后一行选中的文本一定要是行尾,否则产生的多光标中的最后一个不会在行尾。但一般用鼠标拖动以选中多行时,会从末尾处开始或结束,也问题不大。

              举个例子,假设要将 data 批量修改为 prize

              使用鼠标是这样子的 👇,还要小心翼翼地,生怕点错位置。

              如果列对齐,使用「⌘ + ⌥ + ↓」会好一些。但不对齐也能用,比如先「⌘ + ⌥ + ↓」创建 4 个光标,然后使用「⌘ + →」将所有光标批量移至行尾,再操作。这些都是可以自由组合的,按需使用即可。

              在录屏或屏幕共享时,可以切换至「屏幕模式」以展示当前按下的快捷键,方法是:先「⌘ + ⇧ + P」,再输入「Toggle Screencast Mode」,关闭同理。

              利用「⌘ + D」是这样的 👇,对于示例中,列不对齐的场景更加方便。

              利用「⌘ + ⇧ + L」是这样的 👇,更快。

              关于「⌘ + D」的介绍在选择文本章节,也可先跳过再回来看。

              选择文本

              前面介绍了光标移动,通常是「⌘/⌥ + 方向键」的形式。只要再加上「Shift」键就能快速选择「光标起始位置到下一个位置的文本」。

              绑定键 描述 使用场景/说明
              ⌥ + ⇧ + ← 选择起始位置至左侧单词边界之间的文本
              ⌥ + ⇧ + → 选择起始位置至右侧单词边界之间的文本
              ⌘ + ⇧ + ← 选择起始位置至行首之间的文本
              ⌘ + ⇧ + → 选择起始位置至行首之间的文本
              ⌘ + ⇧ + ↑ 选择起始位置至文件开头之间的文本
              ⌘ + ⇧ + ↓ 选择起始位置至文件开头之间的文本
              ⌘ + D 将下一个查找匹配项添加到选择 多次点击可以选择多个匹配项。

              对于「⌥ + ⇧ + ←」或「⌥ + ⇧ + →」,可连续多次以选择更多的文本。

              假设光标停在 updateUserInfoeU 中间,如何快速选择 updateUserInfo 文本?

              async function doSomething() {
                await updateUserInfo(data)
                // ...
              }
              

              方法很多,比如:

              • 先「⌥ + →」移动至文本右侧,然后「⌥ + ⇧ + ←」选中整个文本。
              • 先「⌥ + ⇧ + →」选中光标右侧文本,然后「⌥ + ⇧ + ←」选中整个文本。
              • 直接「⌘ + D」选中。
              • 直接「⌃ + ⇧ + →」选中。
              • ......
              绑定键 描述 使用场景/说明
              ⌃ + ⇧ + ← 收起选择
              ⌃ + ⇧ + → 展开选择 用来选择 URL 特方便。

              可用来快速收缩/扩展选中文本,也是以单词为颗粒度的,看图感受一下。

              如果单纯想要拷贝当前行,无需选中,直接「⌘ + C」即可。但不爽的是,拷贝的文本中包含前导缩进空格和尾随换行符。

              移动行

              移动行还是很常见的,比如用来对调两行之间的位置。

              绑定键 描述 使用场景/说明
              ⌥ + ↑ 向上移动行 除了单行,还支持选中多行进行移动。
              ⌥ + ↓ 向上移动行
              ⌘ + [ 向左行缩进 当光标不在行首时,好用。
              ⌘ + ] 向右行缩进

              对于向左/向右行缩进,实际中更多的是开启 "editor.formatOnSave": true 在保存时自动格式化。

              插入行

              你添加新行的方式,还是先移动光标至行尾吗?

              绑定键 描述 使用场景/说明
              ⌘ + Return 在下方插入行
              ⌘ + ⇧ + Return 在上方插入行

              部分键盘又称为「Enter」键。

              操作行

              绑定键 描述 使用场景/说明
              ⌃ + J 合并行 比如链式调用时,可以选中多行然后快捷键合并为一行
              ⌥ + ⇧ + ↓ 向下复制行
              ⌥ + ⇧ + ↑ 向上复制行

              向上/下复制行,看起来是一样的,区别在于最终光标停留在哪而已。

              删除

              绑定键 描述 使用场景/说明
              ⌘ + ⇧ + K 删除所在行 支持选中多行删除。
              ⌘ + Delete 删除光标左侧内容
              ⌘ + fn + Delete 删除光标右侧内容

              其中「⌘ + fn + Delete」实质上是「⌘ + Backspace」。

              注释

              其实这个应该不用多说。

              绑定键 描述 使用场景/说明
              ⌘ + / 行注释 支持选中多行操作。
              ⌥ + ⇧ + A 块注释

              块注释个人用得不多。

              折叠与展开代码

              绑定键 描述 使用场景/说明
              ⌘ + ⌥ + [ 折叠所在区域的代码块 多次可向上一级折叠。
              ⌘ + ⌥ + ] 展开所在区域的代码块
              ⌘ + K 和 ⌘ + - 折叠除所在区域之外的所有区域
              ⌘ + K 和 ⌘ + J 全部展开
              ⌘ + K 和 ⌘ + 0 全部折叠

              全部折叠其实不太好用。

              格式化

              绑定键 描述 使用场景/说明
              ⌥ + ⇧ + F 格式化文档
              ⌘ + K 和 ⌘ + F 格式化选中内容

              本人几乎不用,会开启 "editor.formatOnSave": true 在保存时自动格式化。

              保存

              至于「⌘ + S」就不说了。

              绑定键 描述 使用场景/说明
              ⌘ + ⌥ + S 保存所有 有时候做小程序开发时,减少重新编译次数。
              ⌘ + K 和 S 保存但不格式化 有时候修改某些文件,但又不希望在保存时自动格式化,特别好用。

              对于「保存但不格式化」,个人更喜欢在命令面板键入「Save without Formatting」来处理,因为命令面板会记录最近使用的命令。

              撤销与恢复

              其实这个也不用说的。

              绑定键 描述 使用场景/说明
              ⌘ + Z 撤销 也支持全局替换操作的撤销。
              ⌘ + ⇧ + Z 恢复

              以上就是编辑区中常见的快捷键,它们之间是可以自由组合的,可实现很多快捷操作。

              命令面板

              其实命令面板是非常好用的,除了键入时自动联想外,它会记录最近使用的命令,使用效率很高。

              使用「⌘ + ⇧ + P」或「⌘ + P」可唤起命令面板。后者在输入框内键入 > 就能切换至命令模式。同理,前者在输入框去掉前导的 > 可切换至文件搜索模式。

              以下介绍一些本人常用的插件及命令。

              常用命令

              Command Title Description System/Extension
              Reload Window 重新加载窗口 内置
              Close All Editors 关闭所有编辑器 内置
              Save without Formatting... 保存但不格式化 内置
              Open User Settings (JSON) 打开用户设置(JSON) 内置
              Change Case upper/low/camel/snake/... 更改命名方式 change-case
              Open/Configure Github Blogger 写博客 Github Blogger
              Toggle Screencast Mode 切换屏幕模式 内置
              Configure Display Language 配置显示语言 内置
              Install/Uninstall 'code' command in PATH 安装/卸载 code 命令 内置
              Format Document... 使用...格式化文档 内置
              ......

              配置命令

              我们可以给这些命令添加、修改、移除或重置快捷键,方式如下:

              查看插件命令

              第三方插件所定义的插件,可在插件页面中查看。

              高阶用法

              我们从实际场景出发。

              场景一

              假设我们要在本地调试,想在 condition1 条件中返回 STATUS_TEST

              // From
              function getStatus() {
                if (condition1) return STATUS_FIRST
                if (condition2) return STATUS_SECOND
                if (condition3) return STATUS_THIRD
                return STATUS_DEFAULT
              }
              
              // To
              function getStatus() {
                // if (condition1) return STATUS_FIRST
                if (condition1) return STATUS_TEST
                if (condition2) return STATUS_SECOND
                if (condition3) return STATUS_THIRD
                return STATUS_DEFAULT
              }
              

              可能需要经过以下几步操作:

              1. 使用「⌥ + ⇧ + ↓」向下复制行;
              2. 移动光标至上一行;
              3. 使用「⌘ + /」注释原始行;
              4. 移动光标至下一行;
              5. 然后进行对应修改。

              现在,我们可以针对这种场景定制一个自定义命令:

              {
                "key": "ctrl+alt+c",
                "command": "runCommands",
                "args": {
                  "commands": [
                    "editor.action.copyLinesDownAction",
                    "cursorUp",
                    "editor.action.addCommentLine",
                    "cursorDown"
                  ]
                }
              }
              

              上面定义了一个「⌃ + ⌥ + C」的快捷键,它执行的命令为 runCommands,该命令接收的参数为 args,在参数中又可以定义一个命令列表,以实现多命令执行。

              其中 argscommand 接收的参数。同理,在 args.commands 的每个命令,都可以定义为类似 {"command": "xxx", "args": {"parm1": "value1"}} 的形式。

              对应的 Command ID 可以在快捷键面板找到,右键可复制命令 ID。

              现在,我们只要用一个快捷键就能实现上述 1 ~ 4 的步骤。

              场景二

              不知道你有没有遇到过,如果文件 URL 很长,且超出一屏时,使用拖动鼠标选择 URL 时稍有不慎偏离本行的话,基本上就要重新拖选了。

              有人可能会说,给格式化程序设置一个默认的 printWidth 就改善了。但由于某些原因,该项目的 printWidth 很大,往往很容易超出一屏。

              举个例子,其中 URL 在靠右一端:

              <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 622" preserveAspectRatio="xMidYMin meet" style="width: 100%; background-size: cover; background-position: top center; background-repeat: no-repeat; background-image: url(https://cloud-minapp-45362.cloud.ifanrusercontent.com/10eb615c88e3709c757f22a50c18f30a.gif)"></svg>
              

              如何快速替换这个 URL 呢?

              1. 使用「⌃ + ⇧ + L」展开选择两次,以选中 URL。
              2. 进行粘贴(剪贴板中已拷贝将要替换的 URL)。
              {
                "key": "ctrl+alt+d",
                "command": "runCommands",
                "args": {
                  "commands": [
                    "editor.action.smartSelect.expand",
                    "editor.action.smartSelect.expand",
                    "editor.action.clipboardPasteAction"
                  ]
                }
              }
              

              现在只要按下「⌃ + ⌥ + D」就可以快速替换了。

              上述自定义的命令,添加到 keybindings.json 文件中,入口在这里 👇。

              更多细节请看 Runing multiple commands

              以上仅仅是抛砖引玉,只讲了很少一部分,大家自由发挥想象力吧。

              References

              ]]>
              <![CDATA[字符与字符集]]> https://github.com/tofrankie/blog/issues/316 https://github.com/tofrankie/blog/issues/316 Sun, 02 Jul 2023 04:08:21 GMT 计算机存储单位

              在此之前,有必要简单了解下计算机中常用的存储单位。

              比特

              比特」是计算机科学中的概念,是数据存储和传输的最小单位,有且仅有 01 两个值。音译自 bit,]]> 计算机存储单位

              在此之前,有必要简单了解下计算机中常用的存储单位。

              比特

              比特」是计算机科学中的概念,是数据存储和传输的最小单位,有且仅有 01 两个值。音译自 bit,是 binary digit 的简写。

              这样的 01 称为一位,常用 1b1bit 表示(小写字母)。一位可以表示两种状态。比如,用一位表示开关的状态,0 表示打开,1 表示关闭。

              既然 1bit 可以表示两种状态,那么多个 bit 组合,便可以表示 N 种状态。我们在计算机中看到的数字、字母、中文、符号、图片以及音频等内容都是由多个二进制数 01 组成的,它们就是以二进制数的形式存储在存储介质上的。

              字节

              由于 1bit 只能表示两种状态,为了可以更方便地表示多种状态,在计算机中还常用「字节」作为存储单位。一个字节用 1B1Byte 表示(大写字母),由 8bit 组成,可以表示 256( $2^{8}$ )种不同的状态。

              字节和比特的换算关系为 1 Byte = 8 bit。

              字符与字符集

              字符」是相对于人类自然语言来说的,是一种更友好的表示方式,可以是一个阿拉伯数字、英文字母、汉字、片假名、标点符号等。除此之外,还有像表示回车、换行、制表等控制字符。「字符集」则顾名思义,是由多个字符组成的集合。

              我们知道,计算机中存储的所有东西本质上都是 01 组成的。那么,英文字母 A 在计算机上存储的二进制数是 01000001,是 01000010,还是其他呢?理论上都是可以的,但为了统一:

              A 对应为 01000001 B 对应为 01000010 C 对应为 01000011 .....

              这些映射关系由字符集规定好的。也就是说,只要遵循同一字符集,那么字符 A 在存储时总是 01000001。相反地,计算机读取到二进制数 01000001 时,总会“翻译”为 A

              A01000001 的过程称为「字符编码」,把字符集中的字符编码为指定集合中某一对象,以便计算机存储、传输和转换它们。反之,则为解码。

              常见字符集

              计算机是美国人发明的,他们的语言体系中只有数字、英文字母以及标点符号,因此他们制定的 ASCII 字符集,只包含数字、英文字母、标点符号以及一些控制字符。在当时,这对他们来说是足够的。

              但随着计算机在全世界流行,ASCII 字符集无法正确地表示其他自然语言的字符,比如中文、日文等。为了适应各类语言,衍生出一系列的字符集,比如我国的 GB2312 和 GBK 字符集。

              然而,很多字符集是基于 ASCII 的,且不兼容其他字符集,无法在全世界范围内使用。随着国际化流行,这样下去肯定不行啊,于是一个非营利组织 Unicode 联盟站了出来,扬言要制定一套适用于多国语言的字符转换标准,积极与 ISO、W3C、ECMA 等各标准制定组织合作,并取得了成功,对国际化和本地化产生重大影响。

              ASCII 字符集

              ASCII」全称是美国标准信息交换代码(American Standard Code for Information Interchange, ASCII),由美国国家标准协会(American Nation Standard Institute, ANSI)制定。

              在标准 ASCII 中,每个字符用一个字节表示,即 8bit。但其最高位(首位)是 0,并没有被利用上,可表示 128 个字符。

              • 编号范围为 0 ~ 31 和 127 的是不可见的控制字符,共 33 个字符。比如回车符、换行符、退格符等。
              • 编号范围为 32 ~ 126 是可显示字符,共 95 个字符。比如空格、A、B 等。

              其优点是每个字符只占用一个字节,但缺点也非常明显,只能显示大小写英文字母、数字以及标点符号,对于汉字、西欧语言显示无能为力。

              EASCII 字符集

              由于 ASCII 最高位没有利用上,只表示 0x00 ~ 0x7F(十进制 0 ~ 127)共 128 个字符。于是其他国家盯上了 0x80 ~ 0xFF(十进制 128 ~ 255)这 128 个字符,并扩展出一系列的字符集。

              EASCII」(Extended ASCII)就是其中之一,兼容 ASCII 的扩展字符集。尽管它跟 ASCII 一样,都是单字节编码,但可表示 256 个字符,比 ASCII 多了一倍。扩展部分包括表格符号、计算符号、希腊字母和特殊的拉丁符号。

              基本上已被淘汰,其标准化程度也没有 ISO 8859 好。

              ISO 8859 系列字符集

              ISO/IEC 8859」是由国际标准化组织(ISO)和国际电工委员会(IEC)联合制定的一系列字符集标准。同样是扩展自 ASCII 字符集,单字节编码,统一了此前各国各语言单独编码的局面。

              目前,ISO 8859 系列包含了以下 15 个字符集,其中 ISO 8859-12 已废除(更多)。

              • ISO/IEC 8859-1(Latin-1)- 西欧语言
              • ISO/IEC 8859-2(Latin-2)- 中欧语言
              • ISO/IEC 8859-3(Latin-3)- 南欧语言。世界语也可用此字符集显示。
              • ISO/IEC 8859-4(Latin-4)- 北欧语言
              • ISO/IEC 8859-5(Cyrillic)- 斯拉夫语言
              • ISO/IEC 8859-6(Arabic)- 阿拉伯语
              • ISO/IEC 8859-7(Greek)- 希腊语
              • ISO/IEC 8859-8(Hebrew)- 希伯来语(视觉顺序)
                • ISO 8859-8-I - 希伯来语(逻辑顺序)
              • ISO/IEC 8859-9(Latin-5 或 Turkish)- 它把 Latin-1 的冰岛语字母换走,加入土耳其语字母。
              • ISO/IEC 8859-10(Latin-6 或 Nordic)- 北日耳曼语支,用来代替 Latin-4。
              • ISO/IEC 8859-11(Thai)- 泰语,从泰国的 TIS620 标准字集演化而来。
              • ISO/IEC 8859-13(Latin-7 或 Baltic Rim)- 波罗的语族
              • ISO/IEC 8859-14(Latin-8 或 Celtic)- 凯尔特语族
              • ISO/IEC 8859-15(Latin-9)- 西欧语言,加入Latin-1欠缺的芬兰语字母和大写法语重音字母,以及欧元(€)符号。
              • ISO/IEC 8859-16(Latin-10)- 东南欧语言。主要供罗马尼亚语使用,并加入欧元符号。

              MySQL 8.0 之前的默认字符集 Latin-1 就是 ISO/IEC 8859-1。

              这系列字符集规定,每个字符集最多可定义 96 个字母或符号,在 0xA0 ~ 0xFF(十进制 160 ~ 255)范围内。其中 0xA0 位置是不换行空格(Non-breaking space)。

              目前这份标准也不再更新了。

              USC 通用字符集

              通用字符集」(Universal Character Set, UCS)是由 ISO 制定的 ISO 10646 标准所定义的标准字符集。原先维护 ISO 8859 标准的工作组已解散,目前该委员会另一工作组致力于 ISO 10646 的开发。

              通用字符集包含了已知的所有字符,比如拉丁字母、希腊字母、阿拉伯字母、汉字、假名以及大量的符号等。它给每个字符赋予一个正式的名字,在字符的十六进制数前面加上 U+ 表示,比如: U+0041 表示字符 A

              历史上,制定的 ISO 10646 标准的工作组和 Unicode 联盟是两个独立的组织,起初它们各自制定的标准并不相同,后来两个项目的组织者意识到世界上并不需要两个不兼容的字符集,于是两者进行合作,从 Unicode 2.0 起,Unicode 采用了与 ISO 10646 相同的字库和字码。在两个标准中,所有字符都在相同的位置并且具有相同的名字。

              但 Unicode 使用更为广泛,有一统江湖的意思。

              GB 2312、GBK 和 GB 18030 字符集

              在西方语言体系中,使用单字节编码可以表示 256 个字符,就算加上其他一些标点符号,对于他们来说是足够的。但对于我国的汉字来说显然是不够的。

              就 2013 年国务院公布的《通用规范汉字表》来说,其中收录了 8105 个字,其中 3500 个常用字,3000 个次常用字,1605 个用于满足信息化时代和专门领域用字需求的字。除简体字之外,还有繁体字、生僻字等。在现代汉语词典第七版收录了约 12000 个汉字。

              GB 2312 字符集

              GB 2312」又称为 GB/T 2312、GB/T 2312-80 或 GB/T 2312-1980,是由国家标准总局于 1980 年发布的信息交换用汉字编码字符集(基本集)。该字符集适用于中国大陆、新加坡等地。

              兼容 ASCII 字符集,但不兼容 ISO 8859 系列字符集。

              冷知识,其中「GB」是「国家标准,国标」二字的拼音首字母:

              • GB 表示强制性国家标准。
              • GB/T 表示推荐性国家标准。
              • GB/Z 表示指导性技术文件。

              GB 2312-1980 自 2017 年 3 月 23 日起,转为推荐性标准,编号改为 GB/T 2312-1980。

              GB 2312 标准收录了包含汉字、数字、序号、拉丁字母、希腊字母、俄文字母、日文假名等 7445 个图形字符,其中有 6773 个。基本满足了汉字使用场景。

              对于人名、地名、古汉语等方面出现的罕见字、繁体字,GB 2312 并不能处理。后来衍生出 GBK 等汉字字符集。

              GBK 字符集

              GBK」意为国家标准扩展,是国、标、扩三字拼音首字母的缩写。由国家技术监督局标准化司和电子工业部科技与质量监督司于 1995 年 12 月 15 日公布,它是技术规范指导性文件,不属于国家标准。由于计算机的普及,GB 2313 收录的字符不够用,于是 GBK 字符集出现了,后者共收录了 21886 个汉字和符号。

              GBK 向下完全兼容 GB 2312 编码。

              GBK 与 GB 2312 一样,采用了双字节编码方式,但编码方式不同,因此它们可存储的字符数量并不相同。

              • 为了兼容 ASCII 码,GB 2313 的汉字两个字节都是从 128 位之后开始。也就是说,如果连续两字节都大于 128(十进制数),说明它就是一个中文字符。它最多能存储 16384( $2^{14}$ )个汉字。
              • 而 GBK 的做法是第一个字节要求大于 128 位,而第二位可以小于 128,所以它最多可以存储 32640( $2^{15}$ - 128 )个汉字。

              GB 18030 字符集

              GB 18030」是由国家质量技术监督局于 2000 年 3 月 17 日推出的标准,以取代 GBK。

              GB 18030 完全兼容 GB 2313,基本兼容 GBK,并支持 Unicode 所有码位。

              GB 18030 采用变长多字节编码,每个字符可以由 1 个、2 个或 4 个字节组成,编码空间庞大,最多可定义 160 多万个字符。

              Unicode 字符集

              未完待续...

              ]]>
              <![CDATA[文件保存时 CSS 属性自动排序]]> https://github.com/tofrankie/blog/issues/315 https://github.com/tofrankie/blog/issues/315 Thu, 29 Jun 2023 03:35:46 GMT 配图源自 Freepik

              背景

              在使用 VS Code 编写代码时,通常会开启 配图源自 Freepik

              背景

              在使用 VS Code 编写代码时,通常会开启 editor.formatOnSave 选项以在保存时格式化文件。

              {
                "editor.formatOnSave": true
              }
              

              一般来说,对于大多数语言来说,VS Code 都有对应的内置格式化程序(formatter)。它们通常只会处理缩进、折行、前导尾随空格等操作。

              如果内置的不够用,可以通过安装第三方插件以实现更多的可能。比如,将颜色值转为小写、改进 JavaScript 写法等都可以在保存文件时自动处理,具体取决于插件实现。

              由于语言众多,格式化插件也很多,还可以支持配置默认的格式化程序。这个可以在文件编辑区右键选择「使用 ... 格式化」进行配置。比如:

              {
                "[html]": {
                  "editor.defaultFormatter": "vscode.html-language-features"
                },
                "[css]": {
                  "editor.defaultFormatter": "stylelint.vscode-stylelint"
                },
                "[javascript]": {
                  "editor.defaultFormatter": "esbenp.prettier-vscode"
                }
              }
              

              现在很多工程化项目都会配置格式化处理,也可在 Git 的 pre-commit 勾子中跑一遍处理脚本,以确保提交到仓库的代码格式是统一的,尽管大家使用的编辑器可能有差异。

              虽说如此,仍然有必要我们的编辑器配置一些格式化程序,以便于在编写一些 Snippet、Demo 等的时候,也有着较为规范、格式统一的代码。尤其是像我这种强迫症患者。

              个人用得最多的是 Prettier,主流编辑器都有对应插件,支持 JavaScript、HTML、CSS 以及对应变体等 Web 前端相关的语言。

              Prettier 是固执己见的(opinionated),提供的选项很少,且在可见的未来可能不会添加更多新功能。原因请看 Option Philosophy

              诉求

              目前我想要的是:在保存文件时,按一定的规则自动对 CSS 属性进行排序。此时 Prettier 就无能为力了。

              对于 CSS 属性声明顺序,最初是因为看了 Code Guide by @AlloyTeam 的规范,目前里面提及的属性顺序已过时,最后维护时间已经是 2015 年了。有一句话我很喜欢(源自):

              虽然这些细节是小事,不会有体验或者性能上的优化,但是却体现了一个 coder 和团队的专业程度。

              排序的好处无非就是快速看得出元素盒子所占的空间,是否有重复声明的属性等。

              目前,本人所参考的是 Code Guide @mdo 规范,对应的 Stylelint 插件是 Bootstrap 在用的 stylelint-config-recess-order,维护较为积极,也能紧跟最新的 CSS 属性,它们会按照以下分组排序(详见):

              • Positioning
              • Box model
              • Typographic
              • Visual
              • Misc

              与 Declaration order 相关的插件还有以下几个,有些已经好几年没更新了,仅供参考。

              以上这些集成到项目里面是非常好用的,但对于单个文件或者工程化程度不高的项目,它们没办法施展才华。原因是它们都是基于 Stylelint 的插件 stylelint-order 实现的,而对应 VS Code 插件并不支持配置插件。

              因此,本文会借助一个名为 CSScomb.js 的格式化程序,它的主要功能便是「属性排序」,对应的 VS Code 插件是 vscode-csscomb

              CSScomb.js 最后更新已经是 2019 年,看起来似乎不再维护更新了,对应插件也因此不再更新。

              尽管如此,它目前仍然足够好用。

              配置

              其中 vscode-csscomb 插件配置项有以下几个:

              此处不展开说明,具体可看文档:

              我的 VS Code 配置如下:

              {
                "csscomb.formatOnSave": true,
                "csscomb.syntaxAssociations": {
                  "*.wxss": "css",
                  "*.acss": "css"
                },
                "csscomb.ignoreFilesOnSave": ["node_modules/**"],
                "csscomb.preset": "~/Library/Mobile Documents/com~apple~CloudDocs/Terminal/csscomb/preset-custom.json",
              }
              

              其中,对于像小程序等 WXSS 样式文件,可以在 csscomb.syntaxAssociations 进行关联,使其当成 CSS 一样去处理。

              由于 csscomb.preset 配置项非常地长(sort-order 造成的),也便于多设备同步,我选择将其存放到 iCloud Drive 目录。如下:

              {
                "exclude": [
                  ".git/**",
                  "node_modules/**",
                  "bower_components/**"
                ],
                "verbose": true,
                "always-semicolon": true,
                "block-indent": 2,
                "color-case": "lower",
                "color-shorthand": true,
                "element-case": "lower",
                "eof-newline": true,
                "leading-zero": true,
                "quotes": "single",
                "remove-empty-rulesets": false,
                "space-before-colon": 0,
                "space-after-colon": 1,
                "space-before-combinator": 1,
                "space-after-combinator": 1,
                "space-before-opening-brace": 1,
                "space-after-opening-brace": "\n",
                "space-before-closing-brace": "\n",
                "space-before-selector-delimiter": 0,
                "space-after-selector-delimiter": "\n",
                "space-between-declarations": "\n ",
                "strip-spaces": true,
                "unitless-zero": true,
                "vendor-prefix-align": false,
                "sort-order": []
              }
              

              以上都是一些比较常规的配置,其中一些也是为了跟 Prettier 保持一致而设置的。另外,为了避免影响篇幅以上 sort-order 配置省略了,可参考这里

              生成 sort-order

              由于 CSS 在不断的发展,为了让 sort-order 跟上最新标准,这里使用 stylelint-config-recess-order 来生成。

              脚本很简单,仅供参考:

              const path = require('path')
              const fs = require('fs')
              const propertyGroups = require('stylelint-config-recess-order/groups')
              
              const CSSCOMB_SORT_ORDER_FILE_PATH = path.resolve(__dirname, '../csscomb/sort-order.json')
              const CSSCOMB_PRESET_BASE_FILE_PATH = path.resolve(__dirname, '../csscomb/preset-base.json')
              const CSSCOMB_PRESET_CUSTOM_FILE_PATH = path.resolve(__dirname, '../csscomb/preset-custom.json')
              
              ;(function main() {
                // generate sort-order
                const sortOrder = []
                propertyGroups.forEach(group => {
                  if (!group?.properties?.length) return
                  sortOrder.push(...group.properties)
                })
              
                // write sort-order.json
                let preset = { 'sort-order': sortOrder }
                const content = JSON.stringify(preset, null, 2)
                fs.writeFileSync(CSSCOMB_SORT_ORDER_FILE_PATH, content, 'utf8')
              
                // read preset-base.json
                const presetBaseContent = fs.readFileSync(CSSCOMB_PRESET_BASE_FILE_PATH, 'utf8')
                const presetBase = JSON.parse(presetBaseContent)
              
                // write preset.json
                preset = {
                  ...presetBase,
                  ...preset,
                }
                const presetContent = JSON.stringify(preset, null, 2)
                fs.writeFileSync(CSSCOMB_PRESET_CUSTOM_FILE_PATH, presetContent, 'utf8')
              })()
              

              其中 preset-base.json 就是上一章节 csscomb.preset 所贴出来的内容,除了 sort-order 选项之外,其他基本上是不变的。其中 sort-order.json 只是本地的一份存档,可要可不要。最后生成的 preset-custom.json 文件才是供 vscode-csscomb 使用的配置。

              这个脚本生成的过程,我是将它存放在 iCloud Drive 中的。比如,今天写下文章的时候我发现 stylelint-config-recess-order 可更新,只要更新下依赖,然后执行 NPM Script 就 OK 了。

              关于 node_modules 同步问题

              以上方式,势必在本地产生一个 node_modules 目录,但我们不希望里面成千上万的文件在 iCloud Drive 中同步。

              如果希望 iCloud Drive 上某个目录不同步,需在目录名称后加上 .nosync

              但是,如果将 node_modules 修改为 node_modules.nosync,那么在执行脚本的时候,就会因为找不到 node_modules 而出错。这里可参考 nosync-icloud 的解决方案。

              原理大致是:创建一个名为 node_modules 的软链接至 node_modules.nosync 目录,而软链接文件内容只是存放一个文件地址,文件大小可忽略,所以它同步到 iCloud Drive 也问题不大。当脚本执行,在查找 node_modules 模块时,会指向真实的目录,所以也没问题。

              参考链接

              ]]>
              <![CDATA[简单易用的图片压缩小工具]]> https://github.com/tofrankie/blog/issues/314 https://github.com/tofrankie/blog/issues/314 Thu, 29 Jun 2023 01:22:07 GMT 配图源自 Freepik

              背景

              对于前端开发来说,图片压缩是很常见的场景。网上各类压缩]]> 配图源自 Freepik

              背景

              对于前端开发来说,图片压缩是很常见的场景。网上各类压缩工具众多,如果对图片压缩没有很高、很精细的要求,那么 TinyPNG 是一个不错的选择。

              最直接的使用方法就是:打开其官网、上传本地图片、再下载到本地。

              是的,我觉着有点麻烦。如果只是偶尔、一两张图片的话还好。

              本文推荐一个 CLI 工具:tinypng-cli(npm package),可省去手动上传与下载的过程,且没有最大 5MB 限制。

              安装

              全局安装:

              $ npm i tinypng-cli -g
              

              其 CLI 命令就是 tinypng

              使用之前,需前往 TinyPNG Developer API 申请一个 API key,在下方输入 Full name 和 Email 即可获取。

              每月有 500 个「免费」的压缩次数 👍🏻(于我而言足矣)。若有需要,可以前往 TinyPNG API Dashboard 升级付费订阅。

              获取到 API key 之后,我们将其写入到用户目录下的 .tinypng 文件中。

              $ echo <your-api-key> > ~/.tinypng
              

              如果不嫌麻烦,也可以不写入该文件,每次执行该命令时传入 -k 参数来指定 API key。比如:

              $ tinypng /path/to/file.png -k <your-api-key>
              

              使用

              用法很简单,支持单个或多个的文件或目录,也可递归遍历子目录。 如果是目录的话,会自动查找目录下的所有图片。

              需要注意的是,图片压缩后会覆盖原文件

              # 1️⃣ 处理当前目录(其中 `.` 可省略)
              $ tinypng .
              
              # 2️⃣ 处理单个目录
              $ tinypng /path/to/image-dir
              
              # 3️⃣ 处理多个目录
              $ tinypng /path/to/image-dir-1 /path/to/image-dir-2
              
              # 4️⃣ 处理单个图片
              $ tinypng /path/to/image.png
              
              # 5️⃣ 处理多个图片
              $ tinypng /path/to/image-1.png /path/to/image-2.png
              
              # 6️⃣ 处理指定目录及其子目录的所有图片(指定 `-r` 参数)
              $ tinypng /path/to/image-dir -r
              

              还可以指定 --width--height 参数以调整图片大小。但对我来说,这个使用场景很少。

              更多请看 tinypng-clitinypng -h 查看。

              $ tinypng -h
              Usage
                tinypng <path>
              
              Example
                tinypng .
                tinypng assets/img
                tinypng assets/img/test.png
                tinypng assets/img/test.jpg
              
              Options
                -k, --key        Provide an API key
                -r, --recursive  Walk given directory recursively
                --width          Resize an image to a specified width
                --height         Resize an image to a specified height
                -v, --version    Show installed version
                -h, --help       Show help
              

              The end.

              ]]>
              <![CDATA[细谈空白符]]> https://github.com/tofrankie/blog/issues/313 https://github.com/tofrankie/blog/issues/313 Sat, 17 Jun 2023 06:22:31 GMT 配图源自 Freepik

              前言

              作为 Web 前端开发,你一定听到过类似「HTML 会]]> 配图源自 Freepik

              前言

              作为 Web 前端开发,你一定听到过类似「HTML 会将多个空格合并为一个」的说法,你有深入了解过它是如何折叠(合并)的吗?

              我们平常编写的源码文件,通常会包含与最终呈现无关的格式。比如,项目采用 Space 或 Tab 的缩进风格,里面可能包含了空格、制表符、换行符等。浏览器在渲染源文件的时候,会根据一定的规则处理(保留或折叠)这些字符,开发者也可以通过 CSS 属性(white-space)控制其渲染规则。

              在此之前,我们要充分了解空白符是什么,有哪些?

              空白符

              空白符(Whitespace)是不可见,但可能会占据一定空间,可在排版中提供水平或垂直空间的一类字符

              比如空格、制表符、换行符等。空白符在各类编程语言中可作为分割 Token 的标识。

              空白符 简称 转义字符 码位 十进制值 十六进制值 HTML 实体
              Space(普通空格) U+0020 32 20 &#32;
              Horizontal tabulation(水平制表符) HT \t U+0009 9 9 &#9;
              Line Feed(换行符) LF \n U+000A 10 0A &#10;
              Vertical tabulation(垂直制表符) VT \v U+000B 11 0B &#11;
              Form Feed(分页符) FF \f U+000C 12 0C &#12;
              Carriage Return(回车符) CR \r U+000D 13 0D &#13;

              其中垂直制表符、分页符可能少见一些。在 ASCII 编码也没有专门的字符来表示垂直制表符。

              在正则表达式中,可通过 \s 来匹配一个除普通空格之外的空白符(也就是 \n\r\f\t\v),相当于 /[\n\r\f\t\v]/

              空格

              通常,我们会使用键盘上的 Space 键输入空格(也就是普通空格)。

              除此之外,表示空格的字符还有很多种。

              名称 HTML 实体 描述
              Space &32; 普通空格,键盘 Space 键产生的空格。
              Non-breaking space &npsp; 半角不换行空格,其占据的宽度受字体影响明显。区别于普通空格,在 macOS 上可通过「Alt + Space」打出一个 Non-breaking space。
              En space &ensp; 半角空格,表现为 1/2 个中文的宽度,基本不受字体影响。
              Em space &emsp; 全角空格,表现为 1 个中文的宽度,基本不受字体影响。
              Thin space &thinsp; 宅空格,顾名思义其占据的宽度比较小,为 em 的 1/6 宽。
              Zero-width space &ZeroWidthSpace; 零宽空格,是一种不可见、不可打印的 Unicode 字符。
              Zero-width joiner &zwj; 零宽连字,是一个控制字符,可使得两个本不会发生连字的字符产生连字效果。
              Zero-width non-joiner &zwnj; 零宽不连字,是一个控制字符,用于抑制本来会发生的连字使得其以原本的字符来绘制。

              请注意,只有「普通空格」才算作空白符。

              在 HTML 中可发生折叠的空格,也只有普通空格才会。

              回车符与换行符

              可能还有一部分同学还分不清「回车」和「换行」区别的。

              随着计算机的快速发展和普及,大家常说的「换行」或「回车」操作所表示的意思,相比之前,其实早就发生了变化。

              通常表示为「移动至下一行行首」,也就是按下键盘上的 Return/Enter 键所做的事情。不仅如此,如今的应用程序功能越来越强大,在按下 Return 键后,它甚至可以自动插入指定数量的制表符或空格以快速实现文本对齐(具体取决于操作系统或者应用程序的实现)。

              关于「回车」和「换行」的词源,它们源起自打字机,后应用于计算机。在打字机中概念如下:

              • 回车(Carriage Return, CR):表示移动至当前行行首。
              • 换行(Line Feed, LF):表示移动至下一行,但不是下一行行首。

              在早期,机械打字机处理字符的效率很低,为了避免丢失字符,须先执行 CR 操作,再执行 LF 操作。现代意义上的换行相当于打字机中「回车 + 换行」的组合。

              作为一名软件开发者,本着严谨的态度,习惯地将 CR 称为「回车符」,将 LF 称为「换行符」还是有必要的。

              计算机中的两种换行符

              很多同学都知道,目前主流操作系统中表示换行的字符,有 \r\n\n 两种。原因是当时存储非常昂贵,有些人认为用 CR+LF 两个字符表示换行过于浪费,因此产生了分歧。

              操作系统 简称 转义字符 码位 十进制值 十六进制值 HTML 实体
              Windows CR LF \r\n U+000D U+000A 13 10 0D 0A &#13;&#10;
              Unix, Linux, OS X, macOS LF \n U+000A 10 0A &#10;
              classic Mac OS CR \r U+000D 13 0D &#13;
              More...

              请注意,classic Mac OS 止于 2001 年发布的 Mac OS 9,再后来的 OS X、macOS 的换行符为 \n

              既然存在两种换行符,对我们会产生什么影响?

              如果软件层面没做好适配,Windows 文件在 Unix/Unix-like 操作系统上打开可能会变成一行。而 Unix/Unix-like 文件在 Windows 操作系统上打开,每行结尾可能会多一个 ^M 符号。

              但基本不用担心,已经 2023 年了,大多数软件都可以很好地兼容两种换行符。对于开发者来说,很多 IDE/Editor 都可以设置默认换行符的,也有各种 Linter 工具(比如 EditorConfig)自动处理。通常,更多人选择将默认换行符设为 LF,也就是 \n

              制表符

              制表符就是键盘上 Tab 键输入的字符,用于将光标前进到下一个制表位(Tab stop)。

              它同样源自打字机,在当时如果要打出一个表格等内容时,需要使用大量的空格键等,为此人们发明了一个类似现代的 Tab 键的东西,按下该键可以前进到下一个制表位。

              随着计算机快速发展,现代的应用程序也足够聪明,除了实现打字机那些基本功能之外,还提供了一些具有对其属性的制表位(比如 Microsoft Word 等),动态制表位、补全(Tab completion)等能力。比如,代码编辑器中 Code Snippet 的占位符可通过 Tab 键快速切换,就是利用了动态制表位实现的。

              平常写代码过程中,按下回车键快速切换至下一行指定位置,相当于「CR + LF + Tab」。如果项目中指定了类似 useTab=false, tabSize=2 的风格要求,在按下 Tab 键时,应用程序会根据配置替换为指定数量的空格实现快速对齐效果(取决于应用程序的实现)。

              通常 Tab 键产生的字符为水平制表符(Horizontal tabulation, HT),除此之外,还有垂直制表符(Vertical tabulation, VT),用于打印或者 Word 排版等场景。

              制表符 简称 转义字符 码位 十进制值 十六进制值 HTML 实体
              Horizontal tabulation(水平制表符) HT \t U+0009 9 9 &#9;
              Vertical tabulation(垂直制表符) VT \v U+000B 11 0B &#11;

              有些标准中垂直制表符也称为 Line tabbulation。

              如无特殊说明,下文提到的制表符(Tab)均表示水平制表符(U+0009)。

              HTML 实体

              在 HTML 中有些字符是预留的。比如小于号 < 和大于号 >,如果在源码中直接键入,会被浏览器误认为它们是标签。为了正确地显示保留字符、难以用键盘输入的特殊字符,需要用 HTML 实体表示。

              HTML 实体是以 & 开头,以 ; 结束的文本,中间可以是实体名称(Entity Name),也可以是实体数字(Entity Number)。

              比如,小于号 <,其实体名称是 lt,实体数字为 60(对应的 ASCII 编码值),所以它有 &lt;&#60;&#060; 三种表示方法。

              以下列出对应的 HTML 实体:

              名称 实体名称 实体数字
              Space &#32;
              Non-breaking space &nbsp; &#160;
              En space &ensp; &#8194;
              Em space &emsp; &#8195;
              Thin space &thinsp; &#8201;
              Zero-width space &ZeroWidthSpace; &#8203;
              Zero-width non-joiner &zwnj; &#8204;
              Zero-width joiner &zwj; &#8205;
              Horizontal tabulation &Tab; &#9;
              Line feed &#10;
              Vertical tabulation &#11;
              Form feed &#12;
              Carriage Return &#13;

              HTML 实体编码在页面上展示取决于页面中所设字符集。常见的页面乱码,就是因为字符集不一致导致的。

              • HTML 5 的默认字符集是 UTF-8
              • HTML 4.01 的默认字符集是 ISO-8859-1
              • HTML 1 的默认字符集是 ASCII 码

              相关链接:

              CSS 空白符处理规则

              写在前面

              在 CSS 中,规则指出,CSS 的空白处理仅影响以下三种空白符

              • 普通空格(Space, U+0020)
              • 水平制表符(Tab, U+0009)
              • 换行符(Line Feed, U+000A)

              如无特殊说明,下文中提到可折叠(忽略)的空白符是指这三种。

              这三个空白符可称为 document white space characters,本文称作「文档空白符」。

              但在 HTML 中表示 Newlines(换行)的字符包含换行符(LF, U+000A)、回车符(CR, U+000D)以及成对的 CR+LF。

              那么,为什么 CSS 不处理回车符(CR, U+000D)呢?

              首先,浏览器渲染 HTML 的流程大致包括:HTML 解析得到 DOM Tree,CSS 解析得到 CSSOM Tree,然后 DOM Tree 和 CSSOM Tree 结合生成 Render Tree,接着 Layout 阶段将 Render Tree 的所有节点分配空间确定坐标,最后 Painting 阶段再将所有阶段绘制出来。而 CSS 起作用发生在解析之后,因此 CSS 并不直接作用于 HTML,而是 DOM Tree。

              其次,在 HTML 解析为 DOM Tree 的过程中,每个换行序列字符(newline sequence)都会做规范化处理为 Line Feed(U+000A),此时 Line Feed 被称为 segment break。

              To normalize newlines in a string, replace every U+000D CR U+000A LF code point pair with a single U+000A LF code point, and then replace every remaining U+000D CR code point with a U+000A LF code point.

              也就是在规划化处理时,先将成对的 U+000D CR U+000A LF 替换为一个 U+000A LF,然后将剩余的 U+000D CR 也替换为一个 U+000A LF。因此对 CSS 而言,它压根感受不到 U+000D 的存在。

              请注意,如果 HTML 文档中存在回车符(CR, U+000D),它不会因为上述规划化处理而凭空消失,通过 Element.innerHTML 等方式仍然可以看得到其转义字符 \r 的。

              相关链接:

              white-space 属性

              用于控制空白处理的 CSS 属性是 white-space,其默认值是 normal

              该属性指定了两件事:

              • 空白符是否以及如何折叠。
              • 行是否采用软换行。

              white-space 设为 normalnowrappre-line,那么文档空白符被认为是可折叠(collapsible)的。这种折叠现象可以称为空白符折叠(white space collapsing)。在空白处理过程中,没有被移除或折叠而保留下来的空白符,称为保留空白符(preserved white space)。

              white-space New Lines Spaces and Tabs Text Wrapping End-of-line spaces End-of-line other space separators
              normal 折叠 折叠 换行 移除 悬挂(Hang)
              pre 保留 保留 不换行 保留 不换行
              nowrap 折叠 折叠 不换行 移除 悬挂
              pre-wrap 保留 保留 换行 悬挂 悬挂
              break-spaces 保留 保留 换行 换行 换行
              pre-line 保留 折叠 换行 移除 悬挂
              • normal

                默认值。该值指示浏览器将(多个)空白序列折叠为一个单个字符(有些情况下,没有字符)。允许存在软换行机会的位置换行。

              • pre

                意为保留(preserved)。该值防止浏览器折叠空白序列。Line Feed(U+000A)被保留为 forced line breaks,当且仅当为强制换行符时,才发生换行。当容器的宽度无法满足内容时,内容会发生溢出。

              • nowrap

                意为不换行。该值像 normal 一样会折叠空白序列,但又像 pre 那样,不允许换行。

              • pre-wrap

                意为保留换行。像 pre 那样保留空白序列,又像 normal 那样允许换行。

              • break-spaces

                该值与 pre-wrap 行为一致,除了:

                • 任何保留的空白序列和其他空格分隔符总是占据空间,包括在行尾。
                • 软换行机会存在于在每个保留的空白序列之后和每个其他空格分隔符(包括相邻空格之间) 之后。
              • pre-line

                该值与 normal 一样,此值折叠连续的空白字符,并允许换行,但同时保留源码中的 Line Feed 作为 forced line breaks

              Unicode® Standard Annex #44 定义了一些包括 U+1680、U+2000 ~ U+200A 在内的 12 个空格分隔符(Space separator)。在 CSS 规范文档中,将除了普通空格(U+0020)和不换行空格(U+00A0)之外的空格分隔符称为其他空格分隔符(other space separators)。

              white-space: normal 就像循规蹈矩的倔牛,它不管你是否有显示指定换行符(Line Feed),它只会在该换行的时候(有软换行机会)才进行换行,比如块容器空间不够了,而且有软换行机会的时候才进行换行。同时为了排版更美观,它会将连续的空白符折叠成一个。但为了满足各种各样的排版需求,才有了其他值以方便换行、或者保留多个空格等。

              关于软换行机会:

              以中文为例,每个空格、中文字后面都存在一个软换行机会。还有中文标点符号有一种「避头」或「避尾」特性,它们通常不会在行首或行尾出现,这点可以通过 line-break 来改变。

              以英文和数字为例,软换行机会通常是空格,由连续的数字或字母组成的字符串,被认为是一个单词,要不就从这个字符串开头就折行显示,要不就是在字符串结束后的空格处才会换行(即使内容溢出)。比如 <div>11111...</div>(这里有足够多的 1 组成),默认情况下它只会单行显示,原因就是它中间没有软换行机会。

              对于一些不以空格或标点符号分割的语言,这里就不展开叙述了。

              空白处理规则

              分为三个阶段:

              • 折叠与转换
              • 裁剪与定位
              • 换行转换规则

              以下三个阶段中提到的 Line Feed,在规范中时用 segment break 表示的。如果 Document language 未定义 segment break 或 newline sequence 的话,那么文本中的 Line Feed(U+000A)被视为 segment break。因此下面才直接称为 Line Feed。

              折叠与转换(Collapsing and Transformation)

              对于内联格式上下文(Inline formatting contexts, IFC)中的每个内联元素,空白序列在换行和双向重新排序(针对诸如阿拉伯语等从右到左书写的语言)之前,按如下方式进行处理:

              • 如果 white-space 设为 normalnowrappre-line,空白序列被认为是可折叠的,并通过一下步骤处理:

                1. 紧接在 Line Feed 之前或之后的任何可折叠 Space 和 Tab 都会被移除。
                2. 可折叠的 Line Feed 按第三个阶段「换行转换规则」处理。
                3. 每个可折叠的 Tab 被转换为一个可折叠的 Space。
                4. 任何紧跟在另一个可折叠的 Space 的可折叠 Space 会被折叠,在渲染时不占据任何水平空间,即视觉上不可见。但保留了软换行机会,如果有的话。
              • 如果 white-space 设为 prepre-wrapbreak-spaces,任何 Space 被视为 Non-breaking space(U+00A0)。

                • 对于 white-space: pre-wrap,在一系列的 Space 或 Tab 的末尾存在软换行机会。
                • 对于 white-space: break-spaces,每个 Space 和每个 Tab 之后都存在软换行机会。

              如何理解内联格式上下文中的每个内联元素(包含匿名内联元素)?

              <p>Several <em>emphasized words</em> appear
              <strong>in this</strong> sentence, dear.</p>
              

              <p> 是一个块级元素,里面包含了 5 个内联元素,其中 3 个是匿名的:

              • Anonymous: Several
              • Em: emphasized words
              • Anonymous: appear
              • Strong: in this
              • Anonymous: sentence, dear.

              任何直接包含在块级元素内(而不是内联元素内)的文本都视为匿名内联元素(Anonymous inline element)。

              裁剪与定位(Trimming and Positioning)

              接着,整个块(block)被渲染。内联元素(inlines)按照双向重排的规则进行布局,并根据 white-space 属性指定的方式进行换行。在逐行布局的过程中:

              1. 移除行开头的一系列可折叠 Space。
              2. tab-size0,保留的 Tab 不进行渲染。否则,每个保留的 Tab 都呈现为水平移动,使得下一个字形的起始边缘与下一个制表位(Tab Stop)对齐。
              3. 若在一行的末尾有一系列可折叠的 Space,它们会被移除。同时,如果在行末尾有 OGHAM SPACE MARK(U+1680)字符,并且它的 white-spacenormalnowrappre-line,那么也会将该字符移除。
              4. 如果在一行的末尾有文档空白符(document white space characters)、其他空格分隔符(other space separators)和保留的 Tab:
                • 如果 white-spacenormalnowrappre-line,浏览器必须(无条件地)挂起(hang)该字符。
                • 如果 white-spacepre-wrap,浏览器必须挂起该字符,除非该字符后跟了一个 forced line break,这时浏览器必须(有条件地)挂起该字符。此外,浏览器还可以在该字符的宽度超出限制时,将其字符宽度进行视觉上的折叠。
                • 如果 white-spacebreak-spaces,Space、Tab 和 other space separators 被视为与其他可见字符相同,它们不能被挂起,也不能折叠其前进宽度。

              换行转换规则(Segment Break Transformation Rules)

              white-spaceprepre-wrapbreak-spacespre-line 时,Line Feed 不可折叠,而是转换为保留的 Line Feed(preserved line feed, U+000A)。

              white-space 为其他值,Line Feed 是可折叠的,并按如下方式进行折叠:

              1. 首先,移除紧跟在另一个可折叠的 Line Feed 之后的任何可折叠 Line Feed。
              2. 然后,根据中断前后的上下文,任何剩余的 Line Feed 要么被转换为 Space,要么被移除。具体取决于浏览器的定义。请注意:在评估此上下文之前,空白处理规则已经移除了 Line Feed 周围的所有 Tab 和 Space。

              换行与单词边界

              • 当 inline-level content 被布局成行时,它会跨行盒子被打断,这样的换行称为 line break
              • 当由于显式的换行控制符(比如被保留下来的换行符)或块的开始或结束而导致换行时,称为 forced line break
              • 当一行由于内容换行而被打断时,这是 soft wrap break
              • 将行级内容分成多行的过程被称为 line breaking

              当换行仅在允许的换行点处执行,称为 soft wrap opportunity(软换行机会)。对于大多数书写系统来说,在没有使用连字的情况下,软换行只会在单词之间发生。对于使用空格或标点符号分隔单词的书写系统,软换行的位置可以通过这些字符来确定。

              尽管 CSS 没有用于软换行机会的属性,但可以通过诸如 line-breakwork-breakhyphensoverflow-wrap/work-wrap 属性去指定改变换行点。

              换行细节

              在确定 line break 时:

              • line breaking 和双向文本的交互由 CSS Writing Modes 4Unicode Bidirectional Algorithm 定义。

              • 保留的 Line Feed、具有 BK(U+000C、U+000B、U+2028、U+2029)、NL(U+0085)类别的 Unicode 字符,必须视为 forced line breaks,且不受 white-space 属性的影响。

              • 除非另有明确定义(例如 line-break: anywhereoverflow-wrap: anywhere),否则必须遵守 WJ(U+2060、U+FEFF)、ZW(U+200B)、GL(U+00A0、U+202F、U+180E)和 ZWJ(U+200D)类别的 Unicode 字符的换行行为。

              • 对于使用标点符号作为分隔符的书写系统,浏览器可以允许在除了词分隔符之外的标点符号处进行换行,但在确定换行位置时应优先考虑换行点(breakpoint)的设置。例如,如果在 / 后面的换行点(breakpoint)优先级低于空格,那么在序列 check /etc 中,将不会在 / 和字母 e 之间发生换行。

              • 脱离标准文档流的元素(out-of-flow elements)和行内元素的边界不会引入 forced line break 或 soft wrap opportunity,它们不会中断文本流的连续性。

              • 为了 Web 兼容性,在每个替换元素(replaced element)或其他原子内联(atomic inline)前后都有一个软换行机会,即使与通常会抑制它们的字符相邻时也是如此,例如 Non-breaking space。

              • 对于由在换行处消失的字符(例如 U+0020 Space)创建的软换行机会,直接包含该字符的盒子上的属性控制该机会的换行。对于由两个字符之间的边界定义的软换行机会,两个字符最近的共同祖先上的 white-space 属性控制换行;哪些元素的 line-breakword-breakoverflow-wrap 属性控制在此类边界处软换行机会的确定在 CSS Level 3 中未定义。

              • 对于盒子的第一个字符之前或最后一个字符之后的软包装机会,换行发生在盒子之前/之后(在其外边距边缘),而不是在其内容边缘和内容之间发生换行。

              • / 周围的 Ruby 文本中的换行行为在 CSS Ruby Annotation Layout 1 中定义。

              • 为了避免意外的溢出,在浏览器无法执行所需的词法或正字法分析(orthographic analysis)来进行需要换行的任何语言(例如由于缺乏某些语言的字典)时,它必须在该书写系统中的排版字母单元对之间假设存在软换行机会。

              work-break 属性

              此属性指定字母之间的软换行机会,即它是“正常”且允许换行的位置。

              • normal(默认值)

                默认断行规则,像上面提到的 line break 那样,单词按照它们自己的习惯进行断行。

              • break-all

                在断行中允许在单词内进行断行。具体而言,除了 normal 的软换行机会外,任何排版字母单元(以及解析为 NU(Numeric)、AL(Alphabetic)或 SA(South East Asian)断行类别的任何排版字符单元)都被视为表意字符(ideographic characters, ID)用于断行的目的。不会应用连字符化。

              • keep-all

                在单词内禁止断行。除此之外,该选项与 normal 情况相同。在这种样式中,连续的 CJK(中文、日文和韩文的统称)字符序列不会断行。比如:这是一些汉字 and some Latin 的换行机会在 这是一些汉字·and·some·Latin(用 · 表示)

              line-break 属性

              此属性指定元素内应用的换行规则的严格性:尤其是换行如何与标点符号(punctuation)和符号(symbols)交互。

              • auto (默认值)

                浏览器确定要使用的断行限制集,并且它可以根据行的长度变化限制。例如,对于较短的行使用一组较少限制的断行规则。

              • loose

                使用最宽松的断行规则来断开文本。通常在短行文本中使用,例如报纸。

              • normal

                使用最常见的一组换行规则来打断文本。

              • strict

                使用最严格的一组换行规则来断开文本。

              • anywhere

                每个印刷字符单元周围都有一个软换行机会,包括任何标点符号周围或保留的空白符,或在单词中间,无视任何禁止换行符的规定,即使是那些由具有 GLWJZWJ 换行符类别的字符引入的或由 work-break 属性强制要求。不能优先考虑不同的换行机会。不应用连字符。

              请注意:line-break: anywhere 只允许在 white-space 设为 break-spaces 时,将行末的保留空格换行到下一行,因为在其他情况下:

              • white-space: normalwhite-space: pre-line 下,行末/行首的保留空格会被丢弃。
              • white-space: nowrapwhite-space: pre 下,禁止换行。
              • white-space: pre-wrap 下,保留空格会保持悬挂状态。

              line-break: anywhere 对保留空格产生影响时(在 white-space: break-spaces 下),它允许在连续空格序列的第一个空格之前进行换行,而独立使用 white-space: break-spaces 则不具备这个功能。

              overflow-wrap 和 word-wrap 属性

              word-wrap 属性原本属于微软扩展的一个非标准、无前缀的属性,后来在大多数浏览器中以相同的名称实现。目前它已被更名为 overflow-wrapword-wrap 相当于其别称。若考虑到兼容性,还是用 word-wrap 吧。

              此属性指定浏览器是否可以在一行内不允许的位置换行以防止溢出,当 otherwise-unbreakable 的字符串太长而无法放入行盒子时。它仅在 white-space 允许换行时有效。

              • normal(默认值)

                行只能在允许的换行点处换行。然而,由于 word-break: keep-all 引入的限制可以放宽以匹配 word-break: normal 如果行中没有 otherwise-acceptable 换行点。

              • anywhere

                如果该行中没有其他方面可接受的换行点,则可以在任意位置中断一个 otherwise-unbreakable 字符。

              • break-word

                除了由 overflow-wrap: break-word 产生的软换行机会之外,在计算元素的最小内容固有尺寸时,不考虑其他任何软换行机会。

              行内元素标签之间的空白符

              两个块级元素之间、块级元素与行内元素之间的普通空白符会被忽略。但行内元素之间的普通空白符就要注意了。

              举个例子,我们可能会经常遇到类似的排版:

              <div class="list">
                <div class="item">1</div>
                <div class="item">2</div>
                <div class="item">3</div>
              </div>
              
              .list {
                background: #f00;
              }
              
              .item {
                font-size: initial;
                display: inline-block;
                background: #eee;
                padding: 4px 10px;
              }
              

              给每个 item 设为 display: inline-block,使得它既能像块级元素那样设置边距宽高等,又能像行内元素那样排列在一行中,兼容性还好。但问题却是行内元素之间有间隙,如下:

              原因很简单,我们在源码中输入了空格、换行符(或是格式化工具产生的),可以打印一下 list 元素的 HTML 结构:

              \n  <div class="item">1</div>\n  <div class="item">2</div>\n  <div class="item">3</div>\n
              

              本文不讨论采用 flex 等布局方式。

              前导和尾随的普通空白符被忽略了,但中间的则被解析为普通空格,而普通空格占据的宽度又跟字体大小有关系,因此就产生了缝隙。

              诸如外部元素设为 font-size: 0、改变布局方式等,都能一定程度上解决问题,更多可看张鑫旭大佬的文章《去除 inline-block 元素间间距的 N 种方法》。

              问题的本质是行内元素之间的空白符是会被解析出来的,比如 <a>link1</a> <a>link2</a> 两个 <a> 标签之间的空格是无法忽略的。那么彻底解决问题的方式是在源码中干掉空格,比如:

              <div class="list">
                <div class="item"
                  >1</div
                ><div class="item"
                  >2</div
                ><div class="item">3</div>
              </div>
              

              虽然这种写法是「丑」的,可读性不好,但它才是终极解决方法。

              难道我们开发的过程中真的要这样写?

              并不是,我们现在享受着各种框架所带来的便利,各种编译、压缩工具横行,它们帮我们做了很多「脏活」,在编译出 HTML 或者动态更新 DOM 之前已经把标签之间的空格、换行符都去掉了。不信你可以用 React 等框架还原上述示例。

              这也是只注重框架使用,而忽视基础知识的一个反映,它掩盖了一些细节,容易让使用者忽略掉了事物的本质。

              htmlWhitespaceSensitivity

              我们知道在 Prettier 中有一个 htmlWhitespaceSensitivity 选项,就是用来指定如何格式化 HTML 文件的。

              通过前面的内容,我们可以知道 1<b> 2 </b>3 将会被解析为 1 2 3(中间是有空格的),但如果我们本地的格式化程序将源码格式化为 1<b>2</b>3(文本的前导和尾随空格被移除),那么解析结果就是 123(中间没有空格),这可能是非预期行为。

              举个例子,以下元素不能安全地格式化:

              <a href="https://prettier.io/">Prettier is an opinionated code formatter.</a>
              

              由于 printWidth 的存在,它有可能会被格式化为:

              <a href="https://prettier.io/">
                Prettier is an opinionated code formatter.
              </a>
              

              这样页面呈现的链接左右可能会出现空格。

              htmlWhitespaceSensitivity 提供了三个可选值:

              • css:遵循 CSS 中 display 属性的默认值。
              • strict:认为所有空白符都是重要的。
              • ignore:认为所有空白符都不重要。
              <!-- <span> is an inline element, <div> is a block element -->
              
              <!-- input -->
              <span class="dolorum atque aspernatur">Est molestiae sunt facilis qui rem.</span>
              <div class="voluptatem architecto at">Architecto rerum architecto incidunt sint.</div>
              

              如果是 "htmlWhitespaceSensitivity": "css",格式化结果如下。即块级元素的前导和尾随空白符可以忽略,因此换行显示。而行内元素前导或尾随空白符是影响显示的不应忽略。

              <!-- output -->
              <span class="dolorum atque aspernatur"
                >Est molestiae sunt facilis qui rem.</span
              >
              <div class="voluptatem architecto at">
                Architecto rerum architecto incidunt sint.
              </div>
              

              如果是 "htmlWhitespaceSensitivity": "strict",格式化结果如下。

              <!-- output -->
              <span class="dolorum atque aspernatur"
                >Est molestiae sunt facilis qui rem.</span
              >
              <div class="voluptatem architecto at"
                >Architecto rerum architecto incidunt sint.</div
              >
              

              如果是 "htmlWhitespaceSensitivity": "ignore",格式化结果如下。

              <!-- output -->
              <span class="dolorum atque aspernatur">
                Est molestiae sunt facilis qui rem.
              </span>
              <div class="voluptatem architecto at">
                Architecto rerum architecto incidunt sint.
              </div>
              

              在全局指定处理方式之后,还支持在局部添加 <!-- display: block --> 来覆盖全局配置。更多请看 Whitespace-sensitive formatting

              <pre><code>

              如果要使得前导、尾随或中间的普通空格等如源码般呈现在页面中,可以使用 <pre> 元素。

              该元素中的文本通常按照原文件中的编排,以等宽字体的形式展现出来,文本中的空白符(比如空格和换行符)都会显示出来。但紧跟在 <pre> 开始标签后的换行符会被省略。

              <pre>
                *
                *    ┏┓   ┏┓
                *   ┏┛┻━━━┛┻┓
                *   ┃       ┃
                *   ┃   ━   ┃
                *   ┃ ┳┛ ┗┳ ┃
                *   ┃       ┃
                *   ┃   ┻   ┃
                *   ┃       ┃
                *   ┗━┓   ┏━┛
                *     ┃   ┃
                *     ┃   ┃
                *     ┃   ┗━━━┓
                *     ┃       ┗┓
                *     ┃       ┏┛
                *     ┗┓┓┏━┳┓┏┛
                *      ┃┫┫ ┃┫┫
                *      ┗┻┛ ┗┻┛
                *
                *   Code is far away from bug with the animal protecting.
                *
              </pre>
              
              pre {
                margin: 0;
                border: 1px solid #f00;
              }
              

              以上示例有普通空格、换行符、半角空格和全角空格等。在 Markdown 文档常用的 Code Block 也是用 <pre> 进行渲染的。

              类似的,还有一个 <code> 元素,表示呈现一段计算机代码。默认情况下,它以浏览器的默认等宽字体显示。但它与 <pre> 不同的是,连续多个空白符仅算作一个。

              <p>Regular text. <code>This is code.</code> Regular text.</p>
              

              同样地,在 Markdown 中单行代码块就是用 <code>(右键检查元素试试?)元素进行渲染的,但通常网站会给该元素设置背景颜色使得更加突出。

              React 如何表示换行

              由于 JSX 在解析的时候,会将文本中的换行符转换为空格(详见)。

              JSX removes whitespace at the beginning and ending of a line. It also removes blank lines. New lines adjacent to tags are removed; new lines that occur in the middle of string literals are condensed into a single space.

              因此,以下几种写法的表现是一样的,并不会表现出换行,即使给设置上 white-space: pre-wrap

              <div>Hello World</div>
              
              <div>
                Hello World
              </div>
              
              <div>
                Hello
                World
              </div>
              
              <div>
              
                Hello World
              </div>
              

              解法一:

              function MyComponent() {
                return <div style={{ whiteSpace: "pre-wrap" }}>{'Hello\nWorld'}</div>
              }
              

              解法二:

              function MyComponent() {
                return (
                  <div>
                    Hello
                    <br />
                    World
                  </div>
                )
              }
              

              解法二:

              function MyComponent() {
                return <div style={{ whiteSpace: 'pre-wrap' }}>{'Hello\u000AWorld'}</div>
              }
              

              解法三:

              function MyComponent() {
                return (
                  <div
                    dangerouslySetInnerHTML={{
                      __html: '<div style="white-space: pre-wrap">Hello&#10;World</div>',
                    }}
                  />
                )
              }
              

              一个有趣的 Issue:Newline having a trailing whitespace character is removed in JSX attribute value #10356

              参考链接

              ]]>
              <![CDATA[Safari/WebKit 无法正确渲染 <foreignObject> 中的 HTML 元素]]> https://github.com/tofrankie/blog/issues/312 https://github.com/tofrankie/blog/issues/312 Tue, 25 Apr 2023 10:14:52 GMT 配图源自 Freepik

              实锤了,Safari 就是新时代的 IE 浏览器。原因是有些东西在 Safa]]> 配图源自 Freepik

              实锤了,Safari 就是新时代的 IE 浏览器。原因是有些东西在 Safari 渲染表现与预期(标准)不一致,而且 Safari for Mac 跟 Safari for iOS 的表现还不一定是相同的。

              背景

              今天遇到了这样一个问题。举个例子,假设外层一个 max-width: 430px 的 section 元素,里面是一个 svg 元素,里面包含动画还有嵌套了一些元素。预期表现是:点击红色区域,绿色背景透明度匀速从 0 切换至 1。

              <section style="max-width: 430px; margin: auto; overflow: hidden; font-size: 0">
                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 350" preserveAspectRatio="xMidYMin meet" style="pointer-events: none; width: 100%; background-color: red">
                  <foreignObject x="0" y="0" width="100%" height="100%">
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 350" preserveAspectRatio="xMidYMin meet" style="opacity: 0; background-color: green">
                      <animate attributeName="opacity" begin="click" from="0" to="1" calcMode="linear" dur="1s" fill="freeze" restart="never" />
                      <rect x="0" y="0" width="100%" height="100%" fill="transparent" style="pointer-events: visible">
                        <set attributeName="visibility" begin="click" to="hidden" fill="freeze" restart="never" />
                      </rect>
                    </svg>
                  </foreignObject>
                </svg>
              </section>
              
              body {
                margin: 20px;
              }
              

              根据所设置的 viewBox="0 0 350 350"preserveAspectRatio="xMidYMin meet" 以及 width: 100%,按道理的话,红色的 <svg> 及其内嵌套 <foreignObject><svg>,应该都是同等大小的正方形,而且取决于父元素 <section> 的宽度。

              是的,这个在 Chrome 表现没问题,但在 Safari for Mac 上就出现问题了,离奇的是 Safari for iOS 也是正常的

              案例一

              如下图,此时 <body> 的宽度是大于 430px,因此 <section> 的宽度为 430px,自然 <svg> 的宽度就是 430px

              但是,当我们点击蓝色框之外,红色区域(截图由于选中元素,该区域表现为橘色)以内的位置,你知道 Safari 定位到的元素是什么吗?

              嗯......它定位到 <section> 元素了。意思就是说,内部的 元素区域并未覆盖到点击区,但我宽高明明设置的都是 100%,就很离谱。

              但是,我在右侧 Elements 选项卡选中 <rect> 元素时,它表现的区域明明就是占满的啊,也就是 430 * 430

              Safari 你在玩我?

              经多次测试,它可点击区域只有 350 * 350,也就是 viewBox 那个空间。

              解决办法

              由于是 Safari 的 bug,目前只能用一些治标不治本的方法,用魔法打败魔法。

              <rect> 设置 transform: scale(2); transform-origin: left top;,其父级的 <svg> 设置 overflow: visible。由于 <foreignObject> 元素默认为 overflow: hidden,因此不用担心点击 430 * 430 之外的位置会触发事件。

              案例二

              利用 <svg> 做了一个循环切换的交互,同样地,它在 Chrome 一切安好,而在 Safari 下则惊喜满满。

              <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 350" preserveAspectRatio="xMidYMin meet" style="width: 100%">
                <foreignObject x="0" y="0" width="100%" height="100%">
                  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 350" preserveAspectRatio="xMidYMin meet" style="width: 100%">
                    <foreignObject x="0" y="0" width="100%" height="100%">
                      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 350" preserveAspectRatio="xMidYMin meet" style="opacity: 1; width: 100%; background-size: cover; background-image: url(https://cdn.jsdelivr.net/gh/tofrankie/blog@main/images/2023/4/1682475354583.png); background-color: red">
                        <animate attributeName="opacity" begin="0s" keyTimes="0; 0.22222222; 0.33333333; 1" values="1; 1; 0; 0" calcMode="linear" dur="9s" repeatCount="indefinite" />
                      </svg>
                    </foreignObject>
                    <foreignObject x="0" y="0" width="100%" height="100%">
                      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 350" preserveAspectRatio="xMidYMin meet" style="opacity: 0; width: 100%; background-repeat: no-repeat; background-size: cover; background-position: top center; background-image: url(https://cdn.jsdelivr.net/gh/tofrankie/blog@main/images/2023/4/1682475369330.png); background-color: green">
                        <animate attributeName="opacity" begin="0s" keyTimes="0; 0.22222222; 0.33333333; 0.55555556; 0.66666667; 0.666666670001; 1" values="0; 0; 1; 1; 0; 0; 0" calcMode="linear" dur="9s" repeatCount="indefinite" />
                      </svg>
                    </foreignObject>
                    <foreignObject x="0" y="0" width="100%" height="100%">
                      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 350" preserveAspectRatio="xMidYMin meet" style="opacity: 0; width: 100%; background-repeat: no-repeat; background-size: cover; background-position: top center; background-image: url(https://cdn.jsdelivr.net/gh/tofrankie/blog@main/images/2023/4/1682475408407.png); background-color: blue">
                        <animate attributeName="opacity" begin="0s" keyTimes="0; 0.55555556; 0.66666667; 0.88888889; 0.99999999; 1" values="0; 0; 1; 1; 0; 0" calcMode="linear" dur="9s" repeatCount="indefinite" />
                      </svg>
                    </foreignObject>
                    <foreignObject x="0" y="0" width="100%" height="100%">
                      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 350" preserveAspectRatio="xMidYMin meet" style="opacity: 0; width: 100%; background-repeat: no-repeat; background-size: cover; background-position: top center; background-image: url(https://cdn.jsdelivr.net/gh/tofrankie/blog@main/images/2023/4/1682475354583.png); background-color: red">
                        <animate attributeName="opacity" begin="0s" keyTimes="0; 0.88888889; 0.99999999; 1" values="0; 0; 1; 0" calcMode="linear" dur="9s" repeatCount="indefinite" />
                      </svg>
                    </foreignObject>
                  </svg>
                </foreignObject>
              </svg>
              

              Safari 表现出「忽大忽小」的问题。如下图,灰色背景大小为 430 * 430,而红色背景处则是 350 * 350。

              由于录制 GIF 太麻烦了,你可以使用 Safari 打开链接体验一下:https://codepen.io/tofrankie/full/abRWpaE

              解决方法

              由于 <foreignObject> 的坑,那就不要嵌套多层,所以可以这样处理,结构上也更清晰。

              <section>
                <section style="height: 0">
                  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 350" preserveAspectRatio="xMidYMin meet" style="opacity: 1; width: 100%; background-size: cover; background-image: url(https://cdn.jsdelivr.net/gh/tofrankie/blog@main/images/2023/4/1682475354583.png); background-color: red">
                    <animate attributeName="opacity" begin="0s" keyTimes="0; 0.22222222; 0.33333333; 1" values="1; 1; 0; 0" calcMode="linear" dur="9s" repeatCount="indefinite" />
                  </svg>
                </section>
                <section style="height: 0">
                  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 350" preserveAspectRatio="xMidYMin meet" style="opacity: 0; width: 100%; background-repeat: no-repeat; background-size: cover; background-position: top center; background-image: url(https://cdn.jsdelivr.net/gh/tofrankie/blog@main/images/2023/4/1682475369330.png); background-color: green">
                    <animate attributeName="opacity" begin="0s" keyTimes="0; 0.22222222; 0.33333333; 0.55555556; 0.66666667; 0.666666670001; 1" values="0; 0; 1; 1; 0; 0; 0" calcMode="linear" dur="9s" repeatCount="indefinite" />
                  </svg>
                </section>
                <section style="height: 0">
                  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 350" preserveAspectRatio="xMidYMin meet" style="opacity: 0; width: 100%; background-repeat: no-repeat; background-size: cover; background-position: top center; background-image: url(https://cdn.jsdelivr.net/gh/tofrankie/blog@main/images/2023/4/1682475408407.png); background-color: blue">
                    <animate attributeName="opacity" begin="0s" keyTimes="0; 0.55555556; 0.66666667; 0.88888889; 0.99999999; 1" values="0; 0; 1; 1; 0; 0" calcMode="linear" dur="9s" repeatCount="indefinite" />
                  </svg>
                </section>
                <section style="height: 0">
                  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 350" preserveAspectRatio="xMidYMin meet" style="opacity: 0; width: 100%; background-repeat: no-repeat; background-size: cover; background-position: top center; background-image: url(https://cdn.jsdelivr.net/gh/tofrankie/blog@main/images/2023/4/1682475354583.png); background-color: red">
                    <animate attributeName="opacity" begin="0s" keyTimes="0; 0.88888889; 0.99999999; 1" values="0; 0; 1; 0" calcMode="linear" dur="9s" repeatCount="indefinite" />
                  </svg>
                </section>
                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 350" preserveAspectRatio="xMidYMin meet" style="width: 100%"></svg>
              </section>
              

              原因

              这是 Webkit 的 Bug,相关链接:

              该问题早在 2009 年就提出了,至今仍然没有任何进展,隔壁 Chromium 的 Blink 已在 2020 年 9 月修复。其中一个可复现的示例:https://codesandbox.io/s/chrome-foreignobject-defect-wf91j。在 Safari 打开使用 + + + - 去缩放页面就能看到。

              我用 Chrome 62 亲测了一下,确实也有问题,而且区域更小了。

              Chrome 62

              简言之,根本原因就是 Safari/WebKit 无法正确渲染 <foreignObject> 中的 HTML 元素

              实锤了,Safari 就是新时代的 IE 浏览器。

              参考链接

              ]]>
              <![CDATA[你不知道的 Margin]]> https://github.com/tofrankie/blog/issues/311 https://github.com/tofrankie/blog/issues/311 Wed, 12 Apr 2023 16:05:44 GMT 配图源自 Freepik

              只要你是 Web 前端开发,是在写页面,几乎离不开 Margin 吧。

              ]]>
              配图源自 Freepik

              只要你是 Web 前端开发,是在写页面,几乎离不开 Margin 吧。

              那么,你真的了解它吗?有哪些特别的应用?

              本文就以下几个方面展开介绍:

              • 大家都熟悉的内容
              • Margin Auto 的作用
              • Margin 与 Relative Position 的区别
              • 外边框折叠
              • 负值 Margin
              • 负值 Margin 的应用

              从大家知道的讲起

              语法

              Margin 用于设置元素的外边距,其语法很简单。

              div {
                margin: 10px; /* 一个属性值 */
                margin: 10px 20px; /* 两个属性值 */
                margin: 10px 20px 30px; /* 三个属性值 */
                margin: 10px 20px 30px 40px; /* 四个属性值 */
              }
              

              四种方式对应的含义大家都懂,就不再啰嗦了,略过...

              属性值

              其属性值可接受具体值(length)、百分比值(percentage)和 auto 。顺便提一句,百分比值通常根据父元素宽度来确定大小,更严谨应该称为包含块(Containing Block)。

              作用范围

              除了行内元素设置 margin-topmargin-bottom 无效,包括 inline-block 在内的所有元素都会起作用。

              逻辑属性

              我们知道 margin-left 等方向是一成不变的,永远表示左外边距。但是与 Margin 相关的逻辑属性 margin-inlinemargin-block,它们实际排版方向会随着书写模式等改变而改变,也就是 margin-inline-start 等可能是左外边距,也可能是右外边距。

              默认情况下,margin-inline 对应水平方向,margin-block 对应垂直方向。这个跟我们平常的阅读或书写习惯是相同的。

              margin-blockmargin-block-startmargin-block-end 的简写。 margin-inlinemargin-inline-startmargin-inline-end 的简写。

              语法如下:

              div {
                margin-inline: 10px; /* 一个属性值,应用于行首和行末 */
                margin-inline: 10px 20px; /* 一个属性值,第一个应用于行首,第二个应用于行末 */
              }
              

              margin-block 同理。通常我们的书写习惯是从左至右、从上至下,此时 margin-inline 对应 margin-leftmargin-rightmargin-block 对应 margin-topmargin-right

              *-start*-end*-inline-start*-inline-end 等这类属性是 CSS 逻辑属性,它们会根据 writing-modedirectiontext-orientation 所定义的值去对应 margin-top 等属性。此处不展开介绍,有兴趣自行查阅。

              Margin Auto

              当设置为 auto 时,浏览器会自动计算外边距。用得最多的就是 margin: automargin: 0 auto,可使得块级元素水平居中对齐。

              以上两种写法是 margin: auto auto automargin: 0 auto 0 auto 的简写。

              假设要实现以下这种布局:子元素宽度是父元素的 30%,且居右对齐。

              那么我们只要给子元素设置一个 margin-left: auto 就可居右对齐。

              <div style="border: 1px solid red">
                <div style="width: 30%; height: 100px; margin-right: auto; background: green"></div>
              </div>
              

              一般情况下,给上下外边距设置 auto 无效的原因是浏览器自动计算结果为 0,此时 marign: auto 相当于 margin: 0 auto。对于左右外边距而言,如果一侧定宽,一侧 auto,auto 则为剩余空间大小。如果两侧均为 auto,则两侧平分剩余空间。

              其实 margin: auto 在特定条件下可以使得元素在水平和垂直方向实现居中,那就是绝对定位的元素。

              <div id="parent">
                <div id="child"></div>
              </div>
              
              #parent {
                position: relative;
                margin: 0 auto;
                border: 1px solid red;
                width: 200px;
                height: 200px;
              }
              
              #child {
                position: absolute;
                top: 0;
                right: 0;
                bottom: 0;
                left: 0;
                margin: auto;
                width: 100px;
                height: 100px;
                background: green;
              }
              

              实现居中对齐的关键点:一是上下左右偏移值均为 0,二是 margin: auto

              Margin 与 Relative Position 的区别

              某些情况下,使用 margin 或 position 都能实现相同的排版。比如:

              但是,如果我们向 item 元素中追加一个子元素,二者区别就能窥探一二了。

              尽管前面示例,在效果上都是“向下偏移”了 30 个像素,但是 margin 会影响文档流的位置,除了其本身位置发生偏移,其后的处于标准文档流的元素都跟着发生变化。而 position: relative 则不同,它自对自身有影响,它本身在文档流中的位置并无发生变化,因此其后元素仍处在原有位置,因此截图中看到两个元素在表现上重叠在一起了。

              CodeSandbox Demo

              外边距折叠

              我们知道,假设有如下两个相邻的普通(块级)元素,两个元素之间的外边距会发生重叠,取最大值 50px 作为两者之间的距离,这种现象称为「外边距折叠(margin collapsing)」。

              <div style="margin-bottom: 30px">Hey,</div>
              <div style="margin-top: 50px">Frankie</div>
              

              为什么要折叠?

              CSS1 Vertical formatting

              The width of the margin on non-floating block-level elements specifies the minimum distance to the edges of surrounding boxes. Two or more adjoining vertical margins (i.e., with no border, padding or content between them) are collapsed to use the maximum of the margin values. In most cases, after collapsing the vertical margins the result is visually more pleasing and closer to what the designer expects.

              CSS2 Collapsing margins

              In CSS, the adjoining margins of two or more boxes (which might or might not be siblings) can combine to form a single margin. Margins that combine this way are said to collapse, and the resulting combined margin is called a collapsed margin.

              从规范描述可知,这种折叠行为是故意为之,目的是使得内容排版在视觉上更美观。另外,有兴趣可以翻翻这文章的回答,历史原因似乎跟 <p> 元素排版有关,然后一直延续下来。

              什么时候发生折叠?

              折叠只发生块级元素的垂直方向上,水平方向是永远不会的。

              <div style="margin-bottom: 30px; display: inline-block">Hey,</div>
              <div style="margin-top: 50px">Frankie</div>
              

              上述示例,由于第一个元素设为 inline-block,因此两个元素之间未产生折叠现象,两者在垂直方向的间距为 80px。下文若无特别说明,在介绍折叠的时候默认元素为块级元素。

              我们知道,标准文档流中元素会从左到右、从上往下的流式排列。如果一个元素脱离了文档流,该元素会从默认排列中移去,不再占据空间,其后的元素会往上或往左移动。脱离文档流的方式有 float、absolute position 和 fixed position。

              当两个元素满足以下条件时会发生外边距折叠:

              1. 处于同一块级格式上下文(BFC)中的两个块级元素,而且元素均属于标准文档流。
              2. 两个元素之间没有被非空元素(包括有内容,但高度为 0 的情况)、清除浮动、边框、内边距隔开。
              3. 两个元素在垂直方向上是「相邻」的

              满足以下情况则视为相邻(毗邻):

              • 元素的 margin-top 和它的第一个标准文档流子元素的 margin-top
              • 元素的 margin-bottom 和它的下一个标准文档流元素的 margin-top
              • 如果父级自动计算高度,元素是最后一个且为标准文档流,元素的 margin-bottom 和父级元素的 margin-bottom
              • 元素(本身)的 margin-topmargin-bottom,且未创建新的块级格式上下文的标准文档流元素,其高度表现为零。包括 height: 0min-height: 0height: auto 且标准文档流的无子元素三种情况。

              注意点:

              • 元素相邻不一定是兄弟或祖先元素。
              • 浮动元素与其他任意元素不会发生折叠。
              • 当一个元素创建了新的块级格式上下文后,它不会与其子元素发生折叠。
              • 绝对定位的元素不会发生折叠,无论是其兄弟元素,还是其子元素。
              • 行内元素不会发生折叠。
              • 当发生折叠时,产生的边距取值分为几种情况:
                • 外边距均为正数时,取最大值。
                • 外边距均为负数时,取绝对值最大的值。
                • 外边距为一正一负时,取两者相加的和。

              折叠示例

              接下来,将列举部分示例说明。

              示例一

              <div style="height: 20px; background: red"></div>
              <div style="height: 40px; background: green; margin-top: 20px">
                <div style="height: 20px; background: blue; margin-top: 50px"></div>
              </div>
              

              绿色块(父)与蓝色块(子) margin-top 发生了折叠,且取最大值,因此与红色块的边距是 50px。

              示例二

              <div style="height: 20px; background: red"></div>
              <div style="background: green; margin-bottom: 20px">
                <div style="height: 20px; background: blue; margin-bottom: 50px"></div>
              </div>
              <div style="height: 20px; background: yellow"></div>
              

              绿色块元素为自动高度,此时绿色块(父)与蓝色块(子) margin-bottom 发生了折叠,且取最大值,因此与黄色块的边距是 50px。

              示例三

              <div style="height: 20px; background: red"></div>
              <div style="background: green; margin-top: 20px; margin-bottom: 20px"></div>
              <div style="height: 20px; background: blue"></div>
              

              中间绿色自动高度,且无子元素,相当于 height0。此时绿色块本身发生折叠,因此红色块与蓝色块之间的边距为 20px。

              但如果我们给零高的元素加些内容,它就不会折叠了。比如:

              <div style="height: 20px; background: red"></div>
              <div style="background: green; margin-top: 20px; margin-bottom: 20px; height: 0">some text...</div>
              <div style="height: 20px; background: blue"></div>
              

              示例四

              <div style="height: 20px; background: red"></div>
              <div style="background: green; margin-bottom: 20px">
                <div style="height: 20px; position: absolute"></div>
                <div style="height: 20px; background: blue; margin-top: 50px"></div>
              </div>
              

              可以看到红色块与蓝色块的边距为 50px,但蓝色块并非是红色块的第一个子元素,但它是红色块的第一个标准文档流的子元素,因此这种情况也是满足相邻条件的。

              示例五

              <div style="height: 20px; background: red; margin-bottom: 20px"></div>
              <div></div>
              <div style="height: 20px; background: green; margin-top: 50px"></div>
              

              image.png

              中间隔了个零高元素,且无子元素,也会发生折叠,边距为 50px。

              我们将中间的元素加一些内容,并显式指定 height0

              <div style="height: 20px; background: red; margin-bottom: 20px"></div>
              <div style="height: 0">some text...</div>
              <div style="height: 20px; background: green; margin-top: 50px"></div>
              

              尽管中间元素仍为零高,可由于其子元素有内容,因此红色块与绿色块并未发生折叠,边距为 70px。

              如何清除外边距折叠?

              根据前面发生折叠的条件可知,只要破坏任一条件,使其不满足就能清除折叠现象,通常采用创建新的块级格式上下文的方式。

              创建新 BFC 的几种方式:

              • float 不为 none
              • overflow 的值不为 visible
              • display 的值为 table-celltable-captioninline-block 中任意一个。
              • position 不为 relativestatic

              由于 BFC 内的子元素无论如何排列,都不会影响外部的元素,因此也可以用于避免外边框折叠。

              负值 Margin

              平常使用 Margin 都是正值居多,那么负值 Margin 会发生什么有趣的事情呢?

              简单总结:

              属性 设置负值的行为
              margin-top 元素本身向上偏移
              margin-left 元素本身向左偏移
              margin-right 元素本身不偏移,其右边元素向左偏移
              margin-bottom 元素本身不偏移,其下方元素向上偏移

              一些奇奇怪怪的表现:

              • 若块级元素父级为自动高度,子元素中设置负值 margin-top,会「改变」元素父级在标准文档流中占用的高度。
              • 若块级元素为自动宽度,元素设置负值 margin-left 或 margin-right,会使得元素向左侧或右侧增加宽度。

              负值的外边距折叠

              当发生折叠时,如果都是正值的话,取最大值作为边距。但如果外边距为一正一负或者均为负值呢?

              • 外边距为一正一负时,取两者相加的和。
              • 外边距均为负数时,取绝对值最大的值。

              一正一负

              <div id="parent">
                <div id="child1" style="height: 20px; background: red; margin-bottom: 20px"></div>
                <div id="child2" style="height: 20px; background: green; margin-top: -30px"></div>
              </div>
              

              上述示例,child1 和 child2 发生折叠,元素间的边距为两数之和 -10px,因此 child2 向上偏移 10px,覆盖了 child1 的下半部分。请注意,文档流只会向上或向左流动,不能向下或向右流动的

              均为负数

              <div id="parent">
                <div id="child1" style="height: 20px; background: red; margin-bottom: -20px"></div>
                <div id="child2" style="height: 20px; background: green; margin-top: -30px"></div>
              </div>
              

              上述示例,child1 和 child2 发生折叠,元素间的边距取绝对值最大者,即为 -30px。所以 child2 向上偏移 30px,child2 覆盖了 child1 的上半部分。

              负值 margin-top、margin-bottom

              无论正值,还是负值,垂直方向的外边距只对块级元素产生作用。同样地,无论正负值都会影响元素在标准文档流的位置或空间,只不过正值是直觉性的,理解起来很自然。而负值似乎有点反直觉罢了。

              属性 设置负值的行为
              margin-top 元素本身向上偏移
              margin-bottom 元素本身不偏移,其下方元素向上偏移

              另外,如果元素父级是自动计算的高度,在子元素中设置负值的 margin-top 或 margin-bottom 的话,最终会影响父级在标准文档流中的高度。

              负值 margin-top

              <div id="parent">
                <div id="child1" style="height: 60px; background: red"></div>
                <div id="child2" style="height: 20px; background: green; margin-top: -60px"></div>
                <div id="child3" style="height: 20px; background: blue"></div>
              </div>
              

              上述示例,child2 设置了 margin-top: -60px,元素自身(绿色块)向上偏移了 60px,所以跟 child1 的上方重合。当 child2 在标准文档流中的位置向上偏移后,其后的 child3 元素也跟着向上流动,紧跟在 child2 之后。

              如果 child2 未设置 margin-top: -60px 的话,parent 元素的高度应为 height(child1 + child2 + child3) = 100px。可设置负值 margin-top 后,使得从 child2 元素起标准文档流的位置向上移动了 60px,所以 parent 元素的高度为 height(child1 + child2 + child3) + 垂直方向的偏移值,即 60 + 20 + 20 - 60 = 40px

              负值 margin-bottom

              <div id="parent">
                <div id="child1" style="height: 40px; background: red"></div>
                <div id="child2" style="height: 20px; background: green; margin-bottom: -60px"></div>
                <div id="child3" style="height: 20px; background: blue"></div>
              </div>
              

              以上示例,child2 设置了 margin-bottom: -60px,元素本身(绿色块)未发生偏移,仍在紧跟在 child1 之后。但 child2 的负值 margin-bottom 会使得其下方的 child3 元素向上偏移 60px,所以跟 child1 的上方重合。看起来像换了位置一样。

              如果 child2 未设置 margin-top: -60px 的话,parent 元素的高度应为 height(child1 + child2 + child3) = 80px。虽然负值的 margin-bottom 不会使自身在标准文档流的位置向上移动,但它会使其下方的元素在标准文档流的位置也会发生变化,且向上移动,有点像给下方元素设置了负值 margin-top 的意思,所以 parent 元素的高度为 height(child1 + child2 + child3) + 垂直方向的偏移值,即 40 + 20 + 20 - 60 = 20px

              小结

              负值的 margin-top 和 margin-bottom 都会使得元素在标准文档流中的位置,区别在于影响本身还是其后的元素,进而可能会影响到父级元素在文档流中占用的空间(指高度)。当然,如果父级元素指定了具体高度,将不会其高度将不会受到影响。

              尽管前面设置了负值边距,但是还是可以看到他们被完整地绘制出来了,原因是元素的 overflow 属性默认溢出可见,如果前面的示例中 parent 元素指定 overflow: hidden 的话,你将会看到只有 40px 和 20px 的大小。还有,子元素占用的空间大小是不会因为设置了 margin-top/margin-bottom 值发生变化的。

              可以简单地这样理解,在文档流的眼里,元素的在文档流中的开始位置是由 margin 决定的,为正数则增加,为负数则减小,而不是看元素实际大小的。

              负值 margin-left 和 margin-right

              我们知道,对于自动宽度的块级元素,设置正值的 margin-left 或 margin-right 会改变减小元素宽度。相反地,负值会增加宽度。

              属性 设置负值的行为
              margin-left 元素本身向左偏移
              margin-right 元素本身不偏移,其右边元素向左偏移

              增加宽度

              我们给一个自动计算宽度的块级元素设置左右外边距设为 -20px,它的宽度增长了 40px。

              <div style="border: 1px solid red; margin-left: -20px; margin-right: -20px">关关雎鸠,在河之洲。窈窕淑女,君子好逑。参差荇菜,左右流之。窈窕淑女,寤寐求之。求之不得,寤寐思服。悠哉悠哉,辗转反侧。参差荇菜,左右采之。窈窕淑女,琴瑟友之。参差荇菜,左右芼之。窈窕淑女,钟鼓乐之。</div>
              <div style="height: 20px; background-color: green; margin-top: 20px"></div>
              

              负值 margin-left

              我们在这段文字中插入了一个 inline 元素和一个 inline-block 元素,未设置 margin 的表现如下:

              <div style="border: 1px solid red">关关雎鸠,在河之洲。窈窕淑女,君子好逑。参差荇菜,左右流之。窈窕淑女,寤寐求之。 <span style="background-color: darkorange; margin-left: 0px">我是 inline 元素。</span>求之不得,寤寐思服。悠哉悠哉,辗转反侧。<span style="display: inline-block; background-color: plum; margin-left: 0px">我是 inline-block 元素。</span>参差荇菜,左右采之。窈窕淑女,琴瑟友之。参差荇菜,左右芼之。窈窕淑女,钟鼓乐之。</div>
              

              接着,给 inline 和 inline-block 元素加上 margin-left: -50px

              <div style="border: 1px solid red">关关雎鸠,在河之洲。窈窕淑女,君子好逑。参差荇菜,左右流之。窈窕淑女,寤寐求之。<span style="background-color: darkorange; margin-left: -50px">我是 inline 元素。</span>求之不得,寤寐思服。悠哉悠哉,辗转反侧。<span style="display: inline-block; background-color: plum; margin-left: -50px">我是 inline-block 元素。</span>参差荇菜,左右采之。窈窕淑女,琴瑟友之。参差荇菜,左右芼之。窈窕淑女,钟鼓乐之。</div>
              

              可以看到除了 inline 和 inline-block 元素向左偏移之外,其后的内容也是跟着移动的。说明它们的在标准文档流的位置发生了变化。

              负值 margin-right

              还是在前面的示例基础上,给 inline 和 inline-block 元素加上 margin-right: -50px

              <div style="border: 1px solid red">关关雎鸠,在河之洲。窈窕淑女,君子好逑。参差荇菜,左右流之。窈窕淑女,寤寐求之。<span style="background-color: darkorange; margin-right: -50px">我是 inline 元素。</span>求之不得,寤寐思服。悠哉悠哉,辗转反侧。<span style="display: inline-block; background-color: plum; margin-right: -50px">我是 inline-block 元素。</span>参差荇菜,左右采之。窈窕淑女,琴瑟友之。参差荇菜,左右芼之。窈窕淑女,钟鼓乐之。</div>
              

              请仔细观察,inline 元素(本身)并未发生移动,但其右边的元素则向左偏移,导致覆盖在元素上了,inline-block 同理。对比前面负值 margin-left 更明显。

              小结

              对于 margin-left 和 margin-right 无论正负值都比较容易理解的。

              负值 Margin 的应用

              自适应三栏布局

              相信大家都听说过「圣杯布局」、「双飞翼布局」这些经典的三栏布局,本质上就是利用了浮动和负值 Margin 实现的。当然,这些经典布局方式有更现代化的解决方案,像 Flex、Grid 等,再利用 Media Queries 可以实现适配移动端、PC 端的响应式布局。

              圣杯布局是由 Matthew Levine 于 2006 年提出的,后来国内提出了改进版的双飞翼布局,据说是玉伯大佬提出的,我未找到出处或原贴。下面将会介绍两者的区别,以及改进了什么问题。

              圣杯布局(Holy Grail Layout)

              出处:In Search of the Holy Grail

              圣杯布局长这样 👇,上下分别为 header、footer,中间是三列布局,有 left、middle、right,其中左右是定宽的,中间则根据窗口大小自适应,而且 container 的高度也是根据内容自适应调整的。

              DOM 结构如下:

              <div id="header"></div>
              <div id="container">
                <div id="middle"></div>
                <div id="left"></div>
                <div id="right"></div>
              </div>
              <div id="footer"></div>
              

              说明:

              • container 设置 padding-leftpadding-right,以腾出位置留给 left、right,除去左右内边距余下的宽度将留给 middle 作为主要内容区域。
              • container 内三个子元素 middle、left、right 均会设为左浮动,因此 container 不占据标准文档流的空间,因而 footer 要清除浮动以避免高度坍塌。
              • container 内 DOM 顺序为 middle、left、right,目的是使得 middle 内容优先渲染。
              • 基于以上顺序设计,需利用负值 margin-left 使 left 和 right 位置发生偏移:
                • left 设 margin-left-100% 使其偏移至 container 左侧;
                • right 设 margin-left 为自身宽度大小,加上 left 的 -100%,right 实际向左的偏移量为 100% + 自身宽度,因此偏移至 container 右侧。
                • 前两项设置,使得 left 和 right 处于 container 的左侧和右侧,且由于它们在 DOM 上排后,因此会覆盖在 middle 上。此时,需将 left 和 right 设为相对布局,并分别向左、向右偏移值是前面 container 设置的左右内边距大小。
              • 由于 container 均为浮动元素,且 footer 清除浮动,因此 container 表现出来的“高度”将取决于三者最大值。

              实现:

              <div id="container">
                <div id="middle" class="column"></div>
                <div id="left" class="column"></div>
                <div id="right" class="column"></div>
              </div>
              <div id="footer"></div>
              
              #header {
                height: 50px;
                background: #eee;
              }
              
              #container {
                padding-right: 100px; /* left 宽度大小 */
                padding-left: 100px; /* right 宽度大小 */
              }
              
              #middle,
              #left,
              #right {
                float: left;
              }
              
              #middle {
                width: 100%;
                background-color: green;
              }
              
              #left {
                position: relative;
                left: -100px; /* 使其位置偏移至 container 的 padding-left 区域 */
                margin-left: -100%; /* 本身及其后元素均向左偏移 100%(相对于父元素宽度,即 container 的宽度) */
                width: 100px;
                background-color: red;
              }
              
              #right {
                position: relative;
                right: -100px; /* 使其位置偏移至 container 的 padding-right 区域 */
                margin-left: -100px; /* 使其再向左偏移 100px,该指需与 container 的 padding-right 和本身大小一致 */
                width: 100px;
                background-color: blue;
              }
              
              #footer {
                clear: both; /* 清除浮动 */
                height: 50px;
                background: #eee;
              }
              
              .column {
                height: 300px;
              }
              

              圣杯布局有一缺点是当浏览器窗口过小,布局就完全变形了,比如:

              那么当窗口缩小到多少会发生变形呢?

              我们来分析一下原因,前面 middle、left、right 的宽度分别为 100%(即父级元素的宽度)、100px100px。当窗口缩小至 width(middle) < width(left) 时,就开始变形。假设 middle 的宽度为 47px,那么 left 元素的 margin-left: -100% 向左偏移的值为 47px,此时 left 元素的文档流(脱离文档流)左侧起始位置与 middle 左侧重合,但由于 middle 的宽度为 47px,而 left 的宽度为 100px,也就是 middle 这行容不下 left 元素,因此 left 就“移”到下一行去了。同时受到 margin-left: -47pxposotion: relative; left: -100px 的影响,元素先向左偏移了 47px,然后再基于当前位置再向左偏移 100px,于是仅剩下上图红色可见部分。

              因此,Matthew Levine 在 In Search of the Holy Grail 一文中通过控制 body 的最小宽度处理该问题。

              body {
                min-width: 550px;  /* 2x LC width + RC width */
              }
              

              双飞翼布局

              玉伯大佬提出的双飞翼布局,两者有什么区别呢?

              先对比下 DOM 结构:

              <!-- 圣杯布局 -->
              <div id="header"></div>
              <div id="container">
                <div id="middle"></div>
                <div id="left"></div>
                <div id="right"></div>
              </div>
              <div id="footer"></div>
              
              <!-- 双飞翼布局 -->
              <div id="header"></div>
              <div id="middle">
                <div id="content"></div>
              </div>
              <div id="left"></div>
              <div id="right"></div>
              <div id="footer"></div>
              

              区别在于双飞翼布局,在 middle 内增加了一个元素 content,其中 content 设置左右外边距以避免内容被 left 和 right 覆盖住。另外由于不用再像圣杯布局那样在 container 中设置左右内边距,因此可以把该元素干掉。

              实现:

              <div id="header"></div>
              <div id="middle">
                <div id="content" class="column"></div>
              </div>
              <div id="left" class="column"></div>
              <div id="right" class="column"></div>
              <div id="footer"></div>
              
              #header {
                height: 50px;
                background: #eee;
              }
              
              #middle,
              #left,
              #right {
                float: left;
              }
              
              #middle {
                width: 100%;
              }
              
              #content {
                margin-right: 100px;
                margin-left: 100px;
                background-color: green;
              }
              
              #left {
                margin-left: -100%;
                width: 100px;
                background-color: red;
              }
              
              #right {
                margin-left: -100px;
                width: 100px;
                background-color: blue;
              }
              
              #footer {
                clear: both;
                height: 50px;
                background: #eee;
              }
              
              .column {
                height: 300px;
              }
              

              但其实它也并没有解决圣杯模式窗口缩小的问题,当 width(middle) < width(left) 时仍会变形,left 部分会掉下来。

              小结

              随着 CSS 越来越强大,实现上述三列布局有更多、更好的现代化解决方案,大家可以尝试使用 Flex、Grid 等方式实现,此文就不再展开了。

              另外,前面两种布局方式,都将 middle 部分放在前面,也就是说在 DOM 渲染时优先渲染主要内容部分,所以它会导致 DOM 顺序与视觉顺序不一致,进而影响到可访问性(Accessibility,A11Y),又称无障碍。当视障人群使用屏幕阅读器等工具访问网页时,由于顺序的不一致,它们可能会感到困惑。

              相关文章:Source order and display order should match

              多列等高布局

              同样地,实现这种布局有 Flex、Grid 等现代化的解决方案。这里介绍一种利用了 float、margin 和 padding 实现「视觉等高」的方式。

              以三列等高为例,其 DOM 结构如下:

              <div id="container">
                <div id="left"></div>
                <div id="middle"></div>
                <div id="right"></div>
              </div>
              

              这里我们给 left、middle、right 三个元素设置背景色以便于判断是否等高。还有需将 left、middle、right 设为 float: left,每个元素设置同等大小的正直 padding-bottom 和负值 margin-bottom,并且这个值要足够大。我们知道负值 margin-bottom 会使得其后元素的文档流位置向上偏移,若它本身是最后一个元素,就相当于自身向上偏移。在 padding-bottommargin-bottom 的同时作用下,使得文档流最后的位置与该元素高度底部对应的重合,这样也就能按内容自适应高度了。而且由于背景色区域也包括 padding 部分,因此视觉上看着就等高,实则不是。最后,要记得往 container 部分加上 overflow: hidden,否则渲染出来的高度为三者中 height + padding-bottom 最大的那个。

              #container {
                overflow: hidden;
                margin: 0 auto;
                width: 100%;
              }
              
              #left {
                float: left;
                margin-bottom: -1000px; /* 示例里就不设过大的值了 */
                padding-bottom: 1000px;
                width: 33.33%;
                height: 100px;
                background-color: red;
              }
              
              #middle {
                float: left;
                margin-bottom: -1000px;
                padding-bottom: 1000px;
                width: 33.33%;
                height: 200px;
                background-color: green;
              }
              
              #right {
                float: left;
                margin-bottom: -1000px;
                padding-bottom: 1000px;
                width: 33.34%;
                height: 300px;
                background-color: blue;
              }
              
              #footer {
                height: 100px;
                background: #eee;
              }
              

              现在 container 整体的高度取决于三者最大的那个,也就是 right 的 300px。

              但如果我们将 right 的高度设为 1300px 的话,这种布局的问题就暴漏出来了,它们不等高了,原因很简单 left 和 right 的背景色高度是 height + padding-bottom,也就是 1100px 和 1200px,所以就不等高了(如下图所示)。当然,这个问题也很好解决,实际应用中把 padding-bottom 设得足够大即可。本文是为了举例故而设得较小。

              去除边框

              还是利用负值 margin-bottom 呗。

              示例一

              <ul>
                <li>关关雎鸠,在河之洲。窈窕淑女,君子好逑。</li>
                <li>参差荇菜,左右流之。窈窕淑女,寤寐求之。</li>
                <li>求之不得,寤寐思服。悠哉悠哉,辗转反侧。</li>
                <li>参差荇菜,左右采之。窈窕淑女,琴瑟友之。</li>
                <li>参差荇菜,左右芼之。窈窕淑女,钟鼓乐之。</li>
              </ul>
              
              li {
                border-bottom: 2px solid red;
              }
              

              假设我们要干掉最后一个 <li> 的下边框,首先想到的可能是 li:last-child { border-bottom: none } 等常规解法。然后我们今天充分了解 Margin 的特性后,我们可以使用 margin-bottom 来解决,比如:

              #box {
                overflow: hidden;
              }
              
              ul {
                margin-bottom: -2px;
              }
              
              li {
                border-bottom: 2px solid red;
              }
              
              <div id="box">
                <ul>
                  <li>关关雎鸠,在河之洲。窈窕淑女,君子好逑。</li>
                  <li>参差荇菜,左右流之。窈窕淑女,寤寐求之。</li>
                  <li>求之不得,寤寐思服。悠哉悠哉,辗转反侧。</li>
                  <li>参差荇菜,左右采之。窈窕淑女,琴瑟友之。</li>
                  <li>参差荇菜,左右芼之。窈窕淑女,钟鼓乐之。</li>
                </ul>
              </div>
              

              上面 margin-bottom 使得文档流向上偏移 2px,也就是说 <ul> 的高度减少了 2px,所以我们在外层设置一个 overflow: hidden 就能隐藏最底下的边框了。

              示例二

              <ul>
                <li>关关雎鸠,在河之洲。窈窕淑女,君子好逑。</li>
                <li>参差荇菜,左右流之。窈窕淑女,寤寐求之。</li>
                <li>求之不得,寤寐思服。悠哉悠哉,辗转反侧。</li>
                <li>参差荇菜,左右采之。窈窕淑女,琴瑟友之。</li>
                <li>参差荇菜,左右芼之。窈窕淑女,钟鼓乐之。</li>
              </ul>
              
              ul {
                border: 2px solid red;
              }
              
              li {
                margin-bottom: -2px;
              }
              

              假设我们要实现一个类似于单行簿的样式,其中 <ul> 外部设置了 2px 的边框,它与最后一个 <li> 下边框重复了,因此我们可以给 <li> 设置一个 margin-bottom: -2px 即可。但注意,这种方式会导致每个元素都会减少 2px,所以这里设置后会总体减少 10px。

              li {
                margin-bottom: -2px;
                border-bottom: 2px solid red;
              }
              

              宫格布局

              要实现这样的布局,要怎么做呢?为了介绍 Margin 的应用,下面我们不用 Flex 等布局方式哈。

              假设每个格子宽高为 100px,格子间距为 10px,这样的话容器的宽度应该为 540px。我们脑海中第一反应可能是使用 nth-child() 等方式设置右侧的 margin-right 为 0,但如果是 3 列或 4 列呢,计算还有点烦,那么我们是不是可以利用负值 margin-right 可以增加元素宽度的特性去解决呢?

              其 DOM 结构如下:

              <div id="container">
                <div id="box">
                  <div class="item"></div>
                  <div class="item"></div>
                  <div class="item"></div>
                  <div class="item"></div>
                  <div class="item"></div>
                  <div class="item"></div>
                  <div class="item"></div>
                  <div class="item"></div>
                  <div class="item"></div>
                  <div class="item"></div>
                </div>
              </div>
              
              #container {
                margin: 0 auto;
                border: 1px solid red; /* 边框 */
                width: 540px; /* 容器宽度 */
              }
              
              #box::after {
                display: block;
                clear: both; /* 清除浮动,使得 container 高度不坍塌 */
                content: '';
              }
              
              .item {
                float: left; /* 设为左浮动,使得每个 item 向左排列 */
                margin: 0 10px 10px 0; /* 右下外边距设为 10px,留出间距  */
                width: 100px;
                height: 100px;
                background: #eee;
              }
              

              还不是预期结果,原因很简单:container 的宽度为 540px,由于右外边距的存在,导致每行右侧仅剩下 100px,该行余下空间容纳不了下一个元素(100px 宽度 + 10px 右外边框)。此时对 box 元素设置 marign-right: -10px 以增加其宽度至 550px,原本下一行的第一个元素就能浮上来了,然后再给 container 设置 overflow: hidden 截取掉溢出部分。我们还注意到最后一行也有一个 10px 的下外边距,同理借助 margin-bottom: -10px 使得文档流位置向上偏移就能很好地处理。

              我们加上这样一个样式即可:

              #box {
                margin-right: -10px;
                margin-bottom: -10px;
              }
              

              微信排版布局

              利用负值 Margin 的特性,我们可以作一个「展开」的交互。如果结合零高特性,甚至可以做多次展开。

              一个简单的示例:整体高度从原来的 600px,在点击触发 <svg> 的动画后变为 1200px,与此同时 <svg> 会隐藏可暴漏出前面绿、蓝的元素,以达到展开的效果。

              <section style="width: 350px; margin: 0 auto; overflow: hidden; font-size: 0; line-height: 0">
                <section>
                  <section style="height: 600px; background-color: green"></section>
                  <section style="height: 600px; background-color: blue"></section>
                </section>
                <section style="margin-top: -1200px">
                  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 600" preserveAspectRatio="xMidYMin meet" style="width: 100%; max-width: none !important; background-color: red; pointer-events: none">
                    <animate attributeName="width" begin="click" from="100%" to="200%" calcMode="linear" dur="2s" fill="freeze" restart="never" />
                    <animate attributeName="opacity" begin="click" from="1" to="0" calcMode="linear" dur="0.001s" fill="freeze" restart="never" />
                    <rect x="0" y="0" width="100%" height="100%" fill="transparent" style="pointer-events: visible">
                      <set attributeName="visibility" begin="click" to="hidden" fill="freeze" restart="never" />
                    </rect>
                  </svg>
                </section>
              </section>
              

              参考链接

              ]]>
              <![CDATA[解决 macOS 自动更换桌面位置的问题]]> https://github.com/tofrankie/blog/issues/310 https://github.com/tofrankie/blog/issues/310 Fri, 07 Apr 2023 09:20:43 GMT 本来 macOS 的多桌面结合触控板能带来极好的体验,但是有一个很头疼的问题是:系统会根据使用情况「自动」更换桌面位置。

              可以在「系统偏好设置 - 调度中心」将「根据最近的使用情况自动排列空格」选项关闭。困扰了很久的问题终于解决了。

              ]]>
              <![CDATA[npm install 指定版本]]> https://github.com/tofrankie/blog/issues/309 https://github.com/tofrankie/blog/issues/309 Wed, 05 Apr 2023 04:37:09 GMT 配图源自 Freepik

              npm 安装包的方式很多很多,本文就以下几种方式作简要介绍。

              配图源自 Freepik

              npm 安装包的方式很多很多,本文就以下几种方式作简要介绍。

              $ npm install <name>
              $ npm install <name>@<tag>
              $ npm install <name>@<version>
              $ npm install <name>@<version range>
              

              npm install <name>

              它其实就是 npm install <name>@<tag> 简写形式。由于 <tag> 默认为 latest(最新版本),因此在不作任何配置的情况下 npm install <name> 会按照该包的最新版本。

              npm install <name>@<tag>

              tag 是什么?

              在软件开发的过程中,如有重大的版本变更,我们通常会给它打上一个「标签」,它只是一个更友好、易于理解的「别名」罢了。像平常使用 Git 做版本管理时,也会用 git tag 打标签。

              有哪些 tag?

              默认情况下,latest 标签用来标识某个包的最新版本。

              除了 latest 之外,没有任何标签对 npm 本身有任何特殊意义。

              一个包如有多个开发流程可以自定义一些有意义的 tag,比如:stablealphabetadevcanarynext。除了 latest 之外的所有 tag,都需要在手动指定。

              如何查看 tag?

              通过 npm-dist-tag 命令:

              $ npm dist-tag ls <name>
              

              举个例子:

              $ npm dist-tag ls lodash-es
              latest: 4.17.21
              
              $ npm dist-tag ls react
              beta: 18.0.0-beta-24dd07bd2-20211208
              experimental: 0.0.0-experimental-b14f8da15-20230403
              latest: 18.2.0
              next: 18.3.0-next-b14f8da15-20230403
              rc: 18.0.0-rc.3
              

              或在 npmjs.com 等平台查看:

              如何添加/移除 tag?

              在发包时通过 --tag 参数指定,若无指定,则默认为 latest。比如:

              $ npm publish --tag <tag>
              

              对于已发布的版本,可以通过 npm dist-tag addnpm dist-tag rm 来添加/移除 tag。比如:

              $ npm dist-tag add <name>@<version> <tag>
              $ npm dist-tag rm <name> <tag>
              

              举个例子:

              # 未指定时,默认为 latest
              $ npm publish
              npm notice 
              npm notice 📦  @summer/my-pack@0.0.1
              npm notice === Tarball Contents === 
              npm notice 27B  README.md   
              npm notice 232B package.json
              npm notice 27B  src/index.js
              npm notice === Tarball Details === 
              npm notice name:          @summer/my-pack                         
              npm notice version:       0.0.1                                   
              npm notice filename:      summer-my-pack-0.0.1.tgz                
              npm notice package size:  342 B                                   
              npm notice unpacked size: 286 B                                   
              npm notice shasum:        84c45d5eb997b714cb1348624b71046ed6c987a8
              npm notice integrity:     sha512-blbi1Q3A8La3A[...]r6BVjGtYZ8rCw==
              npm notice total files:   3                                       
              npm notice 
              npm notice Publishing to http://localhost:4873/ with tag latest and default access
              + @summer/my-pack@0.0.1
              
              $ npm dist-tag ls @summer/my-pack
              latest: 0.0.1
              
              # 发布一个名为 next 的 tag
              $ npm publish --tag next
              npm notice 
              npm notice 📦  @summer/my-pack@0.0.2
              npm notice === Tarball Contents === 
              npm notice 27B  README.md   
              npm notice 232B package.json
              npm notice 27B  src/index.js
              npm notice === Tarball Details === 
              npm notice name:          @summer/my-pack                         
              npm notice version:       0.0.2                                   
              npm notice filename:      summer-my-pack-0.0.2.tgz                
              npm notice package size:  342 B                                   
              npm notice unpacked size: 286 B                                   
              npm notice shasum:        e9575fc34aacf2a08bb4f33a768dcf5ba4aab494
              npm notice integrity:     sha512-kcHYodhWGbAds[...]TU53bhFy46wUw==
              npm notice total files:   3                                       
              npm notice 
              npm notice Publishing to http://localhost:4873/ with tag next and default access
              + @summer/my-pack@0.0.2
              
              $ npm dist-tag ls @summer/my-pack
              latest: 0.0.1
              next: 0.0.2
              
              # 移除 next tag,但注意移除后 latest 仍为 0.0.1 版本
              $ npm dist-tag rm @summer/my-pack next
              -next: @summer/my-pack@0.0.2
              
              $ npm dist-tag ls @summer/my-pack
              latest: 0.0.1
              
              # 手动指定 0.0.2 版本为 latest tag
              $ npm dist-tag add @summer/my-pack@0.0.2 latest
              +latest: @summer/my-pack@0.0.2
              
              # 手动指定 0.0.1 版本为 legacy tag
              $ npm dist-tag add @summer/my-pack@0.0.1 legacy
              +legacy: @summer/my-pack@0.0.1
              
              $ npm dist-tag ls @summer/my-pack
              latest: 0.0.2
              legacy: 0.0.1
              

              安装指定 tag

              前面了解 tag 之后,安装就很容易理解了。

              $ npm install <name>@<tag>
              
              # 相当于
              $ npm install <name> --tag <tag>
              

              如有特殊需要,可通过 npm config set tag <tag> 去配置 tag(详见),后面 npm install 不指定 tag 时,默认取该配置值。

              npm install <name>@<version>

              安装时指定版本或指定版本范围:

              $ npm install <name>@<version>
              $ npm install <name>@<version range>
              

              安装确切版本

              一是安装时指定确切版本,二是配置 save-exacttruenpm config set save-exact true)。比如:

              $ npm install react@18.0.0
              
              # 相当于(假设当前 react 的 latest 为 18.0.0)
              $ npm config set save-exact true
              $ npm install react
              

              那么安装就不会下载符合 ^x.y.z~x.y.z 范围的版本了。

              安装 ^ 或 ~ 版本

              使用 npm install <name> 安装包时,它会以 ^x.y.z 形式添加到 package.json 里面。因为 npm 的 save-prefix 默认配置就是 ^,可通过 npm config set save-prefix '~' 指定为 ~。二者含义,大家都懂就不再展开赘述了。

              安装主要版本的最新版本

              $ npm install <name>@<major-version>
              

              比如 npm install react@16npm install react@16.x 会安装 16.x 中最新的版本 ^16.14.0

              最近也遇到这种需求:目前 ora 最新版本是 6.3.0,该版本不再支持 CommonJS 形式导入,因此使用 const ora = require('ora') 将会报错:

              const ora = require('ora')
                          ^
              
              Error [ERR_REQUIRE_ESM]: require() of ES Module /Users/frankie/Web/Temp/demo/node_modules/ora/index.js from /Users/frankie/Web/Temp/demo/src/index.js not supported.
              

              可安装 5.x 版本以支持 CommonJS(npm install ora@5)。

              npm install <name>@<version range>

              范围可通过使用 ><=- 等限定符组合。比如:

              # 安装范围内的最新版本(17.0.1)
              $ npm install react@">=16.0.0 <17.0.2" 
              
              # 安装大版本为 16 至 17 的最新版本(17.0.2)
              $ npm install react@"16 - 17"
              

              参考链接

              ]]>
              <![CDATA[tsconfig.json 详解]]> https://github.com/tofrankie/blog/issues/308 https://github.com/tofrankie/blog/issues/308 Sat, 25 Mar 2023 14:16:58 GMT 一、简介

              tsconfig.json 是 TypeScript 编译器的配置文件,用于指定编译 TypeScript 代码时的编译选项和编译目标等信息。通过修改该文件,可以定制 TypeScript 编译器的行为,例如指定编译目标、启用或禁用特定的语言特性、设置代码检查]]> 一、简介

              tsconfig.json 是 TypeScript 编译器的配置文件,用于指定编译 TypeScript 代码时的编译选项和编译目标等信息。通过修改该文件,可以定制 TypeScript 编译器的行为,例如指定编译目标、启用或禁用特定的语言特性、设置代码检查规则等。

              1.1 与 jsconfig.json 的关系

              jsconfig.json 源自 tsconfig.json,默认启用了一些与 JavaScript 相关的编译选项,常用于 JavaScript 项目。可以简单理解为设置了 allowJstruetsconfig.json

              比如,当我们使用 Webpack Alias 时,可以往 jsconfig.json 里添加 baseUrlpaths 配置以获得路径智能提示,提高开发体验。

              {
                "compilerOptions": {
                  "baseUrl": ".",
                  "paths": {
                    "@/*": ["./src/*"]
                  }
                }
              }
              

              1.2 创建 tsconfig.json

              方式有二。一是利用 tsc --init 命令添加(详见)。

              $ cd /path/to/project
              $ npm init -y
              $ npm i typescript -D
              $ npx tsc --init
              
              Created a new tsconfig.json with:                                                       
                target: es2016
                module: commonjs
                strict: true
                esModuleInterop: true
                skipLibCheck: true
                forceConsistentCasingInFileNames: true
              
              
              You can learn more at https://aka.ms/tsconfig
              

              二是手动添加,然后参考 TSConfig bases,借鉴各种框架的配置示例,也可直接安装对应 @tsconfig/xxx 包并使用 extends 继承配置。

              1.3 选择编译配置

              通常,若目录下存在 tsconfig.json 文件,则表示该目录是 TypeScript 项目的根目录。尽管如此,在用法上还是要注意。

              # 1️⃣
              $ tsc
              
              # 2️⃣
              $ tsc index.ts
              
              # 3️⃣
              $ tsc --project /path/to/your_tsconfig.json
              

              以上三种方式,编译时的配置稍有不同:

              • 1️⃣ 无输入文件的情况下调用 tsc,编译器会从当前目录开始并逐级向上检索 tsconfig.json 文件作为其编译配置。若一直找不到,则使用默认配置。
              • 2️⃣ 有输入文件的情况下调用 tsc,编辑器将会使用默认配置。
              • 3️⃣ 指定 --project-p 参数情况下调用 tsc,编译器将会使用该路径下的配置文件。

              如果你是刚接触,可参考 TSConfig bases 选择适合你所用框架的配置。

              1.4 配置优先级

              CLI Options > 项目 tsconfig.json 配置 > 默认配置

              二、顶层选项

              tsconfig.json 配置选项非常多,超过 100 个。

              顶层选项有:

              {
                "compileOnSave": true,
                "vueCompilerOptions": {},
                "compilerOptions": {},
                "watchOptions": {},
                "include": [],
                "exclude": [],
                "files": [],
                "typeAcquisition": {},
                "references": [],
                "extends": [],
                "buildOptions": {},
                "ts-node": {}
              }
              

              还包括 Vue.js 中会用到的 vueCompilerOptions 选项等。

              2.1 compileOnSave

              启用该选项,可以让 IDE/Editor 在保存文件时自动编译。

              请注意,它(目前)并没有得到 Visual Studio Code 的支持。仅在 Visual Studio 2015 和安装了 atom-typescript 插件的 Atom 中得到了支持。而该插件和 Atom 均已停止维护。

              2.2 compilerOptions

              该选项是 TypeScript 配置的重头戏,下文再详细介绍。

              2.3 files

              该选项用于指定待编译的文件,接受一个字符串数组,可以是相对路径或绝对路径,但不能是 Glob 模式。

              注意点:

              • 如果指定了 files 选项,则只有指定的文件才会被编译,其他文件会被忽略;
              • 如果 files 内指定的文件引用了其他模块,这些模块也会被编译。
              • 当使用了 files 选项,通常还会指定 compilerOptions.outFilecompilerOptions.outDir 选项以便编译器将编译结果输出到指定文件中。
              • 如果指定了 filesinclude 的默认值为 [],否则为 ["**"]

              2.4 include

              该选项类似于 files 字段,同样用于指定编译器应该包含哪些文件进行编译,未指定时默认编译根目录下所有 TypeScript 文件。区别在于,include 支持 Glob 模式来指定文件路径。

              includeexclude 支持 Glob 模式如下:

              • * 匹配零个或多个字符(除了路径分隔符 /)。
              • ? 匹配一个字符(除了路径分隔符 /)。
              • **/ 匹配零个或多个目录及其子目录。

              若 Glob 模式未指定文件扩展名时,默认支持 .ts.tsx.d.ts。若 allowJstrue,还包括 .js.jsx

              2.5 exclude

              该选项用于指定 include 里面需要忽略的文件。exclude 的默认值为 ["node_modules", "bower_components", "jspm_packages"],但排除文件还包括 outDir 指定值。

              以下示例将会忽略的文件为 ["node_modules", "bower_components", "jspm_packages", "dist"]

              {
                "compilerOptions": {
                  "outDir": "dist"
                },
                "exclude": []
              }
              

              请注意,exclude 字段只影响由 include 字段指定的文件和目录中包含哪些文件,而不会完全排除在 exclude 中列出的文件。也就是说,如果一个文件在 exclude 中列出,但是它被代码中的 import 语句、/// <reference 或者在 filestypes 字段中的文件中引用,那么这个文件仍然会被编译。

              2.6 files、include 和 exclude 的关系

              TS 到底是如何决定要编译哪些文件?

              1. 收集候选文件
              2. 应用排除规则
              3. 形成最终编译文件夹

              不是简单的 files + include - exclude,而是:

              1️⃣ 如果存在 files
                 → 直接使用 files 指定的文件
                 → include / exclude 不参与
              
              2️⃣ 如果不存在 files
                 → 使用 include(或默认 include)
                 → 再应用 exclude
              
              • files - 准确指定参与编译的文件路径,不支持 Glob 形式。一般不直接使用该选项。
              • include - 指定参与编译的文件范围,通常是 Glob 形式。
              • exclude - 排除 include 范围内的黑名单文件。如果 include 中白名单文件引用了黑名单文件,这个黑名单文件最终也会参与到编译。

              2.7 references

              references 是 TS 3.0 的一项新功能,详见 Project References

              它的优点是大幅提升构建时间,并以更好的方式组织代码。

              现在,一个现代工程化的 Web 项目,除了业务代码外,少不了各种构建脚本、测试文件等。它们运行运行环境不同,比如 Web 的宿主环境是浏览器,而构建工具、测试文件的宿主环境是 Node.js。不同的宿主环境,其 tsconfig.json 配置是不同的,比如浏览器环境需要 lib: ["ES2022", "DOM"],而 Node.js 环境则是 lib: ["ES2022"] + types: ["node"]。为了在一个项目既支持浏览器环境、Node.js 我们可能要做出这样的选择 lib: ["ES2022", "DOM"] + types: ["node"],但这样是有风险的,如果在业务代码中导入了 Node.js 的 API,由于 TS 配置的原因,编译时是 OK 的,但运行时就可能会运行错误。

              使用 references 可以使用新的组织方式来解决这个问题。

              以 Vite + react-ts 模板为例,它包含了三个文件:

              • tsconfig.ts - 它只是调度器,不产出 JS 或类型声明文件
              • tsconfig.app.ts - 浏览器环境,通过 include 选项关联文件范围,比如 src
              • tsconfig.node.ts - Node 环境,通过 include 选项关联文件范围,比如 vite.config.ts

              假设引用的子配置是可以单独构建的,需在子配置中指定 compositetrue

              这样浏览器环境和 Node.js 环境就不会混淆了,TS 可以识别到,当在业务代码使用 Node.js 的 API 就可以看到编译错误了,反之亦然。

              ▼ tsconfig.js

              {
                "files": [],
                "references": [
                  { "path": "./tsconfig.app.json" },
                  { "path": "./tsconfig.node.json" }
                ]
              }
              

              ▼ tsconfig.app.js

              {
                "compilerOptions": {
                  "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
                  "target": "ES2022",
                  "useDefineForClassFields": true,
                  "lib": ["ES2022", "DOM", "DOM.Iterable"],
                  "module": "ESNext",
                  "types": ["vite/client"],
                  "skipLibCheck": true,
              
                  /* Bundler mode */
                  "moduleResolution": "bundler",
                  "allowImportingTsExtensions": true,
                  "verbatimModuleSyntax": true,
                  "moduleDetection": "force",
                  "noEmit": true,
                  "jsx": "react-jsx",
              
                  /* Linting */
                  "strict": true,
                  "noUnusedLocals": true,
                  "noUnusedParameters": true,
                  "erasableSyntaxOnly": true,
                  "noFallthroughCasesInSwitch": true,
                  "noUncheckedSideEffectImports": true
                },
                "include": ["src"]
              }
              

              ▼ tsconfig.node.js

              {
                "compilerOptions": {
                  "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
                  "target": "ES2023",
                  "lib": ["ES2023"],
                  "module": "ESNext",
                  "types": ["node"],
                  "skipLibCheck": true,
              
                  /* Bundler mode */
                  "moduleResolution": "bundler",
                  "allowImportingTsExtensions": true,
                  "verbatimModuleSyntax": true,
                  "moduleDetection": "force",
                  "noEmit": true,
              
                  /* Linting */
                  "strict": true,
                  "noUnusedLocals": true,
                  "noUnusedParameters": true,
                  "erasableSyntaxOnly": true,
                  "noFallthroughCasesInSwitch": true,
                  "noUncheckedSideEffectImports": true
                },
                "include": ["vite.config.ts"]
              }
              

              未完待续...

              三、参考链接

              ]]>
              <![CDATA[CSS 三角形详解]]> https://github.com/tofrankie/blog/issues/307 https://github.com/tofrankie/blog/issues/307 Sat, 25 Mar 2023 09:38:00 GMT 配图源自 Freepik

              本文将会详细介绍如何使用纯 CSS 实现各种三角形。文末会推荐一个在线工具。<]]> 配图源自 Freepik

              本文将会详细介绍如何使用纯 CSS 实现各种三角形。文末会推荐一个在线工具。

              了解边框

              我们知道,任何 HTML 元素其实都是一个矩形的盒子。通过以下 CSS 属性可以设置元素的四条边框:

              • border-top
              • border-right
              • border-bottom
              • border-left

              话不多说,先看示例:

              <!-- 为了方便截图,父元素宽度设置为 300px -->
              <div class="rect">some text...</div>
              
              .rect {
                border-top: 30px solid red;
                border-right: 30px solid green;
                border-bottom: 30px solid blue;
                border-left: 30px solid orange;
              }
              

              接着,我们把 div 的文本干掉,再看下效果:

              <div class="rect"></div>
              

              由于 div 是块级元素,它默认占满父元素的宽度(截图中父元素宽度为 300px)。

              然后,我们把 div 的 widthheight 均设为 0,再看下效果:

              .rect {
                width: 0;
                height: 0;
                border-top: 30px solid red;
                border-right: 30px solid green;
                border-bottom: 30px solid blue;
                border-left: 30px solid orange;
              }
              

              image.png

              到这里,我想你应该知道如何设置各种形状的三角形了,最起码思路是有了。

              只要把边、边框背景组合一下,就能实现各种形状了。

              直觉性误区

              假设我们要做一个左下边(橙蓝)组成的直角三角形,那是不是只要把右上(绿红)的边框干掉就行?

              .rect {
                width: 0;
                height: 0;
                /* border-top: 30px solid red; */
                /* border-right: 30px solid green; */
                border-bottom: 30px solid blue;
                border-left: 30px solid orange;
              }
              

              这似乎不是我们想要的结果哦!换个思路,把右上的边框颜色设为透明(transparent)呢?

              .rect {
                width: 0;
                height: 0;
                border-top: 30px solid transparent;
                border-right: 30px solid transparent;
                border-bottom: 30px solid blue;
                border-left: 30px solid orange;
              }
              

              这才是我们想要的结果。

              我们来分析下原因:

              由于我们将 div 的 widthheight 均设为 0,那么它就靠 border 来撑开空间了。其中「水平方向」靠 border-leftborder-right 来撑开,「垂直方向」靠 border-topborder-bottom 来撑开。所以设置四条边为 30px 的时候,它占用了 60 × 60 的空间。一旦我们干掉了上边框和右边框,它实际只占了 30 × 30 的空间,即右下角的部分。

              基础三角形

              本文不会一一列举各种形状的示例,道理是相通的,自由组合即可。

              如果要实现以下这个直角三角形,有多少种做法呢?

              我们做个辅助线,就很清晰了。

              共有三种方式可实现:橙色 + 红色二分之一橙色二分之一红色。为了方便举例,以下示例不设为相同的颜色,实现需求形状即可,大小也请自行调整。

              橙色 + 红色

              四条边均不能省略,其中 border-top 和 border-left 设为相同的颜色,border-right 和 border-bottom 设为透明色。

              .rect {
                width: 0;
                height: 0;
                border-top: 30px solid red;
                border-right: 30px solid transparent;
                border-bottom: 30px solid transparent;
                border-left: 30px solid orange;
              }
              

              二分之一橙色

              不设置 border-top 和 border-right 两条边。boder-bottom 设为透明色,border-left 设置背景色。

              .rect {
                width: 0;
                height: 0;
                border-bottom: 30px solid transparent;
                border-left: 30px solid orange;
              }
              

              二分之一红色

              不设置 border-left 和 border-bottom 两条边。boder-right 设为透明色,border-top 设置背景色。

              .rect {
                width: 0;
                height: 0;
                border-top: 30px solid red;
                border-right: 30px solid transparent;
              }
              

              特殊三角形

              有时候可能要实现等腰三角形、等边三角形等。道理很简单,无非就是按要求先把宽高计算出来,然后设置对应边的 border-width 即可。

              平时工作当然要善用工具了,比如 CSS triangle generator

              既能选择各种形状,也能输入设置宽高,那还用得着自己计算呢,对吧。

              2024.02.01 更新,上述工具链接已失效,其他更多选择 👇

              总结

              使用 CSS 来画三角形,无非就是将元素的宽高设为 0,然后将四条边自由组合就能画出各种形状。较为麻烦的是,特殊三角形宽高需计算,但已经有工具在帮我们做这件事了,所以非常轻松就能完成了。

              The end.

              ]]>
              <![CDATA[在 Parallels Desktop 中使用宿主机代理]]> https://github.com/tofrankie/blog/issues/306 https://github.com/tofrankie/blog/issues/306 Sat, 25 Mar 2023 08:08:17 GMT 在 Mac 上安装了 ClashX 等代理客户端后,如何让 Parallels Desktop 里的 Windows 使用宿主机代理以科学上网呢?

              1. 启用「ClashX → 允许局域网连接」选项。
              2. 打开 Windows,找到「设置 → 网络和 Internet → 手动设]]> 在 Mac 上安装了 ClashX 等代理客户端后,如何让 Parallels Desktop 里的 Windows 使用宿主机代理以科学上网呢?

                1. 启用「ClashX → 允许局域网连接」选项。
                2. 打开 Windows,找到「设置 → 网络和 Internet → 手动设置代理」,添加以下设置并保存。

                代理 IP 地址修改为你的 IP 地址,7890 是 ClashX 的默认端口号。若有调整,自行更改。

                查看 Mac 的 IP 地址:

                $ ifconfig | grep "inet " | grep -Fv 127.0.0.1 | awk '{print $2}'
                
                ]]>
                <![CDATA[npm 安装时锁定版本]]> https://github.com/tofrankie/blog/issues/305 https://github.com/tofrankie/blog/issues/305 Thu, 23 Mar 2023 06:34:55 GMT 配图源自 Freepik

                我们知道,使用 npm 或 yarn 安装包时,它会以 ^x.y.]]> 配图源自 Freepik

                我们知道,使用 npm 或 yarn 安装包时,它会以 ^x.y.z 形式添加到 package.json 里面。

                {
                  "devDependencies": {
                    "typescript": "^5.0.2"
                  }
                }
                

                可通过 save-prefixsave-exact 进行修改:

                • save-prefix 可以指定为 ^(默认)或 ~
                • save-exact 指定确切版本(优先级更高)。

                npm 配置文件的作用范围可分为 globaluserproject,但通常我们只要关注 userproject 就行,对应的配置文件在 ~/.npmrc/path/to/project/.npmrc

                # 用户级别
                $ npm config set save-prefix '~'
                
                # 项目级别
                $ npm config set save-prefix '~' --location project
                
                # 用户级别
                $ npm config set save-exact true
                
                # 项目级别
                $ npm config set save-exact true --location project
                

                其中 npm config 修改配置是 user 级别的(通常意义上的全局配置)。当然你也可以直接修改对应配置文件(其中 .npmrc 为 ini 格式)。

                由于 yarn v1 会读取 .npmrc 作为补充,通常我们只要使用 npm config 去设置即可。

                参考链接

                ]]>
                <![CDATA[小程序 textarea 组件的坑]]> https://github.com/tofrankie/blog/issues/304 https://github.com/tofrankie/blog/issues/304 Fri, 10 Mar 2023 02:22:46 GMT 配图源自 Freepik

                都 2021 年了,微信小程序 textarea 组件还是那么多问题,兼容性问]]> 配图源自 Freepik

                都 2021 年了,微信小程序 textarea 组件还是那么多问题,兼容性问题在 Android 机型(特别是低配置的)上尤为突出!

                2025 年了,好像还很多问题,可能是压根没修吧...

                举个例子

                <textarea
                  name="address"
                  id="address"
                  rows="3"
                  bindinput="onInput"
                  bindblur="onBlur"
                  bindfocus="onFocus"
                  class="textarea"
                  value="{{address}}"
                  placeholder-class="input-placeholder"
                  placeholder="请输入地址"
                  confirm-type="done"
                  disable-default-padding="{{true}}"
                  cursor-spacing="50"
                  show-confirm-bar="{{false}}"
                  auto-height="{{true}}"
                  maxlength="60"
                />
                

                1. show-confirm-bar 问题

                测试同学发现,在红米 4X 上 textarea 组件的「完成」一栏不透明度不是 100%,导致文案与底下文案重叠。

                尝试使用 show-confirm-bar="{{false}}" 来隐藏「完成」一栏。好家伙,新问题又来了。当设置之后,在 Android 上首次聚焦输入框时,会失焦(自动收起键盘)。

                外部原因,无法解决,不考虑设置 show-confirm-bar。

                相关链接:

                2. auto-height 问题

                auto-height="{{true}}" 作用是根据内容自适应高度,以提高用户体验。

                实际上,用户体验没提高,倒是提高了开发者的开发难度。

                还是 Android 机型的问题,在失焦之后 textarea 会多了一大部分的空白部分。

                折中处理:去掉 auto-height 属性,使用 CSS 控制 min-height 属性。

                相关链接:

                3. disable-default-padding 问题

                通常,设计稿要求输入框内容与其他文本都是左对齐。

                但由于 iOS 上会有默认的内边距,即便设置了 padding-left 等属性也是无法消除的。

                解决方法:使用 disable-default-padding="{{true}}"

                4. textarea 原生组件问题

                由于原生组件的特性,在支持原生组件同层渲染之前,textarea 等原生组件层级是最高的,无论其他标签的 z-index 有多大,均无法覆盖原生组件。

                唯一解决的方法是使用 cover-view、cover-image 等组件包裹原生组件,但是本身 cover-view 等就一身毛病,毫无开发体验。

                关于原生组件,可看文档 native-component

                结合以上问题,于是改成这样子:

                <textarea
                  name="address"
                  id="address"
                  rows="3"
                  bindinput="onInput"
                  bindblur="onBlur"
                  bindfocus="onFocus"
                  class="textarea"
                  value="{{address}}"
                  placeholder-class="input-placeholder"
                  placeholder="请输入地址"
                  confirm-type="done"
                  disable-default-padding="{{true}}"
                  cursor-spacing="50"
                  maxlength="60"
                />
                

                5. 当 fixed 元素碰上输入框

                在产品上要求,聚焦时输入框要在可视范围内,输入换行时也可以上推页面。很好,有属性 adjust-position 支持这种特性。

                一个表单页,有 N 个表单项,底部一个 fixed 的悬浮提交按钮,这是非常常见的设计稿。

                问题一

                假设提交按钮处于 textarea 上方,点击按钮,你会发现 textarea 的 focus 事件也被触发了。

                尝试使用 catch 事件也无法阻止这种事件穿透现象,毕竟提交按钮不是 textarea 的后代元素,所以阻止冒泡不起作用也是合理的。

                解决方法:在提交按钮下方 fixed 一个 disabled 的 textarea。

                问题二

                当聚焦输入框时:

                • iOS 页面整体上推(包括 fixed 的元素)。也就是说,输入换行的过程中,会看到“冉冉升起”的提交按钮,挡住了输入框的内容。
                • Android 上表现看似没啥问题,页面会上推,但 fixed 元素不会跟着上来。

                解决方法:在 iOS 设备聚焦时隐藏按钮,失焦后再显示。

                ]]>
                <![CDATA[扫普通链接二维码打开小程序调研]]> https://github.com/tofrankie/blog/issues/303 https://github.com/tofrankie/blog/issues/303 Fri, 10 Mar 2023 02:22:16 GMT 配图源自 Freepik

                背景

                近期有一个需求:扫普通链接二维码打开微信小程序。 配图源自 Freepik

                背景

                近期有一个需求:扫普通链接二维码打开微信小程序。

                唤起微信小程序方式:

                1. 扫小程序码

                2. 扫小程序二维码,可满足不同需求和场景。例如业务中需要记录渠道参数等,详见《获取小程序二维码

                3. 浏览器网页唤起小程序:

                  1. 微信浏览器:只能通过 wx-open-launch-weapp 标签唤起,适用于公众号网页等打开小程序的场景。可参考《关于 React 中使用 wx-open-launch-weapp 唤起微信小程序
                  2. 非微信浏览器(如 Safari 等):可通过 URL Scheme 方式打开小程序,适用于短信推送等场景。详见《获取 URL Scheme
                  3. 竞品 App 内置浏览器(如支付宝内置浏览器等),无法唤起。原因你懂的 🙄
                4. 扫普通链接二维码打开小程序。这种方式配置简单、灵活,且无需自行开发 H5 页面。缺点是只能用使用微信扫一扫或微信内长按识别二维码跳转小程序,其他 App 扫描无法打开小程序。

                在浏览器网页唤起小程序均需要自行实现 H5,普通链接二维码则不需要,而且配置简单灵活。但缺点是只能用使用微信扫一扫或微信内长按识别二维码跳转小程序,其他 App 扫描无法打开小程序。

                开启能力

                登录小程序后台,在「设置 — 开发设置 — 扫普通链接二维码打开小程序」处开启功能,开启后便可配置二维码规则。

                ⚠️ 注意:

                1. 对于普通链接二维码,目前支持使用微信扫一扫微信内长按识别二维码打开小程序。
                2. 该功能暂不支持个人类型小程序

                踩坑/注意点

                主要介绍一些踩坑/注意点,更多请看官方文档

                注:规则可能随时发生改变,以官方为准

                二维码跳转规则

                如需支持子路径匹配,请确认后台配置的二维码规则以 / 结尾。

                后台已配置的二维码规则 线下二维码完整链接 错误原因
                https://www.qq.com/a/b https://www.qq.com/a/b/123 规则没有以 / 结尾,不支持子路径匹配

                链接带参

                假设通过 https://www.qq.com/test 链接打开小程序的 pages/index/index 页面,配置如下:

                ▲ 图中二维码规则的域名是乱写的,自行修改并校验便可。

                配置完成后,便可以拿到一个符合规则的链接:https://www.qq.com/test?channel=123,满足携带参数的需求。

                如不确定自己编写的链接是否满足规则,可在配置项的测试链接中填写,若不符合规则,会有提示以及无法保存成功。

                获取参数

                二维码的链接内容会以参数 q 的形式带给页面。可在 App.onLaunchApp.onShowPage.onLoad 中获取。还需要自行 decodeURIComponent 一下,才是原二维码的完整内容。

                // app.js
                App({
                  onLaunch(options) {
                    console.log(options)
                    // {
                    //   mode: 'default',
                    //   path: 'pages/index/index',
                    //   query: {
                    //     q: 'https%3A%2F%2Fwww.qq.com%2Ftest%3Fchannel%3D123',
                    //     scancode_time: '1615973269',
                    //   },
                    //   referrerInfo: {
                    //     scene: 1011
                    //   }
                    // }
                  }
                })
                
                
                // index.js
                Page({
                 onLoad(options) {
                    console.log(options)
                    // {
                    //   q: 'https%3A%2F%2Fwww.qq.com%2Ftest%3Fchannel%3D123',
                    //   scancode_time: '1615973269'
                    // }
                  }
                })
                

                发布二维码跳转规则

                小程序必须先发布代码才可以发布二维码跳转规则。一个小程序帐号一个月可发布不多于 20 次二维码跳转规则。

                如何在不提审的情况下进行测试?

                如果小程序已经上线,且相关规则发布之后,扫码该链接会打开线上版本的小程序。

                如果想要打开开发版小程序,测试范围选择开发部,同时在测试链接中配置链接,保存后重新扫码便可。

                规则生效时间

                二维码配置规则必须发布后等待 5 分钟以后才可以使用(这个坑没踩,在写这篇文章的时候仍处于调研阶段)

                参考链接

                ]]>
                <![CDATA[支付宝小程序获取用户手机号]]> https://github.com/tofrankie/blog/issues/302 https://github.com/tofrankie/blog/issues/302 Fri, 10 Mar 2023 02:21:43 GMT 记录下支付宝小程序获取会员手机号的踩坑过程。

                使用限制

                • 基础库 1.16.4 或更高版本;支付宝客]]> 记录下支付宝小程序获取会员手机号的踩坑过程。

                  使用限制

                  • 基础库 1.16.4 或更高版本;支付宝客户端 10.1.35 或更高版本,若版本较低,建议采取兼容处理
                  • 此 API 暂仅支持企业支付宝账户使用。
                  • IDE 模拟器暂不支持调试,请以真机调试结果为准。
                  • 目前该功能需要在开发者后台完成敏感信息申请才可以使用此功能,入口为开发管理 > 功能列表 > 添加功能 > 获取会员手机号 > 用户信息申请,此功能需谨慎使用,若支付宝发现信息存在超出约定范围使用或者不合理使用等情况,支付宝有权永久回收该小程序的该接口权限。
                  • 需要将 <button> 组件 open-type 的值设置为 getAuthorize,当用户点击并同意之后,可以通过 my.getPhoneNumber() 接口获取到支付宝服务器返回的加密数据, 然后在第三方服务端结合签名算法和 AES 密钥进行解密获取手机号,方法详见敏感信息加解密方法,若用户未授权,直接调用my.getPhoneNumber() 接口,则无法返回正确信息。

                  配置工作

                  • 请检查小程序后台已添加获取会员手机号功能包,并已在隐私内容申请申请手机号(若在小程序后台看不到用户信息申请的入口,请使用主账号登录)。申请路径为:小程序后台 > 开发管理 > 功能列表添加功能 > 获取会员手机号 > 用户信息申请

                  • 请确保已在小程序后台 > 设置 > 开发设置中,设置支付宝公钥aes 密钥应用网关。aes 相关信息可参见内容加密接入指引。(若缺失这三个设置,在调用 my.getPhoneNumber() 时可能只返回 response 不会返回 sign)。

                  前端工作

                  my.getPhoneNumber 是获取支付宝用户绑定的手机号 API。因为需要用户主动触发才能发起获取手机号,所以该功能不由 API 直接调用,需用 button 组件 的点击来触发。

                  <button
                    open-type="getAuthorize"
                    scope="phoneNumber"
                    onGetAuthorize="getPhoneNumber"
                    onError="getPhoneNumberError"
                  >
                    获取手机号码
                  </button>
                  
                  Page({
                    /**
                     * 获取手机号码,用户点击并同意回调函数
                     *
                     * @param {object} e 授权成功回调信息 { type: 'getAuthorize', target, currentTarget, timeStamp }
                     */
                    async getPhoneNumber(e) {
                      my.getPhoneNumber({
                        success: res => {
                          // 获取到支付宝服务器返回的加密数据
                          // 其中 response 为 JSON 字符串,结构为:'{"response":"xxxxx","sign":"xxx"}'
                          const { respone, ariverRpcTraceId } = res
                  
                          // 将加密数据传给后端,结合签名算法和AES密钥进行解密获取手机号
                          my.request({
                            url: '后端服务端 URL',
                            data: respone,
                            success: res => {
                              // 解密成功返回
                            },
                            fail: err => {
                              console.warn('my.request fail: ', err)
                            }
                          })
                        },
                        fail: err => {
                          console.warn('my.getPhoneNumber fail: ', err)
                        }
                      })
                    },
                  
                    /**
                     * 获取手机号异常,包括用户拒绝和系统异常
                     * @param {object} e 授权失败回调信息
                     */
                    getPhoneNumberError(e) {
                      console.warn('getPhoneNumberError fail: ', e)
                      // 异常信息如下:
                      // {
                      //   type: 'error',
                      //   timeStamp: 1610937854940,
                      //   target: {
                      //     targetDataset: {},
                      //     tagName: 'button',
                      //     dataset: {}
                      //   },
                      //   currentTarget: {
                      //     tagName: 'button',
                      //     dataset: {}
                      //   },
                      //   detail: {
                      //     errorMessage: '用户取消授权',
                      //     type: 'getAuthorize'
                      //   }
                      // }
                    }
                  })
                  

                  后端解密

                  由于对后端不是很了解,具体看内容加密指引

                  常见问题

                  1. 调用 my.getPhoneNumber(),报错 isv.insufficient-isv-permissions(ISV 权限不足)
                  {
                    "code":"40006",
                    "msg":"Insufficient Permissions",
                    "subCode":"isv.insufficient-isv-permissions",
                    "subMsg":"ISV权限不足,建议在开发者中心检查对应功能是否已经添加,解决办法详见:https://docs.open.alipay.com/common/isverror"
                  }
                  

                  原因可能是,没有添加“获取会员手机号”功能包或者没有“申请用户信息”。

                  解决方法:

                  1. 小程序开发管理后台能力列表中,点击添加能力
                  2. 添加获取会员手机号功能包;
                  3. 点击用户信息申请;(这一步不能忽略)
                  4. 申请权限中申请用户手机号;
                  5. 填写申请原因、使用场景等信息,提交申请,等待审核。

                  关于添加了相关能力之后,没有“用户信息申请”的入口,可以看这里

                  我就遇到过这个坑,是小程序一些基础信息未设置,完善信息保存之后,入口就出来了。

                  参考链接

                  ]]>
                  <![CDATA[小程序扫码加载的区别]]> https://github.com/tofrankie/blog/issues/301 https://github.com/tofrankie/blog/issues/301 Fri, 10 Mar 2023 02:20:56 GMT 最近,项目要调整获取小程序渠道的方式,于是记录一下有些忘掉或者不确定的东西:

                  微信小程序

                  场景一:先后扫同一个或者不同的二维码。

                  1. 通过【开发工具 - 预览】方式。

                  第二]]> 最近,项目要调整获取小程序渠道的方式,于是记录一下有些忘掉或者不确定的东西:

                  微信小程序

                  场景一:先后扫同一个或者不同的二维码。

                  1. 通过【开发工具 - 预览】方式。

                  第二次扫码,会重新加载小程序,会触发 App.onLaunchApp.onShowPage.onLoad 等方法。

                  1. 扫描真正的线上小程序二维码

                  它不会触发 App.onLaunch,但会触发 App.onShowPage.onLoad 等方法。可通过 App.onShowPage.onLoad 钩子可以拿到最新码的一些参数。

                  // 假如当前页面为 pages/xxx/xxx,点击右上角按钮退出小程序,接着重新扫码,会触发以下动作:
                  pages/xxx/xxx: onHide have been invoked
                  App: onHide have been invoked
                  App: onShow have been invoked
                  On app route: pages/xxx/xxx
                  pages/xxx/xxx: onUnload have been invoked
                  Update view with init data
                  pages/xxx/xxx: onLoad have been invoked
                  pages/xxx/xxx: onShow have been invoked
                  Invoke event onReady in page: pages/xxx/xxx
                  pages/xxx/xxx: onReady have been invoked
                  ...
                  
                  ]]> <![CDATA[微信小程序将字符串中所有 '\\n' 转换成 '\n']]> https://github.com/tofrankie/blog/issues/300 https://github.com/tofrankie/blog/issues/300 Fri, 10 Mar 2023 02:20:20 GMT 假如有一个配置的功能,接口返回数据如下,其中 \n 表示换行,即在前端需要换行展示。

                  <view style="white-space: pre-wrap">{{message}}&]]>
                              假如有一个配置的功能,接口返回数据如下,其中 \n 表示换行,即在前端需要换行展示。

                  <view style="white-space: pre-wrap">{{message}}</view>
                  
                  // 接口数据
                  const res = {
                    code: 1000,
                    body: {
                      message: "1. 规则一\n2. 规则二\n3. 规则三"
                    },
                    msg: "request:ok"
                  }
                  
                  this.setData({ message: res.body.message })
                  

                  这种情况下,在微信小程序里面直接 setDate 的话,会被转化为 '1. 规则一\\n2. 规则二\\n3. 规则三' 导致无法换行。

                  所以我们需要 replace(/\\n/g, '\n') 转化一下:

                  const message = res.body.message.replace(/\\n/g, '\n')
                  this.setData({ message })
                  

                  这样就 OK 了。

                  ]]>
                  <![CDATA[小程序 onLaunch 参数差别]]> https://github.com/tofrankie/blog/issues/299 https://github.com/tofrankie/blog/issues/299 Fri, 10 Mar 2023 02:19:29 GMT 今天在调整小程序项目获取参数的方法时,发现一直以来有个参数记错了,所以整理一下微信、支付宝、百度小程序的 App.onLaunch 参数的差别,大体相似,但还是有细微的区别。

                  微信小程序

                  相关说明

                  {
                    path: 'pages/handle/handle', // 启动小程序的路径
                    scene: '1037', // 场景值
                    query: {
                      // 启动小程序的 query 参数
                      // 若没有启动参数,则返回一个空对象
                      // ...
                    },
                    shareTicket: undefined, // string 类型,转发信息
                    referrerInfo: {
                      // 来源信息
                      appId: '', // 来源小程序、公众号或者 App 的 AppId
                      extraData: {
                        // 来源小程序传过来的数据,scene=1037 或者 1038 时支持
                        // ...
                      }
                    }
                  }
                  

                  返回有效 referrerInfo 的场景有这些

                  支付宝小程序

                  相关说明

                  • 小程序首次启动时,App.onLaunch 方法可获取 querypath 属性值。
                  • 小程序在后台被用 scheme 打开,也可从 App.onShow 方法中获取 querypath 属性值。
                  {
                    path: 'pages/handle/handle', // 当前小程序的页面地址,从启动参数 page 字段解析而来,page 忽略时默认为首页。
                    scene: '1037', // 场景值
                    query: {
                      // 启动小程序的 query 参数
                      // 若没有启动参数,则不会返回 query 参数。这点跟微信小程序有区别
                      // ...
                    },
                    referrerInfo: {
                      // 来源消息
                      appId: '', // 来源小程序
                      sourceServiceId: '', // 来源插件,当处于插件运行模式时可见。(注意:基础库 1.11.0 版本开始支持)
                      extraData: {
                        // 来源小程序传过来的数据。
                        // ...
                      }
                    }
                  }
                  

                  百度小程序

                  相关说明

                  需要注意的是,百度小程序之间跳转是通过 appKey 的,所以获取到的 referrerInfo.appId 也是指 appKey。这点跟微信、支付宝小程序是有区别的。

                  {
                    path: 'pages/handle/handle', // 打开小程序的路径。
                    scene: '11700000', // 打开智能小程序的场景值,scene 值统一由百度小程序场景值管理中心在 B 端平台统一配置后下发到宿主(例如百度 App),调起协议中会携带相应入口的 scene 值。
                    query: {
                      // 打开当前页面路径中的参数
                      // ...
                    },
                    shareTicket: '', // 标记转发对象
                    referrerInfo: {
                      // 从另一个小程序打开该小程序时,返回此字段
                      appId: '', // 来源小程序的 appKey
                      extraData: {
                        // 来源小程序传过来的数据
                        // ...
                      }
                    }
                  }
                  

                  三者的部分区别

                  1. query 来源:

                  • 微信小程序的 query 参数源于所打开页面路径的参数
                  • 支付宝小程序是区分全局参数页面参数的,其 query 参数源于前者。

                  ▼ 支付宝小程序

                  2. query 对象:

                  • 支付宝小程序 App.onLaunchoptions.query 参数、 Page.onLoadoptions 参数应该是通过 Object.create(null) 创建的对象,不能通过 obj.constructor === Object (结果为 false)来判断是否为对象类型。

                  • 微信小程序 App.onLaunchoptions.query 参数、 Page.onLoadoptions 参数 obj.constructor === Object (结果为 true

                  ]]>
                  <![CDATA[开启 Chrome 浏览器的下载气泡]]> https://github.com/tofrankie/blog/issues/298 https://github.com/tofrankie/blog/issues/298 Sat, 04 Mar 2023 06:41:37 GMT 一直以来,Chrome 浏览器文件下载工具栏都存在于底部,除了不美观之外,还占用网页空间。

                  你有没有很羡慕隔壁 Microsoft Edge 的 Download Hub 👇,却又不想离开 Chrome。

                  现在,Google Chrome 也可以了 👇

                  该功能可以追溯到 Chrome 102 Canary 版本,当时可以在启动时添加 --enable-features=DownloadBubble 参数来启用下载气泡的功能。

                  $ open -a "Google Chrome" --args --enable-features=DownloadBubble
                  

                  现在,尽管来到 Chrome 110 版本,它仍然是实验性功能。实际上同版本下 ARM Mac 已正式启用,但 Intel Mac 却没有。

                  chrome://flags/#download-bubble 界面设置,将 Default 调整为 Enabled 并重启浏览器即可体验该功能,再也不用下载第三方插件实现了。

                  现在,下载文件会出现在左上角位置,美观且不占位置。在不使用快捷键也能更快地进入下载列表了。

                  The end.

                  ]]>
                  <![CDATA[TypeError: can't access property "writeText", navigator.clipboard is undefined]]> https://github.com/tofrankie/blog/issues/297 https://github.com/tofrankie/blog/issues/297 Sun, 26 Feb 2023 13:14:32 GMT 配图源自 Freepik

                  前言

                  在此前,我们会使用 document.ex]]> 配图源自 Freepik

                  前言

                  在此前,我们会使用 document.execCommand('copy')document.execCommand('paste') 来实现复杂和粘贴的功能。但它们是同步的,在处理大量文本或者图像解码等耗时较大的情况下,它会阻塞页面。

                  后来,WHATWG 带来了异步的 Clipboard API,如果用户授予了相应的权限,它就能提供系统剪贴板的读写访问能力,常用于实现剪切、复制和粘贴功能。

                  它们都暴露于 navigator.clipboard 属性上:

                  • navigator.clipboard.write()
                  • navigator.clipboard.writeText()
                  • navigator.clipboard.read()
                  • navigator.clipboard.readText()

                  均返回 Promise 对象。其中 writeText() 只是通用 write() 方法的一种便捷方法,read()readText() 同理。

                  其兼容性如下:

                  问题

                  这里有一个最简单的示例,利用 navigator.clipboard.writeText() 复制一段文本。

                  <!DOCTYPE html>
                  <html lang="en">
                    <body>
                      <button>Copy</button>
                      <script>
                        const btn = document.querySelector('button')
                        btn.addEventListener('click', copy)
                  
                        function copy() {
                          navigator.clipboard.writeText(new Date())
                        }
                      </script>
                    </body>
                  </html>
                  

                  当我们启用一个本地服务,发现点击复制是不成功的,报错如下:

                  TypeError: can't access property "writeText", navigator.clipboard is undefined TypeError: Cannot read properties of undefined (reading 'writeText')

                  由于

                  与许多新 API 一样,Clipboard API 仅支持通过 HTTPS 提供的页面。为帮助防止滥用,仅当页面是活动选项卡时才允许访问剪贴板。活动选项卡中的页面无需请求许可即可写入剪贴板,但从剪贴板读取始终需要许可。

                  Clipboard API 的复制与粘贴能力对应 Permissions APIclipboard-writeclipboard-read 权限。

                  通过以下示例,我们可以发现:

                  const queryOpts = { name: 'clipboard-write', allowWithoutGesture: false }
                  const permissionStatus = await navigator.permissions.query(queryOpts)
                  console.log(permissionStatus.state) // Will be 'granted', 'denied' or 'prompt'
                  

                  permissionStatus.state 返回结果是 denied,也就是该权限被设为「拒绝」状态,自然就无法实现复制的目的了。

                  其实是浏览器的一种安全策略,当页面不安全的时候,全局属性 navigator.clipboard 是不存在的,也就是 undefined,所以就出现前面的问题了。

                  解决方法

                  在域名安全的情况下,比如 HTTPSlocalhost127.0.0.1,是可以访问到 navigator.clipboard 对象的。 由于生产环境上几乎都是 HTTPS 了,所以主要面向本地调试的场景,我们可以把类似 http://172.10.3.24:3000 改成 http://localhost:3000 来解决复制问题。

                  更多

                  其实 window 对象上有一个属性 isSecureContext 可用于判断当前域名是否安全。

                  if (window.isSecureContext) {
                    // 页面在安全上下文中,所以 Clipboard API 可用
                    // do something...
                  }
                  

                  许多 Web API 仅能在安全上下文(Secure contexts)中访问。

                  本地传递的资源,如那些带有 http://127.0.0.1http://localhosthttp://*.localhost 网址(如 http://dev.whatever.localhost/)和 file:// 网址的资源也是认为经过安全传递的。

                  非本地资源要被认为是安全的,必须满足以下标准:

                  • 必须通过 https://wss:// URL 提供服务
                  • 用于传送资源的网络信道的安全属性不能是废弃的

                  参考链接

                  ]]>
                  <![CDATA[零宽空格 U+200B 引发的问题及扩展]]> https://github.com/tofrankie/blog/issues/296 https://github.com/tofrankie/blog/issues/296 Sun, 26 Feb 2023 13:13:36 GMT 配图源自 Freepik

                  背景

                  是这样的,最近在写一个微信公众号的处理脚本,用来替换替]]> 配图源自 Freepik

                  背景

                  是这样的,最近在写一个微信公众号的处理脚本,用来替换替换文章中的指定内容。

                  function getInsertElement(rootElement) {
                    const matchFlag = 'AA'
                    const pList = [...rootElement.querySelectorAll('p')]
                    let matchedElement = pList.find(el => {
                      const text = (el.textContent || el.innerText).replace(/\u00a0/gi, '').trim()
                      return text === matchFlag
                    })
                    return matchedElement
                  }
                  

                  上面的方法是脚本的一部分,用于获取文章中指定字符串所在的 DOM 元素,思路是通过 Node.textContent 来匹配的。

                  在调试的时候,发现有时候匹配不上,用「肉眼」看是没问题的,但硬是匹配不上。经过一番排查之后,发现了一个有趣的事情,如图:

                  在编辑器内有字符 AA,然后使用 encodeURIComponent($0.textContent) 的编码结果是 AA%E2%80%8B,所以上面 text === matchFlag 比较结果为 false,自然就匹配不上了。下面将其转换为 Unicode 字符:

                  function string2Unicode(str) {
                    return str
                      .split('')
                      .map(value => {
                        const temp = value.charCodeAt(0).toString(16).padStart(4, '0').toUpperCase()
                        if (temp.length > 2) return '\\u' + temp
                        return value
                      })
                      .join('')
                  }
                  
                  const encodedStr = '%E2%80%8B'
                  const originString = decodeURIComponent(encodedStr)
                  const unicodeStr = string2Unicode(originString)
                  console.log(unicodeStr) // \u200B
                  

                  转换得出 %E2%80%8B 的 Unicode 编码为 U+200B,然后查询这里发现它是「零宽空格」,是一种不可见的字符。

                  因此,只要使用正则表达式 /\u200b/gi,把所有零宽空格干掉就行了。

                  function getInsertElement(rootElement) {
                    const matchFlag = 'AA'
                    const pList = [...rootElement.querySelectorAll('p')]
                    let matchedElement = pList.find(el => {
                      const text = (el.textContent || el.innerText)
                        .replace(/\u00a0/gi, '')
                        .replace(/\u200b/gi, '')
                        .trim()
                      return text === matchFlag
                    })
                    return matchedElement
                  }
                  

                  零宽空格

                  零宽空格(zero-width space,ZWSP)是一种不可见、不可打印的 Unicode 字符,用于可能需要换行处。

                  在 Unicode 中,该字元为 U+200B。在 HTML 中转义字符有:&#8203;&ZeroWidthSpace;&#x200B;。一般情况下,它是不可见的,但一些软件对这些不可见字符做了处理,视觉上可感知。举个例子:

                  相邻单词之间有一个零宽空格 👇

                  LoremIpsumDolorSitAmetConsecteturAdipiscingElitSedDoEiusmodTemporIncididuntUtLaboreEtDoloreMagnaAliquaUtEnimAdMinimVeniamQuisNostrudExercitationUllamcoLaborisNisiUtAliquipExEaCommodoConsequatDuisAuteIrureDolorInReprehenderitInVoluptateVelitEsseCillumDoloreEuFugiatNullaPariaturExcepteurSintOccaecatCupidatatNonProidentSuntInCulpaQuiOfficiaDeseruntMollitAnimIdEstLaborum
                  

                  相邻单词之间无零宽空格 👇

                  LoremIpsumDolorSitAmetConsecteturAdipiscingElitSedDoEiusmodTemporIncididuntUtLaboreEtDoloreMagnaAliquaUtEnimAdMinimVeniamQuisNostrudExercitationUllamcoLaborisNisiUtAliquipExEaCommodoConsequatDuisAuteIrureDolorInReprehenderitInVoluptateVelitEsseCillumDoloreEuFugiatNullaPariaturExcepteurSintOccaecatCupidatatNonProidentSuntInCulpaQuiOfficiaDeseruntMollitAnimIdEstLaborum
                  

                  它们在 VS Code 及页面中的展示效果,如图所示:

                  扩展

                  除此之外,还有零宽连字、零宽不连字也是不可见字符。

                  • 零宽连字(zero-width joiner,ZWJ)是一个控制字符,放在某些需要复杂排版语言(如阿拉伯语、印地语)的两个字符之间,使得这两个本不会发生连字的字符产生了连字效果。其 Unicode 编码为 U+200D,HTML 转义字符有:&#8205;&zwj;

                  • 零宽不连字(zero-width non-joiner,ZWNJ)是一个不打印字符,放在电子文本的两个字符之间,抑制本来会发生的连字,而是以这两个字符原本的字形来绘制。其 Unicode 编码为U+200C,HTML 转义字符有:&#8204;&zwnj;

                  相信你也看过网友「把幸福的一家几口强行分开」的段子,哈哈:

                  我们利用前面的 string2Unicode() 方法,将其转化为 Unicode 编码,如下:

                  其实它们是由多个字符组合而成的,前面所看到的“空字符串”其实就是 U+200D(零宽连字)。

                  一篇有趣的文章:Why […‘👩❤️💋👨’] returns [‘👩’, ‘’, ‘❤’, ‘️’, ‘’, ‘💋’, ‘’, ‘👨’] in JavaScript?

                  应用

                  零宽字符能做什么?

                  • 传递信息:利用其不可见的特性,在未对零宽字符做过滤的网站插入不可见的隐形文本。
                  • 水印:同样利用其不可见的特性,给我们的产品添加隐形水印。
                  • ...

                  The end.

                  ]]>
                  <![CDATA[二维码内容解析]]> https://github.com/tofrankie/blog/issues/295 https://github.com/tofrankie/blog/issues/295 Sun, 26 Feb 2023 13:12:54 GMT 配图源自 Freepik

                  如果需要获取二维码的原始内容,可以这样操作:

                  
                              配图源自 Freepik

                  如果需要获取二维码的原始内容,可以这样操作:

                  const Jimp = require('jimp')
                  const QrcodeReader = require('qrcode-reader')
                  
                  async function parseQrcode(qrcodePath) {
                    const image = await Jimp.read(qrcodePath) // 可以是本地路径、网络链接
                    const originContent = await new Promise((resolve, reject) => {
                      const qr = new QrcodeReader()
                      qr.callback = (err, value) => {
                        if (err) {
                          reject(err)
                          return
                        }
                        resolve(value.result)
                      }
                      qr.decode(image.bitmap)
                    })
                  
                    return originContent
                  }
                  

                  以上用到 jimpqrcode-reader 来个库。

                  假设我们有这样一个二维码,就能解析到其原始内容。

                  parseQrcode('https://cdn.jsdelivr.net/gh/tofrankie/blog@main/images/2026/1/1767209265557.png')
                    .then(res => {
                      console.log(res) // https://mp.weixin.qq.com/a/~~piirdiB7tRQ~GYk703f8DizEuFT0tGHcvA~~
                    })
                  

                  我遇到的一个场景是,微信小程序项目接入官方的 CI 工具以提供二维码「预览」时,默认情况下二维码过大,导致在终端体验不佳。

                  因此,我的做法是对 CI 工具生成的二维码进行解析,获取到其原始内容(如上,其实就是一个 URL 而已),然后利用 qrcode-terminal qrcode 等主流库重新打印「尺寸较小」的二维码到 Terminal 上。

                  const qrcode = require('qrcode-terminal')
                  
                  qrcode.generate('qrcodeOriginUrl', {small: true})
                  

                  作一个记录,The end.

                  ]]>
                  <![CDATA[初尝 AST]]> https://github.com/tofrankie/blog/issues/294 https://github.com/tofrankie/blog/issues/294 Sun, 26 Feb 2023 13:12:13 GMT 配图源自 Freepik

                  概念

                  在计算机科学中,抽象语法树 配图源自 Freepik

                  概念

                  在计算机科学中,抽象语法树(Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。

                  之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。比如,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现;而类似于 if-condition-then 这样的条件跳转语句,可以使用带有三个分支的节点来表示。

                  初次见面

                  以 JavaScript 为例,通过 EsprimaAST Explorer 等平台,就可以跟 AST 初次见面,交个朋友。

                  x + y
                  

                  以上最简单的表达式,可以表示为这样一棵树:

                  type: Program
                  -body
                    -#1
                      type: ExpressionStatement
                      -expression
                        type: BinaryExpression
                        operator: +
                          -left
                            type: Identifier
                            name: x
                          -right
                            type: Identifier
                            name: y
                  sourceType: script
                  

                  如果用 JSON 表示是这样的:

                  {
                    "type": "Program",
                    "body": [
                      {
                        "type": "ExpressionStatement",
                        "expression": {
                          "type": "BinaryExpression",
                          "operator": "+",
                          "left": {
                            "type": "Identifier",
                            "name": "x"
                          },
                          "right": {
                            "type": "Identifier",
                            "name": "y"
                          }
                        }
                      }
                    ],
                    "sourceType": "script"
                  }
                  

                  Esprima 编写更多的案例,可以发现每一层的结构都是相似的:

                  {
                    "type": "FunctionDeclaration",
                    "id": {...},
                    "params": [...],
                    "body": {...}
                  }
                  
                  {
                    "type": "Identifier",
                    "name": "..."
                  }
                  
                  {
                    "type": "BinaryExpression",
                    "operator": "...",
                    "left": {...},
                    "right": {...}
                  }
                  

                  这些每层结构也称为节点(Node),一个 JavaScript 程序就是由成千上万的节点构成的。

                  AST 节点很多很多,不需要全部记住。

                  如果有需要,但又不记得的话,可以借助 EsprimaAST Explorer 去查看,后者还支持切换多种解析器,比如:

                  如果你想要查看所有节点,可以去对应官网查看,比如 @babel/parser (babylon) Spec 等。

                  前面提到可切换 AST 解析器,是因为每一种 AST 规范又有细微的差别,但基本上都遵循 ESTree Spec 的。被更多人广泛使用的应该是 Babel AST 吧,因此接下来学习也是基于它来展开。

                  未完待续...

                  ]]>
                  <![CDATA[细谈设计模式]]> https://github.com/tofrankie/blog/issues/293 https://github.com/tofrankie/blog/issues/293 Sun, 26 Feb 2023 13:10:37 GMT 配图源自 Freepik

                  终于有时间写这一系列啦...

                  一、起源

                    <]]> 配图源自 Freepik

                    终于有时间写这一系列啦...

                    一、起源

                    • 1977 年,建筑师 Christopher Alexander 编写了一本汇集设计模式的书,模式(Pattern)一词源自此书。
                    • 1987 年,Kent Beck 和 Ward Cunningham 利用 Christopher Alexander 的思想开发了设计模式(Design Pattern),并首次应用于计算机领域。
                    • 1989 年,Erich Gamma 在其博士毕业论文中将这种思想改写为适用于软件开发。
                    • 1989 ~ 1991 年期间,James Coplien 利用相同的思想致力于 C++ 开发。
                    • 1994 年,被称为四人帮(Gang of Four,GoF)的 Erich Gamma、Richard Helm、Ralph Johnson、John Vlissides 于 1994 年合作出版了《Design Patterns: Elements of Reusable Object-Oriented Software》一书,此书共收录了 23 种设计模式。

                    更多请看维基百科

                    二、简介

                    设计模式用于解决软件「设计」层面的问题,而非具体的编码实现。

                    设计模式(Design Pattern),是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结。使用设计模式是为了可重用代码、让代码更容易被他人理解、保证代码可靠性、程序的重用性。 (摘自百度百科

                    三、设计原则

                    设计模式并不是语言规范,解决实际问题时并没有规范必须使用哪一种设计模式,不宜生搬硬套,应灵活运用。最重要的是,一定不要为了用设计模式而用设计模式,避免设计过度,使得简单问题复杂化。因此,在用之前应思考问题本身是否需要设计模式。

                    通常应遵循七大设计原则:

                    • 开闭原则(Open Closed Principle,OCP) 当应用的需求改变时,在不修改软件实体的源代码或者二进制代码的前提下,可以扩展模块的功能,使其满足新的需求。

                    • 里氏替换原则(Liskov Substitution Principle,LSP) 子类可以扩展父类的功能,但不能改变父类原有的功能。

                    • 依赖倒置原则(Dependence Inversion Principle,DIP) 高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。

                    • 单一职责原则(Single Responsibility Principle,SRP) 其核心是控制类的粒度大小、将对象解耦、提高其内聚性。

                    • 接口隔离原则(Interface Segregation Principle,ISP) 为了约束接口、降低类对接口的依赖性。

                    • 迪米特法则(Law of Demeter,LoD) 如果两个软件实体无须直接通信,那么就不应当发生直接的相互调用,可以通过第三方转发该调用。其目的是降低类之间的耦合度,提高模块的相对独立性。

                    • 合成复用原则(Composite Reuse Principle,CRP) 通过将已有的对象纳入新对象中,作为新对象的成员对象来实现的,新对象可以调用已有对象的功能,从而达到复用。

                    根据侧重点总结归纳:

                    设计原则 一句话归纳 目的
                    开闭原则 对扩展开放,对修改关闭 降低维护带来的新风险
                    依赖倒置原则 高层不应该依赖低层,要面向接口编程 更利于代码结构的升级扩展
                    单一职责原则 一个类只干一件事,实现类要单一 便于理解,提高代码的可读性
                    接口隔离原则 一个接口只干一件事,接口要精简单一 功能解耦,高聚合、低耦合
                    迪米特法则 不该知道的不要知道,一个类应该保持对其它对象最少的了解,降低耦合度 只和朋友交流,不和陌生人说话,减少代码臃肿
                    里氏替换原则 不要破坏继承体系,子类重写方法功能发生改变,不应该影响父类方法的含义 防止继承泛滥
                    合成复用原则 尽量使用组合或者聚合关系实现代码复用,少使用继承 降低代码耦合

                    以上这些原则的目的只有一个:降低对象之间的耦合,增加程序的可复用性、可扩展性和可维护性。

                    访问加限制,函数要节俭,依赖不允许,动态加接口,父类要抽象,扩展不更改。

                    四、分类

                    一个软件设计模式的格式根据作者的不同,划分和名称等都会有所不同。《Design Patterns》一书将设计模式分为三大类:创建型模式、结构型模式和行为型模式,共 23 种。

                    • 创建型模式
                      • 工厂方法模式
                      • 抽象工厂模式
                      • 建造者模式
                      • 原型模式
                      • 单例模式
                    • 结构型模式
                      • 适配器
                      • 桥接
                      • 组合
                      • 装饰器
                      • 外观
                      • 享元
                      • 代理
                    • 行为型模式
                      • 责任链
                      • 命令
                      • 解释器
                      • 迭代器
                      • 中介
                      • 备忘录
                      • 观察者
                      • 状态
                      • 策略
                      • 模板方法
                      • 访问者

                    注意,设计模式并不是某一门编程语言的专利,它适用于 Java、C++、C#、JavaScript 等面向对象的编程语言。下文将会以 JavaScript 为例。

                    五、创建型模式

                    5.1 工厂方法模式(Factory Method Pattern)

                    我们常说的工厂模式就是指工厂方法模式,也是使用频率非常高的设计模式之一。

                    简单工厂模式

                    简单工厂模式(Simple Factory Pattern)并不属于 GoF 的 23 种设计模式之一。但它是其他工厂模式的基础。

                    简单工厂模式虽然简单,但存在一个很严重的问题。当系统中需要引入新产品时,由于静态工厂方法通过所传入参数的不同来创建不同的产品,这必定要修改工厂类的源代码,将违背“开闭原则”,如何实现增加新产品而不影响已有代码?

                    定义一个接口用于创建对象,但是让子类决定初始化哪个类。工厂方法把一个类的初始化下放到子类。

                    未完待续...

                    ]]>
                    <![CDATA[通过面试题 a == 1 && a == 2 && a == 3,你看到了什么?]]> https://github.com/tofrankie/blog/issues/292 https://github.com/tofrankie/blog/issues/292 Sun, 26 Feb 2023 13:07:58 GMT 配图源自 Freepik

                    一、背景

                    上周回家路上,逛社区给我推了这样一道面试题,要使]]> 配图源自 Freepik

                    一、背景

                    上周回家路上,逛社区给我推了这样一道面试题,要使其结果为 true,如何实现?

                    a == 1 && a == 2 && a == 3
                    

                    心想不是很简单嘛,考察的是数据类型转换的知识。

                    实现一个 @@toPrimitive 方法就行,思路很简单。

                    换句话说:实现这样一个方法,在每次调用时返回值增加 1,这样大家闭眼都能写出来:

                    const a = {
                      [Symbol.toPrimitive]() {
                        if (this._val === undefined) this._val = 0
                        this._val++
                        return this._val
                      }
                    }
                    
                    console.log(a == 1 && a == 2 && a == 3) // true
                    

                    这道题它的属性名 [Symbol.toPrimitive] 相对少用,仅此而已。

                    其中 @@toPrimitive 方法只能通过计算属性 [Symbol.toPrimitive] 的方式去表示。类似的还有很多,比如实现或重写迭代器的 @@iterator 方法表示为 [Symbol.iterator]

                    二、为什么?

                    这会该有人跳出来吐槽:实际项目中毫无意义,怎么还有面试官问这种傻逼问题?我尝试换位思考,面试官如何在短时间内判断一个候选人的专业技能?通过这些看似奇葩,但综合性较强的题目来考察不失为一种好方法(这题好像不太强)。

                    私以为,学好一个知识点,应做到「深入了解,举一反三」。

                    加个菜,若要使下面同时成立(临时想出来的),你还会做吗?

                    console.log(a == 1 && a == 2 && a == 3) // true
                    console.log(+a) // 100
                    console.log(`${a}`) // 1000
                    

                    其实也很简单,原因是 @@toPrimitive 方法在执行时,会接收到一个 hint 参数,其值为 defaultstringnumber 之一。然后你又能立刻实现出来了:

                    const a = {
                      [Symbol.toPrimitive](hint) {
                        if (hint === 'number') return 100
                        if (hint === 'string') return 1000
                        if (this._val === undefined) this._val = 0
                        this._val++
                        return this._val
                      },
                    }
                    
                    console.log(a == 1 && a == 2 && a == 3) // true
                    console.log(+a) // 100
                    console.log(`${a}`) // 1000
                    

                    私以为,不能做到举一反三,可能是没有完全掌握相关知识。这样的话,应该找时间去学习一下。

                    三、进一步深入?

                    在 ECMAScript 标准中,常见数据类型转换操作有这些:

                    注意,以上这些并不是 JavaScript 里面具体的方法,而是 ECMAScript 标准中的抽象操作(Abstract Operations)。其实不止这些,ECMAScript 标准还有很多转换方法,更多请看 Type Conversion 章节。如果你是第一次阅读 ECMAScript 标准,难免会有种羞涩难懂的感觉,这里推荐阮一峰老师读懂规范一文,或许有帮助。

                    此前其实已经写过一篇关于 JavaScript 数据类型转换的文章,里面也很详细地介绍了,可移步至:数据类型转换详解。还写下这篇文章其实是想「温故知新」。另外,我想读者可能更喜欢从一个实际问题出发,然后再深入其背后的原理。

                    就文章开头的题目而言,这里明显就是「ToPrimitive」操作了,给大家看下 ECMAScript 是如何定义(详见):

                    第一次阅读的时候,也曾感慨「不愧是抽象操作,确实很抽象」,但反复阅读下来之后,也不会很难。

                    本文将不会逐行逐字进行翻译,此前写那篇文章 数据类型转换详解 已经做了这件事。

                    ToPrimitive(obj, hint) 为例

                    1. 如果 obj 为原始值,直接返回该值。

                    2. 如果 obj 为引用值,且含有 @@toPrimitive 方法(必须是函数),然后 a. 若参数 hint 不存在,令 hint"default";若 hint 为字符串,令 hint"string";否则令 hint"number"。 b. 调用 @@toPrimitive 方法,若其返回值为「原始值」,则返回其值,否则抛出 TypeError。

                    3. 若参数 hint 不存在,令 hint"number"

                    4. hint"string",将会先后调用 objtoString()valueOf() 方法,然后 a. 若 toString 为函数,则调用该方法。若其返回值为「原始值」,则返回该值,否则进行往下。 b. 若 valueOf 为函数,则调用该方法。若其返回值为「原始值」,则返回该值,否则抛出 TypeError。

                    5. hint"number",将会先后调用 objvalueOf()toString() 方法,然后

                      a. 若 valueOf 为函数,就调用该方法。若其返回值为「原始值」,则返回该值,否则进行往下。 b. 若 toString 为函数,就调用该方法。若其返回值为「原始值」,则返回该值,否则抛出 TypeError。

                    目前 ECMAScript 内置的对象中,只有 DateSymbol 对象有实现其 @@toPrimitive 方法。因此,其他内置对象无非就是根据 valueOf()toString() 方法,得到其转换结果罢了,所以数据类型转换没那么神秘。

                    回到文章开头那道题:

                    a == 1 && a == 2 && a == 3
                    

                    a 成为一个引用值,比如对象。这时,除了实现 @@toPrimitive 方法,还可以改写其 toString() 方法去达到目的。

                    const a = {
                      toString() {
                        if (this._val === undefined) this._val = 0
                        this._val++
                        return this._val
                      }
                    }
                    
                    console.log(a == 1 && a == 2 && a == 3) // true
                    

                    但如果要使成立,只能利用 @@toPrimitive 方法会传入 hint 参数的特点去实现了:

                    console.log(a == 1 && a == 2 && a == 3) // true
                    console.log(+a) // 100
                    console.log(`${a}`) // 1000
                    

                    也就是

                    const a = {
                      [Symbol.toPrimitive](hint) {
                        if (hint === 'number') return 100
                        if (hint === 'string') return 1000
                        if (this._val === undefined) this._val = 0
                        this._val++
                        return this._val
                      },
                    }
                    
                    console.log(a == 1 && a == 2 && a == 3) // true,此时 hint 为 "default"
                    console.log(+a) // 100,此时 hint 为 "number"
                    console.log(`${a}`) // 1000,此时 hint 为 "string"
                    

                    那么,这个 hint 有何规律呢?实在惭愧,我也没太琢磨出来。常见操作的 hint 如下:

                    const obj = {
                      [Symbol.toPrimitive](hint) {
                        if (hint === 'number') {
                          return 1
                        } else if (hint === 'string') {
                          return 'string'
                        } else {
                          return 'default'
                        }
                      }
                    }
                    
                    
                    console.log(+obj)          // 1              hint is "number"
                    console.log(Number(obj))   // 1              hint is "number"
                    
                    console.log(`${obj}`)      // "string"       hint is "string"
                    console.log(String(obj))   // "string"       hint is "string"
                    
                    console.log(obj + '')      // "default"      hint is "default"
                    console.log(obj + 1)       // "default1"     hint is "default"
                    

                    个人猜测:「显式转换」为字符串或数组时,对应为 "string""number",其他情况为 "default"

                    还有一个略麻烦的方法,在规范中通过 References 中一个一个找到引用此算法的其他操作,然后一层一层去查哪些方法有使用了这种操作,然后总结列出来。

                    四、其他

                    此前写过不少与 JavaScript 数据类型相关的文章,如果对此不熟的童鞋,可以看一看:

                    ]]>
                    <![CDATA[细读 JS | 详谈一下 NaN]]> https://github.com/tofrankie/blog/issues/291 https://github.com/tofrankie/blog/issues/291 Sun, 26 Feb 2023 13:04:21 GMT 配图源自 Freepik

                    NaN 的怪诞行为?

                    NaN 是 Not-A-Number]]> 配图源自 Freepik

                    NaN 的怪诞行为?

                    NaN 是 Not-A-Number 的简写,表示「不是一个数字」的意思。

                    尽管如此,它却是 Number 类型。

                    typeof NaN === 'number' // true
                    

                    平常所写的 NaN 只是「全局变量」的一个「属性」而已,该属性的「初始值」为 NaN。

                    为了便于区分,下文中高亮 NaN 表示全局变量的属性,NaN 表示属性值。

                    类似的还有 undefinedInfinityglobalThis,它们都是全局对象属性。

                    前面故意提到 NaN 的初始值为 NaN,原因是在 ES3 时代,该属性是可以被覆盖的,也就是 writable 的(这点与 undefined 表现是一致的),自 ES5 起就不可被重新赋值了。

                    The value of NaN is NaN.

                    需要注意的是,NaN 是 JavaScript 中唯一一个不等于其本身的值。这样的话,全等比较结果为 false 是不是看起来合乎情理了?

                    NaN === NaN // false
                    

                    利用这一特性,可以快速地写出一个判断某个值是否为 NaN 的方法:

                    const myIsNaN = x => x !== x
                    
                    myIsNaN(NaN) // true
                    

                    小结:

                    • NaN 表示一个不为数字的值,但它类型是 Number 类型。
                    • NaN 是 JavaScript 中唯一一个不等于其本身的值。
                    • NaN === NaN 的比较结果为 false

                    isNaN()、Number.isNaN()、Number.NaN 傻傻分不清?

                    先上几个菜尝尝鲜:

                    NaN == NaN // false
                    NaN === NaN // false
                    NaN === Number.NaN // false
                    
                    isNaN(NaN) // true
                    isNaN('NaN') // true
                    isNaN('string') // true
                    isNaN(undefined) // true
                    isNaN({}) // true
                    isNaN('11abc') // true
                    isNaN(new Date().toString()) // true
                    
                    isNaN(null) // false
                    isNaN(10) // false
                    isNaN('10') // false
                    isNaN('10.2') // false
                    isNaN('') // false
                    isNaN(' ') // false
                    isNaN(new Date()) // false
                    
                    Number.isNaN(NaN) // true
                    Number.isNaN(Number.NaN) // true
                    Number.isNaN(0 / 0) // true
                    
                    Number.isNaN('NaN') // false
                    Number.isNaN('') // false
                    Number.isNaN(' ') // false
                    Number.isNaN(10) // false
                    Number.isNaN(undefined) // false
                    Number.isNaN(null) // false
                    Number.isNaN({}) // false
                    Number.isNaN(new Date()) // false
                    Number.isNaN(new Date().toString()) // false
                    

                    希望你没有晕,其实掌握内在原理就很简单了,最多是「反直觉」而已...

                    Number.NaN

                    它是内置对象 Number 提供的一个「静态属性」,其值就是 NaN,且「只读」。

                    ECMAScript 1st Edition #15.7.3.4 可以看到:

                    The value of Number.NaN is NaN. This property shall have the attributes { DontEnum, DontDelete, ReadOnly }.

                    这大概就是与全局对象属性 NaN 的唯一区别吧。

                    isNaN()

                    它是全局对象的一个方法,用于判断一个值是否为 NaN。

                    ECMAScript 1st Edition #15.1.2.6 可以看到:

                    Applies ToNumber to its argument, then returns true if the result is NaN, and otherwise returns false.

                    从规范描述可知,它内部做了「类型转换」。

                    isNaN(x) 为例,它先将 x 转换为 Number 类型(即规范中的 ToNumber 抽象操作),然后再判断转换后的值是否为 NaN,若为 NaN 返回 true,否则返回 false。比如:

                    const str = 'string'
                    
                    isNaN(str) // true
                    
                    // 相当于
                    const transformedStr = Number(str) // NaN
                    isNaN(transformedStr) // true
                    

                    这样的特性有什么用呢,MDN 是这样介绍的,更多

                    基于此,我们可以快速写出其 Polyfill 方法:

                    globalThis.myIsNaN = function (value) {
                      const transformedValue = Number(vulue)
                      return transformedValue != transformedValue
                    }
                    

                    若对数据类型转换不是太熟,可看(隐式)数据类型转换详解

                    Number.isNaN()

                    从命名上看,globalThis.isNaN() 它是反直觉的,它偷偷给我们做了一次类型转换。

                    可能正是因为这个原因,所以 ES6 标准中提供了一个全新的方法 Number.isNaN(),其内部逻辑如下(详见):

                    1. 若入参值不是 Number 类型,则返回 false
                    2. 若入参值为 NaN 则返回 true,否则返回 false

                    isNaN() 不同的是,它不会对传入的值做类型转换。因此,可以快速写出其 Polyfill 方法:

                    Number.myIsNaN = function (value) {
                      if (typeof value !== 'number') return false
                      return value !== value
                    }
                    

                    都 2022 年了,都用 Number.isNaN() 来判断吧,其余的就交给 Babel 了。

                    小结

                    • Number.NaN 静态属性的返回值正是 NaN 本身,该属性只读。
                    • 在 ES5 及以上,可以认为 globalThis.NaN 等价于 Number.NaN,它们的值均为 NaN。
                    • isNaN()Number.isNaN() 多了一个 Number() 过程,实际项目中若要判断一个值是否为 NaN,建议使用后者。

                    indexOf() 和 includes() 对 NaN 是如何判断的?

                    其实在另一篇文章《相等比较详解》已经介绍过了。

                    const arr = [NaN]
                    
                    arr.indexOf(NaN) // -1
                    arr.includes(NaN) // true
                    

                    究其原因,是由于其内部使用了不同的比较算法。

                    这俩算法对 NaN 的处理如下(详见):

                    另外,全等比较也使用了 Number::equal (x, y) 算法:

                    两个 Number 类型的值的比较,其实都是围绕了这三个算法:Number::equal (x, y)Number:: sameValue (x, y)Number::sameValueZero (x, y),有兴趣可以看下。它们之中比较特别的值无非就是 +0-0NaN

                    其他

                    此前写过不少关于 JavaScript 数据类型的一些文章:

                    ]]>
                    <![CDATA[Prettier 终于支持缓存策略了]]> https://github.com/tofrankie/blog/issues/290 https://github.com/tofrankie/blog/issues/290 Sun, 26 Feb 2023 13:03:27 GMT 配套源自 Freepik

                    就在昨天 Prettier 发布了 v2.7.0 版本(

                    就在昨天 Prettier 发布了 v2.7.0 版本(Release Note),它终于支持缓存了。

                    --cache

                    在 CLI 中添加 --cache 参数:

                    prettier --cache --write .
                    

                    当 Prettier 版本、Prettier 配置、Node 版本发生变化,以及满足 --cache-strategy 条件时,会执行格式化。

                    --cache-strategy

                    其缓存策略类似于 ESLint

                    prettier --cache --cache-strategy metadata --write .
                    

                    取值支持 metadatacontent(默认),前者可根据文件元数据的变更(例如文件修改时间)而触发,后者则根据文件内容是否变更来触发。

                    其他

                    另一个常用的格式化工具 Stylelint 则早已支持,它还支持 CSS 排序,对于我这样的强迫症,这功能太爱了,如果 Prettier 也支持就好了(但 Prettier 似乎没有这个计划,prettier #1963)。社区上也有一个 Prettier 插件 prettier-plugin-rational-order,也是基于 Stylelint 插件修改的。

                    ]]>
                    <![CDATA[requestAnimationFrame 使用]]> https://github.com/tofrankie/blog/issues/289 https://github.com/tofrankie/blog/issues/289 Sun, 26 Feb 2023 13:02:48 GMT 配图源自 Freepik

                    一、requestAnimationFrame

                    配图源自 Freepik

                    一、requestAnimationFrame

                    MDN 介绍可知:

                    调用 window.requestAnimationFrame() 方法告知浏览器,希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。

                    从这句话可以看出,它相比 setTimeout 等 API 的优势之一是减少 DOM 重绘的次数。

                    语法也很简单:

                    window.requestAnimationFrame(callback)
                    

                    它接受一个回调函数(即下一次重绘之前更新动画帧所调用的函数),并返回一个非零且唯一的 requestId(可以传给 window.cancelAnimationFrame() 以取消回调函数)。

                    回调函数将会被传入一个 DOMHighResTimeStamp 参数,表示 requestAnimationFrame() 开始去执行回调函数的时刻。

                    看一下 MDN 给出的一个示例:

                    <!DOCTYPE html>
                    <html lang="en">
                      <head>
                        <meta charset="UTF-8" />
                        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
                        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
                        <title>Document</title>
                        <style>
                          #foo {
                            width: 100px;
                            height: 100px;
                            background-color: rebeccapurple;
                          }
                        </style>
                      </head>
                      <body>
                        <div id="foo"></div>
                        <script>
                          let start
                          const element = document.getElementById('foo')
                    
                          function step(timestamp) {
                            if (start === undefined) start = timestamp
                            const elapsed = timestamp - start
                    
                            // 这里使用 `Math.min()` 确保元素刚好停在 200px 的位置。
                            element.style.transform = `translateX(${Math.min(0.1 * elapsed, 200)}px)`
                    
                            if (elapsed < 2000) {
                              // 在两秒后停止动画
                              window.requestAnimationFrame(step)
                            }
                          }
                    
                          window.requestAnimationFrame(step)
                        </script>
                      </body>
                    </html>
                    

                    效果可看下 👉 CodeSandbox

                    帧动画回调函数的执行次数取决于「屏幕刷新率」,以 60Hz(表示每秒钟图像刷新的次数)的屏幕来说,约 16.7ms 会刷新一次,也就是说 requestAnimationFrame() 的回调函数约 16.7ms 就会执行一次。

                    而且,在多数浏览器中,当 requestAnimationFrame() 处于后台标签页或者被隐藏的 <iframe> 元素里,它会被暂停调用以提升性能和电池寿命。

                    未完待续...

                    ]]>
                    <![CDATA[HTML 表单元素的自动填充与聚焦样式]]> https://github.com/tofrankie/blog/issues/288 https://github.com/tofrankie/blog/issues/288 Sun, 26 Feb 2023 13:01:10 GMT 配图源自 Freepik

                    今天来看一下与表单元素相关的两个东西:

                    • 配图源自 Freepik

                      今天来看一下与表单元素相关的两个东西:

                      • outline:绘制轮廓样式
                      • autocomplete:表单自动填充功能

                      outline

                      在绝大多数浏览器中,如果一个元素是可交互的,它一般都有一个可见的聚焦提示。比如 Chrome 浏览器下当 <input> 聚焦时,默认会有一个蓝色的边框。

                      从用户交互体验上讲,浏览器这种提示是好的,比如,一个良好的按钮交互,应该要包括被按下那一刻的样式。

                      但可能默认样式与你的页面风格不相符,然后就需要先移除默认样式,然后再自定义。

                      比如有一个 <input> 元素聚焦时,在不添加任何样式情况下,Chrome 浏览器表现如下图:

                      <input type="text" placeholder="请输入文本" />
                      

                      想要移除这种默认聚焦样式,非常地简单,只要添加以下样式即可:

                      input {
                        outline: none;
                      } 
                      

                      outline 设置为 0none 会移除浏览器的默认聚焦样式。

                      outlineoutline-styleoutline-widthoutline-color 的简写,用于绘制元素轮廓的样式。

                      它跟 boder 很类似,区别在于 outline 不占据空间,绘制于元素内容周围。

                      对于很多元素来说,如果没有设置样式,轮廓是不可见的。因为样式的默认值是 none。但 <input> 元素是例外,其样式默认值由浏览器决定。

                      语法如下:

                      /* 样式 */
                      outline: solid;
                      
                      /* 颜色 | 样式 */
                      outline: #f66 dashed;
                      
                      /* 样式 | 宽度 */
                      outline: inset thick;
                      
                      /* 颜色 | 样式 | 宽度 */
                      outline: green solid 3px;
                      

                      在实际场景中,我还真只见过移除默认样式,还没有遇到主动设置样式的。因此取值不做详细介绍,更多请看MDN

                      autocomplete

                      我们可以观察到一个现象,当我们聚焦某个输入框的时,浏览器会有一个历史输入的提示,比如:

                      <input name="nickname" type="text" placeholder="请输入昵称" />
                      

                      Chrome 浏览器可能会出现如图类似「建议值」提示:

                      这是什么回事呢?原来是浏览器会记录我们的文本输入历史,当 <input>nameid 属性匹配的时候,就会有此提示(前提是启用了 autocomplete 属性)。上一个示例,由于 <input> 并未设置 nameid 属性,因此是没有提示的。

                      你可以试下改为 name="email",那么文本提示就可能会出现你常输入的邮箱地址。

                      autocomplete 是什么?

                      MDN 描述如下:

                      HTML autocomplete 属性可用于以文本或数字值作为输入的 <input> 元素 , <textarea> 元素,<select> 元素,和 <form> 元素。 autocomplete 允许 web 开发人员指定,如果有任何权限 user agent 必须提供填写表单字段值的自动帮助,并为浏览器提供关于字段中所期望的信息类型的指导。

                      一般来说,<input><select><textarea> 这些元素具备以下条件才会有自动填充功能:

                      1. 具有 name 或 id 属性。
                      2. 成为 <form>  的后代。
                      3. 具有 submit 按钮的表单。

                      自动填充的建议值来源通常取决于浏览器。 一般是来自用户输入的过去值,但它们也可能来自预先配置的值。

                      自动填充功能通常是默认启用的,如果想要关闭,可以为整个表单设置或为表单中某个输入元素单独设置:

                      <form id="form1" autocomplete="off">
                        <!-- 这时,其下属表单元素将会默认关闭 autocomplete -->
                        <input name="nickname" type="text" placeholder="请输入昵称" />
                      </form>
                      
                      <form id="form2">
                        <!-- 可对其下属表单元素独立设置 autocomplete -->
                        <input name="nickname" type="text" placeholder="请输入昵称" autocomplete="off" />
                      </form>
                      
                      <form id="form3" autocomplete="off">
                        <!-- 当 form 和下属表单元素同时指定 autocomplete 时,下属表单会覆盖 form 元素的指定 -->
                        <input name="nickname" type="text" placeholder="请输入昵称" autocomplete="on" />
                      </form>
                      

                      设置 autocomplete="off" 会有两种效果:

                      • 这会告诉浏览器,不要为了以后在类似表单上自动填充而保存用户输入的数据。但浏览器不一定遵守
                      • 这会阻止浏览器缓存会话历史记录中的数据。若表单数据缓存于会话历史记录,用户提交表单后,再点击返回按钮返回之前的表单页面,则会显示用户之前输入的数据。

                      如果即使在 autocomplete 已经设置为 off 时,浏览器仍继续提供输入建议,那么你需要更改输入元素的 name 属性。

                      其他

                      许多现代浏览器,不支持在登录字段中设置 autocomplete="off"(即使设置了也会有提示),因为浏览器内置了密码管理,它们认为对安全是有益无害的。更多请看这里

                      注意: 在大多数现代浏览器中, autocomplete 设置为 "off" 不会阻止密码管理器询问用户是否要保存用户名和密码信息,或者自动在网站的登录表单中填写这些值。 请参阅 the autocomplete attribute and login fields

                      如果我们希望在密码字段阻止自动填充,可以添加 autocomplete="new-password" 来避免意外填写现有密码:

                      <input name="password" type="password" placeholder="请输入密码" autocomplete="new-password" />
                      

                      除了常见的 "on""off" 之外,更多 autocomplete 取值请看这里

                      小结

                      按照规范,当指定 autocomplete="on"autocomplete="off" 时,会启用或关闭自动填充功能。但是实际情况是,浏览器不一定遵循标准实现。

                      所以,规范只是弟弟,浏览器厂商才是大爷啊。

                      参考链接

                      ]]>
                      <![CDATA[为什么用 void 0 代替 undefined?]]> https://github.com/tofrankie/blog/issues/287 https://github.com/tofrankie/blog/issues/287 Sun, 26 Feb 2023 13:00:19 GMT 配图源自 Freepik

                      众所周知,void 运算符总会返回一个 配图源自 Freepik

                      众所周知,void 运算符总会返回一个 undefined 的结果。

                      那么,为什么要用 void 0 代替 undefined 呢?这不是多此一举了吗?

                      void 运算符

                      语法非常简单:

                      void expression
                      

                      它将会对表达式进行求值,然后返回 undefined 的「原始值」。那么,void 0 不就是得到 undefined 最简单的写法嘛!

                      写法上 void 0 相当于 void(0)

                      我们知道,立即函数执行表达式(IIFE)有非常多的写法,使用 void 关键字也是可以的,比如:

                      void (function () {
                        // some statements
                      })()
                      
                      // 相当于
                      void function () { /* some statements */ }()
                      

                      另外,你应该看过以下类似的写法:

                      <a href="javascript:0;">Link</a>
                      

                      我们知道 javascript: 是 JavaScript 中的伪协议,表示将会使用 JS 解析器执行其后的所有语句,若其最后一个JavaScript 语句返回值不为 undefined,那么其返回值将会替换为页面内容。

                      就以上 HTML 标签,在不同浏览器下点击,会有不同的结果。其中 Chrome、Safari 中点击无任何反馈,而 Firefox 中页面内容将会被替换为 0

                      为了解决以上差异表现,通常的做法就是使用 void 关键字,比如:

                      <a href="javascript:void(0);">Link</a>
                      

                      这也是处理 <a> 标签默认行为的方式之一。

                      这种伪协议除了在事件处理程序中使用,也可在浏览器地址栏、书签地址中使用。

                      除此之外,还有一种常用的用法:当箭头函数中只有一行语句,且不需要返回值时,则可以:

                      element.onclick = () => void doSomething()
                      

                      undefined

                      你有可能不知道,我们天天写的 undefined,它其实是一个「全局对象」中的一个「属性」。

                      // 浏览器宿主环境
                      window.undefined === undefined // true
                      
                      // Node 宿主环境
                      global.undefined === undefined // true
                      

                      除了 undefined 之外,类似的还有 NaNInfinityglobalThis 也是全局对象的属性。它们的属性描述对象都是:不可写、不可枚举、不可配置(详见 ECMAScript 19.1 Value Properties of the Global Object)。

                      {
                        [[writable]]: false,
                        [[enumerable]]: false
                        [[configurable]]: false
                      }
                      

                      当然,这个在 ES5 之前是可以被改写的,下面可以对比一下:

                      ▼ IE 8

                      ▼ Chrome 100

                      从这个角度看是不是可以理解为什么用 void 0 而不是 undefined 这个全局属性了。

                      还有,需要特别注意的是:

                      undefined 并不是 ECMAScript 标准中的一个保留字,因此它是可以被作为变量标识符而使用的(但项目中千万别这么用),就像 window 一样。

                      在 ECMAScript 标准中,保留字有以下这些(详见 12.6.2 Keywords and Reserved Words):

                      在现代浏览器下,请对比以下三种示例:

                      // 示例一
                      !(function () {
                        var undefined = 1
                        console.log(undefined) // 结果是?
                      })()
                      
                      // 示例二
                      !(function () {
                        undefined = 2
                        console.log(undefined) // 结果是?
                      })()
                      
                      // 示例三
                      var undefined = 3
                      console.log(undefined) // 结果是?
                      
                      // 示例四
                      let undefined = 4
                      console.log(undefined) // 结果是?
                      

                      前三个打印结果分别是:1undefinedundefined,最后一个则在 let undefined = 4 处抛出 SyntaxError。

                      原因分析:

                      • 示例一的 undefined 被声明为一个变量标识符,并赋值为 1,因此打印结果为 1
                      • 示例二中 undefined = 2 表示修改全局变量的 undefined 属性(window.undefined),由于它是 non-writable 的,因此不会被重写,打印结果还是其默认值 undefined
                      • 示例三与示例二同理,只是它们所处的作用域不同罢了;
                      • 示例四原意是为了说明 varlet/const 的区别,在全局作用域下,前者声明的变量会被添加至全局对象上,而后者则不会。但是实际执行下来,会抛出语法:Uncaught SyntaxError: Identifier 'undefined' has already been declared(目测是默认情况下就有一个 var undefined 的声明的语句了,因此使用 let undefined 重复声明会抛出错误,这种猜测暂未在标准中找到佐证)。

                      不知道有没有人会分不清:undefined 什么时候作为原始值?什么时候是作为变量(属性)?

                      var foo // 此处 foo 的值是原始值 undefined;
                      
                      function bar() {}
                      bar() // 此处函数 bar 的返回值是原始值 undefined;
                      
                      void 0 // 此处 void 操作的返回值是原始值 undefined;
                      
                      foo == undefined // 此处相等运算符右侧的 undefined 是全局对象的 undefined 属性。
                      

                      到这里,你应该对于 undefined 有了一个比较全面的认识了。

                      我猜你应该看过类似以下的「小心机」代码:

                      ;(function ($, window, undefined) {
                        // some statements
                      })(jQuery, window)
                      

                      如果在 IIFE 中使用 undefined,它其实是引用了形参 undefined。但形参 undefined 的值就是 undefined 的原始值,因为在调用函数时并未传入实参。除了能确保 undefined 的值是原始值之外,还能加速变量 undefined 的查找,由于它在函数作用域内就能找到该变量,就不会继续往作用域链上查找。

                      用 void 0 代替 undefined?

                      void 0 总是返回原始值 undefined,无论全局属性 undefined 是否被改写,它都能确保其值是 undefined(原始值)。

                      比如著名的工具库 underscore 大量使用了 void 0 来代替 undefined,再者 UglifyJS、Terser 等代码压缩工具也会将 undefined 转换为 void 0,这样可以节省一些字节:

                      function isUndefined(x) {
                       return x === undefined
                      }
                      
                      // Minified
                      function isUndefined(n){return void 0===n}
                      

                      但我们在编写代码的时候,直接使用 undefined 也是没有太大问题的,注意下前面提到的一些点就好了,其余的就交由工具来处理即可。

                      ]]>
                      <![CDATA[啊,似乎没有真正理解 try...catch...finally!]]> https://github.com/tofrankie/blog/issues/286 https://github.com/tofrankie/blog/issues/286 Sun, 26 Feb 2023 12:54:04 GMT 配图源自 Freepik

                      写了那么久的 JavaScript,似乎真的没有很认真地去了解 ]]> 配图源自 Freepik

                      写了那么久的 JavaScript,似乎真的没有很认真地去了解 try...catch...finally 的各种用法,真是惭愧了!Anyway,不懂就学...

                      一、错误与异常

                      错误,在程序中是很常见的。它可以是 JS 引擎在执行代码时内部抛出的,也可以是代码开发人员针对一些不合法的输入而主动抛出的,或者是网络断开连接导致的错误等等...

                      可能很多人会认为,「错误」和「异常」是同一回事,其实不然,一个错误对象只有在被抛出时才成为异常。

                      1.1 错误

                      在 JavaScript 中,错误通常是指 Error 实例对象或 Error 的派生类实例对象(比如 TypeErrorReferenceErrorSyntaxError 等等)。创建 Error 实例对象很简单,如下:

                      const error = new Error('oops') // 等价于 Error('oops')
                      const typeError = new TypeError('oops')
                      // ...
                      

                      虽然 Error 及其派生类是构造函数,但是当作函数调用也是允许的(即省略 new 关键字),同样会返回一个错误实例对象。

                      一个错误实例对象,包含以下属性和方法:

                      const errorInstance = {
                        name: String, // 标准属性,所有浏览器均支持(默认值为构造方法名称)
                        message: String, // 标准属性,所有浏览器均支持(默认值为空字符串,实例化时传入的第一个参数可修改其属性值)
                        stack: String, // 非标准属性,但所有浏览器均支持(栈属性,可以追踪发生错误的具体信息)
                      
                        columnNumber: Number, // 非标准属性,仅 Firefox 浏览器支持(列号)
                        lineNumber: Number, // 非标准属性,仅 Firefox 浏览器支持(行号)
                        fileName: String, // 非标准属性,仅 Firefox 浏览器支持(文件路径)
                      
                        column: Number, // 非标准属性,仅 Safari 浏览器支持(同上述三个属性)
                        line: Number, // 非标准属性,仅 Safari 浏览器支持
                        sourceURL: String, // 非标准属性,仅 Safari 浏览器支持
                      
                        toString: Function, // 标准方法(其返回值是 name 和 message 属性的字符串表示)
                      }
                      

                      我们写个最简单的示例,打印看下各大浏览器的情况:

                      try {
                        throw new TypeError('oops')
                      } catch (e) {
                        console.log(e.toString())
                        console.dir(e)
                      }
                      

                      插个题外话:

                      不知道有人没有对此有疑惑的,为什么 console.log() 一个 Error 对象,打印出来的是字符串,而不是一个对象呢?

                      const err = new Error('wrong')
                      console.log(err) // "Error: wrong"
                      console.log(typeof err) // "object"
                      

                      那么,如果想打印出 Error 对象,使用 console.dir() 即可。

                      前面 console.log() 打印结果为字符串的原因其实很简单,那就是 console.log() 内部「偷偷地」做了一件事,当传入的实参为 Error 对象(或其派生类错误对象),它会先调用 Error 对象的 Error.prototype.toString() 方法,然后将其结果输出到控制台,所以我们看到的打印结果为字符串。

                      其实现如下:

                      // polyfill
                      Error.prototype.toString = function () {
                        'use strict'
                      
                        var obj = Object(this)
                        if (obj !== this) throw new TypeError()
                      
                        var name = this.name
                        name = name === undefined ? 'Error' : String(name)
                      
                        var msg = this.message
                        msg = msg === undefined ? '' : String(msg)
                      
                        if (name === '') return msg
                        if (msg === '') return name
                      
                        return name + ': ' + msg
                      }
                      

                      细心的同学会发现,在不同浏览器下,其打印结果可能会不相同(但不重要)。原因也非常简单,console 并不是 ECMAScript 标准,而是浏览器 BOM 对象提供的一个接口,其标准由 WHATWG 机构制定,虽然标准是统一的,但实现的是各浏览器厂商大爷们,它们有可能不会严格遵守规范去实现,因而产生差异化。比如,此前写过一篇文章是关于不同宿主环境下 async/await 和 promise 执行顺序的差异,就因为 JS 引擎实现差异导致的。

                      1.2 异常

                      前面提到,当错误被抛出时就会成为异常。

                      假设我们编写的代码存在语法错误,那么在编译阶段的语法分析过程就会被聪明的 JS 引擎发现,因而在编译阶段便会抛出 SyntaxError。

                      假设我们代码没有语法错误,但错误地引用了一个不存在的变量,那么在执行阶段的执行上下文过程(代码执行之前的一个过程),聪明的 JS 引擎发现在其作用域链上找不到该变量,那么就会抛出 ReferenceError。

                      假设既不存在语法错误,也没有引用错误,但我们对一个变量做了“不合法”的操作,比如 null.name'str'.push('ing'),那么 JS 引擎就会抛出 TypeError。

                      还有很多很多,就不举例了。

                      前面都是 JS 引擎主动抛出的错误,那么,我们开发者则可通过 throw 关键字来抛出错误,语法很简单:

                      // throw expression
                      throw 123
                      throw 'abc'
                      throw { name: 'Frankie' }
                      // ...
                      

                      请注意,在 JavaScript 中 throw 关键字和 returnbreakcontinue 等关键字一样,会受到 ASI(Automatic Semicolon Insertion)规则的影响,它不能在 throwexpression 之间插入任意换行符,否则可能得不到预期结果。

                      语法很简单,但通常项目中「不建议」直接抛出一个字面量,而是抛出 Error 对象或其派生类对象,应该这样:

                      throw new Error('oops')
                      throw new TypeError('arguments must be a number.')
                      // ...
                      

                      原因是 Error 对象会记录引发此错误的文件的路径、行号、列号等信息,这应该是排除错误最有效的信息。在 ESLint 中的 no-throw-literal 规则,正是用来约束上述直接抛出字面量的写法的。

                      除了 throw 关键字之外,ES6 中强大的 Generator 函数也提供了一个可抛出异常的方法:Generator.prototype.throw()。它可以在函数体外抛出异常,然后在函数体内捕获异常。

                      function* genFn() {
                        try {
                          yield 1
                        } catch (e) {
                          console.log('inner -->', e)
                        }
                      }
                      
                      try {
                        const gen = genFn()
                        gen.next()
                        gen.throw(new Error('oops'))
                      } catch (e) {
                        console.log('outer -->', e)
                      }
                      

                      打印结果是 inner --> Error: oops。如果生成器函数体内没有 try...catch 去捕获异常,那么它所抛出的异常可以被外部的 try...catch 语句捕获到。

                      当生成器「未开始执行之前」或者「执行结束之后」,调用生成器的 throw() 方法。它的异常只会被生成器函数外部的 try...catch 捕获到。若外部没有 try...catch 语句,则会报错且代码就会停止执行。详看

                      需要注意的是,生成器函数虽然是一个很强大的异步编程的解决方案,但它本身是同步的,而且执行生成器函数并不会立刻执行函数体的逻辑,它需要主动调用生成器实例对象的 next()return()throw() 方法去执行函数体内的代码。当然,你也可以通过 for...of、解构等语法去遍历它,因为生成器本身就是一个可迭代对象。

                      二、try...catch

                      对于可能存在异常的代码,我们通常会使用 try...catch...finally 去处理一些可预见或不可预见的错误。语法有以下三种形式:

                      • try...catch
                      • try...finally
                      • try...catch...finally

                      且必须至少存在一个 catch 块或 finally 块。

                      try {
                        throw new Error('oops')
                      } catch (e) {
                        // some statements...
                      }
                      

                      以上这些语法,写过 JavaScript 相信都懂。

                      曾经 Firefox 59 及以下版本的浏览器,有一种 Conditional catch-blocks 的「条件 catch 子句」的语法(请注意,其他浏览器并不支持该语法,即便是远古神器 IE5,因此知道有这回事就行了)。它的语法如下:

                      try {
                        // may throw three types of exceptions
                        willThrowError()
                      } catch (e if e instanceof TypeError) {
                        // statements to handle TypeError exceptions
                      } catch (e if e instanceof RangeError) {
                        // statements to handle RangeError exceptions
                      } catch (e if e instanceof EvalError) {
                        // statements to handle EvalError exceptions
                      } catch (e) {
                        // statements to handle any unspecified exceptions
                      }
                      

                      那么符合 ECMAScript 标准的「条件 catch 子句」应该这样写:

                      try {
                        // may throw three types of exceptions
                        willThrowError()
                      } catch (e) {
                        if (e instanceof TypeError) {
                          // statements to handle TypeError exceptions
                        } else if (e instanceof RangeError) {
                          // statements to handle RangeError exceptions
                        } else if (e instanceof EvalError) {
                          // statements to handle EvalError exceptions
                        } else {
                          // statements to handle any unspecified exceptions
                        }
                      }
                      

                      请注意,try...catch 只能以「同步」的形式处理异常,因此对于 XHR、Fetch API、Promise 等异步处理是无法捕获其错误的,究其原因就是 Event Loop 嘛。当然实际中可能结合 async/await 来控制会更多一些。

                      2.1 catch子句

                      我们知道,若 try 块中抛出异常时,会立即转至 catch 子句执行。若 try 块中没有异常抛出,会跳过 catch 子句。

                      try {
                        // try statements
                      } catch (exception_var) {
                        // catch statements
                      }
                      

                      其中 exception_var 表示异常标识符(如 catch(e) 中的 e),它是「可选」的,因此可以这样编写 try { ... } catch { ... }。通过该标识符我们可以获取关于被抛出异常的信息。

                      请注意,该标识符的「作用域」仅在 catch 块中有效。当进入 catch 子句时,它被创建,当 catch 子句执行完毕,此标识符将不可再用。也可以理解为(在 ES6 以前)异常标识符是 JavaScript 中含有“块级作用域”的变量。

                      2.2 finally 子句

                      finally 子句在 try 块和 catch 块之后执行,但在下一个 try 声明之前执行。无论是否异常抛出,finally 子句总是会执行。

                      如果从 finally 块中返回一个值,那么这个值将成为整个 try...catch...finally 的返回值,无论是否有 return 语句在 trycatch 块中(即使 catch 块中抛出了异常)。

                      对于这个我表示很无语,可能整个前端圈子就我还不知道吧,原来 finally 还能 return 一个值,在做项目的过程中,确实没写过和见过在 finallyreturn 某个值的,让您见笑了,实在惭愧。

                      但请注意,若要在 try...catch...finally 中使用 return,它只能在函数中运行,否则是不允许的,会抛出语法错误。

                      try {
                        doSomething()
                      } catch (e) {
                        console.warn(e)
                        throw e
                      } finally {
                        return 'completed' // SyntaxError: Illegal return statement
                      }
                      

                      2.3 执行顺序

                      在平常的项目中,一般的 try...catch 写法是在 try 块中 returncatch 块则作相应的异常处理,少数情况也会在 catch 块中 return。因此,大家对这种常规写法的执行顺序应该没什么问题。

                      先来个谁都会的示例:

                      function foo() {
                        try {
                          console.log('try statement')
                          throw new Error('oops')
                        } catch (e) {
                          console.log('catch statement')
                          return 'fail'
                        }
                      }
                      
                      foo()
                      // 以上,先后打印 "try statement"、"catch statement",foo 函数返回一个 "fail" 值
                      

                      接着再看,它打印什么,函数又返回什么呢?

                      function foo() {
                        try {
                          console.log('try statement')
                          throw new Error('oops')
                        } catch (e) {
                          console.log('catch statement')
                          return 'fail'
                        } finally {
                          console.log('finally statement')
                          return 'complete'
                        }
                      }
                      
                      foo()
                      // 先后打印:"try statement"、"catch statement"、"finally statement"
                      // foo 函数返回值是 "complete"
                      

                      前面提到,如果 finally 块中含有 return 语句,那么它的 return 值将作为当前函数的返回值,因此 foo() 结果为 "complete"

                      然后我们再稍微改动一下,在 try 块中 return 一个值,看下结果又有什么不同?

                      function foo() {
                        try {
                          console.log('try statement')
                          return 'success'
                        } catch (e) {
                          console.log('catch statement')
                          return 'fail'
                        } finally {
                          console.log('finally statement')
                          return 'complete'
                        }
                      }
                      
                      foo()
                      // 先后打印:"try statement"、"finally statement"
                      // foo 函数返回值是 "complete"
                      

                      由于 try 块中没有抛出异常,因此 catch 块会被跳过,不执行,但是 finally 块还是会执行的,而且它里面返回了 "complete",因此这个值也就作为 foo 函数的返回值了。

                      因此,我们大致可以得出一个结论,finally 块的代码总会在 return 之前执行,不管 return 是存在于 trycatch 还是 finally 块中。

                      但是,这就完了吗?

                      还没有,我们再看一个示例,看看里面这个 bar() 函数是惰性求值?还是怎样?

                      function foo() {
                        try {
                          console.log('try statement')
                          throw new Error('oops')
                        } catch (e) {
                          console.log('catch statement')
                          return bar()
                        } finally {
                          console.log('finally statement')
                          return 'complete'
                        }
                      }
                      
                      function bar() {
                        console.log('bar statement')
                        return 'something'
                      }
                      
                      foo()
                      

                      以上示例,打印顺序和结果是什么呢?

                      // 打印顺序,依次是:
                      "try statement"
                      "catch statement"
                      "bar statement"
                      "finally statement"
                      
                      // 结果是 "complete"
                      

                      假设 catch 块中的 return bar() 换成 throw bar() 呢,结果又有什么变化呢?如果换成这个你就犹豫了,说明你理解得不够深刻,因此这里我不给出答案,你自己去试试,效果更佳!

                      综上所述,finally 块的执行时机如下:

                      在所有 try 块和 catch 块(如果有,且触发进入的话)执行完之后,即便此时 try 块或 catch 块中存在 returnthrow 语句,它们将会被 Hold 住先不返回或抛出异常,继续执行 finally 块中的代码:

                      • 如果 finally 中存在 return 语句,其返回值将作为整个函数的返回值(前面 try 块或 catch 中的 returnthrow 都会被忽略,可以理解为没有了 returnthrow 关键字一样)。
                      • 如果 finally 中存在 throw 语句,前面 try 块或 catch 中的 returnthrow 同样会被忽略,最后整个函数将会抛出 finally 块中的异常。

                      2.4 嵌套使用

                      它是可以嵌套使用的,当内部的 try...catch...finally 中抛出异常,它会被离它最近的 catch 块捕获到。

                      function foo() {
                        try {
                          try {
                            return 'success'
                          } finally {
                            throw new Error('inner oops') // 它将会被外层的 catch 块所捕获到
                          }
                        } catch (e) {
                          console.log(e) // Error: inner oops
                        }
                      }
                      
                      foo()
                      

                      注意,本节内容所述都是同步代码,而不存在任何异步代码。

                      到此,已彻底弄懂 try...catch...finally 语句了,再也不慌了!

                      三、异常有哪些?

                      在 Web 中,主要有以下几种异常类型:

                      • JavaScript 异常
                      • DOM 和 BOM 异常
                      • 网络资源加载异常
                      • Script Error
                      • 网页异常

                      3.1 JavaScript 异常

                      try...catch 可以捕获同步任务导致的异常,也可以捕获 async/await 中的异常。

                      Promise 中抛出的异常,则可通过 Promise.prototype.catch()Promise.prototype.then(onResolved, onRejected) 捕获。

                      3.2 DOM Exception

                      在调用 DOM API 时发生的,都属于 DOM Exception。比如:

                      <!DOCTYPE html>
                      <html>
                        <body>
                          <video id="video" controls src="https://dl.ifanr.cn/hydrogen/landing-page/ifanr-products-introduce-v1.1.mp4"></video>
                          <script>
                            window.onload = function () {
                              const video = document.querySelector('#video')
                              video.play() // Uncaught (in promise) DOMException: play() failed because the user didn't interact with the document first.
                            }
                          </script>
                        </body>
                      </html>
                      

                      未完待续...

                      ]]>
                      <![CDATA[细读 ES6 | 模板字符串进阶用法]]> https://github.com/tofrankie/blog/issues/285 https://github.com/tofrankie/blog/issues/285 Sun, 26 Feb 2023 12:52:38 GMT 配图源自 Freepik

                      模板字符串(template string)是 ECMAScript 201]]> 配图源自 Freepik

                      模板字符串(template string)是 ECMAScript 2015 规范中的一种新特性,在平常开发中使用频率非常高。也可称作模板字面量(template literal)。

                      常规用法

                      模板字符串使用反引号(` `)来代替普通字符串中的用双引号和单引号。

                      // 单行字符串
                      `single line text`
                      
                      // 多行字符串
                      `multiline text 1
                       multiline text 2`
                      
                      // 嵌入表达式
                      function sayHi(name) {
                        console.log(`Hi, ${name}~`)
                      }
                      
                      // 模板字符串内使用反引号,需在前面添加转义符 (\)
                      `\`` === '`' // true
                      

                      需要注意的是,模板字符串内的「任意字符」都是它的一部分,包括很容易被视觉忽略的前导尾随空格、换行符等「空白符」。比如:

                      String.raw`multiline text 1
                       multiline text 2` // "multiline text 1\n multiline text 2"
                      

                      以上 String.raw() 是模板字符串的标签函数(下面会介绍),它返回指定模板字符串的原始字符串,可以看到它是包含 \n (换行符和空格)的。

                      它也支持嵌套语法,比如:

                      const className = `flex-center ${isShow ? `display ${isLarge ? 'big' : 'normal'}` : ''}`
                      

                      以上这些内容,相信看到这篇文章的你都懂,没什么问题。

                      进阶用法

                      如果你在项目中使用过 CSS-in-JS,可以经常看到类似这样的语法:

                      import styled from 'styled-components'
                      
                      const Button = styled.button`
                        color: palevioletred;
                        font-size: 1em;
                        margin: 1em;
                        padding: 0.25em 1em;
                        border: 2px solid palevioletred;
                        border-radius: 3px;
                      `
                      

                      以上是 styled-components 的基础用法。

                      这看起来有点“奇怪”的语法,正是模板字符串中的标签模板(tagged template)。看个例子:

                      alert`Hey`
                      

                      以上等同于 alert('Hey'),在这里 alert 被称作「标签函数」。

                      因此「标签模板」实际上只是函数调用的一种特殊形式。标签指的是函数(即 alert() 方法),而函数后面的模板字符串就是它的参数。

                      如果标签模板中的模板字符串是包含变量的,就不是简单的调用了。比如:

                      const name = 'Frankie'
                      
                      // 标签模板
                      tag`Hey, ${name}~`
                      
                      // 等同于这样调用函数
                      tag(['Hey, ', '~'], 'Frankie')
                      

                      标签函数的第一个参数,是模板字符串中由不包含 ${expression} 部分的字符串拆分而成的数组。即 `Hey, ${name}~` 中的剔除掉 ${name} 后拆分组成的数组。而后面的参数则为模板字符串中的所有变量。注意,标签函数并不是只能返回字符串,它也是一个普通函数,可以返回任何值。

                      形式如:

                      function tagFunction(strArr, ...values) {
                        // some statements...
                      }
                      

                      其中标签函数第一个参数中,存在一个特殊的属性 raw,通过它可以访问模板字符串的「原始字符串」,而不经过特殊字符的转换。

                      function tagFunction(strArr) {
                        console.log(strArr.raw[0])
                      }
                      
                      tagFunction`multiline text 1
                       multiline text 2` // "multiline text 1\n multiline text 2"
                      

                      应用场景

                      1. 写 GraphQL 应用常用的 graphql-tag 库,就是应用了标签函数的语法,写法很简洁。
                      import gql from 'graphql-tag';
                      
                      const query = gql`
                        {
                          user(id: 5) {
                            firstName
                            lastName
                          }
                        }
                      `
                      

                      未完待续...

                      ]]>
                      <![CDATA[面试题:['1', '2', '3'].map(parseInt) 输出什么?]]> https://github.com/tofrankie/blog/issues/284 https://github.com/tofrankie/blog/issues/284 Sun, 26 Feb 2023 12:51:47 GMT 配图源自 Freepik

                      一、背景

                      事情经过是这样的,前几天上班路上,跟往常一样拿起]]> 配图源自 Freepik

                      一、背景

                      事情经过是这样的,前几天上班路上,跟往常一样拿起手机看头条、逛知乎、刷掘金嘛。

                      过程中,看到以下这个面试题:

                      ['1', '2', '3'].map(parseInt) 输出什么?

                      其实,这题很简单,不就是类似 [0, 1, 2].filter(Boolean) 这种变形题目嘛,但我却没能马上说出答案。

                      我知道 parseInt() 的第二个参数跟进制数相关,但由于平常多数是缺省第二个参数,平常写项目也会启用 ESLint 的 radix 规则,但规则启用时也几乎是填写 10 作为实参,因为涉及其他进制数的情况确实很少很少...

                      所以,趁机再熟悉下 parseInt(string, radix) 这个函数,也是挺不错的。

                      回到上面的题目,分解一下,就是返回以下三个运算结果组成的数组嘛:

                      parseInt('1', 0, ['1', '2', '3'])
                      parseInt('2', 1, ['1', '2', '3'])
                      parseInt('3', 2, ['1', '2', '3'])
                      

                      对于 parseInt() 函数,仅接收两个参数,所以第三个参数实际上没有任何作用,因此 ['1', '2', '3'].map(parseInt) 结果就是:

                      [
                        parseInt('1', 0),
                        parseInt('2', 1),
                        parseInt('3', 2)
                      ]
                      

                      但这篇文章的重点并非是答案,我们应该关注 parseInt(string, radix) 函数本身。

                      二、八进制数表示法的前世今生

                      如果常用 ESLint 的同学,应该知道它有一个规则 radix 是跟 parseInt() 相关的。

                      看个例子,它们分别打印什么结果?

                      parseInt('071') // 57
                      parseInt('071', 10) // 71
                      

                      有些本着求真的同学,将 parseInt('071') 拷到控制台发现,无论是 Chrome、Firefox 还是 Safari 都是打印出 71 而不是 57

                      我为什么写成 57 呢?是写错了吗?明明在浏览器中 parseInt('071') 都是打印出 71 呢!

                      先别急,我们知道在「严格模式」下,是不允许使用以 0 开头的八进制语法的。

                      "use strict"
                      var n = 071 // SyntaxError: Octal literals are not allowed in strict mode.
                      

                      你有可能不知道,其实在 ES6 标准发布之前,ECMAScript 是没有八进制语法的,至于类似 071 这种八进制表示法它只是被所有浏览器厂商支持罢了。比如 Object.prototype.__proto__ 从来就不是 ECMAScript 的标准语法一样,但所有浏览器都支持罢了,标准语法是 Object.getPrototypeOf()

                      在 ES6 中提供了八进制数的标准规范:在数字前加上 0o 来表示八进制数,比如八进制的 710o71 表示。

                      回到 parseInt(string, radix) 与八进制的话题上,

                      当没有指定 radix 参数时,看看各家是如何解析八进制数的?

                      • ES3「不提倡」将带有 0 开头的数值字符串解析为一个八进制。(不赞成,但没禁止)

                      • ES5 规范中「不允许」parseInt 函数的实现环境把带有 0 开头的数值字符串解析为八进制数值,而是以 10 为基数(即十进制)进行解析。(规范禁止了,但浏览器没有按标准实现)

                      • 各浏览器厂商大爷们:我偏不按你规范去实现,仍要把带有 0 开头的数值字符串解析成八进制数值。(我行我素)

                      本着求真的态度,拿出了上古神器去验证并得出结果:在 IE8 及以下浏览器 parseInt('071') 打印结果为 57(下图),而 IE9 及以上则为 71

                      ▼ Internet Explorer 8

                      随着 JavaScript 的飞速发展,浏览器厂商们都向标准靠近了,不再肆意我行我素了。至于浏览器 parseInt('071') 打印结果是 71,原因正是现在的浏览器 JS 引擎是以 10 为基数进行解析了。

                      尽管 2022 年了,但仍要兼容旧版(远古)浏览器,所以显式指定 radix 参数是非常有必要的。本节用一个比较典型的案例来说明,使用 parseInt 函数时,应当指定 radix 参数。

                      在 JavaScript 中,有四种进制数的表示语法:

                      • 十进制:没有「前导零」的数值。
                      • 二进制:以 0b0B 开头的数值。
                      • 八进制:以 0o0O 开头的数值。浏览器等宿主环境也支持以「前导零」开头,且只有 0 ~ 7 的数字表示八进制数。
                      • 十六进制:以 0x0X 开头的数值。

                      三、parseInt

                      语法

                      parseInt(string, radix)
                      

                      解析一个字符串并返回指定基数的「十进制整数」或者 NaN

                      • string 被解析的值。若参数不是字符串类型,内部先隐式类型转换为字符串类型(即调用相应值的 toString() 方法)

                      • radix(可选,取值范围 2 ~ 36 的整数) 表示字符串的基数。但请注意,10 不是默认值!当参数 radix 缺省时,会有几种情况,下面会介绍。

                      注意点 1

                      string 参数带有前导或尾随空白符(包括 \n\r\f\t\v 和空格),它们将会被忽略。换句话说,实际有意义的是第一个非空白符开始。

                      parseInt('  \n\r\f\t\v   11', 2) // 3,相当于 parseInt('11', 2)
                      

                      注意点 2

                      string 参数的「第一个非空格字符」不能转换为数字,或者当 radix < 2 || radix > 36 时,返回值为 NaN

                      parseInt('a11') // NaN
                      parseInt('11', 1) // NaN
                      parseInt('11', 37) // NaN
                      

                      但请注意,并不是所有的字母开头的都返回 NaN。比如 parseInt('a11', 12) 返回值为 1453。因为超过十进制之后,字母也可能用于表示相应的进制数的。

                      注意点 3

                      radix 参数为 undefinednull0 或缺省(未显式指定)时,JavaScript 引擎会假定以下情况:

                      • 如果 string 是以 0x0X 开头的,那么 radix 将会假定为 16,将其余部分当作十六进制数去解析。比如 parseInt('0xf') 相当于 parseInt('f', 16),结果为 15

                      • 如果 string 是以 0 开头的,那么 radix 在不同的环境下,有可能被假定为 8(八进制)或假定为 10(十进制)。具体选择哪一种作为 radix 的值,视乎运行 JavaScript 的宿主环境(前面提到过了)。因此,在使用 parseInt 时,一定要显式指定 radix 参数。

                      • 如何 string 是以任何其他值开头,radix 会被假定为 10(十进制)。

                      注意点 4

                      parseIntstring 的解析规则是:从第一个非空白符开始,然后一直往后面查找,若有任意一个字符不能被转换为数值就会停止,那么最终被解析的字符串就是从开始到停止的这一截字符串。

                      parseInt('11a', 10) // 结果为 11,被解析的字符串为 '11'
                      parseInt('11a', 11) // 结果为 142,被解析的字符串为 '11a',因为在十一进制里面,a 是有意义的。
                      

                      所以,在使用 parseInt 处理 BigInt 类型的时候,最终的返回值总是为 Number 类型(过程中会失去精度),其中 BigInt 类型的拖尾的 n 是会被丢弃的。

                      parseInt(1024n, 10) // 1024
                      parseInt(1024n, 36) // 46732,相当于 parseInt('1024', 36)
                      
                      // 注意,有别于以下这个
                      parseInt('1024n', 36) // 1682375
                      

                      原因非常的简单,前面也提到过的。当 parseInt 的第一个参数不是 String 类型时,会调用 BigInt.prototype.toString() 方法先转换为字符串,即 1024n.toString(),结果为 1024

                      四、最后

                      回到文章开头的题目:

                      [
                        parseInt('1', 0),
                        parseInt('2', 1),
                        parseInt('3', 2)
                      ]
                      

                      这时候,是不是就可以快速说出答案了:[1, NaN, NaN]

                      借机彻底弄懂了 parseInt() 的方法,可以满意地离开了。

                      ]]>
                      <![CDATA[浅读 HTTP]]> https://github.com/tofrankie/blog/issues/283 https://github.com/tofrankie/blog/issues/283 Sun, 26 Feb 2023 12:51:01 GMT 配图源自 Freepik

                      HTTP 全称是 Hyper Text Transfer Protocol,译为“超文本传输协议”(超文本转移协议)。

                      一、前言

                      在很久很久以前,人们为了在世界范围内轻松知识共享,提出了 Web(World Wide Web)的概念。简单来说,就是在 Web 浏览器的地址栏输入 Web 页面对应的 URL,Web 浏览器从 Web 服务器端获取到文件资源等信息,从而显示出 Web 页面。它使用 HTTP 协议为规范,完成在客户端与服务端一系列的运作流程。

                      最初,HTTP 协议出现是为了解决文本传输的难题。但由于协议本身非常简单,现在 HTTP 协议已经超出了 Web 这个框架,被运用到各种场景里。

                      尽管我们常说,在 Web 中客户端与服务器端是通过 HTTP 协议进行通信的,但不代表这个通信的全过程都由 HTTP 就能完成的。

                      通常使用的的网络是在 TCP/IP 协议簇的基础上运作的,而 HTTP 属于它内部的一个子集。

                      二、概念

                      协议(Protocol)

                      在计算机与网络设备进行通信,双方需要基于相同的方法。不同的硬件、操作系统之间的通信,所有的这一切都需要一种规则,这种规则被称为“协议”。

                      TCP/IP 协议(簇)

                      TCP/IP(Transmission Control Protocol/Internet Protocol,传输控制协议/网际协议)是指能够在多个不同网络间实现信息传输的协议簇(Protocol Suite,也有译作“协议族”)。TCP/IP 协议不仅仅指的是 TCP 和 IP 两个协议,而是指由 FTP、SMTP、TCP、IP 等协议构成的协议簇。由于在 TCP/IP 协议中 TCP 协议和 IP 协议最具代表性,因此被称为 TCP/IP 协议。

                      计算机网络体系结构分层

                      OSI(Open System Interconnect)七层模型:

                      OSI 七层模型与 TCP/IP 四层模型的区别:

                      OSI 七层模型:

                      应用层(Application Layer)
                          网络服务与最终用户的一个接口。
                          协议有:HTTP、FTP、TFTP、SMTP、SNMP、DNS、TELNET、POP3、DHCP 等。
                      
                      表示层(Presentation Layer)
                          数据的表示、安全、压缩。
                          格式有 JPEG、ASCII、EBCDIC、加密格式等。
                      
                      会话层(Session Layer)
                          建立、管理、终止会话。
                          对应主机进程,指本地主机与远程主机正在进行的会话。
                      
                      传输层(Transport Layer)
                          定义传输数据的协议端口号,以及流控和差错校验。
                          协议有:TCP、UDP,数据包一旦离开网卡即进入网络传输层。
                      
                      网络层(Network Layer)
                          进行逻辑地址寻址,实现不同网络之间的路径选择。
                          协议有:ICMP、IGMP、IP(IPv4、IPv6)。
                      
                      数据链路层(Data Link Layer)
                          建立逻辑连接、进行硬件地址寻址、差错校验等功能。(由底层网络定义协议)
                          将比特组合层字节进而组合成帧,用 MAC 地址访问介质,错误发现但不能纠正。
                      
                      物理层(Physical Layer)
                          建立、维护、断开物理连接。(由底层网络定义协议)
                      

                      TCP/IP 四层模型:

                      TCP/IP 协议簇按层次分别分为:应用层、传输层、网络层和数据链路层。

                      应用层:
                          应用层决定了向用户提供应用服务时通信的活动。
                          TCP/IP 协议簇内预设了各类通用的应用服务。比如:FTP、DNS 服务就是其中两类。
                          HTTP 协议也处于该层。
                      
                      传输层:
                          传输层对应上层应用层,提供处于网络连接中的两台计算机之间的数据传输。
                          在传输层有两个性质不同的协议:TCP 和 UDP。
                      
                      网络层(网络互连层):
                          网络层用来处理在网络上流动的数据包。数据包是网络传输的最小数据单位。该层规定了通过怎样的路径(所谓的传输路线)到达对方计算机,并把数据包传送给对方。
                          与对方计算机之间通过多台计算机或网络设备进行传输时,网络层所起的作用就是在众多的选项内选择一条传输线路。
                      
                      链路层(数据链路层、网络接口层)
                          用来处理连接网络的硬件部分。包括控制操作系统、硬件的设备驱动、NIC(网络适配器,即网卡)、及光纤等物理可见部分(还包括连接器等一切传输媒介)。硬件上的范畴均在链路层的作用范围之内。
                      

                      三、HTTP

                      HTTP 状态码

                      2XX(成功)
                          200 OK:表示请求在服务端被正常处理了。
                          204 No Content:表示请求已正常处理,但在返回的响应报文中不含实体的主体部分。
                          206 Partial Content:表示客户端进行了范围请求,而服务器成功执行了这部分的 GET 请求。
                      
                      3XX(重定向)
                          301 Moved Permanently:表示永久重定向。它会按照响应报文的 Location 字段重新发起请求。
                          302 Found:表示临时重定向。与 301 相似,但它只是临时性质的,换句话说,资源对应 URI 未来还有可能发生改变。
                          303 See Other:表示请求对应的资源存在着另一个 URI,应使用 GET 方法定向获取请求的资源。
                          304 Not Modified:表示服务器端资源未发生改变,可直接使用客户端未过期的缓存。尽管 304 被划分在 3XX 类别中,但它和重定向没有关系。
                          307 Temporary Redirect:临时重定向,与 302 Found 有着相同的含义。但它不会从 POST 变成 GET。
                      
                      4XX(客户端错误)
                          400 Bad Request:表示请求报文中存在语法错误。
                          401 Unauthorized:表示用户认证失败,即请求需要有认证信息。
                          403 Forbidden:表示对请求资源的访问被服务器拒绝了。
                          404 Not Found:表示服务器上无法找到请求的资源。
                      
                      5XX(服务器错误)
                          500 Internal Server Error:表示服务器端在执行 请求时发生了错误。
                          503 Service Unavailable:表示服务器暂时处于超负载或正在进行停机维护,现在无法处理请求。
                      

                      HTTP 报文

                      报文首部
                      空行
                      报文主体
                      
                      报文首部分为:请求报文首部、响应报文首部
                      
                      请求报文包括:请求行、首部字段(请求首部字段、通用首部字段、实体首部字段)、其他
                      响应报文包括:状态行、首部字段(响应首部字段、通用首部字段、实体首部字段)、其他
                      
                      请求行:由方法、URI、HTTP 版本组成
                      状态行:由HTTP 版本、状态码(数字和原因短语)组成
                      

                      未完待续...

                      ]]>
                      <![CDATA[初试 Yarn Workspaces]]> https://github.com/tofrankie/blog/issues/282 https://github.com/tofrankie/blog/issues/282 Sun, 26 Feb 2023 12:50:04 GMT 配图源自 Freepik

                      示例 👉

                      示例 👉 tofrankie/yarn-workspaces-demo

                      Usage

                      查看 workspaces

                      # 查看 Workspace 信息
                      $ yarn workspaces info
                      

                      执行脚本命令

                      # 在某个 workspace 中执行脚本命令
                      $ yarn workspace <workspace-name> <command>
                      
                      # 在所有 workspace 中执行脚本命令
                      $ yarn workspaces run <command>
                      

                      添加依赖

                      # 在某个 workspace 中添加依赖
                      $ yarn workspace <workspace-name> add <package>
                      

                      以上为 yarn classic 命令,而 2.x 部分有调整,更多

                      软链接查找

                      假设我们项目的 Workspaces 如下,

                      {
                        "@workspace/project-1": {
                          "location": "packages/project-1",
                          "workspaceDependencies": ["@workspace/common"],
                          "mismatchedWorkspaceDependencies": []
                        },
                        "@workspace/project-2": {
                          "location": "packages/project-2",
                          "workspaceDependencies": [],
                          "mismatchedWorkspaceDependencies": []
                        },
                        "@workspace/common": {
                          "location": "packages/common",
                          "workspaceDependencies": [],
                          "mismatchedWorkspaceDependencies": []
                        }
                      }
                      

                      可以看到,其中 @workspace/project-1 依赖了 @workspace/common,所以 workspaceDependencies 不为空。反之,没有引用的话就为空数组。

                      假设,我们在 project-1 中引用了 common 中的导出模块,如下:

                      // packages/common/index.js
                      export const config = { name: 'Frankie', age: 20 }
                      
                      // packages/project-1/index.js
                      import { config } from '@workspace/common'
                      console.log(config)
                      

                      project-1 中查找 @workspace/common 时:

                      1. 先查找 project-1 是否存在对应版本的 @workspace/common,若找不到继续往下;
                      2. 查找 workspace-root 是否存在对应版本的 @workspace/common,若找不到继续往下;
                      3. 查找 NPM 平台是否存在对应版本的 @workspace/common,若找到会拉取下来,若找不到继续往下;
                      4. 抛出错误 Not Found。

                      参考链接

                      ]]>
                      <![CDATA[细读 JS | JavaScript 模块化之路]]> https://github.com/tofrankie/blog/issues/281 https://github.com/tofrankie/blog/issues/281 Sun, 26 Feb 2023 12:47:42 GMT 配图源自 Freepik

                      学习不能停,都给我卷起来...

                      一、前世今生

                      配图源自 Freepik

                      学习不能停,都给我卷起来...

                      一、前世今生

                      在 ES6 之前,JavaScript 一直没有官方的模块(Module)体系,对于开发大型、复杂的项目形成了巨大的障碍。幸好社区上有一些模块加载方案,最主要的有 CommonJS(CommonJS Modules)和 AMD(Asynchronous Module Definition)两种模块规范,前者用于服务器,后者用于浏览器。

                      随着 ES6 的正式发布,全新的模块将逐步取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

                      ES6 模块的设计思想尽量的静态化,是的编译时就能确定模块的依赖关系,以及输入和输出的变量。

                      rollupwebpack 等构建工具中常见的 Tree Shaking 能力,就是依赖于 ES6 模块的静态特性实现的。

                      而 CommonJS 和 AMD 模块都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。

                      // CommonJS 模块
                      const { stat, exists, readFile } = require('fs')
                      
                      // 相当于
                      const _fs = require('fs')
                      const stat = _fs.stat
                      const exists = _fs.exists
                      const readFile = _fs.readFile
                      

                      以上示例,实际上是整体加载了 fs 模块(即加载 fs 的所有方法),生成了一个对象 _fs,然后再从这个对象上读取了 3 个方法。这种方式称为“运行时加载”,原因是只有运行时才能得到这个对象,导致完全没有办法在编译时做“静态优化”。

                      ES6 模块不是对象,而是通过 export 命令显式指定输出的代码,再通过 import 命令输入。

                      import { stat, exists, readFile } from 'fs'
                      

                      以上示例,实际上是从 fs 模块中加载了 3 个方法,其他方法不加载。这种方式称为“编译时加载”或“静态加载”,即 ES6 模块可以在编译时就完成模块加载,效率要高于 CommonJS 模块的加载方式。这也导致了没法引用 ES6 模块本身,因为它不是对象。

                      由于 ES6 模块是编译时加载,使得静态分析成为可能。有了它,就能进一步拓宽 JavaScript 的语法,比如引入宏(macro)和类型检验(type system)这些只能靠静态分析实现的功能。

                      除了静态加载带来的各种好处,ES6 模块还有以下好处:

                      • 不再需要 UMD 模块格式了,将来服务器和浏览器都会支持 ES6 模块格式。
                      • 将来浏览器的新 API 就能用模块格式提供,不再必须做成全局变量或者 navigator 对象的属性。
                      • 不再需要对象作为命名空间(比如 Math 对象),未来这些功能可以通过模块提供。

                      二、为什么需要模块化?

                      举个 🌰

                      <!DOCTYPE html>
                      <html lang="en">
                        <body>
                          <script src="module-a.js"></script>
                          <script src="module-b.js"></script>
                        </body>
                      </html>
                      
                      // module-a.js
                      var person = { name: 'Frankie', age: 20 }
                      
                      // module-b.js
                      console.log(person.name) // 将会打印什么呢?
                      

                      我们可以轻而易举就知道 module-b.js 里将会打印出 Frankie,原因很简单,它们都是处于全局作用域下,因此 module-b.js 中的 person.name 就能读取到在 module-a.js 中定义的 person 变量。

                      如果将 module-a.jsmodule-b.js 在 HTML 中的顺序换过来,就会抛出错误。原因是<script> 是按块加载的,包括下载、(预)编译和执行。唯有当前块执行完毕,或者抛出错误,才会接着加载下一个 <script>

                      注意,这里提到的按顺序加载,是指没有 deferasync 属性的哈。它俩对外部脚本的加载方式是有影响的。但非本文话题,因此不展开讲述。

                      那问题就来了,这很容易造成全局污染,对于大型、复杂的项目来说会非常棘手。

                      假设没有 CommonJS 等模块化解决方案,要怎样解决这种问题呢?

                      1. 对象字面量(Object Literal)

                      // 声明
                      var namespace = {
                        prop: 123,
                        method: function () {},
                        // ...
                      }
                      
                      // 调用
                      namespace.prop
                      namespace.method()
                      

                      缺点:

                      作为一个单一的、有时很长的句法结构,它对其内容施加了限制。内容必须在 {} 之间,并且属性或方法之间必须添加逗号。当模块内容复杂起来之后,维护成本高,移动内容变得更加困难。

                      在多个文件中使用相同的命名空间:可以将模块定义分散到多个文件中,并按如下方式创建命名空间变量,则可忽视加载文件的顺序。

                      var namespace = namespace || {}
                      

                      使用多个模块,可以通过创建单个全局命名空间并向其添加子模块来避免全局名称的扩散。不建议进一步嵌套,如果名称冲突是一个问题,您可以使用更长的名称。这种方式称为:嵌套命名空间。

                      // 全局命名空间
                      var globalns = globalns || {}
                      
                      // 添加 A 子模块
                      globalns.moduleA = {
                        // module content
                      }
                      
                      // 添加 B 子模块
                      globalns.moduleB = {
                        // module content
                      }
                      

                      尽管使用命名空间可以在一定程度上解决了命名冲突的问题,但是存在一个问题:在 moduleB 中可以修改 moduleA 的内容,而且 moduleA 可能还蒙在鼓里,不知情。

                      以上命名空间内的所有成员和方法,无论是否私有,对外都是可访问的。这是一个明显的缺点,模块化不应该如此设计。

                      Yahoo 公司的 YUI 2 就是采用了这种方案。

                      2. 立即执行函数表达式(Immediately-Invoked Function Expression,简称 IIFE)

                      在模块模式中,我们使用 IIFE 将环境附加到模块数据。可以从模块访问该环境内的绑定,但不能从外部访问。另一个优点是 IIFE 为我们提供了执行初始化的地方。

                      var namespace = (function () {
                        // private data
                        var _prop = 123
                        var _method = function () {}
                      
                        return {
                          // read-only
                          get prop() {
                            return _prop
                          },
                          get method() {
                            return _method
                          }
                        }
                      })()
                      

                      这样的话,我们就不用担心,在外部直接修改 namespace 内部的成员或者方法了。

                      // 读取
                      namespace.prop // 123
                      namespace.method() 
                      
                      // 写入
                      namespace.prop = 456 // 无效
                      namespace.method = function foo() {} // 无效
                      

                      因此,结合前面的内容,就可以这样去处理:

                      // 全局命名空间
                      var globalns = globalns || {}
                      
                      // 添加 A 子模块
                      globalns.moduleA = (function () {
                        // ...
                      
                        return {
                          // ...
                        }
                      })()
                      
                      // 添加 B 子模块
                      globalns.moduleB = (function () {
                        // ...
                      
                        return {
                          // ...
                        }
                      })()
                      

                      到现在,有了命名空间解决了命名冲突问题,同时使用 IIFE 来维护各模块的私有成员和方法,导出对外的开放接口即可。这似乎有了模块化该有的样子。

                      但是,还有一个问题。前面提到过 <script> 是按书写顺序加载的(即使下载顺序可能并行的),主要包括:

                      • 脚本下载
                      • 脚本解析(编译和执行)

                      假设我们的脚本如下:

                      <!DOCTYPE html>
                      <html lang="en">
                        <body>
                          <script src="module-a.js"></script>
                          <script src="module-b.js"></script>
                        </body>
                      </html>
                      

                      那么我们的 modueA 在(首次)解析的时候,就没办法调用 moduleB 的内容,因为它压根还没解析执行。一旦项目复杂度、模块数量上来之后,模块之间的依赖关系就很难维护了。

                      三、社区模块化方案

                      在 ES2015 之前,社区上已经有了很多模块化方案,流行的主要有以下几个::

                      • CommonJS
                      • AMD(Asynchronous Module Definition)
                      • CMD(Common Module Definition)
                      • UMD(Universal Module Definition)

                      其中 CommonJS 规范在 Node.js 环境下取得了很不错的实践,它只能应用于服务器端,而不支持浏览器环境。CommonJS 规范的模块是同步加载的,由于服务器的模块文件存在于本地硬盘,只有磁盘 I/O 的,因此同步加载机制没什么问题。

                      但在浏览器环境,一是会产生开销更大的网络 I/O,二是天然异步,就会产生时序上的错误。后来社区上推出了异步加载、可在浏览器环境运行的 RequireJS 模块加载器,不久之后,起草并发布了 AMD 模块化标准规范。

                      由于 AMD 会提前加载,很多开发者担心有性能问题。假设一个模块依赖了另外 5 个模块,不管这些模块是否马上被用到,都会执行一遍,这些性能消耗是不容忽视的。为了避免这个问题,有部分人试图保留 CommonJS 书写方式和延迟加载、就近声明(就近依赖)等特性,并引入异步加载机制,以适配浏览器特性。比如,已经凉凉的 BravoJS、FlyScript 等方案。

                      在 2011 年,国内的前端大佬玉伯提出了 SeaJS,它借鉴了 CommonJS、AMD,并提出了 CMD 模块化标准规范。但并没有大范围的推广和使用。

                      在 2014 年,美籍华裔 Homa Wong 提出了 UMD 方案:将 CommonJS 和 AMD 相结合。本质上这不算是一种模块化方案。

                      到了 2015 年 6 月,随着 ECMAScript 2015 的正式发布,JavaScript 终于原生支持模块化,被称为 ES Module。同时支持服务器端和浏览器端。

                      尽管到了 2022 年,现状仍然是多种模块化方案共存,但未来肯定是 ES Module 一统江湖...

                      关于 JavaScript 模块化历史线,可看这篇文章

                      四、CommonJS

                      Node.js 的模块系统是基于 CommonJS 规范的实现的。除此之外,像 CouchDB 等也是 CommonJS 的一种实现。而且它们有一些是没有完全按照 CommonJS 规范去实现的,甚至额外添加了特有的功能。

                      由于我们接触到的 CommonJS 通常指 Node.js 中的模块化解决方法,因此,接下来提到的 CommonJS 均指 Node.js 的模块系统。

                      先瞅一下,一个 CommonJS 模块里面都包括一些什么信息:

                      如果有一些看不懂或不了解其用处的,先不急,下面娓娓道来。

                      CommonJS 的模块特点:

                      • 每一个 JavaScript 文件就是一个独立模块,其作用域仅在模块内,不会污染全局作用域。
                      • 一个模块包括 requiremoduleexports 三个核心变量。
                      • 其中 module.exportsexports 负责模块的内容导出。后者只是前者的“别名”,若使用不当,还可能会导致无法导出预期内容。其中 require 负责其他模块内容的导入,而且其导入的是其他模块的 module.exports 对象。
                      • 模块可以加载多次,但只会在第一次加载时运行一次,然后运行结果就会被缓存起来。下次再加载是直接读取缓存结果。模块缓存是可以被清除的。
                      • 模块的加载是同步的,而且是按编写顺序进行加载。

                      4.1 Module 对象

                      前面打印的 module 就是 Module 的实例对象。每个模块内部,都有一个 module 对象,表示当前模块。它有以下属性:

                      // Module 构造函数
                      function Module(id = '', parent) {
                        this.id = id
                        this.path = path.dirname(id)
                        this.exports = {}
                        moduleParentCache.set(this, parent)
                        updateChildren(parent, this, false)
                        this.filename = null
                        this.loaded = false
                        this.children = []
                      }
                      

                      源码 👉 node/lib/internal/modules/cjs/loader.js(Node.js v17.x)

                      • module.id:返回字符串,表示模块的标识符,通常这是完全解析的文件名。
                      • module.path:返回字符串,表示模块的目录名称,通常与 module.id 的 path.dirname() 相同。
                      • module.exports:模块对外输出的接口,默认值为 {}。默认情况下,module.exportsexports 是相等的。
                      • module.filename:返回字符串,表示模块的完全解析文件名(含绝对路径)。
                      • module.loaded:返回布尔值,表示模块是否已完成加载或正在加载。
                      • module.children:返回数组,表示当前模块引用的其他模块的实例对象。
                      • module.parent:返回 null 或数组。若返回值为数组时,表示当前模块被其他模块引用了,而且每个数组元素表示被引用模块对应的实例对象。
                      • module.paths:返回数组,表示模块的搜索路径(含绝对路径)。
                      • module.isPreloading:返回布尔值,如果模块在 Node.js 预加载阶段运行,则为 true。

                      注意点

                      • 赋值给 module.exports 必须立即完成,不能在任何回调中完成(应在同步任务中完成)。 比如,在 setTimeout 回调中对 module.exports 进行赋值是“不起作用”的,原因是 CommonJS 模块化是同步加载的。

                      请看示例:

                      // module-a.js
                      setTimeout(() => {
                        module.exports = { welcome: 'Hello World' }
                      }, 0)
                      
                      // module-b.js
                      const a = require('./a')
                      console.log(a.welcome) // undefined
                      
                      // ❌ 错误示例
                      

                      再看个示例:

                      // module-a.js
                      const EventEmitter = require('events')
                      module.exports = new EventEmitter() // 同步任务中完成对 module.exports 的赋值
                      
                      setTimeout(() => {
                        module.exports.emit('ready') // ❓ 这个会生效吗?
                      }, 1000)
                      
                      // module-b.js
                      const a = require('./module-a')
                      a.on('ready', () => {
                        console.log('module a is ready')
                      })
                      
                      // ⚠️ 执行 `node module-b.js` 命令运行脚本,以上 ready 事件可以正常响应,
                      // 原因 require() 会对模块输出值进行“浅拷贝”,因此 module-a.js 中的 setTimeout 是可以更新 EventEmitter 实例对象的。
                      

                      module.exports 属性被新对象完全替换时,通常也会“自动”重新分配 exports(自动是指不显式分配新对象给 exports 变量的前提下)。但是,如果使用 exports 变量导出新对象,则必须“手动”关联 module.exprotsexports,否则无法按预期输出模块值。

                      请看示例:

                      // 1️⃣ 以这种方式进行模块的输出,module.exports 与 exports 会自动分配,即 module.exports === exports
                      module.exports = {
                        // ...
                      }
                      
                      // 2️⃣ 以这种方式导出的值,将会是空对象 {},而不是 { sayHi: <Function> }
                      // 此时 module.exports !== exports
                      exports = { sayHi: function () {} } // ❌
                      
                      // 3️⃣ 解决以上问题,需要手动关联 module.exprots 和 exports,使得二者相等
                      module.exports = exports = { sayHi: function () {} } // ✅
                      
                      • 由以上示例也可以看出,require() 方法引用的是 module.exports 对象,而不是 exports 变量。
                      • 利用 module.parent 返回 null 或数值的特性,可以判断当前模块是否为入口脚本。另外,也可以通过 require.main 来获取入口脚本的实例对象。

                      module.exports 与 exports 的注意点

                      此前已写过一篇文章去介绍它俩的区别了。

                      一句话总结:exports 变量只是 module.exports 属性的一个别名,仅此而已。

                      我们可以这样对模块进行输出:

                      module.exports = {
                        name: 'Frankie',
                        age: 20,
                        sayHi: () => console.log('Hi~')
                      }
                      
                      // 相当于
                      exports.name = 'Frankie'
                      exports.age = 20
                      exports.sayHi = () => console.log('Hi~')
                      

                      但请注意,若模块只对外输出一个接口,使用不当,可能会无法按预期工作。比如:

                      // ❌ 以下模块的输出是“无效”的,最终输出值仍是 {}
                      exports = function () { console.log('Hi~') }
                      

                      原因很简单,在默认情况下 module.exports 属性和 exports 变量都是同一个空对象 {}(默认值)的引用(reference),即 module.exports === exports

                      当对 exports 变量重新赋予一个基本值或引用值的时候, module.exportsexports 之间的联系被切断了,此时 module.exports !== exports,在当前模块下 module.exports 的值仍为 {},而 exports 变量的值变为函数。而 require() 方法的返回值是所引用模块的 module.exports 的浅拷贝结果。

                      正确姿势应该是:

                      module.exports = export = function () { console.log('Hi~') } // ✅
                      

                      使用类似处理,使得 module.exportsexports 重新建立关联关系。

                      这里并不存在任何难点,仅仅是 JavaScript 基本数据类型和引用数据类型的特性罢了。如果你还是分不清楚的话,建议只使用 module.exports 进行导出,这样的话,就不会有问题了。

                      4.2 require 查找算法

                      require() 参数很简单,那么 require() 内部是如何查找模块的呢?

                      简单可以分为几类:

                      • 加载 Node 内置模块 形式如:require('fs')require('http') 等。

                      • 相对路径、绝对路径加载模块 形式如:require('./file')require('../file')require('/file')

                      • 加载第三方模块(即非内置模块) 形式如:require('react')require('lodash/debounce')require('some-library')require('#some-library') 等。

                      其中,绝对路径形式在实际项目中几乎不会使用(反正我是没用过)、而 require('#some-library') 形式目前仍在试验阶段...

                      以下基于 Node.js 官网 相关内容翻译并整理的版本(存档

                      场景:在 `Y.js` 文件下,`require(X)`,Node.js 内部模块查找算法:
                      
                      1. 如果 `X` 为内置模块的话,立即返回该模块;
                      
                         因此,往 NPM 平台上发包的话,`package.json` 中的 `name` 字段不能与 Node.js 内置模块同名。
                      
                      2. 如果 `X` 是以绝对路径或相对路径形式,根据 `Y` 所在目录以及 `X` 的值以确定所要查找的模块路径(称为 `Z`)。
                      
                        a. 将 `Z` 当作「文件」,按 `Z`、`Z.js`、`Z.json`、`Z.node` 顺序查找文件,若找到立即返回文件,否则继续往下查找;
                        b. 将 `Z` 当作「目录」,
                           1)查找 `Z/package.json` 是否存在,若 `package.json` 存在且其 `main` 字段值不为虚值,将会按照其值确定模块位置,否则继续往下;
                           2)按 `Z/index.js`、`Z/index.json`、`Z/index.node` 顺序查找文件,若找到立即返回文件,否则会抛出异常 "not found"。
                      
                      3. 若 `X` 是以 `#` 号开头的,将会查找最靠近 `Y` 的 `package.json` 中的 `imports` 字段中 `node`、`require` 字段的值确认模块的具体位置。
                        (这一类现阶段用得比较少,后面再展开介绍一下)
                         // https://github.com/nodejs/node/pull/34117
                      
                      4. 加载自身引用 `LOAD_PACKAGE_SELF(X, dirname(Y))`
                      
                          a. 如果当前所在目录存在 `package.json` 文件,而且 `package.json` 中存在 `exports` 字段,
                             其中 `name` 字段的值还要是 `X` 开头一部分,
                             满足前置条件下,就会匹配 subpath 对应的模块(无匹配项会抛出异常)。
                            (这里提到的 subpath 与 5.b.1).1.1 类似)
                          b. 若不满足 a 中任意一个条件均不满足,步骤 4 执行完毕,继续往下查找。
                      
                      5. 加载 node_modules `LOAD_NODE_MODULES(X, dirname(Y))`
                         a. 从当前模块所在目录(即 `dirname(Y)`)开始,逐层查找是否 `node_modules/X` 是否存在,
                            若找到就返回,否则继续往父级目录查找 `node_modules/X` ,依次类推,直到文件系统根目录。
                         b. 从全局目录(指 `NODE_PATH` 环境变量相关的目录)继续查找。
                        
                         若 `LOAD_NODE_MODULES` 过程查找到模块 X(可得到 X 对应的绝对路径,假定为 M),将按以下步骤查找查找:
                            1) 若 Node.js 版本支持 `exports` 字段(Node.js 12+),
                                1.1 尝试将 `M` 拆分为 name 和 subpath 形式(下称 name 为 `NAME`)
                      
                                    比如 `my-pkg` 拆分后,name 为 `my-pkg`,subpath 则为空(为空的话,对应  `exports` 的 "." 导出)。
                                    比如 `my-pkg/sub-module` 拆分后,name 为 `my-pkg`,subpath 为 `sub-module`。
                                    请注意带 Scope 的包,比如 `@myorg/my-pkg/sub-module` 拆分后 name 应为 `@myorg/my-pkg`,subpath 为 `sub-module`。
                      
                                1.2 如果在 M 目录下存在 `NAME/package.json` 文件,而且 `package.json` 的 `exports` 字段是真值,
                                    然后根据 subpath 匹配 `exports` 字段配置,找到对应的模块(若 subpath 匹配不上的将会抛出异常)。
                                    请注意,由于 `exports` 支持条件导出,而且这里查找的是 CommonJS 模块,
                                    因此 `exports` 的 `node`、`require`、`default` 字段都是支持的,键顺序更早定义的优先级更高。
                      
                                1.3 如果以上任意一个条件不满足的话,将继续执行 2) 步骤
                      
                            2) 将 X 以绝对路径的形式查找模块(即前面的步骤 2),若找不到步骤 5 执行完毕,将会跑到步骤 6。
                      
                      6. 抛出异常 "not found"
                      

                      如果不是开发 NPM 包,在实际使用中的话,并没有以上那么多复杂的步骤,很容易理解。但深入了解之后有助于平常遇到问题更快排查出原因并处理掉。如果你是发包的话,可以利用 exports 等做条件导出模块。

                      想了解 Node.js package.json 的两个字段的意义,请看:

                      4.3 require 源码

                      源码 👉 node/lib/internal/modules/cjs/loader.js(Node.js v17.x)

                      // Loads a module at the given file path. Returns that module's `exports` property.
                      Module.prototype.require = function (id) {
                        validateString(id, 'id')
                        if (id === '') {
                          throw new ERR_INVALID_ARG_VALUE('id', id, 'must be a non-empty string')
                        }
                        requireDepth++
                        try {
                          return Module._load(id, this, /* isMain */ false)
                        } finally {
                          requireDepth--
                        }
                      }
                      

                      源码 👉 node/lib/internal/modules/cjs/loader.js(Node.js v17.x)

                      /**
                       * 检查所请求文件的缓存
                       * 1. 如果缓存中已存在请求的文件,返回其导出对象(module.exports)
                       * 2. 如果请求的是原生模块,调用 `NativeModule.prototype.compileForPublicLoader()` 并返回其导出对象
                       * 3. 否则,为该文件创建一个新模块并将其保存到缓存中。 然后让它在返回其导出对象之前加载文件内容。
                       */
                      Module._load = function (request, parent, isMain) {
                        let relResolveCacheIdentifier
                        if (parent) {
                          debug('Module._load REQUEST %s parent: %s', request, parent.id)
                          // Fast path for (lazy loaded) modules in the same directory. The indirect
                          // caching is required to allow cache invalidation without changing the old
                          // cache key names.
                          relResolveCacheIdentifier = `${parent.path}\x00${request}`
                          const filename = relativeResolveCache[relResolveCacheIdentifier]
                          if (filename !== undefined) {
                            const cachedModule = Module._cache[filename]
                            if (cachedModule !== undefined) {
                              updateChildren(parent, cachedModule, true)
                              if (!cachedModule.loaded) return getExportsForCircularRequire(cachedModule)
                              return cachedModule.exports
                            }
                            delete relativeResolveCache[relResolveCacheIdentifier]
                          }
                        }
                      
                        // 1️⃣ 获取 require(id) 中 id 的绝对路径(filename 作为模块的标识符)
                        const filename = Module._resolveFilename(request, parent, isMain)
                      
                        if (StringPrototypeStartsWith(filename, 'node:')) {
                          // Slice 'node:' prefix
                          const id = StringPrototypeSlice(filename, 5)
                      
                          const module = loadNativeModule(id, request)
                          if (!module?.canBeRequiredByUsers) {
                            throw new ERR_UNKNOWN_BUILTIN_MODULE(filename)
                          }
                      
                          return module.exports
                        }
                      
                        // 2️⃣ 缓动是否存在缓存
                        // 所有加载过的模块都缓存于 Module._cache 中,以模块的绝对路径作为键值(cache key)
                        const cachedModule = Module._cache[filename]
                      
                        if (cachedModule !== undefined) {
                          updateChildren(parent, cachedModule, true)
                          if (!cachedModule.loaded) {
                            const parseCachedModule = cjsParseCache.get(cachedModule)
                            if (!parseCachedModule || parseCachedModule.loaded) return getExportsForCircularRequire(cachedModule)
                            parseCachedModule.loaded = true
                          } else {
                            // 若该模块缓存过,则直接返回该模块的 module.exports 属性
                            return cachedModule.exports
                          }
                        }
                      
                        // 3️⃣ 加载 Node.js 原生模块(内置模块)
                        const mod = loadNativeModule(filename, request)
                        if (mod?.canBeRequiredByUsers) return mod.exports
                      
                        // 4️⃣ 若请求模块无缓存,调用 Module 构造函数生成模块实例 module
                        const module = cachedModule || new Module(filename, parent)
                      
                        // 如果是入口脚本,将入口模块的 id 置为 "."
                        if (isMain) {
                          process.mainModule = module
                          module.id = '.'
                        }
                      
                        // 5️⃣ 将模块存入缓存中
                        // ⚠️⚠️⚠️ 在模块执行之前,提前放入缓存,以处理「循环引用」的问题
                        // See, http://nodejs.cn/api/modules.html#cycles
                        Module._cache[filename] = module
                        if (parent !== undefined) {
                          relativeResolveCache[relResolveCacheIdentifier] = filename
                        }
                      
                        let threw = true
                        try {
                          // 6️⃣ 执行模块
                          module.load(filename)
                          threw = false
                        } finally {
                          if (threw) {
                            delete Module._cache[filename]
                            if (parent !== undefined) {
                              delete relativeResolveCache[relResolveCacheIdentifier]
                              const children = parent?.children
                              if (ArrayIsArray(children)) {
                                const index = ArrayPrototypeIndexOf(children, module)
                                if (index !== -1) {
                                  ArrayPrototypeSplice(children, index, 1)
                                }
                              }
                            }
                          } else if (
                            module.exports &&
                            !isProxy(module.exports) &&
                            ObjectGetPrototypeOf(module.exports) === CircularRequirePrototypeWarningProxy
                          ) {
                            ObjectSetPrototypeOf(module.exports, ObjectPrototype)
                          }
                        }
                      
                        // 7️⃣ 返回模块的输出接口
                        return module.exports
                      }
                      

                      4.4 require 中几个常见的问题

                      Q: Node.js 是如何实现同步加载机制的? A:

                      未完待续...

                      参考链接

                      ]]>
                      <![CDATA[细读 JS | XSS、CSRF 浅谈]]> https://github.com/tofrankie/blog/issues/280 https://github.com/tofrankie/blog/issues/280 Sun, 26 Feb 2023 12:46:58 GMT 配图源自 Freepik

                      一、前提

                      • Cookie 常用于会话状态管理、个性]]> 配图源自 Freepik

                        一、前提

                        • Cookie 常用于会话状态管理、个性化设置等。
                        • 在浏览器可以通过 document.cookie 来访问 Cookie
                        • document.cookie 只能获取当前作用域下的 Cookie,这个作用域受 DomainPath 共同影响。

                        二、CSRF 攻击

                        它与 Cookie 相关

                        1. 什么是 CSRF?

                        CSRF 是 Cross-Site Request Forgery 的简称,译为“跨站请求伪造”。

                        我们知道,假设有两个网站 A 和 B:

                        • 只要在 B 网站发起了 A 网站的 HTTP(S) 请求,这个就算是跨站请求(至于算不算攻击就另说)。
                        • 当你访问并登录网站 A,服务器返回了一些 Set-Cookie 字段。若前往 B 网站,并在 B 网站发起了 A 网站的请求,这时候在 HTTP 请求头是会自动带上 A 网站的 Cookie 的(这里假定没有 Cookie 的 SameSite 限制)。

                        关于第二点,可能会有人疑惑。

                        先明确一下,通过 JS 脚本(document.cookie)只能获取本站下的 Cookie,换句话说,在 B 网站里只能获取 B 网站的 Cookie,是永远没有办法获取到网站 A 的 Cookie 的。这是脚本的行为。

                        其次,在发起 HTTP 请求时,会有一种自动携带 Cookie 的行为。它会自动带上所请求 URL 对应站点的相关 Cookie。

                        CSRF 攻击只是利用了 HTTP 请求自动携带 Cookie 的特性进行攻击,攻击者还是无法获取到被攻击网站的 Cookie 的。这与 XSS 不同,它是直接拿到被攻击网站的 Cookie,然后进行攻击。

                        2. 如何应当 CSRF?

                        方案一:放弃 Cookie,使用 Token

                        既然 CSRF 是利用了 HTTP 请求自动携带 Cookie 的特性,伪造请求以达到欺骗服务器的目的。那么只要我们不使用 Cookie 的方式来验证用户身份,转用 Token 策略,就能完全避免 CSRF 攻击。

                        方案二:SameSite

                        这是 Chrome 51 引入的新特性,它有三个值:NoneLaxStrict。自 Chrome 80 起,Cookie 的 SameSite 默认值为 Lax(在不设置 SameSite 时,其默认值取决于浏览器的默认值)。亦可主动设置为 None,但与此同时,Cookie 必须设置为 Secure

                        这个特性可以解决 CSRF 攻击的问题,表示当前页面与请求的 URL 是相同的,才会携带上这个 Cookie。

                        还是前面的例子,攻击者 B 网站与被攻击者 A 网站是不同域的,当在 B 网站内发起 A 网站的请求时,对应 Cookie 就不会携带上。

                        但 SameSite 较新,在兼容性上可能不太好。

                        方案三:服务端 Referer 验证

                        在发起 HTTP 请求时,在请求头中会有 Referer 字段,它表示当前域的域名。服务端可以通过这个字段来判断请求是否来自“真正”的用户请求。

                        但是 Referer 是可以伪造的,因此并不可靠。

                        虽然 Referer 并不可靠,但用来防止图片盗链还是足够的,毕竟不是每个人都会修改客户端的配置。(一般只允许站内访问)

                        需要注意的是,Referer 的正确英语拼法是 referrer。由于早期 HTTP 规范的拼写错误,为保持向下兼容就将错就错了。例如 DOM Level 2Referrer Policy 等其他网络技术的规范曾试图修正此问题,使用正确拼法,导致目前拼法并不统一。

                        三、XSS 攻击

                        XSS,是 Cross-Site Scripting 的简称,译为“跨站脚本攻击”。命名应该是为了与 CSS 进行区分。

                        1. 什么是 XSS?

                        XSS 是由于不安全的数据引起的,可能是提交表单数据,有可能是页面路径的参数问题。

                        与 CSRF 不同的是,CSRF 是利用了 HTTP 自动携带 Cookie 的特性来达到攻击的目的,攻击者无法通过 JS 脚本获取到被攻击者的 Cookie 等信息的。而 XSS 则是利用一些不安全的数据,例如是一个 <script> 标签,然后获取到用户的一些信息,对其发起攻击。

                        未完待续...

                        参考链接

                        ]]>
                        <![CDATA[细读 JS | 浅谈内存泄露、内存溢出]]> https://github.com/tofrankie/blog/issues/279 https://github.com/tofrankie/blog/issues/279 Sun, 26 Feb 2023 12:46:15 GMT 配图源自 Freepik

                        讲真,这两个概念很容易被混为一谈。

                        一、内存

                        ]]> 配图源自 Freepik

                        讲真,这两个概念很容易被混为一谈。

                        一、内存

                        在 JavaScript 中,没有像 C 语言等提供有内存管理接口,JavaScript 是在创建变量时自动进行分配内存,并且在不使用它们时“自动”释放。释放的过程被称为“垃圾回收”。

                        这个“自动”就是混乱的根源,并让 JavaScript 开发者错误地认为他们可以不用关心内存管理。

                        内存的生命周期

                        不管什么程序语言,内存的生命周期基本是一致的:

                        1. 分配你所需要的内存
                        2. 使用分配到的内存(读、写)
                        3. 不需要时,将其释放/归还

                        所有语言第二部分都是明确的。第一和第三部分在底层语言中是明确的,但在像 JavaScript 这些高级语言中,大部分都是隐含的。

                        内存管理的难题

                        大多数内存管理的问题都在“当内存不需要使用时释放”这个阶段。最困难的就是如何界定并找到“哪些被分配的内存确实不再需要了”。它往往要求开发人员来确定在程序中哪一块内存不再需要并且释放它。

                        高级语言解释器嵌入了“垃圾回收器”,它的主要工作是跟踪内存的分配和使用,以便当分配的内存不再使用时,自动释放它。这只能是一个近似的过程,因为要知道是否仍然需要某块内存是无法判定的(无法通过某种算法解决)

                        垃圾回收(Garbage Collection,GC)

                        由于一些内存“不再需要”的问题无法判定,因此,垃圾回收实现只能有限制的解决一般问题。

                        • 引用计数算法 最初的垃圾回收算法,它把“对象是否不再需要”简化为“对象有没有其他对象引用到它”。如果没有引用指向改对象(零引用),对象将被垃圾回收机制回收。但是这个算法有个限制,无法处理循环引用的情况。

                          关于“引用”的概念,一个对象如果有访问另一个对象的权限(显式或隐式),叫做一个对象引用另一个对象。

                        • 标记清除算法 这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。

                          这个算法假定设置一个叫做根对象(在 Javascript 里是 globalThis 对象)。垃圾回收器将定期从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象……从根开始,垃圾回收器将找到所有可以获得的对象和收集所有不能获得的对象。

                          它解决了早期算法无法处理循环引用的问题,但还是有一个限制:那些无法从根对象查询到的对象都将被清除(实际中很少会碰到这种情况)。

                          从 2012 年起,所有现代浏览器都使用了标记清除垃圾回收算法。所有对 JavaScript 垃圾回收算法的改进都是基于标记-清除算法的改进,并没有改进标记-清除算法本身和它对“对象是否不再需要”的简化定义。

                        所以,通常我们不会在全局作用域下进行过多的变量或函数声明,更推荐将它们放在立即执行函数表达式(IIFE)内进行声明。否则它们将无法被垃圾回收,即在程序的生命周期内一直存在。

                        这一小节提到的“对象”,不仅特指 JavaScript 对象,还包括函数作用域、全局作用域。

                        二、内存泄露、溢出

                        区别

                        这两个概念是存在区别的。

                        • 内存溢出(Out of Memory) 当系统无法提供应用程序所需内存时,会导致应用程序抛出内存溢出的错误。

                        • 内存泄露(Memory Leak) 应用程序中一些被分配的内存,在使用完之后,没有及时被垃圾回收器进行回收(释放),导致一部分无效的内存被占用着。

                        当内存泄露积累到一定程度,就会发生内存溢出。而内存溢出导致的结果是应用程序被杀死。

                        场景

                        1. 内存溢出
                        const obj = {}
                        for (let i = 0; i < 10000; i++) {
                          obj[i] = new Array(1000000)
                        }
                        // 将会崩溃
                        
                        1. 内存泄露
                        • 意外的全局变量
                        • 闭包
                        • setInterval 没有及时清除
                        • DOM 引用未移除
                        • ...

                        未完待续...

                        ]]>
                        <![CDATA[细读 JS | Cookie 详解]]> https://github.com/tofrankie/blog/issues/278 https://github.com/tofrankie/blog/issues/278 Sun, 26 Feb 2023 12:41:15 GMT 配图源自 Freepik

                        Cookie 简述

                        Cookie 是什么?

                        配图源自 Freepik

                        Cookie 简述

                        Cookie 是什么?

                        “Cookie” 这个词没有太多的含义,在计算机学科中很早就出现了,用来表示少量文本数据。

                        在前端领域,我认为 Cookie 的概念是很模糊的。有的时候,可以将它理解为一个小型文本,也可以理解为一种客户端(下称“浏览器”)与服务端(下称“服务器”)交互的一种存储机制。比如,同事 A 说让我看看那个 XXX Cookie 是多少,它具体指浏览器中存储的一小块文本。如果将 Cookie 与服务器 Session 放一起描述的时候,它指的是一种交互机制。

                        Cookie 是不安全的,原因是它本身没有采用任何加密机制。通过 HTTPS 来传输 Cookie 数据是安全的,它与 Cookie 本身无关,与 HTTPS 协议相关。

                        在 Web 中,Cookie 可被服务器、浏览器进行读写操作。在浏览器与服务器进行交互的过程中,浏览器会将任意 Cookie 写入 HTTP 头部,传输给服务端。随着 Web 的飞速发展,出于安全性的考虑,标准与浏览器都在往前走,因而并不是所有 Cookie 都会出现在 HTTP 头部。具体行为下文再谈。

                        Cookie 是一个小型文本文件,用于浏览器与服务器的数据传输。

                        为什么需要 Cookie?

                        我们都知道 HTTP 是无状态的。通俗地讲, 同一浏览器连续发送 HTTP 请求两次,服务器也无法识别到两个请求是来自同一客户端的。

                        但并不意味着,HTTP 协议的无状态是不好的。只是一些场景下,我们需要来维护“状态”(指的是客户端与服务端会话的状态,而不是说给 HTTP 协议加个状态,这是不对的,也无法做到)。

                        比如,网上购物、网络聊天、发布评论等场景,是需要维护状态的。试想一下,如果没有状态,你添加至购物车的商品,刷新下页面就不见了,那还不得急......解决方案也很多,比如给请求打上一个“标记”即可,在添加至购物车的请求上带上 userIdgoodsId 并发送服务端,服务端拿到这些信息就可以区分来自哪个用户的了,并存储到相应的表。这里提到的 userIdgoodsId 都是标记。

                        那一系列的问题来了,标记来自哪里、如何存储、发送请求如何带上、服务端如何接受?

                        • 标记一般来自服务端
                        • 客户端存储标记的方式有很多,比如常见的有:全局变量、CookiesessionStoragelocalStorage,再有更新的 IndexedDBCache API 等接口。

                        Cookie 仅仅只是其中一种存储方式而已,但它可以做到在服务端写、客户端读,然后发起请求时,自动携带在 HTTP 头部,服务端可以接收到。是一种可以做到无感的读写方案。同时,正是因为这种请求时自动带上的机制,被一些别有用心的人利用它来做一些坏事,比如 XSS、CSRF 等。

                        在这些存储方案里,Cookie 是最早出现的,所有浏览器都支持。随着 Web Storage 的发展,又出现了诸如 sessionStoragelocalStorage 等方案。如果还不够用,还有可存储大量结构化数据的解决方案 IndexedDB

                        随着 Web Storage 的普及,Cookie 将会回到最初的形态,作为一种被服务端脚本使用的客户端存储机制。

                        Cookie 属性

                        往下之前,可以先看下这篇文章:Cookie 和 Storage API 区别与详解

                        Cookie 主要是用来服务服务器的,在浏览器里,每个域 Cookie 的空间限制不超过 4K,因而不适宜用于存储与服务器无关的数据。

                        以下这些元数据,用来表示 Cookie 的文本数据、有效期、作用域、可访问性。

                        • NameValue - 对应 Cookie 的名称和真实数据。
                        • DomainPath - 决定谁可以读写这个 Cookie,类似于作用域。
                        • ExpiresMax-Age - 决定了这个 Cookie 的寿命(有效期)。
                        • SecureHttpOnly - 用来应对 XSS(跨站脚本攻击)。
                        • SameSite - 用来应对 CSRF(跨站请求伪造)。

                        先看看一个真实的 Cookie 吧,如下:

                        Name、Value

                        没什么好说的,对应 Cookie 的名称和数据,属于 Key-Value 键值对形式。Cookie 一旦创建,名称就不能更改了。Value 通常要进行编码处理。

                        Domain、Path

                        Domain 决定了浏览器发出 HTTP 请求时,哪些域名会携带这个 Cookie。Path 则在 Domain 的基础上,进一步约束了该 Cookie 的可访问路径。

                        注意点:

                        • 若没有指定 Domain 属性,则默认设为当前 URL 的域名,即对应 window.location.hostdocument.domain 的值。
                        • 若没有指定 Path 的话,默认为 /
                        • 另外,在百度的页面下,不能将 Domain 设置成阿里的。即使不会报错,但它无效的,浏览器会自动忽略。

                        举个例子:

                        假设 Cookie 的 Domain 是 .example.com,那么发起 a.example.comb.example.com 域名下的 HTTP 请求,都会携带该 Cookie,反之不行。

                        假设 Cookie 的 Path 为 /user,那么请求路径为 /user/1 会带上;若请求路径为 //config 则不会带上。

                        通俗地讲,

                        Domain:你是 A 公司的员工,那么 B 公司就不能使唤你。 Path:你是 C 部门的人,那么 D 部门就不能使唤你干活。

                        Expires、Max-Age

                        • 若没有指定 Expires 和 Max-Age 时,这个 Cookie 属于 Session Cookie,退出浏览器就会被清除。
                        • Expires 用于设置 Cookie 的过期时间,它的值是 UTC 格式。
                        • Max-Age 用于指定从现在开始 Cookie 存在的秒数,比如:60 * 60 * 24 * 7 表示一周。
                        • 若同时指定 Expires 和 Max-Age,那么 Max-Age 优先生效。
                        • 若到了失效时间,浏览器会自动清除相应的 Cookie。

                        注意点:

                        • Session Cookie 退出浏览器时,有些浏览器不一定会清除,原因请看这里
                        • Expires 依赖于本地系统时间,因此是不可靠的。而 Max-Age 则与本地时间无关,它总会在设置完的多少秒之后失效。
                        • 若想通过脚本去删除某个 Cookie,可以将 Expires 设为过去的时间,比如 new Date(0)。也可将 Max-Age 设为 0
                        • 将 Max-Age 设为负数,也相当于会话级 Cookie。
                        • 在 HTTP/1.0 是通过 Expires 来判断的,而 HTTP/1.1 则通过 Max-Age 来判断,若无此参数,则降级使用 Expires 判断。

                        Secure、HttpOnly

                        Secure:

                        • 若 Cookie 指定了 Secure,它只会在 HTTPS 请求中携带上。
                        • 若当前协议是 HTTP,那么浏览器会自动忽略服务器发来的 Secure 属性。
                        • Secure 只是一个开关,不需要指定值。如果通信协议是 HTTPS 协议,通过服务器写入的方式,将会自动打开。

                        HttpOnly:

                        若 Cookie 指定了 HttpOnly 属性,那么这个 Cookie 将无法通过 JavaScript 脚本获取。只有在浏览器发出 HTTP 请求时才会携带上该 Cookie。这个主要是用来解决 XSS 攻击的。

                        SameSite

                        在 Chrome 51 中引入这个 SameSite 属性,那时默认值为 None。从 Chrome 80 开始,默认值改为 Lax

                        Cookie 的 SameSite 属性用来限制第三方 Cookie,从而减少安全风险。它可以设置三个值:

                        • Strict - 最为严格,完全禁止第三方 Cookie,跨站请求时,任何情况都不会发送 Cookie。只有当前页面 URL 与请求 URL 一致,才会带上 Cookie。
                        • Lax - 规则稍稍放宽,大多数情况下,也是不发送第三方 Cookie,但是导航到目标网站的 GET 请求除外,只包括三种情况:链接,预加载请求,GET 表单(详见下表)。
                        • None - 若浏览器的 SameSite 不为 None 时,网站可以显式关闭 SameSite 属性,将其设为 None。不过前提是必须同时设置 Secure 属性(Cookie 只能通过 HTTPS 协议发送),否则无法显式关闭,即设置无效。
                        请求类型 示例 None Lax
                        链接 <a href="..."></a> 发送 Cookie 发送 Cookie
                        预加载 <link rel="prerender" href="..."/> 发送 Cookie 发送 Cookie
                        GET 表单 <form method="GET" action="..."> 发送 Cookie 发送 Cookie
                        POST 表单 <form method="POST" action="..."> 发送 Cookie 不发送
                        iframe <iframe src="..."></iframe> 发送 Cookie 不发送
                        AJAX $.get("...") 发送 Cookie 不发送
                        Image <img src="..."> 发送 Cookie 不发送

                        设置了 StrictLax 以后,基本就杜绝了 CSRF 攻击。当然,前提是用户浏览器支持 SameSite 属性。

                        设置为 Strict

                        Set-Cookie: CookieName=CookieValue; SameSite=Strict;
                        

                        设置为 Lax

                        Set-Cookie: CookieName=CookieValue; SameSite=Lax;
                        

                        设置为 None

                        // ❌ 无效
                        Set-Cookie: widget_session=abc123; SameSite=None
                        
                        // ✅ 有效
                        Set-Cookie: widget_session=abc123; SameSite=None; Secure
                        

                        额外地,Cookie 和 CSRF 的关系是什么?

                        CSRF 攻击,仅仅是利用了 HTTP 携带 Cookie 的特性进行攻击的,但是攻击站点还是无法得到被攻击站点的 Cookie。这个和 XSS 不同,XSS 是直接通过拿到 Cookie 等信息进行攻击的。

                        Priority

                        优先级,这是 Chrome 的提案,定义了三种优先级,Low/Medium/High,当 cookie 数量超出时,低优先级的 cookie 会被优先清除。在 Safari 和 FireFox 中,不存在 Priority 属性。

                        不同浏览器有不同的清除策略:一些是替换掉最先(老)的 Cookie,有些则是随机替换。

                        SameParty

                        前面不是提到 SameSite 会禁用第三方 Cookie 嘛,这个 SameParty 就是为了合法地使 Cookie 在一些第三方站点下也可使用(指的是发起 HTTP 请求时会带上)。它需要配合 First-Party Sets 策略使用。

                        Set-Cookie: name=frankie; Secure; SameSite=Lax; SameParty
                        

                        这个是新特性,更多请看这里

                        Cookie 读写

                        我们知道,Cookie 的读写是有差异的,读取的时候可以一次性获取当前作用域下的所用 Cookie,而写入的时候只能一条一条地进行写入操作。这跟浏览器与服务器之间 Cookie 的通信格式有关。浏览器向服务器发送 Cookie 的时候,是一行将所有 Cookie 全部发送。

                        关于 document.cookie 的读写差异,就是对象的 set/get 的原因。

                        通过以下方式,可以窥探一二,可以知道它们都是函数。但由于是原生的内置方法,因此无法打印出具体的函数内容。

                        // 这种方式已废弃
                        document.__lookupSetter__('cookie') // ƒ cookie() { [native code] }
                        document.__lookupGetter__('cookie') // ƒ cookie() { [native code] }
                        
                        // 现在标准推荐用这个,但注意要 Document.prototype 上面找,因为它不会往原型上找的。
                        const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie')
                        descriptor.set // ƒ cookie() { [native code] }
                        descriptor.get // ƒ cookie() { [native code] }
                        
                        // 若要覆盖,可以这样写(实际项目中千万别重写,除非另起名称)
                        Object.defineProperty(document, 'cookie', {
                          set: function () {
                            console.log('custom setter method...')
                            // do something...
                          },
                          get: function () {
                            console.log('custom getter method...')
                            // do something and return some value
                          }
                        })
                        

                        浏览器禁用 Cookie

                        通过以下语句,可判断浏览器是否打开了 Cookie 功能,返回一个布尔值。

                        window.navigator.cookieEnabled // true 开启
                        

                        若关闭了 Cookie 功能,无论是服务器 Set-Cookie,还是客户端通过 JS 脚本,都无法写入 Cookie。同样地,发起 HTTP 请求也将无法携带。

                        写入 Cookie

                        在服务端,各种后端语言或框架林立,写入 Cookie 的方式也各不相同。下面以 express 为例:

                        import express from 'express'
                        
                        const app = express()
                        const port = 8080
                        
                        app.get('/', (req, res) => {
                          // 通常 name 和 value 会经过编码处理的,像 express 默认采用 encodeURIComponent 进行编码处理,亦可在以下可选项中传入 encode 属性,它接受一个函数。
                          res.cookie('cookie-name', 'cookie-value', {
                            // 以下为可选项
                            // domain, // 默认为 URL 对应域名(注意是服务器 URL)
                            // path, // 默认为 /
                            // expires, // 默认不设置,即会话级 Cookie
                            // maxAge, // 默认不设置,即会话级 Cookie
                            // secure, // 默认,根据请求协议决定是否开启。若 HTTP 请求,设置了 true,将无法写入浏览器
                            // httpOnly, // 默认为 false
                            // sameSite // 默认不设置,此时它的值取决于浏览器的默认值。比如 Chrome 80 之后,即使你在浏览器看到的是空的,但它的默认值为 Lax。
                          })
                        
                          // 可写入多个
                          res.cookie('other', '123')
                        
                          // other statements...
                        })
                        
                        app.listen(port)
                        

                        在客户端写入,只能通过 document.cookie 进行设置。注意,它只能为当前域写入 Cookie。假设你在个人网站设置:name=Frankie; Domain=github.com 是无效的。

                        document.cookie = 'name=Frankie; domain=*.example.com; path=/user; expires=Fri, 31 Dec 9999 23:59:59 GMT; max-age=3600; sameSite=Lax; secure; HttpOnly'
                        

                        在浏览器里,通过 JS 脚本的方式,似乎无法写入 HttpOnly 的 Cookie。(待进一步验证)

                        这样设置太麻烦了,我们通常会封装一个方法来添加、修改、删除、检查 Cookie,请看:cookie.js

                        未完待续...

                        ]]>
                        <![CDATA[Promise 不能被取消,真的算是缺点吗?]]> https://github.com/tofrankie/blog/issues/277 https://github.com/tofrankie/blog/issues/277 Sun, 26 Feb 2023 12:40:39 GMT 配图源自 Freepik

                        前两天面试的时候,面试官问到 Promise 有哪些缺点?

                        我的]]> 配图源自 Freepik

                        前两天面试的时候,面试官问到 Promise 有哪些缺点?

                        我的回答是,在处理多个异步操作时,需要编写多个 then()catch() 方法来处理结果,尽管可以有效解决回调地狱(Callback Hell),但也会有纵向发展的趋势,不够优雅。

                        就没想起以下这几点:

                        1. 无法取消 Promise,一旦创建它就会立即执行,无法中途取消;
                        2. 在不设置回调函数情况下,Promise 内部抛出错误,不会反馈到外部;
                        3. 当处于 pending 状态,无法得知目前进展到哪个阶段。

                        哦,原来上面这些是它的缺点啊,当时就没往这方面想。

                        但是细想一下,第一点真的算是它的缺点吗?

                        有兴趣的话,请看以下两个问答:

                        ]]>
                        <![CDATA[0.1 + 0.2 为什么不等于 0.3?]]> https://github.com/tofrankie/blog/issues/276 https://github.com/tofrankie/blog/issues/276 Sun, 26 Feb 2023 12:40:05 GMT 配图源自 Freepik

                        0.10.2 在转换]]> 配图源自 Freepik

                        0.10.2 在转换成二进制后会无限循环,由于标准位数的限制后面多余的位数会被截掉,此时就已经出现了精度的损失,相加后因浮点数小数位的限制而截断的二进制数字在转换为十进制就会变成 0.30000000000000004

                        众所周知,JavaScript 中表示数值类型为 Number,而 Number 采用的是 IEEE 754 64 位双精度浮点数编码。

                        也就是说,不仅是 JavaScript 会产生这种问题,只要是采用 IEEE 754 Floating-point 的浮点数编码方式来表示浮点数时,则会产生这类问题。

                        相关链接:

                        ]]>
                        <![CDATA[Cookie 和 Storage API 区别与详解]]> https://github.com/tofrankie/blog/issues/275 https://github.com/tofrankie/blog/issues/275 Sun, 26 Feb 2023 12:38:59 GMT 配图源自 Freepik

                        它们都是浏览器数据存储的方案,是用于解决数据持久化 配图源自 Freepik

                        它们都是浏览器数据存储的方案,是用于解决数据持久化的问题。除此之外,数据也可以存储在内存中(比如挂载到 window 等全局对象下),但这种方式每当页面刷新就会丢失。

                        下面分别从几个方面,详细地介绍 CookiesessionStoragelocalStorage 的区别。

                        空间限制

                        • Cookie - 大小约为 4K
                        • sessionStoragelocalStorage - 大小约为 5M

                        请注意,其中 Cookie 的空间大小指的是 namevalue 以及 = 号。

                        另外,这三个不同浏览器下可能会有细微的差异,可忽略。

                        在所有浏览器中,若 Cookie 大小已超出空间限制,后续设置的新 Cookie 就会被忽略。

                        数量限制

                        • sessionStoragelocalStorage 无数量限制一说。
                        • Cookie 是有数量限制的。

                        下表为网上收集(非当前实测结论),看一眼知道有限制这回事就好。

                        浏览器 大小 数量
                        IE6 4095 个字节 每个域 20 个
                        IE7/8 4095 个字节 每个域 50 个
                        Opera 4096 个字节 每个域 30 个
                        FireFox 4097 个字节 每个域 50 个
                        Safari 4097 个字节 没有数量限制
                        Chrome 4097 个字节 每个域 53 个

                        若有兴趣,实测可以看这个网站:Browser Cookie Limits。作者简单跑了下,Chrome 每个域是 180 个;Firefox 很快卡住了,风扇嗡嗡响...... 测试结果 Firefox 和 Safari 都 N/A(没数据),应该是没数量限制吧。

                        鉴于各浏览器对 Cookie 的空间、数量限制不完全相同,为了较好地兼容,建议如下:

                        • 总共 300 个 Cookie
                        • 每个 Cookie 大小为 4096 个字节
                        • 每个域 20 个 Cookie
                        • 每个域 81920 个字节(20 × 4096) 。

                        没错,面试官问到这么说吧,应该就 OK 了。

                        另外,与超出空间限制不同的是,超出数量限制之后,是可以继续添加 Cookie 的,但不同浏览器有不同的策略:一些是替换掉最先(老)的 Cookie,有些则是随机替换。作简单了解就好,一般项目不会设那么多的,而且 Cookie 过期浏览器是会自动清除的。

                        有效期

                        • Cookie - 有效期是由 Max-Age 或 Expires 决定的。当 Cookie 过期或失效,由浏览器自动删除。
                          • 若在设置 Cookie 时不写入这两个属性,那么它的就是会话级别的,即退出浏览器会被销毁。
                          • 若同时存在时,Max-Age 优先级更高。
                        • SessionStorage - 在浏览器标签(或窗口)关闭之前均有效。刷新页面不影响。
                        • localStorage - 若不主动清除,永久有效(“主动”是指由浏览器或脚本清除)。

                        请注意:

                        讲道理的话,会话级的 Cookie 在浏览器退出时就会被删除。但是有些浏览器不讲武德,比如 Chrome。在某些情况下,关闭浏览器重新进入,会话级别的 Cookie 并不会删除,在 Application 选项的 Cookie 栏还能看到。

                        原因可能是:Chrome - 设置 - 启动时 选择了 打开新标签 之外的选项。切换过来就好了...

                        作用域

                        同源与同站

                        同源和同站的区别:

                        • 同源(Same-Origin):协议(protocol)+ 主机名(hostname)+ 端口(port)完全一致。
                        • 同站(Same-Site):eTLD + 1 完全一致。

                        顶级域名和二级域名:

                        • 顶级域名:也称为“一级域名”,常见的有 .com.cn.org.net 等等,需要注意的是像 .com.cn.com.hk 也属于顶级域名。
                        • 二级域名:就是顶级域名的下一级域名。

                        在国内,很多资料认为,顶级域和一级域是分开的。比如 .baidu.com,如果按照这种方式划分,那么.com 是顶级域名,.baidu.com 就是一级域名。好像阿里云就是这样定义的。

                        而我更偏向于认为,.baidu.com 属于二级域名。不用过分纠结,在团队内统一即可。

                        eTLD(Top-Level Domains)表示有效顶级域名,那么 eTLD + 1 就表示二级域名。例如:

                        https://www.example.com.cn
                        

                        其中 eTLD.com.cn,那么 eTLD + 1 就是 .example.com.cn

                        关于 eTLD 更多请看这里

                        完整的 URL(网址)构成如下:

                        简单来说,只要二级域名相同,就属于同站。同源则要求更严格。因此同源一定同站,反之则不一定。

                        以下例子,同站,但不同源。

                        http://a.example.com:80
                        http://b.example.com:80
                        

                        三者的作用域

                        • localStorage - 这个最简单,必须同源才能访问,在不同标签(或窗口)之间可共享。
                        • sessionStorage - 必须同源,且不同标签(或窗口)之间是不能共享的(这点上盲猜挺多人会理解错的,我当初也理解错了)。
                        • Cookie - 同站是前提,它还受限于具体的 DomainPath

                        理解这些很重要,为什么这么说呢?

                        作用域引发的问题

                        很多公司,不是每个 Web 项目对应一个三级域名。

                        // 主营业务、较为重要的业务,可能会这样去区分:
                        https://project-a.company.com
                        https://project-b.company.com
                        
                        // 但一些非业务性、或者一些小项目,很可能是这样的:
                        https://sub.company.com/project-a/index.html
                        https://sub.company.com/project-b/index.html
                        

                        这些都很常见,那么问题来了。它们很多都是同源、同站的。假设按小项目划分场景,我在 a 项目中设置了一个 sessionStorage 会话级缓存,那么当前从 a 项目跳转至 b 项目时,b 是可以获取到 a 项目的所有 sessionStorage 的,反之也成立。如果 ab 项目中某个 sessionStoragekey 不小心设置成相同的话,那很可能就会影响到对方。localStorage 同理。至于 Cookie 的话,由于它的空间限制最大只允许 4K,因此不适宜存过多数据,一般会存一些像鉴权信息等比较多。同个公司,业务的用户鉴权等是相似的,所以 Cookie 的访问机制也不会有太大的影响。

                        针对这些问题,建议是非必要的话,将数据存在内存中,比如用 Vuex、Redux、MobX 等状态管理工具来维护应用的状态。一是信息更不容易暴露,而是可以减少 IO 的读写。但是,这样的话,就要解决数据持久化的问题,因为在内存中的话,只要刷新页面就会丢失。怎么解决?

                        以 Redux 为例,在创建 Store 时,是可以传入一个初始状态的,它的值取下面这个会话缓存即可。只要监听到状态发生变化变化,并设置或更新 sessionStorage 级别的缓存,将状态缓存起来即可。比如:

                        import { createStore } from 'redux'
                        
                        // 定义一个可按项目划分的 key
                        const { host, pathname } = window.location
                        const stateCacheKey = `cache_state_${host}_${pathname}`
                        
                        // 设置 store 的初始值,取 stateCacheKey 缓存的值,首次为空对象
                        const initialState = JSON.parse(sessionStorage.getItem(stateCacheKey)) || {}
                        
                        // 创建 Store(reducers、middlewares 非本文讨论重点省略...)
                        const reducers = (state = {}, action) => { /* some reducers... */ }
                        const store = createStore(reducers, initialState) // 这里省略了中间件
                        
                        // 监听状态,每次状态变化 stateCacheKey 都得以更新
                        const unsubscribe = store.subscribe(() => {
                          const currentStateStr = JSON.stringify(store.getState())
                          window.sessionStorage.setItem(stateCacheKey, currentStateStr)
                        })
                        
                        // 解除监听,这样 store.unsubscribe() 调用即可
                        store.unsubscribe = unsubscribe
                        
                        // 个人习惯,也将 store 挂载全局,以备特殊情况调用
                        window.store = store
                        
                        // 作为模块导出,并传入 react-redux 的 Provider 组件
                        export default store
                        

                        sessionStorage 鲜为人知的点(冷门)

                        下面例子,均在同源情况下。

                        假设有两个同源页面: A 页面、B 页面对应 URL 为 page_a_urlpage_b_url

                        示例一:

                        // 1. 在 A 页面,设置一个会话缓存:keyA
                        sessionStorage.setItem('keyA', '123')
                        
                        // 2. 若 A 有一个链接,可跳转至 B 页面(将会以新窗口的形式打开 B 页面)
                        <a target="_blank" href="page_b_url">To Page B</a>
                        
                        // 3. 点击链接,跳转到 B 页面
                        sessionStorage.getItem('keyA') // ❓ 打印结果是什么?
                        
                        // 4. 接着,在 B 页面中,设置另一个会话缓存:keyB
                        sessionStorage.setItem('keyB', '456')
                        
                        // 5. 切换至 A 页面,打印一下:
                        sessionStorage.getItem('keyB') // ❓ 打印结果又是什么?
                        
                        // 6. 在 A 页面中再次设置一个缓存:keyB
                        sessionStorage.setItem('keyB', '789')
                        
                        // 7. 再次切换至 B 页面的窗口,
                        sessionStorage.getItem('keyB') // ❓ 打印结果是 "456" 还是 "789" 呢?
                        

                        示例二:将上述第二步改成下面这样:

                        <a onclick="window.open('page_b_url', 'DescriptiveWindowName')">To Page B</a>
                        

                        结果又是什么呢?直接看下结果:

                        示例一,依次打印出:null、null、"456"
                        
                        示例二,依次打印出:"123"、null、"456"
                        

                        结论:

                        • 同一标签(或窗口)下,所有同源页面将共享 sessionStorage,同样地,在某个页面修改,将影响其他页面。

                        • 通过 <a target="_blank" href="page_b_url"></a>window.open('page_b_url', 'windowName') 方式打开其他同源页面,有以下特点:

                          • 两个 Tab 之间的 sessionStorage 是独立的,互不影响。
                          • 区别点在于,打开新窗口时,初始缓存不一样:前者的初始 sessionStorage 缓存为空。后者基于原 Tab 的 sessionStorage 拷贝一份,作为新窗口的初始 sessionStorage 缓存。
                          • 前者表现与手动创建新窗口是一致的。
                        • 需要另外一种情况,当在某页面内嵌套了一个同源的 iframe,它们之间 sessionStorage 是共享的。若非同源页面则不共享。(这一点不完全严谨,具体原因请往下看)

                        总结

                        • Cookie 作用域前提是同站,同时还受限于 DomainPath。若两者一致,即可理解为同站共享。
                        • sessionStoragelocalStorage 前提必须是同源,其次前者在不同标签(或窗口)相互独立;后者在所有标签之间共享。
                        • 除此之外,以不同方式创建新标签(或窗口),它的 sessionStorage 初始值会有所差异。通过 window.open() 方式,会将原来先的 sessionStorage 值拷贝过来,作为其初始值;其他方式初始缓存为空。但注意,后续的 sessionStorage 读写操作都是独立对。

                        与服务器通信的差异

                        sessionStoragelocalStorage 不会主动参与与服务器的通信。

                        Cookie 是保存在浏览器上的一小型文本文件。在每次与服务器的通信中都会携带在 HTTP 请求头之中。

                        但是会有一些限制,另起一文,请稍等...

                        其他

                        storage 事件的迷惑行为

                        首先,注册 storage 事件监听器,它只能监听其他同源页面的缓存发生改变时,它才会被触发。

                        得出几个结论:

                        • 用于监听其他页面的缓存变化,而同一页面内的缓存变化,都不起作用。(奇葩吧)
                        • 由于不同 Tab 之间 sessionStorage 是独立的,因此无法监听 sessionStorage 的变化,即只能监听 localStorage 的变化。
                        • “发生改变”包括:创建、更新、删除。但重复设置相同的 key-value 不会触发该事件,它至多在首次创建时触发。
                        const listener = function (e) {
                          // key: 对应缓存 key
                          // newValue: 该 key 新设置的缓存值
                          // oldValue: 该 key 对应的旧缓存值
                          // storageArea: 对应为 window.localStorage 对象
                          // storageArea: 触发该事件所对应的 URL
                        }
                        window.addEventListener('storage', listener)
                        

                        当 localStorage 超过 5M 的空间限制之后,若再次 setItem 会怎样?

                        答案显而易见,这次 setItem() 将会失败,且会抛出错误。针对这种情况,可以做一些处理,比如清空再重新记录等...

                        for (let i = 0; i < 2; i++) {
                          try {
                            localStorage.setItem('key', 'value')
                            break
                          } catch (e) {
                            // 清空,并重试
                            // QuotaExceededError: The quota has been exceeded. localStorage缓存超出限制
                            localStorage.clear()
                          }
                        }
                        

                        在 Safari 无痕模式下,对 sessionStorage 操作可能会抛出异常

                        请看:html5 localStorage error with Safari: "QUOTA_EXCEEDED_ERR: DOM Exception 22: An attempt was made to add something to storage that exceeded the quota."

                        sessionStorage 在 iframe 的问题

                        建议少用 iframe,尽管目前很多大网站仍然使用它。

                        前面提到顶级窗口和 iframe 窗口的页面都是同源的情况下,sessionStorage 是可共享的。但不完全是,比如受 iframesandbox 属性影响,分为两种情况:

                        // 1️⃣
                        <iframe sandbox>
                        
                        // 2️⃣
                        <iframe sandbox="allow-same-origin">
                        

                        假设两个页面同源;情况一 sessionStorage 不共享,在 iframe 中是一个全新的 sessionStorage 对象。情况二则共享 sessionStorage

                        参考链接

                        ]]>
                        <![CDATA[HTTP 状态码]]> https://github.com/tofrankie/blog/issues/274 https://github.com/tofrankie/blog/issues/274 Sun, 26 Feb 2023 12:38:00 GMT STATUS_CODES 如下:

                        {
                          '100': 'Continue',
                          '101': 'Switching Protocols',
                          '102': 'Processing',
                          '103': 'Early Hints',
                          ']]>
                                    STATUS_CODES 如下:

                        {
                          '100': 'Continue',
                          '101': 'Switching Protocols',
                          '102': 'Processing',
                          '103': 'Early Hints',
                          '200': 'OK',
                          '201': 'Created',
                          '202': 'Accepted',
                          '203': 'Non-Authoritative Information',
                          '204': 'No Content',
                          '205': 'Reset Content',
                          '206': 'Partial Content',
                          '207': 'Multi-Status',
                          '208': 'Already Reported',
                          '226': 'IM Used',
                          '300': 'Multiple Choices',
                          '301': 'Moved Permanently',
                          '302': 'Found',
                          '303': 'See Other',
                          '304': 'Not Modified',
                          '305': 'Use Proxy',
                          '307': 'Temporary Redirect',
                          '308': 'Permanent Redirect',
                          '400': 'Bad Request',
                          '401': 'Unauthorized',
                          '402': 'Payment Required',
                          '403': 'Forbidden',
                          '404': 'Not Found',
                          '405': 'Method Not Allowed',
                          '406': 'Not Acceptable',
                          '407': 'Proxy Authentication Required',
                          '408': 'Request Timeout',
                          '409': 'Conflict',
                          '410': 'Gone',
                          '411': 'Length Required',
                          '412': 'Precondition Failed',
                          '413': 'Payload Too Large',
                          '414': 'URI Too Long',
                          '415': 'Unsupported Media Type',
                          '416': 'Range Not Satisfiable',
                          '417': 'Expectation Failed',
                          '418': "I'm a Teapot",
                          '421': 'Misdirected Request',
                          '422': 'Unprocessable Entity',
                          '423': 'Locked',
                          '424': 'Failed Dependency',
                          '425': 'Unordered Collection',
                          '426': 'Upgrade Required',
                          '428': 'Precondition Required',
                          '429': 'Too Many Requests',
                          '431': 'Request Header Fields Too Large',
                          '451': 'Unavailable For Legal Reasons',
                          '500': 'Internal Server Error',
                          '501': 'Not Implemented',
                          '502': 'Bad Gateway',
                          '503': 'Service Unavailable',
                          '504': 'Gateway Timeout',
                          '505': 'HTTP Version Not Supported',
                          '506': 'Variant Also Negotiates',
                          '507': 'Insufficient Storage',
                          '508': 'Loop Detected',
                          '509': 'Bandwidth Limit Exceeded',
                          '510': 'Not Extended',
                          '511': 'Network Authentication Required'
                        }
                        
                        ]]>
                        <![CDATA[History 对象及事件监听详解]]> https://github.com/tofrankie/blog/issues/273 https://github.com/tofrankie/blog/issues/273 Sun, 26 Feb 2023 12:36:17 GMT 配图源自 Freepik

                        一、前言

                        理论上说,每个有效的 URL 都指向一个唯一的资]]> 配图源自 Freepik

                        一、前言

                        理论上说,每个有效的 URL 都指向一个唯一的资源。这个资源可以是一个 HTML 页面,一个 CSS 文档,一幅图像等。在地址栏键入完整的 URL 地址,浏览器就会将对应资源展示出来。

                        为了在多个 URL 之间往返,浏览器厂商定义了一种可存储浏览器会话历史(下称“历史记录”)的机制,每访问新的 URL 就会在历史记录中增加一个新的历史记录条目。当前“历史条目”可通过 History 对象(即 window.history)获取,该对象包括了 back()forward()go() 等方法。

                        在很早以前,不同 URL 之间进行切换,都是需要重新加载资源的。直到 Ajax 的出现,打破了这种限制。Ajax 技术允许通过 JavaScript 脚本向服务器发起请求,服务器接收到请求,将数据返回客户端(浏览器),然后根据响应数据按需操作 DOM 以实现局部刷新。这个过程页面并不会重新加载,只会更新 DOM 的局部,因此 URL 并没有发生变化。但是 Ajax 局部刷新的能力,似乎与一个 URL 对应一个资源相悖。于是......就出现了一种解决方案,既可以实现页面局部刷新,也会修改 URL。

                        那就是 URL 中的 # 模式,例如:

                        http://www.example.com/index.html#user
                        

                        # 号表示网页的一个位置,跟在 # 号后面的字符串称为“锚”。当锚发生变化,若页面中存在这样一个位置(可通过锚点或标签元素 id 属性设置),浏览器会使页面自动滚动至对应位置。这种机制的好处是,仅用于指导浏览器的动作,而对服务器是完全无用的。例如,请求上述网址,HTTP 请求的服务器地址是:http://www.example.com/index.html(不会包含 #user)。

                        相比 http://www.example.com/index.html/user 这种形式,URL 上带 # 号除了看着不顺眼之外,对于分享 URL 或 SEO 来说也是一个问题(对此 Google 还提出了一种优化 SEO 的方案,即 URL 中带上 "#!"详见)。后来 HTML5 中提供了另外一种解决方案。它同样是可以修改 URL 且不触发页面重载,而且可以修改 URL 中 Origin 后面的任意路径(即 /index.html/user),这点 # 模式是做不到的。他们将这种能力内置在 History 对象下,包含 history.pushState()history.replaceState() 方法。

                        上面提到了一些词语,有必要说明一下:

                        • 历史记录

                          是指在浏览器中每个标签(窗口)的会话历史(下称“历史记录”)。它由浏览器某个线程维护着,而且标签之间的历史记录是相互独立的,且无法通过 JavaScript 脚本读取。

                          当标签关闭或者退出浏览器,会话结束,历史记录也随之被销毁(没错,这里的“历史记录”,并不是指浏览器应用的“历史记录”功能)。

                        • 历史条目 浏览器每访问一个新的 URL,就会产生一条记录(下称“记录”),并保存至“历史记录”。这条记录,仅能在当前页面的 window.history 对象读取到。

                          举个例子,假设当前历史记录里有 3 条不同页面的记录(假设用数组 [A, B, C] 表示,真正如何表示不去深究,非本文讨论范围),若当前处于 C 页面,那么通过 window.history 读取到数据,是指 C 页面的记录信息。而 AB 页面的信息是获取不对的,除非后退并在对应页面内执行脚本。

                        • 新的 URL 请注意,这个“新”是相对的。由于下文经常提到,因此有必要说明一下。

                          假设在 A 页面跳转到 B 页面,这个 B 就是“新的 URL”。若在 B 中也有一个链接指向 A 页面,点击的时候,这个 A 也是“新的 URL”,因为它是相较于当前页面 URL 所得出来的结论。因此,这个过程会产生 3 条记录,所以历史记录将会是 [A, B, A]

                        下面将按历史顺序一一介绍...

                        二、URL 的 # 号

                        其实前面刚提到,# 表示页面中的一个位置。比如:

                        https://github.com/tofrankie/csscomb-mini#usage
                        

                        上述 URL 中,#usage 表示 https://github.com/tofrankie/csscomb-mini 页面的 usage 位置。

                        URL 上跟在 # 后面的所有字符串,被称为 Fragment,或片段标识符),所以此 URL 的锚为 usage

                        1. location.hash 属性

                        打印 window.location 结果如下:

                        {
                          hash: '#usage'
                          host: 'github.com'
                          hostname: 'github.com'
                          href: 'https://github.com/tofrankie/csscomb-mini#usage'
                          origin: 'https://github.com'
                          pathname: '/tofrankie/csscomb-mini'
                          port: ''
                          protocol: 'https:'
                          search: ''
                        }
                        

                        其中 location.hash 值为 #usage,它是由 # + Fragment 组成的字符串。

                        如果 URL 中不存在 Fragment,location.hash 会返回一个空字符串('')。

                        // 1. https://github.com/tofrankie/csscomb-mini
                        window.location.hash // ""
                        
                        // 2. https://github.com/tofrankie/csscomb-mini#
                        window.location.hash // ""
                        
                        // 3. https://github.com/tofrankie/csscomb-mini#/
                        window.location.hash // "#/"
                        
                        // 4. https://github.com/tofrankie/csscomb-mini#usage
                        window.location.hash // "#usage"
                        

                        2. 修改 URL hash 值

                        修改 hash 值就会直接体现在地址栏上,并且在历史记录中会产生一条新记录。比如,执行 history.length 可以看到 length 的变化。history.length 表示历史记录中的记录个数。

                        可以通过以下几种方式去修改:

                        // 1. 直接给该属性赋值
                        window.location.hash = '#usage' // # 号可省略
                        
                        // 2. 给 window.location 赋值,请注意 # 是不能省略,否则不仅是修改 Fragment 了
                        window.location = '#usage'
                        window.location.href = '#usage'
                        
                        // 3. 请注意,只修改 Fragment 部分,否则会重新加载页面。类似 history.replaceState 作用
                        window.location.replace('https://github.com/tofrankie/csscomb-mini#/usage')
                        
                        // 4. 通过 <a> 标签设置 href 属性,且不能省略 # 号
                        <a href="#usage"></a>
                        

                        请注意,多次设置同一个 Fragment 时,仅首次有效,重复的部分可以理解为是无效的。

                        3. location.hash、location.href 与 location.replace()

                        前面两个方法都可读可写,其中 location.hash 绝对不会重载页面。这跟它的设计初衷有关,前面提过了,不再赘述。而 location.hreflocation.replace() 若只是 URL 的 Fragment 部分发生,也不会重载页面,而其他情况总会重载页面。

                        通过 location.hreflocation.hash 方式去“修改” URL,历史记录都会新增一条新记录。由于 history.length 是历史记录数量的体现,因此也会随之改变。而 location.replace() 则是用新记录覆盖当前记录,因此 history.length 不会发生变化。

                        注意点:

                        • 以上三种方式(包括 <a> 标签形式)去修改 URL,只有在新旧 URL 不相同的情况下,才会新增一条记录。

                        • 其中 location.hreflocation.replace() 方法,若 URL 中包含 Fragment 部分,且新旧 URL 之间仅 Fragment 部分发生变化,也不会重载页面。

                        • 不管新旧 URL 是否一致(URL 不含 Fragment 时),location.href 总会重载页面。

                        • 当新旧 URL 相同时,location.href 作用等同于 location.reload()history.go(0)。虽说是重新加载页面,但多数是从浏览器缓存中加载,除非页面缓存失效或过期了。

                        • 对于 location.href 我们通常会赋予一个完整的 URL 地址,但它是支持“相对路径”形式的 URL 的。(详见:绝对 URL 和相对 URL

                        • 上面是指写操作,并不是读操作哈。

                        一句话总结:若新旧 URL 之间仅仅 Fragment 部分发生改变,以上几种方法都会在历史记录新增一条记录,且不会重载页面。

                        4. Fragment 的位置

                        前面提到,# + Fragment 表示网页的一个位置,用于指导浏览器的行为。当 Fragment 的值发生改变,页面会滚动至对应位置。当然,前提是这个位置存在于页面中,否则也是不会发生滚动的。

                        那么这个“位置”,如何设置呢?

                        讲真的,天天用框架写页面,最原始的反而忘了。有两种方式:

                        • 使用锚点,即利用 <a> 标签的 name 属性(不推荐)
                        • 使用标签 id 属性(推荐)

                        请注意,<a> 标签的 name 属性在 HTML5 中已废弃,请使用 HTML 全局属性 id 来代替。后者在整个 DOM 中必须是唯一的。常用于查询节点、样式选择器、作为页面 Fragment 的位置。

                        <!-- 1. 锚点 -->
                        <a name="usage"></a>
                        
                        <!-- 2. 设置 id 属性 -->
                        <div id="usage"></div>
                        
                        <!-- 这种也是可以的,但这种不称为锚点 -->
                        <a id="usage"></a>
                        

                        再看一例子:

                        <!-- 1. 在点击 a 标签时,会修改 hash 属性为 #usage,但不会滚动至 a 标签 -->
                        <a href="#usage"></a>
                        
                        <!-- 2. 以下情况,除了修改 hash 值,页面也会随之滚动至 a 标签 -->
                        <a name="usage" href="#usage"></a>
                        <a id="usage" href="#usage"></a>
                        

                        上述示例,作者本人会经常混淆(希望你们不会),顺道提一下。简单来说,href="usage" 是为了修改 URL,当 URL 的 hash 变成 #usage,浏览器就会滚动至对应位置(即锚点为 usageid 属性为 usage 的元素所在位置)。

                        5. hashchange 事件

                        若在全局注册了 hashchange 事件监听器,只要 URL 的 Fragment 发生变化,将会被事件处理程序捕获到,事件对象包含了 newURLoldURL 等该事件特有的属性。其余的,在下文对比 popstate 事件时再详细介绍。

                        三、History 对象

                        前面提到,每个标签都有一个独立的历史记录,里面维护着一条或多条记录。每条记录保存了对应 URL 的一些状态,仅能在当前页面的 window.history 对象读取到。(这里不再赘述,若概念有混淆的,请回到开头再看一遍)

                        在 HTML5 之前,History 对象主要包含以下属性和方法:

                        • history.length
                        • history.back()
                        • history.forward()
                        • history.go()

                        1. history.length

                        只读,该属性返回当前会话的历史记录个数。由于 history.length 是历史记录数量的体现,那么当历史记录发生变化时,它才会随之改变。

                        注意以下几点:

                        • 若“主动”打开浏览器的新标签,就会产生一条记录,尽管它可能是一个空标签页,即 history.length1。当键入新 URL 并回车,此时 history.length 就会变为 2

                        • 若浏览器的标签是通过类似 <a target="_blank"> 形式自动创建的话,新标签的 history.length1(不是 2 哦)。此时原标签的历史记录不会受到影响,它们是相互独立的。这种情况就类似于在微信里打开一个链接,进入页面的 history.length1

                        • 不管以任何方式刷新页面,历史记录和 history.length 都不会改变。

                        • 在地址栏键入新的 URL,历史记录会增加 1

                        • 一般情况下,若新旧 URL 相同,此时历史记录不会发生变化,history.length 也不会。特殊情况是,history.pushState()history.replaceState() 方法总会产生一条新记录,即使新旧 URL 相同也会。

                        • 点击浏览器前进/后退/刷新按钮,或者调用 history.back()history.forward()history.go() 方法,不会使历史记录和 history.length 值发生变化。这些操作只会退回/前往历史记录中某个具体的页面。但会触发 popstatehashchange 事件(若有注册的话)。

                        这里描述的场景很多,原因是此前对某些场景没有完全弄清楚(如果你没有这个困扰,简单略过即可)。

                        既然 history.length 是只读的,换句话说,就是我们无法“直接”操作历史记录(比如删除某个历史记录),事实上我们也访问不到。

                        2. history.back()

                        它的作用同浏览器的后退按钮,通俗地讲就是后退至上一页。等价于 history.go(-1)

                        若当前页面是历史记录的第一个页时,调用此方法不执行任何操作。此时浏览器后退按钮也是置灰的,是不可操作的。换句话说,此方法仅在 history.length > 1 时有效。

                        3. history.forward()

                        它的作用同浏览器的前进按钮,通俗地讲就是前往下一页。等价于 history.go(1)

                        若当前页面是历史记录里最顶端的页面时,调用此方法不执行任何操作。此时浏览器前进按钮也是置灰的,是不可操作的。

                        4. history.go()

                        该方法接受一个 delta 参数(可选),通过当前页面的相对位置加载某个页面。

                        window.history.go(delta)
                        

                        一般来说,参数可缺省、为 0、为负整数(表示后退)、正整数(表示前进)。

                        • 比如说,history.go(-2) 会历史记录里后退两个页面。相应地,history.go(2) 会前进两个页面。

                        • 其中 history.go(1) 作用同 history.forward()history.go(-1) 作用同 history.back()

                        • 若缺省 delta 或者 delta0,会重新加载当前页面。此时作用同 location.reload() 或者浏览器的刷新按钮。

                        • delta 数值部分超出了历史记录的范围,不会执行任何操作。既不会后退至历史记录的第一个页面,也不会前往历史记录里最顶端的页面。它会默默地失败,且不会报错。假设历史记录只有 5 条,然后你试图后退/前进 10 个页面,这就属于超出范围。

                        • delta 不是 Number 类型,内部先进行隐式类型转换成对应的 Number 值,再执行 go() 方法。比如,history.go(true) 相当于 history.go(1)history.go(NaN) 相当于 history.go(0)

                        5. 小结

                        back()forward()go() 三个方法,简单总结一下:

                        • 仅调用以上三个方法,不会使得历史记录或 history.length 发生改变。

                        • 调用以上三个方法,通常是从浏览器缓存中加载页面。在 Network 选项卡中往往可以看到类似 from disk cache 的字样。

                        • 当超出了当前标签的历史记录范围,调用以上三个方法都不会执行任何操作,默默地失败且不报错。

                        • 请注意,若后退/前进时,只是锚点发生变化,是不会重新加载页面。

                        四、HTML5 History API

                        History API 作为 HTML5 的新特性之一,解决了 Fragment 的一些痛点,包括 URL 分享,SEO 优化等都得到了很好的解决。这些新特性都内置于 History 对象之中:

                        • history.state
                        • history.scrollRestoration
                        • history.pushState()
                        • history.replaceState()

                        1. history.state

                        只读,该属性返回当前页面的状态值。

                        const currentState = history.state
                        

                        只有通过 pushState()replaceState() 方法产生的历史记录,这个属性才会有相应的值,否则为 null

                        请注意,history.state 的返回值是一份拷贝值

                        2. history.scrollRestoration

                        可读写,该属性允许 Web 应用程序在历史导航上显式地设置默认滚动恢复行为。此属性可以是自动的(auto)或者手动的(manual)。

                        3. history.pushState()

                        在当前位置,总会产生一条新的记录,并保存在历史记录里面,而且 history.length 也会增加。若新旧 URL 不相同的情况下,也伴随着 URL 的变化。

                        请注意,它并不会重载页面。同样的还有 history.pushState() 方法。

                        伪代码:

                        // 假设历史记录(称为 histories)有 5 个页面,当前处于最后一个页面,即 5 位置。
                        const histories = [1, 2, 3, 4, 5]
                        
                        // 若后退 2 页
                        history.go(-2) // 此时,我们的页面处于历史记录中的 3 位置。
                        
                        // 插入一个新记录,假设新记录称为 6
                        history.pushState(
                          { state: 'new' },      // 通常是对象,可通过 history.state 获取
                          'custom title',        // 几乎所有浏览器都会忽略此参数,所以是没用的
                          'https://xxx.com'      // 该 URL 必须跟当前网页是同源的,否则会报错。
                        )
                        
                        // 执行 pushState() 方法后,不会加载页面
                        window.location.href     // "https://xxx.com"
                        window.document.URL      // "https://xxx.com"
                        window.document.title    // 这还是原来的标题,而不是 "custom title"
                        window.history.state     // { state: 'new' }
                        window.history.length    // 4
                        histories                // [1, 2, 3, 6]
                        

                        语法

                        history.pushState(state, title[, url])
                        
                        • state - 可以是任意值,通常为(可序列化)对象。它可以通过 history.state 获取到,或者在 popstate 事件的事件对象中体现。

                        • title - 请忽视该参数的作用,它几乎被所有浏览器所忽略,但不得不传。通常,会传递 ''nullundefined

                        • url - (可选)新 URL,它最终体现在地址栏的 URL 上。请注意,新 URL 与当前页面 URL 必须是同源的(即 location.origin 相同),否则将会抛出错误。

                        注意点

                        参数 state 是可序列化对象,怎么理解?

                        个人猜测是那些可作用域 JSON.stringify() 方法的原始值或引用值,具体没去深究。举个例子,下面这个将会抛出错误:

                        history.pushState(
                          { fn: function () {} }, 
                          '', 
                          location.href + 'abc'
                        )
                        // DOMException: Failed to execute 'pushState' on 'History': 
                        // function() {} could not be cloned.
                        

                        较为冷门的东西,参数 url 也支持 绝对 URL 和相对 URL。举些例子:

                        // 假设当前 URL 如下,它的 Origin 是 https://developer.mozilla.org
                        // https://developer.mozilla.org/zh-CN/docs/Web/API/History/pushState
                        
                        // 1️⃣ 完整网站,可理解为绝对路径,将会变成:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript
                        history.pushState({}, '', 'https://developer.mozilla.org/zh-CN/docs/Web/JavaScript')
                        
                        // 2️⃣ 含 / 可理解为相对路径,相对于当前 Origin,将会变成 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript
                        history.pushState({}, '', '/zh-CN/docs/Web/JavaScript')
                        
                        // 3️⃣ 若为 ../xxx 形式,相对于当前 URL,将会变成 https://developer.mozilla.org/zh-CN/docs/Web/API/History/go
                        history.pushState({}, '', '../History/go')
                        
                        // 4️⃣ 若为字符串,将会变成 https://developer.mozilla.org/zh-CN/docs/Web/API/History/hhh
                        history.pushState({}, '', 'hhh')
                        

                        另外,使用 history.pushState() 可以改变 referrer

                        4. history.replaceState()

                        参数约定与 pushState() 完全一致,语法如下:

                        history.replaceState(stateObj, title[, url])
                        

                        replaceState() 也总会产生一条新记录,并用新记录替换掉当前页面对应的历史记录。

                        伪代码...

                        const histories = [1, 2, 3, 4, 5] // 当前处于 5 位置
                        
                        history.replaceState({}, '', 'new-url') // 创建一个新记录,假设称为 6
                        
                        // 新记录 6 会替换记录 5
                        histories // 历史记录,将会变为 [1, 2, 3, 4, 6]
                        history.length // 5,未发生变化
                        

                        5. pushState 与 replaceState 区别

                        还是伪代码哈:

                        // 假设历史记录里,有 5 条记录,并处于历史记录的顶端,即第五个位置
                        const histories = [1, 2, 3, 4, 5]
                        
                        // 后退 2 个页面,即当前处于第三个位置
                        history.go(-2)
                        
                        // 使用 replaceState 产生一条新记录(假设称为 6),
                        // 它的作用是用新记录替换当前记录,因此记录 3 被新记录 6 所替换
                        // 但仍处于历史记录的第三个位置
                        history.replace('6', '', 'new-url-6')
                        histories // [1, 2, 6, 4, 5]
                        history.length // 5
                        
                        // 使用 pushState 产生一条新记录(假设称为 7),
                        // 它的作用是在当前记录后面添加一条新记录,
                        // 它会删除当前记录后面的所有记录,然后再往后追加一条,
                        // 同时,它的位置也会前往至历史记录顶端,即第四个位置。
                        history.replace('7', '', 'new-url-7')
                        histories // [1, 2, 6, 7]
                        history.length // 4
                        

                        如果用 Array.prototype.splice() 来类比的话,可以这样:

                        const arr = [1, 2, 3, 4, 5]
                        
                        // pushState 类似于
                        arr.splice(curIndex + 1, 1000, newItem)
                        
                        // replaceState 类似于
                        arr.splice(curIndex, 1, newItem)
                        
                        // 注释:
                        // arr 表示历史记录
                        // curIndex 表示当前记录的位置,
                        // 1000 只是为了表达删除完 curIndex + 1 后面的所有项,可用 arr.length 等替代
                        // newItem 表示新记录
                        

                        简单来说,pushState()replaceState() 区别如下:

                        • 两者都会产生新的记录。
                        • 前者会先移除当前记录后面的所有记录,并将新记录追加到历史记录顶端。而后者仅会用新记录替换当前记录,后面的记录并不受影响(若有)。
                        • 两者都会使得历史记录发生变化。后者不会使得 history.length 发生改变。

                        另外,对于历史记录及其数量,history.replaceState()location.replace() 表现是一致的,只是后者有可能会重载页面。

                        6. popstate 事件

                        调用 pushState()replaceState() 方法的话,既不会触发 popstate 事件监听器,也不会触发 hashchange 事件监听器(即使新旧 URL 只是 Fragment 部分不同)。这个也是 History API 的优点之一。

                        其余的下一节介绍...

                        五、hashchange 和 popstate 事件

                        1. hashchange 事件

                        IE8 及以上浏览器都支持 hashchange 事件。注册事件监听器,如下:

                        function listener(e) {
                          // 可通过 e.newURL 和 e.oldURL 获取完整的新旧 URL 值(只读)
                          // do something...
                        }
                        
                        // 通过 DOM2 注册(更推荐)
                        window.addEventListener('hashchange', listener)
                        
                        // 通过 DOM0 注册
                        window.onhashchange = listener
                        

                        对于事件监听器的兼容性,可看 JavaScript 事件详解

                        除了通过调用 pushState()replaceState() 使 URL 的 Fragment 部分发生变化,不会触发 hashchange 事件之外,其他任何方式致使 Fragment 发生改变,都会触发该事件,包括 history.forward()history.back()location.hash<a href="#anchor">、操作浏览器后退/前进按钮、修改地址栏 Fragment 值等方式。

                        本文提到的 Fragment 均指 URL 上跟在 # 后面的所有字符串。

                        2. popstate 事件

                        需要注意的是,调用 history.pushState()history.replaceState() 不会触发 popstate 事件。

                        只有通过点击浏览器后退/前进按钮,或者通过脚本调用 history.back()history.forward()history.go()go(0) 除外)方法,popstate 事件才会被触发。

                        function listener(e) {
                          // 通过 e.state 可以获取当前记录的状态对象对应的拷贝值。
                          // 非 pushState、replaceState 产生的记录,该属性值都为 null。
                        }
                        
                        // 通过 DOM2 注册(更推荐)
                        window.addEventListener('popstate', listener)
                        
                        // 通过 DOM0 注册
                        window.onpopstate = listener
                        

                        另外,不同浏览器在加载页面时处理 popstate 事件的形式可能存在差异。

                        3. 小结

                        下面总结了很多条,很大可能会记不住,没关系:

                        • 通过 back()forward()go() 或浏览器后退/前进按钮切换的过程,一定会触发 popstate 事件。若伴随着 Fragment 的变化,也会触发 hashchange 事件。(与记录产生的方式无关)

                        • 在调用 pushState()replaceState() 时,既不会触发 popstate 事件,也不会触发 hashchange 事件(即使包括 Fragment 发生改变)。

                        • 除了 pushState()replaceState(),其他任何方式致使 Fragment 发生改变,都会触发 hashchange 事件。

                        • 通过 location.hash = 'foo' 方式致使 Fragment 发生改变,会触发 hashchange 事件,而不会触发 popstate 事件。

                        • 而通过 window.location = '#foo'<a href="#foo"> 形式致使 Fragment 发生改变,同时触发 hashchangepopstate 事件。

                        简化记忆:

                        其实常用的方法只有三个:history.pushState()history.replaceState()location.hash。最重要的是,通常一个项目不会两者混用,不然得多乱啊。例如 React 、Vue 提供的路由系统只能二选一:

                        • History 模式:使用 HTML5 History API,更符合未来发展的方向
                        • Hash 模式:利用 location.hashhashchange 事件实现,兼容性较好,且服务端无需额外的配置。

                        所以,就简化成两条:

                        • 调用 pushState()replaceState() 时,不会触发 popstate 事件。其他 URL 的变化都会触发此事件。
                        • 当 URL 的 Fragment 部分发生改变,都会触发 hashchange 事件。

                        六、比较

                        History 模式和 Hash 模式,在不重载页面的前提下,实现了局部刷新的能力。

                        从某种程度来说, 调用 pushState() 和 window.location= "#foo" 基本上一样, 他们都会在当前的历史记录中创建和激活一个新的历史条目。但是 pushState() 有以下优势:

                        • 新的 URL 可以是任何和当前 URL 同源的 URL。但是设置 window.location 只会在你只设置 Fragment 的时候才会使当前的 URL。

                        • 非强制修改 URL。相反,设置 window.location = '#foo' 仅仅会在锚的值不是 #foo 情况下创建一条新的历史记录。

                        • 可以在新的历史记录中关联任何数据。window.location = "#foo" 形式的操作,你只可以将所需数据写入锚的字符串中。

                        注意: pushState() 不会造成 hashchange 事件调用,即使新旧 URL 只是 Fragment 不同。

                        更多...

                        七、React Router

                        在 React 的路由系统中,修改路由、监听路由实际上是由 ReactTraining/react-history 库中 createBrowserHistory()createHashHistory() 方法所构造的 history 对象(有别于 window.history 对象)去操作的。

                        在 React 中,路由操作有这几种方法。

                        • props.history.push() - 新增一条历史记录
                        • props.history.replace() - 新增一条记录,并替换当前记录
                        • props.history.go() - 后退/前进
                        • props.history.goBack() - 即 props.history.go(-1)
                        • props.history.goForward() - 即 props.history.go(1)

                        其中,props.history.go() 实际上就是调用了 window.history.go() 方法。前面两个方法,在不同路由模式下,调用的能力是不一样的。

                        BrowserRouter 模式下,对应 window.history.pushState()window.history.replaceState() 方法。

                        HashRouter 模式下,对应 window.location.hashwindow.location.replace() 方法。

                        在 React Router 中,路由更新以加载不同的组件,是通过 React Context 实现的,即 Provider/Consumer 的模式。当路由更新时,Providervalue 属性会发生变化,使得对应消费 Consumer 的组件得以更新。

                        前面我们提到过,调用 history.pushState()history.replaceState() 并不会触发 popstate 事件监听函数。那么 React Router 是怎么知道 URL 发生变化的呢?

                        首先在选择使用 <BrowserRouter><HashHistory> 组件时,它内部设置了一个监听器,这个监听器的回调函数里面有一个 setState() 方法。当我们在 React 组件中使用 props.history.push() 方法去跳转页面时,它除了会执行 window.history.pushState() 使得 URL 发生改变之外,还会执行前面提到的监听器,那么监听器的回调函数也会被执行,既然里面有 setState() 操作,就会使得 <BrowserRouter><HashHistory> 组件执行一次更新,那么该组件的 Provider 就会更新,React Router 的 Consumer 们根据 URL 来匹配对应的路由,以加载相应的组件。因此,我们就能在浏览器中看到 URL 的变化以及页面的跳转。

                        ]]>
                        <![CDATA[关于 slice、splice 记忆和区分]]> https://github.com/tofrankie/blog/issues/272 https://github.com/tofrankie/blog/issues/272 Sun, 26 Feb 2023 12:35:20 GMT 配图源自 Freepik

                        你们会不会有这些困扰,记不住 slice() 和 <]]> 配图源自 Freepik

                        你们会不会有这些困扰,记不住 slice()splice() 的用法,隔一段时间,再用时就得翻文档。比如说:

                        • 哪个会改变原数组,哪个不会?
                        • 对数组进行操作,包不包括要修改的最后一项呢?
                        • 怎样区分会更容易记忆?

                        Array.prototype.slice

                        slice() 不会“改变”原数组。

                        语法

                        array.slice([begin[, end])
                        

                        beginend 都是可选的。返回一个新的数组,是由 beginend 决定的原数组的浅拷贝(包括 begin,不包括 end )。通俗地讲,就是截取原数组的一部分,并返回截取部分,且“不改变”原数组

                        总结

                        • 返回新数组,且不改变原始值,但注意引用值的问题。

                        • 返回结果为 [begin, end),用数学话术就是左闭右开区间,即含 begin,但不包括 end

                        • beginend 的值可为 负数0正数。其中 0 和正数不多说,若为负数,从数组末尾开始确定索引值,其中 -1 为数组最后一个元素,以此类推。若负数小于 -length,则索引值为 0(可在内心转换为正向索引值)。

                        • begin ≥ arr.length(超出数组范围)或 begin ≥ end(两者翻译成正向索引后再比较),不会报错,会返回空数组([])。

                        • 若参数 beginend 不为数值,会自动隐式类型转换为 Number 类型,再截取。

                        • Array.prototype.slice() 可用来将一个类数组(array-like)转换为新数组。

                        示例

                        const arr = [1, 2, 3, 4, 5]
                        const arr2 = [1, 2, { num: 3 }, 4, 5]
                        
                        // 1️⃣ 当 begin 和 end 同时缺省时,会“浅拷贝”一个完整的数组
                        //    1)适合拷贝一个完整无引用值的数组
                        //    2)将类数组转换为数组,如 arguments -> array
                        arr !== arr.slice() // true,浅拷贝效果
                        function foo() {
                          // 类数组不具有数组的任何方法,转换后 arr 就变成了真正的数组
                          // Tips: 但现在用得更多的可能是 rest 参数
                          const arr = Array.prototype.slice.call(arguments) // 或 [].slice.call(arguments)
                        }
                        
                        
                        // 2️⃣ 返回数组的一部分
                        arr.slice(4) // [5]
                        arr.slice(1, 3) // [2, 3]
                        arr.slice(-2, -1) // [4]
                        
                        
                        // 3️⃣ 返回空数组
                        arr.slice(1, 1) // [],属于 begin = end,但不包含 end
                        arr.slice(3, -4) // [],属于 begin ≥ end 的情况
                        arr.slice(5) // [],属于超出数组范围的情况
                        
                        
                        // 4️⃣ 浅拷贝引用值的问题,其实非常简单,顺便提一下而已
                        const newArr = arr2.slice(2) // [{ num: 3 }, 4, 5]
                        newArr[0].num = 6 // 修改新数组的引用值
                        arr2[2] // { num: 6 },会影响原数组
                        
                        
                        // 5️⃣ 若参数不为数值,将会发生隐式类型转换,再截取
                        arr.slice(true) // [2, 3, 4, 5],true 会转换为 Number 类型为 1
                        arr.slice(false) // [1, 2, 3, 4, 5],同理
                        arr.slice('str') // [1, 2, 3, 4, 5],字符串 'str' 会被转换为 NaN,效果同 arr.slice(NaN)
                        
                        
                        // 6️⃣ 若参数为小数,应该是采用 parseInt() 取整数部分
                        arr.slice(1, 3.6) // [2, 3]
                        arr.slice(-2.3) // [4, 5]
                        

                        其他

                        字符串也有一个类似的方法:String.prototype.slice(),它用来提取字符串的某一部分,并返回一个新的字符串,且不会改变原字符串。

                        语法:

                        // 从原字符串中,截取 [beginIndex, endIndex) 的字符串
                        string.slice(beginIndex, endIndex)
                        

                        应用场景:

                        const str = 'string'
                        // 1️⃣ 截取字符串
                        str.slice(1, 3) // "tr"
                        
                        // 2️⃣ 相信你也见过以下用法,常用来生成随机字符串
                        const randomKey = Math.random().toString(36).slice(2)
                        

                        Array.prototype.splice

                        讲真的,它跟 slice() 长得像不说,还容易混淆,隔一段时间不用,都得翻一下文档确认一下。

                        slice() 不同,splice() 会改变原数组。

                        语法

                        array.splice([start[, deleteCount[, item1[, item2[, ...]]]]])
                        

                        所有参数都是可选的。从 MDN 上的表述看,参数 start 应该不能缺省的,但实际并不会报错,因此可认为是全可选的。splice() 返回被删除的元素所组成的一个新数组,若没有删除,则返回空数组([]

                        • start - 开始位置(从 0 计数)

                          • start ≥ length,即超出数组长度,则从数组末尾添加内容;
                          • start < 0,即为负数,则从数组末尾开始的第几位开始,负数小于 -length 时,从 0 开始。计数方式与 slice() 相同。
                        • deleteCount - 整数,表示要移除的数组元素的个数。

                          • 若缺省 deleteCount 或者 deleteCount 大于 start 之后所有元素数量,则删除 start 后面的所有元素,含 start
                          • deleteCount ≤ 0,此时不移除元素。这种情况下,常用来插入新元素。
                        • item1, item2, ... - 要添加进数组的元素,从 start 位置开始。 若不指定,则 splice() 将只删除数组元素。

                        总结

                        • splice() 会改变原数组,并返回一个被删除元素组成的数组。

                        • splice() 参数 start 接受负数、0、正数,计算起始位置与 slice() 方法一致。

                        • deleteCount 为正数时,splice(start, deleteCount) 所删除的元素包含 [start, start + deleteCount),这里的 start 是指转换后正向索引值。

                        • deleteCount 为负数或零,则不删除元素。常用于不删除原数组元素,并插入新元素的场景。

                        • 请注意,deleteCount 是指删除的个数,而非索引值。我想这也就是 slice()splice() 容易混淆的原因所在。

                        • splice() 中的 startdelectCount 也是会发生隐式类型转换的。

                        示例

                        const arr = [1, 2, 3, 4, 5]
                        
                        // 1️⃣ 允许缺省所有参数,但没有真正的意义
                        arr.splice() // [],原数组也没有改变
                        
                        
                        // 2️⃣ 删除,并插入新元素
                        arr.splice(1, 2, 6) // [2, 3],此时 arr 被修改为 [1, 6, 4, 5]
                        arr.splice(-2, 3, 6) // [4, 5],此时 arr 被修改为 [1, 2, 3, 6]
                        
                        
                        // 3️⃣ 删除 start 后面所有元素
                        arr.splice(1) // [2, 3, 4, 5],此时 arr 被修改为 [1]
                        arr.splice(-100) // [1, 2, 3, 4, 5],此时 arr 被修改为 []
                        
                        // 4️⃣ 不删除原数组元素,并插入一个或多个新元素
                        //    这个应该就是 splice() 用得最多的场景吧!
                        arr.splice(1, 0, 6) // [],此时 arr 被修改为 [1, 6, 2, 3, 4, 5]
                        arr.splice(1, 0, 6, true) // [],此时 arr 被修改为 [1, 6, true, 2, 3, 4, 5]
                        
                        // 5️⃣ 隐式类型转换
                        arr.splice(true, true) // [2],相当于 arr.splice(1, 1)
                        
                        // ⚠️ 请注意,以上每条语句是基于 arr 为 [1, 2, 3, 4, 5] 得出的结果,
                        // 并不是按顺序执行得出的结果,就怕有人误解。因为 splice() 方法是会修改原数组的。
                        

                        String.prototype.split

                        顺道提一下,其实 split() 这个就很简单了,常用于字符串转为数组、解析 URL 参数等场景。

                        在字符串与数字切换,常用到 String.prototype.split()Array.prototype.join()Array.prototype.reverse() 方法。

                        语法

                        str.split([separator[, limit]])
                        

                        参数 separatorlimit 都是可选的。若缺省 separator 时,返回的数组包含一个由整个字符串组成的元素。而 limit 的作用是返回分割片段的数量。

                        const str = 'hello'
                        
                        // 字符串分割
                        str.split() // ["hello"]
                        str.split('') // ["h", "e", "l", "l", "o"]
                        str.split('', 2) // ["h", "e"]
                        
                        // 字符串与数字转换
                        str.split('').join(',') // "h,e,l,l,o"
                        
                        // 也常用来反转字符串
                        str.split('').reverse().join('') // "olleh"
                        

                        separator 可以是字符串,也可以为正则表达式,它适合提取一些不太规则的字符串。

                        假设有以下两个字符串,我们要把月份提取出来,并返回数组:

                        const str1 = 'Jan; March; April; June'
                        const str2 = 'Jan ;March ; April; June'
                        
                        // 对于 str1 是相对比较规律的,我们可以直接
                        str1.split('; ') // ["Jan", "March", "April", "June"]
                        
                        // 而 str2 就不能通过上述方式去提取了,可以使用正则表达式作为 separator 参数
                        const re = /\s*;\s*/g
                        str2.split(re) // ["Jan", "March", "April", "June"]
                        

                        结论

                        本文,主要是讲解 slice()splice() 方法及其区别。好吧,面试官也喜欢问这俩货。

                        1. 从参数上区分:slice(begin, end)splice(start, deleteCount, ...item) 两个方法:

                          • slice()beginend 都指原数组对应的索引值。

                          • splice() 中只有 start 是指原数组对应的索引值,deleteCount 是要删除的数量。如果要转化为 [begin, end) 的形式,先将 start 转为正向索引值(如 -1 转为原始值最后一项的索引值),然后要删除的区间自然就是 [start, start + deleteCount)

                          • slice() 缺省 endsplice() 缺省 deleteCount,它们都是会截取或删除 start 之后的所有元素,且包含 beginstart

                        2. 从是否改变原数组来区分:slice() 不会改变原数组,而 splice() 会改变原数组。从这点上看,它们适合应用以下场景:

                          • slice() 适合用于浅拷贝数组,在不改变原数组的前提下,拷贝原数组的一部分或整个数组。
                          • splice() 适合在数组中插入新元素。
                        3. 是否截取(或删除)起始项、终止项的问题,换个角度起始很容易区分。

                          在实际场景中,起始项或终止项设为 负数,也是很常见的。我们只要在内心将其翻译为“正向”的索引值即可,比如 -1 表示数组最后一个元素,那它的索引值就是 length - 1,以此类推。

                          负数 值超出 -length正数 值超出 length 时,要么从 0 开始或 数值末尾 开始。

                          • slice() 会截取 [begin, end) 区间的元素。
                          • splice() 会删除 [start, start + deleteCount) 区间的元素。

                          从数学的区间角度去看,这个就很容易区分了。

                        参考链接

                        ]]>
                        <![CDATA[Node 节点常用 API 详解]]> https://github.com/tofrankie/blog/issues/271 https://github.com/tofrankie/blog/issues/271 Sun, 26 Feb 2023 12:34:30 GMT 配图源自 Freepik

                        Node 与 Element 的关系

                        Node<]]> 配图源自 Freepik

                        Node 与 Element 的关系

                        Node 是一个接口(基类),本身继承自 EventTargent 接口,有许多接口都从 Node 继承方法和属性:DocumentElementAttrCharacterData(which TextComment and CDATASection inherit)ProcessingInstructionDocumentFragmentDocumentTypeNotationEntityEntityReference

                        Element 继承于 Node,具有 Node 的方法,同时又拓展了很多自己的特有方法。

                        比如以下这些方法,都明显区分了 NodeElement

                        Node Element
                        childNodes children
                        parentNodeparentElement
                        nextSibling nextElementSibling
                        previousSibling previousElementSibling
                        ... ...

                        我们常说的「DOM 节点」就是指 Node,而「DOM 元素」是指 Element。DOM 节点包括了 ElementDocumentCommentText 等。

                        它们都有一个特定的节点类型(nodeType)来表示:

                        常量 描述
                        Node.ELEMENT_NODE 1 一个元素节点,例如 <p> 和 <div>
                        Node.TEXT_NODE 3 Element 或者 Attr 中实际的  文字
                        Node.COMMENT_NODE 8 一个 Comment 节点。
                        Node.DOCUMENT_NODE 9 一个 Document 节点。
                        Node.DOCUMENT_TYPE_NODE 10 描述文档类型的 DocumentType 节点。例如 <!DOCTYPE html>  就是用于 HTML5 的。
                        Node.DOCUMENT_FRAGMENT_NODE 11 一个 DocumentFragment 节点

                        部分不常用或者已废弃的,此处未列举,详见 Node.nodeType

                        <div id="root">Root</div>
                        
                        const element = document.getElementById('root')
                        
                        element.nodeType // 1,一个 Element 节点
                        element.children // HTMLCollection []
                        element.childNodes // NodeList [text]
                        element.childNodes[0].nodeType // 3,一个 Text 节点
                        element.parentElement // Element body
                        element.parentNode // Element body
                        

                        总结:

                        • Node 节点有很多种,比如 ElementDocumentTextComment 等,而 Element 节点只是其中一种而已。
                        • ElementNode 的派生类,因此 Element 节点可以访问 Node 的属性和方法,反之不可。
                        • Element 节点常被称为“元素”。
                        • 注意,常见的 document 对象是 与 Element 不是同一类型的节点,因此称为 Document 节点比较合适。

                        Node 常见属性

                        属性 读写 描述
                        Node.nodeType 只读 返回节点类型,如上表。
                        Node.nodeName 只读 返回节点名字的 DOMString。若是 Element 节点跟它管理的标签对应,如 'DIV''SPAN'(一般都是大写的)。若是 Text 节点对应的是 '#text'。若是 Document 节点对应是 '#document'。(若是 Element 节点,有个类似的 Element.tagName 属性返回)
                        Node.nodeValue 可读写 返回或设置当前节点的值。一般用于 TextComment 节点的文本内容。而像 ElementDocument 节点其返回值为 null。若 nodeValue 的值为 null,则对它赋值也不会有任何效果。
                        Node.parentNode 只读 返回一个当前节点 Node的父节点 。如果没有这样的节点,比如说像这个节点是树结构的顶端或者没有插入一棵树中, 这个属性返回 null
                        Node.childNodes 只读 返回一个包含了该节点所有子节点的实时的NodeListNodeList 是动态变化的。如果该节点的子节点发生了变化,NodeList对象就会自动更新。

                        添加节点

                        一般情况,往 DOM 中添加节点,会使用 Node.appendChild() 方法和 Element.append() 方法。它们的作用都是:将节点附加到指定父节点的子节点列表的末尾处

                        但有些差异,如下:

                        • Element.append() 方法接受 Node 对象和 DOMString 对象,而 Node.appendChild() 只接受 Node 对象。

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

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

                        Element.append() 接受字符串作为参数,其实是将字符串转化为 Text 节点再附加。

                        举个例子:

                        <div id="root">Root</div>
                        
                        const root = document.getElementById('root')
                        
                        const p = document.createElement('p') // 创建 p 元素
                        p.append('paragraph') // 往 p 元素添加 Text 节点
                        root.appendChild(p) // appendChild 每次只能添加一个
                        root.append('123', 'abc') // append 可以每次可添加多个
                        

                        这时 DOM 变成了:

                        <div id="root">
                          "Root"
                          <p>paragraph</p>
                          "123"
                          "abc"
                        </div>
                        

                        注意点

                        1. Element.append() 参数若是非字符串的原始值,会先转换为字符串的。
                        root.append(true) // 成功添加一个值为 "true" 的文本节点
                        root.append(Symbol('desc')) // 将会抛出 TypeError,因为 Symbol 值无法转换为字符串
                        

                        需要注意的是,Symbol 原始值不能转换为字符串,只能将其转换成对应的包装对象,再调用 Symbol.prototype.toString() 方法。

                        1. 如果将被插入的节点已经存在于 DOM 中,那么 appendChild()append() 只会将它从原先的位置移动到新的位置。
                        <div id="modal">Modal</div>
                        <div id="root">Root</div>
                        
                        const root = document.getElementById('root')
                        const modal = document.getElementById('modal')
                        
                        root.appendChild(modal) // 会发生什么呢?
                        

                        DOM 将会变成这样:

                        <div id="root">
                          "Root"
                          <div id="modal">Modal</div>
                        </div>
                        

                        注意,这种情况下原有节点的事件监听器也会随之移动。

                        1. Node.appendChild() 传入多个 Node 节点不会报错,但仅第一个有效。
                        const root = document.getElementById('root')
                        const span1 = document.createElement('span')
                        span1.append('span1')
                        const span2 = document.createElement('span')
                        span2.append('span2')
                        
                        root.appendChild(span1, span2) // 仅 span1 节点添加成功
                        root.appendChild('string') // 但是这样会报错,TypeError: Failed to execute 'appendChild' on 'Node': parameter 1 is not of type 'Node'.
                        

                        移除节点

                        移除节点,对应的方法是 Node.removeChild()Element.remove()

                        • Node.removeChild() 方法是从 DOM 中移除一个子节点,并返回删除的节点。该方法接受一个 Node 节点。
                        • Element.remove() 是移除一个节点,无返回值。

                        由于 Node.removeChild() 会返回被移除的子节点,所以该子节点仍存在于内存中,因此你可以把这个节点重新添加回 DOM 中。若没有对被移除节点保持引用,则认为它已经没有用了,短时间内将会被垃圾回收。

                        举个例子:

                        <div id="root">
                          <p id="p"> p 元素节点</p>
                          <span id="span">span 元素节点</span>
                          文本节点
                        </div>
                        
                        const root = document.getElementById('root')
                        const p = document.getElementById('p')
                        const span = document.getElementById('span')
                        
                        p.remove() // <p> 节点从 DOM 中删除
                        root.removeChild(span) // <div> 的子节点 <span> 被移除,不存在引用,因此很快 span 对象将会被回收掉。
                        
                        // ️ 此时 root.childNodes 可不只有一个 Node 子节点,前面 HTML 的写法,会产生一些空白符的文本节点。
                        root.childNodes // NodeList(3) [text, text, text],而且最后一个才是 "\n    文本节点\n  " 对应的文本节点
                        

                        替换节点

                        Node.replaceChild() 方法用指定的节点替换当前节点的一个子节点,并返回被替换掉的节点。语法如下:

                        parentNode.replaceChild(newChild, oldChild)
                        

                        请注意,第二个参数 oldChild 必须是 parentNode 节点下的子节点,否则会抛出异常:DOMException: Failed to execute 'replaceChild' on 'Node': The node to be replaced is not a child of this node.

                        举个例子:

                        <div id="modal">Modal</div>
                        <div id="root">Root</div>
                        
                        const root = document.getElementById('root')
                        const modal = document.getElementById('modal')
                        
                        root.replaceChild(modal, root.childNodes[0]) // 将 modal 节点替换了 root 节点的第一个子节点(当然本示例中也只有一个子节点)
                        

                        因此,DOM 变成了:

                        <div id="root">
                          <div id="modal">Modal</div>
                        </div>
                        

                        插入节点

                        插入节点,这里使用的时 Node.insertBefore() 方法。语法如下:

                        var insertedNode = parentNode.insertBefore(newNode, referenceNode)
                        

                        请注意,若第一个参数 newNode 是 DOM 中某个节点的引用,使用 Node.insertBefore() 会将其从原来的位置移动到新位置。这点也 Node.appendChild() 一致。

                        举个例子:

                        <div id="root">Root</div>
                        
                        const root = document.getElementById('root')
                        
                        // 新节点
                        const p = document.createElement('p')
                        p.append('paragraph')
                        
                        // 将新节点插入到 root 第一个子节点的前面
                        root.insertBefore(p, root.childNodes[0])
                        

                        因此,DOM 变成了:

                        <div id="root">
                          <p>paragraph</p>
                          "Root"
                        </div>
                        

                        注意两种情况:

                        root.insertBefore(p) // 不会报错,也不会往 root 内插入任何节点
                        root.insertBefore(p, null) // 当引用节点为 null,p 将会被插入到 root 子节点列表末尾(类似 appendChild 的作用)
                        

                        还记得以前项目里面,动态加载脚本,就是使用 insertBefore 插入到 DOM 中的。

                        const script = document.createElement('script')
                        script.src = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'
                        script.onload = () => { /* do something, such as event listener */ }
                        script.onerror = err => { /* handle error */ }
                        
                        const s0 = document.getElementsByTagName('script')[0]
                        if (s0 && s0.parentNode) {
                          s0.parentNode.insertBefore(script, s0)
                        }
                        

                        比如,动态加载微信 JSSDK,然后在脚本加载完成调用 wx.config({ ... }) 接口注入权限验证配置。

                        The end.

                        发现一篇文章:JavaScript 操作 DOM 常用的 API,里面非常全面,可移步前往。

                        ]]>
                        <![CDATA[细读 ES6 | let 真的不会提升吗?]]> https://github.com/tofrankie/blog/issues/270 https://github.com/tofrankie/blog/issues/270 Sun, 26 Feb 2023 12:33:37 GMT 配图源自 Freepik

                        本将会从 ES5 中一些怪诞的行为出发,然后再到 ES6 中的 l]]> 配图源自 Freepik

                        本将会从 ES5 中一些怪诞的行为出发,然后再到 ES6 中的 letconst 是否会「提升」的讨论。

                        前菜

                        先上个前菜,如下:

                        {
                          var a = 2
                          function b() { }
                          let c = 1
                        }
                        console.log(a) // 2
                        console.log(b) // ƒ b()
                        console.log(c) // ReferenceError: c is not defined
                        

                        上述示例,相信这个谁都懂。再看下面这个示例:

                        console.log(foo)
                        {
                          function foo() { }
                          console.log(foo)
                        }
                        console.log(foo)
                        // 这三个 foo 将会打印什么呢?
                        

                        请不要犹疑,快速给出脑海中“第一感觉”的答案。

                        我想很多人的答案都是打印出三个 ƒ foo(),对吧。讲实话,在写下文章之前,我的答案也是这个。因为使用 varfunction 关键字的声明语句会提升啊,因而有此答案...

                        先不论答案对与错,我们看看各大浏览器的结果是什么:

                        1. Safari 14 依次打印出(JavaScriptCore 引擎)
                          ƒ foo()
                          ƒ foo()
                          ƒ foo()
                        
                        2. Chrome 92、Edge 92 (Chromium)、Node 14.16.0 依次打印出(V8 引擎)
                          undefined
                          ƒ foo()
                          ƒ foo()
                        
                        3. Firefox 92 依次打印出(SpiderMonkey 引擎)
                          undefined
                          ƒ foo()
                          ƒ foo()
                        

                        从结果看,主要区别在于第一个 foo 打印的是 undefinedƒ foo()。可能 Safari 浏览器的结果更符合多数人的认知。

                        为什么会有这样的结果,留个悬念,原因下面会介绍...

                        ES5 的提升

                        先明确一点:

                        ES5(或更早)规定,函数只能在顶层作用域或函数作用域顶层声明,不能在代码块中声明。

                        原来,上面示例在 ES5 规范中是不合法的。但由于浏览器厂商都支持这个不合法的语法,只不过各 JS 引擎的实现细节上存在差异,因此才出现了前面的差异。

                        就比如 __proto__ 从来就不是 ECMAScript 规范的一部分,但所有浏览器都支持,庆幸的是 __proto__ 在各引擎表现是一致的。之前文章《关于 Await、Promise 执行顺序差异问题》提到的情况,也是 JS 引擎实现差异所致。

                        请在不要在块级作用域下声明函数,可用函数表达式替代。

                        function foo() { } // 合法
                        
                        {
                          function bar() { } // 不合法,且不被推荐
                          var fn = function () { } // 合法 & 合理
                        }
                        
                        function baz() {
                          function fn() { } // 合法
                        }
                        

                        在 ESLint 中规则 no-inner-declarations 就是专门检查这种情况的,若启用函数声明处会发出警告:Move function declaration to program root.

                        在 ES5 严格模式下,对函数声明的某些行为做了限制。

                        1. 在早些版本中,在严格模式下含函数声明语句,会直接抛出 SyntaxError 。而在当前版本(如 Chrome 92)是不会抛出语法错误的。
                        'use strict'
                        {
                          function fn() { } // SyntaxError: in strict mode code, functions may be declared only at top level or immediately within another function
                          console.log(fn)
                        }
                        
                        // ⚠️ 而当前最新版本浏览器中,以上代码是不会抛出错误的。
                        
                        1. 在代码块内函数声明,不能在代码块外部使用,否则会抛出 ReferenceError。原因是在严格模式下,fn 被提升至代码块的顶层,而不是全局作用域顶层。这点各浏览器表现是一致的。
                        'use strict'
                        console.log(fn) // ReferenceError: fn is not defined
                        {
                          function fn() { }
                        }
                        

                        JavaScript 严格模式详解

                        回到前面的示例(非严格模式下):

                        console.log(foo)
                        {
                          function foo() { }
                          console.log(foo)
                        }
                        console.log(foo)
                        

                        为什么行为那么怪,我们打个断点吧(以 Firefox 为例,由于 Chrome 那个 window 对象展开太多属性了,截图太影响篇幅了):

                        看到没有,代码块中 foo 的变量是有提升至全局作用域顶层的,可......初始值是 undefined 而不是 ƒ foo(),Chrome 是一样的。

                        当代码往下执行到 function foo() { } 会更新 window.foo,因而结果就是 undefinedƒ foo()ƒ foo()

                        而 Safari 中,一开始 function foo() {} 提升至全局顶层时就是一个函数,所以与 Chrome、Firefox 结果不同。

                        不妨用 Babel 转换一下。

                        留两个示例,你们可以去看看都打印些什么,是否跟你们预期中的一致。尤其是第二个示例。

                        if (true) {
                          function foo() { console.log(1) }
                        } else {
                          function foo() { console.log(2) }
                        }
                        foo()
                        
                        var a = 0
                        if (true) {
                          console.log(a)
                          a = 1
                          function a() { }
                          a = 21
                          console.log(a)
                        }
                        console.log(a)
                        

                        若第二个示例看不懂,请看分析

                        ES6 会提升?

                        我们知道 ES6 中引入了块级作用域,自此 JavaScript 就拥有了全局作用域、函数作用域和块级作用域。

                        只要通过 letconstclass 关键字声明的变量或类,都具有块级作用域。而且使用之前必须先声明,否则会抛出 ReferenceError,这个错误与“暂时性死区”(Temporal Dead Zone,TDZ)有关。

                        let foo = true
                        
                        if (true) { // enter new scope, TDZ starts
                          // Uninitialized binding for `foo` is created
                          console.log(foo) // ReferenceError
                        
                          let foo // TDZ ends, `foo` is initialized with `undefined`
                          console.log(foo) // undefined
                        
                          foo = 1
                          console.log(foo) // 1
                        }
                        
                        console.log(foo) // true
                        

                        即在 TDZ starts 与 TDZ ends 的时间跨度,称为“暂时性死区”。这种机制也使得 typeof 变得不再安全,在此区间内引用变量会抛出 ReferenceError。关于更多 TDZ,请看:

                        let/const 会提升吗?

                        其实,民间对于 let 等是否提升的问题,分为两派:

                        • 一派认为 let 没有提升行为
                        • 另一派则认为 let 还是有提升行为的

                        无论提升与否,但我认为在实际编写代码中,大家对 let/const 的使用是毫无疑问的。因为大家对“使用前先声明”的认知是统一的。也相信很多人早就开始用 let/const 全面代替 var 了。

                        也建议在项目 ESLint 中启用 no-var 规则。

                        无论 let/const 提升与否,几乎不会影响大家在项目中的使用,而且不会造成混乱,它们比 var/function 的“提升”行为更容易区分。

                        什么是提升?

                        关于“提升”行为是什么,我就不多说了,大家都知道。

                        但我想说,在 ECMAScript 规范中,尽管文档中不乏类似 hoisting 的单词,但就是没有对 “Hoisting” 一词作专门定义。

                        但在前端社区中,Hoisting 的说法确实很多。我想可能是因为,ECMAScript 就 varfunction 声明语句将会前置到所在作用域顶层的行为或现象,使用了 hoistinghoisted 等词去描述,然后在坊间互传时,在语言表述或认知理解上总会存在偏差,久而久之形成了 Hoisting 的说法。

                        综上:Hoisting 不是一个 ECMAScript 规范的术语,它只是描述了一种行为或现象。

                        坊间对提升的理解

                        举个例子,在我们眼里它只是一个再简单不过的声明语句而已。

                        var a = 1
                        

                        那么 JS 引擎是怎么理解的呢。就这条简单的语句,大概会经历这些步骤:

                        编译阶段
                          词法分析
                              拆分成一个个有意义的 token
                        
                          语法分析
                              检查能否构成合法的语句,如无语法错误,将 tokens 形成 AST
                        
                          代码生成
                              生成 JS 引擎看得懂的代码
                        
                        执行阶段
                          创建全局上下文
                              在此之前 JS 引擎还会创建一个执行上下文栈去管理各种上下文。(非重点不展开)
                        
                          进入全局上下文
                              主要是执行上下文初始化的一些工作:
                                  JS 引擎识别到`var a` 知道这是一个声明操作。
                                  首先以 a 作为标识符,在内存中创建一个空间(将用于存实际的值)。
                                  然后根据 ECMAScript 实现要求,
                                  这个 a 将会作为执行上下文(可理解为一个 JS 对象)
                                  中变量对象(VO)的一个属性名,并默认存值为 undefined。
                                  就是说,在前面分配的内存空间中存入 undefined 值。
                                  初始化还有其他一些工作,如确定作用域链等。
                        
                          执行全局上下文
                              前面初始化工作完成之后,接下来就是,
                              按顺序逐条执行代码(这里说的顺序,不一样与源代码编写顺序一致,你懂的)
                              当执行到 `var a = 1` 这行的时候,JS 引擎眼里其实是 `a = 1` 赋值操作。
                              于是根据标识符 a 找到它在内存中的位置,并将真实值 1 存入到该空间下,
                              以覆盖原先的 undefined 值。
                              还有一些如当前执行上下文 this 指向也是在这过程确定的。
                          
                              当代码都执行完之后,执行栈就会空闲下来,摸会鱼。等待后面有执行任务再进入工作状态。
                        

                        为什么又重新提一遍这个过程,原因是:

                        • 一派人,将分配内存空间的过程,称为提升。
                        • 另一派人,将分配内存后会默认存值的过程,称为提升。

                        根据社区上的普遍说法,letvar 的区别在于分配内存空间后是否默认存值,若使用 let 不会默认存一个 undefined 值。但我对这句话是有所保留的。

                        原因如下:

                        在 ECMAScript 规范中关于 #14.3.1 Let and Const Declarations 有一段话是这么说的(原文略长,这里分成两段):

                        let and const declarations define variables that are scoped to the running execution context's LexicalEnvironment. The variables are created when their containing Environment Record is instantiated but may not be accessed in any way until the variable's LexicalBinding is evaluated.

                        A variable defined by a LexicalBinding with an Initializer is assigned the value of its Initializer's AssignmentExpression when the LexicalBinding is evaluated, not when the variable is created. If a LexicalBinding in a let declaration does not have an Initializer the variable is assigned the value undefined when the LexicalBinding is evaluated.

                        let a = 1 为例,简单说明一下:

                        第一部分大致意思:通过 let/const 声明的变量,它会记录当前词法环境信息,且在 LexicalBinding 之前,不能以访问任何方式访问。LexicalBinding 是规则描述中的一个抽象操作,用 JS 代码比喻就是 let a = 1 中赋值操作。

                        第二部分,其实就是当执行代码 let a = 1 时,将赋值操作符右边的表达式的值 1 绑定到变量 a 上。若是 let a 这种形式的,那么将 undefined 值(是一个二进制的真实值)绑定到变量 a 上。这就是前一部分提到的 LexicalBinding 过程。

                        这段话里,由始至终没有提及执行上下文初始化时,要不要为变量默认存一个 undefined 的值。这就是我说有所保留的原因。

                        JS 引擎眼中的 TDZ

                        很多人都知道 TDZ 是什么回事。

                        但我还是那句话:TDZ 是 ECMAScript 规范中的一个术语吗?

                        按关键词通篇搜索 ECMAScript 文档可知,TDZ 并不是 ECMAScript 规范的术语。据说 TDZ 的说法最早出现在 ES Discussion 的讨论帖。因而,我认为 TDZ 跟 Hoisting 一样,它只是描述了一种行为或现象。这种现象就是:

                        通过 letconstclass 关键字声明的变量或类,它不能在声明之前调用,否则会抛出 ReferenceError

                        前面提到 LexicalBinding 之前不允许访问,那么 JS 引擎总要告诉我们不能访问吧,于是就抛出一个 ReferenceError 来提醒我们:小伙子你不能这么使用。

                        从浏览器的角度看是否会提升?

                        先看个示例:

                        console.log(a) // ReferenceError: a is not defined
                        let a = 1
                        

                        上面这两行代码,问谁都知道将会抛出 ReferenceError。但我发现这个报错的 Message 是不同的:

                        • 某旧版 Chrome:ReferenceError: Cannot access 'a' before initialization
                        • 最新 Chrome 92:ReferenceError: a is not defined
                        • 最新 Safari 14: ReferenceError: Cannot access 'a' before initialization

                        自从 ES6 发布 letconst 相关标准后,后续这块内容应该没有调整过了。从提示信息来看,我偏向认为 JS 引擎在实现时还是会存在“提升”行为的。

                        再看另一示例:

                        function foo() {
                          console.log('')
                          let a = 1
                        }
                        foo()
                        

                        我们分别从 Chrome、Firefox、Safari 中观察以上示例:

                        ▼ Chrome 92

                        ▼ Firefox 92

                        ▼ Safari 14

                        我在 let a = 1 前一行语句添加了断点,从结果上看不完全相同。Chrome、Safari 中 a 的值是 undefined,而 FireFox 中 auninitialized。但是它们都在 let a = 1 之前就存在了变量 a,即使在下一行中都将会报错。

                        也再次佐证了 let/const/class 声明语句是会产生提升行为。

                        尽管如此,还是必须在使用之前进行定义,否则会报错。

                        若提升,它被提升到哪?

                        请看示例

                        if (true) {
                          let a = 1
                          console.log(a) // 1
                        }
                        console.log(a) // ReferenceError: a is not defined
                        

                        尽管会提升,它只会提升至当前代码块的顶层,所以在 if 语句外部无法访问 a 变量,因而报错。

                        总结

                        就本文内容,总结一下:

                        跟 let/const 无关,跟是否严格模式相关。

                        鉴于各浏览器实现差异,无论是否为严格模式,请不要在块级作用域中声明函数。

                        let/const 会提升吗?

                        • 从浏览器角度看,let/const/class 确实是存在提升行为的。
                        • 从使用角度看,不需要关心 let/const/class 是否会提升,它必须在使用前进行定义。
                        • 从面试官角度,我认为他想听到的答案是:let/const/class 是会提升的”。若你简单从 ECMAScript 规范、浏览器调试结果去回答,这题就基本能拿满分。
                        • 如果本文所说的“提升”会让你感到困惑的话,请坚定不移地相信 let/const/class 是不存在提升行为的。

                        还是那句话,不必过分关注 let/const/class 等声明的变量或类是否存在“提升”现象。但如果作为基础拓展,还是有必要了解一下的...

                        建议

                        在遇到一些不太理解的 ES6 语法的时候,不妨使用 Babel 转换一下,看看它们是怎么实现的,说不定会有灵光一现的感觉。

                        比如开头的示例 Babel 转换之后,就变成下面这样,然后结合自己的理解,想想为什么它要这么做。

                        "use strict";
                        console.log(foo);
                        {
                          var _foo = function _foo() {};
                          console.log(_foo);
                        }
                        console.log(foo);
                        

                        这一招是我看某篇文章的时候学到的...

                        The end.

                        ]]>
                        <![CDATA[Arguments 对象与简易柯里化]]> https://github.com/tofrankie/blog/issues/269 https://github.com/tofrankie/blog/issues/269 Sun, 26 Feb 2023 12:31:58 GMT 配图源自 Freepik

                        简述

                        arguments 是函数实]]> 配图源自 Freepik

                        简述

                        arguments 是函数实参对象,常被称为“类数组”(array-like)。形如:

                        arguments = {
                          0: xx,
                          1: xx,
                          ...,
                          n - 1: xx,
                          length: n // n 取决于实参的数量
                        }
                        

                        arguments 的一些特性:

                        function foo() {
                          // 1. arguments 不是数组,自然也不具备数组 forEach 等方法
                          Object.prototype.toString.call(arguments) // "[object Arguments]"
                          arguments instanceof Array // false
                        
                          // 2. arguments 对象具有数字索引属性
                          arguments[0] // "a"
                          arguments[1] // "b"
                          
                          // 3. arguments 对象有 length 属性,反映实参个数。
                          //    但与函数的 length 属性不同,foo.length 反映形参个数。可在 ES6 之后由于参数默认值、REST 参数等新特性,使 foo.length 变得不可靠
                          arguments.length // 2
                          foo.length // 0
                        }
                        
                        foo('a', 'b')
                        

                        类数组对象的特征:含有 length 属性、索引元素属性,但不包含数组任何方法。

                        常见的类数组,除了 arguments 之外,还有 HTMLCollection(通过 getElementsByName() 等返回的 DOM 列表)、NodeList(通过 querySelectorAll() 返回的节点列表)。

                        arguments 使用

                        arguments 通常用于不能确定实参个数的应用场景,例如函数柯里化等。

                        它还经常被转换为数组使用。

                        // ES5
                        function foo() {
                          // 方法一:会阻止某些 JS 引擎的优化,如 V8
                          // 请看:https://github.com/petkaantonov/bluebird/wiki/Optimization-killers#3-managing-arguments
                          var args = Array.prototype.slice.call(arguments) 
                          // var args = [].slice.call(arguments)
                        
                          // 方法二(推荐,尽管丑了点)
                          var args = arguments.length === 1 ? [arguments[0]] : Array.apply(null, arguments)
                        }
                        
                        // ES6
                        function foo() {
                          // 利用 arguments 的 Iterator 接口,可以快速转化为数组
                          const args = [...arguments]
                          // const args = Array.from(arguments)
                        }
                        function foo(...args) {
                          // 直接使用 REST 参数,args 天生就是数组,可以直接使用 Array 的方法
                        }
                        

                        Function.prototype.apply()Array.prototype.slice() 等方法也可接受类数组,无需转换为数组再进行操作。举个例子:

                        // 求最大数
                        function getMax() {
                          return Math.max.apply(null, arguments)
                        }
                        
                        getMax(1, 2, 3) // 3
                        

                        都 2021 年了,更被推荐按 ES6 的写法。

                        注意点

                        关于 arguments 只能在所有(非箭头)函数内部可用的局部变量。

                        • 存在于所有(非箭头)函数内部。函数上下文的 AO 对象就包括 arguments 属性。

                        • 箭头函数不存在 arguments 对象。有时候箭头函数内可以使用 arguments 是“视觉”认知错误。

                        • ES6 的箭头函数中,要获取实参对象,可通过 REST 参数获得。

                        • 非严格模式下,arguments 允许重新赋值。而且形参的更新,也会伴随着 arguments 对象相应属性值的更新。

                        • 严格模式下,则不允许对 arguments 对象赋值,且不会最终参数的变化。也不允许使用 arguments.callee 方法(关于严格模式对 arguments 对象的限制,可看这篇文章)。

                        在函数外使用会抛出 ReferenceError。此时它就是一个标识符而已。

                        // 1. 相当于一个变量 arguments,因此会抛出引用错误
                        console.log(arguments) // ReferenceError: arguments is not defined
                        
                        // 2. 非严格模式下,可将其作为变量
                        let arguments = 1 // or arguments = 'any'
                        console.log(arguments) // 1
                        
                        // 3. 严格模式下,还是将其声明变量或对其进行赋值操作
                        'use strict'
                        arguments = 1 // Wrong, SyntaxError: 'arguments' can't be defined or assigned to in strict mode code
                        

                        在箭头函数内,没有 arguments 变量。例如以下这样使用同样会报错。

                        const foo = () => {
                          console.log(arguments) // ReferenceError: arguments is not defined
                        }
                        
                        foo()
                        

                        但这样用不会报错,这就是前面提到的“视觉认知”错误。

                        function foo() {
                          const bar = () => {
                            console.log(arguments) 
                            // 由于箭头函数 bar 没有 arguments,
                            // 这里引用的 arguments 对象其实是函数 foo 的实参对象。
                          }
                          bar('b')
                        }
                        
                        foo('a') // { 0: 'a', length: 1 }
                        

                        非严格模式与严格模式,对 arguments 对象的操作。

                        function foo(x) {
                          x = 10
                          console.log(x) // 10
                          console.log(arguments[0]) // 10
                        }
                        
                        function bar(x) {
                          'use strict'
                          x = 10
                          console.log(x) // 10
                          console.log(arguments[0]) // 1
                        
                          // 以下这样将会直接抛出语法错误:SyntaxError: Unexpected eval or arguments in strict mode
                          // arguments = {}
                        }
                        
                        foo(1)
                        bar(1)
                        

                        求和函数(柯里化)

                        假设我们有一个求和函数 sum(),要实现下面的需求:

                        sum(1, 2, 3) // 6
                        sum(1, 2)(3)(4) // 10
                        sum(1)(2, 3)(4, 5, 6) // 21
                        // ...
                        

                        其实上面的需求是有问题的,它没有出口,导致不知道什么时候求和。我们可以稍微改下需求:

                        sum(1, 2, 3).value() // 6
                        sum(1, 2)(3)(4).value() // 10
                        sum(1)(2, 3)(4, 5, 6).value() // 21
                        // ...
                        

                        就是说 sum() 函数的结束时机(出口)是调用 value() 方法的时候。

                        function sum(...args) {
                          const arr = [...args]
                          function repeat(...nextArgs) {
                            ;[].push.apply(arr, nextArgs)
                            return repeat
                          }
                          repeat.value = () => {
                            if (!arr.length) return 0
                            return arr.reduce((a, b) => a + b)
                          }
                          return repeat
                        }
                        

                        也可以改成调用 sum(1, 2)(3)(4)() 时进行求和,我们修改一下:

                        function sum(...args) {
                          if (!args.length) return 0
                          const arr = [...args]
                          function repeat(...nextArgs) {
                            if (!nextArgs.length) {
                              return arr.reduce((a, b) => a + b)
                            }
                            ;[].push.apply(arr, nextArgs)
                            return repeat
                          }
                          return repeat
                        }
                        
                        sum() // 0
                        sum(1, 2, 3)() // 6
                        sum(1, 2)(3)(4)() // 10
                        sum(1)(2, 3)(4, 5, 6)() // 21
                        

                        The end.

                        ]]>
                        <![CDATA[JavaScript 作用域链简述]]> https://github.com/tofrankie/blog/issues/268 https://github.com/tofrankie/blog/issues/268 Sun, 26 Feb 2023 12:29:33 GMT 配图源自 Freepik

                        前言

                        继上一篇文章

                        前言

                        继上一篇文章 JavaScript 脚本编译与执行过程简述,再来介绍一下 JavaScript 中的作用域链(Scope Chain)。

                        函数的 [[scope]] 也是与闭包直接相关。并推荐专题:

                        作用域链的形成

                        作用域链(以下简称 Scope)与执行上下文相关。

                        全局上下文:
                          其 Scope 就是 GlobalContext.VO,即 window 对象。
                        
                        函数上下文:
                          函数被调用时,函数上下文的 Scope 被创建,包括 AO 和这个函数内部的 [[scope]] 属性。
                        

                        因此,我们可以大致当前上下文的作用域链:Scope = AO + function.[[scope]]

                        函数内部 [[scope]] 属性的形成

                        当函数被创建的时候,属性 [[scope]] 会保存所有的父级变量对象。

                        举个例子:

                        function foo() {
                          // some statements...
                        }
                        

                        上述例子,函数 foo 处于全局上下文。而全局上下文中所声明的函数,它们的 [[scope]]GlobalContext.VO,即 window 对象。因此 foo.[[scope]] = [ GlobalContext.VO ]

                        再看:

                        function foo() {
                          function bar() {
                            // some statements...
                          }
                        }
                        

                        同样地,函数 foo[[scope]] 属性为 GlobalContext.VO。然后调用 foo 函数,进入 foo 函数上下文并进行初始化,包括以下过程:

                        1. 以当前函数 foo.[[scope]] 为基础初始化函数上下文的 Scope
                        2. 初始化上下文的 AO 对象,包括 Arguments、形参、函数声明、变量声明。 该过程若有函数声明,对应函数的 [[scope]] 也将会被确定,其值就是 Scope
                        3. AO 初始化完成后,将 AO 插入上下文的 Scope 中。

                        因此有两个结论:

                        • 当前上下文的作用域链 Scope = AO + foo.[[scope]]
                        • 当前上下文内定义的函数 bar,其 [[scope]] 属性值为上下文的 Scope,即 AO + foo.[[scope]]

                        注意,即便是函数表达式,它在代码执行的时候,才会确定其 [[scope]],由于执行过程中 AO 也会跟着更新,且它们是引用关系,因此总能确保,当前作用域内的函数(函数声明或函数表达式)的 [[scope]] 总是 AO + 各父级上下文的 AO/VO

                        但是使用 Function 构造器来创建一个新的函数,该函数的 [[scope]] 只有 GlobalContext.VO。下面的示例中,执行 bar 函数会去作用域链上查找 a 变量,可它的作用域链只含全局对象,导致找不到 a 变量而抛出 ReferenceError

                        function foo() {
                          var a = 1
                          var bar = new Function('console.log(a)')
                          bar() // ReferenceError: a is not defined
                        }
                        
                        foo()
                        

                        因此,尽量不要使用构造函数的方式来创建函数。

                        影响作用域链的一些例子

                        一般情况下,一个作用域链 Scope 包括父级变量对象、函数上下文的活动对象 AO,并从当前上下文逐级往上查询。

                        提醒一下:当我们从对象上查询某个属性,首先从对象自身属性上查找,当找不到的时候,才会往原型上查找......直至原型链的顶端 Object.prototype 再查询不到就返回 undefined

                        其实作用域链的原理跟原型链很类似,当前如果这个变量在自己的作用域中没有,那么它会往父级查找,直至最顶层(全局对象),再查找不到就会抛出 ReferenceError

                        前面讲过,当前上下文(作用域)内声明的变量或函数,是以属性的形式,放到一个变量对象(Variable Object)上的。但由于 VO 是无法通过代码访问的,因此在函数调用的时候 VO 被激活形成一个活动对象(Activation Object),它是可以被访问到的(可以简单的理解为 AOVO 浅拷贝的一个引用)。

                        但是,AO 是没有原型的。假设我们在当前作用域下查找一个变量 a,相当于从 AO 上查找 a 属性。假设 AO 本身没有该属性,自然会往 AO 原型上查找,但很遗憾 AO 没有原型,即当前作用域下查找不到该变量(或称为属性)。然后往作用域链的上一级 AO 中查找......查找规律同理......直到全局作用域(其 VO 就是 window 对象)下的 window 对象查找。由于 window 对象是有原型的,如果自身找不到 a 属性,就会往 window 的原型上查找,查到就返回,查不到就抛出 ReferenceError

                        说那么多,还不如看个例子更清晰:

                        Object.prototype.a = 'proto'
                        
                        function foo() {
                          console.log(a)
                        }
                        
                        foo() // "proto"
                        

                        从例子可以看出 foo 函数上下文下并没有声明 a 变量,于是往上一级查找(即全局上下文),那么从 window 自身查找,是没有的。但是 window 是基于 Object 创建的(window instanceof Object 结果为 true),于是从 Object.prototype 上查找,并找到 a 属性,属性值为 "proto"

                        如何证明 AO 是没有原型的?

                        Object.prototype.a = 'proto'
                        
                        function foo() {
                          var a = 'inner'
                          function bar() {
                            console.log(a)
                          }
                          bar()
                        }
                        
                        foo() // "inner"
                        

                        过程就不在赘述了,假设 AO 是有原型的,那么 bar 函数上下文中查找 a 变量是,应该会取到 AO 对象原型上的 a 属性 "proto",但实际情况 a 取到的结果是 "inner"。因此可以证明:活动对象 AO 是没有原型的。

                        全局和 eval 上下文中的作用域链

                        全局上下文的作用域链仅包含全局对象。而 eval 上下文与当前的调用上下文(calling context)拥有同样的作用域链。

                        GlobalContext.Scope = [ window ]
                         
                        EvalContext.Scope === CallingContext.Scope;
                        

                        代码执行时对作用域链的影响

                        有些情况下也会包含其他对象,例如执行期间,动态加入作用域链中的,例如 with 语句或者 catch 语句。此时作用域链如下:

                        Scope = (withObject | catchObject)  +  (AO | VO)  +  [[Scope]]
                        
                        withObject
                          表示 with 语句产生的临时作用域对象。如 with({ name }) 中的 { name } 对象;
                        catchObject
                          表示 catch 从句产生的异常对象。如 catch(e) 中的 e 对象。
                        

                        举个例子:

                        var foo = { x: 1, y: 2 }
                        
                        with (foo) {
                          console.log(x) // 1
                          console.log(y) // 2
                        }
                        

                        它的作用域链变成了:Scope = foo + (AO | VO) + [[Scope]]。上面这个例子可能没有体现出来,我们修改一下:

                        var x = 1, y = 2
                        var foo = { x: 2 }
                        
                        with (foo) {
                          var x = 3, y = 4
                          console.log(x) // 3
                          console.log(y) // 4
                        }
                        
                        console.log(x) // 1
                        console.log(y) // 4
                        console.log(foo) // { x: 3 }
                        

                        我们来分析一下:

                        1. 进入全局上下文的时候,会创建声明 xyfoo 变量。
                        2. 执行到 with 语句,会将 foo 对象添加至作用域链顶端。
                        3. with 内部的 xy 前面已被解析添加,因此它只是一个赋值语句,并不会重新赋值语句。
                        4. 关键在于 with 内部,给 x、y 赋值,究竟是对应哪个变量。前面提到遇到 with 语句会往作用域链顶端插入该对象 foo(注意不会创建一个全新的作用域上下文,只是修改了作用域链而已)。
                        5. 因此,当 console.log(x) 查找 x 变量时,从 foo 对象上查找 x 属性,并找到,因此 foo.x 被修改为 3
                        6. 接着,查找 y 变量,而 foo 对象上没有(其原型也没有),因此往上一级作用域查找(即全局作用域),因此全局作用域下的 y 被修改为 4
                        7. 因此 with 内部的 xy 分别打印出:34
                        8. with 执行完,作用域链上的 foo 对象会被移除。即作用域链上只剩下 window 对象。
                        9. 后面查找 xyfoo 变量都是从全局作用域下查找的,因此会分别打印出 14
                        10. 最后我们也可以看到 foo 对象是更新变为:{ x: 3 }

                        结合前面的原型的内容,假设将 foo 对象的原型上添加 y 属性,那么 y = 4 被修改的是 foo.__proto__ 上的属性,而不是全局作用域下的 y 变量。(有兴趣的自行尝试一下)

                        The end.

                        ]]>
                        <![CDATA[JavaScript 闭包详解]]> https://github.com/tofrankie/blog/issues/267 https://github.com/tofrankie/blog/issues/267 Sun, 26 Feb 2023 12:26:58 GMT 配图源自 Freepik

                        继上一篇文章

                        继上一篇文章 JavaScript 脚本编译与执行过程简述,再来介绍一下 JavaScript 中神奇的“闭包”(Closure)。

                        闭包是基于词法作用域书写代码时所产生的自然结果。

                        JavaScript 语言是采用了词法作用域。一般情况下,函数、变量的作用域在编写的时候已经确定且不可改变的。除了 evalwith 之外,它们会在运行的时候“修改”词法作用域,但实际项目中,几乎很少用到它们,欺骗词法作用域会有性能问题,我们可以忽略。

                        还有,千万别把 this 跟作用域混淆在一起,this 与函数调用有关,可以说是“动态”的。而作用域是静态的,跟函数怎样调用没关系。词法作用域也被叫做“静态作用域”。

                        若对词法作用域、执行上下文、变量对象、作用域链等内容不熟悉的话,建议先学习相关知识。到时回来再看闭包的时候,就非常容易理解了。

                        概念

                        无论网上文章,还是各类书籍,对闭包的定义都不尽相同。列举几个:

                        • MDN:闭包是指那些能够访问自由变量的函数。
                        • JavaScript 高级程序设计:闭包指的是那些引用了另一个函数作用域中变量的函数。
                        • 你不知道的 JavaScript:闭包是代码块和创建该代码块的上下文中数据的结合。

                        讲实话,我也不知道以上哪个说法更贴切、更符合。当了解作用域链之后,就很容易理解闭包了。

                        上面提到了自由变量一词,

                        自由变量(Free Variable):是指在函数中使用的,但既不是函数参数,也不是函数的局部变量的变量。

                        var a = 1
                        function foo() {
                          var b = 2 // b 不是自由变量
                          console.log(a) //  a 是自由变量
                        }
                        foo()
                        

                        在 ECMAScript 中,闭包指的是:

                        • 从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。

                        • 从实践角度:以下函数才算是闭包:

                          • 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)。
                          • 在代码中引用了自由变量。

                        就我个人认为,闭包不是一个函数,它是一种机制,用于访问自由变量。闭包不是 JavaScript 中专有术语,在上世纪很早就被提出来了,在其他语言(如 Ruby 语言)中,闭包可以是一个过程对象,一个 Lambda 表达式或者是代码块。

                        Chrome 眼中的闭包

                        其实上面概念可能很多人都不理解,但问题不大,我们先看看 Chrome 眼中的闭包是长怎么样的。

                        举个例子:

                        function foo() {
                          var a = 1
                          function bar() {
                            console.log(a)
                          }
                          return bar
                        }
                        
                        var f = foo()
                        f() // 1
                        

                        相信很多人都知道,函数 foo 就是一个闭包,通过 Chrome 断点调试可以从视角感知。

                        但是我们稍微修改一下,

                        var a = 1
                        function foo() {
                          function bar() {
                            console.log(a)
                          }
                          return bar
                        }
                        
                        var f = foo()
                        f()
                        

                        此时 a 是全局上下文的变量,尽管对于函数 bar 来说 a 属于自由变量,但它不是 foo 函数上下文内声明的变量,因此 foo 就不是闭包。

                        函数内访问全局变量算是闭包吗?

                        总结:在函数 A(如 foo)中存在某个函数 B(如 bar,且必须是在 A 中定义的),且 B 内至少引用了 A 中的一个“变量”,那么函数 A 就是一个闭包。

                        请注意,与函数 B 的调用方式没关系。无论 B 是在 foo 内部被调用,还是作为返回值返回,然后在别处调用。

                        再看一个例子:

                        function foo() {
                          var b = () => {
                            // 由于 b 是箭头函数,内部没有 arguments 对象,
                            // 所以这个 arguments 对象是 foo 中变量对象的一员,
                            // 因此 foo 也是一个闭包。
                            console.log(arguments)
                          }
                          return b
                        }
                        
                        var f = foo('foo')
                        f() // { 0: 'foo', length: 1 }
                        

                        上述这个示例,是为了提醒 BA 中的某个“变量”(指变量、函数、arguments、形参等)的引用,不仅仅是通过 varfunctionletconstclass 等关键字显式声明的,还可以是 arguments 对象、形参。换句话说,就是 AO 中的所有变量。

                        再看,下面示例中 foo 是闭包吗?

                        function foo(fn) {
                          var a = 'local'
                          fn()
                        }
                        
                        function bar() {
                          console.log(a)
                        }
                        
                        var a = 'global'
                        
                        foo(bar) // "global"
                        

                        答案是 NO。前面总结过一个函数要成为闭包,该函数(foo)内部必须存在另外一个函数(fn),且 fn 内需要 foo 中的某个变量。那不正好引用了 foo 中的变量 a 吗?显然,这理解是错误的。

                        根据词法作用域可知,函数 bar 的作用域链 [[scope]] 在声明时就已确定且不可变,只含 GlobalContext.VO,因此当查找自由变量 a 时,当 bar 的 AO 内查不到,下一步是前往全局对象下查找,于是就找到了 a 其值为 "global"。所以 fn 内部对 foo 构成不了引用,因此 foo 就不是闭包。

                        若到这里,对闭包还是懵懵懂懂的,这块引用的内容,请跳过。

                        突然间,我好像明白了为什么函数内部缺省声明关键字的变量(如 a = 1),在执行时才将其视为全局变量。

                        假设将其作为函数上下文的变量,要怎么做:

                        • 假设将其视为当前函数执行上下文的一个变量,那么 JS 引擎在进入执行上下文时,初始化工作量实在太多了,要通篇扫描当前上下文的声明语句和赋值语句,还要判别赋值语句是单纯地给已有变量赋值,还是上面提到的缺省声明情况。显然很影响效率和性能。
                        • 如果不通篇扫描,在执行代码的时候再更新到 AO 上,那么又会破坏 JavaScript 的词法作用域。似乎就变成了“动态作用域”。

                        但如果将其视为全局上下文的一个变量,上面的额外的工作都省了。但注意,它与全局声明的变量有些区别,前者可以被删除,而后者无法删除(原因可看这里)。在严格模式下对这种“隐式”声明全局变量的行为作为禁止,并抛出 SyntaxError。不确定是不是因为这个原因而被禁的。

                        这个是突然灵光一闪的,所以也 Mark 下来了。

                        综上所述,Chrome 浏览器眼中的闭包应该是这样的:

                        在某个函数 A 中存在另一个函数 B(函数 B 必须是在函数 A 中定义的),而且 B 内至少引用了 A 中的一个变量,那么当 B 在任意地方被调用时,函数 A 就是一个闭包。

                        其实,我认为概念不是很重要的...

                        更多示例

                        前面的示例,都相对比较简单和清晰的。再看多几个吧。

                        关于 Chrome 浏览器调试,在 Source 选项卡进行断点调试时,可以看到作用域、闭包的变化。

                        CallStack: 调用栈
                        Scope: 当前执行上下文的作用域链
                          Local   // 当前 AO/VO 对象,但不完全是,我们也可以看到 this 指向
                          Block   // 包含块级作用域 let、const、class 的变量
                          Closure // 闭包
                          modules // ESM 模块
                          Script  // <script> 内所有 let、const、class 声明的变量
                          Global  // 即 window,通过 var function 声明的全局变量,会放在这里
                        

                        示例一

                        请问以下示例会不会产生闭包?(这道题不是考你 this 指向哈,别搞错了)

                        var name = 'Frankie'
                        var obj = {
                          name: 'Mandy',
                          sayHi: function () {
                            return function () {
                              console.log(this.name)
                            }
                          }
                        }
                        
                        obj.sayHi()()
                        

                        答案是 NO。我们可以在控制台看到。

                        然后再修改下,当 obj.sayHi() 返回的匿名函数被调用时,存在对 obj.sayHi 方法的引用。因此 obj.sayHi 就是一个闭包。

                        var name = 'Frankie'
                        var obj = {
                          name: 'Mandy',
                          sayHi: function () {
                            var _this = this
                            return function () {
                              console.log(_this.name)
                            }
                          }
                        }
                        
                        obj.sayHi()()
                        

                        我们知道箭头函数内部不存在 this,因此无论 obj.sayHi() 返回的匿名箭头函数怎样调用,最终 this 都指向 obj 对象。但我的疑问在于,以下示例会不会产生闭包?

                        var name = 'Frankie'
                        var obj = {
                          name: 'Mandy',
                          sayHi: function () {
                            return () => {
                              console.log(this.name)
                              // console.log(arguments) // arguments 会产生闭包,而 this 是不会的
                            }
                          }
                        }
                        
                        obj.sayHi()()
                        

                        答案还是 NO。其实我这里是有个疑问的,按道理箭头函数不存在 argumentsthis 对象,若在监听函数内访问这两个对象,都应该产生闭包。但事实是,this 引用不会使得 sayHi 称为闭包。但是若箭头函数内引用了 arguments 对象,则会产生闭包。这一点要注意下!

                        示例二

                        经典面试题,哈哈!

                        for (var i = 1; i <= 3; i++) {
                          setTimeout(function timer() {
                            console.log(i)
                          }, i * 1000)
                        }
                        

                        上述示例打印结果是:3、3、3(时间间隔一秒)。如果要每间隔一秒分别输出:1、2、3,怎么处理?解决方案很简单。

                        解决方法一:

                        setTimeout 披一个函数,即多一层作用域。

                        for (var i = 1; i <= 3; i++) {
                          (function fn(i) {
                            setTimeout(function timer() {
                              console.log(i)
                            }, i * 1000)
                          })(i)
                        }
                        

                        我就不打断点了,直接从执行过程分析:

                        1. 全局代码开始执行
                          ECStack = [ GlobalContext.VO ]
                        
                        2. 开始执行 for 循环,fn 的函数上下文初始化如下,
                        
                          FunctionalContext<fn> = {
                            AO: {
                              arguments: {
                                0: 1,
                                length: 1
                              }
                              i: 1,
                              timer: ƒ timer()
                            },
                            Scope: [ GlobalContext.VO, AO ],
                            this: undefined
                          }
                        
                          当 timer 声明的时候,它的 [[scope]] 就确定了,即 FunctionalContext<fn>.Scope
                          由于 fn 内部存在一个函数 timer,且 timer 中的 i 引用了 fn 中的 AO 变量,
                          因此 fn 形成闭包。
                        
                        3. 后面两次循环同理...
                        
                        4. 一秒后,会调用 timer 函数,然后进入 timer 函数执行上下文,并初始化:
                        
                          FunctionalContext<timer> = {
                            AO: {
                              arguments: {
                                length: 0
                              }
                            },
                            Scope: [ GlobalContext.VO, FunctionalContext<fn>.AO, AO ],
                            this: undefined
                          }
                        
                          执行 timer 内部代码时,要查找 a 变量,首先当前 AO 没有,
                          接着往 FunctionalContext<fn>.AO 上面找,于是就找到了 a 为 1。
                          然后 timer 执行完毕。
                        
                        5. 又过了一秒,又会触发 timer 函数,过程同上。
                        
                        6. 但注意每次循环执行的 fn 函数都不是同一个函数哦,它们原先执行上下文的 AO 对象
                           被保存至 timer 函数 [[scope]] 里面了。
                           因此,每次执行 timer 函数的时候,i 都是不一样的。
                        
                        7. 所以按照这样去改造的话,就能每间隔一秒分别输出:1、2、3
                        

                        解决方法二:使用 let 来声明变量 i

                        首先请注意 for 语句两种方式的区别,如下:

                        /* 全局作用域 */
                        for (let i = 1; ;/* 块级作用域 1 */) {
                          /* 块级作用域 2 */
                        }
                        
                        /* 全局作用域 */
                        for (var j = 1; ;/* 全局作用域 */) {
                          /* 全局作用域 */
                        }
                        
                        for (let i = 1; i <= 3; i++) {
                          setTimeout(function timer() {
                            console.log(i)
                          }, i * 1000)
                        }
                        

                        三个作用域的体现:

                        我认为的闭包

                        在我的理解里, 每个 JavaScript 函数都是闭包。或者说某个函数引用了其作用域外的变量,那么这个函数可以被认为是闭包。

                        尽管 Chrome 浏览器不认同我的说法,也不会影响我理解和使用闭包,因为我已经知道了作用域链与闭包直接相关。

                        插一段废话,不想看的话直接跳到下一节。

                        我在学习闭包的之前,先整体了 JS 整个加载、编译和执行过程。其实学习还是其他,都应该从宏观和微观的角度分析。它们的过程是循序渐进的。

                        我猜,可能还有挺多有一定经验的 JSer 不知道 JS 脚本是按块加载的。按块加载什么意思?比如我们的网页有两个 JS 脚本(即两对 <script> 标签)。JS 引擎会先对其中一块进行编译与执行的过程,完成之后,才开始对下一个脚本进行编译与执行。假设你不了解,可能误以为 JS 引擎会通篇扫描所有脚本的语法,然后再按顺序(或不按顺序)执行。这是不对的。

                        因此学习闭包也是一样的道理,请先了解 JavaScript 代码从编译到执行的过程。

                        编译阶段:
                          词法分析
                          语法分析
                          代码生成
                        
                        执行阶段:
                          执行上下文栈
                            出栈/入栈
                          创建执行上下文
                          初始化执行上下文:
                            变量对象/活动对象(VO/AO):
                              创建时就确定函数的 [[scope]](要学好闭包,这玩意要弄明白)
                            作用域链(Scope Chain)
                            This
                          代码执行:
                            闭包
                        
                        词法作用域:
                          什么是词法作用域?
                          什么是动态作用域?
                        

                        等这些内容都属性之后,再结合本文或其他大佬的文章,闭包就自然而然就懂了。如果跳过以上内容,直接看闭包,我认为是很难理解的,即使好像当时看懂了,但很快就会忘了。

                        The end.

                        ]]>
                        <![CDATA[JavaScript 脚本编译与执行过程简述]]> https://github.com/tofrankie/blog/issues/266 https://github.com/tofrankie/blog/issues/266 Sun, 26 Feb 2023 12:23:29 GMT 配图源自 Freepik

                        由于

                        由于上一篇文章对作用域、执行上下文等写得过于详细,而且参杂了很多内容,看起来并不好理解,因此就简化一下。

                        在网页里,JavaScript 脚本是插入在 <script> 标签内的(内联或外部引入)。

                        <script>
                          // some JavaScript code...
                        </script>
                        
                        <script>
                          // some JavaScript code...
                        </script>
                        
                        <script src="xxx"></script>
                        

                        一般情况下,脚本是按顺序一块一块地执行的(一个 <script> 标签为一块)。只有执行完一块,才会执行下一块的。如果当前块有语法错误或其他错误导致当前块终止执行,不会影响下一块的执行。

                        对于外部引入的 JavaScript 脚本,若设置 <script> 标签的 deferasync 属性会影响脚本的加载顺序,不会按着编写顺序执行。而这些属性对于内联脚本是没有作用的。

                        要让 JavaScript 代码运行起来,包括编译阶段执行阶段。每一块脚本的运行都包含这两个过程。请注意,不是将“所有块”编译完再执行的。

                        编译阶段:
                          词法分析:将源码分解成很多个有意义的 token
                          语法分析:通篇检查当前块的语法是否有误,若无,将 tokens 转换为 AST
                          代码生成:将 AST 转换为 JS 引擎可执行代码
                        
                        执行阶段:
                          创建执行上下文
                          初始化执行上下文
                          代码执行
                        

                        JS 引擎的优化工作是在编译阶段完成的,例如 V8 引擎。若编译阶段检查出语法有误,将会终止当前块的后续阶段。

                        待编译阶段完成后,接着进入执行阶段。而执行阶段可以分为两个步骤:

                        1. 进入执行上下文
                        2. 代码执行

                        进入执行上下文步骤,可以理解为,真正一行一行执行代码的前期准备工作:

                        1. JS 引擎创建一个叫做:执行上下文栈(Execution Context Stack,ECS)的东西。顾名思义,就是用了维护执行上下文的。
                        2. 最先进入的肯定是全局代码,这个执行上下文被称为:全局上下文。首先插入 ECS,因此 ECS 栈底永远是全局上下文。后面调用函数时,会进入函数上下文,该上下文也会插入 ECS。
                        3. 每进入一个执行上下文,都会做一些初始化工作。

                        待准备工作完成后,就会进入代码执行步骤,即一行一行地去执行代码。

                        大家可能疑惑的地方,应该是“前期准备工作”的具体过程是怎样的?

                        执行上下文栈

                        进入全局代码,ECS 插入 GlobalContext,后面每调用一个函数就往 ECS 栈顶插入 FunctionalContext,函数执行完 FunctionalContext 又会从栈顶删除......周而复始的过程。ESC 栈底永远保留着 GlobalContext。直到页面销毁,它才会被删除,ESC 的使命也就结束了,被 JS 引擎清空。

                        ECS = [
                          FunctionalContext // 栈顶
                          ...
                          FunctionalContext
                          GlobalContext  // 栈底
                        ]
                        

                        前面提到上下文:GlobalContext(全局上下文)、FunctionalContext(函数上下文)。

                        执行上下文

                        其实 JavaScript 中有三种执行上下文:

                        全局上下文:
                          全局下都是属于全局上下文。
                        
                        函数上下文:
                          顾名思义,就是 JavaScript 函数内的执行上下文
                        
                        eval 上下文:
                          就是 eval('some code...'),尽可能不要用,会影响性能、欺骗词法作用域
                        

                        每个执行上下文,可以看作是一个 JavaScript 对象,它有三个重要属性:

                        Variable Object: 
                          即变量对象,简称 VO。我们声明的变量、函数就是放在 VO 中的。
                        
                        Scope Chain: 
                          即作用域链,简称 Scope。它包含了当前上下文 VO,以及各父级的 VO。查找变量就是根据这链条去查询的。
                        
                        This: 
                          即 this 对象,这是一个很灵活的对象,跟函数调用相关。
                        }
                        

                        执行上下文的初始化工作,就是确定这些属性的一些值。这个“初始化”的过程也常被称为“预编译”,网上很多文章都有这个说法。

                        举例

                        上面的执行阶段的过程,举例会更好地说明。

                        var a = 'global'
                        function foo() {
                          var a = 'local'
                          function bar() {
                            console.log(a)
                          }
                          bar()
                        }
                        foo() // "local"
                        

                        当编译过程之后,进入执行阶段。JS 创建一个执行上下文栈 ECStack,总是先进入全局上下文,全局上下文被插入到执行上下文栈:

                        ECStack = [ GlobalContext ]
                        

                        接着对 GlobalContext 进行初始化:

                        GlobalContext = {
                          VO: window, 
                          Scope: [ GlobalContext.VO ],
                          this: GlobalContext.VO
                        }
                        // 注意,不同宿主环境顶层对象是不一样的。
                        // 浏览器下为 window 对象,Node 中为 global 对象。
                        // 本文以浏览器为例,因此直接写了 window 对象。
                        

                        初始化的同时 foo 函数也被创建,会保存当前上下文的作用域链到函数内部的 [[scope]] 属性中。该属性可以通过 console.dir(foo) 查看。

                        foo.[[scope]] = [ GlobalContext.VO ]
                        

                        当全局上下文初始化完成之后,开始执行全局代码,当进行到 foo() 时,JS 引擎又会创建 foo 函数执行上下文,并插入 ECStack 栈顶。

                        ECStack = [ GlobalContext, FunctionalContext<foo> ]
                        

                        跟着,会进入 foo 函数上下文,该上下文也会进行初始化工作:

                        1. 复制 foo 函数 [[scope]] 属性创建作用域链。
                        2. arguments 创建活动对象 AO。
                        3. 初始化活动对象,即 AO 按顺序加入:形参、函数声明、变量声明。若存在同名情况,函数声明会覆盖形参,变量声明会被忽略。
                        4. 将 AO 对象加入 foo 作用域链顶端。
                        FunctionalContext<foo> = {
                          AO: {
                            arguments: {
                              length: 0
                            },
                            a: undefined,
                            bar: ƒ bar()
                          },
                          Scope: [ GlobalContext.VO, AO ],
                          this: undefined // this 是在函数执行的时候才确定下来的,foo 函数的 this 的值跟作用域链没有关系
                        }
                        

                        初始化完成,开始执行 foo 函数体代码,AO 对象也会更新。当执行到 bar() 函数时,又将会进入 bar 函数上下文,并进行初始化工作:

                        ECStack = [ GlobalContext, FunctionalContext<foo>, FunctionalContext<bar> ]
                        
                        FunctionalContext<bar> = {
                          AO: {
                            arguments: {
                              length: 0
                            }
                          },
                          Scope: [ GlobalContext.VO, FunctionalContext<foo>.VO, AO ],
                          this: undefined
                        }
                        

                        初始化完成,开始执行 bar 函数代码,在 console.log(a) 中,会查找 a 变量。

                        按作用域链顺序查找,先从当前上下文的 AO 中查找,若查找不到再往上一层 AO 中查找...直到全局上下文的 VO 中查找,若再查找不到就会抛出引用错误(ReferenceError)。

                        AO -> FunctionContext<foo>.AO -> GlobalContext.VO
                        

                        很明显,a 变量将会在 FunctionContext<foo>.AO 中查找到,其值为 "local",因此打印结果就是 "local"

                        bar() 执行完毕,bar 函数上下文会被从栈顶删除。

                        ECStack.pop(FunctionalContext<bar>)
                        

                        又将执行权交还给 foo 函数上下文,它也将会执行完毕,也会从栈顶移除。

                        ECStack.pop(FunctionalContext<foo>)
                        

                        然后又交还给全局上下文

                        ECStack = [ GlobalContext ]
                        

                        至此,这个程序执行完毕。当页面销毁时,ECStack 才会被清空。

                        ]]>
                        <![CDATA[JavaScript 变量不能被 delete 的原因]]> https://github.com/tofrankie/blog/issues/265 https://github.com/tofrankie/blog/issues/265 Sun, 26 Feb 2023 12:22:26 GMT 配图源自 Freepik

                        请记住:

                        任何时候,变量只能通过使用 ]]> 配图源自 Freepik

                        请记住:

                        任何时候,变量只能通过使用 varletconst 关键字才能被声明。

                        我们都知道, 无论在全局上下文,还是其他任何上下文中,都可以通过省略变量声明关键字的形式(类似 a = 1 )给全局对象添加一个新“属性”。请注意,严格来说这个 a 不能称作“变量”,原因是它不符合 ECMAScript 规范中变量的概念。

                        举个例子:

                        var a = 1
                        b = 2
                        
                        delete a
                        delete b
                        
                        console.log(a) // 1
                        console.log(b) // ReferenceError: b is not defined
                        

                        严格来说,示例中 b 不能被称为变量。它只是全局对象下的一个属性。

                        示例中,变量 a 并没有被删除掉,而 b 却被删除了,为什么呢?问题先放一放

                        delete 操作符

                        我们先看看 delete 操作符是怎样工作的,语法如下:

                        delete object.property
                        delete object['property']
                        

                        它的作用是删除对象的某个属性。一般情况下它的返回值都是 true,表示对象某个属性被移除成功。若该属性是一个自身的不可配置的属性,这时非严格模式下返回 false(即属性移除失败),而严格模式下会抛出 TypeError

                        上面这句话有两个关键词:“自身”、“不可配置”。

                        • 自身:是指对象本身的属性,而非原型上的。可通过 Object.prototype.hasOwnProperty() 判断。
                        • 不可配置:其实对象属性都有一个属性描述对象,属性描述对象其中包含一个 configurable 属性,若为 false,则表示该属性不可配置。可通过 Object.getOwnPropertyDescriptor() 获取。
                        var a = 1
                        b = 2
                        
                        window.hasOwnProperty('a') // true
                        window.hasOwnProperty('b') // true
                        
                        Object.getOwnPropertyDescriptor(window, 'a') // { configurable: false, ... }
                        Object.getOwnPropertyDescriptor(window, 'b') // { configurable: true, ... }
                        
                        var obj = {}
                        obj.__proto__.prop = 'proto'
                        delete obj.prop // true,尽管 obj 本身没有 prop 属性,仍会返回 true
                        obj.prop // "proto",delete 不会删除原型上的属性
                        

                        区别

                        回到文中开头的问题:变量 a 为什么没有被删除掉?

                        尽管 var a = 1b = 2 都会往 window 对象下添加对应属性,但是变量相比于简单属性来说,变量有一个特性:DontDelete,这个特性的含义就是不能通过 delete 操作符直接删除变量属性。

                        eval

                        另外还要注意,eval 上下文中的变量声明和函数声明没有 DontDelete 特性,因此是可以删除的。

                        eval('var a = 1; function foo() {}')
                        delete a // true
                        delete foo // true
                        

                        但是 eval 上下文内的函数体内部的变量或函数是不能删除的。

                        eval('function foo() { var inner = 2; console.log(delete inner); console.log(inner) }')
                        foo() // false
                        

                        总结

                        • 全局上下文或函数上下文中,变量声明或函数声明总含有 DontDelete 特性。
                        • eval 上下文的变量声明或函数声明不含 DontDelete 特性,因此可以被 delete 删除。
                        • 不要相信全局对象下的 delete 操作。
                        • 编写代码时,不要写出类似 a = 1 省略声明关键字的语句。
                        ]]> <![CDATA[细读 JS | 事件详解]]> https://github.com/tofrankie/blog/issues/264 https://github.com/tofrankie/blog/issues/264 Sun, 26 Feb 2023 12:19:06 GMT 配图源自 Freepik

                        本文将会介绍事件、事件流、事件对象、事件处理程序、事件委托、以及兼容 IE 浏]]> 配图源自 Freepik

                        本文将会介绍事件、事件流、事件对象、事件处理程序、事件委托、以及兼容 IE 浏览器等内容。

                        一、概念

                        就本文一些“术语”作简单介绍,后续章节再详述。

                        1. 事件

                        事件,可以理解为用户与浏览器(网页)交互产生的一个动作。比如点击(click)、双击(dblclick)、聚焦(focus)、鼠标悬停(mouseover)、松开键盘(keyup)等动作。浏览器内部包含了非常复杂的事件系统,事件种类非常多,JavaScript 与 HTML 的交互就是通过事件实现的。前面列举这些仅仅是冰山一角。

                        2. 事件对象

                        在产生事件之后,浏览器会生成一个对象并存储起来,它包含了当前事件的所有信息,比如发生事件的类型、导致事件的元素以及其他与事件相关的数据。例如鼠标操作,该对象还会记录事件产生时鼠标的位置等信息。待事件完成使命(即事件完结)之后,它将会被销毁。这个对象,被称为“事件流”。

                        3. 事件流

                        事件对象在 DOM 中的传播过程,被称为“事件流”。

                        经常能看到类似 IE 事件流、标准事件流(DOM 事件流)的说法,主要原因是早期浏览器厂商各干各的,没有一个“中间人”去进行统一。随着 Web 的飞速发展,相关标准就由特定机构制定,浏览器厂商负责按照标准去实现。例如 W3C、WHATWG、ECMAScript 等。但由于历史遗留原因,不得不写出一大堆的兼容方法以适配所有的浏览器。

                        说到事件流,就不得不提“事件冒泡”和“事件捕获”了。它们分别由微软、网景团队提出,是两种几乎完全相反的事件流方案。前者被所有浏览器支持,后者则是被所有的现代浏览器所支持(包括 IE9 及以上)。

                        由于事件捕获不被旧版本浏览器(IE8 及以下)支持,因此实际中通常在冒泡阶段触发事件处理程序。

                        综上,我们可以简单地,将 IE8 及以下浏览器的事件流处理方案称为“IE 事件流”,其余的称为标准事件流(或 DOM 事件流)。

                        4. 事件处理程序

                        为响应事件而调用的函数被称为**“事件处理程序”**(也可称为事件处理函数、事件监听器、监听器)。通常我们会在发生事件之前,需提前为某个 DOM 元素编写事件处理监听器并进行绑定。待用户与浏览器产生交互后,事件对象会通过事件流机制在 DOM 中进行传播,一旦命中目标,事件监听器就被调用,并接收事件对象作为其唯一的参数。(注意,IE8 需要通过 window.event 全局对象来获取事件对象)

                        5. 事件委托

                        事件委托,它是利用事件流机制来提高页面性能的一种解决方案,也被称为事件代理。

                        6. 其他

                        文中还会提到事件目标一词,是指触发事件的 DOM 元素,但不一定是事件处理程序所在的 DOM 元素。当生成事件对象之后,事件目标(在 JavaScript 就是一个对象)将会被存储在事件对象的 target 属性(只读,IE8 则是 srcElement 属性)。

                        还有,本文将大量使用到“现代浏览器”、“主流浏览器”、“标准浏览器”、“IE 浏览器”等词语。若无特殊说明,前三个指的是包括 IE9 ~ IE11 及其他常见的浏览器。而“IE 浏览器”通常指 IE8 及更低版本的浏览器。

                        二、事件流

                        前面提到,事件流就是事件对象在 DOM 中的传播过程。标准事件流过程,如下:

                        捕获阶段 -> 目标阶段 -> 冒泡阶段
                        
                        Captruing Phase -> Target Phase -> Bubbling Phase
                        

                        其中 IE8 及以下浏览器,不支持事件捕获。即 IE8 事件流则不含捕获阶段。

                        举个例子:

                        <div id="div">
                          Division
                          <p id="p">Paragraph</p>
                        </div>
                        

                        结合事件流,当我们点击 <p> 元素,产生一个点击事件。事件对象的捕获阶段的过程,如下:

                        window -> document -> html -> body -> div -> p
                        
                        注:到达 p 之前是捕获阶段的所有过程,到达 p 处于目标阶段。
                        

                        目标阶段过后,是冒泡阶段的过程,如下:

                        p -> div -> body -> html -> document -> window
                        

                        因此,事件流过程可以简单绘成如下表格:

                        1. 事件捕获、事件冒泡

                        上面提到的捕获阶段和冒泡阶段,所对应的就是事件捕获、事件冒泡。

                        事件冒泡和事件捕获,分别由微软和网景团队提出,这是几乎完全相反的两个概念,是为了解决页面中事件流而提出的。

                        • 事件冒泡(Event Bubbling)

                        想象一下:气泡从水中冒出水面的过程,它是从里(底)到外的。事件冒泡跟这个过程很相似,事件对象会从最内层的元素开始,一直向上传播,直到 window 对象。因此冒泡过程如下:

                        p -> div -> body -> html -> document -> window
                        

                        注意,并非所有事件都支持冒泡行为,比如 onbluronfocus 等事件。

                        • 事件捕获(Event Capture)

                        既然事件捕获与事件冒泡是相反的,捕获过程如下:

                        window -> document -> html -> body -> div -> p
                        

                        总的来说,可以简单概括为:冒泡过程是由里到外,而捕获过程则是由外到里。两者刚好相反。

                        注意,现代浏览器都是从 window 对象开始捕获事件的,冒泡最后一站也是 window 对象。而 IE8 及以下浏览器,只会冒泡到 document 对象。

                        2. 示例

                        我们分别给 divp 元素添加了两个事件处理程序,如下:

                        const elem1 = document.getElementById('div')
                        const elem2 = document.getElementById('p')
                        
                        // 捕获阶段触发事件处理程序
                        elem1.addEventListener('click', () => console.log('div capturing'), true)
                        elem2.addEventListener('click', () => console.log('p capturing'), true)
                        
                        // 冒泡阶段触发事件处理程序
                        elem1.addEventListener('click', () => console.log('div bubbling'), false)
                        elem2.addEventListener('click', () => console.log('p bubbling'), false)
                        
                        // 注意,本示例在现代浏览器中可正常执行。
                        
                        1. 点击 div 元素区域(不包含 p 元素区域),先后打印:
                        
                        div capturing
                        div bubbling
                        
                        2. 点击 p 元素区域,先后打印:
                        
                        div capturing
                        p capturing
                        p bubbling
                        div bubbling
                        

                        3. 注意点

                        通过 DOM2 Event 提供的 addEventListener() 方法,可以在捕获阶段触发事件监听器。在事件监听器中,除了可以写业务逻辑外,还经常做阻止事件冒泡、取消元素默认行为等处理。

                        如果不支持某个阶段,或事件对象已停止传播,则将跳过该阶段。例如,将 addEventListener() 方法的第三个参数 useCapture 设为 true,则将跳过冒泡阶段(但注意它是会到达目标阶段的)。如果事件监听器中调用了 stopPropagation(),则将跳过后续的所有阶段。

                        还有,我们给某个 DOM 元素注册一个点击事件监听器,假设其后代元素未阻止冒泡行为,只要点击该元素本身或其后代任意子元素,最后都会触发该事件监听器。因此,我们可以得出一个结论:事件监听函数的作用范围,包含元素本身所占空间及其后代元素所占空间。不论后代元素是否溢出当前元素范围(长宽),或者是否脱离文档流(指绝对布局等)。

                        四、事件对象

                        前面提到,当用户与浏览器发生交互会产生一个事件,接着会创建生成一个事件对象。

                        1. 获取事件对象

                        在标准浏览器中,无论是以哪种方式(DOM0 或 DOM2)注册事件处理程序,事件对象都是传给事件处理程序的唯一参数。但是在 IE8 及以下浏览器下事件对象只能通过 window.event 获取。

                        target.onclick = function (e) {
                          // 以下方式可兼容所有浏览器
                          var ev = e || window.event
                        }
                        
                        // ⚠️ 以下这块内容,纯属满足个人强迫症,建议跳过!!!
                        //
                        // 关于 e 和 window.event 的异同(亲测):
                        //
                        // 1. 在标准浏览器, 可能是为了兼容处理,同样支持 window.event 对象,实际中我们从事件监听函数参数 e 取值即可。
                        //    事件发生过程中 e === window.event。当事件结束后,window.event 的值变为 undefined。
                        //    这点 IE11 与标准浏览器表现是一致的。
                        //
                        // 2. 在 IE9、IE10 中,e 可能是 MouseEvent 等实例对象,
                        //    而 window.event 是 MSEventObj 实例对象,
                        //    因此,e !== window.event,但问题不大。
                        //
                        // 3. 在 IE8 及以下,DOM0 事件处理程序中将不会接收到事件对象,即 e 为 undefined。
                        //    此时要获取事件对象,只能通过全局 window.event 获取。
                        //    而且 window.event 是 Object 的实例,IE8 下并没有 MSEventObj 对象,与 IE9 ~ 10 有细微差别。
                        //    同样地,window.event 只在事件发生过程有效,其他时候取到的值为 null。
                        //    还有偶然发现,在 IE8 下竟然 Object.prototype.toString.call(null) === '[object Object]',无语!
                        //
                        // 4. 综上,想要在事件监听函数中取到事件对象,只需通过:
                        //    var ev = e || window.event 
                        //    以上这条语句,可以兼容所有浏览器,并正确获取到事件对象。
                        //    至于 IE9 ~ 10 中 e 与 window.event 的细微差异,实际中无需关心,没有影响。
                        //
                        // 5. That's all.
                        

                        需要注意的是,事件对象仅在事件发生过程有效,一旦事件完毕,就会被销毁。这一点所有浏览器表现一致。

                        在标准浏览器里,可以通过 window.event 或者事件对象的 eventPhase 属性来验证,如下:

                        target.onclick = function (e) {
                          console.log(e === window.event) // true
                        
                          setTimeout(() => {
                            console.log(e.eventPhase) // 0
                            console.log(window.event) // undefined
                          })
                        }
                        
                        // ⚠️ 解释:
                        // 1. 在标准浏览器,事件发生过程中监听器参数 e 与全局对象 window.event 是一致的
                        // 2. 事件对象 eventPhase 属性为 0 表示目前没有事件正在执行。
                        

                        2. 内置事件类型

                        在现代浏览器中,内置了很多事件类型:

                        以上这些都是基于 Event 接口的派生类,而事件对象一般是派生类的实例化对象。比如:

                        target.onclick = function (e) {
                          var ev = e || window.event
                          console.log(ev instanceof Event) // true
                          console.log(ev instanceof MouseEvent) // true
                        }
                        
                        // ⚠️ 
                        // 注意 IE8 不含 MouseEvent 等派生类,只有 Event 基类,
                        // 因此 IE8 中第 4 行会抛出错误。
                        

                        再啰嗦一句,window.event 最初是由 IE 引入的全局属性,且只有事件发生过程有效。现代浏览器为了兼容,也实现了这个全局属性。

                        3. Event 对象

                        前面提到,所有事件对象都源自 Event 基类,意味着 Event 基类(对象)本身包含适用于所有事件类型实例的属性和方法。主要是为了兼容性。

                        • 标准浏览器中 Event 对象常用属性:
                        Event = {
                          bubbles        // (只读,布尔值)表示当前事件是否会冒泡。
                        
                          eventPhase     // (只读,数值范围 0 ~ 3)表示事件流正被处理到了哪个阶段。
                                         // 0 表示当前没有事件正在被处理,
                                         // 1 ~ 3 分别表示事件对象处于捕获、目标、冒泡阶段。
                        
                          cancelable     // (只读,布尔值)表示事件是否可以取消元素的默认行为。
                                         // 若为 true 可以使用 preventDefault() 来取消元素的默认行为。
                                         // 若为 false 调用 preventDefault() 没有任何效果。
                        
                          cancelBubble   // (布尔值)如果设为 true,相当于执行 stopPropagation(),事件对象将会停止传播。
                                         // 标准浏览器中,请使用 stopPropagation();
                                         // IE8 浏览器中,只能使用 cancelBubble = true 来阻止传播;
                        
                          currentTarget   // (只读)返回当前事件处理程序所绑定的节点(不一定是事件触发节点)。
                                          // 标准浏览器,this === currentTarget 总为 true。
                                          // IE8 及以下浏览器不支持该属性。
                        
                          target          // (只读)返回事件触发节点(即事件目标)。
                                          // 当事件触发节点与事件处理程序所绑定节点相同时,target === currentTarget 结果为 true。
                                          // IE8 及以下浏览器中不支持该属性,请使用 srcElement 属性(相当于 target 属性)。
                        
                          type            // (只读,字符串)表示事件类型
                        
                          isTrusted       // (只读,布尔值)true 表示事件是由浏览器生成的。
                                          // false 表示由 JavaScript 创建,一般指自定义事件。
                        
                          detail          // 数值。该属性只有浏览器的 UI 事件才具有,表示事件的某种信息。
                                          // 例如,单击为 1,双击为 2,三击为 3。
                        
                          composed        // (只读,布尔值)表示事件是否可以穿过 Shadow DOM 和常规 DOM 之间的隔阂进行冒泡。
                                          // 替代已废弃的是 scoped 属性。
                        }
                        
                        • 标准浏览器中 Event 对象常用方法:
                        Event = {
                          preventDefault()    // 取消元素默认行为
                                              // 仅当 cancelable 为 true 时,调用 preventDefault() 才有效。
                                              // IE8 及以下浏览器,请设置 returnValue = false 来取消元素默认行为
                        
                          stopPropagation()   // 停止事件对象的传播,后续事件流其他阶段将会被取消。
                                              // IE8 及以下浏览器,请设置 cancelBubble = true,来阻止冒泡行为
                                              // 注意 IE8 及以下浏览器“有且仅有”事件冒泡过程。
                        
                          stopImmediatePropagation() // 用来阻止同一时间的其他监听函数被调用。
                                                     // 当同一节点同一事件指定多个监听函数时,这些函数
                                                     // 会根据添加次序依次调用。但只要其中一个监听函数调用了
                                                     // stopImmediatePropagation() 方法,其他监听函数就不会执行了。
                                                     // DOM3 Event 新增方法
                        }
                        
                        • IE8 及以下浏览器中 Event 对象常用属性:
                        Event = {
                          cancelBubble   // (可读写,布尔值)设置为 true 可以阻止冒泡行为。
                                         // 作用同标准浏览器中的 stopPropagation() 方法。
                        
                          returnValue    // (可读写,布尔值)默认为 true。
                                         // 设置为 false 可以取消元素的默认行为
                                         // 作用同标准浏览器中的 preventDefault() 方法。
                        
                          srcElement     // (只读)返回事件触发节点(即事件目标)。
                                         // 作用同标准浏览器中的 target 属性。
                        
                          type           // (只读,字符串)表示事件类型(同标准浏览器的 type 属性)。
                        }
                        

                        以上这些方法(包括标准浏览器、IE 浏览器)列举出来是为了方便下文封装方法时,注意兼容处理。

                        五、事件冒泡与默认行为

                        前面提到,并非所有事件都支持冒泡行为。因此,若给不支持冒泡行为的事件去 stopPropagation() 是多次一举,且没有意义。取消元素默认行为同理。

                        阻止事件冒泡和元素默认行为经常放在一起讨论。上一章节已经清楚介绍了标准浏览器与 IE 浏览器的兼容性。如下:

                        // 阻止冒泡
                        ev.stopPropagation() // 标准浏览器
                        ev.cancelBubble = true // IE8 及以下浏览器
                        
                        // 取消默认行为
                        ev.preventDefault() // 标准浏览器
                        ev.returnValue = false // IE8 及以下浏览器
                        
                        // ⚠️ ev 表示事件发生过程中的事件对象
                        

                        还有,在事件处理程序中慎用 return false 语句,不同环境下会发生非预期行为。例如:

                        // 原生 JS,只会取消元素默认行为,不会阻止事件冒泡行为
                        target.onclick = function (e) {
                          return false
                        }
                        
                        // JQuery,既会取消元素默认行为,也会阻止事件冒泡行为
                        $(target).on('click', function (e) {
                          return false
                        })
                        

                        因此,无论使用 JQuery 库或其他库,还是原生 JS 去编写事件处理程序,都尽量避免使用 return 语句。

                        其实 JQuery 已经给我们封装了 stopPropagation()preventDefault() 方法,它是兼容所有浏览器的,因此按照标准浏览器的方式来去阻止冒泡或取消默认行为即可。可参考文章

                        六、事件处理程序

                        事件处理程序,也常称为事件监听器或监听器。可通过以下几种方式给 DOM 元素注册事件处理程序:

                        • HTML 事件处理程序(不推荐)
                        • DOM0 事件处理程序(也不太推荐)
                        • DOM2 事件处理程序
                        • IE 事件处理程序

                        前两种方式不推荐,后两种就能覆盖 99.9% 的浏览器了。如果不用兼容 IE,那么 DOM2 就更香了,至少可以减少 70% 的代码量...

                        1. HTML 事件处理(不推荐使用)

                        这是最早的事件处理方式,说实话在项目中没见过这种写法。简单了解下即可,不推荐使用。

                        <div>
                          Division
                          <p onclick="inner()">Paragraph</p>
                          <p onclick="inner(event)">Paragraph</p>
                          <p onclick="console.log('inner')">Paragraph</p>
                        </div>
                        
                        <script>
                          function inner() {
                            // 事件处理程序中 this 指向 window 对象
                            console.log('inner')
                          }
                        </script>
                        
                        <!--
                          ⚠️ 注意点:
                          1. 内联式事件处理程序,this 执行 window 对象;
                          2. 不要使用全局内置的方法,例如 onclick="open()",它会触发 window.open() 而不是自定义的 open() 方法;
                          3. 浏览器不会给你传入事件对象,只能手动传入:可以是 window.event 或 event(推荐前者,后者也可,因为处于 window 环境)
                          4. HTML 事件处理程序会被 DOM0 事件处理程序覆盖。
                          5. 以上种种原因,不推荐使用。事实上也没见过项目这么用了。
                        -->
                        

                        2. DOM0 事件处理程序

                        使用方法简单,也很常见。就是将一个函数赋值给一个 DOM 元素的事件属性。其中元素的事件属性通常是 on + type(事件类型),比如 onclickondblclickonfocusonload 等。

                        举个例子:

                        // 注册事件处理程序
                        target.onclick = function (e) {
                          // this 将会指向事件处理程序所绑定的元素
                          // do something...
                        }
                        
                        // 移除事件处理程序
                        target.onclick = null
                        

                        这种方式只能够注册一个事件处理程序。若多次绑定,后者会覆盖前者。

                        3. DOM2 事件处理程序

                        在 DOM2 Event 标准中,新增了 addEventListener()removeEventListener() 方法来注册或移除事件处理程序。它的优势有两点:

                        • 可以为同一元素同一事件注册多个事件处理程序。
                        • 事件处理程序可以在“捕获阶段”被触发。这也是目前唯一可以在捕获阶段命中事件处理程序的方法。

                        该特性仅在现代浏览器中被支持,IE8 及以下不支持。

                        语法:

                        target.addEventListener(type, listener, useCapture)
                        target.removeEventListener(type, listener, useCapture)
                        
                        • type:表示事件类型(字符串)。比如 click
                        • listener:通常是一个函数,即事件处理程序。被触发时将会接收到一个事件对象作为参数。
                        • useCapture:布尔值,默认为 false。该参数决定了 listener 是否在“捕获阶段”被触发。

                        举个例子:

                        function handler(e) {
                          // this 将会指向事件处理程序所绑定的元素
                          // do something...
                        }
                        // 注册事件处理程序
                        target.addEventListener('click', handler, false)
                        // 移除事件处理程序
                        target.removeEventListener('click', handler, false)
                        
                        
                        // ⚠️ 注意点:
                        // 1. 保留事件处理程序的引用,是移除监听事件的唯一方法。
                        // 2. 换句话说,若使用匿名函数作为事件处理程序,将无法移除监听事件。
                        // 3. 若多次注册事件处理程序,对应地需要多次移除事件。
                        // 4. addEventListener() 是目前唯一一个可在“捕获阶段”触发事件监听的方法。
                        // 5. 在注册事件处理程序时,即使 listener 引用相同,若 useCapture 参数不同,
                        //    也会被注册多个。
                        // 6. 多次重复(指三个参数都完全相等时)注册事件处理程序,仅第一次有效。
                        //    这点与 DOM0 事件处理程序的方式是不同的。
                        // 7. 除了 DOM 元素,其他对象也有这个接口,比如 window、XMLHttpRequest 等。
                        // 8. IE8 及以下浏览器不支持,对应的解决方法是 attachEvent() 和 detachEvent() 方法,
                        //    这是 IE 浏览器特有的。
                        

                        4. IE 事件处理程序

                        尽管 IE8 及以下浏览器不支持 addEventListener()removeEventListener() 方法,但它有两个类似的方法来注册或移除事件处理程序,那就是 attachEvent()detachEvent()。区别是不支持在事件捕获阶段触发,因为 IE8 事件流只含事件冒泡。

                        语法如下:

                        // 只接受两个参数
                        target.attachEvent(ontype, listener)
                        target.detachEvent(ontype, listener)
                        
                        • ontype:表示事件属性(字符串),即 on + type,比如 onclick。这点与 addEventListener() 中的 type 参数是不同的。
                        • listener:同样接受一个函数作为事件处理程序。请注意 listener 函数内部 this 将指向 window 对象。

                        举个例子:

                        function handler(e) {
                          // this 将会指向 window 对象
                          // 要处理 this 指向问题很简单,例如 Function.prototype.bind() 等
                          // do something...
                        }
                        // 注册事件处理程序
                        target.attachEvent('onclick', handler)
                        // 移除事件处理程序
                        target.detachEvent('onclick', handler)
                        
                        
                        // ⚠️ 注意点:
                        // 1. 注意第一个参数是 on + type 的形式,这点与 DOM2 是不同的。
                        // 2. 若无特殊处理,事件处理程序内 this 指向 window 对象,这点与 DOM0、DOM2 是不同的。
                        // 3. 若 Function.prototype.bind() 去处理 this 问题,注意保持事件处理程序引用问题。
                        // 4. attachEvent() 不支持在捕获阶段触发事件处理程序。
                        

                        5. 跨浏览器事件处理程序

                        跨浏览器,就是说要同时兼容 IE 浏览器和现代浏览器。其实上面已经将所有方式都介绍了一遍,写起来就很简单了。

                        // 注册
                        function addHandler(el, type, fn) {
                          if (el.addEventListener) {
                            el.addEventListener(type, fn, false)
                          } else if (el.attachEvent) {
                            el.attachEvent('on' + type, fn)
                          } else {
                            el['on' + type] = listener
                          }
                        }
                        
                        // 移除
                        function removeHandler(el, type, fn) {
                          if (el.removeEventListener) {
                            el.removeEventListener(type, fn, false)
                          } else if (el.detachEvent) {
                            el.detachEvent('on' + type, fn)
                          } else {
                            el['on' + type] = null
                          }
                        }
                        

                        6. DOM3 自定义事件(扩展内容)

                        前面介绍的,全都是浏览器内置事件,当用户与浏览器发生交互时,事件(对象)就诞生了,它接着会在 DOM 中进行传播,命中目标后会触发相应的时间处理函数。

                        在 DOM3 Event 标准上,除了在 DOM2 Event 的基础上,新增了很多事件类型,而且它还允许自定义事件。但是自定义事件,需要“手动”触发,即主动调用 dispatchEvent() 方法才可以。

                        至于用处嘛,假设动态加载脚本,需在加载完成后才能做一些事情,例如配置什么的。那么我们需要监听脚本什么时候加载完,这时候自定义事件就能发挥其作用了。举个例子:

                        // 创建自定义事件
                        const customEvent = new CustomEvent('ready')
                        
                        // 创建元素
                        const el = document.createElement('script')
                        el.src = 'https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js'
                        el.onload = () => el.dispatchEvent(customEvent) // 派发事件
                        el.onerror = err => { /* 脚本加载失败... */ }
                        
                        // 注册事件处理程序
                        el.addEventListener('ready', e => {
                          // 脚本加载完成后,可以做些配置什么的...
                        })
                        
                        // 插入 document
                        const s = document.getElementsByTagName('script')[0]
                        if (s && s.parentNode) s.parentNode.insertBefore(el, s)
                        

                        关于 CustomEvent 的一些语法注意点:

                        // 1. 为 watch 事件添加监听事件函数
                        target.addEventListener('watch', function () { /* ... */ }, false)
                        
                        // 2. 创建 watch 事件,若无需传入指定数据,可直接使用 new Event('watch')
                        var watchEvent = new CustomEvent('watch', 
                            detail: { /* ... */ }, // 可以是任意值,通常是与本事件相关的一些信息
                            bubbles: false, // 是否冒泡
                            cancelable: false // 是否取消默认行为
                        )
                        
                        // 3. 手动触发事件
                        target.dispatchEvent(watchEvent)
                        
                        
                        // ⚠️ 注意点:
                        // 1. 前面 1 和 2 的顺序可以调换过来,没关系的。
                        // 2. 上述我们给事件目标 target(DOM 元素)注册了 watch 事件监听函数,
                        //    是无法通过鼠标点击或触控等形式去触发事件监听函数的,
                        //    需要主动触发,即调用 dispatchEvent() 方法。
                        // 3. 还有 CustomEvent 是有兼容性的,IE 是不支持的,
                        // 4. 至于兼容性如何处理,暂时不展开讲述。后面有空再另起一文吧。
                        

                        7. 小结

                        • 可能有人会好奇为什么不介绍 DOM1,原因是 DOM1 没有关于事件的新增或改动,因而没提及。

                        • 若同时存在 HTML 事件处理程序和 DOM0 处理函数,后者会覆盖掉前者。 DOM2 不会覆盖 HTML 事件处理程序或 DOM0 事件处理程序。

                        • 为同一事件目标注册多个事件处理程序时,执行次序为:HTML 事件处理程序或 DOM0 > DOM2 或 IE 事件处理程序。跟事件注册先后顺序无关。

                        若有兴趣想了解 DOM0 ~ DOM3 新增了哪些内容,可看文章。另外附上 WHATWG 的 DOM 标准:DOM Living Standard

                        七、总结(上半部分)

                        就前面关于事件处理程序所有兼容性问题,我们来进一步封装下。

                        由于要兼容 IE8 的原因,这里并没有使用 class 类的写法。因为经过 Babel 处理,类似 Object.assgin()Object.defindProperty() 等 ES5 方法在 IE8 及更低版本浏览器压根不支持。

                        先写个构造函数吧:

                        function CreateMyEvent({ type, el, fn }) {
                          this.el = el
                          this.type = type
                          this.fn = fn
                          this.listener = function (e) {
                            // IE8 DOM0 需要从 window.event 获取事件对象
                            const ev = e || window.event
                        
                            // 为 IE8 的事件对象添加以下属性和方法:
                            // target、currentTarget、stopPropagation()、preventDefault()
                            if (!ev.stopPropagation) {
                              ev.target = ev.srcElement
                              ev.currentTarget = el
                              ev.stopPropagation = () => {
                                ev.cancelBubble = true
                              }
                              ev.preventDefault = () => {
                                ev.returnValue = false
                              }
                            }
                        
                            // 统一 this 指向
                            fn.call(el, ev)
                          }
                        }
                        
                        CreateMyEvent.prototype.addEventListener = function () {
                          const { type, el, listener } = this
                          if (el.addEventListener) {
                            // 为了兼容所有浏览器,这里将 useCapture 设为 false
                            el.addEventListener(type, listener, false)
                          } else if (el.attachEvent) {
                            el.attachEvent('on' + type, listener)
                          } else {
                            el['on' + type] = listener
                          }
                        }
                        
                        CreateMyEvent.prototype.removeEventListener = function () {
                          const { type, el, listener } = this
                          if (el.removeEventListener) {
                            el.removeEventListener(type, listener, false)
                          } else if (el.detachEvent) {
                            el.detachEvent('on' + type, listener)
                          } else {
                            el['on' + type] = null
                          }
                        }
                        

                        一些注意点在注释有标注体现,然后进行实例化并调用,如下:

                        // 创建实例对象
                        const myEvent = new CreateMyEvent({
                          el: document.getElementById('directory'),
                          type: 'click',
                          fn: e => {
                            // 解决以下痛点:
                            // 1. this 总指向监听事件函数所绑定节点
                            // 2. e 总会得到事件对象
                            // 3. IE8 也可以轻松使用 target、currentTarget、stopPropagation()、preventDefault()
                            // do something...
                          }
                        })
                        
                        // 注册/移除监听器
                        myEvent.addEventListener()
                        setTimeout(() => {
                          myEvent.removeEventListener()
                        }, 5000)
                        

                        以上示例部分使用了 ES6 的语法,都 2021 年了,如需兼容 ES5 请放心交给 Babel 吧。(亲测 IE8 下可正常运行)

                        八、事件委托

                        除了面试中常被问及,实际应用场景里也是优化性能的一种手段。

                        前面提到,事件流的传播路径:捕获阶段 -> 目标阶段 -> 冒泡阶段。我们都知道,所有浏览器都支持事件冒泡,但事件捕获并不是都支持的,例如 IE8。

                        需要注意的是,多数情况下利用事件冒泡行为实现“事件委托”(或称为事件代理)。但其实,捕获阶段也是可以实现事件委托的。可能为了兼容,选择前者居多。

                        为什么要事件委托?

                        想象一个生活场景:一本书的目录包含大章节、小章节,每个小章节都会对应一个页码,然后根据页码就可以翻到对应的内容。

                        如果用程序实现的话,“最笨”的做法是:给每个小章节注册一个点击监听器并实现跳转。似乎也没太大问题,是吗?如果这本书有 1000 个小章节,意味着要注册 1000 个事件监听函数。先不说性能问题,写代码的是不是得疯掉。假设还没疯,哪天产品经理又新增 500 章节,是不是又得改,总有一天会逼疯你的!

                        如果用“事件委托”怎么做呢?我把监听器注册到目录上。当点击某章节时,利用点击事件的冒泡行为,事件会被传递到目录并命中来触发监听器。而且,这是一劳永逸的事情,无论产品经理如何增删章节,都无需再改动了。这不就有时间摸鱼了对吧。

                        在 JavaScript 中,页面中事件处理程序的数量与页面整体性能直接相关。原因有很多。首先每个函数都是对象,都占用内存空间,对象越多,性能越差。其次,为指定事件处理程序所需访问 DOM 的次数会先期造成整个页面交互的延迟。只要在事件处理程序时多注意一些方法,就可以改善页面性能。(这段话摘自《JavaScript 高级程序设计》)

                        举个例子:

                        <!-- 通常将与渲染无关的信息放到 dataset 里面,即 data-* 的形式  -->
                        <div id="directory">
                          Directory
                          <ol>
                            <div class="chapter">Chapter1</div>
                            <li data-page="10">section1</li>
                            <li data-page="20">section2</li>
                          </ol>
                          <ol>
                            <div class="chapter">Chapter2</div>
                            <li data-page="30">section1</li>
                            <li data-page="40">section2</li>
                          </ol>
                        </div>
                        

                        以上示例中,有多组 <ol><li> 的目录,想要点击 <li>(即 section 部分)的时候,打开书本对应页面。

                        根据上述给出的 HTML 示例,事件委托最粗糙、最不灵活、最简单的实现如下:

                        function delegate() {
                          const el = document.getElementById('directory')
                          el.addEventListener('click', e => {
                            const { page } = e.target.dataset
                            if (page === undefined) return
                            // do something...
                            // 处理业务逻辑,比如:
                            console.log(`Please turn to page ${page} of the book.`)
                          })
                        }
                        

                        上述示例,只要点击 <div id="directory"> 及其后代元素都会命中事件处理程序,按需求是点击 <li> 才要执行业务逻辑。像点击 <div class="chapter"> 其实是没有实际意义的。而且明显上面的方法并不灵活。

                        我们再改一下:

                        /**
                         * 事件委托
                         *
                         * @param {Element} el 事件委托的目标元素
                         * @param {string} type 事件类型
                         * @param {Function} fn 监听器
                         * @param {string} selectors CSS 选择器
                         */
                        function delegate({ el, type, fn, selectors }) {
                          el.addEventListener(type, e => {
                            if (!selectors) {
                              fn.call(el, e)
                              return // 请注意不要 return false 避免取消默认行为
                            }
                        
                            // myClosest() 作用:向上查找最近的 selectors 元素。
                            // 1. 内置 closest() 方法兼容性较差,不支持 IE 浏览器;
                            // 2. 内置 closest() 方法内部从 Document 开始检索的,若结合事件委托场景,
                            //    如果从事件绑定元素(含)开始检索,效果更优。
                            // 3. 因而,基于 Element.closest() 稍作修改,并添加到 Element 原型上。
                            if (!Element.prototype.myClosest) {
                              ;(() => {
                                Element.prototype.myClosest = function (s, root) {
                                  let el = this
                                  let i
                                  // 其实改成 root.querySelectorAll(s) 也行,
                                  // 目前这种写法是为了让 root === el 也正常匹配到,
                                  // 但是回头想一下,这种情况还有必要事件委托吗,对吧!自个看着办吧,问题也不大
                                  const matches = root.parentElement.querySelectorAll(s)
                        
                                  do {
                                    i = matches.length
                                    // eslint-disable-next-line no-empty
                                    while (--i >= 0 && matches.item(i) !== el) {}
                                  } while (i < 0 && (el = el.parentElement))
                        
                                  return el
                                }
                              })()
                            }
                        
                            // 若匹配不到 selectors 则返回 null
                            const matchEl = e.target.myClosest(selectors, el)
                        
                            matchEl && fn.call(matchEl, e)
                            // 如果像这样绑定 this,请注意:
                            // 1. fn 的 this 指向 selectors 对应的元素
                            // 2. e.target 仍指向事件目标
                            // 3. e.currentTarget 仍指向监听器绑定元素。
                            // 4. 由于事件对象的 target、currentTarget 属性只读,唯有改变 this 来指向引用 selectors 元素
                          })
                        }
                        

                        按上述方式封装的好处是方便灵活,一些实现思路或注意事项,在相应位置的注释已标注。

                        delegate({
                          el: document.getElementById('directory'),
                          type: 'click',
                          selectors: 'li',
                          fn: e => {
                            const { page } = e.target.dataset
                            console.log(`Please turn to page ${page} of the book.`)
                            // other statements...
                          }
                        })
                        

                        思路就这样,其实很简单,麻烦在于兼容各浏览器而已。若不需要兼容 IE 浏览器,那简直不能太爽了,后面会给出一个简化版。

                        当然上面还不支持 IE8 浏览器。因为 e.targetel.addEventListener 还没兼容处理。请稍等,下一章节结合前面的内容再整合一下。

                        九、最终总结

                        这里会将本文所有的内容都封装在一起,包括 addEventListener()attachEvent()、事件委托以及兼容问题等等。

                        1. 兼容所有浏览器的版本(包括 IE8)

                        先给一个可兼容 IE8 的版本。可以轻松地按现代浏览器的方式去注册、移除事件监听器,以及方便处理冒泡、默认行为等。

                        但前提是,使用 Babel 转换一下以兼容 ES5。

                        function CreateMyEvent({ el, type, fn }) {
                          this.el = el
                          this.type = type
                          this.fn = fn
                          this.listener = function (e) {
                            // 使得 IE8 中,正常用上 target、stopPropagation() 等属性或方法
                            const ev = CreateMyEvent.eventPolyfill(e, el)
                            // 统一 this 指向
                            fn.call(el, ev)
                          }
                          this.added = false // 是否已添加监听器
                        }
                        
                        CreateMyEvent.eventPolyfill = function (e, currentTarget) {
                          // 处理 IE8 兼容性问题
                          const ev = e || window.event
                          if (!ev.stopPropagation) {
                            ev.target = ev.srcElement
                            ev.currentTarget = currentTarget
                            ev.stopPropagation = () => {
                              ev.cancelBubble = true
                            }
                            ev.preventDefault = () => {
                              ev.returnValue = false
                            }
                          }
                          return ev
                        }
                        
                        CreateMyEvent.closestPolyfill = function () {
                          Element.prototype.myClosest = function (s, root) {
                            let el = this
                            let i
                            const matches = root.parentElement.querySelectorAll(s)
                        
                            do {
                              i = matches.length
                              // eslint-disable-next-line no-empty
                              while (--i >= 0 && matches.item(i) !== el) {}
                            } while (i < 0 && (el = el.parentElement))
                        
                            return el
                          }
                        }
                        
                        CreateMyEvent.prototype.addEventListener = function () {
                          if (this.added) {
                            console.warn('Please note that you have added event handler before and will not be added again.')
                            return
                          }
                        
                          const { type, el, listener } = this
                          if (el.addEventListener) {
                            // 为了兼容所有浏览器,这里将 useCapture 设为 false
                            el.addEventListener(type, listener, false)
                          } else if (el.attachEvent) {
                            el.attachEvent('on' + type, listener)
                          } else {
                            el['on' + type] = listener
                          }
                        
                          this.added = true
                        }
                        
                        CreateMyEvent.prototype.removeEventListener = function () {
                          const { type, el, listener } = this
                          if (el.removeEventListener) {
                            el.removeEventListener(type, listener, false)
                          } else if (el.detachEvent) {
                            el.detachEvent('on' + type, listener)
                          } else {
                            el['on' + type] = null
                          }
                        
                          this.added = false
                        }
                        
                        CreateMyEvent.prototype.delegate = function (selectors) {
                          const { el, fn } = this
                        
                          if (!selectors) {
                            this.addEventListener()
                            return
                          }
                        
                          // 在重写 listener 监听器之前,确保移除此前的监听器
                          if (this.added) console.warn('Please note that the previously registered event handler will be deleted and a new event handler will be added.')
                          this.removeEventListener()
                        
                          // 重写监听器
                          this.listener = function (e) {
                            const ev = CreateMyEvent.eventPolyfill(e)
                            // 在 Element 原型上添加 myClosest 方法
                            if (!Element.prototype.myClosest) CreateMyEvent.closestPolyfill()
                            const matchEl = ev.target.myClosest(selectors, el)
                            matchEl && fn.call(matchEl, ev)
                          }
                        
                          // 重新注册监听器
                          this.addEventListener()
                        }
                        
                        

                        使用方式如下:

                        // 创建实例对象
                        const myEvent = new CreateMyEvent({
                          type: 'click',
                          el: document.getElementById('directory'),
                          fn: e => {
                            // 1. e 总是能获取到事件对象。
                            // 2. 阻止冒泡:e.stopPropagation()
                            // 3. 取消默认行为:e.preventDefault()
                            // 4. 唯一要注意的是:
                            //    当使用事件委托时,this 指向 selectors 对应元素;
                            //    其他的均指向事件监听器所绑定的元素,即 e.currentTarget。
                          }
                        })
                        
                        // 注册事件监听器
                        myEvent.addEventListener() // 有效
                        
                        // 重复注册事件监听器,会被阻止(实际上注册同一个也是无效的)。
                        myEvent.addEventListener() // 无效
                        
                        // 移除事件监听器
                        myEvent.removeEventListener() // 有效
                        
                        // 移除后,重新注册事件处理程序
                        myEvent.addEventListener() // 有效
                        
                        // 事件委托,可传入 selectors 作为参数(实质上也就是注册事件处理程序)
                        // 不传入 selectors 参数时,相当于 myEvent.addEventListener() 所以无效
                        myEvent.delegate() // 无效
                        
                        // 事件委托:内部会先移除上一个事件监听器,在重新注册
                        myEvent.delegate('li') // 有效
                        
                        // 事件委托,这将会注册全新的一个事件监听器(同样的上一个会被移除)
                        myEvent.delegate('li') // 有效
                        

                        哎,丑陋的代码......由于 IE8 不支持类似 Object.assgin()Object.defindProperty() 等 ES5 方法,上面只能使用最原始的构造函数去写了。

                        ⚠️ 暂不支持添加多个事件监听器,实际中我暂时想不到需要添加多个事件监听器的场景。实现倒是不难,处理起来也很简单,但我感觉没必要。

                        另外,前面示例是将页面数据定义在 data-* 上的,但由于 IE10 及更低版本浏览器并不支持 Element.prototype.dataset 属性,因此也要处理一下。例如:

                        // 也要用 Babel 转换一下
                        function getDataset(el) {
                          if (el.dataset) return el.dataset
                        
                          const attrs = el.attributes
                          const dataset = {}
                        
                          for (let i = 0, re1 = /^data-(.+)/, re2 = /-([a-z\d])/gi, len = attrs.length; i < len; i++) {
                            // data-camel-case to camel-case
                            const matchName = re1.exec(attrs[i].name)
                            if (!matchName) continue
                            // camel-case to camelCase
                            const name = matchName[1].replace(re2, (...args) => {
                              return args[1].toUpperCase()
                            })
                            // add to dataset
                            dataset[name] = attrs[i].value
                          }
                        
                          return dataset
                        }
                        

                        不需要兼容 IE8 的版本如下,将会使用 class 写法,会简洁很多。辣鸡 IE...

                        2. 兼容现代浏览器版本(包括 IE9 ~ IE11)

                        class CreateMyEvent {
                          constructor({ el, type, fn }) {
                            this.el = el
                            this.type = type
                            this.fn = fn
                            this.listener = fn.bind(el)
                            this.added = false // 是否已添加监听器
                          }
                        
                          static closestPolyfill() {
                            Element.prototype.myClosest = function (s, root) {
                              let el = this
                              let i
                              const matches = root.parentElement.querySelectorAll(s)
                        
                              do {
                                i = matches.length
                                // eslint-disable-next-line no-empty
                                while (--i >= 0 && matches.item(i) !== el) {}
                              } while (i < 0 && (el = el.parentElement))
                        
                              return el
                            }
                          }
                        
                          addEventListener() {
                            if (this.added) {
                              console.warn('Please note that you have added event handler before and will not be added again.')
                              return
                            }
                            const { type, el, listener } = this
                            el.addEventListener(type, listener, false)
                            this.added = true
                          }
                        
                          removeEventListener() {
                            const { type, el, listener } = this
                            el.removeEventListener(type, listener, false)
                            this.added = false
                          }
                        
                          delegate(selectors) {
                            const { el, fn } = this
                        
                            if (!selectors) {
                              this.addEventListener()
                              return
                            }
                        
                            // 在重写 listener 监听器之前,确保移除此前的监听器
                            if (this.added) console.warn('Please note that the previously registered event handler will be deleted and a new event handler will be added.')
                            this.removeEventListener()
                        
                            // 重写监听器
                            this.listener = function (e) {
                              // 在 Element 原型上添加 myClosest 方法
                              if (!Element.prototype.myClosest) CreateMyEvent.closestPolyfill()
                              const matchEl = e.target.myClosest(selectors, el)
                              matchEl && fn.call(matchEl, e)
                            }
                        
                            // 重新注册监听器
                            this.addEventListener()
                          }
                        }
                        

                        到这里,好像就完了,改了好几版,想吐血...

                        The end.

                        ]]>
                        <![CDATA[细读 JS | 执行上下文、作用域]]> https://github.com/tofrankie/blog/issues/263 https://github.com/tofrankie/blog/issues/263 Sun, 26 Feb 2023 12:17:32 GMT 配图源自 Freepik

                        本文主要是深入 JavaScript 执行过程,覆盖了执行上下文、变量对象、作]]> 配图源自 Freepik

                        本文主要是深入 JavaScript 执行过程,覆盖了执行上下文、变量对象、作用域链、This、闭包等内容。

                        但文中可能参杂了很多其他内容,看起来没有那么地清晰。内容主要整合了:《JavaScript 高级程序设计(第四版)》、冴羽大佬的 JavaScript 专题系列、Veda 专题、以及本人此前写过的一些文章。

                        其实我建议你们以下专题会更好:

                        一、JavaScript

                        先讲一些不是很重要的“废话”,可直接跳到第二节。

                        就个人而言,自从喜欢上写文章之后,我经常会在一些概念性上的东西纠结,本着认真负责任的态度,理应如此。举个例子,JavaScript 是什么类型的语言?

                        • JavaScript 是解析型语言?
                        • 还是编译型语言?

                        说实话,虽然作为一个 JSer,我至今也没弄明白 JavaScript 是什么语言。我不知道你们会不会有这些纠结点,反正我会,我想要一个官方且权威的解释,并将它作为我此后的观点。假设将来某一天有人问我:JavaScript 是什么?我可以只字不差地跟他说道。

                        JavaScript 与 ECMAScript 的历史关系

                        我们知道,JavaScript 语言的创造者是 Brendan Eich,也被称为 JavaScript 之父。1995 年任职于 Netscape 公司的 Brendan Eich 接到一项任务:负责开发一个在浏览器上运行的编程语言。没错,那就是“后来”的 JavaScript 语言。

                        其实,起初 JavaScript 语言的名称是 “LiveScript”。由于当时 Java 语言如日中天,为了蹭热度而更名为 JavaScript。原本 JavaScript 这个项目就是 Netscape 公司和 Sun 公司合作开发,用这个名字也无可厚非。

                        在 1996 年 Netscape 公司决定将 JavaScript 提交给行业标准组织 ECMA,希望这种语言能够成为国际标准。次年 6 月发布第一版,即 ECMA-262 号标准。其中 ECMA-262 是一个编号,对应的标准名称为 ECMAScript,换句话说 ECMA-262 和 ECMAScript 是同一个东西的两种不同表达方式。就好比如身份证号码和姓名的关系,只是 EMCA 组织的标准名称不会重名罢了。

                        请注意,ECMAScript 是一种规范和标准,而不是一门编程语言。只是我们习惯将符合 ECMAScript 标准的语言统称为 “JavaScript” 罢了。严谨地的说法应该是:JavaScript 是 ECMAScript 的一种实现。

                        那 JavaScript 究竟是什么类型的语言?

                        由于 JavaScript 编程语言并没有严格意义上的官网,因此并没有一个明确的说法。相对来说,由 Mozilla 基金会维护的 MDN Web Docs 相对权威一些。

                        • JavaScript (JS) 是一种具有函数优先的轻量级,解释型或即时编译型的编程语言。(源自 MDN)

                        • 通常将 JavaScript 归类为“动态”或“解释执行”语言,但事实上它是一门编译语言。(源自《你不知道的 JavaScript》书本)

                        因此,我将会理解为:JavaScript 是一门编译型的语言。当你对 JavaScript 有基础的认知之后,自然就想着深入地了解这门语言,于是第一站来到了 JavaScript 引擎。

                        JavaScript 是怎样解析代码的呢?

                        由于蹩脚的英语水平,没办法直接去看类似 V8 引擎等官网,有点吃力。于是全网搜索各类文章,于是又出现了很多名词/术语,比如:解析器、编译器、预编译、词法分析等等等...

                        由于各类文章参差不齐,而且从英文翻译成中文,可能语义上也会有偏差。加之每个人对 JavaScript 相关术语的理解或认知水平又不一样,有时候真的很难去考究谁对谁错,唯有更相信一些大佬的文章。从这个心路历程来看,学好英文真的很重要。

                        举个例子,我看到很多文章描述 JS 引擎去执行代码的过程,有的说是解析器、有的又称为编译器。当初看到这些真的好烦,究竟谁对谁错呢?谁更严谨呢?由于我又是强迫症,又特想搞清楚。

                        于是我不停地找答案......终于找到了这篇文章:我们应该如何去了解 JavaScript 引擎的工作原理,并截取文中一段话:

                        因此,我也会开始认同:不需要过分去强调 JavaScript 解析引擎到底是什么,了解它究竟做了什么事情就可以了。由于前面把 JavaScript 更倾向于定义为编译型语言,因此,下文将涉及的相关内容称为“编译”、“编译器”、“编译过程”等。当然用“解析”也不能说它错。

                        二、JavaScript 编译与执行

                        举个例子,再简单不过了。

                        var a = 1;
                        

                        通常情况下,我们习惯将 var a = 1; 看作一个声明,而实际上 JS 引擎并不这么认为。它将 var aa = 1 当作两个单独的声明,第一个是编译阶段的任务,而第二个则是执行阶段的任务。

                        在 JavaScript 中,编译阶段和执行阶段包括以下过程:

                        词法分析 -> 语法分析 -> 代码生成 -> 解释执行
                        

                        1. 编译阶段

                        简单地说,编译阶段就是将源代码转换成可执行代码。大致包括以下过程:

                        词法分析 -> 语法分析 -> 代码生成
                        
                        • 词法分析:将源代码(字符串形式)拆分成有意义的代码块,这些代码块被称为词法单元(token)。例如 var a = 1; 被分解成:vara=1;

                        • 语法分析:将词法单元流(数组形式)转换成一个表示程序结构的树,称为“抽象语法树”(Abstract Syntax Tree,AST)。上述示例经过转换之后的抽象语法树如下:

                        • 代码生成:将 AST 转换为可执行代码(一组机器指令)的过程。简单来说,就是创建一个叫作 a 的变量(包括分配内存),并将值 1 存储在 a 中。

                        首先,在词法分析阶段将源代码 var a = 1; 转换为 tokens 如下:

                        [
                          {
                            "type": "Keyword",
                            "value": "var"
                          },
                          {
                            "type": "Identifier",
                            "value": "a"
                          },
                          {
                            "type": "Punctuator",
                            "value": "="
                          },
                          {
                            "type": "Numeric",
                            "value": "1"
                          },
                          {
                            "type": "Punctuator",
                            "value": ";"
                          }
                        ]
                        

                        接着语法分析阶段,将 tokens 转换为 AST(Esprima Parser),结构如下:

                        {
                          "type": "Program",
                          "body": [
                            {
                              "type": "VariableDeclaration",
                              "declarations": [
                                {
                                  "type": "VariableDeclarator",
                                  "id": {
                                    "type": "Identifier",
                                    "name": "a"
                                  },
                                  "init": {
                                    "type": "Literal",
                                    "value": 1,
                                    "raw": "1"
                                  }
                                }
                              ],
                              "kind": "var"
                            }
                          ],
                          "sourceType": "script"
                        }
                        

                        假设在语法分析阶段,若 tokens 无法构成合法的语句时,将会抛出语法错误(SyntaxError),例如:

                        var a = () // SyntaxError: Unexpected token ')'
                        

                        然后,如果前面的过程都没有问题,将会代码生成阶段,来生成可执行代码。

                        上述三个过程是传统编译语言在执行源代码之前经历的三个步骤,统称为“编译”。而 JS 引擎要复杂得多,例如在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化。对此,就本文讨论范围有个大致的认知就行。如有兴趣另行搜索 V8 性能优化的策略。

                        JavaScript 的编译过程,它发生在代码运行之前,它主要做的事情有这些:

                        • 确定函数、变量的词法作用域(由编写的位置来决定)。除了 eval、with 会欺骗词法作用域之外,其他情况词法分析器会保持其作用域不变。
                        • 检查语法是否有误
                        • 为变量声明、函数声明分配内存空间。

                        说那么多,大家更关心的可能是这句话:包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理

                        另外,JavaScript 是按块(<script>)编译的,其中语法分析是对当前块进行通篇的语法检查,若有误则抛出语法错误(SyntaxError),因此也不会执行当前块的任何代码了。接着再对下一个 <script> 进行编译执行。

                        <script>
                          console.log('script1')
                          if (true) else false // 这里语法有误
                        </script>
                        <script>
                          console.log('script2')
                        </script>
                        

                        最终的打印结果是 "script2"。但编译器对第一个 <script> 块语法分析阶段就抛出错误:SyntaxError: Unexpected token 'else'。接着执行下一个块,于是打印出 "script2"

                        <script>
                          function fn1() {
                            console.log('fn1')
                          }
                          fn2() // ReferenceError: fn2 is not defined
                        </script>
                        <script>
                          function fn2() {
                            console.log('fn2')
                          }
                          fn1() // "fn1"
                        </script>
                        

                        上述这个例子,也可以证明 <script> 是按块编译执行的。

                        2. 执行阶段

                        编译阶段完成之后,接着就到代码执行阶段。这个阶段主要涉及到执行上下文的内容。下午再详说...

                        三、JavaScript 作用域

                        1. 作用域是什么?

                        在编写 JavaScript 程序时,例如一个简单的声明语句:

                        var a = 1
                        

                        我们可能会问这些问题:

                        • JS 引擎如何声明一个标识符名称为 a 的变量?
                        • 变量 a 将会被存储在哪里?
                        • 假设对变量 a 进行赋值操作,JS 引擎又是怎样根据标识符 a 找到对应变量的?

                        疑问先保留着,再看个例子:

                        var a = 1
                        
                        function foo() {
                          var a = 'local'
                          consol.log(a)
                        }
                        
                        foo()
                        

                        如果函数 foo 内部有一个同名变量 a,那么调用 foo() 时,语句 console.log(a) 中的标识符 a 是指哪一个?

                        上述示例非常简单,但往往在写项目的时候,我们会定义很多变量和函数,以及嵌套使用等场景。那么这一连串的复杂问题,JS 引擎是怎么去处理它们的呢?它肯定需要约定好一套规则,开发者在编写代码的时候,唯有老老实实地按照这套规则去编写程序,才会得到预期结果。

                        这套规则被称为**“作用域”**,它约定了如何存储变量以及查找变量。然后各家 JS 引擎将会按照这套规则去实现,所以同一份代码在各浏览器下都可以得到相同的效果。

                        2. 了解作用域

                        我们将“作用域”定义为一套规则,这套规则用来管理引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行变量查找。

                        作用域的概念,不是 JavaScript 语言特有的,每一门编程语言都存在的,只是规则不一样而已。作用域有两种工作模型:一是“词法作用域”,二是“动态作用域”。前者也称为“静态作用域”,被大多数编程语言(包括 JavaScript)所采用。词法作用域是在编写代码或者说定义时确定的,而动态作用域是在运行时确定的。

                        需要明确的是,JavaScript 只有词法作用域,并不具有动态作用域。请注意 this 机制只是“像”动态作用域而已。

                        词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。this 也是关注函数如何调用的,所以说它像动态作用域。

                        举个例子:

                        var a = 1
                        
                        function foo() {
                          console.log(a)
                        }
                        
                        function bar() {
                          var a = 2
                          foo()
                        }
                        
                        bar() // 打印 1
                        

                        因此,无论函数 foo 在哪里被调用,都会打印出 1。执行 foo() 时,首先在函数内部查找是否存在(局部)变量 a,如无,再往上一层作用域查找,结果找到了且值为 1,因此会打印出 1。假设一层一层往上查找,直至全局作用域也找不到变量时,就会抛出 ReferenceType 错误。

                        无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。

                        四、执行上下文栈

                        我们书写的(源)代码会被编译器进行处理,并生成“可执行代码”,接着去执行它们。整个 JavaScript 程序的编译及执行过程,都由 JS 引擎来协调处理。

                        前面提到,函数的作用域是由其声明的位置决定的。那么我们一个项目中编写的函数会非常多,那么 JS 引擎如何管理它们呢?

                        JS 引擎在执行代码之前,会创建一个执行上下文栈,将用于管理执行上下文。

                        名词说明:

                        • 执行上下文栈,英文 Execution Context Stack,ECS。有些文章称为“调用栈”。
                        • 执行上下文(Execution Context,EC),简称上下文。执行上下文主要分为两类,一个是全局执行上下文(简称全局上下文),一个是函数执行上下文(简称函数上下文)。

                        我们知道,栈的特点是先进后出、后进先出,而且栈只能在栈顶进行插入(push)、删除(pop)操作。

                        在 JavaScript 中,代码执行及执行上下文栈的变化过程,大致如下:

                        (1)当 JS 引擎开始执行代码的时候,总会最先遇到全局代码,并产生一个全局上下文(GlobalContext,浏览器环境为 window 对象),并在执行上下文栈的栈顶插入。

                        (2)接着,每执行一个函数时,就会创建一个函数上下文(FunctionalContext),并插入栈顶。

                        (3)当一个函数执行完毕,该函数上下文就会从栈顶删除。

                        (4)往后代码执行就是 2、3 步骤周而复始的过程...

                        (5)请注意,在页面销毁之前,执行上下文栈栈底永远会保留着一个 GlobalContext。直至页面销毁(关闭页面或退出浏览器)才会被删除,自然执行上下文栈也消失了。

                        举个例子 🌰:

                        function baz() {
                          console.log('baz')
                        }
                        
                        function bar() {
                          console.log('bar')
                          baz()
                        }
                        
                        function foo() {
                          console.log('foo')
                          bar()
                        }
                        
                        foo()
                        

                        然后代码执行过程,执行上下文栈变化如下:

                        // 请注意,如下是伪代码,栈跟数组是两种不同的结构。
                        // 假设用一个数组来模拟执行上下文栈,数组的第一项为栈底,最后一项为栈顶。
                        // 恰好数组的 push()、pop() 方法分别是在末尾添加一项、删除最后一项。刚好符合栈操作特点。
                        
                        // 1. 代码执行之前,JS 引擎创建一个执行上下文栈(用 ECStack 表示)
                        ECStack = []
                        
                        // 2. 执行全局代码,在 ECStack 插入全局上下文
                        ECStack.push({ <GlobalContext>: window })
                        
                        // 3. 当调用函数 foo(),往 ECStack 插入 foo 函数上下文
                        ECStack.push({ <FunctionalContext>: foo })
                        
                        // 4. 执行 foo() 函数,里面又会调用函数 bar(),又插入 bar 函数上下文
                        ECStack.push({ <FunctionalContext>: bar })
                        
                        // 5. 执行 bar() 函数,里面又调用了函数 baz(),又插入 baz 函数上下文
                        ECStack.push({ <FunctionalContext>: baz })
                        
                        // 6. 由于 baz 内没有调用其他函数了,当它执行完后,它的函数上下文会被从栈顶删除
                        ECStack.pop({ <FunctionalContext>: baz })
                        
                        // 7. 同上
                        ECStack.pop({ <FunctionalContext>: bar })
                        
                        // 8. 同上
                        ECStack.pop({ <FunctionalContext>: foo })
                        
                        // 9. 当函数 foo 也执行完之后,全局上下文并不会被删除,ECStack 永远保留着全局上下文
                        ECStack = [{ <GlobalContext>: window }]
                        
                        // 10. 直到将来某个时刻页面销毁,全局上下文从栈顶删除,ECStack 也会被 JS 引擎回收。
                        

                        相信以上过程大家理解起来都应该很轻松。让大家困惑的可能是这些 ECStack 栈有什么用?不急,一步一步来...

                        三、执行上下文

                        前面提到,代码执行的时候会在执行上下文栈中插入执行上下文,那么这个执行上下文具体做了什么工作呢?

                        执行上下文,分为全局上下文和函数上下文两类。

                        我们可以将每个执行上下文,简单地理解为一个 JavaScript 对象,该对象有一系列的属性(称为上下文状态),主要有三个属性:

                        • 变量对象(Variable Object,VO)
                        • 作用域链(Scope Chain)
                        • this

                        1. 变量对象

                        在变量对象上,存储了当前上下文中声明的变量或函数。全局上下文与函数上下文的变量对象,稍有不同。

                        全局上下文

                        全局上下文的变量对象,就是宿主环境的顶层对象 globalThis。比如,浏览器中为 window 对象,Node.js 中是 global 对象。

                        需要注意的是,一是全局上下文的变量对象不含 arguments 属性。二是通过 varfunction 关键字声明的变量或函数,都会成为 globalThis 对象的属性,而 letconst 则不会。

                        由于 letconst 全局下的特性,我对“全局的变量对象等于顶层对象”这句话有所保留。但我们只要知道,无论通过 varlet 还是 const 声明的变量,都包含在其变量对象中即可。

                        函数上下文

                        在函数上下文中,我们使用活动对象(Activation Object,AO)来表示变量对象。其实变量对象是无法通过代码来访问的,当进入一个执行上下文中,这个执行上下文的变量对象才会被激活,也只有活动对象上的各种属性才能被访问。可以理解为 AO 就是 VO 的一个别名。

                        活动对象是在进入函数上下文的时候才会被创建的,它通过函数的 arguments 属性进行初始化,arguments 属性值是 Arguments 对象。

                        执行过程

                        执行上下文分成两个阶段进行处理:

                        1. 进入执行上下文
                        2. 代码执行

                        当进入执行上下文时,这时还没执行代码。内部会创建一个活动对象,它包括:

                        AO = {
                          1. 函数的所有形参(函数上下文):
                            * 由形参名称和对应值组成的一个变量对象的属性被创建
                            * 若没有实参,对应属性值设为 undefined
                        
                          2. 函数声明
                            * 由函数名称和对应值组成一个变量对象的属性被创建
                            * 若函数名称与形参名称重名,那么函数声明创建的属性将会覆盖形参所创建的属性
                        
                          3. 变量声明
                            * 由变量名称与对应值(undefined)组成的一个变量对象的属性被创建
                            * 若变量名称与形参名称或函数名称存在重名,那么此变量声明将会被忽略。
                        }
                        

                        举个例子:

                        function foo(a) {
                          var b = 2
                          function c() { }
                          var d = 3
                        }
                        
                        foo(1)
                        

                        进入 foo() 的执行上下文后,这时 AO 如下:

                        AO = {
                          arguments: {
                            0: 1,
                            length: 1
                          },
                          a: 1,
                          b: undefined,
                          c: ƒ c(),
                          d: undefined
                        }
                        

                        “进入执行上下文”的过程完成之后,接着开始按顺序执行代码,并执行对应的操作。当函数 foo() 执行完之后,这时 AO 如下:

                        AO = {
                          arguments: {
                            0: 1,
                            length: 1
                          },
                          a: 1,
                          b: 2,
                          c: ƒ c(),
                          d: 3
                        }
                        

                        请注意,在非严格模式下,arguments 会追踪变量的变化,而严格模式下则不会。例如:

                        function foo(a) {
                          a = 2
                        }
                        foo(1)
                        // 当 foo 执行完之后,arguments 为 { 0: 2, length: 1 }
                        // 假设 foo 处于严格模式下,arguments 始终为 { 0: 1, length: 1 }
                        

                        变量对象小结

                        1. 进入全局上下文的变量对象是顶层对象。
                        2. 进入函数上下文的变量对象(或活动对象)初始化只包括 Arguments 对象。
                        3. 在进入执行上下文时,会给活动对象添加形参、函数声明、变量声明等初始的属性。
                        4. 在代码执行阶段,会再次修改活动对象的属性值。

                        2. 作用域链

                        未完待续...

                        ]]>
                        <![CDATA[JavaScript 获取 URL 参数]]> https://github.com/tofrankie/blog/issues/262 https://github.com/tofrankie/blog/issues/262 Sun, 26 Feb 2023 12:09:31 GMT 配图源自 Freepik

                        前面会介绍一些乱七八糟的东西,请忍耐一下。

                        URI、URL、U]]> 配图源自 Freepik

                        前面会介绍一些乱七八糟的东西,请忍耐一下。

                        URI、URL、URN

                        提到这三个货,循例丢一张表出来,麻烦用余光一扫而过就算了。

                        简称 全称 中文名称
                        URI Universal Resource Identifier 统一资源标志符
                        URL Universal Resource Locator 统一资源定位符
                        URN Universal Resource Name 统一资源名称

                        网上充斥着类似上面表格中的名词解释,还有诸如以下的言论:

                        • URI 包括 URL 和 URN。
                        • URL 可以是 URI,但 URI 不一定是 URL,它可能是 URN。

                        说真的,这些解释说了跟没说一样,我相信 99% 的人都知道但没用。还记得此前写过一篇文章介绍三者的定义及区别,现在再回头看感觉简直是浪费时间了。

                        以下这句话,从某乎看到的,我觉得简单明了:

                        原来 URI 包括 URL 和 URN ,后来 URN 没流行起来,导致几乎目前所有的 URI 都是 URL。

                        因此,在 99.9% 的情况下,我们看到的 URI 全都是 URL,没必要理会 URN 了。而我们在浏览器中输入的 Web 地址,指的就是 URLIdentifying resources on the Web)。

                        URL

                        由于本节部分内容节选自 Node.js API 之 URL,因此会多了一些额外的内容。

                        网址字符串是包含多个有意义组件的结构化字符串。 下面提供了 WHATWG 和 旧版 API 之间的比较。 在网址 'https://user:pass@sub.example.com:8080/p/a/t/h?query=string#hash' 上方显示的是由旧版 url.parse() 返回的对象的属性。 下方则是 WHATWG URL 对象的属性。

                        图中 "" 行中的所有空格都应被忽略。它们纯粹是为了格式化。

                        对于浏览器(前端)而言,注意几点:

                        • 在 Web 浏览器中应以 WHATWG 网站标准为准。
                        • WHATWG 网址的 origin 属性包括 protocolhost,但不包括 usernamepassword
                        • 关于 usernamepassword 虽然一些浏览器可能仍然支持它,但它可能已经从相关的 Web 标准中删除,可能正在被删除,或者可能只是为了兼容性目的而保留。

                        因此,我们来简化一下:

                        image.png

                        三、获取 URL 参数

                        前面铺垫了那么多,其实本文的话题是获取 URL 上的参数,不废话了。

                        参数通常存在于 window.location.searchwindow.location.hash 上,考虑一些特殊情况就好了:

                        const queryUrlValue = key => {
                          if (!key) return ''
                        
                          // 考虑到 URL 上存在中文编码问题,
                          // 例如:http%3A%2F%2Fui.cn%3F%E4%BD%9C%E8%80%85%3D%E8%B6%8A%E5%89%8D%E5%90%9B
                          const url = decodeURIComponent(window.location.href)
                        
                          // 匹配正则表达式
                          const re = new RegExp(`[?|&]${key}=([^&]+)`, 'g')
                          const matchResult = re.exec(url)
                          if (!matchResult) return ''
                        
                          let value = matchResult[1]
                          if (value.includes('#')) {
                            // 考虑到匹配结果可能含 hash 值,比如:
                            // http://ui.cn?state=1#/mine
                            // http://ui.cn?state=1/#/mine
                            const separator = value.includes('/#') ? '/#' : '#'
                            value = value.split(separator)[0]
                          }
                        
                          return value
                        }
                        

                        删掉注释部分,如下:

                        const queryUrlValue = key => {
                          if (!key) return ''
                        
                          const url = decodeURIComponent(window.location.href)
                          const re = new RegExp(`[?|&]${key}=([^&]+)`, 'g')
                          const matchResult = re.exec(url)
                        
                          if (!matchResult) return ''
                        
                          let value = matchResult[1]
                          if (value.includes('#')) {
                            const separator = value.includes('/#') ? '/#' : '#'
                            value = value.split(separator)[0]
                          }
                        
                          return value
                        }
                        

                        已收录在 tofrankie/utils,里面还有一些实用的方法哦!

                        更新(2023.02.26)

                        都 2023 年了,可以考虑使用 URLSearchParams 了。

                        // https://example.com/?name=Jonathan&age=18
                        const params = new URLSearchParams(document.location.search.substring(1))
                        const name = params.get('name') // is the string "Jonathan"
                        const age = parseInt(params.get('age'), 10) // is the number 18
                        

                        The end.

                        ]]>
                        <![CDATA[Thunk 函数与 Generator 函数]]> https://github.com/tofrankie/blog/issues/261 https://github.com/tofrankie/blog/issues/261 Sun, 26 Feb 2023 12:04:20 GMT 配图源自 Freepik

                        关于 Thunk 这个词,其实第一次看到是

                        关于 Thunk 这个词,其实第一次看到是 redux-thunk 库。还长时间内都没有理解 “Thunk” 是什么意思,当初想可能只是类似 Foo、Bar 等,就一个名称罢了。

                        一、Thunk

                        早在上世纪 60 年代 Thunk 函数就诞生了。那时候,编程语言刚起步,计算机学家还在研究,编译器怎么写比较好。其中一个争论的焦点是“求值策略”,即函数的参数到底应何时求值?

                        存在两派意见:

                        • 传值调用(call by value)
                        • 传名调用(call by name)

                        比如,以下示例:

                        var x = 1
                        
                        function fn(m) {
                          return m * 2
                        }
                        
                        fn(x + 4)
                        

                        对于传值调用的话,在进入函数体之前,计算 x + 4 的值(等于 5),再将这个值传入函数 fn。JavaScript、C 语言就是采用这种策略。

                        若对于传名调用的话,直接将表达式 x + 4 传入函数体,只在用到它的时候求值。Haskell 语言采用这种策略。

                        至于“传值调用”和“传名调用”,哪一种比较好?

                        回答是各有利弊。传值调用比较简单,但是对参数求值的时候,实际上没用到这个参数,有可能造成性能损失。

                        var x = 5
                        
                        function fn(m, n) {
                          return n
                        }
                        
                        fn(8 * x * x - 3 * x -1, x)
                        

                        上面示例中,如果采用“传值调用”的策略,函数 fn 的第一个参数是一个复杂的表达式,但是函数体内根本没用到,对这个参数求值,实际上是没必要的。因此,有些计算机科学家倾向于“传名调用”。

                        二、Thunk 函数的含义

                        编译器的“传名调用”实现,往往是将参数放到一个临时函数中,再将这个临时函数传入函数体。这个临时函数就被叫做 Thunk 函数

                        var x = 1
                        function fn(m) {
                          return m * 2
                        }
                        fn(x + 4)
                        
                        // 相当于
                        var thunk = function() {
                          return x + 4
                        }
                        function fn(thunk) {
                          return thunk() * 2
                        }
                        

                        上面的示例中,函数 fn 的参数 x + 4 被一个函数替换了。凡是用到原参数的地方,对于 Thunk 函数求值即可。

                        以下这个是我的疑问?

                        其实我认为,“传名调用”也是有性能影响的,例如:

                        var x = 1
                        function fn(m) {
                          return m * m * 2 // 这里我们调整一下,调用两次参数 m
                        }
                        fn(x + 4)
                        
                        // 按前面的定义,自然就变成如下这样
                        var thunk = function() {
                          return x + 4
                        }
                        function fn(thunk) {
                          return thunk() * thunk() * 2 // 执行了两遍 thunk 函数
                        }
                        

                        上面示例中,fn 函数的参数 m 被不止一次地使用,那不是会执行多次 thunk 函数吗?如果这样同样会有性能问题吧。还是说,使用“传名调用”的策略的时候,编译器内部在第一次计算得到结果后,会记录起来。若再有引用,直接取上一次的计算结果,而不是重复执行 Thunk 函数?求解,谢谢!!!

                        三、JavaScript 语言的 Thunk 函数

                        JavaScript 是传值调用,它的 Thunk 函数含义有所不同。

                        在 JavaScript 语言中,Thunk 函数替换的不是表达式,而是多参数函数,将其替换成一个只接受回调函数作为参数的单参数函数。

                        以下是 Node.js 中 fs 模块的 readFile 方法,它是一个多参数函数。

                        fs.readFile('data.json', {}, (err, data) => {
                          // do something...
                        })
                        

                        那么 Thunk 版的 readFile 如下:

                        function thunk(path, options) {
                          return function (callback) {
                            return fs.readFile(path, options, callback)
                          }
                        }
                        
                        var readFileThunk = thunk('data.json', {})
                        readFileThunk((err, data) => {
                          // do something...
                        })
                        

                        上面的示例中,经过 thunk 函数转换处理,它变成了单一参数函数,只接受回调函数作为参数。这个 thunk 函数就被叫做 Thunk 函数。

                        任何函数,只要参数有回调函数,就能写成 Thunk 函数的形式。

                        const thunk = function(fn) {
                          return function(...args) {
                            return function(callback) {
                              fn.apply(this, ...args, callback)
                            }
                          }
                        }
                        

                        使用上面的转换器,生成 fs.readFile 的 Thunk 函数。

                        const readFileThunk = thunk(fs.readFile)
                        readFileThunk('data.json', {})((err, data) => {
                          // do something...
                        })
                        

                        看到这里,还是没懂这么做意义在哪,感觉多此一举对吧。应用场景后面会讲到。

                        四、Thunkify 模块

                        thunkify 模块,将常规 Node 函数转换为返回 Thunk 的函数,这对于基于生成器的流程控制非常有用,例如将其应用于 co

                        使用方式非常地简单,如下:

                        $ npm i thunkify
                        
                        var thunkify = require('thunkify')
                        var fs = require('fs')
                         
                        var read = thunkify(fs.readFile)
                        read('data.json', {})((err, data) => {
                          // do something...
                        })
                        

                        同样 thunkify源码也很简单,如下:

                        /**
                         * Wrap a regular callback `fn` as a thunk.
                         *
                         * @param {Function} fn
                         * @return {Function}
                         * @api public
                         */
                        function thunkify(fn) {
                          return function () {
                            var args = new Array(arguments.length);
                            var ctx = this;
                        
                            for (var i = 0; i < args.length; ++i) {
                              args[i] = arguments[i];
                            }
                        
                            return function (done) {
                              var called;
                        
                              args.push(function () {
                                if (called) return; // 确保回调函数 done 只会执行一遍
                                called = true;
                                done.apply(null, arguments);
                              });
                        
                              try {
                                fn.apply(ctx, args);
                              } catch (err) {
                                done(err);
                              }
                            }
                          }
                        };
                        

                        思路跟前面的大致相同,区别在于它针对回调函数多了一个检查机制,确保回调函数(即源码中的 done)最多只会执行一遍。比如:

                        function fn(x, y, cb) {
                          const sum = x + y
                          cb(sum)
                          cb(sum)
                        }
                        
                        const testThunk = thunkify(fn)
                        testThunk(1, 2)(sum => {
                          console.log(sum) // 3,且只会打印一次
                        })
                        

                        这个检查机制,像给前面提出的关于“传名调用”可能存在性能损耗问题,提供了一种思路。但在 JavaScript 中 Thunk 的理解,跟开头提到的 Thunk 函数是有区别的,所以疑问点还在!

                        五、Generator 与 Thunk

                        我们都知道 Generator 函数,需要自己实现执行器,自动去执行生成器。

                        在我认为 Generator 函数,主要用途是自定义迭代器、异步编程。在我印象中,实际项目里几乎没遇到需要自定义迭代器的。跟多的是异步编程中用到 Generator 函数去控制。

                        但后面 ES2017 标准中,又引入了语法、语义更好的 Async/Await,但尽管如此,也不影响 Generator 的强大和重要性。因为 Async 函数本质上就是 Generator 函数的语法糖而已。

                        举个例子,

                        const thunkify = require('thunkify')
                        const fs = require('fs')
                        const readFileThunk = thunkify(fs.readFile)
                        
                        function* generatorFn() {
                          const data1 = yield readFileThunk('./js/data.json', 'utf-8')
                          console.log('data1', data1)
                          const data2 = yield readFileThunk('./js/data.json', 'utf-8')
                          console.log('data2', data2)
                        }
                        

                        利用 Thunk 函数,我们就可以实现一个 Generator 执行器了,如下:

                        function runAuto(genFn) {
                          const gen = genFn()
                          const step = iteratorResult => {
                            const { done, value } = iteratorResult
                        
                            if (done) return
                        
                            // iteratorResult.value 就是 Thunk 函数,
                            // 即 readFileThunk('data.json', 'utf-8') 返回值,它返回一个 Thunk 函数。
                            value((err, data) => {
                              // 只要在其回调中,执行下一步操作,就能达到按“顺序”执行的效果,
                              // 为了使 yield 得到对应的值,需要在 next 方法中传入 data。
                              step(gen.next(data))
                            })
                          }
                        
                          step(gen.next())
                          // 注意,若 Generator 函数中存在异步操作是不能使用类似 while 等语句去迭代其实例的,
                          // 例如本实例中,若使用 while 语句就会不断地调用 fs.readFile 读取文件,导致报错!
                        }
                        

                        调用方式如下:

                        runAuto(generatorFn)
                        // 依次打印出
                        // data1 "data.json's value"
                        // data2 "data.json's value"
                        

                        一般函数内含有 yield 关键字表示含有异步操作,示例中 readFileThunk 就是异步操作。若一个函数内没有异步操作,没必要用 yield 表达式,更没必要使用 Generator 函数(自定义迭代器除外)。

                        Thunk 函数与 Generator 能联系在一起的挈机,就是因为 Thunk 函数接受一个回调函数作为参数。刚好 Generator 函数某个异步操作的结果与往后的代码有关联,需要在异步操作的回调函数中执行生成器的 next() 方法,那么 yield 关键字后面跟着一个 Thunk 函数,就能达到按编写“顺序”去执行代码的效果了。

                        前面的 runAuto 方法还有再简化一下:

                        function runAuto(genFn) {
                          const gen = genFn()
                        
                          const step = (err, data) => {
                            const { done, value } = gen.next(data)
                        
                            if (done) return
                        
                            // 怕有人不理解,说明一下:
                            // 注意 value 就是一个 Thunk 函数,即前面的 readFileThunk(),
                            // 它接受一个回调函数,那么我们把 step 传进去就好了。
                            value(step)
                          }
                        
                          step()
                        }
                        
                        // 这里没有去捕获 Generator 内部的异常哈,
                        // 若有需要在 step 内部使用 try...catch 捕获,
                        // 并使用 gen.throw() 抛出对应原因即可。
                        

                        ⚠️ 请注意,如果按照上述 runAuto 去迭代 Generator 函数,其函数体内的 yield 关键字后面必须是 Thunk 函数。否则将可能会报错。

                        thunkify 模块的作者 TJ Holowaychuk 开源了另一模块: co。它允许 yield 后面跟着一个 Thunk 函数或者是 Promise 对象。因为两种思路是相似的,Thunk 是利用其回到,而 Promise 对象则是利用了当状态发生变化,会触发 thencatch 方法的机制。

                        如果使用 co 模块,可以这样用:

                        $ npm i co
                        
                        const fs = require('fs')
                        const co = require('co')
                        const thunkify = require('thunkify')
                        const readFileThunk = thunkify(fs.readFile)
                        
                        function* generatorFn() {
                          const data1 = yield readFileThunk('./js/data.json', 'utf-8')
                          console.log('data1', data1)
                          const data2 = yield readFileThunk('./js/data.json', 'utf-8')
                          console.log('data2', data2)
                        }
                        
                        co(generatorFn)
                        
                        // 依次打印出
                        // data1 "data.json's value"
                        // data2 "data.json's value"
                        

                        注意,使用 co 包装的 Generator 函数的 yield 表达式接受 Thunk 函数Promise 对象。当使用 Promise 对象的形式,co 就充当了类似 Async 函数内部执行器的角色。

                        反正自从 Async/Await 面世之后,我接触到的项目,几乎没有人使用 Generator 函数去封装异步流程了,都是全面拥护 Async 了。我猜这个是不是 co 不再更新的原因,是不是它的使命完成了,哈哈。

                        至于 Async 函数内部执行器是怎么实现的,结合上面的 runAuto 方法,再动下脑子就应该能大致想到了,具体可以看下我的另外一篇文章,文中末尾有介绍。

                        本文到这里,好像就要完了。

                        The end.

                        ]]>
                        <![CDATA[关于 Await、Promise 执行顺序差异问题]]> https://github.com/tofrankie/blog/issues/260 https://github.com/tofrankie/blog/issues/260 Sun, 26 Feb 2023 12:02:42 GMT 配图源自 Freepik

                        背景

                        缘起《

                        背景

                        缘起《8 张图帮你一步步看清 async/await 和 promise 的执行顺序》一文所抛出的话题,本质上就是考察是否完全掌握了 JavaScript 的事件循环机制罢了。

                        不同宿主环境(比如浏览器、Node),JS 的事件循环会稍有不同。本文基于浏览器环境展开讨论。

                        前面文章末尾或评论区提到的,同样一段代码在不同浏览器、或同一浏览器的不同版本,执行顺序存在差异(代码就不贴上来了)。

                        本人亲测结果,在 Chrome 92Safari 14.1.2 执行顺序仍有差异(2021.08)。

                        这种差异会带来什么影响呢?

                        • 实际应用场景几乎没有影响。如果有人在项目中写出这样的代码,你可以去干他了。请不要过分依赖异步操作的顺序。
                        • 一般来说,若再遇到 JavaScript 运行方面的差异,应以最新 Chrome 浏览器的行为为准(跟 Chrome 浏览器的 V8 引擎更新策略有关)。

                        找原因

                        本着寻根问底去找答案。

                        阅读 ECMAScript 标准是最直接、最权威的(Await)。但由于功力不够,没办法完全看懂。于是搜了好久,终于找到了一个相关的问题:

                        该问题中的示例(略微修改)如下:

                        async function foo() {
                          console.log('a')
                          await bar()
                          console.log('b')
                        }
                        
                        async function bar() {
                          console.log('c')
                        }
                        
                        foo()
                        
                        new Promise(resolve => {
                          console.log('d')
                          resolve()
                        }).then(() => {
                          console.log('e')
                        })
                        

                        相信很多同学一下就写出了“正确”的打印顺序:a、c、d、b、e

                        我们执行代码并打印出来看下:

                        ▼ Chrome 92

                        ▼ Safari 14.1.2

                        对比发现,不同浏览器下运行结果竟然不一样,Why?

                        • 当下最新版 Chrome 92 浏览器打印结果为 a、c、d、b、e
                        • 当下最新版 Safari 14.1.2 浏览器打印结果为 a、c、d、e、b
                        • 在 Node 14.16.0 环境下,运行结果同 Chrome 浏览器

                        2026.01.12 使用 Safari 26.1 测试,运行结果同 Chrome 浏览器

                        造成以上差异的根本原因是,ECMAScript 就 Await 标准有所调整,最新规定是 await 将直接使用 Promise.resolve() 相同的语义。正是因为此次调整,导致了不同 JS 引擎或者同一 JS 引擎的不同版本,在解析同一脚本会出现结果的差异。

                        上面示例中 await bar() 的计算结果(指 bar() 返回值)就是一个 Promise 对象。根据 Promise.resolve() 的语法,若参数是一个 Promise 实例对象,将会不做任何修改、原封不动地返回该实例。

                        const p1 = new Promise(resolve => resolve(1))
                        const p2 = Promise.resolve(p1)
                        console.log(p1 === p2) // true
                        // ⚠️ 注意,关于 Promise.resolve() 在 Chrome 与 Safari 表现是一致的。
                        

                        其实无需过分担心这种差异,对平时写项目有什么影响,如果在真正项目写出类似的逻辑,确实该反思一下。但是......面试官可能会问哦,前面文章提到的那道题好像就是头条的面试题。

                        原因剖析

                        这种差异,是 JavaScript 引擎在实现时没有严格遵循 ECMAScript 标准导致的。

                        先明确几点:

                        • Promise 对象的构造方法内属于同步任务,而 Promise.prototype.then() 才属于异步任务(微任务,它的执行顺序后于同步任务)
                        • Promise.resolve() 方法,若参数为 Promise 对象,将会直接返回该对象,而不是返回一个全新的 Promise 对象。
                        • 只有当 Promise 对象的状态发生变化,才会被放入微任务队列。

                        上面的示例中 acd 的顺序都没有争议,因此我们简化一下示例:

                        // 其中 p1、p2 都是状态为 fulfilled 的 Promise 对象
                        async function foo() {
                          await p1
                          console.log('b')
                        }
                        
                        foo()
                        
                        p2.then(() => {
                          console.log('e')
                        })
                        

                        关键点在于 await p1 的语义是什么?一般而言,我们可以把:

                        async function foo() {
                          await p1
                          console.log('b')
                        }
                        

                        理解为:

                        function foo() {
                          return RESOLVE(p1).then(() => {
                            console.log('b')
                          })
                        }
                        

                        按目前的标准定义 RESOLVE(p1) 等同于 Promise.resolve(p1),因此 RESOLVE(p1) 结果就是 p1。根据代码逻辑可知 p1p2 更早地放入微任务队列。本着先进先出的原则,会先执行微任务 p1,后执行微任务 p2,因此先后打印出 be

                        但是旧版的 JS 引擎在实现 RESOLVE(p1) 的问题上,与当前标准有微妙而重要的区别。区别在于,即使 p1 是一个 Promise 对象,RESOLVE(p1) 仍会返回一个全新Promise 对象(假设为 p3)。

                        换句话说,就是执行 p1.then() 时,又产生了一个微任务 p3,并放入微任务队列。还是本着先进先出的原则,接着执行微任务 p2 并打印 e。等 p2 执行完毕,接着执行微任务 p3,然后打印出 b。因此先后顺序是 eb

                        function foo() {
                          return RESOLVE(p1).then(() => {
                            console.log('b')
                          })
                        }
                        
                        // 相当于
                        function foo() {
                          return new Promise(resolve => resolve(p1)) // 相当于微任务 p1
                            .then(() => { // 相当于微任务 p3
                              console.log('b')
                            })
                        }
                        

                        虽然我认为自己懂 Async 内部执行器的执行过程,但是我自认为对本案例解释得不够好。就是那种“懂但不知道怎么表达出来”的感觉。如果看懵了的话,建议直接看贺老的回答

                        结论

                        综上,不同浏览器下执行顺序不一样,应该就是 JS 引擎(其中 Chrome、Node 是 V8 引擎,而 Safari 是 JavaScriptCore 引擎。)底层实现 await 语法的方式略有不同。若严格遵循 ECMAScript 标准的话, 执行结果与最新的 Chrome 浏览器应该是一致的。

                        前面提到若有差异,一般以最新版本的 Chrome 为准,原因是:Chrome 浏览器每次升级都会同时更新到 V8 的最新版。而 Node 更新小版本时,V8 也只更新小版本,只有 Node 更新大版本时才会更新 V8 大版本。所以,绝大部分时候 Node 的 V8 会比同时期的 Chrome 的 V8 要落后。

                        参考链接

                        ]]>
                        <![CDATA[细读 ES6 | async/await]]> https://github.com/tofrankie/blog/issues/259 https://github.com/tofrankie/blog/issues/259 Sun, 26 Feb 2023 11:59:49 GMT 配图源自 Freepik

                        前一篇文章中,最后提到 Generator 的应用,很实际场景可能需要自实现一个 Generator 函数执行器。因此,用起来还是很麻烦的。

                        现在有另外一个替代方法,那就是 ES7 标准中引入的 async 函数,它使得异步操作变得更加方便。

                        Async 函数,其实就是 Generator 函数的语法糖。

                        一、语法

                        定义一个 Async 函数语法非常简单,在函数名称之前加上 async 关键字即可。

                        async function foo {
                          // 内部的 await 语句是可选的
                        }
                        
                        // ⚠️ Async 函数注意点:
                        // 1. 函数体内部的 await 语句是可选的;
                        // 2. 当内部含有 await 语句时,表示有异步操作;
                        // 3. 针对类似 let a = await 1 的语句,语法上是允许的,但这里使用 await 是无意义的;
                        // 4. 针对同步代码,只要类似 let a = 1 这样写即可,无需使用 await 关键字。
                        

                        也可以按以下方式去定义:

                        // 函数声明形式
                        async function foo {}
                        
                        // 函数表达式形式(匿名或具名均可)
                        const bar = async function () {}
                        
                        // 箭头函数形式
                        const baz = async () => {}
                        
                        // Class 的方法
                        class Foo {
                          async sayHi() {}
                          getName = async () => {}
                          // 语法没问题,但两者的区别是:
                          // sayHi 方法挂载到 Foo 原型上,而 getName 方法则是挂载到实例对象上
                        }
                        

                        Async 函数总返回给一个 Promise 对象。

                        因此,调用方式也很忒简单。相比 Generator 函数,简直不要太爽了。

                        foo()
                          .then(res => { /* res 为 foo 函数的 return 值 */ })
                          .catch(err => { /* err 为 foo 函数的异常原因 */ })
                        

                        async 函数内部 return 语句返回的值,会成为 then() 方法回调函数的参数,即 Promise 对象的状态变为 fulfilled。若无显式的 return 语句,相当于 return undefined,自然 then() 方法接收到的参数也就是 undefined

                        若内部抛出错误,会导致返回的 Promise 对象变为 rejected 状态,从而被 Promise 对象的 catch() 方法捕获到。

                        二、特点

                        Async 函数是 Generator 函数的语法糖,它对 Generator 函数的改进,体现在以下四点:

                        • 内置执行器

                        我们都知道,Generator 函数的执行必须依赖执行器,执行器内部就是不断执行生成器的 next() 方法的过程。所以才有了 co 模块。而 async 函数则内置了执行器。调用方法也只需要跟普通函数那样,一行就行。

                        // Async 函数
                        async function foo() { /* do something... */ }
                        foo()
                        
                        // Generator 函数
                        function* bar() { /* do something... */ }
                        const gen = bar()
                        gen.next() // 这才开始执行 Generator 函数体内的代码
                        // ...
                        
                        • 更好的语义

                        asyncawait,比起星号 *yield,语义更清晰。async 表示函数里有异步操作,await 表示紧跟在后面的表达式需要等待结果。

                        // Async 函数
                        async function foo() {
                          let a = await 1
                          let b = await 2
                          return b
                          // 直接调用 foo() 且 a、b 的值对应为 1、2
                        }
                        foo() // Promise { <fulfilled>: 2 }
                        
                        // Generator 函数
                        function* bar() {
                          let a = yield 1
                          let b = yield 2
                          return b
                          // 若按如下方式调用,a、b 的值均为 undefined,而非对应为 1、2
                        }
                        const gen = bar()
                        gen.next() // { done: false, value: 1 }
                        gen.next() // { done: false, value: 2 }
                        gen.next() // { done: true, value: undefined }
                        
                        • 更广的适用性

                        co 模块约定,yield 命令后面只能是 Thunk 函数或 Promise 对象,而 async 函数的 await 命令后面,可以是 Promise 对象或非 Promise 的值(原始值或引用值均可,但这时内部会自动使用 Promise.resolve() 转换为 Promise 对象)。

                        • 返回值是 Promise 对象

                        async 函数的返回值总是 Promise 对象,这比 Generator 函数的返回值是 Iterator(迭代器)对象方便多了。你可以用 Promise.prototype.then() 方法指定下一步的操作。

                        三、基本用法

                        async 函数返回的 Promise 对象,必须等到内部所有await 命令后面的 Promise 对象执行完(即状态变为 fullfilled),且内部不发生错误的情况下,最终 async 函数返回的 Promise 对象才会变成 fulfilled 状态,从而触发 Promise 对象的 then() 方法的回调函数。

                        若函数内部 await 后面的某个 Promise 对象变为 rejected 状态(且函数内未使用 try...catch 捕获处理),或者函数内部出现抛出错误,将会停止执行函数体内后面的代码,并使得最终 async 函数返回的 Promise 对象会变成 rejected 状态,从而触发 catch() 的回调函数。

                        看着有点像 Promise.all() 方法,但两者是有区别的,这里先不展开,下文再详说。

                        看一个简单的例子:

                        async function request() {
                          const response = await fetch('/user/1')
                          const res = await response.json()
                          return res
                        }
                        
                        // 如果使用 Promise 是这样的,无论在语义和写法上都不如 async 函数清晰、简洁。
                        // 而且这例子只有一个异步操作,若存在多个,那 then 方法真的不忍直视。
                        // function request() {
                        //   return fetch('/user/1')
                        //     .then(response => response.json())
                        //     .then(res => res)
                        // }
                        

                        然后按照下面那样去调用即可。

                        request()
                          .then(res => {
                            console.log(res)
                            // do something...
                          })
                          .catch(err => {
                            console.warn(err)
                            // 处理异常
                            // 例如网络问题,导致 fetch 请求出错了,自然 response (它是一个 Promise 对象)的结果
                            // 就不是一个 JSON 数据,那么调用 response.json() 解析就会出错。(这时可使用 response.text() 进行解析)
                            // 使得 request 函数停止往下执行,且返回的 Promise 状态变为 rejected,
                            // 自然就会被这里的 catch 捕获到。错误信息可能是:SyntaxError: Unexpected token < in JSON at position 0
                          })
                        

                        1. await 命令

                        其中 await 关键字,目前只能在 async 函数内部使用。

                        但未来就不一定了。现在有一个语法提案,允许在模块顶层独立使用 await 命令,目前以进入 Stage-4 阶段,相信在不久的将来就能正式纳入 ECMAScript 标准了。关于它的用法可看:Top-level await

                        正常情况下,await 关键字后面应该接着一个 Promise 对象,并返回该对象的结果。如果不是 Promise 对象,就直接返回对应的值。

                        async function foo() {
                          return await 1
                          // 相当于 return 1
                          // 
                          // 对于 `await 1` 函数内部的执行器,其实做的事情是 Promise.resolve(1),
                          // 而 Promise.resolve() 的作用就是将某个值转化为 Promise 对象。
                          // 关于 Promise.resolve() 可看文章:https://github.com/tofrankie/blog/issues/256
                        }
                        
                        // ⚠️ 对于非 `Promise` 对象,没必要使用 `await` 关键字。
                        

                        还有一种情况,就是 await 关键字后面接了一个 thenable 对象,那么 await 会将其当做是 Promise 对象。thenable 对象是指该对象上实现了 then() 方法(本身或原型上均可)。

                        const thenable = {
                          then(resolve, reject) {
                            resolve('abc')
                            // 注意,then 方法中要使用 resolve 或 reject 去改变状态,
                            // 否则 await thenable 的状态一直会是 pending,
                            // 由于 await 一直未等到 thenable 对象的状态发生变化,
                            // 因此 foo() 返回的 Promise 对象也将永远停留在 pending 状态,
                            // 它只能苦苦地等待有朝一日 thenable 状态能发生改变
                          }
                        }
                        
                        async function foo() {
                          return await thenable // await 会把 thenable 当作一个 Promise 对象去处理
                        }
                        
                        foo().then(res => {
                          console.log(res) // "abc"
                        })
                        

                        一旦遇到 await 后面的 Promise 对象为 rejected 状态的情况,会终止后面代码的执行。例如:

                        async function foo() {
                          await Promise.reject('some errors...')
                          let a = 'any' // 这一行及后面的代码,并不会被执行
                          return a
                        }
                        
                        foo().catch(err => {
                          console.warn(err) // "some errors..."
                        })
                        

                        这些异常都可以使用 try...catch 去捕获,下面会讲到。

                        2. 错误处理

                        async 函数的语法规则总体上比较简单,难点是错误处理机制。

                        前面也提到过,一旦 async 函数内部某个 Promise 对象状态变为 rejected,或者存在语法错误,或者主动抛出错误,会停止执行函数体内部的余下代码,并使得 async 函数返回的 Promise 对象改变状态 rejected

                        举个例子:

                        async function foo() {
                          await Promise.reject('error') // 表示一个异步操作
                          let a = 'any'
                          return a
                        }
                        
                        // 如何使其正常调用 then 方法
                        foo().then(res => {
                          console.log(res) // "any"
                        })
                        

                        假设异步操作的结果成功与否,不影响函数最终的结果,使其正常执行到最后并返回结果,有几种处理方式:

                        // 方式一:利用 try...catch 语句
                        async function foo() {
                          try {
                            await Promise.reject('error')
                          } catch (e) {
                            console.warn(e) // "error"
                            // 错误处理...
                          }
                          let a = 'any'
                          return a
                        }
                        
                        // 方式二:如果是 Promise 对象,可以用 catch 方法捕获
                        async function foo() {
                          await Promise.reject('error').catch(err => {
                            console.warn(err) // "error"
                            // 错误处理...
                            // 只要这里面不再抛出错误,await 拿到的 Promise 对象状态为 fulfilled
                          })
                          let a = 'any'
                          return a
                        }
                        

                        以上示例中,只有一个异步操作,这种情况下也可以直接采用 Promise 的写法去处理。一般情况下,使用到 async/await 的写法,表示函数体内部会存在多个异步操作,通常的错误处理方式是:利用一个 try...catch 语句将整个函数体包裹起来。

                        async function request() {
                          try {
                            await fetch('/user/1')
                            await fetch('/user/2')
                            await fetch('/user/3')
                          } catch (e) {
                            // 捕获异常,并做错误处理
                          }
                        }
                        

                        四、Async 函数应用

                        1. 实现重复请求

                        此前另一篇文章提到过,可以 async 函数结合 for 循环、try...catch 可以实现多次重复尝试的效果。

                        async function request(url) {
                          let res
                          let err
                          const MAX_NUM_RETRIES = 3
                        
                          for (let i = 0; i < MAX_NUM_RETRIES; i++) {
                            try {
                              res = await fetch(url).then(response => response.json())
                              break
                            } catch (e) {
                              err = e
                              // Do nothing and make it continue.
                            }
                          }
                        
                          if (res) return res
                          throw err
                        }
                        
                        request('/user/1')
                          .then(res => {
                            console.log('success')
                          })
                          .catch(err => {
                            console.log('fail')
                          })
                        

                        2. 不要在 forEach 中使用 async/await

                        它可能得不到预期结果,在另一篇文章也详细地分析过了。

                        如果使用 promiseasync 函数作为 forEach() 等类似方法的 callback 参数,最好对造成的执行顺序影响多加考虑,否则容易出现错误。

                        举个例子,打印 sum 并不会得到预期的结果 6,而是 3

                        // PS:实际场景肯定不会这样去求和,这里只是为了举例而举例
                        function foo() {
                          let sum = 0
                          const arr = [1, 2, 3]
                          const sumFn = (a, b) => a + b
                        
                          arr.forEach(async item => {
                            sum = await sumFn(sum, item)
                          })
                        
                          setTimeout(() => {
                            console.log(sum) // 3
                            // 注意,这里打印不能去掉 setTimeout,否则打印结果永远是 0。
                          })
                        }
                        
                        foo()
                        

                        由于 await anything 表达式(这里 anything 表示任意值)相当于使用了 Promise.resolve()anything 包裹起来,其中 Promise 属于异步任务(微任务),它会在同步任务执行完之后才会去执行。

                        当执行第一次循环,先执行 forEach 的回调函数,遇到 await sumFn(sum, 1),会执行 sumFn 函数计算结果,所以变成了 sum = await 1。由于是异步,会暂时 Hold 将其放入微任务队列,因此 sum 暂时不会被重新赋值,它仍是 0;接着执行下一次循环,同理变成 sum = await 2,又被 Hold 住;再下一次循环同理变成 sum = await 3。至此 forEach 的三次回调函数执行完毕,接着继续往下走,遇到 setTimeout(属于异步任务中的宏任务),由于延迟时间为 0 会直接放入任务队列,它将会在下一次宏任务中执行。

                        至此,同步任务已执行完毕,紧接着,会依次执行刚刚被 Hold 住的三个微任务(分别是 sum = 1sum = 2sum = 3),因此 sum 变成了 3。微任务执行完毕之后,(由于本示例中没有 UI Render)立刻会执行下一次宏任务,即 console.log(sum),因此打印结果为 3

                        其实理解原理之后,分析这道题就很简单了。那么替代方案就是使用 for...of 语句:

                        async function foo() {
                          let sum = 0
                          const arr = [1, 2, 3]
                          const sumFn = (a, b) => a + b
                        
                          for (let item of arr) {
                            sum = await sumFn(sum, item)
                          }
                        
                          setTimeout(() => {
                            console.log(sum) // 6
                            // 改成 for...of 之后,这里可以去掉 setTimeout 了,
                            // 直接将 console 语句放到外面,也可以按顺序执行了
                          })
                        }
                        
                        foo()
                        

                        那为什么 for...of 就可以,因为它本质上就是一个 while 循环。

                        async function foo() {
                          let sum = 0
                          const arr = [1, 2, 3]
                          const sumFn = (a, b) => a + b
                        
                          // for (let item of arr) {
                          //   sum = await sumFn(sum, item)
                          // }
                        
                          // 相当于
                          const iterator = arr[Symbol.iterator]()
                          let iteratorResult = iterator.next()
                          while (!iteratorResult.done) {
                            sum = await sumFn(sum, iteratorResult.value)
                            iteratorResult = iterator.next()
                          }
                        
                          setTimeout(() => {
                            console.log(sum) // 6
                          })
                        }
                        
                        foo()
                        

                        3. 继发关系

                        文章前面部分提到过 Async 函数中使用 await 去控制异步操作,看起来像 Promise.all(),但又有区别。

                        如下示例:

                        async function request() {
                          // 假设 fetchUser1、fetchUser2、fetchUser3 表示异步请求
                          let user1 = await fetchUser1()
                          let user2 = await fetchUser2()
                          let user3 = await fetchUser3()
                          return 'abc'
                        }
                        

                        上面示例中,request() 函数的返回值不依赖于 fetchUser 的结果,而且三个 fetchUser 请求是相互独立的。如果按上面的写法,直接影响程序的执行时间。

                        因为目前继发式写法,fetchUser2 请求只有在 fetchUser1 请求完成并返回数据后才会被发出(fetchUser3 同理)。但根据代码逻辑来看,它们是没有关联关系的,为什么不一次性连续发出三个请求以减少整个程序的执行时间呢?

                        因此,我们可以优化一下。

                        // 写法一(推荐)
                        async function request() {
                          // 这样 fetchUser1、fetchUser2、fetchUser3 将会同时发出请求
                          // 这也是文中所说 await 与 Promise.all() 的不同点。
                          let [user1, user2, user3] = await Promise.all([
                            fetchUser1(),
                            fetchUser2(),
                            fetchUser3()
                          ])
                          return 'abc'
                        }
                        
                        // 写法二
                        async function request() {
                          let p1 = fetchUser1()
                          let p2 = fetchUser2()
                          let p3 = fetchUser3()
                          let user1 = await p1
                          let user2 = await p2
                          let user3 = await p3
                          return 'abc'
                        }
                        

                        五、Async 函数的实现原理

                        Async 函数的实现原理,就是将 Generator 函数和自动执行器,包装在一个函数里面。

                        如果对 Generator 函数不熟悉的话,建议先看下这篇文章:细读 ES6 之 Generator 生成器,再回来看本节内容。

                        举个例子,下面 requestasync 函数。

                        function delay(time) {
                          return new Promise(resolve => setTimeout(resolve, time))
                        }
                        
                        async function request() {
                          let response = await fetch('http://192.168.1.117:7701/config')
                          let result = await response.json()
                          await delay(1000)
                          await delay(2000)
                          await delay(3000)
                          return result
                        }
                        

                        既然 Async 函数是 Generator 函数和自动执行器的结合,那么相当于将上面 Async 函数中的 asyncawait 关键字,就替换成 Generator 函数的 *yield,外加一个自动执行器。

                        于是变成了这样:

                        function* request() {
                          let response = yield fetch('http://192.168.1.117:7701/config')
                          let result = yield response.json()
                          yield delay(1000)
                          yield delay(2000)
                          yield delay(3000)
                          return result
                        }
                        

                        那么怎样实现执行器,才能达到 Async 函数的语义呢?

                        其实没那么难,只要弄清楚 Generator.prototype.next()Generator.prototype.throw() 两个方法就没太大问题了。当然前提还是要知道生成器的执行过程。

                        实现如下:

                        function executor(genFn, ...args) {
                          return new Promise((resolve, reject) => {
                            if (Object.prototype.toString.call(genFn) !== '[object GeneratorFunction]') {
                              return reject(new Error('Must be a generator function!'))
                            }
                        
                            const gen = genFn(...args)
                        
                            const step = iteratorResult => {
                              console.count('次数')
                              const { done, value } = iteratorResult
                        
                              if (done) {
                                return resolve(value)
                              }
                        
                              Promise
                                .resolve(value)
                                .then(res => {
                                  // 关于捕获异常,怕有人不懂,这里解释一下:
                                  // 假设调用 next 方法后,Generator 内部报错,且内部未进行捕获错误时,
                                  // 内部的错将会被 Generator 外部的 try...catch 捕获到(即这里的 then 回调函数),
                                  // 但是这里不进行捕获的原因是:使其传递出去,让后面的 Promise.prototype.catch() 方法捕获,
                                  // 进而在 catch() 的回调函数内调用 Generator.prototype.throw() 主动使得生成器结束,并 reject 错误。
                                  step(gen.next(res))
                                })
                                .catch(err => {
                                  try {
                                    gen.throw(err)
                                  } catch (e) {
                                    reject(e)
                                  }
                                })
                            }
                        
                            step(gen.next())
                          })
                        }
                        

                        调用方式如下:

                        function* request() {
                          let response = yield fetch('http://192.168.1.117:7701/config')
                          let result = yield response.json()
                          yield delay(1000)
                          yield delay(2000)
                          yield delay(3000)
                          return result
                        }
                        
                        // 语法:executor(generatorFunction[, param[, ... param]])
                        // 其中第一个参数必须是生成器函数,若生成器函数需要传递参数,往后面添加即可
                        executor(request)
                          .then(res => {
                            console.log(res)
                          })
                          .catch(err => {
                            console.warn(err)
                          })
                        

                        大致实现如上。如果还是不太懂的,应该是 Generator 函数这块知识点还不够熟悉。

                        如果这样,还是先要看下这篇文章熟悉相关知识,而且文章末尾很相似的自动执行器的实现示例。

                        六、总结

                        前面写过关于 IteratorPromiseGenerator 相关内容的文章,再到本文的 Async 函数,其实都是环环相扣的,因此应按顺序来学习,才能事半功倍。

                        其中 Promise 虽然解决了“横向”回调地狱(Callback Hell)的问题,但是又出现了“纵向”不断 thencatch 的处理。

                        而 Generator 函数提出了一种全新的异步控制的方案,调用 Generator 函数不会自动执行其函数体内部的代码,仅返回一个生成器对象,需要我们手动去调用生成器的 next() 方法以执行内部的代码。尽管生成器对象实现了 Iterator 接口,因而可供 for...of 等使用,但是往往结合网络请求等异步操作时,用 for...of 等语句几乎是不能满足我们需求的。这种前提下,需要我们自实现一个执行器来自动调用 Generator 函数实例。但要我们自个实现???这就是最大的问题了(手动狗头)。

                        基于这种状况下,著名的 co 模块就实现了 Generator 执行器,以供我们使用。但要另外引入第三方库,这本身也是个问题,哈哈...😄

                        基于以上种种不尽人意,于是 TC39 被开发者各种骂,说能不能原生实现一个 Generator 的执行器啊,现在用得很不爽啊!TC39 委员会那波人终究承受不住各方压力,于是只能加班帮我们实现了...

                        千呼万唤始出来,终于在 ES2017 标准中引入了 Async 函数,它就是实打实的 Generator 语法糖啊。除了将 Generator 中的 *yield 换成了 asyncawait 之外,还结合了 Promise。既解决了 Generator 的执行问题,Promise 的“纵向发展”问题。皆大欢喜了,TC39 委员会大佬们心想,终于可以消停了,又可以日常摸鱼了。

                        The end.

                        ]]>
                        <![CDATA[细读 ES6 | Generator 生成器]]> https://github.com/tofrankie/blog/issues/258 https://github.com/tofrankie/blog/issues/258 Sun, 26 Feb 2023 11:57:48 GMT 配图源自 Freepik

                        在 ES6 标准中,提供了 Generator 函数(即“生成器函数”),它是]]> 配图源自 Freepik

                        在 ES6 标准中,提供了 Generator 函数(即“生成器函数”),它是一种异步编程的解决方案。在前面一篇文章中也提到一二。

                        一、Generator 简述

                        避免有人混淆概念,先说明一下:

                        生成器对象常被我们称为“生成器”(Generator),而 Generator 函数常称为“生成器函数”(Generator Function)。

                        由于生成器对象是实现了可迭代协议迭代器协议的,因此生成器也是一个迭代器,生成器也是一个可迭代对象。所以,本文有时候直接称为迭代器,其实指的就是生成器对象。

                        // 生成器函数
                        function* genFn() {}
                        
                        // 生成器对象
                        const gen = genFn()
                        
                        // 生成器对象包含 @@iterator 方法,因此满足可迭代协议
                        gen[Symbol.iterator] // ƒ [Symbol.iterator]() { [native code] }
                        
                        // 生成器对象含 next 方法,因此也是满足迭代器协议的
                        gen.next // ƒ next() { [native code] }
                        
                        // 生成器对象的 @@iterator 方法返回自身(即迭代器)
                        gen === gen[Symbol.iterator]() // true
                        

                        怎样理解 Generator 函数?

                        • Generator 函数是一个状态机,封装了多个内部状态。

                        • Generator 函数返回一个生成器对象,该对象也实现了 Iterator 接口(也可供 for...of 等消费使用),所以具有了 next() 方法。因此,使得生成器对象拥有了开始、暂停和恢复代码执行的能力。

                        • 生成器对象可以用于自定义迭代器和实现协程(coroutine)。

                        • Generator 函数从字面理解,形式与普通函数很相似。在函数名称前面加一个星号(*),表示它是一个生成器函数。尽管语法上与普通函数相似,但语法行为却完全不同。

                        • Generator 函数强大之处,感觉很多人没 GET 到。它可以在不同阶段从外部直接向内部注入不同的值来调整函数的行为。

                        生成器对象,是由 Generator 函数返回的,并且它返回可迭代协议和迭代器协议,因此生成器对象是一个可迭代对象。

                        倘若对迭代器 Iterator 不熟悉的话,建议先看下这篇文章:细读 ES6 之 Iterator 迭代器,以熟悉相关内容。

                        二、Generator 函数语法

                        1. Generator 函数

                        与普通函数声明类似,但有两个特有特征:

                        • 一个是 function 关键字与函数名称之间有一个星号 *
                        • 二是函数体内使用 yield 表达式,以定义不同的内部状态。

                        星号 * 位置没有明确限制,只要处于关键字与函数名之间即可,空格可有可无,不影响。还有,这里 yield 是“产出”的意思。

                        实际中,基本上使用字面量形式去声明一个 Generator 函数,很少用到构造函数 GeneratorFunction 来声明的。

                        例如,先来一个最简单的示例。

                        // generatorFn 是一个生成器函数
                        function* generatorFn() {
                          console.log('do something...')
                          // other statements
                        }
                        
                        // 调用生成器函数,返回一个生成器对象。
                        const gen = generatorFn()
                        
                        // 注意,上面像平常一样调用函数,并不会执行函数体内部的逻辑/语句。
                        // 需要(不断地)调用生成器对象的 next() 方法,才会开始(继续)执行内部的语句。
                        // 具体如何执行,请看下一个示例。
                        gen.next()
                        // 执行到这里,才会打印出:"do something..."
                        // 且 gen.next() 的返回值是:{ value: undefined, done: true }
                        

                        上述示例中,调用生成器函数被调用,并不会立即立即执行函数体内部的语句。另外,函数体内的 yield 表达式是可选的,可以不写,但这就失去了生成器函数本身的意义了。

                        再看示例:

                        function* generatorFn() {
                          console.log(1)
                          yield '100'
                          console.log(2)
                          yield '200'
                          console.log(3)
                          return '300'
                        }
                        
                        const gen = generatorFn()
                        

                        前面提到,Generator 函数返回一个生成器,它也是一个迭代器。因此生成器内部存在一个指针对象,指向每次遍历结束的位置。每调用生成器的 next() 方法,指针对象会从函数头部(首次调用时)或上一次停下来的地方开始执行,直到遇到下一个 yield 表达式(或 return 语句)为止。

                        上面一共调用了四次 next() 方法,从结果分析:

                        当首次调用 gen.next() 方法,代码执行到 yield '100' 会停下来(指针对象指向此处),并返回一个 IteratorResult 对象:{ value: '100', done: false },包含 donevalue 属性。其中 value 属性值就是 yield 表达式的返回值 '100'donefalse 表示遍历还没结束。

                        第二次调用 next() 方法,它会从上次 yield 表达式停下的地方开始执行,直到下一个 yield 表达式(指针对象也会指向此处),并返回 IteratorResult 对象:{ value: '200', done: false }

                        第三次调用 next() 方法,执行过程同理。它遇到 return 语句遍历就结束了。返回 IteratorResult 对象为:{ value: '300', done: true },其中 value 对应 return 表达式的返回值。如果 Generator 函数内没有 return 语句,那么 value 属性值为 undefined,因此返回 { value: undefined, done: true }

                        第四次调用 next() 方法,返回 { value: undefined, done: true },原因是生成器对象 gen 已遍历结束。当迭代器已遍历结束,无论你再调用多少次 next() 方法,都是返回这个值。

                        2. yield 表达式

                        生成器函数返回的迭代器对象,只有调用 next() 方法才会遍历下一个内部状态,所以它提供了一种可以暂停执行的函数。而 yield 表达式就是暂停标志。

                        遍历器对象的 next() 方法的运行逻辑如下:

                        1. yield 表达式,就暂停执行后面的操作,并将紧跟在 yield 后面的那个表达式的值,作为返回的对象的 value 属性值。

                        2. 次调用next()方法时,再继续往下执行,直到遇到下一个 yield 表达式。

                        3. 没有再遇到新的 yield 表达式,就一直运行到函数结束,直到 return 语句为止,并将 return 语句后面的表达式的值,作为返回的对象的 value 属性值。

                        4. 该函数没有 return 语句,则返回的对象的 value 属性值为 undefined

                        需要注意的是,yield 表达式后面的表达式,只有在调用 next() 方法,且内部指针指向该语句时才会执行,因此相当于为 JavaScript 提供了手动的“惰性求值”(Lazy Evaluation)的语法功能。

                        function* generatorFn() {
                          // 请注意 yield 关键字后面的表达式,是惰性求值的!
                          // 为了更明显地说明问题,这里使用 IIFE。
                          yield (function () {
                            console.log('here here')
                            return 1
                          })()
                        }
                        
                        const gen = generatorFn()
                        gen.next() // 调用 next 方法才会打印出:"here here"
                        

                        上面的示例中,yield 后面的立即执行函数表达式,不会在调用 generatorFn() 后立即求值,只会在调用 gen.next() 方法才会进行求值。

                        3. yield 与 return 的特点及异同点

                        • 无论普通函数还是 Generator 函数,最多只能有一个 return 语句,表示该函数的终止。若没有显式声明,相当于在函数体最后 return undefined

                        • yield 表达式,只能在 Generator 函数内使用,否则会报错。

                        • 一个 Generator 函数中,可以有多个 yield 语句。每个 yield 语句对应生成器的一个状态。

                        • yield 表达式具备“记忆”功能,而 return 是不具备的。每当遇到 yield,函数暂停执行,下一次再从该位置继续向后执行。它是由迭代器内部由一个(指针)对象去维护的,我们无需关心。

                        • Generator 函数内部可以不用 yield 表达式。但如果这样使用 Generator 函数就没意义了,不如考虑使用普通函数。

                        • 理论上,yield 表达式可以返回任何值。若语句仅有 yield;,相当于 yield undefined;

                        4. yield 注意点

                        请注意以下几点,否则可能会出现语法错误。

                        // ️ 1. yield 只能用在 Generator 函数里面
                        function* foo() {
                          [1].map(item => {
                            yield item // SyntaxError
                            // 这里 Array.prototype.map() 的回调函数,并不是一个生成器函数
                          })
                        }
                        
                        // ️ 2. 当 yield 表达式作用于另外一个表达式,必须放入圆括号里面
                        function* foo() {
                          // Wrong
                          // console.log('Hello' + yield) // SyntaxError
                          // console.log('Hello' + yield 'World') // SyntaxError
                        
                          // Correct
                          console.log('Hello ' + (yield))
                          console.log('Hello ' + (yield 'World'))
                          // 不过要注意的是,(多次)调用生成器实例的 next() 方法
                          // 以上两个都会打印出 "Hello undefined",并不是想象中的 "Hello World"。
                          // yield 表达式本身没有返回值,或者说总是返回 undefined,
                          // yield 关键字后面的表达式结果,只会作为 IteratorResult 对象的 value 值。
                        }
                        
                        // ️ yield 表达式可以用作函数参数,或放在表达式的右边,可以不加括号
                        function* foo() {
                          const bar = (a, b) => {
                            console.log('paramA:', a)
                            console.log('paramB:', b)
                          }
                          bar(yield 'AAA', yield 'BBB')
                        
                          let input = yield
                          return input
                          // 多次调用 next 方法,bar 函数中,只会打印出:"paramA: undefined"、"paramB: undefined"
                          // 原因第 2 点提到过了
                        }
                        

                        Generator 函数还可以这样用:

                        // 函数声明形式
                        function* generatorFn() {}
                        
                        // 函数表达式形式
                        const generatorFn = function* () {}
                        
                        // 作为对象属性
                        const obj = {
                          * generatorFn() {} // or
                          // generatorFn: function* () {}
                        }
                        
                        // 作为类的实例方法,或类的静态方法
                        class Foo {
                          static * generatorFn() {}
                          * generatorFn() {}
                        }
                        

                        三、Generator 应用详解

                        前面提到的只是生成器函数的语法与简单用法,并没有体现其强大之处。

                        1. Generator 与 Iterator

                        生成器里面是部署了 Iterator 接口,因此可以把它当做迭代器供 for...of 等使用。前面一篇文章提到,使用生成器函数来实现自定义迭代器。

                        看示例:

                        class Counter {
                          constructor([min = 0, max = 10]) {
                            this.min = min
                            this.max = max
                          }
                        
                          *[Symbol.iterator]() {
                            let point = this.min
                            const end = this.max
                            while (point <= end) {
                              yield point++
                            }
                          }
                        }
                        
                        const counter = new Counter([0, 3])
                        const gen = counter[Symbol.iterator]() // gen 既是生成器,又是迭代器
                        for (const x of gen) {
                          console.log(x)
                        }
                        // 依次打印:0、1、2、3
                        

                        2. next 方法传参

                        yield 表达式本身没有返回值,或者说总是返回 undefinednext() 方法可以带一个参数,该参数被作为上一个 yield 表达式的返回值。

                        function* generatorFn() {
                          let str = 'Hello ' + (yield 'World')
                          console.log(str)
                          return str
                        }
                        const gen1 = generatorFn()
                        const gen2 = generatorFn()
                        

                        不传递参数时,执行结果如下:

                        // 第一次调用 next()
                        console.log(gen1.next())
                        // 打印出:{ done: false, value: 'World' }
                        
                        // 第二次调用 next()
                        console.log(gen1.next())
                        // "Hello undefined"
                        // { done: true, value: 'Hello undefined' }
                        

                        相信刚开始学 Generator 的童鞋,会认为在第二次调用 gen1.next() 方法时,str 变量的值会变成 'Hello World',当初我也是这么认为的,但这是错误的,str 的值 'Hello undefined'

                        yield 关键字后面的表达式结果,仅作为 next() 方法的返回对象 IteratorResultvalue 属性值,即:{ done: false, value: 'World' }

                        但如果我们在 next() 方法进行传参呢?

                        // 第一次调用 next()
                        console.log(gen2.next('Invalid'))
                        // 打印出:{ done: false, value: 'World' }
                        
                        // 第二次调用 next()
                        console.log(gen2.next('JavaScript'))
                        // "Hello JavaScript"
                        // { done: true, value: 'Hello JavaScript' }
                        

                        需要注意的是,由于 next() 方法表示上一个 yield 表达式的返回值,因此在第一次使用 next() 方法时,传递的参数是无效的。只有第二次(起)调用 next() 方法,参数才有效。从语义上讲,第一个 next() 方法用于启动遍历器对象,所以不用带有参数。

                        第一次调用 gen2.next('Invalid') 时,参数 'Invalid' 是无效的,所以结果还是 { done: false, value: 'World' }

                        当第二次调用 gen2.next('JavaScript') 时,由于该参数将作为上一次 yield 表达式的返回值。所以 let str = 'Hello ' + (yield 'World') 就相当于 let str = 'Hello ' + 'JavaScript',因此 str 就变成了 'Hello JavaScript',自然 gen2.next() 的返回值就是 { done: true, value: 'Hello JavaScript' }

                        这个功能有很重要的语法意义。Generator 函数从暂停状态到恢复运行,它的上下文状态(context)是不变的。通过给 next() 方法传递参数,就有办法在 Generator 函数开始运行之后,继续向函数体内部注入值。也就是说,可以在 Generator 函数运行的不同阶段,从外部向内部注入不同的值,从而调整函数行为。

                        如果还没弄懂,再看一个示例:

                        function* foo(x) {
                          const y = 2 * (yield (x + 1))
                          const z = yield (y / 3)
                          return (x + y + z)
                        }
                        
                        const f1 = foo(5)
                        f1.next()     // { done: false, value: 6 }
                        f1.next()     // { done: false, value: NaN }
                        f1.next()     // { done: true, value: NaN }
                        
                        const f2 = foo(5)
                        f2.next()     // { done: false, value: 6 }
                        f2.next(12)   // { done: false, value: 8 }
                        f2.next(13)   // { done: true, value: 42 }
                        
                        // 若结果跟你内心预期的一样,那说明你弄明白了!
                        

                        如果想在第一次调用 next() 方法时传入参数并使其有效。换个思路就行:在 Generator 函数外面包裹一个函数,在此函数内部调用第一次,并返回生成器即可。

                        function genWrapper(genFn) {
                          return function (...args) {
                            const g = genFn(...args)
                            g.next() // 其实是在内部调用了真正意义上的第一次 next 方法。
                            return g
                          }
                        }
                        
                        function* generatorFn() {
                          let str = 'Hello ' + (yield 'World')
                          console.log(str)
                          return str
                        }
                        
                        const gen = genWrapper(generatorFn)(5)
                        
                        // 这样在外部调用 next() 就算是“第一次”
                        gen.next('JavaScript') // { done: true, value: 'Hello JavaScript' }
                        

                        3. for...of 语句

                        for...of 语句是 ES6 标准新增的一种循环遍历的的方式,为了 Iterator 而生的。只有任何部署了 Iterator 接口的对象,都可以使用它来遍历。

                        那 for...of 什么时候会停止循环呢?

                        我们知道 for...of 内部其实是不断调用迭代器 next() 的过程,当 next() 方法返回的 IteratorResult 对象的 done 属性为 true 时,循环就会中止,且不包含返回对象

                        请看示例和注释:

                        function* generatorFn() {
                          yield 1
                          yield 2
                          yield 3
                          yield 4
                          yield 5
                          return 6 // 一般不指定 return 语句
                        }
                        
                        const gen = generatorFn()
                        for (const x of gen) {
                          console.log(x)
                        }
                        // 依次打印:1、2、3、4、5
                        
                        console.log([...gen]) // 打印结果为:[]
                        
                        // ️
                        // 一般情况下,迭代器是不指定 return 语句的,即返回 return undefined,
                        // 因为遇到 return 时,调用 next 会返回:{ done: true, value: '对应return的结果' }
                        // 这时无论使用 for...of,还是数组解构或其他,它们看到状态 done 为 true(表示遍历结束),
                        // 它们就停止往下遍历了,而且不会遍历 { done: true } 的这一次哦!
                        // 所以,示例中 for...of 只会打印出 0 ~ 5,而不包括 6。
                        // 同理,执行到 [...gen] 时,由于此前迭代器已经是 done: true 结束状态,
                        // 因此解构结果就是一个空数组:[]
                        

                        此前的文章提到过,迭代器是一次性对象,而且不应该重用生成器。例如上面示例中,已经使用 for...of 去遍历完 gen 对象了,然后还使用解构去遍历 gen 对象,由于解构之前 gen 对象已结束,再去使用就没意义了。

                        再看示例,你就明白了:

                        function* foo() {
                          yield 1
                          yield 2
                          return 3
                          yield 4
                        }
                        
                        [...foo()] // [1, 2]
                        Array.from(foo()) // [1, 2]
                        const [x, y] = foo() // x 为 1, y 为 2
                        for (const x of foo()) { console.log(x) } // 依次打印:1、2
                        

                        所以,无论是 for...of 或是解构操作,遇到状态 donetrue 就会中止,且不包含返回对象

                        for...of 本质上就是一个 while 循环。

                        const arr = [1, 2, 3]
                        for (const x of arr) {
                          console.log(x)
                        }
                        
                        // 相当于
                        const iter = arr[Symbol.iterator]() // 迭代器
                        let iterRes = iter.next() // IteratorResult
                        while (!iterRes.done) { // 当 done 为 true 时退出循环
                          console.log(iterRes.value)
                          iterRes = iter.next()
                        }
                        

                        建议:同一个迭代器最好不要重复使用。

                        4. Generator.prototype.return()

                        此前的文章提到过,迭代器要提前退出,并“关闭”迭代器(即状态 done 变为 true),需要实现迭代器协议的 return() 方法。

                        也提到过,生成器对象本身实现了 return() 方法。因此,因应不同场景,使用 breakcontinuereturnthrow 或数组解构未消费所有值时,都会提前关闭状态。

                        function* foo() {
                          yield 1
                          yield 2
                          console.log('here')
                          yield 3
                        }
                        
                        // 情况一:属于未消费所有值,也会提前关闭。其中 x 为 1, y 为 2。
                        const [x, y] = foo()
                        
                        // 情况二:使用 break 提前退出,因此不会执行到 console.log('here') 这条语句。
                        for (const x of foo()) {
                          console.log(x)
                          if (x === 2) break
                        }
                        // 依次打印:1、2
                        
                        // 情况三:属于从开始到结尾,迭代完全
                        for (const x of foo()) {
                          console.log(x)
                        }
                        // 依次打印:1、2、"here"、3
                        

                        对于生成器对象,除了通过以上方式“提前关闭”之外,还提供了一个 Generator.prototype.return() 方法供我们使用。

                        function* foo() {
                          yield 1
                          yield 2
                          yield 3
                        }
                        
                        const gen = foo()
                        gen.next() // { done: false, value: 1 }
                        gen.return('closed') // { done: true, value: 'closed' } // 若 return 不传参时,value 为 undefined。
                        gen.next() // { done: true, value: undefined }
                        

                        注意,return() 方法的参数是可选的。当传递某个参数时,它将作为 { done: true, value: '参数对应的值' }。若不传参,那么 value 的值为 undefined

                        但如果 Generator 函数体内,包含 try...finally 代码块,且正在执行 try 代码块,那么 return() 方法会导致立即进入 finally 代码块,执行完以后,整个函数才会结束。

                        function* foo() {
                          yield 1
                        
                          try {
                            yield 2
                            yield 3
                          } finally {
                            yield 4
                            yield 5
                          }
                        
                          yield 6
                        }
                        
                        // ️ 注意执行顺序及结果
                        const gen = foo()
                        gen.next()             // { done: false, value: 1 }
                        gen.next()             // { done: false, value: 2 }
                        gen.return('closed')   // { done: false, value: 4 }
                        gen.next()             // { done: false, value: 5 }
                        gen.next()             // { done: true, value: 'closed' }
                        

                        上面代码中,调用 return() 方法后,就开始执行 finally 代码块,不执行 try 里面剩下的代码了,然后等到 finally 代码块执行完,再返回 return() 方法指定的返回值。

                        5. Generator.prototype.throw()

                        生成器对象都有一个 throw() 方法(注意,它跟全局的 throw 关键字是两回事),可以在函数体外抛出错误,然后在 Generator 函数体内捕获。

                        当生成器未开始之前或者已结束(已关闭)之后,调用生成器的 throw() 方法。它的错误信息会被生成器函数外部的 try...catch 捕获到。若外部没有 try...catch 语句,则会报错且代码就会停止执行。

                        • 未开始,是指调用 Generator 函数返回生成器对象之后,第一次就调用了 throw() 方法。此时由于 Generator 函数还没开始执行,throw() 方法抛出的错误只能抛出到 Generator 函数外。

                        • 已结束,是指生成器对象的状态是 { done: true }。此后再调用生成器对象 throw() 方法,错误只能在 Generator 函数外被捕获。

                        以上两种情况均不会被 Generator 函数内部的 try...catch 捕获到。

                        看示例:

                        function* generatorFn() {
                          try {
                            yield
                          } catch (e) {
                            console.log('Generator Inner:', e)
                          }
                        }
                        
                        const gen = generatorFn()
                        gen.next()
                        
                        try {
                          console.log(gen.throw('a'))
                          console.log(gen.throw('b'))
                        } catch (e) {
                          console.log('Generator Outer:', e)
                        }
                        
                        // 依次打印出:
                        // "Generator Inner: a"
                        // { value: undefined, done: true }
                        // "Generator Outer: b"
                        

                        上面示例中,当代码执行到 gen.throw('a') 时(此前已调用过一次 gen.next() 了),由于 Generator 函数体内部署了 try...catch 语句块,因此在外部的 gen.throw('a') 会被内部的 catch 捕获到,而且参数 'a' 将作为 catch 语句块的参数,所以打印出 'Generator Inner: a'

                        请注意,当 throw() 方法被捕获到之后,会“自动”执行下一条 yield 表达式,相当于调用一次 next() 方法。由于 Generator 函数体内在执行 catch 之后,已经没有其他语句,相当于有一个隐式的 return undefined,即 gen 对象会变成 donetrue 而关闭。所以 console.log(gen.throw('a')) 就会打印出 { value: undefined, done: true }

                        完了继续执行 gen.throw('b') 方法,由于 gen 已经是“结束状态”,所以 throw() 方法抛出的错误将会在 Generator 函数外部被捕获到。所以就是打印出:'Generator Outer: b'

                        怕有人还没完全理解,再给出一个示例:

                        function* generatorFn() {
                          try {
                            yield 1
                          } catch (e) {
                            console.log('Generator Inner:', e)
                          }
                          yield 2
                        }
                        
                        const gen = generatorFn()
                        console.log(gen.next())
                        console.log(gen.throw(new Error('Oops')))
                        console.log(gen.next())
                        // 依次打印出:
                        // { value: 1, done: false }
                        // "Generator Inner: Error: Oops"
                        // { value: 2, done: false }
                        // { value: undefined, done: true }
                        

                        以上示例中,gen.throw() 之后,内部会自动执行一次 next() 方法,即执行到 yield 2,因此返回的 IteratorResult 对象为:{ value: 2, done: false }。接着再执行一次 gen.next() 方法生成器就会变成关闭状态。

                        这种函数体内捕获错误的机制,大大方便了对错误的处理。多个 yield 表达式,可以只用一个 try...catch 代码块来捕获错误。如果使用回调函数的写法,想要捕获多个错误,就不得不为每个函数内部写一个错误处理语句,现在只在 Generator 函数内部写一次 try...catch 语句就可以了。

                        还有,当 Generator 函数内报错,且未被捕获,生成器就会变成“关闭”状态。若后续再次调用此生成器的 next() 方法,只会返回 { done: true, value: undefined } 结果。

                        6. next、return、throw 的共同点

                        其实 next()return()throw() 三个方法本质上都是同一事件,可以放在一起理解。它们的作用都是让 Generator 函数恢复执行,兵器使用不同的语句替换 yield 表达式。

                        const gen = function* (x, y) {
                          const result = yield x + y
                          return result
                        }(1, 2)
                        
                        gen.next() // { done: false, value: 3 }
                        

                        next() 方法是将 yield 表达式替换成一个值。注意,首次调用 next() 方法进行传参是无效的,从第二次起才有效。

                        gen.next(10) // { done: true, value: 10 }
                        // 如果第二次调用 next 方法,且不传参时,yield 表达式返回值为 undefined。因此,
                        // gen.next() // { done: true, value: undefined }
                        

                        return() 方法是将 yield 表达式替换成一个 return 语句

                        gen.return('closed') // { done: true, value: 'closed' }
                        // 这样的话 `let result = yield x + y` 相当于变成 `let result = return 'closed'`
                        

                        throw() 方法是将 yield 表达式替换成一个 throw 语句,以主动抛出错误。

                        gen.throw(new Error('exception')) // 报错:Uncaught Error: exception
                        // 这样的话 `let result = yield x + y` 相当于变成 `let result = throw new Error('exception')`
                        

                        7. yield* 表达式

                        如果在 Generator 函数内部调用另外一个 Generator 函数,需要前者的函数体内部“手动”完成遍历。

                        function* foo() {
                          yield 'foo1'
                          yield 'foo2'
                          // return 'something'
                          // 假设指定一个 return 语句,
                          // 使用 yield* foo() 迭代时将不会被迭代到,
                          // 因此可以理解成 yield* 内部执行了一遍 for...of 循环。
                          // 返回值 something,仅当 let result = yield* foo() 使用时,作为 result 的结果。
                        }
                        
                        function* bar() {
                          yield 'bar1'
                          for (let x of foo()) {
                            console.log(x)
                          }
                          yield 'bar2'
                        }
                        
                        for (let x of bar()) {
                          console.log(x)
                        }
                        // 依次打印出:
                        // "foo1"
                        // "bar1"
                        // "bar2"
                        // "foo2"
                        

                        上面示例中,foobar 都是Generator 函数,在 bar 内部调用 foo,需要“手动”迭代 foo 的生成器实例。如果存在多个 Generator 函数嵌套时,写起来就会非常麻烦。

                        针对这种情况,ES6 提供了 yield* 表达式,用于在一个 Generator 函数里面执行另外一个 Generator 函数。

                        因此,上面的示例可以利用 yield* 改写成:

                        function* foo() {
                          yield 'foo1'
                          yield 'foo2'
                        }
                        
                        function* bar() {
                          yield 'bar1'
                          yield* foo()
                          yield 'bar2'
                        }
                        
                        for (let x of bar()) {
                          console.log(x)
                        }
                        

                        关于 yieldyield* 的区别:

                        • yield 关键字后面,可以跟着一个值或表达式,其结果将作为 next() 方法返回值的 value 属性值。

                        • yield* 后面,只能跟着一个可迭代对象(即具有 Iterator 接口的任意对象),否则会报错。生成器本身就是迭代器,也是可迭代对象。

                        因此,yield* 后面除了生成器对象,还可以是以下这些可迭代对象等等。

                        function* foo() {
                          yield 'foo1'
                          yield* [1, 2] // 数组、字符串均属于可迭代对象
                          yield [3, 4] // 未使用星号时,将会返回数组
                          yield 'foo2'
                          yield* 'Hi'
                          yield 'JSer' // 同理,未使用星号将会返回整个字符串
                          // yield 100 // 若 yield* 后面跟一个不可迭代对象,将会报错:TypeError: undefined is not a function
                        }
                        
                        for (const x of foo()) {
                          console.log(x)
                        }
                        // 依次打印出:"foo1"、1、2、[3, 4]、"foo2"、"H"、"i"、"JSer"
                        

                        8. Generator 函数中的 this

                        在普通函数中 this 指向当前的执行上下文环境,而箭头函数则不存在 this,那么 Generator 函数中 this 是怎样的呢?

                        function* foo() {}
                        const gen = foo()
                        foo.prototype.sayHi = function () { console.log('Hi~') }
                        
                        console.log(gen instanceof foo) // true
                        gen.sayHi() // "Hi~"
                        

                        上面的示例中,实例 gen 继承了 foo.prototype。Generator 函数算是构造函数,但它是“特殊”的构造函数,它不返回 this 实例,而是生成器实例。

                        function* foo() {
                          this.a = 1
                        }
                        const gen = foo()
                        gen.next()
                        console.log(gen.a) // undefined
                        
                        // 其实我们通过打印 this 可知,this 仍指向当前执行上下文环境。
                        // 此处执行上下文环境是全局,因此 this 是 window 对象。
                        // 如果执行 gen.next() 时所处的上下文是某个对象(假设为 obj),
                        // 那么 this 就会指向 obj,而不是 gen 对象。
                        
                        // 看着是不是有点像以下这个:
                        // function Bar() {
                        //   this.a = 1
                        //   return {} // 不返回 this,返回另一个对象
                        // }
                        // const bar = new Bar()
                        // console.log(bar.a) // undefined
                        

                        上面的示例中,当我们调用 gen.next() 方法,会给 this.a 赋值为 1,接着打印 gen.a 的结果却是 undefined,说明 this 并不是指向 gen 生成器实例。所以,Generator 函数跟平常的构造函数是不一样的。

                        而且,不能使用 new 关键字进行实例化,会报错。

                        const gen2 = new foo() // TypeError: foo is not a constructor
                        

                        9. Generator 与上下文

                        JavaScript 代码运行时,会产生一个全局的上下文环境(context,又称运行环境),包含了当前所有的变量和对象。然后,执行函数(或块级代码)的时候,又会在当前上下文环境的上层,产生一个函数运行的上下文,变成当前(active)的上下文,由此形成一个上下文环境的堆栈(context stack)。

                        这个堆栈是“后进先出”的数据结构,最后产生的上下文环境首先执行完成,退出堆栈,然后再执行完成它下层的上下文,直至所有代码执行完成,堆栈清空。

                        Generator 函数不是这样,它执行产生的上下文环境,一旦遇到 yield 命令,就会暂时退出堆栈,但是并不消失,里面的所有变量和对象会冻结在当前状态。等到对它执行 next 命令时,这个上下文环境又会重新加入调用栈,冻结的变量和对象恢复执行。

                        function* foo() {
                          yield 1
                          return 2
                        }
                        
                        let gen = foo()
                        
                        console.log(
                          gen.next().value,
                          gen.next().value
                        )
                        

                        上面代码中,第一次执行 gen.next()时,Generator 函数 foo 的上下文会加入堆栈,即开始运行 foo 内部的代码。等遇到 yield 1时,foo 上下文退出堆栈,内部状态冻结。第二次执行 gen.next() 时,foo 上下文重新加入堆栈,变成当前的上下文,重新恢复执行。

                        四、Generator 的应用

                        Generator 与 Promise 都是 ES6 通过的异步编程的解决方案。尽管 Promise 有效解决了 ES6 之前的“回调地狱”(Callback Hell),但它仍然需要写一堆的 then()catch() 的处理。

                        如示例:

                        // 这里 delay 表示各种异步操作,比如网络请求等等
                        // 一下子想不到要列举哪些异步操作,就用 setTimeout 表示吧
                        // 问题不大,举例而已
                        function delay(time) {
                          return new Promise(resolve => setTimeout(resolve, time))
                        }
                        
                        function requestByPromise(url) {
                          let result = null
                          window.fetch(url)
                            .then(respone => respone.json())
                            .then(res => {
                              result = res
                            })
                            .then(() => {
                              return delay(1000)
                            })
                            .then(() => {
                              return delay(2000)
                            })
                            .then(() => {
                              return delay(3000)
                            })
                            .then(() => {
                              console.log('Done', result)
                              // do something...
                            })
                            .catch(err => {
                              console.warn('Exception', err)
                            })
                        }
                        
                        requestByPromise('/config/user')
                        

                        上述示例中,当我们存在多个异步操作,想利用 Promise 封装的话,避免不了要写一系列的 then()catch() 方法,假设 requestByPromise() 方法,进行网络请求之后,还有很多个异步操作要执行,等它们完成之后,这里封装的 requestPromise(请求操作)才会完结。整个代码的实现起来代码里还是很长。

                        虽然利用 async...await 可以写出很简洁的结构,但是本文的主角不是它。

                        当然,利用 Promise.all() 等方法也可以简化以上流程。如果利用 Generator 要怎么做呢?

                        如果我们想写出如下这样更直观的“同步”方式:

                        function* requestByGenerator(url) {
                          let response = yield window.fetch(url)
                          let result = yield response.json()
                          yield delay(1000)
                          yield delay(2000)
                          yield delay(3000)
                          return result
                        }
                        

                        如果像下面那样,直接去(多次)调用 next() 方法,显然不会得到我们预期结果,且会报错。

                        const gen = requestByGenerator('/config/user')
                        gen.next()
                        gen.next() // 这一步就会报错,TypeError: Cannot read property 'json' of undefined
                        // ...
                        

                        原因很简单,yield 表达式的返回值总是 undefined。如果 response 要得到预期值,在调用 gen.next() 方法时,应传入 window.fetch(url) 的结果,在下一个 yield 表达式才会正确解析。而且还有一个最大的问题,由于实例化 gen 对象,以及调用 gen.next() 都是同步的,当我们如上述示例调用第二次 next() 方法时,Fetch 请求还没有得到结果。即使已经请求到数据,但由于 Event Loop 机制,它的处理也后于 next() 方法。

                        请注意,尽管 Generator 函数是异步编程的解决方案,但它并不是异步的,而是同步的。只是 Generator 函数在调用之后,不会立即执行函数体内的代码,而是提供了 next() 等方法,方便我们去控制异步流程罢了。

                        因此,像前一个示例的 requestByGenerator 函数,它并不会按编写顺序“同步”地处理这些异步操作,还需要我们进一步去封装,才能按照预期的“同步”执行多个异步操作。

                        Generator 还有一个很蛋疼的问题,需要主动调用 next() 才会去执行 Generator 函数体内的代码。如果利用 for...of 等语句去遍历,遇到 donetrue 的又不执行。

                        所以,我们要做的就是实现一个 Generator 执行器。

                        /**
                         * 思路:
                         * 1. 封装方法并返回一个 Promise 对象;
                         * 2. Promise 对象的返回值就是 Generator 函数的 return 结果;
                         * 3. 封装的方法内部,要自动调用生成器的 next() 方法,在生成器结束时,将结果返回 Promise 对象(fulfilled);
                         * 4. 这里将 Generator 内部的异常情况,在 Generator 外部使用 try...catch 补换,并返回 Promise 对象(rejected);
                         * 5. 针对 Generator 函数内 yield 关键字后的异步操作,若非 Promise 的话,请使用 Promise 包装一层;
                         * 6. 由于封装方法会自动调用 next() 方法,在 Generator 函数内若不是异步操作,没必要使用 yield 关键字去创建一个状态,直接同步写法即可。
                         *
                         * @param {GeneratorFunction} genFn 生成器函数
                         * @param  {...any} args 传递给生成器函数的参数
                         * @returns {Promise}
                         */
                        function generatorExecutor(genFn, ...args) {
                          const getType = obj => {
                            const type = Object.prototype.toString.call(obj)
                            return /^\[object (.*)\]$/.exec(type)[1]
                          }
                        
                          if (getType(genFn) !== 'GeneratorFunction') {
                            throw new TypeError('The first parameter of generatorExecutor must be a generator function!')
                          }
                        
                          // 下面就是不断调用 next() 方法的过程,直至结束或报错
                          return new Promise((resolve, reject) => {
                            const gen = genFn(...args)
                            let iterRes = gen.next()
                        
                            const goNext = iteratorResult => {
                              const { done, value } = iteratorResult
                        
                              // Generator 结束时退出
                              if (done) return resolve(value)
                        
                              if (getType(value) !== 'Promise') {
                                const nextRes = gen.next(value)
                                goNext(nextRes)
                                return
                              }
                        
                              // 处理 yield 为 Promise 的情况
                              value.then(res => {
                                const nextRes = gen.next(res)
                                goNext(nextRes)
                              }).catch(err => {
                                try {
                                  // 利用 Generator.prototype.throw() 抛出异常,同时使得 gen 结束
                                  gen.throw(err)
                                } catch (e) {
                                  reject(e)
                                }
                              })
                            }
                        
                            goNext(iterRes)
                          })
                        }
                        

                        然后,像下面那样去调用即可。

                        function* requestByGenerator(url) {
                          let response = yield window.fetch(url)
                          let result = yield response.json()
                          yield delay(1000)
                          yield delay(2000)
                          yield delay(3000)
                          return result
                        }
                        
                        generatorExecutor(requestByGenerator, '/config/user')
                          .then(res => {
                            // do something...
                            // res 将会预期地得到 fetch 的响应结果
                          })
                          .catch(err => {
                            // do something...
                            // 处理异常情况
                          })
                        

                        尽管 Generator 函数提出了一种全新的异步编程的解决方案,可以在函数外部注入值取干预函数内部的行为,这种思想提供了极大的创造性,强大之处不是 Promise 能比的。但是在结合实际场景时,很大可能需要自实现一个 Generator 执行器,使其自动执行生成器。

                        例如,著名的 co 函数库就是去做了这件事情。如果想了解,可以看一下这篇文章,或直接看官方文档。但看了下 GitHub 上最新一次提交已经是 5 年前,大概都去用 async/await 了吧。

                        接下来就介绍 async/await 了。

                        ]]>
                        <![CDATA[细读 ES6 | Iterator 迭代器]]> https://github.com/tofrankie/blog/issues/257 https://github.com/tofrankie/blog/issues/257 Sun, 26 Feb 2023 11:56:13 GMT 配图源自 Freepik

                        迭代的英文是 “iteration”,源自拉丁文 “itero”,是“重复”或]]> 配图源自 Freepik

                        迭代的英文是 “iteration”,源自拉丁文 “itero”,是“重复”或“再来”的意思。在软件开发领域,“迭代”的意思是按照顺序反复多次执行一段程序,通常会有明确的终止条件。ES6 规范新增了两个高级特性:迭代器生成器。使用这两个特性,能够更清晰、高效、方便地实现迭代。

                        一、迭代器简述

                        迭代器(Iterator)是一种机制,是一种接口。为各种不同的数据结构提供了统一的访问机制。

                        有些书籍或文章译为“遍历器”,其他语言多称为“迭代器”。

                        1. 为什么 ECMAScript 要引入“迭代器”?

                        在 ES6 之前,最常见的数据结构有数组和对象。

                        遍历这些数据结构通常使用 for 语句。例如数组,除了最原始的 for 循环,还有 Array.prototype.forEach() 等方法。而普通对象则使用 for...in 语句。那么,我们决定使用哪一种方法之前,是不是要提前知道它们内部结构是怎样的,才能正确地写出迭代的程序,对吧。

                        而在 ES6 标准中,新增了 MapSet 两种不同的数据结构。它们的内部结构与原本就存在的 ArrayObject 又不一样。假设我们要去遍历它们,是不是要实现一个类似 Array.prototype.forEach() 的方法,例如,遍历以上两种结构的方法叫 Map.prototype.forEach()Set.prototype.forEach()(当然这两个是我瞎编了)。那么这样是不是又多了两种迭代方式,无论对负责制定标准的 TC39 委员会,还是 JavaScript 开发者来说,都增加了工作量。委员会要负责实现,开发者要了解学习,都需要成本。

                        看到这种苗头,TC39 委员会那些大佬就内心开始骂街了。每增加一种数据结构,就要实现一种全新的方法去遍历它,那劳资不干了,都不能好好摸鱼了。

                        于是它们就想出一个“偷懒”的方法:实现一种通用的迭代机制,并制定相关标准。它一定要支持原有的数据结构,而且未来新增的数据结构,也要满足这种模式(机制)才行。因此,在 ECMAScript 中就出现了“迭代器”(Iterator)的概念。

                        这样的话,对于我们开发者来说,无需知道某种对象的内部数据结构,就可以利用 for...of 等语句,方便、快速、安全地写出迭代程序。假设在未来的 ES2050 标准中,新增了一种名为 Record 的数据结构,只要符合迭代器机制的,我们仍然可以使用 for...of 去遍历它们。

                        2. JavaScript 内置的可迭代对象

                        如果一个对象具有 Iterator 接口,我们将它称为“可迭代对象”(Iterable)。

                        在 JavaScript 中,目前内置的可迭代对象有:StringArrayTypedArrayMapSetArgumentsNodeList。它们的原型对象(prototype)都实现了 @@iterator 方法。(即对象含有 Symbol.iterator 属性)。

                        除了内置的可迭代对象,我们可以实现自己的可迭代对象。例如:

                        const myIterable = {
                          [Symbol.iterator]: function* () {
                            yield 1
                            yield 2
                            yield 3
                            // 生成器函数,返回一个可迭代对象
                          }
                        }
                        
                        console.log([...myIterable]) // [1, 2, 3]
                        

                        3. Iterator 接口

                        迭代器是一个对象,带有特殊的接口。一个具有 Iterator 接口的对象,分为两部分:可迭代协议迭代器协议

                        简单来说:

                        • 可迭代协议,是指对象实现了 @@iterator 方法(不管是本身属性,还是对象原型链上的属性都可以),并返回一个迭代器。可通过常量 Symbol.iterator 访问该属性。

                        • 迭代器协议,是指 @@iterator 方法返回的迭代器实现了 next 方法。调用 next 方法返回一个 IteratorResult 对象,包括两个属性:{ done: true/false, value: '...' },其中 done 是一个布尔值,表示遍历是否结束;value 表示当前成员的值。

                        某个对象只有实现了以上两个协议,才算是一个完整、合格的迭代器。

                        二、生成器简述

                        说到生成器,你们一定知道 Generator 函数(即“生成器函数”),它是 ES6 中提供的一种异步编程的解决方案。

                        生成器的形式是一个函数,在函数名称前面加一个星号(*),表示它是一个生成器。尽管语法上与普通函数相似,但语法行为却完全不同。

                        注意,箭头函数不能用来定义生成器函数。

                        如示例:

                        function* generatorFn() {} // generatorFn 表示一个生成器
                        const gen = generatorFn() // 调用生成器函数,会返回一个“生成器对象”
                        
                        console.log(gen) // generatorFn {<suspended>}
                        console.log(gen.next()) // {value: undefined, done: true}
                        
                        // 生成器对象,一开始会处于暂停执行(suspended)的状态
                        // 调用 next() 方法会让生成器开始或恢复执行
                        // 由于生成器函数体为空,因此调用一次 next() 方法就会让生成器到达 done: true 的状态
                        

                        本文不会详细介绍生成器函数相关语法,后面另起一文。本文提到它的缘由是:Generator 函数会产生一个“生成器对象”,该对象也实现了 Iterator 接口。

                        因此,生成器格外合适作为默认迭代器。在实现自定义可迭代对象时,也是最简单的一种实现。例如:

                        class Foo {
                          constructor(value) {
                            this.value = value
                          }
                        
                          *[Symbol.iterator]() {
                            yield* this.value
                          }
                        }
                        
                        // foo 实例对象是一个可迭代对象,因为它的原型上实现了 @@iterator 方法
                        const foo = new Foo([1, 2, 3])
                        
                        // 访问迭代器
                        for (const x of foo) {
                          console.log(x)
                        }
                        // 依次打印:1、2、3
                        

                        三、Iterator 详解

                        迭代器是按需创建的一次性对象。每个迭代器都会关联一个可迭代对象。

                        这句话怎么理解,看示例:

                        const num = 1
                        const obj = {}
                        const arr = [1, 2, 3]
                        
                        // 通过访问 Symbol.iterator 属性可判断对象是否实现了可迭代协议,
                        // 返回值为函数,则表示实现了,否则未实现,
                        // 因此 num、obj 不可迭代,arr 是可迭代的。
                        console.log(num[Symbol.iterator]) // undefined
                        console.log(obj[Symbol.iterator]) // undefined
                        console.log(arr[Symbol.iterator]) // ƒ values() { [native code] }
                        
                        // 调用 @@iterator 方法,会生成一个迭代器,
                        // iter1、iter2 表示两个不同的迭代器,
                        // 但它们相关联的可迭代对象都是 arr
                        const iter1 = arr[Symbol.iterator]()
                        const iter2 = arr[Symbol.iterator]()
                        
                        console.log(iter1 === iter2) // false
                        for (const x of iter1) {
                          console.log(x)
                        }
                        // for...of 语句依次打印:1、2、3
                        

                        在上面的实例中,结论与分析请看注释部分。

                        如何理解“一次性”对象,看示例:

                        // 示例一:
                        const gen = (function* () {
                          yield 1
                          yield 2
                          yield 3
                        })()
                        
                        for (let x of gen) {
                          console.log(x)
                          break // 关闭生成器
                        }
                        
                        // 生成器不应该重用,以下没有意义!
                        for (let x of gen) {
                          console.log(x)
                        }
                        
                        // 由始至终,仅打印出 1
                        

                        上面的示例一中,使用了一个生成器函数返回一个生成器(也是迭代器,它实现了 Iterator 接口)。当我们使用 for...of 循环遍历它,并使用了 break 关键字提前终止。在退出循环后,生成器关闭。若尝试再次迭代,不会产生任何进一步的结果。因此,我们不应该重用生成器。

                        再看以下示例:

                        // 示例二
                        const arr = [1, 2, 3, 4, 5]
                        const iter = arr[Symbol.iterator]()
                        
                        // 第一次迭代
                        for (const x of iter) {
                          console.log(x)
                          if (x > 2) break
                        }
                        // 依次打印出:1、2、3
                        
                        // 再次迭代
                        for (const x of iter) {
                          console.log(x)
                        }
                        // 依次打印出:4、5
                        

                        观察示例二,数组的迭代器跟生成器函数返回的迭代器又稍有不同。尽管我们在第一次迭代的时候,提前跳出循环了,但是迭代器 iter 并没有关闭。因此,我们可以尝试继续从上一次离开的地方继续迭代,这个离开的地方由迭代器内部维护。因此,数组的迭代器是不能主动关闭的,当它迭代至最后一个成员才会关闭。

                        结合两个示例,同一个迭代器最好不要重复使用,因此才说是一次性对象。

                        迭代器的概念很容易混淆,所以要注意了。

                        例如,数组不是一个迭代器(Iterator),但它是一个可迭代对象(Iterable)。在调用数组实例的 @@iteator 方法,才会生成一个与数组实例相关联的迭代器。

                        还需要注意的是,由于迭代器维护着一个执行可迭代对象的引用,因此迭代器会阻止垃圾回收程序回收可迭代对象。

                        Iterator 遍历过程

                        过程如下:

                        (1)创建一个指针对象,指向当前数据结构的起始位置。也就是说,迭代器对象本质上,就是一个指针对象。 (2)第一次调用指针对象的 next() 方法,可以将指针对象指向数据结构的第一个成员。 (3)第二次调用指针对象的 next() 方法,可以将指针对象指向数据结构的第二个成员。 (4)不断调用指针对象的 next() 方法,直到它指向数据结构的结束位置。

                        每一次调用迭代器的 next() 方法,都会返回数据结构的当前成员的信息,即前面提到的 IteratorResult 对象。它有包含 donevalue 两个属性,其中 done 是一个布尔值,表示遍历是否结束;value 表示当前成员的值。

                        我们基于数组的结构特点,模拟一个简单的 next() 方法(迭代器协议):

                        function createIterator(array) {
                          let nextIndex = 0
                          return {
                            next: function () {
                              return nextIndex < array.length
                                ? { done: false, value: array[nextIndex++] }
                                : { done: true, value: undefined }
                        
                              // 对迭代器对象来说,`done: false` 和 `value: undefined` 都是可以省略的,
                              // 因此可以简写成:
                              // return nextIndex < array.length
                              //   ? { value: array[nextIndex++] }
                              //   : { done: true }
                            }
                          }
                        }
                        
                        const iter = createIterator([1, 2, 3])
                        

                        上面示例中,定义了一个 createIterator 函数,作用是返回一个迭代器。当我们对数组 [1, 2, 3] 执行 createIterator(),就会返回与该数组相关联的遍历器对象 iter(即上文提到的指针对象)。

                        第一次调用指针对象 iternext() 方法,指针指向数组的开始位置,并返回一个 IteratorResult 对象 {done: false, value: 1},它包含了当前数据成员的信息。若 donefalse 表示遍历未结束,即可继续调用 next() 方法访问下一个成员信息......直到 donetrue 遍历结束。

                        其实迭代器(Iterator)与可迭代对象(Iterable)是分开的,迭代器无需了解与其关联的可迭代对象,只需要知道如何取得连续的值。

                        四、默认迭代器

                        迭代器的目的就是提供一种统一的访问机制。可供给 JavaScript 所有接收可迭代对象的语句或方法使用。

                        目前支持以下这些:
                        for...of 语句
                        数组解构
                        扩展运算符
                        Array.from()
                        new Set()
                        new Map()
                        Promise.all()
                        Promise.race()
                        Promise.allSettled()
                        Promise.any()
                        yield* 操作符
                        

                        前面提到,只要某种数据结构(对象)部署了 Iterator 接口,那么我们就称这种数据结构是“可迭代的”。

                        ES6 规定,默认的 Iterator 接口部署在对象的 Symbol.iterator 属性上。该属性本身就是一个函数,就是当前对象的迭代器生成函数。调用此方法就会返回一个迭代器。

                        插个话:

                        千万不要被 Symbol.iterator 属性吓到了,标准规定用它指向对象的默认遍历器方法而已,你完全可以在内心把它当成名称为 iterator 的属性。那么 arr[Symbol.iterator](),就相当于 arr.iterator()arr['iterator']()。它就是一个键名,仅此而已。

                        我们知道使用 Symbol 类型去表示独一无二的值。同样的,当使用 Symbol 值作为对象的键名,就能保证不会出现同名属性。假设我们的默认迭代器部署在一个名称为 iterator(或其他)的属性上,那么是不是很容易被我们改写或覆盖掉。因此指定标准的那帮家伙才会使用 Symbol 值作为键名,并规定它的特殊含义,这样开发者就不会随意覆盖它了。

                        由于 Symbol 值是唯一的,因此不能使用点运算符(.)去访问,需通过方括号([])的形式才能访问。

                        除了 Symbol.iterator,还有很多内置的 Symbol 值。例如 Symbol. hasInstanceSymbol.toPrimitiveSymbol.toStringTag 等等,它们都有特定的含义。有时候也会对应称作 @@iterator@@toPrimitive 等。

                        在 JavaScript 中,有以下这些数据结构原生具备 Iterator 接口:

                        String、Array、TypedArray、Map、 Set、Arguments 和 NodeList。
                        

                        因此我们不用任何处理,无需自己编写迭代器生成函数,就可以使用如 for...of 语句去遍历它们。for...of 内部会自动访问默认 Iterator 接口 Symbol.iterator

                        而普通对象并没有默认部署 Iterator 接口,因此我们不能使用 for...of 去迭代普通对象。但我们可以为它实现自定义迭代器接口。

                        实现迭代器接口,最简单的方法应该是使用 Generator 函数。

                        如何判断某种数据结构是否实现了 Iterator 接口,可以这样:

                        // Symbol.iterator 属性值为 undefined,表示未实现 Iterator 接口
                        console.log(1[Symbol.iterator]) // undefined
                        console.log({}[Symbol.iterator]) // undefined
                        console.log('abc'[Symbol.iterator]) // ƒ [Symbol.iterator]() { [native code] }
                        console.log([][Symbol.iterator]) // ƒ values() { [native code] }
                        

                        五、自定义迭代器

                        普通对象,为什么不实现默认 Iterator 接口。主要是因为对象的属性是无序的,先遍历哪个属性,后遍历哪个属性是不确定的。若需要有序的对象结构,那不就 Map 对象嘛。因此,普通对象好像没必要默认部署迭代器接口了。

                        尽管原生未部署 Iterator 接口,我们可以自定义啊,标准又没有限制我们不能自定义对吧。

                        我们来定义一个 Counter 类,并实现 Iterator 接口。

                        1. 粗略版本

                        如下:

                        class Counter {
                          constructor([min = 0, max = 10]) {
                            this.min = min
                            this.max = max
                          }
                        
                          // 实现迭代器协议
                          next() {
                            const isDone = this.min > this.max
                            return {
                              done: isDone,
                              value: isDone ? undefined : this.min++
                            }
                            // 迭代器的 next() 方法,需要返回一个对象,
                            // 对象包括 { done, value } 属性,
                            // 其中 done 为 false,或 value 为 undefined 时,返回值可省略该属性。
                            // 这里返回 this(即实例对象),它的原型上实现了 next() 方法
                          }
                        
                          // 实现可迭代协议
                          [Symbol.iterator]() {
                            return this
                            // @@iterator 方法返回一个迭代器,它是一个对象。
                            // 不能返回类似 return 1 等无意义的值,否则进行迭代操作时会报错。
                          }
                        }
                        

                        以上 Counter 类实现了可迭代协议和迭代器协议,因此其实例对象就具备了 Iterator 接口,可使用 for...of 进行迭代。

                        const counter = new Counter([0, 3]) // { min: 0, max: 3 }
                        for (const x of counter) {
                          console.log(x)
                        }
                        // 依次打印出:0、1、2、3
                        

                        尽管实现了 Iterator 接口,但不理想。原因是每个实例只能被迭代一次,而且实例的 min 属性值会发生变化。

                        // 上一个代码块里面,counter 实例对象已迭代过一次
                        // 现在尝试再迭代一次,以下代码并不会打印出什么
                        for (const x of counter) {
                          console.log(x)
                        }
                        
                        // 原因很简单:
                        // 当我们再次使用 for...of 之前,counter 实例已经变成:{ mix: 4, max: 3 }
                        // 然后执行到 for...of 的时候,内部做了以下这些步骤:
                        // 1) 首先创建一个迭代器,即访问实例的 @@iterator 方法,
                        //    返回的迭代器就是 this,值为 { mix: 4, max: 3 }。
                        // 2) 接着循环,其实就是不停调用迭代器的 next() 方法,即 this.next()
                        //    那么循环什么时候终止呢,那就是 done 为 true 时,表示遍历结束。
                        //    此时,第一次调用 next() 方法,返回值 done 就是为 true,即结束了。
                        //    因此,压根不会执行 for...of 的循环体,也就不会打印相关值了。
                        //
                        // 可通过以下示例去验证:
                        // for (const x of counter) {
                        //   console.log(x)
                        //   if (x === 1) break
                        // }
                        // 依次打印:0、1
                        // for (const x of counter) {
                        //   console.log(x)
                        // }
                        // 依次打印:2、3
                        

                        2. 改进版本

                        鉴于以上原因,我们还需要改进一下。

                        为了让一个实例对象能够创建多个迭代器,必须每创建一个迭代器就对应一个新计算器。因此,我们可以把计算器变量放到闭包里,然后通过闭包返回迭代器。

                        class Counter {
                          constructor([min = 0, max = 10]) {
                            this.min = min
                            this.max = max
                          }
                        
                          [Symbol.iterator]() {
                            let point = this.min
                            const end = this.max
                            return {
                              next: () => {
                                const isDone = point > end
                                return {
                                  done: isDone,
                                  value: isDone ? undefined : point++
                                }
                              }
                            }
                          }
                        }
                        
                        const counter = new Counter([0, 3])
                        for (const x of counter) {
                          console.log(x)
                        }
                        // 依次打印出:0、1、2、3
                        for (const x of counter) {
                          console.log(x)
                        }
                        // 依次打印出:0、1、2、3
                        

                        以上这种改进写法,与数组迭代的表现是相似的。

                        3. 完善版本

                        但目前跟数组,还存在一点区别。例如:

                        // ✅ 数组可以这样使用
                        const arr = [0, 1, 2, 3]
                        const iter = arr[Symbol.iterator]()
                        for (const x of iter) {
                          console.log(x)
                        }
                        // 依次打印:0、1、2、3
                        
                        
                        // ❌ 但是如果 Counter 这样去使用
                        const counter = new Counter([0, 3])
                        const iter = counter[Symbol.iterator]()
                        // 当代码执行到 for...of 这行的时候,发现就会报错:TypeError: iter is not iterable
                        for (const x of iter) {
                          console.log(x)
                        }
                        // 原因分析:
                        // 其实很简单,使用 for...of 的时候,内部会自动访问 iter[Symbol.iterator],
                        // 但 iter 是仅含有 next 属性(不计原型)的普通对象,并没有 Symbol.iterator 属性,
                        // 因此判断 iter 不是一个可迭代对象,所以报错:TypeError: iter is not iterable
                        

                        解决方法也很简单,只要我们给 iter 添加一个 Symbol.iterator 属性,并返回其本身即可。

                        class Counter {
                          constructor([min = 0, max = 10]) {
                            this.min = min
                            this.max = max
                          }
                        
                          [Symbol.iterator]() {
                            let point = this.min
                            const end = this.max
                            return {
                              [Symbol.iterator]() {
                                // 不能使用箭头函数,否则 this 指向就不对了
                                // 因此,我也把下面 next 方法,将原来的箭头函数改回普通函数
                                return this
                              },
                              next() {
                                const isDone = point > end
                                return {
                                  done: isDone,
                                  value: isDone ? undefined : point++
                                }
                              }
                            }
                          }
                        }
                        

                        再次执行,就可以了。

                        const counter = new Counter([0, 3])
                        const iter = counter[Symbol.iterator]()
                        for (const x of iter) {
                          console.log(x)
                        }
                        // 依次打印出:0、1、2、3
                        

                        4. 更完善版本

                        假设我们在 for...of 循环使用 breakcontinuereturnthrow 提前退出,或者使用解构操作未消费所有值时,我们希望就此“关闭”迭代器(即 done 变为 true)。例如,生成器对象若提前退出,它的状态就会关闭。

                        怎么实现呢?

                        ECMAScript 标准为我们提供了一个“可选”的 return() 方法,它属于迭代器协议的一部分。

                        由于 return() 方法是可选的,因此并非所有数据结构的默认迭代器都是可关闭的。比如,数组的迭代器就是不可关闭的。而生成器对象就是可关闭的。

                        数组迭代器示例:

                        const arr = [1, 2, 3, 4, 5]
                        const iter = arr[Symbol.iterator]()
                        
                        for (const x of iter) {
                          console.log(x)
                          if (x > 3) break
                        }
                        // 依次打印:1、2、3
                        
                        for (const x of iter) {
                          console.log(x)
                        }
                        // 依次打印:4、5
                        
                        // 需要注意的是:
                        // `for (const x of arr) {}` 与 `for (const x of iter) {}` 是有区别的,
                        // 前者每次调用,内部都会产生一个全新的迭代器,因此每次迭代,都会输出 0 ~ 5;
                        // 而后者则同一个迭代器,因此再次迭代时,会接着遍历下一个成员。
                        // 所以,不用误以为数组的迭代器是“可关闭的”。
                        

                        生成器对象示例:

                        function* generatorFn() {
                          yield 0
                          yield 1
                          yield 2
                          yield 3
                        }
                        
                        // 生成器对象,也是一个迭代器。
                        const gen = generatorFn() // generatorFn {<suspended>}
                        for (const x of gen) {
                          console.log(x)
                          if (x === 1) break
                        }
                        console.log(gen) // generatorFn {<closed>}
                        // 迭代器“关闭”之后,再次去迭代的话就没意义了,它不会打印任何东西。
                        for (const x of gen) {
                          // do something...
                        }
                        

                        其实 return() 的实现要求也很简单,它必须返回一个有效的 IteratorResult 对象。一般情况,可以只返回 { done: true }。因为这个返回值只会用在生成器的上下文中。

                        我们基于前面的 Counter 类,修改一下:

                        class Counter {
                          constructor([min = 0, max = 10]) {
                            this.min = min
                            this.max = max
                          }
                        
                          [Symbol.iterator]() {
                            let point = this.min
                            const end = this.max
                            let isDone
                            return {
                              [Symbol.iterator]() {
                                return this
                              },
                              next() {
                                isDone = isDone || (point > end)
                                return {
                                  done: isDone,
                                  value: isDone ? undefined : point++
                                }
                              },
                              return() {
                                console.log('Exiting early')
                                isDone = true
                                return { done: true }
                                // 只会在“提前退出”才会触发此方法,
                                // 正常遍历结束,是不会执行此方法的。
                              }
                            }
                          }
                        }
                        

                        上面除了实现了 return() 方法,且在触发 return() 方法时,迭代器也会“关闭”,只要将原先表示是否结束的 isDone 变量提到外面一层,利用闭包即可实现。

                        以下为“提前退出”的情况,可以看到都触发了此方法。

                        const counter = new Counter([0, 3])
                        for (const x of counter) {
                          console.log(x)
                          if ( x === 1 ) break // 或 throw 'xxx' 或 return 都行
                        }
                        // 依次打印出:0、1、"Exiting early"
                        
                        const [a, b] = counter
                        // "Exiting early"
                        

                        利用迭代器实例的 return 属性,就可以判断一个可迭代对象是否具备可关闭的 Iterator 接口。但注意,我们不能给一个不可关闭的迭代器仅增加 return() 方法,使其变成可关闭的。除非自定义 Iterator 接口去覆盖原生的 Symbol.iterator 方法。

                        5. 最终版本

                        既然又要提前关闭,为什么不直接利用 Generator 函数呢,简单又快捷。

                        class Counter {
                          constructor([min = 0, max = 10]) {
                            this.min = min
                            this.max = max
                          }
                        
                          *[Symbol.iterator]() {
                            let point = this.min
                            const end = this.max
                            while (point <= end) {
                              yield point++
                            }
                          }
                        }
                        

                        一般情况下,Generator 函数是实现自定义迭代器的一种选择。它非常强大,语法上更为简练。但如果要实现类似数组默认迭代器那样的话,就不能用它了。

                        若对 Generator 函数不太熟悉,可看这篇文章

                        六、Iterator 应用场景

                        无论是默认的迭代器接口,还是自定义迭代器接口,它们主要供以语法或方法消费。目前有以下这些方法:

                        • for...of 循环
                        • 数组解构
                        • 扩展运算符
                        • Array.from()
                        • new Set()
                        • new Map()
                        • Promise.all()
                        • Promise.race()
                        • Promise.allSettled()
                        • Promise.any()
                        • yield* 操作符

                        这些语法(方法)内部都会去访问(可迭代)对象的 @@iterator 方法,或叫作 Symbol.iterator 方法。

                        The end.

                        ]]>
                        <![CDATA[细读 ES6 | Promise 下篇]]> https://github.com/tofrankie/blog/issues/256 https://github.com/tofrankie/blog/issues/256 Sun, 26 Feb 2023 11:52:31 GMT 配图源自 Freepik

                        上一篇,继续介绍了 Promise 相关 API。

                        一、Promise.resolve()

                        Promise.resolve() 方法的作用就是将某个值(非 Promise 对象实例)转换为 Promise 对象实例。

                        const promise = Promise.resolve('foo')
                        // 相当于
                        const promise = new Promise(resolve => resolve('foo'))
                        

                        需要注意的是,它仍然会遵循 Event Loop 机制,包括后面介绍的其他 API。具体执行顺序本文不展开讨论。

                        Promise.resolve() 方法的参数分为四种情况:

                        1. 不带任何参数

                        它返回一个状态为 fulfilled,值为 undefinedPromise 实例对象。

                        const promise = Promise.resolve()
                        
                        // promise 结果:
                        // {
                        //   [[Prototype]]: Promise,
                        //   [[PromiseState]]: "fulfilled",
                        //   [[PromiseResult]]: undefined
                        // }
                        

                        2. 参数是一个 Promise 实例对象

                        这时,Promise.resolve() 将会不做任何修改、原封不动地返回该实例。

                        请注意,即使参数是一个 rejected 状态的 Promise 实例,返回的实例也不会变成 fulfilled 状态,不要被这个 resolve 字面意思误解了。

                        const p1 = new Promise(resolve => resolve({ name: 'Frankie' })) // "fulfilled"
                        const p2 = new Promise((resolve, reject) => reject({ name: 'Frankie' })) // "rejected"
                        const p3 = Promise.resolve(p1)
                        const p4 = Promise.resolve(p2)
                        
                        console.log(p1 === p3) // true
                        console.log(p2 === p4) // true
                        
                        // p3 结果:
                        // {
                        //   [[Prototype]]: Promise,
                        //   [[PromiseState]]: "fulfilled",
                        //   [[PromiseResult]]: { name: 'Frankie' }
                        // }
                        
                        // p4 结果:
                        // {
                        //   [[Prototype]]: Promise,
                        //   [[PromiseState]]: "rejected",
                        //   [[PromiseResult]]: { name: 'Frankie' }
                        // }
                        

                        其实这种情况,就是上一篇提到过的。

                        const p5 = new Promise(resolve => resovle(1))
                        const p6 = new Promise(resolve => {
                          reslove(p5)
                          // 注意,不要尝试在此处调用 Promise.resolve(),会导致无限递归。
                        })
                        

                        上面示例中,p6 的状态取决于 p5 的状态。

                        3. 参数是一个 thenable 对象

                        thenable 对象,是指具有 then 方法的对象。例如:

                        const obj = {
                          then: function(resolve, reject) {
                            resolve('foo')
                          }
                        }
                        

                        上面示例中,obj 对象就是一个 thenable 对象。Promise.resolve() 方法会将这个 thenable 对象转为 Promise 对象,然后就立即执行 thenable 对象的 then() 方法。

                        const obj = {
                          then: function (resolve, reject) {
                            console.log(2)
                            resolve('foo')
                            // reject('foo') // 如果是这样,最终 promise 对象将会变成了 rejected 状态。
                          }
                        }
                        const promise = Promise.resolve(obj)
                        
                        promise.then(res => {
                          console.log(3)
                          console.log(res) // "foo"
                        })
                        
                        console.log(1)
                        // 打印结果分别是 1、2、3、"foo"
                        
                        // promise 结果:
                        // {
                        //   [[Prototype]]: Promise,
                        //   [[PromiseState]]: "fulfilled",
                        //   [[PromiseResult]]: "foo"
                        // }
                        

                        上述示例中,obj 对象的 then() 方法执行后,对象 promise 的状态变成了 fulfilled,接着执行最后的那个 promise.then() 方法,打印出 "foo"

                        4. 参数是一个不具有 then() 方法的对象,或者压根不是一个对象,而是原始值。

                        如果是这种情况,Promise.resolve() 方法返回一个新的 Promise 对象,状态为 fulfilled,且该实例对象的值,就是该参数值。

                        const p1 = Promise.resolve('foo')
                        const p2 = Promise.resolve({})
                        
                        // p1 结果:
                        // {
                        //   [[Prototype]]: Promise,
                        //   [[PromiseState]]: "fulfilled",
                        //   [[PromiseResult]]: "foo"
                        // }
                        
                        // p2 结果:
                        // {
                        //   [[Prototype]]: Promise,
                        //   [[PromiseState]]: "fulfilled",
                        //   [[PromiseResult]]: {}
                        // }
                        

                        在实际项目中,一般是第 4 种情况居多,我似乎真的没见过前三种情况的。

                        二、Promise.reject()

                        Promise.reject() 方法会返回一个新的 Promise 实例对象,该实例的状态总是为 rejected

                        const promise = Promise.reject('foo')
                        
                        // 相当于
                        const promise = new Promise((resolve, reject) => reject('foo'))
                        

                        Promise.resolve() 不同的是,Promise.reject() 方法的参数(无论是原始值、普通对象、还是 Promise 实例对象),将会原封不动地作为返回实例对象的值。

                        Promise.reject('Oops').catch(err => {
                          console.log(err === 'Oops') // true
                          // do something...
                        })
                        

                        三、Promise.all()

                        Promise.all() 方法用于将多个 Promise 实例,包装成一个新的 Promise 实例。

                        const promise = Promise.all([p1, p2, p3])
                        

                        上面代码中,Promise.all() 方法接受一个数组作为参数,其中 p1p2p3 都是 Promise 实例。如果数组中包含非 Promise 实例,它们会使用 Promise.resolve() 的方法,将参数转为 Promise 实例,再进一步处理。另外,Promise.all() 方法的参数可以不是数组,但必须具有 Iterator 接口,且返回的每个成员都是 Promise 实例。

                        其中 promise 的状态由 p1p2p3 决定,分为两种情况。

                        • 只有当 p1p2p3 的状态都变成 fulfilledpromise 的状态才会变成 fulfilled,此时 p1p2p3 实例的值,会组成一个数组,并传递给 promise

                        • 只要 p1p2p3 之中有一个被 rejectedpromise 的状态就会变成 rejected。此时第一个 rejected 实例的值(注意,不会像上面一样组成数组哦),会传递给 promise

                        看个例子:

                        const userIds = [1, 3, 5]
                        const promiseArr = userIds.map(id => {
                          return window.fetch(`/config/user/${id}`) // 假设是请求用户配置
                        })
                        
                        Promise
                          .all(promiseArr)
                          .then(res => {
                            // res 是一个数组,每一项对应每个实例的值,即 [[PromiseResult]]
                            // 常见做法是将 res 进行解构,即 Promise.all(promiseArr).then(([a, b, c]) => { /* do something... */ })
                            // 假设 promiseArr 是一个空的可迭代对象,例如空数组,Promise.all([]) 实例状态为 fulfilled,值为 []。
                            // do something...
                          })
                          .catch(err => {
                            // err 为 Promise.all() 被 rejected 的原因(reason)
                          })
                        

                        上面的示例中,promiseArr 是包含 3 个 Promise 实例的数组,只有这 3 个实例的状态都变成 fulfilled,或其中一个变为 rejected,才会调用 Promise.all() 方法的回调函数。

                        四、Promise.race()

                        Promise.race() 方法同样是将多个 Promise 实例,包装成一个新的 Promise 实例。

                        const promise = Promise.race([p1, p2, p3])
                        

                        Promise.race() 方法同样接受一个可迭代对象,只要 p1p2p3 中有一个实例率先改变状态(fulfiledrejected),promise 的状态就会跟着改变,而且 promise 实例的值就是率先改变的实例的返回值。若可迭代对象中的某一项不是 Promise 实例,仍会使用 Promise.resolve() 进行转换。

                        当传递一个空的可迭代对象,那么 Promise.race() 实例的状态将会一直停留在 pending。这点跟 Promise.all() 是不同的。

                        const p1 = Promise.all([])
                        const p2 = Promise.race([])
                        
                        setTimeout(() => {
                          console.log(p1) // Promise {<fulfilled>: Array(0)}
                          console.log(p2) // Promise {<pending>}
                        })
                        

                        五、Promise.allSettled()

                        Promise.allSettled() 是 ES11 标准引入的一个方法,同样还是将多个 Promise 实例包装成一个新的 Promise 实例。只有等所有实例都返回结果(无论是 fulfilledrejected),包装实例的状态才会发生变化。

                        我认为,这算是对 Promise.all() 存在 rejected 实例情况的一种补全吧。

                        注意,Promise.allSettled() 的状态,只可能是 pendingfulfilled 状态,不可能存在 rejected 状态。即只会从 pending -> fulfilled 的变化。

                        我们来看看以下示例,各种情况的结果吧:

                        const p1 = Promise.reject(1)
                        const p2 = Promise.resolve(2)
                        const p3 = new Promise((resolve, reject) => {
                          setTimeout(() => { reject(3) }, 1000)
                        })
                        const p4 = new Promise(() => { }) // p4 状态将会一直停留在 pending
                        
                        const p5 = Promise.allSettled([]) // 参数为空迭代对象
                        const p6 = Promise.allSettled([p4])
                        const p7 = Promise.allSettled([p1, p2, p3])
                        
                        
                        setTimeout(() => {
                          console.log('p1:', p1)
                          console.log('p2:', p2)
                          console.log('p3:', p3)
                          console.log('p4:', p4)
                          console.log('p5:', p5)
                          console.log('p6:', p6)
                          console.log('p7:', p7)
                        
                          p5.then(res => {
                            console.log('p5 then:', res)
                          })
                          p6.then(res => {
                            // 这里将不会执行,因为 p6 一直处于 pending 状态
                            console.log('p6 then:', res)
                          })
                          p7.then(res => {
                            console.log('p7 then:', res)
                          })
                        }, 2000)
                        

                        列举以上示例,是为了得出以下结论:

                        • Promise.allSettled() 一定要等到参数中每一个 Promise 状态定型后,它返回的实例对象才会定型为 fulfilled 状态。否则只会是 pending 状态。

                        • 类似 Promise.allSettled([]) 把一个空数组(空的迭代对象)作为参数,最后实例的状态为 fulfilled,且实例的值为空数组 []

                        • 注意 Promise.allSettled() 返回的实例的值,首先它是一个数组,而数组每项都是一个对象,该对象的属性取决于对应参数 Promise 实例的状态。

                          例如 p1 的状态为 rejectedp2 的状态为 fulfilled。因此包装实例的前两项的对象分别为 { status: "rejected", reason: 1 }{ status: "fulfilled", value: 2 },其他项同理。其中 status 属性只会是 fulfilledrejected 两个字符串值。主要区别在于 value 属性和 reason 属性,即 fulfilled 状态对应 value 属性,而 rejected 状态对应 reason 属性。

                        有时候,我们不关心异步操作的结果,只关心这些操作有没有结束。这时,使用 Promise.allSettled() 方法就很有用了。而 Promise.all() 是没办法确保这一点的。

                        六、Promise.any()

                        在 ES12 标准中,引入了 Promise.any() 方法,它用于将多个 Promise 实例,包装成一个新的 Promise 实例。

                        Promise.any() 接受一个 Promise 可迭代对象,只要参数实例中有一个变成 fulfilled 状态,包装实例就会变成 fulfilled 状态,其值就是参数实例的值。

                        Promise.any()Promise.race() 很像,只有一个不同点,就是 Promise.any() 不会因为某个参数 Promise 实例变成 rejected 状态而接受,必须要等到所有参数实例的状态都变为 rejected,包装实例的状态才会是 rejected

                        const p1 = Promise.reject(1)
                        const p2 = Promise.resolve(2)
                        const p3 = new Promise((resolve, reject) => {
                          setTimeout(() => { reject(3) }, 1000)
                        })
                        const p4 = new Promise(() => { }) // p4 状态将会一直停留在 pending
                        
                        const p5 = Promise.any([]) // p5 会变成 rejected 状态
                        const p6 = Promise.any([p4])
                        const p7 = Promise.any([p1, p2, p3])
                        const p8 = Promise.any([p1, p3])
                        
                        
                        setTimeout(() => {
                          console.log('p1:', p1)
                          console.log('p2:', p2)
                          console.log('p3:', p3)
                          console.log('p4:', p4)
                          console.log('p5:', p5)
                          console.log('p6:', p6)
                          console.log('p7:', p7)
                          p5.then(res => {
                            console.log('p5 then:', res)
                          }).catch(err => {
                            // p5 的状态会变成 rejected,因此会执行到这里。
                            console.log('p5 catch:', err)
                          })
                          p6.then(res => {
                            // p6 的状态一直会是 pending,因此不会执行回调。
                            console.log('p6 then:', res)
                          })
                          p7.then(res => {
                            console.log('p7 then:', res)
                          })
                          p8.then(res => {
                            console.log('p8 then:', res)
                          }).catch(err => {
                            // 注意 err 是一个对象
                            console.log('p8 catch:', err)
                            console.dir(err)
                          })
                        }, 2000)
                        

                        Promise.any() 返回的实例变成 rejected 时,其实例的值是 AggregateError 实例。但传递一个空的迭代对象,Promise.any() 包装实例也会变成 rejected 状态,如 p5

                        七、总结

                        关于 Promise.all()Promise.race()Promise.allSettled()Promise.any() 方法,总结以下特点。

                        • 它们的用处都是将多个 Promise 实例,包装成一个新的 Promise 实例。

                        • 它们都接受一个具有 Iterator 接口的可迭代对象,通常为数组。且会返回一个新的 Promise 实例对象。

                        • 它们处理参数为空的可迭代对象的方式不一样,本来就是要处理多个 Promise 对象,才会用到它们,所以这种情况无需理会。真遇到再回来翻阅文档即可,现在我写到这里都记不太清楚其中的区别了,但问题不大。

                        • Promise.all() 当所有实例均为 fulfilled 状态,最终的包装实例才会是 fulfilled,其值是一个数组。否则将会是 rejected 状态;

                        • Promise.race() 则是某个实例的状态发生变化,最终包装实例将对应率先变化实例所对应的值和状态。“发生变化”是指 pending -> fulfilledpending -> rejected

                        • Promise.allSettled() 单从命名上来猜测,就知道它需要等所有参数实例确定状态后,包装实例的状态才会变成 fulfilled 状态,注意它不存在 rejected 状态的情况。包装实例的返回值是一个数组,数组每项可能是 { status: "fulfilled", value: /* 对应 fulfilled 的值 */ }{ status: "rejected", reason: /* 对应 rejected 的原因 */ },取决于每个参数实例的状态。

                        • Promise.any() 当某个参数实例的状态变为 fulfilled,那么包装实例就定型了,对应该参数实例的状态和值。否则它必须等到所有参数实例变为 rejected 状态,包装实例的状态才会发生改变,变为 rejected,其值是一个 AggregateError 实例。

                        ]]>
                        <![CDATA[细读 ES6 | Promise 上篇]]> https://github.com/tofrankie/blog/issues/255 https://github.com/tofrankie/blog/issues/255 Sun, 26 Feb 2023 11:52:17 GMT 配图源自 Freepik

                        我认为 Promise 应该算是 ES6 标准最大的亮点,它提供了异步编程的一]]> 配图源自 Freepik

                        我认为 Promise 应该算是 ES6 标准最大的亮点,它提供了异步编程的一种解决方案。比传统的回调函数和事件解决方案,它更合理、更强大。

                        一、简介

                        Promise 是一个容器,里面保存着某个未来才会结束的事件(一般为异步操作)的结果。从语法上来说,Promise 是一个对象,它可以获取异步操作的消息。

                        Promise 对象的特点:

                        • Promise 对象有且只有三种状态:pendingfulfilledrejected,分别表示进行中、已成功、已失败。

                        • 一旦状态发生改变,就不会再变。状态的改变只有两种可能:pending -> fulfilledpending -> rejected。若发生了其中一种情况,状态就会一直保存这个结果,这时就成为 resolved(已定型)。

                        这种以“同步的方式”去表达异步流程,可以避免层层嵌套的回调函数,避免出现“回调地狱”(Callback Hell)。

                        BTW,网上有些文章把 fulfilled 状态,叫成 resolved,尽管我们可能知道他想表达的意思,但其实是不对的。

                        Promise 对象的缺点:

                        一是无法取消 Promise,一旦创建它就会立即执行,无法中途取消;二是若不设置回调函数情况下,Promise 内部抛出错误,不会反馈到外部;三是当处于 pending 状态,无法得知目前进展到哪个阶段。

                        二、Promise 用法

                        根据规定,Promise 是一个构造函数,用来生成 Promise 实例对象。

                        1. 创建 Promise 对象

                        示例:

                        const handler = (resolve, reject) => {
                          // some statements...
                        
                          // 根据异步操作的结果,通过 resolve 或 reject 函数去改变 Promise 对象的状态
                          if (true) {
                            // pending -> fulfilled
                            resolve(...)
                          } else {
                            // pending -> rejected
                            reject(...)
                          }
                        
                          // 需要注意的是:
                          // 1. 在上面 Promise 状态已经定型(fulfilled 或 rejected),
                          //    因此,我们再使用 resolve() 或 reject() 或主动/被动抛出错误的方式,
                          //    试图再次修改状态,是没用的,状态不会再发生改变。
                          // 2. 当 Promise 对象的状态“已定型”后,若未使用 return 终止代码往下执行,
                          //    后面代码出现的错误(主动抛出或语法错误等),在外部都不可见,无法捕获到。
                          // 3. hander 函数的返回值是没意义的。怎么理解?
                          //    假设内部不包括 resolve() 或 reject() 或内部不出现语法错误,
                          //    或不主动抛出错误,仅有类似 `return 'anything'` 语句,
                          //    那么 promise 对象永远都是 pending 状态。
                        }
                        
                        const promise = new Promise(handler)
                        

                        Promie 构造函数接受一个函数作为参数,该函数的两个参数分别是 resolvereject。而 resolverejeact 也是函数,其作用是改变 Promise 对象的状态,分别是 pending -> fulfilledpending -> rejected

                        假设构造函数内不指定 resolvereject 函数,那么 Promise 的对象会一直保持着 pending 待定的状态。

                        2. Promise 实例

                        Promise 实例生成以后,当 Promise 内部状态发生变化,可以使用 Promise.prototype.then() 方法获取到。

                        const success = res => {
                          // 当状态从 pending 到 fulfilled 时,执行此函数
                          // some statements...
                        }
                        
                        const fail = err => {
                          // 当状态从 pending 到 rejected 时,执行此函数
                          // some statements...
                        }
                        
                        promise.then(success, fail)
                        

                        then() 方法接受两个回调函数作为参数,第一个回调函数在 Promise 对象状态变为 fulfilled 时被调用。第二回调函数在状态变为 rejected 时被调用。then() 方法的两个参数都是可选的。

                        注意,由于 Promise 实例对象的 Promise.prototype.then()Promise.prototype.catch()Promise.prototype.finally() 方法属于异步任务中的微任务。注意它们的执行时机,会在当前同步任务执行完之后,且在下一次宏任务执行之前,被执行。

                        还有,Promise 构造函数(即上述示例的 handler 函数)内部,仍属于同步任务,而非异步任务。

                        所以,那个经典的面试题就是,包括 setTimeoutPromise 等,然后问输出顺序是什么?本质就是考察 JavaScript 的事件循环机制(Event Loop)嘛。可以看下这篇文章:通过两个例子再探 Event Loop

                        插了个话题,回来

                        then() 方法的两个参数 success()fail(),它们接收的实参就是传递给 resolve()reject() 的值。

                        例如:

                        function timeout(delay, status = true) {
                          const promise = new Promise((resolve, reject) => {
                            setTimeout(() => {
                              // 一般 reject 应返回一个 Error 实例对象,如:new Error('Oops')
                              status ? resolve('Success') : reject('Oops')
                            })
                          }, delay)
                          return promise
                        }
                        
                        // 创建两个 Promise 实例对象
                        const p1 = timeout(1000)
                        const p2 = timeout(1000, false)
                        
                        // pending -> fulfilled
                        p1.then(res => {
                          console.log(res) // "Success"
                        })
                        
                        // pending -> rejected
                        p2.then(null, err => {
                          console.warn(err) // "Oops"
                        })
                        

                        上面示例中,根据 timeout 函数的逻辑,p1 实例的 Promise 状态会从 pending -> fulfilled,而 p2 实例则是从 pending -> rejected。因此会分别打印出 "Success""Oops"

                        例如,异步加载图片的例子。

                        function loadImage(url) {
                          return new Promise((resolve, reject) => {
                            const image = new Image()
                            image.onload = function () {
                              resolve(image)
                            }
                            image.onerror = function () {
                              reject(new Error(`Could not load image at ${url}.`))
                            }
                            image.src = url
                          })
                        }
                        
                        loadImage('https://jquery.com/jquery-wp-content/themes/jquery/images/logo-jquery@2x.png')
                          .then(
                            res => {
                              console.log('Image loaded successfully:', res)
                            },
                            err => {
                              console.warn(err)
                            }
                          )
                        

                        因此,Promise 的用法还是很简单的,是预期结果的话,使用 resolve() 修改状态为 fulfilled,非预期结果使用 reject() 修改状态为 rejected。具体返回值根据实际场景返回就好。

                        3. Promise 注意事项

                        在构建 Promise 对象的内部,使用 resolve()reject() 去改变 Promise 的状态,并不会终止 resolvereject 后面代码的执行。

                        例如:

                        const promise = new Promise((resolve, reject) => {
                          resolve(1)
                          // 以下代码仍会执行,且会在 then 之前执行。
                          // reject() 同理。
                          console.log(2)
                        })
                        
                        promise.then(res => { console.log(res) }) // 先后打印出 2、1
                        

                        若要终止后面的执行,只要使用 return 关键字即可,类似 return resolve(1)return reject(1)。但如果这样,其实后面的代码就没意义,因此也就没必要写了。千万别在工作中写出这样的代码,我怕你被打。这里只是为了说明 resolvereject 不会终止后面的代码执行而已。

                        一般来说,调用 resolve()reject() 说明异步操作有了结果,那么 Promise 的使命就完成了,后续的操作应该是放到 then() 方法里面,而不是放在 resolve()reject() 后面。

                        在前面的示例中,resolve()reject() 都是返回一个“普通值”。如果我们返回一个 Promise 对象,会怎样呢?

                        首先,它是允许返回一个 Promise 对象的,但是有些区别。

                        const p1 = new Promise((resolve, reject) => {
                          setTimeout(() => {
                            resolve('p1 success')
                            // reject('p1 fail')
                          }, 3000)
                        })
                        
                        const p2 = new Promise((resolve, reject) => {
                          // 这时返回一个 Promise 对象
                          // ⚠️ 注意,这里 resolve(p1) 或 reject(p1) 执行的逻辑会有所不同。
                          resolve(p1)
                          // reject(p1)
                        })
                        
                        p2.then(
                          res => {
                            console.log('p2 then:', res)
                          },
                          err => {
                            console.log('p2 catch:', err)
                          }
                        )
                        

                        分析如下:

                        1. 若 p2 内里面 `resolve(p1)` 时:
                        
                           当代码执行到 `resolve(p1)` 时,由于 p1 的状态仍是 pending,
                           这时 p1 的状态会传递给 p2,也就是说 p1 的状态决定了 p2 的状态,
                           因此 `p2.then()` 需要等 p1 的状态发生变化,才会被调用,
                           且 `p2.then()` 获取到的状态就是 p1 的状态
                        
                           假设代码执行到 `resolve(p1)` 时,若 p1 的状态已定型,即 fulfilled 或 rejected,
                           会立即调用 `p2.then()` 方法。
                           PS:这里“立即”是指,当前同步任务已执行完毕的前提下。第 2 点也是如此。
                          
                        2. 若 p2 内是 `reject(p1)` 时,情况会有所不同:
                        
                           当代码执行到 `reject(p1)` 时,由于 p2 的状态会变更为 rejected,
                           接着会立即调用 `p2.then()` 方法,由于是 rejected 状态,
                           因此,会触发 `p2.then()` 的第二个参数,此时 err 的值就是 p1(一个 Promise 对象)。
                        
                           假设 p1 的状态最终变成了 rejected,那么 err 还要捕获异常,
                           例如 `err.catch(err => { /* do something... */ })`,
                           否则的话,在控制台会报错,类似:"Uncaught (in promise) p1 fail",
                           原因就是 Promise 对象的 rejected 状态未处理,导致的。
                        
                           假设 p1 的状态最终变成 fulfilled,那么不需要做上一步类似的处理。
                        

                        上面两种情况,其实相当于 Promise.resolve(p1)Promise.reject(p1)。我们来打印一下两种结果:

                        p1 状态为 fulfilled 时,p2 状态如图:

                        p1 状态为 rejected 时,p2 状态如图:

                        三、Promise.prototype.then()

                        Promise 的实例具有 then() 方法,它是定义在原型对象 Promise.prototype 上的。当 Promise 实例对象的状态发生变化,此方法就会被触发调用。

                        前面提到 Promise.prototype.then() 接受两个参数,两者均可选,这里不再赘述。

                        then() 方法返回一个新的 Promise 实例对象(注意,不是原来那个 Promise 实例),也因此可以采用链式写法,即 then() 方法后面可以再调用另一个 then() 方法。

                        例如,以下示例使用 Fetch API 进行网络请求:

                        window.fetch('/config')
                          .then(response => response.json())
                          .then(
                            res => {
                              // do something...
                            },
                            err => {
                              // do something...
                            }
                          )
                          // .then() // ...
                        

                        以上链式调用,会按照顺序调用回调函数,后一个 then() 的执行,需等到前一个 Promise 对象的状态定型。

                        四、Promise.prototype.catch()

                        Promise.prototype.catch() 方法是 then(null, rejection)then(undefined, rejection) 的别名,用于指定发生错误时的回调函数。

                        同样地,它会返回一个新的 Promise 实例对象。

                        const promise = new Promise((resolve, reject) => {
                          reject('Oops') // 或通过 throw 方式主动抛出错误,使其变成 rejected 状态
                          // 但注意的是,前面状态“定型”之后,状态是不会再变的。
                          // 这后面试图改变状态,或主动抛出错误,或出现其他语法错误,
                          // 不会被外部捕获到,即无意义。
                        })
                        
                        promise.catch(err => {
                          console.log(err) // "Oops"
                        })
                        
                        // 相当于
                        promise.then(
                          null,
                          err => {
                            console.log(err) // "Oops"
                          }
                        )
                        

                        通过 throw 等方式使其变成 rejected 状态,相当于:

                        const promise = new Promise((resolve, reject) => {
                          try {
                            throw 'Oops' 
                            // 一般地,是抛出一个 Error(或派生)实例对象,如 throw new Error('Oops')
                          } catch (e) {
                            reject(e)
                          }
                        })
                        

                        五、捕获 rejected 状态的两种方式比较

                        前面提到有两种方式,可以捕获 Promise 对象的 rejected 状态。那么孰优孰劣呢?

                        建议如下:

                        尽量不要在 Promise.prototype.then() 方法里面定义 onRejection 回调函数(即 then() 的第二个参数),总使用 Promise.prototype.catch() 方法。

                        const promise = new Promise((resolve, reject) => {
                          // some statements
                        })
                        
                        // bad
                        promise.then(
                          res => { /* some statements */ },
                          err => { /* some statements */ }
                        )
                        
                        // good
                        promise
                          .then(res => { /* some statements */ })
                          .catch(err => { /* some statements */ })
                        

                        上面示例中,第二种写法要好于第一种写法。理由是第二种写法可以捕获前面 then() 方法中的异常或错误,也更接近同步写法(try...catch)。因此,建议总是使用 Promise.prototype.catch() 方法。

                        与传统的 try...catch 代码块不同的是,即使 Promise 内部出现错误,也不会影响 Promise 外部代码的执行。

                        const promise = new Promise((resolve, reject) => {
                          say() // 这行会报错:ReferenceError: say is not defined
                        })
                        
                        promise.then(res => { /* some statements */ })
                        
                        setTimeout(() => {
                          console.log(promise) // 这里仍会执行,打印出 promise 实例对象
                        })
                        

                        上面的示例中,在 Promise 内部就会发生引用错误,因为 say 函数并没有定义,但并未终止脚本的执行。接着还会输出 promise 对象。也就是说,Promise 内部的错误并不会影响到 Promise 外部代码,通俗的说法就是“Promise 会吃掉错误”。

                        但是,如果脚本放在服务器上执行,退出码就是 0(表示执行成功)。不过 Node.js 有一个 unhandledRejection 事件,它专门监听未捕获的 reject 错误,脚本会触发这个事件的监听函数,可以在监听函数里面抛出错误。如下:

                        注意,Node.js 有计划在未来废除 unhandledRejection 事件。如果 Promise 内部由未捕获的错误,会直接终止进程,并且进程的退出码不为 0

                        catch() 方法中,也可以抛出错误。而且由于 then()catch() 方法均返回一个新的 Promise 实例对象,因此可以采用链式写法,写出一系列的...

                        const promise = new Promise((resolve, reject) => {
                          reject('Oops')
                        })
                        
                        promise
                          .then(res => { /* some statements */ })
                          .catch(err => { throw new Error('Oh...') })
                          .catch(err => { /* 这里可以捕获上一个 rejected 状态 */ })
                          // ... 还可以写一系列的 then、catch 方法
                        

                        六、Promise.prototype.finally()

                        在 ES9 标准中,引入了 Promise.prototype.finally() 方法,用于指定 Promise 对象状态发生改变(不管 fulfilled 还是 rejected)后,都会触发此方法。

                        const promise = new Promise((resolve, reject) => {
                          // some statements
                        })
                        
                        promise
                          .then(res => { /* some statements */ })
                          .catch(err => { /* some statements */ })
                          .finally(() => {
                            // do something...
                            // 注意,finally 不接受任何参数,自然也无法得知 Promise 对象的状态。
                          })
                        

                        若 Promise 内部不写任何 resovle()、或 rejected()、或无任何语法错误(如上述示例),Promise 实例对象的状态并不会发生变化,即一直都是 pending 状态,它都不会触发 then()catch()finally() 方法。这点就怕有人会误解,状态不发生变化时也会触发 finally() 方法,这是错的。

                        Promise.prototype.finally() 也是返回一个新的 Promise 实例对象,而且该实例对象的值,就是前面一个 Promise 实例对象的值。

                        const p1 = new Promise(resolve => resolve(1))
                        const p2 = p1.then().finally()
                        const p3 = p1.then(() => { }).finally()
                        const p4 = p1.then(() => { return true }).finally()
                        const p5 = p1.then(() => { throw 'Oops' /* 当然这里没处理 rejected 状态 */ }).finally()
                        const p6 = p1.then(() => { throw 'Oh...' }).catch(err => { return 'abc' }).finally()
                        const p7 = p1.finally(() => { return 'finally' })
                        const p8 = p1.finally(() => { throw 'error' })
                        
                        setTimeout(() => {
                          console.log('p1:', p1)
                          console.log('p2:', p2)
                          console.log('p3:', p3)
                          console.log('p4:', p4)
                          console.log('p5:', p5)
                          console.log('p6:', p6)
                          console.log('p7:', p7)
                          console.log('p8:', p8)
                        })
                        
                        // 解释一下 `p1` 和 `p1.then()`:
                        // 当 `then()` 方法中不写回调函数时,会发生值的穿透,
                        // 即 `p1.then()` 返回的新实例对象(假设为 `x`)的值跟 p1 实例的值是一样的,
                        // 但注意 `p1` 和 `x` 是两个不同的 Promise 实例对象。
                        // 关于值穿透的问题,后面会给出示例。
                        

                        根据打印结果可以验证: finally() 方法返回的 Promise 实例对象的值与前一个 Promise 实例对象的值是相等的,但尽管如此,两者是两个不同的 Promise 实例对象。可以打印一下 p1 === p7,比较结果为 false

                        关于 Promise.prototype.finally() 的实现,如下:

                        Promise.prototype.finally = function (callback) {
                          let P = this.constructor
                          return this.then(
                            value => P.resolve(callback && callback()).then(() => value),
                            reason => P.resolve(callback && callback()).then(() => { throw reason })
                          )
                        }
                        

                        七、总结

                        关于 Promise.prototype.then()Promise.prototype.catch()Promise.prototype.finally() 方法,总结以下特点:

                        • 三者均返回一个全新的 Promise 实例对象。

                        • 即使 then()catch()finally() 方法在不指定回调函数的情况下,仍会返回一个全新的 Promise 实例对象,但此时会出现“值穿透”的情况,即实例值为前一个实例的值。

                        • 假设三者的回调函数中无语法错误(包括不使用 throw 关键字) 时,then()catch() 方法返回的实例对象的值,依靠 return 关键字来指定,否则为 undefined

                          finally() 方法稍有不同,即使使用了 return 也是无意义的,因为它返回的 Promise 实例对象的值总是前一个 Promise 实例的值。

                          三个方法的返回操作 return any,相当于 Promise.resolve(any)(这里 any 是指任何值)。

                        • then()catch()finally() 方法中出现语法错误或者利用 throw 关键字主动抛出错误,它们返回的 Promise 实例对象的状态会变成 rejected,而且实例对象的值就是所抛出的错误原因。

                        • Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被捕获为止。也就是说,错误总是会被下一个 catch() 方法捕获。

                        关于值的穿透,请看示例:

                        const person = { name: 'Frankie' } // 使用引用值更能说明问题
                        const p1 = new Promise(resolve => resolve(person))
                        const p2 = new Promise((resolve, reject) => reject(person))
                        
                        // 情况一:fulfilled
                        p1.then(res => {
                          console.log(res === person) // true
                        })
                        
                        // 情况二:fulfilled
                        p1
                          .then()
                          .then(res => {
                            console.log(res === person) // true
                          })
                        
                        // 情况三:rejected
                        p2
                          .catch()
                          .then(res => { /* 不会触发 then */ })
                          .catch(err => {
                            console.log(err === person) // true
                          })
                        
                        // 情况四:fulfilled
                        p1
                          .finally()
                          .then(res => {
                            console.log(res === person) // true
                          })
                        

                        从结果上看,尽管三者在不指定回调函数的情形下,“似乎”是不影响结果的。但前面提到 p1p1.then()p1.catch()p1.finally() 都是两个不同的 Promise 实例对象,尽管这些实例对象的值是相等的。

                        在实际应用场景中,我们应该避免写出这些“无意义”的代码。但是我们在去学习它们的时候,应该要知道。就是“用不用”和“会不会”是两回事。

                        下一篇接着介绍 Promise.all()Promise.race() 等。

                        ]]>
                        <![CDATA[在 forEach 使用 async/await 的问题]]> https://github.com/tofrankie/blog/issues/254 https://github.com/tofrankie/blog/issues/254 Sun, 26 Feb 2023 11:50:18 GMT 配图源自 Freepik

                        在上一篇文章《

                        在上一篇文章《for 语句详解》提到了在循环中应用 async/await 的例子。

                        于是,顺道提一下在 Array.prototype.forEach() 使用 async/await 的问题。其实,在 MDN 上就有提醒:

                        如果使用 promiseasync 函数作为 forEach() 等类似方法的 callback 参数,最好对造成的执行顺序影响多加考虑,否则容易出现错误。

                        示例:

                        let sum = 0
                        const arr = [1, 2, 3]
                        
                        async function sumFn(a, b) {
                          return a + b
                        }
                        
                        // 为了方便后续修正改造,将 forEach 逻辑放到函数 main 中执行了。
                        function main(array) {
                          array.forEach(async item => {
                            sum = await sumFn(sum, item)
                          })
                        
                          console.log(sum) // 0, Why?
                        }
                        
                        main(arr)
                        

                        为什么 sum 打印结果是 0,而不是预期的 6 呢?

                        首先,我们要理解 async 函数的语义,它表示函数中有异步操作,await 则表示其后面的表达式需要等待结果,函数最终返回一个 Promise 对象。

                        当代码执行到 forEach 时:

                        1. 首先遇到 `sum = await sumFn(sum, item)` 语句(注意,它是从右往左执行的)
                           因此,它会执行 `sumFn(0, 1)`,那么该函数 `return 1`,
                           由于 async 函数始终会返回一个 Promise 对象,即 `return Promise.resolve(1)`。
                        
                        2. 由于 await 的原因,它其实相当于执行 `Promise.resolve(1).then()` 方法,
                           它属于微任务,会暂时 Hold 住,被放入微任务的队列,待本次同步任务执行完之后,
                           才会被执行,因此并不会立即赋值给 sum(所以 sum 仍为 0)。
                        
                        3. 那 JS 引擎主线程不会闲着的,它会继续执行“同步任务”,即下一次循环。
                           同理,又将 `return Promise.resolve(2)` 放入微任务队列。
                           直到最后一次循环,同样的的还是 `return Promise.resolve(3)`。
                           其实到这里,forEach 其实算是执行完了。
                           以上示例,forEach 的真正意义是创建了 3 个微任务。
                        
                        4. 由于主线程会一直执行同步任务,待同步任务执行完之后,才会执行任务队列里面的微任务。
                           待 forEach 循环结束之后,自然会执行 `console.log(sum)`,
                           但注意,由于 await 的原因,sum 一直未被重新赋值,因此 sum 还是为 0 ,
                           所以控制台输出了 0。
                        
                        5. 等 `console.log(sum)` 执行完毕,才开始执行队列中的微任务,
                           其中 `await Promise.resolve(0)` 的结果,
                           相当于 `Promise.resolve(0)` 的 then 方法的返回值,
                           所以此前的三个微任务,相当于:
                           `sum = 1`
                           `sum = 2`
                           `sum = 3`
                           它们被依次执行。
                        
                        6. 因此 sum 最终的值变成了 3(注意不是 6 哦)。
                        

                        所以,在 forEach 中使用 async/await 可能没办法到达预期目的哦。

                        如何解决以上问题呢?

                        我们可以使用 for...of 来替代:

                        let sum = 0
                        const arr = [1, 2, 3]
                        
                        async function sumFn(a, b) {
                          return a + b
                        }
                        
                        // await 要放在 async 函数中
                        async function main(array) {
                          for (let item of array) {
                            sum = await sumFn(sum, item)
                          }
                        
                          console.log(sum) // 6
                        }
                        
                        main(arr)
                        

                        这样就能输出预期结果 6 了。

                        那为什么 for...of 就可以呢?因为它本质上就是一个 while 循环。

                        let sum = 0
                        const arr = [1, 2, 3]
                        
                        async function sumFn(a, b) {
                          return a + b
                        }
                        
                        // await 要放在 async 函数中
                        async function main(array) {
                          // for (let item of array) {
                          //   sum = await sumFn(sum, item)
                          // }
                        
                          // 相当于
                          const iterator = array[Symbol.iterator]()
                          let iteratorResult = iterator.next()
                          while (!iteratorResult.done) {
                            sum = await sumFn(sum, iteratorResult.value)
                            iteratorResult = iterator.next()
                          }
                        
                          console.log(sum) // 6
                        }
                        
                        main(arr)
                        

                        只要了解了 async/awaitfor...of 的内部运行机制,分析起来就不难了。

                        The end.

                        ]]>
                        <![CDATA[细读 JS | for 语句详解]]> https://github.com/tofrankie/blog/issues/253 https://github.com/tofrankie/blog/issues/253 Sun, 26 Feb 2023 11:48:04 GMT 配图源自 Freepik

                        今天看看一个最基础的 for 语句吧!

                        配图源自 Freepik

                        今天看看一个最基础的 for 语句吧!

                        一、语法

                        for 语句用于创建一个循环,它包含了三个可选的表达式(包围在圆括号之中,使用分号 ; 分隔),后跟一个用于在循环中执行的语句(通常为块语句)。

                        for ([initialization]; [condition]; [final-expression])
                          statement
                        
                        • initialization 一个表达式(包含赋值语句)或变量声明。可使用 varlet 声明变量(但不能使用 const 关键字声明)。但两者声明的变量作用域不同,前者 varfor 循环处于同样的作用域中,而后者 let 则是语句的局部变量。该表达式的结果无意义。

                        • condition 一个表达式被用于确定每一次循环是否能被执行。如果表达式结果为 truestatement 将被执行。该表达式是可选的,如果被忽略,那么被认为永远为证。如果表达式结果为 false,那么执行流程将被跳到 for 语句结构后面的第一条语句。

                        • final-expression 每次循环的最后都要执行的表达式。执行时机是在下一次 condition 的计算之前。通常被用于更新或递增计数器变量。

                        • statement 只要 condition 的结果为 true,就会被执行的语句。要在循环体内执行多条语句,使用一个块语句来包括要执行的语句。没有任何语句要执行,使用一个空语句;

                        类似下面的循环语句,再熟悉再常见不过了。

                        for (var i = 0; i < 10; i++) {
                          // statements
                        }
                        

                        二、示例

                        由于 for 语句头部圆括号中的所有三个表达式都是可选的,因此下面列举一些相对没那么“常见”的示例。

                        例如,省略 initialization 初始化块中的表达式:

                        var i = 0
                        for (; i < 10; i++) {
                          console.log(i)
                          // more statements
                        }
                        

                        例如,省略 condition 表达式,但必须在 statement 循环体内跳出循环,避免死循环。

                        for (var i = 0; ; i++) {
                          console.log(i)
                          if (i > 3) break
                          // more statements
                        }
                        

                        甚至,你可以忽略所有的表达式。同样的,要确保使用 break 语句来跳出循环,并且还要修改(增加)一个变量,使得 break 语句的条件在某个时候为真。

                        var i = 0
                        for (; ;) {
                          if (i > 3) break
                          console.log(i)
                          i++
                        }
                        

                        但有一个需要特别注意的是,当循环体 statement 不执行任何语句时,必须使用一个空语句(即 ;,且分号是不能省略的)。

                        for (var i = 0; i < 10; i++)/* empty statement */;
                        

                        注意,这里的分号 ; 是强制性的,是 JavaScript 中的少数几种强制分号的情况。若没有分号,循环声明之后的行将被视为循环语句。

                        例如:

                        for (var i = 0; i < 10; i++)
                          console.log('loop') // 这个被当作 for 语句的 statement 循环体
                        console.log('no loop') // 但这个不属于循环体哦
                        

                        三、BTW

                        关于能否省略分号的问题,顺便一下。

                        1. if...else 语句中,根据 ASI 机制,由于 )else 无法构成合法的语句,且 JavaScript 解析器不会在 else 之前自动插入分号 ; ,导致词法分析阶段就出错了。
                        // SyntaxError: Unexpected token 'else'
                        if (true) else console.log('...')
                        
                        // Correct, but this "if" does nothing!
                        if (true); else console.log('...')
                        

                        正确做法是,需主动在 else 之前键入分号 ;(不可省略),表示 if 条件为真时,执行了一个空语句。

                        1. do...while 语句中,以下形式是允许的哦,语法不会出错。
                        do statement while(condition) out-of-loop-statement
                        
                        // 根据 ASI 机制,JavaScript 解析器看到的其实是长这样的:
                        do statement; while(condition); out-of-loop-statement;
                        
                        1. for 语句中,即使把 initializationconditionfinal-expression 表达式都省略了,但是分号 ; 却一个都不能省略。当循环体不执行任何语句时,分号 ; 也不能省略。
                        // Correct
                        for (; ;); // 当然这样是没意义,而且会陷入死循环。但语法是正确的,为了举例罢了。
                        
                        // SyntaxError
                        for (;);
                        
                        // Bad, 容易产生非预期结果
                        for (;;)
                        

                        关于自动分号插入(ASI,Automatic Semicolon Insertion),请看文章 JavaScript ASI 机制详解,包括上述示例都有详细描述。

                        四、被忽略的地方

                        1. for 语句的 initializationconditionfinal-expression 除了表达式形式,还可以是函数形式。若 initializationfinal-expression 以函数形式存在,它们的返回值是无实际意义的。但是 condition 则必须返回一个布尔值。
                        for (var i = 0; i < 10; i++) {
                          console.log(i)
                        }
                        
                        // 相当于
                        function compare(number) {
                          // 若 condition 是一个较为复杂的表达式,使用函数形式或许可以使得代码更清晰。
                          return number < 10
                        }
                        for (var i = 0; compare(i); i++) {
                          console.log(i)
                        }
                        

                        五、经典面试题

                        initialization 表达式中使用 varlet 声明变量是有区别的。

                        上面提到使用 var 声明的变量,其作用域与 for 循环处于同样的作用域中,而使用 let 则是 for 循环内部的块级作用域。

                        看看以下两个示例的异同,不同点在于 varlet

                        // 示例一
                        var arr = []
                        for (var i = 0; i < 10; i++) {
                          arr[i] = function () {
                            console.log(i)
                          }
                        }
                        arr[6]() // 10
                        
                        // 示例二
                        var arr = []
                        for (let i = 0; i < 10; i++) {
                          arr[i] = function () {
                            console.log(i)
                          }
                        }
                        arr[6]() // 6
                        

                        上面的示例中,为什么结果会有差异呢?我们来分析一下:

                        示例一分析:

                        在 ES6 之前,JavaScript 的变量是没有块级作用域的,只有函数作用域和全局作用域。所以,在示例一中,变量 i 与 变量 arr 同属全局作用域。当循环执行完毕,i 已经增加到 10。接着执行 arr[6](),由于匿名函数 function () { console.log(i) } 内部并没有声明 i 变量,于是从上一级作用域(这里的上一级作用域是全局作用域)中查找 i,并找到其值为 10,因此打印结果为 10

                        注意,在示例一中,由始至终只有一个全局作用域。

                        示例二分析:

                        在示例二中,使用了 let 来声明变量 i,此时 i 不再与 arr 同属全局作用域了。

                        此时,其实存在三个作用域:包括 arr 所在的全局作用域、i 所在的块级作用域、以及 for 语句循环体内的块级作用域。为什么有三个不同的作用域?看示例:

                        /* 全局作用域 */
                        for (let i = 0 /* 块级作用域 1 */; i < 10; i++) {
                          /* 块级作用域 2 */
                          let i = 'abc'
                          console.log(i) // 结果是打印了 10 遍 "abc",而不是 0 ~ 10 哦
                        }
                        
                        // 理由:
                        // 假设 1 和 2 是同级作用域下,我们重复使用 let 关键字来声明 i 变量,
                        // 理应抛出语法错误,如:SyntaxError: Identifier 'i' has already been declared
                        // 但事实上运行是没问题的,说明通过了词法分析。
                        // 再者,假设我们在循环体内声明 let j = 'temp',然后在 final-expression 表达式内是无法访问变量 j 的。
                        // 综上,可知它俩作用域是不一样的。
                        
                        // 基于以上反证结论,我们有理由认为:
                        // 若在 for 语句的圆括号和循环体内使用了 let 来声明变量,它们所处的作用域是不一样的。
                        // 而在 for 循环的圆括号的三个表达式,其作用域是同一个。
                        // 注意,只能在循环体内部访问圆括号内的变量,反之不行。
                        

                        我们打个断点看下就清楚了:

                        接着分析示例二,当我们使用 let 时,每次循环 JavaScript 引擎会重新创建环境,大致是拷贝上一次的变量及其值到本次循环中(详情看标准:CreatePerIterationEnvironment),这也是为什么重新创建环境,i 还能取到上一次值的原因。需要注意的是,initialization 表达式仅在首次执行 for 循环的时候进行初始化,下一次创建环境的时候并不会执行它,因此 i 不会重置为 0

                        而每次循环,循环体 statement 内都会产生一个块级作用域,对应作用域内的 i 值就是本次循环的 i 的值。而且,由于我们循环体内含有 arr[i] = function () { console.log(i) },会形成闭包。

                        所以,最后执行 arr[6]() 时,先从块级作用域下查找,并查找到为 6,因此它不会继续往上一级块级作用域下查找,自然也不会再往全局作用域下查找了。

                        可以断点调试看下:

                        而示例一断点调试可知,由始至终就一个全局作用域。(不贴图了)

                        关于示例二,相当于以下这样,那作用域就更加地清晰了:

                        var arr = []
                        {
                          let i
                          for (i = 0; i < 10; i++) {
                            let _i = i
                            arr[_i] = function () {
                              console.log(_i)
                            }
                          }
                        }
                        arr[6]() // 6
                        

                        我们再看下,Babel 是如何转换的:

                        看到这个,那不就想起那个经典面试题:如何修改代码使其打印出 0 ~ 9 吗?

                        六、为什么 for...of 里可以用 const 声明变量

                        前面提到,for 语句里的 initialization 部分不能用 const 声明变量。

                        举个例子:

                        for (const i = 0; i < 10; i++) {
                          console.log(i)
                        }
                        

                        执行会抛出 Uncaught TypeError: Assignment to constant variable 异常。

                        原因很简单,(const i = 0; i < 10; i++) 这部分有一个作用域,每次循环体执行完之后,执行 i++ 是对此作用域里的 i 变量重新赋值,因此会报错。

                        但我们可以经常看到这样的代码,是可以正常工作的:

                        const arr = [0, 1, 2]
                        
                        for (const i of arr) {
                          console.log(i)
                        }
                        

                        它相当于

                        const arr = [0, 1, 2]
                        
                        const iterator = arr[Symbol.iterator]()
                        let result
                        while (result = iterator.next() && !result.done) {
                          console.log(result.value)
                        }
                        

                        七、async 在 for 语句中的应用

                        例如,实现休眠效果。

                        function sleep(delay) {
                          return new Promise(resolve => setTimeout(resolve, delay))
                        }
                        
                        async function traverse() {
                          for (let i = 1; i <= 5; i++) {
                            console.log(i)
                            await sleep(1000)
                          }
                        }
                        
                        traverse() // 间隔 1 秒,依次输出 1 ~ 5
                        

                        例如,在网络请求中,可以实现多次重复尝试。

                        async function request(url) {
                          let res
                          let err
                          const MAX_NUM_RETRIES = 3
                        
                          for (let i = 0; i < MAX_NUM_RETRIES; i++) {
                            try {
                              res = await fetch(url).then(res => res.json())
                              break
                            } catch (e) {
                              err = e
                              // Do nothing and make it continue.
                            }
                          }
                        
                          if (res) return res
                          throw err
                        }
                        
                        request('http://192.168.1.102:7701/config')
                          .then(res => {
                            console.log('success')
                          })
                          .catch(err => {
                            console.log('fail')
                          })
                        

                        另外,在 Array.prototype.forEach() 使用 async/await 可能得不到预期结果哦,详情看文章分析。

                        八、参考链接

                        ]]>
                        <![CDATA[细读 ES6 | Class 下篇]]> https://github.com/tofrankie/blog/issues/252 https://github.com/tofrankie/blog/issues/252 Sun, 26 Feb 2023 11:45:29 GMT 配图源自 Freepik

                        上一篇介绍了 Class 的语法,今天来看看 ES6 中的继承。

                        在 ES5 大概有 6 种继承方式:类式继承、构造函数继承、组合式继承、原型继承、寄生式继承、寄生组合式继承,而这些方式都有一些各自的缺点,可看文章:深入继承原理

                        而 ES6 标准提供了 Class(类)的语法来定义类,语法很像传统的面向对象写法。本质上仍然是通过原型实现继承的,可以理解为 class 只是一个语法糖,跟传统面向对象的类又不一样。废话说完了,入正题...

                        那 Class 是怎样实现继承的呢?

                        一、简介

                        通过 extends 关键字实现继承,比 ES5 写一长串的原型链,方便清晰很多,对吧。

                        class Person {}
                        
                        class Student extends Person {} // 没错,这样就实现了继承
                        

                        上面的示例中,定义了一个 Student(子)类,该类通过 extends 关键字继承了 Person(父)类的所有属性和方法。但由于两个类中并没有实现什么功能,相当于 Student 复制了一个 Person 类而已。

                        需要注意的是,若子类自实现了 constructor 方法,需在其内部使用 super 关键字来调用父类的构造方法,否则当子类进行实例化时会报错。

                        class Person {}
                        
                        class Student extends Person {
                          constructor() {
                            super()
                            // 相当于调用父类 Person 的构造方法,
                            // 相当于 Person.prototype.constructor.call(this),
                            // 而且,若 constructor 内使用了 this 关键字,
                            // super() 一定要在 this 之前进行调用,否则会报错。
                          }
                        }
                        
                        const stu = new Student() // ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor
                        
                        // 至于为什么上一个示例,实例化时并不会报错,原因如下:
                        // 当 constructor 缺省时,JS 引擎会默认添加一个 constructor 方法,相当于:
                        // 
                        // class Person {}
                        // class Student extends Person {
                        //   constructor(...args) {
                        //     super(...args)
                        //   }
                        // }
                        

                        原因是,ES5 的继承实质是先创建子类的实例对象(即 this),然后再将父类的方法添加到实例对象 this 上(类似 Parent.apply(this))。而 ES6 继承机制完全不同,它先将父类的实例对象的属性和方法,放到 this 上,然后再用子类的构造函数修改 this。因此,在子类使用 this 之前,需要调用 super() 方法执行父类的 constructor() 方法来创建实例对象 this

                        我们来改下:

                        class Person {
                          constructor(name, age) {
                            this.name = name
                            this.age = age
                          }
                        
                          sayHi() {
                            console.log(`Hi, my name is ${this.name}.`)
                          }
                        }
                        
                        class Student extends Person {
                          constructor(name, age, stuNo) {
                            super(name, age)
                            this.stuNo = stuNo // this 只能在调用 super() 后使用
                          }
                        }
                        
                        const stu = new Student('Frankie', 20, 2021001)
                        stu.sayHi() // "Hi, my name is Frankie."
                        

                        实例对象 stu 同时是 StudentPerson 类的实例,这点与 ES5 表现一致。

                        stu instanceof Student // true
                        stu instanceof Person // true
                        

                        二、Object.getPrototypeOf()

                        使用 Object.getPrototypeOf() 可以通过子类获取其直接父类。

                        Object.getPrototypeOf(stu) === Student // true
                        Object.getPrototypeOf(Student) === Person // true
                        
                        // 也可以使用非标准的 __proto__ 访问原型
                        stu.__proto__.constructor === Student // true
                        stu.__proto__ === Student.prototype // true
                        

                        提一下,我们一直使用的 Object.prototype.__proto__ 并不是 ECMAScript 标准,只是被各大浏览器厂商支持,因此我们才可以使用。现在被推荐使用的是,标准支持的 Objec.getPrototypeOf()Object.setPrototypeOf() 方法。

                        三、super 关键字

                        关键字 super 可以作为函数使用,也可以作为对象使用,两种是有区别的。

                        1. super 作为函数

                        super 作为函数,只能在(子类)构造方法中使用,若在非子类或类的其他方法中调用,是会报错的。

                        class Person {
                          constructor(name, age) {
                            this.name = name
                            this.age = age
                            console.log(new.target.name)
                            // super() // 若在此调用,会报错:SyntaxError: 'super' keyword unexpected here
                          }
                        
                          sayHi() {
                            console.log(`Hi, my name is ${this.name}.`)
                          }
                        }
                        
                        class Student extends Person {
                          constructor(name, age, stuNo) {
                            // super(name, age)
                            console.log(super(name, age) === this) // true
                            this.stuNo = stuNo // this 只能在调用 super() 后使用
                          }
                        
                          getStuNo() {
                            // super() // 若在此调用,会报错:SyntaxError: 'super' keyword unexpected here
                            return this.stuNo
                          }
                        }
                        
                        const stu = new Student('Frankie', 20, 2021001) // 打印:"Student"
                        const person = new Person('Mandy', 18) // 打印:"Person"
                        

                        在构造方法当作函数调用 super(),它代表了父类的构造方法。根据 ES6 规定,子类的构造函数必须执行一次 super 函数。当缺省 constructor() 方法时,JS 引擎会帮我们添加一个默认构造方法,里面也包括 super() 的调用。

                        小结:

                        • super() 只能在子类的 constructor() 方法内调用,在 getStuNo() 方法内调用会报错。例如,示例中父类 Person 并没有继承自其他类,因此在父类 Personconstructor() 方法内调用是会报错的。

                        • 调用 super() 返回当前实例化对象,即 this

                        • new.target 指向直接被 new 执行的类。因此通过 new Student()new Person() 进行实例化时,new.target 分别指向 Student 类和 Person 类。

                        2. super 作为对象

                        super 作为对象使用时,在普通方法内(包括 constructor() 在内的非静态方法),它指向父类的原型对象(即 Parent.prototype);而在静态方法内,它指向父类(即 Parent)。

                        // 父类
                        class Person {
                          constructor(name, age) {
                            this.name = name
                            this.age = age
                          }
                        
                          sayHi() {
                            console.log(`Hi, my name is ${this.name}.`)
                          }
                        
                          printAge() {
                            console.log(this.age)
                          }
                        
                          static classMethodParent() {
                            console.log(Person.name)
                          }
                        }
                        
                        Object.assign(Person.prototype, {
                          prop: 'hhh'
                        })
                        
                        // 子类
                        class Student extends Person {
                          constructor(name, age, stuNo) {
                            super(name, age)
                            this.stuNo = stuNo
                        
                            // 作为对象 super 相当于 Person.prototype
                            super.sayHi() // "Hi, my name is Frankie."
                            console.log(super.name) // undefined
                            console.log(super.prop) // "hhh"
                          
                            // 另外,要注意:
                            super.tmp = 'temporary' // 相当于 this.tmp = 'temporary'
                            console.log(super.tmp) // undefined
                            console.log(this.tmp) // "temporary"
                          }
                        
                          getStuNo() {
                            return this.stuNo
                          }
                        
                          getAge() {
                            // 普通方法内,super 指向父类的原型对象,
                            // 即相当于 Person.prototype.printAge.call(this)
                            super.printAge()
                          }
                        
                          static classMethod() {
                            // 静态方法内,super 指向父类
                            // 相当于 Person.classMethodParent()
                            super.classMethodParent()
                          }
                        }
                        
                        const stu = new Student('Frankie', 20, 2021001)
                        stu.getAge() // 20
                        Student.classMethod() // "Person"
                        

                        小结:

                        • 在普通方法内,类似 super.xxx取值操作,super 均指向父类的原型对象。例如,上述子类构造方法内 super.name 打印结果为 undefined,原因是属性 name 是挂载到实例对象上的,而不是实例的原型对象上的。即 super.name 相当于 Person.prototype.name

                        • 在普通方法内,类似 super.xxx = 'xxx'赋值操作,相当于 this.xxx = 'xxx',因此属性 xxx 会被挂载到实例对象上,而不是父类原型对象。

                        • 普通方法内,通过 super.xxx() 调用父类方法,相当于 Person.prototype.xxx.call(this)

                        • 在静态方法内,super 指向父类,因此 super.xxx() 相当于 Person.classMethodParent()

                        以上提到的普通方法,是指非静态方法。

                        3. 注意事项

                        无论 super 是作为函数,还是对象使用,必须明确指定,否则会报错。

                        class Person {}
                        
                        class Student extends Person {
                          constructor() {
                            super()
                            console.log(super) // SyntaxError: 'super' keyword unexpected here
                          }
                        }
                        

                        4. 关于 super 总结

                        • 作为函数时,仅可在子类constructor() 内使用。若 constructor() 内包括 this 的使用,则 super() 必须在 this 之前进行调用。

                        • 作为对象时,若在非静态方法内使用,super.xxxsuper.xxx())相当于 Parent.prototype.xxxParent.prototype.xxx.call(this))。

                        • 作为对象时,若在静态方法内使用,super.xxxsuper.xxx())相当于 Parent.xxxParent.xxx())。

                        • 我们都知道在 JavaScript 访问对象的某个属性(或方法),先从对象本身去查找是否有此属性,再从原型上一层一层的查找,若最终查找不到会返回 undefined(或抛出 TypeError 错误)。

                          同样地,在 Class 继承中,若子类、父类存在同名方法,使用实例对象进行调用该方法,若子类查找到了,自然不会再去父类中查找。但我们在设计类的时候,可能仍需要执行父类的同名方法,那么怎么调用呢?

                          显然通过 父类名.方法名() 的方式调用是不合理、不灵活的,道理就跟 JavaScript 要设计 this 关键字一样。于是 super 就诞生了(最后这句是我猜的,哈哈)。

                        四、类的 prototype 属性和 __proto__ 属性

                        在 ES5 之中,每个对象都有 __proto__ 属性,它指向对象的构造函数的 prototype 属性。

                        关于对象可分为:普通对象和函数对象,区别如下:

                        对象类型 prototype(原型对象) __proto__(原型)
                        普通对象
                        函数对象

                        所有对象都有 __proto__ 属性,而只有函数对象才具有 prototype 属性。其中构造函数属于函数对象,而实例对象则属于普通对象,因此实例对象是没有 prototype 属性的。

                        而 ES6 的 class 作为构造函数的语法糖,同时有 prototype 属性和 __proto__,因此同时存在两条继承链。

                        • 子类的 __proto__ 属性,表示构造函数的继承,总是指向父类。
                        • 子类的 prototype 属性的 __proto__ 属性,表示方法的继承,总是指向父类的 prototype 属性。
                        class Person { }
                        
                        class Student extends Person { }
                        
                        Student.__proto__ === Person // true
                        Student.prototype.__proto__ === Student.prototype // true
                        

                        上面的示例中,子类 Student__proto__ 指向父类 Person,子类的 Studentprototype 属性的 __proto__ 属性指向父类 Personprototype 属性。

                        因为类的继承,是按照以下模式实现的:

                        class Person { }
                        class Student extends Person { }
                        
                        // 相当于
                        class Person { }
                        class Student { }
                        Object.setPrototypeOf(Student, Person)
                        Object.setPrototypeOf(Student.prototype, Person.prototype)
                        
                        // -------------------------------------------------------
                        // 关于 Object.setPrototypeOf() 内部是这样实现的:
                        // Object.setPrototypeOf = functon(obj, proto) {
                        //   obj.__proto__ = proto
                        //   return obj
                        // }
                        //
                        // 因此,相当于:
                        // Student.__proto__ = Person
                        // Student.prototype.__proto__ = Person.prototype
                        // -------------------------------------------------------
                        

                        这样去理解:

                        • 作为一个对象,子类 Student 的原型(__proto__)是父类 Person
                        • 作为一个构造函数,子类 Student 的原型对象(prototype)是父类 Person 的原型对象(prototype)的实例。

                        因此,理论上 extends 关键字后面的(函数)对象,只要含有 prototype 属性就可以了,但 Function.prototype 除外。但在做项目的时候应该从实际应用场景考虑,这样去做是否有意义。

                        // 1. Student 类继承 Object 类
                        class Student extends Object { }
                        Student.__proto__ === Object // true
                        Student.prototype.__proto__ === Object.prototype // true
                        
                        // 2. Student 不继承
                        class Student { }
                        Student.__proto__ === Function.prototype
                        Student.prototype.__proto__ === Object.prototype
                        // 因此,相当于:
                        // Object.setPrototypeOf(Student, Function.prototype)
                        // Object.setPrototypeOf(Student.prototype, Object.prototype)
                        

                        五、Mixin 模式的实现

                        Mixin 指的是多个对象合成一个新的对象,新对象具备各个组成成员的接口。它的最简单实现如下:

                        const a = { a: 1 }
                        const b = { b: 2 }
                        const c = { ...a, ...b }
                        

                        上面的示例,对象 c 是对象 a 和对象 b 的合成,具备两者的接口。

                        下面是一个更完备的实现,将多个类的接口“混入”另一个类。

                        function mix(...mixins) {
                          class Mix {
                            constructor() {
                              for (let mixin of mixins) {
                                copyProperties(this, new mixin()) // 拷贝实例属性
                              }
                            }
                          }
                        
                          for (let mixin of mixins) {
                            copyProperties(Mix, mixin) // 拷贝静态属性
                            copyProperties(Mix.prototype, mixin.prototype) // 拷贝原型属性
                          }
                        
                          return Mix
                        }
                        
                        function copyProperties(target, source) {
                          for (let key of Reflect.ownKeys(source)) {
                            if (
                              key !== 'constructor'
                              && key !== 'prototype'
                              && key !== 'name'
                            ) {
                              let desc = Object.getOwnPropertyDescriptor(source, key)
                              Object.defineProperty(target, key, desc)
                            }
                          }
                        }
                        

                        上面示例中的的 mix 函数,可以将多个对象合成为一个类。使用的时候,只要继承这个类即可。

                        class DistributedEdit extends mix(Loggable, Serializable) {
                          // ...
                        }
                        

                        The end.

                        ]]>
                        <![CDATA[细读 ES6 | Class 上篇]]> https://github.com/tofrankie/blog/issues/251 https://github.com/tofrankie/blog/issues/251 Sun, 26 Feb 2023 11:41:50 GMT 配图源自 Freepik

                        此前写了两篇关于 JavaScript 原型以及继承的文章(源自 配图源自 Freepik

                        此前写了两篇关于 JavaScript 原型以及继承的文章(源自 ULIVZ)。

                        来持续学习吧!今天来看下 ES6 中的 Class 语法。

                        一、简介

                        在 JavaScript 中,生成实例对象的传统方法是通过构造函数。

                        function Point(x, y) {
                          this.x = x
                          this.y = y
                        }
                        
                        Point.prototype.toString = function() {
                          return '(' + this.x + ', ' + this.y + ')'
                        }
                        
                        var p = new Point(1, 2)
                        

                        上面这种写法,跟传统的面向对象语言(比如 C++、Java)差异很大,很容易让新学习这门语言的程序员感到困惑。

                        在 ES6 提供了更接近传统语言的写法,引入了 Class(类)这个概念,作为对象的模板。通过 class 关键字,可以定义类。

                        基本上,ES6 的 class 可以看作只是一个语法糖,它的绝大部分功能,ES5 都可以做到,全新的 class 写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。

                        上面示例,可以使用 ES6 的 class 改写为:

                        class Point {
                          constructor(x, y) {
                            this.x = x
                            this.y = y
                          }
                        
                          toString() {
                            return '(' + this.x + ', ' + this.y + ')'
                          }
                        }
                        

                        上面的示例定义了一个“类”,可以看到里面有一个 constructor() 方法,这就是构造方法,而 this 关键字则代表实例对象。这种全新的 Class 写法,本质上与开头的 ES5 的构造函数 Point 是一致的。

                        Point 类除了构造方法,还定义了一个 toString() 方法。注意,定义了 toString() 方法的时候,前面不需要加上 function 这个关键字,直接把函数定义放进去就可以了。另外,方法与方法之间不需要逗号 , 分隔,加了会报错。

                        ES6 的类,完全可以看作构造函数的另一种写法。

                        class Point {
                          // ...
                        }
                        
                        console.log(typeof Point) // "function"
                        Point.prototype.constructor === Point // true
                        // 上面代码表明,类的数据类型就是函数,类本身就指向构造函数。
                        

                        使用的时候,也是直接对类使用 new 关键字,跟构造函数的用法完全一致。还有,当实例化不指定参数列表时,new Point() 等同于 new Point

                        与 ES5 有一点区别的是,类不能直接当作函数一样调用,即 Point() 是会抛出错误的:TypeError: Class constructor Point cannot be invoked without 'new'。而 ES5 中,若构造函数不使用 new 关键字进行实例化,而是直接当作函数调用是没问题的。

                        class Bar {
                          doStuff() {
                            console.log('stuff')
                          }
                        }
                        
                        const b = new Bar()
                        b.doStuff() // "stuff"
                        

                        构造函数的 prototype 属性,在 ES6 的“类”上依然存在。事实上,类的所有方法都定义在类的 prototype 属性上面。我们在控制台打印下 point 实例对象:

                        class Point {
                          constructor() {}
                        
                          toString() {}
                        
                          toValue() {}
                        }
                        
                        const point = new Point()
                        

                        上面的示例中,constructor()toString()toValue() 这三个方法,其实都是定义在 Point.prototype 上面。

                        point.constructor === Point.prototype.constructor // true
                        

                        上面的示例中,pointPoint 类的实例,它的 constructor() 方法就是 Point 类原型的 constructor() 方法。

                        小结:

                        • 在 Class 内部定义的方法,尽管与 ES5 一样最终都是挂载在 prototype 上的,但这些方法是不可枚举的。这一点与 ES5 的行为不一致。
                        • 在 Class 内部定义的属性,则是挂载在实例对象上的。

                        二、constructor

                        constructor() 方法是类的默认方法,通过 new 关键字实例化对象是,内部会自动调用该方法。一个类必须有 constructor() 方法。当你定义一个类时,若无显式定义,会自动添加一个空的默认 constructor() 方法(由 JS 引擎自动添加),即:

                        class Point {}
                        
                        // 相当于
                        class Point {
                          constructor() {}
                        }
                        

                        constructor() 方法默认返回实例对象(即 this),亦可返回任意一个对象(引用类型的值)。

                        class Point() {
                          constructor() {
                            return Object.create(null)
                            // 1. 若不显式 return 的话,默认返回 this
                            // 2. 显式返回只能是引用值(即对象),若是原始值是无效的,此时仍然是返回 this。
                            // 3. 以上两点,跟 ES5 实现构造方法表现是一致的。
                            // 4. 一般情况,无需定义显式 return。
                          }
                        }
                        
                        const point = new Point()
                        console.log(point instanceof Point) // false
                        

                        上面示例中,constructor() 返回了一个全新对象,导致了 point 对象并不是 Point 的实例对象。

                        三、类的实例

                        上面提到,Class 不能当做函数直接调用,否则会抛出语法错误的。正确地,应使用 new 关键字进行实例化。

                        class Point {}
                        
                        // 正确
                        const p1 = new Point()
                        // 错误
                        const p2 = Point() // Uncaught TypeError: Class constructor Point cannot be invoked without 'new'
                        

                        在 Class 中,如何定义属性和方法?那它们是挂载到实例对象,还是类的原型上?

                        下面我们来看看吧:

                        class Point {
                          // 这样定义属性,也是挂载到实例对象的,并非挂载到 Point.prototype 上的哦
                          z = 0
                        
                          constructor(x, y) {
                            // 通过如下 this.xxx 的形式,可以显式地为实例对象增删属性和方法
                            this.x = x // 
                            this.y = y
                            this.show = () => {
                              return `The point is (${this.x}, ${this.y}).`
                            }
                            this.tmp = "It's temporary property."
                            delete this.tmp
                          }
                        
                          // 类似如下 setX、setY、setZ 等定义类的方法,它们最终是挂载到 Point.prototype,并非实例对象
                          setX(x) {
                            this.x = x
                          }
                        
                          setY(y) {
                            this.y = y
                          }
                        
                          setZ(z) {
                            this.z = z
                          }
                        }
                        
                        // 既然上面的方式定义属性,都挂载到实例对象上,
                        // 那怎样给 Point.prototype 添加“属性”呢?
                        // 只能利用 Point.prototype.xxx 了,像这样:
                        Object.assign(Point.prototype, {
                          prop: 'haha',
                          method: function () {}
                        })
                        
                        const point = new Point(1, 10)
                        

                        我们来打印一下 point 实例对象,一目了然:

                        与 ES5 一样,类的所有实例共享一个原型对象。

                        const p1 = new Point(1, 1)
                        const p2 = new Point(2, 2)
                        
                        Object.getPrototypeOf(p1) === Object.getPrototypeOf(p2) // true
                        

                        因此,不建议在实例中,利用 __proto__ 去改写原型,它会改变类的定义,进而影响到该类的所有实例。

                        // ❌ 以下做法不被推荐
                        const p1 = new Point(1, 1)
                        const p2 = new Point(2, 2)
                        
                        p1.__proto__.print = function () {
                         console.log('Oops')
                        }
                        
                        p2.print() // "Oops"
                        

                        请注意,以下这种写法及其结果。

                        class Point {
                          fn() {
                            console.log(1)
                          }
                        }
                        
                        // 在执行到这里时 class 内部的 fn 已经完成挂载到 Point.prototype 上,
                        // 因此下面会把原先原型上的 fn 方法覆盖
                        Point.prototype.fn = function() {
                          console.log(2)
                        }
                        
                        const p = new Point()
                        p.fn() // 2
                        

                        四、setter、getter

                        在 JavaScript 中,我们可以借助 settergetter 语法,以安全的方式来访问对象的属性。使用 getter 可以访问属性值,而 setter 可以修改属性值。

                        // 本例的 setter、getter 设计在实际中并无意义,
                        // 这里只是为了举例而举例罢了。
                        class Point {
                          constructor(x, y) {
                            this.x = x
                            this.y = y
                          }
                        
                          set prop(x) {
                            console.log('setter:', x)
                            this.x = x
                          }
                        
                          get prop() {
                            console.log('getter:', this.x)
                            return this.x
                          }
                        }
                        
                        const point = new Point(1, 10)
                        point.prop = 100 // setter: 100
                        point.prop // gettter: 100
                        

                        上面示例中,prop 属性有对应的存值函数和取值函数,因此存取行为都被自定义了。还有 gettersetter 方法是设置在属性的 Descripter 对象上的。

                        五、属性表达式

                        类的属性名,可以采用表达式,即计算属性名。

                        let methodName = 'getX'
                        
                        class Point {
                          [methodName]() {
                            // ...
                          }
                        }
                        
                        // 访问
                        const point = new Point()
                        point[methodName] // or point.getX
                        

                        六、类的表达方式

                        类内部是在严格模式下运行的。

                        类可以这样定义:

                        // 1️⃣ 类声明
                        class Foo {
                          constructor() {}
                        }
                        
                        
                        // 2️⃣ 匿名类表达式(匿名类,就像匿名函数表达式一样)
                        const Foo = class {
                          constructor() {}
                        }
                        
                        
                        // 3️⃣ 具名类表达式
                        const Foo = class NamedFoo {
                          constructor() {
                            // 在内部,可以使用 NamedFoo 或 Foo 访问类的属性或(静态)方法。
                            // 但是,在类的外部只能使用 Foo,不能使用 NamedFoo。
                            // 若内部无需使用到 NamedFoo,则可以使用匿名的方式。
                            console.log(NamedFoo.name) // "NamedFoo"
                            console.log(Foo.name) // "NamedFoo"
                          }
                        }
                        
                        Foo.name // "NamedFoo"
                        NamedFoo.name // ReferenceError: NamedFoo is not defined
                        

                        以上三种类的表达方式,可以对应上:函数声明、匿名函数表达式、(具名)函数表达式,这点是相同的。

                        还有,利用“类表达式”的形式,可以写出立即执行的 Class,这点与函数表达式是相同的。

                        // 此时 foo 就是类的实例对象
                        const foo = new class {
                          constructor(name) {
                            this.name = name
                          }
                        }('Frankie')
                        
                        foo.name // "Frankie"
                        

                        以上三种方式都可以定义一个类,但需要注意的是:

                        // 1. 重复声明一个类会抛出类型错误。
                        // 在这点上,class 与 let、const 表现是一致的,均不可重复声明。
                        class Foo {}
                        class Foo {} // Uncaught TypeError: Identifier 'Foo' has already been declared
                        
                        // 2. class 同样不会“提升”(Hoisting),
                        // 因此实例化之前,一定要先声明类,否则会抛出引用错误。
                        const foo = new Foo() // ReferenceError: Cannot access 'Foo' before initialization
                        class Foo {}
                        

                        七、注意点

                        1. 严格模式

                        在类和模块的内部,默认就是严格模式,无需通过 use strict 来指定,也仅有严格模式可用。

                        2. 提升问题

                        刚才提到使用 class 关键字声明的类,不存在“提升” (Hoisting)问题。这种规定的原因与类的继承有关,必须保证子类在父类之后定义。

                        3. name 属性

                        本质上,ES6 的类只是 ES5 的构造函数的一层包装,所以函数的许多特性都被 class 继承,包括 name 属性。它总是返回 class 关键字后面的类名,若是匿名类表达式声明,则返回变量名。

                        class Foo {}
                        const Bar = class {}
                        const B = class Baz {}
                        
                        console.log(Foo.name) // "Foo"
                        console.log(Bar.name) // "Bar"
                        console.log(B.name) // "Baz"
                        

                        4. Generator 方法

                        如果在某个方法之前加上星号(*),则表示该方法是一个 Generator 函数。

                        以下示例中,Foo 类的 Symbol.iterator 方法就是一个 Generator 函数。Symbol.iterator 方法返回一个 Foo 类的默认遍历器,for...of 循环会自动调用这个遍历器。

                        class Foo {
                          constructor(...args) {
                            this.args = args
                          }
                        
                          *[Symbol.iterator]() {
                            for (let arg of this.args) {
                              yield arg
                            }
                          }
                        }
                        
                        const foo = new Foo('Hello', 'World')
                        for (let x of foo) {
                          console.log(x)
                        }
                        // "Hello"
                        // "World"
                        

                        5. this 指向

                        类的方法内部如果含有 this,它默认指向类的实例。但是,必须非常小心,一旦单独使用该方法,很可能会报错。注意,如果是静态方法内,this 指向类本身。

                        class Point {
                          constructor(x, y) {
                            this.x = x
                            this.y = y
                          }
                        
                          // static classMethod() {
                          //   return this // this 指向 Point 本身
                          // }
                        
                          getX() {
                            return this.x
                          }
                        }
                        
                        const point = new Point(1, 10)
                        console.log(point.getX()) // 1
                        
                        const { getX } = point
                        getX() // TypeError: Cannot read property 'x' of undefined
                        

                        在上述示例中,getX 方法的 this 默认指向 Point 实例对象。但是,如果将这个方法提取出来单独使用,this 会指向该方法运行时所在的环境(由于 class 内部是严格模式,所以 this 实际指向的是 undefined),导致找不到 getX 方法而报错。

                        解决方法如下:

                        // 解决方法一
                        class Point {
                          constructor(x, y) {
                            this.x = x
                            this.y = y
                            this.getX = this.getX.bind(this) // 构造函数中绑定实例对象
                          }
                        
                          getX() {
                            return this.x
                          }
                        }
                        
                        // 解决方法二
                        class Point {
                          constructor(x, y) {
                            this.x = x
                            this.y = y
                          }
                        
                          // 这写法,相当于在 constructor 中定义了:
                          // this.getX = () => { /* ... */ }
                          getX = () => {
                            return this.x
                          }
                        }
                        
                        // 注意,两者还是有区别的:
                        // 1. 两种解决方法,都会在 Point 的实例对象上,定义了一个 getX 方法。
                        // 2. 解决方法一,除了在实例对象上含有 getX 方法,在其实例对象的原型上也有一个 getX 方法。
                        // 3. 而解决方案二,其实只会将 getX 挂载到实例对象上,而原型上是没有的。
                        // 4. 以上的区别,其实上面的内容都有提到,如还不太清楚,建议回头再看看。
                        

                        八、静态方法

                        类相当于实例的原型,所有在类中定义的方法,都会被实例继承。如果在一个方法前,加上 static 关键字,就表示该方法不会被实例继承,而是直接通过类来调用,这个就被称为**“静态方法”**。

                        class Foo {
                          static classMethod() {
                            console.log('Hello World!')
                          }
                        }
                        
                        // Correct
                        Foo.classMethod() // "Hello World!"
                        
                        // Wrong
                        const foo = new Foo()
                        foo.classMethod() // TypeError: foo.classMethod is not a function
                        

                        上述示例中,Foo 类的 classMethod 方法前有 static 关键字,表示该方法是一个静态方法,可以直接在 Foo 类上调用,而不是在 Foo 类的实例对象上调用。若通过实例对象调用静态方法,会抛出错误,因为实例对象上并没有 classMethod 方法。

                        注意,静态方法内 this 指向类本身,而非实例对象。

                        class Foo {
                          static bar() {
                            ths.baz() // this 指向 Foo 本身
                          }
                        
                          static baz() {
                            console.log('baz')
                          }
                        
                          // 这是没问题的,允许静态方法与非静态方法重名
                          // static qux() {
                          //   // ...
                          // }
                        
                          // 该方法只会在实例化时才会挂载到实例对象上
                          // 而 Foo 类本身是不含此方法的
                          // 因此,静态方法与非静态方法是可以重名的。
                          qux() {
                            console.log('qux')
                          }
                        }
                        
                        Foo.bar() // "baz"
                        Foo.qux() // TypeError: Foo.qux is not a function
                        

                        父类的静态方法,可以被子类继承。

                        class Foo {
                          static classMethod() {
                            console.log('The static method of the parent class.')
                          }
                        }
                        
                        class Bar extends Foo {}
                        
                        // 可以在子类中调用父类的 classMethod 静态方法
                        Bar.classMethod() // "The static method of the parent class."
                        

                        若子类也定义了 classMethod 静态方法,可以通过 super 对象调用父类的 classMethod 静态方法。

                        class Foo {
                          static classMethod() {
                            console.log('The static method of the parent class.')
                          }
                        }
                        
                        class Bar extends Foo {
                          static classMethod() {
                            super.classMethod() // 调用父类静态方法
                            console.log('Static method of subclass.')
                          }
                        }
                        
                        Bar.classMethod()
                        // "The static method of the parent class."
                        // "Static method of subclass."
                        

                        九、静态属性

                        静态属性指的是 Class 本身的属性,即 Class.propName,而不是定义在实例对象上的属性。

                        目前,根据 ECMAScript 规定,Class 内部只有静态方法,没有静态属性。静态属性只能通过在 Class 外部定义。

                        class Foo {}
                        Foo.prop = 1 // 静态属性 prop
                        const foo = new Foo()
                        
                        console.log(foo.prop) // undefined
                        console.log(Foo.prop) // 1
                        

                        现在有一个提案提供了类的静态属性,写法是在属性签名加上 static 关键字。

                        class Foo {
                          static prop = 1
                        }
                        

                        通过以上方式来定义静态属性,显然要比老式写法更好地组织代码,其语义更好。而老式写法往往很容易让人忽略这个静态属性。

                        十、私有方法和私有属性

                        在目前,在 Class 内部定义的属性和方法,在类的外部都是可以访问到的。

                        而私有方法和私有属性的目的在于,它们只允许在 Class 内部访问,而外部是不能访问的。但由于目前 ECMAScript 标准并未提供,只能通过变通的方式模拟实现。

                        1. 通过命名加以区别
                        class Foo {
                          // 公有方法
                          bar() {
                            this._baz()
                          }
                        
                          // 私有方法,通过在变量方法名之前添加下划线 "_" 区分
                          _baz() {
                            // do something...
                          }
                        }
                        

                        但显然这仍然可在 Foo 实例对象中访问到 instance._baz()

                        1. 将私有方法移出类
                        class Foo {
                          // 公有方法
                          bar(...args) {
                            baz.apply(this, args)
                          }
                        }
                        
                        // 相当于私有方法
                        function baz() {
                          // do something...
                        }
                        

                        以上示例,间接使得 baz 成了类的“私有方法”,它对类的实例是不可见的。

                        1. 利用 Symbol 的唯一性,将私有方法的名称命名为 Symbol 值。
                        const _baz = Symbol('baz')
                        
                        class Foo {
                          // 公有方法
                          bar(...args) {
                            this[_baz].apply(this, args)
                          }
                        
                          // 私有方法
                          [_baz]() {
                            // do something...
                          }
                        }
                        

                        以上示例中,_barSymbol 值,一般在封装类时不让其在获取到,以达到私有方法和私有属性的效果。但是仍然可通过 Reflect.ownKeys() 依然可以获取到。

                        Reflect.ownKeys(Foo.prototype) // ["constructor", "bar", Symbol(baz)]
                        

                        私有属性的提案

                        目前,有一个提案为 Class 添加私有属性。在属性名之前,使用 # 表示。

                        class Foo {
                          // 公用属性
                          prop = 'public property'
                        
                          // 公有方法
                          bar(...args) {
                            this.#bar.apply(this, args)
                          }
                        
                          // 私有属性
                          #prop = 'private property'
                        
                          // 私有方法
                          #bar() {
                            // do something...
                          }
                        }
                        
                        const foo = new Foo()
                        foo.bar('bar') // Correct
                        foo.prop // Correct
                        Reflect.ownKeys(Foo.prototype) // ["constructor", "bar"]
                        
                        // 外部不可访问私有属性和私有方法,会报错。
                        // foo.#prop // Wrong, SyntaxError: Private field '#prop' must be declared in an enclosing class
                        // foo.#bar() // Wrong
                        

                        在上述示例中,#prop#bar 就是私有属性和私有属性,且 # 是属性名的一部分,使用时也必须带有 #,因此 #propprop 是两个不同的属性。

                        另外,私有属性也可以设置 settergetter 方法。

                        还有,私用属性和私有方法,前面也可以加上 static 关键字,使其成为静态的私有属性或方法。

                        class Foo {
                          // 静态属性
                          static prop = 'private property'
                        
                          // 静态私有属性
                          static #prop = 'static private property'
                        
                          // 静态方法
                          static bar() {
                            console.log(Foo.prop)
                            console.log(Foo.#prop)
                          }
                        
                          // 静态私有方法
                          static #bar() {
                            console.log(Foo.prop)
                            console.log(Foo.#prop)
                          }
                        }
                        
                        // 正常访问
                        Foo.prop // "private property"
                        Foo.bar() // "private property"、"static private property"
                        
                        // 以下报错
                        Foo.#prop // Private field '#prop' must be declared in an enclosing class
                        Foo.#bar()
                        

                        上面示例中,#prop 是静态私有属性,#bar 是静态私有方法,在 Class 外部是不能访问的,只能在内部使用。

                        还有,静态的私有属性或方法,都是可以被子类继承的。

                        class Bar extends Foo {}
                        
                        Bar.prop // Correct
                        Bar.bar() // Correct
                        

                        十一、new.target 属性

                        new 运算符是从构造函数生成实例对象的关键字。在构造函数是通过 newReflect.constructor() 调用的,那么 new.target 指向被被调用的构造函数,否则返回 undefined

                        因此,可以利用它来确保构造函数只能通过 new 关键字来调用。例如:

                        function Point(x, y) {
                          // 也可以这样判断:`new.target === Point`
                          if (new.target !== undefined) {
                            this.x = x
                            this.y = y
                          } else {
                            throw new TypeError('Point() must be called with new.')
                          }
                        }
                        
                        const p1 = new Point(1, 2) // 正确使用方式
                        const p2 = Point(3, 4) // TypeError: Point() must be called with new.
                        

                        而在类的构造方法中,new.target 指向**“直接”**被 new 执行的构造函数。那么,当子类继承父类时,在父类的构造方法中 new.target 指向子类。

                        class Point {
                          constructor(x, y) {
                            this.x = x
                            this.y = y
                            // 若子类继承父类时,new.target 指向子类。
                            console.log(new.target === Point)
                        
                            // if (new.target === Point) {
                            //   throw new TypeError('The Point class cannot be instantiated.')
                            // }
                          }
                        }
                        
                        class P extends Point {
                          constructor(x, y, z) {
                            super(x, y)
                            this.z = z
                          }
                        }
                        
                        const point = new Point(1, 2) // 会打印 true
                        const p = new P(1, 2, 3) // 会打印 false
                        

                        利用此特性,可以写出不可独立使用,必须继承后才会使用的父类。如注释部分。

                        若在函数外部使用 new.target 会抛出错误:

                        new.target // SyntaxError: new.target expression is not allowed here
                        

                        下一篇接着介绍 Class 继承...

                        十二、参考链接

                        ]]>
                        <![CDATA[在事件处理函数中的 this]]> https://github.com/tofrankie/blog/issues/250 https://github.com/tofrankie/blog/issues/250 Sun, 26 Feb 2023 11:39:25 GMT 配图源自 Freepik

                        在 JavaScript 中,this 是一个很重要]]> 配图源自 Freepik

                        在 JavaScript 中,this 是一个很重要的关键字。此前写过一篇文章:this 详解。本文内容主要介绍事件处理函数中如何使用 this 的几种方法及区别。

                        注意,本文示例均在非严格模式下运作。而严格模式下,某些场景对 this 会有一些约束,具体可看这篇文章

                        一、Owner

                        我们将在本文讨论的问题是:在函数 doSomething()this 指向的是什么?

                        <script>
                          function doSomething() {
                            this.style.color = '#c00'
                          }
                        </script>
                        

                        在 JavaScript 中, this 总是指我们正在执行的函数的“owner”,或者更确切地说,是指函数作为其方法的对象。当我们在页面中定义我们函数 doSomething() 时,它的“owner”是页面,或者更确切地说,是 JavaScript 的 window 对象(或全局对象)。但是,onclick 属性归它所属的 HTML 元素所有。

                        这种“ownership”是 JavaScript 面向对象方法的结果。有关更多信息,请参阅作为关联数组的对象页面。

                        ------------ window --------------------------------------
                        |                                          / \           |
                        |                                           |            |
                        |                                          this          |
                        |   ----------------                        |            |
                        |   | HTML element | <-- this         -----------------  |
                        |   ----------------      |           | doSomething() |  |
                        |               |         |           -----------------  |
                        |          --------------------                          |
                        |          | onclick property |                          |
                        |          --------------------                          |
                        |                                                        |
                        ----------------------------------------------------------
                        

                        如果我们在没有任何准备的情况下执行 doSomething(),则 this 关键字指向 window 对象,该函数会尝试更改 window 对象的 style.color。由于 window 没有 style 对象,该函数会失败并产生 JavaScript 错误(TypeError)。

                        二、Copying

                        如果我们想充分利用 this,我们必须注意使用它的函数是由正确的 HTML 元素“拥有”的。换句话说,我们必须将函数复制到我们的 onclick 属性。传统的事件注册会处理它。

                        element.onclick = doSomething
                        

                        该函数被完整地复制到 onclick 属性(现在变成了一个方法)。因此,如果执行事件处理程序,this 会指向 HTML 元素,并且它的 color 会发生变化。

                        ------------ window --------------------------------------
                        |                                                        |
                        |                                                        |
                        |                                                        |
                        |   ----------------                                     |
                        |   | HTML element | <-- this         -----------------  |
                        |   ----------------      |           | doSomething() |  |
                        |               |         |           -----------------  |
                        |          -----------------------          |            |
                        |          |copy of doSomething()|  <-- copy function    |
                        |          -----------------------                       |
                        |                                                        |
                        ----------------------------------------------------------
                        

                        当然是我们可以将函数复制到多个事件处理程序。每次 this 将指向正确的 HTML 元素:

                        ------------ window --------------------------------------
                        |                                                        |
                        |                                                        |
                        |                                                        |
                        |   ----------------                                     |
                        |   | HTML element | <-- this         -----------------  |
                        |   ----------------      |           | doSomething() |  |
                        |               |         |           -----------------  |
                        |          -----------------------          |            |
                        |          |copy of doSomething()|  <-- copy function    |
                        |          -----------------------          |            |
                        |                                           |            |
                        |   -----------------------                 |            |
                        |   | another HTML element| <-- this        |            |
                        |   -----------------------     |           |            |
                        |               |               |           |            |
                        |          -----------------------          |            |
                        |          |copy of doSomething()|  <-- copy function    |
                        |          -----------------------                       |
                        |                                                        |
                        ----------------------------------------------------------
                        

                        因此,你可以最大程度地使用它。每次调用该函数时, this 指的是当前正在处理事件的 HTML 元素,即“拥有” doSomething() 副本的 HTML 元素。

                        三、Referring

                        但是,如果你使用内联事件注册

                        <element onclick="doSomething()">doSomething...</element>
                        

                        你不要复制功能!相反,你指向它,差异至关重要。onclick 属性不包含实际的函数,而只是一个函数调用:

                        doSomething()
                        

                        所以这相当于,前往 doSomething() 并执行它。当我们到达 doSomething() 时,this 关键字再次指向全局 window 对象,并且该函数返回错误消息(TypeError)。

                        ------------ window --------------------------------------
                        |                                          / \           |
                        |                                           |            |
                        |                                          this          |
                        |   ----------------                        |            |
                        |   | HTML element | <-- this         -----------------  |
                        |   ----------------      |           | doSomething() |  |
                        |               |         |           -----------------  |
                        |          -----------------------         / \           |
                        |          | go to doSomething() |          |            |
                        |          | and execute it      | ---- reference to     |
                        |          -----------------------       function        |
                        |                                                        |
                        ----------------------------------------------------------
                        

                        四、The difference

                        如果你想使用 this 来访问正在处理事件的 HTML 元素,你必须确保 this 关键字实际上已写入 onclick 属性。只有在这种情况下,this 才指向事件处理程序注册到的 HTML 元素。因此,如果你这样处理:

                        element.onclick = doSomething
                        alert(element.onclick)
                        

                        你将会得到:

                        function doSomething() {
                          this.style.color = '#c00'
                        }
                        

                        如你所见,this 关键字存在于 onclick 方法中。因此 this 指向 HTML 元素。

                        但是,如果你这样处理:

                        <element onclick="doSomething()">doSomething...</element>
                        alert(element.onclick)
                        

                        你得到的是:

                        function onclick(event) {
                          doSomething()
                        }
                        

                        这仅仅是对函数 doSomething() 的引用。onclick 方法中不存在 this 关键字,因此 this 不指向 HTML 元素。

                        五、Examples - copying

                        以下情况 this 会写入 onclick 方法:

                        element.onclick = doSomething
                        
                        element.addEventListener('click', doSomething, false)
                        
                        element.onclick = function () { this.style.color = '#c00' }
                        
                        <element onclick="this.style.color = '#c00'">doSomething...</element>
                        

                        六、Examples - referring

                        以下情况 this 指向 window 对象:

                        element.onclick = function () { doSomething() }
                        
                        element.attachEvent('onclick', doSomething)
                        
                        <element onclick="doSomething()">doSomething...</element>
                        

                        请注意 attachEvent() 的存在。 Microsoft 事件注册模型的主要缺点是 attachEvent() 创建对函数的引用而不复制它。因此,有时无法知道当前是哪个 HTML 处理该事件。

                        七、Combination

                        使用内联事件注册时,你还可以将 this 发送到该函数,以便你仍然可以使用它:

                        <element onclick="doSomething(this)">doSomething...</element>
                        
                        function doSomething(obj) {
                          // `this` is present in the event handler and is sent to the function
                          // `obj` now refers to the HTML element, so we can do
                          obj.style.color = '#c00'
                        }
                        

                        八、References

                        ]]>
                        <![CDATA[使用 Nginx 搭建静态网站]]> https://github.com/tofrankie/blog/issues/249 https://github.com/tofrankie/blog/issues/249 Sun, 26 Feb 2023 11:36:21 GMT 配图源自 Freepik

                        开始建站了,暂时还没想要做些什么东西。

                        Anyway,先搞个云服]]> 配图源自 Freepik

                        开始建站了,暂时还没想要做些什么东西。

                        Anyway,先搞个云服务器吧,那要怎么搭建呢?先来个最简单的。

                        以阿里云服务器为例,假设公网 IP 为 100.2.3.4(随便乱写的)。

                        登录云服务

                        $ ssh root@100.2.3.4
                        

                        安装 Nginx 及相关命令

                        # 安装
                        $ yum install nginx -y
                        
                        # 启动
                        $ nginx
                        
                        # 关闭
                        $ nginx -s stop
                        
                        # 重启
                        $ nginx -s reload
                        

                        Nginx 默认配置

                        Nginx 配置文件目录一般在 /etc/nginx/ 下,打开 nginx.conf 文件可以看到配置:

                        user nginx;
                        worker_processes auto;
                        error_log /var/log/nginx/error.log;
                        pid /run/nginx.pid;
                        
                        # Load dynamic modules. See /usr/share/doc/nginx/README.dynamic.
                        include /usr/share/nginx/modules/*.conf;
                        
                        events {
                            worker_connections 1024;
                        }
                        
                        http {
                            log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                                              '$status $body_bytes_sent "$http_referer" '
                                              '"$http_user_agent" "$http_x_forwarded_for"';
                        
                            access_log  /var/log/nginx/access.log  main;
                        
                            sendfile            on;
                            tcp_nopush          on;
                            tcp_nodelay         on;
                            keepalive_timeout   65;
                            types_hash_max_size 2048;
                        
                            include             /etc/nginx/mime.types;
                            default_type        application/octet-stream;
                        
                            include /etc/nginx/conf.d/*.conf;
                        
                            server {
                                listen       80 default_server;
                                listen       [::]:80 default_server;
                                server_name  _;
                                root         /usr/share/nginx/html;
                        
                                # Load configuration files for the default server block.
                                include /etc/nginx/default.d/*.conf;
                        
                                location / {
                                }
                        
                                error_page 404 /404.html;
                                location = /40x.html {
                                }
                        
                                error_page 500 502 503 504 /50x.html;
                                location = /50x.html {
                                }
                            }
                        }
                        

                        当外网用户访问服务器 Web 服务由 Nginx 提供,Nginx 需要配置静态资源的路径信息才能通过 URL 正确访问到服务器上的静态资源。

                        当我们在服务器上安装并启动 Nginx 之后,就可以通过 http://<域名或IP> 访问我们的网页了。所以,在浏览器中输入 http://100.2.3.4 即可。

                        我们观察到浏览器的地址变成了 http://100.2.3.4/index.html,这页面是安装 Nginx 的默认站点,可以在 /usr/share/nginx/html 目录下找到。在 nginx.conf 配置文件中,有一项 root /usr/share/nginx/html 的配置,意思是当外网访问服务器跟目录时,Nginx 会将资源指向 /usr/share/nginx/html 的站点。

                        但如果输入地址,无法打开(如下截图)。

                        以阿里云为例,需要在云服务器添加安全组规则,添加并保存,重新刷新页面就能打开了。

                        关于阿里云服务器安全组规则说明,推荐这篇文章

                        修改 Nginx 配置

                        我习惯将前端静态资源放置到服务器的 /data/www 下,因此将配置修改为 root /data/www。此时访问 http://100.2.3.4 会指向 /data/www/index.html(在不配置 locationindex 情况下,Nginx 默认配置是 index.html)。

                        server {
                            listen       80;
                            server_name  _;
                            root         /data/www;
                        
                            # Load configuration files for the default server block.
                            include /etc/nginx/default.d/*.conf;
                        
                            location / {
                            }
                        
                            error_page 404 /404.html;
                            location = /40x.html {
                            }
                        
                            error_page 500 502 503 504 /50x.html;
                            location = /50x.html {
                            }
                        }
                        

                        修改配置后,记得执行 ngnix -s reload 重启 Nginx 服务。

                        将 Webpack 打包的文件上传服务器

                        由于我使用的是 Mac 机器,因此可以直接在系统终端使用 scp 命令将本地文件上传到云服务器。

                        # scp [参数] [原路径] [目标路径]
                        $ scp -r /Users/frankie/Desktop/Web/react-demo/dist/* root@100.2.3.4:/data/www
                        

                        scp(secure copy)用于在 Linux 下进行远程拷贝文件的命令。类似于 cp,只不过 cp 只能在本机进行拷贝,不能跨服务器。-r 表示递归复制整个目录。

                        关于 scp 更多细节,请看文章

                        需要注意一下,下面两种的区别:

                        # 1️⃣
                        $ scp -r /xxx/dist /data/www
                        
                        # 2️⃣
                        $ scp -r /xxx/dist/* /data/www
                        

                        其中 1️⃣ 得到的是 /data/www/dist,而 2️⃣ 得到的是 /data/www。前者表示将 dist 整个目录拷贝至 /data/www 下。后者是把 dist 目录下的所有子文件和子目录都拷贝至 /data/www

                        换句话说就是,前者配置 root 的路径应该是 /data/www/dist,后者则为 /data/www

                        效果如下:

                        $ scp -r /Users/frankie/Desktop/Web/react-demo/dist/* root@100.2.3.4:/data/www/
                        root@100.2.3.4's password: 
                        bundle.2b1a17.js                              100% 1580   120.1KB/s   00:00    
                        favicon.ico                                   100% 9662   724.9KB/s   00:00    
                        index.html                                    100% 2045   149.9KB/s   00:00    
                        vendors.chunk.7fb171.js                       100%  625KB 409.8KB/s   00:01
                        
                        [root@ali-ecs www]# ls
                        bundle.2b1a17.js  favicon.ico  index.html  vendors.chunk.7fb171.js
                        

                        效果

                        在浏览器中访问 http://100.2.3.4 即可看到我们配置的网页了。

                        最简单的 Nginx 部署静态网页就完了,其他的下次再讲...

                        The end.

                        ]]>
                        <![CDATA[JavaScript 之函数防抖、节流]]> https://github.com/tofrankie/blog/issues/248 https://github.com/tofrankie/blog/issues/248 Sun, 26 Feb 2023 11:33:34 GMT 配图源自 Freepik

                        写在前面,实际项目应使用 Lodash 等主流工具库,它们经过社区的反复验证,]]> 配图源自 Freepik

                        写在前面,实际项目应使用 Lodash 等主流工具库,它们经过社区的反复验证,肯定比自己写的要完善很多。

                        前言

                        相信无论在实际应用场景、亦或是面试,都会经常遇得到函数防抖、函数节流等,下面我们来聊一聊吧。

                        先放出一个示例:

                        import React, { useEffect, useRef } from 'react'
                        import debounce from '../../utils/debounce'
                        import throttle from '../../utils/throttle'
                        import style from './index.scss'
                        
                        export default function Demo(props) {
                          const inputElem1 = useRef()
                          const inputElem2 = useRef()
                          const inputElem3 = useRef()
                        
                          useEffect(() => {
                            inputElem1.current.addEventListener('keyup', request)
                            inputElem2.current.addEventListener('keyup', debounce(request, 1000))
                            inputElem3.current.addEventListener('keyup', throttle(request, 3000))
                          }, [])
                        
                          function request(event) {
                            const { value } = event.target
                            console.log(`Http request: ${value}.`)
                          }
                        
                          return (
                            <div className={style.container}>
                              <div className={style.list}>
                                <label htmlFor="input1">普通输入框:</label>
                                <input name="input1" ref={inputElem1} defaultValue="" />
                              </div>
                        
                              <div className={style.list}>
                                <label htmlFor="input2">防抖输入框:</label>
                                <input name="input2" ref={inputElem2} defaultValue="" />
                              </div>
                        
                              <div className={style.list}>
                                <label htmlFor="input3">节流输入框:</label>
                                <input name="input3" ref={inputElem3} defaultValue="" />
                              </div>
                            </div>
                          )
                        }
                        

                        以上 Demo 只有三个输入框,很简单。我给每个输入框绑定了一个 keyup 键盘事件,该事件执行会发起网络请求(为了更简洁,这里只是打印一下而已),而对应防抖、节流输入框则经过相应的处理。

                        函数防抖(debounce)

                        如果我们在普通输入框快速键入 12345,可以从控制台上的打印结果看到,它会发起 5 次网络请求(假设我们这个是一个简单的搜索引擎)。

                        还不知道用什么截屏/录屏软件可以生成 GIF 动图,有时间再研究下...

                        从实际场景考虑,如果每键入一个字符就立刻发起网络请求,去检索结果,这是非常影响体验的。假设我们限制为:用户在停止输入后 1s 后才发起网络请求。

                        要实现这样的需求,我们只有使用函数防抖即可。

                        什么是函数防抖?

                        概念:在一定时间间隔内,事件处理函数只会执行一次。若在该时间间隔内(多次)重新触发,则重新计时。

                        怎么理解?

                        • 假设用户键入字母 a 后就停止输入了,那么网络请求会在停止键入操作的 1s 后发起。这个很好理解。
                        • 若用户继续键入字母 b 后,若有所思地停了一会(这个时间在 1s 之内,假设为 800ms 吧),接着键入字母 c,之后就停止键入了。网络请求会发生在键入字母 c 的 1s 后被发起,而不是键入字母 b 之后的 1s 发起。因为函数防抖会在键入 c 之后重新计时。

                        函数防抖实现

                        debounce(func, wait)

                        实现思路:

                        首先,接收两个参数 func(要防抖的函数,一般是事件回调函数)和 wait(需要延迟的时间间隔,单位毫秒)。然后 funcsetTimeout 中执行,而 setTimeout 的延迟时间就是 wait。而重新计时的话,则在每次触发的时候 clearTimeout 即可实现。

                        需要注意下,func 的执行上下文(this)及其入参。

                        function debounce(func, wait) {
                          let timerId
                          
                          // 为确保 this 指向正确,此处不能用箭头函数
                          return function (...args) {
                            // 在 wait 时间内,若重新触发,清除 clearTiemout,以达到重新计时的效果
                            if (timerId) clearTimeout(timerId)
                            
                            timerId = setTimeout(() => {
                              func.apply(this, args)
                            }, wait)
                          }
                        }
                        

                        依次在对应输入框内键入 12345,对比下防抖前后的结果:

                        两次键入速度差不多,而且每个字符键入时间间隔小于 1s(可调大延迟执行时间,更容易对比)。

                        // 普通输入框
                        inputElem1.current.addEventListener('keyup', request)
                        // 防抖输入框
                        inputElem2.current.addEventListener('keyup', debounce(request, 1000))
                        

                        ▼ 无防抖处理

                        ▼ 防抖处理

                        对比以上无防抖处理和防抖处理的结果,可以看到前者每键入一个字符都会执行回调函数,而后者则会在最后一次触发的 N 毫秒(即 wait 延迟时间)之后才会执行一次回调函数。

                        还有一种是“立即执行”的函数防抖:区别在于第一次触发时,是否立即执行回调函数。

                        再结合以上的“非立即执行”的防抖,完整方法如下:

                        function debounce(func, wait, immediate = false) {
                          let timerId
                          return function (...args) {
                            if (timerId) clearTimeout(timerId)
                        
                            if (immediate && !timerId) {
                              func.apply(this, args)
                            }
                        
                            timerId = setTimeout(() => {
                              func.apply(this, args)
                            }, wait)
                          }
                        }
                        

                        当我们修改成:

                        inputElem2.current.addEventListener('keyup', debounce(request, 1000, true))
                        

                        从以下结果可以看到,当我在防抖输入框键入 12345 的时候,它会在键入 1 时立刻发起一次网络请求,由于每个字符键入的时间间隔都在 1s 之内,因此它只会在最后停止键入的 1s 后才会发起网络请求。

                        函数节流(throttle)

                        概念:在一定时间间隔内只会触发一次函数。若在该时间间隔内触发多次函数,只有第一次生效。

                        函数节流实现

                        function throttle(func, wait) {
                          // 记录上一次执行 func 的时间
                          let prev = 0
                          return function (...args) {
                            // 当前触发的时间(时间戳)
                            const now = Number(new Date()) // +new Date()
                            
                            // 单位时间内只会执行一次
                            if (now >= prev + wait) {
                              // 符合条件执行 func 时,需要更新 prev 时间
                              prev = now
                              func.apply(this, args)
                            }
                          }
                        }
                        

                        函数节流优化

                        以上节流方法有个问题,假设节流控制间隔时间为 1s,若最后一次触发时间在 1.5s,则最后一次触发并不会执行。因此,需要在节流中嵌入防抖思想,以保证最后一次会被触发。

                        function throttle(func, wait) {
                          // 记录上一次执行 func 的时间
                          let prev = 0
                          let timerId
                          return function (...args) {
                            // 当前触发的时间(时间戳)
                            const now = Number(new Date()) // +new Date()
                        
                            // 保证最后一次也会触发
                            // 我看到很多文章,将清除定时器的步骤放到 2️⃣ 里面
                            // 我认为应该放在这里才对,原因看我下面举例的场景。
                            if (timerId) clearTimeout(timerId)
                            
                            if (now >= prev + wait) {
                              // 1️⃣
                              // 符合条件执行 func 时,需要更新 prev 时间
                              prev = now
                              func.apply(this, args)
                            } else {
                              // 2️⃣
                              // 单位时间内只会执行一次
                              // if (timerId) clearTimeout(timerId) // 不应该放在这里
                              timerId = setTimeout(() => {
                                prev = now
                                func.apply(this, args)
                              }, wait)
                            }
                          }
                        }
                        

                        假设我将 clearTimeout() 放在了 2️⃣ 里面,而不是在外层。基于 throttle(func, 1000) 考虑以下场景:

                        我在 4s 时触发了一次,应该走 1️⃣ 逻辑。然后在 4.9s 时又触发了一次,这会走的 2️⃣ 逻辑并记录了一个定时任务。然后时间到了 5s,我又触发了一次(后面就停止操作了),它会走 1️⃣ 逻辑一次,接着时间来的 5.9s,它还会执行一遍 fn.apply(this, args),因为在 5s 触发时,没有 clearTimeout()。因此,清除定时器的步骤应该放在外层,以保证每次被触发是都清掉最后一次的定时器,避免在一些边界 Case 触发两次。

                        当然,以上场景是在理想的状态,实际场景可能几乎碰不到这些边界。但从严谨的角度去看问题,应该也要考虑的。

                        写到这里,我又在想刚刚的“立即执行的函数防抖”,跟这个优化版的节流是不是有点像,第一次触发都会执行回调函数。但区别是防抖会重新计时,而节流在第一次触发后面的每个间隔时间点都会触发,非间隔点的最后一次触发也将会被执行。

                        我在节流输入框内,依次键入 1234567890,可以看到:在键入字符 1 时执行了回调;接着键入的 23467 字符都属在上一个时间间隔内,因此无法执行回调。其中键入的 90 字符应属于 8 之后的 1s 周期之内,由于键入 0 字符属于最后一次的非时间间隔内的触发动作,因此回调会在键入 0 的 1s 后被执行。(可打印时间戳的形式,更精细地对比)

                        inputElem3.current.addEventListener('keyup', throttle(request, 1000))
                        

                        ▼ 节流处理

                        防抖与节流

                        其实,函数防抖和函数节流都是为了防止某个时间段频繁触发某个事件。它俩在某个时间间隔内多次重复触发,都只会执行一次回调函数。区别在于函数防抖最后一次触发有效,而函数节流则是第一次触发有效。

                        而在上面,都对函数防抖和函数节流做了“拓展”,例如:

                        • 在函数防抖中,增加了 immediate 的参数,用于控制第一次是否执行回调。
                        • 在函数节流中,允许最后一次在非时间间隔的触发动作有效。

                        应用场景:

                        • 函数防抖(debounce)

                          • 搜索场景:防止用户不停地输入,来节约请求资源。
                          • window resize:调整浏览器窗口大小时,利用防抖使其只触发一次。
                        • 函数节流(throttle)

                          • 鼠标事件、mousemove 拖拽
                          • 监听滚动事件

                        如果还是不太明白 debounce 和 throttle 的差异,可以在以下这个页面,可视化体验。

                        类型标注

                        function debounce<A extends any[], R>(
                          func: (...args: A) => R,
                          wait: number = 0,
                          immediate: boolean = false
                        ): (...args: A) => void {
                          let timerId: number | undefined
                        
                          return function (this: any, ...args) {
                            if (timerId) clearTimeout(timerId)
                        
                            if (immediate && !timerId) {
                              func.apply(this, args)
                            }
                        
                            timerId = setTimeout(() => {
                              func.apply(this, args)
                            }, wait)
                          }
                        }
                        
                        function throttle<A extends any[], R>(func: (...args: A) => R, wait: number): (...args: A) => void {
                          let prev = 0
                          let timerId: number | undefined
                        
                          return function (this: any, ...args) {
                            const now = Number(new Date())
                        
                            if (timerId) clearTimeout(timerId)
                        
                            if (now >= prev + wait) {
                              prev = now
                              func.apply(this, args)
                            } else {
                              timerId = setTimeout(() => {
                                prev = now
                                func.apply(this, args)
                              }, wait)
                            }
                          }
                        }
                        

                        看到有些库像下面这样标注,这里有个问题是 debounce 或 throttle 里 return 的函数是没有返回值的,也就是说,理应返回 (...args: A) => void,但实际返回了 (...args: A) => R,这样做其实不太对。但这样体验上有一个好处:在调用 throttledFn 时可以看到原函数返回值是什么类型。

                        function throttle<T>(func: T, wait: number): T {
                          // ...
                        }
                        
                        const throttledFn = throttle(function doSomething() {}, 100)
                        

                        使用 JSDoc 注释(节流同理)

                        /**
                         * 函数防抖
                         * @template A, R
                         * @param {(...args: A) => R} func 要防抖的函数
                         * @param {number} wait 防抖时间
                         * @param {boolean} [immediate=false] 立即执行
                         * @returns {(...args: A) => void} 返回被防抖处理的函数
                         */
                        export const debounce = (func, wait, immediate = false) => {
                          let timerId
                          return function (...args) {
                            if (timerId) clearTimeout(timerId)
                        
                            if (immediate && !timerId) {
                              func.apply(this, args)
                            }
                        
                            timerId = setTimeout(() => {
                              func.apply(this, args)
                            }, wait)
                          }
                        }
                        

                        参考链接

                        ]]>
                        <![CDATA[针对 Chrome 80 和 Chrome 91 对 Cookie SameSite 限制的解决方案]]> https://github.com/tofrankie/blog/issues/247 https://github.com/tofrankie/blog/issues/247 Sun, 26 Feb 2023 11:32:37 GMT 配图源自 Freepik

                        背景

                        由于此前我的 Chrome 浏览器一直都是安装最新 B]]> 配图源自 Freepik

                        背景

                        由于此前我的 Chrome 浏览器一直都是安装最新 Beta 版本,但是有一天因为该浏览器 Cookie 的 SameSite 属性的限制(Chrome 80 版本以后),导致跨域请求无法携带上 Cookie 了,导致在开发过程中遇阻了。

                        解决方法

                        自 Chrome 80 版本起,Chrome 更新了 SameSite 属性的默认值,由 None 改成了 Lax,主要用于限制第三方 Cookie,减少安全风险和用户追踪。基于 Chromium 的 Edge 浏览器,在对应版本也会有此限制哦。

                        方案一(不推荐)

                        安装 Chrome 80 以下版本。

                        方案二:针对 Chrome 80 以上,Chrome 91 以下的浏览器

                        浏览器地址输入 chrome://flags/ 并前往 ,搜索 SameSite by default cookiesCookies without SameSite must be secure,将这两项设置为 Disabled,然后重启浏览器。

                        方案三:针对 Chrome 91 及更新版本

                        近期将 Google Chrome 升级到 91 版本之后,将上述提到的 SameSite by default cookiesCookies without SameSite must be secure 直接屏蔽并设置为默认值(Default),所以我们原来的配置失效了,且再也无法通过上述方式去修改配置了。

                        其中一个解决方法,还是降级到 Chrome 91 以下(仍然不推荐)。

                        Windows 平台:

                        在完全关闭 Chrome 浏览器的情况下,打开 Chrome 浏览器快捷方式,在目标后添加 --disable-features=SameSiteByDefaultCookies 保存。

                        Win 平台未亲测,方案和配图源自:NgZaamPaang

                        macOS 平台:

                        前提还是完全关闭浏览器,根据不同的浏览器,选择不同的启动命令,在终端执行命令打开。

                        # Google Chrome
                        $ open -a "Google Chrome" --args --disable-features=SameSiteByDefaultCookies
                        
                        # Microsoft Edge by Chromium
                        $ open -a "Microsoft Edge" --args --disable-features=SameSiteByDefaultCookies
                        

                        方案四:针对未来版本(94 版本)

                        Chromium 项目官网提到在 94 版本通过命令行禁用设置 SameSite 默认值的方式会被移除,届时方案二和方案三的方式都将无法使用,后续可通过 Nginx 等代理工具或软件将跨域请求转为非跨域请求来解决改问题。

                        官方描述如下

                        The flags #same-site-by-default-cookies and #cookies-without-same-site-must-be-secure have been removed from chrome://flags as of Chrome 91, as the behavior is now enabled by default. In Chrome 94, the command-line flag --disable-features=SameSiteByDefaultCookies,CookiesWithoutSameSiteMustBeSecure will be removed.

                        下载

                        Chrome 浏览器:

                        也可访问 Chrome Downloads 下载其他历史版本,Mac/Win 版都挺全的。

                        Firefox 浏览器:

                        参考链接

                        ]]>
                        <![CDATA[超详细的 JavaScript 深拷贝实现]]> https://github.com/tofrankie/blog/issues/246 https://github.com/tofrankie/blog/issues/246 Sun, 26 Feb 2023 11:27:14 GMT 配图源自 Feepik

                        一、JSON.stringify() 的缺陷

                        利用内置的 JS]]> 配图源自 Feepik

                        一、JSON.stringify() 的缺陷

                        利用内置的 JSON 静态方法,可以实现简易的深拷贝:

                        const obj = {
                          // ...
                        }
                        JSON.parse(JSON.stringify(obj)) // 序列化与反序列化
                        

                        它可以满足大部分应用场景,毕竟很少去拷贝函数之类的。

                        JSON.stringify(value[, replacer[, space]])
                        

                        简单总结:

                        • 布尔值、数值、字符串对应的包装对象,在序列化过程会自动转换成其原始值。

                        • undefined、任意函数、Symbol 值,在序列化过程有两种不同的情况。

                          • 若出现在非数组对象的属性值中,会被忽略。
                          • 若出现在数组中,会转换成 null
                        • 任意函数undefined 被单独转换时,会返回 undefined

                        • 所有以 Symbol 为属性键的属性都会被忽略,即便在第二个参数 replacer 中指定了该属性。

                        • Date 调用了其内置的 toJSON() 方法转换成字符串,因此会被当初字符串处理。

                        • NaNInfinity 的数值及 null 都会当做 null

                        • 这些对象 MapSetWeakMapWeakSet 仅会序列化可枚举的属性。

                        • 被转换值如果含有 toJSON() 方法,该方法定义什么值将被序列化。

                        • 对包含循环引用的对象进行序列化,会抛出错误。

                        从命名来看,我认为它们只是方便我们操作符合 JSON 格式的 JavaScript 对象或符合 JSON 格式的字符串。

                        JSON 是一种数据格式,也可以说是一种规范。JSON 是用于跨平台数据交流的,独立于语言和平台。而 JavaScript 对象是一个实例,存在于内存中。JavaScript 对象是没办法传输的,只有在被序列化为 JSON 字符串后才能传输。

                        它只是恰好能满足一些简单的深拷贝需求而已。

                        二、边界条件

                        其实实现一个较为完整的深拷贝,要处理很多边界条件。比如:

                        • 循环引用
                        • 包装对象
                        • 函数
                        • 原型链
                        • 不可枚举属性
                        • Map/WeakMap、Set/WeakSet
                        • RegExp
                        • Symbol
                        • Date
                        • ArrayBuffer
                        • 原生 DOM/BOM 对象
                        • ...

                        至于要不要考虑那么多边界条件,视实际需求而定。

                        目前,最完善的深拷贝方法应该是 Lodash 的 _.cloneDeep() 方法。实际项目中,如需处理 JSON.stringify() 无法解决的 Case,我会推荐使用它

                        本文旨在学习,以上边界条件都会尽可能兼顾到。这样,无论日后实现特殊的深拷贝,还是面试,都可以从容应对。

                        三、实现

                        以下将会逐步实现,完整示例放在文末。

                        使用递归思路实现。先写一个简易版本:

                        const deepCopy = source => {
                          // 判断是否为数组
                          const isArray = arr => Object.prototype.toString.call(arr) === '[object Array]'
                        
                          // 判断是否为引用类型
                          const isObject = obj => obj !== null && (typeof obj === 'object' || typeof obj === 'function')
                        
                          // 拷贝(递归思路)
                          const copy = input => {
                            if (typeof input === 'function' || !isObject(input)) return input
                        
                            const output = isArray(input) ? [] : {}
                            for (let key in input) {
                              if (input.hasOwnProperty(key)) {
                                const value = input[key]
                                output[key] = copy(value)
                              }
                            }
                        
                            return output
                          }
                        
                          return copy(source)
                        }
                        

                        以上简易版本还存在很多情况要特殊处理,接下来针对 JSON.stringify() 的缺陷,一点一点去完善它。

                        3.1 针对布尔值、数值、字符串的包装对象的处理

                        需要注意的是,从 ES6 开始围绕原始数据类型创建一个显式包装器对象不再被支持。但由于遗留原因,现有的原始包装器对象(如 new Booleannew Numbernew String)仍可使用。这也是 ES6+ 新增的 SymbolBigInt 数据类型无法通过 new 关键字创建实例对象的原因。

                        由于 for...in 无法遍历不可枚举的属性。例如,包装对象的 [[PrimitiveValue]] 内部属性,因此需要我们特殊处理一下。

                        以上结果,显然不是预期结果。包装对象的 [[PrimitiveValue]] 属性可通过 valueOf() 方法获取。

                        const deepCopy = source => {
                          // 获取数据类型(本次新增)
                          const getClass = x => Object.prototype.toString.call(x)
                        
                          // 判断是否为数组
                          const isArray = arr => getClass(arr) === '[object Array]'
                        
                          // 判断是否为引用类型
                          const isObject = obj => obj !== null && (typeof obj === 'object' || typeof obj === 'function')
                        
                          // 判断是否为包装对象(本次新增)
                          const isWrapperObject = obj => {
                            const theClass = getClass(obj)
                            const type = /^\[object (.*)\]$/.exec(theClass)[1]
                            return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt'].includes(type)
                          }
                        
                          // 处理包装对象(本次新增)
                          const handleWrapperObject = obj => {
                            const type = getClass(obj)
                            switch (type) {
                              case '[object Boolean]':
                                return Object(Boolean.prototype.valueOf.call(obj))
                              case '[object Number]':
                                return Object(Number.prototype.valueOf.call(obj))
                              case '[object String]':
                                return Object(String.prototype.valueOf.call(obj))
                              case '[object Symbol]':
                                return Object(Symbol.prototype.valueOf.call(obj))
                              case '[object BigInt]':
                                return Object(BigInt.prototype.valueOf.call(obj))
                              default:
                                return undefined
                            }
                          }
                        
                          // 拷贝(递归思路)
                          const copy = input => {
                            if (typeof input === 'function' || !isObject(input)) return input
                        
                            // 处理包装对象(本次新增)
                            if (isWrapperObject(input)) {
                              return handleWrapperObject(input)
                            }
                        
                            // 其余部分没变,为了减少篇幅,省略一万字...
                          }
                        
                          return copy(source)
                        }
                        

                        我们在控制台打印一下结果,可以看到是符合预期结果的。

                        3.2 针对函数的处理

                        直接返回就好了,一般不用处理。在实际应用场景需要拷贝函数太少了...

                        const copy = input => {
                          if (typeof input === 'function' || !isObject(input)) return input
                        }
                        

                        3.3 针对以 Symbol 值作为属性键的处理

                        由于以上 for...in 方法无法遍历 Symbol 的属性键,因此:

                        const sym = Symbol('desc')
                        const obj = {
                          [sym]: 'This is symbol value'
                        }
                        console.log(deepCopy(obj)) // {},拷贝结果没有 [sym] 属性
                        

                        这里,我们需要用到两个方法:

                        const copy = input => {
                          // 其它不变
                          for (let key in input) {
                            // ...
                          }
                        
                          // 处理以 Symbol 值作为属性键的属性(本次新增)
                          const symbolArr = Object.getOwnPropertySymbols(input)
                          if (symbolArr.length) {
                            for (let i = 0, len = symbolArr.length; i < len; i++) {
                              if (input.propertyIsEnumerable(symbolArr[i])) {
                                const value = input[symbolArr[i]]
                                output[symbolArr[i]] = copy(value)
                              }
                            }
                          }
                        
                          // ...
                        }
                        

                        下面我们对 source 对象做拷贝操作:

                        const source = {}
                        const sym1 = Symbol('1')
                        const sym2 = Symbol('2')
                        Object.defineProperties(source,
                          {
                            [sym1]: {
                              value: 'This is symbol value.',
                              enumerable: true
                            },
                            [sym2]: {
                              value: 'This is a non-enumerable property.',
                              enumerable: false
                            }
                          }
                        )
                        

                        打印结果,也符合预期结果:

                        3.4 针对 Date 对象的处理

                        其实,处理 Date 对象,跟上面提到的包装对象的处理是差不多的。暂时先放到 isWrapperObject()handleWrapperObject() 中处理。

                        const deepCopy = source => {
                          // 其他不变...
                        
                          // 判断是否为包装对象(本次更新)
                          const isWrapperObject = obj => {
                            const theClass = getClass(obj)
                            const type = /^\[object (.*)\]$/.exec(theClass)[1]
                            return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt', 'Date'].includes(type)
                          }
                        
                          // 处理包装对象
                          const handleWrapperObject = obj => {
                            const type = getClass(obj)
                            switch (type) {
                              // 其他 case 不变
                              // ...
                              case '[object Date]':
                                return new Date(obj.valueOf()) // new Date(+obj)
                              default:
                                return undefined
                            }
                          }
                        
                          // 其他不变...
                        }
                        

                        3.5 针对 Map、Set 对象的处理

                        同样的,暂时先放到 isWrapperObject()handleWrapperObject() 中处理。

                        利用 Map、Set 对象的 Iterator 特性和自身的方法,可以快速解决。

                        const deepCopy = source => {
                          // 其他不变...
                        
                          // 判断是否为包装对象(本次更新)
                          const isWrapperObject = obj => {
                            const theClass = getClass(obj)
                            const type = /^\[object (.*)\]$/.exec(theClass)[1]
                            return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt', 'Date', 'Map', 'Set'].includes(type)
                          }
                        
                          // 处理包装对象
                          const handleWrapperObject = obj => {
                            const type = getClass(obj)
                            switch (type) {
                              // 其他 case 不变
                              // ...
                              case '[object Map]': {
                                const map = new Map()
                                obj.forEach((item, key) => {
                                  // 需要注意的是,这里的 key 不能深拷贝,否则就会失去引用了
                                  // 具体原因可以思考一下,不难。想不明白再评论区吧
                                  map.set(key, copy(item))
                                })
                                return map
                              }
                              case '[object Set]': {
                                const set = new Set()
                                obj.forEach(item => {
                                  set.add(copy(item))
                                })
                                return set
                              }
                              default:
                                return undefined
                            }
                          }
                        
                          // 其他不变...
                        }
                        

                        打印下结果:

                        3.6 针对循环引用的问题

                        以下是一个循环引用(circular reference)的对象:

                        const foo = { name: 'Frankie' }
                        foo.bar = foo
                        

                        上面提到 JSON.stringify() 无法处理循环引用的问题,我们在控制台打印一下:

                        从结果可以看到,当对循环引用的对象进行序列化处理时,会抛出类型错误:Uncaught TypeError: Converting circular structure to JSON

                        接着,使用自行实现的 deepCopy() 方法,看下结果是什么:

                        我们看到,在拷贝循环引用的 foo 对象时,发生栈溢出了。

                        在另一篇文章,我提到过使用 JSON-js 可以处理循环引用的问题,具体用法是,先引入其中的 cycle.js 脚本,然后 JSON.stringify(JSON.decycle(foo)) 就 OK 了。但究其根本,它使用了 WeakMap 去处理。

                        那我们去实现一下:

                        const deepCopy = source => {
                          // 创建一个 WeakMap 对象,记录已拷贝过的对象(本次新增)
                          const weakmap = new WeakMap()
                        
                          // 中间这块不变,省略一万字...
                        
                          // 拷贝(递归思路)
                          const copy = input => {
                            if (typeof input === 'function' || !isObject(input)) return input
                        
                            // 针对已拷贝过的对象,直接返回(本次新增,以解决循环引用的问题)
                            if (weakmap.has(input)) {
                              return weakmap.get(input)
                            }
                        
                            // 处理包装对象
                            if (isWrapperObject(input)) {
                              return handleWrapperObject(input)
                            }
                        
                            const output = isArray(input) ? [] : {}
                        
                            // 记录每次拷贝的对象
                            weakmap.set(input, output)
                        
                            for (let key in input) {
                              if (input.hasOwnProperty(key)) {
                                const value = input[key]
                                output[key] = copy(value)
                              }
                            }
                        
                            // 处理以 Symbol 值作为属性键的属性
                            const symbolArr = Object.getOwnPropertySymbols(input)
                            if (symbolArr.length) {
                              for (let i = 0, len = symbolArr.length; i < len; i++) {
                                if (input.propertyIsEnumerable(symbolArr[i])) {
                                  output[symbolArr[i]] = input[symbolArr[i]]
                                }
                              }
                            }
                        
                            return output
                          }
                        
                          return copy(source)
                        }
                        

                        先看看打印结果,不会像之前一样溢出了。

                        需要注意的是,这里不使用 Map 而是 WeakMap 的原因:

                        首先,Map 的键属于强引用,而 WeakMap 的键则属于弱引用。且 WeakMap 的键必须是对象,WeakMap 的值则是任意的。

                        由于它们的键与值的引用关系,决定了 Map 不能确保其引用的对象不会被垃圾回收器回收的引用。假设我们使用的 Map,那么图中的 foo 对象和我们深拷贝内部的 const map = new Map() 创建的 map 对象一直都是强引用关系,那么在程序结束之前,foo 不会被回收,其占用的内存空间一直不会被释放。

                        相比之下,原生的 WeakMap 持有的是每个键对象的“弱引用”,这意味着在没有其他引用存在时垃圾回收能正确进行。原生 WeakMap 的结构是特殊且有效的,其用于映射的 key 只有在其没有被回收时才是有效的。

                        基本上,如果你要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap。

                        可看 Why WeakMap?

                        我们熟知的 Lodash 库的深拷贝方法,自实现了一个类似 WeakMap 特性的构造函数去处理循环引用的。(详见

                        这里提供另一个思路,也是可以的。

                        const deepCopy = source => {
                          // 其他一样,省略一万字...
                        
                          // 创建一个数组,将每次拷贝的对象放进去
                          const copiedArr = []
                        
                          // 拷贝(递归思路)
                          const copy = input => {
                            if (typeof input === 'function' || !isObject(input)) return input
                        
                            // 循环遍历,若有已拷贝过的对象,则直接放回,以解决循环引用的问题
                            for (let i = 0, len = copiedArr.length; i < len; i++) {
                              if (input === copiedArr[i].key) return copiedArr[i].value
                            }
                        
                            // 处理包装对象
                            if (isWrapperObject(input)) {
                              return handleWrapperObject(input)
                            }
                        
                            const output = isArray(input) ? [] : {}
                        
                            // 记录每一次的对象
                            copiedArr.push({ key: input, value: output })
                        
                            // 后面的流程不变...
                          }
                        
                          return copy(source)
                        }
                        

                        此前实现有个 bug,感谢虾虾米指出,现已更正。

                        请在实现深拷贝之后测试以下示例:

                        const foo = { name: 'Frankie' }
                        foo.bar = foo
                        
                        const cloneObj = deepCopy(foo) // 自实现深拷贝
                        const lodashObj = _.cloneDeep(foo) // Lodash 深拷贝
                        
                        // 打印结果如下,说明是正确的
                        console.log(lodashObj.bar === lodashObj) // true
                        console.log(lodashObj.bar === foo) // false
                        console.log(cloneObj.bar === cloneObj) // true
                        console.log(cloneObj.bar === foo) // false
                        

                        3.7 针对正则表达式的处理

                        正则表达式里面,有两个非常重要的属性:

                        const { source, flags } = /\d/g
                        console.log(source) // "\\d"
                        console.log(flags) // "g"
                        

                        有了以上两个属性,我们就可以使用 new RegExp(pattern, flags) 构造函数去创建一个正则表达式了。

                        const { source, flags } = /\d/g
                        const newRegex = new RegExp(source, flags) // /\d/g
                        

                        但需要注意的是,正则表达式有一个 lastIndex 属性,该属性可读可写,其值为整型,用来指定下一次匹配的起始索引。在设置了 global 或 sticky 标志位的情况下(如 /foo/g/foo/y),JavaScript RegExp 对象是有状态的。他们会将上次成功匹配后的位置记录在 lastIndex 属性中。

                        因此,上述拷贝正则表达式的方式是有缺陷的。看示例:

                        const re1 = /foo*/g
                        const str = 'table football, foosball'
                        let arr
                        
                        while ((arr = re1.exec(str)) !== null) {
                          console.log(`Found ${arr[0]}. Next starts at ${re1.lastIndex}.`)
                        }
                        
                        // 以上语句会输出,以下结果:
                        // "Found foo. Next starts at 9."
                        // "Found foo. Next starts at 19."
                        
                        
                        // 当我们修改 re1 的 lastIndex 属性时,输出以下结果:
                        re1.lastIndex = 9
                        while ((arr = re1.exec(str)) !== null) {
                          console.log(`Found ${arr[0]}. Next starts at ${re1.lastIndex}.`)
                        }
                        // "Found foo. Next starts at 19."
                        
                        // 以上这些相信你们都都懂。
                        

                        所以,你可以发现以下示例,打印结果是不一致的,原因就是使用 RegExp 构造函数去创建一个正则表达式时,lastIndex 会默认设为 0

                        const re1 = /foo*/g
                        const str = 'table football, foosball'
                        let arr
                        
                        // 修改 lastIndex 属性
                        re1.lastIndex = 9
                        
                        // 基于 re1 拷贝一个正则表达式
                        const re2 = new RegExp(re1.source, re1.flags)
                        
                        console.log('re1:')
                        while ((arr = re1.exec(str)) !== null) {
                          console.log(`Found ${arr[0]}. Next starts at ${re1.lastIndex}.`)
                        }
                        
                        console.log('re2:')
                        while ((arr = re2.exec(str)) !== null) {
                          console.log(`Found ${arr[0]}. Next starts at ${re2.lastIndex}.`)
                        }
                        
                        // re1:
                        // expected output: "Found foo. Next starts at 19."
                        // re2:
                        // expected output: "Found foo. Next starts at 9."
                        // expected output: "Found foo. Next starts at 19."
                        

                        因此:

                        const deepCopy = source => {
                          // 其他不变,省略...
                        
                          // 处理正则表达式
                          const handleRegExp = regex => {
                            const { source, flags, lastIndex } = regex
                            const re = new RegExp(source, flags)
                            re.lastIndex = lastIndex
                            return re
                          }
                        
                          // 拷贝(递归思路)
                          const copy = input => {
                            if (typeof input === 'function' || !isObject(input)) return input
                        
                            // 正则表达式
                            if (getClass(input) === '[object RegExp]') {
                              return handleRegExp(input)
                            }
                        
                            // 后面不变,省略...
                          }
                        
                          return copy(source)
                        }
                        

                        打印结果也是符合预期的:

                        由于 RegExp.prototype.flags 是 ES6 新增属性,我们可以看下 ES5 是如何实现的(源自 Lodash):

                        /** Used to match `RegExp` flags from their coerced string values. */
                        var reFlags = /\w*$/;
                        
                        /**
                         * Creates a clone of `regexp`.
                         *
                         * @private
                         * @param {Object} regexp The regexp to clone.
                         * @returns {Object} Returns the cloned regexp.
                         */
                        function cloneRegExp(regexp) {
                          var result = new regexp.constructor(regexp.source, reFlags.exec(regexp));
                          result.lastIndex = regexp.lastIndex;
                          return result;
                        }
                        

                        但还是那句话,都 2021 年了,兼容 ES5 的问题就放心交给 Babel 吧。

                        3.8 处理原型

                        注意,这里只实现类型为 "[object Object]" 的对象的原型拷贝。例如数组等不处理,因为这些情况实际场景太少了。

                        主要是修改以下这一步骤:

                        const output = isArray(input) ? [] : {}
                        

                        主要利用 Object.create() 来创建 output 对象,改成这样:

                        const initCloneObject = obj => {
                          // 处理基于 Object.create(null) 或 Object.create(Object.prototype.__proto__) 的实例对象
                          // 其中 Object.prototype.__proto__ 就是站在原型顶端的男人
                          // 但我留意到 Lodash 库的 clone 方法对以上两种情况是不处理的
                          if (obj.constructor === undefined) {
                            return Object.create(null)
                          }
                        
                          // 处理自定义构造函数的实例对象
                          if (typeof obj.constructor === 'function' && (obj !== obj.constructor || obj !== Object.prototype)) {
                            const proto = Object.getPrototypeOf(obj)
                            return Object.create(proto)
                          }
                        
                          return {}
                        }
                        
                        const output = isArray(input) ? [] : initCloneObject(input)
                        

                        来看下打印结果,可以看到 source 的原型对象已经拷贝过来了:

                        再来看下 Object.create(null) 的情况,也是预期结果。

                        我们可以看到 Lodash 的 _.cloneDeep(Object.create(null)) 深拷贝方法并没有处理这种情况。当然了,要拷贝这种数据结构在实际应用场景,真的少之又少...

                        关于 Lodash 拷贝方法为什么不实现这种情况,我找到了一个相关的 Issue lodash #588

                        A shallow clone won't do that as it's just _.assign({}, object) and a deep clone is loosely based on the structured cloning algorithm and doesn't attempt to clone inheritance or lack thereof.

                        四、优化

                        综上所述,完整但未优化的深拷贝方法如下:

                        const deepCopy = source => {
                          // 创建一个 WeakMap 对象,记录已拷贝过的对象
                          const weakmap = new WeakMap()
                        
                          // 获取数据类型
                          const getClass = x => Object.prototype.toString.call(x)
                        
                          // 判断是否为数组
                          const isArray = arr => getClass(arr) === '[object Array]'
                        
                          // 判断是否为引用类型
                          const isObject = obj => obj !== null && (typeof obj === 'object' || typeof obj === 'function')
                        
                          // 判断是否为包装对象
                          const isWrapperObject = obj => {
                            const theClass = getClass(obj)
                            const type = /^\[object (.*)\]$/.exec(theClass)[1]
                            return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt', 'Date', 'Map', 'Set'].includes(type)
                          }
                        
                          // 处理包装对象
                          const handleWrapperObject = obj => {
                            const type = getClass(obj)
                            switch (type) {
                              case '[object Boolean]':
                                return Object(Boolean.prototype.valueOf.call(obj))
                              case '[object Number]':
                                return Object(Number.prototype.valueOf.call(obj))
                              case '[object String]':
                                return Object(String.prototype.valueOf.call(obj))
                              case '[object Symbol]':
                                return Object(Symbol.prototype.valueOf.call(obj))
                              case '[object BigInt]':
                                return Object(BigInt.prototype.valueOf.call(obj))
                              case '[object Date]':
                                return new Date(obj.valueOf()) // new Date(+obj)
                              case '[object Map]': {
                                const map = new Map()
                                obj.forEach((item, key) => {
                                  map.set(key, copy(item))
                                })
                                return map
                              }
                              case '[object Set]': {
                                const set = new Set()
                                obj.forEach(item => {
                                  set.add(copy(item))
                                })
                                return set
                              }
                              default:
                                return undefined
                            }
                          }
                        
                          // 处理正则表达式
                          const handleRegExp = regex => {
                            const { source, flags, lastIndex } = regex
                            const re = new RegExp(source, flags)
                            re.lastIndex = lastIndex
                            return re
                          }
                        
                          const initCloneObject = obj => {
                            if (obj.constructor === undefined) {
                              return Object.create(null)
                            }
                        
                            if (typeof obj.constructor === 'function' && (obj !== obj.constructor || obj !== Object.prototype)) {
                              const proto = Object.getPrototypeOf(obj)
                              return Object.create(proto)
                            }
                        
                            return {}
                          }
                        
                          // 拷贝(递归思路)
                          const copy = input => {
                            if (typeof input === 'function' || !isObject(input)) return input
                        
                            // 正则表达式
                            if (getClass(input) === '[object RegExp]') {
                              return handleRegExp(input)
                            }
                        
                            // 针对已拷贝过的对象,直接返回(解决循环引用的问题)
                            if (weakmap.has(input)) {
                              return weakmap.get(input)
                            }
                        
                            // 处理包装对象
                            if (isWrapperObject(input)) {
                              return handleWrapperObject(input)
                            }
                        
                            const output = isArray(input) ? [] : initCloneObject(input)
                        
                            // 记录每次拷贝的对象
                            weakmap.set(input, output)
                        
                            for (let key in input) {
                              if (input.hasOwnProperty(key)) {
                                const value = input[key]
                                output[key] = copy(value)
                              }
                            }
                        
                            // 处理以 Symbol 值作为属性键的属性
                            const symbolArr = Object.getOwnPropertySymbols(input)
                            if (symbolArr.length) {
                              for (let i = 0, len = symbolArr.length; i < len; i++) {
                                if (input.propertyIsEnumerable(symbolArr[i])) {
                                  const value = input[symbolArr[i]]
                                  output[symbolArr[i]] = copy(value)
                                }
                              }
                            }
                        
                            return output
                          }
                        
                          return copy(source)
                        }
                        

                        接下来就是优化工作了...

                        4.1 优化一

                        我们上面使用到了 for...inObject.getOwnPropertySymbols() 方法去遍历对象的属性(包括字符串属性和 Symbol 属性),还涉及了可枚举属性和不可枚举属性。

                        • for...in:遍历自身继承过来可枚举属性(不包括 Symbol 属性)。
                        • Object.keys:返回一个数组,包含对象自身所有可枚举属性(不包括不可枚举属性和 Symbol 属性)
                        • Object.getOwnPropertyNames:返回一个数组,包含对象自身的属性(包括不可枚举属性,但不包括 Symbol 属性)
                        • Object.getOwnPropertySymbols:返回一个数组,包含对象自身的所有 Symbol 属性(包括可枚举和不可枚举属性)
                        • Reflect.ownKeys:返回一个数组,包含自身所有的属性(包括 Symbol 属性,不可枚举属性以及可枚举属性)

                        由于我们仅拷贝可枚举的字符串属性和可枚举的 Symbol 属性,因此我们将 Reflect.ownKeys()Object.prototype.propertyIsEnumerable() 结合使用即可。

                        所以,我们将以下这部分:

                        for (let key in input) {
                          if (input.hasOwnProperty(key)) {
                            const value = input[key]
                            output[key] = copy(value)
                          }
                        }
                        
                        // 处理以 Symbol 值作为属性键的属性
                        const symbolArr = Object.getOwnPropertySymbols(input)
                        if (symbolArr.length) {
                          for (let i = 0, len = symbolArr.length; i < len; i++) {
                            if (input.propertyIsEnumerable(symbolArr[i])) {
                              const value = input[symbolArr[i]]
                              output[symbolArr[i]] = copy(value)
                            }
                          }
                        }
                        

                        优化成:

                        // 仅遍历对象自身可枚举的属性(包括字符串属性和 Symbol 属性)
                        Reflect.ownKeys(input).forEach(key => {
                          if (input.propertyIsEnumerable(key)) {
                            output[key] = copy(input[key])
                          }
                        })
                        

                        4.2 优化二

                        优化 getClass()isWrapperObject()handleWrapperObject()handleRegExp() 及其相关的类型判断方法。

                        由于 handleWrapperObject() 原意是处理包装对象,但是随着后面要处理的特殊对象越来越多,为了减少文章篇幅,暂时都写在里面了,稍微有点乱。

                        因此下面我们来整合一下,部分处理函数可能会修改函数名。

                        五、最后

                        其实,上面提到的一些边界 Case、或者其他一些特殊对象(如 ArrayBuffer 等),这里并没有处理,但我认为该完结了,因为这些在实际应用场景真的太少了。

                        代码已放在 GitHub 👉 tofrankie/utils

                        还是那句话:

                        [!IMPORTANT] 如果生产环境使用 JSON.stringify() 无法解决你的需求,请使用 Lodash 库的 _.cloneDeep() 方法,那个才叫面面俱到。千万别用我这方法,切记!

                        这篇文章主要面向学习、面试(手动狗头),或许也可以帮助你熟悉一些对象的特性。如有不足,欢迎指出,万分感谢 👋 ~

                        终于终于终于......要写完了,吐了三斤血...

                        最终版本如下:

                        const deepCopy = source => {
                          // 创建一个 WeakMap 对象,记录已拷贝过的对象
                          const weakmap = new WeakMap()
                        
                          // 获取数据类型,返回值如:"Object"、"Array"、"Symbol" 等
                          const getClass = x => {
                            const type = Object.prototype.toString.call(x)
                            return /^\[object (.*)\]$/.exec(type)[1]
                          }
                        
                          // 判断是否为数组
                          const isArray = arr => getClass(arr) === 'Array'
                        
                          // 判断是否为引用类型
                          const isObject = obj => obj !== null && (typeof obj === 'object' || typeof obj === 'function')
                        
                          // 判断是否为“特殊”对象(需要特殊处理)
                          const isSepcialObject = obj => {
                            const type = getClass(obj)
                            return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt', 'Date', 'Map', 'Set', 'RegExp'].includes(type)
                          }
                        
                          // 处理特殊对象
                          const handleSepcialObject = obj => {
                            const type = getClass(obj)
                            const Ctor = obj.constructor // 对象的构造函数
                            const primitiveValue = obj.valueOf() // 获取对象的原始值
                        
                            switch (type) {
                              case 'Boolean':
                              case 'Number':
                              case 'String':
                              case 'Symbol':
                              case 'BigInt':
                                // 处理包装对象 Wrapper Object
                                return Object(primitiveValue)
                              case 'Date':
                                return new Ctor(primitiveValue) // new Date(+obj)
                              case 'RegExp': {
                                const { source, flags, lastIndex } = obj
                                const re = new RegExp(source, flags)
                                re.lastIndex = lastIndex
                                return re
                              }
                              case 'Map': {
                                const map = new Ctor()
                                obj.forEach((item, key) => {
                                  // 注意,即使 Map 对象的 key 为引用类型,这里也不能 copy(key),否则会失去引用,导致该属性无法访问得到。
                                  map.set(key, copy(item))
                                })
                                return map
                              }
                              case 'Set': {
                                const set = new Ctor()
                                obj.forEach(item => {
                                  set.add(copy(item))
                                })
                                return set
                              }
                              default:
                                return undefined
                            }
                          }
                        
                          // 创建输出对象(原型拷贝关键就在这一步)
                          const initCloneObject = obj => {
                            if (obj.constructor === undefined) {
                              return Object.create(null)
                            }
                        
                            if (typeof obj.constructor === 'function' && (obj !== obj.constructor || obj !== Object.prototype)) {
                              const proto = Object.getPrototypeOf(obj)
                              return Object.create(proto)
                            }
                        
                            return {}
                          }
                        
                          // 拷贝方法(递归思路)
                          const copy = input => {
                            if (typeof input === 'function' || !isObject(input)) return input
                        
                            // 针对已拷贝过的对象,直接返回(解决循环引用的问题)
                            if (weakmap.has(input)) {
                              return weakmap.get(input)
                            }
                        
                            // 处理包装对象
                            if (isSepcialObject(input)) {
                              return handleSepcialObject(input)
                            }
                        
                            // 创建输出对象
                            const output = isArray(input) ? [] : initCloneObject(input)
                        
                            // 记录每次拷贝的对象
                            weakmap.set(input, output)
                        
                            // 仅遍历对象自身可枚举的属性(包括字符串属性和 Symbol 属性)
                            Reflect.ownKeys(input).forEach(key => {
                              if (input.propertyIsEnumerable(key)) {
                                output[key] = copy(input[key])
                              }
                            })
                        
                            return output
                          }
                        
                          return copy(source)
                        }
                        

                        六、参考链接

                        ]]>
                        <![CDATA[浏览器内核及渲染过程杂谈]]> https://github.com/tofrankie/blog/issues/245 https://github.com/tofrankie/blog/issues/245 Sun, 26 Feb 2023 11:26:16 GMT 配图源自 Freepik

                        网页依赖于浏览器这个宿主环境,那么它是如何渲染,怎样显示在屏幕上的呢?

                        ]]>
                        配图源自 Freepik

                        网页依赖于浏览器这个宿主环境,那么它是如何渲染,怎样显示在屏幕上的呢?

                        一、浏览器内核

                        浏览器主要由用户界面、浏览器内核、数据存储、网络等组成。而浏览器内核分为两部分:渲染引擎(Rendering Engine)和 JS 引擎(JavaScript Engine)。

                        渲染引擎常被称为“浏览器内核”,主要功能是解析 HTML、CSS 进行页面渲染,渲染引擎决定了浏览器如何显示网页的内容以及页面的格式信息。渲染引擎也被称为排版引擎(Layout Engine)、浏览器引擎(Browser Engine)。而 JS 引擎负责 JavaScript 脚本的解析、编译和执行。

                        早期内核的概念并没有明确区分渲染引擎和 JS 引擎,现在 JS 引擎已经独立出来,而我们常说的浏览器内核通常指的是渲染引擎。我们知道 JavaScript 是 ECMAScript 标准的一种实现,JavaScript 在浏览器端实现还必须包括 DOM 和 BOM。

                        1.1 浏览器内核

                        下面列举一些常见的浏览器引擎

                        • Chrome:早期使用 WebKit 引擎,自 2013 年起使用 Blink 引擎。Blink 派生自 WebKit 的一个分支。注意:iOS 版本的 Chrome 使用了 iOS 平台的渲染引擎,而非 Blink。

                        • FireFox:使用 Gecko 内核。

                        • Opera:早期采用 ElektraPresto 引擎,自 2013 年起使用 Blink 引擎。

                        • Safari:采用 WebKit 引擎。WebKit 早期源自 KHTML 引擎。

                        • IE:采用 Trident 引擎,又称为 MSHTML。

                        • Edge:自 2015 年 1 月 25 日发布 Microsoft Edge 浏览器,其内核使用 EdgeHTML 引擎(EdgeHTML 是 Trident 的一个分支)。从 2020 年 1 月 15 日发布基于 Chromium 的 Edge 浏览器,使用 Blink 引擎。

                        ▲ Timeline

                        苹果要求其平台下浏览器必须使用 WebKit 渲染引擎,所以一些浏览器在不同平台,其内核是不一样的。(查看当前浏览器内核:浏览器内核版本检测)。

                        1.2 JS 引擎

                        一些常见的 JS 引擎

                        • Chrome:采用 V8 引擎。V8 引擎除了在 Google Chrome、基于 Chromium 的浏览器中使用,也被使用于服务端,如 Node.js 等。

                        • FireFox:各版本所采用引擎的情况:SpiderMonkey(1.0 ~ 3.0)、TraceMonkey(3.5 ~ 3.6)、JägerMonkey(4.0 ~)。

                        • Opera:各版本所采用引擎的情况:Opera Linear A(4.0 ~ 6.1)、Linear B(7.0 ~ 9.2)、Futhark(9.5 ~ 10.2)、Carakan(10.5 ~)

                        • Safari:采用 JavaScriptCore 引擎。

                        • IE:在 IE3 ~ IE8 采用 JScript 引擎,IE9 ~ IE11 使用 Chakra 引擎。

                        • Edge:采用 Chakra 引擎,而基于 Chromium 的 Edge 浏览器则使用 V8 引擎。

                        1.3 浏览器引擎前缀

                        浏览器厂商们有时会给实验性的或者非标准的 CSS 属性和 JavaScript API 添加前缀,这样开发者就可以用这些新的特性进行试验。

                        CSS 前缀:

                        • -webkit-(Chrome、Safari、新版 Opera 浏览器,以及几乎所有 iOS 系统中的浏览器(包括 iOS 系统中的火狐浏览器);基本上所有基于 WebKit 内核的浏览器)。
                        • -moz-(Firefox 浏览器)
                        • -o-(旧版 Opera 浏览器)
                        • -ms-(IE 浏览器和 旧版 Edge 浏览器)
                        div {
                          -webkit-transition: all 4s ease;
                          -moz-transition: all 4s ease;
                          -ms-transition: all 4s ease;
                          -o-transition: all 4s ease;
                          transition: all 4s ease;
                        }
                        

                        API 前缀:

                        接口前缀,需要大写的前缀修饰接口名:

                        • WebKit(Chrome、Safari、新版 Opera 浏览器,以及几乎所有 iOS 系统中的浏览器(包括 iOS 系统中的火狐浏览器);基本上所有基于 WebKit 内核的浏览器)。
                        • MOZ(Firefox 浏览器)
                        • O(旧版 Opera 浏览器)
                        • MS(IE 浏览器和 旧版 Edge 浏览器)

                        属性和方法前缀,需要使用小写的前缀修饰属性或者方法:

                        • webkit(Chrome、Safari、新版 Opera 浏览器,以及几乎所有 iOS 系统中的浏览器(包括 iOS 系统中的火狐浏览器);基本上所有基于 WebKit 内核的浏览器)。
                        • moz(Firefox 浏览器)
                        • o(旧版 Opera 浏览器)
                        • ms(IE 浏览器和 旧版 Edge 浏览器)
                        const requestAnimationFrame = window.requestAnimationFrame ||
                                                      window.mozRequestAnimationFrame ||
                                                      window.webkitRequestAnimationFrame ||
                                                      window.oRequestAnimationFrame ||
                                                      window.msRequestAnimationFrame
                        

                        二、网页生成过程

                        首先,渲染引擎从网络层请求到 HTML 文档,然后进行如下所示的基本流程:

                        ▲ Rendering Engine Basic Flow

                        以 WebKit 为例,主流程如下:

                        ▲ WebKit Main Flow

                        至于 Mozilla 的 Gecko,整体流程跟 WebKit 基本相同,术语稍有不同。例如 WebKit 的 Layout(布局),在 Gecko 上称为 Reflow(重排)。由于本文并非两者的差异,因此不展开赘述,详情看 Introduction to Layout in Mozilla Overview

                        大致过程:

                        1. 渲染引擎开始解析 HTML 文档,构建出 DOM Tree
                        2. 同时解析外部 CSS 文件以及样式元素中的样式数据,构建出 CSSOM Tree,即上图的 Style Rules。
                        3. 结合 DOM TreeCSSOM Tree,生成一个 Render Tree(渲染树)。
                        4. 接着进入 Layout (布局)处理阶段,即将 Render Tree 的所有节点分配一个应出现在屏幕上的确切坐标。
                        5. 下一个是 Painting (绘制)阶段,渲染引擎会遍历 Render Tree 将每个节点绘制出来。

                        需要着重指出的是,这是一个渐进的过程。为了达到更好的用户体验,渲染引擎会力求尽快将内容显示在屏幕上。它不必等到整个 HTML 文档解析完毕之后,就会开始构建 Render Tree 和设置 Layout。在不断接收和处理来自网络的其余内容的同时,渲染引擎会将部分内容解析并显示出来。

                        其中 DOM 是 Document Object Model 的简写,而 CSSOM 则是 CSS Object Model 的简写。

                        需要注意的是:

                        Render TreeDOM Tree 是相对应的,但并不是一一对应的。非可视化的元素不会插入 Render Tree 中,如 HTML 的 head 元素。如果元素的 displaynone,那么也不会显示在 Render Tree 中,但是 visibilityhidden 的元素仍会显示。

                        三、解析

                        解析是渲染引擎中非常重要的一个环节。

                        3.1 什么是解析?

                        解析文档是指将文档转化为有意义的结构,也就是可让代码理解和使用的结构。解析得到的结果通常是代表了文档结构的节点树,它被称作“解析树”或“语法树”。

                        ▲ From source document to parse tree

                        解析分为两个过程:词法分析(Lexical Analysis)和语法分析(Syntax Analysis)。

                        词法分析是将输入内容分割成大量有效的标记(token)的过程。token 是语言词汇,是组成内容最小的元素。语法分析是指应用语言的语法规则的过程。

                        解析器是这样工作的:词法分析器(Lexer)负责将输入内容分割成一个个有效的 token;而解析器(Parser)负责根据语言的语法规则分析文档结构,从而构建出解析树(Parse Tree)。词法分析器知道如何将无关的字符(如空格、换行符等)分离出来。

                        举个例子,我们在 AST Explorer 网站将以下这段 HTML 文档解析成“树”结构。

                        <!DOCTYPE html>
                        <html>
                          <body>
                            <h1>Hello,</h1>
                            <p id="name">I'm Frankie.</p>
                          </body>
                        </html>
                        

                        假设我们要访问 <p> 标签的 id 属性值,如果不将其先解析为“树”的话,应该会想到使用正则表达式去匹配源文档,若需要获取的属性各种各样,显然正则表达式会很麻烦。但现在,我们可以通过类似 document.html.body.p.attrs.id 的形式来获取其值。

                        如果你对浏览器如何将 HTML 生成“抽象语法树”(AST),可以看下这个库:parse5

                        3.2 翻译

                        很多时候,语法树(解析树)并不是最终的产品。解析通常在翻译过程中使用的,而翻译是指将输入文档转换成另一种格式。编译就是这样一个例子。编译器可以将源代码(source code)编译成机器代码(machine code)。

                        ▲ Compilation flow

                        四、处理脚本和样式表的顺序

                        4.1 脚本

                        网络的模型是同步的。网页作者希望解析器遇到 <script> 标记时立即解析并执行脚本。文档的解析将停止,直到脚本执行完毕。如果脚本是外部的,那么解析过程会停止,直到从网络同步抓取资源完成后再继续。此模型已经使用了多年,也在 HTML4 和 HTML5 规范中进行了指定。作者也可以将脚本标注为 defer,这样它就不会停止文档解析,而是等到解析结束才执行。HTML5 增加了一个选项,可将脚本标记为异步(async),以便由其他线程解析和执行。

                        4.2 预解析

                        WebKit 和 Gecko 都进行了这项优化。在执行脚本时,其他线程会解析文档的其余部分,找出并加载需要通过网络加载的其他资源。通过这种方式,资源可以子在并行连接上加载,从而提高总体速度。

                        需要注意的是,预解析器不会修改 DOM Tree,而是将这项工作交由主解析器处理。预解析器只会解析外部资源(例如外部脚本、样式表和图片)的引用。

                        未完待续...

                        ]]>
                        <![CDATA[JavaScript 编程风格(书写习惯)]]> https://github.com/tofrankie/blog/issues/244 https://github.com/tofrankie/blog/issues/244 Sun, 26 Feb 2023 11:20:42 GMT 一门编程语言的“语法规则”(grammar)应该是每一位开发者都必须遵循的;而“编程风格”则可自由选择。

                        假设团队中有 100 位成员,然后每位成员的 coding 风格都不一样,而且团队中没有明确的编写规范,那 Code Review 的时候,不得打起来,或者内心口吐芬芳......所以,社区中出]]> 一门编程语言的“语法规则”(grammar)应该是每一位开发者都必须遵循的;而“编程风格”则可自由选择。

                        假设团队中有 100 位成员,然后每位成员的 coding 风格都不一样,而且团队中没有明确的编写规范,那 Code Review 的时候,不得打起来,或者内心口吐芬芳......所以,社区中出现了很多 Lint 工具来帮助团队去规范书写习惯,如 ESLintPrettier 等等...

                        “编程风格”的选择不应该基于个人爱好、熟悉程度、打字工作量等因素,而要考虑如何尽量使代码清晰易读、减少出错。你选择的,不是你喜欢的风格,而是一种能够清晰表达你的意图的风格。

                        推荐 JavaScript 权威 Douglas Crockford 的一篇演讲

                        一、大括号位置

                        规则 1:表示区块起首的大括号,不要另起一行。

                        绝大多数的编程语言,都用大括号 {} 表示区块(block),JavaScript 也一样。

                        常见的写法有以下两种:

                        // bad
                        block
                        {
                          // ...
                        }
                        
                        // good
                        block {
                          // ...
                        }
                        

                        由于 JavaScript 的 自动插入分号(Automatic Semicolon Insertion,简称 ASI)机制,导致一些难以察觉的错误。

                        因此,原则上更推荐后者的写法:起首的大括号跟在关键字的后面

                        例如:

                        return
                        {
                          key: 'value'
                        }
                        

                        上面的代码原意是要返回一个对象,但实际上返回值为 undefined。因为 ASI 的原因,JavaScript 解析器会在return 后面插入分号 ;,由于分号是终止符,解析器就认为这个语句结束了。

                        类似 return 的特定语句,在 ASI 中被称为 restricted production。它包括以下几个语法,均不能换行:

                        • 后缀表达式 ++--
                        • return
                        • continue
                        • break
                        • throw
                        • ES6 的箭头函数(参数和箭头之间不能换行)
                        • yield

                        这些无需死记,因为按照一般的书写习惯,几乎没有人会这样换行的。如果有的话,打 si 它。

                        二、圆括号的位置

                        圆括号(parentheses)在 JavaScript 中有两种作用,一种是表示调用函数,另一种是表示不同的值的组合(grouping)。我们可以用空格区分两种不同的圆括号。

                        规则 2:调用函数的时候,函数名与左括号之间没有空格。

                        // bad
                        foo (bar)
                        
                        // good
                        foo(bar)
                        

                        规则 3:函数名与参数序列之间,没有空格。

                        // bad
                        function fn (foo) {
                          // ...
                        }
                        
                        // good
                        function fn(foo) {
                          // ...
                        }
                        

                        规则 4:所有其他语法元素与左括号之间,都有一个空格。

                        // bad
                        return(foo + bar)
                        
                        // good
                        return (foo + bar)
                        

                        上面这个示例,其实最好去掉圆括号,其实在这句语句里面它是不需要圆括号的。这里只是为了举例而已。

                        相信很多人对以上三条建议,不屑一顾。2021 年了,那就交给 Prettier 处理吧。

                        三、不要省略分号

                        规则 5:不要省略句末的分号。

                        其实我是偏向 semicolon-less 风格,我认为通篇少了 99% 的分号,看起来会简洁很多。

                        之前写个一篇文章是关于自动插入分号的:JavaScript ASI 机制详解

                        但是这时候,有人就会举出类似示例,来反对“无分号党”了:

                        a = b
                        (function () {
                          // ...
                        })
                        

                        由于 ASI 机制,JavaScript 解析器看到的会是这这样子:

                        a = b(function () {
                          // ...
                        });
                        

                        是的,这样就得不到预期的结果。但是作为合格的 semicolon-less 风格的码农(俗称 coder),如果一条语句是以 ([/+- 开头,我们会在该语句的行首主动键入分号来避免分号 ; 来避免 ASI 机制产生的非逾期结果或报错。

                        在实际项目中,以 /+- 作为行首的代码其实是很少的,([ 也是较少的。

                        **如果你说这很容易忘记键入分号,那说明你不是合格的“无分号党”,那还是乖乖敲分号吧!**或许多数人更容易忘记,所以才有了“不要省略句末分号”的推荐写法吧。(算是少数服从多数的妥协吧)

                        对于不加思考而一味否定 semicolon-less 的人,我还是给出尤大那句话:所有直觉性的 “当然应该加分号” 都是保守的、未经深入思考的草率结论。

                        四、with 语句

                        规则 6:不要使用 with 语句。

                        with (obj) {
                          foo = bar
                        }
                        

                        上面的代码,可能会产生四种运行结果:

                        obj.foo = bar
                        
                        obj.foo = obj.bar
                        
                        foo = bar
                        
                        foo = obj.bar
                        

                        以上四种结果都可能发生,取决于不同的变量是否有定义。因此,建议不要使用 with 语句。

                        五、相等与严格相等

                        规则 7:尽可能不要使用相等运算符(==),而使用全等运算符(===)。

                        此前针对相等运算符与全等运算符,写了一篇文章:JavaScript 相等比较详解,文章开头就有如下这句话:

                        Always use 3 equals unless you have a good reason to use 2.

                        主要原因是,使用相等运算符对两个不同类型的操作数进行比较时,JavaScript 内部会“偷偷”进行了类型转换。

                        0 == '' // true
                        2 == true // false
                        0 == '0' // true
                        false == '0' // true
                        ' \t\r\n ' == 0 // true
                        

                        六、语句的合并

                        有些开发者追求“过分”简洁,喜欢合并不同目的的语句。

                        例如,原语句是:

                        a = b
                        if (a) { ... }
                        

                        他喜欢写成下面这样:

                        if (a = b) { ... }
                        

                        虽然语句少了一行,但是可读性大打折扣,而且会造成误读,让别人误以为这行代码的意思是:

                        if (a === b) { ... }
                        

                        另外一种情况是,有些开发者喜欢在同一行中赋值多个变量:

                        // 非严格模式
                        var a = b = 0
                        

                        它可能认为,上面的代码相当于:

                        var a = 0, b = 0
                        

                        但实际上不是,它的真正效果是下面这样:

                        // 这里指非严格模式(严格模式下会报错,不允许这种默认全局变量的写法)
                        b = 0
                        var a = b
                        

                        因此,不建议将不同目的的语句合并成一行。

                        七、变量声明

                        在 JavaScript 中,有一个非常著名的变量提升Hoisting)概念,相信都知道。

                        不管我们实际项目中怎样,作为一个合格或不合格的 Jser,都应该去了解清楚提升是什么鬼,好吧。之前写了一篇文章:深入了解 JavaScript 从预编译到解析执行的过程。(PS:当初写得不好,建议看 MDN 或 ECMAScript 标准)

                        就一句话:

                        先声明,后调用。

                        还有都 2021 年了,尽可能别用 var 了,用 letconst 吧。至于兼容就放心交给 Babel 吧!

                        // bad
                        if (!obj) {
                          var obj = {}
                        }
                        
                        // good
                        var obj
                        if (!obj) {
                           obj = {}
                        }
                        

                        为了避免可能出现的问题,不如把变量声明都放在代码块的头部。

                        // bad
                        for (var i ...) { ... }
                        
                        // good
                        var i
                        for (i ...) { ... }
                        

                        因此,

                        规则 9:所有变量声明都放在函数的头部。

                        规则 10:所有函数都在使用之前定义。

                        八、全局变量

                        Javascript 最大的语法缺点,可能就是全局变量对于任何一个代码块,都是可读可写。这对代码的模块化和重复使用,非常不利。

                        规则 11:避免使用全局变量;如果不得不使用,用大写字母表示变量名,比如 UPPER_CASE

                        九、new 关键字

                        JavaScript 使用 new 关键字,从构造函数生成一个新对象。

                        var obj = new myObject()
                        

                        这种做法的问题是,一段你忘了加上 new 关键字,myObject() 内部的 this 关键字就会指向全局对象(严格模式下 thisundefined),导致所有绑定在 this 上的变量,都变成了全局变量。

                        规则 12:不要使用 new 关键字,改用 Object.create() 来创建新对象。

                        规则 13:构造函数的函数名,采用首字母大写(InitialCap)的书写方式;其他的函数名,一律首字母小写。

                        当然,构造函数名称首字母大写,只是约定俗成的一种书写方式而已,不影响实际运行。像 ESLint 就有一个 new-cap 规则去检查这种写法的。

                        十、自增和自减运算符

                        自增 ++ 和自减 -- 运算符,放在操作数的前面或后面,返回值是不一样的,很容易发生错误。

                        说实话,我到现在还经常记不太清这俩玩意。有时候遇到,还特地去查阅一番以确认是否预期运行。

                        所以我也是使用 += 1-= 1 去替代它的。ESLint 也有一个规则 no-plusplus 去限制这种写法。

                        //  bad
                        ++i
                        
                        // good
                        i += 1
                        

                        听说某 JS 库源码出现了以下代码片段:

                        ++i;
                        ++i;
                        
                        // 怎么看,更合理的写法应该是:
                        i += 2
                        

                        因此,

                        规则 14:不要使用自增(++)和自减(--),使用 +=-= 代替。

                        其实我的习惯是,仅允许 forfinal-expression 使用自增或自减,其他的不允许,所以 ESLint 规则可以这样写。

                        /* eslint no-plusplus: ["error", { "allowForLoopAfterthoughts": true }] */
                        for (let i = 0; i < 10; i++) {
                          // 上面 final-expression 允许自增、自减,但循环体本身不允许
                        }
                        

                        详见:no-plusplus

                        十一、总结

                        综上所述,总结成一句话:爱咋咋地

                        都 2021 年了,项目都继承 ESLint、Prettier 了吧,那上面的很多毛病,它们基本上都给你处理了。但我们应该要了解下背后的原因,对吧。

                        对于有强迫症的我,还要继承 CSScomb,去对 CSS 进行排序。示例:tofrankie/csscomb-mini

                        十二、参考链接

                        ]]>
                        <![CDATA[CORS 详解,终于不用担心跨域问题了]]> https://github.com/tofrankie/blog/issues/243 https://github.com/tofrankie/blog/issues/243 Sun, 26 Feb 2023 11:19:00 GMT CORS 是一个W3C标准,全称是跨域资源共享(Cross-Origin Resource Sharing),也有译为“跨源资源共享”的。 CORS 是一个W3C标准,全称是跨域资源共享(Cross-Origin Resource Sharing),也有译为“跨源资源共享”的。

                        它允许浏览器向跨源服务器,发出 XMLHttpRequest(XHR) 或 Fetch API 跨域 HTTP 请求,从而克服了同源使用的限制。

                        本文内容主要参考于跨域资源共享 CORS 详解MDN 相关文档

                        一、简介

                        CORS 是 HTTP 的一部分,它允许服务端来指定哪些主机可以从这个服务端加载资源。

                        CORS 需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE 浏览器不能低于 IE10。

                        整个 CORS 通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS 通信与同源的 AJAX 通信没有差别,代码完全一样。浏览器一旦发现 AJAX 请求跨源,就会自动添加一些附件的头信息,有时还会多处一次附件的请求,但用户不会有感觉。

                        因此,实现 CORS 通信的关键是服务器。只要服务器实现了 CORS 接口(响应报文包括了正确的 CORS 响应头),就可以跨源通信。

                        二、同源安全策略

                        同源策略是一个重要的安全策略,它用于限制一个 Origin 的文档或它加载的脚本如何能与另一个源的资源进行交互。它能帮助阻隔恶意文档,减少可能被攻击的媒介。

                        如果两个 URL 的协议(Protocol)、主机(Host)、端口(Port,如果有指定的话)都相同的话,那么这两个 URL 是同源的,否则是不同源的。

                        下表给出了与 URL http://store.company.com/dir/page.html 的源进行对比的示例:

                        URL 结果 原因
                        http://store.company.com/dir2/other.html 同源 只有路径不同。
                        http://store.company.com/dir/inner/another.html 同源 只有路径不同。
                        https://store.company.com/secure.html 不同源 协议不同。
                        http://store.company.com:81/dir/etc.html 不同源 端口不同(http:// 默认端口是 80)。
                        http://news.company.com/dir/other.html 不同源 主机不同。

                        关于 IE 浏览器的特例,其中差异点是不规范的,其他浏览器未做出支持。

                        出于安全性,浏览器限制脚本内发起的跨域 HTTP 请求,例如常见的 XHR、Fetch API 都遵循同源策略。

                        三、两种请求

                        浏览器将 CORS 请求分成两类:

                        • 简单请求(simple request)
                        • 非简单请求(not-so-simple request)

                        若满足下述所有条件,则为简单请求,否则为非简单请求。

                        • 请求方法是以下三种之一:

                          • HEAD
                          • GET
                          • POST
                        • HTTP 的头信息不超出以下几种字段:

                          • Accept
                          • Accept-Language
                          • Content-Language
                          • Content-Type
                          • DPR
                          • Downlink
                          • Save-Data
                          • Viewport-Width
                          • Width
                        • Content-Type 的值仅限于以下三种之一

                          • text/plain
                          • multipart/form-data
                          • application/x-www-form-urlencoded
                        • 请求中的任意 XMLHttpRequestUpload 对象均没有注册任何事件监听器。

                        • 请求中没有使用 ReadableStream 对象。

                        注意: 这些跨站点请求与浏览器发出的其他跨站点请求并无二致。如果服务器未返回正确的响应首部,则请求方不会收到任何数据。因此,那些不允许跨站点请求的网站无需为这一新的 HTTP 访问控制特性担心。

                        四、简单请求

                        一个简单的 XHR 请求示例:

                        客户端(Client):

                        // Client 客户端(http://192.168.1.105:8080)
                        function xhrRequest() {
                          // 创建 xhr 对象
                          const xhr = new XMLHttpRequest()
                        
                          // 通过 onreadystatechange 事件捕获请求状态的变化
                          // 必须在 open 之前指定该事件,否则无法接收 readyState 0 和 1 的状态
                          xhr.onreadystatechange = () => {
                            console.log(xhr.status)
                            console.log(xhr.readyState)
                          }
                        
                          // 捕获错误
                          xhr.onerror = err => {
                            console.log('error:', err)
                          }
                        
                          // 初始化请求
                          xhr.open('GET', 'http://192.168.1.105:7701/config', true)
                        
                          // 发送请求
                          xhr.send(null)
                        }
                        
                        xhrRequest()
                        

                        服务端(Server):

                        // Server 服务端(通过 node 命令即可启动,http://192.168.1.105:7701)
                        const http = require('http')
                        const port = 7701
                        const allowedOrigin = 'http://192.168.1.105:8080' // 这个是我本地客户端的 Origin(源)
                        
                        const server = http.createServer((request, response) => {
                          if (request.headers.origin === allowedOrigin) {
                            response.setHeader('Access-Control-Allow-Origin', allowedOrigin)
                          }
                          if (request.url === '/config') {
                            response.end(JSON.stringify({ name: 'Frankie', age: 20 }))
                          }
                        })
                        
                        server.listen(port)
                        

                        发起 XHR HTTP 请求,我们可以在控制台看到请求报文和响应报文。(View source)

                        1. 基本流程

                        对于简单请求,浏览器直接发出 CORS 请求。具体来说,就在头信息之中,增加一个 Origin 字段。

                        通过以上示例发起 HTTP GET 请求,请求报文如下:

                        GET /config HTTP/1.1
                        Host: 192.168.1.105:7701
                        Connection: keep-alive
                        User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1
                        Accept: */*
                        Origin: http://192.168.1.105:8080
                        Referer: http://192.168.1.105:8080/
                        Accept-Encoding: gzip, deflate
                        Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7
                        

                        上面的头信息中,Origin 字段用来说明,本次请求来自哪个源(协议 + 主机 + 端口)。服务器根据这个值,绝对是否同意这次请求。

                        如果 Origin 指定的源,不在许可范围内,服务器会返回一个正确的 HTTP 回应。浏览器发现,这个回应的头信息没有包含 Access-Control-Allow-Origin 字段(详见下文),就知道出错了,从而抛出一个错误,被 XMLHttpRequestonerror 回调函数捕获。注意,这种错误无法通过状态识别,因为 HTTP 回应的状态码有可能是 200

                        在上述服务端示例中,我们指定的源就是 http://192.168.1.105:8080,因此可以正常发起请求,并拿到接口响应数据:{"name":"Frankie","age":20}

                        假设,我们将服务端示例许可的 Origin 去掉:

                        // if (request.headers.origin === allowedOrigin) {
                        //  response.setHeader('Access-Control-Allow-Origin', allowedOrigin)
                        // }
                        

                        我们再次发起 /config 请求的时候,可以看到控制台抛出跨域错误了:

                        需要注意的是,尽管无论从控制台看到的报错、还是 Network 选项卡的 Failed to load response data,都可以知道我们的请求失败了。但......其实是这样的:就本文服务端示例而言,即使是跨域请求,也是会返回响应数据的,只是 JavaScript 脚本获取到的结果是“失败”而已。

                        那不对啊,如果服务器正常返回数据,而前端却失败了,那信息不对称啊,中间商赚差价了?怎么回事呢?原因是浏览器在中间搞鬼,那浏览器在中间扮演了什么角色呢?

                        当发起 CORS 请求时,浏览器首先会在请求报文上自动加上 Origin 的字段(它的值由当前页面的 Protocol + Host + Port 部分组成),到达服务端之后,会做出相应的处理并返回数据。由于服务端并没有给响应报文的头部设置 Access-Control-Allow-Origin(前面说把这块注释掉了),自然浏览器接收到的响应报文中就不含 Access-Control-Allow-Origin,当浏览器判断到请求报文与响应报文的 Origin 不相等,此时它不会将服务器的响应数据 JavaScript 脚本,即我们的 XHR 对象无法得到服务器的响应数据,并且会触发 XHR 对象的 onerror 事件,让脚本来捕获错误。所以,我们就看到请求失败了。(这种情况下,可以通过抓包工具查看服务器返回的数据)

                        还有,只有在 Origin 指定的源在许可范围内,服务器响应报文才会多出这些 Access-Control-Allow-OriginAccess-Control-Allow-CredentialsAccess-Control-Expose-Headers 头信息字段。(可从客户端 Network 选项卡观察到)

                        以下与 CORS 请求相关的头信息字段,都以 Access-Control- 开头。

                        Access-Control-Allow-Origin: http://10.16.4.226:8080
                        Access-Control-Allow-Credentials: true
                        Access-Control-Expose-Headers: Date
                        
                        • Access-Control-Allow-Origin

                        这个字段是必须的。它的值要么是请求时 Origin 字段的值,要么是一个 *,表示接受任意的源(域名)的请求。

                        • Access-Control-Allow-Credentials

                        这个字段可选。它的值是一个布尔值,表示是否允许发送 Cookie。默认情况下,Cookie 不包括在 CORS 请求之中。设为 true,即表示服务器明确许可,Cookie 可以包含在请求中,一起发给服务器。这个值也只能设为 true,如果服务器不要浏览器发送 Cookie,删除该字段即可。

                        // Client
                        xhr.withCredentials = true
                        
                        // Server
                        response.setHeader('Access-Control-Allow-Credentials', true)
                        

                        当客户端携带 Cookie 发起请求,同时服务端允许携带 Cookie 的情况下,可以看到请求报文包含了 Cookie 信息:

                        Cookie: username=Frankie
                        
                        • Access-Control-Expose-Headers

                        这个字段可选。CORS 请求时,XMLHttpRequest 对象的 getResponseHeader() 方法只能拿到 6 个基本字段: Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma。如果想要拿到其他字段,就必须在 Access-Control-Expose-Headers 里面指定。(但由于 W3C 的限制,不指定的情况下,客户端获取到的值可能为 null

                        例如,服务端将 Access-Control-Expose-Headers 指定为 "Date,Access-Control-Allow-Origin",我们可以通过 XMLHttpRequest 对象的 getResponseHeader() 方法获取对应字段的值。

                        // Client
                        xhr.onreadystatechange = () => {
                          if (xhr.status === 200 && xhr.readyState === 4) {
                            console.log(xhr.getResponseHeader('Cache-Control')) // null
                            console.log(xhr.getResponseHeader('Content-Language')) // null
                            console.log(xhr.getResponseHeader('Content-Type')) // null
                            console.log(xhr.getResponseHeader('Expires')) // null
                            console.log(xhr.getResponseHeader('Last-Modified')) // null
                            console.log(xhr.getResponseHeader('Pragma')) // null
                            console.log(xhr.getResponseHeader('Date')) // "Sun, 06 Jun 2021 14:34:34 GMT"
                            console.log(xhr.getResponseHeader('Access-Control-Allow-Origin')) // "http://192.168.1.105:8080"
                            console.log(xhr.getResponseHeader('X-Custom-Header')) // Error: Refused to get unsafe header "X-Custom-Header"
                          }
                        }
                        
                        // Server
                        if (request.headers.origin === allowedOrigin) {
                          response.setHeader('Access-Control-Allow-Origin', allowedOrigin)
                          response.setHeader('Access-Control-Allow-Credentials', true)
                          response.setHeader('Access-Control-Expose-Headers', 'Date,Access-Control-Allow-Origin')
                        }
                        

                        如果连接未完成,响应中不存在报文项,或者被 W3C 限制,则返回 null。假设如上示例,自定义头信息 X-Custom-Header 字段,且服务端未对其进行设置,则会抛出错误:Refused to get unsafe header "X-Custom-Header"

                        2. withCredentials 属性

                        上面说到,CORS 请求默认不发送 Cookie 和 HTTP 认证信息。如果要把 Cookie 发送到服务器,一方面要服务器同意,指定 Access-Control-Allow-Credentials 字段。

                        Access-Control-Allow-Credentials: true
                        

                        另一方面,开发者必须在 AJAX 请求中打开 withCredentials 属性。

                        xhr.withCredentials = true
                        

                        否则,即使服务器同意发送 Cookie,浏览器也不会发送。或者服务器要求设置 Cookie,浏览器也不会处理。

                        但是,如果省略 withCredentials 设置,有的浏览器还是会一起发送 Cookie。这时,可以显示关闭 withCredentials

                        xhr.withCredentials = false
                        

                        需要注意的是,如果要发送 Cookie,Access-Control-Allow-Origin 就不能设为 *(否则会抛出如下错误),必须指定明确的、与请求网页一致的域名。同时,Cookie 依然遵循同源策略,只有用服务器域名设置的 Cookie 才会上传,其他域名的 Cookie 并不会上传,且(跨源)原网页代码中的 document.cookie 也无法读取服务器域名下的 Cookie。

                        五、非简单请求

                        调整一下示例:

                        // Client 客户端
                        function xhrRequest() {
                          // 创建 xhr 对象
                          const xhr = new XMLHttpRequest()
                        
                          // 通过 onreadystatechange 事件捕获请求状态的变化
                          xhr.onreadystatechange = () => {
                            console.log(xhr.status)
                            console.log(xhr.readyState)
                        
                            if (xhr.status === 200 && xhr.readyState === 4) {
                              console.log(xhr.getResponseHeader('Date'))
                              console.log(xhr.getResponseHeader('Access-Control-Allow-Origin'))
                            }
                          }
                        
                          // 捕获错误
                          xhr.onerror = err => {
                            console.log('error:', err)
                          }
                        
                          // 初始化请求
                          xhr.open('PUT', 'http://192.168.1.105:7701/config', true)
                        
                            // 设置自定义头部(必须在 open 之后)
                            xhr.setRequestHeader('X-Custom-Header', 'foo')
                        
                          // 发送请求
                          xhr.send(null)
                        }
                        
                        xhrRequest()
                        
                        // Server 服务端
                        const http = require('http')
                        const port = 7701
                        const allowedOrigin = 'http://192.168.1.105:8080' // 这个是我本地客户端的 Origin(源)
                        
                        const server = http.createServer((request, response) => {
                          if (request.headers.origin === allowedOrigin) {
                          response.setHeader('Access-Control-Allow-Origin', allowedOrigin)
                          response.setHeader('Access-Control-Allow-Credentials', true)
                          response.setHeader('Access-Control-Allow-Methods', 'PUT')
                          response.setHeader('Access-Control-Allow-Headers', 'X-Custom-Header')
                          response.setHeader('Access-Control-Expose-Headers', 'Date,Access-Control-Allow-Origin')
                          }
                          if (request.url === '/config') {
                            response.end(JSON.stringify({ name: 'Frankie', age: 20 }))
                          }
                        })
                        
                        server.listen(port)
                        

                        1. 预检请求

                        非简单请求时那种对服务器有特殊要求的请求,比如请求方法是 PUTDELETE,或者 Content-Type 字段的类型是 application/json

                        非简单请求的 CORS 请求,会在正式通信之前,增加一次 HTTP 查询请求,称为预检请求(preflight)。

                        浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些 HTTP 动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的 XMLHttpRequest 请求,否则就报错。

                        上面代码中,HTTP 请求的方法是 PUT,并且发送一个自定义头信息 X-Custom-Header

                        浏览器发现,这是一个非简单请求,就自动发出一个“预检请求”,要求服务器确认可以这样请求。下面是这个“预检请求”的 HTTP 头信息。

                        OPTIONS /config HTTP/1.1
                        Host: 192.168.1.105:7701
                        Connection: keep-alive
                        Accept: */*
                        Access-Control-Request-Method: PUT
                        Access-Control-Request-Headers: x-custom-header
                        Origin: http://192.168.1.105:8080
                        User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1
                        Sec-Fetch-Mode: cors
                        Referer: http://192.168.1.105:8080/
                        Accept-Encoding: gzip, deflate
                        Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7
                        

                        “预检请求”用的请求方法是 OPTIONS,表示这个请求是用来询问的。头信息里面,关键字是 Origin,表示请求来自哪个源。

                        除了 Origin 字段,“预检请求”的头信息包括两个特殊字段。

                        • Access-Control-Request-Method 这个字段是必须的,用来列出浏览器的 CORS 请求会用到哪些 HTTP 方法,上面示例是 PUT。

                        • Access-Control-Request-Headers 这个字段是一个逗号 , 分隔的字符串,指定浏览器 CORS 请求会额外发送的头信息字段,上面示例是 X-Custom-Header

                        Tips:预检请求可以在调试器 Network 选项卡中查看,如下图:

                        2. 预检请求的回应

                        服务器收到“预检请求”以后,检查了 Origin、Access-Control-Request-Method 和 Access-Control-Request-Headers 字段以后,确认允许跨域请求,就可以做出回应。

                        HTTP/1.1 200 OK
                        Access-Control-Allow-Origin: http://192.168.1.105:8080
                        Access-Control-Allow-Credentials: true
                        Access-Control-Allow-Methods: PUT
                        Access-Control-Allow-Headers: X-Custom-Header
                        Access-Control-Expose-Headers: Date,Access-Control-Allow-Origin
                        Date: Sun, 06 Jun 2021 15:27:27 GMT
                        Connection: keep-alive
                        Keep-Alive: timeout=5
                        Content-Length: 27
                        

                        上面的 HTTP 回应中,关键的是 Access-Control-Allow-Origin 字段,表示 http://192.168.1.105:8080 可以请求数据。该字段也可以设为 *,表示同意任意的跨源请求。(本示例携带了 Cookie 信息,不可设为 *

                        Access-Control-Allow-Origin: *
                        

                        如果服务器否定了“预检请求”,会返回一个正常的 HTTP 回应,但是没有任何的 CORS 相关的头信息字段。这是,浏览器就认定,服务器不同意预检请求,因此触发一个错误,被 XMLHttpRequest 对象的 onerror 回调函数捕获。

                        如果将服务端的 Access-Control-Allow-Headers 注释掉:

                        // response.setHeader('Access-Control-Allow-Headers', 'X-Custom-Header')
                        

                        控制台会打印出如下的报错信息:

                        服务器回应的其他 CORS 相关字段如下:

                        Access-Control-Allow-Methods: PUT
                        Access-Control-Allow-Headers: X-Custom-Header
                        Access-Control-Allow-Credentials: true
                        Access-Control-Max-Age: 1728000
                        
                        • Access-Control-Allow-Methods 这个字段是必须的,它的值是逗号 , 分隔的一个字符串,表明服务器支持的所有跨域请求。注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次“预检请求”。(本示例仅设置了一个 PUT 方法。)

                        • Access-Control-Allow-Headers 如果浏览器请求包括 Access-Control-Request-Headers 字段,则 Access-Control-Allow-Headers 字段是必须的,它也是一个逗号 , 分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在“预检请求”的字段。

                        • Access-Control-Allow-Credentials 这个字段与简单请求时的含义相同。

                        • Access-Control-Max-Age 这个字段可选,用来指定本次预检请求的有效期,单位为秒。上面结果中,有效期是 20 天(1728000 秒),即允许缓存该条回应 1728000 秒(即 20 天),在此期间,不用发出另一条预检请求。

                        3. 浏览器的正常请求和回应

                        一旦服务器通过了“预检请求”,以后每次浏览器正常的 CORS 请求,就都跟简单请求一样,会有一个 Origin 头信息字段。服务器的回应,也都会有一个 Access-Control-Allow-Origin 头信息字段。

                        下面是“预检请求”之后,浏览器的正常 CORS 请求的请求报文。

                        PUT /config HTTP/1.1
                        Host: 192.168.1.105:7701
                        Connection: keep-alive
                        Content-Length: 0
                        User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1
                        X-Custom-Header: foo
                        Accept: */*
                        Origin: http://192.168.1.105:8080
                        Referer: http://192.168.1.105:8080/
                        Accept-Encoding: gzip, deflate
                        Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7
                        Cookie: username=Frankie
                        

                        上面的头信息的 Origin 字段是浏览器自动添加的。

                        下面是服务器正常的响应报文。

                        HTTP/1.1 200 OK
                        Access-Control-Allow-Origin: http://192.168.1.105:8080
                        Access-Control-Allow-Credentials: true
                        Access-Control-Allow-Methods: PUT
                        Access-Control-Allow-Headers: X-Custom-Header
                        Access-Control-Expose-Headers: Date,Access-Control-Allow-Origin
                        Date: Sun, 06 Jun 2021 15:40:11 GMT
                        Connection: keep-alive
                        Keep-Alive: timeout=5
                        Content-Length: 27
                        

                        上面的头信息中,Access-Control-Allow-Origin 字段是每次回应都必定包含的。

                        六、与 JSONP 的比较

                        CORS 与 JSONP 的使用目的相同,但是比 JSONP 更强大。

                        JSONP 只支持 GET 请求,CORS 支持所有类型的 HTTP 请求。JSONP 的优势在于支持老式浏览器,以及可以向不支持 CORS 的网站请求数据。

                        七、其他

                        1. Check List

                        如果按以上设置后,仍会出现跨域情况,可以仔细检查:

                        • 请求 URL 是否有拼写错误
                        • 请求 URL 是否有空格(特别是开头结尾)

                        被坑过,错误往往出现在这些不起眼的地方...

                        2. HTTP headers 之 Referer

                        Referer 请求头包含了当前请求页面的来源页面的地址,即表示当前页面是通过此来源页面里的链接进入的。服务端一般使用 Referer 请求头识别访问来源,可能会以此进行统计分析、日志记录以及缓存优化等。

                        注意,如果直接在地址栏输入 URL 访问某网页,这时 Referer 为空。

                        例如,上面示例中请求 /config 接口的网页地址是 http://192.168.1.105:8080/#/about,从请求报文可以大致看到 OriginReferer 的区别。

                        Origin: http://192.168.1.105:8080
                        Referer: http://192.168.1.105:8080/
                        
                        • Origin Origin 的值可以置空或者是 <scheme>://<host> [:<port>],即协议 + 主机 + 端口。其中主机是指域名或者 IP 地址;端口号可选,缺省时为服务的默认端口(例如 HTTP 请求默认端口是 80,HTTPS 请求默认端口是 443

                        • Referer 当前页面被链接而至的前一页面的绝对路径或者相对路径。不包含 URL fragments (例如 "#section") 和 userinfo (例如 "https://username:password@example.com/foo/bar/" 中的 "username:password" )。

                        上面的解释比较官方,换句话说就是从哪个页面链接过来的。例如,我从 GitHub 仓库点击跳转至简书文章,可以从 HTML 的请求报文看到:

                        Host: www.jianshu.com
                        Referer: https://github.com/toFrankie/csscomb-mini
                        

                        假设有一个需求是统计从 GitHub 访问简书的访问量,那么就可以通过 Referer 来实现。也常用于防盗链等。

                        需要注意的是,Referer 的正确英语拼法是 referrer。由于早期 HTTP 规范的拼写错误,为保持向下兼容就将错就错了。例如 DOM Level 2Referrer Policy 等其他网络技术的规范曾试图修正此问题,使用正确拼法,导致目前拼法并不统一。

                        八、参考链接

                        ]]>
                        <![CDATA[细读 JS |(隐式)数据类型转换详解]]> https://github.com/tofrankie/blog/issues/242 https://github.com/tofrankie/blog/issues/242 Sun, 26 Feb 2023 11:15:58 GMT 配图源自 Freepik

                        在 JavaScript 的世界里,数据类型之间的转换无处不在。即使你没有主]]> 配图源自 Freepik

                        在 JavaScript 的世界里,数据类型之间的转换无处不在。即使你没有主动显式地去转换,但 JavaScript 在私底下“偷偷地”帮我们做了很多类型转换的工作。那么,它们究竟是按照什么规则去转换的呢?我们试图在本文中找到答案,来解开谜底。

                        ECMAScript 是负责制定标准的,而 JavaScript 则是前者的一种实现。

                        在 ECMAScript 标准中定义一组转换抽象操作,常见的抽象操作(Abstract Operation)有 ToPrimitiveToBooleanToNumberToStringToObject 等等。

                        一、ToPrimitive

                        1. ToPrimitive

                        在 ECMAScript 标准中,使用 ToPrimitive 抽象操作将引用类型转换为原始类型。(详见 #sec-7.1.1

                        ▼ ToPrimitive Abstract Operation

                        ToPrimitive(input[, PerferredType])
                        

                        参数 input 是文章开头提到的 8 种数据类型的值(Undefined、Null、Boolean、String、Symbol、Number、BigInt、Object)。参数 PreferredType 是可选的,表示要转换到的原始值的预期类型,取值只能是字符串 "default"(默认)、"string""number" 之一。

                        对于 ToPrimitive 操作,可概括如下:

                        1. input 是 ECMAScript 语言类型的值。
                        2. 如果 input 是引用类型,那么
                          1. 如果没有传递 PreferredType 参数,使 hint 等于 "default"
                          2. 如果参数 PreferredType 提示 String 类型,使 hint 等于 "string"
                          3. 否则,参数 PreferredType 提示 Number 类型,使 hint 等于 "number"
                          4. 使 exoticToPrim 等于 GetMethod(input, @@toPrimitive) 的结果(大致意思是,获取 input 对象的 @@toPrimitive 属性值,并将其赋给 exoticToPrim)。
                          5. 如果 exoticToPrim 不等于 undefined(即 input 对象含 @@toPrimitive 属性),那么
                            1. 使 result 等于 Call(exoticToPrim, input, « hint ») 的结果(大致意思是,执行 @@toPrimitive 方法,即 exoticToPrim(hint))。
                            2. 如果 result 不是引用类型,则返回 result
                            3. 否则抛出 TypeError 类型错误。
                          6. 如果 hint"default",则将 hint 设为 "number"
                          7. 返回 OrdinaryToPrimitive(input, hint)
                        3. 返回 input(即原始类型的值直接返回)。

                        注意:当不带 hint 去调用 ToPrimitive 抽象操作时,通常它的行为就像 hintNumber 类型一样。但是,(派生)对象可以通过定义 @@toPrimitive 方法来替代此行为。在本规范中定义的对象里,只有 Date 对象(详见 #sec-20.4.4.45)和 Symbol 对象(详见 #sec-19.4.3.5)会覆盖默认的 ToPrimitive 行为。

                        其他:

                        1. 以上提到的 @@toPrimitive 方法,即属性名称为 [Symobl.toPrimitive] 的方法。
                        2. Date 对象的 @@toPrimitive 方法定义:当 hint"default" 时,会使 hint 等于 "string"。所以这也是 Date 对象转换为原始值时,会先调用 instance.toString() 方法,而不是 instance.valueOf() 方法的原因。
                        3. 目前 JavaScript 的内置对象中,含有 @@toPrimitive 方法的,只有 DateSymbol 对象。

                        用口水话再总结一下:

                        1. 如果 input 是原始类型,直接返回 input(不做转换操作)。
                        2. 如果参数 PreferredType 是 String(Number)类型,那么使得 hint 等于 "string""number"),否则 hint 等于默认的 "default"
                        3. 如果 input 中存在 @@toPrimitive 属性(方法),若 @@toPrimitive 方法的返回值为原始类型,则 ToPrimitive 的操作结果就是该返回值,否则抛出 TypeError 类型错误。
                        4. 如果经过以上步骤之后 hint"default",则使 hint 等于 "number"
                        5. 返回 OrdinaryToPrimitive(input, hint) 操作的结果。

                        提醒:

                        1. 关于 Date 对象的 Date.prototype[@@toPrimitive] 内部方法实现,其实是将 hint"default" 的情况改为 "string",然后执行第 5 步的 OrdinaryToPrimitive 操作。(详情看 #sec-20.4.4.45

                        2. 关于 Symbol 对象的 Symbol.prototype[@@toPrimitive] 内部方法实现,如果传递给该方法的是一个 Symbol 类型的值,则直接返回该值。如果该值是引用类型,且含有属性 [[SymbolData]],而且该属性值为 Symbol 类型,则返回该属性值,否则会抛出 TypeError 类型错误。(详见 #sec-19.4.3.5

                        那么 OrdinaryToPrimitive 的操作是怎样的呢?我们接着往下看...

                        2. OrdinaryToPrimitive

                        详见:#sec-7.1.11

                        ▼ OrdinaryToPrimitive Abstract Operation

                        OrdinaryToPrimitive(O, hint)
                        

                        参数 O 为引用类型。参数 hint 为 String 类型,其值只能是字符串 "string""number" 之一。

                        对于 OrdinaryToPrimitive 操作,可概括如下:

                        1. O 是引用类型。
                        2. hint 是 String 类型,且 hint 的值只能是 "string""number" 之一。
                        3. 如果 hint"string",使 methodNames 等于 « "toString", "valueOf" »(其中 «» 表示规范中的 List,类似于数组)。
                        4. 如果 hint"number",使 methodNames 等于 « "valueOf", "toString" »
                        5. 遍历 methodNames,使 name 等于每个迭代值,并执行:
                          1. 使 method 等于 Get(O, name)(即获取对象 Oname 属性,相当于获取对象的 toStringvalueOf 属性,具体执行顺序视 hint 而定)。
                          2. 如果 IsCallable(method) 结果为 true,那么:
                            1. 使 result 等于 Call(method, O) 结果(即调用 method() 方法)。
                            2. 如果 result 为原始类型,则返回 result
                        6. 抛出 TypeError 类型错误。

                        其中 IsCallable(argument) 操作,大致内容是:当参数 argument 为引用类型且 argument 对象包含内部属性 [[Call]] 时返回 true, 否则返回 false。话句话说,就是用于判断是否为函数。

                        口水话总结:

                        1. 当经过 ToPrimitive 操作,然后执行 OrdinaryToPrimitive(input, hint) 操作,那么步骤如下:
                        2. 如果 hint"string",它会先调用 input.toString() 方法,
                          1. toString() 结果为原始类型,则直接返回该结果。
                          2. 否则,继续调用 input.valueOf() 方法,若结果为原始类型,则返回该结果,否则抛出 TypeError 类型错误。
                        3. hint"number",它先调用 input.valueOf() 方法,
                          1. valueOf() 结果为原始类型,则直接返回该结果。
                          2. 否则,继续调用 input.toString() 方法,若结果为原始类型,则返回该结果,否则抛出 TypeError 类型错误。

                        到这里,已经完整地讲述了 ToPrimitive 操作的全部过程。文笔不太好,我不知道你们有没看明白,倘若仍有疑惑,请反复斟酌或直接查看 ECMAScript 标准。

                        3. 一些示例

                        • -*/% 这四种操作符都会把符号两边的操作数先转换为数字再进行运算。
                        • + 的作用可以是数值求和,也可以是字符串拼接。
                          • 若符号两边操作数都是数字,则进行数字运算。
                          • 若符号一边是字符串,则会把另一端转换为字符串进行拼接操作。

                        一元加运算符 +(unary plus)是将操作数转换为数字的最快且首选的方式,因为它不对该数字执行任何其他运算。

                        区分一元加运算符(+)和算术运算符(+)的方法,就是前者只有一个操作数,而后者是两个操作数。

                        // 运算符: x + y
                        
                        // Number + Number -> 数字相加
                        1 + 2 // 3
                        
                        // Boolean + Number -> 数字相加
                        true + 1 // 2
                        
                        // Boolean + Boolean -> 数字相加
                        false + false // 0
                        
                        // Number + String -> 字符串连接
                        5 + 'foo' // "5foo"
                        
                        // String + Boolean -> 字符串连接
                        'foo' + false // "foofalse"
                        
                        // String + String -> 字符串连接
                        'foo' + 'bar' // "foobar"
                        
                        const obj = {
                          [Symbol.toPrimitive]: hint => {
                            if (hint === 'number') {
                              return 1
                            } else if (hint === 'string') {
                              return 'string'
                            } else {
                              return 'default'
                            }
                          }
                        }
                        
                        +obj          // 1              hint is "number"
                        `${obj}`      // "string"       hint is "string"
                        obj + ''      // "default"      hint is "default"
                        obj + 1       // "default1"     hint is "default"
                        Number(obj)   // 1              hint is "number"
                        String(obj)   // "string"       hint is "string"
                        

                        二、ToBoolean

                        将一个操作数转换为布尔值,这应该是最简单的了。(详见 #sec-7.1.2

                        ▼ ToBoolean Abstract Operation

                        所以总结下来就是:

                        操作数 结果
                        undefinednullfalse+0-0NaN''0n false
                        除以上这些(falsy)值之外 true

                        在 JavaScript 中,如果一个操作数 argument 通过 ToBoolean(argument) 操作后被转换为 true,那么这些操作数称为真值(truthy),否则为虚值(falsy)。

                        // 转换为 Boolean 值的两种方式
                        !!x
                        Boolean(x)
                        

                        三、ToNumber

                        将一个操作数转换为数字值。(详见 #sec-7.1.4

                        ▼ ToNumber Abstract Operation

                        参数类型 结果
                        Undefined NaN
                        Null +0
                        Boolean true 转换为 1false 转换为 +0
                        Number 直接返回,不做类型转换。
                        String 1. 纯数字的字符串转换为相应的数字;
                        2. 空字符串 '' 转为 +0
                        3. 否则为 NaN

                        其中 0x 开头的字符串被当成 16 进制。
                        Symbol 无法转换,抛出 TypeError 错误。
                        BigInt 无法转换,抛出 TypeError 错误。
                        Object 两个步骤:

                        1. 将引用类型转化为原始值 ToPrimitive(argument, 'number')
                        2. 转化为原始值后,进行 ToNumber(primValue) 操作,即按上面的类型转换。

                        需要注意的是

                        1. Number(undefined) 结果为 NaN,而 Number(null) 结果为 0
                        2. 含有前导和尾随空白符(\n\r\t\v\f)的字符串,在转换为数字类型的时候空白符会被忽略。
                        3. 上面也提到过,使用一元加运算符(+) 是将其他类型转换为数值的常用方式。
                        Number(undefined) // NaN
                        Number(null) // 0
                        
                        '\n  123  \t' == 123 // true
                        
                        +'string' // NaN
                        +true // 1
                        +[] // 0
                        +{} // NaN
                        

                        四、ToString

                        将一个操作数转换为字符串类型的值。(详见 #sec-7.1.17

                        ▼ ToString Abstract Operation

                        参数类型 结果
                        Undefined undefined
                        Null null
                        Boolean true 转换为 "true"false 转换为 "false"
                        Number 1. NaN 转换为 "NaN"
                        2. +0-0 转换为 "0"
                        3. 其中 Infinity-Infinity 分别转换为 "Infinity""-Infinity"
                        4. 若 x 是小于 0 的负数,则返回 "-x";若 x 是大于 0 的正数,则返回 "x"
                        5. 其他不常用的数值,详见 #sec-6.1.6.1.20
                        String 直接返回,不做类型转换。
                        Symbol 无法转换,抛出 TypeError 错误。
                        BigInt 10n 转换为 "10"
                        Object 两个步骤:

                        1. 将引用类型转化为原始值 ToPrimitive(argument, 'string')
                        2. 转化为原始值后,进行 ToString(primValue) 操作,即按上面的类型转换。

                        需要注意的是,Symbol 原始值不能转换为字符串,只能将其转换成对应的包装对象,再调用 Symbol.prototype.toString() 方法。

                        // 下面会导致 Symbol('foo') 进行隐式转换,即 ToString(Symbol),按以上规则,是会抛出异常的
                        console.log(Symbol('foo') + 'bar' ) // TypeError: Cannot convert a Symbol value to a string
                        
                        // Symbol('foo') 结果是 Symbol 的原始值,再调用其包装对象的属性时,会自动转化为包装对象再调用其 toString() 方法
                        console.log(Symbol('foo').toString() + 'bar' ) // "Symbol(foo)bar"
                        

                        抛一个有趣的问题:

                        // 运行出错
                        var name = Symbol() // TypeError: Cannot convert a Symbol value to a string
                        
                        // 正常运行,不会抛出错误
                        let name = Symbol()
                        
                        // 为什么呢 ❓❓❓
                        

                        答案我不写了,感兴趣的可以自行搜索。对此有疑问可看:关于 var、let 的顶层对象的属性

                        五、ToObject

                        将一个操作数转换为引用类型的值。(详见 #sec-7.1.18

                        ▼ ToObject Abstract Operation

                        参数类型 结果
                        Undefined 无法转换,抛出 TypeError 错误。
                        Null 无法转换,抛出 TypeError 错误。
                        Boolean 返回 new Boolean(argument)
                        Number 返回 new Number(argument)
                        String 返回 new String(argument)
                        Symbol 返回 Object(Symbol(argument))
                        BigInt 返回 Object(BigInt(argument))
                        Object 直接返回,不做类型转换。

                        需要注意都是,JavaScript 内置的 SymbolBigInt 对象不能使用 new 关键字去创建实例对象,只能通过 Object() 函数来创建一个包装对象(wrapper object)。

                        // 错误示例
                        const sym = new Symbol() // TypeError: Symbol is not a constructor
                        
                        // 正确示例
                        const sym = Symbol()
                        console.log(typeof sym) // "symbol"
                        const symObj = Object(sym)
                        console.log(typeof symObj) // "object"
                        
                        // BigInt 同理
                        

                        需要注意的是,从 ES6 开始围绕原始数据类型创建一个显式包装器对象不再被支持。但由于遗留原因,现有的原始包装器对象(如 new Booleannew Numbernew String)仍可使用。这也是 ES6+ 新增的 Symbol、BigInt 数据类型无法通过 new 关键字创建实例对象的原因。

                        六、参考链接

                        ]]>
                        <![CDATA[细读 JS | valueOf 和 toString 方法]]> https://github.com/tofrankie/blog/issues/241 https://github.com/tofrankie/blog/issues/241 Sun, 26 Feb 2023 11:14:53 GMT 前言

                        在另外一篇文章《JS 数据类型详解》中讲述 ToPrimitiveOrdinaryToPrimitive 操作时,]]> 前言

                        在另外一篇文章《JS 数据类型详解》中讲述 ToPrimitiveOrdinaryToPrimitive 操作时,涉及到这两方法,所以今天来简单写一下。

                        其实我们一般很少主动去调用这两个方法,那它们什么时候会被使用到呢?当我们需要将一个对象(严格来说是,引用类型的值)转化为原始值的时候,JavaScript 可能会调用到它们。

                        举个例子:

                        const obj = {}
                        console.log('This is ' + obj) // "This is [object Object]"
                        

                        内部过程是这样的:

                        1. 'This is ' + obj 操作,使得 obj 会自动转换为原始值。
                        2. 由于 obj 内部没有定义 @@toPrimitive 属性,所以它会先调用 toString 方法或 valueOf 方法。
                        3. 由于 obj 本身没有 toString 方法,JavaScript 会从原型上找到 Object.prototype.toString(),执行结果是 [object Object]。
                        4. 由于 toString 方法已经返回原始值了,就不会再调用 valueOf 方法了。(假设上面 toString 没有返回原始值,接着调用 valueOf 方法,如果结果还不是原始值,则会抛出 TypeError 错误)
                        5. 所以最后执行拼接操作的是两个字符串:'This is ' + '[object Object]',所以结果就是它了。

                        验证一下:

                        // 示例 1:验证第 2 点。在类型转换时,优先寻找 @@toPrimitive 方法(即下面的 [Symbol.toPrimitive])
                        const obj = {
                          [Symbol.toPrimitive]: hint => {
                            console.log('注意:根据 ECMAScript 标准,若该方法返回引用类型,会抛出 TypeError')
                            return 'abc'
                          }
                        }
                        console.log('This is ' + obj) // "This is abc"
                        
                        
                        // 示例 2:验证第 3 点
                        const obj = {}
                        Object.prototype.toString = () => 'rewrite toString method'
                        console.log('This is ' + obj) // "This is rewrite toString method"
                        
                        
                        // 示例 3:验证第 4 点
                        const obj = {
                          valueOf: hint => {
                            console.log('执行 valueOf 方法')
                            return {}
                          },
                          toString: hint => {
                            console.log('执行 toString 方法')
                            return 'tostring'
                          }
                        }
                        console.log('This is ' + obj) // "This is tostring"(在控制台可以看到,先后执行了 valueOf、toString 方法)
                        console.log(String(obj)) // "tostring"
                        

                        valueOf

                        OrdinaryToPrimitive(O, hint) 抽象操作的 hint"number" 时,JavaScript 会首先调用 valueOf() 方法。

                        关于 OrdinaryToPrimitive(O, hint) 的介绍可以看文章

                        Object.prototype.valueOf

                        Object.prototype.valueOf() 方法返回对象的原始值

                        Object.prototype.valueOf.call({}) // {}
                        Object.prototype.valueOf.call([]) // []
                        

                        当我们创建一个对象 const obj = {},当我们调用 obj.valueOf() 时,访问的就是 Object.prototype.valueOf() 方法。

                        但是,JavaScript 里面内置了很多全局性的对象,如 ArrayBooleanDateFunctionNumberObjectString。它们都重写了自己的 valueOf 方法。其中 MathError 对象没有 valueOf 方法。

                        通过以下方式,可以判断一个内置对象是否有重写自己的 valueOf 方法:

                        // 结果为 false 表示有重写(toString 同理)
                        Array.prototype.valueOf === Object.prototype.valueOf // true
                        Function.prototype.valueOf === Object.prototype.valueOf // true
                        
                        Boolean.prototype.valueOf === Object.prototype.valueOf // false
                        Date.prototype.valueOf === Object.prototype.valueOf // false
                        Number.prototype.valueOf === Object.prototype.valueOf // false
                        String.prototype.valueOf === Object.prototype.valueOf // false
                        Symbol.prototype.valueOf === Object.prototype.valueOf // false
                        BigInt.prototype.valueOf === Object.prototype.valueOf // false
                        
                        // 还有很多内置对象没列出来,可自行翻查 MDN 或 ECMAScript 文档...
                        
                        对象 返回值
                        Boolean 返回布尔值。
                        Date 返回的时间是从 1970 年 1 月 1 日 00:00:00 开始计的毫秒数(UTC)。
                        Number 返回数字值。
                        String 字符串值。
                        Object 返回对象本身。这是默认情况。

                        需要注意的是,MathError 对象没有 valueOf() 方法。

                        假设我们自行创建一个对象,可以这样去覆盖默认的 Object.prototype.valueOf() 方法:

                        // 构造函数
                        function MyObject(my) {
                          this.my = my
                        }
                        
                        // 在原型上定义 valueOf 方法(该方法不应传入参数)
                        MyObject.prototype.valueOf = function() {
                          return this.my
                        }
                        
                        // 实例化
                        var myObj = new Object('This is myObj.')
                        console.log('' + myObj) // "This is myObj."
                        

                        其他内置对象的 valueOf 方法

                        其实好像没什么好说的,放链接自己看吧。

                        二、toString

                        同样的,一般比较少主动去调用 toString() 方法。

                        Object.prototype.toString()

                        Object.prototype.toString() 返回一个表示该对象的字符串。

                        它实际访问的是对象内部的 [[Class]] 属性,返回的形式如:"[object type]",常用于检测对象类型。

                        function getClass(x) {
                          const { toString } = Object.prototype
                          const str = toString.call(x)
                          return /^\[object (.*)\]$/.exec(str)[1]
                        }
                        
                        getClass(null) // "Null"
                        getClass(undefined) // "Undefined"
                        getClass({}) // "Object"
                        getClass([]) // "Array"
                        getClass(JSON) // "JSON"
                        getClass(() => {}) // "Function"
                        ;(function() { return getClass(arguments) })() // "Arguments"
                        

                        更多详见 JS 数据类型详解 — 对象的内部属性章节

                        Array.prototype.toString === Object.prototype.toString // false
                        Function.prototype.toString === Object.prototype.toString // false
                        
                        Boolean.prototype.toString === Object.prototype.toString // false
                        Date.prototype.toString === Object.prototype.toString // false
                        Number.prototype.toString === Object.prototype.toString // false
                        String.prototype.toString === Object.prototype.toString // false
                        Symbol.prototype.toString === Object.prototype.toString // false
                        BigInt.prototype.toString === Object.prototype.toString // false
                        

                        Array.prototype.toString()

                        Array.prototype.toString() 方法,返回一个包含用逗号 , 分隔的每个数组元素的字符串。

                        var arr = [1, 2, 3]
                        console.log(arr.toString()) // "1,2,3"
                        
                        // 结果相当于 Array.prototype.join.call(instance, ',')
                        arr.join(',') // "1,2,3"
                        

                        Function.prototype.toString()

                        Function.prototype.toString() 方法,返回一个表示当前函数源代码的字符串。

                        function fn() {}
                        console.log(fn.toString()) // "function fn() {}"
                        

                        Boolean.prototype.toString()

                        Boolean.prototype.toString() 方法,返回指定的布尔对象的字符串形式。

                        console.log(true.toString()) // "true"
                        console.log(false.toString()) // "false"
                        

                        String.prototype.toString()

                        String.prototype.toString() 方法,返回指定对象的字符串形式。

                        console.log(new String('foo').toString()) // "foo"
                        

                        Symbol.prototype.toString()

                        Symbol.prototype.toString() 方法,返回当前 Symbol 对象的字符串表示。

                        需要注意的是,Symbol 原始值不能转换为字符串,只能将其转换成对应的包装对象,再调用 toString() 方法。

                        console.log(Symbol('foo') + 'bar' ) // TypeError: Cannot convert a Symbol value to a string
                        
                        // Symbol('foo') 结果是 Symbol 的原始值,再调用其包装对象的属性时,会自动转化为包装对象再调用其 toString() 方法
                        console.log(Symbol('foo').toString() + 'bar' ) // "Symbol(foo)bar"
                        

                        BigInt.prototype.toString()

                        BigInt.prototype.toString() 方法,返回一个字符串,后面的 n 不是字符串的一部分。

                        console.log(1024n.toString()) // "1024"
                        console.log(1024n.toString(2)) // "10000000000"
                        console.log(1024n.toString(16)) // "400"
                        

                        The end.

                        ]]>
                        <![CDATA[细读 JS | 相等比较详解]]> https://github.com/tofrankie/blog/issues/240 https://github.com/tofrankie/blog/issues/240 Sun, 26 Feb 2023 11:10:26 GMT 先说结论

                        记住一句话:

                        Always use 3 equals unless you have a good reason to use 2.

                        基本概念

                        在 JavaScript 中我们经常会使用]]> 先说结论

                        记住一句话:

                        Always use 3 equals unless you have a good reason to use 2.

                        基本概念

                        在 JavaScript 中我们经常会使用 ===(Strict equality,全等运算符)和 ==(Equality,相等运算符)来比较两个操作数是否相等,它俩都返回一个布尔值的结果(否定形式分别是 !==!=)。

                        两者的区别:

                        • === 总是认为不同类型的操作数是不相等的。
                        • == 与前者不同,它会尝试强制类型转换且比较不同类型的操作数。(即如果操作数的类型不同,相等运算符会在比较之前尝试将它们转换为相同的类型)

                        一张很经典的图:

                        ▲ 相等运算符(==)判断

                        源自 JavaScript-Equality-Table

                        全等运算符

                        全等运算符(===和 !==)使用全等比较算法来比较两个操作数。

                        MDN 可以看到,大致概括如下:

                        • 如果操作数的类型不同,则返回 false
                        • 如果两个操作数都是对象,只有当它们指向同一个对象时才返回 true
                        • 如果两个操作数都为 null,或者两个操作数都为 undefined,返回 true
                        • 如果两个操作数有任意一个为 NaN,返回 false
                        • 否则,比较两个操作数的值:
                          • 数字类型必须拥有相同的数值。+0 和 -0 会被认为是相同的值。
                          • 字符串类型必须拥有相同顺序的相同字符。
                          • 布尔运算符必须同时为 true 或同时为 false

                        列举几个示例:

                        console.log(1 === '1') // false
                        console.log(1 === true) // false
                        console.log(undefined === null) // false
                        console.log({} === {}) // false
                        

                        以上这些结果相信是毫无疑问的。但是,从写下这篇文章的时候 MDN 上关于 === 的描述并未涉及 Symbol、BigInt 类型。

                        那么,我们直接看 ECMAScript 最新标准吧。(#sec-7.2.16

                        看起来“好像”只是简单的几句话对吧,翻译一下:

                        需要注意的是,Type(x) 是 ECMAScript 标准定义的抽象操作(Abstract Operation),并非是 JavaScript 的某个语法,它返回的是 8 种数据类型。如果你多读 ECMAScript 标准,就会发现很多抽象操作里都有引用到 Type(x),假设每处都对 Type(x) 进行描述显得多余重复,何不将它抽出来定义成一个抽象操作。(我猜 ECMAScript 标准修订者也是这么想的)

                        1. 如果 xy 是两种不同的数据类型,则返回 false
                        2. 如果 xy 的类型同时是 Number 或 BigInt 类型,那么 a. 对应返回 Number::equal(x, y)BigInt::equal(x, y) 的操作结果。
                        3. 返回 SameValueNonNumber(x, y) 的结果。

                        注意:该算法与 SameValue 算法的区别在于对有符号零NaN 的处理。

                        原来它是这样的三句话,哎~

                        1. Number::equal(x, y)

                        #sec-6.1.6.1.13

                        翻译过来就是:

                        1. 如果 xy 任意一个的值为 NaN,则返回 false
                        2. 如果 xy 的数学量(Number value)是相等的,则返回 true
                        3. 如果 x+0y-0,则返回 true(反之亦然)。
                        4. 以上均不符合,则返回 false

                        需要注意的是,+0-0 的数学量是不相等的。(将 Number value 翻译成数学量,不一定准确哈,这是 Google Translation 告诉我的,我没去细究,就那个意思,哈哈)

                        1 / +0 // Infinity
                        1 / -0 // -Infinity
                        Infinity === -Infinity // false
                        

                        2. BigInt::equal(x, y)

                        #sec-6.1.6.2.13

                        如果 xy 均为 BigInt 类型,且具有相同的数学整数值(mathematical integer),则返回 true,否则返回 false

                        3. SameValueNonNumeric(x, y)

                        #sec-7.2.13

                        翻译过来就是:

                        1. 断言:x 既不是 Number 类型,也不是 BigInt 类型。
                        2. 断言:xy 的数据类型相同。
                        3. 如果 x 是 Undefined 类型,则返回 true
                        4. 如果 x 是 Null 类型,则返回 true
                        5. 如果 xString 类型,那么 a. 如果 xy 是完全相同的代码单元序列(在相应的索引处具有相同的长度和相同的代码单元),则返回 true;否则返回 false
                        6. 如果 x 是 Boolean 类型,那么 a. 如果 xy 同时为 true,或同时为 false,则返回 true,否则返回 false
                        7. 如果 x 是 Symbol 类型,那么 a. 如果 xy 都是相同的 Symbol 值,则返回 true,否则返回 false
                        8. 如果 xy 是同一个引用值,则返回 true,否则返回 false

                        几个点注意一下:

                        1. Assert 是断言。若以 Assert: 为开头的步骤(Step),明确了其算法的不变条件。例如上面的 SameValueNonNumeric 算法,第 1、2 步骤明确了 xy 的具有相同的数据类型,且不为 Number 或 BigInt 类型。可以理解为,这两句断言是该算法的前提条件。

                          细心的同学可能会发现,类似第 5 条步骤:if Type(x) is String, then...,接着下一条不是 if Type(y) is String, then...,因为它没必要了,前面的步骤已经明确了它的类型是一致的,如果有反而多余了。回头再看 Number::equal(x, y) 算法,其中第 1、2 步骤分别说明了 xNaNyNaN 的情况,因为它前置条件没有断言。下文的算法也有类似的情况。

                        2. 如果第 3、4 步骤有人不理解的话,看这里。由于 Undefined、Null 类型对应的值分别只有 undefinednull。如果 x 为 Undefined 类型,则可以推断出 xy 的值,只能是 undefined,因此返回 true。Null 同理。

                          注意:本文表示数据类型,均以大写字母开头,例如 Undefined、String 等,而表示某种数据类型的值,均以小写字母出现,例如 undefinednull 等。前者不使用代码块形式高亮展示,后者则高亮展示。

                        4. 小结

                        • 如果操作数的数据类型不同,则返回 false
                        • 如果两个操作数的数据类型相同:
                          • 如果操作数均为引用类型,只有当它们指向同一个对象时才返回 true,否则返回 false
                          • 如果操作数是 String、Boolean、Undefined、Null 类型之一,它们的值相等才返回 true,否则返回 false
                          • 如果操作数是 Symbol 类型,只有它们指向同一个值才返回 true,否则返回 false。(单独拿出来是因为它有点像引用类型的意思,Symbol 类型的本质就是创建一个独一无二的字符串,即使我们使用 Symbol() 函数创建了表面“看似一样”的字符串,但实际上它们是不相等的。例如:Symbol() === Symbol() 结果为 false
                          • 如果操作数是 Number 或 BigInt 类型,(除了一个例外)它们必须具有相同的数学值,才会返回 true,否则返回 false
                            • xy 的值之一是 NaN,返回 false
                            • x+0y-0,返回 true,反之亦然。
                            • x+0ny-0n,返回 true,反之亦然。

                        三、全等运算符的“坑”

                        根据以上的比较算法,感觉 === 也并不是总“靠谱”。例如以下“反直觉”的判断:

                        // PS:NaN 是一个全局对象的属性,与 Number.NaN 的值一样,都为 NaN(Not-A-Number)。
                        console.log(NaN === NaN) // false
                        console.log(Number.NaN === NaN) // false
                        
                        console.log(+0 === -0) // true
                        

                        1. NaN

                        NaN 是 JavaScript 中唯一一个不等于自身的值。

                        因此,NaN !== NaN 结果为 true 似乎没毛病,只是反人类、反直觉罢了。

                        下面总结了几种方法,来判断一个值是否为 NaN

                        // 1. 利用 NaN 的特性,JavaScript 中唯一一个不等于自身的值
                        function isnan(v) {
                          return v !== v
                        }
                        
                        // 2. 利用 ES5 的 isNaN() 全局方法
                        function isnan(v) {
                          return typeof v === 'number' && isNaN(v)
                        }
                        
                        // 3. 利用 ES6 的 Number.isNaN() 方法
                        function isnan(v) {
                          return Number.isNaN(v)
                        }
                        
                        // 4. 利用 ES6 的 Object.is() 方法
                        function isnan(v) {
                          return Object.is(v, NaN)
                        }
                        

                        因此,无法通过 Array.prototype.indexOf() 来确定 NaN 在数组中的索引值。

                        [NaN].indexOf(NaN) // -1
                        

                        可使用 ES6 的 Array.prototype.includes 方法判断

                        [NaN].includes(NaN) // true
                        

                        Array.prototype.indexOf() 内部基于 Strict Equality Comparison 去比较,判断两个 Number 类型的操作数是否相等是根据 equal 算法实现的。而 Array.prototype.includes 内部则使用了 sameValueZero 算法,前者认为 NaNNaN 是不相等的,后者则认为相等的。(详情看 Number::equal (x, y)Number::sameValueZero (x, y)

                        关于 NaNisNaN()Number.isNaN() 的区别,详见 JavaScript 的迷惑行为大赏

                        2. +0 与 -0

                        在 JavaScript 中,数字类型包括浮点数、+Infinity(正无穷)、-Infinity(负无穷)和 NaN(not-a-number,非数字)。

                        还有 ES2021 标准中增加了一种 BigInt(原始)类型,表示极大的数字(非本文范围,不展开叙述)。

                        其实,+0-0 是不相等的,为什么?

                        console.log(1 / +0 === Infinity) // true
                        console.log(1 / -0 === -Infinity) // true
                        console.log(Infinity === -Infinity) // false
                        
                        // 因此,+0 和 -0 是两个不相等的值。只是“全等比较算法”里认为他们是相对的而已。
                        

                        3. 处理以上两种特殊的情况

                        在 ES6 标准中,提出来一个方法 Object.is(),对以上两种情况都做了“正确”的处理。

                        Object.is(NaN, NaN) // true
                        Object.is(+0, -0) // false
                        

                        而 ES5 可以这样去处理:

                        function objectIs(x, y) {
                          if (x === y) {
                            // x === 0 => compare via infinity trick
                            return x !== 0 || (1 / x === 1 / y)
                          }
                        
                          // x !== y => return true only if both x and y are NaN
                          return x !== x && y !== y
                        }
                        

                        四、相等运算符

                        相等运算符(==!=)使用抽象相等比较算法比较两个操作数。

                        如果对 JavaScript 的类型转换不熟悉的话,建议先看 JS 数据类型转换详解

                        1. ES5 相等比较算法

                        MDN 可以看到 x == y 比较的描述,如下:

                        • 如果两个操作数都是对象,则仅当两个操作数都引用同一个对象时才返回 true
                        • 如果一个操作数是 null,另一个操作数是 undefined,则返回 true
                        • 如果两个操作数是不同类型的,就会尝试在比较之前将它们转换为相同类型:
                          • 当数字与字符串进行比较时,会尝试将字符串转换为数字值。
                          • 如果操作数之一是 Boolean,则将布尔操作数转换为 10
                            • 如果是 true,则转换为 1
                            • 如果是 false,则转换为 0
                          • 如果操作数之一是对象,另一个是数字或字符串,会尝试使用对象的 valueOf()toString() 方法将对象转换为原始值。
                        • 如果操作数具有相同的类型,则将它们进行如下比较:
                          • Stringtrue 仅当两个操作数具有相同顺序的相同字符时才返回。
                          • Numbertrue 仅当两个操作数具有相同的值时才返回。+0 并被 -0 视为相同的值。如果任一操作数为 NaN,则返回 false
                          • Booleantrue 仅当操作数为两个 true 或两个 false 时才返回 true

                        截止发文日期,我们可以看到它并没有关于 Symbol 和 BigInt 类型的描述,因此以上相等比较并不是最新的。

                        目前 MDN 上的文档仍展示的是 ES5.1 的算法,而阮一峰老师翻译的一篇也不是最新的相等比较算法,它里面没有 ES2020 新增的 BigInt 类型。

                        2. ES6+ 相等比较算法

                        目前 ECMAScript 标准最新的抽象相等比较算法如下:

                        翻译一下:

                        1. 如果 xy 相同,则返回全等比较 x === y 的结果。
                        2. 如果 xnullyundefined,则返回 true
                        3. 如果 xundefinedynull,则返回 true
                        4. 如果 x 为 Number 类型,而 y 为 String 类型,那么先将 y 转换为 Number 再比较。
                        5. 如果 x 为 String 类型,且 y 为 Number 类型,那么先将 x 转换为 Number 再比较。
                        6. 如果 x 是 BigInt 类型,且 y 是 String 类型,则 a. 将 y 转换为 BigInt 类型,转换结果暂记为 n。 b. 如果 nNaN,则返回 false。 c. 否则,返回 x == n 的比较结果。
                        7. 如果 x 为 String 类型,y 为 BigInt 类型,则返回比较结果 y == x
                        8. 如果 x 为 Boolean 类型,那么先将 x 转换为 Number 类型,再比较。
                        9. 如果 y 为 Boolean 类型,那么先将 y 转换为 Number 类型,再比较。
                        10. 如果 x 是 String、Number、BigInt 或 Symbol 类型,并且 y 是Object 类型,那么将 y 转换成原始(Primative)类型再比较。
                        11. 如果 x 是 Object 类型,且 y 是String、Number、BigInt 或Symbol 类型,那么将 x 转换成原始(Primative)类型再比较。
                        12. 如果 x 是 BigInt 类型,且 y 是 Number 类型,或者 x 是 Number 类型且 y 是 BigInt 类型,则 a. 如果 xyNaNInfinity-Infinity 中的任何一个,则返回 false。 b. 否则返回 xy 的比较结果。
                        13. 以上都不满足,那么返回 false。例如规则 10 在类型转换时出现 TypeError 状况时,那么就会在这里返回 false

                        相等运算符与全等运算符===)运算符之间最显着的区别在于,后者不尝试类型转换。相反,前者始终将不同类型的操作数视为不同。

                        列举几个示例:

                        // 这些例子都是很简单的
                        console.log(false == undefined) // false
                        console.log(null == undefined) // true
                        console.log(null == false) // false
                        console.log(null == 0) // false
                        console.log(null == '') // false
                        console.log('\n  123  \t' == 123) // true, because conversion to number ignores leading and trailing whitespace in JavaScript.
                        

                        相信很多人在没有完全弄清楚相等运算符比较的【套路】之前,会很让人抓狂...

                        针对以上 13 条规则,再提炼总结一下(但是标准描述真的是非常地严谨 👍):

                        1. 如果两个操作数是同一类型,返回 x === y 的结果。
                        2. 如果一个操作数是 null,另一个操作数是 undefined,则返回 true
                        3. 如果一个操作数是 Number 类型,另一个是操作数 String 类型,那么会将 String 类型的操作数先转换为 Number 类型,接着按第 1 点去比较并返回结果。
                        4. 如果一个操作数 x 是 BigInt 类型,另一个是操作数 y 是 String 类型,那么会将 String 类型的操作数先转换为 BigInt 类型(假设转换结果为 n)。
                          1. 如果 nNaN,则返回 false
                          2. 否则返回 x == n 的结果。
                        5. 如果一个操作数是 Boolean 类型,则会将布尔值转换为 Number 类型,并返回 x == ToNumber(y) 的结果。
                        6. 如果一个操作是引用类型,另一个是原始类型,那么会将引用值转换为原始值,并返回 ToPrimitive(x) == y
                        7. 如果一个操作数是 BigInt 类型,另一个是 Number 类型,那么
                          1. 如果其中一个数是 NaNInfinity-Infinity 中的任何一个,则返回 false
                          2. 如果 xy 的数学值相等,则返回 true,否则返回 false
                        8. 返回 false(包括以上转换过程出现 TypeError 都会在这里返回 false)。

                        仔细观察可以看得到,相等比较都向着数字类型(这里指 Number 和 BigInt)转化的趋势。

                        五、相等比较常见示例

                        其实熟悉以上算法之后,遇到一些看似很“奇葩”的比较也不足为惧了。

                        1. 0 == false

                        0 == null // false
                        0 == undefined // false,同理
                        

                        根据 Type(x) 抽象操作规则,Type(0) 结果为 NumberType(null) 结果为 Null,再结合比较算法,可以清楚地知道它按照第 13 点返回 false

                        需要注意的是,这里的 Type(x) 是指 ECMAScript 标准里面定义的一个操作,该操作表示返回 x 对应的数据类型。而不是具体的 JavaScript 语法,与 typeof() 有本质意义上的不同,别混淆了。

                        2. [] == ![]

                        看到这个式子,千万别急眼,一步一步来......

                        [] == ![] // true
                        

                        我们来分析一下,根据运算符优先级,! (逻辑非)的优先级高于 ==,因此优先执行 ![]。而 [] 属于真值(Truthy) ,所以 ![] 结果为 false

                        [] == false
                        

                        根据第 9 条规则,先将 Boolean 类型的值转换为 Number 类型,所以变成了:

                        [] == 0
                        

                        再根据第 11 条规则,先将引用类型转换为原始类型,故进行的操作是 ToPrimitive([])

                        (这步操作可能稍微复杂一点点,但别急)由于数组实例本身没有 @@toPrimitive 方法,且此时 ToPrimitive 操作的 hint 值为 "default"(根据 ToPrimitive 规则,里面的其中一个步骤会将 hint 设为 "number"),然后进行 OrdinaryToPrimitive 操作,因此会先调用 valueOf 方法,再调用 toString 方法。

                        // 由于
                        [].valueOf() // 结果为数组本身,并非原始值,接着调用 toString 方法
                        [].toString() // 结果为 "",所以 [] 的 ToPrimitive 操作返回的是空字符串
                        
                        // 所以变成了
                        '' == 0
                        

                        根据第 5 条规则,将 "" 转换为数值 0,即

                        0 == 0 // true
                        

                        严格来说,其实还有一步的。根据第 1 条规则,返回 0 === 0 的结果。

                        整个转换过程如下,因此 [] == ![] 比较结果为 true

                        [] == ![]
                        [] == false
                        [] == 0
                        '' == 0
                        0 == 0
                        0 === 0 // true
                        

                        3. {} == !{}

                        它看着跟前面一个例子很相似,转换过程同理,但结果是...

                        {} == !{} // false
                        

                        首先,根据操作符优先级顺序,先将 !{} 转换为 false。即变成了 {} == false 的比较,然后将 false 转换为 0,所以变成了。

                        {} == 0
                        

                        然后将 {} 转换为原始值,即执行 ToPrimitive({}) 操作。其中 hint"default"。所以先执行 {}.toString() 方法,得到 "[object Object]" 结果,由于结果已经是原始值,不再调用 valueOf() 方法了。

                        "[object Object]" == 0
                        

                        根据第 5 条规则,将 "[object Object]" 转换为数值,进行的操作是 ToNumber("[object Object]"),即执行方法 Number("[object Object]"),得到的结果是 NaN

                        NaN == 0
                        

                        由于 NaN0 都是 Number 类型,根据第 1 条规则,返回 NaN === 0 的结果。而 NaN 与任何操作数(包括其本身)进行全等比较,均返回 false

                        因此 {} == !{} 的结果为 false。整个过程如下:

                        {} == !{}
                        {} == false
                        {} == 0
                        '[object Object]' == 0
                        NaN == 0
                        NaN === 0 // false
                        

                        4. 10 == 10n

                        来看看 Number 类型与 BigInt 类型的相等比较。

                        10 == 10n // true
                        

                        根据规则第 12 条,且两个操作数并非是 NaNInfinity-Infinity,因此比较两者的数学值(mathematical value),其数学值是相等的,所以结果为 true

                        五、其他

                        1. +、-、*、/、% 的隐式类型转换

                        除了 +- 既可以作为一元运算符、也可以是算术运算符,其余的均为算术运算符。

                        • +-*/% 均为算术运算符时,会将运算符两边的操作数先转换为 Number 类型(若已经是 Number 类型则无需再转换),再进行相应的算术运算。

                        • +- 作为一元运算符时,即只有一个运算符和操作数。前者将操作数转换为 Number 类型并返回。后者将操作数转换为 Number 类型,然后取反再返回。

                        未完待续,拼命更新中...

                        六、参考链接

                        ]]>
                        <![CDATA[细读 JS | 数据类型详解]]> https://github.com/tofrankie/blog/issues/239 https://github.com/tofrankie/blog/issues/239 Sun, 26 Feb 2023 11:09:16 GMT 一、数据类型的分类

                        截止发文日期,ECMAScript 标准的数据类型仅有 8 种(ECMAScript Language Types)]]> 一、数据类型的分类

                        截止发文日期,ECMAScript 标准的数据类型仅有 8 种(ECMAScript Language Types)。可以分为两类:

                        • 原始类型(Primitives),我们也称作基本数据类型
                          • Undefined
                          • Null(一种特殊的原始类型,typeof(instance) === 'object'
                          • Boolean
                          • String
                          • Symbol(typeof(instance) === 'symbol'
                          • Number
                          • BigInt(typeof(instance) === 'bigint'
                        • 引用类型(Objects)
                          • Object(包括从 Object 派生出来的结构类型,如 Object、Array、Map、Set、Date 等)

                        关于使用 typeof 判断以上数据类型的话题,老生常谈了。例如,为什么 typeof null === 'object'typeof(() => {}) === 'function' 呢?这里不展开赘述了,详见:JavaScript 的迷惑行为大赏

                        原始类型的比较的是值,只有两者的值相等,那么它们被认为是相等的,否则不相等。而引用类型比较的是地址,当两者的标识符同时指向内存的同一个地址,则被认为是相等的,否则不相等。

                        console.log({} == {}) // false
                        console.log([] == []) // false
                        

                        二、原始类型与原始值

                        所有基本类型的值(即原始值,Primitive Values)都是不可改变(immutable)的,而且不含任何属性和方法的。

                        到这里可能会有小伙伴打问号了???

                        Q1:原始类型与原始值有什么区别?

                        原始类型的值称为原始值。例如原始类型 Boolean 有两个(原始)值 truefalse。同样的原始类型 Undefined(Null),只有一个原始值 undefinednull)。其他的就有很多个了...

                        Q2:原始值不可改变?这样不是改变了吗?

                        var foo = true
                        foo = false
                        console.log(foo) // false
                        

                        其实不然,以上示例是原始类型和一个赋值为原始类型的变量的区别。变量会被赋予一个新值,而原值不能像数组、对象以及函数那样被改变。

                        基本类型值可以被替换,但不能被改变。

                        // 使用字符串方法不会改变一个字符串
                        var foo = 'foo'
                        foo.toUpperCase()
                        console.log(foo) // "foo"
                        
                        // 赋值行为可以给基本类型一个新值,而不是改变它
                        foo = foo.toUpperCase() // "FOO"
                        

                        再有示例:

                        var num = 1
                        
                        function add(num) {
                          num += 1
                          console.log(num)
                        }
                        
                        add(num) // 2
                        console.log(num) // 1
                        
                        // ************************** 华丽的分割线 **************************
                        
                        // 如果没有看上面的一些概念,单纯地看上面的例子,我相信百分百都能得到正确答案。
                        // 但看完上面一些的概念之后,再结合例子,不知道会不会有人对 “原始类型的值不可改变” 这句话产生怀疑?
                        // 如果有怀疑就继续往下看 👇👇👇,否则可直接跳到 Q3 了。
                        
                        // ************************** 华丽的分割线 **************************
                        
                        // JS 运行的三个步骤:词法分析、预编译、解析执行。
                        // 其中预编译,不仅仅发生在 script 代码块执行之前,还发生在函数执行之前。
                        // 
                        // 函数预编译的过程大致是这样的:
                        // 1. 首先查找形参和变量声明(此时并赋予值 undefined)
                        // 2. 接着将实参赋值给形参
                        // 3. 接着查找函数体内的函数声明(赋予函数本身)。
                        //
                        // 函数 add 在实参赋值给形参的过程,会将传递进来的参数(基本类型的值)复制一份,
                        // 创建一个本地副本,该副本只存在于该函数的作用域中。(原本的值与副本是完全独立,互不干扰的)
                        

                        Q3:原始值没有任何属性和方法?那这个是怎么回事?

                        var foo = 'foo'
                        console.log(foo.length) // 3
                        console.log(foo.toUpperCase()) // "FOO"
                        
                        // 试图改变 length 属性
                        foo.length = 4
                        console.log(foo.length) // 3
                        

                        其实这是 JavaScript 包装类的内容了。

                        在 JavaScript 中除了 nullundefined 之外,所有的基本类型都有其对应的包装对象(Wrapper Object)。因此,访问 nullundefined 的任何属性和方法都会抛出错误。

                        • String 为字符串基本类型。
                        • Number 为数值基本类型。
                        • BigInt 为大整数基本类型。
                        • Boolean 为布尔基本类型。
                        • Symbol 为字面量基本类型。

                        这些包装对象的 valueOf 方法返回其对应的原始值。

                        再次明确一点,原始值是没有任何属性和方法的。

                        不是说好的,原始值不含任何的属性和方法吗?那 foo.lengthfoo.toUpperCase() 是咋回事啊???

                        其实它内部是这样实现的:当字符串字面量调用一个字符串对象才有的方法或属性时,JavaScript 会自动将基本字符串转化为字符串对象并且调用相应的方法或属性。(Boolean 和 Number 也同样如此)。

                        我们尝试在控制台上打印一下 new String('foo'),可以看到该实例对象有一个 length 属性,其值为 3,实例对象本身没有 toUpperCase() 方法,所以接着往原型上查找,果然找到了。(由于原型上方法太多,截图里没有展开,否则影响文章篇幅)

                        因此

                        var foo = 'foo'
                        console.log(foo.length) // 3
                        console.log(foo.toUpperCase()) // "FOO"
                        
                        // 相当于
                        var foo = 'foo'
                        console.log(new String(foo).length) // 3
                        console.log(new String(foo).toUpperCase()) // "FOO"
                        

                        可下面为什么 length 还是 3 呢?

                        foo.length = 4
                        console.log(foo.length) // 3
                        
                        // 怎样理解呢?
                        //
                        //
                        // 执行第一行代码
                        // foo.length = 4 可以拆分成两部分去理解:
                        var temp = new String(foo) // 在内存中创建了一个对象,只是没有一个标识符(变量)指向它而已(为了便于理解,我这里假装有一个 temp 变量指向它)
                        temp.length = 4 // 修改包装对象的 length 属性,其实是修改成功的
                        // 由于该对象并没有被引用,所以在执行下一句代码之前就被回收销毁了
                        //
                        //
                        // 2. 执行第二行代码
                        // console.log(foo.length) 相当于
                        console.log(new String(foo).length) // foo 还是 "foo",自然结果就是 3 了。
                        

                        三、对象

                        在 JavaScript 中,除了以上的原始值,其余都属于对象。

                        与原始类型不同的是,对象是可变(mutable)的。

                        1. 对象的分类

                        我们可以将对象划分为普通对象(ordinary object)和函数对象(function object)。

                        那怎样区分呢?我们先定义一些 Function 实例和 Object 实例:

                        // Function 实例
                        function fn1() {}
                        var fn2 = function() {}
                        var fn3 = new Function('console.log("Hi, everyone")') // 一般不使用 Function 构造器去生成 Function 对象,相比函数声明或者函数表达式,它表现更为低效。
                        
                        // Object 实例
                        var obj1 = {}
                        var obj2 = new Object()
                        var obj3 = new fn1()
                        

                        我们来打印一下结果:

                        typeof Object     // "function"
                        typeof Function   // "function"
                        
                        typeof fn1        // "function"
                        typeof fn2        // "function"
                        typeof fn3        // "function"
                        
                        typeof obj1       // "object"
                        typeof obj2       // "object"
                        typeof obj3       // "object"
                        

                        ObjectFunction 本身就是 JavaScript 中自带的函数对象。其中 obj1obj2obj3 为普通对象(均为 Object 的实例),而 fn1fn2fn3 为函数对象(均是 Function 的实例)。

                        记住以下这句话:

                        所有 Function 的实例都是函数对象,而其他的都是普通对象

                        2. 对象的原型

                        接着,引入两个很容易让人抓狂、混淆的两兄弟 prototype (原型对象)和 __proto__(原型)。这俩兄弟的主要是为了构造原型链而存在的。

                        对象类型 prototype __proto__
                        普通对象
                        函数对象

                        因此有以下结论:

                        所有对象都有 __proto__ 属性,而只有函数对象才具有 prototype 属性。

                        再上几个菜,请慢慢品尝:

                        // 每个对象都有一个 constructor 属性,该属性指向实例对象的构造函数
                        Object.prototype.constructor === Object // true
                        Function.prototype.constructor === Function // true
                        
                        
                        // (全局对象)Object 是 (构造器)Function 的实例
                        // (全局对象)Function 也是 (构造器)Function 的实例
                        Object.__proto__ === Function.prototype // true
                        Function.__proto__ === Function.prototype // true
                        
                        
                        // (构造器)Function 也是(构造器)Object 的实例
                        Function.prototype.__proto__ === Object.prototype // true
                        
                        
                        // 从原型上查找属性,不可能无终止地查找下去,那原型的尽头在哪呢?
                        // 站在原型顶端的男人,是它。
                        // 假设我们访问一个对象的属性或者方法,如若前面的原型上均无法查找到,最终会止步于此,并返回 undefined。
                        Object.prototype.__proto__ // null
                        

                        在 JavaScript 中访问一个对象属性,它在原型上是怎样查找的呢?

                        function Person() {} // 构造函数
                        var person = new Person() // 实例化对象
                        console.log(person.name);  // undefined
                        
                        // 过程如下:
                        person // 是对象,可以继续
                        person['name'] // 不存在属性 name,继续查找
                        person.__proto__ // 是对象,可以继续
                        person.__proto__['name'] // 不存在属性 name,继续查找
                        person.__proto__.__proto__ // 是对象,可以继续
                        person.__proto__.__proto__['name'] // 不存在属性 name,继续查找
                        person.__proto__.__proto__.__proto__ // 不是对象,是 null 值。停止查找,返回 undefined
                        

                        需要注意的是,Object.prototype.__proto__ 从未被包括在 ECMAScript 语言规范中标准化,但它被大多数浏览器厂商所支持。该特性已从 Web 标准中删除,详情可看 Object.prototype.__proto__

                        在标准中,几乎(例外是 Object.create(null) ,下面有说明)每个实例对象内部都有一个 [[Prototype]] 属性,该属性指向对象的原型,而且该属性值只会是对象或者 null

                        在非标准下,可以通过 Object.prototype.__proto__ 访问(或设置)实例对象内部的 [[Prototype]],这种方式其实是不被推荐使用的。现在更被推荐使用的方式是 Objec.getPrototypeOf()/Object.setPrototypeOf()

                        请注意,以上(包括下文)所指对象均不是通过 Object.create(null) 实例化的(除特意说明外)。Object.create(null) 实例化的对象比较特殊,它内部没有 [[Prototype]] 属性,也没有任何其他内部属性。(Object.create()

                        var obj = Object.create(null)
                        
                        var obj1 = Object.create(null)
                        var obj2 = {}
                        
                        obj.__proto__ === undefined // true
                        obj.getPrototypeOf() // 抛出错误 TypeError: obj.getPrototypeOf is not a function
                        

                        我们可以在控制台打印一下,看下两者的区别。

                        JavaScript 常被描述为一种基于原型的语言 —— 每个对象拥有一个原型([[Prototype]]),对象以其原型为模板、从原型继承方法和属性。原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为原型链(prototype chain)。

                        3. 继承

                        关于继承内容,详见 深入 JavaScript 继承原理

                        4. 对象的内部属性(Internal properties)

                        在规范中,对象的内部方法和内部插槽使用双方括号 [[]] 中包含的名称标识,且首字母为大写。例如 [[Prototype]][[Class]][[Extensible]][[Call]][[Scopes]][[FunctionLocation]] 等等。

                        下面挑几个来讲一下:

                        4.1 [[Class]]

                        [[Class]] 是对象的一个内部属性,其值为以下字符串之一:

                        • 常见的有:FunctionObjectArrayBooleanNumberStringSymbolRegExpJSONDateMathErrorArguments 等。
                        • 比较少用的有:BigIntSetWeakSetMapWeakMapReflectPromiseGeneratorFunctionAsyncFunctionWindowIntlWebAssembly,以及派生于 HTMLElement 的(如 HTMLScriptElement )等等。
                        • 几乎所有标准内置对象,都有特定的类型。实在太多了...

                        我们都知道 typeof 无法判断对象的具体类型,无论是 typeof {}typeof []、还是 typeof Math 都返回 "object"。但有了 [[Class]] 属性之后,我们就可以利用它来判断对象的类型了。访问 [[Class]] 的唯一方法是通过默认的 toString() 方法(该方法是通用的):

                        Object.prototye.toString()

                        • 如果参数 undefined,则返回 [object Undefined] 字符串;
                        • 如果参数 null,则返回 [object Null] 字符串;
                        • 如果参数是一个对象,则返回 "[object " + obj.[[Class]] + "]" 字符串,例如 [object Array]
                        • 如果参数是一个原始值,则会先将其转换为相应的对象,然后按照以上的规则输出。

                        以下封装了获取对象类型的方法:

                        function getClass(x) {
                          const { toString } = Object.prototype
                          const str = toString.call(x)
                          return /^\[object (.*)\]$/.exec(str)[1]
                        }
                        
                        getClass(null) // "Null"
                        getClass(undefined) // "Undefined"
                        getClass({}) // "Object"
                        getClass([]) // "Array"
                        getClass(JSON) // "JSON"
                        getClass(() => {}) // "Function"
                        ;(function() { return getClass(arguments) })() // "Arguments"
                        

                        4.2 [[Construct]]

                        一个对象里,如若没有 [[construct]] 属性,是无法使用 new 关键字进行构造的。

                        四、类型转换

                        在 JavaScript 中,我们会经常使用相等运算符(==)去比较两个操作数是否相等。当两个操作数一个是引用类型,另一个是原始类型的时候,前者会先转换为原始类型,再比较。

                        那么,引用类型是如何转换为原始类型的呢?

                        关于 JavaScript 类型转换的内容,详见 JS 数据类型转换详解

                        未完待续...

                        五、参考链接

                        ]]>
                        <![CDATA[解决安卓收起键盘无法触发失焦事件的问题]]> https://github.com/tofrankie/blog/issues/238 https://github.com/tofrankie/blog/issues/238 Sun, 26 Feb 2023 11:06:46 GMT 背景

                        最近在做一个移动端 Web 项目,在首页底部是有一个类似于 APP 导航栏(以下称 FootNav),采用的 fixed 布局固定于底部。同时页面有一些 <input> 输入框(以下称 I]]> 背景

                        最近在做一个移动端 Web 项目,在首页底部是有一个类似于 APP 导航栏(以下称 FootNav),采用的 fixed 布局固定于底部。同时页面有一些 <input> 输入框(以下称 Input)。

                        当聚焦于 Input 时,在 iOS 预期效果是没问题,但是在杀千刀的 Android 上,页面高度发生变化,导致 FootNav 固定在手机键盘上面,同时 FootNav 也直接挡住了输入框,交互体验非常的糟糕。

                        烦死了...

                        iOS 与 Android 键盘弹出收起的表现

                        先了解下背景,键盘的弹出收起,在 iOS 端与 Android 端的 WebView 中表现并非一致的。

                        键盘弹出

                        • iOS 在 iOS 系统的键盘处于窗口的最上层。当键盘弹出时,WebView 的高度 height 并没有发生改变,只是 scrollTop 发生改变。页面可以滚动,且页面可滚动的最大限度为弹出键盘的高度,而只有键盘弹出时页面恰好也滚动到最底部时,scrollTop 的变化值为键盘高度。

                        • Android 在 Android 系统中,键盘也是处于窗口的最上层。键盘弹出时,页面高度发生变化,如果输入框在靠近底部的话,就会被键盘挡住,只有你输入的时候才会滚动到可视化区域。

                        键盘收起

                        • iOS 当触发键盘上按钮收起键盘或者输入框以外的区域时,输入框会失去焦点,因此会触发输入框的 blur 失焦事件。

                        • Android 当触发键盘上的按钮收起键盘时,输入框并不会失去焦点,因此不会触发输入框的 blur 事件;触发输入框以外的区域时,输入框会失去焦点,触发输入框的 blur 事件。

                        由于我并没有过多的深入了解两者的差异表现,以上内容来自此处

                        寻找解决方案

                        针对 Android 设备做处理就行了,iOS 无需处理。

                        方案一

                        处理方式:Input 聚焦隐藏 FootNav,失焦时再将其显示出来。(同理,修改布局方式也是一样的)

                        首先这种处理思路是没毛病的,但是...

                        某些机型、某些输入法,收起键盘并不会触发输入框的 blur 失焦事件,导致该方案直接流产。

                        方案二

                        监听页面高度的变化,利用这一点我们就可以处理 FootNav 的隐藏/显示了。

                        实现

                        思路很简单:首先进入页面时,先记录窗口的原始高度。每当 Input 聚焦时,设置 window.onresize 函数,当窗口宽高发生改变时便会触发。

                        以 React 为例:

                        class Home extends React.Component {
                          constructor(props) {
                            super(props)
                            this.state = {
                              showFootNav: true,
                              initClientHeight: document.documentElement.clientHeight // 记录初始高度
                            }
                          }
                        
                          // 判断 Android 设备
                          isAndroid() {
                            const ua = navigator.userAgent.toLowerCase()
                            return ua.includes('android') || ua.includes('linux')
                          }
                        
                          // 监听窗口变化
                          listenWindowResize() {
                            let that = this
                        
                            if (this.isAndroid()) {
                              window.onresize = () => {
                                const { initClientHeight } = that.state
                                const curClientHeight = document.documentElement.clientHeight // 当前页面高度
                        
                                if (curClientHeight < initClientHeight) {
                                  // 键盘弹出
                                  that.setState({ showFootNav: false })
                                } else {
                                  // 键盘收起
                                  that.setState({ showFootNav: true }, () => {
                                    window.onresize = null // 清空 onresize
                                  })
                                }
                              }
                            }
                          }
                        
                          render() {
                            const { showFootNav } = this.state
                        
                            // 以下 Input、FootNav 为自定义封装的组件
                            return (
                              <div>
                                <Input onFocus={this.listenWindowResize.bind(this)} />
                                {showFootNav ? <FootNav /> : null}
                              </div>
                            )
                          }
                        }
                        

                        本文之外的一些兼容性问题,iOS 也有一些 bug 的,比如微信浏览器里收起键盘之后,页面不回弹,可参考文章

                        参考链接

                        ]]>
                        <![CDATA[解决跨域问题 Response to preflight request doesn't pass access control check]]> https://github.com/tofrankie/blog/issues/237 https://github.com/tofrankie/blog/issues/237 Sun, 26 Feb 2023 11:06:25 GMT 对于前后端分离,跨域问题老生常谈了,问题是这样的:

                        Access to fetch at 'https://xxx/api/user' from origin 'https://xxx/api/user' from origin 'http://h5.xxx.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: The 'Access-Control-Allow-Origin' header contains multiple values 'http://h5.xxx.com, *', but only one is allowed. Have the server send the header with a valid value, or, if an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

                        翻译过来就是:

                        从源 http://h5.xxx.com 访问 https://xxx/api/user 处的访存已被 CORS 策略阻止:对预检请求的响应未通过访问控制检查:Access-Control-Allow-Origin 标头包含多个值 http://h5.xxx.com, *,但仅允许一个。 让服务器发送带有有效值的标头,或者,如果不透明的响应满足您的需要,请将请求的模式设置为 no-cors,以在禁用 CORS 的情况下获取资源。

                        原因是就是在服务器设置了允许跨域,同时又在 Nginx 代理又设置了一次。

                        只要移除其一即可解决。

                        ]]>
                        <![CDATA[细读 JS | this 详解]]> https://github.com/tofrankie/blog/issues/236 https://github.com/tofrankie/blog/issues/236 Sun, 26 Feb 2023 11:04:35 GMT 一、前言

                        我相信很多人会将 this 和作用域混淆在一起理解,其实它们完全是两回事。

                        例如 this.xxxconsole.log(xxx) 有什么不同呢?前者是查找当前 this 一、前言

                        我相信很多人会将 this 和作用域混淆在一起理解,其实它们完全是两回事。

                        例如 this.xxxconsole.log(xxx) 有什么不同呢?前者是查找当前 this 所指对象上的 xxx 属性,后者是在当前作用域链上查找变量 xxx

                        var foo = {
                          bar: 'property', // 属性
                          fn: function () {
                            var bar = 'variable' // 变量
                            console.log(this.bar)
                            // 这里的 this.bar 永远不会取到 bar 变量,
                            // 可能从 foo 对象或 window 对象上查找 bar 属性(视乎函数调用方式)
                          }
                        }
                        foo.fn() // "property"
                        

                        作用域与函数的调用方式无关,而 this 则与函数调用方式相关。

                        this 问题写了两篇文章:

                        二、this 是什么?

                        MDN 上原话是:

                        The this keyword refers to the current object the code is being written inside.

                        翻译过来就是:在 JavaScript 中,关键字 this 指向了当前代码运行时的对象。

                        如果我是刚接触 JavaScript 的话,看到这句话应该会有很多问号???

                        三、为什么需要设计 this?

                        假设我们有一个 person 对象,该对象有一个 name 属性和 sayHi() 方法。然后我们的需求是,sayHi() 方法要打印出如下信息:

                        var person = {
                          name: 'Frankie',
                          sayHi: function() {
                            // 若该方法的功能是,打印出:Hi, my name is Frankie.
                            // 要如何实现?
                          }
                        }
                        
                        1. 方案一

                        最愚蠢的方案,如下:

                        var person = {
                          name: 'Frankie',
                          sayHi: function(name) {
                            console.log('Hi, my name is ' + name + '.')
                          }
                        }
                        
                        person.sayHi(person.name) // Hi, my name is Frankie.
                        
                        1. 方案二

                        方案一在每次调用方法都需要传入 person.name 参数,还不如传入一个 person 参数。试图优化一下:

                        var person = {
                          name: 'Frankie',
                          sayHi: function(context) {
                            console.log('Hi, my name is ' + context.name + '.')
                          }
                        }
                        
                        person.sayHi(person) // Hi, my name is Frankie.
                        

                        先卖个关子,这种方案看着是不是跟 Function.prototype.call() 有点相似?

                        1. 方案三

                        在方案二里方法的调用方式,看着还是有点不雅。能不能把形参 context 也省略掉,然后通过 person.sayHi() 直接调用方法?

                        当然可以,但此时的形参 context 要换成 JavaScript 中的保留关键字 this

                        var person = {
                          name: 'Frankie',
                          sayHi: function() {
                            console.log('Hi, my name is ' + this.name + '.')
                          }
                        }
                        
                        person.sayHi() // Hi, my name is Frankie.
                        

                        但是为什么 this.name 的值等于 person.name 的属性值呢?还有 this 哪来的?

                        原来 this 指向函数运行时所在的环境(执行上下文)。

                        实际上,this 可以理解为函数中的第一个形参(看不见的形参),在你调用 person.sayHi() 的时候,JavaScript 引擎会自动帮你把 person 绑定到 this 上。所以当你通过 this.name 访问属性时,其实就是 person.name

                        四、了解 this

                        到目前为止,好像 this 也没那么神秘,没那么难理解嘛!

                        注意,本小节示例均在非严格模式下,除非有特殊说明。

                        再看:

                        var person = {
                          name: 'Frankie',
                          sayHi: function() {
                            console.log('Hi, my name is ' + this.name + '.')
                          }
                        }
                        
                        var name = 'Mandy'
                        var sayHi = person.sayHi
                        
                        // 写法一
                        person.sayHi() // Hi, my name is Frankie.
                        
                        // 写法二,Why???
                        sayHi() // Hi, my name is Mandy.
                        

                        上面示例中,person.sayHisayHi 明明都指向同一个函数,但为什么执行结果不一样呢?

                        原因就是函数内部使用了 this 关键字。上面提到 this 指的是函数运行时所在的环境。对于 person.sayHi() 来说,sayHi 运行在 person 环境,所以 this 指向了 person;对于 sayHi() 来说,sayHi 运行在全局环境,所以 this 指向了全局环境。(可在 sayHi 函数体内打印 this 来对比)

                        那么,函数的运行环境是怎么决定的呢?

                        1. 内存的数据结构

                        下面先了解一下 JavaScript 内存的数据结构。JavaScript 之所以有 this 的设计,跟内存里面的数据结构有关系。

                        var person = { name: 'Frankie' }
                        

                        上面的示例将一个对象赋值给变量 person。JavaScript 引擎会先在内存里面,生成一个对象 { name: 'Frankie' },然后把这个对象的内存地址赋值给变量 person

                        也就是说,变量 person 是一个地址(reference)。后面如果要读取 person.name,JavaScript 引擎先从 person 拿到内存地址,然后再从该地址读出原始的对象,返回它的 name 属性。

                        原始的对象以字典结构保存,每一个属性名都对应一个属性描述对象。举例来说,上面的例子 name 属性,实际上是以下面的形式保存的。

                        {
                          name: {
                            [[value]]: 'Frankie',
                            [[writable]]: true,
                            [[enumerable]]: true,
                            [[configurable]]: true
                          }
                        }
                        

                        这样的结构是很清晰的,问题在于属性的值可能是一个函数。

                        var person = {
                          sayHi: function() {}
                        }
                        

                        这时,JavaScript 引擎会将函数单独保存在内存中,然后再将函数的地址赋值给 sayHi 属性的 value 属性。

                        {
                          name: {
                            [[value]]: 函数的地址,
                            [[writable]]: true,
                            [[enumerable]]: true,
                            [[configurable]]: true
                          }
                        }
                        

                        由于函数是一个单独的值,所以它可以在不同的环境(上下文)执行。

                        var fn = function() {}
                        var obj = { fn: fn }
                        
                        // 单独执行
                        fn()
                        
                        // obj 环境执行
                        obj.fn()
                        

                        2. 环境变量

                        JavaScript 允许在函数体内部,引用当前环境的其他变量。

                        var sayHi = function() {
                          console.log('Hi, my name is ' + name + '.')
                        }
                        

                        上面示例中,函数体里面使用了变量 name。该变量由运行环境提供。

                        现在问题来了,由于函数可以在不同的运行环境执行,所以需要有一种机制,能够在函数体内部获取当前的运行环境(context)。所以, this 就出现了,它的设计目的就是在函数内部,指代函数当前的运行环境。

                        var sayHi = function() {
                          console.log('Hi, my name is ' + this.name + '.')
                        }
                        

                        上面的示例中,函数体内部的 this.name 就是指当前运行环境的 。

                        var sayHi = function() {
                          console.log('Hi, my name is ' + this.name + '.')
                        }
                        
                        var name = 'Mandy'
                        var person = {
                          name: 'Frankie',
                          sayHi: sayHi
                        }
                        
                        // 单独执行
                        sayHi() // Hi, my name is Mandy.
                        
                        // person 环境执行
                        person.sayHi() // Hi, my name is Frankie.
                        

                        上面的示例中,函数 sayHi 在全局环境执行,this.name 指向全局环境的 name

                        person 环境执行,this.name 指向 person.name

                        回到本小节开头的示例中:

                        var person = {
                          name: 'Frankie',
                          sayHi: function() {
                            console.log('Hi, my name is ' + this.name + '.')
                          }
                        }
                        
                        var name = 'Mandy'
                        var sayHi = person.sayHi
                        
                        // 写法一
                        person.sayHi() // Hi, my name is Frankie.
                        
                        // 写法二,Why???
                        sayHi() // Hi, my name is Mandy.
                        

                        person.sayHi() 是通过 person 找到 sayHi,所以就是在 person 环境执行。一旦 var sayHi = person.sayHi,变量 sayHi 就直接指向函数本身,所以 sayHi() 就变成在全局环境执行。

                        在 JavaScript 中,this 的四种绑定规则,而以上的示例 this 均属于默认绑定的。

                        五、函数调用

                        上面有提到 Function.prototype.call(),下面先聊聊这个方法。

                        顺便提一下,call()apply() 方法类似,区别只有一个,call() 方法接受的是参数列表,而 apply() 方法接受的是一个参数数组

                        call() 允许为不同的对象分配和调用属于一个对象的函数/方法。

                        1. call 语法

                        function.call(thisArg, arg1, arg2, ...)

                        • thisArg(可选) 在 function 函数运行时使用的 this 值。请注意,this 可能不是该方法看到的实际值。 在非严格模式下,若参数指定为 nullundefinedthis 会自动替换为指向全局对象
                        • arg1, arg2, ... 指定的参数列表。

                        2. 使用 call 方法调用函数,且不指定第一参数

                        此时分为两种情况:严格模式和非严格模式,两者在 this 的绑定上有区别。

                        // 非严格模式下
                        var thing = 'world'
                        
                        function hello(thing) {
                          console.log('hello ' + this.thing)
                        }
                        
                        hello.call() // hello world
                        // 等价于以下三种方式,均打印出 hello world
                        hello()
                        hello.call(null)
                        hello.call(undefined)
                        

                        但在严格模式下,this 的值将会是 undefined

                        所以下面示例会报错。

                        // 严格模式下
                        'use strict'
                        
                        var thing = 'world'
                        
                        function hello(thing) {
                          console.log('hello ' + this.thing)
                        }
                        
                        hello.call() // TypeError: Cannot read property 'thing' of undefined
                        

                        3. 普通函数调用(小结)

                        在 JavaScript 中,其实所有的函数原始的调用方式是这样的:

                        function.call(thisArg, arg1, arg2, ...)

                        function fn() { }
                        
                        // 加糖调用
                        fn()
                        fn(x)
                        
                        // 脱糖调用(desugar)& 严格模式
                        fn.call(undefined)
                        fn.call(undefined, x)
                        
                        // 脱糖调用(desugar)& 非严格模式
                        fn.call(window)
                        fn.call(window, x)
                        

                        fn() 其实就是 fn.call() 的语法糖形式。

                        注意,当函数被调用时,函数的 this 值才会被绑定。

                        普通函数调用可以总结成这样:

                        fn(...args)
                        // 等价于
                        fn.call(window [ES5-strict: undefined], ...args)
                        
                        
                        (function() {})()
                        // 等价于
                        (function() {}).call(window [ES5-strict: undefined])
                        

                        4. 成员函数调用(方法)

                        这也是一种非常常见的调用方式,又回到开头的那个示例。

                        var person = {
                          name: 'Frankie',
                          sayHi: function() {
                            console.log('Hi, my name is ' + this.name + '.')
                          }
                        }
                        
                        person.sayHi() // Hi, my name is Frankie.
                        // 等价于
                        person.sayHi.call(person) // Hi, my name is Frankie.
                        

                        根据前面讲述的函数在内存中的数据结构,以上示例跟下面动态地将 sayHi 方法绑定到 person 上是一致的。

                        function sayHi() {
                          console.log('Hi, my name is ' + this.name + '.')
                        }
                        var person = { name: 'Frankie' }
                        var name = 'Mandy'
                        
                        // 动态添加到 person 上
                        person.sayHi = sayHi
                        
                        person.sayHi() // Hi, my name is Frankie.
                        sayHi() // Hi, my name is Mandy.
                        

                        以上两者区别是,前一个例子的 sayHi 函数只能通过变量 person 在内存中根据变量存放的对象地址找到对象,该对象下有一个 sayHi 属性,该属性的 [[value]] 存放的函数地址,所以只能通过 person.sayHi() 进行调用。而后一个例子中 sayHi 函数除了前面提到的调用方式外,还可以直接通过变量 sayHi 去调用该函数(sayHi())。

                        注意,sayHi 函数并没有在编写代码时确定 this 的指向。this 总是在函数被调用时,根据其调用的环境进行设置。

                        六、this 的绑定方式

                        this 是 JavaScript 的一个关键字,它是函数运行时,在函数体内部自动生成的一个对象,只能在函数体内部使用。

                        function fn() {
                          this.x = 1
                        }
                        

                        上面示例中,函数 fn 运行时,内部会自动有一个 this 可以使用。

                        函数的不同使用场景,this 会有不同的值。总的来说,this 就是函数运行时所在的环境对象。

                        1. 普通函数调用(默认绑定)

                        这是函数最常用的用法了,属于全局性调用,因此 this 就代表全局对象。

                        注意,全局对象在不同宿主环境下是有区别的。在浏览器环境下全局对象是 window,而在 Node 环境下,全局对象是 global

                        var x = 1
                        
                        function fn() {
                          console.log(this.x)
                        }
                        
                        fn() // 1
                        

                        注意,如果使用了 ES6 的 letconst 去声明变量 x 时,结果又稍微有点不同了!

                        let x = 1
                        
                        function fn() {
                          // 注意,若严格模式下,this 为 undefined,所以执行 this.x 就会报错
                          console.log(this.x)
                        }
                        
                        fn() // undefined
                        

                        原因很简单。首先在 fn 运行时,this 仍然指向 window,只不过 window 对象下没有 x 属性,所以打印了 undefined

                        为什么会这样呢?

                        // 以下两种方式,其实都往 window 对象添加了 x、y 属性,并赋值。
                        x = 1
                        var y = 2
                        
                        // 但在 ES6 之后,做出了改变
                        // 使用了 let、const 或者 class 来定义变量或类,都不会往顶层对象添加对应属性
                        let x = 1
                        const y = 2
                        class Fn {}
                        

                        关于这块内容,可以看另外一篇文章:关于 var、let 的顶层对象的属性

                        2. 作为对象方法调用(隐式绑定)

                        函数还作为某个对象的方法调用,这时 this 就指向这个上级对象。

                        function fn() {
                          console.log(this.x)
                        }
                        
                        var obj = {
                          x: 1,
                          fn: fn
                        }
                        
                        obj.fn() // 1
                        
                        // 这里我又再次提一下,但不解释了。如果还不懂,从头再看一遍。
                        fn() // undefined
                        

                        3. 通过 call、apply 调用(显式绑定)

                        这两个方法的参数以及区别不再赘述,上面已经讲过了。

                        var x = 0
                        
                        function fn() {
                          console.log(this.x)
                        }
                        
                        var obj = {
                          x: 1,
                          fn: fn
                        }
                        
                        obj.fn.call() // 0,此时 this 指向全局对象
                        obj.fn.call(obj) // 1,此时 this 指向 obj 对象
                        

                        4. 作为构造函数调用(new 绑定)

                        所谓构造函数,就是通过这个函数,可以生成一个新对象。这时,this 就指向这个新对象(实例)。

                        function Fn() {
                          this.x = 1
                        }
                        
                        var obj = new Fn()
                        console.log(obj.x) // 1
                        

                        为了表明这时 this 不是指向全局对象,我们修改一下代码:

                        var x = 'global'
                        
                        function Fn() {
                          this.x = 'local'
                        }
                        
                        var obj = new Fn()
                        
                        console.log(obj.x) // "local"
                        console.log(x) // "global"
                        

                        根据结果,我们可以看到全局变量 x 没有发生变化。

                        以上几种绑定方式,优先级如下:

                        1. 只要使用 new 关键字调用,无论是否还有 callapply 绑定,this 均指向实例化对象。
                        2. 通过 callapply 或者 bind 显式绑定,this 指向该绑定对象(第一个参数缺省时,根据是否为严格模式,this 指向全局对象或者 undefined)。
                        3. 函数通过上下文对象调用,this 指向(最后)调用它的对象。
                        4. 如以上均没有,则会默认绑定。严格模式下,this 指向 undefined,否则指向全局对象。

                        七、其他

                        1. 箭头函数

                        箭头函数,没有自己的 thisargumentssupernew.target,并且它不能用作构造函数。由于没有 this,因此它不能绑定 this

                        const obj = {
                          x: 1,
                          fn1: () => console.log(this.x, this),
                          fn2: function() {
                            console.log(this.x, this)
                          }
                        }
                        
                        obj.fn1() // undefined, Window{...}
                        obj.fn1.call(obj) // undefined, Window{...}
                        obj.fn2() // 1, Object{...}
                        

                        2. 事件处理函数

                        在事件处理函数中,不同的使用方式,会导致 this 指向不同的对象,你知道吗?

                        详见:在事件处理函数中的 this

                        八:思考题

                        抛下两个题,可以分析分析为什么结果不一样。

                        例一:

                        var length = 1
                        
                        function foo() {
                          console.log(this.length)
                        }
                        
                        const obj = {
                          length: 2,
                          bar: function (cb) {
                            cb()
                          }
                        }
                        
                        obj.bar(foo, 'p1', 'p2') // 1
                        

                        例二:

                        var length = 1
                        
                        function foo() {
                          console.log(this.length)
                        }
                        
                        const obj = {
                          length: 2,
                          bar: function (cb) {
                            arguments[0]()
                          }
                        }
                        
                        obj.bar(foo, 'p1', 'p2') // 3
                        

                        九、参考链接

                        ]]>
                        <![CDATA[细读 ES6 | 解构赋值]]> https://github.com/tofrankie/blog/issues/235 https://github.com/tofrankie/blog/issues/235 Sun, 26 Feb 2023 11:03:04 GMT 在 ES6 中,允许按照一定的模式,从数组对象中提取值,对变量进行赋值,这种行为被称为解构(Destructuring)。

                        解构赋值的规则是,只要被解构的值(等号右边的值)不为对象或者数组(如字符]]> 在 ES6 中,允许按照一定的模式,从数组对象中提取值,对变量进行赋值,这种行为被称为解构(Destructuring)。

                        解构赋值的规则是,只要被解构的值(等号右边的值)不为对象或者数组(如字符串、数值、布尔值),就先将其转为对象。但 undefinednull 除外,因为它俩无法转为对象,所以进行解构赋值会报错。

                        本文主要包括:

                        • 数组的解构赋值
                        • 对象的解构赋值
                        • 字符串的解构赋值
                        • 数值和布尔值的解构赋值
                        • 函数参数的解构赋值
                        • 圆括号问题
                        • 用途

                        一、数组的解构赋值

                        只要某种数据结构具有 Iterator 接口(可遍历结构),都可以采用数组形式的解构赋值。JavaScript 中原生具备 Iterator 接口的数据结构如下:

                        • Array
                        • Map
                        • Set
                        • String
                        • TypredArray
                        • 函数的 arguments 对象
                        • NodeList 对象

                        1. 基本用法

                        在 JavaScript 中,我们可以这样为变量进行赋值:

                        // 在 ES5 我们只能直接指定值
                        var a = 1
                        var b = 2
                        var c = 3
                        
                        // 在 ES6 允许这样为变量赋值
                        let [a, b, c] = [1, 2, 3]
                        

                        上面的示例表示,可以从数组中提取值,按照对应位置对变量赋值。

                        本质上,这种写法属于**“模式匹配”**,只要等号两边的模式相同,左边的变量就会被赋予对应的值。

                        下面是一些使用嵌套数组进行解构的例子:

                        let [foo, [[bar], baz]] = [1, [[2], 3]]
                        foo // 1
                        bar // 2
                        baz // 3
                        
                        let [ , , third] = ["foo", "bar", "baz"]
                        third // "baz"
                        
                        let [x, , y] = [1, 2, 3]
                        x // 1
                        y // 3
                        
                        let [head, ...tail] = [1, 2, 3, 4]
                        head // 1
                        tail // [2, 3, 4]
                        
                        let [x, y, ...z] = ['a']
                        x // "a"
                        y // undefined
                        z // []
                        

                        如果解构不成功,变量的值就等于 undefined。以下两种情况都属于解构不成功,foo 的值都会等于 undefined

                        let [foo] = []
                        foo // undefined
                        
                        let [bar, foo] = [1]
                        foo // undefined
                        

                        当等号左边的模式,只匹配一部分等号右边的数组,依然可以解构成功,这种情况属于不完全解构。

                        以下两个例子,都属于不完全解构,但是可以成功。

                        let [x, y] = [1, 2, 3];
                        x // 1
                        y // 2
                        
                        let [a, [b], d] = [1, [2, 3], 4];
                        a // 1
                        b // 2
                        d // 4
                        

                        如果等号右边的值不是数组(或者严格地说,不是可遍历的结果),将会报错。

                        let [foo] = 1
                        let [foo] = false
                        let [foo] = NaN
                        let [foo] = undefined
                        let [foo] = null
                        let [foo] = {}
                        
                        // TypeError: xxx is not iterable
                        

                        上面的语句都会报错,因为等号右边的值,要么转为对象以后不具备 Iterator 接口(前五个表达式),要么本身就不具备 Iterator 接口(最后一个表达式)。

                        上面提到,只要某种数据结构具有 Iterator 接口,都可以采用数组形式的解构赋值。比如 Set 结构,也可以使用数组的解构赋值。

                        let [x, y, z] = new Set(['a', 'b', 'c'])
                        x // "a"
                        

                        下面示例中,gen 是一个 Generator 函数,原生具有 Iterator 接口。解构赋值会依次从这个接口获取值。

                        function* gen() {
                          let a = 0
                          let b = 1
                          while (true) {
                            yield a
                            ;[a, b] = [b, a + b]
                          }
                        }
                        
                        let [first, second, third, fourth, fifth, sixth] = gen()
                        sixth // 5
                        

                        2. 默认值

                        解构赋值允许指定默认值。

                        let [foo = true] = []
                        foo // true
                        
                        let [x, y = 'b'] = ['a']
                        x // "a"
                        y // "b"
                        
                        let [x, y = 'b'] = ['a', undefined]
                        x // "a"
                        y // "b"
                        

                        需要注意的是,ES6 内部使用严格相等运算符(===)判断一个位置是否有值。所以,只有当一个数组成员严格等于 undefined,默认值才会生效。

                        以下示例中,如果一个数组成员是 null,默认值就不会生效,因为 null 不严格等于 undefined

                        let [x = 1] = [undefined]
                        x // 1
                        
                        let [x = 1] = [null]
                        x // null
                        

                        如果默认值是一个表达式,那么这个表达式是惰性求值的,即只有在用到的时候,才会求值。下面示例中,因为 x 能取到值,所以函数 fn 根本不会执行。

                        function fn() {
                          return 'aaa'
                        }
                        let [x = f()] = [1]
                        
                        
                        // 等价于
                        let x
                        if ([1][0] === undefined) {
                          x = fn()
                        } else {
                          x = [1][0]
                        }
                        

                        注意,默认值可以引用解构赋值的其他变量,但该变量必须已经声明。

                        let [x = 1, y = x] = [] // x=1; y=1
                        
                        let [x = 1, y = x] = [2] // x=2; y=2
                        
                        let [x = 1, y = x] = [1, 2] // x=1; y=2
                        
                        let [x = y, y = 1] = [] // ReferenceError: Cannot access 'y' before initialization
                        

                        上面示例中,最后一个表达式会报错,是因为将 y 用做 x 的默认值时,y 还没有声明。

                        二、对象的解构赋值

                        解构不仅可以用于数组,还可以用于对象。

                        1. 基本用法

                        let { foo, bar } = { foo: 'aaa', bar: 'bbb' }
                        foo // "aaa"
                        bar // "bbb"
                        

                        对象的解构与数组有一个重要的的不同。数组的元素是按次序排列的,变量的取值由它的位置决定;而对象的属性是没有次序的,变量必须与属性同名,才能取到正确的值。

                        let { bar, foo } = { foo: 'aaa', bar: 'bbb' }
                        foo // "aaa"
                        bar // "bbb"
                        
                        let { baz } = { foo: 'aaa', bar: 'bbb' }
                        baz // undefined
                        

                        上面的示例中,第一例子等号左边的两个变量的次序,与等号右边两个同名属性的次序不一致,但是对取值完全没有影响。第二个例子的变量没有对应的同名属性,导致取不到值,最后等于 undefined

                        如果解构失败,变量的值等于 undefined。下面的示例中,等号右边的对象中没有 foo 属性,所以变量 foo 取不到值,所以等于 undefined

                        let { foo } = { bar: 'baz' }
                        foo // undefined
                        

                        对象的解构赋值,可以很方便地将现有对象的方法,赋值到某个变量。

                        // 例一,将 Math 对象的对数、正弦、余弦方法赋值到对应的变量上
                        let { log, sin, cos } = Math
                        
                        // 例二,将 console.log 赋值到 log 变量上
                        const { log } = console
                        log('Hello World') // "Hello World"
                        

                        如果变量名与属性名不一致,必须写成下面这样:

                        let { foo: baz } = { foo: 'aaa', bar: 'bbb' };
                        baz // "aaa"
                        
                        let obj = { first: 'hello', last: 'world' };
                        let { first: f, last: l } = obj;
                        f // "hello"
                        l // "world"
                        

                        这实际上说明,对象的解构赋值是下面形式的简写(对象属性的简写形式):

                        let { foo: foo, bar: bar } = { foo: 'aaa', bar: 'bbb' }
                        

                        也就是说,对象的解构赋值的内部机制,是先找到同名属性,然后再赋值给对应的变量。真正被赋值的后者,而不是前者。

                        let { foo: baz } = { foo: 'aaa', bar: 'bbb' }
                        baz // "aaa"
                        foo // ReferenceError: foo is not defined
                        

                        上面的示例中,foo 是匹配的模式,baz 才是变量。真正被赋值的是变量 baz,而不是模式 foo

                        与数组一样,解构也可以用于嵌套结构的对象。

                        let obj = {
                          p: [
                            'Hello',
                            { y: 'World' }
                          ]
                        }
                        
                        let { p: [x, { y }] } = obj
                        x // "Hello"
                        y // "World"
                        

                        注意,此时 p 是模式,不是变量,因此也不会被赋值。如果 p 也要作为变量赋值,也可以写成下面这样。

                        let obj = {
                          p: [
                            'Hello',
                            { y: 'World' }
                          ]
                        };
                        
                        let { p, p: [x, { y }] } = obj
                        x // "Hello"
                        y // "World"
                        p // ["Hello", {y: "World"}]
                        

                        下面是另一个例子,示例中有三次解构赋值,分别对 locstartline 三个属性的解构赋值。注意,最后一次对 line 属性的结构赋值之中,只有 line 是变量,locstart 都是模式,不是变量。

                        const node = {
                          loc: {
                            start: {
                              line: 1,
                              column: 5
                            }
                          }
                        };
                        
                        let { loc, loc: { start }, loc: { start: { line }} } = node
                        line // 1
                        loc  // Object {start: Object}
                        start // Object {line: 1, column: 5}
                        

                        下面是嵌套赋值的例子。

                        let obj = {}
                        let arr = []
                        
                        ;({ foo: obj.prop, bar: arr[0] } = { foo: 123, bar: true })
                        
                        obj // {prop: 123}
                        arr // [true]
                        

                        如果解构模式是嵌套的对象,而且子对象所在的父属性不存在,那么将会报错。 下面的示例中,等号左边对象的 foo 属性,对应一个子对象。该子对象的 bar 属性,解构是会报错。原因很简单,因为 foo 这时等于 undefined,再取子属性就会报错。

                        // TypeError: Cannot read property 'bar' of undefined
                        let { foo: { bar } } = { baz: 'baz' }
                        

                        注意,对象的解构赋值可以取到继承的属性。

                        const obj1 = {}
                        const obj2 = {}
                        Object.setPrototypeOf(obj1, obj2)
                        
                        const { foo } = obj1
                        foo // "bar"
                        

                        上面的示例中,对象 obj1 的原型对象是 obj2foo 属性不是 obj1 自身的属性,而是继承自 obj2 的属性,解构赋值可以取到这个属性。

                        2. 默认值

                        对象的解构也可以指定默认值。

                        let { x = 3 } = {}
                        x // 3
                        
                        let { x, y = 5 } = { x: 1 }
                        x // 1
                        y // 5
                        
                        let { x: y = 3 } = {}
                        y // 3
                        
                        let { x: y = 3 } = { x: 5 }
                        y // 5
                        
                        let { message: msg = 'Something went wrong' } = {}
                        msg // "Something went wrong"
                        

                        默认值生效的条件是,对象的属性值严格等于 undefined

                        let { x = 3 } = { x: undefined }
                        x // 3
                        
                        let { x = 3 } = { x: null }
                        x // null
                        

                        上面的示例中,属性 x 等于 null,因为 nullundefined不严格相等,所以是个有效的赋值,导致默认值3` 不会生效。

                        3. 注意点

                        (1) 如果要将一个已经声明的变量用于解构赋值,必须非常小心。

                        // 错误的写法
                        let x
                        { x } = { x: 1 }
                        // SyntaxError: Unexpected token '='
                        

                        上面示例的写法会报错,因为 JavaScript 引擎会将 { x } 理解成一个代码块,从而发生语法错误。只有不将大括号写在行首,避免 JavaScript 将其解析为代码块,才能解决这个问题。如下:

                        // 正确的写法
                        let x
                        ;({ x } = { x: 1 })
                        
                        // 注意一下,由于我平常的编程偏向于 semicolon-less 风格,所以我行尾是不写分号的,
                        // 但是此时需要注意的是,若行首是以 (、[、/、+、- 开头的时候为了避免出现非预期结果或语法错误,需要手动插入分号(;)
                        // 所以相当于:
                        let x;
                        ({ x } = { x: 1 });
                        

                        上面的示例将整个解构赋值语句,放在一个圆括号里面,就可以正确执行(关于圆括号与解构赋值的关系,下文会提到)。

                        (2) 解构赋值允许等号左边的模式之中,不放置任何变量名。因此,可以写出非常古怪的赋值表达式。

                        ;({} = [true, false])
                        ;({} = 'abc')
                        ;({} = [])
                        

                        以上的表达式虽然毫无意义,但是语法是合法的,可以正常执行。

                        (3) 由于属性本质是特殊的对象,因此可以对数组进行对象属性的解构。

                        let arr = [1, 2, 3]
                        let { 0: first, [arr.length - 1]: last } = arr
                        first // 0
                        lst // 3
                        

                        上面的示例中,对数组进行对象解构。数组 arr0 键对应的值是 1[arr.length - 1] 就是 2 键,对应的值是 3。方括号的这种写法属于“属性名表达式”的表达方式。

                        三、字符串的解构赋值

                        字符串也可以解构赋值,这是因为字符串被转换成了一个类似数组的对象。

                        const  [a, b, c, d, e] = 'hello'
                        a // "h"
                        b // "e"
                        c // "l"
                        d // "l"
                        e // "o"
                        

                        类似数组的对象都有一个 length 属性,因此还可以对这个属性解构赋值。

                        const { length: len } = 'hello'
                        len // 5
                        

                        四、数值和布尔值的解构赋值

                        解构赋值时,如果等号右边是数值和布尔值,则会转为对象。

                        let { toString: s } = 123
                        s === Number.prototype.toString // true
                        
                        let { toString: s } = true
                        s === Boolean.prototype.toString // true
                        

                        上面示例中,数值和布尔值的包装对象都有 toString 属性,因此变量 s 都能取到值。

                        解构赋值的规则是,只要等号右边的值不是对象或数值,就先其转为对象。由于 undefinednull 无法转为对象,所以对它们进行解构赋值,都会报错。

                        let { prop: x } = undefined // TypeError: Cannot destructure property 'prop' of 'undefined' as it is undefined.
                        let { prop: y } = null // TypeError: Cannot destructure property 'prop' of 'null' as it is null.
                        

                        五、函数参数的解构赋值

                        函数的参数也可以使用解构赋值。

                        下面的示例中,函数 add 的参数表面上是一个数组,但在传入参数的那一刻,数组参数就被解构成变量 xy。对于函数内部的代码拉说,它们能感受到的参数就是 xy

                        function add([x, y]) {
                          return x + y
                        }
                        
                        add([1, 2]) // 3
                        

                        下面是另一个例子。

                        ;[[1, 2], [3, 4]].map(([a, b]) => a + b) // [3, 7]
                        

                        函数参数的解构也可以使用默认值。

                        function move({x = 0, y = 0} = {}) {
                          return [x, y]
                        }
                        
                        move({x: 3, y: 8}) // [3, 8]
                        move({x: 3}) // [3, 0]
                        move({}) // [0, 0]
                        move() // [0, 0]
                        

                        上面的示例中,函数 move 的参数是一个对象,通过对这个对象进行解构,得到变量 xy 的值。如果解构失败,xy 等于默认值。

                        注意,下面的写法会得到不一样的结果。

                        function move({x, y} = { x: 0, y: 0 }) {
                          return [x, y]
                        }
                        
                        move({x: 3, y: 8}) // [3, 8]
                        move({x: 3}) // [3, undefined]
                        move({}) // [undefined, undefined]
                        move() // [0, 0]
                        

                        上面的示例,是为函数 move 的参数指定默认值,而不是为变量 xy 指定默认值,所以会得到与前一种写法不同的结果。

                        undefined 就会触发函数才能上的默认值。

                        ;[1, undefined, 3].map((x = 'yes') => x) // [1, 'yes', 3]
                        

                        六、圆括号的问题

                        解构赋值虽然很方便,但是解析起来并不容易。对于编译器来说,一个式子到底是模式,还是表达式,没有办法从一开始就知道,必须解析到(或解析不到)等号才能知道。

                        由此带来的问题是,如果模式中出现圆括号怎么处理?ES6 的规则是,只要有可能导致解构的歧义,就不得使用圆括号。

                        但是,这条规则实际上不那么容易辨别,处理起来相对麻烦。因此,建议只要有可能,就不要在模式中放置圆括号。

                        不能使用圆括号的情况:

                        以下三种解构赋值不得使用圆括号。

                        1. 变量声明语句

                        下面 6 个语句都会报错,因为它们都是变量声明语句,模式不能使用圆括号。

                        let [(a)] = [1]
                        
                        let {x: (c)} = {}
                        let ({x: c}) = {}
                        let {(x: c)} = {}
                        let {(x): c} = {}
                        
                        let { o: ({ p: p }) } = { o: { p: 2 } }
                        

                        2. 函数参数

                        函数参数也属于变量声明,因此不能带有原括号。

                        // 报错:Unexpected token
                        function fn([(z)]) { return z }
                        
                        // 报错:Unexpected token
                        function fn([z, (x)]) { return x }
                        

                        3. 赋值语句的模式

                        // 全部报错:Parenthesized pattern
                        ;({ p: a }) = { p: 42 }
                        ;([a]) = [5]
                        

                        上面的示例将整个模式放在圆括号之中,导致报错。

                        ;[({ p: a }), { x: c }] = [{}, {}]
                        

                        上面代码将一部分模式放在圆括号之中,导致报错。

                        可以使用圆括号的情况:

                        可以使用圆括号的情况只有一种:赋值语句的非模式部分,可以使用圆括号。

                        ;[(b)] = [3] // 正确
                        ;({ p: (d) } = {}) // 正确
                        ;[(parseInt.prop)] = [3] // 正确
                        

                        以上三行语句都可以正确执行,因为它们都是赋值语句,而不是声明语句;其次,它们的圆括号都不属于模式的一部分。第一行语句中,模式是取数组的第一成员,跟圆括号无关;第二行语句中,模式是 p,而不是 d;第三行语句与第一行语句的性质一致。(如果到这里对模式没完全弄清楚的,建议往回再细看一下)

                        七、用途

                        变量的解构赋值用途很多。

                        1. 交换变量的值

                        下面示例交换变量 xy 的值,这样的写法不仅简洁,而且易读,语义非常清晰。

                        let x = 1
                        let y = 1
                        
                        ;[x, y] = [y, x]
                        

                        2. 从函数返回多个值

                        函数只能返回一个值,如果要返回多个值,只能将它们放在数组或者对象里返回。有了结构赋值,取出这些值就非常方便。

                        // 返回一个数组
                        function fn1() {
                          return [1, 2, 3]
                        }
                        let [a, b, c] = fn1()
                        
                        // 返回一个对象
                        function fn2() {
                          return { foo: 1, bar: 2 }
                        }
                        let { foo, bar } = fn2()
                        

                        3. 函数参数的定义

                        解构赋值可以方便地将一组参数与变量名对应起来。

                        // 参数是一组有次序的值
                        function fn1([x, y, z]) { ... }
                        fn1([1, 2, 3])
                        
                        // 参数是一组无次序的值
                        function fn2({x, y, z}) { ... }
                        fn2({z: 3, y: 2, x: 1})
                        

                        4. 提取 JSON 数据

                        解构赋值对提取 JSON 对象中的数据,尤其有用。

                        let jsonData = {
                          id: 42,
                          status: "OK",
                          data: [867, 5309]
                        }
                        
                        let { id, status, data: number } = jsonData
                        
                        console.log(id, status, number) // 42, "OK", [867, 5309]
                        

                        5. 函数参数的默认值

                        指定参数的默认值,就避免了在函数体内部再写 var foo = config.foo || 'default foo'; 这样的语句。

                        jQuery.ajax = function(url, {
                          async = true,
                          beforeSend = function() {},
                          cache = true,
                          complete = function() {},
                          crossDomain = false,
                          global = true,
                          // ... more config
                        } = {}) {
                          // ... do stuff
                        }
                        

                        6. 遍历 Map 结构

                        任何部署了 Iterator 接口的对象,都可以用 for...of 循环遍历。Map 解构原生支持 Iterator 接口,配合变量的解构赋值,获取键名和键值就非常方便。

                        const map = new Map()
                        map.set('first', 'hello')
                        map.set('second', 'world')
                        
                        for (let [key, value] of map) {
                          console.log(key + ' is ' + value)
                        }
                        // first is hello
                        // second is world
                        

                        如果只是想获取键名(或者键值),可以写成下面这样。

                        // 获取键名
                        for (let [key] of map) {
                          // ...
                        }
                        
                        // 获取键值
                        for (let [,value] of map) {
                          // ...
                        }
                        

                        7. 输入模块的指定方法

                        加载模块时,往往需要指定哪些方法。解构赋值使得输入语句非常清晰。

                        const { SourceMapConsumer, SourceNode } = require("source-map")
                        

                        八、参考

                        ]]>
                        <![CDATA[JavaScript 的迷惑行为大赏]]> https://github.com/tofrankie/blog/issues/234 https://github.com/tofrankie/blog/issues/234 Sun, 26 Feb 2023 11:00:53 GMT 今天来聊一聊 JavaScript 中让人摸不着头的设计失误。

                        Brendan Eich 在 1995 年加入 Netscape 公司,当时 Netscape 和 Sun 合作开发一个可运行在浏览器上的编程语言,当时 JavaScript 的开发代号是 Mocha。Brendan Eich 花了 1]]> 今天来聊一聊 JavaScript 中让人摸不着头的设计失误。

                        Brendan Eich 在 1995 年加入 Netscape 公司,当时 Netscape 和 Sun 合作开发一个可运行在浏览器上的编程语言,当时 JavaScript 的开发代号是 Mocha。Brendan Eich 花了 10 天完成了第一版的 JavaScript。

                        由于设计时间太短,语言的一些细节考虑得不够严谨,一些因不可抗因素而无法修复的 bug,加之后来填坑过程中新挖的坑,总之开发者表示很烦...

                        一起看下 JavaScript 设计的「坑」有哪些?

                        本文主要内容源自 JavaScript 的设计失误蒋豪群

                        一、typeof null === 'object'

                        这是一个众所周知的失误。

                        对于刚接触 JavaScript 的朋友,有可能会直觉性地、错误地认为 typeof null === 'null',这是不对的。

                        typeof null === 'object' 的 “bug” 其实是第一版 JavaScript 就存在了,随着 JavaScript 的流行,很多人提议修复这个 bug,但被拒绝了,因为修改它意味着会破坏现有的代码。历史原因可以看下这篇文章:The history of “typeof null”

                        在 JavaScript 中,数据类型在底层都是以二进制形式表示的。在初版 JavaScript 中,以 32 位为单位存储一个值,其中包括一个类型标记(1-3 位)和该值的实际数据。类型标记存储在单元的低位。其中有五个:

                        • 000:object,数据是一个对象的引用。
                        • 1:int,数据是一个 31 位有符号整数。
                        • 010:double,数据是一个双精度浮点数。
                        • 100:string,数据是一个字符串
                        • 110:boolean,数据是一个布尔值。

                        也就是说,最低位如果是 1,那么类型标记长度只有 1 位;如果最低位是 0,那么类型标记长度为 3 位,为四种类型提供两个附加位。

                        有两个特殊的值:

                        • undefined(JSVAL_VOID)是整数 −2^30^(整数范围之外的数字)
                        • null(JSVAL_NULL)是机器码空指针。或:一个对象类型标记加上一个零的引用。(null 二进制表示全是 0)

                        现在我们知道为什么 typeof 会认为 null 是一个对象了,它检查了 null 的类型标记,且类型标记表示 object。以下是该引擎的 typeof 代码。

                        JS_PUBLIC_API(JSType)
                        JS_TypeOfValue(JSContext *cx, jsval v)
                        {
                          JSType type = JSTYPE_VOID;
                          JSObject *obj;
                          JSObjectOps *ops;
                          JSClass *clasp;
                        
                          CHECK_REQUEST(cx);
                          if (JSVAL_IS_VOID(v)) { // (1)
                            type = JSTYPE_VOID;
                          } else if (JSVAL_IS_OBJECT(v)) { // (2)
                            obj = JSVAL_TO_OBJECT(v);
                            if (obj &&
                              (ops = obj -> map -> ops,
                                ops == & js_ObjectOps
                                  ? (clasp = OBJ_GET_CLASS(cx, obj),
                                    clasp -> call || clasp == & js_FunctionClass) // (3,4)
                                  : ops -> call != 0)) { // (3)
                              type = JSTYPE_FUNCTION;
                            } else {
                              type = JSTYPE_OBJECT;
                            }
                          } else if (JSVAL_IS_NUMBER(v)) {
                            type = JSTYPE_NUMBER;
                          } else if (JSVAL_IS_STRING(v)) {
                            type = JSTYPE_STRING;
                          } else if (JSVAL_IS_BOOLEAN(v)) {
                            type = JSTYPE_BOOLEAN;
                          }
                          return type;
                        }
                        

                        上面的代码执行的步骤是:

                        1. 在(1)首先检查值 v 是否 undefined(VOID)。通过 == 比较值是否相同:
                        #define JSVAL_IS_VOID(v)  ((v) == JSVAL_VOID)
                        
                        1. 下一个检查(2)是该值是否具有对象标记。如果它另外可以调用(3)或它的内部属性 [[Class]] 将其标记为一个函数(4),则 v 是一个函数。否则,它是一个对象。这是由 typeof null 产生的结果。
                        2. 随后的检查是数字,字符串和布尔值。甚至没有显式的 null 检查,可以由以下 C 宏执行。
                        #define JSVAL_IS_NULL(v)  ((v) == JSVAL_NULL)
                        

                        这似乎是一个非常明显的错误,但请不要忘记,只有很少的时间来完成 JavaScript 的第一个版本。

                        Brendan Eich 在 Twitter 表示这是一个 abstraction leak,可理解为变相承认这是代码的 bug。

                        null means "no object", undefined =>"no value". Really it's an abstraction leak: null and objects shared a Mocha type tag.

                        下面列出各种数据类型 typeof 对应的结果:

                        Operand Result
                        undefinded "undefined"
                        null "object"
                        Boolean value "boolean"
                        Number value "number"
                        BigInt value (ES11) "bigint"
                        String value "string"
                        Symbol value (ES6) "symbol"
                        宿主对象(由 JS 环境提供) 取决于具体实现
                        Function "function"
                        All other values "object"

                        typeof returning "object" for null is a bug. It can’t be fixed, because that would break existing code. Note that a function is also an object, but typeof makes a distinction. Arrays, on the other hand, are considered objects by it.

                        某文章表示:

                        在 JavaScript V8 引擎中,针对 typeof null === 'object' 这种“不规范”情况,对 null 提前做了一层判断。假设在 V8 中把这行代码删掉, typeof null 会返回 undefined

                        GotoIf(InstanceTypeEqual(instance_type, ODDBALL_TYPE), &if_oddball);
                        

                        好了,关于 typeof null === 'object' 的话题告一段落。

                        二、typeof NaN === 'number'

                        不确定这个算不算一个设计失误,但毫无疑问这是反直觉的。

                        关于 NaN,还有一些很有趣的知识点,推荐一个 Slide,非常值得一看:Idiosyncrasies of NaN v2

                        三、NaN、isNaN()、Number.isNaN()

                        在 JavaScript 中,NaN 是一个看起来很莫名其妙的存在。当然 NaN 不是只有 JavaScript 才存在的。其他语言也是有的。

                        我觉得应该是这样:"NaN" actually stands for "Not a NaN".

                        1. NaN

                        NaN 是一个全局对象属性,其属性的初始值就是 NaN,和 Number.NaN的值一样。

                        NaN 是 JavaScript 中唯一一个不等于自身的值。虽然这个设计其实理由很充分(参照前面推荐的那个 Slide,在 IEEE 754 规范中有非常多的二进制序列都可以被当做 NaN,所以任意计算出两个 NaN,它们在二进制表示上很可能不同),但不管怎样,这个还是非常值得吐槽...

                        NaN == NaN // false
                        NaN === NaN // false
                        Number.NaN === NaN // false
                        

                        2. isNaN()

                        isNaN() 是全局对象提供的一个方法,它的命名和行为非常让人费解:

                        • 它并不只是用来判断一个值是否为 NaN,因为所有对于所有非数字类型的值它也返回 true
                        • 但也不能说它是用来判断一个值是否为数值的,因为根据前文,NaN 的类型是 number,应当被认为是一个数值。

                        isNaN() 方法,当参数值是 NaN 或者将参数转换为数字的结果为 NaN,则返回 true,否则返回 false。因此,它不能用来判断是否严格等于 NaN

                        isNaN(NaN) // true
                        isNaN('hello world') // true
                        

                        3. Number.isNaN()

                        ES6 提供了 Number.isNaN() 方法,用于判断一个值是否严格等于 NaN,终于是拨乱反正了。

                        和全局函数 isNaN() 相比,Number.isNaN() 不会自行将参数转换成数组,它会先判断参数是否为数字类型,如不是数字类型则直接返回 false,接着判断参数值是否为 NaN,若是则返回 true

                        Number.isNaN(NaN) // true
                        Number.isNaN(Number.NaN) // true
                        Number.isNaN(0 / 0) // true
                        Number.isNaN('hello world') // false
                        Number.isNaN(undefined) // false
                        

                        4. 总结几种判断值是否为 NaN 的方法

                        // 1. 利用 NaN 的特性,JavaScript 中唯一一个不等于自身的值
                        function myIsNaN(v) {
                          return v !== v
                        }
                        
                        // 2. 利用 ES5 的 isNaN() 全局方法
                        function myIsNaN(v) {
                          return typeof v === 'number' && isNaN(v)
                        }
                        
                        // 3. 利用 ES6 的 Number.isNaN() 方法
                        function myIsNaN(v) {
                          return Number.isNaN(v)
                        }
                        
                        // 4. 利用 ES6 的 Object.is() 方法
                        function myIsNaN(v) {
                          return Object.is(v, NaN)
                        }
                        

                        四、==、=== 与 Object.is()

                        JavaScript 是一种弱类型语言,存在隐式类型转换。因此,== 的行为非常令人费解。

                        [] == ![] // true
                        2 == '2' // true
                        

                        所以,各种 JavaScript 书籍都推荐使用 === 替代 ==(仅在 null checking 之类的情况除外)。

                        但事实上, === 也并不总是靠谱,它至少存在两类例外情况。(Stricter equality in JavaScript

                        // 1. 前文提到的 NaN
                        NaN === NaN // false
                        
                        // 2. +0 与 -0 两者其实是不相等的值 👇
                        +0 === -0 // true
                        // 因为
                        1 / +0 === Infinity // true
                        1 / -0 === -Infinity // true
                        Infinity === -Infinity // false
                        
                        
                        // ES6 是提供的方法
                        Object.is(NaN, NaN) // true
                        Object.is(+0, -0) // false
                        

                        直到 ES6 才有一个可以比较两个值是否严格相等的方法:Object.is(),它对于 === 的这两者例外都做了正确的处理。

                        如果 ES6 以下,这样实现 Object.is()

                        function myObjectIs (x, y) {
                          if (x === y) {
                            // x === 0 => compare via infinity trick
                            return x !== 0 || (1 / x === 1 / y)
                          }
                        
                          // x !== y => return true only if both x and y are NaN
                          return x !== x && y !== y
                        }
                        

                        关于 ===== 部分值的比较,可以看下 JavaScript-Equality-Table

                        Always use 3 equals unless you have a good reason to use 2.(除非您有充分的理由 ==,否则始终使用 ===

                        五、分号自动插入机制(ASI)

                        此前还专门针对 ASI 内容写了一篇文章:JavaScript ASI 机制详解,不用再纠结分号问题

                        1. Restricted Productions

                        据 Brendan Eich 称,JavaScript 最初被设计出来时,上级要求这个语言的语法必须像 Java。所以跟 Java 一样,JavaScript 的语句在解析时,是需要分号分隔的。但是后来出于降低学习成本,或者提高语言的容错性的考虑,他在语法解析中加入了分号自动插入的纠正机制。

                        这个做法的本意当然是好的,有不少其他语言也是这么处理的(比如 Swift)。但是问题在于,JavaScript 的语法设计得不够安全,导致 ASI 有不少特殊情况无法处理到,在某些情况下会错误地加上分号(在标准文档里这些被称为 Restricted Productions)。

                        最典型的是 return 语句:

                        // returns undefined
                        return
                        {
                          name: 'Frankie'
                        }
                        
                        // returns { name: 'Frankie' }
                        return {
                          name: 'Frankie'
                        }
                        

                        这导致了 JavaScript 社区写代码时花括号都不换行,这在其他编程语言社区是无法想象的。

                        2. 漏加分号的问题

                        有好几种情况要注意(更多 ASI 详情看上面推荐的文章),比如:

                        // 假设源码是这样的
                        var a = function (x) { console.log(x) }
                        (function () {
                          console.log('do something')
                        })()
                        
                        // 在 JS 解析器的眼里却是这样的,所以这段代码会报错
                        var a = function (x) { console.log(x) }(function () {
                          console.log('do something')
                        })()
                        

                        3. semicolon-less

                        由于以上这些已经是语言特性了,并且无法绕开,无论怎样我们都需要去学习掌握。

                        对于使用 semicolon-less 风格的朋友,注意一下 5 种情况就可以了:

                        如果一条语句是以 ([/+- 开头,那么就要注意了。根据 JavaScript 解析器的规则,尽可能读取更多 token 来构成一个完整的语句,而以上这些 token 极有可能与前一个 token 可组成一个合法的语句,所以它不会自动插入分号。

                        实际项目中,以 /+- 作为行首的代码其实是很少的,([ 也是较少的。**当遇到这些情况时,通过在行首手动键入分号 ; 来避免 ASI 规则产生的非预期结果或报错。**这样的记忆成本和出错概率远低于强制分号风格。

                        还有,ESLint 中有一条规则 no-unexpected-multiline 哦,这样就几乎没有什么负担了。

                        六、Falsy values

                        在 JavaScript 中至少有七种假值(在条件表达式中与 false 等价):00nnullundefinedfalse'' 以及 NaN。(其中 0n 是 BigInt 类型的值)

                        以上六种假值均可通过 Double Not 运算符(!!)来显示转换成 Boolean 类型的 false 值。

                        七、+、- 操作符相关的隐式类型转换

                        大致可以这样记:作为二元操作符的 + 会尽可能地把两边的值转为字符串,而 - 和作为一元操作符的 + 则会尽可能地把值转为数字。

                        ('foo' + + 'bar') === 'fooNaN' // true
                        '3' + 1 // '31'
                        '3' - 1 // 2
                        '222' - - '111' // 333
                        

                        注意: + 两侧只要有一侧是字符串,另一侧的数字则会自动转换成字符串,因为其中存在隐式转换。

                        八、null、undefined 以及数组的 “holes”

                        在一个语言中同时有 null 和 undefined 两个表示空值的原生类型,乍看起来很难理解,不过这里有一些讨论可以一看:

                        不过数组里的 "holes" 就非常难以理解了。

                        产生 holes 的方法有两种:一是定义数组字面量时写两个连续的逗号:var a = [1, , 2];二是使用 Array 对象的构造器:new Array(3)

                        数组的各种方法对于 holes 的处理非常非常非常不一致,有的会跳过(forEach),有的不处理但是保留(map),有的会消除掉 holes(filter),还有的会当成 undefined 来处理(join)。这可以说是 JavaScript 中最大的坑之一,不看文档很难自己理清楚。

                        具体可以参考这两篇文章:

                        九、 Array-like objects

                        在 JavaScript 中,类数组但不是数组的对象不少,这类对象往往有 length 属性、可以被遍历,但缺乏一些数组原型上的方法,用起来非常不便。比如在为了能让 arguments 对象用上 Array.prototype.shift() 方法,我们往往需要先写这样一条语句,非常不便。

                        var args = Array.prototype.slice.apply(arguments)
                        

                        在 ES6 中,arguments 对象不再被建议使用,我们可以用 Rest parametersconst fn = (...args) => {}),这样拿到的对象(args)就直接是数组了。

                        不过在语言标准之外,DOM 标准中也定义了不少 Array-like 的对象,比如 NodeList 和 HTMLCollection。对于这些对象,在 ES6 中我们可以用 spread operator 处理:

                        const nodeList = document.querySelectorAll('div')
                        const nodeArray = [...nodeList]
                        
                        console.log(Object.prototype.toString.call(nodeList))   // [object NodeList]
                        console.log(Object.prototype.toString.call(nodeArray))   // [object Array]
                        

                        1. arguments

                        在非严格模式下(sloppy mode)下,对 argument 赋值会改变对应的形参

                        可以看下这篇文章:JavaScript 严格模式详解(4-8-2 小节)

                        function foo(x) {
                          console.log(x === 1) // true
                          arguments[0] = 2
                          console.log(x === 2) // true
                        }
                        
                        function bar(x) {
                          'use strict'
                          console.log(x === 1) // true
                          arguments[0] = 2
                          console.log(x === 2) // false
                        }
                        
                        foo(1)
                        bar(1)
                        

                        十、函数作用域与变量提升(Variable hoisting)

                        1. 函数作用域

                        蝴蝶书上的例子想必大家都看过:

                        // The closure in loop problem
                        for (var i = 0; i !== 10; ++i) {
                          setTimeout(function() { console.log(i) }, 0)
                        }
                        

                        函数级作用域本身没有问题,但是如果只能使用函数级作用域的话,在很多代码中它会显得非常反直觉。比如上面的这个循环例子,对于程序员来说,根据花括号的违章确定变量作用域远比找到外层函数容易得多。

                        在以前,要解决这个问题,我们只能使用闭包 + IIFE 产生一个新作用域,代码非常难看(其实 with 以及 catch 语句后面跟的代码块也算是块级作用域,但这并不通用)。

                        幸而现在 ES2015 引入了 let / const,让我们终于可以用上真正的块级作用域。

                        2. 变量提升

                        JavaScript 引擎在执行代码的时候,会先处理作用域内所有的变量声明,给变量分配空间(在标准里叫 binding),然后在再执行代码。

                        这本来没什么问题,但是 var 声明在被分配空间的同时也会被初始化成 undefined(ES5 中的 CreateMutableBinding),这就相当于把 var 声明的变量提升到了函数作用域的开头,也就是所谓的 “hoisting”。

                        ES6 中引入的 letconst 则实现了 temporal dead zone,虽然进入作用域时用 letconst 声明的变量也会被分配空间,但不会被初始化。在初始化语句之前,如果出现对变量的引用,会抛出 ReferenceError 错误。

                        // without TDZ
                        console.log(a) // undefined
                        var a = 1
                        
                        // with TDZ
                        console.log(b) // ReferenceError
                        let b = 2
                        

                        在标准层面,这是通过把 CreateMutableBing 内部方法分拆成 CreateMutableBinding 和 InitializeBinding 两步实现的,只有 VarDeclaredNames 才会执行 InitializeBinding 方法。

                        3. let、const

                        然而,letconst 的引入也带来了一个坑。主要是这两个关键词的命名不够精确合理。

                        const 关键词所定义的是一个 immutable binding(类似于 Java 的 final 关键词),而非真正的常量(constant),这一点对于很多人来说也是反直觉的。

                        ES6 规范的主笔 Allen Wirfs-Brock 在 ESDiscuss 的一个帖子里表示,如果可以从头再来的话,他会更倾向于选择 let var / let 或者 mut / let 替代现在的这两个关键词,可惜这只能是一个美好的空想了。

                        4. for...in

                        for...in 的问题在于它会遍历到原型链上的属性,这个大家应该都知道的,使用时需要加上 obj.hasOwnProperty(key) 判断才安全。

                        在 ES6+ 中,使用 for(const key of Object.keys(obj)) 或者 for(const [key, value] of Object.entries()) 可以绕开这个问题。

                        顺便提一下 Object.keys()Object.getOwnPropertyNames()Reflect.ownKeys() 的区别:我们最常用的一般是 Object.keys() 方法,Object.getOwnPropertyNames() 会把 enumerable: false 的属性名也会加进来,而 Reflect.ownKeys() 在此基础上还会加上 Symbol 类型的键。

                        5. with

                        最主要的问题在于它依赖运行时语义,影响优化。

                        此外还会降低程序可读性、易出错、易泄露全局变量。

                        function fn(foo, length) {
                          with(foo) {
                            console.log(length)
                          }
                        }
                        fn([1, 2, 3], 222) // 3
                        

                        6. eval

                        eval 的问题不在于可以动态执行代码,这种能力无论如何也不能算是语言的缺陷。

                        7. Scope

                        它的第一个坑在于传给 eval 作为参数的代码段能够接触到当前语句所在的闭包。

                        而用 new Function 动态执行的代码就不会有这个问题。因为 new Function 所生成的函数是确保执行在最外层作用域下的(严格来说标准里不是这样定义的,但实际效果基本可以看作等同,除了 new Function 中可以获取到 arguments 对象)。

                        function test1() {
                          var a = 11
                          eval('(a = 22)')
                          console.log(a) // 22
                        }
                        
                        function test2() {
                          var a = 11
                          new Function('return (a = 22)')()
                          console.log(a) // 11
                        }
                        

                        8. 直接调用 vs 间接调用(Direct Call vs Indirect Call)

                        第二个坑是直接调用 eval 和间接调用的区别。

                        事实上,但是「直接调用」的概念就足以让人迷糊了。

                        首先,eval 是全局对象上的一个成员函数

                        但是,window.eval() 这样的调用 不算是 直接调用,因为这个调用的 base 是全局对象而不是一个 "environment record"

                        接下来的就是历史问题了:

                        直接调用和间接调用最大的区别在于他们的作用域不同:

                        function test() {
                          var x = 2, y = 4
                          console.log(eval("x + y"))    // Direct call, uses local scope, result is 6
                          var geval = eval;
                          console.log(eval("x + y"))   // Indirect call, uses global scope, throws ReferenceError because `x` is undefined
                        }
                        

                        间接调用 eval 最大的用处(可能也是唯一的实际用处)是在任意地方获取到全局对象(然而 Function('return this')() 也能做到这一点): javascript // 即使是在严格模式下也能起作用 var global = ("indirect", eval)("this");

                        未来,如果 Jordan Harband 的 System.global 提案能进入到标准的话,这最后一点用处也用不到了……

                        十一、非严格模式下,赋值给未声明的变量会导致产生一个新的全局变量

                        1. Value Properties of the Global Object

                        平常我们使用到的 NaNInfinityundefined 并不是作为原始值被使用的,而是定义在全局对象上的属性名

                        在 ES5 之前,这几个属性甚至可以被覆盖,直到 ES5 之后它们才被改成 non-configurable、non-writable。

                        然而,因为这几个属性名都不是 JavaScript 的保留字,所以可以被用来当做变量名使用。即使全局变量上的这几个属性不可被更改,我们仍可以在自己的作用域里面对这几个名字进行覆盖。

                        (function () {
                          var undefined = 'foo'
                          console.log(undefined, typeof undefined) // "foo" "string"
                        })()
                        

                        2. Stateful RegExps

                        JavaScript 中,正则对象上的函数是有状态的:

                        const re = /foo/g
                        console.log(re.test('foo bar')) // true
                        console.log(re.test('foo bar')) // false
                        

                        这使得这些方法难以调试,无法做到线程安全。

                        Brendan Eich 的说法是这些方法来自于 90 年代的 Perl 4,那时候并没有想到这么多

                        未完待续...

                        十二、参考链接

                        ]]>
                        <![CDATA[随机打乱数组]]> https://github.com/tofrankie/blog/issues/233 https://github.com/tofrankie/blog/issues/233 Sun, 26 Feb 2023 11:00:09 GMT 配图源自 Freepik

                        本文介绍三种数组乱序的方式:

                        • Array.proto]]> 配图源自 Freepik

                          本文介绍三种数组乱序的方式:

                          • Array.prototype.sort
                          • Fisher–Yates Shuffle
                          • Knuth-Durstenfeld Shuffle

                          若要实现随机打乱数组的需求,不建议使用 arr.sort(() => Math.random() - 0.5) 。目前用得较多的是 Knuth-Durstenfeld Shuffle 算法(洗牌算法),前端常用的 Lodash 库里面的 _.shuffle() 也是使用了这种算法。

                          Array.prototype.sort 排序

                          最简单的乱序实现:

                          function randomShuffle(arr) {
                            return arr.sort(() => Math.random() - 0.5)
                          }
                          

                          但实际上这种方法并不能真正的随机打乱数组。在多次执行后,每个元素有很大几率还在它原来的位置附近出现。详见:常用的 sort 打乱数组方法真的有用?

                          Fisher–Yates Shuffle(Fisher and Yates' original method)

                          由 Ronald Fisher 和 Frank Yates 提出的 Fisher–Yates shuffle 算法思想,大致如下:

                          假设有一个长度为 N 的数组:

                          1. 从第 1 个到剩余的未删除项(包含)之间选择一个随机数 k。
                          2. 从剩余的元素中将第 k 个元素删除并取出,放到新数组中。
                          3. 重复第 1、2 步直到所有元素都被删除。
                          4. 最终将新数组返回

                          实现

                          function shuffle(arr) {
                            let random
                            const newArr = []
                          
                            while (arr.length) {
                              random = Math.floor(Math.random() * arr.length)
                              newArr.push(arr[random])
                              arr.splice(random, 1)
                            }
                          
                            return newArr
                          }
                          

                          举例

                          假设我们有 1 ~ 8 的数字

                          表格每列分别表示:范围、随机数(被移除数的位置)、剩余未删除的数、已随机排列的数。

                          Range Roll Scratch Result
                          1 2 3 4 5 6 7 8

                          现在,我们从 1 ~ 8 中随机选择一个数,得到随机数 k 为 3,然后在 Scratch 上删除第 k 个数字(即数字 3),并将其放到 Result 中:

                          Range Roll Scratch Result
                          1 - 8 3 1 2 3 4 5 6 7 8 3

                          现在我们从 1 ~ 7 选择第二个随机数 k 为 4,然后在 Scratch 上删除第 k 个数字(即数字 5),并将其放到 Result 中:

                          Range Roll Scratch Result
                          1 - 7 4 1 2 3 4 5 6 7 8 3 5

                          现在我们从 1 ~ 6 选择下一个随机数,然后从 1 ~ 5 选择依此类推,总是重复上述过程:

                          Range Roll Scratch Result
                          1–6 5 1 2 3 4 5 6 7 8 3 5 7
                          1–5 3 1 2 3 4 5 6 7 8 3 5 7 4
                          1–4 4 1 2 3 4 5 6 7 8 3 5 7 4 8
                          1–3 1 1 2 3 4 5 6 7 8 3 5 7 4 8 1
                          1–2 2 1 2 3 4 5 6 7 8 3 5 7 4 8 1 6
                          1 2 3 4 5 6 7 8 3 5 7 4 8 1 6 2

                          Knuth-Durstenfeld Shuffle(The modern algorithm)

                          Richard Durstenfeld 于 1964 年推出了现代版本的 Fisher–Yates shuffle,并由 Donald E. Knuth 在 The Art of Computer Programming 以 “Algorithm P (Shuffling)” 进行了推广。Durstenfeld 所描述的算法与 Fisher 和 Yates 所给出的算法有很小的差异,但意义重大。

                          -- To shuffle an array a of n elements (indices 0..n-1):  
                          for i from n−1 downto 1 do  // 数组从 n-1 到 0 循环执行 n 次
                            j ← random integer such that 0 ≤ j ≤ i  // 生成一个 0 到 n-1 之间的随机索引
                            exchange a[j] and a[i] // 将交换之后剩余的序列中最后一个元素与随机选取的元素交换
                          

                          Durstenfeld 的解决方案是将“删除”的数字移至数组末尾,即将每个被删除数字与最后一个未删除的数字进行交换

                          实现

                          function shuffle(arr) {
                            let i = arr.length
                          
                            while (--i) {
                              let j = Math.floor(Math.random() * i)
                              ;[arr[j], arr[i]] = [arr[i], arr[j]]
                            }
                          
                            return arr
                          }
                          

                          Knuth-Durstenfeld Shuffle 将算法的时间复杂度降低到 O(n),而 Fisher–Yates shuffle 的时间复杂度为 O(n2)。后者在计算机实现过程中,将花费不必要的时间来计算每次剩余的数字(可以理解成数组长度)。

                          举例

                          同样,假设我们有 1 ~ 8 的数字

                          表格每列分别表示:范围、当前随机数(即随机交互的位置)、剩余未交换的数、已随机排列的数。

                          Range Roll Scratch Result
                          1 2 3 4 5 6 7 8

                          我们从 1 ~ 8 中随机选择一个数,得到随机数 k 为 6,然后交换 Scratch 中的第 6 和第 8 个数字:

                          Range Roll Scratch Result
                          1 - 8 6 1 2 3 4 5 8 7 6

                          接着,从 1 ~ 7 中随机选择一个数,得到随机数 k 为 2,然后交换 Scratch 中的第 2 和第 7 个数字:

                          Range Roll Scratch Result
                          1 - 7 6 1 7 3 4 5 8 2 6

                          继续,下一个随机数是1 ~ 6,得到的随机数恰好是 6,这意味着我们将列表中的第 6 个数字保留下来(经过上面的交换,现在是 8),然后移到下一个步。同样,我们以相同的方式进行操作,直到完成排列:

                          Range Roll Scratch Result
                          1 - 6 6 1 7 3 4 5 8 2 6
                          1 - 5 1 5 7 3 4 1 8 2 6
                          1 - 4 3 5 7 4 3 1 8 2 6
                          1 - 3 3 5 7 4 3 1 8 2 6
                          1 - 2 1 7 5 4 3 1 8 2 6

                          因此,结果是 7 5 4 3 1 8 2 6

                          参考链接

                          ]]>
                          <![CDATA[JavaScript 严格模式详解]]> https://github.com/tofrankie/blog/issues/232 https://github.com/tofrankie/blog/issues/232 Sun, 26 Feb 2023 10:58:07 GMT 一、概念

                          ECMAScript 5 添加了第二种运行模式:ECMAScript 5 添加了第二种运行模式:严格模式(strict mode)。顾名思义,这种模式使得 JavaScript 在更严格的条件下运行。

                          与之相反的非严格模式,被称为“sloppy mode”,也称作“正常模式”。因为翻译原因,正常模式也被翻译为 —— 马虎模式/稀松模式/懒散模式。但这并不是一个官方术语,但是你会经常见到如上的一些说法,其意义就是指代非严格模式,即正常模式。

                          设立严格模式的目的,主要有以下几个:

                          • 消除 JavaScript 语法的一些不合理、不严谨之处,减少一些怪异行为
                          • 消除代码运行的一些不安全之处,保证代码运行的安全
                          • 提高编译器效率,增加运行速度
                          • 为未来新版本的 JavaScript 做好铺垫

                          严格模式体现了 JavaScript 更合理、更安全、更严谨的发展方向,包括 IE10 在内的主流浏览器都已经支持它,许多大项目已经开始全面拥抱它。

                          另外,同样的代码在不同的模式下可能会有不一样的运行结果;一些在正常模式下可以运行的语句,在严格模式下可能不能运行。

                          二、启用严格模式

                          启用严格模式很简单,就一行语句。

                          'use strict'
                          

                          不支持该模式的浏览器,会把它当作一行普通字符串,加以忽略。

                          三、调用严格模式

                          严格模式可以应用到整个脚本个别函数中。不要在封闭大括弧 {} 内这样做,在这样的上下文中这么做是没有效果的。

                          严格模式有两种调用方法,适用于不同的场合。

                          1. 针对整个脚本文件

                          'use strict' 放在脚本文件的第一行,则整个脚本都将以严格模式运行。如果这行语句不在第一行,则无效,整个脚本以正常模式运行。

                          如果不同模式的代码文件合并成一个文件,这一点需要特别注意。

                          <script>
                            'use strict'
                            console.log('这是严格模式!')
                          </script>
                          
                          <script>
                            console.log('这是正常模式!')
                          </script>
                          

                          上述代码表示,一个网页中依次有两段 JavaScript 代码。前一个 <script> 标签是严格模式,后一个是非严格模式。

                          2. 针对单个函数

                          'use strict' 放在函数体的第一行,则整个函数以严格模式运行。

                          function strict() {
                            "use strict";
                            return '这是严格模式!'
                          }
                          
                          function notStrict() {
                            return '这是正常模式!'
                          }
                          

                          3. 脚本文件的变通写法

                          因为第一种调用方法不利于文件合并,所以更好的做法是借用第二种方法,将整个脚本文件放在一个立即执行的匿名函数之中。

                          void (function () {
                            'use strict'
                            // ...
                          })()
                          

                          4. 关于 'use strict' 放在 Program 或 FunctionBody 的第一行问题

                          严格地说,只要前面不是产生实际运行结果的语句,'use strict' 可以不在第一行,比如前面包括一些注释、或者是一些 JS 引擎无法识别的指令序言等。

                          例如:

                          function fn() {
                            'use bar'
                            'abc'
                            'use strict' // 因为这完全符合指令序言 — 多指令共存的语法. 所被应用的代码仍然会进入严格模式。
                          }
                          

                          ES5 会把 'use bar''abc' 也作为指令序言的某个指令处理,由于 JS 引擎不认识该指令,只认识 'use strict' 指令,则同样会进入严格模式.

                          四、严格模式对于语法和行为的改变

                          严格模式对 JavaScript 的语法和行为,都做了一些改变。

                          1. 全局变量显式声明

                          在正常模式中,如果一个变量没有声明就赋值,默认是全局变量。严格模式禁止这种用法,全局变量必须显式声明。

                          'use strict'
                          
                          name = 'Frankie' // Uncaught ReferenceError: name is not defined
                          
                          for (i = 0; i < 2; i++) { // Uncaught ReferenceError: i is not defined
                            // ...
                          }
                          
                          // 上述代码在正常模式下,是可以正常运行的,而在严格模式下就会报错(引用类型错误)
                          

                          因此,严格模式下变量都必须先声明再使用。抛开 JavaScript 设计的不合理、缺陷、甚至是 Bug,或者是其他看起来很反人类的东西,在了解历史原因和其中原理之后,为了代码可读性都理应如此。

                          2. 静态绑定

                          JavaScript 语言的一个特点,就是允许“动态绑定”,即某些属性和方法到底属于哪一个对象,不是在编译时确定的,而是在运行时(runtime)确定的。

                          严格模式对动态绑定做了一些限制。某些情况下,只允许静态绑定。也就是说,属性和方法到底归属哪个对象,在编译阶段就确定。这样做有利于编译效率的提高,也使得代码更容易阅读,更少出现意外。

                          具体来说,涉及以下几个方面。

                          (1)禁止使用 with 语句

                          因为 with 语句无法在编译时就确定,属性到底归属哪个对象。

                          'use strict'
                          
                          var obj = {
                            name: 'Frankie'
                          }
                          
                          // 语法错误,Uncaught SyntaxError: Strict mode code may not include a with statement
                          with (obj) {
                            name = 'Mandy'
                          }
                          

                          (2)创设 eval 作用域

                          正常模式下,JavaScript 语言有两种变量作用域(scope):全局作用域函数作用域。严格模式创设了第三种作用域:eval 作用域

                          正常模式下,eval 语句的作用域,取决于它处于全局作用域,还是处于函数作用域。严格模式下,eval 语句本身就是一个作用域,不再能够生成全局变量了,它所生成的变量只能用于 eval 内部。

                          'use strict'
                          
                          var name = 'Frankie'
                          console.log(eval("var name = 'Mandy'; name")) // "Mandy"
                          console.log(name) // "Frankie"
                          

                          3. 增强的安全措施

                          (1)禁止 this 关键字指向全局对象

                          function fn1() {
                            // 返回 false,因为 this 指向全局对象 
                            return !this
                          }
                          
                          function fn2() {
                            'use strict'
                            // 返回 true,因为严格模式下,this 的值为 undefined。
                            return !this
                          }
                          

                          因此,使用构造函数时,如果忘加 new 关键字时,this 不再指向全局对象,而是报错。

                          // 构造函数
                          function Fn() {
                            'use strict'
                            this.name = 'Frankie' // Uncaught TypeError: Cannot set property 'name' of undefined
                          }
                          
                          // 直接当作普通函数调用就会报错,因为此时 this 为 undefined。
                          Fn()
                          

                          (2)禁止在函数内部遍历调用栈

                          function fn() {
                            'use strict'
                            fn.arguments // 报错
                            fn.caller // 报错
                            // Uncaught TypeError: 'caller', 'callee', and 'arguments' properties may not be accessed on strict mode functions or the arguments objects for calls to them
                          }
                          
                          fn()
                          

                          4. 禁止删除变量

                          严格模式下无法删除变量。

                          'use strict'
                          
                          var name
                          delete name // 语法错误,Uncaught SyntaxError: Delete of an unqualified identifier in strict mode.
                          

                          只有 configurable 设置为 true 的对象属性,才能被删除。

                          'use strict'
                          
                          var obj = Object.create(null, {
                            name: {
                              value: 'Frankie',
                              configurable: true
                            }
                          })
                          
                          delete obj.name // 删除成功
                          

                          5. 显式报错

                          正常模式下,对一个对象的只读属性进行赋值,不会报错,只会默默地失败。严格模式下,将报错。

                          'use strict'
                          
                          var obj = {}
                          
                          Object.defineProperty(obj, 'name', { value: 'Frankie', writable: false })
                          
                          obj.name = 'Mandy' // 报错,Uncaught TypeError: Cannot assign to read only property 'name' of object '#<Object>'
                          

                          严格模式下,对一个使用 getter 方法读取的属性进行赋值,会报错。

                          'use strict'
                          
                          var obj = {
                            get name() {
                              return 'Frankie'
                            }
                          }
                          
                          obj.name = 'Mandy' // 报错,Uncaught TypeError: Cannot set property name of #<Object> which has only a getter
                          

                          严格模式下,对禁止扩展的对象添加新属性,会报错。

                          'use strict'
                          
                          var obj = {}
                          
                          Object.preventExtensions(obj)
                          
                          obj.name = 'Frankie' // 报错,Uncaught TypeError: Cannot add property name, object is not extensible
                          

                          严格模式下,删除一个不可删除的属性,会报错。

                          'use strict'
                          
                          // 报错,Uncaught TypeError: Cannot delete property 'prototype' of function Object() { [native code] }
                          delete Object.prototype
                          

                          6. 重名错误

                          严格模式新增了一些语法错误。

                          (1)对象不能有重名的属性

                          在 Gecko 版本 34 之前,严格模式要求一个对象内的所有属性名在对象内必须唯一。正常模式下重名属性是允许的,最后一个重名的属性决定其属性值。因为只有最后一个属性起作用,当代码要去改变属性值而不是修改最后一个重名属性的时候,复制这个对象就产生一连串的 bug。在严格模式下,重名属性被认为是语法错误:

                          这个问题在 ECMAScript 6 中已经不复存在(bug 1041128)。

                          'use strict'
                          
                          // 语法错误:SyntaxError: property name age appears more than once in object literal
                          var obj = {
                            age: 18,
                            age: 20
                          }
                          

                          (2)函数不能有重名的参数

                          正常模式下,如果函数有多个重名的参数,最后一个重名参数名会掩盖之前的重名参数,之前的参数仍然可以通过 arguments[i] 来访问。然而,这种隐藏毫无意义而且可能是意料之外的 (比如它可能本来是打错了),所以在严格模式下重名参数被认为是语法错误。

                          'use strict'
                          
                          // 语法错误:Uncaught SyntaxError: Duplicate parameter name not allowed in this context
                          function fn(x, x, y) {
                            return
                          }
                          

                          7. 禁止八进制表示法

                          ECMAScript 并不包含八进制语法,但所有的浏览器都支持这种以零(0)开头的八进制语法:0100 === 64,还有 "\045" === "%"。在 ECMAScript 6 中支持为一个数字加 "0o" 的前缀来表示八进制数.

                          'use strict'
                          
                          var n = 0100 // 语法错误:Uncaught SyntaxError: Octal literals are not allowed in strict mode.
                          
                          var n = 0o100 // ES6 八进制数表示法
                          

                          8. arguments 对象的限制

                          arguments 是函数的参数对象,严格模式对它的使用做了限制。

                          (1)不允许对arguments赋值

                          'use strict'
                          
                          arguments++ // 语法错误:Uncaught SyntaxError: Unexpected eval or arguments in strict mode
                          
                          var obj = { set p(arguments) { } } // 语法错误,同上
                          
                          try { } catch (arguments) { } // 语法错误,同上
                          
                          function arguments() { } // 语法错误,同上
                          
                          var fn = new Function('arguments', "'use strict'; return 17;") // 语法错误,同上
                          

                          (2)arguments不再追踪参数的变化

                          function fn1(x) {
                            x = 2
                            return [x, arguments[0]]
                          }
                          
                          fn1(1); // 正常模式为 [2, 2]
                          
                          function fn2(x) {
                            'use strict'
                            x = 2
                            return [x, arguments[0]]
                          }
                          
                          fn2(1) // 严格模式为 [2, 1]
                          

                          (3)禁止使用 arguments.callee

                          这意味着,你无法在匿名函数内部调用自身了。

                          'use strict'
                          
                          var fn = function () { return arguments.callee }
                          
                          fn() // 报错:Uncaught TypeError: 'caller', 'callee', and 'arguments' properties may not be accessed on strict mode functions or the arguments objects for calls to them
                          

                          9. 函数必须声明在顶层

                          将来 JavaScript 的新版本会引入"块级作用域"。为了与新版本接轨,严格模式只允许在全局作用域或函数作用域的顶层声明函数。也就是说,不允许在非函数的代码块内声明函数。

                          严格模式禁止了不在脚本或者函数层面上的函数声明。在浏览器的普通代码中,在“所有地方”的函数声明都是合法的。这并不在 ES5 规范中(甚至是 ES3)!这是一种针对不同浏览器中不同语义的一种延伸。未来的 ECMAScript 版本很有希望制定一个新的,针对不在脚本或者函数层面进行函数声明的语法。在严格模式下禁止这样的函数声明对于将来 ECMAScript 版本的推出扫清了障碍:

                          'use strict'
                          
                          if (true) {
                            function f() { } // !!! 语法错误,SyntaxError: in strict mode code, functions may be declared only at top level or immediately within another function
                            f()
                          }
                          
                          for (var i = 0; i < 5; i++) {
                            function f2() { } // !!! 语法错误,SyntaxError: in strict mode code, functions may be declared only at top level or immediately within another function
                            f2()
                          }
                          
                          function baz() { // 合法
                            function eit() { } // 同样合法
                          }
                          

                          关于这块内容,可以看下这几个链接:

                          10. 保留字

                          为了向将来 JavaScript 的新版本过渡,严格模式新增了一些保留字:implementsinterfaceletpackageprivateprotectedpublicstaticyield。使用这些词作为变量名将会报错。

                          function package(protected) { // 语法错误,Uncaught SyntaxError: Unexpected strict mode reserved word
                            'use strict'
                            var implements // 语法错误
                          }
                          

                          此外,ECMAScript 5 本身还规定了另一些保留字(classenumexportextendsimportsuper),以及各大浏览器自行增加的 const 保留字,也是不能作为变量名的。

                          未完待续...

                          五、参考链接

                          ]]>
                          <![CDATA[正则表达式]]> https://github.com/tofrankie/blog/issues/231 https://github.com/tofrankie/blog/issues/231 Sun, 26 Feb 2023 10:56:20 GMT 配图源自 Freepik

                          正则表达式(Regular Expressions)是用于匹配字符串中字符组]]> 配图源自 Freepik

                          正则表达式(Regular Expressions)是用于匹配字符串中字符组合的模式。在 JavaScript 中,正则表达式也是对象。这些模式被用于 RegExp 的 exec 和 test 方法, 以及 String 的 matchmatchAllreplacesearch 和 split 方法。

                          创建正则表达式的方式有两种:

                          // 正则表达式字面量
                          const re = /ab+c/
                          
                          // 使用 RegExp 对象的构造函数
                          const re = new RegExp("ab+c")
                          

                          正则表达式中的特殊字符

                          • \ 为转义,通常在 \ 后面的字符不按原本的意义解释。如 /b/ 匹配字符 "b",当 b 前面添加反斜杠 /\b/,转义为匹配一个单词的边界。也可以对正则表达式功能字符的还原,/a*/ 将匹配 "a""aa""aaa"等字符(* 匹配它前面元字符 0 次或多次,),加了反斜杠后 /a\*/ 将只匹配 "a*"
                          • ^ 匹配一个输入或一行的开头,/^a/ 匹配 "an A" 字符,而不匹配 "An a" 字符。
                          • $ 匹配一个输入或一行的结尾,/a$/ 匹配 "An a" 字符,而不匹配 "an A" 字符。
                          • * 匹配前面元字符 0 次或多次,/ba*/ 匹配 "b""ba""baa""baaa" 等字符。
                          • + 匹配前面元字符 1 次或多次,/ba+/ 匹配 "ba""baa""baaa" 等字符。
                          • ? 匹配前面元元字符 0 次或 1 次,/ba?/ 匹配 "b""ba" 字符。
                          • (x) 匹配 x,保存 x$1...$9 的变量中。
                          • x|y 匹配 xy
                          • {n} 精确匹配 n 次。
                          • {n,} 精确匹配 n 次及以上。
                          • {m,n} 精确匹配 m ~ n 次。
                          • [xyz] 字符集,匹配这个集合中的任一一个字符(或元字符)。
                          • [^xyz] 不匹配这个集合中的任一一个字符。
                          • [\b] 匹配一个退格符。
                          • \b 匹配一个单词的边界。
                          • \B 匹配一个单词的非边界。
                          • \cX 这里的 X 是一个控制符,\cM/ 匹配 Ctrl-M
                          • \d 匹配一个数字字符,/\d/ 相当于 /[0-9]/
                          • \D 匹配一个非数字字符,/\D/ 相当于 /[^0-9]/
                          • \n 匹配一个换行符。
                          • \r 匹配一个回车符。
                          • \s 匹配一个空白字符,包括 \n\r\f\t\v 等,相当于 /[\n\r\f\t\v]/
                          • \S 匹配一个非空白字符,相当于 /[^\n\r\f\t\v]/
                          • \t 匹配一个制表符。
                          • \v 匹配一个垂直制表符。
                          • \w 匹配一个可以组成单词的字符(包括字母、数字或者下划线)。如 [\w] 匹配 "$5.28" 中的 5,相当于 [a-zA-Z0-9]
                          • \W 匹配一个不可以组成单词的字符,如 [\W] 匹配 "$5.28" 中的 $,相当于 [^a-zA-Z0-9]

                          直接量字符

                          如果想在正则表达式中使用特殊的标点符号,必须在他们之前加上一个 \ 进行转义。

                          • \/ 表示一个 / 直接量。
                          • \\ 表示一个 \ 直接量。
                          • \. 表示一个 . 直接量。
                          • \* 表示一个 * 直接量。
                          • + 表示一个 + 直接量。
                          • ? 表示一个 ? 直接量。
                          • \| 表示一个 | 直接量。
                          • \( 表示一个 ( 直接量。
                          • \) 表示一个 ) 直接量。
                          • \[ 表示一个 [ 直接量。
                          • \] 表示一个 ] 直接量。
                          • \{ 表示一个 { 直接量。
                          • \} 表示一个 } 直接量。

                          字符类

                          将单独的直接复放进中括号([...])内就可以组合成字符类。一个字符类和它所包含的任何一个字符都匹配。正式表达式 /[abc]/ 和字母 "a""b""c" 中的任何一个都匹配,另外还可以定义否定字符类,这些类匹配的是除那些包含在中括号之内的字符外的所有字符。定义否定字符尖(^)时,要将一个 ^ 符号作为从左中括号算起的第一个字符。

                          由于某些字符类非常常用,所以 JavaScript 的正则表达式语法包含一些特殊字符和转义序列来表示这些常用的类。比如 \s 匹配的是空白符(包括空格符、制表符、换行符等空白符),\S 匹配的是除空白符之外的任意字符。

                          • [...] 匹配位于括号之内的任意字符。
                          • [^...] 匹配不在括号内的任意字符。
                          • . 匹配除换行符之外的任意字符,相当于 [^\n]
                          • \w 匹配任何单字字符,相当于 [a-zA-Z0-9_]
                          • \W 匹配任何非单字字符,相当于 [^a-zA-Z0-9_]
                          • \s 匹配任意空白符,相当于 /[\n\r\f\t\v]/
                          • \S 匹配任意非空白符,相当于 /[^\n\r\f\t\v]/
                          • \d 匹配任意数字,相当于 [0-9]
                          • \D 匹配除数字之外的任意字符,相当于 [^0-9]
                          • \b 匹配一个退格直接量(特例)。

                          复制

                          用以上的正则表达式语法,可以把两位数描述成 /\d\d/,把四位数描述成 /\d\d\d\d/,但是我们还没有一种方法可以用来描述具有任意多位数的数字或者是一个字符串,这个字符串由三个字符以及跟随在字母后的一位数字构成。这些复杂的模式使用的正则表达式语法指定了改表达式中每个元素要重复出现的次数。

                          指定复制的字符总是出现在它们所作用的模式后面。由于某种复制类型相对常用。所以有一些特殊的字符专门用于表示它们。比如 + 匹配的就是复制前一模式一次或多次的模式下。

                          先看写例子:

                          • /\d{2,4}/ 表示匹配 2 ~ 4 个的数字字符。
                          • /\w{3}\d?/ 表示匹配三个单字字符和一个任意的数字。
                          • \s+java\s+ 表示匹配字符串 "java",并且该字符串前后可以有一个或多个空格。
                          • /[^"]*/ 表示匹配零个或多个非引号字符。

                          复制字符的几种方式:

                          • {m,n} 匹配前一项至少 m 次,但是不能超过 n 次。
                          • {n,} 匹配前一项 n 次及以上。
                          • {n} 匹配前一项恰好 n 次。
                          • ? 匹配前一项 0 次或 1 次,就是说前一项是可选的,相当于 {0,1}
                          • + 匹配前一项 1 次或多次,相当于 {1,}
                          • * 匹配前一项 0 次或多次,相当于 {0,}

                          选择、分组、引用

                          正则表达式的语法还包括指定选择项,对子表达式分组和引用前一子表达式的特殊字符。字符 | 用于分隔供选择的字符,例如:/ab|cd|ef/ 匹配的是 "ab" 或者是 "cd" 又或者是 "ef" 字符串。再比如:/\d{3}|[a-z]{4}/ 匹配的是要么是一个三位数字,要么是四个小写字母。

                          在正则表达式中,括号((...))具有几种作用:

                          1. 它的主要作用是把单独的项目分组成子表达式,以便可以像处理一个独立的单元那种用 *+? 来处理那些项目。例如:/java(script)?/ 匹配的是字符串 "java",其后的既可以有 "script",也可以没有。再比如,/(ab|cd)+|ef/ 匹配的既可以是字符串 "ef",也可以是字符串 "ab" 或者 "cd" 的一次或多次重复。

                          2. 括号的第二个用途是,在完整的模式中定义子模式。当一个正则表达式成功地和目标字符串相匹配是,可以从目标串中抽出和括号中的子模式相匹配的部分。例如,假定我们正在检索的模式是一个或多个字母后面跟随一位或多位数组,你们我们可以使用模式 /[a-z]+\d+/。但是由于假定我们真正关心的是每个匹配尾部的数字,那么如果我们将模式的数字部分放在括号中 /[a-z]+(\d+)/,我们就可以从所检索到的任何匹配中抽取数字了,之后我们会对此进行解析的。

                          3. 代括号的另一个用途是,允许我们在同一正则表达式的后面引用前面的子表达式。这是通过在字符串 \ 后加一位或多位数字来实现的。数字指的是代括号的子表达式在正则表达式中的位置。例如:\1 引用的的是第一个代括号的子表达式,\3 引用的是第三个代括号的子表达式。注意,由于子表达式可以嵌套在其他子表达式中,所以它的位置是被计数的左括号的位置。

                          例如,在下面的正则表达式被指定为 \2/([Jj]ava[Ss]cript)\sis\s(fun\w*)/,对正则表达式中前一子表达式的引用所指定的并不是那个子表达式,而是与那个模式相匹配的文本。这样,引用就不只是帮助你输入正则表达式的重复部分的快捷方式了,它还实施了一条规约,那就是一个字符串各个分离的部分包含的是完全相同的字符。例如:/['"][^'"]['"]/ 匹配的就是位于单引号或者双引号之内的所有字符。但是它要求开始和结束的引号匹配。(例如两个都是双引号或者都是但引号) 如果要求开始和结束的引号匹配,我们可以使用如下的引用:/(['"])[^'"]*\1/\1 匹配的是第一个代括号的子表达式所匹配的模式。在这个例子中,它实施了一种规约,那就是开始的引号必须和结束的引号相匹配。注意,如果反斜杠后跟随的数字比代括号的子表达式数多,那么它就会被解析为一个十进制的转义序列,而不是一个引用。你可以坚持使用完整的三个字符来表示转义序列,这样就可以避免混淆了。例如 \044,而不是 \44

                          下面是正则表达式的选择、分组和引用字符:

                          • | 选择:匹配的要么是该符号左边的子表达式,要么是它右边的子表达式。
                          • (...) 分组:将几个项目分为一个单元,这个单元可由 *+?| 等符号使用,而且还可以记住和这个组匹配的字符以供此后引用使用 \n 和第 n 个(注意这里的 n 是指数字)分组所匹配的字符相匹配。分组是括号中的子表达式。(可能是嵌套的)分组号是从左到右计数的左括号数。

                          通过标志进行高级搜索

                          正则表达式有六个可选参数flags)允许全局和不分大小写搜索等。这些参数既可以单独使用,也能以任意顺序一起使用,并且被包含在正则表达式实例中。

                          • g 全局搜索
                          • i 不区分大小写搜索
                          • m 多行搜索。
                          • s 允许 . 匹配换行符。
                          • u 使用 unicode 码的模式进行匹配。
                          • y 执行“粘性(sticky)”搜索,匹配从目标字符串的当前位置开始。
                          // 正则表达式字面量
                          var re = /pattern/flags;
                          // RegExp 构造函数
                          var re = new RegExp("pattern", "flags");
                          

                          动态创建正则表达式

                          从用户输入等来源中动态地产生,就需要使用构造函数来创建正则表达式。

                          // 假设需求是生成一个正则表达式:/temp/
                          
                          // 此时我们手上有一个字符串 "temp"
                          var str = 'temp';
                          
                          // 这种方式是不对的,得到的只是一个 "/temp/" 字符串。
                          var re = '/' + str + '/';
                          
                          // 应该是这样,才是正则表达式
                          var re = new RegExp(str);
                          

                          未完待续...

                          \s 空白符
                          \S 非空白符
                          [\s\S]任意字符
                          [\s\S]* 0个到任意多个字符
                          [\s\S]*? 0个字符,匹配任何字符前的位置。
                          

                          常用正则表达式

                          // 删除字符串前后第一个空格
                          str.replace(/(^\s*)|(\s*$)/g, '')
                          
                          // 删除字符串中所有空格
                          str.replace(/\s/g, '')
                          

                          需要转义的字符

                          需要转义的字符有:\.*^&[]{}? 等。

                          [ ] \ ^ $ . | ? * + ( )

                          参考链接

                          ]]>
                          <![CDATA[如何判断 JS 数组]]> https://github.com/tofrankie/blog/issues/230 https://github.com/tofrankie/blog/issues/230 Sun, 26 Feb 2023 10:55:21 GMT 配图源自 Freepik

                          先说结论

                          一律使用 ES]]> 配图源自 Freepik

                          先说结论

                          一律使用 ES5 提供的方法:

                          Array.isArray()
                          

                          非要降级考虑,那就:

                          function isArray(val) {
                            return Object.prototype.toString.call(val) === '[object Array]'
                          }
                          

                          其原理是通过 Object.prototype.toString() 判断对象的内部属性 [[Class]] 是否为 "Array"

                          其他方法的缺陷

                          typeof

                          要是 Function、String、Number、Undefined 等类型来说,它完全可以胜任。

                          但判断 Array 类型不行的,因此不能用它来判断一个值是否为数组。

                          const arr = []
                          console.log(typeof arr) // "object"
                          
                          // 同样地
                          console.log(typeof null) // "object"
                          console.log(typeof {}) // "object"
                          

                          instanceof 和 constructor

                          instanceof 用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上,比如:

                          const arr = []
                          console.log(arr instanceof Array) // true
                          

                          使用 constructor 判断类型,比如:

                          const arr = []
                          console.log(arr.constructor === Array) // true
                          
                          // 其他
                          // arr.__proto__.constructor === Array // true
                          // arr.__proto__ === Array.prototype // true
                          

                          在某些情况下是不准确的,比如:

                          function Fn() {}
                          Fn.prototype = new Array()
                          
                          const instance = new Fn()
                          
                          console.log(instance.constructor === Fn) // false
                          console.log(instance.constructor === Array) // true
                          // 此时 instance 应该是一个普通对象,而非数组,使用 constructor 判断是不合适的
                          

                          使用 instanceof 和 constructor 的局限性:

                          使用和声明都必须是在当前页面,比如父页面引用了子页面,在子页面中声明了一个 Array,将其赋值给父页面的一个变量,那么此时做原型链的判断:Array === object.constructor 得到的是 false,原因如下:

                          1. Array 属于引用型数据,在传递过程中,仅仅是引用地址的传递。
                          2. 每个页面的 Array 原生对象所引用的地址是不一样的,在子页面声明的Array 所对应的构造函数是子页面的 Array 对象;父页面来进行判断,使用的 Array 并不等于子页面的 Array

                          Categorizing values in JavaScript 一段原话: Array.isArray() exists because of one particular problem in browsers: each frame has its own global environment. An example: Given a frame A and a frame B (where either one can be the document). Code in frame A can pass a value to code in frame B. Then B code cannot use instanceof Array to check whether the value is an array, because its B Array is different from the A Array (of which the value could be an instance).

                          示例:

                          const iframe = document.createElement('iframe')
                          document.body.appendChild(iframe)
                          
                          const xArray = window.frames[window.frames.length - 1].Array
                          const xarr = new xArray()
                          const arr = new Array()
                          
                          // 不同页面,结果并非我们所预期的 true,而是 false
                          console.log(xarr instanceof Array) // false
                          console.log(xarr.constructor === Array) // false
                          
                          // 同页面才是 true
                          console.log(arr instanceof Array) // true
                          console.log(arr.constructor === Array) // true
                          
                          ]]>
                          <![CDATA[URI、URL、URN 是什么?]]> https://github.com/tofrankie/blog/issues/229 https://github.com/tofrankie/blog/issues/229 Sun, 26 Feb 2023 10:54:08 GMT

                          URI

                          URI:统一资源标识符(Uniform Resource Identifie]]>

                          URI

                          URI:统一资源标识符(Uniform Resource Identifier)

                          每个 web 服务器资源都有一个名字,服务器资源名被统称为统一资源标识符;URI 就像 Internet 的邮政地址,唯一地标识和定位世界各地的信息资源。URI 有两种形式,分别为 URL 和 URN。

                          ftp://ftp.is.co.za/rfc/rfc1808.txt
                          http://www.ietf.org/rfc/rfc2396.txt
                          ldap://[2001:db8::7]/c=GB?objectClass?one
                          mailto:John.Doe@example.com
                          news:comp.infosystems.www.servers.unix
                          tel:+1-816-555-1212
                          telnet://192.0.2.16:80/
                          urn:oasis:names:specification:docbook:dtd:xml:4.1.2
                          

                          URL

                          URL:统一资源定位符(Uniform Resource Locator)

                          URL 是最常见的资源标识符,URL 描述了一台特定服务器上某资源的特定位置。它们可以明确说明如何从一个精准、固定的位置获取资源。

                          https://developer.mozilla.org
                          https://developer.mozilla.org/en-US/docs/Learn/
                          https://developer.mozilla.org/en-US/search?q=URL
                          https://tools.ietf.org/html/rfc2396#section-3.1
                          

                          URN

                          URN:统一资源名称(Uniform Resource Name)

                          URI 的第二种形式是统一资源名。URN 是作为特定内容的唯一名称使用的,与目前的资源所在地无关。如图书的编号(ISBN)urn:isbn:0451450523。

                          要理解这三者的区别,不要 URI 与 URL 和 URN 放在同一个等级。

                          区别

                          URL 一定是 URI,但 URI 不一定是 URL,URI 还包括 URN。

                          URL 通过描述资源的位置来标识资源,而 URN 是通过名字来标识资源的,与位置无关。

                          参考链接

                          ]]>
                          <![CDATA[详谈 JSON 与 JavaScript]]> https://github.com/tofrankie/blog/issues/228 https://github.com/tofrankie/blog/issues/228 Sun, 26 Feb 2023 10:53:12 GMT 配图源自 Freepik

                          JSON 在编程中无处不在。

                          • 那么 JSON 是什么]]> 配图源自 Freepik

                            JSON 在编程中无处不在。

                            • 那么 JSON 是什么呢?
                            • 跟我们的 JavaScript 有什么关系呢?
                            • 在 JavaScript 中,我们如何处理 JSON 数据呢?

                            一、JSON

                            JSON(JavaScript Object Natation)是一种轻量级的数据交换格式。由于易于阅读、编写,以及便于机器解析与生成的特性,相比 XML,它更小、更快、更易解析,使得它成为理想的数据交换语言。完全独立于语言的一种文本格式。

                            JSON 的两种结构:

                            • “名称/值” 对的集合:不同语言中,它被理解成对象(object)、记录(record)、结构(struct)、字典(dictionary)、哈希表(hash table)、有键列表(keyed list)或者关联数组(associative array)。
                            • 值的有序列表:大部分语言中,它被理解成数组(array)。

                            例如用以下 JSON 数据来描述一个人的信息:

                            {
                              "name": "Frankie",
                              "age": 20,
                              "skills": ["Java", "JavaScript", "TypeScript"]
                            }
                            

                            注意,JavaScript 不是 JSON,JSON 也不是 JavaScript。但 JSON 与 JavaScript 是存在渊源的,JSON 的数据格式是从 JavaScript 对象中演变出来的。(从名称上可以体现)

                            二、JSON 与 JavaScript 的区别

                            JSON 是一种数据格式,也可以说是一种规范。JSON 是用于跨平台数据交流的,独立于语言和平台。而 JavaScript 对象是一个实例,存在于内存中。JavaScript 对象是没办法传输的,只有在被序列化为 JSON 字符串后才能传输。

                            JavaScript 类型 JSON 的不同点
                            对象和数组 属性名称必须是双引号括起来的字符串;最后一个属性后不能有逗号
                            数值 禁止出现前导零( JSON.stringify() 方法自动忽略前导零,而在 JSON.parse() 方法中将会抛出 SyntaxError);如果有小数点,则后面至少跟着一位数字。
                            字符串 只有有限的一些字符可能会被转义;禁止某些控制字符; Unicode 行分隔符 (U+2028)和段分隔符 (U+2029)被允许 ; 字符串必须用双引号括起来。请参考下面的示例,可以看到 JSON.parse() 能够正常解析,但将其当作 JavaScript 解析时会抛出 SyntaxError 错误:
                            let code = '"\u2028\u2029"'
                            JSON.parse(code)  // 正常
                            eval(code)  // 错误
                            

                            在 JavaScript 中,我们不能把以下对象叫做 JSON,如:

                            // 这只是 JS 对象
                            var people = {}
                            
                            // 这跟 JSON 就更不沾边了,只是 JS 的对象
                            var people = { name: 'Frankie', age: 20 }
                            
                            // 这跟 JSON 就更不沾边了,只是 JS 的对象
                            var people = { 'name': 'Frankie', 'age': 20 }
                            
                            // 我们可以把这个称做:JSON 格式的 JS 对象
                            var people = { "name": "Frankie", "age": 20 }
                            
                            // 我们可以把这个称做:JSON 格式的字符串
                            var people = '{"name":"Frankie","age":20}'
                            
                            // 稍复杂的 JSON 格式的数组
                            var peopleArr = [
                              { "name": "Frankie", "age": 20 },
                              { "name": "Mandy", "age": 18 }
                            ]
                            
                            // 稍复杂的 JSON 格式字符串
                            var peopleStr = '[{"name":"Frankie","age":20},{"name":"Mandy","age":18}]'
                            

                            尽管 JSON 与严格的 JavaScript 对象字面量表示方式很相似,如果将 JavaScript 对象属性加上双引号就理解成 JSON 是不对的,它只是符合 JSON 的语法规则而已。JSON 与 JavaScript 对象本质上是完全不同的两个东西,就像“斑马”和“斑马线”一样。

                            在 JavaScript 中,JSON 对象包含两个方法,用于解析的 JSON.parse() 和转换的 JSON.stringify() 方法。除了这两个方法,JSON 这个对象本身并没有其他作用,也不能被调用或者作为构造函数调用。

                            三、JSON.stringify()

                            将一个 JavaScript 对象或值转换为 JSON 字符串。

                            JSON.stringify(value, replacer, space)
                            
                            • 参数 value ,是将要序列化成 JSON 字符串的值。

                            • 参数 replacer (可选),如果该参数是一个函数,则在序列化过程中,被序列化的值的每个属性都会经过该函数的转换和处理;如果该参数是一个数组,则只有包含在这个数组中的属性名才会被序列化到最终的 JSON 字符串中;如果该参数未提供(或者值为 null),则对象所有的属性都会被序列化。

                            const people = {
                              name: 'Frankie',
                              age: 20
                            }
                            
                            const peopleStr1 = JSON.stringify(people, ['name'])
                            const peopleStr2 = JSON.stringify(people, (key, value) => {
                              if (typeof value === 'string') {
                                return undefined
                              }
                              return value
                            })
                            
                            console.log(peopleStr1) // '{"name":"Frankie"}'
                            console.log(peopleStr2) // '{"age":20}'
                            
                            • 参数 space (可选),指定缩进用的空白字符串,用于美化输出(pretty-print)。如果参数为数字,它表示有多少个空格,值大于 10 时,输出空格为 10,小于 1 则表示没有空格。如果参数为字符串,该字符串将被将被作为空格。如果参数没有提供(或者值为 null),将没有空格。注意,若使用非空字符串作为参数值,就不能被 JSON.parse() 解析了,会抛出 SyntaxError 错误。

                            一般来说,参数 replacerspace 平常比较少用到。

                            看示例:

                            const symbol = Symbol()
                            
                            const func = () => { }
                            
                            const people = {
                              name: 'Frankie',
                              age: 20,
                              birthday: new Date(),
                              sex: undefined,
                              home: null,
                              say: func,
                              [symbol]: 'This is Symbol',
                              skills: ['', undefined, , 'JavaScript', undefined, symbol, func],
                              course: {
                                name: 'English',
                                score: 90
                              },
                              prop1: NaN,
                              prop2: Infinity,
                              prop3: new Boolean(true) // or new String('abc') or new Number(10)
                            }
                            
                            const replacer = (key, value) => {
                              // 这里我其实没做什么处理,跟忽略 replacer 参数是一致的。
                            
                              // 若符合某种条件不被序列化,return undefined 即可。
                              // 比如 if (typeof value === 'string') return undefined
                              
                              // 也可以通过该函数来看看序列化的执行顺序。
                            
                              // console.log('key: ', key)
                              // console.log('value: ', value)
                              return value
                            }
                            
                            // 序列化操作
                            const peopleStr = JSON.stringify(people, replacer)
                            
                            // '{"name":"Frankie","age":20,"birthday":"2021-01-17T10:24:39.333Z","home":null,"skills":["",null,null,"JavaScript",null,null,null],"course":{"name":"English","score":90},"prop1":null,"prop2":null,"prop3":true}'
                            console.log(peopleStr) 
                            
                            console.log(JSON.stringify(function(){})) // undefined
                            console.log(JSON.stringify(undefined)) // undefined
                            

                            结合以上示例,有以下特点:

                            • 非数组对象的属性不能保证以特定的顺序属性出现在序列化后的字符串中。(示例可能没体现出来)
                            • 布尔值、数字、字符串的包装对象在序列化过程中会自动转换成对应的原始值。(如 prop3
                            • undefined、任意的函数以及 symbol 值,在序列化过程中有两种不同的情况。若出现在非数组对象的属性值中,会被忽略;若出现在数组中,会被转换成 null
                            • 函数、undefined 被单独转换时,会返回 undefined
                            • 所有以 symbol 为属性键的属性都会被完全忽略掉,即便 replacer 参数中强制指定包含了它们。
                            • Date 日期调用了其内置的 toJSON() 方法将其转换成字符串(同 Date.toISOString()),因此会被当做字符串处理。
                            • NaNInfinity 格式的数值及 null 都会被当做 null
                            • 其他类型的对象,包括 MapSetWeakMapWeakSet,仅会序列化可枚举的属性。
                            • 转换值如果含有 toJSON() 方法,该方法定义什么值将被序列化。
                            • 对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误。

                            针对最后两点举例说明:

                            • 若对象本身实现了 toJSON() 方法,那么调用 JSON.stringify() 方法时,JSON.stringify() 会将这个对象的 toJSON() 方法的返回值作为参数去进行序列化。
                            const people = {
                              name: 'Frankie',
                              age: 20,
                              toJSON: () => {
                                return { name: 'Mandy' }
                              }
                            }
                            
                            console.log(JSON.stringify(people))
                            // 结果是 {"name":"Mandy"},而不是 {"name":"Frankie","age":20}
                            // 需要注意的是,若对象的 toJSON 属性值不是函数的话,仍然是该对象作为参数进行序列化。
                            
                            
                            // 上面还提到 Date 对象本身内置了 toJSON() 方法,所以以下返回结果是:
                            // "2021-01-17T09:40:08.302Z"
                            console.log(JSON.stringify(new Date()))
                            
                            
                            // 假如我去修改 Date 原型上的 toJSON 方法,结果会怎样呢?
                            Date.prototype.toJSON = function () { return '被改写了' }
                            console.log(JSON.stringify(new Date())) // "被改写了"
                            
                            • 对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误。
                            const foo = {}
                            const bar = {
                              b: foo
                            }
                            foo.a = bar
                            console.log(foo)
                            
                            // 如果这时候对 foo 进行序列化操作,就会抛出错误。
                            JSON.stringify(foo) // Uncaught TypeError: Converting circular structure to JSON
                            

                            foo 对象和 bar 对象会无限相互引用,可以看下 foo 打印结果如下,如果此时对 foo 进行序列化操作,就会抛出错误:Uncaught TypeError: Converting circular structure to JSON

                            针对这个问题,看看别人的解决方法,看这里 JSON-js。具体用法是,先引入其中的 cycle.js 脚本,然后 JSON.stringify(JSON.decycle(foo)) 就 OK 了。

                            JSON.stringify() 总结:

                            1. 若被序列化的对象,存在 toJSON() 方法,真正被序列化的其实是 toJSON() 方法的返回值。
                            2. 若提供了 replacer 参数,应用这个函数过滤器,传入的函数过滤器的值是第 1 步返回的值。
                            3. 对第 2 步返回的每个值,进行相应的序列化。
                            4. 如果提供了 space 参数,执行相应的格式化操作。

                            四、JSON.parse()

                            JSON.parse() 方法用来解析 JSON 字符串,构造由字符串描述的 JavaScript 值或对象。提供可选的 reviver 函数用以在返回之前对所得到的对象执行变换(操作)。

                            JSON.parse(text, reviver)
                            
                            • 参数 text,要被解析成 JavaScript 值的字符串。

                            • 参数 reviver(可选)转换器,如果传入该参数(函数),可以用来修改解析生成的原始值,调用时机在 parse 函数返回之前。

                              如果指定了 reviver 函数,则解析出的 JavaScript 值(解析值)会经过一次转换后才将被最终返回(返回值)。更具体点讲就是:解析值本身以及它所包含的所有属性,会按照一定的顺序(从最最里层的属性开始,一级级往外,最终到达顶层,也就是解析值本身)分别的去调用 reviver 函数,在调用过程中,当前属性所属的对象会作为 this 值,当前属性名和属性值会分别作为第一个和第二个参数传入 reviver 中。如果 reviver 返回 undefined,则当前属性会从所属对象中删除,如果返回了其他值,则返回的值会成为当前属性新的属性值。

                              当遍历到最顶层的值(解析值)时,传入 reviver 函数的参数会是空字符串 ""(因为此时已经没有真正的属性)和当前的解析值(有可能已经被修改过了),当前的 this 值会是 {"": 修改过的解析值},在编写 reviver 函数时,要注意到这个特例。(这个函数的遍历顺序依照:从最内层开始,按照层级顺序,依次向外遍历

                            // JSON 字符串
                            const peopleStr = '{"name":"Frankie","age":20,"birthday":"2021-01-17T10:24:39.333Z","home":null,"skills":["",null,null,"JavaScript",null,null,null],"course":{"name":"English","score":90},"prop1":null,"prop2":null,"prop3":true}'
                            
                            // 若需输出 this 对象,不能使用箭头函数
                            const reviver = (key, value) => {
                              // 可以通过该函数来看看序列化的执行顺序。
                              // console.log('key: ', key)
                              // console.log('value: ', value)
                            
                              // 若删除某个属性,return undefined 即可。
                              // 比如 if (typeof value === 'string') return undefined
                            
                              // 如果到了最顶层,则直接返回属性值
                              if (key === '') return value
                            
                              // 数值类型,将属性值变为原来的 2 倍
                              if (typeof value === 'number') return value * 2
                            
                              // 其他的原样解析
                              return value
                            }
                            
                            const parseObj = JSON.parse(peopleStr, reviver)
                            
                            console.log(parseObj)
                            

                            解析结果如下:

                            {
                              "age": 40,
                              "birthday": "2021-02-14T06:02:18.491Z",
                              "course": { "name": "English", "score": 180 },
                              "home": null,
                              "name": "Frankie",
                              "prop1": null,
                              "prop2": null,
                              "prop3": true,
                              "skills": ["", null, null, "JavaScript", null, null, null]
                            }
                            

                            五、其他

                            针对 Line separator 和 Paragraph separator 的处理,可以看这里:JSON.stringify 用作 JavaScript

                            六、拓展

                            根据 ECMA-262 标准定义,一个字符串可以包含任何东西,只要它不是一个引号,一个反斜线或者一个行终止符。

                            以下被认为是行终止符:

                            • \u000A - Line Feed
                            • \u000D - Carriage Return
                            • \u2028 - Line separator
                            • \u2029 - Paragraph separator

                            七、参考链接

                            ]]>
                            <![CDATA[JavaScript 获取二级域名的方法]]> https://github.com/tofrankie/blog/issues/227 https://github.com/tofrankie/blog/issues/227 Sun, 26 Feb 2023 10:52:32 GMT 背景

                            有个需求,将 cookie 设置到我们的二级域名上,在不同的项目都可以公用。

                            准备

                            1. 一个完整的域名由二个或二个以上部分组成,各部分之间用英文的句号 . 来分隔
                            2. 在设置 cookie 时,如省略 doma]]> 背景

                              有个需求,将 cookie 设置到我们的二级域名上,在不同的项目都可以公用。

                              准备

                              1. 一个完整的域名由二个或二个以上部分组成,各部分之间用英文的句号 . 来分隔
                              2. 在设置 cookie 时,如省略 domain 参数,那么它默认设置为当前域名
                              3. domain 参数可以设置父域名以及当前域名。不能设置为其他域名,包括子域名也不行
                              4. cookie 的作用域是 domain 本身以及 domain 下的所有子域名。(这里忽略 path 参数限制,当做 /)

                              思路

                              如何获取二级域名,以 shortcuts.sspai.com 为例:

                              接着我们对 URL 进行拆分,按先后顺序对 comsspai.comshortcuts.sspai.com (以此类推)设置 cookie。倘若能设置 cookie 则说明域名是合法的,此时停止继续往下执行,直接返回结果 sspai.com,同时删掉该 cookie。

                              其他

                              1. 需要注意的是,baidu.com 应属于二级域名。
                              2. 关于顶级域名、二级域名、三级域名等划分如理解有偏差,自行搜索。参考
                              3. 当前采用部分 ES6 语法,若需兼容 ES5 自行修改即可。
                              4. 还有一些比较实用的 JS 方法,比如中文转拼音、身份证号码校验等。

                              实现

                              /**
                               * 获取当前 URL 二级域名
                               * 如果当前是 IP 地址,则直接返回 IP Address
                               */
                              function getSubdomain() {
                                try {
                                  let subdomain = ''
                                  const key = `mh_${Math.random()}`
                                  const expiredDate = new Date(0)
                                  const { domain } = document
                                  const domainList = domain.split('.')
                              
                                  const reg = new RegExp(`(^|;)\\s*${key}=12345`)
                                  const ipAddressReg = /^(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\.(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\.(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\.(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])$/
                              
                                  // 若为 IP 地址、localhost,则直接返回
                                  if (ipAddressReg.test(domain) || domain === 'localhost') {
                                    return domain
                                  }
                              
                                  const urlItems = []
                                  urlItems.unshift(domainList.pop())
                              
                                  while (domainList.length) {
                                    urlItems.unshift(domainList.pop())
                                    subdomain = urlItems.join('.')
                              
                                    const cookie = `${key}=12345;domain=.${subdomain}`
                                    document.cookie = cookie
                              
                                    if (reg.test(document.cookie)) {
                                      document.cookie = `${cookie};expires=${expiredDate}`
                                      break
                                    }
                                  }
                              
                                  return subdomain || document.domain
                                } catch (e) {
                                  return document.domain
                                }
                              }
                              

                              参考链接

                              ]]>
                              <![CDATA[Date 对象]]> https://github.com/tofrankie/blog/issues/226 https://github.com/tofrankie/blog/issues/226 Sun, 26 Feb 2023 10:51:47 GMT 关于 Date 对象,有几个方法总是记不住或者记混淆的,所以写篇文章整理一下。

                              Date

                              在 ECMAScript 中,Date 类型使用自世界标准时间(UTC)1970-1-1 00:00:00 开始经过的毫秒数来保存日期。

                              <]]>
                                          关于 Date 对象,有几个方法总是记不住或者记混淆的,所以写篇文章整理一下。

                              Date

                              在 ECMAScript 中,Date 类型使用自世界标准时间(UTC)1970-1-1 00:00:00 开始经过的毫秒数来保存日期。

                              // 获取起始时间
                              const date = new Date(0)
                              // Thu Jan 01 1970 08:00:00 GMT+0800 (中国标准时间)
                              

                              创建 Date 两种方式:

                              // 字符串类型
                              const date = Date()
                              console.log(typeof date) // string
                              
                              // 对象类型(常用)
                              const date = new Date()
                              console.log(typeof date) // object
                              

                              Date Get 方法

                              下面以 2020 年 12 月 03 日为例:

                              • getTime() 返回 1970 年 1 月 1 日零时至今的毫秒数。

                              • getFullYear() 返回 4 位数的年份(如 2020)。

                              • getMonth() 返回月份,值为 0~11 分别表示 1 月到 12 月。所以一般获取月份都要加一

                              • getDate() 返回一个月的某天,值为 1~31 分别表示 1日到 31 日。

                              • getDay() 返回一周的某天,值为 0~6 分别表示周日、周一到周六。

                              • getHours() 返回小时数,值为 0 ~ 23

                              • getMinutes() 返回分钟数,值为 0 ~ 59

                              • getSeconds() 返回秒数,值为 0 ~ 59

                              • getMilliseconds() 返回毫秒数,值为 0 ~ 999

                              **getYear() 方法在 Web 标准中已被删除,尽管某些浏览器仍然支持它,但建议不要用了!**因为它在不同浏览器的表现不一样。像 Chrome、Safari、FireFox 返回的是 当前年份 - 1900 的值。而在 IE 浏览器上,当当前年份大于等于 2000 时,返回值是当前 4 位数年份。 👉 Date.getYear()

                              Date Set 方法

                              • setTime() 以毫秒设置 Date 对象(参数:毫秒数

                              • setDate() 根据本地时间设置 Date 对象中月的某一天(合理参数:1 ~ 31,分别表示 1 ~ 31 日)

                                如果参数超出合理范围,会相对应的更新 Date 对象。

                                • 参数为 0 时,则设置为上个月的最后一天
                                • 参数为 负数 时,则设置为上个月最后一天往前数这个负数绝对值天数后的日期。例如:-1 会设置为上月最后一天的前一天
                                • 参数为 超过当前月最大的天数 时,则超出天数参数值 - 当前月天数)设置为下个月对应日期。例如:当前 Date 为 4 月,当参数为 32 时,则日期被设置为 5 月 2 日。

                              下列 Set 方法同理。

                              • setMonth() 根据本地时间设置 Date 对象中月份(参数:0 ~ 11,分别表示 1 ~ 12 月份)

                              • setFullYear() 根据本地时间设置 Date 对象中的年份(参数:4 位数字年份

                                同样 setYear() 已从 Web 标准中删除,不建议使用。

                              • setMilliseconds() 根据本地时间设置 Date 对象中的毫秒数(参数:0 ~ 999

                                超出合理范围,日期对象的时间会相应地更更新。例如 1005,秒数加 1,毫秒数为 5。

                              • setSeconds() 根据本地时间设置 Date 对象中的秒数(参数:0 ~ 59

                                超出合理范围,日期对象的时间会相应地更更新。例如 100,分钟数加 1,秒数为 40。

                              • setMinutes() 根据本地时间设置 Date 对象中的秒数(参数:0 ~ 59

                                超出合理范围,日期对象的时间会相应地更更新。例如 60,小时数加 1,秒数为 0。

                              • setHours() 根据本地时间设置 Date 对象中的小时数(参数:0 ~ 23

                                超出合理范围,日期对象的时间会相应地更更新。例如 24,天数加 1,小时数为 0。

                              UTC 时间

                              上述所讲的 Get 和 Set 方法是针对本地时区时间去获取、设置时间的。而 Date 同样提供了获取、设置 UTC 时间,方法如:getUTCMonth()setUTCMonth() 等等。实践过程中,比较少涉及 UTC 时间,所以不展开赘述。可以偷偷点这里去了解。

                              Date 其他方法

                              • Date.now()

                                返回自 1970-1-1 00:00:00(UTC)到当前时间的毫秒数。

                              • Date.prototype.toString()Date.prototype.toTimeString()Date.prototype.toDateString()

                                返回值是以美式英语和人类易读的形式返回日期对象的格式化字符串。该字符串由日期部分(年月日)和时间部分(时分秒及时区)组成。

                              const date = new Date()
                              
                              date.toString() // "Thu Dec 03 2020 18:19:17 GMT+0800 (中国标准时间)"
                              date.toDateString() // "Thu Dec 03 2020"
                              date.toTimeString() // "18:19:17 GMT+0800 (中国标准时间)"
                              
                              • Date.parse()Date.prototype.getTime()Date.prototype.valueOf() 它们都返回 1970-1-1 00:00:00(UTC)到指定日期的毫秒数。但是它们还是有区别的。

                                前者精确到,而后两者是精确到毫秒

                              const date = new Date('5/24/2020')
                              date.setMilliseconds(45) // 设置毫秒数
                              
                              console.log(date.getTime()) // 1590249600045
                              console.log(date.valueOf()) // 1590249600045
                              console.log(Date.parse(date)) // 1590249600000
                              

                              兼容性

                              类似 new Date('xxxx/xx/xx xx:xx:xx') 形式的时间对象在 IOS 和 Andriod 系统上都可以被正确的识别,而类似 new Date('xxxx-xx-xx xx:xx:xx') 形式的时间对象在 iOS 系统上无法被正确的识别,需要做一层转化。

                              const date = new Date('xxxx-xx-xx xx:xx:xx'.replace(/-/g, '/'))
                              

                              UTC vs GMT

                              • UTC 世界协调时间 Greenwich Mean Time
                              • GMT 格林威治标准时间 Universal Time Coordinated

                              详见 GMT 与 UTC 简介

                              参考链接

                              ]]>
                              <![CDATA[数组的高阶函数 Array.prototype.reduce]]> https://github.com/tofrankie/blog/issues/225 https://github.com/tofrankie/blog/issues/225 Sun, 26 Feb 2023 10:50:14 GMT 一、背景

                              最近在写 React 系列文章,其中写到 Redux 的时候(这里),提到了一个叫做 compose 的高阶函数, 它里面用到的就是 一、背景

                              最近在写 React 系列文章,其中写到 Redux 的时候(这里),提到了一个叫做 compose 的高阶函数, 它里面用到的就是 Array.prototype.reduce 方法。它的源码如下:

                              // https://github.com/reduxjs/redux/blob/4.x/src/compose.js
                              export default function compose(...funcs) {
                                if (funcs.length === 0) {
                                  return arg => arg
                                }
                              
                                if (funcs.length === 1) {
                                  return funcs[0]
                                }
                              
                                return funcs.reduce((a, b) => (...args) => a(b(...args)))
                              }
                              

                              其实类似的组合函数,很多都是使用到 Array.prototype.reduce 方法。在此之前,平时比较少用到它,就没好好整理过知识点,但最近我认为,这是必须掌握而且用起来的方法。

                              To be continue...

                              二、reduce 方法

                              它是 ES5 提供的一个数组原生的方法,语法如下:

                              arr.reduce(reducer, [initialValue])
                              

                              参数:

                              • reducer:是执行数组中每个值(如果没有提供 initialValue 则第一个值除外)的函数。

                              • initialValue(可选):作为第一次调用 reducer 函数时的第一个参数的值。如果没有提供初始值,则将使用数组中的第一个元素。在没有初始值的空数组上调用 reduce 方法将报错。

                              返回值:

                              函数累计处理的结果

                              const arr1 = [1, 2, 3, 4]
                              const arr2 = []
                              const arr3 = new Array(10)
                              
                              const reducer = (acc, cur) => {
                                console.log(`acc: ${acc}, cur: ${cur}`)
                                return acc + cur
                              }
                              
                              // (1) 是否含初始值的区别
                              const sum1 = arr1.reduce(reducer) // 循环三次(reduce没有初始值,数组第一项被作为 acc 的初始值)
                              const sum2 = arr1.reduce(reducer, 0) // 循环四次
                              
                              // (2) 空数组访问 reduce 方法,必须传初始值,否则报错
                              const sum3 = arr2.reduce(reducer, 0) // 初始值 0 作为返回结果,且不执行 reducer
                              const sum4 = arr2.reduce(reducer) // 运行报错
                              
                              // (3) 同理
                              const sum5 = arr3.reduce(reducer, 0) // 初始值 0 作为返回结果,从未被赋值的元素不执行 reducer
                              const sum6 = arr3.reduce(reducer) // 运行报错
                              
                              console.log(sum1, sum2, sum3, sum4, sum5, sum6)
                              

                              三、reducer 函数

                              arr.reduce(reducer(accumulator, currentValue, index, array), initialValue)
                              

                              四个参数:

                              • accumulator:累计器回调的返回值。它是上一次调用回调时返回的累计值。若它是第一次执行,它的值就是 initialValue 或者数组的第一项(结合上面的理解)。千万不要被它的命名吓到,如果真是这样,就把它当做参数 abc 看待,因为它只是一个存储之前执行结果的变量而已。

                              • currentValue:当前正在遍历的数组元素。

                              • index(可选):当前正在遍历的数组元素的索引。若提供了 initialValue,则起始索引是 0,否则从索引 1 起始。

                              • array(可选):调用 reduce() 的数组

                              四、MDN 官方描述

                              reduce 为数组中的每一个元素依次执行 reducer 函数,不包括数组中被删除或从未被赋值的元素,接受四个参数:

                              • accumulator 累计器
                              • currentValue 当前值
                              • currentIndex 当前索引
                              • array 数组

                              回调函数第一次执行时,accumulator 和 currentValue 的取值有两种情况:

                              1. 如果调用 reduce() 时提供了 initialValueaccumulator 取值为 initialValuecurrentValue 取数组中的第一个值;
                              2. 如果没有提供 initialValue,那么 accumulator 取数组中的第一个值,currentValue 取数组中的第二个值。

                              注意:如果没有提供 initialValuereduce 会从索引 1 的地方开始执行 reducer 方法,跳过第一个索引。如果提供 initialValue,从索引 0 开始。

                              如果数组为空且没有提供 initialValue,会抛出 TypeError。如果数组仅有一个元素(无论位置如何)并且没有提供 initialValue, 或者有提供 initialValue 但是数组为空,那么此唯一值将被返回并且 reducer 不会被执行。

                              这一小节跟上面的是有重复的。

                              就本节内容,举例说明一下:

                              // 在 Array 原型上添加一个 delete 方法:用于删除数组某项
                              Array.prototype.delete = function (item) {
                                for (let i = 0; i < this.length; i++) {
                                  if (this[i] === item) {
                                    this.splice(i, 1)
                                    return this
                                  }
                                }
                                return this
                              }
                              
                              const arr1 = new Array(10)
                              const arr2 = [1, 2, undefined, null]
                              const arr3 = [1, 2, 3, 4]
                              
                              const reducer = (acc, cur, index, arr) => {
                                // 删除 arr3 数组的第二项
                                if (index === 0 && arr === arr3) arr.delete(2)
                                return `${acc} - ${cur}`
                              }
                              
                              const sum1 = arr1.reduce(reducer, 0) // 返回:0
                              const sum2 = arr2.reduce(reducer, 0) // 返回:0 - 1 - 2 - undefined - null
                              const sum3 = arr3.reduce(reducer, 0) // 返回:0 - 1 - 3 - 4
                              
                              // 结论:
                              // 1. 空项(未被赋值项)不执行 reducer ,而 undefined、null 会执行 reducer
                              // 2. 被删除项,不执行 reducer
                              // PS: 由于我经常被各种方法对 undefined、null、empty(空项)执不执行困扰,所以我就全列举出来了。
                              

                              五、应用场景

                              未完待续...

                              ]]>
                              <![CDATA[JavaScript ASI 机制详解,不用再纠结分号问题]]> https://github.com/tofrankie/blog/issues/223 https://github.com/tofrankie/blog/issues/223 Sun, 26 Feb 2023 10:43:12 GMT 前言

                              关于要不要加分号的问题,其实有很多争论!有的坚持加分号,而有的不喜欢加分号...但是无论那种风格,都不能百分百避免某些特殊情况产生的问题,究其根本就是因为对 JavaScript 解析和 ASI 规则的不了解。

                              对于此问题,看看几位大佬是怎么说的:

                              • ]]> 前言

                                关于要不要加分号的问题,其实有很多争论!有的坚持加分号,而有的不喜欢加分号...但是无论那种风格,都不能百分百避免某些特殊情况产生的问题,究其根本就是因为对 JavaScript 解析和 ASI 规则的不了解。

                                对于此问题,看看几位大佬是怎么说的:

                                所有直觉性的 “当然应该加分号” 都是保守的、未经深入思考的草率结论。

                                个人立场是偏向 semicolon-less 风格,可能是强迫症使然,还有加了也避免不了特殊情况。

                                ASI 是什么?

                                按照 ECMAScript 标准,一些特定语句(statement)必须以分号结尾。分号代表这段语句的终止。但是有时候为了方便,这些分号是有可以省略的。这种情况下解析器会自己判断语句该在哪里终止。这种行为被叫做「自动插入分号」,简称 ASIAutomatic Semicolon Insertion)。

                                实际上分号并没有真的被插入,只是便于解释的形象说法。

                                这些特定的语句有:

                                • 空语句
                                • let
                                • const
                                • import
                                • export
                                • 变量赋值
                                • 表达式
                                • dubugger
                                • continue
                                • break
                                • return
                                • throw

                                ASI 会按照一定的规则去判断,应该在哪里插入分号。但在此之前,先了解一下 JavaScript 是如何解析代码的。

                                Token

                                解析器在解析代码时,会把代码分成很多 token,一个 token 相当于一小段有特定意义的语法片段。

                                看例子:

                                var a = 12;
                                

                                通过 Esprima Parser 可以看到被拆分为多个 token

                                [
                                  {
                                    "type": "Keyword",
                                    "value": "var"
                                  },
                                  {
                                    "type": "Identifier",
                                    "value": "a"
                                  },
                                  {
                                    "type": "Punctuator",
                                    "value": "="
                                  },
                                  {
                                    "type": "Numeric",
                                    "value": "12"
                                  },
                                  {
                                    "type": "Punctuator",
                                    "value": ";"
                                  }
                                ]
                                

                                以上变量声明语句可以分成五个 token

                                • var 关键字
                                • a 标识符
                                • = 标点符号
                                • 12 数字
                                • ; 标点符号

                                解析器在解析语句时,会一个一个地读入 token 尝试构成给一个完整的语句(statement),直到碰到特定情况(例如语法规定的终止)才会认为这个语句结束了。这个例子中的终止符就是分号。用 token 构成语句的过程类似于正则里的贪婪匹配,解释器总是试图用尽可能多的 token 构成语句。

                                敲重点!!!

                                任意 token 之间都可以插入一个或多个换行符(Line Terminator),这完全不会影响 JavaScript 的解析。

                                var
                                a
                                =
                                
                                // 假设这里有 N 个换行符
                                
                                12
                                ;
                                

                                上面的代码,跟之前单行 var a = 12; 是等价的。这个特性可以让开发者通过增加代码的可读性,更灵活地组织语言风格。平时写的跨多行数组、字符串拼接、链式调用等都属于这一类。

                                当然了,在省略分号的风格中,这种特性会导致一些意外的情况。举个例子:

                                var a
                                  , b = 12
                                  , hi = 2
                                  , g = {exec: function() { return 3 }}
                                
                                a = b
                                /hi/g.exec('hi')
                                
                                console.log(a)
                                
                                // 打印出 2, 因为代码会被解析成:
                                // a = b / hi / g.exec('hi');
                                // a = 12 / 2 / 3
                                

                                以上例子,以 / 开头的正则表达式被解析器理解成除法运算,所以打印结果是 2

                                其实,这不是省略分号风格的错误,而是开发者没有理解 JavaScript 解析器的工作原理。如果你偏向于省略分号的,那更要理解 ASI 的原理了。

                                ASI 规则

                                ECMAScript 标准定义的 ASI 包括三条规定两条例外

                                三条规则

                                1. 解析器从左往右解析代码(读入 token),当碰到一个不能构成合法语句的 token 时,他会在以下几种情况中,在该 token 之前插入分号,此时不合群的 token 被称为违规 tokenoffending token
                                  • 1.1 如果这个 token 跟上一个 token 之间有至少一个换行。
                                  • 1.2 如果这个 token}
                                  • 1.3 如果前一个 token) 它会试图把签名的 token 理解成 do-while 语句并插入分号。
                                2. 当解析到文件末尾发现语法还是无法构成合法的语句,就会在文件末尾插入分号。
                                3. 当解析碰到 restricted production 的语法(比如 return),并且在 restricted production 规定的 [no LineTerminator here] 的地方发现换行,那么换行的地方就会被插入分号。

                                两个例外

                                1. 分号不能被解析成空语句。
                                2. 分号不能被解析成 for 语句的两个分号之一。

                                到这里,好像规则挺晦涩的。先别慌,看完下面的例子就能明白其中的含义了。

                                举例说明

                                就以上规则,举例说明助于理解。

                                例一:换行

                                a
                                b
                                

                                解析器一个一个读取 token,但读到第二个 token b 时,它就发现没法构成合法的语句,然后它发现 token b 和上一个 token a 是有换行的,于是按照 规则 1.1 的处理,在 token b 之前插入分号变成 a\n;b,这样语法就合法了。接着继续读取 token,这时读到文件末尾,token b 还是不能构成合法的语句,这是按照 规则 2 处理,在末尾插入分号终止。故得到:

                                a
                                ;b;
                                

                                例二:大括号

                                { a } b
                                

                                解析器仍然一个一个读取 token,读到 token } 时,它发现 { a } 是不合法的,因为 a 是表达式,它必须以分号结尾。但是当前 token},所以按照 规则 1.2 处理,他在 } 前面插入分号变成 { a; },接着往后读取 token b,按照 规则 2b 加上分号。故得到:

                                { a; } b;
                                

                                另外,也许有人会认为是 { a; }; ,但不是这样的。因为 {...} 属于块语句,而按照定义块语句是不需要分号结尾的,不管是不是在一行。因为块语句也被用在其他地方(比如函数声明),所以下面代码是完全合法的,不需要任何的分号。

                                function a() {} function b() {}
                                

                                例三:do-while 语句

                                这个是为了解释 规则 1.3,这是最绕的地方。

                                do a; while(b) c
                                

                                这例子种解析到 token c 的时候就不对了。这里既没有 换行,也没有 },但 token c 前面是 token ) ,所以解析器把之前的 token 组成一个语句,并判断是否为 do-while 语句,结果正好是的,于是自动插入分号变成 do a; whiile(b);,这种给 token c 加上分号。故得到:

                                do a; while(b); c;
                                

                                简单来说,do-while 后面的分号是自动插入的。但是如果其他以 ) 结尾的情况就不行了。规则 1.3 就是为 do-while 量身定做的。

                                例四:return

                                return
                                a
                                

                                我们都知道 return返回值 之间不能换行,因为上面代码会解析成:

                                return;
                                a;
                                

                                但为什么不能换行呢?是因为 return 语句就是一个 restricted production 语法。restricted production 是一组有严格限定的语法的统称,这些语法都是在某个地方不能换行的,不能换行的地方会被标注 [no LineTermiator here]

                                比如 ECMAScript 的 return 语法定义如下:

                                return [no LineTerminator here] Expression;
                                

                                这表示 return 跟表达式之间是不允许换行的(但后面的表达式内部可以换行)。如果这个地方恰好有换行,ASI 就会自动插入分号,这就是 规则 3 的含义。

                                刚才我们说了 restricted production 是一组语法的统称,它一共包含下面几个语法:

                                • 后缀表达式 ++--
                                • return
                                • continue
                                • break
                                • throw
                                • ES6 的箭头函数(参数和箭头之间不能换行)
                                • yield

                                这些无需死记,因为按照一般的书写习惯,几乎没有人会这样换行的。顺带一提,continuebreak 后面是可以接 label 的。但这不在本文讨论范围内,有兴趣自行探索。

                                例五:后缀表达式

                                a
                                ++
                                b
                                

                                解析器读到 token ++ 时发现语句不合法(注意:++ 不是两个 token,而是一个)。因为后缀表达式是不允许换行的。换句话说,换行的都不是后缀表达式,所以它只能按照 规则 1.1token ++ 前面插入分号来结束语句 a,然后继续执行,因为前缀表达式并不是 restricted production,所以 ++b 可以组成一条语句,然后按照 规则 2 在末尾加上分号。故得到:

                                a
                                ;++
                                b;
                                

                                例六:空语句

                                if (a)
                                else b
                                

                                解析器解析到 token else 时发现不合法( else 是不能跟在 if 语句头后面的),本来按照 规则 1.1,它应该加上分号变成 if (a)\n;,但是这样 ; 就变成空语句了,所以按照 例外 1,这个分号不能加,程序在 else 处抛异常结束。Node.js 的运行结果:

                                else b
                                ^^^^
                                
                                SyntaxError: Unexpected token else
                                

                                而以下这样语法是正确的。

                                if (a);
                                else b
                                

                                例七:for 语句

                                for(a; b
                                )
                                

                                解析器读到 token ) 时发现语法不合法,本来换行可以自动插入分号,但是按照 例外 2,不能为 for 头部自动插入分号,于是程序在 ) 处抛异常结束。Node.js 运行结果如下:

                                )
                                ^
                                
                                SyntaxError: Unexpected token )
                                

                                如何手动测试 ASI

                                我们很难有办法去测试 ASI 是不是如预期那样工作的,只能看到代码最终执行结果是对是错。ASI 也没有手动打开或者关掉去对比结果。但我们可以通过对比解析器生成的 tree 是否一致来判断 ASI 插入的分号是不是跟我们预期的一致。这点可以用 Esprima Parser 验证。

                                举个例子:

                                do a; while(b) c
                                

                                Esprima 解析的 Syntax 如下所示(不需要看懂,记住大概样子就好):

                                {
                                  "type": "Program",
                                  "body": [
                                    {
                                      "type": "DoWhileStatement",
                                      "body": {
                                        "type": "ExpressionStatement",
                                        "expression": {
                                          "type": "Identifier",
                                          "name": "a"
                                        }
                                      },
                                      "test": {
                                        "type": "Identifier",
                                        "name": "b"
                                      }
                                    },
                                    {
                                      "type": "ExpressionStatement",
                                      "expression": {
                                        "type": "Identifier",
                                        "name": "c"
                                      }
                                    }
                                  ],
                                  "sourceType": "script"
                                }
                                

                                然后我们主动加上分号,输入进去:

                                do a; while(b); c;
                                

                                你就会发现 do a; while(b) cdo a; while(b); c; 生成的 Syntax 是一致的。这说明解析器对这两段代码解析过程是一致的,我们并没有加入任何多余的分号。

                                然后试试这个多余分号的版本:

                                do a; while(b); c;; // 结尾多一个分号
                                

                                它的结果是:

                                {
                                  "type": "Program",
                                  "body": [
                                    {
                                      "type": "DoWhileStatement",
                                      "body": {
                                        "type": "ExpressionStatement",
                                        "expression": {
                                          "type": "Identifier",
                                          "name": "a"
                                        }
                                      },
                                      "test": {
                                        "type": "Identifier",
                                        "name": "b"
                                      }
                                    },
                                    {
                                      "type": "ExpressionStatement",
                                      "expression": {
                                        "type": "Identifier",
                                        "name": "c"
                                      }
                                    },
                                    {
                                      "type": "EmptyStatement"
                                    }
                                  ],
                                  "sourceType": "script"
                                }
                                

                                你会发现多出来一条空语句(EmptyStatement),那么这个分号就是多余的。

                                最后

                                看到这里,相信你对 JavaScript 解析机制和 ASI 机制有一个大致的了解了。

                                所以即使坚持使用分号,仍然会因为 ASI 机制导致产生与预期不一致的结果。

                                比如下面例子,这不是加不加分号的问题,而是懂不懂 JavaScript 解析的问题。

                                // 坚持添加分号的小明
                                return 
                                123;
                                
                                // 坚持不添加分号的小红
                                return
                                123
                                
                                // 以上小明、小红都不能返回预期的结果 123,而是 undefined。
                                // 因为 JavaScript 解析器解析它们,都会变成这样:
                                return;
                                123;
                                
                                // 如果我们懂得了 JavaScript 解析规则,那么无论我们写分号或者不写分号,都能得到预期结果。
                                return 123
                                return 123;
                                

                                正如文章开头所说,我更偏向于不加分号的。这时候,我们要注意一些特殊情况,以避免 ASI 机制产生与我们编写程序的预期结果不一致的问题。

                                敲重点!!!

                                如果一条语句是以 ([/+- 开头,那么就要注意了。根据 JavaScript 解析器的规则,尽可能读取更多 token 来构成一个完整的语句,而以上这些 token 极有可能与前一个 token 可组成一个合法的语句,所以它不会自动插入分号。

                                实际项目中,以 /+- 作为行首的代码其实是很少的,([ 也是较少的。当遇到这些情况时,通过在行首手动键入分号 ; 来避免 ASI 规则产生的非预期结果或报错。

                                ( 开头的情况

                                a = b
                                (function() {
                                
                                })
                                

                                JavaScript 解析器会解析成这样:

                                a = b(function() {
                                
                                });
                                

                                [ 开头的情况

                                a = function() {
                                 
                                }
                                [1,2,3].forEach(function(item) {
                                 
                                })
                                

                                JavaScript 解析器会按以下这样去解析,由于 function() {}[1,2,3] 返回值是 undefined,所以就会报错。

                                a = function() {
                                }[1,2,3].forEach(function(item) {
                                 
                                });
                                

                                / 开头的情况

                                a = 'abc'
                                /[a-z]/.test(a)
                                

                                JavaScript 解析器会按以下这样去解析,所以就会报错。

                                a = ‘abc’/[a-z]/.test(a);
                                

                                + 开头的情况

                                a = b
                                +c
                                

                                JavaScript 解析器会解析成这样:

                                a = b + c;
                                

                                - 开头的情况

                                a = b
                                -c
                                

                                JavaScript 解析器会解析成这样:

                                a = b - c;
                                

                                关于后缀表达式 ++-- 跟上面的有点区别,上面已经举例说明了,它属于 restricted production 的情况之一,会在换行处自动插入分号,所以它们不能换行写,否则可能会产生非预期结果。

                                所以理解了以上规则之后,我可以愉快了使用 ESLint + Prettier 一键去掉分号以及统一格式化,而不会有任何的负担了。

                                参考链接

                                ]]>
                                <![CDATA[JS 包装类]]> https://github.com/tofrankie/blog/issues/222 https://github.com/tofrankie/blog/issues/222 Sun, 26 Feb 2023 10:42:19 GMT JavaScript 的「原始值」其实是没有任何的属性和方法的

                                那么问题来了,

                                const str = 'abc'
                                console.log(str.length) // 3
                                
                                            JavaScript 的「原始值」其实是没有任何的属性和方法的

                                那么问题来了,

                                const str = 'abc'
                                console.log(str.length) // 3
                                

                                那为什么能访问到 length 属性呢?不是说原始值没有属性、方法吗?

                                // console.log(str.length) 经历了什么过程呢?
                                // 1. 使用 new String(str) ,实例化一个 String 对象;(假设为 obj)
                                // 2. 这个实例化对象 obj 下面有个 length 属性,然后 obj.length 就能获取到对应的属性值 3;
                                // 3. 接着马上销毁 delete obj.length。
                                

                                再来,

                                const str = 'abc'
                                str.len = 1
                                console.log(str.len) // undefined
                                
                                // 相当于
                                const str = 'abc'
                                new String(str).len = 1
                                console.log(new String(str).len)
                                
                                • 在第二行的 str.len = 1 里面,虽然给 String 的实例化对象的 len 属性赋值 1,但由于没有保存起来,所以,接着系统会马上给销毁掉 delete new String(str).len
                                • 第三行里面,又是一个新的实例化对象,而该实例化对象里面并没有 len 属性(或方法),所以输出的是 undefined

                                所以,我们常见的 string.length 并不是我们表面看到的 string 变量有一个 length 属性,它没有。

                                记住,原始值是没有属性和方法的

                                str.len = 1 不报错,为什么呢?

                                因为经历了一个包装类。BooleanStringNumber 的原始值都可以加属性和方法,因为有包装类,但是 Null 和 Undefined 加属性和方法会报错(例外)。

                                有些文章把这个过程成为对象的装箱与拆箱。

                                ]]>
                                <![CDATA[关于 var、let 的顶层对象的属性]]> https://github.com/tofrankie/blog/issues/221 https://github.com/tofrankie/blog/issues/221 Sun, 26 Feb 2023 10:41:18 GMT 顶层对象 ,在浏览器环境指的是 window 对象,在 Node 指的是 global 对象。ES5 之中,顶层对象的属性与全局变量是等价的。

                                window.]]>
                                            顶层对象 ,在浏览器环境指的是 window 对象,在 Node 指的是 global 对象。ES5 之中,顶层对象的属性与全局变量是等价的。

                                window.a = 1;
                                console.log(a);  // 1
                                
                                a = 2;
                                console.log(window.a);  // 2
                                

                                在上面代码中,顶级对象的属性赋值与全局变量的赋值,是同一件事。

                                顶层对象的属性与全局变量挂钩,被认为是 JavaScript 语言最大的设计败笔之一。这样的设计带来了几个很大的问题,首先是没法在编译时就报出变量未声明的错误,只有运行时才能知道(因为全局变量可能是顶层对象的属性创造的,而属性的创造是动态的);其次,程序员很容易不知不觉地就创建了全局变量(比如打字出错);最后,顶层对象的属性是到处可以读写的,这非常不利于模块化编程。另一方面,window 对象有实体含义,指的是浏览器的窗口对象,顶层对象是一个有实体含义的对象,也是不合适的。

                                ES6 为了改变这一点,一方面规定,为了保持兼容性,varfunction 声明的全局变量,依旧是顶层对象的属性;另一方面规定,letconstclass 声明的全局变量,不属于顶层对象的属性。也就是说,从 ES6 开始,全局变量将逐步与顶层对象的属性脱钩。

                                var a = 1;
                                // 如果在 Node 的 REPL 环境,可以写成 global.a
                                // 或者采用通用方法,写成 this.a
                                console.log(window.a);  // 1
                                console.log(this.a);    // 1
                                
                                let b = 1;
                                console.log(window.b);  // undefined
                                console.log(this.b);    // undefined
                                

                                上面代码中,全局变量 avar 命令声明,所以它是顶层对象的属性;全局变量 blet 命令声明,所以它不是顶层对象的属性,返回 undefined

                                ]]>
                                <![CDATA[ECMAScript 和 JavaScript 的关系?]]> https://github.com/tofrankie/blog/issues/220 https://github.com/tofrankie/blog/issues/220 Sun, 26 Feb 2023 10:39:23 GMT 一个常见的问题是,ECMAScript 和 JavaScript 到底是什么关系?

                                要讲清楚这个问题,需要回顾历史。1996 年 11 月,JavaScript 的创造者 Netscape 公司,决定将 JavaScript 提交给标准化组织 ECMA,希望这种语言能够成为国际标准。次年,ECMA ]]> 一个常见的问题是,ECMAScript 和 JavaScript 到底是什么关系?

                                要讲清楚这个问题,需要回顾历史。1996 年 11 月,JavaScript 的创造者 Netscape 公司,决定将 JavaScript 提交给标准化组织 ECMA,希望这种语言能够成为国际标准。次年,ECMA 发布 262 号标准文件(ECMA-262)的第一版,规定了浏览器脚本语言的标准,并将这种语言称为 ECMAScript,这个版本就是 1.0 版。

                                该标准从一开始就是针对 JavaScript 语言制定的,但是之所以不叫 JavaScript,有两个原因。一是商标,Java 是 Sun 公司的商标,根据授权协议,只有 Netscape 公司可以合法地使用 JavaScript 这个名字,且 JavaScript 本身也已经被 Netscape 公司注册为商标。二是想体现这门语言的制定者是 ECMA,不是 Netscape,这样有利于保证这门语言的开放性和中立性。

                                因此,ECMAScript 和 JavaScript 的关系是,前者是后者的规格,后者是前者的一种实现(另外的 ECMAScript 方言还有 JScript 和 ActionScript)。日常场合,这两个词是可以互换的。

                                ]]>
                                <![CDATA[如何判断 JS 对象为空]]> https://github.com/tofrankie/blog/issues/219 https://github.com/tofrankie/blog/issues/219 Sun, 26 Feb 2023 10:38:50 GMT 配图源自 Freepik

                                对象属性有这几种情况下:

                                1. 自身属性还是原型上的属性]]> 配图源自 Freepik

                                  对象属性有这几种情况下:

                                  1. 自身属性还是原型上的属性
                                  2. 是否为可枚举属性
                                  3. 是否包含 Symbol 属性

                                  不同的遍历方式:

                                  • for...in:遍历自身继承过来可枚举属性(不包括 Symbol 属性)。
                                  • Object.keys:返回一个数组,包含对象自身所有可枚举属性(不包括不可枚举属性和 Symbol 属性)
                                  • Object.getOwnPropertyNames:返回一个数组,包含对象自身的属性(包括不可枚举属性,但不包括 Symbol 属性)
                                  • Object.getOwnPropertySymbols:返回一个数组,包含对象自身的所有 Symbol 属性(包括可枚举和不可枚举属性)
                                  • Reflect.ownKeys:返回一个数组,包含自身所有的属性(包括 Symbol 属性,不可枚举属性以及可枚举属性)

                                  一般的业务场景,没有那么复杂,使用 Object.keys

                                  const obj = {}
                                  Object.keys(obj).length === 0 // true
                                  

                                  若要考虑不可枚举属性、Symbol 属性,使用 Reflect.ownKeys

                                  const obj = {}
                                  Reflect.ownKeys(obj).length === 0 // true
                                  

                                  一句话:按需使用。

                                  ]]>
                                  <![CDATA[module.exports 属性与 exports 变量的区别]]> https://github.com/tofrankie/blog/issues/218 https://github.com/tofrankie/blog/issues/218 Sun, 26 Feb 2023 10:36:51 GMT 一、CommonJS 模块规范

                                  Node 应用由模块组成,采用 CommonJS 模块规范。每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。

                                  CommonJS 规范规定,每个模块内部,module 变]]> 一、CommonJS 模块规范

                                  Node 应用由模块组成,采用 CommonJS 模块规范。每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。

                                  CommonJS 规范规定,每个模块内部,module 变量代表当前模块。这个变量是一个对象,它的 exports 属性(即 module.exports )是对外的接口。加载某个模块,其实是加载该模块的 module.exports 属性。

                                  CommonJS 规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。

                                  CommonJS 模块的特点如下:

                                  • 所有代码都运行在模块作用域,不会污染全局作用域。
                                  • 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
                                  • 模块加载的顺序,按照其在代码中出现的顺序。

                                  1. module.exports 属性

                                  module.exports 属性表示当前模块对外输出的接口,其他文件加载该模块,实际上就是读取 module.exports 变量。

                                  2. exports 变量

                                  为了方便,Node 为每个模块提供一个 exports 变量,它指向 module.exports。这等同于在每个模块头部,有一行这样的命令:var exports = module.exports,我们可以通过以下方式来验证

                                  console.log(exports === module.exports);  // true
                                  

                                  所以在对外输出模块接口时,可以向 exports 对象添加属性方法。

                                  module.exports.age = 20
                                  module.exports.getAge = function() {}
                                  
                                  // 相当于
                                  exports.age = 20
                                  exports.getAge = function() {}
                                  

                                  但是不能直接将 exports 变量指向一个值,因为这样等于切断了 exportsmodule.exports 的联系。

                                  // 以下写法无效,因为 exports 不再指向 module.exports 了。
                                  exports = function() {};
                                  

                                  3. module.exports 与 exports 的使用

                                  当一个模块的对外接口,只是一个单一的值时,不能使用 exports 输出,只能使用 module.exports 输出。

                                  // moduleA.js
                                  // 1️⃣ 正确 ✅
                                  module.exports = function() {};
                                  
                                  // 2️⃣ 错误 ❌
                                  exports = function() {};
                                  

                                  导入模块看结果:

                                  // other.js
                                  var moduleA = require('moduleA.js');
                                  console.log(moduleA);
                                  
                                  // 两种写法打印的值分别为:
                                  // 1️⃣ 预期结果 ✅  ƒ () { console.log('moduleD'); }
                                  // 2️⃣ 非预期结果 ❌  {}
                                  

                                  分析结果: 首先我们要知道 module.exports 的初始值是 {},当执行 exports = function() {}; 赋值时,无论赋值的是基本数据类型还是引用数据类型,都将改变 exports 的指向,即切断了 exportsmodule.exports 的联系。但是我们模块对外输出的接口是 module.exports,所以 2️⃣ 得到的是初始值 {}

                                  如果你觉得 exportsmodule.exports 之间的区别很难分清,一个简单的处理方法,就是放弃使用 exports,只使用 module.exports

                                  *我个人也没觉得 exports 的写法有多方便,哈哈。

                                  4. 总结

                                  • module.exports 初始值为一个空对象 {}
                                  • exports 是指向的 module.exports 的引用;
                                  • require() 返回的是 module.exports 而不是 exports

                                  还是那句话,如果你觉得 exportsmodule.exports 之间的区别很难分清,一个简单的处理方法,就是放弃使用 exports,只使用 module.exports

                                  二、require() 扩展话题

                                  以下案例源自知乎回答

                                  关于 require() 的解释

                                  To illustrate the behavior, imagine this hypothetical implementation of require(), which is quite similar to what is actually done by require():

                                  function require(/* ... */) {
                                    const module = { exports: {} };
                                    ((module, exports) => {
                                      // Module code here. In this example, define a function.
                                      function someFunc() {}
                                      exports = someFunc;
                                      // At this point, exports is no longer a shortcut to module.exports, and
                                      // this module will still export an empty default object.
                                      module.exports = someFunc;
                                      // At this point, the module will now export someFunc, instead of the
                                      // default object.
                                    })(module, module.exports);
                                    return module.exports;
                                  }
                                  

                                  注意实现顺序,也就是下面代码为什么不成功的原因。

                                  // moduleA.js
                                  module.exports = function() {};
                                  // 为什么这段配置不成功?你们有 BUG!!!
                                  exports.abc = 'abc';
                                  

                                  require() 的时候,是先通过 exports.abc 获取, 然后通过 module.exports 直接覆盖了原有的 exports,所以 exports.abc = 'abc' 就无效了。

                                  一般库的封装都是 exports = module.exports = _ (underscore 的例子)。

                                  原因很简单,通过 exports = module.exportsexports 重新指向 module.exports

                                  三、参考链接

                                  ]]>
                                  <![CDATA[关于 ES6 class 类]]> https://github.com/tofrankie/blog/issues/217 https://github.com/tofrankie/blog/issues/217 Sun, 26 Feb 2023 10:36:30 GMT 本文用来记录之前比较少用到的知识点。

                                  一、super 关键字

                                  // 父类
                                  class Person {
                                    constructor(name) {
                                      this.name = name
                                    }
                                    showName() {]]>
                                              本文用来记录之前比较少用到的知识点。

                                  一、super 关键字

                                  // 父类
                                  class Person {
                                    constructor(name) {
                                      this.name = name
                                    }
                                    showName() {
                                      console.log(`My name is ${this.name}. (class Person)`)
                                    }
                                  }
                                  
                                  // 子类
                                  class Student extends Person {
                                    constructor(name, skill) {
                                      // 继承类中的构造函数必须调用 super(...),并且在使用 this 之前执行它。
                                      super(name)
                                      this.skill = skill
                                    }
                                  }
                                  
                                  // 实例
                                  let student = new Student('Frankie', 'JavaScript')
                                  console.log(student)    // Student {name: "Frankie", skill: "JavaScript"}
                                  student.showName()      // My name is Frankie. (class Person)
                                  

                                  一个极其简单的例子,问题如下:

                                  1. 假如我们的子类 Student 也有一个 showName() 方法,会怎样呢?

                                  class Student extends Person {
                                    constructor(name, skill) {
                                      super(name)
                                      this.skill = skill
                                    }
                                    showName() {
                                      console.log(`My name is ${this.name}. (class Student)`)
                                    }
                                  }
                                  

                                  那么(从自身找到了,自然停止往原型上找,没毛病)

                                  student.showName()      // My name is Frankie. (class Student)
                                  

                                  2. 如果我们既想执行父类 PersonshowName() 方法, 也要执行子类的 StudentshowName() 方法,要怎么办呢?

                                  class Student extends Person {
                                    constructor(name, skill) {
                                      super(name)
                                      this.skill = skill
                                    }
                                    showName() {
                                      super.showName()
                                      console.log(`My name is ${this.name}. (class Student)`)
                                    }
                                  }
                                  

                                  通常我们不想完全替代父类方法,而是在父类方法的基础上调整或扩展其功能。我们进行一些操作,让它之前或之后或在过程中调用父方法。

                                  Class 为此提供了 super 关键字。

                                  • 使用 super.method() 调用父类方法
                                  • 使用 super() 调用父构造函数(仅在 constructor 函数中)
                                  student.showName()      
                                  // My name is Frankie. (class Person)
                                  // My name is Frankie. (class Student)
                                  

                                  二、set、get

                                  与 ES5 一样, 在 Class 内部可以使用 getset 关键字, 对某个属性设置存值函数和取值函数,拦截该属性的存取行为。

                                  class Student {
                                    constructor(name, skill) {
                                      this.name = name
                                      this.skill = skill
                                    }
                                  
                                    // 不得有参数(Getter must not have any formal parameters.)
                                    get age() {
                                      console.log(`getter`)
                                    }
                                  
                                    // 必须有一个参数(Setter must have exactly one formal parameter.)
                                    set age(value) {
                                      console.log(`setter: ${value}`)
                                    }
                                  }
                                  
                                  let student = new Student('Frankie', 'JavaScript')
                                  student.age = 20    // setter: 20
                                  student.age         // getter
                                  
                                  ]]>
                                  <![CDATA[JavaScript 最全面的身份证号码校验方法]]> https://github.com/tofrankie/blog/issues/216 https://github.com/tofrankie/blog/issues/216 Sun, 26 Feb 2023 10:34:28 GMT 关于国家公民身份号码规定如下:

                                  /**
                                   * 〖中华人民共和国国家标准 GB 11643-1999〗中有关公民身份号码的规定:
                                   *    公民身份号码是特征组合码,由十七位数字本体码和一位数字校验码组成。排列顺序从左至右依次为:六位数字地址码]]>
                                              关于国家公民身份号码规定如下:

                                  /**
                                   * 〖中华人民共和国国家标准 GB 11643-1999〗中有关公民身份号码的规定:
                                   *    公民身份号码是特征组合码,由十七位数字本体码和一位数字校验码组成。排列顺序从左至右依次为:六位数字地址码,八位数字出生日期码,三位数字顺序码和一位数字校验码。
                                   *
                                   * 地址码编码规则为:
                                   *    第一位数字表示地区,1 是华北,2 是东北,3 是华东,4 是中南,5 是西南,6 是西北。
                                   *    第二位数字表示户籍地所在的直辖市、省、自治区,比如在华北地区,1 代表北京市,2 代表天津市等。
                                   *    第三、四位数字则表示户籍所在地的区、县、县级市、旗。
                                   *    第五、六位数字是户籍所在地。
                                   *
                                   * 出生日期码:
                                   *    身份证号码第七位到第十四位表示编码对象出生的年、月、日。
                                   *
                                   * 顺序码:
                                   *    身份证号码第十五位到十七位是顺序码,对同年、月、日出生的人员编定的顺序号。其中第十七位奇数分给男性,偶数分给女性。
                                   *
                                   * 校验码:
                                   *    身份证号码最后一位为校验码。
                                   *
                                   * 校验码的计算方法:
                                   *    一、将身份证号码前面的 17 位数分别乘以不同的系数。从第一位到第十七位的系数分别为:7-9-10-5-8-4-2-1-6-3-7-9-10-5-8-4-2。
                                   *    二、将这 17 位数字和系数相乘的结果相加。
                                   *    三、用加出来的和除以 11,余数只可能有:0-1-2-3-4-5-6-7-8-9-10 这 11 个数字。其分别对应的最后一位身份证的号码为:1-0-X -9-8-7-6-5-4-3-2。
                                   *    四、如果余数是 2,那么身份证的第 18 位数字就是 X。
                                   */
                                  

                                  身份证校验的 JavaScript 实现如下:

                                  /**
                                   * 支持 15 位和 18 位身份证号校验
                                   * 关于身份证号更多规则,请看文件末尾
                                   *
                                   * @param {string} code 身份证号码
                                   * @returns {boolean} 是否检验通过
                                   */
                                  export const checkIDNumber = code => {
                                    if (!code || typeof code !== 'string') return false
                                    // prettier-ignore
                                    const provinceCodes = ['11', '12', '13', '14', '15', '21', '22', '23', '31', '32', '33', '34', '35', '36', '37', '41', '42', '43', '44', '45', '46', '50', '51', '52', '53', '54', '61', '62', '63', '64', '65', '71', '81', '82', '91']
                                    const reg = /^\d{6}(18|19|20)?\d{2}(0[1-9]|1[012])(0[1-9]|[12]\d|3[01])\d{3}(\d|X)?$/i
                                    const codeLen = code.length
                                  
                                    // 格式错误
                                    if (!reg.test(code) || ![15, 18].includes(codeLen)) {
                                      return false
                                    }
                                  
                                    // 地址码错误
                                    const provinceCode = code.substring(0, 2)
                                    if (!provinceCodes.includes(provinceCode)) {
                                      return false
                                    }
                                  
                                    // 18 位需校验最后一位校验码
                                    if (codeLen === 18) {
                                      const codeArr = code.split('')
                                      const factor = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
                                      const parity = [1, 0, 'X', 9, 8, 7, 6, 5, 4, 3, 2]
                                      let sum = 0
                                      let ai = 0
                                      let wi = 0
                                      for (let i = 0; i < 17; i++) {
                                        ai = codeArr[i]
                                        wi = factor[i]
                                        sum += ai * wi
                                      }
                                      if (parity[sum % 11] != codeArr[17].toUpperCase()) {
                                        // 校验码错误
                                        return false
                                      }
                                    }
                                  
                                    return true
                                  }
                                  
                                  /**
                                   * 〖中华人民共和国国家标准 GB 11643-1999〗中有关公民身份号码的规定:
                                   *    公民身份号码是特征组合码,由十七位数字本体码和一位数字校验码组成。排列顺序从左至右依次为:六位数字地址码,八位数字出生日期码,三位数字顺序码和一位数字校验码。
                                   *
                                   * 地址码编码规则为:
                                   *    第一位数字表示地区,1 是华北,2 是东北,3 是华东,4 是中南,5 是西南,6 是西北。
                                   *    第二位数字表示户籍地所在的直辖市、省、自治区,比如在华北地区,1 代表北京市,2 代表天津市等。
                                   *    第三、四位数字则表示户籍所在地的区、县、县级市、旗。
                                   *    第五、六位数字是户籍所在地。
                                   *
                                   * 出生日期码:
                                   *    身份证号码第七位到第十四位表示编码对象出生的年、月、日。
                                   *
                                   * 顺序码:
                                   *    身份证号码第十五位到十七位是顺序码,对同年、月、日出生的人员编定的顺序号。其中第十七位奇数分给男性,偶数分给女性。
                                   *
                                   * 校验码:
                                   *    身份证号码最后一位为校验码。
                                   *
                                   * 校验码的计算方法:
                                   *    一、将身份证号码前面的 17 位数分别乘以不同的系数。从第一位到第十七位的系数分别为:7-9-10-5-8-4-2-1-6-3-7-9-10-5-8-4-2。
                                   *    二、将这 17 位数字和系数相乘的结果相加。
                                   *    三、用加出来的和除以 11,余数只可能有:0-1-2-3-4-5-6-7-8-9-10 这 11 个数字。其分别对应的最后一位身份证的号码为:1-0-X -9-8-7-6-5-4-3-2。
                                   *    四、如果余数是 2,那么身份证的第 18 位数字就是 X。
                                   *
                                   * 示例:
                                   *    某女性的身份证号码为【11010519491231002X】, 我们看看这个身份证是不是合法的身份证。
                                   *    首先我们得出前 17 位的乘积和【(1*7)+(1*9)+(0*10)+(1*5)+(0*8)+(5*4)+(1*2)+(9*1)+(4*6)+(9*3)+(1*7)+(2*9)+(3*10)+(1*5)+(0*8)+(0*4)+(2*2)】是 167,
                                   *    然后用 167 除以 11 得出的结果是 167/11=15----2,也就是说其余数是 2。
                                   *    最后通过对应规则就可以知道余数 2 对应的检验码是 X。
                                   *    所以,可以判定这是一个正确的身份证号码。
                                   *    请知悉,示例中身份证号码来自国家标准文件附录,请勿非法使用,否则后果自负!
                                   *
                                   * 附上:
                                   *    〖中华人民共和国国家标准 GB 11643-1999〗:http://www.gb688.cn/bzgk/gb/newGbInfo?hcno=080D6FBF2BB468F9007657F26D60013E
                                   *    关于身份证校验码,可以看:https://baike.baidu.com/item/身份证校验码/3800388
                                   *
                                   * 其他:
                                   *    相比 18 位的身份证号码,15 位的身份证号少了出入年份的前两位,以及最后的校验位。因此将你现在的身份证号码去掉这三位数字,使用本方法也可校验通过
                                   */
                                  
                                  // 地址码前两位,所对应的省市:
                                  // const provinceCodes = {
                                  //   11: '北京',
                                  //   12: '天津',
                                  //   13: '河北',
                                  //   14: '山西',
                                  //   15: '内蒙古',
                                  //   21: '辽宁',
                                  //   22: '吉林',
                                  //   23: '黑龙江',
                                  //   31: '上海',
                                  //   32: '江苏',
                                  //   33: '浙江',
                                  //   34: '安徽',
                                  //   35: '福建',
                                  //   36: '江西',
                                  //   37: '山东',
                                  //   41: '河南',
                                  //   42: '湖北',
                                  //   43: '湖南',
                                  //   44: '广东',
                                  //   45: '广西',
                                  //   46: '海南',
                                  //   50: '重庆',
                                  //   51: '四川',
                                  //   52: '贵州',
                                  //   53: '云南',
                                  //   54: '西藏',
                                  //   61: '陕西',
                                  //   62: '甘肃',
                                  //   63: '青海',
                                  //   64: '宁夏',
                                  //   65: '新疆',
                                  //   71: '台湾',
                                  //   81: '香港',
                                  //   82: '澳门',
                                  //   91: '国外'
                                  // }
                                  

                                  已收录在 tofrankie/utils,还有一些比较实用的方法,比如中文转拼音等。

                                  The end.

                                  ]]>
                                  <![CDATA[六、Ajax 之 FormData]]> https://github.com/tofrankie/blog/issues/215 https://github.com/tofrankie/blog/issues/215 Sun, 26 Feb 2023 10:32:51 GMT 表单编码

                                  当用户提交表单时,表单中的数据(每个表单元素的名字和值)编码到一个字符串并随请求发送。默认情况下,HTML 表单通过 POST 方法发送给服务器,而编码后的表单数据则用做请求主体。

                                  对表单数据使用的编码方案相对简单:对每个表单元素的名字和值执行普通的 URL ]]> 表单编码

                                  当用户提交表单时,表单中的数据(每个表单元素的名字和值)编码到一个字符串并随请求发送。默认情况下,HTML 表单通过 POST 方法发送给服务器,而编码后的表单数据则用做请求主体。

                                  对表单数据使用的编码方案相对简单:对每个表单元素的名字和值执行普通的 URL 编码(使用十六进制转义码替换特殊字符),使用 = 把编码后的名字和值分开,并使用 & 符号分开 名/值对

                                  一个简单的表单的编码如下所示:

                                  name=Frankie&age=20&height=180
                                  

                                  表单数据编码格式有一个正式的 MIME 类型:

                                  application/x-www-form-urlencoded
                                  

                                  当使用 POST 方法提交这种顺序的表单数据市,必须将请求头 Content-Type 设置为该值。 注意:这种类型的编码并不需要 HTML 表单,在 Ajax 应用中,希望发送给服务器的很可能是一个 JavaScript 对象,前面展示的数据变成 JavaScript 对象的表单编码形式可能是:

                                  { name: 'Frankie', age: 20, height: 180 }
                                  

                                  表单编码在 Web 上如此广泛地使用,同时所有服务器端的编程语言都能得到良好的支持,所以非表单数据的表单编码通常也是容易实现的事情,下面代码展示如何实现对象属性的表单编码:

                                  function encodeFormData(data) {
                                    if (!data) return ''
                                    const pairs = []    // 用于保存名值对
                                    for (let key in data) {
                                      if (!data.hasOwnProperty(key) || typeof data[key] === 'function') {
                                        continue
                                      }
                                      let value = data[key].toString()                        // 把值转换成字符串
                                      let name = encodeURIComponent(key.replace('%20', '+'))  // 编码名字
                                      value = encodeURIComponent(value.replace('%20', '+'))   // 编码值
                                      pairs.push(name + '=' + value)
                                    }
                                    return pairs.join('&')  // 返回使用'&'连接的名值对
                                  }
                                  
                                  const data = { name: '越前君', age: 20, height: 180 }
                                  console.log(encodeFormData(data))   // name=%E8%B6%8A%E5%89%8D%E5%90%9B&age=20&height=180
                                  

                                  表单序列化

                                  随着 Ajax 的出现,表单序列化已经成为一种常见的需求,在 JavaScript 中,可以利用表单字段的 type 属性,连同 namevalue 属性一起实现对表单序列化。在编写代码之前,有必要先弄清楚在表单提交期间,浏览器是如何将数据发送给服务器的。

                                  • 对表单字段的名称和值进行 URL 编码,使用 & 分隔;
                                  • 不发送禁用的表单字段;
                                  • 只发送勾选的复选框和单选框按钮;
                                  • 不发送 typeresetbutton 的按钮;
                                  • 对选选择框中,每个选中的值单独一个条目;
                                  • 在单击提交按钮提交表单的情况下,也会发送提交按钮;否则,不发送提价按钮。也包括 typeimage<input> 元素;
                                  • <select> 元素的值,就是选中的 <option> 元素的 value 属性值。 如果 <option> 元素没有 value 属性,则是 <option> 元素的文本值

                                  在表单序列化过程中,一般不包括任何按钮字段,因为结果字符串很可能是通过其他方式提交的。除此之外的其他上述规则都应该遵循。

                                  在下面表单序列化 serialize() 函数中,首先定义了一个名为 parts 数组,用于保存将要创建的字符串的各个部分。然后,通过 for 循环遍历每个表单字段,并将其保存在 field 变量中。在活动一个字段的引用之后,使用 switch 语句检测其 type 属性。

                                  序列化过程中最麻烦的就是 <select> 元素,它可能是单选框,也可能是多选框。为此,需要遍历控件中的每一个选项,并在相应选项被选中的情况下向数组添加一个值。对于单选框,只有一个选中项,而多选框则可能有零个或多个选中项。这里的代码适用于这两种选择框,至于可选项的数量则是由浏览器控制的。在找到一个选中项之后,需要确定使用什么值。如果不存在 value 属性,或者虽然存在该属性,但值为空字符串,都要使用选项的文本来代替。为检查这个特性,在 DOM 兼容的浏览器中需要使用 hasAttribute() 方法,而在 IE7 中需要使用属性 specified 属性。

                                  如果表单中包含 <fieldset> 元素,则该元素会出现在元素集合中,但没有 type 属性。因此,如果 type 属性未定义,则不需要对其进行序列化。同样,对于各种按钮以及文件输入字段也是如此(文件输入字段在表单提交过程中包含文件的内容;但是,这个字段是无法模仿的,序列化时一般都要忽略)

                                  对于单选按钮和复选框,要检查其 checked 属性是否被设置为 false,如果是则退出 switch 语句。如果 checked 属性是 true,则继续执行 default 语句,即将当前字段的名称和值进行编码,然后添加到 parts 数组中。函数的最后一步,就是使用 join() 个数整个字符串,也就是用 & 来分隔每一个表单字段。

                                  最后 serialize() 函数会以查询字符串的格式输出序列化之后的字符串。

                                  // 未完待续,例子晚点加上
                                  
                                  ]]>
                                  <![CDATA[JavaScript 之快速排序]]> https://github.com/tofrankie/blog/issues/214 https://github.com/tofrankie/blog/issues/214 Sun, 26 Feb 2023 10:31:21 GMT 配图源自 Freepik

                                  快速排序(快排)是最常见的算法之一。桶排序虽然快,但是空间消耗大,冒泡排序利用]]> 配图源自 Freepik

                                  快速排序(快排)是最常见的算法之一。桶排序虽然快,但是空间消耗大,冒泡排序利用的空间较为合理但是 O(n²),显然在数据量较大时不够快。快速排序算是从两者取长补短的算法。

                                  原理

                                  以升序为例:

                                  1. 在数据中选一个作为 基准数。(一般习惯选择中间的数,但选择其他数也可以)
                                  2. 将所有的数据进行遍历(除基准数外),小于等于基数的放到一个临时数组 arr1,大于放到一个临时数组 arr2
                                  3. 若只有两个数则将小的放前面,大的放后面;若 arr1arr2 只有 0~1 个则当前数组退出。
                                  4. arr1arr2 执行步骤 1、2、3。(即递归实现)

                                  图示

                                  ▲ 快速排序

                                  实现

                                  function quickSort(arr) {
                                    // 设置递归出口
                                    if (arr.length <= 1) return arr
                                    // 设置基数,并将基数移出
                                    let base = Math.floor(arr.length / 2)
                                    let baseValue = arr.splice(base, 1)[0]
                                    // 临时数组。1)小于等于基数放入 arr1;2)大于放入基数放入 arr2
                                    let arr1 = []
                                    let arr2 = []
                                    for (let i = 0; i < arr.length; i++) {
                                      if (arr[i] <= baseValue) {
                                        arr1.push(arr[i])
                                      } else {
                                        arr2.push(arr[i])
                                      }
                                    }
                                    // 递归
                                    return sort(arr1).concat([baseValue], sort(arr2))
                                  }
                                  
                                  let array = [4, 9, 3, 6, 21, 5, 0, 30, 2, 14]
                                  console.log(quickSort(array)) // [0, 2, 3, 4, 5, 6, 9, 14, 21, 30]
                                  

                                  时间复杂度

                                  快速排序的平均时间复杂度为 O(nlogn),快速排序的时间复杂度计算较为复杂。

                                  有兴趣的同学可参考:如何证明快速排序法的平均复杂度为 O(nlogn)?

                                  可以看到的是 快速排序 的时间复杂度是介于 桶排序 O(n)冒泡排序 O(n^2) 之间的: O(1) < O(logn) < O(n) < O(n*logn) < O(n^2) < O(n^3) < O(2^n) < O(n!) < O(n^n)

                                  ]]>
                                  <![CDATA[细读 JS | 深入继承原理]]> https://github.com/tofrankie/blog/issues/213 https://github.com/tofrankie/blog/issues/213 Sun, 26 Feb 2023 10:18:57 GMT

                                  系列文章:

                                  原文出自 ULIVZ

                        ES6class 语法糖你是否已经用得炉火纯青呢?那如果回归到 ES5 呢?

                        本文,将继续上一篇尾篇提出的疑问:如何用 JavaScript 实现类的继承? 来展开阐述。

                        通过本文,你将学到:

                        1. 如何用 JavaScript 模拟类中的私有变量?
                        2. 了解常见的几种 JavaScript 继承方法,原理极其优缺点。
                        3. 实现一个较为 fancy 的 JavaScript 继承方法。

                        此外,如果你完全明白了文末的终极版继承,你也就懂了这两篇所要将的核心知识,同时也能说明你拥有不错的 JavaScript 基础。

                        我们回顾一下 ES6 / TypeScript / ES5 的类写法以作对比。首先,我们创建一个 GitHubUser 类,它拥有一个 login 方法和一个静态方法 getPublicServices,用于获取 public 的方法列表:

                        // ES6
                        class GithubUser {
                          static getPublicServices() {
                            return ["login"];
                          }
                        
                          constructor(username, password) {
                            this.username = username;
                            this.password = password;
                          }
                        
                          login() {
                            console.log(this.username + " 要登录 Github,密码是:" + this.password);
                          }
                        }
                        

                        实际上,ES6 这个类的写法有一个弊病,实际上密码 password 应该是 GitHub 用户一个私有变量,接下来,我们用 TypeScript 重写一下:

                        // TypeScript
                        class GithubUser {
                          static getPublicServices() {
                            return ["login"];
                          }
                        
                          public username: string;
                          private password: string;
                        
                          constructor(username, password) {
                            this.username = username;
                            this.password = password;
                          }
                        
                          public login(): void {
                            console.log(this.username + " 要登录 Github,密码是:" + this.password);
                          }
                        }
                        

                        如此一来,password 就只能在类的内部访问了。好了,问题来了,如果结合原型讲解那疑问的知识,来用 ES5 实现这个类呢?

                        function GithubUser(username, password) {
                          // private 属性
                          let _password = password;
                          // public 属性
                          this.username = username;
                          // public 方法
                          GithubUser.prototype.login = function() {
                            console.log(this.username + " 要登录 Github,密码是:" + _password);
                          };
                        }
                        
                        GithubUser.getPublicServices = function() {
                          return ["login"];
                        };
                        

                        值得注意的是,我们一般都会把 共有方法 放在类的原型上,而不会采用 this.login = function() {} 这种写法。因为只有这样,才能让多个实例引用同一个共有方法,从而避免重复创建方法的浪费。

                        是不是很直观,留下 2 个疑问:

                        1. 如何实现 private 方法 呢?
                        2. 能否实现 protected 属性/方法 呢?

                        继承

                        我们如果创建一个 User 来继承 GitHubUser,那么 User 及其实例就可以调用 GitHubUserlogin 方法了。首先,先写出这个简单的 User 类:

                        function User(username, password, articles) {
                          // TODO need implementation
                          this.articles = articles;  // 文章数量
                          User.prototype.readArticle = function () {
                            console.log('Read article');
                          }
                        }
                        

                        由于 ES6/TS 的继承方式太过直观,本节将忽略。首先概述一下本文将要讲解的几种继承方式:

                        • 类式继承
                        • 构造函数继承
                        • 组合式继承
                        • 原型继承
                        • 寄生式继承
                        • 寄生组合式继承

                        看起来很多,下面我们一一论述。

                        类式继承

                        在此之前,我们已经得知:若通过 new Parent() 创建了 child,则 child.__proto__ = Parent.prototype,而原型链则顺着 __proto__ 依次向上查找。因此,可以通过修改子类的原型为父类的实例来实现继承。

                        function GithubUser(username, password) {
                          let _password = password;
                          this.username = username;
                          GithubUser.prototype.login = function () {
                            console.log(this.username + " 要登录 Github,密码是:" + _password);
                          };
                        }
                        
                        function User(username, password, articles) {
                          this.articles = articles;
                          User.prototype = new GithubUser(username, password);
                          User.prototype.readArticle = function () {
                            console.log('Read article');
                          }
                        }
                        var user = new User('Frankie', 'abc', 5);
                        console.log(user);
                        

                        在控制台查看打印结果:

                        诶,不对啊,很明显 user.__proto__ 并不是 GitHubUser 的一个实例。

                        实际上,这是因为之前我们为了能够在类的方法中读取私有变量,将 User.prototype 的重新赋值放在了构造函数中,而此时实例已经创建,其 __proto__ 还指向老的 User.prototype。所以,重新赋值一下实例的 __proto__。所以重新赋值一下实例的 __proto__ 就可以解决这些问题。

                        // 类式继承
                        
                        function GithubUser(username, password) {
                          let _password = password;
                          this.username = username;
                          GithubUser.prototype.login = function () {
                            console.log(this.username + " 要登录 Github,密码是:" + _password);
                          };
                        }
                        
                        function User(username, password, articles) {
                          this.articles = articles;
                          var prototype = new GithubUser(username, password);
                          // User.prototype = prototype;  // 这一行已经没有意义了
                          prototype.readArticle = function () {
                            console.log('Read article');
                          }
                          this.__proto__ = prototype;
                        }
                        var user = new User('Frankie', 'abc', 5);
                        console.log(user);
                        
                        // 但由于私自篡改了 __proto__,导致以下不成立
                        console.log(user.__proto__ === User.prototype);   // false
                        console.log(user instanceof User);                // false
                        

                        继续查看控制台打印的结果:

                        Perfect!原型链已经出来,问题“好像”得到了完美解决!但实际上还是有明显的问题:

                        1. 在原型链上创建了属性(一般来说,这不是一种好的实践)。
                        2. 私自篡改 __proto__,导致 user.__proto__ === User.prototype 不成立!从而导致 user instanceof User 也不成立,这不是应该发生的!

                        细心的同学会发现,造成这种问题的根本原因在于我们在实例化的时候动态修改了原型,那有没有一种方法可以在实例化之前就固定好类的原型的 refernce 呢?

                        事实上,我们可以考虑把类的原型赋值挪出来:

                        // 类式继承,修改版
                        
                        function GithubUser(username, password) {
                          let _password = password;
                          this.username = username;
                          GithubUser.prototype.login = function () {
                            console.log(this.username + " 要登录 Github,密码是:" + _password);
                          };
                        }
                        
                        function User(username, password, articles) {
                          this.articles = articles;
                        }
                        
                        // 此时构造函数还未运行,无法访问 username 和 password !
                        User.prototype = new GithubUser();
                        
                        User.prototype.readArticle = function () {
                          console.log('Read article');
                        }
                        
                        var user = new User('Frankie', 'abc', 5);
                        console.log(user)
                        
                        // 以下成立
                        console.log(user.__proto__ === User.prototype);   // true
                        console.log(user instanceof User);                // true
                        

                        打印 user 实例的结果:

                        这样做又有明显的缺点

                        1. 父类过早被创建,导致无法接受子类的动态参数;
                        2. 仍然在原型上创建了属性,此时,多个子类的实例将共享同一个父类的属性,完蛋会互相影响!
                        // 举例说明缺点 2:
                        function GithubUser(username) {
                          this.username = username;
                        }
                        
                        function User(username, password) {};
                        
                        User.prototype = new GithubUser();
                        var user1 = new User('Frankie', 'abc');
                        var user2 = new User('Mandy', 'mno');
                        
                        // 这就是把属性定义在原型链上的致命缺点,你可以直接访问,但修改就是一件难事了!
                        console.log(user1.username);          // undefined (缺点 1:父类过早地被创建,导致无法接受子类的动态参数)
                        user1.__proto__.username = 'Other';
                        console.log(user1.username);          // "Other"
                        
                        // 卧槽无情,影响了另一个实例!
                        console.log(user2.username);          // "Ohter"
                        

                        由此可见,类式继承 的两种方式缺陷太多!

                        构造函数继承

                        通过 call() 来实现继承。相应地,你可以用 apply() 实现。

                        // 构造函数继承
                        
                        function GithubUser(username, password) {
                          let _password = password;
                          this.username = username;
                          GithubUser.prototype.login = function () {
                            console.log(this.username + " 要登录 Github,密码是:" + _password);
                          };
                        }
                        
                        function User(username, password, articles) {
                          this.articles = articles;
                          GithubUser.call(this, username, password);
                          // 或者使用 apply
                          // GithubUser.apply(this, [username, password]);
                        }
                        
                        var user = new User('Frankie', 'abc', 5);
                        console.log(user.username);   // Frankie
                        console.log(user.articles);   // 5
                        user.login();                 // Uncaught TypeError: user.login is not a function
                        

                        当然,如果继承真如此简单,那么本文就没有存在的必要了,本继承方法也存在明显的缺陷:构造函数式继承并没有继承父类原型上的方法

                        组合式继承

                        既然上述两种方式各有缺点,但是又各有所长,那么我们是否可以将其结合起来使用呢??没错,这种方式叫做:组合式继承

                        // 组合式继承
                        
                        function GithubUser(username, password) {
                          let _password = password;
                          this.username = username;
                          GithubUser.prototype.login = function () {
                            console.log(this.username + " 要登录 Github,密码是:" + _password);
                          };
                        }
                        
                        function User(username, password, articles) {
                          this.articles = articles;
                          GithubUser.call(this, username, password);  // 第二次执行 GitHubUser 构造函数
                        }
                        
                        User.prototype = new GithubUser();    // 第一次执行 GitHubUser 构造函数
                        
                        var user = new User('Frankie', 'abc', 5);
                        console.log(user);
                        console.log(user.username);
                        console.log(user.articles);
                        user.login();
                        

                        打印结果如下:

                        虽然这种方式弥补了上述两种方式的一些缺陷,但有些问题仍然存在:

                        1. 子类仍旧无法传递动态参数给父类!
                        2. 父类的构造函数被调用了两次。

                        本方式很明显执行了两次父类的构造函数。因此,这也不是我们最终想要的继承方式。

                        原型继承

                        原型继承 实际上就是对 类式继承 的一种封装,只不过其独特之处在于,定义了一个干净的中间类,如下:

                        // 原型继承
                        
                        function createObject(o) {
                          // 创建临时类
                          function F() { };
                          // 修改临时类的原型为 o,于是 f 的实例都将继承 o 上的方法。
                          F.prototype = o;
                          return new F();
                        }
                        
                        function GithubUser(username, password) {
                          let _password = password;
                          this.username = username;
                          GithubUser.prototype.login = function () {
                            console.log(this.username + " 要登录 Github,密码是:" + _password);
                          };
                        }
                        
                        function User(username, password, articles) {
                          this.articles = articles;
                        }
                        
                        User.prototype = createObject(GithubUser);
                        
                        var user = new User('Frankie', 'abc', 5);
                        console.log(user.username);   // undefined
                        console.log(user.articles);   // 5
                        user.login();                 // Uncaught TypeError: user.login is not a function
                        

                        熟悉 ES5 的同学,会注意到,这不就是 Object.create 吗?没错,你可以认为是如此。

                        既然只是 类式继承 的一种封装,其使用方式如下:

                        User.prototype = createObject(GithubUser);
                        

                        也仍旧没有解决 类式继承 的一些问题。

                        个人觉得,原型继承类式继承 应该归为一种继承,但无奈众多 JavaScript 书籍均是如此命名,算是 follow legacy 的标准吧。

                        寄生继承

                        寄生继承 是依托于一个对象而生的一种继承方式,因此称之为 寄生

                        // 寄生继承
                        
                        var userSample = {
                          username: 'Frankie',
                          password: 'abc',
                          articles: 5
                        }
                        
                        function User(obj) {
                          var o = Object.create(obj);
                          o.readArticle = function () {
                            console.log('Read article');
                          }
                          return o;
                        }
                        
                        var user = new User(userSample);
                        console.log(user.username);   // Frankie
                        user.readArticle();           // Read article
                        

                        由于实际情况,继承一个单例对象的场景实在是太少。因此,我们仍然没有找到最佳的继承方法。

                        寄生组合式继承

                        看起来很玄乎,先看代码:

                        // 寄生组合式继承
                        
                        // 核心方法
                        function inherit(child, parent) {
                          // 继承父类的原型
                          var p = Object.create(parent.prototype);
                          // 重写子类的原型
                          child.prototype = p;
                          // 重写被污染的子类的 constructor
                          p.constructor = child;
                        }
                        
                        // 父类
                        function GithubUser(username, password) {
                          this.password = password;
                          this.username = username;
                        }
                        
                        GithubUser.prototype.login = function () {
                          console.log(this.username + ' 要登录 GitHub,密码是:' + this.password);
                        }
                        
                        // 子类
                        function User(username, password, articles) {
                          this.articles = articles;
                          GithubUser.call(this, username, password);
                        }
                        
                        // 实现原型上的方法
                        inherit(User, GithubUser);
                        
                        // 在原型上添加新的方法
                        User.prototype.readArticle = function () {
                          console.log('Read article');
                        }
                        
                        var user = new User('Frankie', 'abc', 5);
                        console.log(user);
                        

                        看下打印结果:

                        简单说明一下:

                        1. 子类继承了父类的属性和方法,同时属性没有被创建在原型链上,因此多个子类不会共享同一个属性。
                        2. 子类可以传递动态参数给父类。
                        3. 父类的构造函数只执行了一次。

                        Nice!这才是我们想要的继承方法。然而,仍然存在一个美中不足的问题

                        • 子类想要在原型上添加方法,必须在继承之后添加,否则将会覆盖掉原有原型上的方法。这样的话,若是已经存在的两个类,就不好办了。

                        所以,我们可以将其优化一下:

                        // 优化版:
                        function inherit(child, parent) {
                          // 继承父类的原型
                          var p = Object.create(parent.prototype);
                          // 重写子类的原型
                          child.prototype = Object.assign(parent.prototype, child.prototype);
                          // 重写被污染的子类的 constructor
                          p.constructor = child;
                        }
                        

                        但实际上,使用 Object.assign 来进行 copy 仍然不是最好的方法,根据 MDN 描述:

                        • The Object.assign() method is used to copy the values of all enumerable own properties from one or more source objects to a target object. It will return the target object.

                        其中有个很关键的词:enumerable,这已经不是本节讨论的知识了,不熟悉的同学可以参考 MDN - Object.defineProperty 补习。简单来说,上述的继承方法只适用于 copy 原型链上可枚举的方法,此外,如果子类本身已经继承自某个类,以上的继承将不能满足要求。

                        终极版继承

                        为了让代码更清晰,我用 ES6 的一些 API,写出来这个我认为最合理的继承方法:

                        • Reflect 代替了 Object
                        • Reflect.getPrototypeOf 来代替 obj.__proto__
                        • Reflect.ownKeys 来读取所有 可枚举/不可枚举/Symbol 的属性;
                        • Reflect.getOwnPropertyDescriptor 读取属性描述符;
                        • Reflect.setPropertyOf 来设置 __proto__

                        源码如下:

                        // 寄生组合式继承
                        
                        // 不同于 object.assign, 该 merge 方法会复制所有的源键
                        // 不管键名是 Symbol 或字符串,也不管是否可枚举
                        function fancyShadowMerge(target, source) {
                          for (const key of Reflect.ownKeys(source)) {
                            Reflect.defineProperty(target, key, Reflect.getOwnPropertyDescriptor(source, key));
                          }
                          return target;
                        }
                        
                        // Core
                        function inherit(child, parent) {
                          const objectPrototype = Object.prototype;
                        
                          // 继承父类的原型
                          const parentPrototype = Object.create(parent.prototype);
                          let childPrototype = child.prototype;
                        
                          // 若子类没有继承任何类,直接合并子类原型和父类原型上的所有方法
                          // 包含可枚举/不可枚举的方法
                          if (Reflect.getPrototypeOf(childPrototype) === objectPrototype) {
                            child.prototype = fancyShadowMerge(parentPrototype, childPrototype);
                          } else {
                            // 若子类已经继承了某个类
                            // 父类的原型将子类的原型链的尽头补全
                            while (Reflect.getPrototypeOf(childPrototype) !== objectPrototype) {
                              childPrototype = Reflect.getPrototypeOf(childPrototype);
                            }
                            Reflect.setPrototypeOf(childPrototype, parent.prototype);
                          }
                        
                          // 重写被污染的子类的 constructor
                          parentPrototype.constructor = child;
                        }
                        

                        测试:

                        function GithubUser(username, password) {
                          this.username = username;
                          this.password = password;
                        }
                        
                        GithubUser.prototype.login = function () {
                          console.log(this.username + " 要登录 Github,密码是:" + this.password);
                        }
                        
                        function User(username, password, articles) {
                          GithubUser.call(this, username, password);
                          WeiboUser.call(this, username, password);
                          this.articles = articles;
                        }
                        
                        User.prototype.readArticle = function () {
                          console.log('Read article');
                        }
                        
                        function WeiboUser(username, password) {
                          this.key = username + '&' + password;
                        }
                        
                        WeiboUser.prototype.compose = function () {
                          console.log('compose');
                        }
                        
                        // 先让 User 继承 GitHubUser
                        inherit(User, GithubUser);
                        
                        // 再让 User 继承 GitHubUser
                        inherit(User, WeiboUser);
                        
                        const user = new User('Frankie', 'abc', 5);
                        
                        console.log(user);
                        

                        打印结果:

                        最后用一个问题来检验你对本文的理解:

                        • 改写上述继承方法,让其支持 inherit(A, B, C, ...) ,实现 A 依次继承后面所有的类,但除了 A 以外的类不产生继承关系。

                        总结

                        1. 我们可以使用 function 来模拟类;
                        2. JavaScript 类的继承是基于原型的,一个完善的继承方法,其继承过程是相当复杂的;
                        3. 虽然建议实际生产中直接使用 ES6 的继承,但仍然建议深入了解内部继承机制。

                        题外话

                        最后放一个彩蛋,为什么我会在寄生组合式继承中尤其强调 enumerable 这个属性描述符呢?因为:

                        • ES6class 中,默认所有类的方法是不可枚举的!

                        The end.

                        ]]>
                        <![CDATA[细读 JS | 原型详解]]> https://github.com/tofrankie/blog/issues/212 https://github.com/tofrankie/blog/issues/212 Sun, 26 Feb 2023 10:15:04 GMT

                        系列文章:

                        原文出自 ULIVZ

                  在此之前,关于原型的认知都是零零散散,没有系统整理过。

                  引入:普通对象与函数对象

                  在 JavaScript 中,一直有一种说法,万物皆对象。

                  事实上,在 JavaScript 中,对象也是有区别的,我们可以将其划分为「普通对象」和「函数对象」。

                  ObjectFunction 便是 JavaScript 自带的两个典型的「函数对象」。而函数对象就是一个纯函数,所谓的函数对象,其实就是使用 JavaScript 在模拟类。

                  那么,什么是普通对象,什么又是函数对象呢?

                  先创建三个 FunctionObject 的实例。

                  function fn1() {};
                  var fn2 = function() {};
                  var fn3 = new Function('getName', 'console.log("Frankie")');
                  
                  var obj1 = {};
                  var obj2 = new Object();
                  var obj3 = new fn1();
                  

                  打印以下结果,可以得到:

                  console.log(typeof Object);     // function
                  console.log(typeof Function);   // function
                  console.log(typeof obj1);       // object
                  console.log(typeof obj2);       // object
                  console.log(typeof obj3);       // object
                  console.log(typeof fn1);        // function
                  console.log(typeof fn2);        // function
                  console.log(typeof fn3);        // function
                  

                  在上述的例子中,obj1obj2obj3普通对象(均为 Object 的实例),而 fn1fn2fn3函数对象(均是 Function 的实例)。

                  如何区分呢?记住这句话就行了:

                  所有 Function 的实例都是函数对象,而其他的都是普通对象。

                  说到这里,细心的同学会发表一个疑问。文中开头,我们提到 ObjectFunction 均是 函数对象,而这里又说:所有的 Function 的实例都是 函数对象,难道 Function 也是 Function 的实例吗?(先留下疑问)

                  从图中可以看出,对象本身的实现还是要依靠构造函数,那 原型链 到底是用来干嘛的呢?

                  众所周知,作为一门面向对象的语言,必定具有以下特征:

                  • 对象唯一性
                  • 抽象性
                  • 继承性
                  • 多态性

                  原型链最大的目的就是为了实现继承。

                  进阶:prototype 与 __proto__

                  原型链究竟是如何实现继承的呢?

                  首先,我们要引入介绍两兄弟:prototype__proto__,这是在 JavaScript 中无处不在的两个变量,然而,这两个变量并不是在所有的对象上都存在。

                  对象类型 prototype __proto__
                  普通对象(NO)
                  函数对象(FO)

                  首先,我们先给出以下结论:

                  • 只有函数对象才具有 prototype 属性。
                  • prototype__proto__ 都是 JavaScript 在定义一个函数或对象时自动创建的预定义属性。
                  function fn() {};
                  console.log(typeof fn.__proto__);   // function
                  console.log(typeof fn.prototype);   // object
                  
                  const obj = {};
                  console.log(typeof obj.__proto__);  // function
                  console.log(typeof obj.prototype);  // undefined,普通对象没有 prototype
                  

                  也就是说,下面代码成立:

                  console.log(fn.__proto__ === Function.prototype);   // true
                  console.log(obj.__proto__ === Object.prototype);    // true
                  

                  看起来很酷,结论瞬间被证明,感觉是不是很爽,那么问题来了:既然 fn 是一个函数对象,那么 fn.prototype.__proto__ 到底等于什么?

                  这是我尝试去解决这个问题的过程:

                  1. 首先用 typeof 得到 fn.prototype 的类型 "object"
                  2. 既然是 "object",那 fn.prototype 岂不是 Object 的实例?我们验证一下:
                  console.log(fn.prototype.__proto__ === Object.prototype);  // true
                  

                  接下来,如果要你快速地写出,在创建一个函数时,JavaScript 对该函数原型的初始化代码,你是不是也能快速地写出:

                  // 实际代码
                  function fn() {};
                  
                  // JavaScript 自动执行
                  fn.prototype = {
                    constructor: fn;
                    __proto__: Object.prototype
                  }
                  
                  fn.__proto__ = Function.prototype;
                  

                  到这里,你是否有一丝恍然大悟的感觉?

                  此外,因为普通对象就是通过函数对象实例化(new)得到的,而一个实例不可能再次进行实例化,也就不会让另一个对象的 __proto__ 指向它的 prototype,隐藏本节一开始提到的「普通对象没有 prototype 属性」的结论似乎非常好理解了。从上述的分析,我们还可以看出,fn.prototype 就是一个普通的对象,它也不存在 prototype 属性。

                  再回顾下上一节,我们还遗留了一个疑问:难道 Function 也是 Function 的实例?

                  是时候去掉「应该」让它成立了:

                  console.log(Function.__proto__ === Function.prototype);    // true
                  

                  重点:原型链

                  上一节,我们详解了 prototype__proto__。实际上,这两兄弟主要就是为了构造原型链而存在的。

                  先上一段代码:

                  function Person(name, age) {
                    this.name = name;
                    this.age = age;
                  }   // 1️⃣
                  
                  Person.prototype.getName = function() {
                    return this.name;
                  };  // 2️⃣
                  
                  Person.prototype.getAge = function() {
                    return this.age;
                  };  // 3️⃣
                  
                  var person = new Person("Frankie", 20); // 4️⃣
                  
                  console.log(person);  // 5️⃣
                  console.log(person.getName());  // 6️⃣
                  
                  // 采用 ES6 更优雅的写法?哈哈
                  // Object.assign(Person.prototype, {
                  // 	getName() {
                  // 	  return this.name;
                  // 	},
                  // 	getAge() {
                  // 	  return this.age;
                  // 	},
                  // })
                  

                  解析一下执行细节:

                  1. 执行 1️⃣,创建一个构造函数 Person,要注意前面已经提到,此时 Person.prototype 已经被自动创建,它包含 constructor__proto__ 这两个属性;
                  2. 执行 2️⃣,给对象 Person.prototype 增加一个方法 getName()
                  3. 执行 3️⃣,给对象 Person.prototype 增加一个方法 getAge()
                  4. 执行 4️⃣,右构造函数 Person 创建一个 person 实例,值得注意的是,一个构造函数在实例化时,一定会自动执行该构造函数。
                  5. 在浏览器得到 5️⃣ 的输出,即 person 应该是:
                  {
                    name: 'Frankie',
                    age: 20,
                    __proto__: Object    // 实际上就是 Person.prototype
                  }
                  

                  结合上一节的经验,以下等式成立:

                  console.log(person.__proto__ === Person.prototype);  // true
                  
                  1. 执行 6️⃣ 的时候,由于在 person 中找不到 getName()getAge() 这两个方法,就会继续朝着原型链上查找,也就是通过 __proto__ 向上查找,于是很快在 person.__proto__ 中,即 Person.prototype 中找到了这两个方法,于是停止查找并执行得到结果。

                  这便是 JavaScript 的原型继承。准确的说,JavaScript 的原型继承是通过 __proto__ 并借助 prototype 来实现的。

                  于是,我们可以作以下总结:

                  • 函数对象的 __proto__ 指向 Function.prototype
                  • 函数对象的 prototype 指向 instance.__proto__
                  • 普通对象的 __proto__ 指向 Object.prototype
                  • 普通对象没有 prototype 属性;
                  • 在访问一个对象的某个属性很方法时,若在当前对象上找不到,则会尝试访问 obj.__proto__,也就是访问该对象的构造函数的原型 objCtr.prototype,若仍然找不到,会继续查找 objCtr.prototype.__proto__,像依次地查找下去。若在某一刻,找到该属性,则会立刻返回值并停止对原型链的搜索,若找不到,则返回 undefined

                  为了检验你对上述的理解,请分析下面两个问题:

                  1. 以下代码输出结果是什么?
                  console.log(person.__proto__ === Function.prototype);    // false
                  
                  1. Person.__proto__Person.prototype.__proto__ 分别指向何处?
                  console.log(Person.__proto__ === Function.prototype);            // true
                  console.log(Person.prototype.__proto__ === Object.prototype);    // true
                  
                  // 分析:
                  // 1. 前面已经提到过,在 JavaScript 中万物皆对象。Person 很明显是 Function 的实例,
                  // 因此,Person.__proto__ 指向 Function.prototype。
                  
                  // 2. 因为 Person.prototype 是一个普通对象,因此Person.prototype.__proto__ 指向 Object.prototype。
                  
                  // 3. 为了验证 Person.__proto__ 所在的原型链中没有 Object,
                  // 以及 Person.prototype.__proto__ 所在的原型链中没有 Function, 结合以下语句验证:
                  console.log(Person.__proto__ === Object.prototype);             // false
                  console.log(Person.prototype.__proto__ == Function.prototype);  // false
                  

                  终极:原型链图

                  上一节,我们实际上还遗留了一个疑问:原型链如果一个搜索下去,如果找不到,那何时停止呢?也就是说,原型链的尽头是哪里?

                  我们可以快速地利用下面的代码验证:

                  function Person() {};
                  var person = new Person();
                  console.log(person.name);  // undefined
                  

                  很显然,上述输出 undefined。下面简述查找过程:

                  person                // 是一个对象,可以继续
                  person['name']           // 不存在,继续查找 
                  person.__proto__            // 是一个对象,可以继续
                  person.__proto__['name']        // 不存在,继续查找
                  person.__proto__.__proto__          // 是一个对象,可以继续
                  person.__proto__.__proto__['name']     // 不存在, 继续查找
                  person.__proto__.__proto__.__proto__       // null !!!! 停止查找,返回 undefined
                  

                  原来路的尽头是一场空。

                  最后,再回过头来看看上一节的那演示代码:

                  function Person(name, age) {
                    this.name = name;
                    this.age = age;
                  }   // 1️⃣
                  
                  Person.prototype.getName = function() {
                    return this.name;
                  };  // 2️⃣
                  
                  Person.prototype.getAge = function() {
                    return this.age;
                  };  // 3️⃣
                  
                  var person = new Person("Frankie", 20); // 4️⃣
                  
                  console.log(person);  // 5️⃣
                  console.log(person.getName(), person.getAge());  // 6️⃣
                  

                  我们来画一个原型链图,或者说,将其整个原型链图画出来?请看下图:

                  画完这张图,基本上之前所有的疑问都可以解答了。

                  与其说万物皆对象,万物皆空似乎更加形象。

                  调料:constructor

                  前面已经有所提及,但只有原型对象才具有 constructor 这个属性,constructor 用来指向引用它的函数对象。

                  console.log(Person.prototype.constructor === Person);    // true
                  console.log(Person.prototype.constructor.prototype.constructor === Person);    // true
                  

                  这是一种循环引用。当然你也可以在上一节的原型链图中画上去,这里就不赘述了。

                  补充 JavaScript中的 六大内置(函数)对象的原型继承

                  通过前文的论述,结合相应的代码验证,整理出以下原型链图:

                  由此可见,我们更加强化了这两个关掉:

                  1. 任何内置对象(类)本身的 __proto__ 都指向 Function 的原型对象;
                  2. 除了 Object 的原型对象的 __proto__ 指向 null,其他所有的内置函数对象的原型对象的 __proto__ 都指向 Object
                  console.log(Object.prototype.__proto__ === null);    // true
                  

                  总结

                  来几个总结:

                  • A 通过 new 创建了 B,则 B.__proto__ = A.prototype

                  • __proto__ 是原型链查找的起点;

                  • 执行 B.a,若在 B 中找不到 a,则会在 B.__proto__ 中,也就是 A.prototype 中查找,若 A.prototype 中仍然没有,则会继续向上查找,最终,一定会找到 Object.prototype,倘若还找不到,因为 Object.prototype.__proto__ 指向 null,因此会返回 undefined

                  • 为什么万物皆空,还是那句话,原型链的顶端,一定有 Object.prototype.__proto__ 等于 null

                  如何用 JavaScript 实现类的继承?

                  请接着下一篇 深入继承原理

                  ]]>
                  <![CDATA[细读 JS | 深入了解从预编译到解析执行的过程]]> https://github.com/tofrankie/blog/issues/211 https://github.com/tofrankie/blog/issues/211 Sun, 26 Feb 2023 10:14:30 GMT 一、前言

                  从简单说起

                  var a = 1;
                  

                  在字面上,这就是简单地将 1 赋值给变量 a。可在 JS 引擎里面,它认为这是两个步骤: 一、前言

                  从简单说起

                  var a = 1;
                  

                  在字面上,这就是简单地将 1 赋值给变量 a。可在 JS 引擎里面,它认为这是两个步骤:var aa = 1,分别是声明和赋值,它们发生在两个不同的阶段。

                  写这篇文章的原因是看到一道题,发现自己对预编译的理解出现了偏差。加上以往也没整理过,久了不接触就会慢慢遗忘、凌乱,所以借此机会整理下预编译的知识点,同时希望这篇文章能帮助屏幕前的你。

                  二、JS 从加载到执行完经历了什么?

                  1. 页面产生便创建了一个 GO 全局对象(Global Object,全局上下文),即 window 对象。
                  2. 第一个脚本文件加载。(加载完成后,接着是 JS 运行三部曲) 1). 语法分析 2). 预编译 3). 解析执行
                  3. 三部曲第一步:语法分析,检查是否合法。
                  4. 三部曲第二步:开始预编译: (其实这里还有创建了 document、navigator、screen 等属性,此处忽略) 1). 查找变量声明,作为 GO 属性,并赋予 undefined; 2). 查找函数声明,作为 GO 属性,并赋予函数体本身(若函数与变量同名,函数会覆盖变量;若多个函数声明是同名,后面的会覆盖前面的)
                  5. 三部曲第三步:开始执行代码。
                  6. 执行完一个 script 块,到下一个 script 块,会重复 2、3、4、5 步骤。
                  <script>
                    console.log(a); // function a() {}
                    console.log(b); // function b() {}
                    console.log(c); // undefined
                    var a = 1;
                    function a() {};
                    function b() {};
                    var c = 3;
                    console.log(a); // 1
                    console.log(d); // Uncaught ReferenceError: d is not defined,且从这里开始代码终止执行
                    d = 4;
                    console.log(d);
                    /**
                    (有必要说明一下:下面的 GO 是伪代码,为了助于理解罢了)
                    1. 创建 GO 对象;GO {}
                    2. 加载 script 块
                    3. 语法分析
                    4. 开始预编译
                      1). 找到变量声明 a 和 c,放到 GO {a: undefined, c: undefined}
                      2). 找到函数声明 a 和 b,因为函数 a 和 变量 a 同名,所以函数覆盖掉变量:GO {a: f(), b: f(), c: undefined}
                    5. 执行代码
                      1). 执行到 console.log(a),然后从 GO 里面找到 a,所以打印出来是函数;
                      2). 继续往下执行,同理打印 b 也是函数;
                      3). 同理,c 打印结果是 undefined;
                      4). 因为 a 赋值 1,所以第二个 console.log(a) 会打印 1;
                      5). 到了 console.log(d) 这一步,因为暗示全局变量 (imply global variable),不参与预编译的过程,所以会报引用错误;(代码终止执行,不会继续往下走)
                    */
                  </script>
                  

                  JavaScript 不是全文编译完再执行,而是块编译,即一个 script 块中预编译然后执行,再按顺序预编译下一个 script 块再执行。但是此时上一个 script 块中的数据都是可用的,而下一个 script 块中的函数和变量则是不可用的。

                  三、解析执行阶段,当遇到函数执行的时候,会产生 AO 活跃对象(Activation Object,活跃上下文),那过程又是怎样的呢?

                  首先要明确一点,预编译不仅仅发生在 script 代码块执行之前,还发生在函数执行之前

                  函数执行之前的“预编译”的过程:

                  1. 创建 AO 对象
                  2. 查找形参和变量声明,并赋予 undefined;
                  3. 实参值赋给形参;
                  4. 查找函数声明,赋予函数本身;

                  预编译完了之后,接着是执行函数。(下面举例说明下这个过程)

                  <script>
                    function fn(a) {
                      console.log(a); // 123
                      console.log(b); // undefined
                      console.log(c); // function c() {}
                      var a = 1;
                      var b = function() {}; // 注意:函数表达式,其实就是将一个匿名函数赋值给变量 b
                      function c() {};
                      console.log(a); // 1
                      console.log(b); // function() {}
                    }
                    fn(123);
                    /**
                      有必要说明一下:下面的 AO 是伪代码,为了助于理解罢了
                      1. 创建 AO 对象;AO {}
                      2. 预编译
                        (还包括 arguments 等,此处忽略)
                        1). 查找形参和变量声明 a、b 和 c,放到 AO {a: undefined, b: undefined}
                        2). 实参赋值给形参:AO {a: 123, b: undefined}
                        3). 找到函数声明 c,AO {a: 123, b: undefined, c: f()}
                      3. 执行代码
                        1). 执行到 console.log(a),然后从 AO 里面找到 a,所以打印出来是 123;
                        2). 继续往下执行,打印 b 是 undefined;
                        3). 继续往下,a 赋值 1;b 赋值一个匿名函数;
                        4). 第二个 console.log(a) 会打印 1;
                        5). 第二个 console.log(b) 会打印 function;
                    */
                  </script>
                  

                  四、其他

                  其实 GO 和 AO 就差在形参和实参这俩个东西。这也是我们常说的“提升” (Hoisting)。

                  其实这些蛋疼的情况,几乎只会出现在“面试”上,在实际的项目中几乎很少出现。如果在多人共同维护项目中,估计早被打死了。因为可读性差而且维护成本高。

                  在 ES6 中,新增的 let、const、class 特性就不会存在提升的问题,声明变量,必须要在调用之前发生,否则就会报错。这应该也是一个信号,减少那些蛋疼的情况。

                  但是无论是为了对付面试,还是为了了解 JavaScript 内部运行的原理,作为前端开发的一员,我们都应该要弄清楚。

                  ]]>
                  <![CDATA[JavaScript 之冒泡排序]]> https://github.com/tofrankie/blog/issues/210 https://github.com/tofrankie/blog/issues/210 Sun, 26 Feb 2023 10:09:55 GMT 配图源自 Freepik

                  冒泡排序,是一个相对较为简单易于理解的排序算法。

                  原理 配图源自 Freepik

                  冒泡排序,是一个相对较为简单易于理解的排序算法。

                  原理

                  1. 比较相邻的两个元素a、b。若 a > b,则互换 a 与 b 的位置,否则不变。
                  2. 按照上面的规则,第一轮循环次数为 array.length - 1,一轮循环结束,数组最后一项应该是最大数。
                  3. 第二轮开始,循环次数为 array.length - 1 - 1,同样把本次循环的最大项互换到 array[length - 1 - 1] 的位置上。
                  4. 以此类推...

                  图示

                  ▲ 冒泡排序

                  实现

                  以升序为例

                  function bubbleSort(arr) {
                    for (let i = 0; i < arr.length - 1; i++) {
                      for (let j = 0; j < arr.length - i - 1; j++) {
                        if (arr[j] > arr[j + 1]) {
                          ;[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]
                        }
                      }
                    }
                    return arr
                  }
                  
                  const array = [8, 12, 4, 0, 22, 8, 21, 3, 56]
                  console.log(bubbleSort(array)) // [0, 3, 4, 8, 8, 12, 21, 22, 56]
                  

                  时间复杂度

                  可知冒泡排序执行次数是 (n-1) + (n-2) + ... + 2 + 1 = (n^2 - n)/2,根据时间复杂度推导方式,可得到 O(n^2)

                  ]]>
                  <![CDATA[五、Ajax 之响应解码]]> https://github.com/tofrankie/blog/issues/209 https://github.com/tofrankie/blog/issues/209 Sun, 26 Feb 2023 09:45:34 GMT 我们接收到的响应主体类型可以是多种形式的,包括字符串 String、ArrayBuffer 对象、二进制 Blob 对象、JSON 对象、JavaScript 文件以及表示 XML 文档的 Document 对象等。下面将针对不同的主体类型,进行相应的响应解码。

                  属性

                  在介绍响应解]]> 我们接收到的响应主体类型可以是多种形式的,包括字符串 String、ArrayBuffer 对象、二进制 Blob 对象、JSON 对象、JavaScript 文件以及表示 XML 文档的 Document 对象等。下面将针对不同的主体类型,进行相应的响应解码。

                  属性

                  在介绍响应解码之前,要先了解一下 XHR 对象的属性。一般地,如果接收的数据是字符串,使用 responseText 即可,这也是最常用的用于接收数据的属性。但如果获取了其他类型的数据,使用 responseText 可能就不太合适了。

                  1. responseText

                  该属性返回从服务器接收到的字符串,该属性只读。如果本次请求没有成功或者数据不完整,该属性就会等于 null。如果服务器返回的数据格式是 JSON、字符串、JavaScript或者XML,都可以使用 responseText 属性。

                  2. response

                  该属性只读,返回接收到的数据体。它的类型可以是 ArrayBuffer、Blob、Document、JSON对象、或者字符串,这由 XMLHttpRequest.responseType 属性的值决定。如果本次请求没有成功或者数据不完整,该属性就会等于 null。(IE 9 浏览器不支持)

                  3. responseType

                  该属性用来指定服务器返回数据(xhr.response)的类型。

                  • "":字符串(默认值)
                  • "arraybuffer":ArrayBuffer 对象
                  • "blob":Blob 对象
                  • "document":Document 对象
                  • "json":JSON 对象
                  • "text":字符串

                  4. responseXML

                  该属性返回从服务器接收到的 Document 对象,该属性为只读。如果本次请求没有成功,或者数据不完整,或者不能被解析为 XML 或 HTML,该属性等于 null

                  5. overrideMimeType()

                  该方法用来指定服务器返回数据的 MIME 类型。该方法必须在 send() 之前调用。传统上,如果希望从服务器取回二进制数据,就要使用这个方法,人为将数据类型伪装成文本数据。但是,这种方法很麻烦,在 XMLHttpRequest 版本升级以后,一般采用指定 responseType 的方法。

                  字符串

                  如果服务器返回的结果是一个字符串,则直接使用 responseText 属性解析即可。 关于 ajax() 函数封装,已经在上一篇文章中详细介绍过,这里就不再赘述。 直接调用 ajax()

                  <button id="btn">取得响应</button>
                  <div id="result"></div>
                  
                  <script>
                    btn.onclick = function () {
                      ajax({
                        url: './example.php',
                        method: 'GET',
                        timeout: 30000,
                        headers: {
                          'Content-Type': 'application/x-www-form-urlencoded'
                        },
                        success: function (res) {
                          // result 假设为 id 为 result 的节点
                          result.innerHTML = res
                        },
                        fail: function (err) {
                          console.log('request fail: ', err)
                        }
                      })
                    }
                  </script>
                  
                  <?php
                      // 设置页面内容的 html 编码格式是 utf-8,内容是纯文本
                      header("Content-Type: text/plain;charset=utf-8");    
                      echo '你好,世界';
                  ?>
                  

                  JSON

                  使用 ajax 最常用的传输方式就是 JSON 字符串,直接使用 responseText 属性解析即可。

                  ajax({
                    success: function (res) {
                      const data = JSON.parse(res)
                    }
                  })
                  

                  JS

                  使用 ajax 也可以接收 js 文件。仍然使用 responseText 来接收数据,但要使用 eval() 来执行代码。

                  // 此处省略一万行代码
                  ajax({
                    success: function (res) {
                      eval(res)
                    }
                  })
                  

                  未完待续...

                  后续补上 xml、blob、arraybuffter...

                  ]]>
                  <![CDATA[四、Ajax 之 GET、POST 请求方式]]> https://github.com/tofrankie/blog/issues/208 https://github.com/tofrankie/blog/issues/208 Sun, 26 Feb 2023 09:45:05 GMT 上一篇中,概要地介绍了 XHR 对象的使用,本文将详细地介绍使用 XHR 对象发送请求的两种方式 GETPOST

                  一、]]> 上一篇中,概要地介绍了 XHR 对象的使用,本文将详细地介绍使用 XHR 对象发送请求的两种方式 GETPOST

                  一、GET 请求

                  GET 请求是最常见的请求类型,最常用于向服务器查询某些信息,它适用于当 URL 完全指定请求资源,当请求对服务器没有任何副作用以及当服务器的响应是可缓存的的情况下。

                  1. 数据发送

                  使用 GET 方式发送请求时,数据被追加到 open() 方法中的 URL 的末尾。 数据以 ? 问号开始,属性名和属性值之间用 = 等号连接,键值对之间使用 & 分隔。使用 GET 方式发送的数据常常被称为查询字符串。

                  xhr.open('GET', 'example.php?name1=value1&name2=value2', true)
                  

                  2. 编码

                  由于 URL 无法识别特殊字符,所以如果数据中包含特殊字符(如中文),则需要使用 encodeURIComponent() 进行编码。

                  const url = 'example.php?name=' + encodeURIComponent('越前君')
                  // url 被编码成:example.php?name=%E8%B6%8A%E5%89%8D%E5%90%9B
                  xhr.open('GET', url, true)
                  

                  注意:encodeURIComponent() 只是六种编解码方法的一种,关于它们的详细信息,请点击这里

                  3. 写个函数来编码

                  function addURLParam(url, name, value) {
                    url += url.indexOf('?') > -1 ? '&' : '?'
                    url += encodeURIComponent(name) + '=' + encodeURIComponent(value)
                    return url
                  }
                  
                  let url = 'example.php'
                  url = addURLParam(url, 'name1', 'Frankie')
                  url = addURLParam(url, 'name2', 'Mandy')
                  
                  xhr.open('GET', url, true)
                  

                  4. 缓存

                  在 GET 请求中,为了避免缓存的影响,可以向 URL 添加一个随机数或者时间戳。

                  xhr.open('GET', 'example.php?' + Number(new Date()), true)
                  // 或者
                  xhr.open('GET', 'example.php?' + Math.random(), true)
                  

                  5. 封装 GET 方法

                  function get(url, data, callback) {
                    let xhr
                  
                    // 创建 xhr 对象
                    if (window.XMLHttpRequest) {
                      xhr = new window.XMLHttpRequest()
                    } else {
                      xhr = new ActiveXObject('Microsoft.XMLHTTP')
                    }
                  
                    // 监听请求完成
                    xhr.onreadystatechange = function () {
                      if (xhr.readyState === 4 && xhr.status === 200) {
                        typeof callback === 'function' && callback(xhr.responseText)
                      }
                    }
                  
                    // 设置超时
                    xhr.ontimeout = function () {
                      typeof callback === 'function' && callback('The request was timed out!')
                    }
                    xhr.timeout = 30000
                  
                    // 编码特殊字符
                    for (const key in data) {
                      url += url.indexOf('?') > -1 ? '&' : '?'
                      url += encodeURIComponent(key) + '=' + encodeURIComponent(data[key])
                    }
                  
                    // 添加时间戳,防止缓存
                    url += (url.indexOf('?') > -1 ? '&' : '?') + Number(new Date())
                  
                    // 建立、发送请求
                    xhr.open('GET', url, true)
                    xhr.send(null)
                  }
                  
                  // 发起 GET 请求
                  get('example.php', { name: 'Frankie', age: '20', sex: '男' }, function (res) {
                    // callback statements...
                  })
                  
                  

                  二、POST 请求

                  使用频率仅次于 GET 请求的是 POST 请求通常用于向服务器发送应该被保存的数据。POST 方法常用于 HTML 表单。它在请求主体中包含额外数据且这些数据常存储到服务器上的数据库中。相同 URL 的重复 POST 请求从服务器得到的响应可能不同,同时不应该缓存使用这个方法的请求。

                  POST 请求应该把数据作为请求的主体提交,而 GET 请求传统上不是这样。POST 请求的主体可以包含非常多的数据,而且格式不限。在 open() 方法第一个参数位置传入 'POST' (大小写不限制,通常习惯大写),就可以初始化一个 POST 请求了。

                  xhr.open('POST', 'example.php', true)
                  

                  1. 设置请求头

                  发送 POST 请求的第二步就是向 send() 方法传入某些数据。由于 XHR 最初的设计是为了处理 XML,因此可以在此传入 XML DOM 文档,传入的文档经序列化之后将作为请求主体被提交到服务器。亦可以在此传入任何想发送到服务器的字符串

                  默认情况下,服务器对 POST 请求和提交 Web 表单的请求并不会一视同仁。因此,服务器端必须有程序来读取发送过来的原始数据,并从中解析出有用的部分。不过,可以使用 XHR 来模仿表单提交:首先将 Content-Type 头部信息设置为 application/x-www-form-urlencoded,也就是表单提交时的内容类型。

                  xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded')
                  

                  如果不设置 Content-Type,发送给服务器的数据就不会出现在 $POST 超级全局变量中。这时要访问同样的数据,需借助 $HTTP_RAW_POST_DATA

                  如果对相同的头调用多次 setRequestHeader(),新值不会取代之前指定的值。相反,HTTP 请求将包含这个头的多个副本或者这个头将指定多个值。

                  2. 发送主体

                  接下来要以适当的格式创建一个字符串,并使用 send() 方法发送。 POST 数据的格式与查询字符串格式相同,键和值之间使用 = 等号连接,键值对之间用 & 分隔,如下:

                  xhr.send('name=Frankie&age=20')
                  

                  3. 编码和缓存

                  由于使用 POST 方式传递数据时,需要设置请求头 content-type,这一步骤已经能勾自动对特殊字符(如中文)进行编码,所以就不需要使用 encodeURIComponent() 方法了。POST 请求主要用于数据提交,相同 URL 的重复 POST 请求从服务器得到的响应可能不同,所以不应该缓存使用 POST 方法的请求。

                  4. 性能

                  GET 对发送信息的数量有限制,一般在 2000 个字符。与 GET 请求相比,POST 请求消耗的资源要更多一些。从性能的角度来看,以发送相同的数据计算,GET 请求的速度最多可 POST 请求的两倍。

                  5. 封装 POST 方法

                  function post(url, data, callback) {
                    let xhr
                  
                    // 创建 xhr 对象
                    if (window.XMLHttpRequest) {
                      xhr = new window.XMLHttpRequest()
                    } else {
                      xhr = new ActiveXObject('Microsoft.XMLHTTP')
                    }
                  
                    // 监听请求完成
                    xhr.onreadystatechange = function () {
                      if (xhr.readyState === 4 && xhr.status === 200) {
                        typeof callback === 'function' && callback(xhr.responseText)
                      }
                    }
                  
                    // 设置超时
                    xhr.ontimeout = function () {
                      typeof callback === 'function' && callback('The request was timed out!')
                    }
                    xhr.timeout = 30000
                  
                    // 序列化 data
                    let strData = ''
                    for (const key in data) {
                      strData += '&' + key + '=' + data[key]
                    }
                    strData = strData.substr(1)
                  
                    // 初始化请求
                    xhr.open('POST', url, true)
                  
                    // 设置请求头
                    xhr.setRequestHeader('content-type', 'application/x-www-form-urlencoded')
                  
                    // 发送请求
                    xhr.send(strData)
                  }
                  
                  // 发起 POST 请求
                  post('example.php', { name: 'Frankie', age: '20', sex: '男' }, function (res) {
                    // callback statements...
                  })
                  

                  三、将 GET 和 POST 封装一下

                  function ajax(opt) {
                    // 请求 url
                    let url = opt.url ? opt.url : ''
                    // 默认异步
                    const isAsync = typeof opt.async !== 'undefined' ? !!opt.async : true
                    // 默认 GET 方法
                    const method = opt.method && opt.method.toUpperCase() === 'POST' ? 'POST' : 'GET'
                    // 成功回调
                    const successCallback = typeof opt.success === 'function' ? opt.success : null
                    // 失败回调
                    const failCallback = typeof opt.fail === 'function' ? opt.fail : null
                  
                    let xhr
                  
                    // 创建 xhr 对象
                    if (window.XMLHttpRequest) {
                      xhr = new window.XMLHttpRequest()
                    } else {
                      xhr = new ActiveXObject('Microsoft.XMLHTTP')
                    }
                  
                    // 监听请求完成
                    xhr.onreadystatechange = function () {
                      if (xhr.readyState === 4) {
                        if (xhr.status === 200) {
                          successCallback && successCallback(xhr.responseText)
                        } else {
                          failCallback && failCallback(xhr.responseText)
                        }
                      }
                    }
                  
                    // 设置超时(同步不能设置超时)
                    if (isAsync) {
                      xhr.ontimeout = function () {
                        console.error('The request was timed out!')
                      }
                      xhr.timeout = opt.timeout ? opt.timeout : 30000
                    }
                  
                    if (method === 'POST') {
                      // POST 处理
                      let strData = ''
                      for (var key in opt.data) {
                        strData += '&' + key + '=' + opt.data[key]
                      }
                      strData = strData.substr(1)
                      xhr.open(method, url, isAsync)
                      if (opt.headers) {
                        for (const key2 in opt.headers) {
                          xhr.setRequestHeader(key2, opt.headers[key2])
                        }
                      }
                      xhr.send(strData)
                    } else {
                      // GET 处理
                      for (var key in opt.data) {
                        url += url.indexOf('?') > -1 ? '&' : '?'
                        url += encodeURIComponent(key) + '=' + encodeURIComponent(opt.data[key])
                      }
                      url += (url.indexOf('?') > -1 ? '&' : '?') + Number(new Date())
                      xhr.open(method, url, isAsync)
                      xhr.send()
                    }
                  }
                  
                  // 发起请求
                  ajax({
                    url: './example.php',
                    method: 'POST',
                    data: {
                      name: 'Frankie',
                      age: 20
                    },
                    headers: {
                      'Content-Type': 'application/x-www-form-urlencoded'
                    },
                    success: function (res) {
                      console.log('request success: ', res)
                    },
                    fail: function (err) {
                      console.log('request fail: ', err)
                    }
                  })
                  

                  下一篇:Ajax 之响应解码

                  The end.

                  ]]>
                  <![CDATA[三、HTTP 协议]]> https://github.com/tofrankie/blog/issues/207 https://github.com/tofrankie/blog/issues/207 Sun, 26 Feb 2023 09:44:33 GMT 上一篇介绍了:HTTP Content-Type 详解

                  什么是 HTTP 协议?

                  HTTP,全称是 HyperText Transfer Protocol,中文叫做]]> 上一篇介绍了:HTTP Content-Type 详解

                  什么是 HTTP 协议?

                  HTTP,全称是 HyperText Transfer Protocol,中文叫做超文本传输协议。它是一种用于分布式、协作式和超媒体信息系统的应用层协议。

                  一系列发布的 RFC 中,最著名的是 1999 年 6 月公布的 RFC 2616,定义了 HTTP 协议中现今广泛使用的一个版本 HTTP/1.1。直到 HTTP/2 于 2015 年 5 月的 RFC 7540 正式发布,取代了 HTTP/1.1 成为 HTTP 的实现标准。

                  HTTP 协议概述

                  HTTP 是一个客户端(用户)和服务器端(网站)请求和应答的标准(TCP)。 通过使用网页浏览器、网络爬虫或者其他工具,客户端发起一个 HTTP 请求到服务器上指定的端口(默认端口为 80),我们称这个客户端为用户代理程序(user agent)。应答的服务器上存储这一些资源,比如 HTML文件、图像等,我们称这个应答服务器为源服务器(origin server)。在用户代理程序和源服务器中间可能存在多个“中间层”,比如代理服务器、网关或者隧道(tunnel)。

                  尽管 TCP/IP 协议是互联网上最流行的应用,但在 HTTP 协议中,并没有规定必须使用它或者他支持的层。事实上,HTTP 可以在任何互联网协议上,或者其他网络上实现。HTTP 假定其下层协议提供可靠的传输。因此,任何能够提供这种保证的协议都可以被其使用。因此,也就是其在 TCP/IP 协议族使用 TCP 作为其传输层。

                  通常,由 HTTP 客户端发起一个 请求,创建一个到服务器指定端口(默认为 80)的 TCP 连接。HTTP 服务器则在那个端口监听客户端的请求。一旦收到请求,服务器会向客户端返回一个状态,比如 “HTTP/1.1 200 OK”,以及返回的内容,如请求的文件、错误信息、或者其他信息。

                  HTTP 工作原理

                  HTTP 协议定义 Web 客户端如何从 Web 服务器请求 Web 页面,以及服务器如何把 Web 页面传送给客户端。HTTP 协议采用了“请求/响应模型”。

                  客户端想服务器发送一个请求报文,请求报文包含了请求方法、URL协议版本、请求头部和请求数据。服务器以一个状态行作为响应,响应的内容包括协议的版本、成功或错误代码、服务器信息、响应头部和响应数据。

                  HTTP 报文可以分为两类:请求报文(request message)和响应报文(response message),两者的基本报文结构相同。

                  请求报文的格式:

                  <method> <request-URL> <version>
                  <headers>
                  
                  <entiry-body>
                  

                  响应报文格式:

                  <version> <status> <reason-phrase>
                  <headers>
                  
                  <entity-body>
                  

                  报文对应表示:

                  // method: 客户端希望服务器对资源执行的动作。是一个单独的词,比如 GET、HEAD 或 POST;
                  // request-URL: 命名了所请求的资源,或者 URL 路径组件的完整 URL
                  // version: 报文所使用的 HTTP 版本,格式:HTTP/<major>.<minor>,主版本号和次版本号都是整数
                  // headers: 首部,可以有零个或多个首部,毎个首部都包含一个名字,后面跟着一个冒号(:),然后是一个可选的空格,接着是一个值,最后是一个 CRLF。首部是由一个空行(CRLF)结束的,表示了首部列表的结束和实体主体部分的开始
                  // entity-body: 主体部分,包含一个由任意数据组成的数据块,并不是所有的报文都包含实体的主体部分,有时报文是已一个 CRLF 结束的。
                  
                  // 注意:请求行、响应行的每个字段都是由“空格符”进行分割的。
                  

                  HTTP 请求/响应的步骤

                  1. 客户端连接到 Web 服务器 一个 HTTP 客户端。通常是浏览器,与 Web 服务器的 HTTP 端(默认为 80)建立一个 TCP 套接字连接。如:http://www.baidu.com

                  2. 发送 HTTP 请求 通过 TCP 套接字,客户端向 Web 服务器发送一个文本的请求报文,一个请求报文包括请求行、请求头部、空行和请求数据 4 部分组成。

                  3. 服务器接受请求并返回 HTTP 响应 Web 服务器解析请求,定位请求资源。服务器将资源副本写到 TCP 套接字,由客户端读取。一个响应由状态行、响应头部、空行和响应数据 4 部分组成。

                  4. 释放 TCP 连接 如果 connect 模式为 close,则服务器阻断关闭 TCP 连接,客户端被动关闭连接,释放 TCP 连接;如果 connection 模式为 keepalive,则该连接会保持一段时间,在改时间内可以继续接收请求。

                  5. 客户端浏览器解析 HTML 内容。 客户端浏览器首先解析状态行,查看表明请求是否成功的状态代码,然后解析每一个响应头,响应头告知以下为若干字节的 HTML 文档和文档的字符集。客户端浏览器读取响应数据 HTML,根据 HTML 语法堆砌进行格式化,并在浏览器窗口中显示。

                  一个经典问题:在浏览器地址栏键入 URL,按下回车之后会经历什么?

                  1. 浏览器向 DNS 服务器请求解析该 URL 中域名对应的 IP 地址;
                  2. 解析出 IP 地址后,根据该 IP 地址和默认端口 80,和服务器建立 TCP 连接;
                  3. 服务器发出读取文件的HTTP请求(URL 中域名后面部分对应的文件),该请求报文作为 TCP 三次握手的第三个报文的数据发送给服务器;
                  4. 服务器对浏览器请求作出响应,并把对应的 HTML 文本发送给浏览器;
                  5. 释放 TCP 连接;
                  6. 浏览器将 HTML 文档渲染并显示内容;

                  请求方法

                  HTTP 1.1 提供了八种方法来以不同方式操作指定的资源

                  • GET:获取资源
                  • POST:传输实体主体
                  • PUT:传输文件
                  • HEAD:获取报文首部
                  • DELETE:删除文件
                  • OPTIONS:询问支持的方法
                  • TRACE:追踪路径
                  • CONNECT:要求用隧道协议连接代理

                  • GET:向指定的资源发出“显示”请求。使用GET方法应该只用在读取数据,而不应当被用于产生“副作用”的操作中,例如在Web Application中。其中一个原因是GET可能会被网络蜘蛛等随意访问。

                  • HEAD:与GET方法一样,都是向服务器发出指定资源的请求。只不过服务器将不传回资源的本文部分。它的好处在于,使用这个方法可以在不必传输全部内容的情况下,就可以获取其中“关于该资源的信息”(元信息或称元数据)。

                  • PUT:向指定资源位置上传其最新内容。

                  • POST:向指定资源提交数据,请求服务器进行处理(例如提交表单或者上传文件)。数据被包含在请求本文中。这个请求可能会创建新的资源或修改现有资源,或二者皆有。

                  • TRACE:回显服务器收到的请求,主要用于测试或诊断。

                  • OPTIONS:这个方法可使服务器传回该资源所支持的所有HTTP请求方法。用'*'来代替资源名称,向Web服务器发送OPTIONS请求,可以测试服务器功能是否正常运作。

                  • DELETE:请求服务器删除Request-URI所标识的资源。

                  • CONNECT:HTTP/1.1协议中预留给能够将连接改为管道方式的代理服务器。通常用于SSL加密服务器的链接(经由非加密的HTTP代理服务器)。

                  注意:

                  1. 方法名称是区分大小写的。当某个请求所针对的资源不支持对应的请求方法的时候,服务器应当返回状态码 405(Method Not Allowed),当服务器不认识或者不支持对应的请求方法的时候,应当返回状态码 501(Not Implemented)。

                  2. HTTP 服务器至少应该实现 GET 和 HEAD 方法,其他方法都是可选的。当然,所有的方法支持的实现都应当匹配下述的方法各自的语义定义。此外,除了上述方法,特定的HTTP服务器还能够扩展自定义的方法。例如 PATCH(由 RFC 5789 指定的方法)用于将局部修改应用到资源。

                  3. GET 提交的数据大小有限制(因为浏览器对 URL 的长度有限制),而 POST 方法提交的数据没有限制.

                  状态码

                  HTTP 状态码负责表示客户端 HTTP 请求的返回结果、标记服务器端处理是否正常、通知出现的错误等工作。HTTP 状态码被分成了“五”大类,不同的类型代表不同类别的状态码。

                  状态码 类别 原因短语
                  1XX Information(信息性状态码) 表示接收的请求正在处理
                  2XX Success(成功状态码) 表示请求正常处理完毕
                  3XX Redirection(重定向状态码) 表示需要进行附加操作以完成请求
                  4XX Client Error(客户端错误状态码) 表示服务器无法处理请求
                  5XX Server Error(服务器错误码) 表示服务器处理请求出错

                  更具体的状态码:

                  下一篇:Ajax 之 GET、POST 请求方式

                  参考链接

                  The end.

                  ]]>
                  <![CDATA[二、HTTP Content-Type 详解]]> https://github.com/tofrankie/blog/issues/206 https://github.com/tofrankie/blog/issues/206 Sun, 26 Feb 2023 09:43:13 GMT 上一篇介绍了:神秘的 XMLHttpRequest 对象

                  之前写 JavaScript 时,并没有特意去整理过类似的知识点,本文整理记录下,方便自己查阅。

                  什么是 C]]> 上一篇介绍了:神秘的 XMLHttpRequest 对象

                  之前写 JavaScript 时,并没有特意去整理过类似的知识点,本文整理记录下,方便自己查阅。

                  什么是 Content-Type?

                  Media-Type(Internet Media Type),互联网媒体类型,也叫做 MIME 类型。在互联网中有成百上千种不同的数据类型,HTTP 在传输数据对象时会为他们打上称为 MIME 的数据格式标签,用于区分数据类型。最初 MIME 是用于电子邮件系统的,后来 HTTP 也采用了这一方案。

                  格式如下:

                  Content-Type: [type]/[subtype]; parameter
                  

                  如:Content-Type: text/html; charset:utf-8

                  • type:主类型,任意的字符串,如 text,如果是 * 表示所有。
                  • subtype:子类型,用于指定 type 的详细形式,任意的字符串,如 html,同样 * 表示所有,用 / 与主类型隔开。
                  • parameter:可选参数,如 charset、boundary 等。

                  常见 type

                  • Text:用于标准化地表示的文本信息,文本消息可以是多种字符串集合或者多种格式的集合;
                  • Multipart:用于连接消息体的多个部分构成一个消息,这些部分可以是不同类型的数据;
                  • Application:用于传输营运程序数据或者二进制数据;
                  • Message:用于包装一个 Email 消息;
                  • Image:用于传输静态图片数据;
                  • Audio: 用于传输音频或者音声数据;
                  • Video:用于传输动态影像数据,可以是与音频编辑咋一起的视频数据格式。

                  常见 subtype

                  为了确保这些值在一个有序而且公开的状态下开发,MIME 使用 Internet Assigned Numbers Authority(IANA)作为中心的注册机制来管理这些值。常用的有如下这些:

                  • text/plain:纯文本
                  • text/html:HTML 文档
                  • text/xml:XML 文档

                  • image/gif:GIF 图像
                  • image/jpeg:JPEG 图像(注意:JPG 与 JPEG 没区别,只是 .jpg 的写法更流行而已)(PHP 中为 image/pjpeg)
                  • image/png:PNG 图像(PHP 中为 image/x-png)
                  • video/mpeg:MPEG 动画
                  • message/rfc822:RFC 822 形式

                  • application/octet-stream:任意的二进制数据
                  • application/pdf:PDF 文档
                  • application/xhtml+xml:XHTML 文档
                  • application/msword:Microsoft Word 文件
                  • application/x-www-form-urlencoded:使用 HTTP 的 POST 方式提交的表单,<form encType=""> 中默认的 encType
                  • application/json:JSON 数据格式

                  • multipart/form-data:用于提交包含文件、非ASCII数据和二进制数据的表单,如文件上传
                  • multipart/alternative:HTML 邮件的 HTML 形式和纯文本形式,相同内容使用不同的形式表示

                  附上:Content-Type 对照表

                  下一篇:HTTP 协议

                  The end.

                  ]]>
                  <![CDATA[一、神秘的 XMLHttpRequest 对象]]> https://github.com/tofrankie/blog/issues/205 https://github.com/tofrankie/blog/issues/205 Sun, 26 Feb 2023 09:35:03 GMT 一、背景

                  在 1999 年,微软发布 IE5,第一次引入了新功能:允许 JavaScript 脚本向服务器发起 HTTP 请求。这功能在当时并没有被引起注意,直到 2004 年 Gmail 和 2005 年 Google Map 的发布,才引起广泛的重视。在 2005 年 2 月,Ajax (<]]> 一、背景

                  在 1999 年,微软发布 IE5,第一次引入了新功能:允许 JavaScript 脚本向服务器发起 HTTP 请求。这功能在当时并没有被引起注意,直到 2004 年 Gmail 和 2005 年 Google Map 的发布,才引起广泛的重视。在 2005 年 2 月,Ajax (Asynchronous JavaScript And XML,中文翻译是:异步的 JavaScript 和 XML )这词第一次提出,指围绕这个功能进行开发的一整套做法。此后,Ajax 成为脚本发起 HTTP 通信的代名词,W3C 也在 2006 年发布了它的国际标准。

                  二、什么是 XHR 对象?

                  XHR 是 XMLHttpRequest 的简称。这是微软首先引入的一个特性,其他浏览器提供商后来都提供了相同的实现。XHR 为向服务器发送请求和解析服务器响应提供了流畅的接口,能够以异步方式从服务器取得更多信息,意味着用户单击后,可以不必刷新页面也能取得新数据。

                  三、创建 XHR

                  IE5是第一款引入 XHR 对象的浏览器, 在 IE5中, XHR 对象是通过 MSXML 库中的一个 ActiveX 对象实现的,而 IE7+ 及其他标准浏览器都支持原生的 XHR 对象。

                  let xhr
                  if (window.XMLHttpRequest) {
                    xhr = new window.XMLHttpRequest()
                  } else {
                    // IE5, IE6
                    xhr = new ActiveXObject('Microsort.XMLHTTP')
                  }
                  

                  注意:如果要建立 N 个不同的请求,就要使用 N 个不同的 XHR 对象。当然可以重用已存在的 XHR 对象,但这会终止之前通过该对象挂起的任何请求。

                  四、发送请求

                  发送请求的过程,包括 open 和 send 两个方法。

                  open

                  在使用 XHR 对象时,要调用的第一个方法是 open() ,该方法接收 3 个参数;

                  // xhr.open(method, url, async)
                  xhr.open('GET', 'example.php', false)
                  
                  • method:即指定发送请求的方式,字符串类型,不区分大小写,但通常使用大写字母。如 GETPOST。还可以是 HEADOPTIONSPUT。而由于安全风险的原因,CONNECTTRACETRACK 被禁止使用。( 关于HTTP协议8种常用方法的详细介绍,可以点击这里
                  • url:所要请求 URL,该 URL相对于执行代码的当前页面,且只能同一个域中使用相同端口协议的 URL 发送请求。如果 URL 与启动请求的页面有任何差别,都会引发安全错误。
                  • async:表示是否异步发送请求,布尔类型(true 表示异步,false 表示同步)。如果不填写,默认 true,表示异步发送。
                  • 如果请求一个受密码保护的 URL,把用于认证的用户名和密码作为第 4 个和 第 5 个参数传递给 open() 方法。

                  如果接收的是同步响应,则需要将 open() 方法的第三个参数设置为 false,那么 send() 方法将阻塞直到请求完成。

                  同步请求是吸引人的,但应该避免使用它们。客户端 JavaScript 是单线程的,当 send() 方法阻塞时,他通常会导致整个页面冻结。如果连接的服务器响应慢,那么用户的浏览器看起来像“假死”状态。

                  send

                  send() 方法接收一个参数,即要作为请求主体发送的数据。调用 send() 方法后,请求被分派到服务器。

                  • 如果 GET 请求, send() 方法无参数,或者参数为 null
                  • 如果 POST 请求,send() 方法参数为要发送的数据,字符串类型,所以一般需要序列化。
                  xhr.open('GET', 'example.php', false)
                  xhr.send(null)
                  

                  五、接收响应

                  一个完整的 HTTP 响应由状态码、响应头集合、响应主体组成。在收到响应后,这些都可以通过 XHR 对象的属性和方法使用,主要有以下 4 个属性:

                  • responseText:作为响应主体被返回的文本(文本形式);
                  • responseXML:如果响应的内容是 text/xmlapplication/xml,这个属性中将保存这响应数据的 XML DOM 文档(document 形式)
                  • status:HTTP 状态码(数字形式)
                  • statusText:HTTP 状态说明(文本形式)

                  在接收到响应之后,第一步是检测 status 属性,以确定响应已经成功返回。一般来说,可以将 HTTP状态码为 200 作为成功的标志。此时,responseText 属性的内容已经就绪,而且在内容类型正确的情况下, responseXML 也可以访问了。此外,状态码为 304 表示请求的资源并没有被修改,可以直接使用浏览器中的缓存版本。当然,也意味着响应是有效的。

                  无论内容类型是什么,响应主体的内容都会保存到 responseText 属性中,而对于非 XML 数据而已,responseXML 属性的值将为 null。

                  // 这是同步的方式,(异步则需要监听 onreadystatechange 事件,继续往下看)
                  if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
                    alert(xhr.responseText)
                  } else {
                    alert('request was unsuccessful: ' + xhr.status)
                  }
                  

                  六、readyState

                  当发送一个请求后,客户端需要确定这个请求什么时候会完成,因此,XHR 对象提供了 onreadystatechange 事件机制来捕获请求的状态,继而实现响应。

                  每当 readyState 改变时,就会触发 onreadystatechange 事件。readyState 属性存有 XHR 的状态信息。 readyState 存有 XHR 的状态,从 0 到 4 发生变化。

                  • 0 (Uninitialized 未初始化):请求未初始化(此阶段确认XMLHttpRequest对象是否创建,还没有调用 open() 方法)
                  • 1(Loading 载入):服务器连接已建立(此阶段调用 open() 方法进行初始化,并调用 send() 方法向服务端发送请求)
                  • 2(Loaded 载入完成):请求已接收(此阶段 send() 方法执行完成,且已接收到服务器端的响应数据。但获得的还只是服务端响应的原始数据,并不能直接在客户端使用)
                  • 3(Interactive 交互):请求处理中(此阶段解析接收到的服务器端响应数据,即根据服务器端响应头部返回的 MIME 类型把数据转换成能通过 responseBody,responseText 或 responseXML 的属性存取的格式)
                  • 4(Completed 完成):请求已完成,且响应已就绪(此阶段响应内容解析完成,可以在客户端调用了)
                  // 异步方式
                  xhr.onreadystatechange = function() {
                    if (xhr.readyState === 4 && xhr.status === 200) {
                      alert('request was successful!')
                    }
                  }
                  

                  注意:理论上,只要 readyState 属性值发生改变,都会触发一次 onreadystatechange 事件。但是为了确保跨浏览器的兼容性,必须在调用 open() 之前指定 onreadystatechange 事件处理程序,否则将无法接收 readyState 属性为 01 的情况。

                  七、超时

                  XHR 对象的 timeout 属性等于一个整数,单位毫秒(ms),表示该请求的最大请求时间。即在多少毫秒后,如果请求仍然没有得到结果,就会自动终止。该属性默认为 0,表示没有时间限制。

                  如果请求超时,将会触发 ontimeout 事件。

                  xhr.open('POST', 'example.php', true)
                  xhr.ontimeout = function () {
                    console.log('The request timed out')
                  }
                  xhr.timeout = 1000
                  xhr.send()
                  

                  IE8 浏览器不支持该属性。

                  八、最后

                  只要弄清楚了整个过程,就会发现其实它并没有那么神秘,对吧。 概而括之,整个 XHR 对象的生命周期应该包括如下阶段:创建 → 初始化请求 → 发送请求 → 接收数据 → 解析数据 → 完成

                  // 整个过程
                  function httpRequest() {
                    let xhr
                  
                    // 创建 xhr 对象
                    if (window.XMLHttpRequest) {
                      xhr = new window.XMLHttpRequest()
                    } else {
                      // 为了兼容 IE5、IE6
                      xhr = new ActiveXObject('Microsoft.XMLHTTP')
                    }
                  
                    // 异步方式必须采用 onreadystatechange 来监听
                    xhr.onreadystatechange = function () {
                      if (xhr.readyState === 4 && xhr.status === 200) {
                        alert('The request was successful! ' + xhr.responseText)
                        // handle statements...
                      }
                    }
                  
                    // 设置超时处理
                    xhr.ontimeout = function () {
                      alert('The request was timed out')
                    }
                    xhr.timeout = 3000
                  
                    xhr.open('POST', './test.ajax', true)
                  
                    // 如需设置 HTTP 请求头,必须在 open() 之后、send() 之前调用,例如:
                    // xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencode');
                  
                    // 序列化
                    xhr.send(JSON.stringify({ name: 'test' }))
                  }
                  
                  httpRequest()
                  

                  下一篇:HTTP Content-Type详解

                  九、参考链接

                  ]]>
                  <![CDATA[JavaScript 算法有哪些?]]> https://github.com/tofrankie/blog/issues/203 https://github.com/tofrankie/blog/issues/203 Sun, 26 Feb 2023 09:33:34 GMT 一、算法优劣评判

                  • 稳定:如果 a 原本在 b 前面,而 a = b,排序之后 a 仍然在 b 的前面;
                  • 不稳定:如果 a 原本在 b 前面,而 a = b,排序之后 a 可能会出现在 b 的后面;<]]> 一、算法优劣评判
                    • 稳定:如果 a 原本在 b 前面,而 a = b,排序之后 a 仍然在 b 的前面;
                    • 不稳定:如果 a 原本在 b 前面,而 a = b,排序之后 a 可能会出现在 b 的后面;

                    • 内排序:所有排序操作都在内存中完成;
                    • 外排序:由于数据太大,因此把数据放在磁盘,而排序通过磁盘和内存中的数据才能进行排序

                    • 时间复杂度:一个算法执行所消耗的时间;
                    • 空间复杂度:运行完一个算法所需内存的大小;

                    二、排序算法

                    三、时间复杂度的推导

                    算法的时间复杂度是表示算法所消耗时间大小的量度,通常使用 大 O 表示法 来建立数学模型,即 O(f(n)),随着 n 的数值增大,O(f(n)) 的数值增长的越慢就越是时间复杂度低的算法。

                    1. 用常数 1 取代运行时间中的所有加法常数。
                    2. 在修改后的运行次数函数中,只保留最高阶项。
                    3. 如果最高阶项存在且不是1,则去除与这个项相乘的常数。得到的结果就是大 O 阶。

                    (如某一步不存在,忽略该步)

                    四、参考链接

                    ]]>
                    <![CDATA[Array.prototype.sort 你真的掌握了吗?]]> https://github.com/tofrankie/blog/issues/202 https://github.com/tofrankie/blog/issues/202 Sun, 26 Feb 2023 09:24:46 GMT 在项目开发当中,对数组的排序肯定少不了,类似的升序排序肯定都见过。

                    const array = [2, 7, 4, 9, 3]
                    array.sort((a, b) => a - b)
                    // 升序排列:[2, 3, 4, 7, 9]
                    
                                在项目开发当中,对数组的排序肯定少不了,类似的升序排序肯定都见过。

                    const array = [2, 7, 4, 9, 3]
                    array.sort((a, b) => a - b)
                    // 升序排列:[2, 3, 4, 7, 9]
                    

                    可你有细究过 sort() 内部是怎么实现的吗?

                    sort() 方法用 原地算法 对数组的元素进行排序,并返回数组。默认排序顺序是在将元素转换为字符串,然后会按照转换为的字符串的每个字符的 Unicode 位点进行排序。

                    array.sort([compareFunction]) // 参数可选
                    

                    净看概率,可能会有点难理解,然后下面结合代码理解。

                    一、没有参数

                    // 1. 没问题
                    const arr1 = [5, 3, 8, 2, 0, -3]
                    arr1.sort() // output: [-3, 0, 2, 3, 5, 8]
                    
                    // 2. 也没问题
                    const arr2 = ['m', 'c', 'h', 'd']
                    arr2.sort() // output: ["c", "d", "h", "m"]
                    
                    // 3. 似乎跟预想的不一样啊!小朋友你是否有很多问号 ❓❓❓
                    const arr3 = [-2, 27, 0, -5, 4]
                    arr3.sort() // output: [-2, -5, 0, 27, 4]
                    

                    为什么 arr3 进行排序之后,返回的为什么不是 [-2, -5, 0, 4, 27] 呢?

                    结合概念来看,其实就没那么难理解了。首先我们没传参数,即采用默认排序方式,它会将每一个元素转化为字符串,接着按照字符串的每个字符的 Unicode 位置进行排序。

                    所以,在比较 274 时,它会先比较 24 ,发现 2 的 Unicode 顺序要比 4 靠前,所以最终的结果是 274 之前。同样的道理,负数在前是因为 - 的 Unicode 顺序更靠前。(附 ASCII 对照表

                    二、带参,且必须是函数

                    其实默认的排序方式在实际的应用中,很多不能满足我们的排序要求,所以 sort 也提供了一个参数 comparFunction,来满足我们的各种需求。

                    array.sort(compareFunction(a, b))

                    指明了 compareFunction,那么数组会按照该函数的返回值排序,即 ab 是两个将要被比较的元素

                    • 如果 compareFunction(a, b) 小于 0,那么 a 会被排列到 b 之前;
                    • 如果 compareFunction(a, b) 大于 0,那么 b 会排列到 a 之前;
                    • 如果 compareFunction(a, b) 等于 0,那么 ab 的相对位置不变。

                    三、几种最常见的排序方式

                    1. 升序、降序排序

                    // 按照上面的规则,来写个升序排序,那就 so easy 了,对吧
                    const array = [-2, 27, 0, -5, -4]
                    array.sort((a, b) => {
                      if (a > b) {
                        return 1
                      } else if (a < b) {
                        return -1
                      } else {
                        return 0
                      }
                    })
                    console.log(array) // [-5, -2, 0, 4, 27]
                    // 简写
                    // array.sort((a, b) => a - b)
                    
                    // 降序也是同理
                    // array.sort((a, b) => b - a)
                    

                    2. 根据对象某属性排序

                    const classroom = [
                      {
                        name: 'Frankie',
                        age: 18
                      },
                      {
                        name: 'Mandy',
                        age: 16
                      },
                      {
                        name: 'John',
                        age: 22
                      },
                      {
                        name: 'Ada',
                        age: 20
                      }
                    ]
                    
                    classroom.sort((a, b) => a.age - b.age)
                    console.log(classroom)
                    // [{"name": "Mandy", "age": 16}, {"name": "Frankie", "age": 18}, {"name": "Ada", "age": 20}, {"name": "John", "age": 22}]
                    

                    3. 根据字母 A-Z 排序

                    const array = ['fifa', 'nba', 'cbb', 'dnfa', 'cba', 'sos', 'dnf']
                    array.sort((a, b) => {
                      let length = Math.max.apply(null, [a.length, b.length])
                      for (let i = 0; i < length; i++) {
                        if (a[i] === undefined) {
                          return -1
                        } else if (b[i] === undefined) {
                          return 1
                        } else if (a.charCodeAt(i) < b.charCodeAt(i)) {
                          return -1
                        } else if (a.charCodeAt(i) > b.charCodeAt(i)) {
                          return 1
                        }
                      }
                      return 0
                    })
                    console.log(array)
                    // ["cba", "cbb", "dnf", "dnfa", "fifa", "nba", "sos"]
                    

                    The end.

                    ]]>
                    <![CDATA[前端如何实现视觉设计稿]]> https://github.com/tofrankie/blog/issues/201 https://github.com/tofrankie/blog/issues/201 Sun, 26 Feb 2023 09:23:54 GMT

                    内容转自:Gaolu - 前端如何实现视觉设计稿

              在这篇文章中将和大家探讨一下关于前]]>

              内容转自:Gaolu - 前端如何实现视觉设计稿

          在这篇文章中将和大家探讨一下关于前端在移动端开发如何去实现视觉设计稿。探讨过后,在大家的实际工作中或许能帮助解决一些问题。

          前端工程师需要明白的「像素」

          一般设计稿是 640px 或者 750px(现在最流行),但是 iPhone 5 不是 320px 宽吗,iPhone 6 不是 375px 宽吗? 这里需要理解一下基础概念: 设备像素(device pixel),CSS 像素(css pixel)以及设备像素比(device pixel ratio)。

          • 设备像素 (device pixel): 设备像素设是物理概念,指的是设备中使用的物理像素。 比如 iPhone 5 的分辨率 640 x 1136px

          • CSS 像素 (css pixel): CSS 像素是 Web 编程的概念,指的是 CSS 样式代码中使用的逻辑像素。 在 CSS 规范中,长度单位可以分为两类,绝对 (absolute) 单位以及相对 (relative) 单位。px是一个相对单位,相对的是设备像素 (device pixel)。

          • 设备像素比 (device pixel ratio): 即 window.devicePixelRatio,是设备上物理像素和设备独立像素 (device-independent pixels (dips)) 的比例。 公式表示就是 window.devicePixelRatio = 物理像素 / dips

          垂直手机屏幕下,使用<meta name="viewport" content="width=device-width"/>,iPhone 5 屏幕物理像素 640 像素,独立像素还是 320 像素,因此,window.devicePixelRatio 等于 2。

          比如 iPhone 5,6 使用的是 Retina 视网膜屏幕(2 倍屏),6 Plus 是 3 倍屏,使用2px × 2px的 device pixel 代表1px × 1px的 css pixel,所以设备像素数为640 × 1136px(5),750 × 1134(6),而 CSS 逻辑像素数为 320 x 568px(5),375 × 667(6);5,6 的 window.devicePixelRatio=2,6 Plus 为 3。

          H5 适配:rem 方案 rem:是 CSS3 新增的一个相对单位,相对于 html 标签的 font-size 的大小为基础的。而 font-size 的大小可以动态根据手机屏幕宽度document.documentElement.clientWidth 来设置,从而达到自适应屏幕的目的。

          我这里找了一下小米网易拉勾网手淘 以及糯米,大同小异。

          小米官网

          设计稿是 720px 的,即 5 英寸屏幕的安卓手机(720 x 1280px)。 对于页面缩放和横竖屏事件进行监听,改变 html 根元素字体 clientWidth/720/100。 如图是这样计算的 375/(720/100) = 52.0833

          网易

          iPhone 6 : 375/7.5=50, 则知道设计稿应该是基于 iPhone 6 来的,所以它的设计稿竖直放时的横向分辨率为 750px,为了计算方便,取一个 100px 的 font-size 为参照,那么 body 元素的宽度就可以设置为 width: 7.5rem,于是 html 的 font-size=deviceWidth / 7.5。布局时,设计图标注的尺寸除以 100 得到 css 中的尺寸。并且布局中的 font-size 也可用 rem 单位。

          拉勾网

          html {
            font-size: 65.5%;
          }
          

          设置html根元素字体为65.5%,对应px单位则为10.48px,则列表里时间信息字体设置为1rem = 10.48px,chrome在-webkit-text-size-adjust: 100%;情况下小于12px的一律显示为12px

          拉勾网页面列表部分是px为单位,字体是rem,底部bar是使用百分百来控制宽高间距。

          之前网上讨论的比较多的是

          body {
            font-size: 62.5%;
          }
          
          p {
            font-size: 1.2em;
          }
          

          1em = 16px * 62.5% = 10px,em 的初始值为 1em = 16px,而为了方便计算, 换算一下 10 / 16(16px 是 Chrome 浏览器默认字体大小)。缺点是进行任何元素设置,都有可能需要知道他父元素的大小,比较繁琐低效。

          手淘

          (1)动态设置 viewport的scale

          var scale = 1 / devicePixelRatio;
          document.querySelector('meta[name="viewport"]').setAttribute('content','initial-scale=' + scale + ', maximum-scale=' + scale + ', minimum-scale=' + scale + ', user-scalable=no');
          

          (2)动态计算 html 的 font-size

          document.documentElement.style.fontSize = document.documentElement.clientWidth / 10 + 'px';
          

          (3)布局的时候,各元素的 css 尺寸 = 设计稿标注尺寸/设计稿横向分辨率/10

          设计稿是 750 的,所以 html 的 font-size 就是 75,如果某个元素是 150px的宽,换算成 rem 就是 150 / 75 = 2rem。

          整个手淘设计师和前端开发的适配协作基本思路是:

          • 选择一种尺寸作为设计和开发基准
          • 定义一套适配规则,自动适配剩下的两种尺寸(介于 iPhone 6的小屏和大屏)
          • 特殊适配效果给出设计效果

          手淘推出了一套移动端适配的方案 - Flexible 方案

          总结来说:

          • 动态读取设备宽度并结合设备的像素比
          • 动态改变 html 的 font-size 大小 & 页面缩放比例
          • 影响以 rem 为单位的元素的最终呈现

          px 方案:css 尺寸为对应设计稿/2

          设计稿是 750 的。

          优点:简单粗暴,所有 css 尺寸均为设计稿尺寸直接除 2,开发快速简单; 缺点:可能出现一排放不下的情况,需要针对小屏幕如 5 及以下做单独适配

          vw 方案

          糯米 WAP

          利用 CSS3 中新增单位 <mark style="background: rgb(255, 255, 0); color: rgb(0, 0, 0);">vw</mark>,配合 <mark style="background: rgb(255, 255, 0); color: rgb(0, 0, 0);">百分比`来做响应式开发。

          单位 释义 说明
          px 相对于显示器屏幕分辨率 -
          em 相对于父元素字体大小 -
          rem 相对于根元素字体大小 css3
          vw 相对于视窗的宽度 css3
          vh 相对于视窗的高度 css3

          vw 相对于视窗的宽度:视窗宽度是 100vw。 如果视区宽度是 100vm, 则 1vm 是视区宽度的 1/100, 也就是 1%,类似于 width: 1%。 那 iPhone 6 来说,document.documentElement.clientWidth=375, 则豆腐块宽度为 375/100*30=112.5px

          混合: rem px vw 百分百等单位混用

          rem & 百分比%

          body {
            padding-bottom: 14.0625%;
          }
          
          a.link {
            width: 30vw;
            height: 23vw;
          }
          

          略,同上糯米WAP

          rem & vw

          html {  
            font-size: 4.375vw;
          }
          

          这里假设设计稿 640px,则设置根元素 font-size 为 4.375vw,根据屏幕宽度自适应,在视窗宽度为 320px 的时候,正好是 14px (14 / 320 = 0.04375)。 达到页面默认字体大小 14px 的目的(其他大小也 ok)。好了,现在页面上所有以  rem  为单位的属性值都会随着屏幕的宽度变化而变化,达到自适应的目的。(自适应不用 js 动态设置根元素大小

          p {  
            font-size: 1rem;   
            padding-top: 2rem: /* 设计稿上为 28px */
          }
          

          总结

          在移动端页面开发中,视觉童鞋一般会用 750px(iPhone 6)来出设计稿,然后要求 FE 童鞋能够做到页面是自适应屏幕的,这种情况下就可以用 rem 或者 vm 等相对单位来做适配,愉快和视觉童鞋一起玩耍啦。

          ]]>
          <![CDATA[JavaScript 深浅拷贝,其实没那么难!]]> https://github.com/tofrankie/blog/issues/200 https://github.com/tofrankie/blog/issues/200 Sun, 26 Feb 2023 09:20:21 GMT

          本文已过时,请移步《超详细的 JavaScript 深拷贝实现

          本文最后更新于 2020-03-03]]>

          本文已过时,请移步《超详细的 JavaScript 深拷贝实现

          本文最后更新于 2020-03-03

          什么是深浅拷贝?

          • 浅拷贝:创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址 ,所以如果其中一个对象改变了这个地址,就会影响到另一个对象。

          • 深拷贝:将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象

          浅拷贝的几种方式

          array.slice()array.concat()Object.assign() 都能实现浅拷贝,前两个针对数组。还可以用以下这种方式。

          // 浅拷贝
          function shallowCopy(source, target = {}) {
            for (let key in source) {
              if (source.hasOwnProperty(key)) {
                target[key] = source[key]
              }
            }
            return target
          }
          
          let student1 = {
            name: 'Frankie',
            age: 22,
            language: ['Chinese', ['English', 'German']]
          }
          
          let student2 = shallowCopy(student1)
          student2.language[1] = ['French']
          
          console.log(student1)
          console.log(student2)
          // 打印对比可知,language 同时改变了
          

          JSON

          利用 JSON.stringify & JSON.parse 方法

          缺点:

          1. 无法拷贝对象中的方法属性;
          2. 无法拷贝对象中的 undefined 属性

          JQuery

          JQuery 提供的两种方法 $.extend()$.clone() ,前者是 JS 对象的拷贝、后者是 DOM 对象的拷贝(这里不讨论)

          缺点:

          1. 需要引入 JQuery 库
          2. 无法拷贝对象中的 undefined 属性

          递归

          利用 for..in..hasOwnProperty递归实现

          缺点:比上面的两种方式稍复杂

          递归实现深拷贝:

          // 假设在 Object 的原型,添加一个 height 属性
          Object.prototype.height = 180
          
          // student1 什么类型的属性都有了
          const student1 = {
            name: 'Frankie',
            age: 22,
            private: true,
            friends: ['Mandy', 'John'],
            abilities: undefined,
            other: null,
            car: {
              color: 'gray',
              brand: 'Benz'
            },
            teacher: {
              name: 'Ada',
              student: ['Helkai', 'Jerry']
            },
            learn: function () {
              console.log(this.name + ' is learning JavaScript now.')
            }
          }
          
          // 深拷贝
          function deepCopy(source, target = {}) {
            for (const key in source) {
              // for...in... 会遍历原型链上的属性,所以这里需要利用 hasOwnProperty 判断,否则会把 Object 原型上的 height 也拷贝进去了
              if (source.hasOwnProperty(key)) {
                if (typeof source[key] === 'object' && source[key] !== null) {
                  // 判断是否为数组
                  target[key] = Object.prototype.toString.call(source[key]) === '[object Array]' ? [] : {}
                  // 递归
                  deepCopy(source[key], target[key])
                } else {
                  target[key] = source[key]
                }
              }
            }
            return target
          }
          
          // 从 student1 中拷贝一个 student2 出来
          const student2 = deepCopy(student1)
          // 修改 student2 的属性值
          student2.name = 'Steven'
          student2.age = 20
          student2.car = {
            color: 'red',
            brand: 'BMW'
          }
          student2.friends[1] = 'Jone'
          student2.teacher.age = '30'
          // 打印结果看下面截图,结果是没有相互影响的
          console.log(student1)
          console.log(student2)
          
          // JSON 方式
          const student3 = JSON.parse(JSON.stringify(student1))
          console.log(student3) //  打印 student3 可见 abilities、learn 属性丢失了
          

          运行结果:

          日常搬砖选择哪种方式呢,视具体情况而定。我的话,能用 JSON 方式解决的,绝不用递归,没必要复杂化,简单够用即可。但是学习的话,递归的方式必须得懂啊,其实不难懂。

          其实还有优化的空间,我们都知道 for...in 的性能是比较差的。关于性能优化篇,可以看下掘金这篇文章

          The end.

          ]]>
          <![CDATA[重写 Function.prototype.bind]]> https://github.com/tofrankie/blog/issues/199 https://github.com/tofrankie/blog/issues/199 Sun, 26 Feb 2023 09:16:24 GMT

          本文已过时,请移步《手写 call、apply、bind

        该方法创建一个新的函数,在 bind() 被调]]>

        本文已过时,请移步《手写 call、apply、bind

        该方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。

        function.bind(thisArg [, arg1 [, arg2 [, ...]]])
        

        参数 thisArg

        • 如果使用 new 运算符构造绑定函数,则忽略该值。
        • 当使用 bindsetTimeout 中创建一个函数(作为回调提供)时,作为 thisArg 传递的任何原始值都将转换为 Object
        • 如果 bind 函数的参数列表为空,执行作用域的 this 将被视为新函数的 thisArg

        参数 arg1、arg2...

        当目标函数被调用时,被预置入绑定函数的参数列表中的参数。

        返回值

        返回一个原函数的拷贝,并拥有指定的 this 值和初始参数。

        实现:

        const point = {
          x: 0,
          y: 0,
          z: 0
        }
        
        // 构造函数
        function Point(x, y, z) {
          console.log(this.x, x, y, z)
          console.log('')
        }
        
        // 函数名写成 bindy,是为了方便与原生 bind 方法对比
        Function.prototype.bindy = function (context) {
          const _this = this
          const args = Array.prototype.slice.call(arguments, 1)
          const tempFn = function () {}
        
          const fn = function () {
            const thatArgs = Array.prototype.slice.call(arguments)
            _this.apply(this instanceof _this ? this : context, args.concat(thatArgs))
          }
        
          // 圣杯模式
          tempFn.prototype = this.prototype
          fn.prototype = new tempFn()
        
          return fn
        }
        
        // bind 可以在调用的时候传入参数
        Point.bind(point, 1, 2)(3) // output: 0 1 2 3
        Point.bindy(point, 4, 5)(6) // output: 0 4 5 6
        
        // 使用 new 去构造时,bind 绑定的 this 最终会执行构造函数
        const p = Point.bind(point)
        const p2 = Point.bindy(point)
        new p(3, 3, 3) // output: undefined 3 3 3
        new p2(4, 4, 4) // output: undefined 4 4 4
        
        ]]>
        <![CDATA[手写实现 Promise/A+ 标准]]> https://github.com/tofrankie/blog/issues/198 https://github.com/tofrankie/blog/issues/198 Sun, 26 Feb 2023 09:11:51 GMT 本文仅作为个人记录,文中可能存在不严谨的地方。

        要了解更多可以看下这两篇文章:

        Promise/A+ 的标准有哪些?

        • 只有一个 then 方法,没有 catchraceall 等方法。
        • then 返回一个新的 Promise。
        • 不同的 Promise 的实现需要相互调用。
        • Promise 的状态有 pendingfullfilledrejected 三种。初始状态是 pending,可以由 pending 转化为 fullfilled 或者 rejected。一旦状态确定之后,不能再被改变。
        • 更具体的官方标准,看这里

        具体代码实现

        function MyPromise(executor) {
          const _this = this
          // 状态
          _this.status = 'pending'
          // resolve 值
          _this.value = null
          // reject 原因
          _this.reason = null
          // resolve、reject 函数
          _this.onFullfilled = []
          _this.onRejected = []
        
          function resolve(value) {
            if (value instanceof MyPromise) {
              return value.then(resolve, reject)
            }
        
            // 其实这里采用 setTimeout 方式实现异步执行 onFullfilled/onRejected 不太符合 Event Loop 机制。下面 reject 同理。
            setTimeout(() => {
              // 只有状态为 pending 才能被改变
              if (_this.status == 'pending') {
                _this.value = value
                _this.status = 'resolved'
                _this.onFullfilled.forEach(currentValue => currentValue(value))
              }
            }, 0)
          }
        
          function reject(reason) {
            setTimeout(() => {
              // 只有状态为 pending 才能被改变
              if (_this.status == 'pending') {
                _this.reason = reason
                _this.status = 'rejected'
                _this.onRejected.forEach(currentValue => currentValue(reason))
              }
            }, 0)
          }
        
          // 注意:若执行过程出现异常,则捕获异常并执行 reject 函数。
          try {
            executor(resolve, reject)
          } catch (e) {
            reject(e)
          }
        }
        
        function resolvePromise(promise2, x, resolve, reject) {
          let then
          let thenCallorThrow = false
        
          if (promise2 === x) {
            return reject(new TypeError('same Promise!'))
          }
        
          if (x instanceof MyPromise) {
            if (x.status === 'pending') {
              x.then(value => {
                resolvePromise(promise2, value, resolve, reject)
              }, reject)
            } else {
              x.then(resolve, reject)
            }
            return
          }
        
          if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
            try {
              then = x.then
              if (typeof then === 'function') {
                then.call(
                  x,
                  res => {
                    if (thenCallorThrow) return
                    thenCallorThrow = true
                    return resolvePromise(promise2, res, resolve, reject)
                  },
                  err => {
                    if (thenCallorThrow) return
                    thenCallorThrow = true
                    return reject(err)
                  }
                )
              } else {
                resolve(x)
              }
            } catch (e) {
              if (thenCallorThrow) return
              thenCallorThrow = true
              return reject(e)
            }
          } else {
            return resolve(x)
          }
        }
        
        MyPromise.prototype.then = function (onFullfilled, onRejected) {
          const _this = this
          let promise2 // promise.then() 返回一个 promise 对象
        
          // Promise 值的穿透处理:
          // 场景如: new Promise(resolve => resolve('abc')).then().catch().then(res => {console.log('print abc')})
          onFullfilled = typeof onFullfilled === 'function' ? onFullfilled : val => val
          onRejected =
            typeof onRejected === 'function'
              ? onRejected
              : err => {
                  throw err
                }
        
          switch (_this.status) {
            case 'pending':
              promise2 = new MyPromise((resolve, reject) => {
                _this.onFullfilled.push(value => {
                  try {
                    const x = onFullfilled(value)
                    resolvePromise(promise2, x, resolve, reject)
                  } catch (e) {
                    reject(e)
                  }
                })
                _this.onRejected.push(reason => {
                  try {
                    const x = onRejected(reason)
                    resolvePromise(promise2, x, resolve, reject)
                  } catch (e) {
                    reject(e)
                  }
                })
              })
              break
            case 'resolved':
              promise2 = new MyPromise((resolve, reject) => {
                setTimeout(() => {
                  try {
                    const x = onFullfilled(_this.value)
                    resolvePromise(promise2, x, resolve, reject)
                  } catch (e) {
                    reject(e)
                  }
                })
              })
              break
            case 'rejected':
              promise2 = new MyPromise((resolve, reject) => {
                setTimeout(() => {
                  try {
                    const x = onRejected(_this.reason)
                    resolvePromise(promise2, x, resolve, reject)
                  } catch (e) {
                    reject(e)
                  }
                })
              })
              break
            default:
              break
          }
        
          return promise2
        }
        
        // Promise 标准里面没有 catch、race、all 等方法,只有一个 then 方法
        MyPromise.prototype.catch = function (onRejected) {
          return this.then(null, onRejected)
        }
        
        new MyPromise((resolve, reject) => {
          resolve('right')
        }).then(
          res => {
            console.log(res)
          },
          err => {
            console.warn(err)
          }
        )
        

        未完待续...

        ]]>
        <![CDATA[你是如何理解 setTimeout 的?]]> https://github.com/tofrankie/blog/issues/197 https://github.com/tofrankie/blog/issues/197 Sun, 26 Feb 2023 09:10:04 GMT 配图源自 Freepik

        一、setTimeout

        在 JavaScript 中 <]]> 配图源自 Freepik

        一、setTimeout

        在 JavaScript 中 setTimeoutsetInterval 最常见不过了,用于延迟或者延迟重复处理等。

        setTimeout(() => {
          console.log('一秒后执行')
        }, 1000)
        

        以上示例,可以简单地理解成:「一秒」后输出对应的字符串。但是这「一秒」只是我们所设的「预期值」,然而实际情况它只是「最小延迟时间」而已。换句话说,最理想情况下,一秒之后会执行回调函数,然而现实往往不是这样的,肯定存在误差在里面。

        那能否在我们所指定的时间执行回调函数呢?

        严格来说肯定是不行的,有实实在在的误差在里面,平常看起来像是指定时间执行只是因为人本身无法感知其中微妙的误差而已。倘若误差在可接受范围内,理解成指定时间后执行也是没问题的。

        误差产生的因素有很多,比如 for 循环、其他异步任务(微任务、宏任务)、浏览器精度等等。本质上是 Event Loop 机制导致的的现象。比如示例:

        setTimeout(() => {
          console.log('会在一秒后执行吗?')
        }, 1000)
        
        for(let i = 0; i < 10000; i++) {
          console.count('循环次数')
        }
        

        我对 setTimeout 的理解是:在指定时间后,将回调函数作为异步任务加入到任务队列中。

        对于刚接触 JavaScript 的朋友,可能会错误地理解为:在指定时间后,执行对应函数。这是不对的。

        为什么呢?因为可能一秒的时间内 for 循环还没执行完,所以一秒后还没开始执行定时器里面的函数。我们都知道 setTimeout 属于异步任务(宏任务),在执行(下)一个异步任务之前,首先得执行当前完同步任务、微任务(也属于异步任务)、接着更新 UI 之后,才会执行(下一个)异步任务。

        二、扩展

        setTimeout 加不加括号,会导致什么不同的结果?

        看个示例,请问二者有什么区别,会产生什么不同的结果:

        function foo() {
          console.log('show foo')
        }
        
        // 写法一
        setTimeout(foo, 3000)
        
        // 写法二
        setTimeout(foo(), 3000)
        // 两者运行结果一致吗?
        
        1. delay 设为 300,看起来好像没区别,都能正常输出 show foo,接着往下看。
        2. 若将 delay 设为 3000,仍然都能输出字符串,但有点区别。setTimeout(foo, 3000) 在预期的 3s 后输出值。然而setTimeout(foo(), 3000) 好像立刻执行了,而不是等 3s 后才输出。
        3. 通过设置不同 delay 值可以更明显地感知其中的区别,越大越明显。

        两者区别:

        1. 不加括号:能正常地按照我们所预期的时候执行对应的函数。
        2. 加括号:同样会执行该函数,但它是立即执行,所以不会达到延迟执行的目的。(这点说法不严谨,只是帮助理解,请继续往下看)

        造成上面差异的原因是什么呢?

        我们改下代码,就很清晰了。

        function foo() {
          console.log('show foo')
          return `console.log('哈哈')`
        }
        
        setTimeout(foo(), 3000)
        
        // 结果:立即打印出 show foo,三秒后打印了 “哈哈”。
        

        由于 foo 函数返回值是 console.log('哈哈'),因此 setTimeout(foo(), 3000) 相当于 setTimeout('console.log("哈哈")', 3000) ,就会产生这样的结果。

        其实 setTimeout 方法第一个参数除了支持函数之外,还可以是字符串。若是字符串,会使用 eval 去执行。

        由于我们最常用的写法是执行一个匿名函数(如setTimeout(() => {}, delay)),没注意的同学,所以可能会忽略加与不加括号的区别。

        还有,不建议使用 setTimeout('String Code', delay) 的形式。因为 eval 通常被用来执行动态创建的代码,如果 eval(...) 中执行的代码包括一个或多个声明(无论变量还是函数),就会对 eval(...) 所处的词法作用域进行修改(可看文章)。避免出现一些意料之外的事情,不建议使用。

        三、其他

        1. 当使用 setTimeout() 方法的时候,是否必须执行 clearTimeout() ?

        • setTimeout() 内的函数执行之前,如果想要阻止执行该方法,只能通过 cleartTimeout() 来处理。

        • setTimeout() 内的函数执行之后,执行 clearTimeout() 方法对整个代码流程没有害处,但是是没有必要的。

        • 通常情况,执行 clearInterval() 比执行 clearTimeout() 更实际一些,因为如果不执行 clearInterval(),则 setInterval() 的方法会无限循环执行下去。而 setTimeout() 在一次调用后,就会停止执行(浏览器会自动回收资源)。除非你创建了一个无限循环的 setTimeout()

        2. 关于 setTimeout(fn, 0) 的问题

        注意,这仍然属于异步任务,指定某个任务在主线程最早可得的空闲时间执行。HTML5 标准中规定了 setTimeout() 的第二个参数的最小值(最短间隔),不得低于 4ms,如果低于这个值,就会自动增加。在此之前,老版本的浏览器都将最短间隔设为 10ms。另外,对于那些 DOM 的变动(尤其是涉及页面重新渲染部分),通常不会立即执行,而是每 16ms 执行一次,对于动画的优化,window.requestAnimationFrame 或是更好的选择。

        四、参考链接

        ]]>
        <![CDATA[History 对象]]> https://github.com/tofrankie/blog/issues/196 https://github.com/tofrankie/blog/issues/196 Sun, 26 Feb 2023 09:00:57 GMT 本文更新于 2020-04-22。

        1. HTML5 使用 history 对象 history.pushState()history.replaceState() 方法添加和修改浏览历史记录, 本文更新于 2020-04-22。

          1. HTML5 使用 history 对象 history.pushState()history.replaceState() 方法添加和修改浏览历史记录,这里

          2. History 对象提供的方法有:

            • back():移动到上一个访问页面,等同于浏览器的后退键。

            • forward():移动到下一个访问页面,等同于浏览器的前进键。

            • go():接受一个整数作为参数,移动到该整数指定的页面,比如 go(1) 相当于 forward()go(-1)相当于 back()history.go(0)history.go() 相当于刷新当前页面。

              注意:如果移动的位置超出了访问历史的边界,以上三个方法并不报错,而是默默的失败。 注意:返回上一页时,页面通常是从浏览器缓存之中加载,而不是重新要求服务器发送新的网页。

            • 其中 pushState()replaceState() 是 HTML5 新增的方法,用来在浏览历史中添加和修改记录。判断是否支持 pushState 方法:!!(window.history && history.pushState)

          3. history.pushState(object state, string title, string url) 方法能实现在不刷新页面的情况下修改浏览器 url 链接,并且该方法创建了新的浏览记录,并将新的链接插入到浏览记录队列中。

            • state:一个与指定网址相关的状态对象,popstate 事件触发时,该对象会传入回调函数。 如果不需要这个对象,此处可以填 null。新页面里可以通过 window.history.state 获取。

            • title:新页面的标题,但是所有浏览器目前都忽略这个值,因此这里可以填 null

            • url:新的网址,必须与当前页面处在同一个域,否则报错(不支持跨域)。浏览器的地址栏将显示这个网址。不允许跨域的目的是,防止恶意代码让用户以为他们是在另一个网站上。假如设置了一个新的锚点值(即 hash),并不会触发 hashchange 事件。

          4. 总之,pushState 方法不会触发页面刷新,只是导致 history 对象发生变化,地址栏会有反应。

          5. history.replaceState 方法的参数与 pushState 方法一模一样,区别是它修改浏览历史中当前纪录。

          6. popstate 事件:

            每当同一个文档的浏览历史(即 history 对象)出现变化时,就会触发 popstate 事件

            需要注意的是,仅仅调用 pushState 方法或 replaceState 方法,并不会触发该事件,只有用户点击浏览器倒退按钮和前进按钮,或者使用 JS 调用 back()forward()go() 方法时才会触发。另外,该事件只针对同一个文档,如果浏览历史的切换,导致加载不同的文档,该事件也不会触发。

          // event.state,就是通过 pushState 和 replaceState 方法,为当前 URL 绑定的 state 对象,可以通过 history.state 获取
          window.onpopstate = function (event) {
            console.log('location: ' + document.location)
            console.log('state: ' + JSON.stringify(event.state))
          }
          
          1. 其他

            获取 URL, 从输出结果上,document.URLwindows.location.href 没有区别。 非要说区别的话,你只可以读取 document.URL 的值,不能修改它。windows.location.href 的值你即可以读取也可以修改。 windows.location.href 是旧的写法,新的标准推荐用 document.URL 替代。(如果你只是读取的话)

          document.location.href
          window.location.href
          document.URL // 只读
          
          1. 参考:
          ]]> <![CDATA[JavaScript 事件循环(含宏任务与微任务)]]> https://github.com/tofrankie/blog/issues/195 https://github.com/tofrankie/blog/issues/195 Sun, 26 Feb 2023 08:59:40 GMT

          本文已过时,可移步《通过两个例子再探 Event Loop》。

        JavaScript 特点

        JavaScrip]]>

        本文已过时,可移步《通过两个例子再探 Event Loop》。

        JavaScript 特点

        JavaScript 是单线程非阻塞的一门语言。

        单线程意味着 JavaScript 代码执行时只有一个主线程去处理所有的任务,即同一时间只能做一件事情。

        非阻塞表示当执行到异步任务时,主线程会挂起该异步任务,待异步任务返回结果时,再根据一定的规则去执行相应的回调。

        思考: 为什么 JavaScript 要设计成单线程?

        单线程是必要的,也是 Javascript 这门语言的基石。在最初的浏览器执行环境中,我们需要进行各种各样的 DOM 操作。如果 Javascript 是多线程的话,当两个线程同时对 DOM 进行一项操作,比如一个向其添加事件,另一个删除了这个 DOM 元素,此时该如何处理呢?因此,Javascript 选择只用一个主线程来执行代码,这样就保证了程序执行的一致性。

        事件循环(Event Loop)

        JavaScript 是通过「事件循环」的实现非阻塞的。而事件循环是通过「任务队列」机制协调的。

        在事件循环中,每进行一次循环操作称为 Tick,每一次 Tick 的任务处理是比较复杂的,主要步骤如下:

        1. 在本次 Tick 中选择最先进入队列的任务,如有则执行一次。
        2. 检查是否存在微任务(microtask),如有则执行,直至清空微任务队列(microtask queue)。
        3. 渲染页面。
        4. 主线程重复执行上述步骤。

        Tick 需要了解的是:

        • JS 任务分为「同步任务」和「异步任务」。
        • 同步任务都在主线程上执行,形成一个执行栈。
        • 主线程之外,事件触发线程管理着一个任务队列,只要异步任务有了结果,就在任务队列里面放置一个事件。
        • 一旦执行栈中所有同步任务执行完毕(JS 引擎空闲之后),就会去读取任务队列,将可运行的异步任务添加到可执行栈里面,开始执行。

        任务分为「同步任务」和「异步任务」,其中异步任务又分为「宏任务」和「微任务」。

        宏任务(Task)

        每次执行栈的代码就是一个宏任务(包括每次从事件队列中获取的一个事件回调并放到执行中执行)。

        浏览器为了能够使得 JS 内部宏任务与 DOM 任务有序地执行,会在宏任务执行结束之后,在下一个宏任务开始执行之前,对页面进行重新渲染。

        当前宏任务 → 渲染页面 → 下一个宏任务 → ...

        常见宏任务有:

        • setInterval
        • setTimeout
        • setImmediate(node.js)
        • XHR 回调
        • 事件回调(鼠标键盘事件)
        • indexedDB 数据库等 I/O 操作
        • 渲染 DOM

        微任务(Microtask)

        当前同步任务执行结束之后,在下一个宏任务之前(也在渲染 DOM 之前),立即执行的任务。

        当前宏任务 → 当前微任务 → 渲染页面 → 下一个宏任务 → ...

        常见微任务包括:

        • Promise 的 thencatchfinally 回调。
        • process.nextTick(node.js)
        • MutationObserver
        • Object.observe(已废弃)

        运行机制

        1. 执行一个宏任务(执行栈中没有就从事件队列中获取)。
        2. 执行过程中如果遇到微任务,就将其添加到微任务的任务队列里面。
        3. 宏任务执行完毕之后,立即执行当前微任务队列里面的所有微任务(依次执行)。
        4. 当前宏任务执行完毕之后,开始检查渲染,然后 GUI 线程接管渲染(但是 UI render 不一定会执行,因为需要考虑 UI 渲染消耗的性能已经有没有 UI 变动)。
        5. 渲染完毕后,JS 线程继续接管,开始下一个宏任务(从事件队列中获取)。
        6. JS 不断重复以上步骤,直至所有任务执行完毕。(栈内存溢出也会终止执行)。

        参考链接

        ]]>
        <![CDATA[H5 唤起原生 APP]]> https://github.com/tofrankie/blog/issues/194 https://github.com/tofrankie/blog/issues/194 Sun, 26 Feb 2023 08:58:00 GMT 可以看看以下这篇文章,里面列举了 URL Scheme、Intent、Universal Link 三种方式唤醒 APP 的坑,没有一个比较完美的解决方法。文章最后建议使用一个 suanmei/callapp-lib]]> 可以看看以下这篇文章,里面列举了 URL Scheme、Intent、Universal Link 三种方式唤醒 APP 的坑,没有一个比较完美的解决方法。文章最后建议使用一个 suanmei/callapp-lib 库去兼容绝大多数的应用场景。

        文章出处:拾壹小筑 —— H5 唤起 APP 指南

        ]]>
        <![CDATA[关于 onbeforeunload]]> https://github.com/tofrankie/blog/issues/193 https://github.com/tofrankie/blog/issues/193 Sun, 26 Feb 2023 08:56:49 GMT

        原文

    页面在关闭前会有 onbeforeUnload 事件,来询问用户是否要关闭这个页面/标]]>

    原文

    页面在关闭前会有 onbeforeUnload 事件,来询问用户是否要关闭这个页面/标签。

    • 浏览器的 F5 刷新为:onbeforeUnload → onunload → onload
    • 浏览器关闭为:onbeforeUnload → onunload

    window.onbeforeunload 的方法体中用 alertconfirm 这样的方法在 IE 中是有效的,会弹出来,点击后页面也会关闭。confirm 也是如此,即使你 confirm 点击了取消还是会刷新/关闭页面。

    而在现代浏览器 Chrome、Firefox 中是不会弹出来的,但是会执行。这是浏览器的一个 bug 或者机制吧,在页面关闭后 alertconfirm 等弹出框是不会弹出来的。想要一个可以让用户选择的是否关闭的办法,alertconfirm 是不可行的。

    网上查了下资料发现用 event.returnValue = '你想让用户看到的信息' 这样的方法可以在页面被关闭之前让用户看到提示信息“这个页面要被关闭了,你想关闭还是不关闭?”来让用户进行选择。

    IE8、Chrome 可以让用户完全看到你 rerturnValue 中的信息,Firefox 的内容就是浏览器自带的,不管你returnValue 设置的显示内容是如何,它始终显示自带的。

    参考链接

    ]]>
    <![CDATA[初识 JavaScript 函数 Arguments 模拟重载]]> https://github.com/tofrankie/blog/issues/192 https://github.com/tofrankie/blog/issues/192 Sun, 26 Feb 2023 08:44:51 GMT 在 JavaScript 中并没有重载函数的功能,但每个函数中的 Arguments 对象可以模拟重载的实现。

    1. 通过下标访问实参

    arguments 不是一个数组对象,没有数组对象所有的属性和方法,但通过 arguments[0] 在 JavaScript 中并没有重载函数的功能,但每个函数中的 Arguments 对象可以模拟重载的实现。

    1. 通过下标访问实参

    arguments 不是一个数组对象,没有数组对象所有的属性和方法,但通过 arguments[0]arguments[1] 等方式获取实参。

    function demo() {
      let str = ''
      for (let i = 0; i < arguments.length; i++) {
        str += arguments[i] + ', '
      }
    }
    console.log(demo('小明', '小红')) // 输出:小明, 小红,
    

    2. 实现重载

    利用 Arguments 对象实现函数重载的方式可以有几种,除了根据参数的个数,还可以根据传入参数的类型、或者利用参数中特殊的参数值来执行不同的操作。

    // 通过参数个数实现重载
    function overloadDemo() {
      switch (arguments.length) {
        case 0:
          console.log(0)
          break
        case 1:
          console.log(1)
          break
        default:
          console.log(arguments.length)
          break
      }
    }
    overloadDemo('name') // 输出:1
    

    3. callee 属性

    Arguments 对象的 callee 属性指向的是正在被执行的 Function 对象。常常利用该属性实现递归。

    function sum(n) {
      if (n == 1) {
        return 1
      } else {
        return n + arguments.callee(n - 1)
      }
    }
    console.log(sum(5)) // 输出:15
    

    但是 arguments.calleearguments.caller 已经在 ES5 严格模式中禁用,将来也会彻底移除。

    ]]>
    <![CDATA[Vue 组件之间通信以及状态管理模式(Vuex)]]> https://github.com/tofrankie/blog/issues/191 https://github.com/tofrankie/blog/issues/191 Sun, 26 Feb 2023 08:36:20 GMT 一、使用 props

    props 用于接收来自父组件的数据。props 可以是简单的数组,或者使用对象作为替代,对象允许配置高级选项,如类型检测、自定义校验和设置默认值。这种方式,不能在子组件更改父组件的对应的属性值 一、使用 props

    props 用于接收来自父组件的数据。props 可以是简单的数组,或者使用对象作为替代,对象允许配置高级选项,如类型检测、自定义校验和设置默认值。这种方式,不能在子组件更改父组件的对应的属性值

    <!-- 父组件 -->
    <template>
      <child message="这是父组件给子组件的信息"></child>
    </template>
    
    <script>
    import Child from '../xxx/xxx'
    export default {
      components: { Child }
    }
    </script>
    
    <!-- 子组件 -->
    <template>
      <div>{{ message }}</div>
    </template>
    
    <script>
    export default {
      props: ['message']
    }
    </script>
    

    props 是单向绑定的:当父组件的属性变化时,将传导给子组件,但是不会反过来。这是为了防止子组件无意修改了父组件的状态。

    若一定要改,只能通过子组件 $emit(),父组件 $on() 进行响应更改,如在更复杂的情况下,建议使用状态管理模式,即 Vuex。下面会讲述这两种方式的具体使用方式。

    二、$emit()、$on() 非父子组件间的通信(简单场景)

    $emit()$on()和的事件必须是在一个公共的实例上,才能触发。官方文档里这个主要用于非父子组件之间的通信。

    下面对这个例子简单修改一下:

    // 新建一个公共示例
    // bus.js
    import Vue from 'vue'
    export const bus = new Vue()
    
    <!-- 父组件 -->
    <template>
      <child :message="message"></child>
    </template>
    
    <script>
    import Child from '../xxx/xxx'
    // 引入公共实例
    import { bus } = from 'xxx/bus.js'
    export default {
        components: {Child},
        data() {
          return {
             message: '这是父组件给子组件的信息'
          }
        },
      mounted() {
        // $on() 响应
        bus.$on('change', (msg) => {
          console.log(msg)
        })
      }
    }
    </script>
    
    <!-- 子组件 -->
    <template>
      <div>{{ message }}</div>
    </template>
    
    <script>
    import { bus } from 'xxx/bus.js'
    export default {
      props: ['message'],
      watch: {
        message(newVal, oldVal) {
          // 通过 $emit() 去触发
          bus.$emit('change', '要改变的值')
        }
      }
    }
    </script>
    

    三、使用 Vuex 进行状态管理(主要用于较为复杂场景),划重点

    1. 安装 Vuex
    npm install vuex --save
    
    1. 新建 store.js ,将 Vuex 放在里面,我就不放 main.js 里了
    import Vue from 'vue'
    import Vuex from 'vuex'
    
    Vue.use(Vuex)
    
    /**
     * 说明:
     *
     * 一、Mutations
     * 1. 更改 Vuex 的 store 中的状态的唯一方法是提交 mutation
     * 2. 一条重要的原则就是要记住 mutation 必须是同步函数
     *
     * 二、Action
     * 1. action 提交的是 mutation,而不是直接变更状态
     * 2. action 可以包含任意异步操作
     * 3. 所以我更喜欢使用 action 去提交 mutation,例如下面这个异步操作
     *
     */
    
    const store = new Vuex.Store({
      // 定义状态
      state: {
        message: ''
      },
      mutations: {
        changeMessage(state, payload) {
          // 状态变更
          state.message = payload
        }
      },
      actions: {
        changeMessage(context, args) {
          context.commit('changeMessage', args)
          setTimeout(() => {
            // 1.5s 后置空
            context.commit('changeMessage', '')
          }, 1500)
        }
      }
    })
    
    export default store
    

    3). main.js 引入

    import Vue from 'vue'
    import App from './App.vue'
    import store from './store.js'
    
    new Vue({
      el: '#app',
      store,
      render: h => h(App)
    })
    

    4). 提交载荷、分发 Action、获取状态

    // 以下 this 指向 vue 实例
    
    // 提交 Payload,仅支持同步操作
    this.$store.commit('changeMessage', 'any')
    
    // 分发 Action,支持异步操作
    this.$store.dispatch('changeMessage', 'any')
    
    // 获取 state
    const var1 = this.$store.state.message
    

    写得有点啰嗦了,更具体或者细节的地方,麻烦移步官方文档: Vuex 是什么?

    啊啊啊,不想写了,好了,就到此为止吧。有疑问的留意,尽我所会的回答。(如有更好的方式,或者改进的地方,欢迎指出!)

    ]]>
    <![CDATA[Vue 实现复制内容到剪贴板]]> https://github.com/tofrankie/blog/issues/190 https://github.com/tofrankie/blog/issues/190 Sun, 26 Feb 2023 08:35:48 GMT 官网:点击跳转

    1. 安装 npm install --save vue-clipboard2

    2. 引入(我一]]> 官网:点击跳转

      1. 安装 npm install --save vue-clipboard2

      2. 引入(我一般在 main.js 引入,如果仅仅是某一小模块用到,在对应的 component 引入即可)

      import Vue from 'vue'
      import VueClipboard from 'vue-clipboard2'
       
      Vue.use(VueClipboard)
      

      3、使用

      const message = '拷贝的文本'
      this.$copyText(message)
        .then(res => {
          alert('Copied')
        })
        .catch(err => {
          alert('Can not copy')
        })
      
      ]]> <![CDATA[JS 中文转换拼音的实现]]> https://github.com/tofrankie/blog/issues/189 https://github.com/tofrankie/blog/issues/189 Sun, 26 Feb 2023 08:31:53 GMT 写在前面

      或许你需要的是:

      需求

      此前项目中有一个中文转拼音的需求,于是整理了一下实现方法。

      在转换过程中,这里我保留了数字和字母,其他特殊字符或者空格将会被去掉。如 测试 会被转换成 CeShi。(可自行修改 convert2Pinyin() 方法实现其他需求)

      实现

      示例 Demo

      <!-- html -->
      <input id="input" value="测试" />
      <input id="output" disabled />
      
      // js
      window.onload = () => {
        const input = document.getElementById('input')
        const output = document.getElementById('output')
      
        // 失焦进行转换
        input.addEventListener('blur', () => {
          const val = input.value
          output.value = convertToPinyin(val)
        })
      
        /**
         * 转换成拼音(若有空格、特殊字符将被移除)
         *
         * @param {string} sourceStr 原始数据
         */
        const convertToPinyin = sourceStr => {
          // 目标数据
          let targetStr = ''
          // 匹配中文
          const cnReg = /[\u4e00-\u9fa5]/
          // 匹配数字和英文
          const enReg = /[a-zA-Z0-9]/
          // 保留英文和数字
          const keep = true
      
          // 遍历源数据
          for (let i = 0, len = sourceStr.length; i < len; i++) {
            const str = sourceStr.substr(i, 1)
            if (keep && enReg.test(str)) {
              targetStr += str
            } else if (cnReg.test(str)) {
              const searchResult = searchPinYin(str, PinYin)
              if (searchResult) {
                // targetStr += searchResult
                targetStr += capitalize(searchResult) // 首字母大写
              }
            }
          }
      
          return targetStr
        }
      
        /**
         * 检索拼音
         *
         * @param {string} str 源字符串
         * @param {object} data 收集的拼音 Unicode 编码集合
         */
        const searchPinYin = (str, data) => {
          for (const key in data) {
            if (data.hasOwnProperty(key) && data[key].indexOf(str) !== -1) {
              return key
            }
          }
          return ''
        }
      
        /**
         * 将拼音首字母转换为大写
         *
         * @param {string} str 源字符串
         */
        const capitalize = str => {
          if (str) {
            const [first] = str
            const other = str.replace(/^\S/, '')
            return `${first.toUpperCase()}${other}`
          }
          return str
        }
      
        /**
         * 目前这个 16 进制 Unicode 编码是网上收集的,可能不能覆盖所有的中文字符,可以自行补充;
         *
         * 例如:‘婋’(xiao)字:
         * 1、使用 '婋'.charCodeAt(0).toString(16) 得到 Unicode 编码:5a4b;
         * 2、将编码前面加上:\u => \u5a4b;
         * 3、然后放到对象 PinYin['xiao'] 里面。
         *
         * 现在只想到了这种笨方法一个一个往里补充,如果有更好的方法,欢迎指出!!!
         */
        const PinYin = {
          a: '\u554a\u963f\u9515'
          // ...
          // 由于这块代码太多,这里省略就不贴上来了,麻烦请看 GitHub Demo。
        }
      }
      
      

      难点

      其实在中文转拼音的过程中,比较麻烦的在于「多音字」和「生僻字」的实现,我想到的解决思路是:

      1. 生僻字:是由于 PinYin 里面列举的缺失一些中文字符,可通过 str.charCodeAt(0).toString(16) 的方式补充,具体方法不再赘述,上面有说明。

      2. 多音字:其实多音字一直是最麻烦的地方,因为每一个中文字符只有一个对应的 Unicode 编码,所以需要在对象的多个对应属性上添加编码。 如 字,通过 '曾'.charCodeAt(0).toString(16) 获取到 66fe,然后前面拼接上 \u,得到 \u66fe

      // 截取 PinYin 一小部分
      const PinYin = {
        ceng: '\u66fe',
        zeng: '\u66fe'
      }
      

      然后修改上述 convert2Pinyin()searchPinYin() 方法,把符合规则的字符返回一个数组,然后用排列组合的方式列出所有可能。

      最后

      除了拼音转中文之外,该仓库还有其他实用方法,请移步 tofrankie/utils

      ]]>
      <![CDATA[关于 Flex 布局]]> https://github.com/tofrankie/blog/issues/188 https://github.com/tofrankie/blog/issues/188 Sun, 26 Feb 2023 08:28:38 GMT 在项目的开发过程中,常遇到水平居中、垂直居中的需求。挺多人第一个想到可能是:text-alignvertical-align,但这两个属性仅适用于行内元素

      {
        text-align: center;
        vertical-align: middle
      }
      

      本文,介绍一些常见的居中方法,包括水平居中、垂直居中。

      <!DOCTYPE html>
      <html lang="en">
        <head>
          <!-- 此处省略一些众所周知的代码... -->
          <style>
            .parent {
              width: 100px;
              height: 100px;
              border: 2px solid #f00; /* 红色 */
            }
            .child {
              width: 50px;
              height: 50px;
              border: 2px solid #00f; /* 蓝色 */
            }
          </style>
        </head>
        <body>
          <div class="parent">
            <div class="child">child</div>
          </div>
        </body>
      </html>
      
      
      1. 水平居中:text-align: centermargin: 0 auto,其中 text-align 作用的对象是它的子元素,且必须为行内元素,块级元素无效
      <!-- 样式 -->
      <style>
        .parent {
          width: 100px;
          height: 100px;
          border: 2px solid #f00;
          text-align: center;
        }
        .child {
          width: 50px;
          height: 50px;
          border: 2px solid #00f;
        }
      </style>
      
      <!-- HTML -->
      <div class="parent">
        <!-- 块级元素 -->
        <div class="child">child</div>
        <!-- 行内元素 -->
        <img src="./images/pic.jpg" width="40px" height="40px" />
      </div>
      

      下班继续写,哈哈哈……

      ]]>
      <![CDATA[JavaScript for 循环比较]]> https://github.com/tofrankie/blog/issues/187 https://github.com/tofrankie/blog/issues/187 Sun, 26 Feb 2023 08:24:03 GMT
    3. for 循环的效率问题比较,建议看一下这篇文章:深入了解 JavaScript 中的 for 循环

    4. 判断是否为空对象

  • for 循环的效率问题比较,建议看一下这篇文章:深入了解 JavaScript 中的 for 循环

  • 判断是否为空对象

  • // 兼容写法,JavaScript in 操作符可获取对象的属性;delete 操作符则删除属性
    function isEmptyObj(obj) {
      for (var i in obj) {
        if (obj.hasOwnProperty(i)) {
          return false
        }
      }
      return true
    }
    
    // ES6 写法
    function isEmptyObj(obj) {
      return Object.keys(obj).length == 0
    }
    
    1. 遍历效率:for > for-of > forEach > filter > map > for-in。虽然 for-in 遍历 Array 元素性能没有普通的 for 循环好,但对于 稀疏数组 的使用是极好的,尤其在数组长度很大的情况,如下:
    const arr = []
    arr[99] = '哈哈'
    arr[999] = '呵呵'
    arr[9999] = '滚'
    
    // 优化版 for 循环,只计算一次数组长度(共遍历了 10000 次)
    for (let i = 0, len = arr.length; i < len; i++) {
      if (i in { '99': '', '999': '', '9999': '' }) {
        console.log(true + ' ' + i)
      } else {
        console.log(false)
      }
    }
    
    // for-in(共遍历了 3 次)
    for (const key in arr) {
      if (key in { '99': '', '999': '', '9999': '' }) {
        console.log(arr[key])
      }
    }
    
    // 两者遍历次数相差很大,这种情况下采用 for-in 是效率高很多。
    
    1. 循环方式的比较
    遍历方式 描述 缺点
    for 效率最高 改进版 for 循环:使用一个变量存储数组的长度,可以省去每次循环都去计算数组长度
    for-of ES6 新增的方法,最简洁、最直接的遍历数组元素的方法,为了改进 forEachfor-in 缺陷而生的新方法。支持数组、对象、字符串、Map 对象、Set 对象遍历 不支持遍历普通对象,可以使用 for-in 代替
    forEach 每执行一遍,都会执行 callback(item, index, array)。其中,item 是当前项,index 是当前索引项,array 是数组对象本身 不能 breakcontinue 或者 return
    filter 用来筛选符合某种条件的元素,将符合条件的元素重新组成一个新的数组
    map 面向数组,不改变原数组。对于 未被初始化被删除项(delete 操作符)数组的属性(array.name='小明') 都不会执行 callback 函数
    for-in for-in 是为普通对象设计的,for (var index in arr) { } 赋值给 index 的值不是索引值 12,而是字符串 '1''2' for-in 缺点更加明显,它不仅遍历数组中的元素,还会遍历自定义的属性,甚至原型链上的属性都被访问到。而且,遍历数组元素的顺序可能是随机的。

    未完待续...

    ]]>
    <![CDATA[Canvas 绘图不清晰的原因及解决方法]]> https://github.com/tofrankie/blog/issues/186 https://github.com/tofrankie/blog/issues/186 Sun, 26 Feb 2023 08:18:16 GMT 配图源自 Freepik

    为什么图片展示不清晰?

    我们知道,图片是有尺寸的,在生成时]]> 配图源自 Freepik

    为什么图片展示不清晰?

    我们知道,图片是有尺寸的,在生成时就确定了。在 CSS 中我们叫做图片的原始尺寸。

    试想一下,把一张 100 * 100 的图片(位图)放大 10 倍,图片会模糊,对吧。

    什么时候图片最清晰呢?答案是:图片渲染尺寸 = 图片原始尺寸

    换句话说,如果希望图片放大 10 倍仍然足够清晰,应该使用尺寸为 1000 * 1000 图片作展示。

    本文讨论的前提,图片在原始尺寸下是清晰的。

    解决 img 不清晰问题

    <img> 通过这样的方式获取图片的渲染尺寸和原始尺寸。

    • width/height 渲染宽高
    • naturalWidth/naturalHeight 原始宽高。

    当 width > naturalWidth 的时候,就会有不同程度的模糊。

    下面提供一些供参考解决方案(注意考虑兼容性)。

    图片渲染尺寸是固定的

    图片渲染尺寸固定,可以通过 srcset 属性进行设置,浏览器会根据设备 DPR 自动选择最高清晰可用的版本。

    <img 
      src="pic@1x.png"
      srcset="
        pic@1x.png 1x,
        pic@2x.png 2x,
        pic@3x.png 3x
      "
    />
    

    图片渲染尺寸是动态的

    图片渲染尺寸不固定,可以结合 srcset + sizes 进行设置:

    <img 
      src="pic-800.jpg"
      srcset="
        pic-400.jpg 400w,
        pic-800.jpg 800w,
        pic-1200.jpg 1200w,
        pic-1600.jpg 1600w
      "
      sizes="
        (max-width: 600px) 100vw,
        (max-width: 1024px) 50vw,
        33vw
      "
    />
    

    background-image 图片

    如果是 background-image 图片,可以使用 image-set() 进行设置。

    .container {
      background-image: image-set(
        "pic@1x.jpg" 1x,
        "pic@2x.jpg" 2x,
        "pic@3x.jpg" 3x
      );
    }
    

    解决 Canvas 图片不清晰的问题

    了解像素

    以 iPhone 7 为例:

    • 屏幕 CSS 像素:375 * 667
    • 屏幕物理像素:750 * 1334(也就是手机分辨率)
    • DPR:2(物理像素与 CSS 像素之比)

    也就是说,一个 CSS 像素,使用 2 个物理像素进行绘制,因此呈现效果要比同期其他手机好。

    如果用一张 375 * 667 尺寸的图片放到 iPhone 7 上显示,其实是“不够清晰”的,此时应该使用 2x 图(也就是 750 * 1334)的图片进行展示,这样才会更清晰。

    前面提到,当「图片原始尺寸 = 图片渲染尺寸」时图片显示最清晰,这里的「图片渲染尺寸」其实是指「CSS 尺寸 * DPR」,因此可以转换下公式:

    图片原始图片 = CSS 尺寸 * DPR

    套入 iPhone 7 的例子:图片原始尺寸宽度 = 375 * 2,所以确保图片清晰的最佳尺寸应该是 750 宽,高度同理。

    了解可替换元素

    关于可替换元素更多细节可以自行查阅。

    <img /> 是一个可替换元素。

    从一个简单的例子开始:

    <img src="example.jpg" />
    

    ▲ 我们没给 img 元素添加任何样式,此时它在文档流中占据的空间取决于图片原始尺寸。

    <img src="https://placehold.co/750x1334/png" />
    

    ▲ 这是一张 750 * 1334 尺寸的图片,此时在文档流中占据的空间是 750 * 1334(CSS 尺寸)。尽管文档流中占据的空间(CSS 像素)和图片实际尺寸是一致的,但由于 DPR 为 2,此时图片显示其实是不清晰的(想象成一张图片放大了 2 倍查看)。

    <img src="https://placehold.co/750x1334/png" style="width: 375px; height: 667px" />
    

    ▲ 接着,给 img 添加样式限制其宽高,这里改变的其实是「渲染尺寸」,此时图片占据文档流的空间是 375 * 667(CSS 尺寸)。注意,这样修改样式并未修改图片的原始尺寸,也改不了,因为图片原始尺寸在生成时就固定了。此时图片显示是清晰的。

    我们可以将 img 这个可替换元素类比成这样的结构:

    <div style="375px; height: 667px">
      <div style="width: 750px; height: 1334px"></div>
    </div>
    

    我们调整 img 的宽高其实修改的是外层的 div,影响不到内层的 div 宽高。当然可以使用 object-fit 等 CSS 属性影响可替换元素的外层与内存的关系。

    了解 Canvas

    Canvas 也是一个可替换元素。

    但它跟 img 有点不一样,我们知道图片原始尺寸在生成时就确定了,无法修改,而 Canvas 的原始宽高是可以控制修改的。

    <canvas></canvas>
    

    首先,Canvas 是有个默认宽高的,300 * 150(Canvas 原始尺寸),我们不作任何样式修改的情况下,它占据文档流 300 * 150 空间(取决于 Canvas 的原始尺寸)。

    举个例子,我们使用 Canvas 进行绘图时,通常会进行类似的初始化处理:

    <canvas id="canvas"></canvas>
    
    #canvas {
      width: 375px;
      height: 667px;
    }
    
    const canvas = document.getElementById('canvas')
    const ctx = canvas.getContext('2d')
    
    const elementWidth = canvas.clientWidth
    const elementHeight = canvas.clientHeight
    
    const dpr = window.devicePixelRatio ?? 1
    ctx.canvas.width = elementWidth * dpr // 可以使用 canvas.width = elementWidth * dpr
    ctx.canvas.height = elementHeight * dpr // 可以使用 canvas.height = elementHeight * dpr
    
    ctx.scale(dpr, dpr)
    
    // 各种绘制...
    

    这里做了几件事:

    1. 通过 CSS 修改了 Canvas 元素在文档流占据的空间,设为 375 * 667
    2. 通过 ctx.canvas.width = elementWidth * dprctx.canvas.height = canvas.clientHeight 修改了 Canvas 的原始尺寸,确保文档流占据的空间与 Canvas 原始尺寸保持正比,以避免显示变形。(如果没有理解,想象一下在 100 * 100 的 img 元素中放了一张 100 * 200 的图片的场景,此时图片显示会变形)
    3. 使用 CanvasRenderingContext2D.scale() 对 Canvas 的坐标系进行放大,放大了 N 倍(取决于设备 DPR),以确保后续绘制按相同的比例进行下去

    为什么要这样做?

    回顾这条公式:图片原始图片 = CSS 尺寸 * DPR

    我们在内心把 Canvas 绘制出来的图形当作一张图片。

    前面,如果要在不同 DPR 设备下使得 img 的图片清晰显示,我们的解决方法是使用不同尺寸的图片。

    我们把 Canvas 本身当作一张图片。针对不同 DPR 的设备,不需要换图解决,使用 Canvas 提供的 API 就能轻松修改其(原始)尺寸。也就是 ctx.canvas.width = elementWidth * dprctx.canvas.height = canvas.clientHeight 所做的事情。

    以 iPhone 7 为例,图片原始宽度(ctx.canvas.width)= 375 * 2 = 750,高度同理。

    类比成 div:

    <div style="375px; height: 667px">
      <!-- 750 = 375 * DPR -->
      <!-- 1134 = 667 * DPR -->
      <div style="width: 750px; height: 1334px"></div>
    </div>
    

    接着,如果我们初始化过程如下:

    // 不用
    // ctx.canvas.width = elementWidth * dpr
    // ctx.canvas.height = elementHeight * dpr
    
    // 改用
    canvas.style.width = elementWidth * dpr + 'px'
    canvas.style.height = elementHeight * dpr + 'px'
    

    它相当于修改了:

    #canvas {
      width: 750px;
      height: 1334px;
    }
    

    使用 div 两层结构类比,它修改的是外层 div 的尺寸,此时效果相当于:把一张 300 * 150 尺寸的图片放到了一个 750 * 1334 尺寸的 img 容器中,图片显示会变形,且在高清屏下显示不清晰。

    Canvas 绘制不清晰的原因

    理解了上一节的内容,就很好理解并知道为什么要这样处理。

    如果在高清屏下 Canvas 绘制模糊,原因大概率是没有进行以下两部处理(多数可能是没有乘以 DPR):

    // 1. 仅确保了 Canvas 的实际尺寸跟文档流占据的空间一致,以避免变形
    ctx.canvas.width = elementWidth * dpr
    ctx.canvas.height = elementHeight * dpr
    // 2. 确保后续绘制以相同的比例进行
    ctx.scale(dpr, dpr)
    

    如果在 Canvas 上绘制一张图片,还要检查图片本身是否清晰。

    总结

    记住这两个结论:

    • 当图片原始尺寸 = 图片渲染尺寸时,图片在屏幕上显示最清晰
    • 图片原始尺寸 = CSS 尺寸 * DPR

    旧内容废弃

    Deprecated

    说明:本文省略部分非关键代码,麻烦自己补全。

    一般情况,在高清屏的设备下,任何绘制 canvas 中的图像、文字、线条、形状都可能会出现模糊的问题。可通过引入 GitHub 中的 hidpi-canvas 有效地解决。

    1. 首先去 GitHub 下载 hidpi-canvas.js 文件;
    2. 在项目中引入 hidpi-canvas.js 文件;
    3. 调用 getPixelRatio() 函数,得到 ratio 值;
    4. drawImage() 中,将 widthheight 乘以 ratio
    5. 效果如下,但抱歉,没做对比图!

    在部分 iOS 设备中,可能会存在 image.onload 失效的问题,会导致无法将图片画到 canvas 中。引起该现象的原因可能是:

    1、iOS 中存在 image.onload 失效的问题(注意:image.onload 执行的前提是图片正常加载完成,如果稍微出错,就会执行 image.onerror 而不是 image.onload); 2、如果 image.src 是 base64 格式文件,不要设置 image.crossOrigin = "anonymous",可能会出现 image.onload 无法执行的问题,从而无法正常画图。

    关于 onload 失效的问题,看 Stack Overflow 这个解答,可能收获挺多的:IPhone img onload fails

    <!-- html -->
    <div onclick="makeCanvasToPhoto()" style="width: 100px; padding: 10px 30px; background: #eee; text-align: center;">生成图片</div>
    <canvas id="canvasBox" style="margin: 50px auto;"></canvas>
    <!-- 引入js -->
    <script type="text/javascript" src="canvas.js"></script>
    <script type="text/javascript" src="hidpi-canvas.min.js"></script>
    
    // canvas.js 文件
    function makeCanvasToPhoto() {
      const canvas = document.getElementById('canvasBox')
      const context = canvas.getContext('2d')
      const ratio = getPixelRatio(context) // 关键代码
      canvas.width = 300 * ratio // 画布宽度
      canvas.height = 300 * ratio // 画布高度
      const divWidth = 300 * ratio // 用于内容居中
      const divHeight = 300 * ratio // 用于内容居中
    
      // 画矩形
      context.beginPath()
      context.fillStyle = '#abcdef'
      context.fillRect(0, 0, divWidth, divHeight)
      context.closePath()
    
      // 图片
      context.beginPath()
      const imgObj = new Image()
      imgObj.crossOrigin = 'anonymous' // 在iOS 9设备中,如果src是base64格式,设置了crossOrigin 属性,会导致无法执行image.onload 而执行image.onerror 函数
      imgObj.src = 'http://img0.imgtn.bdimg.com/it/u=458129248,1588126214&fm=26&gp=0.jpg'
      imgObj.onload = function () {
        const imgWidth = '150'
        const imgHeight = '150'
        context.drawImage(this, 50, 50, imgWidth * ratio, imgHeight * ratio)
      }
      context.closePath()
    
      // 文本
      context.beginPath()
      context.font = '32px bold'
      context.fillStyle = '#1a1a1a'
      context.textAlign = 'center'
      context.textBaseline = 'middle'
      context.fillText('文本', 50, 240)
      context.closePath()
    
      context.drawImage(canvas, 0, 0, divWidth, divHeight)
      const base64Obj = canvas.toDataURL('image/png', 1)
      console.log(base64Obj)
    }
    
    function getPixelRatio(context) {
      const backingStore =
        context.backingStorePixelRatio ||
        context.webkitBackingStorePixelRatio ||
        context.mozBackingStorePixelRatio ||
        context.msBackingStorePixelRatio ||
        context.oBackingStorePixelRatio ||
        context.backingStorePixelRatio ||
        1
      return (window.devicePixelRatio || 1) / backingStore
    }
    

    效果如图:

    ]]>
    <![CDATA[JavaScript 数组]]> https://github.com/tofrankie/blog/issues/185 https://github.com/tofrankie/blog/issues/185 Sun, 26 Feb 2023 08:12:39 GMT
  • 筛选满足条件的项
  • const arr = [1, 6, 9, 3, 6, 56, 7, 36]
    arr.filter(item => item > 6 && item < 32) //]]>
                
    
  • 筛选满足条件的项
  • const arr = [1, 6, 9, 3, 6, 56, 7, 36]
    arr.filter(item => item > 6 && item < 32) // 输出 [9, 7]
    
    1. 去掉空字符、空格字符串、null、undefined
    const arr = ['A', '', 'B', null, undefined, 'C', ' ']
    const newArr = arr.filter(
      item => item && item.trim() // 注:IE9(不包含IE9)以下的版本没有trim()方法
    )
    console.log(newArr) // 输出 ['A', 'B', 'C']
    
    1. 排序(按对象属性排序)
    const fruit = [
      { id: 3, type: '苹果' },
      { id: 7, type: '草莓' },
      { id: 2, type: '梨子' },
      { id: 6, type: '凤梨' },
    ]
    function sortById(item1, item2) {
      // 升序,如降序,反过来即可
      return item1.id - item2.id
    }
    console.log(fruit.sort(sortById))
    
    // 输出 [{id: 2, type: '梨子'}, {id: 3, type: '苹果'},{id: 6, type: '凤梨'},{id: 7, type: '草莓'}]
    // array.sort() 方法是在原数组的基础上重新排序,不会生成副本。
    
    1. 数组去重
    const newArr = arr.reduce((pre, cur) => {
      if (!pre.includes(cur)) {
        return pre.concat(cur)
      } else {
        return pre
      }
    }, [])
    console.log(newArr) // [1, 6, 9, 3, 56, 36]
    
    ]]>
    <![CDATA[Muse UI 遇到的坑]]> https://github.com/tofrankie/blog/issues/184 https://github.com/tofrankie/blog/issues/184 Sun, 26 Feb 2023 08:10:36 GMT 故事背景是这样的,最近做一个Vue项目,使用到 Muse UI 组件库。刚开始时想着能用 Material Design 设计规范是一件非常开心的事情,然后事情并不会一直美好下去。。。

    项目本身需要用到 ]]> 故事背景是这样的,最近做一个Vue项目,使用到 Muse UI 组件库。刚开始时想着能用 Material Design 设计规范是一件非常开心的事情,然后事情并不会一直美好下去。。。

    项目本身需要用到 Material Icon 字体包,但由于一些众所周知的原因,国内网络无法访问一些外网。于是采用了本地化部署字体的方法。下载 Material Icons 字体包,然后放到 static 静态文件夹中,再从 index.html 引入。

    问题来了:

    1. 在首页(路由是:/),可以正常读取到字体包,所以页面渲染成功,没问题的。
    2. 当进入其他路由(例如历史行程路由:/trip/history),刚进入页面同样是渲染成功。当此时对页面重新加载时,就会出现错误,字体图标找不到,只显示了的字体图标名称。

    ▼ 首页

    ▼ 历史行程

    对于这个 Bug,大概困扰了我一个多月。一度很无奈解决不了,甚至想过放弃使用 Muse UI。直到今天才发现问题所在,然后就解决了。

    按照官方文档的方法引入(最后就在这里解决的,就是一个退格键的事):

    // index.html
    <link rel="stylesheet" href="./static/fonts/material-icons/material-icons.css"/>
    

    这个 Bug 是在控制台偶然 Warning:Resource interpreted as Stylesheet but transferred with MIME type text/html: "URL(这个URL是关于 Material Icon 的路径)",才意识到 URL 错了。

    ▼ 首页

    ▼ 历史行程

    1. 仔细对比路径之后发现了问题,首页的 Requset URL 是正确的,而历史行程页面是错误的。然后就锁定到 index.html 中引入 Material Icon 的 <link/> 标签。
    2. 跟官方文档对比后,好像没发现有错。由于 index.htmlstatic 文件夹是同级目录下的,所以 href="./static/fonts/material-icons/material-icons.css" 按道理应该是没错的,官方文档也这么写,但实际上确实出错了。

    于是乎......我把路径改成项目根目录,然后就行了,如下。

    // index.html
    <link rel="stylesheet" href="/static/fonts/material-icons/material-icons.css">
    

    在本地确定没问题后,再打包项目放到云服务器上,看看能否读取到静态资源,发现也正常。

    回想整个过程,最主要是因为没发现在不同页面下的 Request URL 不一致,且其中一个是不正确的。发现了这个问题,事情就好办了。

    这个 Bug 困扰太久了,作个记录。

    ]]>
    <![CDATA[vue-baidu-map 进入页面自动定位的解决方案!]]> https://github.com/tofrankie/blog/issues/183 https://github.com/tofrankie/blog/issues/183 Sun, 26 Feb 2023 08:07:35 GMT 之前也被这问题困扰过,在网上查了一番,没找到解决方法。

    直到今天,在 Dafrok/vue-baidu-map 提了个问题,才点醒了我。此前没有认真阅读 vue-baidu-map 的 之前也被这问题困扰过,在网上查了一番,没找到解决方法。

    直到今天,在 Dafrok/vue-baidu-map 提了个问题,才点醒了我。此前没有认真阅读 vue-baidu-map 的文档。(也可能是看完忘了)

    文档原话:由于百度地图 JS API 只有 JSONP 一种加载方式,因此 BaiduMap 组件及其所有子组件的渲染只能是异步的。因此,请使用在组件的 ready 事件来执行地图 API 加载完毕后才能执行的代码,不要试图在 vue 自身的生命周期中调用 BMap 类,更不要在这些时机修改 model 层。

    我试过,以上这种方法好像是可行,效果可以出来,但我们最好采用作者提供的正确方法!

    推荐这种方法!那下面解决进入页面自动定位的方法也是在这里。(仅供参考)

    我只是一个小,不足之处欢迎指出!

    ▼ template:

    ▼ script

    ]]>
    <![CDATA[Uncaught SyntaxError: Unexpected token <]]> https://github.com/tofrankie/blog/issues/182 https://github.com/tofrankie/blog/issues/182 Sun, 26 Feb 2023 08:05:52 GMT 今天做一个 VUE 的项目,引入 JS 文件时遇到了一个问题:

    控制台的提示: 今天做一个 VUE 的项目,引入 JS 文件时遇到了一个问题:

    控制台的提示:Uncaught SyntaxError: Unexpected token <。查看源文件如下图所示:

    提示:<!DOCTYPE html> 出错,是跟我开玩笑是吧!

    但根据以往的印象,应该是引入 JS 的问题。确认 JS 文件没出错后,再仔细看了看 index.html 文件。

    原本我的 JS 文件是放在 /src/utils 文件夹下的,但引入 /src/static 的文件是有区别的。其中区别推荐看这篇文章:vue 中静态文件引用注意事项

    现在我的解决办法是将 JS 文件放到 /static/utils 目录下,引入路径也改成:<script src="./static/utils/sockjs.js"></script>,这样就不报错了!

    ]]>
    <![CDATA[Vue 项目中常见问题的解决办法(不定期更新)]]> https://github.com/tofrankie/blog/issues/181 https://github.com/tofrankie/blog/issues/181 Sun, 26 Feb 2023 08:01:05 GMT

    大佬请绕路

    页面间传参问题

    页面 A 跳转到页面 B 需要传参,我一般采用的是编程式导航 router.push(),而不是声明式导航 <router-link :to=" ]]>

    大佬请绕路

    页面间传参问题

    页面 A 跳转到页面 B 需要传参,我一般采用的是编程式导航 router.push(),而不是声明式导航 <router-link :to=" ">,二者效果作用都一样。以下为路由设置:

    // 部分不关键的代码省略
    // ...
    const routes = [
      {
        path: '/trip/history',
        name: 'HistoryTrip',
        component: HistoryTrip,
      },
      {
        path: '/trip/history/detail',
        name: 'TripDetail',
        component: TripDetail,
      },
    ]
    
    const router = new VueRouter({
      routes,
      // 使用 HTML 5 history 模式
      mode: 'history',
    })
    
    export default router
    

    编程式传参有两种方式:

    方式一:router.push({ name: 'TripDetail', params: { TripOrderId: 123 }})

    // A 页面,此时 URL 为:http://localhost:8080/trip/history
    this.$router.push({ name: 'TripDetail', params: { TripOrderId: 123 }})
    // B 页面获取参数的方法,此时的 URL 为:http://localhost:8080/trip/history/detail
    console.log(this.$route.params.TripOrderId)
    

    大家注意一下 B 页面的 URL,上面并没有 TripOrderId 这个参数。采用这种方法进行传参有两个缺点,这就是其中一个。另一个就是:跳转到页面 B 之后,如果对页面进行刷新,这个 params 参数是会丢失的。打印一下就知道,打印结果应该是 undefined。 方式二(推荐):router.push({ path: '/trip/history/detail', query: { TripOrderId: 123 }})

    // A 页面,此时URL为:http://localhost:8080/trip/history
    this.$router.push({ path: '/trip/history/detail', query: { TripOrderId: 123 }})
    // B 页面获取参数的方法,此时的URL为:http://localhost:8080/trip/history/detail?TripOrderId=123
    console.log(this.$route.query.TripOrderId)
    

    方法二中,最明显的就是 URL 中可以看出参数是什么,这种方法更为直观。最重要的是对页面进行刷新,query 参数并不会丢失,依然可以在地址栏看到 TripOrderId 参数。

    ]]>
    <![CDATA[前端那些经常被忽视而真实存在的小问题]]> https://github.com/tofrankie/blog/issues/180 https://github.com/tofrankie/blog/issues/180 Sun, 26 Feb 2023 08:00:10 GMT img 在 div 总有那几个像素的高度不能完全填充的问题(前提是不设置 div 宽高)。

    解决办法是:给 img 加个 display: block

    <style>
      .div1 {]]>
                img 在 div 总有那几个像素的高度不能完全填充的问题(前提是不设置 div 宽高)。

    解决办法是:给 img 加个 display: block

    <style>
      .div1 {
        background: red;
      }
      
      .div1 img {
        width: 100px;
        height: 100px;
        /* display: block 加上即可解决 */
      }
    </style>
    
    <div class="div1">
      <img src="./images/pic.jpg"/>
    </div>
    

    ▼ 实际效果图,按道理 div1 宽高应该是 100x100

    ]]>
    <![CDATA[JS 代码片段]]> https://github.com/tofrankie/blog/issues/179 https://github.com/tofrankie/blog/issues/179 Sun, 26 Feb 2023 07:56:07 GMT 配图源自 Freepik

    首字母大写转换

    首字母大写转换

    function captialize([first, ...rest]) {
      return first.toUpperCase() + rest.join('')
    }
    

    生成数字范围数组

    function range(length) {
      return Array.from({ length }, (_, index) => index)
    }
    

    过滤数组虚值

    虚值(falsy)有 undefinednullfalse+0-0NaN0n'',除此之外称为真值(truthy)。

    function filterFalsy(arr) {
      return arr.filter(Boolean)
    }
    

    生成随机字符串

    function getRandomKey() {
      return Math.random().toString(36).slice(2)
    }
    
    // create unique id from letters and numbers
    const uniqueAlphaNumericId = (() => {
      const heyStack = '0123456789abcdefghijklmnopqrstuvwxyz'
      const randomInt = () => Math.floor(Math.random() * Math.floor(heyStack.length))
      return (length = 24) => Array.from({ length }, () => heyStack[randomInt()]).join('')
    })()
    

    生成随机颜色值(十六进制)

    function getRandomColor() {
      // 带不透明度的话 slice(2, 10)
      return Math.random().toString(16).slice(2, 8)
    }
    

    颜色转换 RGB → Hex

    function rgb2hex(rgb) {
      return `#${((rgb.r << 16) | (rgb.g << 8) | rgb.b).toString(16).padStart(6, '0')}`
    }
    

    反转字符串

    function reverseString(str) {
      return [...str].reduce((a, s) => s + a)
    }
    

    五星打分

    有大佬利用这个思路做了个五星评级的组件,详看:★构建东半球最小的评级组件☆

    function getRating(rate) {
      if (rate > 5 || rate < 0) throw new Error('不在范围内')
      return '★★★★★☆☆☆☆☆'.substring(5 - rate, 10 - rate)
      // return '★'.repeat(rate) + '☆'.repeat(5 - rate)
    }
    

    判断是否为苹果设备

    function isAppleDevice() {
      return /(iPhone|iPad|iPod|iOS|mac\sos)/i.test(navigator.userAgent)
    }
    

    数字千分位表示法

    思路看这里

    function thousandsReplace(str) {
      return String(str).replace(/\B(?=(\d{3})+$)/g, ',')
    }
    
    thousandsReplace(10000) // "10,000"
    

    移除末尾 0

    常用场景是价格展示。

    function removeTrailingZero(str) {
      const reg = /(?:\.0*|(\.\d+?)0+)$/
      return str.replace(reg, '$1')
    }
    
    ]]> <![CDATA[深究 Safari 下 border-radius & overflow 不生效的问题]]> https://github.com/tofrankie/blog/issues/178 https://github.com/tofrankie/blog/issues/178 Sun, 26 Feb 2023 07:55:14 GMT 配图源自 Freepik

    背景

    前两天在做需求的时候,发现了 Safari 浏览器(包]]> 配图源自 Freepik

    背景

    前两天在做需求的时候,发现了 Safari 浏览器(包括 iOS 平台各浏览器)下有一个渲染的 Bug,其他则没问题。

    复现示例如下:

    <div class="box">
      <img src="https://gw.alipayobjects.com/mdn/rms_08e378/afts/img/A*lEtuTZi2GvIAAAAAAAAAAABkARQnAQ" alt="picture" />
    </div>
    
    .box {
      width: 270px;
      height: 169px;
      margin: 0 auto;
      overflow: hidden;
      border-radius: 30px;
      border: 4px solid #000;
    }
    
    .box img {
      width: 100%;
      height: 100%;
      transform: translateZ(10px);
    }
    

    CodeSandbox Demo

    其实就是简单地在 .box 中添加了 overflow: hidden; border-radius: 30px; 做一个圆角处理。上图为预期表现。 但是在 Apple 的 WebKit 平台(不包含 Chrome 的 Blink 平台),就出现问题了 👇

    是 overflow: hidden 处理无效?还是 border-radius 的问题?

    原因

    解决方法很多,我们先深究下原因。

    前面,我们给 <img> 添加了 transform: translateZ(10px),于是该元素产生了 Composite Layer(合成层)。

    .box img {
      width: 100%;
      height: 100%;
      transform: translateZ(10px);
    }
    

    而 Webkit 内核中,border-radius 对含有 Composite Layer 的元素的裁剪是存在 Bug 的,该问题可以追溯到 2011 年,很早就有人提出问题了。

    Bug 68196: border-radius clipping of composited layers doesn't work

    发现该 Bug 在 2022 年 9 月 7 日已被标记为「RESOLVED FIXED」,在 2022 年 10 月 19 日发布的 Safari Technology Preview 156 中已修复。好家伙,这问题整整十多年才解决。

    隔壁 Blink 内核(基于 Webkit 的一个分支)则在 2017 年 1 月 24 日修复。

    Issue 157218: border-radius clipping without a stacking context does not apply to composited children

    解决方法

    我们只要在 border-radius 的元素上添加一个可创建 Stacking Context(层叠上下文)的 CSS 属性即可。比如 transform: scale(1)transform: translateZ(1px)isolation: isolateposition: relative; z-index: 0 等等。

    • 从语义角度考虑,个人更偏向使用 isolation,它表示该元素是否必须创建一个新的层叠上下文。
    • 从兼容性角度考虑,相比 isolation,transform 或 position + z-index 会更好一些。
    .box {
      width: 270px;
      height: 169px;
      margin: 0 auto;
      overflow: hidden;
      border-radius: 30px;
      border: 4px solid #000;
      isolation: isolate; /* 新增 */
    }
    
    .box img {
      width: 100%;
      height: 100%;
      transform: translateZ(10px);
    }
    

    参考链接

    ]]>
    <![CDATA[关于 CSS Reset 的思考]]> https://github.com/tofrankie/blog/issues/177 https://github.com/tofrankie/blog/issues/177 Sun, 26 Feb 2023 07:54:53 GMT 配图源自 Freepik

    记得当时第一次接触 CSS,老师一上来就说用这个抹平浏览器的样式差异。 配图源自 Freepik

    记得当时第一次接触 CSS,老师一上来就说用这个抹平浏览器的样式差异。

    * {
      margin: 0;
      padding: 0;
    }
    

    但,这合理吗?

    显然,这是不合理的。margin 差异的只有 body 元素,但上述做法却对所有元素生效,会使得浏览器为元素计算样式的时候更耗时。

    相关文章:

    可选项:

    ]]>
    <![CDATA[overflow 使得 transform-style 失效了]]> https://github.com/tofrankie/blog/issues/176 https://github.com/tofrankie/blog/issues/176 Sun, 26 Feb 2023 07:54:13 GMT 配图源自 Freepik

    这周做了一个需求,出现了 Bug,经排查后发现:

    配图源自 Freepik

    这周做了一个需求,出现了 Bug,经排查后发现:

    同一元素同时设置 overflow: hiddentransform-style: preserve-3d 样式,会使得后者失去 3D 效果,也就是相当于 transform-style: flat

    下面用示例验证一下:

    <div class="container">
      <div class="rect red"></div>
      <div class="rect green"></div>
    </div>
    
    • .constainer 区域设置了 transform-style: preserve-3d
    • .red 区域设置了 transform: translate3d(20px, 20px, 10px)
    • .green 区域设置了 transform: translate3d(0, 0, 5px)

    👇

    .container {
      margin: 0 auto;
      border-radius: 10px;
      padding: 10px;
      width: 200px;
      height: 200px;
      background: #f8f8f8;
      transform-style: preserve-3d;
    }
    
    .rect {
      box-sizing: border-box;
      border: 2px solid #000;
      border-radius: 4px;
      width: 100px;
      height: 100px;
    }
    
    .red {
      background: #f00;
      /* translateZ 为 10px */
      transform: translate3d(20px, 20px, 10px);
    }
    
    .green {
      background: #0f0;
      /* translateZ 为 5px */
      transform: translate3d(0, 0, 5px);
    }
    

    由于红色区域与绿色区域置于 3D 空间中,其中红色在 Z 轴更上面,因此实际表现如下,与预期一致。 👇

    一旦,将 overflow: hidden 应用于 .container

    .container {
      margin: 0 auto;
      border-radius: 10px;
      padding: 10px;
      width: 200px;
      height: 200px;
      background: #f8f8f8;
      transform-style: preserve-3d;
      /* 新增 */
      overflow: hidden
    }
    

    那么结果就... 👇

    在不同设备实际表现还不一样,应该是渲染内核实现不一致导致的。FireFox 浏览器同左边表现一样。 其中左边为「非预期效果」,右边为「预期效果」。

    示例:CodePen

    我们分析一下左边的原因:

    我们知道,使用 transform 会使得元素创建一个「层叠上下文」,也就是说 .red.green 是两个不同的层叠上下文。由于这里并没有使用 z-index 来控制元素的「层叠顺序」,加上 overflow: hidden 使得 transform-style: preserve-3d 失去了 3D 空间效果,因此无法通过 transform: translateZ() 的大小来控制层叠顺序。因此在文档流中遵循「后者居上」的原则,使得 .green 处于 .red 的上方(即左边的表现)。

    因此,可得出结论:

    在「非 Webkit 内核」中,同一元素同时设置 overflow: hiddentransform-style: preserve-3d 样式,会使得后者失去 3D 效果,也就是相当于 transform-style: flat

    注意,以上结论表述为「非 Webkit 内核」可能不严谨。原因是:以上 Chrome 为 Mac 平台的截图,查看了其 UA 是 Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36 标注的是 Webkit 内核,难道实际是 Google 的 Blink 内核(虽说 Blink 师出 Webkit)?如果是的话,表述就没问题了。

    有时候,犯错了印象才会更加深刻...

    ]]>
    <![CDATA[关于浏览器如何渲染含小数像素的问题]]> https://github.com/tofrankie/blog/issues/175 https://github.com/tofrankie/blog/issues/175 Sun, 26 Feb 2023 07:53:34 GMT 配图源自 Freepik

    最近跟进的一个项目,其中涉及到含小数像素的渲染问题,在查阅了一番资料之后,有]]> 配图源自 Freepik

    最近跟进的一个项目,其中涉及到含小数像素的渲染问题,在查阅了一番资料之后,有了全新的认识。

    以下为相关链接,等周末有空再整理一下:

    ]]>
    <![CDATA[你真的了解「像素」吗?]]> https://github.com/tofrankie/blog/issues/174 https://github.com/tofrankie/blog/issues/174 Sun, 26 Feb 2023 07:52:08 GMT 配图源自 Freepik

    一切都从「像素」开始...

    配图源自 Freepik

    一切都从「像素」开始...

    维基百科词源可知,像素 pixel 一词由 pictureselement 两个词的简写形式 pixel 结合而来。

    像素,在数字成像中是不可分割的最小单元,可分为两类:

    • 虚拟像素:其大小是任意的、可变的,没有实际的物理尺寸大小。
    • 物理像素:通常用于描述手机、电脑、显示器等设备的像素,它在设备出厂时已经确定,是不可变的,具有实际的物理尺寸大小(一般使用英寸表示)。

    物理像素(即屏幕像素,screen pixel)是显示设备成像的最小单位。我们「肉眼」看到的物理像素都是真实的物理发光元件,既然是物理元件,就不可能「无缝」填满整个屏幕,而且像素(即元件)大小、形状也是可以自由定义的,在屏幕上多是方形或者接近方形的。两个相邻像素之间的距离就是点距(dot pitch),点距越小成像越细腻,近距离使用体验越好。

    但并不是说像素紧密排列就意味着「视觉效果」更好,比如说户外硕大的 LED 广告牌,是可以明显地看到黑色缝隙的,原因是它们的元件排列是有间隙的。

    虚拟像素到物理成像,是有一定的映射关系的,具体由显示设备或打印设备来决定。

    一、分辨率

    我们发现,通常描述「分辨率」的单位也叫像素,但有什么区别呢?

    • 物理屏幕分辨率(Physical screen resolution):泛指电视机、电脑显示器、手机屏幕等显示设备的分辨率,它在设备出厂时便以确定,不可修改,而且是有着具体的物理尺寸大小。

      常说的 1920 × 1080 分辨率的手机,指的是显示器横向排列了 1080 个像素,纵向排列了 1920 个像素。

    • 显示分辨率Display resolution):也称作屏幕分辨率(screen resolution),它没有具体的物理大小,是可以修改的。

      常说的“调节电脑屏幕分辨率”,修改的就是显示分辨率。当显示器分辨率与屏幕分辨率一致时,表示一个虚拟像素将会用一个物理像素来显示,此时显示效果是最佳的。当二者不相同时,将有相关系统使用算法去模拟像素,而这些模拟处理都会带来画质的损失。

    • 还有很多分类,比如 Image resolution、Printing resolution、Sensor resolution 等等,详看 Wiki

    以上描述源自这里

    请注意,不能单凭显示器分辨率来计量屏幕的细腻程度。试想,同样是 1920 × 1080 分辨率,一个放在 5.5 英寸的手机屏幕上,一个放在 40 英寸的电视机屏幕上,细腻程度会一样吗?

    二、屏幕尺寸

    这玩意本来就是外国人发明的,因此常用单位「英寸」来描述。单位换算:1 英寸 = 2.54 厘米(cm)。

    请注意,英寸是「长度」单位,而不是「面积」单位。

    比如 4.7 英寸的手机,这里的 4.7 英寸指的是手机显示屏「对角线」的长度,而不是显示器的宽度或者高度,更不是面积。对,就是数学中直角三角形的那个斜边,勾股定理还记得吧。

    下文将会提到的像素密度(PPI)表示「每英寸」屏幕所拥有的像素数,也是指对角线的长度,而非面积大小。

    三、像素密度

    像素密度(Pixel density),通常用来计量电子设备屏幕细腻程度。

    像素密度的单位一般是 PPI(Pixel Per Inch,每英寸像素),表示单位长度上的像素数量(注意,不是单位面积的像素数量)。

    电子设备的 PPI 取决于「屏幕尺寸」和「显示器分辨率」,计算规则如下:

    • dp 为屏幕对角线的分辨率
    • di 为屏幕对角线的长度(单位为英寸)
    • wp 为屏幕横向分辨率
    • hp 为屏幕纵向分辨率

    以 4.7 英寸的 iPhone 7 为例,分辨率为 750 × 1334,像素密度约为 326PPI(在线换算)。需要注意的是,应以设备「实际尺寸」换算,而非厂商「宣传尺寸」,二者之间可能会有一定的偏差,自然计算结果就会产生误差。比如:

    「数码相机」与手机等屏幕不同的是,数码相机的屏幕通常用「点色数」来描述,而不是像素。每个像素包含了三个点色数(分别是红、绿、蓝三原色)。以佳能 50D 相机显示屏为例,它有 920,000 个点色数(应该是约数),即 92 万色,通过这个数据,可以推算出屏幕为 307,200 个像素,分辨率为 640 × 480。因此,相机显示屏的点色数和像素常被人混淆(源自维基百科)。

    还有,用于量度「打印机」打印精细程度的计量单位是 DPI

    DPI(Dots Per Inch,每英寸点数),表示单位长度上可打印的点数。

    虽说 PPI 和 DPI 两者所用的领域存在区别,「像素」只存在于屏幕显示领域,而「点」只出现于打印或印刷领域。但由于 PPI 也会影响到图片的打印质量,因此容易出现混用或混淆的情况。比如,在 SONY Support 的一篇文章中指出:

    Much of the confusion between these two terms happens for a couple of reasons. First, even though PPI refers to the resolution of an on-screen digital image, it can also affect the quality of the final printed picture. Second, even some professional print services request that pictures must be at a certain DPI level before they can be printed; what they normally mean is PPI, not DPI - thus, this adds to the confusion.

    由于本文的重点讨论的是显示屏幕领域像素之间的关系,而非打印领域,加之本人对此并不是很了解,因此不作过多介绍,点到为止,哈哈!

    四、设备独立像素(即逻辑像素)

    设备独立像素(device-independent pixel),也称为「逻辑像素」,虽然它属于虚拟像素范畴,但是它固定不可变的,在显示设备出厂时便已确定。

    YESVZ 网站可以查看常见设备的逻辑像素:

    既然有了物理像素,为什么还要逻辑像素的概念,它是为了解决什么实际问题而出现的呢?

    在很早以前,一个 CSS 像素由一个物理像素表示(即 DPR 为 1)。改变从 Apple 开始,随着 iPhone 4 发布,苹果向世界展示了一块显示效果俱佳 Retina 屏幕,其后高清屏如雨后春笋般出现,比如 720P、1080P、2K 屏等等,加之各设备的屏幕大小还不一样......面对如此之多不同尺寸不同分辨率的屏幕,对于软件开发者来说是非常头疼的,亟需一种「规范」或「方案」使得同一图像在不同设备上“看起来”一样,因此「逻辑像素」的概念就出现了。

    前面提到过,虚拟像素到物理成像,是具有映射关系的。在 Web 领域中,这种映射关系可以用 DPR 表示。当 DPR 为 1 时,一个 CSS 像素用一个物理像素表示;当 DPR 为 2 时,一个 CSS 像素用两个物理显示表示,其他同理。

    逻辑像素与 CSS 像素

    网上很多文章,指出「逻辑像素等于CSS 像素」,真的是这样吗?

    在「网页未缩放」情况下,逻辑像素与 CSS 像素是 1:1 的关系。如有缩放的话,其缩放比例就是逻辑像素与逻辑像素的比值。

    CSS 像素

    我们知道,Web 标准由 W3C(The World Wide Web Consortium) 制定,在 CSS 标准中,长度单位包括「相对长度」和「绝对长度」两类。其中 px 是绝对长度单位之一(详见),

    但真的「绝对」吗?

    网上很多文章提到 px 归为「相对单位」, 原因有二:

    1. 不加思索,人云亦云。
    2. 所选参照物不一样。由于 CSS 像素与物理像素不一定是 1 : 1 的关系,它跟屏幕的像素密度有关,比如在 iPhone 7 下 1 × 1 的 CSS 像素,将会使用 2 × 2 个物理像素去渲染。在 Web 领域通常使用「设备像素比」(Device Pixel Ratio,DPR)去描述 CSS 像素与最终显示成像的关系。

    我看到一句话「A pixel no longer equals a pixel」,在当下高清屏大行其道的时代,这句话就成立了,一个虚拟像素不再等于一个物理像素。

    设备像素比

    DPR(Device Pixel Ratio,设备像素比),表示当前显示设备的物理像素分辨率与 CSS 像素分辨率之比。

    在 CSS 规范中 window.devicePixelRatio 的计算逻辑如下(详见):

    1. If there is no output device, return 1 and abort these steps.
    2. Let CSS pixel size be the size of a CSS pixel at the current page zoom scale factor and at a pinch zoom scale factor of 1.0.
    3. Let device pixel size be the vertical size of a device pixel of the output device.
    4. Return the result of dividing CSS pixel size by device pixel size.

    关键词是「缩放比例为 1」、「输出设备处于垂直方向」(可以理解为手机竖屏状态)、

    设备像素比是可变的,比如页面的缩放(但注意手势缩放不会)

    未完待续...

    ]]> <![CDATA[CSS 隐藏滚动条总结]]> https://github.com/tofrankie/blog/issues/173 https://github.com/tofrankie/blog/issues/173 Sun, 26 Feb 2023 07:51:18 GMT 配图源自 Freepik

    一、什么时候会出现滚动条?

    我们知道,默认情况下,当一个元]]> 配图源自 Freepik

    一、什么时候会出现滚动条?

    我们知道,默认情况下,当一个元素的内容大小超过了所在容器的空间大小时,会产生溢出效果,而且溢出部分的内容是可见的。

    在 CSS 中,可以使用 overflow 属性对溢出内容做控制,它是 overflow-xoverflow-y 的简写属性。其中 overflow 产生效果的前提是:

    元素所在的块级容器指定了高度(设置 heightmax-height)或将 white-space 设为 nowrap

    取值

    属性值 描述
    visibile 默认值,内容不会被修剪,会呈现在容器之外。
    hidden 内容会被修剪,并且溢出内容不可见。
    scroll 内容会被修剪,浏览器会显示滚动条,滚动可查看溢出内容。
    auto 由浏览器定夺,如果内容被修剪,就会显示滚动条。
    overlay 行为与 auto 相同,但滚动条绘制在内容之上而不是占用空间。仅被基于 WebKit 内核的浏览器所支持。
    clip 行为与 hidden 相同,区别在于它无法通过 DOM API 使得元素滚动(新特性兼容性较差)。

    由于 overflow 是简写形式,按照规范:

    • 如果指定一个值时,overflow-xoverflow-y 都应用相同的值。
    • 如果指定两个值时,第一个值应用于 overflow-x,第二个应用于 overflow-y

    但是总有不遵循规范的家伙,在 Firefox 63 之前,指定两个值时,刚好与规范相反,前者作用于 overflow-y,后者作用于 overflow-x。在后续新版本中顺序已经调整过来,如规范一般。因此,当 overflow-xoverflow-y 需指定不同的值时,不建议使用简写形式,应分别设置为好。

    注意点

    1. 当一个轴设置了非 visibile 的值,即使另外一个轴指定为 visibile,它的行为也会变成 auto 的效果。
    2. overflow 指定为非 visibile 的值,该容器会形成块级格式化上下文(Block Formatting Context,BFC)。
    3. 尽管 overflow: hidden 可以隐藏溢出内容,但使用 Element.scrollTop 仍可以用来滚动元素。目前有一个兼容性不好的新特性 clip 倒是可以解决这种通过 DOM API 滚动的问题。

    关于更多 clip 内容,请看文章

    二、如何隐藏滚动条?

    在不同浏览器中,隐藏的方式还有点不同,只要在所在的块级容器中,添加以下样式即可。

    Chrome、Safari 等基于 WebKit(Blink)内核的浏览器

    它使用到 ::-webkit-scrollbar 伪元素,更多请看

    .container::-webkit-scrollbar {
      display: none;
      width: 0;
      height: 0;
      color: transparent;
    }
    

    FireFox 浏览器

    使用到标准的 scrollbar-width 属性,更多请看

    .container {
      scrollbar-width: none;
    }
    

    IE 浏览器

    这货这样处理:

    .container {
      -ms-overflow-style: none; /* IE 10+ */
    }
    

    因此,汇总起来如下兼容处理:

    .container {
      scrollbar-width: none;
      -ms-overflow-style: none;
    }
    
    .container::-webkit-scrollbar {
      display: none;
      width: 0;
      height: 0;
      color: transparent;
    }
    

    小程序

    关于微信小程序隐藏 <scroll-view> 滚动条(暂时未遇到这种需求),请看这两篇文章:

    The end.

    ]]>
    <![CDATA[Taro 单位转换优化]]> https://github.com/tofrankie/blog/issues/172 https://github.com/tofrankie/blog/issues/172 Sun, 26 Feb 2023 07:49:47 GMT 配图源自 Freepik

    近期将 Taro 从 v3.4.8 升级至 v3.]]> 配图源自 Freepik

    近期将 Taro 从 v3.4.8 升级至 v3.6.20,发现有一些调整(文末)。

    一、单位使用

    初始化一个项目:

    $ yarn global add @tarojs/cli
    
    $ taro init simple-taro
    
    $ yarn dev:weapp
    

    实际项目建议将 @taro/cli 安装到项目依赖里面,而不是全局安装。@taro/cli@tarojs/taro 版本一致很重要,可以避免一些问题。

    750px 的设计稿尺寸为例,通常 Taro 配置文件会设置(更多尺寸):

    const config = {
      designWidth: 750,
      deviceRatio: { 750: 1 }
    }
    

    这样,源码使用 px 单位即可,打包时会对应平台的单位。比如小程序的 rpx、H5 的 rem

    /* 编译前 */
    .avatar {
      width: 50px;
    }
     
    /* 编译为小程序 */
    .avatar {
      width: 50rpx;
    }
     
    /* 编译为 H5 */
    .avatar {
      width: 1.0667rem;
    }
    

    如果是运行时,可以使用 Taro.pxtransform API:

    Taro.pxtransform(50) // 无需指定单位
    

    如果不想被转换,可以写成 1Px1PX(排版引擎大小写均可识别)。

    .avatar {
      width: 1Px; /* 将会被忽略 */
    }
    

    若要忽略某个文件,则在文件顶部添加 /* postcss-pxtransform disable */ 注释即可。

    注意,如果项目有用 Prettier、Stylelint 等格式化工具,保存时可能会被自动修复为 px,需要用 /* prettier-ignore */ 等忽略掉。

    还提供了这些配置:

    {
      postcss: {
        pxtransform: {
          enable: true,
          config: {
            onePxTransform: true, // 设置 1px 是否需要被转换
            unitPrecision: 5, // rem 单位允许的小数位
            propList: ['*'], // 允许转换的属性
            selectorBlackList: [], // 黑名单里的选择器将会被忽略,不做转换处理
            replace: true, // 直接替换而不是追加一条进行覆盖
            mediaQuery: false, // 允许媒体查询里的 px 单位转换
            minPixelValue: 0 // 设置一个可被转换的最小 px 值
          }
        }
      }
    }
    

    二、为什么要优化?

    其实 Taro 已经提供了完备转换方案,为什么还要优化呢?

    示例:

    /* 编译前 */
    .avatar {
      width: 50px;
    }
     
    /* 编译为小程序 */
    .avatar {
      width: 50rpx;
    }
     
    /* 编译为 H5 */
    .avatar {
      width: 1.0667rem;
    }
    

    从编译结果看,编译到 H5 端宽度是 1.0667rem

    试想,如果要用到 DevTool 调试像素大小(比如添加 2 像素),面对 width: 1.0667rem 这个值,你要怎么下手呢?

    如果 H5 端编译前后相差 100,是不是就没有换算负担了?

    /* 编译前 */
    .avatar {
      width: 50px;
    }
    
    /* 编译为 H5 */
    .avatar {
      width: 0.5rem;
    }
    

    好了,我们就朝着这个方向去做...

    三、转换原理

    你也许用过 postcss-pxtorem 插件,将 px 转换为 rem。Taro 内部的单位转换插件 postcss-pxtransform 在此基础上二次开发的,新增了对小程序的支持。

    我们知道 rem 参照物是页面根节点字号。比如根节点字号是 16px,那么 1rem = 16px 的长度。

    假设 750px 设计稿中,某个元素长度为 123px 时,按照根元素字号 16px 来换算,对应就是 123 / 16 = 7.6875rem,这样的话换算负担非常大。

    如果换算的基础值是 100,那么无论是 123px 还是 345px,原有值除以 100 就是编译结果,换算负担为零。

    使用 postcss-pxtorem

    在非 Taro 项目中,我通常是这样使用 postcss-pxtorem 的:

    // postcss.config.js
    module.exports = {
      // ...
      plugins: [
        require('postcss-pxtorem')({
          propList: ['*'],
          rootValue: 100,
          minPixelValue: 2
        })
      ]
    }
    

    我们先看下 postcss-pxtorem 配置项的默认值:

    {
      rootValue: 16,
      unitPrecision: 5,
      propList: ['font', 'font-size', 'line-height', 'letter-spacing'], // 这些 CSS 属性将会被转换
      selectorBlackList: [],
      replace: true,
      mediaQuery: false,
      minPixelValue: 0,
      exclude: /node_modules/i,
    }
    

    主要关注 rootValue 配置项,它接受一个 NumberFunction 参数,描述如下:

    Represents the root element font size or returns the root element font size based on the input parameter.

    简单来说,书写值/rootValue = 转换值。比如源码是 50px,当 rootValue16,那么结果为 50/16 = 3.125rem

    所以 rootValue 设置为 100 的原因很简单:换算负担最小,约为零。这样 50px0.5rem123px1.23rem

    设备像素与 CSS 像素

    在往下之前,先了解下这些内容:

    设备 设备分辨率 设备像素 CSS 像素 设备像素比
    iPhone 5/5s 640 × 1136 640 × 1136 320 × 568 2
    iPhone 6/6s/7/8 750 × 1334 750 × 1334 375 × 667 2
    iPhone 6/6s/7/8 Plus 1080 × 1920 1242 × 2208 414 × 736 3
    iPhone X/XS 1125 × 2436 1125 × 2436 375 × 812 3
    iPhone XR 828 × 1792 828 × 1792 414 × 896 2
    iPhone 11 Pro 1125 × 2436 1125 × 2436 375 × 812 3
    iPhone XS Max/11 Pro Max 1242 × 2688 1242 × 2688 414 × 896 3
    iPhone 12 mini 1125 × 2436 1125 × 2436 375 × 812 2
    iPhone 12/12 Pro 1170 × 2532 1170 × 2532 390 × 844 3
    iPhone 12 Pro Max 1284 × 2778 1284 × 2778 428 × 926 3

    推荐 YESVIZMy Device 两个网站,可以查看常见设备参数。

    怎么区分:

    • 设备分辨率:通常是用户比较关注的购机指标。
    • 设备像素:是设计师关注的指标,常说的 750px 设计稿尺寸,就是指设备像素的宽度
    • CSS 像素:是开发者需关注的指标,同时要理解设备像素与 CSS 像素的关系。
    • 设备像素比:设备像素 / CSS 像素。

    我们还经常看得到这样的声明:

    <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no" />
    
    • width=device-width:定义 Viewport 宽度。由于各浏览器默认 Viewport 宽度可能不一致,而且移动设备的屏幕大小有限,通常会将设备宽度设置为 Viewport 宽度。
    • initial-scale=1:定义设备宽度与 Viewport 之间的缩放比例。
    • minimum-scale=1:定义缩放比例的最小值。
    • maximum-scale=1:定义缩放比例的最小值。
    • user-scalable:取值 yesno,其中 no 表示用户将无法缩放当前页面。

    为什么要这样设置呢?

    原来「设备像素比」是指在未缩放状态下,设备像素与 CSS 像素的初始比例关系。

    当网页缩放比例设为 1 时,document.documentElement.clientWidth 的返回值等于该设备横向 CSS 像素宽度。

    动态设置根元素字体大小

    接下来,介绍如何动态地设置根元素 <html> 的字体大小。

    我们知道,通过 JavaScript 获取元素的宽高等,对应的是 CSS 像素,而不是设备像素,更不是设备分辨率。比如:

    设备 设备像素 CSS 像素 设备像素比
    iPhone 7 750 × 1334 375 × 667 2

    以 iPhone 7 为例,750px 设计稿上的 100px 对应 CSS 像素为 50px

    当根元素字号设为 50px 时,此时 1rem = 50 CSS 像素 = 100 设备像素

    兼容其他设备可以这样算:

    rootFontSize = document.documentElement.clientWidth / 375 * 50
    

    实现如下:

    <script>
      !(function (n, e) {
        var t = n.documentElement
        var i = 'orientationchange' in window ? 'orientationchange' : 'resize'
        var d = function () {
          var n = t.clientWidth
          if (n) {
            var e = 50 * (n / 375)
            e = e > 58 ? 58 : e
            t.style.fontSize = e + 'px'
          }
        }
        if (n.addEventListener) {
          e.addEventListener(i, d)
          n.addEventListener('DOMContentLoaded', d)
        }
      })(document, window)
    </script>
    

    四、Taro 转换原理

    前面提到 Taro 对 postcss-pxtorem 进行了二次开发,以适配多端的单位转换。

    其配置项与 postcss-pxtorem 是相似,最大的区别在于 rootValue 上。尽管在 README 提到 rootValue 是必填的,但其实是没用的。

    根据源码 rootValue 在不同端会有不同的换算规则。

    如果 Taro 的编译配置如下:

    const config = {
      designWidth: 750,
      deviceRatio: { 750: 1 }
    }
    

    那么从源码中,可以知道调用 rootValue() 方法,将会得到什么值。以 50px 为例:

    小程序端:

    options.rootValue = input => 1 / options.deviceRatio[designWidth(input)]
    
    // 根据配置,可知 designWidth(input) 结果为 750,
    // 因此 options.deviceRatio[designWidth(input)] 即为 1
    // 所以,小程序端转换,仅涉及单位的转换(px => rpx),数值是不变的,即转换结果为 50rpx。
    

    H5端:

    options.rootValue = input => baseFontSize * designWidth(input) / 640
    
    // 其中 baseFontSize 是源码中写死的 40
    // 其中 designWidth(input) 为 750,
    // 因此该方法返回值将会是 46.875
    // 所以 H5 端 50px 会转换为 1.06666667rem
    

    到这里,你应该就明白其转换结果为什么会是这样的了。

    /* 编译前 */
    .avatar {
      width: 50px;
    }
     
    /* 编译为小程序 */
    .avatar {
      width: 50rpx;
    }
     
    /* 编译为 H5 */
    .avatar {
      width: 1.0667rem;
    }
    

    完整的转换过程看 createPxReplace 部分:

    理解转换规则后,就很简单了。本质上就是通过 String.prototype.replace() 方法来替换字符串而已。从这里,你也理解了 onePxTransformminPixelValue 配置项的作用。

    至于匹配 px 的正则表达式如下(#L11):

    const pxRegex = /"[^"]+"|'[^']+'|url\([^\)]+\)|(\d*\.?\d+)px/g
    

    五、转换调整

    Taro 项目 index.html 是这样处理的:

    <script>
      !(function (n) {
        function e() {
          var e = n.document.documentElement,
            t = e.getBoundingClientRect().width;
          e.style.fontSize =
            t >= 640 ? "40px" : t <= 320 ? "20px" : (t / 320) * 20 + "px";
        }
        n.addEventListener("resize", function () {
          e();
        }),
          e();
      })(window);
    </script>
    

    按上一节的调整为:

    <script>
      !(function (n, e) {
        var t = n.documentElement
        var i = 'orientationchange' in window ? 'orientationchange' : 'resize'
        var d = function () {
          var n = t.clientWidth
          if (n) {
            var e = 50 * (n / 375)
            e = e > 58 ? 58 : e
            t.style.fontSize = e + 'px'
          }
        }
        if (n.addEventListener) {
          e.addEventListener(i, d)
          n.addEventListener('DOMContentLoaded', d)
        }
      })(document, window)
    </script>
    

    通过源码,我们知道 H5 中 rootValue 的计算如下,由于我们的 designWidth750,而 baseFontSize 则是写死的 40

    options.rootValue = input => baseFontSize * designWidth(input) / 640
    

    要使得 rootValue() 方法的返回值为 100,需要将 designWidth 设为 1600。但是小程序端仍要设为 750,可以通过 process.env.TARO_ENV 判断平台。

    const config = {
      designWidth: process.env.TARO_ENV === 'h5' ? 1600 : 750,
      deviceRatio: { 750: 1 },
      mini: {
        postcss: {
          pxtransform: {
            enable: true,
            config: {
              platform: 'weapp',
              minPixelValue: 2,
              onePxTransform: false
            }
          }
        }
      },
      h5: {
        postcss: {
          pxtransform: {
            enable: true,
            config: {
              platform: 'h5',
              minPixelValue: 2,
              onePxTransform: false,
            }
          }
        }
      }
    }
    

    至此,就能实现类似 postcss-pxtorem 设置 rootValue100 的效果,编译前后就如预期所想:

    /* 编译前 */
    .avatar {
      width: 50px;
    }
     
    /* 编译为小程序 */
    .avatar {
      width: 50rpx;
    }
     
    /* 编译为 H5 */
    .avatar {
      width: 0.5rem;
    }
    

    六、2023.11.30 更新

    postcss-pxtransform 最新仓库移到了这里原先的应该弃用了。

    最近从 v3.4.8 升级至 v3.6.20 后会导致报错以及样式错乱。

    Error: deviceRatio 配置中不存在 1600 的设置!
    

    NervJS/taro #12078 发现了给 taro-h5 的 pxTransform() 添加了以下检测逻辑,应该是各平台都加了:

    if (!(designWidth in config.deviceRatio)) {
      throw new Error(`deviceRatio 配置中不存在 ${designWidth} 的设置!`)
    }
    

    这个问题只要在配置文件加上就好 1600deviceRatio 就能解决:

    const config = {
      designWidth: process.env.TARO_ENV === 'h5' ? 1600 : 750,
      deviceRatio: {
        750: 1,
        1600: 0.8
      },
    }
    

    然后还观察到 baseFontSizerootValue 计算的规则发生了一些变化(详见):

    const transUnits = ['px']
    // 变化 1️⃣
    const baseFontSize = options.baseFontSize || (options.minRootSize >= 1 ? options.minRootSize : 20)
    const designWidth = (input) =>
      typeof options.designWidth === 'function' ? options.designWidth(input) : options.designWidth
    
    switch (options.platform) {
      case 'h5': {
        targetUnit = options.targetUnit ?? 'rem'
    
        if (targetUnit === 'vw') {
          options.rootValue = (input) => {
            return designWidth(input) / 100
          }
        } else if (targetUnit === 'px') {
          options.rootValue = (input) => (1 / options.deviceRatio[designWidth(input)]) * 2
        } else {
          // rem
          // 变化 2️⃣
          options.rootValue = (input) => {
            return (baseFontSize / options.deviceRatio[designWidth(input)]) * 2
          }
        }
    
        transUnits.push('rpx')
        break
      }
      
      // ...
    }
    

    为了跟之前一样使得 rootValue 总等于 100,一是主动配置 baseFontSize40,然后换算过来 deviceRatio['1600'] 就是 0.8

    更新后的配置如下:

    const config = {
      designWidth: process.env.TARO_ENV === 'h5' ? 1600 : 750,
      deviceRatio: { 750: 1, 1600: 0.8 },
      mini: {
        postcss: {
          pxtransform: {
            enable: true,
            config: {
              platform: 'weapp',
              minPixelValue: 2,
              onePxTransform: false
            }
          }
        }
      },
      h5: {
        postcss: {
          pxtransform: {
            enable: true,
            config: {
              baseFontSize: 40,
              platform: 'h5',
              minPixelValue: 2,
              onePxTransform: false,
            }
          }
        }
      }
    }
    

    The end.

    ]]> <![CDATA[CSS 中的伪类、伪元素总结]]> https://github.com/tofrankie/blog/issues/171 https://github.com/tofrankie/blog/issues/171 Sun, 26 Feb 2023 07:49:30 GMT 配图源自 Freepik

    本文摘抄自 AlloyTeam 团队: 配图源自 Freepik

    本文摘抄自 AlloyTeam 团队:总结伪类与伪元素

    老是不记得哪个用双冒号,哪个用单冒号,不知道你们有没有这种困惑。尽管知道是可兼容的,但还是想再整理一下。

    一、概念

    • 伪类(pseudo-classes) 用于当已有元素处于的某个状态时,为其添加对应的样式,这个状态是根据用户行为而动态变化的。

    • 伪元素(pseudo-elements) 用于创建一些不在文档树中的元素,并为其添加样式。

    伪类的操作对象是「文档树中已有的元素」,而伪元素则创建了一个「文档树之外的元素」。因此,伪类与伪元素的区别在于:有没有创建一个文档树之外的元素。

    二、伪元素是使用单冒号还是双冒号?

    按 CSS3 规范,

    伪元素使用双冒号(::)表示,伪类使用单冒号(:)表示。

    如果不按规范行事,并需要兼容 IE8 以下浏览器,

    无论是伪类,还是伪元素,都使用单冒号(:)表示。

    但是,经过各大浏览器厂商们的加班加点(可能是为了兼容性考虑),

    除了少部分伪元素(比如 ::backdrop)必须使用双冒号之外,大部分伪元素都支持单冒号和双冒号的写法。

    对于伪元素是使用单冒号还是双冒号的问题,W3C 标准中的描述如下:

    Please note that the new CSS3 way of writing pseudo-elements is to use a double colon, eg a::after { ... }, to set them apart from pseudo-classes. You may see this sometimes in CSS. CSS3 however also still allows for single colon pseudo-elements, for the sake of backwards compatibility, and we would advise that you stick with this syntax for the time being.

    综上所述:除了必须要双冒号的伪元素之外,为了向后兼容,建议伪元素也使用单冒号的写法。

    三、常见的伪元素、伪类

    伪类:

    伪元素:

    具体用法原文,真心写得不错~

    The end.

    ]]> <![CDATA[CSS 变量使用详解]]> https://github.com/tofrankie/blog/issues/170 https://github.com/tofrankie/blog/issues/170 Sun, 26 Feb 2023 07:47:19 GMT 配图源自 Freepik

    这篇文章你将学到以下内容:

    • CSS 变量
    • ]]> 配图源自 Freepik

      这篇文章你将学到以下内容:

      • CSS 变量
      • CSS 常用函数
      • iPhone X 系列机型适配
      • CSS At-rules 和媒体查询
      • 深色模式适配

      一、简述

      CSS 变量(CSS Variables),也称作 CSS 自定义属性(CSS Custom Properties),它是带有前缀 -- 属性名,且带有值的自定义属性。然后通过 var 函数在全文范围复用。

      至于为什么采用 --,大概是因为 @Less 占用了,$Sass 占用了吧。

      1.1 语法

      定义 CSS 变量的语法非常简单,在变量名称之前添加两个短横线 --

      --<custom-property-name>: <declaration-value>
      

      其中 <custom-property-name> 表示变量名称,<declaration-value> 表示变量值,形如:--*。这类自定义 CSS 属性与 colorfont-sizebackground-image 等属性并没有什么不同,只是它没有默认含义罢了,它必须通过 var() 函数复用之后,才会产生意义。

      其中「变量名称」命名约束是比较宽松的,可以是数字、字母、下划线 _、短横线 - 的组合,但不能包含 $[^(% 等字符。比如:

      --some-keyword: left;
      --some-color: #f00;
      --some-complex-value: 3px 6px rgb(20, 32, 54);
      

      甚至可以是以数字开头、也可以是中文、韩文等。

      :root {
       --红色: #f00; /* 有效 */
       --1: 1px; /* 有效 */
      }
      
      body {
       background-color: var(--红色);
       height: var(--1);
      }
      

      当然,实际项目中,千万别以这种花里胡哨、奇奇怪怪的组合来命名变量名称,主要是避免被打。建议使用 kebab-case 方式进行命名,比如 --theme-primary 等。

      请注意,CSS 变量名称是大小写敏感的,--foo--Foo 是两个不同的变量。这一点与 CSS 属性大小写不敏感是有区别的。

      1.2 作用域

      同一个 CSS 变量,可以在多个选择器内声明,读取顺序与 CSS 匹配规则一致,优先级最高的生效。请注意,CSS 变量并没有 !important 用法,变量的覆盖规则由 CSS 选择器权重决定。

      一般情况下,全局性变量放在 :root 内声明,也可以在任意元素中声明 CSS 变量,视实际情况而定即可。如果是小程序,则在全局样式 app.wxsspage 内声明。

      :root 这个 CSS 伪类匹配文档树的根元素。对于 HTML 来说,:root 表示 <html> 元素,除了优先级更高之外,与 html 选择器相同。

      :root {
        --theme-primary: #f00; /* 全局可复用 */
      }
      
      header {
        --theme-primary: #0f0; /* 仅 header 范围内可复用 */
      }
      
      section {
        --theme-primary: #00f; /* 仅 section 范围内可复用 */
      }
      

      比如 <section> 内使用 color: var(--theme-primary),生效的将会是 color: #00f。再者,以下示例中,在 <div id="error"></div> 中引用 --color 变量,最终生效的是 ID 选择器的变量值。

      :root {
        --color: #f00;
      }
      
      div {
        --color: #0f0;
      }
      
      #id {
        --color: #00f;
      }
      

      总的来讲,CSS 变量是有作用域概念的,它只能作用于自身或后代元素,而兄弟元素、祖先元素都是不用享用的。

      可以试下这个示例:css-variable-scope-demo

      1.3 兼容性

      兼容性如下,还是挺不错的(如果忽略 IE 的话),更多请看 Can I use

      很棒 🙄,IE 全系不支持,骂骂咧咧地说:还是用 SCSS 或 LESS 吧。可以参考下这个项目:css-vars-ponyfill

      对于不支持 CSS 变量的浏览器,可以采用如下方式兼容处理:

      :root {
        --color-primary: #00f;
      }
      
      a {
        color: #00f;
        color: var(--color-primary);
      }
      

      也可以使用 @supports 规则,然而它也不兼容 IE 浏览器。

      @supports (--foo: 0) {
        /* supported */
      }
      
      @supports (not (--foo: 0)) {
        /* unsupported */
      }
      

      二、JavaScript 操作

      利用 CSS.supports() 方法即可判断当前浏览器是否支持 CSS 变量,如下:

      const isSupported = window.CSS.supports('--foo', 0)
      

      由于 CSS 变量就是自定义的 CSS 属性嘛,因此按照平常设置 CSS 属性的方式去操作即可,如下:

      const element = document.querySelector('selectors')
      
      // 定义 CSS 变量
      element.style.setProperty('--color', '#f00')
      
      // 读取 CSS 变量
      element.style.getPropertyValue('--color', '#f00')
      
      // 删除 CSS 变量
      element.style.removeProperty('--color')
      

      另外有一个比较奇怪的用法(来自 EXAMPLE 7),如下:

      :root {
        --foo: if(x > 5) this.width = 10;
      }
      

      尽管这个属性值是「无用」的,不会使得任意 CSS 属性产生实际效果,但是这个 CSS 变量定义是「有效」的。它可以被 JavaScript 读取,至于有什么用,我也不知道。

      三、CSS 函数

      3.1 var 函数

      var() 函数用于读取 CSS 变量,它可以替代元素中「任何属性」中的「值的任何部分」,不能作为属性名、选择器或其他处理属性值之外的值。

      语法如下:

      var(<custom-property-name>[, <declaration-value>])
      
      • <custom-property-name> 表示自定义属性名
      • <declaration-value>(可选)表示声明值(后备值),仅自定义属性没有定义时,它才会有效(类似 ES6 中的函数参数设定默认值)。

      3.1.1 CSS 变量不合法的缺省特性

      看看以下示例,变量 --color 的值为 20px,显然它作为 background-color 值的话是无效的,那么 <body> 会显示什么背景颜色呢?红色?绿色?还是...

      body {
        --color: 20px;
        background-color: #f00;
        background-color: var(--color, #0f0); /* 正确语法,与 background-color: 20px 有着本质上的区别 */
      }
      

      它最终生效的属性值为 transparent,即 background-color 的默认值,因此相当于:

      body {
        --color: 20px;
        background-color: #f00;
        background-color: transparent;
      }
      

      👆 可看 EXAMPLE 13

      但请注意,以下示例生效的是 background-color: #f00,就怕有人看到上面示例之后,对原来的认知产生怀疑,特意说明下。

      body {
        background-color: #f00; /* 有效 */
        background-color: 20px; /* 语法错误,这条规则声明被丢弃,因此上一条规则生效 */
      }
      

      因此,当 CSS 变量值不合法时,生效的是 CSS 属性的“默认值”。

      但注意,CSS 变量值不合法并不能使得 <declaration-value> 声明值生效,它仅限于 CSS 变量没有定义才会生效(类似函数参数的默认值仅实参为 undefined 才会生效,即便是 null 等 falsy 实参也不会使其生效一样)。

      为什么这里默认值要打双引号呢,原因是标准 EXAMPLE 13 部分明确说明了:

      If the property was one that’s inherited by default, such as color, it would compute to the inherited value rather than the initial value.

      也就是说,如果一个 CSS 属性是可继承的,那个当它应用了一个不合法的 CSS 变量值,最终生效的是其继承值,而不是默认值。比如:

      <style>
        :root {
          --not-a-color: 20px;
        }
      
        body {
          color: #f00;
        }
      
        p {
          color: var(--not-a-color);
        }
      </style>
      <body>
        <p>字体会是什么颜色呢?</p>
      </body>
      

      你看最终 <p> 生效的 color 是其从 <body> 中继承过来的 #f00 红色,而不是 color 的默认颜色 canvastext

      插个话题,我很好奇 canvastext 颜色是什么颜色,一般来说它会是黑色 rgb(0, 0, 0),然后我尝试将系统调至深色模式,然而它并不会默认变为白色,哈哈。然后我翻查了下标准,发现它跟 有关,它一般由浏览器来定义(如下),可看 6.2 System Color 章节。

      因此,比较严谨的说法是:当 CSS 变量值不合法时,生效的是 CSS 属性的继承值或初始值。

      3.1.2 var 函数的尾随空格

      <style>
        body {
          --size: 20;
          font-size: var(--size)px;
        }
      </style>
      <body>
        <p>字号是多大呢?</p>
      </body>
      

      猜一下 font-size 会是预期的 20px 吗?它不是,如下图:

      请注意,浏览器最终解析出来的规则是:font-size: var(--size) px;,它在 var(--size)px 之间多了一个「空格」,因此这条规则是无效的(注意并不是引用 CSS 变量无效),所以字号是浏览器默认字体大小 16px

      如果你使用诸如 VS Code 等编辑器,它一般会有 semi-colon expected 错误提醒的,如果保存自动格式化,它将会被保存为:font-size: var(--size) px;

      这种情况可结合 calc() 函数处理,比如:

      body {
        --size: 20;
        font-size: calc(var(--size) * 1px); /* 这样就能正常计算得出 20px 了 */
      }
      

      但个人更推荐这样用:对于一些长度、大小等 CSS 属性值,在定义 CSS 变量时,应带上单位:

      body {
        --size: 20px;
        font-size: var(--size);
      }
      

      请注意,如果变量值包含单位,就不能写成字符串形式。

      body {
        --size: '20px';
        font-size: var(--size); /* 注意,CSS 变量引用的语法是有效的,但经 CSS 解析器计算之后,其值并不符合 font-size 属性值的要求,因此被判定为语法错误,规则会被丢弃。 */
      }
      

      相当于 font-size: '20px'; 语法错误,规则会被丢弃,因此取其继承值或默认值。

      3.1.3 CSS 变量的相互传递性

      我们在某个选择器中定义了一个 CSS 变量,它除了在子元素中被复用,它本身作用域内也可以复用,而且与编写顺序无关。比如:

      body {
        --size: 20px;
        font-size: var(--size);
      }
      
      body {
        font-size: var(--size);
        --size: 20px;
      }
      

      以上两个示例,均是有效的。后者并不会因为 --size: 20px; 定义在后,就不会生效。这样规则,对于我们通过 JavaScript 动态设置 CSS 变量有着非常重要的意义。

      3.2 calc 函数

      calc() 语法非常地简单,如下:

      property: calc(expression)
      

      该函数接收一个表达式作为它的参数,表达式的返回值作为 calc() 函数的值。表达式可以是 +-*/ 的组合,而且可以混用不同单位进行运算。

      它同样支持 CSS 变量,例如:

      .foo {
        --height: 30px;
        width: calc(100% - 30px);
        height: calc(100vh - var(--height))
      }
      

      注意点:

      • 对于 +- 运算,运算符两边必须要有「空格」,而 */ 运算则没有要求,因此建议都加上空白符。
      • 对于 * 运算,参与运算的至少有一个数值(),且不能为 0
      • 对于 / 运算,运算符 / 右侧必须是一个数值()。
      • calc() 函数支持嵌套写法,但其实被嵌套的 calc() 函数只会被当做普通的括号,因此函数内直接使用括号就好了。

      那么嵌套语法有什么用呢,比如:

      .foo {
        --widthA: 100px;
        --widthB: calc(var(--widthA) / 2);
        --widthC: calc(var(--widthB) / 2);
        width: var(--widthC);
      }
      

      那么,以上 --widthC 的值就会变成 calc(calc(100px / 2) / 2),即 25px

      Web 前端总是绕不开兼容性,那么看下 calc() 函数的兼容性如何:

      绿悠悠的一片,甚好!可以看到 IE9 以上都支持,可 IE 浏览器不支持嵌套写法,由于 IE 浏览器都不支持 CSS 变量,因此这个无伤大雅。

      3.3 env 和 constant 函数

      2017 年 Apple 公司发布了 iPhone X 和 iOS 11,开启了「刘海屏」和底部小横条之路。

      于是就有了「安全区 Safe Area」之说(详见):

      A safe area defines the area within a view that isn’t covered by a navigation bar, tab bar, toolbar, or other views a view controller might provide.

      简单来讲,以 iPhone X 为例,其安全区是指不受刘海(Sensor Housing)、底部小横条(Home Indicator)、设备圆角(Corners)影响的区域,如下图的浅蓝色区域所示,其中粉色部分是指浏览器默认的 Margin 值,通常为了抹平各浏览器不同的外边距,都会设置 body { margin: 0 }

      我们知道,Viewport 是规则的矩形,如果显示设备的屏幕是不规则(比如圆形)的话,页面中的某些部分就会被裁剪。那么 viewport-fit 可以通过设置可视 Viewport 大小来控制裁剪区域。

      其中 viewport-fit 提供了 auto(默认)、containcover 三种属性值(详见):

      <meta name="viewport" content="viewport-fit=auto|contain|cover" />
      
      属性值 描述
      auto 默认值,表现与 contain 一致,Viewport 会显示在「安全区」之内,相当于 viewport-fit: contain
      contain 将可视 Viewport 设置为页面所有内容均可见的最大矩形。
      cover 将可视 Viewport 大小设置为显示设备屏幕的外接矩形。

      以圆形屏幕为例:

      Apple 公司为了适配旗下的全面屏设备,(iOS 11 起)WebKit 内核的浏览器中定义了 safe-area-inset-* 四个环境变量。

      • safe-area-inset-top
      • safe-area-inset-right
      • safe-area-inset-bottom
      • safe-area-inset-left

      需要注意的是,在竖屏和横屏状态下,safe-area-inset-* 值是不同的。比如,竖屏状态下环境变量 safe-area-inset-leftsafe-area-inset-right 的值为 0,横屏状态下环境变量 safe-area-inset-top 的值为 0

      上图源自 Deng's Blog

      通过 env()constant() 函数就能引用以上几个环境变量,对于不支持 env()constant() 的浏览器,包含它的样式规则将被忽略。

      自 Safari Technology Preview 41 和 iOS 11.2 beta 起,constant() 函数已被移除,并用 env() 函数替换(详见)。可为了兼容性,一般两个都会写。

      另外,若要环境变量 safe-area-inset-* 生效,需将页面设置为 viewport-fit: cover

      接下来,会介绍如何适配 iPhone X 系列刘海屏手机,有以下示例:

      <!DOCTYPE html>
      <html lang="en">
        <head>
          <meta charset="UTF-8" />
          <meta http-equiv="X-UA-Compatible" content="IE=edge" />
          <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
          <title>Document</title>
          <style>
            * {
              margin: 0;
              padding: 0;
            }
      
            body {
              width: 100%;
              background-color: darkorange;
            }
      
            .container {
              height: 100%;
              min-height: 200vh;
              background-color: forestgreen;
            }
          </style>
        </head>
        <body>
          <div class="container"></div>
        </body>
      </html>
      

      当我们不做任何处理,以上示例在 iPhone X 系列手机横屏状态下,左右边框会空出一部分,究其原因就是 Safari 浏览器会将网页内容置于「安全区之内」,相当于 viewport-fit: contain

      当我们将 <meta> 标签内的 viewport-fit 改为 cover 之后,并在页面中添加一首诗。

      这样,Viewport 就占满了显示设备最大的矩形,但因为设备的刘海、圆角等因素,会导致页面中的部分内容无法完全显示。

      然后,我们试着在 container 内添加左右外边距,其值分别取 safe-area-inset-leftsafe-area-inset-right 环境变量。

      <!DOCTYPE html>
      <html lang="en">
        <head>
          <meta charset="UTF-8" />
          <meta http-equiv="X-UA-Compatible" content="IE=edge" />
          <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover" />
          <title>Document</title>
          <style>
            * {
              margin: 0;
              padding: 0;
            }
      
            body {
              width: 100%;
              background-color: darkorange;
            }
      
            .container {
              height: 100%;
              min-height: 200vh;
              background-color: forestgreen;
      
              /* 新增 */
              margin-left: env(safe-area-inset-left);
              margin-right: env(safe-area-inset-left);
            }
          </style>
        </head>
        <body>
          <div class="container">
            <article>
              <h1>蒹葭</h1>
              <p>蒹葭苍苍,白露为霜。所谓伊人,在水一方。溯洄从之,道阻且长。溯游从之,宛在水中央。</p>
              <p>蒹葭萋萋,白露未晞。所谓伊人,在水之湄。溯洄从之,道阻且跻。溯游从之,宛在水中坻。</p>
              <p>蒹葭采采,白露未已。所谓伊人,在水之涘。溯洄从之,道阻且右。溯游从之,宛在水中沚。</p>
              <p></p>
            </article>
          </div>
        </body>
      </html>
      

      横屏、竖屏显示如下:

      考虑 env()constant() 函数兼容性的写法如下:

      @supports (bottom: constant(safe-area-inset-bottom)) or (bottom: env(safe-area-inset-bottom)) {
        .container {
          /* 请注意,constant 和 env 先后顺序如下 */
          margin-left: constant(safe-area-inset-left); /* 兼容 iOS < 11.2 */
          margin-left: env(safe-area-inset-left); /* 兼容 iOS >= 11.2 */
      
          margin-right: constant(safe-area-inset-right);
          margin-right: env(safe-area-inset-right);
        }
      }
      

      3.4 max 和 min 函数

      从文章排版来看,这是极不美观的,我们希望在左右两边再加点内边距。PS:上述图片为了更方便对比,采用了外边距(Margin),接下来会将其修改为内边距(Padding)。

      .container {
        /* 这里将原先的 margin 修改为 padding */
        padding-left: env(safe-area-inset-left);
        padding-right: env(safe-area-inset-left);
      }
      

      但有个小小的需求,我们只希望竖屏状态下,添加 10px 的左右内边距,而横屏状态下取 safe-area-inset-* 的值就好。

      它们提供了两个 CSS 函数:min()max(),利用它们就能实现这需求。

      .container {
        /* 这里使用到 max() 函数,表示取二者最大值。 */
        padding-left: max(10px, env(safe-area-inset-left));
        padding-right: max(10px, env(safe-area-inset-left));
      }
      

      效果如下:

      如果考虑兼容性的话,可以使用 @supports 语法来处理:

      @supports (padding: max(0px)) {
        .container {
          padding-left: max(10px, env(safe-area-inset-left));
          padding-right: max(10px, env(safe-area-inset-left));
        }
      }
      

      对于 max()min() 的语法和使用非常地简单,分别表示取最大值和最小值。

      两者语法是一致的,以 max 为例:

      property: max(expression [, expression])
      

      它接受一个或多个值,若有多个值则采用逗号 , 分隔,选择最大的值作为 CSS 属性的值。每个值除了可以是直接数值,还可以是数学运算(如 calc())、其他表达式(如 attr())。

      还支持嵌套 max()min() 函数,需要时可以使用小括号 () 来设定运算顺序。

      兼容性如下,一如既往 IE 全系不支持:

      至此,相信你对 iPhone X 等机型的适配有更深刻的了解,适配起来就完全没有压力了。

      四、CSS At-rules

      一个 at-rule 是一个 CSS 语句,它以 @ 符号开头,后接一个标识符,并包括直到下一个分号 ; 的所有内容或下一个 CSS 块,以先到者为准。

      主要可分为不可嵌套、可嵌套两类:

      不可嵌套 at-rule:

      • @charset:指定样式表中使用的字符编码。
      • @import:导入其他外部样式表。
      • @namespace:指示 CSS 引擎必须考虑XML命名空间。

      可嵌套 at-rule:

      • @media:用于基于一个或多个媒体查询的结果来应用样式表中的一部分。
      • @font-face:指定一个用于显示文本的自定义字体。
      • @keyframs:通过在动画序列中定义关键帧的样式来控制 CSS 动画序列中的中间步骤。
      • @supports:指定依赖于浏览器中的一个或多个特定的 CSS 功能的支持声明。
      • @document:根据文档的 URL 限制其中包含的样式规则的作用范围(实验特性)。
      • @page:用于在打印文档时修改某些 CSS 属性。

      每个 at-rule 规则都有不同的语法,有一部分 at-rule 可以归为一类:条件规则组

      这些规则组所指的条件总等效于 truefalse,如果为 true 那么它里面的 CSS 语句生效。

      本文仅介绍 @supports@media,其他规则请看 CSS At-rules

      4.1 @supports

      @supports 常用于 CSS 兼容性判断。

      它由一组支持条件和一组样式声明组成。支持条件可以是一个或多个条件使用逻辑与 and、逻辑或 or、逻辑非 not 组合而成。

      • 单一条件:由一个 CSS 属性和属性值组成,中间用分号 ; 隔开。
      @supports (transform-origin: 5% 5%) {
        /* 样式声明 */
      }
      

      transform-origin 的实现语法认为 5% 5% 是有效的值,表达式会返回 true,此时规则内声明的样式就会生效。

      • 多个条件:使用 notandor 操作符组合。

      相当于 JavaScript 中的 !&&|| 操作符啦,需设定运算顺序,则使用括号包裹。

      /* 当 transform-origin: 10em 10em 10em 无效时,表达式返回 true */
      @supports not (transform-origin: 10em 10em 10em) {
        /* 样式声明 */
      }
      
      /* 当所有条件同时为真时,表达式才返回 true */
      @supports (display: table-cell) and (display: list-item) {
        /* 样式声明 */
      }
      
      /* 当条件至少有一个为真时,表达式才返回 true */
      @supports (transform-style: preserve) or (-moz-transform-style: preserve) {
        /* 样式声明 */
      }
      

      还有一个实验性的语法:selector(),有兴趣请看这里

      兼容性仍然是 IE 全系不支持,呵呵~

      4.2 @media 介绍

      媒体查询(Media Queries),在网页开发中是非常常用的。浏览器给 Web 提供了一些媒体特性(Media Features),它描述了 User Agent、输出设备、浏览器环境的具体特征。网页开发者可根据这些特性,来提供更好的用户体验。

      比如:

      @import 'common.css' screen, projection;
      
      @media screen and (min-width: 480px) {
          /* ... */
      }
      
      <link rel="stylesheet" src="styles.css" media="screen" />
      <link rel="stylesheet" href="mobile.css" media="(max-width: 480px)" />
      
      // 如果参数是 CSS 声明(也就是出现了冒号),外面需要有个括号,否则语法不正确。
      if (window.matchMedia('(max-width: 480px)').matches) {
          // ...
      }
      

      使用媒体查询最常见的是 @media 方式,但是在 HTML 和 JavaScript 同样是可以使用的,后者用得较少。

      媒体查询可以这样使用:

      需要注意的是,当媒体查询作用于 <link> 元素时,即使媒体查询结果为 false,该样式表仍会被下载,只是样式内容不被作用而已。

      4.2.1 媒体查询语法

      每条媒体查询语句(不区分大小写),由一个可选的「媒体类型」和任意数量的「媒体特征」表达式构成。同时可使用多种「逻辑操作符」合并多条媒体查询语句。

      当媒体查询语句中指定的媒体类型与设备匹配时,这条媒体查询结果返回 true。若是多条媒体查询语句组合,需每个条件均为 true,那么媒体查询结果才为 true

      4.2.2 媒体类型(Media Types)

      在媒体查询中,媒体类型(Media Types)是可选的,默认是 all 类型。一般是使用了 notonly 逻辑操作符,才需要显式指定。有以下这些:

      媒体类型 描述
      all 默认值,适用于所有设备
      screen 适用于屏幕。
      print 适用于在打印预览模式下的屏幕上查快递分页材料和文档。
      speech 适用于语音合成器。

      一部分媒体类型在 Media Queries Level 4 中已被弃用,可看这里

      简单示例:

      @media print {
        /* 针对打印设备 */
      }
      
      @media screen, print {
        /* 针对多种设备 */
      }
      

      4.2.3 媒体特性(Media Features)

      媒体特性描述了 User Agent、输出设备,或是浏览环境的具体特征,更多请看 MDN

      建议:使用前先弄清楚兼容情况

      常见的媒体特性:

      媒体特性 描述
      aspect-ratio 视口(Viewport)的宽高比。
      width 输出设备更新内容的渲染结果的频率。
      height 视口(Viewport)的高度
      hover 主要输入模式是否允许用户在元素上悬停。
      orientation 视口(Viewport)的旋转方向。
      prefers-color-scheme 探测用户倾向于选择亮色还是暗色的配色方案。
      resolution 输出设备的像素密度(分辨率)。
      scripting 探测脚本(例如 JavaScript)是否可用?

      实际场景中用得较少的媒体特性:

      媒体特性 描述
      any-hover 是否有任何可用的输入机制允许用户(将鼠标等)悬停在元素上?
      any-pointer 可用的输入机制中是否有任何指针设备,如果有,它的精度如何?
      pointer 主要输入机制是一个指针设备吗?如果是,它的精度如何?
      color 输出设备每个像素的比特值,常见的有 8、16、32 位。如果设备不支持输出彩色,则该值为 0。
      color-gamut 用户代理和输出设备大致程度上支持的色域。
      color-index 输出设备的颜色查询表(color lookup table)中的条目数量,如果设备不使用颜色查询表,则该值为 0。
      display-mode 应用程序的显示模式,比如 Web App 中的 manifest 中的 display 成员所指定。
      forced-color 检测 User Agent 是否限制调色板。
      grid 输出设备使用网格屏幕还是点阵屏幕?
      inverted-colors User Agent 或者底层操作系统是否反转了颜色。
      light-height 环境光亮度。
      monochrome 输出设备单色帧缓冲区中每个像素的位深度。如果设备并非黑白屏幕,则该值为 0。
      overflow-block 输出设备如何处理沿块轴溢出视窗(viewport)的内容
      overflow-inline 沿内联轴溢出视口(Viewport)的内容是否可以滚动?
      prefers-contrast 探测用户是否有向系统要求提高或降低相近颜色之间的对比度。
      prefers-reduced-motion 用户是否希望页面上出现更少的动态效果。
      update 输出设备更新内容的渲染结果的频率。

      在 Media Queries Level 4 已被弃用的媒体特性:

      媒体特性 描述
      device-aspect-ratio 输出设备的宽高比。
      device-width 输出设备渲染表面(如屏幕)的宽度。
      device-height 输出设备渲染表面(如屏幕)的高度。

      对于 Media Queries Level 各级中新增或弃用的媒体特性,可看这里

      对于表示「范围」的的媒体特性(比如 widthheight 等),可以使用前缀 min-*max-* 表示查询最小值、最大值。比如:

      @media (width: 480px) {
        /* 当 Viewport 宽度等于 480px 时被应用 */
      }
      
      @media (min-width: 720px) {
        /* 当 Viewport 最小宽度大于等于 720px 时被应用 */
      }
      
      @media (max-width: 960px) {
        /* 当 Viewport 最大宽度不超过 960px 时被应用 */
      }
      

      以上这种写法,在 Media Queries Level 4 中支持以下范围语法,但目前兼容性非常的差,了解下就行了:

      @media (min-width: 720px) and (max-width: 960px) {}
      
      /* 等价于 */
      @media ( 720px <= width <= 960px) {}
      

      4.2.4 device-width 与 width 的区别

      其中 device-width 表示设备的宽度,width 表示视口 Viewport 宽度。举个例子,在 PC 中打开了一个「非全屏」的浏览器窗口,那么 device-with 是显示器的宽度,是不可变的。而 width 则是浏览器内页面的 Viewport 宽度(注意,不能单纯地认为浏览器窗口的宽度),通过拖拽浏览器窗口等行为是可使其发生变化的。

      但请注意,在移动端中,横屏与竖屏的切换,device-width 会相应跟着改变的。应对这种行为,可结合 orientation 这个媒体特性使用。

      device-heightheight 同理。

      4.2.5 逻辑操作符(Logical Operators)

      通过逻辑操作符(andnotonly),可以创建复杂、多条件的媒体查询。其中 andnotonly 分别表示与、非、唯一的意思。

      此外,还可以使用 ,(逗号)将多个媒体查询合并,只要任一查询结果为 true,整个查询语句就返回 true(跟逻辑或 or 行为类似)。

      需要注意的是:

      • not 操作符不能用于否定单个查询条件,只能用于否定整个媒体查询。
      • 使用 notonly 操作符,必须指定「媒体类型」。
      • and 操作符可以将媒体类型、媒体特性或其他媒体功能组合在一起。
      • only 操作符,一般用于处理不支持「媒体特性」查询的语句旧版本浏览器。

      下面看一些示例:

      /* not 作用于整个媒体查询 */
      @media not all and (monochrome) {}
      
      /* 等价于 */
      @media not (all and (monochrome)) {}
      
      /* 而不是 */
      @media (not all) and (monochrome) {}
      

      再看:

      @media not screen and (color), print and (color) {}
      
      /* 等价于 */
      @media (not (screen and (color))), print and (color) {}
      

      再看:

      /* 通过 and 可组合多个条件 */
      @media screen and (min-width: 30em) and (orientation: landscape) {}
      

      再看:

      /*
        用户设备的最小高度为 680px 或为竖屏模式的屏幕设备,都会被应用此样式。
      */
      @media (min-height: 680px), screen and (orientation: portrait) {}
      

      再看这个示例,如果是不支持「媒体特性」查询的旧浏览器,那么以下样式将不会被应用,在这些浏览器看来,它将 only 识别为「媒体类型」了,但实际上并没有这样的媒体类型,因而将不会被应用。而在现代浏览器中,就没有任何影响。

      @media only screen and (color) {}
      
      /* 在旧版浏览器,相当于这样 */
      @media only {}
      
      /* 在现代浏览器,相当于这样 */
      @media screen and (color) {}
      

      4.3 @media 应用场景

      1. 浅色模式与深色模式

      可以使用媒体特性 prefers-color-scheme 来判断是否在系统层面设置了深色模式。它有三个可选值:

      • light - 表示用户在系统层面使用了“浅色模式”。
      • dark - 表示用户在系统层面使用了“深色模式”。
      • no-preference - 理解为浏览器不支持设置主题色,或者浏览器支持设置主题色,但默认被设为“未设置”、“无偏好”等(也可简单理解为无默认值)。
      /* 深⾊模式 */
      @media (prefers-color-scheme: dark) {}
      
      /* 浅⾊模式 */
      @media (prefers-color-scheme: light) {}
      

      通过 DOM API 提供的 matchMedia 接口,可以做一些判断:

      if (window.matchMedia('(prefers-color-scheme)').media !== 'not all') {
        console.log('🎉 Dark mode is supported')
      }
      
      if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
        console.log('Dark mode is on.')
      }
      
      if (window.matchMedia('(prefers-color-scheme: light)').matches) {
        console.log('Light mode is on.')
      }
      

      想要监听浅色/深色模式的切换,可以这样做:

      const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
      const lightModeMediaQuery = window.matchMedia('(prefers-color-scheme: light)')
      
      darkModeMediaQuery.addListener(e => {
        const darkModeStatus = e.matches
        console.log(`Dark mode is ${darkModeStatus ? 'on' : 'off'}.`)
      })
      
      lightModeMediaQuery.addListener(e => {
        const lightModeStatus = e.matches
        console.log(`Light mode is ${lightModeStatus ? 'on' : 'off'}.`)
      })
      

      结合 CSS 变量就可以很好地去适配网站的深色模式了。举个简单示例:

      <!DOCTYPE html>
      <html lang="en">
        <head>
          <meta charset="UTF-8" />
          <meta http-equiv="X-UA-Compatible" content="IE=edge" />
          <meta name="viewport" content="width=device-width, initial-scale=1.0" />
          <title>Document</title>
          <link rel="stylesheet" href="theme.css" />
        </head>
        <body>
          <div class="container">
            <article>
              <h1>蒹葭</h1>
              <p>蒹葭苍苍,白露为霜。所谓伊人,在水一方。溯洄从之,道阻且长。溯游从之,宛在水中央。</p>
              <p>蒹葭萋萋,白露未晞。所谓伊人,在水之湄。溯洄从之,道阻且跻。溯游从之,宛在水中坻。</p>
              <p>蒹葭采采,白露未已。所谓伊人,在水之涘。溯洄从之,道阻且右。溯游从之,宛在水中沚。</p>
              <p></p>
            </article>
          </div>
        </body>
      </html>
      
      /* theme.css */
      :root {
        --theme-bg: #fff;
        --theme-color: #000;
      }
      
      @media screen and (prefers-color-scheme: dark) {
        :root {
          --theme-bg: #000;
          --theme-color: #fff;
        }
      }
      
      body {
        color: var(--theme-color);
        background: var(--theme-bg);
      }
      

      以上示例会自适应系统主题色,如果你想交由用户决定,去掉示例中的媒体查询,然后通过 JavaScript 去动态设置 CSS 变量即可。另外,可以看下张鑫旭大佬这篇文章:几行 CSS 让整站支持深色模式的探索与拓展

      兼容性如下,看着还不错:

      私认为深色模式的适配难点并不在实现上,通过本文的学习,可以说是没有难度对吧,难点应该在于设计师要考虑全站的交互样式,并制定出深浅模式下的各级色调等。

      推荐看下这篇文章:prefers-color-scheme: Hello darkness, my old friend

      未完待续...

      ]]>
      <![CDATA[关于 Safari 100vh 的问题与解决方案]]> https://github.com/tofrankie/blog/issues/169 https://github.com/tofrankie/blog/issues/169 Sun, 26 Feb 2023 07:46:15 GMT 配图源自 Freepik

      一、背景

      最近在做一个移动端的 H5 项目,遇到了一个「]]> 配图源自 Freepik

      一、背景

      最近在做一个移动端的 H5 项目,遇到了一个「有趣」的问题。假设有一页面布局如下:

      下方 50px 悬浮于底部,采用 fixed 布局,示例如下:

      <div class="container">
        <!-- height: 100vh - 50px -->
        <div class="page"></div>
        <!-- fixed bottom, height: 50px -->
        <div class="tabbar">TabBar</div>
      </div>
      <script>
        window.onload = function () {
          const arr = new Array(100).fill(0).map((_, index) => index + 1)
          const pageEl = document.querySelector('.page')
          const listEl = document.createElement('div')
      
          arr.forEach(item => {
            const itemElement = document.createElement('div')
            itemElement.innerText = item
            itemElement.className = 'list'
            listEl.appendChild(itemElement)
          })
      
          pageEl.appendChild(listEl)
        }
      </script>
      

      完整示例请看 CodeSandbox

      测试下来看似乎没问题,可当你使用 iPhone 的 Safari 浏览器打开此页面时,就会出现如下情况:

      截图中已滑动至页面最底部,然而 100 被 TabBar 部分挡住了(其他浏览器均能正常展示出来)。

      二、原因

      我们知道,vhvw 都是 CSS 中的一种相对长度单位,1vh 表示 viewport 高度的 1%(vm 同理)。简单来讲,viewport 基本上是指当前文档的可见部分,因此 100vh 表示可见文档的最大高度。

      可事实真是如此吗?

      在 Safari 浏览器中,100vh 如下图所示(出自):

      按上面所说,100vh 不应该是 viewport 可视区域的全部高度,为什么右图的高度会超出的呢?

      做个简图区分一下吧:

      所以,这就是为什么在 Safari 会被挡住一部分的原因。

      吐槽一下,Safari 会是现代化的 IE 浏览器?

      三、寻根问底

      是 bug 还是故意为之?

      可以详细地看下这篇文章:Viewport height is taller than the visible part of the document in some mobile browsers,然后文章作者给 WebKit 提了个 bug,其中 Apple 工程师 Benjamin Poulain 的回答如下:

      This is completely intentional. It took quite a bit of work on our part to achieve this effect. :)

      So, it's a feature, not a bug.

      然而,并不是只有 Safari 是这样做的,比如 iOS 端 Chrome 浏览器表现与 iOS 端 Safari 一致... (⊙ˍ⊙)

      四、解决方法

      尽管这并不是大多数开发者想要的,但很无奈,我们只能想办法去「修复」它,使得我们的网站在各浏览器表现一致。

      方案一:使用 -webkit-fill-available

      简单来说,-webkit-fill-available 就是自动填满剩余空间(详见)。这里使用另一个示例,来说明 -webkit-fill-available 的一些问题,如下:

      <!DOCTYPE html>
      <html>
        <head>
          <meta charset="UTF-8" />
          <meta http-equiv="X-UA-Compatible" content="IE=edge" />
          <meta name="viewport" content="width=device-width, initial-scale=1.0" />
          <title>Document</title>
        </head>
        <style>
          * {
            padding: 0;
            margin: 0;
            text-align: center;
          }
      
          body {
            min-height: 100vh;
            display: flex;
            flex-direction: column;
          }
        </style>
        <body>
          <div style="height: 100px; background: blue"></div>
          <div style="flex: 1; background: red"></div>
          <div style="height: 100px; background: green"></div>
        </body>
      </html>
      

      以上示例,分别会有蓝、红、绿三部分,按预期结果,它们应该会占满整个可视区域(完整示例请看 CodeSandbox),而 Safari 中底部绿色部分会被挡住。然后我们添加在 body 添加上 -webkit-fill-available 看看:

      body {
        min-height: 100vh;
        min-height: -webkit-fill-available;
        display: flex;
        flex-direction: column;
      }
      

      iOS 上的 Safari 中的表现是正常了,但是你会发现 Chrome 下红色区域没了,原因是 Chrome 84 起已不再支持 -webkit-fill-available,所以实际渲染如下:

      那么解决方法就是,针对 Safari 浏览器才设置 -webkit-fill-available 即可,这里利用到 @support-webkit-touch-callout,如下:

      body {
        min-height: 100vh;
        display: flex;
        flex-direction: column;
      }
      
      @supports (-webkit-touch-callout: none) {
        body {
          min-height: -webkit-fill-available;
        }
      }
      

      这样就 OK 了,Safari 和 Chrome 表现均如预期一致。这也是 postcss-100vh-fix 插件的解决方案。

      然而,以上方案并不适用于这些情况,比如:height: 90vhheight: calc(100vh - 50px),因此才有了方案二。

      方案二:通过 CSS 变量计算 1vh 所表示的实际高度

      思路:

      设置一个 CSS 变量(比如 --vh),然后通过 JavaScript 脚本动态设置 --vh 的值,然后使用时需兼容处理即可(比如,height: 100vh; height: calc(var(--vh) * 100))。

      实现如下:

      <style>
        :root {
          --vh: 1vh;
        }
      </style>
      
      <script>
        !(function (n, e) {
          function setViewHeight() {
            var windowVH = e.innerHeight / 100
            n.documentElement.style.setProperty('--vh', windowVH + 'px')
          }
          var i = 'orientationchange' in window ? 'orientationchange' : 'resize'
          n.addEventListener('DOMContentLoaded', setViewHeight)
          e.addEventListener(i, setViewHeight)
        })(document, window)
      </script>
      

      使用 vh 时,需要这样兼容处理:

      .page {
        height: calc(100vh - 50px);
        height: calc(var(--vh) * 100 - 50px);
      }
      

      有一个 react-div-100vh 库就是获取 window.innerHeight,然后将其值设置为容器高度实现的,然而这也是仅可处理 100vh 的情况。

      至于使用哪一种解决方案,视乎实际情况而定吧!

      五、新解法

      现在 CSS 支持新特性:

      • svh(Small Viewport Height):视窗最小高度(地址栏、工具栏展示时的高度)
      • lvh(Large Viewport Height):视窗最大高度(地址栏、工具栏隐藏时的高度)
      • dvh(Dynamic Viewport Height):视窗动态高度(根据地址栏、工具栏展示与隐藏动态调整的高度,也就是我们“想要”的 100vh 效果)

      看看兼容性,还是有点“新”的,生产环境还是再观望观望。

      六、References

      ]]>
      <![CDATA[影响回流、重绘的 CSS 属性有哪些?]]> https://github.com/tofrankie/blog/issues/168 https://github.com/tofrankie/blog/issues/168 Sun, 26 Feb 2023 07:44:52 GMT

      目前,比较常见的浏览器内核(渲染引擎)有:

      目前,比较常见的浏览器内核(渲染引擎)有:WebKitBlinkGeckoTridentEdgeHTML,更多请看

      • WebKit 课代表是 Safari 浏览器
      • Blink 课代表是 Chrome 浏览器(Blink 起源自 WebKit 的一个分支)
      • Gecko 课代表是 Firefox 浏览器
      • Trident 课代表是 IE 浏览器。2015 年微软推出的 Edge 浏览器其内核是 EdgeHTML。从良之后于 2020 年推出基于 Chromium 的 Edge 浏览器,使用 Blink 引擎。

      以下是两个主流浏览器内核 WebKit、Gecko 合成 DOM 的过程:

      ▼ WebKit

      ▼ Gecko

      两者整体流程基本相似,在术语方面也有所不同,比如 WebKit 中的 Layout 过程,Gecko 称为 Reflow

      提高网页性能的其中一个方式就是,减少回流(reflow)、重绘(repaint),分别对应 LayoutPainting 过程。

      比如,React Hooks 中处理副作用时,某些场景下应选择 useLayoutEffect,而不是 useEffect 的原因正是为了减少回流重绘的过程。

      「回流」也有称作「重排」的。

      什么是卡顿?

      在很长一段时间里,显示器的刷新率多数为 60Hz,即使到现在仍然是占多数。

      刷新率(RefreshRate),表示单位时间内能够绘制新图像的次数。举个例子,60Hz 的刷新率,表示显示器要在一秒内刷新 60 次图像,换句话说,一次图像的更新要在 16.67ms 内完成。这样才不会造成卡顿。如果超出这个时间,在视角上就会产生卡顿感。

      60Hz 的刷新率是人类不会感觉到屏幕图像闪烁的数值,由科学家验证得出。

      附一个来自网上的图:

      还是用 React 举例吧。我们知道 React 16 起采用了全新的 Fiber 架构,就是为了解决大型应用卡顿的问题。

      我们知道,浏览器是多进程的,JavaScript 是单线程的。浏览器会为每个标签(Tab)分配一个进程,每个进程由 GUI 渲染线程、JS 线程、定时器线程、网络线程、事件线程多个线程组成。最重要的一点是:GUI 渲染线程与 JS 线程是互斥的。换句话说,某个时刻它只能执行其中一个线程,等待该线程执行完毕之后,才将执行权交由另一个线程。

      那么为什么 React 15 在处理大型应用的时候会卡顿呢?

      首先 React 15 架构,由协调器(Reconciler)和渲染器(Render)组成。而 React 16 在原来的基础上新增了调度器(Scheduler),用于调度任务的优先级。

      在 React 15 的 Reconciler 中,组件的挂载(Mounting)、更新(Updating)会对应调用 mountComponentupdateComponent 方法,它们内部会执行递归操作,以更新子组件。**但是递归执行的缺点是「无法中断」。**假设 JS 线程执行递归耗时超过了 16.67ms,由于互斥期间 GUI 线程并不能执行任何的操作,等递归完并生成新的虚拟 DOM 之后,触发 DOM 等更新,此时由 GUI 线程进行处理,可能包括回流、重绘的过程,然后才完成一次页面的更新。由于无法满足刷新率的要求,就会产生卡顿感。

      因此,解决卡顿的思路就是:在每一帧的时间内,预留一些时间给 JS 线程,当预留的时间不够用时,React 中断当前任务,将线程控制权交换给浏览器,使其有时间渲染页面,等下一帧到来的时候,继续被中断的工作。

      在 React 16 新增的 Scheduler 可以使得浏览器有剩余时间的时候通知 React,而且提供了多种调度优先级,使得更高优先级的任务优先进入 Reconciler 阶段。 而 Reconciler 则是利用了 Fiber 这种架构实现了**「可中断的异步更新」**(请注意,这里的异步并不是由 setTimeout 实现的,由于精度问题,setTimeout 实际上最低延迟时间是 4ms,在这寸土寸金的一帧时间才 16.67ms,显然是不合理的)。

      因此,React 就是采用了这种思路来解决大型应用卡顿问题的。

      题外话扯完了,回到本文的主题...

      影响页面渲染性能的有什么?

      再附上一张源自网上的图:

      这几个关键点:

      • JavaScript - 使用 JavaScript 脚本来触发 DOM 的更新。
      • Style - 匹配各种选择器,并计算出哪些元素应用哪些 CSS 样式的过程。
      • Layout - 前面知道一个元素应用哪些规则之后,浏览器开始计算它要占用的空间大小以及在屏幕的位置。一个元素在空间上的变化,会同时影响其他元素的重排。
      • Paint - 绘制是填充的像素的过程,比如文本、颜色、图像、边框和阴影等。
      • Composite - 合成过程处理元素绘制到哪一层,可能是在某个元素的上层或下层。与层叠上下文有关。
      1. 如果修改了某个元素的 Layout 属性,那么浏览器会检查其他所有元素,然后“自动重排”,任何受影响的部分都需要重新绘制,然后才进行合成。
      2. 如果仅修改了「paint only」属性,比如背景图片,文字颜色、背景等,它不会影响页面布局,所以会跳过 Layout 阶段,然后执行重绘。
      3. 如果修改了一个既不影响 Layout,也无需 Paint 的属性,那么浏览器只执行 Composite 合成过程。这个开销是最小的,可以看下这篇文章

      CSS Triggers

      哪些 CSS 属性影响 Layout,哪些影响 Paint 呢?

      可以看这个网站 CSS Triggers

      其中 Change from default 表示从未设置(即 CSS 默认值)到设置为其他值,Subsequent updates 表示属性修改。

      参考链接

      ]]>
      <![CDATA[原生 JavaScript 读写 CSS样式的方法]]> https://github.com/tofrankie/blog/issues/167 https://github.com/tofrankie/blog/issues/167 Sun, 26 Feb 2023 07:40:22 GMT 好久没用原生 JS 写 CSS 样式,差点忘了,记录一下!

      1. 通过 DOM 节点对象的style对象
      var element = document.getElementById('id')
      element.]]>
                  好久没用原生 JS 写 CSS 样式,差点忘了,记录一下!

      1. 通过 DOM 节点对象的style对象
      var element = document.getElementById('id')
      element.style.color = 'red'
      
      1. 通过 Element 对象的 setAttribute()、getAttribute()、removeAttribute() 方法
      var element = document.getElementById('id')
      element.setAttribute('color', 'red')
      
      1. 通过 style 对象的 cssText 属性、setProperty()、removeProperty() 方法
      var element = document.getElementById('id')
      element.style.cssText = 'color: red'
      element.style.setProperty('color', 'red', 'important')
      element.style.removeProperty('color')
      element.style.cssText = ''  // 快速清空该规则的所有声明
      
      1. 直接添加样式表

      1)创建 style> 标签,内联样式

      var style = document.createElement('style')
      style.innerHTML = 'body{color: red}'
      document.head.appendChild(style)
      

      2)添加外部样式表

      var link = document.createElement('link')
      link.setAttribute('rel', 'stylesheet')
      link.setAttribute('type', 'text/css')
      link.setAttribute('href', 'reset-min.css')
      document.head.appendChild(link)
      
      1. 还有很多…
      ]]>
      <![CDATA[细读 CSS | 层叠上下文]]> https://github.com/tofrankie/blog/issues/166 https://github.com/tofrankie/blog/issues/166 Sun, 26 Feb 2023 07:21:48 GMT 配图源自 Freepik

      一、概念

      在 HTML 页面中,我们通常会使用 配图源自 Freepik

      一、概念

      在 HTML 页面中,我们通常会使用 marginfloatoffset 等 CSS 属性控制元素在 X 轴和 Y 轴页面中的位置,另外会使用 z-indextransform 来控制元素在 Z 轴的排列顺序。

      因此,我们可以得知 HTML 页面其实是一个三维的结构。

      了解以下这几个词:

      • 层叠上下文(Stacking Context)

      层叠上下文是一个比较抽象的概念。当一个元素拥有了一个“三维”的表现,即在 Z 轴上有一定的顺序,那么我们称该元素有一个层叠上下文。

      • 层叠等级(Stacking Level)

      有了层叠上下文之后,需要一个叫做层叠等级(层叠水平)的家伙来决定同一个层叠上下文中元素在 Z 轴上的显示顺序。

      • 层叠顺序(Stacking Order)

      层叠顺序是一种用于描述层叠等级的特定规则。

      如何区分?

      层叠上下文和层叠等级是概念,而层叠顺序是规则。通俗地讲,前两者就像是公司和领导,负责画饼和制定计划,而后者则是打工人,负责具体实施(就是搬砖)。

      二、重点

      本文术语约定

      本文中所提到的“层叠元素”和“定位元素”均按以下描述进行约定,有特殊说明除外。

      • 层叠元素:指含有层叠上下文的元素(主要用于区别不含层叠上下文的普通元素)。
      • 定位元素:指含有 position 属性,且其值不为 static 的元素。

      注意点

      • 普通元素的层叠等级优先由层叠上下文决定。
      • 层叠顺序的比较,只有在同一层叠上下文中进行对比才有意义。
      • 所有元素(普通元素、层叠元素)都存在层叠等级。请不要将层叠等级和 CSS 中的 z-index 属性混为一谈,尽管 z-index 在某些情况下会影响到层叠等级。

      层叠上下文的特性

      层叠元素有以下特性:

      • 层叠元素的层叠等级要比普通元素高。
      • 层叠上下文可以阻断元素的混合模式。
      • 层叠上下文可嵌套,内部层叠上下文及其所有子元素均受限于外部的层叠上下文。
      • 每个层叠上下文和兄弟元素独立,也就是当进行层叠变化或渲染的时候,只需要考虑后代元素即可。
      • 每个层叠上下文是自成体系的,当元素发生层叠的时候,整个元素都被认为是在父层叠上下文的层叠顺序中。

      创建层叠上下文

      以下几种方式,都会创建层叠上下文:

      • 根层叠上下文 页面的根元素 <html> 自带一个层叠上下文。我们知道,绝对定位元素通过 top/right/bottom/left 来定位时,若没有其他定位元素限制,会相对浏览器窗口定位。其中缘由,就是因为有根层叠上下文的原因。

      • 定位元素层叠上下文(传统) 当元素的 position 不为 staticz-index 不为 auto 时,该元素就会创建层叠上下文(但有些特例,下文会提到)。

      • CSS3层叠上下文(新时代) 在 CSS3 中新增了很多新属性,有一些会创建层叠上下文。

        1. z-index 值不为 autoflex 项(父元素 display:flex|inline-flex);
        2. 元素的 opacity 值不为 1
        3. 元素的 transform 值不为 none
        4. 元素 mix-blend-mode 值不为 normal
        5. 元素的 filter 值不为 none
        6. 元素的 isolation 值为 isolate
        7. will-change 指定的属性值为上面任意一个;
        8. 元素的 -webkit-overflow-scrolling 设为 touch

      特例说明

      1. 在远古神兽 IE5/6/7 浏览器下,z-index: auto 会创建层叠上下文。至于这是 Bug 还是微软故意为之,就不去考究了,反正也接触不到这些远古神兽了。在现代浏览器(包括 IE8 及以上)中,z-index: auto 都不会创建层叠上下文
      2. 在过去 position: relative | absolute | fixed 都需要配合 z-index(数值)才会创建新的层叠上下文。但是不知道什么时候起,Chrome、Firefox、Safari、Edge 等浏览器,position: fixed | sticky 元素天然层叠上下文,无需 z-index 为数值。(2022.01 亲测)

      层叠上下文与层叠顺序

      一旦普通元素具有了层叠上下文,其层叠顺序就会变高。

      分为两种情况:

      1. 如果层叠元素不依赖 z-index,那么其层叠顺序看成与 z-index: auto 一致的 z-index: 0 级别。
      2. 如果层叠元素依赖 z-index,那么其层叠顺序由 z-index 决定。

      *张鑫旭大佬原图出处,请看这里

      Q:为什么定位元素会层叠在普通元素的上面?

      当一个元素成为了定位元素,z-index 会自动生效,取其默认值 auto。根据层叠顺序图,我们可以看到 z-index: auto 的层叠顺序比 blockfloatinline/inline-block 都要高。

      层叠准则

      当元素发生层叠时,其覆盖关系遵循以下两个准则:

      1. 谁大谁上:在同一层叠上下文中,层叠等级较大的会覆盖较小的。
      2. 后来居上:在同一层叠上下文中,若层叠等级(顺序)相同时,在 DOM 文档流中处于后面的元素会覆盖前面的元素。

      请注意,上面两条准则的前提都是“处于同一层叠上下文中”,那是因为在不同层叠上下文的比较是没有意义的。

      接下来,介绍一些常见的层叠上下文、层叠顺序的案例,帮助进一步理解。

      三、position 与 z-index

      它们取值如下:

      position: static(default) | relative | absolute | fixed | sticky
      
      z-index: auto(default) | <integer> | inherit
      

      position 不为 static 时,z-index 才会生效

      其中 z-index 属性值如下:

      Value Description
      auto 将层叠水平设为 0,且不会创建新的层叠上下文(默认值)。
      inherit 将层叠水平设为与父元素相同,且不会创建新的层叠上下文。
      将层叠水平设为对应(正负)整数,并创建新的层叠上下文。

      *其实还有一些全局值(如 initialunset),但不重要,故忽略。

      请注意,在 CSS3 里当父元素设为 display: flex | inline-flex,子元素使用 z-index 时,也会使得子元素创建新的层叠上下文(请注意:创建新的层叠上下文是子元素,而不是父元素哦)。

      换句话说,z-index 不再是定位元素独享,它还可以与 flex 搞在一起做点什么。

      基础示例一 👉 源码及演示

      基础示例二 👉 源码及演示

      以上两个示例都很基础,不多说了...

      小结

      • positionstatic 时,toprightbottomleftz-index 属性均无效。
      • z-index 生效时,层叠顺序表现为:-integer < auto/0 < +integer
      • 层叠顺序而言,z-index: autoz-index: 0 表现是一致,都是 0 级别。但两者在层叠上下文领域有着根本性的差异,前者不会创建新的层叠上下文,而后者会创建新的层叠上下文。
      • 即使不显式设置 z-index,定位元素的层叠顺序也会比普通元素高。

      验证 z-index: auto 和 z-index: 0 的层叠顺序是相同的

      👉 源码及演示

      从结果上看,可以知道:

      • 层叠顺序 z-index: -1 < z-index: auto | z-index: 0 < z-index: 1,且 z-index: autoz-index: 0 层叠顺序均为 0 级别。
      • z-index: autoz-index: 0 的定位元素同时存在,发生层叠时,在 DOM 流中处于后面的元素会覆盖前面的元素(同一层叠上下文中)。

      请记住:处于同一层叠上下文中,遵循谁大谁上、后来居上的准则。

      验证 z-index: auto 不会创建层叠上下文

      👉 源码及演示

      图中示例的区别在于 inner 盒子外层的 outer 盒子,一个设为 z-index: auto,另一个设为 z-index: 0

      假设 z-index: auto 会使得元素本身创建新的层叠上下文,那么 inner1inner2 对应的参照物分别是 outer1outer2,此时 inner1 中的 z-index 值,其实是没有意义的,无论它的值是正数、负数、还是 0auto,它总会显示在 outer1 上面(inner2 同理)。同时,由于 outer1outer2 的层叠顺序是一致的,outer2 在 DOM 流后面,属于“后来居上”,因此 outer2 会覆盖 outer1

      倘若假设成立,蓝色块应覆盖在绿色块上面(效果应如示例二相同),可事实并非如此。因此,我们可以得出结论:z-index: auto 并不会创建新的层叠上下文。

      给你们看看远古神兽 IE5/6/7 浏览器下,z-index: auto 创建层叠上下文的效果。

      Internet Explorer 7

      因此,较为严谨的说法是:在现代浏览器(包括 IE8 及以上浏览器)中,z-index: auto 不会创建层叠上下文。

      关于 z-index 负值时而有效,时而无效的问题

      👉 源码及演示

      究其原因,其实很简单,前面也提到过。比较层叠顺序时,应在同一层叠上文中进行对比,否则是无意义的

      在示例一,red 盒子和 green 盒子处于同一层叠上下文(即根层叠上下文)中,且 z-index: -1 层叠顺序较低,所以可以看到 red 盒子覆盖了 green 盒子。

      在示例二,由于 red 盒子的 z-index: 0 是使得其本身创建了新的层叠上下文,所以 green 永远不会穿越 red 盒子。此时,即使 green 盒子的 z-index-9999 也总会显示在 red 盒子上层。

      在示例三中,red 盒子内多了一个 blue 盒子,并设为 z-index: 1。由于 blue 盒子和 green 盒子同属于 red 盒子创建的层叠上下文,因而,此时 z-index 就有了比较的意义了。所以,我们可以看到 green 盒子处于 blue 盒子下方,且不会穿过 red 盒子。

      再看另外一个示例 👉 源码及演示

      它的原因是一样的,因为除了 z-index 会创建层叠上下文之外,CSS3 中的 transform 也会创建新的层叠上下文哦!

      以上两个都是常见的 z-index “失效”的场景,根本原因还是,有的小伙伴不知道 transform 也会创建层叠上下文。

      当遇到 z-index 失效时,Check List 如下:

      • 检查当前元素是否为定位元素,或其父级元素是否有 display: flex | inline-flex
      • 检查它们是否处于同一层叠上下文;
      • 若前面条件都确认无疑,那说明有一些你不知道的 CSS 属性会创建层叠上下文(此时应去翻阅文档);
      • 否则,就是这垃圾浏览器没有遵循标准,偷偷地创建了新的层叠上下文(这种情况应该几乎不会出现,毕竟微软都放弃 IE 转投 Chromium 了)。

      关于 position: fixed | sticky 自带层叠上下文

      👉 源码及演示

      于 2022.01 亲测,Chrome 97、Safari 15.2、Firefox 97、Microsoft Edge 97 浏览器下面,表现均如上图。由于 position: fixed | sticky 自带层叠上下文,因此图中示例三、示例四蓝色块会覆盖绿色块。

      四、transform

      在 CSS3 中有几个与动画相关的属性:transformtransitionanimation 分别对应变换、过渡、动画。虽意义相近,但具体角色不一。它们的取值如下:

      .selector {
        transform: transform-function | none;
        transition: property duration timing-function delay;
        animation: name duration timing-function delay iteration-count direction fill-mode play-state;
      }
      

      本文重点并非谈谈它们的区别,我们接着看 transform。与之相关的 CSS 属性有这些:

      .selector {
        /* 设置元素变形方式,如旋转、缩放、倾斜、平移等 */
        transform: transform-function | none;
      
        /* 更改一个元素变形的原点 */
        transform-origin: <length> | <percentage> | center | left | right | top | bottom;
      
        /* 定义与 transform、transform-origin 这两个属性有关联的布局框 */
        transform-box: content-box | border-box | fill-box | stroke-box | view-box;
      
        /* 设置元素的子元素是位于平面中,还是 3D 空间中 */
        transform-style: flat | preserve-3d;
      }
      

      它还有兼容性属性:

      .selector {
        -webkit-transform: transform-function | none;
        -moz-transform: transform-function | none;
        -ms-transform: transform-function | none;
        -o-transform: transform-function | none;
        transform: transform-function | none;
      }
      

      其中 transform-function 的变换函数有以下这些:

      • 透视:perspective()查看示例
      • 矩阵:matrix()matrix3d()查看示例
      • 倾斜:skew()skewX()skewY()查看示例
      • 缩放:scale()scaleX()scaleY()scaleZ()scale3d()查看示例
      • 旋转:rotate()rotateX()rotateY()rotateZ()rotate3d()查看示例
      • 平移:translate()translateX()translateY()translateZ()translate3d()查看示例

      我们知道,只要 transform 不为 none 时,它也会创建层叠上下文。还有 translateZ()perspective() 两个变换函数,可以实现 z-index 类似的效果。translateZ(tz) 就是 translate3d(0, 0, tz) 简写形式。

      transform-style 与 translateZ

      从字面上看,我们可以很容易知道,translateZ 的变换是属于 3D 空间的。

      先看个简单示例

      为什么红色块的 translateZ 值更大,但却不是在更上面呢?

      原因很简单,就是因为 transform-style 的默认值是 flat,即处于 2D 平面中。如果选择平面,元素的子元素将不会有 3D 的遮挡关系。

      它的取值有:

      transform-style: flat | preserve-3d
      

      只要将其添加一个 transform-style: preserve-3d 的父元素,就能得到预期结果:红色覆盖在绿色上面。preserve-3d 使得该元素的子元素应位于 3D 空间中。

      再看示例 👉 源码及演示

      以上示例多组对比,其实还是为了说明:在不同层叠上下文中比较层叠高低是没有意义的。

      Transform 和 z-index 同时使用,会产生什么问题呢?

      看示例 👉 源码及演示

      我们来看下 iPhone 微信浏览器下,表现如何:

      微信浏览器 iOS 版

      看到差异了吗?

      原因是,苹果旗下的 Safari 浏览器在使用 3D 变换时,会忽略 z-index 的作用。

      详情戳这里:Safari 3D transform 变换 z-index 层级渲染异常的研究

      个人建议

      • 尽量不要同时使用 translateZ()z-index,iOS 与 Android 下表现有差异;
      • 在涉及 3D 变换时,应采用 transform-style: preserve-3dtransform: translateZ()transform: perspective 的方案,而不是 z-index

      未完待续...

      参考链接

      ]]>
      <![CDATA[动态设置 CSS 样式时,如何写入各浏览器兼容性样式?]]> https://github.com/tofrankie/blog/issues/165 https://github.com/tofrankie/blog/issues/165 Sun, 26 Feb 2023 07:20:36 GMT 配图源自 Freepik

      我们知道,通过原生 JS 脚本动态设置内联样式的方式有:

      <]]>
                  配图源自 Freepik

      我们知道,通过原生 JS 脚本动态设置内联样式的方式有:

      <!-- 省略一万行代码... -->
      <style>
        @keyframes slidein {
          from {
            transform: scaleX(0);
          }
          to {
            transform: scaleX(1);
          }
        }
      </style>
      
      <div id="app">关关雎鸠,在河之洲...</div>
      
      const element = document.getElementById('app')
      
      // 1️⃣ 方式一(泛指一类,下同)
      element.style.fontSize = '30px'
      
      // 2️⃣ 方式二
      element.style.setProperty('font-size', '30px')
      
      // 3️⃣ 方式三(请注意,这可能会抹掉其他 CSS 属性的哦)
      element.style.cssText = 'color: 30px'
      element.setAttribute('style', 'color: 30px')
      

      但是,这里面有一些限制...

      • 方式一:无法设置权重 !important,若带上权重,其属性值也不会生效。
      • 方式二:无法设置带 -webkit--moz--o--ms- 等各浏览器厂商实验性或非标准的 CSS 属性,如 -webkit-animation
      • 方式三:可以处理以上两种方式无法实现的功能。另外,要获取内联样式中某个 CSS 属性是否含有 !important 权重,也需借助 element.style.cssText 方可获取。

      验证

      方式一

      // 对比一
      element.style.fontSize = '30px' // 有效
      element.style.fontSize = '50px !important' // 无效,除了不会设置权重之外,50px 也不会生效哦。
      
      // 对比二
      // 假设 element 元素本身就含有 font-size: 30px !important 的内联样式
      element.style.fontSize // 输出 "30px",注意是不含权重的
      element.style.fontSize = '50px' // 这是有效的,同时权重也会丢失
      element.style.fontSize = '50px !important' // 无效
      
      // 对比三
      element.style.animation = '3s slidein' // 有效
      element.style.webkitAnimation = '3s slidein' // 有效,但是最终会变成 animation: "3s slidein",而不是 -webkit-animation: "3s slidein"
      element.style.MozAnimation = '3s slidein' // 有效,但同上
      

      小结:

      • 通过 element.style.fontSize 这一类形式读写内联样式时,都无法读取写入权重,而且需要注意的是 element.style.fontSize = '30 !important' 并不会被写入哦。
      • 通过 element.style.webkitAnimation = '3s slidein'element.style.MozAnimation = '3s slidein' 形式写入包含浏览器厂商特性的 CSS 属性时,将会被处理成标准的 CSS 属性。例如:-webkit-animation 变为 animation,跟预期结果是有出入的。
      • 另外,请注意写法。标准 CSS 属性是小驼峰形式,而带浏览器厂商特性的 CSS 属性,则可能不是小驼峰命名规则。例如:webkitXxxMozXxx 首字母大小写就不一样。

      请注意,以上示例仅列举了个别属性,但其实是泛指同一类。

      方式二

      // 对比一
      element.style.setProperty('font-size', '30px') // 有效
      element.style.setProperty('font-size', '30px', 'important') // 有效(可设置权重,但请注意是不含 ! 的)
      
      // 对比二
      element.style.setProperty('-webkit-animation', '3s slidein') // 有效,可会变为 animation。同方式一
      element.style.setProperty('-moz-animation', '3s slidein') // 有效,可会变为 animation。同方式一
      

      小结:

      • 可通过 CSSStyleDeclaration.setProperty(propertyName, value, priority) 方法,并传递 important(请注意是不含 ! 的) 给 priority 参数,来设置 CSS 优先级。

      方式三

      这种方式,除了可以实现以上功能之外,最重要的是,它可以设置 -webkit--moz--o--ms- 等实验性或非标准的 CSS 属性。

      element.style.cssText = 'font-size: 30px; -webkit-animation: 3s slidein' // 有效
      element.setAttribute('style', 'font-size: 30px; -webkit-animation: 3s slidein') // 有效
      

      ⚠️ 尽管以上两种方式都能实现,但注意可能会抹掉当前元素的其他 CSS 属性,可利用类型以下的方式处理。

      const { cssText } = element.style
      element.style.cssText = `${cssText} font-size: 30px; color: #f00`
      

      2023-03-21 更新

      上述方式三中通过 element.style.cssText 方式设置带有 -webkit 等样式时,其前缀可能会被抹除。举个例子:

      element.style.cssText = '-webkit-user-select: none'
      

      在 Safari 16.3 中,可按预期设置 -webkit-user-select: none,而在 Chrome 111、Firefox 112 则会被处理成 user-select: none

      目前比较安全的做法如下:

      element.setAttribute('style', '-webkit-user-select: none')
      

      参考链接

      ]]>
      <![CDATA[CSS 图片最大边自适应]]> https://github.com/tofrankie/blog/issues/164 https://github.com/tofrankie/blog/issues/164 Sun, 26 Feb 2023 07:19:48 GMT 模拟移动端图片预览效果,使图片最大边适应屏幕宽或高。

      <div class="prev-box">
        <img class="prev-img" src="https:/]]>
                  模拟移动端图片预览效果,使图片最大边适应屏幕宽或高。

      <div class="prev-box">
        <img class="prev-img" src="https://images.unsplash.com/photo-1612998137328-15874448d686?ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1&auto=format&fit=crop&w=668&q=80" />
      </div>
      
      .prev-box {
        background: rgba(0, 0, 0, 1);
        position: fixed;
        left: 0;
        top: 0;
        width: 100%;
        height: 100%;
        z-index: 10000;
      }
      
      /* 保证最大边占满屏幕 */
      .prev-box .prev-img {
        max-width: 100%;
        max-height: 100%;
        position: absolute;
        left: 50%;
        top: 50%;
        transform: translate(-50%, -50%);
      }
      

      ▼ 竖屏图片效果 image.png

      ▼ 横屏

      ]]>
      <![CDATA[CSS white-space]]> https://github.com/tofrankie/blog/issues/163 https://github.com/tofrankie/blog/issues/163 Sun, 26 Feb 2023 07:19:09 GMT 配图源自 Freepik

      对于 white-space 属性,经常记不清楚那几个属性,然后有什么微妙的差]]> 配图源自 Freepik

      对于 white-space 属性,经常记不清楚那几个属性,然后有什么微妙的差别,所以就有了这篇文章。

      white-space

      通常用于处理元素的空白,包含空格(Space)、回车(Enter)、制表符(Tab)。

      在默认情况下,无论我们有 N 多个空格或者回车,它显示在页面上都会合并成一个。超过一行的情况下,文字会换行。

      但无奈的是,我们真实需求可能是要求各式各样的,所以默认情况显然是不满足我们所有要求的。

      • normal(默认) 所有空格、回车、制表符都合并成一个空格,文本自动换行。

      • nowrap 所有空格、回车、制表符都合并成一个空格,文本不换行。

      • pre 所有东西原样输出,文本不换行。

      • pre-wrap 所有东西原样输出,文本换行。

      • pre-line 所有空格、制表符合并成一个空格,回车不变,文本换行。

      • inherit 继承父元素(IE 不支持)

      white-space 属性 源码空格 源码换行 <br> 换行 容器边界换行
      normal 合并 忽略 换行 换行
      nowrap 合并 忽略 换行 不换行
      pre 保留 换行 换行 不换行
      pre-wrap 保留 换行 换行 换行
      pre-line 合并 换行 换行 换行

      更多细节《细谈空白符

      ]]>
      <![CDATA[input placeholder 兼容性处理]]> https://github.com/tofrankie/blog/issues/162 https://github.com/tofrankie/blog/issues/162 Sun, 26 Feb 2023 07:18:24 GMT 若要修改 <input /> 元素 placeholder 属性的样式,需要兼容多种浏览器,如下:

      /* WebKit, Blink, Edge */
      input::-webki]]>
                  若要修改 <input /> 元素 placeholder 属性的样式,需要兼容多种浏览器,如下:

      /* WebKit, Blink, Edge */
      input::-webkit-input-placeholder {
        color: #eee !important;
        font-size: 16px !important;
      }
      
      /* Mozilla Firefox 19+ */
      .input::-moz-placeholder {
        color: #eee !important;
        font-size: 16px !important;
      }
      
      /* Mozilla Firefox 4 to 18 */
      .input:-moz-placeholder {
        color: #eee !important;
        font-size: 16px !important;
      }
      
      /* Internet Explorer 10-11 */
      .input:-ms-input-placeholder {
        color: #eee !important;
        font-size: 16px !important;
      }
      

      设置不生效?

      若设置 placeholder 的样式不生效,原因可能是在某些公共样式已经对 placeholder 进行设置了,再一次设置就会无效。

      解决方法是,添加 !important 增加权重。

      ]]>
      <![CDATA[Autoprefixer 没有添加前缀?]]> https://github.com/tofrankie/blog/issues/161 https://github.com/tofrankie/blog/issues/161 Sun, 26 Feb 2023 07:16:42 GMT PostCSS 中使用 Autoprefixer]]> PostCSS 中使用 Autoprefixer 发现没有给我添加前缀,然后...

      两种解决方案:

      方案一

      无论使用 postcss.config.js 等配置文件还是直接在 webpack.config.js 中使用 Autoprefixer,都需要设置 browserslist 才会帮你添加前缀。

      // postcss.config.js
      module.exports = {
        plugins: [
          require('autoprefixer')
        ]
      }
      
      // webpack.config.js
      module.exports = {
        module: {
          rules: [
            {
              test: /\.css$/,
              use: ['style-loader', 'css-loader', {
                loader: 'postcss-loader',
                options: {
                  plugins: [
                    require('autoprefixer')
                  ],
                }
              }]
            }
          ]
        }
      }
      
      // package.json
      {
        "browserslist": [
          "last 2 versions",
          "> 1%",
          "iOS 7",
          "last 3 iOS versions"
        ]
      }
      

      或者添加配置文件 .browserslistrc

      # Browsers that we support
      
      last 2 versions
      > 1%
      iOS 7
      last 3 iOS versions
      
      2. 方案二(不推荐)

      postcss.config.js 配置文件添加 browsers 选项,但是这种方式,Autoprefixer 不提倡这种写法,会导致一些错误。

      建议使用方案一解决,否则项目构建时会有警告 ⚠️:

      Replace Autoprefixer browsers option to Browserslist config. Use browserslist key in package.json or .browserslistrc file.

      Using browsers option cause some error. Browserslist config can be used for Babel, Autoprefixer, postcss-normalize and other tools.

      If you really need to use option, rename it to overrideBrowserslist.

      Learn more at: https://github.com/browserslist/browserslist#readme https://twitter.com/browserslist

      // postcss.config.js
      module.exports = {
        plugins: [
          require('autoprefixer')({
            browsers: ['defaults', 'not ie < 11', 'last 2 versions', '> 1%', 'iOS 7', 'last 3 iOS versions']
          })
        ]
      }
      
      ]]>
      <![CDATA[针对不规则图案阴影 drop-shadow]]> https://github.com/tofrankie/blog/issues/160 https://github.com/tofrankie/blog/issues/160 Sun, 26 Feb 2023 07:15:52 GMT 背景

      最近在做微信小程序优惠券的一个需求,然后优惠券卡片展示是不规则图形且含阴影

      如下:

      因为卡片有展开交互,所以高度非固定的,所以就没用切图去弄,想着用 CSS 实现,然后一顿操作:

      .card {
        width: 100%;
        height: auto;
        margin: 24rpx 0;
        position: relative;
        border-radius: 10rpx;
        box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
        background: radial-gradient(circle at 0 142rpx, transparent 10rpx, #fff 0) top left, linear-gradient(0.25turn, #fff, #fff),
          radial-gradient(circle at 70rpx 142rpx, transparent 10rpx, #fff 0) bottom right;
        background-size: 10% 100%, 82% 100%, 10% 100%;
        background-repeat: no-repeat;
        background-position: 0 0, 50% 0, 100% 0;
      }
      

      出来的效果如下,有瑕疵,强迫症表示受不了。

      针对不规则图形,使用 filter 属性替换掉 box-shadow。(效果如图一)

      {
        filter: drop-shadow(0px 4rpx 12rpx rgba(0, 0, 0, 0.06));
      }
      

      其中 drop-shadow 参数与 box-shadow 基本一致。

      兼容性

      在使用 filter: drop-shadow 设置阴影会导致部分 iOS 设备导致花屏。

      参考链接

      ]]>
      <![CDATA[微信浏览器中 Input 标签 CSS 兼容性问题]]> https://github.com/tofrankie/blog/issues/159 https://github.com/tofrankie/blog/issues/159 Sun, 26 Feb 2023 07:15:06 GMT 经测试与个人猜测,在微信浏览器中,<input /> 标签,微信的 webview 容器应该是赋予了一些默认的属性,如标签禁用状态下 opacity 不透明度非 100% 等。

      为什么有这种猜测,因为在 Chr]]> 经测试与个人猜测,在微信浏览器中,<input /> 标签,微信的 webview 容器应该是赋予了一些默认的属性,如标签禁用状态下 opacity 不透明度非 100% 等。

      为什么有这种猜测,因为在 Chrome 调试是预期表现,而微信浏览器中则非预期结果,所以有了以上大胆的猜测,哈哈 。如有知道根本原因的,请欢迎大胆指出,谢谢。

      例如,我遇到的问题,disabled 状态下只设置了 color 之后,而且输入框的背景颜色是白色,所以导致前端页面看起来就像输入框没有任何值一样。

      解决方案一

      局部添加

      .your-input {
        color: #ababab;
        text-fill-color: #ababab;
        opacity: 1;
        -webkit-text-fill-color: #ababab;
        -webkit-opacity: 1;
      }
      

      解决方案二

      全局 input 标签覆盖

      input:disabled, textarea:disabled {
        color: #ababab;
        text-fill-color: #ababab;
        opacity: 1;
        -webkit-text-fill-color: #ababab;
        -webkit-opacity: 1;
      }
      

      其中 -webkit-text-fill-color 是用来做填充色使用的,如果有设置这个值,则 color 属性将不生效。

      ]]> <![CDATA[微信浏览器中 Input 的兼容性处理]]> https://github.com/tofrankie/blog/issues/158 https://github.com/tofrankie/blog/issues/158 Sun, 26 Feb 2023 07:14:13 GMT
    • 在 iOS 上输入框默认样式会有内阴影,无法通过 box-shadow: none 去除,可通过以下方式处理。
    • input {
        -webkit-appearance: none;
      }
      <]]>
                  
      
    • 在 iOS 上输入框默认样式会有内阴影,无法通过 box-shadow: none 去除,可通过以下方式处理。
    • input {
        -webkit-appearance: none;
      }
      
      1. 在微信浏览器中部分 iOS 设备在输入框失焦时,页面无法正常回弹,可通过在 input 失焦事件加上:
      function blurHandler() {
        setTimeout(() => {
          const scrollHeight = document.documentElement.scrollTop || document.body.scrollTop || 0
          window.scrollTo(0, Math.max(scrollHeight - 1, 0))
        }, 0)
      }
      
      ]]>
      <![CDATA[解决多行文本换行省略显示失效的问题]]> https://github.com/tofrankie/blog/issues/157 https://github.com/tofrankie/blog/issues/157 Sun, 26 Feb 2023 07:12:15 GMT 相信大家的前端工程化项目中,都会用到 autoprefixer ,它让我们无需关系要为哪些浏览器加前缀。配合 postcss 一起使用会更好。

      但同时它也会带来一些问题,例如它不会帮你添加 -webkit- 之类的 pre]]> 相信大家的前端工程化项目中,都会用到 autoprefixer ,它让我们无需关系要为哪些浏览器加前缀。配合 postcss 一起使用会更好。

      但同时它也会带来一些问题,例如它不会帮你添加 -webkit- 之类的 prefixer。虽然它会帮你增加新前缀,但也会帮你删除老式过时的代码。

      需求来了

      例如,我们有一个需求,文本最多显示两行,溢出部分使用省略号表示,要怎么做呢?

      代码实现

      心想 so easy 啦,然后叭拉一下写出来下面的代码,页面热更新好了,擦了擦眼镜,不是眼花了吧,没成功?(内心十万个马儿奔腾...)

      <div class="text">关关雎鸠,在河之洲。窈窕淑女,君子好逑。参差荇菜,左右流之。窈窕淑女,寤寐求之。求之不得,寤寐思服。悠哉悠哉,辗转反侧。参差荇菜,左右采之。窈窕淑女,琴瑟友之。参差荇菜,左右芼之。窈窕淑女,钟鼓乐之。</div>
      
      .text {
        overflow: hidden;
        text-overflow: ellipsis;
        display: -webkit-box;
        -webkit-line-clamp: 2;
        -webkit-box-orient: vertical;
      }
      

      结果

      预期表现:✅

      实际表现:❌

      找原因 Why

      为什么会这样呢?细心一看,会发现编译后的 CSS 中 -webkit-box-orient: vertical; 不见了。👇

      ▲ 编译后的 CSS

      然后就大概能猜出是什么玩意搞鬼了,不是 postcss,就是 autoprefixer 了。然后搜了一番,发现杀人凶手就是 autoprefixer,它把我的 -webkit-box-orient: vertical 吃了(上面提过,会删掉一些过时的规则)

      解决方法

      1. 在 postcss-loader 增加 autoprefixer({ remove: false })(不推荐)
      2. 只移除过时前缀,不自动增加新前缀:autoprefixer({ add: false })(非本文问题解决方案,仅扩展用)
      3. 使用 Control Comments(控制注释,块级)(有效)
      .text {
        overflow: hidden;
        text-overflow: ellipsis;
        display: -webkit-box;
        -webkit-line-clamp: 2;
        /*! autoprefixer: off */
        -webkit-box-orient: vertical;
        /*! autoprefixer: on */
      }
      
      1. 使用 Control Comments(控制注释,单行)(无效) 网上很多所使用这种方式的,但我测试无效,原因未知。如用补充,欢迎指出修正,谢谢!
      .text {
        overflow: hidden;
        text-overflow: ellipsis;
        display: -webkit-box;
        -webkit-line-clamp: 2;
        /*! autoprefixer: ignore next */
        -webkit-box-orient: vertical;
      }
      
      1. 增加内联样式 style={{WebkitBoxOrient: 'vertical'}}(有效)

      个人推荐第 3 种方式

      参考链接

      ]]>
      <![CDATA[CSS 选择器的权重]]> https://github.com/tofrankie/blog/issues/156 https://github.com/tofrankie/blog/issues/156 Sun, 26 Feb 2023 07:08:51 GMT 记录一下,有些还是很少使用到的。

      权重由高到低:!important内联样式id 选择器类选择器标签选择器通配符]]> 记录一下,有些还是很少使用到的。

      权重由高到低:!important内联样式id 选择器类选择器标签选择器通配符继承

      • id 选择器(#myid
      • 类选择器(.myclassname
      • 标签选择器(divh1p
      • 相邻选择器(h1 + p
      • 子选择器(ul > li
      • 后代选择器(li a
      • 通配符选择器(*
      • 属性选择器(a[rel="external"]
      • 伪类选择器(a:hoverli:nth-child
      • 可继承的属性:font-sizefont-familycolor
      ]]>
      <![CDATA[关于 fail can only be invoked by user TAP gesture 的记录]]> https://github.com/tofrankie/blog/issues/155 https://github.com/tofrankie/blog/issues/155 Sat, 25 Feb 2023 14:30:47 GMT 配图源自 Freepik

      相信大家开发小程序的时候都遇到过:

      {errMs]]>
                  配图源自 Freepik

      相信大家开发小程序的时候都遇到过:

      {errMsg: "requestSubscribeMessage:fail can only be invoked by user TAP gesture."}
      

      意思就是需要「用户触发」,是用户自发、主动的行为才能正常调用,很多 API 都用此限制,比如 wx.navigateToMiniprogram()wx.openSetting() 等。官方甩锅说开发者滥用接口,其实都是产品经理需求上的滥用。他们限制不了产品经理,就只能搞开发者了。

      诸如此类的问题,在微信开放社区一搜一堆,然后官方万能回复模板是:「请具体描述问题出现的流程,并提供能复现问题的简单代码片段」。

      原因可能会有这些,自行排查一下:

      1. 只能在 bindtap/catchtap 绑定的方法内调用 wx.requestSubscribeMessage()
      2. 只能在所绑定的方法内直接调用,不允许在回调函数中调用。
      3. 在执行 wx.requestSubscribeMessage() 前,不能有 await xxx 等异步操作。看到一个更离谱的是前面有 console.log() 也失败了,移步这里

      关于能不能在回调函数里使用,有些人在 wx.request()wx.showToast() 等回调里是成功的,总之很诡异~

      解决方法是,将其他逻辑后置,首先调用 wx.requestSubscribeMessage() 接口。

      相关链接:

      ]]>
      <![CDATA[wx-open-launch-weapp 样式问题]]> https://github.com/tofrankie/blog/issues/154 https://github.com/tofrankie/blog/issues/154 Sat, 25 Feb 2023 14:25:12 GMT 配图源自 Freepik

      系列文章

      • 系列文章

        背景

        上篇介绍了如何在 React 中引入 wx-open-launch-weapp。完了之后,该标签设置样式非常地蛋疼。

        由于在 <wx-open-launch-weapp> 设置样式会有很多问题,甚至不起作用,因此我是这样设计的:

        • container 为点击唤起小程序的区域(相对定位)
        • content 则是该区域的展示内容,wx-open-launch-weapp 则是占满唤起区域(绝对定位)。
        <div class="container">
          <div class="content">页面内容</div>
          <wx-open-launch-weapp>省略了一部分代码</wx-open-launch-weapp>
        </div>
        
        .container {
          position: relative;
          margin: 30px;
          height: 182px;
        }
        
        .content {
          width: 100%;
          height: 100%;
        }
        

        简化版,完整示例请看文末。

        为什么要这样设计?后面的方案会给出答案。

        方案一

        当前需求,由于我的 content 部分是一张图片,因此我的第一个想法是这样的。

        为了方便对比,添加了背景色。其中紫色部分为 <wx-open-launch-weapp> 区域,粉红部分为 <script type="text/wxtag-template"> 区域。

        <div class="container">
          <wx-open-launch-weapp
            username="gh_xxxxxxxx"
            path="pages/index/index.html"
            style={{ width: '100%', height: '100%', opacity: 0.3, background: 'blue' }}
          >
            <script type="text/wxtag-template">
              <div style={{ opacity: 0.3, background: 'red' }} />
            </script>
          </wx-open-launch-weapp>
        </div>
        
        .container {
          margin: 30px;
          height: 182px;
          background-image: url(../../../images/banner-movecar.png);
          background-repeat: no-repeat;
          background-position: center center;
          background-size: 100% 100%;
        }
        

        <wx-open-launch-weapp> 宽高设为 100%,我们先看下效果:

        此时只出现了紫色部分,且紫色部分点击也没有任何效果,不能唤起小程序

        我想是不是 <script type="text/wxtag-template"> 未设置宽高的问题,将其设置为 100% 之后,效果一样均无效。

        方案二

        方案一流产之后,想到会不会是 100% 不生效,于是想着将宽高设置为具体值。如下:

        <div class="container">
          <wx-open-launch-weapp
            username="gh_xxxxxxxx"
            path="pages/index/index.html"
            style={{ width: '6.9rem', height: '1.82rem', opacity: 0.3, background: 'blue' }}
          >
            <script type="text/wxtag-template">
              <div style={{ width: '100%', height: '100%', opacity: 0.3, background: 'red' }} />
            </script>
          </wx-open-launch-weapp>
        </div>
        

        尽管 <wx-open-launch-weapp> 占满 container 的宽度,但高度...

        接着尝试设为 {{ width: '6.9rem', height: '100%' }},效果完全一致,高度仍无法占满 container 的高度。

        PS:项目设置 100px 对应的是 1rem

        我又想是不是 rem 单位问题,然后我又改为 {{ width: '690px', height: '182px' }} 看看有什么不一样,但高度仍然如上图一样,可宽度倒是有变化。

        多次调整宽高及其单位后发现:宽度可控,可高度始终如一

        无奈.jpg

        方案三

        到这里想吐了,我想着先解决 <wx-open-launch-weapp> 占满 container 的问题,暂时忽略 <script type="text/wxtag-template"> 的问题。

        既然方案二尝试了各种可能性,无论怎么设置宽高仍不尽人意。于是采用绝对布局看看:

        此前设置了背景色来区分,将 <script type="text/wxtag-template"> 区域宽度暂时设为 90% 以便于对比。

        <div class="container">
          <wx-open-launch-weapp
            username="gh_xxxxxxxx"
            path="pages/index/index.html"
            style={{ position: 'absolute', width: '100%', height: '100%', opacity: 0.3, background: 'blue' }}
          >
            <script type="text/wxtag-template">
              <div style={{ width: '90%', height: '100%', opacity: 0.3, background: 'red' }} />
            </script>
          </wx-open-launch-weapp>
        </div>
        
        .container {
          position: relative
          /* 其他不变 */
        }
        

        好像看到希望了,<wx-open-launch-weapp> 已占满 container 了。

        <script type="text/wxtag-template"> 的区域仍然没有展示出来,那我是不也要设为绝对布局呢,试试看:

        <div class="container">
          <wx-open-launch-weapp
            username="gh_xxxxxxxx"
            path="pages/index/index.html"
            style={{ position: 'absolute', width: '100%', height: '100%', opacity: 0.3, background: 'blue' }}
          >
            <script type="text/wxtag-template">
              <div style={{ position: 'absolute', width: '90%', height: '100%', opacity: 0.3, background: 'red' }} />
            </script>
          </wx-open-launch-weapp>
        </div>
        

        效果如下:

        PS:注意这里宽度是没问题的,写成 100% 就能横向占满。

        好像快成功了,高度还是不对。其中紫色部分属于 <wx-open-launch-weapp>,而粉红部分属于 <script type="text/wxtag-template">。所以点击粉红区域可以正常唤起小程序了。

        细心的同学可能发现了,缺的那部分高度跟未设置布局时的高度是一样的,为什么会这样,我也没找到原因。有知道的同学可以告诉我哦,谢谢。

        若将 <script type="text/wxtag-template"> 设为 relative 布局,我试了,发现是不行的。

        然后,又想到将 top 设为 0,发现可以了。

        为了兼容性,于是我谨慎地将 topleft 均设为 0

        到这里,感觉可以收尾了。

        最终版

        上文是这样设计的:

        • container 为点击唤起小程序的区域(相对定位)
        • content 则是该区域的展示内容,wx-open-launch-weapp 则是占满唤起区域(绝对定位)。

        但由于前面宽高问题,就那么难搞了,我想把页面元素与唤起小程序的区域分开来,是不是省心很多。

        完整示例:

        import React, { useState } from 'react'
        import style from './index.scss'
        
        export default function Demo() {
          return (
            <div className={style.container}>
              <div className={style.content}>
                {/* 这里写页面内容 */}
              </div>
              <wx-open-launch-weapp
                username="gh_xxxxxxxx"
                path="pages/index/index.html"
                style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
              >
                <script type="text/wxtag-template">
                  {/* 这里唤起小程序的点按区域 */}
                  <div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', opacity: 0 }} />
                </script>
              </wx-open-launch-weapp>
            </div>
          )
        }
        
        // index.scss
        .container {
          position: relative;
          margin: 30px;
          height: 182px;
        }
        
        .content {
          width: 100%;
          height: 100%;
          background-image: url(../../../images/banner-movecar.png);
          background-repeat: no-repeat;
          background-position: center center;
          background-size: 100% 100%;
        }
        

        吐了三斤血,庆幸的是可以愉快地唤起小程序了。

        The end.

        ]]> <![CDATA[关于 React 中使用 wx-open-launch-weapp 唤起微信小程序]]> https://github.com/tofrankie/blog/issues/153 https://github.com/tofrankie/blog/issues/153 Sat, 25 Feb 2023 14:18:58 GMT 配图源自 Freepik

        系列文章

        • 系列文章

          背景

          最近在做中秋国庆活动,其中一个需求是在 H5 页面中唤起微信小程序。

          前不久,微信 JSSDK 开放了该接口 wx-open-launch-weapp ,仅支持微信浏览器内唤起小程序。官方文档

          记录一下踩坑的过程。

          重要前提

          [!WARNING] 必须满足以下条件,否则渲染不出来。

          • 已认证的服务号:服务号绑定“JS接口安全域名”下的网页可使用此标签跳转任意合法合规的小程序。

          • 已认证的非个人主体的小程序:使用小程序云开发的静态网站托管绑定的域名下的网页,可以使用此标签跳转任意合法合规的小程序。

          这里踩坑了。我一直以为生产环境和测试环境都是同一个公众号(服务号)。后来问了后端才知道,测试环境是一个私人公众号(订阅号),所以在测试环境捣鼓了半天也没弄出来! 😔

          版本要求

          要真机上才有效果,微信开发工具是看不到的!

          • 微信 JS-SDK 版本:1.6.0 及以上
          • 微信版本:7.0.12 及以上
          • 系统版本:iOS 10.3 及以上、Android 5.0 及以上

          wx.config

          wx.config({
            debug: true, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印
            appId: '', // 必填,公众号的唯一标识
            timestamp: '', // 必填,生成签名的时间戳
            nonceStr: '', // 必填,生成签名的随机串
            signature: '',// 必填,签名
            jsApiList: [], // 必填,需要使用的JS接口列表
            openTagList: [ 'wx-open-launch-weapp' ] // 可选,需要使用的开放标签列表
          })
          

          我看过某帖说,即使没用到 jsApiList,也要显式配置 jsApiList: [],这样 wx.config() 才能生效。但我项目本身就有使用它,所以我没有去验证。

          可通过指定 debug: true 来验证 wx.config() 是否成功。

          只有配置成功了,后续的功能才能实现。

          wx-open-launch-weapp

          提一嘴,别弄错标签了,长得有点像!

          • <wx-open-launch-app> 打开 APP 标签
          • <wx-open-launch-weapp> 打开微信小程序标签

          [!NOTE] 官方文档有更新,支持更多属性,详见这里

          属性:

          • username(必填):所需跳转的小程序原始 id。是以 gh_ 开头的原始 id,非以 wx 开头的小程序 APPID。

          • path(非必填):所需跳转的小程序内页面路径及参数。页面路径必须添加.html后缀,比如pages/index/index.html

          以下是使用 React 的写法,其他的框架或库我没特意去写过,可以参考 官方文档

          但看微信文档要带一双慧眼,初期官方示例似乎有语法错误,现在似乎修复了。

          React 中不支持直接写 <template /> 标签,需要使用 <script type="text/wxtag-template" /> 替换。或考虑使用 dangerouslySetInnerHTML

          <div style={{ position: 'relative', width: '6.3rem', height: '2.46rem' }}>
            <div>唤起小程序的页面元素,如按钮等</div>
            <wx-open-launch-weapp
              id="launch-btn"
              username="gh_****"
              path="/pages/index/index.html"
              style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
            >
              <script type="text/wxtag-template">
                <div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', opacity: 0 }} />
              </script>
            </wx-open-launch-weapp>
          </div>
          

          若使用 TS 遇到

          Property 'wx-open-launch-weapp' does not exist on type 'JSX.IntrinsicElements'.
          

          可添加类型声明:

          declare global {
            namespace JSX {
              interface IntrinsicElements {
                'wx-open-launch-weapp': React.DetailedHTMLProps<
                  React.HTMLAttributes<HTMLElement>,
                  HTMLElement
                > & {
                  username: string
                  path?: string
                }
              }
            }
          }
          

          参考链接

          ]]> <![CDATA[小程序启动参数相关问题]]> https://github.com/tofrankie/blog/issues/152 https://github.com/tofrankie/blog/issues/152 Sat, 25 Feb 2023 14:15:50 GMT 一、支付宝小程序

          相关链接:

          // scheme 链接
          alipays://platformapi/startapp?appId=[appId]&page=[pagePath]&query=[params]
          
          参数 描述 示例
          appId 要跳转的目标小程序 appId 20170713077xxxxx
          pagePath 要跳转到目标小程序的具体 page 页面,该值等于 app.json 里面的配置值;如果不带 page 字段,默认跳转到小程序首页。

          注意:如 pagePath 需要带参数,也要进行 UrlEncode 处理。
          pages/index/index
          query 表示从外部 APP 携带的参数透传到目标小程序;如果不需要携带参数给小程序,可以不带该参数。

          query:启动参数,内容按照格式为:参数名=参数值&参数名=参数值

          注意:query 携带的启动参数必须进行 UrlEncode 否则只能获取第一个参数!
          xx%3Dxx

          1. 应用逻辑

          每次通过 scheme 调用,前端表现是重新触发 onLaunchonShow,都会传参给 app.jsonLaunchonShow,基础页面会重新触发 onLoadonShow 方法。

          在保活期间(5分钟),比如锁屏之后,会重新触发 onShow 方法,但是却没法获取参数,也就是传参 scheme 只会在调用的时候触发一次,再次启动只是触发 onShow 不会传参。只能前端在 onShow 里做相应的业务逻辑。

          2. 页面的逻辑

          当小程序用 scheme 从后台唤起的时候,其实相当于重新被打开了 onLoad,还有 onShow 都会被触发。

          在保活期间(5分钟)被重新唤起的时候,就只会触发 onShow

          3. 小程序通过 scheme 跳转如何获取启动参数

          在小程序 app.jsonLaunchonShow 进行获取启动参数。

          如果冷启动,则会在 onLaunch(options) 中获得参数; 如果为热启动,则会在 onShow(options) 中获得参数。建议冷启动中获取不了的时候,再尝试在 onShow 中获取,若还是获取不了,则可判定为没有拿到该参数。

          4. 其他

          my.switchTab()my.navigateBack() 不支持带参跳转。

          二、微信小程序

          大同小异,后续补上...

          ]]>
          <![CDATA[微信小程序转支付宝小程序]]> https://github.com/tofrankie/blog/issues/151 https://github.com/tofrankie/blog/issues/151 Sat, 25 Feb 2023 14:06:04 GMT 社区官方列出的常见问题:小程序常见问题汇总

          框架相关

          1. 页面标题:nav]]> 社区官方列出的常见问题:小程序常见问题汇总

            框架相关

            1. 页面标题:navigationBarTitleTextdefaultTitle

            组件

            1. wx.showModal,在支付宝小程序上拆分为 my.alertmy.confirm交互反馈
            2. canvas:

            API

            1. 微信支付:wx.request()my.httpRequest(),二者参数不一致。

            2. 缓存: wx.setStorageSync(string key, any data)my.setStorageSync({key: key, data: value})getStorageSyncremoveStorage 也类似。

            3. wx.showToast 关于 loading 的要改成:my.showLoadingmy.showToast() 内容属性,微信小程序是 title,支付宝小程序是 content,且没有mask 属性。

            4. <form> 组件事件:bindreset → onResetbindsubmit → onSubmit

            5. <input>textarea 组件事件:bindinput → onInputbindblur → onBlurbindfocus → onFocus

            6. <canvas> 组件事件:onTouchstart → onTouchStartonTouchmove → onTouchMoveonTouchend → onTouchEnd;组件属性:canvas-id → id

            7. <div><span> 标签在支付宝小程序都不支持,改成 <view><text> 标签即可。

            8. 拨打电话 微信:wx.makePhoneCall({ phoneNumber: '020-11183' }) 支付宝:my.makePhoneCall({ number: '020-11183' })

            9. my.alert(object) 没有“取消”按钮,以及“确定”按钮的键为:buttonText

            10. 支付宝小程序中,如果标签中写了两个或以上 class 属性,只能读取第一个,可能是 bug。

            11. (请忽略,跟支付宝小程序无关)一些字段改变: wxChannel → aliChannel updateWxUserChannel → updateAliUserChannel

            12. <checkbox> 组件的改变事件属性为:onChange="onChangeFn"

            13. my.getImageInfo 有兼容性的问题,需要做处理。在联想手机上有问题,可能是因为支付宝 APP 版本过低的原因。

            14. 在支付宝小程序 my.uploadFile 接口中,不需要把头部信息设为 header: { "Content-Type": "multipart/form-data" },这个可能导致个别机型接口返回数据错误,导致无法正常显示图片。而微信小程序 wx.uploadFile 中,如果发起 POST 请求,则需要把头部信息设成上面那样。

            15. this.setData() 方法,从 1.7.0 版本开始,setData 可以接受传递一个回调函数,该回调函数会在页面渲染之后执行。使用 my.canIUse('page.setData.callback')做兼容性处理。

            16. 基础库版本分布如下:

            1. my.httpRequest API 接口失败返回的错误码描述有误,官方已确认,待修正!my.httpRequest

            2. <picker> 中只能含有一个子元素。若多个元素,需要用 <view> 括起来,否则不显示。

            3. (请忽略,非支付宝 api )去掉函数 compareAppVersionToTargetcompareWxVersion

            4. 多个空格的问题:

            <!-- 微信小程序支持,但低版本的支付宝客户端不兼容这种写法 -->
            <text decode="{{true}}" space="nbsp">&nbsp;</text>
            
            <!-- 兼容低版本支付宝客户端的写法 -->
            <text style="white-space: pre-wrap">{{' '}}</text>
            

            CSS3 为 white-space 新增了pre-linepre-wrap 属性,前者会将多个空格合成一个,后者会保留所有空格。详情可以看一下这篇文章:white-space 中 pre、pre-line、pre-wrap 的区别

            1. 文件下载的相关接口,成功回调里的路径:res.tempFilePath → res.apFilePath

            2. 适配 iPhone X 导航的需要修改,支付宝里 my.getSystemInfo 获取的 res.model 值不一样。

            3. 删除地址的弹窗:wx.alert() 要改成 my.confirm()

            ]]>
            <![CDATA[记录前端遇到的坑,含小程序!]]> https://github.com/tofrankie/blog/issues/150 https://github.com/tofrankie/blog/issues/150 Sat, 25 Feb 2023 13:58:08 GMT

            本文最后更新于 2020-09-04

      1. react 项目无法(热)更新,是编译路径找不到。
      2. 在绝对布局中,当 left0 时,如果不写,在部分机型的浏览器会出现]]>

        本文最后更新于 2020-09-04

    1. react 项目无法(热)更新,是编译路径找不到。
    2. 在绝对布局中,当 left0 时,如果不写,在部分机型的浏览器会出现错位。
    div {
      position: absolute;
      left: 0;  /* 加上最好 */
      top: 10px
    }
    
    1. React 项目中,一些纯文本页面,如果要设置整个 HTML 页面背景颜色,可以把他设为 fixed 布局,再设置 widthheight100%,因为 fixed 布局参考对象是 body 的宽度。
    /* css 样式 */
    div {
      width: 100%;
      height: 100%;
      position: fixed;
      left: 0;
      bottom: 0;
      overflow-y: scroll;
    }
    
    1. 在 CSS3 标准中,伪类使用单冒号 :,而伪元素使用双冒号 ::。在此之前的标准,他俩都使用单冒号。需要注意的是,双冒号的写法在低版本的IE浏览器中兼容性有问题,所以一些开发者为了保证兼容性,伪类和伪元素都用单冒号的写法。

    2. rpx 是微信小程序的 CSS 尺寸单位,小程序把不同的设备统一规定屏幕宽度为 750rpxrpx 可以根据设备屏幕的真实宽度进行自适应。常见设备的转化关系(小程序开发文档):

    设备 rpx 换算 px (屏幕宽度/750) px 换算 rpx (750/屏幕宽度)
    iPhone 5 1rpx = 0.42px 1px = 2.34rpx
    iPhone 6 1rpx = 0.5px 1px = 2rpx
    iPhone 6 Plus 1rpx = 0.552px 1px = 1.81prx

    说明一下,iPhone 6/7 的屏幕宽度是 375px,意外着 1px = 2rpx。很多UI设计师以 iPhone6/7 作为视觉稿。(插句话,iPhone 6/7 的物理像素是 750 个,即用 2 个像素表示网页中的 1px,所以在以前 iPhone 比一些普通设备显得更加细腻就是这个原因。1px = 2rpx = 2 个物理像素。如果觉得混乱的可以忽略这句!)

    1. 小程序 rem(root em)单位,规定屏幕宽度为 20rem1rem = (750/20)rpx

    2. 鼠标形状

    /* css 样式*/
    div {
      cursor: default;  /* 默认,箭头 */
      cursor: pointer;  /* 手指 */
    }
    
    1. npm(nodejs package manager),nodejs 包管理器; --save 的目的是将项目对该包的依赖写入到 package.json 文件中。

    2. box-shadow,其中 IE6 和 IE7 应该是不支持 box-shadow 属性。

    /* css 样式*/
    div {
      -moz-box-shadow:0 4px 4px rgba(0, 0, 0, 0.4);  /* 不需要设置透明度的,直接使用颜色值 */
      -webkit-box-shadow:0 4px 4px rgba(0, 0, 0, 0.4); 
      box-shadow:0 4px 4px rgba(0, 0, 0, 0.4); 
    }
    
    1. CSS3 边框
    /* css样式 */
    div {
      border: 1px solid #eee;
      box-sizing: border-box;  /* 内边框,默认 */
      box-sizing: content-box;  /* 外边框 */
    }
    
    1. 在微信小程序中 <map><canvas><video><textarea> 是由客户端创建的原生组件,原生组件的层级是最高的,所以页面中的其他组件无论设置 z-index 为多少,都无法盖在原生组件上。 原生组件暂时还无法放在 <scroll-view> 上,也无法对原生组件设置 css 动画。(官方解释)<map><video><canvas><textarea> 是原生组件,层级位于 webview 之上。 在原生控件 <cover-view> 作为父容器时,不能使用其他控件嵌套作为子元素,只能使用 cover 类的控件,例如:<cover-view><cover-image>

    2. 不同数据类型的布尔运算(详细说明:点击这里if(a)afalse0undefinednullNaN空字符串 时都返回 false

    数据类型 规律 备注
    Object 都为 true
    Number 只有 0NaNfalse
    String 只有空字符串('')为 false 空格字符串(' ')为 true
    Function 都为 true
    null, undefined 都为 false
    1. 小程序 <scroll-view> 中的 bindscrolltolower 方法失效的原因:需要在 app.wxss 中设置 page 的高度为 100%,并在 <scroll-view> 组件中设置高度为 100%
    // app.wxss
    page {
      height: 100%
    }
    
    // scroll-view 组件
    <scroll-view scroll-y="true" style="height:100%" bindscrolltolower="scrollLower"></scroll-view>
    
    1. <input> 组件,在手写输入的状态下,可能不会触发 bindinput 事件,为了确保获取的 value 值正确,可以在 bindblur 事件中通过 e.detail.value 获取最新的输入值。

    2. wx.scanCode API 调用扫一扫,扫码成功或者返回的时候,在真机上会调用 onShow 生命周期。如果在在 onShow 中读取缓存数据,需要加个判断,把新值赋过去

    3. 文本不换行,溢出部分用省略号显示:

    .text {
      width: 100px;    /* 必须要设置宽度 */
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
      display: inline-block;
    }
    
    1. 善用 !! 将变量转化为布尔类型。除了false0nullundefinedNaN空字符串返回 false,其余的都返回 true。(空格字符串也是返回true)。

    2. 微信小程序:当项目上传或者预览编译的时候出现无法使用代码保护功能的情况,是因为当前项目勾选了上传时进行代码保护的选项,而且项目本身引用了第三方库,才导致出现这样的情况。解决办法是:关闭代码保护。

    3. 微信小程序在 <scroll-view /> 子元素使用 <textarea /> 原生组件可能会报错:如:insertTextArea:fail 等。

    4. 支付宝小程序关于 <cover-view /><cover-image /> 的问题:

    • <cover-view> 默认白色背景,虽然官方回答是不能更改,但可通过再 app.acss 里面添加样式修改,亲测有效:
    cover-view {
      background-color: rgba(0, 0, 0, 0);
    }
    
    • <cover-image>border-radius 属性部分机型无效,官方回答可修改,但无效(已知华为 P30 不生效,iPhone 都有效),可能是支付宝版本问题(10.1.26)
    1. 支付宝小程序开发工具 Mac 版同时开启多个项目的方法:
    $ open -n /Applications/小程序开发者工具.app
    
    1. 微信小程序 <input /> 不支持自定义 font-family详见
    ]]>
    <![CDATA[微信小程序 Canvas 绘图存图]]> https://github.com/tofrankie/blog/issues/149 https://github.com/tofrankie/blog/issues/149 Sat, 25 Feb 2023 13:57:41 GMT 使用 Canvas 画图,并保存至相册的过程,以下是踩过的坑:

    1. canvas 标签不可以设置为 display: none;若不想显示到页面,可以采用 fixed 布局视觉隐藏;
    2. context.draw(boolea]]> 使用 Canvas 画图,并保存至相册的过程,以下是踩过的坑:

      1. canvas 标签不可以设置为 display: none;若不想显示到页面,可以采用 fixed 布局视觉隐藏;
      2. context.draw(boolean reserve, function callback) 是异步的,wx.canvasToTempFilePath一定要写在回调里,否则会出现无法绘图的情况;
      3. wx.canvasToTempFilePath 保存图片至相册,需要用户授权,所以保存前需要检查权限。一旦用户一次拒绝授权,第二次保存时是不会有授权弹窗的,可以使用 wx.openSetting 去引导用户授权。(插一句:支付宝小程序,只要没权限,每次调用都会弹出授权弹窗)

      代码片段:canvas demo

      再放个采坑过程:关于 HTML → Canvas → Image → 保存相册 的过程。点我跳转。(不是我写的,但过程跟我相同,懒得写了)

      ▼ 效果图

      ]]>
      <![CDATA[微信小程序转为百度小程序]]> https://github.com/tofrankie/blog/issues/148 https://github.com/tofrankie/blog/issues/148 Sat, 25 Feb 2023 13:56:24 GMT 最近在做百度小程序开发需求,把原有的微信小程序快速迁移过去,从文档上看,两者相似度还是挺高的。

      现在网上有挺多的转换工具的,而我使用的是 wx2swan ,在实践过程中,转化完成度能到 80% ~]]> 最近在做百度小程序开发需求,把原有的微信小程序快速迁移过去,从文档上看,两者相似度还是挺高的。

      现在网上有挺多的转换工具的,而我使用的是 wx2swan ,在实践过程中,转化完成度能到 80% ~ 90% 之间,挺不错的。

      快速开始

      1. 全局安装
      $ npm install -g wx2swan
      
      1. 转换
      $ wx2swan input-path output-path
      # wx2swan ./projectA ./projectB
      
      1. 转化过程在 log 文件夹下查看
      2. 转换完成

      wx2swan 做了什么?

      • 工具只做了静态语法上的转换,根据一些规则去转换代码,抹平微信小程序语法和百度小程序语法上的差异,避免大家踩坑
      • 搬家工具是离线的,没有运行时框架,所以有些没法抹平的运行时 diff ,需要二次开发调整
      • 使用中的任何问题,都可以提 Issues 或者加 wx2swan 作者微信小助手:wx2swan-helper
      ]]>
      <![CDATA[购房有感:原来不仅仅要考虑首付问题...]]> https://github.com/tofrankie/blog/issues/147 https://github.com/tofrankie/blog/issues/147 Sat, 25 Feb 2023 13:55:42 GMT 配图源自 Freepik

      近两周,工作之余一直在忙购房的事情,作一个简单的有感记录。

      早在]]> 配图源自 Freepik

      近两周,工作之余一直在忙购房的事情,作一个简单的有感记录。

      早在 2021 年,家里人就让我抽空去看下房,但由于一直拖拖拖......就到 2023 年了。 从网上看房、线下看房、交定金、给首付,整个过程也才三周不到的时间,人生大事之一就草草定下了。

      现在回想起来,有点不可思议,其实只在 2 月 12 日跑了两个楼盘,对比之下,然后感觉后者还不错,就交定金了。

      由于此前资料不全,按揭、网签还没处理完,还得抽空再跑一趟。

      在这个过程中,有些东西是经历过才知道:「哦,原来如此...」

      要不要找中介?

      像我这样,其实对购房很多东西都是不了解的,所以我是有找中介的。

      你也可以直接去售楼部/楼盘营销中心去看。 中介的好处是整个购房过程,她会全程跟踪,有不懂的也可以直接问。

      但其实中介和楼盘肯定是有合作的,所以大家也要留个心眼,学会判断他们推介的好坏与否,心里要有数,小心被骗。

      然后我是在某 App 上咨询楼盘相关情况时,随意加上的中介,然后接触下来,感觉还是比较靠谱、专业的。

      至于线下看房,最好带上有购房经验的朋友同学,如果有的话。

      是不是只需要考虑首付款?

      既然考虑购房了,月供应该是在可接受范围的区间内,首付是第一道砍。

      或许你有刷到过,只需首付 5w 诸如此类的,目的是吸引更多年轻人去买房,但细想里面的利息肯定很高...

      其实更多的是,最低首付是房屋总价的 2 成或 3 成,各地政策会有所不同。我们这边的小城市是:

      • 商业贷款:首付最低 2 成。
      • 组合贷款(商业贷款 + 公积金贷款):首付最低 3 成。

      然后,很多楼盘的套路是最低 2 成首付...... 我也是去谈价格的时候才知道,组合贷首付最低要 3 成,这也是有点超预算的。

      除了首付、月供还有哪些费用呢?

      • 按揭费用(含服务费、公证费、按揭律所代收)
      • 签约费(含服务费、产权登记费、工本费、印花税等)
      • 专项维修基金(每平方米建筑面积成本价 × 比例 × 面积,各地区维修基金的比例存在一定差异)
      • 契税(建筑面积 × 税率。以首套房为例,建筑面积小于 90㎡ 税率为 1%,大于 90㎡ 税率为 1.5%;二套房无论建站面积多少税率均为 3%)

      因此,除了多少成的首付比例之外,以上这些费用都是要考虑的,所以它们也算是首付的一部分。

      关于贷款

      由于商贷和公积金贷的利率可以相差很多,因此能用公积金一定要优先选择公积金贷款,剩余部分选择商业贷款。

      公积金贷款额度

      各地政策不一样,以当地的「住房公积金管理中心官网」最新的相关政策文件为准。政策是会调整的,比如我们这边去年 10 ~ 12 月个人可贷总额由 40w 调至 50w,现在又调整回 40w 了。

      主要考虑以下这些问题(以当地政策为准):

      • 最高贷款额度是分个人、夫妻双方的,一般夫妻的额度较高;
      • 实际可贷额度按「公积金余额 × 一定的倍数」计算,且不能超过最高贷款额度;
      • 注意,「倍数」如何确定的问题,是按「累计缴存时间」还是「连续缴存时间」计算;
      • 注意,如果你是异地公积金,要清楚购房地能否使用你所缴存地的公积金进行贷款;
      • 注意,一般情况申请公积金贷款要求「连续」缴存 6 个月、1 年、甚至 2 年不等。

      以个人为例,在清远购房,用的是广州公积金,累计缴存时间超过 48 个月,但 2021 年中由于离职断缴过,目前连续缴存时间不到 24 个月。目前清远政策如下:

      1. 需「连续」足额缴存住房公积金 6 个月(含)以上;
      2. 在清远市区(清新区、清城区)购房;
      3. 个人申请住房公积金贷款最高额度为 40w,夫妻双方为 50w;
      4. 实际可贷额度(以首套房为例) a. 累计缴存时间在 24 个月(不含)以下的,可贷额度不超过公积金账户余额的 8 倍; b. 累计缴存时间在 24 个月以上,48 个月(不含)以下的,可贷额度不超过公积金账户余额的 10 倍; c. 累计缴存时间在 48 个月(含)以上的,可贷额度不超过公积金账户余额的 12 倍;
      5. 申请住房公积金贷款购买首套房普通自住住房,最底首付比例为 30%;
      6. 公积金贷款利率为 3.1%,商业贷款利率为 4.1%(LPR - 20 个浮动基点);

      这其中发生了一些事情:看房前,从朋友转发的公积金贷款额度计算的推文看,我是可以贷 12 倍的。到售楼部看房之后,无论是置业顾问、中介、还是售楼部某工作人员(应该是贷款或公积金相关的),都说只能贷 8 倍,原因是我中间断缴了,目前连续缴存时间不足 24 个月,加上我是外市的公积金,只能按「连续缴存时间」计算。

      一开始,我想政策相关的他们肯定最熟悉,就只能认了,谁让我断缴了呢!但是,在去网签的当天,将相关资料交给做按揭的律所工作人员,她说或许能贷 12 倍,具体以公积金那边审批为准。那时候还有点希望,但隔天他们就来电说只能贷 8 倍,原因是异地公积金要按「连续缴存时间」计算,我中间断缴过,所以不行。

      由于一起一落的,心里很不爽、不服气,他们口中所说跟政策文件所示并不一致。于是我去清远、广州的住房公积金官网翻阅相关政策文件,以及给清远住房公积金管理中心的客服咨询,得出的结论是:公积金可贷额度是按照「累计缴存时间」去计算倍数的,并不是「连续缴存时间」

      以上文中所示,均表明应按「累计缴存时间」去计算可贷倍数。于是,我将以上链接发给律所,然后她那边再跟公积金贷款对接人员沟通确认一下。从中间反馈的一些内容看得出来,对接人员应该是一直按「连续缴存时间」去办理的,所以她当初「理所当然」地认为我只能贷 8 倍。从律所转发的截图看,对接的工作人员说是城区的审单人员就是这样跟他们说的。可能是因为我把政策文件链接给她了,整得她都不自信,不敢立马下结论。她说明天再问市区的科长确认。最后是我胜利了

      回想起来,努力争取还是有用的。就这样看来,在我之前应该有部分购房者因断缴导致贷款倍数被误算的情况。

      未完待续...

      ]]>
      <![CDATA[Google Docs 代码块高亮]]> https://github.com/tofrankie/blog/issues/146 https://github.com/tofrankie/blog/issues/146 Sat, 25 Feb 2023 13:54:53 GMT 配图源自 Freepik

      现在,我更推荐使用飞书个人版,观感、使用体验更佳。<]]> 配图源自 Freepik

      现在,我更推荐使用飞书个人版,观感、使用体验更佳。

      2023.11.27 更新:

      Google Workspace 于 2022.12.14 推出了内置的代码块功能。详见 Easily format and display code in Google Docs

      但个人免费账号无法使用,所支持的语言并没有很丰富。

      使用方式:「插入 Insert - 组成要素 Building blocks - 代码块 Code block」

      代码块高亮

      如果使用 Google Docs 编写设计文档,少不了插入代码。

      但由于默认情况下,贴代码的阅读性很不好,那么 Code Blocks 插件应该是你想要的。

      安装之后,这样启动插件:

      所支持的语言、主题众多:

      格式化前后对比:

      The end.

      ]]> <![CDATA[地图 SDK 坐标系说明]]> https://github.com/tofrankie/blog/issues/145 https://github.com/tofrankie/blog/issues/145 Sat, 25 Feb 2023 13:54:31 GMT 配图源自 Freepik

      坐标系分类

      常见坐标系有三种:

      • 地]]> 配图源自 Freepik

        坐标系分类

        常见坐标系有三种:

        • 地球坐标系(WGS84):常见于 GPS 设备,Google 地图等国际标准的坐标体系
        • 火星坐标系(GCJ-02):中国国内使用的被强制加密后的坐标体系,高德坐标就属于该种坐标体系
        • 百度坐标系(BD-09):百度地图所使用的坐标体系,是在火星坐标系的基础上又进行了一次加密处理

        在不同坐标系中,同一经纬度所表示的实际位置是有偏差、不相同的,但一般各 SDK 平台都会提供坐标系转化的 API。

        • 谷歌地图使用的是 WGS84 坐标系
        • 高德地图、腾讯地图(包括微信、QQ、微信小程序 Map 组件等)使用的是 GCJ-02 坐标系
        • 百度地图采用的是 BD-09 坐标系

        需要注意的是:

        • 高德地图 SDK 在大陆、港澳使用的是 GCJ-02 坐标系,台湾省、海外地区使用的是 WGS84 坐标系。
        • 百度地图 SDK 在国内(包括港澳台)使用的是 BD09 坐标系,海外地区统一使用 WGS84 坐标系。
        • 腾讯地图 SDK 在大陆、港澳使用的是 GCJ-02 坐标系,台湾省、海外地区使用的是 WGS84 坐标。

        国内三大地图服务商在海外地区均使用的是 WGS84 坐标系,但在港澳台略有不同。

        坐标拾取器

        参考链接

        ]]>
        <![CDATA[Stylish 给网页一键换肤,还去广告!]]> https://github.com/tofrankie/blog/issues/144 https://github.com/tofrankie/blog/issues/144 Sat, 25 Feb 2023 13:53:16 GMT 一、什么是 Stylish ?

        先看效果图,Stylish 把百度首页变成了这样子:

        ]]> 一、什么是 Stylish ?

        先看效果图,Stylish 把百度首页变成了这样子:

        Stylish 是一款浏览器插件,目前适用于 Chrome、Safari、Firefox 等主流浏览器。原理是通过替换网页本身的 CSS 样式来达到网页美化的效果。

        Stylish 官网上有非常非常多的样式供用户下载,不仅能让百度、新浪微博变得极简、讨人喜欢,还能给新浪网、腾讯网、Google 、YouTube 、Facebook 、Wikipedia 等主流网站「换肤」。

        如果你懂 CSS 知识,还可以自定义样式,做一个属于自己的网页效果,也可以在已有样式的基础上进行修改完善。

        二、安装使用 Stylish 插件

        下面我将以 Chrome 浏览器为例子,其他浏览器的使用方法大同小异,不太浏览器效果也可能略有不同。

        1. 下载安装插件:前往 Chrome 应用商店下载安装。亦可去 插件网(不用翻墙)下载。

        2. 在浏览器扩展程序页面启用 Stylish 插件后,前往 Stylish 官网下载模板。

        3. 可以对已安装模板进行修改、禁用、删除、更新等。

        1. 快捷启用、禁用、修改样式。

        三、常用模板推荐

        百度轻

        安装链接

        我用上 Stylish 后不再吐槽搜索界面丑、乱、广告多了,甚至有点喜欢了。

        这套模板目前版本有一点点小瑕疵:“默认用户名颜色值为白色”。看着不顺眼,于是我改成了黑色,效果如上图。

        weibo_v6

        安装链接

        原来网页版微博也可以这么好看,全新布局,去广告,设计源自 “微博急简 WC”。效果很赞,最喜欢的样式(没有之一)。

        Flat_Zhihu v2.4.13

        安装链接

        字体大小、排版稍微调整,更合理,更好看。

        Taobao clean

        安装链接

        我只想安安静静地搜索,但是纯白色的背景,总觉得缺点什么...

        TieBa - Maverick

        安装链接

        更现代化的贴吧页、隐藏广告和无用功能。

        Google Material

        安装链接

        效果赞,看起来舒服,基于 Dribbble 设计师 Aurélien Salomon 的设计图。

        Dark is better for my eyes

        安装链接

        黑色的背景,搭配淡蓝色字体,理论上适用于所有的网站,但它不会影响浏览器的菜单。

        更多

        还有很多很多,可以去 Stylish 官网下载更多好看的模板,也可以自行 DIY!

        四、写在最后

        无论你是否懂 CSS,Stylish 都能满足大部分用户的需求。可以帮你去广告排版调整全新布局等,给你的网页换「新皮肤」。

        ]]>
        <![CDATA[Google 新标签页打开搜索结果]]> https://github.com/tofrankie/blog/issues/143 https://github.com/tofrankie/blog/issues/143 Sat, 25 Feb 2023 13:52:11 GMT 配图源自 Freepik

        背景

        使用 Google 作为搜索引擎,有个用得不顺手的地方]]> 配图源自 Freepik

        背景

        使用 Google 作为搜索引擎,有个用得不顺手的地方:默认情况下,打开搜索条目是在「当前标签内」打开的。

        我更希望在新标签页打开。

        原因

        看了下,搜索条目上的 <a> 标签没有 target="_blank" 属性,因此不会用新标签页打开链接。

        解决方法

        搜了下,有很多方法,安装 Chrome 插件之类的,但没必要...

        方法一(不推荐)

        按下 ⌘ 键(Win 为 Ctrl 键),再用鼠标点击链接,也可在新标签页中打开。可每次都要点按,太麻烦了,故不推荐。

        原理:按下 ⌘ 键,可以使得 <a> 标签产生 <a target="_blank"> 的效果。

        方法二

        建议,先登录你的 Google 账号,再进行设置,以便于同步,否则有可能关掉浏览器重新打开设置被重置。

        步骤如图:

        PS:勾选上截图中的选项,确实是在“新标签页中”打开页面,而不是“新浏览器窗口”哦。英文选项是:Open each selected result in a new browser window

        刷新页面,可以看到 <a> 标签有 target="_blank" 属性了

        The end.

        ]]>
        <![CDATA[Markdown 如何高亮反引号]]> https://github.com/tofrankie/blog/issues/142 https://github.com/tofrankie/blog/issues/142 Sat, 25 Feb 2023 13:50:55 GMT 配图源自 Freepik

        在 Markdown 语法中,通常使用「反引号」(backquote, 配图源自 Freepik

        在 Markdown 语法中,通常使用「反引号」(backquote,`)表示代码。它会产生高亮效果,语法形如 `<some code>`

        由于 Markdown 解析器中「反引号」都会被转义,那么如何显示反引号本身呢?

        反引号的 HTML 转义字符为 &#96;,但直接输入 &#96; 字符或单个反引号,它们将会显示为(`),无高亮效果。

        若需高亮效果,需在外层连续使用「两个反引号」括起来,而且最好在两边使用「空格」隔开。

        以下效果为 `Hello Markdown!`

        `` `Hello Markdown!` `` ✅ 有效
        
        `` `Hello Markdown!``` ❌ 无效,末尾的三个反引号需空格隔开
        

        以下效果为 `

        `` ` ``
        

        The end.

        ]]>
        <![CDATA[一个短小却令人惊叹的 JavaScript 代码]]> https://github.com/tofrankie/blog/issues/141 https://github.com/tofrankie/blog/issues/141 Sat, 25 Feb 2023 13:50:19 GMT 配图源自 Freepik

        简直是大呼惊叹,不得不佩服...

        
                    配图源自 Freepik

        简直是大呼惊叹,不得不佩服...

        try {
          something
        } catch (e) {
          window.location.href = `https://stackoverflow.com/search?q=[js] ${e.message}`
        }
        

        面向 Stack Overflow Debug,简直了,帖子出处,以上无营养,但值得一笑,哈哈~

        另外,可以看下这个知乎问题:有哪些短小却令人惊叹的 JavaScript 代码?

        ]]>
        <![CDATA[你真的会使用数据库的索引吗?]]> https://github.com/tofrankie/blog/issues/140 https://github.com/tofrankie/blog/issues/140 Sat, 25 Feb 2023 13:49:31 GMT 配图源自 Freepik

        转载自:

        转载自:你真的会使用数据库的索引吗?

        使用索引也很简单,然而, 会使用索引是一回事, 而深入理解索引原理又能恰到好处使用索引又是另一回事。

        一、前言

        无论是面试、还是日常工作中,或多或少都会使用或者听到别人谈论索引这个技术。

        然而很大一部份程序员对索引的了解仅限于到“加索引能使查询变快”这个概念为止。

        使用索引也很简单,然而, 会使用索引是一回事, 而深入理解索引原理又能恰到好处使用索引又是另一回事。

        这已经是两个相差甚远的技术层级了。

        二、千万级数据表索引和无索引查询效率对比

        现在有一个学生表 student,有 1000 万条数据

        未加索引,查询 class_id=2 的学生信息的耗时:SELECT \* FROM student WHERE class_id=2 花费了 3.357 秒

        加上索引,查询 class_id=2 的学生信息的耗时:SELECT \* FROM student WHERE class_id=2 花费了 0.017 秒

        1000 万条数据下,两个查询的性能差了近 200 倍!!

        这个差距是特别大的! 难怪需要加索引!!!

        三、什么是索引

        网上很多讲解索引的文章对索引的描述是这样的:

        索引就像书的目录, 通过书的目录就可以准确的定位到书籍的具体的内容。

        这句话概述的非常正确!

        但说了跟没说一样,懂的人自然懂!不懂的人感觉懂了,但还是一脸蒙的状态!

        其实想要理解索引原理,必须清楚一种数据结构:

        「平衡树」(非二叉),也就是 B Tree 或者 B+Tree

        当然, 有的数据库也使用哈希桶作用索引的数据结构 , 然而, 主流的 RDBMS 都是把平衡树当做数据表默认的索引数据结构的。

        我们平时建表的时候都会为表加上主键, 在某些关系数据库中, 如果建表时不指定主键,数据库会拒绝建表的语句执行。

        事实上, 一个加了主键的表,并不能被称之为“表”。一个没加主键的表,它的数据无序的放置在磁盘存储器上,一行一行的排列的很整齐。

        如果给表上了主键,那么表在磁盘上的存储结构就由整齐排列的结构转变成了树状结构,也就是上面说的“平衡树”结构,换句话说,就是整个表就变成了一个索引。

        没错, 再说一遍, 整个表变成了一个索引!

        也就是所谓的“聚集索引”。 这就是为什么一个表只能有一个主键, 一个表只能有一个“聚集索引”,因为主键的作用就是把“表”的数据格式转换成“树(索引)”的格式。

        未加索引时,之前执行的查询 SQL 会让数据库系统逐行的遍历整张表,对于每一行都要检查其 class_id 字段是否等于 2。因为我们要查找所有 class_id2 的员工,所以当我们发现了一条 class_id2 的记录后,并不能停止继续查找,因为可能还有 class_id 等于 2 的其他记录。

        这就意味着,对于表中的千万条记录,数据库每一条都要检查。这就是所谓的“全表扫描”(full table scan)

        而加上索引的最大作用就是加快查询速度,它能从根本上减少需要扫表的记录/行的数量。

        四、Mysql 中的索引

        在 MySQL 中, 索引有两种分类方式:逻辑分类物理分类

        按照逻辑分类,索引可分为:

        • 主键索引:一张表只能有一个主键索引,不允许重复、不允许为 NULL;

        • 唯一索引:数据列不允许重复,允许为 NULL 值,一张表可有多个唯一索引,但是一个唯一索引只能包含一列,比如身份证号码、卡号等都可以作为唯一索引;

        • 普通索引:一张表可以创建多个普通索引,一个普通索引可以包含多个字段,允许数据重复,允许 NULL 值插入;

        • 全文索引:让搜索关键词更高效的一种索引。

        按照物理分类,索引可分为:

        • 聚集索引:一般是表中的主键索引,如果表中没有显示指定主键,则会选择表中的第一个不允许为 NULL 的唯一索引,如果还是没有的话,就采用 Innodb 存储引擎为每行数据内置的 6 字节 ROWID 作为聚集索引。每张表只有一个聚集索引,因为聚集索引的键值的逻辑顺序决定了表中相应行的物理顺序。聚集索引在精确查找和范围查找方面有良好的性能表现(相比于普通索引和全表扫描),聚集索引就显得弥足珍贵,聚集索引选择还是要慎重的(一般不会让没有语义的自增 id 充当聚集索引);

        • 非聚集索引:该索引中索引的逻辑顺序与磁盘上行的物理存储顺序不同(非主键的那一列),一个表中可以拥有多个非聚集索引。

        在目前用的最多的 mysql 的 InnoDB 存储引擎中,是使用 B+Tree 索引方法来进行索引建立的。

        B+ 树索引是 B+ 树在数据库中的一种实现,是最常见也是数据库中使用最为频繁的一种索引。

        B+ 树中的 B 代表平衡(balance),而不是二叉(binary),因为 B+ 树是从最早的平衡二叉树演化而来的。先了解二叉查找树、平衡二叉树(AVLTree)和平衡多路查找树(B-Tree),B+ 树即由这些树逐步优化而来。

        具体的讲解可参考文章:MySQL 索引机制(B+Tree)

        五、索引的优缺点

        优点:

        • 索引能够提高数据检索的效率,降低数据库的 IO 成本。
        • 通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性,创建唯一索引
        • 在使用分组和排序子句进行数据检索时,同样可以显著减少查询中分组和排序的时间
        • 加速两个表之间的连接,一般是在外键上创建索引

        缺点:

        • 创建索引和维护索引要耗费时间,这种时间随着数据量的增加而增加
        • 索引需要占物理空间,除了数据表占数据空间之外,每一个索引还要占一定的物理空间,如果要建立聚簇索引,那么需要的空间就会更大
        • 当对表中的数据进行增加、删除和修改的时候,索引也要动态的维护,降低了数据的维护速度

        六、索引何时应该使用

        需创建索引的情况:

        • 主键,自动建立唯一索引
        • 频繁作为查询的条件的字段
        • 查询中与其他表关联的字段存在外键关系
        • 查询中排序的字段,排序字段若通过索引去访问将大大提高排序的速度
        • 查询中统计或者分组字段

        避免创建索引的情况:

        • 数据唯一性差的字段不要使用索引

          比如性别,只有两种可能数据。意味着索引的二叉树级别少,多是平级。这样的二叉树查找无异于全表扫描。

        • 频繁更新的字段不要使用索引

          比如登录次数,频繁变化导致索引也频繁变化,增大数据库工作量,降低效率。

        • 字段不在 where 语句出现时不要添加索引

          只有在 where 语句出现,mysql 才会去使用索引

        • 数据量少的表不要使用索引

          使用了改善也不大

        七、哪些 sql 能命中索引

        1. 前导模糊查询不能使用索引,如 name like '%涛'

        2. unioninor 可以命中索引,建议使用 in

        3. 负条件查询不能使用索引,可以优化为 in 查询,其中负条件有 !=<>not innot existsnot like

        4. 联合索引最左前缀原则,又叫最左侧查询,如果在 (a, b, c) 三个字段上建立联合索引,那么它能够加快 a | (a, b) | (a, b, c) 三组的查询速度。

        5. 建立联合查询时,区分度最高的字段在最左边

        6. 如果建立了(a,b)联合索引,就不必再单独建立 a 索引。同理,如果建立了(a,b,c)索引就不必再建立 a,(a,b) 索引

        7. 存在非等号和等号混合判断条件时,在建索引时,要把等号条件的列前置

        8. 范围列可以用到索引,但是范围列后面的列无法用到索引。

        索引最多用于一个范围列,如果查询条件中有两个范围列则无法全用到索引。范围条件有:<<=>>=between 等。

        1. 把计算放到业务层而不是数据库层。在字段上计算不能命中索引,

        2. 强制类型转换会全表扫描,如果 phone 字段是 varchar 类型,则下面的 SQL 不能命中索引。Select \* fromuser where phone=13800001234

        3. 更新十分频繁、数据区分度不高的字段上不宜建立索引。

        更新会变更 B+ 树,更新频繁的字段建立索引会大大降低数据库性能。

        “性别”这种区分度不太大的属性,建立索引是没有什么意义的,不能有效过滤数据,性能与全表扫描类似。

        一般区分度在 80%以上就可以建立索引。区分度可以使用 count(distinct(列名))/count(\*)来计算。

        1. 利用覆盖索引来进行查询操作,避免回表。

        被查询的列,数据能从索引中取得,而不是通过定位符 row-locator 再到 row 上获取,即“被查询列要被所建的索引覆盖”,这能够加速度查询。

        1. 建立索引的列不能为 null,使用 not null 约束及默认值

        2. 利用延迟关联或者子查询优化超多分页场景,

        MySQL 并不是跳过 offset 行,而是取 offset+N 行,然后放弃前 offset 行,返回 N 行,那当 offset 特别大的时候,效率非常低下,要么控制返回的总数,要么对超过特定阈值的页进行 SQL 改写。

        1. 业务上唯一特性的字段,即使是多个字段的组合,也必须建成唯一索引。

        2. 超过三个表最好不要用 join,需要 join 的字段,数据类型必须一致,多表关联查询时,保证被关联的字段需要有索引。

        3. 如果明确知道查询结果只要一条,limit 1 能够提高效率,比如验证登录的时候。

        4. Select 语句务必指明字段名称

        5. 如果排序字段没有用到索引,就尽量少排序

        6. 尽量用 union all 代替 unionunion 需要将集合合并后在进行唯一性过滤操作,这会涉及到排序,大量的 CPU 运算,加大资源消耗及延迟,当然,使用 union all 的前提条件是两个结果集没有重复数据。

        八、总结

        索引是非常重要的技术!

        但每建立一个索引,实际上都需要在硬盘上开辟一块空间用于存储这个索引所需要的数据结构(虽然表述不太准确但是是这个意思),因此不建议对太长的字段建立索引。

        而且建立的索引并不是越多越好,因为索引虽然能够提高查询效率,但是会大大得影响插入、删除和修改的效率,因为每一次数据的更新都会牵涉到对索引的修改。

        综上所述,往往在对于大量数据的插入的情况的时候,我们需要先删除掉数据表的索引,等插入完毕后重新建立索引,这样才能最大限度地保证数据库的效率!

        ]]> <![CDATA[关于微信公众号插入外部链接]]> https://github.com/tofrankie/blog/issues/139 https://github.com/tofrankie/blog/issues/139 Sat, 25 Feb 2023 13:48:54 GMT 2022 北京冬奥会开幕式

        一、公众号自定义菜单

        已认证的公众号,即可插入外链。 2022 北京冬奥会开幕式

        一、公众号自定义菜单

        已认证的公众号,即可插入外链。

        二、微信图文外链

        分为订阅号服务号两类:

        • 服务号 + 已认证 + 开通微信支付
        • 订阅号 + 已认证 + 开通微信支付

        满足以上添加之一的公众号,均可在微信图文正文插入外部链接。

        当外链能力开通之后,可以看到插入超链接时,就会有下图的选项:

        ⚠️ 其中服务号开通微信支付很简单,而订阅号开通微信支付比较难,需满足:

        按官方指引说明,目前仅政府与媒体类订阅号方能申请微信支付。

        如果支持申请微信支付,公众号后台左侧导航栏是有一个“微信支付”入口的:

        ▼ 不支持开通微信支付的

        ▼ 支持申请微信支付的

        但是我发现,有些订阅号也似乎可以添加外链,而且它们看起来并不像是“政府与媒体类”订阅号,目测是充了 VIP 或者是腾讯亲戚。

        至于有没有其他 Hack 方式,似乎全被封掉了...

        如果还不死心的话,看看官方人员的回答

        当然,不排除日后官方开通其他渠道允许在微信图文插入外部链接,毕竟微信都能打开抖音等链接了...

        三、其他

        目前微信图文对跳转小程序没有限制,可以选择折中方案:将 H5 嵌入至小程序 <webview></webview> 组件中(也是有门槛的),然后在微信图文中跳转小程序。

        另外,微信图文的“阅读原文”也是支持任意第三方链接的,且没有门槛。

        但我想,这并不是你们想要的,也不是我想要的。

        四、参考链接

        ]]>
        <![CDATA[关于 JavaScript 异步编程学习顺序]]> https://github.com/tofrankie/blog/issues/138 https://github.com/tofrankie/blog/issues/138 Sat, 25 Feb 2023 13:47:53 GMT 配图源自 Freepik

        前段时间辞职在家,抽空写了几篇关于 ES6+ 中 Promise、Genera]]> 配图源自 Freepik

        前段时间辞职在家,抽空写了几篇关于 ES6+ 中 Promise、Generator、Async 的文章。写完之后,才发现原先并没有很好地理解“异步编程”,事后有点恍然大悟的意思。

        个人认为要写好 JavaScript 异步编程,学习顺序如下:

        • 事件循环机制
        • Promise 对象
        • 迭代器 Iterator
        • 生成器 Generator
        • Generator 函数与 Thunk 函数
        • Async/Await 函数

        我们都知道 JavaScript 是单线程的,它通过事件循环(Event Loop)机制去实现非阻塞。这属于必须掌握的内容,包括同步任务、异步任务(微任务、宏任务),这是学习其他内容的前提。

        在 ES6 之前,通常通过回调函数、事件监听等方式去处理异步操作。当 ES6 标准正式发布之后,提供了全新的 Promise 对象、Generator 函数。

        Promise 对象干掉了“地狱回调”(Callback Hell),但容易纵向发展,即一堆的 then()catch() 处理。个人认为 Generator 才是使得 ES6+ 异步编程更强大的功臣。

        但在 Generator 之前,应该先要了解 Iterator 迭代器。迭代器是一种机制,是一种接口。为各种不同的数据结构提供了统一的访问机制。迭代器不能完全算是“全新”的东西,只是在 ES6 中被写入标准,并为原有(如数组、字符串)、新增(如Set、Map)的数据结构实现了 Iterator 接口。其实我们很多常用方便的 ES6 语法都是利用迭代器 Iterator 接口实现的。例如 for...of、数组解构、扩展运算符、new Set()Promise.all() 等等。

        由于调用 Generator 函数,返回一个生成器对象,该对象本身就是一个可迭代对象(具有 Iterator 接口),也可以利用 for...of 等去遍历它。Generator 函数还是实现自定义迭代器的好方法。

        而 Generator 函数的全新而独特的调用机制,才是它强大的原因,所以应该要学会它,并且要了解怎样写一个执行器去自动执行生成器对象。这个才是实际编程中最长用到的。

        等你彻底了解 Generator 函数之后,Async 就是小菜一碟,它本质上就是 Generator + Promise + 自定义执行器的组合。

        附上几篇相关的文章:

        ]]>
        <![CDATA[常见默认端口]]> https://github.com/tofrankie/blog/issues/137 https://github.com/tofrankie/blog/issues/137 Sat, 25 Feb 2023 13:47:10 GMT 配图源自 Freepik

        常见服务端口

        端口<]]> 配图源自 Freepik

        常见服务端口

        端口 用途
        21 FTP 文件传输服务
        22 SSH 远程连接服务
        23 TELENT 终端仿真服务
        25 SMTP 简单邮件传输服务
        53 DNS 域名解析服务
        80 HTTP 超文本传输服务
        443 HTTPS 加密超文本传输服务
        8080 TCP 服务端默认端口
        8888 Nginx 服务器端口

        常见数据库端口

        端口 用途
        1433 SQL Server 数据库端口
        1521 Oracle 数据库端口
        3306 MySQL 数据库端口
        5000 DB2 数据库端口
        5432 PostgreSQL 数据库端口
        6379 Redis 数据库端口
        27017 MongoDB 数据库默认端口
        ]]>
        <![CDATA[常见变量命名]]> https://github.com/tofrankie/blog/issues/136 https://github.com/tofrankie/blog/issues/136 Sat, 25 Feb 2023 13:46:10 GMT 配图源自 Freepik

        相信很多 Coder 会遇到一个很头痛的问题,就是变量命名。

        反]]> 配图源自 Freepik

        相信很多 Coder 会遇到一个很头痛的问题,就是变量命名。

        反正我就是那种可以在变量命名上纠结半天的人,没错,我是强迫症患者。对于一个“合格”的变量,既能明确表示它的准确意图(前提),也希望可以简短一点。但个人认为还是前者更重要一些。

        举个例子,i18n 就是英文单词 internationalization 的“简写”(中间 18 个字母,用 18 表示),表示国际化的意思。起码在开发编程中约定俗成的,至于其他行业是不是这样简写或缩写的,我就不深究了。类似的还有 K8s,是 Kubernets 的简写。

        有意义的命名方式,应成为良好编程习惯的一部分。尽管它不是语言规范的要求,但我认为是非常有必要的。

        因此,我会有意识地去记录下来。比如某天,看了某库的源码,它觉得它里面某个变量的命名是 OK 的,可以应用在我平常的项目中,那么我就把它记录下来...

        尽管它们算是没什么技术含量的东西,但我也想把它做好,仅此而已...

        也有很多人推荐的网站:CODELF,它是从 GitHub、GitLab、Bitbucket 的项目中爬取的。支持中文检索,以前看了下请求接口,好像是利用 Bing Microsoft Translator 接口先将中文转换为英文,然后再进行检索的。我现在很少在上面找变量了...

        一、常见简写

        例如,表示用户信息的变量,相信绝大多数开发者会使用 userInfo,而不是 userInformation(驼峰式非本文讨论范围,忽略)。

        全称 简写 备注
        template tmpl
        contribution contrib
        versus vs
        arguments args
        international Intl
        package pkg
        dependencies deps
        information info
        property prop
        properties props
        regular expression regex、regexp、re
        second sec
        memoization memo 注意,它与 memorization 不同,区别请看下文。
        High-Order Component HOC
        standard std
        corporation corp
        First In First Out FIFO 先进先出
        Last In First Out LIFO 后进先出
        temporary temp、tmp
        instance inst
        asynchronous async 异步
        synchronous sync 同步
        double click dblclick
        specification spec 规格
        conference conf 会议
        Read-Eval-Print-Loop REPL “读取-求值-输出”循环,一个交互式解释器。
        Generate Your Projects GYP、gyp 一个用来生成项目文件的工具。
        No Operation noop 空操作,在 JavaScript 中一般是无操作的空函数,可作为参数默认值,例如回调函数,以避免代码报错。
        Request For Comments RFC 征求修正意见书
        picture pix

        二、常见翻译

        中文 英文 备注
        键值对 key-value map
        数据结构 data structures
        生命周期 life cycle

        三、常见场景的命名

        timerId // setTimeout、setInterval 产生的 Id
        

        四、扩展

        1. 关于 Corp.Inc.Co.,Ltd. 的区别:

        • Corp. 是 Corporation 的缩写,主要是用于大公司集团的后缀。
        • Inc. 是 Incorporation 的缩写,即股份有限公司,意思是“团体、法人组织、公司”。
        • Co. 是 Company 的缩写,无论规模大小,一般指有限责任公司,用得较普遍。
        • Co.,Ltd. 是 Limited Company 的缩写,即有限责任公司。Ltd. 适用于规模较小的企业。英国、加拿大常用的表述方式。

        2. memoizationmemorization 的区别:

        没错,它们只差在一个字母 r 上,而且都是与“记忆” 相关的,但是有区别的。

        • memoization:是计算机科学中的一个概念,是使程序运行更快的一种方法。比如。React 中的 memo 就是它的简写。
        • memorization:是装进你脑袋里面的。

        与之对应的动词就是 memoizememorize

        详见:什么是 Memoization ?

        3. Node.js REPL 简述

        Node.js REPL(Read Eval Print Loop:交互式解释器) 表示一个电脑的环境,类似 Window 系统的终端或 Unix/Linux shell,我们可以在终端中输入命令,并接收系统的响应。

        Node 自带了交互式解释器,可以执行以下任务:

        • 读取 - 读取用户输入,解析输入的 Javascript 数据结构并存储在内存中。

        • 执行 - 执行输入的数据结构

        • 打印 - 输出结果

        • 循环 - 循环操作以上步骤直到用户两次按下 Ctrl + C⌘ + C 按钮退出。

        Node 的交互式解释器可以很好的调试 Javascript 代码。

        4. node-gyp

        gyp(Generate Your Projects,简称 GYP,官网)是一个用来生成项目文件的工具,一开始是设计给 Chromium 项目使用的,后来大家发现比较好用就用到了其他地方。生成项目文件后就可以调用 GCC、VSBuild、Xcode 等编译平台来编译。至于为什么要有 node-gyp,是由于 Node 程序中需要调用一些其他语言编写的工具甚至是 DLL,需要先编译一下,否则就会有跨平台的问题。

        参考:node-gyp 的作用是什么?

        其他

        一些关于变量/函数命名的文章:

        ]]>
        <![CDATA[什么是 RC 版本?]]> https://github.com/tofrankie/blog/issues/135 https://github.com/tofrankie/blog/issues/135 Sat, 25 Feb 2023 13:45:44 GMT 常见的 RC 版本,全称是 Release Candidate。其中 Release 是发行、发布的意思。Candidate 是候选人的意思,用在软件或者操作系统上就是候选版本。因此 Release Candidate 就是发行候选版本。

        版本名称 常见的 RC 版本,全称是 Release Candidate。其中 Release 是发行、发布的意思。Candidate 是候选人的意思,用在软件或者操作系统上就是候选版本。因此 Release Candidate 就是发行候选版本。

        版本名称 介绍 说明
        Alpha 内测版本 内部测试版本。
        Beta 公测版本 Beta 阶段会一直加入新的功能。
        RC 候选版本 几乎就不会加入新的功能了,而主要着重于除错。
        Release 正式版本 稳定版本。

        RC 版本和 Beta 版最大的差别在于 Beta 阶段会一直加入新的功能,但是到了 RC 阶段,几乎就不会加入新的功能了,而主要着重于除错。

        RC 版本,它不是最终的版本,而是最终版(RTM,Release To Manufacture)之前的最后一个版本。广义上对测试有三个传统的称呼:Alpha(α)、Beta(β)、Gamma(γ),用来标识测试的阶段和范围。Alpha 是指内测,即现在说的 CB,指开发团队内部测试的版本或者有限用户体验测试版本。Beta 是指公测,即针对所有用户公开的测试版本。然后做过一些修改,成为正式发布的候选版本时叫做 Gamma,现在就做 RC。

        ]]>
        <![CDATA[常用正则表达式]]> https://github.com/tofrankie/blog/issues/134 https://github.com/tofrankie/blog/issues/134 Sat, 25 Feb 2023 13:44:31 GMT 配图源自 Freepik

        记录一些正则表达式。

        以下示例号码为随]]> 配图源自 Freepik

        记录一些正则表达式。

        以下示例号码为随意输入,如有相同,纯属巧合!

        号码脱敏

        身份证号,保留前 6 位和最后 2 位。

        const id = '801823200507142619'
        id.replace(/(\w{6})\w*(\w{2})/, '$1******$2') // "801823******19"
        

        手机号,保留前 3 位和最后 4 位。

        const phone = '13463592385'
        phone.replace(/(\w{3})\w*(\w{4})/, '$1****$2') // "134****2385"
        

        格式化

        移除空白符

        移除所有空白符。

        const str = '  abc def  '
        str.replace(/\s/g, '') // "abcdef"
        

        移除前导、尾随空白符。也可以用 String.prototype.trim() 方法。

        const str = '  abc def  '
        str.replace(/^\s*|\s*$/g, '') // "abc def"
        

        \s 表示匹配一个空白字符,包括 \n\r\f\t\v 等,相当于 /[\n\r\f\t\v]/

        空格隔开

        银行卡号码每 4 位空格隔开、手机号码 344 形式空格隔开等场景。

        const code = '801823200507142619'
        code.replace(/(.{4})(?!$)/g, '$1 ') // "8018 2320 0507 1426 19"
        

        为避免出现 '8018 2320 ' 末尾有空字符串的情况,使用负向先行断言 exp1(?!exp2),表示查找后面不是 exp2 的 exp1。

        const phone = '13463592385'
        phone.replace(/(.{3})(.{4})(.{4})/, '$1 $2 $3') // "134 6359 2385"
        

        数字千分位表示法

        注意,这个版本并不支持带小数的情况。

        const str = '100000000000'
        console.log(str.replace(/\B(?=(\d{3})+$)/g, ',')) // "100,000,000,000"
        

        (?=p):表示匹配 p 前面的位置。

        ?=\d{3}$:表示匹配 3 个数字前的位置,替换结果是 100000000,000

        ?=(\d{3})+$:在原有基础上加 + 量词表示匹配多个 3 位数字的位置,替换结果是 ,100,000,000,000

        \B(?=(\d{3})+$):去掉开头的逗号,\B 表示匹配非单词边界,替换结果是 100,000,000,000

        理解正则中的 (?=p)、(?!p)、(?<=p)、(?<!p)

        保留 2 位小数

        const regex = /^(([1-9]{1}\d*)|(0{1}))(\.\d{2})$/
        
        regex.test(0.11) // true
        regex.test(5.12) // true
        
        regex.test(1) // false
        regex.test(2.5) // false
        regex.test(3.324) // false
        regex.test(4.) // false
        regex.test(5.00) // false,请注意数值 5.00 的写法会直接转为 5 再做判断,所以是 false。
        regex.test('5.00') // true,所以字符串形式是匹配成功的。
        

        思路请看这里

        汉字匹配

        对于匹配汉字,网上一搜基本上都是这个版本:

        const regex = /^[\u4e00-\u9fa5]+$/
        
        regex.test('') // false
        regex.test('123') // false
        regex.test('你好') // true
        

        以上 \u4e00\u9fa5 分别是 Unicode 字符集里汉字的第一个和最后一个码点。

        但 Unicode 一直在更新,目前最后一个汉字码点不再是 \u9fa5,所以这种方式匹配是有问题的。

        比如,「䶮」姓。

        regex.test('䶮') // false
        

        另一个版本:

        const newRegex = /^\p{sc=Han}+$/gu
        
        newRegex.test('䶮') // true
        

        设备/浏览器判断

        判断苹果设备

        const isAppleDevice = /Mac|iPod|iPhone|iPad/.test(navigator.platform)
        

        判断移动设备

        const isMobile = /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|IEMobile)/i.test(navigator.userAgent)
        

        HTML 相关

        匹配 HTML 字符串的 px 单位数值,如 <div style="font-size: 10px"></div> 中的 10

        const pxReg = /\b(\d+(\.\d+)?)px\b/g
        

        匹配 HTML 字符串的 style 属性值,如 <div style="font-size: 10px"></div> 中的 font-size: 10px

        const styleReg = /(\s+style="[^"]*")/gi
        

        其他

        语义化版本号

        // https://regex101.com/r/vkijKf/1/
        const regex = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
        
        regex.test('1.0.0') // true
        regex.test('v1.0.0') // false
        regex.test('1.0') // false
        

        若要支持更多特性(如 1.0.0-alpha 等)、版本比较等,可以使用 semver 这个库。

        参考资料

        ]]> <![CDATA[Markdown 代码块语言高亮]]> https://github.com/tofrankie/blog/issues/133 https://github.com/tofrankie/blog/issues/133 Sat, 25 Feb 2023 13:43:11 GMT 简书效果

        ▼ 指定 JavaScript 语言

        const path = require('path')
        

        ▼ 没有指定语言

        const path = requir]]>
                    简书效果
        

        ▼ 指定 JavaScript 语言

        const path = require('path')
        

        ▼ 没有指定语言

        const path = require('path')
        

        通过元素审查,可以发现它是添加相应语言的高亮风格(highlight)的,然后各个平台的高亮风格会略有不同。

        在简书平台应该是自动识别语言了,所以你现在看到的效果是一样的。

        它们好像是用到了 syntaxhighlighter 库,或者自己实现了一套吧。

        GitHub 效果

        支持的语言

        名称 关键字
        JavaScript js、jscript、javascript
        CSS css
        SASS&SCSS sass、scss
        Java java
        Python py、python
        text text 、plain
        XML xml、xhtml、xslt、html
        SQL sql
        PHP php
        AppleScript applescript
        ActionScript 3.0 actionscript3、as3
        Shell bash , shell
        ColdFusion coldfusion、cf
        C cpp、c
        C# c#、c-sharp、csharp
        Delphi delphi、pascal、pas
        diff&patch diff patch
        Erlang erl、erlang
        Groovy groovy
        JavaFX jfx、javafx
        Perl perl、pl、Perl
        Ruby ruby、rails、ror、rb
        Scala scala
        Visual Basic vb、vbnet
        Objective C objc、obj-c
        F# f#、f-sharp、fsharp
        xpp、dynamics-xpp
        R r、s、splus
        matlab matlab
        swift swift
        GO go、golang
        • diff&patch:代码版本控制,遇到代码冲突
        • text:普通文本
        ]]>
        <![CDATA[APP 设计常用字体]]> https://github.com/tofrankie/blog/issues/132 https://github.com/tofrankie/blog/issues/132 Sat, 25 Feb 2023 13:42:44 GMT 作个记录

        iOS 7

        • 中文:华文细黑、黑体简
        • 英文:Helvetica Ne

        iOS 9

        • 中文:苹方黑
        • 英文:System San Francisco
        <]]>
        作个记录

        iOS 7

        • 中文:华文细黑、黑体简
        • 英文:Helvetica Ne

        iOS 9

        • 中文:苹方黑
        • 英文:System San Francisco

        安卓 4.0 以上

        • 中文:方正兰亭黑简体、方正兰亭细黑体
        • 英文:Roboto

        Material Design

        • 中文:Noto(思源黑体)
        • 英文:Roboto
        ]]>
        <![CDATA[ENOENT 是什么?]]> https://github.com/tofrankie/blog/issues/131 https://github.com/tofrankie/blog/issues/131 Sat, 25 Feb 2023 13:39:36 GMT 今天调试一个 Gulp 的命令,发现有一个报错:

        😌 前半句]]> 今天调试一个 Gulp 的命令,发现有一个报错:

        😌 前半句看懂了,然后 ENOENT 似乎是第一次见,然后就搜了一番。

        ENOENT 是 Error No Entry 的缩写。 意思是:没有这样的目录条目。详见

        一篇没有营养的文章...

        ]]>
        <![CDATA[全国哀悼,为了更好地前行]]> https://github.com/tofrankie/blog/issues/130 https://github.com/tofrankie/blog/issues/130 Sat, 25 Feb 2023 13:37:25 GMT 为表达全国人民对抗击新冠肺炎疫情斗争牺牲烈士和逝世同胞的深切哀悼,国务院 4 月 3 日发布公告:决定 2020 年 4 月 4 日举行全国性哀悼活动。在此期间,全国和驻外使领馆下半旗志哀,全国停止公共娱乐活动,从上午 10 时起,全国人民默哀 3 分钟,汽车、火车、舰船鸣笛,防]]> 为表达全国人民对抗击新冠肺炎疫情斗争牺牲烈士和逝世同胞的深切哀悼,国务院 4 月 3 日发布公告:决定 2020 年 4 月 4 日举行全国性哀悼活动。在此期间,全国和驻外使领馆下半旗志哀,全国停止公共娱乐活动,从上午 10 时起,全国人民默哀 3 分钟,汽车、火车、舰船鸣笛,防空警报鸣响。

        几乎所有的游戏停服、直播停播、影视停更,同时上线抗疫缅怀专题。还有一个现象就是很多 APP、网站界面置灰。

        ▼ 简书

        ▼ 掘金

        一行 CSS 代码让页面变灰:

        .your-class {
          filter: grayscale(1); /* 灰度滤镜:范围 0 ~ 1,或者 0 ~ 100% */
        }
        

        感谢有你们,愿春暖花开之日早些到来。

        ]]>
        <![CDATA[将 CSScomb 集成到 Git Hook 中]]> https://github.com/tofrankie/blog/issues/129 https://github.com/tofrankie/blog/issues/129 Sat, 25 Feb 2023 13:30:24 GMT 在此前文章介绍了如何在微信小程序中使用 CSScomb 来处理我们的小程序样式文件。

        系列文章:

        1. 将 CSScomb 集成到微信小程序项目中
        2. 在此前文章介绍了如何在微信小程序中使用 CSScomb 来处理我们的小程序样式文件。

          系列文章:

          1. 将 CSScomb 集成到微信小程序项目中
          2. 将 CSScomb 集成到 Git Hook 中(本文)

          示例:tofrankie/csscomb-mini

          但是此前实现有个不足的地方:没有实现在提交代码之前去执行我们的脚步命令。

          一、设想

          假设我们可以在 pre-commit 阶段做一些类似 ESLint、Prettier 的操作,岂不美哉!

          例如,如下配置文件是利用 husky、lint-staged 做了一些处理,在代码 commit 之前对代码进行检查和格式化。

          {
            "husky": {
              "hooks": {
                "pre-commit": "lint-staged"
              }
            },
            "lint-staged": {
              "*.{js}": "eslint --fix --ext .js",
              "*.{css,wxss,acss}": "prettier --config .prettierrc.js --write"
            }
          }
          

          那我们使用 CSScomb 也想这么做的话,要怎样实现呢?

          二、尝试

          关于前一篇文章的具体思路就不再赘述了,详见 将 CSScomb 集成到微信小程序项目中 )。

          1. 首先安装 husky、lint-staged。

          如果对两者不太了解的话,可以看下这篇文章

          # 这里我不安装最新版 husky 的原因是 husky 5.x 在使用上有很大变化
          # 我暂时还没时间去了解它,所以先用着已经习惯的 4.x 版本,问题不大
          $ yarn add --dev husky@4.3.8 lint-staged
          
          1. package.json 添加配置,如下:
          {
            "scripts": {
              "csscomb": "gulp wxssTask",
              "csscomb:mini": "gulp csscombTask --path './**/*.wxss'"
            },
            "husky": {
              "hooks": {
                "pre-commit": "lint-staged"
              }
            },
            "lint-staged": {
              "*.wxss": "gulp csscombTask"
            }
          }
          
          1. 接着,我们尝试去修改一个样式文件,并提交代码。
          $ git commit -m 'test'
                                                                                                                    
          husky > pre-commit (node v14.16.0)
          ✔ Preparing...
          ⚠ Running tasks...
            ❯ Running tasks for *.wxss
              ✖ gulp csscombTask [FAILED]
          ↓ Skipped because of errors from tasks. [SKIPPED]
          ✔ Reverting to original state because of errors...
          ✔ Cleaning up...
          
          ✖ gulp csscombTask:
          [14:30:14] Task never defined: /Users/frankie/Desktop/Web/Git/csscomb-mini/app.wxss
          [14:30:14] To list available tasks, try running: gulp --tasks
          [14:30:14] Using gulpfile ~/Desktop/Web/Git/csscomb-mini/gulpfile.js
          husky > pre-commit hook failed (add --no-verify to bypass)
          

          非常的遗憾,它失败了,并提示:Task never defined: /Users/frankie/Desktop/Web/Git/csscomb-mini/app.wxss

          执行如下命令,明明是找得到 csscombTask 任务的啊!

          $ npx gulp --tasks
          
          [14:35:09] Tasks for ~/Desktop/Web/Git/csscomb-mini/gulpfile.js
          [14:35:09] ├── wxssTask
          [14:35:09] └── csscombTask
          

          为什么会这样呢?什么原因呢?这也是我的踩坑的地方。

          三、寻找失败原因

          首先,我先翻阅了 lint-staged 的文档 ,里面有一段话:

          {
            "*": "your-cmd"
          }
          

          This config will execute your-cmd with the list of currently staged files passed as arguments.

          So, considering you did git add file1.ext file2.ext, lint-staged will run the following command: your-cmd file1.ext file2.ext.

          假设我们暂存的文件是 /Users/frankie/Desktop/Web/Git/csscomb-mini/app.wxss,那么我们执行:

          {
            "*.wxss": "gulp csscombTask"
          }
          

          就等于执行了 gulp csscombTask /Users/frankie/Desktop/Web/Git/csscomb-mini/app.wxss 这条指令。

          但如果熟悉 Gulp 的话,形式如:gulp <task> <othertask> 其实是执行了多个任务。 所以,上面实际上执行了两个任务 csscombTask/Users/frankie/Desktop/Web/Git/csscomb-mini/app.wxss,它把后者也当成是一个任务了。

          那么我们来验证一下,添加一个 Gulp 任务:

          const test = cb => {
            // 需要注意的是:
            // 由于 node 特性,非主进程下执行该命令,无法将 test log 打印出来,
            // 例如在 lint-staged 下执行该 gulp 任务,就无法打印,
            // 如果通过 npx gulp xxx 执行任务,属于主进程,就能打印出来。
            console.log('test log')
            cb()
          }
          
          module.exports = {
            csscombTask,
            '/Users/frankie/Desktop/Web/Git/csscomb-mini/app.wxss': test
          }
          

          接着重新提交一下代码:

          $ git commit -m 'test'
          
          husky > pre-commit (node v14.16.0)
          ✔ Preparing...
          ✔ Running tasks...
          ✔ Applying modifications...
          ✔ Cleaning up...
          [master a2ba057] test
           1 file changed, 3 insertions(+), 3 deletions(-)
          

          它成功了,说明我们的猜想是正确的。

          既然找到了缘由,那么我们开始着手解决问题吧。

          四、解决问题

          其实 /Users/frankie/Desktop/Web/Git/csscomb-mini/app.wxss 应该是我们 Gulp 任务的参数才对。

          按照此前的约定:

          gulp csscombTask --path '<filepath>' --ext <extension>
          

          所以只要我们执行的命令形式如:gulp csscombTask --path '/Users/frankie/Desktop/Web/Git/csscomb-mini/app.wxss' 即可。

          我们来修改一下 lint-staged 的配置文件,此前在 package.json 的配置方式局限性太大了,下面我们使用 JavaScript 形式的配置方式:

          Writing the configuration file in JavaScript is the most powerful way to configure lint-staged (lint-staged.config.jssimilar, or passed via --config).

          其实 lint-staged 提供了很多的配置示例,我们使用 Example: Wrap filenames in single quotes and run once per file 即可。

          移除 package.json 的配置项,并在项目根目录下,新建 .lintstagedrc.js 配置文件:

          // .lintstagedrc.js
          module.exports = {
            '*.wxss': filenames => filenames.map(filename => `gulp csscombTask --path ${filename}`)
          }
          
          // 其实这里可配置的方式很多,
          // 例如:超过 10 个暂存文件时,我们可以在整个项目下执行一遍 csscombTask 操作:
          // module.exports = {
          //   '*.wxss': filenames => {
          //     if (filenames.length > 10) {
          //       return 'gulp csscombTask --path './**/*.wxss''
          //     }
          //     return `gulp csscombTask --path '${filenames.join(',')'}`
          //   }
          // }
          

          接下来我们修改两个样式文件,再提交一下:

          $ git commit -m 'test'
          
          husky > pre-commit (node v14.16.0)
          ✔ Preparing...
          ✔ Running tasks...
          ✔ Applying modifications...
          ✔ Cleaning up...
          [master 7b84b9c] test
           2 files changed, 7 insertions(+), 3 deletions(-)
          

          可以正常运行,而且我们所提交的文件都经过了 CSScomb 格式化了。🎉🎉🎉

          The end.

          ]]>
          <![CDATA[将 CSScomb 集成到微信小程序项目中]]> https://github.com/tofrankie/blog/issues/128 https://github.com/tofrankie/blog/issues/128 Sat, 25 Feb 2023 13:29:44 GMT 最近在看 AlloyTeam 团队的 Code Guide 代码书写习惯,其中一项是 AlloyTeam 团队的 Code Guide 代码书写习惯,其中一项是 CSS 属性编写顺序

          虽然 CSS 属性顺序先后并不影响我们的程序,那为什么要做这件事呢?我理解是可分类、有迹可循、有利于排查错误。

          由于属性众多,那么人总是会犯错的,所以我们借助一个工具来处理,它就是 CSScomb,用于 CSS 格式化、且可排序。官方描述是:CSScomb is a coding style formatter for CSS.

          本文旨在使得 CSScomb 支持格式化小程序样式 .wxss。同时也支持支付宝小程序 .acss,百度小程序样式文件的扩展名是 .css,所以它天生就可以直接使用了。

          文章有两篇:

          1. 将 CSScomb 集成到微信小程序项目中(本文)
          2. 将 CSScomb 集成到 Git Hook 中

          示例:tofrankie/csscomb-mini

          此前也写过一篇文章【使 Prettier 一键格式化 WXSS】是讲述使用 Prettier 来格式化小程序的,但它没有 CSScomb 的属性排序功能。(后续可能会考虑整合起来,这是后话了)

          一、CSScomb 安装使用

          创建一个微信小程序项目,因过于简单,忽略该步骤。

          1. 本地安装

          $ yarn add csscomb --dev
          

          2. 添加配置文件

          在项目根目录下,添加配置文件 .csscomb.json

          若无配置文件,CSScomb 的默认配置请看 csscomb.js/config/csscomb.json

          而本文均采用 AlloyTeam 推荐的排序规则,而非默认。但由于 sort-order 很长,影响文章篇幅,部分配置没贴上去,完整配置请看 👉 csscomb-mini/.csscomb.json

          关于该配置文件是按我平常编写代码的习惯调整有所调整的,并非 CSScomb 默认配置。具体配置项所指可细看 👉 CSScomb Configuration Options。注意 CSScomb 所能做的事情并非就只有属性排序哦,比如设置缩进、颜色值十六进制大小写、前导零也是可以的。

          {
            "exclude": [".git/**", "node_modules/**", "bower_components/**"],
            "verbose": true,
            "always-semicolon": true,
            "block-indent": 2,
            "color-case": "lower",
            "color-shorthand": true,
            "element-case": "lower",
            "eof-newline": true,
            "leading-zero": false,
            "quotes": "single",
            "remove-empty-rulesets": false,
            "space-before-colon": 0,
            "space-after-colon": 1,
            "space-before-combinator": 1,
            "space-after-combinator": 1,
            "space-before-opening-brace": 1,
            "space-after-opening-brace": "\n",
            "space-before-closing-brace": "\n",
            "space-before-selector-delimiter": 0,
            "space-after-selector-delimiter": "\n",
            "space-between-declarations": "\n ",
            "strip-spaces": true,
            "unitless-zero": true,
            "vendor-prefix-align": false,
            "sort-order": []
          }
          

          3. 添加 NPM 脚本

          为了测试效果,根目录下创建一个 app.css 文件。

          {
            "scripts": {
              "csscomb": "csscomb app.css"
            }
          }
          

          4. 运行脚本

          $ yarn run csscomb
          
          ✓ app.css
          ✨  Done in 0.45s.
          

          5. 效果

          按以上配置文件的排序规则,padding-right 属性在 padding-left 之前,所以可以看到修改前后的对比如下:

          /* before */
          view {
            padding-left: 30px;
            padding-right: 30px;
          }
          
          /* after */
          view {
            padding-right: 30rpx;
            padding-left: 30rpx;
          }
          

          二、微信小程序如何使用 CSScomb

          由于 CSScomb 仅支持扩展名为 .css.sass.scss.less 的文件,那么怎么处理呢?

          只能利用 Gulp 来曲线救国了。

          大致思路,利用 Gulp 将 WXSS 文件临时转为 CSS 扩展名,使用 CSScomb 格式化之后,再将其转换回 WXSS 的扩展名,以达到曲线救国的目的。

          插了一下,恰巧有一款 Gulp 插件 gulp-csscomb 可用,话不多说。

          1. 安装相关依赖包

          $ yarn add --dev gulp gulp-debug gulp-csscomb gulp-rename
          

          2. 创建 Gulp 任务

          在项目根目录下创建 gulpfile.js 文件。而 gulp-csscomb 的使用方法很简单,于是我们很快可以写出:

          const { src, dest } = require('gulp')
          const rename = require('gulp-rename')
          const debug = require('gulp-debug')
          const csscomb = require('gulp-csscomb')
          
          const wxssTask = cb => {
            return src('app.wxss')
              .pipe(debug()) // 打印一些调试信息
              .pipe(
                rename({
                  extname: '.css'
                })
              )
              .pipe(csscomb()) // 格式化操作
              .pipe(
                rename({
                  extname: '.wxss'
                })
              )
              .pipe(dest(file => file.base))
          }
          
          module.exports = {
            wxssTask
          }
          

          修改 NPM 脚本:

          {
            "scripts": {
              "csscomb": "gulp wxssTask"
            }
          }
          

          执行脚本 yarn run csscomb 可以看到:

          $ yarn run csscomb
          
          yarn run v1.22.4
          $ gulp wxssformat
          [15:34:11] Using gulpfile ~/Desktop/Web/MyGitHub/csscomb-mini/gulpfile.js
          [15:34:11] Starting 'wxssformat'...
          [15:34:11] gulp-debug: app.wxss
          
          Failed to configure "remove-empty-rulesets" option:
           Value must be one of the following: true.
          
          Failed to configure "vendor-prefix-align" option:
           Value must be one of the following: true.
          [15:34:11] gulp-debug: 1 item
          [15:34:11] Finished 'wxssformat' after 98 ms
          ✨  Done in 1.05s.
          

          此时 app.wxss 文件已经被格式化了,但很遗憾我们看到两行 Failed 信息:

          Failed to configure "remove-empty-rulesets" option:
           Value must be one of the following: true.
          
          Failed to configure "vendor-prefix-align" option:
           Value must be one of the following: true.
          

          它不支持 remove-empty-rulesetsvendor-prefix-align 配置选项,估计是 gulp-csscomb “年久失修”了。当前安装 csscomb 是最新版本 4.3.0(本文编写时),而 gulp-csscomb 里面引用的 csscomb 版本还是 3.1.7,猜测是旧版本不支持该选项。

          # 查看依赖包版本
          $ npm view <package> versions
          

          再次曲线救国一下,干脆自己实现一个 csscomb-gulp 插件的功能。

          三、编写 Gulp 插件

          顺便学习一下,如何写一个 Gulp 插件哦。

          第一次写的同学,可简单看下这篇文章:Gulp 插件编写入门

          我看了 gulp-csscombCSScomb Core 的源码,发现自己实现一个插件不难,同时还发现可以少一步 wxsscss 来回切换的转换过程。

          首先是 gulp-csscomb 的源码(gulp-csscomb),截取了一部分下来:

          // 部分源码
          var comb = new Comb(config || 'csscomb'); // 根据配置实例化对象
          var syntax = options.syntax || file.path.split('.').pop(); // 获取 syntax,从参数或者扩展名获取,即 css、less、sass、scss
          
          try {
            var output = comb.processString( // 关键就是这步,对我们的文件进行格式化
              file.contents.toString('utf8'), {
                syntax: syntax,
                filename: file.path
              });
            file.contents = new Buffer(output); // 将格式化后的字符串替换回文件中
          } catch (err) {
            this.emit('error', new PluginError(PLUGIN_NAME, file.path + '\n' + err));
          }
          

          从以上源码可以看到,关键在于 comb.processString() 方法。于是找到 CSScomb 的核心源码(CSScomb Core),该方法的描述如下:

          comb.processString(string, options)
          
          Params:
            {String} Code to process
            {{context: String, filename: String, syntax: String}} Options (optional) where context is Gonzales PE rule, filename is a file's name that is used to display errors and syntax is syntax name with css being a default value.
          
          Return: {Promise} Resolves with processed string.
          

          syntax is syntax name with css being a default value.

          所以只要在调用 comb.processString() 方法时,对微信小程序的样式文件,传值为 {syntax: 'css'} 即可。

          上代码,框架大致是这样:

          先安装必要依赖

          $ yarn add --dev gulp-csscomb through2
          
          // gulpfile.js
          const { src, dest } = require('gulp')
          const rename = require('gulp-rename')
          const debug = require('gulp-debug')
          const csscomb = require('gulp-csscomb')
          const through = require('through2')
          
          // csscomb 插件
          const csscombPlugin = () => {
            return through.obj(function (file, enc, cb) {
              // 暂时什么都没做...
              return cb()
            })
          }
          
          // Gulp 任务
          const csscombTask = cb => {
            try {
              return src('app.wxss')
                .pipe(debug())
                .pipe(csscombPlugin())
                .pipe(dest(file => file.base))
            } catch (e) {
              console.warn(e)
            }
          }
          
          module.exports = {
            csscombTask
          }
          

          创建新脚本,并运行 yarn run csscomb:mini

          {
            "scripts": {
              "csscomb:mini": "gulp csscombTask"
            }
          }
          

          看样子是可以正常跑起来的,接下来就实现 csscomb 转化过程。

          $ yarn run csscomb:mini
          
          yarn run v1.22.10
          $ gulp csscombTask
          [20:29:25] Using gulpfile ~/Desktop/Web/Git/csscomb-mini/gulpfile.js
          [20:29:25] Starting 'csscombTask'...
          [20:29:25] gulp-debug: app.wxss
          [20:29:25] gulp-debug: 1 item
          [20:29:25] Finished 'csscombTask' after 14 ms
          ✨  Done in 0.51s.
          

          如对 Gulpthrough2 不了解,可去官网了解一下。through2 是快速创建一个 transform stream 的工具函数。

          编写 csscombPlugin 实现

          安装必要依赖

          $ yarn add --dev plugin-error
          
          const fs = require('fs')
          const path = require('path')
          const { src, dest } = require('gulp')
          const rename = require('gulp-rename')
          const debug = require('gulp-debug')
          const Comb = require('csscomb')
          const csscomb = require('gulp-csscomb')
          const through = require('through2')
          const PluginError = require('plugin-error') // 用于处理异常
          
          
          // csscomb 插件
          const csscombPlugin = () => {
            // 默认支持扩类型
            const defaultExts = ['.css', '.sass', '.scss', '.less']
            // 扩展类型,假设以后兼容字节跳动小程序,可加上 .ttss
            const expandExts = ['.wxss', '.acss']
            const supportExts = defaultExts.concat(expandExts)
          
            return through.obj(async function (file, enc, cb) {
              let syntax = 'css'
              const filePath = file.path
              const extname = path.extname(filePath)
              // 获取 csscomb 配置
              const combConfigPath = Comb.getCustomConfigPath(path.resolve(__dirname, '.csscomb.json'))
              const combConfig = Comb.getCustomConfig(combConfigPath)
          
              if (file.isNull()) {
                // 文件为空不做处理,直接返回进入下一个 pipe()
                return cb()
              } else if (file.isStream()) {
                // 不支持对流(Stream)进行操作,抛出异常
                this.emit('error', new PluginError('csscombPlugin', 'Streams are not supported!'))
                return cb()
              } else if (file.isBuffer() && supportExts.includes(extname)) {
                // 默认支持类型,通过扩展名获取。其余当做 css
                if (defaultExts.includes(extname)) {
                  syntax = extname.split('.').pop()
                }
          
                // 找不到配置文件
                if (combConfigPath && !fs.existsSync(combConfigPath)) {
                  this.emit('error', new PluginError('csscombPlugin', 'Configuration file not found: ' + configPath))
                  return cb()
                }
          
                try {
                  // 实例化
                  const comb = new Comb(combConfig)
                  // 对所要转换的文件进行格式化
                  const output = await comb.processString(
                    file.contents.toString('utf8'),
                    {
                      syntax,
                      filename: filePath
                    }
                  )
                  // 创建 Buffer,并将格式化后的字符串替换原本的 contents
                  file.contents = Buffer.from(output, 'utf-8')
                  this.push(file)
                  return cb()
                } catch (e) {
                  this.emit('error', new PluginError('csscombPlugin', filePath + '\n' + e))
                }
          
              } else {
                // 其余情况,直接返回。例如 file 为 JS 文件等
                return cb()
              }
            })
          }
          
          
          // Gulp 任务
          const csscombTask = cb => {
            try {
              return src('app.wxss')
                .pipe(debug())
                .pipe(csscombPlugin())
                .pipe(dest(file => file.base))
            } catch (e) {
              console.warn(e)
            }
          }
          
          
          module.exports = {
            csscombTask
          }
          

          我们再次运行脚本 yarn run csscomb:mini,可以看到 app.wxss 文件的变化:

          /* before */
          .container {
            height: 100%;
            padding: 200rpx 0;
            display: flex;
            align-items: center;
            flex-direction: column;
            justify-content: space-between;
            box-sizing: border-box;
          }
          
          /* after */
          .container {
            display: flex;
          
            box-sizing: border-box;
            padding: 200rpx 0;
            height: 100%;
          
            align-items: center;
            flex-direction: column;
            justify-content: space-between;
          }
          

          至此,我们的工作完成了 90%... 还可以继续优化。

          你注意到我们 csscombTask 方法里面是针对一个相对固定的路径或者文件了吗?假设我每次格式化其他目录的文件,都需要修改此方法,显然是不合理的。

          我们可在 NPM 脚本进行传参,然后通过 process.argv 来获取,我们处理下再传入 gulp.src() 方法即可。

          四、优化

          先约定好传参规则:

          • --path 表示符合 glob 文件匹配模式的路径,多个路径是用 , 隔开,并用单引号 ' 括起来,还有我限制了仅支持项目下的文件。
          • --ext 表示扩展名,如 .css.wxss 等。(此选项目前没什么用,保留下来后续优化用)

          若对 Glob 模式不了解,可看 Glob 详解。其中 gulp.src(globs[, options]) 第一个参数就是这种模式的。

          {
            "scripts": {
              "csscomb:mini": "gulp csscombTask --path '<filepath>' --ext <extension>"
            }
          }
          

          我们借助 minimist 来获取 NPM 参数,通过

          安装必要依赖,

          $ yarn add --dev minimist
          
          // gulp.js
          // 这里省略其他部分,仅修改了 csscombTask 方法
          const minimist = require('minimist')
          
          // Gulp 任务
          const csscombTask = cb => {
            try {
              // 获取参数,如 { _: [ 'csscomb:mini' ], path: 'xxx', ext: 'xxx' }
              const options = minimist(process.argv.slice(2))
          
              // 过滤掉非项目下的路径
              const paths = options.path.split(',').filter(item => {
                const re1 = /^\//
                const re2 = /^\.\.\//
                return item && !re2.test(item) && (!re1.test(item) || (re1.test(item) && item.includes(__dirname)))
              })
          
              // 去重
              const newPaths = Array.from(new Set(paths))
          
              if (!newPaths.length) {
                return cb()
              }
          
              // allowEmpty 选项是为了避免在没有找到匹配的文件时抛出错误
              // Error: File not found with singular glob: xxx (if this was purposeful, use `allowEmpty` option)
              return src(newPaths, { allowEmpty: true })
                .pipe(debug())
                .pipe(csscombPlugin())
                .pipe(dest(file => file.base))
            } catch (e) {
              console.warn(e)
            }
          }
          

          修改 NPM 脚本,并创建一个支付宝小程序的样式文件 app.acss 测试一下:

          {
            "scripts": {
              "csscomb:mini": "gulp csscombTask --path './**/*.wxss,app.acss'"
            }
          }
          

          运行脚本,发现可以一键格式化了,至此基本完成了。

          五、GitHub

          示例:tofrankie/csscomb-mini,欢迎 Star 👋

          下一篇介绍:将 CSScomb 集成到 Git Hook 中

          六、参考链接

          ]]>
          <![CDATA[no-plusplus]]> https://github.com/tofrankie/blog/issues/127 https://github.com/tofrankie/blog/issues/127 Sat, 25 Feb 2023 13:28:36 GMT 假如我们使用 ESLint 来检查代码质量,且启用了其中一条规则 no-plusplus(禁止使用一元操作符 ++--),下面代码就会提示错误。

          // Unar]]>
                      假如我们使用 ESLint 来检查代码质量,且启用了其中一条规则 no-plusplus(禁止使用一元操作符 ++--),下面代码就会提示错误。

          // Unary operator '++' used. eslint (no-plusplus)
          for (let i = 0; i < 10; i++) {
            // ...
          }
          

          由于一元 ++-- 运算符都受自动插入分号机制(Automatic Semicolon Insertion,简称 ASI)的影响,因此空格的差异可能会改变源代码的语义。

          var i = 10;
          var j = 20;
          
          i ++
          j
          // i = 11, j = 20
          
          var i = 10;
          var j = 20;
          
          i
          ++
          j
          // i = 10, j = 21
          

          此规则的错误代码示例:

          /*eslint no-plusplus: "error"*/
          
          var foo = 0;
          foo++;
          
          var bar = 42;
          bar--;
          
          for (i = 0; i < l; i++) {
              return;
          }
          

          此规则的正确代码示例:

          /*eslint no-plusplus: "error"*/
          
          var foo = 0;
          foo += 1;
          
          var bar = 42;
          bar -= 1;
          
          for (i = 0; i < l; i += 1) {
              return;
          }
          

          选项

          该规则还有一个选项 { "allowForLoopAfterthoughts": true },它允许在 for 循环中使用一元运算符 ++--

          此规则的正确代码示例包含以下 { "allowForLoopAfterthoughts": true } 选项:

          /*eslint no-plusplus: ["error", { "allowForLoopAfterthoughts": true }]*/
          
          for (let i = 0; i < 10; i++) {
            // ...
          }
          
          for (let i = 10; i > 0; i--) {
            // ...
          }
          

          此规则的错误代码示例包含以下 { "allowForLoopAfterthoughts": true } 选项:

          /*eslint no-plusplus: ["error", { "allowForLoopAfterthoughts": true }]*/
          let i, j, l;
          
          for (i = 0, j = l; i < l; i++, j--) {
            // ...
          }
          
          for (let i = 0; i < 10; j = i++) {
            // ...
          }
          
          for (i = l; i--;) {
            // ...
          }
          
          for (i = 0; i < l;) i++;
          

          参考链接

          ]]>
          <![CDATA[ESLint 在代码源文件添加配置的方法]]> https://github.com/tofrankie/blog/issues/126 https://github.com/tofrankie/blog/issues/126 Sat, 25 Feb 2023 13:28:02 GMT 除了通过添加配置文件的方式,来指定 ESLint 相关规则之外,还可以在源文件中使用 JavaScript 注释来指定。

          1. 指定环境

          /* eslint-env node, browser */
          
          // 相当于
          {
            &]]>
                      除了通过添加配置文件的方式,来指定 ESLint 相关规则之外,还可以在源文件中使用 JavaScript 注释来指定。

          1. 指定环境

          /* eslint-env node, browser */
          
          // 相当于
          {
            "env": {
              "browser": true,
              "node": true
            }
          }
          

          2. 指定全局变量

          /* global var1, var2 */
          
          // 选择性地指定这些全局变量可以被写入(而不是只被读取)
          /* global var1:writable, var2:readonly */
          
          // 由于历史原因,
          // 布尔值 false 和字符串值 "readable" 等价于 "readonly"。
          // 布尔值 true 和字符串值 "writeable" 等价于 "writable"。但是,不建议使用旧值。
          // 使用字符串 "off" 禁用全局变量。假如当前环境不支持使用 Promise 可以通过 "Promise": "off" 全局禁用。
          {
            "globals": {
              "var1": "writable",
              "var2": "readonly"
            }
          }
          

          3. 指定禁用或者启用规则

          /* eslint eqeqeq: "off", curly: "error" */
          /* eslint eqeqeq: 0, curly: 2 */
          
          // 如果一个规则有额外的选项,你可以使用数组字面量指定它们,比如:
          /* eslint quotes: ["error", "double"], curly: 2 */
          
          // 规则 plugin1/rule1 表示来自插件 plugin1 的 rule1 规则。你也可以使用这种格式的注释配置,比如:
          /* eslint "plugin1/rule1": "error" */
          
          // 相当于
          {
            "rules": {
              "eqeqeq": "off",
              "curly": "error",
              "quotes": ["error", "double"],
              "plugin1/rule1": "error"
            }
          }
          

          4. 临时禁止规则出现警告

          /* eslint-disable */
          alert('foo');
          /* eslint-enable */
          

          关于临时禁止出现警告,看这里

          ]]>
          <![CDATA[使 Prettier 一键格式化 WXSS(结局篇)]]> https://github.com/tofrankie/blog/issues/125 https://github.com/tofrankie/blog/issues/125 Sat, 25 Feb 2023 13:15:55 GMT

          ⚠️ Deprecated

        写在前面

        最近,在处理部门前端项目由 SVN 迁移 Git 的事情。由于历史代码在此之前并没有引入类似 ESLintESLintPrettier 的代码检查或者格式约束等工具。

        目前部门仅剩我一人维护这十几个小程序、H5 前端项目。现在只要接触以前没有经手的项目,就头疼不想改,很无奈,谁让我是一个打工人呢!

        本文将会结合 ESLint、Prettier、husky、lint-stage 展开介绍,旨在在代码格式化、代码检查上减少时间浪费。

        完整示例:tofrankie/wechat_applet_demo

        共三篇:

        扩展篇:

        前面终究还是留下了一些不太完美的地方。加之,今天看到了 Prettier Configuration OverridesSetting the parser option 配置项。

        于是我发现:

        1. 使用 Gulp.js 处理 wxss 文件反而是多此一举了;
        2. 同时很好地解决了下集关于使用 lint-staged 仅处理暂存文件的问题。

        好吧,使用 Gulp 来处理的方式并非完全不可取,起码给我提供了一个思路,可供参考。

        调整

        首先,Prettier 是支持对某些文件扩展名,文件夹和特定文件进行不同的配置这里

        Overrides let you have different configuration for certain file extensions, folders and specific files.

        1. 对 Prettier 配置做调整:
        {
          overrides: [
            {
              files: ['*.wxss', '*.acss'],
              options: {
                parser: 'css'
              }
            },
            {
              files: ['*.wxml', '*.axml'],
              options: {
                parser: 'html'
              }
            },
            {
              files: ['*.wxs', '*.sjs'],
              options: {
                parser: 'babel'
              }
            }
          ]
        }
        
        1. 调整 NPM 脚本命令

        因为无需使用 Gulp.js 了,移除 gulpfile.js 以及相关依赖包,然后对 npm scripts 调整下:

        {
          "scripts": {
            "eslint": "eslint . --ext .js,.wxs,.sjs",
            "eslint:fix": "eslint --fix . --ext .js,.wxs,.sjs",
            "prettier:fix": "prettier --config .prettierrc.js --write './**/*.{js,sjs,wxs,css,wxss,acss,wxml,axml,less,scss,json}'",
            "format:all": "npm-run-all -s prettier:fix eslint:fix"
          },
        }
        
        1. 调整 husky 及 lint-staged 配置

        由于无需再使用到函数的形式,我们将原先的 .lint-stagedrc.js 配置文件移除,然后放到 package.json 中。

        // package.json
        {
          "husky": {
            "hooks": {
              "pre-commit": "lint-staged"
            }
          },
          "lint-staged": {
            "*.{js,wxs,sjs}": [
              "prettier --config .prettierrc.js --write",
              "eslint --fix --ext .js"
            ],
            "*.{json,wxml,axml,css,wxss,acss,wxml,axml,less,scss}": "prettier --config .prettierrc.js --write"
          }
        }
        

        最后

        完整示例:tofrankie/wechat_applet_demo

        ]]>
        <![CDATA[使 Prettier 一键格式化 WXSS(下集)]]> https://github.com/tofrankie/blog/issues/124 https://github.com/tofrankie/blog/issues/124 Sat, 25 Feb 2023 13:15:21 GMT

        ⚠️ Deprecated

        写在前面

        最近,在处理部门前端项目由 SVN 迁移 Git 的事情。由于历史代码在此之前并没有引入类似 ESLintESLintPrettier 的代码检查或者格式约束等工具。

        目前部门仅剩我一人维护这十几个小程序、H5 前端项目。现在只要接触以前没有经手的项目,就头疼不想改,很无奈,谁让我是一个打工人呢!

        本文将会结合 ESLint、Prettier、husky、lint-stage 展开介绍,旨在在代码格式化、代码检查上减少时间浪费。

        完整示例:tofrankie/wechat_applet_demo

        共三篇:

        扩展篇:

        上一篇介绍了如何一键格式化 wxss 文件。今天介绍利用 Git Hooks 钩子实现提交代码自动执行此前的 ESLint、Prettier 命令,以保证我们提交的代码是不丑的。

        Git 钩子

        Git 提供了一些钩子,能在特定的重要操作发生时触发自定义脚本。

        当我们执行 git init 初始化一个 Git 版本库时,Git 会默认在 .git/hooks 目录中放置一些示例脚本(Shell 脚本)。这些示例脚本都是以 .sample 结尾,如果你想启用它们,得先移除这个后缀。

        把一个正确命名(不带扩展名)且可执行的文件放入 .git/hooks 目录下,即可激活该钩子脚本。 这样一来,它就能被 Git 调用。

        常用钩子

        pre-commit

        该钩子在键入提交信息前运行。 它用于检查即将提交的快照,例如,检查是否有所遗漏,确保测试运行,以及核查代码。 如果该钩子以非零值退出,Git 将放弃此次提交,不过你可以用 git commit --no-verify 来绕过这个环节。 你可以利用该钩子,来检查代码风格是否一致、尾随空白字符是否存在,或新方法的文档是否适当等等。

        husky

        husky 是一个为 Git 客户端增加 hook 的工具。当其安装到所在仓库时,它会自动在 .git/hooks 增加相应的钩子实现在 pre-commit 阶段就执行一系列保证每一个 commit 的正确性。

        当然,pre-commit 阶段执行的命令,当然要保证其速度不要太慢,每次 commit 都等很久也不是好的体验。

        安装 npm-run-all

        它用于同步或者并行执行 npm script 脚本。

        $ yarn add --dev npm-run-all@4.1.5
        

        于是乎,结合之前的 npm script,再通过 npm-run-all 来把几个命令串起来。

        {
          "scripts": {
            "format:all": "npm-run-all -p prettier:wxss:acss prettier:fix -s eslint:fix"
          }
        }
        

        这行命令做了什么:首先并行执行 prettier:wxss:acssprettier:fix 两个命令,等到执行完之后才会执行 eslint:fix 命令。

        • npm-run-all -p 表示并行操作。
        • npm-run-all -s 表示按顺序操作。
        • 它同时提供了上面两条命令的简写版 API,分别对应 run-prun-s

        因为 prettier:wxss:acssprettier:fix 匹配的文件没有重合的,所以可以并行操作。至于为什么先进行 Prettier 格式化,再进行 ESLint 检查,因为它们两个是存在冲突的。

        虽然我们可以在 .eslintrc.js 引入相关插件进行配置,使其当 Prettier 规则不符合 ESLint 规则时进行报错提醒,但没有解决我们的痛点,它需要我们手动去修复。

        还有,总是可能会存在先执行 ESLint,再进行 Prettier 的情况。所以我就想着整合这个脚本,使其按照我们预期方向走:当两者有冲突的情况时,采用 ESLint 的规则。

        完整脚本如下:

        {
          "scripts": {
            "test": "echo \"Error: no test specified\" && exit 1",
            "eslint": "eslint ./ --ext .js",
            "eslint:fix": "eslint --fix ./ --ext .js",
            "prettier:fix": "prettier --config .prettierrc.js --write './**/*.{js,css,less,scss,json}'",
            "prettier:wxss": "gulp wxss",
            "prettier:acss": "gulp acss",
            "prettier:wxss:acss": "gulp all",
            "format:all": "npm-run-all -p prettier:wxss:acss prettier:fix -s eslint:fix"
          }
        }
        

        安装 husky

        $ yarn add --dev husky@4.3.0
        

        package.json 添加配置,使其在进行 git commit -m 'xxx' 代码提交时,进行格式化操作,以保证我们提交的代码是不丑的。

        如果过程中出现错误(如 ESLint 校验不通过),将会停止 commit 操作,即 pre-commit 返回非零结果以退出。

        它可以通过 git commit --no-verify 命令进行忽略。

        // package.json
        {
          "husky": {
            "hooks": {
              "pre-commit": "yarn run format:all"
            }
          }
        }
        

        验证

        我们随便修改一个文件,然后进行提交。如图,可以看到是按照预期执行的,好了。

        lint-staged

        看到上面的结果,似乎一切顺利。但没有完...

        从上图我们看出来,我们只提交了一个文件的变动,但是它对所有文件进行了扫描,这里是存在体验性问题的。

        假如我们有 N 多个暂存文件,那么每当我们 git commit 一次就所有检查所有文件一遍,这导致我们的体验非常不好,过程很慢,显然不是我们想要的。

        那么如何解决呢?我们需要用到它 lint-staged

        $ yarn add --dev lint-staged@10.3.0
        

        自 v3.1 版本开始,可以有多种不同的方式进行配置,这里不多说。

        在项目根目录创建一个 .lintstagedrc.js 的配置文件,然后通过 --config 或者 -c 指定。

        // package.json
        {
          "husky": {
            "hooks": {
              "pre-commit": "lint-staged --config .lintstagedrc.js"
            }
          }
        }
        
        // .lintstagedrc.js
        const path = require('path')
        
        module.exports = {
          '*.js': ['prettier --config .prettierrc.js --write', 'eslint --fix --ext .js'],
          '*.json': 'prettier --config .prettierrc.js --write',
          '*.wxss': absolutePaths => {
            // 获取相对路径
            // const cwd = process.cwd()
            // const relativePaths = absolutePaths.map(file => path.relative(cwd, file))
            // return `gulp wxss --path ${relativePaths.join(' ')}`
        
            return 'gulp wxss'
          },
          '*.acss': 'gulp acss'
        }
        

        注意,我们不将路径作为命令调用时的参数传递。这一点很重要,因为 lint-staged 将为我们完成这一点。

        lint-staged 采用的是 glob 匹配模式。从上面的配置中,通过匹配不同的文件类型执行相应的操作。

        关于 lint-staged 相关使用说明,建议查看官方文档或者较瘦的这篇文章,我就不再详说。

        不知道有没有人好奇,上面 lint-staged 配置文件中,我在匹配 .wxss 文件时采用的是函数形式。

        其实这里是存在一个问题没解决的,就是在提交 .wxss 暂存文件时,不是只处理该 .wxss 文件,而是将项目所有的 .wxss 文件(包含未提交至暂存区的 .wxss 文件)。

        原因大概如下: 1 在前面我介绍了,由于 Prettier 没有解析器去处理 .wxss 扩展名的文件,所以我们使用了 Gulp.js 通过转换文件类型的方式去处理。而对应 Gulp 任务是匹配当前项目下所有 .wxss 文件的,使用 gulp.dest(__dirname) 是正常导出到源文件路径下。

        2 按照 lint-staged 的思想,只处理提交的暂存文件。意味着我们在执行 gulp wxss 任务时应该要传递一个文件路径,然后再修改 wxssPrettier 任务,使其既能匹配所有的,也可以匹配个别或多个的(而非所有).wxss 文件。然后我尝试了很多几种方法,都没能得到预期效果。

        3 我踩坑思路大致是:在执行 gulp wxss 时传递一个或者多个路径参数(如上配置文件注释部分),通过 process.argv 获取 NPM 脚本参数,接着在 wxssPrettier 任务中对获取的参数做处理,往 gulp.src() 传递一个数组,到这来我觉得思路应该是没错的。但是现实是残酷的,在 gulp.dest() 时导出的路径总是不对,所有的 .wxss 文件都被导出到项目根目录下了,这显然不是我们想要的结果。在 Stack Overflow 看到一个帖子,应该跟我这个问题类似。

        4 目前我还没找到更好的解决方案,欢迎大佬们赐教。

        就是因为这个问题,我觉得我这个 tofrankie/wechat_applet_demo 还不是很完美(我有强迫症),若后续有解决方案了,会回来更新的。

        有解决方案了,快去看结局篇

        最后

        到这里基本就结束了,但可能还会加入 Commit Message 提交说明的规范,因为一个清晰明了的提交说明,可以让人很清楚本次代码提交的目的或者解决了什么具体问题。目前使用最广的应该是 Angular 规范了,比较合理和系统化,而且有配套的工具。

        补充了一篇关于 Git Commit Message 规范的文章这里

        若有不足之处,欢迎留言指正。

        The end.

        ]]>
        <![CDATA[使 Prettier 一键格式化 WXSS(上集)]]> https://github.com/tofrankie/blog/issues/123 https://github.com/tofrankie/blog/issues/123 Sat, 25 Feb 2023 13:14:56 GMT

        ⚠️ Deprecated

        写在前面

        最近,在处理部门前端项目由 SVN 迁移 Git 的事情。由于历史代码在此之前并没有引入类似 ESLintESLintPrettier 的代码检查或者格式约束等工具。

        目前部门仅剩我一人维护这十几个小程序、H5 前端项目。现在只要接触以前没有经手的项目,就头疼不想改,很无奈,谁让我是一个打工人呢!

        本文将会结合 ESLint、Prettier、husky、lint-stage 展开介绍,旨在在代码格式化、代码检查上减少时间浪费。

        完整示例:tofrankie/wechat_applet_demo

        共三篇:

        扩展篇:

        编辑器插件

        使用到 VS Code 插件:

        相关配置以保存时自动格式化:

        {
          "files.associations": {
            "*.wxss": "css",
            "*.wxs": "javascript",
            "*.acss": "css",
            "*.axml": "html",
            "*.wxml": "html",
            "*.swan": "html"
          },
          "files.trimTrailingWhitespace": true,
          "eslint.workingDirectories": [{ "mode": "auto" }],
          "eslint.enable": true, // 是否开启 vscode 的 eslint
          "eslint.options": {
            // 指定 vscode 的 eslint 所处理的文件的后缀
            "extensions": [".js", ".ts", ".tsx"]
          },
          "eslint.validate": ["javascript"],
          "editor.codeActionsOnSave": {
            "source.fixAll.eslint": true
          },
          "git.ignoreLimitWarning": true
        }
        

        开始

        安装 ESLint、Prettier 相关依赖

        避免重复造轮子,社区上已有最佳实践,选择它们即可,比如 airbnb、standard、prettier 等。如果团队有特殊要求,自定义一些规则即可。

        这里选择的是国内腾讯 AlloyTeam 团队出品的 eslint-config-alloy

        其实他们团队最开始使用 Airbnb 规则,但是由于它过于严格,部分规则还是需要个性化,导致后来越改越多,最后决定重新维护一套。经过两年多的打磨,现在 eslint-config-alloy 已经非常成熟了。

        我选择它的几点原因:

        • 适用于 React/Vue/Typescript 项目
        • 样式相关规则由 Prettier 管理
        • 中文文档和网站示例
        • 更新快,且拥有官方维护的 vue、typescript、react+typescript 规则
        $ yarn add --dev babel-eslint@10.0.3
        $ yarn add --dev eslint@6.7.1
        $ yarn add --dev eslint-config-alloy@3.7.1
        $ yarn add --dev eslint-config-prettier@6.10.0
        $ yarn add --dev eslint-plugin-prettier@3.1.4
        $ yarn add --dev prettier@2.0.5
        $ yarn add --dev prettier-eslint-cli@5.0.0
        

        ESLint、Prettier 配置文件

        按需配置,仅供参考。

        .eslintrc.js 👇

        module.exports = {
          root: true,
          parser: 'babel-eslint',
          env: {
            browser: true,
            es6: true,
            node: true,
            commonjs: true
          },
          extends: ['alloy'],
          plugins: ['prettier'],
          globals: {
            Atomics: 'readonly',
            SharedArrayBuffer: 'readonly',
            __DEV__: true,
            __WECHAT__: true,
            __ALIPAY__: true,
            App: true,
            Page: true,
            Component: true,
            Behavior: true,
            wx: true,
            my: true,
            swan: true,
            getApp: true,
            getCurrentPages: true
          },
          parserOptions: {
            ecmaVersion: 2018,
            sourceType: 'module'
          },
          rules: {
            'no-debugger': 2,
            'no-unused-vars': 1,
            'no-var': 0,
            'no-param-reassign': 0,
            'no-irregular-whitespace': 0,
            'no-useless-catch': 1,
            'max-params': ['error', 3],
            'array-callback-return': 1,
            eqeqeq: 0,
            indent: ['error', 2, { SwitchCase: 1 }]
          }
        }
        

        .prettierrc.js 👇

        module.exports = {
          printWidth: 120,
          tabWidth: 2,
          useTabs: false,
          semi: false,
          singleQuote: true,
        
          // 对象的 key 仅在必要时用引号
          quoteProps: 'as-needed',
        
          // jsx 不使用单引号,而使用双引号
          jsxSingleQuote: false,
        
          // 末尾不需要逗号
          trailingComma: 'none',
        
          // 大括号内的首尾需要空格
          bracketSpacing: true,
        
          // jsx 标签的反尖括号需要换行
          jsxBracketSameLine: false,
        
          // 箭头函数,只有一个参数的时候,无需括号
          arrowParens: 'avoid',
        
          // 每个文件格式化的范围是文件的全部内容
          rangeStart: 0,
        
          rangeEnd: Infinity,
        
          // 不需要写文件开头的 @prettier
          requirePragma: false,
        
          // 不需要自动在文件开头插入 @prettier
          insertPragma: false,
        
          // 使用默认的折行标准
          proseWrap: 'preserve',
        
          // 根据显示样式决定 html 要不要折行
          htmlWhitespaceSensitivity: 'css',
        
          // 换行符使用 lf
          endOfLine: 'lf'
        }
        

        配置 ESLint、Prettier 忽略文件

        按需配置,仅供参考。

        .eslintignore 👇

        # .eslintignore
        
        *.min.js
        typings
        node_modules
        

        .prettierignore 👇

        *.min.js
        /node_modules
        /dist
        # OS
        .DS_Store
        .idea
        .editorconfig
        .npmrc
        package-lock.json
        # Ignored suffix
        *.log
        *.md
        *.svg
        *.png
        *ignore
        ## Built-files
        .cache
        dist
        

        EditorConfig 配置文件

        它是用来抹平不同编辑器之间的差异的。同样放置在项目根目录下。

        按需配置,仅供参考。

        .editorconfig 👇

        # .editorconfig
        # http://editorconfig.org
        # https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties
        
        
        # 根目录的配置文件,编辑器会由当前目录向上查找,如果找到 `roor = true` 的文件,则不再查找
        root = true
        
        # 匹配所有的文件
        [*]
        # 缩进风格:space
        indent_style = space
        # 缩进大小 2
        indent_size = 2
        # 换行符 lf
        end_of_line = lf
        # 字符集 utf-8
        charset = utf-8
        # 不保留行末的空格
        trim_trailing_whitespace = true
        # 文件末尾添加一个空行
        insert_final_newline = true
        # 运算符两遍都有空格
        spaces_around_operators = true
        
        # 对所有的 js 文件生效
        [*.js]
        # 字符串使用单引号
        quote_type = single
        
        [*.md]
        trim_trailing_whitespace = false
        

        添加 NPM Scripts

        添加三条脚本指令:

        {
          "scripts": {
            "eslint": "eslint ./ --ext .js",
            "eslint:fix": "eslint --fix ./ --ext .js",
            "prettier:fix": "prettier --config .prettierrc.js --write './**/*.{js,css,less,scss,json}'"
          }
        }
        

        这样就可以一键格式化和修复了,注意 ESLint 使用 --fix 只能修复一部分问题,部分需手动解决。

        {
          "name": "wechat_applet_demo",
          "version": "1.0.0",
          "description": "微信小程序 Demo",
          "main": "app.js",
          "repository": "git@github.com:toFrankie/wechat_applet_demo.git",
          "author": "Frankie <1426203851@qq.com>",
          "license": "MIT",
          "private": true,
          "scripts": {
            "eslint": "eslint ./ --ext .js",
            "eslint:fix": "eslint --fix ./ --ext .js",
            "prettier:fix": "prettier --config .prettierrc.js --write './**/*.{js,css,less,scss,json}'"
          },
          "devDependencies": {
            "babel-eslint": "10.0.3",
            "eslint": "6.7.1",
            "eslint-config-alloy": "3.7.1",
            "eslint-config-prettier": "6.10.0",
            "eslint-plugin-prettier": "3.1.4",
            "prettier": "2.0.5",
            "prettier-eslint-cli": "5.0.0"
          }
        }
        

        还没完

        接下来涉及 Gulp.js 内容,是为了让 Prettier 处理 Gulp.js 转换出来的 css,以达到最终 Prettier 格式化处理 wxss 的目的。

        上述方式走了一些弯路,其实通过 Overrides 配置是可以指定 .wxss 文件使用指定的解析器的。换句话说,我们可以在处理 .wxss 文件时使用 CSS 解析器去处理它就好了,具体看结局篇

        本文最想分享的是下面的内容,前面很简单。

        Prettier 支持的 JavaScript、JSX、Angular、Vue、Flow、TypeScript、CSS、Less、Scss、HTML、JSON、GraphQL、Markdown(GFM、MDX)、YAML 的代码格式化。

        但其实是不能识别 wxssacss 等小程序特有的层叠样式,尽管它们规则与 CSS 无异,但是 Prettier 并没有解析器去解析它们。

        我们试图去调整脚本命令为(添加 *.wxss 扩展名的文件):

        {
          "scripts": {
            "prettier:fix": "prettier --config .prettierrc.js --write './**/*.wxss'",
          }
        }
        

        然后去执行的时候就会报错,如下:

        [error] No parser could be inferred for file: app.wxss

        既然这样走不通的话,总不能利用 VS Code 的 Prettier 插件一个一个地去格式化 *.wxss 的文件吧,那样工作量太大了,不符合我们“偷懒”的做法。下面使用 Gulp 来处理。

        Gulp

        简单说下 Gulp 的工作方式,它使用的是 Node.js 中的 stream(流),首先获取到需要的 stream,然后通过 streampipe() 方法把流导入到你想要的地方。比如 Gulp 插件中,经过插件处理后的流又可以导入到其他插件汇总,当然也可以把流写入文件中,所以 Gulp 是以 stream 为媒介的,它不需要频繁的生成临时文件,这也是 Gulp 的速度比 Grunt 快的一个原因。

        我刚开始时的想法是:首先将 wxssacss)转换并导出为 css,接着删除 wxssacss)文件,再者使用 Prettier 对 css 文件进行格式化,转回 wxssacss)之后,再删除掉 css 文件。这个过程会频繁的生成临时文件,思路是有点像 Grunt。

        但是了解了 Gulp 的思想后,其实它帮我们省掉了频繁增删文件的环节,全部放在内存中操作,也会更快一些,所以此前的方案被我否掉了。

        下面我们只用到 Gulp 的其中两个 API, gulp.src()gulp.dest()

        gulp.src()

        这个方法是用来获取流的,但要注意这个流里面的内容不是原始的文件流,而是一个虚拟文件对象流(Vinyl files),这个虚拟文件对象中存储着原始文件的路径、文件名、内容等信息。(这里不深入,点到为止,有兴趣自行了解)

        语法:gulp.src(globs[, options])

        • globs:是文件匹配模式,用来匹配文件路径(包括文件名)
        • options:为可选参数,通常情况我们不需要用到

        关于参数详细说明,请看文档

        gulp.dest()

        该方法是用来写文件的

        gulp.dest(path[, options])

        • path:是写入文件的路径
        • options:为可选参数,通常情况我们不需要用到

        要想使用好 gulp.dest() 这个方法,就要理解给它传入的路径参数与最终生成的文件的关系。

        Gulp 的使用流程一般是:首先通过 gulp.src() 方法获取到我们想要处理的文件流,然后把文件流通过 pipe() 方法导入到 Gulp 的插件中,最后把经过插件处理后的流再通过 pipe() 方法导入到 gulp.dest() 中,gulp.dest() 方法则把流中的内容写入到文件中。

        这里需要弄清楚的一点是,我们给 gulp.dest() 传入的路径参数,只能用来指定要生成的文件的目录,而不能指定生成文件的文件名,它生成文件的文件名使用的是导入到它的文件流自身的文件名,所以生成的文件名是由导入到它的文件流决定的,即使我们给它传入一个带有文件名的路径参数,然后它也会把这个文件名当做是目录名,例如:

        const gulp = require('gulp')
        gulp.src('script/jquery.js').pipe(gulp.dest('dist/foo.js'))
        
        // 最终生成的文件路径为 dist/foo.js/jquery.js,而不是 dist/foo.js
        

        若需要修改文件名,需要使用插件 gulp-rename

        • 关于上述 Gulp 的 API 与方法说明,主要参考自官方文档与无双的一篇文章

        Gulp 配置

        首先,安装 Gulp 相关依赖包。

        $ yarn add --dev gulp@4.0.2
        $ yarn add --dev gulp-clean@0.4.0
        $ yarn add --dev gulp-debug@4.0.0
        $ yarn add --dev gulp-prettier@3.0.0
        $ yarn add --dev gulp-rename@2.0.0
        

        接着,我们在项目根目录下创建一个 gulpfile.js 文件。思路如下:

        使用 gulp.src() 获取流,然后使用 Gulp 插件对流分别作重命名(gulp-rename)、格式化(gulp-prettier)、再重命名回来(gulp-rename)、最后导出(gulp.dest())。过程中有利用 gulp-debug 插件来查看一些信息。

        这里我对微信小程序、支付宝小程序的层叠样式都处理了。

        // gulpfile.js
        const { series, parallel, src, dest } = require('gulp')
        const rename = require('gulp-rename')
        const debug = require('gulp-debug')
        const clean = require('gulp-clean')
        const prettier = require('gulp-prettier')
        const config = require('./.prettierrc')
        
        // wxss 一键格式化
        const wxssPrettier = () => {
          return src('./**/*.wxss')
            .pipe(
              // 可以利用插件,查看一些 debug 信息
              debug()
            )
            .pipe(
              // 重写扩展名为 css,才能被 Prettier 识别解析
              rename({
                extname: '.css'
              })
            )
            .pipe(
              // Prettier 格式化
              prettier(config)
            )
            .pipe(
              // 重新将扩展名改为 wxss
              rename({
                extname: '.wxss'
              })
            )
            .pipe(
              // 导出文件
              dest(__dirname)
            )
        }
        
        // acss 一键格式化
        const acssPrettier = () => {
          return src('./**/*.acss')
            .pipe(debug())
            .pipe(
              rename({
                extname: '.css'
              })
            )
            .pipe(prettier(config))
            .pipe(
              rename({
                extname: '.acss'
              })
            )
            .pipe(dest(__dirname))
        }
        
        // 这里导出多个 task,通过 gulp xxx 就能来调用了,如 gulp all
        // 关于 series、parallel API 分别是按顺序执行(同步)、同时执行(并行)
        module.exports = {
          all: parallel(wxssPrettier, acssPrettier),
          wxss: wxssPrettier,
          acss: acssPrettier
        }
        

        在配置下 NPM Script:

        {
          "scripts": {
            "prettier:wxss": "gulp wxss",
            "prettier:accs": "gulp acss",
            "prettier:wxss:acss": "gulp all"
          }
        }
        

        结果如下,说明配置成功了。

        Git Hooks

        上面已经实现了对 wxssacss 扩展名的文件进行一键格式化了。

        还可以“更懒”一些,利用 git-hooks 我们可实现在 commit 之前,对项目进行 ESLint、Prettier 检测和格式化,一旦出现错误,将停止 commit 操作。

        本文篇幅过长,放到下篇接着...

        插个题外话

        由于本项目的 npm 包仅用于代码检查与格式化,并未参与页面代码逻辑中。所以我在小程序本地项目配置文件中添加上打包配置选项

        packOptions 用以配置项目在打包过程中的选项。打包是预览、上传时对项目进行的必须步骤。

        目前可以指定 packOptions.ignore 字段,用以配置打包时对符合指定规则的文件或文件夹进行忽略,以跳过打包的过程,这些文件或文件夹将不会出现在预览或上传的结果内。

        需要注意的是支付宝小程序,在编写本文时还未支持类似 ignore 选项。

        // project.config.json
        {
          "packOptions": {
            "ignore": [
              {
                "type": "regexp",
                "test": "\\.md$"
              },
              {
                "type": "folder",
                "test": "node_modules"
              }
            ]
          }
        }
        
        ]]>
        <![CDATA[ESLint 禁止规则出现警告的 5 种方式]]> https://github.com/tofrankie/blog/issues/122 https://github.com/tofrankie/blog/issues/122 Sat, 25 Feb 2023 13:13:43 GMT 我们一般会在 ESLint 配置文件 rules 中针对我们的项目做一些个性化的禁用规则的配置。

        比如我们配置文件中,有以下这一条禁用规则,意味着所有被检测的文件中都不允许使用 alert() 方法。

        {
          rules: {
            'no-alert': 2
          }
        }
        

        假如我们调试某个功能时需要用到 alert() 方法,但又不想 ESLint 检测出现警告,要怎么做呢?

        其实,ESLint 支持在你的文件中使用行注释或者块注释的方式来禁止(某些)规则。

        1. 通过块注释来临时禁止规则出现警告
        /* eslint-disable */
        alert('foo')
        /* eslint-enable */
        
        1. 对指定的规则启用或禁用警告
        /* eslint-disable no-alert, no-console */
        alert('foo')
        console.log('bar')
        /* eslint-enable no-alert, no-console */
        
        1. 通过行注释或块注释在某一特定的行上禁用所有规则
        // 当前行
        alert('foo') // eslint-disable-line
        alert('foo') /* eslint-disable-line */
        
        // 关闭下一行校验
        // eslint-disable-next-line
        alert('foo')
        /* eslint-disable-next-line */
        alert('foo')
        
        1. 在文件顶部加上块注释,使整个文件范围内禁止规则出现警告
        /* eslint-disable */
        
        alert('foo')
        

        若临时禁止某个或多个规则出现警告,可以在末尾跟上要禁止的规则,比如:// eslint-disable-line no-alert 可以在当前行使用 alert() 时禁止出现警告。多个规则时,使用逗号 (,) 隔开。

        另外,以上所有方法同样适用于插件规则。例如,禁止 eslint-plugin-examplerule-name 规则,把插件名(example)和规则名(rule-name)结合为 example/rule-name

        1. 若要禁用一组文件的配置文件中的规则,请使用 overridesfiles。例如:
        {
          rules: {...},
          overrides: [
            {
              files: ['*-test.js','*.spec.js'],
              rules: {
                'no-unused-expressions': 0
              }
            }
          ]
        }
        

        最后

        注意:为文件的某部分禁用警告的注释,告诉 ESLint 不要对禁用的代码报告规则的冲突。ESLint 仍解析整个文件,然而,禁用的代码仍需要是有效的 JavaScript 语法。

        ]]>
        <![CDATA[应该拥抱 ESLint]]> https://github.com/tofrankie/blog/issues/121 https://github.com/tofrankie/blog/issues/121 Sat, 25 Feb 2023 13:11:11 GMT 最最最简单的 ESLint 使用案例。

        ESLint 重心在代码质量上,而 Prettier 只关心代码格式。

        1. 初始化项目
        # 创]]>
                    最最最简单的 ESLint 使用案例。

        ESLint 重心在代码质量上,而 Prettier 只关心代码格式。

        1. 初始化项目
        # 创建项目 HelloESLint
        $ mkdir HelloESLint
        
        # 进入项目目录
        $ cd HelloESLint
        
        # 初始化项目
        $ npm init
        
        1. 安装 eslint
        $ npm i --save-dev eslint
        
        1. 运行命令自动创建 .eslintrc 文件
        # 方式一(推荐)
        $ npx eslint --init
        
        # 方式二
        $ node ./node_modules/eslint/bin/eslint.js --init
        

        // .eslintrc.js
        module.exports = {
            "env": {
                "es6": true,
                "node": true
            },
            "extends": "eslint:recommended",
            "globals": {
                "Atomics": "readonly",
                "SharedArrayBuffer": "readonly"
            },
            "parserOptions": {
                "ecmaVersion": 2018,
                "sourceType": "module"
            },
            "rules": {
        
            }
        };
        
        1. 创建 src/index.jssrc/common.js 文件
        // index.js
        var a = 123;
        
        // common.js
        function sum(a, b) {
          return a + b;
        }
        
        1. eslint 检查
        # 单文件检查
        $ npx eslint ./src/index.js
        
        # 多文件检查
        $ npx eslint ./src/index.js ./src/common.js
        
        # 或用通配符的方式
        $ npx eslint ./src/*.js
        
        1. eslint 检查结果

        为什么出现报错呢?如何屏蔽此类错误提示?如何自定义 ESLint 规则呢?

        1. 在我们的 .eslintrc 文件中,我们看到 "extends": "eslint:recommended" 这一行,其实是采用了 ESLint 推荐的规则,该规则页面在这里,里面就包括其中一项:no-unused-vars(禁止出现未使用过的变量),就是我们上面报错的原因。
        2. ESLint 并不推荐任何编码风格,规则是自由的。(跟第一点是不是很矛盾),其实并不是。eslint:recommended 它只是是涵盖了行业普遍的最佳实践而已,并不是完全适合任何一个开发者或者团队,还有 Airbnb 的 eslint-config.airbnb、腾讯 Alloy 团队的 eslint-config-alloy 等深受开发者喜爱的配置规则。
        3. 自定义 ESLint 规则,我们可以在 .eslintrc 文件中 rules 中添加符合自己的规则。假如我要屏蔽上面的错误,我们可以添加:"no-unused-vars": "off",再执行检查就不会报此类错误了。

        更便捷地实时检测插件

        在上面,我们需要执行命令 npx eslint youfile.js 才知道结果,但如果使用 Visual Studio Code 进行开发的话,可以安装 ESLint 插件,如果发生不符合规则,会直接报错。

        最后,上面的教程只是一个最最简单的案例去说明如何使用 ESLint 而已,其实现在前端项目都是工程化了,ESLint 最佳的实践应该是结合 React、Vue、Angular 等使用才对,还有加上 Prettier,后面有时间会继续写的,谢谢。

        ]]>
        <![CDATA[如何在 React 中添加 !important 的行内样式?]]> https://github.com/tofrankie/blog/issues/120 https://github.com/tofrankie/blog/issues/120 Sat, 25 Feb 2023 13:10:19 GMT 配图源自 Freepik

        前言

        不知道你有没有发现,在 React 中是无法给行内样式]]> 配图源自 Freepik

        前言

        不知道你有没有发现,在 React 中是无法给行内样式添加 !important 权重的。

        // not worked
        export default function App() {
          return (
            <div className="app" style={{ fontSize: '30px !important' }} >
              React App
            </div>
          )
        }
        

        如果非要用,可以用 Callback Refs 处理,比如:

        // worked
        export default function App() {
          return (
            <div
              className="app"
              ref={el => el && el.style.setProperty('font-size', '30px', 'important')}
            >
              React App
            </div>
          )
        }
        

        相关讨论可看:

        Support !important for styles? facebook/react #1881

        To be fair, the ref solution doesn’t help for server rendering. Again, if you have a particular API proposal, sending an RFC would be a better place to discuss it. Thank you! commented by dan

        为什么 React 不支持呢?

        首先,这需求的实现是没有任何技术难度的。但 React 为什么不做呢,个人猜测可能是开发者滥用 !important 处理样式样式优先级。虽说如此,但理应支持,总会遇到「用魔法打败魔法」的场景的。

        关于 !important 的一些看法

        当在一个样式声明中使用一个 !important 规则时,此声明将覆盖任何其他声明。

        虽然,从技术上讲,!important 与优先级无关,但它与最终的结果直接相关。

        使用 !important 是一个坏习惯,应该尽量避免,因为这破坏了样式表中的固有的级联规则,使得调试找 bug 变得更加困难了。当两条相互冲突的带有 !important 规则的声明被应用到相同的元素上时,拥有更大优先级的声明将会被采用。

        一些经验法则(摘自 MDN

        • 一定要优先考虑使用样式规则的优先级来解决问题而不是 !important
        • 只有在需要覆盖全站或外部 CSS 的特定页面中使用 !important
        • 永远不要在你的插件中使用 !important
        • 永远不要在全站范围的 CSS 代码中使用 !important
        • 与其使用 !important,你可以:
          1. 更好地利用 CSS 级联属性
          2. 使用更具体的规则。在您选择的元素之前,增加一个或多个其他元素,使选择器变得更加具体,并获得更高的优先级。
          3. 对于(2)的一种特殊情况,当您无其他要指定的内容时,请复制简单的选择器以增加特异性。
        ]]>
        <![CDATA[细读 React | React Router 路由切换原理]]> https://github.com/tofrankie/blog/issues/119 https://github.com/tofrankie/blog/issues/119 Sat, 25 Feb 2023 13:09:32 GMT 2022 北京冬奥会开幕式

        此前一直在疑惑,明明 pushState()、]]> 2022 北京冬奥会开幕式

        此前一直在疑惑,明明 pushState()replaceState() 不触发 popstate 事件,可为什么 React Router 还能挂载对应路由的组件呢?

        翻了一下 remix-run/history 源码,终于知道原因了。

        源码

        假设项目路由设计如下:

        import { render } from 'react-dom'
        import { BrowserRouter, Routes, Route } from 'react-router-dom'
        import { Mine, About } from './routes'
        import App from './App'
        
        const rootElement = document.getElementById('root')
        
        render(
          <BrowserRouter>
            <Routes>
              <Route path="/" exact element={<App />} />
              <Route path="/mine" element={<Mine />} />
              <Route path="/about" element={<About />} />
            </Routes>
          </BrowserRouter>,
          rootElement
        )
        

        然后我们看下 <BrowserRouter /> 的源码(react-router-dom/modules/BrowserRouter.js),以下省略了一部分无关代码:

        import React from 'react'
        import { Router } from 'react-router'
        import { createBrowserHistory as createHistory } from 'history'
        
        /**
         * The public API for a <Router> that uses HTML5 history.
         */
        class BrowserRouter extends React.Component {
          // 构建 history 对象
          history = createHistory(this.props)
        
          render() {
            // 将 history 对象等传入 <Router /> 组件
            return <Router history={this.history} children={this.props.children} />
          }
        }
        
        // ...
        
        export default BrowserRouter
        

        接着我们继续看下 <Router /> 组件的源码(react-router/modules/Router.js),如下:

        import React from 'react'
        import HistoryContext from './HistoryContext.js'
        import RouterContext from './RouterContext.js'
        
        /**
         * The public API for putting history on context.
         */
        class Router extends React.Component {
          static computeRootMatch(pathname) {
            return { path: '/', url: '/', params: {}, isExact: pathname === '/' }
          }
        
          constructor(props) {
            super(props)
        
            this.state = {
              location: props.history.location
            }
            
            // 关键点:
            // 当触发 popstate 事件、
            // 或主动调用 props.history.push()、props.history.replace() 方法时,
            // 都会执行 history 对象的 listen 方法,使得执行 setState 强制更新当前组件
            this.unlisten = props.history.listen(location => {
              this.setState({ location })
            })
          }
        
          componentWillUnmount() {
            // 组件卸载时,解除监听
            if (this.unlisten) this.unlisten()
          }
        
          render() {
            return (
              // 由于 React Context 的特性,所有消费 RouterContext.Provider 的 Custom 组件
              // 在其 value 值发生变化时,都会重新渲染。
              // 当前 <Router /> 组件并没有做任何限制重新渲染的处理,
              // 因此每次 setState 都会引起 RouterContext.Provider 的 value 值发生变化。
              <RouterContext.Provider
                value={{
                  history: this.props.history,
                  location: this.state.location,
                  match: Router.computeRootMatch(this.state.location.pathname),
                  staticContext: this.props.staticContext
                }}
              >
                <HistoryContext.Provider children={this.props.children || null} value={this.props.history} />
              </RouterContext.Provider>
            )
          }
        }
        
        export default Router
        

        原因剖析

        往下之前,如果对 History API 或者 URL Fragment 不了解的,可以看下这篇文章:History 对象及事件监听详解

        react-router-dom 引用了 remix-run/history 库 ,它主要提供了三种方法:createBrowserHistorycreateHashHistorycreateMemoryHistory

        它们用于构建对应模式的 history 对象(请注意,它有别于 window.history 对象),该对象的属性和方法可在 Devtools 中清晰地看到(如下图),也可查阅文档。这个太简单了,你们都懂,不说了。

        本文讨论的是 History 模式,因而对应 createBrowserHistory 方法。

        在构建项目路由时,选择 <BrowserRouter /> 组件,它内部是将通过 createBrowserHistory() 方法构造的 history 对象传递给 <Router /> 组件。

        我们知道,在 React 应用中切换路由,它会加载对应的组件。我们知道 createBrowserHistory() 利用了 HTML5 History API 特性,但是主动调用 window.history.pushState()window.history.replaceState() 方法都不会触发 popstate 事件,因此,如果仅通过监听 popstate 事件是不能完全实现路由切换的。

        那么 React Router 是如何解决问题的呢?

        在前面的源码部分,其实已经添加了一些注解,<Router /> 组件它内部依赖于 Context 的 Provider/Comsumer 模式。因此,它只要做到 URL 发生变化时更新 Context.Providervalue 值即可,至于后续如何加载组件就交给 React 了(当然里面还包括 React Router 的路由匹配,但非本文讨论内容,不展开讲述)。

        一般情况下,<BrowserRouter /> 都会作为整个项目的根路由,它包裹了一层 <Router /> 组件,<Router /> 组件在实例化时,设置了一个监听函数:

        // props.history 就是通过 createBrowserHistory(props) 生成的对象
        this.unlisten = props.history.listen(location => {
          // 回调函数的作用是,通过 setState 触发 Router 组件更新,
          // 使得 Provider 的 value 值发生变化,以带动 Consumer 的更新。
          this.setState({ location })
        })
        // this.unlisten 是一个函数,执行它内部会移除 popstate 事件监听器
        

        history.js 是如何做到每当 URL 发生变化,会触发这个回调函数的?

        在 React 中是通过调用组件的 props.history.push()props.history.replace() 方法实现路由切换的。

        我们来看一下 remix-run/history 的源码(history/esm/history.js):

        里面省略了一部分代码,然后分析顺序已经按顺序标注出来。

        function createTransitionManager() {
          // ...
        
          function confirmTransitionTo(location, action, getUserConfirmation, callback) {
            var result = typeof prompt === 'function' ? prompt(location, action) : prompt
        
            if (typeof result === 'string') {
              if (typeof getUserConfirmation === 'function') {
                getUserConfirmation(result, callback)
              } else {
                process.env.NODE_ENV !== 'production' ? warning(false, 'A history needs a getUserConfirmation function in order to use a prompt message') : void 0
                callback(true)
              }
            } else {
              // Return false from a transition hook to cancel the transition.
              callback(result !== false)
            }
          }
        
          var listeners = []
        
          function appendListener(fn) {
            var isActive = true
        
            function listener() {
              if (isActive) fn.apply(void 0, arguments)
            }
        
            // 添加监听器
            listeners.push(listener)
            return function () {
              isActive = false
              // 过滤重复的监听器
              listeners = listeners.filter(function (item) {
                return item !== listener
              })
            }
          }
        
          // 6️⃣ 执行 listeners 中所有的 listener 监听器,
          // 最后触发 <Router /> 中的回调函数 this.unlisten = props.history.listen(location => { this.setState({ location }) }) 逻辑
          function notifyListeners() {
            // 将类数组 arguments 转换为数组形式
            for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
              args[_key] = arguments[_key]
            }
        
            listeners.forEach(function (listener) {
              // 回调函数将获得 location、action 两个参数
              return listener.apply(void 0, args)
            })
          }
        
          return {
            setPrompt: setPrompt,
            confirmTransitionTo: confirmTransitionTo,
            appendListener: appendListener,
            notifyListeners: notifyListeners
          }
        }
        
        /**
         * Creates a history object that uses the HTML5 history API including
         * pushState, replaceState, and the popstate event.
         */
        function createBrowserHistory(props) {
          // ...
        
          // 创建 location 对象
          function getDOMLocation(historyState) {
            var _ref = historyState || {},
              key = _ref.key,
              state = _ref.state
        
            var _window$location = window.location,
              pathname = _window$location.pathname,
              search = _window$location.search,
              hash = _window$location.hash
            var path = pathname + search + hash
        
            if (basename) path = stripBasename(path, basename)
            return createLocation(path, state, key)
          }
        
          // ...
        
          // 创建 transitionManager 对象
          var transitionManager = createTransitionManager()
        
          // 5️⃣ 主要更新 history 对象,并调用 notifyListeners 方法
          function setState(nextState) {
            _extends(history, nextState)
        
            history.length = globalHistory.length
            // 执行 transitionManager 中的所有 listeners
            transitionManager.notifyListeners(history.location, history.action)
          }
        
          // 3️⃣ popstate 事件监听器的处理函数
          function handlePopState(event) {
            // getDOMLocation 方法用于生成 location 对象,location: { hash, pathname, search, state }
            // handlePop 方法,主要是用于触发 setState 方法
            handlePop(getDOMLocation(event.state))
          }
        
          // 4️⃣ 用于调用 setState 方法
          function handlePop(location) {
            var action = 'POP'
            transitionManager.confirmTransitionTo(location, action, getUserConfirmation, function (ok) {
              if (ok) {
                setState({
                  action: action,
                  location: location
                })
              }
            })
          }
        
          // 7️⃣
          // 这里的 push 和 replace 方法,是利用了 window.history.pushState() 和 window.history.replaceState()
          // 他们不会触发 popstate 事件,因此无法执行 handlePopState 方法,因此我们需要主动执行 setState() 方法,进而
          // 执行 notifyListeners() 以使得 <Router /> 组件的回调被执行,使得组件进行更新。
          function push(path, state) {
            // ...
            var action = 'PUSH'
            var location = createLocation(path, state, createKey(), history.location)
            // 将会执行 confirmTransitionTo 的 callback 函数
            transitionManager.confirmTransitionTo(location, action, getUserConfirmation, function (ok) {
              if (!ok) return
              var href = createHref(location)
              var key = location.key,
                state = location.state
        
              if (canUseHistory) {
                globalHistory.pushState(
                  {
                    key: key,
                    state: state
                  },
                  null,
                  href
                )
        
                if (forceRefresh) {
                  window.location.href = href
                } else {
                  var prevIndex = allKeys.indexOf(history.location.key)
                  var nextKeys = allKeys.slice(0, prevIndex + 1)
                  nextKeys.push(location.key)
                  allKeys = nextKeys
                  // 调用 setState() 方法,然后里面会执行 notifyListeners 方法,并触发 listeners 的所有监听器
                  setState({
                    action: action,
                    location: location
                  })
                }
              } else {
                window.location.href = href
              }
            })
          }
        
          function replace(path, state) {
            // 与 push 方法同理,省略...
          }
        
          // 8️⃣
          // 这里的 go()、goBack()、goForward() 全是利用了 History API 的能力,
          // 他们都会触发 popstate 事件,因此都会执行 handlePopState 方法。
          function go(n) {
            globalHistory.go(n)
          }
        
          function goBack() {
            go(-1)
          }
        
          function goForward() {
            go(1)
          }
        
          var listenerCount = 0
        
          // 2️⃣ 注册/移除 popstate 事件监听器
          function checkDOMListeners(delta) {
            listenerCount += delta
        
            if (listenerCount === 1 && delta === 1) {
              // 添加 popstate 事件监听器,执行 handlePopState 时将会触发 setState
              window.addEventListener(PopStateEvent, handlePopState)
              if (needsHashChangeListener) window.addEventListener(HashChangeEvent, handleHashChange)
            } else if (listenerCount === 0) {
              // 移除事件监听器
              window.removeEventListener(PopStateEvent, handlePopState)
              if (needsHashChangeListener) window.removeEventListener(HashChangeEvent, handleHashChange)
            }
          }
        
          // 1️⃣ 设置监听器,以触发 <Router /> 组件中的回调函数
          function listen(listener) {
            // 往 transitionManager 中的 listeners 数组添加新的监听器 listener,
            // 其中 transitionManager 对象有这些方法:{ setPrompt, confirmTransitionTo, appendListener, notifyListeners }
            var unlisten = transitionManager.appendListener(listener)
        
            // 负责添加、移除 popstate 事件监听器
            checkDOMListeners(1)
        
            // 执行回调函数移除 listener 监听器
            return function () {
              checkDOMListeners(-1)
              unlisten()
            }
          }
        
          var history = {
            length: globalHistory.length,
            action: 'POP',
            location: initialLocation,
            createHref: createHref,
            push: push,
            replace: replace,
            go: go,
            goBack: goBack,
            goForward: goForward,
            block: block,
            listen: listen
          }
          return history
        }
        

        以下是 history.js 中创建的 historylocation 对象的一些属性和方法:

        先回到 <Router /> 组件中的 history.listen(fn),它主要做几件事:

        • fn 保存在负责存储监听器的 listeners 数组中,未来它将会被 notifyListeners() 方法调用。
        • 注册 popstate 事件监听器,触发之后,会执行 notifyListeners() 方法
        • 在 React 组件中调用 props.history.push() 等方法,也将会触发 notifyListeners() 方法。
        • 执行 notifyListeners() 方法,会执行 listeners 中所有的 listener,因此 fn 将会被触发。
        • 执行 fn() 触发 Component 中的 setState() 方法更新 <Router /> 组件,即 Router.Providervalue 发生改变,那么 Router.Consumer 就会跟着更新

        所以,React Router 是利用了 Context 的 Provider/Custom 特性,解决了 pushState/replaceState 不触发 popstate 事件时实现了路由切换的问题。

        The end.

        ]]>
        <![CDATA[细读 React | Refs]]> https://github.com/tofrankie/blog/issues/118 https://github.com/tofrankie/blog/issues/118 Sat, 25 Feb 2023 13:08:29 GMT 配图源自 Freepik

        在经典的 React 数据流中,props 是父组件]]> 配图源自 Freepik

        在经典的 React 数据流中,props 是父组件与子组件交互的唯一方式,而且 props 是自上而下(由父及子)进行传递的。后来,由于一些全局性的属性需要在各个组件中共享,但鉴于 props 需逐层手动添加极其繁琐,于是 React 提供了一种全新的方式 Context,它无需在组件树中逐层传递 props,属于 Provider/Consumer 模式。

        但是,在某些情况下,你需要在典型数据流之外强制修改子组件。被修改的子组件可能是一个 React 组件的实例,也可能是一个 DOM 元素。为此 React 提供了 Refs。

        以下场景适合使用 Refs:

        • 管理焦点,文本选择或媒体播放
        • 触发强制动画
        • 集成第三方 DOM 库

        我想,Refs 最常用的场景应该是获取某个真实 DOM 元素或者 React 组件实例吧。其实不止于此,还可以用于组件通信等高阶一点的用法。

        一、Refs 基础

        1. Ref 创建与访问

        以下方式可以创建 Ref 对象:

        • React.useRef():适用于函数组件
        • React.createRef():适用于类组件
        • 回调 Ref:可用于函数组件或类组件
        • 字符串 Ref:已过时,不建议使用...

        优先选择前两种,若 React 版本较低再考虑后面的两种方式。

        React.useRef()

        适用于 React 16.8 + 函数组件

        React.useRef(initialValue) 方法返回一个 Ref 对象,该对象只有一个 current 属性。其中 initialValue 参数用于指定 current 的初始值。当参数缺省时 currentundefined

        import React from 'react'
        
        function Parent() {
          const domRef = React.useRef()
          const classRef = React.useRef()
          const funcRef = React.useRef()
        
          useEffect(() => {
            console.log(domRef.current) // 指向 div 节点
            console.log(classRef.current) // 指向 Child1 组件实例
            console.log(funcRef.current) // undefined
          }, [])
        
          return (
            <>
              <div ref={domRef}>这是DOM节点</div>
              <Child1 ref={classRef}>这是类组件</Child1>
              <Child2 ref={funcRef}>这是函数组件</Child2>
            </>
          )
        }
        

        我们知道,函数组件每一次更新是通过重新调用函数实现的,意味着里面的变量会重新创建,那么使用 Hook 才能保留上一次的引用。因此,请不要在函数组件内使用 React.createRef()

        还有,若在函数组件上设置 ref 属性,由于函数组件是没有实例的,因此类似 <Child2 ref={funcRef} /> 设置 ref 属性是无效的。当你在任意地方访问 funcRef.current 的时候只会得到初始值

        在类组件或函数组件内,无论 Ref 对象是通过哪一种方式创建的,只要给子函数组件设置 Refs,开发模式下都会发出如下警告:

        Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

        关于 React.forwardRef() 下文会介绍的。

        React.createRef()

        适用于 React 16.3 + 类组件。由于函数组件的渲染机制,此方法不适合用于函数组件。若低于 React 16.3 版本,请使用回调 Ref。

        React.createRef() 方法返回一个 Ref 对象,该对象只有一个 current 属性,初始值为 null。将来 current 属性会指向 Ref 对象所绑定的 DOM 节点或 React(类)组件。绑定方式很简单,只要将 Ref 对象添加到 ref 属性上即可。

        import React from 'react'
        
        class Parent extends React.Component {
          domRef = React.createRef()
          classRef = React.createRef()
          funcRef = React.createRef()
        
          componentDidMount() {
            console.log(this.domRef.current) // 指向 div 节点
            console.log(this.classRef.current) // 指向 Child1 组件实例
            console.log(this.funcRef.current) // null
          }
        
          render = () => (
            <>
              <div ref={this.domRef}>这是DOM节点</div>
              <Child1 ref={this.classRef}>这是类组件</Child1>
              <Child2 ref={this.funcRef}>这是函数组件</Child2>
            </>
          )
        }
        

        从上述示例中,可以看到 Ref 对象的创建与访问很简单。

        回调 Ref

        适用于 React 16.2 及以下版本

        这种方式可以更精细地控制何时设置和解除 Refs。

        它的创建方式不同于 React.createRef()React.useRef(),你需要在 DOM 节点或 React(类)组件中传递一个函数,这个函数接受 React 组件实例或 DOM 节点作为参数,使得它们能在其他地方被存储和访问。

        import React from 'react'
        
        class Parent extends React.Component {
          componentDidMount() {
            console.log(this.domRef) // 指向 div 节点
            console.log(this.classRef) // 指向 Child1 组件实例
            console.log(this.funcRef) // undefined
          }
        
          setCallbackRef(instKey) {
            return ref => {
              // 将函数赋予 ref 属性时,对应的 DOM 节点、类组件实例作为函数参数返回。
              // 作用于函数组件,将不符合任何参数,即 ref 为 undefined。
              this[instKey] = ref
            }
          }
        
          render = () => (
            <>
              <div ref={this.setCallbackRef('domRef')}>这是DOM节点</div>
              <Child1 ref={this.setCallbackRef('classRef')}>这是类组件</Child1>
              <Child2 ref={this.setCallbackRef('funcRef')}>这是函数组件</Child2>
            </>
          )
        }
        

        字符串 Ref

        这是一个过时的 Ref,不建议使用。它存在一些问题,可能会在未来的版本中移除。

        创建字符串 Ref 非常简单,在 DOM 节点或 React(类)组件的 ref 属性设置为一个字符串即可,它们将会绑定到当前组件实例的 refs 对象下,ref 属性的名称将作为 refs 对象的键名。

        import React from 'react'
        
        class Parent extends React.Component {
          componentDidMount() {
            // 所有字符串 Ref 将会被添加到组件实例的 refs 对象上。
            console.log(this.refs.domRef) // 指向 div 节点
            console.log(this.refs.classRef) // 指向 Child1 组件实例
            console.log(this.refs.funcRef) // undefined
          }
        
          render = () => (
            <>
              <div ref="domRef">这是DOM节点</div>
              <Child1 ref="classRef">这是类组件</Child1>
              <Child2 ref="funcRef">这是函数组件</Child2>
            </>
          )
        }
        

        2. 绑定与解除 Ref

        React 16.4 及更高版本的生命周期如下,若不了解,先简单看看,以便于后续理解。

        一个 React 组件完整的生命周期包括了 Mounting(挂载)、Updating(更新)、Unmounting(卸载)三个阶段。每一阶段又可以再细分为 Render、Pre-commit、Commit 阶段。例如 constructor() 只存在于 Mounting 的 Render 阶段;Unmounting 只含 Commit 阶段。

        注意,React 16.3 对于 getDerivedStateFromProps 方法稍有不同,但不影响本文讨论的内容。

        在类组件中,我们通常的做法是,在构造组件(即 constructor() 方法内)时,将创建的 Ref 对象挂载到实例属性,以便可以在整个组件中引用它们。

        例如:

        class Comp extends React.Component {
          constructor(props) {
            this.xxxRef = React.createRef()
          }
          
          // 或者
          // xxxRef = React.createRef()
        }
        

        以上两种方式,都将 xxxRef 挂载到 Comp 实例上,在组件的任意生命周期方法内都能访问。

        好了,前面提到调用 React.createRef() 方法返回 Ref 对象的值为 { current: null },那什么时候才会将 React 组件实例或 DOM 节点绑定到 Ref 对象的 current 属性上呢?

        • 在组件挂载的 Render 阶段绑定 Refs
        • 在组件卸载的 Commit 阶段解除 Refs

        例如 React.createRef(),在组件挂载时将组件实例或 DOM 节点关联到 xxxRef.current 上。当组件卸载时,xxxRef.current 又会传入 null,实现解除目的。

        至于将 Ref 对象存放在哪,是你的自由,但通常会挂载到组件实例上,方便调用。

        3. createRef 与 useRef

        前面提到 React.createRef() 不要在函数组件内使用,为什么呢?

        举个例子:

        import React from 'react'
        
        function Comp() {
          const domRef = React.createRef()
          const [num, setNum] = useState(0)
        
          const focus = () => domRef.current.focus()
          const update = () => setNum(num + 1)
        
          return (
            <>
              <input ref={domRef} />
              <button onClick={focus}>聚焦</button>
              <button onClick={update}>点击触发更新 {num}</button>
            </>
          )
        }
        

        在上述示例中,我们在函数组件 Comp 中使用 React.createRef() 创建了一个 Ref 对象 domRef,其关联了 input 节点,另外还有一个聚焦按钮,点击时聚焦 input 输入框。而最后一个更新按钮用于触发组件更新。

        我们知道,函数组件每一次更新是通过重新调用函数实现的,意味着里面的变量会重新创建。换句话说,每一次 Comp 函数被调用,domRef 都是一个全新的变量。

        在这个示例中,“似乎”在函数组件中使用 React.createRef() 也没问题,对吗?虽然 domRef 每次函数执行都会重新创建,但也会关联到 input 节点上,因此点击聚焦按钮触发 domRef.current.focus() 也没问题。

        我们来改造一下上面的示例:

        import React from 'react'
        
        function Comp() {
          const domRef = createRef()
          const [num, setNum] = useState(0)
        
          useEffect(() => {
            setNum(1) // 触发一个更新
            setTimeout(() => {
              domRef.current.focus() // 这里能正常聚焦吗?
            }, 3000)
          }, [])
        
          return <input ref={domRef} />
        }
        

        以上示例,将会报错!

        原因也很简单。前面说了,每一次更新都会重新执行 Comp() 函数。简单分析一下:

        当我们第一次加载 Comp 组件时,创建了一个 domRef 变量(假设称为 domRef1),当 Comp 渲染完毕会执行副作用操作 useEffect 的回调函数,里面的 setNum(1) 将会触发一次更新,并创建了一个异步任务,异步任务中存在对 domRef1 的引用。

        然后在下一次渲染之前,Ref 对象会被解除,并传入 null,即 domRef1 = { current: null }

        然后执行 Comp() 函数重新渲染,又会创建一个 domRef 变量(假设称为 domRef2)。显然 domRef2domRef1 不是同一个变量。然后 3 秒过去了,定时器被触发 domRef.current.focus(),那么这里的 domRefdomRef1 还是 domRef2 呢?

        如果对闭包不熟悉的话,我们打个断点,看看:

        显然定时任务内的 domRefdomRef1,即上一次的 domRef 变量,由于 domRef.currectnull,自然会抛出错误。

        说了那么多只是为了强调:函数组件内请不要使用 React.createRef()

        至于为什么仍引用着 domRef1,原因自然是闭包。

        **闭包是基于词法作用域书写代码时所产生的自然结果。**变量的作用域与函数如何执行没关系,跟如何创建有关系。这就是闭包形成的原因(下面举个例子简单说下,若已理解闭包直接跳过,有兴趣者看)。

        var a = 'global'
        
        function foo() {
          var a = 'local'
          function bar() {
            console.log(a) // 无论 bar 何时何地调用,总会打印 local
            // 注意,词法作用域与 this 是两回事,别混淆了。
          }
        }
        
        // 当执行函数 foo() ,会创建函数执行上下文(可看作是一个对象,包含了 { AO, Scope, this } 三个属性):
        // 
        // * AO :它会记住当前函数内声明的变量(含形参)、函数、 arguments (非箭头函数)等。
        // * Scope :它是基于当前函数 foo 的 [[scope]] 属性 + 当前执行上下文的 AO 对象组成的,这个就是常说的作用域链。
        // * this :它跟函数如何调用有关(但跟闭包没关系,不展开讲述)
        // 
        // 有一点很重要,就是函数被定义时,其内部属性 [[scope]] 就会记录当前的 Scope 。比如当 bar() 被调用,要查找变量 a ,它先从 bar 执行上下文中的 Scope 查找,发现当前 AO 对象没有,于是往上一层 Scope 中查找并成功找到变量 a ,值为 local ,就停止查找了。
        // 
        // 回到这个 Comp 例子,它本身只是一个函数而已。每当执行一次 Comp() 函数,会创建一个全新的执行上下文, AO 会记录变量 domRef (当然 num 、 setNum 它也会记录的),
        // ...
        // 由于词法作用域与函数如何调用没关系,所以你不用管 useEffect 、 useEffect 内部的函数、及其回调函数是如何调用的。你只要清楚内部各种函数没有定义一个名为 domRef 的变量即可。
        // ...
        // 然后执行到 setTimeout 这行代码,会在 useEffect 回调函数内创建一个匿名的箭头函数,尽管我们没有办法引用它,但 AO 也会记住的。由于 Comp 之后的每个执行上下文中都没有 domRef 变量,所以最终执行匿名箭头函数,寻找变量 domRef 时,总会往作用域链上找到 Comp.[[scope]] ,并从其 AO 对象上找到了 domRef 变量,值为 { current: null } 。
        

        就前面 Comp 的示例,使用回调 Ref 或字符串 Ref 的方式也是不可以的,原因同理。唯有使用 React.useRef() 解决,我就不写 Demo 了,你们都懂。

        为什么 React.useRef() 能解决这个问题呢?

        原因也很简单,当第一次加载函数组件时,执行 React.useRef() 生成一个 Ref 对象,React 会将其在某个神秘的角落记录起来,后面组件更新再从小黑屋里将原先的 Ref 对象取出来(下文再详解)。

        二、Refs 进阶

        是的,前面都是 Refs 的基础用法,也是必须要掌握的内容。

        那么进阶 Refs 是什么呢?主要是利用 React.forwardRef() API 对 Ref 对象进行转发,它是 React 16.3 新增的特性,称为“Refs 转发”。

        1. 转发 Refs 到 DOM 节点

        前面介绍的 Refs 都有些缺点:

        • 无法在函数组件上使用 ref 属性。
        • 在类组件上使用 ref 属性,只会得到组件实例。

        在以前,如果父组件的 Ref 对象要传递给子组件的某个 DOM 节点或者更下层,唯一方法只有变通地使用特殊的属性名来传递 Ref 对象。自 React 16.3 起,可以使用 React.forwardRef() 方案。例如:

        import React from 'react'
        
        class Parent extends React.Component {
          parentRef1 = React.createRef()
          parentRef2 = React.createRef()
        
          componentDidMount() {
            // 以下这两种方式都可以获取到子组件的 input 节点
            console.log(this.parentRef1.current)
            console.log(this.parentRef2.current)
          }
        
          render = () => (
            <>
              <div>这是父组件</div>
        
              {/* 原始方法:使用特殊的属性名来传递 */}
              <Child forwardRef={this.parentRef1}>这是子组件</Child>
        
              {/* ForwardRef 方法:可以将 Ref 对象直接传入 ref 属性,可以是类组件或函数组件  */}
              <NewChild ref={this.parentRef2}>这是子组件</NewChild>
            </>
          )
        }
        
        function Child(props) {
          return (
            <>
              <div>{props.children}</div>
              <input ref={props.forwardRef} placeholder="子组件的input" />
            </>
          )
        }
        
        
        // 第二个参数 ref 只在使用 React.forwardRef 定义组件时存在,函数组件或类组件不接收 ref 参数。
        const NewChild = React.forwardRef((props, ref) => (
          <Child {...props} forwardRef={ref} />
        ))
        

        说真的,这种转发 Refs 个人感觉很鸡肋,是我没 Get 到吗?

        2. 高阶组件转发 Refs

        高阶组件是参数为组件,返回值为新组件的函数。

        高阶组件定义如上,假设我们不对 Refs 进行转发,当我们给高阶组件包装后的新组件添加 ref 属性,显然这个 Ref 对象将会指向高阶组件返回的新组件的实例。这种场景下,Refs 转发就显得很重要了。

        import React from 'react'
        
        class Parent extends React.Component {
          parentRef = React.createRef()
        
          componentDidMount() {
            // 将会获取到 Child 组件
            console.log(this.parentRef.current)
          }
        
          render = () => (
            <>
              <div>这是父组件</div>
              <NewChild ref={this.parentRef}>这是子组件</NewChild>
            </>
          )
        }
        
        class Child extends React.Component {
          render = () => (
            <>
              <div>{this.props.children}</div>
              <input ref={this.props.forwardRef} placeholder="子组件的input" />
            </>
          )
        }
        
        function HOCWrapper(Comp) {
          // 这里的高阶组件啥也没做,就单纯做了个转发罢了
          // 为了举例而举例...
          class WrapperComponent extends React.Component {
            render() {
              const { forwardRef, ...others } = this.props
              return <Comp {...others} ref={forwardRef} />
            }
          }
        
          // 如果这里不做转发,将来对 Comp 作用的 Ref 对象,将指向 WrapperComponent 组件实例
          return React.forwardRef((props, ref) => <WrapperComponent {...props} forwardRef={ref} />)
        }
        
        const NewChild = HOCWrapper(Child)
        

        三、深入 Refs

        未完待续...

        ]]>
        <![CDATA[细读 React | Fragment]]> https://github.com/tofrankie/blog/issues/117 https://github.com/tofrankie/blog/issues/117 Sat, 25 Feb 2023 13:07:22 GMT 配图源自 Freepik

        React 中的一个常见模式是一个组件返回多个元素。Fragments 允许你]]> 配图源自 Freepik

        React 中的一个常见模式是一个组件返回多个元素。Fragments 允许你将子列表分组,而无需向 DOM 添加额外节点。

        假设我们要使用 React 组件渲染以下这段真实 DOM 节点。

        Some text.
        <h2>A heading</h2>
        More text.
        <h2>Another heading</h2>
        Even more text.
        

        要怎么做呢?很简单,谁都知道...

        React.Fragment 是在 React 16.2 新增的新特性,旧版本并不支持。下面我们从几个方面,说明 Fragment 的好处。

        一、React 16.0 之前

        在低于 React 16.0 的版本,类组件或函数组件有很多限制。

        比如,它们必须返回 React 元素null。其中 React 元素包括类似 <MyComponent /> 等自定义组件、类似 <div /> 等 DOM 节点元素。

        正确示例:

        function MyComponent() {
          // ✅ 合法,也可以是其他 HTML 元素
          return <div>...</div>
        }
        
        function MyComponent() {
          // ✅ 合法,返回 React 组件
          return <ChildComponent />
        }
        
        function MyComponent() {
          // ✅ 合法,不渲染任何真实 DOM 节点
          return null
        }
        

        错误示例:

        function MyComponent() {
          // ❌ 不能返回数组
          return [1, 2, 3].map((item, index) => (
            <div key={index}>{item}</div>
          ))
        
          // ✅ 但注意,下面这种包裹在 {} 内是合法的,
          // map 方法返回的数组,目测是除了子元素时,做了扁平化处理。
          // return (
          //   <div>
          //     {[1, 2, 3].map((item, index) => (
          //       <div key={index}>{item}</div>
          //     ))}
          //   </div>
          // )
        }
        
        function MyComponent() {
          // ❌ 一定要有返回值,跟 return null 是两回事
          return undefined
        }
        

        类组件同理。当不正确使用时,将会报错:

        Warning: MyComponent(...): A valid React element (or null) must be returned. You may have returned undefined, an array or some other invalid object.

        这种方案的缺点也是显而易见的,在组件的返回值上,总需要一层 <div><span> 或其他 DOM 节点包装起来。当 React 渲染成真实 DOM 时,这个包装节点总是会存在的。

        很多时候,往往这个包装节点对我们的 UI 层是没有意义的,反而加深了 DOM 树的层次。但很无奈,谁让我们要用 React 呢,人家语法限制就那样...

        二、React 16.0 起

        除了原来的 React 元素和 null 之外,新增了几种类型:

        • React 16.0 起支持返回数组Protals字符串数值布尔值
        • React 16.2 起支持返回 Fragment,个人认为这是对数组形式的一种增强用法。

        其中布尔值null 什么都不渲染,字符串或数值类型会渲染为文本节点

        例如:

        function MyComponent() {
          // ✅ 合法,支持数组了,需要添加 key 属性去避免警告,
          // 这种情况下,底层会默认嵌套一个 <Fragment> 包裹起来。
          return [1, 2, 3].map((item, index) => (
            <div key={index}>{item}</div>
          ))
        
          // 或者是
          // return [
          //   <div key="1">1</div>,
          //   <div key="2">2</div>,
          //   <div key="3">3</div>
          // ]
        }
        
        function MyComponent() {
          // ✅ 合法,自 React 16.2 起支持 Fragment 语法,不用像上面一样需要 key 了
          return (
            <React.Fragment>
              <div>1</div>
              <div>2</div>
              <div>3</div>
            </React.Fragment>
          )
        }
        
        function MyComponent() {
          // ✅ 合法,最终会渲染为文本节点(注意,不是 <span>some string...</span> 哦)
          return 'some string...'
        }
        

        相比 React 15.x 及更早版本,这种方式实在是太棒了。除了支持更多类型,最重要的是不会增加额外的节点。

        前面提到,React 15.x 里的 React 组件总是避免不了需要一层可能是“无谓”的节点节点进行包装,那么 React 16.0 的改进,可以解决如下场景:

        问题示例:

        function Table() {
          return (
            <table>
              <tbody>
                <tr>
                  <Columns />
                </tr>
              </tbody>
            </table>
          )
        }
        
        function Columns() {
          // 按照 React 15.x 的语法要求,Columns 组件的返回值,
          // 必须要用一个类似 div 元素等包装起来
          return (
            <div>
              <td>Hello</td>
              <td>World</td>
            </div>
          )
        }
        

        根据 W3C 的要求,一个合法的 <table><tr> 的子元素必须是 <td>。而 React 这种组件的写法直接破坏了 <table> 结构,最终也得不到我们的预期结果。

        一个合法的 <table> 结构应该是这样的,table > thead/tbody/tfoot > tr > td > div/other

        如果按照 React 16.x 提供的新特性,可以轻松解决...

        function Columns() {
          // React.Fragment 最终渲染为真实 DOM 并不会产生任何 DOM 节点,
          // 因此,不会破坏 <table> 的结构了。(数组形式也是可以的)
          return (
            <React.Fragment>
              <td>Hello</td>
              <td>World</td>
            </React.Fragment>
          )
        }
        

        三、Fragment

        自 React 16.2 起,开始支持 React.Fragment 语法。前面提到该特性是对数组形式的一种增强用法。

        语法

        它的语法非常简单,把它是 React 内置的一个 React 组件。

        <React.Fragment>
          // One or more child elements
        </React.Fragment>
        

        key 是唯一可以传递给 Fragment 的属性。将来可能会添加对其他属性的支持,例如事件处理程序。

        class App extends React.Component {
          state = {
            items: [
              {
                id: '`2`',
                name: '计算机',
                description: '用来计算的仪器...'
              },
              {
                id: '2',
                name: '显示器',
                description: '以视觉方式显示信息的装置...'
              }
            ]
          }
        
          render() {
            return <Glossary items={this.state.items} ></Glossary>
          }
        }
        
        function Glossary(props) {
          return (
            <dl>
              {props.items.map(item => (
                // 没有 `key`,React 会发出一个关键警告
                <React.Fragment key={item.id}>
                  <dt>{item.name}</dt>
                  <dd>{item.description}</dd>
                </React.Fragment>
              ))}
            </dl>
          )
        }
        

        也可以使用它的简写语法 <></>,但这种写法不接受任意属性,包括 key

        JSX 中的片段语法受到现有技术的启发,例如 E4X 中的 XMLList() <></> 构造函数。使用一对空标签是为了表示它不会向 DOM 添加实际元素的想法。

        对比

        回到文章开头的示例,要渲染这样一段真实 DOM 节点。

        Some text.
        <h2>A heading</h2>
        More text.
        <h2>Another heading</h2>
        Even more text.
        

        前面提到,可以有几种解决方案,各有利弊。

        解决方法一

        低于 React 16.0 版本,由于不支持 Fragment 和数组形式,唯一的方法是将它们包装在一个额外的元素中,通常是 divspan。如下:

        function MyComponent() {
          return (
            <div>
              Some text.
              <h2>A heading</h2>
              More text.
              <h2>Another heading</h2>
              Even more text.
            </div>
          )
        }
        

        但上述这种方法有个缺点,在渲染成真实 DOM 的时候,会增加一个节点,比如上述的 <div />

        解决方法二

        自 React 16.0 起,支持数组形式。因此可以这么做:

        function MyComponent() {
          return [
            'Some text.',
            <h2 key="heading-1">A heading</h2>,
            'More text.',
            <h2 key="heading-2">Another heading</h2>,
            'Even more text.'
          ]
        }
        

        这种方式有点麻烦,我们对比一下 Fragment 形式。

        解决方法三(推荐)

        自 React 16.2 起,支持 React.Fragment 语法,因此我们可以这样使用。

        function MyComponent() {
          return (
            <React.Fragment>
              Some text.
              <h2>A heading</h2>
              More text.
              <h2>Another heading</h2>
              Even more text.
            </React.Fragment>
          )
        }
        

        仔细对比数组和 Fragment 形式,可以发现数组形式有以下缺点:

        • 数组中的子项必须用逗号分隔。
        • 数组中的 children 必须有一个 key 来防止 React 的 key 警告。
        • 字符串必须用引号括起来。

        以上这些限制 Fragment 统统都没有,我们就按正常的思维去编写 DOM 节点就好了。

        四、参考链接

        ]]>
        <![CDATA[细读 React | PureComponet]]> https://github.com/tofrankie/blog/issues/116 https://github.com/tofrankie/blog/issues/116 Sat, 25 Feb 2023 13:06:23 GMT 配图源自 Freepik

        今天来聊一聊 React.Component 配图源自 Freepik

        今天来聊一聊 React.ComponentReact.PureComponentReact.memo 的一些区别以及使用场景。

        一、类组件定义

        在 React 中,可以通过继承 React.ComponentReact.PureComponent 来定义 Class 组件:

        import React, { Component, PureComponent } from 'react'
        
        class Comp extends Component {
          // ...
        }
        
        class PureComp extends PureComponent {
          // ...
        }
        

        两者很相似,区别在于 React.Component 并未实现 shouldComponentUpdate(),而 React.PureComponent 中以浅层对比 propstate 的方式来实现了该函数。

        如果赋予 React 组件相同的 propsstaterender() 函数会渲染相同的内容,那么在某些情况下使用 React.PureComponent 可提高性能。

        注意:React.PureComponent 中的 shouldComponentUpdate() 仅作对象的浅层比较。如果对象中包含复杂的数据结构,则有可能因为无法检查深层的差别,产生错误的比对结果。仅在你的 propsstate 较为简单时,才使用 React.PureComponent,或者在深层数据结构发生变化时调用 forceUpdate() 来确保组件被正确地更新。你也可以考虑使用 immutable 对象加速嵌套数据的比较。

        此外,React.PureComponent 中的 shouldComponentUpdate() 将跳过所有子组件树的 prop 更新。因此,请确保所有子组件也都是“纯”的组件。

        二、浅层对比实现

        我们来看下源码,它们是如何“浅层对比”的?

        首先,在非强制更新组件的情况下,若 propsstate 的变更,内部都会触发 checkShouldComponentUpdate 方法来判断是否重新渲染组件。若使用 forceUpdate() 强制更新组件的话,则会跳过该方法。

        // checkHasForceUpdateAfterProcessing 方法用于判断是否强制更新
        // 若不是强制更新,则会根据 checkShouldComponentUpdate 方法判断是否应该更新组件
        var shouldUpdate = checkHasForceUpdateAfterProcessing() || checkShouldComponentUpdate(workInProgress, ctor, oldProps, newProps, oldState, newState, nextContext);
        
        function checkShouldComponentUpdate(workInProgress, ctor, oldProps, newProps, oldState, newState, nextContext) {
          var instance = workInProgress.stateNode;
        
          // 若自实现了 shouldComponentUpdate 方法,则不会跑到后面的步骤
          if (typeof instance.shouldComponentUpdate === 'function') {
            startPhaseTimer(workInProgress, 'shouldComponentUpdate');
            var shouldUpdate = instance.shouldComponentUpdate(newProps, newState, nextContext);
            stopPhaseTimer();
        
            {
              !(shouldUpdate !== undefined) ? warningWithoutStack$1(false, '%s.shouldComponentUpdate(): Returned undefined instead of a ' + 'boolean value. Make sure to return true or false.', getComponentName(ctor) || 'Component') : void 0;
            }
        
            return shouldUpdate;
          }
        
          // 关键是这里:
          // 在 React 组件未实现 shouldComponentUpdate 前提下,
          // 可通过 isPureReactComponent 判断是否为 PureComponent 组件的原因是构造函数里设置了该属性的值为 true。
          // 使用 shallowEqual 方法来判断组件属性和状态时是否发生了变化,若两种均是“相等”,则返回 false,即不更新组件,否则会触发组件的 render() 方法以更新组件。
          if (ctor.prototype && ctor.prototype.isPureReactComponent) {
            return !shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState);
          }
        
          return true;
        }
        

        再看下 shallowEqual 的实现,不难:

        function shallowEqual(objA, objB) {
          // is$1 相当于 ES6 的 Object.is() 方法,比较两个操作数是否相等
          if (is$1(objA, objB)) {
            return true;
          }
        
          // 讲过上一步的排除之后,若 objA 或 ObjB 的值是“非引用类型”或 null,则可以确定 objA 与 objB 是不相等的。
          if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) {
            return false;
          }
        
          // 走到这步,说明 objA 和 objB 是两个不同的引用类型的值
          var keysA = Object.keys(objA);
          var keysB = Object.keys(objB);
          
          // 比较两者的属性数量是否一致,若不一致,则可确定两者是不相等的
          if (keysA.length !== keysB.length) {
            return false;
          } // Test for A's keys different from B.
        
          // 这里只遍历最外层的属性是否一致
          for (var i = 0; i < keysA.length; i++) {
            // hasOwnProperty$2 即 Object.prototype.hasOwnProperty;
            // 先比较 objA 的属性,在 objB 属性有没有,若无说明两者不相等,否则接着再判断同一属性值是否相等,
            // 这判断就比较简单了:Object.is() 是使用全等判断的,并认为 NaN === NaN 和 +0 !== -0 的。
            if (!hasOwnProperty$2.call(objB, keysA[i]) || !is$1(objA[keysA[i]], objB[keysA[i]])) {
              return false;
            }
          }
        
          // 否则,返回 true,认为它们相等。
          return true;
        }
        

        默认浅层对比方法,相当于:

        shouldComponentUpdate(nextProps, nextState) {
          return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState);
        }
        

        关于 Object.is() 解决了什么“奇葩”问题,可以看这篇文章 JavaScript 相等比较详解 的第三节内容。

        根据以上源码的分析,可以得出结论:

        • 若基于 React.PureComponent 的组件自实现了 shouldComponentUpdate() 方法,则会跳过默认的“浅层对比”,可以理解为覆盖了默认的 shouldComponentUpdate() 方法。。

        • 从源码可知,React.Component “未实现” shouldComponentUpdate() 是因为内部返回了 true 而已。

        • React.PureComponent 的浅层对比,主要分为三步判断:1️⃣ 对比 oldPropsnewProps 是否相等,若相等则返回 false,否则继续往下走;2️⃣ 接着判断 oldPropsnewProps (此时可以确定两者是不相等的引用值了)的第一层属性,若属性数量或者属性 key 不一致,则认为两者不相等并返回 true,否则继续往下走;3️⃣ 判断对应属性的属性值是否相等,若存在不相等则返回 true,否则返回 false。对于 oldStatenewState 的判断同理。

          注意:这里提到的返回值 true/false 是指 !shalldowEqual() 的结果,相当于 shouldComponentUpdate() 的返回值

        三、示例及注意事项

        基于以上结论,来看几个示例吧。

        先明确几点:

        • 使用 setState() 来更新状态,无论状态值是否真的发生了改变,都会产生一个全新的对象,即 oldState !== newState
        • 组件的 props 对象是 readonly(只读)的,React 会保护它不被更改,否则会出错。
        • 每次父组件的重新渲染,子组件的 props 都会是一个全新的对象,即 oldProps !== newProps
        • 一般情况下,组件实例的 props 值几乎都是一个引用类型的值,即对象,我还没想到有什么场景会出现 null 的情况。而组件实例的 state 值则可能是对象或 null,后者即无状态的类组件,当然这种情况下应可能使用函数组件。
        // 父组件
        class Parent extends React.Component {
          state = {
            number: 0, // 原始类型
            list: [] // 引用类型
          }
        
          changeList() {
            const { list } = this.state
            list.push(0)
            this.setState({ list })
          }
        
          changeNumber() {
            this.setState({ number: this.state.number + 1 })
          }
        
          render() {
            console.log('---> Parent Render.')
            return (
              <>
                <button onClick={this.changeNumber.bind(this)}>Change Number</button>
                <button onClick={this.changeList.bind(this)}>Change List</button>
                <Child num={this.state.number} lists={this.state.list} />
              </>
            )
          }
        }
        
        
        // 子组件
        class Child extends React.PureComponent {
          state = {
            name: 'child'
          }
        
          render() {
            console.log('---> Child Render.')
            return (
              <>
                <div>Child Component.</div>
              </>
            )
          }
        }
        

        1️⃣ 当我们点击父组件的 Change Number 按钮时,子组件会重新渲染。因为在对比子组件的 oldProps.numnewProps.num 时,两者的值不相等,因此会更新组件。在控制台可以看到:

        ---> Parent Render.
        ---> Child Render.
        

        2️⃣ 当我们点击父组件的 Change List 按钮时,子组件不会重新渲染。因为在对比子组件的 oldProps.listnewProps.list 时,它们都是引用类型,且两者在内存中的地址是一致的,而且不会更深层次地去比较了,因此 React 认为它俩是相等的,因此不会更新组件。在控制台只看到:

        ---> Parent Render.
        

        当然,这一点也是 React.PureComponent 的局限性,因此它应该应用于一些数据结构较为简单的展示类组件。

        另外,React.PureComponent 中的 shouldComponentUpdate() 将跳过所有子组件树的 prop 更新。因此,请确保所有子组件也都是“纯”的组件。

        四、延伸 React.memo

        如果在函数组件中,想要拥有类似 React.PureComponent 的性能优化,可以使用 React.memo

        const MyComponent = React.memo(function MyComponent(props) {
          /* 使用 props 渲染 */
        })
        

        React.memo 为高阶组件

        如果你的组件在相同 props 的情况下渲染相同的结果,那么你可以通过将其包装在 React.memo 中调用,以此通过记忆组件渲染结果的方式来提高组件的性能表现。这意味着在这种情况下,React 将跳过渲染组件的操作并直接复用最近一次渲染的结果。

        React.memo 仅检查 props 变更。如果函数组件被 React.memo 包裹,且其实现中拥有 useStateuseReducer 或 useContext 的 Hook,当 context 发生变化时,它仍会重新渲染。

        默认情况下其只会对复杂对象做浅层对比,如果你想要控制对比过程,那么请将自定义的比较函数通过第二个参数传入来实现。

        function MyComponent(props) {
          /* 使用 props 渲染 */
        }
        
        function areEqual(prevProps, nextProps) {
          /*
          如果把 nextProps 传入 render 方法的返回结果与
          将 prevProps 传入 render 方法的返回结果一致则返回 true,
          否则返回 false
          */
        }
        
        export default React.memo(MyComponent, areEqual)
        

        此方法仅作为性能优化的方式而存在。但请不要依赖它来“阻止”渲染,因为这会产生 bug。

        注意,与 class 组件中 shouldComponentUpdate() 方法不同的是,如果 props 相等,areEqual 会返回 true;如果 props 不相等,则返回 false。这与 shouldComponentUpdate 方法的返回值相反。

        简单来说,若需要更新组件,那么 areEqual 方法请返回 false,否则返回 true

        The end.

        ]]>
        <![CDATA[细读 React | setState]]> https://github.com/tofrankie/blog/issues/115 https://github.com/tofrankie/blog/issues/115 Sat, 25 Feb 2023 13:04:06 GMT 配图源自 Freepik

        今天来细聊一下 React 中的 setState() 配图源自 Freepik

        今天来细聊一下 React 中的 setState()。当然,今时今日大家都可能使用 Functional Component + Hook 替代 Class Component 了吧。尽管如此,也不妨碍我们去探寻那些“过时”的设计。

        那么,我们常用的 setState(),有什么鲜为人知的设计呢?

        抛出几个问题:

        • setState() 是同步还是异步?
        • setState() 什么场景下立即更新,什么场景批量更新?

        一、Props vs State

        propsstate 都是普通的 JavaScript 对象。它们都是用来保存信息的,这些信息可以控制组件的渲染输出,而它们的一个重要的不同点就是:props 是传递给组件的(类似于函数的形参),而 state 是在组件内被组件自己管理的(类似于在一个函数内声明的变量)。

        二、State 使用

        读写组件状态,最简单的示例如下:

        // 读取状态
        const { count } = this.state
        // 更新状态
        this.setState = { count: xxx }
        

        1. setState 简述

        setState() 是更新用户界面的主要方式,它的作用是将对组件 state 的更改排入队列,并通知 React 需要使用更新后的 state 重新渲染此组件及其子组件。

        需要注意的是,使用 setState() 更新状态可能是“异步”的,React 并不会保证 state 的变更会立即生效,因此使得在调用 setState() 后立即读取 this.state 成为了隐患。

        举个例子:

        class Counter extends React.Component {
          constructor(props) {
            super(props)
            this.state = { count: 0 }
            this.increment = this.increment.bind(this)
          }
        
          increment() {
            this.setState({ count: this.state.count + 1 })
            this.setState({ count: this.state.count + 1 })
            this.setState({ count: this.state.count + 1 })
            console.log(this.state.count) // 1️⃣
          }
        
          render() {
            return (
              <div>
                <button onClick={this.increment}>add</button>
                <div>count: {this.state.count}</div>
              </div>
            )
          }
        }
        

        假设 count0 开始,我们点击按钮触发 increment 事件处理函数,里面依次更新了三次 count 的状态,“直觉性”的结果应该是 count 更新到 3 且 1️⃣ 处打印结果为 3,这是不对的。事实是 count 只增加了 1,1️⃣ 处打印结果为 0,且 render() 只触发了一次。

        为什么???

        原因就是上面提到的。使用 setState() 更改状态,React 并不会立即更新组件,它会批量推迟更新。即在 increment() 方法里,四次读取 this.state.count 的值均为 0,即使再重复 N 次也一样,每次触发仅会在原来基础上增加 1

        increment() {
          const curCount = this.state.count // 0
          this.setState({ count: curCount + 1 })
          this.setState({ count: curCount + 1 })
          this.setState({ count: curCount + 1 })
          // ...
          console.log(curCount) // 0
        }
        

        2. setState 语法

        来看看 setState() 的语法,支持两种形式:

        // 1️⃣ updater 接受函数类型
        setState(updater[, callback])
        
        // 2️⃣ stateChange 接受对象类型
        setState(stateChange[, callback])
        
        • updater:如:(state, props) => stateChange,并返回一个对象。

          state 是对应用变化时组件状态的引用。props 则是当前组件的属性对象。但需要注意的是,尽管 updater 函数中接收的 stateprops 都保证为最新的,但此时组件状态还没改变(关于 this.state 值的更新,下一节详解)。

        • stateChange:接受对象类型,它会将传入的对象浅层合并到新的 state 中。这种形式也是异步的,在同一周期内会对多个 setState 进行批处理更新。

        • callback:第二个参数为可选的回调函数,它将在 setState 完成合并并并重新渲染组件后执行。通常建议使用 componentDidUpdate() 来代替此方式。

        因此,

        上述示例是 stateChange 对象形式,如下:

        increment() {
          this.setState({ count: this.state.count + 1 })
          this.setState({ count: this.state.count + 1 })
          this.setState({ count: this.state.count + 1 })
        }
        
        // setState 操作相当于
        Object.assign(
          previousState,
          { count: previousState.count + 1 },
          { count: previousState.count + 1 },
          { count: previousState.count + 1 }
        )
        

        如果采用 updater 函数形式,如何得到我们“预期”结果,如下:

        increment() {
          const incrementChange = state => ({ count: state.count + 1 })
          this.setState(incrementChange)
          this.setState(incrementChange)
          this.setState(incrementChange)
          console.log(this.state.count) // 需要注意的是,这里仍然是 0
        }
        

        这样的话,每触发一次 increment 事件处理函数,count 都能“预期”地增加 3,且只会触发一次 render() 方法。但由于此时 this.state 还没被改变,因此读取的值仍是原本的状态值 0

        3. setState 其他用法

        在批量更新时,React 总会按照定义顺序进行浅合并。比如:

        handleState() {
          this.setState({ a: 1 })
          this.setState({ b: 2 })
          this.setState({ c: 3, a: '1' })
        }
        
        // React 会进行浅合并,对多个 setState 进行批量更新,相当于:
        handleState() {
          this.setState({ a: '1', b: 2, c: 3 }) // 总是按顺序进行浅合并,因此 a 会被覆盖
        }
        

        再看个例子,updaterstateChange 两种形式混用,会产生什么结果?

        increment() {
          // 将 this.setState({ count: this.state.count + 1 }) 插入以下 1️⃣ 2️⃣ 3️⃣  不同的位置,得到的结果有什么差异呢?
          const incrementChange = state => ({ count: state.count + 1 })
          // 1️⃣
          this.setState(incrementChange)
          this.setState(incrementChange)
          // 2️⃣
          this.setState(incrementChange)
          // 3️⃣
          // 请问最终 count 会加到几?
        }
        
        // 假设 count 初始状态为 0,触发一次 increment 处理函数后,count 最终的状态会是 4、2、1。
        

        我们来分析下原因:

        setState() 的作用是将 state 的更新排入队列,然后其接受不同的实参(即对象 stateChange 形式 和函数 updater 形式),从上面的定义中,我们可以得到以下的过程:

        以 2️⃣ 为例,注意以下是伪代码,为了更好地理解罢了:

        // 假设初始 count 为 0
        increment() {
          const incrementChange = state => ({ count: state.count + 1 })
          this.setState(incrementChange)
          this.setState(incrementChange)
          this.setState({ count: this.state.count + 1 })
          this.setState(incrementChange)
        }
        
        // state 更新队列(伪代码)
        const queue = {
          // ...
        }
        
        // 触发一次 increment() 之后,发生以下过程:
        // 1. 执行第一个 setState,是函数形式的,它的 state 是应用变化时对组件状态的引用。
        //    此时队列为空,state.count 取的值就当前组件的 count 值 0,并基于此加 1,然后放入队列中,即 queue.count 为 1;
        // 2. 接着执行第二个 setState,同理。由于队列中存在 count 的引用,因此当前 count = queue.count + 1,
        //    再放入队列中,即 queue.count 为 2;
        // 3. 执行第三个 setState,由于是对象形式,会发生浅合并,
        //    类似于:Object.assgin(queue, { count: this.state.count + 1 }) 的操作,
        //    其中 this.state.count 为 0,因此浅合并的结果就是 { count: 1 },然后再存入队列,即 queue.count 为 1
        // 4. 执行第四个 setState 同理,队列存在引用,并基于此增加再存入队列,所以 queue.count 为 2.
        // 5. 所以最终结果为 2。
        

        其他同理,只要按以上方式去分析的话,都能得到正确答案。若想更深入地了解,请看源码!

        三、为什么要使用 setState 来更新 state ?

        开头提到了,读取和更新状态的正确方式,应如下:

        // 读取状态
        const { count } = this.state
        // 更新状态
        this.setState = { count: xxx }
        

        那么,这样更新状态可以吗?

        // bad
        this.state.count = xxx
        

        答案是可以的,但不推荐。它不会触发 UI 的更新,因此是无意义的。它类似于 setStateshouldComponetUpdate() { return false } 的结合。

        state 是由用户自定义的一个普通 JavaScript 对象而已,当然可以通过 state.xxx = xxx 去更改它,不就是 setter 嘛。但如果结合 React 设计 state 的初衷,我们不应该通过这种方式去更改某个状态的值。

        相信大家都听过:

        UI=f(State),状态即 UI。具体状态是如何映射用户界面的,这就由 React 去操心就好了。

        还有,

        请记住,如果某些值未用于渲染或数据流(传递给子孙组件),例如计时器 ID,则不必将其设置为 state。此类值可以在组件实例上定义。

        四、state 更新时机

        此前写了一篇文章 React 的生命周期都懂了吗? ,提到 Class Component 的生命周期分为 Mounting、Updating、Unmounting 三个阶段。而 setState() 带来的更新,则发生在 Updating 阶段。

        state(或 props)发生变化时,会触发以下生命周期:

        • shouldComponentUpdate()
        • UNSAFE_componentWillUpdate()
        • render()
        • getSnapshotBeforeUpdate()
        • componentDidUpdate()

        还包括 React 16.3 提供的 getDerivedStateFromProps() 全新 API。

        shouldComponentUpdate()UNSAFE_componentWillUpdate() 被调用的时候,this.state 都未被更新。直到 render() 被调用的时候,this.state 才得到更新。

        需要注意的是,当 shouldComponentUpdate() 返回 false 时,会导致本次更新被中断,自然不会调用 render() 了。但是 React 也不会放弃掉对 this.state 的更新(可通过定时器去观察)。这种情况,像 this.state.xxx = xxx 这种方式去更改 this.state 值,但不会触发组件的重新渲染。

        因此,可以简单的认为:当调用 setState() 方法对组件状态进行更新时,直到下一次 render() 被调用(或 shouldComponentUpdate() 返回 false)之后,this.state 才得到更新。且 setState 的第二个参数,也是在此时才会被执行,也正是如此,此时 this.state 是最新值(预期值)。

        五、setState 批量更新

        上面提到,在同一时期内多次进行 setState 操作,会被 React 批量更新,它们会被浅合并,只会触发一次重新渲染。我们所说的 setState 可能是“异步”的,就是因为批量更新机制,使得看起来像“异步”而已,并非真正的异步。

        不同版本下,批量更新策略会稍有不同。以下为 Dan(React 核心开发)在 Stack Overflow 某贴的原话:

        Currently (React 16 and earlier), only updates inside React event handlers are batched by default. There is an unstable API to force batching outside of event handlers for rare cases when you need it.

        In future versions (probably React 17 and later), React will batch all updates by default so you won't have to think about this. As always, we will announce any changes about this on the React blog and in the release notes.

        翻译过来,大致意思是:在 React 16(或更早)版本,默认情况下只会在 React 事件处理程序中进行批量更新。在未来(可能是 React 17),默认情况下会批处理所有的更新。

        总结:

        setState() 只在 React 合成事件 和生命周期函数中是“异步”的,而在原生事件和 setTimeoutsetInterval 以及网络响应中都是同步的。

        举个例子,如下:

        // base on React 16.12.0
        componentDidMount() {
          fetch('http://192.168.1.102:7701/config')
            .then(() => {
              this.setState({ count: this.state.count + 1 })
              this.setState({ count: this.state.count + 1 })
              this.setState({ count: this.state.count + 1 })
              console.log(this.state.count) // 3
            })
        }
        

        我们在 Fetch 或者 XHR 请求的响应处理程序中,使用 setState() 来更新状态,这时每个 setState() 都会立即处理,因此 count 增加了 3 次,即 render() 也触发了 3 次,同时打印结果也为 3

        其实针对上述情况,React 也提供了一个 “unstable” 的 API ReactDOM.unstable_batchedUpdates() 进行批量更新。“不稳定”是因为它会在默认启用批量更新后被移除。关于此 API 的使用就不展开了,请看原贴。(PS:基于 React 17.0.2 亲测,如上响应处理程序,仍未启用批量更新)

        原生事件和 React 合成事件区别:

        class Counter extends React.Component {
          constructor(props) {
            super(props)
            this.state = { count: 0 }
            this.increment = this.increment.bind(this)
            this.refElem = React.createRef()
          }
        
          componentDidMount() {
            this.refElem.current.addEventListener('click', this.increment, false)
          }
        
          increment() {
            this.setState({ count: this.state.count + 1 })
            this.setState({ count: this.state.count + 1 })
            this.setState({ count: this.state.count + 1 })
          }
        
          render() {
            return (
              <div>
                {/* React 合成事件 */}
                <button onClick={this.increment}>react add</button>
                {/* 原生事件 */}
                <button ref={this.refElem}>dom add</button>
                <div>count: {this.state.count}</div>
              </div>
            )
          }
        }
        

        运行可知,每次触发合成事件 count 会增加 1,而触发原生事件 count 会增加 3

        六、setState 是如何实现“异步”的?

        随着 React 的不断更新,不同版本的方法,及其逻辑都略有不同,例如旧版是通过 isBatchingUpdates 来判断是否批量更新的,然后新版又变成了 isBatchingEventUpdates(可看源码)。

        详看文章:

        七、总结

        • setState 只在 React 合成事件生命周期函数中是“异步”的,而在原生事件setTimeoutsetInterval 以及网络响应中都是同步的。

        • setState 的“异步”并不是内部由异步代码实现,其内部本身执行过程和代码都是同步的,只是合成事件和生命周期函数的调用顺序在更新之前,导致在合成事件和生命周期函数中无法立刻拿到更新后的值,形成了所谓的“异步”,当然可以通过第二个参数 callback 回调函数中拿到更新后的结果(该回调函数在组件更新后触发,因此也可在 ComponentDidUpdate 中获取更新后的值)。

        • setState 的批量更新优化也是建立在“异步”(合成事件、生命周期函数)之上的,在原生事件和 setTimeout 中不会批量更新,在批量更新中,如果进行多次 setState,批量更新策略会形成浅合并的效果,若有相同的值(Key),该值仅最后一次有效。

        八、参考链接

        ]]>
        <![CDATA[细读 React | 元素、组件、实例]]> https://github.com/tofrankie/blog/issues/114 https://github.com/tofrankie/blog/issues/114 Sat, 25 Feb 2023 13:03:19 GMT 配图源自 Freepik

        一、前言

        元素是构成 React 应用最小的砖块,它描述了你]]> 配图源自 Freepik

        一、前言

        元素是构成 React 应用最小的砖块,它描述了你在页面上想看到的内容。

        const element = <h1>Hello World</h1>
        

        与浏览器的 DOM 元素不同,React 元素是创建开销极小的普通对象。React DOM 会负责更新 DOM 来与 React 元素保持一致。

        以上 JSX 语法编写的代码,最终会被 Babel (在线 Babel 编译器)转换为:

        const element = React.createElement('h1', null, 'Hello World')
        

        React.createElement() 最后会返回一个普通的 JavaScript 对象(该对象就是对 React 元素的描述):

        const element = {
          type: 'h1',
          props: {
            children: 'Hello World'
          },
          // ...
        }
        

        可以在控制台打印一下 element 元素:

        需要注意的是,React 元素描述的是 Virtual DOM 的结构,而非真实 DOM。真实 DOM 由 React DOM 根据 Virtual DOM 负责更新。

        二、React 元素

        上面提到,React 元素本身上就是一个 JavaScript 对象,而且是不可变对象(Immutable Object)。

        由于 React 元素是不可变对象,若对其 props 等属性进行修改操作,是会抛出错误的。

        React 元素表示了某个时刻的 UI,而更新 UI 的唯一方式是创建一个全新的元素。React DOM 会根据 Diff 算法只更新它需要更新的部分。

        三、React 组件

        刚开始接触 React 时,人们很容易将元素组件的概念混淆。元素是 React 应用最小的单元,而组件则是由一个或多个元素构成的。

        组件是 React 中很重要的思想。一个复杂庞大的 React 应用,是由许多结构简单、清晰的组件组合而成的。

        组件,从概念上类似于 JavaScript 函数,它接受任意的入参(即 props),并返回 React 元素。

        在 React 中,组件分为函数组件Class 组件。下面声明了两种不同类型的组件:

        // 函数组件
        function Comp1() {
          return <h1>Functional Component.</h1>
        }
        
        // class 组件
        class Comp2 extends React.Component {
          // render 是类组件唯一必需实现的方法
          render() {
            return <h1>Class Component.</h1>
          }
        }
        

        我们知道 ReactDOM.render(element, container[, callback]) 方法,第一个参数 element 接收的是 React 元素。而我们声明的组件本质上是一个 JavaScript 函数,因此,我们应该要这样使用自定义组件:

        ReactDOM.render(<Comp1 />, document.getElementById('root'))
        

        四、React 元素分类

        从以上可知,我们遇到的 React 元素,可以是 DOM 标签:

        const element = <h1>Hello World</h1>
        

        也可以是用户自定义的组件:

        const element = <Comp1 />
        

        因此,我们可以将 React 元素分为两类:DOM 类型元素、组件类型元素。

        前者是指使用类似 divh1pspan 等 DOM 标签创建的 React 元素,而后者是指使用 React 组件(如 Class Components、Functional Components)创建的 React 元素。

        对于 DOM 标签,React 可以分辨出来,而自定义的 React 组件呢?若不约定规则,那么它无法辨认啊。所以 React 要求以 JSX 语法编写 React 组件时,其组件名称必须以大写字母开头

        例如,MyComponent 是“合法”的 React 组件,而 myComponent 是“不合法”的。否则 React 会发出如下警告 ⚠️:

        Warning: The tag <myComponent> is unrecognized in this browser. If you meant to render a React component, start its name with an uppercase letter.

        上面打了双引号,其实在声明一个 React 组件时,是可以以小写字母开头的,但在使用时必须以大写字母开头。即我们可以将 React 组件赋值给一个以大写字母开头的变量,然后正常使用。**但这种“非主流”的写法,不被推荐。**再者,声明一个构造函数,它的名称应以大写字母开头,这是一种约定俗成的写法。

        同样的,DOM 类型元素只能以小写字母的形式,例如 <div />。如果使用 <Div /> 会被 React 认为是自定义组件,但由于我们又没有声明,因此可能会抛出引用错误:ReferenceError: Div is not defined

        想了解更多关于此规范的原因,请看深入 JSX

        五、React 元素、组件使用误区

        一些容易混淆、出错的写法:

        const Element = <h1>React Element.</h1>
        const rootEl = document.getElementById('root')
        
        // 正确示例 ✅
        ReactDOM.render(Element, rootEl)
        ReactDOM.render(<Comp1 />, rootEl)
        
        // 错误示例 ❌
        ReactDOM.render(<Element />, rootEl) // 1️⃣
        ReactDOM.render(Comp1, rootEl) // 2️⃣
        

        错误示例 1️⃣ 会抛出以下警告 ⚠️:

        Warning: React.createElement: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: <h1 />. Did you accidentally export a JSX literal instead of a component?

        错误示例 2️⃣ 会抛出以下警告 ⚠️:

        Warning: Functions are not valid as a React child. This may happen if you return a Component instead of <Component /> from render. Or maybe you meant to call this function rather than return it.

        我们都知道,ReactDOM.render(element, container[, callback]) 第一个参数接收 React 元素。而 React 元素都是由 React.createElement() 返回的。我们从 React.createElement() 的语法上解释以上错误示例:

        React.createElement(
          type,
          [props],
          [...children]
        )
        
        • type 可以是标签名字符串(如 divspanh1 等),也可以是 React 组件类型(class 组件或函数组件),或者是 React Fragment 类型。

        • props 可选,组件属性

        • children 可选,子元素。含有多个子元素时,最终 React Element 的 props.children 会返回一个数组。

        原因分析:

        1. ReactDOM.render(Element, rootEl) 相当于 ReactDOM.render(<h1>React Element.</h1>, rootEl)( 这里 Element 只是一个变量)。以小写字母开头的元素代表一个 HTML 内置组件,在编译时 <h1> 会生成响应的字符串 'h1' 传递给 React.createElement,是符合参数要求的。所以没问题。
        2. ReactDOM.render(<Comp1 />, rootEl)以大写字母开头的元素,对应着自定义组件,<Comp1 /> 会被编译为 React.createElement(Comp1)。而且 Comp1 正是 React 组件(本质上就是 JavaScript 函数),也符合参数要求,所以没问题。
        3. 按上面的规则,<Element /> 以大写字母开头,会被认为是 React 组件,但 Element 的类型是 "object",而不是 "function"(class 本质也是函数),不符合 React.createElement 参数要求,因此会被报错或警告。
        4. ReactDOM.render(Comp1, rootEl) 中的 Comp1 是一个 JavaScript 函数,而非 React 元素,因此也会报错。

        六、实例

        React 组件是一个函数或者类,而实际发挥作用的是 React 组件的实例对象。只有在组件实例化之后,每个组件实例才有自身的 props、state 或对 DOM 节点的引用。

        function Child() {
          return <div>Child Component</div>
        }
        
        class Parent extends React.Component {
          // 需要注意的是,在组件将要被销毁的时候会触发此生命周期函数
          // 当组件从页面中“移除”,并不意味着组件实例被回收掉了
          // 仅在组件实例不再被任何地方引用,它才会被垃圾回收。
          componentWillUnmount() {
            // ...
          }
        
          render() {
            return (
              <div>
                 <div>Parent Component</div>
                 {/* 当父组件触发 render 之后,子组件就会被实例化 */}
                 <Child />
              </div>
            )
          }
        }
        

        七、节点

        很多情况下,我们会使用 PropTypes 来限制组件属性类型。这里我们提一下与本文相关的两种类型:PropTypes.nodePropTypes.element

        import PropTypes from 'prop-types'
        
        function MyComponent(props) {
          return (
            <div>
              <div>node: { props.node }</div>
              <div>element: { props.element }</div>
            </div>
          )
        }
        
        MyComponent.propTypes = {
          node: PropTypes.node,
          element: PropTypes.element
        }
        
        • PropTypes.element:可以是 nullundefined 或 React 元素。
        • PropTypes.node: 可以是 nullundefined、字符串、React 元素或包含这些类型的数组。

        The end.

        ]]>
        <![CDATA[React 的生命周期都懂了吗?]]> https://github.com/tofrankie/blog/issues/113 https://github.com/tofrankie/blog/issues/113 Sat, 25 Feb 2023 13:02:14 GMT 配图源自 Freepik

        发现好像有些没有过的生命周期函数,还没完全弄清楚...

        一、组]]> 配图源自 Freepik

        发现好像有些没有过的生命周期函数,还没完全弄清楚...

        一、组件的生命周期

        组件的生命周期,主要分为 Mounting(挂载)、Updating(更新)、Unmounting(卸载)三个阶段。

        ▲ React ≥ 16.4

        1.1 Mounting

        当组件示例被创建并插入 DOM 中时,其生命周期调用顺序如下:

        以下生命周期方法即将过时,在新代码中应该避免使用它们UNSAFE_componentWillMount()

        1.2 Updating

        当组件的 props 或 state 发生变化时会触发更新。组件更新的生命周期调用顺序如下:

        请注意,以下方法即将过时,在新代码中应该避免使用它们UNSAFE_componentWillUpdate()UNSAFE_componentWillReceiveProps()

        1.3 Unmounting

        当组件从 DOM 中移除时会调用如下方法:

        1.4 Error Handling(错误处理)

        当渲染过程,生命周期,或子组件的构造函数中抛出错误时,会调用如下方法:

        二、Mounting(挂载)

        2.1 constructor

        constructor(props)
        

        如果不初始化 state 或不进行方法绑定,则不需要为 React 组件实现构造函数。

        在 React 组件挂载之前,会调用它的构造函数。在为 React.Component 子类实现构造函数时,应该在其他语句之前调用 super(props)。否则,this.props 在构造函数中可能会出现未定义的 bug。

        一般需重写构造函数,只做两件事:

        • 初始化组件内部 state,即 this.state = { ... }
        • 为时间处理函数绑定实例,如:this.handleClick = this.handleClick.bind(this)

        否则无需为 React 组件实现构造函数。

        2.2 static getDerivedStateFromProps(不常用)

        static getDerivedStateFromProps(props, state)
        

        需要注意的是,此方法无法访问组件实例,即不能使用 this

        getDerivedStateFromState 会在调用 render 方法之前调用,并且在初始挂载及后续更新时都会被调用。它应返回一个 对象 来更新 state,如果返回 null 则不更新任何内容。

        componentWillReceiveProps 不同的是,getDerivedStateFromProps 不管原因是什么,都会在每次渲染前触发此方法。而 componentWillReceiveProps 仅在父组件重新渲染时触发,而不是在内部调用 setState 时。

        请避免使用派生 state

        2.2.1 UNSAFE_componentWillReceiveProps

        此生命周期之前名为 componentWillReceiveProps。该名称将继续使用至 React 17。在 React 16.3 之后,它的替代者是 getDerivedStateFromProps

        UNSAFE_componentWillReceiveProps() 会在已挂载的组件接收新的 props 之前被调用。如果你需要更新状态以响应 prop 更新(例如,重置它),你可以比较 this.propsnextProps 并在此方法中使用 this.setState() 执行 state 转换。

        请注意,如果父组件导致组件重新渲染,即使没有 props 没有更改,也会调用此方法。如果只是想处理更改,请确保进行当前值与变更值的比较。

        在挂载过程,React 不会针对初始 props 调用 UNSAFE_componentWillReceiveProps()。组件只会在组件的 props 更新时调用此方法。调用 this.setState() 通常不会触发 UNSAFE_componentWillReceiveProps()

        2.3 UNSAFE_componentWillMount

        该生命周期之前名为 componentWillMount,旧名称仍可使用至 React 17.

        UNSAFE_componentWillMount()constructor 之后,render 之前被调用,因此在此方法中同步调用 setState() 不会触发额外渲染。通常,我们建议使用 constructor() 来初始化 state。

        避免在此方法引入任何副作用或订阅,请放置在 componentDidMount()

        此方法是服务端渲染唯一会调用的生命周期函数。

        2.4 render

        render() 方法是 class 组件中唯一必须实现的方法。

        render 被调用,它会检测 this.propsthis.state 的变化并返回以下类型之一:

        • React 元素:通常通过 JSX 创建。例如,<div /> 会被 React 渲染为 DOM 节点,<MyComponent /> 会被 React 渲染为自定义组件,无论是 <div /> 还是 <MyComponent /> 均为 React 元素。
        • 数组或 fragments: 使得 render 方法可以返回多个元素。欲了解更多详细信息,请参阅 fragments 文档。
        • Portals:可以渲染子节点到不同的 DOM 子树中。欲了解更多详细信息,请参阅有关 portals 的文档
        • 字符串或数值类型:它们在 DOM 中会被渲染为文本节点
        • 布尔类型或 null:什么都不渲染。(主要用于支持返回 test && <Child /> 的模式,其中 test 为布尔类型。)

        render() 函数应该为纯函数,这意味着在不修改组件 state 的情况下,每次调用时都返回相同的结果,并且它不会直接与浏览器交互。

        如需与浏览器进行交互,请在 componentDidMount() 或其他生命周期方法中执行你的操作。保存 render() 为纯函数,可以使组件更容易思考。

        需要注意都是,shouleComponentUpdate() 返回 false,则不会调用 render()

        2.5 componentDidMount

        componentDidMount()
        

        componentDidMount() 会在组件挂载后(插入 DOM 树中)立即调用。依赖于 DOM 节点的初始化应该放在这里。如需通过网络请求获取数据,此处是实例化请求的好地方。

        这个地方是比较合适添加订阅。如果添加了订阅,请不要忘记在 componentWillUnmount() 里取消订阅。

        你可以在 componentDidMount() 里直接调用 setState()。它将会触发额外渲染,但此渲染会发生在浏览器更新屏幕之前。如此保证了即使在 render() 两次调用的情况下,用户也不会看到中间状态。请谨慎使用该模式,因为它会导致性能问题。通常,你应该在 constructor() 中初始化 state。如果你的渲染依赖于 DOM 节点的大小或位置,比如实现 modals 或 tooltips 等情况下,你可以使用此方式处理。

        三、Updating(更新)

        3.1 shouldComponentUpdate(不常用)

        shouldComponentUpdate(nextProps, nextState)
        

        根据 shouldComponentUpdate() 的返回值,判断 React 组件的输出是否受当前 stateprops 更改的影响。默认行为是 state 每次发生变化组件都会重新渲染。大部分情况下,你应该遵循默认行为。

        propsstate 发生变化时,shouldComponentUpdate() 会在渲染执行之前被调用。返回值默认为 true。首次渲染或使用 forceUpdate() 时不会调用该方法。

        此方法仅作为性能优化的方式而存在。不要企图依靠此方法来“阻止”渲染,因为这可能会产生 bug。你应该考虑使用内置的 PureComponent 组件,而不是手动编写 shouldComponentUpdate()。PureComponent 会对 propsstate 进行浅层比较,并减少了跳过必要更新的可能性。

        如果你一定要手写编写此函数,可以将 this.propsnextProps 以及 this.statenextState 进行比较,并返回 false 以告知 React 可以跳过更新。请注意,返回 false 并不会阻止子组件在 state 更改时重新渲染。

        不建议在 shouldComponentUpdate() 中进行深层比较或使用 JSON.stringify()。这样非常影响效率,且会损害性能。

        目前,如果 shouldComponentUpdate() 返回 false,则不会调用 UNSAFE_componentWillUpdate()render()componentDidUpdate()。后续版本,React 可能会将 shouldComponentUpdate 视为提示而不是严格的指令,并且当返回 false 时,仍可能导致组件重新渲染。

        3.2 UNSAFE_componentWillUpdate(不常用)

        UNSAFE_componentWillUpdate(nextProps, nextState)
        

        此生命周期之前名为 componentWillUpdate。该名称将继续使用至 React 17。

        当组件收到新的 propsstate 时,会在渲染之前调用 UNSAFE_componentWillUpdate() 。使用此作为更新发生之前执行准备更新的几乎。初始渲染不会调用此方法。

        注意,你不能此方法中调用 setState();在 UNSAFE_componentWillUpdate() 返回之前,你不应该执行任何其他操作(例如,dispatch Redux 的 action)触发对 React 组件的更新。

        通常,此方法可以替换为 componentDidUpdate()。如果你在此方法中读取 DOM 信息(例如,为了保存滚动违章),则可以将此逻辑移至 getSnapShotBeforeUpdate() 中。

        如果 shouldComponentUpdate() 返回 false,则不会调用 UNSAFE_componentWillUpdate()

        3.3 componentDidUpdate

        componentDidUpdate(prevProps, prevState, snapshot)
        

        componentDidUpdate() 会在更新后被立即调用,但首次渲染不会执行此方法。

        当组件更新后,可以在此处对 DOM 进行操作。如果你对更新前后的 props 进行了比较,也可以选择在此处进行网络请求。(例如,当 props 为发生变化时,则不会执行网络请求。)

        componentDidUpdate(prevProps) {
          // 典型用法(不要忘记比较 props)
          // 若直接 setState() 会导致死循环。
          if (this.props.userID !== prevProps.userID) {
            this.fetchData(this.props.userID)
          }
        }
        

        你也可以在 componentDidUpdate() 中直接调用 setState(),但请注意它必须被包裹在一个条件语句里,正如上述的例子那样进行处理,否则会导致死循环。它还会导致额外的重新渲染,虽然用户不可见,但会影响组件性能。不要将 props “镜像”给 state,请考虑直接使用 props

        如果组件实现了 getSnapshotBeforeUpdate() 生命周期(不常用),则它的返回值将作为 componentDidUpdate() 的第三个参数 snapshot 传递。否则此参数将为 undefined

        需要注意的是,shouldComponentUpdate() 返回值为 false,则不会调用 componentDidUpdate()

        四、Unmounting(卸载)

        4.1 componentWillUnmount

        componentWillUnmount() 会在组件卸载及销毁之前直接调用。在此方法中执行必要的清理操作,例如清除 timer,取消网络请求或清除 componentDidMount() 中创建的订阅等。

        componentWillUnmount() 中不应调用 setState(),因为该组件将永远不会重新渲染。组件实例卸载后,将永远不会再挂载它。

        需要注意的是,我们常说的组件销毁,只是组件从页面中删除而已。而并不意味着它真正被销毁,被 GC 回收。我们所写的 JSX 形式的组件,都会被 Babel 转化成 React.createElement() 形式的JS 对象(没错,React 组件本质上就是一个 JavaScript 对象)。因此只有它不再被引用,垃圾回收机制才会回收掉,这才算真正销毁。这也是为什么要做 componentWillUnmount 清除副作用的原因。

        五、其他

        5.1 defaultProps

        defaultProps 可以为 Class 组件添加默认 props。这一般用于 props 未赋值,但又不能为 null 的情况。例如:

        class CustomButton extends Component {
          // ...
        }
        
        CustomButton.defaultProps = {
          type: 'primary'
        }
        

        利用 ES6 的 class 静态语法

        class CustomButton extends Component {
          static defaultProps = {
            type: 'primary'
          }
          // ...
        }
        

        5.2 一些问题

        React 16 之后采用了 Fiber 架构,只有 componentDidMount 生命周期函数是确定被执行一次的,类似 ComponentWillMount 的生命周期钩子都有可能执行多次,所以不加以在这些生命周期中做有副作用的操作,比如请求数据之类。

        5.3 componentDidCatch()

        未完待续...

        六、逐步迁移路径

        React 遵循语义版本控制,因此这种变化将是逐步的。我们目前的计划是:

        • 16.3:为不安全的生命周期引入别名,UNSAFE_componentWillMountUNSAFE_componentWillReceiveProps 和 UNSAFE_componentWillUpdate。(旧的生命周期名称和新的别名都可以在此版本中使用。)
        • 未来 16.x 版本:为 componentWillMountcomponentWillReceiveProps 和 componentWillUpdate 启用废弃告警。(旧的生命周期名称和新的别名都将在这个版本中工作,但是旧的名称在开发模式下会产生一个警告。)
        • 17.0:删除 componentWillMountcomponentWillReceiveProps 和 componentWillUpdate。(在此版本之后,只有新的 UNSAFE_ 生命周期名称可以使用。)

        这里的 “unsafe” 不是指安全性,而是表示使用这些生命周期的代码在 React 的未来版本中更有可能出现 bug,尤其是在启用异步渲染之后。

        七、示例

        由于使用新 API(如 getDerivedStateFromPropsgetSnapshotBeforeUpdate)时,React 不会调用组件的 “unsafe” 生命周期(如 UNSAFE_componentWillMountUNSAFE_componentWillReceivePropsUNSAFE_componentWillUpdate),因此暂时先注释掉新 API。同时使用,会出现类似如下警告⚠️:

        Warning: Unsafe legacy lifecycles will not be called for components using new component APIs.
        
        LifeCycle uses getDerivedStateFromProps() but also contains the following legacy lifecycles:
          UNSAFE_componentWillMount
        
        The above lifecycles should be removed. Learn more about this warning here:
        https://fb.me/react-unsafe-component-lifecycles
        

        示例基于 React 16.12.0 版本。

        import React, { Component } from 'react'
        
        export default class LifeCycle extends Component {
          constructor(props) {
            super(props)
            this.state = {
              count: 0
            }
            this.setCount = this.setCount.bind(this)
            console.log('---> constructor')
          }
        
          static getDerivedStateFromProps(prevProps, prevState) {
            console.log('---> getDerivedStateFromProps')
            return null
          }
        
          // 不会为使用新组件 API 的组件调用不安全的遗留生命周期。
          // Warning: Unsafe legacy lifecycles will not be called for components using new component APIs.
          // LifeCycle uses getDerivedStateFromProps() but also contains the following legacy lifecycles: UNSAFE_componentWillMount
          // UNSAFE_componentWillMount() {
          //   console.log('---> UNSAFE_component')
          // }
        
          componentDidMount() {
            console.log('---> componentDidMount')
          }
        
          setCount() {
            this.setState({ count: this.state.count + 1 })
          }
        
          render() {
            console.log('---> render')
        
            return (
              <div>
                <div>Count: {this.state.count}</div>
                <button onClick={this.setCount}>Add</button>
              </div>
            )
          }
        }
        

        当首次加载加载时,分别触发了以下生命周期,打印结果:

        ---> constructor
        ---> UNSAFE_componentWillMount
        ---> render
        ---> componentDidMount
        

        当我们点击按钮通过 setState 更新 count 时,会触发以下动作:

        ---> shouldComponentUpdate
        ---> UNSAFE_componentWillUpdate
        ---> render
        ---> componentDidUpdate
        

        未完待续...

        八、参考链接

        ]]>
        <![CDATA[React 源码之 createElement() 解读]]> https://github.com/tofrankie/blog/issues/112 https://github.com/tofrankie/blog/issues/112 Sat, 25 Feb 2023 13:01:01 GMT 配图源自 Freepik

        今天一起来看下 React.createElement() 配图源自 Freepik

        今天一起来看下 React.createElement() 方法

        React.createElement

        中文文档 | 英文文档

        React 不强制要求使用 JSX,每个 JSX 元素只是调用 React.createElement(type, [props], [...children]) 的语法糖。因此,使用 JSX 可以完成的任何事情都可以通过纯 JavaScript 完成。

        语法:

        React.createElement(
          type,
          [props],
          [...children]
        )
        
        • type 可以是标签名字符串(如 div 或 span 等),也可以是 React 组件类型(class 组件或函数组件),或者是 React fragment 类型。
        • props 可选,组件属性
        • children 可选,子元素。含有多个子元素时,最终 React Element 的 props.children 会返回一个数组。

        例如:使用 JSX 编写代码:

        class Hello extends React.Component {
          render() {
            return <div>Hello {this.props.toWhat}</div>
          }
        }
        
        ReactDOM.render(
          <Hello toWhat="World" />,
          document.getElementById('root')
        )
        

        也可以编写不使用 JSX 的代码:

        class Hello extends React.Component {
          render() {
            return React.createElement(
              'div',
              null,
              `Hello ${this.prorps.toWhat}`
            )
          }
        }
        
        ReactDOM.render(
          React.createElement(Hello, { toWhat: 'World' }, null),
          document.getElementById('root')
        )
        

        如果你想了解更多 JSX 转换为 JavaScript 的示例,可以尝试使用在线 Babel 编译器

        源码

        createElement 源码:

        function createElement(type, config, children) {
          var propName;
        
          // Reserved names are extracted
          var props = {};
        
          // 一些保留的属性
          var key = null;
          var ref = null;
          var self = null;
          var source = null;
        
          // 提取 key、ref、self、source、prop
          if (config != null) {
            // 将合法的 ref 赋值给 ref 变量
            if (hasValidRef(config)) {
              ref = config.ref;
        
              {
                warnIfStringRefCannotBeAutoConverted(config);
              }
            }
        
            // 将合法的 key 转换为字符串类型,并赋值给变量 key
            if (hasValidKey(config)) {
              key = '' + config.key;
            }
        
            self = config.__self === undefined ? null : config.__self;
            source = config.__source === undefined ? null : config.__source;
        
            // Remaining properties are added to a new props object
            // 将 config 中除 key、ref、__self、__source 之外的 prop 提取出来,并放入 props 变量中
            for (propName in config) {
              if (hasOwnProperty$1.call(config, propName) && !RESERVED_PROPS.hasOwnProperty(propName)) {
                props[propName] = config[propName];
              }
            }
          }
        
          // Children can be more than one argument, and those are transferred onto
          // the newly allocated props object.
          // 入参的前两个 type 和 config,剩余的都是 children 参数。所以减 2
          var childrenLength = arguments.length - 2;
        
          if (childrenLength === 1) {
            // 只有一个子元素时,直接挂到 props.children 下(非数组形式)
            props.children = children;
          } else if (childrenLength > 1) {
            // 子元素多于一个时,将它们都放入数组中,然后挂到 props.children 下(数组形式)
            var childArray = Array(childrenLength);
        
            for (var i = 0; i < childrenLength; i++) {
              childArray[i] = arguments[i + 2];
            }
        
            {
              // 冻结子元素列表
              if (Object.freeze) {
                Object.freeze(childArray);
              }
            }
        
            props.children = childArray;
          }
        
          // Resolve default props
          // 取出组件类(即 type 不为字符串的情况)中的静态属性 defaultProps,并给未在 JSX 中设置值的属性设置默认值。
          if (type && type.defaultProps) {
            var defaultProps = type.defaultProps;
        
            for (propName in defaultProps) {
              // 注意下,若属性值为 null 并不会触发设置默认值的处理,仅 undefined。
              if (props[propName] === undefined) {
                props[propName] = defaultProps[propName];
              }
            }
          }
        
          {
            // 以下步骤主要是避免一些保留属性被错误取到,提供警告
            if (key || ref) {
              var displayName = typeof type === 'function' ? type.displayName || type.name || 'Unknown' : type;
        
              if (key) {
                defineKeyPropWarningGetter(props, displayName);
              }
        
              if (ref) {
                defineRefPropWarningGetter(props, displayName);
              }
            }
          }
        
          // 调用 ReactElement 构建元素,并返回
          // type 是直接透传的,
          // key、ref 等等都是从 config 里面解析出来的,props 是除去一些保留属性之外在 config 上读取的属性
          // children 是子元素,多个时返回数组
          return ReactElement(type, key, ref, self, source, ReactCurrentOwner.current, props);
        }
        

        ReactElement 源码:

        /**
         * Factory method to create a new React element. This no longer adheres to
         * the class pattern, so do not use new to call it. Also, instanceof check
         * will not work. Instead test $$typeof field against Symbol.for('react.element') to check
         * if something is a React Element.
         *
         * @param {*} type
         * @param {*} props
         * @param {*} key
         * @param {string|object} ref
         * @param {*} owner
         * @param {*} self A *temporary* helper to detect places where `this` is
         * different from the `owner` when React.createElement is called, so that we
         * can warn. We want to get rid of owner and replace string `ref`s with arrow
         * functions, and as long as `this` and owner are the same, there will be no
         * change in behavior.
         * @param {*} source An annotation object (added by a transpiler or otherwise)
         * indicating filename, line number, and/or other information.
         * @internal
         */
        var ReactElement = function (type, key, ref, self, source, owner, props) {
          // 我们暂时把下面的代码折叠,可以看到 ReactElement 方法最后返回的就是 element 对象
          var element = {
            // This tag allows us to uniquely identify this as a React Element
            // 通过这个标签来识别react的元素(一个 Symbol)
            $$typeof: REACT_ELEMENT_TYPE,
        
            // Built-in properties that belong on the element
            // 内置属性
            type: type,
            key: key,
            ref: ref,
            props: props,
        
            // Record the component responsible for creating this element.
            // 记录创建该组件的组件
            _owner: owner
          };
        
          {
            // The validation flag is currently mutative. We put it on
            // an external backing store so that we can freeze the whole object.
            // This can be replaced with a WeakMap once they are implemented in
            // commonly used development environments.
            // 这个验证标志是可变的,我们把这个放在外部支持存储,以便我们能够冻结整个对象,
            element._store = {};
        
            // To make comparing ReactElements easier for testing purposes, we make
            // the validation flag non-enumerable (where possible, which should
            // include every environment we run tests in), so the test framework
            // ignores it.
            // 为了更加方便地进行测试,我们设置了一个不可枚举的验证标志位,以便测试框架忽略它
            // 给 _store 设置 validated 属性 false
            Object.defineProperty(element._store, 'validated', {
              configurable: false,
              enumerable: false,
              writable: true,
              value: false
            });
        
            // self and source are DEV only properties.
            // self 和 source 都是开发环境才存在的
            Object.defineProperty(element, '_self', {
              configurable: false,
              enumerable: false,
              writable: false,
              value: self
            });
        
            // Two elements created in two different places should be considered
            // equal for testing purposes and therefore we hide it from enumeration.
            // 两个再不同地方创建的元素从测试的角度来看是相等的,我们在列举的时候忽略他们
            Object.defineProperty(element, '_source', {
              configurable: false,
              enumerable: false,
              writable: false,
              value: source
            });
        
            // 如果 Object 有 freeze 的实现,我们冻结元素和它的属性
            if (Object.freeze) {
              Object.freeze(element.props);
              Object.freeze(element);
            }
          }
        
          return element;
        };
        

        注意,React 内部是使用 $$typeof 来判断 react 元素的类型的。使用 _store 来记录内部的状态,后面会有用到。为了方便测试框架,_store 中定义了不可配置不可枚举的 validated 属性。类似的,框架内部定义了 selfsource 的副本 _self_source,他们都是不可配置不可枚举不可写的。

        最近在重新学习 React,也会去看一些源码什么的,在 GitHub 上建了个仓库(tofrankie/react-learn)去记录学习的东西,然后把其中的一些挑选出来发文了。

        ]]>
        <![CDATA[Warning: This synthetic event is reused for performance reasons.]]> https://github.com/tofrankie/blog/issues/111 https://github.com/tofrankie/blog/issues/111 Sat, 25 Feb 2023 13:00:00 GMT 配图源自 Freepik

        背景

        可能大家在 react 开发中,会遇到以下报错: 配图源自 Freepik

        背景

        可能大家在 react 开发中,会遇到以下报错:

        Warning: This synthetic event is reused for performance reasons. If you're seeing this, you're accessing the property target on a released/nullified synthetic event. This is set to null. If you must keep the original synthetic event around, use event.persist(). See https://fb.me/react-event-pooling for more information.

        示例

        // 其他无关紧要部分省略了
        <input onChange={event => {
          console.log(event.type)  // change
          setTimeout(() => {
            console.log(event.type)  // null,并会出现以上警告
          })
        }} />
        

        原因

        首先,我们的事件处理程序将传递 SyntheticEvent 实例,这是一个跨浏览器原生事件包装器。它具有与浏览器原生事件相同的接口,包括 stopPropagation()preventDefault(),除了事件在所有浏览器中它们的工作方式都相同

        每个 SyntheticEvent 对象都具有以下属性:

        boolean bubbles
        boolean cancelable
        DOMEventTarget currentTarget
        boolean defaultPrevented
        number eventPhase
        boolean isTrusted
        DOMEvent nativeEvent
        void preventDefault()
        boolean isDefaultPrevented()
        void stopPropagation()
        boolean isPropagationStopped()
        DOMEventTarget target
        number timeStamp
        string type
        

        SyntheticEvent 对象是通过合并得到的。出于性能原因的考虑,SyntheticEvent 对象将被重用并且所有属性都将被取消,因此,无法以异步方式访问该事件。

        所以,我们异步访问 event.type 时会得到 null 值,那么怎么破呢?

        如果要以异步方式访问事件属性,应该对事件调用 event.persist(),这将从池中删除合成事件,并允许用户代码保留对事件的引用。

        异步调用方式

        // 其他无关紧要部分省略了
        <input onChange={event => {
          console.log(event.type)  // change
          event.persist()
          setTimeout(() => {
            console.log(event.type)  // change,能正常访问了
          })
        }} />
        

        更多

        更多请看 合成事件(SyntheticEvent)

        ]]>
        <![CDATA[LeetCode 字符串反序]]> https://github.com/tofrankie/blog/issues/110 https://github.com/tofrankie/blog/issues/110 Sat, 25 Feb 2023 12:59:03 GMT 整数反转

        /**
         * 整数反转
         * @param {Number} num 所求整数
         * @returns {String} 返回字符串类型
         */
        function intReverse(num) {
            let i = num]]>
                    整数反转
        
        /**
         * 整数反转
         * @param {Number} num 所求整数
         * @returns {String} 返回字符串类型
         */
        function intReverse(num) {
            let i = num / 10
            let j = num % 10
            if (i < 1) {
                // 小于 10 直接返回
                return num.toString()
            } else {
                let nextNum = Math.floor(i)
                // 要采用字符串拼接,否则就求和了
                return `${j}${intReverse(nextNum)}`
            }
        }
        
        let num = 123456
        console.log(intReverse(num))        // 654321
        console.log(typeof intReverse(num)) // string
        

        字符串数组反转

        本文不讨论 Array.prototype.reverse.call(someArray)Array.prototype.reverse.apply(someArray)

        /**
         * 字符串数组反转
         * @param {character[]} arr
         * @return {void} Do not return anything, modify arr in-place instead.
         */
        function reverseString(arr) {
            let len = arr.length
            if (len <= 1) return
            // 数组长度为奇数时,中间元素无需处理
            for (let i = 0, j = len - 1; i < len / 2; i++, j--) {
                let temp = arr[i]
                arr[i] = arr[j]
                arr[j] = temp
            }
        }
        
        let num = ['h', 'e', 'l', 'l', 'o']
        reverseString(num)
        console.log(num)    // ['o', 'l', 'l', 'e', 'h']
        
        // while 循环处理
        // function reverseString(arr) {
        //     let len = arr.length
        //     if (len <= 1) return
        //     let i = 0, j = len - 1
        //     while (i < j) {
        //         let temp = arr[i]
        //         arr[i] = arr[j]
        //         arr[j] = temp
        //         i++
        //         j--
        //     }
        // }
        
        ]]>
        <![CDATA[Mercurial 使用详解]]> https://github.com/tofrankie/blog/issues/109 https://github.com/tofrankie/blog/issues/109 Sat, 25 Feb 2023 12:56:02 GMT 配图源自 Freepik

        Mercurial 跟 Git 一样,也是一个分布式版本控制系统,使用汞的化学元素符号「Hg」的小写形式 hg 作为命令。

        安装

        使用 Homebrew 进行安装,其他方式请看这里

        $ brew install mercurial
        

        初始化

        在全局配置文件 ~/.hgrc(若无则新增),配置提交者的用户名和邮箱。比如:

        [ui]
        username = Frankie <frankie@example.com>
        

        Use hg config to configure a user name, email address, editor, and other preferences once per machine.

        $ hg init
        
        # or
        $ hg clone <repo-address> [repo-name]
        

        忽略文件 .hgignore 支持 regexpglob 两种语法,比如:

        syntax: glob
        
        .vscode
        .DS_Store
        
        *.swp
        *.orig
        

        Related Link: Syntax for Mercurial ignore files

        分支操作

        创建分支

        $ hg branch <branch-name>
        

        一个刚创建的分支,至少 commit 一次,分支才算真正被创建。可以考虑提交一个空 commit,比如:hg commit -m "create '<branch-name>' branch"

        查看分支

        # 查看当前分支
        $ hg branch
        
        # 查看所有分支
        $ hg branches
        

        切换分支

        $ hg checkout <branch-name>
        

        假设有分支 A 和分支 B 同时在开发,需要来回切换,但当前分支功能未开发完成又不好 commit,这种场景可以考虑 shelve。

        在 Git 中,可以使用 git stash 将所有未提交的修改(工作区和暂存区)保存至堆栈中,用于后续恢复当前工作目录。Mercurial 也有类似的命令:

        $ hg shelve --name <name>
        $ hg unshelve <name>
        

        其中 --name 参数是可选的,也可简写为 -n

        合并分支

        $ hg merge <branch-name>
        

        合并后别忘了 commit。

        如果后悔了,想取消未提交的合并(变更不会丢失)

        $ hg merge --abort
        

        还有有一种场景,假设你在合并分支,但另外一个同学又 push 了新的变更上来,当你合并完成,是推不上去。有一个很危险的操作,请谨慎使用:

        $ hg update --clean
        

        ⚠️ 请注意 --clean 会丢掉所有 uncommited 的变更(包括 shelve 的内容)。如果是 untracked files 则不会被删除。

        Related Link: hg update

        关闭分支

        一个仓库通常有 Master 和 Develop 两个主干分支,还应该有 Feature、Release、Hotfix 等辅助分支。当辅助分支完成使命后,它们将会被合并至主干分支,再进行删除。

        在 Git 里可以通过 git branch -d <branch-name>git push origin --delete <branch-name> 等方式删除本地分支或远程分支。

        但 Mercurial 里没有删除分支的概念。Mercurial 则用「关闭」的概念来表示一个分支的结束。如果一个分支该结束了,我们可以提交一个空的 Commit 来关闭它(需加上 --close-branch 参数),比如 hg commit -m 'xxx' --close-branch。如果在已关闭的分支上提交新的 Commit,该分支会 Reopen。

        举个例子,将 feature 合并至 default,同时关闭 feature 分支:

        # 1. 在 feature 分支提交一个空 Commit 来关闭当前分支
        $ hg commit -m 'xxx' --close-branch
        
        # 2. 切换至 default 分支
        $ hg checkout default
        
        # 3. 合并 feature
        $ hg merge feature
        
        # 4. 提交本次合并
        $ hg commit -m 'xxx'
        
        # 5. 推到远端
        $ hg push
        

        代码合并工具

        篇幅可能有点长,另起章节,放在文末。

        代码提交

        # 普通提交
        $ hg commit -m 'some message...'
        
        # 空 commit 提交
        $ hg commit -m 'some message...' --config ui.allowemptycommit=1
        

        刚创建的分支,是可以直接 hg commit -m 'xxx' 提交一个空 Commit 的。但在已有 Commit Log 的分支上,如果没有文件的改动,直接提交空 Commit 会失败并提示 nothing changed。针对有需要提交空 Commit 的场景,可以在末尾加上 --config ui.allowemptycommit=1

        撤销上一个提交

        $ hg rollback
        

        代码推送

        $ hg push
        

        若推送远程仓库中还没有的新分支,需要加上 --new-branch 参数。

        代码拉取与更新

        # 拉取代码(类似 git fetch)
        $ hg pull
        
        # 拉取代码后,更新本地分支
        $ hg update
        

        以上两步等价于

        $ hg pull -u
        

        查看提交记录

        $ hg log
        
        # 分支图形化 --graph
        $ hg log -G
        
        # 指定文件
        $ hg log /path/to/file
        
        $ 含 patch 差异 --patch
        $ hg log -p 
        

        更多 hg log -h 或请看这里

        取消文件跟踪

        通常来说,我们会将不需要提交到远程仓库的文件或目录添加到 .hgignore 文件中。对于已跟踪的文件(tracked file),若要取消某个文件或目录的跟踪,只更新 .hgigonre 是不行的,还要用到 hg removehg forget 命令。

        # 取消跟踪,并删除本地文件
        $ hg remove <tracked-file>
        
        # 取消跟踪,但不删除本地文件
        $ hg forget <tracked-file>
        

        分别对应 Git 的 git rmgit rm --cache,根据实际需求选择即可。更多请看这里

        其他

        • hg status shows the status of a repository.
        • hg add puts files in the staging area.
        • hg commit saves the staged content as a new commit in the local repository.
        • hg log lists all changes committed to a repository, starting with the most recent.

        代码合并工具

        Mercurial 没有内置任何交互式的合并程序,但可以依赖外部工具来实现。

        Mercurial does not include any interactive merge programs but relies on external tools for that.

        默认情况下,如果合并时存在冲突,会被标记为失败,需要进一步手动解决冲突。

        比如,当前有 defaultdevelop 两个分支,现在要将 develop 合并至 default 分支里,而且两个分支是存在冲突的。

        $ hg checkout default
        
        $ hg merge develop
        

        默认情况下,它使用的是 vimdiff 工具,如下:

        左侧是合并结果。中间是要合并进来的分支。右侧是两个分支的共同祖先节点。

        鄙人对 vim 操作不熟练,面对复杂的合并时,用起来很不顺手,唯有另寻他路。

        使用 VS Code

        就 Git 来说,在 VS Code 上的合并体验还不错。

        由于 VS Code 并没有内置支持 Mercurial 版本管理,因此要安装 Hg 相关扩展。

        1. 安装 Hg 相关的 VS Code 扩展(比如这个),以便在资源管理器面板查看变更等,像 Git 一样。
        2. ~/.hgrc 以下配置。
        [ui]
        merge = :merge
        

        现在执行 Merge 操作时,就能像 Git 一样操作了,操作完成后,点击冲突文件左侧的 ✔ 表示 Merge Complete。也可以用 hg resolve 来代替。

        使用 3-way merge

        在 VS Code 1.69 版本,它带来了 3-way merge editor 方式。但似乎争议很大,至于好不好用,见仁见智。

        插件还用上面那个,调整 ~/.hgrc 配置如下:

        [ui]
        merge = code
        
        [extensions]
        extdiff =
        
        [merge-tools]
        code.priority = 100
        code.premerge = True
        code.args = --wait --merge $other $local $base $output
        
        [extdiff]
        cmd.vsd = code
        opts.vsd = --wait --diff
        

        现在执行 Merge 操作,视图如下:

        上左表示要合并进来的分支。上中表示两个分支的共同祖先节点。右侧表示当前分支。下方表示合并结果。

        操作完成后,保存文件。关闭 Merging Tab 就表示 Merge Completed。如果有多个冲突文件,关闭这个会自动打开下一个。

        当然,还有很多第三方工具,就不展开介绍了,选择一个合适自己的就行。

        复制提交

        场景:有 A、B 两个分支,在 A 分支提交了一个 commit,要将该 commit 修改的内容“复制”到 B 分支。

        假设当前处于 A 分支,commit 的修订版本 rev 是 123。

        方式有两种:

        一是使用 hg graft 命令,它会在 B 分支创建一个新的 commit,内容和 commit message 跟原提交相同,但 commit hash 不同。

        $ hg checkout feature-b
        
        $ hg graft -r 123
        

        二是使用 hg diffhg import 命令,这样可以手动调整、检查再提交。

        $ hg diff -c 123 > patch.diff
        
        $ hg checkout feature-b
        
        $ hg import --no-commit patch.diff
        

        参考链接

        ]]>
        <![CDATA[细读 Git | 进阶]]> https://github.com/tofrankie/blog/issues/108 https://github.com/tofrankie/blog/issues/108 Sat, 25 Feb 2023 12:55:41 GMT 配图源自 Freepik

        你对 Git 的认知,只停留在 git status 配图源自 Freepik

        你对 Git 的认知,只停留在 git statusgit addgit commitgit pushgit pull 层面的简单操作吗?你知道 git mergegit rebase 的区别吗?如何选择呢?

        一、File Status

        在 Git 的工作目录下,每一个文件都不外乎两种状态: tracked(已跟踪)和 untracked(未跟踪)。

        • tracked:表示被纳入版本控制的文件,它又细分为 unmodified(未修改)、modified(已修改)、staged(已暂存)三种状态。
        • untracked:表示除了 tracked 之外的所有文件,它既没有快照记录,也没有被放入 staging area(暂存区)。

        文件的状态变化周期,如下:

        在当前工作目录下,可以通过 git status 命令查看所有(已跟踪和未跟踪)文件状态。如果你希望某些文件出现在 untracked 列表,可以将其添加至 .gitignore 文件中。在 GitHub 上提供了针对数十种项目及语言的 .gitignore 模板,详看这里

        二、Git Object

        从根本上来讲 Git 是一个内容寻址(content-addressable)文件系统,核心部分是一个简单的键值对数据库(key-value data store)。当你向 Git 仓库插入任意类型的内容时,它会返回一个唯一的键(SHA-1),通过该键可以在任意时刻再次取回该内容。

        在初始化 Git 仓库时,它会创建一个 .git 目录,该目录下包含了几乎所有 Git 存储和操作的东西。因此,如果你想备份或者复制一个版本库,只需把这个目录拷贝至另一处即可。

        $ git init temp-repo
        Initialized empty Git repository in /Users/frankie/Desktop/Web/Temp/temp-repo/.git/
        
        $ cd temp-repo
        
        $ tree .git -L 1
        .git
        ├── HEAD         # 指向当前分支
        ├── config       # 包含项目特有的配置选项
        ├── description  # 仅供 GitWeb 程序使用,无需关心
        ├── hooks        # 包含客户端或服务端的钩子脚本(hook scripts)
        ├── index        # 保存暂存区信息(git init 时无此文件,尚待创建)
        ├── info         # 包含一个全局性排除(global exclude)文件,用以放置那些不希望被记录在 .gitignore 文件中的忽略模式(ignored patterns)
        ├── objects      # 存储所有数据内容
        └── refs         # 存储指向数据(分支、远程仓库和标签等)的提交对象的指针
        

        我们需要了解下 Git 对象(Git Object),有以下几种类型:

        • blob object(数据对象)
        • tree object(树对象)
        • commit object(提交对象)
        • tag object(标签对象)

        2.1 Blob Object

        通过 Git 操作的数据都存放于 objects 目录下。在仓库初始化时,objects 目录及其 packinfo 两个子目录,内容均是空的。

        $ find .git/objects
        .git/objects
        .git/objects/pack
        .git/objects/info
        
        $ find .git/objects -type f
        

        接下来,我们利用底层命令(plumbing command) git hash-object 来演示一下 Git 存入和取出内容。

        使用 git hash-object 创建一条新数据对象,并将它手动存入你的 Git 数据库(指 .git/objects 目录)中:

        $ echo 'some text...' | git hash-object -w --stdin
        2c3e89d43daa5761b247cbd1ae08e08ed8cd054d
        

        其中 -w 表示除了返回「唯一键」之外,还要将该对象写入数据库中。--stdin 表示从标准输入读取内容。

        此时,我们可以看到 objects 目录下,新增了 2c/3e89d43daa5761b247cbd1ae08e08ed8cd054d 文件。

        $ tree .git/objects
        .git/objects
        ├── 2c
        │   └── 3e89d43daa5761b247cbd1ae08e08ed8cd054d
        ├── info
        └── pack
        
        3 directories, 1 file
        

        前面 git hash-object 操作,返回了一个长度为 40 个字符的校验和。它是一个 SHA-1 哈希值,是将待存储的数据外加一个头部信息(header)一起做 SHA-1 校验运算而得的校验和。

        在 Git 中,它将校验和的「前 2 个字符」作为子目录名称,「余下 38 个字符」用作文件名。

        以上就是 Git 将内容存储到对象数据库的过程。如果想要取回数据,那么可以通过 git cat-file 命令取回。

        $ git cat-file -p 2c3e89d4
        some text...
        

        关于 git cat-file 更多请看这里

        $ git cat-file <options> <object>
          -t  show object type
          -s  show object size
          -e  exit with zero when there's no error
          -p  pretty-print object's content
        

        到这里,你对 Git 如何存入/取出数据应该有了初步的了解。

        除此之外,我们可以将这些操作应用于文件的内容。比如,创建一个自述文件并将其内容存入数据库:

        $ echo 'Some instructions...' > README.md
        
        $ git hash-object -w README.md
        9e486f6a40f2e45a8dd0835e6a0357d6f7f0db64
        
        $ find .git/objects -type f
        .git/objects/9e/486f6a40f2e45a8dd0835e6a0357d6f7f0db64
        .git/objects/2c/3e89d43daa5761b247cbd1ae08e08ed8cd054d
        

        我们可以再次修改 README.md 的内容,对象数据库中将会记录该文件的两个不同版本:

        $ echo 'Some instructions...(V2)' > README.md
        
        $ git hash-object -w README.md
        e04f0c5c9d740ead52734ed920c580bf0f380ea2
        
        $ git cat-file -p e04f0c5c
        Some instructions...(V2)
        
        $ find .git/objects -type f
        .git/objects/9e/486f6a40f2e45a8dd0835e6a0357d6f7f0db64
        .git/objects/e0/4f0c5c9d740ead52734ed920c580bf0f380ea2
        .git/objects/2c/3e89d43daa5761b247cbd1ae08e08ed8cd054d
        

        接着,我们模拟下「版本回退」,取回 README.md 的第一版本内容:

        $ git cat-file -p 9e486f6a > README.md
        
        $ cat README.md
        Some instructions...
        

        请注意,尽管以上操作进行了“版本回退”,但是 objects/e0/4f0c5c9d740ead52734ed920c580bf0f380ea2 文件并没有被删除。

        记住文件的每一个版本所对应的 SHA-1 值并不现实,还有另外一个问题,上述操作仅在 objects 目录下保存了文件的内容,而文件名却没有被记录。通俗地将,我们通过 git cat-file -p 9e486f6a 只能获取对应的内容为 Some instructions...,但 Git 不知道它是 README.md 文件的内容。

        上述类型的对象,我们称为数据对象(blob object)。可以通过 git cat-file -t 命令查到:

        $ git cat-file -t 9e486f6a
        blob
        

        2.2 Tree Object

        树对象(tree object),可以解决文件名保存的问题,也允许将多个文件组织到一起。

        一个树对象包含了一条或多条树对象的记录(tree entry),每条记录含有一个指向数据对象(blob object)或者子树对象的 SHA-1 指针,以及相应的模式、类型、文件名信息。

        我们将上一节用到的本地 temp-repo 仓库清空,并重新初始化,另外创建三个文件,目录结构如下:

        $ rm -rf .git
        
        $ git init
        Initialized empty Git repository in /Users/frankie/Desktop/Web/Temp/temp-repo/.git/
        
        $ echo 'some instructions...(v1)' > README.md
        $ echo 'node_modules' > .gitignore
        $ mkdir 'src' && echo 'console.log("entry point")' > src/index.js
        
        $ tree . -a
        .
        ├── .git
        │   └── ...
        ├── .gitignore
        ├── README.md
        └── src
            └── index.js
        

        接着,我们利用上层命令(porcelain command),将这些 unstracked 的文件,添加至暂存区并提交。

        $ git add .
        
        $ git commit -m 'first commit'
        [main (root-commit) 10a28a5] first commit
         3 files changed, 3 insertions(+)
         create mode 100644 .gitignore
         create mode 100644 README.md
         create mode 100644 src/index.js
        

        当前对应的最新树对象是这样的:

        $ git cat-file -p main^{tree}
        100644 blob 3c3629e647f5ddf82548912e337bea9826b434af    .gitignore
        100644 blob 6df930682ee921766a29e6504c27f79790a7bae6    README.md
        040000 tree b79960a978f46909a0449add4b2ab35766883e6d    src
        

        其中 main^{tree} 语法表示 main 分支上最新的提交所指向的树对象。 请注意,src 子目录(所对应的那条树对象记录)并不是一个数据对象,而是一个指针,其指向的是另一个树对象:

        $ git cat-file -p b79960a9
        100644 blob 064a5f9cffbc4df304f475a580dd961bdc2f7d38    index.js
        
        $ git cat-file -t b79960a9
        tree
        

        此时,Git 内部存储的数据是类似这样的:

        通常,Git 根据某一时刻暂存区(即 index 区域,.git/index 文件)所表示的状态创建并记录一个对应树对象,如此重复便可依次记录(某个时间段内)一系列的树对象。

        接下来,我们先重置一下仓库,因为上面利用了上层命令暂存并提交了,免得干扰。下面将使用底层命令演示一下:

        1. 利用 git hash-object 来创建一个数据对象,并记录在 Git 数据库中:
        $ echo 'readme v1' | git hash-object -w --stdin
        504a7438c95afbd7f5280d756fb405bd85fbf19e
        
        1. 利用底层命令 git update-index 将这个数据对象加入暂存区,该文件为 README.md
        $ git update-index --add --cacheinfo 10064 \
        > 504a7438c95afbd7f5280d756fb405bd85fbf19e README.md
        

        关于 git update-index 命令选项说明:

        • --add 是因为该文件并不在暂存区中,因此需要指定。
        • --cacheinfo 是因为添加的文件位于 Git 数据库中,而不是位于当前目录下。
        • 文件模式:10064 为文件模式,表示一个普通文件;10075 表示一个可执行文件;120000 表示一个符号链接。
        1. 通过 git write-tree 命令将暂存区内容写入一个树对象。可以使用 git cat-file -t 来验证一下它确实是树对象。
        $ git write-tree
        d50d689553de001d8537d94dae3cb2c89788dae1
        
        $ git cat-file -p d50d689553de001d8537d94dae3cb2c89788dae1
        100644 blob 504a7438c95afbd7f5280d756fb405bd85fbf19e    README.md
        
        $ git cat-file -t d50d689553de001d8537d94dae3cb2c89788dae1
        tree
        

        执行 git write-tree 命令时,如果某个树对象此前并不存在的话,它会根据当前暂存区状态自动创建一个新的树对象。

        1. 接着,我们再创建一个新的树对象,它包含一个新文件 docs.md 以及 README.md 文件的第二个版本。
        $ echo 'docs v1' > docs.md
        
        $ git update-index --add docs.md
        
        $ echo 'readme v2' | git hash-object -w --stdin
        8d85786d2dc2fd2cad833d88bce5fca5d28a12fa
        
        $ git update-index --add --cacheinfo 100644 \
        > 8d85786d2dc2fd2cad833d88bce5fca5d28a12fa README.md
        

        到这来,目前暂存区包含了新文件 docs.mdREADME.md 的新版本。然后我们记录下这个目录数(将当前暂存区的状态记录为一个树对象),然后观察它的结构:

        $ git write-tree
        a3f266e268f9d5d337a4c494b234f7482a0b5840
        
        $ git cat-file -p a3f266e268f9d5d337a4c494b234f7482a0b5840
        100644 blob 8d85786d2dc2fd2cad833d88bce5fca5d28a12fa    README.md
        100644 blob e9074071f146011f8a927c2b7690df6dfe765a90    docs.md
        

        我们发现,新的记录树对象包含两条文件记录,文件 README.md 的 SHA-1 值(8d8578)对应的内容是 readme v2,文件 docs.md 的 SHA-1 值(e90740)对应的内容是 docs v1

        1. 如果为了好玩,你还可以将第一树对象(d50d68)加入到第二个树对象(a3f266),使其成为新的树对象的一个子目录。
        $ git read-tree --prefix=bak d50d689553de001d8537d94dae3cb2c89788dae1
        
        $ git write-tree
        783727c40cc4b1205242d4130b3f713ed525b23d
        
        $ git cat-file -p 783727c40cc4b1205242d4130b3f713ed525b23d
        100644 blob 8d85786d2dc2fd2cad833d88bce5fca5d28a12fa    README.md
        040000 tree d50d689553de001d8537d94dae3cb2c89788dae1    bak
        100644 blob e9074071f146011f8a927c2b7690df6dfe765a90    docs.md
        

        通过调用 git read-tree 命令,可以把树对象读入暂存区。以上指定 --prefix 选项,将一个已有的树对象作为子树读入暂存区。

        此时,Git 内部存储的结构是这样的:根目录有 README.mddocs.md 两个文件,以及一个名为 bak 的子目录,它包含了 README.md 文件的第一个版本。如图:

        2.3 Commit Object

        做完以上操作之后,现在有了两个树对象(783727d50d68),分别代表我们想要跟踪的不同项目快照。

        目前存在的问题有:如果想重用这些快照,你必须要记住所有 SHA-1 哈希值。况且,你也完全不知道谁保存了这些快照,在什么时候保存的,以及为什么保存这些快照。

        接下来介绍的提交对象(commit object)就是为了保存基本信息的。通过调用底层命令 git commit-tree 可以创建一个提交对象。为此,需要指定一个树对象的 SHA-1 值,以及该提交的父提交对象(如果有的话)。

        $ git commit-tree <tree-object-sha1> -p <parent-commit-object-sha1>
        

        我们使用 d50d68 树对象来创建第一个提交对象。由于是首次,因此没有父提交对象。

        $ echo 'first commit' | git commit-tree d50d68
        242bd136ff24d2880a68f2de9a8a3a66a0338eea
        
        $ git cat-file -p 242bd136ff24d2880a68f2de9a8a3a66a0338eea
        tree d50d689553de001d8537d94dae3cb2c89788dae1
        author Frankie <1426203851@qq.com> 1647702687 +0800
        committer Frankie <1426203851@qq.com> 1647702687 +0800
        
        first commit
        
        $ git cat-file -t 242bd136ff24d2880a68f2de9a8a3a66a0338eea
        commit
        

        由于创建时间、作者数据的不同,总会得到一个唯一的 SHA-1 值。同样地,可以通过 git cat-file 来查看这个新的提交对象。我们来看下提交对象都包含些什么信息:

        tree d50d689553de001d8537d94dae3cb2c89788dae1
        author Frankie <1426203851@qq.com> 1647702687 +0800
        committer Frankie <1426203851@qq.com> 1647702687 +0800
        
        first commit
        

        提交对象的格式很简单,它先指定一个顶层的树对象,代表当前项目快照;然后是可能存在的父提交(到目前为止还不存在任何父提交);之后是作者、提交者信息,它由 user.nameuser.email 配置来设定,外加一个时间戳;留空一行,最后是提交注释。

        接着,我们使用 783727 树对象创建第二个提交对象,其父提交对象是 242bd1

        $ echo 'second commit' | git commit-tree 783727 -p 242bd1
        086ba597542c232e267d4b9aa4c0d3d4bcf2411a
        
        $ git cat-file -p 086ba5
        tree 783727c40cc4b1205242d4130b3f713ed525b23d
        parent 242bd136ff24d2880a68f2de9a8a3a66a0338eea
        author Frankie <1426203851@qq.com> 1647703338 +0800
        committer Frankie <1426203851@qq.com> 1647703338 +0800
        
        second commit
        

        现在,我们对最后提交的 SHA-1 值执行 git log 命令,你会发现,你已经有一个货真价实、可由 git log 查看的 Git 提交历史了。

        $ git log --stat 086ba5
        commit 086ba597542c232e267d4b9aa4c0d3d4bcf2411a
        Author: Frankie <1426203851@qq.com>
        Date:   Sat Mar 19 23:22:18 2022 +0800
        
            second commit
        
         README.md     | 2 +-
         bak/README.md | 1 +
         docs.md       | 1 +
         3 files changed, 3 insertions(+), 1 deletion(-)
        
        commit 242bd136ff24d2880a68f2de9a8a3a66a0338eea
        Author: Frankie <1426203851@qq.com>
        Date:   Sat Mar 19 23:11:27 2022 +0800
        
            first commit
        
         README.md | 1 +
         1 file changed, 1 insertion(+)
        

        在以上过程,我们没有借助任何上层命令,仅凭几个底层命令就完成了一个 Git 提交历史的创建。

        我们使用 git addgit commit 上层命令时,Git 所做的工作实质就是:将被改写的文件保存为数据对象,更新暂存区,记录树对象,最后创建一个指明了顶层树对象和父提交对象的提交对象。

        这三种 Git 对象 — 数据对象、树对象、提交对象,最初均以单独文件的形式保存在 .git/objects 目录下:

        $ find .git/objects -type f
        .git/objects/50/4a7438c95afbd7f5280d756fb405bd85fbf19e      # readme v1    (blob object)
        .git/objects/a3/f266e268f9d5d337a4c494b234f7482a0b5840      # tree 2       (tree object)
        .git/objects/08/6ba597542c232e267d4b9aa4c0d3d4bcf2411a      # commit 2     (commit object)
        .git/objects/d5/0d689553de001d8537d94dae3cb2c89788dae1      # tree 1       (tree object)
        .git/objects/e9/074071f146011f8a927c2b7690df6dfe765a90      # docs v1      (blob object)
        .git/objects/24/2bd136ff24d2880a68f2de9a8a3a66a0338eea      # commit 1     (commit object)
        .git/objects/8d/85786d2dc2fd2cad833d88bce5fca5d28a12fa      # readme v2    (blob object)
        .git/objects/78/3727c40cc4b1205242d4130b3f713ed525b23d      # tree 3       (tree object)
        

        不知道有没有同学好奇,这里面为什么有三个树对象?原因是前面我们将 tree 1(d50d68)加入到 tree 2(a3f266),形成了新的树对象 tree 3(783727)。

        通过 git cat-file 查看下就清楚了:

        $ git cat-file -p d50d68
        100644 blob 504a7438c95afbd7f5280d756fb405bd85fbf19e    README.md
        
        $ git cat-file -p a3f266
        100644 blob 8d85786d2dc2fd2cad833d88bce5fca5d28a12fa    README.md
        100644 blob e9074071f146011f8a927c2b7690df6dfe765a90    docs.md
        
        $ git cat-file -p 783727
        100644 blob 8d85786d2dc2fd2cad833d88bce5fca5d28a12fa    README.md
        040000 tree d50d689553de001d8537d94dae3cb2c89788dae1    bak
        100644 blob e9074071f146011f8a927c2b7690df6dfe765a90    docs.md
        

        我们可以得到一个对象关系图:

        2.4 Tag Object

        标签对象(tag object) 非常类似于一个提交对象,它包含一个标签创建者信息、一个日期、一段注释信息,以及一个指针。 主要的区别在于,标签对象通常指向一个提交对象,而不是一个树对象。 它像是一个永不移动的分支引用——永远指向同一个提交对象,只不过给这个提交对象加上一个更友好的名字罢了。

        2.5 对象存储

        在介绍数据对象的时候,曾提及,在 Git 中的 SHA-1 哈希值,是由「待存储的数据」和「头部信息」一起做 SHA-1 校验运算而得的校验和。

        比如,在 Git 中字符串 what is this? 返回的 SHA-1 值为:3246c91c89bbcada55565188499b8e214198fd48

        $ echo -n 'what is this?' | git hash-object --stdin
        ed8f50cbf7a25a1ad0a4ffed9f721b3e6f30bd25
        

        这里使用了 echo -n 是避免在输出中添加换行。

        我们使用 Ruby 脚本语言来演示下,如何生成相同的 SHA-1 值。这里通过 irb(Interactive Ruby)命令启动 Ruby 的交互模式:

        $ irb
        
        >> content = "what is this?"
        => "what is this?"
        
        >> header = "blob #{content.length}\0"
        => "blob 13\u0000"
        
        >> store = header + content
        => "blob 13\u0000what is this?"
        
        >> require "digest/sha1"
        => true
        
        >> sha1 = Digest::SHA1.hexdigest(store)
        => "ed8f50cbf7a25a1ad0a4ffed9f721b3e6f30bd25"
        

        说明:

        1. Git 会以识别出的对象类型作为开头来构造一个「头部信息」,以上示例是一个 "blob" 字符串,接着添加一个空格,随后是数据内容的字节数,最后一个是空字节(null byte)。
        2. 将「头部信息」和「原始数据」拼接起来,并计算出这条内容的 SHA-1 校验和。计算 SHA-1 值,先通过导入 SHA-1 digest 库,然后对目标字符串调用 Digest::SHA1.hexdigest()

        生成 SHA-1 值之后,还没完呢... Git 会通过 zlib 压缩这条新内容,然后将压缩后的内容存入特定的文件中。

        它将会存在于 .git/objects 目录下一级,将 SHA-1 值的「前 2 位字符」作为子目录,「余下 38 位」作为该子目录下的文件名,文件内存储的是「头部信息」和「原始数据」经 zlib 压缩后的内容。

        比如,SHA-1 值为 ed8f50cbf7a25a1ad0a4ffed9f721b3e6f30bd25 的话,那么最终的文件路径为 .git/objects/ed/8f50cbf7a25a1ad0a4ffed9f721b3e6f30bd25

        >> require "zlib"
        => true
        
        >> zlib_content = Zlib::Deflate.deflate(store)
        => "x\x9CK\xCA\xC9OR04f(\xCFH,Q\xC8,V(\xC9\xC8,\xB6\a\x00J\f\x06\xEB"
        
        >> path = ".git/objects/" + sha1[0,2] + "/" + sha1[2,38]
        => ".git/objects/ed/8f50cbf7a25a1ad0a4ffed9f721b3e6f30bd25"
        
        >> require "fileutils"
        => true
        
        >> FileUtils.mkdir_p(File.dirname(path))
        => [".git/objects/ed"]
        
        >> File.open(path, "w") { |f| f.write zlib_content }
        => 29
        

        说明:

        1. 在 Ruby 引入 zlib 库,对目标内容调用 Zlib::Deflate.deflate() 来进行压缩。
        2. 接着确定新内容存储的路径,然后再写入内容(如果目录不存在会先创建)。

        然后,可以通过 git cat-file 验证一下内容是否一致哦!

        $ git cat-file -p ed8f50cbf7a25a1ad0a4ffed9f721b3e6f30bd25
        what is this?
        

        到这里,你就创建了一个有效的 Git 数据对象。

        所有的 Git 对象均以这种方式进行存储,区别仅在于「类型标识」,另外两种对象的「头部信息」是以 "tree""commit" 开头,而不是 "blob"

        另外,虽然「数据对象」的内容几乎可以是任何东西,但「树对象」和「提交对象」的内容却有各自固定的格式。

        2.6 其他

        到这里,不应该再有此疑问了吧:git cat-file 是如何将 SHA-1 密文还原成明文的?

        我们知道 SHA-1、MD5 这类加密算法是不可逆的,在忽略 SHA-1 被 Google 攻破的事实前提下,可以认为利用 SHA-1 值是不能被还原出原始数据的。

        在 Git 中,实际被存储在 .git/objects 的所有内容,均是由「头部信息」和「原始数据」拼接后经 zlib 压缩后的结果。它是可以被还原的。而 SHA-1 哈希值则作为对应的文件路径而已,由于文件路径规则是固定的(前 2 位作为子目录名称,后 38 位作为文件名称),因此借助 SHA-1 值,我们可以找到对应的内容,可以理解为 SHA-1 在里面起指引作用。

        因此,git cat-file 并不是要还原 SHA-1 所表示的原始数据,而是根据 SHA-1 哈希值在本地数据库(.git/objects 目录)找到对应的文件,然后再将这个文件还原出来。

        三、Git References

        如果你对仓库某一个提交(比如前面的 086ba5)开始往前的历史感兴趣,那么你可以运行 git log 086ba5 命令来显示历史:

        $ git log 086ba5
        commit 086ba597542c232e267d4b9aa4c0d3d4bcf2411a
        Author: Frankie <1426203851@qq.com>
        Date:   Sat Mar 19 23:22:18 2022 +0800
        
            second commit
        
        commit 242bd136ff24d2880a68f2de9a8a3a66a0338eea
        Author: Frankie <1426203851@qq.com>
        Date:   Sat Mar 19 23:11:27 2022 +0800
        
            first commit
        

        但这样的话,有一个明显的弊端,它要求你必须记住 SHA-1 哈希值,这显然是不合理的。假设有个一个文件来保存 SHA-1 值,而这个文件有一个简单的名字,然后用这个名字指针来代替原始的 SHA-1 值的话,会更加简单。

        在 Git 中,这种简单的名称被称为**「引用」(references,简写 refs)**。你可以在 .git/refs 目录下找到这类含有 SHA-1 值的文件。在当前仓库中,这个目录还没包含任何文件,目录结构如下:

        $ find .git/refs
        .git/refs
        .git/refs/heads
        .git/refs/tags
        
        $ find .git/refs -type f
        

        如要创建一个新引用用来帮助记录最新提交所在的位置,从技术上,只需要简单地做如下操作:

        $ echo 086ba597542c232e267d4b9aa4c0d3d4bcf2411a > .git/refs/heads/main
        

        现在,你可以使用刚创建的新引用来代替 SHA-1 值了。

        $ git log main --pretty=oneline
        086ba597542c232e267d4b9aa4c0d3d4bcf2411a (HEAD -> main) second commit
        242bd136ff24d2880a68f2de9a8a3a66a0338eea first commit
        

        但是不提倡直接编辑引用文件,如果想要更新某个引用,Git 提供了一个更加安全的命令 git update-ref 来完成此事:

        # usage: git update-ref <refname> <new-val>
        $ git update-ref refs/heads/main 086ba597542c232e267d4b9aa4c0d3d4bcf2411a
        

        这基本就是 Git 分支的本质:一个指向某一系列提价之首的指针或引用。若想在第一个提交上创建一个分支,可以这么做:

        $ git update-ref refs/heads/test 242bd136ff24d2880a68f2de9a8a3a66a0338eea
        
        $ git log test --pretty=oneline
        242bd136ff24d2880a68f2de9a8a3a66a0338eea (test) first commit
        

        这个 test 分支将只包含从第一个提交开始往前追溯的记录(但本文目前只有包含两次提交,哈哈)。此时我们运行上层命令 git branch 发现,目前已存在 maintest 两个分支了。

        $ git branch
        * main
          test
        

        当运行类似于 git branch <branch-name> 命令时,Git 实际上会运行 git update-ref 命令,取得当前所在分支最新提交对应的 SHA-1 值,并将其加入你想要创建的任何新引用中。

        此时,从 Git 数据库角度看起来像这样:

        在 Git 有三种引用类型:

        • head reference(HEAD 引用)
        • tag reference(标签引用)
        • remote reference(远程引用)

        3.1 HEAD 引用

        现在的问题是,当你执行 git branch <branch-name> 时,Git 如何知道最新提交的 SHA-1 值呢?

        答案就是 HEAD 文件(存在于 .git 目录下)。

        HEAD 文件通常是一个「符号引用」(symbolic reference),指向目前所在的分支。所谓的符号引用,表示它是已指向其他引用的指针。

        在某些罕见的情况下,HEAD 文件可能会包含一个 Git Object 的 SHA-1 值。当你 checkout 一个标签、提交或远程分支,使得你的仓库变成 的 detached HEAD 状态时,就会出现这种情况(关于分离 HEAD 另一篇文章也介绍过)。

        当你查看 HEAD 文件的内容时,通常会看到类似这样的内容:

        $ cat .git/HEAD
        ref: refs/heads/main
        

        然后,如果执行 git checkout test,Git 会更新 HEAD 文件:

        $ git checkout test
        Switched to branch 'test'
        
        $ cat .git/HEAD
        ref: refs/heads/test
        

        当执行 git commit 时,该命令会创建一个「提交对象」,并用 HEAD 文件中那个引用所指向的 SHA-1 值设置其父提交字段。

        同样地,Git 提供了一个更加安全的命令:git symbolic-ref。既可以用来查看 HEAD 引用对应的值,同样也可以设置 HEAD 引用的值。

        $ git symbolic-ref HEAD
        refs/heads/main
        
        $ git symbolic-ref HEAD refs/heads/test
        
        $ cat .git/HEAD
        ref: refs/heads/test
        

        但是,不能把符号引用设置为一个不符合引用规范的值:

        $ git symbolic-ref HEAD test
        fatal: Refusing to point HEAD outside of refs/
        

        3.2 标签引用

        在 Git 中,标签的作用就是给某个提交对象加上一个更友好的名字罢了,分为「轻量标签」(lightweight)和「附注标签」(annotated)两种。

        可以这样创建一个轻量标签:

        $ git update-ref refs/tags/v1.0.0 242bd136ff24d2880a68f2de9a8a3a66a0338eea
        

        若要创建一个附注标签,Git 会创建一个标签对象,并记录一个引用来指向该标签对象,而不是直接指向提交对象。下面使用 git tag -a 命令来生成:

        $ git tag -a v2.0.0 086ba597542c232e267d4b9aa4c0d3d4bcf2411a -m 'latest tag'
        

        下面我们对比看下两个 Tag 的内容:

        $ cat .git/refs/tags/v1.0.0
        242bd136ff24d2880a68f2de9a8a3a66a0338eea
        
        $ git cat-file -p 242bd136ff24d2880a68f2de9a8a3a66a0338eea
        tree d50d689553de001d8537d94dae3cb2c89788dae1
        author Frankie <1426203851@qq.com> 1647702687 +0800
        committer Frankie <1426203851@qq.com> 1647702687 +0800
        
        first commit
        
        $ git cat-file -t 242bd136ff24d2880a68f2de9a8a3a66a0338eea
        commit
        
        $ cat .git/refs/tags/v2.0.0
        980d0eab8a71de526ebd1eece1f6cbe33db0931b
        
        $ git cat-file -p 980d0eab8a71de526ebd1eece1f6cbe33db0931b
        object 086ba597542c232e267d4b9aa4c0d3d4bcf2411a
        type commit
        tag v2.0.0
        tagger Frankie <1426203851@qq.com> 1647771598 +0800
        
        latest tag
        
        $ git cat-file -t 980d0eab8a71de526ebd1eece1f6cbe33db0931b
        tag
        

        我们可以发现,标签对象的 object 条目指向了我们打了标签的那个提交对象的 SHA-1 值。但是,标签对象并非必须指向某个提交对象,可以是任意类型的 Git 对象打标签。

        3.3 远程引用

        接着介绍第三种引用类型:远程引用(remote reference)。如果你添加了一个远程版本库并对其执行过推送操作,Git 会记录下最近一次推送时每个分支所对应的值,并保存在 .git/refs/remotes 目录下。

        下面是未推送过,.git/refs 的目录结构:

        $ tree .git/refs
        .git/refs
        ├── heads
        │   ├── main
        │   └── test
        └── tags
            ├── v1.0.0
            └── v2.0.0
        
        2 directories, 4 files
        

        接下来,我们将 main 分支推送至远程仓库,然后再观察下 .git/refs 目录的变化。

        $ git remote add origin git@github.com:toFrankie/simple-git.git
        
        $ git push origin main
        Enumerating objects: 7, done.
        Counting objects: 100% (7/7), done.
        Delta compression using up to 12 threads
        Compressing objects: 100% (3/3), done.
        Writing objects: 100% (7/7), 507 bytes | 507.00 KiB/s, done.
        Total 7 (delta 0), reused 0 (delta 0), pack-reused 0
        To github.com:toFrankie/simple-git.git
         * [new branch]      main -> main
        
        $ tree .git/refs
        .git/refs
        ├── heads
        │   ├── main
        │   └── test
        ├── remotes
        │   └── origin
        │       └── main
        └── tags
            ├── v1.0.0
            └── v2.0.0
        
        4 directories, 5 files
        
        $ cat .git/refs/remotes/origin/main
        086ba597542c232e267d4b9aa4c0d3d4bcf2411a
        

        我们可以看到,新增了 .git/refs/remotes/origin/main 文件,其内容 086ba597542c232e267d4b9aa4c0d3d4bcf2411a 正是最新一次的提交对象的 SHA-1 值。

        远程引用(位于 refs/remotes 目录下的引用)和分支(位于 refs/heads 目录下的引用)之间最主要的区别在于,远程引用是只读的。

        尽管你可以通过 git checkout 切换至某个远程引用,但是 Git 不会将 HEAD 引用指向该远程引用。你永远不能通过 commit 命令来更新远程引用。Git 将这些远程引用作为记录远程服务器上各分支最后已知位置状态的书签来管理。

        未完待续...

        参考链接

        ]]>
        <![CDATA[细读 Git | 弄懂 origin、HEAD、FETCH_HEAD 相关内容]]> https://github.com/tofrankie/blog/issues/107 https://github.com/tofrankie/blog/issues/107 Sat, 25 Feb 2023 12:47:15 GMT 图片源自 Freepik

        如果大家平常都在使用 Git 作为版本控制工具的话,那么一定每天都能见到 <]]> 图片源自 Freepik

        如果大家平常都在使用 Git 作为版本控制工具的话,那么一定每天都能见到 origin,诸如:

        $ git push origin main
        
        $ git fetch origin main
        
        $ git pull origin main
        

        这里的 origin,还有看似相同的 origin/masterorigin/main 又是什么呢?

        一、远程名称(Remote Name)

        在 Git 中,其实无论是 origin,还是 upstream 并没有特殊的含义,但由于被广泛使用,因此它们有了约定俗成、众所周知的含义。

        就好比如说,在现实世界中小明、小红是再普通不过的名字,但由于在小学语文课本的对话中常被作为男一女一,用于表示对话的两个人而已,并没有特别的意义在里面。而在技术博文中,经常可以看到使用 foobar 作为变量标识符举例,它们就相当于语文课本中的小明、小红一样。那么本文接下来要讨论的 originupstream 等也是同样的道理。

        先来个餐前菜...

        如果我跟你说,以下两条命令是完全等效的,你是不是就差不多猜得出 origin 表示什么了?

        $ git push origin main
        $ git push git@github.com:toFrankie/repo-demo.git main
        

        是的,跟你猜的一样...

        1.1 Origin

        我们用示例来讲...

        先在本地随意创建一个 Git 仓库 repo-demo,然后新增一个 README.md 文件,接着 Commit 一下(如下图):

        以上都没问题!接着,我们试着 Push 一下:

        可以看到 git push 失败了,原因很容易理解:我们只是在本地创建一个仓库,并没有将本仓库与远程仓库进行关联,因此 Git 无法理解是将其推送至哪个代码托管平台,然后也不知道是平台上的哪个远程仓库,是 GitHub 平台的,还是 GitLab 平台的?是平台上的 React 仓库,还是 Vue 仓库,还是别的什么仓库?Git 统统都不知道,那么自然是无法替你办事了。

        因此,我们需要做的就是把本地的 repo-demo 仓库与远程仓库关联一下(请注意,一个本地仓库是可以关联多个远程仓库的):

        $ git remote add origin <repo_address>
        

        这里用到了 origin,我们先不管为什么用 origin,用其他(比如 foo)行不行的问题?(答案是可以的)

        关联之后,再进行 Push 就能成功了。

        那么 git remote add 内部做了什么默默无闻的工作呢,它其实是往 .git/config 中写入了一个叫 [remote "origin"] 配置:

        [core]
        	repositoryformatversion = 0
        	filemode = true
        	bare = false
        	logallrefupdates = true
        	ignorecase = true
        	precomposeunicode = true
        [remote "origin"]
        	url = git@github.com:toFrankie/repo-demo.git
        	fetch = +refs/heads/*:refs/remotes/origin/*
        

        如果你本地的仓库是通过 git clone 下来的,Git 会默认将远程仓库命名为 origin,自动帮你关联上远端仓库(可在 .git/config 文件中看到已有 [remote "origin"] 配置项了),因此 Commit 之后就能直接 Push 了。

        When a repo is cloned, it has a default remote called origin that points to your fork on GitHub, not the original repo it was forked from.(引自 Github page

        如果我们在 GitHub 新创建一个 Repository 的话,会看到以下指引:

        我们来分析一下,这配置表示什么意思。

        [remote "origin"]
        	url = git@github.com:toFrankie/repo-demo.git
        	fetch = +refs/heads/*:refs/remotes/origin/*
        

        通过 git remote add 命令,添加了一个叫做 origin 的远程名称(Remote Name),

        • 其中 url 参数,表示该远程名称对应的远程仓库地址。
        • 其中 fetch 参数分为两部分,以冒号 : 进行分割,冒号左边表示本地仓库文件夹,冒号右边表示远程仓库在本地的副本文件夹。里面的加号 + 表示往里面添加数据的意思。

        当使用 git fetch origin 时,Git 将远程仓库下的所有分支拉取到本地的 refs/remotes/origin/ 目录下,然后 git merge 时,它会把 refs/remotes/origin/ 目录下的对应分支合并到 refs/heads/ 目录下对应分支上。

        那么 origin 究竟是什么呢?

        请注意,origin 只是一个别名,用于指向远程仓库。这个别名是可以自行修改的,比如命名为 foobar 等。使用别名好处是「方便」。

        比起记住一个远程仓库地址,别名实在方便太多了。将 origin 作为远程仓库的别名是较为普遍的做法,况且所有代码托管平台默认就是 origin

        回到文章开头的例子:

        $ git push origin main
        
        # 相当于(其中 origin 指向了 git@github.com:toFrankie/repo-demo.git 远程仓库)
        $ git push git@github.com:toFrankie/repo-demo.git main
        

        以上两种方式是完全等价的,这样就更能体现别名的优势了,简洁很多。

        既然是别名,自然是可以修改的,主要有以下命令:

        # 新增远程名称(一个本地仓库,可以关联多个远程仓库)
        $ git remote add <remote-name> <repo-address>
        
        # 删除已存在远程名称(只会移除本地仓库与远程仓库的管理,不会删除远程仓库的代码哈)
        $ git remote rm <remote-name>
        
        # 更新远程名称关联的远程仓库
        $ git remote set-url <remote-name> <repo-address>
        
        # 修改远程名称(也可以先删除再添加)
        $ git remote rename <old-remote-name> <new-remote-name>
        

        比如,像这样:

        然后,我们修改下远程名称为 foo,也是可以的:

        接着,我们随意修改个文件 Push 一下,是这样的 git push foo main

        到这里,你应彻底明白 origin 是什么了吧。

        前面提到过,一个本地仓库是可以关联多个远程仓库的,举个例子:

        $ git remote add bar git@github.com:toFrankie/git-test-demo.git
        

        我们可以查看下 .git/congfig 配置文件,如下(或者通过 git remote -v 查看)

        从图中可以看到,别名 foobar 分别指向了两个不同的远程仓库,然后使用方法与 origin 是相同的,比如:

        # 将本地的 main 分支推送至 foo 对应的远程仓库(repo-demo)
        $ git push foo main
        
        # 将本地的 main 分支推送至 bar 对应的远程仓库(git-test-demo)
        $ git push bar main
        

        1.2 upstream(一个特殊的 remote name)

        upstream 的译为“上游”。当你 git clone 一个别人的 Repository 到本地,由于你不是该仓库的成员,因此你是无法向该仓库推送代码的。此时,相较于本地仓库,别人的这个 Repository 称为 upstream

        我们可以 Fork 这个 Repository 到自己 GitHub 账号下,然后通过 git clone 将这个 Fork 出来的仓库克隆到本地电脑上。(下文将这个别人的仓库称为 Upstream-Repo,Fork 出来的仓库称为 Origin-Repo

        大致关系如图所示(源自),其中 Upstream-Repo 对应图中的 Original,Origin-Repo 对应图中 Fork:

        当我们将 Origin-Repo 克隆到本地,Git 会默认创建一个 origin 的别名指向 Origin-Repo 的仓库地址。

        如果要跟踪 Upstream-Repo 仓库的变更,您需要添加另一个名为 upstream 的别名,使其指向 Upstream-Repo 仓库。

        # 1. 添加上游仓库的别名
        $ git remote add upstream <upstream-repo-address>
        
        # 2. 获取上游仓库的变更
        $ git fetch upstream
        
        # 3. 有需要的话,可以通过 merge 或 rebase 方式合并到本地分支中,比如:
        $ git merge upstream/main
        

        尽管添加了 upstream,诸如 git push upstream main 等方式试图向 Upstream-Repo 提交代码仍然是不被允许的,因为你不是 Upstream-Repo 仓库的成员。想给 Upstream-Repo 仓库贡献代码的话,只能通过 Pull Request 的方式。

        当然,这一节提到的 upstream 也是一个约定俗称的别名,也是可以自定义的。

        1.3 小结

        除了 originupstream 等有众所周知的含义的远程名称之外,我们还可以这样使用:

        由于一个本地仓库是可以关联多个远程仓库的,因此,可以设置多个「别名」分别指向不同的远程仓库(比如一个 GitHub、一个 GitLab、一个 Gitee),然后通过别名的方式方便、快速地拉取某个远程仓库的代码或者将代码推送至某个远程仓库。

        # 添加 github 别名
        $ git remote add github git@github.com:toFrankie/repo-demo.git
        
        # 添加 gitlab 别名
        $ git remote add gitlab git@gitlab.com:toFrankie/repo-demo.git
        
        # 添加 gitee 别名
        $ git remote add gitee git@gitee.com:toFrankie/repo-demo.git
        

        小结:

        • 常见的 originupstream 都只是通过 git remote add 命令创建的名称(Remote Name),用于指向某个远程仓库(Remote URL)。

        • 常用 origin 作为远程仓库的别名,是一个较为主流的做法。同时,也是各大代码托管平台的默认名称(即 git clone 一个远程仓库, Git 会默认将 origin 指向该仓库)。如果你觉得不爽,完全可以自定义(git remote set-url)为“阿猫”、“阿狗”等名称。

        • 查看本地仓库关联的远程仓库信息,可以在 .git/config 文件或通过 git remote -v 命令查看。

        • 使用别名的最大好处是,无需记住远程仓库的 URL,也是唯一的好处吧。不用也是完全 OK 的,完全可以直接使用远程仓库 URL,但我想不会有这种朋友吧。

        若无特殊需求,不要为了个性去更改 originupstream 等被广泛使用的别名,其中所表示的约定俗成的、众所周知的含义。

        二、远程分支(Remote Branch)

        常说的「远程分支」是远程仓库对应分支在本地的一个副本。比如常见的 origin/masterorigin/mainorigin/develop 等都是远程分支,可以在 .git/refs/remotes/ 目录下看到。

        # 查看所有本地分支
        $ git branch
        
        # 查看所有远程分支(-r 是 --remotes 的简写)
        $ git branch -r
        
        # 查看所有本地分支和远程分支(-a 是 --all 的简写)
        $ git branch -a
        

        可以通过 git branch -r 命令查看所有的远程分支:

        $ git branch -r
          origin/HEAD -> origin/main
          origin/dev
          origin/main
        

        如果对 origin/HEAD 不理解的话,先不管下文会介绍。

        上一节,我们介绍了远程名称只是一个代号、别名,是可以修改的。那么我们将 Remote Name 由 origin 修改为 foo,那么远程分支,会不会由 origin/main 变为 foo/main 呢?

        修改前:

        $ git remote -v
        origin  git@github.com:toFrankie/repo-demo.git (fetch)
        origin  git@github.com:toFrankie/repo-demo.git (push)
        
        $ git branch -r
          origin/HEAD -> origin/main
          origin/dev
          origin/main
        

        修改后:

        $ git remote rename origin foo
        
        $ git remote -v
        foo     git@github.com:toFrankie/repo-demo.git (fetch)
        foo     git@github.com:toFrankie/repo-demo.git (push)
        
        $ git branch -r
          foo/HEAD -> foo/main
          foo/dev
          foo/main
        

        果然,将远程名称修改之后,远程分支名称也会跟着改变的。我们通过 tree 命令看下目录结构,如下:

        $ tree .git/refs
        .git/refs
        ├── heads
        │   ├── dev
        │   └── main
        ├── remotes
        │   └── foo
        │       ├── HEAD
        │       ├── dev
        │       └── main
        └── tags
        

        那么接下来,若无特殊说明,都将以 origin 作为远程名称进行说明或举例。

        通常,拉取最新代码的过程是这样的:

        1. 通过 git fetch 拉取代码的过程:先读取 .git/config 文件里面的配置 [remote <remote-name>],将里面的所有(因为 fetch 并没有指定其中一个或多个远程仓库)远程名称对应仓库的分支下载到本地,并放在 .git/refs/remotes/<remote-name>/ 目录下。

          比如 git fetch origin main 会创建或更新 .git/refs/remotes/origin/main 的文件,此时通过 git branch -r 就能看到一个 origin/main 的分支。但注意,我们使用的时候还是用 origin/main 而不是 remotes/origin/main 哦。

        2. 有时候,我们可能会通过 git diff 命令来对比本地分支与远程分支的一些信息,才决定要不要合并。比如,git diff main origin/main

        3. 通过 git mergegit rebase 来进行分支合并。比如 git merge origin/main,表示将远程分支 origin/main 合并至本地分支 main 中。

        也可以直接使用 git pull 命令,其实包括了 git fetchgit merge 两个过程。请注意 git fetch 并不会修改「本地分支」的代码。

        细心的同学可能会发现,refs/remotes/origin/ 目录下,相应的分支文件记录的只是一个 Commit-ID(SHA-1),比较特殊的是 HEAD 文件(即 origin/HEAD 分支)记录的是 ref: refs/remotes/origin/main 的东西,它始终指向默认远程分支。

        三、HEAD、Detached HEAD、origin/HEAD、FETCH_HEAD、ORIG_HEAD 区别

        这个对于刚接触的同学,可能看起来有点懵。

        其实 Git 中的「分支」是由一个或多个 Commit-ID 组成的集合。通过 git branch 命令创建的分支,只是对某个 Commit-ID 的「引用」。因此,使用 git branch -d 删除某个本地分支,也只是删除了这个「引用」而已,并不会删除任何的 Commit-ID。但是,如果一个 Commit-ID 没有被任何一个分支引用的话,在一定时间之后,将会被 Git 回收机制删除。

        本节内容将会讲述以下相关内容:

        • HEAD 跟「本地分支」相关。
        • Detached HEAD 是一种特殊状态的 HEAD
        • origin/DEAD 跟「远程分支」相关。
        • FETCH_HEADgit fetch 操作相关
        • ORIG_HEADgit mergegit reset 等「危险操作」相关

        3.1 HEAD

        我们在哪能看到 HEAD 呢?

        接着,我们从 main 分支切换至 dev 分支。

        对比发现,HEAD 发生改变了。

        前面提到,分支只是对 Commit-ID 的引用。每当在某个分支上提交代码,Git 都会产生一个全新的、唯一的 Commit-ID,此时我们的分支名称也随之移向最新的一个 Commit-ID。

        关于 HEAD 存放于本地仓库的 .git/HEAD 文件里面,利用 cat 命令可以看到它的内容。

        $ cat .git/HEAD
        ref: refs/heads/dev
        
        $ cat .git/refs/heads/dev
        866bc9f1d8f4797c0e46e959cb0c9abdd47d8176
        

        所以说到底,此时 HEAD 只是对 Commit-ID 为 866bc9f1d8f4797c0e46e959cb0c9abdd47d8176 的引用。如果切回 main 分支,那么 HEAD 相应的内容就会跟着改变。

        HEAD 则是比较特殊的一个引用(有些文章称为「指针」,也问题不大)。除了 git commit 之外,git checkoutgit reset 等命令都会影响 HEAD 的指向。

        一句话总结:HEAD 是对当前 Commit-ID 的「引用」。

        • 当使用 git commit 时,HEAD 会跟着移动,并指向最新的 Commit-ID。
        • 当使用 git checkout 时,HEAD 会移动并指向对应分支的最新一个 Commit-ID。
        • 当使用 git reset 时,HEAD 会移动至对应分支的某个 Commit-ID。请注意 git reset --hard 可以将 HEADBranch 移动至任何地方。

        顺道提一下,git reset 的本质就是移动 HEAD 来达到撤销的目的。

        观察以下示例,我使用 git reset --softHEAD 从 Commit-ID 为 42d46a2 移至 222a33c,变化如下:

        $ git log
        commit 42d46a2356cfdde0ad80bfc042b6cb15eae04759 (HEAD -> main, origin/main, origin/HEAD)
        Author: Frankie <1426203851@qq.com>
        Date:   Sat Feb 26 16:44:30 2022 +0800
        
            docs: update
        
        commit e0c619ca3978a38f6eabe79c3dfc67d4296ccc36
        Author: Frankie <1426203851@qq.com>
        Date:   Sun Feb 20 18:55:48 2022 +0800
        
            docs: update readme.md
        
        commit 222a33cb3185457a5d726325aa7233e43f0b92d3
        Author: Frankie <1426203851@qq.com>
        Date:   Sun Feb 20 18:36:51 2022 +0800
        
            docs: update README.md
        
        commit 62733124c6485bc5d81123dd3eae95d4da22753f
        Author: Frankie <1426203851@qq.com>
        Date:   Sun Feb 20 15:45:15 2022 +0800
        
            docs: add README.md
        
        $ git reset --soft 222a33cb3185457a5d726325aa7233e43f0b92d3
        
        $ git log
        commit 222a33cb3185457a5d726325aa7233e43f0b92d3 (HEAD -> main)
        Author: Frankie <1426203851@qq.com>
        Date:   Sun Feb 20 18:36:51 2022 +0800
        
            docs: update README.md
        
        commit 62733124c6485bc5d81123dd3eae95d4da22753f
        Author: Frankie <1426203851@qq.com>
        Date:   Sun Feb 20 15:45:15 2022 +0800
        
            docs: add README.md
        

        此时,我们再看下 .git/HEAD 的内容:

        $ cat .git/HEAD
        ref: refs/heads/main
        
        $ cat .git/refs/heads/main
        222a33cb3185457a5d726325aa7233e43f0b92d3
        

        关于 git reset 的三个参数 --mixed(默认)、--hard--soft 的区别,推荐看这篇文章,讲得很详细易懂。

        3.2 Detached HEAD

        detached HEAD 可以称为「游离 HEAD」,也有称为「分离 HEAD」的。

        一般情况下,我们的 HEAD 会指向某个分支的某个 Commit-ID。但是 HEAD 偶尔会发生「没有指向某个本地分支」的情况,这种状态的 HEAD 称为 detached HEAD

        以下情况,就可能会出现 detached HEAD

        1. 使用 git checkout 跳转至某个 Commit-ID,而这个 Commit-ID 刚好目前没有分支指向它。
        2. Rebase 的过程其实也是处于不断的 detached HEAD 状态。
        3. 切换至某个远程分支的时候。

        我们先将 Git 的提示语言切换为英文,看得更加清晰。

        $ git checkout e0c619ca3978a38f6eabe79c3dfc67d4296ccc36
        Note: switching to 'e0c619ca3978a38f6eabe79c3dfc67d4296ccc36'.
        
        You are in 'detached HEAD' state. You can look around, make experimental
        changes and commit them, and you can discard any commits you make in this
        state without impacting any branches by switching back to a branch.
        
        If you want to create a new branch to retain commits you create, you may
        do so (now or later) by using -c with the switch command. Example:
        
          git switch -c <new-branch-name>
        
        Or undo this operation with:
        
          git switch -
        
        Turn off this advice by setting config variable advice.detachedHead to false
        
        HEAD is now at e0c619c docs: update readme.md
        

        此时 HEAD 指向 e0c619c,这个就是 detached HEAD

        还有,前面提到「没有指向某个本地分支」,但其实我们使用 git branch 会发现有以下这样一个分支:

        $ git branch
        * (HEAD detached at e0c619c)
          dev
          main
        

        但注意,当我们切换至其他分支时,这个 (HEAD detached at e0c619c) 分支是会被干掉的,因为它只是临时的。因此人家也提醒你可以使用 git switch -c <new-branch-name> 命令,以创建一个新分支来指向该 Commit-ID。

        假设我们有这样一个场景:想要查看某个历史版本的源码,就可以利用此功能来解决。

        # -t 即 --track
        $ git branch <new-branch-name> <commit-id>
        # 或者
        $ git checkout -b <new-branch-name> <commit-id>
        

        如果我们使用 git checkout origin/main 切换至 origin/main 远程分支时,也会产生一个 detached HEAD 的。如果我们想基于某个远程分支,新建一个同名本地分支,可以这样:

        $ git checkout -t origin/dev
        branch 'dev' set up to track 'origin/dev'.
        Switched to a new branch 'dev'
        
        # 相当于
        $ git checkout -b dev origin/dev
        

        如果要离开 detached HEAD 状态很简单,只要切换至其他分支即可。

        3.3 origin/HEAD

        从名字可以看出,origin/HEAD 也是一个「远程分支」,其中 origin 则对应远程名称。

        一般情况下,origin/HEAD 总是指向远程仓库的「默认分支」。假设我们的远程默认分支为 main。那么在远程仓库在本地的副本,origin/HEAD 就是相当于 origin/main

        Git 提供了以下命令让我们去修改 origin/HEAD 的指向(可通过 git remote set-head -h 查看):

        # 将 origin/HEAD 设为远程仓库的默认分支(-a 即 --auto)
        $ git remote set-head <remote-name> -a
        
        # 将 origin/HEAD 设为某个远程分支,
        # 比如 git remote set-head origin dev,将 origin/HEAD 指向远程的 dev 分支,相当于 origin/dev
        $ git remote set-head <remote-name> <branch-name>
        
        # 删除 origin/HEAD(-d 即 --delete)
        $ git remote set-head <remote-name> -d
        

        以上注释部分,假定了远程名称为 origin

        我们修改下 origin/HEAD 为远程仓库的 dev 分支,origin/HEAD 文件里面存储的内容,同样表示的也是对某个远程分支的引用,仅此而已。

        $ git remote set-head origin dev
        $ cat .git/refs/remotes/origin/HEAD
        ref: refs/remotes/origin/dev
        

        3.4 ORIG_HEAD(拓展内容)

        .git 目录下,有一个 ORIG_HEAD 的文件,你有没有好奇怪,它是什么呢?

        $ cat .git/ORIG_HEAD
        222a33cb3185457a5d726325aa7233e43f0b92d3
        

        还记得,前面使用过 git reset --soft 222a33cb3185457a5d726325aa7233e43f0b92d3 指令来移动 HEAD 的指向吗?

        当我们进行了一些「危险操作」时,比如 git resetgit mergegit rebase 等操作时,Git 会将当前 HEAD 指向的的 Commit-ID 原值保存至 ORIG_HEAD 文件内。需要注意的是,类似 git commit 等操作并不会更新 ORIG_HEAD 的内容。

        这样的话,加入我们执行了一些「误操作」时,可以利用 git reset --hard ORIG_HEAD 回退至上一步。

        举个例子,我们进行一次 git reset 操作:

        $ git log
        commit 42d46a2356cfdde0ad80bfc042b6cb15eae04759 (HEAD -> main, origin/main)
        Author: Frankie <1426203851@qq.com>
        Date:   Sat Feb 26 16:44:30 2022 +0800
        
            docs: update
        
        commit e0c619ca3978a38f6eabe79c3dfc67d4296ccc36
        Author: Frankie <1426203851@qq.com>
        Date:   Sun Feb 20 18:55:48 2022 +0800
        
            docs: update readme.md
        
        commit 222a33cb3185457a5d726325aa7233e43f0b92d3
        Author: Frankie <1426203851@qq.com>
        Date:   Sun Feb 20 18:36:51 2022 +0800
        
            docs: update README.md
        
        commit 62733124c6485bc5d81123dd3eae95d4da22753f
        Author: Frankie <1426203851@qq.com>
        Date:   Sun Feb 20 15:45:15 2022 +0800
        
            docs: add README.md
        

        reset 之前,我们可以看到 HEAD 指向的 Commit-ID 为 42d46a2356cfdde0ad80bfc042b6cb15eae04759,接着我们执行 git reset 指令:

        $ git reset --soft e0c619ca3978a38f6eabe79c3dfc67d4296ccc36
        $ git log
        commit e0c619ca3978a38f6eabe79c3dfc67d4296ccc36 (HEAD -> main)
        Author: Frankie <1426203851@qq.com>
        Date:   Sun Feb 20 18:55:48 2022 +0800
        
            docs: update readme.md
        
        commit 222a33cb3185457a5d726325aa7233e43f0b92d3
        Author: Frankie <1426203851@qq.com>
        Date:   Sun Feb 20 18:36:51 2022 +0800
        
            docs: update README.md
        
        commit 62733124c6485bc5d81123dd3eae95d4da22753f
        Author: Frankie <1426203851@qq.com>
        Date:   Sun Feb 20 15:45:15 2022 +0800
        
            docs: add README.md
        

        git reset 完成之后,HEAD 指向了 e0c619ca3978a38f6eabe79c3dfc67d4296ccc36

        既然我们前面提到过,ORIG_HEAD 会记录高危操作前的 Commit-ID。如果没错的话,此时 ORIG_HEAD 记录的应该是 42d46a2356cfdde0ad80bfc042b6cb15eae04759

        $ cat .git/ORIG_HEAD
        42d46a2356cfdde0ad80bfc042b6cb15eae04759
        

        如果我们想吃后悔药了,就可以通过 git reset --hard ORIG_HEAD 进行回退:

        $ git reset --hard ORIG_HEAD
        HEAD is now at 42d46a2 docs: update
        
        $ git log
        commit 42d46a2356cfdde0ad80bfc042b6cb15eae04759 (HEAD -> main, origin/main)
        Author: Frankie <1426203851@qq.com>
        Date:   Sat Feb 26 16:44:30 2022 +0800
        
            docs: update
        
        commit e0c619ca3978a38f6eabe79c3dfc67d4296ccc36
        Author: Frankie <1426203851@qq.com>
        Date:   Sun Feb 20 18:55:48 2022 +0800
        
            docs: update readme.md
        
        commit 222a33cb3185457a5d726325aa7233e43f0b92d3
        Author: Frankie <1426203851@qq.com>
        Date:   Sun Feb 20 18:36:51 2022 +0800
        
            docs: update README.md
        
        commit 62733124c6485bc5d81123dd3eae95d4da22753f
        Author: Frankie <1426203851@qq.com>
        Date:   Sun Feb 20 15:45:15 2022 +0800
        
            docs: add README.md
        

        当然,不同的场景下版本回退还有其他方式的,视乎实际场景,这里就不展开了。

        3.5 FETCH_HEAD

        其中 FETCH_HEADgit fetch 有关,也是关键部分。

        FETCH_HEAD 指的是某个分支在远程仓库上最新的状态。每一个执行过 git fetch 操作的本地仓库都会存在一个 FETCH_HEAD 列表,这个列表保存在 .git/FETCH_HEAD 文件中。FETCH_HEAD 文件中的每一行对应着远程仓库的一个分支。当前本地分支指向的 FETCH_HEAD 就是该文件中的「第一行」对应的分支(这段表述源于此处)。

        我们知道 git fetch 用以下几种用法:

        # 1 
        $ git fetch
        
        # 2
        $ git fetch <remote-name>
        
        # 3
        $ git fetch <remote-name> <remote-branch-name>
        
        # 4 
        $ git fetch <remote-name> <remote-branch-name>:<local-branch-name>
        
        • git fetch

          拉取「所有远程仓库」所包含的分支到本地,并在本地创建或更新远程分支。所有分支最新的 Commit-ID 都会记录在 .git/FETCH_HEAD 文件中,若有多个分支,FETCH_HEAD 内会多行数据。

        • git fetch origin

          拉取 origin 对应的远程仓库的所包含的分支到本地,FETCH_HEAD 设定同上。

        • git fetch origin main

          拉取 origin 对应远程仓库的 main 分支到本地,且 FETCH_HEAD 只记录了一条数据,那就是远程仓库 main 分支最新的 Commit-ID。

        • git fetch origin main:temp

          拉取 origin 对应远程仓库的 main 分支到本地,其中 FETCH_HEAD 记录了远程仓库 main 分支最新的 Commit-ID,并且基于远程仓库的 main 分支创建一个名为 temp 的新本地分支(但不会切换至新分支)。

        因此,FETCH_HEAD 记录的是从远程仓库拉取到本地,「对应分支」的最新一个 Commit-ID。当通过 git fetch 拉取代码时:

        • 若有具体指定了某个远程仓库的某个分支,那么 FETCH_HEAD 就对应此分支。
        • 若没有具体指定远程仓库的某个分支,
          1. FETCH_HEAD 总是指向 .git/FETCH_DEAD 首行对应的分支。
          2. 文件 .git/FETCH_DEAD 可能会记录着多个分支,且该文件首行对应的是 git fetch 时所在分支的同名远程分支。

        接着,与 FETCH_HEAD 相关的是 git pull 操作。

        git pull 等价于 git fetch + git merge FETCH_HEAD 两个步骤的结合。

        git pull 不添加其他参数时,等价于 git pull <remote-name> <当前分支名>,如果远程仓库无与之对应的同名分支,执行该命令就会抛出错误。举个例子:

        $ git branch -a
        * temp
          remotes/origin/HEAD -> origin/dev
          remotes/origin/dev
          remotes/origin/main
        
        $ git pull
        There is no tracking information for the current branch.
        Please specify which branch you want to merge with.
        See git-pull(1) for details.
        
            git pull <remote> <branch>
        
        If you wish to set tracking information for this branch you can do so with:
        
            git branch --set-upstream-to=origin/<branch> temp
        
        

        好,我们切换到本地的 main 分支,远程仓库有与之对应的同名分支。

        # 相当于 git pull origin main
        $ git pull
        

        拆分为以下步骤:

        • git fetch origin main 将远程仓库的 main 分支最新 Commit-ID 记录到 .git/FETCH_HEAD 中,此时 FETCH_HEAD 指向该 Commit-ID。

        • git merge FETCH_HEADFETCH_HEAD 对应的 Commit-ID 合并至本地 main 分支中。如果合并过程不存在冲突(即只是 Fast-Forward),那么可以顺利完成 git pull 最后一个步骤,否则的话,需要手动解决冲突。

        四、更多

        其实上面介绍了很多,细心的同学可能会发现,其实无论是「本地分支」,还是「远程分支」,它们记录的只是一个 Commit-ID 或者是对某个分支的引用(形如 ref: refs/heads/main)。

        我们观察本地仓库的 .git 目录可以发现,我们的本地分支、远程分支、标签都是存在于 .git/refs/ 目录下:

        $ tree .git/refs
        .git/refs
        ├── heads
        │   ├── dev
        │   └── main
        ├── remotes
        │   └── origin
        │       ├── HEAD
        │       ├── dev
        │       └── main
        └── tags
        

        前面介绍过,「分支」是由一个或多个 Commit-ID 组成的集合。但我们合并的确是实实在在的代码啊,那么这些代码被存放到哪呢?

        具体数据都被放在 .git/objects/ 目录下。

        然后,现在回头再看 .git/config 的配置,看起来是不是很容易理解了。

        [core]
        	repositoryformatversion = 0
        	filemode = true
        	bare = false
        	logallrefupdates = true
        	ignorecase = true
        	precomposeunicode = true
        [remote "origin"]
        	url = git@github.com:toFrankie/repo-demo.git
        	fetch = +refs/heads/*:refs/remotes/origin/*
        [branch "main"]
        	remote = origin
        	merge = refs/heads/main
        [branch "dev"]
        	remote = origin
        	merge = refs/heads/dev
        

        未完待续...

        ]]>
        <![CDATA[关于 error: refname refs/heads/master not found 的问题]]> https://github.com/tofrankie/blog/issues/106 https://github.com/tofrankie/blog/issues/106 Sat, 25 Feb 2023 12:45:27 GMT 从 2020 年 10 月 1 日起,在 GitHub 新创建的仓库(repository)会以 main 作为仓库默认分支(default branch),但它不会影响已有的仓库。

        当然,GitHub 仍然是支持自定义设置默认仓库名称的,可在 https://github.com/settings/repositories 页面进行设置。

        关于更多 GitHub 重命名相关,请看 👉 github/renaming

        抱歉,废话多了,回到正题。

        今天创建了一个新项目 vite-demo,然后想着就用 main 作为默认分支吧。

        $ git init
        $ git branch -M main
        

        执行以上重命名分支的命令后,发现报错了,如下:

        error: refname refs/heads/master not found
        fatal: Branch rename failed
        

        原因是本地代码还没提交(commit),因此没办法进行更名操作。正确的步骤应该是先 init → add → commit,接着才能进行更名操作。

        在本地修改 Git 的默认分支名称,可通过以下命令调整:

        $ git config --global init.defaultBranch <default-branch-name>
        

        Git 3.0 默认分支将会从 master 改为 main详见

        ]]>
        <![CDATA[Git 配置多个 SSH key]]> https://github.com/tofrankie/blog/issues/105 https://github.com/tofrankie/blog/issues/105 Sat, 25 Feb 2023 12:43:55 GMT 背景

        同一台机器上,拥有多个 Git 代码托管平台账号是很常见的。比如 GitLab 用于工作,GitHub 用于个人。

        这时需要配置多个 SSH key。

        配置

        生成 SSH key:

        # 用于 GitHub
        $ ssh-keygen -t rsa -C 'example@personal.com' -f ~/.ssh/github_id_rsa
        
        # 用于 Gitee
        $ ssh-keygen -t rsa -C 'example@company.com' -f ~/.ssh/gitee_id_rsa
        

        ~/.ssh 目录下的 config 文件(没有则新建),添加如下内容:

        # github
        Host github.com
        HostName github.com
        PreferredAuthentications publickey
        IdentityFile ~/.ssh/github_id_rsa
        
        # gitee
        Host gitee.com
        HostName gitee.com
        PreferredAuthentications publickey
        IdentityFile ~/.ssh/gitee_id_rsa
        

        其中 HostHostName 填写 Git 服务器的域名,IdentityFile 指定私钥的路径。

        用 SSH 命令测试。

        $ ssh -T git@github.com
        $ ssh -T git@gitee.com
        

        如果成功的话,会返回以下内容。

        $ ssh -T git@github.com
        Hi tofrankie! You've successfully authenticated, but GitHub does not provide shell access.
        
        $ ssh -T git@gitee.com
        Hi 越前君! You've successfully authenticated, but GITEE.COM does not provide shell access.
        

        参考链接

        ]]>
        <![CDATA[git push 报错 pre-receive hook declined]]> https://github.com/tofrankie/blog/issues/104 https://github.com/tofrankie/blog/issues/104 Sat, 25 Feb 2023 12:43:19 GMT 配图源自 Freepik

        背景

        今天推送代码到 GitLab 远程 mas]]> 配图源自 Freepik

        背景

        今天推送代码到 GitLab 远程 master 分支上,然后提交失败了,提示如下:

         ! [remote rejected] master -> master (pre-receive hook declined)
        

        究其原因,就是用户权限不足,无法 push 代码到 master 分支上。只要将用户角色设置成 Master、Owner 等含有 master 分支操作的权限即可。

        但应根据自身实际情况而定,是赋予可修改 master 分支权限,还是交由 Leader 等含有 master 分支处理权限的其他人处理?

        关于 GitLab 访问权限

        GitLab 访问权限 - Visibility Level

        这个是在建立项目时就需要选定的,主要用于决定哪些人可以访问此项目,包含 3 种:

        • Private - 私有,只有属于该项目成员才有看到
        • Internal - 内部,用 GitLab 账号的人都看到
        • Public - 公开,任何人可以看到

        开源项目和组设置的是 Internal。

        行为权限:

        在满足行为权限之前,必须具备访问权限(如果没有访问权限,那就无所谓行为权限了),行为权限是指对该项目进行某些操作,比如提交、创建问题、创建新分支、删除分支、创建标签、删除标签等角色

        GitLab 定义了以下几个角色:

        GitLab 官方文档关于 Permissions 有一个很详细的说明。

        • Guest - 访客

          可以创建 issue、发表评论,不能读写版本库。

        • Reporter  - 报告者

          可以理解为测试员、产品经理等,一般负责提交 issue 等 可以克隆代码,不能提交,QA、PM 可以赋予这个权限。

        • Developer - 开发者

          可以克隆代码、开发、提交、push,RD 可以赋予这个权限。

        • Master - 主人

          可以创建项目、添加 tag、保护分支、添加项目成员、编辑项目,核心 RD 负责人可以赋予这个权限。

        • Owner - 拥有者

          可以设置项目访问权限 - Visibility Level、删除项目、迁移项目、管理组成员,开发组 Leader 可以赋予这个权限。

        • Maintainer - 维护者

          权限与 Owner 差不多,但无删除项目等权限。

        参考链接

        ]]>
        <![CDATA[Git 常用命令介绍]]> https://github.com/tofrankie/blog/issues/103 https://github.com/tofrankie/blog/issues/103 Sat, 25 Feb 2023 12:40:21 GMT 配图源自 Freepik

        作个记录。

        基本配置

        指定提交者信息。 配图源自 Freepik

        作个记录。

        基本配置

        指定提交者信息。

        # 全局配置
        $ git config --global user.name <your-name>
        $ git config --global user.email <your-email>
        
        # 本地配置
        git config user.name <your-name>
        git config user.email <your-email>
        

        指定不忽略大小写。但不建议修过此配置。

        $ git config --global core.ignorecase false
        

        Related Link: git config core.ignoreCase

        自 2020 年 10 月起,在 GitHub 平台新创建的仓库,其默认分支名称正式调整为 main详见)。可通过命令调整默认分支:

        $ git config --global init.defaultBranch main
        

        Git 3.0 默认分支将会从 master 改为 main,详见

        初始化

        # 初始化
        $ git init
        
        # 与远程仓库建立连接
        $ git remote add origin <repo-address>
        
        # 修改远程仓库地址(一)
        $ git remote set-url origin <repo-address>
        
        # 修改远程仓库地址(二)
        $ git remote rm origin
        $ git remote add origin <repo-address>
        
        # 修改远程仓库地址(三)直接修改配置文件
        $ vim .git/config
        

        克隆

        # dir-name 可选,默认为仓库名称
        $ git clone <repo-address> [dir-name]
        

        以上方式仅会克隆仓库的「默认分支」,通过 git branch --list 能看到本地只有一个分支。

        如果再通过新建分支再拉取指定分支,甚至可能还需要解决冲突,太繁琐了。

        那么,如何快速有效的直接克隆远程指定分支?

        $ git clone -b <branch-name> <repo-address>
        
        # 指定目录名称
        $ git clone -b <branch-name> <repo-address> <local-dirname>
        

        比如,远程有 masterdevelop 两个分支,通过 git clone -b develop git@github.com:toFrankie/git_dev_demo.git 会克隆 develop 分支到本地,而且本地只有 develop 一个分支。

        部分内容源自 Git 克隆远程仓库的指定分支

        分支操作

        查看分支

        # 列出所有本地分支
        $ git branch
        
        # 列出所有远程分支
        $ git branch -r
        
        # 列出所有本地分支和远程分支
        $ git branch -a
        
        # 如果分支太多,可以使用模糊查找
        $ git branch | grep <branch-name>
        

        新建分支

        若分支已存在,以下命令都会失败。

        # 新建分支,但不会切换至新分支
        $ git branch <branch-name>
        
        # 新建分支,并切换至该分支。
        $ git checkout -b <branch-name>
        
        # 根据已有分支创建新的分支
        $ git checkout -b <branch-name> origin/<远程分支名称>
        # 例如,创建一个远程 develop 分支的本地分支
        # git checkout -b develop origin/develop
        
        # 重命名本地分支名称
        $ git branch -m <old-branch-name> <new-branch-name>
        

        有时候,你可能需要取出某个历史版本,可以理解为基于某个 Commit 来创建一个新的分支,就能看到历史版本了。可以使用以下命令:

        # 基于 <SHA1> 新建一个新分支
        $ git checkout -b <branch-name> <SHA1>
        
        # 若不打算修改,只是想 checkout 的话
        $ git checkout <SHA1>
        

        比如有以下 Commit Log,现基于 1b90fceff0f3e4f16b1b850019573d3103b69a96 的历史版本,新创建一个分支:

        合并分支

        # 合并指定分支到当前分支
        $ git merge <branch-name>
        
        # 工作中,特性分支合并至主干分支,通常会使用以下这个
        $ git merge --no-ff <branch-name>
        

        --no-ff(not fast forward)的作用是:要求 git merge 即使在 fast forward 条件下也要产生一个新的 Merge Commit。采用 --no-ff 的方式进行分支合并目的在于:希望保持原有特性分支整个提交链的完整性。

        关于分支合并,有使用 git merge 的,也有使用 git rebase 的,而且不同派别还很容易争吵起来。就像缩进是采用 Tab 好还是 Space 好的争论一样,那么实际中如何权衡,根据团队而定吧。

        删除分支

        # 删除本地指定分支
        $ git branch -d <branch-name>
        
        # 删除远程分支
        git push origin --detele <branch-name>
        
        # 删除远程分支(方法二:向远程分支推送一个空分支)
        $ git push origin :<远程分支名>
        

        重命名远程分支

        要重命名远程分支名称,其实就是先删除远程分支,然后重命名本地分支,再重新推送一个远程分支的过程。

        例如,将远程分支 dev 重命名为 develop,可以这样操作:

        # 1. 删除远程分支 dev
        $ git push origin --delete dev
        
        # 2. 重命名本地分支 dev 为 develop
        $ git branch -m dev develop
        
        # 3. 推送本地分支 develop
        $ git push origin develop
        

        暂存、提交、推送操作

        暂存文件

        # 提交新文件(new file)、被修改(modified)文件以及被删除(deleted)文件。
        $ git add .
        
        # 提交新文件(new file)、被修改(modified)文件以及被删除(deleted)文件。
        $ git add -A
        
        # 提交被修改(modified)和被删除(deleted)文件,不包括新文件(new file)
        $ git add -u
        
        # 提交新文件(new file)和被修改(modified),不包括被删除(deleted)文件
        $ git add --ignore-removal .
        

        提交暂存

        # 提交已暂存文件
        $ git commit -m 'commit message'
        
        # 提交已暂存文件,以及跟踪过但未被添加到暂存的文件
        # 注意:git commit -am 可以写成 git commit -a -m,但不能写成 git commit -m -a
        $ git commit -am 'commit message'
        
        # 修改最后一次的提交说明(但 commitId 会改变哦,因为它是一次全新的提交)
        $ git commit --amend
        

        注意一下,git commit --amend 命令,只能修改最新一次的提交说明 ,执行命令进入 vim 模式(具体如何插入编辑,保存退出不展开赘述了,你们都会的)。亦可通过 git commit --amend -m 'Commit Message' 直接覆盖不用进入 vim 编辑器。

        需要注意的是,该命令会导致 commitId(快照唯一标识)发生改变。可通过 git log(查看历史提交记录)前后对比发现。其实呢,修改过的提交实际上是全新的提交,而先前的提交将不再位于您当前的分支上。

        该命令其实还有很多选项,还能更改提交文件等,想了解请看这里

        推送至远程分支

        如果你不熟悉 origin 所表示的意思,可先看 《弄懂 origin、HEAD、FETCH_HEAD 相关内容》。

        主要用于将本地更新推送到远程仓库,但不同简化形式、命令参数产生延申效果。

        # 不省略本地分支名和远程分支名情况下,冒号(:)前后是没有空格的
        $ git push <远程主机名> <本地分支名>:<远程分支名>
        # git push origin master:master
        

        还有几种简化形式的写法:

        • 省略远程分支(多分支情况下,本人常用这种形式)
        # 将本地分支推送到远程主机上的同名分支。如果远程分支不存在,则会自动创建一个远程分支。
        $ git push <远程主机名> <本地分支名>
        # git push origin master
        
        展开
        • (慎用)省略本地分支(相当于删除远程分支)
        # 将一个“空的本地分支”推送至远程分支,即表示删除指定的远程分支。
        $ git push <远程主机名> :<远程分支名>
        # git push origin :master
        
        # 等同于
        $ git push origin --delete <远程分支名>
        
        • (不推荐)省略本地分支、远程分支
        # 将当前分支推送至远程主机上的对应分支
        $ git push <远程主机名>
        # git push origin
        

        这种形式要求当前本地分支和远程分支之间存在追踪关系。

        怎么理解?

        个人不推荐,而且平常也不用这种形式的进行推送的。假如有存在 masterdevelop 两个分支,当与远程分支建立追踪关系的是 master 分支,那么处于 develop 分支时,使用 git push origin 形式推送至远程主机时就会提示:The current branch test has no upstream branch.,然后再执行 git push --set-upstream origin develop 即可使用这种形式的推送。

        • 省略远程主机、本地分支、远程分支
        # 将当前分支推送至远程主机对应分支
        $ git push
        

        这种形式,除了要求当前本地分支和远程分支之间存在追踪关系之外,还要求当前当前分支只有一个追踪分支。

        • 省略远程分支,添加参数 -u
        # 将本地分支推送到远程主机上的同名分支。如果远程分支不存在,则会自动创建一个远程分支。
        $ git push -u <远程主机名> <本地分支名>
        # git push -u origin master
        

        这种形式适用于当前分支与多个主机存在追踪关系,可以利用 -u 指定一个默认的主机,这样后面就可以不加任何参数适用 git push 推送对应的分支了。

        以上指令执行之后,在 .git/config 配置文件下会有分支的对应:

        [remote "origin"]
        	url = git@github.com:toFrankie/git_dev_demo.git
        	fetch = +refs/heads/*:refs/remotes/origin/*
        [branch "master"]
        	remote = origin
        	merge = refs/heads/master
        [branch "develop"]
        	remote = origin
        	merge = refs/heads/develop
        

        标签操作

        # 列出所有 tag
        $ git tag
        
        # 查看 tag 信息
        $ git show <tag-name>
        
        # 轻量标签
        $ git tag <tag-name>
        
        # 轻量标签,给历史 Commit 打标签
        $ git tag <tag-name> <commit-id>
        
        # 附注标签
        $ git tag -a <tag-name> -m <message>
        # 例如,打一个 v1.0.0 的标签
        # git tag -a v1.0.0 -m 'v1.0.0 release'
        
        # 后期打标签
        $ git tag -a <tag-name> <version>
        
        # 提交指定 tag
        $ git push origin <tag-name>
        
        # 提交所有 tag
        $ git push origin --tags
        
        # 删除本地 tag
        $ git tag -d <tag-name>
        
        # 删除远程 tag
        $ git push origin --detele tag <tag-name>
        
        # 还可以向远程推送一个“空标签”,等同于删除远程标签
        $ git push origin :refs/tags/<tag-name>
        
        # 将本地所有不在远程仓库服务器上的标签推送至服务器
        git push origin –tags
        

        拉取操作

        主要有 git fetchgit pull,区别如下:

        • git fetch:将远程主机的最新内容拉到本地,用户在检查了以后决定是否合并到「本地分支」中。
        • git pull:将远程主机的最新内容拉下来后直接合并,相当于:git fetch + git merge FETCH_HEAD

        比如 git fetch origin dev 虽说会将远程的更新拉取到本地,它会更新至本地的 origin/dev 的分支下,换句话说:此时 origin/dev 与远程的 dev 分支是一致的,都是最新的(请注意,本地的 origin/devdev 是两个独立的分支)。

        如果你 Fetch 完成之后,不进行 Merge 或 Rebase 等合并操作,它是不会更新 dev 分支内容的。在合并之前,可以利用 git diff dev origin/dev 去对比两个分支的内容,最终决定是否要将远程的更新合并至本地的 branch-name 分支中。

        git fetch

        # 将 git remote 中所有关联的远程仓库包含的所有分支的更新拉取到本地
        $ git fetch
        
        # 将指定远程所有分支的更新拉取至本地
        $ git fetch <远程主机名>
        
        # 将远程特定分支的更新拉取至本地
        $ git fetch <远程主机名> <分支名>
        

        当拉取成功后,会返回一个 FETCH_HEAD,指的是某个分支在服务器上的最新状态。通过以下方式可以查看刚拉取回来的更新信息:

        $ git log -p FETCH_HEAD
        

        我们来试一下,如下图:

        图中可以看到一些文件更新信息,包括文件名、更新作者、更新时间、更新代码等,并通过这些信息来判断是否产生冲突。

        接着,可以通过 git merge 来将这些拉取下来的最新内容合并到当前分支中:

        $ git merge FETCH_HEAD
        
        # 或者使用以下这个,其中 <branch-name> 表示要合并的分支名称,例如 origin/main
        $ git merge origin/<branch-name>
        

        git pull

        # 拉取远程某分支的更新,并与本地指定分支合并
        $ git pull <远程主机名> <远程分支名>:<本地分支名>
        # git pull origin
        
        # 如果远程分支与当前本地分支进行合并,则冒号及后面部分可以省略
        $ git pull <远程主机名> <远程分支名>
        # git pull origin <branch-name>
        

        因此

        $ git pull origin main
        
        # 相当于
        $ git fetch origin main
        $ git merge FETCH_HEAD
        

        少用 Pull,多用 Fetch 和 Merge(建议)

        将拉取和合并独立开来是一个较好的方法,即少用 Pull,多用 Fetch 和 Merge。

        git pull 的问题是它把过程的细节都隐藏了起来,以至于你不用去了解 Git 中各种类型分支的区别和使用方法。当然,多数时候这是没问题的,但一旦代码有问题,你很难找到出错的地方。

        将拉取(Fetch)和合并(Merge)放到一个命令里的另外一个弊端是,你的本地工作目录在未经确认的情况下就会被远程分支更新。当然,除非你关闭所有的安全选项,否则 git pull 在你本地工作目录还不至于造成不可挽回的损失,但很多时候我们宁愿做的慢一些,也不愿意返工重来。

        尤其在多人协作的时候,可能是一个复杂的合并操作,可以这样:

        # 1. 拉取
        $ git fetch origin main
        
        # 2. 比较
        $ git diff main origin/main
        
        # 3. 合并
        $ git merge origin/main
        

        有些团队的合并操作要求使用 git rebase,而不是 git merge。当然了也有 git pull --rebase 可使用啦。

        Related Link:

        文件操作

        我们知道 .gitignore 仅对未跟踪过的文件才起作用。对于已存在于版本管理库中的文件/目录,我们需要使用 git rm 命令来取消对文件/目录的跟踪。

        # 不删除本地文件
        $ git rm -r --cached <file-or-dir>
        
        # 删除本地文件
        $ git rm -r -f <file-or-dir>
        

        文件大小写重命名

        通过 git config core.ignorecase false 可以让 git 区分大小写。

        但如果文件已经推送到远端,以上设置可能不会生效。

        可以用 git mv 命令,比如:

        $ git mv App.tsx app.tsx
        

        如果出现类似 fatal: bad source, source=App.tsx, destination=app.tsx 错误,可以加上 -f 参数。

        ]]>
        <![CDATA[Git 分支管理艺术]]> https://github.com/tofrankie/blog/issues/102 https://github.com/tofrankie/blog/issues/102 Sat, 25 Feb 2023 12:39:37 GMT 一个中心版本库至少有两个分支:

        • 主分支(master)
        • 开发分支(develop)

        在团队开发协作中,建议要有辅助分支的概念。

        一个中心版本库至少有两个分支:

        • 主分支(master)
        • 开发分支(develop)

        在团队开发协作中,建议要有辅助分支的概念。

        辅助分支的最大特点就是生命周期十分有限,完成使命后即可被清除

        辅助分支大致包括:

        • 管理功能开发的分支(Feature branches)
        • 帮助构建可发布代码的分支(Release branches)
        • 可以便捷的修复发布版本关键 BUG 的分支(Hotfix branches)

        A successful Git branching model

        ]]>
        <![CDATA[Git Commit 规范]]> https://github.com/tofrankie/blog/issues/101 https://github.com/tofrankie/blog/issues/101 Sat, 25 Feb 2023 12:37:41 GMT 本文是对前面系列文章的补充与完善。

        前面利用 husky、lint-staged 在提交之前做一些 linter、formatter 的操作。今天补一下 Commit Message]]> 本文是对前面系列文章的补充与完善。

        前面利用 husky、lint-staged 在提交之前做一些 linter、formatter 的操作。今天补一下 Commit Message 提交说明规范。

        使用 Git 提交一个变更,需要写 Commit Message(提交说明),否则无法提交。

        一份清晰简明的 Commit Message 很重要,它可以让我们清楚了解本次代码提交的目的以及解决了具体什么问题。也可能让后续 Code Review、信息查找、版本回退都更加高效可靠。

        社区有多种 Commit Message 的写法规范,本文介绍使用较为广泛的 Angular 规范

        Commit Message 规范

        每次提交,Header 是必需的,而 Body 和 Footer 可以省略。

        不管哪一部分,任何一行都不得超过 72 个字符(或 100 个字符)。这是为了避免自动换行影响美观。

        <Header>
        <空行>
        <Body>
        <空行>
        <Footer>
        

        Header

        Header 包括 typescopesubject 三部分,其中 typesubject 是必须的,而 scope 是可选的。

        <type>(<scope>): <subject>
        

        1. type

        type 用于说明 commit 的类型,包含这几种:

        • feat 新功能 A new feature

        • fix 修复 bug A bug fix

        • docs 仅包含文档的修改 Documentation only changes

        • style 格式化变动,不影响代码逻辑。比如删除多余的空白,删除分号等 Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)

        • refactor 重构,既不是新增功能,也不是修改 bug 的代码变动 A code change that neither fixes a bug nor adds a feature

        • perf 性能优化 A code change that improves performance

        • test 增加测试 Adding missing tests or correcting existing tests

        • build 构建工具或外部依赖包的修改,比如更新依赖包的版本等 Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)

        • ci 持续集成的配置文件或脚本的修改 Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)

        • chore 杂项,其他不修改源代码与测试代码的修改 Other changes that don't modify src or test files

        • revert 撤销某次提交 Reverts a previous commit

        如果 typefeatfix,则该 commit 将肯定会出现在 Change Log 中。其他情况(docschorestylerefactortest)由你决定,要不要放入 Change Log 中,建议是不要。

        2. scope

        scope 用于说明 commit 影响的范围,比如数据层、控制层、视图层等。根据项目本身情况处理,如: views, components, utils, test...

        3. subject

        subject 是 commit 目的的简短描述,不超过 50 个字符。

        • 以动词开头,使用第一个人称现在时,比如 change,而不是 changed 或者 changes
        • 第一字母小写
        • 结尾不加句号

        Body

        Body 部分是对本次 commit 的详细描述,可以分成多行。下面是一个范例:

        More detailed explanatory text, if necessary.  Wrap it to 
        about 72 characters or so. 
        
        Further paragraphs come after blank lines.
        
        - Bullet points are okay, too
        - Use a hanging indent
        

        注意两点:

        • 使用第一人称现在时,比如使用 change 而不是 changed 或者 changes。
        • 应该说明代码变动的动机,以及与之前行为的对比。

        Footer

        Footer 部分只用于两种情况。

        1. 不兼容的变动

        如果当前代码与上一个版本不兼容,则 Footer 部分以 BREAKING CHANGE 开头,后面是对变动的描述以及变动理由和迁移方法。

        BREAKING CHANGE: isolate scope bindings definition has changed.
        
            To migrate the code follow the example below:
        
            Before:
        
            scope: {
              myAttr: 'attribute',
            }
        
            After:
        
            scope: {
              myAttr: '@',
            }
        
            The removed `inject` wasn't generaly useful for directives so there should be no code using it.
        

        2. 关闭 Issue

        如果当前 commit 针对某个 issue,那么旧可以在 Footer 部分关闭这个 issue。

        # 关闭单个
        Closes #123
        
        # 关闭多个
        Closes #123, #234, #345
        

        Revert

        有一种特殊情况,如果当前 commit 用于撤销以前的 commit,则必须以 revert: 开头,后面跟着被撤销 Commit 的 Header。

        Body 部分格式是固定的,必须写成 This reverts commit <hash>.,其中的 hash 是被撤销 commit 的 SHA 标识符。

        revert: feat(pencil): add 'graphiteWidth' option
        
        This reverts commit 667ecc1654a317a13331b17617d973392f415f02.
        

        如果当前 commit 与被撤销的 commit 在同一个发布(release)里面,那么它都不会出现在 Change Log 里面。如果两者在不同的发布,那么当前 commit,会出现在 Change Log 的 Reverts 小标题下面。

        Commitizen

        Commitizen 是一个撰写符合上面 Commit Message 标准的一款工具。

        安装依赖

        $ yarn add commitizen cz-conventional-changelog -D
        

        配置 package.json:

        {
          "scripts": {
            "commit": "git-cz",
            "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0 && git add CHANGELOG.md"
          },
          "config": {
            "commitizen": {
              "path": "./node_modules/cz-conventional-changelog"
            }
          }
        }
        

        commitizen 与 cz-conventional-changelog 的关系?

        commitizen 根据不同的 Adapter 配置 Commit Message。比如,要使用 Angular 规范可以安装 cz-conventional-changelog。还有很多其他的 Adapters

        实践

        其中 scope、breaking changes、issue 等非必需项可回车跳过。

        生成 Changelog

        如果你的 Commit Message 都符合 Angular 格式,那么 Changelog 可以用脚本自动生成。

        生成的文档包括以下三个部分:

        • New features
        • Bug fixes
        • Breaking changes.

        conventional-changelog 就是生成 Changelog 的工具。

        此前已安装并配置:

        {
          "scripts": {
            "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0 && git add CHANGELOG.md"
          }
        }
        

        运行以下命令生成 CHANGELOG 文件,其中 featfix 类型的变动会生成在里面。

        $ yarn changelog
        

        每个部分都会罗列相关的 commit ,并且有指向这些 commit 的链接。当然,生成的文档允许手动修改,所以发布前,你还可以添加其他内容。

        示例 Demo:tofrankie/wechat_applet_demo

        参考链接

        ]]>
        <![CDATA[本地项目关联远程 Git 仓库]]> https://github.com/tofrankie/blog/issues/100 https://github.com/tofrankie/blog/issues/100 Sat, 25 Feb 2023 12:36:34 GMT 步骤
        1. 本地项目初始化 git
        $ git init
        
        1. 关联远程仓库
        $ git init
        
        1. 关联远程仓库
        $ git remote add origin <仓库地址>
        
        1. 提交暂存
        $ git add . # 将目录下所有文件全部添加至暂存区
        
        1. 提交到分支
        $ git commit -m '备注'
        
        1. 推送
        $ git push -u origin master
        
        1. 移除 git
        $ rm -rf .git
        

        常见问题

        1. 如何配置邮箱和用户名
        $ git config --global user.name "xxx"       
        $ git config --global user.email "xxx"
        
        # 以上是设置全局用户,如果是单一项目则:
        $ git config user.name "xxx"       
        $ git config user.email "xxx"
        
        1. 在第 5 步之前都很顺利,然后到最后推送的时候,可能会推送失败,并提示:Git: Permission to xxx denied to deploy key。

          原因是你没有权限推送到该仓库。

          在本地生成 Deploy Keys,然后将公钥添加到远程仓库的 Settings/Deploy keys 上。

        # 以下步骤涉及的路径、Deploy Keys 名称以及密码,按照自己喜好而定,且记住密码
        
        # 生成 Deploy Keys
        $ ssh-keygen -f ~/.ssh/deploy_key_nodeUploadDemo
        
        # 添加到认证列表
        $ ssh-add ~/.ssh/deploy_key_nodeUploadDemo
        
        # 查看认证列表,看是否成功添加
        $ ssh-add -l
        
        # 拷贝 deploy public key
        $ cat ~/.ssh/deploy_key_nodeUploadDemo.pub | pbcopy
        
        1. 方法 2 只对当前仓库有效,若想要所有项目有效应该添加到 SSH and GPG keys
        # 生成公钥和私钥 https://blog.csdn.net/weixin_33775582/article/details/93798019
        $ ssh-keygen -t rsa -b 4096 -C "your_email@example.com"
        
        # 命令解读:https://blog.csdn.net/weixin_33775582/article/details/93798019
        
        # 验证与github连接是否成功
        $ ssh -T git@github.com
        
        # 查看具体信息
        $ ssh -T -v git@github.com
        
        1. 查看配置
        # 当前项目
        $ git config --list
        #  全局
        $ git config --global --list
        
        1. 其他问题

        Hi tofrankie! You've successfully authenticated, but GitHub does not provide shell access.

        $ ssh-add ~/.ssh/id_rsa
        

        参考链接

        ]]>
        <![CDATA[Git 操作与解决方法]]> https://github.com/tofrankie/blog/issues/99 https://github.com/tofrankie/blog/issues/99 Sat, 25 Feb 2023 12:34:52 GMT 本文更新于 2020-04-20,本篇为个人笔记,记录用。

        1. 清除 git 仓库信息:rm -rf .git

        2. 解决:The authenticity of host 'github.com (52.74.223.119)' can't be established. 参考

        3. 生成 ssh 的方法和配置

        4. 生成 Deploy Keys,规则与 ssh key 一样

          1. ssh-keygen -f ~/.ssh/deploy_key_repo1(生成保存)
          2. ssh-add ~/.ssh/deploy_key_repo1(添加路径到认证列表)
          3. ssh-add -l(查看认证列表,看是否成功添加)
          4. cat ~/.ssh/deploy_key_repo1.pub | pbcopy(复制 deploy public key)
        5. git@github.com: Permission denied (publickey).

        6. Git: The key you are authenticating with has been marked as read only. 参考

        其他

        1. 配置邮箱和用户名
        $ git config --global user.name "xxx"       
        $ git config --global user.email "xxx"
        
        # 以上是设置全局用户,如果是单一项目则:
        $ git config user.name "xxx"       
        $ git config user.email "xxx"
        
        1. 提交到暂存(全部)
        $ git add -A -- .
        
        1. 查看当前用户信息以及其他的一些信息
        $ git config --list
        
        1. 将暂存提交到 git 库
        $ git commit -m "变更说明"
        
        1. 将本地的库链接到远程
        $ git remote add origin <仓库地址>
        
        1. push
        $ git pull origin master
        
        ]]>
        <![CDATA[或许能帮你解开 node-sass 的所有疑问?]]> https://github.com/tofrankie/blog/issues/98 https://github.com/tofrankie/blog/issues/98 Sat, 25 Feb 2023 12:33:34 GMT 配图源自 Freepik

        那个是不是 node-sass 的安装就可能难倒一批]]> 配图源自 Freepik

        那个是不是 node-sass 的安装就可能难倒一批前端同学。看完这篇文章,或许能解开一些疑惑。

        先说结论

        • 新项目选择 Dart Sass(即 sass),Node Sass 不再提供 CSS 新特性的支持。

        • 尽管 Node Sass 的性能目前最佳,但它被弃用已成事实。而且跟 Dart Sass 的性能差异,一般规模的项目几乎无性能差异感知。

        • 大家都知道改为国内镜像源,可以加快 npm 包的下载。但 node-sass 包较为特殊,在安装时还要从 GitHub 中下载对应平台的 binding.node 文件,因此还要将 Sass Binary Site 指定为国内镜像源使其也在国内镜像源中下载,才能彻底解决网络不稳定导致安装失败的问题。

        • node-sass 基于 LibSass 构建,后者使用 C++ 开发。因此需要用到 node-gyp,在较新版本的 Node.js 中会自带 node-gyp,因此大部分情况下无需额外安装。

        • node-gyp 是 GYP 的 Node 实现,是用来编译 C++ 模块的跨平台工具。而 GYP 是基于 Python 开发,所以需要安装 Python。

        • node-gyp 除了需要安装 Python 之外,在不同平台还要安装其他一些东西,比如 macOS 的 Xcode、Windows 的 VC++ 编译器等。

        • 在 Node 中调用其他语言编写的模块,需要用 node-gyp 生成平台相关的项目文件,然后调用 gcc、vsbuild、xcode 等编译平台来进行编译。

        • node-gyp 构建项目文件的过程中,需要指定 Python 路径,在未配置的情况下,默认从环境变量 PATH 查找名为 python2 的可执行文件,找不到就会报错。通常做法是用 npm config set python /path/to/your/python 去指定,特别是本机有多个 Python 版本。

        • 镜像源问题只能解决下载慢的问题,如果 node-sass 还安装失败,原因无非就几个:

          • 一是,未安装平台相关的编译器。比如 macOS 的 Xcode 等。
          • 二是,当前 node-sass 与 Node 版本不兼容,这个版本对应关系可以在 node-sass 官网中查看;
          • 三是,当前 node-sass 所依赖的 node-gyp 不支持你本机安装的 Python 版本,可根据实际情况降低/升级 Python 解决。

        前言

        自诞生以来,CSS 在语法上都较为简单。随着 Web 的飞速发展,Web 项目越来越复杂,原生 CSS 在应对复杂项目的时候似乎力不从心。后来社区上出现了很多 CSS 预处理器(CSS preprocessor),比如 Sass、Less、Stylus、PostCSS 等。它们提供了原生 CSS 不具备的特性,比如代码混合、嵌套选择器、继承选择器等,使得 CSS 更容易维护。CSS 预处理器可以理解为一门新的语言,都有着特定的语法,然后通过对应的编译器生成浏览器可识别的原生 CSS。

        Sass 与其他预处理器的区别

        此处不讨论语法上的差异。Less 和 Stylus 的编译器都是使用 JavaScript 编写的。而 Sass 则经历了 Ruby Sass、Node Sass、Dart Sass 三代编译器,且都不是基于 JavaScript 编写的。

        Sass 编译器

        • Ruby Sass:基于 Ruby 语言编写,性能最差,于 2019 年停止维护。
        • Node Sass:基于 LibSass 构建并与 Node.js 进行集成,而 LibSass 是用 C++ 编写的。于 2020 年宣布不再提供新特性的支持。
        • Dart Sass:基于 Dart 语言编写,Dart 是 Flutter 的编程语言,它可以编译为 JavaScript。

        Node Sass 性能最佳,Dart Sass 次之,Ruby Sass 最拉。尽管 Node Sass 的性能最佳,但由于 LibSass 跟不上 CSS 及 Sass 快速发展的步伐,所以 Sass 团队决定放弃它,全面拥抱 Dart Sass。

        Node Sass 与 Dart Sass 如何选择?

        新项目优先考虑 Dart Sass,这也是 Sass 团队所推荐的。

        Warning: LibSass and Node Sass are deprecated. While they will continue to receive maintenance releases indefinitely, there are no plans to add additional features or compatibility with any new CSS or Sass features. Projects that still use it should move onto Dart Sass.

        由于 Node Sass 不再支持新特性,未来逐步被淘汰是很自然的事。

        Dart Sass 提供了纯 JavaScript 的 npm 包 sass(以前叫做 dart-sass),它的安装可比 node-sass 省心多了 😪。从 Node Sass 迁移到 Dart Sass 也非常简单,只要把 package.json 中的 node-sass 依赖改为 sass 即可,两者提供的 JavaScript API 是相同的。

        node-sass

        node-gyp

        由于 node-sass 构建在 LibSass 之上,LibSass 则是用 C++ 实现的,因此使用 node-sass 的话,node-gyp 是必需的。node-gypGYP 在 Node 中的实现,用来编译原生 C++ 模块的。其中 node-gyp 在较新版本的 Node.js 是自带的。

        node-gyp 正常运行的前提

        使用 node-gyp之前,要安装对应平台的相关工具才能正常使用。更多安装介绍请看:node-gyp Installation

        Linux/Unix 平台:

        • Python 3.x
        • make
        • A proper C/C++ compiler toolchain, like GCC

        macOS 平台:

        • Python 3.x
        • XCode Command Line Tools

        Windows 平台:

        • Python
        • VC++ 编译器

        以 macOS 为例:

        # 安装 XCode Command Line Tools
        $ xcode-select --install
        
        # 安装 Python 3
        $ brew install python
        

        以 Windows 为例,前往 Microsoft Store 下载安装 Python,以管理员身份打开 cmd 或 PowerShell 执行以下命令以安装 VC++ 编译器。更多 Environment setup and configuration

        $ npm install --global --production windows-build-tools
        

        指定 Python 版本

        如果你安装了多个 Python 版本,可在 npm 或 yarn 的配置文件中指定。以 macOS 为例:

        # 获取 Python 路径
        $ which python3
        /usr/local/bin/python3
        
        # 配置 npm 或 yarn 的 python 路径
        $ npm config set python /usr/local/bin/python3
        $ yarn config set python /usr/local/bin/python3
        

        请注意,低版本 node-gyp 可能仅支持 Python 2.x。

        node-sass 安装慢是怎么回事?

        首先是镜像源的问题,它不单是 node-sass 包才这样,所有包都一样。由于 npm 默认镜像源 https://registry.npmjs.org/ 在境外,访问的时候慢或者不稳定是正常的。这个可以挂梯子或者修改为国内镜像源解决。

        比如,修改为淘宝镜像源:

        $ npm config set registry https://registry.npmmirror.com/
        $ yarn config set reigstry https://registry.npmmirror.com/
        

        如果是管理镜像源,个人推荐使用 nrmyrm

        为什么修改为国内镜像源还慢,甚至失败?

        node-sasspackage.json 中,我们可以看到有两个命令:

        {
          "scripts": {
            "install": "node scripts/install.js",
            "postinstall": "node scripts/build.js"
          }
        }
        

        所以在安装依赖的时候,会先后执行 installpostinstall 对应命令,它们所做的事情大致是:

        1. 下载对应平台的 binding.node 文件;
        2. 下载完成,执行 node-gyp rebuild 命令进行构建。

        script/index.js 会执行一个 checkAndDownloadBinary() 方法,以检查是否有缓存。若无,继续执行一个 download() 方法在指定 URL 中下载 binding.node 文件,而 URL 则通过 getBinaryUrl() 方法获取:

        function getBinaryUrl() {
          var site = getArgument('--sass-binary-site') ||
                     process.env.SASS_BINARY_SITE  ||
                     process.env.npm_config_sass_binary_site ||
                     (pkg.nodeSassConfig && pkg.nodeSassConfig.binarySite) ||
                     'https://github.com/sass/node-sass/releases/download';
        
          return [site, 'v' + pkg.version, getBinaryName()].join('/');
        }
        

        从代码可知,先后顺序是:

        1. 命令行参数 --sass-binary-site
        2. 环境变量 SASS_BINARY_SITE
        3. .npmrc 配置 sass_binary_site
        4. package.json 中的 nodeSass.binarySite 字段
        5. 若以上都没有指定,则从 Github 中下载,比如:https://github.com/sass/node-sass/releases/download/v8.0.0/darwin-x64-83_binding.node

        因此,仅指定国内镜像源还不够,还要指定 Sass Binary Site。

        指定 Sass Binary Site

        首先,从上面的 getBinaryUrl() 方法可知,可以有多种方式去指定,推荐在 .npmrc 指定:

        $ npm config set sass_binary_site https://npmmirror.com/mirrors/node-sass
        

        这样的话,其 binding.node 文件就会从 https://npmmirror.com/mirrors/node-sass/v8.0.0/darwin-x64-83_binding.node 下载。

        也可以这样:

        • 如果是使用命令行,可以在 --sass-binary-site 参数指定,比如:npm install node-sass --sass-binary-site=https://npmmirror.com/mirrors/node-sass
        • 可以设置 SASS_BINARY_SITE 环境变量,有两种方式:
          • 全局环境变量(持久化),比如 echo 'export SASS_BINARY_SITE=https://npmmirror.com/mirrors/node-sass' >> ~/.zshrc
          • 临时环境变量,每次安装的时候指定。比如 SASS_BINARY_SITE=https://npmmirror.com/mirrors/node-sass npm install

        问题排查

        按上述处理后,安装 node-sass 还失败?

        检查 Node 版本

        先检查当前 node-sass 版本所支持的 Node 版本,然后在安装对应的 Node 版本重试。详见 Node version support policy

        像我项目中 node-sass 版本号为 4.13.0,使用 Node 16 就不行,因此我降到了 Node 12。

        $ fnm install 12
        $ fnm use 12
        

        Node 多版本管理的话,个人推荐使用 fnm

        除了 Node 版本过高之外,该版本仅支持 Python 2。而系统内置的 Python 2.x 在 macOS 12.3 之后被移除了,而且目前 Homebrew 不再支持安装 Python 2。然后参考这篇文章,找到了一个安装 Python 2 的方法,如下:

        $ brew install pyenv
        $ pyenv install 2.7.18
        $ echo 'export PATH="$(pyenv root)/shims:${PATH}"' >> ~/.zshrc
        $ source ~/.zshrc
        $ pyenv global 2.7.18
        

        安装完之后,再将它的路径设置到 .npmrc 里面。

        $ python --version
        Python 2.7.18
        
        $ which python
        /Users/frankie/.pyenv/shims/python
        
        $ npm config set python /Users/frankie/.pyenv/shims/python
        $ yarn config set python /Users/frankie/.pyenv/shims/python
        

        还不行?

        那我想,问题多半是出现在这个过程中:

        {
          "scripts": {
            "postinstall": "node scripts/build.js"
          }
        }
        

        它无非就是通过 Node 提供 child_process.spawn 去执行 Shell 命令。

        function build(options) {
          var args = [require.resolve(path.join('node-gyp', 'bin', 'node-gyp.js')), 'rebuild', '--verbose'].concat(
            ['libsass_ext', 'libsass_cflags', 'libsass_ldflags', 'libsass_library'].map(function(subject) {
              return ['--', subject, '=', process.env[subject.toUpperCase()] || ''].join('');
            })).concat(options.args);
        
          console.log('Building:', [process.execPath].concat(args).join(' '));
        
          var proc = spawn(process.execPath, args, {
            stdio: [0, 1, 2]
          });
        
          proc.on('exit', function(errorCode) {
            if (!errorCode) {
              afterBuild(options);
              return;
            }
        
            if (errorCode === 127 ) {
              console.error('node-gyp not found!');
            } else {
              console.error('Build failed with error code:', errorCode);
            }
        
            process.exit(1);
          });
        }
        

        执行的命令行类似 node_modules/node-gyp/bin/node-gyp.js rebuild --verbose --libsass_ext= --libsass_cflags= --libsass_ldflags= --libsass_library=。也就是 node-gyp rebuild 命令。也就是下面这个:

        Command Description
        help Shows the help dialog
        build Invokes make/msbuild.exe and builds the native addon
        clean Removes the build directory if it exists
        configure Generates project build files for the current platform
        rebuild Runs clean, configure and build all in a row
        install Installs Node.js header files for the given version
        list Lists the currently installed Node.js header versions
        remove Removes the Node.js header files for the given version

        所以 node-gyp rebuild 就是先后执行 node-gyp cleannode-gyp configurenode-gyp build 三条命令而已。绝大多数问题,可能会出现在 node-gyp configure 上,也就是生成对应平台的项目构建文件。

        示例分析一

        yarn install v1.22.19
        [1/5] 🔍  Validating package.json...
        [2/5] 🔍  Resolving packages...
        [3/5] 🚚  Fetching packages...
        [4/5] 🔗  Linking dependencies...
        warning " > styled-jsx@3.2.3" has incorrect peer dependency "react@15.x.x || 16.x.x".
        warning "zent > react-beautiful-dnd > react-motion@0.5.2" has incorrect peer dependency "react@^0.14.9 || ^15.3.0 || ^16.0.0".
        warning "zent > react-beautiful-dnd > react-redux@5.1.1" has incorrect peer dependency "react@^0.14.0 || ^15.0.0-0 || ^16.0.0-0".
        [5/5] 🔨  Building fresh packages...
        [-/3] ⠁ waiting...
        [2/3] ⠁ fsevents
        error /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/node-sass: Command failed.
        Exit code: 1
        Command: node scripts/build.js
        Arguments: 
        Directory: /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/node-sass
        Output:
        Building: /usr/local/bin/node /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/node-gyp/bin/node-gyp.js rebuild --verbose --libsass_ext= --libsass_cflags= --libsass_ldflags= --libsass_library=
        gyp info it worked if it ends with ok
        gyp verb cli [
        gyp verb cli   '/usr/local/bin/node',
        gyp verb cli   '/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/node-gyp/bin/node-gyp.js',
        gyp verb cli   'rebuild',
        gyp verb cli   '--verbose',
        gyp verb cli   '--libsass_ext=',
        gyp verb cli   '--libsass_cflags=',
        gyp verb cli   '--libsass_ldflags=',
        gyp verb cli   '--libsass_library='
        gyp verb cli ]
        gyp info using node-gyp@3.8.0
        gyp info using node@16.15.0 | darwin | arm64
        gyp verb command rebuild []
        gyp verb command clean []
        gyp verb clean removing "build" directory
        gyp verb command configure []
        gyp verb check python checking for Python executable "python2" in the PATH
        gyp verb `which` failed Error: not found: python2
        gyp verb `which` failed     at getNotFoundError (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:13:12)
        gyp verb `which` failed     at F (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:68:19)
        gyp verb `which` failed     at E (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:80:29)
        gyp verb `which` failed     at /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:89:16
        gyp verb `which` failed     at /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/isexe/index.js:42:5
        gyp verb `which` failed     at /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/isexe/mode.js:8:5
        gyp verb `which` failed     at FSReqCallback.oncomplete (node:fs:198:21)
        gyp verb `which` failed  python2 Error: not found: python2
        gyp verb `which` failed     at getNotFoundError (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:13:12)
        gyp verb `which` failed     at F (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:68:19)
        gyp verb `which` failed     at E (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:80:29)
        gyp verb `which` failed     at /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:89:16
        gyp verb `which` failed     at /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/isexe/index.js:42:5
        gyp verb `which` failed     at /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/isexe/mode.js:8:5
        gyp verb `which` failed     at FSReqCallback.oncomplete (node:fs:198:21) {
        gyp verb `which` failed   code: 'ENOENT'
        gyp verb `which` failed }
        gyp verb check python checking for Python executable "python" in the PATH
        gyp verb `which` failed Error: not found: python
        gyp verb `which` failed     at getNotFoundError (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:13:12)
        gyp verb `which` failed     at F (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:68:19)
        gyp verb `which` failed     at E (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:80:29)
        gyp verb `which` failed     at /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:89:16
        gyp verb `which` failed     at /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/isexe/index.js:42:5
        gyp verb `which` failed     at /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/isexe/mode.js:8:5
        gyp verb `which` failed     at FSReqCallback.oncomplete (node:fs:198:21)
        gyp verb `which` failed  python Error: not found: python
        gyp verb `which` failed     at getNotFoundError (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:13:12)
        gyp verb `which` failed     at F (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:68:19)
        gyp verb `which` failed     at E (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:80:29)
        gyp verb `which` failed     at /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:89:16
        gyp verb `which` failed     at /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/isexe/index.js:42:5
        gyp verb `which` failed     at /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/isexe/mode.js:8:5
        gyp verb `which` failed     at FSReqCallback.oncomplete (node:fs:198:21) {
        gyp verb `which` failed   code: 'ENOENT'
        gyp verb `which` failed }
        gyp ERR! configure error 
        gyp ERR! stack Error: Can't find Python executable "python", you can set the PYTHON env variable.
        gyp ERR! stack     at PythonFinder.failNoPython (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/node-gyp/lib/configure.js:484:19)
        gyp ERR! stack     at PythonFinder.<anonymous> (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/node-gyp/lib/configure.js:406:16)
        gyp ERR! stack     at F (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:68:16)
        gyp ERR! stack     at E (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:80:29)
        gyp ERR! stack     at /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:89:16
        gyp ERR! stack     at /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/isexe/index.js:42:5
        gyp ERR! stack     at /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/isexe/mode.js:8:5
        gyp ERR! stack     at FSReqCallback.oncomplete (node:fs:198:21)
        gyp ERR! System Darwin 22.3.0
        gyp ERR! command "/usr/local/bin/node" "/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/node-gyp/bin/node-gyp.js" "rebuild" "--verbose" "--libsass_ext=" "--libsass_cflags=" "--libsass_ldflags=" "--libsass_library="
        gyp ERR! cwd /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/node-sass
        gyp ERR! node -v v16.15.0
        gyp ERR! node-gyp -v v3.8.0
        gyp ERR! not ok 
        
        ...
        

        然后可以快速锁定到这几行:

        Building: /usr/local/bin/node /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/node-gyp/bin/node-gyp.js rebuild --verbose --libsass_ext= --libsass_cflags= --libsass_ldflags= --libsass_library=
        gyp info it worked if it ends with ok
        gyp verb cli [
        gyp verb cli   '/usr/local/bin/node',
        gyp verb cli   '/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/node-gyp/bin/node-gyp.js',
        gyp verb cli   'rebuild',
        gyp verb cli   '--verbose',
        gyp verb cli   '--libsass_ext=',
        gyp verb cli   '--libsass_cflags=',
        gyp verb cli   '--libsass_ldflags=',
        gyp verb cli   '--libsass_library='
        gyp verb cli ]
        gyp info using node-gyp@3.8.0
        gyp info using node@16.15.0 | darwin | arm64
        gyp verb command rebuild []
        gyp verb command clean []
        gyp verb clean removing "build" directory
        gyp verb command configure []
        gyp verb check python checking for Python executable "python2" in the PATH
        gyp verb `which` failed Error: not found: python2
        gyp verb `which` failed     at getNotFoundError (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:13:12)
        gyp verb `which` failed     at F (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:68:19)
        gyp verb `which` failed     at E (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:80:29)
        gyp verb `which` failed     at /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/which/which.js:89:16
        gyp verb `which` failed     at /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/isexe/index.js:42:5
        gyp verb `which` failed     at /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/isexe/mode.js:8:5
        gyp verb `which` failed     at FSReqCallback.oncomplete (node:fs:198:21)
        

        看样子出现在 node-gyp configure 过程上,然后追溯到 node-gyp/lib/configure.js 中的 findPython() 方法上,其路径取决于:

        var python = gyp.opts.python || process.env.PYTHON || 'python2'
        

        其中 gyp.opts 可以是 Command Options 或者是 .npmrc 中的对应配置。由于执行 node-gyp rebuild 时没有传递 --python 参数,.npmrc 中没有设置 python 配置,因此默认使用 python2。但由于我本机的环境变量 PATH 的路径中并没有名为 python2 的可执行文件,因此报错了。

        解决方法思路很简单:

        • 如果是较新版本的 node-sass,它对应的 node-gyp 版本也较新,此时应首选安装 Python 3。
        • 如果当前 node-sass 版本仅支持 Python 2.x,那么安装该版本就行。

        安装完之后,设置 npm 或 yarn 配置,那么它能从 var python = gyp.opts.python 中获取 Python 的路径了。以 macOS 为例:

        # 若是 Python 2,则是 which python
        $ which python3
        /usr/local/bin/python3
        
        $ npm config set python /usr/local/bin/python3
        $ yarn config set python /usr/local/bin/python3
        

        示例分析二

        yarn install v1.22.19
        [1/5] 🔍  Validating package.json...
        [2/5] 🔍  Resolving packages...
        [3/5] 🚚  Fetching packages...
        [4/5] 🔗  Linking dependencies...
        warning " > styled-jsx@3.2.3" has incorrect peer dependency "react@15.x.x || 16.x.x".
        warning "zent > react-beautiful-dnd > react-motion@0.5.2" has incorrect peer dependency "react@^0.14.9 || ^15.3.0 || ^16.0.0".
        warning "zent > react-beautiful-dnd > react-redux@5.1.1" has incorrect peer dependency "react@^0.14.0 || ^15.0.0-0 || ^16.0.0-0".
        [5/5] 🔨  Building fresh packages...
        [-/3] ⠂ waiting...
        [2/3] ⠂ fsevents
        warning Error running install script for optional dependency: "/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/fsevents: Command failed.
        Exit code: 1
        Command: node install
        Arguments: 
        Directory: /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/fsevents
        Output:
        node-pre-gyp info it worked if it ends with ok
        node-pre-gyp info using node-pre-gyp@0.12.0
        node-pre-gyp info using node@12.22.12 | darwin | x64
        node-pre-gyp WARN Using request for node-pre-gyp https download 
        node-pre-gyp info check checked for \"/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/fsevents/lib/binding/Release/node-v72-darwin-x64/fse.node\" (not found)
        node-pre-gyp http GET https://fsevents-binaries.s3-us-west-2.amazonaws.com/v1.2.9/fse-v1.2.9-node-v72-darwin-x64.tar.gz
        node-pre-gyp http 403 https://fsevents-binaries.s3-us-west-2.amazonaws.com/v1.2.9/fse-v1.2.9-node-v72-darwin-x64.tar.gz
        node-pre-gyp WARN Tried to download(403): https://fsevents-binaries.s3-us-west-2.amazonaws.com/v1.2.9/fse-v1.2.9-node-v72-darwin-x64.tar.gz 
        node-pre-gyp WARN Pre-built binaries not found for fsevents@1.2.9 and node@12.22.12 (node-v72 ABI, unknown) (falling back to source compile with node-gyp) 
        node-pre-gyp http 403 status code downloading tarball https://fsevents-binaries.s3-us-west-2.amazonaws.com/v1.2.9/fse-v1.2.9-node-v72-darwin-x64.tar.gz 
        gyp info it worked if it ends with ok
        gyp info using node-gyp@3.8.0
        gyp info using node@12.22.12 | darwin | x64
        gyp info ok 
        gyp info it worked if it ends with ok
        gyp info using node-gyp@3.8.0
        gyp info using node@12.22.12 | darwin | x64
        gyp ERR! configure error 
        gyp ERR! stack Error: Command failed: /usr/bin/python3 -c import sys; print \"%s.%s.%s\" % sys.version_info[:3];
        gyp ERR! stack   File \"<string>\", line 1
        gyp ERR! stack     import sys; print \"%s.%s.%s\" % sys.version_info[:3];
        gyp ERR! stack                       ^
        gyp ERR! stack SyntaxError: invalid syntax
        gyp ERR! stack 
        gyp ERR! stack     at ChildProcess.exithandler (child_process.js:308:12)
        gyp ERR! stack     at ChildProcess.emit (events.js:314:20)
        gyp ERR! stack     at maybeClose (internal/child_process.js:1022:16)
        gyp ERR! stack     at Process.ChildProcess._handle.onexit (internal/child_process.js:287:5)
        gyp ERR! System Darwin 22.3.0
        gyp ERR! command \"/Users/frankie/Library/Application Support/fnm/node-versions/v12.22.12/installation/bin/node\" \"/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/node-gyp/bin/node-gyp.js\" \"configure\" \"--fallback-to-build\" \"--module=/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/fsevents/lib/binding/Release/node-v72-darwin-x64/fse.node\" \"--module_name=fse\" \"--module_path=/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/fsevents/lib/binding/Release/node-v72-darwin-x64\" \"--napi_version=8\" \"--node_abi_napi=napi\" \"--napi_build_version=0\" \"--node_napi_label=node-v72\" \"--python=/usr/bin/python3\"
        gyp ERR! cwd /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/fsevents
        gyp ERR! node -v v12.22.12
        gyp ERR! node-gyp -v v3.8.0
        gyp ERR! not ok 
        node-pre-gyp ERR! build error 
        node-pre-gyp ERR! stack Error: Failed to execute '/Users/frankie/Library/Application Support/fnm/node-versions/v12.22.12/installation/bin/node /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/node-gyp/bin/node-gyp.js configure --fallback-to-build --module=/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/fsevents/lib/binding/Release/node-v72-darwin-x64/fse.node --module_name=fse --module_path=/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/fsevents/lib/binding/Release/node-v72-darwin-x64 --napi_version=8 --node_abi_napi=napi --napi_build_version=0 --node_napi_label=node-v72 --python=/usr/bin/python3' (1)
        node-pre-gyp ERR! stack     at ChildProcess.<anonymous> (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/fsevents/node_modules/node-pre-gyp/lib/util/compile.js:83:29)
        node-pre-gyp ERR! stack     at ChildProcess.emit (events.js:314:20)
        node-pre-gyp ERR! stack     at maybeClose (internal/child_process.js:1022:16)
        node-pre-gyp ERR! stack     at Process.ChildProcess._handle.onexit (internal/child_process.js:287:5)
        node-pre-gyp ERR! System Darwin 22.3.0
        node-pre-gyp ERR! command \"/Users/frankie/Library/Application Support/fnm/node-versions/v12.22.12/installation/bin/node\" \"/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/fsevents/node_modules/node-pre-gyp/bin/node-pre-gyp\" \"install\" \"--fallback-to-build\"
        node-pre-gyp ERR! cwd /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/fsevents
        node-pre-gyp ERR! node -v v12.22.12
        node-pre-gyp ERR! node-pre-gyp -v v0.12.0
        node-pre-gyp ERR! not ok 
        

        我们可以快速定位到:

        gyp ERR! configure error 
        gyp ERR! stack Error: Command failed: /usr/bin/python3 -c import sys; print \"%s.%s.%s\" % sys.version_info[:3];
        gyp ERR! stack   File \"<string>\", line 1
        gyp ERR! stack     import sys; print \"%s.%s.%s\" % sys.version_info[:3];
        gyp ERR! stack                       ^
        gyp ERR! stack SyntaxError: invalid syntax
        

        简单来说,就是使用 Python3 执行代码时候提示语法错误,因为 print "xxx" 是 Python2 的语法,而 Python3 的语法应该是 print("xxx")。因此,我们可以猜到是目前 node-sass 所依赖的 node-gyp 版本过低,后者用的是 Python2 语法实现的。

        解决思路很简单,安装 Python2 并将其路径添加到 npm 配置中来,具体操作不展开赘述,前文已介绍过了。

        示例分析三

        前面安装完成之后,执行 yarn start 构建项目的时候,出现问题:

        $ yarn start
        yarn run v1.22.19
        $ webpack-dev-server --env.NODE_ENV=development --hot
        ℹ 「wds」: Project is running at http://0.0.0.0:3001/
        ℹ 「wds」: webpack output is served from /
        ℹ 「wds」: Content not from webpack is served from /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/dist/
        Browserslist: caniuse-lite is outdated. Please run next command `yarn upgrade`
        [styled-jsx] Loading plugin from path: styled-jsx-plugin-sass
        ✖ 「wdm」: Hash: baa097cdaf43c729d164
        Version: webpack 4.41.2
        Time: 455ms
        Built at: 2023/02/07 18:21:57
                          Asset       Size  Chunks                                Chunk Names
                   ./index.html  812 bytes          [emitted]                     
        assets/main.baa097cd.js    923 KiB    main  [emitted] [immutable]  [big]  main
        Entrypoint main [big] = assets/main.baa097cd.js
        [0] multi (webpack)-dev-server/client?http://0.0.0.0:3001 (webpack)/hot/dev-server.js ./src/index.tsx 52 bytes {main} [built]
        [./node_modules/strip-ansi/index.js] 161 bytes {main} [built]
        [./node_modules/webpack-dev-server/client/index.js?http://0.0.0.0:3001] (webpack)-dev-server/client?http://0.0.0.0:3001 4.29 KiB {main} [built]
        [./node_modules/webpack-dev-server/client/overlay.js] (webpack)-dev-server/client/overlay.js 3.51 KiB {main} [built]
        [./node_modules/webpack-dev-server/client/socket.js] (webpack)-dev-server/client/socket.js 1.53 KiB {main} [built]
        [./node_modules/webpack-dev-server/client/utils/createSocketUrl.js] (webpack)-dev-server/client/utils/createSocketUrl.js 2.89 KiB {main} [built]
        [./node_modules/webpack-dev-server/client/utils/log.js] (webpack)-dev-server/client/utils/log.js 964 bytes {main} [built]
        [./node_modules/webpack-dev-server/client/utils/reloadApp.js] (webpack)-dev-server/client/utils/reloadApp.js 1.59 KiB {main} [built]
        [./node_modules/webpack-dev-server/client/utils/sendMessage.js] (webpack)-dev-server/client/utils/sendMessage.js 402 bytes {main} [built]
        [./node_modules/webpack/hot sync ^\.\/log$] (webpack)/hot sync nonrecursive ^\.\/log$ 170 bytes {main} [built]
        [./node_modules/webpack/hot/dev-server.js] (webpack)/hot/dev-server.js 1.59 KiB {main} [built]
        [./node_modules/webpack/hot/emitter.js] (webpack)/hot/emitter.js 75 bytes {main} [built]
        [./node_modules/webpack/hot/log-apply-result.js] (webpack)/hot/log-apply-result.js 1.27 KiB {main} [built]
        [./node_modules/webpack/hot/log.js] (webpack)/hot/log.js 1.34 KiB {main} [built]
        [./src/index.tsx] 1.21 KiB {main} [built] [failed] [1 error]
            + 20 hidden modules
        
        ERROR in ./src/index.tsx
        Module build failed (from ./node_modules/babel-loader/lib/index.js):
        Error: /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/src/index.tsx: Node Sass does not yet support your current environment: OS X Unsupported architecture (arm64) with Unsupported runtime (93)
        For more information on which environments are supported please see:
        https://github.com/sass/node-sass/releases/tag/v4.13.0
            at module.exports (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/node-sass/lib/binding.js:13:13)
            at Object.<anonymous> (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/node-sass/lib/index.js:14:35)
            at Module._compile (node:internal/modules/cjs/loader:1105:14)
            at Object.Module._extensions..js (node:internal/modules/cjs/loader:1159:10)
            at Module.load (node:internal/modules/cjs/loader:981:32)
            at Function.Module._load (node:internal/modules/cjs/loader:822:12)
            at Module.require (node:internal/modules/cjs/loader:1005:19)
            at require (node:internal/modules/cjs/helpers:102:18)
            at Object.<anonymous> (/Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/styled-jsx-plugin-sass/index.js:1:14)
            at Module._compile (node:internal/modules/cjs/loader:1105:14)
        Child html-webpack-plugin for "index.html":
             1 asset
            Entrypoint undefined = ./index.html
            [./node_modules/html-webpack-plugin/lib/loader.js!./src/index.html] 980 bytes {0} [built]
            [./node_modules/lodash/lodash.js] 528 KiB {0} [built]
            [./node_modules/webpack/buildin/global.js] (webpack)/buildin/global.js 472 bytes {0} [built]
            [./node_modules/webpack/buildin/module.js] (webpack)/buildin/module.js 497 bytes {0} [built]
        ℹ 「wdm」: Failed to compile.
        

        Node Sass does not yet support your current environment: OS X Unsupported architecture (arm64) with Unsupported runtime (93)

        可以追查到 node-sass/lib/extensions.jsisSupportedEnvironment() 方法:

        function isSupportedEnvironment(platform, arch, abi) {
          return (
            false !== getHumanPlatform(platform) &&
            false !== getHumanArchitecture(arch) &&
            false !== getHumanNodeVersion(abi)
          );
        }
        

        对应 getHumanArchitecture() 方法如下:

        function getHumanArchitecture(arch) {
          switch (arch || process.arch) {
            case 'ia32': return '32-bit';
            case 'x86': return '32-bit';
            case 'x64': return '64-bit';
            default: return false;
          }
        }
        

        并不支持 arm64 架构,因此报错了。Related Issue: sass/node-sass #3033

        解决方法是降低 Node 版本,比如:

        $ fnm use 12
        

        然后为什么 Node 12 没问题呢?翻查源码 node-sass/lib/binding.js 发现:

        /**
         * Require binding
         */
        module.exports = function (ext) {
          if (!ext.hasBinary(ext.getBinaryPath())) {
            if (!ext.isSupportedEnvironment()) {
              throw new Error(errors.unsupportedEnvironment());
            } else {
              throw new Error(errors.missingBinary());
            }
          }
        
          return require(ext.getBinaryPath());
        };
        

        首先 ext.hasBinary(ext.getBinaryPath()) 会指定目录查找是否存在 binding.node 文件,指定路径类似: /Users/frankie/Web/ifanr/ifanr-wxlayout-editor/node_modules/node-sass/vendor/darwin-x64-72/binding.node。这个路径可以通过 SASS_BINARY_PATHSASS_BINARY_DIRSASS_BINARY_NAME 来指定。如果没有指定,其默认值由 platform-arch-versions.modules 组成(如 darwin-x64-72),前面两个好理解,后面那个应该是由 Node Module 对应组成。

        在使用 Node 12 的时候,本地可以找到 node_modules/node-sass/vendor/darwin-x64-72/binding.node 文件,因此跳过了 isSupportedEnvironment() 的检查,所以降低 Node 版本也是解决方法之一。当使用 Node 16 的时候,本地没有 node_modules/node-sass/vendor/darwin-arm64-93/binding.node 文件,因此跑去校验平台、架构去了,但由于本机是 ARM 架构的 Mac,而前面代码所示是不支持 arm64 架构的,因此就报错了。

        解决方法是,前往 GitHub 下载对应版本的 binding.node 文件至本地,然后通过 SASS_BINARY_PATHSASS_BINARY_DIRSASS_BINARY_NAME 来指定该路径(具体配置方法请看:Binary configuration parameters)。

        尽管至今 Node Sass 还未支持 ARM 架构,但 ARM Mac 在使用 Node 12 时,对应的 darwin-x64-72/binding.node 是没问题的,因此我猜下载 darwin-x64-xx_binding.node 也是 OK 的,没亲测,有兴趣可以自行尝试。

        示例分析四

        $ yarn install                                                                                                                                                                                                                          [9:51:54]
        yarn install v1.22.19
        [1/4] 🔍  Resolving packages...
        [2/4] 🚚  Fetching packages...
        [3/4] 🔗  Linking dependencies...
        warning " > slick-carousel@1.8.1" has unmet peer dependency "jquery@>=1.8.0".
        warning "react-dev-utils > fork-ts-checker-webpack-plugin@6.5.0" has unmet peer dependency "typescript@>= 2.7".
        [4/4] 🔨  Building fresh packages...
        [6/6] ⠁ node-sass
        [-/6] ⠁ waiting...
        [-/6] ⠁ waiting...
        [-/6] ⠁ waiting...
        error /Users/frankie/Web/ifanr/puydufou/pc/node_modules/node-sass: Command failed.
        Exit code: 1
        Command: node scripts/build.js
        Arguments: 
        Directory: /Users/frankie/Web/ifanr/puydufou/pc/node_modules/node-sass
        Output:
        Building: /Users/frankie/.nvm/versions/node/v18.16.0/bin/node /Users/frankie/Web/ifanr/puydufou/pc/node_modules/node-gyp/bin/node-gyp.js rebuild --verbose --libsass_ext= --libsass_cflags= --libsass_ldflags= --libsass_library=
        gyp info it worked if it ends with ok
        gyp verb cli [
        gyp verb cli   '/Users/frankie/.nvm/versions/node/v18.16.0/bin/node',
        gyp verb cli   '/Users/frankie/Web/ifanr/puydufou/pc/node_modules/node-gyp/bin/node-gyp.js',
        gyp verb cli   'rebuild',
        gyp verb cli   '--verbose',
        gyp verb cli   '--libsass_ext=',
        gyp verb cli   '--libsass_cflags=',
        gyp verb cli   '--libsass_ldflags=',
        gyp verb cli   '--libsass_library='
        gyp verb cli ]
        gyp info using node-gyp@8.4.1
        gyp info using node@18.16.0 | darwin | arm64
        gyp verb command rebuild []
        gyp verb command clean []
        gyp verb clean removing "build" directory
        gyp verb command configure []
        gyp verb find Python checking Python explicitly set from command line or npm configuration
        gyp verb find Python - "--python=" or "npm config get python" is "/Users/frankie/.pyenv/shims/python"
        gyp verb find Python - executing "/Users/frankie/.pyenv/shims/python" to get executable path
        gyp verb find Python - executable path is "/Users/frankie/.pyenv/versions/2.7.18/bin/python"
        gyp verb find Python - executing "/Users/frankie/.pyenv/versions/2.7.18/bin/python" to get version
        gyp verb find Python - version is "2.7.18"
        gyp verb find Python - version is 2.7.18 - should be >=3.6.0
        gyp verb find Python - THIS VERSION OF PYTHON IS NOT SUPPORTED
        gyp verb find Python Python is not set from environment variable PYTHON
        gyp verb find Python checking if "python3" can be used
        gyp verb find Python - executing "python3" to get executable path
        gyp verb find Python - executable path is "/opt/homebrew/opt/python@3.12/bin/python3.12"
        gyp verb find Python - executing "/opt/homebrew/opt/python@3.12/bin/python3.12" to get version
        gyp verb find Python - version is "3.12.3"
        gyp info find Python using Python version 3.12.3 found at "/opt/homebrew/opt/python@3.12/bin/python3.12"
        gyp verb get node dir no --target version specified, falling back to host node version: 18.16.0
        gyp verb command install [ '18.16.0' ]
        gyp verb install input version string "18.16.0"
        gyp verb install installing version: 18.16.0
        gyp verb install --ensure was passed, so won't reinstall if already installed
        gyp verb install version is already installed, need to check "installVersion"
        gyp verb got "installVersion" 11
        gyp verb needs "installVersion" 9
        gyp verb install version is good
        gyp verb get node dir target node version installed: 18.16.0
        gyp verb build dir attempting to create "build" dir: /Users/frankie/Web/ifanr/puydufou/pc/node_modules/node-sass/build
        gyp verb build dir "build" dir needed to be created? Yes
        gyp verb build/config.gypi creating config file
        gyp verb build/config.gypi writing out config file: /Users/frankie/Web/ifanr/puydufou/pc/node_modules/node-sass/build/config.gypi
        gyp verb config.gypi checking for gypi file: /Users/frankie/Web/ifanr/puydufou/pc/node_modules/node-sass/config.gypi
        gyp verb common.gypi checking for gypi file: /Users/frankie/Web/ifanr/puydufou/pc/node_modules/node-sass/common.gypi
        gyp verb gyp gyp format was not specified; forcing "make"
        gyp info spawn /opt/homebrew/opt/python@3.12/bin/python3.12
        gyp info spawn args [
        gyp info spawn args   '/Users/frankie/Web/ifanr/puydufou/pc/node_modules/node-gyp/gyp/gyp_main.py',
        gyp info spawn args   'binding.gyp',
        gyp info spawn args   '-f',
        gyp info spawn args   'make',
        gyp info spawn args   '-I',
        gyp info spawn args   '/Users/frankie/Web/ifanr/puydufou/pc/node_modules/node-sass/build/config.gypi',
        gyp info spawn args   '-I',
        gyp info spawn args   '/Users/frankie/Web/ifanr/puydufou/pc/node_modules/node-gyp/addon.gypi',
        gyp info spawn args   '-I',
        gyp info spawn args   '/Users/frankie/Library/Caches/node-gyp/18.16.0/include/node/common.gypi',
        gyp info spawn args   '-Dlibrary=shared_library',
        gyp info spawn args   '-Dvisibility=default',
        gyp info spawn args   '-Dnode_root_dir=/Users/frankie/Library/Caches/node-gyp/18.16.0',
        gyp info spawn args   '-Dnode_gyp_dir=/Users/frankie/Web/ifanr/puydufou/pc/node_modules/node-gyp',
        gyp info spawn args   '-Dnode_lib_file=/Users/frankie/Library/Caches/node-gyp/18.16.0/<(target_arch)/node.lib',
        gyp info spawn args   '-Dmodule_root_dir=/Users/frankie/Web/ifanr/puydufou/pc/node_modules/node-sass',
        gyp info spawn args   '-Dnode_engine=v8',
        gyp info spawn args   '--depth=.',
        gyp info spawn args   '--no-parallel',
        gyp info spawn args   '--generator-output',
        gyp info spawn args   'build',
        gyp info spawn args   '-Goutput_dir=.'
        gyp info spawn args ]
        Traceback (most recent call last):
          File "/Users/frankie/Web/ifanr/puydufou/pc/node_modules/node-gyp/gyp/gyp_main.py", line 42, in <module>
            import gyp  # noqa: E402
            ^^^^^^^^^^
          File "/Users/frankie/Web/ifanr/puydufou/pc/node_modules/node-gyp/gyp/pylib/gyp/__init__.py", line 9, in <module>
            import gyp.input
          File "/Users/frankie/Web/ifanr/puydufou/pc/node_modules/node-gyp/gyp/pylib/gyp/input.py", line 19, in <module>
            from distutils.version import StrictVersion
        ModuleNotFoundError: No module named 'distutils'
        gyp ERR! configure error 
        gyp ERR! stack Error: `gyp` failed with exit code: 1
        gyp ERR! stack     at ChildProcess.onCpExit (/Users/frankie/Web/ifanr/puydufou/pc/node_modules/node-gyp/lib/configure.js:259:16)
        gyp ERR! stack     at ChildProcess.emit (node:events:513:28)
        gyp ERR! stack     at ChildProcess._handle.onexit (node:internal/child_process:291:12)
        gyp ERR! System Darwin 23.5.0
        gyp ERR! command "/Users/frankie/.nvm/versions/node/v18.16.0/bin/node" "/Users/frankie/Web/ifanr/puydufou/pc/node_modules/node-gyp/bin/node-gyp.js" "rebuild" "--verbose" "--libsass_ext=" "--libsass_cflags=" "--libsass_ldflags=" "--libsass_library="
        gyp ERR! cwd /Users/frankie/Web/ifanr/puydufou/pc/node_modules/node-sass
        gyp ERR! node -v v18.16.0
        

        报错原因是找不到 distutils 模块:

        ModuleNotFoundError: No module named 'distutils'
        

        从以下可知:

        distutils 在 Python 3.10 被废弃,于 Python 3.12 正式移除。

        我这里刚好是 3.12 版本,一是通过 pyenv 切换更低版本 Python,二是安装 python-setuptools 来解决。

        $ brew install python-setuptools
        

        Related Link:

        ]]>
        <![CDATA[unable to verify the first certificate 原因及解决方法]]> https://github.com/tofrankie/blog/issues/97 https://github.com/tofrankie/blog/issues/97 Sat, 25 Feb 2023 12:31:12 GMT 配图源自 Freepik

        背景

        此前,安装依赖遇到了该报错:

        
                    配图源自 Freepik

        背景

        此前,安装依赖遇到了该报错:

        yarn install v1.22.19
        [1/4] 🔍  Resolving packages...
        [2/4] 🚚  Fetching packages...
        error An unexpected error occurred: "https://r2.cnpmjs.org/form-data/-/form-data-3.0.1.tgz: unable to verify the first certificate".
        info If you think this is a bug, please open a bug report with the information provided in "/Users/frankie/Web/ifanr/yuegonghui/activity-collection/yarn-error.log".
        info Visit https://yarnpkg.com/en/docs/cli/install for documentation about this command.
        

        yarn-error.log 如下

        Arguments: 
          /Users/frankie/Library/Application Support/fnm/node-versions/v16.15.0/installation/bin/node /usr/local/bin/yarn
        
        PATH: 
          /Users/frankie/Library/Caches/fnm_multishells/57063_1669556334889/bin:/Users/frankie/.yarn/bin:/usr/local/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/frankie/Library/Caches/fnm_multishells/57063_1669556334889/bin:/Users/frankie/.yarn/bin:/usr/local/sbin:/opt/homebrew/bin:/opt/homebrew/bin
        
        Yarn version: 
          1.22.19
        
        Node version: 
          16.15.0
        
        Platform: 
          darwin arm64
        
        Trace: 
          Error: unable to verify the first certificate
              at TLSSocket.onConnectSecure (node:_tls_wrap:1532:34)
              at TLSSocket.emit (node:events:527:28)
              at TLSSocket._finishInit (node:_tls_wrap:946:8)
              at TLSWrap.ssl.onhandshakedone (node:_tls_wrap:727:12)
        
        npm manifest:
          ...
        
        yarn manifest: 
          No manifest
        
        Lockfile:
          ...
        

        报错信息为:unable to verify the first certificate,与证书有关。

        由于 yarn installnpm install 走的是 HTTPS 协议,它的安全通过数字证书来保障。数字证书由专门机构颁发,通常是付费的。自签证书,就是自己扮演数字证书机构给自己颁发的证书。

        由于自 2014 年 2 月 27 日起,npm 不再支持「自签证书 Self-Signed Certificate」。👉 npm Blog

        https://r2.cnpmjs.org/form-data/-/form-data-3.0.1.tgz 所在域名的证书是不被信任的。通过 Firefox 浏览器访问可以看到警告:

        其中 npm 与证书相关的配置有两项:

        • ca - 用于指定信任的证书颁发机构(Certificate Authority)。默认为 null,表示仅允许「已知且可信的」证书颁发机构所颁发的证书。
        • strict-ssl - 通过 https 向注册表发出请求时是否进行 SSL 密钥验证,若校验失败,npm 将无法连接到服务器并报错。默认为 true

        解决方法

        方法一

        确保安全的情况下,可以临时关闭 strict-ssl 选项:

        $ npm config set strict-ssl false
        

        strict-ssl 设置为 false 时,npm 将不会对服务器的 SSL 证书进行校验,并且即使证书是由不可信的认证机构颁发的也不会报错。这可能会导致安全风险,因为你的网络流量可能被劫持或篡改,而你并不会意识到这一点。因此,应该尽量避免使用 strict-ssl 设置为 false

        如果你确实需要使用 strict-ssl 设置为 false,例如你所连接的服务器使用的是自签名的 SSL 证书,应该只在短时间内使用,并在操作完成后尽快将 strict-ssl 设置回 true

        方法二(推荐)

        找出所有相关的 npm 包,并选择可信的镜像源后重装。

        如果你处于具有拦截 HTTPS 代理的环境中,它可能会破坏 npm,与运维人员联系解决。

        The end.

        ]]>
        <![CDATA[再见 nvm,改用 fnm 了]]> https://github.com/tofrankie/blog/issues/96 https://github.com/tofrankie/blog/issues/96 Sat, 25 Feb 2023 12:30:01 GMT 配图源自 Freepik

        社区上用于管理 Node 版本的工具很多,较为流行的有:

          <]]> 配图源自 Freepik

          社区上用于管理 Node 版本的工具很多,较为流行的有:

          个人弃用 nvm 不是因为它不跨平台,而是启动 Shell 进程太耗时了... 加之它本身问题挺多的,详见:Important Notes

          对我最直接的影响是:此前那台 8G 内存的 MacBook Pro 打开 VS Code 的时候,总会因为 Shell 解析太久导致 VS Code 终止解析,最终造成了某些扩展无法正常使用。此前吐槽过了,可移步文章:解决 Unable to resolve your shell environment in a reasonable time

          fnm 安装与使用

          fnm(Fast Node Manager)基于 Rust 开发,是不是还没用就感觉到它的快了,哈哈~ 同时,它是跨平台的,支持 macOS、Linux、Windows。

          🚀 Fast and simple Node.js version manager, built in Rust.

          1. 安装 fnm(以 macOS 为例)

          $ brew install fnm
          

          2. 配置 fnm 所需的环境变量到 bash 或 zsh 配置文件中,以 zsh 为例:

          $ fnm env --use-on-cd >> ~/.zshrc
          

          亦可执行 fnm env --use-on-cd,将输出内容手动添加至 .bash_profile.zshrc 里。

          3. 用 fnm 安装 Node

          # 安装 LTS 版本
          $ fnm install --lts
          
          # 安装指定大版本的最新版本
          $ fnm install 18
          
          # 安装指定版本
          $ fnm install 18.21.1
          

          相反地,可通过 fnm uninstall <version>fnm uninstall <alias-name> 来删除指定版本,后者会同时移除别名。

          4. 通过 fnm 来指定 Node 版本

          # 使用系统版本
          $ fnm use system
          
          # 使用 fnm 所安装,且版本号为 18.21.1 的 Node 程序
          $ fnm use 18.21.1
          
          # 使用 fnm 所安装,且主版本号为 18 的最新版本的 Node 程序
          $ fnm use 18
          

          只要用 fnm use <version> 指定后,每次启动 Shell 将会默认使用对应的 Node 版本。

          5. 设置别名

          # 形式如:fnm alias <指定版本号> <别名>
          $ fnm alias 18.21.1 v18
          
          # 设置别名后,可以简化指令为:
          $ fnm use v18
          

          其实以上示例的别名意义不大,仅用于举例而已。原因是:在「不设置别名」的情况下,使用 fnm use 18,也能切换至 18.21.1。使用 fnm use <major> 会切换至对应主版本号对应的最新版本。

          假设我们安装了 18.20.018.21.1 两个主版本号相同的 Node 程序,使用 fnm use 18 只会切换至 18.21.1(即最新的版本),尽管通过 fnm alias 18.20.0 1818.20.0 的别名设为 18,这样设置别名是无意义的。 此时可能需要用 fnm use 18.20fnm use 18.20.0 来切换指定版本了,或者其他非纯数字的别名了。

          较有意义的特殊别名 systemdefault

          • 前者是以 .pkg 等形式(比如官网下载的安装包)所安装的 Node 应用程序,称为系统版本。
          • 后者是用于指定 fnm 的一个默认版本,作为与 18 是类似的,只是其语义表示默认罢了。
          # 指定默认版本
          $ fnm default 18.21.1
          
          # 相当于
          $ fnm alias 18.21.1 default
          

          相反地,可通过 fnm unalias <alias-name> 来取消别名。

          6. 项目中指定特定版本

          可以通过在项目根目录下添加 .node-version.nvmrc 文件,并在其中指定版本。比如:

          $ echo '18' > .node-version
          

          前提是,配置 fnm 环境用的是 fnm env --use-on-cd 命令,而不是 fnm env。后者没有添加 Hook,因此不会是检查对应配置文件。有兴趣的可以对比两条命令的差别就明白了。

          由于团队成员所安装的 Node,其次版本或补丁版本号可能是不一样的,因此,多数情况下指定主版本号即可,无需指定到 18.21.1 等更具体的版本号(特殊场景除外)。

          7. 卸载 fnm

          若是通过 brew 安装的 fnm,则:

          $ brew uninstall fnm
          

          接着,再移除 ~/.fnm 目录。

          $ rm -rf ~/.fnm
          

          最后,移除 bashzsh 的配置文件中与 fnm 相关的配置。比如:

          export PATH="/Users/frankie/Library/Caches/fnm_multishells/49559_1670052262156/bin":$PATH
          export FNM_VERSION_FILE_STRATEGY="local"
          export FNM_DIR="/Users/frankie/Library/Application Support/fnm"
          export FNM_NODE_DIST_MIRROR="https://nodejs.org/dist"
          export FNM_MULTISHELL_PATH="/Users/frankie/Library/Caches/fnm_multishells/49559_1670052262156"
          export FNM_ARCH="x64"
          export FNM_LOGLEVEL="info"
          autoload -U add-zsh-hook
          _fnm_autoload_hook() {
            if [[ -f .node-version || -f .nvmrc ]]; then
              fnm use --silent-if-unchanged
            fi
          }
          
          add-zsh-hook chpwd _fnm_autoload_hook &&
            _fnm_autoload_hook
          
          rehash
          

          移除 nvm

          在移除之前,通过以下方式查看使用 nvm 所安装的全局包,然后切换到 fnm 安装一下(有需要的话):

          $ nvm use 16
          Now using node v16.14.0 (npm v8.3.1)
          
          $ npm list -g
          /Users/frankie/.nvm/versions/node/v16.14.0/lib
          ├── corepack@0.10.0
          ├── npm@8.3.1
          ├── pnpm@7.5.0
          ├── simple-shell@
          └── zx@7.0.7
          

          移除 nvm 的安装目录,通常是 ~/.nvm。执行以下命令即可:

          $ rm -rf "$NVM_DIR"
          

          移除 bashzsh 的配置文件中与 nvm 相关的配置。比如:

          export NVM_DIR="$HOME/.nvm"
          [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" --no-use          # This loads nvm
          [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion
          

          其他系统可看 Uninstalling / Removal

          参考链接

          ]]>
          <![CDATA[path.resolve 与 path.join 的区别详解]]> https://github.com/tofrankie/blog/issues/95 https://github.com/tofrankie/blog/issues/95 Sat, 25 Feb 2023 12:29:03 GMT 配图源自 Freepik

          前言

          相信大家一定用过 path.resolve]]> 配图源自 Freepik

          前言

          相信大家一定用过 path.resolve()path.join(),特别是 Webpack、Rollup、Vite 等构建工具,再熟悉不过了。

          path.resolve(__dirname, 'src/index.js')
          

          像这个例子,用 path.join(__dirname, 'src/index.js') 所得到的结果也是完全一样的。 那么它俩究竟有何不同,在什么情况下使用才能体现出区别呢?

          path.resolve

          官方文档

          该函数接受「零或多个」字符串类型的路径参数,并返回一个 normalized 的「绝对路径」。

          path.resolve(path1, path2, ..., pathN)
          

          特点:

          • 其中 zero-length 的参数会被忽略,比如 ''(空字符串)和 '.'(表示当前目录)。
          • / 开头的参数会被当作文件系统根路径,不以 .././ 开头的参数会被当作是目录。
          • 若参数不为字符串类型,则会抛出 TypeError。
          • 若不传递任何参数时,返回 Node 进程的当前工作目录。因此 path.resolve() === process.cwd() 结果为 true
          • 结果返回之前其内部也会做类似 path.normalize() 的路径规范化处理。

          示例:

          path.resolve('/a', 'b', 'c') // return: "/a/b/c"
          path.resolve('/a', '/b', 'c') // return: "/b/c"
          path.resolve('/a', '/b', '/c') // return: "/c"
          

          其实官网文档描述已经很清晰了。无非就是,「从右到左」一个一个参数开始解析,每解析一个就将其加到原来路径的「前面」,参数之间使用平台对应的路径分隔符连接。若能拼接成一个绝对路径,就停止解析并立即返回。如果将所有参数都解析完,仍然无法拼凑得出一个绝对路径,那么就将这些参数的结果拼到当前工作目录中,以得出绝对路径。

          因此,上述示例内部解析过程如下:

          "c" -> "b/c" -> "/a/b/c"
          "c" -> "/b/c"
          "/c"
          

          假设有 path.resolve('a', 'b'),就是 "b" -> "a/b" -> "process.cwd()/a/b" 的过程,相当于 path.resolve(process.cwd(), 'a', 'b')。其中 proccess.cwd() 就是当前工作目录。

          个人认为,更好的理解倒是「从左往右」看,将 path.resolve() 方法看作 Shell 的 cd 操作,只是前者不管文件系统是否存在此目录或文件。如伪代码:

          path.resolve('/a', '/b', 'c')
          
          // 相当于
          $ cd /a; cd /b; cd c
          

          或许 path.resolve() 称为 path.cd() 更让人豁然开朗吧。

          path.join

          官方文档

          该函数接受「零或多个」字符串类型的路径参数,返回一个所有参数拼接起来的(相对/绝对)路径。

          path.join(path1, path2, ..., pathN)
          

          特点:

          • 其中 zero-length 的参数会被忽略,比如 ''(空字符串)和 '.'(表示当前目录)。
          • ../ 开头的参数认为是上一级目录。
          • 第一个参数若以 / 会被认为是根目录,其他以 / 开头的参数作用与 ./ 相同。
          • 若参数不为字符串类型,则会抛出 TypeError。
          • 若不传递任何参数时,返回 .(当前目录)。
          • 结果返回之前其内部也会做类似 path.normalize() 的路径规范化处理。

          示例:

          path.join('a', 'b', 'c') // return: "a/b/c"
          path.join('/a', 'b', 'c') // return: "/a/b/c"
          path.join('/a', '/b', 'c') // return: "/a/b/c"
          path.join('/a', '/b', '/c') // return: "/a/b/c"
          

          其实不用看那么多,换个角度去理解或许更清晰,两个步骤:

          1. 用相加运算符 + 将所有参数连接起来(参数之间用 / 连接)。
          2. 使用 path.normalize() 对相加后的字符串路径作规范化处理。

          有人可能会问,path.normalize() 又是干嘛的呢?

          1. ./../ 翻译成对应路径
          2. 把多余无用的路径连接符干掉(如 a//b => a/b
          3. 将路径连接符转换为特定平台的连接符(比如 Unix 的 /,Windows 的 \)。

          因此,可以把 path.join('/a', '/b', 'c') 理解成这样:

          let args = ['/a', '/b', 'c']
          let str = args.join('/') // "/a//b/c"
          str = path.normalize(str) // "/a/b/c"
          

          区别

          以几个示例做总结:

          无参数时

          path.resolve() // 返回当前工作目录,相当于 `process.cwd()`,是绝对路径。
          path.join() // 返回 `.`(当前目录),是相对路径。
          

          注意「当前工作目录」和「当前目录」的区别。

          有多个参数,且中间参数以 / 开头

          path.resolve('/a', '/b', 'c') // 返回 `/b/c`,绝对路径。
          path.join('/a', '/b', 'c') // 返回 `/a/b/c`,绝对路径。
          
          path.resolve('a', '/b', 'c') // 返回 `/b/c`,绝对路径。
          path.join('a', '/b', 'c') // 返回 `a/b/c`,相对路径。
          

          The end.

          ]]>
          <![CDATA[梳理 node、npm、yarn 相关路径]]> https://github.com/tofrankie/blog/issues/94 https://github.com/tofrankie/blog/issues/94 Sat, 25 Feb 2023 12:27:18 GMT 配图源自 Freepik

          如果你对 node、npm、yarn 全局安装路径存疑,混乱分不清,那么这篇]]> 配图源自 Freepik

          如果你对 node、npm、yarn 全局安装路径存疑,混乱分不清,那么这篇文章或许能帮到你。

          本文以 macOS 为例

          一、which 命令

          使用 which 命令可以查看命令(可执行文件)的路径,比如:

          $ which node npm
          /usr/local/bin/node
          /usr/local/bin/npm
          

          PS:/usr/local/bin 目录下的可执行文件多是软链接,并非文件真正所在路径。

          二、Node 路径

          在安装完 Node.js 之后,会有如下提示:

          This package has installed:

          • Node.js v16.15.1 to /usr/local/bin/node
          • npm v8.11.0 to /usr/local/bin/npm

          Make sure that /usr/local/bin is in your $PATH.

          PS:本文将以这种方式安装的 Node.js 称为系统版本的 Node,以区别使用 nvm 安装的 Node。

          三、npm 路径

          在 NPM 官方文档中,对「全局安装」如何存放文件都有比较清晰的描述(详见)。

          简单来说:

          • 依赖包将会下载至 {prefix}/lib/node_modules 目录下。
          • 依赖包的可执行文件被软链接至 {prefix}/bin 目录。

          其中 prefix 是 npm 的一个配置项(详见),它的默认值与 Node 的安装位置有关。在 Unix/Linux/Mac 操作系统中,通常是 /usr/local。在 Windows 操作系统上通常是 %AppData%\npm。其中「可执行文件」是指 package.jsonbin 字段的配置项。

          使用 npm config 命令可对 prefix 配置进行操作:

          # 查看
          $ npm config get prefix
          /usr/local
          
          # 设置
          $ npm config set prefix <value>
          
          # 移除
          $ npm config delete prefix
          

          也可在配置文件 ~/.npmrc 直接进行修改(详见 )。

          若你在使用 nvm 来管理 Node 版本,不建议主动配置 prefix。如果它存在的话应将其移除,否则可能会导致无法合理地安装依赖包到相应目录,具体原因下文会介绍。

          结论:

          • 依赖包存放于 /usr/local/lib/node_modules 目录。
          • 依赖包可执行文件将被软链接至 /usr/local/bin 目录。

          可通过以下命令查看:

          $ npm root -g
          /usr/local/lib/node_modules
          
          $ npm bin -g
          /usr/local/bin
          

          可通过 npm ls 命令查看全局安装的依赖包,个人更喜欢使用其别名 npm list,原因是它跟 yarn list 一致。

          $ npm list -g
          /usr/local/lib
          ├── corepack@0.10.0
          ├── jest@28.1.1
          ├── npm@8.3.1
          └── yarn@1.22.10
          

          其打印结果为树状形式,可配合 --depth=n 参数使用以查看包的依赖信息,其中 n 表示树状深度。上面的 npm list -g 相当于 npm list -g --depth=0

          了解更多 NPM Docs

          四、yarn (classic) 路径

          插个话,在 yarn 2 版本有着较大的差异。比如,yarn 2 将 yarn global 命令移除,其替代者是 yarn dlx了解更多

          yarn 全局安装路径:

          • 依赖包存放于 ~/.config/yarn/global/node_modules 目录。
          • 依赖包可执行文件将被软链接至 /usr/local/bin 目录,

          可通过以下命令查看:

          $ yarn global dir
          /Users/frankie/.config/yarn/global
          
          $ yarn global bin
          /usr/local/bin
          

          若要修改以上配置,可通过 yarn config 命令处理:

          # 修改依赖包安装路径
          $ yarn config set global-folder <value>
          
          # 修改依赖包可执行文件软链接路径
          $ yarn config set prefix <value>
          

          需要注意的是,修改全局安装路径的配置 keyglobal-folder,可执行文件的 keyprefix。别跟 npm 混淆了。

          也可以在配置文件 ~/.yarnrc 直接修改:

          # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
          # yarn lockfile v1
          
          
          registry "https://registry.npmmirror.com/"
          lastUpdateCheck 1656144727204
          

          需要注意的是,其中 npm 配置文件使用的是 ini-formatted 格式,也就是 key=value 形式,而 yarn 则是 key "value" 形式。

          五、使用 nvm

          我们知道 npm(Node Package Manager)是 Node 包管理工具,而 nvm(Node Version Manager)则是 Node 版本管理工具。它可以通过命令行快速安装、使用不同版本的 Node。

          假设有多个项目使用了不同版本的 Node,或者需要在不同版本的 Node 下测试开发的 npm 包,那么使用 nvm 将会更高效。安装方法请看官方文档

          使用「传统」的方法安装 Node.js,其路径如下:

          • Node 被安装到 /usr/local/bin/node
          • npm 被安装到 /usr/local/bin/npm
          • npm 全局依赖包默认存放于 /usr/local/lib/node_modules 目录。

          但如果使用 nvm 来管理 Node,这些都将会发生变化,相关内容将会默认存放至 ~/.nvm 目录。

          使用 nvm install 来安装 Node,它将会存放于 ~/.nvm/versions/node/v16.14.0 目录:

          $ nvm install 16.14.0
          

          使用 nvm use 可以切换系统 Node 与 nvm 的 Node:

          # 使用系统版本
          $ nvm use system
          
          # 使用 nvm 的 Node 版本,还有更多写法,请看:https://github.com/nvm-sh/nvm#usage
          $ nvm use 16.14.0
          

          对比下 prefix 的变化:

          $ nvm use system
          Now using system version of node: v16.14.0 (npm v7.24.2)
          
          $ npm config get prefix
          /usr/local
          
          $ npm bin -g
          /usr/local/bin
          
          $ which node
          /usr/local/bin/node
          
          $ nvm use 16.14.0
          Now using node v16.14.0 (npm v8.3.1)
          
          $ npm config get prefix
          /Users/frankie/.nvm/versions/node/v16.14.0
          
          $ npm bin -g
          /Users/frankie/.nvm/versions/node/v16.14.0/bin
          
          $ which node
          /Users/frankie/.nvm/versions/node/v16.14.0/bin/node
          

          因此,在使用 nvm 管理的情况下:

          • 全局依赖包将会安装至 ~/.nvm/versions/node/vX.Y.Z/lib/node_modules 目录。
          • 依赖包可执行文件将被软链接至 ~/.nvm/versions/node/vX.Y.Z/bin 目录。

          前面提到过,prefix 的默认位置与 Node 的安装路径有关。在 Unix/Linux/Mac 操作系统中,prefix 通常是 Node 安装路径的上一级,即 ~/.nvm/versions/node/vX.Y.Z 目录。因此,当我们在切换 Node 版本中,它总能正确地安装到 /usr/local/lib/node_modules~/.nvm/versions/node/vX.Y.Z/lib/node_modules/ 目录。

          了解更多 NPM Docs

          如果在 ~/.npmrc 中配置了 prefix,无论你如何切换 Node 版本,它总是被安装至所配置的路径下。

          $ npm config set prefix "~/.npm-packages"
          
          $ npm i -g jest
          
          $ tree ~/.npm-packages -L 3 
          /Users/frankie/.npm-packages
          ├── bin
          │   └── jest -> ../lib/node_modules/jest/bin/jest.js
          └── lib
              └── node_modules
                  └── jest
          

          这样就违背了用 nvm 的初心,自定义 prefix 配置与 nvm 不兼容。了解更多

          If you have an ~/.npmrc file, make sure it does not contain any prefix settings (which is not compatible with nvm)

          六、总结

          目前,我同时使用着 nvm、npm、yarn(classic)三个工具,平常是这样管理它们的:

          • 一概不主动修改 npm、yarn 默认安装路径
          • npm 配置中一概不设置 prefix,以避免全局依赖包安装至“不正确”的路径
          • 与 Node 版本有关的全局依赖包,统一用 npm 进行安装(不推荐)
          • 与 Node 版本无关的全局依赖包,统一用 yarn 进行安装(不推荐)
          • 尽可能少装全局依赖,善用 npxyarn dlxpnpm dlxpnpm createyarn create 等命令

          基于这种情况下,全局安装的依赖路径如下:

          • /usr/local/lib/node_modules
          • ~/.config/yarn/global/node_modules
          • ~/.nvm/versions/node/vX.X.X/lib/node_modules

          查看全局依赖包所在路径的命令:

          • npm root -g
          • yarn global dir

          查看已安装的全局依赖包:

          • npm list -g
          • yarn global list

          The end.

          ]]>
          <![CDATA[Node.js 实现浏览器打开链接]]> https://github.com/tofrankie/blog/issues/93 https://github.com/tofrankie/blog/issues/93 Sat, 25 Feb 2023 12:26:45 GMT 配图源自 Freepik

          在 Node.js 中实现在浏览器中打开指定 URL。

          利用 ]]> 配图源自 Freepik

          在 Node.js 中实现在浏览器中打开指定 URL。

          利用 Node.js 提供的 child_process.exec() 方法即可,但是不同操作系统下,指令有所不同:

          const { exec } = require('child_process')
          const uri = 'https://www.google.com'
          
          // Windows
          exec('start https://www.google.com')
          
          // Mac
          exec('open https://www.google.com')
          
          // Otherwise: https://portland.freedesktop.org/doc/xdg-open.html
          exec('xdg-open https://www.google.com')
          

          社区上有一些 NPM 包可以直接使用,比如 openopn 等。

          open 为例:

          const open = require('open')
          
          open('http://www.google.com', 'firefox')
          

          The end.

          ]]>
          <![CDATA[Node.js 复制至剪贴板]]> https://github.com/tofrankie/blog/issues/92 https://github.com/tofrankie/blog/issues/92 Sat, 25 Feb 2023 12:26:10 GMT 配图源自 Freepik

          拷贝是操作系统提供的一个能力,在不同操作系统下,指令集是不同的,如下: 配图源自 Freepik

          拷贝是操作系统提供的一个能力,在不同操作系统下,指令集是不同的,如下:

          const { exec } = require('child_process')
          
          // Windows
          exec('clip').stdin.end('some text')
          
          // Mac
          exec('pbcopy').stdin.end('some text')
          
          // Linux
          exec('xclip').stdin.end('some text')
          

          在社区上,也有提供了各平台通用的 NPM 包,比如:copy-paste,使用也非常地简单。

          $ npm i copy-paste
          
          const ncp = require('copy-paste')
          
          ncp.copy('some text', function () {
            // complete...
          })
          

          需要注意的是,copy() 方法是异步的。

          ]]>
          <![CDATA[一个比 nrm 更好用的镜像源管理工具]]> https://github.com/tofrankie/blog/issues/91 https://github.com/tofrankie/blog/issues/91 Sat, 25 Feb 2023 12:24:38 GMT 配图源自 Freepik

          此前介绍过 nrm 用于快速切换 NPM 源。

          配图源自 Freepik

          此前介绍过 nrm 用于快速切换 NPM 源。

          详见:nrm 使用详解

          原始设置 NPM 或 YARN 源的命令对应如下:

          # use npm
          $ npm config set registry <registry-url>
          
          # use yarn
          $ yarn config set registry <registry-url>
          

          而使用 nrm 可以快速地切换源:

          # nrm use taobao
          $ nrm use <registry>
          

          但是这个有点不爽,就是说它只会切换 npm 命令的镜像源,nrm 无法修改 yarn 的。

          最近发现了一个 yrm(YARN registry manager)工具,可以同步修改 npmyarn 镜像源,它本身也是 nrm 的一个 Fork 分支。

          # install
          $ npm i yrm -g
          
          # switch registry
          $ yrm use <registry>
          
          # ...
          

          使用方法与 nrm 一致,就不多说了。如果对 nrm 不了解的话,可以先看看前面那篇文章。也可以通过 -h 查看所有命令。

          $ yrm -h
          
          Usage: yrm [options] [command]
          
          Options:
            -V, --version                output the version number
            -h, --help                   output usage information
          
          Commands:
            ls                           List all the registries
            current                      Show current registry name
            use <registry>               Change registry to registry
            add <registry> <url> [home]  Add one custom registry
            del <registry>               Delete one custom registry
            home <registry> [browser]    Open the homepage of registry with optional browser
            test [registry]              Show response time for specific or all registries
            help                         Print this help
          

          当你使用其他非预设注册表时,不能使用 publish 命令。通常我发布 NPM 包都是直接使用 npm public 命令。

          效果如下:

          $ yrm use taobao
          
             YARN Registry has been set to: https://registry.npm.taobao.org/
          
             NPM Registry has been set to: https://registry.npm.taobao.org/
          

          「重要通知」 原淘宝 npm 域名即将停止解析,请切换至新域名 npmmirror.comhttp://npm.taobao.org和 http://registry.npm.taobao.org 将在 2022.06.30 号正式下线和停止 DNS 解析。

          由于 yrm 包最新一次更新还是五年前,因此淘宝镜像的域名还是旧域名,可以通过以下方式进行更新:

          $ yrm add taobao https://registry.npmmirror.com/
          

          参考链接

          ]]>
          <![CDATA[从零到一搭建私有 NPM 服务器]]> https://github.com/tofrankie/blog/issues/90 https://github.com/tofrankie/blog/issues/90 Sat, 25 Feb 2023 12:21:23 GMT 配图源自 Freepik

          我们知道 ]]> 配图源自 Freepik

          我们知道 npmjs.com 平台上的 package 对任何人都是开放的,可以随意安装。

          假设公司/团队想把一些可复用的模块做成 NPM 包,但由于涉及公司业务的原因,不能发布到像 NPM 这种公开平台。此时,可以在公司内部建立一个私有的 NPM 平台。

          我们从零到一搭建个人的 NPM 私有服务器。

          一、选择

          选择 Verdaccio 作为我们私有 NPM 仓库的平台,主要原因是免费、零配置,开箱即用。

          如果是公司层面,可能要深入可靠、稳定性等因素。

          二、NPM 平台搭建

          安装、启动都非常简单~

          # install
          $ npm i -g verdaccio
          
          # run
          $ verdaccio
          
           warn --- config file  - /Users/frankie/.config/verdaccio/config.yaml
           warn --- Plugin successfully loaded: verdaccio-htpasswd
           warn --- Plugin successfully loaded: verdaccio-audit
           warn --- http address - http://localhost:4873/ - verdaccio/5.1.1
           http --- 127.0.0.1 requested 'GET /'
           http --- 304, user: null(127.0.0.1), req: 'GET /', bytes: 0/0
           http --- 127.0.0.1 requested 'GET /-/verdaccio/packages'
           http --- 304, user: null(127.0.0.1), req: 'GET /-/verdaccio/packages', bytes: 0/0
          

          Verdaccio 跑起来之后,就可以访问 http://localhost:4873/(仓库地址)了。

          配置文件在 ~/.config/verdaccio/config.yaml

          目前还没发布过包,长这样...

          我们使用 nrm 切换镜像源:

          # 添加私有源,源名称 local 可自定义,按需使用
          $ nrm add local http://localhost:4873/
          
          # 切换源
          $ nrm use local
          
          # 注册用户,对应你 NPM 账号密码
          $ npm adduser
          
          # 查看当前用户是否是注册用户
          $ npm who am i
          

          三、发包

          3.1 创建 NPM 项目

          我们来创建一个最简单的 NPM 包项目,目录如下:

          privative-npm
          ├── .gitignore                # 相应目录,发布时会忽略上传
          ├── .npmignore                # 同理,会忽略上传
          ├── index.js                  # 作为入口
          ├── package.json              # 包描述文件
          └── README.md                 # 项目说明
          

          一个 NPM 包,其中 nameversion 是必需的,其他都可以省略,而且 name 不能与平台上已有包重名。

          // package.json
          {
            "name": "privative-npm", // 必需,不能有大写字母、空格、下划线
            "version": "1.0.0", // 必需,请严格遵循语义化
            "description": "Test only, not published to NPM.",
            "author": "Frankie <1426203851@qq.com>",
            "license": "MIT",
            "type": "module",
            "main": "./index.js"
          }
          

          由于仅演示用,简单处理导出一个方法。

          // index.js
          export default function log(str) {
            console.log(str)
          }
          

          3.2 发布 NPM 包

          在发包之前,你需要去 npmjs.com 注册一个账号(已有跳过)。

          登录你的 NPM 账号:

          # add registry
          $ nrm add local http://localhost:4873/
          
          # switch registry
          $ nrm use local
          
          # 首次需要添加用户,包括了登录操作 add npm account
          $ npm adduser
          
          # 已添加过用户,直接用 login
          $ npm login
          

          此处添加 local 源后,下文将会直接使用 nrm use local 切换至 http://localhost:4873/ 源。

          以下登录成功:

          $ npm login
          
          Username: xxx
          Password: 
          Email: (this IS public) example@gmail.com
          Logged in as xxx on http://localhost:4873/.
          

          项目根目录下,执行命令 npm publish 进行发版。

          通常会在 package.json 定义 prepublish 命令,可能会伴随着构建、测试等流程以确保无误。

          $ npm publish
          
          npm notice 
          npm notice 📦  privative-npm@1.0.0
          npm notice === Tarball Contents === 
          npm notice 54B  index.js    
          npm notice 212B package.json
          npm notice 36B  README.md   
          npm notice === Tarball Details === 
          npm notice name:          privative-npm                           
          npm notice version:       1.0.0                                   
          npm notice package size:  416 B                                   
          npm notice unpacked size: 302 B                                   
          npm notice shasum:        887836aa4a154902faf31b13e60b8adcdd07b924
          npm notice integrity:     sha512-fyzitNqmif188[...]hFY+zmcIW6qTg==
          npm notice total files:   3                                       
          npm notice 
          + privative-npm@1.0.0
          

          上传成功刷新页面:

          3.3 更新包

          发布新版本时,版本号 version 一定要修改,否则发布会失败,比如:

          $ npm publish
          
          ...
          npm ERR! code EPUBLISHCONFLICT
          npm ERR! publish fail Cannot publish over existing version.
          npm ERR! publish fail Update the 'version' field in package.json and try again.
          npm ERR! publish fail 
          npm ERR! publish fail To automatically increment version numbers, see:
          npm ERR! publish fail     npm help version
          
          npm ERR! A complete log of this run can be found in:
          npm ERR!     /Users/frankie/.npm/_logs/2021-07-14T06_16_31_553Z-debug.log
          

          只是演示更新操作,我只改个版本号。仍然使用 npm publish 发布,刷新页面:

          版本号应该遵循语义化版本原则进行更新。

          对于使用包的角度来说,由于版本控制只是一种约定,每个具体项目的解读可能有所不同,因此你不应盲目信任它。

          3.4 撤销包

          需要注意的是,在 NPM 平台撤销包是严格限制的,不允许随意撤销已发布包,详见 NPM Unpublish Policy

          # 撤销包的某个版本
          $ npm unpublish [<@scope>/]<package>@<version>
          
          # 撤销包
          $ npm unpublish [<@scope>/]<package>
          

          如果你的目的是鼓励用户升级,或者你不想再维护软件包,请考虑使用 deprecate 命令。

          $ npm deprecate <pkg>[@<version>] <message>
          

          我们在安装一些依赖包的时候,不是经常看得到类似的东西吗,就是 deprecate 搞的鬼~

          npm WARN deprecated core-js@1.2.7: core-js@<3.3 is no longer maintained and not recommended for usage due to the number of issues.
          

          四、使用包

          前面我们将 privative-npm 包发布到本地私有的 NPM 服务器下,要安装此包,要切换至 local 镜像源:

          $ nrm use local
          

          创建一个项目,安装 privative-npm 包,写一个脚本执行看到结果符合预期的。

          五、依赖安装流程

          在使用 http://localhost:4873/ 镜像源时,要安装 reactvue 等包,内部是怎么处理的呢?

          首先,正常使用 NPM 安装、共享、发包的流程:

          ▲ 图片源自十三月

          使用 npm 去安装一个包时,先检查 node_modules 目录是否已经缓存了该模块,如果没有便会向 NPM 平台查询。

          NPM 提供了一个模块信息查询服务,通过访问:

          registry.npmjs.org/packaename/version
          

          就可以查到某个发布在 NPM 平台上模块的具体信息,以及下载地址。然后下载并解压到本地完成安装。

          如果我们启用了私有 NPM 服务器,流程又有什么变化呢?

          ▲ 图片源自“十三月”

          Verdaccio 的默认配置文件在 ~/.config/verdaccio/config.yaml

          #
          # 配置文件(这里我删除了一些默认注解)
          # 更多请看: https://github.com/verdaccio/verdaccio/blob/master/packages/config/src/conf/default.yaml
          #
          
          # 上传的所有包存放目录
          storage: ./storage
          # 插件目录
          plugins: ./plugins
          
          # web 服务,即我们可以通过 web 查看我们上传的包。
          web:
            title: Verdaccio
            # 一些关于 web 页面的配置项,我删掉了
          
          # 验证信息
          auth:
            htpasswd:
              # 用户信息存储目录
              file: ./htpasswd
          
          # 公有仓库配置
          uplinks:
            npmjs:
              # 默认
              # url: https://registry.npmjs.org/
              # 可以改成淘宝镜像源
              url: https://registry.npmmirror.com/
          
          packages:
            '@*/*':
              # scoped packages
              access: $all
              publish: $authenticated
              unpublish: $authenticated
              # 代理。当我们安装一些私有服务器上没有的包时,它就会往这里找,即上面的 uplinks 配置
              proxy: npmjs
          
            '**':
              # 三种角色:所有人、匿名用户、认证(登录)用户
              # "$all", "$anonymous", "$authenticated"
          
              # 可访问包角色
              access: $all
          
              # 可发包、撤包角色
              publish: $authenticated
              unpublish: $authenticated
          
              # if package is not available locally, proxy requests to 'npmjs' registry
              proxy: npmjs
          
          # 服务连接活跃时间
          server:
            keepAliveTimeout: 60
          
          middlewares:
            audit:
              enabled: true
          

          六、参考链接

          ]]>
          <![CDATA[nrm 使用详解]]> https://github.com/tofrankie/blog/issues/89 https://github.com/tofrankie/blog/issues/89 Sat, 25 Feb 2023 12:20:16 GMT 配图源自 Freepik

          [!WARNING] 原淘宝 npm 域名即将停止解]]> 配图源自 Freepik

          [!WARNING] 原淘宝 npm 域名即将停止解析,请切换至新域名 npmmirror.comhttp://npm.taobao.org和 http://registry.npm.taobao.org 将在 2022 年 6 月 30 日正式下线和停止 DNS 解析。

          前言

          npm 默认镜像源是 https://registry.npmjs.org/,在国内访问可能会比较慢。

          后来,淘宝做了一个镜像网站(npmmirror)以便国内开发者使用。

          淘宝为什么要提供 npm 镜像?

          使用 npm config 命令可以设置镜像源:

          $ npm config set registry https://registry.npmmirror.com/
          

          但有点长,特别是源地址,不好记。

          nrm

          nrm(NPM registry manager)是 npm 的镜像源管理工具之一。

          全局安装

          $ npm i nrm -g
          

          查看所有源

          $ nrm ls
          
          * npm -------- https://registry.npmjs.org/
            yarn ------- https://registry.yarnpkg.com/
            cnpm ------- http://r.cnpmjs.org/
            taobao ----- https://www.npmmirror.com/
            nj --------- https://registry.nodejitsu.com/
            npmMirror -- https://skimdb.npmjs.com/registry/
            edunpm ----- http://registry.enpmjs.org/
          

          其中 * 号表示当前使用的源。

          也可使用 nrm current 命令查看当前源。

          切换源

          相比之下,nrm use taobao 简直不要太方便了。

          $ nrm use <registry>
          

          其中 <registry> 就是 nrm ls 所列出来的名称。

          切换源之后,仍使用 npm i <package> 的方式进行安装。

          添加源

          适用于企业内部定制的私有源,<registry> 表示源名称,<url> 表示源地址。

          $ nrm add <registry> <url>
          

          比如,在本地使用 Verdaccio 搭建一个私有分发平台,可用 nrm add local http://localhost:4873/ 来添加源。

          删除源

          $ nrm del <registry>
          

          测试源

          $ nrm test <registry>
          

          参考链接

          ]]> <![CDATA[pnpm/yarn/npm 常用命令]]> https://github.com/tofrankie/blog/issues/88 https://github.com/tofrankie/blog/issues/88 Sat, 25 Feb 2023 12:19:23 GMT 配图源自 Freepik

          作个记录。

          pnpm

          配图源自 Freepik

          作个记录。

          pnpm

          官网

          1. 安装 workspace 下的子包

          $ pnpm add <package>@workspace:*
          

          其中 workspace:* 表示该包的最新版本(推荐),也可以用 workspace:^workspace:~ 方式

          除此之外,也可以在项目的 .npmrc 中指定 link-workspace-packages=true,这样就可以直接用 pnpm add <package> 形式,它会优先使用工作区内的包(若有)

          2. 给子包安装依赖

          在子包路径下,像平常那样用便可:

          $ pnpm add <package>
          

          在项目根目录,可以添加 --filter 参数以匹配子包:

          $ pnpm add <package> --filter <subpackage-name>
          

          --filter 还支持模糊匹配,了解更多

          3. 依赖排查

          显示依赖于指定包的所有包(即指定包被谁所依赖):

          $ pnpm why <package>
          

          yarn classic

          官网

          待补充...

          1. 列举全局包

          $ yarn global list
          

          npm

          官网

          1. 升级包

          $ npm update <package>
          

          2. 检查包更新

          $ npm outdated   # 检查当前项目的包
          

          3. 查看包版本

          $ npm view <package> versions   # 查看全部版本
          $ npm view <package> version    # 查看最新版本
          

          4. 更新指定版本

          $ npm install <package>@<version>
          

          5. 卸载包

          $ npm uninstall <package>               # 删除包,但不删除包在 package.json 中的依赖关系
          $ npm uninstall <package> --save        # 删除包,同时删除包在 package.json 中 dependencies 下的依赖关系
          $ npm uninstall <package> --save-dev    # 删除包,同时删除包在 package.json 中 devDependencies 下的依赖关系
          

          6. 列举全局包

          $ npm list -g
          

          7. 其他

          $ npm info <package>                      # 查看包信息
          $ npm view <package> engines              # 查看当前包依赖的 Node 最低版本
          $ npm search <package>                    # 搜索包
          $ npm root                                # 查看当前包的安装路径
          $ npm root -g                             # 查看全局包的安装路径
          $ npm repo <package>                      # 打开包的仓库地址
          $ npm docs <package>                      # 打开包的文档
          $ npm config get registry                 # 获取当前镜像源
          $ npm config set registry <registry-url>  # 获取当前镜像源
          
          ]]>
          <![CDATA[使用 yarn]]> https://github.com/tofrankie/blog/issues/87 https://github.com/tofrankie/blog/issues/87 Sat, 25 Feb 2023 12:18:36 GMT 配图源自 Freepik

          本文以 yarn classic 为例

          配图源自 Freepik

          本文以 yarn classic 为例

          yarn

          # 初始化
          $ yarn init
          
          # 添加包
          $ yarn add <package>
          
          # 依赖项类别,分别对应 devDependencies、peerDependencies、optionalDependencies
          $ yarn add <package> --dev
          $ yarn add <package> --peer
          $ yarn add <package> --optional
          
          # 升级包
          $ yarn upgrade <package>
          
          # 移除包
          $ yarn remove <package>
          
          # 安装依赖,install 可省略
          $ yarn install
          
          # 更新 yarn 本体
          $ yarn set version latest
          $ yarn set version from source
          
          # 查看全局包
          $ yarn global list --depth=0
          
          # 缓存相关
          $ yarn cache list          # 查看缓存列表
          $ yarn cache clean         # 清除缓存
          

          npm vs yarn

          npm (v5) yarn
          npm install yarn add
          (N/A) yarn add --flat
          (N/A) yarn add --har
          (N/A) yarn add --har
          npm install --no-package-lock yarn add --no-lockfile
          (N/A) yarn add --pure-lockfile
          npm install <package> --save yarn add <package>
          npm install <package> --save-dev yarn add <package> --dev
          (N/A) yarn add --peer
          npm install <package> --save-optional yarn add --optional
          npm install <package> --save-exact yarn add --exact
          npm install <package> --global yarn global add <package>
          npm update --global yarn global upgrade
          npm rebuild yarn add --force
          npm uninstall <package> yarn remove <package>
          npm cache clean yarn cache clean <package>
          rm -rf node_modules && npm install yarn upgrade
          npm version major yarn version --mojor
          npm version minor yarn version --minor
          npm version patch yarn version --patch

          参考链接

          ]]>
          <![CDATA[解决 checkPermissions Missing write access to]]> https://github.com/tofrankie/blog/issues/86 https://github.com/tofrankie/blog/issues/86 Sat, 25 Feb 2023 12:16:54 GMT 在使用 npm install 命令时,我们可能会遇到因为没有写访问权限,导致安装失败的情况。

          npm WARN checkPermissions Missing write access to /Users/frankie/Documents/P]]> 在使用 npm install 命令时,我们可能会遇到因为没有写访问权限,导致安装失败的情况。

          npm WARN checkPermissions Missing write access to /Users/frankie/Documents/Project-React/demo/node_modules/decode-uri-component

          尝试删除 node_modules 后重新执行 npm install

          再不行,考虑修改项目目录权限,添加写权限:

          $ chmod -R u+w /path/to/project
          

          Linux 文件权限详解

          ]]> <![CDATA[npm WARN: No repository field.]]> https://github.com/tofrankie/blog/issues/85 https://github.com/tofrankie/blog/issues/85 Sat, 25 Feb 2023 12:15:58 GMT 在使用 npm 安装包时,遇到 npm Warn name@x.x.x No repository field.

          意思是你的 package.json 缺少 repository 仓库字段。可采用以下两种方式解决 WARNING。

          有]]> 在使用 npm 安装包时,遇到 npm Warn name@x.x.x No repository field.

          意思是你的 package.json 缺少 repository 仓库字段。可采用以下两种方式解决 WARNING。

          有两种解法:

          1. 添加仓库地址:
          {
            "repository": {
              "type": "git",
              "url": "http://github.com/xxx/xxx.git"
            }
          }
          
          1. 设为私有(如果该包是不发布的情况下)
          {
            "private": true
          }
          
          ]]>
          <![CDATA[package-lock.json 用处是?]]> https://github.com/tofrankie/blog/issues/84 https://github.com/tofrankie/blog/issues/84 Sat, 25 Feb 2023 12:14:31 GMT 语义化版本

          格式遵循 X.Y.Z,即「主要版本号.次版本号.补丁版本号」,且都不含前导零(详见)。

          • X]]> 语义化版本

            格式遵循 X.Y.Z,即「主要版本号.次版本号.补丁版本号」,且都不含前导零(详见)。

            • X 主要版本更新表示一个破坏兼容性的大变化。
            • Y 次要版本更新表示不会破坏任何内容的新功能。
            • Z 补丁版本更新表示不会破坏任何内容的错误修复。

            注意,以上只是约定,不是一定。

            npm 版本说明

            比如,使用 npm install eslint --save-dev 安装 ESLint。

            {
              "devDependencies": {
                "eslint": "^6.7.1"
              }
            }
            

            关于 6.7.1~6.7.1^6.7.1 的区别:

            • 6.7.1 表示只会安装 6.7.1 的版本。
            • ~6.7.1 表示安装 [6.7.1, 6.8.0) 之间(当前)最新的版本,也就是不改变主要版本和次要版本。
            • ^6.7.1:表示安装 [6.7.1, 7.0.0) 之间(当前)最新的版本,也就是不改变主要版本。

            package-lock.json 的必要性

            当我们使用 npm install 命令时,实际安装的版本与 package.json 中版本有可能是不一致的,可能是 6.8.x6.9.x,因为 npm 的默认方式是 ^

            这样就会导致一个问题:每次安装的 npm 包版本可能都不一样,同时会带来一些风险。

            假设 6.7.1 版本是没问题的,然后最新的 6.8.0 版本有 bug。此时一位新同事把项目拉取下来之后,使用 npm install 安装依赖,其本地是 6.8.0 版本,这时候项目在新同事电脑就可能跑不起来了。这种情况放到生产上就会产生很大的风险。

            如何解决呢?

            package-lock.json 出场了。

            npm install 的输入是 package.json,它的输出是一棵 node_modules 树。理想情况下,npm install 应该像纯函数一样工作,对于同一个 package.json 总是生成完全相同的 node_modules 树。在某些情况下,确实如此。但在其他很多情况中,npm 无法做到这一点。有以下原因:

            • 不同版本的 npm 的安装算法不同。
            • 某些依赖项自上次安装以来,可能已发布了新版本,因此将根据 package.json 中的 semver-range version 更新依赖。
            • 某个依赖项的依赖项可能已发布新版本,即使你使用了固定依赖项说明符(比如 1.2.3 而不是 ^1.2.3 ),它也会更新,因为你无法固定子依赖项的版本。
            {
              "devDependencies": {
                "eslint": "^6.7.1"
              }
            }
            

            而依赖项版本更新可能会带来一些问题,例如:同事 A 新建了一个项目,生成了上面这份 package.json 文件,但同事 A 安装依赖的时间比较早,此时 packageA 的最新版本是 6.7.1,该版本与代码兼容,没有出现 bug。后来同事 B 克隆了同事 A 的项目,在安装依赖时 packageA 的最新版本是 6.8.0,那么根据语义化 npm 会去安装 6.8.0 的版本,但 6.8.0 版本的 API 可能发生了改动,导致代码出现 bug。

            这就是 package.json 会带来的问题,同一份 package.json 在不同的时间和环境下安装会产生不同的结果。

            理论上这个问题是不应该出现的,因为 npm 作为开源世界的一部分,也遵循一个发布原则:相同大版本号下的新版本应该兼容旧版本。即 6.7.1 升级到 6.8.0 时同一 API 不应该发生变化。可一旦开发者并没有严格遵守语义化的原则去更新包,就会导致使用者的。

            为了在不同的环境下生成相同的 node_modulesnpm 使用 package-lock.json。无论何时运行 npm installnpm 都会生成或更新 package-lock.json

            不同 npm 版本下 npm install 的区别

            • npm 5.0.x 版本:不管 package.json 中依赖是否有更新,npm install 都会根据 package-lock.json 下载。针对这种安装策略,有人提出了 issue #16866 ,然后就演变成了 5.1.0 版本后的规则。

            • 5.1.0 版本后:当 package.json 中的依赖项有新版本时,npm install 会无视 package-lock.json 去下载新版本的依赖项并且更新 package-lock.json。针对这种安装策略,又有人提出了 issue #17979,参考 npm 贡献者 iarna 的评论,得出 5.4.2 版本后的规则。

            • 5.4.2 版本后:
              • 如果只有一个 package.json 文件,运行 npm install 会根据它生成一个 package-lock.json 文件,这个文件相当于本次 install 的一个快照,它不仅记录了 package.json 指明的直接依赖的版本,也记录了间接依赖的版本。

              • 如果 package.json 的 semver-range version 和 package-lock.json 中版本兼容(package-lock.json版本在package.json指定的版本范围内),即使此时 package.json 中有新的版本,执行 npm install 也还是会根据 package-lock.json 下载(实践场景一)。

              • 如果手动修改了 package.jsonversion ranges,且和 package-lock.json 中版本不兼容,那么执行 npm ipackage-lock.json 将会更新到兼容 package.json 的版本(实践场景二)。

            注意:npm install 读取 package.json 创建依赖项列表,并使用 package-lock.json 来通知要安装这些依赖项的哪个版本。如果某个依赖项在 package.json 中,但是不在 package-lock.json 中,运行 npm install 会将这个依赖项的确定版本更新到 package-lock.json 中,不会更新其它依赖项的版本。

            场景一

            package.json

            {
              "dependencies": {
                "eslint": "^6.7.1"
              }
            }
            

            package-lock.json

            {
              "eslint": {
                "version": "6.8.0",
                "resolved": "https://registry.npm.taobao.org/eslint/download/eslint-6.8.0.tgz?cache=0&sync_timestamp=1595098436203&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Feslint%2Fdownload%2Feslint-6.8.0.tgz",
                "integrity": "sha1-YiYtZylzn5J1cjgkMC+yJ8jJP/s="
              }
            }
            

            这种情况下 package-lock.json 指定的 6.8.0^6.7.1 指定的范围内,npm install 会安装 6.8.0 版本,且不会更新 package.jsonpackage-lock.json 记录。

            场景二

            package.json

            {
              "dependencies": {
                "eslint": "^6.8.0"
              }
            }
            

            package-lock.json

            {
              "eslint": {
                "version": "6.7.1",
                "resolved": "https://registry.npm.taobao.org/eslint/download/eslint-6.7.1.tgz?cache=0&sync_timestamp=1595098436203&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Feslint%2Fdownload%2Feslint-6.7.1.tgz",
                "integrity": "sha1-JpzMzsPvYKsyNYpE0UesIJFUuRk="
              }
            }
            

            这种情况下 package-lock.json 指定的 6.7.1 不在 ^6.8.0 指定的范围内,npm install 会按照 ^6.8.0 的规则去安装最新的 6.8.0 版本,并且将 package-lock.json 的版本更新为 6.8.0

            npm ci

            介绍

            • ci:Continuous Integration。
            • npm 版本至少是 v5.7.1
            • 此命令与 npm install 类似,不同之处在于它旨在用于自动化环境,例如集成测试环境、线上环境、或者您希望确保干净安装依赖项的任何情况。通过跳过某些面向用户的功能,它可以比常规的 npm install 快得多。它也比常规安装更严格,它可以捕获由于本地环境的增量安装引起的错误或不一致。
            • npm ci 是根据 package-lock.json 去安装确定的依赖,package.json 只是用来验证是不是有不匹配的版本,假设 package-lock.json 中存在一个确定版本的依赖 A,如果 package.json 中不存在依赖 A 或者依赖 A 版本和 package-lock.json 中不兼容,npm ci 就会报错。

            npm ci 和 npm install 差异

            • 项目必须存在 package-lock.jsonnpm-shrinkwrap.json
            • 如果 package-lock.json 中的依赖和 package.json 中不匹配,npm ci 会退出并且报错,而不是去更新 package-lock.json
            • npm ci 只能安装整个项目的依赖,无法安装单个依赖。
            • 如果 node_modules 已经存在,它将在 npm ci 开始安装之前自动删除。 npm ci 永远不会改变 package.jsonpackage-lock.json

            npm 和 cnpm 的差异

            • cnpm i 不受 package-lock.json 影响,只会根据 package.json 进行下载。
            • cnpm i xxx@xxx 不会跟新到 package-lock.json 中去。
            • npm i xxx@xxx 会跟新到 package-lock.json 中去。

            参考链接

            ]]>
            <![CDATA[语义化版本 SemVer]]> https://github.com/tofrankie/blog/issues/83 https://github.com/tofrankie/blog/issues/83 Sat, 25 Feb 2023 12:13:15 GMT 配图源自 Freepik

            [!NOTE] 由于版本控制只是一种约定,每个具体项]]> 配图源自 Freepik

            [!NOTE] 由于版本控制只是一种约定,每个具体项目的解读可能有所不同,因此你不应盲目信任它。

            简介

            SemVer 名为语义化版本,全称 Semantic Versioning。

            SemVer 格式如下,均是(不含前导零的)非负整数。例如 1.2.52.0.0 等。

            MAJOR.MINOR.PATCH
            
            • MAJOR 主版本号
            • MINOR 次版本号
            • PATCH 补丁版本

            更新约定:

            • 主版本号更新:表示一个破坏兼容性的大变化。
            • 次版本号更新:表示不会破坏任何内容的新功能。
            • 补丁版本号更新:表示不会破坏任何内容的错误修复。

            也就是说,向后不兼容的 API 变更应增加主要版本;向后兼容的 API 添加或更改增加次要版本;错误修复不影响 API 应增加补丁版本。

            npm 版本范围

            npm 默认的版本范围前缀是 ^

            在忽略 lock 文件作用的前提下,执行 npm install 时实际安装的版本如下:

            1.x 及以上版本

            • 1.2.5:将会安装 1.2.5 的版本。
            • ~1.2.5:将会安装 [1.2.5, 1.3.0) 之间最新的版本。
            • ^1.2.5:将会安装 [1.2.5, 2.0.0) 之间最新的版本。

            0.x 版本

            对于 0.x 的版本,npm 将其视为不稳定的版本,安装策略会更保守。

            • ^0.2.5:将会安装 [0.2.5, 0.3.0) 之间最新的版本。

            除了常见的 ^~ 之外,npm 还支持 >>=更多版本范围。

            扩展阅读

            参考链接

            ]]> <![CDATA[为什么不推荐使用 cnpm?]]> https://github.com/tofrankie/blog/issues/82 https://github.com/tofrankie/blog/issues/82 Sat, 25 Feb 2023 12:12:18 GMT 配图源自 Freepik

            在前端项目构建的过程中,npmyar]]> 配图源自 Freepik

            在前端项目构建的过程中,npmyarn 应该是当前使用得最频繁的包管理工具了,他们帮助我们解决了复杂的依赖关系。在使用 npm 下载包时,由于是从国外的 NPM 服务器上下载,导致有时安装第三方包到本地时很慢,很蛋疼吧。

            于是就有了 cnpm,这是淘宝团队做的淘宝 NPM 镜像。你可以放心地用 cnpm 来代替 npm,因为它同步频率为 10 分钟一次,以保证尽量与官方服务同步。

            如果需要马上更新,cnpm 提供了一个特有命令 cnpm sync <pkg-name> 来同步某个模块。

            [!IMPORTANT] 原淘宝 npm 域名即将停止解析,请切换至新域名 npmmirror.comhttp://npm.taobao.org和 http://registry.npm.taobao.org 将在 2022.06.30 号正式下线和停止 DNS 解析。

            相关域名切换参考如下:

            http://npm.taobao.org => https://npmmirror.com
            http://registry.npm.taobao.org => https://registry.npmmirror.com
            

            一、使用 cnpm(不推荐)

            使用 cnpm 命令替代 npm,安装非常地简单。

            它支持 npm 除了 npm publish 之外的所有命令。

            1. 安装
            $ npm i -g cnpm --registry=https://registry.npmmirror.com/
            
            1. 测试是否成功安装
            $ cnpm -v
            
            1. 安装模块
            $ cnpm install [name]
            

            二、替换 npm 镜像源

            尽管我们安装并使用 cnpm,但是有一些命令(如 create-react-app 等)它们内部还是使用了 npm 命令,仍是访问国外的镜像源,所以还是会慢。

            首先,通过以下命令可以查看或设置 npm 的源地址:

            # 查看 registry
            $ npm config get registry
            
            # 设置 registry
            $ npm config set registry <registry-url>
            

            下面我们将其修改为淘宝镜像源:

            $ npm config set registry https://registry.npmmirror.com/
            

            也可以直接在配置文件 ~/.npmrc 中添加一行配置并保存:

            registry=https://registry.npm.taobao.org/
            

            三、安装依赖包时,直接指定镜像源

            这种方式太麻烦了,每次都要加上镜像源地址。

            $ npm i <package> --registry=https://registry.npmmirror.com
            

            四、为什么不推荐 cnpm 呢?

            首先,使用 npm 作为前端项目包管理工具的话,使用 npmcnpm 来安装包时有区别的。通常我们前端项目中都会有 package-lock.json 文件(其作用可看文章),这两个命令对其有一定的影响:

            • cnpm i不受 package-lock.json 影响,只会根据 package.json 进行下载安装。
            • cnpm i xxx@xxx不会跟新到 package-lock.json 中去。
            • npm i xxx@xxx 会跟新到 package-lock.json 中去。

            在多人共同协作维护的项目中,package-lock.json 是必不可少的,是为了确保不同开发者安装的包及其依赖保持一致,同时也是降低不同版本 npm 包给项目稳定性带来的影响。尤其是一些不遵循语义化版本控制的第三方 npm 包,就很容易被坑到。

            五、用更好的方式吗?

            通常,本人在项目中使用得更多的是 yarn classic。管理镜像源推荐使用 nrmyrm,可以快速切换镜像源。前者仅会修改 npm 镜像源,后者会同时修改 npmyarn 镜像源。

            另外,你有尝试过 pnpmcorepack 吗?

            关于一些全局依赖包,个人使用习惯是:与 Node 版本有关的使用 npm 安装(比如 Taro CLI 等)并配合 nvm 工具进行管理,其他的一般使用 yarn 进行安装。

            管理 Node 版本推荐使用 nvm 工具,但请注意,如果使用了 nvm 建议移除 ~/.npmrc 中的 prefix 配置项(如果有的话),否则全局包可能会安装到同一目录下,而不会随着 Node 的切换而变化(原因看这里)。

            The end.

            ]]>
            <![CDATA[Mac 解决 gyp: No Xcode or CLT version detected! 报错]]> https://github.com/tofrankie/blog/issues/81 https://github.com/tofrankie/blog/issues/81 Sat, 25 Feb 2023 12:11:14 GMT 背景

            我的系统版本是 macOS Catalina 10.15.5

            最近在执行命令 cnpm install 时,会出现如下报错:

            No rec]]>
                        背景
            

            我的系统版本是 macOS Catalina 10.15.5

            最近在执行命令 cnpm install 时,会出现如下报错:

            No receipt for 'com.apple.pkg.CLTools_Executables' found at '/'.
            
            No receipt for 'com.apple.pkg.DeveloperToolsCLILeo' found at '/'.
            
            No receipt for 'com.apple.pkg.DeveloperToolsCLI' found at '/'.
            
            gyp: No Xcode or CLT version detected!
            
            gyp ERR! configure error 
            gyp ERR! stack Error: `gyp` failed with exit code: 1
            gyp ERR! stack     at ChildProcess.onCpExit (/usr/local/lib/node_modules/cnpm/node_modules/_node-gyp@3.8.0@node-gyp/lib/configure.js:345:16)
            gyp ERR! stack     at ChildProcess.emit (events.js:210:5)
            gyp ERR! stack     at Process.ChildProcess._handle.onexit (internal/child_process.js:272:12)
            gyp ERR! System Darwin 19.5.0
            gyp ERR! command "/usr/local/bin/node" "/usr/local/lib/node_modules/cnpm/node_modules/_npminstall@3.27.0@npminstall/node-gyp-bin/node-gyp.js" "rebuild"
            gyp ERR! cwd /Users/frankie/Web/ReactDemo/node_modules/_fsevents@1.2.13@fsevents
            gyp ERR! node -v v12.13.1
            gyp ERR! node-gyp -v v3.8.0
            gyp ERR! not ok 
            

            解决方法

            网上搜查了一番,应该是 XCode 的问题,重新安装 CommandLineTools

            1. 删除已经安装的 CommandLineTools
            $ sudo rm -rf $(xcode-select -p)
            
            1. 重新安装,然后同意条款选择
            $ sudo xcode-select --install
            
            1. 重试,一个 ERROR 和 WARNING 都没有
            $ cnpm install
            ✔ Installed 54 packages
            ✔ Linked 0 latest versions
            ✔ Run 0 scripts
            ✔ All packages installed (used 114ms(network 102ms), speed 0B/s, json 0(0B), tarball 0B)
            

            我猜测原因可能是,最近从 10.14 升级到 10.15,然后因为部分第三方插件需要安装过 CommandLineTools,没有打开过该工具同意条款,然后出现这种问题的吧。(纯猜测未证实)

            参考链接

            ]]>
            <![CDATA[Cannot find module 'webpack-cli/bin/config-yargs']]> https://github.com/tofrankie/blog/issues/80 https://github.com/tofrankie/blog/issues/80 Sat, 25 Feb 2023 12:09:43 GMT 配图源自 Freepik

            今天创建了一个新项目

            今天创建了一个新项目

            {
              "scripts": {
                "start": "webpack-dev-server",
                "build": "webpack --progress --colors"
              },
              "devDependencies": {
                "webpack": "^5.42.0",
                "webpack-cli": "^4.7.2",
                "webpack-dev-server": "^3.11.2"
              }
            }
            

            然而启动项目时,却报错了:

            Error: Cannot find module 'webpack-cli/bin/config-yargs'

            $ yarn start
            yarn run v1.22.10
            $ webpack-dev-server
            internal/modules/cjs/loader.js:883
              throw err;
              ^
            
            Error: Cannot find module 'webpack-cli/bin/config-yargs'
            Require stack:
            - /Users/frankie/Desktop/Web/React/react-ts/node_modules/webpack-dev-server/bin/webpack-dev-server.js
                at Function.Module._resolveFilename (internal/modules/cjs/loader.js:880:15)
                at Function.Module._load (internal/modules/cjs/loader.js:725:27)
                at Module.require (internal/modules/cjs/loader.js:952:19)
                at require (internal/modules/cjs/helpers.js:88:18)
                at Object.<anonymous> (/Users/frankie/Desktop/Web/React/react-ts/node_modules/webpack-dev-server/bin/webpack-dev-server.js:65:1)
                at Module._compile (internal/modules/cjs/loader.js:1063:30)
                at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)
                at Module.load (internal/modules/cjs/loader.js:928:32)
                at Function.Module._load (internal/modules/cjs/loader.js:769:14)
                at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12) {
              code: 'MODULE_NOT_FOUND',
              requireStack: [
                '/Users/frankie/Desktop/Web/React/react-ts/node_modules/webpack-dev-server/bin/webpack-dev-server.js'
              ]
            }
            error Command failed with exit code 1.
            info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
            

            然后搜查了一番,有部分人是删除了 node_modules,重新 install 可以跑起来,但这是不对的。

            解决方法:

            Install the lastest webpack-cli and use webpack serve to run webpack dev server instead webpack-dev-server. webpack/webpack-dev-server #3304

            安装最新的 webpack-cli,使用 webpack serve 来代替 webpack-dev-server

            {
              "scripts": {
                "start": "webpack serve",
                "build": "webpack --progress"
              },
              "devDependencies": {
                "webpack": "^5.42.0",
                "webpack-cli": "^4.7.2",
                "webpack-dev-server": "^3.11.2"
              }
            }
            

            改完就能愉快地玩耍了...

            ]]> <![CDATA[No module factory available for dependency type: ModuleHotAcceptDependency]]> https://github.com/tofrankie/blog/issues/78 https://github.com/tofrankie/blog/issues/78 Sat, 25 Feb 2023 12:09:19 GMT 今天在优化 Webpack 配置时,使用到 speed-measure-webpack-plugin 来测算 Webpack 各项打包速度。

            const SpeedMeasurePlugin = require('sp]]>
                        今天在优化 Webpack 配置时,使用到 speed-measure-webpack-plugin 来测算 Webpack 各项打包速度。

            const SpeedMeasurePlugin = require('speed-measure-webpack-plugin')
            const smp = new SpeedMeasurePlugin()
            
            const webpackConfig = {
              // ...
            }
            
            module.exports = smp.wrap(webpackConfig)
            

            在初次启动项目的时候,可以成功编译。可就在热更新的时候,就报了以下错误:

            ℹ 「wdm」: Compiling...
            ✖ 「wdm」: Error: No module factory available for dependency type: ModuleHotAcceptDependency
                at addDependency (/Users/frankie/Desktop/Web/company-git/pro234/react-demo/node_modules/webpack/lib/Compilation.js:800:12)
                at iterationOfArrayCallback (/Users/frankie/Desktop/Web/company-git/pro234/react-demo/node_modules/webpack/lib/Compilation.js:208:3)
                at addDependenciesBlock (/Users/frankie/Desktop/Web/company-git/pro234/react-demo/node_modules/webpack/lib/Compilation.js:816:5)
                at Compilation.processModuleDependencies (/Users/frankie/Desktop/Web/company-git/pro234/react-demo/node_modules/webpack/lib/Compilation.js:827:4)
                at afterBuild (/Users/frankie/Desktop/Web/company-git/pro234/react-demo/node_modules/webpack/lib/Compilation.js:954:15)
                at processTicksAndRejections (internal/process/task_queues.js:75:11)
            /Users/frankie/Desktop/Web/company-git/pro234/react-demo/node_modules/neo-async/async.js:16
                throw new Error('Callback was already called.');
                ^
            

            似乎是使用了 speed-measure-webpack-plugin 导致的,移除即可。

            但如果同时该插件如何解决报错,暂时没找到解决方案,若后续有解决方案会回来更新的。

            相关链接:

            ]]>
            <![CDATA[如何在本地运行 webpack 打包后的文件]]> https://github.com/tofrankie/blog/issues/77 https://github.com/tofrankie/blog/issues/77 Sat, 25 Feb 2023 12:08:36 GMT 如果想着本地打开 webpack 打包后的项目,可以利用 express 搭建本地服务器。

            1. 安装 express-generator
            $ npm i express-generator -g
            
                        如果想着本地打开 webpack 打包后的项目,可以利用 express 搭建本地服务器。

            1. 安装 express-generator
            $ npm i express-generator -g
            
            1. 创建 express 项目
            $ express my-express-project
            
            1. 将打包(一般是 distbuild)后的所有文件复制到 public 文件夹下。

            2. 启动项目

            $ cd my-express-project
            $ npm install
            $ npm run start
            
            1. 在浏览器打开 http://localhost:3000
            ]]>
            <![CDATA[webpack 术语之 module、chunk 和 bundle]]> https://github.com/tofrankie/blog/issues/76 https://github.com/tofrankie/blog/issues/76 Sat, 25 Feb 2023 12:08:12 GMT 概念术语
            • Module:提供比完整程序接触面(surface area)更小的离散功能块。精心编写的模块提供了可靠]]> 概念术语

              • Module:提供比完整程序接触面(surface area)更小的离散功能块。精心编写的模块提供了可靠的抽象和封装界限,使得应用程序中每个模块都具有条理清楚的设计和明确的目的。

              • Chunk:这是 webpack 特定的术语被用在内部来管理 building 过程。bundle 由 chunk 组成,其中有几种类型(例如,入口 chunk(entry chunk)和子 chunk(child chunk))。通常 chunk 会直接对应所输出的 bundle,但是有一些配置并不会产生一对一的关系。

              • Bundle:由多个不同的模块生成,bundles 包含了早已经过加载和编译的最终源文件版本。

              这些官话,看着会云里雾里吗?

              通俗解释

              上来先仍一张图:

              ▲ 图片源自“卤蛋实验室”文章

              • Module:对于 webpack 来说,项目源码中所有资源(包括 JS、CSS、Image、Font 等等)都属于 module 模块。可以配置指定的 Loader 去处理这些文件。

              • Chunk:当使用 webpack 将我们编写的源代码进行打包时,webpack 会根据文件引用关系生成 chunk 文件,webpack 会对这些 chunk 文件进行一些操作。

              • Bundle:webpack 处理完 chunk 文件之后,最终会输出 bundle 文件,这个 bundle 文件包含了经过加载和编译的最终产物。

              简单来说:modulechunkbundle 其实就是同一份代码在不同转换场景取的三个名称。我们编写的是 module,webpack 处理时是 chunk,最终生成供浏览器允许的是 bundle

              参考链接

              ]]> <![CDATA[给 webpack-dev-server 提了个 bug]]> https://github.com/tofrankie/blog/issues/75 https://github.com/tofrankie/blog/issues/75 Sat, 25 Feb 2023 12:05:16 GMT 背景是这样的,最近在全面整理 webpack 的配置项,为了加深理解和印象,会一个一个地选项去验证。今天看到 resolve 模块,然后发现了一个问题,所以有了 背景是这样的,最近在全面整理 webpack 的配置项,为了加深理解和印象,会一个一个地选项去验证。今天看到 resolve 模块,然后发现了一个问题,所以有了 Modify webpack's resolve.mainFiles, resulting in an error when using webpack-dev-server #2801

              最终开发人员表示这确实是 bug,将在未来发布的 v4 版本修复。


              • Operating System: macOS 10.15.7
              • Node Version: 12.12.0
              • NPM Version: 6.13.3
              • webpack Version: 4.41.2
              • webpack-dev-server Version: 3.9.0
              • Browser: It has nothing to do with the browser (Chrome)
              • [x] This is a bug
              • [ ] This is a modification request

              Code

              // webpack.config.js
              module.exports = {
                // ...
                resolve: {
                  mainFiles: ['fortest'] // Any name, as long as it is not 'index'
                }
              }
              
              // index.js(entry)
              // The directory of components/button contains the fortest.js file
              import Button from './components/button'
              

              Expected Behavior

              The resolve.mainFiles to be used while resolving directories.

              When I import the button component as above, there should be no errors.

              Actual Behavior

              But the actual behavior is not the expected result, and the build fails with the following prompt:

              ERROR in (webpack)-dev-server/client?http://localhost:8080
              Module not found: Error: Can't resolve 'strip-ansi' in '/Users/frankie/Desktop/Web/Demo/Temp/temp_webpack/node_modules/webpack-dev-server/client'
               @ (webpack)-dev-server/client?http://localhost:8080 6:16-37
               @ multi (webpack)-dev-server/client?http://localhost:8080 (webpack)/hot/dev-server.js ./src/index.js
              

              Guess

              According to the error message, I found...

              // node_modules/webpack-dev-server/client/index.js
              // ...
              var stripAnsi = require('strip-ansi');
              // ...
              
              // node_modules/strip-ansi/index.js
              // ...
              var ansiRegex = require('ansi-regex')();
              // ...
              

              I found the package.json of the two dependency packages strip-ansi and ansi-regex does not contain the main attribute, so it should look for index.js according to the default configuration of webpack. But now because I modified the resolve.mainFiles configuration to fortest, it should be fortest.js when parsing, but there is no fortest.js file for the two dependent packages. So an error was reported.

              To verify my guess, I tried to modify it to:

              // node_modules/webpack-dev-server/client/index.js
              var stripAnsi = require('strip-ansi/index');
              
              // node_modules/strip-ansi/index.js
              var ansiRegex = require('ansi-regex/index')();
              

              After the modification, this can be parsed normally without an error. The button component can also be imported as expected

              For Bugs; How can we reproduce the behavior?

              A very simple configuration

              // webpack.config.js
              module.exports = {
                entry: './src/index.js'
                devServer: {
                  contentBase: './dist',
                  hot: true,
                  open: true
                },
                resolve: {
                  mainFiles: ['fortest'],
                },
                // Other irrelevant configuration...
              }
              
              // package.json
              {
                "name": "temp_webpack",
                "version": "1.0.0",
                "description": "temp demo",
                "license": "MIT",
                "scripts": {
                  "dev": "webpack-dev-server --config webpack.config.js --colors"
                },
                "devDependencies": {
                  "clean-webpack-plugin": "3.0.0",
                  "css-loader": "3.2.0",
                  "file-loader": "5.0.2",
                  "html-webpack-plugin": "3.2.0",
                  "style-loader": "1.0.0",
                  "webpack": "4.41.2",
                  "webpack-cli": "3.3.10",
                  "webpack-dev-server": "3.9.0"
                }
              }
              

              webpack allows us to modify the resolve.mainFiles configuration to specify the file name to be used when resolving the directory. But when I modify it, webpack-dev-server can't work normally.

              Is this caused by my configuration error? Or is it a bug?

              Thanks,

              ]]>
              <![CDATA[webpack 中极容易混淆的 path、publicPath、contentBase 配置]]> https://github.com/tofrankie/blog/issues/74 https://github.com/tofrankie/blog/issues/74 Sat, 25 Feb 2023 12:03:51 GMT 先说结论

              最近在写一个 Webpack + React 的 Demo,关于涉及路径参数配置很容易混淆,所以写这篇文章整理一下。

              以下关于 webpack-dev-server 会简写成 dev-server 或者 devServer。

              总结:

                先说结论

                最近在写一个 Webpack + React 的 Demo,关于涉及路径参数配置很容易混淆,所以写这篇文章整理一下。

                以下关于 webpack-dev-server 会简写成 dev-server 或者 devServer。

                总结:

                • output.path:表示 output 目录对应的一个绝对路径。
                • output.publicPath:表示打包生成的 index.html 文件里面引用资源的前缀。
                • devServer.publicPath:表示打包生成的静态文件所在的位置,若 devServer.publicPath 没有设置,则会取 output.publicPath 的值。
                • devServer.contentBase:表示服务器从哪里提供内容。一般只有在提供静态文件时才需要。

                基本配置

                写一个简单的项目,没有过多的东西,足以说明本文的几个配置项即可,目录结构如下:

                my-project
                  src
                    index.css // 仅设置了一个背景图片
                    index.html // 简单的一个 HTML 作为模板而已,随意写个 div 标签啥的就 OK 了
                    index.js // 仅引用了 index.css
                  package.json
                  webpack.config.js
                

                项目里面 index.js、index.css、index.html 里面其实没什么内容的,就如上面备注一样而已。

                // package.json
                {
                  "scripts": {
                    "build": "webpack",
                    "dev": "webpack-dev-server"
                  }
                }
                
                // webpack.config.js
                const path = require('path')
                const webpack = require('webpack')
                const HtmlWebpackPlugin = require('html-webpack-plugin')
                const MiniCssExtractPlugin = require('mini-css-extract-plugin')
                const { CleanWebpackPlugin } = require('clean-webpack-plugin')
                
                module.exports = {
                  entry: './src/index.js',
                  output: {
                    filename: 'bundle.js',
                    path: path.resolve(__dirname, 'dist')
                  },
                  devServer: {
                    open: true
                  },
                  plugins: [
                    new HtmlWebpackPlugin({
                      title: 'webpack demo',
                      template: './src/index.html',
                      filename: 'index.html',
                      inject: true,
                      hash: false
                    }),
                    new CleanWebpackPlugin(),
                    new MiniCssExtractPlugin({
                      filename: 'style.css'
                    })
                  ],
                  module: {
                    rules: [
                      {
                        test: /\.css$/,
                        exclude: /node_modules/,
                        use: [MiniCssExtractPlugin.loader, 'css-loader']
                      },
                      {
                        test: /\.(png|jpg|gif)$/,
                        loader: 'file-loader',
                        options: {
                          name: '[name].[ext]'
                        }
                      }
                    ]
                  }
                }
                

                1. output.path

                默认值:path.join(process.cwd(), 'dist')

                指定输出文件的目标路径。它是一个绝对路径,默认是项目根目录下的 dist 路径。项目中经常会看到如下配置:

                output: {
                  path: path.resolve(__dirname, 'dist')
                }
                

                简单地说,就是运行 yarn run build 命令,webpack 将项目打包之后的文件(如 index.html、bundle.js、图片等)输出到该目录。这个还是比较好理解的。

                2. output.publicPath

                默认值:''(空字符串)

                output.publicPath 常用于在生产环境。它会为所有的资源指定一个基础路径,它被称为公共路径。

                如何理解?

                这里所说的所有资源的基础路径是指项目中引用 CSS、JS、Image 等资源时候的一个基础路径。这个基础路径要配合具体资源中指定的路径使用,所以打包后的资源的访问可以用如下公式表示:

                静态资源最终访问路径 = output.publicPath + 资源 loader 或插件等配置路径

                这个最终静态资源访问路径在使用 html-webpack-plugin 打包后得到的 html 中可以看到。如果 output.publicPath 设置成相对路径后,相对路径是相应地打包后的 html 的。

                假设 output.publicPath 设置成了 './dist/',那么打包后的 JS 引用路径为 ./dist/main.js。这里会存在一个问题,相对路径在本地能正常访问到。但是如果把静态资源托管到 CDN 上,访问路径显然不能使用相对路径的。如果设置成 '/dist/',则打包后的访问路径是 localhost:8080/dist/main.js,此时本地无法访问。一般解决方法就是利用 webpack.DefinePlugin 来定义一个全局变量(process.env.NODE_ENV)区分开发、生产环境来设定不同的值,或者是采用两份不同的配置文件来进行打包。

                一般来说,output.publicPath 应该以 '/' 结尾,而其他 loader 或插件的配置不要以 '/' 开头。

                案例:

                output.publicPath = '/dist/'
                
                
                // 案例一(image file-loader)
                options: {
                  name: 'img/[name].[ext]'
                }
                // 最终路径:output.publicPath + 'img/[name].[ext]' = '/dist/img/[name].[ext]'
                
                
                // 案例二(js output.filename)
                output: {
                  filename: '[name].js'
                }
                // 最终路径:output.publicPath + '[name].js' = '/dist/[name].js'
                
                
                // 案例三(extract-text-webpack-plugin css)
                new ExtractTextPlugin({
                  filename: 'style.[chunkhash].css'
                })
                // 最终路径:output.publicPath + 'style.[chunkhash].css' = '/dist/style.[chunkhash].css'
                

                我们修改一下 output.publicPath 配置,如下:

                // webpack.config.js
                const path = require('path')
                module.exports = {
                  output: {
                    filename: 'bundle.js',
                    path: path.resolve(__dirname, 'dist'),
                    publicPath: '/outputDir/'
                  }
                }
                

                运行 yarn run dev 命令,可以看到命令行显示如下信息:

                ℹ 「wds」: Project is running at http://localhost:8080/
                ℹ 「wds」: webpack output is served from /outputDir/
                

                然后,访问 http://localhost:8080/ 结果如下:

                访问 http://localhost:8080/outputDir/ 结果如下:

                根据上面两张图可以看出,设置 output.publicPath 后,如果 devServer.publicPath 没有设置,那么使用 webpack-dev-server 进行打包时生成的静态文件所在的位置以及 index.html 文件里面引用资源的前缀都是 output.publicPath 里面设置的值。

                3. devServer.publicPath

                默认值:'/'

                插句话,斜杠 / 的含义表示 URL 的根路径,例如 http://localhost:8080/dist/main.js 中的 http://localhost:8080/

                在开发过程中,我们借用 webpack-dev-server 启动一个开发服务器,我们一般也会配置一个 devServer.publicPath,这里的 devServer.publicPath 路径下的打包文件可以在浏览器中访问。而静态资源仍然使用 output.publicPath

                webpack-dev-server 打包的内容是放在内存中的,这些打包后的资源对外的根目录就是 devServer.publicPath,换句话说,这里我们设置的是打包后资源存放的位置。

                假设 devServer.publicPath = '/dist/'
                那么,启动 webpack-dev-server 后 index.html 路径为 publicPath + index.html
                那么,启动 webpack-dev-server 后 main.js 路径为 publicPath + main.js
                

                以上这些,通过访问 http://localhost:8080/webpack-dev-server 可以看到启动后的资源访问路径。点击里面的静态资源文件可以看到路径为 http://localhost:8080${publicPath}index.html

                接着,我们修改一下 devServer.publicPath 的配置,如下:

                // webpack.config.js
                module.exports = {
                  output: {
                    filename: 'bundle.js',
                    path: path.resolve(__dirname, 'dist'),
                    publicPath: '/outputDir/'
                  },
                  devServer: {
                    publicPath: '/assets/'
                    open: true
                  }
                }
                

                执行 yarn run dev 命令 ,命令行显示如下信息(我们看到跟此前不一样了):

                ℹ 「wds」: Project is running at http://localhost:8080/
                ℹ 「wds」: webpack output is served from /assets/
                

                然后,访问 http://localhost:8080/ 结果如下:

                访问 http://localhost:8080/assets/ 结果如下:

                我们发现 JS 和 CSS 文件的引用路径还是没变,但是我们发现页面的背景图片没有了,因为它报错了,CSS 和 JS 都找不到,如下:

                可以看出,devServer.publicPath 表示打包生成的静态文件所在的位置。并且它的优先级是最高的。而 output.publicPath 表示 index.html 文件里面引用资源的前缀。

                4. devServer.contentBase

                默认值:process.cwd()(即当前工作目录)

                只有在你想要提供静态文件时才需要。

                接着,添加 devServer.contentBase 配置,如下:

                // webpack.config.js
                module.exports = {
                  output: {
                    filename: 'bundle.js',
                    path: path.resolve(__dirname, 'dist'),
                    publicPath: '/outputDir/'
                  },
                  devServer: {
                    contentBase: './aaa',
                    publicPath: '/assets/',
                    open: true
                  }
                }
                

                执行 yarn run dev 命令,命令行显示信息如下:

                ℹ 「wds」: Project is running at http://localhost:8080/
                ℹ 「wds」: webpack output is served from /assets/
                ℹ 「wds」: Content not from webpack is served from ./aaa
                

                可以发现有一条是:Content not from webpack is served from ./aaa,可以看出 devServer.contentBase 指的是,不由 webpack 打包生成的静态文件

                访问 http://localhost:8080/ 结果如下:

                因为 http://localhost:8080/ 下并没有 aaa 目录,所以根本找不到。而前面没有设置 devServer.contentBase 的时候,会使用 contentBase 的默认值(当前执行的目录,即项目根目录)。在访问 http://localhost:8080/ 时,由于在根目录下没有找到 index.html 文件,因此会显示根目录下的资源文件。

                访问 http://localhost:8080/assets/,结果如下:

                可见,devServer.contentBase 与打包生成的静态文件所在的位置和 index.html 里面引用资源的前缀是没有影响的。

                接着,我们再修改一下 devServer.contentBase 的配置,将其设置为 src 目录,而该目录下是有我们编写的 index.html 模板文件的。

                // webpack.config.js
                module.exports = {
                  output: {
                    filename: 'bundle.js',
                    path: path.resolve(__dirname, 'dist'),
                    publicPath: '/outputDir/'
                  },
                  devServer: {
                    contentBase: './src',
                    publicPath: '/assets/',
                    open: true
                  }
                }
                

                访问 http://localhost:8080/,结果如下:

                可以看出,访问的是我们本地编写的 index.html 文件。请注意,这个不是 webpack 打包生成的 index.html 文件。

                5. html-webpack-plugin

                这个插件用于将 CSS 和 JS 添加到 HTML 模板中,其中 template 和 filename 会受到路径的影响。

                template

                作用是用于定义模板文件的路径。

                // 源码
                this.options.template = this.getFullTemplatePath(this.options.template, compiler.context)
                

                因此,template 只有定义在 webpack 的 context 才会被识别,webpack 的 context 默认值为 process.cwd(),即运行 node 命令时所在的文件夹的绝对路径。

                filename

                作用是输出的 HTML 文件名,默认为 index.html,可以直接配置带有子目录。

                // 源码
                this.options.filename = path.relative(compiler.options.output.path, filename)
                

                所以 filename 的路径是相当于 output.path 的,而在 webpack-dev-server 中,则是相当于 devServer.publicPath 的。

                如果 devServer.publicPathoutput.publicPath 不一致,在使用 html-webpack-plugin 可能会导致引用静态资源失败,因为在 devServer 中仍然以 output.publicPath 引用静态资源的,当跟 webpack-dev-server 的提供的资源访问路径不一致,从而无法正常访问。

                有一种情况除外,就是 output.publicPath 是相对路径,这时候可以访问本地资源。

                所以一般情况下,都要保证 devServer.publicPathoutput.publicPath 保持一致。

                参考链接

                ]]>
                <![CDATA[__webpack_hmr 404 (Not Found)]]> https://github.com/tofrankie/blog/issues/73 https://github.com/tofrankie/blog/issues/73 Sat, 25 Feb 2023 12:01:52 GMT 今天在调试 webpack-dev-serverwebpack-dev-middleware 的时候,突然发现了控制台出现了一个错误提示:

                GET http://localhost:8080/__webpack_hmr 404]]>
                            今天在调试 webpack-dev-serverwebpack-dev-middleware 的时候,突然发现了控制台出现了一个错误提示:

                GET http://localhost:8080/__webpack_hmr 404 (Not Found)
                

                然后再看了 webpack.config.js 配置文件:

                module.exports = {
                  // ...
                  entry: [
                    'webpack-hot-middleware/client',
                    './src/index.js'
                  ],
                  devServer: {
                    hot: true,
                    open: true,
                    inline: false
                  },
                }
                

                发现入口文件有一个 webpack-hot-middleware/client,经过一番搜索,在 Stack Overflow 有一个类似的提问 # Webpack hmr: __webpack_hmr 404 not found

                大概原因是,webpack-hot-middleware/clientwebpack-hot-middleware 的要求,可用于除 webpack-dev-server 之外的任何服务器(例如 express 或类似服务器),所以把它从入口文件删去就好了。

                ]]>
                <![CDATA[Webpack 开发环境选择]]> https://github.com/tofrankie/blog/issues/72 https://github.com/tofrankie/blog/issues/72 Sat, 25 Feb 2023 12:01:05 GMT 为了提高开发效率,我们会选择一个可监听文件的修改、可重新编译、并且可以自动刷新浏览器,这样可能还不满足,我们还需要热更新(HMR),避免页面状态丢失。

                选择一个工具

                在开发过程中,如果每次修改完代码都需要手动去编译的话,那显然效率极低。都 2020 年了,不提高效率哪有时间摸鱼划水]]> 为了提高开发效率,我们会选择一个可监听文件的修改、可重新编译、并且可以自动刷新浏览器,这样可能还不满足,我们还需要热更新(HMR),避免页面状态丢失。

                选择一个工具

                在开发过程中,如果每次修改完代码都需要手动去编译的话,那显然效率极低。都 2020 年了,不提高效率哪有时间摸鱼划水。

                webpack 提供了几种可选方式,帮助我们在修改代码后自动编译代码:

                • webpack watch mode
                • webpack-dev-server
                • webpack-dev-middleware

                webpack 可以在 watch mode(观察模式)下使用。在这种模式下,webpack 将监视您的文件,并在更改时重新编译。

                webpack-dev-server 提供了一个易于部署的开发服务器,具有快速的实时重载(live reloading)功能。

                如果你已经有一个开发服务器并且需要完全的灵活性,可以使用 webpack-dev-middleware 作为中间件。

                webapck-dev-serverwebpack-dev-middleware 使用内存编译,这意味着 bundle 不会被保存在硬盘上。这使得编译十分迅速,并导致你的文件系统更少麻烦。

                我的选择是 webpack-dev-server

                使用 watch mode

                webpack-dev-server 和 webpack-dev-middleware 里 Watch 模式默认开启。

                此前,我一直没有 get 到这货有什么用,没太懂。首先,开启 Watch 模式的方式有两种。

                通过 CLI 参数传递:

                // package.json
                {
                  "scripts": {
                    "watch": "webpack --watch"
                  }
                }
                

                或者在配置文件中添加:

                // webpack.config.js
                module.exports = {
                  // ...
                  watch: true,
                  watchOptions: {
                    // 定制 Watch 模式选项
                    poll: 1000, // 检查一次变动的时间间隔(ms)
                    aggregateTimeout: 500, // 重新构建前增加延迟,这段时间内允许其他任何更改都聚合到一次重新构建里面(ms)
                    ignored: /node_modules/, // 这个选项可以排除一些巨大的文件夹,避免占用大量的 CPU 或者内存。多个可以是数组
                  }
                }
                

                现在,我们运行脚本命令 yarn run watch,然后就会看到 webpack 是如何编译代码的。同时你会发现并没有退出命令行,这是因为此 script 当前还在 watch 你文件。

                我们随意更改一行代码,然后保存,接着我们应该可以在终端(Terminal)窗口可以看到 webpack 自动地重新编译修改后的模块!

                它的缺点是,为了看到修改后的实际效果,我们需要刷新浏览器。明显不是我们想要的对吧。还有,它跟 webpack-dev-serverwebpack-dev-middleware 不同的是,前者每次更改都会重新打包到本地,而后者是将 bundle 文件保存在内存中,这样前者速度会慢很多。

                那么有没有能够自动刷新浏览器的呢?可以通过 webpack-dev-server 实现我们的需求。

                使用 webpack-dev-server

                它为我们提供了一个简单的 web server,并且具有 live reloading (实时重新加载)功能。

                webpack-dev-server 在编译之后不会写入到任何输出文件。而是将 bundle 文件保留在内存中,然后将它们 serve 到 server 中,就好像它们是挂载在 server 根路径上的真实文件一样。如果你的页面希望在其他不同路径中找到 bundle 文件,则可以通过 devServer 配置中的 publicPath 选项进行修改。

                webpack-dev-server 实际上相当于启用了一个 express 的 HTTP 服务器 + 调用 webpack-dev-middleware。它的作用主要是用来伺服资源文件。这个 HTTP 服务器和 Client 使用了 Websocket 通讯协议,原始文件作出改动后,webpack-dev-server 会用 webpack 实时的编译,再用 webpack-dev-middleware 将 webpack 编译后文件会输出到内存中。适合纯前端项目,很难编写后端服务,进行整合。

                使用 webpack-dev-middleware

                webpack-dev-middleware 是一个封装器(wrapper),它可以把 webpack 处理过的文件发送到一个 server。

                webpack-dev-middleware 输出的文件存在于内存中。你定义了 webpack.config.js,webpack 就能据此梳理出 entryoutput 模块的关系脉络,而 webpack-dev-middleware 就在此基础上形成一个文件映射系统,每当应用程序请求一个文件,它匹配到了就把内存中缓存的对应结果以文件的格式返回给你,反之则进入到下一个中间件。

                因为是内存型文件系统,所以重建速度非常快,很适合于开发阶段用作静态资源服务器;因为 webpack 可以把任何一种资源都当作是模块来处理,因此能向客户端反馈各种格式的资源,所以可以替代 HTTP 服务器。事实上,大多数 webpack 用户用过的 webpack-dev-server 就是一个 express + webpack-dev-middleware 的实现。二者的区别仅在于 webpack-dev-server 是封装好的,除了 webpack.config.js 和命令行参数之外,很难去做定制型开发。而 webpack-dev-middleware 是中间件,可以编写自己的后端服务然后把它整合进来,相对而言比较灵活自由。

                参考链接

                ]]>
                <![CDATA[Webpack 如何解析模块路径]]> https://github.com/tofrankie/blog/issues/71 https://github.com/tofrankie/blog/issues/71 Sat, 25 Feb 2023 11:59:28 GMT 你一定见过这些导入方式,无论是 ESM 还是 CommonJS 模块,或是其他模块规范。

                import react from 'react'
                import button from './components/button'
                const path]]>
                            你一定见过这些导入方式,无论是 ESM 还是 CommonJS 模块,或是其他模块规范。

                import react from 'react'
                import button from './components/button'
                const path = require('path')
                

                那么 webpack 是如何去解析查找它们的呢?

                模块解析

                resolver 是一个库(library),用于帮助找到模块的绝对路径。一个模块可以作为另一个模块的依赖模块,然后被后者引用。例如:

                import foo from 'path/to/module'
                

                所依赖的模块可以是来自应用程序或者第三方库(library)。resolver 帮助 webpack 找到 bundle 中需要引入的模块代码,这些代码在每个 import/require 语句中。

                webpack 使用 enhanced-resolve 来解析文件路径。

                解析规则

                使用 enhanced-resolve 解析模块,支持三种形式:绝对路径相对路径模块路径

                1. 绝对路径

                不建议使用。

                由于已经取得文件的绝对路径,因此不需要进一步再做解析了。

                在实际项目中,除了设置别名 resolve.alias 时采用绝对路径的方式,其他的我几乎没见过使用绝对路径的。(也可能我读的项目太少了)

                import button from '/Users/frankie/component/button'
                

                2. 相对路径

                在这种情况下,使用 import/require 的资源文件(resource file)所在的目录被认为是上下文目录(context directory)。在 import/require 中给定的相对路径,会添加此上下文路径(context path),以产生模块的绝对路径(absolute path)。

                import button from './component/button'
                

                3. 模块路径

                上面两种方式,应该没有太多理解难度,而模块名才是我们要重点理解的。

                直接引入模块名,首先查找当前文件目录,若查找不到,会继续往父级目录一个一个地查找,直至到项目根目录下的 node_modules 目录(默认)。若再查找不到,则会抛出错误。

                import 'react'
                import 'module/lib/file'
                

                注意:

                • 默认的 node_modules 可以通过 resolve.modules 进行更改。
                • 查找中会根据 resolve.extensions 自动补全扩展名,默认是 ['.wasm', '.mjs', '.js', '.json']
                • 查找中会根据 resolve.alias 替换掉别名。

                模块将在 resolve.modules 中指定的目录内搜索。可以通过 resolve.alias 配置创建一个别名来替换初始模块路径。

                一旦上述规则解析路径之后,解析器(resolver)将检查路径是否指向文件目录

                • 指向文件

                  1. 如果路径具有文件扩展名,则被直接打包。
                  2. 否则,将使用 resolve.extensions 选项作为文件扩展名来解析。
                • 指向目录

                  按以下步骤找到具有正确扩展名的文件:

                  1. 如果文件夹中包含 package.json 文件,则按顺序查找 resolve.mainFields 配置选项中指定的字段,并且 package.json 中的第一个这样的字段确定文件路径。
                  2. 如果 package.json 文件不存在或者 package.json 文件中 main 字段没有返回一个有效路径,则按顺序查找 resolve.mainFields 配置选项中指定的文件名,看是否能在 import/require 目录下匹配到一个存在的文件名。
                  3. 文件扩展名通过 resolve.extensions 选项采用类似的方法进行解析。

                若使用 webpack-dev-server 3.x 版本,建议不要随意修改 resolve.mainFields 配置项,它会报错。已确认是 webpack-dev-server 的 bug,将在不久要发布的 4.x 版本修复。webpack/webpack-dev-server #2801

                解析与缓存

                Loader 解析遵循与文件解析器指定的规则相同的规则。resolveLoader 配置选项可以用来为 Loader 提供独立的解析规则。

                每个文件系统访问都被缓存,以便更快触发对同一文件的多个并行或者串行请求。在观察模式下,只有修改过的文件会从缓存中摘出。如果关闭观察模式,在每次编译前清理缓存。

                Resolve 配置

                该选项用于配置模块如何解析。例如,当在 ES6 中调用 import 'lodash'resolve 选项能够对 webpack 查找 lodash 的方式去做修改。

                1. resolve.alias

                文档

                创建 import 或 require 的别名,来确保模块引入变得更简单。

                例如,一些位于 src/ 文件夹下的常用模块:

                // webpack.config.js
                const path = require('path')
                
                module.exports = {
                  //...
                  resolve: {
                    alias: {
                      // 可以是绝对路径,或者是相对路径。
                      // 据我不完全观察,结合 path 模块和 __dirname 拼接成“绝对路径”的方案更多。
                      // 以下为模糊匹配
                      Utilities: path.resolve(__dirname, 'src/utilities/'),
                      Templates: path.resolve(__dirname, 'src/templates/')
                    }
                  }
                }
                

                现在,你可以这样使用别名了:

                import Utility from '../../utilities/utility'
                
                // 别名
                import Utility from 'Utilities/utility'
                

                也可以在给定的对象的键后的末尾添加 $,以表示精准匹配。这里不展开赘述,详细请看这里

                注意,采用别名引入模块时,先替换后解析。先将模块路径中匹配 alias 中的 key 替换成对应的 value,再做查找。

                2. resolve.extensions

                文档

                自动解析确定的扩展。

                // webpack.config.js
                module.exports = {
                  //...
                  resolve: {
                    // 使用此选项,会覆盖默认数组,默认值:['.wasm', '.mjs', '.js', '.json']。
                    // 注意不要少了符号(.),有些人配置不成功,就是因为少了它。
                    // 从左到右(从上到下)先后匹配扩展名,选项中没有的后缀,是不会自动补全的。
                    extensions: ['.js', '.json']
                  }
                }
                

                3. resolve.modules

                文档

                告诉 webpack 解析模块时应该搜索的目录。可以是绝对路径或者相对路径,但是它们之间有一点差异。

                通过查看当前目录以及祖先路径(即 ./node_modules../node_modules 等等),相对路径将类似于 Node 查找 node_modules 的方式进行查找。

                当使用绝对路径,将只在给定目录中搜索。

                // webpack.config.js
                const path = require('path')
                
                module.exports = {
                  //...
                  resolve: {
                    // 默认值
                    modules: ['node_modules']
                    // 添加一个目录到模块搜索目录,此目录优先于 node_modules 搜索。
                    modules: [path.resolve(__dirname, 'src'), 'node_modules']
                  }
                }
                

                一般地,不要去更改该选项。

                4. resolve.mainFields

                文档

                当从 npm 包中导入模块时(例如,import * as D3 from 'd3'),此选项将决定在 package.json 中使用哪个字段导入模块。根据 webpack 配置中指定的 target 不同,默认值也会有所不同。

                // webpack.config.js
                module.exports = {
                  //...
                  resolve: {
                    // 不建议修改
                
                    // target 为 webworker、web 或没有指定时,默认值为:
                    mainFields: ['browser', 'module', 'main'],
                
                    // 除去上述几个 target,对于其他任意 target(包括 node),默认值为:
                    mainFields: ['browser', 'module', 'main']
                  }
                }
                

                通常情况下,模块的 package.json 都不会声明 browsermodule 字段,所以便是使用 main 了。(该选项同样不建议更改)

                5. resolve.mainFiles

                文档

                解析目录时要使用的文件名。

                当目录中没有 package.json 时,结合 resolve.extensions 来指明使用该目录中哪个文件。

                // webpack.config.js
                module.exports = {
                  //...
                  resolve: {
                    // 默认值
                    // 可添加多个,但不建议修改。
                    mainFiles: ['index']
                  }
                }
                

                尽可能地,不要去修改该选项。因为它同样会影响第三方依赖包解析,可能会导致部分第三方包解析错误。例如,我在验证该配置时,就发现 webpack-dev-server v3 的一个 bug,开发者表示将在 v4 版本中修复。所以,不建议随意修改的配置包括 modulesmainFieldsmainFiles

                6. 更多

                它还有其他一些配置项,但比较少用,所以不展开赘述。更多请看这里

                ResolveLoader 配置

                从 webpack 2 开始,在配置 loader 时**强烈建议使用全名**。例如 example-loader,以尽可能地清晰。

                然而,如果你确实想省略 -loader,也就是说只使用 example,则可以使用 resolveLoader.moduleExtensions 此选项来实现:

                // webpack.config.js
                module.exports = {
                  //...
                  resolve: {
                    // ...
                  }
                  resolveLoader: {
                    moduleExtensions: ['-loader']
                  }
                }
                

                我使用 webpack 4 在不配置该选项时,假如将 css-loader 省略为 css,会报错提示找不到 loader。为什么我会单独拿出来介绍一下,因为网上很多文章表示在配置 module.rules 时可以省略 -loader,但我是省略了就不行。所以这里补充一下原因。

                小技巧

                关于 webpack 默认配置可以从 node_modules/webpack/lib/WebpackOptionsDefaulter.js 查看。

                参考链接

                ]]>
                <![CDATA[Webpack 学习心得]]> https://github.com/tofrankie/blog/issues/69 https://github.com/tofrankie/blog/issues/69 Sat, 25 Feb 2023 11:58:08 GMT
              • 注意,命令行接口(Command Line Interface)参数的优先级,高于配置文件参数。例如,如果将 --mode="production" 传入 webpack CLI,而配置文件使用的是 development,最终会使用 production。
              • ]]>
              • 注意,命令行接口(Command Line Interface)参数的优先级,高于配置文件参数。例如,如果将 --mode="production" 传入 webpack CLI,而配置文件使用的是 development,最终会使用 production。
              • Webpack CLI 指南: https://webpack.docschina.org/api/cli

                1. 模块热(Hot Module Replacement)替换选择

                在每次编译代码时,手动运行 npm run build 会显得很麻烦。webpack 提供几种可选方式,帮助你在代码发生变化后自动编译代码:

                • webpack watch mode(webpack 观察模式,不建议)
                • webpack-dev-server
                • webpack-dev-middleware

                选择其中一个开发工具

                webpack-dev-server 配套设置

                // webpack.config.js
                module.exports = {
                  devServer: {
                    hot: true
                  }
                }
                

                如果你在技术选型中使用了 webpack-dev-middleware 而没有使用 webpack-dev-server,请使用 webpack-hot-middleware package,以在你的自定义 server 或应用程序上启用 HMR。webpack-dev-server 在内部使用了 webpack-dev-middleware

                ]]>
                <![CDATA[从零到一搭建 react 项目系列之(十五)2020-12-23]]> https://github.com/tofrankie/blog/issues/68 https://github.com/tofrankie/blog/issues/68 Sat, 25 Feb 2023 11:54:04 GMT 上一篇文章介绍了 webpack 的 entryoutputmoduleresolve、]]> 上一篇文章介绍了 webpack 的 entryoutputmoduleresolvemodedevtoolpluginswebpack-dev-server 配置。但由于篇幅过长,本文接着写...

                9. 优化(optimization)

                从 webpack 4 开始,移除了 CommonsChunkPlugin,取而代之的是 optimization.splitChunks。webpack 4 会根据你选择的 mode 来执行不同的优化,不过所有的优化还是可以手动配置和重写。

                The CommonsChunkPlugin was removed. Instead the optimization.splitChunks options can be used.

                未完待续...

                ]]>
                <![CDATA[从零到一搭建 react 项目系列之(十四)]]> https://github.com/tofrankie/blog/issues/67 https://github.com/tofrankie/blog/issues/67 Sat, 25 Feb 2023 11:53:08 GMT 前面的文章介绍了 Webpack、HMR、React、Redux、ESLint、Prettier 等内容。

                但其实 Webpack 4 部分内容是没有比较详细的讲述的,那这篇文章就来介绍]]> 前面的文章介绍了 Webpack、HMR、React、Redux、ESLint、Prettier 等内容。

                但其实 Webpack 4 部分内容是没有比较详细的讲述的,那这篇文章就来介绍它吧。

                编写本文的时候,最新版本是 webpack 5.1.3。而本文要介绍的时候 webpack 4.x 相关接口。

                提供两个链接:

                请注意本文所指 Webpack 中文文档由印记中文翻译。

                一、前言

                在此前的系列文章,多多少少都涉及到 webpack 的相关配置,主要有这几项。

                今天就每一个知识点,尽可能地都详细介绍一下。

                webpack 支持所有符合 ES5 标准 的浏览器(不支持 IE8 及以下版本)。webpack 的 import() 和 require.ensure() 需要 Promise。如果你想要支持旧版本浏览器,在使用这些表达式之前,还需要提前加载 polyfill

                {
                  mode, // 模式
                  entry, // 入口
                  deServer, // 开发
                  optimization, // 优化
                  plugins, // 插件
                  resolve, // 解析
                  module  // 模块
                }
                

                即使有些内容前面已经介绍过,这里还是再啰嗦简单介绍一下。

                安装依赖包

                不推荐全局安装 webpack。这会将你项目中的 webpack 锁定到指定版本,并且在使用不同的 webpack 版本的项目中,可能会导致构建失败。

                $ yarn add --dev webpack@4.41.2
                $ yarn add --dev webpack-cli@3.3.10
                

                webpack 配置文件

                webpack 开箱即用,可以无需使用任何配置文件。然而,默认情况下 webpack 会假定项目的入口起点为 src/index,然后会在 dist/main.js 输出结果,并且在生产环境开启压缩和优化。

                通常,我们的项目还需要继续扩展此能力,为此我们可以在项目根目录下创建一个 webpack.config.js 文件,webpack 会自动使用它。

                Webpack 配置文件是标准的 Node.js CommonJS 模块(所以不能使用 ESM 标准导出),可以导出为 object、function 或 Promise,本项目将使用导出 object 的形式。虽然可行,但不建议通过 CLI 形式指定过多参数,会导致编写很长的脚本命令,推荐使用配置文件的形式。

                使用自定义配置文件,则可通过 Webpack CLI 命令 --config 来指定。

                // package.json
                // 假定项目根目录下有两个配置文件 dev.config.js 和 prod.config.js,分别对应开发模式和生产模式的两种不同配置,这样我们就可以通过 --config 来指定了。
                {
                  "script": {
                    "webpack:dev": "webpack --config dev.config.js --mode development",
                    "webpack:build": "webpack --config prod.config.js"
                  }
                }
                

                *附上一个 Webpack 配置文件选项详解。 *附上一个前端构建配置生成器 Create App

                常用 Webpack CLI 接口参数

                需要注意的是,命令行接口参数的优先级是高于配置文件参数的。

                参数 说明 默认值
                --config 配置文件的路径 webpack.config.js 或者 webpackfile.js
                --mode 用到的模式,development 或 production
                --hot 开启模块热替换
                --progress 打印出编译进度的百分比值 false
                --debug 将 loader 设置为 debug 模式 false
                --color, --colors 强制在控制台开启颜色
                --watch, -w 观察文件系统的变化
                --env 配置文件导出一个函数时,会将此环境变量传给该函数

                二、Webpack API

                1. 入口(entry)

                项目的入口文件,从这个入口文件开始,应用程序启动执行。如果传递一个数组,那么数组的每一项都会执行。

                不配置入口文件的情况下,Webpack 会默认取 src/index.js 作为启动文件。若不存在,则打包失败并报错:

                ERROR in Entry module not found: Error: Can't resolve './src' in 'xxx'

                // 仅举例说明,实际情况取其一,下同
                module.exports = {
                  // 支持 string | array | object | function 形式,常用的是数组和对象的形式
                  // 字符串形式,chunk 被命名为 'main'
                  entry: 'string',
                
                  // 字符串数组形式,chunk 被命名为 'main'
                  entry: ['string'],
                
                  // 对象形式,每个 key 作为 chunk 名称
                  entry: {
                    main: 'string or array',
                    vendor: 'string or array'
                  },
                
                  // 动态入口(dynamic entry)可使用函数形式,比如从服务器获取等,我暂时未用过
                  entry: () => {
                    // 还可以返回 Promise。
                    return 'string | [string]'
                  }
                }
                

                2. 输出(output)

                它包括了一组选项,指示 Webpack 如何去输出,以及在哪里输出你的 bundle、asset 和其他你所打包或者使用 Webpack 载入的任何内容。

                注意整个配置中我们使用 Node 内置的 path 模块,并在它前面加上 __dirname 这个全局变量。可以防止不同操作系统之间的文件路径问题,并且可以使相对路径按照预期工作。我们在很多地方将会使用到它。

                __dirname 指当前文件所在的目录 __filename 表示正在执行脚本的文件名

                需要注意的是,它是两个下划线,两者均返回一个绝对路径

                它的选项很多,主要介绍常用的几个:

                • path

                所有输出文件的目标路径。它是一个绝对路径,默认是项目根目录下的 dist 路径。

                例如,打包后的 JS 文件、url-loader 解析的图片,html-webpack-plugin 生成的 HTML 文件等都会存放到该路径下(或相对于该路径的子目录)

                若非绝对路径,它将会构建失败并报错:configuration.output.path: The provided value "xxx" is not an absolute path!

                • publicPath

                publicPath 并不会对生成文件的路径造成影响,主要是对你的页面里面引入的资源的路径做对应的补全,常见的就是 CSS 文件里面引入的图片。

                其中某些 loader(例如 file-loader) 的 publicPath 选项会覆盖掉 output.publicPath 的。

                关于 pathpublicPath 很多人容易混淆,官方的描述我看起来是模糊的,所以下面我通俗地描述一下。

                通俗地讲,path 就是打包文件存放在硬盘上的路径,它不会因为 publicPath 的设置而改变。

                publicPath 会影响项目中引用的资源路径并重写。它只会修改项目中的相对路径和绝对路径,而完整的绝对路径将不受影响(例如 https://cdn.example.com/assets/ 这种形式不会被修改)。最常见的就是图片资源、打包产出的 JavaScript 文件在 HTML 中的引用路径等。这些文件的路径目录将被 publicPath 替换重写(除了文件名不变,其他被替换)。常被用来指定上线后的 cdn 域名。

                • filename

                此选项决定了每个(入口 chunk 文件)输出 bundle 的名称。这些 bundle 将写入到 output.path 选项指定的目录下。

                注意,此选项不会影响那些「按需加载 chunk」的输出文件。对于这些文件,请使用 output.chunkFilename 选项来控制输出。通过 loader 创建的文件也不受影响。在这种情况下,你必须尝试 loader 特定的可用选项。

                可以使用以下替换模板字符串:

                模板 描述
                [hash] 模块标识符(module identifier)的 hash
                [chunkhash] chunk 内容的 hash
                [name] 模块名称(即入口文件名称),默认为 main
                [id] 模块标识符(module identifier)
                [query] 模块的 query,例如文件名 ? 后面的字符串
                [function] The function, which can return filename [string]

                *[hash][chunkhash] 的长度可以使用 [hash:16](默认为 20)来指定。 *如果将这个选项设为一个函数,函数将返回一个包含上面表格中替换信息的对象。 *注意此选项被称为文件名,但是你还是可以使用像 js/[name]/bundle.js 这样的文件夹结构。

                关于 Webpack 的 hashchunkhashcontenthash 的区别,可以看下这篇文章

                • chunkFilename

                此选项决定了非入口(non-entry)chunk 文件的名称。

                注意,这些文件名需要在 runtime 根据 chunk 发送的请求去生成。因此,需要在 webpack runtime 输出 bundle 值时,将 chunk id 的值对应映射到占位符(如 [name] 和 [chunkhash])。这会增加文件大小,并且在任何 chunk 的占位符值修改后,都会使 bundle 失效。

                默认使用 [id].js 或从 output.filename 中推断出的值([name] 会被预先替换为 [id] 或 [id].),所以它的可读性很差。

                默认 [id][name] 是一样的。

                chunkFileName 不能灵活自定义,这谁能忍,于是便有了webpackChunkName,可以看下这篇文章

                const path = require('path')
                
                module.exports = {
                  output: {
                    // 指定打包输出路径为 dist,
                    // 它必须绝对路径,为了避免不同操作系统之间文件路径问题,这里借助 Node.js 内置的 path 模块以及 __dirname 全局变量
                    // __dirname 是两个下划线
                    path: path.resolve(__dirname, 'dist'),
                
                
                
                    // 它通常是以 '/' 结束,避免出现访问不到生成之后的静态资源的问题
                    // 实际场景,根据项目本身设置
                    publicPath: '',
                    // publicPath: 'https://cdn.example.com/assets/', // CDN(总是 HTTPS 协议)
                    // publicPath: '//cdn.example.com/assets/', // CDN(协议相同)
                    // publicPath: '/assets/', // 相对于服务(server-relative)
                    // publicPath: 'assets/', // 相对于 HTML 页面
                    // publicPath: '../assets/', // 相对于 HTML 页面
                    // publicPath: '', // 相对于 HTML 页面(目录相同),默认
                
                
                
                    // 入口文件输出 bundle 的名称
                    filename: 'bundle.js', // 静态名称
                    // filename: '[name].bundle.js', // 使用入口名称
                    // filename: 'js/[name].bundle.js', // 支持文件夹结构
                    // filename: '[id].bundle.js', // 使用内部 chunk id
                    // filename: '[name].[hash].bundle.js', // 使用每次构建过程中,唯一的 hash 生成
                    // filename: '[chunkhash].bundle.js', // 使用基于每个 chunk 内容的 hash
                    // filename: '[contenthash].bundle.css', // Using hashes generated for extracted content
                    // filename: (chunkData) => { // Using function to return the filename
                    //   // 如果将这个选项设为一个函数,函数将返回一个包含上面表格中替换信息的对象。
                    //   return chunkData.chunk.name === 'main' ? '[name].js' : '[name]/[name].js'
                    // },
                
                
                
                    // 非入口文件,但参与构建的 bundle
                    chunkFilename: '[chunkhash].bundle.js' // 可取的值与 filename 一致
                  }
                }
                

                一句话总结: filename 指列在 entry 中,打包后输出的文件的名称。 chunkFilename 指未列在 entry 中,却又需要被打包出来的文件的名称。

                3. 模块(module)

                这些选项决定了如何处理项目中的不同类型的模块

                • noParse

                它的作用是防止 webpack 解析那些任意与给定正则表达式项匹配的文件。因为它们被忽略了,所以不会被 Babel 等做语法转换以兼容低版本的浏览器,故它们不应该含有 import、require、define 的调用

                module.exports = {
                  module: {
                    // 支持 RegExp、[RegExp]、function(resource)、string、[string] 的形式
                    noParse: /jquery|loadsh/
                    // noParse: content => /jquery|lodash/.test(content)
                  }
                }
                
                • rules(重要)

                创建模块时,匹配请求的规则数组。这些规则能够修改模块的创建方式。这些规则能够对模块(module)应用 loader,或者修改解析器(parser)。

                module.rules 是数组形式,支持一个或多个规则,而每个规则(Rule)可以分为三部分:条件(condition)、结果(result)、嵌套规则(nested rule)。

                • Rule 条件

                  条件有两种输入值:  1. resource:请求文件的绝对路径。(它已经根据 resolve 规则解析)  2. issuer:被请求资源的模块文件的绝对路径,它是导入时的路径。

                  如果看起来有点懵,没关系,下面举例说明。

                  在规则中,resource 由属规则属性 testincludeexcluderesource 对其进行匹配。而 issuer 则由规则属性 issuer 对其进行匹配。

                // 假如我们在入口文件 index.js 导入 app.css
                import './styles/app.css?inline'
                
                // webpack 匹配
                module.exports = {
                  module: {
                    rules: [
                      {
                        test: /\.css$/,
                        exclude: /node_modules/,
                        use: info => {
                          // info 是正在加载模块的一些参数
                          // 包括 resource、issuer、realResource、compiler
                          console.log(info)
                          return ['style-loader', 'css-loader']
                        }
                      }
                    ]
                  }
                }
                
                // info 打印结果如下:
                {
                  resource: '/Users/frankie/Desktop/Web/Temp/temp_webpack/src/styles/app.css',
                  realResource: '/Users/frankie/Desktop/Web/Temp/temp_webpack/src/styles/app.css',
                  resourceQuery: '?inline',
                  issuer: '/Users/frankie/Desktop/Web/Temp/temp_webpack/src/index.js',
                  compiler: undefined
                }
                

                结合概念和例子,其实已经很清楚了。app.css 是我们的目标文件,而 index.js 则是导入目标文件的位置。因此,resource 就是目标文件的绝对路径,而 issuer 则是 index.js 的绝对路径。

                • Rule 结果 规则结果只有在规则条件匹配时使用。 规则有两种输入值:  1. 应用的 loader:应用在 resource 上的 loader 数组。  2. Parser 选项:用于为模块创建挤下去的选项对象。

                  这些规则属性loaderoptionsuse 会影响 loader。(queryloaders 也会影响,但它们也被废弃) enforce 属性会影响 loader 种类。 parser 属性会影响 parser 选项。

                • 嵌套的 Rule

                  可以使用属性 rules 和 oneOf 指定嵌套规则。 这些规则用于在规则条件(rule condition)匹配时进行取值。

                不知道你们第一次看到上面这些概率描述,会不会有点发懵,反正我开始看的时候是会的。

                接下来,介绍规则(Rule)的属性,先看下有哪些:

                module.exports = {
                  module: {
                    rules: [
                      // Rule
                      {
                        resource: {
                          test,
                          include,
                          exclude
                        },
                        use: [
                          {
                            loader, 
                            options
                          }
                        ],
                        loaders, // 此选项已废弃,请使用 Rule.use
                        query, // 此选项已废弃,请使用 Rule.use.options
                        issuer,
                        enforce,
                        oneOf, 
                        parser,
                        resourceQuery,
                        rules,
                        type,
                        sideEffects
                      },
                      {
                        // 可能你们看到更多是长这样的,但其实它们只是简写罢了。
                        // 后面添加配置,我可能使用简写多一些。
                        test, // Rule.resource.test 的简写
                        include, // Rule.resource.include 的简写
                        exclude, // Rule.resource.exclude 的简写
                        loader, // Rule.use: [ { loader } ] 的简写
                        options // Rule.use: [ { options } ] 的简写
                      }
                    ]
                  }
                }
                

                (1) Rule.testRule.includeRule.exclude

                它们分别是 Rule.resource: { test, inclued, exclued } 的缩写。实际中,很多开发的朋友都说采用缩写的写法。

                条件可以是这些之一:

                • 字符串:匹配输入必须以提供的字符串开始。是的。目录绝对路径或文件绝对路径。
                • 正则表达式:test 输入值。
                • 函数:调用输入的函数,必须返回一个真值(truthy value)以匹配。
                • 条件数组:至少一个匹配条件。
                • 对象:匹配所有属性。每个属性都有一个定义行为。

                test:匹配特定条件。一般是提供一个正则表达式或正则表达式的数组,但这不是强制的。 include:匹配特定条件。一般是提供一个字符串或者字符串数组,但这不是强制的。 exclude:排除特定条件。一般是提供一个字符串或字符串数组,但这不是 强制的。

                匹配条件每个选项都接收一个正则表达式或字符串。testinclude 具有相同的作用,都是必须匹配选项。exclude 是必不匹配选项(优先于 testinclude

                最佳实践:

                • 只在 test文件名匹配 中使用正则表达式。
                • includeexclude 中使用绝对路径数组。
                • 尽量避免 exclude,更倾向于使用 include

                (2) Rule.use

                支持 UseEntriesfunction(info) 两种方式。

                其中 UseEntry 是一个对象,要求必须有一个 loader 属性是字符串。 也可以有一个 options 属性为字符串或对象,其值可以传递到 loader 中,将其理解为 loader 选项。 由于兼容性原因,也有可能有 query 属性,它是 options 属性的别名。请使用 options 属性替代。

                传递字符串(如:use: [ 'style-loader' ])是 loader 属性的简写方式(如:use: [ { loader: 'style-loader' } ]

                它还可以传递多个 loader,但要注意 loader 的加载顺序是从右往左(从下往上)

                Rule.use 也可以是一个函数,该函数接收描述正在加载的模块的 object 参数,并且必须返回 UseEntry 项的数组。 该函数 function(info) 的参数 info 包含以下几个字段 { compiler, issuer, realResource, resource }。 那这几个字段究竟是什么呢,其实上面讲述 Rule 条件的时候,就有打印出来,可以往上翻翻,或者看下官网的介绍。 关于此我不展开赘述,因为也不知道要利用它解决什么实际的场景问题,所以其实没用过。那说明我目前是不需要它的,使用 UseEntry 即可满足我的需求。 我在写 Redux 篇的时候,引用过一句话,用着这里也是同理的。

                如果你不知道是否需要 Redux,那就是不需要它。
                
                module.exports = {
                  module: {
                    rules: [
                      {
                        // ...
                        // 单个 loader,可以使用简写形式
                        loader: 'file-loader',
                        options: {
                          name: '[name].[ext]'
                        }
                      },
                      {
                        // ...
                        // 多个 loader,不含 options 简写形式
                        use: ['style-loader', 'css-loader'],
                      },
                      {
                        // ...
                        // 多个 loader,且含 options 简写形式
                        use: [
                          'style-loader',
                          {
                            loader: 'css-loader',
                            options: {
                              importLoaders: 1
                            }
                          },
                          {
                            loader: 'less-loader',
                            options: {
                              noIeCompat: true
                            }
                          }
                        ]
                      }
                    ]
                  }
                }
                

                (3) Rule.enforce

                该属性指定 loader 种类,其值可以是 pre 或者 post(字符串),没有值表示普通 loader。

                所有一个接一个地进入的 loader,都有两个阶段:  1. Pitching 阶段:loader 上的 pitch 方法,按照 后置(post)行内(inline)普通(normal)前置(pre) 的顺序调用。更多详细信息,请查看 pitching loader。  2. Normal 阶段:loader 上的常规方法,按照 前置(pre)普通(normal)行内(inline)后置(post) 的顺序调用。模块源码的转换,发生在这个阶段。

                所有普通 loader 可以通过在请求中加上 ! 前缀来忽略(覆盖)。 所有普通和前置 loader 可以通过在请求中加上 -! 前缀来忽略(覆盖)。 所有普通,后置和前置 loader 可以通过在请求中加上 !! 前缀来忽略(覆盖)。

                不应该使用 行内 loader! 前缀,因为它们是非标准的。

                PS:我没使用过行内 loader 的方式,也不太了解它这样做的目的是什么。设置成前置 loader 倒是用过,前面文章讲解 eslint-loaderbabel-loader 顺序先后问题用过。

                module.exports = {
                  module: {
                    rules: [
                      {
                        test: /\.js$/,
                        exclude: path.resolve(__dirname, 'node_modules'),
                        loader: 'babel-loader'
                      },
                      // 由于 eslint-loader 要于 babel-loader 之前执行,且 loader 执行顺序是从下往上执行的,所以 eslint-loader 要写在下面
                      // 但出于安全谨慎考虑,添加 enforce: 'pre' 属性,使其无论写在 babel-loader 前后都能优先执行。
                      {
                        test: /\.js$/,
                        enforce: 'pre',
                        exclude: path.resolve(__dirname, 'node_modules'),
                        loader: 'eslint-loader',
                        options: {
                          fix: true
                          cache: true
                        }
                      }
                    ]
                  }
                }
                

                (4) 其他属性

                4. 解析(resolve)

                该选项用于配置模块如何解析。例如,当在 ES6 中调用 import 'lodash'resolve 选项能够对 webpack 查找 lodash 的方式去做修改。

                这一块内容已在另外一篇文章详细介绍了,请移步至文章 Webpack 如何解析模块路径

                5. 模式(mode)

                提供 mode 配置选项,告知 webpack 使用响应环境的内置优化。可选值有:nonedevelopmentproduction

                如果没有设置,mode 默认设置为 production。可通过以下方式设定:

                // webpack.config.js
                module.exports = {
                  mode: 'production'
                }
                

                或者从 CLI 传递参数:

                // package.json
                {
                  "scripts": {
                    "build": "webpack --mode production"
                  }
                }
                
                • development 它会将 DefinePlugin 中的 process.env.NODE_ENV 的值设置为 development。启用 NamedChunksPluginNamedModulesPlugin

                • production 它会将 DefinePlugin 中的 process.env.NODE_ENV 的值设置为 production。启用 FlagDependencyUsagePluginFlagIncludedChunksPluginModuleConcatenationPluginNoEmitOnErrorsPluginOccurrenceOrderPluginSideEffectsFlagPluginTerserPlugin

                • none 它会退出任何默认优化选项。

                注意,设置了 NODE_ENV 并不会自动地设置 mode

                6. devtool

                此选项控制是否生成,以及如何生成 Source Map。不同的值会明显影响到构建(build)和重新构建(rebuild)的速度。

                建议:开发环境使用 eval-cheap-module-source-map,而生产环境多数只需要知道报错的模块和行号就可以了,所以使用的是 nosources-source-map

                你可以直接使用 SourceMapDevToolPlugin/EvalSourceMapDevToolPlugin 来替代使用 devtool 选项,因为它有更多的选项。切勿同时使用 devtool 选项和 SourceMapDevToolPlugin/EvalSourceMapDevToolPlugin 插件。devtool 选项在内部添加过这些插件,所以你最终将应用两次插件。

                7. 插件(plugins)

                该选项用于已各种方式自定义 webpack 构建过程。webpack 附带了各种内置的插件,可以通过 webpack.[plugin-name] 访问这些插件。

                可以查看插件页面获取插件列表和对应的文档,这只是其中一部分,社区中还有很多插件。

                每个插件都是一个构造函数,使用它的时候需要用 new 实例化。

                以下是此前系列文章使用过的插件,后续文章还将会用到其他插件,比如 copy-webpack-pluginhappypack 等,用到再介绍。

                // webpack.config.js
                
                const webpack = require('webpack')
                // 导入非 webpack 自带默认插件
                const HtmlWebpackPlugin = require('html-webpack-plugin')
                const { CleanWebpackPlugin } = require('clean-webpack-plugin')
                
                module.exports = {
                  // ...
                  plugins: [
                    // 创建 HTML 文件
                    new HtmlWebpackPlugin({
                      title: 'webpack demo',
                      template: './src/index.html',
                      filename: 'index.html',
                      inject: 'body',
                      hash: true,
                      favicon: './src/favicon.ico'
                    }),
                
                    // 新版无需再指定删除目录,默认删除 output 的目录
                    new CleanWebpackPlugin(),
                
                    // 通过它启用 HMR 之后,它的接口将被暴露在 module.hot 属性下面
                    new webpack.HotModuleReplacementPlugin(),
                
                    // 允许在编译时(compile time)配置的全局常量
                    new webpack.DefinePlugin({
                      // 注意,因为这个插件直接执行文本替换,给定的值必须包含字符串本身内的实际引号。通常,有两种方式来达到这个效果,使用 '"production"', 或者使用 JSON.stringify('production')。
                      'process.env.NODE_ENV': JSON.stringify('development')
                    })
                  ]
                }
                

                8. 开发环境

                关于 watch modewebpack-dev-serverwebpack-dev-middleware 的选择,写在这篇 Webpack 开发环境选择文章了。

                文章中提到了 webpack-dev-server 生成的包并没有存储在你的硬盘中,而是放到了内存里。

                接下来介绍的是 webpack-dev-server 选项。

                *若想通过 Node.js API 来使用它,此处有一个简单示例。

                webpack-dev-server 支持两种模式来刷新页面:

                • iframe:页面放在 <iframe> 标签中,当文件发生更改会重新刷新页面,设置方式有两种,如下:
                module.exports = {
                  devServer: {
                    inline: false, // 启用 iframe 模式
                    open: true // 在 server 启动后打开浏览器
                  }
                }
                

                或者通过 CLI 方式:

                {
                  "scripts": {
                    "dev": "webpack-dev-server --inline=false"
                  }
                }
                

                启动之后,打开的 URL 格式如下:

                http://«host»:«port»/webpack-dev-server/«path»
                
                # 比如
                http://localhost:8080/webpack-dev-server/
                

                *我看过的项目好像还没有人用这种方式的,我也没用过,不展开说了。(PS:我尝试过这种方式好像只能 Live Reload,不能 HMR。我不知道是我配置问题,还是其他原因?后面有时间再研究一下,研究明白了再回来更新这块内容)

                • inline:默认是 inline mode。 配置方式有三种,看这篇别人踩坑的文章。我怕我说越多越乱,记住它是默认的模式就好了。

                注意接着,往下的内容将基于 inline 模式介绍。

                告诉服务器从哪个目录中提供内容。只有在你需要提供静态文件(如图片,数据等一些不受 webpack 控制的资源文件)时才需要。devServer.publicPath 将用于确定应该从哪里提供 bundle,并且此选项优先。

                推荐使用一个绝对路径。

                默认情况下,将使用当前工作目录作为提供内容的目录,将其设置为 false 以禁用 contentBase

                // webpack.config.js
                const path = require('path')
                
                module.exports = {
                  devServer: {
                    // 单个目录
                    contentBase: path.join(__dirname, 'public'),
                    // 多个目录
                    contentBase: [
                      path.join(__dirname, 'public'),
                      path.join(__dirname, 'assets')
                    ]
                  }
                }
                

                *CLI 用法不介绍了,下同。

                此路径下的打包文件可在浏览器中访问。devServer.publicPath 默认值是 /

                假设服务器运行在 http://localhost:8080 并且 output.filename 被设置为 bundle.jsdevServer.publicPath 默认值是 /,所以你的包(bundle)可以通过 http://localhost:8080/bundle.js 访问。

                module.exports = {
                  //...
                  devServer: {
                    publicPath: '/assets/'
                  }
                }
                

                修改配置,将 bundle 放置指定的目录下。现在通过 http://localhost:8080/assets/bundle.js 访问到 bundle。

                未完待续...

                参考链接

                ]]>
                <![CDATA[从零到一搭建 react 项目系列之(十三)]]> https://github.com/tofrankie/blog/issues/66 https://github.com/tofrankie/blog/issues/66 Sat, 25 Feb 2023 11:48:33 GMT

                原先打算将 ESLint、Prettier 放到最后一个环节介绍的,但由于不想再写示例案例的时候,还要]]>

                原先打算将 ESLint、Prettier 放到最后一个环节介绍的,但由于不想再写示例案例的时候,还要手动格式化,真的会逼死强迫症的人呐!

                好吧,我有强迫症。。。

                关于 ESLint、Prettier 的内容还是挺多的,所以本文篇幅较长。

                一、简介

                1. ESLint

                ESLint 最初是由 Nicholas C. Zakas 于 2013 年 6 月创建的开源项目。ESLint 凭借插件化、配置化、可满足不同的技术栈的个性需求打败了 JSHint 成为最受欢迎的 JavaScript 代码检测工具。 👉 中文官网

                从 JSLint 到 ESLint,经历了什么,他们各有什么特点,看这篇文章 👉 JS Linter 进化史

                恰恰正是因为 ESLint 推崇配置化,往往需要配置很多繁杂的 rules 规则,如果每个人都要这种做的话,显然会耗费很多精力。于是就有人站了出来,并在 GitHub 上开源了他们的代码规范库,比较流行的有 airbnb、standard、prettier 等。

                但在这里我选择的是国内腾讯 AlloyTeam 团队出品的 eslint-config-alloy 开源规范库。

                其实他们团队最开始使用 Airbnb 规则,但是由于它过于严格,部分规则还是需要个性化,导致后来越改越多,最后决定重新维护一套。经过两年多的打磨,现在 eslint-config-alloy 已经非常成熟了。

                我选择它的几点原因:

                • 适用于 React/Vue/Typescript 项目
                • 样式相关规则由 Prettier 管理
                • 中文文档和网站示例(鄙人蹩脚的外语水平,这点极吸引我,哈哈)
                • 更新快,及时跟进最新规则和废弃规则,且拥有官方维护的 Vue、Typescript、React + Typescript 规则

                2. Prettier

                An opinionated code formatter.

                Prettier 是一个代码格式化工具,相比于 ESLint 中的代码格式规则,它提供了更少的选项,但是却更加专业,且与大多数编辑器集成。

                它要解决的就是类似于使用两个空格,还是四个空格的争论。在团队开发中达成统一。

                支持以下语言:

                二、ESLint

                接下来介绍本项目是如何配置它们的。

                此前我写了一篇关于小程序项目配置 ESLint、Prettier、Git 提交规范的文章,其实是大同小异,可以去看下 👉 点这里

                1. ESLint 的安装

                必要条件:

                • Node.js 6.14+
                • npm 3+(or yarn) *个人建议 npm 版本大于 5.2 以上,可使用 npx 命令。
                # 全局安装(建议本地项目安装)
                $ yarn global add eslint
                # 创建 .eslintrc 配置文件
                $ eslint --init
                # 检测某个或多个文件(支持 glob 匹配模式,如 eslint lib/** )
                $ eslint file1.js file2.js
                
                
                # 华丽的分割线 ****************************************************
                
                
                # 本地项目安装(推荐)
                $ yarn add --dev eslint@6.7.1
                # 以下同时安装了一些 ESLint 插件或者与 Prettier 相关的依赖包
                $ yarn add --dev babel-eslint@10.0.3
                $ yarn add --dev eslint-config-alloy@3.7.1
                $ yarn add --dev eslint-plugin-react@7.18.3
                $ yarn add --dev prettier@2.0.5
                $ yarn add --dev prettier-eslint-cli@5.0.0
                #
                # 创建 .eslintrc 配置文件
                #(不支持 npx 就使用 ./node_modules/.bin/eslint --init ,下同)
                $ npx eslint --init
                # or
                $ yarn eslint --init
                #
                # 检测文件
                $ npx eslint file1.js file2.js
                # or
                $ yarn eslint file1.js file2.js
                

                我就直接在项目根目录添加一个 ESLint 的配置文件 .eslintrc.js,不用命令行生成了。

                // .eslintrc.js
                module.exports = {
                  extends: [
                    'alloy'
                  ],
                  env: {
                    // 你的环境变量(包含多个预定义的全局变量)
                    //
                    // browser: true,
                    // node: true,
                    // mocha: true,
                    // jest: true,
                    // jquery: true
                  },
                  globals: {
                    // 你的全局变量(设置为 false 表示它不允许被重新赋值)
                    //
                    // myGlobal: false
                  },
                  rules: {
                    // 自定义你的规则
                  }
                }
                

                还有一种方式,使用 JavaScript 注释吧配置信息直接嵌入到一个代码源文件中。可以看下这篇文章

                2. 介绍 ESLint 的几个配置项

                • 解析器(Parser)

                ESLint 默认使用 Espree 作为其解析器,你可以在配置文件中指定一个不同的解析器。(需单独安装解析器包)

                {
                  parser: 'babel-eslint'
                }
                

                注意,在使用自定义解析器时,为了让 ESLint 在处理非 ES5 特性时正常工作,配置属性 parserOptions 仍然是必须的。解析器会被传入 parserOptions,但是不一定会使用它们来决定功能特性的开关。

                • 解析器选项(Parser Options)

                ESLint 允许你指定你想要支持的 JavaScript 语言选项。默认情况下,ESLint 支持 ES5 语法。你可以覆盖该设置,以启用对 ECMAScript 其它版本和 JSX 的支持。

                {
                  parserOptions: {
                    ecmaVersion: 10, // 指定 ECMAScript 版本,默认为3,5。同样支持使用年份命名的版本号指定为 2015(同 6),2016(同 7),或 2017(同 8)或 2018(同 9)或 2019 (same as 10)
                    sourceType: 'module', // 设置为 "script" (默认) 或 "module"
                    ecmaFeatures: { // 表示你想使用的额外的语言特性
                      jsx: true // 启用 JSX
                      // 还有 globalReturn、impliedStrict、experimentalObjectRestSpread 等选项
                    }
                  }
                }
                

                设置解析器选项能帮助 ESLint 确定什么是解析错误,所有语言选项默认都是 false

                • 处理器(Processor)

                这个个人平常几乎不使用,所以不展开细说,请看官方介绍

                • 环境(Environments)

                一个环境定义了一组预定义的全局变量。这些环境并不是互斥的,所以你可以同时定义多个。

                • browser - 浏览器环境中的全局变量。
                • node - Node.js 全局变量和 Node.js 作用域。
                • commonjs - CommonJS 全局变量和 CommonJS 作用域 (用于 Browserify/Webpack 打包的只在浏览器中运行的代码)。
                • es6 - 启用除了 modules 以外的所有 ES6 特性(该选项会自动设置 ecmaVersion 解析器选项为 6)。
                • worker - Web Workers 全局变量。
                • jquery - jQuery 全局变量。
                • 更多...
                {
                  plugins: ['example']
                  env: {
                    browser: true,
                    node: true,
                    es6: true,
                    'example/custom': true // 使用插件(不带前缀) example 中的 custom 环境
                  }
                }
                
                • 全局变量(Globals)

                当访问当前源文件内未定义的变量是,no-undef 规则将发出警告。前面提到环境时,设置某个环境时会定义一组对应的全局变量。

                {
                  globals: {
                    // 你的全局变量(设置为 false 表示它不允许被重新赋值)
                    // myGlobal: false
                  }
                }
                

                由于历史原因,布尔值 false 和字符串值 "readable" 等价于 "readonly"。类似地,布尔值 true 和字符串值 "writeable" 等价于 "writable"。但是,不建议使用旧值。

                使用字符串 "off" 禁用全局变量。假如当前环境不支持使用 Promise 可以通过 "Promise": "off" 全局禁用。

                • 插件(Plugins)

                ESLint 支持使用第三方插件。在使用插件之前,你必须使用 npm 安装它。插件名称可以省略 eslint-plugin- 前缀。

                {
                  plugins: [
                    // 相当于 'eslint-plugin-prettier'
                    'prettier'
                  ]
                }
                
                • 规则(Rules)

                ESLint 附带有大量的规则。你可以使用注释或配置文件修改你项目中要使用的规则。要改变一个规则设置,你必须将规则 ID 设置为下列值之一:

                • "off"0 - 关闭规则
                • "warn"1 - 开启规则,使用警告级别的错误:warn (不会导致程序退出)
                • "error"2 - 开启规则,使用错误级别的错误:error (当被触发的时候,程序会退出)
                {
                  plugins: ['plugin1']
                  rules: {
                    'no-alert': 2,
                    'no-eval': 2,
                    'plugin1/rule1': 2,
                    indent: ['error', 2, { SwitchCase: 1 }]
                  }
                }
                

                若要禁用一组文件的配置文件中的规则,请使用 overridesfiles 配合使用。

                {
                  rules: {...},
                  overrides: [
                    {
                      files: ['*-test.js', '*.spec.js'],
                      rules: {
                        'no-unused-expressions': 0
                      }
                    }
                  ]
                }
                

                在你的文件中使用行注释或者块注释的方式来禁止(某些)规则出现警告,可以看这篇文章,里面有详细介绍。

                • 扩展(Extends)

                它的属性值可以是:

                • 指定配置的字符串(配置文件路径、可共享配置的名称、eslint:recommended 或者 eslint:all
                • 字符串数组:每个配置继承它前面的配置

                eslint:recommended

                ESLint 所有的规则默认都是禁用的。在配置文件中,使用 "extends": "eslint:recommended" 来启用推荐的规则,报告一些常见的问题,在规则页面中这些推荐的规则都带有一个 ✅ (对勾)标记。

                它只能在 ESLint 主要版本进行更新。换句话说,假如 ESLint 6.7.1 版本的规则 ruleA6.8.0 不会更新,只有在 ESLint 7.x 或更高才可能会更新。所以在更新 ESLint 主版本之后,在使用 --fix 选项继续修复之前,应该先检查一下报告的问题,这样你就知道哪些规则有调整了。

                eslint:all(不推荐使用)

                它启用当前安装的 ESLint 中所有的核心规则,它可以在 ESLint 的任何版本进行更改,使用有风险。

                可共享的配置

                它是一个 npm 包,输出一个配置对象。属性值可省略包名 eslint-config-,比如 "extends": "alloy"

                插件

                它是一个 npm 包,除了通常的输出规则之外,一些插件还可以输出一个或多个命名的 配置

                • plugins 属性值可以省略包名的前缀 eslint-plugin-.
                • extends 属性值由以下组成
                1. plugin:
                2. 包名(是省略了 eslint-plugin- 前缀,比如 react
                3. /
                4. 配置名称(比如 recommended

                例如,"plugin:react/recommended"

                {
                  plugins: ['react'],
                  extends: [
                    'eslint:recommended',
                    'plugin:react/recommended'
                  ]
                }
                

                配置文件路径

                extends 属性值可以是基本配置文件的绝对/相对路径,相对路径相对于当前配置文件的路径。

                {
                  extends: [
                    './node_modules/coding-standard/eslintDefaults.js',
                    './node_modules/coding-standard/.eslintrc-es6',
                    './node_modules/coding-standard/.eslintrc-jsx'
                  ]
                }
                
                • 覆盖(Overrides)

                比如,如果同一个目录下的文件需要有不同的配置。因此,你可以在配置中使用 overrides 键,它只适用于匹配特定的 glob 模式的文件。

                几点要注意的:

                • 只能在 .eslintrc.* 或者 package.json 中配置。
                • 采用相对路径,相对于配置文件的路径。
                • Glob 模式覆盖要比其他常规配置具有更高的优先级。同一配置文件中多个覆盖按照顺序被应用,即最后一个覆盖会有最高优先级。
                {
                  rules: {
                    quotes: ['error', 'double']
                  },
                  overrides: [
                    {
                      files: ['bin/*.js', 'lib/*.js'],
                      excludedFiles: '*.test.js',
                      rules: {
                        quotes: ['error', 'single']
                      }
                    }
                  ]
                }
                

                3. ESLint 配置文件格式

                ESLint 支持几种格式的配置文件:

                • JavaScript - 使用 .eslintrc.js 然后输出一个配置对象。
                • YAML - 使用 .eslintrc.yaml.eslintrc.yml 去定义配置的结构。
                • JSON - 使用 .eslintrc.json 去定义配置的结构,ESLint 的 JSON 文件允许 JavaScript 风格的注释。
                • (弃用) - 使用 .eslintrc,可以使 JSON 也可以是 YAML。
                • package.json - 在 package.json 里创建一个 eslintConfig 属性,在那里定义你的配置。

                注意:如果同一个目录下多个配置文件,ESLint 只会使用一个

                优先级从高到低

                1. .eslintrc.js
                2. .eslintrc.yaml
                3. .eslintrc.yml
                4. .eslintrc.json
                5. .eslintrc
                6. package.json

                4. ESLint 的层叠配置

                *下面介绍的配置文件采用 JavaScript 格式,即 .eslintrc.js

                假如如下场景,项目根目录有一个 .eslintrc.js 配置文件,test/ 目录也有一个配置文件。

                your-project
                ├── .eslintrc.js
                ├── lib
                │ └── source.js
                └─┬ tests
                  ├── .eslintrc
                  └── test.js
                

                这种情况 ESLint 如何处理呢?

                层叠配置使用离检测文件最近的 .eslintrc.js 文件作为最高优先级,然后才是父目录里的配置文件。

                当 ESLint 遍历 lib/ 目录时,使用根目录里的 .eslintrc.js 作为它的配置文件。当遍历到 tests/ 目录时,除了会使用根目录 your-project/.eslintrc.js 之外,还会用到 your-project/tests/.eslintrc.js ,所以 your-project/tests/test.js 是基于它的目录层次结构中的两个配置文件的组合,并且离的最近的一个优先。通过这种方式,你可以有项目级 ESLint 设置,也有覆盖特定目录的 ESLint 设置。

                完整的配置层次结构,从最高优先级到最低的优先级,如下:

                1. 行内配置
                  1. /* eslint-disable *//* eslint-enable */
                  2. /* global */
                  3. /* eslint */
                  4. /* eslint-env */
                2. 命令行选项(或者 CLIEngine 等价物)
                  1. --global
                  2. --rule
                  3. --env
                  4. -c--config
                3. 项目级配置:
                  1. 与检测的文件在同一目录下的 .eslintrc.* 或者 package.json 文件。
                  2. 继续在父级目录寻找 .eslintrc.*package.json 文件,直到根目录(包含)或直到发现一个有 "root": true 的配置。
                4. 如果不是(1)到(3)中的任何一种情况,则退回到 ~/.eslintrc(用户目录下) 中自定义的默认配置。

                非常有必要的是,在项目根目录下设置一个 "root": true,表示 ESLint 一旦发现配置文件中有该属性时,它就会停止在父级目录中寻找。

                {
                  root: true
                }
                

                5. 检测扩展名

                目前只能通过命令行选项 --ext 指定,告诉 ESLint 哪个文件扩展名要检测。

                // package.json
                {
                  "scripts": {
                    "eslint": "eslint . --ext .js"
                  }
                }
                

                6. 忽略特定的文件和目录

                通过项目根目录创建一个 .eslintignore 文件告诉 ESLint 去忽略特定文件或目录。它依照 .gitignore 规范

                如果相比于当前目录下 .eslintignore 文件,你更想使用一个不同的文件,你可以在命令行使用 --ingnore-path 选项指定它。例如,你可以使用 .jshintignore 文件,它有相同的格式。

                $ npx eslint --ignore-path .jshintignore file.js
                

                如果没有发现 .eslintignore 文件,也没有指定替代文件,ESLint 将在 package.json 文件中查找 eslintIgnore 键,来检查要忽略的文件。

                重要:

                1. 注意代码库的 node_modules 目录,比如,一个 packages 目录,默认情况下不会被忽略,需要手动添加到 .eslintignore
                2. 指定 --ignore-path 意味着任何现有的 .eslintignore 文件将不被使用。

                附上我的 .eslintrc.js 配置文件

                module.exports = {
                  root: true,
                  parser: 'babel-eslint',
                  extends: [
                    'alloy',
                    'alloy/react' // eslint-config-alloy 启用 eslint-plugin-react
                  ],
                  parserOptions: {
                    ecmaVersion: 2019,
                    sourceType: 'module',
                    ecmaFeatures: {
                      jsx: true // 启用 JSX
                    }
                  },
                  settings: {
                    react: {
                      version: 'detect' // 自动选择你已安装的版本
                    }
                  },
                  // 插件名称可以省略 eslint-plugin- 前缀。
                  plugins: [],
                  // 环境变量(包含多个预定义的全局变量)
                  env: {
                    browser: true,
                    es6: true,
                    node: true,
                    commonjs: true
                  },
                  // 全局变量(设置为 false 表示它不允许被重新赋值)
                  globals: {},
                  // 自定义规则
                  rules: {
                    'react/prop-types': [0],
                    'default-case-last': 0,
                    'no-unused-vars': 0,
                    'no-var': 0,
                    'no-irregular-whitespace': 0,
                    'use-isnan': 2,
                    'no-alert': 2,
                    'no-eval': 2,
                    'spaced-comment': 2,
                    'react/self-closing-comp': 0,
                    indent: ['error', 2, { SwitchCase: 1 }]
                  }
                }
                
                

                三、Prettier

                终于讲完 ESLint 了,内容挺多的,差点吐了...接着介绍 Prettier。

                1. 首先安装 Prettier

                $ yarn add --dev prettier@2.0.5
                $ yarn add --dev prettier-eslint-cli@5.0.0
                

                2. 格式化文件

                $ yarn prettier --write [dir or file]
                
                # 格式化全部文件
                # yarn prettier --write .
                # 格式化确切的目录,如 app
                # yarn prettier --write app/
                # 格式化确切的文件,如 index.js
                # yarn prettier --write app/index.js
                # 支持 Glob 模式匹配文件,如格式化 app 目录下的所有 JS 扩展文件
                # yarn prettier --write "app/**/*.js"
                

                3. 检查文件

                # 匹配方式同上
                $ yarn prettier --check [dir or file]
                

                --check--write 不同的是,前者仅检查文件是否已被格式化,后者是格式化并覆盖。

                4. 添加 Prettier 配置文件 .prettierrc.js,以及相关规则。

                下面是个人比较喜欢的配置,若想知道更多可选配置,请移步官方文档

                // .prettierrc.js
                module.exports = {
                  // 与 ESLint 整合
                  // eslintIntegration: false,
                  // 一行最多 160 字符
                  printWidth: 160,
                  // 使用 2 个空格缩进
                  tabWidth: 2,
                  // 不使用缩进符,而使用空格
                  useTabs: false,
                  // 行尾不需要有分号
                  semi: false,
                  // 使用单引号(JSX 引号会忽略此选项)
                  singleQuote: true,
                  // JSX 不使用单引号,而使用双引号
                  jsxSingleQuote: false,
                  // 对象的 key 仅在必要时用引号
                  quoteProps: 'as-needed',
                  // 末尾不需要逗号
                  trailingComma: 'none',
                  // 大括号内的首尾需要空格
                  bracketSpacing: true,
                  // JSX 标签的反尖括号需要换行
                  jsxBracketSameLine: false,
                  // 箭头函数,只有一个参数的时候,不需要括号
                  arrowParens: 'avoid',
                  // 每个文件格式化的范围是文件的全部内容
                  rangeStart: 0,
                  rangeEnd: Infinity,
                  // 不需要写文件开头的 @prettier
                  requirePragma: false,
                  // 不需要自动在文件开头插入 @prettier
                  insertPragma: false,
                  // 使用默认的折行标准
                  proseWrap: 'preserve',
                  // 根据显示样式决定 HTML 要不要折行
                  htmlWhitespaceSensitivity: 'css',
                  // 换行符使用 lf
                  endOfLine: 'lf'
                }
                

                5. 关于 overrides 选项

                上面的可选项,除了通过配置文件的形式指定,也可以通过 CLI 形式来指定,但推荐前者。而 overrides 选项只能在配置文件中指定。

                overrides 的作用是对某些文件扩展名,文件夹和特定文件进行不同的配置

                例如,Prettier 的默认解析器是不支持解析小程序中扩展名为 .wxss.acss 的文件的,那么我们就可以利用 overrides 来指定解析器,然后就能对其进行格式化了。

                {
                  overrides: [
                    {
                      files: ['*.wxss', '*.acss'],
                      options: {
                        parser: 'css'
                      }
                    },
                    // 类似地,如果有需要的话,亦可将 JavaScript 文件使用 flow 来代替默认的 babel 解析器。
                    // {
                    //   files: '*.js',
                    //   options: {
                    //     parser: 'flow'
                    //   }
                    // }
                  ]
                }
                

                6. 共享配置

                Prettier 提供了一个共享配置(Sharing configurations)的选项。

                它适用于什么场景呢?

                假如我们有一份 Prettier 配置,同时适用于我们公司多个项目,那么我们就可能需要用到它,这样我们只需要维护一套配置文件就好了。类似于 NPM 包一样,但这里不赘述,有需要的看下官方文档介绍,后续我会考虑单独写一篇文章介绍,到时会回来更新文章的。

                7. 忽略文件 .prettierignore

                它同样依照 .gitignore 的语法,没什么好说的,根据项目自行增删改。

                # .prettierignore 文件配置
                /node_modules
                /dist
                /src/fonts/
                
                ## OS
                .DS_Store
                .idea
                .editorconfig
                package-lock.json
                .npmrc
                
                # Ignored suffix
                *.log
                *.md
                *.svg
                *.png
                *ignore
                
                
                ## Built-files
                .cache
                dist
                

                四、ESLint + Prettier

                1. 有必要了解的两个依赖

                ESLint 解决的是代码质量问题,而 Prettier 解决的是代码风格问题。按道理 ESLint + Prettier 结合就能解决前面两个问题,但实际上两者一起使用的时候会有冲突,原因是 ESLint 也有参与代码格式问题。

                所以,我们需要以下两个依赖包来处理冲突:

                关闭所有与 Prettier 冲突的 ESLint 规则。(被关闭的规则是与代码风格相关的)

                {
                  extends: ['prettier'] // eslint-config-prettier 一定要是最后一个,才能确保覆盖其他配置
                }
                

                将 Prettier 作为 ESLint 规则运行,并将差异报告为单个 ESLint 问题。

                使用了 eslint-config-prettier 关闭掉与 Prettier 冲突的规则,这时格式问题已全面交给 Prettier 处理,为什么还需要 eslint-plugin-prettier 呢?

                原因是这样的,我们期望报错的来源依旧是 ESLint。这个插件把 Prettier 格式配置以 ESLint rules 规则的方式写入,这样所有的报错都来自于 ESLint 了。

                {
                  plugins: ['prettier'],
                  rules: {
                    'prettier/prettier': 'error'
                  }
                }
                

                上面两者配置的结合就相当于(官方推荐):

                {
                  extends: ['plugin:prettier/recommended']
                }
                

                其实本项目的配置并不使用以上两个依赖,但我认为,非常有必要清楚了解两个插件的作用。

                既然如此,那我是怎么处理 ESLint 和 Prettier 两者的冲突呢?

                2. 解决方案

                原因其实跟我选用的 eslint-config-alloy 有关。eslint-config-alloy 从 v3 开始,已经不包含所有样式相关的规则了,故不需要引入 eslint-config-prettier

                虽然官方介绍说,不包含任何的样式相关的规则,但是我发现在使用的过程中,ESLint 与 Prettier 仍然存在一些冲突,比如三元运算的缩进问题。

                这是我抛出的一个疑惑,不知道是我理解错了,还是什么原因?后续打算提个 issues 问下大佬。

                我的 NPM 脚本是这样设计的:

                {
                  "scripts": {
                    "eslint": "eslint . --ext .js",
                    "eslint:fix": "eslint --fix . --ext .js",
                    "prettier:fix": "prettier --config .prettierrc.js --write './**/*.{js,css,less,scss,json}'",
                    "format:all": "npm-run-all -s prettier:fix eslint:fix"
                  }
                }
                

                看到上面这个,你们大致也清楚思路了。既然 ESLint 与 Prettier 仍存在冲突,那么两者存在冲突时,我就以 ESLint 的优先级最高就好了。利用 npm-run-all 按顺序分别执行 prettier:fixeslint:fix 命令,这样只要执行 yarn run format:all 一条命令就把代码质量检查与代码风格都检查处理一遍(包含了一些自动修复的)。

                五、EditorConfig

                EditorConfig 有助于维护跨多个编辑器和 IDE 从事同一项目的多个开发人员的一致编码风格。 EditorConfig 项目包含一个用于定义编码样式的文件格式和一个文本编辑器插件集合,这些文本编辑器插件使编辑器可以读取文件格式并遵循定义的样式。 EditorConfig 文件易于阅读,并且可以与版本控制系统很好地协同工作。

                它主要是用于规范缩进风格,缩进大小,Tab 长度以及字符集等,解决不同 IDE 的编码范设置。EditorConfig 插件会去查找当前编辑文件的所在文件夹或其上级文件夹中是否有 .editorconfig 文件。如果有则编辑器的行为会与 .editorconfig 文件中定义的一致,并且其优先级高于编辑器自身的设置。

                使用 VS Code 进行开发的话,搜索安装 EditorConfig for VS Code 插件。

                在项目根目录添加一份 .editorconfig 配置文件。

                # .editorconfig
                
                # 根目录的配置文件,编辑器会由当前目录向上查找,如果找到 `roor = true` 的文件,则不再查找
                root = true
                
                # 匹配所有的文件
                [*]
                # 缩进风格:space
                indent_style = space
                # 缩进大小 2
                indent_size = 2
                # 换行符 lf
                end_of_line = lf
                # 字符集 utf-8
                charset = utf-8
                # 不保留行末的空格
                trim_trailing_whitespace = true
                # 文件末尾添加一个空行
                insert_final_newline = true
                # 运算符两遍都有空格
                spaces_around_operators = true
                
                # 对所有的 js 文件生效
                [*.js]
                # 字符串使用单引号
                quote_type = single
                trim_trailing_whitespace = true
                
                [.*rc]
                indent_size = 2
                indent_style = space
                
                [*.json]
                indent_size = 2
                indent_style = space
                
                [*.md]
                indent_style = space
                trim_trailing_whitespace = false
                

                六、关于 eslint-loader 和 babel-eslint

                1. eslint-loader

                我们希望在项目开发过程中,对每次修改代码都能够自动进行 ESLint 的检查。这样,倘若编写代码时出现了语法错误等问题,我们能够迅速定位问题并解决,所以我们需要借助 eslint-loader 来帮我们完成这个需求。

                $ yarn add --dev babel-loader@4.0.2
                
                // webpack.config.js
                {
                  module: {
                    rules: [
                      {
                        test: /\.js$/,
                        exclude: /node_modules/,
                        use: 'babel-loader'
                      },
                      {
                        test: /\.js$/,
                        enforce: 'pre', // 确保要比 babel-loader 执行,因为 eslint-loader 要检测的是 babel 之前的代码
                        exclude: /node_modules/,
                        loader: 'eslint-loader',
                        options: {
                          fix: true, // 启用 ESLint autofix 自动修复,注意此选项将更改源文件。
                          cache: true
                        }
                      }
                    ]
                  }
                }
                

                需要注意的是,我们使用 eslint-loader 检查的是我们编写的源码,而不是经过 Babel 转译后的代码,所以我们要确保在被 Babel 转译之前使用它。

                两种方法:

                1. 严格按照上面 👆 配置文件中 rules 的顺序,eslint-loader 一定要放在 babel-loader 后面,因为 Loader 的加载顺序是**“从下往上(从右往左)”**。
                2. eslint-loaderRule.enforce 属性设置为 pre,以前置处理。这样配置编写顺序可在 babel-loader 或前或后了。(为了安全起见,推荐这种写法)

                温馨提示:官方指出 eslint-loader 已弃用,被 eslint-webpack-plugin 取代,后者解决了前者的一些已知问题。(若使用最新版,建议用后者)

                2. babel-eslint

                上面讲述 ESLint 的时候,我使用的解析器是 babel-eslint,而不是默认的 Espree

                为什么使用它作为 ESLint 的解析器呢?

                原因是这样的,ESLint 的默认解析器和核心规则仅支持最新的 ECMAScript 标准,不支持 Babel 提供的实验性(例如新功能)和非标准(例如 Flow 或 TypeScript 类型)语法。 babel-eslint 是允许 ESLint 在 Babel 转换后的源代码上运行的解析器。 注意:仅在使用 Babel 转换代码时才需要使用 babel-eslint。如果不是这种情况,请为您选择的 ECMAScript 风格使用相关的解析器(请注意,默认解析器支持所有非实验语法以及 JSX)。(eslint-babel 官方原话

                而本项目就是使用了 Babel 将代码转换为向后兼容的 JavaScript 语法,所以需要用到 babel-eslint

                温馨提示:babel-eslint 已移入 Babel monorepo,若使用最新版本可直接安装 @babel/eslint-parser 依赖包。

                七、至此

                关于 ESLint + Prettier 的内容基本就介绍完了,篇幅有点长。

                但其实这里应该要结合 Git Hooks 来做一些代码提交规范与检查的事情,这样就能确保我们提交的代码都是经过代码质量检查与格式化的。

                因为人总会有在提交代码前忘记进行 Code Formatting 的时候,人非圣贤,孰能无过,表示理解。假如我们设了一个“关卡”,在每次 git commit 之前都必须自动对暂存文件进行检测,只要检测不通过就不允许提交代码,这样就能有效地防止这种现象了。

                以上这个做法,其实在另一系列文章介绍过(点击查看)。当然本项目后续对这块内容也还会介绍的,但会放后一点,这里篇幅也过长了,不宜再述。

                ]]>
                <![CDATA[从零到一搭建 react 项目系列之(十二)]]> https://github.com/tofrankie/blog/issues/65 https://github.com/tofrankie/blog/issues/65 Sat, 25 Feb 2023 11:47:01 GMT 此前讲解 react-router、redux、react-redux、redux-saga 所涉及的内容较多,篇幅也较长。终于可以介绍 HMR 模块热更新。

                其实此前已经介绍过了,但今天就结合 React 搭建更友好的热更新效果。

                一、HMR 实现

                此前是怎么做的呢]]> 此前讲解 react-router、redux、react-redux、redux-saga 所涉及的内容较多,篇幅也较长。终于可以介绍 HMR 模块热更新。

                其实此前已经介绍过了,但今天就结合 React 搭建更友好的热更新效果。

                一、HMR 实现

                此前是怎么做的呢?

                当然这种方式是没有针对使用 React 做优化处理的。

                // webpack.config.js
                modules.exports = {
                  mode: 'develop',
                  devServer: {
                    // 需要注意的是,要完全启用 HMR,需要 webpack.HotModuleReplacementPlugin
                    hot: true
                  },
                  optimization: {
                    // 告知 webpack 使用可读取模块标识符,来帮助更好地调试。开发模式默认开启。简单来说,开启时你看到的是一个具体的模块名称,而不是一个数字 id。
                    namedModules: true
                  },
                  plugins: [
                    // 通过它启用 HMR,那么它的接口将被暴露在 module.hot 属性下面。
                    new webpack.HotModuleReplacementPlugin()
                  ],
                  module: {
                    rules: [
                      {
                        // 样式热更新,借助于 style-loader,其实幕后使用了 module.hot.accept
                        test: /\.css/,
                        use: ['style-loader', 'css-loader']
                      }
                    ]
                  }
                }
                

                我们还需在入口文件添加 module.hot.accpet() 方法。

                // index.js
                import React from 'react'
                import { render } from 'react-dom'
                import '../styles/style.css'
                import Root from './Root'
                
                // 最简单的 React 示例
                const rootElem = document.getElementById('app')
                render(<Root />, rootElem)
                
                // 通常,先检查 HotModuleReplacementPlugin 暴露的接口是否可访问,然后再开始使用它。
                if (module.hot) {
                  // accept 方法接受给定的依赖模块的更新,并触发一个回调函数来对这些更新做出响应。
                  module.hot.accept('./Root', () => {
                    import('./Root.js').then(module => {
                      const NextRoot = module.default
                      render(<NextRoot/>, rootElem)
                    })
                  })
                }
                

                需要注意的是: 原先的 new webpack.NameModulesPlugin() 在 webpack 4 中已废弃,取代它的是 optimization.nameModules。开发模式下默认开启,生产模式下,默认关闭。关于开启与禁用,最直观的区别如下图。

                尝试随便修改一下 Home 组件,看到控制台的输出(如下图),两者区别不同。开启时,能看到具体涉及更新的模块有哪些,而关闭状态则是只能看到一个数字 id。

                ▼ 关闭状态

                ▼ 开启状态

                二、HMR 搭配 React 的不足

                上面的方式,如果搭配 React 使用的话,其实还不够友好。

                用一个最简单的例子说明问题

                // 一个非常简单的有状态组件
                import React, { Component } from 'react'
                
                class HMRDemo extends Component {
                  constructor(props) {
                    super(props)
                    this.state = {
                      count: 0
                    }
                  }
                
                  render() {
                    return (
                      <div>
                        <h3>HMR Demo Component!</h3>
                        <h5>计数器:{this.state.count}</h5>
                        <button onClick={() => { this.setState({ count: ++this.state.count }) }}>add</button>
                      </div>
                    )
                  }
                }
                
                export default HMRDemo
                

                这时候我们点击按钮,然后组件状态 count 自然变成了 1,这个没问题。接着,我们随意增加一个标签元素,然后页面自然会热更新,但是我们看到,count 又变成了 0,组件状态丢失了,如下图:

                我们享受着 HMR 给我们开发带来的便利,但同时我们又不想丢失 Component State 组件状态,怎么做呢?

                为了解决这个问题,React Hot Loader 出现了。

                三、React Hot Loader

                先看一下官方指南的一段原话

                React-Hot-Loader is expected to be replaced by React Fast Refresh. Please remove React-Hot-Loader if Fast Refresh is currently supported on your environment.

                大概的意思是,react-hot-loader 将会被 React Fast Refresh 取代。如果您当前的环境支持的话,请移除 react-hot-loader。

                至于 React Fast Refresh 是什么?如何使用?这里不展开述说,可以看一下这篇文章

                继续介绍 react-hot-loader,还需要在上面的基础上,添加以下配置。

                1. 安装依赖包

                $ yarn add --dev react-hot-loader@4.12.19
                # 下面步骤用到
                $ yarn add --dev @hot-loader@react-dom@16.12.0
                

                @hot-loader/react-dom 是在 react-dom 相同版本的基础上,添加了一些支持热更新的补丁。所以需要安装与 react-dom 一致的版本。

                2. 添加 react-hot-loader/babel.babelrc 配置中。

                // .babelrc
                {
                  "plugins": [
                    "react-hot-loader/babel"
                  ]
                }
                

                3. 将根组件标记为热导出(hot-exported)

                import React from 'react'
                import { Provider } from 'react-redux'
                import { hot } from 'react-hot-loader/root'
                import App from './pages/App'
                import store from './store'
                
                const Root = () => {
                  return (
                    <Provider store={store}>
                      <App />
                    </Provider>
                  )
                }
                
                export default hot(Root)
                
                // 温馨提示
                // 关于新 API 👉 hot 是位于 '/root' 下面的,但并不是所有的打包工具都支持该新 API。比如 parcel 就不支持。
                // 此时 react-hot-loader 就会抛出错误,并要求你使用旧的 API,方法如下:
                // 
                // import { hot } from 'react-hot-loader'
                // export default hot(module)(App)
                

                4. 确保在 reactreact-dom 之前导入 react-hot-loader,两种方式可选择:

                • 在入口文件导入 import react-hot-loader(要在 React 之前)
                • 在 webpack 配置文件的 entry 配置 react-hot-loader/patch
                // webpack.config.js
                {
                  entry: [
                    'react-hot-loader/patch',
                    './src/js/index.js'
                  ]
                }
                

                需要注意的是:react-hot-loader/patch 一定要写在 entry 的最前面。如果有 babel-polyfill 就写在 babel-polyfill 的后面。

                5. 使用 React Hooks 需要用到 @hot-loader/react-dom。同时,就以上配置重新启动,此时控制台会打印一个 WARNING,如下:

                React-Hot-Loader: react-🔥-dom patch is not detected. React 16.6+ features may not work. (Issue #1227

                解决方案之一,另一种可以看下 gaearon/react-hot-loader #1227

                // webpack.config.js
                {
                  resolve: {
                    alias: {
                      'react-dom': '@hot-loader/react-dom'
                    }
                  }
                }
                

                测试一下,分别两次点击 add 后,再添加一个节点元素,发现 Component State 是在我们预期之内的,并没有像之前一样因为热更新而丢失状态。

                四、至此

                关于 Hot Module Replacement 模块热替换(热更新)基本就介绍完了。

                前面我们提到一个 React Fast Refresh 概念,它由官方维护,稳定性与性能有保障,对 React Hooks 有更完善的支持。官方实现是 react-refresh

                后面有时间的话,应该会写一篇关于它的文章。但是,它最低支持版本是 react-dom@16.9+

                The end.

                ]]>
                <![CDATA[从零到一搭建 react 项目系列之(十一)]]> https://github.com/tofrankie/blog/issues/64 https://github.com/tofrankie/blog/issues/64 Sat, 25 Feb 2023 11:44:40 GMT 之前文章提过,Redux 是 Flux 架构与函数式编程结合的产物。

                一、Redux Flow

                Redux 的数据流大致如下:

                之前文章提过,Redux 是 Flux 架构与函数式编程结合的产物。

                一、Redux Flow

                Redux 的数据流大致如下:

                UI ComponentActionReducerStateUI Component

                ▼ Redux Flow

                用户访问页面,然后通过 View 发出 Action(一个原始的 JavaScript 对象),由 Dispatcher 进行派发,Reducer(一个纯函数)接收到后进行处理并返回状态 State(存储 State 的容器叫做 Store),然后通知 View 更新页面。

                对于同步且没有副作用的操作,上述数据流可以起到管理数据,从而控制视图更新的目的。

                那么遇到含有副作用的操作时(比如 Ajax 异步请求),我们应该怎么做?

                答案是使用中间件。

                二、中间件的概念

                对于中间件或者异步操作的思想,我不展开赘述,可以看一下阮一峰老师的这篇中间件与异步操作的文章。我对文中内容有多少疑惑,但又不知道怎么说,可能是我造诣不够深。

                我是这样理解的,类似 redux-thunk、redux-promise、redux-saga 等中间件是帮助我们在异步操作结束后,使得 Reducer 自动执行。

                其实中间件的实现是对 store.dispatch() 的改造,在发出 Action 和执行 Reducer 之间,添加了其他功能。

                例如:

                let next = store.dispatch
                store.dispatch = function dispatchAndLog(action) {
                  console.log('dispatching', action)
                  next(action)
                  console.log('next state', store.getState())
                }
                

                上面的代码,对 store.dispatch() 进行了重定义,在发送 Action 前后添加了打印功能,这就是中间件的雏形。

                加入中间件后,Redux 的数据流大致如下:

                ▼ Redux Flow With Middleware

                在含副作用的 Action 与原始 Action 之间增加了中间件的处理。其中中间件的作用转换异步操作,生成原始的 Action 对象,后面的流程不变。

                在此之前,其实我们已经使用到了中间件,那就是 redux-logger

                三、redux-thunk

                我们先看看 redux-thunk 的源码

                // redux-thunk 源码
                function createThunkMiddleware(extraArgument) {
                  return ({ dispatch, getState }) => (next) => (action) => {
                    if (typeof action === 'function') {
                      return action(dispatch, getState, extraArgument);
                    }
                
                    return next(action);
                  };
                }
                
                const thunk = createThunkMiddleware();
                thunk.withExtraArgument = createThunkMiddleware;
                
                export default thunk;
                

                对吧,看起来并不难。当 action 为函数时,就调用该函数。

                1. 示例

                1. 安装 redux-thunk
                $ yarn add redux-thunk@2.3.0
                
                1. 调整 store/index.js,引入 redux-thunk 中间件。这里我们暂时把此前 redux-saga 的配置注释掉,并改成 redux-thunk 配置。
                // src/js/store/index.js
                import { createStore, applyMiddleware, compose } from 'redux'
                import thunkMiddleware from 'redux-thunk'
                import logger from 'redux-logger'
                import reducers from '../reducers'
                
                const initialState = { count: 0, status: 'offline' }
                const composeEnhancers = (typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || compose
                const store = createStore(reducers, initialState, composeEnhancers(applyMiddleware(thunkMiddleware, logger)))
                
                export default store
                
                1. 新建一个 userHelper.js 文件。这里我们不用 fetch 或者 XHR 来创建一个异步请求,而是使用 setTimeout 方法来实现一个异步请求用户数据的场景。
                // src/js/utils/userHelper.js
                class UserHelper {
                  // 延迟函数
                  delay(time) {
                    return new Promise(resolve => setTimeout(resolve, time))
                  }
                
                  // 获取数据
                  fetchData(status) {
                    return new Promise((resolve, reject) => {
                      const response = { status: 'success', response: { name: 'Frankie', age: 20 } }
                      const error = { status: 'error', error: 'oops' }
                      status ? resolve(response) : reject(error)
                    })
                  }
                
                  // 请求用户数据(异步场景)
                  async getUser(data) {
                    try {
                      const res = await this.fetchData(data)
                      await this.delay(2000)
                      return res
                    } catch (e) {
                      await this.delay(1000)
                      throw e
                    }
                  }
                }
                
                export default new UserHelper()
                
                1. 新建一个 userActions.js,这是 redux-thunk 的关键。
                // src/js/action/userActions.js
                import userHelper from '../utils/userHelper'
                
                export const getUser = (data, callback) => {
                  return (dispatch, getState) => {
                    dispatch({ type: 'FETCH_REQUEST', status: 'requesting' })
                    userHelper.getUser(data).then(res => {
                      dispatch({ type: 'FETCH_SUCCESS', ...res })
                      callback && callback(res)
                    }).catch(err => {
                      dispatch({ type: 'FETCH_FAILURE', ...err })
                    })
                  }
                }
                
                1. 修改 About 组件,需要注意的是,在这里我们传给 store.dispatch() 的不是一个原始对象(plain object),而是 getUser 函数。这就是 redux-thunk 的特点,它可让 store.dispatch() 接受一个函数作为参数,而这个函数叫做 Action Creator
                // src/js/pages/about/index.js
                import React, { Component } from 'react'
                import { connect } from 'react-redux'
                import { getUser } from '../../actions/userActions'
                
                class About extends Component {
                  constructor(props) {
                    super(props)
                    this.state = {}
                  }
                
                  render() {
                    return (
                      <div>
                        <h3>About Component!</h3>
                        <h5>Get User: {this.props.user.status || ''}</h5>
                        {/* 我们发现这里并不是传了一个标准的 Action 对象,而是一个函数 */}
                        <button onClick={() => { this.props.dispatch(getUser(false)) }}>Get User Fail</button>
                        <button onClick={() => { this.props.dispatch(getUser(true)) }}>Get User Success</button>
                      </div>
                    )
                  }
                }
                
                const mapStateToProps = state => {
                  return { user: state.user }
                }
                
                export default connect(mapStateToProps)(About)
                

                我们点击 Get User Success 按钮,即可看到如下效果。

                2. redux-thunk 的缺点

                根据源码我们知道,它仅仅帮我们执行了这个函数,而不在乎函数主体内部是什么。实际情况下,它这个函数可能要复杂得很多。再者,如果每一个异步操作都需要如此定义一个 Action Creator,显然它是不易维护的。

                • Action 的形式不统一
                • 异步操作太分散,分散在各个 Action 当中

                所以本项目将不会使用它,只是因为 redux-thunk 在之前做项目的时候用过,就拿出来讲一下。

                四、redux-saga

                关于接下来 redux-saga 部分的内容,我认为我可能讲解得不太好,建议看文章最后的链接。

                redux-saga 是一个用于管理异步操作的中间件。它通过创建 Saga 将所有的异步操作逻辑收集在一个地方集中处理,Saga 负责协调那些复杂或异步的操作,Reducer 还是负责处理 Action 和 State 的更新。

                Saga 是通过 Generator 函数创建的,如果还不太熟悉 Generator 函数,请看阮一峰老师的 ES6 入门教程中对 Generator 函数的介绍。

                Saga 不同于 Thunk,Thunk 是在 Action 被创建时调用,而 Saga 只会在应用启动时调用(但初始启动的 Saga 可能会动态调用其他 Saga),Saga 可以被看作是在后台运行的进程,Saga 监听发起的 Action,然后决定基于这个 Action 来做什么:是发起一个异步调用(如 fetch 请求),还是发起其他的 Action,甚至是调用其他的 Saga。

                redux-saga 的世界里,所有的任务都通过 yield Effects 来完成(Effect 可以看作是 redux-saga 的任务单元)。Effects 是简单的 JavaScript 对象,包含了要被 Saga middleware 执行的信息(比如,我们 Redux Action 其实是一个包含了执行信息({ type, ... })的原始的 JavaScript 对象 ),redux-saga 为各项任务提供了各种 Effect 创建器,比如调用一个异步函数,发起一个 Action 到 Store,启动一个后台任务或者等待一个满足某些条件的未来的 Action。

                1. Saga 辅助函数

                redux-saga 提供了一些辅助函数,用来在一些特定的 Action 被发起到 Store 时派生任务,下面先讲解两个辅助函数:takeEverytakeLatest

                takeEvery

                例如:每次点击 Fetch 按钮是,我们发起一个 FETCH_REQUESTED 的 Action。我们想通过启动一个任务从服务器获取一些数据来处理这个 Action。

                // src/
                import { call, put, takeEvery } from 'redux-saga/effects'
                import userHelper from '../utils/userHelper'
                
                // 创建一个异步任务
                function* fetchData(action) {
                  try {
                    // call([context, fnName], ...args)
                    const data = yield call([userHelper, userHelper.getUser], action.flag)
                    yield put({ type: 'FETCH_SUCCESS', ...data })
                  } catch (e) {
                    yield put({ type: 'FETCH_FAILURE', ...e })
                  }
                }
                
                // 每次 FETCH_REQUESTED Action 被发起时启动上面的任务
                export function* watchFetchData(a) {
                  yield takeEvery('FETCH_REQUEST', fetchData)
                
                  // 等同于
                  // while (true) {
                  //   const action = yield take('FETCH_REQUEST')
                  //   yield fork(fetchData, action)
                  // }
                }
                

                takeLatest

                在上面的例子,takeEvery 允许多个 fetchData 实例同时启动,在某个特定的时刻,我们可以启动新的 fetchData 任务,尽管此前还有一个或者多个 fetchData 尚未结束。

                如果只想得到最新的那个请求的响应,我们可以使用 takeLatest 辅助函数

                和 takeEvery 不同的是,在任何时刻 takeLatest 只允许一个 fetchData 任务,并且这个任务时最后被启动的那个。如果此前已经有一个任务在执行,那么此前这个任务会自动被取消。

                import { takeLatest } from 'redux-saga/effects'
                
                export function* watchFetchData(a) {
                  yield takeLatest('FETCH_REQUEST', fetchData)
                }
                

                2. Effect 创建器

                Saga 是由一个个的 effect 组成的,那么 effect 是什么?

                redux-saga 官网的解释:一个 effect 就是一个 Plain Object JavaScript 对象,包含一些将被 saga middleware 执行的指令。redux-saga 提供了很多 effect 创建器,如 callputtake 等。

                比如 call

                import { call } from 'redux-saga/effects'
                
                function* fetchData() {
                  yield call(fetch)
                }
                

                call(userHelper.getUser) 生成的就是一个 effect,类似如下:

                {
                  isEffect: true,
                  type: 'CALL',
                  fn: fetch
                }
                

                常用的 effect 有:

                3. 常用 Effect 方法

                (1)take

                take 这个方法是用来监听未来的 Action,它创建一个命令对象,告诉 Middleware 等待一个特定的 Action,Generator 函数会暂停,直到一个与 pattern 匹配的 action 被发起,才会继续执行下面的语句。也就是说,take 是一个阻塞的 effect。

                export function* watchFetchData(a) {
                  while (true) {
                    // 监听一个 type 为 'FETCH_REQUEST' 的 Action 的执行,直到这个 Action被触发,
                    // 才会执行下面的 yield fork(fetchData) 语句。
                    yield take('FETCH_REQUEST')
                    yield fork(fetchData)
                  }
                }
                

                (2)put

                它是用来发送 Action 的 effect,你可以简单地理解成 redux 框架中的 dispatch 函数。当 put 一个 Action 后,reducer 就会计算新的 state 并返回。put 也是阻塞的 effect。

                结合 take 和 put 方法,举个例子:

                // *********************** 辅助理解 ***********************
                
                // 在 redux 中,我们发起这样一个 Action
                const fetchAction = { type: 'FETCH_REQUEST' }
                store.dispatch(fetchAction)
                
                // 使用 Saga 如何处理呢?
                // 需要注意的是:以下 Saga 方法实现,并不是一个完整可执行的逻辑,仅用以举例说明,辅助理解而已。
                //
                // 1. 首先,在我们启动 Saga 时,使用 take 来监听 type 为 'FETCH_REQUEST' 的 Action
                const fetchAction = yield take('FETCH_REQUEST')
                // 2. 从 UI 向 Saga 中间件传递一个 Action
                this.props.dispatch({ type: 'FETCH_REQUEST' })
                // 3. 此时我们的 Saga 监听到 'FETCH_REQUEST',接着开始执行 take('FETCH_REQUEST') 后面的逻辑
                yield put(fetchAction)
                // 4. put 方法,可以发出 Action,且发出的 Action 会被 Reducer 监听到。从而返回一个新状态
                

                (3)call/apply

                call(fn, ...args)
                
                // 支持传递 this 上下文给 fn。在调用对象方法时很有用。
                call([context, fn], ...args)
                
                // 支持用字符串传递 fn。在调用对象的方法时很有用。
                // 例如 yield call([localStorage, 'getItem'], 'redux-saga')。
                call([context, fnName], ...args)
                
                // call([context, fn], ...args) 的另一种写法
                apply(context, fn, [args])
                

                语法与 JS 中的 call/apply 相似。

                可以把它简单的理解为调用其他函数的函数,它命令 middleware 以参数 args 来调用 fn 函数。

                注意: fn 既可以是一个 Generator 函数, 也可以是一个返回 Promise 或任意其它值的普通函数

                还有,call 是阻塞的 effect。

                (4)fork

                fork(fn, ...args)
                

                fork 类似于 call,可以用来调用普通函数和 Generator 函数。不过,fork 的调用是非阻塞的,Generator 不会在等待 fn 返回结果的时候被 middleware 暂停;恰恰相反地,它在 fn 被调用时便会立即恢复执行。

                (5)select

                select(selector, ...args)
                
                // 如果 select 的参数为空会取得完整的 state(与调用 getState() 的结果相同)
                // yield select()
                
                // 返回 state 的一部分数据可以这样获取
                // yield select(state => state.user)
                

                select 函数是用来指示 middleware 调用提供的选择器获取 Store 上的 state 数据。你也可以简单的把它理解为 redux 框架中获取 store 上的 state 数据一样的功能(store.getState()

                4. Middleware API

                • createSagaMiddleware() 创建一个 Redux middleware,并将 Sagas 连接到 Redux Store。

                • middleware.run(saga, ...args) 动态地运行 saga,只能用于在 applyMiddleware 阶段之后执行 Saga。
                  sagas 中的每个函数都必须返回一个 Generator 对象,middleware 会迭代这个 Generator 并执行所有 yield 后的 Effect。(Effect 可以看作是 redux-saga 的任务单元)

                五、Saga 案例实现

                下面写一个处理 Fetch 请求的异步处理场景。

                首先,实现 Saga 处理场景:

                import { call, fork, put, select, take, delay, race, takeEvery, takeLatest } from 'redux-saga/effects'
                
                // fetch 请求
                function fetch() {
                  return new Promise((resolve, reject) => {
                    window
                      .fetch('http://192.168.1.124:7701/config')
                      .then(response => response.json())
                      .then(res => {
                        // 请求成功,返回一个 JSON 数据:{"name":"Frankie","age":20}
                        resolve(res)
                      })
                      .catch(err => {
                        reject(err)
                      })
                  })
                }
                
                // saga 处理异步场景
                function* fetchData() {
                  try {
                    // race 与 Promise.race 类似,这里做一个超时处理
                    const { result, timeout } = yield race({
                      result: call(fetch),
                      timeout: delay(30000)
                    })
                    if (timeout) throw new Error('请求超时!')
                    yield put({ type: 'FETCH_SUCCESS', ...result })
                  } catch (e) {
                    console.warn(e)
                    yield put({ type: 'FETCH_FAILURE', status: 'error', error: 'oops' })
                  }
                }
                
                export function* watchFetchData() {
                  // 每次 Saga 监听到 'FETCH_REQUEST' 类型的 Action,都会触发 fetchData 函数
                  yield takeEvery('FETCH_REQUEST', fetchData)
                }
                

                接着,我们在 UI 中派发一个 FETCH_REQUEST 的 Action,然后 Saga 监听到之后,就会执行 fetchData 的逻辑了。

                <div>
                  <h3>About Component!</h3>
                  <h5>Get User: {this.props.user.name || ''}</h5>
                  <button onClick={() => { this.props.dispatch({ type: 'FETCH_REQUEST', status: 'requesting' }) }}>Fetch Data</button>
                </div>
                

                看结果:

                六、至此

                Redux + Middleware 基本的已经介绍完了,但我不认为我讲好了。建议大家看看以下几篇文章来加深理解。

                还有 Redux 搭配中间件的我认为要学习的 API 很多,有点费劲。有空看下另一个解决方案:👉 MobX

                接下来终于可以介绍 react-hot-loader 热更新了,关于 react-router、redux、react-redux、redux-saga 等内容花了好多篇幅。

                七、参考链接

                ]]>
                <![CDATA[从零到一搭建 react 项目系列之(十)]]> https://github.com/tofrankie/blog/issues/63 https://github.com/tofrankie/blog/issues/63 Sat, 25 Feb 2023 11:43:15 GMT 本文将介绍 Reducer 的拆分。

                Reducer 函数负责生成 State。但由于整个 Web 应用只有一个 State 对象,包含所有数据,对于大型应用来说,这个 State 必然十分庞大,导致 Reducer 函数也十分庞大。

                为此,我们给自己加个需求]]> 本文将介绍 Reducer 的拆分。

                Reducer 函数负责生成 State。但由于整个 Web 应用只有一个 State 对象,包含所有数据,对于大型应用来说,这个 State 必然十分庞大,导致 Reducer 函数也十分庞大。

                为此,我们给自己加个需求,来讲述 Reducer 的拆分。

                如下图,在原有 state 只存储 count 计数的基础上,增加一个 status 来存储登录状态。

                示例

                只要在原有基础上做简单修改,即可满足需求,但我们的目的不在此。

                1. reducer 函数上增加对 LOGIN_INLOGIN_OUT 的处理:
                // reducers/index.js
                const reducer = (prevState, action) => {
                  const { type, payload } = action
                  switch (type) {
                    case 'ADD':
                      return { ...prevState, count: prevState.count + payload }
                    case 'SUB':
                      return { ...prevState, count: prevState.count - payload }
                    case 'LOGIN_IN':
                      return { ...prevState, status: 'online' }
                    case 'LOGIN_OUT':
                      return { ...prevState, status: 'offline' }
                    default:
                      // default 或者未知 action 时,返回旧的 state
                      return prevState
                  }
                }
                
                export default reducer
                
                1. Home 组件上,增加一个 dispatch 分发 Action的动作。
                import React, { Component } from 'react'
                import { connect } from 'react-redux';
                import store from '../../store'
                
                class Home extends Component {
                  constructor(props) {
                    super(props)
                    this.state = {}
                  }
                
                  handle(type, val) {
                    this.props.simpleDispatch(type, val)
                    // 获取 State 快照
                    console.log(`当前操作是 ${type},count 为:${store.getState().count}`)
                  }
                
                  render() {
                    return (
                      <div>
                        <h3>Home Component!</h3>
                        {/* 将 state 展示到页面上 */}
                        <h5>count:{this.props.count}</h5>
                        <button onClick={this.handle.bind(this, 'ADD', 1)}>加一</button>
                        <button onClick={this.handle.bind(this, 'SUB', 1)}>减一</button>
                        <h5>status:{this.props.status}</h5>
                        <button onClick={() => {this.props.toggleStatus('LOGIN_IN')}}>Login in</button>
                        <button onClick={() => {this.props.toggleStatus('LOGIN_OUT')}}>Login out</button>
                      </div>
                    )
                  }
                }
                
                const mapStateToProps = (state, ownProps) => {
                  return {
                    count: state.count,
                    status: state.status
                  }
                }
                
                const mapDispatchToProps = (dispatch, ownProps) => {
                  return {
                    simpleDispatch: (type, payload) => {
                      dispatch({ type, payload })
                    },
                    toggleStatus: type => {
                      dispatch({ type })
                    }
                  }
                }
                
                export default connect(mapStateToProps, mapDispatchToProps)(Home)
                

                如果对此前的文章介绍的内容已经掌握了的话,这些已经是轻而易举的了。

                但是,我们会发现一个问题,上面的 reducer 函数夹杂着对 count、status 的处理,两种截然不同的 Action 并不会干扰双方的 State ,它们之间没有关联。如果按照上述将其写在一起,若后续增加更多的存储状态时,它看起来就更加的紊乱,维护成本也将越来越高。

                所以,我们需要将其进行拆分。

                那么如何拆分呢?

                我们试图将上述的 reducer 函数拆分成两个 countReducerstatusReducer(如下),这样一拆,Reducer 就易读很多了,两者职能也很清晰。

                // count reducer
                const countReducer = (prevState, action) => {
                  const { type, payload } = action
                  switch (type) {
                    case 'ADD':
                      return { ...prevState, count: prevState.count + payload }
                    case 'SUB':
                      return { ...prevState, count: prevState.count - payload }
                    default:
                      return prevState
                  }
                }
                
                // status reducer
                const statusReducer = (prevState, action) => {
                  const { type } = action
                  switch (type) {
                    case 'LOGIN_IN':
                      return { ...prevState, status: 'online' }
                    case 'LOGIN_OUT':
                      return { ...prevState, status: 'offline' }
                    default:
                      return prevState
                  }
                }
                

                然后我们现在的问题是,如何将其合成一个标准的 Reducer 函数?

                combineReducers(reducers) 相关概念

                Redux 提供了一个 combineReducers 方法,它的作用是,把一个由多个不同 reducer 函数作为 value 值的对象,合并成一个最终的 reducer 函数,然后就可以对这个 reducer 调用 createStore 方法。

                合并后的 reducer 可以调用各个子 reducer 函数,并把它们返回的结果合并成一个 state 对象。由 combineReducers() 返回的 state 对象,会将传入的每个 reducer 返回的 state 按其传递给 combineReducers() 时对应的 key 进行命名。

                举个例子:

                const rootReducer = combineReducers({
                  keyA: aReducer,
                  keyB: bReducer
                })
                
                // 那么返回的 state 对象就是:{ keyA: ..., keyB: ... }
                

                *还有几乎所有关于 combineReducers 的教程都有提到的一点:使用 ES6 对象属性简写的方式使得其看起来更简洁。这里不再赘述,太简单了,相信你们早就掌握了。

                它的返回值看这里(为了避免被这些概念绕晕,个人建议不看)。我们只要知道 combineReducers() 的返回值可直接作为 createStore() 第一个参数使用即可。

                需要注意的是

                本函数设计的时候有点偏主观,就是为了避免新手犯一些常见错误。也因此作者故意设定一些规则。(但如果你自己手动编写 rootRedcuer 时并不需要遵守这些规则。)

                每个传入 combineReducersreducer 都需满足以下规则:

                • 所有未匹配到的 action,必须把它接收到的第一个参数也就是那个 state 原封不动返回。

                • 永远不能返回 undefined。当过早 return 时非常容易犯这个错误,为了避免错误扩散,遇到这种情况时 combineReducers 会抛异常。

                • 如果传入的 state 就是 undefined,一定要返回对应 reducer 的初始 state。根据上一条规则,初始 state 禁止使用 undefined。使用 ES6 的默认参数值语法来设置初始 state 很容易,但你也可以手动检查第一个参数是否为 undefined

                虽然 combineReducers 自动帮你检查 reducer 是否符合以上规则,但你也应该牢记,并尽量遵守。即使你通过 Redux.createStore(combineReducers(...), initialState) 指定初始 statecombineReducers 也会尝试通过传递 undefinedstate 来检测你的 reducer 是否符合规则。因此,即使你在代码中不打算实际接收值为 undefinedstate,也必须保证你的 reducer 在接收到 undefined 时能够正常工作。

                实施

                1. 首先将 countReducerstatusReducer 分别分离到 /reducers/countReducer.js/reducers/statusReducer.js 两个文件中。
                // reducers/countReducer.js
                const countReducer = (prevState = {}, action) => {
                  const { type, payload } = action
                  switch (type) {
                    case 'ADD':
                      return prevState + payload
                    case 'SUB':
                      return prevState - payload
                    default:
                      return prevState
                  }
                }
                
                export default countReducer
                
                // reducers/statusReducer.js
                const statusReducer = (prevState = {}, action) => {
                  const { type } = action
                  switch (type) {
                    case 'LOGIN_IN':
                      return 'online'
                    case 'LOGIN_OUT':
                      return 'offline'
                    default:
                      return prevState
                  }
                }
                
                export default statusReducer
                
                1. 引入 combineReducers
                // reducers/index.js
                import { combineReducers } from 'redux'
                import countReducer from './countReducer'
                import statusReducer from './statusReducer'
                
                const rootReducer = combineReducers({
                  count: countReducer,
                  status: statusReducer
                })
                
                export default rootReducer
                

                至此我们的 Reducer 就拆分成功了,效果图就不贴了,与拆分之前无异。

                当然了,以上教程只是为了引入而引入。如果已经掌握了,那么实际项目中遇到再复杂的 store 状态存储我们都不用怕了。

                ]]> <![CDATA[从零到一搭建 react 项目系列之(九)]]> https://github.com/tofrankie/blog/issues/62 https://github.com/tofrankie/blog/issues/62 Sat, 25 Feb 2023 11:41:51 GMT 这篇文章,我们先介绍如何观察 store 的变化,上文我说了可以通过 redux-logger 或者 Redux DevTools 来解决这个我们的需求。

                一、Redux-Logger

                它是一个可以生成日志的中间件。我们可以在控制台看到 Action 操作,包括 action、pr]]> 这篇文章,我们先介绍如何观察 store 的变化,上文我说了可以通过 redux-logger 或者 Redux DevTools 来解决这个我们的需求。

                一、Redux-Logger

                它是一个可以生成日志的中间件。我们可以在控制台看到 Action 操作,包括 action、prev state、next state 状态。

                接入 redux-logger 的方法非常的简单。

                1. 安装 redux-logger
                $ yarn add redux-logger@3.0.6
                
                1. createStore 方法传入

                此前文章我们介绍了 reduxcreateStore 方法接收三个参数:createStore(reducer, [preloadedState], enhancer),在这里我们将使用到第三个参数。

                我们将 redux-logger 放在 redux 提供的 applyMiddleware 中,再传入 createStore 就能完成 store.dispatch() 功能的增强。

                // store/index.js
                // 注意:为了缩减文章篇幅,这里省略了部分代码。
                import { createStore, applyMiddleware } from 'redux'
                import logger from 'redux-logger'
                
                // some statements...
                
                // 如果这里不传 preloadedState 参数,那么 applyMiddleware 就是第二个参数了
                const store = createStore(reducer, initialState, applyMiddleware(logger))
                
                export default store
                

                需要注意的是,中间件的次序是有要求的,使用之前,请查一下相关文档。比如 redux-logger 就一定要放在最后,否则输出结果会不正确。

                1. 完成了,看效果:

                二、Redux DevTools

                我更偏向于使用插件,一个是我本身强迫症使然,二是项目可能会需要打印其他信息,如果每次 Redux 处理 Action 都打印的话,假如频繁操作的话,我可能没办法很快找到我想要的信息,看到满屏的 log 我会疯掉,哈哈。

                *插件安装,自行解决,不再赘述。

                在这里我会这样处理,在含有 Redux DevTools 插件的情况下,不使用 redux-logger。若不含插件(如 Safari 浏览器),我就会启用 redux-logger。

                Redux DevTools 使用文档,在这里。使用方法其实很多种,详情点击了解。

                截止目前,我们项目除了 redux-logger,暂时未使用到其他中间件。所以我们只要做以下调整就能使用了。

                // store/index.js
                // 省略的代码无需改动,我们只需加一个判断即可。
                
                // 判断是否含有 Redux DevTools 插件
                const enhancers = window.__REDUX_DEVTOOLS_EXTENSION__ ? __REDUX_DEVTOOLS_EXTENSION__() : applyMiddleware(logger)
                
                const store = createStore(reducer, initialState, enhancers)
                

                我们将 Chrome DevTool 的 Tab 切换至 Redux 栏,即可看到效果如下:

                ▼ 含有插件的 Chrome 浏览器

                ▼ 不含插件的 Safari 浏览器

                三、你以为完了吗?

                不不不,Redux DevTools 的使用不仅如此,上面只是最基础的玩法,更多请看 zalmoxisus/redux-devtools-extension

                下面我介绍一种 Advanced Store Setup 进价配置。

                这里我率先引入 redux-saga 中间件,它是用来处理异步操作的,本文只是为了结合 Redux DevTools 的使用而提前引入,对它暂不做详解,后续文章会有。

                $ yarn add redux-saga@1.1.3
                

                因为现在项目引入的东西越来越多,我们对项目结构作一些简单调整,这样看起来会更清晰。

                1. 我们先把原先在 /scr/js/store/index.js 的 reducer 处理函数分离到 /scr/js/reducers/index.js 文件下,其中处理逻辑不变。
                // reducers/index.js
                const reducer = (prevState, action) => {
                  const { type, payload } = action
                  switch (type) {
                    case 'ADD':
                      // 一定要不能修改 state,而是返回一个新的副本
                      // 倘若 state 是引用数据类型,一定要借助 Object.assign、对象展开运算符(...)、其他库的拷贝方法或者自己实现深拷贝方法,返回一个新副本
                      return { ...prevState, count: prevState.count + payload }
                    case 'SUB':
                      return { ...prevState, count: prevState.count - payload }
                    default:
                      // default 或者未知 action 时,返回旧的 state
                      return prevState
                  }
                }
                
                export default reducer
                
                1. 创建 redux-saga 最简单的例子。我们在 src/js 目录下新建 sagas/index.js 文件。
                // sagas/index.js
                import { all, fork } from 'redux-saga/effects'
                
                function* helloSaga() {
                    console.log('Hello Saga!')
                }
                
                function* rootSaga() {
                    yield all([fork(helloSaga)])
                }
                
                export default rootSaga
                
                1. 启动 saga

                这里我们需要用 redux 的另一个高阶函数 compose,它是一个组合函数,与 Redux 本身没有太大关系。它要做的事情就是把 var a = fn1(fn2(fn3(fn4(x)))) 这种嵌套的调用方式改成 var a = compose(fn1, fn2, fn3, fn4)(x) 的方式调用。它里面使用了数组的 Array.prototype.reduce 方法。

                这里不展开讲述了,有兴趣的自行搜索或者查看 redux 源码,文章结尾考虑贴一个 compose 的源码。

                回到正题:

                // store/index.js
                import { createStore, applyMiddleware, compose } from 'redux'
                import createSagaMiddleware from 'redux-saga'
                import logger from 'redux-logger'
                import rootSaga from '../sagas'
                import reducers from '../reducers'
                
                // 初始值
                const initialState = { count: 0 }
                
                // 创建 saga middleware
                const sagaMiddleware = createSagaMiddleware()
                
                // 当使用 middleware 时,我们需要使用 window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ 作判断了
                const composeEnhancers = (typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || compose
                
                // 判断是否含有 Redux DevTools 插件
                const middlewares = typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION__ ? applyMiddleware(sagaMiddleware) : applyMiddleware(sagaMiddleware, logger)
                
                // 创建 Store(也可以不传入 initialState 参数,而将 reducer 中的 state 设置一个初始值)
                const store = createStore(reducers, initialState, composeEnhancers(middlewares))
                
                // 启动 saga
                sagaMiddleware.run(rootSaga)
                
                // 监听 state 变化
                // const unsubscribe = store.subscribe(() => {
                //   console.log('监听 state 变化', store.getState())
                // })
                
                // 解除监听
                // unsubscribe()
                
                export default store
                

                大功告成,如图:

                到此为止,redux-saga 并没有解决什么实际问题,本文只是单纯的为了引入而引入的,后续将会讲解。

                四、题外话

                贴上 Redux 的 compose 方法的源码,很精髓,哈哈!

                export default function compose(...funcs) {
                  if (funcs.length === 0) {
                    return arg => arg
                  }
                
                  if (funcs.length === 1) {
                    return funcs[0]
                  }
                
                  return funcs.reduce((a, b) => (...args) => a(b(...args)))
                }
                
                ]]>
                <![CDATA[从零到一搭建 react 项目系列之(八)]]> https://github.com/tofrankie/blog/issues/61 https://github.com/tofrankie/blog/issues/61 Sat, 25 Feb 2023 11:39:36 GMT 为了方便使用, Redux 的作者封装了一个 React 专用的 React-Redux 库。

                本篇内容主要参考自阮一峰老师的React-Redux 库。

                本篇内容主要参考自阮一峰老师的文章

                React-Redux

                它将组件分成两类:UI 组件(presentational component)和容器组件(container component)。

                1. UI 组件

                const Title = <h3>Title</h3>
                

                主要有以下几个特征:

                • 只负责 UI 的呈现,不带有任何业务逻辑。
                • 没有状态。(即没有 this.state 这个变量)
                • 所有数据由参赛(this.props)提供。
                • 不使用任何 Redux 的 API。

                2. 容器组件

                与 UI 组件相反,它的特征主要有:

                • 负责管理数据和业务逻辑,不负责 UI 的呈现。
                • 带有内部状态。
                • 使用 Redux 的 API。

                3. 我们常见的是容器组件 + UI 组件

                记住就好了:UI 组件负责 UI 的呈现,容器组件负责管理数据和逻辑。

                当同时存在 UI 组件和容器组件时,我们将采用容器组件包裹 UI 组件的策略,前者负责与外部通信,将数据传递给后者,由后者渲染出视图。

                React-Redux 规定,所有的 UI 组件都由用户提供,容器组件则是由 React-Redux 自动生成。也就是说,用户负责视觉层,状态管理则是全部交给它。

                React-Redux API

                1. connect()

                React-Redux 提供的 connect() 方法,用于从 UI 组件生成容器组件。从字面理解的话,就是将两种组件连起来。

                import { connect } from 'react-redux'
                const VisibleTodoList = connect()(TodoList)
                
                // TodoList 是 UI 组件
                // VisibleTodoList 是由 React-Redux 通过 `connect` 方法生成的容器组件
                

                上述案例,并没有定义业务逻辑,它没有任何意义。

                为了定义业务逻辑,需要给出两方面的信息:

                1. 输入逻辑: 外部的数据(即 state 对象)如何转换为 UI 组件的参数。
                2. 输出逻辑: 用户发出的动作如何变为 Action 对象,从 UI 组件传出去。

                connect() 完整 API 如下:

                import { connect } from 'react-redux'
                const VisibleTodoList = connect(mapStateToProps, mapDispatchToProps)(TodoList)
                
                // connect 接收两个参数:mapStateToProps 和 mapDispatchToProps。
                // 前者负责输入逻辑,即将 state 映射到 UI 组件的参数(props)。
                // 后者负责输出逻辑,即将用户对 UI 组件的操作映射成 Action。
                

                2. mapStateToProps

                它是一个函数,作用是建立一个从(外部的) state 对象到(UI 组件的) props 对象的映射关系。

                作为函数,mapStateToProps 执行后应该返回一个对象,里面的每一个键值对就是一个映射。

                const mapStateToProps = (state, ownProps) => {
                  return {
                    // ...
                  }
                }
                
                // mapStateToProps 接收两个参数,并且返回一个对象
                // state 就是我们 store 的全局状态
                // ownProps 是容器组件的 props 对象。使用它之后,如果容器组件的参数发送变化,也会引发 UI 组件重新渲染。
                

                mapStateToProps 会订阅 Store,每当 state 更新时,就会自动执行,重新计算 UI 组件的参数,从而触发 UI 组件的重新渲染。

                connect 方法可以省略 mapStateToProps 参数,这样的话,UI 组件就不会订阅 Store,即 Store 的更新不会引发 UI 组件的更新。

                3. mapDispatchToProps

                它是 connect 方法的第二个参数,用于建立 UI 组件的参数到 store.dispatch 方法的映射。即将 Action 绑定到 UI 组件的 props 对象上。

                1. mapDispatchToProps 是函数时,会得到 dispatchownProps 两个参数。
                2. mapDispatchToProps 是对象时,它每个键名对应 UI 组件的同名参数,键值应该是一个函数,会被当做 Action Creator,返回的 Action 会由 Redux 字段发出。
                // 函数
                const mapDispatchToProps = (
                  dispatch,
                  ownProps
                ) => {
                  return {
                    onClick: () => {
                      dispatch({
                        type: 'SET_VISIBILITY_FILTER',
                        filter: ownProps.filter
                      })
                    }
                  }
                }
                
                // 对象
                const mapDispatchToProps = {
                  onClick: (filter) => {
                    type: 'SET_VISIBILITY_FILTER',
                    filter: filter
                  }
                }
                

                <Provider> 组件

                connect() 方法生成容器组件以后,需要让容器组件拿到 state 对象,才能生成 UI 组件的参数。

                一种解决方法是将 state 对象作为参数,传入容器组件。但是,这样做比较麻烦,尤其是容器组件可能在很深的层级,一级一级将 state 传下去就很麻烦。

                React-Redux 提供了 Provider 组件,可以让容器组件拿到 state

                import { Provider } from 'react-redux'
                import { createStore } from 'redux'
                import todoApp from './reducers'
                import App from './components/App'
                
                let store = createStore(todoApp)
                
                render(
                  <Provider store={store}>
                    <App />
                  </Provider>,
                  document.getElementById('root')
                )
                

                上述代码中,Provider 在根组件外面包了一层,这样一来,App 的所有子组件就默认都可以拿到 state 了。

                它的原理是 React 组件的 context 属性,请看源码:

                class Provider extends Component {
                  getChildContext() {
                    return {
                      store: this.props.store
                    }
                  }
                  render() {
                    return this.props.children
                  }
                }
                
                Provider.childContextTypes = {
                  store: React.PropTypes.object
                }
                

                上面的代码中,store 放在了上下文对象 context 上面。然后,子组件就可以从 context 拿到 store,代码大致如下:

                class VisibleTodoList extends Component {
                  componentDidMount() {
                    const { store } = this.context
                    this.unsubscribe = store.subscribe(() =>
                      this.forceUpdate()
                    )
                  }
                
                  render() {
                    const props = this.props
                    const { store } = this.context
                    const state = store.getState()
                    // ...
                  }
                }
                
                VisibleTodoList.contextTypes = {
                  store: React.PropTypes.object
                }
                

                React-Redux 自动生成的容器组件的代码,就类似上面这样,从而拿到 store

                React-Redux 简单案例

                首先,上述的代码与本项目没有关联,其实只是为了讲解 React-Redux 相关 API 的使用。

                下面我们将结合我们项目来实现一个简单的案例。

                1. 安装 react-redux

                $ yarn add react-redux@7.1.3
                

                2. 使用 Provider 包裹我们的根组件 App

                // pages/Root.js
                import React from 'react'
                import { Provider } from 'react-redux'
                import App from './pages/App'
                import store from './store'
                
                const Root = () => {
                  return (
                    <Provider store={store}>
                      <App />
                    </Provider>
                  )
                }
                
                export default Root
                

                3. 调整我们的 store 以及 reducer

                实际情况下,store 应该是一个对象,因为它存储的数据可能会很多很复杂。此前为了用最简单案例来讲解 store,所以我们将它设置为一个 Number 类型的值。

                下面我们修改一下,初始值设置为 { count: 0 },并修改 reducer 处理函数。

                // store/index.js
                import { createStore } from 'redux'
                
                // Reducer 处理函数
                const reducer = (prevState, action) => {
                  const { type, payload } = action
                  switch (type) {
                    case 'ADD':
                      // 一定要不能修改 state,而是返回一个新的副本
                      // 倘若 state 是引用数据类型,一定要借助 Object.assign、对象展开运算符(...)、其他库的拷贝方法或者自己实现深拷贝方法,返回一个新副本
                      return { ...prevState, count: prevState.count + payload }
                    case 'SUB':
                      return { ...prevState, count: prevState.count - payload }
                    default:
                      // default 或者未知 action 时,返回旧的 state
                      return prevState
                  }
                }
                
                // 初始化值
                const initialState = { count: 0 }
                
                // 创建 Store(也可以不传入 initialState 参数,而将 reducer 中的 state 设置一个初始值)
                const store = createStore(reducer, initialState)
                
                // 监听 state 变化
                // const unsubscribe = store.subscribe(() => {
                //   console.log('监听 state 变化', store.getState())
                // })
                
                // 解除监听
                // unsubscribe()
                
                export default store
                

                4. 我们在 Home 组件引入 connect 方法

                // pages/home/index.js
                import React, { Component } from 'react'
                import { connect } from 'react-redux';
                import store from '../../store'
                
                class Home extends Component {
                  constructor(props) {
                    super(props)
                    this.state = {}
                  }
                
                  handle(type, val) {
                    this.props.simpleDispatch(type, val)
                    // 获取 State 快照
                    console.log(`当前操作是 ${type},count 为:${store.getState().count}`)
                  }
                
                  render() {
                    return (
                      <div>
                        <h3>Home Component!</h3>
                        {/* 将 state 展示到页面上 */}
                        <h5>count:{this.props.count}</h5>
                        <button onClick={this.handle.bind(this, 'ADD', 1)}>加一</button>
                        <button onClick={this.handle.bind(this, 'SUB', 1)}>减一</button>
                      </div>
                    )
                  }
                }
                
                // 将 count 映射到 Home 组件的 props 属性上,通过 this.props.count 即可访问到它。
                const mapStateToProps = (state, ownProps) => {
                  return { count: state.count }
                }
                
                // 同理,它将 simpleDispatch 映射到组件的 props 属性上,通过 this.props.simpleDispatch 访问并由 Redux 发出一个 Action。
                const mapDispatchToProps = (dispatch, ownProps) => {
                  return {
                    simpleDispatch: (type, payload) => {
                      dispatch({ type, payload })
                    }
                  }
                }
                
                // 若忽略 mapStateToProps 参数,store 的更新将不会触发组件重新渲染
                // 若忽略 mapDispatchToProps 参数,默认情况下,store.dispatch 会注入组件 props 中。
                // 若指定了,你就不能通过 this.props.dispatch 来发出 Action 了。
                export default connect(mapStateToProps, mapDispatchToProps)(Home)
                

                5. 效果

                我们看到了 store 的变化,将会反映到页面上。

                最后

                我们的 Redux 最简单的环境已经搭建好了,你学会了吗?但是,实际项目中,这可能远远不够...

                这里抛出几个问题:

                • 大型应用的 Reducer 不会那么简单,那么我们如何拆分呢?
                  • 拆分 Reducer 我们使用 Redux 提供的 combineReducers 来处理。
                • 如何让 Reducer 在异步操作结束之后,自动执行呢?
                  • 解决异步操作自动执行 Reducer 的中间件常用的用 redux-thunkredux-promiseredux-saga 等。我们项目将会采用 redux-sage,后续文章会讲解。
                • 如何利用一些第三方库或者插件来观察 store 的变化?
                  • 观察 store 可以利用 redux-logger 中间件或者 Redux DevTools 浏览器插件。

                由于本文篇幅以及很长了,就下一篇接介绍吧。

                ]]>
                <![CDATA[从零到一搭建 react 项目系列之(七)]]> https://github.com/tofrankie/blog/issues/60 https://github.com/tofrankie/blog/issues/60 Sat, 25 Feb 2023 11:38:17 GMT 上一篇介绍了 react-router,今天介绍 redux。

                什么是 Redux?

                现如今,我们的项目都是由一个一个的模块组成的,组件化的思想更适合大且复杂的项目。

                在此之前,React 并不适合写大型应用,因为它当时还没有很好地解决组件之间的通信<]]> 上一篇介绍了 react-router,今天介绍 redux。

                什么是 Redux?

                现如今,我们的项目都是由一个一个的模块组成的,组件化的思想更适合大且复杂的项目。

                在此之前,React 并不适合写大型应用,因为它当时还没有很好地解决组件之间的通信问题。

                为了解决这个问题,2014 年 Facebook 提出了 Flux 架构的概念,引发了很多的实现。2015 年,Redux 出现,将 Flux 与函数式编程结合在一起,很短时间内就成为了最热门的前端框架。

                什么时候需要 Redux?

                有人说过,挺有道理的哈。

                如果你不知道是否需要 Redux,那就是不需要它。
                

                在以下场景你可以考虑使用它:

                • 组件间需要共享状态
                • 状态需要在任何地方都能拿到
                • 一个组件需要改变另一个组件的状态
                • 一个组件需要改变全局状态

                以上内容引自阮一峰老师的博客

                Redux 设计思想

                • Web 应用是一个状态机,View 与 State 是一一对应的。
                • 所有的状态,保存在一个对象里面。

                基本概念和 API

                先了解一下以下几个概念吧

                • Store:保存数据的容器。由 Redux 提供的 createStore 函数来生成唯一的 Store 对象。

                • StateStore 对象包含的数据。当前的状态,可通过 store.getState() 获取。Redux 规定一个 State 对应一个 View

                • ActionView 发出的动作(如用户点击鼠标等行为)通知 State 要发生改变。Action 是改变 State 的唯一办法。它是一个对象,其中 type 属性是必须的,表示 Action 的名称,其他属性可以自由设置,其社区有一个规范可以参考。如 const action = { type: 'SOMETHING_TODO', payload: 'SOME DATA' }

                • Action Creator:生成 Action 的函数。当有若干 Action 时,全部手写可能略显麻烦,可以定义一个函数来生成 Action,这种函数叫做 Action Creator

                • store.dispatch():是 View 发出 Action 的唯一方式。

                • Reducer:当 Store 收到 Action 之后,必须返回一个新的 State,这样 View 才会发生变化,这种 State的计算过程叫做 Reducer。它是一个纯函数。

                • Pure Function:即纯函数,同样的输入,必定得到同样的输出,且没有任何副作用

                • store.subscribe():监听 State 的变化,一旦 State 发生变化,就自动执行这个函数。它返回一个函数,执行该返回函数就解除监听。

                实现最简单的 Store 案例

                先安装 redux 依赖。

                $ yarn add redux@4.0.4
                

                src/js/store 目录下新建一个 index.js 文件。

                // store/index.js
                import { createStore } from 'redux'
                
                // Reducer 处理函数
                const reducer = (prevState, action) => {
                  const { type, payload } = action
                  switch (type) {
                    case 'ADD':
                      // 一定要不能修改 state,而是返回一个新的副本
                      // 倘若 state 是引用数据类型,一定要借助 Object.assign、对象展开运算符(...)、其他库的拷贝方法或者自己实现深拷贝方法,返回一个新副本
                      return prevState + payload
                    case 'SUB':
                      return prevState - payload
                    default:
                      // default 或者未知 action 时,返回旧的 state
                      return prevState
                  }
                }
                
                // 初始值
                const initialState = 0
                
                // 创建 Store(也可以不传入 initialState 参数,而将 reducer 中的 state 设置一个初始值)
                const store = createStore(reducer, initialState)
                
                // 监听 state 变化
                // const unsubscribe = store.subscribe(() => {
                //   console.log('监听 state 变化', store.getState())
                // })
                
                // 解除监听
                // unsubscribe()
                
                export default store
                

                我们在 Home 组件引入 Store,并修改成:

                // pages/home/index.js
                import React, { Component } from 'react'
                import store from '../../store'
                
                class Home extends Component {
                  constructor(props) {
                    super(props)
                    this.state = {}
                  }
                
                  // Action Creator 函数
                  actionCreator(type, payload) {
                    return { type, payload }
                  }
                
                  handle(type, val) {
                    // 创建 Action
                    const action = this.actionCreator(type, val)
                    // 派发 Action
                    store.dispatch(action)
                    // 获取 State 快照
                    console.log(`当前操作是 ${type},State 为:${store.getState()}`)
                  }
                
                  render() {
                    return (
                      <div>
                        <h3>Home Component!</h3>
                        <button onClick={this.handle.bind(this, 'ADD', 1)}>加一</button>
                        <button onClick={this.handle.bind(this, 'SUB', 1)}>减一</button>
                      </div>
                    )
                  }
                }
                
                export default Home
                

                上面案例,我们做了一个很简单的加减操作。

                通过 redux 提供的 createStore 函数创建了唯一的一个 store 对象,该函数接收三个参数 createStore(reducer, [preloadedState], enhancer) ,其中第一第二个分别是 reducer 函数和初始值,第三个一般是使用中间件时用到,我们后面会用到,这里暂不展开探讨。(点这里了解更多)

                Reducer 函数接收两个参数 reducer(state, action),分别是 previousState(旧状态)Action。在首次执行 stateundefined,可以为它设置一个初始值,或者在 createStore 中传入。它有点类似 Array.prototype.reduce()

                借助我们在 Home 组件下引入 store,并添加两个按钮,对 storestate 做加减操作。

                最后

                我们最简单 redux 案例实现了,但是这个距离我们想要的,还不够哦。

                在此抛出几个问题:

                • 如何接入我们的 Component 了,当 state 发生变化时,使其自动更新 View?
                • 组件如何共享 Store?
                • 如何查看 State 的变化?

                下文继续,未完待续…

                ]]>
                <![CDATA[从零到一搭建 react 项目系列之(六)]]> https://github.com/tofrankie/blog/issues/59 https://github.com/tofrankie/blog/issues/59 Sat, 25 Feb 2023 11:35:05 GMT 在讲解 react-hot-loader 之前,我们先把 react 大致的框架搭建好。包括 react-router、redux、react-redux、redux-saga 等。

                本篇主要介绍 react-router-v4 搭建。

                项目结构调整

                如图,目前所有新增目录并没有内容,仅调整了入口文件 index.js 路径,以及删除了用处不大的 main.js

                *调整后,请修改 webpack.config.js 的入口文件(entry)路径,以及修改 CSS 的导入路径等,否则会编译失败。

                react-router 搭建

                首先,我们的项目是单页 Web 应用(simple-page web application,SPA),它跟我们传统的一个页面对应一个 HTML 不太一样。

                单页应用的路由跳转:

                • 浏览器的 url 发生改变,但其实并没有发送请求,也没有刷新整个页面。
                • 根据我们配置的路由信息,每次切换路由,会根据配置来加载不同的组件,同时 url 地址也会发送变化。
                • 主要有 HashRouterBrowserRouter 两种模式。
                • 原理其实就是使用 HTML5 history API 来使你的内容随着 url 动态改变。

                主要路线是:

                index.htmlindex.jsApp.jsrouter 配置加载所匹配的组件 xxxComponent.js

                我们的模板 HTML 里面有个 <div id="app"></div> 标签作为容器,接着我们在入口文件 index.js 通过 react-domrender 函数将我们的 App.js 组件插入到其中。而 App.js 就是我们路由的根组件,然后我们的路由,又根据配置加载对应的 xxxComponent.js 子组件。

                1. 安装 react-router-dom

                react-router-dom 基于 react-router,加入了在浏览器运行环境下的一些功能。我们无需再安装 react-router 了,因为在 yarn add react-router-dom 时,它就会帮我们安装了。

                $ yarn add react-router-dom@5.2.0
                

                2. 创建 App、Home、About、404 组件

                App 组件

                // App.js
                import React, { Component } from 'react'
                import { BrowserRouter, HashRouter as Router, Route, Switch, Link } from 'react-router-dom'
                import { Home, About, NotFound } from './index'
                
                class App extends Component {
                  render() {
                    return (
                      <Router>
                        <Switch>
                          <Route path="/" exact component={Home} />
                          <Route path="/about" exact component={About} />
                          <Route component={NotFound} />
                        </Switch>
                      </Router>
                    )
                  }
                }
                
                export default App
                

                Home 组件

                // pages/home/index.js
                import React, { Component } from 'react'
                
                class Home extends Component {
                    constructor(props) {
                        super(props)
                        this.state = {}
                    }
                
                    render() {
                        return <div>Home Component!</div>
                    }
                }
                
                export default Home
                

                About 组件

                // pages/about/index.js
                import React, { Component } from 'react'
                
                class About extends Component {
                    constructor(props) {
                        super(props)
                        this.state = {}
                    }
                
                    render() {
                        return <div>About Component!</div>
                    }
                }
                
                export default About
                

                404 组件

                // pages/404.js
                import React, { Component } from 'react'
                import { Link } from 'react-router-dom'
                
                class NotFound extends Component {
                  constructor(props) {
                    super(props)
                    this.state = {}
                  }
                
                  render() {
                    return <div>Page not found! <br /> <Link to="/">Go back</Link></div>
                  }
                }
                
                export default NotFound
                
                // pages/index.js
                export { default as Home } from './home'
                export { default as About } from './about'
                export { default as NotFound } from './404'
                

                3. 修改 src/index.js,以及新增 src/Root.js

                // Root.js
                import React from 'react'
                import App from './pages/App'
                
                const Root = () => {
                  return <App />
                }
                
                export default Root
                
                // index.js
                import React from 'react'
                import { render } from 'react-dom'
                import '../styles/style.css'
                import Root from './Root'
                
                // 最简单的 React 示例
                const rootElem = document.getElementById('app')
                render(<Root />, rootElem)
                

                这里设计多一层 Root 是为了后面引入 Redux 抽离代码,看起来更加简洁一些。

                最终目录结构如下:

                至此,效果出来了

                好了,我们的 react-router 基本搭建好了。

                切换路由,http://localhost:8080/#/about 就能看到页面加载了 About Component!

                关于 react-router 相关的补充说明

                react-router 相关 API 导入问题:

                // 1️⃣ 写法一(推荐)
                import { Swtich, Route, Router, HashHistory, Link } from 'react-router-dom'
                
                // 2️⃣ 写法二,二者是等价的
                import { Switch, Route, Router } from 'react-router'
                import { HashHistory, Link } from 'react-router-dom'
                
                // ************************************************************************
                // Why?
                // 我们从源码里面可以看到类似以下的这一段代码(截取),
                // 比如 Switch,它实际上就是引用了 react-router 的 Switch.
                // 所以我们直接用“写法一”即可
                Object.defineProperty(exports, 'Switch', {
                  enumerable: true,
                  get: function () {
                    return reactRouter.Switch;
                  }
                });
                exports.BrowserRouter = BrowserRouter;
                exports.HashRouter = HashRouter;
                exports.Link = Link;
                exports.NavLink = NavLink;
                
                ]]>
                <![CDATA[从零到一搭建 react 项目系列之(五)]]> https://github.com/tofrankie/blog/issues/58 https://github.com/tofrankie/blog/issues/58 Sat, 25 Feb 2023 11:32:06 GMT 上一篇介绍完 HMR 热更新之后,接着我们会讲解 React 项目搭建。

                题外话

                首先官方提供了一个 create-react-app 脚手架工具来创建 React]]> 上一篇介绍完 HMR 热更新之后,接着我们会讲解 React 项目搭建。

                题外话

                首先官方提供了一个 create-react-app 脚手架工具来创建 React 项目。

                • 本项目不采用该脚手架进行搭建。
                • 下面使用 npm 举例,与 yarn 大同小异。
                • 仅简述,不感兴趣可跳过该内容。
                # 安装 create-react-app
                $ npm install -g create-react-app
                
                # 创建项目
                $ create-react-app your_project
                
                # 打开项目
                $ cd your_project
                
                # 运行项目
                $ npm run start
                
                # 项目打包
                $ npm run build
                
                # 自定义配置,注意执行这个命令是不可逆的
                $ npm run eject
                

                我们通过 create-react-app 创建的项目,可以看到它只有 reactreact-domreact-scripts 等少数依赖。其实,它将 webpackBabelESLint 等依赖包通过 creat-react-app 封装到了 react-scripts 当中,包括基本启动命令都是通过调用 react-scripts 这个依赖下面的命令来完成的。

                除了 startbuildtest 命令。它还有一个 "eject": "react-scripts eject" 命令。它的作用是什么呢?

                它会将原本 creat-react-appwebpackBabelESLint 等相关配置的封装“弹出”。假如我们要将creat-react-app 配置文件进行修改,现有目录下是没有地方修改的。此时,我们就可以通过 npm run eject 命令将原本被封装到脚手架当中的命令弹射出来,然后在项目目录下看到很多配置文件。注意,该命令是单向操作,不可逆。(在 README.md 中对此命令有简述。)

                使用 Babel

                Babel 是什么?

                Babel 是一个工具链,主要用于将 ECMAScript 2015+ 版本的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前和旧版本的浏览器或其他环境中。下面列出的是 Babel 能为你做的事情:

                • 语法转换
                • 通过 Polyfill 方式在目标环境中添加缺失的特性 (通过 @babel/polyfill 模块,babel 7.4 开始废弃 @babel/polyfill)
                • 源码转换 (codemods)
                • 更多! (查看这些 视频 获得启发)

                babel 配置

                1. 安装依赖
                $ yarn add --dev babel-loader@8.0.6 @babel-core/core@7.7.4
                
                1. 添加 module.rules 配置
                // webpack.config.js
                {
                  test: /\.js$/,
                  exclude: /node_modules/,
                  use: 'babel-loader'
                }
                
                1. 创建 .babelrc 配置文件

                我们已经配置了 Babel,但实际上并未做任何事情。在项目根目录中创建一个 .babelrc 配置文件(JSON),并启用一些预设和插件。

                • @babel/preset-env:解析 ES6 语法,它只做语法转换,如 constlet。它并不处理 includesPromise 等 api。
                • @babel/preset-react:解析 JSX 语法。
                • @babel/plugin-transform-runtime:解决全局变量污染,以及重复声明 helper 函数的问题。
                $ yarn add --dev @babel/preset-env@7.7.4
                $ yarn add --dev @babel/preset-react@7.7.4
                $ yarn add --dev @babel/plugin-transform-runtime@7.7.4
                $ yarn add --dev core-js@3.6.0
                
                // .babelrc
                {
                  "presets": [
                    [
                      "@babel/preset-env",
                      {
                        "modules": false, // 将 ESM 转化为其他模块规范,默认值 false。
                        "useBuiltIns": "usage", // 按需加载
                        "corejs": 3, // Babel 建议使用 useBuiltIns 选项时显式设置 core-js 版本
                        "debug": false // 打印插件使用情况
                      }
                    ],
                    "@babel/preset-react" // 解析 JSX 语法
                  ],
                  "plugins": ["@babel/plugin-transform-runtime"]
                }
                

                关于 .babelrc 相关预设与插件详解,在后续会专门出一篇文章整理介绍;

                顺序问题:1 Plugins 在 Presets 前运行;2 Plugins 按照声明次序顺序执行;3 Presets 按照声明次序逆序执行。

                React

                1. 首先安装 reactreact-dom 依赖包。
                $ yarn add react@16.12.0 react-dom@16.12.0
                

                插个话题:为什么 react 和 react-dom 要分成两个包?

                对于具有跨平台能力的 React 体系来说,分包可以将抽象逻辑与平台实现分开。

                • react 包即是抽象逻辑,它包含了 React 的主干逻辑。例如组件实现、更新调度等。
                • react-dom 顾名思义就是一种针对 dom 的平台实现,主要用于在 web 端进行渲染。而声名在外的 React Native 则是原生应用实现,可以通过 React Native 内部的相应机制与操作系统进行通信来调用原生控件进行渲染。

                *以上简述来自知乎 Shockw4ver 回答。

                1. 引入 react
                // index.js
                import React from 'react'
                import { render } from 'react-dom'
                import './main.js'
                import './style.css'
                
                // 最简单的 React 示例
                const rootElem = document.getElementById('app')
                render(<div>Hello React!</div>, rootElem)
                
                if(module.hot) {
                  module.hot.accept('./main.js', () => {
                    console.log('Accept update!')
                  })
                }
                
                <!-- index.html -->
                <!DOCTYPE html>
                <html lang="en">
                  <head>
                    <meta charset="UTF-8" />
                    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
                    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
                    <title><%= htmlWebpackPlugin.options.title %></title>
                  </head>
                  <body>
                    <div id="app"></div>
                  </body>
                </html>
                

                至此,最简单的 React 示例已经成功了。

                接下来,我们将会先介绍接入 react-hot-loader 实现 react 的局部刷新问题。

                最后附上

                // package.json
                {
                  "name": "webpack4_demo",
                  "version": "1.0.0",
                  "description": "从零到一搭建 react 项目",
                  "main": "src/index.js",
                  "repository": "git@github.com:toFrankie/webpack4_demo.git",
                  "author": "Frankie <1426203851@qq.com>",
                  "license": "MIT",
                  "private": true,
                  "scripts": {
                    "dev": "webpack-dev-server --config webpack.config.js --colors",
                    "build": "webpack --config webpack.config.js --progress --colors --mode=production"
                  },
                  "dependencies": {
                    "react": "16.12.0",
                    "react-dom": "16.12.0",
                    "webpack": "4.41.2",
                    "webpack-cli": "3.3.10"
                  },
                  "devDependencies": {
                    "@babel/core": "7.7.4",
                    "@babel/plugin-transform-runtime": "7.7.4",
                    "@babel/preset-env": "7.7.4",
                    "@babel/preset-react": "7.7.4",
                    "babel-loader": "8.0.6",
                    "clean-webpack-plugin": "3.0.0",
                    "core-js": "3.6.0",
                    "css-loader": "3.2.0",
                    "html-webpack-plugin": "3.2.0",
                    "style-loader": "1.0.0",
                    "webpack-dev-server": "3.9.0"
                  }
                }
                
                ]]>
                <![CDATA[从零到一搭建 react 项目系列之(四)]]> https://github.com/tofrankie/blog/issues/57 https://github.com/tofrankie/blog/issues/57 Sat, 25 Feb 2023 11:31:06 GMT 上一篇文章介绍了如何打包成 HTML 文件,接着我们将介绍如何搭建开发环境之热更新

                使用 source-map

                如果按照默认的 production 模式打包代码是,可能会很难追踪错误和警告在源代码中的原始位置。例如,如果将三个源文件( 上一篇文章介绍了如何打包成 HTML 文件,接着我们将介绍如何搭建开发环境之热更新

                使用 source-map

                如果按照默认的 production 模式打包代码是,可能会很难追踪错误和警告在源代码中的原始位置。例如,如果将三个源文件(a.js, b.jsc.js)打包到一个 bundle(bundle.js)中,而其中一个源文件包含一个错误,那么堆栈跟踪就会直接指向到 bundle.js。你可能需要准确地知道错误来自于哪个源文件,所以这种提示这通常不会提供太多帮助。

                为了更容易地追踪 error 和 warning,JavaScript 提供了 source maps 功能,可以将编译后的代码映射回原始源代码。如果一个错误来自于 b.js,source map 就会明确的告诉你。

                source map 有许多可用选项,请务必仔细阅读它们,以便可以根据需要进行配置。

                对于本指南,我们将使用 eval-source-map 选项,这有助于解释说明示例意图(此配置仅用于示例,不要用于生产环境):

                选择一个工具

                在每次编译代码时,手动运行 yarn run build 会显得特别麻烦。

                webpack 提供了几种可选方式,帮助我们在代码发生变化后自动编译代码:

                • webpack watch mode(webpack 观察模式)
                • webpack-dev-server
                • webpack-dev-middleware

                webpack 可以在 watch mode(观察模式)下使用。在这种模式下,webpack 将监视您的文件,并在更改时重新编译。 webpack-dev-server 提供了一个易于部署的开发服务器,具有快速的实时重载(live reloading)功能。 如果你已经有一个开发服务器并且需要完全的灵活性,可以使用 webpack-dev-middleware 作为中间件。

                webapck-dev-serverwebpack-dev-middleware 使用内存编译,这意味着 bundle 不会被保存在硬盘上。这使得编译十分迅速,并导致你的文件系统更少麻烦。

                在大多数情况下你会想要使用 webpack-dev-server,因为这是最简单的开始的方式,并且提供了很多开箱即用的功能。本项目中也将会使用到它。

                简单配置 webpack-dev-server

                # 安装
                $ yarn add webpack-dev-server@3.9.0 --dev
                
                
                // 修改 webpack.config.js 配置
                devServer: {
                  contentBase: './dist',
                  open: true
                }
                
                // 在 package.json 添加 npm script
                "scripts": {
                	"dev": "webpack-dev-server --config webpack.config.js --colors"
                }
                

                以上配置告知 webpack-dev-server,将 dist目录下的文件 servelocalhost:8080 下。(译注:serve,将资源作为 server 的可访问文件)。

                现在,在命令行中运行 yarn run dev,我们会看到浏览器自动加载页面。如果你更改任何源文件并保存它们,web server 将在编译代码后自动重新加载。

                但是同时可以观察到一个细节,每次更改文件页面会重新加载,但是这应该不是我们想要的,我们想要的是模块热替换hot module replacement)。

                HMR 模块热替换配置

                它不适用于生产环境,仅应用于开发环境。

                模块热替换功能会在应用程序运行过程中,替换、添加或删除模块,而无需重新加载整个页面。主要是通过以下几种方式,来显著加快开发速度:

                • 保留在完全重新加载页面期间丢失的应用程序状态。
                • 只更新变更内容,以节省宝贵的开发时间。
                • 在源代码中 CSS/JS 产生修改时,会立刻在浏览器中进行更新,这几乎相当于在浏览器 devtools 直接更改样式。
                // webpack.config.js
                const config = {
                  // ... 其他已有配置不变
                  devServer: {
                    contentBase: './dist',
                    hot: true,
                    open: true
                  },
                  plugins: [
                    // 在 webpack 4 中其实已被弃用,取代它的是 optimization.namedModules 后续的文章会讲解到
                    // new webpack.NamedModulesPlugin(),
                    new webpack.HotModuleReplacementPlugin()
                  ]
                }
                

                我们在入口文件 index.js 引入 main.js

                // main.js
                console.log('This is main.js')
                

                我们来修改 index.js文件,以便当 main.js 内部发生变更时可以告诉 webpack 接受更新的模块。

                // index.js
                import './main.js'
                
                console.log('Hello Webpack!')
                
                if(module.hot) {
                    module.hot.accept('./main.js', () => {
                        console.log('Accept update!')
                    })
                }
                

                注意,若修改了 webpack 配置文件,我们需要重新执行 yarn run dev 使其生效。

                当我们修改 main.js 按下保存之后,会看到浏览器控制台会输出以下信息:

                [WDS] App updated. Recompiling...
                [WDS] App hot update...
                [HMR] Checking for updates on the server...
                This is main.js.
                We modified the main.js file.
                Accept update!
                [HMR] Updated modules:
                [HMR]  - ./src/main.js
                [HMR] App is up to date.
                

                HMR 加载样式

                借助于 style-loader,使用模块热替换来加载 CSS 实际上极其简单。此 loader 在幕后使用了 module.hot.accept,在 CSS 依赖模块更新之后,会将其 patch(修补) 到 <style> 标签中。

                在此之前,我们说过 webpack 只能读取 JavaScript 和 JSON 文件,其他类型的文件需要借助对应的 loader 来处理。

                首先使用以下命令安装两个 loader 。

                $ yarn add --dev css-loader@3.2.0 style-loader@1.0.0
                

                更新 webpack 配置文件

                // webpack.config.js
                const config = {  
                  module: {
                    rules: [
                      {
                        test: /\.css/,
                        use: ['style-loader', 'css-loader']
                      }
                    ]
                  }
                }
                
                module.exports = config
                

                我们在 src 目录下新增 style.css 文件,并在 index.js 中引入。

                /* style.css */
                body {
                    font-size: 30px;
                }
                
                // index.js
                import './main.js'
                import './style.css'
                
                console.log('Hello Webpack!')
                
                if(module.hot) {
                    module.hot.accept('./main.js', () => {
                        console.log('Accept update!')
                    })
                }
                

                然后我们试着修改 style.css 的样式,就能看到页面字体大小随之更改,而无需完全刷新。

                最后

                我们最简单的 HMR 已经配置好了,接着我们将会介绍接入 React 。

                附上完整示例:

                // webpack.config.js
                const path = require('path')
                const HtmlWebpackPlugin = require('html-webpack-plugin')
                const { CleanWebpackPlugin } = require('clean-webpack-plugin')
                const webpack = require('webpack')
                
                const config = {
                  mode: 'development',
                  devtool: 'eval-source-map',
                  entry: {
                    index: path.resolve(__dirname, './src/index.js')
                  },
                  devServer: {
                    contentBase: './dist',
                    hot: true,
                    open: true
                  },
                  plugins: [
                    new HtmlWebpackPlugin({
                      title: '开发环境', // 模板要使用 <title><%= htmlWebpackPlugin.options.title %></title> 配置才生效
                      template: './src/index.html', // 模板路径
                      filename: 'index.html', // 输出 HTML 文件名称
                      inject: 'body', // 插入的 script 标签位于 body 底部,true 同理
                      hash: true, // 加上 hash 值
                      favicon: './src/favicon.ico'
                    }),
                    // 新版无需再指定删除目录,默认删除 output 目录
                    new CleanWebpackPlugin(),
                    // 热更新
                    new webpack.NamedModulesPlugin(),
                    new webpack.HotModuleReplacementPlugin()
                  ],
                  module: {
                    rules: [
                      {
                        test: /\.css/,
                        use: ['style-loader', 'css-loader']
                      }
                    ]
                  }
                }
                
                module.exports = config
                
                // index.js
                import './main.js'
                import './style.css'
                
                console.log('Hello Webpack!')
                
                if(module.hot) {
                    module.hot.accept('./main.js', () => {
                        console.log('Accept update!')
                    })
                }
                
                // 当前项目目录
                webpack4_demo
                  | - /assets
                  | - /config
                  | - /dist
                    | - some outputh file
                  | - /src
                    | - favicon.ico
                    | - index.html
                    | - index.js
                    | - main.js
                    | - styles.css
                  | - .gitignore
                  | - package.json
                  | - README.md
                  | - webpack.config.js
                  | - yarn.lock
                
                ]]>
                <![CDATA[从零到一搭建 react 项目系列之(三)]]> https://github.com/tofrankie/blog/issues/56 https://github.com/tofrankie/blog/issues/56 Sat, 25 Feb 2023 11:28:53 GMT 接着继续介绍 webpack 配置

                从 webpack 4.0 开始,webpack 可以零配置即可进行打包,门槛进一步降低。但是这种方式显然不能满足我们的需求。上面一篇文章中,如果细心的同学会发现,执行 yarn run build 命令,会有一条 WRANI]]> 接着继续介绍 webpack 配置

                从 webpack 4.0 开始,webpack 可以零配置即可进行打包,门槛进一步降低。但是这种方式显然不能满足我们的需求。上面一篇文章中,如果细心的同学会发现,执行 yarn run build 命令,会有一条 WRANING 信息。

                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/

                大概意思,mode 选项没有配置,webpack 会把 production 最为默认配置。

                既然这样,我们调整一下 package.json,给它指定了 mode 选项,WARNING 就没有了。

                // package.json
                {
                  "scripts": {
                    "build": "webpack --progress --colors --mode=production"
                  }
                }
                

                那么这 mode 是什么呢?除了这个,还有什么配置选项呢?

                一、先了解 webpack 一些核心配置

                这里我不再讲述,里面有链接,可以点击去了解。后续文章,对这些配置也会有更详细的介绍。

                二、开始写页面了

                最最最简单的 webpack 打包已经学会了,那么我们如何进行开发呢?

                按照我们传统写前端的方式,它应该要有一个 HTML 文件,然后将 JS、CSS、图片等等引入到其中,这就构成了我们的前端页面。

                但其实,webpack 只能读取 JavaScriptJSON 文件,这是开箱可用的自带能力。那么遇到 CSS、Image、HTML 文件 webpack 如何处理呢?那么它就需要 loader 加载器去处理它们。

                这里我们借助一个 html-webpack-plugin 来生成我们的 HTML 文件。

                # 安装 html-webpack-plugin
                $ yarn add --dev html-webpack-plugin@3.2.0
                

                接着我们在项目根目录添加一个 webpack.config.js 的文件。

                // webpack.config.js
                const HtmlWebpackPlugin = require('html-webpack-plugin')
                
                const config = {
                  plugins: [
                    new HtmlWebpackPlugin()
                  ]
                }
                
                module.exports = config
                

                然后我们重新打包,看下会发生什么?

                $ yarn run build
                

                它给我们生成了 index.html 文件,并将我们打包后的 main.js 引入其中了。然后我们使用浏览器打开 index.html 文件可以看到控制台打印了 Hello Webpack!

                到此为止,我们最最最简单的需求已经完成了。使用 webpack 打包了一个前端页面。

                三、优化我们输出的 HTML 文件

                其实啊,html-webpack-plugin 也可以根据提供的模板HTML来生成的符号我们要求的样子。

                给自己提个需求,在 index.html 要有一个立即函数表达式(IIFE,Immediately Invoked Function Expression)去执行某些动作。(具体什么不重要)

                再借助上述的方法显然是不合适的,那我们怎么办呢?

                1. /src 下新建一个 index.html 文件
                <!-- index.html -->
                <!DOCTYPE html>
                <html lang="en">
                	<head>
                		<meta charset="UTF-8" />
                		<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
                		<meta http-equiv="X-UA-Compatible" content="ie=edge" />
                		<title><%= htmlWebpackPlugin.options.title %></title>
                	</head>
                	<script>
                		!(function() {
                			console.log('给自己加需求,什么人啊!怕不是脑子有毛病吧')
                		})()
                	</script>
                	<body>
                		<div id="app">This is app.</div>
                	</body>
                </html>
                
                1. 修改 webpack.config.js 配置。
                const HtmlWebpackPlugin = require('html-webpack-plugin')
                
                const config = {
                  plugins: [
                    new HtmlWebpackPlugin({
                      title: 'Hi', // 模板要使用 <title><%= htmlWebpackPlugin.options.title %></title> 配置才生效
                      template: './src/index.html', // 模板路径
                      filename: 'index.html', // 输出 HTML 文件名称
                      inject: 'body', // 插入的 script 标签位于 body 底部,true 同理
                      hash: true, // 加上 hash 值
                      favicon: './src/favicon.ico'
                    })
                  ]
                }
                
                module.exports = config
                

                简单介绍一下 html-webpack-plugin 的配置:

                • title:生成 HTML 文件的标题,需配合 <%= htmlWebpackPlugin.options.title %> 使用。
                • template:模板所在的文件路径,它可以是 html、jade、ejs、hbs 等。但是需要安装对应的 loader,否则 webpack 将不能解析。
                • filename:输出 HTML 文件名称。
                • inject:注入选项,可选值:truebodyheadfalse
                  1. true:默认值,script 标签位于 HTML 文件的 body 底部;
                  2. body:同 true;
                  3. head:script 标签位于 head 标签内;
                  4. false:不插入生成的 js 文件,只是单纯的生成一个 HTML 文件。
                • favicon:网站的 favicon 图标。
                • hash:默认值 false。给生成的 js 文件一个独特的 hash 值,该 hash 值是本次 webpack 编译的 hash 值(如下图)。
                • minify、cache、chunks...:这些选择就不介绍了,有兴趣自行搜索查找。
                1. 打包结果:

                四、除此之外

                利用 clean-webpack-plugin 来清除 output 输出文件。

                为什么要这样做呢?

                假如我们输出 HTML 的 filename 是变化的,那么每打包一次输出的 dist 目录,就会不断地积累各种旧版本的输出文件,显然这并不是我们想要的。(可自行体验一下)

                配置 clean-webpack-plugin 也是很简单,如下:

                $ yarn add --dev clean-webpack-plugin@3.0.0
                
                // webpack.config.js
                const HtmlWebpackPlugin = require('html-webpack-plugin')
                const { CleanWebpackPlugin } = require('clean-webpack-plugin')
                
                const config = {
                  plugins: [
                    new HtmlWebpackPlugin({
                      title: 'Hi', // 模板要使用 <title><%= htmlWebpackPlugin.options.title %></title> 配置才生效
                      template: './src/index.html', // 模板路径
                      filename: 'index.html', // 输出 HTML 文件名称
                      inject: 'body', // 插入的 script 标签位于 body 底部,true 同理
                      hash: true, // 加上 hash 值
                      favicon: './src/favicon.ico'
                    }),
                    // 新版无需再指定删除目录,默认删除 output 目录
                    new CleanWebpackPlugin()
                  ]
                }
                
                module.exports = config
                

                最后附上:

                // package.json
                {
                  "name": "webpack4_demo",
                  "version": "1.0.0",
                  "description": "从零到一搭建 react 项目",
                  "main": "src/index.js",
                  "repository": "git@github.com:toFrankie/webpack4_demo.git",
                  "author": "Frankie <1426203851@qq.com>",
                  "license": "MIT",
                  "private": true,
                  "scripts": {
                    "build": "webpack --progress --colors --mode=production"
                  },
                  "dependencies": {
                    "webpack": "4.41.2",
                    "webpack-cli": "3.3.10"
                  },
                  "devDependencies": {
                    "clean-webpack-plugin": "3.0.0",
                    "html-webpack-plugin": "3.2.0"
                  }
                }
                

                未完待续,下一篇继续啊~

                ]]>
                <![CDATA[从零到一搭建 react 项目系列之(二)]]> https://github.com/tofrankie/blog/issues/55 https://github.com/tofrankie/blog/issues/55 Sat, 25 Feb 2023 11:25:16 GMT 一、初始化项目

                初始化项目,生成 package.json

                $ yarn init
                

                $ yarn init

                // package.json
                {
                  "name": "webpack4_demo",
                  "version": "1.0.0",
                  "description": "从零到一搭建 react 项目",
                  "main": "index.js",
                  "repository": "git@github.com:toFrankie/webpack4_demo.git",
                  "author": "Frankie <1426203851@qq.com>",
                  "license": "MIT",
                  "private": true
                }
                

                关于 yarn 常用命令,可以看下这篇文章《yarn 使用以及 npm 的迁移》。

                二、修改项目目录

                • assets:存放一些静态文件
                • config:存放一些配置配置文件
                • src:项目资源,并添加 index.js 作为项目入口文件

                三、使用 webpack 作为打包工具

                本项目使用 webpack 4.x 版本。

                1. 安装依赖包

                注意,需要同时安装 webpack-cli。因为从 webpack 4.x 起,将原先存在于一个依赖包的拆分成 webpackwebpack-cli 两个依赖包。

                In webpack 3, webpack itself and the CLI for it used to be in the same package, but in version 4, they've separated the two to manage each of them better.

                $ yarn add webpack@4.41.2
                $ yarn add webpack-cli@3.3.10
                

                命令执行,依赖包会被放置在 node_modules 目录,同时生成 yarn.lock 锁文件(类似 npm v5 的 package-lock.json)。

                与此同时,package.json 会发生变化。它记录了我们所安装的包以及对应包的版本号。

                {
                  "dependencies": {
                    "webpack": "4.41.2",
                    "webpack-cli": "3.3.10"
                  }
                }
                

                2. webpack 配置

                v4.0.0 开始,webpack 可以不用再引入一个配置文件来打包项目,然而,它仍然有着高度可配置性,可以很好满足你的需求。

                在此之前版本,需要类似 webpack.config.js 配置文件才能打包。

                它的默认入口文件是 src/index.js

                3. webpack 打包

                我们可以在 package.json 添加 scripts 字段来配置 NPM 脚本。

                "scripts": {
                  "build": "webpack --progress --colors"
                }
                

                执行命令 yarn run build,我们看下会 webpack 会帮我做些什么工作?

                它给我们生成了 main.js 文件,于 /dist 目录下。

                结合上图:

                • webpack 的默认入口文件(entry point)是 ./src/index.js
                • webpack 的默认输出目录(output)是 ./dist。它的默认打包 filenamemain.js

                4. webpack 打包(题外话)

                我们在 /src 目录下,新建 main.js

                // main.js
                console.log('This is main.js!')
                

                也可以通过 npx webpack ./src/main.js -o ./build/bundle.js 来打包并输出。但我们项目一般不会这样使用。

                四、至此

                webpack 最简单的配置以及打包已经学会了,接着会介绍 webpack 配置以及 react 搭配。

                附上:

                // package.json
                {
                  "name": "webpack4_demo",
                  "version": "1.0.0",
                  "description": "从零到一搭建 react 项目",
                  "main": "src/index.js",
                  "repository": "git@github.com:toFrankie/webpack4_demo.git",
                  "author": "Frankie <1426203851@qq.com>",
                  "license": "MIT",
                  "private": true,
                  "scripts": {
                    "build": "webpack --progress --colors"
                  },
                  "dependencies": {
                    "webpack": "4.41.2",
                    "webpack-cli": "3.3.10"
                  }
                }
                
                ]]>
                <![CDATA[从零到一搭建 react 项目系列之(一)]]> https://github.com/tofrankie/blog/issues/54 https://github.com/tofrankie/blog/issues/54 Sat, 25 Feb 2023 11:24:31 GMT 详细介绍从零到一搭建基于 webpack 的 react 项目系列文章。基于 Webpack 4、React 16、Redux、redux-saga、ESLint、Prettier、Ant Design 等。

                Github 仓库: tofrankie/webpack4_demo。 本系列文章目前暂未写完,加速 ing...

                该系列文章涉及的知识点,我会尽可能地详写,大部分内容比较基础,大佬请绕路。初衷尽可能让刚入坑的朋友看得懂。本人行文表述以及涉猎的知识面有限,有词不达意或偏颇之处,欢迎指正。

                一、克隆项目

                $ git clone https://github.com/tofrankie/webpack4_demo.git
                
                $ code webpack4_demo
                

                二、创建 .gitignore

                创建 .gitigonre

                $ touch .gitigonre
                

                并添加必要配置,根据需求自行添加。

                # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
                
                # dependencies
                /node_modules
                /.pnp
                .pnp.js
                
                # testing
                /coverage
                
                # production
                /build
                /dist
                
                # misc
                .DS_Store
                .env.local
                .env.development.local
                .env.test.local
                .env.production.local
                
                npm-debug.log*
                yarn-debug.log*
                yarn-error.log*
                

                三、推送至远程仓库

                # 提交暂存
                $ git add .gitignore
                
                # 将本地暂存的修改提交到版本库
                $ git commit -m "initial .gitignore"
                
                # 推送至远程仓库
                $ git push -u origin master
                

                ]]>
                <![CDATA[TS 之对象的类型 — 接口]]> https://github.com/tofrankie/blog/issues/53 https://github.com/tofrankie/blog/issues/53 Sat, 25 Feb 2023 11:18:21 GMT 上一篇介绍了联合类型,今儿接着介绍对象类型。

                在 TypeScript 中,我们使用接口(Interface)来定义对象的类型。

                什么是接口<]]> 上一篇介绍了联合类型,今儿接着介绍对象类型。

                在 TypeScript 中,我们使用接口(Interface)来定义对象的类型。

                什么是接口

                在面向对象语言中,接口(Interface)是一个很重要的概念,它是对行为的抽象,而具体如何行动需要由类(classes)去实现(implement)。

                TypeScript 中的接口是一个非常灵活的概念,处理可用于对类的一部分进行抽象以外,也常用于「对象的形状(Shape)」进行描述。

                简单的例子

                // 接口一般首字母大写
                interface Person {
                    name: string,
                    age: number
                }
                
                let frankie: Person = {
                    name: 'Frankie',
                    age: 22
                }
                

                上述例子中,我们定义了一个接口 Person,接着定义了一个变量 frankie,它的类型是 Person。这样,我们就约束了 frankie 的形状必须是和接口 Person 一致。

                定义的变量比接口少了或者多了一些属性是不允许的:

                interface Person {
                    name: string,
                    age: number
                }
                
                let frankie: Person = {
                    name: 'Frankie'
                }
                // (1)少了 age 属性
                // Property 'age' is missing in type '{ name: string; }' but required in type 'Person'.
                
                
                // (2)多了 height 属性
                let mandy: Person = {
                    name: 'Mandy',
                    age: 22,
                    height: 180
                }
                // Type '{ name: string; age: number; height: number; }' is not assignable to type 'Person'.
                // Object literal may only specify known properties, and 'height' does not exist in type 'Person'.
                

                可见,赋值的时候,变量的形状必须和接口保持一致。

                可选属性

                有时我们希望不要完全匹配一个形状,那么可以用可选属性:

                interface Person {
                    name: string,
                    age?: number
                }
                
                let frankie: Person = {
                    name: 'Frankie'
                }
                
                let mandy: Person = {
                    name: 'Mandy',
                    age: 22
                }
                

                可选属性的含义是该属性可以不存在,但这时仍然不允许添加未定义的属性。

                任意属性

                有时候,我们希望一个接口允许有任意的属性,可以使用如下方式:

                interface Person {
                    name: string,
                    age?: number,
                    [propName: string]: any
                }
                
                let frankie: Person = {
                    name: 'Frankie',
                    sex: 'male',
                    height: 180,
                }
                

                使用了 [propName: string] 定义了任意属性取 string 类型的值。

                需要注意的是,一旦定义了任意属性,那么确定属性和可选属性的类型都必须是它的类型的子集。

                interface Person {
                    name: string,
                    age?: number,
                    [propName: string]: string
                }
                
                let frankie: Person = {
                    name: 'Frankie',
                    age: 20,
                    height: '180',
                }
                
                // Property 'age' of type 'number' is not assignable to string index type 'string'.
                // Type '{ name: string; age: number; height: string; }' is not assignable to type 'Perso
                n'.
                // Property 'age' is incompatible with index signature.
                // Type 'number' is not assignable to type 'string'.  
                

                上述例子中,任意属性的值允许是 string,但是可选属性 age 以及任意属性 height 的值却是 numbernumber 不是 string 的子属性,所以报错了。

                未完待续...

                ]]>
                <![CDATA[TS 之联合类型]]> https://github.com/tofrankie/blog/issues/52 https://github.com/tofrankie/blog/issues/52 Sat, 25 Feb 2023 11:17:54 GMT 上一篇介绍了类型推论,今儿接着介绍联合类型。

                联合类型(Union Types)表示取值可以为多种类型中的一种。

                联合类型使用 |<]]> 上一篇介绍了类型推论,今儿接着介绍联合类型。

                联合类型(Union Types)表示取值可以为多种类型中的一种。

                联合类型使用 | 分隔每个类型。

                简单例子

                // 联合类型
                let myFavoriteNumber: string | number;
                myFavoriteNumber = 'seven';
                myFavoriteNumber = 7;
                

                这里的 let myFavoriteNumber: string | number 的含义是,允许 myFavoriteNumber 的类型可以是 string 或者 number ,但不能是其他类型。

                let myFavoriteNumber: string | number;
                myFavoriteNumber = true;
                
                // Type 'boolean' is not assignable to type 'string | number'.
                

                访问联合类型的属性或方法

                当 TypeScript 不确定一个联合类型的变量到底是哪个类型的时候,我们只能访问此联合类型的所有类型共有的属性或方法

                function getLength(something: string | number): number {
                    return something.length;
                }
                
                // Property 'length' does not exist on type 'string | number'.
                // Property 'length' does not exist on type 'number'.
                

                上述例子中,length 不是 stringnumber 的共有属性,所以会报错。访问 stringnumber 的共有属性是没有问题的。

                function getString(something: string | number): string {
                    return something.toString();
                }
                

                联合类型的变量再被赋值的时候,会根据推论的规则推断出一个类型:

                let myFavoriteNumber: string | number;
                myFavoriteNumber = 'seven';             // 被推断成 string
                console.log(myFavoriteNumber.length);   // 5
                myFavoriteNumber = 7;                   // 被推断成 number
                console.log(myFavoriteNumber.length);   // 编译时报错
                
                // Property 'length' does not exist on type 'number'.
                

                上述例子中,第二行的 myFavoriteNumber 被推断成了 string,访问它的 length 属性不会报错。而第四行的 myFavoriteNumber 被推断成了 number,访问它的 length 属性时自然就会报错了。

                下一篇介绍对象类型。

                The end.

                ]]>
                <![CDATA[TS 之类型推论]]> https://github.com/tofrankie/blog/issues/51 https://github.com/tofrankie/blog/issues/51 Sat, 25 Feb 2023 11:17:33 GMT 上一篇介绍了 TypeScript 的任意值。今儿接着介绍类型推论。

                如果没有明确的指定类型,那么 TypeScript 会依照类型推论(TypeInference)的规则推断出一个类]]> 上一篇介绍了 TypeScript 的任意值。今儿接着介绍类型推论。

                如果没有明确的指定类型,那么 TypeScript 会依照类型推论(TypeInference)的规则推断出一个类型。

                什么是类型推论呢?

                以下代码虽然没有指定类型,但是会在编译的时候报错:

                let myFavoriteNumber = 'seven';
                myFavoriteNumber = 7;
                
                // Type 'number' is not assignable to type 'string'.
                

                事实上,它等价于:

                let myFavoriteNumber: string = 'seven';
                myFavoriteNumber = 7;
                
                // Type 'number' is not assignable to type 'string'.
                

                TypeScript 会在没有明确的指出类型的时候推测出一个类型,这就是类型推论。

                注意:如果定义的时候没有赋值,不管之后有没有赋值,都会被推断成 any 类型而完全不被类型检查。

                let myFavoriteNumber;
                myFavoriteNumber = 'seven';
                myFavoriteNumber = 7;
                

                下一篇介绍联合类型。

                The end.

                ]]>
                <![CDATA[TS 之任意值]]> https://github.com/tofrankie/blog/issues/50 https://github.com/tofrankie/blog/issues/50 Sat, 25 Feb 2023 11:17:16 GMT 上一篇介绍了 TypeScript 的原始数据类型,本文介绍一下任意值。

                任意值(Any)用来表示允许赋值为任意类型。

                一、什么是任意值类型?]]> 上一篇介绍了 TypeScript 的原始数据类型,本文介绍一下任意值。

                任意值(Any)用来表示允许赋值为任意类型。

                一、什么是任意值类型?

                如果是一个普通类型,在赋值过程中改变类型是不被允许的:

                let myFavoriteNumber: string = 'seven';
                myFavoriteNumber = 7;
                
                // Type '7' is not assignable to type 'string'.
                

                但如果是 any 类型,则被允许赋值为任意类型。

                let myFavoriteNumber: any = 'seven';
                myFavoriteNumber = 7;
                

                二、任意值的属性和方法

                在任意值上访问任何属性、任何方法都是允许的:

                let anything: any = 'Hello';
                console.log(anyThing.myName);
                console.log(anyThing.myName.firstName);
                
                let anything2: any = 'Frankie';
                anything2.setName('Mandy');
                anything2.setName('Mandy').sayHello();
                anything2.myName.setFirstName('Ada');
                

                可以认为,声明一个变量为任意值之后,对它的任何操作,返回的内容都是任意值。

                三、未声明类型的变量

                变量如果在声明的时候,未指定其类型,那么它会被识别为任意值类型:

                let something;
                something = 'seven';
                something = 7;
                something.setName('Frankie');
                
                // 这里先说下后面要介绍的“类型推论”
                // let something 会被推断成 any 类型;
                // let something = 7 会被推断成 number 类型
                

                等价于:

                let something: any;
                something = 'seven';
                something = 7;
                
                something.setName('Frankie');
                

                下一篇介绍 TypeScript 之类型推论。

                The end.

                ]]>
                <![CDATA[TS 之原始数据类型]]> https://github.com/tofrankie/blog/issues/49 https://github.com/tofrankie/blog/issues/49 Sat, 25 Feb 2023 11:16:55 GMT JavaScript 的类型分为两种:原始数据类型(Primitive data types)和对象类型(Object data types)。

                原始数据类型包括:BooleanNumber<]]> JavaScript 的类型分为两种:原始数据类型(Primitive data types)和对象类型(Object data types)。

                原始数据类型包括:BooleanNumberStringNullUndefinedSymbol(ES6 新增)

                本节主要介绍前五种原始数据类型在 TypeScript 中的应用。

                布尔值

                布尔值是最基础的数据类型,在 TypeScript 中,使用 boolean 定义布尔值类型。

                let isDone: boolen = true;
                
                // 直接调用 Boolean,也可以返回一个 boolean 类型
                let createByBoolean: boolean = Boolean(true);
                

                注意,使用构造函数 Boolean 创造的对象不是布尔值:

                let createByNewBoolean: boolean = new Boolean(true);
                
                // 不能将类型“Boolean”分配给类型“boolean”。
                //  “boolean”是基元,但“Boolean”是包装器对象。如可能首选使用“boolean”。
                

                事实上,new Boolean() 返回的是一个 Boolean 对象:

                let createByNewBoolean: Boolean = new Boolean(true);
                

                在 TypeScript 中,boolean 是 JavaScript 中的基本类型,而 Boolean 是 JavaScript 中的构造函数,其他基本类型一样(除了 nullundefined),不再赘述。

                数值

                使用 number 定义数值类型:

                let decLiteral: number = 6;
                let hexLiteral: number = 0xf00d;
                let binaryLiteral: number = 0b1010;     // ES6 中二进制表示法
                let octalLiteral: number = 0o744;       // ES6 中八进制表示法
                let notANumber: number = NaN;
                let infinityNumber: number = Infinity;
                

                编译结果:

                var decLiteral = 6;
                var hexLiteral = 0xf00d;
                var binaryLiteral = 10; // ES6 中二进制表示法
                var octalLiteral = 484; // ES6 中八进制表示法
                var notANumber = NaN;
                var infinityNumber = Infinity;
                

                其中 0b10100o744ES6 中的二进制和八进制表示法,它们会被编译为十进制数字。

                字符串

                使用 string 定义字符串类型:

                let myName: string = 'Frankie';
                let myAge: number = 20;
                let sentence: string = `Hello, I'm ${myName}`;
                

                编译结果:

                var myName = 'Frankie';
                var myAge = 20;
                var sentence = "Hello, I'm " + myName;
                

                空值

                JavaScript 没有空值(Void)的概念,在 TypeScript 中,可以用 void 表示没有任何返回值的函数。

                function showName(): void {
                    console.log(`I'm Frankie.`);
                }
                

                声明一个 void 类型的变量没什么用,因为你只能将它赋值为 undefinednull。(任意值类型也是可以的)

                let unusable1: void = undefined;
                let unusable2: void = null;
                

                Null 和 Undefined

                在 TypeScript 中,可以使用 nullundefined 来定义这两个原始数据类型:

                let u: undefined = undefined;
                let n: null = null;
                

                void 的区别是,undefinednull 是所有类型的子类型。也就是说,undefinednull 类型的变量,可以赋值给 stringnumber 等类型的变量:

                // 但是要注意,以下这个在 --strictNullChecks  严格空检查模式下,编译会报错。
                let num1: number = undefined;
                let u: undefined;
                let num2: number = u;
                

                void 类型的变量不能赋值给 number 类型的变量:

                let u: void;
                let num: number = u;
                
                // 编译出错
                // Type 'void' is not assignable to type 'number'.
                

                下一篇介绍任意值。

                The end.

                ]]>
                <![CDATA[TypeScript 初探]]> https://github.com/tofrankie/blog/issues/48 https://github.com/tofrankie/blog/issues/48 Sat, 25 Feb 2023 11:15:59 GMT 本文更新于 2020-02-06。

                TypeScript (中文官网)是具有类型系统,且是 JavaScript 的超集。 它可以编译成普通的 JavaScript 代码。 TypeScript 支持任意浏览器,任意环境,任意]]> 本文更新于 2020-02-06。

                TypeScript (中文官网)是具有类型系统,且是 JavaScript 的超集。 它可以编译成普通的 JavaScript 代码。 TypeScript 支持任意浏览器,任意环境,任意系统并且是开源的。

                1. 安装 TypeScript 转换工具
                $ npm i -g typescript
                
                1. 编写第一个 TS 程序 Hello.ts
                // hello.ts
                console.log('Hello TypeScript!')
                
                1. 使用 tsc 命令转化为 JavaScript 文件:$ tsc ./Hello.ts 即可生成 Hello.js 文件了。

                2. That's all, Thanks!

                TypeScript 与 JavaScript 的选择:

                如何更好的利用 JS 的动态性和 TS 的静态特质,我们需要结合项目的实际情况来进行综合判断。一些建议:

                • 如果是中小型项目,且生命周期不是很长,那就直接用 JS 吧,不要被 TS 束缚住了手脚。
                • 如果是大型应用,且生命周期比较长,那建议试试 TS。
                • 如果是框架、库之类的公共模块,那更建议用 TS 了。

                至于到底用不用 TS,还是要看实际项目规模、项目生命周期、团队规模、团队成员情况等实际情况综合考虑。所以 TypeScript 能不能成为了你的 “刚需” 就看你自己的情况了。(摘自知乎某贴)

                ]]>
                <![CDATA[解决 Unable to resolve your shell environment in a reasonable time.]]> https://github.com/tofrankie/blog/issues/47 https://github.com/tofrankie/blog/issues/47 Sat, 25 Feb 2023 11:14:57 GMT 配图源自 Freepik

                一、背景

                不知道什么时候起,我那服役了 5 年多的 MacB]]> 配图源自 Freepik

                一、背景

                不知道什么时候起,我那服役了 5 年多的 MacBook Pro,每次重启后立刻唤醒 VS Code 的时候,总会弹出提示:

                Unable to resolve your shell environment in a reasonable time. Please review your shell configuration. 无法在合理的时间内解析 shell 环境。请检查 shell 配置。

                打开 VS Code 的方式有:

                • 命令行启动 code path/to/file
                • 点击应用图标

                上述问题,仅在非命令行启动才会出现。也就是说,它就是解决问题的方式之一。但我想,更多人是通过点击 LaunchPad 或 Dock 栏应用图标启动的。

                二、原因

                复现步骤:只要在 Shell 配置文件中添加一行 sleep 30(睡眠 30s,实际上超过 10s 即可),然后重启 VS Code 就能看到该提示。

                Resolving shell environment fails 可知:

                通过非命令行方式启用 VS Code 时,它会启动一个小进程来运行 Shell 环境,也就是执行 .bashrc.zshrc 配置文件。如果 10s 后,Shell 环境仍未解析完成或者由于其他原因导致解析失败,那么 VS Code 将会终止解析,然后就会提示:Unable to resolve your shell environment in a reasonable time. Please review your shell configuration.

                由于使用命令行启动 VS Code,它会继承 Shell 环境变量,因此不会出现上述问题(#717)。

                至于为什么 VS Code 在启动时要解析 Shell ?从其描述上看,大概是因为像 task、debug targets、plugins 等功能需要读取 Shell 环境变量。

                因此,只要确保 Shell 配置不出错,且解析时间在 10s 之内,就能解决问题了。

                官方给出的排查步骤如下:

                The process outlined below may help you identify which parts of your shell initialization are taking the most time:

                • Open your shell's startup file (for example, in VS Code by typing ~/.bashrc or ~/.zshrc in Quick Open (⌘P)).
                • Selectively comment out potentially long running operations (such as nvm if you find that).
                • Save and fully restart VS Code.
                • Continue commenting out operations until the error disappears.

                Note: While nvm is a powerful and useful Node.js package manager, it can cause slow shell startup times, if being run during shell initialization. You might consider package manager alternatives such as asdf or search on the internet for nvm performance suggestions.

                把 Shell 配置文件中一些耗时操作给注释掉以减小解析时间。这里 nvm 被点名了,没错,我确实有用到它。

                三、zsh 启动耗时测试

                本节以 zsh 为例。

                首先,这里利用自带的 time 命令来衡量命令执行用时(包括 zsh)。

                $ /usr/bin/time /bin/zsh -i -c exit
                
                        0.62 real         0.33 user         0.32 sys
                

                time 命令结果输出由 real_timeuser_timesys_time 组成:

                • real_time:表示从程序开始到程序执行结束时所消耗的时间,包括 CPU 的用时和所有延迟程序执行的因素的总和。其中 CPU 用时被划分为 user 和 sys 两部分。
                • user_time:表示程序本身以及它所调用的库中的子进程使用的时间。
                • sys_time:表示由程序直接或间接调用的系统调用执行的时间。

                但注意三者并没有严格的关系。通常单核 CPU 是 real_time > user_time + sys_time,而多核 CPU 则是 real_time < user_time + sys_time,更多请看

                以上 zsh 启动时间仅 0.62s,为了数据更准确,使用 for 循环连续启动 5 次:

                $ for i in $(seq 1 5); do /usr/bin/time /bin/zsh -i -c exit; done
                
                        0.66 real         0.34 user         0.35 sys
                        0.64 real         0.34 user         0.34 sys
                        0.66 real         0.34 user         0.36 sys
                        0.66 real         0.34 user         0.36 sys
                        0.65 real         0.34 user         0.35 sys
                

                如果不加载 ~/.zshrc(使用 --no-rcs 参数)看看有多快(以下显示为 0 是因为太快了):

                $ for i in $(seq 1 5); do /usr/bin/time /bin/zsh --no-rcs -i -c exit; done
                        0.00 real         0.00 user         0.00 sys
                        0.00 real         0.00 user         0.00 sys
                        0.00 real         0.00 user         0.00 sys
                        0.00 real         0.00 user         0.00 sys
                        0.00 real         0.00 user         0.00 sys
                

                另外,zsh 提供了专门的 profiling 模块用于衡量 zsh 各个函数的执行用时。在 ~/.zshrc 配置文件中添加一行以加载 zprof 模块。

                # ~/.zshrc
                zmodload zsh/zprof
                

                接着使用 zprof 命令获取各函数用时数据:

                $ zprof
                num  calls                time                       self            name
                -----------------------------------------------------------------------------------
                 1)    2         446.71   223.36   48.18%    190.57    95.28   20.55%  compinit
                 2)    2         297.80   148.90   32.12%    170.88    85.44   18.43%  nvm
                 3)    1         155.83   155.83   16.81%    155.83   155.83   16.81%  compdump
                 4)    1         403.10   403.10   43.48%    105.29   105.29   11.36%  nvm_auto
                 5)    1         112.56   112.56   12.14%    103.25   103.25   11.14%  nvm_ensure_version_installed
                 6)  771          67.70     0.09    7.30%     67.70     0.09    7.30%  compdef
                 7)    4          32.85     8.21    3.54%     32.85     8.21    3.54%  compaudit
                 8)    1          30.02    30.02    3.24%     30.02    30.02    3.24%  is_update_available
                 9)    2          42.88    21.44    4.63%     12.86     6.43    1.39%  (anon)
                10)    1          14.29    14.29    1.54%     10.26    10.26    1.11%  nvm_die_on_prefix
                11)    1           9.31     9.31    1.00%      9.31     9.31    1.00%  nvm_is_version_installed
                12)  192           7.64     0.04    0.82%      7.45     0.04    0.80%  _zsh_autosuggest_bind_widget
                13)    2           6.04     3.02    0.65%      6.04     3.02    0.65%  update_terminalapp_cwd
                14)    1           5.87     5.87    0.63%      5.87     5.87    0.63%  nvm_supports_source_options
                15)    1          13.21    13.21    1.43%      5.57     5.57    0.60%  _zsh_autosuggest_bind_widgets
                16)    1           4.52     4.52    0.49%      4.52     4.52    0.49%  load_device_zsh_configuration
                17)    1           3.83     3.83    0.41%      3.83     3.83    0.41%  nvm_grep
                18)    3           1.17     0.39    0.13%      1.17     0.39    0.13%  up-line-or-beginning-search
                19)    1           0.84     0.84    0.09%      0.84     0.84    0.09%  colors
                20)    5           1.75     0.35    0.19%      0.53     0.11    0.06%  _zsh_autosuggest_invoke_original_widget
                21)    4           2.11     0.53    0.23%      0.29     0.07    0.03%  _zsh_autosuggest_widget_clear
                22)    4           0.24     0.06    0.03%      0.24     0.06    0.03%  add-zsh-hook
                23)    1           0.23     0.23    0.02%      0.23     0.23    0.02%  update_terminal_cwd
                24)    4           4.03     1.01    0.43%      0.20     0.05    0.02%  nvm_npmrc_bad_news_bears
                25)    2           0.20     0.10    0.02%      0.20     0.10    0.02%  is-at-least
                26)   15           0.19     0.01    0.02%      0.19     0.01    0.02%  _zsh_autosuggest_incr_bind_count
                27)    3           1.98     0.66    0.21%      0.11     0.04    0.01%  _zsh_autosuggest_bound_2_up-line-or-beginning-search
                28)    5           0.10     0.02    0.01%      0.10     0.02    0.01%  _zsh_autosuggest_highlight_reset
                29)    5           0.09     0.02    0.01%      0.09     0.02    0.01%  _zsh_autosuggest_highlight_apply
                30)    4           1.68     0.42    0.18%      0.08     0.02    0.01%  _zsh_autosuggest_clear
                31)    1           0.31     0.31    0.03%      0.07     0.07    0.01%  _zsh_autosuggest_widget_accept
                32)    1           0.06     0.06    0.01%      0.06     0.06    0.01%  nvm_has
                33)    1          13.27    13.27    1.43%      0.06     0.06    0.01%  _zsh_autosuggest_start
                34)    2           0.06     0.03    0.01%      0.06     0.03    0.01%  bashcompinit
                35)    1         409.03   409.03   44.12%      0.06     0.06    0.01%  nvm_process_parameters
                36)    1           0.12     0.12    0.01%      0.06     0.06    0.01%  complete
                37)    3           0.05     0.02    0.01%      0.05     0.02    0.01%  is_theme
                38)    1           0.20     0.20    0.02%      0.05     0.05    0.00%  _zsh_autosuggest_accept
                39)    1           0.35     0.35    0.04%      0.04     0.04    0.00%  _zsh_autosuggest_bound_1_forward-char
                40)    1           0.04     0.04    0.00%      0.04     0.04    0.00%  _zsh_autosuggest_orig_forward-char
                41)    1           0.27     0.27    0.03%      0.03     0.03    0.00%  _zsh_autosuggest_bound_1_accept-line
                42)    1           0.03     0.03    0.00%      0.03     0.03    0.00%  is_plugin
                43)    1           0.03     0.03    0.00%      0.03     0.03    0.00%  omz_termsupport_precmd
                44)    1           0.02     0.02    0.00%      0.02     0.02    0.00%  zle-line-finish
                45)    1           0.02     0.02    0.00%      0.02     0.02    0.00%  _zsh_autosuggest_orig_accept-line
                46)    1           0.02     0.02    0.00%      0.02     0.02    0.00%  detect-clipboard
                47)    2           0.02     0.01    0.00%      0.02     0.01    0.00%  env_default
                48)    1           0.02     0.02    0.00%      0.02     0.02    0.00%  omz_termsupport_preexec
                49)    1           0.02     0.02    0.00%      0.02     0.02    0.00%  zle-line-init
                50)    1           0.01     0.01    0.00%      0.01     0.01    0.00%  nvm_is_zsh
                
                -----------------------------------------------------------------------------------
                ...
                

                从这里可以看出 nvm 用时占比还是很大的。此前我在 Oh My Zsh 的 plugins 加载了一遍 nvm 插件,加上原有的 nvm 加载配置,启动耗时来到 1.6s 左右,就很离谱,是我用错了。

                四、解决 nvm 耗时问题

                当然,影响 zsh 启动用时的不仅仅有 nvm,具体因人而异。

                我这里除了 Oh My Zsh 的一些东西(有空再收拾它)之外,就属 nvm 耗时最大了。

                方案一(不推荐)

                用 Google 搜索 Unable to resolve your shell environment in a reasonable time. 应该很容易找到类似以下的解决方法:

                .zshrc 中添加以下配置:

                function load-nvm {
                  export NVM_DIR="$HOME/.nvm"
                  [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"                   # This loads nvm
                  [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion
                }
                
                # nvm
                if [[ "x${TERM_PROGRAM}" = "xvscode" ]]; then
                  echo 'in vscode, nvm not work; use `load-nvm`'
                else
                  load-nvm
                fi
                

                思路很简单,利用环境变量 TERM_PROGRAM 判断调用 Shell 的应用程序,如果是 VS Code 的话,就不加载 nvm,以减少解析 Shell 的时间,从而解决文章开头的问题。

                但从使用体验上看,有点傻,有点麻烦... 当你使用 VS Code 内置终端时,可以看到:

                # 加载 .zshrc 的输出内容
                in vscode, nvm not work; use `load-nvm`
                
                # 执行 nvm 命令出错,因为启动当前 Shell 时为加载 nvm,自然就找不到了
                $ nvm current
                zsh: correct 'nvm' to 'nm' [nyae]? n
                zsh: command not found: nvm
                
                # 手动加载 nvm(前面声明的一个加载函数)
                $ load-nvm
                
                # 再次执行 nvm 命令
                $ nvm current
                v16.14.0
                

                对于一个长时间使用 VS Code 的用户来说,这是不能容忍的,即使使用 nvm 的次数也是寥寥无几。

                方案二

                在 nvm 文档中,可以发现:

                You can add --no-use to the end of the above script (...nvm.sh --no-use) to postpone using nvm until you manually use it.(详见

                也就是添加 --no-use 参数,以推迟使用 nvm。当你在使用时才会加载。修改 nvm 相关配置,如下:

                # NVM
                export NVM_DIR="$HOME/.nvm"
                [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" --no-use          # This loads nvm
                [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion
                

                然后对比下,--no-use 添加前后 zsh 的启动用时:

                $ for i in $(seq 1 5); do /usr/bin/time /bin/zsh -i -c exit; done
                
                        0.23 real         0.16 user         0.07 sys
                        0.23 real         0.16 user         0.07 sys
                        0.23 real         0.16 user         0.07 sys
                        0.23 real         0.16 user         0.07 sys
                        0.23 real         0.16 user         0.07 sys
                

                从之前的 0.6s 多降低到 0.2s 多。最重要的是,它不会像方案一那样,还要手动执行加载 nvm 的命令。

                方案三

                听说 nvm 相比其他 Node 版本解决方案,要慢很多。可选的解决方案有:

                • n:与 nvm 不同的是,它是一个 npm 包,也就是依赖于 node。而 nvm 是一个独立的程序。
                • fnm:使用 rust 写的,是不是还没用就感觉到快了,哈哈。
                • nvs:这个没了解过...
                • 更多请看...

                关于 管理 node 版本,选择 nvm 还是 n?

                五、参考链接

                ]]>
                <![CDATA[VS Code 配置详解]]> https://github.com/tofrankie/blog/issues/46 https://github.com/tofrankie/blog/issues/46 Sat, 25 Feb 2023 11:14:25 GMT 配图源自 Freepik

                作为一个 JSer,可用于前端开发的工具实在是太多了,像

                作为一个 JSer,可用于前端开发的工具实在是太多了,像 AtomWebStormHBuilderSublime Text 3 等等等......可独爱 VS Code。讲真的,除了刚开始接触前端时用过 Sublime Text 来写代码,后面发现 VS Code 之后,就没换过编辑器了...

                Anyway,哪个舒服用哪个,一搬砖工具而已,哈哈。

                但是,工欲善其事,必先利其器。

                为此,本文会记录一些 VS Code 常用且建议的配置,并及时持续地更新以适应最新版本,也会移除一些废弃的配置项。它更新得实在太频繁了...

                配置

                {
                  // 工作区相关
                  "workbench.iconTheme": "material-icon-theme", // 文件图标主题:Material Icon Theme
                
                  // 编辑器相关
                  "editor.trimAutoWhitespace": false, // 删除自动插入的尾随空白符号
                  "editor.codeActionsOnSave": {
                    // 在保存时运行的代码操作类型
                    "source.fixAll.eslint": true
                  },
                  "editor.formatOnSave": true, // 在保存时格式化文件
                  "editor.defaultFormatter": "esbenp.prettier-vscode", // 定义默认格式化程序,这里指定了 Prettier。
                  "editor.minimap.enabled": false, // 是否显示缩略图
                  "editor.bracketPairColorization.enabled": true, // 启用括号对着色,自 version 1.60 起开始支持。
                  "editor.suggest.preview": true, // 控制是否在编辑器中预览建议结果
                
                  // 文件相关
                  "files.trimTrailingWhitespace": true, // 保存文件时删除文件末尾的空白格
                  "files.associations": {
                    // 配置语言的文件关联
                    "*.wxss": "css",
                    "*.acss": "css",
                    "*.wxs": "javascript",
                    "*.sjs": "javascript",
                    "*.axml": "html",
                    "*.wxml": "html",
                    "*.swan": "html",
                    "*.vue": "vue"
                  },
                  "files.exclude": {
                    // 文件资源管理器根据此设置决定要显示或隐藏的文件和文件夹
                    "**/.git": true,
                    "**/.svn": true,
                    "**/.hg": true,
                    "**/CVS": true,
                    "**/.DS_Store": true
                  },
                
                  // 搜索相关
                  // 在使用搜索功能时,默认将这些文件夹/文件排除在外。
                  // 设置之后可在搜索框下切换“齿轮+减号”的图标来是否执行此选项
                  "search.exclude": {
                    "**/node_modules": true,
                    "**/bower_components": true
                  },
                
                  // ESLint 相关
                  "eslint.workingDirectories": [{ "mode": "auto" }], // 指示 ESLint 根据 package.json、.eslintignore 和 .eslintrc* 文件的位置推断工作目录。
                  "eslint.options": {
                    // 更多详见 https://eslint.cn/docs/developer-guide/nodejs-api#cliengine
                    "extensions": [".js", ".ts", "jsx", ".tsx"] // 要检查的文件扩展名的数组
                  },
                  "eslint.validate": ["javascript", "javascriptreact", "vue", "typescript", "typescriptreact"] // 指定 ESLint 可识别的语言数组,未安装 ESLint 插件时将显示错误。
                
                  // Prettier 相关,需配合 editor.formatOnSave 使用
                  // 当没有显式指定配置文件时,插件中的配置项将作为后备。
                  // 相反地,如果存在任何本地配置文件,将不会使用 VS Code 插件的配置。
                  // 更多:https://github.com/tofrankie/lint-config-custom/blob/main/docs/usage-prettier.md
                  "prettier.printWidth": 120,
                  "prettier.semi": false,
                  "prettier.arrowParens": "avoid",
                  "prettier.singleQuote": true,
                  "prettier.trailingComma": "none",
                }
                

                其他

                参考链接

                ]]>
                <![CDATA[安利一个通过命令行使用 VS Code 打开项目的方法]]> https://github.com/tofrankie/blog/issues/45 https://github.com/tofrankie/blog/issues/45 Sat, 25 Feb 2023 11:14:01 GMT 一次无意中的发现,感觉还挺好用。

                通过如下命令,使用 VS Code 打开项目。

                $ code /path/to/project
                

                安装

                方法非常简单,打开 VS Code,然后打开命令面板,快捷键是 CMD]]> 一次无意中的发现,感觉还挺好用。

                通过如下命令,使用 VS Code 打开项目。

                $ code /path/to/project
                

                安装

                方法非常简单,打开 VS Code,然后打开命令面板,快捷键是 CMD + shift + P(Win 下为 Ctrl + shift + P),接着输入 > code 即可看到,然后点击一下即可安装。

                卸载

                与上述差不多,命令面板输入 > uninstall code,选择对应项点击确认即可卸载。

                ]]>
                <![CDATA[推荐两个 VS Code 图标扩展]]> https://github.com/tofrankie/blog/issues/44 https://github.com/tofrankie/blog/issues/44 Sat, 25 Feb 2023 11:13:35 GMT 推荐两个还不错的图标扩展。

                Material Icon Theme

                Material Icon Theme]]> 推荐两个还不错的图标扩展。

                Material Icon Theme

                Material Icon Theme 采用 Google Material Design 风格,文件/文件夹图标非常丰富。深色模式下使用更佳。

                ▼ 文件图标

                ▼ 文件夹图标

                vscode-icons-mac

                vscode-icons-mac 的文件图标与 vscode-icons/vscode-icons 一致,只是文件夹图标改成了 Mac 风格。

                ]]>
                <![CDATA[常用 VS Code 扩展推荐]]> https://github.com/tofrankie/blog/issues/43 https://github.com/tofrankie/blog/issues/43 Sat, 25 Feb 2023 11:12:31 GMT 配图源自 Freepik

                写在前面

                在此记录日常中 VS Code 用得较多的插件,会]]> 配图源自 Freepik

                写在前面

                在此记录日常中 VS Code 用得较多的插件,会偏向 Web 前端多一些。

                主题类

                作为颜值党,第一反应是更换默认主题。下面这配色一见钟情,深得我心。

                https://github.com/antfu/vscode-theme-vitesse

                目前在使用的插件:

                我的配置 👇

                {
                  "workbench.iconTheme": "material-icon-theme",
                  "workbench.colorTheme": "Vitesse Black",
                  "workbench.productIconTheme": "icons-carbon",
                  "autoDetectColorScheme": false,
                  "workbench.preferredDarkColorTheme": "Vitesse Black",
                  "workbench.preferredLightColorTheme": "Vitesse Light"
                }
                

                编辑类

                HTML/JSX

                支持 JSX/TSX 等扩展语法。

                关于自动重命名标签,其实有内置 editor.linkedEditing(默认关闭)。开启后,不安装第三方插件也可以实现相同的效果,使用 1.87 版本亲测,已支持 HTML、Vue Template、JSX/TSX。但像 Vue 可能要安装 Vue - Official 插件才可以正常识别。

                JS/TS

                个人用得较多的是 impimdclg,对应为 import fs from fsimport { rename } from 'fs'console.log()。其实 VS Code 是有内置 Import Statement,但体验上感觉不如该插件。

                重命名

                • change-case - 更改命名规则,提供了非常丰富的规则,比如 fooBarFOO_BAR 等。

                智能提示类

                • Path Intellisense - 路径智能提示,键入 .../ 即可自动触发路径提示。
                • CSS Modules - 如果你在使用 CSS Module 的话,它可以提供提示,且可以快速跳转至定义处。
                • CSS Peek - 跟上面插件类似,假设在 HTML 中引入了外部 CSS,点击 idclass 处可以快速跳转至定义处。

                快速跳转,需 ⌘ + Click 或 Ctrl + Click 组合。也可使用 ⌘ + ⌥ + Click 在侧栏打开。

                快速跳转,本质上是利用了 Go to Definition(转到定义)的能力。

                检查与格式化类

                这个就不多说了。

                它们都要求本地/全局有按照相应依赖。

                像 ESLint 如果没有安装依赖,VS Code 会有安装的提示。

                像 Stylelint 如果找不到依赖,则不做任何格式化处理。

                像 Prettier 如果本地找不到任意本地配置文件(比如 .prettierrc),则使用插件本身的默认配置,可手动配置。

                其中 CSScomb 是一个相对小众的插件吧,目前已不再维护,我主要用来对 CSS 属性进行排序(保存时)。感兴趣可看这篇文章

                我的配置 👇

                {
                  "editor.formatOnSave": true,
                  "editor.codeActionsOnSave": {
                    "source.fixAll.eslint": "explicit",
                    "source.fixAll.prettier": "explicit",
                    "source.fixAll.stylelint": "explicit"
                  },
                  "editor.defaultFormatter": "esbenp.prettier-vscode",
                  
                  "prettier.printWidth": 100,
                  "prettier.semi": false,
                  "prettier.arrowParens": "avoid",
                  "prettier.singleQuote": true,
                  
                  "eslint.options": {
                    "extensions": [".js", ".ts", "jsx", ".tsx"],
                    "fix": true
                  },
                  "eslint.workingDirectories": [
                    {
                      "mode": "auto"
                    }
                  ],
                  
                  
                  "csscomb.formatOnSave": true,
                  "csscomb.syntaxAssociations": {
                    "*.wxss": "css",
                    "*.acss": "css"
                  },
                  "csscomb.ignoreFilesOnSave": ["node_modules/**"],
                  "csscomb.preset": "~/Library/Mobile Documents/com~apple~CloudDocs/Terminal/csscomb/preset-custom.json",
                }
                

                提示类

                • Import Cost - 显示导入包的大小。
                • Image preview - 图片预览,可支持本地/网络链接图片。
                • TODO Highlight - 高亮 TODO:FIXME: 等注释,以便更明显地提醒还有尚未完成的事情。

                其中 Image preview 有时不能正确解析到一些不以 .png 等常见扩展名结尾的图片链接,可以配置 gutterpreview.urlDetectionPatterns 选项处理。比如微信公众号的图片资源链接。

                我的配置 👇

                {
                  "importCost.mediumPackageDarkColor": "#7cc36e4d",
                  "importCost.smallPackageDarkColor": "#7cc36e4d",
                  "importCost.mediumPackageLightColor": "#7cc36e4d",
                  "importCost.smallPackageLightColor": "#7cc36e4d",
                  "importCost.largePackageLightColor": "#d44e404d",
                  "importCost.largePackageDarkColor": "#d44e404d",
                  
                  "gutterpreview.urlDetectionPatterns": ["/^http(s)*://mmbiz.qpic.cn/i"],
                  
                  "todohighlight.isEnable": true,
                  "todohighlight.isCaseSensitive": true,
                  "todohighlight.keywords": [
                    {
                      "text": "TIPS:",
                      "color": "#fff",
                      "backgroundColor": "#64aaf0",
                      "isWholeLine": false
                    }
                  ]
                }
                

                由于 Import Cost 默认颜色有点喧宾夺主的意思,于是在原来颜色基础上调整到 30% 的不透明度。

                其他

                有时需要写个临时 HTML 示例来调试/验证某些功能,诸如此类的,Live Server 是一个非常不错的选择,很便捷。

                参考链接

                ]]>
                <![CDATA[AlmaLinux 云服务器图形化界面、Chrome 浏览器的安装]]> https://github.com/tofrankie/blog/issues/42 https://github.com/tofrankie/blog/issues/42 Sat, 25 Feb 2023 11:11:26 GMT 配图源自 Freepik

                记录一下~

                一、通过 SSH 客户端登录云服务器

                <]]>
                配图源自 Freepik

                记录一下~

                一、通过 SSH 客户端登录云服务器

                使用 ssh 命令进行登录,如下:

                $ ssh user@hostname
                
                • user 是用户名
                • hostname 是主机名,可以是域名或 IP 地址(通常是公网 IP 地址)。

                用户名与主机名之间使用 @ 隔开。 假设云服务器的用户名是 root,云服务器公网 IP 地址为:130.227.10.82(如有雷同,纯属巧合),命令则是:

                $ ssh root@130.227.10.82
                

                通过 ssh 登录连接云服务器,先会有一个验证过程,以验证远程服务器是否为陌生地址。

                如果是第一次连接云服务器,会有如下类似的输出,表示不认识该机器,提醒是否确认连接。

                The authenticity of host '130.227.10.82 (130.227.10.82)' can't be established.
                ED25519 key fingerprint is SHA256:Vybt22mVXuNuB5unE++yowF7lgA/9/2bLSiO3qmYWBY.
                This key is not known by any other names
                Are you sure you want to continue connecting (yes/no/[fingerprint])? 
                

                若要继续,键入 yes 即可。

                接着,按要求输入所登录 user 的密码(非明文形式的),就能成功登录上云服务器了。👇

                root@130.227.10.82's password:
                	
                	Welcome to Huawei Cloud Service
                
                Last login: Sun Oct 30 11:50:46 2022 from 112.94.175.201
                

                二、安装图形化界面

                后续操作,均以 AlmaLinux 操作系统为例。

                通过 SSH 登录云服务器之后...

                1. 确保系统是最新的。

                $ sudo dnf update
                $ sudo dnf install epel-release
                

                以上为两条命令,请在前一条命令执行完毕(会有类似 Complete! 的提示,后续命令同理)后,才接着执行第二条。

                安装过程可能会有类似 Is this ok [y/N] 的询问式交互,按提示键入 y 以继续(后续操作同理)。

                2. 安装 Gnome GUI 图形化界面

                执行以下命令:

                $ sudo dnf groupinstall "Server with GUI"
                

                耐心等待安装完成即可...

                3. 默认使用图形化界面启动系统。

                执行以下命令:

                $ sudo systemctl set-default graphical
                

                4. 重启云服务器

                执行以下命令以重启云服务器。

                $ reboot
                

                注意,执行 reboot 会自动断开与云服务器的连接,请稍等一会待云服务器重新启动成功后,方可再次连接云服务器,否则通过 ssh 访问会提示 Operation timed out 超时。

                请注意,即使完成以上操作,通过 SSH 客户端来访问云服务器是无法看到图形化界面的。 请在云服务器厂商的 Web 控制台去访问与云服务器。

                三、安装 Chrome 浏览器

                1. 下载 Chrome 浏览器 RPM 包:

                $ wget https://dl.google.com/linux/direct/google-chrome-stable_current_x86_64.rpm
                

                2. 使用 dnf 命令安装 Chrome 浏览器

                $ sudo dnf install google-chrome-stable_current_x86_64.rpm
                

                3. 验证是否安装成功

                执行命令 google-chrome --version 后,如正常打印出版本号表示安装成功了。

                $ google-chrome --version
                Google Chrome 107.0.5304.87 
                

                4. 解决 root 用户无法启动 Chrome 浏览器的问题

                如果我们通过 root 用户登录了云服务器,可能会无法正常启动 Chrome 浏览器。可能会出现以下提示:👇

                Running as root without --no-sandbox is not supported. See https://crbug.com/638180.
                

                解决方法:

                一是,通过命令启动 Chrome 浏览器。👇

                $ /usr/bin/google-chrome-stable --no-sandbox
                

                二是,找到 Chrome 的快捷方式,右键在其 Command 中末尾添加 --no-sandbox 即可。

                注意,上图为 Ubuntu 操作系统,AlmaLinux 操作系统应该也是类似的。

                四、修改服务器 SSH 登录超时时间

                由于「不活动」而导致的 SSH 超时是相当恼人的,通常迫使你重新启动连接并重新开始,因此我们来修改下超时时间。

                为了简化操作,分别执行以下两条命令来设置 ClientAliveIntervalClientAliveCountMax

                $ echo ClientAliveInterval 30 >> /etc/ssh/sshd_config
                $ echo ClientAliveCountMax 120 >> /etc/ssh/sshd_config
                

                该设置表示:

                • ClientAliveInterval:每隔 30s 发送心跳数据
                • ClientAliveCountMax: 允许超时120次数,超过后断开链接

                因此,整个 SSH 会话将会在 1 小时(1200s × 30 = 3600s)内保持活动状态而不会丢失。当然你也可以设置 ClientAliveInterval 3600 来获得相同的结果。

                重启 sshd 服务使设置生效。

                $ sudo systemctl reload sshd
                

                五、参考链接

                The end.

                ]]>
                <![CDATA[Shell 之变量(三)]]> https://github.com/tofrankie/blog/issues/41 https://github.com/tofrankie/blog/issues/41 Sat, 25 Feb 2023 11:11:05 GMT 配图源自 Freepik

                上一篇:

                上一篇:Shell 之变量

                一、前言

                Shell 脚本语言是一门弱类型语言。实际上,它并没有数据类型的概念,无论你输入的是字符串还是数字,都是按照字符串类型来存储的。

                至于是什么类型,Shell 会根据上下文去确定具体类型。

                举个例子:

                $ sum=1+2
                $ echo $sum
                1+2
                

                👆 以上示例,Shell 认为 1+2 是字符串,而不是算术运算之后将结果再赋值给变量 sum

                如果你要进行算术运算,可以用 let 命令或 expr 命令。

                $ let sum=1+2
                $ echo $sum
                3
                

                👆 根据 let 命令,Shell 确定了你想要的是算术运算,因此就能得到 3

                如果非要划分的话,可以有:「字符串」、「布尔值」、「整数」和「数组」。

                二、字符串

                在 Shell 中,最常见的就是字符串类型了。注意几点:

                • 当字符串不包含「空白符」,引号是可选的。若原意就是表示一个字符串,而非整数或数组时,建议使用引号。
                • 由单引号包裹的字符,都会原样输出。且单引号包裹的内容不允许再出现单引号,转义也不行。
                • 由双引号包裹的字符,一些特殊字符(主要有 $`\)会进行扩展或转义。
                • 若要在双引号内输出 $`\" 字符,使用反斜杠 \ 进行转义即可。

                关于引号的用法,推荐看下 👉 Google Shell Style Guide - quoting

                举个例子:

                # ✅
                str=Frankie
                str='Frankie' # 推荐
                str="Frankie"
                str="Frankie's" # 推荐
                str="Frankie's MacBook Pro" # 推荐
                str='Frankie"s MacBook Pro' # 推荐
                

                👆 以上示例语法上是允许的,👇 以下则是错误示例。

                # ❌
                str='Frankie's MacBook Pro'
                

                2.1 获取字符串长度

                语法为 ${#变量名},且 {} 是必须的。

                $ str='Frankie'
                $ echo ${#str} 
                7
                

                2.2 截取子串

                语法为 ${变量名:起始位置:截取长度},注意起始位置从 0 开始计算。

                • 若省略截取长度,表示截取从起始位置开始到结尾的子串。
                • 起始位置可以是负数,但负数前面必须要要有一个空格,以免与设置变量默认值 ${foo:-hello} 的语法混淆。
                • 截取长度可以是负值,表示要排除从字符末尾开始的 N 个字符。

                以上操作,不会改变原字符串,类似 JavaScript 的 Array.prototype.substr() 方法。

                比如 ${str:6:5},在变量 str 中截取第 6 位(包含)开始,长度为 5 的子串。

                $ str='Hello Shell!'
                $ echo ${str:6:5}
                Shell
                

                👆 以上 ${str:6:5} 可以替换为 ${str: -6:-1},表示截取变量 str 中倒数第 6 位(包含)开始,到倒数第 1 个之前的子串。

                2.3 字符串搜索与替换

                Shell 提供了多种搜索、替换的方法。

                具体看这一篇:Bash 字符串操作。请注意,替换方法只有贪婪匹配模式。

                2.4 大小写转换

                利用 tr(transform)命令,可实现大小写转换。

                $ str='Frankie'
                $ echo $str | tr 'a-z' 'A-Z'
                FRANKIE
                $ echo $str | tr 'A-Z' 'a-z'
                frankie
                

                三、布尔值

                定义布尔值跟字符串一样 👇

                truth=true
                falsy=false
                

                注意条件判断即可,举个例子:

                bool=false
                if $bool; then
                  echo 'Done'
                fi
                

                👆 以上示例,只有变量 bool 的值为 false,才会进入 then 语句输出 Done。就算是 bool 未定义、或变量被删除了、或者 bool 的值为空字符,都不会进入 then 语句。

                因此,布尔值正确的判断方式,应使用 test 命令,或使用 test 的简写语法 [ ][[ ]]。比如:

                bool=false
                
                if [ $bool = true ]; then
                  echo 'Done'
                fi
                
                if [ $bool = false ]; then
                  echo 'Error'
                fi
                

                👆 以上判断方式,只有当变量 bool 的值为 truefalse 时,才会命中条件。

                四、整数

                4.1 算术运算

                在 Shell 有两种语法可以进行算术运算。

                • (( ... ))
                • $[ ... ] - 此为旧语法。

                其中 (( ... )) 内部的空白符会被忽略,因此 ((1+1))(( 1 + 1 )) 是一样的。

                (( ... )) 语法不返回值,只要运算结果不为 0,则表示命令执行成功,否则表示命令执行失败。

                若要获取运算结果,需在前面加上 $,即 $(( ... )),使其变成算术表达式,返回运算结果。

                $ echo $((1 + 1))
                2
                

                (( ... )) 支持这些运算操作:加减乘除、取余(%)、指数(**)、自增(++)、自减(--)。

                注意点:

                1. (( ... )) 内部可使用圆括号 () 来改变运算顺序,亦可嵌套。
                2. (( ... )) 内部的变量无需添加 $,因此里面的字符串会被认为是变量。
                3. (( ... )) 内部使用了不存在的变量,不会报错。在 Shell 中访问不存在的变量会返回空值,此时 (( ... )) 会将空值当作 0 处理。
                4. 除法运算的结果总是「整数」。比如 $((5 / 2)) 结果为 2,而不是 2.5
                5. (( ... ))$[ ... ] 语法,都只能做「整数」的运算,否则会报错。
                6. (( ... )) 可以执行赋值运算,比如 $((a = 1)) 会将变量 a 赋值为 1

                4.2 expr 命令

                expr 是一个表达式计算工具。支持:

                • 加法运算:+
                • 减法运算:-
                • 乘法运算:\*
                • 除法运算:/
                • 取模运算:%

                注意,这里乘法运算 \* 要加 \ 转义,否则 Shell 解析特殊符号。还有,非整数参与运算会报错哦!

                $ sum=$(expr 1 + 2)
                $ echo $sum
                3
                

                4.3 let 命令

                let 命令用于将算术运算的结果,赋予一个变量。

                $ let sum=1+2
                $ echo $sum
                3
                

                👆 以上示例,使得变量 sum 等于 1+2 的运算结果。注意,sum=1+2 里面不能有空格。

                4.4 小数运算

                以上 (( ... ))expr 命令均不支持小数运算,如果想进行小数运算,可以借助 bc 计算器或者 awk 命令。

                $ echo 'scale=4; 10/3' | bc
                3.3333
                

                👆 其中 scale=4 表示保留四位小数。

                4.5 逻辑运算

                (( ... )) 也提供了逻辑运算:

                • < 小于
                • > 大于
                • <= 小于或相等
                • >= 大于或相等
                • == 相等
                • != 不相等
                • && 逻辑与
                • || 逻辑或
                • ! 逻辑否
                • expr1 ? expr2 : expr3 三元条件运算符。若表达式 expr1 的计算结果为非零值(算术真),则执行表达式 expr2 ,否则执行表达式 expr3

                当逻辑表达式为真,返回 1,否则返回 0

                五、数组

                在 Shell 中,可以用数组来存放多个值,数组元素之间通过「空格」隔开。只支持一维数组,不支持多维数组。

                在读取数组成员、遍历数组等方面,bash、zsh 之间会有一定的区别。

                5.1 数组起始索引

                现代高级编程语言中,它们的数组起始索引多数都是 0但在 Shell 编程语言中,不同的 Shell 解析器其数组起始索引(下标)可能是不同的。比如 bash 的起始索引 0,zsh 的起始索引是 1

                👇 摘自 StackExchange

                Virtually all shell arrays (Bourne, csh, tcsh, fish, rc, es, yash) start at 1. ksh is the only exception that I know (bash just copied ksh).

                这样看,起始索引为 1 的 Shell 解析器占多数。对于习惯了从 0 开始的我来说,这一点是有的难以接受的。关于数组起始索引,有兴趣的可看:CITATION NEEDED

                arr[0]=a
                arr[1]=b
                

                👆 以上示例,使用 bash 去解析是没问题的。但用 zsh 解析时,就会报错:assignment to invalid subscript range。因为 zsh 的起始索引是 1 开始的,所以索引 0 是一个不合法的下标。

                5.2 创建数组

                可使用以下几种方式来创建数组:

                # 创建空数组
                arr=()
                
                # 创建数组,按顺序赋值
                arr=(val1 val2 ... valN)
                
                # 创建数组,逐项添加
                arr[0]=val1
                arr[1]=val2
                arr[2]=val3
                
                # 创建数组,不按顺序赋值
                arr=([2]=val3 [0]=val1 [1]=val2)
                
                # 创建稀疏数组
                arr=(val1 [2]=val3 val4)
                

                注意几点:

                • 没有赋值的数组元素其默认值是空字符串。
                • 以上 [2]=val3 形式不允许有空格。
                • 元素之间使用空格隔开。

                前面提到,不同类型的 Shell 的起始索引可能是不一样的,因此以上采用 [0][1][2] 等方式设置指定项的值,其表示的第几项元素可能是不相同的。

                还可以这样 👇

                # 可使用通配符,将当前目录的所有 MP3 文件,放入一个数组
                $ mp3s=(*.mp3)
                
                # 用 declare 声明一个关联数组,其索引除了支持整数,也支持字符串。
                $ declare -a ARRAYNAME
                

                5.3 读取数组长度

                前面介绍过,读取字符串长度的语法为 ${#变量名},数组也是类似的。 但要借助数组的特殊索引 @*,将数组扩展成列表,然后再次使用 # 获取数组的长度。语法有以下两种:

                ${#array[*]}
                ${#array[@]}
                
                arr1=(a b c)
                arr2=('aa 00' 'bb 11' 'cc 22')
                
                echo ${#arr1[*]}
                echo ${#arr1[@]}
                
                echo ${#arr2[*]}
                echo ${#arr2[@]}
                

                👆 以上结果均输出 3

                如果是读取数组某项的长度,则使用 ${#数组变量名[下标]} 的形式。比如:

                arr[10]=foo
                echo ${#arr[10]}
                

                👆以上输出 3,它读取的是索引为 10 的元素的值的长度。

                5.4 读取数组单个成员

                其语法为 ${数组变量[下标]},比如:

                $ arr=(a b c)
                $ echo ${arr[1]}
                

                👆 基于起始索引的问题,${arr[1]} 输出值可能是 a,也可能是 b。 注意,里面的 {} 不能省略,否则 $arr[1] 在 bash 里输出位 a[1],相当于 $arr 的值与字符串 [1] 连接,因此结果是 a[1]

                注意以下语法:

                $ arr=(a b c)
                $ echo $arr
                

                👆 在 zsh 中,可以输出数组所有项,即 a b c,在 bash 则是输出数组第一项,即 a

                若想在各种 Shell 环境下统一,最合理的做法是什么呢?使用类似截取字符串子串的语法:

                ${array[@]:offset:length}
                

                其中 array[@] 表示所有元素,offset 表示偏移量(总是从 0 开始),length 表示截取长度。这种语法在不同 Shell 环境下总能获得一致行为。因此 ${arr[@]:0:1} 总能正确地取到数组的第一项。

                因此,需要兼容 bash 和 zsh 时,应使用 ${array[@]:offset:length} 语法而不是 ${array[subscript]} 语法。

                5.5 读取数组所有成员

                利用数组的特殊索引 @*,它们返回数组的所有成员。

                $ arr=(a b c)
                $ echo ${arr[@]}
                $ echo ${arr[*]}
                

                👆 以上 ${arr[@]}${arr[*]} 都输出数组所有成员 a b c。因此,利用这两个特殊索引,可配合 for 循环来遍历数组。

                for item in "${arr[@]}"; do
                  echo $item
                done
                

                5.6 ${arr[@]}${arr[*]} 细节区别

                其差异,主要体现在 for 循环上。

                示例一:

                arr=('aa 00' 'bb 11' 'cc 22')
                
                echo 'use @, with double quote:'
                for item in "${arr[@]}"; do
                  echo "--> item: $item"
                done
                
                echo 'use @, without double quote:'
                for item in ${arr[@]}; do
                  echo "--> item: $item"
                done
                

                👆 都是使用了 @,区别在于 ${arr[@]} 外层是否使用了「双引号」。bash 输出如下:👇

                use @, with double quote:
                --> item: aa 00
                --> item: bb 11
                --> item: cc 22
                
                use @, without double quote:
                --> item: aa
                --> item: 00
                --> item: bb
                --> item: 11
                --> item: cc
                --> item: 22
                

                这里用高级语言来类比:首先,原数组是 ['aa 00', 'bb 11', 'cc 22']。如果带双引号 "${arr[@]}",遍历的是原数组。如果不带双引号 ${arr[@]},相当于内部隐式地做了一次「扁平化」操作,使其变成 ['aa', '00', 'bb', '11', 'cc', '22'] 形式,最终遍历的是扁平化后的产物。

                因此,在遍历数组时,若使用 @ 索引,应该要使用双引号,以保持原有数组的结构。

                示例二:

                arr=('aa 00' 'bb 11' 'cc 22')
                
                echo 'use *, with double quote:'
                for item in "${arr[*]}"; do
                  echo "--> item: $item"
                done
                
                echo 'use *, without double quote:'
                for item in ${arr[*]}; do
                  echo "--> item: $item"
                done
                

                👆 都是使用了 *,区别在于 ${arr[*]} 外层是否使用了「双引号」。bash 输出如下:👇

                use *, with double quote:
                --> item: aa 00 bb 11 cc 22
                
                use *, without double quote:
                --> item: aa
                --> item: 00
                --> item: bb
                --> item: 11
                --> item: cc
                --> item: 22
                

                从结果上看, "${arr[*]}" 把数组所有项当成了一个整体,遍历时只有一项。而不带双引号时, ${arr[*]}${arr[@]} 行为一致,都把原数组扁平化了。

                对于类似 arr=(a b c) 的数组(即数组每一项不包含空白符), 循环中 "${arr[@]}"${arr[@]}${arr[*]} 行为都是一致的,而 "${arr[*]}" 同样是把原数组所有项当做一个整体了。

                提一下,上述示例输出结果均在 bash 下执行。但在 zsh 环境下,这三种 "${arr[@]}"${arr[@]}${arr[*]} 形式,都能「正确」遍历原数组,不会扁平化。

                基于以上细微差异,遍历数组的最佳实践是:应使用 @,且要带上「双引号」。

                5.7 截取数组

                其实前面已经提到过了,其语法就是:${array[@]:offset:length}。比如:

                $ fruits=(apple banana lemon pear plum orange watermelon)
                $ echo "${fruits[@]:3:2}"
                pear plum
                

                其中 offset 为偏移量(总是从 0 开始),length 表示截取长度。它不会改变原数组,类似于 JavaScript 的 Array.prototype.slice() 方法。${array[@]:offset:1} 也是获取数组中某项最兼容的写法。

                length 省略,截取从 offset 开始到结尾的数组项。其中 offsetlength 也支持负值,类似字符串截取,这里不再展开。

                5.8 追加数组成员

                数组末尾追加成员,可以使用 += 赋值操作符,会自动把值最佳到数字末尾。

                $ arr1=(a b c)
                $ arr1+=(d e)
                $ echo "${arr1[@]}"
                a b c d e
                

                注意 += 前后不能有空格,若追加多项元素,则使用空格隔开。 如果知道了数组下标,也可以使用 arr[index]=xxx 形式添加。但注意若 index 位置已有元素,则会产生覆盖效果。

                5.9 删除数组成员

                清空数组,应使用 unset 语法。比如:

                $ arr=(a b c)
                $ unset arr
                $ echo "${arr[@]}"
                
                

                对于 arr='' 这种形式,在 zsh 上可以起到清空数组的作用,而 bash 上是对数组第一项赋值为空字符串而已。

                如是删除某项,可以这样:

                $ arr=(a b c)
                $ unset arr[1]
                echo "${arr[@]}"
                
                

                未完待续...

                ]]> <![CDATA[Shell 之变量(二)]]> https://github.com/tofrankie/blog/issues/40 https://github.com/tofrankie/blog/issues/40 Sat, 25 Feb 2023 11:10:42 GMT 配图源自 Freepik

                上一篇:

                上一篇:Shell 之初识 下一篇:Shell 之数据类型

                在 Shell 中,变量分为「环境变量」和「自定义变量」,也包括一些特殊变量(如 $@$0$$ 等)。(永久性)环境变量在进入 Shell 时已经定义好了, 可以直接使用它们。

                一、读取变量

                当一个变量声明之后,在其作用域访问内,便可被访问。变量读取有两种方式:

                • $变量名
                • ${变量名}

                其中 $foo${foo} 两种写法效果是一样的。前者可以理解为后者的简写形式。

                $ echo $USER
                frankie
                
                $ echo ${USER}
                frankie
                

                对于 ${变量名} 可用于变量与其他字符连用的情况。比如:

                $ a='foo'
                # 以下读取的名为「a_file」的变量,由于不存在,因此输出空字符。
                $ echo $a_file
                
                $ echo ${a}_file
                foo_file
                

                在其他高级语言中,如果引用了一个不存在的变量,可能会抛出错误。比如在 JavaScript 中会抛出 Reference Error。但是,在 Shell 中,如果引用的变量不存在,它不会报错,而是输出「空字符」。

                $ echo $unknow_var
                
                

                二、环境变量

                大多数环境变量,都是「只读」的,可视为「常量」。常见环境变量有:

                • USER - 当前登录用户
                • HOME - 当前用户目录
                • PATH - 系统查找指令时的检索目录
                • PWD - 当前工作目录
                • OLDPWD - 上一个工作目录
                • SHELL - 当前系统默认 Shell
                • 还有很多,不一一列举了...

                全局变量的读取同上。

                同时,Shell 内置的 envprintenv 命令可以查看所有的全局变量。但是,查看单个全局变量的值,echoprintenv 上稍有不同:

                $ echo $PATH
                /usr/local/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
                
                $ printenv PATH
                /usr/local/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
                

                👆 其中 printenv 命令后的变量名,是不用添加 $ 前缀的。

                参考主流的 Shell 编程风格指南:常量和任何导出到环境的(自定义)变量,其变量名应「大写」表示,用下划线「_」分隔,且声明在文件顶部。

                内置的环境变量也是如此,推荐看 Google Shell Style Guide

                三、自定义变量

                自定义变量,就是用户在当前 Shell 中定义的变量。 使用 set 命令可以查看当前 Shell 的变量(包括环境变量和自定义变量),以及所有 Shell 内置函数。

                变量定义形式如下:

                name=value
                

                👆 等号左边为变量名,等号右边为变量值。注意,等号两边不能有空格。

                对于变量名命名限制,大致上与其他高级语言类似。以下仅列举主流编程风格中推荐的用法:

                • 只能使用英文字母、数字和下划线,且首字母不能以数字开头。
                • 不能与内置变量重名。
                • 中间不能有空格,且应使用下划线分割。
                • 全局变量应大写表示,其余的应小写表示。

                对于变量值,此处只说明以下几点:

                • 若变量值不包含「空格」,引号是可以省略的,但不推荐。
                • 变量值应根据实际情况选择用「单引号」或「双引号」包裹。尽管是可选的,但推荐用引号。
                • 对于「单引号」:用于保留字符的字面含义,单引号内的各种特殊字符,都会变为普通字符,原样输出。
                • 对于「双引号」:比单引号宽松,大部分特殊字符在双引号里面,都会失去特殊含义,变成普通字符。但是,三个特殊字符除外:美元符号($)、反引号(`)和反斜杠(\)。这三个字符在双引号之中,依然有特殊含义,会被 Bash 自动扩展。

                其他注意点,下文介绍「数据类型」时再作详细介绍。举个例子:

                $ echo '$USER'
                $USER
                
                $ echo "$USER"
                frankie
                

                四、变量作用域

                跟其他高级语言中一样,Shell 的变量也是有作用域(Scope)的,主要分为三种:

                • 局部变量:其作用域为函数体内部。
                • 全局变量:其作用域为当前 Shell 进程。
                • 环境变量:其作用域为当前 Shell 进程及其子进程。

                局部变量使用 local 命令进行声明,比如 local foo="bar"

                function fn() {
                  local foo="bar"
                }
                fn
                echo $foo # 输出空字符
                

                👆 echo $foo 输出空字符,由于变量 foo 为局部变量,只能在函数 fn 内使用,因此函数外部无法找到变量而输出空字符。

                function fn() {
                  foo="bar"
                }
                fn
                echo $foo # 输出 bar
                

                👆 由于在 Shell 中定义的变量默认为全局变量,因此 echo $foo 输出 bar

                $ foo="bar"
                $ echo $foo # bar
                $ echo $$ # 16531 当前进程 ID
                
                $ bash # 创建并进入子进程
                $ echo $$ # 16846 子进程 ID
                $ echo $foo # 输出空字符
                $ foo="baz" # 在子进程设置变量
                $ echo $foo # 输出 baz
                $ exit # 退出子进程,然后返回父进程中
                
                $ echo $$ # 16531 当前进程 ID
                $ echo $foo # bar
                

                👆 由于全局变量仅在当前进程中有效,因此进入子进程后,找不到变量 foo,因此子进程中输出空字符。同时在子进程中设置的全局变量,不会影响到父进程,因此退出子进程返回到父进程后,父进程的 foo 变量并未发生改变。

                环境变量,根据持久性可以划分为:「永久性环境变量」和「临时性环境变量」。

                • 永久性环境变量:即在 Shell 配置文件(比如 ~/.zshrc~/.bash_profile 等)中的声明的变量,包括内置的环境变量,进入任意一个 Shell 进程都可被引用。因为每启动一个进程之前 Shell 都会去执行相应的配置文件。
                • 临时性环境变量:在全局变量的基础上,使用 export 命令导出,使得当前进程及其子孙进程都可引用。但是,其他 Shell 进程(包括当前进程的父进程)是不可引用的。当退出进程,便会被销毁。

                临时性环境变量,只会向下传递,而不能向上传递,即「传子不传父」。

                使用 export 命令,可以用来向 Shell 子进程输出变量。

                FOO="bar"
                export FOO
                

                五、变量默认值

                前面提到,在 Shell 中,如果读取了一个不存在的变量,它是不会报错的,而是会输出空字符。在 Shell 中,提供了四种特殊语法,与变量的默认值有关,可以保证读取到的结果不为空。

                形式为:${变量名 + : + 操作符 + 值},注意实际使用是没有空格的。比如,${foo:-hello} 表示当变量 foo 存在时返回它的值,不存在则返回 hello。其中 varname 为变量名,: 是固定的,- 为操作符(还有 =+?),hello 为值。

                有以下四种情况:

                $ foo=${bar:-hello} # 相当于 JS 中的 foo = bar || 'hello'
                
                $ foo=${bar:=hello} # 相当于 JS 中的 foo = bar || (bar = 'hello')
                
                $ foo=${bar:+hello} # 相当于 JS 中的 foo = !bar ? 'hello' : ''
                
                $ foo=${bar:?hello} # 相当于 JS 中的 foo = bar || (throw 'hello')
                

                👆 以上四种形式,相同的是:当变量 bar 存在且不为空时,右侧输出结果为变量 bar 的值,因此变量 foo 的值等于变量 bar 的值。

                区别在于:

                • ${bar:-hello} - 表示当变量 bar 存在且不为空时,返回变量 bar 的值,否则返回 hello。目的是为了返回一个默认值。
                • ${bar:=hello} - 表示当变量 bar 存在且不为空时,返回变量 bar 的值,否则将变量 bar 的值设为 hello 并返回 hello。目的是变量的默认值。
                • ${bar:+hello} - 表示当变量 bar 存在且不为空时,返回 hello,否则返回空值。目的是为了判断一个变量是否存在。
                • ${bar:?hello} - 表示当变量 bar 存在且不为空时,返回变量 bar 的值,否则输出错误信息 bar: hello,并中断脚本执行。目的是为了防止变量未定义。

                六、特殊变量

                Shell 提供了一些特殊变量,用户不能对其进行赋值,即只读。

                • $? - 表示上一个命令的退出码。若上一个命令执行成功,则返回 0,因此,若返回值不为 0 ,则表示上一个命令执行失败。
                • $$ - 表示当前 Shell 进程 ID。
                • $_ - 表示上一个命令的最后一个参数。
                • $! - 表示最后一个后台执行的异步命令的进程 ID。
                • $- - 表示当前 Shell 的启动参数。
                • $# - 表示脚本或函数的参数数量。
                • $@ - 表示脚本或函数的全部参数,参数之间使用空格隔开。
                • $* - 表示函数的全部参数,参数之间使用变量 $IFS 值的第一个字符分割,默认为空格,可自定义。
                • $0 - 表示当前 Shell 的名称(在命令直接执行时)或脚本名(在脚本中执行时)。
                • $1 ~ $9 - 表示脚本或函数第一个到第九个参数,也可用 ${0} 表示。超过第 9 个,则用 ${10} 形式获取。

                七、其他

                unset 命令可以用来删除一个变量,基于 Shell 读取不存在的变量会得到空字符的特性,它相当于给变量设置为空字符串。

                declare 命令可以声明一些特殊类型的变量。若在函数中使用 declare 声明的变量,仅函数内有效,相当于 local 命令。

                declare OPTION variable=value
                
                # 主要 OPTION 参数如下:
                # -a: 声明数组变量
                # -i: 声明整数变量
                # -r: 声明只读变量
                # ...
                

                readonly 命令等同于 declare -r,用来声明只读变量,不能改变变量值,也不能 unset 变量。

                let 命令声明变量时,可以直接执行算术表达式。

                $ let sum=1+2
                $ echo sum
                3
                

                如果包含空格,则需要「引号」,比如 let "sum = 1 + 2"

                ]]> <![CDATA[Shell 之初识(一)]]> https://github.com/tofrankie/blog/issues/39 https://github.com/tofrankie/blog/issues/39 Sat, 25 Feb 2023 11:10:24 GMT 配图源自 Freepik

                下一篇:

                下一篇:Shell 之变量

                一、简述

                在计算机科学中,Shell 英文原意是「外壳」,用来区别于 Kernel,即「内核」。

                它是用户与内核之间的一座桥梁,简化操作的同时,又能保证内核的安全。从这个层面看,Shell 是一个「应用程序」,一个功能强大、系统级别的超级应用。Shell 提供了很多方便、实用的工具来降低用户操作成本,例如 cptouchmkdir 等内置命令。

                历史上,出现了很多种 Shell 应用,其实现、功能以及使用方式大部分相同,但又稍有区别。比如 Boush Shell(sh)、Bourne Again shell(bash)、Z Shell(zsh)等。从这个层面来看,Shell 是一个「解析器」。当用户在命令行环境输入操作指令后,由 Shell 解析器进行解析,然后再传递给操作系统,接着转换为内核可识别的指令操作,进而控制硬件,以到达用户操作计算机的目的。主流操作系统中,内置了很多种 Shell 解析器,最常见的有 bash、zsh 等。

                提供了 Shell 的命令行环境的应用,被称为「终端」,即 Terminal。

                不仅如此,Shell 同时又是一种「编程语言」。作为命令语言,它交互式解释和执行用户输入的命令,或者自动地解析和执行预先设定好的一连串的命令。作为程序设计语言,它定义了各种变量和参数,并提供了许多在高级语言中才具有的控制结构,包括循环和分支等。

                所以,Shell 既是一个超级应用,也是一种用户输入的解析器,又能作为一门编程语言。本文将 Shell 视为一门编程语言去介绍。

                二、简单使用

                Shell 命令执行很简单,只要有一个终端工具即可,而且主流操作系统都会内置。例如 Windows 的 CMD、PowerShell,Unix-like 的 Terminal 等。简单地像 JavaScript 一样,只要有一个浏览器就能运行你所编写的代码。例如:

                $ echo 'Hello Shell'
                Hello Shell
                
                $ echo $TERM_PROGRAM
                Apple_Terminal
                

                是不是很简单!没错,你已经入门了,哈哈!

                注意,以上 $ 只是一个「命令行提示符」,一种行文习惯,表示其后的是一个 Shell 命令,常用美元符号 $ 表示。在命令行环境中,不需要输入它。

                由于我是写前端的,这里用 JavaScript 类比一下。以上示例,echo 是 Shell 内置函数,将用户输入转换为标准输出(STDOUT),类似于 JavaScript 中的 console.log() 函数。而 TERM_PROGRAM 则是 Shell 内置的一个全局变量,类似于 JavaScript 的 windowObject 等内置变量(对象),只不过在 Shell 中引用变量,需要在变量名之前加上 $,仅此而已。

                2.1 命令格式

                形式如下:

                $ command [ arg1 arg2 ... [ argN ] ]
                

                其中 command 是具体的一个命令或可执行文件,arg1 arg2 ... argN 是传递给命令的参数,它们都是可选的。在 Shell 里,命令与参数、参数与参数之间用一个「空格」(或 Tab 键)区分。多余空格会被忽略,作用相当于一个空格。

                $ ls -l
                

                以上示例,ls 是命令,-l 是参数,是可选的。参数通常分为命令配置项和用户输入两种:

                • 配置项参数:一般以连字符 --- 开头,通常有长、短两种形式,比如 list 命令中的 -l--list,二者作用完全一致。短形式便于输入,长形式便于理解,仅此而已。
                • 用户输入参数:除配置项之外的参数,比如 git push origin main 中的 originmain 参数,这类参数往往是每个用户传参差异大,因此无法做成配置项。

                2.2 多行命令表示

                在命令行环境中,单个命令通常是一行表示,当命令输入完成后,按下「Enter」键,随即执行该命令,并输出结果。

                太离谱了,Shell 竟然不给我换行输入的机会,哼!

                其实不是的,Shell 是有提供这种输入的。假设命令很长,或者出于阅读性的考虑,我们是需要将命令写成多行的。可以在当前行末尾键入反斜杠 \,再按下「Enter」键即可换行输入了:

                # 相当于 echo foo bar
                $ echo foo \
                bar
                
                # 相当于 echo foobar
                $ echo foo\
                bar
                

                请注意,反斜杠后的回车并不会占用形成一个「空格」效果,因此实际中需注意参数问题。

                2.3 命令结束符与组合符

                通常,一行输入多是单条命令。若要一行中执行多条命令,也是可以的。

                命令结束符「分号」:

                $ command1; command2
                

                👆 以上示例,先执行 command1 命令,待其执行完成之后,才接着执行 command2 命令。而且,无论第一个命令是否执行成功,第二个命令总会执行。

                命令组合符 &&||

                $ command1 && command2
                
                $ command1 || command2
                

                👆 以上示例,其实跟其他高级语言类似,所以看到就能猜出个大概。

                • && 表示只有 command1 执行成功,才会继续执行 command2
                • || 表示如果 command1 执行失败,才会继续执行 command2

                管道符 |

                $ command1 | command2
                
                # 相当于
                $ command1 > tempfile
                $ command2 < tempfile
                $ rm tempfile
                

                👆 以上示例,前一个命令的输出,作为第二个命令的输入。这种方式对命令的简化非常有用。

                以上提到的几种命令组合方式,命令都是「继发」执行的,并不是「并行」执行的。

                三、常用快捷键

                在终端工具中,提供了很多快捷键,可以简化操作。常用的有:

                • Ctrl + L:清除屏幕并将当前行移到页面顶部,作用同 clear 命令。
                • Ctrl + C:中止当前正在执行的命令,有些情况下需要按下多次。
                • Ctrl + A:将光标移至行首。
                • Ctrl + E:将光标移至行尾。
                • 方向键上:向后浏览命令执行历史记录。
                • 方向键下:向前浏览命令执行历史记录。

                注意,不同操作系统下,部分快捷键组合方式会有所差异。主要差异体现在 Windows 下的 Ctrl 与 macOS 下的 Ctrl 键吧。

                论快捷键的话,不得不提的是 Tab 键,可自动补全命令。如果再结合类似 zsh 等强大的 Shell 解析器,那么 Tab 键能玩出各种花样,成倍地提高输入效率。

                ]]> <![CDATA[Linux 文件描述符]]> https://github.com/tofrankie/blog/issues/38 https://github.com/tofrankie/blog/issues/38 Sat, 25 Feb 2023 11:09:53 GMT 配图源自 Freepik

                一、前言

                在 Linux 操作系统中,将一切看作是「文件」。]]> 配图源自 Freepik

                一、前言

                在 Linux 操作系统中,将一切看作是「文件」。

                比如,普通文件、目录文件、链接文件、字符设备文件(如键盘、鼠标、打印机等)、块设备文件(如硬盘、光驱等)、套接字等等。

                内核则是利用文件描述符来访问文件。

                二、文件描述符

                2.1 基本认识

                文件描述符(File descriptor,fd)在形式上是一个「非负整数」。每打开或创建一个文件,内核都会返回一个文件描述符,该文件描述符将最终对应上被打开或被创建的那个文件。

                根据 POSIX 标准要求,每次打开文件时,必须使用当前进程中最小可用的文件描述符号码。

                通常情况下,文件描述符为 0、1、2 的,有着特定的含义:

                文件描述符 用途 POSIX 名称 stdio 流
                0 标准输入 STDIN_FILENO stdin
                1 标准输出 STDOUT_FILENO stdout
                2 标准错误 STDERR_FILENO stderr

                因此,此时再打开一个文件,它的文件描述符将会返回 3。以此类推。

                2.2 文件描述符的限制

                我们知道,进程启动时需要占用内存的,其中一部分内存分配给了文件描述符。因此,我们可以猜到每个进程可打开文件数是有限制的。你是否遇到过「Too many open files」的情况,很大可能就是因为打开文件数超过了进程最大可打开数所导致的。

                每个操作系统最多可打开文件数是不同的,此处不展开介绍了,有兴趣自行了解。

                ulimit

                ulimit 主要是用来限制「进程」对资源的使用情况的,支持各种类型的限制。

                # 查看进程允许打开的最大文件句柄数
                $ ulimit -n
                256
                
                # 设置进程允许打开的最大文件句柄数
                $ ulimit -n <num>
                

                请注意,使用 ulimit -n 设置仅在当前进程生效,因此它属于进程级别的控制。

                ...

                2.3 进程级别的文件描述符表

                当一个 Linux 进程启动后,内核会创建一个进程控制块(Process Control Block,PCB),里面维护着一个「文件描述符表,File descriptor table」,用于记录当前进程所有可用的文件描述符,即当前进程所有打开的文件。

                因此,文件描述符表是进程级别的,每个进程都会有一个。

                文件描述符表的每个条目包含两个域:

                • 控制标志(fd flags)
                • 文件指针(file pointer):指向打开文件表对应条码的指针。

                实际上,文件描述符就是文件描述符表的索引。

                2.4 系统级别的打开文件表

                系统内核对所有打开的文件都有一个系统级别的文件描述符表(Open file description table),也称为打开文件表(Open file table)。该表的每个条目称为文件句柄(File handle),它存储了与一个打开文件相关的全部信息。

                • 当前文件偏移量(file offse)
                • 打开文件时的标识(status flags)
                • 文件访问模式
                • 与信号驱动相关的设置
                • 当前文件 inode 指针
                • 文件类型和访问权限
                • 指向该文件所持有的锁列表指针
                • 还有文件的各种属性...

                2.5 系统级别的 i-node 表

                一个文件系统只有一个 i-node 表。想要真正读写文件,还要通过打开文件标的 i-node 指针进入 i-node 表。

                i-node 表的每个条目包含了以下信息:

                • 文件类型,例如普通文件、套接字或 FIFO 等。
                • 文件大小
                • 文件锁
                • 文件时间戳,比如 mtime、atime、ctime。

                2.6 三个表之间的关系

                文件描述表在每个进程中都会有且仅有一个,而打开文件表和 i-node 表,它们在整个文件系统中只有一个。

                三者关系如下:

                关系图源自《The Linux Programming Interface》一书。图示说明如下:

                • 在进程 A 中,文件描述符 120 都指向了打开文件表中的索引为 23 的句柄,这可能是调用了 dup()dup2()dcntl()、或者对同一个文件多次调用了 open() 函数形成的。

                • 进程 A 的文件描述符 2 和进程 B 的文件描述符 2 都指向了同一个打开文件表句柄,可能是因为调用 fork() 后出现的,子进程会继承父进程的打开文件描述符表,也就是子进程继承了父进程的打开文件。或者是某进程通过 Unix 域套接字将一个打开的文件描述符传递给另一个进程。或者不通过进程独自调用 open() 函数打开同一个文件是正好分配到与其他进程打开该文件描述符一样。

                • 进程 A 的文件描述符 0 和进程 B 的文件描述符 3 分别指向了不同的打开文件句柄,但这些句柄均指向 i-node 表相同的条目(即同一个文件),发生这种情况是因为每个进程各自对同一个文件发起了 open() 调用。同一个进程两次打开同一个文件,也会发生类似情况。

                从图中可知,我们可以将文件描述符理解为进程中文件描述符表的「索引」,或者把文件描述符表看作为一个数组,那么文件描述符就是数组的下标。

                当进行 I/O 操作时,会传入 fd 作为参数,先从当前进程文件描述符表查找该 fd 对应的条目,得到文件指针。根据文件指针,在打开文件表取出对应的那个已经打开的文件句柄,得到 inode 指针。根据 inode 指针,在 i-node 表中找到对应条目,从而定位到文件真正的位置,然后进行 I/O 操作。

                2.7 小结

                基于前面的介绍,可知:

                • 同一进程的不同文件描述符可以指向同一文件。
                • 不同进程可以拥有相同的文件描述符。
                • 不同进程的同一文件描述符可以指向不同的文件(比较特殊的是文件描述符 012 分别对应标准输入、标准输出、标准错误)。
                • 不同进程的文件描述符也可以指向同一文件。

                未完待续...

                ]]>
                <![CDATA[Linux 文件的 mtime、atime、ctime 三种时间]]> https://github.com/tofrankie/blog/issues/37 https://github.com/tofrankie/blog/issues/37 Sat, 25 Feb 2023 11:09:33 GMT 配图源自 Freepik

                在上一篇文章

                在上一篇文章 Linux 链接文件详解中提到了 inode 信息。在 inode 的中包含三种文件时间戳:mtimeatimectime

                含义

                简写 全称 描述
                mtime Modify Time 修改时间,表示文件内容最后一次修改的时间。
                atime Access Time 访问时间,表示文件最后一次访问(读取或执行)的时间。
                ctime Change Time 改变时间,表示文件最后一次改变(包括属性、权限、链接个数等)的时间。

                获取文件时间

                可以选择通过 statls 命令获取文件时间。

                $ stat -x README.md
                  File: "README.md"
                  Size: 21           FileType: Regular File
                  Mode: (0644/-rw-r--r--)         Uid: (  501/ frankie)  Gid: (   20/   staff)
                Device: 1,5   Inode: 59499107    Links: 1
                Access: Sun Jul 10 19:57:53 2022
                Modify: Sun Jul 10 19:57:51 2022
                Change: Sun Jul 10 19:57:51 2022
                 Birth: Sun Jul 10 19:57:26 2022
                

                使用 ls 命令的话,有几个参数:

                • -l 列举信息时间一栏默认为表示文件的 mtime
                • -lu 列举信息时间一栏表示文件 atime,其中 -u 可用 --time=atime 长格式代替。
                • -lc 列举信息时间一栏表示文件 ctime,其中 -c 可用 --time=ctime 长格式代替。

                更多可看这里

                $ ls -l
                total 56
                -rw-r--r--  1 frankie  staff   21  7 10 19:57 README.md
                -rw-r--r--  1 frankie  staff   13  7 10 20:29 a-symlink-absolute.txt
                -rw-r--r--  1 frankie  staff   13  7 10 20:29 a-symlink-relative.txt
                -rw-r--r--  1 frankie  staff   13  7 10 20:29 a-symlink.txt
                -rw-r--r--@ 1 frankie  staff   13  7 10 20:00 a.txt
                -rw-r--r--  1 frankie  staff  960  7 10 20:29 a.txt的替身
                -rw-r--r--  1 frankie  staff   92  7 10 19:57 package.json
                drwxr-xr-x  2 frankie  staff   64  7 10 19:57 src
                

                相关命令操作对文件时间的影响

                其中 ❌ 表示对应时间不发生改变,✅ 则表示对应时间发生了改变。

                命令 mtime atime ctime
                cat
                cp
                mv
                chmod
                chown
                touch
                >、>>
                vim、vi(不修改时)
                vim、vi(修改时)
                执行可执行文件

                关于 catcp 等命令不会更改 atime 的原因

                从命令语义看,无论是读取文件内容,还是复制文件,肯定是访问源文件的,这样按理来讲就应该更新文件的 atime。在 Linux kernel 2.6 以前确实如此,但后来发现 OS 更新 atime 的开销很大,会造成频繁的写操作。

                为了提高性能,虽然可以完全禁止 atime 的修改(详看 mount 命令的 noatimenodiratime 选项),但这会破坏 POSIX 兼容性。比如某些备份软件需要通过对比 atimemtimectime 的时间来判断是否需要进行备份。

                针对这个问题,Linux kernel 2.6.20 开始为 mount 引入了 relatime 选项,并从 2.6.30 开始默认开启此选项。

                当开启 relatime 选项后,只有当 atime < mtimeatime < ctime 时,才会去更新 atime。通过这种方式,既能大幅度减少 atime 引起的写操作,也保证了备份软件不受到影响。

                此设计非常精妙,这也成了很多系统的默认设置。

                更多请看

                The end.

                ]]>
                <![CDATA[Linux 链接文件详解]]> https://github.com/tofrankie/blog/issues/36 https://github.com/tofrankie/blog/issues/36 Sat, 25 Feb 2023 11:09:00 GMT 配图源自 Freepik

                一、前言

                以 macOS 为例,在 Finder 中右键可]]> 配图源自 Freepik

                一、前言

                以 macOS 为例,在 Finder 中右键可以看到「复制」、「拷贝」、「制作替身」等操作。它们之间有什么区别呢?另外,还有本文重点讨论的「软链接」、「硬链接」又是什么呢?

                从操作结果表面看,似乎都生成了一个「副本」,但有着本质上的区别。

                1. 复制与拷贝

                它们的共同点是都会生成一个文件的副本,而且副本与原始文件是两个独立的文件。也就是说,修改其一不会对另一个造成任何影响。二者区别在于,复制操作会立刻在当前目录下生成一个副本,而拷贝操作则会将副本放至剪贴板,等待被粘贴。

                2. 软链接与硬链接

                操作系统内置的文件管理器未直接提供相关功能,通常只能通过命令行操作。从操作结果看,它们都产生了一个“副本”(打个引号),但这个副本与原始文件是有关联的。

                3. 替身(Alias)

                它是 macOS 操作系统独有的一个概念。它是结合了软链接和硬链接的优缺点的一种解决方案。

                4. 提前看看

                可以提前观察一下,它们之间有哪些特点?

                $ ls -il
                total 40
                59499107 -rw-r--r--  1 frankie  staff   21  7 10 19:57 README.md
                59499302 -rw-r--r--  2 frankie  staff   13  7 10 20:00 a-hardlink.txt
                59499638 lrwxr-xr-x  1 frankie  staff   49  7 10 20:09 a-symlink-absolute.txt -> /Users/frankie/Desktop/Web/Temp/simple-link/a.txt
                59499576 lrwxr-xr-x  1 frankie  staff    5  7 10 20:08 a-symlink-relative.txt -> a.txt
                59499486 lrwxr-xr-x  1 frankie  staff    5  7 10 20:04 a-symlink.txt -> a.txt
                59499302 -rw-r--r--  2 frankie  staff   13  7 10 20:00 a.txt
                59499900 -rw-r--r--@ 1 frankie  staff  960  7 10 20:12 a.txt的替身
                59499003 -rw-r--r--  1 frankie  staff   92  7 10 19:57 package.json
                59499138 drwxr-xr-x  2 frankie  staff   64  7 10 19:57 src
                

                观察第二列(文件类型 + 文件权限)可知:

                • d 开头的是目录
                • l 开头的是软链接文件
                • - 开头、以 @ 结尾的是替身文件
                • - 开头的可能是普通文件、硬链接文件、替身文件

                二、基础概念

                1. 链接

                通常人们常说的链接多指 URL,表示互联网上的网页、图像、音频、文件等资源。

                但本文所提之「链接」是 POSIX 标准中的一个概念,是一种文件共享的方式,这种文件称为「链接文件」。目前主流操作系统都支持链接文件。

                链接文件分为:

                下文将称为「软链接」和「硬链接」。

                2. 文件与目录

                人们在管理文件时,通常会划分为「文件」和「目录」两种,后者是承载前者的一种容器,这种划分方式对人类是友好的,可便于归纳整理。但在操作系统角度,目录(directory)本身也是一种文件,下称为目录文件。

                若无特殊说明,下文所提到的「文件」均从操作系统角度出发,因此它可能包含了文件和目录。

                3. 文件存储

                我们的文件是存储在「硬盘」上的,硬盘上的最小存储单位是「扇区 Sector」。操作系统读写硬盘时,最小的存取单位是「块 Block」。一个扇区存储 512 个字节(0.5KB),而一个块大小最常见为 4KB,即连续的八个扇区组成了一个块。

                一个文件分为数据部分和元信息两部分,其中文件数据都存储在「Block」中,文件元信息存储在一个名为「inode」的区域。

                每一个文件都有对应的 inode,它包含了文件的元信息,比如创建者、创建日期、大小等信息。需要注意的是,文件名称并不包含在 inode 信息中(下文会介绍),而且 inode 也会占用硬盘空间

                4. 文件区分

                通常来说,人们是通过「文件名称」来区分不同文件的,更严谨一点应该是「文件路径」。

                但是,操作系统是通过一个称为「inode 号码」(一个 inode 对应一个 inode 号码)的东西来区分不同文件的。操作系统允许不同的文件名称可以具有同一个 inode 号码。inode 号码相同的文件,其文件数据是相同的,都指向了硬盘中存储的同一份数据(自然也就只占用了一份硬盘空间)。这才是本质意义上的同一文件。

                以祖国的公民身份证系统来比喻,身份证号码对应 inode 号码,姓名对应为文件名称。在全国范围内,身份证号码是唯一的,姓名则不是,因此若要确定具体的某个人,只能通过身份证去查找。姓名和文件名称一样允许重名,当然在操作系统中同一路径下文件不允许重名。

                细心的同学会发现,这里面其实是有界定范围的,身份证系统中的证件号码只能确保在国内是唯一的。假设某个国家也有一套类似的身份证系统,我国的小明与他国的小花证件号码是有可能重复的,那么在全世界范围内,就无法通过这个证件号码指向具体的某个人。在操作系统上同样存在这个问题,原因是多数操作系统都支持挂载「多个」文件系统,而 inode 号码则是由文件系统进行分配。尽管同一个文件系统中 inode 号码不会重复,但是多个文件系统之间是会出现相同的 inode 号码的。可以将操作系统比喻为全世界,文件系统比喻成国家,道理是一样的。

                若无特殊说明,下文所指 inode 号码均指同一文件系统内。

                5. 文件信息

                通过 ls(英文 list 的缩写)命令可以查看文件清单。本文用到参数有:

                • -a 表示列举全部文件,包括隐藏文件。
                • -l 表示列举文件细节,包括文件属性、权限、所有者、所在群组、大小、相关日期、文件名称、链接指向等。
                • -i 表示列举文件的 inode 号码。
                # 列举所有文件名称
                $ ls
                README.md               a-symlink-relative.txt  a.txt的替身
                a-hardlink.txt          a-symlink.txt           package.json
                a-symlink-absolute.txt  a.txt                   src
                
                # 列举所有文件名称和 inode 号码
                $ ls -i
                59499107 README.md              59499576 a-symlink-relative.txt 59499900 a.txt的替身
                59499302 a-hardlink.txt         59499486 a-symlink.txt          59499003 package.json
                59499638 a-symlink-absolute.txt 59499302 a.txt                  59499138 src
                
                # 列举所有文件名称、inode 号码以及其他细节
                $ ls -il
                total 40
                59499107 -rw-r--r--  1 frankie  staff   21  7 10 19:57 README.md
                59499302 -rw-r--r--  2 frankie  staff   13  7 10 20:00 a-hardlink.txt
                59499638 lrwxr-xr-x  1 frankie  staff   49  7 10 20:09 a-symlink-absolute.txt -> /Users/frankie/Desktop/Web/Temp/simple-link/a.txt
                59499576 lrwxr-xr-x  1 frankie  staff    5  7 10 20:08 a-symlink-relative.txt -> a.txt
                59499486 lrwxr-xr-x  1 frankie  staff    5  7 10 20:04 a-symlink.txt -> a.txt
                59499302 -rw-r--r--  2 frankie  staff   13  7 10 20:00 a.txt
                59499900 -rw-r--r--@ 1 frankie  staff  960  7 10 20:12 a.txt的替身
                59499003 -rw-r--r--  1 frankie  staff   92  7 10 19:57 package.json
                59499138 drwxr-xr-x  2 frankie  staff   64  7 10 19:57 sr
                

                以 macOS 为例,a-symlink.txt 文件细节如下:

                不同操作系统或不同终端工具所展示内容或有不同。

                关于文件权限可看《Linux 文件权限详解

                三、inode

                本文部分内容摘自阮一峰老师文章《理解 inode

                往下之前,需要先了解 inode 是什么,干什么用的?

                inode 是 index node 的简写,译为索引节点。每一个文件都有对应的 inode,它包含了以下这些文件元信息:

                • 文件字节数
                • 文件所有者的 User ID
                • 文件所在群组的 User ID
                • 文件的读、写、执行权限
                • 文件的时间戳,共有三个:ctime 指 inode 上一次更改时间,mtime 指文件内容上一次更改时间,atime 指文件上一次被访问时间。
                • 链接个数,即有多少个文件名称指向这个 inode
                • 文件数据 Block 的文件

                是的,inode 信息里没有存储文件名称。可以思考下:它会被存放到哪里呢?

                inode 信息

                使用 stat 命令可以查看文件的 inode 信息:

                $ stat -x README.md
                
                  File: "README.md"
                  Size: 21           FileType: Regular File
                  Mode: (0644/-rw-r--r--)         Uid: (  501/ frankie)  Gid: (   20/   staff)
                Device: 1,5   Inode: 59499107    Links: 1
                Access: Sun Jul 10 19:57:53 2022
                Modify: Sun Jul 10 19:57:51 2022
                Change: Sun Jul 10 19:57:51 2022
                 Birth: Sun Jul 10 19:57:26 2022
                

                1. inode 大小

                inode 本身也会占用硬盘空间。操作系统会将硬盘分为两个区域:一个是「数据区」,用于存储文件数据;一个是「inode 区」,用于存放 inode 所包含的信息。

                每个 inode 大小一般是 128 字节或 256 字节。通常是每 1KB 或每 2KB 就设置一个 inode。由于在硬盘格式化时,inode 总数量就已经给定。而且每个文件必须要有一个 inode,因此有可能发生 inode 已经用完,但硬盘未存满的情况,此时无法再从硬盘上创建新文件。

                2. inode 号码

                每个文件都有 inode,而每个 inode 会对应一个号码,称为「inode 号码」。在 Unix/Linux 操作系统中是通过 inode 号码来识别不同文件的。文件名称只是 inode 号码的一个「别称」,但这个别称对人类是友好的、易于记忆与区分的。

                还是用我国的公民身份证系统来比喻,身份证号码对应 inode 号码,而姓名则对应文件名称(文件路径)。平常问候朋友总不能说「4413*****3425 吃饭了没?」,显然「小明你吃饭了没?」更合适。

                身份证号是乱写的

                3. 目录文件

                目录文件的结构非常简单,就是一系列目录项(dirent)的列表。

                每个目录项由两部分组成:所包含文件的文件名称,以及该文件名称对应的 inode 号码。

                前面提到,文件名称没有存到 inode 信息中,文件名称被存在至目录项中。

                通过 ls -i 命令可以查看文件的 inode 号码:

                # 列出目录文件中的所有文件名称
                $ ls
                README.md       package.json    src
                
                # 列出目录文件中所有文件名称和 inode 号码
                $ ls -i
                59499107 README.md      59499003 package.json   59499138 src
                
                # 列出目录文件中所有文件名称、inode 号码以及其他详细信息
                $ ls -il
                total 16
                59499107 -rw-r--r--  1 frankie  staff  21  7 10 19:57 README.md
                59499003 -rw-r--r--  1 frankie  staff  92  7 10 19:57 package.json
                59499138 drwxr-xr-x  2 frankie  staff  64  7 10 19:57 src
                

                平常访问一个文件,可以分为几个步骤:

                1. 在当前目录文件中,根据文件名称找到对应的文件 inode 号码;
                2. 通过 inode 号码,获取到 inode 信息;
                3. 根据 inode 信息,找到文件数据存储的 Block,然后读出数据。

                当操作系统得知 inode 号码之后,后续可以直接通过该号码对文件执行相关操作。

                四、软链接与硬链接

                1. 创建链接

                使用 ln(英文 link 的缩写)命令可以为文件创建软链接、硬链接。命令较为简单,如下:

                # 创建硬链接
                $ ln <source-file> <target-file>
                
                # 创建软链接
                $ ln -s <source-file> <target-file>
                

                创建软链接时,原始文件路径(即 <source-file>)建议使用「绝对路径」,以避免移动软链接后无法正常访问目标文件。

                2. 硬链接

                通常情况下,文件名称与 inode 号码是「一一对应」的关系,即一个 inode 对应一个文件名称。但是,主流操作系统都允许多个文件名称指向同一个 inode 号码

                换句话说,通过不同的文件名称可以访问到同样的文件内容,对文件内容的修改自然会影响所有对应的文件名称。但是,删除其中一个,不会影响到另一个的访问,这种情况称为「硬链接」。

                $ touch a.txt && echo 'some text...' >> a.txt
                
                $ ln a.txt a-hardlink.txt
                
                $ ls -il
                total 32
                59499107 -rw-r--r--  1 frankie  staff  21  7 10 19:57 README.md
                59499302 -rw-r--r--  2 frankie  staff  13  7 10 20:00 a-hardlink.txt
                59499302 -rw-r--r--  2 frankie  staff  13  7 10 20:00 a.txt
                59499003 -rw-r--r--  1 frankie  staff  92  7 10 19:57 package.json
                59499138 drwxr-xr-x  2 frankie  staff  64  7 10 19:57 src
                

                可以看到 a.txta-hardlink.txt 的 inode 号码是相同的,都指向 59499302 这个 inode 号码。其中有一项表示「链接个数」,记录指向该 inode 的文件名称总数。目前指向 59499302 号码的文件有 a.txta-hardlink.txt 两个文件,因此其链接个数为 2

                $ rm a-hardlink.txt
                
                $ ls -il
                total 24
                59499107 -rw-r--r--  1 frankie  staff  21  7 10 19:57 README.md
                59499302 -rw-r--r--  1 frankie  staff  13  7 10 20:00 a.txt
                59499003 -rw-r--r--  1 frankie  staff  92  7 10 19:57 package.json
                59499138 drwxr-xr-x  2 frankie  staff  64  7 10 19:57 src
                

                当我们删除其一时,a.txt 对应链接数将会「减一」。当链接个数减到零时,表示没有文件名称指向这个 inode 了,操作系统将会回收这个 inode 号码,以及其对应的 block 区域。

                需要注意的是,创建目录时会默认生成两个目录项:...,分别表示当前目录的硬链接、父目录的硬链接,它们的 inode 号码也是对应的。因此,当我们创建一个空目录后其链接个数为 2:一个是所创建目录名,一个是当前目录下的 . 目录。

                因此,硬链接本质上只是新增一个文件名称到某个 inode 号码而已。

                3. 软链接

                通过前面发现,硬链接与原始文件的 inode 号码是相同的。但是软链接的 inode 号码与原始文件是不一样的。

                假设有文件 A 和 B,它们的 inode 号码不同,但是 B 文件的内容是 A 文件的路径。当读取 B 文件时,系统将会自动导向 A 文件,读取的都是 A 文件的内容。此时,B 文件被称为 A 文件的「软链接」。

                $ ln -s a.txt a-symlink.txt
                
                $ ls -il
                total 32
                59499107 -rw-r--r--  1 frankie  staff  21  7 10 19:57 README.md
                59499302 -rw-r--r--  2 frankie  staff  13  7 10 20:00 a-hardlink.txt
                59499486 lrwxr-xr-x  1 frankie  staff   5  7 10 20:04 a-symlink.txt -> a.txt
                59499302 -rw-r--r--  2 frankie  staff  13  7 10 20:00 a.txt
                59499003 -rw-r--r--  1 frankie  staff  92  7 10 19:57 package.json
                59499138 drwxr-xr-x  2 frankie  staff  64  7 10 19:57 src
                

                可以发现,a.txta-symlink.txt 的 inode 号码是不一样的。因此,创建软链接不会使得原始文件的链接个数发生改变。

                通过创建软链接而生成的新文件,其本身拥有一个新的 inode。与原始文件不同的是,它的文件内容是原始文件的文件路径。以 a-symlink.txt 为例,其文件大小为 5 个字节,就是文件内容 a.txt 的字节数(一个英文字符占一个字节)。

                由于 a-symlink.txt 文件是依赖于 a.txt 文件的,意味着删除原始文件 a.txt 后,再次访问 a-symlink.txt 文件就会报错:No such file or directory。

                因此,软链接本质上只是记录了某个文件路径。

                4. 注意点

                1. 创建软链接建议使用「绝对路径」,以避免所生成的软链接在移动至其他目录之后,无法访问到原始文件。
                $ ln -s a.txt a-symlink-relative.txt
                
                $ ln -s ~/Desktop/Web/Temp/simple-link/a.txt a-symlink-absolute.txt
                

                二者区别在于,原始文件的路径一个是相当路径,一个是绝对路径。接着,将这两个软链接移至其他桌面目录,然后在桌面目录下访问软链接,会发生什么呢?

                $ mv a-symlink-relative.txt ~/Desktop
                
                $ mv a-symlink-absolute.txt ~/Desktop
                
                $ cat ~/Desktop/a-symlink-relative.txt
                cat: a-symlink-relative.txt: No such file or directory
                
                $ cat ~/Desktop/a-symlink-absolute.txt
                some text...
                
                $ ls -l
                total 32
                -rw-r--r--  1 frankie  staff  21  7 10 19:57 README.md
                -rw-r--r--  2 frankie  staff  13  7 10 20:00 a-hardlink.txt
                lrwxr-xr-x  1 frankie  staff  49  7 10 20:09 a-symlink-absolute.txt -> /Users/frankie/Desktop/Web/Temp/simple-link/a.txt
                lrwxr-xr-x  1 frankie  staff   5  7 10 20:08 a-symlink-relative.txt -> a.txt
                lrwxr-xr-x  1 frankie  staff   5  7 10 20:04 a-symlink.txt -> a.txt
                -rw-r--r--  2 frankie  staff  13  7 10 20:00 a.txt
                -rw-r--r--  1 frankie  staff  92  7 10 19:57 package.json
                drwxr-xr-x  2 frankie  staff  64  7 10 19:57 src
                

                a-symlink-relative.txt 在移动到其他目录后,就不能正常访问原始文件了,而 a-symlink-absolute.txt 则不受影响。换句话说,软链接里记录的原始文件路径可以是相当路径或绝对路径,取决于创建时使用了哪种路径(即上面箭头后面的路径)。其中 cat ~/Desktop/a-symlink-relative.txt 相当于 cat ~/Desktop/a.txt,自然就找不到了。

                注意,修改原始文件名称、移动或删除原始文件,就无法通过它的软链接访问到文件了。

                1. 由于硬链接和原始文件的形式是一模一样的,因此操作系统是无法区分硬链接还是原始文件的。在文件管理器中二者看着是一样的。软链接则在文件图标上有一个箭头的图案。

                2. 硬链接无法跨文件系统。

                前面也提到过,不能跨文件系统的原因是,不同文件系统的 inode 号码是会出现重复现象的。

                1. 硬链接不能链接目录文件。
                $ ln src src-harklink
                ln: src: Is a directory
                

                拒绝创建目录硬链接是 ln 命令本身,而不是操作系统。因为对目录创建硬链接,可能会打破文件系统的有向无环图结构。

                更多请看文章:多角度分析为什么 Linux 的硬连接不能指向目录

                1. 移动、修改文件名称,不会影响 inode 号码。

                2. 一些文件名称包含特殊字符的文件,无法正常删除。可以通过直接删除 inode 的方式去删除文件。

                五、替身

                通过前面的介绍,可以知道软链接、硬链接都有一些缺点:

                • 移动、重命名或删除原始文件后,就无法通过软链接访问到原始文件了。
                • 硬链接无法链接目录,不能跨文件系统。

                那么,能不能结合两者的优点,产生一种新的方案来避免两者的缺点呢?

                在 macOS 上有一种类似的解决方案:「替身 Alias」。替身文件记录了原始文件的路径和 inode 号码,当访问替身文件时,系统分析替身文件,找到原始文件的路径信息,然后判断原始文件是否存在,若存在则访问它,如果不存在,就寻找有相同 inode 号码的文件,然后访问该文件。

                $ osascript -e 'tell application "Finder" to make alias file to POSIX file "/Users/frankie/Desktop/Web/Temp/simple-link/a.txt" at POSIX file "/Users/frankie/Desktop/Web/Temp/simple-link/"'
                alias file a.txt的替身 of folder simple-link of folder Temp of folder Web of folder Desktop of folder frankie of folder Users of startup disk
                
                $ ls -il
                total 40
                59499107 -rw-r--r--  1 frankie  staff   21  7 10 19:57 README.md
                59499302 -rw-r--r--  2 frankie  staff   13  7 10 20:00 a-hardlink.txt
                59499638 lrwxr-xr-x  1 frankie  staff   49  7 10 20:09 a-symlink-absolute.txt -> /Users/frankie/Desktop/Web/Temp/simple-link/a.txt
                59499576 lrwxr-xr-x  1 frankie  staff    5  7 10 20:08 a-symlink-relative.txt -> a.txt
                59499486 lrwxr-xr-x  1 frankie  staff    5  7 10 20:04 a-symlink.txt -> a.txt
                59499302 -rw-r--r--  2 frankie  staff   13  7 10 20:00 a.txt
                59499900 -rw-r--r--@ 1 frankie  staff  960  7 10 20:12 a.txt的替身
                59499003 -rw-r--r--  1 frankie  staff   92  7 10 19:57 package.json
                59499138 drwxr-xr-x  2 frankie  staff   64  7 10 19:57 src
                
                $ cat a.txt的替身
                bookmark88��$n�|=�A�lUsersfrankieDesktopWebTemp
                                                               simple-linka.txt 0@LX �~
                                                                                      ��I������A�={l��  file:///
                                                                                                                Macintosh H� hA���$C7FF9732-E725-402E-8FC2-AF9D4F2482C5���/3dnibtxt????����|D@4TlUlVd $ � � � � �  0 d�x� ���d��l"�0%
                

                创建替身其 inode 号码与原始文件的不相同。通过 cat 命令查看内容时,不会重定向到原始文件,而是直接看到其内容。在 Finder 上可以看到它跟软链接一样,文件图标上都有一个箭头图案。

                六、应用场景

                总结一下软链接、硬链接的特点:

                共同点

                • 创建软链接或硬链接,都会产生一个文件副本。
                • 删除硬链接或软链接,都不会对原始文件产生影响。
                • 对硬链接或软链接的文件内容进行修改,实质上都是在操作原始文件的内容。因此修改任意一个,其他都会同步受影响。

                不同点

                • 硬链接无法链接目录,软链接是可以的。
                • 硬链接无法跨文件系统,软链接是可以的。
                • 删除或移动原始文件,对硬链接不会产生影响,但通过软链接再也无法访问到原始文件的内容。
                • 重命名原始文件,本质上只是修改了文件路径,对 inode 无影响,自然就不会影响到硬链接,但软链接就无法访问到原始文件了。
                • 创建硬链接不占用硬盘空间,而创建软链接会占用一点点的硬盘空间(需存储 inode 信息和文件内容)。
                • 从视觉角度来看,软链接的文件图标有一个箭头图案(不同操作系统可能略有差别),而硬链接跟普通文件一样,如果不通过文件名称无法区分普通文件还是硬链接文件。

                应用场景

                在前端领域也广泛使用了软链接、硬链接,比如 npm CLI、npm link、pnpm 等工具。

                以 pnpm 为例,通过 pnpm install 命令安装的依赖包都将存储在 ~/Library/pnpm/store 目录下 (7.x 版本之前全局目录是 ~/.pnpm_store,详见 pnpm #2574) 。假设项目中安装了 jest 依赖包,会产生这样两个目录:

                ./node_modules/jest
                ./node_modules/.pnpm/jest@28.1.2/node_modules/jest
                

                第一个目录是第二个目录的软链接,Node 查找 jest 依赖会找到 ./node_modules/jest,由于它是软链接,系统根据其文件内容的路径找到了 ./node_modules/.pnpm/jest@28.1.2/node_modules/jest 目录,该目录里面的文件都是全局依赖目录下相关文件的硬链接。pnpm 利用软链接构建出更干净的 node_modules 目录,不再像 npm/yarn 那样的扁平化结构,也解决了「幽灵依赖」的问题。而且 node_modules/.pnpm 下使用了硬链接可以节省大量的硬盘空间。

                除此之外,还有很多很多...

                比如 Windows 的快捷方式、Finder 的侧边栏等。对于一些路径层级特别深的文件,快捷方式是一个非常高效的方法。还有,你女朋友在桌面删掉的只是软链接而已,对原始文件毫无影响...

                比如照片分类。假设我们的硬盘里存储了 10GB 的照片,如果想要按拍摄时间、拍摄地点、图片类型等方式分类,我们可以利用硬链接的特点,在其他目录创建图片文件的硬链接进行分类,最重要的是它们只会占用一份硬盘空间。

                比如文件共享。假设多人对同一个文件进行维护的时候,每个人可以在私人目录下创建该文件的硬链接,它的所有修改都会同步到原始文件中。最重要的是,即使某人不慎误删了文件,也不会丢失文件。

                比如文件备份。最原始的方式应该是修改完目标文件后,然后拷贝一份至存档目录。这种方式除了麻烦,还会占用两份同等大小的硬盘空间。利用硬链接的特点就可以轻松解决,达到仅占用一份硬盘空间,同步修改的效果。

                七、参考链接

                ]]>
                <![CDATA[Linux 文件权限详解]]> https://github.com/tofrankie/blog/issues/35 https://github.com/tofrankie/blog/issues/35 Sat, 25 Feb 2023 11:08:34 GMT 配图源自 Freepik

                在对文件进行操作之前,应先了解文件权限是什么?有哪些权限?

                配图源自 Freepik

                在对文件进行操作之前,应先了解文件权限是什么?有哪些权限?

                $ tree .
                .
                ├── README.md
                ├── package.json
                └── src
                
                1 directory, 2 files
                

                以上有一个目录和两个文件。然后通过 ls -l 命令,可以查看文件的具体属性:

                $ ls -l
                total 8
                -rw-r--r--  1 frankie  staff   0  7  3 00:18 README.md
                -rw-r--r--  1 frankie  staff  90  7  2 22:42 package.json
                drwxr-xr-x  2 frankie  staff  64  7  2 22:42 src
                

                package.json 文件为例,

                其中 -rw-r--r-- 首个字符 - 表示「文件类型」,后面的九位字符 rw-r--r-- 表示「文件权限」。

                「文件类型」用于表明它是文件、链接文件或者目录等,主要有以下几种:

                • 若为 d 则是目录;
                • 若为 - 则是文件;
                • 若为 l 则表示为链接文件(link file);
                • 若为 b 则表示为装置文件里面的可供储存的接口设备(可随机存取装置);
                • 若为 c 则表示为装置文件里面的串行端口设备,例如键盘、鼠标(一次性读取装置)。

                文件权限分为三种身份,分别为文件所有者权限(owner)、文件所在群组权限(group)、其他用户权限(others)。每一种身份都有各自的读写执行权限。通常情况下,一个文件只能归属于一个用户和群组,如果其他用户想拥有此文件的权限,可以将该用户加入到具有权限的群组,一个用户可同时归属于多个群组。

                每种身份包括读写和执行权限,其中 r 表示读权限(read),w 表示写权限(write),x 表示执行权限(execute),- 表示无对应权限。除了用 rwx- 形式之外,也可以使用八进制数模式来表示,对应如下:

                八进制值 文件权限 权限说明
                0 --- 无任何权限
                1 --x 仅执行权限
                2 -w- 仅写权限
                3 -wx 有写和执行权限
                4 r-- 只读权限
                5 r-x 读和执行权限
                6 rw- 读写权限
                7 rwx 读写和执行权限

                每种身份的权限数字为 rwx 的累加得出来的。比如,前面 package.json 的权限为 -rw-r--r--,表示文件所有者权限为 rw-(4+2+0),文件所在群组权限为 r--(4+0+0),其他用户权限为 r--(4+0+0),因此该文件的权限数字为 0o644

                我们常用 chmod 命令来修改文件的权限,比如:

                $ chmod 755 ./src/bin/test.js
                

                以上的 755 就是表示权限数字,该文件的权限将被改写为 rwxrw-rw-,即所有者可读写可执行,所在群组可读,其他用户可读。

                除了数字方式,还可以通过字符方式修改文件权限。形式如:

                $ chmod who+operator+permissions <file>
                

                其中 who 表示要更改权限的用户,operator 表示要执行的操作,permissions 表示要更改的权限。

                符号 功能 说明
                u who 文件所有者
                g who 文件所在群组
                o who 其他用户
                a who 所有用户
                👻
                = operator 赋值
                + operator 添加
                - operator 删除
                👻
                r permissions 读权限
                w permissions 写权限
                x permissions 执行权限

                示例:

                $ chmod 755 ./src/bin/test.js
                
                # 相当于
                $ chmod u=rwx,g=rw,o=rw ./src/bin/test.js
                
                # 也可对每种用户分别设置
                $ chmod u=rwx ./src/bin/test.js
                $ chmod g=rw ./src/bin/test.js
                $ chmod o=rw ./src/bin/test.js
                

                除了常见的 r/w/x 权限之外,还有一些特殊权限:SUID、SGID 和 SBIT,本文不展开,有兴趣自行查阅。

                The end.

                ]]>
                <![CDATA[Shell 脚本学习]]> https://github.com/tofrankie/blog/issues/34 https://github.com/tofrankie/blog/issues/34 Sat, 25 Feb 2023 11:07:53 GMT 配图源自 Freepik

                本文大部分内容来自阮一峰老师的

                本文大部分内容来自阮一峰老师的 Bash 脚本教程

                一、前言

                虽然一直都在用,但有些命令仍是半知半懂的,所以就好好学一下吧。

                一些辅助工具:

                • shellcheck - Shell 脚本静态检查工具,主流编辑器都有插件。类似 ESLint 的工具。
                • zx - Google 出品,用 JavaScript 写 Shell 脚本。

                二、Shell 命令格式

                $ command [ arg1 ... [ argN ] ]
                

                其中 command 是一个具体的命令或者一个可执行文件,arg1... argN 是传递给命令的参数,是可选的。

                命令与参数,参数与参数之间通过「一个空格」隔开。若有「多个空格」,多余空格会被自动忽略,作用相当于一个空格。

                $ ls -l
                

                其中 ls 是命令,-l 是参数。有些参数是命令的配置项,它们一般以一个「短横线」开头,比如上面的 -l。通常配置项参数有短形式和长形式两种形式,比如 -l 是短形式,--list 是长形式。两种写法作用完全相同,短形式便于输入,长形式可读性、语义更好。

                通常命令都是一行的,可有些命令较长,写成多行有利于阅读和编辑,只要在每行结尾处加上反斜杠 \ 可以,Shell 会将下一行跟当前行一起解析。

                $ echo Hello World
                
                # 等同于
                $ echo Hello \
                World
                

                三、命令的组合与继发

                命令组合符 &&,前一个命令执行成功,才会接着执行第二个命令。

                $ command1 && command2
                

                命令组合符 ||,前一个命令执行失败,才会接着执行第二个命令。

                $ command1 || command2
                

                命令结束符 ;(分号),前一个命令执行结束后(无论成功与否),接着执行第二个命令。命令结束符可使得一行中放置多个命令。

                $ clear; ls
                

                管道符 |,前一个命令的输出作为第二个命令的输入。

                $ command1 | command2
                
                # 相当于
                $ command1 > tempfile
                $ command2 < tempfile
                $ rm tempfile
                

                四、引号

                • 单引号:单引号用于保留字符的字面含义,各种特殊字符在单引号里面,都会变为普通字符。
                • 双引号:比单引号宽松,大部分特殊字符在双引号里面,都会失去特殊含义,变成普通字符。但是,三个特殊字符除外:美元符号($)、反引号(`)和反斜杠(\)。这三个字符在双引号之中,依然有特殊含义,会被 Bash 自动扩展。
                $ echo '$USER'
                $USER
                
                $ echo "$USER"
                frankie
                

                换行符在双引号之中,会失去特殊含义,Bash 不再将其解释为命令的结束,只是作为普通的换行符。所以可以利用双引号,在命令行输入多行文本。

                $ echo "hello
                world"
                
                hello
                world
                

                echo 发音 [ˈekō](才发现原来一直读错了,惭愧)。其参数 -e 会解析引号中的特殊字符(比如换行符 \n)。若在 CLI 中直接输入 echo 命令 \n 也会解析为换行符,而不是普通的 \n 字符串。

                $ echo -e "Hello\nShell"
                Hello
                Shell
                

                五、子命令扩展

                $(...) 可以扩展成另一个命令的运行结果,该命令的所有输出都会作为返回值。还有另一种较老的语法,子命令放在反引号之中,也可以扩展成命令的运行结果。

                $ echo $(date) 
                2022年 6月27日 星期一 00时31分14秒 CST
                
                $ echo `date`
                2022年 6月27日 星期一 00时32分01秒 CST
                

                六、读取变量

                • 在变量名前加上 $,比如 $SHELL
                • 读取变量时,变量名可以使用花括号 {} 包围,比如 $SHELL 可以写成 ${SHELL}
                • 如果变量的值本身也是变量,可以使用 ${!varname} 语法,读取最终的值。(好像不太对,待进一步验证)

                七、算术运算

                • 除法运算符的返回结果总是为「整数」,比如 $(( 5 / 2 )) 的结果为 2,而不是 2.5
                • $(( ... )) 的圆括号之中,不需要在变量名之前加上 $,不过加上也不报错。
                • 如果 $((...)) 里面使用不存在的变量,也会当作 0 处理。
                • $[...] 是以前的语法,也可以做整数运算,不建议使用。

                小数运算,需借助 bc 命令,其中 scale 表示小数位,ibaseobase 进行其他进制数运算。比如:

                $ var1=3
                $ var2=6  
                $ result=$(echo "scale=2; $var1 / $var2" | bc) 
                $ echo $result 
                .50
                

                八、目录堆栈

                cd - 命令可以返回前一次的目录。默认情况下,只记录上一次所在的目录。

                $ cd ~/Desktop/
                $ cd -
                ~
                

                九、脚本

                9.1 Shebang

                脚本的第一行通常是指定解释器,即这个脚本必须通过什么解释器执行。这一行以 #! 字符开头,这个字符称为 Shebang,所以这一行就叫做 Shebang 行。

                #! 后面就是脚本解释器的位置,Bash 脚本的解释器一般是 /bin/sh/bin/bash

                #!/bin/sh
                
                # 或者
                #!/bin/bash
                

                #! 与脚本解释器之间有没有空格,都是可以的。

                如果 Bash 解释器不放在目录 /bin,脚本就无法执行了。为了保险,可以写成下面这样。

                #!/usr/bin/env bash
                

                上面命令使用 env 命令(这个命令总是在 /usr/bin 目录),返回 Bash 可执行文件的位置。env 命令的详细介绍,请看后文。

                Shebang 行不是必需的,但是建议加上这行。如果缺少该行,就需要手动将脚本传给解释器。

                举例来说,脚本是 script.sh,有 Shebang 行的时候,可以直接调用执行。

                $ ./script.sh
                

                上面例子中,script.sh 是脚本文件名。脚本通常使用 .sh 后缀名,不过这不是必需的。

                如果没有 Shebang 行,就只能手动将脚本传给解释器来执行。

                $ /bin/sh ./script.sh
                
                # 或者
                $ bash ./script.sh
                

                9.2 执行权限和路径

                前面说过,只要指定了 Shebang 行的脚本,可以直接执行。这有一个前提条件,就是脚本需要有执行权限。可以使用下面的命令,赋予脚本执行权限。

                给所有用户执行权限

                $ chmod +x script.sh
                

                给所有用户读权限和执行权限

                $ chmod +rx script.sh
                
                # 或者
                $ chmod 755 script.sh
                

                只给脚本拥有者读权限和执行权限

                $ chmod u+rx script.sh
                

                脚本的权限通常设为 755(拥有者有所有权限,其他人有读和执行权限)或者 700(只有拥有者可以执行)。

                除了执行权限,脚本调用时,一般需要指定脚本的路径(比如 path/script.sh)。如果将脚本放在环境变量 $PATH 指定的目录中,就不需要指定路径了。因为 Bash 会自动到这些目录中,寻找是否存在同名的可执行文件。

                建议在主目录新建一个 ~/bin 子目录,专门存放可执行脚本,然后把 ~/bin 加入 $PATH

                export PATH=$PATH:~/bin
                

                上面命令改变环境变量 $PATH,将 ~/bin 添加到 $PATH 的末尾。可以将这一行加到 ~/.zshrc 文件里面,然后重新加载一次 .zshrc,这个配置就可以生效了。

                $ source ~/.zshrc
                

                以后不管在什么目录,直接输入脚本文件名,脚本就会执行。

                $ script.sh
                

                上面命令没有指定脚本路径,因为 script.sh$PATH 指定的目录中。

                上面的配置文件,取决于你当前所用的 Shell。比如我这里是 zsh,配置文件为 ~/.zshrc,如果你是 bash,可能是 ~/.bash_profile~/.bashrc 等。

                十、条件判断

                if 关键字后面跟的是一个命令。这个命令可以是 test 命令,也可以是其他命令。命令的返回值为 0 表示判断成立,否则表示不成立。

                if commands; then
                  commands
                [elif commands; then
                  commands...]
                [else
                  commands]
                fi
                

                判断条件 commands 可以是一条命令,这条命令执行成功(返回值为 0),就意味着判断条件成立。

                但更多地是使用 test 命令,语法如下:

                # 写法一
                test expression
                
                # 写法二
                [ expression ]
                
                # 写法三
                [[ expression ]]
                

                以上三种形式是等价的,第三种形式支持正则判断,前两种不支持。需要注意的是,后两种写法中 [] 与内部命令之间必须要有「空格」。因为 [test 命令的简写形式,因此它后面必须要有空格。举个例子,使用 if 语句判断一个文件是否存在:

                # 写法一
                if test -e /tmp/foo.txt ; then
                  echo "Found foo.txt"
                fi
                
                # 写法二
                if [ -e /tmp/foo.txt ] ; then
                  echo "Found foo.txt"
                fi
                
                # 写法三
                if [[ -e /tmp/foo.txt ]] ; then
                  echo "Found foo.txt"
                fi
                

                10.1 文件判断

                以下表达式用来判断文件状态:

                • [ -a file ]:如果 file 存在,则为 true
                • [ -b file ]:如果 file 存在,并且是一个块(设备)文件,则为 true
                • [ -c file ]:如果 file 存在,并且是一个字符(设备)文件,则为 true
                • [ -d file ]:如果 file 存在,并且是目录,则为 true
                • [ -e file ]:如果 file 存在,则为 true
                • [ -f file ]:如果 file 存在,并且是一个普通文件,则为 true
                • [ -g file ]:如果 file 存在,并且设置了组 ID,则为 true
                • [ -G file ]:如果 file 存在,并且属于有效的组 ID,则为 true
                • [ -h file ]:如果 file 存在,并且是符号链接(软链接),则为 true
                • [ -k file ]:如果 file 存在,并且设置了它的 sticky bit,则为 true
                • [ -L file ]:如果 file 存在,并且是一个符号链接(软链接),则为 true
                • [ -N file ]:如果 file 存在,并且自上次读取后已被修改,则为 true
                • [ -O file ]:如果 file 存在,并且属于有效的用户 ID,则为 true
                • [ -p file ]:如果 file 存在,并且是一个命名管道,则为 true
                • [ -r file ]:如果 file 存在,并且可读(当前用户有可读权限),则为 true
                • [ -s file ]:如果 file 存在,并且其长度大于零,则为 true
                • [ -S file ]:如果 file 存在,并且是一个网络 socket,则为 true
                • [ -t fd ]:如果 fd 是一个文件描述符,并且重定向到终端,则为 true。 这可以用来判断是否重定向了标准输入/输出/错误。
                • [ -u file ]:如果 file 存在,并且设置了 setuid 位,则为 true
                • [ -w file ]:如果 file 存在,并且可写(当前用户拥有可写权限),则为 true
                • [ -x file ]:如果 file 存在,并且可执行(当前用户拥有可执行/搜索权限),则为 true
                • [ file1 -nt file2 ]:如果 file1file2 的更新时间最近,或者 file2 存在而 file1 不存在,则为 true
                • [ file1 -ot file2 ]:如果 file1file2 的更新时间更旧,或者 file2 存在而 file1 不存在,则为 true
                • [ file1 -ef file2 ]:如果 file1file2 引用相同的设备和 inode 编号,则为 true
                if [ -f "$FILE" ]; then
                  echo "$FILE is a regular file."
                fi
                

                上面代码中,$FILE 要放在双引号之中,这样可以防止变量 $FILE 为空,从而出错。因为 $FILE 如果为空,这时 [ -e $FILE ] 就变成 [ -e ],这会被判断为真。而 $FILE 放在双引号之中,[ -e "$FILE" ] 就变成 [ -e "" ],这会被判断为假。

                ]]> <![CDATA[如何让 Shell 脚本全局执行]]> https://github.com/tofrankie/blog/issues/33 https://github.com/tofrankie/blog/issues/33 Sat, 25 Feb 2023 11:07:24 GMT 配图源自 Freepik

                碰巧前面一篇

                碰巧前面一篇文章中浅浅介绍了一下 Shell。然后最近刚好要写一个 Shell 脚本去批量去处理一下文件,因此写下来记录一下,尽管思路很简单。

                假设我们 ~/Desktop 目录下有一个 Shell 脚本 myscript.sh,如下:

                #!/bin/bash
                echo "🎉🎉🎉 The first shell script..."
                

                使用 source 命令便可执行此脚本:

                $ source ~/Desktop/myscript.sh
                🎉🎉🎉 The first shell script...
                

                然后我在想能否将 source 指令也省略掉呢,直接 myscript.sh 这样:

                $ cd ~/Desktop
                $ myscript.sh
                zsh: command not found: myscript.sh
                

                然后 Shell 解析器将 myscript.sh 识别成了一个「指令」,而不是一个文件。由于 Shell 脚本的扩展名是可选的,因此尝试将 .sh 扩展名去掉试试:

                $ myscript
                zsh: permission denied: myscript
                

                跟前面有点不一样了,它提示没有权限。哦,原来一个 Shell 脚本只有具备了「可执行权限」方可被执行。那好办,我们将其权限改一改:

                $ chmod u+x myscript
                

                其中 chmod 命令用于改变文件或目录的权限,u 表示所有者用户,x 表示执行权限,+ 表示增加权限。

                其实通过 Finder 可以观察到其图标变了样(不同系统可能不一样),该图标表示可执行文件。

                于是我们兴高采烈地执行了:

                $ myscript
                zsh: command not found: myscript
                

                可现实却狠狠地打了一巴掌,咋回事!!!原来是要将「可执行文件」所在目录配置到 PATH 环境变量中,因为在终端工具中输入指令的时候,系统会从环境变量 PATH 所包含的路径中「逐一查找」相应的可执行文件,如果最后都找不到的话,就会抛出错误并提示找不到指令。

                所以有几种解决方法:

                • 一是,将其拷贝至环境变量 PATH 的某个目录(如 /usr/bin 等)里面,cp ~/Desktop/myscript /bin/myscript(个人不喜欢这样处理)。
                • 二是,创建一个软链接到环境变量 PATH 目录中,比如 ln -s ~/Desktop/myscript /bin/myscript(个人不喜欢这样处理)。
                • 三是,在 Shell 配置文件中,将可执行文件所在目录添加到环境变量 PATH 中即可。就用它吧,毕竟这个只是测试脚本,写完文章是要删掉的。

                在 Shell 配置文件中加入 ~/Desktop 路径即可:

                export PATH=$PATH:$HOME/Desktop
                

                具体是哪个配置将取决于你使用的是哪一种 Shell,我这里是 zsh,因此用户级别的配置文件是 ~/.zshrc 文件,添加完之后,记得 source ~/.zshrc 刷新变量使其生效。

                至此,你就可以在任意目录下执行 myscript 指令了。

                $ cd ~
                $ myscript
                🎉🎉🎉 The first shell script...
                

                这样执行脚本也不用输入那一串长长的地址,舒服多了。

                One more thing...

                前面我们为了不让 myscript.sh 被识别为一个指令,因此把扩展名删掉了。但如果我们不希望去掉扩展名,可以怎样做呢?毕竟我们在修改脚本的时候,编辑器根据扩展名会有语法高亮、语法提示等好处。很简单,我们修改下 Shell 配置即可:

                export PATH=$PATH:$HOME/Desktop
                alias myscript="myscript.sh"
                

                没错,配置多一个别名即可。

                The end.

                ]]>
                <![CDATA[浅谈 Shell]]> https://github.com/tofrankie/blog/issues/32 https://github.com/tofrankie/blog/issues/32 Sat, 25 Feb 2023 11:04:34 GMT 配图源自 Freepik

                一、前言

                通常,用户控制计算机的方式有图形化界面(GUI,]]> 配图源自 Freepik

                一、前言

                通常,用户控制计算机的方式有图形化界面(GUI,Graphical User Interface)和命令行界面(CLI,Command Line Interface)两种。然而,真正能够控制计算机硬件(如 CPU、内存、显示器等)的只有操作系统内核(Kernel)。因此,图形化界面和命令行只是架设在用户和内核之间的一种桥梁。

                由于安全、复杂、繁琐等原因,用户不能直接接触内核(也没必要),因此需要开发一个程序。用户直接使用这个程序,程序的作用就是接收用户的操作(输入),进行简单处理,然后传递给内核,这样用户就可以间接使用操作系统了。Shell 就是这样的一个程序。在用户和内核之间增加一层「代理」,既能简化操作,又能保证内核的安全,何乐而不为呢?

                因此,Shell 并不是操作系统内核的一部分。

                以上内容部分摘自 Shell 是什么

                二、Shell 的含义

                Shell 的英文原意是「外壳」,与之相对的是内核(Kernel)。但具体来说,Shell 这个词有多种含义。

                1. Shell 是一个应用程序,提供一个与用户对话的环境,该环境只有一个命令提示符(通常是 $#),让用户输入命令。这种环境被称为命令行环境(CLI)。Shell 接收到用户输入的命令,将命令传递给操作系统执行,并将结果返回给用户。

                2. Shell 是一个命令解析器,解析用户输入的命令,它支持变量、条件判断、循环操作等语法,所以用户可以用 Shell 写出各种小程序,又被称为脚本(Script)。这些脚本通过 Shell 解析器解析执行,注意不是编译。

                3. Shell 是一个工具箱,提供了各种小工具,供用户方便地使用操作系统的功能。

                以上摘自 Bash 简介

                三、Shell 的种类

                Shell 又很多种,只要能给用户提供命令行环境的程序,都可以看作是 Shell。在历史上,主要 Shell 有这些:

                • Bourne Shell(sh)
                • Bourne Again shell(bash)
                • C Shell(csh)
                • TENEX C Shell(tcsh)
                • Korn shell(ksh)
                • Z Shell(zsh)
                • Friendly Interactive Shell(fish)

                更详细可看网道Wikiwand。目前最常用的 Shell 是 bash。

                四、Shell 解析器

                各操作系统通常会内置多种 Shell 解析器,且有一个默认的 Shell。比如 Linux 操作系统是 bash,Windows 操作系统是 PowerShell,Mac 操作系统是 bash 或 zsh。

                以 macOS 为例,不同版本下其内置默认 Shell 如下:

                • OS X 10.2 及以下版本默认 Shell 为 csh。
                • OS X 10.3 ~ macOS 10.14 版本默认 Shell 为 bash。
                • 自 macOS 10.15 起默认 Shell 为 zsh。

                关于如何从 bash 切换至 zsh,或者在不修改默认 Shell 的情况下使用其他 Shell,可参考:在 Mac 上将 zsh 用作默认 Shell

                一些命令:

                1. 查看当前操作系统默认 Shell
                $ echo $SHELL
                /bin/zsh
                
                1. 查看当前使用的 Shell(不一定是默认 Shell)
                $ ps
                  PID TTY           TIME CMD
                13766 ttys000    0:00.25 /bin/zsh -l
                14879 ttys000    0:04.45 node /usr/local/Cellar/yarn/1.22.19/libexec/bin/yarn.j
                14880 ttys000    0:10.75 gulp start --project qualcomm-202206    
                14886 ttys000    0:00.06 open -W http://localhost:3000
                15495 ttys001    0:00.37 /bin/zsh -l
                16701 ttys002    0:00.63 -zsh
                18509 ttys003    0:00.26 -zsh
                

                一般来说,ps 命令(显示当前系统的进程状态)结果的「倒数第二行」是当前正在使用的 Shell。

                1. 查看当前操作系统的所有的 Shell
                $ cat /etc/shells
                # List of acceptable shells for chpass(1).
                # Ftpd will not allow users to connect who are not using
                # one of these shells.
                
                /bin/bash
                /bin/csh
                /bin/dash
                /bin/ksh
                /bin/sh
                /bin/tcsh
                /bin/zsh
                
                1. 进入或退出 Shell

                当前我的默认 Shell 是 zsh,如果要进入 bash 环境,执行一些命令,然后再退出。举个例子:

                $ bash
                
                The default interactive shell is now zsh.
                To update your account to use zsh, please run `chsh -s /bin/zsh`.
                For more details, please visit https://support.apple.com/kb/HT208050.
                bash-3.2$ echo $NVM_DIR
                /Users/frankie/.nvm
                bash-3.2$ exit
                exit
                

                其中 bash 命令表示进入 bash Shell 环境,echo $NVM_DIR 表示输出 $NVM_DIR 环境变量,然后执行 exit 命令退出 bash Shell 环境。

                五、Shell 脚本

                脚本(Script)是指包含一系列命令的文本文件。利用 Shell 解析器便可执行的脚本,被称为 Shell 脚本。

                举个例子,一个最简单的 Shell 脚本 hello.sh

                #!/bin/bash
                
                echo "Hello Shell"
                
                • 脚本首行 #! 是一个约定标记(称为 Shebang 行),用于指定执行该脚本的 Shell 解析器,通常是 /bin/bash/bin/sh
                • #! 与解析器之间的「空格」可有可无。
                • 文件顶部的 Shebang 行是非必需的。若无,则要在执行脚本时手动指定,比如 /bin/sh ./hello.shbash ./hello.sh
                • 若手动指定解析器,那么将会使用所指定的解析器去执行该脚本,而脚本内指定的解析器会被忽略。
                • Shell 脚本文件的扩展名是非必需的,甚至可以命名为奇奇怪怪的扩展名,不影响脚本的执行。但是...按照习惯通常会命名为 .sh

                但需要注意的是,如果 Shell 解析器并没有放在 /bin,这样脚本叫无法执行了。为了确保稳定性,可以写成这样:

                #!/usr/bin/env bash
                

                原因是 env 命令一定是在 /bin/bin 目录下,它返回对应 Shell 解析器的位置。

                如果开发过一些 Node 相关工具(比如 vue-cli),你一定看到过这样的 Shebang 行,用于指定解析器为 Node。

                #!/usr/bin/env node
                

                也可以观察一下项目的 node_modules/.bin 目录下的所有可执行文件,它们几乎都是以 #!/usr/bin/env node 开头的,其作用是正确地查找对应解析器所在路径,以避免用户没有安装在默认路径下导致无法正常执行脚本的问题。如果你是写 Python 的话,一定见过 #!/usr/bin/env python...

                如果平常想要看下 Node 安装目录的话,可以使用 which 命令,比如:

                $ which node
                /Users/frankie/.nvm/versions/node/v16.15.0/bin/node
                

                六、NPM 脚本与 Shell

                在前端项目中,我们通常会在 package.json 中定义一系列的脚本命令,比如:

                {
                  "scripts": {
                    "test": "jest"
                  }
                }
                

                当我们在终端输入 npm run test 命令时,就可以执行对应的测试脚本。当我们在项目根目录或者用户根目录等路径下直接执行 jest 命令,抛出错误:

                $ jest
                zsh: command not found: jest
                

                原因是执行命令的当前目录或 PATH(系统环境变量)目录都不存在一个名为 jest 的可执行文件。那么 npm run jest 内部又偷偷做了哪些工作呢,为什么它能准确找到可执行文件 jest 所在的目录呢?

                假设我们有一个 my-app 包,如下:

                {
                  "name": "my-app",
                  "version": "1.0.0",
                  "bin": {
                    "myapp": "./cli.js"
                  }
                }
                

                其中有一个 bin 字段(详见),表示该包有一个名为 myapp 的可执行文件。在执行 npm install my-app 时,除了将包下载至 node_modules 目录下,还会创建一个 cli.js 文件的软链接(symlink)至 node_modules/.bin 目录,该可执行文件的名称就是对应的键名 myapp。若以 "bin": "./cli.js" 形式配置,名称则为其包名 my-app。若全局安装,则会被链接至全局目录。

                jest 为例,可执行文件 node_modules/.bin/jest 其实是 node_modules/jest/bin/jest.js 的软链接,因此真正被执行的是后者。

                如果 package.json 中定义的命令是这样:

                {
                  "test": "./node_modules/.bin/jest"
                }
                

                或这样:

                {
                  "test": "./node_modules/jest/bin/jest.js"
                }
                

                都非常容易理解,但观感来说,它又长又臭,还丑。那么 { "test": "jest" } 又是如何找到目标可执行文件的呢?

                其内在奥秘在于 npm run 命令。当执行此命令时,会创建一个 Shell 子进程,然后在这个 Shell 中执行指定的脚本命令。换句话说,只要是 Shell 可以运行的命令,都可以写在 NPM 脚本里面

                比较特别的是,npm run 创建的 Shell 进程,会将当前目录的 node_modules/.bin 子目录加入到 PATH 环境变量,在执行结束之后,再将 PATH 环境变量恢复原样。

                推荐下阮一峰老师的这篇文章:npm scripts 使用指南

                我们来验证下,假设有这样一个项目,且有一个 test.sh 脚本用于输出 PATH 环境变量:

                {
                  "name": "simple-test",
                  "version": "1.0.0",
                  "scripts": {
                    "test": "source ./test.sh"
                  }
                }
                
                #!/bin/bash
                # test.sh
                echo $PATH
                

                其中 source 命令表示执行一个脚本,也可用其简写形式 . 表示,即 . ./test.sh。我们也常用此命令来刷新环境变量,比如 source ~/.zshrc,更多可看文章

                下面为了方便对比,执行前后都输出一下 PATH 环境变量的值:

                可以发现,在 npm run 执行期间,PATH 环境变量发生了变化,截图标记部分为临时新增的环境变量,其中包括项目所在路径 simple-test/node_modules/.bin,执行完之后又变为了最初的模样。

                至于为什么 simple-test 逐层往上添加 node_modules/.bin 目录,我猜是跟 Node 逐层查找模块有关系,具体没去细究。

                因此,我们甚至可以在 bin 字段去声明一些命令:

                {
                  "name": "simple-test",
                  "version": "1.0.0",
                  "scripts": {
                    "test": "source ./test.sh"
                  },
                  "bin": {
                    "hello": "./hello.sh"
                  }
                }
                

                然后安装此包后,就可以在 script 中使用 hello 命令了。

                七、Shell 配置文件

                由于很多高级编程语言,有着丰富的第三方库,因此可选择的配置文件格式有很多,比如 JSON、XML、YAML 等等。对于 Shell 而言,其配置文件多是 key=value 形式的文本文件,等号两边不应有「空格」。

                Shell 配置文件本身就是一种特殊的 Shell 脚本,只是没有用 .sh 扩展名而已。前面提到过 Shell 脚本扩展名是非必需的。当 Shell 被启动时,会执行对应 Shell 的配置文件中的命令,通常是配置当前 Shell 的环境,比如 aliasPATH 等。

                Shell 配置文件可分为「系统级别」和「用户级别」。当 Shell 启动时,会先执行系统级别的配置文件(如果存在的话),然后再执行用户级别的配置文件(如果存在的话),因此用户级别的配置文件优先级更高。另外,系统级别的配置对所有用户生效,而用户级别仅对当前用户生效,可以理解为继承关系。

                • 系统级别配置文件一般位于 /etc 目录下,比如 /etc/profile/etc/bashrc 等。
                • 用户级别配置文件一般位于 ~(用户目录)下,比如 ~/.profile~/.bashrc~/.bash_profile~/.zshrc 等,通常是隐藏性文件。

                以 macOS 为例,通常修改 Shell 配置都是在用户级别的配置文件上操作,比如 ~/.bash_profile~/.zshrc,取决于你在使用哪一种 Shell 解析器。

                常见 Shell 是如何读取配置的,附一张图,源自 Shell startup scripts

                图中涉及了几个概念 interactive 和 non-interactive、login 和 non-login,分别表示是否为交互式 Shell、是否为登录式 Shell。它们在读取配置文件上会有所区别,比如,非交互式和非登录式的 zsh 不会读取 ~/.zshrc 配置文件。

                以下说明,摘自此处

                Login Shell:是指该 Shell 被启动时用于用户登录。 Non-login Shell:是指用户已登录下启动的那些 Shell,被自动执行的 Shell 也属于非登录式 Shell,它们的执行通常与用户登录无关。

                Interactive Shell:是指可以让用户通过键盘进行交互的 Shell。 平常在使用的 CLI 都是交互式 Shell。 Non-interactive Shell:是指被自动执行的脚本, 通常不会请求用户输入,输出也一般会存储在日志文件中。

                如果在使用 zsh,可阅读下这篇大而全的配置指引:zsh wiki

                八、环境变量与 Shell 变量

                本小节内容大部分摘自文章:设置与查看Linux系统中的环境变量

                在 Linux/Unix 系统中,分为「环境变量」和「Shell 变量」两种变量。它们都是区分大小写的,因此 HOMEhome 是两个不同的变量。

                • 环境变量:通常在 Shell 配置文件中以 key=value 形式实现,它在整个系统范围内都可用,并被所有子进程和 Shell 继承。通常以大写形式命名,比如 PATH 等。
                • Shell 变量:专门用于设置或定义它们的 Shell 中的变量,每一种 Shell 解析器都有一组属于自己的内部 Shell 变量。在编写 Shell 脚本时常用于跟踪临时数据。

                使用 envprintenv 命令(不带其他参数时),可以显示所有环境变量。若查看单个环境变量的值,可以使用 printenvecho 命令。

                $ printenv PATH
                /Users/frankie/.nvm/versions/node/v16.14.0/bin:/usr/local/sbin:/usr/local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
                
                $ echo $PATH
                /Users/frankie/.nvm/versions/node/v16.14.0/bin:/usr/local/sbin:/usr/local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
                

                echo 命令中的变量名前需要添加前缀 $,否则当做输出一个字符串。

                使用 set 命令,可以显示所有变量(包括环境变量和自定义变量),以及所有的 Shell 函数。

                8.1 环境变量

                单个值的环境变量,形式看起来是这样的:

                KEY=value1
                

                如果变量的值中含有空格,则需要将值放在引号中,比如:KEY="value with spaces"

                多个值的环境变量,形式是这样的:

                KEY=value1:value2:value3
                

                每个值之间以分号 : 作为分隔符。可以理解为类似于高级编程语言中的数组。

                8.2 Shell 变量

                Shell 变量声明语法如下:

                variable=value
                

                等号左边为「变量名」,右边为变量值,同样地,若变量值含有空格,需要将值放在引号中。需要注意的是,在 Shell 中没有数据类型的概念,所有变量值都是字符串

                Shell 变量除了可以在 Shell 脚本文件中声明、使用,也是可以直接在 Shell 会话中操作的,但是该变量的声明周期仅在会话被销毁之前,而且不能被其他新的 Shell 会话环境中读取。若用浏览器来比喻的话,每个 Shell 会话就是两个标签的应用,应用之间的变量是不可共享的。

                比如:

                $ myname=frankie
                $ echo $myname
                frankie
                

                由于用户创建的 Shell 变量,仅可在当前 Shell 中使用,若要传递给子 Shell,需要使用 export 命令。这样输出的变量,对于子 Shell 来说就是环境变量。但注意其他非子 Shell,是不能读取到的。

                $ export myname=frankie
                $ myage=20
                $ bash
                
                The default interactive shell is now zsh.
                To update your account to use zsh, please run `chsh -s /bin/zsh`.
                For more details, please visit https://support.apple.com/kb/HT208050.
                bash-3.2$ echo $myname
                frankie
                bash-3.2$ echo $myage
                
                bash-3.2$ 
                

                其他就不展开了,不然就跑偏了,有兴趣可看阮一峰老师的文章:Bash 变量

                8.3 常见变量

                常见环境变量:

                变量名 含义
                USER 当前登录用户。
                PWD 当前工作目录。
                OLDPWD 上一个工作目录。
                PATH 系统查找指令时会检查的目录列表。当用户输入一个指令时,系统将会按此目录列表的顺序检索目录,以寻找相应的可执行文件。
                LANG 当前的语言和本地化设置,包括字符编码。
                HOME 当前用户的主目录。
                SHELL 当前系统的默认 Shell。
                TERM 终端类型名,即终端仿真器所用的协议。

                常见的 Shell 变量:

                变量名 含义
                COLUMNS 用于设置绘制到屏幕上的输出信息的宽的列数。
                HOSTNAME 主机名。
                PS1 命令提示符样式。
                PS2 输入多行命令时,命令提示符样式。

                8.4 特殊变量

                变量名 含义
                $? 上一个命令的退出码,用于判断上一个命令是否执行成功。返回值为 0 表示命令执行成功,否则执行失败。
                $$ 当前 Shell 的进程 ID。
                $_ 上一个命令的最后一个参数。
                $! 最近一个后台执行的异步命令的进程 ID。
                $0 在 CLI 直接执行时,表示当前 Shell 的名称。在 Shell 脚本执行时,表示当前脚本名称。
                $@$# $@ 表示脚本的参数值;$# 表示脚本的参数数量。

                8.5 变量持久化

                若不希望每次启动新的 Shell 会话时,都必须重新设置重要的变量,则需要将变量写入配置文件中。以 macOS 为例,最常见的是配置文件是 ~/.bash_profile~/.zshrc,具体取决于你的使用了哪一种 Shell 解析器,请参考前面的第七节内容。

                比如我在 ~/.zshrc 中配置 nvm 的目录,可以添加这样一行配置:

                export NVM_DIR="$HOME/.nvm"
                

                其中 $HOME 本身就是一个环境变量(表示当前用户主目录),假设我的主目录是 /Users/frankie,那么变量 NVM_DIR 的值就是 /Users/frankie/.nvm

                平常配置最多的可能是 PATH 变量,假设安装了一个工具 A,然后需要配置该工具的可执行文件 aaa 的路径(假设为 ~/.aaa/bin),可以这样:

                export PATH=$PATH:$HOME/.aaa/bin
                

                如此 /Users/frankie/.aaa/bin 就会被添加至环境变量 PATH 中,这样我们就可以在任何目录下执行 aaa 命令了。

                记得配置完,要使用类似 source ~/.zshrc 的命令刷新变量,使其在已启动的 Shell 进程中生效,否则只在新打开的 Shell 进程中生效。因为每次启动一个新的 Shell 进程都会先读取对应的 Shell 配置文件,然后这个刷新命令本质上就是执行了一个 Shell 脚本而已,这些内容在前一节提到过了。

                九、常见目录区分

                • bin - 是 binary 的缩写,表示可执行二进制文件,主要用于具体应用。
                • sbin - 是 system binary 的缩写,表示系统级别的可执行二进制文件,主要用于系统管理。
                • etc - 源自拉丁语 et cetera,是「等等」的意思,用于存放整个文件系统的配置文件。其命名似乎是历史遗留问题,最初用于存放一些零零碎碎的文件。另一种说法是 Editable Text Configuration。
                • usr - 是 unix system resources 的缩写,它是系统中最重要的目录之一,涵盖了二进制文件、各种文档、各种头文件、x、各种库文件以及诸多程序。好像以前是存放 user 的目录,因此也认为是 user 的缩写,但现在用户目录多是 home。
                • var - 是 varible 的简写。目录中保存的是未知增长和其内容频繁变动的文件(因此名为变化的)
                • 更多请看 Linux 目录概览

                • /bin:通常是普通用户和超级用户都会用到的必要的命令,例如 lspwd 等等。
                • /sbin:通常是系统管理员使用的必要的来管理系统的命令,例如 shutdownifconfig 等等。
                • /usr/bin:通常是一些非必要的,但是普通用户和超级用户都可能使用到的命令,例如 gccldd 等等。
                • /usr/sbin:通常是一些非必要的,由系统管理员来使用的管理系统的命令,例如 crondhttpd 等等。
                • /usr/local/bin:通常是用户后来安装的软件,可能被普通用户或超级用户使用。
                • /usr/local/sbin:通常是用户后来安装的软件,一般是用来管理系统的,被系统管理员使用。

                从用户权限角度来看,sbin 下的命令都是用来管理系统的,所以一般是普通用户无法执行,只有系统管理员可以执行,而 bin 下的命令则是所有用户都可以执行的。

                注:以上所说的并不是绝对的,例如 ifconfig/sbin 下,但是普通用户一般具有可执行权限。

                小结

                • 如果是用户和管理员必备的二进制文件,就会放在 /bin
                • 如果是系统管理员必备,但是一般用户根本不会用到的二进制文件,就会放在 /sbin
                • 如果不是用户必备的二进制文件,多半会放在 /usr/bin
                • 如果不是系统管理员必备的工具,如网络管理命令,多半会放在 /usr/sbin

                其他目录

                • 主目录:/root/home/username
                • 用户可执行文件:/bin/usr/bin/usr/local/bin
                • 系统可执行文件:/sbin/usr/sbin/usr/local/sbin
                • 其他挂载点:/media/mnt
                • 配置:/etc
                • 临时文件:/tmp
                • 内核和 Bootloader:/boot
                • 服务器数据:/var/srv
                • 系统信息:/proc/sys
                • 共享库:/lib/usr/lib/usr/local/lib

                未完待续...

                ]]>
                <![CDATA[macOS 配置指南]]> https://github.com/tofrankie/blog/issues/31 https://github.com/tofrankie/blog/issues/31 Sat, 25 Feb 2023 11:02:06 GMT 配图源自 Freepik

                开始之前

                安装 Command Line Tools,很多命]]> 配图源自 Freepik

                开始之前

                安装 Command Line Tools,很多命令行工具都依赖它。

                $ xcode-select --install
                

                安装 Homebrew(参考此文),除了命令行工具,也可以用来安装图形化应用。

                如果不清楚包名的,可以用 brew search <keyword> 进行模糊搜索,比如:

                $ brew search alfred
                ==> Formulae
                fred                                                                   alure
                
                ==> Casks
                alfred                                                                 alfred@4
                

                系统偏好设置

                ▼ 个人主观感受的一些设置,仅供参考。

                触控板

                • 开启「轻点来点按」:轻触实现鼠标单击效果。
                • 将「在全屏幕显式的应用程序之间轻扫」调整为「四指左右轻扫」,这样触发成功率更高,相比三指会更舒服些。

                三指拖移应用窗口

                前往「系统偏好设置 - 辅助功能 - 指针控制(或鼠标与触控板) - 触控板选项 - 拖动样式」,选择「三指拖移」。更多可参考这里

                这样,将鼠标移动应用顶部,三指就能拖动应用窗口了。

                关闭自动更换桌面顺序

                参考解决 macOS 自动更换桌面位置的问题

                修改鼠标方向

                严格来说 macOS 默认的「自然方向」,个人认为是符合触控场景的。这点与手机体验是一致的,应该没有人认为手机触控屏的滑动方向是反人类的吧。

                那么在 macOS 上为什么备受争议呢?原因很简单,绝大多数人都是从 Windows 操作系统开始接触电脑的,加之市面上绝大多数的鼠标也是「滚轮式」的,在身体已经形成条件反射之后,初次接触到 macOS 的自然方向式的操作之后,是显然会抵触的,自然会被人视为反人类。但是 Apple 自家的 Magic Mouse 一直都是「触控式」的啊,它没有滚轮,因此它的滑动的方向与触控板一致逻辑上是正确的。

                虽然但是...,由于平常用得最多的是滚轮鼠标,可以使用第三方软件去调整鼠标的滚动方向。这里推荐 Scroll Reverser

                打开任何来源

                $ sudo spctl –master-disable
                

                也可以设置 alias 以便于记忆。

                $ echo 'alias allow-anywhere="sudo spctl --master-disable"' >> ~/.zshrc
                $ echo 'alias close-anywhere="sudo spctl --master-enable"' >> ~/.zshrc
                

                有个图形化的小工具,可从这里获取。

                调整 LaunchPad 图标大小

                以 MacBook 为例,默认一屏 7 × 5 的图标数量,可按调整大小。

                # 行数量
                $ defaults write com.apple.dock springboard-columns -int 9
                
                # 列数量
                $ defaults write com.apple.dock springboard-rows -int 6
                
                # 重置 LaunchPad
                $ defaults write com.apple.dock ResetLaunchPad -bool TRUE
                
                # 杀掉 Dock 栏进程
                $ killall Dock
                

                如需恢复默认状态,如下:

                $ defaults write com.apple.dock springboard-columns -int Default
                $ defaults write com.apple.dock springboard-rows -int Default
                $ defaults write com.apple.dock ResetLaunchPad -bool TRUE
                $ killall Dock
                

                系统增强

                QuickLook

                使用「空格键」以快速预览是 macOS 的一大特色。

                可某些文件类型并不支持预览器内容,可通过安装插件的形式来扩展。

                更多 macOS QuickLook 常用插件

                Finder 插件

                按住 Option 键,拖动窗口大小,可用于调整每次打开 Finder 窗口的默认尺寸。

                虽然 Finder 的颜值越来越好,但架不住功能简陋。

                比如,创建一个 TXT 的纯文本文件,如果不用命令行的话,你能想到其他办法吗?

                推荐两个 Finder 插件:

                Finder 隐藏性文件显隐

                快捷键:Command + Shift + .

                终端命令如下,但体验不好,不 killall 的话,不会刷新。

                # 显示「隐藏文件」
                $ defaults write com.apple.Finder AppleShowAllFiles YES; killall Finder;
                
                # 不显示「隐藏文件」
                $ defaults write com.apple.Finder AppleShowAllFiles NO; killall Finder;
                

                第三方增强

                • Raycast,一个比 Alfred 更好用的启动器。
                • Alfred,或许是比内置 Spotlight 更好的选择,生态上也有很多便捷、好用的 Workflows。(现更推荐 Raycast)
                • XtraFinder,Finder 增强插件,可惜颜值没跟上最新的 Finder。
                • TotalFinder,Finder 增强插件,可实现类似 Chrome 的多标签。
                • Scroll Reverser,还在为 macOS 触控板、鼠标的“自然”方向抓狂?试试这个吧。
                • Itsycal for Mac,菜单栏日历增强插件,颜值能打,而且可以在菜单栏查看日程等交互。

                终端

                个人没怎么用第三方终端应用。

                Oh My Zsh

                zsh Shell 增强,免掉繁琐的 zsh 配置,使得终端更加便捷高效。

                一个很不错的插件 zsh-autosuggestions,提供历史命令提示。

                一个自行调配的 Dark Mode 系统终端配色 Terminal Dark Theme,颜值还可以,配合 Oh My Zsh 味更佳。

                开发

                偏前端开发方向

                访问 GitHub

                最优解是科学上网。其次参考:

                Git

                安装

                $ brew install git
                

                SSH Key

                # 生成公钥、密钥
                $ ssh-keygen -t rsa -C '<your-email-address>' -f ~/.ssh/id_rsa
                
                # 将私钥加入 ssh-agent
                $ ssh-add ~/.ssh/id_rsa
                
                # 拷贝公钥
                $ cat ~/.ssh/id_rsa.pub | clipcopy
                
                # 将公钥添加到对应平台...
                

                全局配置

                $ git config --global user.name 'your-username'
                $ git config --global user.email 'your-email-address'
                

                Related link

                Node.js

                可以在官网下载安装,或者使用 Homebrew 安装。

                推荐用 nvmfnm 等管理 Node.js 版本,以便于在项目之间切换不同版本。

                以 nvm 为例:

                # 安装指定版本
                $ nvm install 22
                
                # 启动 corepack,就可以用 npm、yarn、pnpm 了
                $ corepack enable
                

                NPM registry 管理工具

                日常软件

                除上文系统增强提到的软件外,还有...

                • Chrome,日常使用最多的软件,没什么好说的。
                • Firefox,开发者版本,调试 CSS 很棒。
                • Edge,基于 Chromium,但颜值打不过 Chrome。
                • 搜狗输入法,自定义短语设置很不错。
                • Visual Studio Code,前端必备吧。
                • HandShaker,连接 Android 设备进行文件管理,老罗出品,但多年不更新了。
                • ClashX,你懂的(作者已删库)。(备用网站
                • iShot,免费的截图工具(个人偏好设置阴影大小 6pt,圆角大小 10pt)。可惜更喜欢的截图(Jietu)不再更新了。
                • Diagram,软件开发作图软件,免费、强大且完善,有网页版、桌面版。
                • CleanMyMac X,MacPaw 出品,是 macOS 里较为受欢迎的垃圾清理、软件卸载工具。

                想到再补充...

                ]]>
                <![CDATA[Intel Mac 升级 Win11 系统]]> https://github.com/tofrankie/blog/issues/30 https://github.com/tofrankie/blog/issues/30 Sat, 25 Feb 2023 11:01:33 GMT 配图源自 Freepik

                前言

                此前写过一篇文章《

                前言

                此前写过一篇文章《Mac 升级 Win11 系统》,提到过 Intel Mac 如何从 Win10 升级到 Win11,但前提是该 Mac 的处理器、TPM 芯片需同时满足 Win11 系统的硬件要求。

                然后,我手上的 MacBook Pro 2016 款(Intel),通过 PC Health Check 检测,是无法满足 Win11 升级要求的。👇

                The processor isn't currently supported for Windows 11.

                也就是说,我这个「2.9 GHz 双核 Intel Core i5」处理器不满足 Win11 系统的硬件要求。更多可看 Windows 11 System Requirements

                解决方案

                如果你「了解并承担」升级系统可能带来的风险,可以创建特定的注册表项值并绕过对 TPM 2.0 和 CPU 系列和型号的检查。

                1. 添加 TPM 芯片。

                安装 Parallels Desktop 17 及以上版本。在对应虚拟机设置的「硬件」选项卡,添加「TMP 芯片」。

                如果说在「+」中没有找到「TPM 芯片」选项,表示 Windows 虚拟机基于 Legacy BIOS。TPM 芯片仅适用于UEFI/EFI BIOS。此时,需要打开虚拟机进行设置:

                1. 在任务栏中搜索「msinfo32」,并回车打开。
                2. 在「系统摘要」中,找到「BIOS 模式」检查是否为「UEFI」。

                2. 下载 ISO 镜像

                前往 Microsoft 下载 Win11 镜像文件。

                3. 将镜像挂载到虚拟机

                下载 Windows 11 ISO 后,将 ISO 挂载到虚拟机。在菜单栏「设备 - CD/DVD 1 - 连接镜像」选择下载好的 ISO 镜像文件,然后在同一个地方点一下「连接」。打开「我的电脑」就能在「设备和驱动器」中找到连接好的镜像。其中前面 CD/DVD 1 或 2 都是可以的。

                下图是连接后的截图:

                4. 安装镜像

                打开对应的 DVD 驱动器,如无意外,就会出现如图所示的安装程序。一步一步往下进行即可。

                安装引导中,可能会出现前面提到过的「风险提醒」,选择「Accept」即可。

                This PC doesn't meet the minimum system requirements for running Windows 11 - these requirements help ensure a more reliable and higher quality experience. Installing Windows 11 on this PC is not recommended and may result in compatibility issues. If you proceed with installing Windows 11, your PC will no longer be supported and won't be entitled to receive updates. Damages to your PC due to lack of compatibility aren't covered under the manufacturer warranty.

                然后就是漫长的安装过程...

                安装完成后,简单体验了一下,没啥毛病...

                参考链接

                ]]>
                <![CDATA[弄清楚 HostName、LocalHostName、ComputerName 之间的区别]]> https://github.com/tofrankie/blog/issues/29 https://github.com/tofrankie/blog/issues/29 Sat, 25 Feb 2023 11:00:37 GMT 配图源自 Freepik

                一、简介

                • ComputerName<]]> 配图源自 Freepik

                  一、简介

                  • ComputerName - 电脑名称。
                  • LocalHostName - 本地主机名。
                  • HostName - 主机名。

                  可通过以下命令查看:

                  $ hostname
                  host-0-1.can.xxx.network
                  
                  $ scutil --get HostName
                  HostName: not set
                  
                  $ scutil --get LocalHostName
                  Frankies-MacBook-Pro
                  
                  $ scutil --get ComputerName
                  Frankie's MacBook Pro
                  

                  在 macOS 上可在「系统偏好设置 - 共享」中查看:

                  细心的同学会发现,关于 HostName 命令行输出与界面上显示的不一样,具体原因下面会介绍。

                  二、scutil 使用

                  以 macOS 为例,HostNameLocalHostNameComputerName 是可以在系统配置文件 /Library/Preferences/SystemConfiguration/preferences.plist 看到的。其中 *.plist 表示属性列表文件,通常用来存储用户设置。

                  这里我截取了一部分配置,如下:

                  <?xml version="1.0" encoding="UTF-8"?>
                  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
                  <plist version="1.0">
                    <dict>
                      <key>CurrentSet</key>
                      <string>/Sets/1B16C032-311E-4EE7-A79F-7058C7340EE8</string>
                      <key>Model</key>
                      <string>MacBookPro13,2</string>
                      <key>System</key>
                      <dict>
                        <key>Network</key>
                        <dict>
                          <key>HostNames</key>
                          <dict>
                            <key>LocalHostName</key>
                            <string>Frankies-MacBook-Pro</string>
                          </dict>
                        </dict>
                        <key>System</key>
                        <dict>
                          <key>ComputerName</key>
                          <string>Frankie's MacBook Pro</string>
                          <key>ComputerNameEncoding</key>
                          <integer>25</integer>
                          <!-- <key>HostName</key>
                  			  <string>如有设置的话,HostName 将会在此展示</string> -->
                        </dict>
                      </dict>
                      <key>__VERSION__</key>
                      <integer>20191120</integer>
                    </dict>
                  </plist>
                  

                  再回头看通过 scutil 命令输出结果就很容易理解了,它读取的就是此文件的内容罢了。

                  $ scutil --get HostName
                  HostName: not set
                  
                  $ scutil --get LocalHostName
                  Frankies-MacBook-Pro
                  
                  $ scutil --get ComputerName
                  Frankie's MacBook Pro
                  

                  其中 scutil --get HostName 输出 HostName: not set 就是因为未曾设置过 HostName

                  scutil 的用法

                  scutil 命令是管理系统配置的工具。

                  查看、设置配置:

                  $ scutil --get <pref>
                  $ scutil --set <pref> <newval>
                  

                  其中 pref(preference)目前仅支持 ComputerNameLocalHostNameHostName

                  查看 DNS 配置信息:

                  $ scutil --dns
                  

                  查看代理信息:

                  $ scutil --proxy
                  

                  查看网络信息(IPv4/IPv6):

                  $ scutil --nwi
                  

                  更详细用法可通过 man scutilscutil -h 查看。

                  三、读取 hostname

                  前面,执行 hostname 命令输出结果如下:

                  $ hostname
                  host-0-1.can.xxx.network
                  

                  它看似乎起来与 LocalHostNameHostName 无关?

                  其实不然,它跟读取顺序有关。以 macOS 为例,其读取顺序如下:

                  1. /etc/hosts 文件读取(在 OS X Yosemite 之前为 /etc/hostconfig 文件)。
                  2. 从系统配置 /Library/Preferences/SystemConfiguration/preferences.plist 读取 System ▸ System ▸ HostName
                  3. 根据本机 IP 地址的反向 DNS 查询获取。
                  4. 从系统配置 /Library/Preferences/SystemConfiguration/preferences.plist 读取 System ▸ Network ▸ HostNames ▸ LocalHostName
                  5. 若以上都无法获取到,则默认为 localhost

                  然后我这里的话,是在第三个步骤里得到的。根据反向 DNS 协议,可使用 IP 地址查询到对应的 HostName,这里利用到 nslookup 命令。

                  假设我的本地 IP 地址为 172.16.0.1(乱写的),反向 DNS 查询结果类似如下:

                  $ nslookup 172.16.0.1
                  Server:		172.24.0.2
                  Address:	172.24.0.2#53
                  
                  0.1.16.172.in-addr.arpa	name = host-0-1.can.xxx.network.
                  
                  

                  四、hostname 是什么?

                  未完待续...

                  ]]>
                  <![CDATA[macOS 终端美化与 zsh 多设备配置同步共享]]> https://github.com/tofrankie/blog/issues/28 https://github.com/tofrankie/blog/issues/28 Sat, 25 Feb 2023 10:59:23 GMT 配图源自 Freepik

                  前言

                  对 iTerm2 等第三方终端工具不大感冒,一直在用系]]> 配图源自 Freepik

                  前言

                  对 iTerm2 等第三方终端工具不大感冒,一直在用系统内置终端。

                  但系统终端确实简陋,终于有空折腾一下它了。

                  为什么选择 zsh?

                  在 Unix-like 操作系统中供选择的 Shell 解析器种类有很多,最常见的是 bash 和 zsh。

                  从 macOS 10.15 起,系统使用 zsh 作为默认 Shell,更多请看「在 Mac 上将 zsh 用作默认 Shell」。

                  相较之下,zsh 优势:

                  • 完全兼容 bash
                  • 更强大的 Tab 补全
                  • 更智能的目录切换
                  • 命令选项补全
                  • 文件、目录大小写自动更正
                  • 丰富的主题、插件(Oh My Zsh)
                  • 可查看命令输入历史记录

                  简单来说,Tab 大法好。

                  Oh My Zsh

                  zsh 功能强大,但配置繁琐,详见 Archlinux Zsh

                  社区上的 Oh My Zsh 项目极大地改变了配置繁琐的现象,并且带来了强大的插件和主题功能。

                  安装

                  $ sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
                  

                  安装后存于 ~/.oh-my-zsh 目录,可以找到它提供的任何插件、主题等。

                  此前的 .zshrc 配置文件会被重命名为 .zshrc.pre-oh-my-zsh

                  插件

                  Oh My Zsh 提供了非常丰富的插件,详见 Plugins Wiki

                  如需添加插件,在 ~/.zshrc 引入即可。比如,按顺序启用 nvm、git、ruby 插件:

                  plugins=(nvm git ruby)
                  

                  插件之间用空格隔开,但不能用逗号,否则会被中断。

                  主题

                  Oh My Zsh 提供了超过 150 种主题,默认主题是 robbyrussell,更多请看这里

                  同样地,如需配置主题,则添加到 ~/.zshrc 里面。

                  ZSH_THEME="robbyrussell"
                  

                  它还提供了一些花里胡哨的功能。

                  # 每打开一个新的 Shell 窗口,随机选择内置的主题。
                  ZSH_THEME="random"
                  
                  # 或者从多个候选主题中随机展示。
                  ZSH_THEME_RANDOM_CANDIDATES=(
                    "robbyrussell"
                    "agnoster"
                  )
                  
                  # 甚至可以排除掉不喜欢的主题,这样就不会在随机主题中出现。
                  ZSH_THEME_RANDOM_IGNORED=(pygmalion tjkirch_mod)
                  

                  系统终端主题

                  系统内置的终端工具默认主题「Basic」是白底黑字的。虽然内置了多种主题,但都挺丑的。

                  先调整下,修改命令提示符:

                  PROMPT="%(?:%{$fg_bold[green]%}→ ●:%{$fg_bold[red]%}→ ●)%{$fg_bold[white]%}●"
                  PROMPT+=" %{$fg_bold[yellow]%}%c %{$reset_color%}$ "
                  RPROMPT="[%{$fg[white]%}%*%{$reset_color%}]"
                  

                  系统处于浅色模式时,vim 模式的默认背景色是白底,可以在 ~/.vimrc 中添加配置使背景色统一。

                  set background=dark "设置背景色
                  

                  配置文件放在 tofrankie/terminal-dark-theme ⭐️

                  设备共享

                  我有多台 Mac 设备,要做一些同步共享处理,省去多设备修改的麻烦。

                  思路

                  一般来说,zsh 配置都是用户级别的,存放于 ~/.zshrc

                  可以这样:

                  1. 将配置文件存放于 iCloud 云盘,可多设备同步。
                  2. 在本地配置中引入 iCloud 云盘同步的配置文件。

                  在 iCloud 云盘下创建 Terminal 目录:

                  $ cd ~/Library/Mobile\ Documents/com~apple~CloudDocs
                  $ mkdir Terminal
                  

                  接着,将 .zshrc 拷贝至云盘目录下:

                  $ cp ~/.zshrc ~/Library/Mobile\ Documents/com~apple~CloudDocs/Terminal/zshrc.sh
                  

                  然后,修改文件 ~/.zshrc 为:

                  source $HOME/Library/Mobile\ Documents/com~apple~CloudDocs/Terminal/zshrc.sh
                  

                  source 命令用于执行一个脚本,通常用于重新加载一个配置文件。

                  最后,执行 source ~/.zshrc 刷新配置。

                  配置拆分

                  既然是多设备,各设备环境很大可能是不一样的,配置自然不同。

                  此场景下,多设备复用同一个 zsh 配置文件,显然不妥。

                  因此,要对 Terminal/zshrc.sh 配置进行拆分:

                  ~/Library/Mobile Documents/com~apple~CloudDocs/Terminal/
                  ├── zshrc.sh            # 统一引入以下细分配置
                  ├── zshrc-common.sh     # 各设备共同配置,比如 Oh-My-Zsh 等
                  ├── zshrc-imac.sh       # iMac 设备特有配置
                  └── zshrc-mbp.sh        # MacBook Pro 设备特有配置 
                  

                  按需调整。

                  此处文件名不以 . 开头,原因是不想隐藏文件,毕竟存放在 iCloud 云盘目录下,没必要隐藏了。Shell 脚本扩展名是可省略的,甚至可自定义。使用 .sh 扩展名也方便编辑器语法高亮。

                  按设备读取

                  先在 zshrc.sh 引入 zshrc-common.sh 共同配置:

                  # zshrc.sh
                  
                  # 加载共同配置
                  source $HOME/Library/Mobile\ Documents/com~apple~CloudDocs/Terminal/zshrc-common.sh
                  
                  # 加载设备特有配置
                  # 而且应放在共同配置后面加载,原则上它们的优先级应该较高的。
                  # ...
                  

                  可通过以下方式区分设备:

                  $ hostname
                  Frankies-iMac.local
                  
                  $ scutil --get LocalHostName
                  Frankies-iMac
                  
                  $ scutil --get ComputerName
                  Frankie's iMac
                  

                  对应如下:

                  • LocalHostName 是 ComputerName 格式化之后的名称,比如空格会替换为连字符 -,一些特殊符号也会被忽略。
                  • hostname 则是在 LocalHostName 加上 .local 的名称。由于 hostname 读取顺序(详见),它可能会受到所连接网络的影响,因此它可能不是固定值。

                  这里使用 scutil --get ComputerName 命令,它的返回值取决于你输入的电脑名称。

                  注意 ComputerName 为 macOS 独有。

                  接着,编写一个 Shell 函数,按设备电脑名称加载对应配置。

                  # 根据设备电脑名称,读取对应配置
                  function load_device_zsh_configuration() {
                    local computer_name=$(scutil --get ComputerName)
                    local icloud_config_dir="$HOME/Library/Mobile Documents/com~apple~CloudDocs/Terminal"
                    if [[ $computer_name =~ 'MacBook' ]]; then
                      local config_file="$icloud_config_dir/zshrc-mbp.sh"
                      if [ -f "$config_file" ]; then
                        source "$config_file"
                      fi
                    elif [[ $computer_name =~ 'iMac' ]]; then
                      local config_file="$icloud_config_dir/zshrc-imac.sh"
                      if [ -f "$config_file" ]; then
                        source "$config_file"
                      fi
                    fi
                  }
                  load_device_zsh_configuration
                  unset -f load_device_zsh_configuration
                  

                  有几处请按需调整:

                  1. icloud_config_dir 后面的目录,也就是 iCloud 云盘中存放配置文件的目录。
                  2. 类似 $computer_name =~ 'MacBook' 这行,意思是当 ComputerName 中包含 MacBook 字符串时,执行 if 语句,将加载 zshrc-mbp.sh 配置。

                  最后 zshrc.sh 的配置如下:

                  # Load zsh common configuration
                  source $HOME/Library/Mobile\ Documents/com~apple~CloudDocs/Terminal/zshrc-common.sh
                  
                  # Load device zsh configuration
                  function load_device_zsh_configuration() {
                    local computer_name=$(scutil --get ComputerName)
                    local icloud_config_dir="$HOME/Library/Mobile Documents/com~apple~CloudDocs/Terminal"
                    if [[ $computer_name =~ 'MacBook' ]]; then
                      local config_file="$icloud_config_dir/zshrc-mbp.sh"
                      if [ -f "$config_file" ]; then
                        source "$config_file"
                      fi
                    elif [[ $computer_name =~ 'iMac' ]]; then
                      local config_file="$icloud_config_dir/zshrc-imac.sh"
                      if [ -f "$config_file" ]; then
                        source "$config_file"
                      fi
                    fi
                  }
                  load_device_zsh_configuration
                  unset -f load_device_zsh_configuration
                  

                  一次失败的尝试与思考

                  最初,利用链接文件特性的思路:

                  Terminal/zshrc.sh~/.zshrc 建立硬链接关系,此时两者就是真正意义上的同一文件(inode 号码相同),修改其中一方,另一方会同步修改。而且删除任意一方,对另一方不会产生影响。

                  我们知道,iCloud 云盘文件变更会自动同步。

                  据观察,当文件同步完成之后,虽然文件名没有发生改变,但文件的 inode 会发生变化。这样会导致前面建立的硬链接关系破裂。也就是说,Terminal/zshrc.sh~/.zshrc 的 inode 不再相同,修改任意一方,另一方都不会同步修改。所以这个思路是不行的。

                  后来,我又萌生了另一种思路:利用软链接文件。软链接文件的文件内容存放的是文件路径,前面 iCloud 同步不会影响文件名,起来似乎是可行的:

                  # 创建 Terminal/zshrc.sh 的软链接至 ~/.zshrc.icloud
                  $ ln -s ~/Library/Mobile\ Documents/com~apple~CloudDocs/Terminal/zshrc.sh ~/.zshrc.icloud
                  
                  # 在 ~/.zshrc 写入配置
                  $ echo 'source ~/.zshrc.icloud' > ~/.zshrc
                  
                  # 刷新配置
                  $ souce ~/.zshrc
                  

                  以上在 ~/.zshrc 加载 ~/.zshrc.icloud 的时候,由于后者是 Terminal/zshrc.sh 的软链接,命令执行时会找到 Terminal/zshrc.sh 的内容进行加载。

                  这种方案看着是可行的,但属实有点多次一举了。何不直接在 ~/.zshrc 加载 Terminal/zshrc.sh 呢?

                  Linux 链接文件详解

                  其他

                  后续配置调整,应根据实际情况对以下文件作出修改,而不是直接修改 ~/.zshrc 本地配置。

                  ~/Library/Mobile Documents/com~apple~CloudDocs/Terminal/
                  ├── zshrc-common.sh
                  ├── zshrc-imac.sh
                  └── zshrc-mbp.sh
                  

                  为了方便,设置了一些变量别名(仅供参考):

                  # zshrc-common.sh
                  
                  # 刷新本地配置
                  alias sourcez="source ~/.zshrc"
                  
                  # 设置别名,可快速打开各配置文件
                  alias openz-common="open $HOME/Library/Mobile\ Documents/com~apple~CloudDocs/Terminal/zshrc-common.sh"
                  alias openz-imac="open $HOME/Library/Mobile\ Documents/com~apple~CloudDocs/Terminal/zshrc-imac.sh"
                  alias openz-mbp="open $HOME/Library/Mobile\ Documents/com~apple~CloudDocs/Terminal/zshrc-mbp.sh"
                  
                  # 设置变量,可直接通过 `cd $ICLOUD_TERMINAL_DIR` 命令打开目录
                  export ICLOUD_TERMINAL_DIR="$HOME/Library/Mobile Documents/com~apple~CloudDocs/Terminal"
                  

                  也可以给每个设备的配置文件设置变量 Configuration_File_Name,然后利用类似 echo 'some configuration' >> $Configuration_File_Name 形式往配置文件中追加配置。

                  The end.

                  ]]>
                  <![CDATA[Win11 打开 IE 浏览器]]> https://github.com/tofrankie/blog/issues/27 https://github.com/tofrankie/blog/issues/27 Sat, 25 Feb 2023 10:58:13 GMT 配图源自 Freepik

                  Microsoft 发布 Windows 11 之后,系统使用基于 Chro]]> 配图源自 Freepik

                  Microsoft 发布 Windows 11 之后,系统使用基于 Chromium 内核的 Edge 浏览器来代替被人唾骂已久的 IE 浏览器,IE 浏览器入口也已经被屏蔽掉了。通过任何常规方式打开 IE 浏览器都会自动使用 Edge 浏览器打开。

                  最新消息,IE 浏览器于北京时间 2022 年 6 月15 日 21:00 正式退役。

                  前端人表示长舒一口气,尽管我目前所做过的 Web 项目并没有遇到需要兼容 IE 浏览器的。然而,尽管 IE 浏览器已经停止服务,只要国内银行、政府等有关部门的网站仅 IE 浏览器可访问,至于 Edge 浏览器提供的 IE 兼容模式,个人没试过,因为我似乎没有这个需要...

                  作为前端人,有时候需要在 IE8 等远古浏览器中运行某些代码,来学习、测试验证 API 兼容性等,因此对我来说还是需要 IE 浏览器的,尽管场景很少很少...

                  尽管 IE 浏览器入口被屏蔽了,但微软目前还未将其彻底删除,存放在 C:\Program Files\Internet Explorer\iexplore.exe。但双击打开会直接跳转到 Edge 浏览器中。

                  接下来将会介绍如何在 Win11 中打开 IE 浏览器。

                  方式一(在最初的预览版中有效,目前已失效)

                  C:\Windows\System32\ieframe.dll 文件进行替换即可。但这种方式每次更新系统后需重新替换,太麻烦了...

                  方式二

                  首先,在 Edge 浏览器设置中将「让 Internet Explorer 在 Microsoft Edge 中打开网站」选项修改为「从不」。

                  其次,创建一个 TXT 文件,复制以下命令到文件内并保存,接着修改文件扩展名为 .vbs,然后双击打开文件便可打开 IE 浏览器。

                  CreateObject("InternetExplorer.Application").Visible=true
                  

                  不排除微软在未来某个版本中将这种方式封堵,但也是必然的事情...

                  The end.

                  ]]>
                  <![CDATA[Mac 终端使用鼠标来移动光标]]> https://github.com/tofrankie/blog/issues/26 https://github.com/tofrankie/blog/issues/26 Sat, 25 Feb 2023 10:57:29 GMT 配图源自 Freepik

                  假设场景:在终端输入了一长串指令后,突然发现中间有个参数写错了需要修改,这时]]> 配图源自 Freepik

                  假设场景:在终端输入了一长串指令后,突然发现中间有个参数写错了需要修改,这时候可能要通过「方向键」移动光标,这是非常影响效率的。

                  我甚至记不住这些快捷键:

                  • Ctrl + A:移至行首
                  • Ctrl + E:移至行末
                  • Option + 左方向键:左移一个单词
                  • Option + 右方向键:右移一个单词

                  那么如何在 Mac 系统终端中愉快地使用鼠标移动关闭呢?

                  • 在输入指令或者 vim 模式下,按住 Option 键(Alt),点击鼠标就能移动光标至对应位置。
                  • 另外,通过在配置文件 ~/.vimrc 添加一项 set mouse=a(activating mouse features)配置,vim 模式下便可直接用鼠标来移动光标位置。

                  第三方终端工具是否支持,没有亲测。个人不喜欢第三方终端工具的花里胡哨。

                  参考链接:

                  ]]>
                  <![CDATA[关于 iMac 内存扩展这件事]]> https://github.com/tofrankie/blog/issues/25 https://github.com/tofrankie/blog/issues/25 Sat, 25 Feb 2023 10:57:04 GMT 配图源自 Freepik

                  俗话说,「工欲善其事,必先利其器」。

                  于是,前几天网上下单买了]]> 配图源自 Freepik

                  俗话说,「工欲善其事,必先利其器」。

                  于是,前几天网上下单买了 2 条 16G 的内存条,装备一下手上的 iMac 2020。

                  购机配置是 8 + 512G 的组合。其实倒不是说 8G 内存不够用,只是偶尔打开虚拟机的时候,略卡,体验不好。但其实我打开虚拟机频次很少,但由于强迫症、尝鲜派等属性加持,然后又考虑到投资成本在可接受范围,所以话不多说就下单了...

                  下班回来装上之后,纵享丝滑...

                  当初购买 iMac 的原因,其实很简单:就是不想每天背着 MacBook Pro 上下班。虽然很多人都说便携,但背多了就会觉得重。但是不带的话,学习又不方便......

                  买了之后,从此释放了肩膀,不用带电脑上下班,但又不影响搬砖学习,幸福感真的很好。

                  从大三开始到现在,已经几乎集齐了 Apple 全家桶了,iPhone × 3、iPad Pro、iMac、MacBook Pro、AirPods × 2、Magic Mouse × 2、Magic Keyboard,另外还有 2 个显示器、2 个机械键盘等等,确实有点费钱...

                  其实,一直不认为用苹果就怎样怎样,适合自己、够用实用、用得舒服就是好产品,无关品牌。对于网上充斥着一些不好的声音,「一笑而过」就好了。生活本来就很忙,真的没必要理会。

                  我买那么多它们家的产品,原因嘛:

                  • 摊牌了,我是 UI 颜值党。大学那会一直在做手机主题,久而久之的浸染,勉强还行的审美,加之产生的强迫症,macOS 和 iOS 等系统细节控,可以很好地满足我。

                  • App Store 的软件体验,要比 Android 好(个人感受)。有人说 Android 可玩性高啊,这个不否认。我曾经也是那个拿着「小米 2」刷各种 ROM 的年轻人(俗称刷机时代)。但由于现在各家系统都做得不错,差异化已经不大了,所以现在刷机时代已经一去不复返了,没几个人天天捣弄这些了。随着慢慢长大,那股折腾劲也没了。

                    那么为什么说体验好呢?我本身是做前端开发,深知其实 iOS 适配效果是相对较好的(Safari 另说 🙄)。无论字体、对齐等等效果比「百花齐放」的 Android 要好很多。曾经我用 Android 也是经常换着字体用的,但新鲜感没了之后,带来的是长时间阅读的不爽。

                    其实,绝大多数人根本不会注意到这些,但由于我本身就是很喜欢「设计」的开发者,又有很强烈的强迫症,这些细节带来的是莫大的舒适感。比如说 Android 的状态栏,各种高低不平的图标,还有各种应用的缩略图,这当初没在用 iPhone 之前我也没觉得有什么问题,但长时间使用下来,真的还是觉得 iOS 的会舒服很多。

                  • Apple 全家桶带来的快乐,iCloud 同步、接力、隔空投送等等,太方便了,起码现在是离不开了。还有就是工作、学习效率上的提升,一个不卡的系统真的太重要了。原本工作就很累,真的不像把时间浪费在卡顿上面(不敢说 Windows 卡顿,应该是我没用过比较贵的 Windows 设备)...把省下来的时间去做更有意义的事,岂不快哉...

                    突然想起,某乎「预算 3000 买二手的 iPhone 11 还是 Android 该价位的旗舰机?」的一个问题(大致是这个意思)。我想说的是,现在可以说是没了手机就寸步难行的时代了,有些人每天对着的手机时间可能甚至比对任何一个人都多,那么在经济条件允许范围内,何不买一个「好用」的手机呢?至于怎样定义好用,就见仁见智了。

                  • 对于不喜欢玩游戏、不天天写 PPT、Word 文档的我,那么 macOS 简直就是不二选择。另外,现在的公司使用 Google Docs 协作真的很方便。然后受不了 Windows 的字体渲染、也不想折腾 Linux 系统(应该是我不会玩)。

                  • 还有人说,macOS 的软件很少,很多专业性软件都没有,这应该是客观存在的问题,毕竟 macOS 的市场占有率就摆在那里。但我想说的是,主流软件(比如开发、设计、剪辑等专业性质的)macOS 应该说做得要比 Windows 好很多(最强 IDE 别喷我,哈哈),起码体验上是这样。即使某一款软件没有 Mac 版,但很多都能找到一些不错的替代软件的。要是真的不行,我们还可以装个虚拟机嘛。

                  • ...

                  ]]>
                  <![CDATA[解决 Sourcetree 报错 Couldn't posix_spawn: error 2 问题]]> https://github.com/tofrankie/blog/issues/24 https://github.com/tofrankie/blog/issues/24 Sat, 25 Feb 2023 10:56:31 GMT 配图源自 Freepik

                  前几天更新到 macOS 12.3 之后,Sourcetree 无法正常打开 ]]> 配图源自 Freepik

                  前几天更新到 macOS 12.3 之后,Sourcetree 无法正常打开 Mercurial 的项目,猜测是跟 macOS 12.3 移除了内置的 Python2 有关。报错如下:

                  在 ATLASSIAN Community 上也能找到相关的帖子,比如这篇:Couldn't posix_spawn: error 2,我也在上面留言了。

                  其实,打开 Sourcetree 偏好设置可以看到报错原因了:

                  其中有两个选项,前者意思是「使用内置的 Mercurial」,后者表示「使用系统安装的 Mercurial」。猜测是 Sourcetree 内置的 Mercurial 依赖了 macOS 系统内置的 Python2,但由于系统更新之后已彻底移除,所以就报错了(当前 Sourcetree 最新版本为 4.1.6,仍存在问题,目测后续版本会解决此问题)。

                  因此,解决思路也很简单,就是使用自行安装的 Mercurial 即可。

                  Use System Mercurial 处选择路径:/usr/local/Cellar/mercurial/6.1/lib/python3.10/site-packages/mercurial(根据自己安装的 Mercurial 版本及对应路径去选择)。

                  如果你是通过 Homebrew 安装 Mercurial 的话,可以使用 brew list mercurial 命令查看其路径。

                  重新打开,就可以了,不再报错。

                  2025.03.19 更新

                  打开仓库错误提示,也可以使用上述方法解决。

                  'hg status' failed with code 255:'abort: repository requires features unknown to this Mercurial: revlog-compression-zstd (see https://mercurial-scm.org/wiki/MissingRequirement for more information) '
                  

                  参考链接

                  ]]>
                  <![CDATA[解决启动 Parallels Desktop 之后 macOS 没声音的问题]]> https://github.com/tofrankie/blog/issues/23 https://github.com/tofrankie/blog/issues/23 Sat, 25 Feb 2023 10:54:16 GMT 配图源自 Freepik

                  以下操作,需先将虚拟机关机。

                  配图源自 Freepik

                  以下操作,需先将虚拟机关机。

                  解决方案

                  1. 在 Parallels Desktop 的控制中心,进入对应系统配置页面。

                  1. 打开「硬件 — CPU 与内存 — 高级」选项:

                  1. 将虚拟机监控程序修改为「Apple」,并保存。

                  1. 打开系统「终端 Terminal」软件,并执行以下命令:
                  $ sudo launchctl stop com.apple.audio.coreaudiod && sudo launchctl start com.apple.audio.coreaudiod
                  

                  若系统版本低于 macOS Catalina 10.15.3,请使用 kill coreaudiod 命令。

                  More

                  如果以上步骤完成并保存之后,发现无法打开虚拟机,提示如下:

                  目前问题暂时无法解决,只能将前面的「虚拟机监控程序」改回「Parallels」。

                  网上有些文章给出的方案:

                  # 1. 列出显示虚拟机信息
                  $ prltcl list -a
                  UUID                                    STATUS       IP_ADDR         NAME
                  {CT_ID}                                  stopped      -               Windows 11
                  
                  # 2. 设置容器参数
                  $ prlctl set <CT_ID> --hypervisor-type parallels
                  The VM has been successfully configured.
                  

                  这两个操作也只是将「虚拟机监控程序」改回「Parallels」而已。

                  参考资料

                  ]]>
                  <![CDATA[解决 Parallels Desktop 17 无法连接网络问题]]> https://github.com/tofrankie/blog/issues/22 https://github.com/tofrankie/blog/issues/22 Sat, 25 Feb 2023 10:53:51 GMT 配图源自 Freepik

                  不少小伙伴在 macOS Big Sur 或 Monterey 中安装 Pa]]> 配图源自 Freepik

                  不少小伙伴在 macOS Big Sur 或 Monterey 中安装 Parallels Desktop 16/17 之后,都遇到了初始化网络失败,无法连接网络的问题。

                  我们只要修改两个文件的配置即可:

                  • /Library/Preferences/Parallels/dispatcher.desktop.xml
                  • /Library/Preferences/Parallels/network.desktop.xml

                  可以通过 Finder 的前往文件夹功能直达,如下图:

                  1. 打开 dispatcher.desktop.xml 文件,找到 <Usb>0</Usb>,修改为 <Usb>1</Usb> 并保存。

                  可使用 ⌘ + F 键,输入关键词,来快速定位

                  1. 打开 network.desktop.xml 文件,找到 <UseKextless>1</UseKextless><UseKextless>-1</UseKextless>,修改为 <UseKextless>0</UseKextless> 并保存。

                  如果找不到 <UseKextless>1</UseKextless><UseKextless>-1</UseKextless>,那么需要新增一行。

                  <ParallelsNetworkConfig schemaVersion="1.0" dyn_lists="VirtualNetworks 1">
                    <!-- 在第一行新增即可 -->
                    <UseKextless>0</UseKextless>
                  </ParallelsNetworkConfig>
                  
                  1. 完全退出 Parallels Desktop,重新打开,就能正常连接网络了。

                  若想在 Parallels Desktop 中,从 Windows 10 升级至 Windows 11,可以看看这篇文章:Mac 升级 Win11 系统

                  其他

                  假设使用系统自带的文本编辑 APP 来进行修改,因读写权限问题,可能无法直接保存,如下图。

                  此时,可以先将文件拷贝到其他可正常读写的文件夹(如 Desktop 桌面),修改保存,然后拷贝原文件夹覆盖便可。

                  The end.

                  ]]>
                  <![CDATA[Mac 升级 Win11 系统(亲测已成功)]]> https://github.com/tofrankie/blog/issues/21 https://github.com/tofrankie/blog/issues/21 Sat, 25 Feb 2023 10:50:44 GMT 配图源自 Freepik

                  网上找了好多教程都是没用的,或者是有偿的......终于自己也有了个教程。本文]]> 配图源自 Freepik

                  网上找了好多教程都是没用的,或者是有偿的......终于自己也有了个教程。本文旨在帮助 Mac(Intel)+ Parallels Desktop 的用户从 Win10 升级到 Win11。

                  声明:本人没有 M1 芯片的机器,未尝试本教程是否适用,也不清楚会否导致意外的问题,后果自负。

                  系统版本:macOS 11.5.2(如图),虚拟机:Parallels Desktop 17.0.0 (51461)。

                  由于本人业余爱好是设计,算是颜狗吧。尽管可能万年都不会打开在虚拟机中沉睡的 Windows 系统,但还是非常地想把原先的 Windows 10 升级到 Windows 11。

                  一、开启 Windows 预览体验计划

                  若无体验计划的话,前往设置 -> 隐私 -> 诊断和反馈,勾选可选诊断数据、开启改进墨迹书写和键入。

                  前往设置 -> 更新和安全 -> 开启 Windows 预览体验计划,应该就能看到加入预览计划了。可能开启后需要重启。(忘截图了)

                  非常遗憾,重启完之后,可能仍会告诉我们您的机器不支持 Windows 11,原因众所周知...(也忘截图了)

                  解决方法:按下 Win + R 组合键(其实在 Mac 键盘应该是 ⌘ + R),输入 regedit 回车打开注册表编辑器,找到:HKEY_LOCAL_MACHINE -> SOFTWARE -> Microsoft -> WindowsSelfHost -> UI -> Selection -> UIBranch,双击修改为 Dev 并保存。

                  然后返回 Windows 预览体验计划界面,可能仍提示不支持 Windows 11 的更新...

                  但没关系,前往设置 -> 更新和安全 -> Windows 更新,我们是可以收到类似 Windows 11 Insider Preview 10.0.22000.160 (co_release) 的更新请求的。

                  二、更新前

                  后续更新下载的过程,可能会提示:

                  原因是在 PD 偏好设置中,我对这台虚拟机分配了 2G 的内存。由于修改内存需要对虚拟机关机才能修改,因此建议提前改一下。(准备升级之后再改回来)

                  按要求设置 4G 内存即可。如果升级过程中才发现也没问题,关掉虚拟机重新设置下,再开机进行更新即可。

                  还有一步操作,在虚拟机的配置中加上 TPM 芯片选项,这是在其他帖子的教程看到的,当初添加了但也是没办法升级 Win11 的。(可能这个步骤也不重要吧)

                  三、更新过程

                  有一个进度节点可能会卡住或弹窗提醒:8%,原因是 Mac 所有机器理论上不支持更新 Win11。

                  我到这一步的提醒是前面的内存不满足系统要求。修改了内存分配就能继续更新了。

                  不要慌,此时前往 文件资源管理器 -> 此电脑 -> 本地磁盘(C:) -> $WINDOWS.~BT -> Sources 目录下,找到 AppraiserRes.dll 文件并删除。其中 $WINDOWS.~BT 属于隐藏性文件。

                  返回更新界面,继续进行更新...

                  此处省略一万字......下载安装完成,重启中...

                  四、更新后

                  更新成功了,在 UI 颜值上确实有了长足的提升...简单体验下来,略有点小卡...

                  某个 Moment 不小心打开电脑健康状况检查发现,竟然又行了,哈哈。

                  我怀疑跟分配的内存有关,我后面改回 2G 内存,又提示这台电脑无法运行 Windows 11 了。Anyway,已经更新成功了...

                  但我发现 IE 浏览器用不了了,用它只是作为前端狗用来调试 JavaScript 兼容性而已...顾此失彼?

                  非常感谢贴吧無殇大佬的教程:MacBook(Intel)更新 Windows11 教程

                  五、其他

                  对于基于苹果 M 系列的 Mac,则需要下载对应的 Windows ARM 版 ISO 镜像进行安装。

                  ]]>
                  <![CDATA[Mac 终端打开 iCloud 目录]]> https://github.com/tofrankie/blog/issues/20 https://github.com/tofrankie/blog/issues/20 Sat, 25 Feb 2023 10:49:23 GMT

                  一篇没有营养的文章,哈哈

                  习惯上,]]>

                  一篇没有营养的文章,哈哈

                  习惯上,不清楚路径在哪的话,在 Finder 相应文件拖拽到终端应用,会显示当前文件或目录的路径。

                  然后,今天想打开 iCloud 云盘路径时,直接拖过去 cd 打开,却:

                  $ cd /Users/frankie/Library/Mobile Documents/comappleCloudDocs/Alfred
                  
                  cd: no such file or directory: /Users/frankie/Library/Mobile Documents/comappleCloudDocs/Alfred
                  

                  然后仔细看了下,原来是 Mobile Documents 目录中间是含一个 空格,因此打开的时候需要 \ 转义。

                  如下:

                  $ cd ~/Library/Mobile\ Documents/com\~apple\~CloudDocs
                  
                  ]]>
                  <![CDATA[iPhone 与 Mac 接力失效解决方法]]> https://github.com/tofrankie/blog/issues/19 https://github.com/tofrankie/blog/issues/19 Sat, 25 Feb 2023 10:48:46 GMT 配图源自 Freepik

                  背景

                  前段时间苹果 WWDC2021 发布 iOS 15 D]]> 配图源自 Freepik

                  背景

                  前段时间苹果 WWDC2021 发布 iOS 15 Developer Beta(面向开发者测试版本),由于我是尝鲜派又想着这次改动好像很小,Bug 应该不会太多,然后就升级了...

                  还是太天真了,虽然不算什么严重的 Bug,但一些细节性的问题会影响正常使用,最终降级了...

                  由于降级时,选择「保留数据」刷固件的方式一直失败,于是没保存数据就降级了,因此变成了一台新机...

                  后来,有一天发现我的 iPhone(iOS 14.7)和 Mac(Big Sur) 不能接力了。

                  但其实不全是,类似通用剪贴板、iPhone 蜂窝移动网络通话、短信转发、隔空投送等功能还是正常的,

                  但是 Safari 网页或 App 接力功能似乎不正常了,具体表现是 Dock 栏没有提示。

                  正常是长这样的:

                  解决方法

                  以下解决方法不一定都适用于所有失效的情况(仅供参考):

                  • 确保 iPhone 与 Mac 的接力功能打开(相信这不是问题,应该是打开了仍然无效,不然你也看不到这篇文章);

                  • 退出 iCloud 账号,重新登录(最好两个设备都退出重新登录,也可以先退出其中一个设备试试有没效果,我的就是退出两台设备才生效了);

                  • iPhone 和 Mac 的蓝牙请匹配上,这点好像网上很多帖子都没提到的(若此前已匹配过,可双方先忽略设备重新建立连接)。像我降级之后,原先的匹配记录就没有了

                  2022.02.27 更新

                  若类似 Mac 无法接收到 iPhone 的「隔空投送」,而 iPhone 却能接收到 Mac 的「隔空投送」的情况,这时可以尝试将双方的蓝牙关闭,然后重新打开。如果还是不行的话,将对方从蓝牙配对列表删除,然后重新配对一下(最好重启下设备)。

                  希望对你有帮助~

                  The end.

                  ]]>
                  <![CDATA[Alfred Clipboard History 回车自动粘贴失效]]> https://github.com/tofrankie/blog/issues/18 https://github.com/tofrankie/blog/issues/18 Sat, 25 Feb 2023 10:48:18 GMT 背景

                Alfred 是一款系统增强的应用,日常使用里已经将它替代了系统自带的 Spotlight 功能。

                平常用得最多的不是 Web Search 功能,而是 Clipboard History 功能。

                毕竟日常搬砖就是 ⌘ + C、⌘ + V,哈哈...

                <]]>
                背景

                Alfred 是一款系统增强的应用,日常使用里已经将它替代了系统自带的 Spotlight 功能。

                平常用得最多的不是 Web Search 功能,而是 Clipboard History 功能。

                毕竟日常搬砖就是 ⌘ + C、⌘ + V,哈哈...

                然而,今天将 Alfred 更新至最新版本之后,发现 Clipboard History 功能无法通过回车键自动粘贴文本了。

                以下教程基于 Alfred 4.3.3 中文版,macOS 11.3.1。不同的系统或软件版本可能稍有差异。

                排查原因

                1. 打开 Alfred 偏好设置,在「Features — Clipboard History — Advanced」,确保开启了「Auto-Paste on return(回车时自动粘贴)」功能。

                1. 打开「系统偏好设置 — 安全性与隐私 — 隐私」,找到「辅助功能」,参照截图,将 Alfred 勾选上。

                1. 如果无法操作,请先把左下角解锁。
                2. 如果已勾选上仍然无法回车粘贴,先将 Alfred 移除,重新添加(即图中 +- 图标)。
                3. 如果列表里面原本没有 Alfred 选项,手动添加即可。

                我通过移除重新添加的方式解决了,可以继续愉快地玩耍了。

                The end.

                ]]>
                <![CDATA[解决 Charles 抓取 HTTPS 请求 unknown 的问题]]> https://github.com/tofrankie/blog/issues/17 https://github.com/tofrankie/blog/issues/17 Sat, 25 Feb 2023 10:46:20 GMT 配图源自 Freepik

                在 Mac 下使用 Charles 工具进行抓包,然后抓取 HTTPS 请求]]> 配图源自 Freepik

                在 Mac 下使用 Charles 工具进行抓包,然后抓取 HTTPS 请求时,出现 unknown,无法解析的情况如何处理呢?

                如果 App 使用了 SSL Pinning 技术,按本文操作之后有可能还是不行,可看文末引用链接。

                安装证书

                包括电脑端和手机端,这也是抓取 HTTPS 请求的关键所在。

                1. 电脑端

                打开 Charles,然后在菜单栏选择 Help → SSL Proxying → Install Charles Root Certificate,将证书安装至电脑,并打开钥匙串访问

                证书安装后,默认是不被信任的,所以我们需要将其设置为信任。

                钥匙串中找到该证书 Charles Proxy CA,并设置为始终信任,然后保存。

                这样电脑端证书就安装完成了。

                2. 手机端

                下面以 iOS 设备为例,而 Android 端各定制系统安装证书的方式可能略有差异。

                同样在菜单栏选择 Help → SSL Proxying → Install Charles Root Certificate on a Mobile Device or Remote Browser,将会有以下提示。

                请注意,手机与电脑需连接在同一局域网内。

                在手机打开:设置 → Wi-Fi → 打开所连 WiFi → 设置 HTTP 代理 → 选择手动,接着将 IP 地址以及端口填写进去,然后存储即可。

                输入过程中,服务器一栏 . 之间可能会自动插入空格,手动删除一下。

                接着打开系统 Safari 浏览器(其他浏览器可能无法唤起安装证书的弹窗),输入地址 http://chls.pro/ssl 打开页面,会自动唤起安装描述文件的弹窗,选择允许

                如果 http://chls.pro/ssl 加载很久都打不开,尝试 http://charlesproxy.com/getssl

                紧接着,前往:设置 → 通用 → 描述文件 → 选择对应描述文件 → 安装

                还有最重要的一步,很多人就是忽略了该步骤,导致安装完证书后,抓取 HTTPS 请求仍是 unknown。

                前往,设置 → 通用 → 关于本机 → 证书信任设置(滑到屏幕最下面) → 将 Charles 证书勾选上即可。(PS:我截图有两个是证书是两台不同的机器)

                Charles 配置

                Charles 默认是 8888,不占用其他服务端口情况下,不修改问题也不大,根据实际情况自行调整。

                还有一个非常重要的配置是 SSL Proxying Settings,勾选上 Enable SSL Proxying,添加 Include。否则即使添加了证书,抓取 HTTPS 也是 unknown。

                这里根据实际需求来设置 Include 或者 Exclude,我这里设置为 *.*(表示所有域名或者端口)。

                效果

                这样我们就可以愉快地玩耍了

                关于 Android 无法抓包的问题

                由于挺久没有怎么折腾过 Android 手机了,下面这块内容源自网上整理的,也没有时间去实际测试过。

                由于 Android 机型众多,各定制系统差别也不同,安装证书在不同 Android 版本也有限制,导致在使用 Charles 进行抓包时要比 iOS 难很多。

                Android 7.0 之后默认不信任用户添加到系统的 CA 证书:

                To provide a more consistent and more secure experience across the Android ecosystem, beginning with Android Nougat, compatible devices trust only the standardized system CAs maintained in AOSP. Android Developers Blog

                换句话说,就是对基于 SDK24 及以上的 APP 来说,即使你在手机上安装了抓包工具的证书也无法抓取 HTTPS 请求。

                附上一些链接:

                参考链接

                ]]>
                <![CDATA[Sourcetree for Mac 跳过注册]]> https://github.com/tofrankie/blog/issues/16 https://github.com/tofrankie/blog/issues/16 Sat, 25 Feb 2023 10:45:48 GMT
              • 关闭 Sourcetree
              • 执行以下命令 defaults write com.torusknot.SourceTreeNotMAS completedWelcomeWizardVersion 3
              • 重新打开 Sourcetree
              • ]]>
              • 关闭 Sourcetree
              • 执行以下命令 defaults write com.torusknot.SourceTreeNotMAS completedWelcomeWizardVersion 3
              • 重新打开 Sourcetree
              • ]]>
                <![CDATA[zsh compinit: insecure directories, run compaudit for list.]]> https://github.com/tofrankie/blog/issues/15 https://github.com/tofrankie/blog/issues/15 Sat, 25 Feb 2023 10:27:50 GMT 背景

                今天修改完 .zshrc 配置之后,通过 source ~/.zshrc 刷新配置,然后一直存在一个烦人的提示,如下:

                zsh compinit: insecure ]]>
                            背景
                

                今天修改完 .zshrc 配置之后,通过 source ~/.zshrc 刷新配置,然后一直存在一个烦人的提示,如下:

                zsh compinit: insecure directories, run compaudit for list.
                Ignore insecure directories and continue [y] or abort compinit [n]?
                

                搜了一番,很多人的解决方法都没用,直到看到这篇文章

                执行命令 compaudit

                $ compaudit
                
                # There are insecure directories:
                # /usr/local/share/zsh/site-functions
                # /usr/local/share/zsh
                

                解决方法

                执行如下命令修改权限

                $ cd /usr/local/share/zsh
                $ sudo chmod -R 755 site-functions
                

                再次执行 source ~/.zshrc,如果问题还未解决,这时你就需要修改 site-functions 的所有者。

                OSX 10.9 以上系统执行如下命令(user:staff 是 OSX 系统默认权限):

                $ cd /usr/local/share/
                $ sudo chmod -R 755 zsh
                $ sudo chown -R root:staff zsh
                

                OSX 10.9 及以下系统执行如下命令:

                $ cd /usr/local/share/
                $ sudo chown -R root:root site-functions
                

                再次执行 source ~/.zshrc 即可!

                ]]>
                <![CDATA[Homebrew 卡在 Updating Homebrew...]]> https://github.com/tofrankie/blog/issues/14 https://github.com/tofrankie/blog/issues/14 Sat, 25 Feb 2023 10:23:01 GMT

                本文已过时,请看 Homebrew 使用详解

            原因

            每次安装包卡在 Updating Homebrew... 的]]>

            本文已过时,请看 Homebrew 使用详解

          原因

          每次安装包卡在 Updating Homebrew... 的问题,原因是 Homebrew 每次安装包的时候默认开启了自动更新的设置。可通过配置关闭掉。

          # 打开 .zshrc
          $ vim ~/.zshrc
          
          # 末尾添加一行配置
          export HOMEBREW_NO_AUTO_UPDATE=true
          
          # 刷新环境变量
          $ source ~/.zshrc
          

          不嫌麻烦可以每次都用 ⌃ + C 跳过。

          解决安装依赖慢的问题

          具体思路是替换 Homebrew 镜像源。

          1. 替换 brew.git
          $ cd "$(brew --repo)"
          $ git remote set-url origin https://mirrors.ustc.edu.cn/brew.git
          
          1. 替换 homebrew-core.git
          $ cd "$(brew --repo)/Library/Taps/homebrew/homebrew-core"
          $ git remote set-url origin https://mirrors.ustc.edu.cn/homebrew-core.git
          
          1. 重置 brew.git
          $ cd "$(brew --repo)"
          $ git remote set-url origin https://github.com/Homebrew/brew.git
          
          1. 重置 homebrew-core.git
          $ cd "$(brew --repo)/Library/Taps/homebrew/homebrew-core"
          $ git remote set-url origin https://github.com/Homebrew/homebrew-core.git
          

          好吧,替换之后,我还是觉得慢。

          ]]>
          <![CDATA[解决 XtraFinder、TotalFinder 无法安装的问题]]> https://github.com/tofrankie/blog/issues/13 https://github.com/tofrankie/blog/issues/13 Sat, 25 Feb 2023 10:21:31 GMT 配图源自 Freepik

          很早之前,macOS 就引入了 SIP(System Integrity Pr]]> 配图源自 Freepik

          很早之前,macOS 就引入了 SIP(System Integrity Protection)机制,使得一些未经过 Apple 认证的第三方应用无法使用。要使用 XtraFinder 这类未经官方认证的插件,就只能关闭 SIP 功能了。

          此前,因为 Mac App Store 无法下载软件,参照官方技术人员的方法去试着《重置 Mac 上的 NVRAM 或者 PRAM》,导致 SIP 机制被重置了,原本的 XtraFinder 和 TotalFinder 两个系统增强插件打不开了。

          步骤

          1. 关机,进入恢复模式(如何从“macOS 恢复”启动

            1. Intel Mac:按住 ⌘ + R 直到出现 Apple 标志再松开。
            2. ARM Mac:长按电源键直到出现选项图标,松开,选择选项进入。
          2. 菜单栏选择 Terminal 执行 csrutil disable 以关闭 SIP。

          3. 重启。

          若要重新启用 SIP,执行 csrutil enable 重启即可。

          使用记录

          • 最新版本的 XtraFinder 已支持 macOS Monterey,比较遗憾的是风格与新版 Finder 仍然不搭。

          • 目前 XtraFinder v1.8TotalFinder v1.15.1 已支持 macOS Ventura。

          • 目前 XtraFinder v1.8 不兼容 macOS Sonoma,会导致无法直接通过点击 Finder 图标打开应用。

          • 目前 XtraFinder v1.9 已支持 macOS Sonoma、macOS Sequoia,但似乎点击 Finder 图标无法打开的问题仍存在。

          参考链接

          ]]>
          <![CDATA[Mac 系统隐藏的超美桌面壁纸]]> https://github.com/tofrankie/blog/issues/12 https://github.com/tofrankie/blog/issues/12 Sat, 25 Feb 2023 10:15:39 GMT

          默认壁纸存放于 /Library/Desktop Pictures 目录。<]]>

          默认壁纸存放于 /Library/Desktop Pictures 目录。

          除此之外,还隐藏了很多精美的壁纸,存放于 /Library/Screen Savers/Default Collections 目录。

          • National Geographic(国家地理杂志)
          • Aerial(空中视角)
          • Cosmos(宇宙)
          • Nature Patterns(大自然)

          可在系统偏好设置「桌面与屏幕保护程序」添加对应目录路径。

          ]]>
          <![CDATA[macOS QuickLook 常用插件]]> https://github.com/tofrankie/blog/issues/11 https://github.com/tofrankie/blog/issues/11 Sat, 25 Feb 2023 10:09:47 GMT

          [!WARNING] 随着 macOS 系统权限越来越严格,很多库年久失修]]>

          [!WARNING] 随着 macOS 系统权限越来越严格,很多库年久失修,部分已不可用。

          安装

          手动安装的话,将下载的 .qlgenerator 文件移动至 ~/Library/QuickLook 目录,启动终端执行 qlmanage -r 以重启 QuickLook。

          有些插件可以通过 Homebrew 安装。

          $ brew install <package>
          

          查看已安装的 QuickLook 插件:

          $ qlmanage -m
          

          [!NOTE] 如安装后未生效,可作如下尝试:

          1. 若首次使用,且为 GUI 应用,在启动台打开一次应用。
          2. 终端执行 qlmanage -r 命令以重新加载 QuickLook。

          常用插件

          QLColorCode

          支持预览语法高亮。

          $ brew install qlcolorcode --cask
          

          qlImageSize

          该项目已停止维护,详见 Nyx0uf/qlImageSize #45

          macOS Mojave 后分辨率不显示

          macOS Catalina 后彻底不可用

          支持预览图片,显示图像大小和分辨率

          $ brew install qlimagesize
          

          QLMarkdown

          预览 Markdown 文件

          $ brew install qlmarkdown --cask
          

          QuickLookJSON

          预览格式化的 JSON 文件

          $ brew install quicklook-json --cask
          

          QLStephen

          预览无扩展名的纯文本文件

          $ brew install qlstephen --cask
          

          BetterZip

          预览 Zip 压缩文件的信息和目录

          $ brew install betterzip --cask
          

          WebPQuickLook

          预览 WebP 图像

          $ brew install webpquicklook --cask
          

          Suspicious Package

          预览 Mac 标准的安装包

          $ brew install suspicious-package --cask
          

          QuickLook Video

          预览 .mkv.avi 等非原生支持的视频格式

          $ brew install qlvideo --cask
          

          PeovisionQL

          预览 iOS 的 .ipa 安装包

          $ brew install provisionql --cask
          

          QuickLookAPK

          已停止维护,但仍可用

          预览 Android 的 .apk 安装包

          $ brew install quicklookapk --cask
          

          quicklook-pat

          年久失修

          预览 PS Pattern 素材

          $ brew install quicklook-pat --cask
          

          QuickLookASE

          年久失修

          预览由 PS、AI 等生成的 Adobe ASE 色板

          $ brew install quicklookase --cask
          

          更多

          收集了一些常用的 QuickLook 插件,可从百度网盘获取。

          下载后放置于 ~/Library/QuickLook 文件夹内即可(前往文件夹快捷键 ⌘ + ⇧ + G)。

          更多插件可以前往 QuickLookPlugins 寻找。(年久失修)

          References

          ]]> <![CDATA[macOS 微信客户端多开和防撤回]]> https://github.com/tofrankie/blog/issues/10 https://github.com/tofrankie/blog/issues/10 Sat, 25 Feb 2023 10:08:12 GMT 微信 macOS 客户端增强 Tweak 动态库,支持多账户登录防撤回等。

          功能

          • 阻止消息撤回
            • 消息列表通知
            • 系统通知
            • 正常撤回自己发出的消息 微信 macOS 客户端增强 Tweak 动态库,支持多账户登录防撤回等。

              功能

              • 阻止消息撤回
                • 消息列表通知
                • 系统通知
                • 正常撤回自己发出的消息
              • 客户端无限多开
                • 右键 Dock icon 登录新的微信账号
                • 命令行执行:open -n /Applications/WeChat.app
              • 消息处理增强
                • 支持任意表情导出
                • 支持二维码识别
                • 支持右键直接复制链接
                • 支持由系统默认浏览器直接打开
              • 重新打开应用无需手机认证(官方已经支持)
              • UI 界面设置面板
              • 支持 Raycast extension
              • 支持 Alfred workflow
              • 支持 Launchbar action

              安装

              首次使用需安装 WeChatTweak-CLI

              $ brew install sunnyyoung/repo/wechattweak-cli
              
              $ sudo wechattweak-cli install   # 安装/更新
              $ sudo wechattweak-cli uninstall # 卸载
              

              微信多开

              方式有二:

              1. Dock 栏微信图标右键登录新的微信账号
              2. 终端执行以下命令
              $ open -n /Applications/WeChat.app
              

              参考链接

              ]]>
              <![CDATA[Homebrew 使用详解]]> https://github.com/tofrankie/blog/issues/9 https://github.com/tofrankie/blog/issues/9 Sat, 25 Feb 2023 10:03:46 GMT 配图源自 Freepik

              Homebrew 是什么?

              Homebrew 是什么?

              Homebrew 是 macOS 和 Linux 上非常流行的开源包管理器,可以理解为一个命令行版本的应用商店。它是相对安全的,如果你知道自己正在下载什么。起码目前 Homebrew 上不存在恶意包(All Formulae)。

              Homebrew complements macOS (or your Linux system).

              术语

              讲真的,Homebrew 术语有点羞涩难懂,本身有自制酿酒之意,诸如 Formula、Cask 等也是与酿酒相关的。

              术语 意译 说明
              Formula 配方 表示安装包的描述文件。复数为 formulae。
              Cask 木桶 装酒的器具,表示具有 GUI 界面的原生应用。
              Keg 小桶 表示某个包某个版本的安装目录,比如 /usr/local/Cellar/foo/0.1
              Cellar 地窖 存放酒的地方,表示包的安装目录,比如 /usr/local/Cellar
              Caskroom 木桶间 表示类型为 Cask 的包的安装目录,比如:/usr/local/Caskroom
              Tap 水龙头 表示包的来源,也就是镜像源。
              Bottle 瓶子 表示预先编译好的包,下载好直接使用。

              Related Link: Homebrew Terminology, Simplifying Homebrew terminology.

              组成

              Homebrew 由以下几部分组成。

              名称 说明
              brew Homebrew 源代码仓库
              homebrew-core Homebrew Core 仓库
              homebrew-cask Homebrew Cask 仓库,提供 macOS 应用和大型二进制文件的安装
              homebrew-bottles Homebrew 预编译二进制软件包与软件包元数据文件
              homebrew-cask-versions Homebrew Cask 其他版本 (alternative versions) 软件仓库,提供使用人数多的、需要的版本不在 Cask 仓库中的应用。
              homebrew-services 与 brew services 有关的文件,用于在 macOS (launchctl) 与 Linux (systemctl) 上管理 brew 安装的服务。

              Homebrew 安装

              本文以 macOS 为例。

              复制以下命令,粘贴到「终端」应用回车执行,等待完成即可。

              $ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
              

              如果没有 🪜 下载很慢的话,可以使用国内的安装脚本。

              $ /bin/bash -c "$(curl -fsSL https://mirrors.ustc.edu.cn/misc/brew-install.sh)"
              

              如果此前没有安装过 Xcode Command Line Tools,上述过程会提示安装,按回车耐心等待安装完成。

              Related link: macOS Requirements.

              ==> The Xcode Command Line Tools will be installed.
              
              Press RETURN/ENTER to continue or any other key to abort:
              

              也可先自行安装 Xcode Command Line Tools,再安装 Homebrew。

              $ xcode-select --install
              

              完成后可使用 brew -v 命令,若有输出版本号,表示已安装成功,可忽略以下环境变量配置步骤。

              $ brew -v
              Homebrew 4.1.22
              Homebrew/homebrew-core (git revision a58688396f3; last commit 2023-12-02)
              

              如果出现如下问题,则需要配置环境变量。

              $ brew -v
              brew:command not found
              

              执行 which $SHELL 确认你的 macOS 的默认 Shell 是哪个?

              $ which $SHELL
              /bin/zsh
              

              通常是 zshbash,对应的配置文件为 ~/.zshrc~/.bash_profile

              从 macOS Catalina 开始,Mac 将使用 zsh 作为默认登录 Shell 和交互式 Shell,详见

              下文以 zsh 为例,如果你使用其他 Shell,涉及到 .zshrc 请自行调整为你的配置文件名称。

              # 添加环境变量至 .zshrc
              $ echo 'export PATH="/usr/local/bin:/usr/local/sbin:/opt/homebrew/bin:/opt/homebrew/sbin:$PATH"' >> ~/.zshrc
              
              # 刷新环境变量
              $ source ~/.zshrc
              

              完了之后,再执行 brew -v 应该就能正常输出版本号了。

              Homebrew 升级指南

              如果首次安装,可忽略本节内容。

              自 4.0 起,有一些变化:

              • 默认使用从 Formulae.brew.sh 下载的 JSON 文件进行包的安装,而不是本地 homebrew/corehomebrew/cask 源。可以考虑使用 brew untap homebrew/corebrew untap homebrew/cask 来节省一些空间(除非你正在开发包)。

              • 可以考虑将 HOMEBREW_NO_INSTALL_FROM_API 移除(如果设置过)。

              • 可以考虑将 HOMEBREW_API_DOMAIN 设为指定镜像源(如果使用新的安装方式)。

              • 自动运行 brew update 的频率由 5min 改为 24h。可以重新考虑是否需要设置 HOMEBREW_NO_AUTO_UPDATEHOMEBREW_AUTO_UPDATE_SECS 了(如果设置过)。

              如果想安装了 4.0 及更新版本,又想沿用以前的安装方式,可以设置环境变量:

              $ echo 'export HOMEBREW_NO_INSTALL_FROM_API=1' >> ~/.zshrc
              

              如果你使用 3.3.0 ~ 3.6.0 之间的版本,想要使用全新的 JSON API 来安装包,可以设置环境变量:

              $ echo 'export HOMEBREW_INSTALL_FROM_API=1' >> ~/.zshrc
              

              自 4.0 起为默认行为,无需设置。

              Related Link:

              Homebrew 源切换

              安装后,如果使用 brew installbrew upgradebrew update 较慢,可以考虑切换为国内的镜像源,比如:

              以 USTC 镜像源为例。

              如果使用 4.x 最新的 JSON API 安装方式(推荐),添加以下环境变量配置:

              $ echo '
              export HOMEBREW_BREW_GIT_REMOTE="https://mirrors.ustc.edu.cn/brew.git"
              export HOMEBREW_API_DOMAIN="https://mirrors.ustc.edu.cn/homebrew-bottles/api"
              export HOMEBREW_BOTTLE_DOMAIN="https://mirrors.ustc.edu.cn/homebrew-bottles/bottles"
              ' >> ~/.zshrc
              

              如果使用 4.0 之前版本,或者使用 4.x 但又想用此前的安装方式,添加以下环境变量配置:

              $ echo '
              export HOMEBREW_NO_INSTALL_FROM_API=1
              export HOMEBREW_BREW_GIT_REMOTE="https://mirrors.ustc.edu.cn/brew.git"
              export HOMEBREW_API_DOMAIN="https://mirrors.ustc.edu.cn/homebrew-bottles/api"
              export HOMEBREW_BOTTLE_DOMAIN="https://mirrors.ustc.edu.cn/homebrew-bottles/bottles"
              export HOMEBREW_CORE_GIT_REMOTE="https://mirrors.ustc.edu.cn/homebrew-core.git"
              export HOMEBREW_CASK_GIT_REMOTE="https://mirrors.ustc.edu.cn/homebrew-cask.git"
              '>> ~/.zshrc
              

              若要重置为官方镜像源,移除以上环境变量即可。

              另外,如不希望 Homebrew 收集匿名数据,可以通过设置环境变量关闭:

              $ echo 'export HOMEBREW_NO_ANALYTICS=1' >> ~/.zshrc
              

              Related Link: Anonymous Analytics.

              Homebrew 自动更新

              默认情况下,在执行 brew installbrew upgradebrew tap 之前,每隔第一段时间会自动执行 brew update 以获取最新的 Homebrew 版本。

              在 4.0 起自动执行频率为 24h,如果开启了 HOMEBREW_NO_INSTALL_FROM_API=1 频率为 5min。可通过以下环境变量完全禁用、设置时间间隔。

              $ echo '
              export HOMEBREW_NO_AUTO_UPDATE=1
              export HOMEBREW_AUTO_UPDATE_SECS=86400
              ' >> ~/.zshrc
              

              这就是每次安装/更新包时,先出现 Downloading https://formulae.brew.sh/api/formula.jws.json 的原因。这个 JSON 文件有 4M 多,如果加上默认的镜像源,不慢才怪。

              Homebrew 相关路径

              相关命令

              # 显示 Homebrew 本地的 Git 仓库
              $ brew --repo
              
              # 显示 Homebrew 安装路径
              $ brew --prefix
              
              # 显示 Homebrew Cellar 路径
              $ brew --cellar
              
              # 显示 Homebrew Caskroom 路径
              $ brew --caskroom
              
              # 缓存路径
              $ brew --cache
              

              Homebrew 默认安装路径如下:

              • macOS ARM: /opt/homebrew
              • macOS Intel: /usr/local

              Related Link: Discussion: longterm Homebrew prefix on Apple Silicon Macs

              brew install git 为例:

              1. Homebrew 将 git 下载至 /usr/local/Cellar/git/<version>/ 目录下,其二进制文件在 /usr/local/Cellar/git/<version>/bin/git

              2. Homebrew 为 /usr/local/Cellar/git/<version>/bin/git 创建了一个软链文件至 /usr/local/bin 里。

              macOS ARM 的路径对应是:

              • /opt/homebrew/Cellar/git/<version>/
              • /opt/homebrew/Cellar/git/<version>/bin/git
              • /opt/homebrew/bin

              这也是 macOS ARM 要将 /opt/homebrew/bin 添加到 PATH 环境变量的原因。

              当执行 brew uninstall 时,会将 /usr/local/Cellar 下对应包目录删除,对应的链接关系也会移除。 当执行 brew cleanup 时,会将 /usr/local/Cellar 所有包里的旧版本,只保留最新版本。

              Homebrew Cask 的区别

              可以简单地将 Homebrew 的包分为命令行工具、GUI 应用两类。

              $ brew install <package-name>    # 安装
              $ brew uninstall <package-name>  # 卸载
              $ brew reinstall <package-name>  # 重装
              

              如安装的是 GUI 应用,加上 --cask 参数。比如 brew install docker --cask

              如需强制卸载,加上 --force 参数。

              使用 brew search 命令可以看到「Formulae」和「Casks」两类:

              • Formulae:一般是那些命令行工具、开发库、字体、插件等不含 GUI 界面的软件。
              • Casks:是指那些含有 GUI 图形化界面的软件,比如 Chrome、FireFox 等。
              $ brew search google
              
              ==> Formulae
              aws-google-auth                          google-sparsehash
              google-authenticator-libpam              google-sql-tool
              google-benchmark                         googler
              google-go                                googletest
              google-java-format
              
              ==> Casks
              google-ads-editor
              google-analytics-opt-out
              google-backup-and-sync
              ...
              

              Related Link: Homebrew Cask

              Homebrew 常用命令

              检查

              用于检查 Homebrew 当前配置是否合理,或者某些包存在的问题等。

              $ brew doctor
              

              搜索

              支持模糊搜索。

              $ brew search <keyword>
              

              更新包

              $ brew upgrade                  # 更新所有已安装的包
              $ brew upgrade <package-name>   # 更新指定包
              

              列出已安装的包

              $ brew list                     # 所有的软件,包括 Formulae  和 Cask
              $ brew list --formulae          # 所有已安装的 Formulae
              $ brew list --cask              # 所有已安装的 Casks
              $ brew list <package-name>      # 列举某个 Formulate 或 Cask 的详细路径
              

              列出可更新的包

              $ brew outdated
              

              锁定某个不想更新的包

              $ brew pin <package-name>       # 锁定指定包
              $ brew unpin <package-name>     # 取消锁定指定包
              

              清理旧包

              $ brew cleanup                  # 清理所有旧版本的包
              $ brew cleanup <package-name>   # 清理指定的旧版本包
              $ brew cleanup -n               # 查看可清理的旧版本包
              

              查看已安装包的依赖

              $ brew deps --installed --tree
              

              查看包的信息

              $ brew info <package>           # 显示某个包信息
              $ brew info                     # 显示安装的软件数量、文件数量以及占用空间
              

              参考链接

              ]]>
              <![CDATA[Mac Terminal 打开 URL]]> https://github.com/tofrankie/blog/issues/8 https://github.com/tofrankie/blog/issues/8 Sat, 25 Feb 2023 09:57:31 GMT 配图源自 Freepik

              有时候需要在终端应用中直接打开 URL,可使用以下快捷键。

              配图源自 Freepik

              有时候需要在终端应用中直接打开 URL,可使用以下快捷键。

              操作 快捷键
              选择 URL 按住 Shift-Command 键并连按 URL
              打开 URL 按住 Command 键并连按 URL
              选择完整文件路径 按住 Shift-Command 键并连按路径
              选择整行文本 点按该行三下

              更多 Mac 上“终端”中的键盘快捷键

              ]]>
              <![CDATA[手写 instanceof]]> https://github.com/tofrankie/blog/issues/5 https://github.com/tofrankie/blog/issues/5 Sun, 29 May 2022 09:30:21 GMT 配图源自 Freepik

              开始之前,先了解一些基本背景...

              一、前言

              配图源自 Freepik

              开始之前,先了解一些基本背景...

              一、前言

              我们知道,在 ECMAScript 标准中,当前数据类型分为两类(共 8 种):

              • 原始类型(Primitives): 包含 Undefined、Null、Boolean、String、Number、Symbol、BigInt 共 7 种基本数据类型。
              • 引用类型(Objects):除原始类型之外,其余均属于引用类型,归为一大类,比如 ObjectArrayMap 等内置方法及其实例对象。

              其中,原始值都是不可改变的,且不含任何属性或方法。平时看到类似的 'string'.length 写法,本质上是发生了隐式类型转换,先将 'string' 转换为 Object('string'),然后调用 String 实例对象的 length 属性罢了。

              下面,我们将「引用类型」划分为两类:

              函数对象(function object):一个具有 [[Call]] 内部方法(详见)的对象,简单来说,就是可以通过 () 调用的对象,比如内置的 ObjectFunctionArray 等方法。 普通对象(ordinary object):除函数对象,其余引用值均可称为普通对象。

              注意,这里提到的对象泛指引用类型,而不是单指平常所写的 {...} 对象。请记住:

              所有 Function 的实例都是函数对象,而其他的都是普通对象

              前面划分对象,就是为了方便分清楚 prototype(原型对象)和 __proto__ (原型)的区别:

              对象类型 prototype __proto__
              普通对象
              函数对象

              换句话说:

              所有对象都有 __proto__ 属性,而只有函数对象才具有 prototype 属性。

              不作过多介绍,如果对这俩兄弟不太了解的,可看文章数据类型详解

              二、instanceof

              MDN 可知,其语法非常简单:

              object instanceof constructor
              

              用于检测 constructor.prototype 是否存在于参数 object 的原型链上。

              要自实现 instanceof,就要了解这些特性:

              • object 必须是引用值,否则将会返回 false
              • constructor 必须是函数对象,否则会抛出 TypeError。

              需要注意的是:不同上下文(比如网页中多个 <iframe>)之间拥有不同的全局对象,可理解为不同的引用地址,因此会出现如下情况:

              const iframe = document.createElement('iframe')
              document.body.appendChild(iframe)
              
              const xArray = window.frames[window.frames.length - 1].Array
              const xarr = new xArray()
              const arr = new Array()
              
              console.log(xarr instanceof Array) // false
              console.log(xarr.constructor === Array) // false
              
              console.log(arr instanceof Array) // true
              console.log(arr.constructor === Array) // true
              

              因此,使用 instanceof 来判断是否为数组是不准确的,可看文章

              三、实现

              获取原型对象的方法:

              // 构造函数访问 prototype 属性
              constructor.prototype
              
              // 实例对象访问 __proto__ 属性
              instance.__proto__
              // __proto__ 非 ECMAScript 标准,只是被所有浏览器支持罢了
              // 可使用标准中的 Object.getPrototypeOf() 方法替换
              

              实现如下:

              function myInstanceof(inst, ctor) {
                // 是否为函数对象
                const isCallable = val => typeof val === 'function'
              
                // 是否为引用值
                const isObject = val => typeof val === 'function' || (val !== null && typeof val === 'object')
              
                // ctor 必须是引用值
                if (!isObject(ctor)) throw new TypeError(`Right-hand side of 'instanceof' is not an object`)
              
                // ctor 必须是函数对象
                if (!isCallable(ctor)) throw new TypeError(`Right-hand side of 'instanceof' is not callable`)
              
                // inst 为原始值,则返回 false
                if (!isObject(inst)) return false
              
                do {
                  const proto = inst.__proto__ // 可换成标准方法 const proto = Object.getPrototypeOf(inst)
                  // 原型链顶端(proto 为 null)或者 inst 通过 Object.create(null) 构造(proto 为 undefined)
                  if (proto == null) return false
                  if (proto === ctor.prototype) return true
                  inst = proto // 往上一级查找
                } while (true)
              }
              

              The end.

              ]]>
              <![CDATA[手写 Object.create()]]> https://github.com/tofrankie/blog/issues/4 https://github.com/tofrankie/blog/issues/4 Sat, 26 Mar 2022 14:53:02 GMT 配图源自 Freepik

              Object.create(proto, propertiesO]]> 配图源自 Freepik

              Object.create(proto, propertiesObject) 方法会创建一个带着指定原型对象和属性的「新对象」。

              实现起来很简单,主要注意参数的边界值即可:

              • proto - 仅接受原始值 null 或任意引用值,否则抛出 TypeError
              • propertiesObject - 可选参数,与 Object.defineProperties() 第二参数相同。若 propertiesObject 不为 undefined 时,该参数「本身可枚举属性」(不包括原型链上的枚举属性)将会作为新创建对象属性值和对应的属性描述符。

              实现如下:

              Object.prototype.myCreate = function (proto, propertiesObject) {
                // 只允许 null 或引用值
                if (typeof proto !== 'object') {
                  throw new TypeError('Object prototype may only be an Object or null: ' + proto)
                }
              
                // 直接使用字面量,无需构造函数
                var _obj = {}
                _obj.__proto__ = proto // 相当于 ES6 中的 Object.setPrototypeOf(_obj, proto)
              
                if (propertiesObject !== undefined) {
                  Object.defineProperties(_obj, propertiesObject)
                }
              
                return _obj
              }
              

              如果你对里面 if (propertiesObject !== undefined) {} 存疑,不妨亲自试下这些用例:

              Object.create({}, 0)
              Object.create({}, '')
              Object.create({}, false)
              Object.create({}, undefined)
              Object.create({}, null) // TypeError
              

              如果 propertiesObject 参数传入原始值,执行到 Object.defineProperties() 时,会先类型转换为引用值(即 Object(propertiesObject)),我们知道 undefinednull 是无法转换为引用类型的,因此会抛出 TypeError。但由于 Object.create() 的第二个参数是允许指定为 undefined 的,因此需要特殊处理。

              The end.

              ]]>
              <![CDATA[手写 call、apply、bind]]> https://github.com/tofrankie/blog/issues/3 https://github.com/tofrankie/blog/issues/3 Fri, 29 Oct 2021 18:26:29 GMT 配图源自 Freepik

              这三个货,都是用来绑定 this 的。区别如下:<]]> 配图源自 Freepik

              这三个货,都是用来绑定 this 的。区别如下:

              • call()apply() 都返回函数执行结果,区别在于参数不一样,前者接收参数列表,后者接收一个数组参数(也可以是类数组)。
              • bind() 返回一个新函数,但注意对使用 new 关键字调用时,绑定无效。其使用方法与 call() 一致,接受一个参数列表。

              一、call 实现

              假设没有 Function.prototype.call() 方法,我们利用 this 默认绑定的特性处理即可。

              Function.prototype.myCall() {
                // 获取执行函数
                const fn = this
              
                // 获取绑定对象,及参数列表
                const [context, ...args] = arguments
              
                // 避免与绑定对象属性冲突,采用 Symbol 值作为属性键,并将执行函数赋予该属性。
                const key = Symbol()
                context[key] = fn
              
                // 执行函数并返回结果
                const res = context[key](...args)
              
                // 移除临时属性
                delete context[key]
              
                // 返回结果
                return res
              }
              

              还没完,请注意以下两种情况:

              • contextundefinednull 的情况。若处于非严格模式,context 应指向顶层对象 globalThis,在浏览器为 window 对象。
              • context 为其他原始值(不是 undefinednull),应该要将其转换为对应的引用值。
              Function.prototype.myCall = function () {
                const fn = this
                const [ctx, ...args] = arguments
                const context = ctx == undefined ? window : Object(ctx)
              
                const key = Symbol()
                context[key] = fn
              
                const res = context[key](...args)
                delete context[key]
              
                return res
              }
              

              再简化一下:

              Function.prototype.myCall = function (ctx, ...args) {
                const key = Symbol()
                const context = ctx == undefined ? window : Object(ctx)
              
                context[key] = this
                const res = context[key](...args)
                delete context[key]
              
                return res
              }
              

              二、apply 实现

              我们知道,Function.prototype.apply()Function.prototype.call() 的区别仅在接收参数的形式不同,前者接收一个数组。

              基于上面的实现,简单修改下函数执行的参数即可。

              Function.prototype.myApply = function (ctx, ...args) {
                const key = Symbol()
                const context = ctx == undefined ? window : Object(ctx)
              
                context[key] = this
                const res = context[key](args) // 与 call 方法的区别点
                delete context[key]
              
                return res
              }
              

              三、bind 实现

              我们知道,Function.prototype.bind() 参数形式与 Function.prototype.call() 相同,区别在于 bind() 返回一个绑定了 this 的新函数。

              其实,我们可以很快就写出以下方法:

              Function.prototype.myBind = function (ctx, ...args) {
                const fn = this
                return function (...newArgs) {
                  return fn.apply(ctx, [...args, ...newArgs])
                }
              }
              

              但是,这是不完全正确的...

              需要注意的是,bind() 方法返回的新函数,若通过 new 关键字进行调用,那么 this 绑定则不生效。

              举个例子:

              const person = {
                name: 'Frankie'
              }
              
              function Foo(name) {
                this.name = name
              }
              
              const Bar = Foo.bind(person)
              const bar = new Bar('Mandy')
              
              console.log(person.name) // "Frankie"
              console.log(bar.name) // "Mandy"
              

              假设 Foo.bind(person) 生效的话,那么 new Bar('Mandy')this.name 应该是 person.name = 'Mandy',修改的应该是 person 对象的 name 属性,但事实并非如此。

              顺道总结一下 this 绑定的优先级:

              1. 只要使用 new 关键字调用,无论是否含有 bind 绑定,this 总指向实例化对象。
              2. 通过 callapply 或者 bind 显式绑定,this 指向该绑定对象。若第一个参数缺省时,则根据是否为严格模式,来确定 this 指向全局对象或者 undefined
              3. 函数通过上下文对象调用,this 指向(最后)调用它的对象。
              4. 如以上均没有,则会默认绑定。严格模式下,this 指向 undefined,否则指向全局对象。

              因此,我们来完善一下 myBind() 方法:

              Function.prototype.myBind = function (ctx, ...args) {
                const fn = this
                return function newFn(...newArgs) {
                  // 若通过 new 关键字调用,有几种方式可以判断:
                  // 1. this instanceof newFn
                  // 2. this.__proto__.constructor === newFn
                  // 3. new.target 不为 undefined
                  if (new.target) {
                    return new newFn(...args, ...newArgs)
                  }
              
                  return fn.apply(ctx, [...args, ...newArgs])
                }
              }
              

              去掉注释,就长这样:

              Function.prototype.myBind = function (ctx, ...args) {
                const fn = this
                return function newFn(...newArgs) {
                  if (new.target) {
                    return new newFn(...args, ...newArgs)
                  }
              
                  return fn.apply(ctx, [...args, ...newArgs])
                }
              }
              

              总的来说,其实并不能,真正了解 this 原理,要手写这几个常考的面试题,其实很简单哈。

              插个话,我认为对于初学者来说,千万不要把作用域(链)和 this 混为一谈,其实它们完全就是两回事。作用域(链)与闭包相关,它在函数被定义时就已“确定”,不会再变了。而 this 则与函数调用相关。

              参考链接

              ]]>
              <![CDATA[手写深拷贝]]> https://github.com/tofrankie/blog/issues/2 https://github.com/tofrankie/blog/issues/2 Fri, 29 Oct 2021 06:38:34 GMT 配图源自 Feepik

              本文后续不再更新,请移步《

              本文后续不再更新,请移步《超详细的 JavaScript 深拷贝实现》。

              一、为什么需要拷贝?

              在 JavaScript 中,分为基本数据类型引用数据类型两类。

              基本数据类型(Primitives,原始类型)
                Undefined
                Null
                Boolean
                String
                Number
                Symbol
                BigInt
              
              引用数据类型(Objects,引用类型)
                Object(包括基于 Object 构造器的派生对象,如 Object、Array、Function、Date、Map 等等一系列的内置对象)
              

              请注意:

              • 原始类型的值称为“原始值”,比如 Boolean 类型的原始值只有 true 和 false 两个。同理,引用类型的值被称为“引用值”。
              • 所有原始值(Primitive Value)都是不可变的(immutable),而且不含任何的属性和方法。

              示例:

              // 情况一:只是将一个新值 true 赋予变量 foo,而不是 20 被修改了。
              let foo = 20
              foo = true
              
              // 情况二:变量 foo 的值,始终没有改变。
              let foo = 'foo'
              foo.toUpperCase() // "FOO"
              foo // "foo"
              
              // 情况三:可以说明将“原始值” num 传入函数 fn,只是将 num 的值拷贝了一份,然后赋予形参 n。并未影响到全局的变量 num。(注意这里指的是原始值,而非引用值)
              let num = 1
              function fn(n) {
                n++
                console.log(n)
              }
              fn(num) // 2
              num // 1
              
              // 情况四:只是 JS 内部自动隐式类型转换了,当一个原始值访问属性或方法时,
              // 会将其转换为对应的引用数据类型,再访问该引用值的属性或方法。(就是常说的包装类)
              let foo = 'foo'
              foo.length // 3,相当于 Object(foo).length 或 new String(foo).length
              foo.toUpperCase() // "FOO"
              

              以上都发生了“拷贝”,只是原始值的拷贝是另外创建一个副本,而原本的值与副本是完全独立,互不干扰的。但如果是“引用值”,那就麻烦了。

              const foo = { name: 'Frankie' }
              const bar = foo
              
              bar.name = 'Mandy'
              foo.name // "Mandy",修改变量 bar 的时候,foo 的值也发生改变了。
              
              foo === bar // true,由始至终变量 foo 和 bar 都指向同一个地址。
              

              这其实也是拷贝,只不过引用值拷贝的是“地址”(也有人说成“指针”,不重要)。因此,通常我们说的“拷贝”是指引用值的拷贝(或称为复制)。

              • 浅拷贝

                • Array.prototype.slice()
                • Array.prototype.concat()
                • Object.assign()
                • 利用 ... 扩展运算符
                • 自实现
              • 深拷贝

                • JSON.stringify()JSON.parse() 结合
                • $.extend()$.clone()(JQuery 库)
                • _.cloneDeep()(Lodash 库)

              区别你们都知道的。

              二、JSON.stringify() 的缺陷

              利用内置的 JSON 静态方法,可以实现简易的深拷贝:

              const obj = {
                // ...
              }
              JSON.parse(JSON.stringify(obj)) // 序列化与反序列化
              

              它可以满足大部分应用场景,毕竟很少去拷贝函数之类的。

              JSON.stringify(value[, replacer[, space]])
              

              简单总结:

              • 布尔值、数值、字符串对应的包装对象,在序列化过程会自动转换成其原始值。

              • undefined、任意函数、Symbol 值,在序列化过程有两种不同的情况。

                • 若出现在非数组对象的属性值中,会被忽略。
                • 若出现在数组中,会转换成 null
              • 任意函数undefined 被单独转换时,会返回 undefined

              • 所有以 Symbol 为属性键的属性都会被忽略,即便在第二个参数 replacer 中指定了该属性。

              • Date 调用了其内置的 toJSON() 方法转换成字符串,因此会被当初字符串处理。

              • NaNInfinity 的数值及 null 都会当做 null

              • 这些对象 MapSetWeakMapWeakSet 仅会序列化可枚举的属性。

              • 被转换值如果含有 toJSON() 方法,该方法定义什么值将被序列化。

              • 对包含循环引用的对象进行序列化,会抛出错误。

              从命名来看,我认为它们只是方便我们操作符合 JSON 格式的 JavaScript 对象或符合 JSON 格式的字符串。

              JSON 是一种数据格式,也可以说是一种规范。JSON 是用于跨平台数据交流的,独立于语言和平台。而 JavaScript 对象是一个实例,存在于内存中。JavaScript 对象是没办法传输的,只有在被序列化为 JSON 字符串后才能传输。

              它只是恰好能满足一些简单的深拷贝需求而已。

              三、边界条件

              其实实现一个较为完整的深拷贝,要处理很多边界条件。比如:

              • 循环引用
              • 包装对象
              • 函数
              • 原型链
              • 不可枚举属性
              • Map/WeakMap、Set/WeakSet
              • RegExp
              • Symbol
              • Date
              • ArrayBuffer
              • 原生 DOM/BOM 对象
              • ...

              至于要不要考虑那么多边界条件,视实际需求而定。

              目前,最完善的深拷贝方法应该是 Lodash 的 _.cloneDeep() 方法。实际项目中,如需处理 JSON.stringify() 无法解决的 Case,我会推荐使用它

              本文旨在学习,以上边界条件都会尽可能兼顾到。这样,无论日后实现特殊的深拷贝,还是面试,都可以从容应对。

              四、实现

              以下将会逐步实现,完整示例放在文末。

              使用递归思路实现。先写一个简易版本:

              const deepCopy = source => {
                // 判断是否为数组
                const isArray = arr => Object.prototype.toString.call(arr) === '[object Array]'
              
                // 判断是否为引用类型
                const isObject = obj => obj !== null && (typeof obj === 'object' || typeof obj === 'function')
              
                // 拷贝(递归思路)
                const copy = input => {
                  if (typeof input === 'function' || !isObject(input)) return input
              
                  const output = isArray(input) ? [] : {}
                  for (let key in input) {
                    if (input.hasOwnProperty(key)) {
                      const value = input[key]
                      output[key] = copy(value)
                    }
                  }
              
                  return output
                }
              
                return copy(source)
              }
              

              以上简易版本还存在很多情况要特殊处理,接下来针对 JSON.stringify() 的缺陷,一点一点去完善它。

              4.1 针对布尔值、数值、字符串的包装对象的处理

              需要注意的是,从 ES6 开始围绕原始数据类型创建一个显式包装器对象不再被支持。但由于遗留原因,现有的原始包装器对象(如 new Booleannew Numbernew String)仍可使用。这也是 ES6+ 新增的 SymbolBigInt 数据类型无法通过 new 关键字创建实例对象的原因。

              由于 for...in 无法遍历不可枚举的属性。例如,包装对象的 [[PrimitiveValue]] 内部属性,因此需要我们特殊处理一下。

              以上结果,显然不是预期结果。包装对象的 [[PrimitiveValue]] 属性可通过 valueOf() 方法获取。

              const deepCopy = source => {
                // 获取数据类型(本次新增)
                const getClass = x => Object.prototype.toString.call(x)
              
                // 判断是否为数组
                const isArray = arr => getClass(arr) === '[object Array]'
              
                // 判断是否为引用类型
                const isObject = obj => obj !== null && (typeof obj === 'object' || typeof obj === 'function')
              
                // 判断是否为包装对象(本次新增)
                const isWrapperObject = obj => {
                  const theClass = getClass(obj)
                  const type = /^\[object (.*)\]$/.exec(theClass)[1]
                  return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt'].includes(type)
                }
              
                // 处理包装对象(本次新增)
                const handleWrapperObject = obj => {
                  const type = getClass(obj)
                  switch (type) {
                    case '[object Boolean]':
                      return Object(Boolean.prototype.valueOf.call(obj))
                    case '[object Number]':
                      return Object(Number.prototype.valueOf.call(obj))
                    case '[object String]':
                      return Object(String.prototype.valueOf.call(obj))
                    case '[object Symbol]':
                      return Object(Symbol.prototype.valueOf.call(obj))
                    case '[object BigInt]':
                      return Object(BigInt.prototype.valueOf.call(obj))
                    default:
                      return undefined
                  }
                }
              
                // 拷贝(递归思路)
                const copy = input => {
                  if (typeof input === 'function' || !isObject(input)) return input
              
                  // 处理包装对象(本次新增)
                  if (isWrapperObject(input)) {
                    return handleWrapperObject(input)
                  }
              
                  // 其余部分没变,为了减少篇幅,省略一万字...
                }
              
                return copy(source)
              }
              

              我们在控制台打印一下结果,可以看到是符合预期结果的。

              4.2 针对函数的处理

              直接返回就好了,一般不用处理。在实际应用场景需要拷贝函数太少了...

              const copy = input => {
                if (typeof input === 'function' || !isObject(input)) return input
              }
              

              4.3 针对以 Symbol 值作为属性键的处理

              由于以上 for...in 方法无法遍历 Symbol 的属性键,因此:

              const sym = Symbol('desc')
              const obj = {
                [sym]: 'This is symbol value'
              }
              console.log(deepCopy(obj)) // {},拷贝结果没有 [sym] 属性
              

              这里,我们需要用到两个方法:

              const copy = input => {
                // 其它不变
                for (let key in input) {
                  // ...
                }
              
                // 处理以 Symbol 值作为属性键的属性(本次新增)
                const symbolArr = Object.getOwnPropertySymbols(input)
                if (symbolArr.length) {
                  for (let i = 0, len = symbolArr.length; i < len; i++) {
                    if (input.propertyIsEnumerable(symbolArr[i])) {
                      const value = input[symbolArr[i]]
                      output[symbolArr[i]] = copy(value)
                    }
                  }
                }
              
                // ...
              }
              

              下面我们对 source 对象做拷贝操作:

              const source = {}
              const sym1 = Symbol('1')
              const sym2 = Symbol('2')
              Object.defineProperties(source,
                {
                  [sym1]: {
                    value: 'This is symbol value.',
                    enumerable: true
                  },
                  [sym2]: {
                    value: 'This is a non-enumerable property.',
                    enumerable: false
                  }
                }
              )
              

              打印结果,也符合预期结果:

              4.4 针对 Date 对象的处理

              其实,处理 Date 对象,跟上面提到的包装对象的处理是差不多的。暂时先放到 isWrapperObject()handleWrapperObject() 中处理。

              const deepCopy = source => {
                // 其他不变...
              
                // 判断是否为包装对象(本次更新)
                const isWrapperObject = obj => {
                  const theClass = getClass(obj)
                  const type = /^\[object (.*)\]$/.exec(theClass)[1]
                  return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt', 'Date'].includes(type)
                }
              
                // 处理包装对象
                const handleWrapperObject = obj => {
                  const type = getClass(obj)
                  switch (type) {
                    // 其他 case 不变
                    // ...
                    case '[object Date]':
                      return new Date(obj.valueOf()) // new Date(+obj)
                    default:
                      return undefined
                  }
                }
              
                // 其他不变...
              }
              

              4.5 针对 Map、Set 对象的处理

              同样的,暂时先放到 isWrapperObject()handleWrapperObject() 中处理。

              利用 Map、Set 对象的 Iterator 特性和自身的方法,可以快速解决。

              const deepCopy = source => {
                // 其他不变...
              
                // 判断是否为包装对象(本次更新)
                const isWrapperObject = obj => {
                  const theClass = getClass(obj)
                  const type = /^\[object (.*)\]$/.exec(theClass)[1]
                  return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt', 'Date', 'Map', 'Set'].includes(type)
                }
              
                // 处理包装对象
                const handleWrapperObject = obj => {
                  const type = getClass(obj)
                  switch (type) {
                    // 其他 case 不变
                    // ...
                    case '[object Map]': {
                      const map = new Map()
                      obj.forEach((item, key) => {
                        // 需要注意的是,这里的 key 不能深拷贝,否则就会失去引用了
                        // 具体原因可以思考一下,不难。想不明白再评论区吧
                        map.set(key, copy(item))
                      })
                      return map
                    }
                    case '[object Set]': {
                      const set = new Set()
                      obj.forEach(item => {
                        set.add(copy(item))
                      })
                      return set
                    }
                    default:
                      return undefined
                  }
                }
              
                // 其他不变...
              }
              

              打印下结果:

              4.6 针对循环引用的问题

              以下是一个循环引用(circular reference)的对象:

              const foo = { name: 'Frankie' }
              foo.bar = foo
              

              上面提到 JSON.stringify() 无法处理循环引用的问题,我们在控制台打印一下:

              从结果可以看到,当对循环引用的对象进行序列化处理时,会抛出类型错误:Uncaught TypeError: Converting circular structure to JSON

              接着,使用自行实现的 deepCopy() 方法,看下结果是什么:

              我们看到,在拷贝循环引用的 foo 对象时,发生栈溢出了。

              在另一篇文章,我提到过使用 JSON-js 可以处理循环引用的问题,具体用法是,先引入其中的 cycle.js 脚本,然后 JSON.stringify(JSON.decycle(foo)) 就 OK 了。但究其根本,它使用了 WeakMap 去处理。

              那我们去实现一下:

              const deepCopy = source => {
                // 创建一个 WeakMap 对象,记录已拷贝过的对象(本次新增)
                const weakmap = new WeakMap()
              
                // 中间这块不变,省略一万字...
              
                // 拷贝(递归思路)
                const copy = input => {
                  if (typeof input === 'function' || !isObject(input)) return input
              
                  // 针对已拷贝过的对象,直接返回(本次新增,以解决循环引用的问题)
                  if (weakmap.has(input)) {
                    return weakmap.get(input)
                  }
              
                  // 处理包装对象
                  if (isWrapperObject(input)) {
                    return handleWrapperObject(input)
                  }
              
                  const output = isArray(input) ? [] : {}
              
                  // 记录每次拷贝的对象
                  weakmap.set(input, output)
              
                  for (let key in input) {
                    if (input.hasOwnProperty(key)) {
                      const value = input[key]
                      output[key] = copy(value)
                    }
                  }
              
                  // 处理以 Symbol 值作为属性键的属性
                  const symbolArr = Object.getOwnPropertySymbols(input)
                  if (symbolArr.length) {
                    for (let i = 0, len = symbolArr.length; i < len; i++) {
                      if (input.propertyIsEnumerable(symbolArr[i])) {
                        output[symbolArr[i]] = input[symbolArr[i]]
                      }
                    }
                  }
              
                  return output
                }
              
                return copy(source)
              }
              

              先看看打印结果,不会像之前一样溢出了。

              需要注意的是,这里不使用 Map 而是 WeakMap 的原因:

              首先,Map 的键属于强引用,而 WeakMap 的键则属于弱引用。且 WeakMap 的键必须是对象,WeakMap 的值则是任意的。

              由于它们的键与值的引用关系,决定了 Map 不能确保其引用的对象不会被垃圾回收器回收的引用。假设我们使用的 Map,那么图中的 foo 对象和我们深拷贝内部的 const map = new Map() 创建的 map 对象一直都是强引用关系,那么在程序结束之前,foo 不会被回收,其占用的内存空间一直不会被释放。

              相比之下,原生的 WeakMap 持有的是每个键对象的“弱引用”,这意味着在没有其他引用存在时垃圾回收能正确进行。原生 WeakMap 的结构是特殊且有效的,其用于映射的 key 只有在其没有被回收时才是有效的。

              基本上,如果你要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap。

              可看 Why WeakMap?

              我们熟知的 Lodash 库的深拷贝方法,自实现了一个类似 WeakMap 特性的构造函数去处理循环引用的。(详见

              这里提供另一个思路,也是可以的。

              const deepCopy = source => {
                // 其他一样,省略一万字...
              
                // 创建一个数组,将每次拷贝的对象放进去
                const copiedArr = []
              
                // 拷贝(递归思路)
                const copy = input => {
                  if (typeof input === 'function' || !isObject(input)) return input
              
                  // 循环遍历,若有已拷贝过的对象,则直接放回,以解决循环引用的问题
                  for (let i = 0, len = copiedArr.length; i < len; i++) {
                    if (input === copiedArr[i].key) return copiedArr[i].value
                  }
              
                  // 处理包装对象
                  if (isWrapperObject(input)) {
                    return handleWrapperObject(input)
                  }
              
                  const output = isArray(input) ? [] : {}
              
                  // 记录每一次的对象
                  copiedArr.push({ key: input, value: output })
              
                  // 后面的流程不变...
                }
              
                return copy(source)
              }
              

              此前实现有个 bug,感谢虾虾米指出,现已更正。

              请在实现深拷贝之后测试以下示例:

              const foo = { name: 'Frankie' }
              foo.bar = foo
              
              const cloneObj = deepCopy(foo) // 自实现深拷贝
              const lodashObj = _.cloneDeep(foo) // Lodash 深拷贝
              
              // 打印结果如下,说明是正确的
              console.log(lodashObj.bar === lodashObj) // true
              console.log(lodashObj.bar === foo) // false
              console.log(cloneObj.bar === cloneObj) // true
              console.log(cloneObj.bar === foo) // false
              

              4.7 针对正则表达式的处理

              正则表达式里面,有两个非常重要的属性:

              const { source, flags } = /\d/g
              console.log(source) // "\\d"
              console.log(flags) // "g"
              

              有了以上两个属性,我们就可以使用 new RegExp(pattern, flags) 构造函数去创建一个正则表达式了。

              const { source, flags } = /\d/g
              const newRegex = new RegExp(source, flags) // /\d/g
              

              但需要注意的是,正则表达式有一个 lastIndex 属性,该属性可读可写,其值为整型,用来指定下一次匹配的起始索引。在设置了 global 或 sticky 标志位的情况下(如 /foo/g/foo/y),JavaScript RegExp 对象是有状态的。他们会将上次成功匹配后的位置记录在 lastIndex 属性中。

              因此,上述拷贝正则表达式的方式是有缺陷的。看示例:

              const re1 = /foo*/g
              const str = 'table football, foosball'
              let arr
              
              while ((arr = re1.exec(str)) !== null) {
                console.log(`Found ${arr[0]}. Next starts at ${re1.lastIndex}.`)
              }
              
              // 以上语句会输出,以下结果:
              // "Found foo. Next starts at 9."
              // "Found foo. Next starts at 19."
              
              
              // 当我们修改 re1 的 lastIndex 属性时,输出以下结果:
              re1.lastIndex = 9
              while ((arr = re1.exec(str)) !== null) {
                console.log(`Found ${arr[0]}. Next starts at ${re1.lastIndex}.`)
              }
              // "Found foo. Next starts at 19."
              
              // 以上这些相信你们都都懂。
              

              所以,你可以发现以下示例,打印结果是不一致的,原因就是使用 RegExp 构造函数去创建一个正则表达式时,lastIndex 会默认设为 0

              const re1 = /foo*/g
              const str = 'table football, foosball'
              let arr
              
              // 修改 lastIndex 属性
              re1.lastIndex = 9
              
              // 基于 re1 拷贝一个正则表达式
              const re2 = new RegExp(re1.source, re1.flags)
              
              console.log('re1:')
              while ((arr = re1.exec(str)) !== null) {
                console.log(`Found ${arr[0]}. Next starts at ${re1.lastIndex}.`)
              }
              
              console.log('re2:')
              while ((arr = re2.exec(str)) !== null) {
                console.log(`Found ${arr[0]}. Next starts at ${re2.lastIndex}.`)
              }
              
              // re1:
              // expected output: "Found foo. Next starts at 19."
              // re2:
              // expected output: "Found foo. Next starts at 9."
              // expected output: "Found foo. Next starts at 19."
              

              因此:

              const deepCopy = source => {
                // 其他不变,省略...
              
                // 处理正则表达式
                const handleRegExp = regex => {
                  const { source, flags, lastIndex } = regex
                  const re = new RegExp(source, flags)
                  re.lastIndex = lastIndex
                  return re
                }
              
                // 拷贝(递归思路)
                const copy = input => {
                  if (typeof input === 'function' || !isObject(input)) return input
              
                  // 正则表达式
                  if (getClass(input) === '[object RegExp]') {
                    return handleRegExp(input)
                  }
              
                  // 后面不变,省略...
                }
              
                return copy(source)
              }
              

              打印结果也是符合预期的:

              由于 RegExp.prototype.flags 是 ES6 新增属性,我们可以看下 ES5 是如何实现的(源自 Lodash):

              /** Used to match `RegExp` flags from their coerced string values. */
              var reFlags = /\w*$/;
              
              /**
               * Creates a clone of `regexp`.
               *
               * @private
               * @param {Object} regexp The regexp to clone.
               * @returns {Object} Returns the cloned regexp.
               */
              function cloneRegExp(regexp) {
                var result = new regexp.constructor(regexp.source, reFlags.exec(regexp));
                result.lastIndex = regexp.lastIndex;
                return result;
              }
              

              但还是那句话,都 2021 年了,兼容 ES5 的问题就放心交给 Babel 吧。

              4.8 处理原型

              注意,这里只实现类型为 "[object Object]" 的对象的原型拷贝。例如数组等不处理,因为这些情况实际场景太少了。

              主要是修改以下这一步骤:

              const output = isArray(input) ? [] : {}
              

              主要利用 Object.create() 来创建 output 对象,改成这样:

              const initCloneObject = obj => {
                // 处理基于 Object.create(null) 或 Object.create(Object.prototype.__proto__) 的实例对象
                // 其中 Object.prototype.__proto__ 就是站在原型顶端的男人
                // 但我留意到 Lodash 库的 clone 方法对以上两种情况是不处理的
                if (obj.constructor === undefined) {
                  return Object.create(null)
                }
              
                // 处理自定义构造函数的实例对象
                if (typeof obj.constructor === 'function' && (obj !== obj.constructor || obj !== Object.prototype)) {
                  const proto = Object.getPrototypeOf(obj)
                  return Object.create(proto)
                }
              
                return {}
              }
              
              const output = isArray(input) ? [] : initCloneObject(input)
              

              来看下打印结果,可以看到 source 的原型对象已经拷贝过来了:

              再来看下 Object.create(null) 的情况,也是预期结果。

              我们可以看到 Lodash 的 _.cloneDeep(Object.create(null)) 深拷贝方法并没有处理这种情况。当然了,要拷贝这种数据结构在实际应用场景,真的少之又少...

              关于 Lodash 拷贝方法为什么不实现这种情况,我找到了一个相关的 Issue lodash #588

              A shallow clone won't do that as it's just _.assign({}, object) and a deep clone is loosely based on the structured cloning algorithm and doesn't attempt to clone inheritance or lack thereof.

              五、优化

              综上所述,完整但未优化的深拷贝方法如下:

              const deepCopy = source => {
                // 创建一个 WeakMap 对象,记录已拷贝过的对象
                const weakmap = new WeakMap()
              
                // 获取数据类型
                const getClass = x => Object.prototype.toString.call(x)
              
                // 判断是否为数组
                const isArray = arr => getClass(arr) === '[object Array]'
              
                // 判断是否为引用类型
                const isObject = obj => obj !== null && (typeof obj === 'object' || typeof obj === 'function')
              
                // 判断是否为包装对象
                const isWrapperObject = obj => {
                  const theClass = getClass(obj)
                  const type = /^\[object (.*)\]$/.exec(theClass)[1]
                  return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt', 'Date', 'Map', 'Set'].includes(type)
                }
              
                // 处理包装对象
                const handleWrapperObject = obj => {
                  const type = getClass(obj)
                  switch (type) {
                    case '[object Boolean]':
                      return Object(Boolean.prototype.valueOf.call(obj))
                    case '[object Number]':
                      return Object(Number.prototype.valueOf.call(obj))
                    case '[object String]':
                      return Object(String.prototype.valueOf.call(obj))
                    case '[object Symbol]':
                      return Object(Symbol.prototype.valueOf.call(obj))
                    case '[object BigInt]':
                      return Object(BigInt.prototype.valueOf.call(obj))
                    case '[object Date]':
                      return new Date(obj.valueOf()) // new Date(+obj)
                    case '[object Map]': {
                      const map = new Map()
                      obj.forEach((item, key) => {
                        map.set(key, copy(item))
                      })
                      return map
                    }
                    case '[object Set]': {
                      const set = new Set()
                      obj.forEach(item => {
                        set.add(copy(item))
                      })
                      return set
                    }
                    default:
                      return undefined
                  }
                }
              
                // 处理正则表达式
                const handleRegExp = regex => {
                  const { source, flags, lastIndex } = regex
                  const re = new RegExp(source, flags)
                  re.lastIndex = lastIndex
                  return re
                }
              
                const initCloneObject = obj => {
                  if (obj.constructor === undefined) {
                    return Object.create(null)
                  }
              
                  if (typeof obj.constructor === 'function' && (obj !== obj.constructor || obj !== Object.prototype)) {
                    const proto = Object.getPrototypeOf(obj)
                    return Object.create(proto)
                  }
              
                  return {}
                }
              
                // 拷贝(递归思路)
                const copy = input => {
                  if (typeof input === 'function' || !isObject(input)) return input
              
                  // 正则表达式
                  if (getClass(input) === '[object RegExp]') {
                    return handleRegExp(input)
                  }
              
                  // 针对已拷贝过的对象,直接返回(解决循环引用的问题)
                  if (weakmap.has(input)) {
                    return weakmap.get(input)
                  }
              
                  // 处理包装对象
                  if (isWrapperObject(input)) {
                    return handleWrapperObject(input)
                  }
              
                  const output = isArray(input) ? [] : initCloneObject(input)
              
                  // 记录每次拷贝的对象
                  weakmap.set(input, output)
              
                  for (let key in input) {
                    if (input.hasOwnProperty(key)) {
                      const value = input[key]
                      output[key] = copy(value)
                    }
                  }
              
                  // 处理以 Symbol 值作为属性键的属性
                  const symbolArr = Object.getOwnPropertySymbols(input)
                  if (symbolArr.length) {
                    for (let i = 0, len = symbolArr.length; i < len; i++) {
                      if (input.propertyIsEnumerable(symbolArr[i])) {
                        const value = input[symbolArr[i]]
                        output[symbolArr[i]] = copy(value)
                      }
                    }
                  }
              
                  return output
                }
              
                return copy(source)
              }
              

              接下来就是优化工作了...

              5.1 优化一

              我们上面使用到了 for...inObject.getOwnPropertySymbols() 方法去遍历对象的属性(包括字符串属性和 Symbol 属性),还涉及了可枚举属性和不可枚举属性。

              • for...in:遍历自身继承过来可枚举属性(不包括 Symbol 属性)。
              • Object.keys:返回一个数组,包含对象自身所有可枚举属性(不包括不可枚举属性和 Symbol 属性)
              • Object.getOwnPropertyNames:返回一个数组,包含对象自身的属性(包括不可枚举属性,但不包括 Symbol 属性)
              • Object.getOwnPropertySymbols:返回一个数组,包含对象自身的所有 Symbol 属性(包括可枚举和不可枚举属性)
              • Reflect.ownKeys:返回一个数组,包含自身所有的属性(包括 Symbol 属性,不可枚举属性以及可枚举属性)

              由于我们仅拷贝可枚举的字符串属性和可枚举的 Symbol 属性,因此我们将 Reflect.ownKeys()Object.prototype.propertyIsEnumerable() 结合使用即可。

              所以,我们将以下这部分:

              for (let key in input) {
                if (input.hasOwnProperty(key)) {
                  const value = input[key]
                  output[key] = copy(value)
                }
              }
              
              // 处理以 Symbol 值作为属性键的属性
              const symbolArr = Object.getOwnPropertySymbols(input)
              if (symbolArr.length) {
                for (let i = 0, len = symbolArr.length; i < len; i++) {
                  if (input.propertyIsEnumerable(symbolArr[i])) {
                    const value = input[symbolArr[i]]
                    output[symbolArr[i]] = copy(value)
                  }
                }
              }
              

              优化成:

              // 仅遍历对象自身可枚举的属性(包括字符串属性和 Symbol 属性)
              Reflect.ownKeys(input).forEach(key => {
                if (input.propertyIsEnumerable(key)) {
                  output[key] = copy(input[key])
                }
              })
              

              5.2 优化二

              优化 getClass()isWrapperObject()handleWrapperObject()handleRegExp() 及其相关的类型判断方法。

              由于 handleWrapperObject() 原意是处理包装对象,但是随着后面要处理的特殊对象越来越多,为了减少文章篇幅,暂时都写在里面了,稍微有点乱。

              因此下面我们来整合一下,部分处理函数可能会修改函数名。

              六、最后

              其实,上面提到的一些边界 Case、或者其他一些特殊对象(如 ArrayBuffer 等),这里并没有处理,但我认为该完结了,因为这些在实际应用场景真的太少了。

              代码已放在 GitHub 👉 tofrankie/utils

              还是那句话:

              [!IMPORTANT] 如果生产环境使用 JSON.stringify() 无法解决你的需求,请使用 Lodash 库的 _.cloneDeep() 方法,那个才叫面面俱到。千万别用我这方法,切记!

              这篇文章主要面向学习、面试(手动狗头),或许也可以帮助你熟悉一些对象的特性。如有不足,欢迎指出,万分感谢 👋 ~

              终于终于终于......要写完了,吐了三斤血...

              最终版本如下:

              const deepCopy = source => {
                // 创建一个 WeakMap 对象,记录已拷贝过的对象
                const weakmap = new WeakMap()
              
                // 获取数据类型,返回值如:"Object"、"Array"、"Symbol" 等
                const getClass = x => {
                  const type = Object.prototype.toString.call(x)
                  return /^\[object (.*)\]$/.exec(type)[1]
                }
              
                // 判断是否为数组
                const isArray = arr => getClass(arr) === 'Array'
              
                // 判断是否为引用类型
                const isObject = obj => obj !== null && (typeof obj === 'object' || typeof obj === 'function')
              
                // 判断是否为“特殊”对象(需要特殊处理)
                const isSepcialObject = obj => {
                  const type = getClass(obj)
                  return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt', 'Date', 'Map', 'Set', 'RegExp'].includes(type)
                }
              
                // 处理特殊对象
                const handleSepcialObject = obj => {
                  const type = getClass(obj)
                  const Ctor = obj.constructor // 对象的构造函数
                  const primitiveValue = obj.valueOf() // 获取对象的原始值
              
                  switch (type) {
                    case 'Boolean':
                    case 'Number':
                    case 'String':
                    case 'Symbol':
                    case 'BigInt':
                      // 处理包装对象 Wrapper Object
                      return Object(primitiveValue)
                    case 'Date':
                      return new Ctor(primitiveValue) // new Date(+obj)
                    case 'RegExp': {
                      const { source, flags, lastIndex } = obj
                      const re = new RegExp(source, flags)
                      re.lastIndex = lastIndex
                      return re
                    }
                    case 'Map': {
                      const map = new Ctor()
                      obj.forEach((item, key) => {
                        // 注意,即使 Map 对象的 key 为引用类型,这里也不能 copy(key),否则会失去引用,导致该属性无法访问得到。
                        map.set(key, copy(item))
                      })
                      return map
                    }
                    case 'Set': {
                      const set = new Ctor()
                      obj.forEach(item => {
                        set.add(copy(item))
                      })
                      return set
                    }
                    default:
                      return undefined
                  }
                }
              
                // 创建输出对象(原型拷贝关键就在这一步)
                const initCloneObject = obj => {
                  if (obj.constructor === undefined) {
                    return Object.create(null)
                  }
              
                  if (typeof obj.constructor === 'function' && (obj !== obj.constructor || obj !== Object.prototype)) {
                    const proto = Object.getPrototypeOf(obj)
                    return Object.create(proto)
                  }
              
                  return {}
                }
              
                // 拷贝方法(递归思路)
                const copy = input => {
                  if (typeof input === 'function' || !isObject(input)) return input
              
                  // 针对已拷贝过的对象,直接返回(解决循环引用的问题)
                  if (weakmap.has(input)) {
                    return weakmap.get(input)
                  }
              
                  // 处理包装对象
                  if (isSepcialObject(input)) {
                    return handleSepcialObject(input)
                  }
              
                  // 创建输出对象
                  const output = isArray(input) ? [] : initCloneObject(input)
              
                  // 记录每次拷贝的对象
                  weakmap.set(input, output)
              
                  // 仅遍历对象自身可枚举的属性(包括字符串属性和 Symbol 属性)
                  Reflect.ownKeys(input).forEach(key => {
                    if (input.propertyIsEnumerable(key)) {
                      output[key] = copy(input[key])
                    }
                  })
              
                  return output
                }
              
                return copy(source)
              }
              

              七、参考链接

              ]]> <![CDATA[手写 new 关键字]]> https://github.com/tofrankie/blog/issues/1 https://github.com/tofrankie/blog/issues/1 Thu, 28 Oct 2021 15:42:21 GMT 配图源自 Freepik

              原理

              先了解下 new 关键字都做]]> 配图源自 Freepik

              原理

              先了解下 new 关键字都做了些什么工作:

              1. 隐式创建一个实例对象 this(空对象);
              2. 将实例对象原型(__proto__)指向构造函数的原型对象(prototype);
              3. 开始执行构造函数,一般会伴随着实例对象属性、方法的绑定;
              4. 返回结果。需要注意的是,上一步执行构造函数返回的结果,若为引用值,则直接返回该值,否则返回实例对象。

              思路

              知道原理之后,手写就有思路了,我们将会使用以下方式进行调用:

              myNew(ctor, arg1, arg2, ...)
              
              • ctor 接受一个构造函数。

              • arg1, arg2, ...(可选)指定参数列表。

              实现

              代码实现,如下:

              function myNew() {
                // 获取构造函数、参数列表
                const [Ctor, ...args] = arguments
              
                // 创建实例对象,并指定原型
                const _this = {}
                _this.__proto__ = Ctor.prototype // 或使用标准方法 Object.setPrototypeOf(_this, Ctor.prototype)
              
                // 执行构造函数,并注意 this 指向
                const res = Ctor.apply(_this, args)
              
                // 返回结果
                return res instanceof Object ? res : _this
              }
              

              优化

              既然我们直接使用了 ES6 的语法,我们不妨将创建实例对象的步骤改用 Object.create() 来处理,再简化一下:

              function myNew(Ctor, ...args) {
                const _this = Object.create(Ctor.prototype)
                const res = Ctor.apply(_this, args)
                return res instanceof Object ? res : _this
              }
              

              示例

              function Foo(name, age) {
                this.name = name
                this.age = age
              }
              
              const foo = myNew(Foo, 'Frankie', 20) // Foo { name: 'Frankie', age: 20 }
              
              ]]>