I'm iotale(LI Yongle)
I work as a Web 🌐 developer!
临渊羡鱼,不如退而结网!
iotale / garden Goto Github PK
View Code? Open in Web Editor NEW种一棵树的最佳时间是十年前,其次是现在!
种一棵树的最佳时间是十年前,其次是现在!
随着技术与设备的发展,用户的终端对动画的表现能力越来越强,更多的场景开始大量使用动画。在 Web 应用中,实现动画效果的方法比较多,JavaScript 中可以通过定时器 setTimeout
来实现,css3 可以使用 transition
和animation
来实现,html5 中的 canvas
也可以实现。除此之外,html5 还提供一个专门用于请求动画的 API,即 requestAnimationFrame
本文内容均非原创,而是在知识点的收集与搬运中学习与理解,也欢迎大家收集与搬运本篇文章!
HTML5 新增加的 API,类似于 setTimeout
定时器
window
对象的一个方法,window.requestAnimationFrame
partial interface Window {
long requestAnimationFrame(FrameRequestCallback callback);
void cancelAnimationFrame(long handle);
};
浏览器(所以只能在浏览器中使用)专门为动画提供的 API,让 DOM 动画、Canvas 动画、SVG 动画、WebGL 动画等有一个统一的刷新机制
- 浏览器重绘频率一般会和显示器的刷新率保持同步。大多数浏览器采取 W3C 规范的建议,浏览器的渲染页面的标准帧率也为 60FPS(frames/ per second)
按帧对网页进行重绘。该方法告诉浏览器希望执行动画并请求浏览器在下一次重绘之前调用回调函数来更新动画
由系统来决定回调函数的执行时机,在运行时浏览器会自动优化方法的调用
显示器有固定的刷新频率(60Hz 或 75Hz),也就是说,每秒最多只能重绘 60 次或 75 次,requestAnimationFrame
的基本**让页面重绘的频率与这个刷新频率保持同步
比如显示器屏幕刷新率为 60Hz,使用requestAnimationFrame
API,那么回调函数就每1000ms / 60 ≈ 16.7ms
执行一次;如果显示器屏幕的刷新率为 75Hz,那么回调函数就每1000ms / 75 ≈ 13.3ms
执行一次。
通过requestAnimationFrame
调用回调函数引起的页面重绘或回流的时间间隔和显示器的刷新时间间隔相同。所以 requestAnimationFrame
不需要像setTimeout
那样传递时间间隔,而是浏览器通过系统获取并使用显示器刷新频率
比如一个动画,宽度从 0px 加一递增到 100px。无缓动效果的情况下,浏览器重绘一次,宽度就加 1。
动画帧请求回调函数列表:每个 Document 都有一个动画帧请求回调函数列表,该列表可以看成是由
<handle, callback>
元组组成的集合。
handle
是一个整数,唯一地标识了元组在列表中的位置,cancelAnimationFrame()
可以通过它停止动画callback
是一个无返回值的、形参为一个时间值的函数(该时间值为由浏览器传入的从 1970 年 1 月 1 日到当前所经过的毫秒数)。- 刚开始该列表为空。
- 当页面被最小化或者被切换成后台标签页时,页面为不可见,浏览器会触发一个
visibilitychange
事件,并设置document.hidden
属性为true
- 当页面切换到显示状态,页面变为可见,同时触发一个
visibilitychange
事件,设置document.hidden
属性为false
调用操作。与setTimeout
相似,但是不需要设置间隔时间,使用一个回调函数作为参数,返回一个大于 0 的整数
handle = requestAnimationFrame(callback);
callback
,是一个回调函数,在下次重新绘制动画时调用。该回调函数接收唯一参数,是一个高精度时间戳(performance.now()
),指触发回调函数的当前时间(不用手动传入)long
型的非零整数,是requestAnimationFrame
回调函数列表中唯一的标识,表示定时器的编号,无其他意义取消操作
cancelAnimationFrame(handle);
requestAnimationFrame
时的返回值浏览器执行过程
首先判断document.hidden
属性是否为true
(页面是否可见),页面处于可见状态才会执行后面步骤
浏览器清空上一轮的动画函数
requestAnimationFrame
将回调函数追加到动画帧请求回调函数列表的末尾
当执行requestAnimationFrame(callback)
的时候,不会立即调用 callback 函数,只是将其放入队列。每个回调函数都有一个布尔标识cancelled
,该标识初始值为false
,并且对外不可见。
当浏览器再执行列表中的回调函数的时候,判断每个元组的 callback 的cancelled
,如果为false
,则执行 callback
当页面可见并且动画帧请求回调函数列表不为空,浏览器会定期将这些回调函数加入到浏览器 UI 线程的队列中
博客园上yyc 元超的文章深入理解 requestAnimationFrame中提供了让一个伪代码,用来说明“采样所有动画”任务的执行步骤
var list = {};
var browsingContexts = 浏览器顶级上下文及其下属的浏览器上下文;
for (var browsingContext in browsingContexts) {
/* !将时间值从 DOMTimeStamp 更改为 DOMHighResTimeStamp 是 W3C 针对基于脚本动画计时控制规范的最新编辑草案中的最新更改,
* 并且某些供应商仍将其作为 DOMTimeStamp 实现。
* 较早版本的 W3C 规范使用 DOMTimeStamp,允许你将 Date.now 用于当前时间。
* 如上所述,某些浏览器供应商可能仍实现 DOMTimeStamp 参数,或者尚未实现 window.performance.now 计时函数。
* 因此需要用户进行polyfill
*/
var time = DOMHighResTimeStamp //从页面导航开始时测量的高精确度时间。DOMHighResTimeStamp 以毫秒为单位,精确到千分之一毫秒。此时间值不直接与 Date.now() 进行比较,后者测量自 1970 年 1 月 1 日至今以毫秒为单位的时间。如果你希望将 time 参数与当前时间进行比较,请使用当前时间的 window.performance.now。
var d = browsingContext 的 active document; //即当前浏览器上下文中的Document节点
//如果该active document可见
if (d.hidden !== true) {
//拷贝 active document 的动画帧请求回调函数列表到 list 中,并清空该列表
var doclist = d的动画帧请求回调函数列表
doclist.appendTo(list);
clear(doclist);
}
//遍历动画帧请求回调函数列表的元组中的回调函数
for (var callback in list) {
if (callback.cancelled !== true) {
try {
//每个 browsingContext 都有一个对应的 WindowProxy 对象,WindowProxy 对象会将 callback 指向 active document 关联的 window 对象。
//传入时间值time
callback.call(window, time);
}
//忽略异常
catch (e) {
}
}
}
}
当调用cancelAnimationFrame(handle)
时,浏览器会设置该 handle 指向的回调函数的cancelled
为true
(无论该回调函数是否在动画帧请求回调函数列表中)。如果该 handle 没有指向任何回调函数,则什么也不会发生。
递归调用。要想实现一个完整的动画,应该在回调函数中递归调用回调函数
let count = 0;
let rafId = null;
/**
* 回调函数
* @param time requestAnimationFrame 调用该函数时,自动传入的一个时间
*/
function requestAnimation(time) {
console.log(time);
// 动画没有执行完,则递归渲染
if (count < 50) {
count++;
// 渲染下一帧
rafId = requestAnimationFrame(requestAnimation);
}
}
// 渲染第一帧
requestAnimationFrame(requestAnimation);
如果在执行回调函数或者 Document 的动画帧请求回调函数列表被清空之前多次调用 requestAnimationFrame
调用同一个回调函数,那么列表中会有多个元组指向该回调函数(它们的 handle 不同,但 callback
都为该回调函数),“采集所有动画”任务会执行多次该回调函数。(类比定时器setTimeout
)
function counter() {
let count = 0;
function animate(time) {
if (count < 50) {
count++;
console.log(count);
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
}
btn.addEventListener("click", counter, false);
在浏览器初次加载的时候执行下面的代码即可。
// 使用 Date.now 获取时间戳性能比使用 new Date().getTime 更高效
if (!Date.now)
Date.now = function() {
return new Date().getTime();
};
(function() {
"use strict";
var vendors = ["webkit", "moz"];
for (var i = 0; i < vendors.length && !window.requestAnimationFrame; ++i) {
var vp = vendors[i];
window.requestAnimationFrame = window[vp + "RequestAnimationFrame"];
window.cancelAnimationFrame =
window[vp + "CancelAnimationFrame"] ||
window[vp + "CancelRequestAnimationFrame"];
}
// 上面方法都不支持的情况,以及IOS6的设备
// 使用 setTimeout 模拟实现
if (
/iP(ad|hone|od).*OS 6/.test(window.navigator.userAgent) ||
!window.requestAnimationFrame ||
!window.cancelAnimationFrame
) {
var lastTime = 0;
// 和通过时间戳实现节流功能的函数相似
window.requestAnimationFrame = function(callback) {
var now = Date.now();
var nextTime = Math.max(lastTime + 16, now);
// 实际上第1帧是不准确的,首次nextTime - now = 0
return setTimeout(function() {
callback((lastTime = nextTime));
}, nextTime - now);
};
window.cancelAnimationFrame = clearTimeout;
}
})();
requestAnimationFrame
采用系统时间间隔,保持最佳绘制效率。不会因为间隔时间过短,造成过度绘制,增加开销;也不会因为间隔时间过长,使动画卡顿。
从实现的功能和使用方法上,requestAnimationFrame
与定时器setTimeout
都相似,所以说其优势是同setTimeout
实现的动画相比。
- 浏览器 UI 线程:浏览器让执行 JavaScript 和更新用户界面(包括重绘和回流)共用同一个单线程,称为“浏览器 UI 线程”
- 浏览器 UI 线程的工作基于一个简单的队列系统,任务会被保存到队列中直到进程空闲。一旦空闲,队列中的下一个任务就被重新提取出来并运行。这些任务要么是运行 JavaScript 代码,要么执行 UI 更新。
通过setTimeout
实现动画
setTimeout
通过设置一个间隔时间不断改变图像,达到动画效果。该方法在一些低端机上会出现卡顿、抖动现象。这种现象一般有两个原因:
setTimeout
的执行时间并不是确定的。
在 JavaScript 中,setTimeout
任务被放进异步队列中,只有当主线程上的任务执行完以后,才会去检查该队列的任务是否需要开始执行。所以,setTimeout
的实际执行时间一般比其设定的时间晚一些。这种运行机制决定了时间间隔参数实际上只是指定了把动画代码添加到【浏览器 UI 线程队列】中以等待执行的时间。如果队列前面已经加入了其他任务,那动画代码就要等前面的任务完成后再执行
let startTime = performance.now();
setTimeout(() => {
let endTime = performance.now();
console.log(endTime - startTime);
}, 50);
/* 一个非常耗时的任务 */
for (let i = 0; i < 20000; i++) {
console.log(0);
}
刷新频率受屏幕分辨率和屏幕尺寸影响,不同设备的屏幕刷新率可能不同,setTimeout
只能设置固定的时间间隔,这个时间和屏幕刷新间隔可能不同
以上两种情况都会导致setTimeout
的执行步调和屏幕的刷新步调不一致,从而引起丢帧现象。
setTimeout
的执行只是在内存中对图像属性进行改变,这个改变必须要等到下次浏览器重绘时才会被更新到屏幕上。如果和屏幕刷新步调不一致,就可能导致中间某些帧的操作被跨越过去,直接更新下下一帧的图像。
假如使用定时器设置间隔 10ms 执行一个帧,而浏览器刷新间隔是 16.6ms(即 60FPS)
由图可知,在 20ms 时,setTimeout
调用回调函数在内存中将图像的属性进行了修改,但是此时浏览器下次刷新是在 33.2ms 的时候,所以 20ms 修改的图像没有更新到屏幕上。
而到了 30ms 的时候,setTimeout
又一次调用回调函数并改变了内存中图像的属性,之后浏览器就刷新了,20ms 更新的状态被 30ms 的图像覆盖了,屏幕上展示的是 30ms 时的图像,所以 20ms 的这一帧就丢失了。丢失的帧多了,画面就卡顿了。
使用 requestAnimationFrame
执行动画,最大优势是能保证回调函数在屏幕每一次刷新间隔中只被执行一次,这样就不会引起丢帧,动画也就不会卡顿
使用 setTimeout
实现的动画,当页面被隐藏或最小化时,定时器setTimeout
仍在后台执行动画任务,此时刷新动画是完全没有意义的(实际上 FireFox/Chrome 浏览器对定时器做了优化:页面闲置时,如果时间间隔小于 1000ms,则停止定时器,与requestAnimationFrame
行为类似。如果时间间隔>=1000ms,定时器依然在后台执行)
// 在浏览器开发者工具的Console页执行下面代码。
// 当开始输出count后,切换浏览器tab页,再切换回来,可以发现打印的值没有停止,甚至可能已经执行完了
let count = 0;
let timer = setInterval(() => {
if (count < 20) {
count++;
console.log(count);
} else {
clearInterval(timer);
timer = null;
}
}, 2000);
使用requestAnimationFrame
,当页面处于未激活的状态下,该页面的屏幕刷新任务会被系统暂停,由于requestAnimationFrame
保持和屏幕刷新同步执行,所以也会被暂停。当页面被激活时,动画从上次停留的地方继续执行,节约 CPU 开销。
// 在浏览器开发者工具的Console页执行下面代码。
// 当开始输出count后,切换浏览器tab页,再切换回来,可以发现打印的值从离开前的值继续输出
let count = 0;
function requestAnimation() {
if (count < 500) {
count++;
console.log(count);
requestAnimationFrame(requestAnimation);
}
}
requestAnimationFrame(requestAnimation);
resize
,scroll
等)中,使用requestAnimationFrame
可以防止在一个刷新间隔内发生多次函数执行,这样保证了流畅性,也节省了函数执行的开销requestAnimationFrame
替代 Throttle 函数,都是限制回调函数执行的频率简单的进度条动画
function loadingBar(ele) {
// 使用闭包保存定时器的编号
let handle;
return () => {
// 每次触发将进度清空
ele.style.width = "0";
// 开始动画前清除上一次的动画定时器
// 否则会开启多个定时器
cancelAnimationFrame(handle);
// 回调函数
let _progress = () => {
let eleWidth = parseInt(ele.style.width);
if (eleWidth < 200) {
ele.style.width = `${eleWidth + 5}px`;
handle = requestAnimationFrame(_progress);
} else {
cancelAnimationFrame(handle);
}
};
handle = requestAnimationFrame(_progress);
};
}
添加缓动效果,实现一个元素块按照三阶贝塞尔曲线的ease-in-out
缓动特效参数运动。如何使用 Javascript 实现缓动特效
缓动动画:指定动画效果在执行时的速度,使其看起来更加真实。
/**
* @param {HTMLElement} ele 元素节点
* @param {number} change 改变量
* @param {number} duration 动画持续时长
*/
function moveBox(ele, change, duration) {
// 使用闭包保存定时器标识
let handle;
// 返回动画函数
return () => {
// 开始时间
let startTime = performance.now();
// 防止启动多个定时器
cancelAnimationFrame(handle);
// 回调函数
function _animation() {
// 这一帧开始的时间
let current = performance.now();
let eleTop = ele.offsetLeft;
// 这一帧内元素移动的距离
let left = change * easeInOutCubic((current - startTime) / duration);
ele.style.left = `${~~left}px`;
// 判断动画是否执行完
if ((current - startTime) / duration < 1) {
handle = requestAnimationFrame(_animation);
} else {
cancelAnimationFrame(handle);
}
}
// 第一帧开始
handle = requestAnimationFrame(_animation);
};
}
/**
* 三阶贝塞尔曲线ease-in-out
* @param {number} k
*/
function easeInOutCubic(k) {
return (k *= 2) < 1 ? 0.5 * k * k * k : 0.5 * ((k -= 2) * k * k + 2);
}
本文内容均非原创,而是在知识点的收集与搬运中学习与理解,也欢迎大家收集与搬运本篇文章!
根据HTTP标准,HTTP请求可以使用多种方法,其功能描述如下所示。
HTTP DELETE 请求方法用于删除指定的资源。DELETE 可以直接理解动词删除、移除。但是HTTP没有规范DELETE的具体实现方式。
请求是否有主体 | 可以有 |
---|---|
成功的返回是否有主体 | 可以有 |
安全 | 否 |
幂等性 | 是 |
可缓存 | 否 |
Methods can also have the property of "idempotence" in that (aside from error or expiration issues) the side-effects of N > 0 identical requests is the same as for a single request.
HTTP方法的幂等性是指一次和多次请求某一个资源应该具有同样的副作用。
幂等性原本是数学上的概念,即使公式:f(x)=f(f(x)) 能够成立的数学性质。
使用 DELETE 方法,需要提供额外的参数时,大多以**键值对(key-value)**的形式,置于URI的查询部分,如:下面的 ?gender=female
https://echo.paw.cloud:443/hello/world?age=24&gender=female
有时候,后端可能希望前端能够传递复杂的数据。那么,将参数放在 entity body 中可以吗?
不建议这样做。但是也不是不可以,因为标准规范中并没有明确禁止。
最新的 HTTP1.1 规范 RFC 7231 在 DELETE 一节中有这样的描述:
A payload within a DELETE request message has no defined semantics; sending a payload body on a DELETE request might cause some existing implementations to reject the request.
DELETE请求消息中的有效负载没有定义的语义;在DELETE请求上发送有效内容正文可能会导致某些现有实现拒绝该请求。
这个意思就是说:是的!DELETE 可以有 entity body。
目前由于规范的含糊不清,反对使用 DELETE 实体主体的主要认为:因为没有针对实体的定义的语义,这暗示着应该忽略它。即body是允许的,但与请求无关,使用它绝对没有意义。就如同,可以通过 GET 传递 body,但是这样做毫无意义。并且,如果网络中存在某些代理服务器,可能造成entity body丢失。
实际上我自己开发的时候,后端的设计的确是用到了在 DELETE 方法中传 body 的情况,所以可以看出,通过 body 传复杂参数绝对是可以实现的,并且支不支持,其实取决于后端设计。并且我觉得我们后端有偷懒的嫌疑,后端为了少些代码逻辑,总是直接让前端去搞些有的没的。
还是要思考几个问题:
HTML5 的表单(form)仅支持 get
与 post
方法。如果将表单的 method
改为 delete
,HTML5 会以预设的 get
方法取代
Axios 是一个基于 promise 的 HTTP 库,从浏览器中创建 XMLHttpRequests 发送请求。其实在浏览器中 Axios 就是 AJAX 技术的一种实现。
AJAX(Asynchronous JavaScript And XML)是一种异步请求的技术,是实现网页的局部数据刷新的技术,可以通过XHR、Fetch、WebSocket等API实现。
注意:需要和Jquery的
$.ajax
区分开,前者是一种技术,而后者是 Jquery 通过 XHR 对前者的实现。同理
Axios 是通过 Promise 实现 XHR 封装,其中Promise是控制手段,XHR 是实际发送Http请求的客户端。就像$.ajax是通过callback+XHR实现一样,你也可以造个轮子叫XXX的,都是AJAX技术的一种具体实现。
axios#delete(url[, config])
可以看到,DELETE 方法除了接受一个url,还可接受一个 config 参数。这个 config 就是请求配置。
{
// ...
// `params` 是即将与请求一起发送的 URL 参数
// 必须是一个无格式对象(plain object)或 URLSearchParams 对象
params: {
ID: 12345
},
// `data` is the data to be sent as the request body
// Only applicable for request methods 'PUT', 'POST', 'DELETE , and 'PATCH'
// When no `transformRequest` is set, must be of one of the following types:
// - string, plain object, ArrayBuffer, ArrayBufferView, URLSearchParams
// - Browser only: FormData, File, Blob
// - Node only: Stream, Buffer
data: {
firstName: 'Fred'
},
// ...
}
通过观察 config 参数可以知道
可以将参数拼接在 url 上,即用 params
axios.delete('/delete', {
params: { // 请求参数拼接在url上
id: 12
}
})
请求参数放在请求体用 data,虽然文档中说“只适用于这些请求方法 'PUT', 'POST', 和 'PATCH'”,但是并没有说不允许 DELETE方法使用,所以下面也是可以传参数的,只是不符合HTTP1.1的规范罢了。
最新的文档已经修改,将 DELETE 方法也加上了,所以说 axios 已经支持 DELETE 方法通过 body 传参了
axios.delete('/delete', { // 请求参数放在请求体
data: {
id: 12
}
})
也可以不使用方法别名,直接通过向
axios
传递相关配置来创建请求,其实只是看着不一样而已。axios({ method: 'delete', url: '/delete', params: {}, // 请求参数拼接在url上 data: {}, // 请求参数放在请求体 });
跟踪config的处理
// https://github.com/axios/axios/blob/16aa2ce7fa42e7c46407b78966b7521d8e588a72/lib/core/Axios.js#L73
utils.forEach(['delete', 'get', 'head', 'options'], function forEachMethodNoData(method) {
/*eslint func-names:0*/
Axios.prototype[method] = function(url, config) {
return this.request(mergeConfig(config || {}, {
method: method,
url: url
}));
};
});
可以看到,请求参数传给了 this.request 方法
function Axios(instanceConfig) {
this.defaults = instanceConfig;
this.interceptors = {
request: new InterceptorManager(),
response: new InterceptorManager()
};
}
// https://github.com/axios/axios/blob/16aa2ce7fa42e7c46407b78966b7521d8e588a72/lib/core/Axios.js#L27
Axios.prototype.request = function request(config) {
// ...
// 合并请求配置
config = mergeConfig(this.defaults, config);
// ...
// 连接拦截器中间件
// 设置一个队列,用来放请求拦截器和反应拦截器
var chain = [dispatchRequest, undefined];
var promise = Promise.resolve(config);
this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
// 将请求拦截器成功与失败的处理放在队列头
chain.unshift(interceptor.fulfilled, interceptor.rejected);
});
// chain.shift() 会从队列头取元素,所以会先执行 dispatchRequest
while (chain.length) {
promise = promise.then(chain.shift(), chain.shift());
}
// ...
};
到 dispatchRequest
这个方法,基本上就可以看到,body 参数的去向了
// https://github.com/axios/axios/blob/16aa2ce7fa42e7c46407b78966b7521d8e588a72/lib/core/dispatchRequest.js#L23
module.exports = function dispatchRequest(config) {
// ...
// Ensure headers exist
config.headers = config.headers || {};
// Transform request data
config.data = transformData(
config.data,
config.headers,
config.transformRequest
);
// ...
}
transformData
方法是将 config 传给拦截器进行执行,到此,其实发送请求前的 header 和实体 body 都已经构造完毕。
接下来就是 axios 如何将参数通过XHR发送出去了。也就是axios封装的 xhr
// axios/lib/adapters/xhr.js
module.exports = function xhrAdapter(config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
var requestData = config.data;
// ...
// 创建XMLHttpRequest实例
var request = new XMLHttpRequest();
// ...
// 发送请求
request.send(requestData);
});
}
所谓同步还是异步指的是调用 setState
之后是否马上能得到最新的 state
。
setState
的“异步”并不是说内部由异步代码实现,相反其执行的过程和代码都是同步的,只是合成事件和钩子函数的调用顺序在更新之前,导致在合成事件和钩子函数中没法立马拿到更新后的值,形式了所谓的“异步”。
可以通过第二个参数 setState(partialState, callback)
中的 callback
拿到更新后的结果。:
this.setState((state, props) => ({
counter: state.counter + props.increment
}));
一个具体例子:
handleClickOnLikeButton () {
this.setState((prevState) => {
return { count: 0 }
})
this.setState((prevState) => {
return { count: prevState.count + 1 } // 上一个 setState 的返回是 count 为 0,当前返回 1
})
this.setState((prevState) => {
return { count: prevState.count + 2 } // 上一个 setState 的返回是 count 为 1,当前返回 3
})
// 最后的结果是 this.state.count 为 3
}
先说结论:既可以异步更新也可以同步更新
异步:
concurrent
模式下都为异步 concurrent 模式Demo同步:(legacy
模式下才生效)
异步代码中调用(如setTimeout
, Promise
, MessageChannel
等)
异步代码中调用 setState
,由于js的异步处理机制,异步代码会暂存,等待同步代码执行完毕再执行,此时 React 的批处理机制已经结束,因而直接更新。
监听原生事件而非 React 的合成事件,在原生事件的回调函数中执行 setState
就是同步的。原因是原生事件不会触发 React 的批处理机制,因而调用 setState
会直接更新
ReactDOM.render(<App />, rootNode)
。这是当前 React app 使用的方式。当前没有计划删除本模式,但是这个模式可能不支持这些新功能。ReactDOM.createBlockingRoot(rootNode).render(<App />)
。目前正在实验中。作为迁移到 concurrent 模式的第一个步骤。ReactDOM.createRoot(rootNode).render(<App />)
。目前在实验中,未来稳定之后,打算作为 React 的默认开发模式。这个模式开启了所有的新功能。
出于性能考虑,React 可能会把多个
setState()
调用合并成一个调用。
当 this.setState()
被调用的时候,React 会调用 render
方法来重新渲染UI,渲染 UI 的过程其实就是操作 DOM 的过程,DOM 的操作对性能的损耗是非常严重的,所以 React 为了提高整体的渲染性能,会将一次渲染周期中的 state
进行合并,再一次性的渲染,这样可以避免频繁调用 setState
导致频繁的操作 DOM 了,从而提高渲染性能。
至于实现原理,由于代码不断的更新优化,也许以后还会更新,所以知道有这个逻辑就好,需要的时候再去阅读当前版本的代码。平时只要知道不需要担心多次进行 setState
会带来性能问题就行。
比如之前是通过 isBatchingUpdates
这个布尔属性判断是否合并更新。到后来更新 Fiber 架构后,是否同步更新的判断逻辑放进了 ReactFiberWorkLoop
中,最新又有变动,重构了 Fiber.expirationTime
并引入 Fiber.lanes
。源码位置
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0,
};
this.handleAddCount = this.handleAddCount.bind(this);
this.btnRef = React.createRef();
}
componentDidMount() {
this.handleAddCount(null, 'a');
setTimeout(this.handleAddCount, 1 * 1000);
Promise.resolve().then(() => {
this.handleAddCount(null, 'b');
});
const button = this.btnRef.current;
if (button) {
button.addEventListener("click", this.handleAddCount);
}
}
componentWillUnmount() {
const button = this.btnRef.current;
if (button) {
button.removeEventListener("click", this.handleAddCount);
}
}
handleAddCount(e, key = 'c') {
console.log(`${key}-0`, this.state.count);
this.setState({ count: this.state.count + 1 });
console.log(`${key}-1`, this.state.count);
this.setState({ count: this.state.count + 100 });
console.log(`${key}-2`, this.state.count);
}
render() {
return (
<div>
<p>count: {this.state.count}</p>
<div>
<button onClick={this.handleAddCount}>React 合成事件改变 state</button>
<button ref={this.btnRef}>addEventListener 事件改变 state</button>
</div>
</div>
);
}
}
const domContainer = document.querySelector("#app");
// legacy 模式
ReactDOM.render(<App />, domContainer);
扩展:下面 4 次打印都是什么值?详细讨论
class Example extends React.Component {
constructor() {
super();
this.state = {
val: 0
};
}
componentDidMount() {
this.setState({val: this.state.val + 1});
console.log(this.state.val); // 第 1 次 log
this.setState({val: this.state.val + 1});
console.log(this.state.val); // 第 2 次 log
setTimeout(() => {
this.setState({val: this.state.val + 1});
console.log(this.state.val); // 第 3 次 log
this.setState({val: this.state.val + 1});
console.log(this.state.val); // 第 4 次 log
}, 0);
}
render() {
return null;
}
};
分情况:
0 0 2 3
0 0 1 1
Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过 DevTools Protocol 控制 Chromium 或 Chrome。Puppeteer 默认以 headless 模式运行,但是可以通过修改配置文件运行“有头”模式。
Puppeteer 可以做的事情很多,常见的有抓取页面(做爬虫)、SSR、自动化测试以及前端页面性能分析等等。它其实就是一个“运行在 node 端的 Chrome 浏览器”。
安装 Puppeteer 的时候默认会下载最新版本的 Chromium,这可是上百 MB 的大家伙,国内网络环境不理想的情况下,很难下载成功。所以经常会出现因为下载 Chromium 失败而导致整个安装过程失败的问题。就像下面这样:(npm 源是官方源,未使用代理)
Puppeteer 寻找某些环境变量来帮助其操作。 下面是 node env 中使用的变量,另一种就是在 npm config 中使用全小写的变量名设定。
HTTP_PROXY
, HTTPS_PROXY
, NO_PROXY
- 定义用于下载和运行 Chromium 的 HTTP 代理设置。PUPPETEER_SKIP_CHROMIUM_DOWNLOAD
- 请勿在安装步骤中下载绑定的 Chromium。PUPPETEER_DOWNLOAD_HOST
- 覆盖用于下载 Chromium 的 URL 的主机部分。PUPPETEER_CHROMIUM_REVISION
- 在安装步骤中指定一个你喜欢 puppeteer 使用的特定版本的 Chromium。PUPPETEER_EXECUTABLE_PATH
- 指定一个 Chrome 或者 Chromium 的可执行路径,会被用于 puppeteer.launch
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD
环境变量跳过 Chromium 的下载首先造成 Puppeteer 安装失败的原因是 Chromium 下载失败导致的,所以可以先跳过 Chromium 的下载。上图在安装的输出信息中也能看到提示 ERROR: Failed to set up Chromium r848005! Set "PUPPETEER_SKIP_DOWNLOAD" env variable to skip download.
使用下面的安装命令:
env PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true npm i puppeteer
顺利完成 Puppeteer 的安装了,但是现在还不能使用,运行代码会报错
➜ node index.js
(node:56008) UnhandledPromiseRejectionWarning: Error: Could not find expected browser (chrome) locally. Run `npm install` to download the correct Chromium revision (848005).
因为 Puppeteer 运行依赖 Chromium ,所以需要在项目中指定 Chromium 的路径,我们可以手动下载当前 Puppeteer 所依赖的版本(报错信息中有版本号),也可以在 node_modules/puppeteer/lib/cjs/puppeteer/revisions.js
中找到
exports.PUPPETEER_REVISIONS = {
chromium: '848005',
firefox: 'latest',
};
然后通过 taobao 镜像网站下载对应版本的 Chromium 文件。下载下来的文件直接解压,尽量不要重命名,比如 macOS 对应解压出来后的文件名是 “chrome-mac”。
有三种方式让 Puppeteer 使用我们下载好的 Chromium:
puppeteer.launch([options])
设置 options
中的 executablePath
属性,传入 Chromium 可执行文件的路径即可const browser = await puppeteer.launch({executablePath: 【路径】});
通过 env 设置环境变量运行 node
env PUPPETEER_EXECUTABLE_PATH=./chrome-mac/Chromium.app/Contents/MacOS/Chromium node index.js
将下载的文件放入 Puppeteer 模块内,比如 macOS 中路径为 node_modules/puppeteer/.local-chromium/mac-848005/chrome-mac/Chromium.app/Contents/MacOS/Chromium
PUPPETEER_DOWNLOAD_HOST
设置国内镜像【推荐】运行安装命令的时候,使用环境变量
env PUPPETEER_DOWNLOAD_HOST=https://npm.taobao.org/mirrors npm i puppeteer
或者在 .npmrc 中设置镜像地址
# .npmrc
puppeteer_download_host=https://npm.taobao.org/mirrors
对比发现不设置下载镜像地址时,我本地安装时间非常长,并且很容易中间断掉而失败
> node install.js
Downloading Chromium r848005 - 110.5 Mb [= ] 2% 17198.2s ^C
但是设置了镜像地址后,总的安装时间只需要二三十秒。
# .npmrc
home=https://npm.taobao.org
亲测,时间也缩短为几十秒,且成功下载。
采用
LaTex
语法写数学公式,github issue 暂不支持该语法
对数函数是指数函数的反函数,指数函数
**根据
这个结论很重要,下面的证明过程需要用到。
下面的证明默认条件成立的前提
$a>0$ ,且$a≠1$
[证明]
证得
证明过程同上,依据①式和
[证明]
证得
[证明]
证得
[证明]
设
根据 ④ 和 ⑤,可得
换底公式还有很多变换形式,掌握一种就行
[证明]
根据 ⑥,$\log_ab = \dfrac{\log_cb}{\log_ca}$
再根据 ⑥,
下面代码打印值是什么?
const Example = () => {
const [val, setVal] = React.useState(0);
React.useEffect(() => {
setVal(val + 1);
console.log(val);
setVal(val + 1);
console.log(val);
setTimeout(() => {
setVal(val + 1);
console.log(val);
setVal(val + 1);
console.log(val);
}, 2000);
}, []);
return (<div>{val}</div>);
};
ReactDOM.render(<Example />, document.querySelector("#example"));
打印值为 0 0 0 0
。这涉及到了一个概念 Capture Value。
现在可以忘记 Class 组件的那一套理论。
Capture Value 的特性不容易想象,可以借助一个简单例子解释:
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}
在每次点击时,count
只是一个不会变的常量,而且也不存在利用 Proxy
的双向绑定,只是一个常量存在于每次 Render 中。
例如,每次点击按钮的时候,count
值都会固化为 1,2,3,... 保存在每次渲染中,就像下面的伪代码一样:
// 初次渲染,获取的 count 的初始值
function Counter() {
const count = 0; // Returned by useState()
// ...
<p>You clicked {count} times</p>;
// ...
}
// 点击一次,函数被调用一次
function Counter() {
const count = 1; // Returned by useState()
// ...
<p>You clicked {count} times</p>;
// ...
}
// 再次点击,函数再次被调用
function Counter() {
const count = 2; // Returned by useState()
// ...
<p>You clicked {count} times</p>;
// ...
}
可以认为每次 Render 的内容都会形成一个快照并保留下来,因此当状态变更而 Rerender 时,就形成了 N 个 Render 状态,而每个 Render 状态都拥有自己固定不变的 Props 与 State。这就是 Capture Value 特性。
其实不仅 props
和 state
有 Capture Value 特性,函数在每次渲染时也是独立的:
const Example = () => {
const [temp, setTemp] = React.useState(5);
const log = () => {
setTimeout(() => {
console.log("3 秒前 temp = 5,现在 temp =", temp);
}, 3000);
};
return (
<div
onClick={() => {
log();
setTemp(3);
// 3 秒前 temp = 5,现在 temp = 5
}}
>
render
</div>
);
};
如果连点两次,会得到这样的结果:
"3 秒前 temp = 5,现在 temp =" 5
"3 秒前 temp = 5,现在 temp =" 3
第一次点击按钮时,temp
的值可以看作常量 5
保存在本次 Rerender 中,当执行 setTemp(3)
时由于引起状态变更,会交由一个全新的 Render 渲染,但是在当前 Rerender 中执行了 log
函数,所以函数内拿到的是本次 Rerender 中的 temp
,也就是 5
。这也就是为什么第一次输出的值是"3 秒前 temp = 5,现在 temp =" 5
。可以看出 temp
、log
都拥有 Capture Value 特性。
useEffect
也一样具有 Capture Value 的特性const Example = () => {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
setTimeout(() => {
alert("count: " + count);
}, 3000);
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>增加 count</button>
<button onClick={() => setCount(count - 1)}>减少 count</button>
</div>
);
};
当点击“增加”后紧接着点击"减少",等待一会儿,浏览器先弹出 count: 1
然后弹出 count: 0
。
很容易理解,useEffect
在实际 DOM 渲染完毕后执行,由于 useEffect
也一样具有 Capture Value 的特性,也就是说每次 Render 都有自己的 Effects,所以每次 Render 过程中,useEffect
拿到的也都是 count
固化下来的“常量”。
useEffect
中每次 “注册” “回收” 拿到的都是成对的固定值。这也就是为什么可以在 useEffect
的返回函数中对注册的监听进行销毁了。
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
};
});
回到一开始的代码中,现在就很容易理解为什么打印值都为 0
了:(先不管 Dependencies)这四次打印其实都是在同一 Render 的 Effects 中,而同一 Render 中的 count
其实就是固定的一个值,即使有异步执行函数,拿到的也都是相同的值。
useRef
返回一个可变的 ref 对象,其 .current
属性被初始化为传入的参数(initialValue
)。返回的 ref 对象在组件的整个生命周期内保持不变。也就是说,useRef
会在每次渲染时返回同一个 ref 对象,并且变更 .current
属性不会引发组件重新渲染。
根据这一点可以绕过 Capture Value 的特性。例如将上面的例子修改下
const Example = () => {
const [count, setCount] = React.useState(0);
const latestCount = React.useRef(count);
React.useEffect(() => {
latestCount.current = count;
setTimeout(() => {
alert("count: " + latestCount.current);
}, 3000);
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>增加 count</button>
<button onClick={() => setCount(count - 1)}>减少 count</button>
</div>
);
};
现在比如连续点击三下增加按钮,等待一段时间后,会连续弹出三个浏览器弹框,且弹框内容都是 count: 3
。原因是 latestCount
不受 Capture Value 特性的影响,每次 Render 都是获取的同一个值,当 setTimeout
事件循环开始的时候,latestCount.current
已经变成 3
了。
useState
更新的问题请思考下面代码:
function App() {
const [count, setCount] = React.useState(0);
const onClick = () => {
setCount(count + 1);
setCount(count + 1);
};
return (
<div className="App">
<h1>count: {count}</h1>
<button onClick={onClick}>click me</button>
</div>
);
}
点击一次按钮,页面上显示的是多少?答案是 1
。为什么不是 2
?
由于 Function Component 内的函数拥有 Capture Value 特性的原因,虽然 setCount
执行了两次,但是入参中的 count
都是 0
。在 setCount
之后,会分别生成两个新的 count
,值都等于 0 + 1
,但彼此互不影响。两次 setCount
会导致两次 render,但是只有最新一次的 render 有效,也就是只有最后一次 setCount
改变的状态值。
这看起来就像是 useState
的异步更新(我也不知道该不该这样认为,虽然这有 Capture Value 的原因)。那么类比 Class Component 的 setState
,如果下一次状态更新依赖前一个状态值,可以通过传入一个函数来获取旧的 state 值:
const onClick = () => {
setCount(c => c + 1);
setCount(c => c + 1);
};
wx.chooseImage(Object object)
wx.chooseImage({
count: 1, // 最多可以选择的图片张数
sizeType: ['original', 'compressed'], // 图片的尺寸:original-原图,compressed-压缩图
sourceType: ['album', 'camera'], // 图片来源选项:album-相册,camera-相机
success (res) {
// tempFilePath 可以作为 img 标签的 src 属性显示图片,图片的本地临时文件路径列表
const tempFilePaths = res.tempFilePaths
}
})
效果如下:
左苹果,右安卓
选择图片没什么好说的,功能基本一致,也体验也良好。
wx.chooseMessageFile(Object object)
API【官方】wx.chooseMessageFile(Object object)
方法是官方给出的 API,用来解决上传非图片类型的文件的问题。
用户体验比较差,甚至很多用户不买账,开发者也很头痛。
说白了,就是现在只能通过聊天记录选择文件,期待
wx.choosFile()
的出现。
它的作用是弹出微信的会话列表,然后让用户选择一个会话(可以是一个人、微信群或者文件传输助手等),然后再选择这个会话中的文件进行上传。
开发者工具中的模拟器使用 wx.chooseMessageFile(Object object)
会报错。
<web-view>
嵌套 H5 页面使用 <input type="file">
实现个人开发类型的小程序不支持使用 web-view
如果不想走微信的文件传输助手中转一下,或者用户非得要直接调用手机的文件管理器,目前可以使用这种方案。
将 H5 所在页面的域名添加到小程序开发设置的【业务域名】中,否则小程序的 <web-view>
会限制打开非业务域名的页面:
<web-view>
打开的页面必须为 https 服务,打开的页面如果有重定向(301或302)则重定向经历的页面也要添加进业务域名;webview 中可以嵌套 <iframe>
元素,但是 iframe 的地址必须是业务域名。
H5 页面中 html 的 title 会自动放到小程序的头部作为标题,所以需要在 H5 页面中修改 html 的 <title>
才能修改实际显示标题
如果要改变导航栏样式,就在该页面的 json 配置文件中配置即可,但标题文字会被覆盖,所以只能改标题文字颜色(黑,白)和标题背景色
在 H5 中可以无限跳转,对于导航条返回和物理键返回都会回到上一个页面直到退出 webview,就像 history.back
webview 中可以正常使用 ajax 之类的操作,所以我们可以用 axios 这样的第三方库进行请求。这也是使用 H5 做表单上传的基础 [H5 上传代码](##基于 Vue + vant2 的简单上传表单)
<web-view>
将 webview 放到单独的一个 Page
中,也就是一个微信小程序页面中只放一个 web-view
<web-view src="{{src}}"></web-view>
然后配合 Page
实例的 onLoad
方法来获取 url
Page({
data: {
src: "",
},
onLoad(options) {
const { src } = options;
if(src) {
this.setData({
src: decodeURIComponent(src),
})
} else {
// ...
}
},
})
在其他页面就可以通过路由跳转打开 webview 了
wx.navigateTo({
url: `/src/components/webview/index?src=${encodeURIComponent(url)}`,
})
PS: <web-view>
的页面中可不可以有其他组件?可以,但是没有意义。
<web-view/>
会自动铺满整个页面,并覆盖其他组件,所以添加其他组件(包括自己的导航栏)都不会被显示出来。
在 H5 页面中引入 jweixin,这样就可以在 H5 页面中调用小程序的一些功能,例如跳转到小程序内的路由:
// 这里使用动态引入 script(因为这个 H5 所在服务的其它页面都不需要 jweixin),也可以直接在入口的 html 文件中引入
const script = document.createElement('script')
script.type = 'text/javascript'
script.src = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'
document.body.appendChild(script)
然后在页面中就可以调用小程序提供的方法了
wx.miniProgram.navigateTo({url: '/pages/index/index'})
// 或者
wx.miniProgram.redirectTo({url: '/pages/index/index'});
如果想要保持小程序来源页面中的数据不被清除掉,可以通过返回操作回到来源页面
wx.miniProgram.navigateBack()
效果就和点击导航头上的返回按钮一样
小程序到 webview 通信,通过给 <web-view>
的 src
属性的 url
带上查询参数传参
// const src = `${HTTPS_HOST}candidate/#/publicUpload?token=${token}&access_token=${access_token}&fieldKey=${_that.properties.fieldKey}`
<web-view src="{{src}}"></web-view>
在 H5 页面就可以通过 window.location
拿到这些参数(基于 Vue2 的 H5 页面)
export default {
mounted () {
const { hash } = window.location
const { token, access_token, fieldKey } = qs(hash);
this.token = token;
this.access_token = access_token;
this.fieldKey = fieldKey;
},
}
webview 到小程序的通信有两种情况
简单且量少
可以在 H5 页面跳转回小程序的时候,在 url
上带上查询参数即可,和上面小程序到 webview 通信一样
wx.miniProgram.navigateTo({
url:'/pages/index/index?test=testtest',
})
复杂且量大,官方提供的唯一接口是 postMessage
方法
首先要在小程序中,在 <web-view>
组件里绑定事件
<web-view src="{{src}}" bindmessage="postMessage"></web-view>
定义事件处理方法
Page({
// ...
postMessage(msg) {
// ...
},
})
然后 H5 页面中调用 postMessage
方法
wx.miniProgram.postMessage
向小程序发送消息,会在特定时机(小程序后退、组件销毁、分享)触发组件的 message 事件
wx.miniProgram.postMessage({ data: res.data })
需要特别注意的是,网页在使用 postMessage
给小程序发送消息时,小程序不一定会立即收到,而是会在特定时机触发:
那就需要使用小程序的 【页面间通信】:
如果一个页面由另一个页面通过
wx.navigateTo
打开,这两个页面间将建立一条数据通道:
- 被打开的页面可以通过
this.getOpenerEventChannel()
方法来获得一个EventChannel
对象;wx.navigateTo
的success
回调中也包含一个EventChannel
对象。这两个
EventChannel
对象间可以使用emit
和on
方法相互发送、监听事件。
正好我们在上面说过,将 <web-view>
放在一个单独的页面,需要使用 webview 的时候就通过路由跳转打开,所以可以使用页面间通信。
在 webview 页面:
Page({
// ...
postMessage(msg) {
const eventChannel = this.getOpenerEventChannel()
const data = msg && msg.detail ? msg.detail : {}
eventChannel.emit('getFileRes', data);
},
})
在跳转前的页面
wx.navigateTo({
url: `/src/components/webview/index?src=${encodeURIComponent(url)}`,
events: {
getFileRes: function(data) {
if(data && data.data) {
const fileList = data.data;
_that.setData({
fileList: fileList,
})
const file = fileList[0];
if(file) {
_that.setData({
fileName: file.fileName || '',
fileUrl: file.fileUrl || '',
})
}
}
}
},
})
目前见到处理小程序上传最好的一个方案,虽然无法突破微信自身的限制,但是用户体验已经得到最大程度的提升:
没有合适的图,拿人家做的比较好的做个参考**【侵删】**:
<template>
<!-- accept="" 不限制文件类型 -->
<div class="heeou-public-uploader">
<van-uploader
upload-icon="plus"
accept="*"
v-model="fileList"
:before-read="beforeRead"
:after-read="afterRead"
:max-size="maxSize"
@oversize="onOversize"
:show-upload="showUpload"
:disabled="uploading"
:deletable="false"
/>
<div v-if="showUpload" class="heeou-public-uploader-text">选择文件</div>
<div v-else class="heeou-public-uploader-btns">
<van-button
:disabled="uploading"
color="#2f54eb"
@click="uploadFile"
>开始上传
</van-button>
<van-button
plain
:disabled="uploading"
color="#323233"
@click="clearChoose"
>清除选择
</van-button>
</div>
</div>
</template>
<script>
import { Toast } from 'vant'
import axios from 'axios'
export default {
mounted () {
const script = document.createElement('script')
script.type = 'text/javascript'
script.src = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'
document.body.appendChild(script)
},
data () {
return {
fileList: [],
maxSize: 30 * 1024 * 1024,
uploading: false,
}
},
computed: {
showUpload () {
return this.fileList.length === 0
},
},
methods: {
beforeRead (file) {
this.$toast.loading({
duration: 0, // 持续展示 toast
message: '加载中...',
forbidClick: true,
loadingType: 'spinner',
})
this.uploading = true
return true
},
afterRead (file) {
this.$toast.clear()
this.uploading = false
},
onOversize () {
Toast('文件大小不能超过 30 MB')
},
clearChoose () {
this.fileList = []
},
async uploadFile () {
try {
if (this.fileList.length !== 1) {
// 只支持单文件上传
return false
}
const toast = this.$toast.loading({
duration: 0, // 持续展示 toast
message: '上传中...',
forbidClick: true,
loadingType: 'spinner',
})
this.uploading = true
const { file } = this.fileList[0]
const formData = new FormData()
formData.append('file', file, file.name)
const actionUrl = `xxx`
const res = await axios.post(actionUrl, formData, {
baseURL: process.env.baseUrl,
timeout: 600000,
headers: {
'Accept': '*/*',
'Authorization': `Bearer ${access_token}`,
},
withCredentials: true,
onUploadProgress: progressEvent => {
const complete = (progressEvent.loaded / progressEvent.total * 100 | 0)
toast.message = `已上传 ${complete}%`
},
})
this.$toast.clear()
this.uploading = false
if (res.data && res.data.fileUrl) {
this.$toast.success('上传成功')
this.fileList = [];
} else {
this.$toast.fail('上传失败')
}
} catch (e) {
this.$toast.clear()
this.uploading = false
this.$toast.fail('上传失败')
}
},
},
}
</script>
<style type="text/scss" lang="scss" rel="stylesheet/scss" scoped>
$color: #2f54eb;
.heeou-public-uploader {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
&-text {
color: $color;
}
/deep/ .van-uploader {
margin-bottom: 16px;
&__upload {
margin: 0;
border: 1px solid $color;
background: transparent;
border-radius: 50%;
&-icon {
font-size: 36px;
color: $color;
}
}
&__file {
width: 200px;
height: 200px;
}
&__preview {
margin: 0;
&-image {
width: 200px;
height: 200px;
}
}
}
}
</style>
与Sass不同,Less (目前v3.12.0)中没有用于编写循环的内置 @for
或 @each
指令,但它仍然可以使用递归 mixins 编写。递归mixins 只不过是一个不断调用自己的混合。
使用 Less 编写的循环有四个关键条件:
for([initialization]; [condition]; [final-expression])
)中的 [condition]下面这个例子,是实现在一定范围内进行循环。注意动态的选择器写法和content的值必须是字符串。
// when 后面跟着 guard 表达式,用来设定循环范围,并在条件满足时终止循环
.for-loop(@index, @min, @max) when ((@index >= @min) and (@index <= @max)) {
// statement
.item {
&[data-value="@{index}px"]::before {
content: "@{index}px";
font-size: @index + 0px;
}
}
// 内部调用,递归
.for-loop(@index + 1, @min, @max);
}
// 第一次迭代调用
.for-loop(20, 20, 22);
编译为CSS:
.item[data-value="20px"]::before {
content: "20px";
font-size: 20px;
}
.item[data-value="21px"]::before {
content: "21px";
font-size: 21px;
}
.item[data-value="22px"]::before {
content: "22px";
font-size: 22px;
}
Debounce 和 throttle 是我们在 JavaScript 中使用的两个概念,用于增强对函数执行的控制,这在事件处理程序中特别有用。这两种技术都回答了同一个问题“一段时间内某个函数的调用频率是多少?”
文中内容多数来自以下文章,侵删!
- http://hackll.com/2015/11/19/debounce-and-throttle/
- mqyqingfeng/Blog#22
- http://drupalsun.com/david-corbacho/2012/10/10/debounce-and-throttle-visual-explanation
- https://blog.coding.net/blog/the-difference-between-throttle-and-debounce-in-underscorejs
- https://github.com/jashkenas/underscore/blob/master/underscore.js#L887
- https://github.com/jashkenas/underscore/blob/master/underscore.js#L842
本是机械开关的“去弹跳”概念,弹簧开关按下后,由于簧片的作用,接触点会连续接触断开好多次,如果每次接触都通电对用电器不好,所以就要控制按下到稳定的这段时间不通电
前端开发中则是一些频繁的事件触发
mousemove
...)键盘(keydown
...)事件等在 debounce 函数没有再被调用的情况下经过 delay 毫秒后才执行回调函数,例如
mousemove
事件中,确保多次触发只调用一次监听函数user
,就会分成u
,us
,use
,user
四次发出请求;而添加防抖,设置好时间,可以实现完整输入user
才发出校验请求由 debounce 的功能可知防抖函数至少接收两个参数(流行类库中都是 3 个参数)
fn
delay
debounce 函数返回一个闭包,闭包被频繁的调用
fn
的执行,强制只有连续操作停止后执行一次使用闭包是为了使指向定时器的变量不被gc
回收
delay
内的连续触发都不执行回调函数fn
,使用的是在闭包内设置定时器setTimeOut
符合原理的简单实现
function debounce(fn, delay) {
var timer;
return function() {
// 清除上一次调用时设置的定时器
// 计时器清零
clearTimeout(timer);
// 重新设置计时器
timer = setTimeout(fn, delay);
};
}
简单实现的代码,可能会造成两个问题
this
指向问题。debounce 函数在定时器中调用回调函数fn
,所以fn
执行的时候this
指向全局对象(浏览器中window
),需要在外层用变量将this
保存下来,使用apply
进行显式绑定
function debounce(fn, delay) {
var timer;
return function() {
// 保存调用时的this
var context = this;
clearTimeout(timer);
timer = setTimeout(function() {
// 修正 this 的指向
fn.apply(this);
}, delay);
};
}
event
对象。JavaScript 的事件处理函数中会提供事件对象event
,在闭包中调用时需要将这个事件对象传入
function debounce(fn, delay) {
var timer;
return function() {
// 保存调用时的this
var context = this;
// 保存参数
var args = arguments;
clearTimeout(timer);
timer = setTimeout(function() {
console.log(context);
// 修正this,并传入参数
fn.apply(context, args);
}, delay);
};
}
underscore
的实现)立刻执行。增加第三个参数,两种情况
fn
,等到停止触发后的delay
毫秒,才可以再次触发(先执行)delay
毫秒后才执行回调函数(后执行)clearTimeout(timer)
后,timer
并不会变成null
,而是依然指向定时器对象function debounce(fn, delay, immediate) {
var timer;
return function() {
var context = this;
var args = arguments;
// 停止定时器
if (timer) clearTimeout(timer);
// 回调函数执行的时机
if (immediate) {
// 是否已经执行过
// 执行过,则timer指向定时器对象,callNow 为 false
// 未执行,则timer 为 null,callNow 为 true
var callNow = !timer;
// 设置延时
timer = setTimeout(function() {
timer = null;
}, delay);
if (callNow) fn.apply(context, args);
} else {
// 停止调用后delay时间才执行回调函数
timer = setTimeout(function() {
fn.apply(context, args);
}, delay);
}
};
}
返回值与取消 debounce 函数
undefined
immediate
为true
的时候,触发一次后要等待delay
时间后才能再次触发,但是想要在这个时间段内想要再次触发,可以先取消掉之前的 debounce 函数function debounce(fn, delay, immediate) {
var timer, result;
var debounced = function() {
var context = this;
var args = arguments;
// 停止定时器
if (timer) clearTimeout(timer);
// 回调函数执行的时机
if (immediate) {
// 是否已经执行过
// 执行过,则timer指向定时器对象,callNow 为 false
// 未执行,则timer 为 null,callNow 为 true
var callNow = !timer;
// 设置延时
timer = setTimeout(function() {
timer = null;
}, delay);
if (callNow) result = fn.apply(context, args);
} else {
// 停止调用后delay时间才执行回调函数
timer = setTimeout(function() {
fn.apply(context, args);
}, delay);
}
// 返回回调函数的返回值
return result;
};
// 取消操作
debounced.cancel = function() {
clearTimeout(timer);
timer = null;
};
return debounced;
}
ES6 写法
function debounce(fn, delay, immediate) {
let timer, result;
// 这里不能使用箭头函数,不然 this 依然会指向 Windows对象
// 使用rest参数,获取函数的多余参数
const debounced = function(...args) {
if (timer) clearTimeout(timer);
if (immediate) {
const callNow = !timer;
timer = setTimeout(() => {
timer = null;
}, delay);
if (callNow) result = fn.apply(this, args);
} else {
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
}
return result;
};
debounced.cancel = () => {
clearTimeout(timer);
timer = null;
};
return debounced;
}
固定函数执行的速率
如果持续触发事件,每隔一段时间,执行一次事件
mousemove
事件时,不管鼠标移动的速度,【节流】后的监听函数会在 wait 秒内最多执行一次,并以此【匀速】触发执行window
的 resize
、scroll
事件的优化等
有两种主流实现方式
节流函数 throttle 调用后返回一个闭包
时间戳方式
定时器方式
将两种方式结合,可以实现兼并立刻执行和停止触发后依然执行一次的效果
时间戳实现
function throttle(fn, wait) {
var args;
// 前一次执行的时间戳
var previous = 0;
return function() {
// 将时间转为时间戳
var now = +new Date();
args = arguments;
// 时间间隔大于延迟时间才执行
if (now - previous > wait) {
fn.apply(this, args);
previous = now;
}
};
}
previous
为 0,除非设置的时间间隔大于当前时间的时间戳,否则差值肯定大于时间间隔)定时器实现
function throttle(fn, wait) {
var timer, context, args;
return function() {
context = this;
args = arguments;
// 如果定时器存在,则不执行
if (!timer) {
timer = setTimeout(function() {
// 执行后释放定时器变量
timer = null;
fn.apply(context, args);
}, wait);
}
};
}
结合时间戳和定时器实现
function throttle(fn, wait) {
var timer, context, args;
var previous = 0;
// 延时执行函数
var later = function() {
previous = +new Date();
// 执行后释放定时器变量
timer = null;
fn.apply(context, args);
if (!timeout) context = args = null;
};
var throttled = function() {
var now = +new Date();
// 距离下次执行 fn 的时间
// 如果人为修改系统时间,可能出现 now 小于 previous 情况
// 则剩余时间可能超过时间周期 wait
var remaining = wait - (now - previous);
context = this;
args = arguments;
// 没有剩余时间 || 修改系统时间导致时间异常,则会立即执行回调函数fn
// 初次调用时,previous为0,除非wait大于当前时间的时间戳,否则剩余时间一定小于0
if (remaining <= 0 || remaining > wait) {
// 如果存在延时执行定时器,将其取消掉
if (timer) {
clearTimeout(timer);
timer = null;
}
previous = now;
fn.apply(context, args);
if (!timeout) context = args = null;
} else if (!timer) {
// 设置延时执行
timer = setTimeout(later, remaining);
}
};
return throttled;
}
增加第三个参数,让用户可以自己选择模式
{ leading: false }
{ trailing: false }
增加返回值功能
增加取消功能
function throttle(func, wait, options) {
var context, args, result;
var timeout = null;
// 上次执行时间点
var previous = 0;
if (!options) options = {};
// 延迟执行函数
var later = function() {
// 若设定了开始边界不执行选项,上次执行时间始终为0
previous = options.leading === false ? 0 : new Date().getTime();
timeout = null;
// func 可能会修改 timeout 变量
result = func.apply(context, args);
// 定时器变量引用为空,表示最后一次执行,则要清除闭包引用的变量
if (!timeout) context = args = null;
};
var throttled = function() {
var now = new Date().getTime();
// 首次执行时,如果设定了开始边界不执行选项,将上次执行时间设定为当前时间。
if (!previous && options.leading === false) previous = now;
// 延迟执行时间间隔
var remaining = wait - (now - previous);
context = this;
args = arguments;
// 延迟时间间隔remaining小于等于0,表示上次执行至此所间隔时间已经超过一个时间窗口
// remaining 大于时间窗口 wait,表示客户端系统时间被调整过
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(context, args);
if (!timeout) context = args = null;
} else if (!timeout && options.trailing !== false) {
timeout = setTimeout(later, remaining);
}
// 返回回调函数执行后的返回值
return result;
};
throttled.cancel = function() {
clearTimeout(timeout);
previous = 0;
timeout = context = args = null;
};
return throttled;
}
leading: false
和 trailing: false
不能同时设置
previous
为 0,则remaining
值和wait
相等。所以,if (!previous && options.leading === false)
为真,改变了previous
的值,而if (remaining <= 0 || remaining > wait)
为假if (!previous && options.leading === false)
为假,而if (remaining <= 0 || remaining > wait)
为真。就变成了开始边界执行。这样就和leading: false
冲突了underscore
中的 debounce 函数和 throttle 函数lodash
中 debounce 函数和 throttle 函数的实现更加复杂,封装更加彻底throttle 和 debounce 是解决请求和响应速度不匹配问题的两个方案。二者的差异在于选择不同的策略
电梯超时现象解释两者区别。假设电梯设定为 15 秒,不考虑容量限制
throttle
策略:保证如果电梯第 1 个人进来后,15 秒后准时送一次,不等待。如果没有人,则待机、debounce
策略:如果电梯有人进来,等待 15 秒,如果又有人进来,重新计时 15 秒,直到 15 秒超时都没有人再进来,则开始运送在FlatList 中我们会用到 ItemSeparatorComponent 设置行与行之间的分隔线组件。并且让该分割线显示为设备屏幕可以显示的最小宽度。
最简单的办法就是设置一个View元素,然后将该元素的下(或上)边框宽度设置为 StyleSheet.hairlineWidth
。【当然,如果分割线宽度不是这样的一条细线,也就不会有下面问题】
StyleSheet.hairlineWidth,This constant will always be a round number of pixels (so a line defined by it can look crisp) and will try to match the standard width of a thin line on the underlying platform.
StyleSheet.hairlineWidth是一个常数,始终是像素的整数,并将尝试匹配上设备上细线的标准宽度
import React from "react";
import { StyleSheet, Text, View } from "react-native";
const Separator = () => (
<View style={styles.Separator} />
);
const styles = StyleSheet.create({
Separator: {
borderBottomColor: '#e0e0e0',
borderBottomWidth: StyleSheet.hairlineWidth
}
});
但是在实际效果中并不理想,会出现有些分割线不显示,有些分割线变粗,如下:
在stackoverflow上有相应的问题并且尝试了几种办法 ReactNative ListView inconsistent separator lines
const styles = StyleSheet.create({
Separator: {
// 无论哪种方案都需要使用marginBottom做hack,注意不是margin
marginBottom: StyleSheet.hairlineWidth,
// 【方法一】用border做分割线
borderBottomColor: '#e0e0e0',
borderBottomWidth: StyleSheet.hairlineWidth,
// 【方法二】设置分割线组件高度,然后用背景色填充
// height: StyleSheet.hairlineWidth,
// backgroundColor: '#e0e0e0',
// 【方法三:无效】只用上面的marginBottom,造成一个空隙,然后让父级背景透出来做分隔,这种方法依然是粗细不均匀
},
});
import React from 'react';
import { View, FlatList, StyleSheet, Text } from 'react-native';
const App = () => {
const renderItem = ({ item }) => (
<View style={styles.item}>
<Text>{item.title}</Text>
</View>
);
return (
<FlatList
data={DATA}
renderItem={renderItem}
keyExtractor={item => item.id}
/>
);
}
const styles = StyleSheet.create({
item: {
borderBottomColor: '#e0e0e0',
borderBottomWidth: StyleSheet.hairlineWidth,
},
});
文档中有说明一种原因,是因为缩小模拟器(这里缩小是指视图的缩放,而不是单纯的尺寸改变),则带有hairline宽度的线可能不可见。在issue中 【StyleSheet.hairlineWidth not visible on devices with decimal PixelRatio #22927】似乎也存在着联系。
可能是因为缩小视图导致,或者某些设备本身的 PixelRatio 就是小数,造成这个常数并没有匹配上设备上细线的标准宽度。从而导致未显示。
这里有一个待验证的做法 https://github.com/facebook/react-native/issues/22927#issuecomment-512158402。
在IOS上,通过nativeScale和scale属性重新计算hairlineWidth的值,已达到可以显示的最终效果
/* * scale / nativeScale = 3 / 2.608 ≈ 1.2 */
const newHairlineWidth = StyleSheet.hairlineWidth * 1.2
前段时间,同事问了一个问题:有三台主机,管理页面需要将一个页面发送给某一个设备显示,怎么区分这三个设备?
其实业务需求很简单,就是发请求告诉后台目标设备的唯一标识符。
第一时间想到的是mac地址。但是同事说后端资源有限,需要前端获取。
MAC地址是具有唯一性的地址,它被分配给我们的蓝牙、WiFi和以太网卡,直接被“埋”在我们的设备中。在本地网络中,它也被各个设备用来进行相互通信。
MAC地址也被称作设备的 “物理”地址。对众多设备来说,MAC地址标明了哪个是哪个。而IP地址则表示,设备在不计其数的网络链中的接入位置。
mac 地址作为物理层信息,的确可以唯一标识设备(除非盗取硬件)。但是目前来说这个方案是无法实现的:
浏览器沙盒不可能让你直接访问硬件,更何况是 MAC 这种跟浏览器没关系的信息
使用控件(如 ActiveX 和 Flash,ActiveX 控件有权使用操作系统级别的API),但是可能要回归 IE。。。
除非是桌面端应用(前端的话可以使用 electron 打包一个桌面端应用)
这个方案显然是行不通了。不过同事提出了一个我不曾听过的知识点:设备指纹。
其实前面说的需求不重要,最终如何解决的也不重要(最终也没有使用设备指纹)。一切都是为了引出『设备指纹』。
设备指纹是指可以用于唯一标识出该设备的设备特征或者独特的设备标识。 ——百度百科
以下内容来源于知乎专栏:设备指纹详解
早期,在一些对安全要求非常高的线上场景中,例如网上银行在线交易,常常使用纯U盾这样的纯硬件技术去追踪业务主体,也就是定位’’你是谁’’。同时,业务往往都是发生在浏览器页面中,而浏览器是属于操作系统上层的应用程序,运行在其中的脚本代码受到沙盒的限制,所以用户也需要安装一个可以跳出浏览器沙盒直接跟操作系统对接的控件,来读取U盾里面的安全数据。
相对来讲,这很安全。不过随着互联网的发展,这种“控件”+“U盾”的结合方式已经越来越落伍:
使用控件的用户体验非常差,需要冗长安装、更新流程,普通用户难以操作,使用不够友好;
移动互联网已成为绝对主流,而iOS,Android等移动互联网入口都不支持控件;
不仅仅在移动端,某些控件在pc端适用范围都很小,很多只支持PC上的IE内核浏览器。同时Chrome和Firefox等份额较大的桌面浏览器也在逐步淘汰控件的使用;
基于控件的本地溢出漏洞层出不穷,用户很容易中木马或者被钓鱼,反而给系统的安全造成严重危害。
由于业务场景实际需要,设备指纹产品应运而生。设备指纹技术可以为每一个操作设备生成一个全球唯一的设备ID,用于唯一表示出该设备特征。
不过无论网络安全如何升级,总会有对抗以及相应的对策。
就算是设备指纹,黑产往往也可以通过伪造新设备或者伪造某些系统底层参数(比如地理位置,imei号等等)的方式来绕过业务的限制,上层设备指纹获取的所有参数都是伪造的,基于这些伪造的数据计算得到的设备ID也就毫无意义了。
主动采集设备信息,比如UA、MAC地址、设备IMEI号、广告追踪ID等与客户端上生成唯一的device_id。
局限性:
在终端设备与服务器通信的过程中,从数据报文的OSI七层协议中,提取出该终端设备的OS、协议栈和网络状态相关的特征集,结合机器学习算法以标识和跟踪具体的终端设备。
避免了主动式的局限性。
即既有主动采集部分,又有服务端算法生成部分。不多说。
背景以及简单的生成方式搬完了,下面才是笔记的重要部分:web 生成和获取设备指纹。
想要了解更多,可以继续阅读这篇文章 设备指纹指南。
FingerprintJS 是一个生成浏览器端指纹的库。原理其实就是通过收集浏览器的诸多属性和可以拿到的操作系统层的数据,通过算法生成一个哈希值作为设备指纹。
可以在 Fingerprintjs2 的代码中查看使用的属性。
Fingerprintjs2 是 FingerprintJS 发布不可兼容性修改前的版本。
上面这些条件只是从概率上降低了重复的可能性,且要保持现在浏览器及上述条件都不变的状态,才能得到相同的浏览器指纹,并不是绝对意义上的唯一性。
介绍 FingerprintJS 并不是为了写怎么使用它,也不是为了推荐它(pro版本不开源)。而是想说通过其代码可以学习到前端生成设备指纹的方法。
纯前端生成的指纹其实并不稳定,并且对抗性和唯一性都不强。建议只作为学习,不建议上生产。
第一类:浏览器提供的信息。浏览器明确提供(例如 JavaScript)多种系统信息,这些信息已知向量如下:
(a) 主要软硬件细节。navigator 和浏览器对象模型 (BOM) 公开了浏览器 / 操作系统厂商和版本、系统语言、平台、user-agent (有时还包括设备型号之类)、已安装插件、浏览器支持的存储机制(如本地存储(localStorage)、索引数据库(indexedDB)、会话存储(sessionStorage)、通过 openDatabase 的 WebSQL)、屏幕分辨率、颜色深度和像素等属性。
(b) WebGL 信息。WebGL 是用来在浏览器内渲染图形的 JavaScript API,公开了底层浏览器和硬件各种属性 (如 GL 版本、最大纹理大小、渲染缓冲区大小、支持的 WebGL 扩展、供应商 / 渲染器字符串)。
(c) 系统时间和时钟漂移。设备的系统时间可通过 JavaScript 访问,并用于推断设备的时区、是否遵守夏令时以及 UTC 时钟漂移。
(d) 电池信息。当提供足够精确的读数时,HTML5 电池状态 API 适用于指纹识别。电池电量可用于在不同网站上对用户端进行短期跟踪,电池容量随着电池老化缓慢下降,但在相对较短的时间内,例如一天内变化不大,可通过监测约 30 秒的放电速率来估计,并用于辅助识别。
(e) 永续 cookie。Evercookie 通过使用 HTML5 本地存储、HTTP ETags 或 Flash Cookie 等多种技术,将用户端标识符存储在设备上,从而允许网站重建用户删除的 cookie。
(f) WebRTC。WebRTC 是一套 W3C 标准,支持原生 (无插件) 浏览器应用,如语音和视频聊天。设备可通过枚举支持的 WebRTC 功能和媒体设备(如麦克风和网络摄像头)来进行指纹识别。对于哪些类型的设备可以在未经用户许可的情况下进行枚举,各浏览器的做法不同。WebRTC 还公开了分配给设备上所有网络接口的 IP 地址,包括由用户分配的私有 IP 地址、NAT 路由器或 VPN。
(g) 密码自动填充。JavaScript 可以用来检测密码是用户输入,还是被浏览器或密码管理器自动填充。使用事件监听器来检测用户是否在密码字段中输入字符,为 keydown 和 keypress 分配一个事件监听器,由于事件是由物理按键触发的,缺失则表明密码是通过自动填充输入。
第二类:基于设备行为推断。不仅可以通过浏览器提供信息,还可在浏览器上执行特定 JavaScript 代码观察效果,如测量执行时间或分析输出来收集设备的信息,包括:
(a) HTML5 画布指纹。通过 JavaScript 在用户端执行 HTML5 画布渲染各种文本和图形,并向服务器发送位图图像的哈希。不同软 / 硬件设备生成的图像有细微不同,例如字体和抗锯齿会随操作系统和显卡驱动变化,表情符号随操作系统和手机厂商变化。使用预定义字体列表渲染文本,可以进行字体检测。使用 WebGL 渲染复杂的图形,可进一步提供指纹多样性。
(b) 系统性能。在一系列计算密集型操作上运行 JavaScript 引擎基准,对运行时间进行测量,可推断设备性能特点;
(c) 硬件传感器。移动设备传感器可以根据制造和工厂校准变化进行指纹识别,例如,测量手机加速度计的校准误差(通过 JavaScript 访问)或扬声器 - 麦克风系统的频率响应;
(d) 滚轮指纹。监听 WheelEvent 事件,可通过 JavaScript 推断用户设备,当用户使用鼠标滚轮或触摸板滚动时,就会触发该事件。鼠标滚轮在触发时以固定增量滚动页面,触摸板则以不同增量滚动。测量文档的滚动速度可以显示用户滚动行为的信息和操作系统的滚动速度值。
(e) CSS 特征检测。浏览器厂商和版本可通过检测 CSS 特征来推断,因为各浏览器不统一。在目标元素上设置所需的 CSS 属性,然后查询该元素判断是否应用更改。这个向量可从 user-agent 获取。如果设备指纹已经通过另一个向量提取了 user-agent,那么这里也用来测试信息是否被篡改。
(f) JavaScript 标准的一致性。浏览器对 JavaScript 标准的符合性不同,各种 JavaScript 一致性测试要数千个测试用例,加起来可能需要 30 多分钟。Mulazzani 等人开发了一种技术,方法是使用决策树来选择一个非常小的子集,这些子集的运行时间可以忽略不计,可用来验证 user-agent 中报告的浏览器供应商和版本。
(g) URL scheme handler。有些浏览器在访问本地资源时使用了非标准方案。例如,res:// 在 Microsoft IE 中是存储在 Windows 系统目录下 DLL 文件,Firefox 中的 moz-icon://、jar:resource:// 和 resource:// 公开了内置浏览器和操作系统资源。因此网站可以创建 HTML 图片标签,将源地址设置为本地资源,并使用 onerror 事件处理来检测图片是否加载。通过迭代不同浏览器或操作系统版本预加载资源列表,向量可以列举。这算是一个替代方案,因为很多新版本浏览器出于隐私考虑,不再支持。
(h) 显卡 RAM 检测。GPU 可用 RAM(VRAM) 数量,虽然不能通过 WebGL API 明确获得,但可以通过反复分配纹理来推断,直到 VRAM 满了,之后纹理开始被交换到系统主内存。通过每次纹理分配的时间长度,并记录观察到的较大峰值,可推断 GPU VRAM 已达到充分利用的状态。在这之后,浏览器可以继续分配纹理,直到出现 OUT OF MEMORY 错误。
(i) 字体检测。虽然不能通过 JavaScript 枚举已安装的字体,但可以用预定义列表中的字体来格式化文本,产生的文本尺寸可以区分不同的字体渲染设置,因此推断每种字体的存在。
(j) 音频处理。HTML5 AudioContext API 通过提供音频播放的实时频域和时域分析接口,允许创建音频可视化。和 HTML5 画布指纹一样,音频处理因浏览器和软 / 硬件不同而不同。
第三类:浏览器扩展插件。包括:
(a) 浏览器插件指纹。浏览器插件,如 Java、Flash 和 Silverlight,可以被查询 (通过嵌入网页插件对象),以采集系统信息,而且比 JavaScript 提供的信息更详细。例如,Flash 提供了完整的操作系统内核版本,Flash 和 Java 插件都允许枚举所有系统字体,甚至系统字体的列举顺序在不同的系统中也会有所不同,增加了指纹的可区分性。
(b) 浏览器扩展指纹。如果安装了 NoScript 扩展 (默认情况下,除了用户白名单上外,所有网站都禁用 JavaScript),网站可以尝试从一大批网站(如 Alexa Top 1000) 加载脚本,检测哪些网站在用户白名单上。同样,广告拦截器也可以通过嵌入一个虚假广告来检测,比如一个隐藏的图片或 iframe,其源 URL 中包含广告拦截器常用的黑名单词(比如 “广告”),然后 JavaScript 可以检测假广告是否被加载,并将结果返回服务器。其他扩展也有不同方法进行指纹识别,比如一些浏览器扩展会添加自定义 HTTP headers。
(c) 系统指纹插件。网站可能会安装专门的插件,例如早年的网上银行,这样可提供更强大指纹信息,包括硬件标识符、操作系统安装日期和已安装驱动程序版本,不过这种插件现在一般会被杀毒软件报出。
第四类:网络和协议级技术。前面几类涉及在客户端上访问 API,而网络和协议层面的技术也可给设备打指纹,包括:
(a) IP 地址。众所周知 IP 可用来做判断,也可查询 WHOIS 获得更多信息,比如所在自治系统和注册组织名称。虽然 IP 地址比 AS 号更精确,但 AS 更稳定,在校验用户位置时可以作为交叉检查。
(b) Geolocation。地理位置可以通过几种机制来确定,浏览器通常会暴露 API(例如通过 navigator BOM 对象),通过这些 API,可以请求用户允许获取当前位置 (GPS 硬件、蜂窝三角、WiFi 信息或用户提供的信息)。基于网络的机制也包括基于 IP 地址的 WHOIS 查询、基于路由数据的推理以及基于地理定位。
(c) 主动式 TCP/IP 协议栈指纹。由于网络和操作系统 TCP/IP 实现之间的差异,可以通过向设备发送针对性探针并分析响应包头字段 (如 RTT、TCP 初始窗口大小) 或链路特征 (如 MTU、延迟) 来确定指纹。这种方法与浏览器无关,可以在任何互联网主机使用。可以理解为 Nmap 之类的扫描,具有主机发现、端口扫描和操作系统检测能力,可以发送各种探测数据包,通过内置数据库中的启发式方法来区分成千上万的系统。这种向用户端发送特殊的探测数据包,称之为主动指纹,但可能会触发防火墙、IDS 警报。
(d) 被动 TCP/IP 协议栈指纹。被动指纹是侵入性较低的方法,但效果也较弱,通过嗅探网络通信,但使用主动指纹的启发式方法来识别主机,例如 p0f 这种工具。被动方法是比较合适的指纹向量,因为对现有 header 分析不需要制造新数据包,不具有侵入性。
(e) 协议指纹。协议指纹用于更高级别的协议,用来区分浏览器软件、版本、配置,例如 HTTP header、user-agent、支持语言、字符编码列表以及 DoNotTrack 参数。此外,浏览器的 TLS 库可以用 ClientHello 数据包从协商参数的握手序列中获得指纹,相关信息包括用户端 TLS 版本、支持的密码套件、它们的顺序、压缩选项和扩展列表 (相关参数如椭圆曲线参数)。
(f) DNS 解析。很多用户默认 DNS 解析器是运营商配置的,但少数用户可能会设置其他 DNS,如阿里云或 OpenDNS。于是就产生了一种比较*的方法,服务器向浏览器发送一份文件,文件包含一份随机生成子域名,但该域名的权威 DNS 服务器由网站所有者控制。当用户端试图解析时,网站的 DNS 服务器会收到来自用户端 DNS 解析请求,然后将随机生成子域与最初为用户生成的进行关联。
(g) 时钟偏移。可以被动分析 TCP 时间戳,来测量用户时钟偏移—用户时钟和真实时间的偏离率。
(h) 计算 NAT 后面的主机。Bellovin 最早提出来计算 NAT 后面的主机数量,通过被动分析 IPv4 ID 字段 (用于片段重构) 来计算 NAT 后面的主机数量。Kohno 则又提出使用时钟偏移来区分 NAT 后面的主机。这些技术可以通过上层信息来增强,例如在指纹中加入从同一 IP 地址访问的其他用户账户。
(i) 广告拦截器检测。虽然广告拦截器检测可以用 JavaScript 在用户端进行,但也可以在服务器监控用户端是否请求了虚假广告。
这里只说三种有研究意义的指纹类型:
Canvas 是一种 HTML5 API 和它的功能就不赘述了。这里只说它可以用作 Web 浏览器指纹识别中的附加熵,并用于在线跟踪目的。
相同的 Canvas 图像在不同的计算机上可能会以不同的方式呈现。
就如下面动图所示:
发生这种情况有几个原因:
其实一段简单的 JavaScript 代码就能实现:
// 带有小写/大写/标点符号的文本
var txt = "BrowserLeaks,com <canvas> 1.0" ;
ctx.textBaseline = "顶" ;
// 最常见的类型
ctx.font = "14px 'Arial'" ;
ctx.textBaseline = "字母" ;
ctx.fillStyle = "#f60" ;
ctx.fillRect(125,1,62,20);
// 一些混色的技巧,以增加渲染的差异
ctx.fillStyle = "#069" ;
ctx.fillText(txt, 2, 15);
ctx.fillStyle = "rgba(102, 204, 0, 0.7)" ;
ctx.fillText(txt, 4, 17);
然而,要从画布创建签名,我们必须使用 toDataURL()
函数从应用程序的内存中导出像素,该函数将返回二进制图像文件的 base64 编码字符串。然后我们可以创建这个字符串的 MD5 哈希,或者甚至从 IDAT 块中提取 CRC 校验和,IDAT 块位于每个 PNG 文件末尾的 16 到 12 字节,这将是我们的 Canvas 指纹。
图像数据块IDAT(image data chunk):IDAT 数据块用于存储各像素点的数据,具体数据由 IHDR 中声明的
Filter method
和Compression method
共同决定。
HTML5提供给 JavaScript 编程用的 Audio API 可以让开发者有能力在代码中直接操作原始的音频流数据,对其进行任意生成、加工、再造,诸如提高音色,改变音调,音频分割等多种操作,甚至可称为网页版的 Adobe Audition。
快速傅里叶变换(英语:Fast Fourier Transform, FFT)
主机或浏览器硬件或软件的细微差别,导致音频信号的处理上的差异,相同器上的同款浏览器产生相同的音频输出,不同机器或不同浏览器产生的音频输出会存在差异。
字体指纹技术基于测量填充文本片段或单个 Unicode 字形的 HTML 元素的屏幕尺寸。Web 浏览器中的字体呈现受许多因素影响,这些测量值可能略有不同。
实现:
所以,阳历、阴历是一类历法,而公历、农历是一种历法。公历和农历的表述方法也是不一样的
好吧,以前总觉得公历就是阳历,农历就是阴历。实际上只是老百姓这样说。从理论上是无法等同的。
我们熟知的是公历,公历分为周期为 365个日历日的平年以及周期为 366个 日历日的闰年。闰年是能被 4 整除的年, 然而,百年并不一定是闰年,除非它们能被 400整除。
公历是一种历法系统,其中的年又叫日历年,日又叫日历日。这种历法系统由一系列连续的日历年(可能是无限的)组成,其中每年又划分成 12个顺序的日历月。
周日历是日常生活中不常用到的历法系统,一般用于政府、商务的会计年度或者学校教学日历中。
国际标准ISO 8601(数据存储和交换形式·信息交换·日期和时间的表示方法)中定义的ISO周日历系统:
国内是采用【GB/T 7408-2005/ISO 8601:2000】标准(位于 4.3.2.2 日历星期,实际上还是采用的ISO 8601:2000年版本的标准)。定义如下:
公历中的2019年12月30日星期一是ISO日历中2020年第1周的第一天,写为2020-W01-1或2020W011。
推理可得:
按照国际标准 ISO 8601 的说法,星期一是一周的开始,而星期日是一周的结束。虽然已经有了国际标准,但是很多国家,比如「美国」、「加拿大」和「澳大利亚」等国家,依然以星期日作为一周的开始。
所以在计算一年的第一周的时候,国内日历和欧美一些国家存在差异。
(符号向上取整)
/**
* 根据年份计算当年周数
* @param {number} y 年
*/
function computeWeeks(y) {
const leapDay = p(y) === 4 || p(y - 1) === 3 ? 1 : 0
return 52 + leapDay;
}
function p(y) {
return (y + Math.ceil(y / 4) + Math.ceil(y / 100) + Math.ceil(y / 400)) % 7;
}
/**
* 实际上 JavaScript 中获取一年的周数更简单
* 12月28日所在的周数,始终是一年中的最后一周
* 求出12月28日是星期几,如果早于或等于周四,那该年有53周
* Date.prototype.getDay 结果中 0 表示星期天
* @param {number} y 年份
*/
function getWeeks(y) {
const day = new Date(`${y}/12/28`).getDay();
return day !== 0 && day <= 4 ? 53 : 52
}
来自The Mathematics of the ISO 8601 Calendar
/**
* 计算自0年1月0日起,CE的天数(Gregorian)
*/
function gregdaynumber(year, month, day) {
y = year;
m = month;
if (month < 3) y = y - 1;
if (month < 3) m = m + 12;
return Math.floor(365.25 * y) - Math.floor(y / 100) + Math.floor(y / 400) + Math.floor(30.6 * (m + 1)) + day - 62;
}
/**
* 根据当前公历日期计算ISO日历日期
*/
function isocalendar1() {
var today = new Date();
year = today.getFullYear();
month = today.getMonth(); // 0=January, 1=February, etc.
day = today.getDate();
wday = today.getDay();
weekday = ((wday + 6) % 7) + 1; // getDay 返回的值是 0 ~ 6,这里转为1 ~ 7
isoyear = year;
d0 = gregdaynumber(year, 1, 0);
weekday0 = ((d0 + 4) % 7) + 1;
d = gregdaynumber(year, month + 1, day);
isoweeknr = Math.floor((d - d0 + weekday0 + 6) / 7) - Math.floor((weekday0 + 3) / 7);
// 检查12月的最后几天是否属于下一年的ISO周
if ((month == 11) && ((day - weekday) > 27)) {
isoweeknr = 1;
isoyear = isoyear + 1;
}
// 检查一月的前几天是否属于上一年的ISO周
if ((month == 0) && ((weekday - day) > 3)) {
d0 = gregdaynumber(year - 1, 1, 0);
weekday0 = ((d0 + 4) % 7) + 1;
isoweeknr = Math.floor((d - d0 + weekday0 + 6) / 7) - Math.floor((weekday0 + 3) / 7);
isoyear = isoyear - 1;
}
if (isoweeknr < 10) return isoyear + "-W0" + isoweeknr + "-" + weekday;
if (isoweeknr > 9) return isoyear + "-W" + isoweeknr + "-" + weekday;
}
(weeks
是第一个计算中的方法)
常数 10
woy
指 week of year
doy
指 day of the year,就是当年的第几天,取值doy = 1 → 365/366
dow
值 day of the week,就是星期几。使用JavaScript的 Date.prototype.getDay
方法取值范围为 0到6,对应周日到周六,但是dow
的值范围为 1~7
,需要相应转换
如果这样获得的星期数等于0,则意味着给定的日期属于上一个(基于周)的年份
如果获得的星期数为53,则必须检查日期是否是第二年的第1周
每月基于1月1日的偏移量
月 | Jan | Feb | Mar | Apr | May | Jun | Jul | Aug | Sep | Oct | Nov | Dec |
---|---|---|---|---|---|---|---|---|---|---|---|---|
平年 | 0 | 31 | 59 | 90 | 120 | 151 | 181 | 212 | 243 | 273 | 304 | 334 |
闰年 | 0 | 31 | 60 | 91 | 121 | 152 | 182 | 213 | 244 | 274 | 305 | 335 |
例如查找2016年11月5日星期六的星期数
// 使用每月基于当年的1月1日的偏移量计算
woy = Math.floor((10 + (305 + 5) − 6) / 7)
woy = Math.floor(314 / 7) = 44
// 既不是 0 也不是 53,所以就是当前周数
在日常生活中,我们经常打电话。当拿起送受话器,话机便向交换机发出了摘机信息,紧接着我们就会听到一种连续的“嗡嗡”声,这是交换机发出的,告诉我们可以拨号的信息。当拨通对方后,又会听到“哒-哒-”的呼叫对方的声音,这是交换局发出的,告诉我们正在呼叫对方接电话的信息……。
这里所说的摘机信息、允许拨号的信息、呼叫对方的回铃信息等等,主要用于建立双方的通信关系,我们把这类信息称为信令。(信令是交换机之间通信的语言)
中文翻译叫带内信令。上面说了信令是用来建立双方的通信关系的信息。而通信建立后传递的消息就是通信的内容。那么带内信令就是指信令和通信内容使用同一通道。具体原理和知识很复杂,这里只做了解。
ANSI 转义序列是命令行终端下用来控制光标位置、字体颜色以及其他终端选项的一项 in-bind signaling 标准。通常是在文本中嵌入确定的字节序列(符合带内信令的定义),大部分以 ESC
转义字符和 "["
字符开始,终端会把这些字节序列解释为相应的指令,而不是普通的字符编码。
在终端中,ASCII编码中有些字符是不能用来打印显示的,比如 '\a' ( 0x7 )
代表响铃,'\n' (0x0A)
代表换行,这些字符被称为控制符。控制符 '\e' (0x1B)
,这个字符代表 ESC
,即键盘上 ESC
按键的作用。ESC
是单词 escape
的缩写,即逃逸的意思。文本中出现这个转义字符,代表其后方的字符是 ANSI Escape code 编码。
如果终端输出不支持 ANSI 转义码,那么可能会看下下面的信息:
ESC[1;Foreground
�[1;30m 30 �[1;30m 30 �[1;30m 30 �[1;30m 30 �[1;30m 30 �[1;30m 30 �[1;30m 30 �[1;30m 30 �[0m
�[1;31m 31 �[1;31m 31 �[1;31m 31 �[1;31m 31 �[1;31m 31 �[1;31m 31 �[1;31m 31 �[1;31m 31 �[0m
�[1;32m 32 �[1;32m 32 �[1;32m 32 �[1;32m 32 �[1;32m 32 �[1;32m 32 �[1;32m 32 �[1;32m 32 �[0m
...
ANSI Escape code编码中有专门控制字符颜色的控制符,例如:
\e[37;44;3;1m
\e
代表开始ANSI Escape code[
代表转义序列开始符 CSI,Control Sequence Introducer37;44;4;1
代表以; 分隔的文本样式控制符,其中 37 代表文本前景色为白色,44代表背景为蓝色,3代表斜体,1代表加粗m
代表结束控制符序列比如在终端命令行中执行
echo -e "\e[37;44;3;1mLYL\e[0m"
-e
参数用于启用 echo
命令控制符转码,结尾的 \e[0m
代表重置文本样式。
因为 \e
控制符的16进制码为0x1B
, 8 进制码为 033
,所以以下表示方式等价:
\e[0m
\x1b[0m
\x1B[0m
\033[0m
\u001b[0m
代码 | 作用 | 备注 |
---|---|---|
0 | 重置/正常 | 关闭所有属性。 |
1 | 粗体或增加强度 | |
2 | 弱化(降低强度) | 未广泛支持。 |
3 | 斜体 | 未广泛支持。有时视为反相显示。 |
4 | 下划线 | |
5 | 缓慢闪烁 | 低于每分钟150次。 |
6 | 快速闪烁 | MS-DOS ANSI.SYS;每分钟150以上;未广泛支持。 |
7 | 反显 | 前景色与背景色交换。 |
8 | 隐藏 | 未广泛支持。 |
9 | 划除 | 字符清晰,但标记为删除。未广泛支持。 |
10 | 主要(默认)字体 | |
11–19 | 替代字体 | 选择替代字体 |
20 | 尖角体 | 几乎无支持。 |
21 | 关闭粗体或双下划线 | 关闭粗体未广泛支持;双下划线几乎无支持。 |
22 | 正常颜色或强度 | 不强不弱。 |
23 | 非斜体、非尖角体 | |
24 | 关闭下划线 | 去掉单双下划线。 |
25 | 关闭闪烁 | |
27 | 关闭反显 | |
28 | 关闭隐藏 | |
29 | 关闭划除 | |
30–37 | 设置前景色 | 参见下面的颜色表。 |
38 | 设置前景色 | 下一个参数是5;n或2;r;g;b,见下。 |
39 | 默认前景色 | 由具体实现定义(按照标准)。 |
40–47 | 设置背景色 | 参见下面的颜色表。 |
48 | 设置背景色 | 下一个参数是5;n或2;r;g;b,见下。 |
49 | 默认背景色 | 由具体实现定义(按照标准)。 |
51 | Framed | |
52 | Encircled | |
53 | 上划线 | |
54 | Not framed or encircled | |
55 | 关闭上划线 | |
60 | 表意文字下划线或右边线 | 几乎无支持。 |
61 | 表意文字双下划线或双右边线 | |
62 | 表意文字上划线或左边线 | |
63 | 表意文字双上划线或双左边线 | |
64 | 表意文字着重标志 | |
65 | 表意文字属性关闭 | 重置60–64的所有效果。 |
90–97 | 设置明亮的前景色 | aixterm(非标准)。 |
100–107 | 设置明亮的背景色 | aixterm(非标准)。 |
下图对应上表只有一个颜色代码的情况(颜色编码只支持 3 或 4 位,也就是只有8种或16种)
为什么是8或16种?因为初始的规格只有8种颜色,只给了它们的名字。SGR参数30-37选择前景色,40-47选择背景色。相当多的终端将“粗体”(SGR代码1)实现为更明亮的颜色而不是不同的字体,从而提供了8种额外的前景色,但通常情况下并不能用于背景色
下图对应代码为 38 或者 48 的设置背景的颜色选择(即8位编码,共256种颜色(2^8) )
代码格式如下
\e[ … 38;5;<n> … m # 选择前景色
\e[ … 48;5;<n> … m # 选择背景色
随着显卡的更新迭代,某些终端已经支持渲染 24 位色彩。格式如下
\e[ … 38;2;<r>;<g>;<b> … m # 选择RGB前景色
\e[ … 48;2;<r>;<g>;<b> … m # 选择RGB背景色
上面的字体样式控制可以混合使用,下面同时设置前景和背景色
本篇着重说明 ANSI color,其他终端控制输入输出等内容不再研究。
在开发web端的日志组件或Web页面上的命令行终端时,知道 ANSI 转义码会让工作更加轻松。因为这可能会避免你对着一堆的乱码发呆。当然还可以使用一些第三方库将 ANSI color 转译成 html 输出,这样操作起来会更加方便。例如 https://github.com/drudru/ansi_up
参考
在使用 CSS3 的 linear-gradient()
函数绘制一个网格背景的时候,遇到了一个奇怪的问题:当将 background-size
放到 background: linear-gradient()
前面时,渐变背景的效果就失效了。放在后面就又正常了。
正常显示时代码:
.example {
width: 300px;
height: 300px;
background: linear-gradient(to top, transparent 39px, blue 40px),
linear-gradient(to left, transparent 39px, blue 40px);
background-size: 40px 40px;
}
但是当将 background-size
移到 background: linear-gradient()
前面时:
.example {
background-size: 40px 40px;
background: linear-gradient(to top, transparent 39px, blue 40px),
linear-gradient(to left, transparent 39px, blue 40px);
}
其实不仅是渐变背景,使用图片背景时,也会因此而导致背景图片的大小和 repeat
特性变得不受控制。毕竟渐变其实是一种特殊的 image 背景。
CSS
linear-gradient()
函数用于创建一个表示两种或多种颜色线性渐变的图片。其结果属于<gradient>
数据类型,是一种特别的<image>
数据类型。—— MDN
原因很简单,其实就是 background
属性简写造成的。
我们知道 CSS 中只写 background
时,是一种属性的简写,会隐式声明背景的多个属性,包括 background-clip
、background-color
、background-image
、background-origin
、background-position
、background-repeat
、background-size
,和 background-attachment
。
另外,在同一选择器中重复声明多条相同的属性(如果不在意编辑器的警告),后面声明的属性值会覆盖前面的属性值。因为 CSS 的语句是顺序‘执行’的。
因此,如果将 background-size
放在 background
前面,实际上 background
中隐式声明的 background-size
会覆盖前面的显式声明,就像这样:
.example {
background-size: 40px 40px;
background-size: auto auto;
/* background-size 的初始值是 auto auto */
}
而如果将显式声明的 background-size
放在 background
后面,那就是显式声明覆盖属性简写中的隐式声明,所以显式声明的属性就生效了
.example {
background-size: auto auto;
background-size: 40px 40px;
}
原因分析中提到的,修正覆盖顺序,将显式声明的 background-size
放在 background
后面
不使用 background
简写,而是改成 background-image
,此时不会发生覆盖,所以顺序可以是任意的了
.example {
background-size: 40px 40px;
background-image: linear-gradient(to top, transparent 39px, blue 40px),
linear-gradient(to left, transparent 39px, blue 40px);
}
只用简写
background
的简写还需要注意一点,“” 只能紧接着 “” 出现,并且以"/"分割,比如 0 0/40px 40px
表示 background-position-x background-position-y/background-size
。
另外要注意一点,background-size
如果仅有一个数值被给定,这个数值将作为宽度值大小,高度值将被设定为 auto
。
background: linear-gradient(to right, transparent 39px, blue 40px) 0 0/40px 40px,
linear-gradient(to bottom, transparent 39px, blue 40px) 0 0/40px 40px;
说到 CSS 的优先级规则,一般想到的是“选择器的权重”或者“同名选择器时的优先级”,又或者“同一元素有多个选择器时的样式层叠效果”。这里实际上是在同一选择器中声明属性时区分顺序的情况,只有两条:
background
发生的隐式声明和显式声明之间的覆盖,其实还是相同属性的书写顺序问题linear-gradient
语法说明最终语法:
linear-gradient([ [ [ <angle> | to [top | bottom] || [left | right] ],]? <color-stop>[, <color-stop>]+);
当使用带前缀的规则时,不要加“to
”关键字。不带前缀且没有 to
关键词的语法会被丢弃。to top, to bottom, to left 和 to right这些值会被转换成角度0度、180度、270度和90度。其余值会被转换为一个以向顶部**方向为起点顺时针旋转的角度。
.grad {
background-color: #F07575; /* 不支持渐变的浏览器回退方案 */
background-image: -webkit-linear-gradient(top, hsl(0, 80%, 70%), #bada55); /* 支持 Chrome 25 and Safari 6, iOS 6.1, Android 4.3 */
background-image: -moz-linear-gradient(top, hsl(0, 80%, 70%), #bada55); /* 支持 Firefox (3.6 to 15) */
background-image: -o-linear-gradient(top, hsl(0, 80%, 70%), #bada55); /* 支持旧 Opera (11.1 to 12.0) */
background-image: linear-gradient(to bottom, hsl(0, 80%, 70%), #bada55); /* 标准语法; 需要最新版本 */
}
当前版本(ECMAScript 2021)ECMAScript 标准定义了 8 种类型:
共 7 种:String
、Number
、Boolean
、Undefined
、Null
、Symbol
、BigInt
。
原始类型的值特点:值不可变,无属性无方法,保存在栈内存中、值比较。
无属性无方法可能会让人迷惑:
let s1 = "some text";
let s2 = s1.substring(2);
原始值本身不是对象,因此逻辑上不应该有方法,实际上这个例子确实没有报错,且得到了结果。原因是第二行访问 s1
时,是以读模式访问的,也就是要从内存中读取变量保存的值。
当以读模式访问原始值的时候,后台会执行以下三步(以上面代码为例):
等同于后台自动执行了下面的代码
let s1 = new String("some text");
let s2 = s1.substring(2);
s1 = null;
因此原始值拥有了对象的行为。
除了上面的 7 种基本数据类型外,剩下的就是引用类型,统称为 Object
类型
基本引用类型
Data
和 RegExp
等ECMAScript 提供的原 生引用类型
原始值包装类型,ECMAScript 提供了 3 种特殊的引用类型:Boolean
、Number
和 String
(只有这三种)
let stringObject = new String('hello');
// 如果在使用包装对象时,忘了在前面加 “new” 操作符,会等被当做普通函数执行,作用是把任何类型的数据转换为对应的类型
函数
单例内置对象:Global
和 Math
ECMA-262中定义:任何由 ECMAScript 实现提供、与宿主环境无关,并在 ECMAScript 程序开始执行时就存在的对象
Global
对象在浏览器中被实现为 window
对象。所有全局变量和函数都是 Global
对象的属性Math
对象包含辅助完成复杂计算的属性和方法集合引用类型
最常见的 Object
和 Array
Map
、WeakMap
、Set
以及 WeakSet
类型
定型数组:例如 Int32Array
、Float64Array
等等
定型数组(typed array)是 ECMAScript 新增的结构,目的是提升向原生库传输数据的效率。实际上, JavaScript 并没有“TypedArray”类型,它所指的其实是一种特殊的包含数值类型的数组。设计定型数组的目的就是提高与 WebGL 等原生库交换二进制数据的效率。
引用类型的值特点:可变、引用比较、值保存在堆(Heap)内存
typeof
typeof
对判断原始值的类型简单有效。
typeof 37 === 'number';
typeof NaN === 'number';
typeof 42n === 'bigint';
typeof 'bla' === 'string';
typeof '1' === 'string';
typeof undefined === 'undefined';
typeof Symbol('foo') === 'symbol';
typeof false === 'boolean';
但是对引用值的作用不大,因为无论什么类型的对象(除了 Function
类型),使用typeof
都会返回 "object"
。但是我们往往关心的不是这个值是不是对象,而是这个值是什么类型的对象。
typeof {a: 1} === 'object';
typeof [1, 2, 4] === 'object';
typeof new Date() === 'object';
typeof /regex/ === 'object';
除了正常的功能外,经常还会提起 typeof
的一些其它特性和例外:
null
是简单类型的值,它有自己的类型 Null
,但是 typeof null
返回的却是 object
在 JavaScript 最初的实现中,值是由一个表示类型的标签和实际数据值表示的。对象的类型标签是
0
。由于null
代表的是空指针(大多数平台下值为 0x00),因此,null
的类型标签是0
,typeof null
也因此返回"object"
。之后也是为了不得罪人,也就一直保留了这个问题特性。
判断函数返回的是 "function"
typeof function() {} // "function"
typeof class C {} // "function"
typeof Math.sin // "function"
typeof (() => {}) // "function"
这里不好理解的是为什么 class C {}
返回的也是 "function"
。其实 ECMAScript 中的 class
只不过是构造函数的语法糖而已。
括号?
typeof
是操作符,语法上后面直接跟操作数,不需要括号。括号本身也是操作符,并且括号的优先级要高于 typeof
。所以当 typeof
后面跟上括号时,其实优先执行括号里的表达式并返回操作数,typeof
判断的是这个返回的操作数的类型。
typeof 42 + ' hello'
// "number hello"
typeof (42 + ' hello')
// "string"
(42 + ' hello')
// "42 hello"
暂时性死区导致的报错
在 ES6 之前,typeof
总能保证对任何所给的操作数返回一个字符串。使用 typeof
永远不会抛出错误。
ES6 新增了 let
和 const
用来声明变量,使用它们会形成块级作用域,那么在变量声明之前对块中的 let
和 const
变量使用 typeof
会抛出一个 ReferenceError
(暂时性死区的特性)。
typeof foo;
let foo = 'bar';
// Uncaught ReferenceError: foo is not defined
一个“挑衅意味”的例外,对,就是各大浏览厂商故意这么做的(不过 document.all
在HTML5 中已经被废弃,但是现在所有浏览器依然支持)
console.log(document.all)
// HTMLAllCollection(1713) [html,... 页面上的所有元素
typeof document.all
// "undefined"
因为 typeof
无法很好的区分引用值是什么类型的对象,所以ECMAScript 提供了 instanceof
操作符来解决这个问题。
instanceof
如果变量是给定引用类型的实例,则 instanceof
操作符返回 true
。instanceof
检测的是构造函数的 prototype
(原型)是否存在于给定的对象实例的原型链上。
使用 instanceof
来判断原生对象类型,像 Object
和 Array
这样的原生构造函数,运行时可以直接在执行环境中使用:
var arr = [];
var arr1 = new Array(10);
console.log(arr instanceof Array); // true
console.log(arr1 instanceof Array); // true
ECMAScript 中可以使用自定义构造函数创建自定义类型的对象,那么依然可以使用 instanceof
判断自定义引用类型:
function Foo() {}
let f = new Foo();
console.log(f instanceof Foo); // true
class Bar {}
let b = new Bar();
console.log(b instanceof Bar); // true
我们知道,每个对象实例都有一个 constructor
属性,指向用于创建当前对象的函数(constructor
属性是在构造函数的 prototype
上定义的)
Array.prototype.constructor === Array; // true
[].constructor === Array; // true
// 为什么原始值也有 constructor ? 可以看 typeof 那一节有解释
''.constructor === String // true
// null 和 undefined 不存在 constructor 的
constructor
本来是用于标识对象类型的。
function Person(name, age, job){
// ...
}
let person1 = new Person("Nicholas", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");
console.log(person1.constructor == Person); // true
console.log(person2.constructor == Person); // true
但是对于自定义构造函数一旦重写 prototype
,可能会造成原有的 constructor
引用丢失,从而导致 constaructor
会变成默认的 Object
:
function F() {}
// { foo: 'bar' } 是对象字面量,本身是一个 `Object` 实例,它自身的 constructor 指向 Object
F.prototype = { foo: 'bar' };
f.constructor === F
// false
f.constructor
// ƒ Object() { [native code] }
所以认为 instanceof
操作符是确定对象类型更可靠的方式。但是 instanceof
真的可靠吗?
并不可靠,就像上面说的,因为构造函数的 prototype
是可以改变的,所以在创建实例之后,修改构造函数的 prototype
属性,如果修改后的原型不在实例对象的原型链上了,那就会返回 false
:
function F() {}
var f = new F;
F.prototype = {}
f instanceof F // false
还有一种方法是借助非标准的 __proto__
伪属性来修改实例的原型链,使实例的原型链上不再出现创建它的构造函数的原型:
function F() {}
var f = new F;
f.__proto__ = {};
f instanceof F // false
使用 instanceof
还有一些其他问题需要注意:
不适用于原始值
var simpleStr = "This is a simple string";
var newStr = new String("String created with constructor");
simpleStr instanceof String; // 返回 false, 非对象实例,因此返回 false
newStr instanceof String; // 返回 true
Object.create(null) instanceof Object
返回 false
。这是一种创建非 Object
实例的对象的方法
如果一个对象实例的原型链上不止一种构造函数的原型,那么使用 instanceof
操作符检查这两种类型都会返回 true
function foo() {}
foo instanceof Function // true
foo instanceof Object // true
上面的 Person
自定义构造函数创建的对象实例 person1
和 person2
,既属于 Person
类型,又属于 Object
类型
console.log(person1 instanceof Object); // true
console.log(person1 instanceof Person); // true
console.log(person2 instanceof Object); // true
console.log(person2 instanceof Person); // true
多全局对象引起的问题
在浏览器中,我们的脚本可能需要在多个窗口之间进行交互。多个窗口意味着多个全局环境,不同的全局环境拥有不同的全局对象,从而拥有不同的内置类型构造函数。
这可能会引发一些问题。例如:
[] instanceof window.frames[0].Array // false
因为 Array.prototype !== window.frames[0].Array.prototype
,意思就是 window.frames[0].Array
的原型不在前者 []
的原型链上。
instanceof
是操作符,所以要注意操作符的优先级,例如检测对象不是某个构造函数的实例时:
if (!(mycar instanceof Car)) {
// ...
}
// 而不是使用 if (!mycar instanceof Car),逻辑非的优先级高于 instanceof
Symbol.hasInstance
(ES6)上一节说到 instanceof
操作符可以用来确定一个对象实例的原型链上是否有原型。在 ES6 中,instanceof
操作符会使用 Symbol.hasInstance
函数来确定关系。
function Foo() {}
let f = new Foo();
// 使用 instanceof
console.log(f instanceof Foo); // true
// 使用 Symbol. hasInstance
console.log(Foo[Symbol.hasInstance](f)); // true
这个属性定义在 Function
的原型上,因此默认在所有函数和类上都可以调用。由于 instanceof
操作符会在原型链上寻找这个属性定义,因此可以在继承的类上通过静态方法重新定义这个函数,从而可以用它自定义 instanceof
操作符在某个类上的行为:
class Array1 {
static [Symbol.hasInstance](instance) {
return Array.isArray(instance);
}
}
console.log([] instanceof Array1); // true
Object.prototype.toString()
每个对象都有一个 toString()
方法,默认情况下,toString()
方法被每个 Object
对象继承。如果此方法在自定义对象中未被覆盖,toString()
返回一个字符串 "[object type]",其中 type
是对象的类型。
var o = new Object();
o.toString(); // [object Object]
为了获取准确的数据类型,我们最常用的方法也是目前最全最有效的方法就是使用 Object.prototype.toString()
来检测。需要注意的是:
不是直接使用 toString
方法,而是使用 Object
构造函数原型上的方法,因为其它内置对象或者自定义类型的对象可能会实现自己的 toString
方法或者根本没有定义该方法,导致返回的不再是 “[object type]” 或者直接报错
var str = new String('hello')
str.toString() // "hello"
123..toString() // "123"
null.toString() // Uncaught TypeError: Cannot read property 'toString' of null
undefined.toString() // Uncaught TypeError: Cannot read property 'toString' of undefined
需要以 Function.prototype.call()
或者 Function.prototype.apply()
的形式来调用(不陌生吧,改变 this
指向)
var toString = Object.prototype.toString;
toString.call(new String); // "[object String]"
toString.call('hello'); // "[object String]"
toString.call(Math); // "[object Math]"
toString.call(123); // "[object Number]"
toString.call(true); // "[object Boolean]"
toString.call(123n); // "[object BigInt]"
toString.call(new Date); // "[object Date]"
toString.call(Symbol()) // "[object Symbol]"
toString.call(() => {}) // "[object Function]"
toString.call([1, 2]); // "[object Array]"
toString.call(new Map) // "[object Map]"
toString.call(function* () {}); // "[object GeneratorFunction]"
toString.call(Promise.resolve()); // "[object Promise]"
// ES5 中才实现下面的功能
toString.call(undefined); // "[object Undefined]"
toString.call(null); // "[object Null]"
这种方法对于日常开发中的大部分开发场景都适用了,但是对于开发者创建的类,默认情况下 toString()
只会返回默认的 Object
标签:
class ValidatorClass {}
Object.prototype.toString.call(new ValidatorClass()); // "[object Object]"
那有没有方法让自定义的类实例返回我们想要的结果呢?在 ES2015 标准中有一个内置 Symbol
:Symbol.toStringTag
。
Symbol.toStringTag
(ES6)Symbol.toStringTag
的值是一个字符串,这个字符串表示该对象的自定义类型标签。上面说到 Object.prototype.toString()
默认会返回返回一个字符串 "[object type]",其中的“type”有时候就是读取的 Symbol.toStringTag
的值。
不是所有的内置 JavaScript 对象类型都有 Symbol.toStringTag
属性,比如 ES6 之前使用 Object.prototype.toString()
也能返回类型:
Object.prototype.toString.call('foo'); // "[object String]"
Object.prototype.toString.call([1, 2]); // "[object Array]"
Object.prototype.toString.call(3); // "[object Number]"
Object.prototype.toString.call(true); // "[object Boolean]"
Object.prototype.toString.call(undefined); // "[object Undefined]"
Object.prototype.toString.call(null); // "[object Null]"
// ...
var o = new Object;
console.log(o[Symbol.toStringTag]); // undefined
JavaScript 引擎会为一些对象类型设置好 toStringTag
标签:
Object.prototype.toString.call(new Map()); // "[object Map]"
Object.prototype.toString.call(function* () {}); // "[object GeneratorFunction]"
Object.prototype.toString.call(Promise.resolve()); // "[object Promise]"
// ...
var m = new Map;
console.log(Map.prototype[Symbol.toStringTag]); // 'Map'
console.log(m[Symbol.toStringTag]); // 'Map'
但是开发者自己创建的类需要手动添加 toStringTag
属性,否则引擎无法识别只能返回默认的 [object Object]
class ValidatorClass {
get [Symbol.toStringTag]() {
return "Validator";
}
}
Object.prototype.toString.call(new ValidatorClass()); // "[object Validator]"
Symbol.toStringTag
的属性特性:是【不可写】、【不可枚举】并且【不可配置】
Map.prototype[Symbol.toStringTag]; // "Map"
Map.prototype[Symbol.toStringTag] = 'map';
var m = new Map;
m[Symbol.toStringTag]; // "Map"
m[Symbol.toStringTag] = 'map';
m[Symbol.toStringTag] // "Map"
Map.prototype[Symbol.toStringTag] // "Map"
// ---
var va = new ValidatorClass
va[Symbol.toStringTag] = 'va'
va[Symbol.toStringTag] // "Validator"
但是在测试的时候发现,内置的 JavaScript 对象类型,如果本身没有设置 Symbol.toStringTag
属性的话,我们是可以赋值并改写它的值的
Object.prototype[Symbol.toStringTag] = 'o'
var o = new Object
o[Symbol.toStringTag] // "o"
o[Symbol.toStringTag] = "object"
o[Symbol.toStringTag] // "object"
Object.prototype[Symbol.toStringTag] // "o"
Object.prototype.toString.call(o); // "[object object]"
目前不知原因。
Array.isArray()
用于确定传递的值是否是一个 Array
Array.isArray([1]); // true
Array.isArray(new Array('a', 'b', 'c', 'd')); // true
有个非常细节的知识点,Array.prototype
是一个数组
Array.isArray(Array.prototype); // true
console.log(Array.prototype);
// [constructor: ƒ, concat: ƒ, copyWithin: ƒ, fill: ƒ, find: ƒ, …]
对于定型数组(TypedArray)实例,总是返回 false
Array.isArray(new Uint8Array(32)); // false
Array.isArray()
能检测iframes
,所以针对数据的检查,应该使用它而不要使用instanceof
// 偷懒,直接搬 MDN 上的
var iframe = document.createElement('iframe');
document.body.appendChild(iframe);
xArray = window.frames[window.frames.length-1].Array;
var arr = new xArray(1,2,3); // [1,2,3]
Array.isArray(arr); // true
arr instanceof Array; // false
isNaN()
和 Number.isNaN()
用来确定一个值是否为 NaN
全局对象的 isNaN
方法会将不是 Number
类型的参数转换为数值(空字符串和布尔值分别会被强制转换为数值 0 和 1),然后才会对转换后的结果是否是 NaN
进行判断。这就产生了有趣的现象
isNaN(NaN); // true
isNaN(undefined); // true
isNaN({}); // true
isNaN(true); // false
isNaN(null); // false
isNaN(37); // false
// strings
isNaN("37"); // false: 可以被转换成数值37
isNaN("37.37"); // false: 可以被转换成数值37.37
isNaN("37,5"); // true
isNaN('123ABC'); // true: parseInt("123ABC")的结果是 123, 但是Number("123ABC")结果是 NaN
isNaN(""); // false: 空字符串被转换成0
isNaN(" "); // false: 包含空格的字符串被转换成0
// dates
isNaN(new Date()); // false
isNaN(new Date().toString()); // true
isNaN("blabla") // true: "blabla"不能转换成数值
// 转换成数值失败, 返回NaN
所以建议使用 Number.isNaN()
方法来确定传递的值是否为 NaN
,它是比原来的全局 isNaN()
的更安全的版本。它不会将
Number.isNaN("NaN"); // false,字符串 "NaN" 不会被隐式转换成数字 NaN。
Number.isNaN(undefined); // false
Number.isNaN({}); // false
Number.isNaN("blabla"); // false
Array.isArray()
和 isNaN
typeof
操作符,对于引用类型的值可以使用 instanceof
操作符确定对象类型。对于ES6的代码,可以通过重写类的 Symbol.hasInstance
静态方法来自定义 instanceof
的行为Object.prototype.toString()
,调用的时候需要使用 .call()
或者 .apply()
Object.prototype.toString()
返回期望的类型字符串,可以通过添加 Symbol.toStringTag
属性。最开始 Mozilla JS Parser API 是 Mozilla 工程师在 Firefox 中创建的 SpiderMonkey 引擎输出 JavaScript AST 的规范文档,文档所描述的格式被用作操作 JAvaScript 源代码的通用语言。
随着 JavaScript 的发展,更多新的语法被加入,为了帮助发展这种格式以跟上 JavaScript 语言的发展。The ESTree Spec 就诞生了,作为参与构建和使用这些工具的人员的社区标准。
Parser API 中描述了一些特定于 SpiderMonkey 引擎的行为,而 ESTree spec 是社区规范,并且向后兼容 SpiderMonkey 格式。
现在大部分 JS 解析器都是基于这两个中的一个规范实现的,大多数情况下生成的 AST 是兼容的。
ESTree 是JS社区所遵循的一种语法表达标准,目的是让遵循该标准的代码工具生成一种 JSON 风格的 AST。GitHub 地址是 https://github.com/estree/estree。这个仓库包含从 ES5 到最新的 ECMAScript 标准的语法表达。通过这些表达规范,更容易用JavaScript写出处理JavaScript源代码的工具(比如语法高亮工具,静态分析工具, 翻译器,编译器,混淆器等等)。
parse(input, options)
返回的值符合 ESTree spec 描述的 AST 对象至于其他的源码工具无外乎遵循社区认同的规范,或者自己实现 AST 的规范,这里不再一一查看。
Acron 比 Esprima 后出现,两者相比,前者实现的代码更少,而且速度和后者相差无几。所以更多的源码工具开始基于 Acron 进行解析器的开发。
ESTree 只是一种规范,本身的意义只是用来描述一种语法表达,也就是 AST 对象。而源码处理工具真正用到的就是 AST。
例如Webpack 中,Parser 类生产 AST 语法树后调用 walkStatements 方法分析语法树,根据 AST 的 node type来递归查找每一个 node 的类型和执行不同的逻辑,并创建依赖。
ES6 作为 JS 的新规范,加入了很多新的语法和 API,而市面上的浏览器并没有全部兼容,所以需要将 ES6 语法代码转为 ES5 的代码。Babel 是一款非常流行的 ES6 转 ES5 的工具,其大致流程:
解析:解析代码字符串,生成 AST;
分词:将整个代码字符串分割成_语法单元_数组,也有叫 Token 生成过程。语法单元是被解析语法当中具备实际意义的最小单元,例如将代码字符串拆分成空白、注释、字符串、数字、标识符等等。经过分词处理后的数据结构更方便后面的处理。类似于这样:
[
{ type: "whitespace", value: "\n" },
{ type: "identifier", value: "if" },
{ type: "whitespace", value: " " },
...
]
语义分析:在分词结果的基础上分析语法单元之间的关系,确定有多重意义的词语最终是什么意思、多个词语之间有什么关系以及又应该再哪里断句等。语义分析的过程又是个遍历语法单元的过程,不过相比较而言更复杂,因为分词过程中,每个语法单元都是独立平铺的,而语法分析中,语句和表达式会以树状的结构互相包含。
转换:按一定的规则转换、修改 AST;
生成:将修改后的 AST 转换成普通代码。
上面只是大致流程,实际上执行的过程更加复杂。
例如有下面一段代码:
const num = 123;
通过 https://astexplorer.net/ 在线使用 babylon7 转译后的结果如下:
JSON 格式的 AST:
{
"type": "VariableDeclaration",
"start": 0,
"end": 16,
"loc": {
"start": {
"line": 1,
"column": 0
},
"end": {
"line": 1,
"column": 16
}
},
"declarations": [
{
"type": "VariableDeclarator",
"start": 6,
"end": 15,
"loc": {
"start": {
"line": 1,
"column": 6
},
"end": {
"line": 1,
"column": 15
}
},
"id": {
"type": "Identifier",
"start": 6,
"end": 9,
"loc": {
"start": {
"line": 1,
"column": 6
},
"end": {
"line": 1,
"column": 9
},
"identifierName": "num"
},
"name": "num"
},
"init": {
"type": "NumericLiteral",
"start": 12,
"end": 15,
"loc": {
"start": {
"line": 1,
"column": 12
},
"end": {
"line": 1,
"column": 15
}
},
"extra": {
"rawValue": 123,
"raw": "123"
},
"value": 123
}
}
],
"kind": "const"
}
对比着ESTree spec 中的描述,ESTree 中的描述使用的是 TypeScript 的接口描述方式 interface
/* VariableDeclaration */
// es5.md
interface VariableDeclaration <: Declaration {
type: "VariableDeclaration";
declarations: [ VariableDeclarator ];
kind: "var";
}
// es2015.md
extend interface VariableDeclaration {
kind: "var" | "let" | "const";
}
/* VariableDeclarator */
interface VariableDeclarator <: Node {
type: "VariableDeclarator";
id: Pattern;
init: Expression | null;
}
上面的 JSON 中有 "kind": "const"
,好了,对接上了,type、declarations 字段都对接上了。
略加思考,Babel 的解析器其实做的事情就是按照 ESTree 的规范将源代码拆分解析生成这样的一个树或者 JSON 数据。ESTree就是一本转换参考指南,设计解析器的时候,根据这本指南将相应的代码拆到对应的位置。可以说 Babel 的解析器是对 ESTree spec 的实践,解析器可以按照不同的规范设计,甚至自定义一套 AST 的格式让解析器去遵循。
完成第一步的解析,生成了 AST,接下来 Babel 对这个 AST 进行规则转换和修改。例如将 const
转为 var
,就是通过遍历 AST 树,通过查找 node 的 type,将 VariableDeclaration
类型节点的 kind
的值由 cont
替换成 var
。babel 内部应该做了更多的事情,但是大致意思就是这样了。
当然 Babel 的 parser 并不是完全按照 ESTree 的 spec 进行解析的,工具可以添加自定义的 node type 或修改相应的规则,以辅助后面步骤的执行。Babel 官网中有提到
The Babel parser generates AST according to Babel AST format. It is based on ESTree spec with the following deviations:
- Literal token is replaced with StringLiteral, NumericLiteral, BooleanLiteral, NullLiteral, RegExpLiteral
- Property token is replaced with ObjectProperty and ObjectMethod
- MethodDefinition is replaced with ClassMethod
- Program and BlockStatement contain additional
directives
field with Directive and DirectiveLiteral- ClassMethod, ObjectProperty, and ObjectMethod value property's properties in FunctionExpression is coerced/brought into the main method node.
修改完成后需要将 AST 重新转为源代码,这时候需要一个能读懂生成 AST 时所遵循的 spec 的代码生成器。比如 Escodegen,Escodegen(escodegen)是来自Mozilla的Parser API AST的ECMAScript(也称为JavaScript)代码生成器。
ESTree spec 和 Parser API 都是定义一种语法表达的标准,这种标准生成的结构就是 AST。大部分流行的JS源码操作工具都是基于 AST 实现的,那么它们就需要使用解析器生成 AST,而解析器就是这些规范的实现,大多数工具依赖
React Portal 是一种将子节点渲染到其父组件以外的 DOM 节点的方案。 -- React 文档
默认情况,组件的 render
方法返回一个元素时,会被挂在到最近的 DOM 节点上,也就是其父节点。比如这样
class Item extends React.Component {
// ...
render() {
return (
<div>xxx</div>
);
}
}
class App extends React.Component {
// ...
render() {
return (
<div className="wrap">
<Item />
</div>
);
}
}
Item 组件会被挂载在 className
为 “wrap
” 的 div
节点上,Item 返回的内容会被渲染在 App 组件渲染的区域内。
但是有时候我们希望在父组件内使用子组件,但是在子组件的渲染内容不会出现在该父组件渲染区域内,而是出现在别的地方,甚至挂载的DOM节点并不在该父组件的子节点中。
如下图的需求:
实现的关键是:每个 Tab 页都单独定义一个 Button 区域组件,通过 CSS 绝对定位定位到指定位置。
这样做可以保证 Button 区域的按钮随着内容区的Tab也切换而改变,因为它们本身就是挂载在下面的Tab页的DOM节点上的。
但是,由于是通过绝对定位将渲染的视觉位置改变,所以需要梳理好父节点及其兄弟节点间的样式关系。比如内容区不能设置 overflow: hidden;
样式;还有在 Tab 组件到 button 区之间可能有其他 position: relative
(或 absolute
)元素的影响等等。
React Portal 用法
ReactDOM.createPortal(child, container)
该方法定义在 react-dom 上而不是 react 中
child
是被传送过去要渲染的内容,是任何可渲染的 React 子元素,例如一个元素,字符串或 fragment
。
React 不会创建一个新的 div。它只是把第一个参数的子元素渲染到“container
”中。“container
” 是一个可以在任何位置的有效 DOM 节点。就如同官方例子中所示
document.createElement
创建一个没有挂载在任何地方的 div
元素div
元素通过 appendChild
方法添加到指定 DOM 节点下div
元素作为 createPortal
的第二个参数,其实就是将子元素渲染进这个 div
中,那么也就将想要渲染的内容渲染在指定位置了。先看一个官方例子:
// 入口 index.html
<!DOCTYPE html>
<html>
<body>
<div id="app-root"></div>
<div id="modal-root"></div>
</body>
</html>
// 组件实现
import React from 'react';
import ReactDOM from 'react-dom';
// 获取两个DOM节点
const appRoot = document.getElementById('app-root');
const modalRoot = document.getElementById('modal-root');
// 创建一个模态组件,它是 Portal API的实现
class Modal extends React.Component {
constructor(props) {
super(props);
// 创建一个div,我们将把modal呈现到其中。因为每个模态组件都有自己的元素,
// 所以我们可以将多个模态组件呈现到模态容器中。
this.el = document.createElement('div');
}
componentDidMount() {
// 将元素附加到mount上的DOM中。我们将呈现到模态容器元素中
modalRoot.appendChild(this.el);
}
componentWillUnmount() {
// 卸载组件的时候,移除手动创建的 DOM
modalRoot.removeChild(this.el);
}
render() {
// 使用传送门将 children 渲染进元素中
return ReactDOM.createPortal(
// 任意有效的 React 子节点:JSX,字符串,数组等等
this.props.children,
// DOM 元素
this.el,
);
}
}
// Modal 组件是一个普通的 React 组件,因此我们可以在任何地方呈现它,用户不需要知道它是用门户实现的。
class App extends React.Component {
constructor(props) {
super(props);
this.state = {showModal: false};
this.handleShow = this.handleShow.bind(this);
this.handleHide = this.handleHide.bind(this);
}
handleShow() {
this.setState({showModal: true});
}
handleHide() {
this.setState({showModal: false});
}
render() {
// 点击时展示 Modal
const modal = this.state.showModal ? (
<Modal>
<div className="modal">
xxx
<button onClick={this.handleHide}>Hide modal</button>
</div>
</Modal>
) : null;
return (
<div className="app">
This div has overflow: hidden.
<button onClick={this.handleShow}>Show modal</button>
{modal}
</div>
);
}
}
ReactDOM.render(<App />, appRoot);
实际测试,直接传目标 DOM 节点是完全可以的(看这里,Fork的官方例子修改)。但问题是,就如同描述的那样第二个参数可以在任何位置。也就是说我们无法保证传进去的DOM节点已经被渲染,所以要手动加一些校验,防止出现“当传送时目标DOM还没有被渲染”的情况。
import React, { useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
import "./styles.css";
function Target(props) {
const modalRoot = document.getElementById("modal-root");
const eleRef = useRef(document.createElement("div"));
useEffect(() => {
if (modalRoot) {
modalRoot.appendChild(eleRef.current);
return () => {
if (modalRoot) {
modalRoot.removeChild(eleRef.current);
}
};
}
}, [modalRoot]);
return ReactDOM.createPortal(
<div>
hello world!
{props.children}
</div>,
eleRef.current
);
}
function View() {
const [show, setShow] = useState(false);
return (
<div>
内容将传送至上方红色区域
<button onClick={() => setShow(true)}>开启传送</button>
{show && (
<Target>
<div className="modal">
<div>
通过Portal,我们可以将内容呈现到DOM的不同部分,就像它是任何其他React子级一样。
</div>
<button onClick={() => setShow(false)}>销毁目标</button>
</div>
</Target>
)}
</div>
);
}
export default function App() {
return (
<div className="app">
<div id="modal-root"></div>
<View />
</div>
);
}
虽然 portal 可以被放置在 DOM 树中的任何地方,但是其行为和普通的 React 子节点行为一致。比如 context
功能、事件冒泡等等。
拿事件冒泡来说,(React v16 之后)在 portal 渲染的 DOM 内部触发的事件会一直冒泡到开启传送的源位置(不是实际渲染挂载的DOM位置)。比如官方文档的示例中,在 #app-root
里的 Parent 组件能够捕获到未被捕获的从兄弟节点 #modal-root
冒泡上来的事件。
以下部分内容来自程墨Morgan的传送门:React Portal(侵删)
React Portal之所以叫Portal,因为做的就是和“传送门”一样的事情:render
到一个组件里面去,实际改变的是网页上另一处的DOM结构。
比如,某个组件在渲染时,在某种条件下需要显示一个对话框(Dialog),这该怎么做呢?
而 portal 的典型用例就是当父组件有 overflow: hidden
或 z-index
样式时,但需要子组件能够在视觉上“跳出”其容器。例如,对话框、悬浮卡以及提示框。
在v16之前,实现“传送门”,要用到两个秘而不宣的React API
unstable_renderSubtreeIntoContainer
unmountComponentAtNode
第一个 unstable_renderSubtreeIntoContainer
,都带上前缀 unstable
了,就知道并不鼓励使用,但是没办法,不用也得用,还好 React 一直没有 deprecate 这个 API,一直挺到 v16 直接支持 portal。这个API的作用就是建立“传送门”,可以把JSX代表的组件结构塞到传送门里面去,让他们在传送门的另一端渲染出来。
第二个 unmountComponentAtNode
用来清理第一个 API 的副作用,通常在 unmount
的时候调用,不调用的话会造成资源泄露的。
一个通用的Dialog组件的实现差不多是这样,注意看 renderPortal
中的注释。
v16之前的React Portal实现方法,有一个小小的缺陷,就是Portal是单向的,内容通过Portal传到另一个出口,在那个出口DOM上发生的事件是不会冒泡传送回进入那一端的。
import React from 'react';
import {unstable_renderSubtreeIntoContainer, unmountComponentAtNode}
from 'react-dom';
class Dialog extends React.Component {
render() {
return null;
}
componentDidMount() {
const doc = window.document;
this.node = doc.createElement('div');
doc.body.appendChild(this.node);
this.renderPortal(this.props);
}
componentDidUpdate() {
this.renderPortal(this.props);
}
componentWillUnmount() {
unmountComponentAtNode(this.node);
window.document.body.removeChild(this.node);
}
renderPortal(props) {
unstable_renderSubtreeIntoContainer(
this, //代表当前组件
<div class="dialog">
{props.children}
</div>, // 塞进传送门的JSX
this.node // 传送门另一端的DOM node
);
}
}
render
函数不要返回有意义的 JSX
,也就说说这个组件通过正常生命周期什么都不画,要是画了,那画出来的 HTML/DOM 就直接出现在使用 Dialog 的位置了,这不是我们想要的。componentDidMount
里面,利用原生 API 来在 body
上创建一个 div
,这个 div
的样式绝对不会被其他元素的样式干扰。componentDidMount
还是 componentDidUpdate
,都调用一个 renderPortal
来往“传送门”里塞东西。总结,这个Dialog组件做得事情是这样:
render
返回一个 null
就够了;renderPortal
把要画的东西画在DOM树上另一个角落。特殊字符其实就是在正则表达式中有特殊含义或者功能的字符,它们不能被简单的理解为字面意义。例如 *
的意思是出现零次或者多次。正则表达式中常见的特殊字符有 [ \ ^ $ . | ? * + ( )
(这里重点不是特殊字符,更多的特殊字符及其用法示例可以查看 Regular expression syntax cheatsheet)。
需要注意的是 “/
” 不是特殊字符。
就是把特殊字符变成普通(常规)字符的方法,具体做法是在特殊字符前加上 \
, \
也叫转义字符。
例如:想要通过正则匹配字符串中的 .
,但是 .
在正则表达式中属于特殊字符, 默认含义是匹配除了换行符以外的任意单个字符,所以可以使用 \.
将小数点号变成普通字符意义,例如匹配小数的正则表达式 /^([0-9]{1,}\.[0-9]*)$/
。
// javascript
var myRe = /^([0-9]{1,}\.[0-9]*)$/;
/**
* 题外话:
* - 由于点(.)和星号(*)这样的特殊符号在一个字符集(就是用中括号“[]”括起来)中没有特殊的意义,
* 所以还可以写成 /^([0-9]{1,}[.][0-9]*)$/,这样就不用转义了
* - 但是转义也是起作用的,所以这样写 /^([0-9]{1,}[\.][0-9]*)$/ 效果都一样
*/
myRe.test("42.0"); // true
myRe.test("42"); // false
特殊字符前加上转义字符 \
时,表示该字符不再是特殊字符,而是普通字符。那如果在非特殊字符前加上 \
会怎样呢?
在非特殊字符之前的反斜杠表示下一个字符是特殊字符,不能按照字面理解(emmm,有点“劝失足从良,拉良家下水”的感觉)。例如:正则表达式中 \b
表示匹配一个词的边界,举例
var myRe = /\bm/;
'moon'.match(myRe); // ["m", index: 0, input: "moon", groups: undefined]
上面虽然说 /
不是特殊字符, 但是在 Javascript 中,它表示正则表达式字面量的开始和结束:/...pattern.../
。所以如果想要匹配斜杠符号本身,也需要转义。
"/".match(/\//); // ["/", index: 0, input: "/", groups: undefined]
/* 下面这种写法是无法正常运行的 */
"/".match(///)
另外由于 \
是特殊字符,所以要想匹配字符 \
本身含义,需要转义:
'\\'.match(/\\/); // ["\", index: 0, input: "\", groups: undefined]
// \ 在JavaScript的字符串中也是转义功能,所以也需要转义自身,后面会说
在 JavaScript 中,字符串也有特殊字符,并且在字符串字面量中反斜杠 \
也是转义字符。
var txt="We are the so-called "Vikings" from the north."
// Error: Uncaught SyntaxError: Unexpected identifier
在 JavaScript 中,字符串使用单引号或者双引号来起始或者结束。这意味着上面的字符串将被截为:We are the so-called。
要解决这个问题,就必须把在 "Viking"
中的引号前面加上反斜杠 (\
)。这样就可以把每个双引号转换为字面上的字符。
var txt="We are the so-called \"Vikings\" from the north."
console.log(txt); // "We are the so-called "Vikings" from the north."
/**
* 题外话:
* 如果只是在字符串中显示引号本身,可以使用单双引号嵌套的方式达到效果
*/
var txt='We are the so-called "Vikings" from the north.'
在字符串中的反斜杠 \
也表示转义或者类似 \n
这种只能在字符串中使用的特殊字符。这个引用会“消费”并且解释这些字符,比如:
\n
变成一个换行字符\u1234
变成包含该码位的 Unicode 字符\d
或者 \z
,碰到这种情况的话会把反斜杠移除在 JavaScript 的字符串中要获取
\
字符本身要使用var str = "\\";
这样写var str = "\"
会报错。其实就是需要“转义”。
上面说了 \
在正则表达式中和 JavaScript 的字符串字面量中都是转义字符。
在 JavaScript 中有两种方法创建正则表达式:
var re = /ab+c/;
// 复杂点,包含了转义特殊字符
var myRe = /[a-z]:\\/i
正则表达式字面量中使用转义,直接在特殊字符前加 \
就可以。
RegExp
对象的构造函数RegExp 构造函数接收一个字符串作为参数,这个字符串参数很像正则表达式字面量,但是不能直接将字面量的开关标记 /
替换成字符串的引号,因为正如上面所说:“字符串字面量中反斜杠也是转义字符”。
var regStr = "\d\.\d";
console.log(regStr); // "d.d"
var regexp = new RegExp(regStr);
"Chapter 5.1".match(regexp); // null
通过上面的代码可以知道,字符串引号会消费 \
,所以在给 new RehExp
传递参数的时候,需要双倍反斜杠 \\
,这也就是双重转义的含义。一重字符串转义,一重正则表达式转义。
复杂点的双重转义:
var regexp1 = new RegExp("node_modules[/\\\\]"); // /node_modules[/\\]/
var regexp2 = new RegExp("[a-z]:\\\\","i"); // /[a-z]:\\/i
偏个题,在 ES5 中,RegExp
构造函数的参数有两种情况。
参数是字符串,这时第二个参数表示正则表达式的修饰符(flag)
var regex = new RegExp('xyz', 'i');
// 等价于
var regex = /xyz/i;
参数是一个正则表示式,但是不允许使用第二个参数添加修饰符
var regex = new RegExp(/xyz/i);
// 等价于
var regex = /xyz/i;
// 无法使用第二个参数
var regex = new RegExp(/xyz/, 'i');
// Uncaught TypeError: Cannot supply flags when constructing one RegExp from another
ES6 改变了这种行为。如果RegExp
构造函数第一个参数是一个正则对象,那么可以使用第二个参数指定修饰符。而且,返回的正则表达式会忽略原有的正则表达式的修饰符,只使用新指定的修饰符。
new RegExp(/abc/ig, 'i')
// 等价于 /abc/i
引用 MDN 上的原话
// JavaScript 诞生以来便如此
typeof null === 'object';
在 JavaScript 第一版的实现中,值是以 32 位二进制存储(主要是那时候只有 32 位机),分为两部分:一个表示类型的标签(1 或者 3bits)和实际数据值表示。
类型标签存储在单元的低位,有五个值:
000
:对象。数据是对对象的引用1
:int
。数据是一个 31 位有符号整数010
:double
。 该数据是对双浮点数的引用100
:字符串。数据是对字符串的引用110
:布尔。 数据是布尔值从中可以知道,如果最低位是 1
,那么这个标签就只有 1 位,剩下 31 位都是数据;如果最低位为 0
,那么标签长度为 3 位,高位是给四种类型提供的附加位。例如:表示字符串的标签是 100
,其中最后一个 0
是类型标签,而 10
这两位是附加位。
为什么 typeof
认为 null
是一个对象:它检查了它的类型标签,而类型标签说“object”。
由于 null
代表的是空指针(大多数平台下值为 0x00
),即全为 0
,因此 null
的类型标签是 0
,typeof null
也因此返回 "object"
。
typeof
在 SpiderMonkey 引擎中的实现JS_PUBLIC_API(JSType)
JS_TypeOfValue(JSContext *cx, jsval v)
{
JSType type = JSTYPE_VOID;
JSObject *obj;
JSObjectOps *ops;
JSClass *clasp;
CHECK_REQUEST(cx);
if (JSVAL_IS_VOID(v)) { // (1)
type = JSTYPE_VOID;
} else if (JSVAL_IS_OBJECT(v)) { // (2)
obj = JSVAL_TO_OBJECT(v);
if (obj &&
(ops = obj->map->ops,
ops == &js_ObjectOps
? (clasp = OBJ_GET_CLASS(cx, obj),
clasp->call || clasp == &js_FunctionClass) // (3,4)
: ops->call != 0)) { // (3)
type = JSTYPE_FUNCTION;
} else {
type = JSTYPE_OBJECT;
}
} else if (JSVAL_IS_NUMBER(v)) {
type = JSTYPE_NUMBER;
} else if (JSVAL_IS_STRING(v)) {
type = JSTYPE_STRING;
} else if (JSVAL_IS_BOOLEAN(v)) {
type = JSTYPE_BOOLEAN;
}
return type;
}
v
是否定义。这个方式的实现:#define JSVAL_IS_VOID(v) ((v) == JSVAL_VOID)
[[Class]]
将其标记为一个函数,那么 v
类型是function
v
是一个对象。此处导致typeof null
产生错误的结果null
,这是明显的设计错误(此处没有任何别的意思,而且个人很敬佩作者)。曾有一个 ECMAScript 的修复提案(通过选择性加入的方式),但被拒绝(开发者不想得罪人,因为它会破坏现有的代码)了,该提案试图改成 typeof null === 'null'
。
在一些比较早的 web 页面,检查源代码经常能看到 html 的首行是这样的代码,或者类似代码
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
// 或
<html xmlns="http://www.w3.org/1999/xhtml">
<!DOCTYPE>
DOCTYPE 声明(全称 Document Type Declaration)。通常情况下,DOCTYPE 位于一个 HTML 文档的最开始的位置,位于根元素 HTML 的起始标记之前。因为浏览器必须在解析 HTML 文档正文之前就确定当前文档的类型,以决定其需要采用的渲染模式,不同的渲染模式会影响到浏览器对于 CSS 代码甚至 JavaScript 脚本的解析(尤其是在 IE 系列浏览器中,由 DOCTYPE 所决定的 HTML 页面的渲染模式至关重要)。
<!DOCTYPE>
虽然是标记的写法,但是它不是 HTML 标记,也没有结束标记<!DOCTYPE>
声明必须是 HTML 文档的第一行,位于 <html>
标记之前<!DOCTYPE>
声明,<!DOCTYPE>
声明对大小写不敏感,不过推荐大写早期浏览器对标准的错误实现、私有扩展的大量滋生和为了向前兼容以及早期标准本身的混乱等导致了那时的文档既没有 doctype 也没有对 DTD 的直接引用,也导致了新的标准难以得到应用和普及,因为浏览器无法区分它们。为了处理根据Web标准创作的网页和根据陈旧实践创作的网页,Todd Fahrner 在1998年提出了“came up with a toggle”方法1允许浏览器提供两套渲染模式: 即有完整的doctype的文档使用W3C的标准进行解析,否则使用旧的方式解析。
在 Internet Explorer 5 for Mac 工作的时代,Microsoft 的开发人员在更新浏览器版本时,针对当时的标准文档的支持进行了优化,但是这一优化致使旧的页面无法正确显示。或更确切地说,它们的渲染正确(根据规范),但是人们期望它们的渲染不正确。这些页面本身是根据当时主要的浏览器(主要是Netscape 4和Internet Explorer 4)的怪癖编写的。
微软首先采用了 Todd Fahrner 提出的解决方案。在呈现页面之前先查看“ doctype”,较旧的页面(依赖于较旧的浏览器的渲染功能)通常根本没有doctype,所以就像老式浏览器一样呈现这些页面。而为了“激活”新的标准支持,网页开发者必须通过在<html>
元素之前提供正确的doctype。这就诞生了[doctype嗅探(doctype sniffing或doctype switching)](#Doctype Sniffing)
很快所有主要的浏览器都有了两种模式:“混杂模式”(又叫怪异模式,Quirks mode)和“标准模式”(又叫严格模式,Standards mode 或者 Strict mode),用以把能符合新规范的网站和老旧网站区分开。
后来,Mozilla 发布 Netscape 1.1时,他们无意间破坏了数千页,这些页依赖于一两个特定的怪癖。他们的解决方案便是后来的“几乎标准的模式”。
The HTML specification defines when a document is set to quirks mode, limited-quirks mode or no-quirks mode. [HTML]
目前现代浏览器的排版引擎都包含三种模式:
<!DOCTYPE>
声明的差异Web文档的呈现需要阅读程序通过某种规则解释文档中的标识。阅读程序可以是浏览器或者校验器等。
HTML 4.01 基于SGML, <!DOCTYPE>
声明通过引用 DTD 来告诉阅读程序如何解析标识,而 DTD 规定了标记语言的规则,这样浏览器就能正确地呈现内容。
在 HTML 4.01 中有三种 <!DOCTYPE>
声明类型:Strict、Transitional 以及 Frameset类型
<!-- Strict DTD类型,干净的标记,免于表现层的混乱 -->
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" " http://www.w3.org/TR/html4/strict.dtd">
<!-- Transitional DTD 类型,用户使用了不支持层叠样式表(CSS)的浏览器以至于不得不使用 HTML 的呈现特性时 -->
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" " http://www.w3.org/TR/html4/loose.dtd">
<!-- Frameset DTD类型,除 frameset 元素取代了 body 元素之外,Frameset DTD 等同于 Transitional DTD -->
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Frameset//EN" " http://www.w3.org/TR/html4/frameset.dtd">
HTML5 基于自己的标准,而不是 SGML,所以不需要引用 DTD。但是 HTML5 语法要求声明 DOCTYPE,以确保浏览器以标准模式渲染页面,此外再没有其他用途。
由于遗留原因,HTML5 需要 DOCTYPE。如果省略,浏览器倾向于使用与某些规范不兼容的呈现模式。在文档中包含 DOCTYPE 可确保浏览器尽最大努力遵循相关规范。
在 HTML5 中只有一种 <!DOCTYPE>
声明:
<!DOCTYPE html>
其实 HTML5 还有兼容旧版的 DOCTYPE 声明。
兼容旧版不是指与旧版浏览器的兼容性,因为旧版浏览器实际上忽略了系统标识符和DTD。以下写法是为了与HTML的旧版生产商(即输出HTML文档的软件)兼容,可以使用替代的旧版兼容性DOCTYPE。一般用在希望 DOCTYPE 包含
PUBLIC
或SYSTEM
标识符且无法忽略它们的软件中。<!DOCTYPE html SYSTEM "about:legacy-compat">
除此之外,常见的还有 XHTML、MathMl 及 Svg 等文档类型声明 valid-dtd-list。
IE条件注释是微软从IE5开始就提供的一种非标准逻辑语句,作用是可以灵活的为不同IE版本浏览器导入不同html元素,如:样式表,html标记等
IE 条件注释在非 IE 浏览器中,可能完全被忽略,也可能被解释为普通 HTML 注释。但是在 IE 中(IE5 以上的版本才开始支持IE条件注释,所以只有 IE5 版本以上)它们才会起作用,而这就是 IE 条件注释的作用。所以这也是目前比较合适的在 DOCTYPE 之前写点什么又保证所有浏览器均为标准模式的做法。
<![if !IE]><!-- some comments --><![endif]>
<![if false]><!-- some comments --><![endif]>
或者
<!--[if !IE]>some text<![endif]-->
关键词解释:
lt
:Less than,也就是小于的意思lte
:Less than or equal to,也就是小于或等于的意思gt
:Greater than,也就是大于的意思gte
:Greater than or equal to,也就是大于或等于的意思。!
:逻辑非,就是不等于的意思通常用 IE 条件注释根据浏览器不同载入不同 css,从而解决样式兼容性问题的。其实它可以做的更多。它可以保护任何代码块——HTML代码块、JavaScript 代码块、服务器端代码等等。如下代码只有在大于 IE7 版本的 IE 浏览器中才会弹出弹框并显示内容:
<!--[if gte IE 7]>
<script>
alert("Congratulations! You are running Internet Explorer 7 or a later version of Internet Explorer.");
</script>
<p>Thank you for closing the message box.</p>
<![endif]-->
<!DOCTYPE html>
<!--[if IE 6]><html class="ie ielt9 ielt8 ielt7 ie6" lang="en-US"><![endif]-->
<!--[if IE 7]><html class="ie ielt9 ielt8 ie7" lang="en-US"><![endif]-->
<!--[if IE 8]><html class="ie ielt9 ie8" lang="en-US"><![endif]-->
<!--[if IE 9]><html class="ie ie9" lang="en-US"><![endif]-->
<!--[if (gt IE 9)|!(IE)]><!--><html lang="en-US"><!--<![endif]-->
<head></head>
<body>
文档内容......
</body>
</html>
先判断用户用的哪个 IE 版本,然后在标记上加上该版本的 class,这样可以方便 hack,css 文件则针对不同版本进行编写
.ie6 xxx {};
.ie7 xxx {};
<!DOCTYPE 根元素 可用性 "注册//组织//类型 标记 定义//语言" "URL">
准确来说,对于HTML文档来说,DOCTYPE 、根元素和可用性字段都是大小写不敏感的(也就是除了两个引号内的内容);但是XHTML 是区分大小写的
PUBLIC
可公开访问的对象; SYSTEM
系统资源,如本地文件或 URL。-//W3C//DTD XHTML 1.0 Strict//EN
+
,表示组织名称已注册;-
: 表示组织名称未注册PS: 记住只要紧紧拥抱 HTML5 就行。现代开发没必要去记 HTML 4.01 和 XHTML 的 <!DOCTYPE>
声明,只要知道有这些东西就行,如果真的要用,很多编辑器都有提示或者模版。也可以现查现用常用的 DOCTYPE 声明。
<!DOCTYPE html>
, is the simplest possible, and the one recommended by HTML5. Earlier versions of the HTML standard recommended other variants, but all existing browsers today will use full standards mode for this DOCTYPE, even the dated Internet Explorer 6. There are no valid reasons to use a more complicated DOCTYPE. If you do use another DOCTYPE, you may risk choosing one which triggers almost standards mode or quirks mode.3
Doctype Sniffing (又叫 Doctype Switching)。现代浏览器使用 doctype Sniffing(doctype 嗅探)来确定 text/html
文档的引擎模式。也就是基于HTML文档开头的文档类型声明(或缺少文档类型声明)来选择模式。
值得一提的是 IE8 为了解决向前兼容采用了
X-UA-Compatible
声明,导致在 IE8 中浏览器的渲染模式不仅仅取决于 doctype 嗅探还取决于X-UA-Compatible
声明,这个不仅仅导致了模式判断更加复杂,也违背了web设计的逐渐增强(progressive enhancement)**4
注意:这不适于使用XML文档类型的文档。至于为什么,Addendum: A Plea to Implementors and Spec Writers Working with XML 中有更好的解释。
HTML4.01规范和ISO 8879(SGML)都没有说关于使用文档类型声明作为引擎模式转换的事情。而 Doctype 嗅探出现的背景可见上文"[那时的文档既没有 doctype 也没有对 DTD 的直接引用](#浏览器渲染模式(rendering modes))"
HTML5 并不基于SGML也没有DTD,但它为了向前兼容,接受了doctype嗅探这个事实,定义了在 text/html
中 <!DOCTYPE>
是唯一的且唯一作用也只有模式转换声明,除此外没有什么别的用处。
以下是为新 text/html
文档选择文档类型的简单准则:
标准模式,最新验证
<!DOCTYPE html>
推荐总是使用此文档类型。这样做会让目前还不支持 HTML5 的浏览器也采用标准模式解析,这意味着他们会解析那些 HTML5 中兼容的旧 HTML 标签的部分,而忽略他们不支持的 HTML5 新特性。(可以在支持 HTML5 的浏览器中验证 <video>
,<canvas>
和 ARIA 等新功能)
标准模式,旧版验证目标
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
此文档类型也会触发“标准”模式,但是这是不太精确的旧版验证,因为它不了解一些 HTML5 新功能,也许应该及时更新文档了。
使用“标准”模式,但是在表布局中使用切片图像,并且不想对其进行修复
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
提供了几乎标准模式,如果以后再使用HTML5(并因此采用完整的标准模式),则基于表中切片图像的布局可能会中断,因此最好使现在就与标准模式兼容。
故意要使用 Quirks模式
不写文档类型声明。
如果非要对 IE 旧版本进行兼容,那么最好使用条件注释对旧版本应用特定的hack,而不是在Quirks模式下寻求通用性。
不建议给 text/html
的文档使用任何 XHTML 文档类型
X-UA-Compatible
声明
X-UA-Compatible
属性是 IE 浏览器在 IE8 版本开始提供的一个特性,允许开发者通过在 meta 元素或 HTTP 响应 headers 中包含该属性说明来指定文档模式所有
<meta http-equiv="X-UA-Compatible" content="IE=xx"/>
都可以改为在 HTTP 响应头添加属性值,两者等效
通俗讲,通过 X-UA-Compatible
声明可以实现 IE 浏览器版本模拟。但是只能模拟比当前版本更低的版本,并不能模拟高版本。使用时其必须放在 <head></head>
标记内,并且应该尽可能靠前,除了 TITLE 元素和其它 META 元素外,它应该放在所有其它元素之前。
直接指定 IE 某个版本的标准文档模式
<meta http-equiv="X-UA-Compatible" content="IE=8"/>
<!-- 这在IE7、IE6中无效,因为 X-UA-Compatible 是 IE8 才开始支持的 -->
<meta http-equiv="X-UA-Compatible" content="IE=9"/>
<!-- 这不仅在IE7、IE6中无效,在 IE8 中也无效,因为不能模拟高于当前的版本 -->
指定某个IE版本,但也允许例外
在IE版本号前面加上 Emulate
,代表,如果网页开头有 <!DOCTYPE>
标记就使用该IE版本的标准文档模式,否则使用怪异模式(即等同于 IE=IE5),不建议使用该语法。
<meta http-equiv="X-UA-Compatible" content="IE=EmulateIE8"/>
<!-- 如果网页开头带有 <!DOCTYPE> 标记,则模拟 IE8,网页开头没有 <!DOCTYPE> 标记,则模拟 IE6 -->
使用最新版本文档模式
IE=edge
表示 IE 应该使用其渲染引擎的最新(Edge)版本,即使用其可用的最高模式,如用 IE8 访问就是 IE8 文档模式,用 IE9 访问就是 IE9 模式
<meta http-equiv="X-UA-Compatible" content="IE=edge">
一种特殊的用法
安装了 Google Chrome Frame(谷歌浏览器內嵌框架) 则使用谷歌浏览器内核模式,否则使用最新的IE模式
此项目已于2014年2月25日正式停止维护与更新6。淘汰原因也比较符合认知:“如今,大多数人都在使用支持大多数最新Web技术的现代浏览器。更好的是,旧版浏览器的使用率显着下降,而新版浏览器会自动保持最新状态,这意味着领先优势已成为主流。7”
<meta http-equiv="X-UA-Compatible" content="IE=Edge,chrome=1">
例如我们要往下兼容到 IE8
我们网页应该提前添加好标记:
<meta http-equiv="X-UA-Compatible" content="IE=IE8"/>
然后开发者按 IE8 浏览器文档模式对 HTML/CSS/JS 代码进行兼容性处理,不使用超过 IE8 文档模式的特性
之后,我们就只需要维护一份 IE8 兼容代码,而用户无论是在 IE9 / IE10 / IE11 访问都是如同 IE8 访问一样(要往下兼容到 IE9 或者 IE10 都以此类推)
最后,为 IE8 以下版本添加旧版IE浏览器升级提示,或跳转到IE浏览器升级提示页,如在 X-UA-Compatible 声明代码下添加 IE 条件注释:
<!--[if lte IE 7]><script>window.location.href='http://support.dmeng.net/upgrade-your-browser.html?referrer='+encodeURIComponent(window.location.href);</script><![endif]-->
IE10不支持 IE 条件注释,但
@cc_on
是 IE10 及更旧版IE特有的条件编译语句,因此可以用来判断是否除 IE11 的其他IE版本。所以如果为 IE11 以下版本添加旧版IE浏览器升级提示,或跳转到IE浏览器升级提示页,应该在 X-UA-Compatible 声明代码下添加:
<script>/*@cc_on window.location.href="http://support.dmeng.net/upgrade-your-browser.html?referrer="+encodeURIComponent(window.location.href); @*/</script>
国内很多浏览器其实就是给 Chromium 内核和 IE 内核套层壳,国内大多浏览器都自称为双核引擎(基于 Webkit 的内核用于常用网站的高速浏览,基于 IE 的内核主要用于部分网银、政府、办公系统等网站的正常使用)。用户访问网页时双核浏览器根据网页内容自动选择内核。不过,双核浏览器也提供了类似 X-UA-Compatible 特性的 meta
标记,允许网页开发者通过标记选择内核
使用 Chromium 内核(极速模式)
<meta name="renderer" content="webkit"/>
<!-- 下面代码可以强制令其使用 Chromium 内核(也就是 Webkit 内核)渲染 -->
<meta name="force-rendering" content="webkit"/>
使用 IE 8/9/10/10 内核(IE标准模式,部分支持 HTML5)
<meta name="renderer" content="ie-stand"/>
使用 IE 6/7 内核(IE兼容模式,不支持 HTML5)
<meta name="renderer" content="ie-comp"/>
实际上,由于 IE11 以下版本都已停止更新5,如无特殊情况,无论是从代码工作量还是从用户安全的角度来讲,我们都不应该再兼容 IE11 以下版本。不仅 IE11 对该属性不再重视(甚至已经从明显的地方移除),其特殊用法使用谷歌浏览器内核模式中的谷歌浏览器内核插件也早已停止维护。
在 HTML5 之前,为了获得正确的 doctype 声明,关键就是让dtd与文档所遵循的标准对应。例如,假定文档遵循的是xhtml 1.0 strict标准,文档的doctype声明就应该引用相应的dtd。
HTML5 时代,请使用HTML5文档类型 <!DOCTYPE html>
,该文档类型更短,更甜美,并在所有现代浏览器中触发标准模式。
这一节只是出于历史完整性和对遗留技术的简单记录。可能存在技术的不严谨和历史的错误描述。
The Standard Generalized Markup Language (SGML), is a language for defining markup languages.
标准通用标记语言(SGML)是用于开发标记语言的语言。制定 SGML 的基本**是把文档的内容与样式分开。
一个SGML文件通常分三个层次:结构、内容和样式。结构为组织文档的元素提供框架,内容是信息本身,样式控制内容的显示。
SGML 中定义的每种标记语言都称为 SGML 应用程序。SGML 应用程序通常具有以下特征:
SGML 声明。SGML声明指定哪些字符和分隔符可以出现在应用程序中,例如 SGML HTML 4声明
文档类型定义(Document Type Definition, DTD)。DTD 定义了标记构造的语法,例如 HTML 4.01 Strict DTD
DTD 还可以包括其他定义,比如数字和命名字符实体(常见的有 <
代表符号 <
,>
代表符号>
,又如 å
代表 å,以及 水
表示汉字 “水”等等)
描述归因于 markup
的语义的规范。该规范规定了 DTD 中无法表达的语法限制(相当于 DTD 的补充)
包含数据(内容)和标记的文档实例。其实就是加上标记处理后的文件。
另外,可扩展标记语言(XML)是从 SGML派生的一种简单、灵活的文本格式。
DTD 用来声明该份文件的结构与语法参数,不同的“文件内容”使用不同的“标记”来描述。在这里所谓“标记”(Tag)是指用一特定符号将信息内容中的某一部分加以注记,而此特定符号就称为“标记”。如
<
及>
都是一种标记。当然标记也可以是任何一小段文字。如<NAME>
与</NAME>
,而<NAME>Iamstudent</NAME>
则是一段加上标记的字串。
HTML DTD 中大部分内容都是元素类型及其属性的声明,剩下是 SGML 语法本身的注释和一系列参数实体定义。
下面是 ul
元素类型声明的例子(属性声明等等语法就不再展示)
<!ELEMENT UL - - (LI)+>
<!ELEMENT
关键字开始一个声明,而 >
字符结束它- -
表示此元素类型的开始标记 <UL>
和结束标记 </ UL>
都是必需的(LI)+
声明此元素类型的内容模型为“至少一个LI元素”,这里的 +
与正则里的通配符很像SGML 的语法不是本文的目标。对此感兴趣可以自行查找。
HTML 的某些早期版本(尤其是从HTML2到HTML4)基于 SGML,并使用 SGML 解析规则。但是,很少(如果有的话)Web浏览器对 HTML 文档实施过真正的 SGML 解析。过去,唯一严格将 HTML 作为 SGML 应用程序处理的用户代理就是验证器。由此产生的混乱-验证者声称文档具有一种表示形式,而广泛部署的Web浏览器可互操作地实现另一种表示形式-浪费了数十年的生产力。因此,HTML5 版本的 HTML 返回到非 SGML 基础。
HTML5 是没有对应的 DTD 的。HTML5 的设计者认为 DTD 的表达能力太有限,因此使用 HTML5 验证器(https://validator.nu/ 和w3c的副本https://validator.w3.org/nu/ 以及WHATWG的验证器https://whatwg.org/validator/)的模式进行验证,而不再是基于 DTD 的验证。
而且,HTML5 的设计使其不可能编写 DTD。例如,在 HTML5 中任何以 data-
开头并符合某些通用规则的属性名称都应是有效的,开发者可以写出无数个不同的这样的属性。然而在 SGML 中,要求每条属性都需要单独列出,想要实现 data-
这一特性,那么 DTD 就必须是无限的。这显然是做不到的。
一个 HTML 页面是一系列嵌套的元素。页面的整个结构就像一棵树。最外面的元素是页面上所有其他元素的祖先,被称为“根元素”。HTML 页面的根元素始终为 <html>
。根元素示例:
<html xmlns="http://www.w3.org/1999/xhtml"
lang="en"
xml:lang="en">
这个标记没有错。它是有效的 HTML5。但是 HTML5 中不再需要其中的一部分,因此可以通过删除它们来节省一些字节。
HTML4 及以前版本则都需要按照开头这种格式,没有可以省略属性
xmlns
属性xml namespace 的缩写,XHTML 是 HTML 向 xml 过度的标记语言,它需要符合 xml 文档规则,因此,需要定义命名空间。
当 XHTML 文档中缺少
xmlns
属性时,位于 http://w3.org的HTML验证程序不会发出警告。因为名称空间xmlns = http://www.w3.org/1999/xhtml
是默认命名空间,即使不包含它也将添加到<html>
标记中
上面代码表示此页面中的元素位于 XHTML 命名空间 http://www.w3.org/1999/xhtml
中,但是HTML5 中的元素始终在此命名空间中8。因此不再需要显式声明它,无论此属性是否存在,HTML5 页面在所有浏览器中的工作方式都将完全相同。
删除 xmlns
属性后根元素:
<html lang="en" xml:lang="en">
lang
和 xml:lang
属性这两个属性都是定义当前 HTML 页面的语言。它们的值都是 ISO 639-1
中定义标准两字符语言代码9。例如,英语 English 代码为 en
,即写作 lang=en
。如果希望指定某种语言的方言,可以在语言代码后面紧跟一个破折号和一个子代码名称。例如,简体中文 Chinese (Simplified) 的代码就是 zh-CN
。
有两个语言属性,这也是 XHTML 遗留的问题,xml:lang
属性是 XHTML 中新定义的,不能在HTML中使用,所以 HTML5 中可以直接省略。如果要保留,必须得保证它跟 lang
的属性值相同。
在 XHTML 文档中, lang
属性已从 XHTML 1.1 中删除。但是,XHTML规范建议在 XHTML 1.0 文档的 <html>
元素中同时使用 lang
属性和 xml:lang
属性,以在不同的浏览器之间获得最大的兼容性。如果网页定义为 XHTML1.1 或者XML格式,那么可以使用 xml:lang
属性(因为 xml:lang
属性是在XML中确定语言信息的标准用法)。
lang
属性在 HTML5 中有效,所以 HTML5 最佳的根元素书写如下:<html lang="en">
当 lang
属性用于 <html>
元素中时,它将作用于整个文档;而在用于其他元素中时,它将仅作用于这些元素的内容。如果一个页面用到了多种语言,那么可以给相应的元素类型添加 lang
属性,它会覆盖添加在 <html>
上的 lang
属性值
<p lang="en-GB">This paragraph is defined as British English.</p>
<p lang="fr">Ce paragraphe est défini en français.</p>
Activating Browser Modes with Doctype
X-UA-Compatibility Meta Tag and HTTP Response Header
Why use X-UA-Compatible IE=Edge anymore?
IE 兼容性标记 X-UA-Compatible 解释和用法
首先 localStorage
对象是简单的键值存储,类似于对象:
// 增加一个数据项,以下写法等价,推荐使用 setItem() 方法
localStorage.setItem('myCat', 'Tom');
localStorage.myCat = 'Tom';
localStorage['myCat'] = 'Tom';
// 获取 'myCat' 数据项的值
let cat = localStorage.getItem('myCat');
let cat = localStorage.myCat;
let cat = localStorage.['myCat'];
// 已存储数量
localStorage.length;
// 移除 'myCat'
localStorage.removeItem('myCat');
delete localStorage.myCat;
// 清空所有
localStorage.clear();
/**
* 下面是一些不常见的方法
*/
// localStorage.hasOwnProperty() 检查 localStorage 中存储的数据里是否保存某个值
// 和对象一样,用来检查自身属性中是否具有指定的属性
localStorage.hasOwnProperty('youCat'); // false
// 可以使用 Object.keys(localStorage) 遍历所有键,没啥奇怪的,毕竟 localStorage 对象其实就是 Storage 对象的实例对象。
// localStorage.key(index),这个方法感觉有意思
localStorage.key(2); // "myCat"
setItem()
、getItem()
和 removeItem()
等方法,而不推荐用类对象方式?setItem()
方法会有个小问题,有些键不会生效,例如 length
或 toString
。storage
事件。storage
事件当 localStorage
或 sessionStorage
中的数据更新后,会触发 storage
事件:
window.addEventListener('storage', () => {
if (event.key !== 'now') return;
console.log(`${event.key }:${ event.newValue } at ${ event.url}`);
});
localStorage.setItem('now', Date.now());
容易忽略的点: 如果在当前页面监听了 storage
事件,然后在当前页面修改 loacalStorage
的数据,那么当前页面(除了 iframe 中)是不会触发该事件的。在相同域名下的其他页面(如一个新标签或 iframe)发生的改变才会起作用
存储格式
简单的键值对格式,类似于对象。
存储的键值对采用什么编码
键和值始终采用 UTF-16 DOMString
格式,或者说就是**字符串。**如果键是数字类型,也会自动转为字符串。
值可不可以直接存对象
不可以,目前仅支持字符串,如果要存对象需要使用 JSON.stringify
和 JSON.parse
方法转换。如果直接存一个对象,会被自动转为 "[object Object]"
。
localStorage
最大容量 5M
是什么意思,M
的单位是什么
存储容量限制,最大存储容量为 5M
。不同浏览器可能不同,但是最大也不会超过 5M
。可以使用 Web Storage Support Test 跑下试试。
最大容量 5M
的意思是同源(域名包括子域名、协议、端口必须都要相同)下所有页面使用同一储存区域,且最大容量为 5M
。
这个 5M
是字符串的长度值(就是 length
),或者是 utf-16 编码单元的个数,或者是 10MB(yte)。
为什么是 10MB?
这是因为 UTF-16 编码规则的原因。码点小于 0xffff(65535)
的字符占用两个字节,大于这个码点值的字符则占用四个字节。
那不应该是 5MB ~ 10MB 之间吗?
不,码点大于 0xffff
的字符长度(length
)为 2
,所以就算有非基本平面的字符,1个长度依然对应两个字节。
"a".length // 1
"人".length // 1
"𠮷".length // 2
"😂".length // 2
所以换算成字节数就是 5M * 2B = 10MB
。
储存满了会怎样,如何存储超过 5M
的数据
超出容量会抛出错误 QuotaExceededError
(超出配额),且本次操作不会成功。
首先,尽量不要存储这么大的数据。如果非要存储,且超过了最大容量,可以在当前页面创建一个不同源的 <iframe>
,然后使用 window.postMessage(message, targetOrigin, [transfer])
方法进行跨源通信。
这个方法也可以用来解决跨域共享 localStorage
数据的问题。
键占不占内存
占,容量应该是键值共用的。
键的数量,对读写的性能影响
键的数量对读取性能有影响,但是不大。值的大小对性能影响更大,不建议保存大的数据(纯大数据,可以用 indexedDB
)。
localStorage
如何做到持久化储存的
对于 localStorage 数据的存储,是存在于本地的文件系统中的,也就是存在硬盘里面(存储位置)的,所以关闭浏览器也不会消失。
数据共享
localStorage
和 sessionStorage
中的信息localStorage
sessionStorage
的信息读取是同步还是异步
读取都是同步的。如果存储数据比较大,会有明显的延迟感知。而且可能会阻塞 UI 渲染。
和 sessionStorage
的区别
sessionStorage
数据在页面刷新后仍然保留,但在关闭/重新打开浏览器标签页后不会被保留;而 localStorage
除非人为删除数据,数据不会过期。浏览器重启甚至系统重启后仍然存在。sessionStorage
的数据是不能在不同页面间共享的,即使同源也不行。但是在同一页面下同源的 iframe 可以。和 cookie
比较
SameSite
属性。localStorage
已使用空间function sieOfLS() {
return Object.entries(localStorage).map(v => v.join('')).join('').length;
}
localStorage
。其实就是使用 js 在每次读取数据时判断:/**
* 封装原生 localStorage API, 支持失效时间
*/
class Storage {
// 设置默认失效时间7天
age = 7 * 24 * 60 * 60 * 1000;
/**
* 设置生存周期
* @param {number} age
*/
setAge(age) {
this.age = age;
// 返回this,使其可以链式调用
return this;
}
/**
* 存储数据
* @param {string} key
* @param {*} content
*/
set(key, content) {
// 先清除相同key的数据
// ios 中不能设置已存在的key值,会报错
localStorage.removeItem(key);
// 创建时间
const _time = new Date().getTime();
const _age = this.age;
// 将数据和生存周期当做对象属性一并存储
const value = {};
value._time = _time;
// 失效时间的临界点
value._age = _age + _time;
value._value = content;
// localStorage 存储的只能是字符串
// 使用JSON字符串化数据
localStorage.setItem(key, JSON.stringify(value));
return this;
}
/**
* 判断是否过期
* @param {string} key
*/
isExpire(key) {
let isExpire = true;
let value = localStorage.getItem(key);
const now = new Date().getTime();
// 有数据才判断是否过期
if (value) {
value = JSON.parse(value);
return now > value._age;
}
// 没有数据直接返回false
return isExpire;
}
/**
* 获取存储的数据
* @param {string} key
*/
get(key) {
let value = null;
// 判断是否过期
const isExpire = this.isExpire(key);
// 如果已经过期,返回 null
if (isExpire) {
return value;
}
const content = localStorage.getItem(key);
value = JSON.stringify(content);
return value._value;
}
}
const storage = new Storage();
export default storage;
最近想学习前端爬虫,在使用无头浏览器 Puppeteer 的时候,知道了Puppeteer 是通过 Chrome DevTools Protocol 来控制 Chrome 或 Chromium。
Chrome DevTools Protocol (非官方叫法还有 Remote Debugging Protocol、Chrome Debugging Protocol 其实指的都是该协议),也可以简称为 CDP。该协议允许工具对 Chromium,Chrome 和其它基于 Blink 的浏览器进行测试、检查、调试以及配置。作为前端,对浏览器的开发者工具 Chrome DevTools (开发时总是要打开的那个可以看console、网络和原码的面板)一定不陌生,它就是基于此协议实现的小应用。第三方开发者也允许调用这个协议来与页面进行交互调试
Chrome DevTools Protocol 被分为多个域(DOM,Debugger,Network 等),每个域定义了相应支持的 commands 和相关的 events,commands 和 events 都是固定结构的序列化 JSON 对象。
协议使用 Websocket 来与页面建立通信,相应数据由发送给页面的 commands 和页面自身产生的 events 共同组成。
使用命令行运行 Chrome 浏览器并增加启动参数 --remote-debugging-port
,一般这个端口设置为 9222
# windows
chrome.exe --remote-debugging-port=9222
# linux
google-chrome --remote-debugging-port=9222
# macOS
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222
Tips: macOS 中使用上述方式打开浏览器前,需要完全退出 Chrome 浏览器,否则无法启动监听页面
在浏览器中输入地址 localhost:9222
可以打开下面页面
页面上显示的是当前浏览器打开的所有页面(包括正在运行的 chrome 插件)。点击其中一个页面,就会跳转到这个页面地址(注意是打开新的页面而不是跳转到已经打开的该页面),并且默认打开了开发者工具页面。
当运行浏览器时,如果添加远程调试端口,那么 Chrome 作为一个服务器提供数据,而 Developer Tools front-end 作为数据展示的前端页面。它们之间使用 Websocket 进行链接,并且传递 json 数据。远程调试协议版的 DevTools 和内建的开发者工具功能一致。
以添加远程调试参数的方式运行 Chrome,然后打开 http://localhost:9222/json/
地址,可以看到当前所有页面的描述信息。可以通过该地址对 Chrome 进行远程调试。
list
列举所以标签页 http://localhost:9222/json/list
,其实http://localhost:9222/json/
是 http://localhost:9222/json/list
的省略写法。会返回一系列 json 数据,内容格式如下:
{
"description": 页面描述,基本都为空,
"devtoolsFrontendUrl": 相对应开发者工具页面的 URL,
"id": 页面 id,每个页面唯一,
"title": 页面标题,
"type": 页面类型,最常见的是 page,
"url": 页面 URL,
"webSocketDebuggerUrl": websocket 对应的 URL,
}
new
新标签页,浏览器会创建一个新的 tab 页并将当前页面的信息以 JSON 格式返回
# 打开一个空白页面
http://localhost:9222/json/new
# 打开一个指定 url 的页面
http://localhost:9222/json/new?https://github.com/
close
关闭标签页,通过访问 http://localhost:9222/json/close/(xxxx_id)
的方式,关闭相应 id 所对应的 tab。
activate
激活标签页,使用方法类似 close,可以通过访问 http://localhost:9222/json/activate/(xxxx_id)
的方式来重新启用该页面
version
返回浏览器版本
{
"Browser": "Chrome/88.0.4324.182",
"Protocol-Version": "1.3",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.182 Safari/537.36",
"V8-Version": "8.8.278.17",
"WebKit-Version": "537.36 (@73ee5087001dcef33047c4ed650471b225dd8caf)",
"webSocketDebuggerUrl": "ws://localhost:9222/devtools/browser/ee79aa28-80d2-44fa-a434-04beaaa9c709"
}
协议本身定义了很多内容,但是对于日常开发来说,几乎不会接触到直接使用其协议定义的原始交互功能,而是通过更高级的封装应用来使用。比如 Chrome DevTools 和 Puppeteer,它们都将协议的功能封装成比较高级的 API,方便开发者调用。
过去被叫做data URIs,直到 WHATWG 将其更名为data URL(s)
Data URLs,即前缀为 data:
协议的URL。目的是为了将一些小文件直接嵌入进文档中,而不是通过发起网络请求获取。
不严谨的说就是一种特殊格式的 url,但是 Data URL与传统的url不同:
Data URLs 由四个部分组成:前缀(data:
)、指示数据类型的MIME类型、如果非文本则为可选的 base64
标记、数据本身:
data:[<mediatype>][;base64],<data>
mediatype 是个 MIME 类型的字符串,例如 "image/jpeg
" 表示 JPEG 图像文件。如果被省略,则默认值为 text/plain;charset=US-ASCII
注意,使用 canvas.toDataURL()
得到的 Data URL 默认MIMIE type为"image/png
"。
如果 data 是纯文本,可以简单的嵌入文本,甚至可以省略 MIME type 和 ;base64,data和数据间只有:,
data:,Hello%2C%20World!
当然普通的文本数据也可以嵌入base64 编码的数据
data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==
如果data不是纯文本的话,可以标识为 base64,并且嵌入base64编码的二进制数据。比如图片:
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAADklEQVQYV2NkgAJGGAMAAC0AA03DhRMAAAAASUVORK5CYII=
,
是一定存在的,无论有没有 base64
选项。前面说了 Data URL 是为了小文件嵌入文档而产生的。虽然可以支持无限长度,但是支不支持无限长度取决于浏览器厂商。而且太长的 Data URL 对于代码的格式化,显示都不友好。
如果图片较大,图片的色彩层次比较丰富,则不适合使用这种方式,因为其base64编码后的字符串非常大,会明显增大HTML页面,影响加载速度
MIME 类型错误或者 base64 编码错误,都会造成 data URL 无法被正常解析,但不会有任何相关错误提示
一个data URL的数据字段是没有结束标记的,所以尝试在一个data URI后面添加查询字符串会导致,查询字符串也一并被当作数据字段
data:text/html,lots of text...<p><a name%3D"bottom">bottom</a>?arg=val
?arg=val
会被当做数据的一部分,从而造成数据无法正确解析。
<data>
由于 base64 仅仅是通过ASCII字符组成,所以 base64 字符串是 **url-safe ** 的,因此才将base64应用于data URL的<data>
中。
base64编码本质上是一种【将二进制数据转成文本数据】的编码规则。对于非二进制数据,是先将其转换成二进制形式,然后每连续6比特(2的6次方=64)计算其十进制值,根据该值在base64索引表中找到对应的字符,最终得到一个只包含可打印ASCII字符的文本字符串。
早期的一些传输协议,例如邮件传输协议SMTP,只能传输可打印的ASCII字符。那么那些控制字符怎么传输就成了问题,比如图片二进制流的每个字节不可能全部是可见字符,所以就传送不了。
怎么办?【把不可打印的字符也能用可打印字符来表示,问题就解决了】。所以 base64 编码应运而生。
标准base64协议规定 base64 包含 64 个ASCII可打印字符(A-Z、a-z、0-9、+、/),所以叫 base64。(可打印字符有 95个,为什么只选 64 个我也不知道)。
ASCII 码的范围是0-127,其中0-31和127是控制字符,共33个。其余95个,即32-126是可打印字符
下面是 base64 的索引表,数值代表字符的索引(对应 2的6次方),所以 base64 是将每连续 6bit 表示一个字符。
假设我们要对 Hello!
进行base64编码,按照ASCII表,其转换过程如下图所示:
原始字符串长度为6个字符,编码后长度为8个字符,每3个原始字符经base64编码成4个字符,也就是编码后比源码多了1/3。
编码前后长度比4/3,这个长度比很重要:
比原始字符串长度短,则需要使用更大的编码字符集,这并不我们想要的
长度比越大,则需要传输越多的字符,传输时间越长
base64应用广泛的原因是在字符集大小与长度比之间取得一个较好的平衡,适用于各种场景。
base64编码是每3个原始字符编码成4个字符,如果原始字符串长度不能被3整除,那怎么办?使用0值来补充原始字符串。
以 Hello!!
为例,其转换过程为:
注:图表中蓝色背景的二进制0值是额外补充的。
最后2个零值只是为了base64 编码而补充的,在原始字符中并没有对应的字符,那么base64编码结果中的最后两个字符 AA
实际不带有效信息,所以需要特殊处理,以免解码错误。
标准base64编码通常用 =
字符来替换结尾处的 A
,即编码结果为 SGVsbG8hIQ==
。因为 =
字符并不在base64编码索引表中,其意义在于结束符号,在base64解码时遇到 =
时即可知道一个base64编码字符串结束。【重要】
如果base64编码字符串不会相互拼接再传输,那么最后的 =
也可以省略,解码时如果发现base64编码字符串长度不能被4整除,则先补充 =
字符,再解码即可。
解码是对编码的逆向操作,但注意一点:对于最后的两个 =
字符,转换成两个 A
字符,再转成对应的两个6比特二进制0值,接着转成原始字符之前,需要将最后的两个6比特二进制0值丢弃,因为它们实际上不携带有效信息。
在 JavaScript 中,有两个函数被分别用来处理解码和编码 base64 字符串:
atob()
解码,ascii to binarybtoa()
编码,binary to asciilet encodedData = window.btoa("Hello, world"); // 编码
let decodedData = window.atob(encodedData); // 解码
大多数的编码都是由字符转化成二进制的过程,而从二进制转成字符的过程称为解码。而base64的概念就恰好反了,由二进制转到字符称为编码,由字符到二进制称为解码。
由于 DOMString
是16位编码的字符串,所以如果有字符超出了8位ASCII编码的字符范围时,在大多数的浏览器中对Unicode字符串调用 window.btoa
将会造成一个 Character Out Of Range
的异常。
扩展:百分号编码
**方法一:**在编码前对数据进行转译:
function b64EncodeUnicode(str) {
/**
* - 通过 encodeURIComponent 会将目标字符串转为 percent-encoded 的字符串
* 例如 encodeURIComponent('你') 结果为 '%E4%BD%A0'
* - 然后将百分比编码里的 % 都替换成 0x (十六进制前缀)
* 现在得到三个十六进制表示的ASCII码点(0x00 ~ 0xFF) 0xE4 0xBD 0xA0
* - String.fromCharCode() 方法返回ASCII字符
*
* btoa() 现在可以对0x00 ~ 0xFF范围内的二进制数据进行编码了
*/
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
(match, p1) => {
return String.fromCharCode(`0x${ p1}`);
}));
}
百分号编码:“%” 后跟替换字符的ASCII的十六进制表示
解码基本思路就是编码的逆转
atob()
方法将ASCII字符转为二进制,获取一串用十六进制表示的二进制字符串decodeURIComponent()
方法对 URI 字符串进行解码。function b64DecodeUnicode(str) {
return decodeURIComponent(atob(str).split('').map((c) => {
return `%${(`00${c.charCodeAt(0).toString(16)}`).slice(-2)}`;
}).join(''));
}
charCodeAt()
方法返回 0
到 65535
之间的整数,表示给定索引处字符的 UTF-16 代码。
方法二: 用JavaScript的 TypedArray 和 UTF-8重写DOM的 atob()
和 btoa()
。思路就是手动构建base64字符集索引表,利用
Uint8Array
(8-bit unsigned integer,0
to 255
)创建类型数组视图,然后按位运算等等。具体代码实现查看。
**方案三:**最简单,最轻量级的解决方法就是使用 TextEncoderLite 和 base64-js
二进制大型对象(英语:binary large object ,或英语:basic large object,缩写为Blob、BLOB、BLOb),在数据库管理系统中,将二进制资料存储为一个单一个体的集合。Blob通常是影像、声音或多媒体文件。——维基百科
Blob
对象表示一个不可变、原始数据的类文件对象。Blob
由一个可选的字符串 type
(通常是 MIME 类型)和 blobParts
组成。
使用语法:
new Blob(blobParts, options);
blobParts
是 Blob
/BufferSource
/String
类型的值的数组options
可选对象:
type
—— Blob
类型,通常是 MIME 类型,例如 image/png
,endings
—— 是否转换换行符,使 Blob
对应于当前操作系统的换行符(\r\n
或 \n
)。默认为 "transparent"
(啥也不做),不过也可以是 "native"
(转换)创建 blob 的几个示例:
// 从字符串创建 Blob
let blob = new Blob(["<html>…</html>"], {type: 'text/html'});
// 从类型化数组(typed array)和字符串创建 Blob
let hello = new Uint8Array([72, 101, 108, 108, 111]); // 二进制格式的 "hello"
let blob = new Blob([hello, ' ', 'world'], {type: 'text/plain'});
// 创建JSON文件的 Blob
let data = { "name": "abc" };
let blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
PS: 如果上传或下载 Blob 对象时,不知道数据的类型时,可以设置
type
为application/octet-stream
。
在网络请求中,type
自然地变成了 Content-Type
。
Blob
对象是不可改变的Blob
中的数据是无法直接更改的,但可以通过 slice
获得 Blob
的多个部分,从这些部分创建新的 Blob
对象,将它们组成新的 Blob
。
这种行为类似于 JavaScript 字符串:无法更改字符串中的字符,但可以生成一个新的改动过的字符串。
blob.slice([byteStart], [byteEnd], [contentType]);
byteStart
—— 起始字节,默认为 0。byteEnd
—— 最后一个字节(专有,默认为最后)contentType
—— 新 blob 的 type
,默认与源 blob 相同参数值类似于 array.slice
,也允许是负数。
File 接口基于Blob
,继承了 blob 的功能并将其扩展使其支持用户系统上的文件。
所以 File 也有一个构造器:
new File(fileParts, fileName, [options])
fileParts
—— Blob/BufferSource/String 类型值的数组fileName
—— 文件名字符串options
—— 可选对象:
lastModified
—— 最后一次修改的时间戳(整数日期)File 对象还可以是来自用户在一个 <input>
元素上选择文件后返回的 FileList 对象,也可以来自拖放操作生成的 DataTransfer 对象,还可以是来自在一个HTMLCanvasElement
上执行 mozGetAsFile()
方法后返回结果。
File 对象的应用“分片上传文件”示例:
function uploadFile(file) {
let blob;
const chunkSize = 1024 * 1024; // 每片1M大小
const totalSize = file.size;
const chunkQuantity = Math.ceil(totalSize / chunkSize); // 分片总数
let offset = 0; // 偏移量
const reader = new FileReader();
reader.onload = function (e) {
const xhr = new XMLHttpRequest();
xhr.open('POST', `http://xxxx/upload?fileName=${ file.name}`);
xhr.overrideMimeType('application/octet-stream');
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
++offset;
if (offset === chunkQuantity) {
alert('上传完成');
} else if (offset === chunkQuantity - 1) {
blob = file.slice(offset * chunkSize, totalSize); // 上传最后一片
reader.readAsBinaryString(blob);
} else {
blob = file.slice(offset * chunkSize, (offset + 1) * chunkSize);
reader.readAsBinaryString(blob);
}
} else {
alert('上传出错');
}
};
if (xhr.sendAsBinary) {
xhr.sendAsBinary(e.target.result); // e.target.result是此次读取的分片二进制数据
} else {
xhr.send(e.target.result);
}
};
blob = file.slice(0, chunkSize);
reader.readAsBinaryString(blob);
}
FileReader
对象FileReader
对象,其唯一目的是从 Blob
(或 File
)对象中读取数据。
const reader = new FileReader(); // 没有参数
它使用事件来传递数据。之所以称它为 “转换通道”,是因为通过FileReader
对象的 read*
方法读取 Blob 数据,可以直接得到想要的数据类型:
主要读取方法:
readAsArrayBuffer(blob)
—— 转换为 ArrayBuffer。用于二进制文件,执行低级别的二进制操作(对于诸如切片之类的高级别的操作,File
是继承自 Blob
的,可以直接调用它们,所以不需要使用该方法再读取)。readAsText(blob, [encoding])
—— 将数据读取为给定编码(默认为 utf-8
编码)的文本字符串。readAsDataURL(blob)
—— 读取二进制数据,并将其编码为 base64 的 data url。// 一个最简单的示例
function readFile(input) {
let file = input.files[0];
let reader = new FileReader();
reader.onload = function() {
// reader.result 是结果(如果成功)
console.log(reader.result);
};
reader.onerror = function() {
// reader.error 是 error(如果失败)
console.log(reader.error);
};
reader.readAsText(file);
}
但是,在很多情况下,我们不必读取文件内容。可以使用 URL.createObjectURL(file)
创建一个短的 url,并将其赋给 <a>
或 <img>
。这样,文件便可以下载文件或者将其呈现为图像,作为 canvas 等的一部分。
另外,下面两种方法可以异步读取 Blob 数据,返回 Promise。功能上等同于 FileReader.readAsText()
方法,但略有些差异:
const text = await (new Response(blob)).text();
// 或者
const text = await blob.text();
// 和 FileReader.readAsText() 差异的地方:Blob.text() always uses UTF-8 as encoding, while FileReader.readAsText() can use a different encoding depending on the blob's type and a specified encoding name
URL.createObjectURL
为 Blob 创建 URL
URL.createObjectURL()
静态方法会创建一个 DOMString,其中包含一个表示参数中给出的对象的 URL。这个 URL 的生命周期和创建它的窗口中的document
绑定。这个新的URL 对象表示指定的 File 对象或 Blob 对象。—— MDN
URL.createObjectURL(blob)
方法参数传入一个 Blob
,并为其创建一个唯一的 URL,形式为 blob:<origin>/<uuid>
。例如:
blob:https://developer.mozilla.org/2e07b873-109d-4781-a321-6fbff8ce89d4
Blob URL 和 Data URL 一样,都可以直接在浏览器中输入,但是 Blob URL 是“临时”的,当创建它的文档被刷新或者关闭后,这个 URL 也就不存在了;但是 Data URL 不同,可以理解为自带内容(数据)😂。
这个方法很实用,在点击下载动态获取的文件的时候经常这样实现代码:
let link = document.createElement('a');
link.download = 'hello.txt';
let blob = new Blob(【请求到的blob数据】, {type: 'xxxx'});
link.href = URL.createObjectURL(blob);
link.click();
URL.revokeObjectURL(link.href);
先说结论:如果我们创建一个 URL,那么即使我们不再需要该 Blob
了,它也会被挂在内存中。
URL.createObjectURL
生成的 URL 存储了一个 URL → Blob
映射。因此,此类 URL 很短,但可以访问 Blob
。<img>
、<a>
中的 Blob
,以及基本上任何其他期望 URL 的对象。Blob
的映射,但 Blob
本身只保存在内存中,浏览器无法释放它。Blob
也相应被释放了。但是,如果应用程序寿命很长,那这个释放就不会很快发生(可能的内存泄漏)。比较好的习惯是,手动释放引用。通过 URL.revokeObjectURL(url)
方法从内部映射中移除引用,因此允许 Blob
被删除(如果没有其他引用的话),并释放内存。
通过 canvas 绘制图像,然后使用 toBlob()
方法导出为 Blob 数据:
let img = /* someone img */
let canvas = document.createElement('canvas');
let context = canvas.getContext('2d');
context.drawImage(img, 0, 0);
// toBlob 是异步操作,结束后会调用 callback
canvas.toBlob((blob) => {
// ...
}, 'image/png');
这个方法可以用在前端生成屏幕快照(截屏)的实现上,或者前端实现 svg 转 png 等等。
现在已经深入了解了 Blob 和 base64,也要回归正题了。
这个最简单,通过 Filereader 对象的 readAsDataURL
方法可以直接转换:
function blobToDataURL(blob, callback) {
const reader = new FileReader();
reader.onload = () => {
callback(reader.result);
};
reader.readAsDataURL(blob);
}
演示:
const blob = new Blob(['hello world!'], {type: 'text/plain'});
blobToDataURL(blob, (result) => console.log(result));
// data:text/plain;base64,aGVsbG8gd29ybGQh
function dataURLtoBlob(dataurl) {
const arr = dataurl.split(',');
const mime = arr[0].match(/:(.*?);/)[1];
const bstr = window.atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
}
演示:
await dataURLtoBlob('data:text/plain;base64,aGVsbG8gd29ybGQh').text()
// 'hello world!'
最近遇到一个业务场景,需要在 H5 页面中调用手机摄像头。思来想去没有头绪(基础知识不扎实啊)。后来看到 <input>
可以实现。学习了。
type="file"
<input>
元素有个选择文件的功能,它会打开系统的文件选择器:
<input type="file" />
但是如果只是一个 type="file"
属性还不足以让手机打开相机功能,还要设置 input 接受的文件类型。
accept
属性如果 accept
属性指出了 input 是图片或者视频类型,那么移动端在上传文件的时候,操作系统会让用户做出选择,通过拍照或者通过选择系统文件来实现上传。使用下面代码就可以实现:
<!-- 在Android和IOS中均会先显示打开相机还是打开系统文件 -->
<input type="file" accept="image/*" />
capture
属性如果只是想要打开相机,不需要打开系统文件选择的话,可以设置 capture 属性为一个字符串。(测试 IOS14 和 Android10 自带 browser 均只打开相机)
<input type="file" accept="image/*" capture="camera" />
Note these work better on mobile devices; if your device is a desktop computer, you'll likely get a typical file picker.
从 caniuse 中可以看到,web 端全部阵亡,移动端基本支持:
实际上新的 capture 属性只有两个值:
user
表示应该使用前置摄像头和/或麦克风environment
表示应该使用后置摄像头和/或麦克风缺少此属性,则 user agent 可以自由决定做什么。如果请求的前置模式不可用,则用户代理可能退回到其首选的默认模式。
如果是其它非空字符串值(例如capture="camera"),会当做布尔值 true
处理,则该布尔属性要求使用设备的媒体捕获设备(例如相机或麦克风)而不是请求文件输入。
默认样式多少有点不满足我们的业务需求,所以需要对控件进行样式修改。
优化方法有很多,这里只用最简单的方法:通过外层嵌套一个容器,将想要的样式和内容放在这个容器,然后将 input[type=file]
的元素通过 opacity:0
隐藏掉即可。opacity:0
隐藏的好处是元素依然存在DOM树上,且点击事件正常。
<span class="box">打开相机
<input class="input" id="camera" type="file" accept="image/*" />
</span>
CSS样式修改
.box {
position: relative;
display: inline-block;
overflow: hidden;
background: lightblue;
color: darkred;
padding: 20px;
border-radius: 4px;
margin: 4px;
font-size: 16px;
font-weight: 600;
}
.box .input {
position: absolute;
right: 0;
top: 0;
opacity: 0;
filter: alpha(opacity=0);/兼容ie/
cursor: pointer;
height: 100%;
}
最终效果:
重点是 CSS 样式,和 React 无关,只是方便渲染
结构
const mockData = [
{name: '1号窗口', value: '001'},
{name: '11号窗口', value: '003'},
{name: '91号窗口', value: '——'},
{name: '471号窗口', value: '178'},
{name: '33号窗口', value: '999'},
{name: '2号窗口', value: '061'},
];
// ...
function App() {
// ...
return (
<div className="box">
{
mockData.map(item => (
<div key={item.name} className="item">
<div className="name">{item.name}: </div>
<div className="value">{`${item.value}号客户`}</div>
</div>
))
}
</div>
);
}
样式
.item {
display: table-row;
}
.name {
display: table-cell;
text-align: right;
}
.value {
display: table-cell;
}
很基础的实现,但是效果很好,代码也很简单。
需要注意的是要 显式 的给类名为 item
的元素设置 display: table-row;
。否 text-align: right;
不生效。
在 ESLint 中有一条规则 no-return-await
。下面的代码会触发该条规则校验不通过:
async function foo() {
return await bar();
}
这条规则的解释是,async function
的返回值总是封装在 Promise.resolve
中,return await
实际上并没有做任何事情,只是在 Promise resolve 或 reject 之前增加了额外的时间。
Promise.resolve()
了解 Promise 对象的知道 Promise.resolve()
作用是将给定的参数转换为 Promise 对象
Promise.resolve('foo')
// 等价于
new Promise(resolve => resolve('foo'))
那么其实上面的问题所在就是 Promise.resolve()
的参数情况:
下面截取自 《ECMAScript 6 入门》的
Promise.resolve()
Promise 实例
如果参数是一个 Promise 实例,Promise.resolve
将不做任何修改、原封不动地返回这个实例。
thenable
对象
thenable
对象指的是具有then
方法的对象
let thenable = {
then: function(resolve, reject) {
resolve(42);
}
};
Promise.resolve()
方法会将这个对象转为 Promise 对象,然后就立即执行thenable
对象的then()
方法
let thenable = {
then: function(resolve, reject) {
resolve(42);
}
};
let p1 = Promise.resolve(thenable);
p1.then(function (value) {
console.log(value); // 42
});
无 then()
方法的对象,或基础类型值
如果参数是一个原始值,或者是一个不具有then()
方法的对象,则Promise.resolve()
方法返回一个新的 Promise 对象,状态为resolved
。
const p = Promise.resolve('Hello');
p.then(function (s) {
console.log(s)
});
// Hello
由于字符串Hello
不属于异步操作(判断方法是字符串对象不具有 then 方法),返回 Promise 实例的状态从一生成就是resolved
,所以回调函数会立即执行。
无参数
Promise.resolve()
方法允许调用时不带参数,直接返回一个resolved
状态的 Promise 对象。
Promise.resolve()
// Promise {<fulfilled>: undefined}
函数 foo()
是一个 async function
,会返回一个 Promise 对象,这个 Promise 对象其实是将函数内 return
语句后面的值使用 Promise.resolve
封装后返回。所以,执行 foo()
之后,我们明确知道得到的结果是一个 Promise 对象,并且在 foo()
的外部我们还会以某种方式比如await
来继续使用这个结果。
另外,我们知道 await
命令后面如果是一个 Promise 对象,则返回该对象的结果。如果不是 Promise 对象,就直接返回对应的值。所以函数内 return
后的 await
的作用就是获取 bar()
的执行结果。
如此一来,async function
中使用 return await
相当于先获取结果然后又将结果变成 Promise 实例。
无论 return await
后面跟的是不是 Promise 对象,那么我们在函数外面都会得到一个 Promise 对象,所以函数内 return
后面的 await
就显得多余了。
虽然上面的写法不推荐使用,但是并不会产生运行错误,只是会造成性能上的损失。唯一影响,可能是其他开发者看到后,会笑话你基础不扎实吧。
首先,如果 async function
没有 return
语句的话,默认返回 undefined
,在 Devtool 中测试如下:
async function foo() {}
foo();
// Promise {<fulfilled>: undefined}
// [[PromiseState]]: "fulfilled"
// [[PromiseResult]]: undefined
既然 return await
中的 await
是多余的,那么最直接的推荐写法就是去掉 await
:
async function foo() {
return bar();
}
无论 bar()
执行结果是一个 Promise 还是一个普通值,我们将它交给 foo
函数外面的程序去处理。
如果非要在 foo
函数中求得 bar()
的执行结果,那么也可以像下面这样写:
async function foo() {
const x = await bar();
return x;
}
但是我觉得这完全只是为了规避 ESLint 的报错而写的一种障眼法,实际它和 return await bar()
一样没有任何好处。
return await promiseValue
与 return promiseValue
的比较返回值隐式的传递给 Promise.resolve
,并不意味着 return await promiseValue;
和 return promiseValue;
在功能上相同。return foo;
和 return await foo;
,有一些细微的差异:
return foo;
不管 foo
是 promise 还是 rejects 都将会直接返回 foo
foo
是一个 Promise
,return await foo;
将等待 foo
执行(resolve)或拒绝(reject),如果是拒绝,将会在返回前抛出异常看下面的代码:
function bar() {
throw new Error('报错了!');
}
async function foo() {
try {
return await bar();
} catch (error) {
console.log('知道了');
}
}
foo();
// 知道了
// Promise {<fulfilled>: undefined}
async function foo2() {
try {
return bar();
} catch (error) {
console.log('我不知道');
}
}
foo2();
// undefined
如果想要在调试的堆栈中得到 bar()
抛出的错误信息,那么此时应该使用 return await
。
开发过程中要求代码风格统一,这个统一又可以分成很多方面的统一,经常提到的有代码语法的风格和代码书写的风格。
在一个项目开始之前,一般会有架构师制定相关的规则(或者指定已有的规则文件),然后发出一份代码规范的说明,用来约束开发人员的代码风格,减少因为代码风格不同而带来的 differ。这样的规范有很多,例如:
但是规范是给人看的,如果团队的 review 不是那么严格,那么规范的约束力就会大打折扣,所以就有了各种代码检查器对开发人员进行硬核约束。
现在检查器有很多,JavaScript 最早的语法检查器可能是 Douglas Crockford 的 JSLint ,之后出现了基于 JSLint 的代码实现的开源项目 JSHint,再后来就是我现在项目使用的由 Nicholas C. Zakas 开发的 Eslint。当然除了 JavaScript 的代码检查工具,一个项目还会配置如 stylelint 这样的样式检查工具和 Bootlint、AriaLinter、htmllint、HTMLHint 及 htmlcs 等 html 代码检查工具。
代码检查工具的作用是强制执行约定好的规则和帮助开发人员避免错误语法,但是偏偏就会有些开发人员为了方便而绕开规则(我身边有,不针对任何人),再加上如果团队 review 的观念不足,那么就拿我使用的 eslint 和 stylelint 来说,配置文件中总会出现一些关闭规则的配置项,甚至在代码文件中出现 disable rule 的注释(我也干过)。这样 lint 的工具
除了代码语法风格,还有一个重要的统一就是代码书写风格的统一。说两个老掉牙的例子:圣战之花括号换行还是不换行和写不写分号。
而 editorConfig 可以帮助开发人员在不同的编辑器和 IDE 中定义和维护一致的编码风格。
首先,editorConfig 不是软件,准确来说 EditorConfig 其实就是针对编辑器的一些配置项,再笼统点说就是让编辑器遵循某种风格的工具。一般 EditorConfig 项目包括一个用于定义编码格式的配置文件 .editorconfig
,还有一个能读懂这个文件的插件(可以去官网查看需要插件的编辑器),当然有些编辑器是默认支持 EditorConfig 的,例如 WebStorm。
.editorconfig
文件,直到找到一个有 root=true
的配置文件或者到达根目录才会停止搜索.editorconfig
文件,或者没有配置文件,则使用编辑器默认的设置在 Windows 系统中,创建配置文件时应该命名为
.editorconfig.
,系统会自动重命名为.editorconfig
.editorconfig
文件使用INI 格式(以简单的文字与简单的结构组成)并且兼容Python ConfigParser
;
或 #
,直达行尾都会被注释,注释只能注释一行[]
括起来的内容称为节,节的写法允许使用路径匹配,就像 .gitignore
文件中一样。但是只能使用 /
符号作为路径分隔符UTF-8
编码,并且每行的分隔符是 CRLF
或者 LF
* 匹配除 / 之外的任意字符串
** 匹配任意字符串
? 匹配任意单个字符
[name] 匹配 name 中的任意一个单一字符
[!name] 匹配不存在 name 中的任意一个单一字符
{s1,s2,s3} 匹配给定的字符串中的任意一个(用逗号分隔)
{num1..num2} 匹配 num1 到 num2 之间的任意一个整数, 这里的 num1 和num2 可以为正整数也可以为负整数
indent_style 设置缩进风格(tab是硬缩进,space为软缩进)
indent_size 用一个整数定义的列数来设置缩进的宽度,如果indent_style为tab,则此属性默认为tab_width
tab_width 用一个整数来设置tab缩进的列数。默认是indent_size
end_of_line 设置换行符,值为lf、cr和crlf
charset 设置编码,值为latin1、utf-8、utf-8-bom、utf-16be和utf-16le,不建议使用utf-8-bom
trim_trailing_whitespace 设为true表示会去除换行行首的任意空白字符。
insert_final_newline 设为true表示使文件以一个空白行结尾
root 表示是最顶层的配置文件,发现设为true时,才会停止查找.editorconfig文件
unset
(会使用编辑器默认属性)# editorconfig.org
root = true
[react/*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
end_of_line = lf
# editorconfig-tools is unable to ignore longs strings or urls
max_line_length = unset
在叫 “Mac OS” 之前,Macintosh 操作系统称为 “System 7”
“Mac OS 8” 原本代号为 “Copland”,该版本具有所有新功能的新操作系统,但项目被取消
“System 7.7” 更名为 “Mac OS 8”,目的是为了让 Apple 脱离定于版本8到期的第三方协议
上世纪90年代末,苹果公司宣布对Macintosh进行基于NextSTEP的重大修改时,选择将新系统标记为 “Mac OS X”,其中**“X”为罗马数字10**
Mac OS X 的第一个版本具有数字版本号:“10.0.0”,并对其进行了四次“更新”直到“10.0.4”,然后对其进行了第一个主要的“10.1 升级”
Apple 认为 “Mac OS X”已经是一个品牌代名词,并且坚持使用“10”为系统版本号
2002年WWDC上宣布“ Mac OS X 10.2 Jaguar”
2012年,苹果从 “ OS X Mountain Lion”(10.8)的操作系统中删除了“ Mac”
2016年,苹果放弃了 “ X-as-ten” ,并且重返 “Mac”,新的版本代号 “ macOS Sierra”(10.12)
之后的系统名称中不再包含 “ X”,但是在 macOS 10.15 Catalina 之前的版本号中仍保留“ 10”
2020年,苹果终于放弃了“10”,并将macOS的主要版本提高到 11,代号 “Big Sur”
通过命令行也可以看到相同结果:
➜ sw_vers -productVersion 11.1
但是有一个陷阱!
在 Big Sur 中,如果查询 macOS 版本,返回的不总是 11,在某些情况下会显示 10.16。
Apple提供了两个基本规则:
在编译语言中,macOS 返回的版本取决于软件所针对的 SDK
在Shell环境中运行的脚本语言中,有一个环境变量控制给定的版本号
SYSTEM_VERSION_COMPAT = 1
,Big Sur返回10.16SYSTEM_VERSION_COMPAT = 0
,则返回11.0➜ export SYSTEM_VERSION_COMPAT=1
➜ sw_vers -productVersion
10.16
有些软件可能只检查版本小数点后面的数字,这就造成软件无法正常启动,就像下面的情况:
macOS 10.15 及以前要查看macOS是Mojave还是更高版本,可以安全地忽略产品版本的第一个数字,而仅比较第二个数字。所以可能会执行以下操作:
# .test
minorVersion=$(sw_vers -productVersion | awk -F. '{ print $2; }')
if [[ $minorVersion -ge 14 ]]; then
echo "Mojave or higher"
fi
➜ sh .test
# 10.15 版本以前
Mojave or higher
# Big Sur 版本则无任何输出
在macOS 11.0发行版中,minorVersion 返回的是 0,条件不成立,所以没有输出。
解决方案是首先提取 majorVersion 并进行比较:
# .test
majorVersion=$(sw_vers -productVersion | awk -F. '{ print $1; }')
minorVersion=$(sw_vers -productVersion | awk -F. '{ print $2; }')
if [[ $majorVersion -ge 11 || $minorVersion -ge 14 ]]; then
echo "Mojave or higher"
fi
上面的检查脚本也有问题,对于比 Big Sur 更高版本的时候,上面的条件显然不足以判断,就要加更多的条件语句进行区分。
那么还有什么更好的方法进行版本比较?
用户可见的版本可能不够细致,无法满足开发人员的需求。因此 macOS 提供了另一个版本,称为“内部版本”
# 此时版本为 macOS Big Sur
➜ sw_vers -buildVersion
20C69
在下面的界面,默认内部版本号是隐藏的,点击版本号才会显示出内部版本
达尔文版本是在主要 macOS 版本上递增的数字
Mac OS X 10.2 Jaguar 是 Mac OS X 的第一个发行版,该版本的达尔文版本始终为 6
为什么第一个办版本从 6 开始?
因为 10.0 是 Mac OS X 所基于的 NextSTEP 的第四版,那么 10.0 的达尔文版本号实际应该是 4,所以 10.2 版本开始出现的达尔文版就从 6 开始了。
macOS 10.15 Catalina,具有 19 和 Big Sur 20 这两个Darwin版本。
可以从OSTYPE环境变量获取Shell中的Darwin版本:
➜ echo $OSTYPE darwin20.0
但是可能未设置环境变量,所以获取Darwin版本的更安全方法是使用 uname 命令:
➜ uname -r 20.2.0
字母 A
代表 .0
或主要版本的第一个发行版。B表示第二个版本或第一个更新或 .1
版本,依此类推。
内部版本号的重要性通常是在beta阶段看到的
Beta版本中的Darwin版本和更新字母是固定的
内部版本号随每个Beta版本的增加而增加
通常来说,两位数和三位数的内部版本号与四位数的内部版本号有所不同。
某些版本的macOS的内部版本号后面带有小写字母,尚不清楚这个字母的确切含义,猜测可能表明安装程序应用程序已被重建一次或多次。
对于大多数比较,我们只需要Darwin版本或更新即可:
# check if Mojave or higher
if [[ $(sw_vers -buildVersion) > "18" ]]; then
...
由于Mojave的所有版本18A...均以字母开头,因此均大于18。如果要检查macOS的最高版本,将采取同样的措施:
# check if Mojave or earlier
if [[ $(sw_vers -buildVersion) < "19" ]]; then
...
还可以过滤特定的最小更新:
# check if Mojave 10.14.6 or later
if [[ $(sw_vers -buildVersion) > "18E" ]]; then
...
macOS 11 Big Sur中版本号的更改可能会影响甚至破坏部署和管理脚本中的某些系统版本检查。
var name = 'Tom';
(() => {
if (typeof name === 'undefined') {
var name = 'jeck';
console.log('1:', name);
} else {
console.log('2:', name);
}
})()
// 结果输出 “1: jeck”
var name = 'Tom';
(() => {
var name;
if (typeof name === 'undefined') {
name = 'jeck';
console.log('1:', name);
} else {
console.log('2:', name);
}
})()
var name = 'Tom';
(() => {
if (typeof name === 'undefined') {
name = 'jeck';
console.log(name);
} else {
console.log(name);
}
})()
// 结果是 Tom
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.