整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

明尼苏达大学工程师开发基于磁隧道结的设备,使人工智能的能耗降低至少1000倍

TechXplore7月26日报道,明尼苏达大学双城分校的工程研究人员展示了一种最先进的硬件设备,可以将人工智能(AI)计算应用的能耗降低至少1000倍。

这项研究发表在npj非常规计算杂志上,题为“基于磁隧道结的计算随机存取存储器的实验演示”。

明尼苏达大学科学与工程学院的一组研究人员展示了一种数据永远不会离开存储器的新模型,称为计算随机存取存储器(CRAM)。

“这项工作是CRAM的第一次实验演示,其中数据可以完全在存储器阵列内处理,而不需要离开计算机存储信息的网格,”明尼苏达大学电子与计算机工程系博士后研究员、论文的第一作者吕阳称。

根据这篇新论文的作者说法,基于CRAM的机器学习推理加速器估计可以实现1000倍的改进。

这项研究已经进行了20多年。“20年前,我们将存储单元直接用于计算的最初概念被认为是疯狂的,”该论文的资深作者、明尼苏达大学电子与计算机工程系Jian-Ping Wang教授说,“自2003年以来,随着不断发展的学生群体和明尼苏达大学建立的真正的跨学科教师团队-从物理学,材料科学与工程,计算机科学与工程,到建模和基准测试,以及硬件创造-我们能够获得积极的成果,现在已经证明这种技术是可行的,并且已经准备好投入实用。”

(编译:晋阳)

链接:

https://techxplore.com/news/2024-07-magnetic-tunnel-junctionbased-device-ai.html


提:本文实现AI贪吃蛇自行对战,加上人机对战,读者可在此基础上自行添加电脑VS电脑和玩家VS玩家(其实把人机对战写完,这2个都没什么了,思路都一样)

私信小编001即可获取大量Python学习资料!

实现效果:

具体功能:

1.智能模式:电脑自己玩(自己吃食物)

2.人机对战:电脑和人操作(在上步的基础上加一个键盘控制的贪吃蛇即可)

实现环境:

Pycharm + Python3.6 + Curses + Win10

具体过程:

一:配置环境:

Curses: 参考链接 (Cp后面代表本的Python环境,别下错了)

( Stackoverflow 真的是个非常好的地方)

二:

1.灵感来源+参考链接:

http://www.hawstein.com/posts/snake-ai.html (Chrome有时候打不开,Firefox可以打开)

2.算法思路:

A*算法: https://www.cnblogs.com/21207-iHome/p/6048969.html (本人之前接触过,当时讲课老师说是自动寻路算法,我感觉和BFS+DFS一样,结果没想到居然是A*算法)

BFS+DFS(略)

第一步是能制作一个 基本的贪吃蛇 ,熟悉Curses的相关环境(最好别对蛇和食物使用特殊字符,在windows环境下会导致像素延迟,非常丑)


#curses官方手册:https://docs.python.org/3.5/library/curses.html#module-curses
#curses参考手册:https://blog.csdn.net/chenxiaohua/article/details/2099304

具体思路:

熟悉Curses中相关指令后基本就没什么了, 保证按的下一个键不导致蛇死亡,保证蛇吃食物后食物不在蛇身上,保证蛇碰到自己和边框就死亡,如果按其他键,会导致头被插入2次,从而让蛇死亡。(具体见代码分析)

 1 #!/usr/bin/env python
 2 # -*- coding: utf-8 -*-
 3 # @Time : 2018/11/5 17:08
 4 # @Author : Empirefree
 5 # @File : 贪吃蛇-01.py
 6 # @Software: PyCharm Community Edition
 7
 8 #curses官方手册:https://docs.python.org/3.5/library/curses.html#module-curses
 9 #curses参考手册:https://blog.csdn.net/chenxiaohua/article/details/2099304
 10
 11 # 基本思路:while循环,让蛇一直右走(直到按键,如果按了其他键就会导致蛇头被重复插入1次到snake中,
 12 # 继而第二次循环就会退出),蛇是每次自动增长,但是每次没吃到食物就会pop尾部(snake放在dict中,类似链表),按键检查就是只能按方向键
 13 # 按方向键也存在判别是否出错(按了up后又按down),然后对于死亡情况就是碰到周围和自己
 14
 15 # 1.蛇的移动和吃食物后的变化
 16 # 2.按键:按其他键和方向键
 17 # 3.死亡判断
 18
 19 import curses
 20 import random
 21
 22 # 开启curses
 23 def Init_Curse():
 24 global s
 25 s = curses.initscr()
 26 curses.curs_set(0) #能见度光标,写错了哇
 27 curses.noecho()
 28 curses.cbreak() #立即得到响应
 29 s.keypad(True) #特殊处理键位,返回KEY_LEFT
 30
 31 #关闭并回到终端
 32 def Exit_Curse():
 33 curses.echo()
 34 curses.nocbreak()
 35 s.keypad(False)
 36 curses.endwin()
 37
 38 def Start_Game():
 39 # 窗口化操作
 40 y, x = s.getmaxyx() # curses中是y,x
 41 w = curses.newwin(y, x, 0, 0)
 42 w.keypad(1)
 43 w.timeout(100)
 44
 45 # 初始化蛇的位置,并用dict存储
 46 snake_x = int(x / 4)
 47 snake_y = int(y / 2)
 48 snake = [[snake_y, snake_x], [snake_y, snake_x - 1], [snake_y, snake_x - 2]]
 49
 50 # 初始化食物
 51 food_pos = [int(y / 2), int(x / 2)]
 52 w.addch(food_pos[0], food_pos[1], '@') # 用@显示食物字元
 53
 54 key = curses.KEY_RIGHT # 得到右方向键
 55
 56 # 开始,为什么我感觉True比1看的爽一些
 57 while True:
 58 next_key = w.getch() # 等待输入,传回整数
 59 print(next_key, 'QAQ')
 60 # 防止Error
 61 if next_key != -1:
 62 if key == curses.KEY_RIGHT and next_key != curses.KEY_LEFT
 63 or key == curses.KEY_LEFT and next_key != curses.KEY_RIGHT
 64 or key == curses.KEY_DOWN and next_key != curses.KEY_UP
 65 or key == curses.KEY_UP and next_key != curses.KEY_DOWN:
 66 key = next_key
 67
 68 # 蛇死亡, 当蛇头碰到蛇身或墙壁
 69 if snake[0][0] in [0, y] or snake[0][1] in [0, x] or snake[0] in snake[1:]:
 70 # print(snake[0], snake[1]) 按下其他键就会导致,new_head被插入2次,从而退出
 71 curses.endwin()
 72 print('!!!游戏结束!!!')
 73 quit()
 74
 75 #按键移动
 76 tempy = snake[0][0]
 77 tempx = snake[0][1]
 78 new_head = [tempy, tempx]
 79 if key == curses.KEY_RIGHT:
 80 new_head[1] += 1
 81 elif key == curses.KEY_LEFT:
 82 new_head[1] -= 1
 83 elif key == curses.KEY_UP:
 84 new_head[0] -= 1
 85 elif key == curses.KEY_DOWN:
 86 new_head[0] += 1
 87 snake.insert(0, new_head) #保留蛇头,根据按键更新蛇头
 88
 89 #食物位置
 90 if snake[0] == food_pos:
 91 food_pos = None
 92 while food_pos is None:
 93 new_food = [random.randint(1, y - 1), random.randint(1, x - 1)]
 94 if new_food not in snake:
 95 food_pos = new_food
 96 w.addch(food_pos[0], food_pos[1], '@') #再次添加食物,保证食物不在蛇上
 97 else:
 98 tail = snake.pop() #dict直接pop尾部
 99 w.addch(tail[0], tail[1], ' ')
100
101 w.addch(snake[0][0], snake[0][1], 'Q')
102
103 if __name__ == '__main__':
104 Init_Curse()
105 Start_Game()
106
107 print('QAQ')
108 Exit_Curse()
基本贪吃蛇

3.代码剖析:

[红色为代码所需函数]

(蛇每走一步,就更新snake距离food的board距离,涉及 board_rest (更新每个非snake元素距离food的距离)和 board_refresh (本文这里采用BFS算法)),寻找到best_move,然后让蛇移动即可

如果吃的到食物( find_safe_way ):----> 放出虚拟蛇( virtual_shortest_move )(防止蛇吃完食物就被自己咬死)

如果虚拟蛇吃完食物还可以找到 蛇尾(出的去)( is_tail_inside )

直接吃食物( choose_shortest_safe_move )

反之,出不去:

就跟着尾巴走( follow_tail )就好比一直上下绕,就绝对不会死,但是蛇就完全没有灵性

如果吃不到食物

跟着尾巴(走最远的路(

choose_longest_safe_move

)),四个方向走(如果是A*算法需要将8个方向改成4个方向)

如果上述方法都不行,就涉及到a ny_possible_move ,挑选距离最小的走(这里就会涉及到将自己吃死,有待改进)

(通过以上方法,就可以制造一个基本AI贪吃蛇了,当然,还有很多细节方面东西需要考虑)

报错:

win = curses.newwin(HEIGHT, WIDTH, 0, 0)

_curses.error: curses function returned NULL

原因:Pycharm下面(或者cmd、exe太小,需要拉大点)

	 1 #!/usr/bin/env python
 2 # -*- coding: utf-8 -*-
 3 # @Time : 2018/11/16 14:26
 4 # @Author : Empirefree
 5 # @File : 贪吃蛇-03.py
 6 # @Software: PyCharm Community Edition
 7
 8 import curses
 9 from curses import KEY_RIGHT, KEY_LEFT, KEY_UP, KEY_DOWN
 10 from random import randint
 11
 12 # 必须要弄成全局哇,不然需要用到的数据太多了
 13 # 1.初始化界面
 14 # 2.更新地图,判断是否可以吃到食物
 15 # 3.如果可以吃到,放出虚拟蛇(这里又设计到地图更新(board_reset),记录距离(board_refresh)操作)
 16 # 3.1虚拟蛇若吃食物距离蛇尾有路径(直接吃),否则,追蛇尾
 17 # 3.2若吃不到,则追蛇尾
 18 # 4.更新best_move,改变距离
 19 ###########################################################################################
 20 #作者:
 21 print('**************************************************************************')
 22 print('*****************!!!欢迎使用AI贪吃蛇 !!!*************************')
 23 print('*****************作者:胡宇乔 *********************')
 24 print('*****************工具: Pycharm *********************')
 25 print('*****************时间: 2018/11/16 14:26 ********************')
 26 print('***************** (按Esc结束贪吃蛇游戏) **********************')
 27 print('**************************************************************************')
 28 # 场地
 29 HEIGHT, WIDTH = map(int, input('请输入长度长宽[20 40]:').split())
 30 FIELD_SIZE = HEIGHT * WIDTH
 31
 32 #蛇和食物
 33 HEAD = 0
 34 FOOD = 0
 35 UNDEFINED = (HEIGHT + 1) * (WIDTH + 1)
 36 SNAKE = 2 * UNDEFINED
 37
 38 # 四个方向的移动
 39 LEFT = -1
 40 RIGHT = 1
 41 UP = -WIDTH
 42 DOWN = WIDTH
 43
 44 # 错误码
 45 ERR = -1111
 46
 47 # 用一维数组来表示二维的东西
 48 # board表示蛇运动的矩形场地
 49 # 初始化蛇头在(1,1)的地方,第0行,HEIGHT行,第0列,WIDTH列为围墙,不可用
 50 # 初始蛇长度为1
 51 board = [0] * FIELD_SIZE
 52 snake = [0] * (FIELD_SIZE + 1)
 53 snake[HEAD] = 1 * WIDTH + 1
 54 snake_size = 1
 55 # tmpsnake即虚拟蛇
 56 tmpboard = [0] * FIELD_SIZE
 57 tmpsnake = [0] * (FIELD_SIZE + 1)
 58 tmpsnake[HEAD] = 1 * WIDTH + 1
 59 tmpsnake_size = 1
 60
 61 # food:食物位置(0~FIELD_SIZE-1),初始在(3, 3)
 62 # best_move: 运动方向
 63 food = 3 * WIDTH + 3
 64 best_move = ERR
 65
 66 # 运动方向数组
 67 mov = [LEFT, RIGHT, UP, DOWN]
 68 # 接收到的键 和 分数
 69 key = KEY_RIGHT
 70 score = 1 # 分数也表示蛇长
 71
 72 #cueses初始化
 73 curses.initscr()
 74 win = curses.newwin(HEIGHT, WIDTH, 0, 0)
 75 win.keypad(1)
 76 curses.noecho()
 77 curses.curs_set(0)
 78 win.border(0)
 79 win.nodelay(1)
 80 win.addch(food // WIDTH, food % WIDTH, '@')
 81
 82 ###########################################################################################
 83 #判断是否为空(可走)
 84 def is_cell_free(idx, psize, psnake):
 85 return not (idx in psnake[:psize])
 86
 87
 88 # 检查某个位置idx是否可向move方向运动
 89 def is_move_possible(idx, move):
 90 flag = False
 91 if move == LEFT:
 92 flag = True if idx % WIDTH > 1 else False
 93 elif move == RIGHT:
 94 flag = True if idx % WIDTH < (WIDTH - 2) else False
 95 elif move == UP:
 96 flag = True if idx > (2 * WIDTH - 1) else False # 即idx/WIDTH > 1
 97 elif move == DOWN:
 98 flag = True if idx < (FIELD_SIZE - 2 * WIDTH) else False # 即idx/WIDTH < HEIGHT-2
 99 return flag
100
101
102 # 计算出board中每个非SNAKE元素到达食物的路径长度,并判断是否可以找到食物
103 def board_reset(psnake, psize, pboard):
104 for i in range(FIELD_SIZE):
105 if i == food:
106 pboard[i] = FOOD
107 elif is_cell_free(i, psize, psnake): # 该位置为空
108 pboard[i] = UNDEFINED
109 else: # 该位置为蛇身
110 pboard[i] = SNAKE
111
112
113 # 广度优先搜索遍历整个board,
114 # 计算出board中每个非SNAKE元素到达食物的路径长度
115 def board_refresh(pfood, psnake, pboard):
116 queue = []
117 queue.append(pfood)
118 inqueue = [0] * FIELD_SIZE
119 found = False
120 # while循环结束后,除了蛇的身体,
121 # 其它每个方格中的数字代码从它到食物的路径长度
122 while len(queue) != 0:
123 idx = queue.pop(0)
124 if inqueue[idx] == 1: continue
125 inqueue[idx] = 1
126 for i in range(4):
127 if is_move_possible(idx, mov[i]):
128 if idx + mov[i] == psnake[HEAD]:
129 found = True
130 if pboard[idx + mov[i]] < SNAKE: # 如果该点不是蛇的身体
131
132 if pboard[idx + mov[i]] > pboard[idx] + 1:
133 pboard[idx + mov[i]] = pboard[idx] + 1
134 if inqueue[idx + mov[i]] == 0:
135 queue.append(idx + mov[i])
136
137 return found
138
139
140 #蛇头开始,根据蛇的4个领域选择最远路径(安全一点)
141 def choose_shortest_safe_move(psnake, pboard):
142 best_move = ERR
143 min = SNAKE
144 for i in range(4):
145 if is_move_possible(psnake[HEAD], mov[i]) and pboard[psnake[HEAD] + mov[i]] < min:
146 min = pboard[psnake[HEAD] + mov[i]]
147 best_move = mov[i]
148 return best_move
149
150
151 # 从蛇头开始,根据board中元素值,
152 # 从蛇头周围4个领域点中选择最远路径
153 def choose_longest_safe_move(psnake, pboard):
154 best_move = ERR
155 max = -1
156 for i in range(4):
157 if is_move_possible(psnake[HEAD], mov[i]) and pboard[psnake[HEAD] + mov[i]] < UNDEFINED and pboard[psnake[HEAD] + mov[i]] > max:
158 max = pboard[psnake[HEAD] + mov[i]]
159 best_move = mov[i]
160 return best_move
161
162
163 # 检查是否可以追着蛇尾运动,即蛇头和蛇尾间是有路径的
164 # 为的是避免蛇头陷入死路
165 # 虚拟操作,在tmpboard,tmpsnake中进行
166 def is_tail_inside():
167 global tmpboard, tmpsnake, food, tmpsnake_size
168 tmpboard[tmpsnake[tmpsnake_size - 1]] = 0 # 虚拟地将蛇尾变为食物(因为是虚拟的,所以在tmpsnake,tmpboard中进行)
169 tmpboard[food] = SNAKE # 放置食物的地方,看成蛇身
170 result = board_refresh(tmpsnake[tmpsnake_size - 1], tmpsnake, tmpboard) # 求得每个位置到蛇尾的路径长度
171 for i in range(4): # 如果蛇头和蛇尾紧挨着,则返回False。即不能follow_tail,追着蛇尾运动了
172 if is_move_possible(tmpsnake[HEAD], mov[i]) and tmpsnake[HEAD] + mov[i] == tmpsnake[
173 tmpsnake_size - 1] and tmpsnake_size > 3:
174 result = False
175 return result
176
177
178 # 让蛇头朝着蛇尾运行一步
179 # 不管蛇身阻挡,朝蛇尾方向运行
180 def follow_tail():
181 global tmpboard, tmpsnake, food, tmpsnake_size
182 tmpsnake_size = snake_size
183 tmpsnake = snake[:]
184 board_reset(tmpsnake, tmpsnake_size, tmpboard) # 重置虚拟board
185 tmpboard[tmpsnake[tmpsnake_size - 1]] = FOOD # 让蛇尾成为食物
186 tmpboard[food] = SNAKE # 让食物的地方变成蛇身
187 board_refresh(tmpsnake[tmpsnake_size - 1], tmpsnake, tmpboard) # 求得各个位置到达蛇尾的路径长度
188 tmpboard[tmpsnake[tmpsnake_size - 1]] = SNAKE # 还原蛇尾
189
190 return choose_longest_safe_move(tmpsnake, tmpboard) # 返回运行方向(让蛇头运动1步)
191
192
193 # 在各种方案都不行时,随便找一个可行的方向来走(1步),
194 def any_possible_move():
195 global food, snake, snake_size, board
196 best_move = ERR
197 board_reset(snake, snake_size, board)
198 board_refresh(food, snake, board)
199 min = SNAKE
200
201 for i in range(4):
202 if is_move_possible(snake[HEAD], mov[i]) and board[snake[HEAD] + mov[i]] < min:
203 min = board[snake[HEAD] + mov[i]]
204 best_move = mov[i]
205 return best_move
206
207 #虚拟蛇蛇移动
208 def shift_array(arr, size):
209 for i in range(size, 0, -1):
210 arr[i] = arr[i - 1]
211
212 #产生新食物
213 def new_food():
214 global food, snake_size
215 cell_free = False
216 while not cell_free:
217 w = randint(1, WIDTH - 2)
218 h = randint(1, HEIGHT - 2)
219 food = h * WIDTH + w
220 cell_free = is_cell_free(food, snake_size, snake)
221 win.addch(food // WIDTH, food % WIDTH, '@')
222
223
224 # 真正的蛇在这个函数中,朝pbest_move走1步
225 def make_move(pbest_move):
226 global key, snake, board, snake_size, score
227 shift_array(snake, snake_size)
228 snake[HEAD] += pbest_move
229
230 # 按esc退出,getch同时保证绘图的流畅性,没有它只会看到最终结果
231 win.timeout(10)
232 event = win.getch()
233 key = key if event == -1 else event
234 if key == 27: return
235
236 p = snake[HEAD]
237 win.addch(p // WIDTH, p % WIDTH, '*')
238
239 # 如果新加入的蛇头就是食物的位置
240 # 蛇长加1,产生新的食物,重置board(因为原来那些路径长度已经用不上了)
241 if snake[HEAD] == food:
242 board[snake[HEAD]] = SNAKE # 新的蛇头
243 snake_size += 1
244 score += 1
245 if snake_size < FIELD_SIZE: new_food()
246 else: # 如果新加入的蛇头不是食物的位置
247 board[snake[HEAD]] = SNAKE # 新的蛇头
248 board[snake[snake_size]] = UNDEFINED # 蛇尾变为空格
249 win.addch(snake[snake_size] // WIDTH, snake[snake_size] % WIDTH, ' ')
250
251
252 #虚拟蛇最短移动
253 def virtual_shortest_move():
254 global snake, board, snake_size, tmpsnake, tmpboard, tmpsnake_size, food
255 tmpsnake_size = snake_size
256 tmpsnake = snake[:] # 如果直接tmpsnake=snake,则两者指向同一处
257 tmpboard = board[:] # board中已经是各位置到达食物的路径长度了,不用再计算
258 board_reset(tmpsnake, tmpsnake_size, tmpboard)
259
260 food_eated = False
261 while not food_eated:
262 board_refresh(food, tmpsnake, tmpboard)
263 move = choose_shortest_safe_move(tmpsnake, tmpboard)
264 shift_array(tmpsnake, tmpsnake_size)
265 tmpsnake[HEAD] += move # 在蛇头前加入一个新的位置
266 # 如果新加入的蛇头的位置正好是食物的位置
267 # 则长度加1,重置board,食物那个位置变为蛇的一部分(SNAKE)
268 if tmpsnake[HEAD] == food:
269 tmpsnake_size += 1
270 board_reset(tmpsnake, tmpsnake_size, tmpboard) # 虚拟运行后,蛇在board的位置
271 tmpboard[food] = SNAKE
272 food_eated = True
273 else: # 如果蛇头不是食物的位置,则新加入的位置为蛇头,最后一个变为空格
274 tmpboard[tmpsnake[HEAD]] = SNAKE
275 tmpboard[tmpsnake[tmpsnake_size]] = UNDEFINED
276
277
278 # 如果蛇与食物间有路径,则调用本函数
279 def find_safe_way():
280 global snake, board
281 safe_move = ERR
282 # 虚拟地运行一次,因为已经确保蛇与食物间有路径,所以执行有效
283 # 运行后得到虚拟下蛇在board中的位置,即tmpboard,见label101010
284 virtual_shortest_move() # 该函数唯一调用处
285 if is_tail_inside(): # 如果虚拟运行后,蛇头蛇尾间有通路,则选最短路运行(1步)
286 return choose_shortest_safe_move(snake, board)
287 safe_move = follow_tail() # 否则虚拟地follow_tail 1步,如果可以做到,返回true
288 return safe_move
289
290 if __name__ == '__main__':
291
292 while key != 27:
293 win.border(0)
294 win.addstr(0, 2, '分数:' + str(score) + ' ')
295 win.timeout(10)
296 # 接收键盘输入,同时也使显示流畅
297 event = win.getch()
298 key = key if event == -1 else event
299 # 重置矩阵
300 board_reset(snake, snake_size, board)
301
302 # 如果蛇可以吃到食物,board_refresh返回true
303 # 并且board中除了蛇身(=SNAKE),其它的元素值表示从该点运动到食物的最短路径长
304 if board_refresh(food, snake, board):
305 best_move = find_safe_way() # find_safe_way的唯一调用处
306 else:
307 best_move = follow_tail()
308
309 if best_move == ERR:
310 best_move = any_possible_move()
311 # 上面一次思考,只得出一个方向,运行一步
312 if best_move != ERR:
313 make_move(best_move)
314 else:
315 break
316
317 curses.endwin()
318 print("
 得分:" + str(score))
贪吃蛇-02

在以上基础上,还需要引入第一步制造的基本贪吃蛇


细节:1.键盘蛇加入后如何与蛇抢分(只需要return即可,但是 new_food()里面是需要更改的)

	 1 # 产生新食物
 2 def new_food():
 3 global food, snake_size, myfood
 4 cell_free = False
 5 while not cell_free:
 6 food1 = [random.randint(1, HEIGHT - 2), random.randint(1, WIDTH - 2)]
 7 w = randint(1, WIDTH - 2)
 8 h = randint(1, HEIGHT - 2)
 9 myfood = [h, w]
10 food = h * WIDTH + w
11 if (is_cell_free(food, snake_size, snake) and [w, h] not in snake1):
12 cell_free = True
13 win.addch(food // WIDTH, food % WIDTH, '@')

2.一直没说,由于蛇加入后很多变量都需要global,导致变量看起来非常麻烦(读者要有心理准备)

3.curses里面的win.timeout()是控制蛇的速度

好像就没什么了,想起来了再更。我没加入2条蛇不能彼此碰撞(读者也可以弄成2个地图,然后看AI蛇和你自己的蛇如何操作跑,我是放在了一个地图里面)

当然还有很多很多细节,不过主要思路写下来了。其余就靠分析代码自行研究了。Python制作AI贪吃蛇

果说,2021年了你还不了解微前端,请自觉搬好板凳前排听讲,小编特地邀请了我们LigaAI团队的前端负责人先军老师,带你轻松玩转微前端。


什么是微前端?

Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently. – Micro Frontends

微前端是一种多个团队通过独立发布功能的方式,来共同构建现代化 web 应用的技术手段及方法策略。

不同于单纯的前端框架/工具,微前端是一套架构体系,这个概念最早在2016年底由 ThoughtWorks 提出。 微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,将 Web 应用从整个的「单体应用」转变为多个小型前端应用的「聚合体」。各个前端应用「原子化」,可以独立运行、开发、部署,从而满足业务的快速变化,以及分布式、多团队并行开发的需求。


核心价值(为什么要使用微前端?)

  • 不限技术栈

主应用不限制接入的子应用的技术栈,子应用拥有完全自主权。所接入的子应用之间也相互独立,没有任何直接或间接的技术栈、依赖、以及实现上的耦合。

  • 独立开发、部署

微应用仓库独立,前后端均可独立开发,部署完成后主框架自动完成同步更新。独立部署的能力在微前端体系中至关重要,能够缩小单次开发的变更范围,进而降低相关风险。 各个微前端都应该有自己的持续交付管道,这些管道可以将微前端构建、测试并部署到生产环境中。

  • 增量升级

在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构。 因此,微前端是一种非常好的实施渐进式重构的手段和策略,它可以逐渐升级我们的架构、依赖关系和用户体验。当主框架发生重大变化时,微前端的每个模块可以独立按需升级,不需要整体下线或一次性升级所有内容。如果我们想要尝试新的技术或互动模式,也能在隔离度更好的环境下做试验。

  • 简单、解耦、易维护

微前端架构下的代码库倾向于更小/简单、更容易开发,避免无关组件之间不必要的耦合,让代码更简洁。通过界定清晰的应用边界来降低意外耦合的可能性,更好地避免这类无意间造成的耦合问题。


在什么场景下使用?

微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用 (Frontend Monolith) 后应用不可维护的问题。这类问题在企业级 Web 应用中尤为常见。

  • 兼容遗留系统

现今技术不断更迭,团队想要保技术栈不落后,就需要在兼容原有系统的前提下,使用新框架去开发新功能。而遗留系统的功能早已完善,且运行稳定,团队没有必要也没有精力去将遗留系统重构一遍。此时团队如果需要使用新框架、新技术去开发新的应用,使用微前端是很好的解决方案。

  • 应用聚合

大型的互联网公司,或商业Saas平台,都会为用户/客户提供很多应用和服务。如何为用户呈现具有统一用户体验和一站式的应用聚合成为必须解决的问题。前端聚合已成为一个技术趋势,目前比较理想的解决方案就是微前端。

  • 不同团队间开发同一个应用,所用技术栈不同

团队需要把第三方的SaaS应用进行集成或者把第三方私服应用进行集成(比如在公司内部部署的 gitlab等),以及在已有多个应用的情况下,需要将它们聚合为一个单应用。

图源:https://micro-frontends.org/


什么是qiankun?

qiankun 是一个基于 single-spa 的微前端实现库,旨在帮助大家能更简单、无痛地构建一个生产可用微前端架构系统。

qiankun 孵化自蚂蚁金融科技基于微前端架构的云产品统一接入平台。在经过一批线上应用的充分检验及打磨后,该团队将其微前端内核抽取出来并开源,希望能同时帮助有类似需求的产品更方便地构建自己的微前端系统,同时也希望通过社区的帮助将 qiankun 打磨得更加成熟完善。

目前 qiankun 已在蚂蚁内部服务了超过 200+ 线上应用,在易用性及完备性上,绝对是值得信赖的。


qiankun特性:

基于 single-spa 封装,提供了更加开箱即用的 API。

不限技术栈,任意技术栈的应用均可 使用/接入,不论是 React/Vue/Angular/JQuery 还是其他等框架。

HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。

样式隔离,确保微应用之间样式互相不干扰。

JS 沙箱,确保微应用之间 全局变量/事件 不冲突。

资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。

umi 插件,提供了 @umijs/plugin-qiankun 供 umi 应用一键切换成微前端架构系统。


遇到的问题及解决建议

子应用静态资源404

  1. 所有图片等静态资源上传至 cdn,css 中直接引用 cdn 地址(推荐)

  2. 将字体文件和图片打包成 base64(适用于字体文件和图片体积小的项目)(但总是有一些不符合要求的资源,请使用第三种)

// webpack config loader, 添加以下rule到rules中
{
  test: /\.(png|jpe?g|gif|webp|woff2?|eot|ttf|otf)$/i,
  use: [{
    loader: 'url-loader',
    options: {},
  }]
}
// chainWebpack
config.module.rule('fonts').use('url-loader').loader('url-loader').options({}).end();
config.module.rule('images').use('url-loader').loader('url-loader').options({}).end();

3.在打包时给其注入完整路径(适用于字体文件和图片体积比较大的项目)

const elementFromPoint = document.elementFromPoint;
document.elementFromPoint = function (x, y) {
  const result = Reflect.apply(elementFromPoint, this, [x, y]);
  // 如果坐标元素为shadow则用该shadow再次获取
  if (result && result.shadowRoot) {
    return result.shadowRoot.elementFromPoint(x, y);
  }
  return result;
};


css样式隔离

默认情况下,qiankun会自动开启沙箱模式,但这个模式无法隔离主应用与子应用,也无法适应同时加载多子应用的场景。 qiankun还给出了shadow dom的方案,需要配置sandbox: { strictStyleIsolation: true } 需要注意: 基于 ShadowDOM 的严格样式隔离并不是一个可以无脑使用的方案,大部分情况下都需要接入应用做一些适配后才能正常在 ShadowDOM 中运行起来。比如 react 场景下需要解决这些问题 ,使用者需要清楚开启了 strictStyleIsolation 意味着什么。下面会列出我解决ShadowDom的一些案例。


fix shadow dom

  • getComputedStyle

当获取shadow dom的计算样式的时候传入的element是DocumentFragment,会报错。

const getComputedStyle = window.getComputedStyle;
window.getComputedStyle = (el, ...args) => {
  // 如果为shadow dom则直接返回
  if (el instanceof DocumentFragment) {
    return {};
  }
  return Reflect.apply(getComputedStyle, window, [el, ...args]);
};
  • elementFromPoint

根据坐标(x, y)当获取一个子应用的元素的时候,会返回shadow root,并不会返回真正的元素。

const elementFromPoint = document.elementFromPoint;
document.elementFromPoint = function (x, y) {
  const result = Reflect.apply(elementFromPoint, this, [x, y]);
  // 如果坐标元素为shadow则用该shadow再次获取
  if (result && result.shadowRoot) {
    return result.shadowRoot.elementFromPoint(x, y);
  }
  return result;
};
  • document 事件 target 为 shadow

当我们在document添加click、mousedown、mouseup等事件的时候,回调函数中的event.target不是真正的目标元素,而是shadow root元素。

// fix: 点击事件target为shadow元素的问题
const {addEventListener: oldAddEventListener, removeEventListener: oldRemoveEventListener} = document;
const fixEvents = ['click', 'mousedown', 'mouseup'];
const overrideEventFnMap = {};
const setOverrideEvent = (eventName, fn, overrideFn) => {
  if (fn === overrideFn) {
    return;
  }
  if (!overrideEventFnMap[eventName]) {
    overrideEventFnMap[eventName] = new Map();
  }
  overrideEventFnMap[eventName].set(fn, overrideFn);
};
const resetOverrideEvent = (eventName, fn) => {
  const eventFn = overrideEventFnMap[eventName]?.get(fn);
  if (eventFn) {
    overrideEventFnMap[eventName].delete(fn);
  }
  return eventFn || fn;
};
document.addEventListener = (event, fn, options) => {
  const callback = (e) => {
    // 当前事件对象为qiankun盒子,并且当前对象有shadowRoot元素,则fix事件对象为真实元素
    if (e.target.id?.startsWith('__qiankun_microapp_wrapper') && e.target?.shadowRoot) {
      fn({...e, target: e.path[0]});
      return;
    }
    fn(e);
  };
  const eventFn = fixEvents.includes(event) ? callback : fn;
  setOverrideEvent(event, fn, eventFn);
  Reflect.apply(oldAddEventListener, document, [event, eventFn, options]);
};
document.removeEventListener = (event, fn, options) => {
  const eventFn = resetOverrideEvent(event, fn);
  Reflect.apply(oldRemoveEventListener, document, [event, eventFn, options]);
};


js 沙箱

主要是隔离挂载在window上的变量,而qiankun内部已经帮你处理好了。在子应用运行时访问的window其实是一个Proxy代理对象。 所有子应用的全局变量变更都是在闭包中产生的,不会真正回写到 window 上,这样就能避免多实例之间的污染了。

图源:前端优选


复用公共依赖

比如:企业中的util、core、request、ui等公共依赖,在微前端中,我们不需要每个子应用都加载一次,这样既浪费资源并且还会导致本来单例的对象,变成了多例。 在webpack中配置externals。把需要复用的排除打包,然后在index.html中加载排除的lib外链(子应用需要在script或者style标签加上ignore属性,有了这个属性,qiankun 便不会再去加载这个 js/css,而子项目独立运行,这些 js/css 仍能被加载)

<link ignore rel="stylesheet" href="//element-ui.css">
<script ignore src="//element-ui.js"></script>
externals: {
  'element-ui': {
    commonjs: 'element-ui',
    commonjs2: 'element-ui',
    amd: 'element-ui',
    root: 'ElementUI' // 外链cdn加载挂载到window上的变量名
  }
}


父子共享(以国际化为例)

应用注册时或加载时,将依赖传递给子项目

// 注册
registerMicroApps([
  {
    name: 'micro-1', 
    entry: 'http://localhost:9001/micro-1', 
    container: '#micro-1', 
    activeRule: '/micro-1', 
    props: { i18n: this.$i18n }
  },
]);
// 手动加载
loadMicroApp({
  name,
  entry,
  container: `#${this.boxId}`,
  props: {
    i18n: this.$i18n
  }
});

子应用启动时获取props参数初始化

let { i18n } = props;
if (!i18n) {
  // 当独立运行时或主应用未共享时,动态加载本地国际化
  const module = await import('@/common-module/lang');
  i18n = module.default;
}
new Vue({
  i18n,
  router,
  render
});

主应用在注册子应用或者手动加载子应用时把共享的变量通过props传递给子应用,子应用在bootstrap或者mount钩子函数中获取,如果没有从props中获取到该变量,子应用则动态加载本地变量。


keep-alive(Vue)

其实并不建议做keepAlive,但是我还是做了,我能说什么…

网上有其他方案,我没有采纳,我在这里说下我的方案吧(综合了网上的方案),使用loadMicroApp手动加载和卸载子应用。这里有几个难点:

// microApp.js (可以走CI/CD运维配置,也可以通过接口从服务器获取)
const apps = [{
  name: 'micro-1',
  activeRule: '/micro-1'
}, {
  name: 'micro-2',
  activeRule: '/micro-2',
  prefetch: true
}, {
  name: 'micro-3',
  activeRule: '/micro-3',
  prefetch: false, // 预加载资源
  preload: false, // 预渲染
  keepalive: true // 缓存子应用
}];

export default apps.map(app => ({ ...app, entry: getEntryUrl(app.name) }));
<template>
  <div
    v-show="isActive"
    :id="boxId"
    :class="b()"
  />
</template>

<script>
import { loadMicroApp } from 'qiankun';

export default {
  name: 'MicroApp',
  props: {
    app: {
      type: Object,
      required: true
    }
  },
  inject: ['appLayout'],
  computed: {
    boxId() {
      return `micro-app_${this.app.name}`;
    },
    activeRule() {
      return this.app.activeRule;
    },
    currentPath() {
      return this.$route.fullPath;
    },
    // 判断当前子应用是否为激活状态
    isActive() {
      const {activeRule, currentPath} = this;
      const rules = Array.isArray(activeRule) ? [ ...activeRule ] : [activeRule];
      return rules.some(rule => {
        if (typeof rule === 'function') {
          return rule(currentPath);
        }
        return currentPath.startsWith(`${rule}`);
      });
    },
    isKeepalive() {
      return this.app.keepalive;
    }
  },
  watch: {
    isActive: {
      handler() {
        this.onActiveChange();
      }
    }
  },
  created () {
    // 需要等spa start后再加载应用,才会有shadow节点
    this.$once('started', () => {
      this.init();
    });
    // 把当前实例加入到layout中
    this.appLayout.apps.set(this.app.name, this);
  },
  methods: {
    init() {
      // 预挂载
      if (this.app.preload) {
        this.load();
      }
      // 如果路由直接进入当前应用则会在这里挂载
      this.onActiveChange();
    },
    /**
     * 加载微应用
     * @returns {Promise<void>}
     */
    async load() {
      if (!this.appInstance) {
        const { name, entry, preload } = this.app;
        this.appInstance = loadMicroApp({
          name,
          entry,
          container: `#${this.boxId}`,
          props: {
            ...,
            appName: name,
            preload,
            active: this.isActive
          }
        });
        await this.appInstance.mountPromise;
      }
    },
    /**
     * 状态变更
     * @returns {Promise<void>}
     */
    async onActiveChange() {
      // 触发全局事件
      this.eventBus.$emit(`${this.isActive ? 'activated' : 'deactivated'}:${this.app.name}`);
      // 如果当前为激活则加载
      if (this.isActive) {
        await this.load();
      }
      // 如果当前为失效并且当前应用已加载并且配置为不缓存则卸载当前应用
      if (!this.isActive && this.appInstance && !this.isKeepalive) {
        await this.appInstance.unmount();
        this.appInstance = null;
      }
      // 通知布局当前状态变更
      this.$emit('active', this.isActive);
    }
  }
};
</script>
// App.vue (layout)
<template>
  <template v-if="!isMicroApp">
    <keep-alive>
      <router-view v-if="keepAlive" />
    </keep-alive>
    <router-view v-if="!keepAlive" />
  </template>
  <micro-app
    v-for="app of microApps"
    :key="app.name"
    :app="app"
    @active="onMicroActive"
  />
</template>
<script>
  computed: {
    isMicroApp() {
      return !!this.currentMicroApp;
    }
  },
  mounted () {
    // 启动qiankun主应用,开启多例与严格样式隔离沙箱(shadow dom)
    start({ singular: false, sandbox: { strictStyleIsolation: true } });
    // 过滤出需要预加载的子应用进行资源预加载
    const prefetchAppList = this.microApps.filter(item => item.prefetch);
    if (prefetchAppList.length) {
      // 延迟执行,放置影响当前访问的应用资源加载
      (window.requestIdleCallback || setTimeout)(() => prefetchApps(prefetchAppList));
    }
    // 触发微应用的初始化事件,代表spa已经started了
    this.appValues.forEach(app => app.$emit('started'));
  },
  methods: {
    onMicroActive() {
      this.currentMicroApp = this.appValues.find(item => item.isActive);
    }
  }
</script>
  1. 路由的响应,如果我们不卸载keepAlive的子应用,则子应用依然会响应路由的变化,从而导致子应用的当前路由已经不是离开时的路由了。

/**
 * 让vue-router支持keepalive,当主路由变更时如果当前子应用没有该路由则不做处理
 * 因为通过浏览器前进后退会先触发主路由的监听,导致没有及时通知到子应用deactivated,则子应用路由没有及时停止监听,则会处理本次主路由变更
 * @param router
 */
const supportKeepAlive = (router) => {
  const old = router.history.transitionTo;
  router.history.transitionTo = (location, cb) => {
    const matched = router.getMatchedComponents(location);
    if (!matched || !matched.length) {
      return;
    }
    Reflect.apply(old, router.history, [location, cb]);
  };
};
// 重写监听路由变更事件
supportKeepAlive(instance.$router);
// 如果为预挂载并且当前不为激活状态则停止监听路由,并设置_startLocation为空,为了在激活的时候可以响应
if (preload && !active) {
  // 如果当前子应用不是预加载(我这里做了多个子应用并存且可以预加载),并且访问的不是当前子应用则把路由停止
  instance.$router.history.teardown();
  instance.$router.history._startLocation = '';
}
  1. 页面的activateddeactivated触发。

// 在子应用创建的时候监听激活与失效事件
if (eventBus) {
  eventBus.$on(`activated:${appName}`, activated);
  eventBus.$on(`deactivated:${appName}`, deactivated);
}
/**
 * 获取当前路由的组件
 * @returns {*}
 */
const getCurrentRouteInstance = () => {
  const {matched} = instance?.$route || {};
  if (matched?.length) {
    const { instances } = matched[matched.length - 1];
    if (instances) {
      return instances.default || instances;
    }
  }
};

/**
 * 触发当前路由组件hook
 * @param hook
 */
const fireCurrentRouterInstanceHook = (hook) => {
  const com = getCurrentRouteInstance();
  const fns = com?.$options?.[hook];
  if (fns) {
    fns.forEach(fn => Reflect.apply(fn, com, [{ micro: true }]));
  }
};

/**
 * 激活当前子应用回调
 */
const activated = () => {
  instance?.$router.history.setupListeners();
  console.log('setupListeners');
  fireCurrentRouterInstanceHook('activated');
};
/**
 * 被 keep-alive 缓存的组件停用时调用。
 */
const deactivated = () => {
  instance?.$router.history.teardown();
  console.log('teardown');
  fireCurrentRouterInstanceHook('deactivated');
};


vuex 全局状态共享(慎用!破坏了vuex的理念, 不适用于大量的数据)

子应用使用自己的vuex,并不是真正的使用主应用的vuex。需要共享的vuex模块主应用与子应用理论来说是引用的相同的文件,我们在这个vuex模块标记它是否需要共享,并watch主应用与子应用的该模块。

当子应用中的state发生了改变则更新主应用的state,相反主应用的state变更后也同样修改子应用的state。

/**
 * 获取命名空间状态数据
 * @param state 状态数据
 * @param namespace 命名空间
 * @returns {*}
 */
const getNamespaceState = (state, namespace) => namespace === 'root' ? state : get(state, namespace);

/**
 * 更新状态数据
 * @param store 状态存储
 * @param namespace 命名空间
 * @param value 新的值
 * @returns {*}
 */
const updateStoreState = (store, namespace, value) => store._withCommit(() => setVo(getNamespaceState(store.state, namespace), value));

/**
 * 监听状态存储
 * @param store 状态存储
 * @param fn 变更事件函数
 * @param namespace 命名空间
 * @returns {*}
 * @private
 */
const _watch = (store, fn, namespace) => store.watch(state => getNamespaceState(state, namespace), fn, { deep: true });

const updateSubStoreState = (stores, ns, value) => stores.filter(s => s.__shareNamespaces.has(ns)).forEach(s => updateStoreState(s, ns, value));

export default (store, mainStore) => {
  // 如果有主应用存储则开启共享
  if (mainStore) {
    // 多个子应用与主应用共享时判断主应用存储是否已经标记为已共享
    if (mainStore.__isShare !== true) {
      // 所有子应用状态
      mainStore.__subStores = new Set();
      // 已监听的命名空间
      mainStore.__subWatchs = new Map();
      mainStore.__isShare = true;
    }
    // 把当前子应用存储放入主应用里面
    mainStore.__subStores.add(store);
    const shareNames = new Set();
    const { _modulesNamespaceMap: moduleMap } = store;
    // 监听当前store,更新主应用store,并统计该子应用需要共享的所有命名空间
    Object.keys(moduleMap).forEach(key => {
      const names = key.split('/').filter(k => !!k);
      // 如果该命名空间的上级命名空间已经共享则下级不需要再共享
      const has = names.some(name => shareNames.has(name));
      if (has) {
        return;
      }
      const { _rawModule: { share } } = moduleMap[key];
      if (share === true) {
        const namespace = names.join('.');
        // 监听当前子应用存储的命名空间,发生变化后更新主应用与之同名的命名空间数据
        _watch(store, value => updateStoreState(mainStore, namespace, value), namespace);
        shareNames.add(namespace);
      }
    });

    // 存储当前子应用需要共享的命名空间
    store.__shareNamespaces = shareNames;

    shareNames.forEach(ns => {
      // 从主应用同步数据
      updateStoreState(store, ns, getNamespaceState(mainStore.state, ns));
      if (mainStore.__subWatchs.has(ns)) {
        return;
      }
      // 监听主应用的状态,更新子应用存储
      const w = mainStore.watch(state => getNamespaceState(state, ns), value => updateSubStoreState([...mainStore.__subStores], ns, value), { deep: true });
      console.log(`主应用store监听模块【${ns}】数据`);
      mainStore.__subWatchs.set(ns, w);
    });
  }
  return store;
};


看到这里,你一定也惊叹于微前端的精妙吧!纸上得来终觉浅,期待各位的实践行动,如果遇到任何问题,欢迎关注我们 LigaAI ,一起交流,共同进步~

本文部分内容参考:

Micro Frontends

Micro Frontends from martinfowler.com

微前端的核心价值

qiankun介绍


本文作者: Alone zhou

本文链接:https://blog.cn-face.com/2021/06/17/微前端(qiankun)尝鲜/

版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!