移动 web 最佳实践(干货长文,建议收藏)(1)

}

阻止原生返回事件


开发中可能会遇到下面这个需求:当页面弹出一个 popup 或 dialog 组件时,点击返回键时是隐藏弹出的组件而不是返回到上一个页面。

为了解决这个问题,我们可以从路由栈角度思考。一般弹出组件是不会在路由栈上添加任何记录,因此我们在弹出组件时,可以在路由栈中 push 一个记录,为了不让页面跳转,我们可以把跳转的目标路由设置为当前页面路由,并加上一个 query 来标记这个组件弹出的状态。

然后监听 query 的变化,当点击弹出组件时,query 中与该弹出组件有关的标记变为 true,则将弹出组件设为显示;当用户点击 native 返回键时,路由返回上一个记录,仍然是当前页面路由,不过 query 中与该弹出组件有关的标记不再是 true 了,这样我们就可以把弹出组件设置成隐藏,同时不会返回上一个页面。相关代码如下:

<van-cell title=“几时入坑”

is-link

:value=“textData.pitDateStr”

@click=“goToSelect(‘calendar’)” />

<van-popup v-model=“showCalendar”

position=“right”

:style=“{ height: ‘100%’, width: ‘100%’ }”>

<Calendar title=“选择入坑时间”

@select=“onSelectPitDate” />

通过 UA 获取设备信息


在开发 h5 开发时,可能会遇到下面几种情况:

  1. 开发时都是在浏览器进行开发调试的,所以需要避免调用 native 的接口,因为这些接口在浏览器环境根本不存在;

  2. 有些情况需要区分所在环境是在 android webview 还是 ios webview,做一些针对特定平台的处理;

  3. 当 h5 版本已经更新,但是客户端版本并没有同步更新,那么如果之间的接口调用发生了改变,就会出现调用出错。

所以需要一种方式来检测页面当前所处设备的平台类型、app 版本、系统版本等,目前比较靠谱的方式是通过 android / ios webview 修改 UserAgent,在原有的基础上加上特定后缀,然后在网页就可以通过 UA 获取设备相关信息了。当然这种方式的前提是 native 代码是可以为此做出改动的。以安卓为例关键代码如下:

安卓关键代码:

// Activity -> onCreate

// 获取 app 版本

PackageManager packageManager = getPackageManager();

PackageInfo packInfo = null;

try {

// getPackageName()是你当前类的包名,0代表是获取版本信息

packInfo = packageManager.getPackageInfo(getPackageName(),0);

} catch (PackageManager.NameNotFoundException e) {

e.printStackTrace();

}

String appVersion = packInfo.versionName;

// 获取系统版本

String systemVersion = android.os.Build.VERSION.RELEASE;

mWebSettings.setUserAgentString(

mWebSettings.getUserAgentString() + " DSBRIDGE_" + appVersion + “_” + systemVersion + “_android”

);

h5 关键代码:

const initDeviceInfo = () => {

const UA = navigator.userAgent;

const info = UA.match(/s{1}DSBRIDGE[w.]+$/g);

if (info && info.length > 0) {

const infoArray = info[0].split(‘_’);

window.$appVersion = infoArray[1];

window.$systemVersion = infoArray[2];

window.$platform = infoArray[3] as Platform;

} else {

window.$appVersion = undefined;

window.$systemVersion = undefined;

window.$platform = ‘browser’;

}

};

mock 数据


Mock[71]

当前后端进度不一致,接口还尚未实现时,为了不影响彼此的进度,此时前后端约定好接口数据格式后,前端就可以使用 mock 数据进行独立开发了。本项目使用了 Mock 实现前端所需的接口。

调试控制台


eruda[72]

vconsole[73]

在调试方面,本项目使用 eruda 作为手机端调试面板,功能相当于打开 PC 控制台,可以很方便地查看 console, network, cookie, localStorage 等关键调试信息。与之类似地工具还有微信的前端研发团队开发的 vconsole,各位可以选择适合自己项目的工具。

关于 eruda 使用,推荐使用 cdn 方式加载,至于什么时候加载 eruda,可以根据不同项目制定不同策略。示例代码如下:

抓包工具


charles[74]

fiddler[75]

虽然有了 eruda 调试工具,但某些情况下仍不能满足需求,比如现网完全关闭 eruda 等情况。

此时就需要抓包工具,相关工具主要就是上面罗列的这两个,各位可以选择适合自己项目的工具。

通过 charles 可以清晰的查看所有请求的信息(注:https 下抓包需要在手机上配置相关证书)。当然 charles 还有更多强大功能,比例模拟弱网情况,资源映射等。

推荐一篇不错的 charles 使用教程:

解锁 Charles 的姿势[76]

异常监控平台


sentry[77]

移动端网页相对 PC 端,主要有设备众多,网络条件各异,调试困难等特点。导致如下问题:

  • 设备兼容或网络异常导致只有部分情况下才出现的 bug,测试无法全面覆盖

  • 无法获取出现 bug 的用户的设备,又不能复现反馈的 bug

  • 部分 bug 只出现几次,后面无法复现,不能还原事故现场

这时就非常需要一个异常监控平台,将异常实时上传到平台,并及时通知相关人员。

相关工具有 sentry,fundebug 等,其中 sentry 因为功能强大,支持多平台监控(不仅可以监控前端项目),完全开源,可以私有化部署等特点,而被广泛采纳。

下面是 sentry 在本项目应用时使用的相关配套工具。

sentry 针对 javascript 的 sdk

sentry-javascript[78]

自动上传 sourcemap 的 webpack 插件

sentry-webpack-plugin[79]

编译时自动在 try catch 中添加错误上报函数的 babel 插件

babel-plugin-try-catch-error-report[80]

补充:

前端的异常主要有以下几个部分:

  • 静态资源加载异常

  • 接口异常(包括与后端和 native 的接口)

  • js 报错

  • 网页崩溃

其中静态资源加载失败,可以通过 window.addEventListener(‘error’, …, true) 在事件捕获阶段获取,然后筛选出资源加载失败的错误并手动上报错误。核心代码如下:

// 全局监控资源加载错误

window.addEventListener(

‘error’,

(event) => {

// 过滤 js error

const target = event.target || event.srcElement;

const isElementTarget =

target instanceof HTMLScriptElement ||

target instanceof HTMLLinkElement ||

target instanceof HTMLImageElement;

if (!isElementTarget) {

return false;

}

// 上报资源地址

const url =

(target as HTMLScriptElement | HTMLImageElement).src ||

(target as HTMLLinkElement).href;

this.log({

error: new Error(ResourceLoadError: ${url}),

type: ‘resource load’

});

},

true

);

关于服务端接口异常,可以通过在封装的 http 模块中,全局集成上报错误函数(native 接口的错误上报类似,可在项目中查看)。核心代码如下:

function errorReport(

url: string,

error: string | Error,

requestOptions: AxiosRequestConfig,

response?: AnyObject

) {

if (window.$sentry) {

const errorInfo: RequestErrorInfo = {

error: typeof error === ‘string’ ? new Error(error) : error,

type: ‘request’,

requestUrl: url,

requestOptions: JSON.stringify(requestOptions)

};

if (response) {

errorInfo.response = JSON.stringify(response);

}

window.$sentry.log(errorInfo);

}

}

关于全局 js 报错,sentry 针对的前端的 sdk 已经通过 window.onerror 和 window.addEventListener(‘unhandledrejection’, …, false) 进行全局监听并上报。

需要注意的是其中 window.onerror = (message, source, lineno, colno, error) =>{} 不同于 window.addEventListener(‘error’, …),window.onerror 捕获的信息更丰富,包括了错误字符串信息、发生错误的 js 文件,错误所在的行数、列数、和 Error 对象(其中还会有调用堆栈信息等)。所以 sentry 会选择 window.onerror 进行 js 全局监控。

但有一种错误是 window.onerror 监听不到的,那就是 unhandledrejection 错误,这个错误是当 promise reject 后没有 catch 住所引起的。当然 sentry 的 sdk 也已经做了监听。

针对 vue 项目,也可对 errorHandler 钩子进行全局监听,react 的话可以通过 componentDidCatch 钩子,vue 相关代码如下:

// 全局监控 Vue errorHandler

Vue.config.errorHandler = (error, vm, info) => {

window.$sentry.log({

error,

type: ‘vue errorHandler’,

vm,

info

});

};

但是对于我们业务中,经常会对一些以报错代码使用 try catch,这些错误如果没有在 catch 中向上抛出,是无法通过 window.onerror 捕获的,针对这种情况,笔者开发了一个 babel 插件 babel-plugin-try-catch-error-report[81],该插件可以在 babel[82] 编译 js 的过程中,通过在 ast 中查找 catch 节点,然后再 catch 代码块中自动插入错误上报函数,可以自定义函数名,和上报的内容(源码所在文件,行数,列数,调用栈,以及当前 window 属性,比如当前路由信息 window.location.href)。相关配置代码如下:

if (!IS_DEV) {

plugins.push([

‘try-catch-error-report’,

{

expression: ‘window.$sentry.log’,

needFilename: true,

needLineNo: true,

needColumnNo: false,

needContext: true,

exclude: [‘node_modules’]

}

]);

}

针对跨域 js 问题,当加载的不同域的 js 文件时,例如通过 cdn 加载打包后的 js。如果 js 报错,window.onerror 只能捕获到 script error,没有任何有效信息能帮助我们定位问题。此时就需要我们做一些事情:第一步、服务端需要在返回 js 的返回头设置 Access-Control-Allow-Origin: *第二部、设置 script 标签属性 crossorigin,代码如下:

如果是动态添加的,也可动态设置:

const script = document.createElement(‘script’);

script.crossOrigin = ‘anonymous’;

script.src = url;

document.body.appendChild(script);

针对网页崩溃问题,推荐一个基于 service work 的监控方案,相关文章已列在下面的。如果是 webview 加载网页,也可以通过 webview 加载失败的钩子监控网页崩溃等。

如何监控网页崩溃?[83]

最后,因为部署到线上的代码一般都是经过压缩混淆的,如果没有上传 sourcemap 的话,是无法定位到具体源码的,可以现在 项目中添加 .sentryclirc 文件,其中内容可参考本项目的 .sentryclirc,然后通过 sentry-cli (需要全局全装 sentry-cli 即npm install sentry-cli)命令行工具进行上传,命令如下:

sentry-cli releases -o 机构名 -p 项目名 files 版本 upload-sourcemaps sourcemap 文件相对位置 --url-prefix js 在线上相对根目录的位置 --rewrite

// 示例

sentry-cli releases -o mcukingdom -p hello-world files 0.2.1 upload-sourcemaps dist/js --url-prefix ‘~/js/’ --rewrite

当然官方也提供了 webpack 插件 sentry-webpack-plugin[84],当打包时触发 webpack 的 after-emit 事件钩子(即生成资源到 output 目录之后),插件会自动上传打包目录中的 sourcemap 和关联的 js,相关配置可参考本项目的 vue.config.js 文件。

通常为了安全,是不允许在线上部署 sourcemap 文件的,所以上传 sourcemap 到 sentry 后,可手动删除线上 sourcemap 文件。

常见问题


  • iOS WKWebView cookie 写入慢以及易丢失

现象:

**原因:**WKWebView 对 NSHTTPCookieStorage 写入 cookie,不是实时存储的。从实际的测试中发现,不同的 IOS 版本,延迟的时间还不一样。同样,发起请求时,也不是实时读取,无法做到和 native 同步,导致页面逻辑出错。

两种解决办法:

各位可以选择适合自己项目的方式,有更好的处理方式欢迎留言。

  1. 客户端手动干预一下 cookie 的存储。将服务响应的 cookie,持久化到本地,在下次 webview 启动时,读取本地的 cookie 值,手动再去通过 native 往 webview 写入。但是偶尔还有 spa 的页面路由切换的时候丢失 cookie 的问题。

  2. 将 cookie 存储的 session 持久化到 localSorage,每次请求时都会取 localSorage 存储的 session,并在请求头部添加 cookieback 字段,服务端鉴权时,优先校验 cookieback 字段。这样即使 cookie 丢失或存储的上一次的 session,都不会有影响。不过这种方式相当于绕开了 cookie 传输机制,无法享受 这种机制带来的安全特性。

  3. iOS 登陆后立即进入网页,会出现 cookie 获取不到或获取的上一次登陆缓存的 cookie

  4. 重启 App 后,cookie 会丢失

  • input 标签在部分安卓 webview 上无法实现上传图片功能

因为 Android 的版本碎片问题,很多版本的 WebView 都对唤起函数有不同的支持。我们需要重写 WebChromeClient 下的 openFileChooser()(5.0 及以上系统回调 onShowFileChooser())。我们通过 Intent 在 openFileChooser()中唤起系统相机和支持 Intent 的相关 app。

相关文章:【Android】WebView 的 input 上传照片的兼容问题[85]

  • input 标签在 iOS 上唤起软键盘,键盘收回后页面不回落(部分情况页面看上去已经回落,实际结构并未回落)

input 焦点失焦后,ios 软键盘收起,但没有触发 window resize,导致实际页面 dom 仍然被键盘顶上去–错位。解决办法:全局监听 input 失焦事件,当触发事件后,将 body 的 scrollTop 设置为 0。

document.addEventListener(‘focusout’, () => {

document.body.scrollTop = 0;

});

  • 唤起软键盘后会遮挡输入框

当 input 或 textarea 获取焦点后,软键盘会遮挡输入框。解决办法:全局监听 window 的 resize 事件,当触发事件后,获取当前 active 的元素并检验是否为 input 或 textarea 元素,如果是则调用元素的 scrollIntoViewIfNeeded 即可。

window.addEventListener(‘resize’, () => {

// 判断当前 active 的元素是否为 input 或 textarea

if (

document.activeElement!.tagName === ‘INPUT’ ||

document.activeElement!.tagName === ‘TEXTAREA’

) {

setTimeout(() => {

// 原生方法,滚动至需要显示的位置

document.activeElement!.scrollIntoView();

}, 0);

}

});

  • 唤起键盘后 position: fixed;bottom: 0px; 元素被键盘顶起

解决办法:全局监听 window 的 resize 事件,当触发事件后,获取 id 名为 fixed-bottom 的元素(可提前约定好如何区分定位在窗口底部的元素),将其设置成 display: none。键盘收回时,则设置成 display: block;

const clientHeight = document.documentElement.clientHeight;

window.addEventListener(‘resize’, () => {

const bodyHeight = document.documentElement.clientHeight;

const ele = document.getElementById(‘fixed-bottom’);

if (!ele) return;

if (clientHeight > bodyHeight) {

(ele as HTMLElement).style.display = ‘none’;

} else {

(ele as HTMLElement).style.display = ‘block’;

}

});

  • 点击网页输入框会导致网页放大通过 viewport 设置 user-scalable=no 即可,(注意:当 user-scalable=no 时,无需设置 minimum-scale=1, maximum-scale=1,因为已经禁止了用户缩放页面了,允许的缩放范围也就不存在了)。代码如下:

<meta

name=“viewport”

content=“width=device-width,initial-scale=1.0,user-scalable=0,viewport-fit=cover”

/>

  • webview 通过 loadUrl 加载的页面运行时却通过第三方浏览器打开,代码如下

// 创建一个 Webview

Webview webview = (Webview) findViewById(R.id.webView);

// 调用 Webview loadUrl

webview.loadUrl(“http://www.baidu.com/”);

解决办法:在调用 loadUrl 之前,设置下 WebviewClient 类,当然如果需要也可自己实现 WebviewClient(例如通过拦截 prompt 实现 js 与 native 的通信)

webview.setWebViewClient(new WebViewClient());

  • 参考资料

[1]

mattermost-mobile: https://github.com/mattermost/mattermost-mobile

[2]

mobile-web-best-practice: https://github.com/mcuking/mobile-web-best-practice

[3]

vue-cli3: https://cli.vuejs.org/

[4]

typescript: http://www.typescriptlang.org/

[5]

react: https://reactjs.org/

[6]

组件库: #组件库

[7]

JSBridge: #jsbridge

[8]

路由堆栈管理(模拟原生 APP 导航): #路由堆栈管理模拟原生-app-导航

[9]

请求数据缓存: #请求数据缓存

[10]

构建时预渲染: #构建时预渲染

[11]

Webpack 策略: #webpack-策略

[12]

基础库抽离: #基础库抽离

[13]

手势库: #手势库

[14]

样式适配: #样式适配

[15]

表单校验: #表单校验

[16]

阻止原生返回事件: #阻止原生返回事件

[17]

通过 UA 获取设备信息: #通过-ua-获取设备信息

[18]

mock 数据: #mock-数据

[19]

调试控制台: #调试控制台

[20]

抓包工具: #抓包工具

[21]

异常监控平台: #异常监控平台

[22]

常见问题: #常见问题

[23]

vant: https://youzan.github.io/vant/#/zh-CN/intro

[24]

vux: https://github.com/airyland/vux

[25]

mint-ui: https://github.com/ElemeFE/mint-ui

[26]

cube-ui: https://github.com/didi/cube-ui

[27]

less-loader: https://github.com/webpack-contrib/less-loader

[28]

less: http://lesscss.org/

[29]

modifyVars: http://lesscss.org/usage/#using-less-in-the-browser-modify-variables

[30]

定制主题: https://youzan.github.io/vant/#/zh-CN/theme

[31]

Vue 常用组件库的比较分析(移动端): https://blog.csdn.net/weixin_38633659/article/details/89736656

[32]

DSBridge-IOS: https://github.com/wendux/DSBridge-IOS

[33]

DSBridge-Android: https://github.com/wendux/DSBridge-Android

[34]

WebViewJavascriptBridge: https://github.com/marcuswestin/WebViewJavascriptBridge

[35]

mobile-web-best-practice-container: https://github.com/mcuking/mobile-web-best-practice-container

[36]

JSBridge: https://github.com/mcuking/JSBridge

[37]

JSBridge 实现原理: https://github.com/mcuking/JSBridge

[38]

vue-page-stack: https://github.com/hezhongfeng/vue-page-stack

[39]

vue-navigation: https://github.com/zack24q/vue-navigation

[40]

vue-stack-router: https://github.com/luojilab/vue-stack-router

[41]

vue-router: https://router.vuejs.org/

[42]

【vue-page-stack】Vue 单页应用导航管理器 正式发布: https://juejin.im/post/5d2ef417f265da1b971aa94f

[43]

Vue 社区的路由解决方案:vue-stack-router: https://juejin.im/post/5d4ce4fd6fb9a06acd450e8c

[44]

mem: https://github.com/sindresorhus/mem

[45]

nuxt.js: https://github.com/nuxt/nuxt.js

[46]

next: https://github.com/zeit/next.js

[47]

Puppeteer: https://github.com/GoogleChrome/puppeteer

[48]

Phantomjs: https://github.com/ariya/phantomjs

[49]

prerender-spa-plugin: https://github.com/chrisvfritz/prerender-spa-plugin

[50]

vue 预渲染之 prerender-spa-plugin 解析(一): https://blog.csdn.net/vv_bug/article/details/84593052

[51]

使用预渲提升 SPA 应用体验: https://juejin.im/post/5d5fa22ee51d4561de20b5f5

[52]

webpack-dll-plugin: https://webpack.docschina.org/plugins/dll-plugin/

[53]

Externals: https://webpack.docschina.org/configuration/externals/

[54]

Webpack 优化——将你的构建效率提速翻倍: https://juejin.im/post/5d614dc96fb9a06ae3726b3e

[55]

hammer.js: https://github.com/hammerjs/hammer.js

[56]

AlloyFinger: https://github.com/AlloyTeam/AlloyFinger

[57]

H5 案例分享:JS 手势框架 —— Hammer.js: https://www.h5anli.com/articles/201609/hammerjs.html

[58]

使用 require.context 实现前端工程自动化: https://www.jianshu.com/p/c894ea00dfec

[59]

postcss-px-to-viewport: https://github.com/evrone/postcss-px-to-viewport

[60]

Viewport Units Buggyfill: https://github.com/rodneyrehm/viewport-units-buggyfill

[61]

flexible: https://github.com/amfe/lib-flexible

[62]

postcss-pxtorem: https://github.com/cuth/postcss-pxtorem

[63]

Autoprefixer: https://github.com/postcss/autoprefixer

[64]

browserslist: https://github.com/browserslist/browserslist

[65]

Viewport Units Buggyfill: https://github.com/rodneyrehm/viewport-units-buggyfill

[66]

rem-vw-layout: https://github.com/imwtr/rem-vw-layout

[67]

细说移动端 经典的 REM 布局 与 新秀 VW 布局: https://www.cnblogs.com/imwtr/p/9648233.html

[68]

如何在 Vue 项目中使用 vw 实现移动端适配: https://www.jianshu.com/p/1f1b23f8348f

[69]

async-validator: https://github.com/yiminghe/async-validator

[70]

vee-validate: https://github.com/baianat/vee-validate

[71]

Mock: https://github.com/nuysoft/Mock

[72]

eruda: https://github.com/liriliri/eruda

[73]

vconsole: https://github.com/Tencent/vConsole

[74]

charles: https://www.charlesproxy.com/

[75]

fiddler: https://www.telerik.com/fiddler

[76]

解锁 Charles 的姿势: https://juejin.im/post/5a1033d2f265da431f4aa81f

[77]

sentry: https://github.com/getsentry/sentry

[78]

sentry-javascript: https://github.com/getsentry/sentry-javascript

[79]

sentry-webpack-plugin: https://github.com/getsentry/sentry-webpack-plugin

[80]

babel-plugin-try-catch-error-report: https://github.com/mcuking/babel-plugin-try-catch-error-report

[81]

babel-plugin-try-catch-error-report: https://github.com/mcuking/babel-plugin-try-catch-error-report

[82]

babel: https://babeljs.io/

总结

我在成长过程中也是一路摸爬滚打,没有任何人的指点,所以走的很艰难。例如在大三的时候,如果有个学长可以阶段性的指点一二,如果有已经工作的师兄可以告诉我工作上需要什么,我应该前面的三年可以缩短一半;后来去面试bat,失败了有5、6次,每次也不知道具体是什么原因,都是靠面试回忆去猜测可能是哪方面的问题,回来学习和完善,当你真正去招人的时候,你就会知道面试记录是多么重要,面试官可以从面试记录里看到你的成长,总是去面试,总是没有成长,就会被定义为缺乏潜力。

开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】

image
如何在 Vue 项目中使用 vw 实现移动端适配: https://www.jianshu.com/p/1f1b23f8348f

[69]

async-validator: https://github.com/yiminghe/async-validator

[70]

vee-validate: https://github.com/baianat/vee-validate

[71]

Mock: https://github.com/nuysoft/Mock

[72]

eruda: https://github.com/liriliri/eruda

[73]

vconsole: https://github.com/Tencent/vConsole

[74]

charles: https://www.charlesproxy.com/

[75]

fiddler: https://www.telerik.com/fiddler

[76]

解锁 Charles 的姿势: https://juejin.im/post/5a1033d2f265da431f4aa81f

[77]

sentry: https://github.com/getsentry/sentry

[78]

sentry-javascript: https://github.com/getsentry/sentry-javascript

[79]

sentry-webpack-plugin: https://github.com/getsentry/sentry-webpack-plugin

[80]

babel-plugin-try-catch-error-report: https://github.com/mcuking/babel-plugin-try-catch-error-report

[81]

babel-plugin-try-catch-error-report: https://github.com/mcuking/babel-plugin-try-catch-error-report

[82]

babel: https://babeljs.io/

总结

我在成长过程中也是一路摸爬滚打,没有任何人的指点,所以走的很艰难。例如在大三的时候,如果有个学长可以阶段性的指点一二,如果有已经工作的师兄可以告诉我工作上需要什么,我应该前面的三年可以缩短一半;后来去面试bat,失败了有5、6次,每次也不知道具体是什么原因,都是靠面试回忆去猜测可能是哪方面的问题,回来学习和完善,当你真正去招人的时候,你就会知道面试记录是多么重要,面试官可以从面试记录里看到你的成长,总是去面试,总是没有成长,就会被定义为缺乏潜力。

开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】

[外链图片转存中…(img-LJ9S43pC-1714735110894)]
image

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值