阅读视图

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

一个 waline 评论系统bug引发的思考

提示

前言: Waline 评论系统这个 bug 有几个月了,现象就是回复完其他人的评论后,评论内容会保留在顶部输入框中,而且不会自动清空。具体见 👉GitHub issuse #2173

许久未见修复,正好我有空,就看看这个问题,接下来跟我一起分析一下这个问题

1. 定位问题

问题现象上边已经描述过了,我们先来定位一下问题。

1.1. 关键

关键在于【不会清空】,即使刷新浏览器,也不会清空,由此可知必然保存在 localStorage 中,打开开发者工具,看看保存在 localStorage 中的数据结构。发现如下图所示

localStorage
localStorage

然后有了key就好办了

1.2. 源码

在 Waline 评论系统源码中,我们全局搜索 WALINE_COMMENT_BOX_EDITOR 结果如下

// walinejs/packages/client/src/composables/inputs.ts
import type { RemovableRef } from '@vueuse/core';
import { useStorage } from '@vueuse/core';

export interface UserMeta {
    nick: string;
    mail: string;
    link: string;
}

export const useUserMeta = (): RemovableRef<UserMeta> =>
useStorage<UserMeta>('WALINE_USER_META', {
    nick: '',
    mail: '',
    link: '',
});

export const useEditor = (): RemovableRef<string> =>
useStorage<string>('WALINE_COMMENT_BOX_EDITOR', '');

继续查找调用,找到CommentBox.vue关键组件,该组件将textarea评论框v-modeluseEditor函数绑定,该函数返回一个RemovableRef,该类型为<string>

const editor = useEditor();
<textarea
  id="wl-edit"
  ref="editorRef"
  v-model="editor"
  class="wl-editor"
  :placeholder="replyUser ? `@${replyUser}` : locale.placeholder"
  @keydown="onKeyDown"
  @drop="onDrop"
  @paste="onPaste"
/>

2. 调试

2.1. 准备工作

  1. 已锁定问题文件为CommentBox.vue,接下来开始 debug,因为准备提 PR,所以先 fork 一份

  2. 按照waline-贡献指南进行准备

  3. 先执行pnpm i & pnpm build,本地调试依赖@waline/api 需要前置 build

  4. 使用 pnpm client:dev 启动 @waline/client 本地开发,由于 waline 是 Client/Server 架构,在调试 client 时,你需要设置 SERVERURL 为调试服务器(可以直接使用 vercel 的服务器),或同时启动下面的 server 开发服务器并使用默认的 localhost:9090

  5. 使用 pnpm server:dev 启动 @waline/server 本地开发,配置必要的本地环境变量至 example/.env。(这里我配置了 leancloud 的环境变量,一直在报错)

2.2. 关键函数 CommentBox.vuesubmitCommentwatch

 const submitComment = async (): Promise<void> => {

     // 此处...省略若干

     try {
         if (recaptchaV3Key)
         comment.recaptchaV3 =
             await useReCaptcha(recaptchaV3Key).execute('social');

         if (turnstileKey)
         comment.turnstile = await useTurnstile(turnstileKey).execute('social');

         const options = {
         serverURL,
         lang,
         token: userInfo.value?.token,
         comment,
         };

         const response = await (props.edit
         ? updateComment({
             objectId: props.edit.objectId,
             ...options,
         })
         : addComment(options));

         isSubmitting.value = false;

         if (response.errmsg) return alert(response.errmsg);

         emit('submit', response.data!);

         editor.value = '';

         previewText.value = '';

         if (props.replyId) emit('cancelReply');
         if (props.edit?.objectId) emit('cancelEdit');
     } catch (err: unknown) {
         isSubmitting.value = false;

         alert((err as TypeError).message);
     }
 };

接下来看 watch

watch(
  () => editor.value,
  (value) => {
    const { highlighter, texRenderer } = config.value;
    content.value = value;
    previewText.value = parseMarkdown(value, {
      emojiMap: emoji.value.map,
      highlighter,
      texRenderer,
    });
    wordNumber.value = getWordNumber(value);

    if (value) autosize(editorRef.value!);
    else autosize.destroy(editorRef.value!);
  },
  {
    immediate: true,
  }
);

2.3. 断点测试,整体流程如下

  1. 监听editor.value,并将editortextarea绑定,用户输入的值自动保存在localStorage

  2. 点击提交按钮,将textarea中的值发送给后端,收到回调后将editor清空,如果抛出异常则不清空

  3. 正常提交评论无异常,在submitComment函数中的editor.value = '';localStorage存储的内容清空

  4. 回复他人评论时,前置流程提交和步骤 3 一致,然后触发了watch,此时watch监听到的value为未被清空的值,既之前用户输入的内容

  5. submitComment中的editor.value = '';watch都打上了断点,先赋值为空,后触发watch,而此时watch的 value 为被清空之前的值,即用户输入的内容

2.4. 分析

问题已经定位到代码级别,接下来只需要找到 watch 被触发的原因即可,初步猜测可能原因:

submitComment是异步函数,其内部赋空值后,editor.value=''没有及时更新,导致watch触发取到旧值

遂添加 watch 的 bebug 函数加以验证

  1. onTrack 将在响应属性或引用作为依赖项被跟踪时被调用。相当于 get
  2. onTrigger 将在侦听器回调被依赖项的变更触发时被调用。相当于 set
watch(
  () => editor.value,
  (value) => {
    const { highlighter, texRenderer } = config.value;
    content.value = value;
    previewText.value = parseMarkdown(value, {
      emojiMap: emoji.value.map,
      highlighter,
      texRenderer,
    });
    wordNumber.value = getWordNumber(value);

    if (value) autosize(editorRef.value!);
    else autosize.destroy(editorRef.value!);
  },
  {
    immediate: true,
    onTrack(e) {
      // 当 editor.value 被追踪为依赖时触发
      debugger;
    },
    onTrigger(e) {
      // 当 editor.value 被更改时触发
      debugger;
    },
  }
);

结果为:

  1. editor.value=''时,onTrigger触发,newValue 为''
  2. 紧接着触发onTrackvalue 为旧值

调整回调的触发时机试试看:

  • Post Watchers

    如果想在侦听器回调中能访问被 Vue 更新之后的所属组件的 DOM,你需要指明 flush: 'post' 选项:

  • 同步侦听器

    它会在 Vue 进行任何更新之前触发:

    注意

    同步侦听器不会进行批处理,每当检测到响应式数据发生变化时就会触发。可以使用它来监视简单的布尔值,但应避免在可能多次同步修改的数据源 (如数组) 上使用。

结果是没有作用,但在更改为 sync 后,发现了一件有趣的事情,因为 sync 不会进行批处理的特性,所以触发了两次onTrack,我们来看一下两次的堆栈信息

第一次
第一次
第二次
第二次

很明显第一次触发是editor.value=''时触发的,第二次触发异步的,在submitComment还未执行完成时就触发了,点击堆栈信息定位到了291行,很明显早于editor.value=''297行。所以取到的是旧的值,虽然断点是editor.value=''先执行,但submitComment是异步的,watch 取到的是旧值,那接下来就需要看 watch是被什么触发了

由于源码中的watch是写在onMounted中的,断点确定二次走到watch中时,是触发了onMounted,那触发onMounted是组件重新加载了,查找后得知,整体逻辑是,评论区域,代码结构如下:

  • 组件为WalineComment.vue,其中包含了评论列表组件 itemCommentCard.vue和顶部默认输入框CommentBox.vue

  • 针对文章发布评论使用的是顶部的CommentBox.vue组件

  • 针对评论回复时,使用的是CommentCard.vue-CommentBox.vue

  • 回复完成后CommentCard.vue-CommentBox.vue销毁,顶部的CommentBox.vue组件重新渲染

顶部的CommentBox.vue组件重新渲染会触发 watch,此时取值是旧值, 经查看,submitComment中的editor.value=''执行完后, localStorage 中的值并未立即修改,所以重新渲染的顶部CommentBox.vue组件在初始化时取到的值仍为localStorage中的旧值。

3. 验证

上边已经基本确定问题出在这句上

editor.value = "";
const editor = useEditor();

而在 1.2 中可以看到useEditor()@vueuse/core的导出函数,怀疑其内部实现有一些异步操作,导致的没有立即更新localStorage

// 更换`editor.value`为
localStorage.setItem('WALINE_COMMENT_BOX_EDITOR', '');
// 或
await nextTick()

3.1. 至此问题解决

相关信息

nextTick()

等待下一次 DOM 更新刷新的工具方法。

  • 类型
function nextTick(callback?: () => void): Promise<void>
  • 详细信息

当你在 Vue 中更改响应式状态时,最终的 DOM 更新并不是同步生效的,而是由 Vue 将它们缓存在一个队列中,直到下一个“tick”才一起执行。这样是为了确保每个组件无论发生多少状态改变,都仅执行一次更新。

nextTick() 可以在状态改变后立即使用,以等待 DOM 更新完成。你可以传递一个回调函数作为参数,或者 await 返回的 Promise。

4. 思考

vueusejs的文档中,其实useStorage应该是一个同步操作,它还有一个useStorageAsync的API,支持异步的响应式Storage,按理说里边不应该包含太多异步的或者延时性的代码,我也找到源码浅浅看了一下,目前还没找到问题在哪里,按照替换localStorage.setItem('WALINE_COMMENT_BOX_EDITOR', '');可行来看,问题就在useStorage身上无疑,后续有时间,会继续探索一下,给官方的PR#2524也已经提了

🔲 ☆

Jenkins 远程触发构建踩坑记

提示

如果想在代码 Push 后,或者 Merge request 后,自动部署,可以采用多种方案,以下介绍两种

不知道如何配置的同学,可以参考一下

1. 通过 Jenkins 提供的【触发远程构建】

1.1. 勾选【触发远程构建】并填入 token

勾选远程构建开关
勾选远程构建开关

1.2. 配置 API token

  1. 打开 Jenkins 控制台。

  2. 在顶部导航栏中,点击您的用户名,然后选择 "Configure" 选项。

  3. 在配置页面中,向下滚动,找到 "API Token" 部分。

  4. 如果您之前没有生成过 API Token,则点击 "Add new Token" 或 "Generate Token" 按钮。

  5. 在生成或更改 Token 的过程中,您可能需要提供您的 Jenkins 用户密码进行身份验证。

  6. 生成或更改成功后,您将看到新生成的 Token 值。请将其复制并妥善保存,因为在以后的访问中,您将无法再查看该 Token 的值。

1.3. 如何调用 Url

Jenkins 提供了便捷的远程触发功能,但是需要配置一个 token,然后在 push 后,通过 post 请求,调用 Jenkins 的 url 即可触发构建

调用 url 可以使用 python 脚本,或者 curl 命令,一般配合 git 提交,使用 curl 命令居多

下面以 GitLab CI/CD 为例进行举例

GitLab CI/CD 是一个简洁好用的的持续集成/持续交付的框架。通过为你的项目配置一个或者多个 GitLab Runner,然后撰写一个 .gitlab-ci.yml,你就可以很方便地利用 GitLab CI/CD 来为你的项目引入持续集成/交付的功能。比较类似之前介绍过的GitHubAction

GitLab CI/CD 是通过 GitLab Runner 来执行的

GitLab CI/CD 将按照 Stage 定义的顺序来执行,任何一个 Stage 失败,整个 CI/CD 将失败

每一个 Stage 可以被若干个 Job 关联。Stage 在执行的时候,关联到这个 Stage 的所有 Job 都将被执行,不过不同的 Job 可能是并行执行的。

每个 Job 在执行的时候,会先按照缓存策略加载缓存数据,然后按照顺序依次运行 before_script、script 和 after_script 中配置的脚本,运行完毕以后,会将生成的数据保存到缓存中。

1.4. 编写.gitlab-ci.yml

stages:
  - deploy

deploy_to_hyjk_open-x:
  stage: deploy
  only:
    - master
  script:
    - ./deploy.sh

大意是当 master 分支 deploy 时执行 ./deploy.sh 脚本
这里 script 调用了一个 shell 脚本,因为在 yml 文件中写脚本比较麻烦,个人比较习惯在 sh 文件中写脚本,然后在 yml 文件中调用这个脚本即可

1.5. deploy.sh

由于新版本的 Jenkins 安全机制,每次调用 API 前都需要先调用获取获取 CSRF crumb 值的接口

下面脚本演示了,如何先获取 crumb 的值,并在之后触发远程构建 API 的时候,携带这个值

#!/bin/bash

# Jenkins 服务器信息
JENKINS_URL="http://192.168.155.57:8080/"
JENKINS_USERNAME="admin"
JENKINS_API_TOKEN="11abf441f4db6e341ae65b660f74de6e02"

# 获取 CSRF crumb 值
CRUMB=$(curl -s -u "$JENKINS_USERNAME:$JENKINS_API_TOKEN" "$JENKINS_URL/crumbIssuer/api/xml?xpath=concat(//crumbRequestField,\":\",//crumb)")

# 提取 crumb 值
CRUMB_VALUE=$(echo "$CRUMB" | awk -F':' '{print $2}')

# 执行 Jenkins 远程构建
curl -X POST "$JENKINS_URL/job/projectName/build?token=xZ7Y3n9A" \
  --user "$JENKINS_USERNAME:$JENKINS_API_TOKEN" \
  --header "Jenkins-Crumb: $CRUMB_VALUE"

2. 通过 GitLab 的 webhook 触发远程构建

使用 gitlab 的 webhook 配合 Jenkins 实现自动化部署。
Jenkins 需要安装 GitLab Plugin 插件,然后配置 webhook 即可。

2.1. 勾选插件选项

勾选插件选项
勾选插件选项

2.2. 选择配置

插件有很多配置项,这里我们常用的就是过滤分支,可以选择,有以下选项

  • Include
  • Exclude
  • 正则表达式
  • tag
设置token
设置token

2.3. 将生成的 url 和 token 填入 GitLab 中

可以选择触发的 events
填入GitLab

2.4. 查看运行结果

查看运行结果
查看运行结果
🔲 ⭐

Vercel deploy忽略指定分支

一招教你解决Vercel deploy时总是任意分支都可以触发,如果你的项目有多个分支,可以指定某一个或几个有提交时触发deploy

1. 前言

提示

这不是刚把博客部署从GitHub Pages转移到了Vercel上,稍微加快了一点国内的访问速度,然后发现了一个新问题~

原先是推送到main分之,通过Github Action 工作流进行部署到gh-page 分支上

转移到Vercel之后,工作流我并没有删掉,所以流程还是一样,就导致main提交了

🔲 ⭐

Git SSH 密钥配置

在 GitLab 中配置 SSH 密钥

生成 SSH 密钥对

如果您还没有 SSH 密钥对,请首先生成一对密钥。在终端中执行以下命令:

ssh-keygen -t rsa -C "your.email@example.com" -b 4096

这将生成一个 RSA 类型的密钥对,并将私钥保存在 ~/.ssh/id_rsa,公钥保存在 ~/.ssh/id_rsa.pub

复制公钥内容

打开公钥文件 ~/.ssh/id_rsa.pub,将其中的内容复制到剪贴板。您可以使用以下命令来完成:

macOS:

pbcopy < ~/.ssh/id_rsa.pub

GNU/Linux (requires the xclip package):

xclip -sel clip < ~/.ssh/id_rsa.pub

Windows Command Line:

type %userprofile%\.ssh\id_rsa.pub | clip

Git Bash on Windows / Windows PowerShell:

cat ~/.ssh/id_rsa.pub | clip

将您的SSH密钥添加到GitLab。

  1. 登录到 GitLab

  2. 打开您的 GitLab 帐户,在右上角点击个人头像,选择设置

  3. 在左侧导航到 SSH 密钥设置

  4. 在 "SSH Keys" 页面,粘贴之前复制的公钥内容到 "Key" 字段中,并为该密钥添加一个可识别的标题(如 "My SSH Key")。然后点击 "Add Key" 按钮。

验证 SSH 密钥

GitLab 将验证并添加您提供的 SSH 密钥。如果一切顺利,您将在页面上看到成功的消息。

测试 SSH 连接

为了确保 SSH 密钥配置正确,您可以在终端中执行以下命令进行测试:

ssh -T git@gitlab.com

如果配置正确,您将看到与 GitLab 相关的欢迎消息。
现在,您已经成功在 GitLab 中配置了 SSH 密钥。这将允许您通过 SSH 协议进行与 GitLab 之间的安全通信,例如进行代码的克隆、推送和拉取操作。

重要

我们建议您使用ssh密钥而非用户名密码登录

因为有些情况比如更换git客户端、或者某些自动化脚本,是无法使用用户名密码登录的

对于macOS用户,我强烈建议您添加以下命令到zsh的配置文件 ~/.zprofile

ssh-add --apple-use-keychain ~/.ssh/id_rsa &> /dev/null

该命令的作用是将指定的 SSH 私钥文件 id_rsa 添加到 Apple Keychain 中,并将输出静默地丢弃,以确保在终端上不会显示任何输出信息。这通常用于在登录时自动加载 SSH 密钥,并将其保存在钥匙串中,以便在需要时无需再次手动输入密码或密钥口令。

🔲 ⭐

初识Rust

Rust 发展历程

Rust 最早是 Mozilla 雇员 Graydon Hoare 的个人项目。从 2009 年开始,得到了 Mozilla 研究院的资助,2010 年项目对外公布,2010 ~ 2011 年间实现自举。自此以后,Rust 在部分重构 -> 崩溃的边缘反复横跳(历程极其艰辛),终于,在 2015 年 5 月 15 日发布 1.0 版。

在紧锣密鼓的开发过程中,Rust 建立了一个强大且活跃的社区,形成一整套完善稳定的项目贡献机制(Rust 能够飞速发展,与这一点密不可分)。Rust 现在由 Rust 项目开发者社区 维护, Rust 基金会赞助支持。

大家可能疑惑 Rust 为啥用了这么久才到 1.0 版本?与之相比,Go 语言 2009 年发布,却在 2012 年仅用 3 年就发布了 1.0 版本。

  • 首先,Rust 语言特性较为复杂,所以需要全盘考虑的问题非常多;
  • 其次,Rust 当时的参与者太多,七嘴八舌的声音很多,众口难调,而 Rust 开发团队又非常重视社区的意见;
  • 最后,一旦 1.0 快速发布,那绝大部分语言特性就无法再被修改,对于有完美强迫症的 Rust 开发者团队来说,某种程度上的不完美是不可接受的。

因此,Rust 语言用了足足 6 年时间,才发布了尽善尽美的 1.0 版本。

为何又来一门新语言?

简而言之,因为还缺一门无 GC 且无需手动内存管理、性能高、工程性强、语言级安全性以及能同时得到工程派和学院派认可的语言,而 Rust 就是这样的语言。你也可以回忆下熟悉的语言,看是不是有另外一门可以同时满足这些需求:)

至于 Rust 最为人诟病的点,那也就一个:学习曲线陡峭。不过当语言生态起来后,这都不算问题。

缓解内卷

有人说 Rust 作为新语言会增加内卷,其实恰恰相反,Rust 可以缓解内卷。为何不说 C++ 内卷,而说 Java、Python、JS 内卷?不就是后几个相对简单、上手容易嘛?而 Rust 怎么看也是 C++ 级别的上手难度。

其实从我内心不可告人的角度出发,并不希望 Rust 大众化,因为这样可以保饭碗、保薪资,还能拥有行业内的地位。但是从对 Rust 的喜爱角度出发,我还是希望能卷一些。不过,目前来看真的卷不动,现在全世界范围内 Rust 的需求都大于供给,特别是优秀的 Rust 程序员更是难寻。

与 Go 语言相比,成为一名优秀的 Rust 程序员所需的门槛高得多,例如融汇贯通 Rust 语言各种中高级特性、闭着眼睛趟过各种坑、不用回忆无需查找就能立刻写出最合适的包/模块/方法、性能/安全/工程性的权衡选择信手拈来、深层性能优化易如反掌、异步编程小菜一碟,更别说 Rust 之外的操作系统、网络、算法等等相关知识。

所以,Rust 可以缓解内卷,而不是增加内卷。可以说是程序员的福音,不再是被随意替换的螺丝钉。

运行效率

得益于各种零开销抽象、深入到底层的优化潜力、优质的标准库和第三方库实现,Rust 具备非常优秀的性能,和 C、C++ 是 一个级别

同时 Rust 有一个极大的优点:只要按照正确的方式使用 Rust,无需性能优化,就能有非常优秀的表现,不可谓不惊艳。

现在有不少用 Rust 重写的工具、平台都超过了原来用 C、C++ 实现的版本,将老前辈拍死在沙滩上,俨然成为一种潮流~~

开发效率

Rust 的开发效率可以用先抑后扬来形容。在最初上手写项目时,你的开发速度将显著慢于 Go、Java 等语言,不过,一旦开始熟悉标准库、熟悉生命周期和所有权的常用解决方法,开发效率将大幅提升,甚至当形成肌肉记忆后,开发效率将不会慢于这些语言,而且原生就能写出高质量、安全、高效的代码,可以说中高级 Rust 程序员就是高效程序员的代名词。

开源

目前 Rust 的主战场是在开源上,Go 的成功也证明了农村包围城市( 开源包围商业 )的可行性。

  • UI 层开发,Rust 的 WASM 发展的如火如荼,隐隐有王者风范,在 JS 的基础设施领域,Rust 也是如鱼得水,例如 swcdeno 等。同时 nextjs 也是押宝 Rust,可以说 Rust 在前端的成功完全是无心插柳柳成荫。
  • 基础设施层、数据库、搜索引擎、网络设施、云原生等都在出现 Rust 的身影,而且还不少。
  • 系统开发,目前 Linux 已经将 Rust 语言纳入内核,是继 C 语言后第二门支持内核开发的语言,不过刚开始将主要支持驱动开发。
  • 系统工具,现在最流行的就是用 Rust 重写之前 C、C++ 写的一票系统工具,还都获得了挺高的关注和很好的效果,例如 sd, exa, ripgrep, fd, bat 等。
  • 操作系统,正在使用 Rust 开发的操作系统有好几个,其中最有名的可能就是谷歌的 Fuchsia,Rust 在其中扮演非常重要的角色。
  • 区块链,如果 Rust 的份额说第二,应该没人敢稳说自己是第一吧?

类似的还有很多,我们就不一一列举。总之,现在有大量的项目正在被 Rust 重写,同时还有海量的项目在等待被重写,这些都是赚取github 星星和认可的好机会。在其它语言杀成一片红海时,Rust 还留了一大片蓝海等待大家的探索!

相比其他语言 Rust 的优势

由于篇幅有限,我们这里不会讲述详细的对比,就是简单介绍下 Rust 的优势,并不是说 Rust 优于这些语言,大家轻喷:)

Go

Rust 语言表达能力更强,性能更高。同时线程安全方面 Rust 也更强,不容易写出错误的代码。包管理 Rust 也更好,Go 虽然在 1.10 版本后提供了包管理,但是目前还比不上 Rust 。

C++

Rust 与 C++ 的性能旗鼓相当,但是在安全性方面 Rust 会更优,特别是使用第三方库时,Rust 的严格要求会让三方库的质量明显高很多。

语言本身的学习,Rust 的前中期学习曲线会更陡峭,但是在实际的项目开发过程中,C++ 会更难,代码也更难以维护。

Java

除了极少数纯粹的数字计算性能,Rust 的性能全面领先于 Java 。同时 Rust 占用内存小的多,因此实现同等规模的服务,Rust 所需的硬件成本会显著降低。

Python

性能自然是 Rust 完胜,同时 Rust 对运行环境要求较低,这两点差不多就足够抉择了。不过 Python 和 Rust 的彼此适用面其实也不太冲突。

使用现状

  • AWS 从 2017 年开始就用 Rust 实现了无服务器计算平台: AWS Lambda 和 AWS Fargate,并且用 Rust 重写了 Bottlerocket OS 和 AWS Nitro 系统,这两个是弹性计算云 (EC2) 的重要服务
  • Cloudflare 是 Rust 的重度用户,DNS、无服务计算、网络包监控等基础设施都与 Rust 密不可分
  • Dropbox 的底层存储服务完全由 Rust 重写,达到了数万 PB 的规模
  • Google 除了在安卓系统的部分模块中使用 Rust 外,还在它最新的操作系统 Fuchsia 中重度使用 Rust
  • Facebook 使用 Rust 来增强自己的网页端、移动端和 API 服务的性能,同时还写了 Hack 编程语言的虚拟机
  • Microsoft 使用 Rust 为 Azure 平台提供一些组件,其中包括 IoT 的核心服务
  • GitHub 和 npmjs.com,使用 Rust 提供高达每天 13 亿次的 npm 包下载
  • Rust 目前已经成为全世界区块链平台的首选开发语言
  • TiDB,国内最有名的开源分布式数据库

尤其值得一提的是,AWS 实际上在押宝 Rust,内部对 Rust 的使用已经上升到头等公民 first-class 的地位。

Rust 语言版本更新

与其它语言相比,Rust 的更新迭代较为频繁(得益于精心设计过的发布流程以及 Rust 语言开发者团队的严格管理):

  • 每 6 周发布一个迭代版本
  • 2-3 年发布一个新的大版本,例如 Rust 2018 edition,Rust 2021 edition

好处在于,可以满足不同的用户群体的需求:

  • 对于活跃的 Rust 用户,他们总是能很快获取到新的语言内容,毕竟,尝鲜是技术爱好者的共同特点:)
  • 对于一般的用户,edition 大版本的发布会告诉他们:Rust 语言相比上次大版本发布,有了重大的改进,值得一看
  • 对于 Rust 语言开发者,可以让他们的工作成果更快的被世人所知,不必锦衣夜行

总结

连续 6 年最受欢迎的语言当然不是浪得虚名。 无 GC、效率高、工程性强、强安全性以及能同时得到工程派和学院派认可,这些令 Rust 拥有了自己的特色和生存空间。社区的友善,生态的快速发展,大公司的重仓跟进,一切的一切都在说明 Rust 的璀璨未来。

当然,语言毕竟只是工具,我们不能神话它,但是可以给它一个机会,也许,你最终能收获自己的真爱 :)

相信大家听了这么多 Rust 的优点,已经迫不及待想要开始学习旅程,那么容我引用一句 CS(Counter-Strike:反恐精英) 的经典台词:Ok, Let's Rust.

引自:Rust语言圣经(Rust Course)

🔲 ⭐

vuepress-plugin-meting2

前言

嘿~
🍰🍰🍰 播放器有了,撒花✿✿ヽ(°▽°)ノ✿🎉🎉🎉

npm  GitHub stars  downloads  downloads  GitHub license

文档👉🏻戳这里文档

介绍

借鉴了vuepress-plugin-sbaudiovuepress-plugin-meting 在此表示感谢

借鉴MetingJS解析和使用APlayer作为播放组件

安装很方便

使用也很方便

plugins: [
    metingPlugin({
      metingOptions: {
        global:true, // 开启关闭全局播放器
        server: "tencent",
        api: "https://api.injahow.cn/meting/?server=:server&type=:type&id=:id&auth=:auth&r=:r",
        type: "playlist",
        mid: "851947617",
      },
    }),
];

可作为组件引入

🔲 ☆

谷歌发布多平台应用开发神器Project IDX!PaLM 2加持

8 月 8 日,谷歌宣布推出 AI 代码编辑器 Project IDX,这是一个基于浏览器的开发环境:集成 AI、支持全栈编程语言、跨平台真机预览、一键部署,用于构建全栈网络和多平台应用程序。

一直以来,从 0 开始构建应用,都是一项复杂的工作。尤其是跨越手机、Web 和桌面平台的程序。

这是一片无尽的复杂海洋,需要把技术堆栈融合在一起,来引导、编译、测试、部署、监控应用程序。

多年来,谷歌一直致力于让多平台程序开发流程更快、更顺畅。

经过几个月的成果,团队成功做出了 Project IDX。

谷歌在创建 Project IDX 时并没有构建新的 IDE(集成开发环境),而是使用 VS Code 作为其项目的基础。这让团队能够专注于与 Codey 的集成,Codey 是谷歌基于 PaLM 2 的编程任务基础模型。Project IDX 支持智能代码补全,可以帮助开发者解答一般编码问题,提供与你正在处理的代码有关的特定问题(包括解释能力)的类似 ChatGPT/Bard 的聊天机器人,以及添加如 “添加注释” 等上下文代码操作的能力。
目前,Project IDX 支持 Angular、Flutter、Next.js、React、Svelte 和 Vue 等框架以及 JavaScript 和 Dart 等语言,后续还将支持 Python、Go 和其他语言。

Project IDX 特性

  • 随时随地快速开始:你能够在任何地方、任何设备上进行开发,具有本地开发的全部保真度。每个 Project IDX 工作空间都具有基于 Linux 的虚拟机的全部功能,配合云中托管的通用访问权限。
  • 一键导入或从模板创建:从 GitHub 导入现有项目,让你继续之前的进度。还可以创建新项目,预先包含流行框架的模板,包括 Angular、Flutter、Next.js、React、Svelte、Vue 和如 JavaScript、Dart 以及(即将推出的)Python、Go 等语言。IDX 还在积极努力为更多项目类型和框架添加一流支持。
  • 跨平台预览:应用成功发布后,需要优化设计和不同平台上的行为(像用户那样预览自己的应用),而 IDX 内置的网络预览(目前只支持 web 预览),和即将推出的完全配置的 Android 模拟器和嵌入式 iOS 模拟器,所有这些都可以直接在浏览器中使用。
  • AI 助手:我们花费大量时间编写代码,而人工智能的最新进展创造了巨大的机会,可以让我们的时间变得更加高效。IDX 正在探索 Google 在 AI 领域的创新 —— 包括 为 Android Studio 中的 Studio Bot 提供支持的 Codey 和 PaLM 2 模型、Google Cloud 中的 Duet [4] 等 —— 可以帮助你不仅更快地编写代码,还可以编写更高质量的代码。
  • 一键部署:在最后,将应用推向生产方面的一个常见痛点是部署它。通过集成 Firebase Hosting,通过几次点击就可以部署你的 Web 应用的可共享预览,或通过快速、安全和全球托管平台部署到生产环境。由于 Firebase Hosting 支持由 Cloud Functions 提供动力的动态后端,所以它非常适合像 Next.js 这样的全栈框架。

  • IDX 申请链接https://idx.dev/

IDX 背后的 AI 模型 Codey

根据介绍,IDX 由 Codey 提供支持。

在 Google I/O 2023 大会上,谷歌正式发布 Codey。这是一款新型 AI 驱动工具,能够编写并理解代码内容。这款新工具被外界视为谷歌对于 GitHub Copilot 的回应,属于同 Replit 结盟打造的成果。

Codey 基于谷歌的下一代大语言模型 PaLM 2,并采用谷歌自家产品代码及大量合法许可的源代码作为训练素材。更重要的是,Codey 仍在不断学习和发展,从谷歌服务生态系统的各个项目中持续汲取新的力量。

Codey 支持 20 多种编程语言,包括 Go、谷歌标准 SQL、Java、JavaScript、Python 以及 TypeScript。开发者可以通过 Visual Studio Code、JetBrains IDE、Google Shell 编辑器以及 Google Cloud 托管工作站服务的扩展来访问 Codey。开发者能够直接在 IDE 的聊天框中与该模型交流(例如 Android Studio Bot),或者在文本文件中编写注释以指示其生成相关代码。它支持各种编码任务,通过以下方式帮助开发人员更快地工作并缩小技能差距:

代码完成:Codey 根据提示中输入的代码上下文建议接下来的几行。

代码生成:小程根据开发人员的自然语言提示生成代码。

代码聊天:Codey 允许开发人员与机器人对话,以获得调试、文档、学习新概念和其他与代码相关问题的帮助。

Codey 在处理与编码相关的提示词方面接受了专门训练,谷歌还通过其他训练让该模型学会了处理关于 Google Cloud 的一般查询。

目前在用的辅助AI开发工具

- CodeGeeX 智能编程助手

  • 代码自动生成和补全 CodeGeeX可以根据自然语言注释描述的功能自动生成代码,也可以根据已有的代码自动生成后续代码,补全当前行或生成后续若干行,帮助你提高编程效率。
  • 提供代码翻译能力 基于AI大模型对代码进行语义级翻译,支持多种编程语言互译
  • 自动添加注释 CodeGeeX可以给代码自动添加行级注释,节省大量开发时间。没有注释的历史代码,也不再是问题。

基于CodeGeeX2: 更强大的多语言代码生成模型
是多语言代码生成模型 CodeGeeX (KDD’23) 的第二代模型。不同于一代 CodeGeeX(完全在国产华为昇腾芯片平台训练) ,CodeGeeX2 是基于 ChatGLM2 架构加入代码预训练实现,得益于 ChatGLM2 的更优性能,CodeGeeX2 在多项指标上取得性能提升(+107% > CodeGeeX;仅 60 亿参数即超过 150 亿参数的 StarCoder-15B 近 10%),更多特性包括:

  • 更强大的代码能力:基于 ChatGLM2-6B 基座语言模型,CodeGeeX2-6B 进一步经过了 600B 代码数据预训练,相比一代模型,在代码能力上全面提升,HumanEval-X 评测集的六种编程语言均大幅提升 (Python +57%, C++ +71%, Java +54%, JavaScript +83%, Go +56%, Rust +321%),在 Python 上达到 35.9% 的 Pass@1 一次通过率,超越规模更大的 StarCoder-15B。
  • 更优秀的模型特性:继承 ChatGLM2-6B 模型特性,CodeGeeX2-6B 更好支持中英文输入,支持最大 8192 序列长度,推理速度较一代 CodeGeeX-13B 大幅提升,量化后仅需 6GB 显存即可运行,支持轻量级本地化部署。
    更全面的 AI 编程助手:CodeGeeX 插件(VS Code, Jetbrains)后端升级,支持超过 100 种编程语言,新增上下文补全、跨文件补全等实用功能。结合 Ask CodeGeeX 交互式 AI 编程助手,支持中英文对话解决各种编程问题,包括且不限于代码解释、代码翻译、代码纠错、文档生成等,帮助程序员更高效开发。
  • 更开放的协议:CodeGeeX2-6B 权重对学术研究完全开放,填写登记表申请商业使用。

- AWS CodeWhisperer

4月18日,亚马逊云科技宣布,实时AI编程助手Amazon CodeWhisperer正式可用,同时推出的还有供所有开发人员免费使用的个人版(CodeWhisperer Individual)。CodeWhisperer帮助开发者基于注释生成代码,追踪开源参考,扫描查找漏洞。此外,还可以帮助开发者创建代码胜任如下场景,比如常规、耗时的无差别任务,或是在使用不熟悉的API或SDK时构建示例代码,亦或要正确高效地使用亚马逊云科技API操作,还有其他场景比如编写读写文件、处理图像、编写单元测试等代码。

Amazon CodeWhisperer 为多种编程语言提供基于人工智能(AI)的代码建议,包括 Python、Java、JavaScript、TypeScript、C#、Go、Rust、PHP、Ruby、Kotlin、C、C++、Shell 脚本、SQL 和 Scala。您可以使用来自多个 IDE 的服务,包括 JetBrains IDE(IntelliJ IDEA、PyCharm、WebStorm 和 Rider)、Visual Studio(VS)Code、AWS Cloud9、AWS Lambda 控制台、JupyterLab 和 Amazon SageMaker Studio。

- Poe

国内可注册,使用方便,免费,有针对代码和程序专门优化的gpt-3.5-turbo模型 Assistant
当然还有其他的模型,包含一些限额和付费的,你也可以创建自己的bot

  • Claude-2-100k
  • Claude-instant-100k
  • gpt-4-32k(Powered by gpt-4-32k. Since this is a beta model, the usage limit is subject to change.)
  • Google-PaLM(Powered by Google's PaLM 2 chat-bison-001 model)

CodeGeeX2AWS CodeWhisperer都提供了VSCode的插件可以使用,体验下来,CodeGeeX2支持的文件类型更多一点,而且反应也还算迅速,AWS CodeWhisperer只支持了主流开发语言,对于如dart、Vue、markdown、CSS等非主流编程语言支持效果很差,目前两者都免费,体验下来CodeGeeX2更不错一点,根据注释生成代码,给现有代码生成注释,代码翻译,纠错等功能也比较实用

Some thoughts🤔

看来云上开发将来一定是主流趋势,程序员再也不用因为换设备,换环境而苦恼,集成式一体开发环境,开箱即用,开发随时可中断,可恢复,不受限于设备,环境,地点影响
而且集成AI代码助手,各大厂家都在发力了,微软的GitHub Copilot,亚马逊的CodeWhisperer,还有即将到来的Google的Project IDX 方便快捷的开发,将大量繁琐重复的工作交给AI来执行

That's It

参考资料:

☑️ ☆

Vue常见优化手段

相关信息

永远不要过早优化,优化也有相应的代价

  • 开发时间变长
  • 开发成本增加
  • 代码难以阅读
  • 增加维护成本

何时优化,因地制宜,是一门艺术,尽量把优化的思想带入写代码的过程中

本文章的优化手段基于vue2

服务端渲染 SSR or 预渲染

客户端渲染:使用 JavaScript 框架进行页面渲染
服务端渲染:服务端将HTML文本组装好,并返回给浏览器,这个HTML文本被浏览器解析之后,不需要经过 JavaScript 脚本的执行,即可直接构建出希望的 DOM 树并展示到页面中,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。

优点:

更好的 SEO,由于搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。对客户端渲染的页面来说,简直无能为力,因为返回的HTML是一个空壳,它需要执行 JavaScript 脚本之后才会渲染真正的页面。
用户将会更快速地看到完整渲染的页面

缺点:

为了实现服务端渲染,应用代码中需要兼容服务端和客户端两种运行情况
由于服务器增加了渲染HTML的需求,使得原本只需要输出静态资源文件的nodejs服务,新增了数据获取的IO和渲染HTML的CPU占用,
服务器渲染应用程序,需要处于 Node.js server 运行环境。

如何实现?

想要在服务器端渲染,我们需要做什么呢?那就是同构我们的项目,Vue.js 是构建客户端应用程序的框架,服务器渲染的 Vue.js 应用程序也可以被认为是"同构"或"通用",因为应用程序的大部分代码都可以在服务器和客户端上运行

当运行在不同环境中时,我们的代码将不会完全相同,同构就是让一份代码,既可以在服务端中执行,也可以在客户端中执行,并且执行的效果都是一样的,都是完成这个html的组装,正确的显示页面。
对于同构应用来说,我们必须实现客户端与服务端的路由、模型组件、数据模型的共享。

服务器端渲染注意事项

为避免造成交叉请求状态污染,每个请求应该都是全新的、独立的应用程序实例。
由于没有动态更新,所有的生命周期钩子函数中,只有 beforeCreate 和 created 会在服务器端渲染(SSR)过程中被调用。
通用代码不可接受像 window 或 document,这种仅浏览器可用的全局变量
浏览器可能会更改的一些特殊的 HTML 结构,例如,浏览器会在

内部自动注入 ,然而,由于 Vue 生成的虚拟 DOM(virtual DOM) 不包含 ,所以会导致无法匹配。

使用key

对于通过循环生成的列表,应给每个列表项一个稳定且唯一的key,这有利于在列表变动时,尽量少的删除、新增、改动元素

使用冻结的对象

冻结的对象不会被响应化,如果对象很多,嵌套结构很深,遍历过程需要花费很多时间,如果对象不需要动态更改,可以使用冻结对象,如:商品列表等纯展示页面,并不会通过用户交互来更改

var obj = {a:1,b:2}
// 冻结对象
Object.freeze(obj)
// 尝试更改
obj.a = 3
console.log(obj)
// 打印
{a:1,b:2}
// 验证
Object.isFrozen(obj)
// 结果
<.true

vue在处理过程中,如果发现对象是冻结对象,就不会去遍历对象,不会变成响应式

下面是1000000个对象的加载过程

vue

冻结对象

可见vue把对象深度遍历成为响应式,对于大量结构复杂的数据来说,是很耗时间的

使用函数式组件

函数式组件,设置functional:true,函数式组件没有data,这以为它无状态(没有响应式数据

,也没用实例(没有this上下文),所以组件树中不存在函数式组件,一个函数式组件就像这样

Vue.component('my-component', {
  functional: true,
  // Props 是可选的
  props: {
    // ...
  },
  // 为了弥补缺少的实例
  // 提供第二个参数作为上下文
  render: function (createElement, context) {
    // ...
  }
})

注意:在 2.3.0 之前的版本中,如果一个函数式组件想要接收 prop,则 props 选项是必须的。在 2.3.0 或以上的版本中,你可以省略 props 选项,所有组件上的 attribute 都会被自动隐式解析为 prop。

当使用函数式组件时,该引用将会是 HTMLElement,因为他们是无状态的也是无实例的。

在 2.5.0 及以上版本中,如果你使用了单文件组件,那么基于模板的函数式组件可以这样声明:

<template functional>
</template>

组件需要的一切都是通过 context 参数传递,它是一个包括如下字段的对象:

  • props:提供所有 prop 的对象
  • children:VNode 子节点的数组
  • slots:一个函数,返回了包含所有插槽的对象
  • scopedSlots:(2.6.0+) 一个暴露传入的作用域插槽的对象。也以函数形式暴露普通插槽。
  • data:传递给组件的整个数据对象,作为 createElement 的第二个参数传入组件
  • parent:对父组件的引用
  • listeners:(2.3.0+) 一个包含了所有父组件为当前组件注册的事件监听器的对象。这是 data.on 的一个别名。
  • injections:(2.3.0+) 如果使用了 inject 选项,则该对象包含了应当被注入的 property。

在添加 functional: true 之后,需要更新我们的锚点标题组件的渲染函数,为其增加 context 参数,并将 this.$slots.default 更新为 context.children,然后将 this.level 更新为 context.props.level

因为函数式组件只是函数,所以渲染开销(时间内存)也低很多。

在作为包装组件时它们也同样非常有用。比如,当你需要做这些时:

  • 程序化地在多个组件中选择一个来代为渲染;
  • 在将 childrenpropsdata 传递给子组件之前操作它们。

下面是一个 smart-list 组件的例子,它能根据传入 prop 的值来代为渲染更具体的组件:

var EmptyList = { /* ... */ }
var TableList = { /* ... */ }
var OrderedList = { /* ... */ }
var UnorderedList = { /* ... */ }

Vue.component('smart-list', {
  functional: true,
  props: {
    items: {
      type: Array,
      required: true
    },
    isOrdered: Boolean
  },
  render: function (createElement, context) {
    function appropriateListComponent () {
      var items = context.props.items

      if (items.length === 0)           return EmptyList
      if (typeof items[0] === 'object') return TableList
      if (context.props.isOrdered)      return OrderedList

      return UnorderedList
    }

    return createElement(
      appropriateListComponent(),
      context.data,
      context.children
    )
  }
})

使用计算属性

如果模版中某个数据会使用多次,并且该数据是通过计算得到的,使用计算属性以缓存它们

非实时绑定的表单项

当使用v-model绑定一个表单项时,当用户改变表单项的状态时,也会随之改变数据,从而导致vue发生重新渲染(rerender),这会带来一些性能的开销。

我们可以通过使用lazy或不使用v-model的方式解决该问题,但要注意,这样可能会导致在某一个时间段内数据和表单项的值是不一致的。

vue设计思想是关注的是数据而不是界面,代码的可维护性和可阅读性也很重要,js执行线程和浏览器渲染线程是互斥的,所以运行动画时执行jS线程动画会卡顿

如双向绑定的文本框输入的内容改变,输入abcd,会进行4次重新渲染,可以使用v-model.lazy,监听@change,不使用监听的是@input

保持对象引用稳定

在绝大部分情况下,vue出发rerender的时机是其依赖的数据发生变化

若数据没有发生变化,哪怕给数据重新赋值了,vue也不会做出任何处理的

下面是vue判断数据没有变化的源码

function hasChanged(x,y) {
  if (x === y) {
    return x === 0 && 1 / x !== 1/y
  }else {
    return x === x || y === y
  }
}

因此,如果需要,只要能保证组件的依赖数据不发生变化,组件就不会重新渲染

对于原始数据类型,保持其值不变即可

对于对象类型,保持其引用不变即可

使用v-show替代v-if

对于频繁切换显示状态的元素,使用v-show可以保证虚拟的dom树的稳定,避免频繁的新增和删除元素,特别是对于那些内部包含大量dom元素的节点,这一点及其重要

使用延迟装载defer

首页白屏时间主要受到两个因素的影响:

  • 打包体积过大

    巨型包需要消耗大量的传输时间,导致JS传输完成前页面只有一个<div>,没有可显示的内容

  • 需要立即渲染的内容太多

JS传输完成后,浏览器开始执行JS构造页面

但可能一开始要渲染的组件太多,不仅JS的事件很长,而且执行完后浏览器要渲染的元素过多,从而导致页面白屏loading过久

一个可行的办法就是延迟装载组件,让组件按照指定的先后顺序依次一个一个渲染出来

提示

延迟装载是一个思路,本质上就是利用requestAnimationFrame事件分批渲染内容,它的具体实现多种多样

告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行

callback: 下一次重绘之前更新动画帧所调用的函数(即上面所说的回调函数)。该回调函数会被传入DOMHighResTimeStamp参数,该参数与performance.now()的返回值相同,它表示requestAnimationFrame() 开始去执行回调函数的时刻。

思路:浏览器渲染1s渲染60次,第一次渲染一部分,第二次一部分,隔开渲染,分批绘制

// defer.js
export default function(maxFrameCount) {
  return {
    data(){
      return{
        // 浏览器每重绘一次,计数
        frameCount:0,
      };
    },
    mounted() {
      const refreshFrameCount = () => {
        requestAnimationFrame(() => {
        	this.frameCount++;
          if(this.frameCount < maxFrameCount) {
            refreshFrameCount();
          }
        });
      };
      refreshFrameCount();
    },
    methods: {
      defer(showInFrameCount) {
        // 用于v-if 的判断条件,渲染次数大于showInFrameCount后继续下一次渲染
        return this.frameCount >= showInFrameCount
      },
    },
  };
}
<template>
  <div class="container">
    <!--vue3.0 v-if 优先级高 vue2.x v-for优先级高-->
    <div class="block" v-for="n in 20" v-if="defer(n)">
      <heavy-comp></heavy-comp>
    </div>
  </div>
</template>
<script>
import defer from "./mixin/defer";
export default {
  mixins: [defer(300)],
  components: { HeavyComp },
};
</script>

使用keep-alive

用于缓存内部组件实例,里面有include和exclude属性,max设置最大缓存数,超过后,自动删除最久没用的。

受到keep-alive影响,其内部的组件都具有两个生命周期,activateddeactivated ,分别再组件激活和失活时触发,第一次activated是在mounted之后。

一般用在需要多个页面频繁操作的场景(导航条)

长列表优化

一般用在app端下拉的时候,或者列表很长的时候,通过一个固定大小的渲染池来解决。通过滚动条等一些操作,减少页面渲染市场,有现成的库,vue-virtual-scroller

https://github.com/Akryum/vue-virtual-scroller

通过v-once创建低开销的静态组件,渲染一次后就缓存起来了,除非你非常留意渲染速度,不然最好不要用,因为有的开发者不知道这个属性或者看漏了,然后花费好几个小时来找为什么模板无法正确更新。

打包体积优化

  • Webpack 对图片进行压缩
  • 静态资源的优化使用对象存储加CDN
  • 减少 ES6 转为 ES5 的冗余代码
  • 提取公共代码
  • 模板预编译
  • 提取组件的 CSS
  • 优化 SourceMap
  • 构建结果输出分析
  • Vue 项目的编译优化

基础优化

  • 开启 gzip 压缩
  • 浏览器缓存
  • CDN 的使用
  • 使用 Chrome Performance 查找性能瓶颈

☑️ ☆

Vue2响应式原理解析

前言

首先要知道vue2 是2013年 基于 ES5开发出来的,我们常说的重渲染就是重新运行render函数

vue2 的响应式原理是利⽤ES5 的⼀个 API ,Object.defineProperty()对数据进⾏劫持结合发布订阅模式的⽅式来实现的。

1. 思路

带入作者的角度思考一下,想要达成响应式的特点应该是:属性更新,自动调用依赖[1]的函数进行重新渲染

  1. 使用属性描述符Object.defineProperty监听属性的赋值
  2. 赋值完成后调用依赖该属性的函数,那如何获取依赖的函数呢?看第三点
  3. 由于依赖会调用属性的get方法,所以可以在get方法中收集依赖
  4. 然后在set方法中执行这些依赖的函数,称为派发更新

基于以上思路的简单实现代码:

// euv.js
/**
 * 观察某个对象的所有属性
 * @param {Object} obj
 */
function observe(obj) {
  for (const key in obj) {
    let internalValue = obj[key];
    let funcs = [];
    Object.defineProperty(obj, key, {
      get: function () {
        //  依赖收集,记录:是哪个函数在用我
        if (window.__func && !funcs.includes(window.__func)) {
          funcs.push(window.__func);
        }
        return internalValue;
      },
      set: function (val) {
        internalValue = val;
        // 派发更新,运行:执行用我的函数
        for (var i = 0; i < funcs.length; i++) {
          funcs[i]();
        }
      },
    });
  }
}

function autorun(fn) {
  window.__func = fn;
  fn();
  window.__func = null;
}
// index.js
var user = {
  name: '有骨气',
  birth: '1998-4-7',
};

observe(user); // 观察

// 显示姓氏
function showFirstName() {
  document.querySelector('#firstName').textContent = '姓:' + user.name[0];
}

// 显示名字
function showLastName() {
  document.querySelector('#lastName').textContent = '名:' + user.name.slice(1);
}

// 显示年龄
function showAge() {
  var birthday = new Date(user.birth);
  var today = new Date();
  today.setHours(0), today.setMinutes(0), today.setMilliseconds(0);
  thisYearBirthday = new Date(
    today.getFullYear(),
    birthday.getMonth(),
    birthday.getDate()
  );
  var age = today.getFullYear() - birthday.getFullYear();
  if (today.getTime() < thisYearBirthday.getTime()) {
    age--;
  }
  document.querySelector('#age').textContent = '年龄:' + age;
}
autorun(showFirstName);
autorun(showLastName);
autorun(showAge);

2. 原理

vue2响应式原理简单来说就是vue官网上的这图片

原理图
原理图

通过 Object.defineProperty 遍历对象的每一个属性,把每一个属性变成一个 gettersetter 函数,读取属性的时候调用 getter,给属性赋值的时候就会调用 setter.

当运行 render 函数的时候,发现用到了响应式数据,这时候就会运行 getter 函数,然后 watcher(发布订阅)就会记录下来。当响应式数据发生变化的时候,就会调用 setter 函数,watcher 就会再记录下来这次的变化,然后通知 render 函数,数据发生了变化,然后就会重新运行 render 函数,重新生成虚拟 dom 树。

3. 深入了解

我们要明白,响应式的最终目标:是当对象本身或对象属性发生变化时,会运行一些函数,最常见的就是 render 函数。不是只有 render,只要数据发生了变化后运行了一些函数,就是响应式,比如 watch。

在具体实现上,vue 采用了几个核心部件:

  1. Observer

  2. Dep

  3. Watcher

  4. Scheduler

4. Observer

observer 要实现的目标非常简单,就是把一个普通的对象转换成响应式的对象

为了实现这一点,observer 把对象的每个属性通过 object.defineProperty 转换为带有 gettersetter 的属性,这样一来,当访问或者设置属性时,vue 就会有机会做一些别的事情。

在组件的生命周期中,这件事发生在 beforeCreate 之后,create 之前。

具体实现上,他会递归遍历对象的所有属性,以完成深度的属性转换。

但是由于遍历只能遍历到对象的当前属性,无法监测到将来动态添加或者删除的属性,因此 vue 提供了$set$delete 两个实例方法,但是 vue 并不提倡这样使用,我讲到 dep 的时候我再说为什么。

对于数组的话,vue 会更改它的隐式原型,之所以这样做是因为 vue 需要监听那些可能改变数组内容的方法。

数组 --> vue 自定义的对象 --> Array.prototype

总之,observer 的目标,就是要让一个对象,它属性的读取,赋值,内部数组的变化都要能够被 vue 感知到。

4.1. 手动转换响应式对象

Vue提供了静态方法:Vue.observable() 手动将普通对象转为响应式对象。

var obj = {
    a: 1,
    b: 2,
    c: {
        d: 3,
        e: 4
    },
    f: [
        {
            a: 1,
            b: 2
        },
        3, 4, 5, 6
    ]
}

// 利用Vue提供的静态方法 .observable, 将一个普通对象转化为响应式对象
var reactiveObj = Vue.observable(obj)
console.log(reactiveObj)

4.2. data

Vue不允许动态添加根级响应式属性,所以需要在组件实例化之前通过配置中的 data 字段,声明所有根级响应式属性,哪怕属性值为 null。由此带来的好处有:

  1. 更易于维护: data对象就像组件的状态结构(schema), 提前声明所有响应式属性,后期有助于开发者理解和修改组件逻辑。
  2. 消除了在依赖项跟踪系统中的一类边界情况。
  3. 使Vue实例能够更好的配合类型检查系统工作。

4.3. 「动态添加或删除属性」

由于Vue会在初始化实例时,对所有属性(配置里 data 中存在的属性)执行 getter/setter 的转化。

那么对于 「动态添加或删除」 的属性,Vue是无法自动检查其变化。

因此,Vue提供了以下方式来手动完成响应式数据。

  1. 添加:「Vue.set(target, key, val)」「this.$set(target, key, val)」
  2. 删除:「Vue.delete(target, key)」「this.$delete(target, key)」
  3. 批量操作:this.reactiveObj = Object.assign({}, this.reactiveObj, obj)

举个例子:

<template>
    <div class="demo-wrapper">
        <p>obj.a -> {{ obj.a }}, obj.b -> {{ obj.b }}</p>

        <!-- 非响应式式数据操作 -->
        <!-- 
            <button @click="obj.b = 2">add obj.b</button>
            <button @click="delete obj.a">delete obj.a</button>
        -->
        <!-- 响应式数据操作 -->
        <button @click="$set(obj, 'b', 2)">add obj.b</button>
        &nbsp;
        <button @click="$delete(obj, 'a')">delete obj.a</button>
    </div>
</template>

<script>
export default {
    data() {
        return {
            obj: {
                a: 1,
            },
        };
    },
};
</script>

4.4. 「关于数组」

由于js的限制, Vue不能检测到以下数组变动:

  1. 当利用索引直接改变数组项时, 例如:vm.arr[idx] = newValue
  2. 当修改数组长度时 ,例如: vm.arr.length = newLength

举个例子:

<script>
export default {
    data() {
        return {
            arr: [1, 2, 3, 4],
        };
    },
    created() {
        window.lesson4 = this;
    },
    mounted() {
        this.arr[0] = 8; // 不是响应式的
        this.arr.length = 2; //不是响应式的
    }
};
</script>

为了让上述数组操作具有响应式,采用以下方法处理:

<script>
export default {
    data() {
        return {
            arr: [1, 2, 3, 4],
        };
    },
    created() {
        window.lesson4 = this;
    },
    mounted() {
				// 操作一:通过索引修改数组项
        this.$set(arr, 0, 8); // 响应式的
        // 或
        Vue.set(arr, 0, 8);  // 响应式的
        // 或
        this.arr.splice(0, 1, 8) //响应式的
      
        // 操作二:修改数组长度为2
        this.arr.splice(2) // 响应式的
    }
};
</script>

除了可以通过静态方法 Vue.set() 和 实例方法 this.$set() 响应式的修改数组项的值。还可以使用数组方法 - splice()

因为,Vue对一些可以改变数组自身内容的操作API,如:splice()、sort()、push()、pop()、reverse()、shift()、unshift() 等进行了拦截和重写。从而在开发者使用这些API时,可以触发响应式数据,进而更新视图。

<script>
  export default{
    data() {
      return {
        arr: [1,2,3,4]
      }
    },
    mounted() {
      console.log(this.arr._proto_ === Array.prototype)  // => false
      console.log(this.arr._proto_._proto_ === Array.prototype)  //=> true
    }
  }
</script>

5. Dep

这里有两个问题没解决,就是读取属性时要做什么事,属性变化时又要做什么事,这个问题就得需要 dep 来解决

dep 的含义是 dependency 表示依赖的意思。

vue 会为响应式对象中的每一个属性,对象本身,数组本身创建一个 dep 实例,每个 dep 实例都可以做两件事情:

1,记录依赖:是谁在用我

2,派发更新:我变了,我要通知那些用我的人

当读取响应式对象的某一个属性时,他会进行依赖收集,有人用到了我

当改变某个属性时,他会派发更新,那些用我的人听好了,我变了

为什么尽量不要使用$set $delete ?

因为如果模板上没有用到值的话,你凭空加了一个数据,理论上来说应该不会重新运行render函数,但是上一级的dep发现自身发生改变了,所以也会导致重新运行render函数。

所以vue不建议使用$set$delete,最好提前先写上数据,哪怕先给数据赋值为 null;

6. watcher

这里又出现了一个问题,就是 dep 如何知道是谁在用我呢

watcher 就解决了这个问题

当函数执行的过程中,用到了响应式数据,响应式数据是无法知道是谁在用自己的

所以,我们不要直接执行函数,而是把函数交给一个 watcher 的东西去执行,watch 是一个对象,每个函数执行时都应该创建一个 watcher,通过 wacher 去执行

watcher 会创建一个全局变量,让全局变量记录当前负责执行的 watcher 等于自己,然后再去执行函数,在函数执行的过程中,如果发生了依赖记录,那么 dep 就会把这个全局变量记录下来,表示有一个 wathcer 用到了我这个属性。

当 dep 进行派发更行时,他会通知之前记录的所有 watcher,我变了。

7. Scheduler

现在还剩下最后一个问题啊,就是 dep 通知 watcher 之后,如果 wathcer 执行重新运行对应的函数,就有可能导致频繁运行,从而导致效率低下,试想,如果一个交给 watcher 的函数,它里面用到了属性 a,b,c,d,那么 a,b,c,d 都会记录依赖,然后这四个值都以此重新赋值,那么就会触发四次更新,这样显然不行啊,所以当 watcher 收到派发更新的通知后,实际上并不是立即执行,而是通过一个叫做 nextTick 的工具方法,把这些需要执行的 watcher 放到事件循环的微队列,nextTick 是通过 Promise then 来完成的。

也就是说,在响应式数据发生变化时,render 函数执行是异步的,并且在微队列中。

8. 异步更新队列

Vue侦听到数据变化,就会开启一个队列。但是组件不会立即重新渲染,而是先会缓冲在同一个事件循环中的发生的所有数据变化。此时如果同一个watcher被多次触发,只会被推入到队列中一次,这样可以**「避免不必要的计算和DOM更操作」**。
在下一个事件循环”tick“中, Vue刷新队列并执行实际(已去重的)工作(更新渲染)。
为此,Vue提供了异步更新的监听接口 —— Vue.nextTick(callback)this.$nextTick(callback) 。当数据发生改变,异步DOM更新完成后,callback回调将被调用。开发者可以在回调中,操作更新后的DOM。

「举例1」

<script>
export default {
    data() {
        return {
            a: 1,
            b: 2,
            c: 3,
            d: 4,
        };
    },
    methods: {
        changeAllData() {
            this.$nextTick(function () {
                var pre = document.querySelector("pre");
                console.log(pre.textContent);
            });

            this.a = this.b = this.c = this.d = 10;

            this.$nextTick(function () {
                var pre = document.querySelector("pre");
                console.log(pre.textContent);
            });
        },
    },
    render(h) {
        console.log('render function')
        return h('div', [
            h('pre', `${this.a}, ${this.b}, ${this.c}, ${this.d}`),
            h('button', {
                on: {
                    click: () => {
                        this.changeAllData()
                    }
                }
            }, 'change all data')
        ])
    }
};
</script>

上例,通过一个NextTick组件的渲染,了解下 的用法。为了方便查看组件渲染时,render函数被调用的过程,在组件定义时,直接给出render函数。当点击按钮后,会在数据修改前后,使用nextTick工具方法。分别写入两个读取界面Dom的函数。结果会发现,第一个 $nextTick 回调函数获取的数据为旧数据,第二个 $nextTick回调函数获取的数据为新数据。

分析一下:

按钮点击后,异步队列的添加步骤是:

  1. 第一个 $nextTick ,会将自己的回调函数(fn1)加入到当前的异步队列中。
  2. 修改数据后, 经过派发更新,Scheduler会将包含了watcher队列执行逻辑的函数(fn2)加入到当前的异步队列中。
  3. 第二个 $nextTick, 已将自己的回调函数(fn3)加入到当前的异步队列中。

当异步队列执行时,会依次执行 fn1 , fn2,fn3。而当fn2执行后,界面才会更新最新数据,所以fn1,fn3获取的界面数据前者为旧数据,后者为新数据。

「举例2」

<template>
  <span>{{a}}</span>
</template>
<script>
	export default {
    data() {
      return {
        a: 'hello'
      }
    },
    mounted() {
      this.a = 'world'
      console.log(this.$el.textContent)  // -> 'hello'
      this.$nextTick(function() {
        console.log(this.$el.textContent) // -> 'world'
      })
    }
  }
</script>

上面代码,当设置 this.a = 'world' 后,访问DOM元素内容,但完成未更新。此时,立即使用 this.$nextTick() 监听DOM更新,并在监听回调调用时,获取更新后的DOM内容。

另外, this.$nextTick() 其内部尝试使用原生的 Promise.thenMutationObservesetImmediate,如果执行环境不支持,则会采用 setTimeout 替代。并且最终返回一个Promise对象,所以可以使用 async/await 语法替代 callback的写法。

<script>
	export default {
    data() {
      return {
        a: 'hello'
      }
    },
    // $nextTick 结合 async/await语法使用
    async mounted() {
      this.a = 'world'
      console.log(this.$el.textContent)  // -> 'hello'
      await this.$nextTick()
      console.log(this.$el.textContent) // -> 'world'
    }
  }
</script>

9. 总流程图


  1. 依赖:某个函数在运行期间用到这个属性的get方法 ↩︎

🔲 ☆

Dart 中的并发

Dart 通过 async-await、isolate 以及一些异步类型概念(例如 FutureStream)支持了并发代码编程。本篇文章会对 async-await、FutureStream 进行简略的介绍,而侧重点放在 isolate 的讲解上。

在应用中,所有的 Dart 代码都在 isolate 中运行。每一个 Dart 的 isolate 都有独立的运行线程,它们无法与其他 isolate 共享可变对象。在需要进行通信的场景里,isolate 会使用消息机制。很多 Dart 应用都只使用一个 isolate,也就是 main isolate。你可以创建额外的 isolate 以便在多个处理器核心上执行并行代码。

尽管 Dart 的 isolate 模型设计是基于操作系统提供的进程和线程等更为底层的原语进行设计的, Dart 虚拟机对其的使用是一个具体的实现,在本篇文章中,我们不对其具体实现展开讨论。

异步的类型和语法

如果你已经对 FutureStream 和 async-await 比较熟悉了,可以直接跳到 isolate 部分 进行阅读。

Future 和 Stream 类型

Dart 语言和库通过 FutureStream 对象,来提供会在当前调用的未来返回某些值的功能。以 JavaScript 中的 Promise 为例,在 Dart 中一个最终会返回 int 类型值的 promise,应当声明为 Future<int>;一个会持续返回一系列 int 类型值的 promise,应当声明为 Stream<int>

让我们用 dart:io 来举另外一个例子。File 的同步方法 readAsStringSync() 会以同步调用的方式读取文件,在读取完成或者抛出错误前保持阻塞。这个会返回 String 类型的对象,或者抛出异常。而与它等效的异步方法 readAsString(),会在调用时立刻返回 Future<String> 类型的对象。在未来的某一刻,Future<String> 会结束,并返回一个字符串或错误。

为什么异步的代码如此重要?

It matters whether a method is synchronous or asynchronous because most apps need to do more than one thing at a time.

大部分应用需要在同一时刻做很多件事。例如,应用可能会发起一个 HTTP 请求,同时在请求返回前对用户的操作做出不同的界面更新。异步的代码会有助于应用保持更高的可交互状态。

异步场景包括调用系统 API,例如非阻塞的 I/O 操作、HTTP 请求或与浏览器交互。还有一些场景是利用 Dart 的 isolate 进行计算,或等待一个计时器的触发。这些场景要么是在不同的线程运行,要么是被系统或 Dart 运行时处理,让 Dart 代码可以在计算时同步运行。

async-await 语法

asyncawait 关键字是用声明来定义异步函数和获取它们的结果的方式。

下面是一段同步代码调用文件 I/O 时阻塞的例子:

void main() {
  // Read some data.
  final fileData = _readFileSync();
  final jsonData = jsonDecode(fileData);

  // Use that data.
  print('Number of JSON keys: ${jsonData.length}');
}

String _readFileSync() {
  final file = File(filename);
  final contents = file.readAsStringSync();
  return contents.trim();
}

下面是类似的代码,但是变成了 异步调用

void main() async {
  // Read some data.
  final fileData = await _readFileAsync();
  final jsonData = jsonDecode(fileData);

  // Use that data.
  print('Number of JSON keys: ${jsonData.length}');
}

Future<String> _readFileAsync() async {
  final file = File(filename);
  final contents = await file.readAsString();
  return contents.trim();
}

main() 函数在调用 _readFileAsync() 前使用了 await 关键字,让原生代码(文件 I/O)执行的同时,其他的 Dart 代码(例如事件处理器)能继续执行。使用 await 后,_readFileAsync() 调用返回的 Future<String> 类型也转换为了 String。从而在将结果 content 赋予变量时,隐式转换为 String 类型。

备注:

await 关键字仅在函数体前定义了 async 的函数中有效。

如下图所示,无论是在 Dart VM 还是在系统中, Dart 代码都会在 readAsString() 执行非 Dart 代码时暂停。在 readAsString() 返回值后,Dart 代码将继续执行。

类似于流程图的图形显示了应用程序代码从开始到退出的执行过程,在这之间等待本地I/O
类似于流程图的图形显示了应用程序代码从开始到退出的执行过程,在这之间等待本地I/O

如果你想了解更多关于 asyncawaitFuture 的内容,可以访问 异步编程 codelab 进行学习。

Isolate 的工作原理

现代的设备通常会使用多核 CPU。开发者为了让程序在设备上有更好的表现,有时会使用共享内容的线程来并发运行代码。然而,状态的共享可能会 产生竞态条件,从而造成错误,也可能会增加代码的复杂度。

Dart 代码并不在多个线程上运行,取而代之的是它们会在 isolate 内运行。每一个 isolate 会有自己的堆内存,从而确保 isolate 之间互相隔离,无法互相访问状态。由于这样的实现并不会共享内存,所以你也不需要担心 互斥锁和其他锁

在使用 isolate 时,你的 Dart 代码可以在同一时刻进行多个独立的任务,并且使用可用的处理器核心。 Isolate 与线程和进程近似,但是每个 isolate 都拥有独立的内存,以及运行事件循环的独立线程。

Platform note: Only the Dart Native platform implements isolates. To learn more about the Dart Web platform, see the Concurrency on the web section.

平台说明

只有Dart Native平台实现了隔离器。要了解更多关于Dart网络平台的信息,请参见Web的并发性部分

主 isolate

在一般场景下,你完全无需关心 isolate。通常一个 Dart 应用会在主 isolate 下执行所有代码,如下图所示:

图中显示了一个主隔离区,它运行,对事件作出反应,然后退出
图中显示了一个主隔离区,它运行main(),对事件作出反应,然后退出

就算是只有一个 isolate 的应用,只要通过使用 async-await 来处理异步操作,也完全可以流畅运行。一个拥有良好性能的应用,会在快速启动后尽快进入事件循环。这使得应用可以通过异步操作快速响应对应的事件。

Isolate 的生命周期

如下图所示,每个 isolate 都是从运行 Dart 代码开始的,比如 main() 函数。执行的 Dart 代码可能会注册一些事件监听,例如处理用户操作或文件读写。当 isolate 执行的 Dart 代码结束后,如果它还需要处理已监听的事件,那么它依旧会继续被保持。处理完所有事件后,isolate 会退出。

一个更一般的图显示,任何隔离体都会运行一些代码,选择性地对事件做出反应,然后退出
一个更一般的图显示,任何隔离体都会运行一些代码,选择性地对事件做出反应,然后退出

事件处理

在客户端应用中,主 isolate 的事件队列内,可能会包含重绘的请求、点击的通知或者其他界面事件。例如,下图展示了包含四个事件的事件队列,队列会按照先进先出的模式处理事件。

一个显示事件被逐一送入事件循环的图。
一个显示事件被逐一送入事件循环的图。

如下图所示,在 main() 方法执行完毕后,事件队列中的处理才开始,此时处理的是第一个重绘的事件。而后主 isolate 会处理点击事件,接着再处理另一个重绘事件。

显示主隔离区逐一执行事件处理程序的图
显示主隔离区逐一执行事件处理程序的图

如果某个同步执行的操作花费了很长的处理时间,应用看起来就像是失去了响应。在下图中,处理点击事件的代码比较耗时,导致紧随其后的事件并没有及时处理。这时应用可能会产生卡顿,所有的动画都无法流畅播放。

图中显示了一个执行时间过长的分接处理程序
图中显示了一个执行时间过长的分接处理程序

在一个客户端应用中,耗时过长的同步操作,通常会导致 卡顿的动画。而最糟糕的是,应用界面可能完全失去响应。

后台运行对象

如果你的应用受到耗时计算的影响而出现卡顿,例如 解析较大的 JSON 文件,你可以考虑将耗时计算转移到单独工作的 isolate,通常我们称这样的 isolate 为 后台运行对象。下图展示了一种常用场景,你可以生成一个 isolate,它将执行耗时计算的任务,并在结束后退出。这个 isolate 工作对象退出时会把结果返回。

A figure showing a main isolate and a simple worker isolate
A figure showing a main isolate and a simple worker isolate

每个 isolate 都可以通过消息通信传递一个对象,这个对象的所有内容都需要满足可传递的条件。并非所有的对象都满足传递条件,在无法满足条件时,消息发送会失败。举个例子,如果你想发送一个 List<Object>,你需要确保这个列表中所有元素都是可被传递的。假设这个列表中有一个 Socket,由于它无法被传递,所以你无法发送整个列表。

你可以查阅 send() 方法 的文档来确定哪些类型可以进行传递。

Isolate 工作对象可以进行 I/O 操作、设置定时器,以及其他各种行为。它会持有自己内存空间,与主 isolate 互相隔离。这个 isolate 在阻塞时也不会对其他 isolate 造成影响。

代码示例

本节将重点讨论使用 Isolate API 实现 isolate 的一些示例。

实现一个简单的 isolate 工作对象

这些例子实现了一个主隔离器,它生成了一个简单的工作隔离器。 Isolate.run() 简化了设置和管理工作者隔离区的步骤:

  1. 生成(启动并创建)一个隔离器
  2. 在生成的隔离体上运行一个函数
  3. 捕获结果
  4. 将结果返回给主隔离区
  5. 工作完成后,终止隔离区的运行
  6. 检查、捕获并将异常和错误抛回给主隔离区

备注

如果你使用Flutter,考虑使用Flutter的compute()函数而不是Isolate.run()compute函数允许你的代码在本地和非本地平台上工作。当只针对原生平台时,使用Isolate.run()以获得更符合人类工程学的的API。

在一个新的隔离区中运行一个现有的方法

主 isolate 的代码如下:

void main() async {
  // Read some data.
  final jsonData = await Isolate.run(_readAndParseJson);

  // Use that data.
  print('Number of JSON keys: ${jsonData.length}');
}

生成的隔离器会执行作为第一个参数传递的函数, _readAndParseJson:

Future<Map<String, dynamic>> _readAndParseJson() async {
  final fileData = await File(filename).readAsString();
  final jsonData = jsonDecode(fileData) as Map<String, dynamic>;
  return jsonData;
}
  1. Isolate.run() 产生了一个隔离器,即后台工作者, 而 main() 则在等待结果
  2. 生成的隔离器会执行传递给 run()的参数: the function _readAndParseJson().
  3. Isolate.run() 从返回中获取结果,并将该值送回主隔离区,从而关闭工作隔离区。
  4. 工作者隔离区会将保存结果的内存转移到主隔离区。它并不复制数据。工作者隔离区会执行一个验证通道,以确保对象被允许转移。

_readAndParseJson() 是一个现有的异步函数,可以很容易地直接在主隔离区中运行。使用 Isolate.run() 来运行它,从而实现了并发性。工作者隔离区完全抽象了_readAndParseJson()的计算过程。它可以在不阻塞主隔离区的情况下完成。

Isolate.run() 的结果总是一个Future,因为主隔离区的代码仍在继续运行。工作者隔离区执行的计算是同步的还是异步的,并不影响主隔离区,因为无论如何,它都是在并发地运行。

Sending closures with isolates

您也可以在主隔离区中直接使用函数字面或闭包,用run() 创建一个简单的工作隔离区。

void main() async {
  // Read some data.
  final jsonData = await Isolate.run(() async {
    final fileData = await File(filename).readAsString();
    final jsonData = jsonDecode(fileData) as Map<String, dynamic>;
    return jsonData;
  });

  // Use that data.
  print('Number of JSON keys: ${jsonData.length}');
}

这个例子的完成情况与前一个例子相同。一个新的隔离器产生了,计算了一些东西,并把结果送了回来。

不过,现在这个隔离体发送的是一个闭包。与典型的命名函数相比,闭包的限制较少,无论是在功能上还是在代码中的编写方式上。在这个例子中,Isolate.run()执行的是看起来像本地代码的东西,同时进行。在这个意义上,你可以把run()想象成一个 "并行运行 "的控制流操作符

实现一个简单的 isolate 工作对象

Isolate.run() a抽取了一些较低级别的、与隔离物相关的API,以简化隔离物管理:

您可以直接使用这些基元来对隔离区的功能进行更精细的控制。例如,run()在返回一条消息后就会关闭其隔离区。如果您想允许多个消息在隔离区之间传递,该怎么办呢?您可以用与run()的实现方式大致相同的方式来设置自己的隔离区,只是以稍微不同的方式利用SendPortsend()方法

如果你想在 isolate 之间建立更多的通信,那么你需要使用 SendPortsend() 方法。下图展示了一种常见的场景,主 isolate 会发送请求消息至 isolate 工作对象,然后它们之间会继续进行多次通信,进行请求和回复。

图中显示了主隔离器催生隔离器,然后发送请求消息,工作隔离器用回复消息进行响应;显示了两个请求-回复循环。
图中显示了主隔离器催生隔离器,然后发送请求消息,工作隔离器用回复消息进行响应;显示了两个请求-回复循环。

下方列举的 isolate 示例 包含了发送多次消息的使用方法:

  • send_and_receive.dart 展示了如何从主 isolate 发送消息至生成的 isolate,与前面的示例较为接近,不过没有使用 run() 方法;
  • long_running_isolate.dart 展示了如何生成一个长期运行、且多次发送和接收消息的 isolate。

性能和 isolate 组

当一个 isolate 调用了 Isolate.spawn(),两个 isolate 将拥有同样的执行代码,并归入同一个 isolate 组 中。 Isolate 组会带来性能优化,例如新的 isolate 会运行由 isolate 组持有的代码,即共享代码调用。同时,Isolate.exit() 仅在对应的 isolate 属于同一组时有效。

某些场景下,你可能需要使用 Isolate.spawnUri(),使用执行的 URI 生成新的 isolate,并且包含代码的副本。然而,spawnUri() 会比 spawn() 慢很多,并且新生成的 isolate 会位于新的 isolate 组。另外,当 isolate 在不同的组中,它们之间的消息传递会变得更慢。

备注

Flutter logoFlutter 不支持 Isolate.spawnUri()

在Web的并发

所有的Dart应用程序都可以使用async-awaitFutureStream进行非阻塞、交错的计算。然而,Dart web 平台并不支持隔离器。Dart网络应用程序可以使用网络工作者在后台线程中运行脚本,这与隔离程序类似。不过,web workers的功能和能力与隔离器有些不同。

例如,当Web工作者在线程之间发送数据时,他们会来回复制数据。不过,数据复制的速度可能非常慢,尤其是对于大的消息。隔离器也做同样的事情,但也提供了API,可以更有效地传输保存消息的内存。

创建Web Worker和Isolates也有不同。你只能通过声明一个单独的程序入口并单独编译来创建网络工作者。启动Web Worker类似于使用Isolate.spoonUri来启动一个隔离器。您也可以使用Isolate.spown来启动一个隔离器,这需要的资源较少,因为它重用了一些与催生隔离器相同的代码和数据。Web Worker没有一个同等的API。

☑️ ☆

Flutter 工作原理

本文档解释了使 Flutter API 正常工作的 Flutter 工具包内部工作原理。由于 Flutter widget 是以积极组合的形式构建的,所以使用 Flutter 构建的用户界面含有大量 widget。为了支撑这些负载,Flutter 使用了次线性算法来布局和构建 widget,这些数据结构使树形结构优化更加高效,并且具有很多常量因子优化。通过一些额外的机制,该设计也允许开发者利用回调(用于构建用户可见的 widget)来轻松创建无限滚动列表。

积极可组合性

组合性是 Flutter 最为出众的一个特性。widget 通过组合其他 widget 的方式进行构建,并且这些 widget 自身由更基础的 widget 构建。比如,Padding 是一个 widget 而非其他 widget 的属性。因此,使用 Flutter 创建的用户界面是由多个 widget 组成的。

widget 递归构建的底层是 RenderObjectwidget,它将在渲染树的底部创建子节点。渲染树是一种存储用户界面几何信息的数据结构,该几何信息在 布局 期间计算并在 绘制命中测试 期间使用。大多数 Flutter 开发者无需直接创建这些对象,而是使用 widget 来操纵渲染树。

为了支持 widget 层的积极可组合性, Flutter 在 widget 和树渲染层使用了大量的高效算法和优化措施,这些将在下面小节中进行介绍。

次线性布局

使用大量 widget 及渲染对象并保持高性能的关键是使用高效的算法。其中最重要的是确定渲染对象几何空间(比如大小和位置)的布局算法的性能。其他一些工具包使用 O(N²) 或更糟糕的布局算法(例如,约束域中的不动点迭代)。 Flutter 的目标在于布局初始化的线性性能,及一般情况下更新现有布局的次线性布局性能。通常情况下,布局所花费的时间应该比对象渲染要多得多。

Flutter 对每一帧执行一次布局操作,且布局算法仅在一次传递中完成。 约束信息通过父节点调用每个子节点的布局方法向下传递。子节点递归执行自身的布局操作,并在它们的布局方法中返回几何信息以便将其添加到渲染树中。需要注意的是,一旦渲染对象从布局中返回,该对象将不会被再次访问 [^1],直到下一帧布局的执行。该策略将可能存在的单独测量和布局传递合并为单次传递,因此,每个渲染对象在布局过程中最多被访问两次 [^2]:一次在树的向下传递过程中,一次在树的向上传递过程中。

针对这个通用协议,Flutter 拥有多种实现。最常用的是 RenderBox,它以二维的笛卡尔坐标进行运算。在盒子布局中,约束是最小及最大宽高。在布局过程中,子节点通过选择这些边界内的大小来确定其几何信息。子节点在布局中返回后,由父节点确定该子节点在父坐标系中的位置 [^3]。注意,子节点的布局并不取决于它的位置,这是因为它的位置直到它从布局中返回后才确定。因此父节点可以在无需重新计算子节点布局的情况下重新定位子节点的位置信息。

更广泛地讲,在布局期间,从父节点流向子节点的唯一信息是约束信息,从子节点流向父节点的唯一信息是几何信息。通过这些不变量可减少布局期间所需的工作量:

  • 如果父节点对子节点使用与上一次布局中相同的约束,且子节点没有将自己的布局标记为脏,那么该节点可立即从布局中返回,以切断布局的向下传递。
  • 当父节点调用子节点的布局方法时,父节点会表明它是否使用从子节点返回的大小信息。如果父节点经常不使用此信息,即使子节点重新选择了大小,父节点依旧无需重新计算其布局,这是因为父节点需要保证新的大小符合现有约束。
  • 严格约束是指恰好由一个有效几何满足的约束。比如,如果最小最大宽度彼此相等,且最小最大高度彼此相等,那么满足这些约束的唯一大小便是具有该宽度及高度的大小。如果父节点提供了严格约束,即便父节点在布局中使用了子节点的大小,在子节点重新计算布局时,父节点的布局也无需重新计算,这是因为子节点在没有父节点新约束的情况下无法更改其大小。
  • 渲染对象可以声明仅使用父节点提供的约束来确定其几何信息。此类声明通知框架: 即便约束为非严格约束,以及父节点的布局取决于子节点的大小, 该渲染对象父节点的布局在子节点的布局重新计算时仍无需重新计算,这是因为子节点在没有父节点新约束的情况下无法更改其大小。

这些优化措施的效果是,当渲染对象包含脏节点时,在布局过程中,只有这些节点以及它们周围子树的有限节点才允许被访问。

次线性 widget 构建

Flutter 使用类似于布局的次线性算法来构建 widget。widget 构建完成后,它们将被保留了用户页面逻辑结构的 element 树 保存。 Element 树是非常有必要的,这是因为 widget 自身是不可变的,这意味着(其他情况除外),它们无法记住父(或子)节点与其他 widget 的关系。 Element 还保存了与 Stateful widget 相关联的 state 对象。

由于用户输入(或来自其他地方的响应),比如开发者在关联的 state 对象上调用了 setState() 方法,element 可能会变脏。框架维护了一个脏 element 列表,使得 构建 过程可跳过干净的 element,直接跳转到脏的 element。构建过程中,信息在 element 树中向下 单向 传递,这意味着该阶段中每个 element 最多会被访问一次。一个 element 一旦被清洗,它将不会再次变脏,这是因为通过归纳,它所有的祖先 element 也都是干净的 [^4]。

由于 widget 是不可变的,因此父节点使用相同的 widget 来重新构建 element,如果 element 没有将自己标记为脏,那么该 element 可立即从构建中返回,以切断构建的向下传递。另外,element 只需比较两个 widget 所引用的对象标识来确定新 widget 与旧 widget 是否相同。开发者可利用该优化实现投影模式,即 widget 包含了被存储为成员变量、在构建过程中预先构建的子 widget

构建过程中,Flutter 同时使用 Inheritedwidgets 来避免父链的遍历。如果 widget 经常遍历它们的父链,比如确定当前的主题颜色,那么构建阶段树的深底将变为 O(N²),由于 Flutter 的积极可组合性,其数量可能非常巨大。为了避免这些父链的遍历,框架通过在每个 element 上维护一个 Inheritedwidget 哈希表来向下传递 element 树中的信息。通常情况下,多个 element 引用相同的哈希表,并且该表仅在 element 引入新的 Inheritedwidget 时改变。

线性协调

不同于传统做法,Flutter 没有使用树差异比较算法。相反,框架通过使用 O(N) 算法独立地检查每个 element 的子节点来决定是否重用该 element。子列表协调算法针对以下情况进行了优化:

  • 旧的子列表为空。
  • 两个列表完全相同。
  • 在列表的某个位置插入或删除一个或多个 widget。
  • 如果新旧列表都包含相同 key [^5] 的 widget,那么这两个 widget 就会被认为是相同的。

通常的做法是从新旧子列表的头部和尾部开始对每一个 widget 的运行时类型和 key 进行匹配,这样就可能找到在两个列表中间所有不匹配子节点的(非空)范围。然后框架将旧子列表中该范围内的子项根据它的 key 放入一个哈希表中。接下来,框架将会遍历新的子列表以寻找该范围内能够匹配哈希表中的 key 的子项。无法匹配的子项将会被丢弃并从头开始重建,匹配到的子项则使用它们新的 widget 进行重建。

树结构优化

重用 element 对性能非常重要,这是因为 element 拥有两份关键数据:Stateful widget 的状态对象及底层的渲染对象。当框架能够重用 element 时,用户界面的逻辑状态信息是不变的,并且可以重用之前计算的布局信息,这通常可以避免遍历整棵子树。事实上,重用 element 是非常有价值的,因为 Flutter 支持 全局 树更新,以此保留状态和布局信息。

开发者可通过将 GlobalKey 与其中一个 widget 相关联来实施全局树更新。每个全局 key 在整个应用中都是唯一的,并使用特定于线程的哈希表进行注册。在构建过程中,开发者可以使用全局 key 将 widget 移动到 element 树的任意位置。框架将不会在该位置上重新构建 element,而是检查哈希表并将现有的 element 从之前的位置移动到新的位置,从而保留整棵子树。

重新构建的子树中的渲染对象能够保留它们的布局信息,这是因为布局约束是渲染树从父节点传递到子节点的唯一信息。子列表发生变化后,父节点将会被标记为脏,但如果新的父节点传递给子节点的布局约束与该子节点从旧的父节点接收到的相同,那么子节点可立即从布局中返回,从而切断布局的向下传递。

开发者广泛使用全局 key 和全局树更新来实现 hero transition 及导航等效果。

恒定因子优化

除了上述算法优化,实现积极可组合还需依赖几个重要的恒定因子优化。这些优化对于上面所讨论的主要算法是非常重要的。

  • 子模型无关。与大多数使用子列表的工具包不同, Flutter 渲染树不会记住一个特定的子模型。比如,类 RenderBox 存在一个抽象的 visitChildren() 方法,而非具体的 firstChildnextSibling 接口。许多子类仅支持直接作为其成员变量的单个子项,而非子项列表。比如,由于 RenderPadding 仅支持单个子节点,因此它拥有一个更为简单、高效的布局方法。
  • 视觉渲染树、widget 逻辑树。在 Flutter 中,渲染树在与设备无关的视觉坐标系中运行,这意味着即使 x 轴的读取方向是从右到左,其左侧的值依旧小于右侧。Widget 树通常在逻辑坐标中运行,这意味着拥有 开始结束 值的视觉解释取决于读取方向。逻辑坐标到视觉坐标的转换是在 widget 树和渲染树之间的切换中完成的。这种方法更为高效的原因是,渲染树中的布局和绘制计算比 widget 到渲染树的切换更加频繁,并且可以避免重复的坐标转换。
  • 通过专门的渲染对象处理文本。大多数渲染对象都不清楚文本的复杂性。相反,文本是由专门的渲染对象 RenderParagraph 进行处理,它是渲染树中的一个叶子节点。开发者使用组合形式将文本并入到用户界面中,而非使用文本感知渲染对象进行子类化。该模式意味着 RenderParagraph 可避免文本布局在父节点提供相同布局约束下的重复计算,这是非常常见的,即使在树优化期间也是如此。
  • 可观察对象。 Flutter 使用模型观察及响应设计模式。显而易见,响应模式占主导地位,但 Flutter 在某些叶子节点的数据结构上使用了可观察对象。比如 Animation 会在值发生变化时通知观察者列表。 Flutter 将这些可观察对象从 widget 树转移到渲染树中,渲染树直接监听这些对象,并在它们改变时仅重绘管道的相关阶段。比如,更改 Animation<Color> 可能只触发绘制阶段,而非整个构建和绘制阶段。

总的来说,这些优化对通过积极组合方式产生的大型树结构的性能产生了重大影响。

元素和 RenderObject 树的分离

Flutter 中的RenderObjectElement(Widget)树是同构的(严格来说,RenderObject树是Element树的一个子集)。一个明显的简化是将这些树合并成一棵树。然而,在实践中,将这些树分开是有很多好处的:

  • 性能 当布局发生变化时,只有布局树的相关部分需要被行走。由于组成的原因,元素树经常有许多额外的节点需要被跳过。

  • 明确性 更清晰的关注点分离允许小部件协议和渲染对象协议各自针对其特定需求进行专业化,简化了 API 表面,从而降低了错误的风险和测试负担。

  • 类型安全 呈现对象树可以更具有类型安全性,因为它可以在运行时保证子代将具有适当的类型(每个坐标系,例如,有自己的呈现对象类型)。组成部件可以不考虑布局时使用的坐标系(例如,同一个部件暴露了应用程序模型的一部分,可以在盒子布局和狭长布局中使用),因此在元素树中,验证呈现对象的类型需要在树上行走。

无限滚动

对于工具包来说,实现无限滚动列表是非常困难的。Flutter 支持基于 构造器 模式实现的简单无限滚动列表界面,其中 ListView 使用回调按需构建 widget,即它们只在滚动过程中才对用户可见。该功能需要 视窗感知布局按需构建 widget 的支持。

视窗感知布局

同 Flutter 中的大多数东西一样,可滚动的 widget 是基于组合模式构建的。可滚动 widget 的外部是一个 Viewport,这是一个拥有更大内部空间的盒子,这意味着它的子节点可以超出视窗口的边界并滚动到可视区域中。但是,视窗口没有 RenderBox 子节点,而是拥有被称为 sliver,实现了视窗感知协议的RenderSliver 子节点。

sliver 布局协议中父节点向下传递给子节点的约束信息及接收到的几何信息的结构与盒子布局相同。但约束和几何数据在两个协议之间不同。在 sliver 协议中,子节点接收到的是关于视窗口的信息,这其中包含剩余的可见空间量。它们返回的几何数据支持各种滚动链接效果,包括可折叠标题及视差。

不同的 sliver 以不同的方式填充视窗口中的可用空间。比如,生成线性子列表的 sliver 按顺序排列每个子节点,直到 sliver 中无任何子节点或可用空间。同理,生成二维子节点网格的 sliver 仅填充网格中的可见区域。由于它们知道还有多大的可见空间,sliver 可以生成有限的子节点,即使它们可能生成无限的子节点。

可组合 sliver 来创建特定的滚动布局和效果。比如,单个视窗口可以有一个折叠标题、一个线性列表和一个网格。所有这些 sliver 将按照 sliver 布局协议进行协作,只生成那些在视窗口实际可见的子节点,而不管这些子节点是否属于标题、列表或网格[^6]。

按需构建 widget

如果 Flutter 拥有一个严格的从构建到布局,再到绘制的管道,那么前面的内容将不足以实现无限滚动列表,这是因为只有在布局阶段才能通过视窗口获取可用的空间信息。如果没有额外的机制,在布局阶段构建用于填充空间的 widget 已经太迟了。 Flutter 使用将管道的构建与布局交叉在一起的方式来解决这个问题。在布局阶段的任意时刻,只要这些 widget 是当前布局的渲染对象的子节点,框架就可以按需构建新的 widget。

只有严格控制构建及布局中消息传播的算法,才能实现构建和布局的交叉执行。也就是说,在构建过程中,消息只能沿构建树向下传递。当渲染对象进行布局时,布局遍历过程中并没有访问该渲染对象的子树,这意味通过子树构建的写入无法使到目前为止已进入布局计算过程的任何信息失效。无独有偶,一旦布局从渲染对象中返回,在当前布局过程中,该渲染对象将永远不会被再次访问,这意味后续布局计算生成的任何写入都不会使用于构建渲染对象的子树的信息失效。

此外,线性协调及树结构优化对于在滚动过程中有效更新 element,以及当 element 在视窗口边缘滚动进出视图期间修改渲染树至关重要。

人机工程 API

速度只有在框架能够被有效使用时才有意义。为了引导设计更高可用性的 Flutter API, Flutter 已经在与开发者进行的广泛用户体验研究中进行了反复测试。这些研究有时证实了已有的设计决策,有时有助于引导功能的优先级,有时会改变 API 的设计方向。比如,Flutter 的 API 文档很多,用户体验的研究不仅证实了这些文档的价值,也同时强调了示例代码及说明性图表的重要性。

本节将要讨论 Flutter API 设计中为提高可用性所做的一些决策。

与开发者思维模式相匹配的专项 API

Flutter 中 widgetElementRenderObject 的基类节点不定义子类模型。该机制允许每个节点对适用于该节点的子模型进行定制化。

大多数 widget 对象都有一个子 widget 对象,因此它只暴露了一个 child 参数。一些 widget 支持任意数量的子节点,并暴露了一个获取子节点列表的 children 参数。有些 widget 无任何子节点、不保留内存且无任何参数。同样的,RenderObjects 暴露特定于子模型的 API。 RenderImage 是一个没有子节点的叶子节点。 RenderPadding 只持有一个子节点,因此它有一个指向单个子节点的指针存储空间。 RenderFlex 接受任意数量的子节点,并通过链表对其进行管理。

在一些罕见情况下,将使用更复杂的子类模型。渲染对象 RenderTable 的构造函数需要使用二维数组来存储子节点,所以该类暴露了用于控制行和列数量的 getter 及 setter 方法,还有一些可以用 x、y 轴坐标来替换单个子节点的特殊方法,可通过提供一个新的子节点数组来添加新行,并用单个数组及列的个数来替换整个子节点列表。该对象并不像大多数渲染对象那样使用链表,而是使用可索引数组来实现。

Chip widget 和 InputDecoration 对象具有与其控制中的插槽相匹配的字段。如果一个通用子模型将强制语义定义在子列表之上,比如将第一个子节点定义为前缀,第二个子节点定义为后缀,那么专用子模型允许使用特有的命名属性。

这种灵活性允许树中的每个子节点以其最常用的方式操作它的角色。很少有人想要在表格中插入一个单元格,从而导致其他所有单元格被环绕;同样的,很少有人想要通过索引而不是通过引用从 flex 行中删除子项。

RenderParagraph 对象是最极端的情况:它有一个完全不同类型的子节点,TextSpan。在 RenderParagraph 的边界,RenderObject 树会被转换为 TextSpan 树。

专门用于满足开发者期望的 API 的一切方法不仅适用于子模型。

专门存在一些琐碎的 widget,以便开发者在寻找问题解决方案时能够发现并使用它们。一旦知道如何使用 Expanded 和大小为零的 SizedBox 子部件,就可以轻松地为行或列添加空格,但你会发现这种模式是没有必要的,因为搜索 space 所找到的 Spacer,它是直接使用 ExpandedSizedBox 来达到同样的效果的。

同理,可以通过在构建过程中不包含 widget 子树来轻松隐藏 widget 子树。但开发者通常希望有一个 widget 来执行该操作,因此 Visibility 的存在便是将此模式封装在一个简单的可重用 widget 中。

明确的参数

UI 框架往往拥有大量的属性,因此很少有开发者能够记住每个类的每个构造函数参数的作用。由于 Flutter 使用响应式编程范式,因此在 Flutter 中,构建方法通常会对构造函数进行多次调用。通过利用 Dart 的命名参数,Flutter 中的 API 能够使这些构建方法保持清晰易懂。

该模式已被扩展到任何具有多个参数(尤其是具有 boolean 类型参数)的方法,因此独立的 truefalse 值在方法调用中总是自我描述的。此外,为避免 API 中通常由双重否定所造成的困惑, boolean 类型的参数和属性始终以肯定的形式命名(比如,使用 enabled: true 而非 disabled: false)。

参数陷阱

在 Flutter 框架中被大量使用的一项技术是定义不存在错误条件的 API。这样可以避免考虑整个错误类别。

比如插值函数允许插值的一端或两端为空,而不是将其定义为错误:两个空值之间的插值永远为空,并且从空值或空值插值等效于对指定类型进行零模拟插值。这意味着不小心将 null 传递给插值函数的开发者不会遇到错误,而是会得到一个合理结果。

一个更加微妙的例子是 Flex 布局算法。该布局给予 flex 渲染对象的空间被它的子节点所划分。因此 flex 的大小应该是整个可用空间。在最初的设计中提供无限空间将导致失败:这意味着 flex 应该是无限大且无用的布局设置。然而,通过对 API 的改造,在为 flex 对象提供无限空间时,渲染对象会调整自身大小来满足所需子节点的大小,从而减少可能出现的错误次数。

该方法也可用于避免使用允许创建不符合逻辑的数据的构造函数。例如,PointerDownEvent 的构造函数不允许将 PointerEventdown 属性设置为 false(这种情况是自相矛盾的);相反,构造函数没有关于字段 down 的参数,且将值始终设置为 true

一般情况下,该方法用于为输入域中的所有值定义有效的解释。最简单的例子是 Color 的构造函数。相对于接受四个整型参数(分别用于表示红色、绿色、蓝色和 alpha),其中任何一个都可能超出范围,它的默认构造函数仅接受一个整数值,并定义每位的含义(例如,低八位代表红色),以便任何输入都是有效的颜色值。

一个更复杂的例子是 paintImage() 函数。该函数需要 11 个参数,其中一些具有相当宽泛的输入域,但它们都经过精心设计且大部分都能够彼此相交,因此很少出现无效组合。

积极报告错误

并非所有的错误都能被设计出来。对于那些遗漏的错误,在 debug 版本中,Flutter 通常会尝试尽早捕获并立即报告。它使用了大量的断言,对构造函数参数进行了详细的完整性检查,并监视其生命周期,一旦检测到不一致,它们会立即引发异常。

这在某些情况下是极端情况:比如,在执行单元测试时,无论测试用例正在做什么,每个 RenderBox 子类都会主动地检查其内部大小调整方法是否满足内部大小调整契约。这有助于捕获可能无法执行的 API 错误。

当异常抛出时,它们会包含尽可能多的信息。 Flutter 中的一些错误会主动探测相关的堆栈跟踪信息,以确定实际错误最可能发生的位置。其他错误则通过相关树来确定坏数据的来源。最常见的错误包含详细说明(在某些情况下会包含避免错误的示例代码),或指向其他文档的链接。

响应式

可变的基于树结构的 API 受二元访问模式的影响:创建树的原始状态通常使用与后续更新完全不同的操作集。Flutter 的渲染层使用了这种范式,因为它是维护持久树的有效方法,是高效布局和绘制的关键所在。但这也意味着,与渲染层的直接交互是十分笨拙的,甚至极其容易出错。

Flutter 在 widget 层引入了一个使用响应式来操作底层渲染树的组合机制[^7]。该 API 通过将树的创建和更新步骤整合到一个单一的树结构描述(构建)中,从而将树操作抽象出来,这包括:每次系统状态更新之后,开发者用于描述用户界面的新配置;框架对于新配置所需要进行的一系列树更新计算。

插值

由于 Flutter 鼓励开发者描述与当前应用状态相匹配的界面配置,因此存在一种在这些配置之间执行隐式的动画机制。

例如,假设界面在状态 S1 由一个圆形组成,在状态 S2 时由一个正方形组成。如果没有动画机制,状态更改将导致不和谐的界面更改。隐式动画则允许界面在几个帧的时间里由圆形平滑地过渡到正方形。

每个可执行隐式动画的特性都包含一个 Stateful widget,它用于记录输入的当前值,并在输入值改变时开始执行动画序列,并在指定的持续时间内从当前值转换为新值。

这是使用不可变对象的 lerp(线性插值)函数来实现的。每个状态(这里为圆形和正方形)代表一个配置中包含恰当设置(比如颜色、笔划宽度等)且知道如何绘制自己的不可变对象。在动画绘制中间步骤时,开始和结束值连同表示动画中点的 t 值一并传递给 lerp函数。其中 0.0 代表开始 start,1.0 代表结束 end[^8],并且该方法返回表示中间阶段的第三个不可变对象。

对于从圆形到正方形的转换,lerp 函数将返回一个圆角正方形对象,其半径被描述为从 t 值导出的分数,使用 lerp 函数进行插值计算的颜色,以及使用 lerp 函数进行双倍插值计算的笔划宽度。该对象与圆形、正方形一样具有相同的接口实现,并且可以在请求时进行自我绘制。

该技术允许状态机、状态到配置的映射、动画和插值机制以及与如何绘制每一桢完全分离的特定逻辑。

在 Flutter 中,该机制得到了广泛应用,无论是像 ColorShape 这样的基本类型,还是像 DecorationTextStyleTheme 这样更为复杂的类型,都是可以进行插值处理的。它们通常是由可插入组件构成的,并且插入更复杂的对象通常就像递归插入描述复杂对象的所有值一样简单。

一些插值对象由类层次结构定义。比如,形状由 ShapeBorder 接口表示,并且存在多种形状类型,包括: BeveledRectangleBorderBoxBorderCircleBorderRoundedRectangleBorderStadiumBorder。单一的 lerp 函数并不能了解所有可能的类型信息,因此接口定义了 lerpFromlerpTo 方法以替代静态的 lerp 方法。当被告知从形状 A 切换到 B 时,将首选询问 B 是否 lerpFrom A,如其答案为否,则询问 A 是否可以 lerpTo B (如两者的答案均为否,如果 t 的值小于 0.5 则返回 A,否则返回 B)。

这允许类层次结构的任意扩展,后续新增的能够在先前已知值与它们之间进行插值处理。

在某些情况下,插值本身不能被任何可用的类描述,并且定义一个私有类来描述中间状态。比如在 CircleBorderRoundedRectangleBorder 之间进行插值时就是如此。

该机制的另外一个优点是:它可以处理从中间态到新值的插值。比如,在圆形到正方形过渡的中途,形状可能再次改变,导致动画需要插值到一个三角形。只要该三角形类是 lerpFrom 圆形到正方形的中间类,就可以无缝进行转换。

结论

Flutter 一切都是 widget 的口号是围绕着通过组合 widget 来构建用户界面, widget 又由更为基础的 widget 构成。这种积极组合的结果是需要精心设计的算法和数据结构才能有效处理大量的 widget。通过一些额外的机制,这些数据结构还能使开发者轻松构建无限滚动列表,以便在 widget 可见时进行按需构建。

🔲 ☆

如何利用GitHub Action提交URL到搜索引擎

相关信息

这是一个GitHub Actions的配置文件,整体思路是,获取本次更新的url
(通过对比两次提交的sitemap.xml文件),所以触发条件是deploy分支有推送并修改了sitemap.xml文件,再利用python脚本进行url推送到百度、Bing、Google

脚本代码请看这里👉提交URL到搜索引擎(百度、Bing、Google)

前些天不是写了Bing的提交脚本吗,但是每次都要自己手动跑脚本也很麻烦,但我又没有服务器,所以用上了GitHub Actions了嘛
总的来说做了以下事情:

  1. 提交代码并修改sitemap.xml 后触发工作流
  2. 比对上一次提交和本次提交的新增URL
  3. 将URL分别提交给百度站长、Bing、Google 供搜索引擎快速收录

流程解读

☑️ ☆

GitHub Actions 使用介绍

GitHub Actions 是什么?

Github Actions 是 Github 官方出的持续集成服务, 挺早之前就推出了。类似的还有如微软的DevOps、GitLab CI、Circle CI、Travis CI等等。大家知道,持续集成由很多操作组成,比如抓取代码、运行测试、登录远程服务器,发布到第三方服务等等。GitHub 把这些操作就称为 actions。

很多操作在不同项目里面是类似的,完全可以共享。GitHub 注意到了这一点,想出了一个很妙的点子,允许开发者把每个操作写成独立的脚本文件,存放到代码仓库,使得其他开发者可以引用。

如果你需要某个 action,不必自己写复杂的脚本,直接引用他人写好的 action 即可,整个持续集成过程,就变成了一个 actions 的组合。这就是 GitHub Actions 最特别的地方。

什么是CI/CD?

What is CI/CD? (redhat.com)

字面意思就是持续集成Continuous Intergation/持续分发Continuous Delivery持续部署Continuous Deployment,网上有太多同质的解释都太过于晦涩,

提到CI/CD就离不开一个词叫流水线。流水线上每个人的工作是分工明确的,而且工程是有先后顺序,就像造一台车,先造零件,然后组装、喷涂、内饰最后还要测试。软件开发同样如此,有前期的产品设计,UI设计、单元的开发,产品的测试,优化迭代等都由不同的人负责。

☑️ ⭐

前端-Q&A

浏览器是如何渲染页面的?

当浏览器的网络线程收到 HTML 文档后,会产生一个渲染任务,并将其传递给渲染主线程的消息队列。

在事件循环机制的作用下,渲染主线程取出消息队列中的渲染任务,开启渲染流程。

🔲 ☆

浏览器的事件循环

浏览器的进程模型

何为进程?

程序运行需要有它自己专属的内存空间,可以把这块内存空间简单的理解为进程

何为进程
何为进程

每个应用至少有一个进程,进程之间相互独立,即使要通信,也需要双方同意。

何为线程?

有了进程后,就可以运行程序的代码了。

运行代码的「人」称之为「线程」。

一个进程至少有一个线程,所以在进程开启后会自动创建一个线程来运行代码,该线程称之为主线程。

如果程序需要同时执行多块代码,主线程就会启动更多的线程来执行代码,所以一个进程中可以包含多个线程。

何为线程
何为线程

浏览器有哪些进程和线程?

浏览器是一个多进程多线程的应用程序

浏览器内部工作极其复杂。

为了避免相互影响,为了减少连环崩溃的几率,当启动浏览器后,它会自动启动多个进程。

浏览器有哪些进程和线程
浏览器有哪些进程和线程

可以在浏览器的任务管理器中查看当前的所有进程

其中,最主要的进程有:

  1. 浏览器进程

    主要负责界面显示、用户交互、子进程管理等。浏览器进程内部会启动多个线程处理不同的任务。

  2. 网络进程

    负责加载网络资源。网络进程内部会启动多个线程来处理不同的网络任务。

  3. 渲染进程(本节课重点讲解的进程)

    渲染进程启动后,会开启一个渲染主线程,主线程负责执行 HTML、CSS、JS 代码。

    默认情况下,浏览器会为每个标签页开启一个新的渲染进程,以保证不同的标签页之间不相互影响。

    将来该默认模式可能会有所改变,有兴趣的同学可参见chrome官方说明文档

渲染主线程是如何工作的?

渲染主线程是浏览器中最繁忙的线程,需要它处理的任务包括但不限于:

  • 解析 HTML
  • 解析 CSS
  • 计算样式
  • 布局
  • 处理图层
  • 每秒把页面画 60 次
  • 执行全局 JS 代码
  • 执行事件处理函数
  • 执行计时器的回调函数
  • ......

思考题:为什么渲染进程不适用多个线程来处理这些事情?

要处理这么多的任务,主线程遇到了一个前所未有的难题:如何调度任务?

比如:

  • 我正在执行一个 JS 函数,执行到一半的时候用户点击了按钮,我该立即去执行点击事件的处理函数吗?
  • 我正在执行一个 JS 函数,执行到一半的时候某个计时器到达了时间,我该立即去执行它的回调吗?
  • 浏览器进程通知我“用户点击了按钮”,与此同时,某个计时器也到达了时间,我应该处理哪一个呢?
  • ......

渲染主线程想出了一个绝妙的主意来处理这个问题:排队

排队
排队
  1. 在最开始的时候,渲染主线程会进入一个无限循环
  2. 每一次循环会检查消息队列中是否有任务存在。如果有,就取出第一个任务执行,执行完一个后进入下一次循环;如果没有,则进入休眠状态。
  3. 其他所有线程(包括其他进程的线程)可以随时向消息队列添加任务。新任务会加到消息队列的末尾。在添加新任务时,如果主线程是休眠状态,则会将其唤醒以继续循环拿取任务

这样一来,就可以让每个任务有条不紊的、持续的进行下去了。

整个过程,被称之为事件循环(消息循环)

若干解释

何为异步?

代码在执行过程中,会遇到一些无法立即处理的任务,比如:

  • 计时完成后需要执行的任务 —— setTimeoutsetInterval
  • 网络通信完成后需要执行的任务 -- XHRFetch
  • 用户操作后需要执行的任务 -- addEventListener

如果让渲染主线程等待这些任务的时机达到,就会导致主线程长期处于「阻塞」的状态,从而导致浏览器「卡死」

image-20220810104344296
image-20220810104344296

渲染主线程承担着极其重要的工作,无论如何都不能阻塞!

因此,浏览器选择异步来解决这个问题

异步
异步

使用异步的方式,渲染主线程永不阻塞

面试题:如何理解 JS 的异步?

参考答案:

JS是一门单线程的语言,这是因为它运行在浏览器的渲染主线程中,而渲染主线程只有一个。

而渲染主线程承担着诸多的工作,渲染页面、执行 JS 都在其中运行。

如果使用同步的方式,就极有可能导致主线程产生阻塞,从而导致消息队列中的很多其他任务无法得到执行。这样一来,一方面会导致繁忙的主线程白白的消耗时间,另一方面导致页面无法及时更新,给用户造成卡死现象。

所以浏览器采用异步的方式来避免。具体做法是当某些任务发生时,比如计时器、网络、事件监听,主线程将任务交给其他线程去处理,自身立即结束任务的执行,转而执行后续代码。当其他线程完成时,将事先传递的回调函数包装成任务,加入到消息队列的末尾排队,等待主线程调度执行。

在这种异步模式下,浏览器永不阻塞,从而最大限度的保证了单线程的流畅运行。

JS为何会阻碍渲染?

先看代码

<h1>Mr.Yuan is awesome!</h1>
<button>change</button>
<script>
  var h1 = document.querySelector('h1');
  var btn = document.querySelector('button');

  // 死循环指定的时间
  function delay(duration) {
    var start = Date.now();
    while (Date.now() - start < duration) {}
  }

  btn.onclick = function () {
    h1.textContent = '袁老师很帅!';
    delay(3000);
  };
</script>

点击按钮后,会发生什么呢?

提示

三秒之后h1文字变更,主线程被阻塞

任务有优先级吗?

任务没有优先级,在消息队列中先进先出

消息队列是有优先级的

根据 W3C 的最新解释:

  • 每个任务都有一个任务类型,同一个类型的任务必须在一个队列,不同类型的任务可以分属于不同的队列。
    在一次事件循环中,浏览器可以根据实际情况从不同的队列中取出任务执行。
  • 浏览器必须准备好一个微队列,微队列中的任务优先所有其他任务执行
    https://html.spec.whatwg.org/multipage/webappapis.html#perform-a-microtask-checkpoint

随着浏览器的复杂度急剧提升,W3C 不再使用宏队列的说法

在目前 chrome 的实现中,至少包含了下面的队列:

  • 延时队列:用于存放计时器到达后的回调任务,优先级「中」
  • 交互队列:用于存放用户操作后产生的事件处理任务,优先级「高」
  • 微队列:用户存放需要最快执行的任务,优先级「最高」

提示

添加任务到微队列的主要方式主要是使用 Promise、MutationObserver

例如:

// 立即把一个函数添加到微队列
Promise.resolve().then(函数)

浏览器还有很多其他的队列,由于和我们开发关系不大,不作考虑

面试题:阐述一下 JS 的事件循环

参考答案:

事件循环又叫做消息循环,是浏览器渲染主线程的工作方式。

在 Chrome 的源码中,它开启一个不会结束的 for 循环,每次循环从消息队列中取出第一个任务执行,而其他线程只需要在合适的时候将任务加入到队列末尾即可。

过去把消息队列简单分为宏队列和微队列,这种说法目前已无法满足复杂的浏览器环境,取而代之的是一种更加灵活多变的处理方式。

根据 W3C 官方的解释,每个任务有不同的类型,同类型的任务必须在同一个队列,不同的任务可以属于不同的队列。不同任务队列有不同的优先级,在一次事件循环中,由浏览器自行决定取哪一个队列的任务。但浏览器必须有一个微队列,微队列的任务一定具有最高的优先级,必须优先调度执行

面试题:JS 中的计时器能做到精确计时吗?为什么?

参考答案:

不行,因为:

  1. 计算机硬件没有原子钟,无法做到精确计时
  2. 操作系统的计时函数本身就有少量偏差,由于 JS 的计时器最终调用的是操作系统的函数,也就携带了这些偏差
  3. 按照 W3C 的标准,浏览器实现计时器时,如果嵌套层级超过 5 层,则会带有 4 毫秒的最少时间,这样在计时时间少于 4 毫秒时又带来了偏差
  4. 受事件循环的影响,计时器的回调函数只能在主线程空闲时运行,因此又带来了偏差
🔲 ☆

CSS 属性计算过程

你是否了解 CSS 的属性计算过程呢?

有的同学可能会讲,CSS属性我倒是知道,例如:

p{
  color : red;
}

上面的 CSS 代码中,p 是元素选择器,color 就是其中的一个 CSS 属性。

但是要说 CSS 属性的计算过程,还真的不是很清楚。

没关系,通过此篇文章,能够让你彻底明白什么是 CSS 属性的计算流程。

image-20220813140434032

首先,不知道你有没有考虑过这样的一个问题,假设在 HTML 中有这么一段代码:

<body>
  <h1>这是一个h1标题</h1>
</body>

上面的代码也非常简单,就是在 body 中有一个 h1 标题而已,该 h1 标题呈现出来的外观是如下:

image-20220813140724136

目前我们没有设置该 h1 的任何样式,但是却能看到该 h1 有一定的默认样式,例如有默认的字体大小、默认的颜色。

那么问题来了,我们这个 h1 元素上面除了有默认字体大小、默认颜色等属性以外,究竟还有哪些属性呢?

image-20220815094215982

答案是**该元素上面会有 CSS 所有的属性。**你可以打开浏览器的开发者面板,选择【元素】,切换到【计算样式】,之后勾选【全部显示】,此时你就能看到在此 h1 上面所有 CSS 属性对应的值。

image-20220813141516153
image-20220813141516153

换句话说,我们所书写的任何一个 HTML 元素,实际上都有完整的一整套 CSS 样式。这一点往往是让初学者比较意外的,因为我们平时在书写 CSS 样式时,往往只会书写必要的部分,例如前面的:

p{
  color : red;
}

这往往会给我们造成一种错觉,认为该 p 元素上面就只有 color 属性。而真实的情况确是,任何一个 HTML 元素,都有一套完整的 CSS 样式,只不过你没有书写的样式,大概率可能会使用其默认值。例如上图中 h1 一个样式都没有设置,全部都用的默认值。

但是注意,我这里强调的是“大概率可能”,难道还有我们“没有设置值,但是不使用默认值”的情况么?

image-20220815094458940

嗯,确实有的,所以我才强调你要了解“CSS 属性的计算过程”。

总的来讲,属性值的计算过程,分为如下这么 4 个步骤:

  • 确定声明值
  • 层叠冲突
  • 使用继承
  • 使用默认值

确定声明值

首先第一步,是确定声明值。所谓声明值就是作者自己所书写的 CSS 样式,例如前面的:

p{
  color : red;
}

这里我们声明了 p 元素为红色,那么就会应用此属性设置。

当然,除了作者样式表,一般浏览器还会存在“用户代理样式表”,简单来讲就是浏览器内置了一套样式表。

image-20220813143500066
image-20220813143500066

在上面的示例中,作者样式表中设置了 color 属性,而用户代理样式表(浏览器提供的样式表)中设置了诸如 display、margin-block-start、margin-block-end、margin-inline-start、margin-inline-end 等属性对应的值。

这些值目前来讲也没有什么冲突,因此最终就会应用这些属性值。

层叠冲突

在确定声明值时,可能出现一种情况,那就是声明的样式规则发生了冲突。

此时会进入解决层叠冲突的流程。而这一步又可以细分为下面这三个步骤:

  • 比较源的重要性
  • 比较优先级
  • 比较次序

来来来,我们一步一步来看。

比较源的重要性

当不同的 CSS 样式来源拥有相同的声明时,此时就会根据样式表来源的重要性来确定应用哪一条样式规则。

那么问题来了,咱们的样式表的源究竟有几种呢?

image-20220823180047075

整体来讲有三种来源:

  • 浏览器会有一个基本的样式表来给任何网页设置默认样式。这些样式统称用户代理样式
  • 网页的作者可以定义文档的样式,这是最常见的样式表,称之为页面作者样式
  • 浏览器的用户,可以使用自定义样式表定制使用体验,称之为用户样式

对应的重要性顺序依次为:页面作者样式 > 用户样式 > 用户代理样式

更详细的来源重要性比较,可以参阅 MDNhttps://developer.mozilla.org/zh-CN/docs/Web/CSS/Cascade

我们来看一个示例。

例如现在有页面作者样式表用户代理样式表中存在属性的冲突,那么会以作者样式表优先。

p{
  color : red;
  display: inline-block;
}
image-20220813144222152
image-20220813144222152

可以明显的看到,作者样式表和用户代理样式表中同时存在的 display 属性的设置,最终作者样式表干掉了用户代理样式表中冲突的属性。这就是第一步,根据不同源的重要性来决定应用哪一个源的样式。

比较优先级

那么接下来,如果是在在同一个源中有样式声明冲突怎么办呢?此时就会进行样式声明的优先级比较。

例如:

<div class="test">
  <h1>test</h1>
</div>
.test h1{
  font-size: 50px;
}

h1 {
  font-size: 20px;
}

在上面的代码中,同属于页面作者样式,源的重要性是相同的,此时会以选择器的权重来比较重要性。

很明显,上面的选择器的权重要大于下面的选择器,因此最终标题呈现为 50px

image-20210916151546500

可以看到,落败的作者样式在 Elements>Styles 中会被划掉。

有关选择器权重的计算方式,不清楚的同学,可以进入此传送门:https://developer.mozilla.org/en-US/docs/Web/CSS/Specificity

比较次序

经历了上面两个步骤,大多数的样式声明能够被确定下来。但是还剩下最后一种情况,那就是样式声明既是同源,权重也相同。

此时就会进入第三个步骤,比较样式声明的次序。

举个例子:

h1 {
  font-size: 50px;
}

h1 {
  font-size: 20px;
}

在上面的代码中,同样都是页面作者样式选择器的权重也相同,此时位于下面的样式声明会层叠掉上面的那一条样式声明,最终会应用 20px 这一条属性值。

image-20220823183928330
image-20220823183928330

至此,样式声明中存在冲突的所有情况,就全部被解决了。

使用继承

层叠冲突这一步完成后,解决了相同元素被声明了多条样式规则究竟应用哪一条样式规则的问题。

那么如果没有声明的属性呢?此时就使用默认值么?

No、No、No,别急,此时还有第三个步骤,那就是使用继承而来的值。

例如:

<div>
  <p>Lorem ipsum dolor sit amet.</p>
</div>
div {
  color: red;
}

在上面的代码中,我们针对 div 设置了 color 属性值为红色,而针对 p 元素我们没有声明任何的属性,但是由于 color 是可以继承的,因此 p 元素从最近的 div 身上继承到了 color 属性的值。

image-20220813145102293
image-20220813145102293

这里有两个点需要同学们注意一下。

首先第一个是我强调了是最近的 div 元素,看下面的例子:

<div class="test">
  <div>
    <p>Lorem ipsum dolor sit amet.</p>
  </div>
</div>
div {
  color: red;
}
.test{
  color: blue;
}
image-20220813145652726
image-20220813145652726

因为这里并不涉及到选中 p 元素声明 color 值,而是从父元素上面继承到 color 对应的值,因此这里是谁近就听谁的,初学者往往会产生混淆,又去比较权重,但是这里根本不会涉及到权重比较,因为压根儿就没有选中到 p 元素。

第二个就是哪些属性能够继承?

关于这一点的话,大家可以在 MDN 上面很轻松的查阅到。例如我们以 text-align 为例,如下图所示:

image-20220813150147885
image-20220813150147885

使用默认值

好了,目前走到这一步,如果属性值都还不能确定下来,那么就只能是使用默认值了。

如下图所示:

image-20220813150824752
image-20220813150824752

前面我们也说过,一个 HTML 元素要在浏览器中渲染出来,必须具备所有的 CSS 属性值,但是绝大部分我们是不会去设置的,用户代理样式表里面也不会去设置,也无法从继承拿到,因此最终都是用默认值。

好了,这就是关于 CSS 属性计算过程的所有知识了。

image-20220814234654914

一道面试题

好了,学习了今天的内容,让我来用一道面试题测试测试大家的理解程度。

下面的代码,最终渲染出来的效果,a 元素是什么颜色?p 元素又是什么颜色?

<div>
  <a href="">test</a>
  <p>test</p>
</div>
div {
  color: red;
}

大家能说出为什么会呈现这样的结果么?

解答如下:

image-20220813151941113
image-20220813151941113

实际上原因很简单,因为 a 元素在用户代理样式表中已经设置了 color 属性对应的值,因此会应用此声明值。而在 p 元素中无论是作者样式表还是用户代理样式表,都没有对此属性进行声明,然而由于 color 属性是可以继承的,因此最终 p 元素的 color 属性值通过继承来自于父元素。

你答对了么?-)

☑️ ☆

你不知道的 CSS 之包含块

一说到 CSS 盒模型,这是很多小伙伴耳熟能详的知识,甚至有的小伙伴还能说出 border-box 和 content-box 这两种盒模型的区别。

但是一说到 CSS 包含块,有的小伙伴就懵圈了,什么是包含块?好像从来没有听说过这玩意儿。

image-20220814222004395

好吧,如果你对包含块的知识一无所知,那么系好安全带,咱们准备出发了。

image-20220813140434032

包含块英语全称为containing block,实际上平时你在书写 CSS 时,大多数情况下你是感受不到它的存在,因此你不知道这个知识点也是一件很正常的事情。但是这玩意儿是确确实实存在的,在 CSS 规范中也是明确书写了的:

https://drafts.csswg.org/css2/#containing-block-details

image-20220814222458695

并且,如果你不了解它的运作机制,有时就会出现一些你认为的莫名其妙的现象。

那么,这个包含块究竟说了什么内容呢?

说起来也简单,就是元素的尺寸和位置,会受它的包含块所影响。对于一些属性,例如 width, height, padding, margin,绝对定位元素的偏移值(比如 position 被设置为 absolute 或 fixed),当我们对其赋予百分比值时,这些值的计算值,就是通过元素的包含块计算得来。

来吧,少年,让我们从最简单的 case 开始看。

image-20220814223152726
<body>
  <div class="container">
    <div class="item"></div>
  </div>
</body>
.container{
  width: 500px;
  height: 300px;
  background-color: skyblue;
}
.item{
  width: 50%;
  height: 50%;
  background-color: red;
}

请仔细阅读上面的代码,然后你认为 div.item 这个盒子的宽高是多少?

image-20220814223451349

相信你能够很自信的回答这个简单的问题,div.item 盒子的 width 为 250px,height 为 150px。

这个答案确实是没有问题的,但是如果我追问你是怎么得到这个答案的,我猜不了解包含块的你大概率会说,因为它的父元素 div.container 的 width 为 500px,50% 就是 250px,height 为 300px,因此 50% 就是 150px。

这个答案实际上是不准确的。正确的答案应该是,div.item 的宽高是根据它的包含块来计算的,而这里包含块的大小,正是这个元素最近的祖先块元素的内容区。

因此正如我前面所说,很多时候你都感受不到包含块的存在。

包含块分为两种,一种是根元素(HTML 元素)所在的包含块,被称之为初始包含块(initial containing block)。对于浏览器而言,初始包含块的的大小等于视口 viewport 的大小,基点在画布的原点(视口左上角)。它是作为元素绝对定位和固定定位的参照物。

另外一种是对于非根元素,对于非根元素的包含块判定就有几种不同的情况了。大致可以分为如下几种:

  • 如果元素的 positiion 是 relative 或 static ,那么包含块由离它最近的块容器(block container)的内容区域(content area)的边缘建立。
  • 如果 position 属性是 fixed,那么包含块由视口建立。
  • 如果元素使用了 absolute 定位,则包含块由它的最近的 position 的值不是 static (也就是值为fixed、absolute、relative 或 sticky)的祖先元素的内边距区的边缘组成。

前面两条实际上都还比较好理解,第三条往往是初学者容易比较忽视的,我们来看一个示例:

<body>
    <div class="container">
      <div class="item">
        <div class="item2"></div>
      </div>
    </div>
  </body>
.container {
  width: 500px;
  height: 300px;
  background-color: skyblue;
  position: relative;
}
.item {
  width: 300px;
  height: 150px;
  border: 5px solid;
  margin-left: 100px;
}
.item2 {
  width: 100px;
  height: 100px;
  background-color: red;
  position: absolute;
  left: 10px;
  top: 10px;
}

首先阅读上面的代码,然后你能在脑海里面想出其大致的样子么?或者用笔和纸画一下也行。

公布正确答案:

image-20220814233548188

怎么样?有没有和你所想象的对上?

其实原因也非常简单,根据上面的第三条规则,对于 div.item2 来讲,它的包含块应该是 div.container,而非 div.item。

如果你能把上面非根元素的包含块判定规则掌握,那么关于包含块的知识你就已经掌握 80% 了。

实际上对于非根元素来讲,包含块还有一种可能,那就是如果 position 属性是 absolute 或 fixed,包含块也可能是由满足以下条件的最近父级元素的内边距区的边缘组成的:

  • transform 或 perspective 的值不是 none
  • will-change 的值是 transform 或 perspective
  • filter 的值不是 none 或 will-change 的值是 filter(只在 Firefox 下生效).
  • contain 的值是 paint (例如: contain: paint;)

我们还是来看一个示例:

<body>
  <div class="container">
    <div class="item">
      <div class="item2"></div>
    </div>
  </div>
</body>
.container {
  width: 500px;
  height: 300px;
  background-color: skyblue;
  position: relative;
}
.item {
  width: 300px;
  height: 150px;
  border: 5px solid;
  margin-left: 100px;
  transform: rotate(0deg); /* 新增代码 */
}
.item2 {
  width: 100px;
  height: 100px;
  background-color: red;
  position: absolute;
  left: 10px;
  top: 10px;
}

我们对于上面的代码只新增了一条声明,那就是 transform: rotate(0deg),此时的渲染效果却发生了改变,如下图所示:

image-20220814234347149

可以看到,此时对于 div.item2 来讲,包含块就变成了 div.item。

好了,到这里,关于包含块的知识就基本讲完了。

image-20220814234654914

我们再把 CSS 规范中所举的例子来看一下。

<html>
  <head>
    <title>Illustration of containing blocks</title>
  </head>
  <body id="body">
    <div id="div1">
      <p id="p1">This is text in the first paragraph...</p>
      <p id="p2">
        This is text
        <em id="em1">
          in the
          <strong id="strong1">second</strong>
          paragraph.
        </em>
      </p>
    </div>
  </body>
</html>

上面是一段简单的 HTML 代码,在没有添加任何 CSS 代码的情况下,你能说出各自的包含块么?

对应的结果如下:

| 元素 | 包含块 |
|

❌