我是一名独立 blogger,有一个维护了很久的博客:峰间的云,里面有技术内容,也有非技术的内容,加上博客天然的按时间倒排序的特点,导致技术文章的组织缺少条理性,不方便汇总和回顾。因此,有了当前这个以类似书本的方式按章节撰写的博客。我将这本“书”叫做《程序员备忘录》。这本书记录了WEB程序员常用的知识点,方便温故知新,自我成长,书里有很多是自己的学习笔记,也有不少是对网上优质内容的“拿来主义”。
你可以通过以下方式阅读本书:
意见与讨论请到这里提交:https://github.com/Yakima-Teng/memo/issues。
保护你的眼睛
本书提供单页HTML版本:https://www.orzzone.com/memo/single。读者可以直接利用浏览器的打印功能打印成 PDF 电子书放到水墨屏电纸书阅读器中阅读。以减少对眼睛的伤害。
版权说明
参考的书籍/文章已标注在正文当中或列于书本末处,但可能少列了。若您发现文字和图片有侵犯到您的权益,请务必联系我。
本书中引用的他人文章版权归原作者/平台所有,本人自己写的部分版权归本人所有。
本书仅用于个人私下学习。谢绝商用。
联系方式可在作者个人主页中找到:峰间的云。
目录
因为本书的目标读者并非零基础,所以本章不会对基础语法、特性进行详细讲解。本章的目的主要是帮助大家迅速回忆基础知识,看是否有明显的缺漏需要补齐。对于本章,推荐的学习方式是:
网上有很多写得很好的教程:
参考:BigInt
BigInt
bigint 是基础数据类型。通过在整数末尾添加 n
,或者通过调用 BigInt(传入整数或字符串)
,可以创建一个 bigint
类型的值。
const previouslyMaxSafeInteger = 9007199254740991n;
// 十进制
const alsoHuge = BigInt(9007199254740991);
// 9007199254740991n
// 十进制
const hugeString = BigInt("9007199254740991");
// 9007199254740991n
// 十六进制
const hugeHex = BigInt("0x1fffffffffffff");
// 9007199254740991n
// 八进制
const hugeOctal = BigInt("0o377777777777777777");
// 9007199254740991n
// 二进制
const hugeBin = BigInt(
"0b11111111111111111111111111111111111111111111111111111",
);
// 9007199254740991n
注意:
10n === 10
的值为 false
(10n == 10
的值为 true
)。bigint
值不支持用 Math
对象的原生方法进行处理。typeof 1n === "bigint";
为 true
。typeof Object(1n) === "object";
为 true
。call
、apply
和 bind
call
:func.call(obj, param1, param2)
将func
函数应用于obj
对象上,此时func
函数内部的this
指向obj
对象。
apply
:与call
类似,只是所有要传入的数据都是以数组的形式放到第二个参数里的,如func.apply(obj, [arg1, arg2])
。一个经典用法是来求数组中的最大数:Math.max.apply(null, [1, 3, 5])
,另一个经典用法是用数组方法去处理非数组对象:[].slice.call(arguments, 1)
。
bind
:与call
类似,但不会立即执行,而是生成了一个新函数,新函数的this
指向的是我们传入的obj
。
关于第一个参数
call
、apply
、bind
的第一个参数,如果传了 null
或者 undefined
会被替换为全局对象(浏览器环境下的话就是 window
对象),如果传的是其他基础类型(比如1
、'a'
、false
等)则会被转换成基础类型对应的对象。
function test (...args) {
console.log(this)
console.log(args)
}
console.log('call')
test.call('a', 1, true, 'example', [], { a: 1 })
console.log('apply')
test.apply(false, [1, true, 'example', [], { a: 1 }])
console.log('bind')
/**
* 注意这里我们直接对 `bind` 生成的函数进行调用
* 并且在这个调用中也传入了几个新参数
*/
test.bind(0, 1, true, 'example', [], { a: 1 })(
'hello', 'world'
)
执行结果见下图:
bind
core-js 的实现
core-js 库中 bind
的实现见:https://github.com/zloirock/core-js/blob/master/packages/core-js/internals/function-bind.js
我们自己实现一个,注意:
bind
后生成的是一个新函数(新函数名为 newFn
)。bind
时传的第二个及后续参数会和调用新函数 newFn
时传入的参数合并作为入参。bind
时会指定 newFn
中的 this
指向。Function.prototype.myBind = function () {
// 这里的 `this` 就是被 `bind` 处理前的函数
const func = this
let obj = arguments[0]
// 如果 `obj` 是 `undefined` 或 `null`,则替换成全局对象
if (typeof obj === 'undefined' || obj === null) {
obj = globalThis
}
// 如果不是对象类型,比如是 `false`、`1` 等,则包装成对象
if (typeof obj !== 'object') {
obj = new Object(obj)
}
let args = [].slice.call(arguments, 1)
return function () {
args = args.concat(Array.from(arguments))
const uniqueKey = Symbol('避免污染对象上的其他属性')
/**
* 使用 `symbol`作为 key,
* 可以避免影响其他人添加的可能同名的 key
*/
obj[uniqueKey] = func
const result = obj[uniqueKey](...args)
// 删除临时添加的属性,避免污染对象
delete obj[uniqueKey]
return result
}
}
function test (...args) {
console.log(this)
console.log(args)
console.log(arguments)
}
test.bind(0, 1, true, 'example', [], { a: 1 })(
'hello', 'world'
)
test.myBind(0, 1, true, 'example', [], { a: 1 })(
'hello', 'world'
)
实现结果如下:
forEach
、for-of
、for-in
循环 // `forEach` 循环无法通过 `break` 或 `return` 语句进行中断
arr.forEach(function (elem) {
console.log(elem)
})
/**
* for-in循环实际上是为循环对象的可枚举(enumerable)属性而设计的,
* 也能循环数组,不过不建议,因为key变成了数字
*/
const obj = { a: 1, b: 2, c: 3 }
for (const p in obj) {
console.log(`obj.${p} = ${obj[p]}`)
}
// 上面的代码依次输出内容如下:
// obj.a = 1
// obj.b = 2
// obj.c = 3
/**
* for-of能循环很多东西,
* 包括字符串、数组、map、set、DOM collection等等
* (但是不能遍历对象,因为对象不是iterable可迭代的)
*/
const iterable = [1, 2, 3]
for (const value of iterable) {
console.log(value)
}
基本上 for in
用于大部分常见的由 key-value
对构成的对象上以遍历对象内容。但是 for in
在遍历数组对象时并不方便,这时候用 for of
会很方便。
如果习惯写完一条语句后不加分号的写法,碰到需要写 IIFE(自执行函数)的时候容易踩到下面的坑:
const a = 1
(function () {})()
上述代码会报错,因为第一行的 1
会和第二行一起被程序解析成 const a = 1(function () {})()
,然后报错:Uncaught TypeError: 1 is not a function
。
这时候可以这样写:
const a = 1
void function () {}()
// 或
const a = 1
void (function () {})()
// 或者下面这种方式,但据说会多一次逻辑运算
const a = 1
!function () {}()
new
关键字还有什么方法可以创建对象? 可以通过 Object.create(proto, [, propertiesObject])
实现。详见:Object.create()。
Object.create()
静态方法以一个现有对象作为原型,创建一个新对象。
const person = {
isHuman: false,
printIntroduction: function () {
console.log(
`I am ${this.name}. Am I human? ${this.isHuman}`
);
},
};
const me = Object.create(person);
// `name` 是 `me` 的属性,不是 `person` 的属性
me.name = 'Matthew';
// 继承过来的属性值可以被重写
me.isHuman = true;
// 打印内容: "I am Matthew. Am I human? true"
me.printIntroduction();
用 Object.create()
实现类式继承
// Shape —— 父类
function Shape() {
this.x = 0;
this.y = 0;
}
// 父类方法
Shape.prototype.move = function (x, y) {
this.x += x;
this.y += y;
console.info("Shape moved.");
};
// Rectangle —— 子类
function Rectangle() {
Shape.call(this); // 调用父类构造函数。
}
// 子类继承父类
Rectangle.prototype = Object.create(Shape.prototype, {
/**
* 如果不将 `Rectangle.prototype.constructor`
* 设置为 `Rectangle`,
* 它将采用 `Shape`(父类)的 `prototype.constructor`。
* 为避免这种情况,
* 我们将 `prototype.constructor` 设置为 `Rectangle`(子类)。
*/
constructor: {
value: Rectangle,
enumerable: false,
writable: true,
configurable: true,
},
});
const rect = new Rectangle();
// true
console.log(
"rect 是 Rectangle 类的实例吗?",
rect instanceof Rectangle
);
// true
console.log(
"rect 是 Shape 类的实例吗?",
rect instanceof Shape
);
// 打印 'Shape moved.'
rect.move(1, 1);
使用 Object.create()
的 propertyObject
参数
Object.create()
方法允许对对象创建过程进行精细的控制。实际上,字面量初始化对象语法可视为 Object.create()
的一种语法糖。使用 Object.create()
,我们可以创建具有指定原型和某些属性的对象。请注意,第二个参数将键映射到属性描述符,这意味着你还可以控制每个属性的可枚举性、可配置性等,而这在 {}
字面量初始化对象语法中是做不到的。
o = {};
// 等价于:
o = Object.create(Object.prototype);
o = Object.create(Object.prototype, {
// foo 是一个常规数据属性
foo: {
writable: true,
configurable: true,
value: "hello",
},
// bar 是一个访问器属性
bar: {
configurable: false,
get() {
return 10;
},
set(value) {
console.log("Setting `o.bar` to", value);
},
},
});
/**
* 创建一个新对象
* 它的原型是一个新的空对象
* 并添加一个名为 'p',值为 42 的属性。
*/
o = Object.create({}, { p: { value: 42 } });
使用 Object.create()
,我们可以创建一个原型为 null
的对象。在字面量初始化对象语法中,相当于将 __proto__
键赋值为 null
。
o = Object.create(null);
// 等价于:
o = { __proto__: null };
你可以使用 Object.create()
来模仿 new
运算符的行为。
function Constructor() {}
o = new Constructor();
// 等价于:
o = Object.create(Constructor.prototype);
当然,如果 Constructor
函数中有实际的初始化代码,那么 Object.create()
方法就无法模仿它。
yield
语句 function* hello (name) {
yield `hello ${name}!`
yield 'I am glad to meet you!'
if (0.6 > 0.5) {
yield `It is a good day!`
}
yield 'See you later!'
}
// Generator函数执行后会返回一个迭代器,通过调用next方法依次yield相应的值
const iterator = hello('Yakima')
iterator.next() // 返回{value: "hello Yakima!", done: false}
iterator.next() // 返回{value: "I am glad to meet you!", done: false}
iterator.next() // 返回{value: "It is a good day!", done: false}
iterator.next() // 返回{value: "See you later!", done: false}
iterator.next() // 返回{value: undefined, done: true}
iterator.next() // 返回{value: undefined, done: true}
生成器函数(Generator)与常见的函数的差异:
function
开始,而生成器函数以 function*
开始;yield
是一个关键字,和 return
有点像。不同点在于,所有函数(包括生成器函数)都只能 return
一次,而在生成器函数中可以 yield
任意次。yield
表达式暂停了生成器函数的执行,然后可以从暂停的地方恢复执行。常见的函数不能暂停执行,而生成器函数可以,这是两者最大的区别。
在Babeljs.io Try it out上转义的结果是:
function _typeof(o) {
"@babel/helpers - typeof";
return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator
? function (o) {
return typeof o;
}
: function (o) {
return (
o
&& "function" == typeof Symbol
&& o.constructor === Symbol
&& o !== Symbol.prototype
)
? "symbol"
: typeof o;
},
_typeof(o);
}
function ownKeys(e, r) {
var t = Object.keys(e);
if (Object.getOwnPropertySymbols) {
var o = Object.getOwnPropertySymbols(e);
r && (o = o.filter(function (r) {
return Object.getOwnPropertyDescriptor(e, r).enumerable;
})), t.push.apply(t, o);
}
return t;
}
function _objectSpread(e) {
for (var r = 1; r < arguments.length; r++) {
var t = null != arguments[r] ? arguments[r] : {};
r % 2
? ownKeys(Object(t), !0).forEach(function (r) {
_defineProperty(e, r, t[r]);
})
: Object.getOwnPropertyDescriptors
? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t))
: ownKeys(Object(t)).forEach(function (r) {
Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r));
});
}
return e;
}
function _defineProperty(obj, key, value) {
key = _toPropertyKey(key);
if (key in obj) {
Object.defineProperty(obj, key, {
value: value,
enumerable: true,
configurable: true,
writable: true
});
} else {
obj[key] = value;
}
return obj;
}
function _toPropertyKey(t) {
var i = _toPrimitive(t, "string");
return "symbol" == _typeof(i) ? i : String(i);
}
function _toPrimitive(t, r) {
if ("object" != _typeof(t) || !t) return t;
var e = t[Symbol.toPrimitive];
if (void 0 !== e) {
var i = e.call(t, r || "default");
if ("object" != _typeof(i)) return i;
throw new TypeError("@@toPrimitive must return a primitive value.");
}
return ("string" === r ? String : Number)(t);
}
var a = {
a: 1,
b: {
c: 3
},
c: 5
};
var d = _objectSpread({}, a);
回调函数、事件监听、发布/订阅、Promise对象。
本文参考了以下文章:
一段时间以来,CommonJS 模块化方案一直是 Node.js 生态中的默认模块化方案。从 Node.js v8.5.0 开始,引入了 ES(ECMAScript) 模块化方案。这两种方案在执行时有一些差异。
import
和 export
来导入、导出模块。require
和 module.exports
/ exports.<keyName>
来导入和导出模块。require()
函数可以在程序的任何地方被调用,import
则在文件头部被调用。require()
引入的文件名使用 .js 作为文件名后缀,用 import
引入的文件名使用 .mjs 作为文件名后缀。(不绝对)require()
得到的内容可以视作一个对象,里面有我们需要的属性或者方法。require()
得到的是原始内容的一个拷贝(如果是对象的话就是浅拷贝),也就是重新又自己声明了一份变量,比如在 b.js
文件中声明了 const { a } = require(./a.js)
后在 a.js
文件中修改 a
的值也不会影响 b.js
中的 a
的值,两个文件中的 a
是不一样的。import
则不会重新声明变量,在上面所述的场景中,a.js
和 b.js
文件中的 a
一直都是同一个变量,值也始终相同。require
引入 外部模块
require
除了支持通过传入一个本地文件路径来引用本地模块,也支持通过传入一个 web 地址来引入外部模块,比如这样:
const myVar = require('http://web-module.location');
new
直接使用 {}
花括号可以很方便地创建一个对象,但是当我们想要创建很多对象时,如果还采用直接使用 {}
的方式就需要写很多冗余代码。JavaScript 提供了 new
关键字,我们可以对构造函数使用 new
操作符来创建一类相似的对象。
构造函数在技术上是常规函数。不过有两个约定:
new
操作符来执行(如果直接调用,这时它就不是构造函数了,而是常规函数)。function User(name) {
this.name = name;
this.isAdmin = false;
}
const user = new User("Jack");
console.log(user.name); // Jack
console.log(user.isAdmin); // false
当一个函数被使用 new
操作符执行时,它会经历以下步骤:
this
。this
对象,比如为其添加新的属性。this
对象。换句话说,执行 new User(...)
时,做的就是类似下面的事情:
function User(name) {
// this = {};(隐式创建)
// 添加属性到 this
this.name = name;
this.isAdmin = false;
// return this;(隐式返回)
}
故 const user = new User("Jack")
可等价为以下代码:
const user = {
name: "Jack",
isAdmin: false
};
现在,如果我们想创建其他用户,我们可以调用 new User("Ann")
、new User("Alice")
等。代码量比每次都使用 {}
字面量的方式去创建要少,而且更易阅读。
这就是构造器的主要目的 —— 实现可重用的对象创建代码。
提示
从技术上讲,任何函数(除了箭头函数,它没有自己的 this
)都可以用作构造器。即可以通过 new
来运行。“首字母大写”是一个共同的约定,以明确表示一个函数将被使用 new
来运行。
new.target
在一个函数内部,我们可以使用 new.target
属性来检查它是否被使用 new
关键字进行调用了。
常规调用时,它为 undefined
。使用 new
调用时,则等于该函数:
function User() {
console.log(new.target);
console.log(new.target === User);
}
// 直接调用(不使用 `new` 关键字):
User(); // undefined, false
// 使用 `new` 关键字调用
new User(); // function User { ... }, true
我们也可以让常规调用和使用 new
关键字调用做相同的工作,像这样:
function User(name) {
if (!new.target) {
return new User(name);
}
this.name = name;
}
const john = User("John");
console.log(john.name);
这种方法有时被用在库中以使语法更加灵活。这样其他人在调用函数时,无论是否使用了 new
,程序都能如期工作。
不过,到处都使用它并不是一件好事,因为省略了 new
使得很难直观地知道代码在干啥。而通过使用 new
关键字,我们都知道代码正在创建一个新对象。
return
通常,构造器函数内没有 return
语句。它们的任务是将所有必要的东西写入 this
,并自动返回 this
。
但是,如果构造器函数内有 return
语句,那么:
return
返回的是一个对象(不含 null
),则返回这个对象,而不是 this
。 return
返回的是一个原始类型(包括 null
),则忽略 return
语句,继续返回 this
。换句话说,带有对象的 return
语句返回该对象,其他情况下都返回 this
。
例如,这里 return
通过返回一个对象覆盖了 this
:
function BigUser() {
this.name = "小明";
return { name: "小王" };
}
console.log(new BigUser().name); // 小王
这里有一个 return
为 undefined
的例子(或者我们可以在它之后放置一个原始类型,结果是一样的):
function SmallUser() {
this.name = "小小王";
return;
}
console.log(new SmallUser().name); // 小小王
通常构造器函数里都是没有 return
语句的,这里只做了解即可。
省略括号
顺便说一下,如果没有参数,我们可以省略 new
后的括号:
const user = new User;
// 等同于
const user = new User();
这里省略括号不是一种好风格,但是语法规范上是允许的。
使用构造函数来创建对象有很大的灵活性。构造函数可能有一些函数入参,这些参数定义了如何构造对象。
当然,我们不仅可以在 this
上添加属性,还可以添加方法。
function User(name) {
this.name = name;
this.sayHi = function () {
console.log(`我的名字是: ${this.name}`);
};
}
const user = new User("李白");
user.sayHi(); // 我的名字是李白
new
问题描述
由于 new
是 JavaScript 中的关键字,我们不可能另外实现一个自己的关键字。所以这里要求我们实现一个函数 myNew
,要求:myNew(Person, '李白')
等价于 new Person('李白')
。
具体实现
function myNew() {
// 1、创建一个空对象
const obj = {}
// 获取构造方法(并将其从 `arguments` 中移出)
const constructor = [].shift.call(arguments)
// 2、将新对象的原型指向构造方法的 `prototype` 对象上
obj.__proto__ = constructor.prototype
/**
* 3、获取到构造方法的返回值
* 如果原先构造方法有返回值,且是对象,
* 那么原始的 `new` 会把这个对象返回出去
* (基本类型会忽略)
*
* 原始 `arguments` 的第一个参数已经在前面被 `shift` 了,
* 所以剩下的参数全都是构造方法需要的值
*/
const ret = constructor.apply(obj, arguments)
// `(ret || obj)` 是为了判断 `null`,当为 `null` 时,也返回新对象
return typeof ret === 'object' ? (ret || obj) : obj
}
使用
function Person(name, age) {
this.name = name
this.age = age
}
const p = myNew(Person, 'cheny', 28)
// true
console.log(p instanceof Person);
this
this
指向 这里说的“普通函数”指箭头函数以外的函数。
在绝大多数情况下,函数的调用方式决定了 this
的值(运行时绑定)。
写出下面代码的执行结果:
// 当前位于全局作用域下
function testObject () {
alert(this)
}
testObject()
上题的答案:在chrome中会弹出 [object Window]
。
this
关键字是函数运行时自动生成的一个内部独享对象,只能在函数内部使用,总指向调用它的对象。
根据不同的使用场合,this
有不同的值,主要分以下几种情况:
new
绑定。全局环境中定义 person
函数,内部使用 this
关键字。
var name = 'Jenny';
function person() {
return this.name;
}
// Jenny
console.log(person());
上述代码输出 Jenny
,原因是调用函数的对象在游览器中为 window
,因此 this
指向 window
,所以输出 Jenny
。
注意:
严格模式下,不能将全局对象用于默认绑定,this
会绑定到 undefined
,只有函数运行在非严格模式下,默认绑定才能绑定到全局对象。
函数还可以作为某个对象的方法调用,这时 this
就指这个上级对象。
function test() {
console.log(this.x);
}
const obj = {};
obj.x = 1;
obj.m = test;
obj.m(); // 1
下面这段代码中包含多级对象,注意 this
指向的只是它上一级的对象 b
(由于 b
内部并没有属性 a
的定义,所以输出 undefined
。)。
const o = {
a: 10,
b: {
fn: function() {
console.log(this.a); // undefined
}
}
}
o.b.fn();
再举一种特殊情况:
const o = {
a: 10,
b: {
a: 12,
fn: function() {
console.log(this.a); // undefined
console.log(this); // window
}
}
}
const j = o.b.fn;
j();
在上面的例子中,this
指向的是 window
,这里的大家需要记住,this
永远指向的是最后调用它的对象,虽然fn
是对象 b
的方法,但是 fn
赋值给 j
时并没有执行,所以最终指向 window
。
new
绑定 通过构建函数 new
关键字生成一个实例对象时,this
指向这个实例对象。
function Test() {
this.x = 1;
}
const obj = new Test();
obj.x // 1
上述代码之所以会输出 1
,是因为 new
关键字改变了 this
的指向。
这里再列举一些特殊情况:
new
过程遇到 return
一个对象(不包括 null
),此时 this
指向返回的对象:
function fn() {
this.user = 'xxx';
return {};
}
const a = new fn();
console.log(a.user); // undefined
如果 return
一个基础类型的值(包括 null
),则 this
指向实例对象:
function fn() {
this.user = 'xxx';
return 1;
}
const a = new fn;
console.log(a.user); // xxx
注意的是 null
虽然也是对象,但是此时 this
仍然指向实例对象:
function fn() {
this.user = 'xxx';
return null;
}
const a = new fn;
console.log(a.user); // xxx
apply
、call
、bind
是函数的几个方法,作用是改变函数的调用对象。它的第一个参数就表示改变后的调用这个函数的对象。因此,这时 this
指的就是这第一个参数。
const x = 0;
function test() {
console.log(this.x);
}
const obj = {};
obj.x = 1;
obj.m = test;
obj.m.apply(obj) // 1
this
匿名函数里的 this
指向 window
。
// 等价于 `window.name = 'The Window'`
var name = "The Window";
var object = {
name : "My Object",
getNameFunc : function() {
return function() {
return this.name;
};
}
};
// 输出为 `The Window`
alert(object.getNameFunc()());
this
指向 大部分情况下,this
总是指向调用该函数的对象。但对箭头函数而言却不是这样的,箭头函数里的 this
是根据外层(函数或者全局)作用域(词法作用域)来决定的。
箭头函数体内的 this
对象,就是定义该函数时所在的作用域指向的对象,而不是使用时所在的作用域指向的对象。
下面是普通函数的列子:
// 其实是window.name = 'window'
var name = 'window';
var A = {
name: 'A',
sayHello: function() {
console.log(this.name)
}
}
// 输出 `A`
A.sayHello();
var B = {
name: 'B'
}
// 输出 `B`
A.sayHello.call(B);
/**
* 不传参数指向全局 `window` 对象,
* 输出 `window.name` 也就是 `'window'`
*/
A.sayHello.call();
从上面可以看到,sayHello
这个方法是定义在 A
对象中的,但是当我们使用 call
方法,把其指向 B
对象后,最后输出了 B
;可以得出,sayHello
的 this
只跟使用时的调用对象有关。
改造一下:
var name = 'window';
var A = {
name: 'A',
sayHello: () => {
console.log(this.name)
}
}
// 还是以为输出 `A`? 错啦,其实输出的是 `window`
A.sayHello();
我相信在这里,大部分同学都会出错,以为 sayHello
是绑定在 A
上的,但其实它绑定在 window
上的,那到底是为什么呢?
一开始,我重点标注了该函数所在的作用域指向的对象,作用域是指函数内部,这里的箭头函数(也就是 sayHello
)所在的作用域其实是最外层的 js 环境,因为没有其他函数包裹;然后最外层的 js 环境指向的对象是 window
对象,所以这里的 this
指向的是 window
对象。
那如何改造成永远绑定A呢:
var name = 'window';
var A = {
name: 'A',
sayHello: function() {
var s = () => console.log(this.name)
// 返回箭头函数 `s`
return s
}
}
var sayHello = A.sayHello();
// 输出 `A`
sayHello();
var B = {
name: 'B'
}
// 输出 `A`
sayHello.call(B);
// 输出 `A`
sayHello.call();
OK,这样就做到了永远指向 A
对象了,我们再根据该函数所在的作用域指向的对象来分析一下:
s
所在的作用域是 sayHello
,因为 sayHello
是一个函数。A.sayHello
指向的对象是 A
。最后是使用箭头函数其他几点需要注意的地方
new
命令,否则会抛出一个错误。arguments
对象,该对象在函数体内不存在。可以用 ...rest
入参代替((...rest) => { console.log(rest); }
)。yield
命令,因此箭头函数不能用作 生成器(Generator)函数。再来看一个例子:
var fullname = 'a'
var obj = {
fullname: 'b',
prop: {
fullname: 'c',
getFullname: () => {
return this.fullname;
}
}
}
// 打印 'a'
console.log(obj.prop.getFullname())
var test = obj.prop.getFullname
// 打印 'a'
console.log(test())
var fullname = 'a'
function hello () {
var fullname = 'd'
var obj = {
fullname: 'b',
prop: {
fullname: 'c',
getFullname: () => {
return this.fullname;
}
}
}
// 打印 'a'
console.log(obj.prop.getFullname())
var test = obj.prop.getFullname
// 打印 'a'
console.log(test())
}
hello()
var fullname = 'a'
function hello () {
var fullname = 'd'
var obj = {
fullname: 'b',
prop: {
fullname: 'c',
getFullname: () => {
return this.fullname;
}
}
}
// 打印 undefined
console.log(obj.prop.getFullname())
var test = obj.prop.getFullname
// 打印 undefined
console.log(test())
}
const d = new hello()
var fullname = 'a'
function hello () {
this.fullname = 'f'
var fullname = 'd'
var obj = {
fullname: 'b',
prop: {
fullname: 'c',
getFullname: () => {
return this.fullname;
}
}
}
// 打印 'f'
console.log(obj.prop.getFullname())
var test = obj.prop.getFullname
// 打印 'f'
console.log(test())
}
const d = new hello()
看一个普通函数的例子:
const obj = {
count: 10,
doSomethingLater() {
setTimeout(function () {
// 这是一个匿名函数,是在 window 作用域下执行的
this.count++;
console.log(this.count);
}, 300);
},
};
// 打印 `NaN`,因为 window 对象上没有 `count` 属性
obj.doSomethingLater();
如果改成箭头函数:
const obj = {
count: 10,
doSomethingLater() {
// 该方法将 `this` 绑定到 `obj` 上下文中
setTimeout(() => {
/**
* 由于箭头函数内部不会自己绑定 `this`,
* `setTimeout` 函数也没有创建 `this` 绑定,
* 所以外部的 `obj` 上下文会被用作 `this`
*/
this.count++;
console.log(this.count);
}, 300);
},
};
// 打印 `11`
obj.doSomethingLater();
if (typeof localStorage !== 'undefined') {
// 此时访问localStorage不会出现引用错误
}
或者
// 浏览器端全局处 `window`/`this`/`self` 三者彼此全等
if ('localStorage' in self) {
// 此时访问 `localStorage` 绝对不会出现引用错误
}
注意二者的区别:
var a // 或 var a = undefined
'a' in self // true
typeof a // 'undefined'
var a = undefined
或者 var a
相当于是给 window
对象添加了 a
属性,但是未赋值,即 window.a === undefined
为 true
。typeof a
就是返回其变量类型,未赋值或者声明类型为 undefined
的变量,其类型就是 undefined
const
、let
与 var
全局作用域下通过 const
或 let
定义一个变量时,并不会在 window
上挂载该对象,这是与 var
表现不同之处。
前提假设
不是只根据引用地址来判断,只要两个对象的键完全一致且同名键对应的值也“相等”,就认为这两个对象是相等的,比如分开创建的 { a: 1 }
和 { a: 1 }
,被认为是相等的两个对象。
具体实现
function isObjectEqual (obj1, obj2) {
if (typeof obj1 !== 'object' || typeof obj2 !== 'object') {
return obj1 === obj2
}
// 如果两个对象指向的是同一个引用地址,则为相同对象
if (obj1 === obj2) {
return true
}
const keys1 = Object.keys(obj1)
const keys2 = Object.keys(obj2)
if (keys1.length !== keys2.length) {
return false
}
if (keys1.length === 0) {
return true
}
for (let i = 0, len = keys1.length; i < len; i++) {
const key = keys1[i]
if (!isObjectEqual(obj1[key], obj2[key])) {
return false
}
}
return true
}
assign
function assign () {
const args = Array.from(arguments)
const target = args.shift()
if (!target || typeof target !== 'object') {
throw new TypeError('入参错误')
}
const objects = args.filter((obj) => typeof obj === 'object')
objects.forEach((obj) => {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
target[key] = obj[key]
}
}
})
return target
}
合并对象的可枚举的属性/方法到指定对象
/**
* 判断是否是非空对象
* @param val {any}
* @returns {boolean}
*/
function isObject(val) {
return (
typeof val !== 'null' &&
({}).toString.call(val).slice(8, -1).toLowerCase() === 'object'
)
}
function merge(target, obj) {
for (const p in obj) {
if (!obj.hasOwnProperty(p)) {
return
}
if (isObject(target[p]) && isObject(obj[p])) {
merge(target[p], obj[p])
return
}
target[p] = obj[p]
}
return target
}
假设你需要一个数组,数据长度为100,数组元素依次为0、1、2、3、4...98、99。该如何实现呢?直接写个for循环当然是可以的。不过这里有更方便的方法。
Array.from(Array(100).keys())
或者
[...Array(100).keys()]
如果你想要直接从 1 开始到 100,可以用 Array.from
方法实现(下面这种传参方法不太常见,第二个参数是一个 map function,可以对第一个参数传进去的类数组对象或者可迭代对象进行处理):
Array.from
的语法如下:
Array.from(arrayLike)
Array.from(arrayLike, mapFn)
Array.from(arrayLike, mapFn, thisArg)
所以,可以这么写:
Array.from({ length: 100 }, (_, i) => i + 1)
注意,上面的例子里可以认为 { length: 100 }
是一个类数组。
要求
实现一个函数,该函数入参为一个时间戳,返回 YYYY:MM:DD HH:mm:ss
格式的字符串。不允许使用 Date
对象的内置方法。
已知:
实现
function toDateStr (ts) {
// 1 天的毫秒数
const tsDay = 24 * 60 * 60 * 1000
// 1 小时的毫秒数
const tsHour = 60 * 60 * 1000
// 1 分钟的毫秒数
const tsMin = 60 * 1000
// 1 秒的毫秒数
const tsSecond = 1000
let remaining = ts
// 天数
const days = Math.floor(remaining / tsDay)
remaining -= days * tsDay
// 小时数
const hours = Math.floor(remaining / tsHour)
remaining -= hours * tsHour
// 分钟数
const mins = Math.floor(remaining / tsMin)
remaining -= mins * tsMin
// 秒数
const seconds = Math.floor(remaining / tsSecond)
// 将天数转换成年和月
let years = 1970
let months = 0
let daysInLastMonth = 0
// 统计过的天数
let numOfDays = 0
while (numOfDays < days) {
// 2 月份
let daysInFebruary = 28
if (years % 400 === 0) {
daysInFebruary = 29
}
if (years % 100 !== 0 && years % 4 === 0) {
daysInFebruary = 29
}
// 各个月份的天数
const arrMonthAndDays = [
31, daysInFebruary, 31, 30, 31,
30, 31, 31, 30, 31,
30, 31,
]
for (const daysInMonth of arrMonthAndDays) {
/**
* 注意这里是小于,不是小于等于,
* 因为是从 1970 年 1 月【1 日】开始计算的,
* 过 30 天就是 1 月 31 日,过 31 天已经是 2 月份了
*/
if (days - numOfDays < daysInMonth) {
daysInLastMonth = days - numOfDays
numOfDays += days - numOfDays
break
}
months++
numOfDays += daysInMonth
}
if (numOfDays < days) {
// 下一个年份
years++
months = 0
}
}
/**
* 因为时间戳是从 1970 年 1 月 1 日开始计时的,
* 所以误差 1 天实际对应的是 2 号,
* 所以我们最终算出来的误差天数需要加 1
*/
return [
`${years}:${months + 1}:${daysInLastMonth + 1}`,
`${hours}:${mins}:${seconds}`
].join( )
}
实现一个函数,用于将目标函数柯里化
柯里化之前的效果:
// 柯里化之前
function add(a, b, c, d, e) {
console.log(a + b + c + d + e)
}
add(1, 2, 3, 4, 5)
实现一个柯里化函数 curry
,使得下面 curryAdd(1)(2)(3)(4)(5)
的计算结果与上方 add(1, 2, 3, 4, 5)
一致:
function curry() {}
const curryAdd = curry(add)
console.log(curryAdd(1)(2)(3)(4)(5)) // 输出:15
实现方案
判断当前传入函数的参数个数 (args.length
) 是否大于等于原函数所需参数个数 (fn.length
) :
注意,这里我们的原函数是指如下这个函数(fn.length
为 5,因为有 a
、b
、c
、d
、e
一共 5 个参数):
function add(a, b, c, d, e) {
console.log(a + b + c + d + e)
}
实现方案如下:
function curry(fn, ...args) {
// 函数的参数个数可以直接通过函数的 `.length` 属性来访问
return args.length === fn.length
? fn(...args)
: (...newArgs) => curry(fn, ...args, ...newArgs)
}
// 使用
function add(a, b, c, d, e) {
console.log(a + b + c + d + e)
}
const curryAdd = curry(add)
console.log(curryAdd(1)(2)(3)(4)(5)) // 输出:15
闭包(closure)指有权访问另一个函数作用域中变量的函数。
闭包的作用:
闭包案例1
function fn1() {
var num = 10;
function fn2() {
console.log(num);
}
fn2();
}
//输出结果:10
fn1();
fn2
的作用域当中访问到了 fn1
函数中的 num
这个局部变量。
闭包案例2
另一个例子:
function fn() {
var num = 10;
return function() {
console.log(num);
}
}
var f = fn();
// 上面这步类似于
// var f = function() {
// console.log(num);
// }
//输出结果:10
f();
在上例中我们做到了在 fn()
函数外面访问 fn()
中的局部变量 num
。闭包延伸了变量作用域范围,读取了函数内部的变量。
闭包案例3
const fn = function() {
let sum = 0
return function(){
sum++
console.log(sum);
}
}
/**
* `fn()` 进行 `sum` 变量申明并且返回一个匿名函数,
* 第二个 `()` 意思是执行这个匿名函数
*/
fn()() // 1
fn()() // 1
我这里直接简单解释一下,执行 fn()()
后,fn()()
已经执行完毕,没有其他资源在引用 fn
,此时内存回收机制会认为 fn
不需要了,就会在内存中释放它。
那如何不被回收呢?
const fn = function() {
let sum = 0
return function(){
sum++
console.log(sum);
}
}
fn1 = fn()
// 1
fn1()
// 2
fn1()
// 3
fn1()
这种情况下,fn1
一直在引用 fn()
,此时内存就不会被释放,就能实现值的累加。那么问题又来了,这样的函数如果太多,就会造成内存泄漏。
内存泄漏了怎么办呢?我们可以手动释放一下。
const fn = function() {
let sum = 0
return function(){
sum++
console.log(sum);
}
}
fn1 = fn()
// 1
fn1()
// 2
fn1()
// 3
fn1()
// `fn1` 的引用 `fn` 被手动释放了
fn1 = null
// `num` 再次归零
fn1 = fn()
// 1
fn1()
除了广义的同步任务和异步任务,我们可以分的更加精细一点:
setTimeout
、setInterval
。Promise
、process.nextTick
、MutationObserver
。不同类型的任务会进入到不同的 事件队列(event queue)。相同类型的任务会进入相同的事件队列。比如 setTimeout
和 setInterval
会进入相同的事件队列。
先看一个例子:
setTimeout(function() {
console.log('setTimeout');
}, 0)
new Promise(function(resolve) {
console.log('promise');
resolve(true)
console.log('after resolve')
}).then(function() {
console.log('then');
})
console.log('console');
// promise
// after resolve
// console
// then
// setTimeout
setTimeout
,将其放到宏任务 event queue 里面。new Promise
会立即执行,then
会分发到微任务。console
立即执行。then
,执行。setTimeout
函数内的语句进入到宏任务,立即执行。区分微任务和宏任务是为了将异步队列任务划分优先级,通俗的理解就是为了插队。
一个事件队列(Event Loop)中,microtask 是在 macrotask 之后被调用的,microtask 会在下一个 Event Loop 之前执行完,并且会将 microtask 执行当中新注册的 microtask 一并调用执行完,然后才开始下一次 Event Loop,所以如果有新的 Macrotask 就需要一直等待,等到上一个 Event Loop 当中 Microtask 被清空为止。由此可见,我们可以在下一次 Event Loop 之前进行插队。
如果不区分 Microtask 和 Macrotask,那就无法在下一次 Event Loop 之前进行插队,其中新注册的任务得等到下一个 Macrotask 完成之后才能进行,这中间可能你需要的状态就无法在下一个 Macrotask 前得到同步。
一个利用任务执行顺序(宏任务 => 微任务 => 浏览器渲染)的案例:
<html>
<head>
<style>
div {
padding: 0;
margin: 0;
display: inline-block;
widtH: 100px;
height: 100px;
background: blue;
}
#microTaskDom {
margin-left: 10px;
}
</style>
</head>
<body>
<div id="taskDom"></div>
<div id="microTaskDom"></div>
<script>
window.onload = () => {
setTimeout(() => {
taskDom.style.background = 'red'
setTimeout(() => {
/**
* 使用 `setTimeout` 立马修改背景色,
* 会闪现一次红色背景
*/
taskDom.style.background = 'black'
}, 0);
microTaskDom.style.background = 'red'
Promise.resolve().then(() => {
/**
* 使用 `Promise` 不会闪现红色背景。
* 因为微任务会在渲染之前完成对背景色的修改,
* 等到渲染时就只需要渲染黑色
*/
microTaskDom.style.background = 'black'
})
}, 1000);
}
</script>
</body>
</html>
由此我们可以联想到一个的应用场景:使用主流的 MVVM 或类似框架每次修改数据后,并不是马上就会同步触发视图更新的。我们自己开发渲染库的话,可以在业务开发者多次进行数据修改和页面 DOM 渲染之间,做一个数据改动的汇总的操作,然后根据实际最终得到的变更差异,去渲染 DOM 更新页面视图。如果每次修改数据都同步去更新 DOM,那 DOM 更新的频率就太高了。
setTimeout
的时间误差 在使用 setTimeout
的时候,经常会发现设定的时间与自己设定的时间有差异。
如果改成下面这段会发现执行时间远远超过预定的时间:
setTimeout(() => {
task()
},3000)
sleep(10000000)
这是为何?
我们来看一下是怎么执行的:
task()
进入到 event table
里面注册计时。sleep
函数,但是非常慢。计时任然在继续。task()
进入 event queue
,但是主线程依旧没有走完。task()
进入到主线程。task()
被调用前的真实延迟时间是远远大于 3 秒的。setTimeout
第二个参数最小值 HTML5标准规定了 setTimeout()
中第二个参数如果传入小于 0 的值会被修改为 0,如果 setTimeout
或者 setInterval
嵌套层级超过 5 层,并且第二个参数传入的数值小于 4,那这个数值会被设置为 4。(本段参考了https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-settimeout)
如果第二个参数如果传入 0
,就表示要尽快调用回调函数,更准确地说 —— 就是要在下一个事件循环(event cycle)中调用回调函数。(本段参考了setTimeout() global function)
promise
和 process.nextTick
process.nextTick(callback)
类似 Node.js 版的 setTimeout
,在事件循环的下一次循环中调用 callback
回调函数。
例1
setTimeout(() => {
console.log(1)
}, 0)
new Promise((resolve) => {
resolve()
}).then(() => {
console.log(2)
})
上面的执行结果是2,1。
从规范上来讲,setTimeout有一个4ms的最短时间,也就是说不管你设定多少,反正最少都要间隔4ms(不是精确时间间隔)才运行里面的回调。 而Promise的异步没有这个问题。
从具体实现上来说,这两个的异步队列不一样,Promise所在的那个异步队列优先级要高一些。
例2
(function () {
setTimeout(() => {
console.log(4)
}, 0)
new Promise((resolve) => {
console.log(1)
for (let i = 0; i < 10000; i++) {
i === 9999 && resolve(null)
}
console.log(2)
}).then(() => {
console.log(5)
})
console.log(3)
})()
执行结果1,2,3,5,4。为什么执行这样的结果?
setTimeout
异步会放到异步队列中等待执行。promise.then
异步会放到 microtask queue 中。microtask 队列中的内容经常是为了需要直接在当前脚本执行完后立即发生的事,所以当同步脚本执行完之后,就调用 microtask 队列中的内容,然后把异步队列中的 setTimeout
的回调函数放入执行栈中执行,所以最终结果是先执行 promise.then
异步,然后再执行 setTimeout
中的异步回调。
这是由于:
Promise 的回调函数属于异步任务,会在同步任务之后执行。但是,Promise 的回调函数不是正常的异步任务,而是微任务(microtask)。它们的区别在于,正常任务追加到下一轮事件循环,微任务追加到本轮事件循环。这意味着,微任务的执行时间一定早于正常任务。
注意:目前 microtask 队列中常用的就是 promise.then。
例3
setTimeout(() => {
console.log(7)
}, 0)
new Promise((resolve, reject) => {
console.log(3);
resolve();
console.log(4);
}).then(() => {
console.log(6)
})
console.log(5)
执行结果3,4,5,6,7。
例4
console.log(1)
const promise = new Promise((resolve, reject) => {
console.log(2)
setTimeout(() => {
resolve(3)
reject(4)
}, 0)
})
promise.then((data) => {
console.log(data)
}).catch((err) => {
console.log('6')
console.log(err)
})
promise.then((data) => {
console.log(data)
}).catch((err) => {
console.log('7')
console.log(err)
})
console.log(5)
上面这段代码会输出:1、2、5、3、3。
promise 的 resolve、reject
这里需要注意,promise 被 resolve
后再触发 reject
是无效的,不会触发 promise.catch
回调。
nextTick()
A utility for waiting for the next DOM update flush.
When you mutate reactive state in Vue, the resulting DOM updates are not applied synchronously. Instead, Vue buffers them until the "next tick" to ensure that each component updates only once no matter how many state changes you have made.
nextTick() can be used immediately after a state change to wait for the DOM updates to complete. You can either pass a callback as an argument, or await the returned Promise.
<script setup>
import { ref, nextTick } from 'vue'
const count = ref(0)
async function increment() {
count.value++
// DOM not yet updated
console.log(document.getElementById('counter').textContent) // 0
await nextTick()
// DOM is now updated
console.log(document.getElementById('counter').textContent) // 1
}
</script>
<template>
<button id="counter" @click="increment">{{ count }}</button>
</template>
实现原理
原理部分参考了:面试官:Vue中的$nextTick有什么作用?。
export function nextTick(cb?: Function, ctx?: Object) {
let _resolve;
// cb 回调函数会经统一处理压入 callbacks 数组
callbacks.push(() => {
if (cb) {
// 给 cb 回调函数执行加上了 try-catch 错误处理
try {
cb.call(ctx);
} catch (e) {
handleError(e, ctx, 'nextTick');
}
} else if (_resolve) {
_resolve(ctx);
}
});
// 执行异步延迟函数 timerFunc
if (!pending) {
pending = true;
timerFunc();
}
// 当 nextTick 没有传入函数参数的时候,返回一个 Promise 化的调用
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve;
});
}
}
上例中,timerFunc
函数会根据当前环境支持什么方法则确定调用哪个,分别有:Promise.then
、MutationObserver
、setImmediate
、setTimeout
。
for
循环的例子:
// 打印:4、5、6
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(++i), 0)
}
// 打印:1、2、3
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(++i), 0)
}
变量声明在函数内部提升至顶部的例子:
var foo = 1;
function bar () {
if (!foo) {
var foo = 10;
}
alert(foo);
}
// 会 alert 10
bar();
变量声明提升在作用域最顶部,其次是函数声明,最后是赋值语句:
var a = 1;
function b () {
a = 10;
return;
function a() {}
}
b();
// 会 alert 1
alert(a);
function a () {
var b = 1;
function b () {};
console.log(b);
}
// 输出 1
a();
function a () {
var b;
function b () {};
console.log(typeof b);
}
// 输出 'function'
a();
function a () {
function b () {};
var b;
console.log(typeof b);
}
// 也是输出 'function'
a();
function a () {
function b () {};
var b = 2;
console.log(b);
}
// 输出 2
a();
例子1
var msg = 'String A'
function test () {
// 弹出 `undefined`
alert(msg)
var msg = 'String A'
// 弹出 'String A'
alert(msg)
}
test()
// 上面的代码等价于下面的写法:
var msg = 'String A'
function test () {
var msg
alert(msg)
msg = 'String A'
alert(msg)
}
来一个复杂的例子:
写出下面代码 a
、b
、c
三行的输出分别是什么?
// mark A
function fun (n, o) {
console.log(o)
return {
// mark B
fun: function (m) {
// mark C
return fun(m, n)
}
}
}
var a = fun(0); a.fun(1); a.fun(2); a.fun(3);
var b = fun(0).fun(1).fun(2).fun(3);
var c = fun(0).fun(1); c.fun(2); c.fun(3);
// 答案:
// undefined, 0, 0, 0
// undefined, 0, 1, 2
// undefined, 0, 1, 1
首先,可以分析得到的结论:标记 A 下面的 fun
函数和标记 C 下面 return
的 fun
是同一个函数,标记 B 下面的 fun
属性对应的函数不同于标记 A 和标记 C 下方的函数。下文为了行文方便,将各个标记处下方的函数方便叫做 A、B、C 函数。
a 行的分析:
a = fun(0)
:即 a = fun (0) {console.log(undefined) return { // ... } }
,故输出 undefined
;a.fun(1)
:相当于给 B 函数传了一个参数 1,返回了 C
函数传参 (1, 0)
执行后的结果,即 A 函数传参 (1, 0)
后执行的结果,故输出 0;a.fun(2)
和 a.fun(2)
同上,因为一开始 a = fun(0)
已经将 n
的值定为 0 了,后面 console.log
出来的就都是0了;b 行的分析:
fun(0)
:毫无疑问输出 undefined
;fun(0).fun(1)
:参考 a 行的分析,可知这里输出的是 0;fun(0).fun(1).fun(2)
:类似的,输出 1;fun(0).fun(1).fun(2).fun(3)
:类似的,输出 2;c 行的分析:
fun(0).fun(1)
:参见上面的分析,输出 undefined
、0;c.fun(2)、c.fun(3)
:参见之前的分析,输出 1、1。class SimplePromise {
/**
* pending 初始状态,既不是成功,也不是失败状态。
* 等待 resolve 或者 reject 调用更新状态。
*/
static pending = 'pending'
/**
* fulfilled 意味着操作成功完成。
* 状态从 pending 转换为 fulfilled,只能由 resolve 方法完成转换
*/
static fulfilled = 'fulfilled'
/**
* rejected 意味着操作失败。
* 状态从 pending 转换为 rejected,只能由 reject 方法完成转换
*/
static rejected = 'rejected'
callbacks = []
constructor(executor) {
// 初始化状态为pending
this.status = SimplePromise.pending
// 存储 this._resolve 即操作成功 返回的值
this.value = undefined
// 存储 this._reject 即操作失败 返回的值
this.reason = undefined
/**
* 存储 `then` 中传入的参数
* 至于为什么是数组呢?
* 因为同一个 Promise 的 `then` 方法可以调用多次。
* 比如:
* ```javascript
* const p = new Promise(
* (resolve, reject) => resolve('3')
* );
* p.then(console.log);
* p.then(console.log);
* ```
* 上面后面的两句 `p.then(console.log)` 都会打印 '3',
* 都是基于 p 的结果 3 进行处理的(两个 `p.then` 互相无关)
*/
this.callbacks = [];
// 这里绑定 this 是为了防止执行时 this 的指向被改
executor(
this._resolve.bind(this),
this._reject.bind(this)
);
}
// onFulfilled 是成功时执行的函数
// onRejected 是失败时执行的函数
then(onFulfilled, onRejected) {
// 这里可以理解为在注册事件
// 也就是将需要执行的回调函数存储起来
this.callbacks.push({
onFulfilled,
onRejected,
});
}
_resolve(value) {
this.value = value
// 将状态设置为成功
this.status = SimplePromise.fulfilled
// 通知事件执行
this.callbacks.forEach((cb) => this._handler(cb))
}
_reject(reason) {
this.reason = reason
// 将状态设置为失败
this.status = SimplePromise.rejected
// 通知事件执行
this.callbacks.forEach((cb) => this._handler(cb))
}
_handler(callback) {
const { onFulfilled, onRejected } = callback;
if (
this.status === SimplePromise.fulfilled &&
onFulfilled
) {
// 传入存储的值
onFulfilled(this.value);
}
if (
this.status === SimplePromise.rejected &&
onRejected
) {
// 传入存储的错误信息
onRejected(this.reason);
}
}
}
要求下面打印出来1、3
const p = new ChainablePromise((resolve) => {
resolve(1)
})
p
.then((val) => {
console.log(val)
return 3
}, (reason) => {
console.log(reason)
})
.then((val) => {
console.log(val)
}, (reason) => {
console.log(reason)
})
实现方式
class ChainablePromise {
static pending = 'pending';
static fulfilled = 'fulfilled';
static rejected = 'rejected';
constructor(executor) {
// 初始化状态为pending
this.status = ChainablePromise.pending;
// 存储 this._resolve 即操作成功 返回的值
this.value = undefined;
// 存储 this._reject 即操作失败 返回的值
this.reason = undefined;
/**
* 存储 then 中传入的参数
* 至于为什么是数组呢?
* 因为同一个 Promise 的 then 方法可以调用多次
*/
this.callbacks = [];
executor(
this._resolve.bind(this),
this._reject.bind(this)
);
}
/**
* onFulfilled 是成功时执行的函数
* onRejected 是失败时执行的函数
*/
then(onFulfilled, onRejected) {
// 返回一个新的Promise
return new ChainablePromise((
nextResolve,
nextReject
) => {
/**
* 这里之所以把下一个 Promise 的 resolve 函数
* + 和 reject 函数也存在 callback 中
* 是为了将 onFulfilled 的执行结果
* 通过 nextResolve 传入到下一个 Promise
* + 作为它的 value 值
*/
this._handler({
nextResolve,
nextReject,
onFulfilled,
onRejected
});
});
}
_resolve(value) {
/**
* 处理 onFulfilled 执行结果是一个 Promise 时的情况
* 这里可能理解起来有点困难
* 当 value instaneof ChainablePromise 时,
* 说明当前 Promise 肯定不会是第一个 Promise
* 而是后续 then 方法返回的 Promise(第二个 Promise)
*
* 我们要获取的是 value 中的 value 值
* (
* 有点绕,value 是个 promise 时,
* 那么内部存有个 value 的变量
* )
*
* 怎样将 value 的 value 值获取到呢,
* 可以将传递一个函数作为 value.then 的 onFulfilled 参数
*
* 那么在 value 的内部则会执行这个函数,
* 我们只需要将当前 Promise 的 value 值
* + 赋值为 value 的 value 即可
*/
if (value instanceof ChainablePromise) {
value.then(
this._resolve.bind(this),
this._reject.bind(this)
);
return;
}
this.value = value;
// 将状态设置为成功
this.status = ChainablePromise.fulfilled;
// 通知事件执行
this.callbacks.forEach(cb => this._handler(cb));
}
_reject(reason) {
if (reason instanceof ChainablePromise) {
reason.then(
this._resolve.bind(this),
this._reject.bind(this)
);
return;
}
this.reason = reason;
// 将状态设置为失败
this.status = ChainablePromise.rejected;
this.callbacks.forEach(cb => this._handler(cb));
}
_handler(callback) {
const {
onFulfilled,
onRejected,
nextResolve,
nextReject
} = callback;
if (this.status === ChainablePromise.pending) {
this.callbacks.push(callback);
return;
}
if (this.status === ChainablePromise.fulfilled) {
// 传入存储的值
// 未传入 onFulfilled 时,传入 value
const nextValue = onFulfilled
? onFulfilled(this.value)
: this.value;
nextResolve(nextValue);
return;
}
if (this.status === ChainablePromise.rejected) {
// 传入存储的错误信息
// 同样的处理
const nextReason = onRejected
? onRejected(this.reason)
: this.reason;
nextReject(nextReason);
}
}
}
const p = new ChainablePromise((resolve) => {
resolve(1)
})
p
.then((val) => {
console.log(val)
return 3
}, (reason) => {
console.log(reason)
})
.then((val) => {
console.log(val)
}, (reason) => {
console.log(reason)
})
普通对象和函数对象
JS中,对象分普通对象和函数对象,Object、Function是JS自带的函数对象。凡是通过new Function()创建的对象都是函数对象,其他的都是普通对象。
typeof Object // "function", 函数对象
typeof Function // "function", 函数对象
function f1 () {}
var f2 = function () {}
var f3 = new Function('str', 'console.log(str)')
var o1 = new f1()
var o2 = {}
var o3 = new Object()
typeof f1 // "function", 函数对象
typeof f2 // "function", 函数对象
typeof f3 // "function", 函数对象
typeof o1 // "object", 普通对象
typeof o2 // "object", 普通对象
typeof o3 // "object", 普通对象
原型对象
每当定义一个对象(函数)时,对象中都会包含一些预定义的属性。
其中,函数对象会有一个 prototype
属性,就是我们所说的原型对象(普通对象没有 prototype
,但有 __proto__
属性;函数对象同时含有 prototype
和 __proto__
属性)。
原型对象其实就是普通对象(Function.prototype
除外,它是函数对象,但同时它又没有 prototype
属性)。
function f1 () {}
// Object{} with two properties: `constructor` and `__proto__`
console.log(f1.prototype)
// "object"
typeof f1.prototype
// 'function'
typeof Object.__proto__
// 特例,没必要记住,平常根本用不到
typeof Function.prototype // "function"
typeof Function.prototype.prototype // "undefined"
typeof Object.prototype // "object"
原型对象的主要作用是用于继承:
var Person = function (name) {
this.name = name
}
Person.prototype.getName = function () {
return this.name
}
var yakima = new Person('yakima')
yakima.getName() // "yakima"
// true
console.log(yakima.__proto__ === Person.prototype)
上面提到原型对象的主要作用是用于继承,其具体的实现就是通过原型链实现的。创建对象(不论是普通对象还是函数对象)时,都有一个叫做__proto__
的内置属性,用于指向创建它的函数对象的原型对象(即函数对象的prototype属性)
// true,对象的内置__proto__对象指向创建该对象的函数对象的prototype
yakima.__proto__ === Person.prototype
// true
Person.prototype.__proto__ === Object.prototype
// 继续,Object.prototype对象也有__proto__属性,但它比较特殊,为null
Object.prototype.__proto__ === null // true
typeof null // "object"
这个由__proto__
串起来的直到Object.prototype.__proto__
==> null对象的链称为原型链。
__proto__
属性指向Person.prototype对象;__proto__
属性指向Object.prototype对象;__proto__
属性指向null对象;原型和原型链是JS实现继承的一种模型。
看个例子
var Animal = function () {}
var Dog = function () {}
Animal.price = 2000
Dog.prototype = Animal
var tidy = new Dog()
console.log(Dog.price) // undefined
console.log(tidy.price) // 2000
对上例的分析:
__proto__
属性往上找,因为Dog赋值时的Dog = function () {}
其实使用new Function ()
创建的Dog,所以,Dog.__proto__
==> Function.prototype
, Function.prototype.__proto__
===> Object.prototype
,而Object.prototype.__proto__
==> null。很明显,整条链上都找不到price属性,只能返回undefined了;__proto__
属性往上找,因为tidy对象是Dog函数对象的实例,tidy.__proto__
==> Dog.prototype
==> Animal,从而tidy.price
获取到了Animal.price
的值。原型对象中都有个 constructor
属性,用来引用它的函数对象。这是一种循环引用。
Person.prototype.constructor === Person // true
Function.prototype.constructor === Function // true
Object.prototype.constructor === Object // true
要求
分为左、中、右三部分,高度均为屏幕高度,左边部分宽度为200px,另外两部分等分剩下的页面宽度。
实现
<html>
<head></head>
<body>
<div class="container">
<aside class="left">Left</aside>
<div class="wrapper">
<article class="middle">Middle</article>
<article class="right">Right</article>
</div>
</div>
</body>
</html>
.clearfix() {
&:after {
content: '';
clear: both;
display: block;
height: 0;
opacity: 0;
visibility: hidden;
}
}
html, body, div, aside, article {
margin: 0;
padding: 0;
}
html, body, .container, .left, .wrapper, .middle, .right {
height: 100%;
}
.container {
padding-left: 200px;
.clearfix();
.left {
float: left;
width: 200px;
margin-left: -200px;
background-color: skyblue;
}
.wrapper {
float: left;
width: 100%;
.middle, .right {
float: left;
width: 50%;
}
.middle {
background-color: gray;
}
.right {
background-color: yellow;
}
}
}
disabled
属性后字体颜色变淡 input[disabled] {
opacity: 1;
}
z-index
建议使用CSS预处理器语言的情况下,对所有涉及z-index的属性的值放在一个文件中统一进行管理。这个主意是从饿了么前端团队代码风格指南中看到的。另外补充一下,应该将同一条直系链里同一层级的元素的z-index分类到一起进行管理。因为不同层级或者非直系链里的同一层级的元素是无法直接根据z-index来判断元素前后排列顺序的。
方案 1:(flex
布局)
.parent {
display: flex;
align-items: center;
justify-content: center;
}
方法 2(使用 absolute
绝对定位)
.parent {
position: relative;
display: block;
.img {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
方法 3(使用 table-cell
)
.parent {
display: table-cell;
// width要写得大一点,以撑满容器之外部容器的宽度
width: 3000px;
text-align: center;
vertical-align: middle;
.img {
display: inline-block;
vertical-align: middle;
}
}
方法 4(如果父元素的高度为已知的定值,使用 line-height
实现)
.parent {
display: block;
text-align: center;
height: 300px;
line-height: 300px;
.img {
display: inline-block;
}
}
方法5(写死间距)
.parent {
display: block;
.img {
display: block;
height: 100px;
margin: 150px auto 0;
}
}
方案6(写死定位)
.parent {
position: relative;
display: block;
width: 600px;
height: 400px;
.img {
position: absolute;
width: 100px;
height: 300px;
top: 50px;
left: 250px;
}
}
方案7(撑开外部容器)
.parent {
// 包围内部元素
display: inline-block;
.img {
// 用来撑开父元素
padding: 30px 20px;
}
}
方案8(作为背景图)
.parent {
display: block;
height: 300px;
background:
transparent url('./example.png') scroll no-repeat center center;
background-size: 100px 200px;
}
justify-content
:
flex-start
:默认值,伸缩项目向一行的起始位置靠齐;flex-end
:伸缩项目向一行的结束位置靠齐;center
:项伸缩项目向一行的中间位置靠齐;space-between
:伸缩项目会平均地分布在行里。第一个伸缩项目一行中的最开始位置,最后一个伸缩项目在一行中最终点位置;space-around
:伸缩项目会平均地分布在行里,两端保留一半的空间;initial
:设置该属性为它的默认值;inherit
:从父元素继承该属性。align-items
:
stretch
:默认值,项目被拉伸以适应容器;center
:项目位于容器的中心;flex-start
:项目位于容器的开头;flex-end
:项目位于容器的结尾;baseline
:项目位于容器的基线上;initial
:设置该属性为它的默认值;inherit
:从父元素继承该属性。要求
使用 flexbox 布局将 9 个格子排列成 3*3 的九宫格,且第一列排完才排第二列。
<html>
<head></head>
<body>
<section class="boxes-wrapper">
<div class="box">1</div>
<div class="box">2</div>
<div class="box">3</div>
<div class="box">4</div>
<div class="box">5</div>
<div class="box">6</div>
<div class="box">7</div>
<div class="box">8</div>
<div class="box">9</div>
</section>
</body>
</html>
实现
body {
margin: 0;
}
.boxes-wrapper {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
flex-wrap: wrap;
gap: 10px;
width: 320px;
height: 320px;
.box {
background-color: aqua;
width: 100px;
height: 100px;
text-align: center;
line-height: 100px;
}
}
清除浮动使用 clear: left/right/both
。业界常用的 .clearfix
也是这么做的,只不过是把该样式写进了父元素的 :after
伪元素中,并加了 opacity: 0; display: block; height: 0; visibility: hidden;
等使伪元素不可见。
不清除浮动但包围浮动元素的方法有:
为浮动元素的父元素添加 overflow: hidden
、或将父元素也浮动起来等使父元素形成 BFC(Block Formatting Context) 的方式,但这些方式在应用上没有 .clearfix
这种方式理想。
position
属性各个值的区别 fixed
:类似 absolute
,但是是相对浏览器窗口而非网页页面进行定位。
absolute
:相对最近的 position
值非 static
的外层元素进行定位。
relative
:相对自身在文档流中的原始位置进行定位。
static
:position
默认值,即元素本身在文档流中的默认位置(忽略 top
、bottom
、left
、right
和 z-index
声明)。
inherit
:继承父元素 position
属性的值。
竖直方向上相接触的 margin-top
、margin-bottom
会塌陷:
CSS 动画会比 JS 动画的性能更好,JS 动画的优势主要在于:
当然,大部分业务中,主要还是使用 CSS 动画的,对低端浏览器进行降级就可以了(保证页面可读可操作就可以了,不建议增加老旧设备的性能负担)。
几个注意点:
transform: translate3d(x, y, z);
可借助 3D 变形开启 GPU 加速(这会消耗更多内存与功耗,确有性能问题时再考虑)。backface-visibility: hidden; perspective: 1000;
。box-shadows
和 gradients
这两页面性能杀手。translate
代替修改 top
/left
/bottom
/right
来实现动画效果,可以减少页面重绘(repaint),前者只触发页面的重组,而后者会额外触发页面的重排和重绘。本文参考:html+CSS+js解析全过程。
主流程
从浏览器请求html文件,到屏幕上出现渲染的实际像素等,可以分为以下部分:
解析HTML文件的细节
DOMContentLoaded 与 load 事件
关于阻塞/非阻塞
其他注意事项
<!-- 设定页面使用的字符集 -->
<meta charset="utf-8">
<meta
http-equiv="Content-Type"
content="text/html; charset=UTF-8"
>
<!-- 优先使用 IE 最新版本和 Chrome -->
<meta
http-equiv="X-UA-Compatible"
content="IE=edge,chrome=1"
>
<!-- 国产360浏览器默认采用高速模式渲染页面 -->
<meta name="renderer" content="webkit">
<meta
name="viewport"
content="
width=device-width,
initial-scale=1,
user-scalable=no
"
>
<!-- 禁止设备检测手机号和邮箱 -->
<meta
name="format-detection"
content="telephone=no,email=no"
>
<!-- QQ强制全屏 -->
<meta name="x5-fullscreen" content="true">
<!-- UC强制全屏 -->
<meta name="full-screen" content="yes">
<!-- uc强制竖屏 -->
<meta name="screen-orientation" content="portrait">
<!-- QQ强制竖屏 -->
<meta name="x5-orientation" content="portrait">
<!-- UC应用模式 -->
<meta name="browsermode" content="application">
<!-- QQ应用模式 -->
<meta name="x5-page-mode" content="app">
<!-- windows phone 点击无高光 -->
<meta name="msapplication-tap-highlight" content="no">
<!--
针对手持设备优化,主要是针对一些老的不识别viewport的浏览器,
比如黑莓
-->
<meta name="HandheldFriendly" content="true">
<!-- 设置 web 应用在 iOS 设备中是否启用全屏模式 -->
<meta name="apple-mobile-web-app-capable" content="yes">
<!--
设置 iOS 设备中 web 应用的状态栏样式。
只在用 `apple-apple-mobile-web-app-capable`
+ 开启全局模式后生效
-->
<meta
name="apple-mobile-web-app-status-bar-style"
content="black"
>
<!-- 启用/禁用 iOS Safari 浏览器中自动检测手机号的功能 -->
<meta name="format-detection" content="telephone=no">
<!--
用于设定禁止浏览器从本机的缓存中读取页面内容。
设定后一旦离开网页就无法从缓冲中再读取
-->
<meta http-equiv="pragma" content="no-cache">
<!-- 禁用缓存(再次访问需重新下载页面) -->
<meta http-equiv="cache-control" content="no-cache">
<!--
可以用于设定网页的到期时间。
一旦网页过期,必须到服务器上重新传输
-->
<meta http-equiv="expires" content="0">
<!-- 停留 2 秒钟后自动刷新到 URL 网址 -->
<meta
http-equiv="Refresh"
content="2;URL=http://www.example.com/"
>
<!-- 用于 SEO,其中 description 的内容应不超过 150 个字符 -->
<meta
http-equiv="keywords"
content="keyword1,keyword2,keyword3"
>
<meta
http-equiv="description"
content="This is my page"
>
<!--
强制页面在当前窗口以独立页面显示,
用来防止别人在 iframe 里调用自己的页面
-->
<meta http-equiv="Window-target" content="_top">
本文参考资料如下:
script 标签用于嵌入可执行脚本或数据,一般用于嵌入一段 JavaScript 脚本,或者指向一个 JavaScript 文件。script 标签也可用于其他语言,比如 WebGL 的 GLSL shader 编程语言脚本,或者 JSON 数据等。
用 script 标签载入数据
注意,如果用 script 标签来载入数据(而非脚本):
src
、async
、nomodule
、defer
、crossorigin
、integrity
、referrerpolicy
、fetchpriority
。比如这样是可以的:
<script id="data" type="application/json">
{"a": "123"}
</script>
<script>
const jsonData = JSON.stringify(
document.querySelector('#data').textContent
)
</script>
但是下面这样是不可以的:
<!-- 这样直接通过 src 属性去指向一个数据文件的方式是不可以的 -->
<script
id="data"
type="application/json"
src="https://bla.bla.com/blabla.json">
</script>
模板语言
script 标签有个特点是其中的内容不会直接展现在页面上,所以有很多前端模板语言会使用 script 标签来存放 html 模板。我们可以自己写个简单的,像下面这样:
<div class="userInfo"></div>
<script id="template" type="text/template">
<div>
<div class="name">{{ userName }}</div>
<div class="address">{{ userAddress }}</div>
</div>
</script>
<script type="application/javascript">
const templateContent = document
.querySelector('#template')
.textContent
const templateData = {
userName: 'name',
address: 'address'
}
// 一般模版语言的渲染函数实际干的内容类似下面这样
document
.querySelector('.userInfo')
.innerHTML = templateContent
.replace(/\{\{ userName \}\}/m, templateData.userName)
.replate(/\{\{ address \}\}/m, templateData.address)
</script>
<script defer>、<script async> 脚本的下载、解析动作与 HTML 解析的时序关系
从下图可以看书,除了 <script> 会暂停 HTML 的解析,其他比如 <script defer>、<script async>、<script type="module"> 脚本的下载与 HTML 的解析都是并行不会暂停 HTML 的解析的。
<script async>:
<script defer>:
书写原生js脚本将body下的第二个div隐藏
var oBody = document.getElementsByTagName('body')[0]
var oChildren = oBody.childNodes
var nDivCounter = 0
for (var i = 0, len = oChildren.length; i < len; i++) {
if (oChildren[i].nodeName === 'DIV') {
nDivCounter++
if (nDivCounter === 2) {
oChildren[i].style.display = 'none'
}
}
}
问题
现有:
<ul id="list" class="foo">
<li>#0</li>
<li><span>#1</span></li>
<li>#2</li>
<li>#3</li>
<li>
<ul>
<li>#4</li>
</ul>
</li>
<!-- ... -->
<li><a href="//v2ex.com">#99998</a></li>
<li>#99999</li>
<li>#100000</li>
</ul>
要求:
解答
// 还原题目真实DOM结构
var list = document.getElementById('list')
void function() {
var html = ''
for (var i = 0; i <= 10000; i++) {
if (i === 1) {
html += '<li><span>#1</span></li>'
} else if (i === 4) {
html += '<li><ul><li>#4</li></ul></li>'
} else if (i === 9998) {
html += '<li><a href="//v2ex.com">#9998</a></li>'
} else {
html += '<li>#' + i + '</li>'
}
}
list.innerHTML = html
}()
// or, list.className += ' bar'
list.classList.add('bar')
var li10 = document.querySelector('#list > li:nth-of-type(10)')
li10.parentNode.removeChild(li10)
var newItem = document.createElement('LI')
var textNode = document.createTextNode('<v2ex.com />')
newItem.appendChild(textNode)
// index for css nth-of-type is 1-based
var li501 = document.querySelector('#list > li:nth-of-type(501)')
list.insertBefore(newItem, li501)
list.addEventListener('click', function(e) {
var target = e.target || e.srcElement
if (target.id === 'list') {
alert('你点到最外层的ul上了,叫我怎么判断?')
return
}
while (target.nodeName !== 'LI') {
target = target.parentNode
}
var parentUl = target.parentNode
var children = parentUl.childNodes
var count = 0
for (var i = 0, len = children.length; i < len; i++) {
var node = children[i]
if (node.nodeName === 'LI') {
count++
}
if (node === target) {
alert('是当前第' + count + '项')
break
}
}
}, false)
// PS: if querySelector method is not available, the following can be changed.
var li10 = document.querySelector('#list > li:nth-of-type(10)')
var li501 = document.querySelector('#list > li:nth-of-type(501)')
// As below:
function getLiByIndex(index /* 0-based index */ ) {
var count = -1
for (var i = 0, len = list.childNodes.length; i < len; i++) {
if (list.childNodes[i].nodeName === 'LI') {
count++
if (count === index) {
return list.childNodes[i]
}
}
}
}
var li10 = getLiByIndex(9)
var li501 = getLiByIndex(500)
JS 中事件流的三个阶段:捕获(低版本 IE 不支持)==> 目标 ==> 冒泡。
如果不同层的元素使用 useCapture
不同,会先从最外层元素往目标元素寻找设定为 捕获(capture)模式的事件,到达目标元素后执行目标元素的事件后,在循原路往外寻找设定为冒泡(bubbling)模式的事件。
addEventListener
语法如下:
element.addEventListener(type, listener, useCapture)
element.addEventListener(type, listener, options)
element
: 要绑定事件的对象,或 HTML 节点;type
:事件名称(不带“on”),如 “click”、“mouseover”;listener
:要绑定的事件监听函数;useCapture
:事件监听方式,只能是 true
或 false
。true
,采用捕获(capture)模式;false
,采用冒泡(bubbling)模式。若无特殊要求,一般是 false
。options
options.capture
:一个布尔值,表示 listener
会在该类型的事件捕获阶段传播到该 EventTarget 时触发。options.once
:一个布尔值,表示 listener
在添加之后最多只调用一次。如果为 true
,listener
会在其被调用之后自动移除。options.passive
:一个布尔值,设置为 true
时,表示 listener
永远不会调用 preventDefault()
。如果 listener
仍然调用了这个函数,客户端将会忽略它并抛出一个控制台警告。addEventListener
addEventListener()
的工作原理是将实现 EventListener 的函数或对象添加到调用它的 EventTarget 上的指定事件类型的事件侦听器列表中。如果要绑定的函数或对象已经被添加到列表中,该函数或对象不会被再次添加。
addEventListener
允许对同一个 target 同时绑定多个事件,且可以控制是在冒泡阶段还是捕获阶段触发。onclick
、onmouseover
这种方式只能绑定一个事件监听回调(最后绑定的生效),且只能在冒泡阶段触发。
removeEventListener
removeEventListener
的入参和 addEventListener
一样。
警告:如果同一个事件监听器分别为“事件捕获(capture
为 true
)”和“事件冒泡(capture
为 false
)”各注册了一次,这两个版本的监听器需要分别移除。移除捕获监听器不会影响非捕获版本的相同监听器,反之亦然。
passive
改善滚屏性能 将 passive
设为 true
可以启用性能优化,并可大幅改善应用性能(副作用是不能 preventDefault
了),正如下面这个例子:
/* 检测浏览器是否支持该特性 */
let passiveIfSupported = false;
try {
window.addEventListener(
"test",
null,
Object.defineProperty({}, "passive", {
get() {
passiveIfSupported = { passive: true };
},
}),
);
} catch (err) {}
window.addEventListener(
"scroll",
(event) => {
/* do something */
// 不能使用 event.preventDefault();
},
passiveIfSupported,
);
根据规范,addEventListener()
的 passive
默认值始终为 false
。然而,这会导致触摸事件和滚轮事件(如 wheel
、mousewheel
、touchstart
、touchmove
)的事件监听器在浏览器尝试滚动页面时可能会阻塞浏览器主线程——这可能会大大降低浏览器处理页面滚动时的性能。
事件代理/委托,是靠事件的冒泡机制实现的(所以,对于一些不具有冒泡特性的事件,比如focus、blur,就没有事件代理/委托这种说法了)。
优缺点
优点有:
缺点有:
实现
// 只考虑IE 9&+
function delegate(element, targetSelector, type, handler) {
element.addEventListener(type, function(e) {
var targets = Array.prototype.slice.call(
element.querySelectorAll(targetSelector)
)
var target = e.target
if (targets.indexOf(target) !== -1) {
return handler.apply(target, arguments)
}
})
}
e = e || window.event
e.preventDefault()
e = e || window.event
e.stopPropagation()
stopImmediatePropagation
方法可阻止同元素或外层元素上相同事件上绑定的其他监听器函数被触发。
触发顺序
如果同类型事件的几个监听器函数被绑定到了同一个对象上,且它们处于相同的阶段(冒泡、捕获),它们会按照添加的顺序被触发。
stopPropagation
和 stopImmediatePropagation的区别
触发事件的对象,也就是用户实际操作(比如点击)的对象。
获取事件对象和目标对象:
function (e) {
e = e ? e : window.event
var target = e.target || e.srcElement
// do some things here
}
绑定事件的对象。对应的就是element.addEventListener(eventName, handler, options)里的element。
<html>
<body>
<div id="a">
<div id="b">
<div id="c">
<div id="d">最里层</div>
</div>
</div>
</div>
<script>
const a = document.querySelector('#a')
const b = document.querySelector('#b')
const c = document.querySelector('#c')
const d = document.querySelector('#d')
const getHandler = (elem, useCapture) => {
return (e) => {
const target = e.target
const currentTarget = e.currentTarget
const payload = {
elemId: elem.id,
useCapture,
targetId: target.id,
currentTargetId: currentTarget.id,
}
console.log(JSON.stringify(payload))
}
}
d.addEventListener('click', () => {
console.log('冒泡 d1')
}, { capture: false })
const elems = [a, b, c, d]
elems.forEach((elem) => {
elem.addEventListener('click', getHandler(elem, false), { capture: false })
elem.addEventListener('click', getHandler(elem, true), { capture: true })
})
d.addEventListener('click', () => {
console.log('捕获 d2')
}, { capture: true })
d.addEventListener('click', () => {
console.log('捕获 d3')
}, { capture: true })
d.addEventListener('click', () => {
console.log('冒泡 d4')
}, { capture: false })
</script>
</body>
</html>
点击#d元素,控制台打印内容如下:
{"elemId":"a","useCapture":true,"targetId":"d","currentTargetId":"a"}
{"elemId":"b","useCapture":true,"targetId":"d","currentTargetId":"b"}
{"elemId":"c","useCapture":true,"targetId":"d","currentTargetId":"c"}
{"elemId":"d","useCapture":true,"targetId":"d","currentTargetId":"d"}
捕获 d2
捕获 d3
冒泡 d1
{"elemId":"d","useCapture":false,"targetId":"d","currentTargetId":"d"}
冒泡 d4
{"elemId":"c","useCapture":false,"targetId":"d","currentTargetId":"c"}
{"elemId":"b","useCapture":false,"targetId":"d","currentTargetId":"b"}
{"elemId":"a","useCapture":false,"targetId":"d","currentTargetId":"a"}
可以看到:
innerHTML
、innerText
和 textContent
本文参考资料如下:
这几个元素都是 DOM 对象的属性,可以用来读取、更新 HTML 中元素的内容。
读取内容
假设现在有如下 HTML 代码片段:
<nav>
<a>Home</a>
<a>About</a>
<a>Contact</a>
<a style="display: none">Pricing</a>
</nav>
通过 document.querySelector('nav').innerHTML
获取到的内容如下:
<a>Home</a>
<a>About</a>
<a>Contact</a>
<a style="display: none">Pricing</a>
通过 document.querySelector('nav').innerText
获取到的内容如下(内容为渲染到屏幕上的内容,会忽略所有 HTML 标签,也会忽略被隐藏的元素):
Home About Contact
通过 document.querySelector('nav').textContent
获取到的内容如下(会忽略 HTML 标签,但不会忽略被隐藏的元素):
Home
About
Contact
Pricing
设置内容
假设现有如下 HTML 代码片段:
<h2>Programming languages</h2>
<ul class="languages-list"></ul>
使用 innerHTML
像下面这样更新内容,可以增加4个由 <li> 标签组成的列表:
const langListElement = document.querySelector('.languages-list')
// Setting or updating content with innerHTML
langListElement.innerHTML = `
<li>JavaScript</li>
<li>Python</li>
<li>PHP</li>
<li>Ruby</li>
`
使用 innerText
像下面这样更新内容,则会得到含有 <li>
字符(不是 HTML 标签)的 4 行文本:
const langListElement = document.querySelector('.languages-list')
langListElement.innerText = `
<li>JavaScript</li>
<li>Python</li>
<li>PHP</li>
<li>Ruby</li>
`
使用 textContent
像下面这样更新内容会直接得到一行文本(不是 4 行文本):
const langListElement = document.querySelector('.languages-list')
langListElement.textContent = `
<li>JavaScript</li>
<li>Python</li>
<li>PHP</li>
<li>Ruby</li>
`
响应式页面设计的原理是让页面根据浏览器屏幕宽度/视口宽度自适应,较理想地呈现出页面内容。
较常见的做法是使用CSS media query, 而且通常会在meta标签中对viewport的宽度等进行设定(比如设定width: device-width)。 但即便不用这种方法,只要页面能根据屏幕宽度做出自适应的调整,那就是响应式设计。
rem布局原理
function fit () {
// 750是设计稿的宽度
const scale = $('body').width() / 750
// 开发时,以100px对应1rem进行计算
document.querySelector('html').style.fontSize = 100 * scale + 'px'
}
$(document).ready(() => {
fit()
$(window).resize(fit)
})
在移动端使用click事件会产生300ms的延迟。
问题的产生:移动端存在双击放大的问题, 所以在移动端点击事件发生时,为了判断用户的行为(到底是要双击还是要点击),浏览器通常会等待300ms, 如果300ms之内,用户没有再次点击,则判定为点击事件,否则判定为双击缩放。
为什么要解决:现代web对性能的极致追求,对用户体验的高标准,让着300ms的卡顿变得难以接受
如何解决:
1、user-scalable:no 禁止缩放——没有缩放就不存在双击,也就没有点击延迟
2、指针事件:CSS:-ms-touch-action:none 点击后浏览器不会启用缩放操作,也就不存在延迟。然而这种方法兼容性很不好。
3、FastClick库:针对这个问题所开发的轻量级库。FastClick在检测到touchend事件后,会立即触发一个模拟的click事件,并把300ms后真正的click事件阻止掉
用法:
window.addEventListener('load', function () {
// 虽然可以绑定到更具体的元素,但绑定到body上能使整个应用都受益
FastClick.attach(document.body)
})
当FastClick检测到页面中使用了user-scalable:no或者touch-action:none方案时,会静默退出。
只需给document绑定touchstart或touchend事件即可, 如document.addEventListener('touchstart', function () {}, false)。
更简单的方法是直接在html中body标签上添加属性ontouchstart=""。
不让安卓手机识别邮箱:
<meta content="email=no" name="format-detection">
禁止IOS识别长串数字为电话:
<meta content="telephone=no" name="format-detection">
禁止iOS弹出各种操作窗口:-webkit-touch-callout: none;
禁止用户选中文字:-webkit-user-select: none;
<input
placeholder="占位符"
type="text"
onfocus="(this.type='date')"
onblur="(this.type='text')"
>
iOS 部分版本的 Date
构造函数不支持规范标准中定义的 YYYY-MM-DD
格式,如 new Date('2013-11-11')
是 Invalid Date
,但支持 YYYY/MM/DD
格式,可用 new Date('2013/11/11')
。最通用的还是直接年月日注入传入的方式,即 new Date(year, month, date)
。
类似的,对于 yyyy-mm-dd hh:mm:ss
格式的日期,可以通过类似下面的方法将其转换为 Date
对象实例(适用于所有设备):
// 将形如"yyyy-mm-dd hh:mm:ss"的日期字符串转换为日期对象(兼容IOS设备)
function longStringToDate (dateString) {
if (dateString && dateString.length === 19) {
// Attention: there is a space between regular expression
const tempArr = dateString.split(/[- :]/)
return new Date(
tempArr[0],
tempArr[1] - 1,
tempArr[2],
tempArr[3],
tempArr[4],
tempArr[5]
)
}
return 'Invalid Date'
}
请求报文:
响应报文:
nickname
属性提示
<form />
表单中使用,注意:上面有些说法严格来说也是不对的,因为 post 请求你可以在 url 上加 query 参数,服务端也能获取到这些参数。
两种方法除了自身的参数限制、缓存限制,通常情况下它们主要区别是:GET 不会产生副作用,而 POST 会。
当使用Keep-alive模式(又称持久连接,连接重用 http1.1的版本才支持)时,Keep-alive功能使客户端到服务端的连接持续有效,当出现服务器的后续请求时,Keep-alive避免了建立或者重新建立连接。
在使用持久连接的情况下,某个连接上的消息传递类似于:
请求1 --> 响应1 --> 请求2 --> 响应2 --> 请求3 --> 响应3
管线化的连接消息传递是类似于:
请求1 --> 请求2 --> 请求3 --> 响应1 --> 响应2 --> 响应3
相当于客户端一次性把所有的请求打包发送给服务端,同时服务端也一次性打包将所有的返回回传回来
只有GET和HEAD请求可以进行管线化,而POST有所限制
管线化是通过持久连接完成的,且只有http/1.1版本支持
注意:TCP并不能保证数据一定会被对方接收到,因为这是不可能的。TCP能够做到的是,如果有可能,就把数据递送到接收方,否则就(通过放弃重传并且中断连接这一手段)通知用户。因此准确说TCP也不是100%可靠的协议,它所能提供的是数据的可靠递送或故障的可靠通知。
所谓三次握手(Three-way Handshake),是指建立一个TCP连接时,需要客户端和服务器总共发送3个包。
三次握手的目的是连接服务器指定端口,建立TCP连接,并同步连接双方的序列号和确认号,交换TCP窗口大小信息。
在socket编程中,客户端执行connect()时。将触发三次握手。
TCP 的连接的拆除需要发送四个包,因此称为四次挥手(Four-way handshake),也叫做改进的三次握手。
客户端或服务器均可主动发起挥手动作,在socket编程中,任何一方执行close()操作即可产生挥手操作。
TCP/IP最重要的一个特点就是分层管理,分别为:
浏览器缓存控制分为强缓存和协商缓存,协商缓存必须配合强缓存使用。
浏览器第一次跟服务器请求一个资源后,服务器在返回这个资源的同时,会根据开发者代码的要求或者浏览器的默认要求,在 response header 里加上相关字段的响应头。
浏览器在请求已经访问过的URL时,会判断是否使用缓存,判断是否使用缓存主要是判断缓存是否在有效期内。
1.1、当浏览器对某个资源的请求命中了强缓存时,利用[Expires]或者[Cache-Control]这两个 http response header 实现
[Expires]描述的是一个绝对时间,依据的是客户端的时间,用GMT格式的字符串表示,如Expires:Thu,31 Dec 2037 23:55:55 GMT,下次浏览器再次请求同一资源时,会先从客户端缓存中查找,找到这个资源后拿出它的[Expires]与当前时间做比较,如果请求时间在[Expires]规定的有效期内就能命中缓存,这样就不用再次到服务器上去缓存一遍,节省了资源。但正因为是绝对时间,如果客户端的时间被随意篡改,这个机制就失效了,所以我们需要[Cache-Control]。
[Cache-Control]描述的是一个相对时间,在进行缓存命中时都是利用浏览器时间判断。
[Expires]和[Cache-Control]这两个header可以只启用一个,也可以同时启用,同时启用时[Cache-Control]优先级高于[Expires]。
1.2、当浏览器对某个资源的请求没有命中强缓存(即缓存过期后),就会发起一个请求到服务器,验证协商缓存是否命中(即验证缓存是否有更新,虽然有效期过了,但我还能继续使用它吗?),如果命中则还是从客户端中加载。协商缓存利用的是[Last-Modified,If-Modified-Since]和[ETag,If-None-Match]这两对header来管理的。
[Last-Modified,If-Modified-Since]:原理和上面的[Expires]相同,服务器会响应一个Last-Modified字段,表示最近一次修改缓存的时间,当缓存过期后, 浏览器就会把这个时间放在If-Modified-Since去请求服务器,判断缓存是否有更新。区别是它是根据服务器时间返回的header来判断缓存是否存在,但有时候也会出现服务器上资源有变化但修改时间没有变化的情况,这种情况我们就需要[ETag,If-None-Match]。
[ETag,If-None-Match]:原理与上面相同,区别是浏览器向服务器请求一个资源,服务器在返回这个资源的同时,在response的header中加上一个Etag字段,这个字段是服务器根据当前请求的资源生成的唯一标识字符串,只有资源有变化这个串就会发生改动。当缓存过期后,浏览器会把这个字符串放在If-None-Match去请求服务器,比较字符串的值判断是否有更新,Etag的优先级比Last-Modified的更高, Etag的出现是为了解决一个缓存文件在短时间内被多次修改的问题,因为Last-Modified只能精确到秒。
[ETag,If-None-Match]这么厉害我们为什么还需要[Last-Modified,If-Modified-Since]呢?有一个例子就是,分布式系统尽量关掉ETag,因为每台机器生成的ETag不一样,[Last-Modified,If-Modified-Since]和[ETag,If-None-Match]一般都是同时启用。
304:
强缓存是不会请求服务端的,命中协商缓存的话,一般服务端会返回状态码 304。
常用场景举例:
前端 SPA 应用发布后,为了保证客户端总能直接加载最新的静态资源文件,结合 nginx.conf
对缓存做出如下配置:
server {
listen 80;
server_name localhost;
root /usr/share/app;
index index.php index.html index.htm;
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源文件 缓存30天;
location ~ \.(css|js|gif|jpg|jpeg|png|bmp|swf|ttf|woff|otf|ttc|pfa)$ {
expires 30d;
}
# `html` 不缓存
location ~ \.(html|htm)$ {
add_header Cache-Control "no-store, no-cache, must-relalidate";
}
}
同时为 index.html
增加缓存相关的 <meta />
标签:
<meta http-equiv="cache-control" content="no-cache">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="expires" content="0">
303 状态码表示服务器要将浏览器重定向到另一个资源,这个资源的 URI 会被写在响应 Header 的 Location 字段。从语义上讲,重定向到的资源并不是你所请求的资源,而是对你所请求资源的一些描述。
303 常用于将 POST 请求重定向到 GET 请求,比如你上传了一份个人信息,服务器发回一个 303 响应,将你导向一个“上传成功”页面。
不管原请求是什么方法,重定向请求的方法都是 GET(或 HEAD,不常用)。
HTTP响应码 304 Not Modified 表明不需要传输所请求的资源。它会将请求重定向到已有的缓存资源上。
当请求是GET或者HEAD这种安全请求,会出现这种情况。
开发者本地开发时经常会看到很多304请求,它们实际就是去访问本地的缓存资源的。
当用户访问一个HTTPS网页时,他们与服务端之间的连接是经过TLS加密的,相对更安全。
如果HTTPS网页中包含的部分内容触发了明文HTTP请求,就会导致部分内容未被加密,也就不安全了。这种页面称为mixed content page。
不要使用物理地址作为url。比如http://www.acme.com/inventory/product003.xml
应更换为http://www.acme.com/inventory/product/003
。
不要返回过多的数据。比如,产品列表查询接口应返回前几条产品数据,而非全部的产品列表数据。数据较多时,可以考虑分页。
接口文档应书写清晰,不要随意改动,避免影响已有的客户端。
应返回实际地址而非让客户端根据id等自行去拼接。
GET请求不应导致服务端状态的变动。
本文参考了以下文章:
由于字符串、对象和数组没有固定大小,当他们的大小已知时,才能对他们进行动态的存储分配。JavaScript 程序每次创建字符串、数组或对象时,解释器都必须分配内存来存储那个实体。 只要像这样动态地分配了内存,最终都要释放这些内存以便他们能够被再用,否则,JavaScript 的解释器将会消耗完系统中所有可用的内存,造成系统崩溃。
在 JavaScript 中,数据类型分为基本类型和引用类型。
程序的内存分配
JavaScript 中调用栈中的数据回收
JavaScript 引擎会通过向下移动 ESP(记录当前执行状态的指针) 来销毁该函数保存在栈中的执行上下文。
JavaScript 堆中的数据回收
在 V8 中会把堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象。
新生区通常只支持 1~8M 的容量,而老生区支持的容量就大很多了。对于这两块区域,V8 分别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收。
不论什么类型的垃圾回收器,它们都有一套共同的执行流程:
内存碎片整理
内存回收次数多了以后,会有很多内存碎片,如果不加清理,会导致过载触发不必要的内存回收动作。清除非活动对象,前移活动对象时,对象的内存地址是汇编的,因为要通过移动把连续的空间腾出来。为了要保持活动对象的可访问,处理方式可能是记录变化前后的地址,然后在取对象内容时加上这个变化的偏移量。
全停顿
由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿。
在 V8 新生代的垃圾回收中,因其空间较小,且存活对象较少,所以全停顿的影响不大,但老生代就不一样了。如果执行垃圾回收的过程中,占用主线程时间过久,主线程是不能做其他事情的。比如页面正在执行一个 JavaScript 动画,因为垃圾回收器在工作,就会导致这个动画在垃圾回收过程中无法执行,这将会造成页面的卡顿现象。
为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记(Incremental Marking)算法.
使用增量标记算法,可以把一个完整的垃圾回收任务拆分为很多小的任务,这些小的任务执行时间比较短,可以穿插在其他的 JavaScript 任务中间执行,这样当执行上述动画效果时,就不会让用户因为垃圾回收任务而感受到页面的卡顿了。
现在各大浏览器通常用采用的垃圾回收有两种方法:标记清除、引用计数。
这是 JavaScript 中最常用的垃圾回收方式。当变量进入执行环境时,就标记这个变量为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到他们,除非变量离开了环境。当变量离开环境时,则将其标记为“离开环境”。
引用计数的含义是跟踪记录每个值被引用的次数。当声明了一个变量 a
并将一个引用类型值(比如 { a: 1 }
)赋值给该变量时,则这个值 { a: 1 }
的引用次数就是 1。 相反,如果包含对这个值的引用的变量(可以是直接引用,也可以间接引用,比如在 a
已经是上述所说的对象时, const b = a
或者 const b = { d: a }
中的 b
都是包含对这个值的引用的变量)又取得了另外一个值,则这个值 { a: 1 }
的被引用次数就减少了 1。
当这个值引用次数变成 0 时,则说明没有办法再访问这个值了,因而就可以将其所占的内存空间给收回来。
这样,垃圾收集器下次再运行时,它就会释放那些引用次数为 0 的值所占的内存。
该策略容易在循环引用时出现问题。因为计数记录的是被引用的次数,循环引用时计数并不会消除。导致无法释放内存。
如果要解决循环引用带来的内存泄露问题,我们只需要把循环引用中的变量设为 null 即可。将变量设置为 null 意味着切断变量与它此前引用的值之间的联系。当垃圾收集器下次运行时,就会删除这些值并回收他们占用的内存。
内存溢出一般是指执行程序时,程序会向系统申请一定大小的内存,当系统现在的实际内存少于需要的内存时,就会造成内存溢出。
内存溢出造成的结果是先前保存的数据会被覆盖或者后来的数据会没地方存。
不再用到的内存,没有及时释放,就叫做内存泄漏(memory leak)。内存泄漏是指程序执行时,一些变量没有及时释放,一直占用着内存。而这种占用内存的行为就叫做内存泄漏。
作为一般的用户,根本感觉不到内存泄漏的存在。真正有危害的是内存泄漏的堆积,这会最终消耗尽系统所有的内存。从这个角度来说,一次性内存泄漏并没有什么危害,因为它不会堆积。
内存泄漏如果一直堆积,最终会导致内存溢出问题。
除了常规设置了比较大的对象在全局变量中,还可能是意外导致的全局变量,如:
function foo(arg) {
bar = "this is a hidden global variable";
}
在函数中,没有使用 var
/let
/const
定义变量,这样实际上是定义在 window
全局对象上面,变成了 window.bar
。再比如由于 this
导致的全局变量:
function foo() {
this.bar = "this is a hidden global variable";
}
foo()
这种函数,在 window
作用域下被调用时,函数里面的 this
指向了 window
,执行时实际上为 window.bar = "this is a hidden global variable"
,这样也产生了全局变量。
先看如下代码:
var someData = getData();
setInterval(function() {
var node = document.getElementById('Node');
if (node) {
node.innerHTML = JSON.stringify(someData);
}
}, 1000);
这里定义了一个计时器,每隔 1s 把一些数据写到 Node
节点里面。但是当这个 Node
节点被删除后,这里的逻辑其实都不需要了,可是这样写,却导致了计时器里面的回调函数无法被回收,同时,someData
里的数据也是无法被回收的。
var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if (originalThing) console.log("hi");
};
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function () {
console.log(originalThing);
}
};
};
setInterval(replaceThing, 1000);
当这段代码反复运行,就会看到内存占用不断上升,垃圾回收器(GC)并无法降低内存占用。
内存泄露是指你「用不到」(访问不到)的变量,依然占据着内存空间,不能被再次利用起来。闭包里面的变量就是我们需要的变量,不能说是内存泄露。
闭包是一个非常强大的特性,但人们对其也有诸多误解。一种耸人听闻的说法是闭包会造成内存泄露,所以要尽量减少闭包的使用。
局部变量本来应该在函数退出的时候被解除引用,但如果局部变量被封闭在闭包形成的环境中,那么这个局部变量就能一直生存下去。从这个意义上看,闭包的确会使一些数据无法被及时销毁。使用闭包的一部分原因是我们选择主动把一些变量封闭在闭包中,因为可能在以后还需要使用这些变量,把这些变量放在闭包中和放在全局作用域,对内存方面的影响是一致的,这里并不能说成是内存泄露。如果在将来需要回收这些变量,我们可以手动把这些变量设为 null
。
跟闭包和内存泄露有关系的地方是,使用闭包的同时比较容易形成循环引用,如果闭包的作用域链中保存着一些 DOM 节点,这时候就有可能造成内存泄露。但这本身并非闭包的问题,也并非 JavaScript 的问题。在基于引用计数策略的垃圾回收机制中,如果两个对象之间形成了循环引用,那么这两个对象都无法被回收,但循环引用造成的内存泄露在本质上也不是闭包造成的。
同样,如果要解决循环引用带来的内存泄露问题,我们只需要把循环引用中的变量设为 null
即可。将变量设置为 null
意味着切断变量与它此前引用的值之间的连接。当垃圾收集器下次运行时,就会删除这些值并回收它们占用的内存。
利用 Chrome 浏览器性能面板记录可视化内存泄漏
Performance 面板的时间轴能够直观实时显示JS内存使用情况、节点数量、侦听器数量等。
打开 Chrome 浏览器,调出调试面板(DevTools),点击 Performance 选项(低版本是 Timeline),勾选 Memory 复选框。一种比较好的做法是使用强制垃圾回收开始和结束记录。在记录时点击 Collect garbage 按钮 (强制垃圾回收按钮) 可以强制进行垃圾回收。所以录制顺序可以这样:开始录制前先点击垃圾回收 --> 点击开始录制 --> 点击垃圾回收 --> 点击结束录制。面板介绍如图:
录制结果如图:
首先,从图中我们可以看出不同颜色的曲线代表的含义,这里主要关注 JS 堆内存、节点数量、侦听器数量。鼠标移到曲线上,可以在左下角显示具体数据。在实际使用过程中,如果您看到这种 JS 堆大小或节点大小不断增大的模式,则可能存在内存泄漏。
使用堆快照发现已分离 DOM 树的内存泄漏
只有页面的 DOM 树或 JavaScript 代码不再引用 DOM 节点时,DOM 节点才会被作为垃圾进行回收。如果某个节点已从 DOM 树移除,但某些 JavaScript 仍然引用它,我们称此节点为“已分离”,已分离的 DOM 节点是内存泄漏的常见原因。
同理,调出调试面板,点击 Memory,然后选择 Heap Snapshot,然后点击进行录制。录制完成后,选中录制结果,在 Class filter 文本框中键入 Detached
,搜索已分离的 DOM 树。以这段代码为例:
<html>
<head></head>
<body>
<button id="createBtn">增加节点</button>
<script>
function create() {
var ul = document.createElement('ul');
for (var i = 0; i < 10; i++) {
var li = document.createElement('li');
ul.appendChild(li);
}
detachedTree = ul;
}
document
.getElementById('createBtn')
.addEventListener('click', create);
</script>
</body>
</html>
点击几下,然后记录。可以得到以下信息:
如上图,点开节点,可以看到下面的引用信息,上面可以看出,有个 HTMLUListElement(ul 节点)被 window.detachedTree
引用。再结合代码,原来是没有加 var
/let
/const
声明,导致其成了全局变量,所以 DOM 无法释放。
WeakSet
和 WeakMap
结构,它们对于值的引用都是不计入垃圾回收机制的,表示这是弱引用。举个例子:const wm = new WeakMap();
const element = document.getElementById('example');
wm.set(element, 'some information');
// "some information"
wm.get(element)
这种情况下,一旦消除对该节点的引用,它占用的内存就会被垃圾回收机制释放。WeakMap
保存的这个键值对,也会自动消失。
基本上,如果你要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap
。
所谓响应式就是首先建立响应式数据和依赖之间的关系,当这些响应式数据发生变化的时候, 可以通知那些绑定这些数据的依赖进行相关操作,可以是 DOM 更新,也可以是执行一个回调函数。
我们知道 Vue2 的对象数据是通过 Object.defineProperty 对每个属性进行监听, 当对属性进行读取的时候,就会触发 getter,对属性进行设置的时候,就会触发 setter。
/**
* 这里的函数 defineReactive 用来对 Object.defineProperty 进行封装。
*/
function defineReactive(data, key, val) {
// 依赖存储的地方
const dep = new Dep()
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function () {
// 在 getter 中收集依赖
dep.depend()
return val
},
set: function(newVal) {
val = newVal
// 在 setter 中触发依赖
dep.notify()
}
})
}
那么是什么地方进行属性读取呢?就是在 Watcher 里面,Watcher 也就是所谓的依赖。 在 Watcher 里面读取数据的时候,会把自己设置到一个全局的变量中。
/**
* 我们所讲的依赖其实就是 Watcher,
* 我们要通知用到数据的地方,而使用这个数据的地方有很多,
* 类型也不一样,有可能是组件的,有可能是用户写的watch,
* 所以我们需要抽象出一个能集中处理这些情况的类。
*/
class Watcher {
constructor(vm, exp, cb) {
this.vm = vm
this.getter = exp
this.cb = cb
this.value = this.get()
}
get() {
Dep.target = this
let value = this.getter.call(this.vm, this.vm)
Dep.target = undefined
return value
}
update() {
const oldValue = this.value
this.value = this.get()
this.cb.call(this.vm, this.value, oldValue)
}
}
在 Watcher 读取数据的时候也就触发了这个属性的监听 getter, 在 getter 里面就需要进行依赖收集,这些依赖存储的地方就叫 Dep, 在 Dep 里面就可以把全局变量中的依赖进行收集,收集完毕就会把全局依赖变量设置为空。 将来数据发生变化的时候,就去 Dep 中把相关的 Watcher 拿出来执行一遍。
/**
* 我们把依赖收集的代码封装成一个 Dep 类,它专门帮助我们管理依赖。
* 使用这个类,我们可以收集依赖、删除依赖或者向依赖发送通知等。
**/
class Dep {
constructor() {
this.subs = []
}
addSub(sub) {
this.subs.push(sub)
}
removeSub(sub) {
remove(this.subs, sub)
}
depend() {
if(Dep.target){
this.addSub(Dep.target)
}
}
notify() {
const subs = this.subs.slice()
for(let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}
// 删除依赖
function remove(arr, item) {
if(arr.length) {
const index = arr.indexOf(item)
if(index > -1){
return arr.splice(index, 1)
}
}
}
总的来说就是通过 Object.defineProperty 监听对象的每一个属性,当读取数据时会触发 getter,修改数据时会触发 setter。
然后我们在 getter 中进行依赖收集,当 setter 被触发的时候,就去把在 getter 中收集到的依赖拿出来进行相关操作,通常是执行一个回调函数。
我们收集依赖需要进行存储,对此 Vue2 中设置了一个 Dep 类,相当于一个管家,负责添加或删除相关的依赖和通知相关的依赖进行相关操作。
在 Vue2 中所谓的依赖就是 Watcher。 值得注意的是,只有 Watcher 触发的 getter 才会进行依赖收集,哪个 Watcher 触发了 getter,就把哪个 Watcher 收集到 Dep 中。 当响应式数据发生改变的时候,就会把收集到的 Watcher 都进行通知。
由于 Object.defineProperty 无法监听对象的变化, 所以 Vue2 中设置了一个 Observer 类来管理对象的响应式依赖,同时也会递归侦测对象中子数据的变化。
这是因为 Object.defineProperty 只会对属性进行监测,而不会对对象进行监测, 为了可以监测对象 Vue2 创建了一个 Observer 类。 Observer 类的作用就是把一个对象全部转换成响应式对象,包括子属性数据, 当对象新增或删除属性的时候负责通知对应的 Watcher 进行更新操作。
// 定义一个属性
function def(obj, key, val, enumerable) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true
})
}
class Observer {
constructor(value) {
this.value = value
// 添加一个对象依赖收集的选项
this.dep = new Dep()
// 给响应式对象添加 __ob__ 属性,表明这是一个响应式对象
def(value, '__ob__', this)
if(Array.isArray(value)) {
} else {
this.walk(value)
}
}
walk(obj) {
const keys = Object.keys(obj)
// 遍历对象的属性进行响应式设置
for(let i = 0; i < keys.length; i ++) {
defineReactive(obj, keys[i], obj[keys[i]])
}
}
}
function set(target, key, val) {
const ob = target.__ob__
defineReactive(ob.value, key, val)
ob.dep.notify()
return val
}
当向一个响应式对象新增属性的时候,需要对这个属性重新进行响应式的设置, 即使用 defineReactive 将新增的属性转换成 getter/setter。
我们在前面讲过每一个对象是会通过 Observer 类型进行包装的, 并在 Observer 类里面创建一个属于这个对象的依赖收集存储对象 dep, 最后在新增属性的时候就通过这个依赖对象进行通知相关 Watcher 进行变化更新。
function del(target, key) {
const ob = target.__ob__
delete target[key]
ob.dep.notify()
}
我们可以看到 vm.$delete 的实现原理和 vm.$set 的实现原理是非常相似的。
通过 vm.$delete 和 vm.$set 的实现原理,我们可以更加清晰地理解到 Observer 类的作用, Observer 类就是给一个对象也进行一个监测,因为 Object.defineProperty 是无法实现对对象的监测的, 但这个监测是手动,不是自动的。 获得授权,非商业转载请注明出处。
面试官一上来可能先问你 Vue2 中数组的响应式原理是怎么样的,这个问题你也许会觉得很容易回答, Vue2 对数组的监测是通过重写数组原型上的 7 个方法来实现,然后你会说具体的实现, 接下来面试官可能会问你,为什么要改写数组原型上的 7 个方法,而不使用 Object.defineProperty, 是因为 Object.defineProperty 真的不能监听数组的变化吗?
其实 Object.defineProperty 是可以监听数组的变化的。
const arr = [1, 2, 3]
arr.forEach((val, index) => {
Object.defineProperty(arr, index, {
get() {
console.log('监听到了')
return val
},
set(newVal) {
console.log('变化了:', val, newVal)
val = newVal
}
})
})
其实数组就是一个特殊的对象,它的下标就可以看作是它的 key。
所以 Object.defineProperty 也能监听数组变化,那么为什么 Vue2 弃用了这个方案呢?
这种直接通过下标获取数组元素的场景就并不少,但是即便可以通过 Object.defineProperty
对数组已有的数字下标 key 进行监听,但 Object.defineProperty
方案监听不了 push
、pop
、shift
等对数组进行操作的方法,而且如果数组元素较多的话,监听每个下标带来的内存占用也会比较多。
所以还是需要通过对数组原型上的那 7 个方法进行重写监听(这比对每个数组的所有下标进行监听要省内存多了)。所以为了性能考虑 Vue2 直接弃用了使用 Object.defineProperty
对数组进行监听的方案。
通过上文我们知道如果使用 Object.defineProperty 对数组进行监听, 当通过 Array 原型上的方法改变数组内容的时候是无发触发 getter/setter 的, Vue2 中是放弃了使用 Object.defineProperty 对数组进行监听的方案, 而是通过对数组原型上的 7 个方法进行重写进行监听的。
原理就是使用拦截器覆盖 Array.prototype,之后再去使用 Array 原型上的方法的时候, 其实使用的是拦截器提供的方法,在拦截器里面才真正使用原生 Array 原型上的方法去操作数组。
拦截器
// 拦截器其实就是一个和 Array.prototype 一样的对象。
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
;[
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
].forEach(function (method) {
// 缓存原始方法
const original = arrayProto[method]
Object.defineProperty(arrayMethods, method, {
value: function mutator(...args) {
// 最终还是使用原生的 Array 原型方法去操作数组
return original.apply(this, args)
},
eumerable: false,
writable: false,
configurable: true
})
})
所以通过拦截器之后,我们就可以追踪到数组的变化了,然后就可以在拦截器里面进行依赖收集和触发依赖了。
接下来我们就使用拦截器覆盖那些进行了响应式处理的 Array 原型,数组也是一个对象, 通过上文我们可以知道 Vue2 是在 Observer 类里面对对象的进行响应式处理,并且给对象也进行一个依赖收集。 所以对数组的依赖处理也是在 Observer 类里面。
class Observer {
constructor(value) {
this.value = value
// 添加一个对象依赖收集的选项
this.dep = new Dep()
// 给响应式对象添加 __ob__ 属性,表明这是一个响应式对象
def(value, '__ob__', this)
// 如果是数组则通过覆盖数组的原型方法进行拦截操作
if(Array.isArray(value)) {
value.__proto__ = arrayMethods
} else {
this.walk(value)
}
}
// ...
}
在这个地方 Vue2 会进行一些兼容性的处理, 如果能使用 __proto__
就覆盖原型, 如果不能使用,则直接把那 7 个操作数组的方法直接挂载到需要被进行响应式处理的数组上, 因为当访问一个对象的方法时,只有这个对象自身不存在这个方法,才会去它的原型上查找这个方法。
数组如何收集依赖呢?
我们知道在数组进行响应式初始化的时候会在 Observer
类里面给这个数组对象的添加一个 __ob__
的属性, 这个属性的值就是 Observer 这个类的实例对象,而这个 Observer
类里面有存在一个收集依赖的属性 dep, 所以在对数组里的内容通过那 7 个方法进行操作的时候,会触发数组的拦截器, 那么在拦截器里面就可以访问到这个数组的 Observer
类的实例对象,从而可以向这些数组的依赖发送变更通知。
// 拦截器其实就是一个和 Array.prototype 一样的对象。
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
;[
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
].forEach(function (method) {
// 缓存原始方法
const original = arrayProto[method]
Object.defineProperty(arrayMethods, method, {
value: function mutator(...args) {
// 最终还是使用原生的 Array 原型方法去操作数组
const result = original.apply(this, args)
// 获取 Observer 对象实例
const ob = this.__ob__
// 通过 Observer 对象实例上 Dep 实例对象去通知依赖进行更新
ob.dep.notify()
},
eumerable: false,
writable: false,
configurable: true
})
})
因为 Vue2 的实现方法决定了在 Vue2 中对数组的一些操作无法实现响应式操作,例如:
this.list[0] = xxx
由于 Vue2 放弃了 Object.defineProperty 对数组进行监听的方案,所以通过下标操作数组是无法实现响应式操作的。
又例如:
this.list.length = 0
这个动作在 Vue2 中也是无法实现响应式操作的。
其实不管是对象还是数组的依赖都是在 getter 中进行依赖收集的。
例如:
{ list: [1,2,3,4] }
你要获取到 list 数组的内容,首先是通过 list 这个 key 进行获取的, 所以当通过 list 这个 key 进行获取数组内容的时候,就触发了 list 这个 key 的 getter。
在 getter 中会进行 key 的依赖收集,收集到的依赖保存在对应 key 的 Dep 对象中, 同时也会判断 key 的值,如果是一个对象,还会对这个对象进行依赖收集, 收集到的依赖则保存在这个对象的 __ob__
属性对象上的 Dep 上, 而这个 __ob__
属性对象就是上文中提到的 Observer 类, 在 Observer 类中也有一个 Dep 属性用于专门保存响应式对象的依赖的。 这样无论是对象还是数组数据都可以通过 __ob__
属性拿到 Observer 实例, 然后拿到 Observer 实例中的 dep。然后就可以进行相关的响应式依赖通知操作了。
要求
<div id="app">
<h3>数据的双向绑定</h3>
<div class="cell">
<div class="text" v-text="myHello"></div>
<input class="input" type="text" v-model="myHello" >
<div class="text" v-text="myWorld"></div>
<input class="input" type="text" v-model="myWorld" >
</div>
</div>
要求实现一个SimpleVue类,使得执行下面的代码可以实现双向数据绑定效果。
// 创建SimpleVue实例
const app = new SimpleVue({
el : '#app' ,
data : {
myHello : 'hello',
myWorld : 'world',
}
})
实现
class SimpleVue {
constructor (options){
// 传入的配置参数
this.options = options;
// 根元素
this.$el = document.querySelector(options.el);
// 数据域
this.$data = options.data;
/**
* 保存数据model与view相关的指令,
* 当model改变时,我们会触发其中的指令类更新,保证view也能实时更新
*/
this._directives = {};
// 数据劫持,重新定义数据的 set 和 get 方法
this._obverse(this.$data);
/**
* 解析器,解析模板指令,
* 并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,
* 一旦数据有变动,收到通知,更新视图
*/
this._compile(this.$el);
}
// _obverse 函数,对data进行处理,重写data的set和get函数
_obverse(data) {
// 遍历数据
for (const key in data) {
// 判断是不是属于自己本身的属性
if (!data.hasOwnProperty(key)) {
continue
}
/**
* 这里的key是一种简单处理,
* 其实在后续递归调用this._obverse时,是可能会出现重复的key的
*/
this._directives[key] = [];
let val = data[key];
// 如果是对象,则继续递归遍历(这里简化了,只考虑对象这种单一场景)
if (typeof val === 'object') {
this._obverse(val);
}
// 初始当前数据的执行队列
let _dir = this._directives[key];
// 重新定义数据的 get 和 set 方法
Object.defineProperty(this.$data, key, {
enumerable: true,
configurable: true,
get: function () {
return val;
},
set: function (newVal) {
if (val !== newVal) {
val = newVal;
/**
* 当 myHello等指令值改变时,
* 触发 _directives 中的绑定的Watcher类的更新
*/
_dir.forEach((item) => {
//调用自身指令的更新操作
item._update();
})
}
}
})
}
}
/**
* 接着我们来看看 _compile 这个方法,它实际上是一个解析器,
* 其功能就是解析模板指令,并将每个指令对应的节点绑定更新函数,
* 添加监听数据的订阅者,一旦数据有变动,就收到通知,
* 然后去更新视图变化,具体实现如下:
*
* 我们从根元素 #app 开始递归遍历每个节点,
* 并判断每个节点是否有对应的指令,这里我们只针对 v-text 和 v-model,
* 我们对 v-text 进行了一次 new Watcher(),
* 并把它放到了 myHello 的指令集里面,对 v-model 也进行了解析,
* 对其所在的 input 绑定了 input 事件,并将其通过 new Watcher() 与 myHello 关联起来
*/
_compile(el){
// 子元素
const nodes = el.children;
for (let i = 0, len = nodes.length; i < len; i++){
const node = nodes[i];
// 递归:对所有子元素进行遍历,并进行处理
if (node.children.length) {
this._compile(node);
}
// 如果有 v-text 指令 , 监控 node的值 并及时更新
if (node.hasAttribute('v-text')) {
const attrValue = node.getAttribute('v-text');
// 将指令对应的执行方法放入指令集
this._directives[attrValue].push(
new Watcher('text', node, this, attrValue, 'innerHTML')
)
}
/**
* 如果有 v-model属性,
* 并且元素是INPUT或者TEXTAREA,
* 我们监听它的input事件
*/
if (
node.hasAttribute('v-model') &&
['INPUT', 'TEXTAREA'].includes(node.tagName)
){
const _this = this;
const attrValue = node.getAttribute('v-model');
// 初始化赋值
_this._directives[attrValue].push(
new Watcher('input', node, _this, attrValue, 'value')
);
// 添加input事件监听
node.addEventListener('input', () => {
// 后面每次都会更新
_this.$data[attrValue] = node.value;
}, { capture: false })
}
}
}
}
/**
* Watcher 其实就是订阅者,
* 是 _observer 和 _compile 之间通信的桥梁,
* 用来绑定更新函数,
* 实现对 DOM 元素的更新。
*
* 每次创建 Watcher 的实例,都会传入相应的参数,
* 也会进行一次 _update 操作,上述的 _compile 中,
* 我们创建了两个 Watcher 实例,不过这两个对应的 _update 操作不同而已,
* 对于 div.text 的操作其实相当于 div.innerHTML=h3.innerHTML = this.data.myHello,
* 对于 input 相当于 input.value=this.data.myHello,
* 这样每次数据 set 的时候,我们会触发两个 _update 操作,
* 分别更新 div 和 input 中的内容~
*/
class Watcher {
/*
* name 指令名称,例如文本节点"text"、输入框'input'
* el 指令对应的DOM元素
* vm 指令所属SimpleVue实例
* exp 指令对应的值,如'myHello'
* attr 绑定的属性值,本例为"innerHTML"
* */
constructor (name, el, vm, exp, attr){
this.name = name;
this.el = el;
this.vm = vm;
this.exp = exp;
this.attr = attr;
// 更新操作
this._update();
}
_update(){
this.el[this.attr] = this.vm.$data[this.exp];
}
}
Vue2 是通过 Object.defineProperty 将对象的属性转换成 getter/setter 的形式来进行监听它们的变化, 当读取属性值的时候会触发 getter 进行依赖收集, 当设置对象属性值的时候会触发 setter 进行向相关依赖发送通知,从而进行相关操作。
由于 Object.defineProperty 只对属性 key 进行监听,无法对引用对象进行监听, 所以在 Vue2 中创建一个了 Observer 类对整个对象的依赖进行管理, 当对响应式对象进行新增或者删除则由响应式对象中的 dep 通知相关依赖进行更新操作。
Object.defineProperty 也可以实现对数组的监听的,但因为性能的原因 Vue2 放弃了这种方案, 改由重写数组原型对象上的 7 个能操作数组内容的变更的方法,从而实现对数组的响应式监听。
假设我们的真实dom是:
<ul id="container">
<li class="box" :key="user1">张三</li>
<li class="box" :key="user2">李四</li>
</ul>
那么他对应的VNode就是:
const oldVNode = {
tag: "ul",
data: {
staticClass: "container",
},
text: undefined,
children: [
{
tag: "li",
data: { staticClass: "box", key: "user1" },
text: undefined,
children: [
{ tag: undefined, data: undefined, text: "张三", children: undefined },
],
},
{
tag: "li",
data: { staticClass: "box", key: "user2" },
text: undefined,
children: [
{ tag: undefined, data: undefined, text: "李四", children: undefined },
],
},
],
};
这时候修改一个li标签的内容:
<ul id="container">
<li class="box" :key="user1">张三123123123</li>
<li class="box" :key="user2">李四</li>
</ul>
对应的虚拟dom就变成:
const newVNode = {
tag: "ul",
data: {
staticClass: "container",
},
text: undefined,
children: [
{
tag: "li",
data: { staticClass: "box", key: "user1" },
text: undefined,
children: [
{ tag: undefined, data: undefined, text: "张三123123123", children: undefined },
],
},
{
tag: "li",
data: { staticClass: "box", key: "user2" },
text: undefined,
children: [
{ tag: undefined, data: undefined, text: "李四", children: undefined },
],
},
],
};
用一句话来概括就是:同层比较、深度优先。
当我们 this.key = xxx
时,触发当前 key
的 setter
,并通过内部 dep.notify()
通知所有 watcher
进行更新,更新的时候就会调用 `patch 方法。
这个函数的作用就是:通过 sameVnode()
判断 oldVnode
、newVnode
是否为同一种节点类型。
patchVnode()
进行 diff 算法。patch 的核心代码:
function patch(oldVnode, newVnode) {
const isRealElement = isDef(oldVnode.nodeType) //判断oldVnode是否是真实节点
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// 更新周期走这里,diff发生的地方
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
// 如果是真实dom,就转换为Vnode,赋值给oldVnode
if (isRealElement) {
oldVnode = emptyNodeAt(oldVnode)
}
// replacing existing element
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm) // 得到真实dom的父节点
// 将oldVnode转换为真实dom,并插入
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0) // 删除老的节点
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
}
这个方法主要是用来比较传入的俩个vnode是否是相同节点。判断条件见如下代码:
function sameVnode (a, b) {
return (
a.key === b.key && // 比较key
a.asyncFactory === b.asyncFactory && (
(
a.tag === b.tag && // 比较标签
a.isComment === b.isComment && // 比较注释
isDef(a.data) === isDef(b.data) && // 比较data
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
isUndef(b.asyncFactory.error)
)
)
)
}
主要作用:比较俩个Vnode,包括三种类型操作:属性更新 、文本更新、子节点更新。
具体规则如下:
patchVnode
核心代码:
function patchVnode (oldVnode, vnode,) {
if (oldVnode === vnode) {
return
} // 如果新节点等于老节点直接返回
const elm = vnode.elm = oldVnode.elm // 将oldVnode的真实dom节点赋值给Vnode
// 获取新旧节点的子节点数组
const oldCh = oldVnode.children
const ch = vnode.children
// 如果新节点没文本,大概率有子元素
if (isUndef(vnode.text)) {
// 如果双方都有子元素
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
}
// 如果新节点有子元素
else if (isDef(ch)) {
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
}
// 如果老节点有子元素(走到这一步说明新节点没有子元素)
else if (isDef(oldCh)) {
removeVnodes(oldCh, 0, oldCh.length - 1)
}
// 如果老节点有文本(走到这一步说明新节点没有文本)
else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
}
// 如果老节点的文本 != 新节点的文本
else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
}
上面的代码验证了我们上面说的四点规则,其中最主要的还是新旧节点都有子元素的时候的对比,也就是 updateChildren
。
这个方式是 patchVnode
中的一个重要方法,也叫重排操作。主要进行新旧虚拟节点的子节点的对比,等通过 sameVnode()
找到相同节点时,再递归调用 patchVnode
。
对比过程:
如果以上都匹配不到,再以新 vnode
为准,依次遍历老节点,直到找到相同的节点之后,再调用 patchVnode
。
备注:过程1~4你可以理解为Vue优化的一种手段,想想你平时使用Vue场景,要么在开头或结尾插入,要么只是单纯的修改某个值(this.key = xxx),Vue考虑到了这种场景可能出现的频率很高,索性就做了这个优化,避免每次重复遍历,这样对性能提升很大。
接下来用一个实际例子,来看一下 diff 过程。
描述:真实 DOM 和 oldVnode 是内容分别为 a、b、c 的 div,新的虚拟 dom 只是改变了原来节点的内容(新a、新b、新c)以及新增了一个内容为 新d 的 div,别的没有任何变化。需要注意的是每次比较都遵循上面的规则。
初始值:
**第一步:oldVnode[oSIdx] === newVnode[nSIdx]
**
描述:按照规则,先 旧首 => 新首,sameVnode(a,b)
结果为 true
,说明是相同节点。需要做的就是调用patchVnode(oldVnode,vNode)
更新节点的内容,之后 oSIdx++
、nSIdx++
。
第二步:oldVnode[oSIdx] === newVnode[nSIdx](注意:此时oSIdx为1,nSIdx为1)
描述:此时分别比较oldVnode、newVnode对应的第二个节点,因为是循环,所以依然是重新执行规则 旧首 => 新首(下面的源码里面可以看到对应逻辑),sameVnode(a,b)结果为true,所以依然是调用patchVnode(oldVnode,vNode)更新节点内容。之后oSIdx++、nSIdx++。
第三步:oldVnode[oSIdx] === newVnode[nSIdx]//注意:此时oSIdx为2,nSIdx为2
描述:这一步跟前两步一样,这里不做过多描述。之后oSIdx++、nSIdx++。
第四步
oSIdx = 3 oEIdx = 2
nSIdx = 3 nEIdx = 3
描述:因为此时oSIdx>oEIdx、nSIdx===nEIdx(按照源码的逻辑,结束while循环),说明oldCh先遍历完,所以newCh比oldCh多,说明是新增操作,执行addVnodes(),将新节点插入到dom中。
附录: updateChildren核心源码,以及注释:
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0 // oldVnode开始下标
let oldEndIdx = oldCh.length - 1 // oldVnode结尾下标
let oldStartVnode = oldCh[0] // oldVnode第一个节点
let oldEndVnode = oldCh[oldEndIdx] // oldVnode最后一个节点
let newStartIdx = 0 // newVnode开始下标
let newEndIdx = newCh.length - 1 // newVnode结尾下标
let newStartVnode = newCh[0] // newVnode第一个节点
let newEndVnode = newCh[newEndIdx] // newVnode最后一个节点
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
const canMove = !removeOnly
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(newCh)
}
// 注意循环条件,只有oldVnode和newVnode的开始节点小于等于的时候才会循环
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // 这一步就是额外操作,如果oldStartVnode取不到元素,就向后移
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx] // 这一步就是额外操作,如果oldEndVnode取不到元素,就向后移
}
// 真正开始执行diff
else if (sameVnode(oldStartVnode, newStartVnode)) {
// 旧首新首比较
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 旧尾新尾比较
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// 旧首新尾比较
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// 旧尾新首比较
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 以上都不匹配执行下面的逻辑
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
// 循环结束后,判断是oldCh多,还是newCh多
// 如果oldCh多 newCh少 就是删除
// 如果oldCh少 newCh多 就是创建
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
}
Vue3 是通过 Proxy 对数据实现 getter/setter 代理,从而实现响应式数据, 然后在副作用函数中读取响应式数据的时候,就会触发 Proxy 的 getter, 在 getter 里面把对当前的副作用函数保存起来, 将来对应响应式数据发生更改的话,则把之前保存起来的副作用函数取出来执行。
具体是副作用函数里面读取响应式对象的属性值时, 会触发代理对象的 getter,然后在 getter 里面进行一定规则的依赖收集保存操作。
简单代码实现:
// 使用一个全局变量存储被注册的副作用函数
let activeEffect
// 注册副作用函数
function effect(fn) {
activeEffect = fn
fn()
}
const obj = new Proxy(data, {
// getter 拦截读取操作
get(target, key) {
// 将副作用函数 activeEffect 添加到存储副作用函数的全局变量 targetMap 中
track(target, key)
// 返回读取的属性值
return Reflect.get(target, key)
},
// setter 拦截设置操作
set(target, key, val) {
// 设置属性值
const result = Reflect.set(target, key, val)
// 把之前存储的副作用函数取出来并执行
trigger(target, key)
return result
}
})
// 存储副作用函数的全局变量
const targetMap = new WeakMap()
// 在 getter 拦截器内追踪依赖的变化
function track(target, key) {
// 没有 activeEffect,直接返回
if(!activeEffect) return
// 根据 target 从全局变量 targetMap 中获取 depsMap
let depsMap = targetMap.get(target)
if(!depsMap) {
// 如果 depsMap 不存,那么需要新建一个 Map 并且与 target 关联
depsMap = new Map()
targetMap.set(target, depsMap)
}
// 再根据 key 从 depsMap 中取得 deps, deps 里面存储的是所有与当前 key 相关联的副作用函数
let deps = depsMap.get(key)
if(!deps) {
// 如果 deps 不存在,那么需要新建一个 Set 并且与 key 关联
deps = new Set()
depsMap.set(key, deps)
}
// 将当前的活动的副作用函数保存起来
deps.add(activeEffect)
}
// 在 setter 拦截器中触发相关依赖
function trgger(target, key) {
// 根据 target 从全局变量 targetMap 中取出 depsMap
const depsMap = targetMap.get(target)
if(!depsMap) return
// 根据 key 取出相关联的所有副作用函数
const effects = depsMap.get(key)
// 执行所有的副作用函数
effects && effects.forEach(fn => fn())
}
通过上面的代码我们可以知道 Vue3 中依赖收集的规则, 首先把响应式对象作为 key,一个 Map 的实例做为值方式存储在一个 WeakMap 的实例中, 其中这个 Map 的实例又是以响应式对象的 key 作为 key, 值为一个 Set 的实例为值。 而且这个 Set 的实例中存储的则是跟那个响应式对象 key 相关的副作用函数。
那么为什么 Vue3 的依赖收集的数据结构这里采用 WeakMap 呢?
所以我们需要解析一下 WeakMap 和 Map 的区别, 首先 WeakMap 是可以接受一个对象作为 key 的,而 WeakMap 对 key 是弱引用的。 所以当 WeakMap 的 key 是一个对象时,一旦上下文执行完毕,WeakMap 中 key 对象没有被其他代码引用的时候, 垃圾回收器 就会把该对象从内存移除,我们就无法通过该对象从 WeakMap 中获取内容了。
另外副作用函数使用 Set 类型,是因为 Set 类型能自动去除重复内容。
上述方法只实现了对引用类型的响应式处理,因为 Proxy 的代理目标必须是非原始值。 原始值指的是 Boolean、Number、BigInt、String、Symbol、undefined 和 null 等类型的值。 在 JavaScript 中,原始值是按值传递的,而非按引用传递。 这意味着,如果一个函数接收原始值作为参数,那么形参与实参之间没有引用关系,它们是两个完全独立的值,对形参的修改不会影响实参。
Vue3 中是通过对原始值做了一层包裹的方式来实现对原始值变成响应式数据的。 最新的 Vue3 实现方式是通过属性访问器 getter/setter 来实现的。
class RefImpl{
private _value
public dep
// 表示这是一个 Ref 类型的响应式数据
private _v_isRef = true
constructor(value) {
this._value = value
// 依赖存储
this.dep = new Set()
}
// getter 访问拦截
get value() {
// 依赖收集
trackRefValue(this)
return this._value
}
// setter 设置拦截
set value(newVal) {
this._value = newVal
// 触发依赖
triggerEffect(this.dep)
}
}
ref 本质上是一个实例化之后的 “包裹对象”, 因为 Proxy 无法提供对原始值的代理,所以我们需要使用一层对象作为包裹,间接实现原始值的响应式方案。 由于实例化之后的 “包裹对象” 本质与普通对象没有任何区别,所以为了区分 ref 与 Proxy 响应式对象, 我们需要给 ref 的实例对象定义一个 _v_isRef
的标识,表明这是一个 ref 的响应式对象。
最后我们和 Vue2 进行一下对比,我们知道 Vue2 的响应式存在很多的问题,例如:
而 Vue3 使用 Proxy 实现之后,以上的问题都不存在了。
我们知道在 Vue2 中是需要对数组的监听进行特殊的处理的,其实在 Vue3 中也需要对数组进行特殊处理。 在 Vue2 是不可以通过数组下标对响应式数组进行设置和读取的,而 Vue3 中是可以的, 但是数组中仍然有很多其他特别的读取和设置的方法, 这些方法没经过特殊处理,是无法通过普通的 Proxy 中的 getter/setter 进行响应式处理的。
数组中对属性或元素进行读取的操作方法。
数组中对属性或元素进行设置的操作方法。
当上述的数组的读取或设置的操作发生时,也应该正确地建立响应式联系或触发响应。
当通过索引设置响应式数组的时候,有可能会隐式修改数组的 length 属性, 例如设置的索引值大于数组当前的长度时,那么就要更新数组的 length 属性, 因此在触发当前的修改属性的响应之外,也需要触发与 length 属性相关依赖进行重新执行。
遍历数组,使用 for ... in 循环遍历数组与遍历常规对象是一致的, 也可以使用 ownKeys 拦截器进行设置。 而影响 for ... in 循环对数组的遍历会是添加新元素:arr[0] = 1
或者修改数组长度:arr.length = 0
, 其实无论是为数组添加新元素,还是直接修改数组的长度,本质上都是因为修改了数组的 length 属性。 所以在 ownKeys 拦截器内进行判断,如果是数组的话,就使用 length 属性作为 key 去建立响应联系。
在 Vue3 中也需要像 Vue2 那样对一些数组原型上方法进行重写。
当数组响应式对象使用 includes、indexOf、lastIndexOf 这方法的时候, 它们内部的 this 指向的是代理对象,并且在获取数组元素时得到的值要也是代理对象, 所以当使用原始值去数组响应式对象中查找的时候,如果不进行特别的处理,是查找不到的, 所以我们需要对上述的数组方法进行重写才能解决这个问题。
首先 arr.indexOf 可以理解为读取响应式对象 arr 的 indexOf 属性, 这就会触发 getter 拦截器,在 getter 拦截器内我们就可以判断 target 是否是数组, 如果是数组就看读取的属性是否是我们需要重写的属性,如果是,则使用我们重写之后的方法。
const arrayInstrumentations = {}
;(['includes', 'indexOf', 'lastIndexOf']).forEach(key => {
const originMethod = Array.prototype[key]
arrayInstrumentations[key] = function(...args) {
// this 是代理对象,先在代理对象中查找
let res = originMethod.apply(this, args)
if(res === false) {
// 在代理对象中没找到,则去原始数组中查找
res = originMethod.apply(this.raw, args)
}
// 返回最终的值
return res
}
})
上述重写方法的主要是实现先在代理对象中查找, 如果没找到,就去原始数组中查找,结合两次的查找结果才是最终的结果, 这样就实现了在代理数组中查找原始值也可以查找到。
在一些数组的方法中除了修改数组的内容之外也会隐式地修改数组的长度。例如下面的例子:
const arr = new Proxy([1], {
get(target, key) {
console.log(`读取`, target, key)
return target[key]
},
set(target, key, val) {
console.log('设置', key, val)
/**
* 注意这里需要return一下,否则会因为返回undefined报错:
* VM680:1 Uncaught TypeError:
* 'set' on proxy: trap returned falsish for property '1'
* at Proxy.push (<anonymous>)
* at <anonymous>:1:5
*/
return Reflect.set(target, key, val)
}
})
arr.push(2)
上述代码执行后,控制台输入内容如下:
读取 [1] push
读取 [1] length
设置 1 2
设置 length 2
我们可以看到我们只是进行 arr.push 的操作却也触发了 getter 拦截器, 并且触发了两次,其中一次就是数组 push 属性的读取,还有一次是什么呢? 还有一次就是调用 push 方法会间接读取 length 属性, 那么问题来了,进行了 length 属性的读取,也就会建立 length 的响应依赖, 可 arr.push
本意只是修改操作,并不需要建立 length 属性的响应依赖。 所以我们需要 “屏蔽” 对 length 属性的读取,从而避免在它与副作用函数之间建立响应联系。
相关代码实现如下:
const arrayInstrumentations = {}
// 是否允许追踪依赖变化
let shouldTrack = true
// 重写数组的 push、pop、shift、unshift、splice 方法
;['push','pop','shift', 'unshift', 'splice'].forEach(method => {
// 取得原始的数组原型上的方法
const originMethod = Array.prototype[method]
// 重写
arrayInstrumentations[method] = function(...args) {
// 在调用原始方法之前,禁止追踪
shouldTrack = false
// 调用数组的默认方法
let res = originMethod.apply(this, args)
// 在调用原始方法之后,恢复允许进行依赖追踪
shouldTrack = true
return res
}
})
在调用数组的默认方法间接读取 length 属性之前,禁止进行依赖跟踪, 这样在间接读取 length 属性时,由于是禁止依赖跟踪的状态, 所以 length 属性与副作用函数之间不会建立响应联系。
对于基本数据类型ref的处理方式还是Object.defineProperty的get()和set()完成。这说法对吗?
从原理上来说不是的,是使用对象的属性的赋值器(setter)和取值器(getter), 而不是Object.defineProperty,Object.defineProperty 是重新定义,劫持属性的访问和设置。
但从实际上来说是对的,因为用babe转译后就是Object.defineProperty。
Vue3 则是通过 Proxy 对数据实现 getter/setter 代理,从而实现响应式数据, 然后在副作用函数中读取响应式数据的时候,就会触发 Proxy 的 getter, 在 getter 里面把对当前的副作用函数保存起来, 将来对应响应式数据发生更改的话,则把之前保存起来的副作用函数取出来执行。
Vue3 对数组实现代理时,用于代理普通对象的大部分代码可以继续使用, 但由于对数组的操作与对普通对象的操作存在很多的不同, 那么也需要对这些不同的操作实现正确的响应式联系或触发响应。 这就需要对数组原型上的一些方法进行重写。
比如通过索引为数组设置新的元素,可能会隐式地修改数组的 length 属性的值。 同时如果修改数组的 length 属性的值,也可能会间接影响数组中的已有元素。 另外用户通过 includes、indexOf 以及 lastIndexOf 等对数组元素进行查找时, 可能是使用代理对象进行查找,也有可能使用原始值进行查找, 所以我们就需要重写这些数组的查找方法,从而实现用户的需求。 原理很简单,当用户使用这些方法查找元素时,先去响应式对象中查找,如果没找到,则再去原始值中查找。
另外如果使用 push、pop、shift、unshift、splice 这些方法操作响应式数组对象时会间接读取和设置数组的 length 属性, 所以我们也需要对这些数组的原型方法进行重写,让当使用这些方法间接读取 length 属性时禁止进行依赖追踪, 这样就可以断开 length 属性与副作用函数之间的响应式联系了。
React是用于构建用户界面的JS框架。因此React只负责解决view层的渲染。
virtual dom 实际上是对实际dom的一个抽象,是一个js对象。react所有的表层操作实际上是在操作virtual dom。经过diff算法会计算出virtual dom的差异,然后针对这些差异进行实际的dom操作进而更新页面。
mount流程
update过程
unmount过程
diff算法用于计算出两个virtual dom的差异,是react中开销最大的地方。
传统diff算法通过循环递归对比差异,算法复杂度为O(n^3)。
react diff算法制定了三条策略,将算法复杂度从 O(n^3)降低到O(n)。
针对这三个策略,react diff实施的具体策略是:
另外,在对比同一层级的子节点时:
diff算法会以新树的第一个子节点作为起点遍历新树,寻找旧树中与之相同的节点。
如果节点存在,则移动位置。如果不存在,则新建一个节点。
在这过程中,维护了一个字段lastIndex,这个字段表示已遍历的所有新树子节点在旧树中最大的index。 在移动操作时,只有旧index小于lastIndex的才会移动。
这个顺序优化方案实际上是基于一个假设,大部分的列表操作应该是保证列表基本有序的。 可以推倒倒序的情况下,子节点列表diff的算法复杂度为O(n^2)
由于react中性能主要耗费在于update阶段的diff算法,因此性能优化也主要针对diff算法。
减少diff算法触发次数实际上就是减少update流程的次数。
正常进入update流程有三种方式:
setState机制在正常运行时,由于批更新策略,已经降低了update过程的触发次数。
因此,setState优化主要在于非批更新阶段中(timeout/Promise回调),减少setState的触发次数。
常见的业务场景即处理接口回调时,无论数据处理多么复杂,保证最后只调用一次setState。
父组件的render必然会触发子组件进入update阶段(无论props是否更新)。此时最常用的优化方案即为shouldComponentUpdate方法。 最常见的方式是对this.props和this.state进行浅比较来判断组件是否需要更新。或者直接使用PureComponent,原理一致。
需要注意的是,父组件的render函数如果写的不规范,将会导致上述的策略失效。
/**
* Bad case
* 每次父组件触发render 将导致传入的handleClick参数都是一个全新的匿名函数引用。
* 如果this.list 一直都是undefined,每次传入的默认值[]都是一个全新的Array。
* hitSlop的属性值每次render都会生成一个新对象
*/
class Father extends Component {
onClick() {}
render() {
return (
<Child
handleClick={() => this.onClick()}
list={this.list || []}
hitSlop={{ top: 10, left: 10}}
/>
)
}
}
/**
* Good case
* 在构造函数中绑定函数,给变量赋值
* render中用到的常量提取成模块变量或静态成员
*/
const hitSlop = {top: 10, left: 10};
class Father extends Component {
constructor(props) {
super(props);
this.onClick = this.onClick.bind(this);
this.list = [];
}
onClick() {}
render() {
return (
<Child
handleClick={this.onClick}
list={this.list}
hitSlop={hitSlop}
/>
)
}
}
forceUpdate方法调用后将会直接进入componentWillUpdate阶段,无法拦截,因此在实际项目中应该弃用。
shouldComponentUpdate
使用shouldComponentUpdate钩子,根据具体的业务状态,减少不必要的props变化导致的渲染。如一个不用于渲染的props导致的update。
另外,也要尽量避免在shouldComponentUpdate
中做一些比较复杂的操作,比如超大数据的pick操作等。
合理设计state
不需要渲染的state,尽量使用实例成员变量。
合理设计props
不需要渲染的props,合理使用context机制,或公共模块(比如一个单例服务)变量来替换。
先提出个疑问:我们为什么需要状态管理?
对于SPA应用来说,前端所需要管理的状态越来越多,需要查询、更新、传递的状态也越来越多,如果让每个组件都存储自身相关的状态,理论上来讲不会影响应用的运行,但在开发及后续维护阶段,我们将花费大量精力去查询状态的变化过程,在多组合组件通信或客户端与服务端有较多交互过程中,我们往往需要去更新、维护并监听每一个组件的状态,在这种情况下,如果有一种可以对状态做集中管理的地方是不是会更好呢?
状态管理好比是一个集中在一处的配置箱,当需要更新状态的时候,我们仅对这个黑箱进行输入,而不用去关心状态是如何分发到每一个组件内部的,这可以让开发者将精力更好的放在业务逻辑上。
但状态管理并不是必需品,当你的UI层比较简单、没有较多的交互去改变状态的场景下,使用状态管理方式反倒会让你的项目变的复杂。例如Redux的发明者Dan Abramov就说过这样一句话:
只有遇到React实在解决不了的问题,你才需要Redux。
一般来讲,在以下场景下你或许需要使用状态管理机制去维护应用:
Redux之类的状态管理库充当了一个应用的业务模型层,并不会受限于如React之类的View层。
我们先来看一下一个完整的Redux数据流是怎样的:
理想情况
setState是“异步”的,调用setState只会提交一次state修改到队列中,不会直接修改this.state。
等到满足一定条件时,react会合并队列中的所有修改,触发一次update流程,更新this.state。
因此setState机制减少了update流程的触发次数,从而提高了性能。
由于setState会触发update过程,因此在update过程必经的生命周期中调用setState会存在循环调用的风险。
另外如果要监听state更新完成,可以使用setState方法的第二个参数,回调函数。在这个回调中读取this.state就是已经批量更新后的结果。
特殊情况
在实际开发中,setState的表现有时会不同于理想情况。主要是以下两种。
在第一种情况下,不会进入update流程,队列在mount时合并修改并render。
在第二种情况下,setState将不会进行队列的批更新,而是直接触发一次update流程。
这是由于setState的两种更新机制导致的,只有在批量更新模式中,才会是“异步”的。
import React, { useState } from "react";
import ReactDOM from "react-dom";
function App() {
const [count, setCount] = useState(0);
return (
<div>
<p>count: {count}</p>
<button
onClick={() => setCount(count + 1)}
>
+1
</button>
</div>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
按照React 16.8.0版本之前的机制,我们知道如果某个组件是函数组件,则这个function就相当于Class组件中的render(),不能拥有自己的状态(故又称其为无状态组件,stateless components),所以数据(输入)必须是来自父组件的props。
而在>=16.8.0中,函数组件支持通过使用Hooks来为其引入state的能力,例如上面所展示的例子:这个App组件提供了一个按钮,每次点击这个都会执行setCount使得count增加1,并更新在视图上。
React.useState() 里都做了些什么:
function useState(initialState) {
let _state = initialState;
const setState = (newState) => {
_state = newState;
ReactDOM.render(<App />, rootElement);
};
return [_state, setState];
}
let _state;
function useState(initialState) {
_state = _state === undefined ? initialState : _state;
const setState = (newState) => {
_state = newState;
ReactDOM.render(<App />, rootElement);
};
return [_state, setState];
}
通过时上面的处理,目前暂时是达到了React.useState()一样的效果。
let _state = []
let _index = 0
function useState(initialState) {
let curIndex = _index; // 记录当前操作的索引
_state[curIndex] = _state[curIndex] === undefined
? initialState
: _state[curIndex]
const setState = (newState) => {
_state[curIndex] = newState;
ReactDOM.render(<App />, rootElement);
/**
* 每更新一次都需要将_index归零,才不会不断重复增加_state
* 说明1:
* useState只在每次渲染App函数的时候执行,
* 将_index重置为0是为了下一次render App时,
* useState重新从_index为0开始计算顺序
*
* 说明2:
* 其实也可以在最后一个useState触发后将_index归0,
* 或者App组件函数调用完毕后归0,
* 只是逻辑不太好写
*/
_index = 0;
}
// 下一个操作的索引
_index += 1
return [_state[curIndex], setState];
}
虽然通过使用数组存储_state成功模拟了多个useState()的情况,但这要求我们保证useState()的调用顺序,所以我们不能在循环、条件或嵌套函数中调用useState(),这在React.useState()同样要求,官网还给出了专门的解释。
实际上,React并不是真的是这样实现的。上面提到的_state其实对应React的memoizedState,而_index实际上是利用了链表。
除了个别岗位,大部分前端都是很偏向应用层面的(不管是电商还是低代码平台)。对数据结构和算法的要求并不高。
但是为什么面试的时候还是有很多人喜欢问算法这块的东西呢?因为它是一个很有区分度的话题。 算法差的人,写业务代码的能力有好有差,上下限都很大。算法好的人,往往下限不会太低(但上限也未必高)。 就为了这一点,面一些算法题就已经无可厚非了。 当然还有些情况纯粹是面试官偷懒,这就不赘述了。
一个人是否擅长编写复杂的业务逻辑,主要是看他是否擅长进行反思总结。 这是一个需要长期编码、迭代、反思、重构的过程。靠面对算法题进行乌托邦式实验是没有多少帮助的。
个人的建议是,你需要了解一些基本的数据结构和算法知识,但不必过多涉猎。 以我自己为例,我并不擅长数据结构和算法,但是仅仅是因为有所了解,也就可以写出类似这样的文字:
在我自己的电脑上,编译1组路由时用时大概是2分钟,同时编译2组路由时用时大概5分钟, 同时编译3组路由时用时大概30分钟,同时编译4组路由时用时大概75分钟。 可以发现编译时长和代码量总体上并不是一个线性增长的关系。 猜测是因为计算需要从入口文件开始去爬引用到的文件,然后一层层去爬,不同入口爬到的文件有交集文件,也有各自独立的文件, 毛估估很像是数据结构里的图(遍历时间复杂度为O(N^2)),而且我大致去算了下也能找到一个合适的模拟函数曲线, 不过鉴于样本量有限,每个页面大小差别也很大,也没啥科学性。 我在IDE里跳转到webpack源码后发现不是很好确认这点,便作罢。……
以上节选自2021年撰写的博文《编译要1小时的Webpack项目优化思路和条件编译方案》。
我认为需要了解的数据结构和算法知识如下:
在你还没法清楚地知道是否需要了解这块的情况下,了解这些内容已经足够了。
冒泡排序的思想是,比较相邻两个数,如果前者大于后者,就把两个数交换位置;这样一来,第一轮就可以选出一个最大的数放在最后面;那么经过n-1轮,就完成了所有数的排序。
基本实现
function bubbleSort (arr) {
let len = arr.length
while (len > 0) {
for (let i = 0; i < len - 1; i++) {
if (arr[i] > arr[i + 1]) {
const temp = arr[i]
arr[i] = arr[i + 1]
arr[i + 1] = temp
}
}
len--
}
return arr
}
优化思路
在上面的方案中,如果我们经过第一轮排序就成功将所有元素正确排好序了的话,仍然会继续遍历。 这里可以每一轮开始遍历时,加一个初始值为false的changeFlag标记, 当本轮有进行过换位的话,就接着遍历下一轮。 当本轮没有进行过换位操作的话,则说明已经排序完毕,就可以直接退出循环,没必要接着遍历了。
具体实现还是比较简单的,大家自行尝试,这里就不写了。
在已从小到大排序的数组(数组内元素均为数字)中找到给定的数字对应的下标
function dichotomySearch (arr, num) {
var low = 0
var high = arr.length - 1
var mid = Math.floor((low + high) / 2)
// while循环的判断条件是high - low > 1
while (high - low > 1) {
if (num === arr[low]) {
return low
}
if (num === arr[high]) {
return high
}
if (num === arr[mid]) {
return mid
}
if (num > arr[mid]) {
low = mid
mid = Math.floor((low + high) / 2)
} else {
high = mid
mid = Math.floor((low + high) / 2)
}
}
// 如果没找到,则返回-1
return -1
}
求一个数n的平方根
/**
* 计算平方根
* @param {number} n 需要求平方根的目标数字
* @param {number} deviation 偏离度(允许的误差范围)
* @return {number} 返回平方根
*/
function square (n, deviation) {
let max = n
let min = 0
let mid = (max - min) / 2
const isAlmost = (val) => (
(val * val - n <= deviation) &&
(n - val * val <= deviation)
)
while (isAlmost(mid) === false) {
if (mid * mid > n) {
max = mid
mid = (max + min) / 2
} else if (mid * mid < n) {
min = mid
mid = (max + min) / 2
}
}
return mid
}
据说著名犹太历史学家 Josephus有过以下的故事:
在罗马人占领乔塔帕特后,39 个犹太人与Josephus及他的朋友(共41个人)躲到一个洞中, 39个犹太人决定宁愿死也不要被敌人抓到,于是决定了一个自杀方式: 41个人排成一个圆圈,由第1个人开始报数,每报数到第3人该人就必须自杀, 然后再由下一个重新报数,直到所有人都自杀身亡为止。 然而Josephus 和他的朋友并不想遵从。 首先从一个人开始,越过k-2个人(因为第一个人已经被越过), 并杀掉第k个人。接着,再越过k-1个人,并杀掉第k个人。 这个过程沿着圆圈一直进行,直到最终只剩下一个人留下,这个人就可以继续活着。 问题是一开始要站在什么地方才能避免自杀?Josephus要他的朋友先假装遵从, 他将朋友与自己安排在第16个与第31个位置,于是逃过了这场死亡游戏。
数组下标方案
// 初始状态各坐标都赋值为0,被选中则标记为1
const arr = new Array(41).fill(0)
const key = 3
// 最后会剩下2人,走掉39人
const numOfPeopleToLeave = arr.length - 2
let count = 0
while (arr.filter((checked) => checked === 1).length < numOfPeopleToLeave) {
for (let i = 0, len = arr.length; i < len; i++) {
// 如果数组中该坐标已被标记,则直接跳过
if (arr[i] === 1) {
continue
}
count++
// 每到第key个人
if (count === key) {
// 标记数组中该坐标已被占位
arr[i] = 1
count = 0
}
}
}
// 输出标记情况,标记为0的表示未被选中,标记为1表示被选中
console.log(arr)
// 输出被留下的人(值为0)对应的坐标
console.log(
arr
.map((checked, idx) => checked === 0 ? idx : undefined)
.filter((idx) => typeof idx === 'number')
)
链表方案(单向循环链表)
如果是单向循环链表的话,对单个链表节点,有:
function Node (next) {
this.next = next || null
}
每当计数计到3时,将当前node节点的上一个节点的next指向当前node节点的下一个节点, 然后继续从1开始计数,代码就不写了,虽说数据结构上和数组下标方案不同, 逻辑是差不多的,while循环的终止条件可以换成当当前节点next指向null时。
输入两个单调递增的链表,输出两个链表合成后的链表,当然我们需要合成后的链表满足单调不减规则。
递归版本
function merge(pHead1, pHead2) {
let node = null;
if (pHead1 === null) {
return pHead2;
} else if (pHead2 === null) {
return pHead1;
}
if (pHead1.val >= pHead2.val) {
node = pHead2;
node.next = merge(pHead1, pHead2.next);
} else {
node = pHead1;
node.next = merge(pHead1.next, pHead2);
}
return node;
}
非递归版本
function merge(pHead1, pHead2) {
if (pHead1 === null) {
return pHead2;
} else if (pHead2 === null) {
return pHead1;
}
let node = null;
let startNode = null;
if (pHead1.val <= pHead2.val) {
node = pHead1;
startNode = pHead1;
pHead1 = pHead1.next;
} else {
node = pHead2;
startNode = pHead2;
pHead2 = pHead2.next;
}
while (pHead1 !== null && pHead2 !== null) {
if (pHead1.val <= pHead2.val) {
node.next = pHead1;
node = pHead1;
pHead1 = pHead1.next;
} else {
node.next = pHead2;
node = pHead2;
pHead2 = pHead2.next;
}
}
if (pHead1 !== null) {
node.next = pHead1;
} else if (pHead2 !== null) {
node.next = pHead2;
}
return startNode;
}
介绍
快速排序由于排序效率在同为O(N*logN)的几种排序方法中效率较高, 因此经常被采用,再加上快速排序思想——分治法也确实实用, 因此很多软件公司的笔试面试,包括像腾讯,微软等知名IT公司都喜欢考这个。
分治法
快速排序是C.R.A.Hoare于1962年提出的一种划分交换排序。 它采用了一种分治的策略,通常称其为分治法(Divide-and-Conquer Method)。
该方法的基本思想是:
代码实现
function quickSort (arr) {
if (arr.length <= 1) {
return arr
}
// pivot:枢纽、中心点
var pivotIndex = Math.floor(arr.length / 2)
// 找基准,并把基准从原数组中删除
var pivot = arr.splice(pivotIndex, 1)[0]
// 定义左右数组
var left = []
var right = []
// 比基准小的放在left,比基准大的放在right
arr.forEach(function (val) {
if (val <= pivot) {
left.push(val)
} else {
right.push(val)
}
})
// 递归
return quickSort(left).concat([pivot], quickSort(right))
}
问题描述
给定一个只包括'('
,')'
,'{'
,'}'
,'['
,']'
的字符串s
, 判断字符串是否有效。
有效字符串需满足:
如:
"()"
、"()[]{}"
、"{[]}"
。"(]"
、"([)]"
。解法
function isStrValid (str) {
const matches = ['()', '[]', '{}']
const arr = []
for (let i = 0, len = str.length; i < len; i++) {
const char = str.charAt(i)
if (arr.length === 0) {
arr.push(char)
continue
}
const last = arr[arr.length - 1]
if (matches.includes(last + char)) {
arr.pop()
continue
}
arr.push(char)
}
return arr.length === 0
}
问题描述
给定一个字符串,消除其中所有的字符串 ac
和 b
,如果消掉之后获得的新字符串中仍存在可消内容则需要继续消,直到没有可继续消的字符串为止。
比如字符串 aaaaaaaaabbbbbbbcccccbbbbbcccc
经过处理后应该得到空字符串。
解决方案
function deleteMatchStr (str) {
const arr = str.split('')
const tempArr = []
arr.forEach((item) => {
const last = tempArr.length ? tempArr[tempArr.length - 1] : ''
if (last + item === 'ac') {
tempArr.pop()
return
}
if (item !== 'b') {
tempArr.push(item)
}
})
return tempArr.join('')
}
deleteMatchStr('aaaaaaaaabbbbbbbcccccbbbbbcccc')
单调栈是一种特殊的栈结构,其内部元素的排序是单调朝一个方向的。 在许多数组的范围查询问题上,用上单调栈可显著降低时间复杂度——毕竟其时间复杂度只有O(N)。
去重返回最小数
这是LeetCode里的一道难度级别显示为中等的题目。
题目:给定一串数字, 去除字符串中重复的数字, 而且不能改变数字之间的顺序, 使得返回的数字最小 "23123" => "123" "32134323" => "1342"。
解法如下:
function handleArray(strings) {
const array = strings.split('')
const stack = []
const obj = {}
for (let i = 0, len = array.length; i < len; i++) {
const item = array[i]
if (stack.length === 0) {
stack.push(item)
continue
}
const lastStackItem = stack[stack.length - 1]
while (lastStackItem >= item && array.slice(i).includes(lastStackItem)) {
stack.pop()
}
if (!stack.includes(item)) {
stack.push(item)
}
}
return stack.join('')
}
handleArray('23123')
handleArray('32134323')
// 节点对象的构造函数
function Node (data, left, right) {
this.data = data
this.left = left
this.right = right
}
Node.prototype.getData = function () {
return this.data
}
// 二叉搜索树的构造函数
function BST () {
this.root = null
}
// 插入方法
BST.prototype.insert = function (data) {
const n = new Node(data, null, null)
if (this.root === null) {
this.root = n
return
}
let current = this.root
let parent
while (true) {
parent = current
if (data < current.data) {
current = current.left
if (current === null) {
parent.left = n
break
}
} else {
current = current.right
if (current === null) {
parent.right = n
break
}
}
}
}
const nums = new BST()
nums.insert(8)
nums.insert(3)
nums.insert(10)
nums.insert(1)
nums.insert(6)
nums.insert(14)
nums.insert(4)
nums.insert(7)
nums.insert(13)
这个算法很好实现:
// 前序遍历二叉树
BST.prototype.preOrder = function (node) {
if (node !== null) {
console.log(node.getData())
this.preOrder(node.left)
this.preOrder(node.right)
}
}
// 中序遍历二叉树
BST.prototype.inOrder = function (node) {
if (node !== null) {
this.inOrder(node.left)
console.log(node.getData())
this.inOrder(node.right)
}
}
// 后序遍历二叉树
BST.prototype.postOrder = function (node) {
if (node !== null) {
this.postOrder(node.left)
this.postOrder(node.right)
console.log(node.getData())
}
}
// 测试
nums.inOrder(nums.root)
// 依次输出如下内容:
// 1 3 4 6 7 8 10 13 14
根据前序遍历访问的顺序,优先访问根结点,然后再分别访问左孩子和右孩子。 即对于任一结点,其可看做是根结点,因此可以直接访问,访问完之后,若其左孩子不为空, 按相同规则访问它的左子树;访问其左子树后,再访问它的右子树。因此其处理过程如下:
对于任一结点P:
访问结点P,并将结点P入栈;
判断结点P的左孩子是否为空,
直到P为NULL并且栈为空,则遍历结束。
function preOrder (bst) {
let p = bst.root
const arr = []
while (p !== null || arr.length > 0) {
while (p !== null) {
console.log(p.getData())
arr.push(p)
p = p.left
}
if (arr.length > 0) {
p = arr.pop()
p = p.right
}
}
}
根据中序遍历的顺序,对于任一结点,优先访问其左孩子,而左孩子结点又可以看做一根结点, 然后继续访问其左孩子结点,直到遇到左孩子结点为空的结点才进行访问, 然后按相同的规则访问其右子树。因此其处理过程如下:
对于任一结点P,
若其左孩子不为空,则将P入栈并将P的左孩子置为当前的P,然后对当前结点P再进行相同的处理;
若其左孩子为空,则取栈顶元素并进行出栈操作,访问该栈顶结点,然后将当前的P置为栈顶结点的右孩子;
直到P为NULL并且栈为空则遍历结束。
function inOrder (bst) {
let p = bst.root
const arr = []
while (p !== null || arr.length > 0) {
while (p !== null) {
arr.push(p)
p = p.left
}
if (arr.length > 0) {
p = arr.pop()
console.log(p.getData())
p = p.right
}
}
}
后续遍历比前中/序遍历是要麻烦一些的。
遍历顺序:左右根。左路的遍历和上面的思路是类似的,区别是元素出栈时不能直接打印, 因为如果有没访问过右侧子树的话,需要先访问右侧子树。 右侧子树访问结束后才访问根节点。
function postOrder (bst) {
let p = bst.root
let last = null
const arr = []
while (p !== null || arr.length > 0) {
while (p !== null) {
arr.push(p)
p = p.left
}
if (arr.length > 0) {
p = arr[arr.length - 1] // 栈顶元素
// 当p不存在右子树或右子树已被访问过的话,直接访问当前节点数据
if (!p.right || p.right === last) {
p = arr.pop()
console.log(p.getData())
last = p // 记录上一次访问过的节点
p = null // 这个容易漏掉,避免下个循环继续访问左子树
} else {
p = p.right
}
}
}
}
递归方案
// 遍历到某个节点后,将该节点的值推入到指定深度对应的数组中
function level(node, idx, arr) {
if (arr.length < idx) {
arr.push([])
}
arr[idx - 1].push(node.value)
if (node.left !== null) {
level(node.left, idx + 1, arr)
}
if (node.right !== null) {
level(node.right, idx + 1, arr)
}
}
function levelOrder(root) {
if (root === null) {
return []
}
const arr = []
level(root, 1, arr)
return arr
}
非递归方案
使用队列实现。
function levelOrder(root) {
const arr = []
if (root === null) {
return arr
}
const queue = [root]
while (queue.length > 0) {
const currentLevel = []
const currentLevelLength = queue.length
for (let i = 0; i <= currentLevelLength; i++) {
const node = queue.pop()
currentLevel.push(node.value)
if (node.left !== null) {
queue.push(node.left)
}
if (node.right !== null) {
queue.push(node.right)
}
}
arr.push(currentLevel)
}
return arr
}
function deepClone(obj) {
// if not object
if (typeof obj !== 'object') {
return obj
}
// if null
if (obj === null) {
return null
}
// if array
if (Array.isArray(obj)) {
return obj.map((elem) => deepClone(elem))
}
// if obj
const tempObj = {}
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
tempObj[key] = deepClone(obj[key])
}
}
return tempObj
}
问题
解答
function flattenArray(arr) {
if (!Array.isArray(arr)) {
throw new TypeError('You should pass in an Array parameter')
}
const tempArr = []
const tempObj = {}
void function recurison(item) {
if (Array.isArray(item)) {
item.forEach(recurison)
} else {
if (typeof item === 'object') {
tempArr.push(item)
} else if (!tempObj[item]) {
tempArr.push(item)
tempObj[item] = true
}
}
}(arr)
return tempArr
}
问题
将数组中奇数放在右边,偶数放在左边,不允许使用额外空间。
说明:从一个数组中间删除元素splice的运行代价是比较大的。
方案
const arr = [1, 4, 5, 2, 3, 7, 8]
arr.sort(function (a, b) {
return a % 2 !== 0
})
问题
要求:
解答
思路:把最大的数字标记为null,然后再求此时的最大数字。
const arr = [1, 3, 5, 2, 7, 6]
arr[arr.indexOf(Math.max.apply(null, arr))] = null
Math.max.apply(null, arr)
const rawArr = [
['1', '2', '3', '4', '5'],
['6', '7', '8', '9', 'a'],
['b', 'c', 'd', 'e', 'f'],
['g', 'h', 'i', 'j', 'k'],
['l', 'm', 'n', 'o', 'p']
]
使用新数组再覆盖原数组
如果直接先生成一个新数组, 然后逐个将旧数组里的元素赋值到新数组中的对应位置,那就很简单了。
先列数据看规律:
可以看出规律是:oldArray(x, y) => newArray(y, 5 - 1 - x)
const rawArr = [
['1', '2', '3', '4', '5'],
['6', '7', '8', '9', 'a'],
['b', 'c', 'd', 'e', 'f'],
['g', 'h', 'i', 'j', 'k'],
['l', 'm', 'n', 'o', 'p']
]
function rotate90(arr) {
const length = arr.length
// 直接这样写是不行的,5个子数组实际对应的是同一个对象,修改一个其实是5个子数组里的值都变了
// const tempArr = new Array(length).fill(new Array(length))
// 这里去掉fill(1)的话就无法构造成二维数组了
const tempArr = new Array(5).fill(1).map(() => new Array(5))
arr.forEach((row, rowIdx) => {
row.forEach((col, colIdx) => {
tempArr[colIdx][length - rowIdx - 1] = arr[rowIdx][colIdx]
console.log(`(${rowIdx}, ${colIdx}) => (${colIdx}, ${length - rowIdx - 1})`)
})
})
return tempArr
}
console.log(rotate90(rawArr))
问题描述
实现一个函数,入参为数字 n
,输出如下图所示的字符串。
解决方案
function drawAsterisk(n) {
function getRepeatStr (num, repeatStr) {
return new Array(num).fill(repeatStr).join('')
}
function log (str) {
console.log(str)
}
const arr = []
const sumLength = 2 * n - 1
for (let i = 0; i < n - 1; i++) {
const numOfStar = 2 * i + 1
const numOfSpaces = sumLength - numOfStar
const sideSpaces = getRepeatStr(numOfSpaces / 2, ' ')
arr.push(sideSpaces + getRepeatStr(numOfStar, '*') + sideSpaces)
}
// 画上半部分
arr.forEach(log)
// 画中间一行
console.log(getRepeatStr(2 * n - 1, '*'))
// 下半部分与上半部分层是轴对称的,直接 `reverse()` 反转下就可以直接用
arr.reverse()
// 画下半部分
arr.forEach(log)
}
drawAsterisk(2)
“最佳实践”只是一种习惯说法,是大家总结出的一些较好的代码实践方式,落地到具体项目中时,它并不一定就是最好的,各位可带着自己的思考进行阅读。
代码要精炼。代码量的多少直接影响可读性。这简直是一句正确的废话,但又因为太朴素,总是被人忽略。在没有其他问题/原因的情况下,3行代码就是比5行代码好。
代码隔离。一言以蔽之,就是在说代码的模块化。提到模块化,大家的第一反应就是import/export和代码复用。其实可以多一些思考。有时候我们不是为了复用,只是为了把代码分开:
设计模式是前人总结出来的一些典型的代码设计方案。了解一下,对提升架构能力会有所帮助。
根据设计模式的参考书《Design Patterns - Elements of Reusable Object-Oriented Software》中所提到的,总共有23种设计模式。 这些模式可以分为三大类:
创建型模式(Creational Patterns):这些设计模式提供了一种在创建对象的同时隐藏创建逻辑的方式,而不是使用 new 运算符直接实例化对象。这使得程序在判断针对某个给定实例需要创建哪些对象时更加灵活。
结构型模式(Structural Patterns):这些设计模式关注类和对象的组合。
行为型模式(Behavioral Patterns):这些设计模式特别关注对象之间的通信。
另外还有一些由Sun Java Center提出的J2EE 设计模式,这些模式特别关注表示层:
提示
观察者模式(Observer) 通常又被称为发布-订阅者模式或消息机制, 它定义了对象间的一种一对多的依赖关系, 只要当一个对象的状态发生改变时, 所有依赖于它的对象都得到通知并被自动更新, 解决了主体对象与观察者之间功能的耦合, 即一个对象状态改变给其他对象通知的问题。
下面这段代码就是一种发布-订阅模式:
document.querySelector('#btn').addEventListener('click', function () {
alert('You click this button');
}, false)
除了我们常见的 DOM 事件绑定外,观察者模式应用的范围还有很多~
比如 vue2 框架里不少地方都涉及到了观察者模式,比如:
数据的双向绑定
利用Object.defineProperty()对数据进行劫持, 设置一个监听器Observer,用来监听所有属性, 如果属性上发上变化了,就需要告诉订阅者Watcher去更新数据, 最后指令解析器Compile解析对应的指令, 进而会执行对应的更新函数,从而更新视图,实现了双向绑定。
子组件与父组件通信
Vue中我们通过props完成父组件向子组件传递数据, 子组件与父组件通信我们通过自定义事件即$on、$emit来实现, 其实也就是通过$emit来发布消息,并对订阅者$on做统一处理。
首先我们需要创建一个观察者对象,它包含一个消息容器和三个方法,分别是:
const Observe = (function() {
// 防止消息队列暴露而被篡改,将消息容器设置为私有变量
let __message = {};
return {
// 注册消息接口
on: function(type, fn) {
// 如果此消息不存在,创建一个该消息类型
if (typeof __message[type] === 'undefined') {
// 将执行方法推入该消息对应的执行队列中
__message[type] = [fn];
} else {
// 如果此消息存在,直接将执行方法推入该消息对应的执行队列中
__message[type].push(fn);
}
},
// 发布消息接口
publish: function(type, args) {
// 如果该消息没有注册,直接返回
if (!__message[type]) return;
// 定义消息信息
const events = {
type, // 消息类型
args: args || {} // 参数
}
// 遍历执行函数
for (let i = 0, len = __message[type].length; i < len; i++) {
// 依次执行注册消息对应的方法
__message[type][i].call(this, events)
}
},
// 移除消息接口
off: function(type, fn) {
// 如果消息执行队列存在
if (__message[type] instanceof Array) {
// 从最后一条依次遍历(之所以从最后一个开始遍历,是因为下面会用到splice)
for (let i = __message[type].length - 1; i >= 0; i--) {
// 如果存在该执行函数则移除相应的动作
__message[type][i] === fn && __message[type].splice(i, 1);
}
}
}
}
})();
使用:
// 订阅消息
Observe.on('say', function (data) {
console.log(data.args.text);
})
Observe.on('success',function () {
console.log('success')
});
//发布消息
Observe.publish('say', { text : 'hello world' } )
Observe.publish('success');
我们在消息类型为say、success的消息中注册了两个方法,其中有一个接受参数,另一个不需要参数, 然后通过publish发布say和success消息,结果跟我们预期的一样, 控制台输出了 hello world 以及 success。
class EventBus {
constructor() {
this.event = Object.create(null);
};
// 注册事件
on(name, fn) {
if(!this.event[name]){
// 一个事件可能有多个监听者
this.event[name]=[];
};
this.event[name].push(fn);
};
// 触发事件
emit(name, ...args) {
//给回调函数传参
this.event[name] && this.event[name].forEach(fn => {
fn(...args)
});
};
// 只被触发一次的事件
once(name, fn) {
// 在这里同时完成了对该事件的注册、对该事件的触发,并在最后取消该事件。
const cb=(...args)=>{
//触发
fn(...args);
//取消
this.off(name,fn);
};
//监听
this.on(name, cb);
};
// 取消事件
off(name, offCb) {
if(this.event[name]){
const index = this.event[name].findIndex((fn)=>{
return offCb === fn;
})
if (index !== -1) {
this.event[name].splice(index, 1);
if(!this.event[name].length){
delete this.event[name];
}
}
}
}
}
通用的单例构造函数:
const getSingle = function (fn) {
let result
return function () {
return result || (result = fn.apply(this, arguments))
}
}
// 单例构造函数
function CreateSingleton (name) {
this.name = name;
this.getName();
};
// 获取实例的名字
CreateSingleton.prototype.getName = function() {
console.log(this.name)
};
// 单例对象
const Singleton = (function(){
var instance;
return function (name) {
if(!instance) {
instance = new CreateSingleton(name);
}
return instance;
}
})();
// 创建实例对象1
const a = new Singleton('a');
// 创建实例对象2
const b = new Singleton('b');
// 返回true
console.log(a===b);
// 返回true
console.log(a.name === b.name)
责任链模式、观察者模式、策略模式 这三种在日常的前端开发中,经常遇到:
责任链模式通常在分布提交表单中,前一步表单满足后才能进入下一步,例如新建商品、营促销活动等;
观察者模式通常应用在组件之间的通讯中;
策略模式通常用来优化过多的 if
/else
或 switch
/case
;
那么单例模式有哪些场景使用呢?
不借助第三方库,我们可以使用单例模式来制作一个全局的状态存储。
例如在小程序这种移动端,需要开发一个新建商品的需求,由于商品的属性很多,会将基本信息、规格属性、商品详情(富文本)等做成三个页面,规格属性选择又会多出一个页面。
总共 4 个页面以及各种组件,都需要能共享到“商品”这个对象用来进行回显。
这个时候就可以用单例模式来存储“商品”数据:
// store.js
const PRODUCT_MODEL = Object.freeze({
productName: "",
productBrand: "",
productSkuList: [],
// etc...
});
class Storage {
static getInstance() {
if (!this.instance) {
this.instance = new Storage();
}
return this.instance;
}
constructor() {
this.data = Object.assign({}, PRODUCT_MODEL);
}
init(obj) {
this.data = { ...this.data, ...obj };
}
set(key, value) {
if (!key) {
throw new Error("A store key must be provided");
}
this.data[String(key)] = value;
return this;
}
get(key) {
const value = key ? this.data[String(key)] : this.data;
return value;
}
removeItem(key) {
delete this.data[String(key)];
}
reset(obj = {}) {
this.data = Object.assign({}, PRODUCT_MODEL, obj);
}
}
module.exports = Storage.getInstance();
这份 store.js
模块,暴露了几个函数用来共享给页面和组件:
init()
用来在编辑模式下回填接口返回的商详数据;
reset()
用来清空并重置当前存储的数据;
// 在保存或某个场景结束操作时,需要重置单例所存储的数据
const store = require("./store.js");
onUnload() {
storage.reset();
}
set()
用来设置某个属性的值,同时它返回了 this
,这样可以链式调用:
const store = require("./store.js");
store
.set("productName", "商品名称")
.set("productBrand", "商品品牌");
get()
用来获取指定属性或全部属性的值;
const store = require("./store.js");
onShow() {
// 获取全部属性的值
const productInfo = storage.get();
// 获取指定属性的值
const productName = storage.get("productName");
}
removeItem()
用来移除某个属性的值;
策略模式有时是违法最少知识原则的,因为使用者可能要了解所有的 strategy 才能判断应该具体使用哪种 strategy。
例1:
const strategy = new SomeStrategy()
context.setStrategy(strategy)
context.doSomething()
例2:
const eventStrategies = {
commandStart: {
editor: async () => {},
},
commandEnd: {
editor: async () => {},
},
*: async () => {}
}
addEventListener((type, msg) => {
const strategy = eventStrategies[type][msg]
if (typeof strategy === 'function') {
strategy()
return
}
eventStrategies['*']()
})
例1:
class ObjType1 {
static add () {}
}
class ObjType2 {
static add () {}
}
function addElement(type) {
switch (type) {
case 'objType1':
ObjType1.add()
break
case 'objType2':
ObjType2.add()
break
default:
break
}
}
// 调用者无需关心具体有哪些对象类自己去调用对应对象类的 add 静态方法
addElement('objType1')
例2:
/**
* 调用者只需要知道对象 id,
* 就可以初始化对象实例,
* 不用关心具体代码是如何判断某个 id 对应的对象是哪个 class 的实例,
* 也不用关心里面的是否有缓存逻辑和是否有优先调用批查询命令替换多次调用单查询命令的情况
* 高级开发可以慢慢完善内部的具体实验,普通开发可以直接开发业务功能,只要确定好输入和输出即可
*/
batchGetElementInstanceList([id1, id2, id3, id4])
本文参考了以下文章:
备忘录模式(Memento Pattern)保存一个对象的某个状态,以便在适当的时候恢复对象。备忘录模式属于行为型模式。
备忘录模式使用三个类 Memento、Originator 和 CareTaker。Memento 包含了要被恢复的对象的状态。Originator 创建并在 Memento 对象中存储状态。Caretaker 对象负责从 Memento 中恢复对象的状态。
创建 Memento
类:
public class Memento {
private String state;
public Memento(String state){
this.state = state;
}
public String getState(){
return state;
}
}
创建 Originator
类。
public class Originator {
private String state;
public void setState(String state){
this.state = state;
}
public String getState(){
return state;
}
public Memento saveStateToMemento(){
return new Memento(state);
}
public void getStateFromMemento(Memento Memento){
state = Memento.getState();
}
}
创建 CareTaker
类。
import java.util.ArrayList;
import java.util.List;
public class CareTaker {
private List<Memento> mementoList = new ArrayList<Memento>();
public void add(Memento state){
mementoList.add(state);
}
public Memento get(int index){
return mementoList.get(index);
}
}
使用 CareTaker
和 Originator
对象。
public class MementoPatternDemo {
public static void main(String[] args) {
Originator originator = new Originator();
CareTaker careTaker = new CareTaker();
originator.setState("State #1");
originator.setState("State #2");
careTaker.add(originator.saveStateToMemento());
originator.setState("State #3");
careTaker.add(originator.saveStateToMemento());
originator.setState("State #4");
System.out.println("Current State: " + originator.getState());
originator.getStateFromMemento(careTaker.get(0));
System.out.println("First saved State: " + originator.getState());
originator.getStateFromMemento(careTaker.get(1));
System.out.println("Second saved State: " + originator.getState());
}
}
验证输出:
Current State: State #4
First saved State: State #2
Second saved State: State #3
本文参考了曾探的《JavaScript设计模式与开发实践》一书。
其实代理模式很常见:
注意:代理和本地的接口需要保持一致。
虚拟代理模式示例:
var myImage = (function(){
var imgNode = document.createElement( 'img' );
document.body.appendChild( imgNode );
return function( src ){
imgNode.src = src;
}
})();
var proxyImage = (function(){
var img = new Image;
img.onload = function(){
myImage( this.src );
}
return function( src ){
myImage( 'file:// /C:/Users/svenzeng/Desktop/loading.gif' );
img.src = src;
}
})();
proxyImage( 'http://imgcache.qq.com/music// N/k/000GGDys0yA0Nk.jpg' );
虚拟代理合并 HTTP 请求:
var synchronousFile = function( id ){
console.log( ’开始同步文件,id为: ' + id );
};
var proxySynchronousFile = (function(){
var cache = [], // 保存一段时间内需要同步的ID
timer; // 定时器
return function( id ){
cache.push( id );
if ( timer ){ // 保证不会覆盖已经启动的定时器
return;
}
timer = setTimeout(function(){
synchronousFile( cache.join( ', ' ) ); // 2秒后向本体发送需要同步的ID集合
clearTimeout( timer ); // 清空定时器
timer = null;
cache.length = 0; // 清空ID集合
}, 2000 );
}
})();
var checkbox = document.getElementsByTagName( 'input' );
for ( var i = 0, c; c = checkbox[ i++ ]; ){
c.onclick = function(){
if ( this.checked === true ){
proxySynchronousFile( this.id );
}
}
};
本文参考了曾探的《JavaScript设计模式与开发实战》。
有时候需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是什么,此时希望用一种松耦合的方式来设计软件,使得请求发送者和请求接收者能够消除彼此之间的耦合关系。
本文参考了曾探的《JavaScript设计模式与开发实战》。
享元(flyweight)模式是一种用于性能优化的模式,“fly”在这里是苍蝇的意思,意为蝇量级。享元模式的核心是运用共享技术来有效支持大量细粒度的对象。
如果系统中因为创建了大量类似的对象而导致内存占用过高,享元模式就非常有用了。在JavaScript中,浏览器特别是移动端的浏览器分配的内存并不算多,如何节省内存就成了一件非常有意义的事情。
对象池
一个典型的例子就是对象池。对象池维护一个装载空闲对象的池子,如果需要对象的时候,不是直接new,而是转从对象池里获取。如果对象池里没有空闲对象,则创建一个新的对象,当获取出的对象完成它的职责之后,再进入池子等待被下次获取。
示例代码如下:
var toolTipFactory = (function(){
var toolTipPool = []; // toolTip对象池
return {
create: function() {
// 如果对象池为空
if ( toolTipPool.length === 0 ) {
// 创建一个dom
var div = document.createElement( 'div' );
document.body.appendChild( div );
return div;
} else {
// 如果对象池里不为空
// 则从对象池中取出一个dom
return toolTipPool.shift();
}
},
recover: function( tooltipDom ){
// 对象池回收dom
return toolTipPool.push( tooltipDom );
}
}
})();
本文参考了曾探的《JavaScript设计模式与开发实战》。
职责链模式的定义是:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系,将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。
职责链模式的名字非常形象,一系列可能会处理请求的对象被连接成一条链,请求在这些对象之间依次传递,直到遇到一个可以处理它的对象,我们把这些对象称为链中的节点。
一般情况下我们会利用一个 Chain
类来把普通函数包装成职责链的节点。但是利用 JavaScript 的函数式特性,有一种更加方便的方法来创建职责链:
Function.prototype.after = function( fn ){
var self = this;
return function(){
var ret = self.apply( this, arguments );
if ( ret === 'nextSuccessor' ){
return fn.apply( this, arguments );
}
return ret;
}
};
var order = order500yuan.after( order200yuan ).after( orderNormal );
order( 1, true, 500 ); // 输出:500元定金预购,得到100优惠券
order( 2, true, 500 ); // 输出:200元定金预购,得到50优惠券
order( 1, false, 500 ); // 输出:普通购买,无优惠券
用 AOP 来实现职责链既简单又巧妙,但这种把函数叠在一起的方式,同时也叠加了函数的作用域,如果链条太长的话,也会对性能有较大的影响。
AOP
AOP 是 Aspect Oriented Programming 的缩写,即“面向切面编程”。编程中,对象之间、方法之间、模块之间,都是一个个的切面。
本文参考了曾探的《JavaScript设计模式与开发实战》。
面向对象设计鼓励将行为分布到各个对象中,把对象划分成更小的粒度,有助于增强对象的可复用性,但由于这些细粒度对象之间的联系激增,又有可能会反过来降低它们的可复用性。
中介者模式的作用就是解除对象与对象之间的紧耦合关系。增加一个中介者对象后,所有的相关对象都通过中介者对象来通信,而不是互相引用,所以当一个对象发生改变时,只需要通知中介者对象即可。中介者使各对象之间耦合松散,而且可以独立地改变它们之间的交互。中介者模式使网状的多对多关系变成了相对简单的一对多关系。
有个购买商品(比如内存条)时选规则然后校验库存的场景:现在我们来引入中介者对象,所有的节点对象只跟中介者通信。当下拉选择框colorSelect、memorySelect和文本输入框numberInput发生了事件行为时,它们仅仅通知中介者它们被改变了,同时把自身当作参数传入中介者,以便中介者辨别是谁发生了改变。剩下的所有事情都交给中介者对象来完成,这样一来,无论是修改还是新增节点,都只需要改动中介者对象里的代码。
var goods = { // 手机库存
"red|32G": 3,
"red|16G": 0,
"blue|32G": 1,
"blue|16G": 6
};
var mediator = (function(){
var colorSelect = document.getElementById( 'colorSelect' ),
memorySelect = document.getElementById( 'memorySelect' ),
numberInput = document.getElementById( 'numberInput' ),
colorInfo = document.getElementById( 'colorInfo' ),
memoryInfo = document.getElementById( 'memoryInfo' ),
numberInfo = document.getElementById( 'numberInfo' ),
nextBtn = document.getElementById( 'nextBtn' );
return {
changed: function( obj ){
var color = colorSelect.value, // 颜色
memory = memorySelect.value, // 内存
number = numberInput.value, // 数量
stock = goods[ color + '|' + memory ]; // 颜色和内存对应的手机库存数量
if ( obj === colorSelect ){ // 如果改变的是选择颜色下拉框
colorInfo.innerHTML = color;
}else if ( obj === memorySelect ){
memoryInfo.innerHTML = memory;
}else if ( obj === numberInput ){
numberInfo.innerHTML = number;
}
if ( ! color ){
nextBtn.disabled = true;
nextBtn.innerHTML = ’请选择手机颜色’;
return;
}
if ( ! memory ){
nextBtn.disabled = true;
nextBtn.innerHTML = ’请选择内存大小’;
return;
}
if ( Number.isInteger ( number -0 ) && number > 0 ){ // 输入购买数量是否为正整数
nextBtn.disabled = true;
nextBtn.innerHTML = ’请输入正确的购买数量’;
return;
}
nextBtn.disabled = false;
nextBtn.innerHTML = ’放入购物车’;
}
}
})();
// 事件函数:
colorSelect.onchange = function(){
mediator.changed( this );
};
memorySelect.onchange = function(){
mediator.changed( this );
};
numberInput.oninput = function(){
mediator.changed( this );
};
可以想象,某天我们又要新增一些跟需求相关的节点,比如CPU型号,那我们只需要稍稍改动 mediator
对象即可:
var goods = { // 手机库存
"red|32G|800": 3, // 颜色red,内存32G, cpu800,对应库存数量为3
"red|16G|801": 0,
"blue|32G|800": 1,
"blue|16G|801": 6
};
var mediator = (function(){
// 略
var cpuSelect = document.getElementById( 'cpuSelect' );
return {
change: function(obj){
// 略
var cpu = cpuSelect.value,
stock = goods[ color + '|' + memory + '|' + cpu ];
if ( obj === cpuSelect ){
cpuInfo.innerHTML = cpu;
}
// 略
}
}
})();
本文参考了曾探的《JavaScript设计模式与开发实战》。
装饰者模式可以动态地给某个对象添加一些额外的职责,而不会影响从这个类中派生的其他对象。
JavaScript语言动态改变对象相当容易,我们可以直接改写对象或者对象的某个方法,并不需要使用“类”来实现装饰者模式。
在JavaScript中,几乎一切都是对象,其中函数又被称为一等对象。在平时的开发工作中,也许大部分时间都在和函数打交道。在JavaScript中可以很方便地给某个对象扩展属性和方法,但却很难在不改动某个函数源代码的情况下,给该函数添加一些额外的功能。在代码的运行期间,我们很难切入某个函数的执行环境。
这里我们可以用 AOP 的方式来装饰函数:
Function.prototype.before = function( beforefn ){
// 保存原函数的引用
var __self = this;
// 返回包含了原函数和新函数的"代理"函数
return function(){
/**
* 执行新函数,且保证this不被劫持,新函数接受的参数
* 也会被原封不动地传入原函数,新函数在原函数之前执行
*/
beforefn.apply( this, arguments );
/**
* 执行原函数并返回原函数的执行结果,
* 并且保证this不被劫持
*/
return __self.apply( this, arguments );
}
}
Function.prototype.after = function( afterfn ){
var __self = this;
return function(){
var ret = __self.apply( this, arguments );
afterfn.apply( this, arguments );
return ret;
}
};
使用示例:
window.onload = function(){
console.log(1);
}
// 该回调被触发时,会依次输出数字:0、1、2、3、4。
window.onload = ( window.onload || function(){} )
.before(function () {
console.log(0)
})
.after(function(){
console.log(2);
})
.after(function(){
console.log(3);
})
.after(function(){
console.log(4);
});
案例:数据统计上报
分离业务代码和数据统计代码,无论在什么语言中,都是 AOP 的经典应用之一。
<html>
<button tag="login" id="button">点击打开登录浮层</button>
<script>
Function.prototype.after = function( afterfn ){
var __self = this;
return function(){
var ret = __self.apply( this, arguments );
afterfn.apply( this, arguments );
return ret;
}
};
var showLogin = function(){
console.log('打开登录浮层');
}
var log = function(){
console.log('上报标签为: ' + this.getAttribute( 'tag' ) );
}
// 打开登录浮层之后上报数据
showLogin = showLogin.after( log );
document.getElementById( 'button' ).onclick = showLogin;
</script>
</html>
案例:用 AOP 动态改变函数的参数
var getToken = function(){
return 'Token';
}
ajax = ajax.before(function( type, url, param ){
param.Token = getToken();
});
ajax( 'get', 'http://xxx.com/userinfo', { name: 'sven' } );
案例:插件式的表单校验
Function.prototype.before = function( beforefn ){
var __self = this;
return function(){
if ( beforefn.apply( this, arguments ) === false ){
// beforefn返回false的情况直接return,不再执行后面的原函数
return;
}
return __self.apply( this, arguments );
}
}
var validata = function(){
if ( username.value === '' ){
alert ( ’用户名不能为空’ );
return false;
}
if ( password.value === '' ){
alert ( ’密码不能为空’ );
return false;
}
}
var formSubmit = function(){
var param = {
username: username.value,
password: password.value
}
ajax( 'http://xxx.com/login', param );
}
formSubmit = formSubmit.before( validata );
submitBtn.onclick = function(){
formSubmit();
}
值得注意的是,因为函数通过Function.prototype.before或者Function.prototype.after被装饰之后,返回的实际上是一个新的函数,如果在原函数上保存了一些属性,那么这些属性会丢失。
另外,这种装饰方式也叠加了函数的作用域,如果装饰的链条过长,性能上也会受到一些影响。
装饰者模式和代理模式
代理模式和装饰者模式最重要的区别在于它们的意图和设计目的。代理模式的目的是,当直接访问本体不方便或者不符合需要时,为这个本体提供一个替代者。本体定义了关键功能,而代理提供或拒绝对它的访问,或者在访问本体之前做一些额外的事情。装饰者模式的作用就是为对象动态加入行为。换句话说,代理模式强调一种关系(Proxy与它的实体之间的关系),这种关系可以静态的表达,也就是说,这种关系在一开始就可以被确定。而装饰者模式用于一开始不能确定对象的全部功能时。代理模式通常只有一层代理-本体的引用,而装饰者模式经常会形成一条长长的装饰链。
本文参考了曾探的《JavaScript设计模式与开发实战》。
适配器模式是一对相对简单的模式。有一些模式跟适配器模式的结构非常相似,比如装饰者模式、代理模式和外观模式。这几种模式都属于“包装模式”,都是由一个对象来包装另一个对象。区别它们的关键仍然是模式的意图。
本文参考了曾探的《JavaScript设计模式与开发实战》。
提炼函数
如果一个函数过长,不得不加上若干注释才能让这个函数显得易读一些,那这些函数就很有必要进行重构。
如果在函数中有一段代码可以被独立出来,那我们最好把这些代码放进另外一个独立的函数中。这是一种很常见的优化工作,这样做的好处主要有以下几点。
合并重复的条件片段
比如把这段代码:
var paging = function( currPage ){
if ( currPage <= 0 ){
currPage = 0;
jump( currPage ); // 跳转
}else if ( currPage >= totalPage ){
currPage = totalPage;
jump( currPage ); // 跳转
}else{
jump( currPage ); // 跳转
}
};
修改成:
var paging = function( currPage ){
if ( currPage <= 0 ){
currPage = 0;
}else if ( currPage >= totalPage ){
currPage = totalPage;
}
jump( currPage ); // 把jump函数独立出来
};
把条件分支语句提炼成函数
比如把下面这段代码:
var getPrice = function( price ){
var date = new Date();
if ( date.getMonth() >= 6 && date.getMonth() <= 9 ){ // 夏天
return price * 0.8;
}
return price;
};
观察这句代码:
if ( date.getMonth() >= 6 && date.getMonth() <= 9 ){
// ...
}
修改成:
var isSummer = function(){
var date = new Date();
return date.getMonth() >= 6 && date.getMonth() <= 9;
};
var getPrice = function( price ){
if ( isSummer() ){ // 夏天
return price * 0.8;
}
return price;
};
合理使用循环
比如将下面这段代码:
var createXHR = function(){
var xhr;
try{
xhr = new ActiveXObject( 'MSXML2.XMLHttp.6.0' );
}catch(e){
try{
xhr = new ActiveXObject( 'MSXML2.XMLHttp.3.0' );
}catch(e){
xhr = new ActiveXObject( 'MSXML2.XMLHttp' );
}
}
return xhr;
};
var xhr = createXHR();
修改成:
var createXHR = function(){
var versions= [
'MSXML2.XMLHttp.6.0ddd',
'MSXML2.XMLHttp.3.0',
'MSXML2.XMLHttp'
];
for ( var i = 0, version; version = versions[ i++ ]; ){
try{
return new ActiveXObject( version );
}catch(e){
}
}
};
var xhr = createXHR();
提前让函数退出代替嵌套条件分支
比如将下面这段代码:
var del = function( obj ){
var ret;
if ( ! obj.isReadOnly ){ // 不为只读的才能被删除
if ( obj.isFolder ){ // 如果是文件夹
ret = deleteFolder( obj );
}else if ( obj.isFile ){ // 如果是文件
ret = deleteFile( obj );
}
}
return ret;
};
修改为:
var del = function( obj ){
if ( obj.isReadOnly ){ // 反转if表达式
return;
}
if ( obj.isFolder ){
return deleteFolder( obj );
}
if ( obj.isFile ){
return deleteFile( obj );
}
};
传递对象参数代替过长的参数列表
比如将下面的代码:
var setUserInfo = function( id, name, address, sex, mobile, qq ){
console.log( 'id= ' + id );
console.log( 'name= ' +name );
console.log( 'address= ' + address );
console.log( 'sex= ' + sex );
console.log( 'mobile= ' + mobile );
console.log( 'qq= ' + qq );
};
setUserInfo( 1314, 'sven', 'shenzhen', 'male', '137********', 377876679 );
修改为:
var setUserInfo = function( obj ){
console.log( 'id= ' + obj.id );
console.log( 'name= ' + obj.name );
console.log( 'address= ' + obj.address );
console.log( 'sex= ' + obj.sex );
console.log( 'mobile= ' + obj.mobile );
console.log( 'qq= ' + obj.qq );
};
setUserInfo({
id: 1314,
name: 'sven',
address: 'shenzhen',
sex: 'male',
mobile: '137********',
qq: 377876679
});
尽量减少参数数量
比如将下面这段代码:
var draw = function( width, height, square ){};
修改为:
// square 完全可以在函数内部自行计算出来,不需要从外部传入
var draw = function( width, height ){
var square = width * height;
};
少用三目运算符
如果条件分支逻辑简单且清晰,这无碍我们使用三目运算符:
var global = typeof window ! == "undefined" ? window : this;
但是像下面这样就不合适了:
if ( ! aup || ! bup ) {
return a === doc ? -1 :
b === doc ? 1 :
aup ? -1 :
bup ? 1 :
sortInput ?
( indexOf.call( sortInput, a ) - indexOf.call( sortInput, b ) ) :
0;
}
合理使用链式调用
在JavaScript中,可以很容易地实现方法的链式调用,即让方法调用结束后返回对象自身,如下代码所示:
var User = function(){
this.id = null;
this.name = null;
};
User.prototype.setId = function( id ){
this.id = id;
return this;
};
User.prototype.setName = function( name ){
this.name = name;
return this;
};
console.log( new User().setId( 1314 ).setName( 'sven' ) );
或者这样写也可以:
var User = {
id: null,
name: null,
setId: function( id ){
this.id = id;
return this;
},
setName: function( name ){
this.name = name;
return this;
}
};
console.log( User.setId( 1314 ).setName( 'sven' ) );
使用链式调用的方式并不会造成太多阅读上的困难,也确实能省下一些字符和中间变量,但节省下来的字符数量同样是微不足道的。链式调用带来的坏处就是在调试的时候非常不方便,如果我们知道一条链中有错误出现,必须得先把这条链拆开才能加上一些调试log或者增加断点,这样才能定位错误出现的地方。
如果该链条的结构相对稳定,后期不易发生修改,那么使用链式调用无可厚非。但如果该链条很容易发生变化,导致调试和维护困难,那么还是建议使用普通调用的形式:
var user = new User();
user.setId( 1314 );
user.setName( 'sven' );
分解大型类
示例代码:
var Spirit = function( name ){
this.name = name;
this.attackObj = new Attack( this );
};
Spirit.prototype.attack = function( type ){ // 攻击
this.attackObj.start( type );
};
var spirit = new Spirit( 'RYU' );
spirit.attack( 'waveBoxing' ); // 输出:RYU:使用波动拳
spirit.attack( 'whirlKick' ); // 输出:RYU:使用旋风腿
用return退出多重循环
const webpackConfig = {
entry: {
bundle: resolve('src/index.lint.jsx'),
componentsInFolders: glob.sync(resolve('src/components/*/*.js?(x)')),
componentsInRoot: glob.sync(resolve('src/components/*.js?(x)')),
api: glob.sync(resolve('src/api/*.js?(x)')),
utils: glob.sync(resolve('src/utils/*.js?(x)')),
},
output: {
path: resolve('dist'),
filename: '[name].js',
publicPath: '',
},
externals: {
'zepto': 'window.$',
'highcharts': 'window.Highcharts',
'jsencrypt': 'window.JSEncrypt',
},
resolve: {
extensions: [
'.lint.jsx',
'.jsx',
'.js',
'.css',
'.ejs',
'.scss',
'.json',
'.sass',
],
alias: {
'@': resolve('src'),
'@img': resolve('src/assets/img'),
'@utils': resolve('src/utils'),
'@css': resolve('src/assets/css'),
'@component': resolve('src/components'),
},
},
resolveLoader: {
modules: [
'node_modules',
],
},
// Some libraries import Node modules but don't use them in the browser.
// Tell Webpack to provide empty mocks for them so importing them works.
node: {
dgram: 'empty',
fs: 'empty',
net: 'empty',
tls: 'empty',
child_process: 'empty',
},
module: {
rules: [
{
test: /\.jsx?$/,
use: [
'happypack/loader?id=babel',
conditionalCompiler,
],
include: [resolve('src')],
},
{
test: /\.(png|jpg|gif|svg|mp3)$/,
loader: 'file-loader',
exclude: /node_modules/,
query: {
name: 'assets/img/[name].[ext]',
// name: 'assets/img/[path][module-img][name].[ext]',
},
},
{
test: /\.less$/,
use: [
{ loader: 'style-loader' },
{ loader: 'css-loader' },
{ loader: 'postcss-loader' },
{
loader: 'less-loader',
options: {
modifyVars: require('../package').theme,
javascriptEnabled: true,
},
},
],
},
],
},
plugins: [
new webpack.DllReferencePlugin({
context: __dirname,
manifest: dllManifestForVendorCxg,
}),
new webpack.DllReferencePlugin({
context: __dirname,
manifest: dllManifestForVendorCxgOthers,
}),
new webpack.LoaderOptionsPlugin({
options: {
customInterpolateName (url) {
if (/\[module-img\]/.test(url)) {
url = url.replace(/img[\\/]/g, '')
url = url.replace(/\[module-img\]/, '')
const folderName = url.replace(
/^.*[\\/]([\w-]+)[\\/][\w-]+\.[\w]+/,
'$1'
)
const fileName = url.replace(
/^.+[\\/]([\w-]+\.[\w]+$)/,
'$1'
)
url = `assets/img/${folderName}/${fileName}`
return url
}
return url
},
},
}),
new HappyPack({
id: 'babel',
threads: os.cpus().length,
verbose: false,
loaders: [
{
loader: 'babel-loader',
options: {
cacheDirectory: 'node_modules/.cache/babel-loader',
},
},
],
}),
new CopyWebpackPlugin([
{
from: resolve('src/hbenv.js'),
to: './assets/js/hbenv.js',
},
{
from: resolve('example'),
to: './example',
},
{
from: resolve('favicon.ico'),
to: './',
},
]),
],
}
module.exports = webpackConfig
通过配置不同的 entry 来生成不同的 chunk。
hash
hash是跟整个项目的构建相关,只要项目里有文件更改, 整个项目构建的hash值都会更改,并且全部文件都共用相同的hash值
chunkhash
采用hash计算的话,每一次构建后生成的哈希值都不一样, 即使文件内容压根没有改变。这样子是没办法实现缓存效果, 我们需要换另一种哈希值计算方式,即chunkhash。
chunkhash和hash不一样, 它根据不同的入口文件(Entry)进行依赖文件解析、构建对应的chunk, 生成对应的哈希值。 我们在生产环境里把一些公共库和程序入口文件区分开,单独打包构建, 接着我们采用chunkhash的方式生成哈希值, 那么只要我们不改动公共库的代码,就可以保证其哈希值不会受影响。
contenthash
只要内容不变,hash值就不变。
const loaderUtils = require('loader-utils');
module.exports = function (source /* 逐个处理的文件内容 */) {
const self = this
const options = loaderUtils.getOptions(self)
const resourcePath = self.resourcePath
// 根据上面的一些信息处理resource
return resource
}
module.exports = class FixedChunkIdPlugin {
constructor (options) {
this.options = options || {}
}
apply (compiler) {
compiler.plugin('compilation', (compilation) =>
compilation.plugin('before-chunk-ids', (chunks) => {
chunks.forEach((chunk) => {
if (!chunk.id) {
/**
* 要求定义路由的chunk名时不要重名
* 我们现有的逻辑,如果页面chunk名重复的话会生成同一个页面js,
* 本来就是不允许重名的
*/
chunk.id = chunk.name
}
})
})
)
}
}
发现简书控制台会输出性能相关的一些统计数据,类似下面这样:
{
"link": "https://www.jianshu.com/",
"times": {
"alltime": 31772,
"details": {
"redirect": 0,
"dns": 0,
"ttfb": 751,
"static": 741,
"render": 31018,
"onload": 1624684987502
},
"lifecycle": {
"_1": {
"key": "redirect",
"desc": "网页重定向的耗时",
"value": 0
},
"_2": {
"key": "cache",
"desc": "检查本地缓存的耗时",
"value": 0
},
"_3": {
"key": "dns",
"desc": "DNS查询的耗时",
"value": 0
},
"_4": {
"key": "tcp",
"desc": "TCP连接的耗时",
"value": 0
},
"_5": {
"key": "request",
"desc": "客户端发起请求的耗时",
"value": 738
},
"_6": {
"key": "response",
"desc": "服务端响应的耗时",
"value": 3
},
"_7": {
"key": "render",
"desc": "渲染页面的耗时",
"value": 31018
},
"__": 31759
}
},
"ua": "Mozilla/5.0 (Macintosh; ... 4 Safari/537.36"
}
去把他们编译产物拿出来格式化处理后,我们来一个个看这些值都是怎么取到的。
link的取值为window.location.href.split("?")[0]
。
ua的取值为:navigator.userAgent
。
编译产物代码里性能数据的核心代码如下:
window.addEventListener("load", function() {
setTimeout(function() {
var e = window.performance;
if (e) {
var t = e.timing,
n = {
_1: {
key: "redirect",
desc: "网页重定向的耗时",
value: t.redirectEnd - t.redirectStart
},
_2: {
key: "cache",
desc: "检查本地缓存的耗时",
value: t.domainLookupStart - t.fetchStart
},
_3: {
key: "dns",
desc: "DNS查询的耗时",
value: t.domainLookupEnd - t.domainLookupStart
},
_4: {
key: "tcp",
desc: "TCP连接的耗时",
value: t.connectEnd - t.connectStart
},
_5: {
key: "request",
desc: "客户端发起请求的耗时",
value: t.responseStart - t.requestStart
},
_6: {
key: "response",
desc: "服务端响应的耗时",
value: t.responseEnd - t.responseStart
},
_7: {
key: "render",
desc: "渲染页面的耗时",
value: t.domComplete - t.responseEnd
}
},
o = 0;
(0, a.default)(n).forEach(function(e) {
n[e] && n[e].value > 0 && (o += n[e].value)
}), n.__ = o;
var r = {
redirect: t.redirectEnd - t.redirectStart,
dns: t.domainLookupEnd - t.domainLookupStart,
ttfb: t.responseStart - t.navigationStart,
static: t.responseEnd - t.requestStart,
render: t.domComplete - t.responseEnd,
onload: t.responseEnd - t.redirectStart
},
i = {
alltime: t.domComplete - t.navigationStart,
details: r,
lifecycle: n
},
l = Date.now() % 5 == 0;
if ((0, a.default)(r).forEach(function(e) {
(r[e] > 15e3 || r[e] < 0) && (l = !1)
}), l) {
var u = "/" === window.location.pathname
? "/index"
: window.location.pathname;
try {
d.default.post(
"https://tr.jianshu.com/fe/1/mon/atf",
(0, s.default)({}, {
url: window.location.href.split("?")[0],
ua: navigator.userAgent,
path: u,
total: t.domComplete - t.navigationStart,
app: "maleskine",
tags: [
"undefined" != typeof ParadigmSDKv3
? "with-Paradigm"
: "without-Paradigm"
]
}, r)
).then(function() {}).catch(function() {})
} catch (e) {}
}
console && console.log({
link: window.location.href.split("?")[0],
times: i,
ua: navigator.userAgent
})
}
}, 0)
})
可以看出,主要是利用了performance这个性能api,当有该API时,才会记录相关的数据。
const timing = performance.timing
// 网页重定向的耗时
const redirect = timing.redirectEnd - timing.redirectStart
// 检查本地缓存的耗时
const cache = timing.domainLookupStart - timing.fetchStart
// DNS查询的耗时
const dns = timing.domainLookupEnd - timing.domainLookupStart
// TCP连接的耗时
const tcp = timing.connectEnd - timing.connectStart
// 客服端发起请求的耗时
const request = timing.responseStart - timing.requestStart
// 服务端响应的耗时
const response = timing.responseEnd - timing.responseStart
// 渲染页面的耗时
const render = timing.domComplete - timing.responseEnd
//
const ttfb = timing.responseStart - timing.navigationStart
// 发起请求到响应结束的耗时
const statics = timing.responseEnd - timing.requestStart
//
const onload = timing.responseEnd - timing.redirectStart
//
const alltime = timing.domComplete - timing.navigationStart
这个API其实已经被弃用,你可以通过在控制台执行performance.getEntries()
来查看相关的一些性能数据。
这里面的API和属性太多就不介绍了。
我们不看简书上的逻辑,根据MDN上的例子,可知:
页面加载时间
const perfData = window.performance.timing;
const pageLoadTime = perfData.loadEventEnd - perfData.navigationStart;
请求响应时间
const connectTime = perfData.responseEnd - perfData.requestStart;
页面渲染时间
const renderTime = perfData.domComplete - perfData.domLoading;
Babel的3个主要处理步骤:
提示
可以通过https://astexplorer.net/在线工具将待转换的代码转换成AST抽象语法树。
以下代码:
let tips = [
"Click on any AST node with a '+' to expand it",
"Hovering over a node highlights something",
"Shift click on an AST node to expand the whole subtree"
];
function printTips() {
tips.forEach((tip, i) => console.log(`Tip ${i}:` + tip));
}
转换成AST后长这样:
{
"type": "Program",
"start": 0,
"end": 481,
"body": [
{
"type": "VariableDeclaration",
"start": 179,
"end": 394,
"declarations": [
{
"type": "VariableDeclarator",
"start": 183,
"end": 393,
"id": {
"type": "Identifier",
"start": 183,
"end": 187,
"name": "tips"
},
"init": {
"type": "ArrayExpression",
"start": 190,
"end": 393,
"elements": [
{
"type": "Literal",
"start": 194,
"end": 241,
"value": "Click on any AST node with a '+' to expand it",
"raw": "\"Click on any AST node with a '+' to expand it\""
},
{
"type": "Literal",
"start": 246,
"end": 330,
"value": "Hovering over a node highlights something",
"raw": "\"Hovering over a node highlights something\""
},
{
"type": "Literal",
"start": 335,
"end": 391,
"value": "Shift click on an AST node to expand the whole subtree",
"raw": "\"Shift click on an AST node to expand the whole subtree\""
}
]
}
}
],
"kind": "let"
},
{
"type": "FunctionDeclaration",
"start": 396,
"end": 480,
"id": {
"type": "Identifier",
"start": 405,
"end": 414,
"name": "printTips"
},
"expression": false,
"generator": false,
"async": false,
"params": [],
"body": {
"type": "BlockStatement",
"start": 417,
"end": 480,
"body": [
{
"type": "ExpressionStatement",
"start": 421,
"end": 478,
"expression": {
"type": "CallExpression",
"start": 421,
"end": 477,
"callee": {
"type": "MemberExpression",
"start": 421,
"end": 433,
"object": {
"type": "Identifier",
"start": 421,
"end": 425,
"name": "tips"
},
"property": {
"type": "Identifier",
"start": 426,
"end": 433,
"name": "forEach"
},
"computed": false,
"optional": false
},
"arguments": [
{
"type": "ArrowFunctionExpression",
"start": 434,
"end": 476,
"id": null,
"expression": true,
"generator": false,
"async": false,
"params": [
{
"type": "Identifier",
"start": 435,
"end": 438,
"name": "tip"
},
{
"type": "Identifier",
"start": 440,
"end": 441,
"name": "i"
}
],
"body": {
"type": "CallExpression",
"start": 446,
"end": 476,
"callee": {
"type": "MemberExpression",
"start": 446,
"end": 457,
"object": {
"type": "Identifier",
"start": 446,
"end": 453,
"name": "console"
},
"property": {
"type": "Identifier",
"start": 454,
"end": 457,
"name": "log"
},
"computed": false,
"optional": false
},
"arguments": [
{
"type": "BinaryExpression",
"start": 458,
"end": 475,
"left": {
"type": "TemplateLiteral",
"start": 458,
"end": 469,
"expressions": [
{
"type": "Identifier",
"start": 465,
"end": 466,
"name": "i"
}
],
"quasis": [
{
"type": "TemplateElement",
"start": 459,
"end": 463,
"value": {
"raw": "Tip ",
"cooked": "Tip "
},
"tail": false
},
{
"type": "TemplateElement",
"start": 467,
"end": 468,
"value": {
"raw": ":",
"cooked": ":"
},
"tail": true
}
]
},
"operator": "+",
"right": {
"type": "Identifier",
"start": 472,
"end": 475,
"name": "tip"
}
}
],
"optional": false
}
}
],
"optional": false
}
}
]
}
}
],
"sourceType": "module"
}
进入一个节点,实际上就是在访问这个节点。之所以使用这个术语,是因为有个叫做访问者模式的概念。
访问者是一个用于 AST 遍历的跨语言的模式。简单的说它们就是一个对象,定义了用于在一个树状结构中获取具体节点的方法。 比如:
const Visitor = {
Identifier() {
console.log("Called!");
}
};
// Identifier() { ... } 是 Identifier: { enter() { ... } } 的简写形式。.
// 也可以先创建一个访问者对象,并在稍后给它添加方法。
let visitor = {};
visitor.MemberExpression = function() {};
visitor.FunctionDeclaration = function() {}
以上为一个简单的访问者,用于遍历时,每遇到一个Identifier的时候都会调用Identifier()方法。
此种方法默认为在进入节点时进行操作,也可以在退出节点时进行操作:
const Visitor = {
Identifier: {
enter() {
console.log("Entered!");
},
exit() {
console.log("Exited!");
}
}
};
如果对不同节点有同样的操作,可以用“|”将方法名分隔开:
const visitor = {
"Idenfifier |MemberExpression"(path){}
}
特别的,还可以使用别名(https://github.com/babel/babel/tree/master/packages/babel-types/src/definitions)去定义:
//Function is an alias for FunctionDeclaration, FunctionExpression,
//ArrowFunctionExpression, ObjectMethod and ClassMethod.
const Visitor = {
Function(path) {}
}
AST 通常会有许多节点,Path 是表示两个节点之间连接的对象。 在某种意义上,路径是一个节点在树中的位置以及关于该节点各种信息的响应式 Reactive 表示。 当你调用一个修改树的方法后,路径信息也会被更新。 Babel 帮你管理这一切,从而使得节点操作简单,尽可能做到无状态。
Paths in Visitors(存在于访问者中的路径)
当你调用一个访问者的Identifier() 成员方法时,你实际上是在访问路径而非节点。 通过这种方式,你操作的就是节点的响应式表示(即路径)而非节点本身。
// 对表达式:a+b+c
const visitor = {
Identifier(path){
console.log("now: " + path.node.name);
}
}
// 使用path.traverse(visitor)进行转换
path.traverse(visitor);
//以下是输出结果:
now: a
now: b
now: c
状态是抽象语法树AST转换的敌人,状态管理会不断牵扯你的精力。 而且几乎所有你对状态的假设,总是会有一些未考虑到的语法最终证明你的假设是错误的。
const parser = require("@babel/parser"); // 解析,js转AST
const traverse = require("@babel/traverse").default; // 转换
const t = require("@babel/types");
const generator = require("@babel/generator").default; // 生成
const fs = require('fs'); // 文件读写
target_js = "function square(n) { return n * n;}"
// 尝试将该代码中的n都变为x
const visitor = {
FunctionDeclaration(path){
const param = path.node.params[0]
if (param.name === 'n'){
paramName = param.name;
param.name = 'x'
}
},
Identifier(path){
if (path.node.name === paramName){
path.node.name = "x"
}
}
}
let ast = parser.parse(target_js);
traverse(ast, visitor);
let {code} = generator(ast);
// 输出:
function square(x) {
return x * x;
}
以上代码可以做到将n变为x,但如果js代码变为:
function square(n){
return n * n;
}
function add(n, m){
return n + m
}
输出结果就变成了:
function square(x) {
return x * x;
}
function add(x, m) {
return x + m;
}
但我们本意只想变换square方法中的n。于是可以用递归的方式:
const parser = require("@babel/parser"); // 解析,js转AST
const traverse = require("@babel/traverse").default; // 转换
const t = require("@babel/types");
const generator = require("@babel/generator").default; // 生成
const fs = require('fs'); // 文件读写
var target_js = "function square(n){\n" +
" return n * n;\n" +
"}\n" +
"\n" +
"function add(n, m){\n" +
" return n + m\n" +
"}"
// 尝试将该代码中的n都变为x
const updateParamName = {
"Identifier"(path){
if (path.node.name === this.paramName){
path.node.name = "x"
}
}
}
const visitor = {
"FunctionDeclaration"(path){
if (path.node.params.length > 1){
return;
}
const param = path.node.params[0]
if (param.name === 'n'){
paramName = param.name;
param.name = 'x'
path.traverse(updateParamName, { paramName });
}
}
}
let ast = parser.parse(target_js);
traverse(ast, visitor);
let {code} = generator(ast);
console.log(code)
//输出结果
function square(x) {
return x * x;
}
function add(n, m) {
return n + m;
}
此处为特殊例子,为了演示如何从访问者中消除全局状态。
JavaScript支持词法作用域,在树状嵌套结构中代码块创建出新的作用域。
在JavaScript中,每创建一个引用,不管是通过变量(variable)、函数(function)、类型(class)、 参数(params)、模块导入(import)还是标签(label)等,都属于当前作用域。
更深的内部作用域代码可以使用外层作用域中的引用。
内层作用域也可以创建和外层作用域同名的引用。
当写转换时,必须小心作用域,必须要确保在改变代码的各个部分时不会破坏已经存在的代码。
在添加一个新的引用时需要确保新增加的引用名字和已有的所有引用不冲突。 或者想找出使用一个变量的所有引用,应该在给定的作用域中找出这些引用。
作用域可以表示为:
const scope = {
path: path,
block: path.node,
parentBlock: path.parent,
parent: parentScope,
bindings: [/** ... */]
}
创建一个新的作用域,需要给出它的路径和父作用域,之后在遍历过程中它会在该作用域内收集所有的引用("绑定")。
一旦引用收集完毕,就可以在作用域上使用各种方法。
所有引用属于特定的作用域,引用和作用域的这种关系被称为:绑定(binding)。
单个绑定可以表示为:
const binding = {
identifier: node,
scope: scope,
path: path,
kind: 'var',
referenced: true,
references: 3,
referencePaths: [path, path, path],
constant: false,
constantViolations: [path]
}
有以上信息就可以查找一个绑定的所有引用,并且知道这是什么类型的绑定(参数,定义等), 查找所属的作用域,或者拷贝标识符,甚至知道是不是常量,如果不是,那么哪里修改了它。
在很多情况下,知道一个绑定是否是常量非常有用,最有用的一种情形就是代码压缩。
Babel实际上是一组模块的集合。接下来是一些主要的模块,会解释他们是做什么的,以及如何使用。
Babylon是Babel的解析器。最初是从Acorn项目fork出来的。Acorn非常快,易于使用,并且针对非标准特性(以及未来的标准特性)设计了一个基于插件的架构。
Babel Traverse模块维护了整棵树的状态,并且负责替换、移除和添加节点。
Babel Types模块是一个用于AST节点的Lodash式工具库(JavaScript函数工具库,提供了基于函数式编程风格的众多工具函数), 包含了构造、验证以及变化AST节点的方法。 该工具库包含考虑周到的工具方法,对编写处理AST逻辑非常有用。
Babel Types模块拥有每一个单一类型节点的定义,包括节点包含那些属性,什么是合法值,如何构建节点、遍历节点,以及节点的别名等信息。
单一节点类型的定义形式如下:
defineType("BinaryExpression", {
builder: ["operator", "left", "right"],
fields: {
operator: {
validate: assertValueType("string")
},
left:{
validate: assertNodeType("Expression")
},
right:{
validate: assertNodeType("Expression")
}
},
visitor: ["left", "right"],
aliases: ["Binary", "Expression"]
});
上边的定义中有builder字段,这个字段的出现是因为每个节点类型都有构造器方法builder,使用方法:
type.binaryExpression("*", type.identifier("a"), type.identifiier("b"));
可以创建的AST:
const ast = {
type: "BinaryExpression",
operator: "*",
left: {
type: "Identifier",
name: "a"
},
right: {
type: "Identifier",
name: "b"
}
}
转为js代码后:
a * b
构造器还会验证自身创建的节点,并在错误使用的情况下抛出描述性错误。于是有了验证器。
BinaryExpression的定义还包含了节点的字段fields信息,以及如何验证这些字段。
const validator = {
fields: {
operator: {
validate: assertValueType("string")
},
left: {
validate: assertNodeType("Expression")
},
right: {
validate: assertNodeType("Expression")
}
}
}
可以创建两种验证方法。第一种是isX。
type.isBinaryExpression(maybeBinaryExpressionNode)
这个测试用来确保节点是一个二进制表达式,另外你也可以传入第二个参数来确保节点包含特定的属性和属性值。
type.isBinaryExpression(maybeBinaryExpressionNode, { operator: "*" });
还有一些断言式版本,会抛出异常而非true或false。
type.assertBinaryExpressiion(maybeBinaryExpressionNode);
type.assertBiinaryExpression(maybeBinaryExpressionNode, { operator: "*" });
// Error: Expected type "BinaryExpression" with option { "operator": "*" }
是Babel的代码生成器,读取AST并将其转换为代码和源码映射。
const code = '......'
const ast = babylon.parse(code);
generate(ast, {}, code);
// 结果
// {
// code: "...",
// map: "..."
// }
// 也可以传递选项
generate(ast, {
retainLines: false,
compact: "auto",
concise: false,
quotes: "double",
}, code);
是另一个虽然小但非常有用的模块,能使你编写字符串形式切带有占位符的代码来代替手动编码,尤其生成大规模AST时。 在计算机科学中,这种能力被称为准引用(quasiquotes)。
import template from "babel-template";
import generate from "babel-generator";
import * as type from "babel-types";
const buildRequire = template(' var IMPORT_NAME = require(SOURCE); ');
const ast = buildRequire({
IMPORT_NAME: type.identifier("myModule"),
SOURCE: type.stringLiteral("my-module")
});
console.log(generate(ast).code);
// 结果:
var myModule = require("my-module");
为了得到一个AST节点的属性值,我们一般先访问到该节点,然后利用path.node.property方法即可。
// BinaryExpression AST node 的属性: 'left', 'right', 'operator'
BinaryExpression(path) {
path.node.left;
path.node.right;
path.node.operator;
}
访问该属性内部的path,使用path对象的get方法,传递属性的字符串形式作为参数:
BinaryExpression(path){
path.get('left');
}
Program(path){
path.get('body.0');
}
如果想检查节点的类型,最好的方式是:
BinaryExpression(path){
if (t.isIdentifier(path.node.left)){
// ...
}
}
或者可以对节点的属性做浅层检查:
BinaryExpression(path){
if (t.isIdentifier(path.node.left, { name: "n" })) {
// ...
}
}
功能上等价于:
BinaryExpression(path){
if (path.node.left != null &&
path.node.left.type === "Identiifiier" &&
path.node.left.name === "n"
){
// ...
}
}
BinaryExpression(path) {
if (path.get('left').isIdentifier({ name: "n" })){
// ...
}
}
// 等价于
BinaryExpression(path) {
if (t.isIdentifier(path.node.left, { name: "n" })) {
// ...
}
}
Identifier(path) {
if (path.isReferencedIdentifier()) {
// ...
}
}
// 或者
Identifier(path) {
if (t.isReferenced(path.node, path.parent)) {
// ...
}
}
有时需要从一个路径向上遍历语法树,直到满足相应的条件。
/**
* 对于每一个父路径调用callback并将其NodePath当作参数,
* 当callback返回真值时,则将其NodePath返回。
*/
path.findParent((path) => path.isObjcetExpression());
// 如果也需要遍历当前节点:
path.find((path) => path.isObjectExpression());
// 查找最接近的父函数或程序:
path.getFunctionParent();
// 向上遍历语法树,直到找到在列表中的父节点路径
path.getStatementParent();
如果一个路径是在一个Function/Program中的列表里面,他就有同级节点。
target_js = `
var a = 1; // pathA, path.key = 0
var b = 2; // pathB, path.key = 1
var c = 3; // pathC, path.key = 2
`
export default function({ types: t }) {
return {
visitor: {
VariableDeclaration(path) {
// if the current path is pathA
path.inList // true
path.listKey // "body"
paht.key // 0
path.getSibling(0) // pathA
path.getSibling(path.key + 1) //pathB
path.container // [pathA, pathB, pathC]
}
}
};
}
如果插件需要在某种情况下不运行,最简单的做法是尽早返回:
BinaryExpression(path){
if (path.node.operator !== '**') return;
}
如果在顶级路径中进行子遍历,则可以使用2个提供的API方法:
path.skip() 会跳过当前路径之后的子节点遍历;path.stop() 完全停止遍历。
BinaryExpression(path) {
// 将当前BinaryExpression替换为:binaryExpression节点
// 该节点值为"**", left为path.node.left, right为t.numberLiteral(2),即2。
path.replaceWith(
t.binaryExpression("**", path.node.left, t.numberLiteral(2))
);
}
ReturnStatement(path) {
path.replaceWithMultiple([
t.expressionStatement(t.stringLiteral("Is this the real life?")),
t.expressionStatement(t.stringLiteral("Is this just fantasy?")),
t.expressionStatement(
t.stringLiteral(
"(Enjoy singing the rest of the song in you head)"
)
),
]);
}
提示
注意,当多个节点替换一个表达式时,他们必须是声明。 因为Babel在更换节点时广泛使用启发式算法,这意味着您可以做一些疯狂的转换,否则将会非常冗长。
FunctionDeclaration(path){
path.replaceWithSourceString(function add(a, b) {
return a + b;
});
}
// 结果:
- function square(n) {
- return n * n
- }
+ function add(a, b) {
+ return a + b
+ }
注意
不建议使用这个API,除非正在处理动态的源码字符串,否则在访问者外部解析代码更有效率
FunctionDeclaration(path) {
path.insertBefore(
t.expressionStatement(
t.stringLiteral("Because I'm easy come, easy go.")
)
);
path.insertAfter(
t.expressionStatement(
t.stringLiteral("A little high, little low.")
)
);
}
// 结果
+ "Because I'm easy come, easy go.";
function square(n) {
return n * n;
}
+ "A little high, little low.";
提示
这里同样应该使用声明或者一个声明数组。这个使用了在用多个节点替换一个节点中提到的相同的启发式算法。
如果要在AST节点属性中插入一个类似body那样的数组,其方法与insertBefore/insertAfter类似,但必须指定listKey。
ClassMethod(path) {
path.get('body').unshiftContainer(
'body',
t.expressionStatement(t.stringLiteral('before'))
);
path.get('body').pushContainer(
'body',
t.expressionStatement(t.stringLiteral('after'))
);
}
// 结果
class A{
+ "before"
var a = 'middle';
+ "after"
}
FunctionDeclaration(path) {
path.remove();
}
//结果
- function square(n) {
- return n * n;
- }
只需要用parentPath: path.parentPath.replaceWith即可:
BinaryExpression(path) {
path.parentPath.replaceWith(
t.expressionStatement(
t.stringLiteral(
"Anyway the wind blows, doesn't really matter to me, to me."
)
)
);
}
// 结果
function square(n) {
- return n * n
+ "Anyway the wind blows, doesn't really matter to me, to me.";
}
BinaryExpression(path) {
path.parentPath.remove();
}
// 结果
function square(n) {
- return n * n
}
FunctionDeclaration(path) {
if (path.scope.hasBinding("n")) {
// ...
}
}
// 以上操作将遍历范围树,并检查特定的绑定
FunctionDeclaration(path) {
if (path.scope.hasOwnBinding("n")) {
// ...
}
}
// 这步是检查一个作用域是否有自己的绑定。
生成一个标识符,不会与任何本地定义的变量冲突:
FunctionDeclaration(path){
path.scope.generateUidIdentifier("uid");
// Node { type: "Identifier", name: "_uid" }
path.scpoe.generateUidIdentifier("uid");
// Node { type: "Identifier", name: "_uid2" }
}
FunctionDeclaration(path) {
const id = path.scope.generateUidIdentifierBasedOnNode(path.node.id);
path.remove();
path.scope.parent.push({ id, init: path.node });
}
// 结果
- function square(n) {
+ var _square = function square(n) {
return n * n
- }
+ }
FunctionDeclaration(path) {
path.scope.rename("n", "x");
}
// 结果
- function square(n) {
- return n * n;
+ function square(x) {
+ return x * x;
}
// 或者将绑定重命名为生成的唯一标识符
FunctionDeclaration(path) {
path.scope.rename("n");
}
// 结果
- function square(n) {
- return n * n;
+ function square(_n) {
+ return _n * _n;
}
// 源代码:
target_js = 'foo === bar'
// AST:
//{
// type: "BinaryExpression",
// operator: "===",
// left: {
// type: "Identifier",
// name: "foo"
// },
// right: {
// type: "Identifier",
// name: "bar"
// }
//}
// 目标是将 === 运算符左右变量名替换
export default function({ types: t}) {
return {
visitor: {
BinaryExpression(path){
if (path.node.operator !== "==="){
return;
}
path.node.left = t.identifier("x");
path.node.rigth = t.identifier("y");
}
}
}
}
// 运行结果:
x === y
如果想自定义Babel插件的行为,可以指定插件特定选项:
{
plugins: [
["my-plugin", {
"option1": true,
"option2": false
}]
]
}
这些选项会通过state对象传递给插件访问者:
export default function({ types: t}) {
return {
visitor: {
FunctionDeclaration(path, state){
console.log(state.opts);
//输出:
// { option1: true, option2: false}
}
}
}
}
这些选项特定于插件,不能访问其他插件中的选项。
插件可以具有在插件之前或之后运行的函数。可以用于设置或清理/分析:
export default function({ types: t }) {
return {
pre(state) {
this.cache = new Map();
},
visitor: {
StringLiteral(path) {
this.cache.set(path.node.value, 1);
}
},
post(state) {
console.log(this.cache);
}
}
}
插件可以启用babylon plugins,以便用户不需要安装/启用他们。这可以防止解析错误,而不会继承语法插件。
export default function({ types: t }) {
return {
inherits: require("babel-plugin-syntax-jsx")
};
}
在线babel编译
在Babeljs.io Try it out可以在线查看babel转换结果(左侧TARGETS里可以输入IE 9
)。
let value = 'a'
// babel编译后:
var value = 'a'
可以看到 Babel是将let编译成了var,那再来一个例子:
if (false) {
let value = 'a';
}
console.log(value); // value is not defined
如果babel将let编译为var应该打印 undefined,为何会报错呢,babel是这样编译的:
if (false) {
var _value = 'a';
}
console.log(value);
babel是改变量名,使内外层的变量名称不一样。
const修改值时报错,以及重复声明报错怎么实现的呢?其实在编译时就报错了。
重点来了:for循环中的 let 声明呢?
var functions = [];
for (let i = 0; i < 3; i++) {
functions[i] = function () {
console.log(i);
};
}
functions[0](); // 0
babel编译成了:
var functions = [];
var _loop = function _loop(i) {
functions[i] = function () {
console.log(i);
};
};
for (var i = 0; i < 3; i++) {
_loop(i);
}
functions[0](); // 0
大体流程
IP地址:IP协议为互联网上的每一个网络和每一台主机都分配的一个逻辑地址。通过IP地址才能确定一台主机(服务器)的位置。
域名(DN,Domain Name):IP地址不便于用户记忆和使用,故用域名来代替纯数字的IP地址。
DNS(Domain Name System):每个域名都对应一个或多个提供相同服务的服务器的IP地址,只有知道服务器IP地址才能建立连接,所以需要通过DNS把域名解析成一个IP地址。
域名和IP的关系
域名和IP不是一一对应的关系,可以把多个提供相同服务的服务器IP设置为同一个域名,同一时刻一个域名可以解析出多个IP地址;同时,一个IP地址可以绑定多个域名,数量不限。
再强调一下,同一时刻一个域名是可以解析出多个IP地址的(多条A记录很常见)。只是每次域名解析请求会根据对应的负载均衡算法计算出一个IP地址返回给访客。
vivi@vivi:~$ nslookup aliyun.com
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
Name: aliyun.com
Address: 140.205.60.46
Name: aliyun.com
Address: 106.11.172.9
Name: aliyun.com
Address: 140.205.135.3
Name: aliyun.com
Address: 106.11.253.83
Name: aliyun.com
Address: 106.11.249.99
Name: aliyun.com
Address: 106.11.248.146
Name: aliyun.com
Address: 2401:b180:1:60::6
Name: aliyun.com
Address: 2401:b180:1:60::5
vivi@vivi:~$ dig aliyun.com
; <<>> DiG 9.16.1-Ubuntu <<>> aliyun.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 47126
;; flags: qr rd ra; QUERY: 1, ANSWER: 6, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 65494
;; QUESTION SECTION:
;aliyun.com. IN A
;; ANSWER SECTION:
aliyun.com. 300 IN A 106.11.248.146
aliyun.com. 300 IN A 106.11.249.99
aliyun.com. 300 IN A 106.11.253.83
aliyun.com. 300 IN A 140.205.135.3
aliyun.com. 300 IN A 106.11.172.9
aliyun.com. 300 IN A 140.205.60.46
;; Query time: 8 msec
;; SERVER: 127.0.0.53#53(127.0.0.53)
;; WHEN: 三 2月 21 16:12:48 CST 2024
;; MSG SIZE rcvd: 135
DNS重定向
这里的知识点,对前端而言只要知道使用CDN存放静态资源这种优化策略的原理是DNS负载均衡: 如果一个大型网站的所有请求都由同一个服务器进行处理,是不现实的; 并且对用户而言,用户并不关注具体是哪台机器处理了他的请求。 因此,DNS可以根据多个服务器中每个服务器的负载量、该机器离用户的地理位置的距离等信息 返回其中某个适合的主机的IP地址,这个过程就是DNS负载均衡,又叫做DNS重定向。 CDN(Content Delivery Network)就是利用DNS的重定向技术,DNS服务器会返回一个跟用户最接近的点的IP地址。
要实现一个域名对应多个IP地址的效果,首先需要了解DNS(域名系统)的工作原理。
DNS(Domain Name System)是因特网的一项服务,它作为域名和IP地址相互映射的一个分布式数据库,能够使人们更方便地访问互联网。我们平时访问网站更多的是通过域名而非IP地址去触达,但域名并不能被计算机直接识别,所以需要通过DNS将域名“翻译”成可由计算机直接识别的IP地址。具体的操作方式,是在DNS解析操作平台,添加一条解析记录(A记录或AAAA记录),将网站的域名指向服务器的IP地址。一般情况下,一个域名对应一个IP地址,也就只需添加一条解析记录即可。如果想要实现一个域名对应多个IP地址,就需要添加多条解析记录,这也是通过DNS实现负载均衡的简单原理。
如我们想要将http://www.example.com这个域名分别指向1.1.1.1(北京电信)、2.2.2.2(上海移动)、3.3.3.3(深圳联通)三个IP。那么我们就可以在DNS服务器中配置三个A记录,分别为
这样,每次域名解析请求都会根据对应的负载均衡算法计算出一个不同的IP地址返回给访客,这样就构成了一个服务器集群,并实现负载均衡的效果。在实际场景中,当北京用户访问http://www.example.com域名时,DNS会根据负载均衡算法和A记录得出一个就近IP地址1.1.1.1返回给客户端,当上海用户访问http://www.example.com域名时,DNS就会返回给2.2.2.2的服务器地址,深圳用户返回3.3.3.3。
不同用户就近访问不同的服务器IP地址,访问速度大大提升,同时也减轻了单个服务器的访问压力。
实现负载均衡的方式有很多种,其中DNS是一种十分简单和有效的技术手段,它主要有以下几点优势:
但基于DNS的负载均衡同样也存在一些弊端:
所以一些大型网站总是使用DNS域名解析作为第一级负载均衡手段,然后在通过提供负载均衡服务的内部服务器再进行负载均衡,将最终请求发到真实的服务器上,从而完成最终请求。
建立连接——三次握手
知道了服务器的IP地址,便可开始与服务器建立连接了,通信连接的建立需要经历以下三个过程:
说明:
TCP的作用是啥?
当服务器与客户端建立了连接之后,客户端便开始与服务器进行通信。 网页请求是一个客户端向服务器请求数据==>服务器返回相应数据的单向的请求过程。
针对浏览器渲染、显示页面的过程,说明如下:
断开连接——四次挥手:
说明:
一些事件,比如touchmove可能会被高频率地触发,如果该事件对应的handler函数中需要处理的逻辑较多,可能会导致FPS下降影响程序流畅度,在这种情况下,可以考虑将handler中的执行体放于setTimeout(function () { //执行的代码 }, 0)中,程序会变流畅。
当事件触发时,函数不立即执行,而是延迟一段时间后再执行。并且这期间只要事件再次被触发,就重新计算延迟时间。所以如果事件被不停触发的话,函数就一直不会被执行。
/**
* 将函数进行防抖处理,生成一个新的防抖函数
* @param {function|Promise} fn - 需要进行防抖处理的原始函数(可以是async函数)
* @param {number} [delay = 1000] - 防抖延迟时间,单位 ms
* @param {boolean} [immediate = false] 不在延迟时间内的每次第一次触发时是否立即执行
* @param {function} [callback] - 回调函数,用于获取每次最终执行后的结果
* @return {function} 生成的防抖函数
*/
function debounce(fn, delay = 1000, immediate = false, callback) {
let timer = 0
let isFirstTime = false
let self = this
function returnFunc(...args) {
if (timer) {
clearTimeout(timer)
timer = 0
}
const mainTask = () => {
let errorObj = null
// apply 第二个入参为数组,call 和 bind 的第二个入参是0到多个arguments
let result = fn.apply(self, args)
const doCallback = () => {
if (typeof callback === 'function') {
// 第一个参数表示error,第二个参数表示实际结果
callback(errorObj, result)
}
}
if (result instanceof Promise) {
result
.then((res) => {
result = res
doCallback()
})
.catch((err) => {
errorObj = err
doCallback()
})
} else {
doCallback()
}
}
if (immediate && isFirstTime) {
mainTask()
isFirstTime = false
}
timer = setTimeout(() => {
mainTask()
isFirstTime = true
}, delay)
}
return returnFunc
}
节流的话,当事件被频繁触发时,函数也只会按指定的间隔频率被触发,函数被触发的时间间隔只会大于等于指定时间间隔。节流与防抖的最大区别在于防抖的核心是延时等待,节流的核心在于保证最小时间间隔。
function throttle (fn, delay) {
let lastTime = 0
return function () {
const currentTime = Date.now()
if (lastTime > 0 && currentTime - lastTime < delay) {
return
}
lastTime = currentTime
fn.apply(this, arguments)
}
}
攻击者会在用户不知情的情况下通过用户浏览器向网站后端发起请求。攻击者可以通过XSS攻击的方式触发CSRF攻击。
比如,在一个未做好安全防范的聊天室或者论坛上,攻击者发送了一个img标签:
<!-- 注意:这个img标签的src属性值并非一个真正的图片地址,而是一个请求银行网站的链接地址 -->
<img src="https://bank.example.com/withdraw?account=bob&amount=1000000&for=mallory" />
如果一个用户在访问这个渲染好的html片段之前正好访问过这个银行网站, 并且cookie信息未过期,且银行方也没有做除了cookie之外的其他校验,那么这个用户的钱就有可能被直接转走了!
通过img标签触发的都是get请求,那是不是转账这种敏感请求都用post不用get就没这个问题了呢?也不是的,因为攻击者也可以通过构造form表单或者直接注入js脚本来实现非get请求。
<form action="https://bank.example.com/withdraw" method="POST">
<input type="hidden" name="account" value="bob" />
<input type="hidden" name="amount" value="1000000" />
<input type="hidden" name="for" value="mallory" />
</form>
<script>
window.addEventListener('DOMContentLoaded', () => {
document.querySelector('form').submit();
})
</script>
上面这段代码,只要是放在一个看不叫的iframe标签内部的话,触发时就不会导致页面跳转,也就不会被用户感知到。
预防措施:
跨站脚本攻击(XSS)是攻击者通过向网站的客户端注入恶意代码来实现的。 受害者执行这段代码之后,攻击者就可以绕过访问权限的拦截,模拟用户行为。
如果Web应用没有采用足够的校验和编码措施来进行预防的话,这些攻击就可能会得逞。 用户的浏览器无法判断这些恶意脚本是“恶意的”,所以会允许它们访问cookie、token或者其他网站敏感信息,也会允许这些代码去修改HTML内容。
当动态内容,或者来自不可信来源(通常是网络请求)的数据,在未经校验是否有恶意内容的情况下被发送给用户时,就容易出现跨站脚本攻击。
这些恶意内容通常包括JavaScript代码,有时候也包括HTML、Flash,或者其他浏览器可以执行的代码。XSS攻击有很多类型,但通常包括:
我们可以将跨站脚本攻击分成3类:
前端加密的意义不是为了防止中间人,而是提供一种隐私保护服务。
这样即使因为使用的是http协议导致通信过程被攻击者拦截, 攻击者直接拿到的也不是用户的原始密码,而是加密字符串,这个加密字符串可以直接被用于当前网站。 但是当攻击者拿这个加密字符串去其他网站尝试使用时,只要其他网站使用的不是同一套加密逻辑,就没有用。 就是说用户在其他网站使用这个密码还是相对安全的。
攻击者如果能拿到密码明文的话,还是很危险的,前端加密一定程度上可以增加这个难度(增加了攻击者从加密字符串破解出明文密码的过程)。
一般做法是使用https协议,并且对密码采用非对称加密算法(如RSA)处理后再进行传输。
使用https协议,可以避免用户密码在网络上裸奔。http协议是明文传输的,有3大风险:
https原理是什么呢?为什么它能解决http的三大风险呢?
https = http + SSL/TLS, SSL/TLS 是传输层加密协议, 它提供内容加密、身份认证、数据完整性校验, 以解决数据传输的安全性问题。
一次完整的https请求流程如下:
https的数据传输过程,数据都是密文的。 但是,即时使用了https协议传输密码信息,也不一定就安全了。 比如,https完全就是建立在证书可信的基础上的。 如果遇到中间人伪造证书,一旦客户端通过验证,安全性就没了。 通过伪造证书,https也是可能被抓包的。
如上所述,即使用了https协议传输用户密码,只要用户信任了伪造证书,也还是会有安全隐患的。所以,对于密码,传输前还是需要先进行加密的。
加密算法有对称加密和非对称加密两种。
加密和解密使用“相同密钥”的加密算法。 使用对称加密算法时,需要考虑如何将密钥给到客户端的问题,如果还是通过网络传输的方式,如果密钥在传输过程中被中间人拿到的话,还是有风险的。
常见的对称加密算法有:
非对称加密算法需要2个密钥(公钥和私钥)。公钥和私钥是成对存在的,用公钥对数据进行加密,用对应的私钥才能解密。 使用费对称加密算法时,也需要考虑如何将密钥、公钥给到客户端的问题, 如果公钥在网络传输过程中被中间人拿到的话,中间人可以伪造公钥,把伪造的公钥给客户端,然后用自己的私钥解密从客户端过来的加密数据。
常见的非对称加密算法有:
密码安全送达服务端后,一定不能明文存储密码到数据库。可以先用哈希摘要算法加密密码,然后再保存到数据库。
哈希摘要算法:只能从明文生成一个对应的哈希值,不能反过来根据哈希值得到对应的明文。
MD5是一种非常经典的哈希摘要算法,被广泛应用于数据完整性校验、数据摘要、数据加密等。
直接MD5加密
对原始密码直接进行MD5加密的话是很不安全的。因为攻击者用彩虹表可以很容易破解出密码。 如果把所有20位以内的数字和字母组合的密码全部计算其MD5哈希值,并把密码和对应哈希值存到一个超大数据库里,就是一个彩虹表了。
提醒:网络上已经有很多MD5免费破解网站了,可以自己随便试。
优化方案:密码加盐后再进行MD5加密
先对字段进行加盐处理,再进行MD5加密,即MD5(password + salt)。只要salt够长,是没有办法通过彩虹表反查的。
在密码学中,通过在密码任意固定位置插入特定的字符串,让散列后的结果和使用原始密码的散列结果不相符,这个过程称为“加盐”。
加盐有几个注意事项
即使MD5加密前加了盐,密码仍有可能被暴力破解。可以采取更慢一点的算法,增加攻击者破解密码所需的成本,迫使攻击者放弃攻击。
为了应对暴力破解,我们需要非常耗时而非高效的哈希算法。 BCrypt算法的特点是可以通过参数设置重复计算的次数,重复计算的次数越多耗时越长。 如果计算一个哈希值需要耗时1秒以上,破解一个6位纯数字密码就需要耗时11.5天以上,更不要说高安全级别的密码了。暴力破解密码的可能性就很低了。
实际上,Spring Security 已经废弃了MessageDigestPasswordEncoder,推荐使用BCryptPasswordEncoder,也就是BCrypt来进行密码哈希。
感知到暴力破解危害时,应开启短信验证、图形验证码、账号暂时锁定等防御机制来进行抵御。
如何明确是暴力破解的话,可以采取封IP等措施。
本文参考了以下文章:
access token 是用来临时授权用户访问受保护的资源或执行特定操作用的,通常有效期较短(分钟级),以减少 token 被泄露带来的风险(攻击者可用于 access token 获取保密资源的时间越少越好)。refresh token 则是用于在 access token 过期后获取新的 access token 用的,从而减少用户因为 access token 过期而总是需要重复登录的问题。
refresh token 的一些最佳实践
1、轮转 refresh token (RTR,Refresh token rotation)
RTR 通过降低 refresh token 的有效期来提高了安全性。Refresh token rotation 的意思是,每次使用 refresh token 去获取 access token 时,服务端就让旧的 refresh token 失效,并返回给前端一个新的 refresh token。基本上可以认为,在该方案下,每个 refresh token 只能使用一次。这样,当攻击者获取到 refresh token 后,该 refresh token 失效的可能性将大大增加。
2、监测 refresh token 的复用
除了轮转 refresh token,还应该监测 refresh token 的复用。如果授权服务器发现有请求尝试使用一个已经用过的、无效了的 refresh token 去获取新 access token 的行为的话,授权服务器应让相关的 token 全部失效,包括所有已经下发给用户的 access token 和最近下发的 refresh token。
3、安全地存储 refresh token
4、设置合理的过期时间
度量过期时间时,其起始时间除了用下发 token 的时间,也可以用 token 最后一次被使用的时间。
5、监控 refresh token 的异常使用情况
在一个应用中,服务端解析 access token 的场景要比解析 refresh token 的场景多得多,我们可以在 access token 中仅包好较少的用户信息,而在 refresh token 中包含较多的信息(比如用户 ip 地址),然后在 refresh token 刷 access token 的请求中,对用户 ip 地址是否有变动等进行检查。
HTTP 缓存见 HTTP 相关章节,本节不再赘述。
Disk Cache
当你访问一个网站的时候,一些资源(图片、CSS样式文件、JS脚本文件等)可能会被储存到你的硬盘中。这种就就是硬盘缓存(Disk Cache)。
硬盘缓存的优点有:
硬盘缓存的缺点:
Memory Cache
Memory Cache 与 Disk Cache 不同的地方在于,命中 Memory Cache 的资源是被储存在设备的 RAM 中的,这种缓存资源访问起来速度非常快。但是只要你关掉浏览器,这些缓存就会被丢弃,也就是说下一次你重新打开浏览器尝试去访问这些资源时,就已经没有缓存了。
基本上前端的职业发展走到后期,技能点自然而然会点到后端这块的。那么最主流的 MySQL 就是一个必须要熟悉的数据库。
MySQL
MySQL 是当下流行的关系数据库管理系统(Relational Database Management System, RDBMS),使用 C 和 C++ 语言编写而成。MySQL 支持多线程,可以充分利用CPU资源。
mysql -h host -u user -p
# 如果在运行 MySQL 的同一台机器上登录,则可以省略主机名:
mysql -u user -p
创建数据库
CREATE DATABASE database_name;
创建表
create table table_name (column_name column_type);
例子:
create table if not exists `userinfo` (
`id` int unsigned auto_increment,
`name` varchar(100) not null,
`age` int not null,
`date` date,
primary key ( `id` )) engine=innodb default charset=utf8;
说明:
查询指定表的结构
describe table_name;
如上面创建的 userinfo
表,查询出来的信息如下:
Field | Type | Null | Key | Default | Extra |
---|---|---|---|---|---|
id | int unsigned | NO | PRI | NULL | auto_increment |
name | varchar(100) | NO | NULL | ||
age | int | NO | NULL | ||
date | date | YES | NULL |
整数类型所需的存储空间和取值范围:
类型 | 存储空间(字节) | 有符号的最小值 | 无符号的最小值 | 有符号的最大值 | 无符号的最大值 |
---|---|---|---|---|---|
tinyint | 1 | -128 | 0 | 127 | 255 |
smallint | 2 | -32768 | 0 | 32767 | 65535 |
mediuminit | 3 | -8388608 | 0 | 8388607 | 16777215 |
int | 4 | -2147483648 | 0 | 2147483647 | 4294967295 |
bigint | 8 | -2^63 | 0 | 2^63 - 1 | 2^64 - 1 |
decimal 列声明中,可以指定精度和小数位数:
# 精度为5,小数位数为2,取值范围为 -999.99 ~ 999.99
salary decimal(5,2)
bit 类型的表示方式为 bit(m)
,m 的取值范围为 1~64(换算成十进制的话,就是 0 ~ 2^64-1)。bit 类型存储的是二进制字符串。
表示时间值的日期和时间类型有这样几种:datetime、date、timestamp、time 和 year。每种时间类型都有一个有效值范围和一个“零”值,当指定的日期或时间数据不符合规则时,MySQL 将使用“零”值来替换。MySQL允许将“零”值(0000-00-00
)存储为“虚拟日期”。在某些情况下,这比使用 null
值更方便,并且使用更少的数据和索引空间。
所有日期和时间类型格式的详细说明如下:
类型 | 存储字节 | 范围 | 格式 | 用途 |
---|---|---|---|---|
date | 3 | 1000-01-01 到 9999-12-31 | YYYY-MM-DD | 日期值 |
time | 3 | '-838:59:59' 到 '838:59:59' | HH:MM:SS | 时间值 |
year | 1 | 1901 到 2155 | YYYY | 年份值 |
datetime | 8 | 1000-01-01 00:00:00 到 9999-12-31 23:59:59 | YYYY-MM-DD HH:MM:SS | 日期和时间值 |
timestamp | 4 | 1970-01-01 00:00:00 到 2038-01-19 11:14:07 | YYYY-MM-DD HH:MM:SS | 日期和时间值 |
在 MySQL中,字符串数据类型有:char、varchar、text、binary、varbinary、blob、enum 和 set。对于数据类型定位为 char、varchar 和 text 的列,MySQL 以字符为单位定义长度规范。对于数据类型为 binary、varbinary 和 blob 的列,MySQL 以字节为单位定义长度规范。
当列定义为 char、varchar、enum 和 set 的数据类型时,同时还可以指定列的字符集,尤其在存储中文时,建议指定字符集格式为 utf8,以防止出现乱码问题。
create table mytable
(
c1 varchar(255) character set utf8,
c2 text character set latin1 collate latin1_general_cs
);
blob 类型
blob类型的值是一个二进制的大对象,可以容纳可变数量的数据。tinyblob、blob、mediumblob 和 longblob 类型的区别仅在于它们可以存储的值的最大长度不相同。
enum 类型
enum 类型(即枚举类型)的列值表示一个字符串对象,其值选自定义列时给定的枚举值。enum 类型具有以下优点:
定义时,注意枚举值必须是带引号的字符串。
create table mytable (
name varchar(40),
size enum('x-small', 'small', 'medium')
);
set 类型
set 类型(集合类型)的列值表示可以有零个或多个字符串对象。一个 set 类型的列最多可以有64个不同的成员值,并且每个值都必须从定义列时指定的值列表中选择。set 类型成员值本身不应包含英文逗号。
create table myset (col set('a', 'b', 'c', 'd'));
create table mytable (jdoc json);
MySQL 支持 JSON 数据类型,JSON 数据类型具有如下优点:
在 MySQL 中,JSON 类型列的值会被写为字符串。如果字符串不符合 JSON 数据格式,则会产生错误。
查看 MySQL 服务器中的所有数据库:
show databases;
切换使用指定数据库:
USE database_name;
查询当前操作的数据库名称:
SELECT DATABASE();
查询当前数据库下的所有表:
SHOW TABLES;
删除数据库:
drop database if exists mydb;
重新创建 mydb
数据库,指定编码为 utf8:
create database mydb charset utf8;
查看建库时的雨具(并验证数据库使用的编码):
show create database mydb;
进入 mydb
库,然后删除 student
表(如果存在):
use mydb;
drop table if exists student;
创建 student
表:
drop table if exists student;
create table student (
id int primary key auto_increment,
name varchar(50),
gender varchar(2),
birthday date,
score double
);
上述语句创建的表结构如下:
Field | Type | Null | Key | Default | Extra |
---|---|---|---|---|---|
id | int(11) | NO | PRI | NULL | auto_increment |
name | varchar(50) | YES | NULL | ||
gender | varchar(2) | YES | NULL | ||
birthday | date | YES | NULL | ||
score | double | YES | NULL |
查看创建时的语句:
show create table student;
# 得到如下内容:
CREATE TABLE `student` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) DEFAULT NULL,
`gender` varchar(20) DEFAULT NULL,
`birthday` date DEFAULT NULL,
`score` double DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8;
插入记录:
insert into student(name,gender,birthday,score)
values('zhangsan','m','1999-2-2',70);
insert into student
values(null,'lisi','m','1997-2-2',80);
insert into student
values(null,'wangwu','m','1989-2-2',75);
查询表中所有学生的信息:
select * from student;
得到:
id | name | gender | birthday | score |
---|---|---|---|---|
1 | zhangsan | m | 1999-02-02 | 70 |
2 | lisi | m | 1997-02-02 | 80 |
3 | wangwu | m | 1989-02-02 | 75 |
修改student表中所有学生的成绩,加10分特长分:
update student set score=score+10;
修改 student 表中 zhangsan 的成绩,将成绩改为 98 分:
update student set score=98 where name='zhangsan';
删除性别是w的数据:
delete from student where gender='w';
删除表中所有数据,数据还能找回:
delete from student;
清空表数据,效率高,但是数据找不回:
truncate studen;
where
子查询 准备数据(部门表和员工表):
# 创建部门表
drop table if exists dept;
create table dept (
deptno int(2) not null,
dname varchar(14) collate utf8_bin default null,
loc varchar(13) collate utf8_bin default null,
primary key (deptno)
) engine=innodb default charset = utf8 collate = utf8_bin;
# 创建员工表
drop table if exists emp;
create table emp (
eno integer not null,
ename varchar(20) not null,
sex varchar(20) not null,
birthday varchar(20) not null,
jdate varchar(20) not null,
salary integer not null,
bonus integer not null,
epost varchar(20) not null,
deptno int(2) not null,
primary key (eno)
);
查询emp表中的所有员工,显示姓名、薪资、奖金:
select ename, salary, bonus from emp;
查询emp表中的所有部门和职位:
select deptno, epost from emp;
查询emp表中的所有部门和职位,并对数据去重:
select distinct deptno, epost from emp;
查询emp表中薪资大于5000的所有员工,显示员工姓名、薪资:
select ename, salary from emp where salary > 5000;
查询emp表中总薪资(薪资+奖金)大于6500的所有员工,显示员工姓名、总薪资:
select ename, salary + bonus from emp
where salary + bonus > 6500;
得到:
ename | salary + bonus |
---|---|
wanger | 8500 |
lisi | 7000 |
wangming | 9000 |
注意上面查询结果中的表头,将表头中的“salavy+bonus”修改为“total-salary”:
select ename, salary + bonus 'total-salary' from emp
where salary + bonus > 6500;
得到:
ename | total-salary |
---|---|
wanger | 8500 |
lisi | 7000 |
wangming | 9000 |
查询emp表中薪资在7000和10000之间的员工,显示员工姓名和薪资:
-- 普通写法
select ename, salary
from emp
where salary >= 7000 and salary <= 10000;
-- 使用 `between and` 的写法
select ename, salary
from emp
where salary between 7000 and 10000;
查询emp表中薪资为5000、6000、8000的员工,显示员工姓名和薪资:
-- 普通写法
select ename, salary
from emp
where salary = 5000 or salary = 6000 or salary = 8000;
-- 使用 `in` 的写法
select ename, salary
from emp
where salary in (5000, 6000, 8000);
查询emp表中薪资不为5000、6000、8000的员工,显示员工姓名和薪资:
-- 普通写法
select ename, salary
from emp
where not (
salary = 5000 or salary = 6000 or salary = 8000
);
-- 使用 `not in` 的写法
select ename, salary
from emp
where salary not in (5000, 6000, 8000);
查询emp表中薪资大于4000和薪资小于2000的员工,显示员工姓名、薪资:
select ename, salary
from emp
where salary > 4000 or salary < 2000;
like
模糊查询 查询emp表中姓名中以“li”开头的员工,显示员工姓名、薪资:
select ename, salary sal
from emp where ename like 'li%';
查询emp表中姓名中包含“li”的员工,显示员工姓名、薪资:
select ename, salary sal
from emp where ename like '%li%';
查询emp表中姓名以“li”结尾的员工,显示员工姓名、薪资:
select ename, salary sal
from emp
where ename like '%li';
对emp表按照职位进行分组,并统计每个职位的人数,显示职位和对应人数:
select epost, count(*) from emp group by epost;
按照部门分组,显示部门、最高薪资:
select deptno, max(salary)
from emp
group by deptno;
查询每个部门的最高薪资,显示部门、员工姓名、最高薪资:
select emp.deptno, ename, t1.msal
from emp,
(
select deptno, max(salary) msal
from emp group by deptno
) t1
where emp.deptno = t1.deptno and emp.salary = t1.msal;
统计emp表中薪资大于3000的员工个数:
select count(eno) from emp where salary > 3000;
统计emp表中所有员工的薪资总和(不包含奖金):
select sum(salary) from emp;
统计emp表中员工的平均薪资(不包含奖金):
-- 普通方式计算平均数
select sum(salary) / count(*) from emp;
-- 使用 `avg` 函数求平均数
select avg(salary) from emp;
查询emp表中所有在1978年和1985年之间出生的员工,显示姓名、出生日期:
select ename, birthday
from emp
where year(birthday) between 1978 and 1985;
查询要在本月过生日的所有员工:
SELECT *
FROM emp
WHERE MONTH(CURDATE()) = MONTH(birthday);
对emp表中所有员工的薪资进行升序(从低到高)排序,显示员工姓名、薪资:
-- 默认就是升序排序,所以 `asc` 可以省略不写
select ename, salary from emp order by salary;
对emp表中所有员工奖金进行降序(从高到低)排序,显示员工姓名、奖金:
select ename, bonus
from emp
order by bonus desc;
查询emp表中的所有记录,分页显示首页记录(前3条记录):
select * from emp limit 0,3;
查询emp表中的所有记录,分页显示(每页显示3条记录),返回第2页:
select * from emp limit 3,3;
查询部门和部门对应的员工信息:
select *
from dept, emp
where dept.deptno = emp.deptno;
查询所有部门和部门下的员工,如果部门下没有员工,则员工显示为null(一定要列出所有部门):
select *
from dept
left join emp no dept.deptno = emp.deptno;
上面这个 SQL 查询语句使用了 left join
关键字来连接 dept
和 emp
表,并以 deptno
字段为连接条件,查询并返回两张表中相关记录的字段值。具体地说,该查询会遍历 dept
表中的每一行记录,然后查找与之对应的 emp
表中的记录,如果两者中存在符合连接关系的记录,则会将它们的字段值合并为一条查询结果,并以列的形式呈现在最终的查询结果中。如果某个部门在 emp
表中没有关联记录,则该部门在查询结果中也会被保留,但其关联字段值会被填充为 null
。
查询每个部门的员工的数量:
select dept.deptno, count(emp.deptno)
from dept
left join emp on dept.deptno = emp.deptno
group by dept.deptno;
列出与lisi从事相同职位的所有员工,显示姓名、职位、部门编号:
select ename, epost, deptno
from emp
where epost = (
select epost from emp where ename = 'lisi'
);
列出薪资比部门编号为30(销售部)的所有员工薪资都高的员工信息,显示员工姓名、薪资和部门名称:
-- 外连接查询:查询所有员工、员工薪资和员工对应的部门名称
select emp.ename, salary, dept.dname
from emp
left join dept on emp.deptno = dept.deptno;
-- 假设销售部门的最高薪资为 3000,列出薪资比 3000 高的员工信息
select emp.ename, salary, dept.dname
from emp
left join dept on emp.deptno = dept.deptno
where salary > 3000;
-- 求出销售部门的最高薪资
select max(salary) from emp where deptno = 30;
-- 合并两条查询 SQL
select emp.ename, salary, dept.dname
from emp
left join dept
on emp.deptno = dept.deptno
where salary > (
select max(salary) from emp where deptno = 30
);
列出最低薪资大于6500的各种职位,显示职位和该职位的最低薪资:
select epost, min(salary)
from emp
group by epost
having min(salary) > 6500;
having
子句
需要注意的是,在 group by
子句之后使用 having
子句可以对分组后的结果进行条件过滤,而在 where
子句中则不能使用聚合函数(如 min
函数)。
列出在每个部门就职的员工数量、平均薪资,显示部门编号、员工数量、平均薪资:
select deptno, count(*), AVG(salary)
from emp
group by deptno;
列出每个部门薪资最高的员工信息,显示部门编号、员工姓名、薪资:
-- 查询 `emp` 表中所有员工的部门编号、姓名、薪资
select deptno, ename, salary from emp;
-- 查询 `emp` 表中每个部门的最高薪资,显示部门编号、最高薪资
select deptno, max(salary) from emp group by deptno;
-- 第二次查询的结果作为一张临时表和第一次查询进行关联查询
select emp.deptno, emp.ename, emp.salary
from emp,
(
select deptno, max(salary) maxsal
from emp
group by deptno
) t1
where t1.deptno = emp.deptno and emp.salary = t1.maxsal;
character_length(s)
:返回字符串长度concat(s1,s2,...,sn)
:字符串合并format(x,n)
:数字格式化(将数字 x
格式化为保留 n
位小数点)lpad(s1,len,s2)
:该函数用于在字符串s1的开始处填充字符串s2,使字符串长度达到len。field(s,s1,s2,…)
:该函数用于返回第一个字符串s在字符串列表(s1,s2,…)中的位置。insert(s1,x,len,s2)
:该函数用字符串s2替换字符串s1中从x位置开始长度为len的字符串。lcase(s)
:把字符串中的所有字母转换为小写字母。ucase(s)
:把字符串中的所有字母转换为大写字母。strcmp(s1,s2)
:比较字符串大小。该函数用于比较字符串s1和s2,如果s1与s2相等则返回0,如果s1>s2则返回1,如果s1<s2则返回‒1。replace(s,s1,s2)
:字符串替换。该函数用字符串s2替换字符串s中的字符串s1。position(s1 in s)
:获取子字符串 s1
在字符串 s
中出现的位置。md5(s)
:字符串加密。inet_aton(ip)
:把 IP 地址转换为数字。inet_ntoa (s)
:把数字转换为 IP 地址。ceil(x)
:返回不小于x的最小整数。ceiling(x)
:返回不小于x的最小整数。同 ceil(x)
。floor(x)
:返回不大于x的最大整数。round(x)
:返回最接近x的整数。max(expression)
:求最大值。min(expression)
:求最小值。sum(expression)
:求总和。avg(expression)
:求平均值。count(expression)
:求总记录数。 count(字段名)
:计算指定列下总的行数,计算时将忽略空值的行。count(*)
:计算数据表中总的行数,无论某列是否为空值都包含在内。adddate(d,n)
:返回指定日期加上指定天数后的日期
/**
计算在2021-06-06的基础上加上60天后的日期
输出:2017-08-14
*/
select adddate("2017-06-15", 60);
addtime(t,n)
:返回指定时间加上指定时间后的时间
/**
2021-06-06 23:23:10 加 8 秒
得到:2021-06-06 23:23:18
*/
select addtime("2021-06-06 23:23:10", 8);
/**
2021-06-06 23:23:10 加 1小时10分5秒
得到:2021-06-07 00:33:15
*/
select addtime("2021-06-06 23:23:10", "1:10:5");
curdate()
:返回当前日期。格式为 YYYY-MM-DD
datediff(d1,d2)
:返回两个日期相隔的天数
dayofyear(d)
:返回指定日期是本年的第几天
extract(type from d)
:从日期 d
中返回 type
类型的值
type
的枚举值有:hour
、minute
、second
、microsecond
、year
、month
、day
、week
、quarter
、year_month
、day_hour
、day_minute
、day_second
、hour_minute
、hour_second
、minute_second
。
-- 得到 `11`
select extract (minute from "2021-06-06 23:11:11");
now()
:返回当前日期和时间
-- 得到格式如:YYYY-MM-DD HH:mm:ss
select now();
quarter(d)
:返回日期对应的季度数,范围是 1~4
second(t)
:返回指定时间中的秒数
timediff(time1, time2)
:计算时间差
-- 得到:`838:59:59`
select timediff("2021-06-06 16:42:45", "2020-06-06 16:42:45");
date(t)
:从指定日期时间中提取日期值
-- 得到:`2021-06-16`
select date("2021-06-16 23:11:11");
hour(t)
:返回指定时间中的小时数
time(expression)
:提取日期时间参数中的时间部分
-- 提取 `2021-06-06 16:42:45` 的时间部分
select time("2021-06-06 16:42:45");
time_format(t,f)
:根据表达式显示时间
year(d)
:返回指定日期的年份
MySQL 目前仅支持使用 InnoDB 和 NDB 存储引擎对数据表进行分区,不支持其他存储引擎。
使用分区的优点有:
MySQL 目前支持多种分区:
范围分区应该是连续且不重叠的,使用 value less than 运算符来定义。
创建表,并通过 partition by range
子句将表按 salary
列进行分区:
create table employees
(
empno varchar(20) not null,
empname varchar(20),
deptno int,
birthdate date,
salary int
)
partition by range(salary) (
partition p0 values less than (5000),
partition p1 values less than (10000),
partition p2 values less than (15000),
partition p3 values less than (20000),
partition p4 values less than maxvalue
);
maxvalue
表示是中大于最大可能得整数值。当员工工资增长到 25000、30000 或更多时,可以使用 alter table
语句为 20000~25000 的工资范围添加新分区。
针对上面这个员工表,我们也可以根据员工的出生日期(birthdate
)进行分区,把同一年出生的员工信息存储在同一个分区中,像下面这样(以 year(birthdate)
作为分区依据)。
create table employees
(
empno varchar(20) not null,
empname varchar(20),
deptno int,
birthdate date,
salary int
)
partition by range(year(birthdate) (
partition p2018 values less than (2018),
partition p2019 values less than (2019),
partition p2020 values less than (2020),
partition p2021 values less than (2021),
partition pmax values less than maxvalue
);
查询每个分区中分配的数据量
select partition_name as "", table_rows as ""
from information_schema.partitions
where table_name="employees";
得到结果格式如下:
/ | / |
---|---|
p0 | 0 |
p1 | 1 |
p2 | 1 |
p3 | 1 |
p4 | 0 |
create table employees_list
(
empno varchar(20) not null,
empname varchar(20),
deptno int,
birthdate date,
salary int
)
partition by list(deptno) (
partition p0 values in (10,20,30),
partition p1 values in (40,50,60),
partition p2 values in (70,80,90)
);
在列表分区的方案中,如果插入的数据中分区字段的值不在分区列表中,则会报错:Table has no partition for value blabla
。如果要在一条语句中批量添加多条数据,并忽略错误数据,可以使用 ignore
关键字:
insert ignore into employees_list (empno,empname,deptno,birthdate,salary)
values
(6, 'name1', 10, '2021-06-20', 12998),
(7, 'name2', 100, '2021-06-20', 12998);
列(column)分区是范围分区和列表分区的变体,分为范围列(range column)分区和列表列(list column)分区。
范围列分区
create table rtable(
a int,
b int,
c char(8),
d int
)
partition by range columns(a,d,c) (
partition p0 values less than (5,20,'aa'),
partition p1 values less than (10,30,'cc'),
partition p2 values less than (15,80,'dd'),
partition p3 values less than (maxvalue,maxvalue,maxvalue)
);
列表列分区
create table customers (
name varchar(25),
street_1 varchar(30),
street_2 varchar(30),
city varchar(15),
renewal date
)
partition by list columns(city) (
partition pregion_1 values in('河南省', '湖北省', '湖南省'),
partition pregion_2 values in('广东省', '广西壮族自治区', '海南省'),
partition pregion_3 values in('上海市', '江苏省', '浙江省'),
partition pregion_4 values in('北京市', '天津市', '河北省')
);
要对表进行哈希分区,必须在 create table
语句后附加一个子句,这个子句可以是一个返回整数的表达式,也可以是 MySQL 整数类型列的名称。
根据表中 store_id
列进行哈希分区,并分为4个分区,示例 SQL 语句如下:
create table employees (
id int not null,
fname varchar(30),
lname varchar(30),
hired date not null default '1970-01-01',
separated date not null default '9999-12-31',
job_code int,
store_id int
)
partition by hash(store_id)
partitions 4;
如果分区不包含 partition
子句,则分区数默认为1;如果分区语句包含 partition
子句,则必须在后面指定分区的数量,否则会提示语法错误。
哈希分区中,还可以使用为 SQL 返回整数的表达式,比如:
create table employees (
id int not null,
fname varchar(30),
lname varchar(30),
hired date not null default '1970-01-01',
separated date not null default '9999-12-31',
job_code int,
store_id int
)
partition by hash( year(hired) )
partitions 4;
MySQL 还支持线性哈希,它与常规哈希的不同之处在于:线性哈希使用线性二次幂算法,二常规哈希使用哈希函数值的模数。在语法上,线性哈希分区唯一区别于常规哈希的地方是在 partition by
子句中添加了 linear
关键字。
create table employees (
id int not null,
fname varchar(30),
lname varchar(30),
hired date not null default '1970-01-01',
separated date not null default '9999-12-31',
job_code int,
store_id int
)
partition by linear hash( year(hired) )
partitions 4;
键分区将表中的数据按照特定的键值进行分区。在键分区中,每个分区都包含相同键值的数据,不同键值的数据则存储在不同的分区中。
键分区和哈希分区很像,但有区别:
当表中存在主键或唯一键时,如果创建键分区时没有指定列,则系统默认会选择主键列作为分区列;如果不存在主键列,则会选择非空的唯一键列作为分区列。
提示
唯一列作为分区列时,唯一列不能为 null
。
create table tb_key (
id int,
var char(32)
)
partition by key(var)
partitions 10;
子分区也称复合分区,是对分区表中的每个分区的进一步划分,分为:
范围-哈希(range-hash)复合分区
create table emp(
empno varchar(20) not null,
empname varchar(20),
deptno int,
birthdate date not null,
salary int
)
partition by range(salary)
subpartition by hash(year(birthdate))
subpartitions 3(
partition p1 values less than (2000),
partition p2 values less than maxvalue
);
在上面这个例子中,先按 salary
列的薪资范围将表进行分区,并对 birthdate
列采用 year
进行哈希分区,子分区数为3。在此分区方案中,将数据分成了两个范围分区 p1 和 p2,每个范围分区又分为3个子分区,其中 p1 分区存储 salary 小于 2000 的数据,p2 分区存储所有大于或等于 2000 的数据。
范围-键(range-key)复合分区
create table emp(
empno varchar(20) not null,
empname varchar(20),
deptno int,
birthdate date not null,
salary int
)
partition by range(salary)
subpartition by key(birthdate)
subpartitions 3
(
partition p1 values less than (2000),
partition p2 values less than maxvalue
);
列表-哈希(list-hash)复合分区
create table emp(
empno varchar(20) not null,
empname varchar(20),
deptno int,
birthdate date not null,
salary int
)
partition by list (deptno)
subpartition by hash(year(birthdate))
subpartitions 3
(
partition p1 values in (10),
partition p2 values in (20),
);
列表-键(list-key)复合分区
create table emp(
empno varchar(20) not null,
empname varchar(20),
deptno int,
birthdate date not null,
salary int
)
partition by list (deptno)
subpartition by key(birthdate)
subpartitions 3
(
partition p1 values in (10),
partition p2 values in (20)
);
null
的处理 MySQL 中的分区不会禁止 null
作为分区表达式的值,无论列值还是用户提供的表达式的值,都允许 null
用作必须产生整数的表达式的值。MySQL 中的分区将 null
视为小于任何非 null
值。
范围分区中如何处理 null
要将一行数据插入分区中,如果用于确定范围分区的列值为 null
,那么该行将插入最低分区中。
列表分区中如何处理 null
当且仅当定义的分区中存在分区其 values in
后跟的值列表中存在 null
值时,才允许 null
值插入该分区。
create table ts2 (
c1 int,
c2 varchar(20)
)
partition by list(c1) (
partition p0 values in (0, 3, 6),
partition p1 values in (1, 4, 7),
partition p2 values in (2, 5, 8),
partition p3 values in (null)
);
哈希分区和健分区中如何处理 null
在哈希分区和键分区的表中,任何产生 null
值的分区表达式的返回值都为 0。
删除分区
# 删除分区
alter table table_name drop partition partition_name;
添加分区(范围分区)
CREATE TABLE employees (
id INT NOT NULL,
fname VARCHAR(50) NOT NULL,
lname VARCHAR(50) NOT NULL,
hired DATE NOT NULL
)
PARTITION BY RANGE( YEAR(hired) ) (
PARTITION p1 VALUES LESS THAN (1991),
PARTITION p2 VALUES LESS THAN (1996),
PARTITION p3 VALUES LESS THAN (2001),
PARTITION p4 VALUES LESS THAN (2005)
);
# 如果要新增的分区的范围值大于之前已有的分区范围值,可以直接添加:
ALTER TABLE employees ADD PARTITION (
PARTITION p5 VALUES LESS THAN (2010),
PARTITION p6 VALUES LESS THAN MAXVALUE
);
# 否则需要像下面这样处理:
ALTER TABLE employees
REORGANIZE PARTITION p1 INTO (
PARTITION n0 VALUES LESS THAN (1970),
PARTITION n1 VALUES LESS THAN (1991)
);
# 如果要对上面的操作反向处理:
ALTER TABLE employees REORGANIZE PARTITION n0,n1 INTO (
PARTITION p1 VALUES LESS THAN (1991)
);
添加分区(列表分区)
CREATE TABLE tt (
id INT,
data INT
)
PARTITION BY LIST(data) (
PARTITION p0 VALUES IN (5, 10, 15),
PARTITION p1 VALUES IN (6, 12, 18)
);
# 如果新增的分区的范围值大于之前已有分区的范围值,可以直接添加:
ALTER TABLE tt ADD PARTITION (
PARTITION p2 VALUES IN (7, 14, 21)
);
拆分、合并分区
在保证数据不丢失的情况下,可以拆分、合并分区:
create table members (
id int(11) default null,
fname varchar(25) default null,
lname varchar(25) default null,
dob date default null
) engine=InnoDB default charset=latin1
partition by range (year(dob))
(
partition n0 values less than (1970) engine = InnoDB,
partition n1 values less than (1980) engine = InnoDB,
partition p1 values less than (1990) engine = InnoDB,
partition p2 values less than (2000) engine = InnoDB,
partition p3 values less than (2010) engine = InnoDB
);
# 把 n0 分区拆分成2个分区:s0、s1
alter table members reorganize partition n0 into (
partition s0 values less than (1960),
partition s1 values less than (1970)
);
# 把 s0、s1 分区合并成一个分区
alter table members reorganize s0, s1 into (
partition p0 values less than (1970)
);
dob
是出生日期(date of birth)的缩写。哈希分区
# 创建一张具有10个哈希分区的数据表
create table clients (
id int,
fname varchar(30),
lname varchar(30),
signed date
)
partition by hash( month(signed) )
partitions 10;
# 把分区数量从 10 个变成 6 个 (即,合并掉 4 个分区)
alter table clients coalesce partition 4;
coalesce partition
需要注意,coalesce partition
后面的数字表示要删除的分区数。
键分区
# 创建一张具有 10 个键分区的表
create table clients (
id int,
fname varchar(30),
lname varchar(30),
signed date
)
partition by linear key(signed)
partitions 10;
# 把键分区数量从 10 个变成 6 个
alter table clients coalesce partition 4;
删除分区(仅限于范围分区和列表分区,会丢失数据)
# 一次性删除一个分区
alter table emp drop partition p1;
# 一次性删除多个分区
alter table emp drop partition p1,p2;
增加分区
# 增加范围分区
alter table emp add partition (partition p3 values less than (5000));
# 增加列表分区
alter table emp add partition (partition p3 values in (5000));
分解分区(不会丢失数据)
reorganize partition
关键字可以对表的部分分区或全部分区进行修改,并且不会丢失数据。分解前后分区的整体范围应该一致。
alter table t
reorganize partition p1 into
(
partition p1 values less than (1000),
partition p3 values less than (2000)
);
合并分区(不会丢失数据)
随着分区数量的增多,有时需要把多个分区合并成一个分区,可以使用 into
指令实现。
alter table t
reorganize partition p1,p3 into
(partition p1 values less than (10000));
重新定义哈希分区(不会丢失数据)
想要对哈希分区进行扩容或缩容,可以对现有的哈希分区进行重新定义。
alter table t partition by hash(salary) partitions 8;
重新定义范围分区(不会丢失数据)
想要对范围分区进行扩容或缩容,可以对现有范围分区进行重新定义。
alter table t partition by range(salary)
(
partition p1 values less than (20000),
partition p2 values less than (30000)
);
删除表的所有分区(不会丢失数据)
如果要删除表的所有分区,但又不想删除数据,可以执行如下语句:
# 注意是 `partitioning`,不是 `partition`
alter table emp remove partitioning;
重建分区
这和先删除保存在分区中的所有记录,然后重新插入它们具有同样的效果,可用于整理分区碎片。
alter table emp rebuild partition p1,p2;
优化分区
如果从分区中删除了大量的行,或者对一个带有可变长度的行做了许多修改,那么可以使用 alter table ... optimize partition
来收回没有使用的空间,并整理分区数据文件的碎片。
alter table t optimize partition p1,p2;
分析分区
想要对现有的分区进行分析,可以执行如下语句:
-- 读取并保存分区的键分布
alter table t analyze partition p1,p2;
修补分区
-- 修补被破坏的分区
alter table t repairpartition p1,p2;
检查分区
想要查看现有的分区是否被破坏,可以执行如下语句:
-- 检查表指定的分区
alter table t check partition p1,p2;
这条语句可以告诉我们表 t
的分区 p1
、p2
中的数据或索引个是否已经被破坏了。如果分区被破坏了,那么可以使用 alter table ... repairpartition
来修补该分区。
在业务中可以对分区进行一些限制:
控制分区键与主键、唯一键关系的规则是:分区表达式中使用的所有列必须是该数据表可能具有的每个唯一键的一部分。换句话说,分区键必须包含在表的主键、唯一键中。
唯一键是 col1
和 col2
的组合,分区键是 col3
create table t1 (
col1 int not null,
col2 date not null,
col3 int not null,
col4 int not null,
unique key (col1, col2)
)
partition by hash(col3)
partitions 4;
-- 报错如下:
# ERROR 1503 (HY000): A PRIMARY KEY must include all columns in the table's
# partitioning function (prefixed columns are not considered).
两个唯一键分别是 col1
和 col3
,分区键是 col1 + col3
create table t2 (
col1 int not null,
col2 date not null,
col3 int not null,
col4 int not null,
unique key (col1),
unique key (col3)
)
partition by hash(col1 + col3)
partitions 4;
-- 报错如下:
# ERROR 1503 (HY000): A PRIMARY KEY must include all columns in the table's
# partitioning function (prefixed columns are not considered).
两个唯一键分别是 (col1, col2)
和 col3
,分区键是 col1 + col3
create table t3 (
col1 int not null,
col2 date not null,
col3 int not null,
col4 int not null,
unique key (col1, col2),
unique key (col3)
)
partition by hash(col1 + col3)
partitions 4;
-- 报错如下:
# ERROR 1491 (HY000): A PRIMARY KEY must include all columns in the table's
# partitioning function.
主键是 col1
和 col2
,分区键是 col3
create table t4 (
col1 int not null,
col2 date not null,
col3 int not null,
col4 int not null,
primary key(col1, col2)
)
partition by hash(col3)
partitions 4;
-- 报错如下:
# ERROR 1503 (HY000): A PRIMARY KEY must include all columns in the table's
# partitioning function (prefixed columns are not considered).
主键是 col1
和 col3
,唯一键为 col2
,分区键为 year(col2)
create table t5 (
col1 int not null,
col2 date not null,
col3 int not null,
col4 int not null,
primary key(col1, col3),
unique key(col2)
)
partition by hash( year(col2) )
partitions 4;
-- 报错如下:
# ERROR 1503 (HY000): A PRIMARY KEY must include all columns in the table's
# partitioning function (prefixed columns are not considered).
create table t1 (
col1 int not null,
col2 date not null,
col3 int not null,
col4 int not null,
unique key (col1, col2, col3)
)
partition by hash(col3)
partitions 4;
create table t2 (
col1 int not null,
col2 date not null,
col3 int not null,
col4 int not null,
unique key (col1, col3)
)
partition by hash(col1 + col3)
partitions 4;
create table t3 (
col1 int not null,
col2 date not null,
col3 int not null,
col4 int not null,
unique key (col1, col2, col3),
unique key (col3)
)
partition by hash(col3)
partitions 4;
以下两种情况,主键都不包括分区表达式中引用的所有列,但语句都是有效的
create table t4 (
col1 int not null,
col2 date not null,
col3 int not null,
col4 int not null,
primary key(col1, col2)
)
partition by hash(col1 + year(col2))
partitions 4;
create table t5 (
col1 int not null,
col2 date not null,
col3 int not null,
col4 int not null,
primary key (col1, col2, col4),
unique key (col2, col1)
)
partition by hash(col1 + year(col2))
partitions 4;
InnoDB 是一种兼顾高可靠性和高性能的通用存储引擎。在 MySQL 8.0 中,InnoDB 是默认的 MySQL 存储引擎。InnoDB 存储引擎主要有以下优势:
InnoDB 的优点有:
information_schema
表来监控存储引擎的内部工作。performance_schema
表来监控存储引擎性能的详细信息。InnoDB
表与来自其他 MySQL
存储引擎的表混合使用。使用InnoDB存储引擎时的推荐做法:
sql_mode=no_engine_substitution
选项来运行服务器。如果想要知道当前服务器的默认存储引擎,则可以使用下面的SQL语句来查看:
show engines;
ACID(事务)模型是一组数据库设计原则,是业务数据和关键任务应用的可靠性保证。在MySQL中,InnoDB存储引擎是严格遵守ACID模型的存储引擎,因此数据不会损坏,执行数据的结果不会因软件崩溃或硬件故障等异常情况而失真。当数据本身符合ACID的特性时,应用程序不需要重新发明一致性检查和崩溃恢复机制的轮子。如果有额外的软件保护措施、超可靠的硬件或可以容忍少量数据丢失的应用程序,则可以调整MySQL设置以获得更高的性能或吞吐量。下面我们将介绍InnoDB存储引擎的ACID模型。
MySQL事务主要用于处理操作量大、复杂度高的数据。比如,在人员管理系统中删除一个人员,既要删除人员的基本资料,又要删除和该人员相关的信息,如信箱、文章等,如果其中有一项没有删除成功,则其他内容也不能被删除。这样,这些数据库操作语句就构成了一个事务。
一般来说,事务必须满足4个条件(ACID):原子性(Atomicity,或称为不可分割性)、一致性(Consistency)、隔离性(Isolation,又称为独立性)、持久性(Durability)。
原子性:一个事务中的所有操作可以全部完成或全部失败,但不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。如图14-2所示,用户A给用户B转账1000,A账户减去1000,B账户加上1000,这个操作是一个原子操作,是不可分割的。如果同时成功,则数据库中A账户减去1000,B账户加上1000;如果同时失败,则操作回滚,数据库中的数据不变。
一致性:在事务开始之前和事务结束以后,数据库的完整性不会被破坏。这表示写入的数据必须完全符合所有的预设规则,包含数据的精确度、串联性,以及后续数据库可以自发性地完成预定的工作。
隔离性:数据库允许多个并发事务同时对其数据进行读写和修改,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据不一致。事务隔离分为不同级别,包括未提交读(read uncommitted)、提交读(read committed)、可重复读(repeatable read)和串行化(serializable)。
持久性:事务处理结束后,对数据的修改就是永久的,即使系统故障也不会丢失。
手动提交
在MySQL命令行的默认设置下,事务都是自动提交的,即执行SQL语句后就会马上执行commit操作。因此,要显式地开启一个事务需要使用命令 begin
或 start transaction
,或者执行命令 set autocommit=0
来禁用当前会话的自动提交。
用 begin rollback commit
实现
直接用 set
来改变 MySQL 的自动提交模式
数据是一种供许多用户共享访问的资源,如何保证数据库并发访问的一致性、有效性是所有数据库必须解决的一个问题,锁的冲突也是影响数据库并发访问性能的一个重要因素。从这个角度来说,锁对于数据库而言就显得尤为重要。MySQL中的锁可以按照不同的维度进行分类,以下是常见的几种锁:
按照粒度分类:
按照类型分类:
幻读和不可重复读
事务A 按照一定条件进行数据读取, 期间事务B 插入了相同搜索条件的新数据,事务A再次按照原先条件进行读取时,发现了事务B 新插入的数据 称为幻读。
如果事务A 按一定条件搜索, 期间事务B 删除了符合条件的某一条数据,导致事务A 再次读取时数据少了一条。这种情况归为 不可重复读。
基准测试可以理解为针对系统的一种压力测试。基准测试不关心业务逻辑,更加简单、直接,易于测试,数据可以由工具生成,不要求真实;而压力测试一般考虑业务逻辑,要求真实的数据。
对于大多数Web应用来说,整个系统的瓶颈在于数据库,原因很简单:Web应用中的其他因素(例如网络带宽、负载均衡节点、应用服务器(包括CPU、内存、硬盘灯、连接数等)、缓存)都很容易通过增加机器水平的扩展来实现性能的提高。而对于MySQL,由于数据一致性的要求,无法通过增加机器来分散向数据库写数据带来的压力,虽然可以通过读写分离、分库、分表来减轻压力,但是与系统其他组件的水平扩展相比,数据库仍然受到了太多的限制。
对数据库进行基准测试的作用是分析在当前配置(硬件配置、操作系统配置、数据库配置等)下,其数据库的性能表现,从而找出MySQL的性能阈值,并根据实际系统的要求调整配置。基准测试的指标有如下几个:
在对MySQL进行基准测试时,一般使用专门的工具,例如MySQLslap、Sysbench等。其中,Sysbench比MySQLslap更通用、更强大。
Sysbench是一个开源的、模块化的、跨平台的多线程性能测试工具,可以用来进行CPU、内存、磁盘I/O、线程、数据库的性能测试。目前支持的数据库有MySQL、Oracle和PostgreSQL。它主要包括以下几种测试:
随着应用业务数据的不断增多,程序应用的响应速度会不断下降,在检测过程中不难发现大多数的请求都是查询操作。此时,我们可以将数据库扩展成主从复制模式,将读操作和写操作分离开来,多台数据库分摊请求,从而减少单库的访问压力,进而使应用得到优化。
读写分离的基本原理是让主数据库处理对数据的增、改、删操作,进而让从数据库处理查询操作。数据库复制用来把事务性操作导致的变更同步到集群的从数据库中。由于数据库的操作比较耗时,因此让主服务器处理写操作以及实时性要求比较高的读操作,而让从服务器处理读操作。读写分离能提高性能的原因在于主、从服务器负责各自的读和写,极大地缓解了锁的争用,其架构图如下图所示。
上图所示架构有一个主库与两个从库:主库负责写数据,从库复制读数据。随着业务发展,如果还想增加从节点来提升读性能,那么可以随时进行扩展。
mysqldump是MySQL用于转存数据库的实用程序。它主要产生一个SQL脚本,其中包含从头重新创建数据库所必需的命令 create table insert
等。
要使用mysqldump导出数据,需要使用 --tab
选项来指定导出文件存储的目录,该目录必须有写操作权限。
备份 demo
数据库下的 userinfo
数据表
# 到 /tmp 目录下查看,该目录下会多出一个 userinfo.sql 文件
mysqldump -uroot -p123456 --no-create-info --tab=/tmp demo userinfo
备份数据库
语法:
mysqldump -h 服务器 -u用户名 -p密码 数据库名 > 备份文件.sql
单库备份:
mysqldump -uroot -p123456 db1 > db1.sql
mysqldump -uroot -p123456 db1 table1 table2 > db1-table1-table2.sql
多库备份:
mysqldump -uroot -p123456 --databases db1 db2 mysql db3 > db1_db2_mysql_db3.sql
备份所有库:
mysqldump -uroot -p123456 --all-databases > all.sql
利用 source
命令导入数据库
需要先登录数据库终端。
-- 创建数据库
create database demo;
-- 使用已创建的数据库
use demo;
-- 设置编码
set names utf8;
-- 导入备份数据库
source /home/data/userinfo.sql;
使用 load data infile
导入数据
MySQL提供了load data infile语句来插入数据。
LOAD DATA LOCAL INFILE 'dump.txt' INTO TABLE mytbl;
使用 mysqlimport
导入数据
mysqlimport -u root -p --local mytbl dump.txt
如果需要使用MySQL服务器提供读写分离支持,则需要MySQL的一主多从架构。在一主多从的数据库体系中,多个从服务器采用异步的方式更新主数据库的变化,业务服务器执行写操作或者相关修改数据库的操作直接在主服务器上执行,读操作在各从服务器上执行。MySQL主从复制实现原理如下图所示。
上图所示是典型的MySQL一主二从的架构图,其中主要涉及3个线程:binlog线程、I/O线程和SQL线程。每个线程说明如下:
MySQL服务之间数据复制的基础是二进制日志文件。一个MySQL数据库一旦启用二进制日志后,其作为主服务器,它的数据库中所有操作都会以“事件”的方式记录在二进制日志中,其他数据库作为从服务器,通过一个I/O线程与主服务器保持通信,并监控主服务器的二进制日志文件的变化。如果发现主服务器的二进制日志文件发生变化,则会把变化复制到自己的中继日志中,然后从服务器的一个SQL线程把相关的“事件”触发操作在自己的数据库中执行,以此实现从数据库和主数据库的一致性,也就实现了主从复制。
在程序中可随时修改变量的值,而Python将始终记录变量的最新值。
message = "Hello Python world!"
print(message)
message = "Hello Python Crash Course world!""
print(message)
以上代码将输出:
Hello Python world!
Hello Python Crash Course world!
变量的命令
字符串就是一系列字符。在Python中,用引号括起的都是字符串,其中的引号可以是单引号,也可以是双引号,如下所示:
"This is a string."
'This is also a string.'
方法title()以首字母大写的方式显示每个单词,即将每个单词的首字母都改为大写。
name = "Ada Lovelace"
print(name.upper())
print(name.lower())
可以用下面的放假将字符串全部转大写或小写:
name = "Ada Lovelace"
print(name.upper())
print(name.lower())
# 输出内容如下
# ADA LOVELACE
# ada lovelace
在字符串中使用变量。
first_name = "ada"
last_name = "lovelace"
full_name = f"{first_name} {last_name}"
print(full_name)
format()
方法
f字符串是Python 3.6引入的。如果你使用的是Python 3.5或更早的版本,需要使用format()方法,而非这种f语法。要使用方法format(),可在圆括号内列出要在字符串中使用的变量。对于每个变量,都通过一对花括号来引用。这样将按顺序将这些花括号替换为圆括号内列出的变量的值,如下所示:
要在字符串中添加制表符,可使用字符组合 \t
。
>>>print("Python")
Python
>>>print("\tPython")
Python
要在字符串中添加换行符,可使用字符组合\n:
>>>print("Languages:\nPython\nC\nJavaScript")
Languages:
Python
C
JavaScript
删除字符串右边的空白可以用 rstrip
,删除左边的空白可以用 lstrip()
,同时删除两边的空白可以用 strip()
:
favorite_language = 'python '
favorite_language.rstrip()
可对整数执行加(+)、减(-)、乘(*)、除(/)运算。
使用两个乘号表示乘方运算:
# 3^2 => 9
3 ** 2
# 10^6 = 1000000
10 ** 6
Python 将所有带小数点的数称为浮点数。但需要注意的是,结果包含的小数位数可能是不确定的:
# 得到 0.30000000000000004
0.2 + 0.1
所有语言都存在这种问题,没有什么可担心的。
将任意两个数相除时,结果总是浮点数,即便这两个数都是整数且能整除:
# 得到 2.0
4 / 2
在其他任何运算中,如果一个操作数是整数,另一个操作数是浮点数,结果也总是浮点数。
无论是哪种运算,只要有操作数是浮点数,Python默认得到的总是浮点数,即便结果原本为整数也是如此。
书写很大的数时,可使用下划线将其中的数字分组,使其更清晰易读(Python 3.6开始支持):
universe_age = 14_000_000_000
当你打印这种使用下划线定义的数时,Python不会打印其中的下划线:
print(universe_age)
# 得到 14000000000
可在一行代码中给多个变量赋值,这有助于缩短程序并提高其可读性。这种做法最常用于将一系列数赋给一组变量。
常量类似于变量,但其值在程序的整个生命周期内保持不变。Python没有内置的常量类型,但Python程序员会使用全大写来指出应将某个变量视为常量,其值应始终不变。
在Python中,注释用井号(#)标识。井号后面的内容都会被Python解释器忽略。
在控制台执行 import this
,可以看到全文:
The Zen of Python, by Tim Peters
Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!
在Python中,用方括号([])表示列表,并用逗号分隔其中的元素。如果让Python将列表打印出来,Python将打印列表的内部表示,包括方括号:[插图]
bicycles = ['trek','cannondale','redline','specialized']
# 输出:['trek','cannondale','redline','specialized']
print(bicycles)
Python为访问最后一个列表元素提供了一种特殊语法。通过将索引指定为-1,可让Python返回最后一个列表元素:
bicycles = ['trek','cannondale','redline','specialized']
print(bicycles[-1])
这种约定也适用于其他负数索引。例如,索引-2返回倒数第二个列表元素,索引-3返回倒数第三个列表元素,依此类推。
bicycles = ['trek','cannondale','redline','specialized']
message = f"My first bicycle was a {bicycles[0].title()}."
# 输出内容为:My first bicycle was a Trek.
print(message)
要修改列表元素,可指定列表名和要修改的元素的索引,再指定该元素的新值。
motorcycles = ['honda','yamaha','suzuki']
motorcycles[0] = 'ducati'
print(motorcycles)
在列表中添加新元素时,最简单的方式是将元素附加(append)到列表。给列表附加元素时,它将添加到列表末尾。
motorcycles = ['honda','yamaha','suzuki']
print(motorcycles)
motorcycles.append('ducati')
使用方法 insert()
可在列表的任何位置添加新元素。为此,你需要指定新元素的索引和值。
motorcycles = ['honda','yamaha','suzuki']
motorcycles.insert(0,'ducati')
# ['ducati','honda','yamaha','suzuki']
print(motorcycles)
如果知道要删除的元素在列表中的位置,可使用del语句。
motorcycles = ['honda','yamaha','suzuki']
print(motorcycles)
del motorcycles[0]
print(motorcycles)
方法pop()删除列表末尾的元素,并让你能够接着使用它。术语弹出(pop)源自这样的类比:列表就像一个栈,而删除列表末尾的元素相当于弹出栈顶元素。
motorcycles = ['honda','yamaha','suzuki']
popped_motorcycle = motorcycles.pop()
# ['honda','yamaha']
print(motorcycles)
# suzuki
print(popped_motorcycle)
实际上,可以使用pop()来删除列表中任意位置的元素,只需在圆括号中指定要删除元素的索引即可。
motorcycles = ['honda','yamaha','suzuki']
first_owned = motorcycles.pop(0)
# The first motorcycle I owned was a Honda.
print(f"The first motorcycle I owned was a {first_owned.title()}.")
如果你不确定该使用del语句还是pop()方法,下面是一个简单的判断标准:如果你要从列表中删除一个元素,且不再以任何方式使用它,就使用del语句;如果你要在删除元素后还能继续使用它,就使用方法pop()。
有时候,你不知道要从列表中删除的值所处的位置。如果只知道要删除的元素的值,可使用方法remove()。
motorcycles = ['honda','yamaha','suzuki','ducati']
motorcycles.remove('ducati')
Python方法sort()让你能够较为轻松地对列表进行排序(按字母顺序升序排列)。方法sort()永久性地修改列表元素的排列顺序。
cars = ['bmw','audi','toyota','subaru']
cars.sort()
# ['audi','bmw','subaru','toyota']
print(cars)
还可以按与字母顺序相反的顺序排列列表元素,只需向sort()方法传递参数reverse=True即可。
cars = ['bmw','audi','toyota','subaru']
cars.sort(reverse=True)
# ['toyota','subaru','bmw','audi']
print(cars)
要保留列表元素原来的排列顺序,同时以特定的顺序呈现它们,可使用函数sorted()。函数sorted()让你能够按特定顺序显示列表元素,同时不影响它们在列表中的原始排列顺序。
注意,调用函数sorted()后,原始列表元素的排列顺序并没有变。如果要按与字母顺序相反的顺序显示列表,也可向函数sorted()传递参数reverse=True。
要反转列表元素的排列顺序,可使用方法reverse()。注意,reverse()不是按与字母顺序相反的顺序排列列表元素,而只是反转列表元素的排列顺序。方法reverse()永久性地修改列表元素的排列顺序,但可随时恢复到原来的排列顺序,只需对列表再次调用reverse()即可。
使用函数len()可快速获悉列表的长度。
cars = ['bmw','audi','toyota','subaru']
# 4
len(cars)
索引错误的话会导致报错,比如下面这段代码:
motorcycles = ['honda','yamaha','suzuki']
print(motorcycles[3])
会导致报错:
Traceback (most recent call last):
File "motorcycles.py",line 2,in <module>
print(motorcycles[3])
IndexError:list index out of range
遍历列表,可以使用 for 语句。for语句末尾的冒号告诉Python,下一行是循环的第一行。如果不小心遗漏了冒号,将导致语法错误,因为Python不知道你意欲何为。
magicians = ['alice','david','carolina']
for magician in magicians:
print(magician)
Python函数range()让你能够轻松地生成一系列数。例如,可以像下面这样使用函数range()来打印一系列数:
for value in range(1,5):
print(value)
上述代码会输出1~4,不会输出5:
1
2
3
4
调用函数range()时,也可只指定一个参数,这样它将从0开始。例如,range(6)返回数0~5。
要创建数字列表,可使用函数list()将range()的结果直接转换为列表。如果将range()作为list()的参数,输出将是一个数字列表。
numbers = list(range(1,6))
# [1,2,3,4,5]
print(numbers)
使用函数range()时,还可指定步长。为此,可给这个函数指定第三个参数,Python将根据这个步长来生成数。
even_numbers = list(range(2,11,2))
# [2,4,6,8,10]
print(even_numbers)
使用函数range()几乎能够创建任何需要的数集。例如,如何创建一个列表,其中包含前10个整数(1~10)的平方呢?在Python中,用两个星号(**)表示乘方运算。下面的代码演示了如何将前10个整数的平方加入一个列表中:
squares = []
for value in range(1,11):
squares.append(value ** 2)
# [1,4,9,16,25,36,49,64,81,100]
print(squares)
有几个专门用于处理数字列表的Python函数。例如,你可以轻松地找出数字列表的最大值、最小值和总和:
digits = [1,2,3,4,5,6,7,8,9,0]
# 0
min(digits)
# 9
max(digits)
# 45
sum(digits)
前面介绍的生成列表squares的方式包含三四行代码,而列表解析让你只需编写一行代码就能生成这样的列表。列表解析将for循环和创建新元素的代码合并成一行,并自动附加新元素。
# 请注意,这里的for语句末尾没有冒号。
squares = [value**2 for value in range(1,11)]
# [1,4,9,16,25,36,49,64,81,100]
print(squares)
除了处理整个列表,你还可以处理列表的部分元素,Python称之为切片。
要创建切片,可指定要使用的第一个元素和最后一个元素的索引。与函数range()一样,Python在到达第二个索引之前的元素后停止。
players = ['charles','martina','michael','florence','eli']
# ['charles','martina','michael']
print(players[0:3])
如果没有指定第一个索引,Python将自动从列表开头开始:
players = ['charles','martina','michael','florence','eli']
# ['charles','martina','michael','florence']
print(players[:4])
要让切片终止于列表末尾,也可使用类似的语法。例如,如果要提取从第三个元素到列表末尾的所有元素,可将起始索引指定为2,并省略终止索引:
players = ['charles','martina','michael','florence','eli']
# ['michael','florence','eli']
print(players[2:])
前面说过,负数索引返回离列表末尾相应距离的元素,因此你可以输出列表末尾的任意切片。例如,如果要输出名单上的最后三名队员,可使用切片players[-3:]:
players = ['charles','martina','michael','florence','eli']
print(players[-3:])
注意
注意:可在表示切片的方括号内指定第三个值。这个值告诉Python在指定范围内每隔多少元素提取一个。
遍历切片:
players = ['charles','martina','michael','florence','eli']
print("Here are the first three players on my team:")
for player in players[:3]:
print(player.title())
打印内容如下:
Here are the first three players on my team:
Charles
Martina
Michael
要复制列表,可创建一个包含整个列表的切片,方法是同时省略起始索引和终止索引([:])。
元组
Python将不能修改的值称为不可变的,而不可变的列表被称为元组。元组看起来很像列表,但使用圆括号而非中括号来标识。定义元组后,就可使用索引来访问其元素,就像访问列表元素一样。
注意:严格地说,元组是由逗号标识的,圆括号只是让元组看起来更整洁、更清晰。如果你要定义只包含一个元素的元组,必须在这个元素后面加上逗号:
my_t = (3,)
遍历元组中的所有值:
dimensions = (200,50)
for dimension in dimensions:
print(dimension)
相比于列表,元组是更简单的数据结构。如果需要存储的一组值在程序的整个生命周期内都不变,就可以使用元组。
cars = ['audi','bmw','subaru','toyota']
for car in cars:
if car == 'bmw':
print(car.upper())
else:
print(car.title())
要检查是否两个条件都为True,可使用关键字and将两个条件测试合而为一。
关键字or也能够让你检查多个条件,但只要至少一个条件满足,就能通过整个测试。仅当两个测试都没有通过时,使用or的表达式才为False。
要判断特定的值是否已包含在列表中,可使用关键字in。
requested_toppings = ['mushrooms','onions','pineapple']
# True
'mushrooms'in requested_toppings
# False
'pepperoni'in requested_toppings
还有些时候,确定特定的值未包含在列表中很重要。在这种情况下,可使用关键字not in。
banned_users = ['andrew','carolina','david']
user = 'marie'
if user not in banned_users:
print(f"{user.title()},you can post a response if you wish.")
布尔表达式:
game_active = True
can_edit = False
最简单的if语句只有一个测试和一个操作:
if conditional_test:
do something
if-else语句:
age = 17
if age >= 18:
print("You are old enough to vote!")
print("Have you registered to vote yet?")
else:
print("Sorry,you are too young to vote.")
print("Please register to vote as soon as you turn 18!")
requested_toppings = ['mushrooms','green peppers','extra cheese']
for requested_topping in requested_toppings:
if requested_topping == 'green peppers':
print("Sorry,we are out of green peppers right now.")
else:
print(f"Adding {requested_topping}.")
print("\nFinished making your pizza!")
if-elif-else结构:
age = 12
if age <4:
print("Your admission cost is $0.")
elif age <18:
print("Your admission cost is $25.")
else:
print("Your admission cost is $40.")
age = 12
if age <4:
price = 0
elif age <18:
price = 25
elif age <65:
price = 40
else:
price = 20
print(f"Your admission cost is ${price}.")
else 代码块是可以省略的:
age = 12
if age <4:
price = 0
elif age <18:
price = 25
elif age <65:
price = 40
elif age >= 65:
price = 20
print(f"Your admission cost is ${price}.")
在if语句中将列表名用作条件表达式时,Python将在列表至少包含一个元素时返回True,并在列表为空时返回False(这点和JavaScript中的空数组的布尔值判断逻辑不一样)。
遍历键值对:
user = {
'username': 'efermi',
}
for key,value in user.items():
print(f"\nkey: {key}")
print(f"Value: {value}")
遍历键:
# .keys() 是可以省略的,默认就是遍历所有键
for name in fvorite_languages.keys():
print(name.title())
遍历值:
for language in favorite_languages.values():
print(language.title())
# 对值进行去重后再遍历
for language in set(favorate_languages.values):
print(language.title())
在Python中,字典是一系列键值对。每个键都与一个值相关联,你可使用键来访问相关联的值。与键相关联的值可以是数、字符串、列表乃至字典。事实上,可将任何Python对象用作字典中的值。
在Python 3.7中,字典中元素的排列顺序与定义时相同。如果将字典打印出来或遍历其元素,将发现元素的排列顺序与添加顺序相同。
alien_0 = {'color':'green','points':5}
对于字典中不再需要的信息,可使用del语句将相应的键值对彻底删除。使用del语句时,必须指定字典名和要删除的键。
alien_0 = {'color':'green','points':5}
print(alien_0)
del alien_0['points']
print(alien_0)
使用放在方括号内的键从字典中获取感兴趣的值时,可能会引发问题:如果指定的键不存在就会出错。就字典而言,可使用方法get()在指定的键不存在时返回一个默认值,从而避免这样的错误。方法get()的第一个参数用于指定键,是必不可少的;第二个参数为指定的键不存在时要返回的值,是可选的。
alien_0 = {'color':'green','speed':'slow'}
point_value = alien_0.get('points','No point value assigned.')
print(point_value)
遍历所有键值对:
user_0 = {
'username':'efermi',
'first':'enrico',
'last':'fermi',
}
for key,value in user_0.items():
print(f"\nKey:{key}")
print(f"Value:{value}")
遍历字典中的所有键:
favorite_languages = {
'jen':'python',
'sarah':'c',
'edward':'ruby',
'phil':'python',
}
for name in favorite_languages.keys():
print(name.title())
遍历字典时,会默认遍历所有的键。即 for name in favorite_languages:
等价于 for name in favorite_languages.keys():
。显式地使用方法keys()可让代码更容易理解,你可以选择这样做,但是也可以省略它。
要以特定顺序返回元素,一种办法是在for循环中对返回的键进行排序。为此,可使用函数sorted()来获得按特定顺序排列的键列表的副本:
favorite_languages = {
'jen':'python',
'sarah':'c',
'edward':'ruby',
'phil':'python',
}
for name in sorted(favorite_languages.keys()):
print(f"{name.title()},thank you for taking the poll.")
遍历字典中的所有值:
favorite_languages = {
'jen':'python',
'sarah':'c',
'edward':'ruby',
'phil':'python',
}
print("The following languages have been mentioned:")
for language in favorite_languages.values():
print(language.title())
通过对包含重复元素的列表调用set(),可让Python找出列表中独一无二的元素,并使用这些元素来创建一个集合。
favorite_languages = {
--snip--
}
print("The following languages have been mentioned:")
for language in set(favorite_languages.values()):
print(language.title())
可使用一对花括号直接创建集合,并在其中用逗号分隔元素:
languages = {'python','ruby','python','c'}
# {'ruby','python','c'}
languages
函数input()让程序暂停运行,等待用户输入一些文本。获取用户输入后,Python将其赋给一个变量,以方便你使用。
message = input("Tell me something,and I will repeat it back to you:")
print(message)
函数input()接受一个参数——要向用户显示的提示(prompt)或说明,让用户知道该如何做。在本例中,Python运行第一行代码时,用户将看到提示Tell me something,and I will repeat it back to you:。程序等待用户输入,并在用户按回车键后继续运行。输入被赋给变量message,接下来的print(message)将输入呈现给用户。
函数int()将数的字符串表示转换为数值表示,如下所示:
age = input("How old are you?")
# How old are you?21
age = int(age)
# True
age >= 18
prompt = "\nTell me something,and I will repeat it back to you:"
prompt += "\nEnter 'quit'to end the program."
message = ""
while message != 'quit':
message = input(prompt)
print(message)
删除为特定值的所有列表元素:
pets = ['dog','cat','dog','goldfish','cat','rabbit','cat']
print(pets)
while 'cat'in pets:
pets.remove('cat')
print(pets)
有时候,预先不知道函数需要接受多少个实参,好在Python允许函数从调用语句中收集任意数量的实参。例如,来看一个制作比萨的函数,它需要接受很多配料,但无法预先确定顾客要多少种配料。下面的函数只有一个形参*toppings,但不管调用语句提供了多少实参,这个形参会将它们统统收入囊中:
def make_pizza(*toppings):
"""打印顾客点的所有配料。"""
print(toppings)
make_pizza('pepperoni')
make_pizza('mushrooms','green peppers','extra cheese')
形参名*toppings中的星号让Python创建一个名为toppings的空元组,并将收到的所有值都封装到这个元组中。函数体内的函数调用print()通过生成输出,证明Python能够处理使用一个值来调用函数的情形,也能处理使用三个值来调用函数的情形。它以类似的方式处理不同的调用。注意,Python将实参封装到一个元组中,即便函数只收到一个值:
('pepperoni',)
('mushrooms','green peppers','extra cheese')
如果要遍历其中的具体元素:
def make_pizza(*toppings):
"""概述要制作的比萨。"""
print("\nMaking a pizza with the following toppings:")
for topping in toppings:
print(f"- {topping}")
make_pizza('pepperoni')
make_pizza('mushrooms','green peppers','extra cheese')
结合使用位置实参和任意数量实参:
def make_pizza(size,*toppings):
"""概述要制作的比萨。"""
print(f"\nMaking a {size}-inch pizza with the following toppings:")
for topping in toppings:
print(f"- {topping}")
make_pizza(16,'pepperoni')
make_pizza(12,'mushrooms','green peppers','extra cheese')
*args
你经常会看到通用形参名*args,它也收集任意数量的位置实参。
使用任意数量的关键字实参:
def build_profile(first,last,**user_info):
"""创建一个字典,其中包含我们知道的有关用户的一切。"""
user_info['first_name'] = first
user_info['last_name'] = last
return user_info
user_profile = build_profile('albert','einstein',
location='princeton',
field='physics')
print(user_profile)
上例得到内容如下:
{'location':'princeton','field':'physics',
'first_name':'albert','last_name':'einstein'}
**kwargs
注意 你经常会看到形参名**kwargs,它用于收集任意数量的关键字实参。
import语句允许在当前运行的程序文件中使用模块中的代码。
假定现在有文件 pizza.py
:
def make_pizza(size,*toppings):
"""概述要制作的比萨。"""
print(f"\nMaking a {size}-inch pizza with the following toppings:")
for topping in toppings:
print(f"- {topping}")
导入整个模块(只需编写一条import语句并在其中指定模块名,就可在程序中使用该模块中的所有函数):
import pizza
pizza.make_pizza(16,'pepperoni')
pizza.make_pizza(12,'mushrooms','green peppers','extra cheese')
还可以导入模块中的特定函数,这种导入方法的语法如下:
from module_name import function_name
通过用逗号分隔函数名,可根据需要从模块中导入任意数量的函数:
from module_name import function_0,function_1,function_2
使用 as
给函数指定别名:
from pizza import make_pizza as mp
mp(16,'pepperoni')
mp(12,'mushrooms','green peppers','extra cheese')
使用as给模块指定别名:
import pizza as p
p.make_pizza(16,'pepperoni')
p.make_pizza(12,'mushrooms','green peppers','extra cheese')
使用星号(*)运算符可让Python导入模块中的所有函数:
from pizza import *
make_pizza(16,'pepperoni')
make_pizza(12,'mushrooms','green peppers','extra cheese')
给形参指定默认值时,等号两边不要有空格:
def function_name(parameter_0,parameter_1='default value')
对于函数调用中的关键字实参,也应遵循这种约定:
function_name(value_0,parameter_1='value')
本章主要讲一些求职技巧、心态辅导、对公司的选择。以及入职后在工作中的一些指导意见、职业路线规划等。
先看一些不好的简历。
曾经看到过一份7年工作经验的高级前端工程师简历,在其自我评价中写道:
项目经验丰富,有XX、XX大型系统产品的前端负责人架构经验,也有谷歌、XX、XX、XX、XX多行业千万级订单项目的负责人管理经验,对于开发项目系统性流程能有效保障,从项目启动到开发完成到检验验收到项目收尾能清晰规范,并完成项目汇报工作。
读完后是什么感觉?是不是感觉这个简历内容很有投机的感觉,让人觉得不踏实。这里列举了一大堆知名公司,好像很厉害,但其实结合这份简历中的其他内容,明眼人一看就知道这段话实际的含义是:做了大量外包项目,而且每个项目的时间都不长。
这种简历,一般都是投机性质的。简历作者会往各个小公司投简历,碰到那种看不破的公司,就容易被忽悠。会被忽悠的公司,一般也没有足够的技术能力去面试其技术水平,就可能被其忽悠出一个较高的薪资。
该简历中类似的文案还有:
使用前端智能化工具gulp进行模块化开发。
来看一位拥有4年工作经历的“百分比先生”的简历:
每次游戏活动能够准时上线,上线后用户体验良好,吸引了30%老玩家回归。
可视化配置的客服系统能够便捷按时开发完成,提高了50%的页面开发效率。
通过开发聚合SDK,将权限与路由统一处理,提高了前端50%的开发效率。
权限平台的开发,让运营人员将权限的操作进行了可视化,提高了70%的工作效率。
数字化平台的推广与接入各个事业部,提高了事业部20%的工作效率。
通过使用课件编辑器,让老师备课到选题进行在线编辑预览,提高了50%的备课效率。
课件编辑器的幻灯片在线播放,提高老师端与学生端20%的工作效率。
接入了试题库以及题目SDK的使用,提高了老师30%的工作效率。
官网上线后的体验极佳,吸引了20%的老玩家回归。
这是一份充斥着各种百分比数据的简历。 正常情况下,数字会让简历显得更有说服力,但这份简历成功地起到了反效果——看着太假了。 老玩家回归和官网上线按理关系并不大。而且所有百分比数字都是10的整数倍。 显然这些数字是简历作者自己凭感觉定的。
这是一个工作经验2年的简历中的内容节选:
工作经历:
这个简历中,每份工作经历都很短,最长的才一年左右。短短2年时间内换了3次工作。 这样的简历,就算内容让人觉得技术能力过关,也很难让人有意向去招聘这个人。 因为很可能这个人不适合团队协作,或者抗压能力低,总之就是招这个人风险很高。
这是一份在线简历,简历地址:https://www.orzzone.com/cv。
为什么说这份简历好?因为是我自己的简历(误)。
首先从简历的使用上来说。这份简历完全是按A4纸格式撰写的,右上方也有打印功能,这一点就非常方便投简历的投递使用。另外,打印内容与页面在浏览器里显示的内容一致,这一点使得我们不需要额外调试打印出来的样式,可以直接根据页面在浏览器中呈现出来的样子决定是否需要对某些内容进行调整。
其次,从简历的可读性上来看。这份简历重点突出,重点关键词用了加黑效果,部分还加大字号显示。另外,这份简历用了不少图片,能有效提高看了一天简历的面试官的兴趣。
再次,从摆事实的角度来看看。很多简历里都只有干巴巴的描述文案,我们这份简历里使用了不少数据。而且一看就是计算出来的数据。但是现在简历造假的人太多了,如果有人怀疑简历中数据的真实性质,可以直接让他们去背调即可。
最后,简历的内容才是核心。排版样式和技巧都是辅助的,简历最核心的还是求职者自身的能力展现,如果自身能力不行,再多的技巧也于事无补。所以大家在平时工作中要注意自我提升,编码时多带着些思考。争取一些有意义的或者偏技术性质的kpi来做是很好的一种途径,既提高了在现任公司中的影响力,也对简历的内容撰写有了不少的帮助。
备战面试,应该从4个方向进行准备:
知识的总结可以通过画思维导图或者记笔记(比如写这本书)的方式进行,方便日后回顾。
但是日常知识体系的搭建和项目经验的积累是靠平日里一点一滴积累起来的,有很多经验性的东西, 靠看面试题之类的是很难积累起来的,比如代码重构相关的经验、对工程化项目代码的理解等。
新人或者一直做小项目或者一直在做大项目里某个子模块的开发可能会觉得所有能封装的东西都封装起来比较好,这样修改相关需求时往往可以事半功倍。
但是经常在大项目里跨模块开发的人可能就会觉得除非非常通用的东西,很多是不用封装的, 一来经常跨模块开发意味着对很多模块的熟悉度都有限,容易出现改一个地方多个页面出错的问题, 测试的时候因为不是需求相关页面所以根本没测,就变成产线问题了,而当一个组件被很多业务里用到时,如果没有人精心维护这种组件, 这个组件内部的代码很容易变得非常丑陋,到最后没法轻易地知道要传什么属性给这个组件。
你说这两种想法有孰优孰劣吗,我觉得是没有的,这个事情就是要看项目体量,体量小时容错率高(比如不容易漏测试,更大概率用户量小), 项目的学习/熟悉成本也低,业务复杂度低,这种情况下我觉得就是要尽量封装。 但是体量大了我就不建议尽量封装了,体量大了容错率低,而且对工程项目来说,可维护性才是最重要的。
当然凡事不绝对,比如虽然是个大项目,但是有很多开发参与,每个人又主要各自维护其中个别几个模块,那其实就是多个小项目的情况了,也就适合封装了(小模块内封装模块自己用的,非常通用的封装到外面)。
这本书其实也帮不了你什么,主要是辅助梳理下知识体系。学习,终归是要靠自己的。自助者,天助之。
这是我很喜欢的一篇网络译文,放在最后,与君共勉。
《我的心曾悲伤七次》——卡里·纪伯伦
第一次,当它本可进取时,却故作谦卑;
第二次,当它在空虚时,用爱欲来填充;
第三次,在困难和容易之间,它选择了容易;
第四次,它犯了错,却借由别人也会犯错来宽慰自己;
第五次,它自由软弱,却把它认为是生命的坚韧;
第六次,当它鄙夷一张丑恶的嘴脸时,却不知那正是自己面具中的一副;
第七次,它侧身于生活的污泥中,虽不甘心,却又畏首畏尾。
参考的书籍:
参考的文章:
感谢以下通过参与到本书的编写工作中来。