前言

最近产品经理又又加了一个需求,想在开单页面加一个计算器,用户就可以在录单时可以使用计算器了。需求内容就一句话:支持加减乘除四则运算,点击计算器图标,当前页面弹出计算器弹窗。我一问,答案就是:你参考别人家的计算器做就行。

我的思路:记录用户每次按下的按键,然后拼接成一个运算表达式,然后再解析运算表达式不久okk了!!

解析运算表达式

按照产品的要求,解析运算表达式需要有这些功能:

  • 运算表达式需要支持 +,-,*,/ 四则运算,并且按照运算顺序,先乘除,后加减
  • 需要支持小数位数的计算,需要解决js计算的精度问题

    思路与难点:

  1. 如何收集数字
    1. 声明一个栈(数组),用于存储数字
    2. 遍历字符串,如果是点或数字,就拼接起来,如果是运算符号,就将数字推入到栈中
  2. 解决运算顺序,先乘除后加减
    1. 遍历字符串时,如果预存符号是加,就直接将数字推入栈,如果是减,就乘 -1再推入到栈,如果是乘除,就先计算栈顶元素与当前数字的结果,再将结果推入到栈顶
    2. 最后将栈中的数字相加,就是运算结果了
  3. 遍历到符号的时候,怎么将后面的数字收集起来推入到栈中
    1. 先预先存一个+的符号,不判断当前的符号,而是判断预存的符号,这样就可以将第一个数字推入栈中
    2. 然后再将预存符号赋值为当前的符号。
  4. 解决js精度问题,可以采用外部库,或参考之前封装的代码:

    举例:

  5. 假设有运算表达式:3.1 - 2 + 3 * 4 - 4 / 2

  6. 先乘除后加减,加入到栈中,栈:[ 3.1, -2, 12, -2 ]
  7. 再将栈中的元素相加:3.1 - 2 + 12 - 2 = 11.1

    实现代码:

    1. function parseExpression(s) {
    2. s = s.replace(/\s/g, '') + 'e' // 去除所有空格,末尾加上一个结束符号,确保最后一个数加入到栈中
    3. let stack = []
    4. let preSign = '+' // 初始置为+,目的:让第一个数字入栈
    5. let curNum = 0 // 当前数字
    6. let reg = /[0-9]|\./
    7. for (let i = 0; i < s.length; i++) {
    8. if (reg.test(s[i])) {
    9. // 当前字符为数字或点,拼接数字
    10. curNum = curNum + s[i]
    11. } else {
    12. // 当前字符是操作符,则判断运算上一符号
    13. if (preSign === '+') {
    14. stack.push(curNum)
    15. } else if (preSign === '-') {
    16. let last = stack[stack.length - 1]
    17. if (last === 0 && 1 / last < 0) {
    18. // 判断上一个数字是不是 -0,解决类似:2--7这种问题
    19. stack.push(curNum)
    20. } else {
    21. stack.push(-1 * curNum)
    22. }
    23. } else if (['+', '-'].includes(s[i]) && !curNum) {
    24. // 对数字的正负号做特殊处理 比如:2*-2
    25. if (['+', '-'].includes(curNum)) return 0 // 2*--2 有两个减号,直接返回 0
    26. curNum = s[i]
    27. continue
    28. } else if (preSign === '*') {
    29. stack.push(calc(stack.pop(), preSign, curNum)) // calc方法是用于解决js计算精度的方法,可参考我的另外一篇文章
    30. } else if (preSign === '/') {
    31. stack.push(calc(stack.pop(), preSign, curNum))
    32. } else {
    33. return 0 // 既不是数字也不是符号也不是点,直接返回0
    34. }
    35. curNum = 0 // 运算后将curNum置为0
    36. preSign = s[i] // 记录当前符号
    37. }
    38. }
    39. let sum = stack.reduce((pre, num) => calc(pre, '+', num), 0) // 计算栈的和
    40. if (isNaN(sum) || !isFinite(sum)) sum = 0 // 如果结果是NaN,或者无穷,就置为0
    41. return sum
    42. }
    43. // 测试
    44. console.log(parseExpression('3.1 - 2 + 3 * 4 - 4 / 2')) // 11.1

    科学计算器

    完成了解析表达式的步骤,就可以做一个简易科学计算器

在完成科学计算器的时候,产品增加了两个要求:

  1. 需要根据鼠标点击的位置不同,在不同位置显示计算器
  2. 要与键盘上的数字与运算键绑定,用键盘也能使用计算器

    两个要求不难办,就不细讲,可以直接看代码

思路:

  1. 页面上需要显示两行,一行是表达式,一行显示当前数字。
    1. data中用两个数据表示
  2. 每次按下运算符号后,当前的数字都要重置

    1. data中使用一个数据记录是否需要重置当前数字

      异常处理:

  3. 连续点击运算符号,需要将最后一次的运算符号覆盖

  4. 一个数字中不能出现多个小数点
  5. 按下等号后继续输入,需要将之前的数据清空

    实现后效果:

    calc.gif

    实现代码:

    ```html

  1. > 产品看后,沉思了片刻,说:和我电脑上的计算器不一样啊,点运算符号都会把上一次的结果算出来,这种:
  2. > ![image.png](https://cdn.nlark.com/yuque/0/2022/png/2628706/1656398531884-a55476f2-3ac0-4637-b4f6-e8017ade725d.png#clientId=ud9e1aab7-e853-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=337&id=u1c663812&margin=%5Bobject%20Object%5D&name=image.png&originHeight=532&originWidth=320&originalType=binary&ratio=1&rotation=0&showTitle=false&size=82867&status=done&style=none&taskId=uc306505c-db1a-4fd3-8840-8fdf7671d49&title=&width=202.55557250976562)
  3. > 我:你这是标准计算器,你点一点左上角,选择科学,就一样了
  4. > 产品:我就要这种标准计算器
  5. > 我:????
  6. <a name="TBY0e"></a>
  7. ## 标准计算器
  8. > 既然产品都发话了,那就做吧,把科学计算器改一改,就是一个标准计算器了
  9. > 重点就是改一改按下字符如果是运算符号时,先将之前按下的数据算出来
  10. <a name="t2QIr"></a>
  11. ### 实现后的效果:
  12. ![calcBB.gif](https://cdn.nlark.com/yuque/0/2022/gif/2628706/1656400523964-bd696643-dc9d-40e0-a40b-e4b15178a01b.gif#clientId=u026a52ab-15a1-4&crop=0&crop=0&crop=1&crop=1&from=ui&id=u365bc872&margin=%5Bobject%20Object%5D&name=calcBB.gif&originHeight=305&originWidth=252&originalType=binary&ratio=1&rotation=0&showTitle=false&size=106174&status=done&style=none&taskId=u9f5ca426-fb73-421b-8873-ac5e539c245&title=)
  13. <a name="XszBR"></a>
  14. ### 实现代码:
  15. ```html
  16. <template>
  17. <div>
  18. <!-- 遮罩层,点击遮罩层隐藏计算器 -->
  19. <div class="mask" @click="$emit('hideCalc')"></div>
  20. <div :style="{ top: top + 'px', left: left + 'px' }" class="calculator">
  21. <div class="showPanel">
  22. <span class="exp">{{ exp }}</span>
  23. <span class="number">{{ number }}</span>
  24. </div>
  25. <div class="caculator-button">
  26. <el-button @click="getResult('c')">c</el-button>
  27. <el-button @click="getResult('/')">/</el-button>
  28. <el-button @click="getResult('*')">*</el-button>
  29. <el-button @click="getResult('del')" icon="delete"> </el-button>
  30. <el-button @click="getResult('7')">7</el-button>
  31. <el-button @click="getResult('8')">8</el-button>
  32. <el-button @click="getResult('9')">9</el-button>
  33. <el-button @click="getResult('-')">-</el-button>
  34. <el-button @click="getResult('4')">4</el-button>
  35. <el-button @click="getResult('5')">5</el-button>
  36. <el-button @click="getResult('6')">6</el-button>
  37. <el-button @click="getResult('+')">+</el-button>
  38. <el-button @click="getResult('1')">1</el-button>
  39. <el-button @click="getResult('2')">2</el-button>
  40. <el-button @click="getResult('3')">3</el-button>
  41. <el-button @click="getResult('=')" type="primary" class="equal">=</el-button>
  42. <el-button @click="getResult('+/-')">+/-</el-button>
  43. <el-button @click="getResult('0')">0</el-button>
  44. <el-button @click="getResult('.')">.</el-button>
  45. </div>
  46. </div>
  47. </div>
  48. </template>
  49. <script>
  50. import parseExpression from '@/utils/parseExpression'
  51. export default {
  52. name: 'Calculator',
  53. props: {
  54. calcTop: {
  55. // calcTop,calcLeft计算器的位置
  56. type: Number,
  57. default: 100
  58. },
  59. calcLeft: {
  60. type: Number,
  61. default: 100
  62. }
  63. },
  64. data() {
  65. return {
  66. number: '0', // 当前显示在输入框的数值,这里要注意number时刻是个字符串格式,否则indexOf方法会报错
  67. exp: '', // 用于计算和显示的表达式
  68. rewrite: false // 是否要清空输入框重写,比如按下5后,再按+,下次再按一个数字是,就需要清空输入框重写
  69. }
  70. },
  71. computed: {
  72. top() {
  73. // 计算器定位,距离边距的处理
  74. if (document.documentElement.clientHeight - this.calcTop < 270) {
  75. return this.calcTop - 300
  76. } else {
  77. return this.calcTop
  78. }
  79. },
  80. left() {
  81. // 计算器定位
  82. return this.calcLeft < 0 ? 30 : this.calcLeft
  83. },
  84. // 键盘按键与事件之间的对应关系
  85. keyMap() {
  86. return new Map([
  87. ['0', this.getResult.bind(this, '0')],
  88. ['1', this.getResult.bind(this, '1')],
  89. ['2', this.getResult.bind(this, '2')],
  90. ['3', this.getResult.bind(this, '3')],
  91. ['4', this.getResult.bind(this, '4')],
  92. ['5', this.getResult.bind(this, '5')],
  93. ['6', this.getResult.bind(this, '6')],
  94. ['7', this.getResult.bind(this, '7')],
  95. ['8', this.getResult.bind(this, '8')],
  96. ['9', this.getResult.bind(this, '9')],
  97. ['Backspace', this.getResult.bind(this, 'del')],
  98. ['/', this.getResult.bind(this, '/')],
  99. ['*', this.getResult.bind(this, '*')],
  100. ['+', this.getResult.bind(this, '+')],
  101. ['-', this.getResult.bind(this, '-')],
  102. ['.', this.getResult.bind(this, '.')],
  103. ['c', this.getResult.bind(this, 'c')],
  104. ['Enter', this.getResult.bind(this, '=')]
  105. ])
  106. }
  107. },
  108. mounted() {
  109. document.addEventListener('keyup', this.keyEvent)
  110. },
  111. destroyed() {
  112. document.removeEventListener('keyup', this.keyEvent)
  113. },
  114. methods: {
  115. getResult(e) {
  116. // 如果之前按下了等号
  117. if (this.isInit) {
  118. this.isInit = false
  119. Object.assign(this.$data, this.$options.data())
  120. }
  121. // 不可以连续点击多个小数点
  122. if (this.number.indexOf('.') != -1 && e === '.') return
  123. if (/[0-9]|\./.test(e)) {
  124. // 如果是小数点或者数字,更改this.number的值
  125. if (this.rewrite) {
  126. this.number = e
  127. this.rewrite = false
  128. } else {
  129. if (this.number === '0' && e !== '.') {
  130. this.number = e
  131. } else {
  132. // 可以输入:0.5
  133. this.number += e
  134. }
  135. }
  136. } else if (['+', '-', '*', '/'].includes(e)) {
  137. // 上一次点击的按键也是运算符号,则需要覆盖最后一个运算符号
  138. let last = this.exp.charAt(this.exp.length - 1)
  139. if (this.rewrite && ['+', '-', '*', '/'].includes(last)) {
  140. this.exp = this.exp.slice(0, this.exp.length - 1) + e
  141. } else {
  142. this.rewrite = true // 下次输入数字时需要重置this.number
  143. this.exp += this.number
  144. // 每次按下运算符都需要将上次的结果算出来给输入框
  145. this.number = parseExpression(this.exp).toString()
  146. this.exp = this.number + e
  147. }
  148. } else if (e === 'del') {
  149. if (this.number === '0') return
  150. if (this.rewrite) return // 刚刚点完符号,不可以删除
  151. this.number = this.number.slice(0, this.number.length - 1)
  152. if (this.number === '') this.number = '0'
  153. } else if (e === '+/-') {
  154. // 取反,并给表达式最后一项乘-1,注:-1需要在最后一项前面乘
  155. this.number = (-1 * this.number).toString()
  156. } else if (e === '=') {
  157. this.exp += this.number
  158. // 算出结果
  159. this.number = parseExpression(this.exp).toString()
  160. this.exp += e
  161. this.isInit = true // 下次点击按键时,重置数据
  162. this.$emit('getCalcResult', this.number)
  163. } else if (e === 'c') {
  164. Object.assign(this.$data, this.$options.data())
  165. }
  166. },
  167. /**
  168. * @description 监听键盘事件
  169. */
  170. keyEvent(e) {
  171. this.keyMap.get(e.key) && this.keyMap.get(e.key)()
  172. }
  173. }
  174. }
  175. </script>
  176. <style lang="scss" scoped>
  177. .mask {
  178. position: absolute;
  179. z-index: 80;
  180. top: 0;
  181. left: 0;
  182. width: 100vw;
  183. height: 100vh;
  184. }
  185. .calculator {
  186. position: fixed;
  187. z-index: 99;
  188. border: solid 1px #dcdfe6;
  189. padding: 5px;
  190. background-color: #fffffff1;
  191. border-radius: 4px;
  192. box-shadow: 0 0 2px #dcdfe6;
  193. .showPanel {
  194. display: flex;
  195. flex-direction: column;
  196. align-items: flex-end;
  197. padding: 2px 20px;
  198. height: 42px;
  199. border: 1px #f0f0f0 solid;
  200. width: 203px;
  201. border-radius: 4px;
  202. box-sizing: border-box;
  203. margin-bottom: 3px;
  204. justify-content: space-evenly;
  205. .exp {
  206. color: #aaa;
  207. font-size: 10px;
  208. height: 12px;
  209. }
  210. .number {
  211. font-size: 16px;
  212. font-weight: 900;
  213. }
  214. }
  215. // 删除的icon图标
  216. ::v-deep.delete {
  217. display: inline-block;
  218. width: 20px;
  219. height: 12px;
  220. background: url('../../icon/calcDetele.png') no-repeat center center;
  221. background-size: 90% 90%;
  222. }
  223. }
  224. .el-button {
  225. margin: 0 !important;
  226. padding: 10px;
  227. font-weight: 600;
  228. width: 100%;
  229. }
  230. .caculator-button {
  231. margin: 0 auto;
  232. width: 190px;
  233. display: grid;
  234. border: solid 1px #eee;
  235. padding: 6px;
  236. grid-template-columns: 1fr 1fr 1fr 1fr;
  237. gap: 3px;
  238. border-radius: 4px;
  239. background-color: #fffffff1;
  240. }
  241. #result {
  242. margin-bottom: 6px;
  243. }
  244. .equal {
  245. grid-column: 4/5;
  246. grid-row: 4/6;
  247. }
  248. </style>

这下产品经理没话说了吧?