JS基础
js介绍
一门脚本语言
组成
-
ECMAScript
- 简称ES,ES5,ES6
- js语法规范
-
DOM
- 文档对象模型
- 操作页面内容
-
BOM
- 浏览器对象模型
- 操作浏览器功能
js基础写法
三种书写方式
-
内联
- 写在标签里面
-
外联
- js独立出来的文件
- script配合src属性导入
- 注意:如果写了外联,那么在
script
标签里,就不要写其他JS代码,因为写了也没用
-
行内(了解)
- 点我,告诉你黑马最帅的男人
js注释
-
// 单行
-
/* */ 多行注释
- vs code 快捷键:shift+alt+a
js输入输出语句
-
alert(’ 提示内容 ')
- 弹出一个提示框
-
prompt(‘提示用户输入内容’)
- 弹出一个输入框
-
confrim(’ 提示用户内容’)
- 弹出一个确认框(使用少)
js结束语
-
;
- 英语分号
-
什么也不写
- 需要单独一行
变量声明,赋值,使用
什么是变量?
- 可以理解为是一个装数据的容器
变量的声明
- let 变量名
- eg: let name
变量的赋值
- 变量名=数据
- eg:name=‘刘德华’
变量的使用
-
name
- 直接写变量名
- eg: alert(name)
- 注:变量名你要加引号
变量初始化
-
在声明变量时就赋值
- let name=‘刘德华’
变量的本质
- 就是在内存中开辟一个空间,存放数据
变量细节补充
-
同时声明多个变量并赋值
- 需要用逗号隔开
- eg:let name = ‘jack’,age = 16
-
如果变量只声明不赋值,那么值是undefined
- 风格let name alert(name) // undefined let name, age = 16 // 相当于写的是 let name let age = 16 alert(name) // undefined alert(age) // 16
-
如果一个变量不声明,但是直接复制了,可以用,但是不推荐
- age = 30 alert(age)
变量命名规则跟规范
-
规则
- 不能用关键字
- 变量名只能由字母,数字,下划线,美元符号($)组成,且数字不能开头
-
规范
-
起名要有意义,变量的名字跟保存的数据有关
-
要用驼峰命名法
-
如果一个变量名由多个单词组成吗,那么第一个单词首字母小写,后面每个单词首字母大写
- 例:userName、 userLoginName
-
-
算术运算符
-
+加
-
-减
-
*乘
-
/ 除
-
% 取余
-
优先级
- 先乘除后加减,有括号的先算括号里面的,同级就从左往右依次运算
document.write
-
给 body 内增加内容
-
如果内容带标签,会解析成html元素
- document.write(‘
哈哈哈,我出现了
’)
- document.write(‘
console.log
- 它主要是给程序员自己调试数据用的
- 在浏览器的f12 -> Console 来显示
- 在控制台中,如果数据是字符串,则颜色为黑色
转义符
-
符号:\ 可以把右边的符号转换原来的意义
-
有
- \n`: 换行
\'
: 输出单引号\"
: 输出双引号\\
: 输出\- …
模板字符串
-
反引号
-
好处
- 内容怎么写的,它就是怎么展示的
- 如果想输出变量的值,不用再拼接字符串,写${变量名} 自动填充在里面
数据类型
数据类型
-
Numbr
- 数值型
- 写法:直接写数字,例如:10,11,12, 10.32, 11.2, -0.3
-
String
- 字符串型,用来表示文字的
- 写法:用单引号或双引号或反引号包起来的都是字符串
-
Boolean
- 布尔类型
- 这种类型只有两个数据: true 与 false
-
undefined
- 代表未定义
- 只有一个值就叫undefined
检测数据类型
-
typeof 检测基本数据类型
- 检测数据类型是什么
- 用法:typeof 数据 typeof 变量名
-
instanceof检测复杂数据类型
-
可以判断一个数据是复杂数据类型的哪种
-
用法:
数据 instanceof 构造函数
数据 instanceof Array // 判断是否为数组
数据 instanceof Function // 判断是否为函数
数据 instanceof Date // 判断是否为日期对象
数据 instanceof Object // 判断是否为对象(所有复杂数据类型本质都是对象) -
如果是这个类型得到true,不是得到false
-
数据类型转换
-
介绍
- 把一种数据类型转换为另外一种数据类型
-
隐式转换
- 不用额外写代码、程序自己根据一定的规则来转换成某种类型
+
两边如果有一个是字符串,会把另外一个非字符串的也自动转换成字符串- 除了
+
以外的所有算术运算符(-、*、/、%) 都会把别的类型转换成数字类型,如果无法得到数字的,会得到NaN - NaN:Not a Number 代表非数字,它也是number类型(数字类型)
- 它做任意数学运算得到的结果也一定是NaN-只要是数学运算都是转换成数字,但是如果是拼接就是转换成字符串
- -缺点:需要记住这些规则,不太明显,增加了代码阅读难度
-
显示转换
-
转成字符串
-
String(数据)
-
数据.toString()
- toString 最好用在变量,否则会报错
-
-
转成数字类型
-
Number(数据)
- 整数小数都能转换
- 只要有一个是非数字,得到的都是NaN,两边有空格无所谓,中间有就不行
-
parseInt(数据)
- 转换成整数,只会的到整数部分
- 从左往右依次转换,遇到非数字停止,有几个数字转换几个,若一个都没有得到NaN
-
parseFloat(数据)
- 转换成小数或整数(数字是什么就的到什么)
- 原理效果跟parseInt一样的,也是从左往右依次转换
-
快速转
- 直接在数据前面加+
-
利用隐式转换
- 数据 - 0 或 数据 * 1 或者 数据 / 1
-
补充
- 0不能作为除数,就是 / 右边的数,其他语言里会报错,JS里得到Infinity
- Infinity代表无穷大,-Infinity代表无穷小
-
-
自增和自减
自增
-
符号:++
- 就是让自己+1
自减
-
符号:–
-
就是让自己-1
-
如果不参与运算,++(–)写在前或者写在后都一样
-
如果参与运算前缀和后缀就有区别:
-前缀:先自增(自减),再用自增(自减)后的值参与运算
后缀:先用原值参与运算,再自增(自减)
比较运算符
-
>
:判断左边是否大于右边 -
<
:判断左边是否小于右边 -
>=
:判断左边是否大于或等于右边 -
<=
:判断左边是否小于或等于右边 -
==
:判断左右两边是否相等,只判断值相等,不判断类型 -
===
:判断左右两边是否全等,既要值相等,也要类型相等 -
!=
:判断左右两边是否不等 -
得到的结果是布尔类型,也就是得到true或false
-
一定要区分:赋值就是一个
=
不要写成==
,除非你要判断值是否相等才写成==
逻辑运算符
&&:逻辑与
- 左右两边都是true,结果才是true,有一个是false结果就是false
- 口诀:一假则假
| | :逻辑或
- 左右两边有一个是true结果就是true,两个是false结果才是false
- -口诀:一真则真
! :逻辑非
- true变false,false变true
- 真变假,假变真
- 它还有把别的数据类型转成布尔类型的特点
转换成布尔类型
- Boolean(数据)
- 只有0、空字符串、NaN、undefined、null转成false,其他都是true
转换成布尔类型
- Boolean(数据)
- 只有0、空字符串、NaN、undefined、null转成false,其他都是true
逻辑运算中的短路
- 指的不执行右边的式子
- 只存在于 && 和 || 中有短路
- 当左边能确定整个式子结果,就没必要看右边了,所以发生了短路的现象(不执行右边的式子)
- &&什么时候短路:在左边式子为false时短路
- | | 什么时候短路: 在左边式子为true的时候短路
逻辑运算中的短路
- 指的不执行右边的式子
- 只存在于 && 和 || 中有短路
- 当左边能确定整个式子结果,就没必要看右边了,所以发生了短路的现象(不执行右边的式子)
- &&什么时候短路:在左边式子为false时短路
- | | 什么时候短路: 在左边式子为true的时候短路
赋值运算符
=
- 代表把右边的值赋值给左边变量
- 他不是判断相等,判断相等是
==
+=
- 在自己值的基础上再+一个值
-=
- 在自己值的基础上再-一个值
*=
- 在自己值的基础上再*一个值
/=
- 在自己值的基础上再/一个值
%=
- 在自己值的基础上再%一个值
程序结构
顺序结构
-
程序从上往下依次执行
-
程序默认就是顺序结构
-
语法:if(条件) { 代码 }
- 当 if 小括号里的 条件 为 true 时,则执行大括号里的代码,如果为false就不执行大括号里的代码
分支结构
-
分支语句之 if-else
-
语法:if(条件){ 代码 } else { 代码 }
- 如果条件满足,执行代码1,不满足就执行代码2
- 所以也就是说代码1和代码2,只会选择一个来执行
-
这也可以称之为
双分支
语句
-
-
分支语句之if - else if - else
- 语法:if(条件1){ 代码1 } else if(条件2){ 代码2 } … else { 代码 }
- 先判断条件1,如果为true则执行代码1,如果为false则继续往下判断条件2,为true则执行代码2,为false则继续往下判断条件3,为true执行代码3,以此类推,如果上面条件都不满足就只执行else里的代码n
-
分支语句之switch
- switch也是多分支语句
- 语法:switch (数据) {case 值1: 代码1 break; case 值2: 代码2 break; … default: 代码n break;
循环结构
-
whilex循环
-
语法:while(循环条件) { 循环体 }
-
循环体:就是要重复执行的代码
-
语义: 判断循环条件是否为true,如果为true则执行循环体,否则跳出while循环
- 如果为true时执行完循环体,会又回到while的循环条件的位置,继续判断是否为true,以此类推
-
一般情况下,我们需要一个变量来控制循环的次数,这个变量叫循环增量,所以我们一般会这样写
- eg:let i = 0 while (i < 次数) { 循环体 i++ }
- 想执行几次,就在次数那写几
- 切记:一定要写i++,因为如果不写会导致无限循环,这种我们称之为死循环
-
-
do-while循环
- 语法:do { 循环体 }while( 循环条件)
- 先执行循环体,然后再判断循环条件,如果条件为true,回来继续执行循环体,那么如果为false,就跳出循环
- do-while循环的循环体至少会执行1次
- 如果某个循环体,至少要执行1次的,那么就用do-while
-
for循环
-
语法: for(声明循环变量;循环条件;变量++ ) { 循环体 }
-
执行过程:
- 先执行声明循环变量,再判断循环条件是否为true,为true就执行循环体,为false就直接跳出循环
- 为true执行完循环体,会回到变量++的位置来做变量自增,自增完了再来判断循环条件,依次类推
-
break和continue
-
break
- 结束所在的switch语句
- 结束所在的循环
-
continue:
- 只能用在循环
- 结束当次循环,继续下次循环
循环嵌套
- 循环里面再写一个循环就叫循环的嵌套
相等的一些细节
-
undefined == null 得到true
-
undefined === null 得到false
-
NaN 不等于任何数据,包括它自己
-
NaN == NaN 永远都是false
-
那么如何判断是否为NaN?
-
isNaN(数据)
-
如果是NaN得到true
-
否则得到false
数组
数组语法
-
数组初始化
- let 数组名 = [ 数据列表 ]
- 数据列表可以写任意个数据,如果多个数据用逗号隔开
-
名词
- 元素:数组里保存的每个数据都叫数组元素
- 下标/索引:每个数据的编号,从0开始
- 长度:数组中数据的个数
-
单独取出某个数组里的元素,该怎么取?
-
用下标取
-
语法: 数组名[下标]
- eg: nums[0]
-
-
数组赋值
-
也是通过下标重新赋值
-
语法是:数组名[下标] = 数据
- eg : nums[0] = 999
-
-
数组的最大下标 = 长度 - 1
数组长度
-
数组名.length
-
数组长度可以更改
-
数组名.length++
- 则是在最后多一个元素,用undefined补齐
-
数组名.length–
- 则相当于删除数组最后一个元素
-
数组长度直接赋值,如果赋的值比原来的长度要大,相当于增加,增加部分用undefined补齐
-
数组长度直接复制,如果赋的值比原来的长度要小,相当于删除,减少几个就删除后面几个
-
遍历数组
- 把数组中每个元素给取出来
- 语法:for (let i = 0; i < 数组.length; i++){ 数组[i] }
动态添加数组
- 可以对数组原本不存在的下标进行赋值,那么它就会在这个下标位置增加数据
- 如果这个下标跟原本存数据的下标存在一段距离,那么这段距离会用undefined补齐
- eg: 例:一个数组为let nums = [10,20, 30] 下标只到2, 但是如果我 nums[8] = 999 ,那么在下标8会多一个999,然后在下标3到下标7用undefined补齐
按顺序添加数组语法
- 数组名[数组名.length] = 数据
- 例:nums[nums.length] = 60
数组内置的方法
-
数组本质也是一种对象,所以也有属性和方法
-
属性
- 数组.length
-
方法:
-
reverse() :反转数组
-
push()
- 在数组末尾添加元素
- 也可以一次性添加多个,用逗号隔开
- 返回值是新长度
-
pop()
- 删除数组末尾的元素(一次只能删一个)
- 返回值就是被删除的元素
-
unshift()
- 在数组第一个位置添加元素
- 也可以一次性添加多个,用逗号隔开
- 返回值是新长度
-
shift()
- 删除数组的第一个元素
- 返回值是被删除的元素
-
join()
- 把数组中每个元素用一个符号连接起来
- 如果什么都不传,默认是逗号隔开
- 如果传空字符串,那么每月任何隔开符,如果传空格字符串,用空格隔开
- 返回一个字符串
-
数组方法
-
数组排序方法
-
sort()
-
默认是先比较第一位,再比较第二位,依次类推
-
如果比较字符,它会先把字符根据ASCII码转整数,小的前面,大的后面
-
按数字大小从小到大排列就传入函数
-
eg:
数组.sort( function (a,b) {
return a - b
} )
-
-
按数字大小从大到小排列就传入函数
-
eg:
数组.sort( function (a,b) {
return b - a
} )
-
-
-
-
数组splice
-
删除
-
splice(从哪个下标开始,删除几个)
- splice(2,3) 代表下标2开始删,删除3个
-
-
替换
-
splice(从哪个下标开始,找几个,替换成什么)
- splice(2,3,300) 下标2开始一共找3个,都只替换成一个300
- 替换多个,也是逗号隔开
-
-
新增
- splice(新增到哪个下标,0, 新增的内容)
- 如果要新增多个,则逗号隔开
-
-
数组indexOf和lastIndexOf
-
indexOf(数据)
- 从前往后找匹配的数据,返回找到的第一个的下标
- 如果不存在得到-1
-
lastIndexOf(数据)
- 从后往前找匹配的数据
- 不存在也得到-1
-
主要作用:判断数据在不在数组里面,如果不在得到-1,在就不等于-1
-
字符串的内置方法
-
indexOf和lastIndexOf
- 跟数组一样的效果
- 但是如果传入空字符串,则永远得到0
-
字符串不可改!
- 所谓的不可改是它的内容不可改,不是说变量不能重新赋值
- eg:
-
spilt 切割
-
把字符串按照某个符号切割为数组
-
eg:
let str = ‘刘德华|张学友|郭富城|黎明’
// 我需要把字符串转成数组
let arr = str.split(’|’) // 按竖线分割字符串变成数组
console.log(arr) -
如果传入不存在的字符,或者没传任何参数,那么会把字符串当做一个整体元素
-
如果传入空字符串,会把字符串的每个内容都当做一个元素
-
-
replace 替换
- 替换字符串
- 参数1:被替换的内容
- 参数2:替换的新内容
- 只会得到新的结果,不会直接改变原来的值
-
toUpperCase() 转大写
-
toLowerCase() 转小写
-
trim () 取出两边的空格
arguments伪数组
- arguments是一个伪数组,它里面保存了调用函数时传递过来的所有实参
- arguments只能用在函数里
- arguments作用:就是可以让函数的扩展性更强,因为可以让它传入任意个参数,我们都可以拿到并处理
函数
函数介绍
- 就是一种把代码封装起来的语法
- 作用:提高代码复用,便于维护与解决代码冗余
函数的基本使用
-
声明
-
function声明
- 语法1: function 函数名 () { 函数体 }
- 用
function
声明的函数,可以在声明之前调用
-
表达式声明
-
语法:let 变量名 = function 函数名 () { 函数体 }
-
如果用这种方式声明的函数,不能再它声明之前调用
-
-
-
调用语法:函数名 ()
-
注:函数只声明不调用,里面代码不会被执行
有参数的函数
- 有的时候函数完成某个功能需要外界传入数据,这时候就需要有参数的函数
- 语法:function 函数名 ( 参数列表) { 函数体 }
- 参数列表,就是定义需要几个数据,多个之间用逗号隔开
形参和实参
-
形参
- 声明函数时写在小括号里的叫形参
-
实参
- 调用函数时写在小括号里的叫实参
有返回值的函数
-
为什么需要?
- 因为调用函数时会得到一个结果,而这个结果是调用者想要得到的,所以应该把这个结果返回给调用者
-
语法: function 函数名 ( 参数列表) { 函数体 return 数据 }
-
可以返回变量值,也可以直接返回数据
return关键词
- return后面可以直接写数据,也可以写变量(代表取出变量的值返回),也可以写表达式(算出表达式的结果再返回)
- return有立即结束函数的作用,所以return后面的代码不会被执行
- return后面也可以不加任何数据,也有返回值,只不过返回值是undefined
- 函数内也可以不写return,只不过这个时候函数的返回值就是undefined
注意
- 函数要想被调用,必须加小括号:函数名()
- 换句话说,只要加了小括号,都代表调用了这个函数
- 如果只是写函数名,不加小括号,不是调用,它只是代表找到这个函数里保存的代码
构造函数
工厂函数
-
本质就是封装代码
-
封装创建对象的代码,因为我们发现每次创建同一种类型的对象代码都一样,所以可以封装成函数
-
那么这种函数就叫工厂函数
-
特点:函数内自己创建对象,自己返回对象,调用时不用写new
-
eg:
function factory(name, age, sex) {
// 现在我要创建2个对象,都有name、age、sex属性,和吃饭的行为
let p = {}// 左边是属性名,右边是变量名,到时候会取出变量名的值作为属性值
p.name = name
p.age = age
p.sex = sex
p.eat = function () {
console.log(‘吃啊吃啊,我骄傲放纵’)
}
return p
}
构造函数
-
实际上构造函数就是对工厂函数的升级
-
升级在:不用我们自己创建对象,也不用我们自己返回对象,利用this关键字访问到构造函数帮我们创建的对
-
注意:构造函数的函数名首字母大写,尽量用名词
-
eg:
function Person(name, age, sex) {
// 现在这个对象是构造函数帮我们创建的,所以不叫p了
// 访问创建的对象用this
this.name = name
this.age = age
this.sex = sex
this.eat = function () {
console.log(‘吃啊吃啊,我骄傲放纵’)
}
}let p1 = new Person(‘jack’,16,‘男’)
-
new关键字做的三件事:
-
- 创建了一个新的空对象
-
- 把函数内的this指向到这个新的空对象
-
- 在函数结束的时候返回这个新的对象
-
-
构造函数创建数组和对象
-
eg
let arr = new Array()
let obj = new Object()
let f1 = new Function() //函数
-
字面量概念
- 就是通过字面意思就能知道是什么数据类型
- {}字面就是对象,[]字面就是数组
对象
什么是对象?
- 对象这种数据类型一般是用来用代码描述现实中的某个具体事物
- 对象也可以保存多个数据,并且取值时能很清晰的知道取的是什么
- 对象跟顺序无关,所以我们一般称对象是无序存储,而数组叫有序存储
对象初始化
-
let 对象名 = { 属性名1 :属性值1 … }
-
属性:对象拥有的特征
-
方法:对象
-
例:要用代码去表示一只猫
- 特征:昵称、年龄、品种…
- 行为:抓沙发
方法的说明
- 方法就是对象的行为
- 在代码中其本质是函数
- 所以函数可以有参数和返回值,方法也有,写法跟函数一样
对象赋值的细节
- 对象如果对一个已经存在的属性进行就是修改
- 如果对一个不存在的属性进行赋值,就是增加
对象取值
-
对象如果访问一个不存在的属性,得到undefined
-
对象.属性名 这种形式叫点语法
- 永远不会找变量,就是找这个对象里的这个属性
-
对象[字符串]
- 代表找字符串对应的整个属性
-
对象[变量名]
- 代表先取出变量的值,变量值是什么就找什么属性
遍历对象
- 语法:for (let key in 对象名) { // 不一定要叫key,也可以叫别的,但是建议叫key或者 // key就是属性名,所以可以通过属性名访问属性值 对象名[key] }
内置对象
JS已经提供好的对象
Math对象
-
这个对象表示的是一个数学高手
-
它可以做一些数学运算
-
Math.pow(): 幂运算(基本不用,了解)
-
Math.abs():取绝对值
-
取整的方法:
- Math.round():- 四舍五入取整,本质是找离自己最近的整数,.5会取更大的整数
- Math.floor(): 向下取整,得到的整数一定比原来的值要小
- Math.ceil():向上取整,得到的整数一定比原来的值要大
- parseInt() : 直接取整
-
生成随机数
-
Math.random() : 生成 0 - 1之间的随机数,包括0,不包括1
-
如果要生成任意整数之间的随机数,
- 公式:Math.floor(Math.random() * ( 大 - 小 + 1)) + 小
-
日期对象
表示日期的对象
方法
-
getFullYear()
- 获得年
-
getMonth()
- 获得月,月从0开始
-
getDate()
- 获得日期的日
-
getDay()
- 获得星期几,星期天获得0,其他都是获得对应的数字
-
getHours()
- 获得时
-
getMinutes()
- 获得分
-
getSeconds()
- 获得秒
日期对象创建时指定时间
-
默认 new Date()什么都不传就获取当前时间
-
new Date() 如果依次传入年、月、日、时、分、秒,就是得到对应的时间,但是要注意传入0得到1月,传入1得到2月
let time1 = new Date(1990, 0, 1, 12, 32, 45)
console.log(time1.getFullYear(), time1.getMonth(), time1.getDate())
console.log(time1) -
new Date() 如果传入一个字符串时间,就是得到字符串内容的时间,更加精确
let time2 = new Date(‘1990-2-1 12:32:45’)
console.log(time2)
时间戳
-
时间戳获取的是自 1970年1月1日0点0分0秒 到现在过了多少毫秒
-
怎么获取?
-
构造函数获取
- Date.now()
-
使用实例化的日期对象来获取
- 对象.getTime()
- 把 对象 转成 number 类型就能获取
-
通过时间戳可以得到指定日期
-
-
利用时间戳计算
-
公式总结如下
天 = parseInt (总毫秒 / (1000 * 60 * 60 * 24))
总毫秒 = 总毫秒 % (1000 * 60 * 60 * 24) // 得到剩余毫秒
时 = parseInt ( 总毫秒 / (1000 * 60 * 60) )
总毫秒 = 总毫秒 % (1000 * 60 * 60 ) // 得到剩余毫秒
分 = parseInt ( 总毫秒 / (1000 * 60) )
总毫秒 = 总毫秒 % (1000 * 60 * 60 ) // 得到剩余毫秒
秒 = parseInt ( 总毫秒 / 1000 )
-
代码如下:
-
作用域
全局作用域
- 从
script
开头到script
结尾的区域
局部作用域
- 只有函数会开辟局部作用域,函数内的就叫局部作用域
块作用域
- 任何 {} 都叫 块作用域,但是只有 let 声明的变量才区分块作用域,var只有全局和局部
全局变量
- 在全局作用域里声明的变量叫全局变量
- 任意范围可以访问
局部变量
- 在函数内声明的变量叫局部变量
- 只有这个函数内可以访问
块变量
- 在
{}
里用let声明的变量叫块变量,只能在这个大括号里访问 - var不存在块作用域,只有let存在
- eg:
作用域链
- 只有函数可以开辟作用域
- 默认也有作用域叫
全局作用域
,我们也称之为0级作用域 - 如果在0级作用域里声明一个函数,那么这个函数开辟的作用域就叫1级作用域
- 如果在1级作用域里又声明一个函数,那么这个函数开辟的作用域就叫2级作用域
- 练习:
预解析以及声明的提升
- 练习
let 和 var 的异同总结
-
相同点:都是声明变量
-
不同点
- let只认块,也就是
{}
,在哪个大括号里声明,就只能在这个大括号里使用 - var认作用域,所以var里会分0级、1级、2级,还要注意,只有function才可以开辟作用域
- var参与预解析时的变量提升,而let不参与
- let在同一个块里不能声明同名变量,var无所谓
- let只认块,也就是
回调函数,自执行函数
-
回调函数
- 函数A当做参数传递给另外一个函数B,那么函数A就叫回调函数
-
自执行函数
-
写法
😭 function () {
}) () // 常用的
😭 function () {
}()) // 不太常用的
-
记住:自执行函数前面记得加分号,不加可能报错
-
-
作用:开辟新的作用域,避免变量冲突