前言:该项目是仿照ios计算器app实现,部分交互细节未实现。主体逻辑是根据自己经验实现,有问题之处欢迎指出。

Github 仓库地址

使用 uni-app 搭建项目后在 index.vue 编写 UI 静态页代码, 同级创建 utils.js 文件,存放一些常量和工具函数。

// index.vue
<template>
    <view class="content">
        <view class="view">
            <span :class="{lessen: viewValue.length >= 7}">
                {{ viewValue }}
            </span>
        </view>

        <!-- 按钮 -->
        <view class="btns">
            <view v-for="item in btns" :key='item.text' :style="item.style" :class="item.className" :data-text="item.text"></view>
        </view>
    </view>
</template>

<script>
import { KEYS, symbolReg1, symbolReg2, symbolReg3 } from './utils';
export default {
    data() {
        return {
            // 渲染值
            viewValue: 0,
            btns: KEYS && KEYS.map(item => {
                let style = {}
                let className = 'default'

                // 相同类型的添加 class
                if (symbolReg3.test(item) || item === KEYS[KEYS.length - 1]) {
                    className = 'origin'
                }
                if (new RegExp(`${KEYS[0]}|(?=${KEYS[1]})|${KEYS[2]}`).test(item)) {
                    className = 'gray'
                }

                // 单独区分样式
                if (item === KEYS[KEYS.length - 3]) {
                    style.width = 'calc(144rpx * 2 + 100% / 4 / 4)'
                    style.justifyContent = 'flex-start'
                    style.padding = '0 50rpx'
                }
                if (item === KEYS[1]) {
                    style.fontSize = '50rpx'
                }
                if (item === KEYS[0]) {
                    style.fontSize = '50rpx'
                }

                return {
                    text: item,
                    style,
                    className
                }
            })
        }
    }
}
</script>
<style scoped lang="scss">
.content {
    display: flex;
    flex-wrap: wrap;
    align-content: flex-end;
    background: #000000;
    min-height: 100vh;
    box-sizing: border-box;
    padding: 30rpx;
    font-family: PingFangSC-Regular, sans-serif;

    > view {
        width: 100%;
        color: #ffffff;
    }

    .view {
        text-align: right;
        font-size: 168rpx;
        line-height: 100px;
        > .lessen {
            font-size: 138rpx;
        }
        > span {
            display: inline-block;

            box-sizing: border-box;
            -webkit-user-select: text;
            -moz-user-select: text;
            -ms-user-select: text;
            user-select: text;
            overflow: hidden;
            border-radius: 20rpx;
            &::selection {
                background-color: #333;
                color: #fff;
            }
        }
    }

    .btns {
        display: flex;
        flex-wrap: wrap;
        justify-content: space-between;
        height: 870rpx;
        > view {
            width: 144rpx;
            height: 144rpx;
            border-radius: 144rpx;
            box-sizing: border-box;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 60rpx;
            color: #ffffff;
            transition: all .3s;
            background: #333333;
            &.origin {
                    background: #fea00c;
                    &:active {
                            background: #ffffff;
                            color: #fea00c;
                    }
            }
            &.gray {
                    background: #a5a5a5;
                    color: #000000;
                    &:active {
                            background: #eeeeee;
                    }
            }
            &.default {
                    &:active {
                            background: #bbbbbb;
                    }
            }
            &::after {
                    content: attr(data-text);
            }
        }
    }
}
</style>
// utils.js
export const KEYS = ['AC', '+/-', '%', '÷', '7', '8', '9', '×', '4', '5', '6', '-', '1', '2', '3', '+', '0', '.', '=']
// 乘除正则
export const symbolReg1 = new RegExp(`^[${KEYS[4 - 1]}${KEYS[2 * 4 - 1]}]$`)
// 加减正则
export const symbolReg2 = new RegExp(`^[\\${KEYS[3 * 4 - 1]}${KEYS[4 * 4 - 1]}]$`)
// 加减乘除正则
export const symbolReg3 = new RegExp(`^[${KEYS[4 - 1]}${KEYS[2 * 4 - 1]}\\${KEYS[3 * 4 - 1]}${KEYS[4 * 4 - 1]}]$`)

到这一步页面效果已经出来了但是还没有点击事件。这里正则中是使用动态正则方式,避免 KEYS 常量中数据半角全角符号改变而无法匹配。

这里使用事件委托方式,给页面 classbtns 的元素添加点击事件 <view class="btns" @click="handleClick">,而不是在 v-for 循环元素上添加事件。在 methods 属性中添加 handleClick 函数。

handleClick({ target }) {
    // 判断是否点击元素
    if (target.dataset.text == undefined) return

    switch (target.dataset.text) {
        // AC 点击
        case this.btns[0].text:
            break;
        // 加减乘除点击
        case KEYS[4 - 1]:
        case KEYS[2 * 4 - 1]:
        case KEYS[3 * 4 - 1]:
        case KEYS[4 * 4 - 1]:
            break;
        // 等于
        case KEYS[KEYS.length - 1]:
            break;
        // 取反
        case KEYS[1]:
            break;
        // 百分号
        case KEYS[2]:
            break;
        // 数字
        default:

    }
},

编写好主体逻辑之后一步步完善,先补充辅助变量和数字点击逻辑

import { KEYS, symbolReg1, symbolReg2, symbolReg3 } from './utils';
// 计算逻辑
let computes = []

// 判断最后一个是否为计算符号
const isSymbol = () => {
    return symbolReg3.test(computes[computes.length - 1])
}
//...
default:
    // `viewValue` 的长度在 `ios` 中只有 `9` 位数,存在 `.` 的时候有 `10` 位数,所以在输入数字时也需要加判断。判断是否存在小数点
    if (this.viewValue.length > (String(this.viewValue).indexOf('.') != -1 ? 9 : 8)) {
        return
    }
    
    // 判断是否最后一个是否是字符
    if (isSymbol()) {
        this.viewValue = target.dataset.text
        computes.push(this.viewValue)
    } else {
        // 判断是否存在 .
        if (String(this.viewValue).indexOf('.') != -1 && target.dataset.text === '.') {
            return
        }
        // 三元判断是否为点,避免 Number(.) 会为NaN
        this.viewValue += this.viewValue === 0
            ? target.dataset.text !== '.' ? Number(target.dataset.text) : target.dataset.text
            : target.dataset.text
        // 替换栈最后一个数字
        computes[computes.length > 0 ? computes.length - 1 : 0] = this.viewValue
    }

    // 修改页面 AC 文字
    this.btns[0].text = computes.length > 0 || this.viewValue > 0 ? 'C' : KEYS[0]
//...

补充取反和百分号逻辑

// 取反
case KEYS[1]:
    // 兼容-0的情况
    if (this.viewValue === '-0') {
        this.viewValue = 0
    } else {
        this.viewValue = this.viewValue >= 0 ? '-' + this.viewValue : Math.abs(this.viewValue)
    }
    
    // 这里用if判断的话就不需要走 lastIndexOf循环查找了
    if (computes.length == 1) {
        computes[0] = this.viewValue
    } else {
        const lastIndex = computes.lastIndexOf(this.viewValue)
        computes.splice(lastIndex, 1, this.viewValue)
    }
    break;
// 百分号
case KEYS[2]:
    this.viewValue = this.viewValue * 0.01
    if (computes.length == 1) {
        computes[0] = this.viewValue
    } else {
        const lastIndex = computes.lastIndexOf(this.viewValue)
        computes.splice(lastIndex, 1, this.viewValue)
    }
    break;

加减乘除符号点击,在 import 下加入辅助变量 countSymbol1、countSymbol2

import { KEYS, symbolReg1, symbolReg2, symbolReg3 } 'utils.js'
// ...
// 乘除运算符次数
let countSymbol1 = 0
// 加减
let countSymbol2 = 0
// ...
// 加减乘除点击
case KEYS[4 - 1]:
case KEYS[2 * 4 - 1]:
case KEYS[3 * 4 - 1]:
case KEYS[4 * 4 - 1]:
    // 不能一开始就是符号
    if (computes.length == 0) return

    if (isSymbol()) {
        // 改变符号
        computes[computes.length - 1] = target.dataset.text
    } else {
        computes.push(target.dataset.text)
        // 加入统计字符数量
        if (symbolReg1.test(target.dataset.text)) countSymbol1++
        if (symbolReg2.test(target.dataset.text)) countSymbol2++
    }

    break;

等于符号点击, 添加计算逻辑函数 numFun,添加辅助变量 cacheLastSymbolutils.js 文件添加运算函数 operation,在 index.vue 文件中导入 operation

ios 计算器中,当如输入 3 + 3 点击多次 = 时, 会在第一次等于运算的结果上一直 + 3,而当输入 3 + 3 + 时会将 3 + 3 的值进行计算出来再进行最后一个加,也就是变成了 6 +当最后一个是运算符时会一直对运算符前一个数字进行运算,结果会变成 12

再举一个例子,2 + 5 + 5 + 结果是 27, 因为它会先将 2 + 5 + 5 的结果先进行计算,再将的到的结果相加 也就变成了 12 +2 + 5 * 5 * 结果是 627,它是对 * 先进行计算也就是 2 + 25 *25 * 25 = 625+ 2

cacheLastSymbol 的作用为存储最后一个字符和最后一个数字,例如:3 + 5 + 会存储 8+
2 + 5 * 5 * 则会存储 25*3 + 5 则会储存 5+。当你点击第二次 = 的时候会拿到第一次的值在拿到 cacheLastSymbol 中储存的值和运算符进行运算。

// utils.js
/// ...
// 计算结果
export const operation = (num1, num2, symbol) => {
    let num = undefined
    // 这里可以使用 eval 将字符串当做js执行, 如果是 × 需要去判断改为 *
    switch (symbol) {
        case KEYS[4 - 1]:
            num = num1 / num2
            break;
        case KEYS[2 * 4 - 1]:
            num = num1 * num2
            break;
        case KEYS[3 * 4 - 1]:
            num = num1 - num2
            break;
        case KEYS[4 * 4 - 1]:
            num = Number(num1) + Number(num2)
            break;
    }

    // 为8主要是ios最大为8为小数
    return String(parseFloat(Number(num).toPrecision(8)))
}
// index.vue
import { KEYS, symbolReg1, symbolReg2, symbolReg3, operation } from './utils';
// ...
// 存储最后操作符 0 为操作符,1为值
let cacheLastSymbol = []
/**
 * 计算逻辑
 * @param {string} type 0加减 1乘除
 * @param {number} count 次数
 * @returns string
 * */
const numFun = (type, count) => {
    let num = undefined
    for(let i = 1; i <= count; i++) {
        const index = computes.findIndex(symbol => type === '0' ? symbolReg2.test(symbol) : symbolReg1.test(symbol))
        
        // 这里做容错判断 避免不存在运算符号时进行运算
        if (index == -1) return
        // 进行计算
        // 这里可以使用 eval将字符串当做js执行, 需要去判断 *
        if (index === computes.length - 1) { // 如果最后一个是字符而非数字的情况
            computes.splice(index - 1, 2, operation(computes[index - 1], computes[index - 1], computes[index]))
        } else {
            computes.splice(index - 1, 3, operation(computes[index - 1], computes[index + 1], computes[index]))
        }

        /**
          * 最后一个是操作符时添加栈中计算的值
          * 假设 2 + 5 * 5 * 4 *
          * 第一次进入时存储 5 * 5 的值 ,此时computes栈中为 [2, '+', '25', '*', '4', '*']
          * 第二次进入时存储 25 * 4 的值, 此时computes栈中为 [2, '+', '100', '*']
          */
        if (isSymbol()) {
            cacheLastSymbol[1] = computes[computes.length - 2]
        }
    }
}
// ...
// 等于
case KEYS[KEYS.length - 1]:
    if (cacheLastSymbol.length == 2) { // 第二次点击会进入
        computes[0] = operation(computes[0], cacheLastSymbol[1], cacheLastSymbol[0])
    }
    if (countSymbol1 || countSymbol2) { // 存在操作符时,将操作符加入缓存变量
        if (isSymbol()) { // 判断最后一个是操作符则取操作符前一个
            cacheLastSymbol[0] = computes[computes.length - 1]
            cacheLastSymbol[1] = computes[computes.length - 2]
        } else {
            cacheLastSymbol[0] = computes[computes.length - 2]
            cacheLastSymbol[1] = computes[computes.length - 1]
        }
    }

    if (countSymbol1) numFun('1', countSymbol1)
    if (countSymbol2) numFun('0', countSymbol2)

    // 清除操作符统计
    countSymbol1 = 0
    countSymbol2 = 0

    if (computes.length == 1) {
        this.viewValue = computes[0]
    }
    break;

再将渲染数据格式化, methods 添加 formatt 函数,因为我们只在页面写了一个 formatt,所以在 methods 写也可以,不会存在性能问题,当页面中写了多个 formatt(viewValue) 时建议改成 computed 计算属性形式,利用计算属性的缓存机制优化性能。

<span :class="{lessen: viewValue.length >= 7}">
    {{ formatt(viewValue) }}
</span>

// ...
methods: {
// ...
    formatt(val) {
        if (/\.$/.test(val)) {
            return val
        } else {
            // 最多保留8位小数
            return Number(val).toLocaleString('en-US', {
                minimumFractionDigits: 0,
                maximumFractionDigits: 8
            })
        }
    }
}

到这一步基本完成了整体功能。
但是有些细节还是需要完善,2 + 5 * 5 * 输入完成时 viewValue 的值应该为 25,当 * 改为 +2 + 5 * 5 + 值应该是 27

添加辅助变量 editStatus ,在 utils.js 文件中添加 computeCount 工具函数,加减乘除逻辑中添加代码

// utils.js
// 计算总和值,如果最后一个是字符时不进行计算
export const computeCount = (computes) => {
    // 添加缓存变量
    let cacheIndex = undefined
    let index = undefined
    for (let i in computes) {
        // 如果当前是乘除则跳出循环,并且给index赋值下标
        if (symbolReg1.test(computes[i])) {
            index = i
            break
        } else if (!cacheIndex && symbolReg2.test(computes[i])) {
            cacheIndex = i
        }
    }
    
    // 如果 computes 中没有乘除符号时,index 会为空,此时将 缓存下的下标赋值给 index
    index = Number(index || cacheIndex)
    // 替换数组内容
    computes.splice(index - 1, 3, operation(computes[index - 1], computes[index + 1], computes[index]))
    
    // 长度大于 2 时说明还需要进行递归计算
    if (computes.length > 2) {
        return computeCount(computes)
    } else {
        return computes[0]
    }
}
// index.vue
import { KEYS, symbolReg1, symbolReg2, symbolReg3, operation, computeCount } from './utils';
// 修改状态 0 不需要操作栈,1删除栈中倒数第二个符号自身、上一个以及下一个下标,2删除倒数第一个符号之前的栈,使用viewValue代替
let editStatus = 0

// ...
// 加减乘除点击
case KEYS[4 - 1]:
case KEYS[2 * 4 - 1]:
case KEYS[3 * 4 - 1]:
case KEYS[4 * 4 - 1]:
    // ...
    if (computes.length > 3) {
        const previousSymbolIndex = computes.length - 3
        // 判断上一个符号是否是乘除 && 当前符号为加减
        if (symbolReg1.test(computes[previousSymbolIndex]) && symbolReg2.test(target.dataset.text)) {
            // 这里使用 ...扩展运算符浅克隆 computes 数组,如果是引用类型则需要深克隆
            this.viewValue = computeCount([...computes])
            // console.log('上个乘除当前符号加减', this.viewValue);
            editStatus = 2
        }
        // 上个符号和当前符号 一样的类型
        if (
            (symbolReg1.test(computes[previousSymbolIndex]) && symbolReg1.test(target.dataset.text)) ||
            (symbolReg2.test(computes[previousSymbolIndex]) && symbolReg2.test(target.dataset.text))
        ) {
            // console.log('符号相同');
            this.viewValue = operation(computes[previousSymbolIndex - 1], computes[previousSymbolIndex + 1], computes[previousSymbolIndex])
            editStatus = 1
        }

        if (!editStatus) {
            this.viewValue = computes[computes.length - 2]
            editStatus = 0
        }
    }

    break;

此时效果已经完成了,点击等于也没什么问题,但是代码还需优化,因为在上面代码中我们的值其实已经计算出来了,当点击等于的时候,相当于又重新计算了一遍栈中的值,所以在点击数字的时候我们需要清除栈中相应数据,之所以在点击数字时,是因为点击数字后,视图 viewValue 会进行改变

default:
    if (this.viewValue.length > (String(this.viewValue).indexOf('.') != -1 ? 9 : 8)) {
        return
    }

    if (editStatus) {
        // 等于 1时说明上个符号和当前符号相同,当前栈中数据为 [1, '+', '1', '+'] 格式时进入该 if
        // 将栈中值替换为 ['2', '+']
        if (editStatus === 1) {
            const previousSymbolIndex = computes.length - 3
            // 将结果替换栈中数据
            computes.splice(previousSymbolIndex - 1, 3, operation(computes[previousSymbolIndex - 1], computes[previousSymbolIndex + 1], computes[previousSymbolIndex]))
        }
        
        // 等于 2 时说明当前栈格式为 [1, '*', '1', '+'] 或者 [1, '+', '2', '*', '3', '+']
        // 将栈中值替换为 ['7', '+']
        if (editStatus === 2) computes.splice(0, computes.length - 1, this.viewValue)
        editStatus = 0
    }

    if (isSymbol()) {
        this.viewValue = target.dataset.text
        computes.push(this.viewValue)
    } else {
    // ...

到这一步计算逻辑都完成了,不懂的有什么提议或者意见可以在评论区回复或者去 github 上提 issue


彭小黑
26 声望2 粉丝