JS语言缺陷
js是一门在极短时间里创造的脚本语言,它存在很多的不足,这使得在学习时无形加大了学习的难度,本文就将这些内容进行总结,以防继续掉坑。
1.变量提升
1.1 案例分析
先来说一下变量提升,它其实就是先用后声明,经常被拿来说明的一个例子是:
console.log(a);
var a = 10;//undefined
这是由于这段代码在执行的时候,js解析器会先把var声明放在前面,然后顺序执行对应的语句,执行到console的时候,由于a变量已经声明提升但未进行赋值操作,在js中这种情况就会报undefined
上面是对出错的解释,接下来就细细说明一下变量提升的具体内容
先来说一下什么是变量,变量就是存放数据的空间,在这个空间里,可以存放具体的数据,也可以存放数据对应的地址,这实际上是对应数据结构中的堆栈,栈数据少可以直接将数据存放进来,堆数据多,所以另开空间存放,然后把数据对应的内存地址放在栈内,在赋值时,栈类型的数据会直接把数据拷贝一份然后进行赋值,而堆类型的数据会把地址复制一份,然后不同的变量会指向同一个地址,在js中对象,函数,数组等都是堆类型数据,也叫引用类型数据,下面直接在控制台写个小例子看看:
//基本类型
//ab彼此修改值的时候相互不影响
var a = 1,b;
b=a;
console.log(a,b)//1 1
a = 2;
console.log(a,b)//2,1
//引用类型
//obj修改值时会相互影响
var obj1 = new Object();
var obj2 = new Object();
obj1.name="kk"
obj2=obj1
obj2.sex="male"
console.log(obj1)//{name: "kk", sex: "male"}
弄清楚了堆栈的区别,就可以来继续看变量提升的问题了,在js中变量包括基本的数据类型和引用的数据类型,并且function被设置为一等公民,也就是说,在声明变量时函数变量的等级比其他变量的等级高,函数的创建又有两种方式一种是函数声明,另一种是函数表达式,在变量提升的时候,只会提升声明而不会提升表达式:
//函数声明
function say(){
console.log('saying');
}
//函数表达式
var say = function(){
console.log('saying');
}
到这里先来总结一下,为了理解变量提升,先了解了变量是什么,变量类型有哪些,函数创建的形式有什么,接下来就可来检验一下,看是否真的懂了:
var name = 'kk';
function say(){
console.log(name); //输出:undefined
var name = 'zoe';
console.log(name); //输出:'zoe'
}
say();
来解释一下为什么:
//1.var name;
//2.发现有函数声明,函数等级高所以function say();var name;
//3.function say();var name;var name;
//4.say()调用函数
//5.此时name声明未赋值,所以是undefined
//6.var name = 'zoe'
//7.由于此时name被赋值了,直接打印zoe
//8.var name = 'kk'
再来看一个例子:
var say = function(){
console.log('1');
};
function say(){
console.log('2');
};
say(); //输出:'1'
说一下为什么:
//1.var say;
//2.函数声明比变量声明等级高,所以function say();var say;
//3.function say()声明未赋值
//4.var say = function(){}赋值
//5.console.log(1)
//6.function say()赋值
1.2编译器和解析器
为什么会出现这种现象呢?从js代码到浏览器识别js代码发生了什么?这个涉及到编译原理,大概分成两个部分,一是将js代码生成AST树,二是将AST树变成浏览器能理解的内容,前者叫编译器,后者叫解释器,如果自己来设计,你会如何处理js代码呢?这里提供一种思路,那就是把所有的js代码的信息都记录下来,然后把他生成一个树状结构,也就是我们所说的AST树,这样说太抽象了,举例看看:
//js代码
if (1 > 0) {
alert("aa");
}
//ast树
{
"type": "Program",
"start": 0,
"end": 29,
"body": [
{
"type": "IfStatement",
"start": 0,
"end": 29,
"test": {
"type": "BinaryExpression",
"start": 4,
"end": 9,
"left": {
"type": "Literal",
"start": 4,
"end": 5,
"value": 1,
"raw": "1"
},
"operator": ">",
"right": {
"type": "Literal",
"start": 8,
"end": 9,
"value": 0,
"raw": "0"
}
},
"consequent": {
"type": "BlockStatement",
"start": 11,
"end": 29,
"body": [
{
"type": "ExpressionStatement",
"start": 15,
"end": 27,
"expression": {
"type": "CallExpression",
"start": 15,
"end": 26,
"callee": {
"type": "Identifier",
"start": 15,
"end": 20,
"name": "alert"
},
"arguments": [
{
"type": "Literal",
"start": 21,
"end": 25,
"value": "aa",
"raw": "\"aa\""
}
]
}
}
]
},
"alternate": null
}
],
"sourceType": "module"
}
可以在
https://astexplorer.net/ 试试
先来定个任务,那就是只实现解析if (1 > 0) {alert("aa");}这句话,因为js的内容太多了,所以只实现上面这句话从js-ast-执行,再次声明,其他所有可能存在的问题都不考虑,只是完成解析上面的一句话,开始:
这句话对于计算机来说就是个字符串,那如何识别它呢?首先把这句话拆分,然后把拆分的内容组合,这个实际叫做词法解析和语法组合,生成对应的类型和值,那这句话中有什么?
1.'if'
2.' '
3.'('
4.'1'
5.' '
6.'>'
7.' '
8.'0'
9.')'
10.' '
11.'{'
12.'\n '
13.'alert'
14.'('
15."aa"
16.')'
17.";"
18.'\n'
19.'}'
知道了有什么,就可以开始解析,把他们对应的类型和值标好,具体看代码:
function tokenizeCode(code) {
var tokens = []; // 保存结果数组
for (var i = 0; i < code.length; i++) {
// 从0开始 一个个字符读取
var currentChar = code.charAt(i);
if (currentChar === ';') {
tokens.push({
type: 'sep',
value: currentChar
});
// 该字符已经得到解析了,直接循环下一个
continue;
}
if (currentChar === '(' || currentChar === ')') {
tokens.push({
type: 'parens',
value: currentChar
});
continue;
}
if (currentChar === '{' || currentChar === '}') {
tokens.push({
type: 'brace',
value: currentChar
});
continue;
}
if (currentChar === '>' || currentChar === '<') {
tokens.push({
type: 'operator',
value: currentChar
});
continue;
}
if (currentChar === '"' || currentChar === '\'') {
// 如果是单引号或双引号,表示一个字符的开始
var token = {
type: 'string',
value: currentChar
};
tokens.push(token);
var closer = currentChar;
// 表示下一个字符是不是被转译了
var escaped = false;
// 循环遍历 寻找字符串的末尾
for(i++; i < code.length; i++) {
currentChar = code.charAt(i);
// 将当前遍历到的字符先加到字符串内容中
token.value += currentChar;
if (escaped) {
// 如果当前为true的话,就变为false,然后该字符就不做特殊的处理
escaped = false;
} else if (currentChar === '\\') {
// 如果当前的字符是 \, 将转译状态变为true,下一个字符不会被做处理
escaped = true;
} else if (currentChar === closer) {
break;
}
}
continue;
}
// 数字做处理
if (/[0-9]/.test(currentChar)) {
// 如果数字是以 0 到 9的字符开始的话
var token = {
type: 'number',
value: currentChar
};
tokens.push(token);
// 继续遍历,如果下一个字符还是数字的话,比如0到9或小数点的话
for (i++; i < code.length; i++) {
currentChar = code.charAt(i);
if (/[0-9\.]/.test(currentChar)) {
// 先不考虑多个小数点 或 进制的情况下
token.value += currentChar;
} else {
// 如果下一个字符不是数字的话,需要把i值返回原来的位置上,需要减1
i--;
break;
}
}
continue;
}
// 标识符是以字母,$, _开始的 做判断
if (/[a-zA-Z\$\_]/.test(currentChar)) {
var token = {
type: 'identifier',
value: currentChar
};
tokens.push(token);
// 继续遍历下一个字符,如果下一个字符还是以字母,$,_开始的话
for (i++; i < code.length; i++) {
currentChar = code.charAt(i);
if (/[a-zA-Z0-9\$\_]/.test(currentChar)) {
token.value += currentChar;
} else {
i--;
break;
}
}
continue;
}
// 连续的空白字符组合在一起
if (/\s/.test(currentChar)) {
var token = {
type: 'whitespace',
value: currentChar
}
tokens.push(token);
// 继续遍历下一个字符
for (i++; i < code.length; i++) {
currentChar = code.charAt(i);
if (/\s/.test(currentChar)) {
token.value += currentChar;
} else {
i--;
break;
}
}
continue;
}
// 更多的字符判断 ......
// 遇到无法理解的字符 直接抛出异常
throw new Error('Unexpected ' + currentChar);
}
return tokens;
}
var tokens = tokenizeCode(`
if (1 > 0) {
alert("aa");
}
`);
console.log(tokens);
测试一下:
解析结果如下:
0: {type: "whitespace", value: "↵ "}
1: {type: "identifier", value: "if"}
2: {type: "whitespace", value: " "}
3: {type: "parens", value: "("}
4: {type: "number", value: "1"}
5: {type: "whitespace", value: " "}
6: {type: "operator", value: ">"}
7: {type: "whitespace", value: " "}
8: {type: "number", value: "0"}
9: {type: "parens", value: ")"}
10: {type: "whitespace", value: " "}
11: {type: "brace", value: "{"}
12: {type: "whitespace", value: "↵ "}
13: {type: "identifier", value: "alert"}
14: {type: "parens", value: "("}
15: {type: "string", value: ""aa""}
16: {type: "parens", value: ")"}
17: {type: "sep", value: ";"}
18: {type: "whitespace", value: "↵ "}
19: {type: "brace", value: "}"}
20: {type: "whitespace", value: "↵ "}
有了词法分析得出来的内容下一步就是要把他们语义化,也就是知道他们代表的是什么意思,有什么联系?比如说括号的范围是什么?变量之间的关系是什么?具体看代码,先写一下大概的结构:
var parser = function(tokens){
const ast = {
type:'Program',
body:[]
};
// 逐条解析顶层语句
while (i < tokens.length) {
const statement = nextStatement();
if (!statement) {
break;
}
ast.body.push(statement);
}
return ast;
}
var ast = parse([
{type: "whitespace", value: "\n"},
{type: "identifier", value: "if"},
{type: "whitespace", value: " "},
{type: "parens", value: "("},
{type: "number", value: "1"},
{type: "whitespace", value: " "},
{type: "operator", value: ">"},
{type: "whitespace", value: " "},
{type: "number", value: "0"},
{type: "parens", value: ")"},
{type: "whitespace", value: " "},
{type: "brace", value: "{"},
{type: "whitespace", value: "\n"},
{type: "identifier", value: "alert"},
{type: "parens", value: "("},
{type: "string", value: "'aa'"},
{type: "parens", value: ")"},
{type: "sep", value: ";"},
{type: "whitespace", value: "\n"},
{type: "brace", value: "}"},
{type: "whitespace", value: "\n"}
]);
具体解析过程,生成ast树:
var parse = function(tokens) {
let i = -1; // 用于标识当前遍历位置
let curToken; // 用于记录当前符号
// 读取下一个语句
function nextStatement () {
// 暂存当前的i,如果无法找到符合条件的情况会需要回到这里
stash();
// 读取下一个符号
nextToken();
if (curToken.type === 'identifier' && curToken.value === 'if') {
// 解析 if 语句
const statement = {
type: 'IfStatement',
};
// if 后面必须紧跟着 (
nextToken();
if (curToken.type !== 'parens' || curToken.value !== '(') {
throw new Error('Expected ( after if');
}
// 后续的一个表达式是 if 的判断条件
statement.test = nextExpression();
// 判断条件之后必须是 )
nextToken();
if (curToken.type !== 'parens' || curToken.value !== ')') {
throw new Error('Expected ) after if test expression');
}
// 下一个语句是 if 成立时执行的语句
statement.consequent = nextStatement();
// 如果下一个符号是 else 就说明还存在 if 不成立时的逻辑
if (curToken === 'identifier' && curToken.value === 'else') {
statement.alternative = nextStatement();
} else {
statement.alternative = null;
}
commit();
return statement;
}
if (curToken.type === 'brace' && curToken.value === '{') {
// 以 { 开头表示是个代码块,我们暂不考虑JSON语法的存在
const statement = {
type: 'BlockStatement',
body: [],
};
while (i < tokens.length) {
// 检查下一个符号是不是 }
stash();
nextToken();
if (curToken.type === 'brace' && curToken.value === '}') {
// } 表示代码块的结尾
commit();
break;
}
// 还原到原来的位置,并将解析的下一个语句加到body
rewind();
statement.body.push(nextStatement());
}
// 代码块语句解析完毕,返回结果
commit();
return statement;
}
// 没有找到特别的语句标志,回到语句开头
rewind();
// 尝试解析单表达式语句
const statement = {
type: 'ExpressionStatement',
expression: nextExpression(),
};
if (statement.expression) {
nextToken();
if (curToken.type !== 'EOF' && curToken.type !== 'sep') {
throw new Error('Missing ; at end of expression');
}
return statement;
}
}
// 读取下一个表达式
function nextExpression () {
nextToken();
if (curToken.type === 'identifier') {
const identifier = {
type: 'Identifier',
name: curToken.value,
};
stash();
nextToken();
if (curToken.type === 'parens' && curToken.value === '(') {
// 如果一个标识符后面紧跟着 ( ,说明是个函数调用表达式
const expr = {
type: 'CallExpression',
caller: identifier,
arguments: [],
};
stash();
nextToken();
if (curToken.type === 'parens' && curToken.value === ')') {
// 如果下一个符合直接就是 ) ,说明没有参数
commit();
} else {
// 读取函数调用参数
rewind();
while (i < tokens.length) {
// 将下一个表达式加到arguments当中
expr.arguments.push(nextExpression());
nextToken();
// 遇到 ) 结束
if (curToken.type === 'parens' && curToken.value === ')') {
break;
}
// 参数间必须以 , 相间隔
if (curToken.type !== 'comma' && curToken.value !== ',') {
throw new Error('Expected , between arguments');
}
}
}
commit();
return expr;
}
rewind();
return identifier;
}
if (curToken.type === 'number' || curToken.type === 'string') {
// 数字或字符串,说明此处是个常量表达式
const literal = {
type: 'Literal',
value: eval(curToken.value),
};
// 但如果下一个符号是运算符,那么这就是个双元运算表达式
stash();
nextToken();
if (curToken.type === 'operator') {
commit();
return {
type: 'BinaryExpression',
left: literal,
right: nextExpression(),
};
}
rewind();
return literal;
}
if (curToken.type !== 'EOF') {
throw new Error('Unexpected token ' + curToken.value);
}
}
// 往后移动读取指针,自动跳过空白
function nextToken () {
do {
i++;
curToken = tokens[i] || { type: 'EOF' };
} while (curToken.type === 'whitespace');
}
// 位置暂存栈,用于支持很多时候需要返回到某个之前的位置
const stashStack = [];
function stash () {
// 暂存当前位置
stashStack.push(i);
}
function rewind () {
// 解析失败,回到上一个暂存的位置
i = stashStack.pop();
curToken = tokens[i];
}
function commit () {
// 解析成功,不需要再返回
stashStack.pop();
}
const ast = {
type: 'Program',
body: [],
};
// 逐条解析顶层语句
while (i < tokens.length) {
const statement = nextStatement();
if (!statement) {
break;
}
ast.body.push(statement);
}
return ast;
};
测试一下:
解析出来的ast的具体结构如下:
{
"type": "Program",
"body": [
{
"type": "IfStatement",
"test": {
"type": "BinaryExpression",
"left": {
"type": "Literal",
"value": 1
},
"right": {
"type": "Literal",
"value": 0
}
},
"consequent": {
"type": "BlockStatement",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "CallExpression",
"caller": {
"type": "Identifier",
"value": "alert"
},
"arguments": [
{
"type": "Literal",
"value": "aa"
}
]
}
}
]
},
"alternative": null
}
]
}
至此生成ast树,这样就有了代码的相关的信息,下一步就是把这些信息转化成执行代码,这就是遍历ast,然后eval处理就行了,具体看代码:
const types = {
Program (node) {
var code = node.body.map(child => {
return generate(child)
});
// console.log(code)
return code;
},
IfStatement (node) {
let code = `if ( ${generate(node.test)} ) { ${generate(node.consequent)} } `;
if (node.alternative) {
code += `else ${generate(node.alternative)}`;
}
return code;
},
BinaryExpression(node){
let code = `${generate(node.left)} > ${generate(node.right)} `;
return code;
},
Literal (node) {
let code = node.value;
return code;
},
BlockStatement(node){
let code = node.body.map(child => {
return generate(child)
});
return code;
},
ExpressionStatement(node){
let code = `${generate(node.expression)}`;
return code;
},
CallExpression(node){
let alert = `${generate(node.caller)}`;
let value = generate(node.arguments[0]);
return `${alert}("${value}")`;
},
Identifier(node){
let code = node.value;
return code;
}
};
function generate(ast) {
return types[ast.type](ast).toString();
}
var code = generate({
"type": "Program",
"body": [
{
"type": "IfStatement",
"test": {
"type": "BinaryExpression",
"left": {
"type": "Literal",
"value": 1
},
"right": {
"type": "Literal",
"value": 0
}
},
"consequent": {
"type": "BlockStatement",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "CallExpression",
"caller": {
"type": "Identifier",
"value": "alert"
},
"arguments": [
{
"type": "Literal",
"value": "aa"
}
]
}
}
]
},
"alternative": null
}
]
});
// console.log(code)
eval(code)
把代码放在控制台试试:
至此,通过解析一句话了解了js到底是如何处理代码的。
2.闭包
2.1闭包基础
接着来看闭包,闭包是函数内的函数,实际是作用域内的作用域,在js中为什么会出现闭包这个概念呢?是因为js中只有局部变量和全局变量,局部变量放在函数作用域内,全局变量放在全局作用域内,局部可以访问全局但全局无法访问局部,如果想要让全局能够访问到局部,就需要通过闭包来实现,具体看代码:
function f(){
var a=1;
}
console.log(a)
`此时console处于全局,a处于局部,全局无法访问局部,所以必然会报错:
`
这时可以利用闭包来进行解决,具体看代码:
function f(){
var a=1;
function g(){
console.log(a)
};
return g;
}
f()()
此时就能够访问到局部的变量了,分析一下,我在f()中写了g(),g()属于f(),所以能访问a,然后在外层把f返回,此时的f实际就是需要访问的变量,看到这里有点疑惑,直接把a进行return不也能达到这样的目的么,为什么还要加一层?这个问题可以用一个例子来解释,假设a是一个局部变量,但有需要被访问到,同时还不希望所有的人都访问到,那使用闭包包装过的变量,只有知道包装形式的人才能使用它,这个就是为什么需要多保障一层的原因
2.1闭包应用
2.1.1 保护私有变量
以上就是对闭包的解释,来想一想闭包帮助我们拥有了访问局部变量的能力,那它怎么用呢?
首先就是用于保护私有变量,导出公有变量,以jquery源码入口结构来进行说明:
( function( global, factory ) {
"use strict";
if ( typeof module === "object" && typeof module.exports === "object" ) {
module.exports = global.document ? factory( global, true ) :
function( w ) {
if ( !w.document ) {
throw new Error( "jQuery requires a window with a document" );
}
return factory( w );
};
} else {
factory( global );
}
} )(
typeof window !== "undefined" ? window : this, function( window, noGlobal ) {
//具体代码
return jQuery;
}
把这个结构抽离出来,如下:
( function() { }) ( )
-
第一个括号有两个作用:
- 让js解析器把后面的function当作函数表达式而不是函数定义
- 形成一个作用域,类似在上面闭包例子中的f函数
-
第二个括号
- 触发函数并传参
2.1.2 定时器
接着是定时器相关的应用:
for( var i = 0; i < 5; i++ ) {
setTimeout(() => {
console.log( i );
}, 1000 * i)
}
这个代码的本意是要每隔1秒输出01234,但实际上它会每隔1秒输出5,因为for循环会很快执行完,i的值固定为5,但setTimeout是异步操作会被挂起,等到异步操作完成的时候,i已经是5,所以会输出5,利用闭包来改造一下:
for( var i = 0; i < 5; i++ ) {
((j) => {
setTimeout(() => {
console.log( j );
}, 1000 * j)
})(i)
}
setTimeout的父级作用域自执行函数中的j的值就会被记录,实现目标
2.1.3 DOM绑定事件
再来看个例子,如果需要给页面上多个div绑定点击事件时,一般是这样写:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>test</title>
</head>
<body>
<div>a</div>
<div>b</div>
<div>c</div>
<script>
function bindEvent(){
var letters = ['A','B','C'];
var elems = document.getElementsByTagName('div');
var len=elems.length;
for(var i=0; i<len; i++){
var letter = letters[i];
elems[i].addEventListener('click',function(){
alert(letter)
})
}
}
bindEvent()
</script>
</body>
</html>
但这样写会导致alert()的内容都是c,原因和上面差不多,所以需要保存每次循环的内容,所以可以这样来写:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>test</title>
</head>
<body>
<div>a</div>
<div>b</div>
<div>c</div>
<script>
function createFunction(letter){
return function(){
alert(letter);
}
}
function bindEvent(){
var letters = ['A','B','C'];
var elems = document.getElementsByTagName('div');
var len=elems.length;
for(var i=0; i<len; i++){
var letter = letters[i];
elems[i].onclick = createFunction(letter)
}
}
bindEvent()
</script>
</body>
</html>
2.2 内存泄露
上面说了什么是闭包以及闭包怎么用,那闭包会不会带来一些不好的影响呢?
答案是内存泄露,意思就是变量不被使用但还占用空间未被清除,对于局部的变量,它的生命周期是局部作用域被调用开始---局部作用域被调用完成,对于全局的变量,它的生命周期是整个应用结束,比如关闭浏览器,函数中的变量毫无疑问是局部变量,但是由于使用了闭包,所以它被全局的某处使用,导致js的垃圾回收机制并不会将它回收,在不注意的情况下就会造成内存泄露,在js中有两种垃圾回收的方法:
- 一种是标记回收,当局部作用域生效开始,就会把局部作用域的变量进行标记,等到局部作用域失效时,被标记的内容就会被清除
- 一种是引用回收,当进入作用域时,会在变量上添加引用计数,当同一个值被赋给另一个变量时计数加1,当该变量值修改时计数减1,如果在回收周期到来时,计数为0,则会被回收
js本身实现了垃圾自动回收,但是系统实际分配给浏览器的内存总量是有限的,如果因为闭包导致垃圾变量不被回收就会导致崩溃,具体看代码:
function f1(){
var n=999;
nAdd=function(){n+=1}
function f2(){
alert(n);
}
return f2;
}
var result=f1();
result(); // 999
nAdd();
result(); // 1000
result实际上就是闭包f2函数。它一共运行了两次,第一次的值是999,第二次的值是1000。这证明,函数f1中的局部变量n一直保存在内存中,并没有在f1调用后被自动清除,原因就在于f1是f2的父函数,而f2被赋给了一个全局变量,这导致f2始终在内存中,而f2的存在依赖于f1,因此f1也始终在内存中,不会在调用结束后,被垃圾回收机制(garbage collection)回收,那怎么解决呢? result = null;手动解除占用
function f1(){
var n=999;
nAdd=function(){n+=1}
function f2(){
alert(n);
}
return f2;
}
var result=f1();
result(); // 999
result = null;
nAdd();
result(); // 1000
3.类
3.1原生实现类功能
在js语言中原本是没有类这个概念的,但是随着业务复杂又需要编写面向对象的代码,那怎么办呢?创造一下,所以js就有了构造函数,原型对象,作用域链等一系列的概念,在其他高级语言中,类就是模板,具有对应的属性和方法,并且支持公有、私有,静态属性和方法等,一个类还必须满足封装继承和多态三大性质,这样的话根据类就能就能创造出实例对象了.
在js中默认存在nativeobject(Function,Date..),built-in object(Global/Math)和host object(DOM/BOM),这些实例对象直接就能使用,但js的强大在于自己定制的类和实例化的对象,所以这就是接下来写文的目的,如果自己来创造类的功能,你会怎么来做呢?
最开始想到的方法是Object,可以有属性和方法,能否用它来实现?来试一试:
function showColor() {
alert(this.color);
}
function createCar() {
var oTempCar = new Object;
oTempCar.color = "blue";
oTempCar.doors = 4;
oTempCar.mpg = 25;
oTempCar.showColor = showColor;
return oTempCar;
}
var oCar1 = createCar();
var oCar2 = createCar();
注意到上面除了object还用了一个function createCar,其实这是创建类的一种设计模式,叫做工厂模式,避免了重复去new object,同时内部的方法以属性的形式来进行关联,避免了每次调用工厂函数的时候重复生成对应的方法
3.2构造+原型实现类功能
会发现虽然上述的工厂函数实现了属性和方法的功能,但是属性和方法是分离开的啊,有没有办法解决呢?用构造函数+原型对象,构造函数本质上就是一个首字母大写的函数,只不过调用的时候是用new关键字来进行生成,原型对象是为了解决类的方法重复创建的问题,所以将方法保存在原型对象中,然后在调用时沿着作用域链去寻找,那如何把方法绑定在原型对象上呢?每个构造函数都可以通过prototype找到原型对象,具体看代码,
function Car(sColor,iDoors,iMpg) {
this.color = sColor;
this.doors = iDoors;
this.mpg = iMpg;
this.drivers = new Array("Mike","John");
}
Car.prototype.showColor = function() {
alert(this.color);
};
var oCar1 = new Car("red",4,23);
var oCar2 = new Car("blue",3,25);
oCar1.drivers.push("Bill");
alert(oCar1.drivers); //输出 "Mike,John,Bill"
alert(oCar2.drivers); //输出 "Mike,John"
会发现在构造函数内没有创建对象,而是使用 this 关键字,新建实例时使用new 运算符,那他们都干了啥?
1.new先新建了个空对象,就像在刚才的工厂函数中new Object()一样,怎么证明呢?在控制台测试一下
var Test = function(){}
console.log(typeof Test)//function
var test = new Test()
console.log(typeof test)//object
会发现经过new后test的类型变成了object
2.接着Car.__proto__=car.prototype,将实例的原型对象指向构造函数的原型对象,为什么这么做呢,因为在工厂函数中我们给对象添加方法是直接通过oTempCar.showColor = showColor;,但通过构造+原型的方式来进行添加函数时,函数是被放在构造函数的原型对象里的,这是为了在调用时避免重复生成方法,所以实例对象要想访问到构造函数的方法,就必须要将自己的原型对象指向构造函数的原型对象,此时就可以访问到对应的方法了
3.再接着car.call(Car),把this指向当前的对象,这是因为在普通函里,this指向的是全局,只有进行修改后才能指向当前对象,这样的话就能像工厂函数那样的进行属性赋值了
这样说太抽象,做了张图大家看看:
前面曾经说过this需要进行绑定,因为在不同的作用域下this所指代的内容是不同的,所以在这里看一下this到底会指向什么东西
首先是全局作用域下的this:
console.log(this)
//Window {postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, parent: Window, …}
接着是对象内的this:
var obj = {
user:"kk",
a:function(){
console.log(this.user)
},
b: {
user: "gg",
fn:function(){
console.log(this.user);
}
}
}
obj.a();//kk
obj.b.fn();//gg
再来是函数内的this:
var a = 1;
function test(){
console.log(this.a)
}
test();//1
还有构造函数中的this:
function Main(){
this.def = function(){
console.log(this === main);
};
}
Main.prototype.foo = function(){
console.log(this === main);
}
var main = new Main();
main.def(); //true
main.foo();//true
得出了什么结论呢?this永远指向最后调用他的对象
3.3类的使用案例
前面说了这么多的类的创建,实战一下,看看学这么多到底有什么用?
1.字符串连接的性能,要先来知道一下,ECMAScript 的字符串是不可变的,要想对它做修改,必须经过以下的几个步骤:
var str = "hello ";
str += "world";
- 创建存储 "hello " 的字符串。
- 创建存储 "world" 的字符串。
- 创建存储连接结果的字符串。
- 把 str 的当前内容复制到结果中。
- 把 "world" 复制到结果中。
- 更新 str,使它指向结果。
如果代码汇中只有几次字符串拼接,那还没什么影响,但如果有几千次几万次呢,上面这些流程在每修改一次的时候就会执行一遍,非常的耗费性能,解决方法是用 Array 对象存储字符串,然后用 join() 方法(参数是空字符串)创建最后的字符串,把它直接封装成类来使用:
function StringBuffer () {
this._strings_ = new Array();
}
StringBuffer.prototype.append = function(str) {
this._strings_.push(str);
};
StringBuffer.prototype.toString = function() {
return this._strings_.join("");
};
封装好了,可以来对比一下传统的字符串拼接和我们封装的这种类之间的性能差异:
<html>
<body>
<script type="text/javascript">
function StringBuffer () {
this._strings_ = new Array();
}
StringBuffer.prototype.append = function(str) {
this._strings_.push(str);
};
StringBuffer.prototype.toString = function() {
return this._strings_.join("");
};
var d1 = new Date();
var str = "";
for (var i=0; i < 1000000; i++) {
str += "text";
}
var d2 = new Date();
document.write("Concatenation with plus: "
+ (d2.getTime() - d1.getTime()) + " milliseconds");
var buffer = new StringBuffer();
d1 = new Date();
for (var i=0; i < 1000000; i++) {
buffer.append("text");
}
var result = buffer.toString();
d2 = new Date();
document.write("<br />Concatenation with StringBuffer: "
+ (d2.getTime() - d1.getTime()) + " milliseconds");
</script>
</body>
</html>
下面是两者进行1百万次操作的耗时对比
Concatenation with plus: 568 milliseconds
Concatenation with StringBuffer: 388 milliseconds
3.4对象冒充继承
上面已经实现了js中类的创建,下一步要解决是类的继承,最常用的有对象冒充继承,原型链继承和混合继承
首先说对象冒充继承,本质就是把父类作为子类的一个方法,然后来调用它,具体看代码:
function ClassA(sColor) {
this.color = sColor;
this.sayColor = function () {
alert(this.color);
};
}
function ClassB(sColor, sName) {
this.newMethod = ClassA;
this.newMethod(sColor);
delete this.newMethod;
this.name = sName;
this.sayName = function () {
alert(this.name);
};
}
var objA = new ClassA("blue");
var objB = new ClassB("red", "John");
objA.sayColor(); //输出 "blue"
objB.sayColor(); //输出 "red"
objB.sayName(); //输出 "John"
父类作为子类的一个方法时当调用这个方法实际上父类的属性和方法就被子类继承了,同时我们还会发现delete this.newMethod;这句话,这是避免子类中新拓展的属性或者方法覆盖掉父类的属性方法,经过这样的冒用,就实现了子类的继承,同时这种方法还可以实现多重继承,也就是一个子类继承多个父类,da但是,这样继承的父类中若果有重复的属性或者方法,会按照继承顺序来确定优先级,后继承的优先级高,具体看代码:
function ClassZ() {
this.newMethod = ClassX;
this.newMethod();
delete this.newMethod;
this.newMethod = ClassY;
this.newMethod();
delete this.newMethod;
}
这种继承方法非常的流行,以至于官方后来扩展了call()和apply()来简化上面的操作,call()第一个参数就是子类,第二个参数就是需要传递的参数[字符串],而apply()和call()的区别是,apply接受的参数形式为数组
//call
function ClassA(sColor) {
this.color = sColor;
this.sayColor = function () {
alert(this.color);
};
}
function ClassB(sColor, sName) {
ClassA.call(this, sColor);
this.name = sName;
this.sayName = function () {
alert(this.name);
};
}
//apply
function ClassA(sColor) {
this.color = sColor;
this.sayColor = function () {
alert(this.color);
};
}
function ClassB(sColor, sName) {
ClassA.apply(this, new Array(sColor));
this.name = sName;
this.sayName = function () {
alert(this.name);
};
}
做了张图,大家看看:
3.5原型链继承
除了对象冒充继承,还可以使用原型链继承,原理是原型链最终会指向原型对象,换句话说,原型对象上的属性方法能被对象实例访问到,利用这个特性就可以实现继承,怎么做呢?ClassB.prototype = new ClassA();搞定,但要记住,子类的所有新属性和方法必须写在这句话后面,因为此时子类的原型对象实际上已经是A的实例所指向的原型对象,如果写在这句话前面,那新属性和方法就被挂载到了B的原型对象上去了,经过这句话赋值,那挂载的内容就相当于全被删了,切记切记,还有一点要知道,原型链继承并不能实现多重继承,这是因为原型对象只有一个,采用A的就不能用B的,否则就相当于把前一个删了。
function ClassA() {
}
ClassA.prototype.color = "blue";
ClassA.prototype.sayColor = function () {
alert(this.color);
};
function ClassB() {
}
ClassB.prototype = new ClassA();
ClassB.prototype.name = "";
ClassB.prototype.sayName = function () {
alert(this.name);
};
var objA = new ClassA();
var objB = new ClassB();
objA.color = "blue";
objB.color = "red";
objB.name = "John";
objA.sayColor();
objB.sayColor();
objB.sayName();
ClassB.prototype = new ClassA();是最重要的,它将ClassB 的 prototype 属性设置成 ClassA 的实例,获得了ClassA 的所有属性和方法
3.6混合继承
对象冒充的主要问题是必须使用构造函数方式,使用原型链,就无法使用带参数的构造函数了,所以可以将两者结合起来:
function ClassA(sColor) {
this.color = sColor;
}
ClassA.prototype.sayColor = function () {
alert(this.color);
};
function ClassB(sColor, sName) {
ClassA.call(this, sColor);
this.name = sName;
}
ClassB.prototype = new ClassA();
ClassB.prototype.sayName = function () {
alert(this.name);
};
var objA = new ClassA("blue");
var objB = new ClassB("red", "John");
objA.sayColor(); //输出 "blue"
objB.sayColor(); //输出 "red"
objB.sayName(); //输出 "John"
3.7多态
一个预语言能使用类这个功能,说明它至少满足了类的三个特点,封装,继承和多态,前面说过了封装和继承,现在来说一下多态,多态:同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果。看了以后感觉很抽象,老办法,举例子,某人家里养了一只鸡,一只鸭,当主人向他们发出‘叫’的命令时。鸭子会嘎嘎的叫,而鸡会咯咯的叫,转换成代码如下:
var makeSound = function(animal) {
animal.sound();
}
var Duck = function(){}
Duck.prototype.sound = function() {
console.log('嘎嘎嘎')
}
var Chiken = function() {};
Chiken.prototype.sound = function() {
console.log('咯咯咯')
}
makeSound(new Chicken());
makeSound(new Duck());
JavaScript中大多是通过子类重写父类方法的方式实现多态,具体看代码:
//使用es6 class简化代码
class Parent {
sayName() {
console.log('Parent');
}
}
class Child extends Parent{
sayName() {
console.log('Child');
}
}
function sayAge(object) {
if ( object instanceof Child ){
console.log( '10' );
}else if ( object instanceof Parent ){
console.log( '30' );
}
}
sayAge(child); // '10'
sayAge(parent); // '30'
很好玩,通过相同的操作但却得到了不同的结果,这个就是多态,这里以后再深入学习后会再补充的,留坑
3.8私有/静态属性和方法
我们前面写的类的属性和方法都是公有的,但其实一个真正的类是包含只提供内部使用的私有属性方法和只提供类本身使用的静态属性和方法,接下来就一一实现一下:
首先是静态属性和方法,这个实现很简单,直接在类中添加就好了
function Person(name) {
}
//添加静态属性
Person.mouth = 1;
//添加静态方法
Person.cry = function() {
alert('Wa wa wa …');
};
var me = new Person('Zhangsan');
me.cry(); //Uncaught TypeError: me.cry is not a function
接着是私有属性和方法,其中私有方法又叫特权方法,它既可以访问共有变量又可以访问私有变量:
function Person(name) {
//公有变量
this.name = name;
//私有变量
let privateValue = 1;
//私有方法
let privateFunc = function(){
console.log(this.name,privateValue)
};
privateFunc()
}
console.log(new Persion('kk'))
3.9ES6类的创建继承
前面说了这么多才把js的类实现好,但每次写代码都要这么麻烦么?幸好ed6中已经将刚才所说的内容封装好了,也就是常说的class和extends,大家叫他们是语法糖,实际原理就是上面讲的内容,那来看看到底怎么用es6来实现类的创建与继承
首先是创建:
class Animal{
constructor(name){
this.name = name;
};
sayNmae(){
console.log(this.name)
}
}
let animal = new Animal('小狗');
console.log(animal.name);
animal.sayNmae('小汪')
会发现多了一些关键字class和constructor,并且方法也写在了类里面,其中class和原来的function对比来看,说明在使用时只能有new这一种调用方式,而不是像以前一样技能当构造函数又能当普通函数,constructor和原来的this差不多都是指向了当前的对象做完了就把对象返回
接着是继承:
class Dog extends Animal{
constructor(name,type){
super(name);
this.type = type
}
sound(content){
console.log(content);
}
}
let dog = new Dog('小狗','aaa');
console.log(dog.name)
dog.sayNmae()
console.log(dog.type)
dog.sound('汪汪汪')
同样发现多了一些关键字extends和super(),其中extends相当于原来的Parent.apply(this),super相当于原来的ClassB.prototype = new ClassA();,也就是指向存放属性和方法的原型对象
ok,至此,关于类的内容告一段落,其实还有很多内容可以说,比如设计模式,但它包含的内容太多了,以后单独开一篇来说。
4.异步
4.1回调函数
js是单线程语言,所以出现了耗时的操作时候,脚本会被卡死,这就需要处理异步的操作的机制,在最开始,js处理异步的方法是采用回调函数,比如下面这个例子:
function test(){
setTimeout(() => {
console.log('a')
},2000)
}
test()
console.log('b')
期望的结果是先a后b,但打印的结果是先b后a如何解决呢?
function test(f){
setTimeout(() => {
console.log('a')
f()
},2000)
}
test(() => {
console.log('b')
})
确实达到了目的,但是如果需要嵌套的层数特别多的时候会导致地狱回调,不利于代码维护,所以es6提出了promise来解决这个问题
4.2Promise
function test(){
return new Promise((resolve,reject) => {
setTimeout(() => {
console.log('a');
resolve()
},2000)
})
}
test()
.then(() => {
return new Promise((resolve,reject) => {
setTimeout(()=> {
let a = 1;
if(a){
reject()
}else{
console.log('b');
resolve();
}
})
},1000)
})
.then(() => {
console.log('c')
}).catch((err) => {
console.log('error')
})
4.3async/await
通过这样的方法确实实现了操作并且将逻辑拆开了避免了callback hell,但是这样写还是不舒服,看着很难受,所以可以用async、await来进行书写:
function a(){
setTimeout(() => {
console.log('a')
},2000)
}
function b(){
setTimeout(() => {
console.log('b')
},1000)
}
async function test(){
try {
await a();
await b();
}catch(ex){
console.log('error')
}
}
test()
ok,完美解决
5.模块化
5.1原生模块化
首先说一下,为什么需要模块化,在es6之前,如果有多个文件,文件彼此之间相互依赖,最简单的就是后一个文件要调用前一个文件的变量,怎么做呢?前一个文件就会将该变量绑定在window顶层对象上暴露出去,这样做确实达到了目的,但是同时也带来了新的问题,如果一个项目是多人开发的,其他人不知道你到底定义了什么内容,很有可能会把原先你定义好的变量给覆盖掉,这是第一个致命,的地方除此以外,当自己写了一个模块,在导入的时候,有可能因为模块文件过大导致加载速度很慢,这是第二个致命的地方,前面两点在开发时定好开发的规范,尽量拆分模块为单一的体积小的内容还是可以解决的,但是还有一点就是模块之间的加载顺序,如果调用在前而加载在后,那肯定会报错,这是第三个致命的地方,并且这种出错还不好排查
为了解决这些问题,先后有很多的模块化规范被提出,那想想,一个良好的模块应该是什么样的?总结了一下,应该具有:
- 1.保证不与其他模块发生变量名冲突
- 2.只暴露特定的模块成员
- 3.模块与模块之间语义分明
- 4.支持异步加载
- 5.模块加载顺序不会影响调用
5.2浏览器模块化AMD
首先是AMD(Asynchronous Module Definition),它是专门为浏览器中JavaScript环境设计的规范,使用方法如下:
1.新建html引入requirejs并通过data-main="main.js"指定主模块
//index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>requirejs</title>
</head>
<body>
<script src="https://cdn.bootcss.com/require.js/2.3.6/require.js" defer async="true" data-main="main.js"></script>
</script>
</body>
</html>
2.接着在主模块中加载需要用到的其他模块,比如math.js,加载模块固定使用require(),第一个参数是个数组指定加载的模块,第二个是个回调函数,当加载完成后具体的执行就在这里
//main.js
require(['math'], function (math){
alert(math.foo());
});
3.被引用的模块写在define函数中,如果还有引用的模块,就把第一个参数写成数组来调用
//math.js
define(['num'], function(num){
function foo(){
return num.number();
}
return {
foo : foo
};
});
//num.js
define(function (){
var number = function (){
var a = 5;
return a;
};
return {
number: number
};
});
好多自己以前写的模块并没有使用define来定义,所以并不支持AMD的规范,那如何来加载这些内容呢?可以通过require.config({ })来进行加载:
//main.js
require.config({
paths:{
'NotAmd':'./jutily'
},
shim:{
'NotAmd':{
exports:'NotAmd'
}
}
});
require(['math','NotAmd'], function (math){
alert(math.foo());
console.log(NotAmd())
});
//jutily.js
(function(global) {
global.NotAmd = function() {
return 'c, not amd module';
}
})(window);
5.3ES6模块化
不管是AMD还是CMD,说到底它们都是加载的外来模块实现js代码的规范,但这样写也太麻烦了,于是es6中本身就开始支持模块化了,具体如下:
//export.js
let myName="laowang";
let myAge=90;
let myfn=function(){
return "我是"+myName+"!今年"+myAge+"岁了"
}
export {
myName,
myAge,
myfn
}
//export default= {
myName,
myAge,
myfn
}
import {myfn,myAge,myName} from "./export.js";
//import * as info from "./export.js";
console.log(myfn());//我是laowang!今年90岁了
console.log(myAge);//90
console.log(myName);//laowang
发现上面有个export和export default 两者的区别是前者可以出现多次后者只能出现一次,可以混合出现这两种导出方式
5.4commonjs模块化
前面的规范适用于浏览器端的js编程,但是现在的js早已经不再局限在浏览器了,在服务端同样也能使用,这就需要在服务端也实现js的模块化,这就是commonjs,具体使用如下:
//export.js
var x = 5;
var addX = function (value) {
return value + x;
};
exports.x = x;
module.exports.addX = addX;
var example = require('./example.js');
console.log(example.x); // 5
console.log(example.addX(1)); // 6
发现有exports和module.exports,他们的区别是什么呢?其实两者差不多,但是如果要导出的是函数的时候就写在module.exports上
重点要理解一下require的内容,它的大概原理是:
- 检查 Module._cache,是否缓存之中有指定模块
- 缓存之中没有,就创建一个新的Module实例
- 把它保存到缓存
- 使用 module.load() 加载指定的模块文件,读取文件内容之后,使用 module.compile() 执行文件代码
- 如果加载/解析过程报错,就从缓存删除该模块
- 返回该模块的 module.exports
参考文章:
1.Babel是如何编译JS代码的及理解抽象语法树(AST):https://www.cnblogs.com/tugen...
2.Babel是如何读懂JS代码的:
https://zhuanlan.zhihu.com/p/...
3.用 Chrome 开发者工具分析 javascript 的内存回收(GC)
https://www.oschina.net/quest...
4.ECMAScript 定义类或对象:
http://www.w3school.com.cn/js...
5.ECMAScript 继承机制实现:
http://www.w3school.com.cn/js...
6.js 多态如何理解,最好能有个例子
https://segmentfault.com/q/10...
7.Javascript模块化编程(一):模块的写法:
http://www.ruanyifeng.com/blo...
8.Javascript模块化编程(二):AMD规范:
http://www.ruanyifeng.com/blo...
9.Javascript模块化编程(三):require.js的用法
http://www.ruanyifeng.com/blo...
10.CommonJS规范
http://javascript.ruanyifeng....
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。