88888

vue是什么?

vue是一门渐进式的javascript框架。所谓的渐进式就是:从中心的的视图层渲染开始向外扩散的构建工具层。这过程会经历:视图层渲染->组件机制->路由机制->状态管理->构建工具;五个层级。

特点:易用,灵活,高效,入门门槛低。

 

面试官:v-if和v-show的区别?

前者是将DOM创建和删除后者则是改变display的值来控制DOM的显示和隐藏。

面试官:vue有什么生命周期?在new Vue 到 vm.$destory的过程经历了什么?

初始化阶段 beforeCreate和create

挂载阶段 beforeMount和mounted

更新阶段 beforeUpdate和update

卸载阶段 beforeDestory和destory

new Vue()后,首先会初始化事件生命周期,接着会执行beforeCreate生命周期钩子,在这个钩子里面还拿不到this.$elthis.$data;接着往下走会初始化inject将data的数据进行侦测也就是进行双向绑定;接着会执行create钩子函数,在这个钩子里面能够拿到this.$data还拿不到this.$el;到这里初始化阶段就走完了。然后会进入一个模版编译阶段,在这个阶段首先会判断有没有el选项如果有的话就继续往下走,如果没有的话会调用vm.$mount(el);接着继续判断有没有template选项,如果有的话,会将template提供的模版编译到render函数中;如果没有的话,会通过el选项选择模版;到这个编译阶段就结束了。(温馨提示:这个阶段只有完整版的Vue.js才会经历,也是就是通过cmd引入的方式;在单页面应用中,没有这个编译阶段,因为vue-loader已经提前帮编译好,因此,单页面使用的vue.js是运行时的版本)。模版编译完之后(这里说的是完整版,如果是运行时的版本会在初始化阶段结束后直接就到挂载阶段),然后进入挂载阶段,在挂在阶段首先或触发beforeMount钩子,在这个钩子里面只能拿到this.$data还是拿不到this.$el;接着会执行mounted钩子,在这个钩子里面就既能够拿到this.$el也能拿到this.$data;到这个挂载阶段就已经走完了,整个实例也已经挂载好了。当数据发生变更的时候,就会进入更新阶段,首先会触发beforeUpdate钩子,然后触发updated钩子,这个阶段会重新计算生成新的Vnode,然后通过patch函数里面的diff算法,将新生成的Vnode和缓存中的旧Vnode进行一个比对,最后将差异部分更新到视图中。当vm.$destory被调用的时候,就会进入卸载阶段,在这个阶段,首先触发beforeDestory钩子接着触发destoryed钩子,在这个阶段Vue会将自身从父组件中删除,取消实例上的所有追踪并且移除所有的事件监听。到这里Vue整个生命周期就结束了。

 

图来自vue官网👆

面试官:vue的模版编译过程是怎么样的?

首先会先将模版通过解析器,解析成AST(抽象语法树),然后再通过优化器,遍历AST树,将里面的所有静态节点找出来,并打上标志,这样可以避免在数据更新进行重新生成新的Vnode的时候做一些无用的功夫,和diff算法对比时进行一些无用的对比,因为静态节点这辈子是什么样就是什么样的了,不会变化。接着,代码生成器会将这颗AST编译成代码字符串,这段字符串会别Vdom里面的createElement函数调用,最后生成Vnode。

面试官:vue是怎么实现数据侦测的?

vue主要是通过Object.defineProperty进行数据的侦测;vue的数据侦测有两种:1.Object类型的数据侦测。2.Array类型的数据侦测。Object类型的数据侦测比较容易直接通过Object.defineProperty结合递归就能实现,但是Array的类型侦测就比较麻烦一些,需要通过劫持Array原型上的push,pop,shift,unshift,splice,`sort,reverse的方法来实现侦测,因为这几个方法都会改变自身的数据。导致Array类型侦测比较麻烦还是因为Object.defineProperty对数组的支持比较差。(到Vue.3,vue的数据侦测会通过proxy进行重写)

具体代码实现如下:

const arrProto = Array.prototype

const arrayMethods = Object.create(arrProto)

const m = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']

m.forEach(function (method) {

    const original = arrProto[method]

    Object.defineProperty(arrayMethods, method, {

        value: function v(...args) {

            return original.apply(this, args)

        }

    })

})

function defineReactive(data) {

    if (data && typeof data !== 'object') return

    if (Array.isArray(data)) {

        data.__proto__ = arrayMethods

    } else {

        Object.keys(data).forEach(function (val) {

            observer(data, val, data[val])

        })

    }

}

function observer(data, key, value) {

    defineReactive(data)

    Object.defineProperty(data, key, {

        get() {

            return value

        },

        set(newVal) {

            if (newVal === value) return

            value = newVal
        }

    })

}
复制代码

面试官:我这样this.xxx[xxx] = xxx,在data里添加一个数据,vue能不能侦测到?为什么?

不能,在new Vue()初始化的时候,在实例的data初始化的数据,才能被侦测到;因为在生命周期beforeCreate到create钩子之间会进行将data中的数据进去双向绑定的侦测;实例初始化完之后再添加的数据,无办法完成侦测初始化。

面试官:那有没有办法解决这个问题?

通过vm.$set()可以解决这个问题。

面试官:vm.$set()具体是怎么做的?

vm.$set(target,key,val)

1.target如果是数组的话,先判断key是不是合法的下标,如果这两个条件都通过.那就将target.length和传进来的key取一个最大值赋值给target.length,然后调用splice去修改数组

2.key已经存在target里面并且不是存在target原型上的,那就是只改变值

3.target如果不是响应式数据,那么也只是改变数据,不需要通知watcher

4.如果target是vue实例,或者target是this.$data,那么直接退出程序

5.如果上面的条件都不满足,那么就是新添加的响应数据,那就直接调用defineReactive()去侦测该数据,然后去通知watcher

具体实现代码如下:

function set(target, key, val) {

    const ob = target.__ob__

    if (Array.isArray(target) && key >= 0) {

        target.length = Math.max(target.length, key)

        target.splice(key, 1, val)

        return val

    }

    if ((key in target && !(key in Object.prototype)) || !ob) {

        target[key] = val

        return val

    }

    if (target._isVue || (ob && ob.vmCount)) {

        return val

    }

    defineReactive(ob.value, key, val)

    ob.dep.notify()

    return val

}

复制代码

提示:这里解释一下target.__ob__target._isVue,ob.vmCount是什么,如果target是一个双向绑定的是数据,它的原型上就会有一个__ob__属性,如果有原型上有_isVue属性,证明它是Vue的实例,如果__ob__.vmCount大于0就证明该target是根数据this.$data

面试官:那vm.$delete又是怎么做的?

vm.delete(target,key)

1.target如果是数组的话并且key是合法的,那就通过splice去改变数组

2.target如果是vue实例.或者是this.$data,那就直接退出程序

3.target如果不是双向绑定数据,那就直接delete就行不需要,通知watcher

4.以上条件都不满足,那么target就是双向绑定数据,delete之后通知watcher

具体实现代码如下:

function del(target, key) {

    if (Array.isArray(target) && key > 0) {

        target.splice(key, 1)

        return

    }

    const ob = target.__ob__

    if ((target._isVue && (ob && ob.vmCount)) || !target.hasOwnProperty(key)) return

    delete target[key]

    if (!ob) return

    ob.dep.notify()

}


作者:高级舔狗  链接:https://juejin.cn/post/6844904110395752462  来源:稀土掘金

1. 各种排序+查找算法
冒泡排序 快速排序 归并排序 插入排序 选择排序 顺序查找 二分查找 https://blog.csdn.net/Sabrina_cc/article/details/106857519

2. call bind apply
apply 、 call 、bind 三者都是用来改变函数的this对象的指向的;第一个参数都是this要指向的对象,也就是想指定的上下文;

call() 方法在使用一个指定的 this 值和若干个指定的参数值的前提下调用某个函数或方法。

所以我们模拟的步骤可以分为:(1)将函数设为对象的属性(2)执行该函数(3)删除该函数

Function.prototype.myCall = function(context) {
    /*if (typeof this !== 'function') {
        throw new TypeError('Error')
    }*/
    context = context || window; //第一个参数为null或者undefined时,this指向全局对象window
    context.fn = this;    // 非常关键:改变 this 的作用域
    const args = [...arguments].slice(1); //从 Arguments 对象中取取第二到最后一个参数放到一个数组里
    const result = context.fn(...args);   //把这个参数数组放到要执行的函数的参数里面去
    delete context.fn;  // 必须删除,会给 context 增加方法 fn
    return result;
}
// 简单测试
let obj = {
    name: 'xxx'
};
function test(){
    console.log('arguments=',arguments); // Arguments(2) [1, 2, callee: ƒ, Symbol(Symbol.iterator): ƒ]
    console.log('this=',this); // {name: "xxx", fn: ƒ}
}
test.myCall(obj,1,2);
apply 接受一个数组(或者类数组对象)作为参数输入

Function.prototype.myApply = function(context) {
  /* if (typeof this !== 'function') {
    throw new TypeError('Error')
  }*/
  context = context || window;
  context.fn = this;
  let result;
  if (arguments[1]) {
    result = context.fn(...arguments[1]);  // 处理参数和 call 有区别
  } else {
    result = context.fn();
  }
  delete context.fn;
  return result;
}
 
let foo = {
  value: 1
}
function bar(name, age) {
  console.log(name)
  console.log(age)
  console.log(this.value)
}
bar.apply(foo, ['kevin', 18]) // kevin 18 1
 会创建一个新函数。当这个新函数被调用时,bind() 的第一个参数将作为它运行时的 this,之后的一序列参数将会在传递的实参前传入作为它的参数。(来自于 MDN )

由此我们可以首先得出 bind 函数的两个特点:(1)返回一个函数(2)可以传入参数(3)一个绑定函数也能使用new操作符创建对象:这种行为就像把原函数当成构造器。提供的 this 值被忽略,同时调用时的参数被提供给模拟函数。

Function.prototype.bind = function(context, ...bindArgs) {
  const func = this;// func 为调用 bind 的原函数
  context = context || window;
  if (typeof func !== 'function') {
    throw new TypeError('Bind must be called on a function');
  }
  // bind 返回一个绑定 this 的函数
  return function(...callArgs) {
    let args = bindArgs.concat(callArgs);
    if (this instanceof func) {
      // 意味着是通过 new 调用的 而 new 的优先级高于 bind
      return new func(...args);
    }
    return func.call(context, ...args);
  }
}
参考链接:JavaScript 之 call和apply,bind 的模拟实现     

 深入理解 call,apply 和 bind     

 apply、call和 bind 方法详解(含手写)

 3. 深拷贝
function deepClone(obj = {}) {
    if (typeof obj !== 'object' || obj == null) {
        return obj   // obj 是 null ,或者不是对象和数组,直接返回
    }
    let result   // 初始化返回结果
    if (obj instanceof Array) {    // 判断是否为数组
        result = []
    } else {
        result = {}
    }
    for (let key in obj) {
        if (obj.hasOwnProperty(key)) {  // 保证 key 不是原型的属性
            result[key] = deepClone(obj[key])  // 递归调用!!!
        }
    }
    return result    // 返回结果
}
let a = {
  age: undefined,
  sex: Symbol('male'),
  jobs: function () {},
  name: 'cmk'
}
let b = JSON.parse(JSON.stringify(a))
console.log(b) // { name: 'cmk' }
let c = deepClone(a)
console.log(c) // { age: undefined, sex: Symbol(male), jobs: [Function: jobs], name: 'cmk' }
4. 防抖 节流
scroll 事件本身会触发页面的重新渲染,同时 scroll 事件的 handler 又会被高频度的触发, 因此事件的 handler 内部不应该有复杂操作,例如 DOM 操作就不应该放在事件处理中。 针对此类高频度触发事件问题(例如页面 scroll ,屏幕 resize,监听用户输入等),有两种常用的解决方法,防抖和节流。

Function.prototype.myThrottle = function(cb, wait) {
    let flag = false;
    return function(...rest) {
        if (flag) {
            return;
        }
        flag = true;// 在wait时间范围内。。通过flag不让执行cb;
        setTimeout(() => {
            cb.apply(this, rest);
            flag = false;
        }, wait)
    }
}
Function.prototype.myDebounce = function(cb, wait) {
    let timer = null;
    return function (...rest) {
        if (timer) {
            // 如果上次的定时器还存在,则清除。重新等待时间
            clearTimeour(timer);
        }
        timer = setTimeout(() => {// 使用箭头函数,确保this还能定义在当前执行环境
            // 里边的闭包的this必须保持和callback保持一致。确保使用ok
            cb.apply(this, rest);
        }, wait)
    }
}
6. 字符串转驼峰
// 字符串转驼峰,注意第一个字符不大写,如border-bottom-color -> borderBottomColor
function Change(str) {
  let arr = str.split('-') // 字符串分割为数组
  // map返回由新的项组成的数组
  arr = arr.map((item, index) => {
    return index === 0 ? item : item.charAt(0).toUpperCase() + item.substring(1) // 第一个字符转大写与之后的字符合并
  })
  return arr.join("") // 数组合并为字符串
}
console.log(Change('border-bottom-color'))
7. 数组拍平
function flat(arr) {
    // 验证 arr 中,还有没有深层数组 [1, 2, [3, 4]]
    const isDeep = arr.some(item => item instanceof Array)
    if (!isDeep) {
        return arr // 已经是 flatern [1, 2, 3, 4]
    }
    // oncat只能解决单层[]
    const res = Array.prototype.concat.apply([], arr)
    return flat(res) // 递归

const res = flat( [1, 2, [3, 4, [10, 20, [100, 200]]], 5] )
console.log(res)
 
function flattern(arr) {
  return arr.reduce((preValue, currValue, currIndex, array) => {
    return preValue.concat(Array.isArray(currValue) ? flattern(currValue) : currValue)
  }, []) // []作为第一个preValue
}
 
// toString & split
function flattern2(arr) {
  return arr.toString().split(',').map(item => {
    return Number(item) // split分隔后数组元素为字符串形式, 需要转换为数值形式
  })
}
 
// join & split
function flattern3(arr) {
  return arr.join(',').split(',').map(item => { // join方法和toString方法效果一样?
    return parseInt(item)
  })
}
 
// 扩展运算符
function flattern5(arr) {
  while (arr.some(item => Array.isArray(item))) { // 如果数组元素中有数组
    arr = [].concat(...arr) // [].concat(...[1, 2, 3, [4, 5]]) 扩展运算符可以将二维数组变为一维的
  }
  return arr
}
8. 数组去重
// 传统方式
function unique(arr) {
    const res = []
    arr.forEach(item => {
        if (res.indexOf(item) < 0) { // 没有当前元素
            res.push(item)
        }
    })
    return res
}
 
// 使用 Set (无序,不能重复)
function unique(arr) {
    const set = new Set(arr)
    return [...set]   // 解构
}
 
const res = unique([30, 10, 20, 30, 40, 10])
console.log(res)
9. 函数柯里化
在计算机科学中,柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。函数柯里化的主要作用和特点就是参数复用、提前返回和延迟执行。

function curry(fn, args) {
    var length = fn.length;
    var args = args || [];
    return function(){
        newArgs = args.concat(Array.prototype.slice.call(arguments));
        if (newArgs.length < length) {
            return curry.call(this,fn,newArgs);
        }else{
            return fn.apply(this,newArgs);
        }
    }
}
 
function multiFn(a, b, c) {
    return a * b * c;
}
 
var multi = curry(multiFn);
 
multi(2)(3)(4);
multi(2,3,4);
multi(2)(3,4);
multi(2,3)(4);
10. 实现jQuery
class jQuery {
    constructor(selector) {
        const result = document.querySelectorAll(selector)
        const length = result.length
        for (let i = 0; i < length; i++) {
            this[i] = result[i]
        }
        this.length = length
        this.selector = selector
    }
    get(index) {
        return this[index]
    }
    each(fn) {
        for (let i = 0; i < this.length; i++) {
            const elem = this[i]
            fn(elem)
        }
    }
    on(type, fn) {
        return this.each(elem => {
            elem.addEventListener(type, fn, false)
        })
    }
    // 扩展很多 DOM API
}
 
// 插件
jQuery.prototype.dialog = function (info) {
    alert(info)
}
// 扩展 “造轮子”
class myJQuery extends jQuery {
    constructor(selector) {
        super(selector)
    }
    // 扩展自己的方法
    addClass(className) {
    }
    style(data) {
    }
}
11. 手写promise
Promise内部then函数注册后续需要执行的函数,resolve函数执行。需要保证函数在执行前都已注册好,所以resolve内部执行函数的代码需要加入延时机制setTimeout(0)放在任务队列的末尾。加入状态机制,若为pending,则将函数注册,等待后续resolve调用。若为fulfilled,则立即执行。resolve函数,将状态设为fulfilled

function myPromise(constructor){
    let self=this;
    self.status="pending" //定义状态改变前的初始状态
    self.value=undefined;//定义状态为resolved的时候的状态
    self.reason=undefined;//定义状态为rejected的时候的状态
    function resolve(value){
        //两个==="pending",保证了状态的改变是不可逆的
       if(self.status==="pending"){
          self.value=value;
          self.status="resolved";
       }
    }
    function reject(reason){
        //两个==="pending",保证了状态的改变是不可逆的
       if(self.status==="pending"){
          self.reason=reason;
          self.status="rejected";
       }
    }
    //捕获构造异常
    try{
       constructor(resolve,reject);
    }catch(e){
       reject(e);
    }
}
实现promise链   参考链接:https://blog.csdn.net/chenzeze0707/article/details/79717354

复杂版本 :https://juejin.im/post/5c9c3989e51d454e3a3902b6#heading-17

12. 模拟new
创建一个新的空对象
把this绑定到空对象
使空对象的__proto__指向构造函数的原型(prototype)
执行构造函数,为空对象添加属性
判断构造函数的返回值是否为对象,如果是对象,就使用构造函数的返回值,否则返回创建的对象
function New(func) {
    var res = {};
    if (func.prototype !== null) {
        res.__proto__ = func.prototype;
    }
    var ret = func.apply(res, Array.prototype.slice.call(arguments, 1));
    if ((typeof ret === "object" || typeof ret === "function") && ret !== null) {
        return ret;
    }
    return res;
}
var obj = New(A, 1, 2);
// equals to
var obj = new A(1, 2);
13. 模拟instanceof
function instanceOf(left,right) {
    let proto = left.__proto__;
    let prototype = right.prototype
    while(true) {
        if(proto === null) return false
        if(proto === prototype) return true
        proto = proto.__proto__;
    }
}
版权声明:本文为CSDN博主「小白Rachel」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。原文链接https://blog.csdn.net/Sabrina_cc/article/details/106887861

面试官:你对SPA单页面的理解,它的优缺点分别是什么?如何实现SPA应用呢

#一、什么是SPA

SPA(single-page application),翻译过来就是单页应用SPA是一种网络应用程序或网站的模型,它通过动态重写当前页面来与用户交互,这种方法避免了页面之间切换打断用户体验在单页应用中,所有必要的代码(HTMLJavaScriptCSS)都通过单个页面的加载而检索,或者根据需要(通常是为响应用户操作)动态装载适当的资源并添加到页面页面在任何时间点都不会重新加载,也不会将控制转移到其他页面举个例子来讲就是一个杯子,早上装的牛奶,中午装的是开水,晚上装的是茶,我们发现,变的始终是杯子里的内容,而杯子始终是那个杯子结构如下图

我们熟知的JS框架如react,vue,angular,ember都属于SPA

#二、SPA和MPA的区别

上面大家已经对单页面有所了解了,下面来讲讲多页应用MPA(MultiPage-page application),翻译过来就是多页应用在MPA中,每个页面都是一个主页面,都是独立的当我们在访问另一个页面的时候,都需要重新加载htmlcssjs文件,公共文件则根据需求按需加载如下图

#单页应用与多页应用的区别

单页面应用(SPA)多页面应用(MPA)
组成一个主页面和多个页面片段多个主页面
刷新方式局部刷新整页刷新
url模式哈希模式历史模式
SEO搜索引擎优化难实现,可使用SSR方式改善容易实现
数据传递容易通过url、cookie、localStorage等传递
页面切换速度快,用户体验良好切换加载资源,速度慢,用户体验差
维护成本相对容易相对复杂

#单页应用优缺点

优点:

  • 具有桌面应用的即时性、网站的可移植性和可访问性
  • 用户体验好、快,内容的改变不需要重新加载整个页面
  • 良好的前后端分离,分工更明确

缺点:

  • 不利于搜索引擎的抓取
  • 首次渲染速度相对较慢

#三、实现一个SPA

#原理

  1. 监听地址栏中hash变化驱动界面变化
  2. pushsate记录浏览器的历史,驱动界面发送变化

#实现

#hash 模式

核心通过监听url中的hash来进行路由跳转

// 定义 Router  
class Router {  
    constructor () {  
        this.routes = {}; // 存放路由path及callback  
        this.currentUrl = '';  
          
        // 监听路由change调用相对应的路由回调  
        window.addEventListener('load', this.refresh, false);  
        window.addEventListener('hashchange', this.refresh, false);  
    }  
      
    route(path, callback){  
        this.routes[path] = callback;  
    }  
      
    push(path) {  
        this.routes[path] && this.routes[path]()  
    }  
}  
  
// 使用 router  
window.miniRouter = new Router();  
miniRouter.route('/', () => console.log('page1'))  
miniRouter.route('/page2', () => console.log('page2'))  
  
miniRouter.push('/') // page1  
miniRouter.push('/page2') // page2  

#history模式

history 模式核心借用 HTML5 history apiapi 提供了丰富的 router 相关属性先了解一个几个相关的api

  • history.pushState 浏览器历史纪录添加记录
  • history.replaceState修改浏览器历史纪录中当前纪录
  • history.popState 当 history 发生变化时触发
// 定义 Router  
class Router {  
    constructor () {  
        this.routes = {};  
        this.listerPopState()  
    }  
      
    init(path) {  
        history.replaceState({path: path}, null, path);  
        this.routes[path] && this.routes[path]();  
    }  
      
    route(path, callback){  
        this.routes[path] = callback;  
    }  
      
    push(path) {  
        history.pushState({path: path}, null, path);  
        this.routes[path] && this.routes[path]();  
    }  
      
    listerPopState () {  
        window.addEventListener('popstate' , e => {  
            const path = e.state && e.state.path;  
            this.routers[path] && this.routers[path]()  
        })  
    }  
}  
  
// 使用 Router  
  
window.miniRouter = new Router();  
miniRouter.route('/', ()=> console.log('page1'))  
miniRouter.route('/page2', ()=> console.log('page2'))  
  
// 跳转  
miniRouter.push('/page2')  // page2  

#四、题外话:如何给SPA做SEO

下面给出基于VueSPA如何实现SEO的三种方式

  1. SSR服务端渲染

将组件或页面通过服务器生成html,再返回给浏览器,如nuxt.js

  1. 静态化

目前主流的静态化主要有两种:(1)一种是通过程序将动态页面抓取并保存为静态页面,这样的页面的实际存在于服务器的硬盘中(2)另外一种是通过WEB服务器的 URL Rewrite的方式,它的原理是通过web服务器内部模块按一定规则将外部的URL请求转化为内部的文件地址,一句话来说就是把外部请求的静态地址转化为实际的动态页面地址,而静态页面实际是不存在的。这两种方法都达到了实现URL静态化的效果

  1. 使用Phantomjs针对爬虫处理

原理是通过Nginx配置,判断访问来源是否为爬虫,如果是则搜索引擎的爬虫请求会转发到一个node server,再通过PhantomJS来解析完整的HTML,返回给爬虫。下面是大致流程图

#参考文献

  • https://segmentfault.com/a/1190000019623624
  • https://juejin.cn/post/6844903512107663368
  • https://www.cnblogs.com/constantince/p/5586851.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值