为了账号安全,请及时绑定邮箱和手机立即绑定
this 使用问题

大部分开发者都会合理、巧妙的运用 this 关键字。初学者容易在 this 指向上犯错,如下面这个 Vue 组件:<div id="app"></div><script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.9/vue.min.js"></script><script> // 发送post请求 const post = (cb) => { // 假装发了请求并在200ms后返回了服务端响应的内容 setTimeout(function() { cb([ { id: 1, name: '小红', }, { id: 2, name: '小明', } ]); }); }; new Vue({ el: '#app', data: function() { return { list: [], }; }, mounted: function() { this.getList(); }, methods: { getList: function() { post(function(data) { this.list = data; console.log(this); this.log(); // 报错:this.log is not a function }); }, log: function() { console.log('输出一下 list:', this.list); }, }, });</script>这是初学 Vue 的同学经常碰到的问题,为什么这个 this.log() 会抛出异常,打印了 this.list 似乎也是正常的。这其实是因为传递给 post 方法的回调函数,拥有自己的 this,有关内容可以查阅 this章节。不光在这个场景下,其他类似的场景也要注意,在写回调函数的时候,如果在回调函数内要用到 this,就要特别注意一下这个 this 的指向。可以使用 ES6 的箭头函数 或者将需要的 this 赋值给一个变量,再通过作用域链的特性访问即可:<div id="app"></div><script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.9/vue.min.js"></script><script> // 发送post请求 const post = (cb) => { // 假装发了请求并在200ms后返回了服务端响应的内容 setTimeout(function() { cb([ { id: 1, name: '小红', }, { id: 2, name: '小明', } ]); }); }; new Vue({ el: '#app', data: function() { return { list: [], }; }, mounted: function() { this.getList(); }, methods: { getList: function() { // 传递箭头函数 post((data) => { this.list = data; console.log(this); this.log(); // 报错:this.log is not a function }); // 使用保留 this 的做法 // var _this = this; // post(function(data) { // _this.list = data; // console.log(this); // _this.log(); // 报错:this.log is not a function // }); }, log: function() { console.log('输出一下 list:', this.list); }, }, });</script>这个问题通常初学者都会碰到,之后慢慢就会形成习惯,会非常自然的规避掉这个问题。

5.2 WebSocket 业务类

public class MyWebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame>{ private SimpleDateFormat format=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { //1.获取Channel通道 final Channel channel=ctx.channel(); //2.创建一个定时线程池 ScheduledExecutorService ses=Executors.newScheduledThreadPool(1); //3.一秒钟之后只需,并且每隔5秒往浏览器发送数据 ses.scheduleWithFixedDelay(new Runnable() { public void run() { String sendTime=format.format(new Date()); channel.writeAndFlush(new TextWebSocketFrame("推送时间=" + sendTime)); } },1,5, TimeUnit.SECONDS); } //接受浏览器消息 @Override protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception { System.out.println("收到消息 " + msg.text()); } //当web客户端连接后,触发方法 @Override public void handlerAdded(ChannelHandlerContext ctx) throws Exception { } //当web客户端断开后,触发方法 @Override public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { }}代码说明:其实 WebSocket 对于的 Handler 跟我们普通业务的 Handler 没有什么区别,这里主要使用定时线程池定时往浏览器推送消息,这个是传统的 Http+Ajax 请求无法实现的逆向推送效果。

4.3 携带 CSRF Token

为了在 HTTP 请求中携带 CSRF Token,我们必须要对 HTTP Request 做一些配置,因为它默认是不会携带 CSRF 相关参数的。默认情况下,Spring Security 中有 CsrfFilter 判断请求中是否有 _csrf 参数,通常请求来自于两种情况,Form 表单提交或者 Ajax。4.3.1 Form 表单提交使用 Form 表单提交代码时,我们需要在 Form 参数中增加一个隐藏项:_csrf,例如:<input type="hidden" name="_csrf" value="4bfd1575-3ad1-4d21-96c7-4ef2d9f86721"/>这里的 _csrf 有几种配置方式:自动注入Spring Security 通过扩展 Spring 的 RequestDataValueProcessor 类,实现了 RequestDataValueProcessor 类,这意味着如果我们使用 Spring 标签库、Thymeleaf 模板插件、或者其它集成了 RequestDataValueProcessor 对象的视图组件是,表单的非幂等请求(例如:POST)都会自动携带 CSRF Token。JSP 标签针对 JSP 作为页面开发基础,我们可以直接使用 Spring 的表单标签库或者 CsrfInput 标签。也可以通过更加直接的方式,在使用 HttpServletRequest 属性 _csrf,代码如下:<c:url var="logoutUrl" value="/logout"/><form action="${logoutUrl}" method="post"><input type="submit" value="登出" /><input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/></form>4.3.2 Ajax 和 JSON 请求如果使用 Javascript 做为请求提交方式,我们没法直接使用 Http CSRF 参数,取而代之的是使用 Http 头的方式。这同样也有几种方法:自动注入Spring Security 可以自动将 CSRF Token 保存到 Cookie 中,一些客户端框架如 AngularJS 会自动从中得到 CSRF Token 并放置到请求头中。Meta 标签另一种方式是从 Cookie 中解压 Token 并使用 Meta 标签,如下:<html><head> <meta name="_csrf" content="4bfd1575-3ad1-4d21-96c7-4ef2d9f86721"/> <meta name="_csrf_header" content="X-CSRF-TOKEN"/> <!-- ... --></head>当 Meta 标签中有 Token 信息时,我们就可以将 Meta 中的 CSRF Token 值用作请求参数了。以 JQuery 为例:$(function () { var token = $("meta[name='_csrf']").attr("content"); var header = $("meta[name='_csrf_header']").attr("content"); $(document).ajaxSend(function(e, xhr, options) { xhr.setRequestHeader(header, token); });});

1. 简介

官方解释:ECharts 中提供了两种格式的地图数据,一种是可以直接 script 标签引入的 js 文件,引入后会自动注册地图名字和数据。还有一种是 JSON 文件,需要通过 AJAX 异步加载后手动注册。慕课解释某种程度上 ECharts 中的地图坐标系可以看做一种特殊直角坐标系,它将现实世界的地理平面映射为二维平面,通过经纬度系统实现坐标定位,通过线段按规则勾画出各个独立的地理、行政区域。在地图坐标系平面上,可以使用散点图、热力图、线图等图表类型。在 ECharts 上,地理坐标系可通过 geo 属性配置,配置项列表可参考 官方文档,本文结合实例讨论地理坐标系中需要理解的知识点。

1. 何时处理响应

首先思考一个问题,我们应该在什么时机处理服务端的响应呢?我们知道,Ajax 可以发送异步请求,那数据的返回当然也不可能是同步返回的。客户端只有等到服务端数据返回才能进行数据的下一步处理。如果服务端没有正确响应,或者说服务端的响应还没结束,那么客户端是无法获得正确响应的。讲得俏皮一点,客户端在这个时候还得看服务端的脸色。那么,在代码中我们要在什么样的时机开始处理响应呢?这里,我们有必要了解一下 XMLHttpRequest.readyState 和 XMLHttpRequest.status。

3.2 常用配置项简介

常用的配置项我们简单带过,比如:data:代表着发送到服务器的数据。 在不同的情况下会转化字符串的格式,在 GET 方法的时候,会变为 “&” 拼接的参数附带在 url 后面。dataType:预期服务器返回的数据类型。 如果没有指定的话,Ajax 会根据 HTTP 的 MIME 信息来进行判断。cache:缓存控制相关。 默认是 true,如果设置为 false,那么浏览器不缓存此页面。headers:请求头。 我们通常会给一个 { key : value} 这样的键值对对象来设置我们的请求头内容。type:请求方法。默认是 GET 。

2. 删除起点网用户的所有书架

首先我们随便添加一个书籍到书架上,然后进行清楚,请看下图,通过 Chrome 开发者工具我们可以找到删除书架上书籍的 URL 请求以及相应携带参数:删除书架上的书籍该请求一共有三个参数:_csrfToken:可以从 cookie 中获取;bids:书籍编号,可以从这一行的 html 元素中提取;gid:发现是固定的100;于是我们在请求到书架上的书籍信息时,解析得到书籍编号,然后对应发送删除该书籍的请求,对应的代码如下:from .get_cookie import get_cookies_from_chromefrom ..items import QidianSpiderItem# 删除书籍信息 https://my.qidian.com/ajax/BookShelf/DelBook?_csrfToken=YJklLmhEFpEfuSmqZZGaK72D4sUVJty52gyKw0TJ&bids=1022282526&gid=-100class BookCaseSpider(Spider): name = "bookcase" # 构造函数 def __init__(self): self.cookie_dict = get_cookies_from_chrome( "qidian.com", ["_csrfToken", "e1", "e2", "newstatisticUUID", "ywguid", "ywkey"] ) def start_requests(self): url = "https://my.qidian.com/bookcase" # http请求时附加上cookie信息 yield Request(url=url, cookies=self.cookie_dict) def parse(self, response): item = QidianSpiderItem() books = response.xpath('//table[@id="shelfTable"]/tbody/tr') for book in books: # ... # 删除该书籍信息 query_data = { 'bids': book.xpath('td[6]/div[@class="ui-datalist"]/div[@class="ui-datalist-datalist"]/a[1]/@data-id').extract_first(), 'gid': '-100', '_csrfToken': self.cookie_dict['_csrfToken'] } url = "https://my.qidian.com/ajax/BookShelf/DelBook?{}".format(parse.urlencode(query_data)) print('对应删除url请求={}'.format(url)) yield Request(url=url, method='get', cookies=self.cookie_dict, callback=self.parse_delete_book) def parse_delete_book(self, response): """ 删除结果:{"code":0,"data":{"1022354901":{"code":0,"message":"操作成功"}},"msg":"成功"} """ data = response.text print('删除响应:{}'.format(data)) if isinstance(data, str): data = json.loads(data) print('msg = {}'.format(data['msg']))是不是非常简单?来看看最后运行的效果:启动清除书架信息爬虫最后用户书架数据是不是很有意思?基于这样的操作,我们想想淘宝一键清除购物车功能,是不是也能这样实现?还有每次明星的恋情有变,连夜删除上千条微博,导致手指酸痛,我们是否能提供一键清除微博的功能,解决他们的痛点?这些事情是不是想想就很激动?还等什么,心动不如行动,这个就作为课后作业吧,希望你能独立完成淘宝的一键清除购物车代码。

5.1 数据录入、发送请求及响应

const submitBtn = document.getElementById('submitBtn'); // 提交按钮/*** 提交数据函数, 返回的是一个 promise*/const submitValues = (values) => { return Ajax({ method: 'post', url: '/course/post', data: values })}// 提交按钮绑定点击事件处理函数submitBtn.addEventListener('click', () => { const inputItems = ['name', 'teacher', 'startTime', 'endTime'] // 表单项几个 key const values = {} // 初始化返回数据 inputItems.forEach(key => { // 遍历 inputItems,获取对应的 input 上的 value // 如果 value 有值, 则添加相应的项 const value = document.getElementById(key).value; if (!value) return; values[key] = value }) // 最后发送提交请求 submitValues(values).then(data => { updateTableProcess(); // 成功后重新更新表格 })})这段代码并显然没有考虑多重情况,比如填写的项是否可以为空,或者填写的值的格式控制,不过这都不重要。我们这里默认全部填写,并且是正确的情况。着重关注发送请求并带上 data 到后端的过程。

1. 什么是 jQuery

jQuery 是一个使用 JavaScript 编写的库,可以让开发者用更少的代码来完成业务逻辑。许多年前前端的技术没有现在这么丰富,jQuery 和 JavaScript 也会被经常称为两个技术,因为使用 jQuery 完全可以替代掉使用原生的 JavaScript 操作 DOM、处理动画、处理 AJAX 等,这让两者之间的概念变得模糊。可以对比一下删除一个节点的操作:// 使用JavaScriptvar el = document.getElementById('element');el.parentNode.removeChild(el);// 使用 jQuery$('#element').remove();两者的区别一比较就出来了,jQuery 封装一层 DOM 操作,将原生的 DOM 方法向上层抽象,提供了一套更简洁的 API 来操作 DOM,同时也针对各个浏览器做了兼容性处理,如事件对象、事件的绑定方式等。

2. 几种常用的客户端-服务器消息传递方式

http 最常用的协议,用于客户端主动向服务器发送请求,单向传递;ajax HTTP 的扩展版,底层还是 HTTP 协议,只不过客户端是无刷新的;comet 也是基于 HTTP 封装的,使用 HTTP 长连接的方式,原理大致是将 HTTP 的timeout 设置较长,服务器有数据变化时返回数据给客户端,同时断开连接,客户端处理完数据之后重新创建一个 HTTP 长连接,循环上述操作(这只是其中一种实现方式);websocket 这是 HTML5 中的新标准,基于 socket 的方式实现客户端与服务端双向通信,需要浏览器支持 HTML5;Adobe Flash Socket 这个也是使用 socket 的方式,需要浏览器支持 flash 才行,为了兼容老版本的浏览器;ActiveX object 只适用于 IE 浏览器;目前尚没有一种方式能兼容所有的浏览器,只能针对软件的目标客户人群做一定的兼容。sse 服务端单向推送。

3. 使用 jQuery

jQuery 使用 $ 或者 jQuery 来生成一个 jQuery 对象,这里统一使用 $。1167$ 可以接受一个 CSS 规范的选择器,用来选择元素,html 方法相当于设置 DOM 节点的 innerHTML 属性。在 DOM 相关章节有提到,如果使用 querySelector 来选择节点,碰到节点不存在的情况下,会返回 null,这样就需要一层判断, jQuery 已经处理好了这些情况。<div>DOM节点</div><div class="element"></div><script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.min.js"></script><script> $('.ele').html('<p>这里是用 jQuery 插入的 HTML</p>'); console.log('不会影响正常程序执行');</script>其可以接受的参数不仅仅是 CSS 选择器,也可以是一个原生 DOM 节点,一段 HTML 字符串等。jQuery 选择 $ 作为作为入口名称,一部分是因为简单,原生 DOM 提供的选择 DOM 节点的方法都是一长串,另一个原因是 $ 本身的发音 dollar 和 DOM 的发音接近。

4.2 Content-type

这一小节也可以当做附录来看,主要罗列一些常见的 Content-type 类型,给出一个参照,我们在了解之余,在实际使用 Ajax 的过程中,也可以随时回来翻看:Content-Type(Mime-Type)备注text/htmlHtml 文本格式类型text/xmlXml 文本格式类型text/plain纯文本格式类型image/jpegJpeg 图片格式类型image/pngPng 图片格式类型image/gifGif 图片格式类型application/jsonJson 数据格式类型application/xmlXml 数据格式类型application/xhtml+xmlHXTML 数据格式类型application/atom+xmlAtom Xml 聚合格式类型application/mswordWord 文档格式类型application/pdfPdf 文档格式类型application/octet-stream二进制数据格式类型application/x-www-form-urlencodedForm表单数据被编码成以 ‘&’ 分隔的键-值对,发送到服务端multipart/form-data表单文件上传使用的格式类型

4. axios 库封装

在真实的项目中会经常使用到 axios 这样的 ajax 请求的库,虽然可以直接使用,但是往往业务中会有很多接口请求的地方,而这么多的请求有些固定不变的,每个接口在请求时都需要,如:token,baseURL,timeout 等等,针对这样的场景,我们可以对 axios 库进行二次业务封装。对于接口不同的返回结果我们希望有一个全局的提示框,这里我们使用 element-ui 组件库搭配使用。封装后的代码如下:import axios from 'axios';import { baseURL } from '@/config'class Http { constructor(baseUrl) { this.baseURL = baseURL; this.timeout = 3000; } setInterceptor(instance) { instance.interceptors.request.use(config => { return config; }); instance.interceptors.response.use(res => { if (res.status == 200) { return Promise.resolve(res.data); } else { return Promise.reject(res); } }, err => { return Promise.reject(err); }); } mergeOptions(options) { return { baseURL: this.baseURL, timeout: this.timeout, ...options } } request(options) { const instance = axios.create(); const opts = this.mergeOptions(options); this.setInterceptor(instance); return instance(opts); } get(url, config = {}) { return this.request({ method: 'get', url: url, ...config }) } post(url, data) { return this.request({ method: 'post', url, data }) }}export default new Http;

1. 前言

本节开始我们将进入 ES6 实战课程,首先会花费两节的时间来学习 Vue3 响应式原理,并实现一个基础版的 Vue3 响应式系统;然后通过 Promise 来封装一个真实业务场景中的 ajax 请求;最后我们会聊聊前端开发过程中的编程风格。本实战主要通过对前面 ES6 的学习应用到实际开发中来,Vue3 的响应式系统涵盖了大部分 ES6 新增的核心 API,如:Proxy、Reflect、Set/Map、WeakMap、Symbol 等 ES6 新特性的应用。更加深入地学习 ES6 新增 API 的应用场景。由于篇幅内容有限,本实战不会完全实现 Vue3 响应式系统的所有 API,主要实现 reactive 、 effect 这四个核心 API,其他 API 可以参考 vue-next源码。本节的目录结构和命名和 Vue3 源码基本一致,在阅读源码的时候我们能看到作者的思考,和功能细颗粒度的拆分,使得代码更易于扩展和复用。

1. 验证目标

表单用于收集信息,从 HTML 上讲,表单内容使用 form 标签进行包裹。<form action="/login"> <label> 用户名:<input type="text"> </label> <label> 密码:<input type="text"> </label> <div> <button type="submit">登入</button> </div></form>这就是一个相对简单的表单,其中包含文本框(input标签)与按钮(button标签),并使用 form 标签进行包裹。利用 form 标签,再触发其 submit 事件时,会将表单内容收集后提交个体 action 属性配置的路径。单其实把 form 标签去掉,在页面上展示的效果几乎是一样的。<label> 用户名:<input type="text"></label><label> 密码:<input type="text"></label><div> <button type="submit">登入</button></div>·所以自出现 AJAX 技术后,很多开发者都不再书写 form 标签,直接使用其他元素对表单内容进行包裹,因为业务上可能不需要使用 form 标签的特性来提交表单。其实不论是使用表单,还是不使用表单,表单的验证都是针对所有表单项的,即输入框、单选项、多选项等。在表单提交之前,需要对写着表单项的内容做校验,然后拦截提交操作。

1. 反爬虫常见方式:

请求头识别这是一种最基本的反爬虫方式,网站运营者通过验证爬虫的请求头的 User-agent,accep-enconding 等信息来验证请求的发出宿主是不是真实的用户常用浏览器或者一些特定的请求头信息。动态加载通过 Ajax,或 者javascript 来动态获取和加载数据,加大爬虫直接获取数据的难度。验证码这个相信大多数读者非常熟悉了吧,当我们输错多次密码的时候,很多平台都会弹出各种二维码让我们识别,或者抢火车票的时候,会出现各种复杂的验证码,验证码是反爬虫措施中,运用最广,同时也是最有效直接的方式来阻止爬虫的措施之一。限制IP在识别到某些异常的访问的时候,网站运营者会设置一个黑名单,把一些判定为爬虫的IP进行限制或者封杀。账号限制有些网站,没有游客模式,只有通过注册后才可以登录看到内容,这个就是典型的使用账号限制网站,一般可以用在网站用户量不多,数据安全要求严格的网站中。

4. 不使用 form 提交表单

不使用 form 标签来提交表单,通常都是使用 AJAX 进行数据交互的情况。这个时候就不需要拦截 form 的提交行为了。<style> h3 {margin-top: 0;color: #4caf50;} .login {width: 300px;padding: 32px;box-shadow: 2px 2px 10px rgba(0, 0, 0, .1);position: fixed;top: 40%;left: 50%;transform: translate(-50%, -50%);} .form-item {display: flex;margin-bottom: 16px;border-bottom: 1px solid #ccc;} .form-item .title {width: 70px;color: #666;font-size: 14px;} .form-item .content {flex: 1;} .form-item .content input {width: 100%;border: 0 none;padding: 2px 8px;outline: none;font-size: 16px;} .login-btn {width: 100%;border: 0 none;background-color: #4caf50;color: white;margin-top: 16px;outline: none;height: 32px;} .login-btn:active {background-color: #2da050;}</style><div class="login"> <h3>登入</h3> <label class="form-item"> <div class="title">用户名</div> <div class="content"> <input autocomplete="off" id="account" class="account" name="account" type="text"> </div> </label> <label class="form-item"> <div class="title">密码</div> <div class="content"> <input autocomplete="off" name="pwd" type="password"> </div> </label> <div> <button class="login-btn" type="button">登入</button> </div></div><script>var loginBtn = document.querySelector('.login-btn');var pwdEle = document.querySelector('[name="pwd"]');function login(cb) { // 假装登入花了 1 秒 setTimeout(function() { alert('登入成功'); cb && cb(); }, 1000);}loginBtn.addEventListener('click', function() { var pwd = pwdEle.value; if (pwd.length < 6 || pwd.length > 16) { alert('密码长度 6-16'); return; } login(function() { window.location.href = 'https://imooc.com'; });});</script>使用这种方式,就可以自主控制流程,不需要再考虑 form 标签的行为。

4. 前端开发流程

前后端分离开发,实际上前端工作就简化了。我们直接新建项目文件夹 shop-front (商城前端项目文件夹),然后将前端页面放到该文件夹即可。注意该页面不需要放到 Spring Boot 项目目录下,随便找个目录放置即可。实际开发过程中,后端和前端的项目可能都不在一台计算机上。前端核心业务代码如下,由于前端技术不是本节介绍的重点,所以不再详细解释,感兴趣的同学可以从 Git仓库 查看完整代码 。实例: //初始化方法 $(function () { var row = ""; $.ajax({ type: "GET", url: "http://127.0.0.1:8080/goods", //后端接口地址 dataType: "json", contentType: "application/json; charset=utf-8", success: function (res) { $.each(res, function (i, v) { row = "<tr>"; row += "<td>" + v.id + "</td>"; row += "<td>" + v.name + "</td>"; row += "<td>" + v.price + "</td>"; row += "<td>" + v.pic + "</td>"; row += "</tr>"; $("#goodsTable").append(row); }); }, error: function (err) { console.log(err); } }); });开发完该页面后,直接使用浏览器双击打开,查看控制台发现有错误信息提示。浏览器控制台返回错误信息考验英文水平的时候到了!关键是 has been blocked by CORS policy ,意味着被 CORS 策略阻塞了。我们的前端页面请求被 CORS 阻塞了,所以没成功获取到后端接口返回的数据。

2. 模板引擎使用场景

我们使用 Spring Boot 开发 Web 项目,大体上有两种方式。第一种方式,是后端服务化的方式,也是当前的主流方式。前端是静态的 HTML 页面,通过 Ajax 请求 Spring Boot 的后端接口。 Spring Boot 返回数据一般采用 JSON 格式,前端接收后将数据显示。第二种方式,是采取模板引擎的方式。前端的请求,到达 Spring Boot 的控制器后,控制器处理请求,然后将返回数据交给模板引擎。模板引擎负责根据数据生成 HTML 页面,最后将 HTML 返回给浏览器。我个人比较推荐第一种方式,说一下该方式的几个优点:便于分工协作:后端可以按自己的进度开发接口,前端可以开发页面,需要的时候直接调用后端 API ;便于项目拓展:比如前期是做的网站,后续要加一个 APP ,后端接口可以直接复用;降低服务端压力:后端只提供数据,一部分业务逻辑在前端处理了。服务端要做的事情少了,自然压力就小。本篇是讲模板引擎,也得说说模板引擎的优点,王婆卖瓜不能光夸草莓啊。模板引擎开发的页面,对搜索引擎 SEO 比较友好;还有就是简单的页面,如果用模板引擎开发速度比较快,毕竟模板化的方法,目的就是减少重复提高效率。

2. 发送一个请求

构建了 xhr 对象之后,我们可以通过方法的调用来进行请求的发送。xhr.open('GET', 'http://www.example.com');xhr.send();这是最简单最典型的发送请求的做法。只需要短短 2 行代码,我们就可以执行一个请求发送动作。实际上 XMLHttpRequest.open 这个方法的参数不止两个这么少,一共有 5 个参数:xhrReq.open(method, url, async, user, password);这些参数分别代表着:method: 代表HTTP请求的方法名,比如 GET、POST、 PUT 和 DELETE。url: 一个DomString,代表着要想向其发送请求的 url。async: 表示是否异步。user:用户名,用于认证用途。password:密码,用于认证用途。其中,user 和 password 都是用于认证用途。而前 3 个参数是我们经常都会使用到的。这里着重说的是参数 async。默认情况下,async 为 true,代表着请求将是异步的。当然我们也可以设置为 false,这样我们就可以同步请求了。然而,事实上我们应该尽量不这么做,因为同步的请求会阻塞我们的UI和一切用户活动,造成的体验非常不好。到目前为止,如果你也跟着做的话应该能看到已经可以发送一个 Ajax 请求了,虽然它是失败的,因为你并没有正确的服务能够处理这个请求。如果你在浏览器上运行,打开控制台,你应该会得到这样的一个效果:

2.4 前端应用部署

前端应用的部署更加简单,我们直接在云服务器上下载 nginx 然后解压。打开网址 http://nginx.org/en/download.html ,点击下图中的链接下载即可。nginx 下载链接下载解压后,将前端页面直接放到 nginx/html 目录下即可。当然如果有很多网页,可以先在该目录下建立子目录便于归类网页。我们建立 shop-front 目录(表示商城系统的前端项目),然后将网页放入其中,效果如下:商城系统前端项目目录内容注意还需要修改 goods.html 中访问的后端 URL 地址,假设云服务器的公网 IP 为 x.x.x.x ,则修改为:实例:$.ajax({ type: "GET", url: "http://x.x.x.x:8080/goods", //后端接口地址 dataType: "json", contentType: "application/json; charset=utf-8", success: function (res) { $.each(res, function (i, v) { row = "<tr>"; row += "<td>" + v.id + "</td>"; row += "<td>" + v.name + "</td>"; row += "<td>" + v.price + "</td>"; row += "<td>" + v.pic + "</td>"; row += "</tr>"; $("#goodsTable").append(row); }); }, error: function (err) { console.log(err); } });此处解释下后端地址 http://x.x.x.x:8080/goods , HTTP 代表协议, x.x.x.x 代表云服务器公网地址, 8080 是我们后端项目的启动端口,由于我们没有在配置文件中设置,所以默认就是 8080 ,最后 goods 是控制器中设定的后端接口路径。双击 nginx.exe 启动 nginx ,由于 nginx 默认启动端口是 80 ,所以此时访问 http://x.x.x.x ,效果如下,说明 nginx 启动成功!nginx 已启动成功

3.2 爬取客户端渲染的网页

在互联网早期,网站的内容都是一些简单的、静态的页面,服务器后端生成网页内容,然后返回给浏览器,浏览器获取 html 文件之后就可以直接解析展示了,这种生成 HTML 文件的方式被称为服务器端渲染。而随着前端页面的复杂性提高,出现了基于 ajax 技术的前后端分离的开发模式,即后端不提供完整的 html 页面,而是提供一些 api 返回 json 格式的数据,前端调用后端的 API 获取 json 数据,在前端进行 html 页面的拼接,最后后展示在浏览器上,这种生成 HTML 文件的方式被称为客户端渲染。简单的使用 requests 库无法爬取客户端渲染的页面:requests 爬下来的页面内容并不包含真正的数据只能通过调用后端的 API 才能获取页面的数据有两种方式爬取客户端渲染的网页:分析网页的调用后端 API 的接口这种方法需要分析网站的 JavaScript 逻辑,找到调用后端 API 的的代码,分析 API 的相关参数。分析后再用爬虫模拟模拟调用后端 API,从而获取真正的数据。很多情况下,后端 API 的接口接口带着加密参数,有可能花很长时间也无法破解,从而无法调用后端 API。用模拟浏览器的方式来爬取数据在无法解析后端 API 的调用方式的情况下,有一种简单粗暴的方法:直接用模拟浏览器的方式来爬取,比如用 Selenium、Splash 等库模拟浏览器浏览网页,这样爬取到的网页内容包含有真实的数据。这种方法绕过分析 JavaScript 代码逻辑的过程,大大降低了难度。

2. 如何获取响应内容

要获取响应内容,当然是 XMLHttpRequest 对象下的几大法器:responseText 、 responseXML 和 response。其中:responseText: 一个 DomString,返回一个纯文本的值。 当该值为 “” 的时候,表示这个请求还没有开始 send();当该值为 null 的时候,表示请求失败。responseXML: 处理 XML 响应。返回一个包含请求检索的 HTML 和 XML 的 Document。 当请求还没有 send(),或者失败了,甚至是解析失败的时候,该值为 null 。当 responseType 不是 “” 或者 "document"的时候,会报错。response: 返回响应正文。返回类型可以有 DOMString、 Blob 、ArrayBuffer 、Document 或 JavaScript Object ,这取决于 responseType。了解获取响应内容的这 3 个属性,接下来,我们会分别返回 DomString、XML 和 Json 类型数据来展示着响应内容。核心响应代码:xhr.onreadystatechange = function() { if (this.readyState == 4) { if (this.status === 200 || this.status === 304) { var res = this.response var resText = this.responseText var resXml = this.responseXML console.log(res, resText, resXml) // 分别打印三者 } }};2.1 返回 DomString服务端返回内容:‘text’请求结果:Content-type:可以看到,当返回的是一个 DomString 的时候,responseText 和 response 都有值,而 responseXML 因为解析失败为 null。2.2 返回 XML服务端返回内容:<data>Hello World</data>请求结果:Content-type:这一次我们的 XML 正常解析了,并且在控制台上可以看到打印出了一个 Document,而 response 和 responseText 分别打印了该 XML 的文本形式。2.3 返回 Json服务端返回内容:{a:1}请求结果:Content-type:当返回的是一个 Json 类型数据的时候,response 和 responseText 分别为对应的文本值,而 responseXML 因为解析失败成了 null。以上展示了 Ajax 获取服务端响应的三种类型的数据,简单的展示给大家 XMLHttpRequest 的 response、responseText 和 responseXML 在不同数据类型下的表现,希望以此能够加深大家对 XMLHttpRequest 的了解。

2.fetch

fetch 面世以来,一直都被称为是 Ajax 的替代方案。作为一个底层的 API 而言,我们将它和 XMLHttpRequest 来进行比较。相信使用过 XMLHttpRequst 的同学,在惊叹它赋予的前后端交互方式的同时,也无不会诟病它丑陋的代码组织方式。举个例子来说明,假设我们要往后端发送一段 GET 请求,使用 XMLHttpRequest 我们会这样做:var xhr = new XMLHttpRequest();xhr.open('GET', url);xhr.responseType = 'text';xhr.onreadystatechange = function() { if (this.readyState == 4) { if (this.status === 200 || this.status === 304) { // code ... } }};xhr.onerror = function() { console.log("Oops, error");};xhr.send();这代码的组织简直是丑陋,写起来也非常的麻烦、松散。而 fetch 在这方面的表现就不一样了。Fetch API 是基于 promise 进行设计的,写法上也更加的方便和简单,更为符合关注点分离的原则,不会将所有的配置和状态混淆在一个对象里。 接下来我们来看看使用 fetch 的写法:// 写法一:fetch(url) .then(response => { if (response.ok) { return response.json(); } }) .then(data => {// code...}) .catch(err => {// code...}) // 写法二:const fetchSend = async (url) => { try { const response = await fetch(url); if (response.ok) { return response.json() } } catch(e) { // code ... }}fetchSend(url)感觉瞬间优雅了许多有木有!使用 promise 写法,我们的整个代码组织变得更加整洁有条理性。而方法二使用 async/await 结合 fetch 的编码形式,让我们能够以同步的方式来书写代码,体验更佳。总结起来:代码组织简单干净,更具语义性。可以结合 async/await 书写,体验更佳。然而,fetch 在其他方面表现并不是都很完美。比如:原生支持率不佳,兼容性差。只对网络请求报错,对于诸如 400 和 500 之类的错误,并不会走 reject 分支。不支持 abort 和 超时控制。无法检测请求进度。不得不说, fetch 还需要多多努力呀。

1. 构造 xhr 对象

首先,我们需要构造一个 xhr 对象。具体方法就是通过 new 来实例化一个 XMLHttpRequest 实例。const xhr = new XMLHttpRequest();问题来了,我们知道早期浏览器如 IE5、IE6 并没有直接支持 XMLHttpRequest,如果我们直接使用 XMLHttpRequest 构造函数,很大可能在早期浏览器我们会得到一个未定义的报错。因此,我们需要通过一定兼容性的写法来解决这个问题。var xhr;if (window.XMLHttpRequest) { // 如果存在 XMLHttpRequest,就直接使用 XMLHttpRequest xhr = new XMLHttpRequest();} else if (window.ActiveXObject) { // IE // 如果不存在 XMLHttpRequest,但存在 ActiveXObject,则考虑 ActiveXObject 的情况 // XMLHttp 版本 var versions = [ "Msxml2.XMLHttp.5.0", "Msxml2.XMLHttp.4.0", "Msxml2.XMLHttp.3.0", "Msxml2.XMLHttp", "Microsoft.XMLHttp" ]; // 通过 for 循环尝试生成某个 XMLHttp 版本的 ActiveXObject 实例 // try...catch.. 捕获并消化掉 ActiveXObject 实例化失败的错误 try { for (var i = 0; i < versions.length; i++) { xhr = new ActiveXObject(versions[i]); break; } } catch (error) {}}if (!xhr) { alert("当前环境不支持初始化Ajax对象");}ActiveXObject 属于微软的私有拓展对象,只有在 IE 上才会有支持。该对象只能用于实例化自动化对象。 在我们上面的代码实现中,实例化一个 ActiveXObject 我们会传入参数 Msxml2.XMLHTTP 或者 Microsoft.XMLHTTP 等,该参数代表着提供对象的应用程序的名称。其中,Msxml2.XMLHttp.5.0、Msxml2.XMLHttp.4.0、Msxml2.XMLHttp.3.0、Msxml2.XMLHttp 和 Microsoft.XMLHttp分别代表着 XMLHttp 的高低版本。我们通过 for 循环,在采用正常有效版本的时候跳出循环。同时,使用 try…catch… 来捕获消化不支持情况下的报错。当然,我们也可以包装我们的 xhr 对象,比如,我们可以通过执行匿名函数:var xhr = (function() { var hr; // 定义一个局部 xhr 对象, 这里命名 hr if (window.XMLHttpRequest) { hr = new XMLHttpRequest(); } else if (window.ActiveXObject) { // IE var versions = [ "Msxml2.XMLHttp.5.0", "Msxml2.XMLHttp.4.0", "Msxml2.XMLHttp.3.0", "Msxml2.XMLHttp", "Microsoft.XMLHttp" ]; try { for (var i = 0; i < versions.length; i++) { hr = new ActiveXObject(versions[i]); break; } } catch (error) {} } return hr; // 返回我们最后的 xhr 对象,如果宿主环境不提供 XMLHttpRequest 及 ActiveXObject, 返回 undefined})();

1. 前端框架改变了什么

随着 AJAX 的普及以及浏览器性能的提升,前端的交互越来越复杂,前端工程师的工作职责也在变广。其中最容易让代码变得复杂的业务逻辑就是 DOM 操作。在没有任何框架的情况下,给一个按钮切换文案可能是这样的:var btn = document.querySelector('.btn');btn.addEventListener('click', function() { var txt = btn.innerText; if (txt === '开') { btn.innerText = '关'; } else { btn. innerText = '开'; }});如果要往里面插入各种逻辑,如发起请求,请求后对应界面上的某个 DOM 的复杂改变,代码就会变得越来越难维护。如果有维护过老项目,对这方面的印象会更深刻。老项目可能会充斥着各种字符串拼接 HTML,代码可读性差,逻辑难以被后人扩充维护,小模块的重构又怕影响到项目根基,这些问题会随着时间慢慢暴露出来。再就是花了太多时间在 DOM 操作上,为了取某个父级会经历多次 .parentNode,导致经常要去数数等这些问题。不管是性能还是可维护性,总归来讲就是在 DOM 操作上吃了太多亏,这一点也是出现这些前端框架的出要原因:UI 与 数据的同步太费事儿。对于新人,刚学习前端框架感到最震撼的点通常都是框架对 DOM 操作的解放,以 Vue2.x 为例:<template> <button @click="toggle"> {{ text }} </button></template><script> export default { data() { return { text: '开', } }, method: { toggle() { this.text = (this.text === '开') ? '关' : '开'; }, }, };</script>以数据来驱动视图,特别是在列表渲染上,这个特性的优点就能被放的很大,其具体实现原理可以学习对应框架的底层细节。所以前端框架带来的最大改变,就是解放了大量的操作 DOM 的工作,让开发者更注重逻辑上的表现。其他的改变,还有组件化、工程化等,具体开发就能体会到。

3. 视频分片上传

视频分片上传这个会稍微有点复杂,我们页尽量简单做一下,尽量不考虑异常情况,细节等后面大家自己慢慢优化。对于大文件上传,往往采用的方式是将大文件切片,然后分片上传,最后全部分片上传完毕后发送合并请求,将服务器上的分片文件合成最终的文件。这个需求需要前后端一同配合操作,前端有许多线程的组件供我们使用,由于我们用的是纯 html/css/js 开发前端页面,所以直接用 Baidu WebFE(FEX) 团队开发的 WebUploader 来帮助我们完成前端的分片上传工作。对于 Django 的后端上传视频的思路如下:首先确定好一个固定上传根目录 UPLOAD_BASE_DIR (如/root/test/video_website);上传的分片会按照如下命名方式保存到临时目录 (${UPLOAD_BASE_DIR}/tmpfiles/) 下:文件名-块编号-总块数如果是共享文件保存到共享目录 (KaTeX parse error: Expected 'EOF', got '下' at position 28: …_DIR}/shared/) 下̲,私密文件保存到个人的目录 ({UPLOAD_BASE_DIR}/用户名/) 下视频上传的代码主要在 videos 应用下,先看视图代码,如下:# 代码位置:videos/views.pyimport osimport shutilfrom django.shortcuts import render, redirectfrom django.views.generic import Viewfrom django.views.decorators.http import require_http_methodsfrom django.http.response import JsonResponsefrom django.contrib.auth.models import Userfrom videos.models import Videofrom utils.constants import LOGIN_URL, UPLOAD_BASE_DIRTMP_DIR = os.path.join(UPLOAD_BASE_DIR, "tmpfiles")SHARED_DIR = os.path.join(UPLOAD_BASE_DIR, "shared")if not os.path.exists(TMP_DIR): os.makedirs(TMP_DIR)if not os.path.exists(SHARED_DIR): os.makedirs(SHARED_DIR)"""将部分操作加上装饰器,需要登录才能进行操作""" class VideoView(View): """ 视频管理 """ def get(self, request, *args, **kwargs): pass def post(self, request, *args, **kwargs): """ 新增上传视频 """ success = True err_msg = '' name = request.POST.get('name', '') label = request.POST.get('label', '') size = int(request.POST.get('size', '0')) is_private = request.POST.get('is_private', 'false') shared_type = 0 if is_private != 'true' else 1 logined_user = None if request.session.get('has_login', False): logined_user = User.objects.all().get(id=int(request.session['user_id'])) if not logined_user or not isinstance(logined_user, User): return JsonResponse({'success': False, 'err_msg': 'please login in first!'}) print('登录用户:{}'.format(logined_user.username)) if not name: return JsonResponse({'success': False, 'err_msg': 'name is empty!'}) file_path = os.path.join(UPLOAD_BASE_DIR, name) if not os.path.exists(file_path): return JsonResponse({'success': False, 'err_msg': '{} not upload succeeded!'.format(name)}) # 共享视频放到 share 目录下,其余放到各自用户下 old_path = os.path.join(UPLOAD_BASE_DIR, name) if not shared_type: new_dir = SHARED_DIR path = "/shared" else: # 私密视频,放到个人目录下 username = logined_user.username new_dir = os.path.join(UPLOAD_BASE_DIR, username) path = "/{}".format(username) if not os.path.isdir(new_dir): os.makedirs(new_dir) print('移动文件{}到目录{}下'.format(old_path, new_dir)) shutil.move(old_path, new_dir) video_upload = Video(name=name, label=label, size=size, shared_type=shared_type, path=path) video_upload.author = logined_user try: video_upload.save() except Exception as e: success = False err_msg = 'error: {}'.format(str(e)) return JsonResponse({'success': success, 'err_msg': err_msg}) def put(self, request, *args, **kwargs): pass def delete(self, request, *args, **kwargs): passdef video_upload(request, *args, **kwargs): """ 分片上传视频 """ if request.method == 'POST': # 异常考虑 name = request.POST.get("name") chunk_id = request.POST.get("chunk", "0") chunks = request.POST.get("chunks", "0") file_name = "%s-%s-%s" % (name, chunk_id, chunks) video_file = request.FILES.get("file") with open(os.path.join(TMP_DIR, file_name), 'wb') as f: for chunk in video_file.chunks(): f.write(chunk) return JsonResponse({'upload_part': True}) return render(request, "video_upload.html", {})@require_http_methods(["POST"])def merge_chunks(request, *args, **kwargs): """ 合并上传视频 """ file_name = request.POST.get("name") chunks = int(request.POST.get("chunks", "0")) # 完成的文件的地址为 path = os.path.join(UPLOAD_BASE_DIR, file_name) with open(path, 'wb') as fp: for chunk in range(chunks): try: name = os.path.join(TMP_DIR, '{}-{}-{}'.format(file_name, chunk, chunks)) with open(name, 'rb') as f: fp.write(f.read()) # 当图片写入完成后,分片就没有意义了,删除 os.remove(name) except Exception as e: print('异常:{}'.format(str(e))) break return JsonResponse({'merge':True, 'file_name': file_name})代码的逻辑是比较清楚的,主要的完成了如下几个功能:分片视频上传 (video_upload);合并分片视频 (merge_chunks);上传视频信息入库 (VideoView.post);接着是 URLConf 的配置,代码如下:# 代码位置:videos/urls.pyfrom django.urls import pathfrom videos import viewsurlpatterns = [ # 视频的管理 path('op/', views.VideoView.as_view(), name="video_operation"), # 视频上传 path('upload/', views.video_upload, name="upload"), path('video_merge/', views.merge_chunks, name='merge_chunks'),]最后,看下我们使用 WebUploader 和 Bootstrap 功能完成的一个分片上传页面,内容稍多,需要耐心阅读。首先要先完成视频上传,然后才是添加视频的描述信息并提交。{# 代码位置:template/video_upload.html #}{% load staticfiles %}<!DOCTYPE html><html><head><meta charset="UTF-8"><title>webuploader上传</title><link rel="stylesheet" type="text/css" href="{% static 'css/main.css' %}"><link rel="stylesheet" type="text/css" href="{% static 'css/webuploader.css' %}"><link rel="stylesheet" type="text/css" href="{% static 'css/bootstrap.min.css' %}"><script type="text/javascript" src="{% static 'js/jquery-3.5.0.min.js' %}"></script><script type="text/javascript" src="{% static 'js/webuploader.min.js' %}"></script></head><body><div class="row"> <div class="col-md-6"> <form class="form-horizontal upload-video-container" class="col-sm-6"> {% csrf_token %} <div class="form-group"> <label class="col-sm-4 control-label">视频名称</label> <div class="col-sm-8"> <input type="text" class="form-control" id="video-name" placeholder="视频名称" name="video_name"> </div> </div> <div class="form-group"> <label class="col-sm-4 control-label">视频简介</label> <div class="col-sm-8"> <textarea class="form-control" rows="5" name="video_label"></textarea> </div> </div> <div class="form-group"> <label for="inputPassword3" class="col-sm-4 control-label">上传视频</label> <div class="col-sm-8"> <div id="picker">点击这里选择视频</div> </div> </div> <div class="form-group"> <div class="col-sm-offset-4 col-sm-8"> <div class="checkbox"> <label style="font-size:14px"> <input type="checkbox" name="is_private"> 设为私密 </label> </div> </div> </div> <div class="form-group"> <div class="col-sm-offset-4 col-sm-8"> <button id="form-submit" class="btn btn-primary" type = "button">提交</button> </div> </div> </form> </div> <div id="uploader" class="col-md-5 upload-video-container"> <!--用来存放文件信息--> <div id="thelist" class="row"> <div class="panel panel-primary"> <div class="panel-heading">视频文件上传</div> <table class="table table-striped table-bordered" id="uploadTable"> <thead style="text-align: center;"> <tr> <th>文件名称</th> <th>文件大小</th> <th>上传进度</th> <th style="width:15%;">状态</th> </tr> </thead> <tbody> </tbody> </table> <div class="panel-footer"> <button id="upload-btn" class="btn btn-primary">开始上传</button> </div> </div> </div> </div></div></body><script type="text/javascript"> success = false current_upload_file = '' $('#form-submit').on('click', function(){ if (current_upload_file !== null && current_upload_file !== undefined && current_upload_file !== '' && success){ csrf_token = $("input[name='csrfmiddlewaretoken']").val() name = $("input[name='video_name']").val() label = $("textarea").val() is_private = $("input[name='is_private']").is(':checked') $.ajax({ type: "POST", url: "{% url 'video_operation'%}", data: { csrfmiddlewaretoken: csrf_token, name: name, label: label, size: current_upload_file.size, is_private: is_private }, success : function(response) { console.log(response) if (response.success) { alert('提交视频记录完成') } else { alert(response.err_msg) } } }); } else { alert('请先上传完成文件') return 0 } }) function formatSizeUnits(bytes){ if (bytes >= 1073741824) { bytes = (bytes / 1073741824).toFixed(2) + " GB"; } else if (bytes >= 1048576) { bytes = (bytes / 1048576).toFixed(2) + " MB"; } else if (bytes >= 1024) { bytes = (bytes / 1024).toFixed(2) + " KB"; } else if (bytes > 1) { bytes = bytes + " bytes"; } else if (bytes == 1) { bytes = bytes + " byte"; } else { bytes = "0 bytes"; } return bytes; } var uploader = WebUploader.create({ // swf文件路径 swf : 'https://cdnjs.cloudflare.com/ajax/libs/webuploader/0.1.1/Uploader.swf', // 文件接收服务端。 server : "{% url 'upload' %}", // 选择文件的按钮。可选。 // 内部根据当前运行是创建,可能是input元素,也可能是flash. pick : { id : '#picker',//这个id是你要点击上传文件的id multiple : false }, // 不压缩image, 默认如果是jpeg,文件上传前会压缩一把再上传! resize : true, auto : false, //开启分片上传 chunked : true, chunkSize : 10 * 1024 * 1024, accept : { extensions : "flv,mp4", mimeTypes : '.flv,.mp4' } }); uploader.on('fileQueued', function(file) { current_upload_file = file // 选中文件时要做的事情,比如在页面中显示选中的文件并添加到文件列表,获取文件的大小,文件类型等 name = file.name size = file.size $('#video-name').val(name) file_upload_html = "<tr><td>" + name + "</td><td>" + formatSizeUnits(size) + "</td><td>0%</td><td><a>准备上传</a></td>" $('#uploader table tbody').html(file_upload_html) $("#upload-btn").removeAttr("disabled") }); uploader.on('uploadBeforeSend',function (object, data, header){ data['csrfmiddlewaretoken'] = $("input[name='csrfmiddlewaretoken']").val() }); // 文件上传过程中创建进度条实时显示。 uploader.on('uploadProgress', function(file, percentage) { $('#thelist').find('tbody').find('tr:eq(0)').find("td:eq(3)").text('上传中') $('#thelist').find('tbody').find('tr:eq(0)').find("td:eq(2)").text((percentage * 100).toFixed(2) + '%') }); uploader.on('uploadSuccess', function(file) { console.log('上传成功') }); uploader.on('uploadError', function(file) { $('#thelist').find('tbody').find('tr:eq(0)').find("td:eq(2)").text('上传失败') }); uploader.on('uploadComplete', function(file) { $('#thelist').find('tbody').find('tr:eq(0)').find("td:eq(3)").text('合并文件中...') csrf_token = $("input[name='csrfmiddlewaretoken']").val() $.ajax({ type: "POST", url: "{% url 'merge_chunks'%}", data: { csrfmiddlewaretoken: csrf_token, name: file.name, chunks: parseInt((file.size + uploader.options.chunkSize - 1) / uploader.options.chunkSize) }, success : function(response) { success = true uploader.removeFile(file); $('#thelist').find('tbody').find('tr:eq(0)').find("td:eq(3)").text('上传完成') $("#upload-btn").attr("disabled", "disabled") } }); }); uploader.on('all', function(type) { console.log('all, type=' + type) }); $('#upload-btn').on('click', function(){ uploader.upload(); }); </script></html>注意:这里的前端代码有许多细节没有考虑,比如错误情况,以及实现暂停上传和查询已上传分片等功能,后续读者可以自行优化。这里的前端代码参考了官方文档和一些 CSDN 博客介绍,用比较简单的方式去完成这个分片上传。主要是上传组件监听的事件以及 jquery 的使用。这里细节不在深究,我们直接看演示的效果。插入视频 35-2

2. xpath 解析实战

lxml 是 Python 中的一个解析库,支持 HTML 和 XML 的解析,支持 XPath 解析方式,而且解析效率非常高。本节将安装该模块解析 html 文本并提取相应的数据。[store@server2 ~]$ sudo pip3 install lxmlWARNING: Running pip install with root privileges is generally not a good idea. Try `pip3 install --user` instead.Collecting lxml Downloading http://mirrors.cloud.aliyuncs.com/pypi/packages/55/6f/c87dffdd88a54dd26a3a9fef1d14b6384a9933c455c54ce3ca7d64a84c88/lxml-4.5.1-cp36-cp36m-manylinux1_x86_64.whl (5.5MB) 100% |████████████████████████████████| 5.5MB 82.9MB/s Installing collected packages: lxmlSuccessfully installed lxml-4.5.1我们先准备好素材,也就是要解析的 HTML 文档。为了更有代入感,我直接使用慕课网 wiki 页面的数据进行操作,获取数据的方式如下图所示:获取慕课网 wiki 页面的 HTML 数据最后保存到一个 test.html 文本,然后我们要准备一段 Python 代码:from lxml import etreetree = etree.parse('test.html', etree.HTMLParser(encoding='utf8'))def print_result(exp, results): print('xpath表达式为:{},其匹配结果为:'.format(exp)) for res in results: print(res.strip()) print('')def test_xpath_expression(exp): results = tree.xpath(exp) print_result(exp, results)将这个 Python 文件命名为 test_xpath.py 和 test.html 放在同一级目录下:[store@server2 ~]$ lsshen test.html test_xpath.py接下来我们就可以进行激动人心的测试了,来完成一个简单的实验:慕课网 wiki 页面数据获取第一个实验的目标就是拿到 javascript 分类下的教程的三个数据:标题、总节数以及访问次数。通过 F12 查看相关的 HTML 结构,我们可以通过如下的 Xpath表达式获取相应的数据:Python 3.6.8 (default, Apr 2 2020, 13:34:55) [GCC 4.8.5 20150623 (Red Hat 4.8.5-39)] on linuxType "help", "copyright", "credits" or "license" for more information.>>> from test_xpath import test_xpath_expression>>> exp1 = '//h2[@class="language-title"]/text()'>>> test_xpath_expression(exp1)xpath表达式为://h2[@class="language-title"]/text(),其匹配结果为:JavaScriptHTML & CSS服务器开发工具其他后端语言基础应用框架应用基础应用Python Web 开发MySQL接下来看一看元素的结构:javascript 专栏的节点结构可以看到 javascript 专栏标题是 h2 节点,这个节点同级下有一个 div,它下面的四个 div 节点正是那四个专栏。我们首先匹配下这四个专栏元素:>>> exp1 = '//h2[contains(text(), "JavaScript")]/following-sibling::div/div[@class="course-card"]'>>> test_xpath_expression(exp1)xpath表达式为://h2[contains(text(), "JavaScript")]/following-sibling::div/div[@class="course-card"],其匹配结果为:<Element div at 0x7f7015bf8808><Element div at 0x7f700c656788><Element div at 0x7f700c6567c8><Element div at 0x7f700c656808>那么我们来进一步分析每个 div 内部如何得到教程标题、总节数以及访问次数这些数据:获取教程数据可以看到,在前面找到 div 节点的基础上在往下两层,找到 class 属性值为 text 的 div 节点,所有的数据都在这个节点中:标题:上面找到的 div 节点下的第一个 a 节点的文本值;教程总节数:上面找到的 div 节点下的第一个 p 节点下第一个 span 元素的文本值;总访问次数:上面找到的 div 节点下的第一个 p 节点下第二个 span 元素的文本值;这样我们就能进行写出提取相应数据的 Xpath 路径表达式了,测试如下:>>> exp1 = '//h2[contains(text(), "JavaScript")]/following-sibling::div/div[@class="course-card"]/child::div/div[@class="text"]/a[1]/text()'>>> test_xpath_expression(exp1)xpath表达式为://h2[contains(text(), "JavaScript")]/following-sibling::div/div[@class="course-card"]/child::div/div[@class="text"]/a[1]/text(),其匹配结果为:Javascript 入门教程TypeScript 入门教程Vue 入门教程Ajax 入门教程>>> exp2 = '//h2[contains(text(), "JavaScript")]/following-sibling::div/div[@class="course-card"]/child::div/div[@class="text"]/p/span[1]/text()'>>> test_xpath_expression(exp2)xpath表达式为://h2[contains(text(), "JavaScript")]/following-sibling::div/div[@class="course-card"]/child::div/div[@class="text"]/p/span[1]/text(),其匹配结果为:56小节38小节39小节9小节>>> exp3 = '//h2[contains(text(), "JavaScript")]/following-sibling::div/div[@class="course-card"]/child::div/div[@class="text"]/p/span[2]/text()'>>> test_xpath_expression(exp3)xpath表达式为://h2[contains(text(), "JavaScript")]/following-sibling::div/div[@class="course-card"]/child::div/div[@class="text"]/p/span[2]/text(),其匹配结果为:9832354736281800接下来我们整理下 Python 代码,将整个 wiki 页面上的教程都解析出来,并将数据整理成 json 格式。预期最后的结果应该是这样的:{ '前端开发': { 'JavaScript': [ {'title': 'JavaScript入门教程', 'total_chapters': 56, 'total_visited': 9001}, {...}, {...}, {...} ], 'HTML & CSS': [ ... ] } '服务端相关': { }, ...}这样的难度再次增加,其核心的获取数据的过程和上面一致。后面获取其他数据的结果过程不作分析,大家有兴趣仔细研究下代码,然后动手实操。话不多说,上代码:# 代码文件:test_xpath2.pyfrom lxml import etreedef get_direction_data(direction_tree): """ 获取一个方向下的课程数据 :return: """ direction_data = {} cards = direction_tree.xpath('.//div[@class="language-card"]') for card in cards: title = card.xpath('.//h2[@class="language-title"]/text()')[0] course_list = card.xpath('.//div[@class="course-card"]') courses = [] for course in course_list: course_title = course.xpath('.//div[@class="text"]/a[1]/text()')[0] course_total_chaps = course.xpath('.//div[@class="text"]/p/span[1]/text()')[0] course_total_visit_count = course.xpath('.//div[@class="text"]/p/span[2]/text()')[0] courses.append({ 'course_title': course_title.strip(), 'course_total_chaps': course_total_chaps.strip(), 'course_total_visit_count': int(course_total_visit_count.strip()) }) direction_data[title] = courses return direction_datadef get_all_data(): """ 解析慕课网wiki数据 :return: """ result = {} html = etree.parse('test.html', etree.HTMLParser(encoding='utf8')) directions = html.xpath('//div[@class="direction-con"]') for direction in directions: # 提取方向key,注意一定要有点号,表示从当前元素开始提取 direction_name = direction.xpath('./div[@class="title-con"][1]/text()') if direction_name: result[direction_name[0]] = get_direction_data(direction) return result运行的结果如下:[store@server2 ~]$ python3Python 3.6.8 (default, Apr 2 2020, 13:34:55) [GCC 4.8.5 20150623 (Red Hat 4.8.5-39)] on linuxType "help", "copyright", "credits" or "license" for more information.>>> from test_xpath2 import get_all_dat>>> get_all_data(){'前端开发': {'JavaScript': [{'course_title': 'Javascript 入门教程', 'course_total_chaps': '56小节', 'course_total_visit_count': 9832}, {'course_title': 'TypeScript 入门教程', 'course_total_chaps': '38小节', 'course_total_visit_count': 3547}, {'course_title': 'Vue 入门教程', 'course_total_chaps': '39小节', 'course_total_visit_count': 3628}, {'course_title': 'Ajax 入门教程', 'course_total_chaps': '9小节', 'course_total_visit_count': 1800}], 'HTML & CSS': [{'course_title': 'CSS3 入门教程', 'course_total_chaps': '32小节', 'course_total_visit_count': 1512}, {'course_title': 'Less 入门教程', 'course_total_chaps': '22小节', 'course_total_visit_count': 364}, {'course_title': '雪碧图入门教程', 'course_total_chaps': '24小节', 'course_total_visit_count': 915}]}, '服务端相关': {'服务器': [{'course_title': 'Nginx 入门教程', 'course_total_chaps': '24小节', 'course_total_visit_count': 4500}, {'course_title': 'HTTP 入门教程', 'course_total_chaps': '16小节', 'course_total_visit_count': 456}, {'course_title': 'Docker 入门教程', 'course_total_chaps': '25小节', 'course_total_visit_count': 1067}, {'course_title': 'Shell 入门教程', 'course_total_chaps': '17小节', 'course_total_visit_count': 2060}, {'course_title': 'Linux 入门教程', 'course_total_chaps': '25小节', 'course_total_visit_count': 1430}], '开发工具': [{'course_title': 'Gradle 入门教程', 'course_total_chaps': '12小节', 'course_total_visit_count': 1121}, {'course_title': 'Vim 入门教程', 'course_total_chaps': '14小节', 'course_total_visit_count': 1491}, {'course_title': 'RESTful 规范教程', 'course_total_chaps': '13小节', 'course_total_visit_count': 1316}, {'course_title': 'Markdown 入门教程', 'course_total_chaps': '31小节', 'course_total_visit_count': 733}, {'course_title': 'Maven 入门教程', 'course_total_chaps': '17小节', 'course_total_visit_count': 155}, {'course_title': 'GitHub 入门教程', 'course_total_chaps': '9小节', 'course_total_visit_count': 261}], '其他后端语言': [{'course_title': 'C 语言入门教程', 'course_total_chaps': '45小节', 'course_total_visit_count': 1933}, {'course_title': 'Go 入门教程', 'course_total_chaps': '36小节', 'course_total_visit_count': 691}, {'course_title': 'Ruby 入门教程', 'course_total_chaps': '26小节', 'course_total_visit_count': 410}]}, 'Java': {'基础应用': [{'course_title': 'Java 入门教程', 'course_total_chaps': '39小节', 'course_total_visit_count': 5229}, {'course_title': 'Android 入门教程', 'course_total_chaps': '29小节', 'course_total_visit_count': 553}, {'course_title': '算法入门教程', 'course_total_chaps': '11小节', 'course_total_visit_count': 628}], '框架应用': [{'course_title': 'Spring Boot 入门教程', 'course_total_chaps': '25小节', 'course_total_visit_count': 4861}, {'course_title': 'Spring 入门教程', 'course_total_chaps': '21小节', 'course_total_visit_count': 850}, {'course_title': 'Hibernate 入门教程', 'course_total_chaps': '23小节', 'course_total_visit_count': 619}, {'course_title': 'MyBatis 入门教程', 'course_total_chaps': '23小节', 'course_total_visit_count': 895}]}, 'Python': {'基础应用': [{'course_title': 'Python 入门语法教程', 'course_total_chaps': '24小节', 'course_total_visit_count': 3617}, {'course_title': 'Python 原生爬虫教程', 'course_total_chaps': '19小节', 'course_total_visit_count': 2001}, {'course_title': 'Python 进阶应用教程', 'course_total_chaps': '29小节', 'course_total_visit_count': 726}], 'Python Web 开发': [{'course_title': 'Django 入门教程', 'course_total_chaps': '33小节', 'course_total_visit_count': 668}, {'course_title': 'NumPy 入门教程', 'course_total_chaps': '21小节', 'course_total_visit_count': 152}]}, '数据库': {'MySQL': [{'course_title': 'MySQL 入门教程', 'course_total_chaps': '32小节', 'course_total_visit_count': 3638}, {'course_title': 'SQL 入门教程', 'course_total_chaps': '47小节', 'course_total_visit_count': 2406}]}}是不是实现了预期效果?爬取网页,解析数据的过程和这个类似。掌握好今天的内容,你就已经掌握了爬虫的一个核心步骤。

4.1 CORS

首先展开一下 CORS 的全称:Cross-origin resource sharing意思是跨域资源共享,这是一个 W3C 标准,从字面意思来看不难理解,它允许浏览器向跨域的资源发送请求,并且获得结果数据。4.1.1 CORS 原理跨域资源共享标准新增了一组 HTTP 首部的字段,使得我们能够通过这些字段来跨域获取到我们所需要的资源。而要实现这一功能,我们需要前后端的配合,只有当后端实现了 CORS 功能,我们才能够通过浏览器直接访问资源。为此,我们先来看看接下来的几个首部字段:Access-Control-Allow-Origin :表示服务端允许的请求源的域,如果是 * 表示允许所有域访问,一般我们不建议使用 *;Access-Control-Allow-Headers: 表示预检测中,列出了将会在正式请求的 Access-Control-Request-Headers 字段中出现的首部信息;Access-Control-Allow-Methods: 表示服务端允许的请求方法;Access-Control-Allow-Credentials: 表示服务端是否允许发送cookie。当然前端也需要设置对应的 xhr.withCredentials 来进行配合;Access-Control-Expose-Headers: 列出了可以作为响应的一部分暴露在外的头部信息。其中,我们更为重要的当属 Access-Control-Allow-Origin 字段,因为这个字段直接关系到你是否能够跨域访问资源的权限了。通常情况下,为了解决跨域问题,后端同学会设置 Access-Control-Allow-Origin 指定为我们的请求源的域,而前端代码基本无感。4.1.2 简单请求和非简单请求关于 CORS ,HTTP 请求上会有一些小小区别,最直观的区别就是会不会触发多一次 OPTIONS 预检测请求。我们把一些不会触发预检测请求的请求,称为简单请求,而相反,会触发预检测的请求则是非简单请求。而关于如何区分简单请求和非简单请求,这里我就不再累赘,有兴趣的同学可以读一下 HTTP 控制访问 。在实际的工作过程中,使用到 CORS 来解决跨域限制是非常常见的,这里我们注意一下简单请求和非简单请求的直观区别即可,并在以后的工作中留意一下,而不至于懵逼于为什么多了一次 OPTIONS 请求。4.1.3 具体例子4.1.3.1 服务端核心代码// 全局设置请求过滤app.all('*',function (req, res, next) { res.header('Access-Control-Allow-Origin', 'http://localhost:8080'); // 设置 Access-Control-Allow-Origin res.header('Access-Control-Allow-Headers', 'Content-Type, Content-Length, Authorization, Accept, X-Requested-With'); // 设置 Access-Control-Allow-Headers res.header('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS'); // 设置 Access-Control-Allow-Methods next()});// 注册一个简单的路由router.get("/simple/get", function(req, res) { const {a} = req.query res.send(`参数值是${a}`)});后端要做的工作就是实现 CORS 功能。正如上方代码,我们规定了一系列 HTTP 请求头首部字段,使得 http://localhost:8080 这个域的前端脚本拥有向服务端发起请求并取得资源的权限。4.1.3.2 前端核心代码$.ajax({ url: 'http://localhost:8083/simple/get', method: 'GET', data : { a: 1 }}).done(data => { console.log(data)})4.1.3.3 效果可见,通过 CORS ,前端成功拿到了不同域的服务端的返回内容。4.1.4 CORS 小结CORS 是一个 W3C 的标准。使用 CORS ,我们可以使用使用常规的方式来解决前后端跨域访问的问题。并且,大多数的工作其实也是放在了服务端上,对于前端而言,基本上可以说是无感的。当然, CORS 也是存在着一些弊端。正因为它是 W3C 中一个比较新的方案,导致了各大浏览器引擎没有对其做严格规格的实现,由此可能产生一些不一致的情况。

2.4 控制提示框内容

tooltip 组件的核心作用是展示数据项相关的信息,提示内容可以通过 tooltip.formatter项进行配置。tooltip.formatter接受模板字符串、模板函数两种类型的值:2.4.1 使用模板字符串定制提示框内容模板字符串行人如 {a}: <br />{c}其中 {}为 echarts 提供的模板变量,不同图表所提供的变量集合不同,但通常有:a:表示系列名;b:表示数据名;c:表示数据值。更多信息可参考 官网解释。模板字符串支持传入 html 标签,这在 tooltip.renderModel = html时会被渲染为标准的 DOM 结构,例如:1325示例中设定提示框的格式为 'Data Item:<br /> {b0}: <strong>{c0}</strong>'渲染结果:Tips:模板字符串存在一些明显的缺陷:功能单一,只实现了变量替换功能,格式化时只能沿用 echarts 所提供的变量集合,不能做进一步计算,即使是很简单的百分比格式化也无法实现;变量的类型、数量、顺序与 tooltip 所在位置强相关,模板与图表强耦合,若实际应用中变更了图表类型,可能导致模板失效;变量名均为 a、b、c 等没有语义的字符,这在某种程度上增加了记忆强度;格式化文本在不同渲染模式下可能渲染出不同的效果,详见 2.5 控制提示框渲染方式 一节。模板字符串实现的非常鸡肋,无法承担较复杂的格式化需求,建议尽量使用模板函数方式。当图表上有多个数据序列,传入的变量名会出现有点变化,例如 :a0:a 代表系列名,0 代表系列下标,根据传入的系列数量还会有 a1a2等;b0:b 代表数据名,0 位系列下标;c0:c 代表数据值,0 代表系列下标。例如下例中:1326示例包含两个折线图,此时 tooltip.formatter为 'Data Item:<br /> {a0}: <strong>{c0}</strong> <br /> {a1}: <strong>{c1}</strong>'指定了 a0、a1 等变量,渲染结果:2.4.2 使用模板函数定制提示框内容tooltip.formatter还支持传入函数值,签名形如:(params: Object|Array, ticket: string, callback: (ticket: string, html: string)) => stringTips:模板函数需返回字符串值,与模板字符串相似,若渲染模式 tooltip.renderMode = html则字符串中支持 html 标签。其中包含参数:params:上下文环境,包含提示框所在位置的关键信息ticket:异步回调令牌,若提示框内容需要以异步形式计算时,需使用令牌实现回调;callback:异步回调函数。params 形态不定,根据触发方式、图表类型会有些差异,但通常包含如下属性:{ componentType: 'series', // 系列类型,如 line、pie、bar seriesType: string, // 系列在传入的 option.series 中的 index seriesIndex: number, // 系列名称 seriesName: string, // 数据名,类目名 name: string, // 数据在传入的 data 数组中的 index dataIndex: number, // 传入的原始数据项 data: number|Array|Object, // 传入的数据值。在多数系列下它和 data 相同。在一些系列下是 data 中的分量(如 map、radar 中) value: number|Array|Object, // 坐标轴 encode 编码方式 encode: Object, // 维度名列表 dimensionNames: Array<String>, // 数据的维度 index,如 0 或 1 或 2 ... // 仅在雷达图中使用。 dimensionIndex: number, // 数据图形的颜色 color: string, // 饼图的百分比 percent: number,}示例:1327示例效果:Tips:params 参数的值与提示框所在位置强相关,建议开发时使用 debugger、console.dir 等手段进一步确认。模板函数支持异步形式,需要配合使用 ticket、callback 两个参数,在上例基础上,修改 tooltip 配置:formatter(params, ticket, cb) { // 执行异步操作 setTimeout(() => { // 异步操作完成后,需调用 cb 回调 // 传入 ticket 及提示内容字符串 cb(ticket, 'Async Success'); }, 1000); // 立即返回过渡态的提示内容 return 'Loading';},其中,ticket 为 ECharts 内部令牌,无需关注;callback 为异步回调函数,示例效果:Tips:模板函数在每次激活提示框时都会被触发,ECharts 没有对函数的执行做任何性能优化,这可能导致:如果模板函数的执行时间长,会导致页面卡顿,影响交互效果。如果模板函数包含了异步操作,比如调用 ajax 接口,则接口会随提示框的激活而多次被调用。

直播
查看课程详情
微信客服

购课补贴
联系客服咨询优惠详情

帮助反馈 APP下载

慕课网APP
您的移动学习伙伴

公众号

扫描二维码
关注慕课网微信公众号