高级篇
【腾讯文档】3、第三部分:高级篇(91 题).
# 1. JavaScript 进阶
# 内置类型
- 基本类型和对象类型
- 基本类型有七种:null,undefined,boolean,number,string,symbol,bigInt
- 其中 JS 的数字类型是浮点类型的,没有整型。并且浮点类型基于 IEEE 754 标准实现,在使⽤中会遇到某些 Bug。 NaN 也属于 number 类型,并且 NaN 不等于⾃身。
- 对于基本类型来说,如果使⽤字⾯量的⽅式,那么这个变量只是个字⾯量,只有在必要的时候才会转换为对应的类型。
let a = 111; // 这只是字⾯量,不是 number 类型
a.toString(); // 使⽤时候才会转换为对象类型
# typeof
typeof 对于基本类型,除了 null 都可以显示正确的类型
typeof 1; // 'number'
typeof "1"; // 'string'
typeof undefined; // 'undefined'
typeof true; // 'boolean'
typeof Symbol(); // 'symbol'
typeof b; // b 没有声明,但是还会显示 undefined
typeof 对于对象,除了函数都会显示 object
typeof []; // 'object'
typeof {}; // 'object'
typeof console.log; // 'function'
对于 null 来说,虽然它是基本类型,但是会显示 object ,这是⼀个存在很久了的 Bug
PS:为什么会出现这种情况呢?因为在 JS 的最初版本中,使⽤的是 32 位系统,为了性能考虑使⽤低位存储了变量的类型信息, 000 开头代表是对象,然⽽ null 表示为全零,所以将它错误的判断为 object 。虽然现在的内部类型判断代码已经改变了,但是对于这个 Bug 却是⼀直流传下来。
如果我们想获得⼀个变量的正确类型,可以通过
Object.prototype.toString.call(xx)
。这样我们就可以获得类似 [object Type]
的字符串
typeof undefined; // 'undefined'
typeof true; // 'boolean'
typeof Symbol(); // 'symbol'
typeof b; // b 没有声明,但是还会显示 undefined
typeof []; // 'object'
typeof {}; // 'object'
typeof console.log; // 'function'
typeof null; // 'object'
let a;
// 我们也可以这样判断 undefined
a === undefined;
// 但是 undefined 不是保留字,能够在低版本浏览器被赋值
let undefined = 1;
// 这样判断就会出错
// 所以可以⽤下⾯的⽅式来判断,并且代码量更少
// 因为 void 后⾯随便跟上⼀个组成表达式
// 返回就是 undefined
a === void 0;
数据类型看这个:https://blog.shenzjd.com/pages/15beed3c2f8d4/ (opens new window)
# 类型转换
# 转 Boolean
在条件判断时,除了 undefined , null , false , NaN , '' ,0 , -0 ,其他所有值都转为 true ,包括所有对象
# 对象转基本类型
对象在转换基本类型时,⾸先会调⽤ valueOf 然后调⽤ toString 。并且这两个⽅法你是可以重写的
# 四则运算符
只有当加法运算时,其中⼀⽅是字符串类型,就会把另⼀个也转为字符串类型。其他运算只要其中⼀⽅是数字,那么另⼀⽅就转为数字。并且加法运算会触发三种类型转换:将值转换为原始值,转换为数字,转换为字符串
1 + "1"; // '11'
2 * "2"; // 4
[1, 2] + [2, 1]; // '1,22,1'
// [1, 2].toString() -> '1,2'
// [2, 1].toString() -> '2,1'
// '1,2' + '2,1' = '1,22,1'
对于加号需要注意这个表达式 'a' + + 'b'
"a" + +"b"; // -> "aNaN"
// 因为 + 'b' -> NaN
// 你也许在⼀些代码中看到过 + '1' -> 1
# == 操作符
这⾥来解析⼀道题⽬ [] == ![] // -> true ,下⾯是这个表达式为何为 true 的步骤
// [] 转成 true,然后取反变成 false
[] == false
// 根据第 8 条得出
[] == ToNumber(false)
[] == 0
// 根据第 10 条得出
ToPrimitive([]) == 0
// [].toString() -> ''
'' == 0
// 根据第 6 条得出
0 == 0 // -> true
# ⽐较运算符
- 如果是对象,就通过 toPrimitive 转换对象
- 如果是字符串,就通过 unicode 字符索引来⽐较
隐士类型转换可以看这个:https://blog.shenzjd.com/pages/52123c6f578c7/ (opens new window)
# 原型
- 每个函数都有 prototype 属性,除了 Function.prototype.bind() ,该属性指向原型
- 每个对象都有 proto 属性,指向了创建该对象的构造函数的原型。其实这个属性指向了 [[prototype]] ,但是 [[prototype]] 是内部属性,我们并不能访问到,所以使⽤ proto 来访问
- 对象可以通过 proto 来寻找不属于该对象的属性, proto 将对象连接起来组成了原型链
原型看这个:https://blog.shenzjd.com/pages/680e335c611f2/ (opens new window)
# new
- 新⽣成了⼀个对象
- 链接到原型
- 绑定 this
- 返回新对象
在调⽤ new 的过程中会发⽣以上四件事情,我们也可以试着来⾃⼰实现⼀个 new
function create() {
// 创建⼀个空的对象
let obj = new Object();
// 获得构造函数
let Con = [].shift.call(arguments);
// 链接到原型
obj.__proto__ = Con.prototype;
// 绑定 this,执⾏构造函数
let result = Con.apply(obj, arguments);
// 确保 new 出来的是个对象
return typeof result === "object" ? result : obj;
}
new 操作符可以看这个:https://blog.shenzjd.com/pages/71d970640a8d9/ (opens new window)
# instanceof
instanceof 可以正确的判断对象的类型,因为内部机制是通过判断对象的原型链中是不是能找到类型的 prototype
function instanceof(left, right) {
// 获得类型的原型
let prototype = right.prototype
// 获得对象的原型
left = left.__proto__
// 判断对象的类型是否等于类型的原型
while (true) {
if (left === null)
return false
if (prototype === left)
return true
left = left.__proto__
}
}
手写 instanceof:https://blog.shenzjd.com/pages/15beed3c2f8d4/#实现-instanceof-功能 (opens new window)
# this
function foo() {
console.log(this.a);
}
var a = 1;
foo();
var obj = {
a: 2,
foo: foo,
};
obj.foo();
// 以上两者情况 `this` 只依赖于调⽤函数前的对象,优先级是第⼆个情况⼤于第⼀个情况
// 以下情况是优先级最⾼的,`this` 只会绑定在 `c` 上,不会被任何⽅式修改 `this` 指向
var c = new foo();
c.a = 3;
console.log(c.a);
// 还有种就是利⽤ call,apply,bind 改变 this,这个优先级仅次于 new
看看箭头函数中的 this
function a() {
return () => {
return () => {
console.log(this);
};
};
}
console.log(a()()());
箭头函数其实是没有 this 的,这个函数中的 this 只取决于他外⾯的第⼀个不是箭头函数的函数的 this 。在这个例⼦中,因为调⽤ a 符合前⾯代码中的第⼀个情况,所以 this 是 window 。并且 this ⼀旦绑定了上下⽂,就不会被任何代码改变
# 执行上下文
当执⾏ JS 代码时,会产⽣三种执⾏上下⽂
- 全局执⾏上下⽂
- 函数执⾏上下⽂
- eval 执⾏上下⽂
每个执⾏上下⽂中都有三个重要的属性
- 变量对象( VO ),包含变量、函数声明和函数的形参,该属性只能在全局上下⽂中访问
- 作⽤域链( JS 采⽤词法作⽤域,也就是说变量的作⽤域是在定义时就决定了)
- this
var a = 10;
function foo(i) {
var b = 20;
}
foo();
对于上述代码,执⾏栈中有两个上下⽂:全局上下⽂和函数 foo 上下⽂。
stack = [globalContext, fooContext];
对于全局上下⽂来说, VO ⼤概是这样的
globalContext.VO === globe
globalContext.VO = {
a: undefined,
foo: <Function>,
}
对于函数 foo 来说, VO 不能访问,只能访问到活动对象( AO )
// arguments 是函数独有的对象(箭头函数没有)
// 该对象是⼀个伪数组,有 `length` 属性且可以通过下标访问元素
// 该对象中的 `callee` 属性代表函数本身
// `caller` 属性代表函数的调⽤者
fooContext.VO === foo.AO
fooContext.AO {
i: undefined,
b: undefined,
arguments: <>
}
对于作⽤域链,可以把它理解成包含⾃身变量对象和上级变量对象的列表,通过 [[Scope]] 属性查找上级变量
fooContext.[[Scope]] = [
globalContext.VO
]
fooContext.Scope = fooContext.[[Scope]] + fooContext.VO
fooContext.Scope = [
fooContext.VO,
globalContext.VO
]
接下来让我们看⼀个⽼⽣常谈的例⼦, var
b(); // call b
console.log(a); // undefined
var a = "Hello world";
function b() {
console.log("call b");
}
想必以上的输出⼤家肯定都已经明⽩了,这是因为函数和变量提升的原因。通常提升的解释是说将声明的代码移动到了顶部,这其实没有什么错误,便于⼤家理解。但是更准确的解释应该是:在⽣成执⾏上下⽂时,会有两个阶段。第⼀个阶段是创建的阶段(具体步骤是创建 VO ), JS 解释器会找出需要提升的变量和函数,并且给他们提前在内存中开辟好空间,函数的话会将整个函数存⼊内存中,变量只声明并且赋值为 undefined ,所以在第⼆个阶段,也就是代码执⾏阶段,我们可以直接提前使⽤
- 在提升的过程中,相同的函数会覆盖上⼀个函数,并且函数优先于变量提升
b(); // call b second
function b() {
console.log("call b fist");
}
function b() {
console.log("call b second");
}
var b = "Hello world";
var 会产⽣很多错误,所以在 ES6 中引⼊了 let 。 let 不能在声明前使⽤,但是这并不是常说的 let 不会提升, let 提升了声明但没有赋值,因为临时死区导致了并不能在声明前使⽤
- 对于⾮匿名的⽴即执⾏函数需要注意以下⼀点
var foo = 1(
(function foo() {
foo = 10;
console.log(foo);
})()
); // -> ƒ foo() { foo = 10 ; console.log(foo) }
因为当 JS 解释器在遇到⾮匿名的⽴即执⾏函数时,会创建⼀个辅助的特定对象,然后将函数名称作为这个对象的属性,因此函数内部才可以访问到 foo ,但是这个值⼜是只读的,所以对它的赋值并不⽣效,所以打印的结果还是这个函数,并且外部的值也没有发⽣更改。
specialObject = {};
Scope = specialObject + Scope;
foo = new FunctionExpression;
foo.[[Scope]] = Scope;
specialObject.foo = foo; // {DontDelete}, {ReadOnly}
delete Scope[0]; // remove specialObject from the front of scope chain
# 闭包
闭包的定义很简单:函数 A 返回了⼀个函数 B,并且函数 B 中使⽤了函数 A 的变量,函数 B 就被称为闭包
function A() {
let a = 1;
function B() {
console.log(a);
}
return B;
}
闭包定义建议看:https://blog.shenzjd.com/pages/84526eb582265/ (opens new window)
你是否会疑惑,为什么函数 A 已经弹出调⽤栈了,为什么函数 B 还能引⽤到函数 A 中的变量。因为函数 A 中的变量这时候是存储在堆上的。现在的 JS 引擎可以通过逃逸分析辨别出哪些变量需要存储在堆上,哪些需要存储在栈上
经典⾯试题,循环中使⽤闭包解决 var 定义函数的问题
for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}
- ⾸先因为 setTimeout 是个异步函数,所有会先把循环全部执⾏完毕,这时候 i 就是 6 了,所以会输出⼀堆 6
- 解决办法两种,第⼀种使⽤闭包
for (var i = 1; i <= 5; i++) {
(function (j) {
setTimeout(function timer() {
console.log(j);
}, j * 1000);
})(i);
}
- 第⼆种就是使⽤ setTimeout 的第三个参数
for (var i = 1; i <= 5; i++) {
setTimeout(
function timer(j) {
console.log(j);
},
i * 1000,
i
);
}
- 第三种就是使⽤ let 定义 i 了
for (let i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}
因为对于 let 来说,他会创建⼀个块级作⽤域,相当于
{
// 形成块级作⽤域
let i = 0;
{
let ii = isetTimeout(function timer() {
console.log(i);
}, i * 1000);
}
i++;
{
let ii = i;
}
i++;
{
let ii = i;
}
...
}
# 深浅拷贝
letet a a = {
age : 1
}
let b = a
a.age = 2
console.log(b.age) // 2
- 从上述例⼦中我们可以发现,如果给⼀个变量赋值⼀个对象,那么两者的值会是同⼀个引⽤,其中⼀⽅改变,另⼀⽅也会相应改变。
- 通常在开发中我们不希望出现这样的问题,我们可以使⽤浅拷⻉来解决这个问题
# 浅拷贝
⾸先可以通过 Object.assign 来解决这个问题
let a = {
age: 1,
};
let b = Object.assign({}, a);
a.age = 2;
console.log(b.age); // 1
当然我们也可以通过展开运算符 (…) 来解决
let a = {
age: 1,
};
let b = { ...a };
a.age = 2;
console.log(b.age); // 1
通常浅拷⻉就能解决⼤部分问题了,但是当我们遇到如下情况就需要使⽤到深拷⻉了
let a = {
age: 1,
jobs: {
first: "FE",
},
};
let b = { ...a };
a.jobs.first = "native";
console.log(b.jobs.first); // native
浅拷⻉只解决了第⼀层的问题,如果接下去的值中还有对象的话,那么就⼜回到刚开始的话题了,两者享有相同的引⽤。要解决这个问题,我们需要引⼊深拷
# 深拷贝
这个问题通常可以通过 JSON.parse(JSON.stringify(object)) 来解决
let a = {
age: 1,
jobs: {
first: "FE",
},
};
let b = JSON.parse(JSON.stringify(a));
a.jobs.first = "native";
console.log(b.jobs.first); // FE
但是该⽅法也是有局限性的:
- 会忽略 undefined
- 不能序列化函数
- 不能解决循环引⽤的对象
let obj = {
a: 1,
b: {
c: 2,
d: 3,
},
};
obj.c = obj.b;
obj.e = obj.a;
obj.b.c = obj.c;
obj.b.d = obj.b;
obj.b.e = obj.b.c;
let newObj = JSON.parse(JSON.stringify(obj));
console.log(newObj);
如果你有这么⼀个循环引⽤对象,你会发现你不能通过该⽅法深拷⻉
在遇到函数或者 undefined 的时候,该对象也不能正常的序列化
let a = {
age: undefined,
jobs: function () {},
name: "poetries",
};
let b = JSON.parse(JSON.stringify(a));
console.log(b); // {name: "poetries"}
- 你会发现在上述情况中,该⽅法会忽略掉函数和`undefined。
- 但是在通常情况下,复杂数据都是可以序列化的,所以这个函数可以解决⼤部分问题,并且该函数是内置函数中处理深拷⻉性能最快的。当然如果你的数据中含有以上三种情况下,可以使⽤ lodash 的深拷⻉函数
# 模块化
在有 Babel 的情况下,我们可以直接使⽤ ES6 的模块化
// file a.js
export function a() {}
export function b() {}
// file b.js
export default function () {}
import { a, b } from "./a.js";
import XXX from "./b.js";
# commonjs
CommonJs 是 Node 独有的规范,浏览器中使⽤就需要⽤到 Browserify 解析了。
// a.js
module.exports = {
a: 1,
};
// or
exports.a = 1;
// b.js
var module = require("./a.js");
module.a; // -> log 1
在上述代码中, module.exports 和 exports 很容易混淆,让我们来看看⼤致内部实现
var module = require("./a.js");
module.a;
// 这⾥其实就是包装了⼀层⽴即执⾏函数,这样就不会污染全局变量了,
// 重要的是 module 这⾥,module 是 Node 独有的⼀个变量
module.exports = {
a: 1,
};
// 基本实现
var module = {
exports: {}, // exports 就是个空对象
};
// 这个是为什么 exports 和 module.exports ⽤法相似的原因
var exports = module.exports;
var load = function (module) {
// 导出的东⻄
var a = 1;
module.exports = a;
return module.exports;
};
再来说说 module.exports 和 exports ,⽤法其实是相似的,但是不能对 exports 直接赋值,不会有任何效果
对于 CommonJS 和 ES6 中的模块化的两者区别是:
- 前者⽀持动态导⼊,也就是 require(${path}/xx.js) ,后者⽬前不⽀持,但是已有提案,前者是同步导⼊,因为⽤于服务端,⽂件都在本地,同步导⼊即使卡住主线程影响也不⼤
- ⽽后者是异步导⼊,因为⽤于浏览器,需要下载⽂件,如果也采⽤同步导⼊会对渲染有很⼤影响
- 前者在导出时都是值拷⻉,就算导出的值变了,导⼊的值也不会改变,所以如果想更新值,必须重新导⼊⼀次
- 但是后者采⽤实时绑定的⽅式,导⼊导出的值都指向同⼀个内存地址,所以导⼊值会跟随导出值变化
- 后者会编译成 require/exports 来执⾏的
# AMD
AMD 是由 RequireJS 提出的
// AMD
define(["./a", "./b"], function (a, b) {
a.do();
b.do();
});
define(function (require, exports, module) {
var a = require("./a");
a.doSomething();
var b = require("./b");
b.doSomething();
});
# 防抖
你是否在⽇常开发中遇到⼀个问题,在滚动事件中需要做个复杂计算或者实现⼀个按钮的防⼆次点击操作。
- 这些需求都可以通过函数防抖动来实现。尤其是第⼀个需求,如果在频繁的事件回调中做复杂计算,很有可能导致⻚⾯卡顿,不如将多次计算合并为⼀次计算,只在⼀个精确点做操作
- PS:防抖和节流的作⽤都是防⽌函数多次调⽤。区别在于,假设⼀个⽤户⼀直触发这个函数,且每次触发函数的间隔⼩于 wait ,防抖的情况下只会调⽤⼀次,⽽节流的 情况会每隔⼀定时间(参数 wait )调⽤函数
// 这个是⽤来获取当前时间戳的
function now() {
return +new Date();
}
/**
* 防抖函数,返回函数连续调⽤时,空闲时间必须⼤于或等于 wait,func 才会执⾏
*
* @param {function} func 回调函数
* @param {number} wait 表示时间窗⼝的间隔
* @param {boolean} immediate 设置为ture时,是否⽴即调⽤函数
* @return {function} 返回客户调⽤函数
*/
function debounce(func, wait = 50, immediate = true) {
let timer, context, args;
// 延迟执⾏函数
const later = () =>
setTimeout(() => {
// 延迟函数执⾏完毕,清空缓存的定时器序号
timer = null;
// 延迟执⾏的情况下,函数会在延迟函数中执⾏
// 使⽤到之前缓存的参数和上下⽂
if (!immediate) {
func.apply(context, args);
context = args = null;
}
}, wait);
// 这⾥返回的函数是每次实际调⽤的函数
return function (...params) {
// 如果没有创建延迟执⾏函数(later),就创建⼀个
if (!timer) {
timer = later();
// 如果是⽴即执⾏,调⽤函数
// 否则缓存参数和调⽤上下⽂
if (immediate) {
func.apply(this, params);
} else {
context = this;
args = params;
}
// 如果已有延迟执⾏函数(later),调⽤的时候清除原来的并重新设定⼀个
// 这样做延迟函数会重新计时
} else {
clearTimeout(timer);
timer = later();
}
};
}
- 对于按钮防点击来说的实现:如果函数是⽴即执⾏的,就⽴即调⽤,如果函数是延迟执⾏的,就缓存上下⽂和参数,放到延迟函数中去执⾏。⼀旦我开始⼀个定时器,只要我定时器还在,你每次点击我都重新计时。⼀旦你点累了,定时器时间到,定时器重置为 null ,就可以再次点击了
- 对于延时执⾏函数来说的实现:清除定时器 ID ,如果是延迟调⽤就调⽤函数
# 节流
防抖动和节流本质是不⼀样的。防抖动是将多次执⾏变为最后⼀次执⾏,节流是将多次执⾏变成每隔⼀段时间执⾏
/**
* underscore 节流函数,返回函数连续调⽤时,func 执⾏频率限定为 次 / wait*
* @param {function} func 回调函数
* @param {number} wait 表示时间窗⼝的间隔
* @param {object} options 如果想忽略开始函数的的调⽤,传⼊{leading: false
* 如果想忽略结尾函数的调⽤,传⼊{trailing: false
* 两者不能共存,否则函数不能执⾏
* @return {function} 返回客户调⽤函数
*/
_.throttle = function (func, wait, options) {
var context, args, result;
var timeout = null;
// 之前的时间戳
var previous = 0;
// 如果 options 没传则设为空对象
if (!options) options = {};
// 定时器回调函数
var later = function () {
// 如果设置了 leading,就将 previous 设为 0
// ⽤于下⾯函数的第⼀个 if 判断
previous = options.leading === false ? 0 : _.now();
// 置空⼀是为了防⽌内存泄漏,⼆是为了下⾯的定时器判断
timeout = null;
result = func.apply(context, args);
if (!timeout) context = args = null;
};
return function () {
// 获得当前时间戳
var now = _.now();
// ⾸次进⼊前者肯定为 true
// 如果需要第⼀次不执⾏函数
// 就将上次时间戳设为当前的
// 这样在接下来计算 remaining 的值时会⼤于0
if (!previous && options.leading === false) previous = now;
// 计算剩余时间
var remaining = wait - (now - previous);
context = this;
args = arguments;
// 如果当前调⽤已经⼤于上次调⽤时间 + wait
// 或者⽤户⼿动调了时间
// 如果设置了 trailing,只会进⼊这个条件
// 如果没有设置 leading,那么第⼀次会进⼊这个条件
// 还有⼀点,你可能会觉得开启了定时器那么应该不会进⼊这个 if 条件了
// 其实还是会进⼊的,因为定时器的延时
// 并不是准确的时间,很可能你设置了2秒
// 但是他需要2.2秒才触发,这时候就会进⼊这个条件
if (remaining <= 0 || remaining > wait) {
// 如果存在定时器就清理掉否则会调⽤⼆次回调
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(context, args);
if (!timeout) context = args = null;
} else if (!timeout && options.trailing !== false) {
// 判断是否设置了定时器和 trailing
// 没有的话就开启⼀个定时器
// 并且不能不能同时设置 leading 和 trailing
timeout = setTimeout(later, remaining);
}
return result;
};
};
防抖节流建议看: https://blog.shenzjd.com/pages/3c209d1a362c4/#防抖函数 (opens new window)
# 继承
在 ES5 中,我们可以使⽤如下⽅式解决继承的问题
function Super() {}
Super.prototype.getNumber = function () {
return 1;
};
function Sub() {}
let s = new Sub();
Sub.prototype = Object.create(Super.prototype, {
constructor: {
value: Sub,
enumerable: false,
writable: true,
configurable: true,
},
});
- 以上继承实现思路就是将⼦类的原型设置为⽗类的原型
- 在 ES6 中,我们可以通过 class 语法轻松解决这个问题
class MyDate extends Date {
test() {
return this.getTime();
}
}
let myDate = new MyDate();
myDate.test();
- 但是 ES6 不是所有浏览器都兼容,所以我们需要使⽤ Babel 来编译这段代码。
- 如果你使⽤编译过得代码调⽤ myDate.test() 你会惊奇地发现出现了报错
因为在 JS 底层有限制,如果不是由 Date 构造出来的实例的话,是不能调⽤ Date ⾥的函数的。所以这也侧⾯的说明了: ES6 中的 class 继承与 ES5 中的⼀般继承写法是不同的
- 既然底层限制了实例必须由 Date 构造出来,那么我们可以改变下思路实现继承
function MyData() {}
MyData.prototype.test = function () {
return this.getTime();
};
let d = new Date();
Object.setPrototypeOf(d, MyData.prototype);
Object.setPrototypeOf(MyData.prototype, Date.prototype);
- 以上继承实现思路:先创建⽗类实例 => 改变实例原先的 __proto__ 转⽽连接到⼦类的 prototype => ⼦类的 prototype 的 __proto__ 改为⽗类的 prototype
- 通过以上⽅法实现的继承就可以完美解决 JS 底层的这个限制
继承建议看这个: https://blog.shenzjd.com/pages/b58b52fcbc575/ (opens new window)
# call, apply, bind
- call 和 apply 都是为了解决改变 this 的指向。作⽤都是相同的,只是传参的⽅式不同
- 除了第⼀个参数外, call 可以接收⼀个参数列表, apply 只接受⼀个参数数组
let a = {
value: 1,
};
function getValue(name, age) {
console.log(name);
console.log(age);
console.log(this.value);
}
getValue.call(a, "shenzjd.com", "24");
getValue.apply(a, ["shenzjd.com", "24"]);
call,apply,bind 建议看:https://blog.shenzjd.com/pages/7a05690c28407/ (opens new window)
# Promise
- 可以把 Promise 看成⼀个状态机。初始是 pending 状态,可以通过函数 resolve 和 reject ,将状态转变为 resolved 或者 rejected 状态,状态⼀旦改变就不能再次变化。
- then 函数会返回⼀个 Promise 实例,并且该返回值是⼀个新的实例⽽不是之前的实例。因为 Promise 规范规定除了 pending 状态,其他状态是不可以改变的,如果返回的是⼀个相同实例的话,多个 then 调⽤就失去意义了
// 三种状态
const PENDING = "pending";
const RESOLVED = "resolved";
const REJECTED = "rejected";
// promise 接收⼀个函数参数,该函数会⽴即执⾏
function MyPromise(fn) {
let _this = this;
_this.currentState = PENDING;
_this.value = undefined;
// ⽤于保存 then 中的回调,只有当 promise
// 状态为 pending 时才会缓存,并且每个实例⾄多缓存⼀个
_this.resolvedCallbacks = [];
_this.rejectedCallbacks = [];
_this.resolve = function (value) {
if (value instanceof MyPromise) {
// 如果 value 是个 Promise,递归执⾏
return value.then(_this.resolve, _this.reject);
}
setTimeout(() => {
// 异步执⾏,保证执⾏顺序
if (_this.currentState === PENDING) {
_this.currentState = RESOLVED;
_this.value = value;
_this.resolvedCallbacks.forEach((cb) => cb());
}
});
};
_this.reject = function (reason) {
setTimeout(() => {
// 异步执⾏,保证执⾏顺序
if (_this.currentState === PENDING) {
_this.currentState = REJECTED;
_this.value = reason;
_this.rejectedCallbacks.forEach((cb) => cb());
}
});
};
// ⽤于解决以下问题
// new Promise(() => throw Error('error))
try {
fn(_this.resolve, _this.reject);
} catch (e) {
_this.reject(e);
}
}
MyPromise.prototype.then = function (onResolved, onRejected) {
var self = this;
// 规范 2.2.7,then 必须返回⼀个新的 promise
var promise2;
// 规范 2.2.onResolved 和 onRejected 都为可选参数
// 如果类型不是函数需要忽略,同时也实现了透传
// Promise.resolve(4).then().then((value) => console.log(value))
onResolved = typeof onResolved === "function" ? onResolved : (v) => v;
onRejected = typeof onRejected === "function" ? onRejected : (r) => throw r;
if (self.currentState === RESOLVED) {
return (promise2 = new MyPromise(function (resolve, reject) {
// 规范 2.2.4,保证 onFulfilled,onRjected 异步执⾏
// 所以⽤了 setTimeout 包裹下
setTimeout(function () {
try {
var x = onResolved(self.value);
resolutionProcedure(promise2, x, resolve, reject);
} catch (reason) {
reject(reason);
}
});
}));
}
if (self.currentState === REJECTED) {
return (promise2 = new MyPromise(function (resolve, reject) {
setTimeout(function () {
// 异步执⾏onRejected
try {
var x = onRejected(self.value);
resolutionProcedure(promise2, x, resolve, reject);
} catch (reason) {
reject(reason);
}
});
}));
}
if (self.currentState === PENDING) {
return (promise2 = new MyPromise(function (resolve, reject) {
self.resolvedCallbacks.push(function () {
// 考虑到可能会有报错,所以使⽤ try/catch 包裹
try {
var x = onResolved(self.value);
resolutionProcedure(promise2, x, resolve, reject);
} catch (r) {
reject(r);
}
});
self.rejectedCallbacks.push(function () {
try {
var x = onRejected(self.value);
resolutionProcedure(promise2, x, resolve, reject);
} catch (r) {
reject(r);
}
});
}));
}
};
// 规范 2.3
function resolutionProcedure(promise2, x, resolve, reject) {
// 规范 2.3.1,x 不能和 promise2 相同,避免循环引⽤
if (promise2 === x) {
return reject(new TypeError("Error"));
}
// 规范 2.3.2
// 如果 x 为 Promise,状态为 pending 需要继续等待否则执⾏
if (x instanceof MyPromise) {
if (x.currentState === PENDING) {
x.then(function (value) {
// 再次调⽤该函数是为了确认 x resolve 的
// 参数是什么类型,如果是基本类型就再次 resolve
// 把值传给下个 then
resolutionProcedure(promise2, value, resolve, reject);
}, reject);
} else {
x.then(resolve, reject);
}
return;
}
// 规范 2.3.3.3.3
// reject 或者 resolve 其中⼀个执⾏过得话,忽略其他的
let called = false;
// 规范 2.3.3,判断 x 是否为对象或者函数
if (x !== null && (typeof x === "object" || typeof x === "function")) {
// 规范 2.3.3.2,如果不能取出 then,就 reject
try {
// 规范 2.3.3.1
let then = x.then;
// 如果 then 是函数,调⽤ x.then
if (typeof then === "function") {
// 规范 2.3.3.3
then.call(
x,
(y) => {
if (called) return;
called = true;
// 规范 2.3.3.3.1
resolutionProcedure(promise2, y, resolve, reject);
},
(e) => {
if (called) return;
called = true;
reject(e);
}
);
} else {
// 规范 2.3.3.4
resolve(x);
}
} catch (e) {
if (called) return;
called = true;
reject(e);
}
} else {
// 规范 2.3.4,x 为基本类型
resolve(x);
}
}
promise 看这个: https://blog.shenzjd.com/pages/e1e76b9843736/ (opens new window)
# generator
Generator 是 ES6 中新增的语法,和 Promise ⼀样,都可以⽤来异步编程
// 使⽤ * 表示这是⼀个 Generator 函数
// 内部可以通过 yield 暂停代码
// 通过调⽤ next 恢复执⾏
function* test() {
let a = 1 + 2;
yield 2;
yield 3;
}
let b = test();
console.log(b.next()); // > { value: 2, done: false }
console.log(b.next()); // > { value: 3, done: false }
console.log(b.next()); // > { value: undefined, done: true }
从以上代码可以发现,加上 * 的函数执⾏后拥有了 next 函数,也就是说函数执⾏后返回了⼀个对象。每次调⽤ next 函数可以继续执⾏被暂停的代码。以下是 Generator 函数的简单实现
// cb 也就是编译过的 test 函数
function generator(cb) {
return (function () {
var object = {
next: 0,
stop: function () {},
};
return {
next: function () {
var ret = cb(object);
if (ret === undefined) return { value: undefined, done: true };
return {
value: ret,
done: false,
};
},
};
})();
}
// 如果你使⽤ babel 编译后可以发现 test 函数变成了这样
function test() {
var a;
return generator(function (_context) {
while (1) {
switch ((_context.prev = _context.next)) {
// 可以发现通过 yield 将代码分割成⼏块
// 每次执⾏ next 函数就执⾏⼀块代码
// 并且表明下次需要执⾏哪块代码
case 0:
a = 1 + 2;
_context.next = 4;
return 2;
case 4:
_context.next = 6;
return 3;
// 执⾏完毕
case 6:
case "end":
return _context.stop();
}
}
});
}
# Proxy
Proxy 是 ES6 中新增的功能,可以⽤来⾃定义对象中的操作
let p = new Proxy(target, handler);
// `target` 代表需要添加代理的对象
// `handler` ⽤来⾃定义对象中的操作
可以很⽅便的使⽤ Proxy 来实现⼀个数据绑定和监听
let onWatch = (obj, setBind, getLogger) => {
let handler = {
get(target, property, receiver) {
getLogger(target, property)
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
setBind(value);
return Reflect.set(target, property, value);
}
};
return new Proxy(obj, handler);
};
let obj = { a: 1 }
let value
let p = onWatch(obj, (v) => {
value = v
}, (target, property) => {
console.log(`Get '${property}' = ${target[property]}`);
})
p.a = 2 // bind `value` to `2`
p.a // -> Get 'a' = 2
# 2. 浏览器
# 事件机制
事件触发三阶段
- document 往事件触发处传播,遇到注册的捕获事件会触发
- 传播到事件触发处时触发注册的事件
- 从事件触发处往 document 传播,遇到注册的冒泡事件会触发
事件触发⼀般来说会按照上⾯的顺序进⾏,但是也有特例,如果给⼀个⽬标节点同时注册冒泡和捕获事件,事件触发会按照注册的顺序执⾏
// 以下会先打印冒泡然后是捕获
node.addEventListener(
"click",
(event) => {
console.log("冒泡");
},
false
);
node.addEventListener(
"click",
(event) => {
console.log("捕获 ");
},
true
);
# 注册事件
- 通常我们使⽤ addEventListener 注册事件,该函数的第三个参数可以是布尔值,也可以是对象。对于布尔值 useCapture 参数来说,该参数默认值为 false 。useCapture 决定了注册的事件是捕获事件还是冒泡事件
- ⼀般来说,我们只希望事件只触发在⽬标上,这时候可以使⽤ stopPropagation 来阻⽌事件的进⼀步传播。通常我们认为 stopPropagation 是⽤来阻⽌事件冒泡的,其实该函数也可以阻⽌捕获事件。 stopImmediatePropagation 同样也能实现阻⽌事件,但是还 能阻⽌该事件⽬标执⾏别的注册事件
node.addEventListener(
"click",
(event) => {
event.stopImmediatePropagation();
console.log("冒泡");
},
false
);
// 点击 node 只会执⾏上⾯的函数,该函数不会执⾏
node.addEventListener(
"click",
(event) => {
console.log("捕获 ");
},
true
);
# 事件代理
如果⼀个节点中的⼦节点是动态⽣成的,那么⼦节点需要注册事件的话应该注册在⽗节点上
<ul id="ul">
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
<li>5</li>
</ul>
<script>
let ul = document.querySelector("#ul");
ul.addEventListener("click", (event) => {
console.log(event.target);
});
</script>
- 事件代理的⽅式相对于直接给⽬标注册事件来说,有以下优点
- 节省内存
- 不需要给⼦节点注销事件
# Event Loop
众所周知 JS 是⻔⾮阻塞单线程语⾔,因为在最初 JS 就是为了和浏览器交互⽽诞⽣的。如果 JS 是⻔多线程的语⾔话,我们在多个线程中处理 DOM 就可能会发⽣问题(⼀个线程中新加节点,另⼀个线程中删除节点)
- JS 在执⾏的过程中会产⽣执⾏环境,这些执⾏环境会被顺序的加⼊到执⾏栈中。如果遇 到异步的代码,会被挂起并加⼊到 Task (有多种 task ) 队列中。⼀旦执⾏栈为空, Event Loop 就会从 Task 队列中拿出需要执⾏的代码并放⼊执⾏栈中执⾏,所以本 质上来说 JS 中的异步还是同步⾏为
console.log("script start");
setTimeout(function () {
console.log("setTimeout");
}, 0);
console.log("script end");
不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务( microtask ) 和 宏任务( macrotask )。在 ES6 规范中,microtask 称为 jobs,macrotask 称为 task
console.log("script start");
setTimeout(function () {
console.log("setTimeout");
}, 0);
new Promise((resolve) => {
console.log("Promise");
resolve();
})
.then(function () {
console.log("promise1");
})
.then(function () {
console.log("promise2");
});
console.log("script end");
// script start => Promise => script end => promise1 => promise2 => setTime
以上代码虽然 setTimeout 写在 Promise 之前,但是因为 Promise 属于 微任务⽽ setTimeout 属于宏任务
# 微任务
- process.nextTick
- promise
- Object.observe
- MutationObserver
# 宏任务
- script
- setTimeout
- setInterval
- setImmediate
- I/O
- UI rendering
宏任务中包括了 script ,浏览器会先执⾏⼀个宏任务,接下来有异步代码的话就先执⾏微任务
所以正确的⼀次 Event loop 顺序是这样的
- 执⾏同步代码,这属于宏任务
- 执⾏栈为空,查询是否有微任务需要执⾏
- 执⾏所有微任务
- 必要的话渲染 UI
- 然后开始下⼀轮 Event loop ,执⾏宏任务中的异步代码
通过上述的 Event loop 顺序可知,如果宏任务中的异步代码有⼤量的计算并且需要操作 DOM 的话,为了更快的响应界⾯响应,我们可以把操作 DOM 放⼊微任务中
# Node 中的 Event loop
- Node 中的 Event loop 和浏览器中的不相同
- Node 的 Event loop 分为 6 个阶段,它们会按照顺序反复运⾏
# timer
- timers 阶段会执⾏ setTimeout 和 setInterval
- ⼀个 timer 指定的时间并不是准确时间,⽽是在达到这个时间后尽快执⾏回调,可能会因为系统正在执⾏别的事务⽽延迟
# I/O
- I/O 阶段会执⾏除了 close 事件,定时器和 setImmediate 的回调
# poll
- poll 阶段很重要,这⼀阶段中,系统会做两件事情
- 执⾏到点的定时器
- 执⾏ poll 队列中的事件
- 并且当 poll 中没有定时器的情况下,会发现以下两件事情
- 如果 poll 队列不为空,会遍历回调队列并同步执⾏,直到队列为空或者系统限制
- 如果 poll 队列为空,会有两件事发⽣
- 如果有 setImmediate 需要执⾏, poll 阶段会停⽌并且进⼊到 check 阶段执⾏ setImmediate
- 如果没有 setImmediate 需要执⾏,会等待回调被加⼊到队列中并⽴即执⾏回调
- 如果有别的定时器需要被执⾏,会回到 timer 阶段执⾏回调。
# check
- check 阶段执⾏ setImmediate
# close callbacks
- close callbacks 阶段执⾏ close 事件
- 并且在 Node 中,有些情况下的定时器执⾏顺序是随机的
setTimeout(() => {
console.log("setTimeout");
}, 0);
setImmediate(() => {
console.log("setImmediate");
});
// 这⾥可能会输出 setTimeout,setImmediate
// 可能也会相反的输出,这取决于性能
// 因为可能进⼊ event loop ⽤了不到 1 毫秒,这时候会执⾏ setImmediate
// 否则会执⾏ setTimeout
上⾯介绍的都是 macrotask 的执⾏情况, microtask 会在以上每个阶段完成后⽴即执⾏
setTimeout(() => {
console.log("timer1");
Promise.resolve().then(function () {
console.log("promise1");
});
}, 0);
setTimeout(() => {
console.log("timer2");
Promise.resolve().then(function () {
console.log("promise2");
});
}, 0);
// 以上代码在浏览器和 node 中打印情况是不同的
// 浏览器中⼀定打印 timer1, promise1, timer2, promise2
// node 中可能打印 timer1, timer2, promise1, promise2
// 也可能打印 timer1, promise1, timer2, promise2
Node 中的 process.nextTick 会先于其他 microtask 执⾏
setTimeout(() => {
console.log("timer1");
Promise.resolve().then(function () {
console.log("promise1");
});
}, 0);
process.nextTick(() => {
console.log("nextTick");
});
// nextTick, timer1, promise1
# Service Worker
Service workers 本质上充当 Web 应⽤程序与浏览器之间的代理服务器,也可以在⽹络可⽤时作为浏览器和⽹络间的代理。它们旨在(除其他之外)使得能够创建有效的离线体验,拦截⽹络请求并基于⽹络是否可⽤以及更新的资源是否驻留在服务器上来采取适当的动作。他们还允许访问推送通知和后台同步 API
⽬前该技术通常⽤来做缓存⽂件,提⾼⾸屏速度
// index.js
if (navigator.serviceWorker) {
navigator.serviceWorker
.register("sw.js")
.then(function (registration) {
console.log("service worker 注册成功");
})
.catch(function (err) {
console.log("servcie worker 注册失败");
});
}
// sw.js
// 监听 `install` 事件,回调中缓存所需⽂件
self.addEventListener("install", (e) => {
e.waitUntil(
caches.open("my-cache").then(function (cache) {
return cache.addAll(["./index.html", "./index.js"]);
})
);
});
// 拦截所有请求事件
// 如果缓存中已经有请求的数据就直接⽤缓存,否则去请求数据
self.addEventListener("fetch", (e) => {
e.respondWith(
caches.match(e.request).then(function (response) {
if (response) {
return response;
}
console.log("fetch source");
})
);
});
打开⻚⾯,可以在开发者⼯具中的 Application 看到 Service Worker 已经启动了
在 Cache 中也可以发现我们所需的⽂件已被缓存
当我们重新刷新⻚⾯可以发现我们缓存的数据是从 Service Worker 中读取的
# 渲染机制
浏览器的渲染机制⼀般分为以下⼏个步骤
- 处理 HTML 并构建 DOM 树。
- 处理 CSS 构建 CSSOM 树。
- 将 DOM 与 CSSOM 合并成⼀个渲染树。
- 根据渲染树来布局,计算每个节点的位置
- 调⽤ GPU 绘制,合成图层,显示在屏幕上
在构建 CSSOM 树时,会阻塞渲染,直⾄ CSSOM 树构建完成。并且构建 CSSOM 树是⼀个⼗分消耗性能的过程,所以应该尽量保证层级扁平,减少过度层叠,越是具体的 CSS 选择器,执⾏速度越慢
当 HTML 解析到 script 标签时,会暂停构建 DOM,完成后才会从暂停的地⽅重新开始。也就是说,如果你想⾸屏渲染的越快,就越不应该在⾸屏就加载 JS ⽂件。并且 CSS 也会影响 JS 的执⾏,只有当解析完样式表才会执⾏ JS,所以也可以认为这种情况下,CSS 也会暂停构建 DOM
# 图层
⼀般来说,可以把普通⽂档流看成⼀个图层。特定的属性可以⽣成⼀个新的图层。不同的图层渲染互不影响,所以对于某些频繁需要渲染的建议单独⽣成⼀个新图层,提⾼性能。但也不能⽣成过多的图层,会引起反作⽤
- 通过以下⼏个常⽤属性可以⽣成新图层
- 3D 变换: translate3d 、 translateZ
- will-change
- video 、 iframe 标签
- 通过动画实现的 opacity 动画转换
- position: fixed
# 重绘(Repaint)和回流(Reflow)
- 重绘是当节点需要更改外观⽽不会影响布局的,⽐如改变 color 就叫称为重绘
- 回流是布局或者⼏何属性需要改变就称为回流
回流必定会发⽣重绘,重绘不⼀定会引发回流。回流所需的成本⽐重绘⾼的多,改变深层次的节点很可能导致⽗节点的⼀系列回流
- 所以以下⼏个动作可能会导致性能问题:
- 改变 window ⼤⼩
- 改变字体
- 添加或删除样式
- ⽂字改变
- 定位或者浮动
- 盒模型
很多⼈不知道的是,重绘和回流其实和 Event loop 有关
- 当 Event loop 执⾏完 Microtasks 后,会判断 document 是否需要更新。因为浏览器是 60Hz 的刷新率,每 16ms 才会更新⼀次。
- 然后判断是否有 resize 或者 scroll ,有的话会去触发事件,所以 resize 和 scroll 事件也是⾄少 16ms 才会触发⼀次,并且⾃带节流功能。
- 判断是否触发了 media query
- 更新动画并且发送事件
- 判断是否有全屏操作事件
- 执⾏ requestAnimationFrame 回调
- 执⾏ IntersectionObserver 回调,该⽅法⽤于判断元素是否可⻅,可以⽤于懒加载上,但是兼容性不好
- 更新界⾯
- 以上就是⼀帧中可能会做的事情。如果在⼀帧中有空闲时间,就会去执⾏ requestIdleCallback 回调
# 减少重绘和回流
- 使⽤ translate 替代 top
- 使⽤ visibility 替换 display: none ,因为前者只会引起重绘,后者会引发回流(改变了布局)
- 不要使⽤ table 布局,可能很⼩的⼀个⼩改动会造成整个 table 的重新布局
- 动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使⽤ requestAnimationFrame
- CSS 选择符从右往左匹配查找,避免 DOM 深度过深
- 将频繁运⾏的动画变为图层,图层能够阻⽌该节点回流影响别的元素。⽐如对于 video 标签,浏览器会⾃动将该节点变为图层
# 3. 性能
# 1.DNS 预解析
DNS 解析也是需要时间的,可以通过预解析的⽅式来预先获得域名所对应的 IP
<link rel="dns-prefetch" href="//blog.shenzjd.com" />
# 2.缓存
- 缓存对于前端性能优化来说是个很重要的点,良好的缓存策略可以降低资源的重复加载提⾼⽹⻚的整体加载速度
- 通常浏览器缓存策略分为两种:强缓存和协商缓存
# 强缓存
实现强缓存可以通过两种响应头实现: Expires 和 Cache-Control 。强缓存表示在缓存期间不需要请求, state code 为 200
Expires: Wed, 22 Oct 2018 08:41:00 GMT
Expires 是 HTTP / 1.0 的产物,表示资源会在 Wed, 22 Oct 201808:41:00 GMT 后过期,需要再次请求。并且 Expires 受限于本地时间,如果修改了本地时间,可能会造成缓存失效
Cache-control: max-age=30
Cache-Control 出现于 HTTP / 1.1 ,优先级⾼于 Expires 。该属性表示资源会在 30 秒后过期,需要再次请求
# 协商缓存
- 如果缓存过期了,我们就可以使⽤协商缓存来解决问题。协商缓存需要请求,如果缓存有效会返回 304
- 协商缓存需要客户端和服务端共同实现,和强缓存⼀样,也有两种实现⽅式
Last-Modified 和 If-Modified-Since
- Last-Modified 表示本地⽂件最后修改⽇期, If-Modified-Since 会将 LastModified 的值发送给服务器,询问服务器在该⽇期后资源是否有更新,有更新的话就会将新的资源发送回来
- 但是如果在本地打开缓存⽂件,就会造成 Last-Modified 被修改,所以在 HTTP /1.1 出现了 ETag
ETag 和 If-None-Match
- ETag 类似于⽂件指纹, If-None-Match 会将当前 ETag 发送给服务器,询问该资源 ETag 是否变动,有变动的话就将新的资源发送回来。并且 ETag 优先级⽐ LastModified ⾼
# 选择合适的缓存策略
对于⼤部分的场景都可以使⽤强缓存配合协商缓存解决,但是在⼀些特殊的地⽅可能需要选择特殊的缓存策略
- 对于某些不需要缓存的资源,可以使⽤ Cache-control: no-store ,表示该资源不需要缓存
- 对于频繁变动的资源,可以使⽤ Cache-Control: no-cache 并配合 ETag 使⽤,表示该资源已被缓存,但是每次都会发送请求询问资源是否更新
- 对于代码⽂件来说,通常使⽤ Cache-Control: max-age=31536000 并配合策略缓存使⽤,然后对⽂件进⾏指纹处理,⼀旦⽂件名变动就会⽴刻下载新的⽂件
# 3.使⽤ HTTP / 2.0
- 因为浏览器会有并发请求限制,在 HTTP / 1.1 时代,每个请求都需要建⽴和断开,消耗了好⼏个 RTT 时间,并且由于 TCP 慢启动的原因,加载体积⼤的⽂件会需要更多的时间
- 在 HTTP / 2.0 中引⼊了多路复⽤,能够让多个请求使⽤同⼀个 TCP 链接,极⼤的加快了⽹⻚的加载速度。并且还⽀持 Header 压缩,进⼀步的减少了请求的数据⼤⼩
# 4.预加载
- 在开发中,可能会遇到这样的情况。有些资源不需要⻢上⽤到,但是希望尽早获取,这时候就可以使⽤预加载
- 预加载其实是声明式的 fetch ,强制浏览器请求资源,并且不会阻塞 onload 事件,可以使⽤以下代码开启预加载
<link rel="preload" href="http://blog.shenzjd.com" />
预加载可以⼀定程度上降低⾸屏的加载时间,因为可以将⼀些不影响⾸屏但重要的⽂件延后加载,唯⼀缺点就是兼容性不好
# 5.预渲染
- 可以通过预渲染将下载的⽂件预先在后台渲染,可以使⽤以下代码开启预渲染
<link rel="prerender" href="http://blog.shenzjd.com" />
预渲染虽然可以提⾼⻚⾯的加载速度,但是要确保该⻚⾯百分百会被⽤户在之后打开,否 则就⽩⽩浪费资源去渲染
# 6.懒执行和懒加载
# 懒执行
懒执行就是将某些逻辑延迟到使用时在计算。该技术可以用于首屏优化,对于某些耗时逻辑并不需要在首屏时就使用的,就可以使用懒执行。懒执行需要唤醒,一般可以通过定时器或者事件的调用来唤醒。
# 懒加载
懒加载就是将不关键的资源延后加载
懒加载的原理就是只加载自定义区域(通常是可视化区域,但也可以是即将进入可视区域)内需要加载的东西。对于图片来说,先设置图片标签的 src 属性为一张占位图,将真实的图片资源放入一个自定义属性中,当进入自定义区域时,就将自定义属性替换为 src 属性,这样图片就会去下载资源,实现了图片的懒加载
懒加载不仅可以用于图片,也可以使用在别的资源上。比如进入可视区域才可以播放视频等
# 7.文件优化
# 图片优化
- 减少像素点
- 减少每个像素点能够显示的颜色
# 图片加载优化
- 不用图片。很多时候使用到的很多修饰类图片完全可以用 css 代替
- 对于移动端来说,屏幕宽度有限,完全没有必要去加载原图浪费带宽。一般图片都用 CDN 加载,可以计算出适配屏幕的宽度,然后去请求相应裁剪好的图片
- 小图使用 base64 格式
- 将多个图标整合到一张图片中,俗称的雪碧图,图片精灵
- 选择正确的图片格式:
- 对于能够显式 Webp 格式的浏览器尽量使用 WebP 格式。因为 WebP 格式具有更好的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量,缺点是兼容性不好
- 小图使用 png,其实对于大部分图标这类图片,完全可以用 svg 代替
- 照片使用 JPEG
其他文件优化
- css 放在 head 中
- 服务端开启文件压缩功能
- 将 script 标签放在 body 底部,因为 js 文件执行会阻塞渲染。
- 当然也可以把 script 标签放在任意位置然后加上 defer,表示该文件会并行下载,但是会放到 html 解析完成后顺序执行。
- 对于没有任何依赖的 js 文件可以加上 async,表示加载和渲染后续文档元素的过程将和 js 文件的加载与执行并行无序进行。
- 对于需要很多时间计算的代码,执行 js 代码过长会卡住渲染。可以考虑使用 WebWorker。 Webworker 可以让我们另开⼀个线程执⾏脚本⽽不影响渲 染。
CDN
静态资源尽量使⽤ CDN 加载,由于浏览器对于单个域名有并发请求上限,可以考虑使⽤多个 CDN 域名。对于 CDN 加载静态资源需要注意 CDN 域名要与主站不同,否则每次请求都会带上主站的 Cookie
# 8.其他性能优化
# webpack
- 对于 Webpack4 ,打包项⽬使⽤ production 模式,这样会⾃动开启代码压缩
- 使⽤ ES6 模块来开启 tree shaking ,这个技术可以移除没有使⽤的代码
- 优化图⽚,对于⼩图可以使⽤ base64 的⽅式写⼊⽂件中
- 按照路由拆分代码,实现按需加载
- 给打包出来的⽂件名添加哈希,实现浏览器缓存⽂件
# 监控
对于代码运⾏错误,通常的办法是使⽤ window.onerror 拦截报错。该⽅法能拦截到⼤部分的详细报错信息,但是也有例外
- 对于跨域的代码运⾏错误会显示 Script error . 对于这种情况我们需要给 script 标签添加 crossorigin 属性
- 对于某些浏览器可能不会显示调⽤栈信息,这种情况可以通过 arguments.callee.caller 来做栈递归
- 对于异步代码来说,可以使⽤ catch 的⽅式捕获错误。⽐如 Promise 可以直接使⽤ catch 函数, async await 可以使⽤ try catch
- 但是要注意线上运⾏的代码都是压缩过的,需要在打包时⽣成 sourceMap ⽂件便于 debug
- 对于捕获的错误需要上传给服务器,通常可以通过 img 标签的 src 发起⼀个请求
# 4. 安全
# 1.XSS
跨⽹站指令码(英语: Cross-site scripting ,通常简称为: XSS )是⼀种⽹站应⽤程式的安全漏洞攻击,是代码注⼊的⼀种。它允许恶意使⽤者将程式码注⼊到⽹⻚上,其他使⽤者在观看⽹⻚时就会受到影响。这类攻击通常包含了 HTML 以及使⽤者端脚本语⾔
XSS 分为三种:反射型,存储型和 DOM-based
# 1.1 如何攻击
- XSS 通过修改 HTML 节点或者执⾏ JS 代码来攻击⽹站。
- 例如通过 URL 获取某些参数
<!-- http://www.domain.com?name=<script>alert(1)</script> -->
<div>{{name}}</div>
上述 URL 输⼊可能会将 HTML 改为 <div><script>alert(1)</script></div> ,这样⻚⾯中就凭空多了⼀段可执⾏脚本。这种攻击类型是反射型攻击,也可以说是 DOM-based 攻击
# 1.2 如何防御
最普遍的做法是转义输⼊输出的内容,对于引号,尖括号,斜杠进⾏转义
function escape(str) {
str = str.replace(/&/g, "&");
str = str.replace(/</g, "<");
str = str.replace(/>/g, ">");
str = str.replace(/"/g, "&quto;");
str = str.replace(/'/g, "&##39;");
str = str.replace(/`/g, "&##96;");
str = str.replace(/\//g, "&##x2F;");
return str;
}
通过转义可以将攻击代码 <script>alert(1)</script> 变成
// -> <script>alert(1)<&##x2F;script>
escape("<script>alert(1)</script>");
对于显示富⽂本来说,不能通过上⾯的办法来转义所有字符,因为这样会把需要的格式也过滤掉。这种情况通常采⽤⽩名单过滤的办法,当然也可以通过⿊名单过滤,但是考虑到需要过滤的标签和标签属性实在太多,更加推荐使⽤⽩名单的⽅式
var xss = require("xss");
var html = xss('<h1 id="title">XSS Demo</h1><script>alert("xss");</script>'
// -> <h1>XSS Demo</h1><script>alert("xss");</script>
console.log(html);
以上示例使⽤了 js-xss 来实现。可以看到在输出中保留了 h1 标签且过滤了 script 标签
# 2.CSRF
跨站请求伪造(英语: Cross-site request forgery ),也被称为 oneclick attack 或者 session riding ,通常缩写为 CSRF 或者 XSRF ,是⼀种挟制⽤户在当前已登录的 Web 应⽤程序上执⾏⾮本意的操作的攻击⽅法
CSRF 就是利⽤⽤户的登录态发起恶意请求
# 2.1 如何攻击
假设⽹站中有⼀个通过 Get 请求提交⽤户评论的接⼝,那么攻击者就可以在钓 ⻥⽹站中加⼊⼀个图⽚,图⽚的地址就是评论接⼝
<img src="http://www.domain.com/xxx?comment='attack'" />
# 2.2 如何防御
- Get 请求不对数据进⾏修改
- 不让第三⽅⽹站访问到⽤户 Cookie
- 阻⽌第三⽅⽹站请求接⼝
- 请求时附带验证信息,⽐如验证码或者 token
# 3.密码安全
# 3.1 加盐
对于密码存储来说,必然是不能明⽂存储在数据库中的,否则⼀旦数据库泄露,会对⽤户造成很⼤的损失。并且不建议只对密码单纯通过加密算法加密,因为存在彩虹表的关系
- 通常需要对密码加盐,然后进⾏⼏次不同加密算法的加密
// 加盐也就是给原密码添加字符串,增加原密码⻓度
sha256(sha1(md5(salt + password + salt)));
# 3.2 验证码
但是加盐并不能阻⽌别⼈盗取账号,只能确保即使数据库泄露,也不会暴露⽤户的真实密码。⼀旦攻击者得到了⽤户的账号,可以通过暴⼒破解的⽅式破解密码。对于这种情况,通常使⽤验证码增加延时或者限制尝试次数的⽅式。并且⼀旦⽤户输⼊了错误的密码,也不能直接提示⽤户输错密码,⽽应该提示账号或密码错误
# 3.3 前端加密
虽然前端加密对于安全防护来说意义不⼤,但是在遇到中间⼈攻击的情况下,可以避免明⽂密码被第三⽅获取
# 5.小程序
# 5.1 登录
# unionid 和 openid
了解⼩程序登陆之前,我们写了解下⼩程序/公众号登录涉及到两个最关键的⽤户标识:
- OpenId 是⼀个⽤户对于⼀个⼩程序/公众号的标识,开发者可以通过这个标识识别出⽤户。
- UnionId 是⼀个⽤户对于同主体微信⼩程序/公众号/ APP 的标识,开发者需要在微信开放平台下绑定相同账号的主体。开发者可通过 UnionId ,实现多个⼩程序、公众号、甚⾄ APP 之间的数据互通了。
# 关键 Api
- wx.login 官⽅提供的登录能⼒
- wx.checkSession 校验⽤户当前的 session_key 是否有效
- wx.authorize 提前向⽤户发起授权请求
- wx.getUserInfo 获取⽤户基本信息
# 登录流程设计
- 利⽤现有登录体系
直接复⽤现有系统的登录体系,只需要在⼩程序端设计⽤户名,密码/验证码输⼊⻚⾯,便可以简便的实现登录,只需要保持良好的⽤户体验即可
OpenId 是⼀个⼩程序对于⼀个⽤户的标识,利⽤这⼀点我们可以轻松的实现⼀套基于⼩程序的⽤户体系,值得⼀提的是这种⽤户体系对⽤户的打扰最低,可以实现静默登录。具体步骤如下
利⽤ OpenId 创建⽤户体系
- ⼩程序客户端通过 wx.login 获取 code
- 传递 code 向服务端,服务端拿到 code 调⽤微信登录凭证校验接⼝,微信服务器返回 openid 和会话密钥 session_key ,此时开发者服务端便可以利⽤ openid ⽣成⽤户⼊库,再向⼩程序客户端返回⾃定义登录态
- ⼩程序客户端缓存 (通过 storage )⾃定义登录态( token ),后续调⽤接⼝时携带该登录态作为⽤户身份标识即可
如果想实现多个⼩程序,公众号,已有登录系统的数据互通,可以通过获取到⽤户 unionid 的⽅式建⽴⽤户体系。因为 unionid 在同⼀开放平台下的所所有应⽤都是相同的,通过 unionid 建⽴的⽤户体系即可实现全平台数据的互通,更⽅便的接⼊原有的功能,那如何获取 unionid 呢,有以下两种⽅式
- 利⽤ Unionid 创建⽤户体系
- 如果户关注了某个相同主体公众号,或曾经在某个相同主体 App 、公众号上进⾏过微信登录授权,通过 wx.login 可以直接获取 到 unionid
- 结合 wx.getUserInfo 和 <button open-type="getUserInfo"><button/> 这两种⽅式引导⽤户主动授权,主动授权后通过返回的信息和服务端交互 (这⾥有⼀步需要服务端解密数据的过程,很简单,微信提供了示例代码) 即可拿到 unionid 建⽴⽤户体系, 然后由服务端返回登录态,本地记录即可实现登录,附上微信提供的最佳实践
- 调⽤ wx.login 获取 code ,然后从微信后端换取到 session_key ,⽤于解密 getUserInfo 返回的敏感数据
- 使⽤ wx.getSetting 获取⽤户的授权情况
- 如果⽤户已经授权,直接调⽤ API wx.getUserInfo 获取⽤户最新的信息;
- ⽤户未授权,在界⾯中显示⼀个按钮提示⽤户登⼊,当⽤户点击并授权后就获取到⽤户的最新信息
- 获取到⽤户数据后可以进⾏展示或者发送给⾃⼰的后端。
注意事项
- 需要获取 unionid 形式的登录体系,在以前(18 年 4 ⽉之前)是通过以下这种⽅式来实现,但后续微信做了调整(因为⼀进⼊⼩程序,主动弹起各种授权弹窗的这种形式,⽐较容易导致⽤户流失),调整为必须使⽤按钮引导⽤户主动授权的⽅式,这次调整对开发者影响较⼤,开发者需要注意遵守微信的规则,并及时和业务⽅沟通业务形式,不要存在侥幸⼼理,以防造成⼩程序不过审等情况
- wx.login(获取 code) ===> wx.getUserInfo(⽤户授权) ===> 获取 unionid
- 因为⼩程序不存在 cookie 的概念, 登录态必须缓存在本地,因此强烈建议为登录态设置过期时间
- 值得⼀提的是如果需要⽀持⻛控安全校验,多平台登录等功能,可能需要加⼊⼀些公共参数,例如 platform , channel , deviceParam 等参数。在和服务端确定⽅案时,作为前端同学应该及时提出这些合理的建议,设计合理的系统。
- openid , unionid 不要在接⼝中明⽂传输,这是⼀种危险的⾏为,同时也很不专业
# 5.2 图片导出
这是⼀种常⻅的引流⽅式,⼀般同时会在图⽚中附加⼀个⼩程序⼆维码
# 基本原理
- 借助 canvas 元素,将需要导出的样式⾸先在 canvas 画布上绘制出来 ( api 基本和 h5 保持⼀致,但有轻微差异,使⽤时注意即可
- 借助微信提供的 canvasToTempFilePath 导出图⽚,最后再使⽤ saveImageToPhotosAlbum (需要授权)保存图⽚到本地
# 如何优雅的实现
- 绘制出需要的样式这⼀步是省略不掉的。但是我们可以封装⼀个绘制库,包含常⻅图形的绘制,例如矩形,圆⻆矩形,圆, 扇形, 三⻆形, ⽂字,图⽚减少绘制代码,只需要提炼出样式信息,便可以轻松的绘制,最后导出图⽚存⼊相册。笔者觉得以下这种⽅式绘制更为优雅清晰⼀些,其实也可以使⽤加⼊⼀个 type 参数来指定绘制类型,传⼊的⼀个是样式数组,实现绘制
- 结合上⼀步的实现,如果对于同⼀类型的卡⽚有多次导出需求的场景,也可以使⽤⾃定义组件的⽅式,封装同⼀类型的卡⽚为⼀个通⽤组件,在需要导出图⽚功能的地⽅,引⼊该组件即可
class CanvasKit {
constructor() {
}
drawImg(option = {}) {
...
return this
}
drawRect(option = {}) {
return this
}
drawText(option = {}) {
...
return this
}
static exportImg(option = {}) {
...
}
}
let drawer = new CanvasKit('canvasId').drawImg(styleObj1).drawText(styleOb
drawer.exportImg()
注意事项
- ⼩程序中⽆法绘制⽹络图⽚到 canvas 上,需要通过 downLoadFile 先下载图⽚到本地临时⽂件才可以绘制
- 通常需要绘制⼆维码到导出的图⽚上,有⼀种⽅式导出⼆维码时,需要携带的参数必须做编码,⽽且有具体的⻓度( 32 可⻅字符)限制,可以借助服务端⽣成 短链接 的⽅式来解决
# 5.3 数据统计
数据统计作为⽬前⼀种常⽤的分析⽤户⾏为的⽅式,⼩程序端也是必不可少的。⼩程序采取的曝光,点击数据埋点其实和 h5 原理是⼀样的。但是埋点作为⼀个和业务逻辑不相关的需求,我们如果在每⼀个点击事件,每⼀个⽣命周期加⼊各种埋点代码,则会⼲扰正常的业务逻辑,和使代码变的臃肿,笔者提供以下⼏种思路来解决数据埋点
# 设计⼀个埋点 sdk
⼩程序的代码结构是,每⼀个 Page 中都有⼀个 Page ⽅法,接受⼀个包含⽣命周期函数,数据的 业务逻辑对象 包装这层数据,借助⼩程序的底层逻辑实现⻚⾯的业务逻辑。通过这个我们可以想到思路,对 Page 进⾏⼀次包装,篡改它的⽣命周期和点击事件,混⼊埋点代码,不⼲扰业务逻辑,只要做⼀些简单的配置即可埋点,简单的代码实现如下
// 代码仅供理解思路
page = function(params) {
let keys = params.keys()
keys.forEach(v => {
if (v === 'onLoad') {
params[v] = function(options) {
stat() //曝光埋点代码
params[v].call(this, options)
}
}
else if (v.includes('click')) {
params[v] = funciton(event) {
let data = event.dataset.config
stat(data) // 点击埋点
param[v].call(this)
}
}
})
}
这种思路不光适⽤于埋点,也可以⽤来作全局异常处理,请求的统⼀处理等场景。
# 分析接⼝
对于特殊的⼀些业务,我们可以采取 接⼝埋点,什么叫接⼝埋点呢?很多情况下,我们有的 api 并不是多处调⽤的,只会在某⼀个特定的⻚⾯调⽤,通过这个思路我们可以分析出,该接⼝被请求,则这个⾏为被触发了,则完全可以通过服务端⽇志得出埋点数据,但是这种⽅式局限性较⼤,⽽且属于分析结果得出过程,可能存在误差,但可以作为⼀种思路了解⼀下。
# 微信⾃定义数据分析
微信本身提供的数据分析能⼒,微信本身提供了常规分析和⾃定义分析两种数据分析⽅式,在⼩程序后台配置即可。借助⼩程序数据助⼿这款⼩程序可以很⽅便的查看
# 4.工程化
⽬前的前端开发过程,⼯程化是必不可少的⼀环,那⼩程序⼯程化都需要做些什么呢,先看下⽬前⼩程序开发当中存在哪些问题需要解决:
- 不⽀持 css 预编译器,作为⼀种主流的 css 解决⽅案,不论是 less , sass , stylus 都可以提升 css 效率
- 不⽀持引⼊ npm 包 (这⼀条,从微信公开课中听闻,微信准备⽀持)
- 不⽀持 ES7 等后续的 js 特性,好⽤的 async await 等特性都⽆法使⽤
- 不⽀持引⼊外部字体⽂件,只⽀持 base64
- 没有 eslint 等代码检查⼯具
# ⽅案选型
对于⽬前常⽤的⼯程化⽅案, webpack , rollup , parcel 等来看,都常⽤与单⻚应⽤的打包和处理,⽽⼩程序天⽣是 “多⻚应⽤” 并且存在⼀些特定的配置。根据要解决的问题来看,⽆⾮是⽂件的编译,修改,拷⻉这些处理,对于这些需求,我们想到基于流的 gulp ⾮常的适合处理,并且相对于 webpack 配置多⻚应⽤更加简单。所以⼩程序⼯程化⽅案推荐使⽤ gulp
# 具体开发思路
通过 gulp 的 task 实现:
- 实时编译 less ⽂件⾄相应⽬录
- 引⼊⽀持 async , await 的运⾏时⽂件
- 编译字体⽂件为 base64 并⽣成相应 css ⽂件,⽅便使⽤
- 依赖分析哪些地⽅引⽤了 npm 包,将 npm 包打成⼀个⽂件,拷⻉⾄相应⽬录
- 检查代码规范
# 5.小程序架构
微信⼩程序的框架包含两部分 View 视图层、 App Service 逻辑层。View 层⽤来渲染⻚⾯结构, AppService 层⽤来逻辑处理、数据请求、接⼝调⽤ 它们在两个线程⾥运⾏。 视图层和逻辑层通过系统层的 JSBridage 进⾏通信,逻辑层把数据变化通知到视图层,触发视图层⻚⾯更新,视图层把触发的事件通知到逻辑层进⾏业务 处理
- 视图层使⽤ WebView 渲染, iOS 中使⽤⾃带 WKWebView ,在 Android 使⽤腾讯的 x5 内核(基于 Blink )运⾏
- 逻辑层使⽤在 iOS 中使⽤⾃带的 JSCore 运⾏,在 Android 中使⽤腾讯的 x5 内核(基于 Blink )运⾏
- 开发⼯具使⽤ nw.js 同时提供了视图层和逻辑层的运⾏环境
# 6.WXML && WXSS
# WXML
- ⽀持数据绑定
- ⽀持逻辑算术、运算
- ⽀持模板、引⽤
- ⽀持添加事件( bindtap )
- Wxml 编译器: Wcc 把 Wxml ⽂件 转为 JS
- 执⾏⽅式: Wcc index.wxml
- 使⽤ Virtual DOM ,进⾏局部更新
# WXSS
- wxss 编译器: wcsc 把 wxss ⽂件转化为 js
- 执⾏⽅式: wcsc index.wxss
# rpx
rpx(responsive pixel ): 可以根据屏幕宽度进⾏⾃适应。规定屏幕宽为 750rpx 。公式:
const dsWidth = 750;
export const screenHeightOfRpx = function () {
return (750 / env.screenWidth) * env.screenHeight;
};
export const rpxToPx = function (rpx) {
return (env.screenWidth / 750) * rpx;
};
export const pxToRpx = function (px) {
return (750 / env.screenWidth) * px;
};
# 样式导入
使⽤ @import 语句可以导⼊外联样式表, @import 后跟需要导⼊的外联样式表的相对路径,⽤ ; 表示语句结束
# 内联样式
静态的样式统⼀写到 class 中。 style 接收动态的样式,在运⾏时会进⾏解析,请尽量避免将静态的样式写进 style 中,以免影响渲染速度
# 全局样式与局部样式
定义在 app.wxss 中的样式为全局样式,作⽤于每⼀个⻚⾯。在 page 的 wxss ⽂件中定义的样式为局部样式,只作⽤在对应的⻚⾯,并会覆盖 app.wxss 中相同的选择器
# 7.小程序的问题
- ⼩程序仍然使⽤ WebView 渲染,并⾮原⽣渲染。(部分原⽣)
- 服务端接⼝返回的头⽆法执⾏,⽐如: Set-Cookie 。
- 依赖浏览器环境的 JS 库不能使⽤。
- 不能使⽤ npm ,但是可以⾃搭构建⼯具或者使⽤ mpvue 。(未来官⽅有计划⽀持)
- 不能使⽤ ES7 ,可以⾃⼰⽤ babel+webpack ⾃搭或者使⽤ mpvue 。
- 不⽀持使⽤⾃⼰的字体(未来官⽅计划⽀持)。
- 可以⽤ base64 的⽅式来使⽤ iconfont 。
- ⼩程序不能发朋友圈(可以通过保存图⽚到本地,发图⽚到朋友前。⼆维码可以使⽤ B 接⼝)。
- 获取⼆维码/⼩程序接⼝的限制
- 程序推送只能使⽤“服务通知” ⽽且需要⽤户主动触发提交 formId , formId 只有 7 天有效期。(现在的做法是在每个⻚⾯都放⼊ form 并且隐藏以此获取更多的 formId 。后端使⽤原则为:优先使⽤有效期最短的)
- ⼩程序⼤⼩限制 2M,分包总计不超过 8M
- 转发(分享)⼩程序不能拿到成功结果,原来可以。链接(⼩游戏造的孽)
- 拿到相同的 unionId 必须绑在同⼀个开放平台下。开放平台绑定限制:
- 50 个移动应⽤
- 10 个⽹站
- 50 个同主体公众号
- 5 个不同主体公众号
- 50 个同主体⼩程序
- 5 个不同主体⼩程序
- 公众号关联⼩程序
- 所有公众号都可以关联⼩程序。
- ⼀个公众号可关联 10 个同主体的⼩程序,3 个不同主体的⼩程序。
- ⼀个⼩程序可关联 500 个公众号。
- 公众号⼀个⽉可新增关联⼩程序 13 次,⼩程序⼀个⽉可新增关联 500 次
- ⼀个公众号关联的 10 个同主体⼩程序和 3 个⾮同主体⼩程序可以互相跳转
- 品牌搜索不⽀持⾦融、医疗
- ⼩程序授权需要⽤户主动点击
- ⼩程序不提供测试 access_token
- 安卓系统下,⼩程序授权获取⽤户信息之后,删除⼩程序再重新获取,并重新授权,得到旧签名,导致第⼀次授权失败
- 开发者⼯具上,授权获取⽤户信息之后,如果清缓存选择全部清除,则即使使⽤了 wx.checkSession ,并且在 session_key 有效期内,授权获取⽤户信息也会得到新的 session_key
# 8.授权获取用户信息流程
- session_key 有有效期,有效期并没有被告知开发者,只知道⽤户越频繁使⽤⼩程序,session_key 有效期越⻓
- 在调⽤ wx.login 时会直接更新 session_key ,导致旧 session_key 失效
- ⼩程序内先调⽤ wx.checkSession 检查登录态,并保证没有过期的 session_key 不会被更新,再调⽤ wx.login 获取 code 。接着⽤户授权⼩程序获取⽤户信息,⼩程序拿到加密后的⽤户数据,把加密数据和 code 传给后端服务。后端通过 code 拿到 session_key 并解密数据,将解密后的⽤户信息返回给⼩程序
# ⾯试题:先授权获取⽤户信息再 login 会发⽣什么?
- ⽤户授权时,开放平台使⽤旧的 session_key 对⽤户信息进⾏加密。调⽤ wx.login 重新登录,会刷新 session_key ,这时后端服务从开放平台获取到新 session_key ,但是⽆法对⽼ session_key 加密过的数据解密,⽤户信息获取失败
- 在⽤户信息授权之前先调⽤ wx.checkSession 呢? wx.checkSession 检查登录态,并且保证 wx.login 不会刷新 session_key ,从⽽让后端服务正确解密数据。但是这⾥存在⼀个问题,如果⼩程序较⻓时间不⽤导致 session_key 过期,则 wx.login 必定会重新⽣成 session_key ,从⽽再⼀次导致⽤户信息解密失败
# 9.性能优化
我们知道 view 部分是运⾏在 webview 上的,所以前端领域的⼤多数优化⽅式都有⽤
# 加载优化
代码包的⼤⼩是最直接影响⼩程序加载启动速度的因素。代码包越⼤不仅下载速度时间⻓,业务代码注⼊时间也会变⻓。所以最好的优化⽅式就是减少代码 包的⼤⼩
⼩程序加载的三个阶段的表示
# 优化方式
- 代码压缩
- 及时清理⽆⽤代码和资源⽂件。
- 减少代码包中的图⽚等资源⽂件的⼤⼩和数量。
- 分包加载
# ⾸屏加载的体验优化建议
- 提前请求: 异步数据请求不需要等待⻚⾯渲染完成。
- 利⽤缓存: 利⽤ storage API 对异步请求数据进⾏缓存,⼆次启动时先利⽤缓存数据渲染⻚⾯,在进⾏后台更新。
- 避免⽩屏:先展示⻚⾯⻣架⻚和基础内容。
- 及时反馈:即时地对需要⽤户等待的交互操作给出反馈,避免⽤户以为⼩程序⽆响应
# 使⽤分包加载优化
- 在构建⼩程序分包项⽬时,构建会输出⼀个或多个功能的分包,其中每个分包⼩程序必定含有⼀个主包,所谓的主包,即放置默认启动⻚⾯/ TabBar ⻚⾯,以及⼀些所有分包都需⽤到公共资源/ JS 脚本,⽽分包则是根据开发者的配置进⾏划分
- 在⼩程序启动时,默认会下载主包并启动主包内⻚⾯,如果⽤户需要打开分包内某个⻚⾯,客户端会把对应分包下载下来,下载完成后再进⾏展示。
# 优点
- 对开发者⽽⾔,能使⼩程序有更⼤的代码体积,承载更多的功能与服务
- 对⽤户⽽⾔,可以更快地打开⼩程序,同时在不影响启动速度前提下使⽤更多功能
# 限制
- 整个⼩程序所有分包⼤⼩不超过 8M
- 单个分包/主包⼤⼩不能超过 2M
- 原⽣分包加载的配置 假设⽀持分包的⼩程序⽬录结构如下
├── app.js ├── app.json ├── app.wxss ├── packageA │ └── pages │ ├── cat │ └── dog ├── packageB │ └── pages │ ├── apple │ └── banana ├── pages │ ├── index │ └── logs └── utils
开发者通过在 app.json subPackages 字段声明项⽬分包结构
{
"pages": ["pages/index", "pages/logs"],
"subPackages": [
{
"root": "packageA",
"pages": ["pages/cat", "pages/dog"]
},
{
"root": "packageB",
"pages": ["pages/apple", "pages/banana"]
}
]
}
# 分包原则
- 声明 subPackages 后,将按 subPackages 配置路径进⾏打包, subPackages 配置路径外的⽬录将被打包到 app (主包) 中
- app (主包)也可以有⾃⼰的 pages (即最外层的 pages 字段
- subPackage 的根⽬录不能是另外⼀个 subPackage 内的⼦⽬录
- ⾸⻚的 TAB ⻚⾯必须在 app (主包)内
# 引用原则
- ``packageA ⽆法 require packageB JS ⽂件,但可以 require app 、⾃⼰ package 内的JS` ⽂件
- ``packageA ⽆法 import packageB 的 template ,但可以 require app 、⾃⼰ package内的 template`
- ``packageA ⽆法使⽤ packageB 的资源,但可以使⽤ app 、⾃⼰ package` 内的资源
官⽅即将推出 分包预加载
独⽴分包
# 渲染性能优化
- 每次 setData 的调⽤都是⼀次进程间通信过程,通信开销与 setData 的数据量正相关。
- setData 会引发视图层⻚⾯内容的更新,这⼀耗时操作⼀定时间中会阻塞⽤户交互。
- setData 是⼩程序开发使⽤最频繁,也是最容易引发性能问题的
# 避免不当使⽤ setData
- 使⽤ data 在⽅法间共享数据,可能增加 setData 传输的数据量。。 data 应仅包括与⻚⾯渲染相关的数据。
- 使⽤ setData 传输⼤量数据,通讯耗时与数据正相关,⻚⾯更新延迟可能造成⻚⾯更新开销增加。仅传输⻚⾯中发⽣变化的数据,使⽤ setData 的特殊 key 实现局部更新。
- 短时间内频繁调⽤ setData ,操作卡顿,交互延迟,阻塞通信,⻚⾯渲染延迟。避免不必要的 setData ,对连续的 setData 调⽤进⾏合并。
- 在后台⻚⾯进⾏ setData ,抢占前台⻚⾯的渲染资源。⻚⾯切⼊后台后的 setData 调⽤,延迟到⻚⾯重新展示时执⾏。
# 避免不当使⽤ onPageScroll
- 只在有必要的时候监听 pageScroll 事件。不监听,则不会派发。
- 避免在 onPageScroll 中执⾏复杂逻辑
- 避免在 onPageScroll 中频繁调⽤ setData
- 避免滑动时频繁查询节点信息( SelectQuery )⽤以判断是否显示,部分场景建议使⽤节点布局橡胶状态监听( inersectionObserver )替代
# 使⽤⾃定义组件
在需要频繁更新的场景下,⾃定义组件的更新只在组件内部进⾏,不受⻚⾯其他部分内容复杂性影响
# 10.wepy vs mpvue
# 数据流管理
相⽐传统的⼩程序框架,这个⼀直是我们作为资深开发者⽐较期望去解决的,在 Web 开发中,随着 Flux 、 Redu x、 Vuex 等多个数据流⼯具出现, 我们也期望在业务复杂的⼩程序中使⽤
- WePY 默认⽀持 Redux ,在脚⼿架⽣成项⽬的时候可以内置
- Mpvue 作为 Vue 的移植版本,当然⽀持 Vuex ,同样在脚⼿架⽣成项⽬的时候可以内置
# 组件化
- WePY 类似 Vue 实现了单⽂件组件,最⼤的差别是⽂件后缀 .wpy ,只是写法上会有差异
- Mpvue 作为 Vue 的移植版本,⽀持单⽂件组件, template 、 script 和 style 都在⼀个 .vue ⽂件中,和 vue 的写法类似,所以对 Vue 开发熟悉的同学会⽐较适应
# 工程化
所有的⼩程序开发依赖官⽅提供的开发者⼯具。开发者⼯具简单直观,对调试⼩程序很有帮助,现在也⽀持腾讯云(⽬前我们还没有使⽤,但是对新的⼀些 开发者还是有帮助的),可以申请测试报告查看⼩程序在真实的移动设备上运⾏性能和运⾏效果,但是它本身没有类似前端⼯程化中的概念和⼯具
- wepy 内置了构建,通过 wepy init 命令初始化项⽬,⼤致流程如下:
- wepy-cli 会判断模版是在远程仓库还是在本地,如果在本地则会⽴即跳到第 3 步,反之继续进⾏。
- 会从远程仓库下载模版,并保存到本地。
- 询问开发者 Project name 等问题,依据开发者的回答,创建项⽬
- mpvue 沿⽤了 vue 中推崇的 webpack 作为构建⼯具,但同时提供了⼀些⾃⼰的插件以及配置⽂件的⼀些修改,⽐如
- 不再需要 html-webpack-plugin
- 基于 webpack-dev-middleware 修改成 webpack-dev-middleware-hard-disk
- 最⼤的变化是基于 webpack-loader 修改成 mpvue-loader
- 但是配置⽅式还是类似,分环境配置⽂件,最终都会编译成⼩程序⽀持的⽬录结构和⽂件后缀
# 11. mpvue
Vue.js ⼩程序版, fork ⾃ vuejs/vue@2.4.1 ,保留了 vue runtime 能⼒,添加了⼩程序平台的⽀持。 mpvue 是⼀个使⽤ Vue.js 开发⼩程序的前端框架。框架基于 Vue.js 核⼼, mpvue 修改了 Vue.js 的 runtime 和 compiler 实现,使其可以运⾏在⼩程序环境中,从⽽为⼩程序开发引⼊了整套 Vue.js 开发体验
# 框架原理
两个⼤⽅向
- 通过 mpvue 提供 mp 的 runtime 适配⼩程序
- 通过 mpvue-loader 产出微信⼩程序所需要的⽂件结构和模块内容
七个具体问题
- 要了解 mpvue 原理必然要了解 Vue 原理,这是⼤前提
现在假设您对 Vue 原理有个⼤概的了解
- 由于 Vue 使⽤了 Virtual DOM ,所以 Virtual DOM 可以在任何⽀持 JavaScript 语⾔的平台上操作,譬如说⽬前 Vue ⽀持浏览器平台或 weex ,也可以是 mp (⼩程序)。那么最后 Virtual DOM 如何映射到真实的 DOM 节点上呢? vue 为平台做了⼀层适配层,浏览器平台⻅ runtime/node-ops.js 、 weex 平台⻅ runtime/nodeops.js ,⼩程序⻅ runtime/node-ops.js 。不同平台之间通过适配层对外提供相同的接⼝, Virtual DOM 进⾏操作 Real DOM 节点的时候,只需要调⽤这些适配层的接⼝即可,⽽内部实现则不需要关⼼,它会根据平台的改变⽽改变
- 所以思路肯定是往增加⼀个 mp 平台的 runtime ⽅向⾛。但问题是⼩程序不能操作 DOM ,所以 mp 下的 node-ops.js ⾥⾯的实现都是直接 return obj
- 新 Virtual DOM 和旧 Virtual DOM 之间需要做⼀个 patch ,找出 diff 。 patch 完了之后的 diff 怎么更新视图,也就是如何给这些 DOM 加⼊ attr 、 class 、style 等 DOM 属性呢? Vue 中有 nextTick 的概念⽤以更新视图, mpvue 这块对于⼩程序的 setData 应该怎么处理呢?
- 另外个问题在于⼩程序的 Virtual DOM 怎么⽣成?也就是怎么将 template 编译成 render function 。这当中还涉及到运⾏时-编译器-vs-只包含运⾏时,显然如果要提⾼性能、减少包⼤⼩、输出 wxml 、 mpvue 也要提供预编译的能⼒。因为要预输出 wxml 且没法动态改变 DOM ,所以动态组件,⾃定义 render ,和 <script type="text/xtemplate"> 字符串模版等都不⽀持
另外还有⼀些其他问题,最后总结⼀下
- 1.如何预编译⽣成 render function
- 2.如何预编译⽣成 wxml , wxss , wxs
- 3.如何 p atch 出 diff
- 4.如何更新视图
- 5.如何建⽴⼩程序事件代理机制,在事件代理函数中触发与之对应的 vue 组件事件响应
- 6.如何建⽴ vue 实例与⼩程序 Page 实例关联
- 7.如何建⽴⼩程序和 vue ⽣命周期映射关系,能在⼩程序⽣命周期中触发 vue ⽣命周期
platform/mp 的⽬录结构
├── compiler //解决问题1,mpvue-template-compiler源码部分
├── runtime //解决问题3 4 5 6 7
├── util //⼯具⽅法
├── entry-compiler.js //mpvue-template-compiler的⼊⼝。package.json相关命令会⾃
├── entry-runtime.js //对外提供Vue对象,当然是mpvue
└── join-code-in-build.js //编译出SDK时的修复
# mpvue-loader
mpvue-loader 是 vue-loader 的⼀个扩展延伸版,类似于超集的关系,除了 vue-loader 本身所具备的能⼒之外,它还会利⽤ mpvue-templatecompiler ⽣成 render function
# entry
- 它会从 webpack 的配置中的 entry 开始,分析依赖模块,并分别打包。在 entry 中 app 属性及其内容会被打包为微信⼩程序所需要的 app.js/app.json/app.wxss ,其余的会⽣成对应的⻚⾯ page.js / page.json / page.wxml / page.wxss ,如示例的 entry 将会⽣成如 下这些⽂件,⽂件内容下⽂慢慢讲来:
// webpack.config.js
{
// ...
entry: {
app: resolve('./src/main.js'), // app 字段被识别为 app
index: resolve('./src/pages/index/main.js'), // 其余字段被识别为 pag
'news/home': resolve('./src/pages/news/home/index.js')
}
}
// 产出⽂件的结构
.
├── app.js
├── app.json
├──· app.wxss
├── components
│ ├── card$74bfae61.wxml
│ ├── index$023eef02.wxml
│ └── news$0699930b.wxml
├── news
│ ├── home.js
│ ├── home.wxml
│ └── home.wxss
├── pages
│ └── index
│ ├── index.js
│ ├── index.wxml
│ └── index.wxss
└── static
├── css
│ ├── app.wxss
│ ├── index.wxss
│ └── news
│ └── home.wxss
└── js
├── app.js
├── index.js
├── manifest.js
├── news
└── home.js
└── vendor.js
wxml 每⼀个 .vue 的组件都会被⽣成为⼀个 wxml 规范的 template ,然后通过 wxml 规范的 import 语法来达到⼀个复⽤,同时组件如果涉及到 props 的 data 数据,我们也会做相应的处理,举个实际的例⼦:
<template>
<div class="my-component" @click="test">
<h1>{{ msg }}</h1>
<other-component :msg="msg"></other-component>
</div>
</template>
<script>
import otherComponent from "./otherComponent.vue";
export default {
components: { otherComponent },
data() {
return { msg: "Hello Vue.js!" };
},
methods: {
test() {},
},
};
</script>
这样⼀个 Vue 的组件的模版部分会⽣成相应的 wxml
<import src="components/other-component$hash.wxml" />
<template name="component$hash">
<view class="my-component" bindtap="handleProxy">
<view class="_h1">{{msg}}</view>
<template is="other-component$hash" wx:if="{{ $c[0] }}" data="{{ ..
</view>
</template>
可能已经注意到了 other-component(:msg="msg") 被转化成了 。 mpvue 在运⾏时会从根组件开始把所有的组件实例数据合并成⼀个树形的数据,然后 通过 setData 到 appData , $c 是 $children 的缩写。⾄于那个 0 则是我们的 compiler 处理过后的⼀个标记,会为每⼀个⼦组件打⼀个特定的 不重复的标记。 树形数据结构如下
// 这⼉数据结构是⼀个数组,index 是动态的
{
$child: {
'0'{
// ... root data
$child: {
'0': {
// ... data
msg: 'Hello Vue.js!',
$child: {
// ...data
}
}
}
}
}
}
# wxss
这个部分的处理同 web 的处理差异不⼤,唯⼀不同在于通过配置⽣成.css 为 .wxss ,其中的对于 css 的若⼲处理,在 postcss-mpvuewxss 和 px2rpx-loader 这两部分的⽂档中⼜详细的介绍
- 推荐和⼩程序⼀样,将 app.json/page.json 放到⻚⾯⼊⼝处,使⽤ copy-webpackplugin copy 到对应的⽣成位置。
这部分内容来源于 app 和 page 的 entry ⽂件,通常习惯是 main.js ,你需要在你的⼊⼝⽂件中 export default { config: {} } ,这才能被我们的 loader 识别为这是⼀个配置,需要写成 json ⽂件
import Vue from "vue";
import App from "./app";
const vueApp = new Vue(App);
vueApp.$mount();
// 这个是我们约定的额外的配置
export default {
// 这个字段下的数据会被填充到 app.json / page.json
config: {
pages: ["static/calendar/calendar", "^pages/list/list"], // Will be
window: {
backgroundTextStyle: "light",
navigationBarBackgroundColor: "##455A73",
navigationBarTitleText: "美团汽⻋票",
navigationBarTextStyle: "##fff",
},
},
};
# 6.React
# 6.1 React 中 keys 的作⽤是什么?
Keys 是 React ⽤于追踪哪些列表中元素被修改、被添加或者被移除的辅助标识
- 在开发过程中,我们需要保证某个元素的 key 在其同级元素中具有唯⼀性。在 ReactDiff 算法中 React 会借助元素的 Key 值来判断该元素是新近创建的还是被移动⽽来的元素,从⽽减少不必要的元素重渲染。此外,React 还需要借助 Key 值来判断元素与本地状态的关联关系,因此我们绝不可忽视转换函数中 Key 的重要性
# 6.2 传⼊ setState 函数的第⼆个参数的作⽤是什么?
该函数会在 setState 函数调⽤完成并且组件开始重渲染的时候被调⽤,我们可以⽤该函数来监听渲染是否完成:
this.setState({ username: "tylermcginnis33" }, () =>
console.log("setState has finished and the component has re-rendere")
);
this.setState((prevState, props) => {
return {
streak: prevState.streak + props.count,
};
});
# 6.3 React 中 refs 的作⽤是什么
- Refs 是 React 提供给我们的安全访问 DOM 元素或者某个组件实例的句柄
- 可以为元素添加 ref 属性然后在回调函数中接受该元素在 DOM 树中的句柄,该值会作为回调函数的第⼀个参数返回
# 6.4 在⽣命周期中的哪⼀步你应该发起 AJAX 请求
我们应当将 AJAX 请求放到 componentDidMount 函数中执⾏,主要原因有下
- React 下⼀代调和算法 Fiber 会通过开始或停⽌渲染的⽅式优化应⽤性能,其会影响到 componentWillMount 的触发次数。对于 componentWillMount 这个⽣命周期函数的调⽤次数会变得不确定, React 可能会多次频繁调⽤ componentWillMount 。如果我们将 AJAX 请求放到 componentWillMount 函数中,那么显⽽易⻅其会被触发多次,⾃然也就不是好的选择。
- 如果我们将 AJAX 请求放置在⽣命周期的其他函数中,我们并不能保证请求仅在组件挂载完毕后才会要求响应。如果我们的数据请求在组件挂载之前就完成,并且调⽤了 setState 函数将数据添加到组件状态中,对于未挂载的组件则会报错。⽽在 componentDidMount 函数中进⾏ AJAX 请求则能有效避免这个问题
# 6.5 shouldComponentUpdate 的作⽤
shouldComponentUpdate 允许我们⼿动地判断是否要进⾏组件更新,根据组件的应⽤场景设置函数的合理返回值能够帮我们避免不必要的更新
# 6.6 如何告诉 React 它应该编译⽣产环境版
通常情况下我们会使⽤ Webpack 的 DefinePlugin ⽅法来将 NODE_ENV 变量值设置为 production 。编译版本中 React 会忽略 propType 验证以 及其他的告警信息,同时还会降低代码库的⼤⼩, React 使⽤了 Uglify 插件来移除⽣产环境下不必要的注释等信息
# 6.7 概述下 React 中的事件处理逻辑
为了解决跨浏览器兼容性问题, React 会将浏览器原⽣事件( Browser Native Event )封装为合成事件( SyntheticEvent )传⼊设置的事件处理器中。这⾥的合成事件提供了与原⽣事件相同的接⼝,不过它们屏蔽了底层浏览器的细节差异,保证了⾏为的⼀致性。另外有意思的是, React 并没有直接将事件附着到⼦元素上,⽽是以单⼀事件监听器的⽅式将所有的事件发送到顶层进⾏处理。这样 React 在更新 DOM 的时候就不需要考虑如何去处理附着在 DOM 上的事件监听器,最终达到优化性能的⽬的
# 6.8 createElement 与 cloneElement 的区别是什么
createElement 函数是 JSX 编译之后使⽤的创建 React Element 的函数,⽽ cloneElement 则是⽤于复制某个元素并传⼊新的 Props
# 6.9 redux 中间件
中间件提供第三⽅插件的模式,⾃定义拦截 action -> reducer 的过程。变为 action -> middlewares -> reducer 。这种机制可以让我们改变数据流,实现如异步 action , action 过滤,⽇志输出,异常报告等功能
- redux-logger :提供⽇志输出
- redux-thunk :处理异步操作
- redux-promise :处理异步操作, actionCreator 的返回值是 promise
# 6.10 redux 有什么缺点
- ⼀个组件所需要的数据,必须由⽗组件传过来,⽽不能像 flux 中直接从 store 取。
- 当⼀个组件相关数据更新时,即使⽗组件不需要⽤到这个组件,⽗组件还是会重新 render ,可能会有效率影响,或者需要写复杂的 shouldComponentUpdate 进⾏判断。
# 6.11 react 组件的划分业务组件技术组件?
- 根据组件的职责通常把组件分为 UI 组件和容器组件。
- UI 组件负责 UI 的呈现,容器组件负责管理数据和逻辑。
- 两者通过 React-Redux 提供 connect ⽅法联系起来
# 6.12 react ⽣命周期函数
# 初始化阶段
- getDefaultProps :获取实例的默认属性
- getInitialState :获取每个实例的初始化状态
- componentWillMount :组件即将被装载、渲染到⻚⾯上
- render :组件在这⾥⽣成虚拟的 DOM 节点
- omponentDidMount :组件真正在被装载之后
# 运⾏中状态
- componentWillReceiveProps :组件将要接收到属性的时候调⽤
- shouldComponentUpdate :组件接受到新属性或者新状态的时候(可以返回 false,接收数据后不更新,阻⽌ render 调⽤,后⾯的函数不会被继续执⾏了)
- componentWillUpdate :组件即将更新不能修改属性和状态
- render :组件重新描绘
- componentDidUpdate :组件已经更新
# 销毁阶段
- componentWillUnmount :组件即将销毁
# 6.13 react 性能优化是哪个周期函数
shouldComponentUpdate 这个⽅法⽤来判断是否需要调⽤ render ⽅法重新描绘 dom。因为 dom 的描绘⾮常消耗性能,如果我们能在 shouldComponentUpdate ⽅ 法中能够写出更优化的 dom diff 算法,可以极⼤的提⾼性能
# 6.14 为什么虚拟 dom 会提⾼性能
虚拟 dom 相当于在 js 和真实 dom 中间加了⼀个缓存,利⽤ dom diff 算法避免了没有必要的 dom 操作,从⽽提⾼性能
具体实现步骤如下
- ⽤ JavaScript 对象结构表示 DOM 树的结构;然后⽤这个树构建⼀个真正的 DOM 树,插到⽂档当中
- 当状态变更的时候,重新构造⼀棵新的对象树。然后⽤新的树和旧的树进⾏⽐较,记录两棵树差异
- 把 2 所记录的差异应⽤到步骤 1 所构建的真正的 DOM 树上,视图就更新
# 6.15 diff 算法
- 把树形结构按照层级分解,只⽐较同级元素。
- 给列表结构的每个单元添加唯⼀的 key 属性,⽅便⽐较。
- React 只会匹配相同 class 的 component (这⾥⾯的 class 指的是组件的名字)
- 合并操作,调⽤ component 的 setState ⽅法的时候, React 将其标记为 - dirty .到每⼀个事件循环结束, React 检查所有标记 dirty 的 component 重新绘制.
- 选择性⼦树渲染。开发⼈员可以重写 shouldComponentUpdate 提⾼ diff 的性能
# 6.16 react 性能优化⽅案
- 重写 shouldComponentUpdate 来避免不必要的 dom 操作
- 使⽤ production 版本的 react.js
- 使⽤ key 来帮助 React 识别列表中所有⼦组件的最⼩变化
# 6.17 简述 flux 思想
Flux 的最⼤特点,就是数据的"单向流动"。
- ⽤户访问 View
- View 发出⽤户的 Action
- Dispatcher 收到 Action ,要求 Store 进⾏相应的更新
- Store 更新后,发出⼀个 "change" 事件
- View 收到 "change" 事件后,更新⻚⾯
# 6.18 说说你⽤ react 有什么坑点
- JSX 做表达式判断时候,需要强转为 boolean 类型
如果不使⽤ !!b 进⾏强转数据类型,会在⻚⾯⾥⾯输出 0 。
render() {
const b = 0;
return <div>
{
!!b && <div>这是⼀段⽂本</div>
}
</div>
}
- 尽量不要在 componentWillReviceProps ⾥使⽤ setState,如果⼀定要使⽤,那么需要判断结束条件,不然会出现⽆限重渲染,导致⻚⾯崩溃
- 给组件添加 ref 时候,尽量不要使⽤匿名函数,因为当组件更新的时候,匿名函数会被当做新的 prop 处理,让 ref 属性接受到新函数的时候,react 内部会先清空 ref,也就是会以 null 为回调参数先执⾏⼀次 ref 这个 props,然后在以该组件的实例执⾏⼀次 ref,所以⽤匿名函数做 ref 的时候,有的时候去 ref 赋值后的属性会取到 null
- 遍历⼦节点的时候,不要⽤ index 作为组件的 key 进⾏传⼊
# 6.19 我现在有⼀个 button,要⽤ react 在上⾯绑定点击事件,要怎么做
class Demo {
render() {
return (
<button
onClick={(e) => {
alert("我点击了按钮");
}}
>
按钮
</button>
);
}
}
你觉得你这样设置点击事件会有什么问题吗?
由于 onClick 使⽤的是匿名函数,所有每次重渲染的时候,会把该 onClick 当做⼀个新的 prop 来处理,会将内部缓存的 onClick 事件进⾏重新赋值,所以相对直接使⽤函数来说,可能有⼀点的性能下降
class Demo {
onClick = (e) => {
alert('我点击了按钮')
}
render() {
return <button onClick={this.onClick}>
按钮
</button>
}
# 6.20 react 的虚拟 dom 是怎么实现的
⾸先说说为什么要使⽤ Virturl DOM ,因为操作真实 DOM 的耗费的性能代价太⾼,所以 react 内部使⽤ js 实现了⼀套 dom 结构,在每次操作在和真实 dom 之前,使⽤实现好的 diff 算法,对虚拟 dom 进⾏⽐较,递归找出有变化的 dom 节点,然后对其进⾏更新操作。为了实现虚拟 DOM ,我们需要把每⼀种节点类型抽象成对象,每⼀种节点类型有⾃⼰的属性,也就是 prop,每次进⾏ diff 的时候, react 会先⽐较该节点类型,假如节点类型不⼀样,那么 react 会直接删除该节点,然后直接创建新的节点插⼊到其中,假如节点类型⼀样,那么会⽐较 prop 是否有更新,假如有 prop 不⼀样,那么 react 会判定该节点有更新,那么重渲染该节点,然后在对其⼦节点进⾏⽐较,⼀层⼀层往下,直到没有⼦节点
# 6.21 react 的渲染过程中,兄弟节点之间是怎么处理的?也就是 key 值不⼀样的时候
通常我们输出节点的时候都是 map ⼀个数组然后返回⼀个 ReactNode ,为了⽅便 react 内部进⾏优化,我们必须给每⼀个 reactNode 添加 key ,这个 key prop 在设计值处不是给开发者⽤的,⽽是给 react ⽤的,⼤概的作⽤就是给每⼀个 reactNode 添加⼀个身份标识,⽅便 react 进⾏识别,在重渲染过程中,如果 key ⼀样,若组件属性有所变化,则 react 只更新组件对应的属性;没有变化则不更新,如果 key 不⼀样,则 react 先销毁该组件,然后重新创建该组件
# 6.22 那给我介绍⼀下 react
- 以前我们没有 jquery 的时候,我们⼤概的流程是从后端通过 ajax 获取到数据然后使⽤ jquery ⽣成 dom 结果然后更新到⻚⾯当中,但是随着业务发展,我们的项⽬可能会越来越复杂,我们每次请求到数据,或则数据有更改的时候,我们⼜需要重新组装⼀次 dom 结构,然后更新⻚⾯,这样我们⼿动同步 dom 和数据的成本就越来越⾼,⽽且频繁的操作 dom,也使我我们⻚⾯的性能慢慢的降低。
- 这个时候 mvvm 出现了,mvvm 的双向数据绑定可以让我们在数据修改的同时同步 dom 的更新,dom 的更新也可以直接同步我们数据的更改,这个特定可以⼤⼤降低我们⼿动去维护 dom 更新的成本,mvvm 为 react 的特性之⼀,虽然 react 属于单项数据流,需要我们⼿动实现双向数据绑定。
- 有了 mvvm 还不够,因为如果每次有数据做了更改,然后我们都全量更新 dom 结构的话,也没办法解决我们频繁操作 dom 结构(降低了⻚⾯性能)的问题,为了解决这个问题,react 内部实现了⼀套虚拟 dom 结构,也就是⽤ js 实现的⼀套 dom 结构,他的作⽤是讲真实 dom 在 js 中做⼀套缓存,每次有数据更改的时候,react 内部先使⽤算法,也就是鼎鼎有名的 diff 算法对 dom 结构进⾏对⽐,找到那些我们需要新增、更新、删除的 dom 节点,然后⼀次性对真实 DOM 进⾏更新,这样就⼤⼤降低了操作 dom 的次数。 那么 diff 算法是怎么运作的呢,⾸先,diff 针对类型不同的节点,会直接判定原来节点需要卸载并且⽤新的节点来装载卸载的节点的位置;针对于节点类型相同的节点,会对⽐这个节点的所有属性,如果节点的所有属性相同,那么判定这个节点不需要更新,如果节点属性不相同,那么会判定这个节点需要更新,react 会更新并重渲染这个节点。
- react 设计之初是主要负责 UI 层的渲染,虽然每个组件有⾃⼰的 state,state 表示组件的状态,当状态需要变化的时候,需要使⽤ setState 更新我们的组件,但是,我们想通过⼀个组件重渲染它的兄弟组件,我们就需要将组件的状态提升到⽗组件当中,让⽗组件的状态来控制这两个组件的重渲染,当我们组件的层次越来越深的时候,状态需要⼀直往下传,⽆疑加⼤了我们代码的复杂度,我们需要⼀个状态管理中⼼,来帮我们管理我们状态 state。
- 这个时候,redux 出现了,我们可以将所有的 state 交给 redux 去管理,当我们的某⼀个 state 有变化的时候,依赖到这个 state 的组件就会进⾏⼀次重渲染,这样就解决了我们的我们需要⼀直把 state 往下传的问题。redux 有 action、reducer 的概念,action 为唯⼀修改 state 的来源,reducer 为唯⼀确定 state 如何变化的⼊⼝,这使得 redux 的数据流⾮常规范,同时也暴露出了 redux 代码的复杂,本来那么简单的功能,却需要完成那么多的代码。
- 后来,社区就出现了另外⼀套解决⽅案,也就是 mobx,它推崇代码简约易懂,只需要定义⼀个可观测的对象,然后哪个组价使⽤到这个可观测的对象,并且这个对象的数据有更改,那么这个组件就会重渲染,⽽且 mobx 内部也做好了是否重渲染组件的⽣命周期 shouldUpdateComponent,不建议开发者进⾏更改,这使得我们使⽤ mobx 开发项⽬的时候可以简单快速的完成很多功能,连 redux 的作者也推荐使⽤ mobx 进⾏项⽬开发。但是,随着项⽬的不断变⼤,mobx 也不断暴露出了它的缺点,就是数据流太随意,出了 bug 之后不好追溯数据的流向,这个缺点正好体现出了 redux 的优点所在,所以针对于⼩项⽬来说,社区推荐使⽤ mobx,对⼤项⽬推荐使⽤ redux
# 7. Vue
# 7.1 对于 MVVM 的理解
MVVM 是 Model-View-ViewModel 的缩写
- Model 代表数据模型,也可以在 Model 中定义数据修改和操作的业务逻辑。
- View 代表 UI 组件,它负责将数据模型转化成 UI 展现出来。
- ViewModel 监听模型数据的改变和控制视图⾏为、处理⽤户交互,简单理解就是⼀个同步 View 和 Model 的对象,连接 Model 和 View
- 在 MVVM 架构下, View 和 Model 之间并没有直接的联系,⽽是通过 ViewModel 进⾏交互, Model 和 ViewModel 之间的交互是双向的, 因此 View 数据的变化会同步到 Model 中,⽽ Model 数据的变化也会⽴即反应到 View 上。
- ViewModel 通过双向数据绑定把 View 层和 Model 层连接了起来,⽽ View 和 Model 之间的同步⼯作完全是⾃动的,⽆需⼈为⼲涉,因此开发者只需关注业务逻辑,不需要⼿动操作 DOM,不需要关注数据状态的同步问题,复杂的数据状态维护完全由 MVVM 来统⼀管理
# 7.2 请详细说下你对 vue ⽣命周期的理解
答:总共分为 8 个阶段创建前/后,载⼊前/后,更新前/后,销毁前/后
- 创建前/后: 在 beforeCreate 阶段, vue 实例的挂载元素 el 和数据对象 data 都为 undefined ,还未初始化。在 created 阶段, vue 实例的数据对象 data 有了,el 还没有
- 载⼊前/后:在 beforeMount 阶段, vue 实例的 $el 和 data 都初始化了,但还是挂载之前为虚拟的 dom 节点, data.message 还未替换。在 mounted 阶段, vue 实例挂载完成, data.message 成功渲染。
- 更新前/后:当 data 变化时,会触发 beforeUpdate 和 updated ⽅法
- 销毁前/后:在执⾏ destroy ⽅法后,对 data 的改变不会再触发周期函数,说明此时 vue 实例已经解除了事件监听以及和 dom 的绑定,但是 dom 结构依然存在
# 什么是 vue ⽣命周期?
- 答: Vue 实例从创建到销毁的过程,就是⽣命周期。从开始创建、初始化数据、编译模板、挂载 Dom→ 渲染、更新 → 渲染、销毁等⼀系列过程,称之为 Vue 的⽣命周期。
# vue ⽣命周期的作⽤是什么?
- 答:它的⽣命周期中有多个事件钩⼦,让我们在控制整个 Vue 实例的过程时更容易形成好的逻辑。
# 第⼀次⻚⾯加载会触发哪⼏个钩⼦?
- 答:会触发下⾯这⼏个 beforeCreate 、 created 、 beforeMount 、 mounted 。
# DOM 渲染在哪个周期中就已经完成?
- 答: DOM 渲染在 mounted 中就已经完成了
# 7.3 Vue 实现数据双向绑定的原理:Object.defineProperty()
- vue 实现数据双向绑定主要是:采⽤数据劫持结合发布者-订阅者模式的⽅式,通过 Object.defineProperty() 来劫持各个属性的 setter , getter ,在数据变动时发布消息给订阅者,触发相应监听回调。当把⼀个普通 Javascript 对象传给 Vue 实例来作为它的 data 选项时,Vue 将遍历它的属性,⽤ Object.defineProperty() 将它们转为 getter/setter 。⽤户看不到 getter/setter ,但是在内部它们让 Vue 追踪依赖,在属性被访问和修改时通知变化。
- vue 的数据双向绑定 将 MVVM 作为数据绑定的⼊⼝,整合 Observer , Compile 和 Watcher 三者,通过 Observer 来监听⾃⼰的 model 的数据变化,通过 Compile 来解析编译模板指令( vue 中是⽤来解析 {{}} ),最终利⽤ watcher 搭起 observer 和 Compile 之间的通信桥梁,达到数据变化 —>视图更新;视图交互变化( input )—>数据 model 变更双向绑定效果。
# 7.4 Vue 组件间的参数传递
# ⽗组件与⼦组件传值
⽗组件传给⼦组件:⼦组件通过 props ⽅法接受数据;
- ⼦组件传给⽗组件: $emit ⽅法传递参数
# ⾮⽗⼦组件间的数据传递,兄弟组件传值
eventBus ,就是创建⼀个事件中⼼,相当于中转站,可以⽤它来传递事件和接收事件。项⽬⽐较⼩时,⽤这个⽐较合适(虽然也有不少⼈推荐直接⽤ VUEX ,具体来说看需求)
# 7.5 Vue 的路由实现:hash 模式 和 history 模式
- hash 模式:在浏览器中符号 “#” ,#以及#后⾯的字符称之为 hash ,⽤ window.location.hash 读取。特点: hash 虽然在 URL 中,但不被包括在 HTTP 请求中;⽤来指导浏览器动作,对服务端安全⽆⽤, hash 不会重加载⻚⾯。
- history 模式:h istory 采⽤ HTML5 的新特性;且提供了两个新⽅法:pushState() , replaceState() 可以对浏览器历史记录栈进⾏修改,以及 popState 事件的监听到状态变更
# 7.6 vue 路由的钩⼦函数
⾸⻚可以控制导航跳转, beforeEach , afterEach 等,⼀般⽤于⻚⾯ title 的修改。⼀些需要登录才能调整⻚⾯的重定向功能。
- beforeEach 主要有 3 个参数 to , from , next 。
- to : route 即将进⼊的⽬标路由对象。
- from : route 当前导航正要离开的路由。
- next : function ⼀定要调⽤该⽅法 resolve 这个钩⼦。执⾏效果依赖 n ext ⽅法的调⽤参数。可以控制⽹⻚的跳转
# 7.7 vuex 是什么?怎么使⽤?哪种功能场景使⽤它?
- 只⽤来读取的状态集中放在 store 中; 改变状态的⽅式是提交 mutations ,这是个同步的事物; 异步逻辑应该封装在 action 中。
- 在 main.js 引⼊ store ,注⼊。新建了⼀个⽬录 store , … export
- 场景有:单⻚应⽤中,组件之间的状态、⾳乐播放、登录状态、加⼊购物⻋
- state : Vuex 使⽤单⼀状态树,即每个应⽤将仅仅包含⼀个 store 实例,但单⼀状态树和模块化并不冲突。存放的数据状态,不可以直接修改⾥⾯的数据。
- mutations : mutations 定义的⽅法动态修改 Vuex 的 store 中的状态或数据
- getters :类似 vue 的计算属性,主要⽤来过滤⼀些数据。
- action : actions 可以理解为通过将 mutations ⾥⾯处⾥数据的⽅法变成可异步的处理数据的⽅法,简单的说就是异步操作数据。 view 层通过 store.dispath 来分发 action
modules :项⽬特别复杂的时候,可以让每⼀个模块拥有⾃⼰的 state 、mutation 、 action 、 getters ,使得结构⾮常清晰,⽅便管理
# 7.8 v-if 和 v-show 区别
答: v-if 按照条件是否渲染, v-show 是 display 的 block 或 none ;
# 7.9 $route 和 $router 的区别
- $route 是“路由信息对象”,包括 path , params , hash , query ,fullPath , matched , name 等路由信息参数。
- ⽽ $router 是“路由实例”对象包括了路由的跳转⽅法,钩⼦函数等
# 7.10 如何让 CSS 只在当前组件中起作⽤?
将当前组件的 <style> 修改为 <style scoped>
# 7.11 <keep-alive></keep-alive> 的作⽤是什么?
- <keep-alive></keep-alive> 包裹动态组件时,会缓存不活动的组件实例,主要⽤于保留组件状态或避免重新渲染
⽐如有⼀个列表和⼀个详情,那么⽤户就会经常执⾏打开详情=>返回列表=>打开详情…这样的话列表和详情都是⼀个频率很⾼的⻚⾯,那么就可以对列表组件使⽤ <keep-alive></keep-alive> 进⾏缓存,这样⽤户每次返回列表的时候,都能从缓存中快速渲染,⽽不是重新渲染
# 7.12 指令 v-el 的作⽤是什么?
提供⼀个在⻚⾯上已存在的 DOM 元素作为 Vue 实例的挂载⽬标.可以是 CSS 选择器,也可以是⼀个 HTMLElement 实例,
# 7.13 在 Vue 中使⽤插件的步骤
- 采⽤ ES6 的 import ... from ... 语法或 CommonJS 的 require() ⽅法引⼊插件
- 使⽤全局⽅法 Vue.use( plugin ) 使⽤插件,可以传⼊⼀个选项对象 Vue.use(MyPlugin,{ someOption: true })
# 7.14 请列举出 3 个 Vue 中常⽤的⽣命周期钩⼦函数?
- created : 实例已经创建完成之后调⽤,在这⼀步,实例已经完成数据观测, 属性和⽅法的运算, watch/event 事件回调. 然⽽, 挂载阶段还没有开始, $el 属性⽬前还不可⻅
- mounted : el 被新创建的 vm.$el 替换,并挂载到实例上去之后调⽤该钩⼦。如果root 实例挂载了⼀个⽂档内元素,当 mounted 被调⽤时 vm.$el 也在⽂档内。
- activated : keep-alive 组件激活时调⽤
# vue-cli 7.15 ⼯程技术集合介绍
问题⼀:构建的 vue-cli ⼯程都到了哪些技术,它们的作⽤分别是什么
- vue.js : vue-cli ⼯程的核⼼,主要特点是 双向数据绑定 和 组件系统。
- vue-router : vue 官⽅推荐使⽤的路由框架。
- vuex :专为 Vue.js 应⽤项⽬开发的状态管理器,主要⽤于维护 vue 组件间共⽤的⼀些 变量 和 ⽅法。
- axios ( 或者 fetch 、 ajax ):⽤于发起 GET 、或 POST 等 http 请求,基于 Promise 设计。
- vuex 等:⼀个专为 vue 设计的移动端 UI 组件库。
- 创建⼀个 emit.js ⽂件,⽤于 vue 事件机制的管理。
- webpack :模块加载和 vue-cli ⼯程打包器
问题⼆:vue-cli ⼯程常⽤的 npm 命令有哪些?
- 下载 node_modules 资源包的命令
npm install
- 启动 vue-cli 开发环境的 npm 命令:
npm run dev
- vue-cli ⽣成 ⽣产环境部署资源 的 npm 命令:
npm run build
- ⽤于查看 vue-cli ⽣产环境部署资源⽂件⼤⼩的 npm 命令:
npm run build --report
在浏览器上⾃动弹出⼀个 展示 vue-cli ⼯程打包后 app.js 、manifest.js 、 vendor.js ⽂件⾥⾯所包含代码的⻚⾯。可以具此优化 vue-cli ⽣产环境部署的静态资源,提升 ⻚⾯ 的加载速度
# 7.16 NextTick
nextTick 可以让我们在下次 DOM 更新循环结束之后执⾏延迟回调,⽤于获得更新后的 DOM
推荐看这个:https://blog.shenzjd.com/pages/e549301c03f9c/#nexttick-的实现 (opens new window)
# 7.17 vue 的优点是什么
- 低耦合。视图( View )可以独⽴于 Model 变化和修改,⼀个 ViewModel 可以绑定到不同的 "View" 上,当 View 变化的时候 Model 可以不变,当 Model 变化的时候 View 也可以不变
- 可重⽤性。你可以把⼀些视图逻辑放在⼀个 ViewModel ⾥⾯,让很多 view 重⽤这段视图逻辑
- 可测试。界⾯素来是⽐较难于测试的,⽽现在测试可以针对 ViewModel 来写
# 路由之间跳转
# 声明式(标签跳转)
<router-link :to="index"></router-link>
# 编程式( js 跳转)
router.push("index");
# 7.18 实现 Vue SSR
# 其基本实现原理
- app.js 作为客户端与服务端的公⽤⼊⼝,导出 Vue 根实例,供客户端 entry 与服务端 entry 使⽤。客户端 entry 主要作⽤挂载到 DOM 上,服务端 entry 除了创建和返回实例,还进⾏路由匹配与数据预获取
- webpack 为客服端打包⼀个 Client Bundle ,为服务端打包⼀个 Server Bundle 。
- 服务器接收请求时,会根据 url ,加载相应组件,获取和解析异步数据,创建⼀个读取 Server Bundle 的 BundleRenderer ,然后⽣成 html 发送给客户端。
- 客户端混合,客户端收到从服务端传来的 DOM 与⾃⼰的⽣成的 DOM 进⾏对⽐,把不相同的 DOM 激活,使其可以能够响应后续变化,这个过程称为客户端激活 。为确保混合成功,客户端与服务器端需要共享同⼀套数据。在服务端,可以在渲染之前获取数据,填充到 stroe ⾥,这样,在客户端挂载到 DOM 之前,可以直接从 store ⾥取数据。⾸屏的动态数据通过 window.INITIAL_STATE 发送到客户端
Vue SSR 的实现,主要就是把 Vue 的组件输出成⼀个完整 HTML , vueserver-renderer 就是⼲这事的
- Vue SSR 需要做的事多点(输出完整 HTML),除了 complier -> vnode ,还需如数据获取填充⾄ HTML 、客户端混合( hydration )、缓存等等。 相⽐于其他模板引擎( ejs , jade 等),最终要实现的⽬的是⼀样的,性能上可能要差点
# 7.19 Vue 组件 data 为什么必须是函数
- 每个组件都是 Vue 的实例。
- 组件共享 data 属性,当 data 的值是同⼀个引⽤类型的值时,改变其中⼀个会影响其他
# 7.20 Vue computed 实现
- 建⽴与其他属性(如: data 、 Store )的联系;
- 属性改变后,通知计算属性重新计算
实现时,主要如下
- 初始化 data , 使⽤ Object.defineProperty 把这些属性全部转为 getter/setter 。
- 初始化 computed , 遍历 computed ⾥的每个属性,每个 computed 属性都是⼀个 watch 实例。每个属性提供的函数作为属性的 getter ,使⽤ Object.defineProperty 转化。
- Object.defineProperty getter 依赖收集。⽤于依赖发⽣变化时,触发属性重新计算。若出现当前 computed 计算属性嵌套其他 computed 计算属性时,先进⾏其他的依赖收集
# 7.21 Vue complier 实现
- 模板解析这种事,本质是将数据转化为⼀段 html ,最开始出现在后端,经过各种处理吐给前端。随着各种 mv* 的兴起,模板解析交由前端处理。
- 总的来说, Vue complier 是将 template 转化成⼀个 render 字符串。
可以简单理解成以下步骤:
- parse 过程,将 template 利⽤正则转化成 AST 抽象语法树。
- optimize 过程,标记静态节点,后 diff 过程跳过静态节点,提升性能。
- generate 过程,⽣成 render 字符串
# 7.22 怎么快速定位哪个组件出现性能问题
⽤ timeline ⼯具。 ⼤意是通过 timeline 来查看每个函数的调⽤时常,定位出哪个函数的问题,从⽽能判断哪个组件出了问题
# 8.框架知识
# 8.1 MVVM
MVVM 由以下三个内容组成
- View :界⾯
- Model :数据模型
- ViewModel :作为桥梁负责沟通 View 和 Model
在 JQuery 时期,如果需要刷新 UI 时,需要先取到对应的 DOM 再更新 UI ,这样数据和业务的逻辑就和⻚⾯有强耦合。 在 MVVM 中, UI 是通过数据驱动的,数据⼀旦改变就会相应的刷新对应的 UI , UI 如果改变,也会改变对应的数据。这种⽅式就可以在业务处理中只关⼼数据的流转,⽽⽆需直接和⻚⾯打交道。 ViewModel 只关⼼数据和业务的处理,不关⼼ View 如何处理数据,在这种情况下, View 和 Model 都可以独⽴出来,任何⼀⽅改变了也不⼀定需要改变另⼀⽅,并且可以将⼀些可复⽤的逻辑放在⼀个 ViewModel 中,让多个 View 复⽤这个 ViewModel
- 在 MVVM 中,最核⼼的也就是数据双向绑定,例如 Angluar 的脏数据检测, Vue 中的数据劫持
# 脏数据监测
当触发了指定事件后会进⼊脏数据检测,这时会调⽤ $digest 循环遍历所有的数据观察者,判断当前值是否和先前的值有区别,如果检测到变化的话,会调⽤ $watch 函数,然后再次调⽤ $digest 循环直到发现没有变化。循环⾄少为⼆次 ,⾄多为⼗次。
脏数据检测虽然存在低效的问题,但是不关⼼数据是通过什么⽅式改变的,都可以完成任务,但是这在 Vue 中的双向绑定是存在问题的。并且脏数据检测可以实现批量检测出更新的值,再去统⼀更新 UI ,⼤⼤减少了操作 DOM 的次数。所以低效也是相对的,这就仁者⻅仁智者⻅智了。
# 数据劫持
Vue 内部使⽤了 Object.defineProperty() 来实现双向绑定,通过这个函数可以监听到 set 和 get 的事件。
var data = { name: "yck" };
observe(data);
let name = data.name; // -> get value
data.name = "yyy"; // -> change value
function observe(obj) {
// 判断类型
if (!obj || typeof obj !== "object") {
return;
}
Object.keys(obj).forEach((key) => {
defineReactive(obj, key, obj[key]);
});
}
function defineReactive(obj, key, val) {
// 递归⼦属性
observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
console.log("get value");
return val;
},
set: function reactiveSetter(newVal) {
console.log("change value");
val = newVal;
},
});
}
以上代码简单的实现了如何监听数据的 set 和 get 的事件,但是仅仅如此是不够的,还需要在适当的时候给属性添加发布订阅
<div>{{name}}</div>
在解析如上模板代码时,遇到 就会给属性 name 添加发布订阅
// 通过 Dep 解耦
class Dep {
constructor() {
this.subs = [];
}
addSub(sub) {
// sub 是 Watcher 实例
this.subs.push(sub);
}
notify() {
this.subs.forEach((sub) => {
sub.update();
});
}
}
// 全局属性,通过该属性配置 Watcher
Dep.target = null;
function update(value) {
document.querySelector("div").innerText = value;
}
class Watcher {
constructor(obj, key, cb) {
// 将 Dep.target 指向⾃⼰
// 然后触发属性的 getter 添加监听
// 最后将 Dep.target 置空
Dep.target = this;
this.cb = cb;
this.obj = obj;
this.key = key;
this.value = obj[key];
Dep.target = null;
}
update() {
// 获得新值
this.value = this.obj[this.key];
// 调⽤ update ⽅法更新 Dom
this.cb(this.value);
}
}
var data = { name: "yck" };
observe(data);
// 模拟解析到 `{{name}}` 触发的操作
new Watcher(data, "name", update);
// update Dom innerText
data.name = "yyy";
接下来,对 defineReactive 函数进⾏改造
function defineReactive(obj, key, val) {
// 递归⼦属性
observe(val);
let dp = new Dep();
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
console.log("get value");
// 将 Watcher 添加到订阅
if (Dep.target) {
dp.addSub(Dep.target);
}
return val;
},
set: function reactiveSetter(newVal) {
console.log("change value");
val = newVal;
// 执⾏ watcher 的 update ⽅法
dp.notify();
},
});
}
以上实现了⼀个简易的双向绑定,核⼼思路就是⼿动触发⼀次属性的 getter 来实现发布订阅的添加
# Proxy 与 Object.defineProperty 对⽐
Object.defineProperty 虽然已经能够实现双向绑定了,但是他还是有缺陷的
- 只能对属性进⾏数据劫持,所以需要深度遍历整个对象 对于数组不能监听到数据的变化
- 虽然 Vue 中确实能检测到数组数据的变化,但是其实是使⽤了 hack 的办法,并且也是有缺陷的
const arrayProto = Array.prototype;
export const arrayMethods = Object.create(arrayProto);
// hack 以下⼏个函数
const methodsToPatch = [
"push",
"pop",
"shift",
"unshift",
"splice",
"sort",
"reverse",
];
methodsToPatch.forEach(function (method) {
// 获得原⽣函数
const original = arrayProto[method];
def(arrayMethods, method, function mutator(...args) {
// 调⽤原⽣函数
const result = original.apply(this, args);
const ob = this.__ob__;
let inserted;
switch (method) {
case "push":
case "unshift":
inserted = args;
break;
case "splice":
inserted = args.slice(2);
break;
}
if (inserted) ob.observeArray(inserted);
// 触发更新
ob.dep.notify();
return result;
});
});
反观 Proxy 就没以上的问题,原⽣⽀持监听数组变化,并且可以直接对整个对象进⾏拦截,所以 Vue 也将在下个⼤版本中使⽤ Proxy 替换 Object.defineProperty
let onWatch = (obj, setBind, getLogger) => {
let handler = {
get(target, property, receiver) {
getLogger(target, property);
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
setBind(value);
return Reflect.set(target, property, value);
},
};
return new Proxy(obj, handler);
};
let obj = { a: 1 };
let value;
let p = onWatch(
obj,
(v) => {
value = v;
},
(target, property) => {
console.log(`Get '${property}' = ${target[property]}`);
}
);
p.a = 2; // bind `value` to `2`
p.a; // -> Get 'a' = 2
# 8.2 路由原理
前端路由实现起来其实很简单,本质就是监听 URL 的变化,然后匹配路由规则,显示相应的⻚⾯,并且⽆须刷新。⽬前单⻚⾯使⽤的路由就只有两种实现⽅式
- hash 模式
- history 模式
www.test.com/##/ 就是 Hash URL ,当 ## 后⾯的哈希值发⽣变化时,不会向服务器请求数据,可以通过 hashchange 事件来监听到 URL 的变化,从⽽进⾏跳转⻚⾯。
History 模式是 HTML5 新推出的功能,⽐之 Hash URL 更加美观
# 8.3 Virtual Dom
# 为什么需要 Virtual Dom
众所周知,操作 DOM 是很耗费性能的⼀件事情,既然如此,我们可以考虑通过 JS 对象来模拟 DOM 对象,毕竟操作 JS 对象⽐操作 DOM 省时的多
// 假设这⾥模拟⼀个 ul,其中包含了 5 个 li
[1, 2, 3, 4, 5][
// 这⾥替换上⾯的 li
(1, 2, 5, 4)
];
从上述例⼦中,我们⼀眼就可以看出先前的 ul 中的第三个 li 被移除了,四五替换了位置。
- 如果以上操作对应到 DOM 中,那么就是以下代码
// 删除第三个 li
ul.childNodes[2].remove();
// 将第四个 li 和第五个交换位置
let fromNode = ul.childNodes[4];
let toNode = node.childNodes[3];
let cloneFromNode = fromNode.cloneNode(true);
let cloenToNode = toNode.cloneNode(true);
ul.replaceChild(cloneFromNode, toNode);
ul.replaceChild(cloenToNode, fromNode);
当然在实际操作中,我们还需要给每个节点⼀个标识,作为判断是同⼀个节点的依据。所以这也是 Vue 和 React 中官⽅推荐列表⾥的节点使⽤唯⼀的 key 来保证性能。
- 那么既然 DOM 对象可以通过 JS 对象来模拟,反之也可以通过 JS 对象来渲染出对应的 DOM
- 以下是⼀个 JS 对象模拟 DOM 对象的简单实现
export default class Element {
/**
* @param {String} tag 'div'
* @param {Object} props { class: 'item' }
* @param {Array} children [ Element1, 'text']
* @param {String} key option
*/
constructor(tag, props, children, key) {
this.tag = tag
this.props = props
if (Array.isArray(children)) {
this.children = children
} else if (isString(children)) {
this.key = children
this.children = null
}
if (key) this.key = key
}
// 渲染
render() {
let root = this._createElement(
this.tag,
this.props,
this.children,
this.key
)
document.body.appendChild(root)
return root
}
create() {
return this._createElement(this.tag, this.props, this.children, this.ke
}
// 创建节点
_createElement(tag, props, child, key) {
// 通过 tag 创建节点
let el = document.createElement(tag)
// 设置节点属性
for (const key in props) {
if (props.hasOwnProperty(key)) {
const value = props[key]
el.setAttribute(key, value)
}
}
if (key) {
el.setAttribute('key', key)
}
// 递归添加⼦节点
if (child) {
child.forEach(element => {
let child
if (element instanceof Element) {
child = this._createElement(
element.tag,
element.props,
element.children,
element.key
)
} else {child = document.createTextNode(element)
}
el.appendChild(child)
})
}
return el
}
}
源码系列文章建议看鲨鱼的系列文章
https://blog.shenzjd.com/pages/de5e42d9aade5/ (opens new window)
# Virtual Dom 算法简述
- 既然我们已经通过 JS 来模拟实现了 DOM ,那么接下来的难点就在于如何判断旧的对象和新的对象之间的差异。
- DOM 是多叉树的结构,如果需要完整的对⽐两颗树的差异,那么需要的时间复杂度会是 O(n ^ 3) ,这个复杂度肯定是不能接受的。于是 React 团队优化了算法,实现了 O(n) 的复杂度来对⽐差异。
- 实现 O(n) 复杂度的关键就是只对⽐同层的节点,⽽不是跨层对⽐,这也是考虑到在实际业务中很少会去跨层的移动 DOM 元素
所以判断差异的算法就分为了两步
- ⾸先从上⾄下,从左往右遍历对象,也就是树的深度遍历,这⼀步中会给每个节点添加索引,便于最后渲染差异
- ⼀旦节点有⼦元素,就去判断⼦元素是否有不同
# Virtual Dom 算法实现
# 树的递归
- ⾸先我们来实现树的递归算法,在实现该算法前,先来考虑下两个节点对⽐会有⼏种情况
- 新的节点的 tagName 或者 key 和旧的不同,这种情况代表需要替换旧的节点,并且也不再需要遍历新旧节点的⼦元素了,因为整个旧节点都被删掉了
- 新的节点的 tagName 和 key (可能都没有)和旧的相同,开始遍历⼦树
- 没有新的节点,那么什么都不⽤做
import { StateEnums, isString, move } from './util'
import Element from './element'
export default function diff(oldDomTree, newDomTree) {
// ⽤于记录差异
let pathchs = {}
// ⼀开始的索引为 0
dfs(oldDomTree, newDomTree, 0, pathchs)
return pathchs
}
function dfs(oldNode, newNode, index, patches) {
// ⽤于保存⼦树的更改
let curPatches = []
// 需要判断三种情况
// 1.没有新的节点,那么什么都不⽤做
// 2.新的节点的 tagName 和 `key` 和旧的不同,就替换
// 3.新的节点的 tagName 和 key(可能都没有) 和旧的相同,开始遍历⼦树
if (!newNode) {
} else if (newNode.tag === oldNode.tag && newNode.key === oldNode.key) {
// 判断属性是否变更
let props = diffProps(oldNode.props, newNode.props)
if (props.length) curPatches.push({ type: StateEnums.ChangeProps, props
// 遍历⼦树
diffChildren(oldNode.children, newNode.children, index, patches)
} else {
// 节点不同,需要替换
curPatches.push({ type: StateEnums.Replace, node: newNode })
}
if (curPatches.length) {
if (patches[index]) {
patches[index] = patches[index].concat(curPatches)
} else {
patches[index] = curPatches
}
}
}
# 判断属性的更改
判断属性的更改也分三个步骤
- 遍历旧的属性列表,查看每个属性是否还存在于新的属性列表中
- 遍历新的属性列表,判断两个列表中都存在的属性的值是否有变化
- 在第⼆步中同时查看是否有属性不存在与旧的属性列列表中
function diffProps(oldProps, newProps) {
// 判断 Props 分以下三步骤
// 先遍历 oldProps 查看是否存在删除的属性
// 然后遍历 newProps 查看是否有属性值被修改
// 最后查看是否有属性新增
let change = [];
for (const key in oldProps) {
if (oldProps.hasOwnProperty(key) && !newProps[key]) {
change.push({
prop: key,
});
}
}
for (const key in newProps) {
if (newProps.hasOwnProperty(key)) {
const prop = newProps[key];
if (oldProps[key] && oldProps[key] !== newProps[key]) {
change.push({
prop: key,
value: newProps[key],
});
} else if (!oldProps[key]) {
change.push({
prop: key,
value: newProps[key],
});
}
}
}
return change;
}
# 判断列表差异算法实现
这个算法是整个 Virtual Dom 中最核⼼的算法,且让我⼀⼀为你道来。 这⾥的主要步骤其实和判断属性差异是类似的,也是分为三步
- 遍历旧的节点列表,查看每个节点是否还存在于新的节点列表中
- 遍历新的节点列表,判断是否有新的节点
- 在第⼆步中同时判断节点是否有移动
PS:该算法只对有 key 的节点做处理
function listDiff(oldList, newList, index, patches) {
// 为了遍历⽅便,先取出两个 list 的所有 keys
let oldKeys = getKeys(oldList);
let newKeys = getKeys(newList);
let changes = [];
// ⽤于保存变更后的节点数据
// 使⽤该数组保存有以下好处
// 1.可以正确获得被删除节点索引
// 2.交换节点位置只需要操作⼀遍 DOM
// 3.⽤于 `diffChildren` 函数中的判断,只需要遍历
// 两个树中都存在的节点,⽽对于新增或者删除的节点来说,完全没必要
// 再去判断⼀遍
let list = [];
oldList &&
oldList.forEach((item) => {
let key = item.key;
if (isString(item)) {
key = item;
}
// 寻找新的 children 中是否含有当前节点
// 没有的话需要删除
let index = newKeys.indexOf(key);
if (index === -1) {
list.push(null);
} else list.push(key);
});
// 遍历变更后的数组
let length = list.length;
// 因为删除数组元素是会更改索引的
// 所有从后往前删可以保证索引不变
for (let i = length - 1; i >= 0; i--) {
// 判断当前元素是否为空,为空表示需要删除
if (!list[i]) {
list.splice(i, 1);
changes.push({
type: StateEnums.Remove,
index: i,
});
}
}
// 遍历新的 list,判断是否有节点新增或移动
// 同时也对 `list` 做节点新增和移动节点的操作
newList &&
newList.forEach((item, i) => {
let key = item.key;
if (isString(item)) {
key = item;
}
// 寻找旧的 children 中是否含有当前节点
let index = list.indexOf(key);
// 没找到代表新节点,需要插⼊
if (index === -1 || key == null) {
changes.push({
type: StateEnums.Insert,
node: item,
index: i,
});
list.splice(i, 0, key);
} else {
// 找到了,需要判断是否需要移动
if (index !== i) {
changes.push({
type: StateEnums.Move,
from: index,
to: i,
});
move(list, index, i);
}
}
});
return { changes, list };
}
function getKeys(list) {
let keys = [];
let text;
list &&
list.forEach((item) => {
let key;
if (isString(item)) {
key = [item];
} else if (item instanceof Element) {
key = item.key;
}
keys.push(key);
});
return keys;
}
# 遍历⼦元素打标识
对于这个函数来说,主要功能就两个
- 判断两个列表差异
- 给节点打上标记
- 总体来说,该函数实现的功能很简单
function diffChildren(oldChild, newChild, index, patches) {
let { changes, list } = listDiff(oldChild, newChild, index, patches);
if (changes.length) {
if (patches[index]) {
patches[index] = patches[index].concat(changes);
} else {
patches[index] = changes;
}
}
// 记录上⼀个遍历过的节点
let last = null;
oldChild &&
oldChild.forEach((item, i) => {
let child = item && item.children;
if (child) {
index =
last && last.children ? index + last.children.length + 1 : index;
let keyIndex = list.indexOf(item.key);
let node = newChild[keyIndex];
// 只遍历新旧中都存在的节点,其他新增或者删除的没必要遍历
if (node) {
dfs(item, node, index, patches);
}
} else index += 1;
last = item;
});
}
# 渲染差异
通过之前的算法,我们已经可以得出两个树的差异了。既然知道了差异,就需要局部去更新 DOM 了,下⾯就让我们来看看 Virtual Dom 算法的最后⼀步骤
- 深度遍历树,将需要做变更操作的取出来
- 局部更新 DOM
let index = 0;
export default function patch(node, patchs) {
let changes = patchs[index];
let childNodes = node && node.childNodes;
// 这⾥的深度遍历和 diff 中是⼀样的
if (!childNodes) index += 1;
if (changes && changes.length && patchs[index]) {
changeDom(node, changes);
}
let last = null;
if (childNodes && childNodes.length) {
childNodes.forEach((item, i) => {
index =
last && last.children
? index + last.children.length + 1
: index + patch(item, patchs);
last = item;
});
}
}
function changeDom(node, changes, noChild) {
changes &&
changes.forEach((change) => {
let { type } = change;
switch (type) {
case StateEnums.ChangeProps:
let { props } = change;
props.forEach((item) => {
if (item.value) {
node.setAttribute(item.prop, item.value);
} else {
node.removeAttribute(item.prop);
}
});
break;
case StateEnums.Remove:
node.childNodes[change.index].remove();
break;
case StateEnums.Insert:
let dom;
if (isString(change.node)) {
dom = document.createTextNode(change.node);
} else if (change.node instanceof Element) {
dom = change.node.create();
}
node.insertBefore(dom, node.childNodes[change.index]);
break;
case StateEnums.Replace:
node.parentNode.replaceChild(change.node.create(), node);
break;
case StateEnums.Move:
let fromNode = node.childNodes[change.from];
let toNode = node.childNodes[change.to];
let cloneFromNode = fromNode.cloneNode(true);
let cloenToNode = toNode.cloneNode(true);
node.replaceChild(cloneFromNode, toNode);
node.replaceChild(cloenToNode, fromNode);
break;
default:
break;
}
});
}
# Virtual Dom 算法的实现也就是以下三步
- 通过 JS 来模拟创建 DOM 对象
- 判断两个对象的差异
- 渲染差异
let test4 = new Element("div", { class: "my-div" }, ["test4"]);
let test5 = new Element("ul", { class: "my-div" }, ["test5"]);
let test1 = new Element("div", { class: "my-div" }, [test4]);
let test2 = new Element("div", { id: "11" }, [test5, test4]);
let root = test1.render();
let pathchs = diff(test1, test2);
console.log(pathchs);
setTimeout(() => {
console.log("开始更新");
patch(root, pathchs);
console.log("结束更新");
}, 1000);
# PDF 下载
https://docs.qq.com/pdf/DV2ppTWJsVU9odWFu (opens new window)