JerryFD


  • Home

  • Archives

Webpack 知识点

Posted on 2019-10-24 | In 总结

webpack 知识点

webpack 的 loader 和 plugin 的区别

loader是对源码层面的转换、webpack 只能识别 js 和 json 文件、其他字资源文件比如 css、 ts 等则需要 loader 进行转换,loader 更像是一个翻译器,将各种类型的语言翻译成 webpack 可以识别的类型
plugin是对 loader 无法实现的功能的补充、通常有各自独立的功能,比如热加载插件,plugin 更像是一个增值套件。提供特定的增值服务。
plugin 可以自定义打包过程的方式,可以参加到整个生命流程中

webpack 原理

理解webpack原理,手写一个100行的webpack - 知乎

  1. Webpack 的打包需要配置一个 entry,根据 entry 配置的文件目录读取文件的内容
  2. 读取到的内容是字符串,调用 babylon 来生产对应的 ast,ast 中包含依赖的文件
  3. 将依赖文件循环调用,生成模块 id 和依赖路径的关联信息,例如
    {"1": "./abc.js" }
    模拟 commonJs 的加载机制,将代码翻译成可以在浏览器里执行的样子 require 接收一个模块 id 然后从之前保存的依赖关联信息中找到并执行模块代码

什么是 webpack 和 grunt 和 gulp 有什么不同

Webpack 与 grunt 的 gulp 最大的不同各自的侧重点不同 grunt 和 gulp 是侧重在流程控制, webpack 侧重在打包机制。 其中 webpack 的 loader 和 gulp 的流程控制类似。

bundle、chunk、module

  • bundle 是 webpack 打包出来的文件
  • chunk 是 webpack 代码分割出来的代码块
  • Module 是开发过程中的模块

webpack-dev-server 的优点

在内存中存储开发环境下打包生成的文件、可以实现热更新。

模块热加载的原理

  1. 启动本地服务,当浏览器发起请求资源的请求时作出响应。
  2. webpack-dev-server 启动服务,与客户端形成 websocket 长连接
  3. webpack 监听本地文件变化,以触发重新编译,生成新的 hash 值和编译后已改动的文件,推送客户端消息编译完成。
  4. 客户端对比接收到的消息,一致缓存、不一致发起 ajax 请求和 jsonp 请求来做最新替换。
  5. 本地服务,替换存在内存中的文件内容实现实时刷新。

webpack 持久化缓存

Webpack 持久化缓存实践 - 前端大全 - CSDN博客

  • 保证业务静态资源文件的 hash 值变化,既每次发布都有唯一的 hash 值

webpack loader 的书写顺序有什么讲究吗,style-loader必须放在css-loader 之前吗

  • webpack loader 的加载顺序采用的是从右到左,是通过函数式编程中的 compose 来实现的,所以在书写的时候需要按照从右向左的顺序来,css-loader 在右,先处理,完成之后再交给 style-loader 处理,顺序不能调整

「开发模式的工作原理是?」

Posted on 2019-10-24 | In 翻译
  • 原文地址:开发模式的工作原理是什么?
  • 原文作者:Dan Abramov
  • 译文出自:掘金翻译计划
  • 本文永久链接:https://github.com/xitu/gold-miner/blob/master/TODO1/how-does-the-development-mode-work.md
  • 译者:Jerry-FD
  • 校对者:TokenJan、hanxiaosss

开发模式的工作原理是?

如果你的 JavaScript 代码库已经有些复杂了,你可能需要一个解决方案来针对线上和开发环境区分打包和运行不同代码。

针对开发环境和线上环境,来区分打包和运行不同的代码非常有用。在开发模式中,React 会包含很多告警来帮助你及时发现问题,而不至于造成线上 bug。然而,这些帮助发现问题的必要代码,往往会造成代码包大小增加以及应用运行变慢。

这种降速在开发环境下是可以接受的。事实上,在开发环境下运行代码的速度更慢可能更有帮助,因为这可以一定程度上消除高性能的开发机器与平均速度的用户设备而带来的差异。

在线上环境我们不想要任何的性能损耗。因此,我们在线上环境删除了这些校验。那么它的工作原理是什么?让我们来康康。


想要在开发环境运行下不同代码关键在于你的 JavaScript 构建工具(无论你用的是哪一个)。在 Facebook 中它长这个样子:

1
2
3
4
5
if (__DEV__) {
doSomethingDev();
} else {
doSomethingProd();
}

在这里,__DEV__ 不是一个真正的变量。当浏览器把模块之间的依赖加载完毕的时候,它会被替换成常量。结果是这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 在开发环境下:
if (true) {
doSomethingDev(); // 👈
} else {
doSomethingProd();
}

// 在线上环境:
if (false) {
doSomethingDev();
} else {
doSomethingProd(); // 👈
}

在线上环境,你可能会在代码中会启用压缩工具(比如, terser)。大多 JavaScript 压缩工具会针对无效代码做一些限制,比如删除 if (false) 的逻辑分支。所以在线上环境中,你可能只会看到:

1
2
// 在线上环境(压缩后):
doSomethingProd();

(注意,针对目前主流的 JavaScript 工具有一些重要的规范,这些规范可以指导怎样才能有效的移除无效代码,但这是另一个的话题了。)

可能你使用的不是 __DEV__ 这个神奇的变量,如果你是用的是流行的 JavaScript 打包工具,比如 webpack,那么这有一些你需要遵守的约定。比如,像这样的一种非常常见的表达式:

1
2
3
4
5
if (process.env.NODE_ENV !== 'production') {
doSomethingDev();
} else {
doSomethingProd();
}

一些框架比如 React 和 Vue 就是使用的这种形式。当你使用 npm 来打包载入它们的时候。 (单个的 <script> 标签会提供开发和线上版本的独立文件,并且使用 .js 和 .min.js 的结尾来作为区分。)

这个特殊的约定最早来自于 Node.js。在 Node.js 中,会有一个全局的 process 变量用来代表你当前系统的环境变量,它属于 process.env object 的一个属性。然而,如果你在前端的代码库里看到这种语法,其实是并不存在真正的 process 变量的。🤯

取而代之的是,整个 process.env.NODE_ENV 表达式在打包的时候会被替换成一个字面量的字符串,就像神奇的 __DEV__ 变量一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 在开发环境中:
if ('development' !== 'production') {
// true
doSomethingDev(); // 👈
} else {
doSomethingProd();
}

// 在线上环境中:
if ('production' !== 'production') {
// false
doSomethingDev();
} else {
doSomethingProd(); // 👈
}

因为整个表达式是常量('production' !== 'production' 恒为 false)打包压缩工具也可以借此删除其他的逻辑分支代码。

1
2
// 在线上环境(打包压缩后):
doSomethingProd();

恶作剧到此结束~


注意这个特性如果面对更复杂的表达式将不会工作:

1
2
3
4
let mode = 'production';
if (mode !== 'production') {
// 🔴 不能保证会被移除
}

JavaScript 静态分析工具不是特别智能,这是因为语言的动态特性所决定的。当它们发现像 mode 这样的变量,而不是像 false 或者 'production' !== 'production' 这样的静态表达式时,它们大概率会失效。

类似地,在 JavaScript 中如果你使用顶层的 import 声明,自动移除无用代码的逻辑会因为不能跨越模块边界而无法生效。

1
2
3
4
5
6
// 🔴 不能保证会被移除
import { someFunc } from 'some-module';

if (false) {
someFunc();
}

所以你的代码需要写的非常严格,来确保条件的绝对静态,并且确保所有你想要移除的代码都包含在条件内部。


为了保证一切按计划运行,你的打包工具需要替换 process.env.NODE_ENV,而且它需要知道你想要在哪种模式下构建项目。

在几年前,忘记配置环境变量非常常见。你会经常发现在开发模式下的项目被部署到了线上。

那很糟糕,因为这会使网站加载运行的速度很慢。

在过去的两年里,这种情况有了显著的改善。例如,webpack 增加了一个简单的 mode 选项,替换了原先手动更改 process.env.NODE_ENV。 React DevTools 现在也会针对开发模式下的站点展示一个红色的 icon,来使得它容易被察觉。

React DevTools 的开发模式警告

一些会帮你做预设置的安装工具比如 Create React App、Next/Nuxt、Vue CLI、Gatsby 等等,会把开发和线上构建分成两个独立的命令,来使得犯错的几率更小。(例如,npm start 和 npm run build。)也就是说,只有线上的构建代码才能被部署,所以开发者再也不可能犯这种错误了。

一直有一个在讨论的点是,把线上模式置为默认,开发模式变为可选项。个人来说,我认为这样做不是很好。从开发模式的警告中受益的人大多是刚刚接触这个框架的开发者。 他们不会意识到要打开开发模式的开关,这样就会错过很多应该被警告提前发现的 bug。

是的,性能问题非常糟糕,但充斥着 bug 的用户体验也是一样。例如,React key 警告 帮助防止发生像发错了消息或者买错了产品这样的 bug。如果在开发中禁用这个警告,对你和你的用户来说都是非常冒险的。因为如果它默认是关闭状态,而之后你发现了这个开关并把它打开了,你会发现有太多的警告需要清理。所以大多数人会再把它关上。所以这就是为什么它需要在开始时候就是打开状态,而不是之后才让它生效的原因。

最后,就算在开发中这些警告是可选项,并且开发者们也知道需要在开发的早期就把它们打开,我们还是要回到最开始的问题。还是会有一些开发者不小心把他们部署到线上环境中!

我们回到这一点来。

个人认为,我坚信工具展示和使用的正确模式取决于你是在调试还是在部署。几乎所有其他环境(无论是手机、桌面还是服务端)除了页面浏览器之外都已经有区分和加载不同的开发和线上环境的方法存在长达数十年了。

不能仅依靠框架提出或者依赖临时公约,可能 JavaScript 的环境是时候把这种区别作为一个很重要的需求来看待了。


大道理已经够了!

让我们再来看一眼代码:

1
2
3
4
5
if (process.env.NODE_ENV !== 'production') {
doSomethingDev();
} else {
doSomethingProd();
}

你可能想知道:如果在前端代码中不存在 process 对象,为什么像 React 和 Vue 这样的框架会在 npm 包中依赖它?

(再次声明:用 <script> 标签可以使用 React 和 Vue 提供的方式把它们加载到浏览器中,这不会依赖 process。取而代之的是,你必须要手动选择,在开发模式下的 .js 还是线上环境中的 .min.js 文件。下面的部分只是关于使用打包工具把 React 或者 Vue 从 npm 中 import 进来而使用它们。)

像编程中的很多问题一样,这种特殊的约定大多是历史原因。我们还在使用它的原因是因为,它现在已经被很多其他的工具所接受并适应了。换成其他的会有很大的代价,并且不是特别值得这么做。

所以背后的历史原因究竟是什么?

在 import 和 export 的语法被标准化的很多年前,有很多方式来表达模块之间的关系。比如 Node.js 中所受欢迎的 require() 和 module.exports,也就是著名的 CommonJS。

在 npm 上注册发布的代码早期多数是针对 Node.js 写的 Express 曾是(可能现在还是?)最受欢迎的服务端 Node.js 框架,它使用 NODE_ENV 这个环境变量 来使线上模式生效。 一些其他的 npm 包也采用了同样的约定。

早期的 JavaScript 打包工具比如 browserify 想要在前端工程中使用 npm 中的代码。(是的,那时候 在前端中几乎没人使用 npm!你可以想象吗?)所以它们拓展了当时在 Node.js 生态系统中的约定,将之应用于前端代码中。

最初的 “envify” 变革是在 2013 正式版。React 就是在差不多那个时候开源的,并且在那个时代 npm 和 browserify 看起来是是打包前端 CommonJS 代码的最佳解决方案。

React 在很早的时候就提供 npm 版本(还有 <script> 标签版本)。随着 React 变得流行起来,使用 CommonJS 模块来写 JavaScript 的模块化代码、并使用 npm 来管理发布代码也变成了最佳实践。

React 需要在线上环境移除只应该出现在开发模式中的代码。刚好 Browserify 已经针对这个问题提供了解决方案,所以 React 针对 npm 版本也接受了使用 process.env.NODE_ENV 的这个约定,随着时间的流逝,一些其他的工具和框架,包括 webpack 和 Vue,也采取了相同的措施。

到了 2019 年时,browserify 已经失去了很大一部分的市场占有率。然而,在构建的阶段把 process.env.NODE_ENV 替换成 'development' 或者 'production' 的这项约定,却一如既往的流行。

(同样有趣的是,了解 ES 模块的方式是如何一步步发展成作为线上的分发引用模式,而不仅仅只是在开发时使用的发展历史,它是如何慢慢改变天平的?在 Twitter 上告诉我)


另一件你可能会感到迷惑的事是,在 GitHub 上 React 源码中,你会看到 __DEV__ 被作为一个神奇的变量来使用。但是在 npm 上的 React 代码里,使用的却是 process.env.NODE_ENV。这是怎么做到的?

从历史上说,我们在源码中使用 __DEV__ 来匹配 Facebook 的源码。在很长一段时间里,React 被直接复制进 Facebook 的代码仓库里,所以它需要遵守相同的规则。对于 npm 的代码,我们有一个构建阶段,在发布代码之前会检查并使用 process.env.NODE_ENV !== 'production' 来字面地替换 __DEV__ 。

这有时会有一个问题。某些时候,遵循 Node.js 约定的代码在 npm 上运行的很好,但是会破坏 Facebook,反之亦然。

从 React 16 起,我们改变了这种方式。取而代之,现在我们会针对每一个环境编译一个包(包括 <script> 标签、npm 和 Facebook 内部的代码仓库)。所以甚至是 npm 的 CommonJS 代码也被提前编译成独立的开发和线上包。

这意味着当 React 源码中出现 if (__DEV__) 的时候,事实上我们会对每一个包产出两个代码块。一个被预编译为 __DEV__ = true 另一个是 __DEV__ = false。每一个 npm 包的入口来“决定”该导出哪一个。

例如:

1
2
3
4
5
if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/react.production.min.js');
} else {
module.exports = require('./cjs/react.development.js');
}

这是你的打包工具把 'development' 或者 'production' 替换为字符串的唯一地方。也是你的压缩工具除去只应在开发环境中 require 代码的唯一地方。

react.production.min.js 和 react.development.js 不再有任何 process.env.NODE_ENV 检查了。这很有意义,因为当代码真正运行在 Node.js 中的时候, 访问 process.env 有可能会很慢。提前编译两个模式下的代码包也可以帮助我们优化文件的大小变得更加一致,无论你使用的是哪个打包压缩工具。

这就是它的工作原理!


我希望有一个更好的方法而不是依赖约定,但是我们已经到这了。如果在所有的 JavaScript 环境中,模式是一个非常重要的概念,并且如果有什么方法能够在浏览器层面来展示这些本不该出现的运行在开发环境下的代码,那就非常棒了。

另一方面,在单个项目中的约定可以传播到整个生态系统,这点非常神奇。2010 年 EXPRESS_ENV 变成了 NODE_ENV 并在 2013 年蔓延到前端。可能这个解决方案并不完美,但是对每一个项目来说,接受它的成本远比说服其他每一个人去做一些改变的成本要低得多。这教会了我们宝贵的一课,关于自上而下与自下而上的方案接受。理解了相比于那些失败的标准来说它是如何一步步地转变成功的标准的。

隔离开发和线上模式是一个非常有用的技术。我建议你在你的库和应用中使用这项技术,来做一些在线上环境很重,但是在开发环境中却非常有用(通常是严格的)的校验和检查。

和任何功能强大的特性一样,有些情况下你可能也会滥用它。这是我下一篇文章的话题!

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。

「使用 React Hook 来检查网络连接状态」

Posted on 2019-10-24 | In 翻译
使用 hooks 轻松实现掉线最佳体验
Read more »

「如何用 React Hooks 打造一个不到 100 行代码的异步表单校验库」

Posted on 2019-10-24 | In 翻译
  • 原文地址:How I built an async form validation library in ~100 lines of code with React Hooks
  • 原文作者:Austin Malerba
  • 译文出自:掘金翻译计划
  • 本文永久链接:https://github.com/xitu/gold-miner/blob/master/TODO1/how-i-built-an-async-form-validation-library-in-100-lines-of-code-with-react-hooks.md
  • 译者:Jerry-FD
  • 校对者:yoyoyohamapi,Xuyuey,xiaonizi1994

表单校验是一件很棘手的事情。深入了解表单的实现之后,你会发现有大量的边界场景要处理。幸运的是,市面上有很多表单校验库,它们提供了必要的表计量(译注:如 dirty、invalid、inItialized、pristine 等等)和处理函数,来让我们实现一个健壮的表单。但我要使用 React Hooks API 来打造一个 100 行代码以下的表单校验库来挑战自我。虽然 React Hooks 还在实验性阶段,但是这是一个 React Hooks 实现表单校验的证明。

我要声明的是,我写的这个库确实是不到 100 行代码。但这个教程却有 200 行左右的代码,是因为我需要阐释清楚这个库是如何使用的。

我看过的大多数表单库的新手教程都离不开三个核心话题:异步校验,表单联动:某些表单项的校验需要在其他表单项改变时触发,表单校验效率的优化。我非常反感那些教程把使用场景固定,而忽略其他可变因素的影响的做法。因为在真实场景中往往事与愿违,所以我的教程会尽量覆盖更多真实场景。

我们的目标需要满足:

  • 同步校验单个表单项,包括当表单项的值发生变化时,会跟随变化的有依赖的表单项

  • 异步校验单个表单项,包括当表单项的值发生变化时,会跟随变化的有依赖的表单项

  • 在提交表单前,同步校验所有表单项

  • 在提交表单前,异步校验所有表单项

  • 尝试异步提交,如果表单提交失败,展示返回的错误信息

  • 给开发者提供校验表单的函数,让开发者能够在合适的时机,比如 onBlur 的时候校验表单

  • 允许单个表单项的多重校验

  • 当表单校验未通过时禁止提交

  • 表单的错误信息只在有错误信息变化或者尝试提交表单的时候才展示出来

我们将会通过实现一个包含用户名,密码,密码二次确认的账户注册表单来覆盖这些场景。下面是个简单的界面,我们来一起打造这个库吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const form = useForm({
onSubmit,
});

const usernameField = useField("username", form, {
defaultValue: "",
validations: [
async formData => {
await timeout(2000);
return formData.username.length < 6 && "Username already exists";
}
],
fieldsToValidateOnChange: []
});
const passwordField = useField("password", form, {
defaultValue: "",
validations: [
formData =>
formData.password.length < 6 && "Password must be at least 6 characters"
],
fieldsToValidateOnChange: ["password", "confirmPassword"]
});
const confirmPasswordField = useField("confirmPassword", form, {
defaultValue: "",
validations: [
formData =>
formData.password !== formData.confirmPassword &&
"Passwords do not match"
],
fieldsToValidateOnChange: ["password", "confirmPassword"]
});

// const { onSubmit, getFormData, addField, isValid, validateFields, submitted, submitting } = form
// const { name, value, onChange, errors, setErrors, pristine, validate, validating } = usernameField

这是一个非常简单的 API,但着实给了我们很大的灵活性。你可能已经意识到了,这个接口包含两个名字很像的函数, validation 和 validate。validation 被定义成一个函数,它以表单数据和表单项的 name 为参数,如果验证出了问题,则返回一个错误信息,与此同时它会返回一个虚值(译者注:可转换为 false 的值)。另一方面,validate 函数会执行这个表单项的所有 validation 函数,并且更新这个表单项的错误列表。

重中之重,我们需要一个来处理表单值的变化和表单提交的骨架。我们的第一次尝试不会包含任何校验,它仅仅用来处理表单的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// 跳过样板代码: imports, ReactDOM, 等等.

export const useField = (name, form, { defaultValue } = {}) => {
let [value, setValue] = useState(defaultValue);

let field = {
name,
value,
onChange: e => {
setValue(e.target.value);
}
};
// 注册表单项
form.addField(field);
return field;
};

export const useForm = ({ onSubmit }) => {
let fields = [];

const getFormData = () => {
// 获得一个包含原始表单数据的 object
return fields.reduce((formData, field) => {
formData[field.name] = field.value;
return formData;
}, {});
};

return {
onSubmit: async e => {
e.preventDefault(); // 阻止默认表单提交
return onSubmit(getFormData());
},
addField: field => fields.push(field),
getFormData
};
};

const Field = ({ label, name, value, onChange, ...other }) => {
return (
<FormControl className="field">
<InputLabel htmlFor={name}>{label}</InputLabel>
<Input value={value} onChange={onChange} {...other} />
</FormControl>
);
};

const App = props => {
const form = useForm({
onSubmit: async formData => {
window.alert("Account created!");
}
});

const usernameField = useField("username", form, {
defaultValue: ""
});
const passwordField = useField("password", form, {
defaultValue: ""
});
const confirmPasswordField = useField("confirmPassword", form, {
defaultValue: ""
});

return (
<div id="form-container">
<form onSubmit={form.onSubmit}>
<Field {...usernameField} label="Username" />
<Field {...passwordField} label="Password" type="password" />
<Field {...confirmPasswordField} label="Confirm Password" type="password" />
<Button type="submit">Submit</Button>
</form>
</div>
);
};

这里没有太难理解的代码。表单的值是我们唯一所关心的。每个表单项在它初始化结束之前把自身注册在表单上。我们的 onChange 函数也很简单。这里最复杂的函数就是 getFormData,即便如此,这也无法跟抽象的 reduce 语法相比。getFormData 遍历所有表单项,并返回一个 plain object 来表示表单的值。最后值得一提的就是在表单提交的时候,我们需要调用 preventDefault 来阻止页面重新加载。

事情发展的很顺利,现在我们来把验证加上吧。当表单项的值发生变化或者提交表单的时候,我们不是指明哪些具体的表单项需要被校验,而是校验所有的表单项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
export const useField = (
name,
form,
{ defaultValue, validations = [] } = {}
) => {
let [value, setValue] = useState(defaultValue);
let [errors, setErrors] = useState([]);

const validate = async () => {
let formData = form.getFormData();
let errorMessages = await Promise.all(
validations.map(validation => validation(formData, name))
);
errorMessages = errorMessages.filter(errorMsg => !!errorMsg);
setErrors(errorMessages);
let fieldValid = errorMessages.length === 0;
return fieldValid;
};

useEffect(
() => {
form.validateFields(); // 当 value 变化的时候校验表单项
},
[value]
);

let field = {
name,
value,
errors,
validate,
setErrors,
onChange: e => {
setValue(e.target.value);
}
};
// 注册表单项
form.addField(field);
return field;
};

export const useForm = ({ onSubmit }) => {
let fields = [];

const getFormData = () => {
// 获得一个 object 包含原始表单数据
return fields.reduce((formData, field) => {
formData[field.name] = field.value;
return formData;
}, {});
};

const validateFields = async () => {
let fieldsToValidate = fields;
let fieldsValid = await Promise.all(
fieldsToValidate.map(field => field.validate())
);
let formValid = fieldsValid.every(isValid => isValid === true);
return formValid;
};

return {
onSubmit: async e => {
e.preventDefault(); // 阻止表单提交默认事件
let formValid = await validateFields();
return onSubmit(getFormData(), formValid);
},
addField: field => fields.push(field),
getFormData,
validateFields
};
};

const Field = ({
label,
name,
value,
onChange,
errors,
setErrors,
validate,
...other
}) => {
let showErrors = !!errors.length;
return (
<FormControl className="field" error={showErrors}>
<InputLabel htmlFor={name}>{label}</InputLabel>
<Input
id={name}
value={value}
onChange={onChange}
onBlur={validate}
{...other}
/>
<FormHelperText component="div">
{showErrors &&
errors.map(errorMsg => <div key={errorMsg}>{errorMsg}</div>)}
</FormHelperText>
</FormControl>
);
};

const App = props => {
const form = useForm({
onSubmit: async formData => {
window.alert("Account created!");
}
});

const usernameField = useField("username", form, {
defaultValue: "",
validations: [
async formData => {
await timeout(2000);
return formData.username.length < 6 && "Username already exists";
}
]
});
const passwordField = useField("password", form, {
defaultValue: "",
validations: [
formData =>
formData.password.length < 6 && "Password must be at least 6 characters"
]
});
const confirmPasswordField = useField("confirmPassword", form, {
defaultValue: "",
validations: [
formData =>
formData.password !== formData.confirmPassword &&
"Passwords do not match"
]
});

return (
<div id="form-container">
<form onSubmit={form.onSubmit}>
<Field {...usernameField} label="Username" />
<Field {...passwordField} label="Password" type="password" />
<Field {...confirmPasswordField} label="Confirm Password" type="password" />
<Button type="submit">Submit</Button>
</form>
</div>
);
};

上面的代码是改进版,大体浏览一下似乎可以跑起来了,但是要做到交付给用户还远远不够。这个版本丢掉了很多用于隐藏错误信息的标记态(译者注:flag),这些错误信息可能会在不恰当的时机出现。比如在用户还没修改完输入信息的时候,表单就立马校验并展示相应的错误信息了。

最基本的,我们需要一些基础的标记状态来告知 UI,如果用户没有修改表单项的值,那么就不展示错误信息。再进一步,除了这些基础的,我们还需要一些额外的标记状态。

我们需要一个标记态来记录用户已经尝试提交表单了,以及一个标记态来记录表单正在提交中或者表单项正在进行异步校验。你可能也想弄清楚我们为什么要在 useEffect 的内部调用 validateFields,而不是在 onChange 里调用。我们需要 useEffect 是因为 setValue 是异步发生的,它既不会返回一个 promise,也不会给我们提供一个 callback。因此,唯一能让我们确定 setValue 是否完成的方法,就是通过 useEffect 来监听值的变化。

现在我们一起来实现这些所谓的标记态吧。用它们来更好的完善 UI 和细节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
export const useField = (
name,
form,
{ defaultValue, validations = [], fieldsToValidateOnChange = [name] } = {}
) => {
let [value, setValue] = useState(defaultValue);
let [errors, setErrors] = useState([]);
let [pristine, setPristine] = useState(true);
let [validating, setValidating] = useState(false);
let validateCounter = useRef(0);

const validate = async () => {
let validateIteration = ++validateCounter.current;
setValidating(true);
let formData = form.getFormData();
let errorMessages = await Promise.all(
validations.map(validation => validation(formData, name))
);
errorMessages = errorMessages.filter(errorMsg => !!errorMsg);
if (validateIteration === validateCounter.current) {
// 最近一次调用
setErrors(errorMessages);
setValidating(false);
}
let fieldValid = errorMessages.length === 0;
return fieldValid;
};

useEffect(
() => {
if (pristine) return; // 避免渲染完成后的第一次校验
form.validateFields(fieldsToValidateOnChange);
},
[value]
);

let field = {
name,
value,
errors,
setErrors,
pristine,
onChange: e => {
if (pristine) {
setPristine(false);
}
setValue(e.target.value);
},
validate,
validating
};
form.addField(field);
return field;
};

export const useForm = ({ onSubmit }) => {
let [submitted, setSubmitted] = useState(false);
let [submitting, setSubmitting] = useState(false);
let fields = [];

const validateFields = async fieldNames => {
let fieldsToValidate;
if (fieldNames instanceof Array) {
fieldsToValidate = fields.filter(field =>
fieldNames.includes(field.name)
);
} else {
// 如果 fieldNames 缺省,则验证所有表单项
fieldsToValidate = fields;
}
let fieldsValid = await Promise.all(
fieldsToValidate.map(field => field.validate())
);
let formValid = fieldsValid.every(isValid => isValid === true);
return formValid;
};

const getFormData = () => {
return fields.reduce((formData, f) => {
formData[f.name] = f.value;
return formData;
}, {});
};

return {
onSubmit: async e => {
e.preventDefault();
setSubmitting(true);
setSubmitted(true); // 用户已经至少提交过一次表单
let formValid = await validateFields();
let returnVal = await onSubmit(getFormData(), formValid);
setSubmitting(false);
return returnVal;
},
isValid: () => fields.every(f => f.errors.length === 0),
addField: field => fields.push(field),
getFormData,
validateFields,
submitted,
submitting
};
};

const Field = ({
label,
name,
value,
onChange,
errors,
setErrors,
pristine,
validating,
validate,
formSubmitted,
...other
}) => {
let showErrors = (!pristine || formSubmitted) && !!errors.length;
return (
<FormControl className="field" error={showErrors}>
<InputLabel htmlFor={name}>{label}</InputLabel>
<Input
id={name}
value={value}
onChange={onChange}
onBlur={() => !pristine && validate()}
endAdornment={
<InputAdornment position="end">
{validating && <LoadingIcon className="rotate" />}
</InputAdornment>
}
{...other}
/>
<FormHelperText component="div">
{showErrors &&
errors.map(errorMsg => <div key={errorMsg}>{errorMsg}</div>)}
</FormHelperText>
</FormControl>
);
};

const App = props => {
const form = useForm({
onSubmit: async (formData, valid) => {
if (!valid) return;
await timeout(2000); // 模拟网络延迟
if (formData.username.length < 10) {
//模拟服务端返回 400
usernameField.setErrors(["Make a longer username"]);
} else {
//模拟服务端返回 201
window.alert(
`form valid: ${valid}, form data: ${JSON.stringify(formData)}`
);
}
}
});

const usernameField = useField("username", form, {
defaultValue: "",
validations: [
async formData => {
await timeout(2000);
return formData.username.length < 6 && "Username already exists";
}
],
fieldsToValidateOnChange: []
});
const passwordField = useField("password", form, {
defaultValue: "",
validations: [
formData =>
formData.password.length < 6 && "Password must be at least 6 characters"
],
fieldsToValidateOnChange: ["password", "confirmPassword"]
});
const confirmPasswordField = useField("confirmPassword", form, {
defaultValue: "",
validations: [
formData =>
formData.password !== formData.confirmPassword &&
"Passwords do not match"
],
fieldsToValidateOnChange: ["password", "confirmPassword"]
});

let requiredFields = [usernameField, passwordField, confirmPasswordField];

return (
<div id="form-container">
<form onSubmit={form.onSubmit}>
<Field
{...usernameField}
formSubmitted={form.submitted}
label="Username"
/>
<Field
{...passwordField}
formSubmitted={form.submitted}
label="Password"
type="password"
/>
<Field
{...confirmPasswordField}
formSubmitted={form.submitted}
label="Confirm Password"
type="password"
/>
<Button
type="submit"
disabled={
!form.isValid() ||
form.submitting ||
requiredFields.some(f => f.pristine)
}
>
{form.submitting ? "Submitting" : "Submit"}
</Button>
</form>
</div>
);
};

最后一次尝试,我们加了很多东西进去。包括四个标记态:pristine、validating、submitted 和 submitting。还添加了 fieldsToValidateOnChange,将它传给 validateFields 来声明当表单的值发生变化的时候哪些表单项需要被校验。我们在 UI 层通过这些标记状态来控制何时展示错误信息和加载动画以及禁用提交按钮。

你可能注意到了一个很特别的东西 validateCounter。我们需要记录 validate 函数的调用次数,因为 validate 在当前的调用完成之前,它有可能会被再次调用。如果是这种场景的话,我们应该放弃当前调用的结果,而只使用最新一次的调用结果来更新表单项的错误状态。

一切就绪之后,这就是我们的成果了。

  • https://codesandbox.io/embed/x964kxp2vo

React Hooks 提供了一个简洁的表单校验解决方案。这是我使用这个 API 的第一次尝试。尽管有一点瑕疵,但是我依然感到了它的强大。这个接口有些奇怪,因为是按照我喜欢的样子来的。然而除了这些瑕疵以外,它的功能还是很强大的。

我觉得它还少了一些特性,比如一个 callback 机制来表明何时 useState 更新 state 完毕,这也是一个在 useEffect hook 中检查对比 prop 变化的方法。

后记

为了保证这个教程的易于上手,我刻意省略了一些参数的校验和异常错误处理。比如,我没有校验传入的 form 参数是否真的是一个 form 对象。如果我能明确地校验它的类型并抛出一个详细的异常信息会更好。事实上,我已经写了,代码会像这样报错。

1
Cannot read property ‘addField’ of undefined

在把这份代码发布成 npm 包之前,还需要合适的参数校验和异常错误处理。如我所说,如果你想深入了解的话,我已经用 superstruct 实现了一个包含参数校验的更健壮的版本。

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。

「X 为啥不是 Hook?」

Posted on 2019-10-24 | In 翻译
  • 原文地址:Why Isn’t X a Hook?
  • 原文作者:Dan Abramov
  • 译文出自:掘金翻译计划
  • 本文永久链接:https://github.com/xitu/gold-miner/blob/master/TODO1/why-isnt-x-a-hook.md
  • 译者:Jerry-FD
  • 校对者:yoyoyohamapi, CoolRice

X 为啥不是 hook?

由读者翻译的版本:西班牙语

自 React Hooks 第一个 alpha 版本发布以来, 这个问题一直被激烈讨论:“为什么 API 不是 hook?”

你要知道,只有下面这几个算是 hooks:

  • useState() 用来声明 state 变量
  • useEffect() 用来声明副作用
  • useContext() 用来读取一些上下文

但是像 React.memo() 和 <Context.Provider>,这些 API 它们不是 Hooks。一般来说,这些 Hook 版本的 API 被认为是 非组件化 或 反模块化 的。这篇文章将帮助你理解其中的原理。

注:这篇文章并非教你如何高效的使用 React,而是对 hooks API 饶有兴趣的开发者所准备的深入分析。


以下两个重要的属性是我们希望 React 的 APIs 应该拥有的:

  1. 可组合:Custom Hooks(自定义 Hooks)极大程度上决定了 Hooks API 为何如此好用。我们希望开发者们经常使用自定义 hooks,这样就需要确保不同开发者所写的 hooks 不会冲突。(撰写干净并且不会相互冲突的组件实在太棒了)

  2. 可调试:随着应用的膨胀,我们希望 bug 很容易被发现。React 最棒的特性之一就是,当你发现某些渲染错误的时候,你可以顺着组件树寻找,直到找出是哪一个组件的 props 或 state 的值导致的错误。

有了这两个约束,我们就知道哪些算是真正意义上的 Hook,而哪些不算。


一个真正的 Hook: useState()

可组合

多个自定义 Hooks 各自调用 useState() 不会冲突:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function useMyCustomHook1() {
const [value, setValue] = useState(0);
// 无论这里做了什么,它都只会作用在这里
}

function useMyCustomHook2() {
const [value, setValue] = useState(0);
// 无论这里做了什么,它都只会作用在这里
}

function MyComponent() {
useMyCustomHook1();
useMyCustomHook2();
// ...
}

无限制的调用一个 useState() 总是安全的。在你声明新的状态量时,你不用理会其他组件用到的 Hooks,也不用担心状态量的更新会相互干扰。

结论: ✅ useState() 不会使自定义 Hooks 变得脆弱。

可调试

Hooks 非常好用,因为你可以在 Hooks 之间传值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
// ...
return width;
}

function useTheme(isMobile) {
// ...
}

function Comment() {
const width = useWindowWidth();
const isMobile = width < MOBILE_VIEWPORT;
const theme = useTheme(isMobile);
return (
<section className={theme.comment}>
{/* ... */}
</section>
);
}

但是如果我们的代码出错了呢?我们又该怎么调试?

我们先假设,从 theme.comment 拿到的 CSS 的 class 是错的。我们该怎么调试? 我们可以打一个断点或者在我们的组件体内加一些 log。

我们可能会发现 theme 是错的,但是 width 和 isMobile 是对的。这会提示我们问题出在 useTheme() 内部。又或许我们发现 width 本身是错的。这可以指引我们去查看 useWindowWidth()。

简单看一下中间值就能指导我们哪个顶层的 Hooks 有 bug。 我们不需要挨个去查看他们所有的实现。

这样,我们就能够洞察 bug 所在的部分,几次三番之后,程序问题终得其解。

如果我们的自定义 Hook 嵌套的层级加深的时候,这一点就显得很重要了。假设一下我们有一个 3 层嵌套的自定义 Hook,每一层级的内部又用了 3 个不同的自定义 Hooks。在 3 处找bug和最多 3 + 3×3 + 3×3×3 = 39 处找 bug 的区别是巨大的。幸运的是, useState() 不会魔法般的 “影响” 其他 Hooks 或组件。与任何 useState() 所返回的变量一样,一个可能造成 bug 的返回值也是有迹可循的。

结论: ✅ useState() 不会使你的代码逻辑变得模糊不清,我们可以直接沿着面包屑找到 bug。


它不是一个 Hook: useBailout()

作为一个优化点,组件使用 Hooks 可以避免重复渲染(re-rendering)。

其中一个方法是使用 React.memo() 包裹住整个组件。如果 props 和上次渲染完之后对比浅相等(shallowly equal),就可以避免重复渲染。这和 class 模式中的PureComponent 很像。

React.memo() 接受一个组件作为参数,并返回一个组件:

1
2
3
4
function Button(props) {
// ...
}
export default React.memo(Button);

但它为什么就不是 Hook?

不论你叫它 useShouldComponentUpdate()、usePure()、useSkipRender() 还是 useBailout(),它看起来都差不多长这样:

1
2
3
4
5
6
7
8
9
10
function Button({ color }) {
// ⚠️ 不是真正的 API
useBailout(prevColor => prevColor !== color, color);

return (
<button className={'button-' + color}>
OK
</button>
)
}

还有一些其他的变种 (比如:一个简单的 usePure()) 但是大体上来说,他们都有一些相同的缺陷。

可组合

我们来试试把 useBailout() 放在 2 个自定义 Hooks 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function useFriendStatus(friendID) {
const [isOnline, setIsOnline] = useState(null);

// ⚠️ 不是真正的 API
useBailout(prevIsOnline => prevIsOnline !== isOnline, isOnline);

useEffect(() => {
const handleStatusChange = status => setIsOnline(status.isOnline);
ChatAPI.subscribe(friendID, handleStatusChange);
return () => ChatAPI.unsubscribe(friendID, handleStatusChange);
});

return isOnline;
}

function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);

// ⚠️ 不是真正的 API
useBailout(prevWidth => prevWidth !== width, width);

useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
});

return width;
}

译注:使用了 useBailout 后,useFriendStatus 只会在 isOnline 状态变化时才允许 re-render,useWindowWidth 只会在 width 变化时才允许 re-render。

现在如果你在同一个组件中同时用到他们会怎么样呢?

1
2
3
4
5
6
7
8
9
10
function ChatThread({ friendID, isTyping }) {
const width = useWindowWidth();
const isOnline = useFriendStatus(friendID);
return (
<ChatLayout width={width}>
<FriendStatus isOnline={isOnline} />
{isTyping && 'Typing...'}
</ChatLayout>
);
}

什么时候会 re-render 呢?

如果每一个 useBailout() 的调用都有能力跳过这次更新,如果 useFriendStatus() 阻止了 re-render,那么 useWindowWidth 就无法获得更新,反之亦然。这些 Hooks 会相互阻塞。

然而,在组件内部,倘若只有所有调用了 useBailout() 都同意不 re-render 组件才不会更新,那么当 props 中的 isTyping 改变时,由于内部所有 useBailout() 调用都没有同意更新,导致 ChatThread 也无法更新。

基于这种假设,将导致更糟糕的局面,任何新置入组件的 Hooks 都需要去调用 useBailout(),不这样做的话,它们就无法投出“反对票”来让自己获得更新。

结论: 🔴 useBailout() 破坏了可组合性。添加一个 Hook 会破坏其他 Hooks 的状态更新。我们希望这些 APIs 是稳定的,但是这个特性显然是与之相反了。

Debugging

useBailout() 对调试有什么影响呢?

我们用相同的例子:

1
2
3
4
5
6
7
8
9
10
function ChatThread({ friendID, isTyping }) {
const width = useWindowWidth();
const isOnline = useFriendStatus(friendID);
return (
<ChatLayout width={width}>
<FriendStatus isOnline={isOnline} />
{isTyping && 'Typing...'}
</ChatLayout>
);
}

事实上即使 prop 上层的某处改变了,Typing... 这个 label 也不会像我们期望的那样出现。那么我们怎么调试呢?

一般来说, 在 React 中你可以通过向上寻找的办法,自信的回答这个问题。 如果 ChatThread 没有得到新的 isTyping 的值, 我们可以打开那个渲染 <ChatThread isTyping={myVar} /> 的组件,检查 myVar,诸如此类。 在其中的某一层, 我们会发现要么是容易出错的 shouldComponentUpdate() 跳过了渲染, 要么是一个错误的 isTyping 的值被传递了下来。通常来说查看这条链路上的每个组件,已经足够定位到问题的来源了。

然而, 假如这个 useBailout() 真是个 Hook,如果你不检查我们在 ChatThread 中用到的每一个自定义 Hook (深入地) 和在各自链路上的所有组件,你永远都不会知道跳过这次更新的原因。更因为任何父组件也可能会用到自定义 Hooks, 这个规模很恐怖。

这就像你要在抽屉里找一把螺丝刀,而每一层抽屉里都包含一堆小抽屉,你无法想象爱丽丝仙境中的兔子洞有多深。

结论:🔴 useBailout() 不仅破坏了可组合性,也极大的增加了调试的步骤和找 bug 过程的认知负担 — 某些时候,是指数级的。


全文我们探讨了一个真正的 Hook,useState(),和一个不太算是 Hook 的 useBailout(),并从可组合性及可调试性两个方面说明了为什么一个是 Hook,而一个不算是 Hook。

尽管现在没有 “Hook 版本的 memo() 或 shouldComponentUpdate(),但 React 确实提供了一个名叫 useMemo() 的 Hook。它有类似的作用,但是他的语义不会迷惑使用它的人。

useBailout() 这个例子,描述了控制组件是否 re-render 并不适合做成一个 hook。这里还有一些其他的例子 - 例如,useProvider(), useCatch(),useSuspense()。

现在你知道为什么某些 API 不算是 Hook 了吗?

(当你开始迷惑时,就提醒自己:可组合… 可调试)

Discuss on Twitter • Edit on GitHub

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。

「Google 的 Pagespeed 的工作原理:提升你的页面分数和搜索引擎排名」

Posted on 2019-10-24 | In 翻译
  • 原文地址:Google 的 Pagespeed 的工作原理:提升你的分数和搜索引擎排名
  • 原文作者:Ben Schwarz
  • 译文出自:掘金翻译计划
  • 本文永久链接:https://github.com/xitu/gold-miner/blob/master/TODO1/how-pagespeed-works.md
  • 译者:Jerry-FD
  • 校对者:weberpan,Endone

通过这篇文章,我们将揭开 PageSpeed 最为重要的页面速度评分的计算方法。

毫无疑问,页面的加载速度已经成了提升页面收益和降低流失率的关键性因素。由于 Google 已经将页面的加载速度列入影响其搜索排名的因素,现在更多的企业和组织都把目光聚焦在提升页面性能上了。

去年 Google 针对他们的搜索排名算法做了两个重大的调整:

  • 三月,搜索结果排名以移动端版本的页面为基础,取代之前的桌面端版本。
  • 七月,SEO 排名算法更新为,增加页面的加载速度作为影响其搜索排名的因素,如移动端页面排名和广告排名。

通过这些改变,我们可以总结出两个结论:

  • 手机端页面的加载速度会影响你整站的 SEO 排名。
  • 如果你的页面加载很慢,就会降低你的广告质量分,进而你的广告费会更贵。

Google 道:

更快的加载速度不仅仅会提升我们的体验;最近的数据显示,提升页面的加载速度也会降低操作成本。和我们一样,我们的用户就很重视速度 — 这就是我们决定将页面的速度这一因素,加入搜索排名计算的原因。

为了从页面性能的角度搞清楚这些变化给我们带来了什么影响,我们需要掌握这些基础知识。PageSpeed 5.0 是之前版本的一次颠覆性的改动。现在由 Lighthouse 和 CrUX 提供技术支持(Chrome 用户体验报告部)。

这次升级使用了新的评分算法,将会使获得 PageSpeed 高分更加困难。

PageSpeed 5.0 有哪些变化?

5.0 之前,PageSpeed 会针对测试的页面给出一系列指导意见。如果页面里有很大的、未经压缩的图片,PageSpeed 会建议对图片压缩。再比如,漏掉了 Cache-Headers,会建议加上。

这些建议是与一些指导方针对应的,如果遵从这些指导方针,很可能会提升你的页面性能,但这些也仅仅是表层的,它不会分析用户在真实场景下的加载和渲染页面的体验。

在 PageSpeed 5.0 中,页面在 Lighthouse 的控制下被载入到真实的 Chrome 浏览器中。Lighthouse 从浏览器中获取记录各项指标,把这些指标套入得分模型里计算,最后展示一个整体的性能分。根据具体的分数指标来给出优化的指导方针。

和 PageSpeed 类似,Lighthouse 也有一个性能分。在 PageSpeed 5.0 中,性能分直接从 Lighthouse 里获取。所以现在 PageSpeed 的速度分和 Lighthouse 的性能分一样了。

Calibre 在 Google 的 Pagespeed 上获得了 97 分

既然我们知道了 PageSpeed 的分数从哪里来,接下来我们就来仔细研究它是如何计算的,以及我们该如何有效的提高页面的性能。

Google Lighthouse 是什么?

Lighthouse 是一个开源项目,由一只来自 Google Chrome 的优秀团队运作。在过去的几年里,它已逐步变成免费的性能分析工具。

Lighthouse 使用 Chrome 的远程调试协议来获取网络请求的信息、计算 JavaScript 的性能、评估无障碍化级别以及计算用户关注的时间指标,比如 首次内容绘制时间 First Contentful Paint、可交互时间 Time to Interactive 和速度指标。

如果你想要深入了解 Lighthouse 的整体架构,请看来自官方的教程。

Lighthouse 如何计算性能分数

在性能测试中,Lighthouse 聚焦于用户所见和用户体验,记录了很多指标。

下面这 6 个指标构成了性能分数的大体部分。他们是:

  • 可交互时间 Time to Interactive (TTI)
  • 速度指标 Speed Index
  • 首次内容绘制时间 First Contentful Paint (FCP)
  • 首次 CPU 空闲时间 First CPU Idle
  • 首次有效绘制 First Meaningful Paint (FMP)
  • 预计输入延迟时间 Estimated Input Latency

Lighthouse 会针对这些指标运用一个 0 – 100 的分数模型。 这个过程会收集移动端第 75 和第 90 百分位的 HTTP 档案,然后输入到对数正太分布函数(校对者注:这样的话只要性能数据低于 25% 的线上移动端页面,也就是排位在 75% 以下,都给 0 分,而只要比 95% 的移动端页面得分高,就得满分)。

根据算法和可交互时间的计算所得数据,我们可以发现,如果一个页面在 2.1 秒内成为“可交互的”,那么它的可交互时间分数指标是 92/100。

当每个指标完成计分后会被分配一个权重,用权重调整后算出页面整体的性能分数。权重规则如下:

指标 权重
可交互时间 (TTI) 5
速度指标 4
首次内容绘制时间 3
首次 CPU 空闲时间 2
首次有效绘制 1
预计输入延迟时间 0

这些权重取决于每个指标对移动端用户的体验的影响程度。

在未来,这些权重在参考来自于 Chrome 用户体验报告的用户观测数据之后,还可能会被进一步优化。

你可能想知道究竟这每一个指标的权重是如何影响整体得分的。Lighthouse 团队打造了一款实用的 Google 电子表格计算器来阐述具体的细节:

这张电子表格的图片可以用来计算性能分数

使用上面的例子,如果我们把可交互时间从 5 秒 变为 17 秒 (全球移动端平均 TTI),我们的分数会降低到 56% (也就是 100 分之中的 56 分)。

然而,如果我们把首次内容绘制时间变为 17 秒,我们的分数会是 62%。

可交互时间 (TTI) 是对你的性能分数影响最大的指标。

因此,想要得到 PageSpeed 的高分,你最需要的是降低 TTI。

剑指 TTI

深入来说,有两个对 TTI 影响极大的重要因素:

  • 传输到页面的 JavaScript 代码的总大小
  • 主线程上 JavaScript 的运行时间

我们的可交互时间文章详细说明了 TTI 的工作原理,但如果你想要一些快速无脑的优化,我们建议:

降低 JavaScript 总大小

尽可能地,移除无用的 JavaScript 代码,或者只传输当前页面会执行的代码。这可能意味着要移除老的 polyfills 或者尽量采用更小、更新的第三方库。

你需要记住的是 JavaScript 花费的 不仅仅是下载它所需要的时间。浏览器需要解压、解析、编译然后才最终执行,这些过程都会消耗不容忽视的时间,尤其在移动设备上。

能降低你的页面脚本总大小的有效措施是:

  • 检查并移除对你的用户来说并不需要的 polyfills。
  • 搞清楚每一个第三方 JavaScript 库所花费的时间。使用 webpack-bundle-analyser 或者 source-map-explorer 来可视化分析他们的大小。
  • 现代 JavaScript 工具(比如 webpack)可以把大的 JavaScript 应用分解成许多小的 bundles,随着用户的浏览而动态加载。这就是所谓的 code splitting,它会极大地优化 TTI。
  • Service workers 会缓存解析和编译后所得的字节码。如果善加利用这个特性,用户只需花费一次解析和编译代码带来的时间损耗,在那之后的结果就会被缓存优化。

监控可交互时间

为了较好的展示用户体验的差异性,我们建议使用监控系统(比如 Calibre),它可以测试页面在两个不同设备上的最小评分;一个较快的桌面端设备和一个中等速度的移动端设备。

这样的话,你就可以得到你的用户可能体验到的最好和最差两种情况下的数据。是时候意识到,你的用户并没有使用和你一样强大的设备了。

深度剖析

为了获得剖析 JavaScript 性能的最好结果,可以刻意使用较慢的移动设备来测试你的页面。如果你的抽屉里有一部老手机,你会发现一片新的天地。

Chrome DevTools 的硬件仿真模块可以很好的替代真实设备来进行测试,我们写了一个详细的性能剖析指南来帮你开始学习分析运行时的性能。

其他的指标呢?

速度指标、首次内容绘制时间和首次有效绘制都是以浏览器绘制为基础的指标。他们的影响因素很相似,往往可以被同时优化。

显然,优化这些指标会相对比较容易,因为他们是通过记录页面的渲染速度来计算的。仔细遵从 Lighthouse 的性能考核准则就能优化这些指标。

如果你还没有对字体进行预加载或者优化那些关键请求,那从这里入手会是一些很好的切入点。我们的文章,关键请求,详细说明了浏览器针对你的页面是如何发起请求以及渲染关键资源的。

跟踪过程做出优化

Google 最近更新了搜索控制台、Lighthouse 和 PageSpeed Insights 针对你的页面的首屏的性能分析有独到之处,但是对于那些需要持续跟踪页面来提升页面性能的团队来说,就显得捉襟见肘了。

持续的性能监控 可以保证速度优化,当页面又变差的时候团队也会立刻知晓。人为的测试会对结果引入大量的不可预期的变量,在不同区域、不同设备上的测试在没有专业的实验室环境下几乎是不可能完成的。

速度已经变成影响了 SEO 排名的关键因素,尤其是目前大约 50% 的页面流量来自于移动设备。

为了避免排名下降,确保你正在使用最新的性能分析套件来跟踪你的关键页面(哈,我们打造了 Calibre 来做你的性能提升伙伴。他以 Lighthouse 为基础。每天都有很多来自全球的团队在使用它)。

相关文章

  • About Time to Interactive
  • How to optimise the performance of a JavaScript application
  • Lighthouse Performance score Calculator

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。

「JavaScript 中 JSON.stringify 的二八法则」

Posted on 2019-10-24 | In 翻译
  • 原文地址:The 80/20 Guide to JSON.stringify in JavaScript
  • 原文作者:Valeri Karpov
  • 译文出自:掘金翻译计划
  • 本文永久链接:https://github.com/xitu/gold-miner/blob/master/TODO1/the-80-20-guide-to-json-stringify-in-javascript.md
  • 译者:JerryFD
  • 校对者:Usey95,mnikn

JavaScript 中 JSON.stringify 的二八法则

函数 JSON.stringify() 是一个把 JavaScript object 转换成 JSON 的标准方法。很多 JavaScript 框架在底层都使用了 JSON.stringify(),例如:Express’ res.json()、Axios’ post() 和 Webpack stats 都在底层调用了 JSON.stringify()。这篇文章会提供一个实用的、包含异常情况的 JSON.stringify() 的概述。

开始

几乎所有现代的 JavaScript 运行环境都支持 JSON.stringify()。甚至 IE 浏览器自从 IE8 起就支持JSON.stringify()。下面是一个把普通的 object 转换成 JSON 的例子:

1
2
3
4
5
const obj = { answer: 42 };

const str = JSON.stringify(obj);
str; // '{"answer":42}'
typeof str; // 'string'

如你所见,下面的例子是 JSON.stringify() 和 JSON.parse() 一起使用的。这种写法可以用来深拷贝 JavaScript 对象。

1
2
3
4
5
const obj = { answer: 42 };
const clone = JSON.parse(JSON.stringify(obj));

clone.answer; // 42
clone === obj; // false

错误和边界处理

如果 JSON.stringify() 的参数是 cyclical object,则会抛出一个错误。也就是说,如果对象 obj 有一个属性,这个属性的值是 obj 本身,那么 JSON.stringify() 会抛出一个错误。

1
2
3
4
5
6
const obj = {};
// 循环 object 指向它自身
obj.prop = obj;

// 抛出 "TypeError: TypeError: Converting circular structure to JSON"
JSON.stringify(obj);

这是 JSON.stringify() 唯一抛出异常的场景,除非你使用自定义的 toJSON() 函数或者使用替代函数(replacer)。然而即便这样,你也还是得把 JSON.stringify() 包在 try/catch 里调用,因为循环 objects 还是可能会出现。

还有很多边界场景 JSON.stringify() 不会抛出异常,但其结果可能不如你所想。比如,JSON.stringify() 会把 NaN 和 Infinity 转换成 null:

1
2
3
const obj = { nan: parseInt('not a number'), inf: Number.POSITIVE_INFINITY };

JSON.stringify(obj); // '{"nan":null,"inf":null}'

JSON.stringify() 也会把属性值为函数或者 undefined 的内容干掉:

1
2
3
4
const obj = { fn: function() {}, undef: undefined };

// 空 object `JSON.stringify()` 过滤 functions 和 `undefined`。
JSON.stringify(obj); // '{}'

优化输出

JSON.stringify() 的第一个参数是要被序列化成 JSON 的 object。实际上 JSON.stringify() 接受 3 个参数,第三个参数 spaces(译注:空隙)。参数 spaces 用来将 JSON 格式化输出成方便阅读的格式。

参数 spaces 可以是 string 或 number。如果 spaces 不是 undefined,那么JSON.stringify() 则会把 JSON 中的每一个 key 单独作为一行输出,并且加上 spaces 的前缀。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const obj = { a: 1, b: 2, c: 3, d: { e: 4 } };

// '{"a":1,"b":2,"c":3,"d":{"e":4}}'
JSON.stringify(obj);

// {
// "a": 1,
// "b": 2,
// "c": 3,
// "d": {
// "e": 4
// }
// }
JSON.stringify(obj, null, ' ');

// 使用 2 个空格来格式化 JSON 输出。和上面的例子等价。
JSON.stringify(obj, null, 2);

把参数 spaces 作为字符串使用时,虽然在实际场景中大多是使用空格,但其实不限制必须全是空格。例如:

1
2
3
4
5
6
7
8
9
// {
// __"a": 1,
// __"b": 2,
// __"c": 3,
// __"d": {
// ____"e": 4
// __}
// }
JSON.stringify(obj, null, '__');

Replacers

JSON.stringify() 的第二个参数是 replacer 函数。在上面的例子中,replacer 是 null。JavaScript 针对 object 中的每一个 key/value 对都会调用 replacer 函数,使用函数的返回值作为属性的值。例如:

1
2
3
4
5
6
7
8
9
10
const obj = { a: 1, b: 2, c: 3, d: { e: 4 } };

// `replacer` 使每个数字的值加 1。输出:
// '{"a":2,"b":3,"c":4,"d":{"e":5}}'
JSON.stringify(obj, function replacer(key, value) {
if (typeof value === 'number') {
return value + 1;
}
return value;
});

替代函数(译注:replacer)在过滤敏感词的场景非常有用。例如,假设你想过滤所有包含 ‘password’ 及 ‘password’ 子字符串的 keys:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const obj = {
name: 'Jean-Luc Picard',
password: 'stargazer',
nested: {
hashedPassword: 'c3RhcmdhemVy'
}
};

// '{"name":"Jean-Luc Picard","nested":{}}'
JSON.stringify(obj, function replacer(key, value) {
// 这个函数会被调用 5 次。 `key` 等于:
// '', 'name', 'password', 'nested', 'hashedPassword'
if (key.match(/password/i)) {
return undefined;
}
return value;
});

函数 toJSON()

JSON.stringify() 函数会遍历 object 寻找含有 toJSON() 函数的属性。如果它找到了 toJSON() 函数,JSON.stringify() 会调用 toJSON() 函数,并使用其返回值作为替代。例如:

1
2
3
4
5
6
7
8
9
10
const obj = {
name: 'Jean-Luc Picard',
nested: {
test: 'not in output',
toJSON: () => 'test'
}
};

// '{"name":"Jean-Luc Picard","nested":"test"}'
JSON.stringify(obj);

函数 toJSON() 可以返回任何值,包括对象、原始类型的值,甚至 undefined。如果 toJSON() 返回 undefined,JSON.stringify() 会忽略这个属性。

许多 JavaScript 模块使用 toJSON() 这一特性来保证复杂的对象能被正确的序列化。比如 Mongoose documents 和 Moment objects。

后续

函数 JSON.stringify() 是 JavaScript 基础的核心。许多库和框架在底层都使用了它,所以对 JSON.stringify() 的扎实理解,可以帮助更好的学习你感兴趣的 npm 模块。比如,针对你的 Express REST API,可以借用自定义 toJSON() 函数的能力来处理原生的 Date 类,以此实现一个日期格式化 的替代方案,或者,当使用 Axios 发送 HTTP 请求时,确保客户端的循环对象能被正确的转换成 JSON。(译注:帕累托法则即 80/20 Rule,一般指 20% 的输入,决定 80% 的结果的现象。)

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。

「高清方案」

Posted on 2019-09-12 | In 总结

概念

viewport -> 视口
layoutviewport -> 布局视口
visualviewport -> 虚拟视口
物理像素 -> 屏幕的真实像素
设备独立像素 -> 操作系统 (广义) 中可以操作控制的像素
设备像素比 -> 物理像素/设备独立像素
ppi -> 每英寸多少个像素 -> 决定物理像素的单位实际大小
rem -> 尺寸单位,根据 html 根节点的 font-size 决定

假设设备像素比大于 1 , css 中定义的的 1 个像素,就会有多个物理像素来真实渲染在屏幕上。

高清方案实现原理

  • Html 引入 js 脚本 -> 根据设备的型号、物理像素比 (dpr) 和屏幕尺寸,动态改写 meta 标签,设置缩放比例,给 html 标签添加 2 个属性,处理后的 dpr 和 font-size ,用于在 css 中描述尺寸时所用的单位。

对 dpr 处理具体内容是 IOS 中对 dpr 设置最大值 3,安卓统一为 1

  • 通过 css 处理器 postcss 来改变项目写的样式
  • webpack 结合 postcss 以及 postcss 的插件 postcss-px2rem 来把 css 中写的 px 无痛转换为 rem

参考文档:

  • 使用Flexible实现手淘H5页面的终端适配 · Issue #17 · amfe/article · GitHub

「前端导出 Excel/csv 文件」

Posted on 2016-06-22 | In 总结

前端导出excel/csv文件

安装所需js

fileSaver
Blob

安装基于npm,先确认安装了nodeJS。进入项目目录,在根目录下打开终端执行行以下代码

npm install file-saver --save

bower install file-saver

安装完成后可以看到在node_modules文件里新增的文件夹file-saver和filesaver 其中 filesaver文件夹为filesaver.js源码

引用fileSaver

在需要下载导出页面对应的JS文件处引入filesaver文件

import FileSaver from 'file-saver';

const {saveAs} = FileSaver;

新建Blob对象

BLOB (binary large object),二进制大对象,是一个可以存储二进制文件的容器。(来自百度百科)

var Blob = new Blob(["JSON"],{type:''})

blob的结构
  • Blob(blobParts[, options])
更多Blob 资料

引用saveAs

saveAs(Blob,filename,boolean)

第三个参数boolean如果传true filesaver.js就不会对你的文件自动设置类型

new Blob有浏览器兼容问题,如需考虑浏览器兼容问题,需要引入Blob.js

demo

1
2
3
4
5
6
7
8
9
function Clickhandle (){
var blob = new Blob
(
["Hello, world!"],
{type: "text/plain;charset=utf-8"}
);

saveAs(blob, "hello world.txt");
}

注意下载导出功能只能写在事情回调函数里,如某个button的onClick事件中

导出文件类型

  • csv
1
2
3
4
5
var exportContent = "\uFEFF";
var blob = new Blob([exportContent+data("")],
{type: "text/plain;charset=utf-8"}
);
saveAs(blob, downloadName + ".csv");
  • excel
1
2
3
4
5
var exportContent = "\uFEFF";
var blob = new Blob([exportContent+data("")], {
type: "application/vnd.ms-excel;charset=charset=utf-8"
});
saveAs(blob, downloadName + ".xls");

其中加入exportContent = “\uFEFF”;可以防止中文乱码

「关于 Cookie 的学习笔记」

Posted on 2016-06-08 | In 总结
简单的关于 cookie 的学习总结
Read more »

JerryFD

10 posts
2 categories
3 tags
© 2019 JerryFD
Powered by Hexo
|
Theme — NexT.Muse v5.1.4