HTML部分
一、HTML语义化
概念题,需要回答:「是什么、怎么做、解决了什么问题、优点是、缺点是、怎么解决缺点」
答:
- 是什么: 用正确的标签来写代码
- 怎么做: 比如标题用 h1~h6,段落用 p, 文章用 article ,主要内容用 main ,边栏用 aside, 导航用 nav 等等(找到中文对应)
- 解决啥: 明确了HTML的书写规范
- 优点是: 一、更适合搜索引擎检索,二、适合阅读,便于维护
- 没有缺点
二、使用过哪些HTML标签
记忆题,不要提自己不熟悉的标签,不然很可能会变成下一道题。
- 文章相关:header main footer article
- 多媒体: video canvas audio
- 表单相关: type=email type=tel 更多的可以去MDN了解相关使用教程
CSS部分
一、盒模型
盒模型:内容,内边距,边框,外边距组成
- content-box:width只包含 内容
- border-box: width包含 内容+内边距+边框
二、如何实现垂直居中
具体看如何实现垂直居中
三、BFC
- 是什么: 块级格式化上下文
- 怎么做: BFC的触发条件:
- 浮动元素(元素的float不是none)
- 绝对定位元素(元素的position为absolute或fixed)
- 行内块 inline block 元素
- overflow 值不为 visible 的块元素
- 弹性元素(display为 flex 或 inline-flex元素的直接子元素)
- 解决啥:
- 清除浮动
- 防止 margin 合并
CSS选择器的优先级
- 选择器越具体,优先级越高
- 相同优先级,出现在后面的,覆盖前面的
- 属性后面加!important的优先级最高(尽量不用)
如何清除浮动
- 方法一: 给父元素加上.clearfix
.clearfix::after{
content: "";
display: block; //或者table
clear: both;
}
.clearfix {
zoom: 1 ; // 兼容IE
}
- 方法二: 给父元素添加: overflow:hidden;
JS部分
JS的数据类型有哪些?
- 基本类型:string, boolen, number, symbol, undefined, null, bigint
- 引用类型: object 类型判断:
- typeof: 最常用的类型判断方法(缺点:无法判断 数组 和 null 的数据类型)
typeof [] // object
typeof {} // object
typeof null // object
- instanceof: 用于判断引用类型的继承关系( 不支持基本类型的判断)
Function instanceof Object // true
Date instanceof Function // true
'1' instanceof String // false
1 instanceof Number // false
- Object.prototype.toString: 类型判断的最佳实践
Object.prototype.toString.call() // object Undefined
Object.prototype.toString.call([]) // object Array
Object.prototype.toString.call({}) // object Object
Object.prototype.toString.call(()=>{}) // object Function
Object.prototype.toString.call(async function(){}) // object AsyncFunction
Object.prototype.toString.call('1') // object String
Object.prototype.toString.call(1) // object Number
Object.prototype.toString.call(null) // object Null
Object.prototype.toString.call(undefined) // object Undefined
0.1 + 0.2 !== 0.3
-
进制转换:js在做数字运算的时候,0.1和0.2会转化成二进制后无限循环,但是JS采用的是IEEE 754二进制浮点数运算,会导致精度丢失
-
解决方法
- 转化成整数运算
function add(a,b){ const maxLen = Math.max( a.toString().split(".")[1].length, b.toString().split(".")[1].length } const base = 10 ** maxLen const bigA = BigInt(base * a) const bigB = BigInt(base * b) const bigRes = (bigA + bigB) / BigInt(base) return Number(bigRes) }
- 转化成字符串运算
let addStrings = function (num1, num2){ let i = num1.length - 1 let j = num2.length - 1 const res = [] let carry = 0 while(i >= 0 || j >=0){ const n1 = i >= 0 ? Number(num1[i] : 0; const n2 = j >= 0 ? Number(num2[j] : 0; const sum = n1 + n2 + carry res.unshift(sum % 10) carry = Math.floor(sum/10) i-- j-- } if(carry){res.unshift(carry)} return res.join("") } function isEqual(a, b, sum){ const [intStr1, deciStr1] = a.toString().split(".") const [intStr2, deciStr2] = b.toString().split(".") const inteSum = addStrings(intStr1, intStr2) const deciSum = addStrings(deciStr1, deciStr2) return inteSum + "." + decisum === string(sum) }
JS的new做了什么?
- 创建临时对象/新对象
- 绑定原型
- 指定this = 临时对象
- 执行构造函数
- 返回临时对象 可以通过方方的创建士兵模型了解更多
JS的立即执行函数是什么?
声明一个匿名函数并立刻执行他,这种做法就是立即执行函数。
(function(){alert('我是匿名函数')} ()) // 用括号把整个表达式包起来
(function(){alert('我是匿名函数')}) () // 用括号把函数包起来
!function(){alert('我是匿名函数')}() // 求反,我们不在意值是多少,只想通过语法检查。
+function(){alert('我是匿名函数')}()
-function(){alert('我是匿名函数')}()
~function(){alert('我是匿名函数')}()
void function(){alert('我是匿名函数')}()
new function(){alert('我是匿名函数')}()
var x = function(){return '我是匿名函数'}()
在 ES6 之前,只能通过它来「创建局部作用域」。
普通函数和箭头函数
this 指向:
- 普通函数: function (){}
- 谁调用函数或者方法,this 指向谁
- 箭头函数: () => {}
- this 在函数定义的时候就确定下来的
- this 指向父级作用域的执行上下文
- 无法使用 apply, call 和 bind 来改变 this 的指向
区别
- 普通函数
- 语法格式: function(){}
- new 和 原型: 有
- arguments: 有
- this 指向: 动态
- call, apply, bind: 修改 this
- 箭头函数
- 语法格式: () => {}
- new 和 原型: 没有
- arguments: 没有,可以调用外围
- this 指向: 一般是全局对象,被普通函数包含则指向上一层
- call, apply, bind: 不可以修改this
什么是原型链
原型链涉及的概念挺多的,举例说明
先说一下原型,假设我们有一个普通对象x={}
,这个x会有一个隐藏属性叫做_proto_,这个属性会指向Object.prototype
x._proto_ === Object.prototype //原型
接下来说说什么是原型链,假设我们有个数组对象a=[]
,这个a也有一个隐藏属性,叫做_proto_,这个属性会指向Array.prototype
- a的原型是 Array.prototype
- a的原型的原型是Object.prototype 于是就通过隐藏属性形成了一个链条
a ===> Array.prototype ===> Object.prototype
解决啥: 在没有Class的情况下实现继承,以上述例子为例
- a 是 Array 的实例, a 拥有 Array.prototype 里的属性
- Array 继承了 Object
- a 是 Object 的间接实例,a 拥有 Object.prototype 里的属性 这样一来, a 既拥有 Array.prototype 里的属性,又拥有 Object.prototype 里的属性
优点: 简单 缺点: 跟Class相比,不支持私有属性
var let const 的区别
- var 不存在块级作用域, let 和 const 有块级作用域
- var 存在变量提升, let 和 const 不存在变量提升
- var 可以重复声明, let 和 const 不行
- var 和 let 在声明时可以不给初始值, const 声明后必须给初始值
- let 可以重新赋值, const 不能,但不意味着值不能改动,只是变量指向的内存地址不能改动
闭包
是什么: 闭包是JS的一种语法特性,闭包=函数+自由变量 怎么做:
let count
function add(){
count += 1 // 访问了外部变量的函数
}
- 把上面的代码放在[非全局环境]里就是闭包(闭包是 count + add 组成的整体。)
- 如何实现一个【闭包的应用】:创建一个立即执行函数,并return
const add2 = function (){
var count
return function add (){
count += 1
}
}()
解决啥:
- 避免污染全局
- 提供对局部变量的间接访问(因为只能 count += 1,不能 count -= 1)
- 维持变量,使其不被垃圾回收
缺点: 闭包使用不当可能造成内存泄漏
JS如何实现类
方法一:使用原型
function Dog(name){
this.name = name
this.legsNumber = 4
}
Dog.prototype.kind = '狗'
Dog.prototype.say = function(){
console.log(`我是${this.name}, 我有${this.legsNumber}条腿。`)
}
const dog1 = new Dog('小狗') // Dog 函数就是一个类
dog1.say()
方法二:使用class
class Dog {
constructor(name) {
this.name = name
this.legsNumber = 4
}
say(){
console.log(`我是${this.name}, 我有${this.legsNumber}条腿。`)
}
}
const dog1 = new Dog("小狗")
dog1.say()
JS如何实现继承
方法一:使用原型链
function Animal(legsNumber){
this.legsNumber = legsNumber
}
Animal.prototype.kind = '动物'
function Dog(name){
this.name = name
Animal.call(this, 4)
}
Dog.prototype._proto_ = Animal.prototype
Dog.prototype.kind = '狗'
Dog.prototype.say = function(){
console.log(`我是${this.name}, 我有${this.legsNumber}条腿。`)
}
const dog1 = new Dog('小狗') // Dog 函数就是一个类
console.dir(dog1)
方法二:使用class
class Animal{
constructor(legsNumber){
this.legsNumber = legsNumber
}
run(){
console.log(`${this.legsNumber}条腿跑起来`)
}
}
class Dog extends Animal{
constructor(name){
super(4)
this.name = name
}
say(){
console.log (`我是${this.name},我有${this.legsNumber}条腿。`)
}
}
const dog1 = new Dog("小狗")
dog1.say()
手写节流throttle、防抖 debounce
- 节流(类似技能冷却)
const throttle = (fn, time) => {
let cd = false
return (...args) => { //...args 把传入的参数收集起来形成数组
if(cd) {return}
fn.call(undefined, ...args)
cd = true
setTimeout(()=>{
cd = false
},time
}
}
- 防抖(类似回城)
const debounce = (fn, time) => {
let TP = null
return(...args) => {
if (TP !== null){
clearTimeout(TP) //打断回程
}
// 重新回城
TP = setTimeout(()=>{
fn.call(undefined, ...args)
TP = null
},time)
}
}
手写AJAX
const ajax = (method, url, data, success, fail) => {
var request = new XMLHttpRequest()
request.open(method, url);
request.onreadystatechange = function(){
if(request.readyState === 4){
if(request.status >= 200 && request.status < 300 || request.status === 304){
success(request)
}else{
fail(request)
}
}
};
request.send();
}
手写深拷贝
方法一:用JSON:
const list = [{name: '拷贝'}]
const listCopy = JSON.parse(JSON.stringify(list))
listCopy[0].name = '深拷贝'
这个方法有如下缺点:
- 不支持Date、正则、undefined、函数等数据
- 不支持引用
方法二:递归
要点: 递归,判断类型,检查环,不拷贝原型上的属性
const deepClone = (a, cache) => {
if(!cache){
cache = new Map() //缓存不能全局,最好临时创建并递归传递
}
if(a instanceof Object){
if(cache.get(a)){return cache.get(a)}
let result
if(a instanceof Function){
if(a.prototype){ //有prototype就是普通函数
result = function(){return a.apply(this.arguments)}
} else {
result = (...args) => {return a.call(undefined, ...args)}
}
}else if(a instanceof Array) {
result = []
} else if (a instanceof Date) {
result = new Date(a - 0)
} else if (a instanceof RegExp) {
result = new RegExp(a.source, a.flags)
} else {
result = {}
}
cache.set(a, result)
for(let key in a){
if(a.hasOwnProperty(key)){
result[key] = deepClone(a[key], cache)
}
}
return result
} else {
return a
}
}
手写数组去重
- hash:利用对象的属性不能相同的特点
function unique(array){
let Array = []
let hash = {}
for(let i = 0; i < array.length; i++){
if(!hash[array[i]]){
Array.push(array[i])
hash[array[i]] = true
}
}
return Array
}
- Set
function unique(array){
return Array.from(new Set(array))
}
- Map
function unique(array){
let map = new Map()
for(let i = 0; i < array.length; i++){
let number = a[i]
if(number === undefined){continue}
if(map.has(number)){
continue
}
map.set(number, true)
}
return [...map.keys()]
}
DOM部分
简述DOM事件模型
- 每个事件都会经历从上到下的捕获阶段,再经历从下到上的冒泡阶段
- 怎么选择捕获(冒泡):添加事件监听的时候addEventLisner('click',fn,true/false)true:捕获,false或者不传:冒泡
- 可以使用event.stopPropagation()来阻止捕获或者冒泡
手写事件委托
简版:
ul.addEventListener('click', function(e){
if(e.target.tagName.toLowerCase() === 'li'){
fn()
}
})
BUG:当用户点击的是li里面的span,就没法触发fn
进阶版
点击 span 后,递归遍历 span 的祖先元素看其中有没有 ul 里面的 li。
function delegate(element, eventType, selector, fn){
element addEventListener(eventType, e => {
let el = e.target
while(!el.matches(selector)){
if(element === el){
el = null
break
}
el = el.parentNode
}
el && fn.call(el, e, el)
})
return element
}
delete(ul, 'click', 'li', f1)
HTTP部分
GET和POST的区别
- 幂等性
- 由于GET是读,POST是写,所以GET是幂等的,POST不是幂等的
- 由于GET是读,POST是写,所以用浏览器打开网页会发送GET请求,POST打开网页需要用form标签
- 由于GET是读,POST是写,所以GET打开的页面刷新是没区别的,POST打开的页面刷新需要确认
- 由于GET是读,POST是写,所以GET结果会被缓存,POST结果不会被缓存
- 由于GET是读,POST是写,所以GET打开的页面可以被书签收藏,POST打开不行
- 请求参数
- 通常,GET请求参数放在url里,POST请求的参数放在body里
- GET比POST更不安全,因为参数直接暴露在URL上
- GET请求参数在url里有长度限制,POST在body里没有长度限制
- TCP packet
- GET产生一个TCP数据包,POST产生两个或以上TCP数据包
HTTP缓存有哪些方案
缓存 | 内容协商 | |
---|---|---|
HTTP 1.1 | Cache-Control: max-age=3600 Etag: ABC | If-None-Match: ABC 响应状态码:304 或 200 |
HTTP 1.0 | Expires: Wed, 21 Oct 2015 02:30:00 GMT Last-Modified: Wed, 21 Oct 2015 01:00:00 GMT | If-Modified-Since: Wed, 21 Oct 2015 01:00:00 GMT 响应状态码:304 或 200 |
- Cache-Control: 在文件的响应头写上 max-age=3600,就会自动缓存1个小时,1小时内再次访问同样的URL就直接不发请求
- Etag: 服务器给每个文件的特征值
- If-None-Match: 通过这个特征值向服务器询问是否需要更新文件
- 304: 不需要更新
- 200: 需要更新,并附带新的文件
HTTP和HTTPS 的区别
HTTPS = HTTP + SSL/TLS(安全层)
区别
- HTTP是明文传输的,不安全; HTTPS是加密传输的,非常安全
- HTTP使用的80端口,HTTPS使用443端口
- HTTP较快, HTTPS较慢
- HTTPS的证书一般需要购买, HTTP不需要证书
HTTP/1.1 和 HTTP/2 的区别
- HTTP/2使用了二进制传输,而且将head 和 body 分成帧来传输;HTTP/1.1是字符串传输
- HTTP/2 支持多路复用,HTTP/1.1 不支持。多路复用:一个TCP连接从单车道变成几百个双向通行的车道
- HTTP/2 可以压缩head, 但是 HTTP/1.1 不行
- HTTP/2 支持服务器推送, HTTP/1.1 不支持
TCP三次握手和四次挥手是什么
TCP:传输内容协议
建立TCP连接时 server 与 client 会经历三次握手
- 浏览器向服务器发送TCP数据: SYN(seq = x)
- 服务器向浏览器发送TCP数据: ACK(seq = x + 1)SYN(y)
- 浏览器向服务器发送TCP数据: ACK(seq = y + 1)
关闭TCP连接时 server 与 client 会经历四次挥手
- 浏览器向服务器发送TCP数据: FIN(seq = x)
- 服务器向浏览器发送TCP数据: ACK(seq = x + 1)
- 服务器向浏览器发送TCP数据: FIN(seq = y)
- 浏览器向服务器发送TCP数据: ACK(seq = y + 1)
- 2、3中间服务器可能还有数据要发送,不能提前发送FIN,所以2、3不能合并
- 可能有很多数据交流,所以有序号x
同源策略和跨域
什么是同源策略?
- 如果两个URL的协议、端口和域名都完全一致的话,那么这两个URL是同源
- 只要在浏览器里打开页面,就默认遵守同源策略
- 优点: 保证用户的隐私安全和数据安全
- 缺点: 很多时候,前端需要访问另一个域名的后端接口,会被浏览器阻止获取响应
怎么跨域
- JSONP
- 甲站点利用 script 标签可以跨域的特性,向乙站点发送get请求
- 乙站点后端改造 JS 文件的内容,将数据传进回调函数
- 甲站点通过回调函数拿到乙站点的数据
- CORS
- 对于简单请求,乙站点在响应头里添加
Access-Control-Allow-Origin:http//甲站点
即可 - 对于复杂请求,如PATCH,乙站点需要:
- 响应OPTIONS请求,在响应中添加如下的响应头
Access-Control-Allow-Origin: https://甲站点 Access-Control-Allow-Methods: POST, GET, OPTIONS, PATCH Access-Control-Allow-Headers: Content-Type
- 响应POST请求,在响应中添加
Access-Control-Allow-Origin
头。
- 如果需要附带身份信息,JS中需要在AJAX里设置
xhr.withCredentials = true
- 对于简单请求,乙站点在响应头里添加
- Nginx代理/Node.js代理
- 前端 → 后端 → 另一个域名的后端
Session、Cookie、LocalStorage、SessionStorage的区别
- Cookie VS LocalStorage
- 主要区别是 Cookie 会被发送到服务器,而 LocalStorage 不会
- Cookie 一般最大 4K, LocalStorage 可以有 5Mb 甚至 10Mb
- LocalStorage VS SessionStorage
- LocalStorage 一般不会自动过期(除非用户手动清除)
- SessionStorage 在会话结束时过期(如关闭浏览器之后,具体由浏览器自行决定)
- Cookie VS Session
- Cookie 存在浏览器的文件里,Session 存在服务器的文件里
- Session 是基于 Cookie 实现的, 具体做法就是把 SessionID 存在 Cookie 里
TypeScript部分
TS 和 JS 的区别是什么?有什么优势?
- 语法层面: TypeScript = JavaScript + Type (TS 是 JS 的超集)
- 执行环境层面: 浏览器、Node.js 可以直接执行JS,但不能执行TS
- 编译层面: TS有编译阶段,JS没有编译阶段
- 编写层面: TS更难写一点,但是类型更安全
- 文档层面: TS的代码写出来就是文档,IDE可以完美提示。 JS的提示主要靠TS
any、unknown、never的区别是什么?
any VS unknown
二者都是顶级类型(top type), 任何类型的值都可以赋值给顶级类型变量:
let foo: any = 123; // 不报错
let bar: unknown = 123; // 不报错
但是 unknown 比 any 的类型检查更加严格,any 什么检查都不做, unknown 要求先收窄类型:
const value: unknown = "Hello World";
const someString: string = value;
// 报错: Type 'unknown' is not assignable to type 'string'.(2322)
const value: unknown = "Hello World";
const someString: string = value as string;//不报错
如果改成any,基本在哪都不报错。所以能用 unknown 就优先用 unknown, 类型更安全一点
never
never 是底类型,表示不应该出现的类型
interface A{
type: 'a'
}
interface B{
type: 'b'
}
type All = A | B
function handleValue(val: All){
switch(val.type){
case 'a':
// 这里 val 被收窄为 A
break
case 'b':
// val 在这里是 B
break
default:
// val 在这里是 never
const exhaustiveCheck: never = val
break
}
}
type 和 interface 的区别
- 组合方式: interface 使用 extends 来实现继承, type 使用 & 来实现联合类型
- 扩展范围: interface 可以重复声明用来扩展, type 一个类型只能声明一次
- 范围不同: type 适用于基本类型, interface 一般不行
- 命名方式: interface 会创建新的类型名, type 只是创建类型别名,并没有新创建类型
TS工具类型Partial、 Required、 Readonly、 Exclude、 Extract、 Omit、 ReturnType 的作用和实现
- Partial: 部分类型(部分实现,不需要把所有类型都写上)
- Required: 必填类型(跟部分类型相反,需要全部写上)
- Readonly: 只读类型(只读,里面的内容不可更改)
- Exclude: 排除类型 (排除不要的)
- Extract: 提取类型 (提取需要的)
- Pick/Omit: 排除key类型(Pick选择需要的,Omit选择不要的)(接对象类型)
- ReturnType: 返回值类型
Vue2 部分
Vue2 的生命周期钩子有哪些?数据请求放在哪个钩子?
- 常用的
- beforecreate,created
- beforemount,mounted
- beforeupdate,updated
- beforedestroy,destroyed
- 不常用的
- activated:被 keep-alive 缓存的组件激活时调用。
- deactivated: 被 keep-alive 缓存的组件失活时调用。
- errorCaptured: 捕获一个来自后代组件的错误时被调用,返回
false
以阻止该错误继续向上传播。
- 请求一般放在 mounted 里面,因为放在其他地方都不合适
Vue2 组件间的通信方式
- 父子组件: 使用[ props 和事件]进行通信
- 爷孙组件:
- 使用两次父子组件间通信来实现
- 使用 [ provide + inject ]来通信
- 任意组件: 使用eventBus = new Vue()来通信
- 主要 API 是
eventBus.$on
和eventBus.$emit
- 缺点是事件多了后就很乱,难以维护
- 主要 API 是
- 任意组件: 使用Vuex通信
Vue 中的 key 有什么作用
- 虚拟 DOM 中 key 的作用:
- key 是虚拟 DOM 的标识,当数据发生变化时, Vue 会根据【新数据】生产【新虚拟 DOM 】,随后vue进行新旧的差异比较
- 对比规则:新旧之间如果有相同的key
- 如果内容没变,就复用原来的
- 如果内容变了,就生成新的真实 DOM ,替换页面旧的真实 DOM
Vuex
- Vuex 是一个转为Vue.js应用程序开发的状态管理模式+库
- 核心概念的名字和作用:
- store 是个大容器,包含以下所有内容
- State 用来读取状态,带有一个 mapState 辅助函数
- Getter 用来读取派生状态,带有一个 mapGetter 辅助函数
- Mutation 用于同步提交状态变更,附有一个 mapMutation 辅助函数
- Action 用于异步变更状态,但它提交的是 mutation,而不是直接变更状态
- Module 用来给store划分模块,方便维护代码 常见追问: 为什么 Mutation 和 Action 要分开 答:为了让代码更容易维护(但是Pinia把这两个合并了。可用减少概念)
VueRouter
- VueRouter 是 Vue.js 的官方路由,使构建单页面应用变得简单
- 说出核心概念的名字和作用:
router-link
:点击跳转,router-view
:容纳路由视图,- 嵌套路由:路由可以加上children属性,路由里面还可以有路由,子路由就渲染在
router-view
里, - Hash模式和History模式,
- 导航守卫:每一个路由都可以设置个钩子,进入离开解析时做什么,
- 懒加载: import()
- Hash模式和History模式的区别:
- 一个用的是Hash,一个用的 History API
- 一个需要后端配合,一个不需要后端nginx配合
- 导航守卫如何实现登录控制
router.beforeEach((to, from, naxt) => {
if(to.path === '/login') return next()
if(to是受控页面 && 没有登录) return next('/login')
next()
})
Vue2 是如何实现双向绑定的?
- 一般是通过
v-model
/.sync
实现的,v-model是v-bind:value
和v-on:input
的语法糖v-bind:value
实现了 data => UI 的单向绑定v-on:input
实现了 UI => data 的单向绑定- 这两个加起来就是双向绑定了
- 这两个单向绑定是如何实现的
- 前者通过 Object.defineProperty API 给 data 创建 getter 和 setter, 用于监听 data 的改变,data 改变就会安排改变 UI
- 后者通过 template compiler 给 DOM 添加事件监听, DOM input 的值改变就回去修改 data
Vue3 部分
Vue3 为什么使用 Proxy?
- 弥补 Object.defineProperty 的两个不足
- 动态创建的 data 属性需要用 Vue.set 来赋值, Vue3 用了 Proxy 就不需要了
- 基于性能考虑, Vue2 篡改了数组的 7 个 API, Vue3 用了Proxy 就不需要了
- defineProperty 需要提前递归地遍历 data 做到响应式,而Proxy 可以在真正用到深层数据的时候再做响应式
Composition API
- Composition API 比 mixins、高阶组件、 extends、 Renderless Components 等更好,原因有三
- 模板中的数据来源不清晰
- 命名空间冲突
- 性能
- 更适合 TypeScript
Vue3 对比 Vue2 做了哪些改动
- createApp() 代替了 new Vue()
- v-model 代替了以前的 v-model 和 .sync
- 根元素可以有不止一个元素了
- 新增 Teleport 传送门
- destroyed 被改名为 unmounted
- ref 属性支持函数了