1import React, {Component} from 'react'; 2import {Clipboard, FlatList, StyleSheet, Text, TextInput, TouchableOpacity, View,} from 'react-native'; 3import event from './event'; 4import {debounce} from './tool'; 5 6let ajaxStack = null; 7 8class AjaxStack { 9 constructor() { 10 this.requestIds = []; 11 this.requests = {}; 12 this.maxLength = 200; 13 this.listeners = []; 14 this.notify = debounce(10, false, this.notify); 15 } 16 17 getRequestIds() { 18 return this.requestIds; 19 } 20 21 getRequests() { 22 return this.requests; 23 } 24 25 getRequest(id) { 26 return this.requests[id] || {}; 27 } 28 29 readBlobAsText(blob, encoding = 'utf-8') { 30 return new Promise((resolve, reject) => { 31 resolve('undefined'); 32 // const fr = new FileReader(); 33 // fr.onload = event => { 34 // resolve(fr.result); 35 // }; 36 // fr.onerror = err => { 37 // reject(err); 38 // }; 39 // fr.readAsText(blob, encoding); 40 }); 41 } 42 43 JSONTryParse(jsonStr) { 44 try { 45 return JSON.parse(jsonStr); 46 } catch (error) { 47 return {}; 48 } 49 } 50 51 formatResponse(response) { 52 if (response) { 53 if (typeof response === 'string') 54 response = this.JSONTryParse(response); 55 return JSON.stringify(response, null, 2); 56 } else { 57 return '{}'; 58 } 59 } 60 61 updateRequest(id, data) { 62 // update item 63 const item = this.requests[id] || {}; 64 65 if (this.requestIds.length > this.maxLength) { 66 const _id = this.requestIds[this.requestIds.length - 1]; 67 this.requestIds.splice(this.requestIds.length - 1, 1); 68 this.requests[id] && delete this.requests[_id]; 69 } 70 for (const key in data) { 71 item[key] = data[key]; 72 } 73 // update dom 74 const domData = { 75 id, 76 index: item.index ?? this.requestIds.length + 1, 77 host: item.host, 78 url: item.url, 79 status: item.status, 80 method: item.method || '-', 81 costTime: item.costTime > 0 ? `${item.costTime} ms` : '-', 82 resHeaders: item.resHeaders || null, 83 reqHeaders: item.reqHeaders || null, 84 getData: item.getData || null, 85 postData: item.postData || null, 86 response: null, 87 actived: !!item.actived, 88 startTime: item.startTime, 89 endTime: item.endTime, 90 }; 91 switch (item.responseType) { 92 case '': 93 case 'text': 94 // try to parse JSON 95 if (typeof item.response === 'string') { 96 try { 97 domData.response = this.formatResponse(item.response); 98 } catch (e) { 99 // not a JSON string 100 domData.response = item.response; 101 } 102 } else if (typeof item.response !== 'undefined') { 103 domData.response = Object.prototype.toString.call( 104 item.response, 105 ); 106 } 107 break; 108 case 'json': 109 if (typeof item.response !== 'undefined') { 110 domData.response = this.formatResponse(item.response); 111 } 112 break; 113 case 'blob': 114 case 'document': 115 case 'arraybuffer': 116 default: 117 if (item.response && typeof item.response !== 'undefined') { 118 this.readBlobAsText(item.response).then(res => { 119 domData.response = this.formatResponse(res); 120 }); 121 } 122 break; 123 } 124 if (item.readyState === 0 || item.readyState === 1) { 125 domData.status = 'Pending'; 126 } else if (item.readyState === 2 || item.readyState === 3) { 127 domData.status = 'Loading'; 128 } else if (item.readyState === 4) { 129 // do nothing 130 } else { 131 domData.status = 'Unknown'; 132 } 133 if (this.requestIds.indexOf(id) === -1) { 134 this.requestIds.splice(0, 0, id); 135 } 136 this.requests[id] = domData; 137 this.notify(this.requests[id]); 138 } 139 140 clearRequests() { 141 this.requestIds = []; 142 this.requests = {}; 143 this.notify(); 144 } 145 146 notify(args) { 147 this.listeners.forEach(callback => { 148 callback(args); 149 }); 150 } 151 152 attach(callback) { 153 this.listeners.push(callback); 154 } 155} 156 157class Network extends Component { 158 constructor(props) { 159 super(props); 160 this.name = 'Network'; 161 this.mountState = false; 162 this.state = { 163 showingId: null, 164 requestIds: [], 165 requests: {}, 166 filterValue: '', 167 }; 168 ajaxStack.attach(currentRequest => { 169 if (this.mountState) { 170 this.setState({ 171 requestIds: ajaxStack.getRequestIds(), 172 requests: ajaxStack.getRequests(), 173 }); 174 } 175 }); 176 } 177 178 getScrollRef() { 179 return this.flatList; 180 } 181 182 componentDidMount() { 183 this.mountState = true; 184 this.setState({ 185 requestIds: ajaxStack.getRequestIds(), 186 requests: ajaxStack.getRequests(), 187 }); 188 event.on('clear', this.clearRequests.bind(this)); 189 } 190 191 componentWillUnmount() { 192 this.mountState = false; 193 event.off('clear', this.clearRequests.bind(this)); 194 } 195 196 clearRequests(name) { 197 if (name === this.name) { 198 ajaxStack.clearRequests(); 199 } 200 } 201 202 ListHeaderComponent() { 203 const count = Object.keys(this.state.requests).length || 0; 204 return ( 205 <View> 206 <View style={[styles.nwHeader]}> 207 <Text 208 style={[ 209 styles.nwHeaderTitle, 210 styles.flex3, 211 styles.bold, 212 ]}> 213 ({count})Host 214 </Text> 215 <Text 216 style={[ 217 styles.nwHeaderTitle, 218 styles.flex1, 219 styles.bold, 220 ]}> 221 Method 222 </Text> 223 <Text 224 style={[ 225 styles.nwHeaderTitle, 226 styles.flex1, 227 styles.bold, 228 ]}> 229 Status 230 </Text> 231 <Text 232 style={[ 233 styles.nwHeaderTitle, 234 styles.bold, 235 {width: 90}, 236 ]}> 237 Time/Retry 238 </Text> 239 </View> 240 <View style={styles.filterValueBar}> 241 <TextInput 242 ref={ref => { 243 this.textInput = ref; 244 }} 245 style={styles.filterValueBarInput} 246 placeholderTextColor={'#000000a1'} 247 placeholder="输入过滤条件..." 248 onSubmitEditing={({nativeEvent}) => { 249 if (nativeEvent) { 250 this.regInstance = new RegExp( 251 nativeEvent.text, 252 'ig', 253 ); 254 this.setState({filterValue: nativeEvent.text}); 255 } 256 }} 257 /> 258 <TouchableOpacity 259 style={styles.filterValueBarBtn} 260 onPress={this.clearFilterValue.bind(this)}> 261 <Text>X</Text> 262 </TouchableOpacity> 263 </View> 264 </View> 265 ); 266 } 267 268 clearFilterValue() { 269 this.setState( 270 { 271 filterValue: '', 272 }, 273 () => { 274 this.textInput.clear(); 275 }, 276 ); 277 } 278 279 copy2cURL(item) { 280 let headerStr = ''; 281 if (item.reqHeaders) { 282 Object.keys(item.reqHeaders).forEach(key => { 283 let reqHeaders = item.reqHeaders[key]; 284 if (reqHeaders) { 285 headerStr += ` -H '${key}: ${reqHeaders}'`; 286 } 287 }); 288 } 289 let cURL = `curl -X ${item.method} '${item.url}' ${headerStr}`; 290 if (item.method === 'POST' && item.postData) 291 cURL += ` --data-binary '${item.postData}'`; 292 Clipboard.setString(cURL); 293 } 294 295 retryFetch(item) { 296 let options = { 297 method: item.method, 298 }; 299 if (item.reqHeaders) options.headers = item.reqHeaders; 300 if (item.method == 'POST' && item.postData) 301 options.body = item.postData; 302 fetch(item.url, options); 303 } 304 305 renderItem({item}) { 306 const _item = this.state.requests[item] || {}; 307 if ( 308 this.state.filterValue && 309 this.regInstance && 310 !this.regInstance.test(_item.url) 311 ) 312 return null; 313 return ( 314 <View style={styles.nwItem}> 315 <TouchableOpacity 316 onPress={() => { 317 this.setState(state => ({ 318 showingId: 319 state.showingId === _item.id ? null : _item.id, 320 })); 321 }}> 322 <View 323 style={[ 324 styles.nwHeader, 325 this.state.showingId === _item.id && styles.active, 326 _item.status >= 400 && styles.error, 327 ]}> 328 <Text 329 numberOfLines={1} 330 ellipsizeMode="middle" 331 style={[styles.nwHeaderTitle, styles.flex3]}> 332 {`(${_item.index})${_item.host}`} 333 </Text> 334 <Text style={[styles.nwHeaderTitle, styles.flex1]}> 335 {_item.method} 336 </Text> 337 <Text 338 numberOfLines={1} 339 style={[styles.nwHeaderTitle, styles.flex1]}> 340 {_item.status} 341 </Text> 342 <TouchableOpacity 343 onPress={() => { 344 this.retryFetch(_item); 345 }} 346 style={[ 347 styles.nwHeaderTitle, 348 { 349 width: 90, 350 borderRadius: 20, 351 borderColor: '#eeeeee', 352 borderWidth: 1, 353 }, 354 ]}> 355 <Text>{_item.costTime}</Text> 356 </TouchableOpacity> 357 </View> 358 </TouchableOpacity> 359 {this.state.showingId === _item.id && ( 360 <View style={styles.nwItemDetail}> 361 <View> 362 <Text 363 style={[ 364 styles.nwItemDetailHeader, 365 styles.bold, 366 ]}> 367 Operate 368 </Text> 369 <TouchableOpacity 370 onPress={() => { 371 this.copy2cURL(_item); 372 }}> 373 <Text>{'[ Copy cURL to clipboard ]'}</Text> 374 </TouchableOpacity> 375 <TouchableOpacity 376 onPress={() => { 377 Clipboard.setString(_item.response); 378 }}> 379 <Text>{'[ Copy response to clipboard ]'}</Text> 380 </TouchableOpacity> 381 </View> 382 <View> 383 <Text 384 style={[ 385 styles.nwItemDetailHeader, 386 styles.bold, 387 ]}> 388 General 389 </Text> 390 <View style={styles.nwDetailItem}> 391 <Text>URL:</Text> 392 <Text>{_item.url}</Text> 393 </View> 394 <View style={styles.nwDetailItem}> 395 <Text>startTime:</Text> 396 <Text>{_item.startTime}</Text> 397 </View> 398 <View style={styles.nwDetailItem}> 399 <Text>endTime:</Text> 400 <Text>{_item.endTime}</Text> 401 </View> 402 </View> 403 {_item.reqHeaders && ( 404 <View> 405 <Text 406 style={[ 407 styles.nwItemDetailHeader, 408 styles.bold, 409 ]}> 410 Request Header 411 </Text> 412 {Object.keys(_item.reqHeaders).map(key => ( 413 <View style={styles.nwDetailItem} key={key}> 414 <Text>{key}:</Text> 415 <Text>{_item.reqHeaders[key]}</Text> 416 </View> 417 ))} 418 </View> 419 )} 420 {_item.resHeaders && ( 421 <View> 422 <Text 423 style={[ 424 styles.nwItemDetailHeader, 425 styles.bold, 426 ]}> 427 Response Header 428 </Text> 429 {Object.keys(_item.resHeaders).map(key => ( 430 <View style={styles.nwDetailItem} key={key}> 431 <Text>{key}:</Text> 432 <Text>{_item.resHeaders[key]}</Text> 433 </View> 434 ))} 435 </View> 436 )} 437 {_item.getData && ( 438 <View> 439 <Text 440 style={[ 441 styles.nwItemDetailHeader, 442 styles.bold, 443 ]}> 444 Query String Parameters 445 </Text> 446 {Object.keys(_item.getData).map(key => ( 447 <View style={styles.nwDetailItem} key={key}> 448 <Text>{key}:</Text> 449 <Text>{_item.getData[key]}</Text> 450 </View> 451 ))} 452 </View> 453 )} 454 {_item.postData && ( 455 <View> 456 <Text 457 style={[ 458 styles.nwItemDetailHeader, 459 styles.bold, 460 ]}> 461 Form Data 462 </Text> 463 <Text>{_item.postData}</Text> 464 </View> 465 )} 466 <View> 467 <Text 468 style={[ 469 styles.nwItemDetailHeader, 470 styles.bold, 471 ]}> 472 Response 473 </Text> 474 <View style={[styles.nwDetailItem]}> 475 <Text>{_item.response || ''}</Text> 476 </View> 477 </View> 478 </View> 479 )} 480 </View> 481 ); 482 } 483 484 render() { 485 return ( 486 <FlatList 487 ref={ref => { 488 this.flatList = ref; 489 }} 490 showsVerticalScrollIndicator={true} 491 ListHeaderComponent={this.ListHeaderComponent.bind(this)} 492 extraData={this.state} 493 data={this.state.requestIds} 494 stickyHeaderIndices={[0]} 495 renderItem={this.renderItem.bind(this)} 496 ListEmptyComponent={() => <Text> Loading...</Text>} 497 keyExtractor={item => item} 498 /> 499 ); 500 } 501} 502 503const styles = StyleSheet.create({ 504 bold: { 505 fontWeight: '700', 506 }, 507 active: { 508 backgroundColor: '#fffacd', 509 }, 510 flex3: { 511 flex: 3, 512 }, 513 flex1: { 514 flex: 1, 515 }, 516 error: { 517 backgroundColor: '#ffe4e1', 518 borderColor: '#ffb930', 519 }, 520 nwHeader: { 521 flexDirection: 'row', 522 backgroundColor: '#fff', 523 }, 524 nwHeaderTitle: { 525 borderColor: '#eee', 526 borderWidth: StyleSheet.hairlineWidth, 527 paddingVertical: 4, 528 paddingHorizontal: 2, 529 }, 530 nwItem: {}, 531 nwItemDetail: { 532 borderColor: '#eee', 533 borderLeftWidth: StyleSheet.hairlineWidth, 534 }, 535 nwItemDetailHeader: { 536 paddingLeft: 5, 537 paddingVertical: 4, 538 backgroundColor: '#eee', 539 }, 540 nwDetailItem: { 541 paddingLeft: 5, 542 flexDirection: 'row', 543 }, 544 filterValueBarBtn: { 545 width: 40, 546 alignItems: 'center', 547 justifyContent: 'center', 548 backgroundColor: '#eee', 549 }, 550 filterValueBarInput: { 551 flex: 1, 552 paddingLeft: 10, 553 backgroundColor: '#ffffff', 554 color: '#000000', 555 }, 556 filterValueBar: { 557 flexDirection: 'row', 558 height: 40, 559 borderWidth: 1, 560 borderColor: '#eee', 561 }, 562}); 563 564function unixId() { 565 return Math.round(Math.random() * 1000000).toString(16); 566} 567 568function proxyAjax(XHR, stack) { 569 if (!XHR) { 570 return; 571 } 572 const _open = XHR.prototype.open; 573 const _send = XHR.prototype.send; 574 this._open = _open; 575 this._send = _send; 576 577 // mock open() 578 XHR.prototype.open = function (...args) { 579 const XMLReq = this; 580 const method = args[0]; 581 const url = args[1]; 582 const id = unixId(); 583 let timer = null; 584 585 // may be used by other functions 586 XMLReq._requestID = id; 587 XMLReq._method = method; 588 XMLReq._url = url; 589 590 // mock onreadystatechange 591 const _onreadystatechange = XMLReq.onreadystatechange || function () {}; 592 const onreadystatechange = function () { 593 const item = stack.getRequest(id); 594 595 // update status 596 item.readyState = XMLReq.readyState; 597 item.status = 0; 598 if (XMLReq.readyState > 1) { 599 item.status = XMLReq.status; 600 } 601 item.responseType = XMLReq.responseType; 602 603 if (XMLReq.readyState === 0) { 604 // UNSENT 605 if (!item.startTime) { 606 item.startTime = +new Date(); 607 } 608 } else if (XMLReq.readyState === 1) { 609 // OPENED 610 if (!item.startTime) { 611 item.startTime = +new Date(); 612 } 613 } else if (XMLReq.readyState === 2) { 614 // HEADERS_RECEIVED 615 item.resHeaders = {}; 616 const resHeaders = XMLReq.getAllResponseHeaders() || ''; 617 const resHeadersArr = resHeaders.split('\n'); 618 // extract plain text to key-value format 619 for (let i = 0; i < resHeadersArr.length; i++) { 620 const line = resHeadersArr[i]; 621 if (!line) { 622 // eslint-disable-next-line no-continue 623 continue; 624 } 625 const arr = line.split(': '); 626 const key = arr[0]; 627 const value = arr.slice(1).join(': '); 628 item.resHeaders[key] = value; 629 } 630 } else if (XMLReq.readyState === 3) { 631 // LOADING 632 } else if (XMLReq.readyState === 4) { 633 // DONE 634 clearInterval(timer); 635 item.endTime = +new Date(); 636 item.costTime = item.endTime - (item.startTime || item.endTime); 637 item.response = XMLReq.response; 638 } else { 639 clearInterval(timer); 640 } 641 642 if (!XMLReq._noVConsole) { 643 stack.updateRequest(id, item); 644 } 645 return _onreadystatechange.apply(XMLReq, args); 646 }; 647 XMLReq.onreadystatechange = onreadystatechange; 648 649 // some 3rd libraries will change XHR's default function 650 // so we use a timer to avoid lost tracking of readyState 651 let preState = -1; 652 timer = setInterval(() => { 653 if (preState !== XMLReq.readyState) { 654 preState = XMLReq.readyState; 655 onreadystatechange.call(XMLReq); 656 } 657 }, 10); 658 659 return _open.apply(XMLReq, args); 660 }; 661 662 // mock send() 663 XHR.prototype.send = function (...args) { 664 const XMLReq = this; 665 const data = args[0]; 666 667 const item = stack.getRequest(XMLReq._requestID); 668 item.method = XMLReq._method.toUpperCase(); 669 670 let query = XMLReq._url.split('?'); // a.php?b=c&d=?e => ['a.php', 'b=c&d=', '?e'] 671 item.url = XMLReq._url; 672 item.host = query[0]; 673 674 if (query.length == 2) { 675 item.getData = {}; 676 query = query[1].split('&'); // => ['b=c', 'd=?e'] 677 for (let q of query) { 678 q = q.split('='); 679 item.getData[q[0]] = decodeURIComponent(q[1]); 680 } 681 } 682 683 item.reqHeaders = XMLReq._headers; 684 685 if (item.method === 'POST' && data) { 686 // save POST data 687 if (typeof data === 'string') { 688 item.postData = data; 689 } else { 690 try { 691 item.postData = JSON.stringify(data); 692 } catch (error) {} 693 } 694 } 695 696 if (!XMLReq._noVConsole) { 697 stack.updateRequest(XMLReq._requestID, item); 698 } 699 700 return _send.apply(XMLReq, args); 701 }; 702} 703 704export default Network; 705 706export const traceNetwork = () => { 707 if (!ajaxStack) { 708 ajaxStack = new AjaxStack(); 709 proxyAjax( 710 global.originalXMLHttpRequest || global.XMLHttpRequest, 711 ajaxStack, 712 ); 713 } 714}; 715