uniapp 踩坑记录(二)

本文正在参加「金石计划」

本篇文章是 uniapp 踩坑记录的第二篇,第一篇在这里:uniapp 初体验踩坑记录

第一篇主要是侧重于开发的前期阶段,更多是工程化角度来说明一些很常见也很令人痛苦的坑点。本篇文章,希望和大家分享一下我本次项目的开发经验,包括检查用户的非法输入,例如 xss 及 sql 注入,还有版本更新,请求库封装,页面数据传递等。我的处理不一定是最正确的,但是希望给新朋友一点参考,帮助大家更好度过前期的痛苦阶段。
另外,我只负责给大家讲实际操作与应用,至于原理层面需要大家去自己探索。

封装 axios 请求

在请求封装这块,可能大多数人的选择还是 axios,当然了,选择 fetch 也可以,但是出于熟悉程度以及开发速度方面的考虑,最终还是选择了 axios。和 web 开发一样,一开始我也是拿着 web 的经验照常简单地封装了一下 axios:

import Axios from 'axios';

export const BASE_URL = 'https://xxxxxxxx.com'

const instance = Axios.create({
  baseURL: BASE_URL,
  timeout: 10000,
})

// 请求拦截器
instance.interceptors.request.use((config) => {
  ...
  uni.showLoading({ title: '加载中...' })
  return config
}, (error) => {
  console.log('request error>>>>>', error);
  uni.hideLoading()
  return Promise.reject(error)
})

// 响应拦截器
instance.interceptors.response.use((response) => {
  uni.hideLoading()
  return response.data
}, (error) => {
  console.log('response error>>>>>', error);
  uni.hideLoading()
  return Promise.reject(error)
})

/**
* get方法,对应get请求
* @param {String} url [请求的url地址]
* @param {Object} params [请求时携带的参数]
*/
export function $get(url, params) {
  return new Promise((resolve, reject) => {
    instance
      .get(url, { params })
      .then(res => {
        resolve(res);
      })
      .catch(err => {
        reject(err);
      });
  });
}
 
/**
* post方法,对应post请求
* @param {*} url
* @param {*} params
* @returns
*/
export function $post(url, params) {
  return new Promise((resolve, reject) => {
    instance
      .post(url, params)
      .then(res => {
        resolve(res);
      })
      .catch(err => {
        reject(err);
      });
  });
}

当我兴致勃勃地准备大干一场时,却发现请求一直失败,网上搜了一下才知道,axios 请求并不能直接在 uniapp 中使用,想要成功发起请求还需要适配 uni.request,这里我们需要写一个 adapter 适配器函数:

export default function(config) {
  return new Promise((resolve, reject) => {
    const settle = require('axios/lib/core/settle');
    const buildURL = require('axios/lib/helpers/buildURL');
    uni.request({
      method: config.method.toUpperCase(),
      url: config.baseURL + buildURL(config.url, config.params, config.paramsSerializer),
      header: config.headers,
      data: config.data,
      dataType: config.dataType,
      responseType: config.responseType,
      sslVerify: config.sslVerify,
      complete: function complete(response) {
        response = {
          data: response.data,
          status: response.statusCode,
          errMsg: response.errMsg,
          header: response.header,
          config: config
        };
        settle(resolve, reject, response);
      }
    })
  })
}

在封装 axios 时引入上面的 adapter 函数,并在生成 axios 实例时注入 adapter

import Axios from 'axios';
+ import adapter from './adapter';
import store from '../store';

export const BASE_URL = 'https://www.cjzx.group'

const instance = Axios.create({
  baseURL: BASE_URL,
  timeout: 10000,
  + adapter: adapter
})

这样处理过后,才能愉快地发起请求,不会再报错了。

校验用户输入非法字符

虽然只是一个小应用(其实不小了),也许未来的使用人数都不会超过100,但是既然是上生产的项目,就需要尽量做到最好(虽然才 tm 几千块钱)。我们不能抱有侥幸心理,觉得被攻击的可能性微乎其微就不做防护。在这里,由于 web 安全的知识有限,这里只做了 xss 与 sql 注入的防护,给大家一个参考。

xss 攻击与 sql 注入都是利用前端表单输入的攻击手段,xss 是在输入时插入一些 js 代码,从而盗取、篡改用户本地应用与信息。举个例子:

用户在表单里输入 eval("alert(sessionStorage.getItem('xxxx'))")

sql 注入也是类似,我们前端提交给后端的数据,非法用户可能会往里面插入一些 sql 代码,假设后端处理不够细致,这些 sql 被错误执行,就会发生严重的数据安全事故。

我们在这里分别编写两个函数,用于判断用户的输入中是否带有非法字符:

检查 xss:

/**
 * 判断是否有XSS语句
 * @param {*} value
 * @returns {Boolean}
 */
const checkIsXSS = (value = '') => {
  var res1 = (new RegExp("\b(document|onload|eval|script|img|svg|onerror|javascript|alert)\b")).test(value);
  var res2 = (new RegExp("<","g")).test(value);
  var res3 = (new RegExp(">","g")).test(value);
  return res1 || res2 || res3
}

检查 sql:

/**
 * 判断是否有类似SQL语句
 * @param {*} value
 * @returns {Boolean}
 */
const checkIsSQL = (value = '') => {
  let result = false
  const lowerCaseString = `${value}`.toLocaleLowerCase();
  const SQL_ENUM = [
    'select', 'union', 'asc', 'desc',
    'like', 'exec', 'from', 'into',
    'update', 'insert', 'delete', 'create',
    'asc(', 'char(', 'chr(', 'drop',
    'table', 'truncat', 'where', 'join',
    'count', 'alter', 'exists', 'or',
    'and', 'order by', 'group by', 'cast'
  ]
  SQL_ENUM.forEach(keyword => {
    if (lowerCaseString.includes(keyword)) {
      result = true    
    }
  })
  return result
}

将两个方法统一在一个校验方法里:

/**
 * 判断是否对象
 * @param {*} target
 * @returns {Boolean}
 */
export const isObject = (target) => Object.prototype.toString.call(target) === '[object Object]';

/**
 * 判断前端传参是否存在非法字符
 * @param {*} params
 * @returns {Boolean}
 */
export const checkParamsIsSafe = (params = {}) => {
  let result = true
  if (isObject(params)) {
    for (let key in params) {
      const isSQL = checkIsSQL(params[key])
      const isXSS = checkIsXSS(params[key])
      if (isSQL || isXSS) {
        result = false
      }
    }
  }
  return result
}

当然了,我们不可能在每个输入框里去做校验,应该是在用户提交数据的请求拦截器中去做统一处理:

const checkParamSafe = (config) => {
  let result = true;
  const { method, data, params } = config
  if (['get', 'GET'].includes(method)) {
    const isSafe = checkParamsIsSafe(params)
    result = isSafe
  }
  if (['post', 'POST'].includes(method)) {
    const isSafe = checkParamsIsSafe(data)
    result = isSafe
  }
  return result
}

// 请求拦截器
instance.interceptors.request.use((config) => {
  if (!checkParamSafe(config)) {
    uni.showToast({ title: '参数存在非法字符,请重新输入', icon: 'none' })
    return;
  }
  uni.showLoading({ title: '加载中...' })
  return config
}, (error) => {
  console.log('request error>>>>>', error);
  uni.hideLoading()
  return Promise.reject(error)
})

页面级数据传递

uniapp 针对页面数据传递提供了 API:eventChannel。下面举个例子,现在有一个商品列表,点击商品进入商品详情。一般做法是拿商品的 id 重新请求一遍,但是如果信息量很少,我们希望减少请求,商品详情是包含在商品列表的接口里的,这时候就需要在页面跳转的时候将商品详情发送到跳转的页面。下面演示代码:

商品列表点击商品在页面跳转时发送数据:

methods: {
  onClickItem(item) {
    uni.navigateTo({
      url: '/pages/goods/detail',
      success: function(res) {
        // 通过 eventChannel 向被打开页面传送数据
        // acceptDataFromOpenerPage 是固定写法,item 是传递的数据
        res.eventChannel.emit('acceptDataFromOpenerPage', item)
      }
    })
  }
},

商品详情页面接收数据:

onLoad: function(option) {
  const eventChannel = this.getOpenerEventChannel();
  // 监听 acceptDataFromOpenerPage 事件
  // 获取上一页面通过 eventChannel 传送到当前页面的数据
  eventChannel.on('acceptDataFromOpenerPage', (data) => {
    this.goodDetial = data
  })
}

安卓 app 实现版本更新

没有人敢对自己的 app 保证 100% 没有问题,如果是小程序或者 web,可以采用推送新代码或者官方提供的更新程序的 API 进行版本更新。但是 app 只有一条路:那就是下载新的安装包。由于时间成本(主要是客户钱没到位)关系,我们并没有制作 ios 版本的打算,所以这里就没法给大家提供 ios 的解决方案了。

首先我们看安卓 app 的版本更新机制,基本都是弹窗提醒,用户可以选择更新也可以选择不更新,当然,也可以强制用户更新,不更新不可以用。我们的思路也是弹窗,在应用每次初始加载时,就会去请求一遍版本接口,查看是否存在新的版本,如果存在,那么就会弹窗提示用户进行一个更新。

// App.vue
export default {
  onShow() {
    if (uni.$u.os() === 'android') {
      this.$store.dispatch('getVersion')
    }
  },
}

版本列表

首先大家需要知道,既然建立了版本机制,那么每个版本的安装包都会存放在我们的静态资源服务器里,需要根据版本号去下载对应的版本安装包。

我们当然可以自定义版本,实现一套自己的版本标准,但是我想除非是有实力的团队,既然都用上 uniapp 了,自然是遵循简单快速的原则。我们都知道 package.json 里有一个 version 字段,记录了我们应用的版本,遵循 semver 规范,形如 0.0.10.10.21。uniapp 打包安卓后就会将此版本号记录为我们 app 的本地版本。

我们希望后端返回的版本列表类似于:

[
    {
        latest: true,
        forceUpdate: false,
        version: 0.0.5,
        info: '本次更新了xxxx'
    },
    {
        latest: false,
        forceUpdate: false,
        version: 0.0.4,
        info: '本次更新了xxxx'
    },
    {
        latest: false,
        forceUpdate: false,
        version: 0.0.3,
        info: '本次更新了xxxx'
    },
    {
        latest: false,
        forceUpdate: false,
        version: 0.0.2,
        info: '本次更新了xxxx'
    },
    {
        latest: false,
        forceUpdate: false,
        version: 0.0.1,
        info: '本次更新了xxxx'
    }
]

首先需要通过 latest 判断当下后端版本库的版本是不是最新版,其次拿到该版本号与前端 app 版本号做对比,如果远程版本号新于本地版本,就发起下载请求,下载完成后调用安装器安装。

比较版本号大小

由于版本号都是类似 0.0.10.0.20.2.01.0.0 的版本号,需要实现一个方法比较版本号大小。

首先看下如何获取本地版本号:const { appVersion } = uni.getAppBaseInfo();,通过 uni.getAppBaseInfo() 可以获取到本地 app 的版本号。

实现版本号比较方法:

/**
 * 比较版本号大小
 * @param {String} version1
 * @param {String} version2
 * @returns -1 | 0 | 1
 */
export const compareVersion = (version1 = '', version2 = '') => {
  const sources = version1.split('.');
  const dests = version2.split('.');
  const maxL = Math.max(sources.length, dests.length);
  let result = 0;
  for (let i = 0; i < maxL; i++) {
    let preValue = sources.length > i ? sources[i] : 0;
    let preNum = isNaN(Number(preValue)) ? preValue.charCodeAt() : Number(preValue);
    let nextValue = dests.length > i ? dests[i] : 0;
    let nextNum =  isNaN(Number(nextValue)) ? nextValue.charCodeAt() : Number(nextValue);
    if (nextNum > preNum) {
      result = 1;
      break;
    } else if (nextNum < preNum) {
      result = -1;
      break;
    }
  }
  return result;
}

此方法也是参考网友实现的方法改造优化的,基本测试没有问题,但是没有经过成熟的单元测试,可能存在部分问题,或者仅适用于 semver 规范的版本号比较,其他的版本号类型不一定适合,如果你想复制使用请留意这点。

新安装包下载并唤起安装器安装

想象下,apk 下载下来后,怎么让用户安装呢?这完全不符合 web 开发习惯。

  • 打开本地文件夹保存,让用户自己找出安装包安装?
  • 如何打开手机上的文件夹呢?得申请什么权限呢?
  • 还要用户自己去找安装包,是不是太不友好了?

这里也不多说废话,其实需要用到 app 模块:plus

methods: {
  confirmUpdate() {
    this.onClose()
    const { version } = this.$store.state.versionInfo
    const downloadURL = `${BASE_URL}/${version}.apk`
    uni.showToast({ title: '后台静默下载中...', icon: 'none' })
    uni.downloadFile({
      url: downloadURL,
      filePath: '',
      onProgressUpdate: (progress) => {
        uni.showToast({ title: `${progress}`, icon: 'none' })
      },
      success: ({ tempFilePath, statusCode }) => {
        if (statusCode === 200) {
          uni.showToast({ title: 'App下载成功!', icon: 'none' })
          plus.runtime.install(tempFilePath, { force: false }, () => plus.runtime.restart())
        }
      },
      fail: (err) => {
        console.log('err>>>>>', err);
        uni.showToast({ title: 'App下载失败,请检查网络是否连接', icon: 'none' })
      },
    });
  },
  onClose() {
    this.$store.commit('setShouldUpdateApp', false)
  }
},

关键代码在于使用 uni.downloadFile 发起下载文件的请求,下载成功后调用 plus 模块进行 apk 的安装。

success: ({ tempFilePath, statusCode }) => {
  if (statusCode === 200) {
    uni.showToast({ title: 'App下载成功!', icon: 'none' })
    plus.runtime.install(tempFilePath, { force: false }, () => plus.runtime.restart())
  }
},

这里是固定写法,可以直接 copy 使用。

写在最后

本篇文章主要还是功能点及代码介绍,没有具体深入到一些原理细节层面,这也是给自己挖个坑。下一篇关于 uniapp 的内容,会侧重于介绍 plus、HTML5+ 标准等更深入的内容,敬请关注。

往期文章:

闲来无事,摸鱼时让 chatgpt 帮忙,写了一个 console 样式增强库并发布 npm

打开一本小说全是“锟斤拷”是什么鬼东西?

两小时学会 JS 正则表达式,终身不忘

【一年前端必知必会】如何写出简洁清晰的代码

【一年前端必知必会】了解 Blob,ArrayBuffer,Base64

原文链接:https://juejin.cn/post/7229899772768370725 作者:北岛贰

(0)
上一篇 2023年5月7日 上午11:07
下一篇 2023年5月8日 上午10:00

相关推荐

发表评论

登录后才能评论